了解 React Compiler ,然后爆改,接着提 PR

1 前言

本文着重于记录我了解 react compiler 的编译原理的过程,同时也有我在学习 react 编译器时的一些思考以及基于思考发展出来的后续优化工作。

2 编译目的

本篇阅读时间预计 10 min,重点章节在第三章编译实践,如果你对编译目的很了解了,可以不用看我这💦的第二章。

根据 react 官方的说法,react 编译器的理念是在不改变目前开发人员熟悉的声明式、组件化的编程模型的前提下,确保应用程序默认的运行速度快------换而言之,在没有 react compiler 之前, react 对于不熟悉的开发者来说,确实很容易出现性能问题。

那么结合我们自身对 react 渲染的理解,我们可以推测出 react compiler 的两个编译目标:

  1. react compiler 必然要限制组件发生重渲染的次数
  2. 为了防止编译导致的体积无序增长, react compiler 必然要在性能和打包体积之间抉择,这其中自然涉及某些设计准则------就像以前的 diff 算法一样

当然,开发者也不能有太高的预期,编译器无法提供完美优化的重新渲染,react 虚拟 dom 、diff 、重复运行组件函数以重渲染的机制使得 react 确实在先天性能上有些不足。

在大致了解了编译目的之后,我们来看到编译实践部分。

3 编译实践

我们下边将用下边这个基础 demo 来探索 react compiler 的编译原理与规则

javascript 复制代码
function Button({ ...props }) {
  return <button {...props}>Add</button>
}

function P({ children, ...props }) {
  return <div {...props}>{children}</div>
}

export default function MyApp() {
  const [data, setData] = useState(0)
  const [count, setCount] = useState(0)

  const total = useMemo(() => data + count, [data, count])
  const total1 = data + count

  return <div onClick={() => setData(data + count + 1)}>
    Base Data - {data}
    <P>Total: {total}</P>
    <P>Total1: {total1}</P>
    <span>{count}</span>
    <Button onClick={() => setCount((count) => count + 1)} />
  </div>;
}

这份 demo ,最终会被编译成下边的代码:
代码

javascript 复制代码
function Button(t0) {
  const $ = _c(4);

  let props;

  if ($[0] !== t0) {
    ({ ...props } = t0);
    $[0] = t0;
    $[1] = props;
  } else {
    props = $[1];
  }

  let t1;

  if ($[2] !== props) {
    t1 = <button {...props}>Add</button>;
    $[2] = props;
    $[3] = t1;
  } else {
    t1 = $[3];
  }

  return t1;
}

function P(t0) {
  const $ = _c(6);

  let props;
  let children;

  if ($[0] !== t0) {
    ({ children, ...props } = t0);
    $[0] = t0;
    $[1] = props;
    $[2] = children;
  } else {
    props = $[1];
    children = $[2];
  }

  let t1;

  if ($[3] !== props || $[4] !== children) {
    t1 = <div {...props}>{children}</div>;
    $[3] = props;
    $[4] = children;
    $[5] = t1;
  } else {
    t1 = $[5];
  }

  return t1;
}

function MyApp() {
  const $ = _c(16);

  const [data, setData] = useState(0);
  const [count, setCount] = useState(0);
  let t0;
  t0 = data + count;
  const total = t0;
  const total1 = data + count;
  let t1;

  if ($[0] !== data || $[1] !== count) {
    t1 = () => setData(data + count + 1);

    $[0] = data;
    $[1] = count;
    $[2] = t1;
  } else {
    t1 = $[2];
  }

  let t2;

  if ($[3] !== total) {
    t2 = <P>Total: {total}</P>;
    $[3] = total;
    $[4] = t2;
  } else {
    t2 = $[4];
  }

  let t3;

  if ($[5] !== total1) {
    t3 = <P>Total1: {total1}</P>;
    $[5] = total1;
    $[6] = t3;
  } else {
    t3 = $[6];
  }

  let t4;

  if ($[7] !== count) {
    t4 = <span>{count}</span>;
    $[7] = count;
    $[8] = t4;
  } else {
    t4 = $[8];
  }

  let t5;

  if ($[9] === Symbol.for("react.memo_cache_sentinel")) {
    t5 = <Button onClick={() => setCount((count_0) => count_0 + 1)} />;
    $[9] = t5;
  } else {
    t5 = $[9];
  }

  let t6;

  if (
    $[10] !== t1 ||
    $[11] !== data ||
    $[12] !== t2 ||
    $[13] !== t3 ||
    $[14] !== t4
  ) {
    t6 = (
      <div onClick={t1}>
        Base Data - {data}
        {t2}
        {t3}
        {t4}
        {t5}
      </div>
    );
    $[10] = t1;
    $[11] = data;
    $[12] = t2;
    $[13] = t3;
    $[14] = t4;
    $[15] = t6;
  } else {
    t6 = $[15];
  }

  return t6;
}

