React 18 Tearing 及 状态管理

Tearing

什么是 tearing

tearing ,来自图形编程的一个术语,是指视觉上的不一致,比如闪动阴影

伴随着 React 18 的并行渲染,出现了 tearing,并行渲染的出现,大幅度提升了用户体验,它意味着 React 在渲染过程中,还可以被更高优先级的任务打断的,比如用户操作,而在此区间,就会出现暂时的渲染抖动,在看 tearing 出现的场景前,我们对比下 React 18 的渲染与之前有何不同


同步渲染

以下图为例

在第一张图中,我们可以看到,组件访问一个外部 store 获取 state,并将他渲染至组件中

在第二张图中,其他组件也来渲染这个 state,由于我们的渲染过程过程不会被打断,所以渲染至第二个组件中

在第三张图中,我们可以看到,所有的组件均渲染完成,这个时候我们可以看到页面会呈现统一的 UI

那么如果 external store 发生变化,我们涉及到的组件,会从第一张图从头来过

img

并行渲染

仍以下图为例

在第一张图中,初始 external store 是蓝色,第一个组件渲染成蓝色,没有问题

在第二张图中,假设用户点击了按钮,将 external store 改成红色,这个时候,渲染会被打断,react 会让用户操作生效,来提升用户体验

在第三张图中,其他组件继续渲染,拿到改变后的 external store,渲染成红色

在第四张图中,几个组件渲染完成,但是由于并行渲染的问题,并不会检测到 external store 的变化,去重新将所有组件再渲染一遍,所以就导致了同一个数据,被展示不同的值,这种情况,就是 tearing

img

tearing 案例

import { useEffect, useState, startTransition } from 'react';

let externalState = { counter: 0 };
let listeners: any[] = [];

function dispatch(action: { type: any }) {
  if (action.type === 'increment') {
    externalState = { counter: externalState.counter + 1 };
  } else {
    throw Error('Unknown action');
  }
  listeners.forEach((fn) => fn());
}

function subscribe(fn: () => void) {
  listeners = [...listeners, fn];
  return () => {
    listeners = listeners.filter((f) => f !== fn);
  };
}

function useExternalData() {
  const [state, setState] = useState(externalState);
  useEffect(() => {
    const handleChange = () => setState(externalState);
    const unsubscribe = subscribe(handleChange);
    return unsubscribe;
  }, []);
  return state;
}

setInterval(() => {
  dispatch({ type: 'increment' });
}, 50);

export default function App() {
  const [show, setShow] = useState(false);
  return (
    <div className="App">
      <button
        onClick={() => {
          startTransition(() => {
            setShow(!show);
          });
        }}
      >
        toggle content
      </button>
      {show && (
        <>
          <SlowComponent />
          <SlowComponent />
          <SlowComponent />
          <SlowComponent />
          <SlowComponent />
        </>
      )}
    </div>
  );
}

function SlowComponent() {
  let now = performance.now();
  while (performance.now() - now < 200) {
    // do nothing
  }
  const state = useExternalData();
  return <h3>Counter: {state.counter}</h3>;
}

上面的代码,我们模拟了一个外部状态,当我们点击按钮,会看到,同一个 state 会出现不同的值,这种情况就是 tearing


那么既然 tearing 是由 external store 引起,那么接下来我们看常用的一些状态管理

React 18 中状态管理

既然对于 external store,会存在 tearing,那么我们看一些比较常见的状态管理工具是如何应对的

zustand

一个轻量简单的状态管理工具,可以将业务逻辑从上下文中提取出来,便于管理 state,集成 immer,保证最少的 rerender,善用 immer,减少 rerender

// store.ts

import create from 'zustand/vanilla';
import { immer } from 'zustand/middleware/immer';

const DEFAULT_STATE = {
  name: 'duanxl.com',
  age: 20,
  arr: ['201603'],
};

// const store = create(() => ({ ...DEFAULT_STATE }));
const store = create(
  immer<typeof DEFAULT_STATE>((set) => ({
    ...DEFAULT_STATE,
  }))
);

const { getState, setState, subscribe, destroy } = store;

subscribe((state) => {
  console.log('state更新了', state.name);
});

function changeState() {
  // 函数体会执行,值相同的话,按理不会造成rerender,但是不尽然
  //   setState({
  //     ...DEFAULT_STATE,
  //     name: 'duanxl.com' + Math.random(),
  //   });
  // bad case   函数体会执行,每次造成rerender
  //   setState({...res})
  //   setState({
  //     name: 'duanxl.com',
  //     age: 20,
  //   });
  // store.setState((state) => {
  //   state.name = Math.random().toString();
  //   state.age = 10;
  // });
  store.setState((state) => {
    state.arr = ['201603'];
  });
}
export { store, changeState, getState };

