likes
comments
collection
share

React Tearing

作者站长头像
站长
· 阅读数 22

什么是React Tearing?

普通情况下,当用户触发更新的时候,整个 React 渲染过程是不可被打断的,直到渲染流程结束之后才可以继续执行其他任务。

比如 React 现在正在渲染下面的组件树,其中子组件 Cpn4、Cpn5、Cpn6 依赖了外部的状态。React 会以 DFS (深度优先遍历)的方式去遍历整棵树,也就是说会以 Cpn1 -> Cpn2 -> Cpn4 -> Cpn5 -> Cpn3 -> Cpn6 这样的顺序来去遍历:

React Tearing

当渲染到 Cpn4 的时候,用户执行一个操作,从而去触发 Store 状态的变化,但是由于渲染并没有结束,所以会继续遍历剩余组件:

React Tearing

可以看到,虽然用户执行改变 Store 的状态的操作,但此时需要等待渲染结束后才能真正更新 Store 状态。当整个过程结束,接下来会改变外部 Store 的状态:

React Tearing

不过 React18 增加了并发更新机制,本质上是时间切片,并且高优先级会打断低优先级的任务。在渲染的过程中,由于整个连续不断的渲染过程拆分成了一个个分片的渲染片段,因此在渲染的间隙时就有机会去响应用户的操作:

React Tearing

我们来看一下上面的过程在 React18 之后是怎么样的:

React Tearing

可以看到当渲染到 Cpn4 时,拿到的是 Store V1 的状态,这时候用户的操作(例如点击事件)改变了外部的状态。在恢复继续渲染时就发生了状态不一致的现象,即 Cpn4 引用的是 Store V1 的状态,而 Cpn5 和 Cpn6 引用的是 Store V2 的状态。这就是 React Tearing(撕裂)问题,即各个组件展示的状态不一致的问题。可以看到,虽然 React18 并发更新带来了诸多优势,但也给状态管理社区带来了新的问题和挑战。

举个实际的 🌰,在 react-redux 7 中,用 startTransition 来开启并发更新,并用 while (performance.now() - start < 20) {} 延长每个组件 render 的时间,模拟真实的 render 过程:

export default function Counter() {
  const value = useSelector((state) => state);
  const start = performance.now();
  while (performance.now() - start < 20) {}
  return <div>{value}</div>;
}

可以看到,当连续点击按钮的时候状态发生了不一致的情况,那最终为什么状态一致了呢?这是因为 Tearing 的问题是发生在点击的过程中的。在用户的操作改变外部 Store 的状态后会触发 re-render(重新渲染),最后一次的 re-render 每个组件所引用 store 状态都是最新的状态,所以最终还是会趋于一致。

React Tearing

react-redux 8 引入了 useSyncExternalStore 来解决这个问题。

useSyncExternalStore

社区的各个状态管理库并没有直接使用 useSyncExternalStore API,而是使用 use-sync-external-store 这个库。因为 useSyncExternalStore 是 React18 提供的一个 API,如果项目是 React17 会拿不到这个 API。而 use-sync-external-store 会根据 React 是否暴露这个 API,如果暴露了,就直接使用,否则会使用该库自己实现的一套。也就是说 useSyncExternalStore 分为两个版本,一个是 React18 内置的,一个是自己实现的一套。

use-sync-external-store 源码解读

接下来我们就讲解下 use-sync-external-store 库的原理,首先是 useSyncExternalStore Api 的实现:

useSyncExternalStore

我们在前面提到 useSyncExternalStore 会区分 React 是否支持(即 React 是否导出了这个 Api)来选择使用 React 原生实现还是 use-sync-external-store 的实现版本:

// 原生实现
import {useSyncExternalStore as builtInAPI} from 'react';

export const useSyncExternalStore: <T>(
  subscribe: (() => void) => () => void,
  getSnapshot: () => T,
  getServerSnapshot?: () => T,
) => T = builtInAPI !== undefined ? builtInAPI : shim;

我们在这里重点讲解 use-sync-external-store 关于 useSyncExternalStore 的实现版本,理解 useSyncExternalStore 的内部逻辑对于日后开发属于我们自己的状态管理库非常重要。useSyncExternalStore分为 client 端和 server 端两个实现,React 会根据 canUseDOM 来区分不同环境:

