源码级剖析 React 18 新特性,以及源码中未完成的新功能

React两个比较重要的版本更新

  • 16.0 引入Fiber架构
  • 16.8 引入React Hooks

Concurrent模式:并发模式

  • React已经着手开发Concurrent几年了,但是一直只存在于实验版本。到了React18,Concurrent终于正式投入使用了。虽然Concurrent不是API之类的新特性,但它是React18大部分新特性的实现基础,包括Suspense、transitions、流式服务端渲染等。

  • Concurrent模式有三个特点:

    • 渲染是可中断的,React18之前的update是同步渲染,在这种情况下,一旦update开启,在任务完成前,都不可中断。可中断是非常重要的,因为任务有轻重缓急,在高优先级的任务(跟用户交互任务)来的时候,如果不先中断低优先级的任务,就会造成高优先级任务等待的局面。这个可中断 React其实是通过Fiber来实现的,Fiber本身是链表结构,想指向别的地方其实加一个属性值就可以了。

      注意:这里说的同步,和setState所谓的同步异步不是一码事,而且setState所谓的异步本质上是个批量处理。

    • 中间的update也可能会被遗弃。举个场景的例子:当用户从第一个页面快速切换到第三个页面的时候,虽然第二个页面没有被渲染完成,但是用户其实并不关心第二个页面,只需要着重渲染第三个页面就好了,所以第二个页面的update可以被遗弃掉

    • Concurrent模式下,还支持状态的复用。某些情况下,比如用户走了,又回来,那么上一次的页面状态应当被保存下来,而不是完全从头再来。实际情况下不能缓存所有的页面,非常消耗内存,所以还得做成可选的。目前,React正在用Offscreen组件来实现这个功能。另外,使用OffScreen,除了可以复用原先的状态,也可以使用它来当做新UI的缓存准备,新UI虽然没有被立即显示,但是可以在后台候着嘛。

新特性一:react-dom/client中的createRoot

  • React18之前用的是ReactDOM.render有三个参数:

    • element:要渲染的 React 元素,通常是 JSX
    • container:一个 DOM 元素,React 元素将被渲染到这个容器中
    • callback (可选):渲染完成后调用的回调函数
  • React18用createRoot,参数是container,createRoot支持并发渲染,createRoot还有一个好处就是根节点的复用

    jsx
    const root = createRoot(document.getElementById("root"));
    
    root.render(<SteStatePage />);
  • 以前ReactDOM.render更新完成执行的callback,在React18中建议放到useEffect

  • SSR 中的 ReactDOM.hydrate 也换成了新的 hydrateRoot

以上两个API目前依然支持,只是已经移入legacy(/ˈleɡəsi/)模式,开发环境下会warning

新特性二:自动批量处理 Automatic Batching

如果你是React技术栈,那么你一定遇到过无数次这样的面试题:

setState是同步还是异步,可以实现同步吗,怎么实现,异步的原理是什么?

  • React18之前:可同步可异步,同步的话把setState放在promises、setTimeout等计时器或者原生事件中等。所谓异步就是个批量处理
  • 以前的React的批量更新是依赖于合成事件的(即,在合成事件中,多个setState会被批量处理),到了React18之后,state的批量更新不再与合成事件(绑定在ReactElement上的事件,例如onClick)有直接关系,而是自动批量处理
jsx
// 以前: 这里的两次setState并没有批量处理,React会render两次
setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
}, 1000);

// React18: 自动批量处理,这里只会render一次
setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
}, 1000);
  • 虽然建议setState批量处理,但如果想要同步setState,可以使用flushSync
jsx
import { flushSync } from "react-dom";
changeCount = () => {
  const { count } = this.state;

  flushSync(() => {
    this.setState({
      count: count + 1,
    });
  });

  console.log("改变count", this.state.count); //hx-log
};

// <button onClick={this.changeCount}>change count 合成事件</button>

新特性三:Suspense

  • Suspense有点像catch,只不过Suspense捕获的不是异常,而是组件的suspending状态,即挂载中。
    • 可以自己编写 ErrorBoundary(getDerivedStateFromError) 类组件包裹 Suspense 来捕获网络异常

新特性四:transition

React把update分成两种:

  • Urgent updates:紧急更新,指直接交互,通常指的用户交互。如点击、输入等。这种更新一旦不及时,用户就会觉得哪里不对。
  • Transition updates:过渡更新,如UI从一个视图向另一个视图的更新。通常这种更新用户并不着急看到。

