🦉 Хранилище(Store) 🦉

Содержание

Введение

Управление состоянием в приложении — непростая задача. В некоторых случаях состояние приложения естественным образом может быть частью дерева компонентов. Однако бывают ситуации, когда некоторые части состояния необходимо отображать в разных частях пользовательского интерфейса, и тогда не очевидно, какой компонент должен владеть какой частью состояния.

Решением этой проблемы в Owl является централизованное хранилище. Это класс, который владеет каким либо (или всем) состоянием и позволяет разработчику обновлять его структурированным образом с помощью actions. Затем компоненты Owl могут подключиться к хранилищу, чтобы прочитать свое соответствующее состояние, и они будут повторно отрендерены, если состояние будет обновлено.

Примечание. Хранилище Owl вдохновлено React Redux и VueX.

Пример

Вот пример на что похоже простое хранилище:

const actions = {
  addTodo({ state }, message) {
    state.todos.push({
      id: state.nextId++,
      message,
      isCompleted: false,
    });
  },
};

const state = {
  todos: [],
  nextId: 1,
};

const store = new owl.Store({ state, actions });
store.on("update", null, () => console.log(store.state));

// обновляет state
store.dispatch("addTodo", "fix all bugs");

В этом примере показано, как можно определить и использовать хранилище. Обратите внимание, что в большинстве случаев действия будут отправляться подключенными компонентами.

Описание элементов

Хранилище(Store)

Хранилище представляет собой простой owl.EventBus, который запускает события update всякий раз, когда его состояние изменяется. Обратите внимание, что эти события инициируются только после тика микрозадачи, поэтому только одно событие будет инициировано для любого количества изменений состояния в стеке вызовов.

Кроме того, важно отметить, что состояние наблюдается (с помощью owl.Observer), поэтому оно может знать, было ли оно изменено. См. документацию Observer для более подробной информации. Класс Store довольно мал. Он имеет два общедоступных метода:

  • его конструктор
  • dispatch

Конструктор принимает объект конфигурации с четырьмя (необязательными) ключами:

  • исходное состояние
  • действия
  • геттеры
  • окружение
const config = {
  state,
  actions,
  getters,
  env,
};
const store = new Store(config);

Действия(Actions)

Действия используются для координации изменений состояния. Их можно использовать как для синхронной, так и для асинхронной логики.

const actions = {
  async login({ state }, info) {
    state.loginState = "pending";
    try {
      const loginInfo = await doSomeRPC("/login/", info);
      state.loginState = loginInfo;
    } catch (e) {
      state.loginState = "error";
    }
  },
};

Первый аргумент метода действия — это объект с четырьмя ключами:

  • state: текущее состояние содержимого хранилища,
  • dispatch: функция, которую можно использовать для отправки других действий,
  • getters: объект, содержащий все геттеры, определенные в хранилище,
  • env: текущее окружение. Иногда это бывает полезно, в частности, если действие требует применения некоторых побочных эффектов (таких как выполнение rpc), а метод rpc находится в окружении.

Действия вызываются с помощью метода dispatch в хранилище и могут принимать произвольное количество аргументов.

store.dispatch("login", someInfo);

Обратите внимание, что все, что возвращается действием, также будет возвращено вызовом dispatch.

Кроме того, важно знать, что нам нужно быть осторожными с асинхронной логикой. Каждое изменение состояния потенциально вызовет повторный рендеринг, поэтому нам нужно убедиться, что у нас нет частично поврежденного состояния. Вот пример, который, вероятно, не является хорошей идеей:

const actions = {
  async fetchSomeData({ state }, recordId) {
    state.recordId = recordId;
    const data = await doSomeRPC("/read/", recordId);
    state.recordData = data;
  },
};

В предыдущем примере есть период времени, в котором состояние имеет recordId, который не соответствует recordData. Более вероятно, что нам нужно атомарное обновление: обновление recordId одновременно со значениями recordData:

const actions = {
  async fetchSomeData({ state }, recordId) {
    const data = await doSomeRPC("/read/", recordId);
    state.recordId = recordId;
    state.recordData = data;
  },
};

Геттеры(Getters)

Обычно данные, содержащиеся в хранилище, хранятся в нормализованном виде. Например,

{
    posts: [{id: 11, authorId: 4, content: 'Greetings'}],
    authors: [{id: 4, name: 'John'}]
}

Однако для пользовательского интерфейса, вероятно, потребуются некоторые денормализованные данные, такие как:

{id: 11, author: {id: 4, name: 'John'}, content: 'Greetings'}

Для этого и нужны getters: они дают централизованный способ обработки и преобразования данных, содержащихся в хранилище.

const getters = {
  getPost({ state }, id) {
    const post = state.posts.find((p) => p.id === id);
    const author = state.authors.find((a) => a.id === post.id);
    return {
      id,
      author,
      content: post.content,
    };
  },
};

// где-то еще
const post = store.getters.getPost(id);

Геттеры принимают как правило один аргумент.

