Многим уже известен стейт-менеджер effector, кто-то его уже не только смотрел, но и использует в проде. С конца осени его автор активно разрабатывает девтулзы для эффектора, и в процессе этой работы у него получилось написать очень интересную библиотеку для рендера приложения — effector-dom.
С этим рендером и познакомимся — в этом туториале мы с вами будем создавать простое Todo приложение.
Для работы с логикой будем использовать effector, для рендера приложения — effector-dom.
Для визуальной части возьмем за основу уже готовый тимплейт todomvc-app-template со стилями todomvc-app-css за авторством tastejs.
1. Подготовка проекта
Предполагаю, что вы уже знакомы с вэбпаком и npm, поэтому шаг установки npm, создание проекта с webpack и запуск приложения мы пропускаем (если не знакомы — то в гугле webpack boilerplate).
Устанавливаем необходимые пакеты с версиями, которые использовались на момент написания статьи:
npm install effector@20.11.5 effector-dom@0.0.10 todomvc-app-css
2. Начнем
Для начала определимся со структурой приложения.
Сразу договоримся, что создаем самое простое приложение, только чтобы понять принцип работы, здесь не будет каких-то best practices по именованию компонентов и разделению на фичи.
Мы создадим очень простую структуру приложения, максимум что отделим отображение от логики и немного вынесем шаблонный код.
Для нашего маленького приложения этого более чем достаточно:
srs/
view/
app.js // вся вьюха приложения
title.js // заголовок
footer.js // счетчик задач, кнопки фильтрации, и удаления выполненных
header.js // создание новой задачи, выбор всех задач
main.js // список задач
todoItem.js // отдельная задача, выбор и удаление
model.js // логика работы
index.js // вход в приложение
3. Создание логики
Посмотрев на темплейт приложения, быстро определим возможные действия: задачу можно создать, отметить выполнение одной или всех сразу, отфильтровать и удалить выполненные.
В эффекторе для хранения любых (кроме undefined) данных используется Store, для событий — Event.
Также возможно создание комбинированных сторов с помощью функции combine, это мы используем для фильтрации задач.
// src/model.js
import {createStore, createEvent, combine} from 'effector';
// сторы
// все задачи
export const $todos = createStore([]);
// текущий фильтр, для простоты будет null/true/false
export const $activeFilter = createStore(null);
// отфильтрованные задачи
export const $filteredTodos = combine(
$todos,
$activeFilter,
(todos, filter) => filter === null
? todos
: todos.filter(todo => todo.completed === filter)
);
// события
// добавление новой задачи
export const appended = createEvent();
// выполнение/снятие выполнения задачи
export const toggled = createEvent();
// удаление задачи
export const removed = createEvent();
// выполнение всех задач
export const allCompleted = createEvent();
// удаление выполненных задач
export const completedRemoved = createEvent();
// фильтрация задач
export const filtered = createEvent();
Теперь созданные сторы и ивенты необходимо связать и создать логику их взаимодействия.
Сторы могут реагировать на события и изменение других сторов, сделав подписку через store.on
// src/model.js
...
$todos
// добавление новой задачи
.on(appended, (state, title) => [...state, {title, completed: false}])
// удаление задачи. Для простоты будем проверять title
.on(removed, (state, title) => state.filter(item => item.title !== title))
// выполнение/снятие выполнения
.on(toggled, (state, title) => state.map(item => item.title === title
? ({...item, completed: !item.completed})
: item))
// выполнение всех задач
.on(allCompleted, state => state.map(item => item.completed
? item
: ({...item, completed: true})))
// удаление выполненных задач
.on(completedRemoved, state => state.filter(item => !item.completed));
$activeFilter
// фильтрация
.on(filtered, (_, filter) => filter);
Вот и вся логика нашего приложения. Несколько сторов для хранения данных и несколько событий для работы с ними.
4. Создание общей view
Создавая view приложения мы воспользуемся effector-dom. По словам автора, он вдохновлялся SwiftUI и визуально это чувствуется.
Особенностью данной библиотеки является то, что он основан на стэке, вложенные колбэки выполняются строго внутри своего контекста и один раз. Вся реактивность основана на прямом биндинге dom-элементов к юнитам эффектора и не вызывает повторный запуск колбэков.
Для работы с effector-dom требуется связать его с конкретным dom элементом, внутри которого и будут создаваться новые элементы. Для этого используется функция using, которая принимает сам dom-элемент и колбэк, выполняемый внутри указанного контекста:
// src/index.js
import {using} from 'effector-dom';
import {App} from './view/app';
using(document.body, () => {
App();
});
Создание новых элементов происходит с помощью функции h, которая принимает строку с типом dom-элемента и колбэк, выполняемый внутри созданного элемента.
Для установки параметров dom-элемента используется функция spec, которая должна быть вызвана внутри переданного колбэка:
// src/view/app.js
import {h, spec} from 'effector-dom';
import classes from 'todomvc-app-css/index.css';
import {Header} from './header';
import {Main} from './main';
import {Footer} from './footer';
export const App = () => {
// создадим section элемент
h('section', () => {
// и укажем ему класс
spec({attr: {class: classes.todoapp}});
// также выведем остальные части приложения
Header();
Main();
Footer();
});
};
Таким же образом создадим остальные вьюхи.
5. Создание заголовка
По факту это будет просто h1 элемент с текстом.
Если создание нового dom-элемента включает в себя только установку параметров, без вложенных элементов, то можно воспользоваться сокращенным синтаксисом. Для этого spec можно не вызывать, а просто передать объект с параметрами вторым аргументом при создании:
// src/view/title.js
import {h} from 'effector-dom';
export const Title = () => {
h('h1', {text: 'todos'});
};
6. Создание новой задачи
Благодаря своему устройству, в effector-dom можно создавать различные юниты effector прямо в колбэке, не беспокоясь о подписках, сборщике мусора и т.д.
Сейчас мы создадим форму добавления новой задачи и заодно кнопку выбора всех задач, все обернем в элемент header. Обработчики событий можно присоединить к dom-элементу также в spec (или в сокращенном варианте), в ключе handler.
Перед созданием новой задачи нам необходимо проверить поле ввода и очистить его после добавления, для этого сделаем input контролируемым, добавим стор $value и событие input.
Для проверки ввода создадим новое событие с помощью специальной функции sample и заодно отфильтруем на наличие данных. В api эффектора sample — это довольно мощный инструмент, но мы используем только его простейшую форму — создадим новое событие, в которое придет значение указанного стора при вызове триггера: event = sample($store, triggerEvent).
// src/view/header.js
import {h, spec} from 'effector-dom';
import {createEvent, createStore, forward, sample} from 'effector';
import classes from 'todomvc-app-css/index.css';
import {Title} from './title';
import {appended} from '../model';
export const Header = () => {
h('header', () => {
Title();
h('input', () => {
const keypress = createEvent();
const input = createEvent();
// создадим фильтруемое событие,
const submit = keypress.filter({fn: e => e.key === 'Enter'});
// стор с текущим значением инпута
const $value = createStore('')
.on(input, (_, e) => e.target.value)
.reset(appended); // заодно очистим при отправке
// для перенаправления события в другое в эффекторе есть forward({from, to})
forward({
// возьмем текущее значение $value по триггеру submit,
// и сразу сделаем фильтрацию для проверки значения
from: sample($value, submit).filter({fn: Boolean}),
to: appended,
});
spec({
attr: {
class: classes["new-todo"],
placeholder: 'What needs to be done?',
value: $value
},
handler: {keypress, input},
})
});
});
};
7. Создание списка задач
Для вывода списка элементов, в effector-dom есть специальная функция list.
Способ вызова не сложный — передаем объект со стором, ключем и списком нужных полей, а также колбэк для отдельного элемента. Есть еще и более простой вызов list($store, itemCallback) но он нам сейчас не очень подойдет.
Заодно выведем кнопку выполнения всех задач.
К сожалению, в стилях todomvc-app-css эта кнопка сделана почему-то в одной секции со списком, хотя визуально находится в заголовке. Поэтому оставим здесь же.
// src/view/main.js
import {h, spec, list} from 'effector-dom';
import classes from 'todomvc-app-css/index.css';
import {TodoItem} from './todoItem';
import {$filteredTodos, allCompleted} from '../model';
export const Main = () => {
h('section', () => {
spec({attr: {class: classes.main}});
// выбор всех задач
h('input', {
attr: {id: 'toggle-all', class: classes['toggle-all'], type: 'checkbox'}
});
h('label', {attr: {for: 'toggle-all'}, handler: {click: allCompleted}});
// список задач
h('ul', () => {
spec({attr: {class: classes["todo-list"]}});
list({
source: $filteredTodos,
key: 'title',
fields: ['title', 'completed']
// в fields окажутся сторы с их значениям
}, ({fields: [title, completed], key}) => TodoItem({title, completed, key}));
});
});
};
8. Создание отдельной задачи
Благодаря своему устройству, в effector-dom можно создавать наследуемые сторы и события без каких-либо проблем с подписками, утечками памяти и т.д.
Так как задач может быть несколько, то нам необходимо в события модели toggled и removed как-то передать признак конкретной задачи — ключ.
Для этого воспользуемся возможностью effector создать событие с предустановкой параметров базового — делается это с помощью метода event.prepend.
Для указания класса выполненным задачам создадим наследуемый стор с помощью store.map
// src/view/todoItem.js
import {h, spec} from 'effector-dom';
import classes from 'todomvc-app-css/index.css';
import {toggled, removed} from '../model';
// title и completed - сторы с конкретными значениями
export const TodoItem = ({title, completed, key}) => {
h('li', () => {
// новый наследуемый стор с классом по флагу
spec({attr: {class: completed.map(flag => flag ? classes.completed : false)}});
h('div', () => {
spec({attr: {class: classes.view}});
h('input', {
attr: {class: classes.toggle, type: 'checkbox', checked: completed},
// новое событие с предустановкой параметров
handler: {click: toggled.prepend(() => key)},
});
h('label', {text: title});
h('button', {
attr: {class: classes.destroy},
// новое событие с предустановкой параметров
handler: {click: removed.prepend(() => key)},
});
});
});
};
9. Создание футера
Используя уже известные возможности создадим футер с каунтером задач, кнопками фильтрации и удаления выполненных задач
// src/view/footer.js
import {h, spec} from 'effector-dom';
import classes from 'todomvc-app-css/index.css';
import {$todos, $activeFilter, filtered, completedRemoved} from '../model';
export const Footer = () => {
h('footer', () => {
spec({attr: {class: classes['footer']}});
h('span', () => { // Каунтер активных задач
spec({attr: {class: classes['todo-count']}});
const $activeCount = $todos.map(
todos => todos.filter(todo => !todo.completed).length
);
h('strong', {text: $activeCount});
h('span', {text: $activeCount.map(count => count === 1
? ' item left'
: ' items left'
)});
});
h('ul', () => { // кнопки фильтров, ничего нового
spec({attr: {class: classes.filters}});
h('li', () => {
h('a', {
attr: {class: $activeFilter.map(active => active === null
? classes.selected
: false
)},
text: 'All',
handler: {click: filtered.prepend(() => null)},
});
});
h('li', () => {
h('a', {
attr: {class: $activeFilter.map(completed => completed === false
? classes.selected
: false
)},
text: 'Active',
handler: {click: filtered.prepend(() => false)},
});
});
h('li', () => {
h('a', {
attr: {class: $activeFilter.map(completed => completed === true
? classes.selected
: false
)},
text: 'Completed',
handler: {click: filtered.prepend(() => true)},
});
});
});
h('button', {
attr: {class: classes['clear-completed']},
text: 'Clear completed',
handler: {click: completedRemoved},
});
});
};
10. Уточнения
Вот такое простое приложение получилось, специально делалось как можно проще, с минимальным разделением на отдельные сущности.
Конечно, при желании те же кнопки фильтрации можно вынести в отдельную сущность, которой можно передать тип фильтра и название.
const FilterButton = ({filter, text}) => {
h('li', () => {
h('a', {
attr: {class: $activeFilter.map(completed => completed === filter
? classes.selected
: false
)},
text: text,
handler: {click: filtered.prepend(() => filter)},
});
});
};
h('ul', () => {
spec({attr: {class: classes.filters}});
FilterButton({filter: null, text: 'All'});
FilterButton({filter: false, text: 'Active'});
FilterButton({filter: true, text: 'Completed'});
});
Таким же образом, благодаря своей работе на основе стэка, effector-dom позволяет свободно выносить не только отдельные элементы, но и общее поведение.
Вынесенный код будет применяться именно к нужным элементам, например:
const WithFocus = () => {
const focus = createEvent();
focus.watch(() => console.log('focused'));
spec({handler: {focus}});
};
h('input', () => {
...
WithFocus();
...
});
11. Итого
Меня лично впечатлила простота работы с новым рендером, тем более у меня уже был опыт работы с effector в большом приложении.
Жду с нетерпением стабильной версии, какой-нибудь популяризации для возможности использовать рендер в проде.
Автор: Иван Владо