import {useSyncExternalStore as client} from './useSyncExternalStoreShimClient';
import {useSyncExternalStore as server} from './useSyncExternalStoreShimServer';

const canUseDOM: boolean = !!(
  typeof window !== 'undefined' &&
  typeof window.document !== 'undefined' &&
  typeof window.document.createElement !== 'undefined'
);

const shim = canUseDOM ? client : server;

我们重点关注client端的实现:

export function useSyncExternalStore<T>(
  subscribe: (() => void) => () => void,
  getSnapshot: () => T,
  getServerSnapshot?: () => T,
): T {
  const value = getSnapshot();
  
  // forceUpdate用来触发组件re-render
  const [{inst}, forceUpdate] = useState({inst: {value, getSnapshot}});

  useLayoutEffect(() => {
    inst.value = value;
    inst.getSnapshot = getSnapshot;

    if (checkIfSnapshotChanged(inst)) {
      forceUpdate({inst});
    }
  }, [subscribe, value, getSnapshot]);

  useEffect(() => {
    if (checkIfSnapshotChanged(inst)) {
      forceUpdate({inst});
    }
    const handleStoreChange = () => {
      // 这里做了性能优化,会判断前后状态是否变化,如果没有变化则不会re-render
      if (checkIfSnapshotChanged(inst)) {
        forceUpdate({inst});
      }
    };
    // 订阅,把handleStoreChange传入到订阅函数subscribe中,最终在状态管理库中会调用handleStoreChange来触发re-render
    return subscribe(handleStoreChange);
  }, [subscribe]);

  return value;
}

// 工具函数,判断状态是否变化
function checkIfSnapshotChanged<T>(inst: {
  value: T,
  getSnapshot: () => T,
}): boolean {
  const latestGetSnapshot = inst.getSnapshot;
  const prevValue = inst.value;
  try {
    const nextValue = latestGetSnapshot();
    return !Object.is(prevValue, nextValue);
  } catch (error) {
    return true;
  }
}

让我们逐步分析这段代码:

