从零到实现 Redux 的过程
Dec 17, 2022
个人愚见:Redux 的源码特别难看懂,也可能是 Redux 的源码是以实用为目的,看起来没什么头绪;
所以我一步一步实现一个 Redux 其中也有一些坑,最终效果和 Redux 的接口几乎是一致的,跟着以下思路,或许可以更容易理解 Redux 为什么要这么实现,包括每个概念
一、尝鲜使用 Vite4 初始化项目
今天是 Vite4 发布的第二天,我使用上了!
2022-12-13
我已经准备了一个简单的 demo,它们的结构特别简单
- App 中有 3 个子元素,分别是:
Parent、Son、Grandson - Parent 负责
展示 User 数据,Son 负责修改 User 数据
1 | import * as React from 'react' |
二、使用 useContext 来读写数据
1. 数据从哪里来?
代码在上面 demo
- 使用 useState 声明了一个对象,里面包含 user
- 把 appState 和 setAppstate 封装成一个对象:contextValue
- 把 contextValue 放到 appContext.Provider 里
- appContext 是使用 React.createContext 创建的
2. 如何让 User 获取到 user 数据?
- 只需要两句话:
- 使用 useContext:
const contextValue = useContext(appContext) - 获取
contextValue.appState.user.name
3. 如何修改数据?
- 那么我们就需要调用
setAppState - 看
UserModifier里的onChange方法 - 注意:这个代码非常的不规范,后面会纠正它们!
三、reducer 的由来
reducer就是用来规范 state 创建流程的一个函数
之前的代码,创建 state 的时候特别的不规范,它直接去修改了原始的 state
那么如何解决呢?
提供一个函数来帮他去创建新的 state
1. reducer 雏形 —— createNewState()
目的:规范了创建流程
1 | // 接受3个参数:state(旧的state), actionType(操作),actionData(新的state) |
2. 如何使用?
1 | const UserModifiler = () => { |
3. reducer 接收两个参数!
那也简单
把 actionType 和 actionData 统一成一个叫 action 的东西,接受一个 type 和一个 payload
payload 其实就是 data 的意思
1 | -const createNewState = (state, actionType, actionData) => { |
3. reducer 使用
1 | const UserModifiler = () => { |
这样的话,reducer 就写出来了,是不是特别的简单呢?
这一点就只需记住一句话:reducer 是用来规范 state 创建流程的一个函数
四、dispatch
如何使用
dispatch来规范setState的流程
1. 太多重复的代码
首先来看一下我们之前是如何 setState 的:
setAppState(reducer(appState, {type: 'updateUser', payload: {name: e.target.value}}))
那如果我们要改 user 的 age 和 height 该怎么改?
1 | setAppState(reducer(appState, { type: 'updateAge', payload: { age: e.target.value } })) |
每次都要重复代码,那么下面将对其进行优化
2. 去除重复的代码
dispatch 的由来
第一步:实现 dispatch
我们写一个 dispatch
1 | const dispatch = action => { |
使用:
1 | const UserModifier = () => { |
你觉得这样子就可以了吗?
并不能,因为 UserModifier 里的 dispatch 是没有办法访问到 setAppState 和 appState 的
React 规定:只能在组件内使用 hooks
至于出现这种情况,是由于我们把 state 放在了 context 里,如果 state 不在 context 里,那就好办了,但是那种改动太大了
那我们想想,就以现在的办法如何实现:让 dispatch 访问到 state 和 setState
第二步:实现让 dispatch 访问到 state 和 setState
思路:我们用一个组件来包住 dispatch,然后把 dispatch 再给需要使用的组件
1 | // 使用 Wrapper 来包住 UserModifier |
1 | const Wrapper = () => { |
想要读数据,就从 props 里面读 state,想要写数据就从 props 里使用 dispatch
目前,我们就完成了 UserModifier 一个组件的封装,它可以通过 props 来读写全局数据
所有人,直接调 dispatch,不要用去多写那三个单词了
实际上这个功能不是由 redux 实现的,是由 react-redux 实现的,但是大家用的时候都是一起用的,这里就不做区分了
五、高阶组件 connect
让组件与全局状态连接起来
原理:函数里接收一个组件,返回一个新的组件
在上面代码,我们是把 UserModifier 包装成了 Wrapper,用的时候我们是一定要使用 Wrapper,因为如果直接使用 UserModifier 是得不到 dispatch 和 state,因此,我们任何一个组件想要读取全局 state,都需要封装成一个 Wrapper,那如果有 100 个组件,难道都要重新写 100 遍吗?当然不是这样子的
所以我们需要声明一个函数来实现,用来自动创建之前的 Wrapper
1 | // connect |
如果你看 redux 提供的 connect,你会发现它接收的参数比我上面的组件还多,后面我们接着实现!
六、避免多余的 render
我们在之前的代码里每个组件都加上 log
1 | const Brother = () => { |
我们会发现:我们只改一个组件,而上面 5 个组件都会重新 render,这样子我们只要改动 state 中的一小点,就会导致整个应用的重新执行
我们希望用到的时候,才 render
我们来看下问题是如何产生的:
- 当我们改变
input的值的时候,它会调用setAppState - 是通过
dispatch调用到的 dispatch是由context来的- 而
context最初是从AppContext拿到的 - 根据 React 规定:只要调用到这个组件的
setState,并且给setState,传的是一个新对象,那么这个组件就一定会重新渲染
首先我们想到的是使用 useMemo,这样能避免组件重新执行,但是,这么写太麻烦了,那么 redux 会设计一种机制:只有用到 state 里某个属性的地方,在这个属性变化的时候,再重新执行
实现思路及过程:
- 首先我们把
setState移除掉,因为它必然会导致组件执行 - 我们创建一个对象
store,里面有state和setState1
2
3
4
5
6
7
8
9
10const store = {
state: {
// 用于存放数据
user: { name: 'heycn', age: 22 }
},
setState(newState) {
// 用于修改数据
store.state = newState
}
} - 把之前用到
useState的地方都改为store的state和setState - 目前展示没问题,但是修改无法显示,其实数据已经在
store改变了,只是我们没有调用react的useState,那么我们让他强制刷新 - 在
connect里使用const [, update] = useState({}),它的值为一个空对象,然后再dispatch里调用update({}),这样的话,被connect的组件就会强制刷新 - 但是这样的话,其他使用到的
state的组件,就无法更新,所以我们需要去订阅一下变化 - 在
store里创建subscribe函数,我们可以让每个组件订阅state的变化1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19const store = {
state: {
user: { name: 'heycn', age: 22 }
},
setState(newState) {
store.state = newState
// 每次 setState 就告诉订阅者
store.listeners.map(fn => fn(store.state))
},
listeners: [], // 把所有订阅的监听者放进来
subscribe(fn) {
store.listeners.push(fn)
return () => {
// 取消订阅
const index = store.listeners.indexOf(fn)
store.listeners.splice(index, 1)
}
}
} - 在
connect中使用useEffect,只在组件第一次渲染时订阅,调用useState的update()
以下是完整代码
1 | import * as React from 'react' |
七、Redux 雏形
将代码抽离,把 redux 有关的代码放在同一个文件
八、让 connect 支持 selector
react-redux提供的selector
这是一个选择函数,比如:
1 | const User = connect(state => { |
以下是 api 的实现步骤:
- 来到
redux.js里 的connect - 我们给他添加一个参数,表示先接受一个参数,再接受第二个参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18- const connect = Component => {
+ const connect = selector => Component => {
return props => {
const { state, setState } = useContext(appContext)
const [_, forceUpdate] = useState({})
+ const data = selector ? selector(state) : {state}
useEffect(() => {
store.subscribe(() => {
forceUpdate({})
})
}, [])
const dispatch = action => {
setState(reducer(state, action))
}
- return <Component {...props} dispatch={dispatch} state={state} />
+ return <Component {...props} {...data} dispatch={dispatch} />
}
}
我们通过一些简单的代码就实现了 selector,他还有其他非常重要的作用,请看下面!
九、实现精准渲染
使用
selector来实现精准渲染
组件只在自己的数据变化时render
问题:
我们在
store里添加:1
2
3
4state: {
user: { name: 'heycn', age: 22 },
+ educational: { school: 'Tsinghua University' }
}然后在
Cousin读取新添加的数据1
2
3
4
5
6
7
8
9
10
11-const Cousin = () => {
+const Cousin = connect(state => {
+ return { educational: state.educational }
+})(() => {
return (
<section>
<h1>Cousin</h1>
+ <div>educational: {educational.school}</div>
</section>
)
})然后让我们修改
user时,Cousin也会重新渲染,而Cousin里只使用到educational
如何解决
那我可以在
Cousin做一个检查:如果Cousin没有更新,我们就不去重新渲染Cousin但是这是存在一个逻辑悖论的,因为:如果
Cousin要去做检查,那么这个时候Cousin就已经执行了,我们可以在connect里做手脚在
connect里我们返回一个组件,我们叫它为Wrapper,这个Wrapper的作用很大,我们可以在Wrapper里面做检查:如果被选择的
selectedState没有改变,我们就不去做渲染1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31+const changed = (oldState, newState) => {
+ let changed = false
+ for (let key in oldState) {
+ if (oldState[key] !== newState[key]) {
+ changed = true
+ break
+ }
+ }
+ return changed
+}
export const connect = selector => Component => {
return props => {
const { state, setState } = useContext(appContext)
const [_, forceUpdate] = useState({})
const selectedState = selector ? selector(state) : { state }
useEffect(() => {
store.subscribe(() => {
+ const selectedState = selector ? selector(state) : { state }
+ if (changed(selectedState, newSelectedState)) {
forceUpdate({})
+ }
})
- }, [])
+ }, [selector])
const dispatch = action => {
setState(reducer(state, action))
}
return <Component {...props} {...selectedState} dispatch={dispatch} />
}
}但是,我们需要取消订阅,不然可能在意想不到的时候不停地订阅,所以需要进行取消订阅,由于订阅里面返回了取消订阅,所以只需要这么做:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21export const connect = selector => Component => {
return props => {
const { state, setState } = useContext(appContext)
const [_, forceUpdate] = useState({})
const selectedState = selector ? selector(state) : { state }
useEffect(() => {
+ return {
store.subscribe(() => {
const selectedState = selector ? selector(state) : { state }
if (changed(selectedState, newSelectedState)) {
forceUpdate({})
}
})
+ }
}, [selector])
const dispatch = action => {
setState(reducer(state, action))
}
return <Component {...props} {...selectedState} dispatch={dispatch} />
}
}
这样就实现精准渲染:组件只在自己的数据变化时 render!
十、connect 的第二个参数:mapDispatchToProps
api 设计
我们期望 api 是这么使用的:
1 | const UserModifier = connect(null, dispatch => { |
代码实现
1 | export const connect = selector => Component => { |
十一、connect 的意义
我们会发现 connect 函数的调用形式很奇怪,我们来看看究竟是在考虑什么!
看这里代码的 diff 就明白了:代码链接
mapStateToProps 是用来封装写,mapDispatchToProps 是用来封装读,所以 connect 是用来封装 读 和 写,也就是封装一个资源,你可以对这个资源进行读写,然后只要再传一个组件就行了,之所以要分成两次调用,就是为了方便:你先调用一次得到一个 “半成品”,这个 “半成品” 可以跟任何组件相结合,它会把 读、写 接口传给任何组件,然后等你想用一个组件的时候,就可以调不同的组件
这就是 connect 的意义
十二、封装 Provider 和 createStore
createStore
它接受两个参数,一个 reducer 一个 initState
看代码 diff 即可知道如何封装:代码链接
封装 Provider
redux 官方的使用方式是这样子的:<Provider store={store}></Provider>,那我们只需要分装成一个组件即可:
1 | export const Provider = ({ store, children }) => { |
目前为止,目前我们的封装的 redux 和 官方的 redux 的接口,几乎是一致的,可能会有一些细微的区别,通过手写 redux,我们基本可以理解 redux 的实现,让我们总结一下
十三、Redux 概念总结(精髓)
让我们来了解,redux 和 react-redux 的主要思路
请配合代码阅读
主要思路
- 首先我们有一个
App组件,里面包含很多组件,我们需要让每一个组件都可以访问到一个全局的state state是我们的第一个概念:state放在哪里呢?redux是把他放在store里的- 让组件和
store的state连接起来:react-redux提供的一个 api ——connect,用于连接组件和state store的state连接之后做什么:连接之后就是读和写读操作:从组件的属性里面取state,如果想读得更精确,可以传一个mapStateToProps写操作:从组件的属性里面取dispatch,如果想写得更精确,可以传一个mapDispatchToProps,可以用来封装api,你可以对这个api的资源进行读写,然后只要再传一个组件就行了
回顾 connect 作用
connect实际上是对组件进行一封装,我称这个组件为Wrapper,然后把Wrapper返回出去,主要做了 3 件事情- 第一件事
获取读写接口:从上下文拿到state和setState(是store的),也就是拿到读和写接口,实际上不用上下文也行,直接从store也行 - 第二件事
封装读写接口:进行封装,比如根据mapStateToProps得到具体的数据和具体的mapDispatchToProps - 第三件事
订阅store更新:在恰当的时候进行更新,对store进行订阅,只要store变化,就会在数据变更的情况下调用forceUpdate进行强制更新组件,这里的forceUpdate是我做的一个小技巧 - 最后就返回
Wrapper这个组件
简单来讲,就是:
- 获取读写接口
- 封装读写接口
- 订阅
store更新,如果store更新了,就更新组件 - 返回这个组件
总结
现在我们已经知道 store、state、dispatch、connect、Provider 的概念了
dispatch 这里又可以分出几个概念,比如:
reducer:这个很难具体的解释,但我把他认为是规范创建state的过程,因为每次更新state我们不能改原来的state,要创建新的state,所以他是创建state的过程initState:这个好理解,就是初始的stateaction:变动的描述。因为reducer是接受一个state,一个action然后返回一个新的state;所以是这一次变动的描述;比如action的类型、payload可以是store的具体的各种信息
到这里,redux 的大部分概念我们都彻底的了解了
十四、API 封装技巧
看代码 diff
十五、让 Redux 支持函数 Action
目前我们的 redux 是不支持异步 Action 的,我们来看下如果要做异步的 Action 的话,我们应该怎么做,只做了以下几步
1 | let { dispatch } = store |
十六、让 Redux 支持 PromiseAction
api 设计
1 | dispatch({ type: 'updateUser', payload: ajax('/user').then(response => response.data) }) |
代码实现
1 | const asyncDispatch = dispatch |
十七、中间件 redux-thunk 和 redux-promise 原理
一个 Middleware 就是一个函数,这个函数可以去修改 dispatch
这两个中间件可以让 redux 支持异步 action
redux-thunk
如果 action 是一个函数,就调用它,否则就进入下一个函数
redux-promise
如果 payload 是一个函数,就在 Promise 后面接上一个 then 和 catch,否则就进入下一个函数
感谢阅读,下次见 :)
cd ../