前进中的 React:昨天、今天和明天

前端开发

undefined

自 2013 年月推出第一个公开版本以来,React 已经走过了十个年头。还记得我刚到淘宝的时候就开始倒腾 React 在业务的落地,当时使用的是 v0.13.0 版本,大家还在纠结于它不支持 IE6(笑)。后来跟着元彦一起倒腾 Rx(Rax 的前身),负责开发组件库和在业务上落地……可以说我的整个前端职业生涯都是伴随着 React 成长的;)一眨眼,Frontend 沧海桑田。回头望,React 历久弥新:

最近这一段时间,我在做 Rax 到 React 的迁移收益评估,以及思考如何基于新的 React 版本去升级我们的工程体系。因此我翻阅了很多的文档,尝试了解从 v16 以来 React 推出的新特性有哪些、怎么用以及背后的动机是什么,由此整理成了一篇文档。同时我从多个维度对比了 Rax@1.xReact@18.x 优缺点,有些结论与固有印象也有一些出入。我把这些内容梳理成文,供大家参考。

因为行文的时间周期比较长,所以虽然已经检查了好几遍,但难免会有错漏之处,还望指正。

tl;dr

v16: 重要的分水岭

v16 发布于 2017 年 9 月,是 React 发展历程中一个重要的分水岭。在这个版本, React 内部启用了新的 Fiber Reconciler 架构,这为后续架构演进奠定了基础。同时 React 推出了 Hooks,由此 React 世界进入到了函数组件的时代。

Fragments

What

React 16 引入了 Fragments 的概念,最早在 v16.0 版本推出,是一种新的 React 渲染返回值类型:

function List() {
  // No need to wrap list items in an extra element!
  return [
    // Don't forget the keys :)
    <li key="A">First item</li>,
    <li key="B">Second item</li>,
    <li key="C">Third item</li>,
  ];
}

返回的这个数组就是 “Fragments”。它允许开发者在不引入额外 DOM 节点的情况下渲染多个子元素。

ps: React 也是从这个版本开始支持渲染函数返回字符串和数字类型。

不过开发者仍需要给每个子元素添加 key ,这样的写法实在有些累赘。所以 React 在 v16.2 版本上推出了内置组件 <Fragment> 来优化这一特性。

import { Fragment } from 'react';

function App() {
  return (
    <Fragment>
      <li>First item</li>
      <li>Second item</li>
      <li>Third item</li>
    <Fragment/>
  );
}

<Fragment>...</Fragment> 可以简写为 <>...</>

function App() {
  return (
    <>
      <li>First item</li>
      <li>Second item</li>
      <li>Third item</li>
    </>
  );
}

How

v16 之前,无论如何你都需要返回一个元素。所以开发者如果要组合某些组件,就得「被迫」添加了一个 div 元素。

function Post() {
  return (
    <div>
      <PostTitle />
      <PostBody />
    </div>
  );
}

v16 之后,可以使用 <Fragment> 将其他元素组合起来,使用 <Fragment> 组合后的元素不会对 DOM 产生影响。

function Post() {
  return (
    <>
      <PostTitle />
      <PostBody />
    </>
  );
}

可以在 React 的 API 参考文档(Fragment)上找到详细的用法示例。

Why

一种常见场景是组件需要返回一个子元素列表。以下面的代码为例:

function Table() {
  return (
    <table>
      <tr>
        <Columns />
      </tr>
    </table>
  );
}

function Columns() {
  return (
    <div>
      <td>Hello</td>
      <td>World</td>
    </div>
  );
}

<Columns /> 组件需要返回多个 <td> 元素。如果在 <Columns /> 中使用了 div,则生成的 HTML 将无效:

<table>
  <tr>
    <div>
      <td>Hello</td>
      <td>World</td>
    </div>
  </tr>
</table>

使用 Fragment 则可以解决这个问题,因为它不会引入额外的 DOM 节点:

function Columns() {
  return (
    <>
      <td>Hello</td>
      <td>World</td>
    </>
  );
}

Portals

What

React 16 引入了 Portals 的概念,是一种将子节点渲染到存在于父组件以外 DOM 节点的方案。

import { createPortal } from 'react-dom';
createPortal(child, domNode)

How

import { createPortal } from 'react-dom';

function App() {
  return (
    <div style={{ border: '2px solid black' }}>
      <p>这个子节点被放置在父节点 div 中。</p>
      {createPortal(
        <p>这个子节点被放置在 document body 中。</p>,
        document.body
      )}
    </div>
  );
}

React 将传递的 JSX 对应的 DOM 节点放入提供的 DOM 节点中。如果没有 Portal,第二个 <p> 将放置在父级 <div> 中,但 Portal 会将其“传送”到 document.body 中。

使用开发者工具检查 DOM 结构,会发现第二个 <p> 直接放置在 <body> 中:

undefined

Portal 只改变 DOM 节点的所处位置。在其他方面,渲染至 Portal 的 JSX 的行为表现与作为 React 组件的子元素一致。该子节点可以访问由父节点树提供的 Context 对象、事件将从子节点依循 React 树冒泡到父节点。

可以在 React 的 API 参考文档(createPortal)上找到详细的用法示例。

Why

React 推出 Portals 的动机有以下几个原因。

一、更容易实现弹窗(Modal),悬浮(Tooltip)等交互效果

v16 之前:

function Modal({ children, visible }) {
  const divEle = useRef(document.createElement('div'));
	
  useEffect(() => {
    document.body.appendChild(divEle.current);
    return () => {
      document.body.removeChild(divEle.current);
    } 
  }, []);
	
  useEffect(() => {
    ReactDOM.render(<div style={{ 
      visibility: visible ? 'visible' : 'hidden',
    }}>
      {children}
    </div>, divEle.current);
  }, [children, visible]);
	
  return null; 
}

v16 之后:

function Modal({ children, visible }) {
  return (
    createPortal(<div style={{
      visibility: visible ? 'visible' : 'hidden',
    }}>
      {children}
    </div>, document.body)
  );
}

Portals 推出后,AntDesgin 和 Fusion 等库也基于此功能更新了其 Modal, Tooltip 的实现。

二、更容易进行集成

有两种比较常见的场景:

  1. 更容易地在页面中进行集成:当页面有部分多个不同区域是 React 渲染时,与创建多个独立的 React 应用相比,Portal 将应用程序视为一个单一的 React 树,即使它不同部分在 DOM 的不同区域渲染,也可以共享状态undefined
    <!DOCTYPE html>
    <html>
      <head><title>我的应用程序</title></head>
      <body>
        <h1>我的网站一部分使用了 React,另外一部分没有使用</h1>
        <div class="parent">
          <div class="sidebar">
            这是一个非 React 渲染的部分
            <div id="sidebar-content"></div>
          </div>
          <div id="root"></div>
        </div>
      </body>
    </html>
    import { render, createPortal } from 'react-dom';
    
    const sidebarContentEl = document.getElementById('sidebar-content');
    
    function App() {
      return (
        <>
          <MainContent />
          {createPortal(
            <SidebarContent />,
            sidebarContentEl
          )}
        </>
      );
    }
    
    function MainContent() {
      return <p>这一部分是被 React 渲染的。</p>;
    }
    
    function SidebarContent() {
      return <p>这一部分也是被 React 渲染的!</p>;
    }
    
    render(
      <App />,
      document.getElementById('root')
    );
  2. 更容易与其他脚本进行集成:例如我们要与地图库进行集成,在地图上显示具体位置的标识。 undefined
    import { useRef, useEffect, useState } from 'react';
    import { createPortal } from 'react-dom';
    import * as L from 'leaflet';
    
    export function createMapWidget(containerDomNode) {
      const map = L.map(containerDomNode);
      map.setView([0, 0], 0);
      return map;
    }
    
    export function addPopupToMapWidget(map) {
      const popupDiv = document.createElement('div');
      L.popup()
        .setLatLng([0, 0])
        .setContent(popupDiv)
        .openOn(map);
      return popupDiv;
    }
    
    function Map() {
      const containerRef = useRef(null);
      const [popupContainer, setPopupContainer] = useState(null);
    
      useEffect(() => {
        const map = createMapWidget(containerRef.current);
        const popupDiv = addPopupToMapWidget(map);
        setPopupContainer(popupDiv);
      }, []);
    
      return (
        <div style={{ width: 250, height: 250 }} ref={containerRef}>
          {popupContainer !== null && createPortal(
            <p>来自 React 的你好!</p>,
            popupContainer
          )}
        </div>
      );
    }

Error Boundaries

What

UI 中的 JavaScript 错误不应引发整个应用程序的崩溃。为了解决这个问题,React 16 引入了错误边界(Error Boundaries)的概念。它允许开发者在组件中捕获子组件的 JavaScript 错误,并进行处理(显示兜底的 UI)。错误边界会在其所有子组件的构造函数、渲染期间、生命周期方法中捕获错误。

错误边界不会捕获以下错误:

在开发环境 React 16 还会将渲染过程中发生的所有错误都打印到控制台。除了错误消息和 JavaScript 堆栈之外,React 还提供了组件的堆栈信息,可以跟踪到错误在组件树中具体的位置:

undefined

通过集成 Babel 插件,还可以在组件堆栈跟踪中查看文件名和行号:

undefined

How

如果类组件定义了生命周期方法 static getDerivedStateFromError()componentDidCatch() 中的一个(或两个),则它将成为错误边界。

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
+   this.state = { hasError: false };
  }

+ static getDerivedStateFromError(error) {
+   // Update state so the next render will show the fallback UI.
+   return { hasError: true };
+ }

+ componentDidCatch(error, errorInfo) {
+   // You can also log the error to an error reporting service
+   logErrorToMyService(error, errorInfo);
+ }

  render() {
+   if (this.state.hasError) {
+     // You can render any custom fallback UI
+     return <h1>Something went wrong.</h1>;
+   }

    return this.props.children; 
  }
}

function App() {
  return (
    <ErrorBoundary>
      <MyComponent />
    </ErrorBoundary>
  );
}

React 推荐使用静态 getDerivedStateFromError() 方法来重设 state。使用 componentDidCatch() 方法来记录错误信息。

getDerivedStateFromError()v16.6 推出的 API,它比 componentDidCatch() 的调用时机更早,允许在 render 完成之前显示兜底的 UI。一个典型的场景是如果子组件在 render 阶段抛错,错误边界定义了 getDerivedStateFromError 的情况下,子组件的 componentDidMount 不会触发。

