- PVSM.RU - https://www.pvsm.ru -

Zone.js или как Dart спас Angular

Zone.js или как Dart спас Angular - 1

Я фронтенд-разработчик в компании Wrike, пишу на JavaScript и на Dart, который компилируется в JavaScript. Сегодня я хочу рассказать о библиотеке Zone.js, лежащей в основе Angular 2.

Изначально Zone.js была придумана разработчиками Google для языка программирования Dart и утилиты Dart2JS. С помощью этой библиотеки «гугловцы» решили проблему с дайджест-циклом, которая была характерна для первого Angular’а.
Чтобы понять, где эта библиотека используется и для чего нужна, прошу под кат.

Проблема

Если вы пишите на JavaScript или на языках, которые компилируются в JavaScript, то наверняка сталкивались с такой ситуацией:
работающий пример [1]:

var feedback = {
    message: 'Привет!',
    send: function () {
        alert(this.message)
    }
}

setTimeout(feedback.send)

Проблема известна давно — потерялся контекст. Поэтому во всплывающем сообщении мы увидим «undefined». Я знаю 4 способа выйти из положения:

На этом можно было бы и закончить, если бы не…

Корень зла

Рассмотренный нами пример — это верхушка айсберга, на самом деле проблема потери контекста более глобальна. Дело в том, что контекст в JavaScript теряется в месте вызова асинхронных функций, и чтобы его сохранить, необходимо всю цепочку вызовов постоянно держать в голове и контролировать. Это особенно сложно в больших проектах

Есть обратный эффект: из-за потери контекста теряется связь с местом, откуда вызывается асинхронная функция. Например, непонятно, в каком месте мы навесили обработчик клика, который вызвал ошибку. В консоли видна только ссылка на сам обработчик клика, но не показано, где он подписан.

Решение

Сколько способов вызовов функций асинхронно вы знаете? Мне на ум приходит setTimeout, addEventListener, асинхронные запросы к серверу и т.д. и т. п. Не так уж и много — количество этих мест конечно. Что это значит? Если предотвратить потерю контекста на каждый асинхронный вызов, проблема решится. Для начала давайте попробуем предотвратить потерю контекста в setTimeout:

Напишем класс с конструктором и тремя методами

class Context {
    constructor(parentContext) {
        let context;

        if (parentContext) {
            // Создаем копию
            context = Object.create(parentContext)
            context.parent = parentContext;
        } else {
            // Возвращаем текущий контекст
            context = this;
        }
        return context;
    }

    fork() {
        // Возвращаем копию
        return new Context(this);
    }

    bind(fn) {
        // Получаем текущий контекст
        const context = this.fork();
        // Возвращаем функцию в которой уже замкнут контекст
        return () => {
            return context.run(() => fn.apply(this, arguments), this, arguments);
        }
    }

    run(fn) {
        // Заменяем текущий контекст на наш
        let oldContext = context;
        context = this;
        const result = fn.call() // Выполняем функцию в контексте
        context = oldContext; // Возвращаем как было
        return result; // Результат выполнения
    }
}


После этого подменим исходный setTimeout:

context = new Context();

var nativeSetTimeout = window.setTimeout; // Подменяем setTimeout

context.setTimeout = (callback, time) => {
	callback = context.bind(callback);
	return nativeSetTimeout.call(window, callback.bind(context), time);
};

window.setTimeout = function (){
	return context.setTimeout.apply(this, arguments);
};

Теперь клиентский код:

context.fork({} /* пустой объект, чтобы склонировалось*/).run(() => {
	context.message = 'Привет!';
	setTimeout(() => {
		console.log(`%cСообщение в контексте: «${context.message}»`, 'font-size: x-large');
	}, 0);
});

console.log(`%cСообщение вне контекста: «${context.message}»`,'font-size: x-large');

