如何使用 React Compiler - 完整指南

原文信息: 查看原文查看原文

How to Use React Compiler – A Complete Guide

- Tapas Adhikary

在本教程中,你将学习 React Compiler如何帮助你编写更优化的 React 应用程序。

React 是一个用户界面库,在过去十多年里一直很好地履行其职责。组件架构、单向数据流和声明性质在帮助开发者构建生产就绪、可扩展的软件应用程序方面脱颖而出。

在发布(甚至直到最新的稳定版本 v18.x)中,React 提供了各种技术和方法来提高应用程序性能。

例如,通过使用 React.memo() 高阶组件,或者像 useMemo()useCallback() 这样的钩子,整个记忆化范式得到了支持。

在编程中,记忆化 是一种优化技术,它通过缓存昂贵计算的结果使程序执行得更快。

尽管 React 的 记忆化 技术对于应用优化非常有用,正如本叔叔(记得吗,蜘蛛侠的叔叔?)曾经说过的,“能力越大,责任越大”。所以我们作为开发者在应用它们时需要更加负责。优化很好,但过度优化可能会扼杀应用程序的性能。

随着 React 19 的发布,开发者社区收到了一系列增强功能和特性:

  • 一个实验性的开源编译器。我们将主要关注本文中的它。

  • React 服务器组件。

  • 服务器操作。

  • 处理文档元数据的更简单、更自然的方式。

  • 增强的钩子和 API。

  • 可以将 ref 作为属性传递。

  • 样式、图像和字体的资产加载改进。

  • 与 Web 组件的更平滑集成。

如果这些让你感到兴奋,我建议你观看这个视频,它解释了每个特性将如何影响你作为 React 开发者。我希望你喜欢它😊。

React 19 引入的 编译器 将是改变游戏规则的。从现在开始,我们可以让编译器处理优化的头痛问题,而不是让我们自己承担。

这是否意味着我们不再需要使用 memouseMemo()useCallback 等了?不 - 我们大多数情况下不需要。如果你理解和遵循 React 组件和钩子的规则,编译器可以自动处理这些事情。

它将如何做到这一点?好吧,我们会讲到的。但在此之前,让我们先了解什么是 编译器,以及将这个新的 React 代码优化器称为 React Compiler 是否合理。

传统上什么是编译器?

简单来说,编译器是一个软件程序/工具,它将高级编程语言代码(源代码)翻译成机器代码。编译源代码并生成机器代码需要遵循几个步骤:

  • 词法分析器 将源代码标记化并生成标记。

  • 语法分析器 创建一个抽象语法树 (AST) 以逻辑结构化源代码标记。

  • 语义分析器 验证代码的语义(或句法)正确性。

  • 经过上述三种分析器的分析后,会生成一些 中间代码。它也被称为 IR 代码。

  • 然后对 IR 代码进行 优化

  • 最后,编译器从优化后的 IR 代码生成 机器代码

如上所述的编译器阶段

现在你了解了编译器的基本原理,让我们学习 React Compiler 并了解它的工作原理。

React Compiler架构

React Compiler是一个构建时工具,你需要使用 React 工具生态系统提供的配置选项明确配置它与你的 React 19 项目。

例如,如果你使用 Vite 创建你的 React 应用程序,编译器配置将在 vite.config.js 文件中进行。

React Compiler有三个主要组件:

  1. Babel 插件 在编译过程中帮助转换代码**。

  2. ESLint 插件 帮助捕获和报告任何违反 React 规则的行为。

  3. 编译器核心:执行代码分析和优化的核心编译器逻辑。Babel 和 ESLint 插件都使用核心编译器逻辑。

编译流程如下:

  • Babel 插件 确定要编译哪些函数(组件或钩子)。我们稍后将看到一些配置,以了解如何选择加入和退出编译过程。插件为每个函数调用核心编译器逻辑,并最终创建抽象语法树。

  • 然后编译器核心将 Babel AST 转换为 IR 代码,对其进行分析,并运行各种验证以确保没有违反任何规则。

  • 接下来,它尝试通过执行各种传递来减少要优化的代码量,以消除死代码。使用记忆化进一步优化代码。

  • 最后,在代码生成阶段,转换后的 AST 被转换回优化后的 JavaScript 代码。