static getDerivedStateFromError(error) {
  console.log('getDerivedStateFromError');
  return { hasError: true };
}
componentDidCatch(error, errorInfo) {
  console.log('componentDidCatch');
}

undefined

static getDerivedStateFromError(error) {
  console.log('getDerivedStateFromError');
}
componentDidCatch(error, errorInfo) {
  console.log('componentDidCatch');
  this.setState({ hasError: true });
}

undefined

Why

通过使用错误边界,开发者可以更好地控制和处理错误情况,提高应用程序的鲁棒性和可靠性。

来看一个示例:假设我们有一个抽奖程序,用户点击按钮后会发起服务端请求进行抽奖,服务端返回抽中的金额并显示给用户。

undefined

下面是示例代码,其中第 13 行模拟了错误输出的情况。这在我们不进行服务端数据校验的情况下很常见(试想一下返回的对象为 undefined 的状况)

// App.jsx
<div>
  <Header />
  <main>
    <p>百分百中奖</p>
  	<Lottery username="匿名用户" />
  </main>
</div>

// Lottery.jsx
<div>
  <p>亲爱的{this.props.username}</p>
  {this.state.visible ? <p>获得金额:{this.money.amount}</p> : null}
  <button onClick={() => this.setState({ visible: !this.state.visible })}>
    点击获得红包
  </button>
</div>

v15 的表现:React 不会显示错误的节点,应用无法精准感知错误并进行处理。前台用户会觉得自己被「欺骗」了

undefined

v16 下我们加一个 ErrorBounday:

+ <ErrorBoundary>
    <Test foo="foo" />
+ </ErrorBoundary>

其表现如下:应用捕获到了错误并进行兜底 UI 的显示

undefined

值得注意的是,从 React 16 开始任何错误边界未捕获的错误将导致整个 React 组件树的卸载。这是一个破坏性的变更。也就是说如果程序不进行任何修改,过往在 v15 中不显示错误节点的表现会变为整个页面白屏:

undefined

Server-side rendering

React server-side rendering(SSR, 服务端渲染)的历史可以追溯到 v0.4 版本:

React 16 重构了服务端渲染的实现,带来了更好的服务器端渲染体验。其 API 也有一些变化。

  1. 在服务端调用 renderToString() 方法将根组件渲染为字符串,然后将其写入响应:
    // using Express
    import { renderToString } from "react-dom/server";
    import MyPage from "./MyPage";
    app.get("/", (req, res) => {
      res.write("<!DOCTYPE html><html><head><title>My Page</title></head><body>");
      res.write("<div id='content'>");  
      res.write(renderToString(<MyPage/>));
      res.write("</div></body></html>");
      res.end();
    });
  2. 在客户端调用 hydrate() 方法为 HTML 重新「注水」:
    - import { render } from "react-dom";
    + import { hydrate } from "react-dom";
    
    import MyPage from "./MyPage"
    - render(
    + hydrate(
      <MyPage/>, 
      document.getElementById("content")
    );

React 16 后 render() 方法适用于客户端渲染,hydrate() 方法则为 SSR 场景服务。React 16 是完全向后兼容的,所以开发者可以继续使用 render() 方法,不过 React 会在开发模式下往浏览器控制台打印一条 waring 信息:

undefined

More Efficient HTML

相比之前的版本,v16 生成的 HTML 体积更小了。以下面的代码为例:

renderToString(
  <div>
    This is some <span>server-generated</span> <span>HTML.</span>
  </div>
);

v15 生成的 HTML 如下:文档中的每个元素都有一个递增的 data-reactid 属性,文本被注释包裹以进行区分

<div data-reactroot="" data-reactid="1" data-react-checksum="122239856">
  <!-- react-text: 2 -->This is some <!-- /react-text -->
  <span data-reactid="3">server-generated</span>
  <!-- react-text: 4--> <!-- /react-text -->
  <span data-reactid="5">HTML.</span>
</div>

v16 生成的 HTML 如下:

<div data-reactroot="">
  This is some <span>server-generated</span> <span>HTML.</span>
</div>

不仅是代码可读性更好了,需要传输的 HTML 文件体积也更小了,间接提升了首屏可视时间。

Less Strict Client-Side Checking

在 React 15 进行 rehydrate 时,ReactDOM.render() 方法会与服务器生成的 HTML 进行逐字符的比较。如果由于任何原因出现不匹配,React 会在开发模式中发出警告,并用客户端上生成的 HTML 整个替换掉服务器生成 HTML。

React 16 带来了更高效的 hydrate 实现:

Faster

根据 React 团队 Sasha Aickin 的测试,v16 的服务端渲染速度对比 v15 有巨大的提升:

undefined

这是一个很简单的测试用例,不能完全代表真实的情况。社区有开发者反馈在他的真实应用中大概有 1.3 倍的性能提升

能取得这样的进步,是因为 React 16 做了以下优化:

一、移除了大量的 process.env.NODE_env

在 React 内部有大量的环境判断:

if (process.env.NODE_ENV !== "production") {
// check some stuff and output great developer warnings here.
}

process.env 不是一个普通的 JavaScript 对象,在 Node 中从 process.env 读取数据非常慢。React 16 使用了 Rollup 来给为其每种不同的目标格式创建产物包。从而避免了频繁读取 process.env 直观点来说:

二、在服务端渲染不涉及任何虚拟 DOM 的逻辑

在 React 15 中 renderToString()的与 render() 有大量相同的代码实现。这意味着维护虚拟 DOM 所需的所有数据结构都是在服务器渲染时设置的,即使对 renderToString 的调用一返回,vDOM 就被丢弃了 —— 在服务端渲染时做了很多不必要的工作。

React 16 重写了服务器渲染的实现,不会执行任何虚拟 DOM 的相关逻辑。所以它更快。

Supports Streaming

React 16 支持流式渲染,流式渲染的好处有:

undefined

React 18 对 Hydrate 进行了异步化处理,在流式 SSR 应用中,可以进一步实现先渲染的页面先达到可交互状态的效果。对于部分首屏接口较慢的应用,这将进一步提升页面的可交互体验。

要使用流式渲染,需要用到新的 API:

这些新方法返回的不是字符串,而是 Readable streams。这样可以很轻松地在一些 Node 框架上进行集成,以 Express 为例:

import { renderToNodeStream } from "react-dom/server";
import MyPage from "./MyPage";

app.get("/", (req, res) => {
  res.write("<!DOCTYPE html><html><head><title>My Page</title></head><body>");
  res.write("<div id='content'>"); 
  const stream = renderToNodeStream(<MyPage/>);
  stream.pipe(res, { end: false });
  stream.on('end', () => {
    res.write("</div></body></html>");
    res.end();
  });
});

StrictMode

What

React v16.3 引入了严格模式(StrictMode)的概念。它是一个内置的工具组件,可以在开发模式下发现应用中潜在的问题,React 会为它的子组件触发额外的检查和警告。与 <Fragment> 一样,<StrictMode> 不会渲染任何 UI。

import React from 'react';

function App() {
  return (
    <React.StrictMode>
	  <MyComponent />
    </React.StrictMode>
  );
}

StrictMode 会启用以下能力:

How

可以为应用程序的任何部分启用严格模式。例如:

import React from 'react';

function ExampleApplication() {
  return (
    <div>
      <Header />
+     <React.StrictMode>
        <ComponentOne />
        <ComponentTwo />
+     </React.StrictMode>
      <Footer />
    </div>
  );
}

在上面的示例中,React 不会对 Header 和 Footer 组件运行严格模式检查。但是,ComponentOne 和 ComponentTwo 以及它们的所有后代组件都将进行严格模式检查。

Why

React 一直具备在开发环境下提供警告的能力,帮助开发者识别潜在的问题。StrictMode 作为一个专门的组件,提供了更加集中和全面的方式来检查应用中的问题。它是之前告警能力的增强,它的引入带来了以下改进:

Suspense

What

Suspense 最早在 2018 年 3 月 1 日的 Iceland JSConf 上由 Dan Abramov 的演讲提出,它让组件在加载异步数据(等待某些事件)的时候,可以延迟(暂停)渲染并显示兜底 UI。它是 React 雄心勃勃的并发渲染方案中的其中一个功能。

React 在 2018 年 10 月 23 日发布的 v16.6 版本上正式集成了该功能,用以实现代码分割(#RFC 64)。

import React, {lazy, Suspense} from 'react';
const OtherComponent = lazy(() => import('./OtherComponent'));

function MyComponent() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <OtherComponent />
    </Suspense>
  );
}

关于代码分割的好处,一图胜千言

undefined

How

在应用中引入代码分割的方式是通过 import() 语法:

- import { add } from './math';
+ import("./math").then(math => {
  console.log(math.add(16, 26));
+ });

当 Webpack 解析到该语法时,会自动进行代码分割。关于代码分割的打包指南可以参考 Webpack 的文档,下面是构建配置的示例:

module.exports = {
  entry: {
    main: './src/app.js',
  },
  output: {
    // `filename` provides a template for naming your bundles (remember to use `[name]`)
    filename: '[name].bundle.js',
    // `path` is the folder where Webpack will place your bundles
    path: './dist',
    // `chunkFilename` provides a template for naming code-split bundles (optional)
    chunkFilename: '[name].bundle.js',
  }
};

那它跟 React 有什么关系?来看一个示例:页面上有一个列表,每次点击的时候弹出一个图表

import React, { useState } from "react";
import StockTable from "./StockTable";
import StockChart from "./StockChart";

function App({ stocks }) {
  const [selectedStock, setSelectedStock] = useState(null);
  return (
    <>
      <StockTable
        stocks={stocks}
        onSelect={(selectedStock) => {
          setSelectedStock(selectedStock);
        }}
      />
      {selectedStock && (
        <StockChart
        stock={selectedStock}
        onClose={() => setSelectedStock(null)}
        />
      )}
    </>
  );
}

export default App;

进行构建产物分析可以发现一些三方依赖都是来自于 StockChart 这个组件:

undefined

所以对其进行代码分割的收益是非常大的:

undefined

怎么对 React 组件进行代码分割:

  1. 来看一种最原始的方式
