tagshift: из <template> — в компонент
tagshift — маленькая библиотека, которая делает ровно одно: превращает <template> в класс. У экземпляра есть props и slots, а render() возвращает готовый элемент. Первый вызов его строит, каждый следующий — обновляет тот же самый элемент на месте.
Ни зависимостей, ни сборки, ни виртуального DOM. Шаблон — обычный HTML, который лежит прямо на странице.
Сайт и песочница jwo1f.github.io/tagshift Живой пример, песочница, в которой можно править шаблон, пропсы и слоты, и полный API на одной странице.Установка
npm install tagshift
Сборщик для этого не нужен. Модуль один, импортов внутри нет, так что его можно взять прямо с CDN:
import { define } from 'https://cdn.jsdelivr.net/npm/tagshift@1.2.0/src/index.js';
Весит это около полутора килобайт в минифицированном и сжатом виде. Типы для TypeScript лежат рядом и написаны руками — в самих исходниках TypeScript нет.
Шаблон остаётся обычным HTML
<template id="user-card">
<article class="card card--${variant}">
<h2>Hello, ${name}!</h2>
<div class="body"><slot>Nothing here yet</slot></div>
<footer><slot name="actions"></slot></footer>
</article>
</template>
Здесь два механизма, и оба вам знакомы.
${name} — плейсхолдер. Он работает в тексте и в значениях атрибутов, пробелы внутри скобок допустимы, а имя подчиняется тем же правилам, что и идентификатор в JavaScript. Ни условий, ни циклов, ни выражений — только имя.
<slot> — место, куда потом придёт содержимое. Правила те же, что у Shadow DOM: безымянный слот — это default, у остальных есть name, а то, что написано внутри слота, показывается, пока ему ничего не передали.
Браузер содержимое <template> не рендерит, так что на странице этот шаблон не виден, пока его не превратят в элемент.
Из шаблона — класс
import { define } from 'tagshift';
const UserCard = define(document.getElementById('user-card'));
define() копирует шаблон, поэтому дальнейшие правки самого <template> на уже созданный класс не влияют. Тут же он проверяет, что корневой элемент ровно один. Пробелы и комментарии вокруг корня не считаются, всё остальное — ошибка, и ловится она сразу, а не на первом рендере:
TemplateError: Template must have exactly one root element, found several
TemplateError: Template must not have text outside its root element
TemplateError: Template root element must not be a <slot>
TemplateError: Template must have exactly one root element, found none
TypeError: define() expects a <template> element
Это настоящие сообщения: шаблоны <p>a</p><p>b</p>, text <p>a</p>, <slot></slot>, пустой — и обычный <div> вместо <template>.
render() всегда возвращает один и тот же элемент
const card = new UserCard({ props: { name: 'Alex', variant: 'compact' } });
card.element; // null — nothing rendered yet
document.body.append(card.render());
<article class="card card--compact">
<h2>Hello, Alex!</h2>
<div class="body">Nothing here yet<!----></div>
<footer><!----></footer>
</article>
Конструктор только запоминает копии props и slots, ничего не рисуя. Элемент появляется на первом render(). Дальше props и slots — обычные объекты: меняете то, что нужно, и снова зовёте render().
const follow = document.createElement('button');
follow.textContent = 'Follow';
card.props.name = 'Sam';
card.slots.actions = follow;
card.render() === card.element; // true
<article class="card card--compact">
<h2>Hello, Sam!</h2>
<div class="body">Nothing here yet<!----></div>
<footer><button>Follow</button><!----></footer>
</article>
Элемент тот же. А значит, обработчик, который вы однажды повесили через addEventListener, переживает любое количество обновлений, и фокус в поле ввода внутри карточки никуда не прыгает.
Пишется только то, что изменилось
Каждое место с плейсхолдером — текстовый узел или значение атрибута — запоминает свой исходный текст. render() пересчитывает его с текущими props и записывает в DOM, только если результат стал другим. Проверить это можно через MutationObserver:
const Tile = define(template); // <div class="tile tile--${trend}"><span>${label}</span><strong>${value}</strong></div>
const cpu = new Tile({ props: { label: 'CPU', value: '12%', trend: 'up' } });
document.body.append(cpu.render());
new MutationObserver((records) => { /* print every record */ })
.observe(cpu.element, { subtree: true, characterData: true, attributes: true, childList: true });
cpu.props.value = '48%';
cpu.render();
characterData #text 48%
Одна изменённая цифра — одна запись. Класс не тронут, подпись не тронута, ничего не пересоздано и деревья не сравнивались: сравнивать нечего, места известны с первого рендера.
Значения — это текст
card.props.name = '<b>bold</b>';
card.render();
Hello, <b>bold</b>!
Значение проходит через String() и вставляется как текст — <b> остаётся строкой и HTML-ом никогда не становится. Пропс, которого нет, а также undefined и null превращаются в пустую строку:
card.props.variant = null;
card.render();
card.element.getAttribute('class'); // 'card card--'
Тут видна обратная сторона: модификатор card-- повис без значения. Ошибки нет, это честная пустая строка. Если для класса или атрибута «нет значения» должно значить что-то своё, пусть это решит ваш код до того, как значение попадёт в props.
Слоты
В слот можно передать узел, строку или любой итерируемый набор из них: массив, NodeList, DocumentFragment. Строка вставляется как текст. Плейсхолдеры в содержимом слота не раскрываются — ${...} там остаётся как есть:
card.slots.default = 'Hi, ${name}';
<div class="body">Hi, ${name}<!----></div>
Пустой массив и отсутствие значения — разные вещи. [] опустошает слот. undefined или delete возвращают запасное содержимое — то, что было написано внутри <slot>:
card.slots.default = [];
card.render(); // <div class="body"><!----></div>
delete card.slots.default;
card.render(); // <div class="body">Nothing here yet<!----></div>
Узлы переносятся, а не копируются. Тот же <li>, отданный второму компоненту, уходит из первого:
const List = define(template); // <ul><slot></slot></ul>
const a = new List();
const b = new List();
a.slots.default = li; a.render();
b.slots.default = li; b.render();
<ul><!----></ul>
<ul><li>one</li><!----></ul>
<!----> в выводе — это сам слот. При первом рендере каждый <slot> заменяется пустым комментарием, и содержимое встаёт перед ним. Без такой метки, когда содержимое меняется, было бы непонятно, куда класть новое. На :empty комментарии не влияют, так что стили, которые на него рассчитывают, продолжают работать.
Список — это массив отрендеренных компонентов
Циклов в шаблоне нет. Список собирается из компонентов поменьше и отдаётся в слот массивом:
<template id="task"><li class="task task--${state}">${title}</li></template>
<template id="tasks"><ul class="tasks"><slot>No tasks</slot></ul></template>
const tasks = ['Buy bread', 'Call mom', 'Fix the tap']
.map((title) => new Task({ props: { title, state: 'open' } }));
const list = new TaskList();
list.slots.default = tasks.map((task) => task.render());
list.render();
tasks[1].props.state = 'done';
tasks.reverse();
list.slots.default = tasks.map((task) => task.render());
list.render();
<ul class="tasks"><li class="task task--open">Buy bread</li><li class="task task--open">Call mom</li><li class="task task--open">Fix the tap</li><!----></ul>
<ul class="tasks"><li class="task task--open">Fix the tap</li><li class="task task--done">Call mom</li><li class="task task--open">Buy bread</li><!----></ul>
Порядок поменялся, у второй задачи сменился класс, а элементы <li> всё те же — переставлены, а не построены заново. Каждый task.render() обновил свой элемент, и слот разложил их в новом порядке.
Где это ломается
Массив, изменённый на месте, не заметят. Слот перезаполняется, только когда его значение перестало быть тем же самым по Object.is. Тот же массив с новым элементом внутри — по-прежнему тот же массив:
const items = [];
card.slots.default = items;
card.render();
items.push('one');
card.render(); // <div class="body"><!----></div>
card.slots.default = [...items];
card.render(); // <div class="body">one<!----></div>
Первый render() после push не сделал ничего, и никакой ошибки об этом не будет. Именно поэтому в примере со списком каждый раз создаётся новый массив через map. Правило простое: в слот — всегда новый массив.
Неизвестный слот — ошибка, и элемент остаётся как был.
card.props.name = 'Nobody';
card.slots.sidebar = 'x';
card.render();
TemplateError: Unknown slot "sidebar"
В шаблоне слота sidebar нет. Обратите внимание: name поменялся в том же обновлении, но в элемент не попал. Всё содержимое сначала проверяется и разбирается и только потом пишется, так что обновление, которое бросило ошибку, не оставляет элемент наполовину обновлённым. Объявленный дважды слот с одним именем — та же TemplateError.
Имя слота с плейсхолдером вычисляется один раз. <slot name="${region}"> допустим: плейсхолдеры раскрываются раньше, чем ищутся слоты. Но происходит это при первом рендере, и если потом поменять region, слот не переименуется.
Когда в слот нужна разметка
Строка в слоте — это текст. Если нужен кусок HTML со значениями внутри, для этого есть тег html: он разбирает разметку во фрагмент, а каждое подставленное значение предварительно экранирует.
import { html } from 'tagshift';
const hint = '"quotes" & <tags>';
card.slots.actions = html`<button title="${hint}">${'<OK>'}</button>`;
card.render();
<button title=""quotes" & <tags>"><OK></button><!---->
Разметка из самого шаблонного литерала доверенная — её написали вы. Всё, что пришло через ${}, — нет.
А если разметку вы собираете где-то ещё, тот же механизм доступен отдельно:
import { escape } from 'tagshift';
escape(`<a href="x">Tom & Jerry's</a>`);
// '<a href="x">Tom & Jerry's</a>'
escape() годится для текста и для значения атрибута в кавычках. Для атрибута без кавычек или для URL в href его не хватит — он не про это.
Чего здесь нет
Ни условий и циклов в шаблоне, ни привязки событий, ни реактивности. render() никто за вас не вызовет: изменили props — вызовите сами.
Это обмен. Нет компилятора, нет своего синтаксиса и ничего, что следит за объектами за вашей спиной, — поэтому шаблон остаётся HTML-ом, который читается глазами, а всё, что происходит с элементом, происходит в ту строчку, где вы позвали render(). Взамен условие пишется в JavaScript до того, как значение попадёт в props, а список — массивом компонентов в слоте.
Если вам нужно больше, вам нужен фреймворк. Если нужна карточка, плитка или строка таблицы, которая обновляет себя на месте, — тут хватит полутора килобайт.
Исходники — https://github.com/JWo1F/tagshift, лицензия MIT.
Комментарии 0
Комментариев пока нет.