hacker0limbo/my-blog

简单实现 useReducer 与 middleware 以及 compose

hacker0limbo opened this issue · 0 comments

这篇文章主要讲两部分内容, 两个内容完全无关, 第一个是如何在 useReducer 中增加简单的 middleware 机制, 第二个是如何实现 compose 函数

useReducer 与 middleware

完整代码: https://stackblitz.com/edit/react-gjsy9j

实现的效果如下:
demo 9 55 35 pm

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;
  };
}

这里就不再细究了

参考