React编译器实现了许多传统编译器转换,通常需要一些编译器理论背景才能理解。在本文中,我将尝试用例子更容易理解的方式解释编译器传递的一个称为"静态单赋值形式"(Static Single Assignment form,简称SSA)的过程。
如果你想知道React编译器是什么,我建议阅读我们最近的更新文章了解一些背景信息。
考虑这个简单的React组件:
function Component({ colours }) {
let styles = { colours };
return <Item styles={styles} />;
}
我们可以很容易地对其进行记忆化处理:
function Component(props) {
const $ = useMemoCache(2);
const { colours } = props;
let t0;
if ($[0] !== colours) {
t0 = { colours };
$[0] = colours;
$[1] = t0;
} else {
t0 = $[1];
}
const styles = t0;
return <Item styles={styles} />;
}
编译器可以追踪创建并传递为props的styles
对象。
不要太担心
useMemoCache
钩子,它是编译器用于缓存值的内部API。将$
视为一个数组。React编译器也可以对JSX进行记忆化处理,但为了简洁起见,我在本文中省略了这部分内容。
现在,假设你想根据条件重构样式:
function Component({ colours, hover, hoverColours }) {
let styles;
if (!hover) {
styles = { colours };
} else {
styles = { colours: hoverColours };
}
return <Item styles={styles} />;
}
对于编译器来说,记忆化styles
对象变得更具挑战性,因为它不再是一个单一语句。它跨越了多个语句,并涉及到控制流 - styles
在if
和else
块中都被创建。
编译器仍然可以跟踪跨两个块的样式创建,并像这样进行记忆化处理:
function Component(props) {
const $ = useMemoCache(4);
const { hover, colours, hoverColours } = props;
let styles;
if ($[0] !== hover || $[1] !== colours || $[2] !== hoverColours) {
if (!hover) {
styles = { colours };
} else {
styles = { colours: hoverColours };
}
$[0] = hover;
$[1] = colours;
$[2] = hoverColours;
$[3] = styles;
} else {
styles = $[3];
}
return <Item styles={styles} />;
}
这能够工作,但不是理想的,因为如果hover
、colours
或hoverColours
中的任何一个发生变化,就会使记忆化值无效。这太“粗粒度”了。我们能不能做得更好呢?
跟踪值,而不是变量
一个核心的直觉是,我们将在if
块中单独记忆化值,与else
块中的值分开。它们是独立的“值”(独立的“对象”),只是被相同的变量标识符(styles
)引用。
考虑稍微修改过的先前的例子,通过给它们分配不同的标识符来单独跟踪值:
let styles;
if (!hover) {
t0 = { colours }; // <-- separate value
} else {
t1 = { colours: hoverColours}; // <-- separate value
}
styles = choose(t0 or t1);
现在,很明显我们可以单独记忆化t0
和t1
。你也意识到我们需要在t0
和t1
之间进行选择并将其正确分配给styles
,但现在我们先忽略这一点。
编译器可以在它们各自的块中记忆化这些值:
if (!hover) {
if ($[0] !== colours) {
t0 = {
colours,
};
$[0] = colours;
$[1] = t0;
} else {
t0 = $[1];
}
} else {
if ($[2] !== hoverColours) {
t1 = {
colours: hoverColours,
};
$[2] = hoverColours;
$[3] = t1;
} else {
t1 = $[3];
}
}
styles = choose(t0 or t1)
这比先前的例子更加“细粒度”。
复杂性在哪里?
但是,等等,我们只是在记忆化在其创建范围内的值,有什么难的?
嗯,让我们考虑另一个例子:
function Component({ colours, hover, hoverColours }) {
let styles;
if (!hover) {
styles = { colours };
} else {
styles = { colours: hoverColours };
}
styles.height = "large"; // <-- modifying styles object
return <Item styles={styles} />;
}
在上面的例子中,我们在if-else
块之后通过添加一个名为height
的新属性修改了styles
对象。在这种情况下,不能安全地在if
块和else
块内部分别记忆化值了。
我们不能在记忆化之后修改一个值。这不是因为它在性能上不够优化,而是因为在重新渲染期间会导致不正确的行为。花一分钟思考一下这种行为在实践中可能如何显现。
我们需要一种方式来跟踪值在流动中的方式,而不仅仅是在创建它们的范围内简单地进行记忆化。
有人可能会争辩说,你不应该编写这样的代码。但是,本地突变在JavaScript中是非常自然的,而且有很多React代码是这样编写的,我们需要高效地进行编译。
跟踪流动
还记得我们之前忽略的“choose
”函数吗?这让编译器在if-else块之间跟踪值的流动!
if (!hover) {
t0 = { colours };
} else {
t1 = { colours: hoverColours};
}
styles = choose(t0 or t1); // <-- tracks values after control flow
styles.height = 'large';
现在,代码(或者更准确地说,编译器的中间表示)告诉编译器styles
要么是t0
,要么是t1
,并且修改styles
等同于修改值t0
和t1
。
编译器现在可以推断styles
只能在这个更粗粒度的水平上进行记忆化处理,就像这样:
if ($[0] !== hover || $[1] !== colours || $[2] !== hoverColours) {
if (!hover) {
styles = {
colours,
};
} else {
styles = {
colours: hoverColours,
};
}
styles.height = "large";
$[0] = hover;
$[1] = colours;
$[2] = hoverColours;
$[3] = styles;
} else {
styles = $[3];
}
编译器理论
总结一下,我们讨论了使用临时标识符单独跟踪值以及使用“choose”函数在控制流中跟踪值的方法。
有趣的是,一个叫做静态单赋值形式(SSA)的经典编译器转换恰好做到了这一点!通过创建新的临时值跟踪新值和重新分配是SSA变换的核心部分。我们先前讨论的“choose”函数只是在SSA形式中定义的“phi”(Φ)函数。
React编译器使用的确切SSA变换来自出色的简单且高效的静态单赋值形式构建论文。
如果你对React编译器中的编译器理论有更多兴趣,可以看看其他标记的文章。