endorphin
v0.11.0
Published
Component-based UI rendering library
Downloads
32
Maintainers
Readme
EndorphinJS
EndorphinJS — это библиотека для построения пользовательских интерфейсов с помощью DOM-компонентов. В основе библиотеки лежит декларативный шаблонизатор Endorphin, цель которого обеспечить инкрементальное обновление UI в браузере, а также рендеринг на стороне сервера на любом языке программирования.
Disclaimer: документация ещё очень поверхностная и сырая и предназначена скорее для энтузиастов, разделяющих ценности и проблемы, которые пытается решить Endorphin. Позже появится полноценный сайт с примерами и лучшими практиками.
Основные возможности
- Декларативный шаблонизатор, имеющий JavaScript-подобный синтаксис выражений для удобного обращения к данным. Синтаксис выражений намеренно ограничен по сравнению с JavaScript, но также имеет расширенную семантику для работы с данными.
- В отличие от React/Vue.js/Svelte, каждый Endorphin-компонент имеет реальное представление в виде DOM-элемента. Поэтому Endorphin-компоненты больше похожи на веб-компоненты, но ими не являются (об этом ниже). Такой подход радикально упрощает отладку и стилизацию компонентов: все они доступны прямо в инструментах разработчика любого браузера, в том числе через протокол удалённой отладки.
- Отсутствие стороннего runtime для работы приложения на EndorphinJS: код, необходимый для работы приложения, определяется на этапе компиляции и внедряется непосредственно в приложение. Сам runtime достаточно компактный: вес всего кода — около 6 КБ (gzip).
- Изоляция CSS: на этапе сборки весь CSS компонента полностью изолируется и применяется только к своему компоненту.
- Очень быстрое обновление UI: никаких Virtual DOM, шаблон компонента анализируется на этапе сборки и для него генерируется код, который обновляет только изменяемые части шаблона. Сгенерированный код оптимизирован под особенности JIT-компиляторов современных JS-движков для большей производительности.
- Endorphin-компоненты не скрывают, а наоборот, пропагандируют использование Web API: вы можете обращаться к содержимому компонента как к любому содержимому DOM-элемента, а также манипулировать содержимым. Сам Endorphin работает только с теми данными, которые сам создал. Это значит, что вы можете манипулировать DOM-элементами компонента (в разумных пределах, конечно) и не боятся, что следующий цикл перерисовки компонента всё отменит.
- Встроенная поддержка анимаций появления и удаления элемента на основе CSS Animations.
- Endorphin-приложение можно безопасно вставлять на любой сайт: за счёт полной изоляции и отсутствия стороннего runtime можно быть уверенным, что приложение никак не повлияет на остальные части сайта.
Первое знакомство
Давайте создадим наш первый компонент на Endorphin:
<!-- my-component.html -->
<style>
button {
appearance: none;
display: inline-block;
background: none;
border: 3px solid blue;
padding: 5px;
}
</style>
<template>
<button on:click={ #count++ }>Click me</button>
<e:if test={#count}>
<p>Clicked { #count } { #count !== 1 ? 'times' : 'time' }</p>
</e:if>
</template>
<script>
export function state() {
return { count: 0 }
}
export function didMount(component) {
console.log('Mounted component', component.nodeName);
}
</script>
И создадим приложение, которое вставляет этот компонент на страницу:
// app.js
import endorphin from 'endorphin';
import * as MyComponent from './my-component.html';
endorphin('my-component', MyComponent, {
target: document.body
});
После сборки мы получим приложение с кнопкой Click me, клик на которую будет менять надпись с количеством кликов. Если посмотреть через DevTools, то вы увидите на странице элемент <my-component>
и его содержимое. Из приведённого примера можно узнать следующее:
- Компонент описывается стандартными HTML-тэгами: стили описываются в тэге
<style>
или подключаются через<link rel="stylesheet" />
, шаблон описывается в тэге<template>
, а поведение — в тэге<script>
либо подключается из стороннего файла через<script src="...">
. - Имя файла используется как имя DOM-компонента. Так как Endorphin-компоненты идейно похожи на веб-компоненты, имя файла должно содержать дефис, однако это поведение можно переопределить (см. раздел Вложенные компоненты).
- В стилях можно безопасно использовать в том числе и тэги для стилизации: за счёт изоляции можно быть уверенным, что стили для
button
изmy-component.html
никак не повлияют на кнопку изother-component.html
. Стандартный CSS содержит несколько расширений, позволяющих управлять изоляцией (см. раздел CSS). - Шаблон (как и всё описание компонента) использует XML-подобный синтаксис. Это означает, что все тэги должны быть закрыты (
<p></p>
) либо иметь закрывающий слэш в конце (<br />
). При этом, в отличие от XML, можно не экранировать спецсимволы вроде<
и>
, а также не обязательно использовать кавычки для значений атрибутов. - Контрольные инструкции для описания динамических частей шаблона также описываются XML-тэгами, как правило, с префиксом
e:
. Динамические выражения указываются внутри фигурных скобок:{ #count }
. Динамические значения атрибутов пишутся какname={...}
, однако если ваш редактор/IDE не понимает такой синтаксис, можно писатьname="{...}"
. - Поведение компонента описывается в виде ES-модуля: вы экспортируете объекты и функции, которые известны Endorphin runtime. В экспортируемые функции первым аргументом всегда (кроме некоторых случаев с обработчиками событий) передаётся экземпляр компонента (DOM-элемент), которым можно управлять. Таким образом, Endorphin продвигает функциональный подход к описанию поведения компонента и избавляет от множества проблем с
this
. - У компонента есть несколько источников данных: props (внешний контракт, передаётся в компонент снаружи), state (внутренний контракт, управляется самим компонентом) и store (данные приложения). Для удобства внутри шаблона используется используется специальный префикс для каждого источника данных:
- Для обращения к значению из props достаточно написать
propName
, то есть обращаться как к глобальной переменной. - Для обращения к state используется префикс
#
(по аналогии с приватными свойствами классов в JS):#stateName
. - Для обращения к store используется префикс
$
:$storeName
.
- Для обращения к значению из props достаточно написать
Шаблон
Как было отмечено выше, шаблон представляет из себя XML-подобный со специальными тэгами, которые управляют содержимым компонента.
Элемент
Элементы в шаблоне описываются так же, как и в HTML: с помощью тэгов и атрибутов. Тэги обязательно должны быть закрыты либо с помощью закрывающего тэга, либо с помощью закрывающего слэша:
<h1>My header</h1>
<p class="intro">First<br />paragraph</p>
Атрибуты могут иметь следующий вид:
name="value"
— значение в кавычках, можно указывать либо ординарные, либо двойные кавычки;name=value
— короткая запись значений, состоящих из одного слова, кавычки можно не использовать;name={expr}
илиname="{expr}" — значением атрибута является результат работы выражения
expr`.name="foo {expr} bar"
– интерполяция строк и выражений: в указанной строке значения в фигурных скобках заменяются на результат выражения. Аналогичный результат можно получить с помощью Template Strings:name={`foo ${expr} bar`}
.name
— булевое значение атрибута, аналогname={true}
.{name}
— сокращённая запись видаname={name}
, такая же запись доступна и для state и store:{#enabled}
→enabled={#enabled}
,{$data}
→data={$data}
.
Endorphin различает два типа тэгов: обычные HTML-элементы и DOM-компоненты. Последние имеют дефис в названии (по аналогии с веб-компонентами):
<h1>My header</h1>
<my-component enabled data={items} />
У обычных элементов и DOM-компонентов немного отличается поведение атрибутов:
- Для обычных элементов атрибуты выводятся как обычные HTML-атрибуты, но для компонентов атрибуты — это props. Для удобства разработки props также отображаются как HTML-атрибуты у сгенерированного элемента. А для того, чтобы соответствовать семантике HTML, названия атрибутов конвертируются из camelCase в kebab-case:
<my-component someItems={items}>
в DevTools отобразится как<my-component some-items="{}">
. HTML-атрибуты у DOM-компонентов носят чисто информативный характер и используются в CSS-стилизации и отладки кода, а сами данные доступны в свойстве.props
элемента. У обычных элементов атрибуты являются источником данных, то есть влияют на работу элемента. - Для обоих типов элементов выполняется приведение значений атрибутов для отображения в HTML:
- строки и числа отображаются как есть;
- функция отображается как
𝑓
; - массив отображается как
[]
; - прочие непустые значения отображаются как
{}
; - для булевых значений
true
выводит атрибут с пустым значением (<input disabled={true} />
→<input disabled />
),false
не выводит атрибут совсем (<input disabled={false} />
→<input />
). - значения
null
иundefined
не выводят атрибут совсем; - у DOM-компонентов для значений атрибутов без кавычек выполняется простое приведение типов для чисел,
true
,false
,null
иundefined
:<my-item foo=1 bar=true type=null />
равнозначно<my-item foo={1} bar />
.
Директивы
Помимо обычных атрибутов, элементам можно указать директивы. Директива – это атрибут с особым поведением, имя которого начинается с префикса.
class:
Директива class:
добавляет элементу указанный класс, если условие, указанное в значении директивы, истинно. Если значение отсутствует, класс добавляется всегда.
<div class="foo" class:bar class:baz={enabled != null}></div>
Следует помнить, что директива class:
именно добавляет класс к имеющимся, в то время как атрибут class
полностью его заменяет:
<div class:foo class:bar class:baz={enabled != null}>
<!-- Всегда будет выводить <div class="abc"> -->
<e:attribute class="abc"/>
</div>
ref:
Добавляет в текущий DOM-компонент ссылку на указанный элемент:
<template>
<div ref:container></div>
</template>
<script>
export function didRender(component) {
console.log(component.refs.container); // <div>
}
</script>
Также может быть указан в виде атрибута: ref="container"
→ ref:container
. Значением атрибута может быть выражение, которое должно вернуть либо имя рефа, либо null
, если ссылку надо удалить:
<div ref={enabled ? 'container' : null}></div>
on:
Добавляет событие с указанным названием элементу. Значением директивы всегда должно быть выражение. В качестве обработчика события указывается функция, экспортируемая из поведения компонента:
<template>
<button on:click={handleClick}>Click me</button>
</template>
<script>
export function handleClick(component, event, target) {
console.log('Clicked on', target.nodeName); // Clicked on BUTTON
}
</script>
В обработчик всегда передаются следующие аргументы:
component
— текущий экземпляр компонента;event
— событие;target
— элемент, к которому было привязано событие.
Дополнительно в обработчике можно передавать произвольные аргументы, они будут добавлены в начало списка аргументов обработчика:
<template>
<button on:click={handleClick('show', 1)}>Click me</button>
</template>
<script>
export function handleClick(action, count, component, event, target) {
event.preventDefault();
console.log('Run %s action %d time(s) on %s', action, count, target.nodeName);
// Run show action 1 time(s) on BUTTON
}
</script>
В качестве обработчика события можно указать arrow function, в которую первым аргументом передаётся объект события:
<template>
<button on:click={evt => moveTo(evt.pageX, evt.pageY)}>Click me</button>
</template>
<script>
export function moveTo(x, y) {
console.log('Move to %d, %d', x, y);
}
</script>
Обработчики событий — это единственное место в шаблоне, где разрешено присвоение в state и store:
<template>
<button on:click={#enabled = !#enabled}>Click me</button>
<e:if test={#enabled}>
Block is enabled
</e:if>
</template>
При объявлении события можно дополнительно указывать модификаторы stop
(вызовет event.stopPropagation()
) и prevent
(вызовет event.preventDefault()
):
<button
on:click:stop
on:mousemove:prevent={handleMouseMove}>
Click me
</button>
animate:
Указывает CSS-анимацию на добавление (in
) или удаление (out
) элемента. Если указана анимация удаления, сам элемент очистится и удалиться только после завершения указанной анимации.
В качестве значения директива принимает значение CSS-свойства animation
: название анимации, длительность, задержка, функция изинга и т.д.
<template>
<section
animate:in="show-block 0.3s ease-out"
animate:out="hide-block 0.2s ease-in"
e:if={#enabled}>
Lorem ipsum dolor sit amet.
</section>
</template>
<style>
@keyframes show-block {
from {
transform: scale(0.5);
opacity: 0;
}
}
@keyframes hide-block {
to {
transform: scale(0.5) translateY(30%);
opacity: 0;
}
}
</style>
Так как весь CSS (в том числе анимации) изолируются, возможна ситуация, что вы дублируете описания одних и тех же анимаций между компонентами. Чтобы избежать этого, при указании названия анимации можно использовать префикс global:
— в этом случае будет использована анимация без изоляции, определённая где-нибудь в другом месте. В разделе про CSS вы узнаете, что для отмены изоляции CSS нужно использовать @media global
:
<template>
<section
animate:in="global:show-block 0.3s ease-out"
animate:out="global:hide-block 0.2s ease-in"
e:if={#enabled}>
Lorem ipsum dolor sit amet.
</section>
</template>
<style>
@media global {
@keyframes show-block {
from {
transform: scale(0.5);
opacity: 0;
}
}
@keyframes hide-block {
to {
transform: scale(0.5) translateY(30%);
opacity: 0;
}
}
}
</style>
В текущей реализации нет проверки, определена ли CSS-анимация с указанным названием. Это означает, что если на
animate:out
вы укажете название анимации, которая не была объявлена, элемент и его содержимое никогда не удалится, так как рантайм будет ожидать событиеanimationend
для выполнения очистки и это событие никогда не произойдёт. В будущих версиях эта проблема будет исправлена.
use:
Директива use:action
выполняет функцию action
в момент создания элемента. В качестве первого аргумента action
передаётся элемент, у которого указана директива. Функция может вернуть объект с методом destroy()
, который вызовется в момент удаления элемента:
<template>
<img src="image.png" use:checkLoad e:if={visible} />
</template>
<script>
export function checkLoad(elem) {
const onLoad = () => console.log('image loaded);
elem.addEventListener('load', onLoad);
return {
destroy() {
elem.removeEventListener('load', onLoad);
}
}
}
</script>
Дополнительно в качестве значения директивы можно передать произвольное значение и вернуть из action
объект с методом update
: этот метод будет вызываться каждый раз, когда указанное значение поменяется:
<template>
<img src="image.png" use:checkLoad={#visible} e:if={visible} />
</template>
<script>
export function checkLoad(elem, param) {
const onLoad = () => console.log('image loaded);
elem.addEventListener('load', onLoad);
return {
update(param) {
console.log('param updated', param);
},
destroy() {
elem.removeEventListener('load', onLoad);
}
}
}
</script>
Текст
Текстовые значения описываются так же, как и в HTML. Для отображения результатов выражений используются фигурные скобки:
Hello { greeting }!
Если фигурные скобки надо вывести в качестве текста, достаточно заменить их соответствующими HTML entity:
Hello { greeting }!
<e:variable>
(or alias <e:var>
)
Создаёт локальные переменные шаблона. Именем переменной является название атрибута в <e:variable>
. Для обращения к локальной переменной в шаблоне используется префикс @
:
<e:variable sum={a + b} enabled={isEnabled != null} />
<e:if test={@enabled}>
Sum is { @sum }
</e:if>
<e:if>
Выводит содержимое, если условие истинно.
test
— выражение для проверки.
<e:if test={a > 1}>
<p><code>a</code> is greater than 1</p>
</e:if>
Для удобства, если выводить нужно только один элемент, условие можно записать как директиву e:if
у элемента:
<p e:if={a > 1}><code>a</code> is greater than 1</p>
<e:choose>
/<e:when>
/<e:otherwise>
(or alias <e:switch>
/<e:case>
/<e:default>
)
Аналог if
/else if
/else
: внутри элемента <e:choose>
перечисляются секции <e:when test={...}>
, из которых выполнится первая, в которой условие атрибута test
истинной. Если ни одно из условий не было истинным, сработает секция <e:otherwise>
:
<e:choose>
<e:when test={#color === 'red'}>Color is red</e:when>
<e:when test={#color === 'blue' || #color === 'green'}>Color is blue or green</e:when>
<e:otherwise>Unknown color</e:otherwise>
</e:choose>
<e:for-each>
Выводит содержимое для каждого элемента из полученной коллекции.
select
— выражение, которое должно вернуть коллекцию для интерации. Коллекция определяется по наличию метода.forEach
у результата, то есть это может быть массив,Map
,Set
или любой другой объект, поддерживающий семантику.forEach
коллекций. Если результат выражения не содержит этот метод, цикл выполнится один раз для этого значения.- [
key
] — выражение, которое должно возвращать строковый ключ для текущего элемента. При наличии этого ключа сгенерированный результат «привязывается» к элементу с этим ключом. В этом случае при пересортировке данных в коллекции гарантируется, что именно эти DOM-элементы, сгенерированные на прошлом шаге отрисовки, будут использоваться для отрисовки этого же элемента. В основном это используется вместе с анимациями, когда нужно гарантировать идентичность элементов при перерисовке, а также в некоторых случаях может повысить производительность.
Для каждого элемента коллекции создаётся три локальные переменные:
@value
— значение элемента коллекции@key
— ключ элемента в коллекции@index
— порядковый номер элемента в коллекции, начиная с 0 (для массива это значение равно@key
).
<ul>
<e:for-each select={items}>
<li value={@key}>Value is { @value }<li>
</e:for-each>
</ul>
Использование key
:
<ul>
<e:for-each select={items} key={@value.id}>
<li value={@key}>{ @value.id }: { @value.name }<li>
</e:for-each>
</ul>
<e:attribute>
(or alias <e:attr>
)
Выводит либо заменяет указанные атрибуты у родительского элемента:
<div title="Section">
<e:attribute class="block" title="Block" />
</div>
Эту инструкцию удобно использовать, когда некоторые атрибуты нужно вывести или удалить в зависимости от условий, а также для удобной организации кода в <e:choose>
блоках:
<my-component data={items} enabled>
<!-- Меняем занчение `enabled` на `false` если `data != 'foo'` -->
<e:attribute enabled=false e:if={data != 'foo'} />
<e:choose>
<e:when test={type === 'block'}">
<!--
Организуем значение `data` родительского элемента и его содержимое
в единый логический блок
-->
<e:attribute data={blockItems} />
<p>This is block</p>
</e:when>
<e:when test={type === 'hidden'}">
<!--
Удаляем значение атрибута `data` у родительского элемента,
также в едином логическом блоке
-->
<e:attribute data=null />
<div>This block is hidden</div>
</e:when>
</e:choose>
</my-component>
В блоке <e:attribute>
, помимо самих атрибутов, можно использовать директивы class:
и on:
.
<e:add-class>
Добавляет указанный класс (или несколько классов) родительскому элементу. Как правило, используется для добавления динамических классов, для которых нужен результат выражения:
<div>
<e:add-class>foo bar-{#bar + 1}</e:add-class>
</div>
Для статических классов удобнее использовать директиву class:
у элемента.
Выражения
Выражения в шаблонах представляют собой обычные JavaScript-выражения но со следующими важными изменениями.
Возможности выражений намеренно ограничены подмножеством, необходимым для получения данных. То есть вы можете обращаться к свойствам объектов, выполнять над ними логические и математические операции, но не сможете, например, создать класс или генератор (а оно вам надо в шаблонах?). Это сделано для того, чтобы ту же самую семантику выражений можно было повторить на любом языке программирования, например, Java, Python, Go и т.д.
Вызов методов внутри объектов допустим, но это не рекомендуется, так как правильно это реализовать для SSR на любом языке программирования будет достаточно проблематично. Например, вот такое выражение будет работать в браузере и SSR на JS, но не будет работать, скажем, на Go SSR, так как для этого нужно будет реализовать целый JS-интерпретатор, чтобы определять тип объекта и его методы:
<e:for-each select={items.slice().sort((a, b) => a.pos > b.pos)}>
...
</e:for-each>
Поэтому задача синтаксиса выражений в Endorphin — это покрыть 90% нужд разработчика, а остальные 10% — с помощью хэлперов. Хэлпер — это функция, которая имеет реализацию и на JS (для браузера), и на языке для SSR. Подробности про хэлперы появятся позже.
Обработчики событий — единственное место, где можно пользоваться JS без указанных выше ограничений, так как этот код будет работать только в браузере.
На данный момент выражения в Endorphin обладают следующими возможностями:
- Все «глобальные» переменные считаются свойствами (props) компонента. То есть выражение
{enabled}
, по сути, обращается кcomponent.props.enabled
. Для обращения к state и store компонента используются префиксы#
и$
соответственно:#enabled
,$config.user.admin
и т.д. - В названиях переменных и свойствах допустимо использование дефиса для лучшей интеграцией с HTML:
$config.current-user.active
,my-prop.list
. Для операции вычитания дефис (знак минуса) нужно отделять пробелами:prop1 - prop2
. - Все обращения к свойствам и методам абсолютно безопасны. Вы можете написать так и не переживать, что какого-то объекта (например,
my
илиnested
) не будет существовать: такое выражение просто вернётundefined
.
<e:if test={my.deeply.nested.prop}>
...
</e:if>
- Для поиска элемента в коллекции можно использовать синтаксис
arr[item => item.enabled]
, что является аналогомarr.find(item => item.enabled)
для массива, но работает в том числе и дляMap
,Set
или любого другого объекта, у которого есть метод.forEach()
. Это рекомендуемый синтаксис для поиска элемента, так как в AST шаблона для него выделяется специальный узел, благодаря чему будет легче реализовать поддержку SSR для всех языков. - Аналогично, для фильтрации коллекции рекомендуется использовать синтаксис
arr[[item => item.enabled]]
(аналогarr.filter(item => item.enabled)
), то есть обрамить стрелочную функцию массивом.
CSS
Для стилизации содержимого компонента используется обычный CSS с добавлением селекторов веб-компонентов, таких как :host()
и ::slotted
. Весь CSS компонента автоматически изолируется и применяется только к текущему компоненту: теперь вы можете безопасно стилизовать обычные тэги и не переживать, что CSS-правила пересекутся с другим компонентом. Например, вот такой CSS:
ul {
padding: 10px;
}
ul li {
}
после компиляции превратится примерно в такой код:
ul[endo4tueq] {
padding: 10px;
}
ul[endo4tueq] li[endo4tueq] {
}
Для каждого компонента высчитывается уникальный хэш на этапе компиляции, который добавляется к селекторам и к элементам шаблона.
В дополнение к стандартному CSS, компилятор понимает следующие селекторы и правила:
:host
spec
Используется для стилизации самого DOM-компонента. Можно использовать как :host
(стили для DOM-компонента), так и :host(sel)
(стили для DOM-компонента, если к нему применён селектор sel
).
:host {
display: block;
padding: 10px;
}
:host(.selected) {
background: red;
}
:host-context()
spec
Свойства внутри :host-context(sel)
применяются к DOM-компоненту только в том случае, если он находится внутри элемента, к которому применим селектор sel
.
:host {
background: red;
}
:host-context(main article) {
background: blue;
}
<my-component /> <!-- bg: red -->
<main>
<article>
<my-component /> <!-- bg: blue -->
</article>
</main>
::slotted(sel)
spec
Применяется к элементам sel
, которые были переданы в текущий компонент снаружи через слот. К данным, указанным в
слоте по умолчанию, правила не применяются.
::slotted(p) {
color: red;
}
@media local
Внутри правила @media local
указываются правила, которые должны применятся каскадом от текущего компонента. Для этих правил не выполняется изоляция, им только добавляется селектор текущего компонента:
@media local {
p {
margin: 1em;
}
blockquote {
padding: 10px;
}
}
...сгенерирует примерно такой код:
[endo4tueq-host] p {
margin: 1em;
}
[endo4tueq-host] blockquote {
padding: 10px;
}
Это правило удобно применять, когда нужно, например, указать базовые стили для всего приложения или когда вы вставляете в компонент стороннюю библиотеку, которая сама генерирует HTML и CSS и вы хотите поменять стиль для этого кода. Например, если ваш компонент вставляет редактор CodeMirror, для его стилизации вам нужно использовать @media local
, чтобы стили не изолировались:
@media local {
.CodeMirror {
font-size: 20px;
}
.CodeMirror-gutters {
border-right: 2px solid red;
}
}
@media global
Внутри @media global
указываются правила, которым вообще не применяется никакая изоляция, то есть они применимы к всему сайту и выводятся как есть. Самый частый пример применения @media global
– это создание библиотеки CSS-анимаций появления и удаления элементов.
@media global {
@keyframes show-item {
from: {
transform: scale(0);
opacity: 0;
}
}
@keyframes hide-item {
to: {
transform: scale(0);
opacity: 0;
}
}
}
При создании библиотек анимаций рекомендуется указывать названиям анимаций какой-нибудь префикс, чтобы они не пересекались с другими анимациями сайта.
Другой пример применения @media global
— это стилизация элементов за пределами вашего приложения. Например, вы разрабатываете приложение, которое должно вставляться на существующий сайт и вы знаете, как обратиться к элементу, в который вставляется ваше приложение, чтобы применить ему стандартные стили.
Препроцессоры
Так как все Endorphin-специфичные дополнения полностью совместимы с базовым CSS, для стилизации компонентов можно использовать популярные CSS-препроцессоры вроде SCSS и Less. В репозитрии с примерами есть шаблон настройки сборки с использованием SCSS для стилизации.
Поведение компонента
Endorphin-компонент — это обычный DOM-элемент, которому добавляется несколько свойств и методов:
props
(Object) — свойства компонента, переданные снаружи. Это внешний контракт, по которому внешний мир общается с компонентом.setProps(obj)
— обновляет свойства компонента, указанные вobj
. Данные должны быть иммутабельными: если меняете свойство какого-то объекта вprops
, сам объект нужно пересоздавать.state
(Object) — внутренние свойства компонента (внутренний контракт), которые компонент сам у тебя меняет.setState(obj)
— обновляет внутренние свойства компонента, указанные вobj
. Как и вsetProps()
, данные должны быть иммутабельными.root
(Element) — указатель на основной компонент приложения.refs
(Object) — указатели на элементы шаблона.store
(Store) — указатель на store приложения, автоматически наследуется от родителя.
Поведение компонента описывается в виде ES-модуля: вы описываете всю логику в модуле и экспортируете функции жизненного цикла, за которые рантайм будет дёргать при наступлении изменений:
/** Начальные свойства компонента */
export function props() {
return { items: null, enabled: false };
}
/** Начальные внутренние свойства компонента */
export function state() {
}
/** Создан экземпляр компонента */
export function init(component) {
}
/** Вызывается при изменении props */
export function didChange(component, { enabled }) {
if (enabled) {
console.log('Enabled changed from', enabled.prev, ' to ', enabled.current);
}
}
Таким образом, поведение компонента описывается в функциональном стиле: во все методы жизненного цикла приходит экземпляр компонента, для которого наступило событие, и вы решаете, как на это событие отреагировать.
Методы setProps()
и setState()
являются bound-методами, то есть они не используют this
и вы можете деструктурировать их в методах:
export function didChange({ setState }, { enabled }) {
if (enabled) {
setState({ show: true });
}
}
Доступны следующие методы жизненного цикла:
props()
— возвращает объект с начальными публичными свойствами компонента. Значения из этого объекта являются значениями по умолчанию, то есть если вызовsetProp()
выставит какое-то свойство вnull
илиundefined
, значение свойства будет взято из этого объекта.state()
— возвращает объект с начальными приватными свойствами компонента.store()
— возвращает стор компонента. Если не указан, стор будет унаследован от родителя.init(component)
— создан экземпляр компонента. Он ещё пустой, не содержит начальных свойств.willMount(component)
— сформированы входные данные для компонента (слоты, props) и он собирается отрисоваться.didMount(component)
— компонент отрисовался в первый раз.willUpdate(component, changes)
— пришло обновление и компонент собирается перерисоваться. Вchanges
перечислены props, которые поменялись после предыдущей отрисовки. Ключом является название свойства, а значением — объект{prev, current}
(предыдущее и текущее значение свойства). Объектchanges
может быть пустым, если перерисовка была вызвана изменением стэйта.didUpdate(component, changes)
— компонент перерисовался после обновления.willRender(component, changes)
— вызывается перед любой отрисовкой компонента. Фактически,willMount()
— это самый первыйwillRender()
,willUpdate()
— все последующие.didRender(component, changes)
— вызывается после любой отрисовки компонента. Фактически,didMount()
— это самый первыйdidRender()
,didUpdate()
— все последующие.didChange(component, changes)
— вызывается после изменения props. Все предыдущиеwill*
/did*
методы могут быть вызваны при изменении стэйта и стора.willUnmount(component)
— компонент будет удалён. Он всё ещё присутствует в дереве и активен.didUnmount(component)
— компонент удалён. Его больше нет в дереве, все события отвязаны, компонент более не активен.didSlotUpdate(component, slotName, slotContainer)
— поменялось содержимое слотаslotName
.
Также доступны следующие свойства модуля:
events
— список DOM-событий, на которые нужно подписать компонента. Подписки будут автоматически удалены при удалении компонента.extend
— свойства и методы, которые нужно добавить DOM-компоненту. За эти свойства и методы можно дёргать компонент напрямую из DOM.plugins
— список плагинов (описание добавится позже).
export const events = {
click(component, event) {
event.stopPropagation();
console.log('Clicked on component at %d, %d', event.pageX, event.pageY);
component.toggle();
}
}
export function state() {
return { enabled: false };
}
export const extend = {
// Так как extend добавляет свойства и методы непосредственно компоненту,
// для обращения к нему нужно использовать `this`
get enabled() {
return this.state.enabled;
},
set enabled(enabled) {
this.setState({ enabled });
},
toggle() {
this.enabled = !this.enabled;
}
}
Вложенные компоненты
Как и веб-компоненты, Endorphin-компоненты можно вкладывать друг в друга с помощью слотов.
По спецификации, <slot>
— это «дырка», через которую можно передавать HTML-элементы в текущий компонент. В этом Endorphin полностью повторяет поведение веб-компонентов: в компоненте можно объявить несколько слотов (один слот по умолчанию + именованные слоты), в них можно указать значение по умолчанию. Если в слот пришли данные снаружи, у него появится атрибут slotted
.
Чтобы добавить вложенный компонент, его нужно сначала подключить через <link rel="import" href="..." />
:
<link rel="import" href="./my-component.html" />
<template>
<my-component size=10 />
</template>
По умолчанию имя тэга компонента определяется из имени подключаемого файла. Если имя по какой-то причине определить не удаётся или вы хотите использовать другое, укажите имя тэга в атрибуте as="..."
:
<link rel="import" href="./my-component.html" as="something-different" />
<template>
<something-different size=10 />
</template>
Всё содержимое компонента попадает в слот по умолчанию:
<link rel="import" href="./my-component.html" />
<template>
<my-component>Hello <strong>world!</strong></my-component>
<!-- Выведет Greeting is <slot>Hello <strong>world!</strong></slot> -->
</template>
<!-- my-component.html -->
<template>
Greeting it <slot>default</slot>
</template>
У компонента может быть несколько слотов, у всех у них должны быть свои названия. Чтобы передать элемент в конкретный слот, нужно указать ему slot="..."
:
<link rel="import" href="./my-component.html" />
<template>
<my-component>
<h2 slot="header">Main header</h2>
Hello <strong>world!</strong>
<p slot="footer">Outer footer</p>
<!-- Порядок и количество элементов для передачи в слот не важен -->
<h3 slot="header">Sub header</h3>
<h4 slot="header">Small header</h4>
</my-component>
</template>
<!-- my-component.html -->
<style>
slot[name=header] {
border: 2px solid red;
padding: 5px;
}
/* Не выводим элемент слота, если он пустой */
slot[name=header]:empty {
display: none;
}
</style>
<template>
<slot name="header"></slot>
Greeting it <slot>default</slot>
<footer>
<slot name="footer"></slot>
</footer>
</template>