首屏直出(IFR) v0.5

首屏直出(Instant First-Frame Rendering,IFR)会在 loadTemplate 期间、后台线程启动之前绘制真实内容。Vue Lynx 将 IFR 与元素模板(Element Templates,ET)组合使用:IFR 消除等待后台线程造成的白屏,ET 则降低主线程同步渲染静态结构子树的成本。

配置

lynx.config.ts 中开启 IFR:

lynx.config.ts
import { defineConfig } from '@lynx-js/rspeedy'
import { pluginVueLynx } from 'vue-lynx/plugin'

export default defineConfig({
  plugins: [
    pluginVueLynx({
      enableIFR: true,
    }),
  ],
})

开启 IFR 时会自动开启元素模板。典型组件树不需要修改应用代码。

配置enableIFRenableElementTemplates用途
No IFRfalsefalse默认的后台线程渲染
IFR + ETtrue省略或 true推荐的 IFR 路径
IFR without ETtruefalse兼容、调试和性能对照的退出开关

如果要在保留 IFR 的同时二分元素模板问题,请显式关闭 ET:

lynx.config.ts
pluginVueLynx({
  enableIFR: true,
  enableElementTemplates: false,
})

也可以通过 enableElementTemplates: true 在普通后台渲染中独立开启 ET。这是高级组合方式,不是推荐的 IFR 配置。

IFR 为什么能消除白屏

没有 IFR 时,可见内容必须等待后台线程启动、执行应用、完成 Vue 首次渲染,再把 ops 批次发回主线程:

主线程:   空页面 ───────────────────────▶ 应用 ops ─▶ 绘制
后台线程:          启动 ─▶ 渲染 ─▶ IPC ─┘

开启 IFR 后,主线程 bundle 会携带 Vue 运行时和应用代码。Vue 在 loadTemplate 内同步渲染,同时后台线程并行启动:

主线程:   loadTemplate ─▶ 渲染 ─▶ 绘制
后台线程:               启动 ─▶ 渲染 ─▶ hydration

因此首屏可以在任何后台 JavaScript 运行之前出现。Hydration 完成后,树由后台线程接管,后续更新全部走正常 ops 管线。

Hydration

两个线程使用相同初始数据执行同一个应用。主线程会记录自己应用的每一批 ops;后台线程的初始批次不直接重复执行,而是与记录逐批协调:

  • 完全一致的批次会被跳过,因为内容已经在屏幕上。
  • 文本、样式和属性差异会原地 patch。
  • 结构差异会拆除首屏树,再按后台 ops 重建。

正确性不依赖两次渲染一致。结构不一致只会失去该屏的 IFR 性能收益,并在开发环境输出 [vue-lynx] IFR hydration mismatch 警告。

事件 sign 和元素 ID 在两次渲染中保持确定性。后台 handler registry 就绪后,首帧元素的事件会直接路由到正常的 Vue handler,无需重新绑定。

元素模板(Element Templates)

IFR 决定首帧何时渲染;元素模板减少这次渲染需要多少工作

普通路径中,每个静态元素仍要创建 vnode 和 shadow node、产生多条 ops、跨线程传输,再由主线程解释器分发。编译器可以把符合条件的子树折叠为:

没有 ET:vnode → ShadowElement → 逐节点 ops → 解释器 → PAPI
使用 ET:一个 vnode → INSTANTIATE_TEMPLATE → 直线 PAPI create()

静态骨架会变成编译器生成的 JavaScript create() 函数。Vue 为整棵子树只发送一条 INSTANTIATE_TEMPLATE op;动态文本、class、样式和属性作为“洞”,继续通过普通 SET_* ops 更新。

哪些结构会下沉

当子树结构在编译期完全可知时,它可以下沉:每个节点都是普通 Lynx 元素,只有属性值或文本内容是动态的。

<view class="card">                       <!-- 一棵下沉子树 -->
  <image class="icon" src="a.png" />     <!-- 烘焙静态骨架 -->
  <text class="title">{{ title }}</text> <!-- 动态文本洞   -->
  <view :class="badgeClass">...</view>   <!-- 动态 class 洞 -->
</view>

以下结构保留在普通 vnode 路径:

  • 组件、slot、v-if / v-for 宿主;它们内部的普通元素结构仍可下沉
  • 内部节点上的运行时指令、ref、key、ID 和 vnode hook
  • 有专用主线程行为的 <list><list-item>
  • 注释和混合动态文本段

模板根节点会保留正常 vnode props 和指令。Scoped CSS 受到完整支持:编译器会把组件 CSS scope ID 烘焙到每个下沉元素。只有确认与普通样式路径语义一致时,静态 inline style 才会被烘焙。

框架模板,不是引擎二进制模板

Vue Lynx 当前不使用 Lynx 的二进制 elementTemplates bundle section、__ElementFromBinary__GetTemplateParts。这里的元素模板是框架层优化,由普通 typed Element PAPI 调用组成。

它消除的是 vnode、逐节点 ops、序列化和解释器分发,同时保留现有 renderer 与 hydration 协议。未来可以让二进制模板后端复用相同的静态骨架和洞元数据,但它不属于当前实现。

语义保证

下沉是优化,不是新的渲染模式。不符合条件的结构会自动回退;下沉和未下沉的树必须产生相同的最终文档与更新结果。内部匿名节点不会携带 Vue 的 selector bookkeeping 属性,但任何带 ref、ID 或其他身份要求的元素都不会进入匿名内部路径。

