从零到实现 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
和setState
1
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
:这个好理解,就是初始的state
action
:变动的描述。因为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 ../