React 编译器详解

473 阅读16分钟

原文地址:tonyalicea.dev/blog/unders…

React 的核心架构会不断调用你提供的函数(即组件)。这一特点简化了 React 的思维模型,使其广受欢迎,但与此同时也可能引发性能问题——如果你的函数(组件)执行了耗时操作,应用程序的性能就会受到影响。

因此,性能调优成为开发者的一大难题,开发者必须手动告诉 React 哪些函数需要重新运行以及何时重新运行。为了解决这一问题,React 团队推出了一个工具——React 编译器,用于自动优化这些手动调优的工作。

那么,React 编译器究竟对你的代码做了什么?它是如何工作的?我们是否应该使用它?让我们一探究竟。

编译器(Compilers)、转译器(Transpiler)和优化器(Optimizers)

在现代 JavaScript 生态系统中,我们经常听到编译器、转译器和优化器这些术语。它们分别是什么呢?

转译

转译器是一种程序,用于分析你的代码,并生成功能等效的不同编程语言或经过调整的同一种编程语言的代码。

多年来,React 开发者一直使用转译器将 JSX 转换为 JavaScript 引擎实际运行的代码。JSX 本质上是构建嵌套函数调用树的简写。

直接编写嵌套函数调用很繁琐且容易出错。利用 JSX,我们可以简化这一过程,它允许我们以更直观的方式编写组件。JSX 的语法接近于 HTML,使得开发工作更加直观易懂。同时,转译器将 JSX 代码转换为相应的函数调用,这样既保持了代码的清晰性,又避免了手动编写嵌套调用的繁琐。

例如,如果你使用 JSX 编写以下 React 代码:

注意,为了便于阅读,文章中的所有代码都是故意简化的。

function App() {    
  return <Item item={item} />;
}

function Item({ item }) {    
  return (
    <ul>
      <li>{ item.desc }</li>
    </ul>
  )
}

转译后会变成:

function App() {  
  return _jsx(Item, {    
    item: item  
  });
}

function Item({ item }) {  
  return _jsx("ul", {    
    children: _jsx("li", {      
      children: item.desc    
    })  
  });
}

这就是实际发送到浏览器的代码。它不是类似 HTML 样式的语法,而是嵌套的函数调用,传递了 React 所谓的 'props'——一个普通的 JavaScript 对象。

转译的结果也说明了为什么你不能在 JSX 中轻易使用 if 语句——不能在函数调用中使用 if 语句。

你可以使用 Babel 快速生成并检查转译后的 JSX。

编译和优化

那么,编译器和转译器有什么区别?这取决于你问谁,以及他们的教育背景和经验。如果你有计算机科学相关背景,可能主要接触的是编译器——它将你编写的代码转换为处理器能理解的机器语言(即二进制代码)。

然而,“转译器”也被称为“源到源编译器(source-to-source compilers)”,而“优化器”也被称为“优化编译器(optimizing compilers)”。转译器和优化器都是编译器的一种形式!

对于这些术语的具体定义可能因人而异,因此关于转译器、编译器或优化器的确切含义可能有分歧。重要的是要理解:转译器、编译器和优化器都是程序,它们获取包含你代码的文本文件,分析它并生成新的功能等效的代码文本文件。它们可能改进你的代码,或通过将代码片段包装在其他代码调用中来增强它们之前没有的功能。

编译器、转译器和优化器是一些程序,它们获取包含你代码的文本文件,分析它并生成内容不同但功能等效的代码。

React 编译器的工作正是最后一种情况。它创建了功能等效于你编写的代码,但将其嵌入到由 React 团队编写的代码调用中。通过这种方式,你的代码被重写为实现你意图的代码,并增加了额外的功能。稍后我们将看到这些“额外的功能”具体指的是什么。

抽象语法树

当我们说你的代码被“分析”时,通常是指代码文本被逐字符解析,并通过算法确定如何调整、重写或添加功能。这个分析过程通常会生成一个抽象语法树(AST)。

AST 虽然听起来很高深,但实际上它只是一个表示你的代码结构的数据树。比起直接分析你编写的代码,分析树更容易操作和理解。

