[Перевод] Исследование Ivy — нового компилятора Angular

«Я думаю, что компиляторы — это очень интересно», — говорит Ури Шейкед, автор материала, перевод которого мы сегодня публикуем. В прошлом году он написал статью, в которой шла речь о реверс-инжиниринге компилятора Angular и об имитации некоторых этапов процесса компиляции, помогающей понять особенности внутреннего устройства этого механизма. Надо отметить, что обычно то, о чём автор этого материала говорит как о «компиляторе», называют «движком рендеринга».

Когда Ури услышал о том, что вышла новая версия компилятора Angular, названная Ivy, он тут же захотел рассмотреть её поближе и узнать, что в ней изменилось по сравнению со старой версией. Тут, так же, как и раньше, на вход компилятора поступают шаблоны и компоненты, созданные средствами Angular, которые преобразуются в обычный код на HTML и JavaScript, понятный Chrome и другим браузерам.

6ohafariunj4046cd6nokfypam0.jpeg

Если сравнить новую версию компилятора с предыдущей, то окажется, что Ivy использует алгоритм tree-shaking. Это означает, что компилятор автоматически удаляет неиспользуемые фрагменты кода (это относится и к коду Angular), уменьшая размер бандлов проектов. Ещё одно улучшение касается того, что теперь каждый файл компилируется самостоятельно, что уменьшает время повторной компиляции. Если в двух словах, то, благодаря новому компилятору мы получаем сборки меньших размеров, ускоренную повторную компиляцию проектов более простой готовый код.

Понимание того, как работает компилятор, интересно и само по себе (по крайней мере, автор материала на это надеется), но это, кроме того, помогает лучше понять внутренние механизмы Angular. Это ведёт к совершенствованию навыков «Angular-мышления», что, в свою очередь, позволяет более эффективно использовать этот фреймворк для веб-разработки.

Кстати, знаете, почему новый компилятор назвали Ivy? Дело в том, что это слово звучит как сочетание букв «IV», прочитанное вслух, которое представляет число 4, записанное римскими цифрами.»4» — это четвёртое поколение компиляторов Angular.

Применение Ivy


Ivy всё ещё находится в процессе интенсивной разработки, за этим процессом можно понаблюдать здесь. Хотя сам по себе компилятор ещё не подходит для применения в боевых условиях, абстракция RendererV3, которой он будет пользоваться, уже вполне функциональна и поставляется с Angular 6.x.

Хотя Ivy ещё и не вполне готов, мы, всё же, можем взглянуть на результаты его работы. Как это сделать? Создав новый Angular-проект:

ng new ivy-internals


После этого нужно включить Ivy, добавив следующие строки в файл tsconfig.json, расположенный в папке нового проекта:

"angularCompilerOptions": {
  "enableIvy": true
}


И, наконец, мы запускаем компилятор, выполняя команду ngc в только что созданной папке проекта:

node_modules/.bin/ngc


Вот и всё. Теперь можно исследовать сгенерированный код, находящийся в папке dist/out-tsc. Например, взглянем на следующий фрагмент шаблона AppComponent:

Welcome to {{ title }}!

Angular Logo


Here are some links to help you start:


Код, сгенерированный для этого шаблона, можно найти, заглянув в файл dist/out-tsc/src/app/app.component.js:

i0.ɵE(0, "div", _c0);
 i0.ɵE(1, "h1");
 i0.ɵT(2);
 i0.ɵe();
 i0.ɵE(3, "img", _c1);
 i0.ɵe();
 i0.ɵe();
 i0.ɵE(4, "h2");
 i0.ɵT(5, "Here are some links to help you start: ");
 i0.ɵe();


Именно в такой JavaScript-код Ivy преобразует шаблон компонента. Вот как то же самое делалось в предыдущей версии компилятора:

1f4d3a278708237e5558f313b316e780.png


Код, который выдаёт предыдущая версия компилятора Angular

Возникает такое ощущение, что код, который генерирует Ivy, оказывается гораздо проще. Можно поэкспериментировать с шаблоном компонента (он находится в src/app/app.component.html), снова его скомпилировать и посмотреть, как изменения, внесённые в него, отразятся на сгенерированном коде.