针对上面的 bad case,我们可以自定义 immer,支持自定义 setSate,达到精细变更,更贴近业务逻辑

import produce from 'immer';

export const useStore = create<{
  lush: { forest: { contains: { a: string } } };
  clearForest: (data: string) => void;
}>((set) => ({
  lush: { forest: { contains: { a: 'bear' } } },
  clearForest: (data: string) =>
    set(
      produce((state) => {
        // console.log('state', data);
        // state = data;
        // return data;
        state.lush.forest.contains.a = data;
      })
    ),
}));

根据这样的自定义方式,如果改变的不是指定路径,会直接阻止提交,并抛出异常,可以以此来约束开发规范

store.setState((state) => {
  state.lush = state;  // Error
});

store.setState((state) => {
  state.lush = { forest: { contains: { a: 'bear' } } };  // success 但是会rerender
});

针对上面的路径一样问题,我们可以在 setState 中抛出异常,以阻止不是原子 state 带来的变更

总结

  • zustand 在使用 immer 后,state 设置相同值,并不会触发 render
  • zustand 在页面初始化,会进行两次 render,为了解决 tearing 问题
  • 设置相同值(基本类型),仍会 rerender
  • 如果 useEffect 中依赖 data 对象,对象属性值重新 set(值不变),仍会执行 useEffect hook,可以结构出属性值,避免 rerender

jotai

用法简单,api 较少,但是目前针对 react18 版本,immer 失效,可能之后会解决吧,上面的 zustand 也存在这样的问题,和 zustand 一样,仍是一个作者(团队)开发,其中还有比较出名的 react-three-fiber 等经典库

import { useImmer } from '@hooks/useImmer';
import { atom, useAtom } from 'jotai';
import { atomWithImmer, useImmerAtom } from 'jotai/immer';
import { memo, useCallback, useEffect, useState } from 'react';

const mangaAtom = atom({ str: '状态测试' });

const mangaAtomObj = atomWithImmer({ str: '状态测试' });
function Courses() {
  //   const [data, setData] = useState({ str: '状态测试' });
  const [data, setData] = useImmer({ str: '状态测试' });
  console.log('rerender...');
  //   const [data, setData] = useAtom(mangaAtomObj);
  // const [data, setData] = useAtom(mangaAtom);
  //   function becomeRicher() {
  //     setData((draft) => {
  //       draft.str = '状态测试';
  //     });
  //   }
  const fn = useCallback(() => {
    setData((draft) => {
      draft.str = '状态测试';
      //   return draft;
    });
    // setData({ str: '状态测试' });
  }, []);
  //   useEffect(() => {
  //     console.log('rerender...', data.str);
  //   }, [data]);
  return (
    <div>
      <h2 onClick={fn}>{data.str}</h2>
      <hr />
    </div>
  );
}

export default memo(Courses);

总结

  • jotai 与 zustand 的问题几乎一样,但是有一点不同,就是 jotai 的 immer 中间件,要更好一些,设置相同值,useEffect 依赖对象,不会重新执行 hook

xstate

与其他状态管理工具相比,xstate 有状态机,适用于复杂业务,可追溯状态,统一 Api,不同技术栈可以共用一套状态管理,比如可以在微前端中解决主站与子站的通信等问题

import { createMachine, interpret } from 'xstate';

// Stateless machine definition
// machine.transition(...) is a pure function used by the interpreter.
const toggleMachine = createMachine({
  id: 'toggle',
  initial: 'inactive',
  states: {
    inactive: { on: { TOGGLE: 'active' } },
    active: { on: { TOGGLE: 'inactive' } }
  }
});

// Machine instance with internal state
const toggleService = interpret(toggleMachine)
  .onTransition((state) => console.log(state.value))
  .start();
// => 'inactive'

toggleService.send('TOGGLE');
// => 'active'

toggleService.send('TOGGLE');
// => 'inactive'


import { DemoData, toggleMachine } from '@duanxl/utils';
import { atomWithMachine } from 'jotai/xstate';
interface EventObject {
  type: string;
}
const toggleMachineAtom = atomWithMachine<
  unknown,
  EventObject,
  {
    value: 'inactive' | 'active';
    context: unknown;
  }
>(() => toggleMachine);

export { toggleMachineAtom };

总结

  • 加载仍存在 rerender 问题
  • 为集成 immer,复杂引用导致多组件 rerender

recoil

Facebook 提供,atom state 管理工具,支持派生数据与异步查询,让业务更小更灵活


这里我们使用官方实例演示

const textState = atom({
  key: 'textState', // unique ID (with respect to other atoms/selectors)
  default: '', // default value (aka initial value)
});