举个例子,假设你有如下一行代码:

const item = { id: 0, desc: 'Hi' };

这行代码的抽象语法树如下所示:

{    
  type: VariableDeclarator,    
  id: {        
    type: Identifier,        
    name: Item    
  },    
  init: {        
    type: ObjectExpression,        
    properties: [            
      {                
        type: ObjectProperty,                
        key: id,                
        value: 0            
      },            
      {                
        type: ObjectProperty,                
        key: desc,                
        value: 'Hi'            
      }        
    ]    
  }
}

生成的数据结构描述了你编写的代码,并将其分解为包含类型和相关值的小片段。例如 desc: 'Hi' 是一个 ObjectProperty,其 key 为 'desc',值为 'Hi'。

当你在思考代码在转译器或编译器等工具中是如何处理时,可以想象这些工具会将你的代码文本转换成一种数据结构,这个数据结构就是抽象语法树(AST)。然后,这些工具会分析和处理这个数据结构。程序员编写程序来完成这个转换过程,把代码文本变成易于处理的数据结构,并对其进行进一步的分析和处理。

最终生成的代码是基于抽象语法树(AST)以及可能的一些其他中间语言。你可以想象程序会循环遍历这个数据结构,然后输出新的代码文本,这些新的代码可能是相同编程语言的代码,或者是不同编程语言的代码,或者是以某种方式调整过的代码。

React 编译器的工作原理也是如此。它利用抽象语法树(AST)和其他中间语言来生成新的 React 代码。重要的是要记住,React 编译器和 React 本身一样,只是由其他人编写的程序

不要把编译器、转译器、优化器等工具看作是无法理解或神秘的东西。实际上,它们是通过编写代码可以实现的工具,如果你有足够的时间和知识,你也可以构建这些工具。

React 的核心架构

在深入探讨 React 编译器之前,我们需要先明确一些概念。

记得我们说过 React 的核心架构既是其受欢迎的原因之一,也是潜在的性能问题之一吗?我们看到,当你编写 JSX 时,实际上是在编写嵌套的函数调用。你将函数交给 React 后,它会决定何时调用它们(即何时更新 UI)。

让我们来看一个处理大数据量的 React 应用程序的例子。假设我们的 App 组件获取了一些数据,然后我们的 List 组件负责处理并展示这些数据。

function App() {    
  // TODO: fetch some items here    
  return <List items={items} />;
}

function List({ items }) {    
  const pItems = processItems(items);    
  const listItems = pItems.map((item) => <li>{ item }</li>);    
  return (        
    <ul>{ listItems }</ul>    
  )
}

我们的函数返回的是普通的 JavaScript 对象,例如一个 ul 对象,其中包含它的子元素(在这里是多个 li 对象)。一些对象如 ulli 是 React 内置的,而其他如 List 则是我们自己创建的。

最终,React 会利用这些对象构建一个称为 Fiber 树的结构。中的每个节点被称为 Fiber 或 Fiber 节点。创建一个 JavaScript 对象树来描述 UI 的概念被称为“虚拟 DOM”。

事实上,React 为树的每个节点保留了两个分支。一个分支称为“current”状态的分支(与实际 DOM 匹配),另一个分支称为“work-in-progress”状态的分支,它与从函数重新运行返回的树匹配。

然后,React 将比较这两棵树,以确定需要对实际 DOM 进行哪些更改,以使其与“work-in-progress”状态的分支匹配。这个过程被称为“协调”。

因此,每当 React 认为 UI 可能需要更新时,它会选择一次又一次地调用我们的 List 函数。这简化了我们的思维模型。每当需要更新 UI 时(例如,响应用户点击按钮等操作时),定义 UI 的函数将被再次调用,然后 React 来决定如何更新浏览器中的实际 DOM,以确保 UI 的显示与我们的函数定义一致。

然而,如果 processItems 函数执行缓慢,那么每次调用 List 函数都会很耗时,整个应用程序在用户交互时会感觉非常缓慢,影响用户体验!

Memoization

在编程中,处理重复调用性能开销较大的函数的解决方案是缓存函数的结果。这个过程称为记忆化(Memoization)。