Теперь связь сохраняется. Вот пример рабочего кода [6]. Используя ту же технику, можно заменить асинхронные вызовы для большинства случаев и управлять контекстом в ручном режиме предсказуемо.

А вот ниже наглядная иллюстрация работы. Каждая зона раскрашена в собственный цвет:

иллюстрация работы Zone.js

По сути, мы реализовали часть  библиотеки Zone.js. Думаю, что на этом можно остановиться и не писать свой велосипед, а продолжить изучение зон, используя оригинальную библиотеку.

Описание библиотеки

После подключения библиотеки в глобальной области видимости появляется объект Zone. Zone.current содержит ссылку на текущую зону. Метод fork объекта Zone возвращает новую зону на основе родительской. О том, какие параметры здесь возможны, лучше посмотреть в документации на github [7]. Метод run принимает функцию, тело которой выполнится в пределах этой зоны. Вот пример [8].

const childZone = Zone.current.fork({
	name: 'Дочерняя зона'
}); 

const handler = () => {
	alert(`Код запустился в зоне с именем «${Zone.current.name}»`);
}

childZone.run(handler);

handler();

Разработчики библиотеки выделяют три вида асинхронных задач:

  • Микротаски (MicroTasks) — задачи, которые выполняются сразу после завершения итерации лупа JavaScript-машины. Эти задачи нельзя отменить.
  • Макротаски (MacroTasks) — задачи, которые выполняются на раньше наступления определенного времени (setTimeout). Эти задачи отменяемы.
  • События (EventTasks) — задачи, которые выполняются по много раз, после наступления события, время задержки неизвестно.

Zone.js перехватывает попытку планирования асинхронных задач, выполнение обратных вызов, ошибки и прочее. Задачи планируются как явно, при помощи вызова специальных методов у объекта Zone.current, так и неявно, с помощью вызова асинхронной функции (setTimeout), как мы это делали в первой части статьи.

Зоны легко комбинировать: например, одна зона отлавливает ошибки в своих границах и отправляет нотификации на сервер, а дочерняя зона (потомок) выполняет функцию трекера и отправляет на сервер статистику работы пользователя в графическом интерфейсе. При этом, если в пределах дочерней зоны случится ошибка, то родительская зона перехватит и пошлет информацию на сервер. Вот пример комбинирования зон [9].

const errorHandlerZone = Zone.current.fork({
	name: 'ErrorZone',
  onHandleError: (parentZoneDelegate, currentZone, targetZone, error) => {
  	sendError(error);
    return false;
  }
 });
 
 const trackingZone = errorHandlerZone.fork({
 	name: 'TrackingZone'
 });
 
 class Widget {
 	render = () => {
  	throw 'render error';
  }
 }
 
 
 trackingZone.runGuarded(function(){
 	document.addEventListener('click', (event) => {
  	trackEvent(event);
  }, true);
  const widget = new Widget();
  widget.render();
  return this;
 });
 
 function sendError(error){
 	alert(`Ошибка: ${error} Название зоны: ${Zone.current.name}`);
 }
 
 function trackEvent(event){
 	alert(`Трекинг: ${event} Название зоны: ${Zone.current.name}`);
 }

Другие полезные примеры

Длинные трейсы

Первый и довольно типичный пример, о котором я писал выше, — ошибка в консоли. Упал обработчик клика по кнопке и все бы ничего, вот только непонятно, где он навешен. При помощи Zone.js мы можем это определить. Для этого используем специальную зону из репозитория Zone.js. Вот пример [10].

Профилирование

При помощи зон замеряем время работы кода, в котором содержатся асинхронные вызовы, исключая задержку не связанную с кодом: таймауты, ожидания ответа сервера и паузы между событиями. Пример [11].

Причем здесь Angular 2?

Зоны используются во втором Angular’е. Фреймворк понимает, что нужно запустить механизм поиска изменений, когда происходят асинхронные событие. О наступлении этого асинхронного события он узнает как раз от Zone.js.