React Compiler在行动

现在你已经知道 React Compiler的工作原理,让我们现在深入配置它与 React 19 项目,以便你可以开始了解各种优化。

理解问题:没有 React Compiler

让我们用 React 创建一个简单的产品页面。产品页面显示带有页面上产品数量的标题、产品列表和特色产品。

产品页面

组件层次结构和组件之间的数据传递如下所示:

产品页面组件层次结构

正如你在上面的图片中看到的,

  • ProductPage 组件有三个子组件,HeadingProductListFeaturedProducts

  • ProductPage 组件接收两个属性,productsheading

  • ProductPage 组件计算产品总数,并将该值连同标题文本值一起传递给 Heading 组件。

  • ProductPage 组件将 products 属性传递给子组件 ProductList

  • 同样,它计算特色产品并将 featuredProducts 属性传递给子组件 FeaturedProducts

以下是 ProductPage 组件的源代码可能的样子:

import React from 'react'

import Heading from './Heading';
import FeaturedProducts from './FeaturedProducts';
import ProductList from './ProductList';

const ProductPage = ({products, heading}) => {
  const featuredProducts = products.filter(product => product.featured);
  const totalProducts = products.length;

  return (
    <div className="m-2">
      <Heading
        heading={heading}
        totalProducts={totalProducts} />

      <ProductList
        products={products} />

      <FeaturedProducts
        featuredProducts={featuredProducts} />  

    </div>
  )
}

export default ProductPage

同时,假设我们在 App.js 文件中像这样使用 ProductPage 组件:

import ProductPage from "./components/compiler/ProductPage";

function App() {

  // 食品产品列表    
  const foodProducts = [
    {
      "id": "001",
      "name": "Hamburger",
      "image": "🍔",
      "featured": true
    },
    {
      "id": "002",
      "name": "French Fries",
      "image": "🍟",
      "featured": false
    },
    {
      "id": "003",
      "name": "Taco",
      "image": "🌮",
      "featured": false
    },
    {
      "id": "004",
      "name": "Hot Dog",
      "image": "🌭",
      "featured": true
    }
  ];

  return (
      <ProductPage 
            products={foodProducts} 
            heading="The Food Product" />
  );
}

export default App;

这都很好 - 那么问题在哪里呢?问题是 React 在父组件重新渲染时会主动重新渲染子组件。不必要的渲染需要优化。让我们先充分理解问题。

我们将在每个子组件中添加当前时间戳。现在渲染的用户界面将如下所示:

带时间戳

你看到的大数字旁边的标题是时间戳(使用 JavaScript Date API 中的简单 Date.now() 函数)我们已经添加到组件代码中。现在,如果我们改变 ProductPage 组件的 heading 属性的值会怎样?

之前:

<ProductPage 
   products={foodProducts} 
   heading="The Food Product" />

之后(注意我们已经通过在 heading 值的末尾添加一个 s 使其复数):

<ProductPage 
   products={foodProducts} 
   heading="The Food Products" />

现在你会注意到用户界面立即发生了变化。所有三个时间戳都更新了。这是因为当父组件由于属性更改而重新渲染时,所有三个组件都被重新渲染了。

编译器差异

如果你注意到,heading 属性只传递给了 Heading 组件,即使这样,其他两个子组件也被重新渲染了。这就是我们需要优化的地方。

解决问题:没有 React Compiler

如前所述,React 为我们提供了各种钩子和 API 进行 记忆化。我们可以使用 React.memo()useMemo() 来保护不必要重新渲染的组件。

例如,我们可以使用 React.memo() 来记忆化 ProductList 组件,以确保除非 products 属性发生变化,否则 ProductList 组件不会被重新渲染。

我们可以使用 useMemo() 钩子来记忆化特色产品的计算。两种实现在下面的图片中指出。

应用记忆化

但是,再次回忆一下伟大的本叔叔的明智话语,在过去的几年里,我们开始过度使用这些优化技术。这些过度优化可能会对应用程序的性能产生负面影响。因此,编译器的可用性对 React 开发者来说是一个福音,因为它让他们将许多这样的优化委托给编译器。

