Знакомство с effector-dom на примере списка задач

от автора

Многим уже известен стейт-менеджер 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 в большом приложении.

Жду с нетерпением стабильной версии, какой-нибудь популяризации для возможности использовать рендер в проде.

ссылка на оригинал статьи https://habr.com/ru/post/487938/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *