В этом посте я расскажу о некоторых функциях, которые делают пользовательские элементы привлекательными, если вы планируете создать собственную библиотеку компонентов. Я также коснусь идей, которые часто упускаются из виду при обсуждении пользовательских элементов, таких как их способность работать с Virtual DOM и отображаться на сервере. Ознакомьтесь с частью 1, если вы еще не успели ее прочитать.

Функции

Итак, какие интересные функции делают пользовательские элементы лучше, чем ручное создание собственных виджетов JavaScript или использование старых добрых плагинов jQuery? Я собираюсь перечислить мои любимые функции, начиная с практических и заканчивая более экспериментальными / экзотическими / интересными. Если первые несколько новостей для вас старые, продолжайте читать, потому что есть много новых вещей, с которыми вы, возможно, не сталкивались раньше.

Обратные вызовы жизненного цикла

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

class AppDrawer extends HTMLElement {
  constructor() {
    // Called when an instance of the element is created or upgraded
    super(); // always call super() first in the ctor.
  }
  connectedCallback() {
    // Called every time the element is inserted into the DOM
  }
  disconnectedCallback() {
    // Called every time the element is removed from the DOM. 
  }
  attributeChangedCallback(attrName, oldVal, newVal) {
    // Called when an attribute was added, removed, or updated
  }
  adoptedCallback() {
    // Called if the element has been moved into a new document
  }
}

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

Особое значение имеет attributeChangedCallback, который будет выполняться каждый раз, когда наблюдаемый атрибут обновляется в вашем элементе. Например, если у вас есть элемент

<x-foo doge="frenchie">

и кто-то изменит значение doge на corgi, обратный вызов будет запущен и сообщит вам новое значение. Это означает, что вам не нужно писать собственных наблюдателей за мутациями только для того, чтобы следить за этими изменениями. Действительно удобно!

Автоматическое обновление

Возможно, вы знакомы с кодом jQuery, который выглядит так:

$('.carousel').carousel({ /* carousel options */ })

Или эквивалент ванильного JavaScript:

Array.from(document.querySelectorAll('.carousel'))
  .forEach((element) => {
    new Carousel(element);
  });

Это типичный шаблон, который необходимо написать для выбора и начальной загрузки компонента. Я не раз открывал клиентский файл main.js и обнаруживал, что это один гигантский обратный вызов onDocumentReady, полный такого кода. Валовой!

Пользовательские элементы решают эту проблему с помощью процесса, известного как обновление. По мере того, как HTML-синтаксический анализатор проходит через страницу, если он встречает настраиваемый элемент, он запускает обратные вызовы его жизненного цикла. Это работает, если элемент уже находится в DOM или, если вы вставляете элемент с помощью JavaScript.

const xfoo = document.createElement('x-foo');
xfoo.setAttribute('doge', 'puggle');
// x-foo will be automatically upgraded
document.body.appendChild(xfoo);

Опять же, вы сокращаете количество шаблонов и путаницы, связанной с загрузкой элементов. Если у вас есть один из этих файлов main.js, который представляет собой один большой обратный вызов для настройки ваших компонентов, представьте, что вы удалили его целиком. Звучит неплохо, правда?

Легко укладывать

Многие разработчики, знакомые с веб-компонентами или пользовательскими элементами, часто думают, что для стилизации элемента необходимо использовать Shadow DOM. Shadow DOM создает область защищенного стиля вокруг вашего элемента, и, хотя это очень круто, я хочу подчеркнуть, что это не является обязательным требованием для использования настраиваемых элементов. Вы можете так же легко предоставить таблицу стилей с вашими элементами, как это делают многие библиотеки пользовательского интерфейса сегодня. Если вы предпочитаете стилизовать все с помощью такой системы, как БЭМ, или Suit CSS, или какой-нибудь другой вариант между ними, это совершенно нормально!

Я поднимаю этот вопрос, потому что много раз говорил с разработчиками, которые подозрительно относятся к количеству полифиллов, необходимых для поддержки всех стандартов веб-компонентов, в частности Shadow DOM, поэтому они избегают связанных спецификаций, таких как Custom Elements. Не позволяйте отсутствию Shadow DOM помешать вам использовать пользовательские элементы. Вы можете использовать их сегодня и продолжать писать CSS так, чтобы вы чувствовали себя комфортно.

