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

96f27d3813bd4e2a98f3c1a14a6e4fde.png

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

Проблема


Если вы пишите на JavaScript или на языках, которые компилируются в JavaScript, то наверняка сталкивались с такой ситуацией:
работающий пример
var feedback = {
    message: 'Привет!',
    send: function () {
        alert(this.message)
    }
}

setTimeout(feedback.send)

Проблема известна давно — потерялся контекст. Поэтому во всплывающем сообщении мы увидим «undefined»
Я знаю 4 способа выйти из положения:
  • Передать переменную «message», используя замыкания
  • Привязать контекст к функции, которую мы передаем в setTimeout, при помощи bind
  • Использовать ES6 классы и стрелочные функции
  • Использовать оператор двойное двоеточие

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

Корень зла


Рассмотренный нами пример — это верхушка айсберга, на самом деле проблема потери контекста более глобальна. Дело в том, что контекст в 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');

Теперь связь сохраняется.

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

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

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

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


После подключения библиотеки в глобальной области видимости появляется объект Zone. Zone.current содержит ссылку на текущую зону. Метод fork объекта Zone возвращает новую зону на основе родительской. О том, какие параметры здесь возможны, лучше посмотреть в документации на github. Метод run принимает функцию, тело которой выполнится в пределах этой зоны. Вот пример.
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), как мы это делали в первой части статьи.
Зоны легко комбинировать: например, одна зона отлавливает ошибки в своих границах и отправляет нотификации на сервер, а дочерняя зона (потомок) выполняет функцию трекера и отправляет на сервер статистику работы пользователя в графическом интерфейсе. При этом, если в пределах дочерней зоны случится ошибка, то родительская зона перехватит и пошлет информацию на сервер. Вот пример комбинирования зон.
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. Вот пример.

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


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

Причем здесь 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 нехорошо). Это минус. При том, что манкипатчинг выполнен аккуратно, мы использовали антипаттерн. Появились дополнительные издержки при вызове базовых функций. Эти издержки малы, но они есть.
Манкипатчинг может привести к дополнительным багам в стандартных ситуациях. Хотя сам я ни разу не натыкался на эти баги, потенциально они возможны.
Еще у меня не получилось заставить работать зоны в приложении на React и Angular первой версии так, как я хотел.
На самом деле ничего не мешает использовать Zone.js как таковой, но вот сделать так, чтобы каждый компонент вызывался в отдельной зоне — проблематично. Для полноценной работы зон нужно, чтобы каждый кусок асинхронного кода (привязка событий, асинхронные запросы к серверу) вызывался в пределах зоны. Мне не удалось управлять этими процессами. Реакт использует виртуальный дом и умный рендеринг с кэшированием, а базовые события в Angular навешиваются в базовых директивах типа ngClick, переписывать которые утомительно. Есть шанс, что у вас это получится. Делитесь успехами в комментариях.

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


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

Комментарии (0)

© Habrahabr.ru