这篇文章来自于我的慕课专栏《Web前端修炼指南》,主要面向工作1-3年的初中级前端进阶,涵盖编程技巧、设计模式、面向对象、AOP、React 等内容,偏向于讲解原理,带你升职加薪。
欢迎大家支持或者帮忙转发,让需要的人看到。
web前端修炼指南
在前端圈子有这样一种说法,Vue 入门最简单,React 学习曲线太陡,Angular…我还是选择狗带吧。
在 React 诞生之初,Facebook 宣传这是一个用于前端开发的界面库,仅仅是一个 View 层。前面我们也介绍过 React 的组件通信,在大型应用中,处理好 React 组件通信和状态管理就显得非常重要。
为了解决这一问题,Facebook 最先提出了单向数据流的 Flux 架构,弥补了使用 React 开发大型网站的不足。
Flux:
随后,Dan Abramov 受到 Flux 和函数式编程语言 Elm 启发,开发了 Redux 这个状态管理库。
Redux 源码非常精简,实现也很巧妙,这篇文章将带你从零手写一个 Redux 和 react-redux 库,以及告诉你该如何设计 Redux 中的 store。
在开始前,我已经将这篇文章的完整代码都整理到 GitHub 上,大家可以参考一下。
Redux:simple-redux
React-redux:simple-react-redux
在开始讲解状态管理前,我们先来了解一下现代前端框架都做了些什么。
以 Vue 为例子,在刚开始的时候,Vue 官网首页写的卖点是数据驱动、组件化、MVVM 等等(现在首页已经改版了)。
那么数据驱动的意思是什么呢?不管是原生 JS 还是 jQuery,他们都是通过直接修改 DOM 的形式来实现页面刷新的。
而 Vue/React 之类的框架不是粗暴地直接修改 DOM,而是通过修改 data/state 中的数据,实现了组件的重新渲染。也就是说,他们封装了从数据变化到组件渲染这一个过程。
原本我们用 jQuery 开发应用,除了要实现业务逻辑,还要操作 DOM 来手动实现页面的更新。尤其是涉及到渲染列表的时候,更新起来非常麻烦。
var ul = document.getElementById("todo-list");
$.each(todos, function(index, todo) {
var li = document.createElement('li');
li.innerHTML = todo.content;
li.dataset.id = todo.id;
li.className = "todo-item";
ul.appendChild(li);
})
所以后来出现了 jQuery.tpl 和 Underscore.template 之类的模板,这些让操作 DOM 变得容易起来,有了数据驱动和组件化的雏形,可惜我们还是要手动去渲染一遍。
如果说用纯原生 JS 或者 jQuery 开发页面是原始农耕时代,那么 React/Vue 等现代化框架则是自动化的时代。
有了前端框架之后,我们不需要再去关注怎么生成和修改 DOM,只需要关心页面上的这些数据以及流动。
所以如何管理好这些数据流动就成了重中之重,这也是我们常说的“状态管理”。
前面讲了很多例子,可状态管理到底要管理什么呢?在我看来,状态管理的一般就是这两种数据。
{
"data": {
"hotels": [
{
"id": "31231231",
"name": "希尔顿",
"price": "1300"
}
]
}
}
{
"isLoading": true,
"isShowModal": false,
"isSelected": false
}
我们用 React 写组件的时候,如果需要涉及到兄弟组件通信,经常需要将状态提升到两者父组件里面。一旦这种组件通信多了起来,数据管理就是个问题。
结合上面的例子,如果想要对应用的数据流进行管理,那是不是可以将所有的状态放到顶层组件中呢?
将数据按照功能或者组件来划分,将多个组件共享的数据单独放置,这样就形成了一个大的树形 store。这里更建议按照功能来划分。
这个大的 store 可以放到顶层组件中维护,也可以放到顶层组件之外来维护,这个顶层组件我们一般称之为“容器组件”。
容器组件可以将组件依赖的数据以及修改数据的方法一层层传给子组件。
我们可以将容器组件的 state 按照组件来划分,现在这个 state 就是整个应用的 store。将修改 state 的方法放到 actions 里面,按照和 state 一样的结构来组织,最后将其传入各自对应的子组件中。
class App extends Component {
constructor(props) {
this.state = {
common: {},
headerProps: {},
bodyProps: {
sidebarProps: {},
cardProps: {},
tableProps: {},
modalProps: {}
},
footerProps: {}
}
this.actions = {
header: {
changeHeaderProps: this.changeHeaderProps
},
footer: {
changeFooterProps: this.changeFooterProps
},
body: {
sidebar: {
changeSiderbarProps: this.changeSiderbarProps
}
}
}
}
changeHeaderProps(props) {
this.setState({
headerProps: props
})
}
changeFooterProps() {}
changeSiderbarProps() {}
...
render() {
const {
headerProps,
bodyProps,
footerProps
} = this.state;
const {
header,
body,
footer
} = this.actions;
return (
<div className="main">
<Header {...headerProps} {...header} />
<Body {...bodyProps} {...body} />
<Footer {...footerProps} {...footer} />
</div>
)
}
}
我们可以看到,这种方式可以很完美地解决子组件之间的通信问题。只需要修改对应的 state 就行了,App 组件会在 state 变化后重新渲染,子组件接收新的 props 后也跟着渲染。
这种模式还可以继续做一些优化,比如结合 Context 来实现向深层子组件传递数据。
const Context = createContext(null);
class App extends Component {
...
render() {
return (
<div className="main">
<Context.Provider value={...this.state, ...this.events}>
<Header />
<Body />
<Footer />
</Context.Provider>
</div>
)
}
}
const Header = () => {
// 获取到 Context 数据
const context = useContext(Context);
}
如果你已经接触过 Redux 这个状态管理库,你会惊奇地发现,如果我们把 App 组件中的 state 移到外面,这不就是 Redux 了吗?
没错,Redux 的核心原理也是这样,在组件外部维护一个 store,在 store 修改的时候会通知所有被 connect 包裹的组件进行更新。这个例子可以看做 Redux 的一个雏形。
根据前面的介绍我们已经知道了,Redux 是一个状态管理库,它并非绑定于 React 使用,你还可以将其和其他框架甚至原生 JS 一起使用,比如这篇文章:如何在非 React 项目中使用 Redux
Redux 工作原理:
在学习 Redux 之前需要先理解其工作原理,一般来说流程是这样的:
从这个流程中不难看出,Redux 的核心就是一个 发布-订阅 模式。一旦 store 发生了变化就会通知所有的订阅者,view 接收到通知之后会进行重新渲染。
Redux 有三大原则:
单一数据源
前面的那个例子,最终将所有的状态放到了顶层组件的 state 中,这个 state 形成了一棵状态树。在 Redux 中,这个 state 则是 store,一个应用中一般只有一个 store。
State 是只读的
在 Redux 中,唯一改变 state 的方法是触发 action,action 描述了这次修改行为的相关信息。只允许通过 action 修改可以使应用中的每个状态修改都很清晰,便于后期的调试和回放。
通过纯函数来修改
为了描述 action 使状态如何修改,需要你编写 reducer 函数来修改状态。reducer 函数接收前一次的 state 和 action,返回新的 state。无论被调用多少次,只要传入相同的 state 和 action,那么就一定返回同样的结果。
关于 Redux 的用法,这里不做详细讲解,建议参考阮一峰老师的《Redux 入门》系列的教程:Redux 入门教程
在 Redux 中,store 一般通过 createStore 来创建。
import { createStore } from 'redux';
const store = createStore(rootReducer, initalStore, middleware);
先看一下 Redux 中暴露出来的几个方法。
其中 createStore 返回的方法主要有 subscribe
、dispatch
、replaceReducer
、getState
。
createStore
接收三个参数,分别是 reducers 函数、初始值 initalStore、中间件 middleware。
store
上挂载了 getState
、dispatch
、subscribe
三个方法。
getState
是获取到 store 的方法,可以通过 store.getState()
获取到 store
。
dispatch
是发送 action 的方法,它接收一个 action 对象,通知 store
去执行 reducer 函数。
subscribe
则是一个监听方法,它可以监听到 store
的变化,所以可以通过 subscribe
将 Redux 和其他框架结合起来。
replaceReducer
用来异步注入 reducer 的方法,可以传入新的 reducer 来代替当前的 reducer。
store 的实现原理比较简单,就是根据传入的初始值来创建一个对象。利用闭包的特性来保留这个 store,允许通过 getState 来获取到 store。
之所以通过 getState 来获取 store 是为了获取到当前 store 的快照,这样便于打印日志以对比前后两次 store 变化,方便调试。
const createStore = (reducers, initialState, enhancer) => {
let store = initialState;
const getState = () => store;
return {
getState
}
}
当然,现在这个 store 实现的比较简单,毕竟 createStore 还有两个参数没用到呢。
先别急,这俩参数后面会用到的。
既然 Redux 本质上是一个 发布-订阅 模式,那么就一定会有一个监听方法,类似 jQuery 中的 $.on
,在 Redux 中提供了监听和解除监听的两个方法。
实现方式也比较简单,使用一个数组来保存所有监听的方法。
const createStore = (...) => {
...
let listeners = [];
const subscribe = (listener) => {
listeners.push(listener);
}
const unsubscribe = (listener) => {
const index = listeners.indexOf(listener)
listeners.splice(index, 1)
}
}
dispatch 和 action 是息息相关的,只有通过 dispatch 才能发送 action。而发送 action 之后才会执行 subscribe 监听到的那些方法。
所以 dispatch 做的事情就是将 action 传给 reducer 函数,将执行后的结果设置为新的 store,然后执行 listeners 中的方法。
const createStore = (reducers, initialState) => {
...
let store = initialState;
const dispatch = (action) => {
store = reducers(store, action);
listeners.forEach(listener => listener())
}
}
这样就行了吗?当然还不够。如果有多个 action 同时发送,这样很难说清楚最后的 store 到底是什么样的,所以需要加锁。在 Redux 中 dispatch 执行后的返回值也是当前的 action。
const createStore = (reducers, initialState) => {
...
let store = initialState;
let isDispatch = false;
const dispatch = (action) => {
if (isDispatch) return action
// dispatch必须一个个来
isDispatch = true
store = reducers(store, action);
isDispatch = false
listeners.forEach(listener => listener())
return action;
}
}
至此为止,Redux 工作流程的原理就已经实现了。但你可能还会有很多疑问,如果没有传 initialState,那么 store 的默认值是什么呢?如果传入了中间件,那么又是什么工作原理呢?
在刚开始接触 Redux 的 store 的时候,我们都会有一种疑问,store 的结构究竟是怎么定的?combineReducers 会揭开这个谜底。
现在来分析 createStore 接收的第一个参数,这个参数有两种形式,一种直接是一个 reducer 函数,另一个是用 combineReducers 把多个 reducer 函数合并到一起。
可以猜测 combineReducers 是一个高阶函数,接收一个对象作为参数,返回了一个新的函数。这个新的函数应当和普通的 reducer 函数传参保持一致。
const combineReducers = (reducers) => {
return function combination(state = {}, action) {
}
}
那么 combineReducers 做了什么工作呢?主要是下面几步:
const combineReducers = reducers => {
const finalReducers = {},
nativeKeys = Object.keys
// 收集所有的 reducer 函数
nativeKeys(reducers).forEach(reducerKey => {
if(typeof reducers[reducerKey] === "function") {
finalReducers[reducerKey] = reducers[reducerKey]
}
})
return function combination(state, action) {
let hasChanged = false;
const store = {};
// 遍历执行 reducer 函数
nativeKeys(finalReducers).forEach(key => {
const reducer = finalReducers[key];
// 很明显,store 的 key 来源于 reducers 的 key 值
const nextState = reducer(state[key], action)
store[key] = nextState
hasChanged = hasChanged || nextState !== state[key];
})
return hasChanged ? nextState : state;
}
}
细心的童鞋一定会发现,每次调用 dispatch 都会执行这个 combination 的话,那岂不是不管我发送什么类型的 action,所有的 reducer 函数都会被执行一遍?
如果 reducer 函数很多,那这个执行效率不会很低吗?但不执行貌似又无法完全匹配到 switch...case
中的 action.type
。
如果能通过键值对的形式来匹配 action.type
和 reducer 是不是效率更高一些?类似这样:
// redux
const count = (state = 0, action) => {
switch(action.type) {
case 'increment':
return state + action.payload;
case 'decrement':
return state - action.payload;
default:
return state;
}
}
// 改进后的
const count = {
state: 0, // 初始 state
reducers: {
increment: (state, payload) => state + payload,
decrement: (state, payload) => state - payload
}
}
这样每次发送新的 action 的时候,可以直接用 reducers
下面的 key 值来匹配了,无需进行暴力的遍历。
天啊,你实在太聪明了。小声告诉你,社区中一些类 Redux 的方案就是这样做的。以 rematch 和 relite 为例:
rematch:
import { init, dispatch } from "@rematch/core";
import delay from "./makeMeWait";
const count = {
state: 0,
reducers: {
increment: (state, payload) => state + payload,
decrement: (state, payload) => state - payload
},
effects: {
async incrementAsync(payload) {
await delay();
this.increment(payload);
}
}
};
const store = init({
models: { count }
});
dispatch.count.incrementAsync(1);
relite:
const increment = (state, payload) => {
state.count = state.count + payload;
return state;
}
const decrement = (state, payload) => {
state.count = state.count - payload;
return state;
}
考虑到这样的情况,我想要打印每次 action 的相关信息以及 store 前后的变化,那我只能到每个 dispatch 处手动打印信息,这样繁琐且重复。
createStore 中提供的第三个参数,可以实现对 dispatch 函数的增强,我们称之为 Store Enhancer
。
Store Enhancer
是一个高阶函数,它的结构一般是这样的:
const enhancer = () => {
return (createStore) => (reducer, initState, enhancer) => {
...
}
}
enhancer
接收 createStore 作为参数,最后返回的是一个加强版的 store
,本质上是对 dispatch 函数进行了扩展。
logger:
const logger = () => {
return (createStore) => (reducer, initState, enhancer) => {
const store = createStore(reducer, initState, enhancer);
const dispatch = (action) => {
console.log(`action=${JSON.stringify(action)}`);
const result = store.dispatch(action);
const state = store.getState();
console.log(`state=${JSON.stringify(state)}`);
return result;
}
return {
...state,
dispatch
}
}
}
createStore 中如何使用呢?一般在参数的时候,会直接返回。
const createStore = (reducer, initialState, enhancer) => {
if (enhancer && typeof enhancer === "function") {
return enhancer(createStore)(reducer, initialState)
}
}
如果你有看过 applyMiddleware 的源码,会发现这两者实现方式很相似。applyMiddleware 本质上就是一个 Store Enhancer
。
在创建 store 的时候,经常会使用很多中间件,通过 applyMiddleware 将多个中间件注入到 store 之中。
const store = createStore(reducers, initialStore, applyMiddleware(thunk, logger, reselect));
applyMiddleware 的实现类似上面的 Store Enhancer
。由于多个中间件可以串行使用,因此最终会像洋葱模型一样,action 传递需要经过一个个中间件处理,所以中间件做的事情就是增强 dispatch 的能力,将 action 传递给下一个中间件。
那么关键就是将新的 store 和 dispatch 函数传给下一个中间件。
来看一下 applyMiddleware 的源码实现:
const applyMiddleware = (...middlewares) => {
return (createStore) => (reducer, initState, enhancer) => {
const store = createStore(reducer, initState, enhancer)
const middlewareAPI = {
getState: store.getState,
dispatch: (action) => dispatch(action)
}
let chain = middlewares.map(middleware => middleware(middlewareAPI))
store.dispatch = compose(...chain)(store.dispatch)
return {
...store,
dispatch
}
}
}
这里用到了一个 compose 函数,compose 函数类似管道,可以将多个函数组合起来。compose(m1, m2)(dispatch)
等价于 m1(m2(dispatch))
。
使用 reduce 函数可以实现函数组合。
const compose = (...funcs) => {
if (!funcs) {
return args => args
}
if (funcs.length === 1) {
return funcs[0]
}
return funcs.reduce((f1, f2) => (...args) => f1(f2(...args)))
}
再来看一下 redux-logger 中间件的精简实现,会发现两者恰好能匹配到一起。
function logger(middlewareAPI) {
return function (next) { // next 即 dispatch
return function (action) {
console.log('dispatch 前:', middlewareAPI.getState());
var returnValue = next(action);
console.log('dispatch 后:', middlewareAPI.getState(), '\n');
return returnValue;
};
};
}
至此为止,Redux 的基本原理就很清晰了,最后整理一个精简版的 Redux 源码实现。
// 这里需要对参数为0或1的情况进行判断
const compose = (...funcs) => {
if (!funcs) {
return args => args
}
if (funcs.length === 1) {
return funcs[0]
}
return funcs.reduce((f1, f2) => (...args) => f1(f2(...args)))
}
const bindActionCreator = (action, dispatch) => {
return (...args) => dispatch(action(...args))
}
const createStore = (reducer, initState, enhancer) => {
if (!enhancer && typeof initState === "function") {
enhancer = initState
initState = null
}
if (enhancer && typeof enhancer === "function") {
return enhancer(createStore)(reducer, initState)
}
let store = initState,
listeners = [],
isDispatch = false;
const getState = () => store
const dispatch = (action) => {
if (isDispatch) return action
// dispatch必须一个个来
isDispatch = true
store = reducer(store, action)
isDispatch = false
listeners.forEach(listener => listener())
return action
}
const subscribe = (listener) => {
if (typeof listener === "function") {
listeners.push(listener)
}
return () => unsubscribe(listener)
}
const unsubscribe = (listener) => {
const index = listeners.indexOf(listener)
listeners.splice(index, 1)
}
return {
getState,
dispatch,
subscribe,
unsubscribe
}
}
const applyMiddleware = (...middlewares) => {
return (createStore) => (reducer, initState, enhancer) => {
const store = createStore(reducer, initState, enhancer);
const middlewareAPI = {
getState: store.getState,
dispatch: (action) => dispatch(action)
}
let chain = middlewares.map(middleware => middleware(middlewareAPI))
store.dispatch = compose(...chain)(store.dispatch)
return {
...store
}
}
}
const combineReducers = reducers => {
const finalReducers = {},
nativeKeys = Object.keys
nativeKeys(reducers).forEach(reducerKey => {
if(typeof reducers[reducerKey] === "function") {
finalReducers[reducerKey] = reducers[reducerKey]
}
})
return (state, action) => {
const store = {}
nativeKeys(finalReducers).forEach(key => {
const reducer = finalReducers[key]
const nextState = reducer(state[key], action)
store[key] = nextState
})
return store
}
}
如果想要将 Redux 结合 React 使用的话,通常可以使用 react-redux 这个库。
看过前面 Redux 的原理后,相信你也知道 react-redux 是如何实现的了吧。
react-redux 一共提供了两个 API,分别是 connect 和 Provider,前者是一个 React 高阶组件,后者是一个普通的 React 组件。react-redux 实现了一个简单的***发布-订阅***库,来监听当前 store 的变化。
两者的作用如下:
使用方式:
// Provider
ReactDOM.render({
,
document.getElementById('app')
})
// connect
@connect(mapStateToProps, mapDispatchToProps)
class App extends Component {}
先来实现简单的 Provider,已知 Provider 会使用 Context 来传递 store,所以 Provider 直接通过 Context.Provider
将 store 给子组件。
// Context.js
const ReactReduxContext = createContext(null);
// Provider.js
const Provider = ({ store, children }) => {
return (
{children}
)
}
Provider 里面还需要一个***发布-订阅器***。
class Subscription {
constructor(store) {
this.store = store;
this.listeners = [this.handleChangeWrapper];
}
notify = () => {
this.listeners.forEach(listener => {
listener()
});
}
addListener(listener) {
this.listeners.push(listener);
}
// 监听 store
trySubscribe() {
this.unsubscribe = this.store.subscribe(this.notify);
}
// onStateChange 需要在组件中设置
handleChangeWrapper = () => {
if (this.onStateChange) {
this.onStateChange()
}
}
unsubscribe() {
this.listeners = null;
this.unsubscribe();
}
}
将 Provider 和 Subscription 结合到一起,在 useEffect 里面注册监听。
// Provider.js
const Provider = ({ store, children }) => {
const contextValue = useMemo(() => {
const subscription = new Subscription(store);
return {
store,
subscription
}
}, [store]);
// 监听 store 变化
useEffect(() => {
const { subscription } = contextValue;
subscription.trySubscribe();
return () => {
subscription.unsubscribe();
}
}, [contextValue]);
return (
{children}
)
}
再来看 connect 的实现,这里主要有三步:
先来实现简单的获取 Context。
const connect = (mapStateToProps, mapDispatchToProps) => (WrappedComponent) => {
return function Connect(props) {
const { store, subscription } = useContext(ReactReduxContext);
return
}
}
接下来就要来实现如何在 store 变化的时候更新这个组件。
我们都知道在 React 中想实现更新组件只有手动设置 state 和调用 forceUpdate 两种方法,这里使用 useState 每次设置一个 count 来触发更新。
const connect = (mapStateToProps, mapDispatchToProps) => {
return (WrappedComponent) => {
return (props) => {
const { store, subscription } = useContext(ReactReduxContext);
const [count, setCount] = useState(0)
useEffect(() => {
subscription.onStateChange = () => setCount(count + 1)
}, [count])
const newProps = useMemo(() => {
const stateProps = mapStateToProps(store.getState()),
dispatchProps = mapDispatchToProps(store.dispatch);
return {
...stateProps,
...dispatchProps,
...props
}
}, [props, store, count])
return
}
}
}
react-redux 的原理和上面比较类似,这里只作为学习原理的一个例子,不建议用到生产环境中。
在开发中,如果想要查看当前页面的 store 结构,可以使用 Redux-DevTools 或者 React Developer Tools 这两个 chrome 插件来查看。
前者一般用于开发环境中,可以将 store 及其变化可视化展示出来。后者主要用于 React,也可以查看 store。
关于 Redux 中 store 如何设计对初学者来说一直都是难题,在我看来这不仅是 Redux 的问题,在任何前端 store 设计中应该都是一样的。
这里以知乎的问题页 store 设计为例。在开始之前,先安装 React Developer Tools,在 RDT 的 Tab 选中根节点。
然后在 Console 里面输入 $r.state.store.getState()
,将 store 打印出来。
可以看到 store 中有一个 entities 属性,这个属性中分别有 users、questions、answer 等等。
这是一个问题页,自然包括问题、回答、回答下面的评论 等等。
一般情况下,这里应该是当进入页面的时候,根据 question_id 来分批从后端获取到所有的回答。点开评论的时候,会根据 answer_id 来分批从后端获取到所有的评论。
所以你可能会想到 store 结构应当这样设计,就像俄罗斯套娃一样,一层套着一套。
{
questions: [
{
content: 'LOL中哪个英雄最能表达出你对刺客的想象?',
question_id: '1',
answers: [
{
answer_id: '1-1',
content: '我就是来提名一个已经式微的英雄的。没错,就是提莫队长...'
comments: [
{
comment_id: '1-1-1',
content: '言语精炼,每一句话都是一幅画面,一组镜头'
}
]
}
]
}
]
}
看图可以更直观感受数据结构:
这是初学者经常进入的一个误区,按照 API 来设计 store 结构,这种方法是错误的。
以评论区回复为例子,如何将评论和回复的评论关联起来呢?也许你会想,把回复的评论当做评论的子评论不就行了吗?
{
comments: [
{
comment_id: '1-1-1',
content: '言语精炼,每一句话都是一幅画面,一组镜头',
children: [
{
comment_id: '1-1-2',
content: '我感觉是好多画面,一部电影。。。'
}
]
},
{
comment_id: '1-1-2',
content: '我感觉是好多画面,一部电影。。。'
}
]
}
这样挺好的,满足了我们的需求,但 children 中的评论和 comments 中的评论数据亢余了。
聪明的你一定会想到,如果 children 中只保存 comment_id
不就好了吗?展示的时候只要根据 comment_id
从 comments 中查询就行了。
这就是设计 store 的精髓所在了。我们可以将 store 当做一个数据库,store 中的状态按照领域(domain)来划分成一张张数据表。不同的数据表之间以主键来关联。
因此上面的 store 可以设计成三张表,分别是 questions、answers、comments,以它们的 id 作为 key,增加一个新的字段来关联子级。
{
questions: {
'1': {
id: '1',
content: 'LOL中哪个英雄最能表达出你对刺客的想象?',
answers: ['1-1']
}
},
answers: {
'1-1': {
id: '1-1',
content: '我就是来提名一个已经式微的英雄的。没错,就是提莫队长...',
comments: ['1-1-1', '1-1-2']
}
},
comments: {
'1-1-1': {
id: '1-1-1',
content: '言语精炼,每一句话都是一幅画面,一组镜头',
children: ['1-1-2']
},
'1-1-2': {
id: '1-1-2',
content: '我感觉是好多画面,一部电影。。。'
}
}
}
你会发现数据结构变得非常扁平化,避免了数据亢余以及嵌套过深的问题。在查找的时候也可以直接通过 id 来查找,避免了通过索引来查找某一具体项。