Если представить все выше изложенное как код, то получится нечто подобное:

// Новая версия addEventListener
function addEventListener(eventName, callback) {
    // Вызов настоящего addEventListener
    callRealAddEventListener(eventName, function () {
        // Оригинальный обратный вызов
        callback(...);
        // Заускаем поиск изменений
        var changed = angular2.runChangeDetection();
        if (changed) {
            angular2.reRenderUIPart(); //  Отображаем изменения
        }
    });
}

Благодаря зонам мы знаем, в каком элементе произошло асинхронное событие. Остается понять, нужно ли рендерить изменения для дочерних элементов, и здесь есть отличительная особенность Angular 2. В первом Angular’е приходилось запускать дайджест-цикл, который много раз обходил дочерние и родительские элементы, чтобы проверить: изменилась модель или нет. Второй Angular проверяет на изменения однонаправленно.

Недостатки

Zone.js меняет стандартное поведение браузерного API (переопределять setTimeout [12] нехорошо). Это минус. При том, что манкипатчинг [13] выполнен аккуратно, мы использовали антипаттерн [14]. Появились дополнительные издержки при вызове базовых функций. Эти издержки малы, но они есть.

Манкипатчинг может привести к дополнительным багам в стандартных ситуациях. Хотя сам я ни разу не натыкался на эти баги, потенциально они возможны.

Еще у меня не получилось заставить работать зоны в приложении на React и Angular первой версии так, как я хотел.

На самом деле ничего не мешает использовать Zone.js как таковой, но вот сделать так, чтобы каждый компонент вызывался в отдельной зоне — проблематично. Для полноценной работы зон нужно, чтобы каждый кусок асинхронного кода (привязка событий, асинхронные запросы к серверу) вызывался в пределах зоны. Мне не удалось управлять этими процессами. Реакт использует виртуальный дом и умный рендеринг с кэшированием, а базовые события в Angular навешиваются в базовых директивах типа ngClick, переписывать которые утомительно. Есть шанс, что у вас это получится. Делитесь успехами в комментариях.

Вывод: Никогда не говори никогда

Zone.js — тот случай, когда манкипатчинг уместен. Те преимущества, которые дает библиотека, перекрывают недостатки подхода, а нарушение неписаных правил иногда приводит к победе.

Автор: Wrike

Источник [15]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/javascript/190096

Ссылки в тексте:

[1] работающий пример: https://jsfiddle.net/zolotyh/1cmpf0ov/

[2] замыкания: https://jsfiddle.net/zolotyh/jwjuLk4u/

[3] bind: https://jsfiddle.net/zolotyh/ypegy5v6/

[4] ES6 классы и стрелочные функции: https://jsfiddle.net/zolotyh/u4v3ja8f/

[5] двойное двоеточие: https://jsfiddle.net/zolotyh/wpath6dz/

[6] рабочего кода: https://jsfiddle.net/zolotyh/mm0tsh1n/

[7] документации на github: https://github.com/angular/zone.js/blob/master/dist/zone.js.d.ts

[8] пример: https://jsfiddle.net/zolotyh/4st0wtv7/

[9] пример комбинирования зон: https://jsfiddle.net/zolotyh/w7trLaL3/

[10] пример: https://jsfiddle.net/zolotyh/zkdzky2r/

[11] Пример: https://jsfiddle.net/zolotyh/7f404twp/

[12] переопределять setTimeout: #setTimeout

[13] манкипатчинг: https://ru.wikipedia.org/wiki/Monkey_patch

[14] антипаттерн: https://ru.wikipedia.org/wiki/%D0%90%D0%BD%D1%82%D0%B8%D0%BF%D0%B0%D1%82%D1%82%D0%B5%D1%80%D0%BD

[15] Источник: https://habrahabr.ru/post/310422/?utm_source=habrahabr&utm_medium=rss&utm_campaign=best