Хорошо работают с модулями ES

Библиотека полимеров сделала популярным использование HTML Imports для загрузки определений пользовательских элементов. Но, как мы видели с Shadow DOM, это не является требованием для работы с Custom Elements. Если вы предпочитаете работать с модулями ES, дерзайте!

Вот пример элемента, который можно импортировать в приложение с помощью модулей ES:

export default class FooWidget extends HTMLElement {
  constructor() {
    super();
    this.message = 'Hello World!';
  }
  connectedCallback() {
    this.innerHTML = `<div>${this.message}</div>`;
  }
}
// Check that the element hasn't already been registered
if (!window.customElements.get('foo-widget')) {
  window.customElements.define('foo-widget', FooWidget);
}

Импорт класса FooWidget зарегистрирует определение элемента в документе. Теперь вы можете использовать ‹foo-widget› в любом месте вашего приложения!

Реальные экземпляры

Плагины jQuery и компоненты фреймворка обертывают собственные узлы DOM, чтобы предоставить им дополнительную функциональность. Вам, как потребителю этих компонентов, дано указание не работать с DOM напрямую, а вместо этого взаимодействовать с этими оболочками с помощью API-интерфейсов, специфичных для библиотеки / фреймворка. Вот почему так много библиотек и фреймворков несовместимы! Если каждая библиотека создает свои собственные оболочки со своими собственными API, то другие библиотеки не смогут использовать эти компоненты.

Единственное, что является универсальным, - это модель DOM, поэтому имеет смысл создавать элементы с помощью модели DOM, если основная цель - поддерживать как можно больше команд на как можно большем количестве стеков.

Когда вы определяете настраиваемый элемент, вы действительно создаете обычный класс JavaScript, который наследуется от HTMLElement. Каждый раз, когда вы используете свой новый тег в разметке, он создает экземпляр этого класса. Это означает, что вы можете определять свои собственные свойства, методы, геттеры / сеттеры и даже отражать изменения состояния обратно в атрибуты вашего элемента.

class MyElement extends HTMLElement {
  // Tells the element which attributes to observer for changes
  // This is a feature added by Custom Elements
  static get observedAttributes() {
    return ['foo'];
  }
  // Get the initial value of foo or use a fallback
  connectedCallback() {
    this.foo = this.getAttribute('foo') || 'Oh, hai!';
  }
  // When an attribute is updated, check if the value is different
  // If so, call our setter
  attributeChangedCallback(name, oldVal, newVal) {
    if (this[name] !== newVal) {
      this[name] = newVal;
    }
  }
  get foo() {
    return this._foo;
  }
  // Set the value for foo, reflect to attribute, and re-render
  set foo(value) {
    this._foo = value;
    this.setAttribute(‘foo’, value);
    this.render();
  }
  render() {
    this.innerHTML = `<div>${this.foo}</div>`;
  }
}
if (!window.customElements.get('my-element')) {
  window.customElements.define('my-element', MyElement);
}

В приведенном выше примере элемент обновится и сразу же проверит, установлен ли для него атрибут foo = ””. В противном случае он будет использовать запасной вариант «О, привет!». Добавление атрибута foo в массив visibleAttributes означает, что он вызовет attributeChangedCallback, если он когда-либо будет изменен в нашем документе.

Это означает, что вы можете сделать что-то вроде этого:

var el = document.querySelector('my-element');
el.foo = 'Custom Elements are awesome!';
el.getAttribute('foo') // value is 'Custom Elements are awesome!'

Мало того, что атрибут синхронизируется со свойством, мы также можем указать элементу на рендеринг в любое время, когда установщик изменяет свое состояние. Глядя на эту функцию render (), вы можете почувствовать, что это немного напоминает вам React, так что давайте поговорим об этом!

Реагировать / Виртуальный DOM

Многие разработчики считают React и пользовательские элементы / веб-компоненты взаимоисключающими, но это не обязательно. Документация React объясняет, как можно использовать пользовательские элементы внутри React или как поместить React внутри пользовательских элементов. Это похоже на начало!

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

