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

в 13:33, , рубрики: effector, effector-dom, javascript, Программирование

Многим уже известен стейт-менеджер effector, кто-то его уже не только смотрел, но и использует в проде. С конца осени его автор активно разрабатывает девтулзы для эффектора, и в процессе этой работы у него получилось написать очень интересную библиотеку для рендера приложения — effector-dom.

С этим рендером и познакомимся — в этом туториале мы с вами будем создавать простое Todo приложение.

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

Для работы с логикой будем использовать 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://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js