译:编译器理论和反应性

原文:https://www.recompiled.dev/blog/ssa/
作者:Chennai
译者:ChatGPT 4 Turbo

React 编译器实现了许多传统的编译器转换,这些转换通常在没有一些编译器理论背景的情况下是无法访问的。在这篇文章中,我将尝试使用示例来提供一个更容易理解的编译器传递称为静态单赋值形式(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 在 ifelse 块中都被创建。

编译器仍然可以跟踪跨越多个块的样式创建,并像这样对其进行记忆化处理:

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} />;
}

这可以工作,但不是理想的,因为如果 hovercolourshoverColours 有任何变化,我们就会使缓存的值失效。这太粗糙了。我们能做得更好吗?

跟踪值,而非变量

我们的一个核心直觉是,我们会分别对 if 块和 else 块中的值进行记忆化。它们是独立的值(不同的对象),只是被相同的变量标识符( styles )引用。

考虑我们之前的例子,但稍作修改,通过给它们不同的标识符来分别跟踪值:

let styles;
if (!hover) {
  t0 = { colours };              // <-- separate value
} else {
  t1 = { colours: hoverColours}; // <-- separate value
}
styles = choose(t0 or t1);

现在,很明显我们可以分别对 t0t1 进行记忆化。你也意识到了我们需要在 t0t1 之间进行选择,并且正确地分配给 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 块之后修改了 styles 对象,添加了一个名为 height 的新属性。在 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 转换来自于出色的《Simple and Efficient Construction of Static Single Assignment Form》论文。