Я фронтенд-разработчик в компании 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 способа выйти из положения:


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

Корень зла


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

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


  1. ilinsky
    20.09.2016 11:01
    +20

    Выход «из положения» номер 0:

    setTimeout(function() {
      feedback.send()
    })
    



    1. likejavascript
      20.09.2016 12:09
      -1

      setTimeout("feedback.send()");
      


      1. vlreshet
        20.09.2016 12:58
        +4

        Ой, код в строке — ну это уж как-то совсем плохо. Но работает, да.


  1. kirill89
    20.09.2016 11:19
    +1

    Поясните пожалуйста, как ваша реализация или Zone.js помогает решить проблему потери контекста? Вы в точности так же передаете в setTimeout некую функцию, которая впоследствии биндится на какой-то объект класса Context, но меня то интересует что бы её контекстом оставался feedback (из описания проблемы). Или я что то упустил?


    1. zolotyh
      20.09.2016 11:52

      Зона в данном случае выступает заменой контекста (в узком смысле). Для маленьких примеров такое применение кажется избыточным. Но что будет, если вместо обработчика setTimeout контекст нужно сохранять для нескольких модулей приложения и они при этом размещены в разных файлах? Моя мысль не в том, что вместо стандартных способов теперь нужно всегда использовать зоны, мысль в том, что если вы хотите хранить некое состояние в независимости от кол-ва асинхронных вызовов, зоны для этого хорошо подходят.


      1. kirill89
        20.09.2016 12:35

        В таком случае в статье потеряна связь между проблемой и решением. Судя по презентации Zone.js, его прежде всего применяют для отладки, профилирования и тестирования.


  1. k12th
    20.09.2016 11:27
    +2

    нарушение неписаных правил иногда приводит к победе

    Во-первых, проблемы с манкипатчингом и изменением встроенных объектов описаны давно и хорошо. Что, уже никто не помнит Prototype.js?
    Во-вторых, о победе пока речи нет, команда здорово всех отпугнула, ломая API на стадии RC, релиз вышел только что и пока непонятно, взлетит или нет.


    1. zolotyh
      20.09.2016 12:02

      Под победой я имею ввиду инженерную победу, ведь фактически от дайджест-цикла они избавились.
      Про Angular 2 говорить еще рано. В свое время с товарищем очень скептически отнеслись к Реакту. А он полетел, причем довольно высоко.
      По моим оценкам, Angular 2 получился удобным и гибким. Частые изменения тоже объяснимы. В остальном время покажет.


      1. k12th
        20.09.2016 12:15

        Дайджест-цикл изначально был провальным решением. И, главное, зачем было городить этот огород? Просто чтобы не писать model.set('key', value)? Так приходилось писать $timeout(() => this.key = value), вместо явного вызова грязный хак. Так что хорошо, что избавились, конечно.


        Частые изменение объяснимы, конечно, но народ, который пытался вскочить на этот поезд, был недоволен


        1. zolotyh
          20.09.2016 12:21
          +1

          Я сам на этом погорел. Несколько часов потратил на перевод на релизную версию. Но в итоге и правда стало лучше от изменений. И код почище. Но это субъективно все.


          1. k12th
            20.09.2016 12:24

            Еще раз: я не против изменений. Просто это создало некоторое негативное впечатление в сообществе.


            1. Valery4
              22.09.2016 21:13
              +1

              Мы с пониманием на работе отнеслись. Использование предрелизной версии — подразумевает приятие ответственности за это решение. Глупо обижаться на Гугл, что они меняют API до релиза и раутер переписали три раза с нуля. На то это и цикл разработки, зато результат действительно хороший, вследствие изменений. Лучше было до релизать порешать все эти вещи, чем потом мучаться несколько лет жизненного цикла фреймворка.


  1. justboris
    20.09.2016 13:15
    +2

    Удивительный аргумент про длинные стектрейсы.


    Во-первых, в длинном стектрейсе помимо важной информации содержится еще куча упоминаний самого zone.js. Не уверен что ковырять этот стек вызовов будет легче чем найти место подписки в чистом коде без зон.
    Во-вторых, exception в зоне проглатывается. А это плохо, потому что ломает стандартое поведение бразуера. (Например, аналитика ошибок без дополнительных костылей собираться не будет) Да еще и неудобно для разработчика потому что флаг "pause on uncaught exceptions" в бразузере перестает работать



    И это усложняет работу, потому что приходится дополнительно ловить кучу уже пойманных исколючений чтобы найти среди них свое.


    1. justboris
      20.09.2016 13:19
      +2

      Да и в целом удивительно.
      Команда React борется за то, чтобы исключения в пользовательском коде не терялись во внутренностях фреймворка, чтобы инструменты отладки в браузерах работали как надо.
      А в команде Angular и сопутствующих технологий пишут бразузер внутри браузера, с которым нужно научиться правильно работать, да еще и специальные иструменты для разработки использовать.


    1. zolotyh
      20.09.2016 13:31

      Весь кайф в том, что всем этим процессом можно управлять. Можно проглатывать исключения, а можно отправлять полные трейсы на сервер и при этом выбрасывать нативные исключения


      1. justboris
        20.09.2016 13:41
        +2

        А зачем мне управлять этим процессом, если меня устраивает стандартное браузерное поведение?


        Стектрейсы на сервер отправлять я и без зон могу, вот посмотреть бы на пример того, что могут только они.


        1. zolotyh
          20.09.2016 14:11

          Можете оставлять браузерные ошибки, если это нужно. Zone.js довольно гибкая. Она не навязывает вам использование своих трейсов, она предоставляет информацию. Как и когда эту информацию использовать решайте сами. Вот пример. Писал быстро, возможны ошибки в фильтрации, но в целом идея должна быть понятна.


          1. justboris
            20.09.2016 14:55

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


            1. zolotyh
              20.09.2016 15:30

              Я здесь не вижу большой проблемы. Да, чтобы управлять ошибками, нужно их сначала перехватить, а потом бросить опять. Лично мне это не мешает отлаживать.


              1. justboris
                20.09.2016 15:45
                +2

                Проблема в том, что браузер останавливается где-то внутри кода zone.js, а не вашем. Пользы от этого немного, ведь причины остановки не понять.


                Как я воспроизвожу баг пользователя:


                Открываю отладчик, ставлю флаг, чтобы останавливался на всех непойманных исключениях. Затем проделываю какие-то действия. Если случилось исключение, браузер останавливается на этой строке, сразу в контексте, со всеми переменными из замыкания. Можно смотреть значения и разбираться, что здесь не так.


                До этого у меня был опыт работы с Angular 1, где все исключения проглатыавались как и в zone. Там приходилось долго копать до настоящего места с ошибкой, и меня это всегда расстраивало.


                В общем, мой пойнт в том, что проглатывать исключения — это плохо. Плюсов, кроме абстрактного "зато все гибко", вы так и не привели, а неудобства налицо.


                1. zolotyh
                  20.09.2016 17:08

                  Вы так хотели отлаживать? У меня вроде как завелось.


                  1. justboris
                    20.09.2016 17:20

                    Имменно так, только со снятой галочкой "pause on caught exceptions".


                    Потому что если ее включить, то в консоль посыпется очень много лишнего


                1. zolotyh
                  20.09.2016 17:13

                  Вы взяли один кейс использования библиотеки, помимо трейсов у нее есть и другие применения. Цель статьи не в том, чтобы продать вам Zone.js или Angular 2. Цель статьи разобраться в том, как работает технология внутри.


  1. ggrnd0
    20.09.2016 14:00
    +1

    А я вот такой callback использую.


    function makeCallback(context, fn) {
        return { 
            context: context,
            fn: fn,
        };
    }
    
    function callCallback(callback) {
        var args = [].slice.call(arguments, 1);
        return callback.fn.apply(callback.context, args);
    }```
    
    Конечно тут нужно вручную контролировать контекст и все такое.
    Но тут и контекст доступен, и метод может использоваться независимо от него.


    1. kirill89
      20.09.2016 14:44

      А чем bind плох?


      1. janitor
        20.09.2016 14:52
        -3

         В IE6 не работает


      1. ggrnd0
        20.09.2016 19:55
        +1

        bind создает новую функцию и жестко связывает с контекстом — у такой функции уже нельзя подменить контекст методами call или apply


        Пример


        function f() {
          return this.value;
        }
        var bindedF = f.bind({value: 1});
        bindedF.call({value: 2});

        В консоль выведет 1


  1. NeLentyai
    20.09.2016 15:03
    +1

    Можно еще в функциях объекта использовать self или that, а сам объект при этом создавать через анонимный конструктор:

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

    Получается конечно не так компактно, но, имхо, лучше, чем использовать специальный объект для трекинга контекста…

    И конкретно в этом примере, раз уж объект через объявлен через var, то можно еще и так:
    var feedback = {
        message: 'Привет!',
        send: function () {
            alert(feedback.message)
        }
    }
    


  1. ZOXEXIVO
    20.09.2016 21:45
    +1

    Понятно, что удобно, но нельзя было ее форкнуть и воткнуть внутрь библиотеки, как они сделали с JQuery в первой версии и чтобы мы вообще не догадывались про ее существование?


  1. arusakov
    21.09.2016 03:13

    А в чем связь зон и отсутствие dirty checking-а? Зоны — это же просто прозрачная замена для всяких $timeout, $interval и тд. А то, что теперь есть change detector однонаправленный, это как бы про другое.


  1. zolotyh
    21.09.2016 11:36

    При помощи зон мы узнаем, в каком элементе произошло событие