为了使记忆化生效,函数必须是“纯”的。这意味着如果你向函数传递相同的输入,总是会得到相同的输出。只要满足这个条件,就可以将输出存储起来,并与输入集相关联。

下次调用性能开销较大的函数时,我们可以编写代码来检查输入,查看是否存在缓存。如果有的话,可以直接从缓存中获取存储的输出,而不必再次调用函数。因为我们知道,使用相同的输入时,输出将与上次相同。

如果前面提到的 processItems 函数实现了记忆化,代码可能如下所示:

function processItems(items) {
    const memOutput = getItemsOutput(items);
    if (memOutput) {
        return memOutput;
    } else {
        // ...行性能开销较大的处理
        saveItemsOutput(items, output);
        return output;
    }
}

我们可以想象 saveItemsOutput 函数存储了一个对象,该对象保存了输入及其相关的函数输出。getItemsOutput 函数将查看是否已经存储了 items,如果已存储,则返回相关的缓存输出,而不需要做任何额外的工作。

对于 React 架构中反复调用函数的情况,记忆化成为一种重要的技术,有助于防止应用程序变慢。

Hook 存储

要理解 React 编译器,我们还需了解 React 架构的另一个重要部分。

当应用程序的“状态”发生变化时,也就是 UI 创建所依赖的数据发生变化时,React 将重新调用你的函数。例如,某个数据,例如叫 "showButton",它的值是 true 或 false,UI 应该根据该数据的值显示或隐藏按钮。

React 将这些状态存储在客户端设备上。它是如何做到的呢?让我们以一个渲染并与列表项进行交互的 React 应用程序为例。假设我们最终会存储一个选中的 item,客户端处理 items 以进行渲染、处理事件并对列表进行排序。我们的应用程序可能开始看起来像下面这样:

function App() {
  // TODO: fetch some items here    
    return <List items={items} />;
}

function List({ items }) {
    const [selItem, setSelItem] = useState(null);
    const [itemEvent, dispatcher] = useReducer(reducer, {});
    const [sort, setSort] = useState(0);

    const pItems = processItems(items);
    const listItems = pItems.map((item) => <li>{ item }</li>);

    return (
        <ul>{ listItems }</ul>
    );
}

当 JavaScript 引擎执行 useStateuseReducer 时,实际上发生了什么?我们的 List 组件创建的 Fiber 树节点附加了更多的 JavaScript 对象,用于存储我们的数据。这些对象在一个称为链表的数据结构中相互连接。

顺便说一句,很多开发者认为 useState 是 React 中状态管理的核心单元。但事实并非如此!它实际上是对 useReducer 的一个简单包装。

因此,当你调用 useStateuseReducer 时,React 将状态附加到 Fiber 树上,即使应用程序在运行过程中多次调用这些函数,状态数据仍然可以随时访问和使用。

Hooks 的存储方式也解释了“Hooks 规则”,即不能在循环或条件语句中调用 Hook。每次调用 Hook,React 都会按顺序移动到链表中的下一个条目。因此,调用 Hook 的次数和顺序必须保持一致,否则 React 可能会指向链表中错误的条目。

最终,Hooks 实际上是设计用来在用户设备内存中保存数据(和函数)的对象。这是理解 React 编译器真正作用的关键,但这并不是全部。

React中的记忆化

React 将记忆化的概念与其钩子存储的理念结合起来。你可以对 React 组件(如 List)进行记忆化,也可以对其中调用的单个函数(如 processItems)进行记忆化。

那么缓存存储在哪里呢?就在 Fiber 树上,就像状态一样!例如,useMemo 钩子会将输入和输出存储在调用 useMemo 的节点上。

因此,React 已经具备将性能开销大的函数结果存储在 Fiber 树的一部分,即 JavaScript 对象的链表中的能力。这很棒,但有一个问题:维护。

在 React 中进行记忆化可能很麻烦,因为你必须明确告诉 React 记忆化依赖于哪些输入。我们对 processItems 的调用变成了:

const pItems = useMemo(processItems(items), [items]);

