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

Однофайловые компоненты

Веб-разработчики, знающие термин прогрессивное улучшение, также знают о мантре разделения слоев. В случае компонентов ничего не меняется. Фактически, слоев даже больше, так как теперь каждый компонент имеет как минимум 3 уровня: контент / шаблон, представление и поведение. Если вы воспользуетесь наиболее консервативным подходом, то каждый компонент будет разделен как минимум на 3 файла, например. Компонент Button может выглядеть так:

В таком подходе разделение уровней равно разделению технологий (контент / шаблон: HTML, презентация: CSS, поведение: JS). Это означает, что если вы не используете какой-либо инструмент для сборки, браузер должен будет получить все 3 файла. Поэтому возникла идея сохранить разделение слоев, но без разделения технологий. А потом родились однофайловые компоненты.

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

Компонент Button в виде отдельного файла будет выглядеть так:

Хорошо видно, что отдельный файловый компонент - это просто старый добрый HTML ™ с внутренними стилями и скриптами + тегом <template>. Благодаря этому подходу, использующему простейшие методы, вы получаете компонент, который по-прежнему имеет сильное разделение слоев (контент / шаблон: <template>, презентация: <style>, поведение: <script>) без необходимости создавать отдельный файл для каждого слоя.

Тем не менее, остается самый важный вопрос: как мне его использовать?

Основные концепции

Начнем с создания loadComponent() глобальной функции, которая будет использоваться для загрузки компонента.

Я использовал здесь модульный паттерн. Он позволяет вам определять все необходимые вспомогательные функции, но предоставляет только функцию loadComponent() для внешней области видимости. Пока эта функция ничего не делает.

И это хорошо, потому что еще нечего загружать. В рамках этой статьи предположим, что вы хотите создать <hello-world> компонент, который будет отображать текст:

Привет, мир! Меня зовут ‹имя›.

Кроме того, после щелчка компонент должен отображать предупреждение:

Не трогай меня!

Сохраните код компонента как файл HelloWorld.wc (.wc обозначает веб-компонент). Вначале это будет выглядеть так:

На данный момент вы не добавили для него никакого поведения. Вы только определили его шаблон и стили. Использование селектора div без каких-либо ограничений и внешний вид элемента <slot> предполагает, что компонент будет использовать Shadow DOM. И это правда: все стили и шаблон по умолчанию будут скрыты в тени.

Использование компонента на сайте должно быть максимально простым:

Вы используете компонент как стандартный Custom Element. Единственное отличие состоит в том, что его необходимо загрузить перед использованием loadComponent() (который находится в файле loader.js). Эта функция выполняет всю тяжелую работу, например, извлекает компонент и регистрирует его через customElements.define().

Это суммирует все основные концепции, пора испачкаться!

Базовый загрузчик

Если вы хотите загрузить данные из внешнего файла, вы должны использовать бессмертный Ajax. Но поскольку на дворе 2018 год, вы можете использовать Ajax в виде Fetch API:

Удивительный! Однако на данный момент вы только загружаете файл, ничего не делая с ним. Лучший вариант получить его содержимое - преобразовать ответ в текст:

Поскольку loadComponent() теперь возвращает результат функции fetch(), он возвращает Promise. Вы можете использовать эти знания, чтобы проверить, действительно ли содержимое компонента было загружено и было ли оно преобразовано в текст:

Оно работает!

Разбор ответа

Однако сам текст не отвечает вашим потребностям. Вы писали компонент в HTML не для того, чтобы делать запрещенное. В конце концов, вы находитесь в браузере - среде, в которой была создана DOM. Используйте его силу!

В браузерах есть хороший DOMParser класс, который позволяет создавать парсер DOM. Давайте создадим его экземпляр, чтобы преобразовать компонент в некий DOM:

Сначала вы создаете экземпляр парсера (1), затем анализируете текстовое содержимое компонента (2). Стоит отметить, что вы используете режим HTML ('text/html'). Если вы хотите, чтобы код лучше соответствовал стандарту JSX или исходным компонентам Vue, вы должны использовать режим XML ('text/xml'). Однако в таком случае вам нужно будет изменить структуру самого компонента (например, добавить основной элемент, который будет содержать все остальные).

Если вы теперь проверите, что возвращает loadComponent(), вы увидите, что это полное дерево DOM.