我们从两个方向出发去探索转换过程,一个是阅读编译后的代码,解析其特点,方便我们深化理解这个过程 react 做了哪些优化。其次是尝试依据官方的设计原理和部分源码,介绍编译过程。

3.1 编译效果

编译的效果可以总结一下:增加判断元素的成本以降低创建的成本。我们可以截取一段编译后代码就能理解这句话。

在这段简单的代码片段中,t1 这个值的赋值需要通过一段比较才确定是做创建还是取数据。这样,当函数组件多次执行(重渲染)时,该元素组件的依赖如果没有变更,就直接取缓存中的元素,否则创建新的元素。

这个优化逻辑即是 react compiler 的核心,我们会在后边看到非常多类似的小片段。

我们先用在线编译器简单看看效果:playground.react.dev/#N在线编辑器

来看到 Button 组件,这是一个非常简易的组件,封装了一个内容为 Add 的按钮🔘,而其最终被编译成了如下边的代码。

这份代码相比原本简洁明了的写法确实大有不同,我们来看 react 改变了什么:

  1. 入参。显然,Button 组件的入参变成了 t0,而原本的 props 从透传变成了条件式获取。
  2. 返回。当我们观察返回的时候就会发现,此时编译后的代码中,react 用 t1 存储了原本的<button {...props}>Add</button> ,其同样变成了条件式的获取。

自然,我们也看到 react 引入了两个新东西。

3.1.1 useMemoCache 与运行时的产物 $

