В этой статье, я хочу показать базовые приемы работы с HTML-таблицами при использовании библиотеки Symbiote.js. Но, сперва, напомню, что это, вообще, такое.
Основы
Две главные вещи, которые нужно знать о Symbiote.js:
1) Symbiote.js - это библиотека для создания интерфейсных компонентов любой сложности, основанная на стандарте Custom Elements. Каждый созданный вами компонент - это полноправный DOM-элемент, со своим собственным API, который будет доступен через самые обычные селекторы и может быть использован совместно с, практически, любым набором веб-технологий. Он полный агностик, и будет дружить как с вашими фронтенд-либами, так и с любым подходом по созданию HTML-документов на сервере.
2) Symbiote.js использует для описания своих шаблонов формат, который может быть преобразован в элементы DOM стандартным парсером браузера без дополнительной обработки. То есть, это, буквально, фрагмент HTML, в виде строки. Таким образом, Symbiote.js может брать под контроль предварительно созданные участки документа или манипулировать шаблонами с очень высокой степенью гибкости. Шаблон может быть сформирован миллионом способов и вам не нужно думать о том, что это какой-то специальный объект в JS-рантайме, который зависит от контекста самого компонента или имеет какие-то иные ограничения. Наверное, это основное отличие от таких решений, как Lit от разработчиков из Google.
Рисуем таблицу
Итак, представим, что у нас есть некие табличные данные и мы хотим визуализировать их на веб-странице. Данные могут меняться в реальном времени, и их может быть, относительно, много. Таким образом, наше решение должно рисовать таблицу достаточно быстро и уметь вносить изменения точечно, без лишних манипуляций с элементами.
Для решения этой задачи, сперва, мы установим нашу базовую зависимость:
npm i @symbiotejs/symbiote
Затем, создадим кастомный тег:
import Symbiote from '@symbiotejs/symbiote'; class MyTable extends Symbiote {} MyTable.reg('my-table');
Теперь, если браузер встретит в разметке тег my-table, он сам вызовет конструктор класса MyTable и, та��им образом, инициализирует наш компонент. Вам не нужно специально следить за тем, когда ваш тег появился или исчез из DOM, весь контроль жизненного цикла инкапсулирован в наш класс и находится полностью под нашим контролем.
Добавляем таблицу:
import Symbiote from '@symbiotejs/symbiote'; class MyTable extends Symbiote { init$ = { tableData: [ { rowNumber: 1, date: Date.now(), }, { rowNumber: 2, date: Date.now(), }, { rowNumber: 3, date: Date.now(), }, ], } } MyTable.template = /*html*/ ` <table itemize="tableData" item-tag="table-row"></table> `; MyTable.reg('my-table');
Здесь мы инициализировали тестовые данные и создали шаблон с таблицей-контейнером, в которой они должны появиться. Шаблон - это самый обычный шаблонный литерал JS, который можно импортировать из другого файла, или даже получить по запросу с сервера.
Давайте взглянем на него подробнее:
<table itemize="tableData" item-tag="table-row"></table>
Как видите, привязка данных таблицы осуществлена с помощью атрибута itemize, и, дополнительно, указан тег, который будет использован для создания каждого элемента списка - table-row.
Itemize API в Symbiote.js работает так:
Для каждого элемента списка создается полноценный компонент со своим локальным стейтом
Если в реестре браузера уже зарегистрирован кастомный элемент с таким именем - он будет использован для вывода элемента списка
Если тег элемента не указан - он будет создан автоматически с уникальным, для данного списка, именем (например:
sym-1) и режимом отображенияdisplay: contentsЕсли имя тега указано, но не зарегистрировано - тег будет создан и зарегистрирован автоматически с указанным именем
Поскольку элемент списка - это полноценный компонент, его шаблон поддерживает вложенные списки. Таким образом, вы можете отобразить сложную древовидную структуру
Стоит остановиться на важной особенности рендеринга стандартных таблиц браузерами: если в теле таблицы браузер встречает теги, отличные от стандартных "табличных" (таких как tr, td), он автоматически создаст дополнительные теги tbody, которые нам будут совсем не нужны в итоговой разметке. Поэтому, если в качестве контейнера списка мы используем стандартный тег table, для отображения элементов нам лучше будет создать отдельный тег вручную. Также, нам это может понадобиться, если мы хотим реализовать там какую-то сложную логику.
Создаём табличную строку:
import Symbiote from '@symbiotejs/symbiote'; class TableRow extends Symbiote { renderCallback() { // Some custom logic: this.onclick = () => { this.classList.toggle('selected'); }; } } TableRow.rootStyles = /*css*/ ` table-row { display: table-row; &.selected { background-color: rgba(255, 0, 200, .3); } } `; TableRow.template = /*html*/ ` <td>Row number: {{rowNumber}}</td> <td>Date: {{date}}</td> `; TableRow.reg('table-row');
Локальный стейт для табличной строки будет создан автоматически, на основе исходных данных нашего списка, поэтому нам можно опустить этот момент. Но, при желании, вы можете создать стейт вручную и добавить в него что угодно.
Также, мы добавили стиль нашего элемента списка, чтобы он расценивался браузером именно как табличная строка. Это можно делать в обычном внешнем CSS, в собственных стилях элемента списка (как в нашем примере), или в сти��ях компонента-контейнера. Все зависит только от ваших целей, привычек и предпочтений. В нашем случае, был использован интерфейс rootStyles, который позволяет задавать правила стилизации в общем теневом контексте (если вы используете Shadow DOM), либо, в общем контексте всего документа. Таким образом, если вам понадобиться включить теневой режим (вдруг приспичит?) где-то в родительском компоненте - все будет продолжать работать, как было задумано. Кстати, зацените современный CSS nesting: да, теперь так можно.
Наш базовый пример готов:
import Symbiote from '@symbiotejs/symbiote'; // Table row component: class TableRow extends Symbiote { // Some custom logic: renderCallback() { this.onclick = () => { this.classList.toggle('selected'); }; } } TableRow.rootStyles = /*css*/ ` table-row { display: table-row; &.selected { background-color: rgba(255, 0, 200, .3); } } `; TableRow.template = /*html*/ ` <td>Row number: {{rowNumber}}</td> <td>Date: {{date}}</td> `; TableRow.reg('table-row'); // Table component: class MyTable extends Symbiote { init$ = { tableData: [ { rowNumber: 1, date: Date.now(), }, { rowNumber: 2, date: Date.now(), }, { rowNumber: 3, date: Date.now(), }, ], } } MyTable.template = /*html*/ ` <table itemize="tableData" item-tag="table-row"></table> `; MyTable.reg('my-table');
Теперь, при использовании тега my-table где-либо в документе, мы увидим следующий результат:
<my-table> <table> <table-row> <td>Row number: 1</td> <td>Date: 1714405915233</td> </table-row> <table-row> <td>Row number: 2</td> <td>Date: 1714405915233</td> </table-row> <table-row> <td>Row number: 3</td> <td>Date: 1714405915233</td> </table-row> </table> </my-table>
Альтернативный вариант
Теперь, сделаем почти тоже самое, но гораздо короче:
import Symbiote from '@symbiotejs/symbiote'; class MyTable extends Symbiote { init$ = { tableData: [], select: (e) => { e.target?.closest('table-row')?.classList.toggle('selected'); }, } } MyTable.template = /*html*/ ` <table-css bind="onclick: select" itemize="tableData" item-tag="table-row"> <td-css>Row number: {{rowNumber}}</td-css> <td-css>Date: {{date}}</td-css> </table-css> `; MyTable.reg('my-table');
В этом случае, мы не используем нативный тег table, а потому, можем сократить наше решение на определение одного компонента. Как видите, теперь шаблон элемента списка определен внутри нашего контейнера.
Для придания большей табличности нашей таблице, используем СSS:
table-css { display: table; border-spacing: 2px; border-collapse: separate; table-row { display: table-row; &.selected { background-color: rgba(255, 0, 200, .3); } td-css { display: table-cell; border: 1px solid currentColor; padding: 4px; } } }
Передаем данные:
document.querySelector('my-table').$.tableData = [ { rowNumber: 1, date: Date.now(), }, { rowNumber: 2, date: Date.now(), }, { rowNumber: 3, date: Date.now(), }, ];
Получаем:
<my-table> <table-css> <table-row> <td-css>Row number: 1</td-css> <td-css>Date: 1714405915233</td-css> </table-row> <table-row> <td-css>Row number: 2</td-css> <td-css>Date: 1714405915233</td-css> </table-row> <table-row> <td-css>Row number: 3</td-css> <td-css>Date: 1714405915233</td-css> </table-row> </table-css> </my-table>
Вуаля. Обновлять данные можно динамически (как и показано в примере), при этом, изменения DOM будут вноситься только туда, где изменились сами данные, включая все вложенные списки, если они есть. Для передачи данных мы использовали интерфейс $, который дает доступ к управлению состоянием, и может использоваться одинаково как во внутренней логике компонентов, так и в качестве внешнего API.
Сами данные можно готовить как в виде массива объектов, так и в виде объекта со структурой ключ-объект. Во втором случае, ключи будут доступны для элементов по сервисному ключу _KEY_. Например:
<div itemize="userList" item-tag="user-card"> <div>ID: {{_KEY_}}</div> <div>{{firstName}}</div> <div>{{secondName}}</div> </div>
Последний штрих
Помните мы установили Symbiote.js через npm? Это было нужно, прежде всего, для нормальной работы инструментов окружения разработки, таких как TypeScript. Но для использования общих зависимостей в среде исполнения непосредственно, я рекомендую использовать Import Maps и не включать их (общие зависимости) в вашу сборку. Так вы можете исключить дублирование кода (и, что менее очевидно, контекстов модулей) в разных сегментах вашей сложной UI-системы.
Давайте добавим запись в карту импортов вашего HTML-документа:
{ "imports": { "@symbiotejs/symbiote": "https://esm.run/@symbiotejs/symbiote" } }
Готово. С живым примером вы можете поиграть тут: https://symbiotejs.org/2x/playground/table-css/
Спасибо за внимание.
