我们通过TodoMVC的例子掌握了React的很多核心知识点,搞一个小应用不成问题,但是,但凡上点规模的应用都会需要状态管理和路由。所以,我们将继续升级TodoMVC,引入这两个关键需求,使大家可以通过这个过程掌握规模化React应用中如何用好状态管理和路由功能。
前言
小羊们好!我们通过TodoMVC的例子掌握了React的很多核心知识点,搞一个小应用不成问题,但是,但凡上点规模的应用都会需要状态管理和路由。所以,我们将继续升级TodoMVC,引入这两个关键需求,使大家可以通过这个过程掌握规模化React应用中如何用好状态管理和路由功能。
我们将学到如下核心知识点:
- 如何选择一个状态管理库
- 如何在React中引入Redux
- 如何编写模块化的Redux代码
- 如何通过RTK避免模版代码
- 如何编写Redux中间件
- 理解不可变数据的思想
- 进一步优化拆分组件结构
- ...
常用状态库选择
现在react社区中的状态管理库有一打那么多,随便说几个:
- redux、mobx、recoil、zustand、jotai、valtio、resso、...
- 我说一下自己对它们的一些个人看法:
- redux:老牌劲旅,59k Star;使用复杂且臃肿,RTK的出现让redux焕发了青春。
- mobx:中坚力量,26k Star;使用简单,响应式,但是 "不够 React"
- recoil:贵族血统,18k Star;Meta推出,使用简单、原子化;但是处于试验状态
- zustand:后起之秀,23.4k Star;小、快、灵,简单版redux
- jotai:10.7k Star;元数据化,hooks写法,符合hooks理念
- valtio:5.7k Star;proxy理念,"不太 React",用起来简单
- resso:0.3K Star;号称世界上最简洁的状态管理库,适用于RN、SSR、小程序
看完这些之后,大家很容易做选择:
想要大众稳定一点,redux,mobx
- 想要简单,后续新出这几个都属于这种风格
- 想要时髦选recoil
- 想要小巧选resso
- 本次案例我将选用redux来做演示,它最具有代表性,设计理念也比较有学习价值。
之前案例问题分析
现在我们来看一下前面的案例中一些比较别扭的地方:
TodoList传递很多属性和方法比较冗长:
<TodoList {...{todos: filteredTodos, removeTodo, updateTodo}}></TodoList>
visibility,setVisibility明显是TodoFilter内部状态,现在因为过滤结果要传给TodoList而不得不写在外面:
<TodoFilter visibility={visibility} setVisibility={setVisibility}></TodoFilter>
如果提取新增功能到独立组件里也会遇到同样的问题:操作的是todos,关心的却是TodoList,很不好写吧?
我们期待的App.jsx
应该是这样的:简单组合,组件之间又可以轻松共享数据和通信。
<AddTodo></AddTodo>
<TodoList></TodoList>
<TodoFilter></TodoFilter>
在组件内部,也应该很容易取到想要的数据和方法,比如TodoList:
function TodoList() {
// 一个hook可以轻松获取数据
const todos = useSelector(state state.todos)
// 一个hook可以获取修改数据的方法
const dispatch = useDispatch()
// 需要修改数据时派发action即可
dispatch(deleteTodoAction)
}
像上面这样的需求,通过redux这样的同一状态管理库就可以很容易实现,下面我们来看一下具体做法。
引入Redux
以前我使用redux需要安装:redux + react-redux。痛点是概念多,代码复杂冗长,心智负担严重。
现在官方推出了Redux Toolkit,以下简称RTK,我们可以使用RTK + react-redux 组合。
RTK主要用来简化和优化redux代码。使用它可以轻松实现模块化和可变数据写法,简直不要太好用,这也是我改变最初准备使用mobx给大家演示的原因!
下面我们引入RTK 和 react-redux:
yarn add @reduxjs/toolkit react-redux
我想要快速尝试一下,store/index.js:创建Store实例,用来存储状态
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
// configureStore()创建一个store实例
export const store = configureStore({
reducer: {
// counter即为模块名称
counter: counterReducer,
},
});
再看看counterSlice中的reducer定义部分:createSlice({...})定义子模块,这样可以很容易拆分代码
import { createSlice } from '@reduxjs/toolkit';
// createSlice定义子模块
export const counterSlice = createSlice({
name: 'counter',
initialState: {
value: 0
},
// `reducers`就是我们用来修改状态的方法
reducers: {
increment: (state) => {
// Redux Toolkit 使我们可以直接修改状态,大幅减少模版代码
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
}
}
});
// 导出actionsCreator便于用户使用,例如:dispatch(increment())
export const { increment, decrement } = counterSlice.actions;
// 导出子模块reducer
export default counterSlice.reducer;
下面是在主文件中设置store,main.jsx:
import store from './store'
import { Provider } from 'react-redux'
ReactDOM.createRoot(document.getElementById('root')).render(
// Provider可以将store透传下去
<Provider store={store}>
<React.StrictMode>
<App />
</React.StrictMode>
</Provider>
)
这就准备好了,下面在组件中使用数据:
import { useSelector, useDispatch } from 'react-redux';
import { decrement, increment } from './store/counterSlice';
export function Counter() {
// useSelector(selector)获取需要的子模块数据
const count = useSelector(state state.counter.value);
// dispatch(action)用来修改状态
const dispatch = useDispatch();
return (
<div>
<button notallow={() => dispatch(decrement())}>-</button>
<span>{count}</span>
<button notallow={() => dispatch(increment())}>+</button>
</div>
)
}
看看效果吧!
重构Todo应用
下面我们着手重构TodoMVC
,首先将todos数据和操作移入独立的todoSlice.js
:
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
value: JSON.parse(localStorage.getItem("todomvc-react") || "[]"),
};
// 创建todoSlice保存todos状态
// 将之前修改方法移至reducers中用来修改todos状态
const todoSlice = createSlice({
name: "todos",
initialState,
reducers: {
addTodo: ({ value: todos }, { payload: title }) => {
const id = todos[todos.length - 1] ? todos[todos.length - 1].id + 1 : 1
todos.push({
id,
title,
completed: false,
});
},
removeTodo: ({ value: todos }, { payload: id }) => {
const idx = todos.findIndex((todo) => todo.id === id);
todos.splice(idx, 1);
},
updateTodo: ({ value: todos }, { payload: editTodo }) => {
const todo = todos.find((todo) => todo.id === editTodo.id);
Object.assign(todo, editTodo);
},
},
});
// selector用于选出想要的数据
export const selectTodos = (state) => state.todos.value;
// actionCreator用于创建dispatch()需要的action
export const { addTodo, removeTodo, updateTodo } = todoSlice.actions;
export default todoSlice.reducer;
现在我们不再需要在app.jsx中声明todos,也不需要给TodoList传递数据:
function App() {
// const {todos, addTodo, removeTodo, updateTodo} = useTodos(todoStorage.fetch())
return (
<div className="App">
{/* ... */}
<TodoList ></TodoList>
{/* ... */}
</div>
);
}
todos发生变化持久化到localStorage可以先注释掉,随后我们通过中间件的方式写到todoSlice中
// useEffect(() => {
// todoStorage.save(todos);
// }, [todos]);
我们再重构一下TodoList.jsx:
import { useDispatch, useSelector } from "react-redux";
import { selectTodos, removeTodo, updateTodo } from "./store/todos";
const TodoList = () {
// 获取todos和dispatch
const todos = useSelector(selectTodos);
const dispatch = useDispatch();
const changeState = (e, currentTodo) => {
// currentTodo.completed = e.target.checked;
// 此处通过dispatch(updateTodo())方式通知更新todos状态
dispatch(updateTodo({ ...currentTodo, completed: e.target.checked }));
};
// ...
const onEditing = (e) => {
const title = e.target.value;
if (title) {
setEditedTodo({ ...editedTodo, title });
} else {
// removeTodo(editedTodo.id);
// 使用dispatch(removeTodo())删除指定项
dispatch(removeTodo(editedTodo.id));
}
};
const onEdited = (e) => {
if (e.code === "Enter") {
if (editedTodo.title) {
// updateTodo(editedTodo)
// 使用dispatch(updateTodo())更新
dispatch(updateTodo(editedTodo));
}
setEditedTodo(initial);
}
};
}
我们再提取新增组件感受一下变化,创建一个AddTodo.jsx:
import { useState } from "react";
import { useDispatch } from 'react-redux'
import { addTodo } from "./store/todoSlice";
export function AddTodo() {
// ...
// 这里只需要dispatch通知新增
const dispatch = useDispatch()
const onAddTodo = (e) => {
if (e.code === "Enter" && newTodo) {
// addTodo(newTodo)
// 修改新增待办调用方式
dispatch(addTodo(newTodo));
setNewTodo("");
}
};
// ...
}
下面修改过滤组件FilterTodo的实现:这里需要提取visibility这个状态到全局,因为修改组件是FilterTodo,关心它的却是TodoList,创建visibilitySlice.js:
import { createSlice } from "@reduxjs/toolkit";
export const VisibilityFilters = {
SHOW_ALL: "SHOW_ALL",
SHOW_COMPLETED: "SHOW_COMPLETED",
SHOW_ACTIVE: "SHOW_ACTIVE",
};
const visibilitySlice = createSlice({
name: "visibility",
initialState: VisibilityFilters.SHOW_ALL,
reducers: {
setVisibilityFilter(state, { payload }) {
return payload;
},
},
});
export const { setVisibilityFilter } = visibilitySlice.actions
export default visibilitySlice.reducer
注册slice,store/index.js
import visibilitySlice from './visibilitySlice'
export default configureStore({
reducer: {
visibility: visibilitySlice
}
})
修改FilterTodo.jsx:可以看到FilterTodo通过dispatch()方式设置visibility。
import { useDispatch, useSelector } from "react-redux";
import {
VisibilityFilters,
setVisibilityFilter,
} from "./store/visibilitySlice";
export default function TodoFilter() {
// 引入visibility
const visibility = useSelector(state state.visibility)
// 获取选中状态
const getSelectedClass = (filter) =>
visibility === filter ? "selected" : "";
// 引入dispatch
const dispatch = useDispatch();
// 设置过滤
const setFilter = (filter) => dispatch(setVisibilityFilter(filter));
return (
<ul className="filters">
<li>
<button
className={getSelectedClass(VisibilityFilters.SHOW_ALL)}
notallow={() => setFilter(VisibilityFilters.SHOW_ALL)}
>
All
</button>
</li>
<li>
<button
className={getSelectedClass(VisibilityFilters.SHOW_ACTIVE)}
notallow={() => setFilter(VisibilityFilters.SHOW_ACTIVE)}
>
Active
</button>
</li>
<li>
<button
className={getSelectedClass(VisibilityFilters.SHOW_COMPLETED)}
notallow={() => setFilter(VisibilityFilters.SHOW_COMPLETED)}
>
Completed
</button>
</li>
</ul>
);
}
现在App.jsx中不在需要visibility状态,也不需要传递他们给FilterTodo
// const {visibility, setVisibility, filteredTodos} = useFilter(todos)
<TodoFilter></TodoFilter>
下面我们看一下visibility发生变化之后,todoSlice中如何做出响应:我们添加一个selectFilteredTodos选择器
export const selectFilteredTodos = ({ visibility, todos }) => {
if (visibility === VisibilityFilters.SHOW_ALL) {
return todos.value;
} else if (visibility === VisibilityFilters.SHOW_ACTIVE) {
return todos.value.filter((todo) => todo.completed === false);
} else {
return todos.value.filter((todo) => todo.completed === true);
}
};
TodoList中只需要替换selectTodos为selectFilteredTodos即可:
const todos = useSelector(selectFilteredTodos);
大家看这像不像Vue中的计算属性,或者Vuex中的getters,那既然要像,就要彻底一些,因此最好引入缓存性,即:todos和visibility不变化就没有必要重新过滤。
这就需要用到RTK提供的createSelector方法,它的前身就是reselect,来看看具体用法:
import { createSelector } from "@reduxjs/toolkit";
// 代码真是相当舒适!
export const selectFilteredTodos = createSelector(
(state) => state.visibility, // 选出所需状态作为输入
(state) => state.todos.value,// 选出所需状态作为输入
(visibility, todos) => { // 接收输入并执行派生逻辑
switch (visibility) {
case VisibilityFilters.SHOW_ACTIVE:
return todos.filter((todo) => todo.completed === false);
case VisibilityFilters.SHOW_COMPLETED:
return todos.filter((todo) => todo.completed === true);
default:
return todos;
}
}
);
全部搞定!现在再看看App.jsx,已经短小到不能再精悍了!
function App() {
return (
<div className="App">
<header>
<h1>我的待办事项</h1>
<img src={reactLogo} className="logo" alt="logo" />
</header>
{/* 新增 */}
<AddTodo></AddTodo>
{/* 列表 */}
<TodoList></TodoList>
{/* 过滤 */}
<TodoFilter></TodoFilter>
</div>
);
}
export default App;
使用Redux中间件
还有最后一件事,就是todos信息变化之后要存入localStorage。
之前我们通过观察todos变化触发保存行为:这需要我们在App中额外引入todos,破坏了App短小精悍的感觉
import { useSelector } from 'react-redux'
function App() {
// 这需要我们在App中额外引入todos
const todos = useSelector(state state.todos)
useEffect(() {
storage.save(todos)
}, [todos])
}
实际上,我们可以利用redux中间件完成这个需求,store/index.js:
import {todoStorage} from '../utils/storage'
// 声明一个中间件:只要是和todos相关的action,我们都触发保存行为
const storageMiddleware = store next action {
if (action.type.startsWith('todos/')) {
next(action)
todoStorage.save(store.getState().todos.value)
}
}
const store = configureStore({
// 引入我们编写的中间件
middleware: gDM gDM().concat(storageMiddleware)
})
后续更新计划
终于写完了!掌握了好多redux知识,但我们还是不满足:
- 我们能否将TodoMVC变成多页面应用
- 能否引入权限控制,使得只有管理员才能创建和删除待办
- 随着Todo的逐渐复杂,看起来编辑Todo需要一个弹出表单
- 等等...
这些功能我们会在后面的教程中带大家逐步实现,顺便,我们再学一下路由库的使用。