React18正式版源码级剖析

15,009 阅读17分钟

b站视频讲解image.png

今天9点多,和往常一样,我和白骨精、菠萝、大鱼、大侦探、99群里小伙伴发完早安后继续睡。11点多,我还没到公司,后厂村堵车,群里小伙伴和我说React18正式发版了已经,我。。。

图司机-20220330-29606729.png

也是巧了,我上周才把mini react的手写hook视频教程录制完毕,打算这周轻松点,做点React源码的文章和视频,昨天才重新配置了下React18 RC的DebugReact,今天React18的正式版就出了,虽然大部分我在B站早就讲过了,地址:www.bilibili.com/video/BV1rK… 本文相关的内容,明天我会在B站更新视频教程,如果明天拔牙不太影响我说话~

不废话了,接下来上React18。

Concurrent

背景

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

image.png

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

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

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

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

So while it’s not super important to know how concurrency works, it may be worth knowing what it is at a high level.

「虽然掌握 concurrency 工作的细节并非至关重要,但是从大体上了解它一下还是有必要的。」

什么是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,等下我会有细讲。

image.png

关于状态复用

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

image.png

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

Concurrent总结

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

React的新特性

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

react-dom/client中的createRoot

创建一个初次渲染或者更新,以前我们用的是ReactDOM.render,现在改用react-dom/client中的createRoot:

const root = createRoot(document.getElementById("root"));
root.render(jsx);

这里的root对象上会有render和unmount函数,但是这里的unmount并不接受callback。

image.png

以前ReactDOM.render第三个参数是在更新完成执行的callback,而在React18中,这样的callback会建议放在useEffect中:

const root = createRoot(document.getElementById("root"));

function AppWithCallbackAfterRender() {

  useEffect(() => {
    console.log('rendered');
  });

  return jsx
}

root.render(<AppWithCallbackAfterRender/>);

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

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

image.png

自动批量处理 Automatic Batching

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

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

恭喜你,接下来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
  };
  
  // <button onClick={this.changeCount}>change count 合成事件</button>

Suspense

可以“等待”目标UI加载,并且可以直接指定一个加载的界面(像是个 spinner),让它在用户等待的时候显示。

<Suspense fallback={<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>SuspensePage</h3>
      <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>

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

错误处理

每当使用 Promises,大概率我们会用 catch() 来做错误处理。但当我们用 Suspense 时,我们不等待 Promises 就直接开始渲染,这时 catch() 就不适用了。这种情况下,错误处理该怎么进行呢?

在 Suspense 中,获取数据时抛出的错误和组件渲染时的报错处理方式一样——你可以在需要的层级渲染一个错误边界组件来“捕捉”层级下面的所有的报错信息。

export default class ErrorBoundaryPage extends React.Component {
  state = {hasError: false, error: null};
  static getDerivedStateFromError(error) {
    return {
      hasError: true,
      error,
    };
  }
  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }
    return this.props.children;
  }
}

SuspenseList

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

用于控制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>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>
  );
}

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>TransitionPage</h3>
      <Suspense fallback={<h1>loading - user</h1>}>
        <User resource={resource} />
      </Suspense>

      <Suspense fallback={<h1>loading-num</h1>}>
        <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>Button</h3>
      <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。

Suspense与transitions结合

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

function handleClick() {
  setTab('comments');
}

<Suspense fallback={<Spinner />}>
  {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');
  });
}

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

useDeferredValue

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

相当于参数版的transitions。

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

image-20210609144400423

用法如下:

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>UseDeferredValuePage</h3>
      {/* 保持将当前文本传递给 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>
  );
});

新的Hooks

image.png

关于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}>NewHookApi</h3>
    </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>NewHookApi</h3>

      <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中动态注入<style> 或者 SVG <defs>。因为执行时机,因此不可读取refs。

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…

React18源码学习

说了这么多React18,大家有没有好奇React18源码到底是怎么写的呢,接下来我们就来学习下吧。React源码本身很庞大,如果说一点点去看的话,怕是要看到天荒地老了,接下来我先给大家一个项目,就是React源码调试项目,也许你尝试过sourcemap的调试方法,但是那样你只能打断点,没法留下自己的注释,也很难下次接着上次的看。

所以不如来用下我的DebugReact项目吧,在这个项目里,src里有一个react的无压缩包是我从github的facebook/react直接下载下来的,稍作修改之后,我再引用的React API就是来自这个未压缩的react了,也就是说你可以随意打log或者debug来调试源码。

image.png

这个DebugReact我会经常更新,项目地址是:github.com/bubucuo/Deb… ,欢迎star支持。当然github上的这个项目里没有react包,因为太大了,传不上去,所以你需要自己下载react再做些修改,修改方法我放在md里了,但是这个文件之后可能会发生改变。建议使用我放在公众号上的压缩包,关注我的公众号“bubucuo”,回复“debug”即可获取包链接,这个包我会不定期更新最新的~ 关于DebugReact,已完成如下:

1. 为什么学习源码

  1. 如何调试React18源码

  2. 解析Concurrent模式

  3. React18新特性

5. 类组件初次渲染与更新流程

  1. 类组件的setState原理

  2. React VDOM DIFF流程剖析

  3. 函数组件初次渲染与更新流程

  4. 函数组件的setState

  5. Hooks原理与源码解析

  6. React中的合成事件

  7. 任务调度

  8. 任务调度算法实现

04 mini react

最后再给大家介绍一个我目前在做的react项目:mini react,手写一套react,我会用简单易懂又符合源码的逻辑来实现react,帮助大家的react源码学习、项目深入与面试。仓库地址是:github.com/bubucuo/min…

目前已经完成功能列表如下:

  1. mini react指南

  2. VDOM与fiber

  3. fiber的构建与任务执行

  4. 初次渲染

  5. 实现原生节点初次渲染 

  6. 实现函数组件、类组件、文本、Fragment的初次渲染

  7. React中的任务调度与最小堆

  8. 实现任务调度

10.  实现useReducer

11.  实现useState

  1. 节点的删除与更新

  2. 修改目录

14.  删除多个老节点

  1. 实现完整的React VDOM DIFF

  2. 对比React 与Vue的 VDOM DIFF

  3. 实现useEffect与useLayoutEffect

最后

如果你也想对React源码、React周边框架源码如router、redux、mobx等、从算法中学习前端、低代码项目感兴趣,欢迎加入~

关注公众号“bubucuo”,回复“菠萝”加入技术群,回复“大鱼”加入读书群,也可直接回复“1”联系我~