编译协同渲染
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 元件由这个模板实例持有;初始动态值随后交给对应的更新函数,动态子节点则插入编译器预留的插槽。
下面是这一过程的概念伪代码:
这里的 initialRender 抽象了首次同步所需的主线程模板实例信息;Lynx 元件本身仍留在主线程。hydrateRootFromMainThread() 抽象了后台渲染、两侧实例关联和首次差异同步,并返回长期负责协调的后台 root。这些名称用于说明机制,并非 ReactLynx 当前公开的 API。
这里我们借用 SSR hydration 的概念来描述这一流程:主线程先执行应用并创建首帧所需的 Host Tree,让内容无需等待后台线程就绪即可展示;后台线程随后再次执行应用逻辑,将自己的模板实例树附着到已有 Host Tree,而不是重新创建整棵树。
ReactLynx 利用主线程能够提前创建 Host Tree 的特点,阻塞 UI 线程直至首次渲染完成,确保展示出的第一帧已经包含内容,这就是首帧直出(IFR)。首次同步后,后台线程接管后续协调,主线程继续持有 Lynx 元件并应用更新。
更新:只处理动态位置的变化
对应关系建立后,后台线程比较新旧动态数据并生成更新,主线程再找到对应的模板实例并应用变化。
下面的伪代码以任意模板为例说明相同机制。动态属性的变化可以交给编译器生成的定向更新函数,动态子节点则仍由 React 协调:
对于动态属性,主线程直接调用编译器生成的 Template.update(),不需要重新遍历静态宿主结构。对于动态子节点,后台线程仍会进行协调,并将产生的子节点变化应用到预留插槽。
回到应用代码
编译协同渲染不是一套需要开发者手动调用的 API,而是 ReactLynx 在编译器与运行时之间自动完成的分工。即使组件中包含动态属性、条件分支、列表和用户组件,常规 JSX 仍然可以正常工作;编译器会在能够确定的范围内提取稳定结构。
在日常开发中,优先写出语义清晰的组件即可:让已知的宿主结构自然地出现在 JSX 中,让动态数据照常通过属性和子节点传入。只有性能分析明确指出相关代码是瓶颈时,才需要在不改变组件语义的前提下考虑额外调整。
换句话说,应当让编译器从自然的 JSX 中寻找优化机会,而不是让应用代码围绕某一种内部 IR 组织。