编译协同渲染
ReactLynx 会在构建时分析 JSX,提前提取其中稳定的宿主元件结构,让运行时专注处理可能变化的值和子树。 本文将这种编译器与运行时分工的机制称为编译协同渲染。
这项优化会自动生效。大多数应用只需继续编写清晰、地道的 JSX,不必为了配合编译器而改变正常的组件设计。
这里的“宿主元件”(host elements)是 ReactLynx 通过 Lynx 引擎渲染的 Lynx 元件,例如 <view> 和 <text>;<ProductCard> 这样的用户组件则不是宿主元件。
编译器提前优化了什么
先看一个常规组件:
如果只像传统的纯运行时 Virtual DOM 那样进行常规 JSX 转换、不提取静态结构,Greeting 的编译结果在语义上类似:
每次渲染时,jsx() 都会创建描述 UI 结构的虚拟节点(VNode)。ReactLynx 再沿着这些节点逐层比较,决定复用、创建或更新哪些宿主元件。这个过程通常称为协调(Reconciliation)。
但在这个例子中,view、text、固定文本 Hello, 及其父子关系都是静态的,只有 className 和 name 会变化。编译器可以提前生成用于创建静态结构的 Element PAPI 操作。由此得到的静态模板定义可以在不同实例的首次渲染以及后续重新渲染中复用;运行时只需提供每次渲染所需的动态数据。概念上类似:
这里的名称和函数形式只用于解释机制,并不是可以依赖的实际编译产物。首次渲染时,编译生成的 create() 直接建立已知的宿主结构;后续渲染时,update() 直接修改已知元件。运行时不需要为这部分结构反复创建和比较 VNode,而 name 等无法提前确定的子节点仍通过动态插槽处理。
历史经验与当前实现:Snapshot
这种利用编译阶段已知信息缩小运行时工作范围的思路并非 ReactLynx 独创。Vue 的编译器信息增强虚拟 DOM、Owl 的 BlockDOM 和 Million.js 都采用了类似的思路。从更宽泛的计算视角,也可以把它理解为一种部分求值(partial evaluation):使用 JSX 中已知的宿主结构对通用渲染过程进行特化,只把无法提前确定的部分留到运行时,达到一种“部分预渲染”的效果。
在 ReactLynx 中,这套思路当前通过一种名为 Snapshot 的中间表示(Intermediate Representation,IR)实现。我们也在尝试演进成性能更好的其他表示。Snapshot 与上面的概念模型一致,可以归纳为以下四个核心部分:
注意:IR 属于内部实现,请勿在应用代码中依赖或复制这些产物。具体名称、参数和数据结构可能随源码、编译配置及 ReactLynx 版本变化。
如何编写编译友好的 JSX
动态属性、文本和子节点本身不会阻止编译优化。对于大多数组件,遵循下面三个原则就足够了:
- 优先保证组件语义清晰、代码易读。
- 让运行时数据照常通过属性和子节点传入。
- 让开发时已经确定的宿主类型、层级和顺序自然地出现在 JSX 中。
不必为了把属性或文本变成静态值而拆分组件,也不必追求整棵树都是静态的。 编译器本来就会把稳定结构和动态区域分开处理。
下面是一些可选的编写模式。它们不是硬性规则;只有当写法同时符合组件本身的语义时,才值得采用。
自然地保留共享结构
下面的写法在两个分支中重复了整个卡片结构:
当两个分支本来就表示同一种组件结构时,可以只保留一份稳定外壳,将价格区域写成动态子节点:
后一种写法让共享外壳更直接地暴露给编译器,只把价格区域留在动态插槽中。 不过,不要单纯为了编译优化重构条件渲染;不同写法可能影响组件身份、状态保留和可读性。
直接写出已知的宿主类型
编译器只能分析源码中可见的 JSX 结构。 如果宿主类型要到运行时才能确定,这部分结构也只能留到运行时处理:
如果候选结构在开发时已经确定,可以直接写出 JSX 分支:
两个分支仍然由运行时数据选择,但每个分支的宿主结构都对编译器可见,因此可以分别提取。 只有宿主类型确实来自运行时配置时,才需要使用动态创建方式。
已知属性适合显式声明
编译协同渲染支持 spread 属性。 编译器知道展开对象作用于哪个元件,但属性集合要到运行时才能确定,因此需要把整个对象保留为一个动态位置:
当属性集合在开发时已经确定,显式声明属性可以让编译器生成更定向的更新逻辑:
显式写法也避免了每次渲染都创建 cardProps 对象。
组件需要透传调用方提供的未知属性时,spread 仍然是合适的写法,不必为了编译优化强行逐项展开。
概括来说,让确定的宿主结构直接出现在源码中,让真正的运行时数据通过属性或动态子节点进入即可。 对于本来就需要在运行时决定的结构,保持自然、动态的写法更重要。
编译器与运行时如何协作
ReactLynx 的一个特点是:这份编译表示不仅参与更新阶段的协调,也参与 Lynx 双线程的首帧直出(IFR)链路。
同一份模板可以被多个已挂载的 Greeting 组件复用。每个已挂载的 Greeting 都有一组彼此对应的模板实例:主线程模板实例持有创建出的 Lynx 元件,后台模板实例保存参与协调的逻辑树和动态数据。运行时消费编译产物的过程可以分为首屏创建和后续更新两个阶段。
首屏:创建模板实例
首屏的 UI 元件由主线程直接创建。运行时先根据 VNode 的类型找到模板,再为这次 Greeting 创建主线程模板实例。静态创建函数返回的 Lynx 元件由这个模板实例持有;初始动态值随后交给对应的更新函数,动态子节点则插入编译器预留的插槽。
下面是这一过程的概念伪代码:
对于当前例子,template.create() 创建 view、text 和固定文本;template.update[0]() 将 values[0] 写入 className;insertDynamicChild() 则把 $0 对应的 name 放进预留插槽。这里仍会创建完整的 Lynx 元件树,差别在于静态层级由 template.create() 直接展开,不再通过每个宿主 VNode 逐层协调。
换一个角度,也可以把它理解为预先计算一部分渲染工作,类似于 Next.js 的 Partial Prerendering。不过,两者的产物不同:Next.js 生成静态 HTML 外壳和序列化的 React Server Component 载荷;ReactLynx 则把稳定的宿主结构继续降级为 Lynx 平台可以直接理解的元件创建操作。动态值和动态子树仍会在页面启动时传入。
后台首轮协调也会生成与主线程模板实例树对应的后台模板实例树。首屏同步通过 hydration 建立两侧模板实例的对应关系,使后续更新可以用 instanceId 定位首屏时创建的主线程模板实例。
更新:只处理动态位置的变化
对应关系建立后,后台线程比较新旧动态数据并生成更新,主线程再找到对应的模板实例并应用变化。
下面的伪代码继续使用 Greeting。highlighted 对应动态位置 0,因此它变化时会生成一条属性更新;name 位于动态子节点插槽中,继续由 React 协调:
对于属性路径,reconcileGreeting() 只描述“哪个模板实例的哪个动态位置发生了变化”,applyAttributePatch() 再调用编译器生成的定向更新函数。动态子节点不经过这组属性更新函数,而是由 React 协调后产生相应的树操作。当前实现会将这些操作编码进序列化的 Patch。
回到应用代码
编译协同渲染不是一套需要开发者手动调用的 API,而是 ReactLynx 在编译器与运行时之间自动完成的分工。即使组件中包含动态属性、条件分支、列表和用户组件,常规 JSX 仍然可以正常工作;编译器会在能够确定的范围内提取稳定结构。
在日常开发中,优先写出语义清晰的组件即可:让已知的宿主结构自然地出现在 JSX 中,让动态数据照常通过属性和子节点传入。只有性能分析明确指出相关代码是瓶颈时,才需要在不改变组件语义的前提下考虑额外调整。
换句话说,应当让编译器从自然的 JSX 中寻找优化机会,而不是让应用代码围绕某一种内部 IR 组织。