callcc.dev

Δ为什么要写全依赖?

// 我们这样声明组件
function Component(props) {
  return <div>props.title</div>;
}

// React这样使用我们的组件
let children = Component(props, secondArg);
我们的组件跟普通的 JS 函数没有什么区别,React 在渲染的时候,会执行我们的函数组件,组件里通过 Hooks 获得 Fiber 保存的状态。以 useCallback 为例,看看它的 API
// 这个会被记忆到Fiber里
const memoizedCallback = useCallback(() => {
  doSomething(depA, depB);
}, [depA, depB]);
第一个参数为一个闭包函数,捕获 depA 和 depB 变量。第二个参数我们称为依赖,其实也就是闭包里需要用到的变量。它的作用是告知 React 更新记忆的 callback。一个错误例子
function Component() {
  const [active, setActive] = useState(false);

  const handleClick = useCallback(() => {
    if (active) {
      // ❌ 永远都不会被执行
      console.log("Do something");
    }
    setActive((active) => !active);
  }, []);

  return <button onClick={handleClick}>ClickMe</button>;
}
我们来看看 React 在更新组件的时候是怎么处理 useCallback 的依赖的
// https://github.com/facebook/react/blob/12adaffef7105e2714f82651ea51936c563fe15c/packages/react-reconciler/src/ReactFiberHooks.new.js#L1417
function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  if (prevState !== null) {
    if (nextDeps !== null) {
      const prevDeps: Array<mixed> | null = prevState[1];
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        // 依赖相等,那么直接返回上次的闭包
        return prevState[0];
      }
    }
  }
  // 保存闭包和本次的依赖
  hook.memoizedState = [callback, nextDeps];
  return callback;
}

Δ一个陷阱场景

function Component() {
  const [active, setActive] = useState(false);
  const [count, setCount] = useState(0);

  const handleClick = useCallback(() => {
    if (active) {
      console.log("Do something");
    }
    setActive((active) => !active);
    setCount((count) => count + 1);
  }, [count]); // 这里没有把active作为依赖

  return (
    <div onClick={handleClick}>
      <div>active: {String(active)}</div>
      <div>count: {count}</div>
    </div>
  );
}
这里例子缺少了一个依赖项,但是貌似不会出现什么问题!现在来修改一下
function doAsync() {
  return new Promise((res) =>
    setTimeout(() => {
      res();
    }, 0),
  );
}

function Component() {
  const [active, setActive] = useState(false);
  const [count, setCount] = useState(0);

  const handleClick = useCallback(() => {
    if (active) {
      console.log("Do something");
    }
    doAsync().then(() => {
      // 🩸 请试着交换下面两行代码看看运行效果
      setCount((count) => count + 1);
      setActive((active) => !active);
    });
  }, [count]);

  return (
    <div onClick={handleClick}>
      <div>active: {String(active)}</div>
      <div>count: {count}</div>
    </div>
  );
}
可以发现,依赖项在异步任务里被更新时就出问题了。同步任务下不会出现问题的原因是,active 和 count 状态被更新完成之后组件才被重新渲染。而在异步任务里,因为 React 众人皆知的问题,组件会被渲染两次,也即是 useCallback 会被调用两次。
如果我们先调用 setActive,再调用 setCount,handleClick 函数会因为 count 依赖更新而更新,而这时候 active 已经先更新了,所以能捕获到正确的闭包变量。
而如果先调用了 setCount,再调用 setActive,count 被更新的时候,active 还是旧值,即使 handleClick 被更新了,它捕获到的 active 变量还是旧值,然后 setActive 更新 active,没有触发 handleClick 的更新。所以就出错了。
所以“现在”没问题的代码,可能“将来”稍微一改,就出 bug 了,这会让代码变得异常脆弱。在工作中,遇到过几次 hooks 依赖不全导致的 bug。。。

Δ难用的 useCallback

估计大家都深有体会,useCallback 很难用。我们希望通过它得到一个不变的函数引用,避免造成以它作为 props 的子组件重新渲染,而实践却是事与愿违。为什么呢?它的依赖造成它经常被更新,还不如不要记忆,减少维护依赖的负担。
有什么方法解决这个问题吗?有的,借助 useRef。这是来自官方的实现
// https://reactjs.org/docs/hooks-faq.html#how-to-read-an-often-changing-value-from-usecallback
function useEventCallback(fn, dependencies) {
  const ref = useRef(() => {
    throw new Error("Cannot call an event handler while rendering.");
  });

  useEffect(() => {
    ref.current = fn;
  }, [fn, ...dependencies]);

  return useCallback(() => {
    const fn = ref.current;
    return fn();
  }, [ref]);
}
如果你的项目里使用 ahooks,那么可以使用 useMemorizedFn hooks/index.ts at c177ec635369cee61204029f097695f862f35ab6 · alibaba/hooks
有关 useCallback 这个问题,有一个 open 的 issue useCallback() invalidates too often in practice · Issue #14099 · facebook/react (github.com) 。推荐看一下,可以了解到为什么 ref.current 需要在 useEffect 或者 useMemo 里被更新。

Δ为什么 Hooks 顺序这么重要?

Hooks 不能出现在条件语句和循环语句中。也就是说不能出现类似这样的代码
function Component() {
  const mounted = useRef(false);

  const [firstName, setFirstName] = useState("first_name");
  if (!mounted.current) {
    useState("mid_name");
    mounted.current = true;
  }
  const [lastName, setLastName] = useState("last_name");

  return (
    <div>
      <div>FirstName: {firstName}</div>
      <div onClick={() => setLastName("LastName")}>LastName: {lastName}</div>
    </div>
  );
}
React 会直接报错。但是为什么呢?这是因为 React 在底层存储 Hooks 是使用链表进行存储的,组件更新的时候,如果前后数量/类型不一致,就会错位导致出问题。
这是 Hook 的底层存储的数据结构
export type Hook = {|
  memoizedState: any,
  baseState: any,
  baseQueue: Update<any, any> | null,
  queue: UpdateQueue<any, any> | null,
  next: Hook | null,
|};
组件挂载的时候调用 Hooks 都会调用这个函数来把所有 Hooks 串联起来(useContext 除外)
// https://github.com/facebook/react/blob/12adaffef7105e2714f82651ea51936c563fe15c/packages/react-reconciler/src/ReactFiberHooks.new.js#L544
function mountWorkInProgressHook(): Hook {
  const hook: Hook = {
    memoizedState: null,

    baseState: null,
    baseQueue: null,
    queue: null,

    next: null,
  };

  if (workInProgressHook === null) {
    // This is the first hook in the list
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // Append to the end of the list
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}
在组件更新重新渲染的时候会调用这个来依次取出
// https://github.com/facebook/react/blob/12adaffef7105e2714f82651ea51936c563fe15c/packages/react-reconciler/src/ReactFiberHooks.new.js#L565
function updateWorkInProgressHook(): Hook {
	...
	if (nextWorkInProgressHook !== null) {
    // There's already a work-in-progress. Reuse it.
    workInProgressHook = nextWorkInProgressHook;
    nextWorkInProgressHook = workInProgressHook.next;

    currentHook = nextCurrentHook;
  } else {
		...
	}
	return workInProgressHook;
}