本文为翻译文章
作者:Antonello Zanini
React 的核心架构会反复调用您赋予它的函数(即您的组件)。这一事实既简化了其思维模型,使其广受欢迎,也带来了可能的性能问题。一般来说,如果您的函数执行昂贵的操作,那么您的应用程序就会很慢。
因此,性能调优成为开发人员的痛点,因为他们必须手动告诉 React 哪些函数应该重新运行以及何时重新运行。React 团队现在提供了一个名为 React Compiler 的工具,通过重写代码,让开发人员可以自动完成手动性能调优工作。
React Compiler 对你的代码做了什么?它的内部工作原理是什么?你应该使用它吗?让我们深入了解一下。
编译器、转译器和优化器
我们在现代 JavaScript 生态系统中听到过编译器、转译器和优化器等术语。它们是什么?
转译
转译器是一种程序,它可以分析您的代码并用不同的编程语言输出功能等效的代码,或者用相同的编程语言输出经过调整的代码版本。
多年来,React 开发人员一直在使用转译器将 JSX 转换为 JavaScript 引擎实际运行的代码。JSX 本质上是构建嵌套函数调用树(然后构建嵌套对象树)的简写。
编写嵌套函数调用很麻烦且容易出错,因此 JSX 使开发人员的生活更轻松,并且需要一个转译器来分析 JSX 并将其转换为那些函数调用。
例如,如果你使用 JSX 编写了以下 React 代码:
jsx
复制代码
function App() {
return <Item item={item} />;
}
function Item({ item }) {
return (
<ul>
<li>{ item.desc }</li>
</ul>
)
}
经过转换后它变成:
jsx
复制代码
function App() {
return _jsx(Item, {
item: item
});
}
function Item({ item }) {
return _jsx("ul", {
children: _jsx("li", {
children: item.desc
})
});
}
这是实际发送到浏览器的代码。不是类似 HTML 的语法,而是传递纯 JavaScript 对象的嵌套函数调用,React 将其称为“props”。
转译结果显示了为什么你不能在 JSX 中轻松使用 if 语句。你不能在函数调用中使用 if 语句。
您可以使用Babel快速生成并检查转译后的 JSX 的输出。
编译和优化
那么转译器和编译器之间有什么区别呢?这取决于你问的是谁,以及他们的教育和经验。如果你有计算机科学背景,你可能主要接触过编译器,它是一个将你编写的代码转换为机器语言(处理器实际上理解的二进制代码)的程序。
但是,“转译器”也被称为“源到源编译器”。优化器也被称为“优化编译器”。转译器和优化器都是编译器的一种!
命名是一件很难的事情,因此对于什么是转译器、编译器或优化器,人们会有分歧。重要的是要理解,转译器、编译器和优化器是一些程序,它们会获取包含代码的文本文件,对其进行分析,然后生成一个包含不同但功能相同的代码的新文本文件。它们可能会使您的代码变得更好,或者通过将代码的片段包装在对其他人代码的调用中来添加以前没有的功能。
编译器、转译器和优化器是获取包含代码的文本文件、对其进行分析并生成不同但功能等效的代码的程序。
最后一部分是 React Compiler 所做的。它创建的代码在功能上与你编写的代码相同,但将其中的一部分包装在对 React 人员编写的代码的调用中。这样,你的代码就会被重写成可以执行你预期的代码,甚至更多。我们稍后会看到“更多”到底是什么。
抽象语法树
当我们说您的代码被“分析”时,我们的意思是您的代码文本被逐个字符地解析,并且对其运行算法以找出如何调整、重写、添加功能等。解析通常会产生抽象语法树(或 AST)。
虽然这听起来很奇特,但它实际上只是一棵代表代码的数据树。因此,分析这棵树比分析你写的代码更容易。
例如,假设您的代码中有一行如下内容:
jsx
复制代码
const item = { id: 0, desc: 'Hi' };
该行代码的抽象语法树最终可能看起来像这样:
jsx
复制代码
{
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”的 和一个value带有“Hi”的 。
当您想象您的代码在转译器/编译器等中会发生什么时,您应该具有这种心理模型。人们编写了一个程序,该程序获取您的代码(文本本身),将其转换为数据结构,然后对其进行分析和处理。
最终生成的代码来自此 AST 以及可能来自其他一些中间语言。您可以想象循环遍历此数据结构并输出文本(使用相同语言或不同语言的新代码,或以某种方式对其进行调整)。
对于 React Compiler 来说,它利用 AST 和中间语言从您编写的代码生成新的 React 代码。重要的是要记住,React Compiler 就像 React 本身一样,只是_其他人的代码_。
当谈到编译器、转译器、优化器等时,不要将这些工具视为神秘的黑匣子。只要你有时间,就可以将它们视为可以构建的东西。
React 的核心架构
在我们继续讨论 React Compiler 本身之前,我们还需要明确一些概念。
还记得我们说过 React 的核心架构既是其受欢迎的源泉,也是潜在的性能问题吗?我们看到,当你编写 JSX 时,你实际上是在编写嵌套函数调用。但你把你的函数交给了 React,它会决定何时调用它们。
让我们以一个用于处理大量项目列表的 React 应用为例。假设我们的App函数获取了一些项目,然后我们的List函数处理并显示它们。
jsx
复制代码
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对象)。其中一些对象(例如ul和)li是 React 内置的。其他是我们创建的,例如List。
最终,React 会从所有这些对象构建一棵树,称为 Fiber 树。树中的每个节点称为 Fiber 或 Fiber 节点。创建我们自己的 JavaScript 对象节点树来描述 UI 的想法称为创建“虚拟 DOM”。
React 实际上保留了两个可以从树的每个节点分叉出来的分支。一个分支被称为该树分支的“当前”状态(与 DOM 匹配),另一个分支被称为该树分支的“正在进行”状态,与我们的函数重新运行时返回的内容所创建的树匹配。
然后,React 会比较这两棵树,以决定需要对实际 DOM 进行哪些更改,以便 DOM 与树的正在进行的一侧相匹配。这个过程称为“协调”。
因此,根据我们向应用添加的其他功能,React 可能会选择在List认为 UI 可能需要更新时反复调用我们的函数。这使我们的思维模型变得相当简单。每当 UI 可能需要更新时(例如,响应用户单击按钮等操作),定义 UI 的函数都会再次被调用,React 会找出如何更新浏览器中的实际 DOM,以匹配我们的函数所说的 UI 外观。
但是如果processItems函数很慢,那么每次调用List都会很慢,并且我们与它交互时整个应用程序都会很慢!
记忆化
在编程中,处理对昂贵函数的重复调用的一种解决方案是缓存函数的结果。这个过程称为记忆化。
要使记忆化发挥作用,函数必须是“纯”的。这意味着,如果您将相同的输入传递给函数,您将_始终_获得相同的输出。如果是这样,那么您可以获取输出并以与输入集相关的方式存储它。
下次调用这个昂贵的函数时,我们可以编写代码来查看输入,检查缓存以查看我们是否已经使用这些输入运行了该函数,如果已经运行,则从缓存中获取存储的输出,而不是再次调用该函数。无需再次调用该函数,因为我们知道输出将与上次使用这些输入时的输出相同。
如果processItems之前使用的函数实现了记忆化,它可能看起来像这样:
jsx
复制代码
function processItems(items) {
const memOutput = getItemsOutput(items);
if (memOutput) {
return memOutput;
} else {
// ...run expensive processing
saveItemsOutput(items, output);
return output;
}
}
我们可以想象该saveItemsOutput函数存储一个对象,该对象保存了项目和函数的相关输出。它将getItemsOutput查看是否items已存储,如果已存储,我们将返回相关的缓存输出,而无需执行任何其他工作。
对于 React 反复调用函数的架构,记忆化成为帮助防止应用程序变慢的重要技术。
挂钩存储
为了理解 React Compiler,还需要了解 React 架构的另一个部分。
如果应用程序的“状态”发生变化,即 UI 的创建所依赖的数据,React 将考虑再次调用您的函数。例如,一段数据可能是“showButton”,其值为 true 或 false,UI 应该根据该数据的值显示或隐藏按钮。
React 将状态存储在客户端设备上。如何存储?让我们以将渲染并与项目列表交互的 React 应用程序为例。假设我们最终将存储选定的项目、在客户端处理项目以进行渲染、处理事件并对列表进行排序。我们的应用程序可能看起来像下面这样。
jsx
复制代码
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 引擎执行useState和行时,这里到底发生了什么?从我们的组件创建的 Fiber 树的节点附加了一些 JavaScript 对象来存储我们的数据。这些对象中的每一个都通过一个称为链表的数据结构相互连接。useReducerList
顺便说一句,很多开发人员认为useState是 React 中状态管理的核心单元。但事实并非如此!它实际上是对 的简单调用的包装器useReducer。
因此,当您调用useState和 时useReducer,React 会将状态附加到应用程序运行时存在的 Fiber 树。因此,当我们的函数不断重新运行时,状态仍然可用。
钩子的存储方式也解释了“钩子规则”,即您不能在循环或 if 语句中调用钩子。每次调用钩子时,React 都会移动到链接列表中的下一个项目。因此,调用钩子的次数必须一致,否则 React 有时会指向链接列表中的错误项目。
归根结底,钩子只是用于在用户设备内存中保存数据(和函数)的对象。这是理解 React Compiler 真正功能的关键。但还有更多。
React 中的记忆化
React 结合了记忆化和钩子存储的思想。你可以记忆化你提供给 React 的作为 Fiber Tree 一部分的整个函数的结果(例如List),或者你在其中调用的单个函数的结果(例如processItems)。
缓存存储在哪里?在 Fiber 树上,就像状态一样!例如,useMemo钩子将输入和输出存储在调用的节点上useMemo。
因此,React 已经有了将昂贵函数的结果存储在作为 Fiber Tree 一部分的 JavaScript 对象链表中的想法。这很棒,但有一件事除外:维护。
React 中的记忆化可能很麻烦,因为你必须明确告诉 React 记忆化依赖于哪些输入。我们的调用processItems变成:
ini
复制代码
const pItems = useMemo(processItems(items), [items]);
末尾的数组是“依赖项”列表,即输入,如果发生更改,则告诉 React 应再次调用该函数。您必须确保正确获取这些输入,否则记忆将无法正常工作。这成为一项需要跟上的文书工作。
React 编译器
进入 React Compiler。该程序会分析 React 代码文本,并生成可供 JSX 转译的新代码。但新代码中添加了一些额外内容。
让我们看看在这种情况下 React Compiler 对我们的应用做了什么。在编译之前它是:
jsx
复制代码
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>
)
}
编译后变成:
jsx
复制代码
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函数来理解它。
首先是:
jsx
复制代码
const $ = _c(6);
该_c函数(将“c”理解为“cache”)创建一个使用钩子存储的数组。React Compiler 分析了我们的Link函数,并决定,为了最大限度地提高性能,我们需要存储六个东西。当我们的函数首次被调用时,它会将这六个东西的结果存储在该数组中。
这是我们函数的后续调用,其中缓存正在起作用。例如,只查看我们调用的区域processItems:
jsx
复制代码
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,包括调用函数和生成lis,都包含一个检查,以查看数组([2]使用缓存数组中的第三个位置()。它存储了映射li时生成的所有 s的列表。React Compiler 的代码表示“如果您给我的项目列表与上次调用此函数时相同,我将为您提供上次存储在缓存中的 sitems列表”。li
如果items传递的 不同,则调用processItems。即便如此,它也会使用缓存来存储_一个_列表项的样子。
jsx
复制代码
if ($[3] === Symbol.for("react.memo_cache_sentinel")) {
t3 = (item) => <li>{item}</li>;
$[3] = t3;
} else {
t3 = $[3];
}
看到这一t3 =行了吗?它不是重新创建返回 的箭头函数,而是将_函数本身_li存储在缓存数组的第四个位置()。这节省了 JavaScript 引擎在下次调用时创建该小函数的工作。由于该函数永远不会改变,因此初始 if 语句基本上是在说“如果缓存数组中的这个位置为空,则将其缓存,否则从缓存中获取”。$[3]List
通过这种方式,React 会自动缓存值并记住函数调用的结果。它输出的代码在功能上与我们编写的代码相同,但包含缓存这些值的代码,从而避免了我们的函数被 React 反复调用时的性能损失。
不过,React Compiler 的缓存比开发人员通常使用的记忆化缓存更精细,而且是自动缓存。这意味着开发人员不必手动管理依赖项或记忆化。他们只需编写代码,React Compiler 就会从中生成利用缓存使其更快的新代码。
值得注意的是,React Compiler 生成的仍然是 JSX,_真正_运行的代码是 React Compiler 转译 JSX 之后的结果。
在 JavaScript 引擎中实际运行的函数List(发送到浏览器或服务器)如下所示:
jsx
复制代码
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 Compiler 添加了用于缓存值的数组,以及执行此操作所需的所有 if 语句。JSX 转译器将 JSX 转换为嵌套函数调用。您编写的内容与 JavaScript 引擎运行的内容之间存在不小的差异。我们相信其他人的代码能够产生符合我们原始意图的东西。
用处理器周期换取设备内存
记忆化和缓存通常意味着用内存换取处理能力。这样可以节省处理器执行昂贵操作的时间,但可以通过使用空间将数据存储在内存中来避免这种情况。
如果您使用 React Compiler,则意味着您要“在设备内存中存储尽可能多的内容”。如果代码在用户设备的浏览器中运行,则需要牢记架构方面的考虑。
可能,对于许多 React 应用来说,这不会是一个真正的问题。但如果您在应用中处理大量数据,那么在 React Compiler 离开实验阶段后,您至少应该注意并密切关注设备内存使用情况。
抽象和调试
所有形式的编译都相当于您编写的代码和实际运行的代码之间的一个抽象层。
正如我们所看到的,在 React Compiler 的情况下,要了解实际发送到浏览器的内容,您需要获取代码并通过 React Compiler 运行它,然后获取_该_代码并通过 JSX 转译器运行它。
在我们的代码中添加抽象层有一个缺点。它们会使我们的代码更难调试。这并不意味着我们不应该使用它们。但你应该清楚地记住,你需要调试的代码不仅仅是你自己的,而是工具生成的代码。
真正影响你调试抽象层生成的代码的能力的是拥有一个准确的抽象思维模型。充分了解 React Compiler 的工作原理将使你能够调试它编写的代码,改善你的开发体验并降低你的开发压力。
如有侵权请联系站点删除!
Technical cooperation service hotline, welcome to inquire!