React compiler的性能优化策略

466 阅读7分钟

image.png

周三,Meta在拉斯维加斯举办的React 大会上发布了React 的开源编译器。 Meta React 团队在过去几年耗费了大量精力投入compiler,目标是让react的开发更简洁,在不改变代码的情况下,提供更好的性能,react compiler真正将react forget的概念进行了落地,下面我们聊聊React compiler是如何做到这一点的。 顺便说一下,React Compiler PR非常大。1900次的提交,添加了358424行代码,甚至用Rust重写了功能性的WIP!

你可能真的不需要UseMemo

过去react的渲染方式都是以重新执行函数的方式来达到重新渲染的目的。我们可以称之为一次次的快照,为了避免不必要的re-render,react提供了useMemo,useCallback,memo等方法,通过浅比较决定组件是否重新渲染。举一个例子:

const userComp = memo(function userComp({ user }) { 
    return ( <h1> Hello, {user.age} {user.name}! </h1>); 
});

每当user发生改变,userComp就会重新渲染,React 通过浅比较来决定props是否发生变化,从而决定是否需要重新渲染组件。浅比较指的是只比较对象的第一层数据是否相同,而不会深入比较对象内部的属性。如果组件的props在浅比较下相同,React 就会跳过重新渲染。

而在javascript中,对象比较是基于内存地址的,即两个对象即使内容完全相同,只要它们在内存中的地址不同,就会被视为不相同。 这就是为什么 Object.is({}, {}) 返回 false 的原因,因为每次对象字面量 {} 都会创建一个新的对象实例。

而对于原始值,比如:数字、字符串等,它们没有复杂的内部结构,所以可以直接比较它们的值,这也是为什么 Object.is(6, 6) 会返回 true 的原因,因为两个数字 6 直接比较其值相等。

我们再强调一下关于内存地址相同意味着什么:

let a = {};
let b = {};
let c = a;

console.log(a === b); // 输出 false,因为a和b指向不同的内存地址
console.log(a === c); // 输出 true,因为c被赋值为a,因此c和a指向同一个内存地址

由于对象需要保持其内存地址一样,才能在浅比较中被认为是相同的,因此在React中,使用memoization(记忆化)技术可以帮助保持对象的引用不变,避免不必要的组件重新渲染。Memoization 意味着缓存一个函数的返回结果,并在下次调用时,如果输入相同,直接返回之前缓存的结果,从而保持对象的引用不变。

对于原始值,这种浅比较就是直接的值比较,非常快速且结果明确。因此,对原始值使用memoization(即缓存一个复杂计算的结果以避免重复计算)在大多数情况下是没有必要的。

如果原始值没有变化,React的浅比较就可以快速确认,而不需要额外的memoization逻辑来优化性能。并且由于其比较本身已经非常快速且开销极小,引入memoization可能会导致更多的内存使用,而带来的性能提升却非常有限。在这种情况下,memoization的成本可能超过了其带来的好处。

React Compiler的类型系统

因此为了优化针对原始值的memoization,react引入了自己的类型系统。我们看一个例子:

const total = subTotal + tax;

编译器会生成如下类型方程:

yield { left: subTotal.typeright: { kind"Primitive" } };
yield { left: tax.typeright: { kind"Primitive" } };
yield { left: total.typeright: { kind"Primitive" } };

通过求解这些方程,编译器得出 subTotaltaxtotal 都是基本类型,这样在记忆化时只需对对象进行比较而非重新计算。 那么在函数式组件中,我们通常会使用useMemo来缓存昂贵的计算来避免不必要的渲染:

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

然而,通过类型推断,编译器可以识别 subTotaltax 是基本类型,不需要memo,从而简化为:

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

这就是黄玄在上一次的react conf大会中分享的react forget:我们在写react项目时,会有大量的useMemo,useCallback,memo而这些操作仅仅是为了避免不必要的渲染,而在一定程度上,增加开发者的心智负担,那么有没有可能react能自动帮我们做这些事情呢?react compiler当然能,而且还做的更多,它允许在编译单元间传递类型信息,即使这些编译单元的代码是未经类型注释的。这种推断能力可以帮助快速地在整个代码库中扩展类型信息,尤其是在未经类型化的代码库中。

从React Compiler看v8的性能优化

其实类型系统优化早已不是什么先进的技术,V8 使用即时编译(JIT)技术,将 JavaScript 代码编译为高效的机器码。JIT 编译器在运行时根据代码的实际执行情况进行动态优化。根据 Monomorphism(单态性)、Polymorphism(多态性)和 Megamorphism(巨态性)三种情况,JIT 编译器分别生成高度优化的代码,我们以单态性为例:

单态性(Monomorphism)

单态性指的是函数在被调用时,它的参数和返回值类型始终保持一致。在 JavaScript 中,这意味着函数每次调用时都使用相同的对象结构(shape)和相同的属性类型。考虑如下代码:

function add(a, b) {
    return a.x + b.y;
}

let obj1 = { x1y2 };
let obj2 = { x3y4 };

add(obj1, obj2); // 第一次调用
add(obj1, obj2); // 第二次调用

在第一次调用 add 函数时,V8 会记录 obj1 和 obj2 的类型,并创建一个内联缓存。如果第二次调用时传入的对象类型与第一次相同,JIT 编译器利用内联缓存来记录和优化这些单态调用,减少类型检查和属性查找的开销。

浏览器是如何识别不同类型的?

JavaScript 对象在底层由引擎以一种称为“形状(shape)”或“隐藏类(hidden class)”的结构来表示。每个对象都有一个形状,它描述了对象的属性及其在内存中的布局。

  • 对象形状:是对一个对象的结构的描述,包括对象的属性名及其顺序。这种形状帮助引擎优化对象属性的查找和访问。
  • 隐藏类:是V8引擎中对对象形状的具体实现。每个对象在第一次创建时会被分配一个隐藏类,随着对象属性的添加和删除,这个隐藏类会动态改变。
let obj = {};
obj.a = 10// 创建了一个新的隐藏类,记录包含属性'a'的形状
obj.b = 20// 创建另一个新的隐藏类,记录包含属性'a'和'b'的形状

这些隐藏类帮助V8快速访问对象的属性,因为它可以通过隐藏类直接定位到属性的存储位置,而不需要在对象上进行查找。而涉及到值的比较和加减,v8会使用 类型标记(Type Tagging) 跟踪变量的类型,比如下面这样:

let a = 42;       // a 是 Smi 类型
a = "hello";      // a 变为 String 类型
a = true;         // a 变为 Boolean 类型

类型标记加速了类型检查和操作的决策过程。因为类型信息直接编码在值中,引擎可以快速确定如何处理一个给定的值(例如,选择使用哪种加法运算),而无需额外的类型判断逻辑。

总结

我不再重复介绍React Compiler带来的好处和内部原理,它其实还做了很多事情,但由此我们不难看出,v8引擎的性能优化策略其实是大多数框架(react,vue)的风向标,我们还可以看出,纯函数在性能优化中其实起到很重要的作用:确定的输入,一定能得到确定输出,v8引擎对此可以做包括内联缓存,类型标记在内的很多优化操作。