startTransition

startTransition 可以用在任何你想更新的时候。但是从实际来说,以下是两种典型适用场景:

  • 渲染慢:如果你有很多没那么着急的内容要渲染更新
  • 网络慢:如果你的更新需要花较多时间从服务端获取。这个时候也可以再结合 Suspense
jsx
import { useState, Suspense, startTransition } from 'react'
import { fetchData } from '../utils'
import ErrorBoundaryPage from './ErrorBoundaryPage'

const initialResource = fetchData()

export default function TransitionPage() {
  const [resource, setResource] = useState(initialResource)
  return (
      <div>
      <h3>TransitionPage</h3>
      <ErrorBoundaryPage fallback="加载失败...">
          <Suspense fallback="Loading...">
            <User resource={resource} />
        </Suspense>
      </ErrorBoundaryPage>
      
      // 这里的 Suspense 的 Loading 并不会显示
      // startTransition 会将页面的更新标记为非紧急更新,使页面的交互看起来不会断
      <button onClick={() => startTransition(() => setResource(fetchData()))}>
        refresh
      </button>
    </div>
  )
}
  • 如果需要在transition期间做更多的处理,知道transition的实时情况,就需要使用 useTransition,可以结合 Suspense 去做显示
jsx
import { useState, Suspense, useTransition } from 'react'
import { fetchData } from '../utils'
import ErrorBoundaryPage from './ErrorBoundaryPage'

const initialResource = fetchData()

export default function TransitionPage() {
  const [resource, setResource] = useState(initialResource)
  const [isPending, startTransition] = useTransition()
  return (
      <div>
      <h3>TransitionPage</h3>
      <ErrorBoundaryPage fallback="加载失败...">
          <Suspense fallback="Loading...">
            <User resource={resource} />
        </Suspense>
      </ErrorBoundaryPage>
      
      // 这里的 Suspense 的 Loading 并不会显示
      // startTransition 会将页面数据的更新标记为非紧急更新,使UI的交互具有连续性
      <button
        disabled={isPending}
        onClick={() => startTransition(() => setResource(fetchData()))}
       >
        refresh
      </button>
      {isPending ? 'pending' : ''}
    </div>
  )
}

与setTimeout的区别

startTransition 出现之前,我们可以使用 setTimeout 来实现优化。但是现在在处理上面的优化的时候,有了 startTransition 基本上可以抛弃 setTimeout 了,原因主要有以三点:

  • setTimeout 不同,startTransition 不会延迟调度,而是立即执行
  • startTransition 接收的函数是同步执行的,并且给这个更新加上了“transitions”标记,React 会在内部处理更新时参考这个标记
  • 使用 startTransition 处理更新比 setTimeout 更早,在较快的设备上,用户几乎无法感知到这种过渡

useDeferredValue

使得我们可以延迟更新某个不那么重要的部分。

相当于参数版的transitions。

例如:假设我们有一个实时搜索输入框,用户在输入时会触发过滤一个庞大的列表。这个过滤操作比较耗时,因此在用户输入时会感觉到明显的卡顿,useDeferredValue 允许 React 将用户输入和过滤操作的更新解耦。输入框的值 (query) 会立即更新,而 deferredQuery 则会稍后更新。这样可以让输入的响应更加即时,而过滤操作则在后台优先级较低的情况下执行,从而减少卡顿感。如果没有 useDeferredValue 的情况下我们一般使用 防抖/节流

jsx
import React, { useState, useDeferredValue } from 'react';

function SearchComponent({ largeList }) {
  const [query, setQuery] = useState("");
  
  // const deferredQuery = query;
  const deferredQuery = useDeferredValue(query);

  const filteredList = largeList.filter(item => 
    item.includes(deferredQuery)
  );

  return (
    <div>
      <input 
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
      <ul>
        {filteredList.map((item, index) => (
          <li key={index}>{item}</li>
        ))}
      </ul>
    </div>
  );
}

实验版新特性:SuspenseList

SuspenseList 在 DebugReact 中可用,也就是 DEV 环境下可以用。目前还未完成,预计18.X会正式支持,以下例子当做参考,以后也许会改变。

用于控制 Suspense 组件的显示顺序。

revealOrder

