React18正式版源码级剖析

共 9341字,需浏览 19分钟

 ·

2022-04-04 06:28

本文适合对React18.0.0源码感兴趣的小伙伴阅读。

欢迎关注前端早茶,与广东靓仔携手共同进阶~

一、前言

本文是广东靓仔的好友bubucuo-高老师写的,高老师最近在准备React18的视频,有兴趣的小伙伴可以去学习学习。


React18最重要的改变必须是Concurrent,就像哪吒降生一样,打磨了很长时间了,终于正式见人了。


a63be0e85f00f2cfc2dc54f51910b4b5.webp


Concurrent Or Concurrency,中文我们通常翻译为并发,也有少部分翻译成并行。React已经着手开发Concurrent几年了,但是一直只存在于实验版本。到了React18,Concurrent终于正式投入使用了。


Concurrent并不是API之类的特性,而是一种能让你的React项目同时具有多个版本UI的幕后机制,相当爱迪生背后的特斯拉。


Concurrent很重要,虽然它不是API之类的新特性,但是如果你想解锁React18的大部分新特性,诸如transition、Suspense等,背后就要依赖Concurrent这位大佬。


React虽然一直在强调开发者并不真的需要了解Concurrent是什么,但是忽然来了一句:

49ab86599eaa739ae8b4584e9fbc29e6.webp


是的,如果你不想追求high level,就别学了。

二、Concurrent

什么是Concurrent

Concurrent最主要的特点就渲染是可中断的。没错,以前是不可中断的,也就是说,以前React中的update是同步渲染,在这种情况下,一旦update开启,在任务完成前,都不可中断。

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

Concurrent模式特点

在Concurrent模式下,update开始了也可以中断,晚点再继续嘛,当然中间也可能被遗弃掉。


关于可中断

先说可中断这件事情的重要性。对于React来说,任务可能很多,如果不区分优先级,那就是先来后到的顺序。虽然听起来很合理,但是现实是普通车辆就应该给救护车让路,因为事有轻重缓急嘛。那么在React中呢,如果高优先级任务来了,但是低优先级任务还没有处理完毕,就会造成高优先级任务等待的局面。比如说,某个低优先级任务还在缓慢中,input框忽然被用户触发,但是由于主线程被占着,没有人搭理用户,结果是用户哐哐输入,但是input没有任何反应。用户一怒之下就走了,那你那个低优先级的任务还更新个什么呢,用户都没了。


由此可见,对于复杂项目来说,任务可中断这件事情很重要。那么问题来了,React是如何做到的呢,其实基础还是fiber,fiber本身链表结构,就是指针嘛,想指向别的地方加个属性值就行了。


关于被遗弃

在Concurrent模式下,有些update可能会被遗弃掉。先举个🌰:
比如说,我看电视的时候,切换遥控器,从1频道切换到2频道,再切换到3频道,最后在4频道停下来。假如这些频道都是UI,那么2、3频道的渲染其实我并不关心,我只关心4频道的结果,如果你非要花时间把2和3频道的UI也渲染出来,最终导致4频道很久之后才渲染出来,那我肯定不开心。正确的做法应该是尽快渲染4频道就行了,至于2和3频道,不管渲染了多少了,遗弃了就行了,反正也不需要了。
最后回到项目的实际场景,比如我想在淘宝搜索“老人与海”,那么我在输入框输入“老人与海”的过程中,“老人”会有对应的模糊查询结果,但是不一定是我想要的结果,所以这个时候的模糊查询框的update就是低优先级,“老人”对应UI的update相对input的update,优先级就会低一些。在现在React18中,这个模糊查询相关的UI可以被当做transition。关于transition,等下我会有细讲。

4c22589a86bd67107c96facb869b8004.webp


关于状态复用

Concurrent模式下,还支持状态的复用。某些情况下,比如用户走了,又回来,那么上一次的页面状态应当被保存下来,而不是完全从头再来。当然实际情况下不能缓存所有的页面,不然内存不得爆炸,所以还得做成可选的。目前,React正在用Offscreen组件来实现这个功能。嗯,也就是这关于这个状态复用,其实还没完成呢。不过源码中已经在做了:

64d475b8ba6f3672a0b7c6f9ecd1d088.webp

另外,使用OffScreen,除了可以复用原先的状态,我们也可以使用它来当做新UI的缓存准备,就是虽然新UI还没登场,但是可以先在后台准备着嘛,这样一旦轮到它,就可以立马快速地渲染出来。

Concurrent总结

总结一下,Concurrent并不是API之类的新特性,但是呢,它很重要,因为它是React18大部分新特性的实现基础,包括Suspense、transitions、流式服务端渲染等。

三、React的新特性

前文说了那么多Concurrent并不是新特性,而是React18新特性的实现基础。那么新特性都有哪些呢,下面来看吧:

react-dom/client中的createRoot

创建一个初次渲染或者更新,以前我们用的是ReactDOM.render,现在改用react-dom/client中的createRoot,这个函数的返回值是卸载函数。


ssr中的ReactDOM.hydrate也换成了新的hydrateRoot。


以上两个API目前依然支持,只是已经移入legacy模式,开发环境下会报warning。