Обратите внимание что геттеры не кешируются

Подключение компонента

В какой-то момент нам нужен способ взаимодействия с хранилищем из компонента. Это означает, что компоненту нужна ссылка на хранилище. По умолчанию он ищет его в ключе env.store. Однако это можно настроить с помощью хука useStore.

Каждое взаимодействие компонента с хранилищем осуществляется с помощью трех хуков хранилища:

  • useStore для подписки компонента на некоторую часть состояния хранилища,
  • useDispatch для получения ссылки на функцию отправки,
  • useGetters для получения ссылки на геттеры, определенные в хранилище.

Предположим, у нас есть такое хранилище:

const actions = {
  increment({ state }, val) {
    state.counter.value += val;
  },
};

const state = {
  counter: { value: 0 },
};
const store = new owl.Store({ state, actions });

Чтобы сделать его доступным для всего приложения, мы поместим его в окружение:

// в этом примере корнево элемент это App
App.env.store = store;

A counter component can then select this value and dispatch an action like this:

class Counter extends Component {
  counter = useStore((state) => state.counter);
  dispatch = useDispatch();
}

const counter = new Counter({ store, qweb });
<button t-name="Counter" t-on-click="dispatch('increment')">
  Click Me! [<t t-esc="counter.value"/>]
</button>

useStore

Хук useStore используется для выбора части состояния хранилища. Он принимает два аргумента:

  • функция-селектор, которая принимает состояние хранилища в качестве первого аргумента (и реквизиты компонента в качестве второго аргумента) и которая должна возвращать часть состояния хранилища, которая будет доступна и будет отслеживаться для изменений,
  • необязательно, объект, который может иметь следующие необязательные ключи:
    • ключ store, содержащий объект хранилища, если мы хотим использовать другое хранилище, отличное от хранилища по умолчанию,
    • ключ isEqual, содержащий функцию равенства, если мы хотим специализировать сравнение (функция должна принимать два аргумента: предыдущий результат и новый результат, и должна возвращать, равны ли они),
    • и ключ onUpdate, содержащий функцию обновления, если мы хотим выполнять произвольный код каждый раз, когда изменяется выбранное состояние (функция получит один аргумент, новый результат, и может выполнять произвольный код).

Если селектор useStore возвращает часть состояния хранилища, компонент будет перерендириваться только при изменении этой части состояния. В противном случае он выполнит строгую проверку на равенство (если не определена опция isEqual, тогда она будет вызываться) и будет обновлять компонент каждый раз, когда эта проверка не пройдена.

Обратите внимание, что если функция выбора возвращает примитивный тип, результат useStore будет неизменным и не будет реагировать на изменения. В этом случае важно определить параметр onUpdate для правильного обновления значения вручную при его изменении.

Кроме того, возвращаемое значение из useStore не должно изменяться. Состояние хранилища должно обновляться только действиями.

useDispatch

Хук useDispatch полезен, когда компонент должен иметь возможность отправлять(dispatch) действия. Он принимает необязательный аргумент, который является хранилищем. Если не указано, будет использоваться хранилище из окружение.

Обратите внимание, что компонент не нужно каким-либо другим образом подключать к хранилищу. Например:

class DoSomethingButton extends Component {
  static template = xml`<button t-on-click="dispatch('something')">Click</button>`;
  dispatch = useDispatch();
}

useGetters

Хук useGetters полезен, когда компонент должен иметь возможность использовать геттеры, определенные в хранилище. Он принимает необязательный аргумент, который является хранилищем. Если не указано, будет использоваться хранилище в среде.

Обратите внимание, что компонент не нужно каким-либо другим образом подключать к хранилищу. Например:

class InfoButton extends Component {
  static template = xml`<span><t t-esc="getters.somevalue()"></span>`;
  getters = useGetters();
}

Семантика

Класс Store и хук useStore стараются быть умными и максимально оптимизировать процесс рендеринга и обновления. Важно знать следующее:

  • компоненты всегда обновляются в порядке их создания (т.е. родитель перед потомком),
  • они обновляются только если находятся в DOM,
  • если родитель является асинхронным, система будет ждать завершения его обновления перед обновлением других компонентов,
  • вообще обновления не согласовываются. Это не проблема для синхронных компонентов, но если есть много асинхронных компонентов, это может привести к ситуации, когда какая-то часть пользовательского интерфейса обновляется, а какая-то другая часть пользовательского интерфейса не обновляется.

Хорошие практики

  • по возможности избегайте асинхронных компонентов. Асинхронные компоненты приводят к ситуациям, когда части пользовательского интерфейса не обновляются сразу.
  • не бойтесь подключать много компонентов, родительских или дочерних, если это необходимо. Например, компонент MessageList может получить список идентификаторов в своем вызове useStore, а компонент Message может получить данные своего собственного сообщения.
  • поскольку функция useStore вызывается для каждого подключенного компонента, для каждого обновления состояния, важно сделать так, чтобы эти функции были максимально быстрыми.