import React, { useState } from "react";
import StockTable from "./StockTable";
- import StockChart from "./StockChart";
+ const stockChartPromise = import("./StockChart");

+ let StockChart = null;
+ const loadComponent = async () => { StockChart = (await stockChartPromise).default };

function App({ stocks }) {
  const [selectedStock, setSelectedStock] = useState(null);
+ const [loading, setLoading] = useState(false);
  return (
    <>
+     {loading && <div>Loading...</div>}
+     {!loading && <>
        <StockTable
          stocks={stocks}
          onSelect={(selectedStock) => {
+           setLoading(true);
+           loadComponent().then(() => {
+             setLoading(false);
			  setSelectedStock(selectedStock);
+           });
          }}
        />
        {selectedStock && (
          <StockChart
            stock={selectedStock}
            onClose={() => setSelectedStock(null)}
          />
        )}
+     </>}
    </>
  );
}
  1. 或者通过引入三方库的方式来简化代码
import React, { useState } from "react";
+ import { Spin } from "antd";
+ import { useRequest } from "ahooks";
import StockTable from "./StockTable";
- import StockChart from "./StockChart";
+ const stockChartPromise = import("./StockChart");

+ const loadComponent = async () => (await stockChartPromise).default;

function App({ stocks }) {
  const [selectedStock, setSelectedStock] = useState(null);
+ const { data: StockChart, loading, runAsync } = useRequest(loadComponent, { manual: true });
  return (
-   <>
+   <Spin spinning={loading}>
      <StockTable
        stocks={stocks}
        onSelect={(selectedStock) => {
+         runAsync().then(() => {
            setSelectedStock(selectedStock);
+         });
        }}
      />
      {selectedStock && (
        <StockChart
          stock={selectedStock}
          onClose={() => setSelectedStock(null)}
        />
      )}
-   </>
+   </Spin>
  );
}
  1. v16.6 推出的 React.lazy 函数则可以让开发者像渲染常规组件一样处理动态引入(的组件),然后在 <Suspense /> 组件中渲染 lazy 组件,这样可以在使用等待加载 lazy 组件时做优雅降级:
import React, { Suspense, useState } from "react";
import StockTable from "./StockTable";
- import StockChart from "./StockChart";
+ const stockChartPromise = import("./StockChart");

+ const StockChart = React.lazy(() => stockChartPromise);

function App({ stocks }) {
  const [selectedStock, setSelectedStock] = useState(null);
  return (
-   <>
+   <Suspense fallback={<div>Loading...</div>}>
      <StockTable
        stocks={stocks}
        onSelect={selectedStock => setSelectedStock(selectedStock)}
      />
      {selectedStock && (
        <StockChart
          stock={selectedStock}
          onClose={() => setSelectedStock(null)}
        />
      )}
-   </>
+   </Suspense>
  );
}

Why

Suspense 简化了开发者的编程模式,维持了长久以来 React 世界里声明式编程的习惯。更为重要的是,它不会触发额外的渲染。

以上面的代码为例,App 函数只会在收到 setSelectedStock 后执行一次,其他的方式则需要执行三次:setLoading -> setSelectedStock -> setLoading

代码分割只是 Suspense 的第一步。React 对 Suspense 的的长远规划包括了利用它来获取数据(并集成一些库,比如 Apollo)。在并发模式中, Suspense 还能提供更好的用户体验(参考下面 v18 章节中的 Concurrent Mode)。

Hooks

What

React Hooks 在 2018 年 10 月 25 日的年度 React Conf 上由 Dan Abramov演讲提出,最早可以在 16.7.0-alpha 版本中使用(#RFC 68)。

React 在 2019 年 2 月 6 日发布的 v16.8 稳定版本上正式集成了该特性

Hooks 允许开发者在不使用 class 的情况下使用 state 和 React 的其他能力,由此可实现功能完备的函数组件。开发者可以编写自定义的 Hooks 来在不同的组件间共享状态的逻辑。

import React, { useState } from 'react';

function Example() {
  // useState 是一个内置 Hook
  // 这里声明一个新的叫做 “count” 的 state 变量
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

How

Hooks 分为两种:内置 Hooks 和自定义 Hooks。

内置 Hooks 又可以分为:

下面是一些内置 Hooks 的用法:

import { useEffect } from 'react';
import { createConnection } from './chat.js';

function ChatRoom({ roomId }) {
  // 状态 Hooks
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');

  // 副作用 Hooks
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [serverUrl, roomId]);
  // ...
}

写一个自定义 Hooks:

import { useState, useEffect } from 'react';

// 声明一个 Hooks
function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(true);
  useEffect(() => {
    function handleOnline() {
      setIsOnline(true);
    }
    function handleOffline() {
      setIsOnline(false);
    }
    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);
    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);
  return isOnline;
}


function StatusBar() {
  const isOnline = useOnlineStatus();
  return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}

function SaveButton() {
  const isOnline = useOnlineStatus();

  function handleSaveClick() {
    console.log('✅ Progress saved');
  }

  return (
    <button disabled={!isOnline} onClick={handleSaveClick}>
      {isOnline ? 'Save progress' : 'Reconnecting...'}
    </button>
  );
}

export default function App() {
  return (
    <>
      <SaveButton />
      <StatusBar />
    </>
  );
}

在线示例:https://codesandbox.io/s/g3m4sq

可以在 React 的 API 参考文档(React 内置 Hooks)上找到详细的用法示例。

Why

可以说,Hooks 给函数组件注入了灵魂。React Hooks 介绍中解释了为什么要为 React 添加 Hooks。根据 React 团队的说法,主要动机是:

Hooks 让函数组件拥有了完备的能力,让 React 的上手成本更低了。但函数组件对开发者的能力提出了更高的要求。一方面从 UX 的角度,类组件写出的组件默认会比函数组件性能更好;另一方面从 DX 的角度,如果不能很好地进行抽象,函数组件的可维护性甚至比类组件更差。好在这些问题可以通过预编译和 Lint 来解决。

Profiler

What