当使用 useMemo 钩子时,需要提供一个依赖项数组,这个数组包含了那些一旦发生变化,React 就会重新计算的输入。你必须仔细选择这些依赖项,否则记忆化功能可能会失效,导致 React 不会在需要的时候重新计算结果。这种手动管理依赖项的过程可能会变得繁琐且容易出错。

React 编译器

重新回到 React 编译器。它是一个分析你的 React 代码文本并生成新的代码的程序。生成的新代码已经准备好进行 JSX 转译,同时还添加了一些额外的内容。

让我们看看 React 编译器到底对我们的应用程序做了什么。编译之前,它是这样的:

function App() {
    // TODO: fetch some items here
    return <List items={items} />;
}

function List({ items }) {
    const [selItem, setSelItem] = useState(null);
    const [itemEvent, dispatcher] = useReducer(reducer, {});
    const [sort, setSort] = useState(0);

    const pItems = processItems(items);
    const listItems = pItems.map((item) => <li>{ item }</li>);
    return (
        <ul>{ listItems }</ul>
    )
}

编译之后,它变成了这样:

function App() {
  const $ = _c(1);

  let t0;

  if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
    t0 = <List items={items} />;
    $[0] = t0;
  } else {
    t0 = $[0];
  }

  return t0;
}

function List(t0) {
  const $ = _c(6);

  const { items } = t0;
  useState(null);
  let t1;

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

  useReducer(reducer, t1);
  useState(0);
  let t2;

  if ($[1] !== items) {
    const pItems = processItems(items);
    let t3;

    if ($[3] === Symbol.for("react.memo_cache_sentinel")) {
      t3 = (item) => <li>{item}</li>;

      $[3] = t3;
    } else {
      t3 = $[3];
    }

    t2 = pItems.map(t3);
    $[1] = items;
    $[2] = t2;
  } else {
    t2 = $[2];
  }

  const listItems = t2;
  let t3;

  if ($[4] !== listItems) {
    t3 = <ul>{listItems}</ul>;
    $[4] = listItems;
    $[5] = t3;
  } else {
    t3 = $[5];
  }

  return t3;
}

这变化非常大!让我们分解一下重写后的 List 函数,以便更好的理解它。

一开始是这样的:

const $ = _c(6);

这个 _c 函数(可以理解为“cache”、“缓存”的缩写)创建了一个数组,并利用 React 的 hooks 机制将这个数组存储起来。React 编译器分析了我们的 List 函数并决定,为了最大化性能,我们需要存储六个不同的值。当我们的函数第一次被调用时,它会把这六个值分别存储在那个数组中。

在我们函数的后续调用中,可以看到缓存的效果。例如,仅看我们调用 processItems 的部分:

if ($[1] !== items) {
    const pItems = processItems(items);
    let t3;

    if ($[3] === Symbol.for("react.memo_cache_sentinel")) {
        t3 = (item) => <li>{item}</li>;
        $[3] = t3;
    } else {
        t3 = $[3];
    }

    t2 = pItems.map(t3);
    $[1] = items;
    $[2] = t2;
} else {
    t2 = $[2];
}

整个与 processItems 相关的工作,包括调用函数和生成 li 元素,都被封装在一个检查中,以查看缓存数组的第二个位置($[1])中的缓存是否与上次调用该函数时的输入(传递给 Listitems 值)相同。

如果它们相等,那么缓存数组的第三个位置($[2])就会被使用。该位置存储了根据 items 映射生成的所有 li 元素的列表。React 编译器的代码表达了这样的意思:“如果你给我与上次调用这个函数时相同的 items 列表,我会给你上次缓存的 li 列表”。

如果传递的 items 不同,那么它将调用 processItems。即便如此,它仍会使用缓存来存储单个列表项的外观。

if ($[3] === Symbol.for("react.memo_cache_sentinel")) {
    t3 = (item) => <li>{item}</li>;
    $[3] = t3;
} else {
    t3 = $[3];
}

看到 t3 = 那行了吗?与其重新创建返回 li 的箭头函数,它将这个 函数本身 存储在缓存数组的第四个位置($[3])。这样可以节省 JavaScript 引擎在下次调用 List 时创建这个小函数的工作。由于该函数从未改变,初始的 if 语句基本上是在说“如果缓存数组中的这个位置是空的,就缓存它,否则就从缓存中获取”。

