React Tearing

什么是React Tearing?

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

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

image.png

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

image.png

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

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

image.png

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

image.png

可以看到当渲染到 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 状态都是最新的状态,所以最终还是会趋于一致。

73b855a6657c4cea968033b7278d85ea.gif

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 中。
  • 返回计算出的选择器结果。2. 检查缓存:
  • 如果 hasMemotrue,表示已经有缓存的选择器结果。
  • 获取缓存的快照 prevSnapshot 和选择器结果 prevSelection
  • 检查当前快照 nextSnapshot 是否与缓存的快照 prevSnapshot 相同:
  • 如果相同,则直接返回缓存的选择器结果 prevSelection。3. 计算新的选择器结果:
  • 如果快照发生了变化,使用 selector 函数计算新的选择器结果 nextSelection
  • 如果提供了 isEqual 函数,并且新的选择器结果 nextSelection 与缓存的选择器结果 prevSelection 相等:
  • 则直接返回缓存的选择器结果 prevSelection。* 如果选择器的结果不相等或没有提供 isEqual 函数:
  • 更新缓存的快照 memoizedSnapshot 和选择器结果 memoizedSelection 为新的值。
  • 返回新的选择器结果 nextSelection

总结

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

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,240评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,328评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,182评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,121评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,135评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,093评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,013评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,854评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,295评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,513评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,678评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,398评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,989评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,636评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,801评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,657评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,558评论 2 352

推荐阅读更多精彩内容