自动批量处理 Automatic Batching

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

6836b79bf46fba98256d56bdbb168dd3.webp


恭喜你,接下来React18之后,这个面试题中的前半部分可以被划入史册了,但是后半部分依然是你我React技术党逃不开的宿命。不过也不是什么大事,谁让你认识我呢~
先回答上面那个问题,可同步可异步,同步的话把setState放在promises、setTimeout或者原生事件中等。所谓异步就是个批量处理,为什么要批量处理呢。举个例子,老人以打渔为生,难道要每打到一条沙丁鱼就下船去集市上卖掉吗,那跑来跑去的成本太高了,卖鱼的钱都不够路费的。所以老人都是打到鱼之后先放到船舱,一段时间之后再跑一次集市,批量卖掉那些鱼。对于React来说,也是这样,state攒够了再一起更新嘛。


但是以前的React的批量更新是依赖于合成事件的,到了React18之后,state的批量更新不再与合成事件有直接关系,而是自动批量处理。
// 以前: 这里的两次setState并没有批量处理,React会render两次
setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
}, 1000);

// React18: 自动批量处理,这里只会render一次
setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
}, 1000);

所以如果你项目中还在用setTimeout之列的“黑科技”实现setState的同步的话,升级React18之前,记得改一下~


虽然建议setState批量处理,但是如果你有一些其它理由或者需要应急,想要同步setState,这个时候可以使用flushSync,下面的例子中,log的count将会和button上的count同步:
   // import { flushSync } from "react-dom";
   changeCount = () => {
    const { count } = this.state;

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

    console.log("改变count"this.state.count); //sy-log
  };
  
  // change count 合成事件

transition

React把update分成两种:

  • Urgent updates 紧急更新,指直接交互,通常指的用户交互。如点击、输入等。这种更新一旦不及时,用户就会觉得哪里不对。

  • Transition updates 过渡更新,如UI从一个视图向另一个视图的更新。通常这种更新用户并不着急看到。


startTransition

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

  • 渲染慢:如果你有很多没那么着急的内容要渲染更新。

  • 网络慢:如果你的更新需要花较多时间从服务端获取。这个时候也可以再结合Suspense

import {useEffect, useState, Suspense} from "react";
import Button from "../components/Button";
import User from "../components/User";
import Num from "../components/Num";
import {fetchData} from "../utils";