React 最早在 v16.4 版本中推出了实验性的 unstable_Profiler 组件(#RFC 51),并在 v16.5 版本中推出了 React Profiler for DevTools。在 v16.9 版本上正式集成了该功能

Profiler 包含两个方面的内容,<Profiler> 组件和 React 开发者工具中的 Profiler 面板。

<Profiler> 组件可以用于采集 React 组件树的渲染性能:

function onRender(id, phase, actualDuration, baseDuration, startTime, commitTime) {
  // 对渲染时间进行汇总或记录...
}
<Profiler id="App" onRender={onRender}>
  <App />
</Profiler>

Profiler 面板则收集了所有组件的渲染耗时,可以找出 React 应用程序的性能瓶颈。

undefined

How

从概念上讲,React 分两个阶段工作:

理解了这两个阶段的概念,就能看得懂<Profiler> 组件和 Profiler 面板里的数据了。

可以使用多个 <Profiler> 组件来测量应用不同部分的性能:

<App>
  <Profiler id="Sidebar" onRender={onRender}>
    <Sidebar />
  </Profiler>
  <Profiler id="Content" onRender={onRender}>
    <Content />
  </Profiler>
</App>

onRender 方法参数的可参考 React 官方文档

Profiler 面板的使用教程可参考 React 的的博客文档,这里就不再赘述了。不过这篇文章比较旧,写的时候 React DevTools 还是 3.0 版本,现在已经发布到 4.x 了

值得注意的是,进行性能分析会增加一些额外的开销。因此在默认情况下,它在生产环境中是被禁用的。也就是说如果你不进行任何配置,默认在生产环境:

如果要在生产环境下开启性能分析,需要使用一个特殊的带有性能分析功能的生产构建包。以 Webpack 4 为例,配置起来也很简单:

module.exports = {
  //...
  resolve: {
    alias: {
      'react-dom$': 'react-dom/profiling',
    }
  }
};

Why

React 推出 Profiler API 是为了帮助开发者更好地理解他们应用的性能情况:测量应用中渲染更新的成本,帮助开发者定位性能瓶颈,从而优化应用的性能。

Others

Custom DOM attributes

从这个版本开始,React 不会忽略无法识别的 HTML 属性,而是将它们透传到 DOM。这带来的额外好处是 React 比修设置属性白名单,从而减少了代码量(文件大小):

<span data-id="test">hello</span>

before(v15):

<span>hello</span>

after(v16):

<span data-id="test">hello</span>

Component Lifecycle Changes

React 在 v16.3 版本中移除了 componentWillMount/ componentWillReceiveProps / componentWillUpdate 生命周期,引入了 getDerivedStateFromProps(#RFC 6) 和 getSnapshotBeforeUpdate(#RFC 33) 生命周期,React 遵循渐进式升级的理念,下面是老生命周期的替换方式:

New APIs

createContext: v16.3 新增(#RFC 2),提供易于使用的 API 和支持深度更新(规避 shouldComponentUpdate 造成的广播阻塞)

+ const ThemeContext = React.createContext('light');

class ThemeProvider extends React.Component {
  state = {theme: 'light'};
  
-  getChildContext() {
-    return {theme: this.state.state};
-  }
  
  render() {
    return (
+     <ThemeContext.Provider value={this.state.theme}>
        {this.props.children}
+     </ThemeContext.Provider>
    );
  }
}

- ThemeProvider.childContextTypes = {
-   theme: PropTypes.string
- };

class ThemedButton extends React.Component {
  render() {
    return (
+     <ThemeContext.Consumer>
+       {theme => 
		  <Button 
		    theme={
-             this.context.theme
+    		  theme
   		    } 
		  />
+ 		}
+     </ThemeContext.Consumer>
    );
  }
}

- ThemedButton.contextTypes = {
-   color: PropTypes.string
- };

contextType: v16.6 新增(#RFC 65),便于在 class 组件中使用 context 的值

const MyContext = React.createContext();
class MyClass extends React.Component {
  static contextType = MyContext;
  render() {
    let value = this.context;
    /* 基于这个值进行渲染工作 */
  }
}

createRef: v16.3 新增(#RFC 17),用于替换字符串 Ref(Implement Better Refs API)

class MyComponent extends React.Component {
  constructor(props) {
    super(props);
+   this.inputRef = React.createRef();
  }

  render() {
    return <input 
	  type="text"
-     ref={(input) => { this.inputRef = input; }} />
+ 	  ref={this.inputRef} 
  	/>;
  }

  componentDidMount() {
-   this.inputRef.focus();
+   this.inputRef.current.focus();
  }
}

forwardRef: v16.3 新增(#RFC 30),便于转发 ref

const CustomTextInput = 
- function (props) {
+ React.forwardRef(function (props, ref) {
    return (
      <input
-       ref={props.inputRef}
+       ref={ref}
	  />
    );
  }

class Parent extends React.Component {
  constructor(props) {
    super(props);
+   this.inputRef = React.createRef();
  }
  
  render() {
    return (
      <CustomTextInput
-       inputRef={el => this.inputRef = el}
+       ref={this.inputRef}
      />
    );
  }
  
  componentDidMount() {
-   this.inputRef.focus();
+   this.inputRef.current.focus();
  }
}

memo: v16.6 新增(#RFC 63),使得函数组件在 props 不变时不会重新执行

const MyComponent = React.memo(function MyComponent(props) {
  /* 仅在 props 发生改变时才会 rerenders */
});

v17: 无关紧要的垫脚石

v16 发布时隔两年半后,React 在 2020 年 10 月 20 日推出了 v17 版本,v17 没有添加新的功能,主要更新了内部的一些特性。小细节很多,但大的改动就两个。

Changes to Event Delegation

What

在 React 组件中,通常会内联编写事件处理:

<button onClick={handleClick}>

与此代码等效的原生 DOM 操作如下:

myButton.addEventListener('click', handleClick);

但是对大多数事件来说,React 实际上并不会将它们附加到 DOM 节点上。相反 React 会直接在 document 节点上为每种事件类型附加一个处理器。这被称为事件委托。除了在大型应用程序上具有性能优势外,它还使添加类似于 replaying events 这样的特性变得更加容易。

自发布以来,React 一直以事件委托的方式来实现内联事件处理。当 document 上触发 DOM 事件时,React 会找出调用的组件,然后 React 事件会在组件中向上 “冒泡”。但实际上,原生事件已经冒泡到了 document 级别,React 在其中安装了事件处理器。

在 React 17 中,React 将不再向 document 附加事件处理器。而会将事件处理器附加到渲染 React 树的根 DOM 容器中:

const rootNode = document.getElementById('root');
ReactDOM.render(<App />, rootNode);

在 React 16 或更早版本中,React 会对大多数事件执行 document.addEventListener()。React 17 将会在底层调用 rootNode.addEventListener()

undefined

如果组件中使用 document.addEventListener(...) 手动添加了 DOM 监听,可能希望能捕获到所有 React 事件。在 React 16 或更早版本中,即使在 React 事件处理器中调用 e.stopPropagation(),创建的 DOM 监听仍会触发,这是因为原生事件已经处于 document 级别。使用 React 17 冒泡将被阻止(按需),因此 document 级别的事件监听不会触发:

document.addEventListener('click', function() {
  // This custom handler will no longer receive clicks
  // from React components that called e.stopPropagation()
});

可以将监听转换为使用捕获来修复此类问题:

document.addEventListener('click', function() {
  // Now this event handler uses the capture phase,
  // so it receives *all* click events below!
}, { capture: true });

Why

在过去,React 一直遵循 “all-or-nothing” 的升级策略。开发者可以继续使用老的版本,也可以将整个应用程序升级至新的版本。但没有介于两者之间的情况。这个升级策略有它的局限性:许多 API 的变更,例如弃用旧版 context API 时,并不能以自动化的方式来完成。可能大多数应用程序从未使用过它们,但 React 仍然选择支持它们来进行向下兼容。

v17 期望支持逐步升级 React 版本,通过允许在页面上使用两个版本的 React 的方式。因此需要解决事件监听问题:如果页面上有多个 React 版本,因为它们都将在顶层注册事件处理器,所以e.stopPropagation()会变得无效(嵌套树结构中阻止了事件冒泡,但外部树依然能接收到它)。这让不同版本的 React 嵌套变得困难重重(Atom 编辑器就遇到了这样的问题)。这就是逐步升级的困难所在。

由于事件委托机制的更改,现在可以更加安全地进行新老版本 React 的嵌套。要让它正常工作,两个版本都必须为 17 或更高版本。React 这一做法实际是为即将到来的 v18 做准备,因为 v18 会有很大底层的变更(为实现并发渲染)。所以 v17 才被称为「垫脚石版本」。

由于事件委托机制的更改,还使得将 React 嵌入使用其他技术构建的应用程序变得更加容易。例如,如果应用程序的“外壳”是用 Vue 编写的,但其中较新的代码是用 React 编写的,则 React 代码中的 e.stopPropagation() 会阻止事件影响到 Vue 的代码 —— 这符合预期。换个角度来说,如果你不再使用 React 并想重写应用程序(比如用 Vue),则可以从外壳开始将 React 转换为 Vue,而不会破坏事件冒泡。

New JSX Transform

What

在浏览器中无法直接使用 JSX,所以大多数 React 开发者需依靠 Babel 来将 JSX 代码转换为 JavaScript。

import React from 'react';

function App() {
  return <h1>Hello World</h1>;
}

旧的 JSX 转换会将上述代码变成下面的代码:

import React from 'react';

function App() {
  return React.createElement('h1', null, 'Hello world');
}

新的 JSX 转换不会将 JSX 转换为 React.createElement,而是自动从 React 的 package 中引入新的入口函数并调用:

import {jsx as _jsx} from 'react/jsx-runtime';

function App() {
  return _jsx('h1', { children: 'Hello world' });
}

可见,这时候源代码无需引入 React 即可使用 JSX 了:

- import React from 'react';
function App() {
  return <h1>Hello World</h1>;
}

这次升级没有改变 JSX 语法,与所有现有 JSX 代码完全兼容。可以通过 Babel 在线示例来看看编译后的输出有何不同。

How

Babel 的 v7.9.0 及以上版本可支持全新的 JSX 转换。

首先需要更新至最新版本的 Babel 和 transform 插件。

$ npm update @babel/core @babel/plugin-transform-react-jsx

然后更新配置为 {"runtime": "automatic"}

{
  "presets": [
    ["@babel/preset-react", {
-     "runtime": "classic"
+     "runtime": "automatic"
    }]
  ]
}

更多内容参考 Babel 文档中的 @babel/plugin-transform-react-jsx

如果项目在用 eslint-plugin-react,那么其中的 react/jsx-uses-reactreact/react-in-jsx-scope 规则已经不再需要了,可以关闭它们或者删除:

{
  // ...
  "rules": {
    // ...
-   "react/jsx-uses-react": "on",
-   "react/react-in-jsx-scope": "on"
+   "react/jsx-uses-react": "off",
+   "react/react-in-jsx-scope": "off"
  }
}

Why

React 提供全新 JSX 转换的动机为:

另外,新的 JSX 转换还会略微改善 bundle 的大小:

v18: 带来了什么

React 于 2021 年 7 月 8 日公布了 v18 的发布计划,在 2021 年 7 月 8 日发布了 v18 Alpha 版本,并于 2022 年 3 月 29 日发布了 v18 的正式版本,可以参考官方指南进行升级

Concurrent Mode

What

Concurrent Mode(并发模式)是 React 雄心勃勃的一种渲染机制,最早在 v16.0 版本(2017 年 9 月)中就被提出。Andrew Clark 当时称其为 “Async Rendering(异步渲染)“,并演示了它早期的功能设定

v16.7 的 Alpha 版本中包含了 React.ConcurrentModeReactDOM.createRoot。早期的文档包括:Adopting Concurrent Mode, Introducing Concurrent Mode

React 在 v16 版本就为此进行了大量的准备。例如:

How

React 18 引入了一个新的 root API,它启用了新的并发渲染器,所以开发者能够通过选择使用该 API 来开启并发模式

- import { render } from 'react-dom';
+ import { createRoot } from 'react-dom/client';
const container = document.getElementById('app');
+ const root = createRoot(container);
- render(<App tab="home" />, container);
+ root.render(<App tab="home" />);

只有开启了并发模式,并发特性才会生效,依赖并发特性的并发功能才会生效:

Automatic Batching

React 18 通过在默认情况下执行批处理来实现了开箱即用的性能改进。批处理是指为了获得更好的性能,在数据层将多个状态更新批量处理,合并成一次更新(在视图层,将多个渲染合并成一次渲染)。

在 React 18 之前,React 只在事件处理函数中进行批处理更新。默认情况下,在 promise、setTimeout、原生事件处理函数中或任何其它事件内的更新都不会进行批处理:

import { useState, useEffect } from 'react';

function App() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(0);
  useEffect(() => {
    // 在原生 js 事件中不会进行批处理
    document.body.addEventListener('click', () => {
      setCount(count => count + 1);
      setFlag(f => !f);
    });
  }, []);

  return (
    <button
      onClick={() => {
        // 在 React 事件中将被批处理
        setCount(count => count + 1);
        setFlag(f => !f);

        // 在 setTimeout 中不会进行批处理
        setTimeout(() => {
          setCount(count => count + 1);
      	  setFlag(f => !f);
        });
      }}
    >
      {`count is ${count}, flag is ${flag}`}
    </button>
  );
};

在 React 18 中,上面的三个例子只会有一次 render,因为所有的更新都将自动批处理。这样无疑是很好地提高了应用的整体性能。

批处理是一个破坏性改动,如果想退出批量更新,可以使用 flushSync

import { useState } from 'react';
import { flushSync } from 'react-dom';

function App() {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);
  return (
    <div
      onClick={() => {
        flushSync(() => {
          // 第一次更新
          setCount1(count => count + 1);
        });
        flushSync(() => {
          // 第二次更新
          setCount2(count => count + 1);
        });
      }}
    >
      <div>count1: {count1}</div>
      <div>count2: {count2}</div>
    </div>
  );
};

flushSync 函数内部的多个 setState 仍然为批量更新,这样可以精准控制哪些不需要批量更新。

更多内容请参阅:Automatic batching for fewer renders in React 18

Transition

过渡(transition)更新是 React 中一个新的概念,用于区分紧急和非紧急的更新。

一个比较典型的场景就是 Tab 切换更新内容列表:从 About 标签切换到 Post 标签时,由于要更新的 DOM 节点非常多,所以 UI 切换可能会卡死,造成页面无法响应用户的交互。

undefined

通过 transition,UI 仍将在重新渲染过程中保持响应性。例如用户点击一个选项卡,但改变了主意并点击另一个选项卡,他们可以在不等待第一个重新渲染完成的情况下完成操作。

+ import { useTransition } from 'react';

function TabContainer() {
+ const [isPending, startTransition] = useTransition();
  const [tab, setTab] = useState('about');

  function selectTab(nextTab) {
+   startTransition(() => {
      setTab(nextTab);
+   });
  }
  // ……
}

再比如一个场景就是根据用户输入实时展示结果。为了更好的用户体验,一个用户输入应该同时产生一个紧急更新和一个过渡更新。可以在一个输入事件中使用 startTransition API 告诉 React 哪些更新是紧急更新,哪些又是过渡更新:

import { startTransition } from 'react';

// 紧急更新: 显示输入的内容
setInputValue(input);

// 将任何内部的状态更新都标记为过渡更新
startTransition(() => {
  // 过渡更新: 展示结果
  setSearchQuery(input);
});

更多内容请参阅:Patterns for startTransition

useDeferredValue

useDeferredValue 返回一个延迟响应的值,可以让一个 state 延迟生效,只有当前没有紧急更新时,该值才会变为最新值。useDeferredValuestartTransition 一样,都是标记了一次非紧急更新。它们一个用来包装值,一个用来包装方法。

所以,上面 startTransition 的例子,我们也可以用 useDeferredValue 来实现:

- import { useTransition } from 'react';
+ import { useDeferredValue } from 'react';

function TabContainer() {
- const [isPending, startTransition] = useTransition();
  const [tab, setTab] = useState('about');

  function selectTab(nextTab) {
-   startTransition(() => {
      setTab(nextTab);
-   });
  }
+ const deferredTab = useDeferredValue(tab);
  // ……
}

Why

undefined undefined

https://medium.com/@jdaudier/notes-from-dan-abramovs-beyond-react-16-talk-5861a92dcdce

来看一个示例,在没有并发特性下执行如下代码:

import { useState, useEffect } from 'react';

export default function ConcurrentMode() {
  const [list, setList] = useState([]);
  useEffect(() => {
    setList(new Array(10000).fill(null));
  }, []);

  return (
    <>
      {list.map((_, i) => (
        <div key={i}>{i}</div>
      ))}
    </>
  );
}

undefined

从打印的执行堆栈图看到,此时由于组件数量繁多(10000个),JS 执行时间为 900ms,也就是意味着,在没有并发特性的情况下:一次性渲染 10000 个标签的时候,页面会阻塞大约 0.9 秒,造成卡顿。

如果开启了并发更新呢?

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

export default function ConcurrentMode() {
  const [list, setList] = useState([]);
  useEffect(() => {
+   startTransition(() => {
      setList(new Array(10000).fill(null));
+   });
  }, []);

  return (
    <>
      {list.map((_, i) => (
        <div key={i}>{i}</div>
      ))}
    </>
  );
}

undefined

此时我们的任务被拆分到每一帧不同的 task 中,JS 脚本执行时间大体在 5ms 左右,这样浏览器就有剩余时间执行样式布局和样式绘制,减少掉帧的可能性。这种将长任务分拆到每一帧中,像蚂蚁搬家一样一次执行一小段任务的操作,被称为时间切片(time slice)

当然由于我们这个示例只有一个视图要渲染,所以看整个性能视图时会发现这个时间空隙没有被充分利用起来。但是可以看到的是总的 JS 执行时长和总阻塞时间变低了

undefined

在并发模式中 Suspense 也提供了更好的用户体验。它可以在网络足够快(loading)的时候略过显示兜底 UI(fallback)。

我们把 Suspense 模式的实现理解成下面这样:

const [loading, setLoading] = useState(false);
  
setLoading(true);
// (async)do something...
setLoading(false);

{ loading ? <div>Loading...</div> : null }

在普通模式中,React 更新内容渲染的方式是通过一个单一的且不可中断的同步事务进行处理。同步渲染意味着,一旦开始渲染就无法中断,直到用户可以在屏幕上看到渲染结果。所以用户一定会看到 loading(fallback)。

在并发渲染中,React 可以开始渲染一个更新,然后中途挂起,稍后又继续。它甚至可能完全放弃一个正在进行的渲染。React 保证即使渲染被中断,UI 也会保持一致。所以如果「do something」足够快时,用户可以看不到 loading(fallback)。

New Hooks API

useId

useId 主要用于生成在客户端和服务端两侧都独一无二的 id,避免 hydrate 后两侧内容不匹配。

示例

aria-describedby 这样的 HTML 无障碍属性允许指定两个标签之间的关系。例如可以指定一个元素(比如输入框)由另一个元素(比如段落)描述:

<label>
  密码:
  <input
    type="password"
+   aria-describedby="password-hint"
  />
</label>
<p 
+ id="password-hint"
>
  密码应该包含至少 18 个字符
</p>

但是在 React 中直接编写 ID 不是一个好的习惯。因为一个组件可能会在页面上渲染多次,但是 ID 必须是唯一的。这时候就可以使用 useId 生成唯一的 ID:

+ import { useId } from 'react';

function PasswordField() {
+ const passwordHintId = useId();
  return (
    <>
      <label>
        密码:
        <input
          type="password"
+         aria-describedby={passwordHintId}
        />
      </label>
      <p
+      id={passwordHintId}
	  >
        密码应该包含至少 18 个字符
      </p>
    </>
  );
}

动机#RFC

useId 的主要好处是 React 确保它能够与服务端渲染一起工作。在服务器渲染期间,组件生成输出 HTML。随后在客户端,hydration 将事件监听附加到生成的 HTML 上。由于 hydration,客户端必须匹配服务器输出的 HTML。使用递增计数器(nextId++)很难保证这一点,因为客户端组件被 hydrate 处理后的顺序可能与服务器 HTML 的顺序不匹配。调用 useId 可以确保 hydration 正常工作,以及服务器和客户端之间的输出相匹配。

React 通过调用组件的“父路径”生成 useId 的。这就是为什么如果客户端和服务器的树相同,不管渲染顺序如何,“父路径”始终都匹配。

useInsertionEffect

在 React 项目中很多样式方案,其中一种是 CSS-in-JS。该方案让开发者直接在 JavaScript 代码中编写样式,而不是编写 CSS 文件。以下是 CSS-in-JS 三种常见的实现方法:

  1. 使用编译器静态提取到 CSS 文件
  2. 内联样式,例如 <div style={{ opacity: 1 }}>
  3. 运行时注入 <style> 标签

通常建议结合使用前两种方法(静态样式使用 CSS 文件,动态样式使用内联样式)来实现。不建议第三种方式有两个原因:

  1. 运行时注入会使浏览器频繁地重新计算样式
  2. 如果在 React 生命周期中某个错误的时机进行运行时注入,可能会非常慢

第一个问题无法解决,而 useInsertionEffect 就是用来解决第二个问题的。

定义

useInsertionEffect(setup, dependencies?)

示例

// 在 CSS-in-JS 库中
const isInserted = new Set();
function useCSS(rule) {
  useInsertionEffect(() => {
    if (!isInserted.has(rule)) {
      isInserted.add(rule);
      document.head.appendChild(getStyleForRule(rule));
    }
  });
  return rule;
}

function Button() {
  const className = useCSS('...');
  return <div className={className} />;
}

动机

useInsertionEffect 比在 useLayoutEffectuseEffect 期间注入样式更好:

参考:Library Upgrade Guide: <style> (most CSS-in-JS libs)

useSyncExternalStore

useSyncExternalStore 能够通过强制同步更新数据让 React 组件在并发模型下安全地有效地读取外部数据源。

定义

const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)

示例

import { useSyncExternalStore } from 'react';

export default function ChatIndicator() {
  const isOnline = useSyncExternalStore(subscribe, getSnapshot);
  return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}

function getSnapshot() {
  return navigator.onLine;
}

function subscribe(callback) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

动机#RFC 214

useSyncExternalStoreuseMutableSource(#RFC 147) 演进而来,主要用来解决外部数据与 React 数据管理之间的撕裂(tear)问题。在 Concurrent Mode 下,React 一次渲染会分片执行,中间可能穿插优先级更高的更新。假如在高优先级的更新中改变了公共数据(比如 redux 中的数据),那之前低优先的渲染必须要重新开始执行,否则就会出现前后状态不一致的情况。

useSyncExternalStore 主要是给三方状态管理库使用的,开发者在日常业务中不需要关注。因为 React 自身的 useState 已经原生地解决了并发特性下的 tear(撕裂)问题。但是比如 redux,它在控制状态时可能并非直接使用的 React 的 state,而是自己在外部维护了一个 store 对象,用发布订阅模式实现了数据更新,脱离了 React 的管理,也就无法依靠 React 自动解决撕裂问题。因此就需要使用到这样一个 API。

目前 React-Redux 8.0 已经基于 useSyncExternalStore 实现。

参考:Concurrent React for Library Maintainers

New Suspense

Suspense 允许以声明方式指定组件尚未准备好渲染(加载)时应显示的内容(状态):

<Suspense fallback={<Spinner />}>
  <Comments />
</Suspense>

在 React 16.6 中, Suspense 只支持了与 React.lazy() 一同使用以实现代码分割。如果 Suspense 只用来实现代码分割,那就真的有点”大材小用”了。React 的长期规划是让其支持获取代码、数据和资源(图片、字体和样式等)的场景,并在服务端渲染的场景中大放异彩。

从概念上讲,可以将 Suspense 视为类似于 Error Boundary(try...catch...块)。只是它捕获的不是错误,而是“挂起”的组件。React 中的任何组件都可以“挂起”,这意味着它还没有准备好渲染(有多种原因,例如由于缺少代码、数据等)。

Suspense 让 “UI 加载状态” 成为 React 编程模型中的一等公民。让开发者可以在其之上构建更高级别的功能。v18 并没有改变 Suspense API 本身,而是改进了它的其语义并添加了一些新功能(#RFC 213),包括了数据获取和服务端渲染场景的支持,以及在并发渲染下的新特性。

Suspense for Data Fetching

Suspense 已经可以用于数据获取的场景了。

在下面的例子中,Albums 组件在获取专辑列表数据时会处于等待的状态。在它准备好渲染前,Albums 祖先组件中距离其最近的 Suspense 将展示兜底组件 —— 即 Loading 组件。当数据加载完成时,React 会隐藏 Loading 组件并渲染带有数据的 Albums 组件。

- import { useState, useEffect } from 'react';
+ import { Suspense, use } from 'react';

function ArtistPage() {
  return (
+   <Suspense fallback={<Loading />}>
	  <Albums />
+   </Suspense>
  );
}

function Albums() {
- const [albums, setAlbums] = useState([]);
- useEffect(() => fetch('/albums').then((data) => setAlbums(data)), []);
+ const albums = use(fetch('/albums'));
  return (
    <ul>
      {albums.map(({ id, title, year }) => (
        <li key={id}>
          {title} ({year})
        </li>
      ))}
    </ul>
  );
}

undefined

Suspense 只是一种让 React 了解声明性加载状态的机制,它的设计目标是与代码/数据的加载方式完全解耦。开发者可以定制自己的策略,例如数据协议(GraphQL 或 REST)、数据获取位置(框架集成或临时封装)、请求策略(串行或并行)以及运行环境(客户端或服务端)等等。

现在的规则是,只有启用了 use() 的数据源才会激活 Suspense 组件,Suspense 也无法检测在 Effect 或事件处理程序中获取数据的情况。

Suspense 还有一个重要的特性是支持嵌套。这意味着开发者可以自己控制是要同时展示内容还是逐步加载内容。

默认情况下,Suspense 内部的整棵组件树都被视为一个单独的单元。因此即使只有一个组件因等待数据而被挂起,Suspense 内部的整棵组件树中的所有组件都将被替换为兜底组件:

<Suspense fallback={<Spinner />}>
+ <SyncComponent />
+ <Panel>
    <AsyncComponent />
+ </Panel>
</Suspense>

当一个组件被挂起时,最近的父级 Suspense 组件会显示兜底组件。所以可以嵌套多个 Suspense 组件创建一个加载序列。每个 Suspense 边界的兜底组件都会在下一级内容可用时填充。

<Suspense fallback={<Spinner />}>
  <SyncComponent />
+ <Suspense fallback={<SpinnerA />}>
    <Panel>
      <AsyncComponent />
    </Panel>
+ </Suspense>
</Suspense>

这样调整后,SyncComponent 不需要等待 AsyncComponent 就可以直接显示。如果是两个可能被挂起的组件呢?

<Suspense fallback={<Spinner />}>
- <SyncComponent />
+ <AsyncComponentB />
  <Suspense fallback={<SpinnerA />}>
    <Panel>
      <AsyncComponent />
    </Panel>
  </Suspense>
</Suspense>

加载序列将会是:

  1. 如果 AsyncComponentB 没有加载完成,Spinner 会显示在整个内容区域
  2. 一旦 AsyncComponentB 加载完成,Spinner 会被内容替换
  3. 如果 AsyncComponent 没有加载完成,SpinnerA 会显示在 AsyncComponent 和它的父级 Panel 的位置
  4. 最后,一旦 AsyncComponent 加载完成,它会替换 SpinnerA

Concurrent Mode with Suspense

考虑这种情况:

function Demo() {
	const [tab, setTab] = useState('photos');
	function handleClick() {
	  	setTab('comments');
	}
	return (
		<Suspense fallback={<Spinner />}>
		  {tab === 'photos' ? <Photos /> : <Comments />}
		</Suspense>
	);
}

其中 <Comments /> 组件存在异步数据源。所以当 tab 设置从 ‘photos’ 切换到 ‘comments’ 时,则会触发 Suspense 边界从而显示 <Spinner />

前面介绍过并发模式下的新功能 Transition,当它们与 Suspense 一起使用时,效果则完全不同。以 Transition 为例:

+ import { startTransition } from 'react';
function handleClick() {
+ startTransition(() => {
    setTab('comments');
+ });
}

这相当于告诉 React 设置 tab 为 ‘comments’ 不是一个“紧急”的更新,而是一个可能需要一些时间的“过渡”。所以 Suspense 会保留显示 <Photos /> 直到 <Comments /> 准备好,而不是显示 <Spinner />

看到这里读者可能会有疑问,如果我不要显示 <Spinner />,那我直接不用 Suspense 不就可以了吗?需要注意的是在这个示例中,因为 <Comments /> 还没有准备好,所以如果没有 Suspense,直接切换到 <Comments /> 将会显示一片空白。因此 Suspense 加 Transition 的组合,使得开发者可以在新内容加载时展示过时的内容

SSR with Suspense

React 18 带来了支持 Suspense 的全新的流式服务端渲染。它包含了两个方面的内容:更好的错误的恢复机制和更强大的流式渲染能力。

Error Handle

先说说更好的错误恢复机制(#RFC 215)。它是通过添加 <Suspense> 来从错误中恢复的:

<Suspense fallback={<Spinner />}>
  <Comments />
</Suspense>
  1. 服务端错误:以前,如果在服务端渲染组件时抛出异常则 renderToString 会执行失败,然后整个应用程序完全转为客户端渲染;使用了 Suspense 后,在服务器上 React 将返回 Suspense 的兜底组件(<Spinner />)。在客户端,React 将识别失败的 Suspense 边界并重试渲染其内的子组件内容。如果渲染成功,则会显示其结果。如果渲染再次抛出异常,React 会把它视为渲染期间的常规异常抛出(这意味着它会由最近的错误边界进行处理)。
  2. 水合作用不匹配:也将使用相同的机制来重试客户端渲染。
    1. 属性不匹配:工作方式与以前相同。React 仅在开发过程中发出警告,但不会尝试修复它。
    2. 丢失/额外的节点和文本内容不匹配:以前 React 会尝试修复树结构以匹配客户端渲染的结果。然而,这可能会导致隐私和安全漏洞。新的行为是丢弃服务器生成的 HTML 直到最近的 <Suspense> 边界,然后从该部分进行重新渲染。如果错误上方没有 <Suspense> 边界,React 会从根重试客户端渲染并丢弃所有服务端生成的 HTML。

如果客户端渲染成功,React 会认为原始错误是“可恢复的”,因为它没有向用户显示。React 仍然会记录开发和生产环境中的可恢复错误,可以通过以下的方式获取:

hydrateRoot(container, <App />, {
  onRecoverableError(error) {
    // ...
  }
});

如果不指定 onRecoverableError,则 React 默认会调用 reportError,如果该方法不可用则调用 console.error

Better Streaming

其次是更强大的流式渲染能力(#Discussions 37)。Shaundai 在 2021 年度 React Conf 上关于演讲描述了这个能力的概貌,v18 正式版本上已可用。

v18 实现了一个全新的服务端渲染器以支持乱序流式 HTML,该渲染器可以「刷新」之前生成的 HTML。新的渲染器跟 Suspense 能力集成,很好地解决了之前 SSR 的一些问题。

在 React 中,SSR 执行步骤是:Fetch Data -> Render as HTML -> Load JS -> Hydration

undefined

整个步骤是串行的,所以如果应用中某些部分比较慢,则这种方法效率不高。因此,传统 SSR 的问题有:

  1. Fetch everything before Show anything: 必须已经为服务器上的组件准备好所有数据,然后才能把组件渲染为 HTML
  2. Load everything before Hydrate anything: 必须先加载客户端上所有组件的代码 ,然后才能开始对其中的组件进行水合
  3. Hydrate everything before Interact with anything: 必须等待所有的组件都水合完成后,然后才能与其中的组件进行交互

怎么解决这些问题呢?

v18 之前,流式服务端渲染可以通过进行代码拆分的方式,边请求数据边流式返回。代码的实现类似这样:

const user = await fetch('/user');
renderToNodeStream(<User {...user} />);

const post = await fetch('/post');
renderToNodeStream(<Post {...post} />);

这样的方案依然存在一些缺陷:

  1. 无法很好地在 SSR 和 CSR 渲染策略间进行切换;
  2. 无法解决队列阻塞问题。如果后面组件更重要时无法优先展示;
  3. 无法解决传统 SSR 中的第二和第三个问题。

那 v18 是怎么解决这些问题的呢?来看一个示例:

<Layout>
  <NavBar />
  <Sidebar />
  <RightPane>
    <Post />
    <Comments />
  </RightPane>
</Layout>

它长这样:

undefined

v18 的解决方式是结合 <Suspense> ,允许每个组件独立管理(Fetch -> Render -> Load -> Hydration)这个执行步骤:

undefined

怎么理解呢?来看第一个问题 「Fetch everything before Show anything」

<Layout>
  <NavBar />
  <Sidebar />
  <RightPane>
    <Post />
    <Suspense fallback={<Spinner />}>
      <Comments />
    </Suspense>
  </RightPane>
</Layout>

// Comments
import { use } from 'react';
function Comments() {
  const comments = use(fetch('/api/comments'));
  return (
    <>
      {comments.map((comment, i) => (
        <p className="comment" key={i}>
          {comment}
        </p>
      ))}
    </>
  );
}

通过把数据获取封装到 Comments 组件内,并使用 <Suspense> 包裹,这相当于告诉 React 不用等 Comments 渲染完成就可以传输其他的组件到 HTML,所以在前台 Comments 区域看到的是一个加载中的界面:

undefined

在一开始输出的 HTML 里面是没有 Comments 组件的:

<main>
  <nav>
    <!--NavBar -->
    <a href="/">Home</a>
   </nav>
  <aside>
    <!-- Sidebar -->
    <a href="/profile">Profile</a>
  </aside>
  <article>
    <!-- Post -->
    <p>Hello world</p>
  </article>
  <section id="comments-spinner">
    <!-- Spinner -->
    <img width=400 src="spinner.gif" alt="Loading..." />
  </section>
</main>

当服务端评论数据准备就绪时,React 会将额外的 HTML 发送到同一个流中,以及一个最小的内联 <script> 标记来将该 HTML 放到在正确的位置:

<div hidden id="comments">
  <!-- Comments -->
  <p>First comment</p>
  <p>Second comment</p>
</div>
<script>
  // This implementation is slightly simplified
  document.getElementById('sections-spinner').replaceChildren(
    document.getElementById('comments')
  );
</script>

undefined

这就解决了第一个问题,不需要在展示任何内容之前获取所有数据。跟传统的 HTML 流式传输不同,它不需要按照自上而下的顺序进行。也就是说我们可以用 <Suspense> 包裹任何部分,例如示例中的 Slider。如果 Slider 有数据的话,流式传输不会等待它准备数据完成而开始开始渲染后面部分的内容。当 Slider 准备完成,React 会立即把它替换过来。这对于要控制优先展示后面部分内容的场景非常有效。

再来看第二个问题 「Load everything before Hydrate anything」

为了避免首屏 bundle 过大,通常会使用代码分割来进行优化:

import { lazy } from 'react';

const Comments = lazy(() => import('./Comments.js'));

<Suspense fallback={<Spinner />}>
  <Comments />
</Suspense>

在 v18 之前,这不适用于服务器渲染。社区上的一些解决方法是要么选择性地不对代码分割组件进行 SSR,要么在加载所有代码后才对它们进行水合,这在一定程度上违背了代码分割的目的。

在 v18 中,使用 <Suspense> 包裹会在 Comments 脚本加载之前对应用程序进行水合

从用户的视角看,最初他们看到的是以 HTML 形式流入的非交互式的内容:

undefined

undefined

然后主脚本加载完成,应用通知然 React 进行水合。Comments 脚本还没有加载完成,但没有关系:

undefined

React 会先对已完成脚本加载的组件进行水合。通过包裹 Comments,<Suspense> 告诉 React 不应该阻止页面的其余部分进行流式传输,而且事实证明,也不应该阻止水合。这意味着第二个问题已经解决:不再需要等待所有代码加载才能开始水合

React 将在 Comments 脚本加载完成后开始对它们进行水合:

undefined

另外还有一种场景是即使 HTML 正在流式传输,也可能部分组件的脚本已经加载完成,但 Comments 组件的数据还没有准备好:

undefined

这时候 React 就会对已经准备好脚本的组件进行水合,而不需要等待 Comments 组件流式传输完成:

undefined

当 Comments 的 HTML 加载完成后,它将显示为非交互式,因为 JS 还没有加载完成:

undefined

当 Comments 的 JS 代码加载完成后,React 会继续对它进行水合,页面变成完全可交互:

undefined

最后一个问题就是 「Hydrate everything before Interact with anything」

例如,当在 Comments 正在进行水合时用户单击了侧边栏:

undefined

在 React 18 中,Suspense 内的内容水合是在浏览器可以处理事件的微小间隙中进行的。因此点击会立即得到处理

另外当有多处 Suspense 时,考虑下面这种情况:Sidebar 和 Comments 都包裹在 Suspense 内

<Layout>
  <NavBar />
  <Suspense fallback={<Spinner />}>
    <Sidebar />
  </Suspense>
  <RightPane>
    <Post />
    <Suspense fallback={<Spinner />}>
      <Comments />
    </Suspense>
  </RightPane>
</Layout>

假设它们的 HTML 已加载,但它们的脚本尚未加载:

undefined

然后包含 Slider 和 Comments 的资源会进行加载。React 将尝试从树中较早位置找到的 Suspense 开始尝试进行水合:

undefined

但假设用户开始在 Comments 组件处进行交互时:

undefined

React 将在点击事件的捕获阶段同步水合 Comments:

undefined

因此 Comments 将及时被水合以处理点击事件并响应交互。然后 React 就会继续水合侧边栏:

undefined

React 会尽早地开始对内容进行水合处理,并根据用户交互优先对屏幕上最紧急的部分进行水合。

综上所述,React 18 为 SSR 提供了两个主要功能:

  1. 可乱序的流式 HTML:可以在完全加载数据之前尽早地生成 HTML。它还允许控制生成页面部分 HTML 的优先级;
  2. 选择性水合:可让在完全加载 HTML 和 JavaScript 代码之前尽早开始水合应用程序。它还优先考虑为用户正在交互的部分补水,营造出一种即时补水的「错觉」。

参考:Upgrading to React 18 on the server

Others

New Strict Mode

React 18 在严格模式下的开发环境中引入了一个新的检查机制:每当组件第一次挂载时,将自动卸载又重新挂载每个组件,并在第二次挂载时复用先前的状态。

什么意思呢,就是说在这个变更之前,React 在挂载组件时产生的副作用有:

* React 装载组件
  * layout effect 创建
  * effect 创建

在 React 18 的严格模式下,React 在开发模式下将会模拟组件的卸载和挂载:

* React 挂载组件
  * layout effect 创建
  * effect 创建
* React 模拟卸载组件
  * layout effect 销毁
  * effect 销毁
* React 模拟挂载组件,并复用之前的状态
  * layout effect 创建
  * effect 创建

相当于为每个 Effect 额外运行一次 setup 和 cleanup 函数

React 这样做的目的是:

一、帮助开发者发现一些 effect 未设置 cleanup 函数时遗漏的错误。来看一个示例

import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
  }, [roomId]);
  return <h1>欢迎来到 {roomId} 聊天室!</h1>;
}

export default function App() {
  const [roomId, setRoomId] = useState('所有');
  const [show, setShow] = useState(false);
  return (
    <>
      <label>
        选择聊天室:
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="所有">所有</option>
          <option value="旅游">旅游</option>
          <option value="音乐">音乐</option>
        </select>
      </label>
      <button onClick={() => setShow(!show)}>
        {show ? '关闭聊天' : '打开聊天'}
      </button>
      {show && <ChatRoom roomId={roomId} />}
    </>
  );
}

在 Effect 缺少 cleanup 函数会导致打开的连接数量一直在增加,导致性能和网络问题:

useEffect(() => {
  const connection = createConnection(serverUrl, roomId);
  connection.connect();
- return () => connection.disconnect();
}, [roomId]);

在没有严格模式的情况下,很容易忽视 Effect 需要进行清理的情况。通过在开发中运行 setup → cleanup → setup,而不是仅运行 setup,更容易发现遗漏的 cleanup 逻辑。

二、在未来允许 React 在保留状态的同时添加和移除 UI

例如当一个用户标签页切出又切回来时,React 应该能够立即将之前的页面内容恢复到它先前的状态。为了实现这一点,React 将在卸载后又重新挂载组件树时,复用之前的状态。这要求组件能够灵活应对多次安装和销毁时产生的副作用(effect)。对于大多数副作用来说,不需要任何改动也依然能够生效。但是部分副作用需要保证它们只执行一次挂载或销毁。

在严格模式下额外运行一次 setup 和 cleanup 函数,有利于暴露可能的问题。

Canary Channel

2023 年 5 月 3 日 React 宣布新增了一个发布渠道,称之为 Canary

要搞清楚它有什么作用,需要理解 React 的版本策略。它包括三方面的内容:版本号规则,发布策略和发布渠道。

React 的版本策略是一路演进的。从 v15 开始应用新的版本号规则,到 v16 开始提出发布渠道的概念,再到 v18 的 Canary 渠道。

其中版本号规则大家都比较熟悉,React 遵循语义化版本(semver)原则。也就是说,若当前版本号为 x.y.z,则:

主版本也可能包含新功能,任何一个版本都可能包含问题修复。

发布策略则是指 React 在发布主要或次要版本时,实施发布计划为之配合的版本策略。这些版本代表了软件发布周期中的不同阶段,它们并非 React 特有,而是广泛应用于整个软件行业的通用策略。以 React 发布 v18 为例,它包含了以下阶段版本:

<sha> 根据发布内容的哈希值生成,<date> 根据提交日期生成,<num> 是依据数字从小到大迭代。示例:18.0.0-beta-24dd07bd2-20211208、18.0.0-rc.3

React 会为每个阶段版本发布一个 npm Tag,以方便开发者进行使用。直到今天在 npm 上,可以看到 React 的 Tag 有以下这些:

undefined

从广义的定义来说,每一个 Tag 都是 React 的一个发布渠道。但从实际应用来说,Alpha/Beta/RC 都是为 Latest 服务的。所以 React 把发布渠道分为 3 类:

那什么是 Canary 渠道呢?其实它不是一个新的发布渠道,它是 Next 渠道的升级。包含了以下这些新的内容:

  1. Canary 渠道用于社区使用 React 单个的新功能
  2. Canary 渠道如果出现任何回归问题,React 将像处理稳定版本的错误一样予以处理
  3. Canary 渠道中如果出现破坏性的变更和重大的新功能,React 会公开它们。例如将在官方博客上写一篇关于它的文章,包括必要的 codemods 和迁移说明
  4. React 鼓励框架通过锁版本的方式逐步发布基于新 React 特性的功能,并且在锁版本的情况下可以构建面向用户的应用程序

Canary 渠道的版本号规则为 x.y.z-<sha>-<date>。Canary 渠道推出后,Next 渠道仅作为该渠道的一个同步镜像。

React 最早在 Meta 内部使用 Canary 渠道,在 v18 推广到社区。旨在让单个新功能(在其可用时)可以更早地被采用,而无需等待 React 的下一个发布周期

Next.js App Router 就是通过锁定 Canary 版本来使用 React 服务端组件的。

v19: 还有哪些值得期待

React 最新的一个版本 v18.2 发布于 2022 年 6 月 15 日。一年多已经过去了,React 团队在干啥?自 v18 之后,React 团队并没有大的 Roadmap(也可能是笔者没找到?欢迎补充)。但 React 团队有一些正在探索的方向。下面是 React 团队已经公开的正在探索的一些功能,就把他们放在「值得期待」章节吧。

undefined

这些最新动态可以在 React 的官方博客以及 RFCs 中获取。

Server-side rendering

SSR 是 React 在后 18 时代最为重要的迭代方向。目前包含了两个方面的内容:React Server Component 和 React Server Action。

React Server Component(以下简称 RSC) 是 React 官方提出的一种新的组件类型,旨在提高 Web 应用的性能和加载效率。这一概念最早于 2020 年 12 月发布了实验性演示#RFC 188

来看一个示例,我们要显示一个博客列表:

undefined

App.js:

import BlogList from './BlogList';

export default function App() {
  return (
    <div>
      <h1>Blog Posts</h1>
      <BlogList /> {/* 这里使用服务器组件 */}
    </div>
  );
}

BlogList.js: 这个一个服务器组件

import { db } from 'db'; 

export default function BlogList() {
  // 在服务器组件中可以直接访问数据库
  const posts = db.getPosts();
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

服务器组件在构建时会被排除在外,运行时在服务端执行并通过下发指令的方式由客户端渲染

以上面的代码为例,初始化是通过 CSR 来渲染的:

undefined

undefined

博客列表是通过下发的指令来渲染的:

undefined

再把这个示例变复杂一些,服务器组件里面还有客户端组件。要渲染的页面如下:

undefined

BlogList.js: 这是一个服务器组件

import { db } from 'db'; 
+ import Post from './Post';

export default function BlogList() {
  const posts = db.getPosts();
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
-		   {post.title}
+		   <Post post={post} />
  		</li>
      ))}
    </ul>
  );
}

Post.js: 这是一个客户端组件

'use client';

import {format, isToday} from 'date-fns';

export default function Post({post}) {
  const updatedAt = new Date(post.updated_at);
  const lastUpdatedAt = isToday(updatedAt)
    ? format(updatedAt, 'h:mm bb')
    : format(updatedAt, 'M/d/yy');
  return (
    <div>
      <strong>{post.title}</strong>
      <br/>
      <small>{lastUpdatedAt}</small>
    </div>
  );
}

在服务器组件内使用客户端组件,需要区分「这个是客户端组件」。因此需要特殊的标识。 最早是通过 xxx.client.js 的文件命名规则来进行标识。2022 年 10 月 React 团队明确了 RSC 的模块标识语法(#RFC 227)。通过在客户端组件文件顶部添加 ‘use client’ 来标识。

此时下发的指令中会包含服务端数据和依赖脚本

undefined

undefined

服务器组件是与服务器渲染 (SSR) 是独立(但互补)的技术,使用它可以构建跨服务器和客户端的应用程序,将客户端应用程序的丰富交互性跟传统服务器渲染的改进性能相结合。虽然都是服务端运行,但 SSR 产出的是 HTML,服务器组件产出的是指令。总得来说,服务器组件是一个需要与框架、构建器结合来落地的一体化技术

React Optimizing Compiler

undefined

React 引入 Hooks 后,为了能够达到更好的 UX,需要做一些 memo。但这必让 DX 体验下降,一旦忘记处理 memo 的依赖还会引入一些未知的问题。在 2021 年的 Conf 上,黄玄提出了一个想法,用编译器分析源代码,把数据和函数都放到内置的 memoCache[] 中来自动 memo,减少多余渲染。这个编译器就叫「React Forget」(笑)。早期示例可以看视频演讲内容

undefined

React 还在开发一个用于展示编译结果的实时 playground。并宣称在已经开始在 Meta 的部分生产环境中使用编译器。他们计划在生产环境中证明它的可靠性后开源。

Offscreen Rendering

如果你想隐藏和显示一个组件,有两种选择:

离屏渲染(Offscreen)引入了第三种选择:在视觉上隐藏 UI,但将其内容优先级降低。这个想法本质上类似于 CSS 属性 content-visibility。当内容被隐藏时,浏览器将跳过元素的渲染工作(包括布局和绘制)。React 的离屏渲染不仅适用于 DOM 元素,还适用于 React 组件。React 将渲染工作推迟到应用程序空闲时,或者直到内容再次可见时。

离屏渲染适用的场景有:

  1. 路由可以在后台预渲染屏幕,以便当用户导航到它们时,可以立即使用
  2. 切换选项卡组件可以保留隐藏选项卡的状态,以便用户可以在不丢失进度的情况下在它们之间切换
  3. 虚拟化列表组件可以在可见窗口的上方和下方预渲染额外的行
  4. 当打弹出窗口时,整个应用程序可以进入“后台”模式,以便除弹窗外的所有内容都禁用事件和更新

React 的技术是开发者在不更改组件编写方式的情况下,可以将任何 React 树渲染到屏幕外。 当组件被离屏渲染时,它实际上并没有挂载,直到组件变为可见状态前它的副作用相关函数都不会被触发。例如:

离屏渲染的一个关键特性是可以切换组件的可见性,而不会失去其状态

React 原计划是于 23 年下旬的时候发布一个 RFC,同时发布一个实验性的 API 用于测试和反馈。目前还有看到有相关的内容。

Asset Loading

目前像脚本、外部样式、字体和图像等资源通常是通过外部系统预加载和加载的。这可能在新的环境(如服务器组件等)之间协调起来比较棘手。React 正在考虑添加 API,以通过适用于所有 React 环境的方式来预加载和加载外部资源。

React 还正在研究如何结合支持 Suspense,这样就可以拥有在加载完成之前阻塞显示的图像、CSS 和字体,但不会阻塞流式渲染和并发渲染。这可以帮助避免视觉上的 popcorning 现象,即视觉效果的突然出现导致的布局变化。

与 Rax 的对比

Feature

与 Rax 相比,React@18 在功能上的升级概览:

APIreactrax说明
getDerivedStateFromError🚫componentDidCatch() 的调用时机更早,允许在 render 完成之前显示兜底的 UI
<StrictMode>🚫提供了更加集中和全面的方式来检查应用中的问题
<Suspense>🚫让组件在加载异步数据的时候延迟渲染并显示兜底 UI 更容易,结合异步渲染和 SSR 有更好的用户体验
Profiler🚫帮助开发者更好地理解应用的性能情况
Event Delegation🚫事件委托机制的更改,更好进行新老版本 React 的嵌套及嵌入到其他技术构建的应用程序
New JSX Transform🚫略微改善 bundle 的大小,运行时性能更佳
Concurrent Mode🚫通过使用并发渲染功能提供更好的交互体验
SSR⭐️体积更小、速度更快、健壮性更高
Streaming🚫更快的首次加载时间和更低的服务器资源消耗
Server Component🚫-

下面从提供的 API 层面一一对比,注意:

Components

APIreactrax
<Fragment>
<Suspense>🚫
<StrictMode>🚫
<Profiler>🚫

APIs

react vs rax:

APIreactrax
createContext
forwardRef
memo
componentDidCatch
cache*🚫
lazy🚫
startTransition🚫
getDerivedStateFromError🚫

react-dom vs rax-dom:

APIreact-domrax-dom
createPortal
render
hydrate
unmount
renderToString
flushSync🚫
renderToReadableStream🚫
renderToPipeableStream🚫
renderToStaticMarkup🚫

使用 Rax 也能通过一些特殊的处理手段实现 Streaming SSR

Hooks

State Hooks:

APIreactrax
useState
useReducer
useContext

Ref Hooks:

APIreactrax
useRef
useImperativeHandle

Effect Hooks:

APIreactrax
useEffect
useLayoutEffect
useInsertionEffect🚫

Performance Hooks:

APIreactrax
useMemo
useCallback
useTransition🚫
useDeferredValue🚫

Other Hooks:

APIreactrax
use*🚫
useDebugValue🚫
useId🚫
useSyncExternalStore🚫

Rax 还内置提供了一些拓展 Hooks,这类功能通常可以在 React 社区里找到对应的实现,例如 ahooks

DevTools

APIreactrax
ESLint Plugin
Browser Extension(Components)🚫
Browser Extension(Profiler)🚫
Browser Extension(Timing)🚫
Test Renderer🚫
Test Utilities🚫

Size

参考 Bundle Phobia 上的数据。

React:

APIreactreact-domreact + react-dom
normal6.4kB130.2kB136.6kB
gzipped2.5kB42kB44.5kB

Rax:

APIraxdriver-domrax + driver-dom
normal17.6kB6.8kB24.4kB
gzipped6.5kB2.6kB9.1kB

结论:Rax 的包体积比 React 更小

两者由于 API 设计和编译能力的差异,所以除了关注框架本身的大小,还需要关注使用框架开发的项目最终打包产物的大小。项目越大越复杂,差异会越明显。感兴趣的话可以使用框架各自的 API 来实现一样功能的示例网站来进行测试。

Performance

参考社区上对框架执行的基准测试。目前已有 React@18.2Rax@0.6 的执行结果。

执行条件

执行结果

Display mode: median results, Duration measurement mode: total duration.

  1. Duration in milliseconds ± 95% confidence interval (Slowdown = Duration / Fastest) undefined

  2. Startup metrics (lighthouse with mobile simulation)

    undefined

  3. Memory allocation in MBs ± 95% confidence interval

    undefined

结论:

  1. 启动时间:纯 CSR 场景下,Rax 比 React 的 TTi 要更好。主要是因为 Rax 体积更小。
  2. 执行时间:React 比 Rax 更快
  3. 内存使用:React 比 Rax 更省

Rax@1.x 相比 Rax@0.x 在性能上又有所不同,可能正向也可能负向。感兴趣的话可以基于 JS-Framework-Benchmark 仓库贡献新的实现

Ecosystem

不言而喻,React Ecosystem in 2024 这篇文章比较宏观地概述了目前 React 的生态,其在 Routing、State Management、Styling、UI Component Library、Component Dev Env、i18n、Documentation、Mobile Apps 以及 Form/Table/Animation/Data Visualization 等富交互场景上欣欣向荣。

总结

就像卓凌在《关于框架升级的思考和讨论》一文里讲的一样,Rax 已经完成它阶段性的使命了。我写这篇文章的初衷旨在回答一个问题:过往 React 的演进过程是怎样的,今天的 React 相比 Rax 优越在哪,开发者(框架或业务)如何利用这些新的特性来提升开发体验(DX)和用户体验(UX)。

开发体验来说:StrictMode 提供了更加集中和全面的方式来检查应用中的问题;Suspense 让组件在加载异步数据的时候延迟渲染并显示兜底 UI 更容易;Profiler 让开发者更好地理解应用的性能情况;Event Delegation 使得渐进升级和异构应用更容易。

用户体验来说:开发者可以通过 Concurrent Mode 使用并发渲染功能提供更好的交互体验;SSR 迎来了全面的升级(体积更小、速度更快、健壮性更高)且添加了流式渲染的支持;Suspense 与前两者的结合进一步优化加载和交互的细节。

最后,一路追踪 React 的演进历史,我们可以看到一个顶级技术团队的克制、开放和长期主义。就拿 Concurrent Mode 来说,如果你不了解背后的历史,你会认为这是 React 的灵光乍现。如果你已经读到了这儿,相信你也会明白其背后的不易。它经过 React 团队长达 3 年的迭代,大胆假设,小心求证。

参考

React 官方文档:

React 17 相比 16 没有大的更新,所以 v16 的文档参考 v17 即可