function CharacterCounter() {
  return (
    <div>
      <TextInput />
      <CharacterCount />
    </div>
  );
}

function TextInput() {
  console.log('rerender......')
  const [text, setText] = useRecoilState(textState);

  const onChange = (event) => {
    setText(event.target.value);
  };

  return (
    <div>
      <input type="text" value={text} onChange={onChange} />
      <br />
      Echo: {text}
    </div>
  );
}

总结

  • 设置相同的基本数据类型,不会 rerender
  • 页面加载不会触发多余的 rerender
  • 设置不同 state,每次 render 两次,绝绝子

最后

为什么 external store 会出现 rerender 的问题,就是为了解决 tearing


当然,在 react wg 中,也讨论过这个问题,官方给出的答复是利用 useSyncExternalStore 解决这个问题,敬请期待…

immer 原理

上面的状态管理工具,很多都集成了 immer,看一眼 immer 解决了什么,为什么要用 immer

溯源

比如我们有一个 state,是一个层级超过 2 层的对象,那么我们每次 setState 的时候,都要使用…(rest 表达式)来编写,而且容易出错,写起来也比较复杂,重要的还是我们得很了解这个对象的属性

那么有人就说了,直接 setState 时候只改变对象的属性就好了,但是有一个问题,就是对于没有改变的属性,进行了无意义的拷贝,而且和原来的对象引用地址也变了,就会触发很多不必要的 render

immer 为了解决问题,提供了变更对象属性的方法, 就是改变对象后,引用地址不变,避免组件不必要的 render

import { cloneDeep } from 'lodash';

const initialState={
  data:{
    site:'duanxl.com'
  }
}
const state = cloneDeep(initialState);
console.log(state.data === initialState.data); // false

import produce from 'immer';
const state = produce(initialState, () => {});
console.log(state.data === initialState.data); // true

那么 immer 是如何实现这个功能的呢


在没有了解 immer 之前,我想的是,就是深比较,使用 rest 表达式不断的赋值,但是 immer 不是这么做的,immer 使用 proxy 实现

简单实现

const state={
  data:{
    site:'duanxl.com'
  }
}

let changeState={};

const handler={
  set(target, key, value) {
    copy = {...target};
    copy[key] = value;
  }
}

const proxy=new Proxy(state,handler)
proxy.data.site='https://duanxl.com'
console.log(changeState.data === state.data)  // true

原理实现

function immer (state, thunk) {
  let momory = new Map()  // 缓存修改过的对象

  const handler = {
    get (target, key) {
      return new Proxy(target[key], handler) // get拦截,返回proxy  target[key]  确保是同一个handler
    },
    set (target, key, value) {
      const copy = { ...target }  // 浅拷贝
      copy[key] = value // 赋值
      momory.set(target, copy)
    }
  }

  const finalize = (state) => {
    const result = { ...state }
    Object.keys(state).map((key) => {
      if (momory.has(state[key])) {
        result[key] = momory.get(state[key])
      } else {
        result[key] = state[key]
      }
    })
    return result
  }

  const proxy = new Proxy(state, handler)
  thunk(proxy)
  return finalize(state)
}

原理中我们可以看到,如果使用 immer,尽量不要直接使用 useEffect 依赖源对象,会造成不必要的 hook 执行,也可能影响之后的逻辑

在研究 tearing 中发现的问题以及 React 18 相关 更新

react router V6 新特性

  • 支持 outlet,类似 vue 的路由插座
  • 支持 children,嵌套路由及配合 outlet 使用
  • 支持 layout,自定义模板

React 其他 rerender 问题

  • 默认路由,比如/,Home 组件,要保证是同步引入,不然会引起 rerender 问题,异步路由站内切 render 一次,但是刷新就会造成 rerender
  • 外部状态的改变,因为改变就会注入 props,会造成 rerender
  • routes 放置在了 layout 中,会参与 history 变化,导致 rerender,可建议放置下层组件
  • StrickMode 也会造成 rerender,但是线上不会

React 18 更新内容

  • useTransition && startTransition
    • 并行渲染,上面说过,区别是一个是 hooks,一个是任务
  • useId
    • useId 用于生成唯一的 id,在 SSR 中,可以更加准确快速进行 hydrate
  • Suspense
    • Suspense 在 Server 端也支持,对于一些较慢的业务组件,可以先返回 loading 等 docx,再进行 patch
  • useSyncExternalStore
    • 帮助外部存储库与React集成
  • useDeferredValue
    • 推迟不紧急部分的渲染树的重新渲染,类似防抖,但预防都不同的是,没有固定的延迟,React在渲染之后就会执行

最后

善用 React 三大件


  • useCallback
  • useEffect
  • useMemo

参考文献

React tearing (Github)

Jotai

Zustand

Xstate

Recoil

0