Разбор сгенерированного кода


Попытаемся разобрать сгенерированный код и посмотреть, какие именно действия он выполняет. Например, поищем ответ на вопрос о смысле вызовов вроде i0.ɵE и i0.ɵT.

Если взглянуть на начало сгенерированного файла, там мы найдём следующее выражение:

var i0 = require("@angular/core");


Таким образом, i0 — это всего лишь модуль ядра Angular, и всё это — функции, экспортируемые Angular. Буква ɵ используется командой разработчиков Angular для указания на то, что некоторые методы предназначены исключительно для обеспечения внутренних механизмов фреймворка, то есть, пользователи не должны вызывать их напрямую, так как неизменность API этих методов при выходе новых версий Angular не гарантируется (на самом деле, я бы сказал, что их API, практически гарантированно, изменятся).

Итак, все эти методы представляют собой приватные API, экспортируемые Angular. С их функционалом несложно разобраться, открыв проект в VS Code и проанализировав всплывающие подсказки:

3df800ffb5948fa113362e73f7d7b54c.png


Анализ кода в VS Code

Даже хотя тут анализируется JavaScript-файл, VS Code использует информацию о типах из TypeScript для того, чтобы выявить сигнатуру вызова и найти документацию для конкретного метода. Если, выделив имя метода, воспользоваться комбинацией Ctrl + щелчок мыши (Cmd + щелчок в Mac), мы узнаем, что настоящее имя этого метода — elementStart.

Эта методика позволила выяснить, что имя метода ɵT — text, имя метода ɵe — elementEnd. Вооружённые этим знанием, мы можем «перевести» сгенерированный код, превратив его в нечто такое, что удобнее будет читать. Вот небольшой фрагмент такого «перевода»:

var core = require("angular/core");
//...
core.elementStart(0, "div", _c0);
core.elementStart(1, "h1");
core.text(2);
core. ();
core.elementStart(3, "img", _c1);
core.elementEnd();
core.elementEnd();
core.elementStart(4, "h2");
core.text(5, "Here are some links to help you start: ");
core.elementEnd();


И, как уже было сказано, этот код соответствует следующему тексту из HTML-шаблона:

Welcome to {{ title }}!

Angular Logo


Here are some links to help you start:


Проанализировав это всё, несложно заметить следующее:

  • Каждому открывающему HTML-тегу соответствует вызов core.elementStart().
  • Закрывающим тегам соответствуют вызовы core.elementEnd().
  • Текстовым узлам соответствуют вызовы core.text().


Первым аргументом методов elementStart и text является число, значение которого увеличивается с каждым вызовом. Вероятно, оно представляет собой индекс в некоем массиве, в котором Angular хранит ссылки на созданные элементы.

В метод elementStart передаётся ещё и третий аргумент. Изучив вышеприведённые материалы, мы можем прийти к выводу, что аргумент это необязательный и содержит список атрибутов для узла DOM. Проверить это можно, посмотрев на значение _c0 и выяснив, что оно содержит список атрибутов и их значений для элемента div:

var _c0 = ["style", "text-align:center"];


Замечание об ngComponentDef


До сих пор мы анализировали часть сгенерированного кода, которая отвечает за рендеринг шаблона для компонента. Этот код, на самом деле, находится в более крупном фрагменте кода, который назначается AppComponent.ngComponentDef — статическому свойству, которое содержит все метаданные о компоненте, такие, как CSS-селекторы, его стратегию обнаружения изменений (если она задана), и шаблон. Если вы чувствуете тягу к приключениям — теперь вы можете самостоятельно разобраться с тем, как это работает, хотя ниже мы об этом поговорим.

Самодельный Ivy


Теперь, когда мы, в общих чертах, понимаем, как выглядит сгенерированный код, мы можем попытаться создать, с нуля, собственный компонент, используя то же самое API RendererV3, которое использует Ivy.

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

Начнём с написания простого компонента, а затем вручную переведём его в код, похожий на тот, что получается на выходе Ivy:

import { Component } from '@angular/core';

@Component({
  selector: 'manual-component',
  template: '

Hello, Component

', }) export class ManualComponent { }


Компилятор принимает на вход информацию декоратора @component, создаёт инструкции, а затем оформляет это всё в виде статического свойства класса компонента. Поэтому, для того, чтобы сымитировать деятельность Ivy, мы убираем декоратор @component и заменяем его статическим свойством ngComponent:

import * as core from '@angular/core';

export class ManualComponent {
  static ngComponentDef = core.ɵdefineComponent({
    type: ManualComponent,
    selectors: [['manual-component']],
    factory: () => new ManualComponent(),
    template: (rf: core.ɵRenderFlags, ctx: ManualComponent) => {
      // Сюда попадает скомпилированный шаблон
    },
  });
}


Мы определяем метаданные для скомпилированного компонента, вызывая ɵdefineComponent. Метаданные включают в себя тип компонента (использованный ранее для внедрения зависимости), CSS-селектор (или селекторы), который будет вызывать этот компонент (в нашем случае manual-component — это имя компонента в HTML-шаблоне), фабрику, которая возвращает новый экземпляр компонента, а затем функцию, которая определяет шаблон для компонента. Этот шаблон выводит визуальное представление компонента и обновляет его при изменении свойств компонента. Для того чтобы создать этот шаблон, мы будем использовать методы, которые мы обнаружили выше: ɵE, ɵe и ɵT.

    template: (rf: core.ɵRenderFlags, ctx: ManualComponent) => {
      core.ɵE(0, 'h2');                 // открываем тег элемента h2
      core.ɵT(1, 'Hello, Component');   // Добавляем текст
      core.ɵe();                        // Закрываем тег элемента h2
    },


На данном этапе мы не используем параметры rf или ctf, предоставляемые нашей функции шаблона. Мы к ним ещё вернёмся. Но для начала давайте посмотрим на то, как вывести наш первый самодельный компонент на экран.

Первое приложение


Для того чтобы выводить компоненты на экран, Angular экспортирует метод, называемый ɵrenderComponent. Всё, что надо сделать — проверить, чтобы в файле index.html был HTML-тег, соответствующий селектору элемента, , а затем добавить следующее в конец файла:

core.ɵrenderComponent(ManualComponent);


Вот и всё. Теперь у нас есть минимальное самодельное Angular-приложение, состоящее лишь из 16 строк кода. Поэкспериментировать с готовым приложением можно на StackBlitz.

Механизм обнаружения изменений


Итак, рабочий пример у нас есть. Можно ли добавить ему интерактивности? Скажем, как насчёт чего-то интересного, вроде использования тут системы обнаружения изменений Angular?

Изменим компонент таким образом, чтобы пользователь мог бы настраивать текст приветствия. То есть, вместо того, чтобы компонент всегда выводил бы текст Hello, Component, мы собираемся позволить пользователю менять ту часть текста, которая идёт после Hello.

Начнём работу с добавления свойства name и метода для обновления значения этого свойства к классу компонента:

export class ManualComponent {
  name = 'Component';

  updateName(newName: string) {
    this.name = newName;
  }
  
  // ...
}


Пока всё это выглядит не особенно впечатляюще, но самое интересное — впереди.

Далее мы отредактируем функцию шаблона так, чтобы она, вместо неизменного текста, выводила бы содержимое свойства name:

template: (rf: core.ɵRenderFlags, ctx: ManualComponent) => {
  if (rf & 1) {   // Создание: вызывается только при первом выводе
    core.ɵE(0, 'h2'); 
    core.ɵT(1, 'Hello, ');
    core.ɵT(2);   // <-- Местозаполнитель для name
    core.ɵe();
  }
  if (rf & 2) {   // Обновление: выполняется при каждом обнаружении изменения
   core.ɵt(2, ctx.name);  // ctx - это экземпляр нашего компонента
  }
},


Возможно, вы заметили, что мы обернули инструкции шаблона в выражения if, проверяющие значения rf. Этот параметр используется Angular для указания на то, создаётся ли компонент в первый раз (будет установлен наименьший значащий бит), или нам нужно лишь обновить динамическое содержимое в процессе обнаружения изменений (именно на это направлено второе выражение if).

Итак, когда компонент выводится в первый раз, мы создаём все элементы, а затем, при обнаружении изменений, мы лишь обновляем то, что могло измениться. Отвечает за это внутренний метод ɵt (обратите внимание на строчную букву t), который соответствует функции textBinding, экспортируемой Angular:

e48347c31b7532a1268509cf0825a031.png


Функция textBinding

Итак, первый параметр — это индекс элемента, который надо обновить, второй — это значение. В данном случае мы создаём пустой текстовый элемент с индексом 2 командой core.ɵT(2);. Он играет роль местозаполнителя для name. Его мы обновляем командой core.ɵt(2, ctx.name);  при обнаружении изменения соответствующей переменной.

В данный момент при выводе этого компонента всё ещё будет появляться текст Hello, Component, хотя мы можем изменить значение свойства name, что приведёт к изменению текста на экране.

Для того чтобы приложение стало бы по-настоящему интерактивным, мы добавим сюда поле для ввода данных с прослушивателем событий, который вызывает метод компонента updateName():

template: (rf: core.ɵRenderFlags, ctx: ManualComponent) => {
  if (rf & 1) {
    core.ɵE(0, 'h2'); 
    core.ɵT(1, 'Hello, ');
    core.ɵT(2);
    core.ɵe();
    core.ɵT(3, 'Your name: ');
    core.ɵE(4, 'input'); 
    core.ɵL('input', $event => ctx.updateName($event.target.value));
    core.ɵe();
  }
  // ...
},


Привязка события выполняется в строке core.ɵL('input', $event => ctx.updateName($event.target.value));. А именно, метод ɵL отвечает за установку прослушивателя событий для самого свежего из объявленных элементов. Первый аргумент — это имя события (в данном случае это input — событие, которые вызывается при изменении содержимого элемента ), второй аргумент представляет собой коллбэк. Этот коллбэк принимает, в качестве аргумента, данные события. Затем мы извлекаем текущее значение из целевого элемента события, то есть — из элемента , и передаём его функции в компоненте.

Вышеприведённый код эквивалентен написанию следующего HTML-кода в шаблоне:

Your name: 


Сейчас уже можно редактировать содержимое элемента и наблюдать за тем, как меняется текст в компоненте. Однако, поле вводе на заполняется при загрузке компонента. Для того чтобы всё работало именно так, нужно добавить ещё одну инструкцию в код функции шаблона, выполняемый при обнаружении изменения:

template: (rf: core.ɵRenderFlags, ctx: ManualComponent) => {
  if (rf & 1) { ... }
  if (rf & 2) {
    core.ɵt(2, ctx.name);
    core.ɵp(4, 'value', ctx.name);
  }
}


Здесь мы используем ещё один встроенный метод системы рендеринга, ɵp, который обновляет свойство элемента с заданным индексом. В данном случае методу передаётся индекс 4, который является тем индексом, который назначен элементу input, и указываем методу на то, что он должен поместить значение ctx.name в свойство value этого элемента.

Теперь наш пример, наконец, готов. Мы реализовали, с нуля, двустороннюю привязку данных, используя API системы рендеринга Ivy. Это просто замечательно.
Здесь можно поэкспериментировать с готовым кодом.

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

О блоках *ngIf и *ngFor


Прежде чем мы окончим исследование Ivy, рассмотрим ещё одну интересную тему. А именно, поговорим о том, как компилятор работает с подшаблонами. Это — шаблоны, которые используются для блоков *ngIf или *ngFor. Обрабатываются они по-особому. Посмотрим на то, как использовать *ngIf в коде нашего самодельного шаблона.

Для начала надо установить npm-пакет @angular/common — именно здесь объявлен *ngIf. Далее, надо импортировать директиву из этого пакета:

import { NgIf } from '@angular/common';


Теперь, для того, чтобы получить возможность использовать NgIf в шаблоне, надо снабдить его некоторыми метаданными, так как модуль @angular/common не был скомпилирован с помощью Ivy (как минимум, во время написания материала, а в будущем это, вероятно, изменится с введением ngcc).

Мы собираемся использовать метод ɵdefineDirective, который родственен уже знакомому нам методу ɵdefineComponent. Он определяет метаданные для директив:

(NgIf as any).ngDirectiveDef = core.ɵdefineDirective({
  type: NgIf,
  selectors: [['', 'ngIf', '']],
  factory: () => new NgIf(core.ɵinjectViewContainerRef(), core.ɵinjectTemplateRef()),
  inputs: {ngIf: 'ngIf', ngIfThen: 'ngIfThen', ngIfElse: 'ngIfElse'}
});


Я обнаружил это определение в исходном коде Angular, вместе с объявлением ngFor. Теперь, когда мы подготовили NgIf к использованию в Ivy, можно добавить следующее в список директив для компонента:

static ngComponentDef = core.ɵdefineComponent({
  directives: [NgIf],
  // ...
});


Далее, определим подшаблон только для раздела, ограниченного *ngIf.

Предположим, что тут надо вывести изображение. Зададим для этого шаблона новую функцию внутри функции шаблона:

function ifTemplate(rf: core.ɵRenderFlags, ctx: ManualComponent) {
  if (rf & 1) {
    core.ɵE(0, 'div');
    core.ɵE(1, 'img', ['src', 'https://pbs.twimg.com/tweet_video_thumb/C80o289UQAAKIqp.jpg']);
    core.ɵe();
  }
}


Эта функция шаблона не отличается от той, которую мы уже писали. Тут используются те же самые конструкции для создания элемента img внутри элемента div.

И, наконец, мы можем собрать всё это вместе, добавив директиву ngIf в шаблон компонента:

template: (rf: core.ɵRenderFlags, ctx: ManualComponent) => {
  if (rf & 1) {
    // ...
    core.ɵC(5, ifTemplate, null, ['ngIf']);
  }
  if (rf & 2) {
    // ...
    core.ɵp(5, 'ngIf', (ctx.name === 'Igor'));
  }

  function ifTemplate(rf: core.ɵRenderFlags, ctx: ManualComponent) {
    // ...
  }
},


Обратите внимание на вызов нового метода в начале кода (core.ɵC(5, ifTemplate, null, ['ngIf']);). Он объявляет новый элемент-контейнер, то есть — элемент, у которого есть шаблон. Первый аргумент — это индекс элемента, такие индексы мы уже видели. Второй аргумент — это функция подшаблона, которую мы только что определили. Она будет использоваться как шаблон для элемента-контейнера. Третий параметр — это имя тега для элемента, которое тут смысла не имеет, и, наконец, здесь имеется список директив и атрибутов, связанных с этим элементом. Именно здесь в дело вступает ngIf.

В строке core.ɵp(5, 'ngIf', (ctx.name === 'Igor')); обновляется состояние элемента путём привязки атрибута ngIf к значению логического выражения ctx.name === 'Igor'. Тут осуществляется проверка на то, равно ли свойство name компонента строке Igor.

Вышеприведённый код эквивалентен следующему HTML-коду:


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

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

Итоги


Мы изрядно попутешествовали по возможностям компилятора Ivy. Надеюсь, это путешествие разожгло ваш интерес к дальнейшим исследованиям Angular. Если это так — то теперь в вашем распоряжении есть всё, что нужно для того, чтобы экспериментировать с Ivy. Теперь вы знаете, как «переводить» шаблоны на JavaScript, как обращаться к тем же механизмам Angular, которые использует Ivy, не применяя сам этот компилятор. Полагаю, всё это даст вам возможность изучить новые механизмы Angular настолько глубоко, насколько вам того захочется.

Вот, вот и вот — три материала, в которых можно найти полезные сведения об Ivy. А вот — исходный код Render3.

Уважаемые читатели! Как вы относитесь к новым возможностям Ivy?

1ba550d25e8846ce8805de564da6aa63.png

© Habrahabr.ru