第一个是$变量。从字面来看,这是一个数组,从_c 函数中返回,推测是做了缓存处理,数组中存储了组件内部的各个变量,在每次运行的过程中似乎都在尝试做比较(即如果相同则复用$里的值,不同则重新生成,并将结果填入数组。

这一部分其实是最好理解的,这也是 react 引入的一些 runtime 代码,在 react 仓库的 react-compiler-runtime 包中,我们可以看到这个c函数:

这个函数是 useState 的一个使用,写法虽然奇怪一点,但是其等价于下边的代码。代表的意义也很简单,返回一个lengthsize的数组,并把数组每一项设置成 react 的 empty 符号。

javascript 复制代码
const $empty = Symbol.for("react.memo_cache_sentinel");
export function c(size: number) {
  const [$] = useState(() => {
    const arr = new Array(size);
    for (let i = 0; i < size; i++) {
      arr[i] = $empty;
    }
    // @ts-ignore
    arr[$empty] = true;
    return arr;
  })
  return $
}

而之后,由于 react 生成的其他代码在写入 $ 时,都是直接读取,所以不会触发重新渲染,故而可以通过 useState 把整个组件内的变量都存储起来。

3.1.2 if($[n] != x){...}else{...}

if($[n] != x){...}else{...} 结构,我称之为缓存计算。这份编译后的代码理解起来不麻烦,我们看到下半部分这一块。

typescript 复制代码
  let t1;

  if ($[2] !== props) {
    t1 = <button {...props}>Add</button>;
    $[2] = props;
    $[3] = t1;
  } else {
    t1 = $[3];
  }

这份代码的逻辑我们要从组件重渲染的角度来讲,可以分成两个逻辑,也就是每当重渲染时(此时组件函数重新运行):

  • 如果 props 不等于上一次执行时的 props 的值,那么将依赖于props的变量t1重新计算并得到值,同时保存当下props的值,并保存计算结果。
  • 如果props等于上一次执行时的props的值,那么直接用上次计算出来的t1值赋值给t1

也就是说,现在的 react 组件能自动缓存一些组件渲染的结果以减少重渲染的成本------换句话说, react 编译器编译的一个重要目的就是减少组件重渲染的成本。
maybe 无用

对 react 有一定了解的同学应该知道, react 的渲染是粗粒度的。对比 solid 、 vue 、 svelte 这些框架来说, react 通过运行时来运转一套虚拟 DOM Diff 更新的算法,并不能真正做到数据更新立即知道如何更新页面,而是数据更新后由react判断需要如何更新,再去更新页面。这也是为什么 react 新手写出来的代码容易出现性能问题。

在这个组件中,其作用可能没有那么明显,我们如果看到 App 组件的编译代码就能感受到了。在 App 组件中,有许多影响返回(t6)的计算结果都被 react 缓存了,这些变量参考下图。从 t1 ~ t5 ,这些变量都单独参与了缓存计算

也就是说,原本的渲染中难以被感知的细粒度更新,经过拆解成 t1 ~ t5 ,现在 react 在渲染时虽然整体还是会重渲染,但是对于组件内部的子元素,会自动通过缓存的方式复用之前的渲染以减少计算成本。

但是很显然,这样子的代码有个缺点,即冗余,当大量编译,我们就不得不考虑是否需要一个函数来复用逻辑。

当然,目前 react 的编译思路里没有这个------我的意思是,我们能不能自己改造一下?

3.2 编译原理

要做到改造,第一步我们需要看懂 react compiler 的编译原理,我们来看到 babel 版本的编译器。

github.com/GrinZero/re...

先简单看看目录结构,相当明了,我们可以从这张图中立即找到入口文件夹 BabelEntryPoint

3.2.1 编译步骤

先总结一下:react compiler 的 babel 编译插件版本,会针对所有的函数式组件做处理,这个过程会跳过所有 class 组件,当然也包括了 class 组件内部的函数。

而其编译步骤上,其最终的转换过程是通过 codegenFunction 函数来实现的,而在最终转换之前会通过 HIR 结构和各个函数处理组织出整体上下文信息,保证 codegen 过程能拿到充分的信息。

接着我们探索一下整个过程。从 Babel 这里唯二的文件中,也很容易找到我们的入口函数 compileProgram ,从这里跳转过去,我们继续研究。

再来看到目前所在的入口文件,这份代码看似复杂,但是可以粗略看成多个步骤:

  1. 初始化环境上下文、runtime 模块。
  1. 遍历整个程序的 AST 节点,目前来讲只编译函数式组件。其中核心是traverseFunction 函数,其会根据函数的组件类型进入 compileFn 函数。
  1. 接着为编译过的地方,检测需要的 runtime 函数是否被加入成功,如需要导入则进行 runtime 的导入。

导入的逻辑我们就不看了,我们来看到 compileFn,这是 react 编译器的核心工作逻辑。

这里很有趣,因为 compileFn 函数最终会走到 runWithEnvironment 函数,而它是一个生成器函数。不过目前看来,这个函数也更多只是在做 log 操作的时候暂停一下函数,我们可以当成一个完整流来看。

如果细心一点我们就会发现,在runWithEnvironment 函数中,里边的各个函数恰好能对上在线 playground 给出的各个输出步骤。

不过其中大部分函数我们这次不需要关心,来看到其中的最后一部分。在 codegenFunction 中,react 编译器对函数的 AST 做了处理,将我们的代码编译成了带缓存的优化代码。

其中除了一些 fast refresh 的适配逻辑之外,核心函数要看到 codegenReactiveFunction ,codegenFunction 的两个参数,后者不必细说,前者则是原始 function 节点经历过 HIR、Prune 等层层转换后生成的函数。

3.2.2 codegen

来到 codgenReactiveFunction,我们算是正式来到了 react 生成代码的逻辑部分。这一部分相当硬核,由于这一步骤前,react 编译器已经将寻常 JS AST 节点转换成 HIR 结构并做了很多处理,此时输入的其实是一些自定义节点,现在要做的就是把这些自定义节点重新翻译成浏览器能识别的 AST 节点。

再往下走,来到 codegenBlock,codegenBlock 内部通过状态机的方式来将自定义节点转换成原始节点。

最外层的话,目前已知包含了四种自定义节点,内部的话还有更多其他类型,但大部分自定义节点会在 instruction 节点状态机函数中被转换成原始节点。

  • instruction:指令节点。编译器识别到 instruction 节点时,会不断递归并根据节点值的类型做转译处理。但是内部细节我还没有啃明白,本篇没有用到这一块的东西,之后或许会有更详细的文章来介绍。
  • pruned-scope:已剪枝域节点。当遇到该节点时,会继续向下遍历其 instructions 属性。
  • scope:域节点。本篇的核心,react 编译器在这一步将生成编译后的代码。
  • terminal:末端节点。末端节点指的不单是 break 、continue、return 这些语句,而是范围更广的for..of `for..in \while` 等语句,这些语句的共同特点是会对控制流(control flow)起着决定性的作用

还是回到 scope 节点,所谓「域」,指的是一块缓存计算赋值区,类似下图,就是一个域。

从 HIR 的结构中,也能看出这些节点。

总之,我们现在可以来到codegenReactiveScope,这里边包含了将 scope 编译成指定代码的相关逻辑。

先介绍一下 scope 编译的原理,我们看到 playground 中的 scope 结构,会发现其中包含了 dependencies、declarations、reasignments 以及基本索引,它们的意义分别是:

  • dependencies:每个 scope 都会返回一个或者多个值赋值给外部元素,而 dependencies 则记载了会导致值变化的依赖项。
  • declarations:declarations 按顺序包含了域内用到的变量。
  • reassignments:reassignments 代表域内部分被重新分配的变量,主要用在重新计算缓存的 block 中。

这些属性的使用在代码中也可以看到。

那么接下来,让我们开始动手,修改 react compiler,争取通过提取公共函数的形式减少代码体积。

3.3 修改编译器

3.3.1 抽离复用函数

理论上,我们可以把大部分if else判断修改成这样。

typescript 复制代码
export function u(
  cache: any[],
  getSource: () => any[],
  conditionIndexs: number[],
  conditionTargets: any[] | null,
  updateIndexs: number[],
  updateTargets: () => any[]
) {
  const test =
    conditionTargets === null
      ? (i: number) => cache[conditionIndexs[i]] === $empty
      : (i: number) => cache[conditionIndexs[i]] !== conditionTargets[i];
  const condition = (() => {
    for (let i = 0; i < conditionIndexs.length; i++) {
      if (test(i)) {
        return true;
      }
    }
    return false;
  })();

  if (!condition) {
    return null;
  }

  const source = getSource();
  const updateList = [...updateTargets(), ...source];
  for (let i = 0; i < updateIndexs.length; i++) {
    cache[updateIndexs[i]] = updateList[updateIndexs[i]];
  }

  return source;
}

讲一下各行代码,首先是第一部分,我们研究源码和生成的代码会发现,第一部分的条件目前只有两种形式,即 xx != $[i] || yy !== $[i+1]xx != $empty ,所以我们的 runtime 函数可以对这一块做一个简单处理,提炼成一个判断函数。

往下走的话,我们指定了一个 getSource 函数,特别用来针对图中这一部分,因为尝试了一下,这一块的情况相对会复杂一些,包含各种解构之类的特异语法,和下边给缓存赋值并不一致,方便区分。

剩下的部分就是缓存赋值,总体来说就是把缓存计算的逻辑放到了函数中。

typescript 复制代码
  const source = getSource();
  const updateList = [...updateTargets(), ...source];
  for (let i = 0; i < updateIndexs.length; i++) {
    cache[updateIndexs[i]] = updateList[updateIndexs[i]];
  }

  return source;

那么接下来我们要做的就是把我们的 runtime 函数注入到其中,从基本逻辑上看,我们大致要做三件事:

  1. 定义唯一的 Identifier
  2. 修改现有的 codegen 函数
  3. 在存在修改的文件顶部 import 函数

3.3.2 定义 Identifier

我们添加一个 identifier 并且顺势修改所有 compileProgram 往下直到环境变量设置这一部分。

------然后定义这一节就结束了,我们继续。

3.3.3 修改 codegen

其实在前边,当我们了解清楚了 scope 的各个属性的用处之后,现在要做的就是把原本的生成方式修改一下。

但是在这之前,我们要给出一些我们的 runtime 函数的使用例子,比如:

typescript 复制代码
  const [t1] = u($, () => [() => setData(data + count + 1)], [0, 1], [data, count], [0, 1, 2], () => [data, count]) || [$[2]];
  const [t2] = u($, () => [/* @__PURE__ */ jsxRuntimeExports.jsxs(P, { children: [
    "Total: ",
    total
  ] })], [3], [total], [3, 4], () => [total]) || [$[4]];

将这份 runtime 函数放到 AST explorer(astexplorer.net/) 中,我们要依据这份 AST 节点来书写代码。

往下看到第一部分代码,我修剪了一些不必关注的逻辑,可以看到这里做的是收集每个域中条件的一部分,也就是不断的收集 $[i] !== target 这样的子项。

typescript 复制代码
  for (const dep of scope.dependencies) {
    const index = cx.nextCacheIndex;
    const comparison = t.binaryExpression(
      "!==",
      t.memberExpression(
        t.identifier(cx.synthesizeName("$")),
        t.numericLiteral(index),
        true
      ),
      codegenDependency(cx, dep)
    );

		changeExpressions.push(comparison);
    cacheStoreStatements.push(
      t.expressionStatement(
        t.assignmentExpression(
          "=",
          t.memberExpression(
            t.identifier(cx.synthesizeName("$")),
            t.numericLiteral(index),
            true
          ),
          codegenDependency(cx, dep)
        )
      )
    );
  }

对我们来讲,我们的 runtime 函数需要 6 个参数,单这一步即可收集到 conditionIndexsconditionTargets 两个部分,此时增加的代码如下:

typescript 复制代码
    const conditionIndexs: t.NumericLiteral[] = [];
    const conditionTargets: t.Expression[] = [];

    for (const dep of scope.dependencies) {
      const index = cx.nextCacheIndex;
      conditionIndexs.push(t.numericLiteral(index));
      conditionTargets.push(codegenDependency(cx, dep));
    }

再往下,屏蔽掉一些逻辑后看到源码,其代表的是往 statements 中添加一个 let语句,恰好,我们也需要这一部分信息。

typescript 复制代码
  let firstOutputIndex: number | null = null;
  for (const [, { identifier }] of scope.declarations) {
    const index = cx.nextCacheIndex;
    const name = convertIdentifier(identifier);
    if (!cx.hasDeclared(identifier)) {
      statements.push(
        t.variableDeclaration("let", [t.variableDeclarator(name)])
      );
    }
    cacheLoads.push({ name, index, value: wrapCacheDep(cx, name) });
    cx.declare(identifier);
  }

我们可以将其收集起来,用于之后的const xx = u(...) 定义。

typescript 复制代码
    const arr: t.Identifier[] = [];
    for (const [, { identifier }] of scope.declarations) {
      const index = cx.nextCacheIndex;
      const name = convertIdentifier(identifier);
      arr.push(name);
    }

再接下来,我们要继续收集需要的数据,然后再进行 ast 节点生成,目前我们还缺少 getSource 需要的返回以及updateIndexsupdateTargets 这些数据。

后两者其实只是流程上的改版,麻烦的是其中 getSource 需要的数据的编写。从源码上看,原始这一部分需要经过 codegenBlock ,这意味着我们要再次进到状态机中。

由于此时的 block 仍是非原始节点,进去转换的过程是必要的。

那么此时我们想达成我们期望拿到正常 getSource 的 AST 语句的话,就有两种选择:

  1. 介入 HIR 的生成过程,增加我们期望的自定义规则。
  2. 根据 Convert 出的代码,继续做处理。

考虑到前者的成本较大,目前先基于第二个方式处理。第一版如下,基本思路是基于解析后的 code,从其中拿到等号右侧的初始化数据。

在我们拿到充分的数据后,进入最后一步,将数据注入到 ast 中生成代码。

4 总结

本文深入分析了React Compiler的编译原理,目的是减少组件重渲染,提升性能。通过基础组件示例,展示了编译器如何通过条件判断和变量缓存来优化渲染过程。文章进一步详细解读了编译器的内部工作机制,包括环境初始化、AST节点处理、以及关键编译函数的作用。作者提出了通过提取公共函数u来简化条件判断和缓存赋值,减少编译后代码的冗余,从而提高编译效率。最终,文章展示了如何在编译过程中集成u函数,实现代码优化。

细节的东西我已经补充完毕,目前已经提起了 react 社区的 PR 草稿,希望大家能对这个 PR 给出自己的 idea。

相关推荐
abc800211703439 分钟前
前端Bug 修复手册
前端·bug
Best_Liu~42 分钟前
el-table实现固定列,及解决固定列导致部分滚动条无法拖动的问题
前端·javascript·vue.js
_斯洛伐克2 小时前
下降npm版本
前端·vue.js
苏十八3 小时前
前端进阶:Vue.js
前端·javascript·vue.js·前端框架·npm·node.js·ecmascript
st紫月3 小时前
用MySQL+node+vue做一个学生信息管理系统(四):制作增加、删除、修改的组件和对应的路由
前端·vue.js·mysql
乐容4 小时前
vue3使用pinia中的actions,需要调用接口的话
前端·javascript·vue.js
似水明俊德4 小时前
ASP.NET Core Blazor 5:Blazor表单和数据
java·前端·javascript·html·asp.net
至天5 小时前
UniApp 中 Web/H5 正确使用反向代理解决跨域问题
前端·uni-app·vue3·vue2·vite·反向代理
与墨学长5 小时前
Rust破界:前端革新与Vite重构的深度透视(中)
开发语言·前端·rust·前端框架·wasm
H-J-L6 小时前
Web基础与HTTP协议
前端·http·php