export function useSyncExternalStore<T>(
  subscribe: (() => void) => () => void,
  getSnapshot: () => T,
  getServerSnapshot?: () => T,
): T {
  const value = getSnapshot();

  // forceUpdate用来触发组件re-render
  const [{inst}, forceUpdate] = useState({inst: {value, getSnapshot}});

这里定义了一个Hook,它接收三个参数:

  • subscribe 是一个订阅函数,返回一个取消订阅的函数。
  • getSnapshot 是一个获取当前快照值的函数。
  • getServerSnapshot 是一个可选函数,用于服务端渲染时获取初始快照值。

useState 创建了一个名为 inst 的状态对象,它包含了当前的值 value 和获取快照的函数 getSnapshotforceUpdate 是一个函数,用于强制更新状态,从而触发组件重新渲染。

useLayoutEffect(() => {
  inst.value = value;
  inst.getSnapshot = getSnapshot;

  if (checkIfSnapshotChanged(inst)) {
    forceUpdate({inst});
  }
}, [subscribe, value, getSnapshot]);

useLayoutEffect 在每次DOM更新之后,浏览器重新绘制之前,同步执行,它用于设置 inst 的值和快照获取函数,并检查快照是否发生了变化。如果快照发生了变化,则通过 forceUpdate 触发组件重新渲染。请注意,useLayoutEffect 的依赖数组中包含 subscribe 函数,这可能不是必要的,因为 subscribe 函数本身不会改变。

useEffect(() => {
  if (checkIfSnapshotChanged(inst)) {
    forceUpdate({inst});
  }
  const handleStoreChange = () => {
    // 这里做了性能优化,会判断前后状态是否变化,如果没有变化则不会re-render
    if (checkIfSnapshotChanged(inst)) {
      forceUpdate({inst});
    }
  };
  // 订阅,把handleStoreChange传入到订阅函数subscribe中,最终在状态管理库中会调用handleStoreChange来触发re-render
  return subscribe(handleStoreChange);
}, [subscribe]);

useEffect 在浏览器完成绘制之后异步执行。它首先检查快照是否发生变化,并且如果变化则强制更新。然后,它创建一个 handleStoreChange 函数,该函数在外部状态改变时被调用。如果状态确实发生了变化,那么它也会触发组件重新渲染。subscribe 函数被用来订阅外部状态的变化,并返回一个取消订阅的函数。

function checkIfSnapshotChanged<T>(inst: {
  value: T,
  getSnapshot: () => T,
}): boolean {
  const latestGetSnapshot = inst.getSnapshot;
  const prevValue = inst.value;
  try {
    const nextValue = latestGetSnapshot();
    return !Object.is(prevValue, nextValue);
  } catch (error) {
    return true;
  }
}

checkIfSnapshotChanged 函数用于比较新旧快照是否相同。如果不同,则返回 true,指示需要重新渲染。这里使用了 Object.is 方法来比较两个值是否严格相等,这可以处理NaN等特殊情况。

总结:

  • useLayoutEffect 负责在每次在每次DOM更新之后,浏览器重新绘制之前,更新 inst 的值,并在快照发生变化时触发重新渲染。
  • useEffect 负责订阅外部状态的变化,并在状态发生变化时触发重新渲染。它也负责在组件卸载时取消订阅。

这样设计可以确保外部状态的变化能够及时反映在组件上,并且尽量减少不必要的渲染。同时,由于使用了 useLayoutEffectuseEffect,可以保证在正确的时机执行相应的副作用。

疑问

useLayoutEffect中forceUpdate({inst});这段逻辑可以去掉吗?去掉会有什么影响?

分析

  • 目的: useLayoutEffect 在DOM更新后立即执行,确保 instvaluegetSnapshot 函数是最新的。这样可以确保当外部状态发生改变时,inst 能够立即反映这种变化。
  • forceUpdate** 的作用**: 如果外部状态发生了变化(通过 checkIfSnapshotChanged 检测),则使用 forceUpdate 强制组件重新渲染。这一步骤确保了组件能够立即响应外部状态的变化。

如果去掉 forceUpdate({inst})

如果我们去掉 forceUpdate({inst}),那么以下情况可能发生:

  1. 组件可能不会立即响应外部状态变化:
  • 当外部状态变化时,如果 checkIfSnapshotChanged 检测到状态确实发生了变化,但是没有 forceUpdate 的话,组件不会立即重新渲染。
  • 这意味着用户界面可能不会立即反映最新的状态,直到其他原因触发重新渲染为止(例如用户交互或其他副作用)。2. 性能影响:
  • 移除 forceUpdate 可能会略微提高性能,因为它减少了不必要的重新渲染。
  • 但是,这也可能导致用户界面更新不及时,影响用户体验。3. useEffect 的关系:
  • 如果外部状态变化发生在 useEffect 中订阅的事件处理程序中,那么 useEffect 中的 forceUpdate 将确保组件重新渲染。
  • 但是,如果外部状态变化发生在 useEffect 之外的地方(例如,由其他组件或库触发的状态变化),那么移除 useLayoutEffect 中的 forceUpdate 将导致组件无法立即响应这些变化。

结论

  • 如果你确定外部状态变化总是由 useEffect 中订阅的事件触发,并且这些事件能够确保组件始终重新渲染,那么你可以考虑去掉 useLayoutEffect 中的 forceUpdate
  • 但是,如果你希望组件能够立即响应所有外部状态变化,无论这些变化是由什么触发的,那么保留 useLayoutEffect 中的 forceUpdate 是必要的。

useSyncExternalStoreWithSelector

useSyncExternalStoreWithSelector 内部会调用 useSyncExternalStore,相比于 useSyncExternalStore 增加了两个额外的参数 selectorisEqual

import * as React from 'react';
import is from 'shared/objectIs';
import {useSyncExternalStore} from 'use-sync-external-store/src/useSyncExternalStore';

const {useRef, useEffect, useMemo, useDebugValue} = React;

export function useSyncExternalStoreWithSelector<Snapshot, Selection>(
  subscribe: (() => void) => () => void,
  getSnapshot: () => Snapshot, 
  getServerSnapshot: void | null | (() => Snapshot),
  selector: (snapshot: Snapshot) => Selection,
  isEqual?: (a: Selection, b: Selection) => boolean,
): Selection {
  // 初始化变量
  const instRef = useRef(null);
  let inst;
  if (instRef.current === null) {
    inst = {
      hasValue: false,
      value: (null: Selection | null),
    };
    instRef.current = inst;
  } else {
    inst = instRef.current;
  }

  // 实现selector版的getSelection、getServerSelection
  const [getSelection, getServerSelection] = useMemo(() => {
    let hasMemo = false;
    let memoizedSnapshot;
    let memoizedSelection: Selection;
    const memoizedSelector = (nextSnapshot: Snapshot) => {
       // ...
    };
    const getSnapshotWithSelector = () => memoizedSelector(getSnapshot());
    const getServerSnapshotWithSelector =
      maybeGetServerSnapshot === null
        ? undefined
        : () => memoizedSelector(maybeGetServerSnapshot());
    return [getSnapshotWithSelector, getServerSnapshotWithSelector];
  }, [getSnapshot, getServerSnapshot, selector, isEqual]);

  // 通过useSyncExternalStore计算状态
  const value = useSyncExternalStore(
    subscribe,
    getSelection,
    getServerSelection,
  );

  // 返回状态
  return value;
}

这段代码定义了一个自定义Hook useSyncExternalStoreWithSelector,它扩展了 useSyncExternalStore,增加了对选择器(selector)的支持。这个Hook可以用于从外部状态管理库(如Redux)中选择特定的数据片段,并且可以根据选择器的结果来决定是否触发组件的重新渲染。同样,让我们逐步分析这段代码:

export function useSyncExternalStoreWithSelector<Snapshot, Selection>(
  subscribe: (() => void) => () => void,
  getSnapshot: () => Snapshot, 
  getServerSnapshot: void | null | (() => Snapshot),
  selector: (snapshot: Snapshot) => Selection,
  isEqual?: (a: Selection, b: Selection) => boolean,
): Selection {}
  • 参数:
    • subscribe: 一个订阅函数,返回一个取消订阅的函数。
    • getSnapshot: 一个函数,用于获取当前的快照值。
    • getServerSnapshot: 一个可选函数,用于服务端渲染时获取初始快照值;也可以是 nullvoid
    • selector: 一个函数,用于从快照中选择特定的数据片段。
    • isEqual: 一个可选函数,用于比较两个选择器的结果是否相等。
const instRef = useRef(null);
let inst;
if (instRef.current === null) {
  inst = {
    hasValue: false,
    value: (null: Selection | null),
  };
  instRef.current = inst;
} else {
  inst = instRef.current;
}
  • 初始化状态:
    • 使用 useRef 创建一个引用 instRef,用于存储 inst 对象。
    • 如果 instRef.currentnull,则初始化 inst 对象,其中包含 hasValuevalue 属性。
    • 否则,直接使用已有的 inst 对象。
const [getSelection, getServerSelection] = useMemo(() => {
  let hasMemo = false;
  let memoizedSnapshot;
  let memoizedSelection: Selection;
  const memoizedSelector = (nextSnapshot: Snapshot) => {
    // ...
  };
  const getSnapshotWithSelector = () => memoizedSelector(getSnapshot());
  const getServerSnapshotWithSelector =
    maybeGetServerSnapshot === null
      ? undefined
      : () => memoizedSelector(maybeGetServerSnapshot());
  return [getSnapshotWithSelector, getServerSnapshotWithSelector];
}, [getSnapshot, getServerSnapshot, selector, isEqual]);

  • 使用 useMemo 缓存选择器:
    • 创建 memoizedSelector 函数,用于缓存选择器的结果。
    • getSnapshotWithSelectorgetServerSnapshotWithSelector 是两个函数,分别用于获取客户端和服务器端的选择器结果。
    • 如果提供了 isEqual 函数,并且上次的选择器结果与当前结果相同,则直接返回上次的结果。
    • 如果没有提供 isEqual 或者结果不同,则调用 selector 函数获取新的选择器结果,并将其缓存。
const value = useSyncExternalStore(
  subscribe,
  getSnapshotWithSelector,
  getServerSelection,
);
return value;
  • 计算状态并返回:
    • 使用 useSyncExternalStore Hook 来订阅外部状态的变化,并获取经过选择器处理后的值。
    • getSnapshotWithSelectorgetServerSelection 作为参数传递给 useSyncExternalStore
    • 返���经过选择器处理后的值 value

总结

  • 这个Hook扩展了 useSyncExternalStore,增加了对选择器的支持,允许开发者根据外部状态的变化选择特定的数据片段,并通过选择器的结果来决定是否触发组件的重新渲染。
  • 使用 useMemo 来缓存选择器的结果,提高性能。
  • 如果提供了 isEqual 函数,Hook 会比较选择器的结果,只有当结果发生变化时才会触发重新渲染,从而进一步提高性能。

不难发现,通过 useMemo 返回的 getSelectiongetServerSelection 分别对应 getSnapshotWithSelectorgetServerSnapshotWithSelector。核心在于 memoizedSelector 的实现,getSnapshotWithSelectorgetServerSnapshotWithSelector 仅仅是用 memoizedSelector 包了一下 getSnapshotgetServerSnapshot 而已。我们来看 memoizedSelector 的实现:

const memoizedSelector = (nextSnapshot: Snapshot) => {
  if (!hasMemo) {
    hasMemo = true;
    memoizedSnapshot = nextSnapshot;
    const nextSelection = selector(nextSnapshot);
    memoizedSelection = nextSelection;
    return nextSelection;
  }

  const prevSnapshot: Snapshot = (memoizedSnapshot: any);
  const prevSelection: Selection = (memoizedSelection: any);

  if (Objectis(prevSnapshot, nextSnapshot)) {
    return prevSelection;
  }

  const nextSelection = selector(nextSnapshot);

  if (isEqual !== undefined && isEqual(prevSelection, nextSelection)) {
    return prevSelection;
  }

  memoizedSnapshot = nextSnapshot;
  memoizedSelection = nextSelection;
  return nextSelection;
};

解释

  1. 初始化缓存:
  • 如果 hasMemofalse,表示这是第一次调用 memoizedSelector 函数。
  • 设置 hasMemotrue
  • nextSnapshot 保存到 memoizedSnapshot 中。
  • 通过 selector 函数计算选择器的结果,并将结果保存到 memoizedSelection 中。
  • 返回计算出的选择器结果。
  1. 检查缓存:
  • 如果 hasMemotrue,表示已经有缓存的选择器结果。
  • 获取缓存的快照 prevSnapshot 和选择器结果 prevSelection
  • 检查当前快照 nextSnapshot 是否与缓存的快照 prevSnapshot 相同:
  • 如果相同,则直接返回缓存的选择器结果 prevSelection
  1. 计算新的选择器结果:
  • 如果快照发生了变化,使用 selector 函数计算新的选择器结果 nextSelection
  • 如果提供了 isEqual 函数,并且新的选择器结果 nextSelection 与缓存的选择器结果 prevSelection 相等:
  • 则直接返回缓存的选择器结果 prevSelection。* 如果选择器的结果不相等或没有提供 isEqual 函数:
  • 更新缓存的快照 memoizedSnapshot 和选择器结果 memoizedSelection 为新的值。
  • 返回新的选择器结果 nextSelection

总结

  • memoizedSelector 函数的主要目的是缓存选择器的结果,以避免重复计算。
  • 当快照没有变化时,直接返回缓存的结果。
  • 当快照发生变化时,计算新的选择器结果。
  • 如果提供了 isEqual 函数并且新的结果与缓存的结果相同,则直接返回缓存的结果。
  • 如果结果不同,则更新缓存,并返回新的结果。* 这种缓存机制有助于提高性能,特别是在外部状态频繁变化但实际选择器的结果不变的情况下。

通过这种方式,memoizedSelector 可以确保只有当选择器的结果确实发生变化时才更新组件,从而提高了组件的性能。

参考资料

转载自:https://juejin.cn/post/7395866920895201332
评论
请登录