Под словом полный я подразумеваю действительно. У вас есть целый HTML-документ с элементами <head> и <body>. Как видите, содержимое компонента заканчивалось внутри <head>. Это вызвано тем, как работает парсер HTML. Алгоритм построения DOM-дерева подробно описан в спецификациях HTML LS. В TL; DR можно сказать, что синтаксический анализатор поместит все в элемент <head>, пока не приблизится к элементу, который разрешен только в контексте <body>. Однако все используемые вами элементы (<template>, <style>, <script>) также разрешены в <head>. Если вы добавили, например, пустой тег <p> в начало компонента, все его содержимое будет отображено в <body>.

Честно говоря, компонент рассматривается как неправильный HTML-документ, поскольку он не начинается с объявления DOCTYPE. Из-за этого он рендерится в так называемом режиме причуд. К счастью, это ничего не меняет для вас, поскольку вы используете парсер DOM только для разделения компонента на соответствующие части.

Имея DOM-дерево, вы можете получить только те части, которые вам нужны:

Переместите весь код выборки и анализа в первую вспомогательную функцию fetchAndParse():

Fetch API - не единственный способ получить дерево DOM внешнего документа. XMLHttpRequest имеет специальный режим документа, который позволяет вам опустить весь этап синтаксического анализа. Однако есть один недостаток: XMLHttpRequest не имеет API на основе Promise, который вам нужно было бы добавить самостоятельно.

Регистрация компонента

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

Напоминаем, что настраиваемый элемент должен быть классом, унаследованным от HTMLElement. Кроме того, каждый компонент будет использовать Shadow DOM, который будет содержать стили и содержимое шаблона. Это означает, что каждый компонент будет использовать один и тот же класс. Создайте его сейчас:

Вы можете создать его внутри registerComponent(), поскольку класс будет использовать информацию, которая будет передана упомянутой функции. Класс будет использовать слегка измененный механизм для присоединения Shadow DOM, который я описал в статье о декларативном Shadow DOM (на польском языке).

Остается только одно, что связано с регистрацией компонента - присвоение ему имени и добавление в коллекцию компонентов текущей страницы:

Если вы попытаетесь использовать компонент сейчас, он сработает:

Получение содержимого скрипта

Простая часть сделана. Теперь пришло время для чего-то действительно сложного: добавить уровень поведения и ... динамическое имя для компонента. На предыдущем шаге вы жестко запрограммировали имя компонента, однако оно должно быть доставлено из одного файлового компонента. Таким же образом вы должны предоставить информацию о слушателях событий, которые вы хотите привязать к настраиваемому элементу. Используйте соглашение, основанное на соглашении из Vue:

Вы можете предположить, что <script> внутри компонента является модулем, поэтому он может что-то экспортировать (1). Этот экспорт представляет собой объект, содержащий имя компонента (2) и прослушиватели событий, скрытые за методами с именем, начинающимся с on… (3).

Выглядит красиво, и снаружи ничего не просачивается (поскольку модули не существуют в глобальной области видимости). Но есть проблема: не существует стандарта обработки экспорта из внутренних модулей (то есть тех, чей код находится непосредственно внутри HTML-документа). Оператор импорта предполагает, что получает идентификатор модуля. Чаще всего это URL-адрес файла, содержащего код. В случае внутренних модулей такого идентификатора нет.

Но прежде чем сдаться, вы можете использовать супер грязный хакер. Есть как минимум два способа заставить браузер обрабатывать данный текст как файл: Data URI и Object URI.

Stack Overflow также предлагает Service Worker. Однако в данном случае это выглядит излишеством.

URI данных и URI объекта

URI данных - это более старый и примитивный подход. Он основан на преобразовании содержимого файла в URL-адрес путем удаления ненужных пробелов и последующего, при желании, кодирования всего с помощью Base64. Предполагая, что у вас есть такой простой файл JavaScript:

Это будет выглядеть так: URI данных:

Вы можете использовать этот URL как ссылку на обычный файл:

Однако самый большой недостаток URI данных становится очевидным довольно быстро: по мере того, как файл JavaScript становится больше, URL становится все длиннее и длиннее. Также довольно сложно поместить двоичные данные в Data URI разумным способом. Вот почему был создан объектный URI. Это наследник нескольких стандартов, включая File API и HTML 5.x с его тегами <video> и <audio>. Цель Object URI проста: создать ложный файл из заданных двоичных данных, который получит уникальный URL, работающий только в контексте текущей страницы. Проще говоря: создайте в памяти файл с уникальным именем. Таким образом, вы получаете все преимущества Data URI (простой способ создания нового файла) без его недостатков (вы не получите строку размером 100 МБ в вашем коде).

URI объектов часто создаются из мультимедийных потоков (например, в контексте <video> или <audio>) или файлов, отправляемых с помощью input[type=file] и механизма перетаскивания. К счастью, вы также можете создавать такие файлы вручную, используя классы File и Blob. В этом случае используйте класс Blob, в который вы поместите содержимое модуля, а затем преобразуете его в URI объекта:

Динамический импорт

Однако есть еще одна проблема: оператор импорта не принимает переменную в качестве идентификатора модуля. Это означает, что, помимо используемого метода преобразования модуля в «файл», вы не сможете его импортировать. Так что победить все-таки?

Не совсем. Эта проблема была замечена давно, и было создано предложение динамического импорта. На момент написания этой статьи (август 2018 г.) он находится на третьем этапе стандартизации, поэтому первые реализации появляются в браузерах и других средах JavaScript. А использование переменной в качестве идентификатора модуля вместе с динамическим импортом больше не является проблемой:

Как видите, import() используется как функция и возвращает Promise, который получает объект, представляющий модуль. Он содержит все объявленные экспортные данные, при этом экспорт по умолчанию находится под ключом default.

Реализация

Вы уже знаете, что должны делать, поэтому вам просто нужно это сделать. Добавьте следующую вспомогательную функцию getSettings(). Вы запустите его до registerComponents() и получите всю необходимую информацию из скрипта:

Пока эта функция просто возвращает все переданные аргументы. Добавьте всю логику, описанную выше. Во-первых, преобразование скрипта в объектный URI:

Затем загружаем его через импорт и возвращаем шаблон, стили и имя компонента, полученные от <script>:

Благодаря этому registerComponent() по-прежнему получает 3 параметра, но вместо <script> теперь получает имя. Исправьте код:

Вуаля!

Слой поведения

Осталась одна часть компонента: поведение, то есть обработка событий. На данный момент вы получаете только имя компонента в функции getSettings(), но вы также должны получить прослушиватели событий. Для этого вы можете использовать метод Object.entries(). Вернитесь к getSettings() и добавьте соответствующий код:

Функция усложнилась. Внутри него появилась новая вспомогательная функция getListeners() (1). Вы передаете ему экспорт модуля (2). Затем вы перебираете все свойства этого экспорта, используя Object.entries() (3). Если имя текущего свойства начинается с on… (4), вы добавляете значение этого свойства к объекту listeners под ключом, равным setting[ 2 ].toLowerCase() + setting.substr( 3 ) (5). Ключ вычисляется путем обрезки префикса on и переключения первой после него буквы на маленькую (так что вы получите click от onClick). Вы передаете объект listeners дальше (6).

Вместо [].forEach() вы можете использовать [].reduce(), что устранит переменную listeners:

Теперь вы можете связать слушателей внутри класса компонента:

В деструктурировании появился новый параметр listeners (1) и новый метод в классе _attachListeners() (2). Вы можете использовать Object.entries() еще раз - на этот раз для перебора listeners (3) и привязки их к элементу (4).

После этого компонент должен отреагировать на щелчок:

Вот как можно реализовать однофайловые веб-компоненты 🎉!

Совместимость браузера и остальная часть резюме

Как видите, было проделано много работы, чтобы создать хотя бы базовую форму поддержки однофайловых компонентов. Многие части описываемой системы созданы с использованием грязных хаков (объектные URI для загрузки модулей ES - FTW!), И сам метод, кажется, не имеет особого смысла без встроенной поддержки со стороны браузеров. Более того, на момент написания этой статьи (август 2018 г.) в Firefox нет поддержки пользовательских элементов и динамического импорта. Если честно, все хорошо работает только в Chrome. И поэтому - пока - это просто любопытство, а не что-то действительно полезное.

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

Конечно, все это доступно онлайн.