@chepchik/dom-template
v0.2.1
Published
Tiny mini-component system for the real DOM: mount an HTML string, collect [ref] elements into a typed object, and layer component()/state/composition on top — no Virtual DOM, no diffing, cleanup driven by AbortSignal.
Downloads
84
Maintainers
Readme
dom-template
Что: mountTemplate — помощник, который вставляет HTML-строку (шаблон)
в DOM-контейнер и собирает все элементы с атрибутом [ref] в типизированный
объект ссылок, и component() — надстроенная поверх него мини-компонентная
система (refs/props/state/lifecycle/cleanup).
Зачем: чтобы не писать руками кучу container.querySelector(...) после
вставки разметки — достаточно пометить нужные элементы атрибутом ref в
самом шаблоне, и получить готовые ссылки на них одним вызовом. component()
добавляет сверху жизненный цикл mount/update/destroy с автоматической отменой
side-эффектов через AbortSignal — но при этом остаётся тонким слоем над
настоящим DOM: никакого Virtual DOM и diff'а здесь нет — setup() мутирует
реальные узлы напрямую.
Установка
npm install @chepchik/dom-templateИспользование
import { mountTemplate } from "@chepchik/dom-template";
interface Refs {
title: HTMLHeadingElement;
button: HTMLButtonElement;
}
const { refs } = mountTemplate<Refs>(
container,
`
<h1 ref="title">Hello</h1>
<button ref="button">Click</button>
`,
);
refs.button.addEventListener("click", () => {
refs.title.textContent = "Clicked!";
});Списки ссылок: ref="name[]"
Если в шаблоне несколько элементов помечены одинаковым именем с суффиксом
[], они собираются в массив — удобно для повторяющихся блоков (строки
списка, карточки и т.п.):
interface Refs {
item: HTMLLIElement[];
}
const { refs } = mountTemplate<Refs>(
list,
`
<li ref="item[]">First</li>
<li ref="item[]">Second</li>
`,
);
refs.item.forEach((li) => li.classList.add("task"));Точка вставки: options.position
По умолчанию шаблон добавляется в конец контейнера ("append"). Доступны
также "prepend" (в начало) и "replace" (полностью заменить содержимое
контейнера):
mountTemplate(container, html, { position: "prepend" });
mountTemplate(container, html, { position: "replace" });Возвращаемые узлы: result.nodes
mountTemplate возвращает не только refs, но и nodes — массив корневых
узлов, вставленных в контейнер. Это удобно, если шаблон нужно позже удалить
целиком:
const { nodes } = mountTemplate(container, html);
// ...позже
nodes.forEach((node) => node.remove());Атрибут ref после вставки
По умолчанию mountTemplate удаляет атрибут ref у привязанных элементов
после сборки ссылок, чтобы он не оставался в финальном DOM. Чтобы оставить
атрибут как есть, передайте removeRefAttribute: false:
mountTemplate(container, html, { removeRefAttribute: false });⚠️ Безопасность
mountTemplate вставляет template как настоящий DOM через
Range.createContextualFragment. Это означает, что встроенные обработчики
событий (onclick="...") и другая исполняемая разметка из шаблона
выполнятся. Передавайте сюда только доверенные строки (статичные шаблоны
компонентов), а не значения, полученные напрямую от пользователя — иначе
это открывает XSS.
Компоненты: component()
component({ template, setup }) — фабрика мини-компонента поверх mountTemplate.
setup() вызывается один раз при монтировании и получает refs/props/state/signal —
никакого Virtual DOM и diff'а: setup() работает с настоящими DOM-узлами напрямую.
import { component } from "@chepchik/dom-template";
interface Refs {
name: HTMLElement;
button: HTMLButtonElement;
}
interface Props {
userId: string;
}
const UserCard = component<Refs, Props>({
template: `
<div class="card">
<span ref="name">…</span>
<button ref="button">Reload</button>
</div>
`,
setup({ refs, props, signal }) {
refs.name.textContent = `Loading user ${props.userId}…`;
fetch(`/api/users/${props.userId}`, { signal })
.then((res) => res.json())
.then((user) => {
refs.name.textContent = user.name;
})
.catch((err) => {
if (err.name === "AbortError") return; // компонент уже уничтожен — это ожидаемо
refs.name.textContent = "Failed to load";
});
refs.button.addEventListener("click", () => refs.name.textContent = "Reloading…", { signal });
},
onUpdate(props, { refs }) {
refs.name.textContent = `Loading user ${props.userId}…`;
},
});
const card = UserCard(container, { userId: "42" });
// позже, без remount'а:
card.update({ userId: "43" });
// при размонтировании:
card.destroy();Жизненный цикл: create → mount → setup → (update)* → destroy
UserCard(container, props)— монтирует шаблон (черезmountTemplate), создаёт собственныйAbortControllerдля экземпляра и вызываетsetup({ refs, props, signal, state, mountChild })ровно один раз.instance.update(nextProps)— мержитnextPropsв живой (мутируемый) объектpropsи вызываетonUpdate(props, ctx)(если он определён), не пересоздавая DOM/refs/AbortController.instance.destroy():- каскадно уничтожает всех детей, смонтированных через
mountChild(дети → родитель); - вызывает
abortController.abort()— сигнал, переданный вsetup(), становитсяaborted; - вызывает cleanup-функцию, возвращённую из
setup()(если она есть); - удаляет
nodesкомпонента из DOM.
destroy()идемпотентен — повторный вызов на уже уничтоженном экземпляре ничего не делает.update()на уже уничтоженном экземпляре бросает ошибку.- каскадно уничтожает всех детей, смонтированных через
Автоочистка side-эффектов через AbortSignal
Вместо того чтобы вручную писать removeEventListener/отменять запросы в cleanup-функции,
передавайте signal из setup() во все API, которые его поддерживают:
setup({ refs, signal }) {
refs.button.addEventListener("click", onClick, { signal });
fetch(url, { signal });
}При destroy() эти обработчики/запросы отменяются автоматически — signal абортится,
и браузер сам снимает слушатель / отклоняет запрос с AbortError.
⚠️ Ограничение: { signal } нативно поддерживают только API, которые сами это
реализуют (fetch, addEventListener — в достаточно свежих браузерах). "Голый"
setTimeout/setInterval signal не принимает — для них нужен явный паттерн-обёртка:
setup({ signal }) {
const id = setTimeout(() => { /* ... */ }, 1000);
signal.addEventListener("abort", () => clearTimeout(id));
}Для ресурсов, которые вообще не выражаются через AbortSignal (например, отписка от
сторонней библиотеки без поддержки signal), верните cleanup-функцию из setup() —
она будет вызвана при destroy() вместе с abort():
setup() {
const subscription = thirdPartyLib.subscribe(...);
return () => subscription.unsubscribe();
}state: точечная синхронизация без Virtual DOM
state в setup() — это фабрика createState, доступная прямо в контексте. Она
создаёт минимальный примитив состояния: get()/set()/subscribe(). Это не
реактивный шаблонизатор — state ничего не решает сам за вас, что перерисовывать.
Вы явно подписываете конкретный DOM-узел на конкретное значение:
setup({ refs, state, signal }) {
const count = state(0);
count.subscribe((value) => {
refs.counter.textContent = String(value);
}, { signal }); // автоотписка при destroy()
refs.increment.addEventListener("click", () => {
count.set((prev) => prev + 1);
}, { signal });
}subscribe() вызывается синхронно при каждом set(). Подписка, сделанная с опцией
{ signal }, автоматически снимается при аборте этого сигнала — то есть подписки,
созданные внутри setup() с ctx.signal, отписываются сами при component.destroy().
Если state используется вне setup() (например, в module-level сторе), отписку нужно
делать вручную — вызвав функцию, которую возвращает subscribe().
Композиция: mountChild
Компоненты можно вкладывать друг в друга. Внутри setup() доступен mountChild,
который монтирует дочерний компонент и регистрирует его для каскадного destroy():
const Avatar = component<{ img: HTMLImageElement }, { src: string }>({
template: `<img ref="img" />`,
setup({ refs, props }) {
refs.img.src = props.src;
},
});
const UserCard = component<{ avatarSlot: HTMLElement }, Props>({
template: `<div><div ref="avatarSlot"></div></div>`,
setup({ refs, props, mountChild }) {
mountChild(Avatar, refs.avatarSlot, { src: props.avatarUrl });
},
});При destroy() родителя все дети, смонтированные через mountChild, уничтожаются
первыми (порядок: дети → родитель), до вызова cleanup-функции и удаления узлов
самого родителя.
⚠️ Если дочерний компонент монтируется вручную (напрямую вызовом его фабрики, а не
через mountChild), это не баг, а осознанная граница ответственности: mountChild
никогда об этом экземпляре не узнает, каскадный destroy() его не затронет — автор
компонента сам обязан вызвать child.destroy() (например, из cleanup-функции,
возвращённой setup()). mountChild — безопасный путь по умолчанию; ручное монтирование
используйте только когда вам нужен контроль над жизненным циклом ребёнка отдельно
от родителя.
API
mountTemplate<TRefs>(container, template, options?): { refs: TRefs; nodes: ChildNode[] }
container: HTMLElement— куда вставить разметку.template: string— доверенная HTML-строка.options?: MountTemplateOptionsposition?: "append" | "prepend" | "replace"— куда вставить фрагмент относительно контейнера. По умолчанию"append".removeRefAttribute?: boolean— удалять атрибут[ref]после сборки ссылок. По умолчаниюtrue.
- Возвращает:
refs: TRefs— объект, где ключи — имена из атрибутовref, а значения — соответствующие DOM-элементы (ref="name") или массивы элементов (ref="name[]").nodes: ChildNode[]— корневые узлы, вставленные в контейнер.
Если один и тот же одиночный ref встречается в шаблоне несколько раз, или
одно и то же имя используется и как одиночная ссылка, и как список —
mountTemplate бросает ошибку.
component<TRefs, TProps>(definition): ComponentFactory<TProps>
definition.template: string— та же семантика[ref]/[ref][], что и вmountTemplate.definition.setup(ctx: SetupContext<TRefs, TProps>): void | (() => void)— вызывается один раз при монтировании; может вернуть cleanup-функцию.definition.onUpdate?(props: TProps, ctx): void— вызывается изinstance.update(props).ctx: SetupContext<TRefs, TProps>:{ refs, props, signal, state, mountChild }.- Возвращает фабрику
(container: HTMLElement, props: TProps) => ComponentInstance<TProps>. ComponentInstance<TProps>:{ nodes, update(nextProps: Partial<TProps>), destroy() }.
createState<T>(initial: T): State<T>
- Возвращает
{ get(), set(next), subscribe(listener, options?) }. set(next: T | ((prev: T) => T))— синхронно уведомляет всех подписчиков.subscribe(listener, { signal? })— возвращает функцию отписки; если переданsignal, подписка снимается автоматически при его аборте.