这个选项用于设置 Suspense 的加载顺序

  • together 所有Suspense 一起显示,也就是最后一个加载完了才一起显示全部
  • forwards 按照顺序显示 Suspense
  • backwards 反序显示 Suspense

tail

是否显示fallback,只在 revealOrderforwards 或者 backwards 时候有效

  • hidden 不显示
  • collapsed 轮到自己再显示
jsx
import { useState, Suspense, SuspenseList } from "react";
import User from "../components/User";
import Num from "../components/Num";
import { fetchData } from "../utils";
import ErrorBoundaryPage from "./ErrorBoundaryPage";

const initialResource = fetchData();

export default function SuspenseListPage(props) {
  const [resource, setResource] = useState(initialResource);

  return (
    <div>
      <h3>SuspenseListPage</h3>
      <SuspenseList tail="collapsed">
        <ErrorBoundaryPage fallback={<h1>网络出错了</h1>}>
          <Suspense fallback={<h1>loading - user</h1>}>
            <User resource={resource} />
          </Suspense>
        </ErrorBoundaryPage>

        <Suspense fallback={<h1>loading-num</h1>}>
          <Num resource={resource} />
        </Suspense>
      </SuspenseList>

      <button onClick={() => setResource(fetchData())}>refresh</button>
    </div>
  );
}

新的 Hooks

useId

用于产生一个在服务端与Web端都稳定且唯一的ID,也支持加前缀,这个特性多用于支持ssr的环境下:

jsx
import { useId } from 'react'

export default function NewHookApi() {
  const id = useId()
  return (
      <div>
        <h3 id={id}>NewHookApi</h3>
    </div>
  )
}

可以看到生成的 id 是下面这种效果:

CleanShot_2024-08-21_at_02.48.21@2x

注意:useId产生的ID不支持css选择器,如querySelectorAll。

useSyncExternalStore

这个虽然是 Library Hooks,但是这个 API 非常重要,跟状态管理有关系。

js
const state = useSyncExternalStore(subscribe, getSnapshot[, getServerSnapshot]);

此Hook用于外部数据的读取与订阅,可应用Concurrent。

基本用法如下:

jsx
import { useStore } from "../store";
import { useId, useSyncExternalStore } from "../whichReact";

export default function NewHookApi(props) {
  const store = useStore();
  const state = useSyncExternalStore(store.subscribe, store.getSnapshot);

  return (
    <div>
      <h3>NewHookApi</h3>

      <button onClick={() => store.dispatch({ type: "ADD" })}>{state}</button>
    </div>
  );
}

useStore是我另外定义的,

jsx
export function useStore() {
  const storeRef = useRef();

  if (!storeRef.current) {
    storeRef.current = createStore(countReducer);
  }

  return storeRef.current;
}

function countReducer(action, state = 0) {
  switch (action.type) {
    case "ADD":
      return state + 1;
    case "MINUS":
      return state - 1;
    default:
      return state;
  }
}

这里的createStore用的redux思路:

jsx
export function createStore(reducer) {
  let currentState;
  let listeners = [];

  function getSnapshot() {
    return currentState;
  }

  function dispatch(action) {
    currentState = reducer(action, currentState);
    listeners.map((listener) => listener());
  }

  function subscribe(listener) {
    listeners.push(listener);

    return () => {
      //   console.log("unmount", listeners);
    };
  }

  dispatch({ type: "TIANNA" });

  return {
    getSnapshot,
    dispatch,
    subscribe,
  };
}

useInsertionEffect

js
useInsertionEffect(didUpdate);

函数签名同useEffect,但是它是在所有DOM变更前同步触发。主要用于css-in-js库,往DOM中动态注入<style> 或者 SVG <defs>。因为执行时机,因此不可读取refs。

在react组件中的执行顺序为:

  1. useInsertionEffect:DOM变更前同步触发
  2. useLayoutEffect:在浏览器重新绘制屏幕之前触发
  3. useEffect:组件挂载到页面时触发
jsx
function useCSS(rule) {
  useInsertionEffect(() => {
    if (!isInserted.has(rule)) {
      isInserted.add(rule);
      document.head.appendChild(getStyleForRule(rule));
    }
  });
  return rule;
}

function Component() {
  let className = useCSS(rule);
  return <div className={className} />;
}

详细文章:github.com/reactwg/rea…

album
profileHersan

热爱编程,开源社区活跃参与者

3文章
0标签
0分类