简单实现 useReducer 与 middleware 以及 compose
hacker0limbo opened this issue · 0 comments
这篇文章主要讲两部分内容, 两个内容完全无关, 第一个是如何在 useReducer
中增加简单的 middleware
机制, 第二个是如何实现 compose
函数
useReducer 与 middleware
完整代码: https://stackblitz.com/edit/react-gjsy9j
useReducer
本身是不支持 middleware
的, 不过可以实现一个自定义 hook, 在 reducer 计算的过程中(状态发生变更之前和之后), 增加可插入式的 middleware
.
还是以最基本的 todolist 为例子, 首先定义基本状态:
const initialTodos = [
{
id: 'a',
task: 'Learn React',
complete: false,
},
{
id: 'b',
task: 'Learn Firebase',
complete: false,
},
];
定义对应的 reducer
:
const todoReducer = (state, action) => {
switch (action.type) {
case 'DO_TODO':
return state.map((todo) => {
return {
...todo,
complete: todo.id === action.id ? true : todo.complete,
};
});
case 'UNDO_TODO':
return state.map((todo) => {
return {
...todo,
complete: todo.id === action.id ? false : todo.complete,
};
});
default:
return state;
}
};
App
定义如下:
const App = () => {
const [todos, dispatch] = React.useReducer(todoReducer, initialTodos);
const handleChange = (todo) => {
dispatch({
type: todo.complete ? 'UNDO_TODO' : 'DO_TODO',
id: todo.id,
});
};
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<label>
<input type="checkbox" checked={todo.complete} onChange={() => handleChange(todo)} />
{todo.task}
</label>
</li>
))}
</ul>
);
};
使用的 middleware
也定义一下, 这里就简单定一个一个 logger
函数, 用于打印即将(之前之后)会触发的 action
, 以及当前所对应的状态:
const loggerBefore = (action, state) => {
console.log('logger before dispatch action:', { action, state });
};
const loggerAfter = (action, state) => {
console.log('logger after dispatch action:', { action, state });
};
由于 useReducer
并不支持第三个 middleware 参数, 因此需要自己实现一个 useReducerWithMiddleware
的 custom hook, 需要注意的有以下几点:
- 有两种 middleware, 一种发生在 dispatch 一个 action 之前, 一个发生在之后
- middleware 可以有多个
先考虑最基础的在 dispatch
一个 action
之前的 middleware
const useReducerWithMiddleware = (reducer, initialState, precedingMiddleware) => {
const [state, dispatch] = React.useReducer(reducer, initialState);
const dispatchWithMiddleware = (action) => {
precedingMiddleware.forEach((pm) => pm(action, state));
dispatch(action);
};
return [state, dispatchWithMiddleware];
};
本质的实现其实就是用一个函数多包装一层, 接受相同的 action 参数, 里面在调用 dispatch(action)
时, 先调用一遍 middleware
而基于上面定义的 logger
函数效果其实类似在定义的 reducer 之前增加一个 console.log
语句:
// 类似如下效果
const todoReducer = (state, action) => {
console.log(state, action);
switch (
action.type
// ...
) {
}
};
具体使用该 hook 的时候如下:
const App = () => {
const [todos, dispatch] = useReducerWithMiddleware(todoReducer, initialTodos, [logger, logger]);
// ...
};
接下来考虑 dispatch
一个 action
之后的 middleware
假设做如下实现:
const useReducerWithMiddleware = (
reducer,
initialState,
precedingMiddleware,
succeedingMiddleware
) => {
const [state, dispatch] = React.useReducer(reducer, initialState);
const dispatchWithMiddleware = (action) => {
precedingMiddleware.forEach((pm) => pm(action, state));
dispatch(action);
succeedingMiddleware.forEach((sm) => sm(action, state));
};
return [state, dispatchWithMiddleware];
};
很可惜该实现存在问题, 由于更新是异步的, 这种情况下拿到的 state 仍旧是之前的, 因此需要使用 useEffect
做状态变更的更新监听:
const useReducerWithMiddleware = (
reducer,
initialState,
precedingMiddleware,
succeedingMiddleware
) => {
const [state, dispatch] = React.useReducer(reducer, initialState);
const dispatchWithMiddleware = (action) => {
precedingMiddleware.forEach((pm) => pm(action, state));
dispatch(action);
};
React.useEffect(() => {
succeedingMiddleware.forEach((sm) => sm(state));
}, [succeedingMiddleware, state]);
return [state, dispatchWithMiddleware];
};
仍旧存在一个问题在于, 无法获取到 action, 解决办法也很简单, 通过 ref
或者临时变量在 dispatch
对应的 action
的时候进行赋值, 即可拿到对应的 action
, 代码如下:
const useReducerWithMiddleware = (
reducer,
initialState,
precedingMiddleware,
succeedingMiddleware
) => {
const [state, dispatch] = React.useReducer(reducer, initialState);
const actionRef = React.useRef();
const dispatchWithMiddleware = (action) => {
precedingMiddleware.forEach((pm) => pm(action, state));
actionRef.current = action;
dispatch(action);
};
React.useEffect(() => {
if (!actionRef.current) return;
succeedingMiddleware.forEach((sm) => sm(actionRef.current, state));
actionRef.current = null;
}, [succeedingMiddleware, state]);
return [state, dispatchWithMiddleware];
};
需要注意的是需要对 actionRef
做判断, 以及最后要将 actionRef
清空, 因为严格意义上来讲可能存在其他 action
也能触发状态的更新, 而我们需要的是仅针对一个 action 做一组之前/之后的 middleware 的调用.
compose
redux 里有一个 compose
函数, 用来将多个函数组合调用, 比如我要调用 compose(f2, f2, f1)(10)
其实就等同于 f3(f2(f1(10)))
, 注意最先计算的是从最右边开始的, 举个例子:
const f1 = (x) => x + 1;
const f2 = (x) => x * 10;
const f3 = (x) => x - 1;
compose(f3, f2, f1)(10); // (10 + 1) * 10 - 1 = 109
redux 官网对这个实现也是非常精致, 去掉 ts 类型如下:
function compose(...funcs) {
if (funcs.length === 0) {
return (arg) => arg;
}
if (funcs.length === 1) {
return funcs[0];
}
return funcs.reduce(
(a, b) =>
(...args) =>
a(b(...args))
);
}
通过 reduce
每次提取两个函数, 返回 (...args) => a(b(...args))
这样包装后的函数体
那如果不用 reduce
, 使用普通的 for 循环实现呢? 其实本质是一样的, 我当时实现的版本如下:
function compose(...funcs) {
if (funcs.length === 0) {
return (arg) => arg;
}
if (funcs.length === 1) {
return funcs[0];
}
let result;
for (let i = funcs.length - 1; i > -1; i--) {
result = result ? (...args) => funcs[i](result(...args)) : (...args) => funcs[i](...args);
}
return result;
}
区别仅在于需要做一次判断, 因为 reduce
一次拿两个函数, 普通的 for
循环一次只拿一个
然而测试的时候发现报错:
RangeError: Maximum call stack size
显示错误原因貌似出现了无限递归, 当时无法理解后去 stackoverflow 提问了一下, 其实造成错误的缘由很简单, 先看下面的简单的例子:
let a = () => 2;
a = () => 3 * a();
console.log(a); // () => 3 * a()
console.log(a()); // 报错, 无限递归
原因在于 a = () => 2
这个函数从来就没被调用过, 他只是被声明了一次, 而后面这个引用又重新被篡改了, 过程如下:
- 先声明一次
a = () => 2
这个函数 - 重新声明 a, 此时 a 就变成了
() => 3 * a()
, 而a()
从没被执行过 - 最后调用
a()
造成了无限递归
函数在被声明和调用的过程中内部变量等可能是不一样的, 要做好区分
回到之前的实现, 其实也是类似的原因, result
在每次循环中引用都被不断被改变, result
本身并没有在我想象的那样被"执行"拿到结果, 他仍旧是一个声明的函数体, 这就导致了最后出现自己调用自己造成无限递归
改起来也简单, 用个变量接一下就好了:
function compose(...funcs) {
if (funcs.length === 0) {
return (arg) => arg;
}
if (funcs.length === 1) {
return funcs[0];
}
let result;
for (let i = funcs.length - 1; i > -1; i--) {
const r = result;
result = r ? (...args) => funcs[i](r(...args)) : (...args) => funcs[i](...args);
}
return result;
}
这里的 r 每次都保留了当前循环环境下的 result 引用, 保证引用不再被篡改. 有点类似 stale closure, 不过这次有点反过来...
当然了, 写法可以有很多种, 比如后面有回答里给了这种写法:
function compose(...funcs) {
if (funcs.length === 0) {
return (arg) => arg;
}
return function (...args) {
let result = funcs.at(-1)(...args);
for (let i = funcs.length - 2; i > -1; i--) {
result = funcs[i](result);
}
return result;
};
}
这里就不再细究了