const initialResource = fetchData();

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

  // useEffect(() => {
  //   console.log("resource", resource); //sy-log
  // }, [resource]);

  return (
    <div>
      <h3>TransitionPageh3>

      <Suspense fallback={<h1>loading - userh1>}>
        <User resource={resource} />
      Suspense>

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

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


Button

import {
  //startTransition,
  useTransition,
from "react";

export default function Button({refresh}{
  const [isPending, startTransition] = useTransition();

  return (
    <div className="border">
      <h3>Buttonh3>

      <button
        onClick={() =>
 {
          startTransition(() => {
            refresh();
          });
        }}
        disabled={isPending}>
        点击刷新数据
      button>
      {isPending ? <div>loading...div> : null}
    div>
  );
}

与setTimeout异同

startTransition出现之前,我们可以使用setTimeout来实现优化。但是现在在处理上面的优化的时候,有了startTransition基本上可以抛弃setTimeout了,原因主要有以三点:首先,与setTimeout不同的是,startTransition并不会延迟调度,而是会立即执行,startTransition接收的函数是同步执行的,只是这个update被加了一个“transitions"的标记。而这个标记,React内部处理更新的时候是会作为参考信息的。这就意味着,相比于setTimeout, 把一个update交给startTransition能够更早地被处理。而在于较快的设备上,这个过度是用户感知不到的。

useTransition

在使用startTransition更新状态的时候,用户可能想要知道transition的实时情况,这个时候可以使用React提供的hook api useTransition

import { useTransition } from 'react';
const [isPending, startTransition] = useTransition();

如果transition未完成,isPending值为true,否则为false。


useDeferredValue

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

相当于参数版的transitions。

举例:如下图,当用户在输入框输入“书”的时候,用户应该立马看到输入框的反应,而相比之下,下面的模糊查询框如果延迟出现一会儿其实是完全可以接受的,因为用户可能会继续修改输入框内容,这个过程中模糊查询结果还是会变化,但是这个变化对用户来说相对没那么重要,用户最关心的是看到最后的匹配结果。


82efe340cc847195cd0c4136a635bf8a.webp

用法如下:

import {useDeferredValue, useState} from "react";
import MySlowList from "../components/MySlowList";

export default function UseDeferredValuePage(props{
  const [text, setText] = useState("hello");
  const deferredText = useDeferredValue(text);

  const handleChange = (e) => {
    setText(e.target.value);
  };
  return (
    <div>
      <h3>UseDeferredValuePageh3>

      {/* 保持将当前文本传递给 input */}
      <input value={text} onChange={handleChange} />
      {/* 但在必要时可以将列表“延后” */}
      <p>{deferredText}p>

      <MySlowList text={deferredText} />
    div>
  );
}

MySlowList

import React, {memo} from "react";

function ListItem({children}{
  let now = performance.now();
  while (performance.now() - now < 3) {}
  return <div className="ListItem">{children}div>;
}

export default memo(function MySlowList({text}{
  let items = [];
  for (let i = 0; i < 80; i++) {
    items.push(
      <ListItem key={i}>
        Result #{i} for "{text}"
      ListItem>

    );
  }
  return (
    <div className="border">
      <p>
        <b>Results for "{text}":b>

      p>
      <ul className="List">{items}ul>
    div>
  );
});

Suspense

可以“等待”目标UI加载,并且可以直接指定一个加载的界面(像是个 spinner),让它在用户等待的时候显示。
}>
  <Comments />
</Suspense>
其实Suspense也早就出现在React中了,只不过之前功能有限。在React18中,背靠Concurrent模式,Suspense终于爆发了自己的光彩。


在概念上,Suspense有点像catch,只不过Suspense捕获的不是异常,而是组件的suspending状态,即挂载中。


基本使用:避免等待太久

import {useState, Suspense} 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 SuspensePage(props{
  const [resource, setResource] = useState(initialResource);

  return (
    <div>
      <h3>SuspensePageh3>

      <ErrorBoundaryPage fallback={<h1>网络出错了h1>}>
        <Suspense fallback={<h1>loading - userh1>}>
          <User resource={resource} />
        Suspense>
      ErrorBoundaryPage>

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

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

错误处理

每当使用 Promises,大概率我们会用 catch() 来做错误处理。但当我们用 Suspense 时,我们不等待 Promises 就直接开始渲染,这时 catch() 就不适用了。这种情况下,错误处理该怎么进行呢?在 Suspense 中,获取数据时抛出的错误和组件渲染时的报错处理方式一样——你可以在需要的层级渲染一个错误边界组件来“捕捉”层级下面的所有的报错信息。
export default class ErrorBoundaryPage extends React.Component {
  state = {hasErrorfalseerrornull};
  static getDerivedStateFromError(error) {
    return {
      hasErrortrue,
      error,
    };
  }
  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }
    return this.props.children;
  }
}


结合transitions

所谓提高用户体验,一个重要的准则就是保证UI的连续性,如下面的例子,如果此时我想把tab从‘photos’切换到‘comments’,但是Comments又没法立马渲染出来,这个时候不可避免地,就会Photos页面消失,显现Spinner的loading页面,等一会儿,Comments页面才姗姗来迟。
function handleClick() {
  setTab('comments');
}

}>
  {tab === 'photos' ? <Photos /> : <Comments />}
</Suspense>
从UI连续性上来说,这个中间出现的Spinner就已经破坏了连续性。而实际上,正常人的反应其实是没有那么快,短暂的延迟我们是感觉不到的。所以考虑到UI的连续性,上面的例子,交互可不可以修改一下,把上面页面的切换当做transitions,这样即使tab切换,但是依然短暂停留在Photos,之后再改变到

Comments:

function handleClick() {
  startTransition(() => {
    setTab('comments');
  });
}


上面这个例子我们使用的是startTransition,如果需要知道pending状态,可以使用useTransition:
const [isPending, startTransition] = useTransition();

function handleClick() {
  startTransition(() => {
    setTab('comments');
  });
}

}>
  <div style={{ opacity: isPending ? 0.8 : 1 }}>
    {tab === 'photos' ? <Photos /> : <Comments />}
  div>

</Suspense>

SuspenseList

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

revealOrder Suspense加载顺序

together 所有Suspense一起显示,也就是最后一个加载完了才一起显示全部

forwards 按照顺序显示Suspense

backwards 反序显示Suspense

tail是否显示fallback,只在revealOrder为forwards或者backwards时候有效

hidden不显示

collapsed轮到自己再显示

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>SuspenseListPageh3>

      <SuspenseList tail="collapsed">
        <ErrorBoundaryPage fallback={<h1>网络出错了h1>}>
          <Suspense fallback={<h1>loading - userh1>}>
            <User resource={resource} />
          Suspense>
        ErrorBoundaryPage>

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

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

四、新的Hooks

8105cf7a084bd04f9cd5d3cb8066c7fd.webp


关于useTransition与useDeferredValue上面已经介绍过了,接下来说下React18其它的新Hooks,其中useSyncExternalStore与useInsertionEffect属于Library Hooks。也就是普通应用开发者一般用不到,这俩主要用于那些需要深度融合React模型的库开发,比如Recoil等。

useId

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

export default function NewHookApi(props{
  const id = useId();

  return (
    <div>
      <h3 id={id}>NewHookApih3>

    div>
  );
}

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

useSyncExternalStore

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

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

基本用法如下:

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>NewHookApih3>


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

useStore是我另外定义的,

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思路:

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,
  };
}

对于还在用自定义store来做低代码项目的我有点开心,可以用于升级我的项目了,原先定义的forceUpdate、unsubscribe之类的,可以去掉了~

useInsertionEffect

useInsertionEffect(didUpdate);

函数签名同useEffect,但是它是在所有DOM变更前同步触发。主要用于css-in-js库,往DOM中动态注入