Знакомство с 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/


Интересные статьи

Интересные статьи

В предыдущих выпусках: Profile 0 Profile 1 Profile 3 Profile 11 Как мы узнали из прошлых записей, в опорной сети при реализации mVPN всегда присутствует конструкция Default MDT...
Пополнить базу собственными задачами может любой желающий, а на свой сайт встроить Kiddo не сложнее, чем плеер YouTube. Об идее детской образовательной площадки и ее реализации во...
Всем привет! Проведенный отпуск дал какую-то легкость мысли, и мне захотелось написать простую и немного забавную статью, в которой я бы смог на обычных примерах из своей жизни объясн...
Всем привет. Когда я искал информацию о журналировании (аудите событий) в Bitrix, на Хабре не было ни чего, в остальном рунете кое что было, но кто же там найдёт? Для пополнения базы знаний...
Несколько дней назад случилась конференция Hydra. Ребята из JUG.ru Group пригласили спикеров мечты (Лесли Лэмпорт! Клифф Клик! Мартин Клеппманн!) и посвятили два дня распределённым системам и выч...