查看原文
其他

【第3223期】React 编译器的类型系统

飘飘 前端早读课 2024-03-27

前言

介绍了 React 编译器的类型系统实现方式,包括如何通过类型推断来优化性能和减少内存占用。React 编译器采用简化版的 Hindley Milner 类型系统,通过生成和解决类型方程来推断变量类型。类型推断还能帮助检测 React 代码中的错误用法,提高代码质量和可维护性。今日前端早读课文章由 @飘飘翻译分享。

正文从这开始~~

如果你对 React 编译器感到好奇,想要了解它的工作原理,我推荐你先阅读我们最近的更新文章,那里有一些基础的背景信息。本文将深入探讨编译器的理论部分,但请放心,你不必强迫自己完全理解文章中的每一个细节,也能正常使用编译器。

关于 Memo props

在 React 框架中,当我们使用 React.memo 高阶组件包裹一个组件时,这个组件只有在其属性(props)发生变化时才会触发重新渲染。

const Greeting = memo(function Greeting({ user }) {
return (
<h1>
Hello, {user.firstName} {user.lastName}!
</h1>
);
});

Greeting 组件会在其接收的属性 user 发生变化时重新渲染。React 通过浅层比较来判断属性是否发生了变化。

在 JavaScript 中,对象需要保持它们的身份,以便浅层比较能够正确进行,这就是记忆化在某些情况下显得尤为重要的原因。而对于原始数据类型(如数字、字符串、布尔值等),它们没有身份的概念,可以直接进行值比较。

Object.is({}, {}); // false
Object.is(3, 3); // true

设想一个简单的组件,它根据传入的属性(props)来计算出一个总和:

function Price({ items, state }) {
const subTotal = calculateSubTotal(items);
const tax = calculateTax(subTotal, state);
const total = subTotal + tax;
return <Text text={total} />;
}

为了避免 Text 组件进行不必要的重新渲染,一种简单的方法是对所有内容进行记忆化处理:

function Price({ items, state }) {
const subTotal = useMemo(() => calculateSubTotal(items), [items]);
const tax = useMemo(() => calculateTax(subTotal, state), [subTotal, state]);
const total = useMemo(() => subTotal + tax, [subTotal, tax]);
return <Text text={total} />;
}

实际上,在进行浅层比较时,我们并不需要对基本数据类型进行记忆化处理。因为从内存占用和打包体积的角度来看,这样做是不必要的。

我们能否让 React 编译器识别出这些值是基本类型呢?理论上,React 编译器可以通过编译整个项目的所有文件 —— 包括那些包含 calculateSubTotal 和 calculateTax 函数的文件 —— 来分析并确认这些函数返回的是数值。但是,只对单个文件进行分析也有其显著优势,比如提升性能、支持渐进式更新以及降低编译器的复杂度。

那么,编译器是否能够根据这些值的使用情况推断出它们是数值类型呢?

类型推导

Hindley-Milner 类型系统是最为经典的类型系统之一,它广泛应用于函数式编程语言。React 编译器的类型推导机制借鉴了该类型系统中的 Algorithm W,但由于 JavaScript 的动态特性使得它并不适合严格的类型检查,因此 React 编译器的类型推导更为简化。下面我将简要介绍 React 编译器所采用的各个步骤。

设定类型变量

在 JavaScript 代码被初步转换为编译器的中间表示形式时,每个标识符都会被赋予一个 Type 变量,用于存储其类型信息。Type 变量与其他变量类似,但它存储的是类型而非具体的值。

type Type = { kind: "type"; id: number } | { kind: "Primitive" };
// { kind: "Type", id: number } represents a type variable
// { kind: "Primitive" } represents a primitive type

let total; // identifier: { name: 'total', type: { kind: "Type", id: 0 } }

{ kind: "Type", id: 0 } 代表了与变量 total 相关联的类型变量。

构建类型方程

为了避免使用复杂且难以接近的形式化表述,我将尝试通过之前提到的例子来阐释一个类型推导的规则。

const total = subTotal + tax;

上述语句的类型化可以描述为:在一个使用算术运算符的 BinaryExpression 中,操作数 subTotal 和 tax 都是基本数据类型,并且它们的运算结果 total 同样是一个基本数据类型。

const total = subTotal + tax;
// subTotal -> primitive
// tax -> primitive
// total -> primitive

在 JavaScript 中,虽然可以使用非基本类型作为 BinaryExpression 的操作数,但在实际应用中,这通常被认为是一个安全的假设。

类型推断阶段的首个步骤是基于编译器设定的类型规则来构建类型方程。类型方程本质上是声明两个类型相等的语句,类似于数学中的等式。一个简单的类型方程可能表现为 "left = right" 的格式,这里的 left 和 right 分别代表两种类型。

