Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

简单实现 useReducer 与 middleware 以及 compose #26

Open
hacker0limbo opened this issue Mar 9, 2022 · 0 comments
Open

简单实现 useReducer 与 middleware 以及 compose #26

hacker0limbo opened this issue Mar 9, 2022 · 0 comments
Labels
react react 笔记整理 redux redux 笔记整理

Comments

@hacker0limbo
Copy link
Owner

这篇文章主要讲两部分内容, 两个内容完全无关, 第一个是如何在 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;
  };
}

这里就不再细究了

参考

@hacker0limbo hacker0limbo added redux redux 笔记整理 react react 笔记整理 labels Mar 9, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
react react 笔记整理 redux redux 笔记整理
Projects
None yet
Development

No branches or pull requests

1 participant