redux
的开发和使用必须要遵循三大原则,即:
关于第一点很容易理解,整个应用应当只有一个 store
,全局唯一的 store
有利于更好的管理全局的状态,方便开发调试,对实现“撤销”、“重做”这类的功能也更加方便。
第二点,state
是只读的,因此,我们在任何时候都不应该直接修改 state
,唯一能改变 state
的方法就是通过 dispatch
一个 action
,间接的来修改,以此来保证对大型应用的状态进行有效的管理。
第三点,要想修改 state
,必要要编写 reducer
来进行,reducer
必须是纯函数,reducer
接收先前的 state
和 action
,并且返回一个全新的 state
。
前面我们介绍 redux
三大原则的时候提到过,修改 state
要编写 reducer
,且 reducer
必须是一个纯函数,那么问题来了,什么是纯函数呢?
维基百科里是这么定义纯函数的:
在程序设计中,若一个函数符合以下要求,则它可能被认为是纯函数
简单总结一下,如果一个函数的返回结果只依赖他的参数,并且在执行过程中没有副作用,我们就把这个函数定义为纯函数。
举个🌰:
const x = 1;
function add(a) {
return a + x;
}
foo(1); // 2
函数 add
就不是一个纯函数,因为函数 add
的返回值依赖外部变量 x
,输入一定的情况下,输出结果不确定。
我们把上面的例子稍微调整下:
const x = 1;
function add(a, b) {
return a + b;
}
foo(1, 2); // 3
现在的函数就是一个纯函数,因为函数 add
的返回值永远只依赖他的入参 a
和 b
,不管外部变量 x
的值如何变化,也不会影响到函数 add
的返回值。
再看一个例子:
function add(obj, a) {
obj.x = 1;
return obj.x + a;
}
const temp = {
x: 4,
y: 9,
}
add(temp, 10); // 11
console.log(temp.x); // 1
现在,我们给函数 add
传一个对象,并且,在函数 add
内部对这个对象的某个属性进行修改,在执行函数 add
的时候修改了外部传进来的 temp
对象,即产生了副作用,因此这不是一个纯函数。
除了上面说的在纯函数内部不能修改外部变量,在函数内部调用 Dom api
修改页面、发送 ajax
请求,甚至调用 console.log
打印日志都是副作用,在纯函数中都是禁止的,也就说,在纯函数内部我们一般只做计算数据的工作,计算的时候不能依赖函数参数以外的数据。
上面我们介绍了什么是纯函数,redux
里面规定 reducer
必须是一个纯函数,并且每个纯函数需要返回一个全新的state,那么这里大家肯定就有一个疑问,为什么 reducer
必须要返回一个全新的 state
,直接修改完了 state
再返回不行吗?
带着这个问题,我们来举个例子验证下,假如我们在一个 reducer
里面直接修改 state
的值,再返回修改后的 state
会发生什么。
我们定义三个组件:App
、Title
和Content
。App
作为Title
和Content
的父组件,有一个默认的 state
状态树,结构如下:
初始state:
{
book: {
title: {
tip: '我是标题',
color: 'red',
},
content: {
tip: '我是内容',
color: 'blue',
},
}
}
Title组件:
const Title = ({ title }) => {
console.log('render Title');
return <div style={{ color: title.color }}>{title.tip}</div>;
}
Content组件:
const Content = ({ content }) => {
console.log('render Content');
return <div style={{ color: content.color }}>{content.tip}</div>;
}
App组件:
const App = ({ book, dispatch }) => {
const changeTitleTip = () => {
dispatch({
type: 'book/changeTitleTip',
payload: {
title: {
tip: '修改后的title',
color: 'green',
},
},
});
};
console.log('render App');
return (
<div>
<Button onClick={changeTitleTip}>修改title名称</Button>
<Title title={book.title} />
<Content content={book.content} />
</div>
);
};
reducer:
reducers: {
changeTitleTip(state, { payload }) {
const { title } = payload;
state.title = title;
return state;
},
}
demo非常简单,我们在 App
组件里面触发一个 dispatch
,发送一个 action
,调用 reducer
来修改 state
里面的 title
,我们点击“修改title名称”按钮,发现组件并没有按照我们的预期发生变化,但是查看state里面的数据发现,state的值却变化了。
错误示例 页面并没有如预期发生变化:
错误示例
这个例子很好的验证了 redux
的说法,我们不能直接修改 state
,并返回。
现在调整下 reducer
,通过 ...
运算符重新新建一个对象,然后把 state
所有的属性都复制到新的对象中,我们禁止直接修改原来的对象,一旦你要修改某些属性,你就得把修改路径上的所有对象复制一遍,例如,我们不写下面的修改代码:
state.title.text = 'hello'
取而代之的是,我们新建一个 state
,新建 state.title
,新建 state.title.tip
。这样做的好处是可以实现共享结构的对象。
比如,state
和 newState
是两个不同的对象,这两个对象里面的 content
属性在我们的场景中是不需要修改的,因此 content
属性可以指向同一个对象,但是因为 title
被一个新的对象覆盖了,所以它们的 title
属性指向的对象是不同的,
使用一个树状结构来表示对象结构的话,结构如下如所示:
共享结构
现在的 reducer
:
reducers: {
changeTitleTip(state, { payload }) {
const { title } = payload;
let newState = { // 新建一个 newState
...state, // 复制 state 里面的内容
title: { // 用一个新的对象覆盖原来的 title 属性
...state.title, // 复制原来 title 对象里面的内容
tip: 'hello' // 覆盖 tip 属性
}
}
return newState;
}
}
重新点击 “修改title名称” 按钮,我们想要的效果就可以实现了。
修改后的效果 好了,知道结果之后我们来稍微探究下背后的原因。
查看 redux
的 combineReducers
源代码
let hasChanged = false
const nextState: StateFromReducersMapObject<typeof reducers> = {}
for (let i = 0; i < finalReducerKeys.length; i++) {
const key = finalReducerKeys[i]
const reducer = finalReducers[key]
const previousStateForKey = state[key]
const nextStateForKey = reducer(previousStateForKey, action)
if (typeof nextStateForKey === 'undefined') {
const actionType = action && action.type
throw new Error(
`When called with an action of type ${
actionType ? `"${String(actionType)}"` : '(unknown type)'
}, the slice reducer for key "${key}" returned undefined. ` +
`To ignore an action, you must explicitly return the previous state. ` +
`If you want this reducer to hold no value, you can return null instead of undefined.`
)
}
nextState[key] = nextStateForKey
hasChanged = hasChanged || nextStateForKey !== previousStateForKey
}
hasChanged =
hasChanged || finalReducerKeys.length !== Object.keys(state).length
return hasChanged ? nextState : state
我们发现,combineReducers
内部通过 hasChanged = hasChanged || nextStateForKey !== previousStateForKey
来比较新旧两个对象是否一致,来判断返回 nextState
还是 state
,出于性能考虑,redux 直接采用了浅比较,也就是说比较的是两个对象的引用地址,所以,当 reducer
函数直接返回旧的 state
对象时,这里的浅比较就会失败,redux
认为没有任何改变,从而导致页面更新出现某些意料之外的事情。
上面我们已经分析了 redux
里面的 reducer
为什么要返回一个全新的 state
,但是,如果按照上面 reducer
的写法,要修改的 state
树层级深了之后,修改起来无疑是非常麻烦的,那么有没有什么快捷的方式可以方便我们直接修改 state
呢?
答案是有的。
immer
是 mobx
的作者写的一个 immutable
库,核心实现是利用 ES6
的 proxy
,几乎以最小的成本实现了 js
的不可变数据结构,简单易用、体量小巧、设计巧妙,满足了我们对 js
不可变数据结构的需求。当然,除了 immer
之外,还有别的库也同样能解决我们的问题,但是 immer
应该是最简单也是最容易上手的一个库之一了。
如果你的工程使用的是dva
,那么可以直接开启 dva-immer
,就可以简化 state
的写法。上面的例子就可以这么写:
reducers: {
changeTitleTip(state, { payload }) {
const { title } = payload;
state.title = title;
}
}
或者直接使用 immer
库来改进我们的 reducer
写法:
安装:
yarn add immer
使用:
import produce from "immer";
const reducer = (state, action) => produce(state, draft => {
const { title } = payload;
draft.title = title;
});
本篇文章重点介绍了 redux
的相关概念,什么是纯函数,以及为什么 reducer
需要返回一个全新的 state ?从源码角度分析了需要返回全新state的原因,最后引入了immer
库,引入了 immutable
概念,redux 配合 immer
可以方便我们便捷高效的用好 redux
。