Но нет причин, по которым вы не можете использовать тот же подход в своих собственных пользовательских элементах, используя готовые библиотеки Virtual DOM. Вот пример, в котором используется отличная библиотека diffhtml Тима Браньена. Обратите внимание, что diffhtml использует строку шаблона с тегами, чтобы вы могли писать код, очень похожий на JSX.

class CurrentTime extends HTMLElement {
  constructor() {
    super();
    // Bind render to this instance so we
    // can call it from within our template
    this.render = this.render.bind(this);
    this.render();
  }
  render() {
    // Note the diff.html tagged template string
    diff.innerHTML(this, diff.html`
      <button onclick="${this.render}">
        Show current unix time
      </button>
      <span>${Date.now()}</span>
    `);
  }
}

В приведенном выше примере, хотя я вызываю render () каждый раз при нажатии кнопки, фактически обновляется только объект DOM ‹span›, содержащий новый строка времени. Отлично!

Один из самых отточенных примеров этого подхода, который я видел, - это библиотека Skate, которая использует Incremental DOM, чтобы делать опоры вниз, события вверх, подход к пользовательским элементам. По словам одного из авторов:

Поскольку Skate инкапсулирует подход виртуального DOM и использует события DOM для реактивности, он работает везде (и с несколькими версиями самого себя на странице). Вы получаете красивую модель FRP, но она не выходит за пределы компонента. Для всех, кто использует ваш компонент, это обычный элемент DOM.

Недавно создатель Preact также продемонстрировал поддержку веб-компонентов в своей библиотеке. И, как я уже упоминал ранее, при размере около 40 КБ (сжатый сжатие) вы даже можете поместить React внутри своих пользовательских элементов и просто рассматривать его как общую зависимость, что и является тем, что Автономная библиотека от Адама Тимберлейка делает.

Поэтому, если вы пытаетесь создать библиотеку пользовательского интерфейса для масштабирования среди множества разных команд, вы можете абсолютно использовать прекрасные идеи и шаблоны экосистемы React, но объединить их в пользовательские элементы, чтобы они служили уровнем взаимодействия. Эти элементы затем можно использовать в React, Angular 2, [insert cool framework] или автономно.

Я лишь вкратце коснусь этой темы, но поскольку в Твиттере было очень много обсуждений, связанных с потоком данных сверху вниз в Custom Elements, я хотел бы сделать следующий пост, чтобы изучить это более подробно. Тем временем Андре Стальц сделал отличную статью об этой идее, поскольку она конкретно относится к React. Обязательно посмотрите его пост (после прочтения моего: P)

Серверный рендеринг

Если мы говорим о React, мы должны также коснуться идеи универсальных или изоморфных приложений. Вкратце, универсальное приложение - это приложение, которое может запускать свою инфраструктуру JavaScript на сервере, загружать свои компоненты и отправлять начальный рендеринг страницы, который затем «увлажняется» дополнительным клиентским JS.

Как мы видели на примере Virtual DOM, вполне возможно использовать тот же подход с Custom Elements. Лучшим примером, который я видел до сих пор, является библиотека Тима Перри под названием Серверные компоненты, которая использует библиотеку домино для имитации серверной DOM и выполнения обратных вызовов жизненного цикла для любых найденных пользовательских элементов.

Вот пример из проекта Server Components. Обратите внимание, что проект по-прежнему полагается на старую версию v0 (которая должна быть av и ноль. Шрифт Medium меня ненавидит) версии Custom Elements, поэтому он использует createdCallback вместо connectedCallback и registerElement вместо customElements.define.

const components = require("server-components");
// Get the prototype for a new element
const NewElement = components.newElement();
// Stamp out the element’s template
// Note this is the old v0 syntax for creating a custom element
NewElement.createdCallback = function () {
  this.innerHTML = "<p>Hi there</p>";
};
// Register the element
components.registerElement("my-new-element", {
  prototype: NewElement
});

И чтобы отобразить страницу:

const components = require(“server-components”);
// Render the HTML, and receive a promise for the resulting
// HTML string.
components.renderPage(`
  <html>
    <head></head>
    <body>
      <my-new-element></my-new-element>
    </body>
  </html>
  `).then(function (output) {
    // Output equals:
    `<html>
      <head></head>
      <body>
        <my-new-element><p>Hi there</p></my-new-element>
      </body>
    </html>`
});

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

Прогрессивное улучшение

