🦉 Хранилище(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
вызывается для каждого подключенного компонента, для каждого обновления состояния, важно сделать так, чтобы эти функции были максимально быстрыми.