Управление нормализованными данными
Как упоминалось в Нормализации формы состояния, библиотека Normalizr часто используется для преобразования вложенных данных ответа в нормализованную форму, подходящую для интеграции в хранилище. Однако это не решает проблему выполнения дополнительных обновлений этих нормализованных данных, поскольку они используются в других частях приложения. Существует множество различных подходов, которые вы можете использовать, исходя из ваших предпочтений. Мы рассмотрим пример добавления нового комментария к посту.
Стандартные подходы
Простое объединение
Один из подходов — объединить содержимое действия в существующее состояние. В этом случае нам необходимо выполнить глубокое рекурсивное объединение, а не просто поверхностную копию. Функция Lodash merge может справиться с этим за нас:
import merge from "lodash/merge";
function commentsById(state = {}, action) {
switch(action.type) {
default : {
if(action.entities && action.entities.comments) {
return merge({}, state, action.entities.comments.byId);
}
return state;
}
}
}
Это требует наименьшего объема работы со стороны редьюсера, но требует, чтобы создатель действия, возможно, проделал значительную работу по организации данных в правильную форму перед отправкой действия. Также он не обрабатывает попытки удаления элемента.
Композиция редьюсеров срезов
Если у нас есть вложенное дерево редьюсеров срезов, каждый редьюсер среза должен знать, как соответствующим образом реагировать на это действие. Мы должны включить все соответствующие данные в действие. Мы должны обновить правильный объект Post с идентификатором комментария, создать новый объект Comment, используя этот идентификатор в качестве ключа, и включить идентификатор комментария в список всех идентификаторов комментариев. Вот как могут быть объединены эти части:
// actions.js
function addComment(postId, commentText) {
// Generate a unique ID for this comment
const commentId = generateId("comment");
return {
type : "ADD_COMMENT",
payload : {
postId,
commentId,
commentText
}
};
}
// reducers/posts.js
function addComment(state, action) {
const {payload} = action;
const {postId, commentId} = payload;
// Look up the correct post, to simplify the rest of the code
const post = state[postId];
return {
...state,
// Update our Post object with a new "comments" array
[postId] : {
...post,
comments : post.comments.concat(commentId)
}
};
}
function postsById(state = {}, action) {
switch(action.type) {
case "ADD_COMMENT" : return addComment(state, action);
default : return state;
}
}
function allPosts(state = [], action) {
// omitted - no work to be done for this example
}
const postsReducer = combineReducers({
byId : postsById,
allIds : allPosts
});
// reducers/comments.js
function addCommentEntry(state, action) {
const {payload} = action;
const {commentId, commentText} = payload;
// Create our new Comment object
const comment = {id : commentId, text : commentText};
// Insert the new Comment object into the updated lookup table
return {
...state,
[commentId] : comment
};
}
function commentsById(state = {}, action) {
switch(action.type) {
case "ADD_COMMENT" : return addCommentEntry(state, action);
default : return state;
}
}
function addCommentId(state, action) {
const {payload} = action;
const {commentId} = payload;
// Just append the new Comment's ID to the list of all IDs
return state.concat(commentId);
}
function allComments(state = [], action) {
switch(action.type) {
case "ADD_COMMENT" : return addCommentId(state, action);
default : return state;
}
}
const commentsReducer = combineReducers({
byId : commentsById,
allIds : allComments
});
Пример немного длинный, потому что он показывает, как все различные редьюсеры срезов и редьюсеры случаев взаимодействуют друг с другом. Обратите внимание на делегирование, которое здесь используется. Редьюсер среза postsById делегирует работу по этому случаю addComment, который вставляет идентификатор нового комментария в правильный элемент Post. Между тем, у редьюсеров срезов commentsById и allComments есть свои собственные редьюсеры случаев, которые соответственно обновляют таблицу поиска Comment и список всех идентификаторов Comment.
Другие подходы
Обновления, основанные на задачах
Поскольку редьюсеры — это просто функции, существует бесконечное множество способов разбить эту логику. Хотя использование редьюсеров срезов, очевидно, является наиболее распространенным, также возможно организовать поведение в более ориентированной на задачи структуре. Поскольку это часто предполагает более вложенные обновления, вы можете использовать библиотеку утилиты для работы с неизменяемыми данными, такую как dot-prop-immutable или object-path-immutable, чтобы упростить операторы обновления. Вот пример того, как это может выглядеть:
import posts from "./postsReducer";
import comments from "./commentsReducer";
import dotProp from "dot-prop-immutable";
import {combineReducers} from "redux";
import reduceReducers from "reduce-reducers";
const combinedReducer = combineReducers({
posts,
comments
});
function addComment(state, action) {
const {payload} = action;
const {postId, commentId, commentText} = payload;
// State here is the entire combined state
const updatedWithPostState = dotProp.set(
state,
`posts.byId.${postId}.comments`,
comments => comments.concat(commentId)
);
const updatedWithCommentsTable = dotProp.set(
updatedWithPostState,
`comments.byId.${commentId}`,
{id : commentId, text : commentText}
);
const updatedWithCommentsList = dotProp.set(
updatedWithCommentsTable,
`comments.allIds`,
allIds => allIds.concat(commentId);
);
return updatedWithCommentsList;
}
const featureReducers = createReducer({}, {
ADD_COMMENT : addComment,
};
const rootReducer = reduceReducers(
combinedReducer,
featureReducers
);
Этот подход делает очень ясным, что происходит в случае "ADD_COMMENTS", но он требует вложенной логики обновления и определенных знаний о структуре дерева состояния. В зависимости от того, как вы хотите составить логику редьюсера, это может быть или не быть желательным.
Redux-ORM
Библиотека Redux-ORM предоставляет очень полезный абстракционный уровень для управления нормализованными данными в хранилище Redux. Она позволяет объявлять классы моделей и определять связи между ними. Затем она может генерировать пустые «таблицы» для ваших типов данных, выступать в качестве специализированного инструмента выбора для поиска данных и выполнять неизменяемые обновления этих данных.
Существует несколько способов использования Redux-ORM для выполнения обновлений. Во-первых, документация Redux-ORM предлагает определить функции редьюсеров для каждого подкласса модели, а затем включить автоматически сгенерированную объединенную функцию редьюсера в ваше хранилище:
// models.js
import {Model, many, Schema} from "redux-orm";
export class Post extends Model {
static get fields() {
return {
// Define a many-sided relation - one Post can have many Comments,
// at a field named "comments"
comments : many("Comment")
};
}
static reducer(state, action, Post) {
switch(action.type) {
case "CREATE_POST" : {
// Queue up the creation of a Post instance
Post.create(action.payload);
break;
}
case "ADD_COMMENT" : {
const {payload} = action;
const {postId, commentId} = payload;
// Queue up the addition of a relation between this Comment ID
// and this Post instance
Post.withId(postId).comments.add(commentId);
break;
}
}
// Redux-ORM will automatically apply queued updates after this returns
}
}
Post.modelName = "Post";
export class Comment extends Model {
static get fields() {
return {};
}
static reducer(state, action, Comment) {
switch(action.type) {
case "ADD_COMMENT" : {
const {payload} = action;
const {commentId, commentText} = payload;
// Queue up the creation of a Comment instance
Comment.create({id : commentId, text : commentText});
break;
}
}
// Redux-ORM will automatically apply queued updates after this returns
}
}
Comment.modelName = "Comment";
// Create a Schema instance, and hook up the Post and Comment models
export const schema = new Schema();
schema.register(Post, Comment);
// main.js
import { createStore, combineReducers } from 'redux'
import {schema} from "./models";
const rootReducer = combineReducers({
// Insert the auto-generated Redux-ORM reducer. This will
// initialize our model "tables", and hook up the reducer
// logic we defined on each Model subclass
entities : schema.reducer()
});
// Dispatch an action to create a Post instance
store.dispatch({
type : "CREATE_POST",
payload : {
id : 1,
name : "Test Post Please Ignore"
}
});
// Dispatch an action to create a Comment instance as a child of that Post
store.dispatch({
type : "ADD_COMMENT",
payload : {
postId : 1,
commentId : 123,
commentText : "This is a comment"
}
});
Библиотека Redux-ORM поддерживает внутреннюю очередь обновлений, которые должны быть применены. Затем эти обновления применяются неизменно, упрощая процесс обновления.
Другой вариант — использовать Redux-ORM в качестве абстракционного уровня в пределах одного редьюсера случая:
import {schema} from "./models";
// Assume this case reducer is being used in our "entities" slice reducer,
// and we do not have reducers defined on our Redux-ORM Model subclasses
function addComment(entitiesState, action) {
const session = schema.from(entitiesState);
const {Post, Comment} = session;
const {payload} = action;
const {postId, commentId, commentText} = payload;
const post = Post.withId(postId);
post.comments.add(commentId);
Comment.create({id : commentId, text : commentText});
return session.reduce();
}
В целом, Redux-ORM предоставляет очень полезный набор абстракций для определения связей между типами данных, создания «таблиц» в нашем состоянии, извлечения и денормализации реляционных данных и применения неизменяемых обновлений к реляционным данным.
© 2015–2017 Dan Abramov
Licensed under the MIT License.
http://redux.js.org/docs/recipes/reducers/UpdatingNormalizedData.html