До этого момента я представил довольно много JavaScript, но часто возникает очевидный вопрос: что, если мой JavaScript не загружается? Останутся ли у моих пользователей пустая страница, если я использую настраиваемые элементы?

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

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

Самым простым (и самым спорным) является использование способности настраиваемых элементов наследовать от существующих собственных тегов, иногда называемых настраиваемыми встроенными модулями в спецификации v1 или расширениями типов в старой спецификации v0.

class FancyButton extends HTMLButtonElement {
  // do setup work
}
customElements.define('fancy-button', FancyButton, {
  extends: 'button'
});
<!-- Elsewhere in the page… -->
<!-- If JS fails to load we’ll still get a regular button here -->
<button is="fancy-button" disabled>Fancy button!</button>

Этот пример по-прежнему будет давать нам обратные вызовы жизненного цикла Custom Element, наследуя семантику и поддержку клавиатуры, встроенную в ‹button›. Я упоминаю, что это спорно, потому что, хотя это часть Спецификации Custom Elements v1, и Chrome и Opera планируют предоставить его поддержку, Safari заявил, что они не будут его реализовывать.

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

<fancy-input-decorator>
  <input type="text">
</fancy-input-decorator>

Именно такой подход используется в приложении Магазин полимеров. Если JavaScript не загружается, пользователь должен по-прежнему получать поля ввода, с которыми он может взаимодействовать.

Но, как Джереми указывает в своем сообщении в блоге, поскольку все приложение Shop содержится в одном элементе ‹shop-app›, если этот элемент не может быть загружен, пользователь никогда не увидит эти собственные входы. Поэтому важно помнить о том, как все ваше приложение структурировано, чтобы случайно не отказаться от собственной работы по постепенному улучшению.

Я поместил этот раздел после раздела, посвященного универсальным приложениям, специально потому, что считаю, что это та область, где рендеринг на стороне сервера может сыграть важную роль. Глядя на такие фреймворки, как Angular 2, React и т. Д., Кажется, что разработка веб-приложений движется в том направлении, в котором все является компонентом, включая сам ‹main-app›. Таким образом, проблема прогрессивного улучшения не уникальна для пользовательских элементов, но действительно для любого подхода, который стремится объединить функциональность в компоненты более высокого порядка (в конечном итоге достигая компонента приложения верхнего уровня). Вышеупомянутые фреймворки могут использовать рендеринг на стороне сервера, чтобы гарантировать, что пользователи, по крайней мере, имеют работоспособную страницу, даже если клиентский JavaScript не работает (поскольку их компонент приложения будет преобразован в строку HTML на сервере). Тем из нас, кто создает пользовательские элементы, следует изучить, можем ли мы воспользоваться этими же методами, возможно, используя библиотеку, такую ​​как Server Components, или создав аналогичные утилиты.

Машинопись

Попытка задокументировать или экспортировать интерфейс для всей поверхности API каждого элемента в вашей библиотеке может оказаться большой работой. Хотя эту работу можно выполнять, используя комментарии к коду и библиотеки, такие как JSDoc, многие крупные команды обратились к написанию компонентов на TypeScript, потому что он имеет встроенную поддержку интерфейсов, проверки типов и предоставляет IDE с поддержкой автозаполнения.

Поскольку пользовательские элементы - это просто JavaScript, их вполне можно написать с помощью TypeScript. Вот пример, составленный товарищем по Google Робом Вормолдом, демонстрирующий, насколько это просто.

Хотя я не очень много работал с TypeScript, мне очень интересно узнать, как разработчики это понимают. Было бы здорово не только иметь автозаполнение в моей среде IDE, когда я работаю с настраиваемым элементом, но и потенциально есть гораздо более серьезные последствия. Если, например, каждый элемент в вашей библиотеке пользовательского интерфейса экспортирует интерфейс TypeScript, то они могут быть использованы графическим интерфейсом перетаскивания (аналогично SquareSpace или Wix), где каждый интерфейс сопряжен с соответствующими элементами управления формой (подумайте о Interface Builder, но для паутина)!

Подведение итогов

В этом посте я много прыгал, и до некоторой степени это было намеренно. Пользовательские элементы - такой гибкий примитив, что как только вы начинаете видеть все возможности, они становятся действительно привлекательными!

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

До следующего раза!