现在让我们使用 React Compiler来解决问题。

解决问题:使用 React Compiler

再次,React Compiler是一个可选择的构建时工具。它不包含在 React 19 RC 中。你需要安装所需的依赖项,并使用 React 19 项目配置编译器。

在配置编译器之前,你可以通过在项目目录中执行此命令来检查你的代码库是否兼容:

npx react-compiler-healthcheck@experimental

它将检查并报告:

  • 编译器可以优化多少组件

  • 是否遵循了 React 规则。

  • 是否有任何不兼容的库。

d7866215-5cda-4a64-b0d6-ecedb100a428

如果你发现事情是兼容的,是时候安装由 React Compiler支持的 ESLint 插件了。这个插件将帮助你捕获代码中任何违反 React 规则的行为。违反代码将被 React Compiler跳过,不会对其执行任何优化。

npm install eslint-plugin-react-compiler@experimental

然后打开 ESLint 配置文件(例如,对于 Vite,是 .eslintrc.cjs)并添加这些配置:

module.exports = {
  plugins: [
    'eslint-plugin-react-compiler',
  ],
  rules: {
    'react-compiler/react-compiler': "error",
  },
}

接下来,你将使用 React Compiler的 Babel 插件来为你的整个项目启用编译器。如果你正在使用 React 19 开始一个新项目,我建议你为整个项目启用 React Compiler。让我们安装 React Compiler的 Babel 插件:

npm install babel-plugin-react-compiler@experimental

安装完成后,你需要通过在 Babel 配置文件中添加选项来完成配置。由于我们使用的是 Vite,打开 vite.config.js 文件并将内容替换为以下代码片段:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

const ReactCompilerConfig = {/* ... */ };

// https://vitejs.dev/config/ 
export default defineConfig({
  plugins: [react({
    babel: {
      plugins: [
        [
          "babel-plugin-react-compiler",
           ReactCompilerConfig
          ]
        ],
    },
  })],
})

在这里,你已经将 babel-plugin-react-compiler 添加到了配置中。ReactCompilerConfig 需要提供任何高级配置,例如如果你想提供任何自定义运行时模块或其他配置。在这种情况下,它是一个没有任何高级配置的空对象。

就这样。你已经完成了 React Compiler与你的代码库的配置,以利用它的力量。从现在开始,React Compiler将查看你项目中的每个组件和钩子,并尝试对其进行优化。

如果你想将 React Compiler与 Next.js、Remix、Webpack 等配置,你可以遵循这个指南

使用 React Compiler优化的 React 应用程序

现在你应该有一个包含 React Compiler的优化 React 应用程序。所以,让我们再次运行你之前做过的测试。再次改变 ProductPage 组件的 heading 属性的值。

这次,你将不会看到子组件重新渲染。所以时间戳也不会更新。但你将看到组件中数据发生变化的部分,因为它将单独反映变化。而且,你再也不必在代码中使用 memouseMemo()useCallback() 了。

你可以在这里直观地看到它的工作。

React DevTools 中的 React Compiler

React DevTools 5.0+ 版本内置了对 React Compiler的支持。你将在 React Compiler优化的组件旁边看到一个带有文本 Memo ✨ 的徽章。这太棒了!

React DevTools

深入了解 - React Compiler是如何工作的?

现在你已经看到了 React Compiler在 React 19 代码上的工作方式,让我们深入理解后台发生了什么。我们将使用 React 编译器游乐场 来探索翻译后的代码和优化步骤。

React Compiler游乐场

我们将使用 Heading 组件作为示例。将以下代码复制并粘贴到游乐场最左侧的部分:

const Heading = ({ heading, totalProducts }) => {
  return (
    <nav>
      <h1 className="text-2xl">
          {heading}({totalProducts}) - {Date.now()}
      </h1>
    </nav>
  )
}

你会看到一些 JavaScript 代码立即在游乐场的 _JS 标签内生成。React Compiler作为编译过程的一部分生成了这个 JavaScript 代码。让我们一步一步地进行:

function anonymous_0(t0) {
  const $ = _c(4);
  const { heading, totalProducts } = t0;
  let t1;
  if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
    t1 = Date.now();
    $[0] = t1;
  } else {
    t1 = $[0];
  }
  let t2;
  if ($[1] !== heading || $[2] !== totalProducts) {
    t2 = (
      <nav>
        <h1 className="text-2xl">
          {heading}({totalProducts}) - {t1}
        </h1>
      </nav>
    );
    $[1] = heading;
    $[2] = totalProducts;
    $[3] = t2;
  } else {
    t2 = $[3];
  }
  return t2;
}

编译器使用一个名为 _c() 的钩子来创建一个要缓存的项目数组。在上面的代码中,创建了一个包含四个元素的数组来缓存四个项目。

const $ = _c(4);

但是,要缓存的是什么呢?

  • 组件接收两个属性,headingtotalProducts。编译器需要缓存它们。因此,它需要缓存项目数组中的两个元素。

  • 标题中的 Date.now() 部分应该被缓存。

  • JSX 本身应该被缓存。除非上述任何一个变化,否则没有必要计算 JSX。

所以总共有四个项目要缓存。

编译器使用 if-block 创建记忆化块。编译器的最终返回值是 JSX,它依赖于三个依赖项:

  • Date.now() 值。

  • 两个属性,一个 headingtotalProducts

当上述任何一个变化时,输出 JSX 需要重新计算。这意味着编译器需要为上述每一个创建两个记忆化块。

第一个记忆化块看起来像这样:

if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
    t1 = Date.now();
    $[0] = t1;
} else {
    t1 = $[0];
}

if-block 将 Date.now() 的值存储在可缓存数组的第一个索引中。它每次都重用相同的,除非它改变了。

同样,在第二个记忆化块中:

if ($[1] !== heading || $[2] !== totalProducts) {
  t2 = (
    <nav>
      <h1 className="text-2xl">
        {heading}({totalProducts}) - {t1}
      </h1>
    </nav>
  );
  $[1] = heading;
  $[2] = totalProducts;
  $[3] = t2;
} else {
  t2 = $[3];
}

在这里,检查是 headingtotalProducts 属性的值是否发生变化。如果这些中的任何一个发生变化,就需要重新计算 JSX。然后所有值都存储在可缓存的数组中。如果没有值的变化,就从缓存中返回之前计算的 JSX。

你现在可以将任何其他组件源代码粘贴到左侧,并查看生成的 JavaScript 代码,以帮助您理解编译器在编译过程中执行的记忆化技术,就像我们上面所做的那样。这将帮助你更好地掌握编译器如何执行记忆化技术。

你如何选择加入和退出 React Compiler?

一旦你像我们在 Vite 项目中所做的那样配置了 React Compiler,它就会为我们的项目中的所有编译器和钩子启用。

但在某些情况下,你可能想要选择性地加入 React Compiler。在这种情况下,你可以使用 compilationMode: "annotation" 选项运行编译器的 “选择加入” 模式。

// 在 ReactCompilerConfig 中指定选项
const ReactCompilerConfig = {
  compilationMode: "annotation",
};

然后使用 "use memo" 指令注释你想要选择加入编译的组件和钩子。

// src/ProductPage.jsx
export default function ProductPage() {
  "use memo";
  // ...
}

请注意,还有一个 "use no memo" 指令。可能有一些罕见的情况,你的组件在编译后可能不会按预期工作,你想暂时退出编译,直到问题被识别和修复。在这种情况下,你可以使用这个指令:

function AComponent() {
  "use no memo";
  // ...
}

我们可以使用 React 18.x 中的 React Compiler吗?

建议与 React 19 一起使用 React Compiler,因为需要兼容。如果你不能将你的应用程序升级到 React 19,你将需要有一个自定义的缓存函数实现。你可以查看这个线程 描述的变通方法。

需要查看的仓库

  • 本文中使用的所有源代码都在这个仓库中。

  • 如果你想开始使用 React 19 及其特性进行编码,这里是模板仓库 配置了 React 19 RC、Vite 和 TailwindCSS。你可能想尝试一下。

分享于 2024-09-08

访问量 99

预览图片