Skip to content

endorphinjs/endorphin

Repository files navigation

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.

Шаблон

Как было отмечено выше, шаблон представляет из себя 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 &#123; greeting &#125;!

<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>