前前言
朋友们好啊,我是来自推啊前端团队的 jarvis 同学,本次分享的内容是「解读Redux、React-Redux源码」。如果大家有不同的观点,欢迎在评论区吐槽和指正哦~😝😝😝
写在前面
作为 React 最早的「状态管理库」,Redux一直是一个备受争议的存在,有人喜欢它单一的数据流向和方便管理回朔的优点,有人讨厌它 入门demo 的复杂。但整体来看,Redux 依旧是一个让人叹服精巧的js库。作为 React 曾经的深度用户,确实也是有很多想要分享的事情。
在初次体验Redux后,我在大脑中思考了以下几个问题:
reducer
的switch
语句为什么要写default
分支,不写会引发什么问题?🤔const counter = (state, action) => { switch (action.type) { case 'ADD': return state + 1; // 诶,我不写就是玩~ // default: // return state; } }
- 有Redux还不够吗,为什么又蹦跶出来个 React-Redux?🧐
- 组件不写
connect
,store
更新 不会触发组件更新,why?🤨 - 为什么用
Vuex
时代码量那么少,而用Redux
要写这么多代码?🤨 - 我想在
action
中用异步方法,为什么还要用中间件呢?🤨 - 中间件是个锤子哦?🤨
Redux怎么读?为什么有人教我念durex['djʊəreks]🤣
小伙伴们对此怎么看呢,或许你会很轻松地侃侃而谈,又或者你也正在为此困惑。其实对于这一类问题,最根本的解决方式就是明白其原理,方可拨开迷雾见光明。
接下来我们会结合源码,对 Redux和React-Redux 的原理一探究竟🧐🧐🧐。
二者的关系
在聊源码之前,我们先说说二者的关系,
Redux 是一个基于js实现的 数据仓库 ,通过手动 订阅更新 可以定义数据改变时的操作。
如果没有 React-Redux,我们使用 Redux 将会是这种情景:
import { createStore } from 'redux';
// 1.创建reducer
function counter(state, action) {
switch (action.type) {
case 'ADD':
return state + 1;
default:
return state;
}
}
// 2.创建store
const store = Redux.createStore(counter);
// 3.在应用中使用
function App() {
const [, forceUpdate] = useState({});
useEffect(() => {
const unsubscribe = store.subscribe(() => {
forceUpdate({});
});
return () => unsubscribe();
}, []);
const add = () => {
store.dispatch({
type: 'ADD',
});
};
return <div onClick={add}>{store.getState()}</div>;
}
嗯?还要手动订阅更新,是不是很麻烦🤔~
我们想简化代码,毕竟在组件里写订阅更新对我们的业务侵入性太高了,于是我们找来了 React-Redux,
React-Redux是 React 和 Redux 的中间桥梁,它简化了在 React 中使用 Redux 的成本,让我们可以在 React 组件 中很容易地获取想要的 store
并且自动订阅更新,于是我们的操作将变得很简单:
import { createStore } from 'redux';
// 1.创建reducer
function counter(state, action) {
switch (action.type) {
case 'ADD':
return state + 1;
default:
return state;
}
}
// 2.创建store
const store = Redux.createStore(counter);
// 3.在根应用中共享状态
ReactDom.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
)
// 4.在业务组件使用
const App = connect()((props) => {
const add = () => { props.dispatch({ type: 'ADD', }); };
return <div onClick={add}>{store.getState()}</div>;
})
可以看到,我们不用再手动订阅更新,另外通过connect
方法处理后的 组件 有了新的props
。其实这就是React-Redux最核心的部分。
所以说,Redux并不是专门为React设计的数据仓库,它可以在任何js库中使用。
1. Redux
1.1. 预先了解
- 函数式编程
- 纯函数
- 合成函数(compose)
- 柯里化
- 洋葱模型
1.2. 源码(1):createStore
1.2.1. createStore
-
作用
- 创建数据仓库
store
- 创建数据仓库
-
核心源码
// 省略了若干代码 export default function createStore(reducer, preloadedState, enhancer) { // 处理边缘情况 ... // 中间件 if (typeof enhancer !== 'undefined') { return enhancer(createStore)(reducer, preloadedState) } // 存储reducer,因为后续可能有覆盖操作 let currentReducer = reducer; // 存储当前状态 let currentState = preloadedState; // 存储订阅函数 let currentListeners; // 存储下一批订阅函数 let nextListeners = currentListeners; // 是否在执行reducer let isDispatching = false; // 深拷贝currentListeners赋值给nextListeners function ensureCanMutateNextListeners() {} // 获取当前状态 function getState(): S {} // 添加订阅函数 function subscribe(listener: () => void) {} // 调用 reducer 生成新的state function dispatch(action: A) {} // 替换reducer function replaceReducer(nextReducer); // 为 观察者模式/响应式 框架提供的可扩展方法,比如Vue function observable() {} // 初始化state dispatch({ type: ActionTypes.INIT } as A); const store = { dispatch: dispatch as Dispatch<A>, subscribe, getState, replaceReducer, [$$observable]: observable, }; return store; }
一个 Store 实例包括五部分,
getState
: 获取当前状态dispatch
: 调用 reducer 生成新的 statesubscribe
: 添加订阅函数ensureCanMutateNextListeners
: 深拷贝currentListeners
赋值给nextListeners
observable
: 为 观察者模式/响应式 框架提供的扩展方法
🐳🐳🐳 实例初始化完成后自动调用一次
dispatch
用作store
的初始值,值得注意的是此次 action 的type
(ActionTypes.INIT
)是一个拼接后的随机字符串:const ActionTypes = { INIT: `@@redux/INIT${/* #__PURE__ */ randomString()}`, // 还有另外两个,也是伴随着随机数 };
type
是随机字符串,走switch
语句的default
分支,返回结果就会作为state
的初始值,所以不处理default
分支可能会造成某些错误💦💦💦。
1.2.2. getState
-
作用
- 返回当前的
state
- 返回当前的
-
核心源码
function getState() { if (isDispatching) { throw new Error( 'You may not call store.getState() while the reducer is executing. ' + 'The reducer has already received the state as an argument. ' + 'Pass it down from the top reducer instead of reading it from the store.', ); } return currentState; }
isDispatching
是正在执行reducer的标志,reducer
用于改变state
,所以此时获取state
是不安全的。
1.2.3. dispatch
-
作用
- 通过reducer改变
state
- 触发所有的订阅更新
- 通过reducer改变
-
核心源码
function dispatch(action: A) { // 处理两种边缘情况,保证参数action可以使用 ... try { isDispatching = true; currentState = currentReducer(currentState, action); } finally { isDispatching = false; } const listeners = (currentListeners = nextListeners); for (let i = 0; i < listeners.length; i++) { const listener = listeners[i]; listener(); } return action; }
接收
action
作为参数,通过currentReducer
返回一个新的state
,currentReducer
就是我们写的reducer
。最后将
action
原样返回,这是为了后续给中间件使用,在中间件部分会详细讲解。我们看到:每次返回一个新的state。思考一下,如果每次返回一个新的引用,并且组件不做优化,是不是会引起不必要的渲染呢,😖😖😖切记,这是一个大坑!我们最后会整理。
1.2.4. subscribe
-
作用
- 添加订阅函数
-
核心源码
function subscribe(listener: () => void) { // 处理边缘情况,保证 listener 是一个函数,并且没有在执行reducer ... let isSubscribed = true; // 添加到nextListeners中,这里面存放的是 订阅函数 ensureCanMutateNextListeners(); nextListeners.push(listener); return function unsubscribe() { if (!isSubscribed) { return; } if (isDispatching) { throw new Error( 'You may not unsubscribe from a store listener while the reducer is executing. ' + 'See https://redux.js.org/api/store#subscribelistener for more details.' ); } isSubscribed = false; ensureCanMutateNextListeners(); // 删除当前的订阅函数 const index = nextListeners.indexOf(listener); nextListeners.splice(index, 1); currentListeners = null; }; }
添加订阅者,返回一个取消订阅的方法。这里的
ensureCanMutateNextListeners
是何物呢?
1.2.5. ensureCanMutateNextListeners
-
作用
- 确保当前可以执行的订阅函数不被改变
-
核心源码
let currentListeners = []; let nextListeners = currentListeners; function ensureCanMutateNextListeners() { if (nextListeners === currentListeners) { nextListeners = currentListeners.slice(); } } function subscribe(fn) { ensureCanMutateNextListeners(); // 这里做了一次深拷贝 nextListeners.push(fn); // 在nextListeners数组中添加不会影响currentListeners数组 }
刚看到这里我是很疑惑的,后来经过多次 「debug调试」 终于发现这是为了处理某种边缘情况:在更新的过程中如果删除或新增订阅者,本次更新不包括新加入的订阅者,下次更新才会带上。
1.3. 源码(2):中间件
经过以上步骤呢,我们也发现了,只有通过dispatch
才可以改变state
,而dispatch
则是调用reducer
,reducer
只是一个单纯的计算器,接收state
和action
,返回新的state
。
由此可以想象,我们没法直接在dispatch
中使用 异步方法 改变state
。
那么接下来,就该中间件登场了,中间件的作用就是 增强dispatch
📍
const store = createStore(reducer, applyMiddleware(中间件1, 中间件2, 中间件3, ...))
1.3.1. applyMiddleware
-
作用
- 接收多个中间件作为参数
- 按照 顺序 调用 中间件
- 最终返回一个新的dispatch(增强后的dispatch)
-
核心源码
export default function applyMiddleware(...middlewares) { return (createStore) => (reducer, preloadedState) => { const store = createStore(reducer, preloadedState); let dispatch = () => { throw new Error( 'Dispatching while constructing your middleware is not allowed. ' + 'Other middleware would not be applied to this dispatch.', ); }; const middlewareAPI = { getState: store.getState, dispatch: (action, ...args) => dispatch(action, ...args), }; const chain = middlewares.map((middleware) => middleware(middlewareAPI)); dispatch = compose(...chain)(store.dispatch); return { ...store, dispatch, }; }; }
这里使用了函数式编程概念中的合成函数
compose
,也就是 洋葱圈模型,后面我们会看一下源码,这里可以先简单理解为得到一个「增强dispatch」,这个「增强dispatch」每次执行时会额外执行中间件的逻辑。我们先整理一下
applyMiddleware
的步骤:- 首先按照顺序为中间件注入核心
store
,核心store
包括getState
和dispatch
; - 接着按照顺序执行中间件,依次返回最新的
createStore
方法; - 然后通过链式组合中间件将dispatch合成一个增强后的dispatch;
- 最后作为一个完整的
store
返回。
所以中间件是一个高阶函数:接收
store
,返回【返回一个新dispatch
的函数】的createStore
函数下面是两个常用中间件的源码,可以看到都很简洁
-
redux-thunk
function createThunkMiddleware(extraArgument) { return ({ dispatch, getState }) => (next) => (action) => { if (typeof action === 'function') { return action(dispatch, getState, extraArgument); } return next(action); }; } const thunk = createThunkMiddleware(); thunk.withExtraArgument = createThunkMiddleware; export default thunk;
之所以返回了三层函数,其实是redux-thunk的特性所致,redux-thunk允许我们在
dispatch
中派发一个 函数 用来做异步操作。 -
redux-promise
export default function promiseMiddleware({ dispatch }) { return (next) => (action) => { if (!isFSA(action)) { return isPromise(action) ? action.then(dispatch) : next(action); } return isPromise(action.payload) ? action.payload .then((result) => dispatch({ ...action, payload: result })) .catch((error) => { dispatch({ ...action, payload: error, error: true }); return Promise.reject(error); }) : next(action); }; }
这个就比较中规中矩了,返回嵌套两层的函数,用于处理异步的情况。
- 首先按照顺序为中间件注入核心
1.3.2. compose
-
作用
- 按照顺序将多个函数组合成一个新函数
-
核心源码
export default function compose(...funcs: Function[]) { // 下面两个if都是处理边缘情况 if (funcs.length === 0) { return <T>(arg: T) => arg; } if (funcs.length === 1) { return funcs[0]; } // 通过reduce将函数按照顺序组合起来 return funcs.reduce((a, b) => (...args: any) => a(b(...args))); }
在函数式编程中通常用来组合多个函数。比如
compose(a, b, c)
,最终会得到新函数(...arbs) => a(g(c(...args)))
。 可以简单理解为:洋葱最里面的核心就是我们的dispatch
,所谓 组合 就是增加了一层层的洋葱圈,每个洋葱圈是一个中间件,最终执行dispatch
的时候会依次经过外面的洋葱圈,最后才会到达核心。
1.4. 源码(3):组合器
组合器也就是 combineReducers
-
作用
- 接收多个
reducer
作为参数 - 最终返回一个新的reducer(增强后的reducer)
- 新的reducer会依次经过每一个
reducer
计算,最终返回新的state
- 接收多个
-
核心源码
export default function combineReducers(reducers) { const reducerKeys = Object.keys(reducers); const finalReducers = {}; // 保留符合规范的reducer for (let i = 0; i < reducerKeys.length; i++) { const key = reducerKeys[i]; if (typeof reducers[key] === 'function') { finalReducers[key] = reducers[key]; } } // 最终reducer的所有key const finalReducerKeys = Object.keys(finalReducers); // 返回一个新的 reducer return function combination(state = {}, action) { // 用 hasChanged变量 记录前后 state 是否已经修改 let hasChanged = false; // 声明对象来存储下一次的state const nextState = {}; // 遍历 finalReducerKeys for (let i = 0; i < finalReducerKeys.length; i++) { const key = finalReducerKeys[i]; const reducer = finalReducers[key]; const previousStateForKey = state[key]; // 执行 reducer const nextStateForKey = reducer(previousStateForKey, action); // 一些处理边缘情况的代码 ... nextState[key] = nextStateForKey; // 两次 key 对比 不相等则发生改变 hasChanged = hasChanged || nextStateForKey !== previousStateForKey; } // 最后的 keys 数组对比 不相等则发生改变 hasChanged = hasChanged || finalReducerKeys.length !== Object.keys(state).length; return hasChanged ? nextState : state; }; }
拿到所有的reducer,当执行dispatch时从上向下依次经过每个 reducer 处理,因此不可以
action.type
切记不可以重复!
因为该函数的功能是组合 reducer,所以最终返回一个新的reducer
1.5. 流程梳理
- Redux初始化时首先判断是否有使用中间件,没有则通过默认方式初始化,有的话通过
applyMiddleware
函数进行创建 applyMiddleware
接收的参数是默认的createStore
方法,最终会返回一个新的createStore
方法,其中dispatch
的功能会被中间件增强- 2.1. 在
applyMiddleware
内部,将中间件依次执行,获取到中间件函数的数组形式 - 2.2. 然后通过
compose
将所有的中间件函数合并成一个dispatch
- 2.3. 最后将加强的
dispatch
和剩余的store
一起返回
- 2.1. 在
combineReducers
接收一个对象类型的参数,将多个reducer合并成一个函数,执行dispatch时自顶向下依次通过每一个reducer得到最终的state
2. React-Redux
React-Redux 的原理很简单,但源码为了处理很多边缘情况而显得略微复杂,提取核心,可以总结如下
- 创建 Context ,通过 Provider 将 store 共享给子组件;
- 类组件 通过 高阶函数
connect
完成订阅更新;通过mapStateToProps
实现props注入、通过mapDispatchToProps
实现dispatch注入; - 函数组件 通过
useSelector
完成订阅更新、实现state注入;通过useDispatch
返回最新的dispatch
。
2.1. connect
-
作用
- connect是一个高阶组件,在组件内部完成订阅、实现
mapStateToProps
和mapDispatchToProps
- connect是一个高阶组件,在组件内部完成订阅、实现
-
核心原理代码
export const connect = (mapStateToProps, mapDispatchToProps) => ( WrapperComponent, ) => (props) => { const store = useStore(); const { getState, dispatch } = store; const forceUpdate = useForceUpdate(); // 客户端渲染使用 useLayoutEffect useLayoutEffect(() => { const unSubscribe = store.subscribe(() => { forceUpdate(); }); return () => unSubscribe(); }, []); const stateToProps = mapStateToProps(getState()); let actionToProps = { dispatch }; if (typeof mapDispatchToProps === 'object') { actionToProps = bindActionCreators(mapDispatchToProps, dispatch); } else if (typeof mapDispatchToProps === 'function') { actionToProps = mapDispatchToProps(dispatch); } return <WrapperComponent {...props} {...stateToProps} {...actionToProps} />; }; const useForceUpdate = () => { const [, forceRender] = useReducer((s) => s + 1, 0); return forceRender; };
源码中实现
mapStateToProps
和mapDispatchToProps
还是比较复杂的,通过match
和工厂函数mapStateToPropsFactories
、mapDispatchToPropsFactories
完成,但核心代码就是上面代码的样子,其中bindActionCreators
如下function bindActionCreators(actionMap, dispatch) { const actions = {}; for (const action in actionMap) { actions[action] = bindActionCreator(actionMap[action], dispatch); } return actions; } function bindActionCreator(action, dispatch) { return (...args) => dispatch(action(...args)); }
值得注意的有两个地方,
-
1. 函数组件实现
forceUpdate
, 这也是 官网 推荐的方式。const [, forceRender] = useReducer((s) => s + 1, 0);
-
2. 为什么用
useLayoutEffect
?
源码中在这里做了个判断,服务端渲染时使用useEffect
;客户端渲染时使用useLayoutEffect
。在React中,
useLayoutEffect
对应componentDidMount
、componentDidUpdate
,他们都会同步执行; 而useEffect
属于异步执行,即在本次更新阶段结束后,在下一个任务调度中执行。这也就意味着在客户端渲染时,如果使用
useEffect
进行订阅,订阅会等更新任务完成后执行,那么在当前更新任务中如果有触发更新的操作将会丢失。而服务端渲染时为什么用
useEffect
呢,有两个原因,其一是服务端渲染用useLayoutEffect
会报出一个warning;其二是服务端渲染时dom已经存在,此时js可能还在加载中,所以不存在调度的延迟问题。
-
2.2. useSelector 和 useDispatch
-
作用
useSelector
内部完成订阅,返回想要的state
useDispatch
返回最新的dispatch
-
核心原理代码
export const useSelector = (selector) => { const store = useStore(); const forceUpdate = useForceUpdate(); useLayoutEffect(() => { const unSubscribe = store.subscribe(() => { forceUpdate(); }); return () => unSubscribe(); }, []); const ret = selector(store.getState()); return ret; }; export const useDispatch = () => { const store = useStore(); return store.dispatch; }; const useStore = () => { const store = useContext(Context); return store; };
3. 思考与总结
-
Redux与Vuex的区别
- Vuex 依赖于 Vue,虽然实现方式很巧妙,但是脱离 Vue 无法使用;Redux 是一个 Javascript 库,可以在任何地方使用;
- Vuex 通过 插件 进行扩展,可以借助 洋葱模型 扩展 多个插件;Redux 通过 中间件 实现扩展;
- Vuex 基本没有上手难度;Redux 需要了解 函数式编程 的一些概念,比如
compose
、洋葱模型、纯函数等; - Vuex 的更新粒度对标 Vue ,属于定向更新;而 Redux 基于发布订阅,更新粒度对标 React,但需要使用
connect
处理后才可以实现 自动更新。
-
一些注意事项
reducer
是纯函数,因此不要在里面做一些带副作用的操作,比如发布订阅;mapStateToProps
和mapDispatchToProps
都有第二个参数[ownProps]
,如果定义该参数,组件将会监听 Redux store 的变化,否则不监听, ownProps 是当前组件⾃身的 props,如果指定了,那么只要组件接收到新的 props,mapStateToProps和mapDispatchToProps 都会被重新计算,此处需要谨慎使用!
-
reducer
函数中default
分支 的意义Redux 在 初始化 时会调用一次 init 级 的
dispatch
用来初始化store
,这时候就用到了switch
语句的default
分支,如果不写,store
的默认值将是undefined
。 -
单一数据源的优与劣
或许,单一数据源真的很好管理,并且方便做数据回朔。但每次返回一个新的
state
,这种行为存在很多隐患。想象一下:我们派发一个dispatch
得到一个新的state
,React 不会管当前state
真正意义上的内容是否发生改变,它只看到前后两次引用不同所以进行更新,这势必会造成很多不必要的更新。这一点 Redux 像极了 React,反观 Vuex 或者 Mobx 这种单引用的数据仓库表现反而更好。 -
useEffect
和useLayoutEffect
的区别-
useEffect
: 处于React 渲染阶段时,在函数组件主体内改变 DOM、添加订阅、设置定时器、记录⽇志以及执 ⾏其他包含副作⽤的操作都是不被允许的,因为这可能会产⽣莫名其妙的 bug 并破坏 UI 的⼀致性。 使⽤useEffect
完成副作⽤操作,赋值给useEffect
的函数会在组件渲染到屏幕之后延迟执⾏。我们可以把effect
看作从 React 的纯函数式世界通往命令式世界的逃⽣通道。 -
useLayoutEffect
: 其函数签名与useEffect
相同,但它会在所有的 DOM 变更之后同步调⽤effect
。可以使⽤它来读取 DOM 布局并同步触发重渲染。在浏览器执⾏绘制之前,useLayoutEffect
内部的代码将被同步执行。 尽可能使⽤标准的useEffect
以避免阻塞视觉更新。
-