通过这种方式,React 自动缓存值并记忆化函数调用的结果。它输出的代码在功能上与我们编写的代码相同,但包括了缓存这些值的代码,从而在我们的函数被 React 多次调用时节省性能开销。

React 编译器比开发人员通常使用记忆化时缓存得更细致,并且是自动进行的。这意味着开发人员不必手动管理依赖项或记忆化。他们只需要编写代码,React 编译器会从中生成使用缓存的新代码,使其更快。

值得注意的是,React 编译器仍然生成 JSX。实际运行的代码是 React 编译器在 JSX 转译之后的结果。

JavaScript 引擎中实际运行的 List 函数如下:

function List(t0) {
  const $ = _c(6);
  const {
    items
  } = t0;
  useState(null);
  let t1;
  if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
    t1 = {};
    $[0] = t1;
  } else {
    t1 = $[0];
  }
  useReducer(reducer, t1);
  useState(0);
  let t2;
  if ($[1] !== items) {
    const pItems = processItems(items);
    let t3;
    if ($[3] === Symbol.for("react.memo_cache_sentinel")) {
      t3 = item => _jsx("li", {
        children: item
      });
      $[3] = t3;
    } else {
      t3 = $[3];
    }
    t2 = pItems.map(t3);
    $[1] = items;
    $[2] = t2;
  } else {
    t2 = $[2];
  }
  const listItems = t2;
  let t3;
  if ($[4] !== listItems) {
    t3 = _jsx("ul", {
      children: listItems
    });
    $[4] = listItems;
    $[5] = t3;
  } else {
    t3 = $[5];
  }
  return t3;
}

React 编译器添加了一个用于缓存值的数组,并且引入了所有必要的 if 语句来进行缓存操作。JSX 转译器将 JSX 代码转换为嵌套的函数调用形式的 JavaScript 代码。这个转换使得我们在 JSX 中编写的代码与实际在 JavaScript 引擎中执行的代码之间存在显著的差异。我们依赖于 JSX 转译器和其他工具来确保生成的 JavaScript 代码能够准确地表达我们最初的意图。因此,我们必须信任这些工具能够按照我们的预期转换代码,使其在运行时行为符合我们的原始设计。

以设备内存换取处理器周期

记忆化和一般的缓存意味着将处理器的计算时间换取为内存的消耗。通过存储计算结果,可以避免处理器执行昂贵的操作,但这会占用设备内存空间。

如果你使用 React 编译器,这意味着你在尝试尽可能多地存储数据在设备的内存中。如果代码在用户设备的浏览器中运行,这是一个需要考虑的架构问题。

对于许多 React 应用程序来说,这可能不会成为真正的问题。但如果你的应用程序处理大量数据,那么设备内存的使用就是你至少要注意的事项,特别是当 React 编译器离开实验阶段并正式启用之后。

抽象和调试

编译意味着在你编写的源代码和实际执行的目标代码之间添加了一层抽象。

就像我们在使用 React 编译器时看到的那样,要理解最终发送到浏览器的内容,你需要先将你的源代码通过 React 编译器处理,然后再通过 JSX 转译器将其转换成最终的代码。

向我们的代码添加抽象层会带来一个缺点,即可能增加代码调试的复杂性。但这并不意味着我们不应该使用这些工具。相反,我们应该清楚地了解,我们需要调试的不仅仅是自己编写的代码,还有工具生成的代码。

要提高从抽象层生成的代码进行调试的能力,关键在于深入理解这些抽象层的工作原理。充分理解 React 编译器的运作方式将使你能够更有效地调试生成的代码,从而提升开发体验,减少开发过程中遇到的问题。

总结

React 编译器是一个强大的工具,可以帮助你自动优化代码,提高应用程序的性能。通过理解其工作原理和核心概念,你可以更好地利用它来构建高性能的 React 应用程序。希望通过这篇文章,你对 React 编译器有了更深入的了解,并能在实际项目中应用这些知识。