编写 IFR 友好的首屏

初始渲染会在每个线程各执行一次,因此它应表现为输入的纯函数:

  • 保持首屏输出确定性。渲染路径中避免 Math.random()Date.now() 和依赖线程的分支。
  • 把数据请求、定时器、订阅和其他副作用放进组合式 API 生命周期钩子。这些钩子会在主线程渲染期间被抑制,只在后台线程运行。
  • Options API 的 mounted() 目前不会在主线程被抑制。使用 IFR 的首屏组件应优先使用组合式 API 钩子。
  • IFR 只能绘制同步可用的数据。fetch 驱动页面仍应 mount,并在主线程画出有意义的外壳或骨架——用例如 useQuery({ enabled: !isIfrMainThread() }) 关掉网络请求,而不是整段跳过 app.mount()。跳过 mount 只会留下更大的 IFR bundle,却拿不到首屏收益。
  • 后台 handler registry 就绪前的短暂窗口中,普通事件可能被丢弃。主线程脚本 handler 本来就在主线程,因此仍可交互。

取舍

  • IFR 会在两个线程 bundle 中都放入 Vue 运行时和应用。全示例中 main.lynx.bundle gzip 体积的中位增长约为 2.26 倍。
  • 应用会在两个线程各求值一次。全示例串行工作量的 TTI proxy 增长约 35–36%;在真机上,主线程渲染会与后台启动重叠。
  • IFR 无法加速只有异步请求完成后才存在的内容。
  • CSS Modules 的 class 名可能在首帧尚未哈希,并在 hydration 时 patch 为最终名称。

对体积敏感或完全由 fetch 驱动的首屏应先实测。对于具有同步初始数据的内容型首屏,IFR + ET 是推荐配置。

Benchmark

下表把三种产品配置明确分开。FCP 来自 Lynx for Web 的真实 Web Worker,hello-world 无 CPU 节流测量;渲染成本来自约 1000 元素动态内容场景的 V8 --jitless warm 中位;TTI 和 gzip 是全示例扫描相对 No IFR 归一化后的中位数。这些列来自不同轮次,不应视为同一条端到端 trace。

配置flagsFCP(hello-world)渲染成本TTI proxybundle gzip
No IFRIFR=false, ET=false96.6 ms9.4 ms1.00x1.00x
IFR + ETIFR=true, ET=default75.4 ms(−22%)1.3 ms1.35x~2.26x
IFR without ETIFR=true, ET=false75.8 ms(−22%)8.4 ms1.36x~2.26x

跨多轮测量,内容优先首屏在 Lynx for Web 上的 IFR + ET FCP 收益大致落在 −12% 到 −26%(十个 demo 中位 −19%;后续含 TodoMVC / gallery / 更大应用的 七应用集中位约 −12%)。绝对毫秒随宿主漂移;比值才是稳定货币。详见 IFR 性能数据

数据说明了什么

  1. IFR 在内容优先首屏上的收益是真实的、结构性的。有真实线程边界的 Lynx for Web 会把后台启动 + IPC 移出关键路径——这也是单进程 benchmark 看不到它的原因。一轮(十个偏小 example)看到 −8% 到 −23%,中位 −19%; 后续含 TodoMVC、gallery、Hacker News(shell IFR)、AI Chat、Elk 的复测 中,内容优先中位约 −12%(hello-world 单独约 −22% 到 −26%,视宿主而定)。 ReactLynx 在第一轮 harness 上为 −23%,同属这一量级。
  2. 元素模板攻击的正是 IFR 新增的那笔成本。主线程同步渲染跨复测约便宜 6–15×,ops 载荷缩小 3–1000×。小屏 web FCP 上这常常看不出来——两种 IFR 配置通常只差几个百分点——但渲染成本随屏幕变大、CPU 变慢而增长, 因此 ET 默认随 IFR 开启。
  3. 代价是 bundle 体积,大应用 / fetch 重应用可能反转。main.lynx.bundle gzip 约 ×2.2–2.5(套件中位 ~×2.26)。Web 上 bundle 解析在 FCP 路径上: 4× CPU 节流下,Elk / AI Chat 曾测到约 +25% 到 +44%;小内容屏仍可保留 较薄收益。IFR 下应画出真正的外壳,不要因数据异步就跳过 app.mount() (Hacker News 改 shell IFR 后,满速从约 +38% 回到 −12%)。

给开发者的建议

你的首屏建议
内容优先、由同步数据渲染enableIFR: true(自带 ET)——内容优先集上典型 web FCP 约 −12% 到 −26%
数据驱动但有同步外壳 / 骨架开;把 fetch 挡在 IFR 主线程之外
fetch 驱动——响应前完全无物可画不开;或先做出真正外壳再开
Web 上大 bundle + 慢 CPU先实测——节流可能抹平甚至反转收益
体积极度敏感先实测——gzip 大约翻倍

无论哪种画像:保持首屏确定性、副作用放进组合式 API 生命周期钩子(见编写 IFR 友好的首屏);enableElementTemplates: false 只用于二分定位疑似 ET 问题——它不是性能调节项。

这张表只是摘要。完整的测量数据——七 variant 策略阶梯、全示例逐项 FCP/TTI/体积、Lynx for Web 真实双线程与 ReactLynx 的对照、后续大应用复测、以及单线程裸 Web 基线分解——见专门的 IFR 性能数据页面。