在实际编码中,这种方程可能被简化为一个对象,该对象包含两个属性,分别对应方程的左侧和右侧,例如:

type TypeEquation = {
left: Type;
right: Type;
};

具体而言,我们之前提到的类型规则可以通过以下方式构建:

function* generateTypeEquationsForBinaryExpression(instruction) {
const { operands, lvalue } = instruction;

yield { left: operands[0].type, right: { kind: "Primitive" } };
// subTotal -> primitive

yield { left: operands[1].type, right: { kind: "Primitive" } };
// tax -> primitive

yield { left: { lvalue.type }, right: { kind: "Primitive" } };
// total -> primitive
}

类似地,我们可以为 JavaScript 中的其他编程结构,例如函数调用和 if 语句,构建类型方程。

解方程

类型方程的求解过程被称作类型统一。这一过程的目标是寻找一组类型变量的替换方案,使得所有的类型方程都能成立。

在我们的例子中,解决类型方程是相对直接的。subTotal、tax 和 total 的类型变量可以直接替换为它们对应的基本数据类型。

但是,回想一下之前定义并赋值给 subTotal 的那个语句:

const subTotal = calculateSubTotal(items);

在声明 subTotal 时,我们并不清楚它的确切类型。只有在分析了 subTotal 的使用情况之后,我们才推断出它应该是一个基本数据类型。

然而,在类型推断的过程中,类型信息不仅向前传递,还会逆向反馈到变量的定义。我们回顾 calculateSubTotal 函数的定义,并意识到其返回类型应与 subTotal 相同。通过这一过程,我们得出结论:calculateSubTotal 函数的返回类型应该是一个基本类型。

这个例子展示了类型推断的强大能力:我们甚至能够推断出独立编译单元中函数的返回类型,而无需查看其具体实现。类型系统通常利用这种推断能力,快速启动并在未类型化的代码库中进行推断扩展。

但这种推断也存在一个明显的缺陷 —— 如果推断出现错误,可能会导致出乎意料的 “远距离效应” 行为。正因如此,Flow 选择了转向局部类型推断,以更明确的类型注解来换取更准确的错误信息。

虽然我们可以让编译器利用 TypeScript 或 Flow 提供的类型信息,但我们更希望确保编译器能够良好地处理未类型化的 JavaScript。我们计划在未来为这些类型系统提供支持,以便实现更高效的缓存机制。

关于 memo props 的讨论

回到我们之前提到的 Price 组件的例子,编译器能够推断出组件中使用的所有值都属于基本数据类型。在这种情况下,Price 组件实际上并不需要对 total、subTotal 或 tax 进行记忆化处理,这样做可以节省我们的打包体积和内存资源!

function Price(t0) {
const $ = useMemoCache(2);
const { items, state } = t0;
const subTotal = calculateSubTotal(items);
const tax = calculateTax(subTotal, state);
const total = subTotal + tax;
let t1;

if ($[0] !== total) {
t1 = <Text text={total} />;
$[0] = total;
$[1] = t1;
} else {
t1 = $[1];
}

return t1;
}

React 类型的引入

建立好类型系统之后,我们很快发现它不仅能用于类型检查,还能作为一个基础平台,帮助我们进行更多其他类型的分析工作。

通过在类型系统中增加一些规则,我们可以轻松地为 React 的特定规则添加验证。举例来说,我们不需要为每个 React API 单独开发验证逻辑,仅仅通过为 useState 钩子添加相应的类型规则,就能够实现对其的验证:

1 | const [state, setState] = useState({ foo: { bar: {} } });
2 | const foo = state.foo;
> 3 | foo.bar = 1;
| ^^^ InvalidReact: Mutating a value returned from 'useState()',
which should not be mutated. Use the setter function to
update instead.

请注意,这里甚至捕捉到了内部可变性的情况 —— 不仅仅是对状态的简单修改,还包括通过别名变量(foo)对状态中的特定属性(state.foo)进行的修改。

深入阅读

如果你对类型系统感兴趣,想了解更多,最初的 Hindley-Milner 类型系统论文以及较新的局部类型推断(Local Type Inference)论文都是很好的入门资料。

这些文献将带你深入了解类型系统的工作原理,以及它们是如何在编程语言和框架中被应用来提高代码的安全性和可维护性的。通过这些阅读,你将能够更好地理解类型系统的强大之处,以及它们在现代软件开发中的重要性。

关于本文
译者:@飘飘
作者:@Sathya Gunasekaran
原文:https://www.recompiled.dev/blog/type-system/

这期前端早读课
对你有帮助,帮” 
 “一下,
期待下一期,帮”
 在看” 一下 。

继续滑动看下一个
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存