两棵树,两个线程:一个小程序引擎的架构拆解

小程序引擎最反直觉的地方在于:真正难的不是把 WXML 渲染成 DOM,而是决定哪些东西可以跨线程,哪些必须留在原地。

这个项目叫 Tiny,一个基于 glass-easel 的小程序引擎。它不是一个 UI 框架,也不是微信开发者工具的复刻,而是一套可以拆开看的运行时:

  • 编译器把 WXML / WXSS / TS 变成 manifest 和可执行产物;
  • service 线程运行小程序逻辑树,宿主 render 线程维护真实渲染树;
  • 两边通过一份版本化的 Bridge 协议同步;
  • 浏览器、iOS、单元测试可以用同一套内核,只是换宿主。

仓库在 YufJi/tiny,架构站点在 yufji.github.io/tiny,在线调试器可以直接跑 tests/apps 下的真实小程序:打开调试器。

市面上讲小程序双线程的文章,大多停在「逻辑层和渲染层通过 JSBridge 通信」。但真正落地时,问题会具体到:dataset 为什么丢类型、首屏消息为什么比 render 先到、catchtap 为什么在滚动后失效、调试器里正常的手势为什么到手机上就变了。这篇文章想把这些细节摊开讲。

先给一组数字,说明这套东西大概做到了什么程度(数字来自当前仓库的验收文档,会随迭代变化):

指标 数值
运行时树 2(service 逻辑树 + host 渲染树)
Bridge 消息域 9
内置组件实现 24
单元测试 288
浏览器验收步骤 58
测试小程序 7(含 Taro 3 产物、Vant、WeUI)
微信示例页 probe 117 页

如果只想带走四个结论:

  1. 两棵树:逻辑和渲染分开,语义留在 service,render 只负责画;
  2. 一份协议:Bridge 只做传输,握手、批处理、错误和 JSON 边界都是显式的;
  3. 一次编译:WXML 在构建期变成 ProcGen,运行时不再解析模板;
  4. 多个宿主:浏览器、iOS、测试进程共用内核,只替换线程容器和渲染器。

这篇文章会按「架构 → 编译 → 协议 → 启动 → 更新 → 宿主 → 事件 → 工程化」的顺序,把这套引擎拆一遍。每一节都会尽量回答两个问题:为什么这样设计 ,以及代价是什么。


一、先看全貌:五个角色,一条向内依赖

很多小程序方案把「框架」和「引擎」混在一起讲。Tiny 把它们分得很开:

角色 代码位置 只负责 不负责
编译器 tools/cli 扫描源码、编译模板和样式、转译脚本、生成 manifest 和诊断 页面生命周期、路由、wx API、DOM
产物契约 packages/manifest 产物类型、schema 版本、固定引擎命名 具体实现
运行时 packages/runtime App/Page/Component 语义、页面栈、setData、observer、relations、逻辑树、虚拟节点 diff 真实 DOM、内置组件实现、宿主 API
传输层 packages/bridge 信封校验、帧、握手、调用关联、批处理、超时、JSON 检查 wx API 语义、渲染指令、页面状态
宿主 hosts/*、apps/devtool、tests/host 线程容器、transport、渲染树、内置组件、canvas、资源 URL、诊断收集 页面栈、业务 data、observer、relations

依赖方向只有一条:宿主依赖内核,内核绝不反向依赖宿主 。packages/* 不能导入 tools/*、apps/*、hosts/*、tests/*。

需要先说明一点:glass-easel 不是完整的小程序 SDK。它提供组件定义、模板、数据绑定、事件和生命周期,但不提供 App、页面栈、路由、Bridge 和平台 API。这些正是 packages/runtime 和 packages/bridge 存在的原因。

text 复制代码
小程序源码(WXML / WXSS / JS / TS)
        │
        ▼
tools/cli ──► manifest.json + templates.js + service.js + styles
        │
        ▼
宿主(iframe / JSCore / WKWebView / 测试进程)
        │
        ├──────────────┐
        ▼              ▼
service 线程       render 线程(每页一个 root)
逻辑树 / 页面栈     真实组件树 / DOM / 布局
        └──────┬───────┘
               ▼
     tree.flow / tree.patch / tree.event

真正把这几层粘起来的不是某个函数调用,而是两份契约:

  1. manifest:编译器和运行时之间的产物契约,当前 schema 版本是 2;
  2. Bridge 协议:内核和宿主之间的消息契约,当前版本是 1。

先定契约,再写实现,是这套代码能在多套宿主之间复用的主要原因。后面会反复看到这条原则的收益。


二、为什么是两棵树

2.1 第一版:把 glass-easel 放在 render 线程

最直觉的做法,是让渲染线程完整运行 glass-easel:模板求值、组件实例、dataset、DOM 都在 render,service 只保存页面 JS 的状态,两边同步 data。

这版能跑,但很快撞上三个硬问题。

第一,dataset 会丢类型。 小程序里 data-id="{{1}}" 和 data-id="{{'1'}}" 是两回事。可一旦这个值经过 DOM 再读回来,它大概率已经变成了字符串。逻辑层拿到的 event.currentTarget.dataset.id 就不再是当初写入的类型。

第二,函数属性过不了桥。 组件属性可以是函数,triggerEvent 的 detail 里也可能带函数。JSON 序列化会把它们直接丢掉,而小程序语义里这些是合法值。

第三,原生宿主得重新实现一遍语义。 如果 render 线程负责模板求值和组件定义,那么换一个非 Web 的渲染后端,就要把 WXML 表达式、dataset、relations 这些逻辑再写一遍。这等于把「跨端」变成了「重写」。

2.2 第二版:逻辑树搬到 service,但 render 还留着「适配器」

第二次尝试把逻辑树搬到了 service,但 render 侧仍然保留了一系列按 tag 写的适配器,以及一个「脱离组件树的 glass 孤岛」。

这个方案的问题更隐蔽:checkbox-group 这种内置组件依赖真实的父子组件关系、slot 和 shadow root。如果 render 侧只是把内置节点展开成几个 div,父组件根本看不到子组件,checkbox-group 的 change 就永远凑不齐。

这也是后来定下的原则:内置组件必须是真实组件树里的真实组件,不能是渲染层临时拼出来的 DOM 片段。

2.3 最终版:两棵完整的树

现在的结构是:

text 复制代码
service 线程                              render 线程(每页一个 root)
+-------------------------------+         +-------------------------------+
| App / Page / Component        |         | host TreeRenderer             |
| glass-easel 逻辑 ComponentSpace |  tree  | 真实内置组件                   |
| 模板求值 -> 虚拟 RenderNode    | <-----> | glass-easel render 树          |
| data / observer / relations   |  flow   | DOM / 布局 / 样式              |
| node id + patch               |  patch  | 不执行 app methods             |
+-------------------------------+         +-------------------------------+

分工可以用一张表说清楚:

能力 归属 说明
App / Page / Component 定义 service 组件实例、setData、observer、relations 都在逻辑树
模板求值 service 输出虚拟 RenderNode,不生成 DOM 字符串
稳定 node id service 分配 render 只维护 id → 真实组件 的映射(外加一份给 WXS 用的只读数据快照)
内置组件的真实实现 host Web 侧是 tiny-* 自定义元素,原生侧可以是原生视图
布局、样式、DOM render 逻辑层不碰 document
事件回调 service render 只回传 node id、事件名、detail 和 native 字段
页面栈、路由 service + host 协作 只有宿主同时持有两侧连接

关键设计是 node id。它不是 DOM 节点 id,也不是组件实例 id,而是 service 在创建虚拟节点时分配的稳定标识。后续的 patch、事件、查询、元素树检查,全部以它为锚点。

这样做的好处是:render 不需要理解小程序的语义。它不需要知道什么是 checkbox-group,只需要知道「这里有一个 tag 叫 checkbox-group 的节点,它有这些 props,它的子节点是这些 id」。至于 checkbox-group 怎么收集子 checkbox 的 value,那是 service 逻辑树的事。

2.4 为什么不是 React 式的 vdom

看到「逻辑树 + 虚拟节点 + diff」,很容易联想到 React。但两者的所有权完全不同。

React 的 vdom 是「UI 的描述」,组件状态和渲染在同一个运行时里;Tiny 的逻辑树是「小程序语义的载体」,渲染树是「宿主的实现」。如果让 render 执行模板,它就必须拿到组件定义、dataset、函数属性------语义又漏回渲染层了。

所以 Tiny 的 render 树不是「DOM 指令执行器」,而是一棵真实的组件树:它有 slot、shadow root、external classes、style scope,只是不执行 app methods、observer 和 relations。

代价也很直接:两棵树会漂移。service 认为某个节点应该在这里,render 可能因为一次 patch 顺序错位放到别处。为此仓库里做了三件事:

  • 虚拟树 diff 有独立单测,覆盖文本、属性、插入、删除、重排;
  • runtime 单测用 headless renderer 跑完整的 service + render 双线程;
  • 浏览器验收直接跑真实小程序,检查 DOM、计算样式和 canvas 像素。

这部分后面在「工程化」一节还会展开。


三、编译:让 WXML 在构建期消失

3.1 十一个阶段

compileMiniProgram() 是一次全量构建,按顺序做十一件事:

  1. 解析项目根目录和 miniprogramRoot;
  2. 按兼容配置扫描脚本、模板、样式、模板脚本和配置;
  3. 读取 app.json,解析主包和分包页面;
  4. 解析 tiny.config.json 的 compatibility;
  5. 解析每个页面/组件的入口(文件入口或目录入口);
  6. 递归查找 usingComponents,记录全局/局部来源;
  7. 从入口沿 import/require 收集可达脚本,转译 TS,修正相对模块路径;
  8. 把所有模板放进同一个 TmplGroup,处理 import/include/template、WXS、作用域标记和 animation;
  9. 编译 WXSS:加作用域、rpx 转 vw、内联 @import;
  10. 复制图片和字体资源;
  11. 汇总 manifest、生成 service.js 和 templates.js,用 esbuild 打包 service。

这里没有「运行时解析 WXML」这一步。模板在构建期就被编译成了 JavaScript 函数。

3.2 ProcGen 到底长什么样

以 P0 demo 里的一个按钮为例,源码是:

html 复制代码
<button bindtap="openAddTodo">Open add todo</button>

编译产物(整理后的等价结构,省略了压缩后的布尔字面量)大致是:

js 复制代码
G["pages/todos/todos"] = (() => {
  const H = {}
  H[""] = (R, C, D, U, A) => {
    // ...
    E("button", {}, (N, C) => {
      R.devArgs(N).A = [":class"]
      if (C) L(N, "tiny-tag-button tiny-scope")
      if (C) R.v(N, "tap", "openAddTodo", false, false, false, false)
    }, h)
  }
  return H
})()

几个符号值得解释:

  • R 是渲染器 API,C 是「是否首次创建」的标志,D 是数据,U 是额外参数,A 是绑定表;
  • E(tag, props, expand, text) 创建一个元素,expand 负责子节点;
  • L(N, "...") 设置 class,编译器在这里注入了 tiny-tag-button 和 tiny-scope;
  • R.v(N, "tap", "openAddTodo", ...) 注册事件;
  • h 是一个文本生产者,首次创建时写入 Open add todo,更新时走绑定表;
  • A 会在多次更新之间复用,避免每次都重新分配闭包。

这就是 ProcGen(procedure generation)的核心:模板不是字符串,而是一段可以执行的创建/更新函数 。首次渲染走 C === true 的分支,后续更新走 C === false 的分支,绑定表 A 负责记住哪些表达式需要重新求值。

好处有三个:

  1. 运行时不再需要 WXML parser,产物更小、启动更快;
  2. 模板错误在构建期暴露,而不是等到用户点开页面;
  3. 表达式可以被静态分析,wx:if、wx:for、事件绑定都能提前变成确定的函数调用。

代价是产物可读性差。你看到的不是模板,而是一棵由 E、L、R.v 组成的函数树。所以调试器提供了元素树,浏览器 DevTools 还能通过 source map 回到源码,尽量把「产物」翻译回「页面」。

3.3 WXS 被拆成了两个实例

WXS 是微信体系里比较特殊的一层:它既能出现在 WXML 表达式里,也能绑定事件,还能修改组件状态。

Tiny 没有把整个 WXML 求值搬到 render,而是把 WXS 拆成两个实例:

用途 执行侧 原因
{{wxs.fn()}}、wx:if、wx:for service 表达式依赖页面 data,必须和逻辑树在一起
静态 {{wxs.tap}} 事件 render 事件在渲染线程本地执行,少一次跨线程往返
动态 WXS / service 事件 service 解析,render 优先执行 render 解析不到 handler 时回退 tree.event
change:prop(内置组件) render 内置组件在 render 侧,旧值/新值都在本地
change:prop(自定义组件) service 走 glass-easel 的 observer 契约
WXS 模块状态 分侧隔离 service / render 各一份,不跨线程同步

编译期会完成 globals 改写、getRegExp / getDate 映射、.constructor 值语义转换,并生成外部/内联模块表。生成代码里的 Q.B 会把 WXS handler 的来源(moduleId / exportPath)注册给 render,Q.C 则在调用时补上组件 descriptor,让 event.instance 和第二个参数 ins 都可用。

这里有一个很实用的取舍:表达式跟着数据走,事件跟着渲染走。不是所有东西都值得跨线程,也不是所有东西都能留在本地。

3.4 样式:class 和 tag 两套作用域

WXSS 的隔离不是简单的「加一个前缀」。Tiny 同时处理 class 和 tag:

css 复制代码
/* 源:pages/todos/todos.wxss */
.card { padding: 24rpx; }
button { height: 88rpx; }

/* 产物:scope = tiny-pages-todos-todos */
.tiny-pages-todos-todos--card { padding: 3.2vw; }
tiny-button:where(.tiny-pages-todos-todos--tiny-tag-button) { height: 11.7333vw; }

几个细节:

  • scope 名由固定的 tiny- 前缀加路径生成,例如 tiny-pages-todos-todos,不对用户开放配置;
  • class 选择器加 scope-- 前缀;
  • tag 选择器改写成 tiny-button:where(.scope--tiny-tag-button),其中 tiny-tag-button 是编译期注入到每个元素上的 tag marker;
  • page 重写成 tiny-page,自定义组件 badge 重写成 tiny-badge;
  • rpx 固定按 750 基准换算成 vw,24rpx → 3.2vw,calc() 里的 rpx 也会处理;
  • @keyframes 内部不做 tag 改写。

app.wxss 保持全局,页面和组件样式各自带 scope。逻辑树在挂载页面时注册样式并计算 scope,再把页面/组件样式随 tree.flow 下发给渲染树。渲染树不根据文件名猜样式类型,只按 manifest 里的 cascade role 应用。

3.5 manifest 是唯一索引

编译的最后一步是写 manifest.json。它包含:

  • schemaVersion: 2;
  • runtimeCompatibility:API 全局、注册全局、behavior scheme、兼容探测全局;
  • entries:service.js 和 templates.js;
  • app / pages / components:入口、模板、样式、配置;
  • styles:每份样式的 scope、role、低优先级路径;
  • assets:复制到输出目录的静态资源;
  • diagnostics:编译期诊断。

从 schema 1 到 schema 2,最大的变化是把 naming 换成了 runtimeCompatibility:只保留运行期真正需要的 API 全局、注册全局、behavior scheme 和兼容探测全局;扩展名、模板指令这些纯编译期配置不再进入产物。

一个真实的片段大概是这样:

json 复制代码
{
  "schemaVersion": 2,
  "runtimeCompatibility": {
    "api": { "global": "wx", "aliases": [] },
    "globals": { "app": "App", "page": "Page", "component": "Component" },
    "behaviorScheme": "wx://"
  },
  "entries": { "service": "service.js", "templates": "templates.js" },
  "pages": [{
    "path": "pages/todos/todos",
    "bodyType": "page",
    "script": { "path": "scripts/pages/todos/todos.js" },
    "template": { "path": "pages/todos/todos", "outputPath": "templates.js" },
    "style": { "scope": "tiny-pages-todos-todos", "outputPath": "styles/pages/todos/todos.wxss.css" }
  }],
  "diagnostics": []
}

这份文件同时被 service、宿主和 devtool 读取,但三者读的字段不同:

  • service 读页面路径、组件 schema、模板和样式;
  • 宿主读入口、兼容配置和编译诊断;
  • render 不读 manifest,它只接收 flow / patch 和宿主注入的渲染能力。

四、Bridge:只做传输,反而最难

4.1 一条消息长什么样

Bridge 的信封是自描述的:

ts 复制代码
type ProtocolMessage<T = unknown> = {
  protocolVersion: 1
  messageId: string
  kind: 'call' | 'reply' | 'event' | 'control'
  domain: 'runtime' | 'page' | 'data' | 'event'
        | 'api' | 'diagnostic' | 'canvas'
        | 'query' | 'tree'
  method: string
  source: 'host' | 'service' | 'render' | 'devtool'
  target: 'host' | 'service' | 'render' | 'devtool'
  payload: T
  pageId?: string
  replyTo?: string
  timestamp: number
}

接收方不需要额外上下文,就能判断版本、方向、类型、消息域和关联关系。

几个约束是硬性的:

  • protocolVersion 必须是 1;
  • messageId 和 method 不能为空;
  • kind、domain、source、target 必须是枚举值;
  • reply 必须带 replyTo;
  • payload 必须通过 JSON 安全校验;
  • source / target 必须和连接双方一致,否则抛 INVALID_ENVELOPE。

最后一条很重要:Bridge 是点对点 的,不做广播。宿主转发事件时会创建一条新消息(新的 messageId),而不是原样转发信封。

4.2 四种消息类型,九种消息域

  • call:立即发送,等回复,有超时;
  • control:立即发送,等回复,语义上偏控制(bootstrap、navigate、inspectTree);
  • event:单向,进入批处理队列;
  • reply:对 call/control 的响应。

九个域里,runtime、page、tree、query、canvas、api、diagnostic 是最常用的。data 和 event 是早期协议里的预留:ADR-0004 曾规划 data.setData 和 event.dispatch,后来模板更新统一走 tree,这两个域现在主要出现在 bridge 的协议测试里。

4.3 握手:为什么等 ready 而不是等 hello 的回复

握手分两步:

  1. 宿主发 runtime.hello,携带协议版本、角色和 capabilities;
  2. 对端回复 hello,同时发一条 runtime.ready。

宿主真正等待的是 ready,不是 hello 的回复。原因很实际:hello 的 handler 可能已经注册,但线程还没装好 transport、没加载完模板、没创建渲染树。如果宿主收到 hello 的回复就开始发业务消息,第一帧很可能丢。

代码大概是这样:

ts 复制代码
async handshake(capabilities = {}) {
  const ready = new Promise((resolve, reject) => {
    this.readyWaiter = { resolve, reject }
  })
  try {
    await this.call('runtime', 'hello', {
      protocolVersion: PROTOCOL_VERSION,
      role: this.localRole,
      capabilities: { ...this.capabilities, ...capabilities },
    })
    return await ready
  } catch (error) {
    this.readyWaiter?.reject(
      error instanceof ProtocolError ? error : new ProtocolError('HANDLER_ERROR', String(error))
    )
    this.readyWaiter = null
    throw error
  }
}

版本不一致时,连接会发一条 error 诊断然后主动 close()。没有多版本协商,也没有降级------协议版本是硬边界。

capabilities 目前只是元数据:service 声明 { role: 'service' },render 声明 { role: 'render', mode: 'tree' },宿主没有根据它做能力协商或降级。这一点在「还没做好的地方」里会再提。

4.4 批处理:只有 event 会攒

sendEvent 会把消息按 source:pageId 分组,放进队列,在 microtask 里统一发出:

ts 复制代码
sendEvent(domain, method, payload, options = {}) {
  const message = createMessage({
    kind: 'event',
    domain,
    method,
    source: this.localRole,
    target: this.peerRole,
    payload,
    pageId: options.pageId,
  })
  const key = `${message.source}:${message.pageId ?? ''}`
  const queue = this.batchedMessages.get(key) ?? []
  queue.push(message)
  this.batchedMessages.set(key, queue)
  this.scheduleFlush()
}

只有 event 批处理。call、control、reply 和诊断都立即发送。

这个选择很好理解:setData、滚动、触摸这类事件对延迟敏感,但对「一条一条发」不敏感;合并成一个 microtask 可以显著减少 postMessage 次数。而 navigateTo、bootstrap 这种控制消息必须立刻发,否则页面栈的顺序会乱。

顺序保证的粒度是「同一分组内 FIFO」。跨分组只保证「同一个 microtask 内的队列一起发出」,不承诺不同 pageId 之间的全局顺序。这不是偷懒,而是并行页面本来就应该是独立的。

4.5 JSON 边界:两套清洗函数

Bridge 对 payload 的检查是严格的:

  • 允许:null、string、boolean、有限 number、普通对象、数组;
  • 拒绝:undefined、function、bigint、symbol、Date、Map、Set、class 实例、DOM 节点、Promise、循环引用、NaN / Infinity。

但 runtime 在发送事件前会先做一次清洗:toJsonSafe 会丢弃函数和 undefined,把 Date / Map / Set 转成可序列化的值,把循环引用写成 [Circular]。

于是形成两层:

  • assertJsonSafe:严格校验,发不出去就是发不出去;
  • toJsonSafe:主动清洗,尽量让事件能过桥。

这两层不能合并。前者是协议边界,后者是业务容错。把清洗塞进 assertJsonSafe,协议就失去了「拒绝非法数据」的能力;把严格校验交给业务层,又会出现「本地能跑,跨线程就炸」。

4.6 错误码与超时

Code 触发条件 处理
INVALID_ENVELOPE 字段缺失、枚举非法、source/target 不匹配 拒绝消息并产生诊断
INVALID_PAYLOAD 非 JSON 安全、reply 结构错误、空帧 拒绝发送或回复
PROTOCOL_VERSION_MISMATCH 版本不是 1 发 error 诊断并关闭连接
TRANSPORT_CLOSED 端口关闭或对端不可用 send 抛错,close 时 reject 所有等待调用
TIMEOUT call/control 超过 30 秒 reject,删除等待记录,不重试
UNSUPPORTED_METHOD 没有注册 handler 自动回复 ok: false
HANDLER_ERROR handler 抛非 ProtocolError 包装成错误 payload 返回

「不自动重试」是刻意的。API 调用可能有副作用,重试会悄悄执行两次 setStorage 或两次导航。重试策略应该由发送方决定,而不是传输层替它决定。

4.7 Transport 是可替换的

Bridge 不关心底层是 MessagePort、内存对象还是原生 bridge,只要求实现三个方法:

ts 复制代码
export interface Transport {
  send(frame: TransportFrame): void
  close(): void | Promise<void>
  onFrame(listener: (frame: TransportFrame) => void): () => void
}

现有实现有:

  • createMessagePortTransport(port):浏览器 iframe / worker;
  • createInMemoryTransportPair():runtime 单测,同步发送,但仍走完整校验;
  • createHostMessageChannelPair():浏览器宿主,返回 host 侧 transport 和可转移的 peer port。

同一套协议,换一个 Transport 就能换一种线程模型。iOS 宿主的 service 端在 JSCore,render 端在 WKWebView,中间用原生 FrameRelay 传 JSON 字符串------协议本身没有改一行。


五、启动:15 秒和一条容易丢帧的链路

5.1 先解决竞态

浏览器宿主的启动顺序里有一个真实的竞态:

  1. 宿主创建 service iframe;
  2. iframe 里的线程加载 bundle,安装端口监听;
  3. 宿主把 MessagePort 转给线程;
  4. 线程开始收消息。

如果第 3 步发生在第 2 步之前,端口消息到达时线程还没有监听器,第一帧就丢了。

解决办法不是加延时,而是让线程主动说「我准备好了」:线程先做完宿主初始化(service 侧装 canvas compat,render 侧跑 prepare 装内置样式),再安装端口监听并发出 tiny-runtime-port-ready;宿主收到后才转移端口,最多等 15 秒。端口到达后再创建 connection,做 hello/ready 握手。

这个顺序后来被证明是整个启动链路里最值得写进文档的一条。

5.2 一次 bootstrap 的完整时序

sequenceDiagram participant H as Host participant S as Service participant R as Render H->>S: runtime.hello S-->>H: reply + runtime.ready H->>S: runtime.bootstrap S-->>H: pageId / initialData / componentSchemas H->>R: 创建 render iframe + runtime.bootstrap R-->>H: ready H->>S: tree.ready S->>R: tree.flow(首屏 flow) H->>S: page.ready S-->>H: RUNTIME_STATE

几个容易忽略的点:

  • service iframe 被隐藏成 0×0,render iframe 填满容器;
  • 每个页面一个 render root,返回页面时只切换可见性,DOM、滚动位置和定时器都保留;
  • bootstrap 必须由宿主主动调用,createStandaloneDualThreadHost 只负责建线程、握手和返回 host;
  • service 的 bootstrap 会导入 service.js,此时 App/Page/Component 才注册,App.onLaunch/onShow 也在这里执行;
  • render 的 TreeRenderer(Web 侧是 createGlassRenderTree)在端口到达后创建,bootstrap 控制消息只负责把它和当前 pageId 关联起来,然后等待 flow。

5.3 首屏 flow 会先于 render 到达

这是一个很反直觉、但真实存在的窗口期:

  • service 在 runtime.bootstrap 里创建首屏逻辑树,并立刻发出 tree.flow;
  • 但 render 页面要等 bootstrap 返回 pageId 之后,宿主才会去创建。

也就是说,消息比接收方先到。

宿主用一个 per-page 的「停车场」解决:按 pageId 暂存,页面打开后按顺序 flush,上限 16 页。没有 pageId 或队列溢出不会静默丢弃,而是记 RENDER_SESSION_MISSING 诊断。

ts 复制代码
const pendingRenderEvents = new Map<string, PendingRenderEvent[]>()
const PENDING_PAGE_LIMIT = 16

const flushRenderEvents = (pageId, connection) => {
  const pending = pendingRenderEvents.get(pageId)
  if (!pending) return
  pendingRenderEvents.delete(pageId)
  for (const event of pending) {
    connection.sendEvent(event.domain, event.method, event.payload, { pageId })
  }
}

这段代码说明了一件事:启动顺序是协议的一部分,不是宿主的实现细节。哪些消息可能早到、早到之后怎么办、溢出怎么报,都必须写进协议行为里。

5.4 宿主侧的实际代码

以 tests/host 为例,启动代码很短:

ts 复制代码
const runtime = await createStandaloneDualThreadHost({
  manifest,
  serviceUrl: '/service.html',
  renderUrl: '/render.html',
  initialPath: 'pages/todos/todos',
  container: document.getElementById('app'),
  systemInfo: {
    pixelRatio: devicePixelRatio,
    windowWidth: innerWidth,
    windowHeight: innerHeight,
  },
})
window.__tinyRuntime = runtime
await runtime.host.bootstrap()

createStandaloneDualThreadHost 内部做的是:

  • 创建 service 的 MessageChannel;
  • 创建并隐藏 service iframe;
  • 等 tiny-runtime-port-ready;
  • 转移 port2;
  • 创建 createTinyRuntime,完成 handshake();
  • 返回 host、iframe 句柄和 dispose()。

每次 navigateTo 创建新页面时,renderConnector.connect(pageId) 会新建一个 render iframe,再走一遍端口转移和 render 侧握手。service 连接只有一条,render 连接每页一条------这是两侧生命周期不同导致的必然结果。


六、tree 协议:flow、patch、event

6.1 首屏用 component flow

首屏不是「把整棵 DOM 树序列化过去」,而是一组 component flow:

js 复制代码
[
  { type: 'define', path: 'pages/index/index', definition: { /* ... */ } },
  { type: 'create-node', id: 'node-1', tag: 'view', parentId: undefined, index: 0 },
  { type: 'props', id: 'node-1', props: { class: 'card' } },
  { type: 'children', id: 'node-1', inserts: ['node-2'] },
  { type: 'ready' },
]

这组消息做三件事:

  1. define 把组件定义带到 render(componentId、options、properties 等渲染所需元数据);
  2. create-node / props / children 按稳定 id 建出真实组件树;
  3. ready 表示这一批结束,render 可以挂载页面根了。

注意:define 带的是渲染元数据,不是 app methods。render 不需要知道组件的 methods,更不会执行 onLoad。

6.2 更新只发 patch

后续模板变化走 tree.patch,只有七种:

ts 复制代码
export type RenderPatch =
  | { type: 'text'; id: RenderNodeId; text: string }
  | { type: 'replace'; id: RenderNodeId; node: RenderNode }
  | { type: 'props'; id: RenderNodeId; props: Record<string, unknown>; removed?: string[] }
  | { type: 'insert'; parentId: RenderNodeId; index: number; node: RenderNode }
  | { type: 'remove'; parentId: RenderNodeId; index: number; count: number }
  | { type: 'reorder'; parentId: RenderNodeId; moves: Array<{ from: number; to: number }> }
  | { type: 'extra'; id: RenderNodeId; data: Record<string, unknown> }

diff 的输入是两棵虚拟 RenderNode 树,输出是 patch 数组。核心逻辑不复杂,但有两个细节很容易写错:

ts 复制代码
// 删除必须从后往前发,否则前面的下标会失效
for (let index = previousChildren.length - 1; index >= 0; index -= 1) {
  const key = childKey(previousChildren[index], index)
  if (nextKeys.has(key)) continue
  patches.push({ type: 'remove', parentId: next.id, index, count: 1 })
}

另一个是重排:

ts 复制代码
if (kept.length === nextChildren.length) {
  const moves = []
  const order = [...kept]
  for (const [target, child] of nextChildren.entries()) {
    const current = order.findIndex((candidate) => candidate.id === child.id)
    if (current === target) continue
    const [moved] = order.splice(current, 1)
    order.splice(target, 0, moved)
    moves.push({ from: current, to: target })
  }
  if (moves.length > 0) patches.push({ type: 'reorder', parentId: next.id, moves })
}

这里用的是「按 id 找位置,再模拟移动」的朴素算法,不是最长递增子序列。对小程序页面这种规模(几十到几百个节点)足够,而且生成的 moves 可以直接被 render 按顺序执行。

6.3 事件:只回传 node id 和原生字段

render 收到真实 DOM 事件后,做两件事:

  1. 归一化 detail(button 的 form 值、input 的 value、scroll-view 的 scrollTop 等);
  2. 发 tree.event,带 node id、事件名、detail、native 字段、touches。

service 收到后:

  1. 按 node id 找到逻辑节点;
  2. 取出带类型的 dataset;
  3. 走 glass-easel 的捕获/冒泡;
  4. 调用页面或组件方法。

这里有一个关键差异:自定义组件的 triggerEvent 完全在 service 逻辑树内部完成捕获和冒泡,不会为了一个自定义事件再走一次 render 往返。只有真实 DOM 事件才需要跨线程。

事件对象里还带 target / currentTarget 的几何信息(offsetLeft / offsetTop / offsetWidth / offsetHeight),因为不少手势库会在 handler 里同步读取这些值。几何信息由 render 在派发时测量并附带,service 不重新计算布局。这里仍有一个已知的近似:嵌套事件的 target 语义还没有完全对齐微信,target 和 currentTarget 目前都会解析到真正绑定 handler 的那个节点。

6.4 查询、相交观测和 canvas

这三类能力都不是「业务消息」,而是「控制面」:

  • query.select:service → host → render,在真实 DOM 里查节点,返回 rect/dataset/scroll;
  • query.intersection:service 把 observe 请求转给 render,entry 再反向回传;
  • canvas.measure:测量 canvas 节点;
  • canvas handoff:render 执行 transferControlToOffscreen(),宿主把 OffscreenCanvas 作为 transferable 转给 service。

canvas 是唯一一条「点对点」链路:移交完成后,service 直接持有控制面,之后的绘制不再产生任何跨线程消息。这也是它和普通消息链路的本质区别。

6.5 成本模型:一次更新到底传了多少东西

把上面的链路放在一起,可以粗略估一下跨线程成本(不含 JSON 序列化/反序列化本身):

操作 消息 量级
首屏挂载 1 条 tree.flow O(节点数) + O(page/component data)
setData 每页每 microtask 最多 1 条 tree.patch O(变更节点) + O(page/component data)
点击/输入 1 条 tree.event O(1)
自定义组件 triggerEvent 0 条 O(0)
selectorQuery 2 次 RPC O(1)
canvas 绘制(handoff 后) 0 条 O(0)
页面导航 若干 control + 1 条 flow O(1) + O(新页)

这里有两个容易被忽略的成本:

  • 首屏 flow 不只有节点,还带 pageData 和 componentData,供 render 侧 WXS descriptor 的 getData 使用;
  • tree.patch 也不只有 patches,同样带一份 page/component data 快照。

所以严格说,setData 的成本不是纯 O(变更节点),而是 O(变更节点) + O(页面 data + 组件 data)。这也是一个明确的优化点:如果 WXS 的 getData 改成按需拉取,或者 render 侧维护增量 data,patch 的体积可以明显下降。当前没有做,是因为它会影响 WXS 的同步语义,需要单独的兼容性验证。

这个成本模型也解释了为什么 triggerEvent 不走跨线程:自定义事件本来就在逻辑树里,走一遍 render 只会白白增加一次往返。


七、setData:一次更新的完整旅程

setData 是小程序里最常被调用的 API,也是最容易出性能问题的地方。Tiny 的链路是这样:

sequenceDiagram participant P as Page/Component participant L as Logic Tree participant B as Bridge participant R as Render Tree P->>L: setData(patch) L->>L: cloneJsonSafe + applyDataPatch L->>L: 执行 observers L->>L: 重新求值模板 L->>L: diff(previous, next) L->>B: tree.patch(microtask 合批) B->>R: applyPatches

逐步拆开:

  1. 写入逻辑树 :cloneJsonSafe 防止外部对象被后续修改影响,applyDataPatch 支持 a.b、list[0] 这样的路径;
  2. 执行 observer :按变更路径匹配 observers 和 property observer,property observer 会收到旧值;
  3. 重新求值模板 :glass-easel 在 service 侧重新计算虚拟 RenderNode;
  4. diff:新旧快照对比,产出 patch;
  5. 合批 :同一页在同一个 microtask 内的多次更新合并成一条 tree.patch;
  6. 应用:render 按顺序执行 patch,不重新求值模板。

几个边界:

  • observer 递归深度上限 20,超过会产生 OBSERVER_RECURSION_LIMIT;
  • observer 抛错会产生 OBSERVER_ERROR,不会影响其他逻辑;
  • render 不保存用于渲染的第二份 data;它会保留一份只读的 pageData/componentData 快照,供 WXS descriptor 的 getData / getState 使用,但权威数据仍在 service;
  • setData 不等待 render 确认,render 也不回 ack。

「不等待 ack」是一个明确的取舍:它让 setData 保持同步语义,也让逻辑层不会被渲染层阻塞。代价是出错时只能靠诊断,而不是返回值。

7.1 组件语义都在逻辑树

能力 实现 关键行为
Behavior normalizeComponentDefinition 嵌套去重、循环检测;执行顺序是「被引用 behavior → 引用者 → 组件」
observers observers / property observer 支持逗号分隔多路径、** 和 .** 通配
relations RelationManager + ComponentTree 双方都声明才会建立;支持 linked / unlinked / linkChanged / linkFailed
selectComponent 逻辑树选择器 默认不穿透组件边界,>>> 可显式穿透
component-export wx://component-export + export() selectComponent 返回 export() 的结果
组件型页面 整理后的组件定义 query 按 property 类型转换,执行 created/attached/ready/detached

relations 的「双方声明」是一个容易踩的坑:父组件声明 child 时,子组件也必须声明对应的 parent,否则关系不会建立。这不是实现偷懒,而是微信本身的契约。

7.2 页面栈:默认 10 层

页面栈由 service 维护,宿主负责开/关 render 页面。默认上限 10 层,可通过 maxPageStack 配置。

一次 navigateTo 的流程是:

  1. service 当前页 onHide;
  2. 创建新 pageId,挂载新页逻辑树;
  3. 新页 onLoad → onShow;
  4. 宿主创建新的 render root,应用新页 flow;
  5. render 挂载完成后,宿主发 page.ready,service 触发 onReady。

navigateBack 则相反:service 移除页面,倒序触发 onHide → onUnload,恢复栈顶页的 onShow;宿主切换可见的 render root。

每页一个 render root 的好处是返回时 DOM、滚动位置、定时器都能保留,代价是内存占用更高。多应用调试器里同时保活 3 个应用,每个应用又可能有多层页面栈,内存压力是真实存在的。

7.3 Taro 3 兼容:一些看起来很怪的补丁

tests/apps/todo 是 Taro 3 的预构建产物,它暴露了不少「框架对运行时的假设」:

  • wx.webpackJsonp 必须作为原始数据属性暴露;
  • 生命周期方法要绑定到页面实例,支持 this.onReady.called 这种写法;
  • 外部 WXS 要同时注册带扩展名和不带扩展名的名字;
  • usingComponents 可能循环引用,需要环检测;
  • input 要转发 focus、blur、keydown 和 confirm,Taro 表单才能按 Enter 提交。

这些兼容逻辑大多在 service 侧,且都有明确的诊断和注释。它们不是「为了让 demo 跑起来」的 hack,而是正式验收的一部分------Taro 3 流程有 6 个浏览器步骤。


八、宿主:同一套语义,三种运行环境

8.1 浏览器:iframe + MessageChannel

浏览器宿主由 @tiny/host-browser 提供:

  • service 是一个隐藏的 0×0 iframe;
  • 每个页面一个 render iframe;
  • 两侧各一条 MessageChannel;
  • 宿主持有 service 连接和每页一条 render 连接,按 pageId 路由;
  • service 和 render 之间没有直连通道。

为什么不让 service 和 render 直连?因为宿主必须同时持有两侧:页面栈在 service,render root 在 host,一次导航要先让 service 创建页面、再让 host 打开 render;首屏消息还可能在 render 尚未创建时先到。直连会把这些协调逻辑撕成两半,页面栈、排队和诊断都没有一个统一的归属。

ts 复制代码
const serviceChannel = createHostMessageChannelPair()
const serviceIframe = createIframe(BROWSER_RUNTIME_NAMES.serviceFrameClass, options.serviceUrl)
await waitForThreadReady(serviceIframe, 'service')
serviceIframe.contentWindow?.postMessage(
  { type: BROWSER_RUNTIME_NAMES.runtimePort, channel: 'service' },
  '*',
  [serviceChannel.peerPort as unknown as Transferable],
)

注意这里的 '*' 和没有 sandbox 的 iframe。这是当前实现的安全债:默认假设同源静态部署,线程侧也没有校验 origin。后面会展开。

渲染侧由 @tiny/host-web-components 提供:

  • createGlassRenderTree 创建 Web 渲染树;
  • 24 个内置组件用真实 tiny-* 自定义元素实现;
  • 内置组件的模板和默认 CSS 在构建期 AOT 生成,产物在 hosts/web-components/src/generated;
  • render 只认 TreeRenderer 契约:mount、applyPatches、applyStyles、query、measure、inspectTree、observeIntersection、dispose。
ts 复制代码
// 节选
export type TreeRenderer = {
  mount: (root: RenderNode) => void
  applyPatches: (patches: RenderPatch[]) => void
  setEventSink?: (sink: (event: TreeEventPayload) => void) => void
  applyStyles?: (styles: string[]) => void
  query?: (selector: string, all: boolean) => unknown[]
  measure?: (ids: string[]) => Record<string, unknown>
  inspectTree?: () => unknown
  observeIntersection?: (request, emit) => (() => void) | undefined
  dispose?: () => void
}

这个契约是跨宿主的真正接口。原生宿主不需要模拟浏览器,只要实现它。

8.2 iOS:JSCore + WKWebView + SwiftUI

iOS 宿主是另一条完整链路:

text 复制代码
SwiftUI Launcher / 原生导航栏 / tabBar
        │
WKWebView(tinyapp://app/www/host.html)
  host shell:createTinyRuntime + 每页 render iframe
        │  WKScriptMessageHandler / callAsyncJavaScript
原生 FrameRelay + WKURLSchemeHandler + UserDefaults
        │  JSON Bridge frames
JavaScriptCore(service thread)
  service-engine.js + __TINY_IOS_APP__
  → bootServiceThread → glass-easel 逻辑树

几个关键决策:

为什么 service 跑在 JSCore? 因为逻辑层不需要 DOM。App/Page/Component、setData、WXML 求值、WXS 表达式、observer/relations/页面栈全部在 JSCore,pnpm check:runtime-purity 仍然通过。

为什么 render 还是 WKWebView? 复用 createGlassRenderEntry 和 createGlassRenderTree,内置组件、样式、布局都不用重写。每个页面一个 iframe,隐藏页保留 DOM、滚动位置、定时器和页级 WXSS。

为什么需要一个 FrameRelay? JSCore 和 WKWebView 不共享内存,只能把 Bridge 帧序列化成 JSON 字符串跨边界。render 侧不需要跨原生,页面 iframe 之间仍然直接使用 MessageChannel。

Canvas 怎么处理? JSCore 没有 OffscreenCanvas,所以 service 侧维护一个同步录制器,把绘制调用批成 display list,再由 render 的 <canvas> 回放。measureText 是唯一不能异步回放的 API(ECharts 在布局时同步调用),通过原生 CoreText 同步返回。

存储怎么处理? 启动时把该 app 的 UserDefaults 快照交给 runtime,运行期 wx.setStorage* 经宿主 API 落盘。key 前缀是 tiny.storage.<appId>.。

当前 iOS 宿主已经跑通 7 个 fixture(demo、mini、todo、wechat-demo、echarts、vant、weui),Canvas 2D 也跑通了 mini 的 ECharts 柱状图。还没接的是 WebGL、同步读回(getImageData / toDataURL / canvasToTempFilePath)和原生网络 API。

8.3 三种宿主对比

宿主 service 容器 render 容器 canvas 存储
tests/host 隐藏 iframe 每页一个 iframe OffscreenCanvas handoff 内存
apps/devtool 隐藏 iframe 每页一个 iframe OffscreenCanvas handoff 内存
iOS JSCore 一个 WKWebView + 每页 iframe service 录制 + render 回放 UserDefaults
runtime 单测 同进程 headless renderer 无 manifest 内联

共享的是:manifest 约定、Bridge 协议、service/render 参数、bootstrap 顺序、page.ready 语义、HostApiRegistry 的默认语义。

各自实现的是:线程容器、渲染后端、canvas、样式/模板/资源加载策略、控制界面。

这就是「内核不依赖宿主」的实际含义:跨端不是把同一份代码跑到三个地方,而是把同一套语义接到三个渲染器上。

8.4 接入一个新宿主需要做什么

按顺序:

  1. 加载并校验 manifest(schemaVersion=2、runtimeCompatibility、产物路径);
  2. 创建 service transport 和 render connector(每页一条连接);
  3. 实现 TreeRenderer;
  4. 启动线程,等连接装好再握手(ready 或同步点,避免丢帧);
  5. 提供宿主信息:systemInfo、storage 快照、apiExtensions、apiHandlers;
  6. 实现导航和 tree.ready;
  7. 收集 diagnostic.*,按需暴露 inspectTree、page.show/hide、关闭语义;
  8. 跑真实测试小程序,至少覆盖 demo / mini / Taro 3 中的一条;
  9. 跑 pnpm check:runtime-purity,确认没有把平台依赖塞回内核。

缺任何一条,都只能证明「能启动」,不能证明「能跑应用」。


九、事件合成器:五个 bug 逼出来的设计

这是整个项目里我最愿意单独讲的一段,因为它很典型:一个看起来只是「转发事件」的模块,最后变成了一个独立的手势识别层。

9.1 原来的三条事件路径

改造前,render 宿主有三套事件来源:

  • 每个节点注册自己的 DOM listener,tap 直接翻译成 click;
  • glass-easel 自己也会从 touch/mouse 序列合成 tap;
  • 内置组件各自长出了 touch* / mouse* / pointer* 分支(hover、touchtrack、picker-view-column、canvas)。

结果是一连串移动端问题:

  1. tap 依赖浏览器的 click 补偿 。页面滚动、preventDefault()、touchend 到 click 之间 DOM 重建,都会让 click 丢掉;
  2. catchtap 不生效 。静态事件路径没有读 binding.catch,冒泡停不下来;
  3. 普通 view 没有 longpress 生产者;
  4. <label> 的一次点击可能产生两次 click,checkbox 被切换两次;
  5. 调试器用鼠标、手机用触摸,走的是两条代码路径,devtool 里正常,真机上炸。

这些问题单看都不难,合在一起就说明:事件不该由每个节点各自「转发」,而应该由一层统一「合成」。

9.2 设计:一个 render 树一个合成器

现在的结构:

text 复制代码
touch ──┐
        ├──► InputEvent 归一化 ──► 手势识别 ──► 注入 glass 树 ──► capture / bubble / catch
mouse ──┘         │
                  └──► mouse 额外投影成 touch 序列(给 hover / touchtrack / bindtouch*)

具体规则:

  • 触摸是主路径,鼠标是调试器模拟手指的方式;
  • 一个手势识别器统一产出 tap(10px 阈值)、longpress(350ms)、canceltap(拖动超过阈值、多指、touchcancel);
  • 鼠标输入额外投影成 touchstart/move/end/cancel,让 hover、touchtrack、内置模板和 bindtouch* 在 devtool 里也走手机路径;
  • 识别出的事件用 Event.triggerEvent 注入 glass 树,捕获、冒泡、composed、catch 全部交给 glass;
  • glass 自己的 tap / canceltap 合成在 backend.onEvent 里被过滤掉,保证「一根手指只产生一个 tap」;
  • render 树不再给每个节点注册 DOM listener,也不再给 tap 映射 click。

核心的 feed 函数很短:

ts 复制代码
const feed = (event: InputEvent): void => {
  if (event.phase === 'start') lastTarget = event.originalEvent.target
  const fromMouse = event.points.some((point) => point.source === 'mouse')
  if (fromMouse) {
    const target = resolveTarget(event.originalEvent)
    if (target) options.onTouch?.(projectTouch(event), target)
  } else {
    lastTouchAt = Date.now()
  }
  recognize(event)
}

还有一个细节:浏览器会在触摸之后补发鼠标事件(click、mousedown 等)。合成器用 lastTouchAt 记录最近一次触摸时间,700ms 内的鼠标事件直接忽略,避免 devtool 的鼠标模拟和真实触摸重复触发。

9.3 catch 怎么过桥

catch 不是 DOM 概念,而是小程序的事件语义。它需要跟着渲染协议走:

  • service 侧把 glass 的 Final / Mut listener 级别映射成 catch / mutated;
  • 渲染协议在节点事件描述里带上 catch;
  • render 的委托监听器收到事件后,对 catch 节点调用 stopPropagation(),对原始事件调用 preventDefault()。

这样 catchtap 才能真的阻止冒泡,而不是「在逻辑层标记一下但 DOM 还在冒」。

9.4 滚动锁:一个容易被忽略的细节

catchtouchmove 在移动端经常用来锁住背景滚动(弹窗、遮罩)。但委托监听器为了滚动性能通常是 passive 的,不能 preventDefault()。

Tiny 的做法是:

  • 真正带 catchtouchmove 的节点额外注册一个节点级、非 passive 的 touchmove 监听;
  • touchstart 时快照内部滚动容器;
  • 让这些容器继续滚,直到它们到达边缘;
  • 只有到达边缘后才 preventDefault()。

这样遮罩能挡住下面的页面,但遮罩里的 scroll-view 不会被冻住。

这是一个很小的实现细节,却决定了「弹窗里的列表能不能滚」这种真实体验。

9.5 收获

  • 事件是合成 出来的,不是转发出来的;
  • 合成层必须同时处理触摸和鼠标,否则调试器和真机就是两个产品;
  • catch、mutated、composed 这些语义要在协议里表达,不能靠 DOM 的默认行为;
  • 这类问题只靠单测很难发现,必须有真实浏览器里的触摸/滚动验收。

十、调试器:让运行时可以被观察

调试器 apps/devtool 不是一个独立实现,它建立在同一套 host 之上:

  • DevtoolRuntimeClient(在 @tiny/host-browser)创建线程、bootstrap、导航、前后台、Reload、Close、inspectTree;
  • React/Redux 只调用 client 方法,订阅它投影出来的状态;
  • 页面栈、生命周期时间线、Console 诊断、Elements 元素树都来自当前会话,不是 mock。

几个约束:

  • 最多同时保活 3 个应用会话,超出后关闭最早打开的;
  • 每个应用默认页面栈上限 10 层;
  • 元素树最多 2000 个节点、最大深度 60、文本截断到 200 字符;
  • Console 最多保留 500 条诊断;
  • 诊断分五类来源:编译、transport、service、render、host。

为什么要在意这些数字?因为调试器本身也会被真实运行时拖垮。一个 117 页的微信示例、一个 Vant 页面、一个 ECharts 页面,真实 DOM 的规模远大于单元测试。限流不是体验问题,是稳定性问题。

在线版本就是同一份构建,可以选 tests/apps 下的应用直接跑:yufji.github.io/tiny/devtoo...。


十一、工程化:把架构约束变成可执行检查

架构文档最容易腐烂的地方,是「写了但没人执行」。Tiny 的做法是把关键约束变成脚本和门禁。

11.1 内核纯净度检查

pnpm check:runtime-purity 从 packages/runtime/src/index.ts 和 packages/wxs-runtime/src/index.ts 出发,遍历相对 import,去掉注释后搜索宿主全局:

text 复制代码
window · document · navigator
localStorage · sessionStorage
HTMLCanvasElement · HTMLImageElement · Image
requestAnimationFrame · cancelAnimationFrame
MessageChannel · MessagePort · postMessage · indexedDB
XMLHttpRequest · WebSocket · fetch · performance
OffscreenCanvas · getComputedStyle · HTMLElement · CustomEvent
devicePixelRatio · process · Buffer · require

其中 require 只在 packages/wxs-runtime 里放行------那是 WXS 的语言级模块加载,不是 Node 的 CommonJS loader。连 process / Buffer 都在黑名单里:内核不应该假设自己跑在 Node 或浏览器里。裸 Element 也会被检查,glassEasel.Element 这种带命名空间的框架类型除外。

同时禁止:

  • 依赖图里出现名字带 browser 的模块;
  • 导入 @tiny/host-*。

packages/* 不能反向依赖 tools/*、apps/*、tests/* 是仓库约定,不完全由这个脚本检查。

这条检查拦住过不少「就这一次」的偷懒。比如某个功能想直接用 performance.now(),正确做法是宿主通过 apiExtensions 注入,而不是让内核去摸全局。

11.2 验收门禁

pnpm accept:glass-easel 是唯一的完整入口:

  1. 构建 10 个目标(css-units、manifest、wxs-runtime、stylesheet-compiler、template-compiler、bridge、cli、runtime、host-web-components、host-browser);
  2. 跑内核纯净度检查;
  3. 跑 9 个包的 288 个单元测试;
  4. 编译 P0 demo,在 Chromium 里跑 24 步;
  5. 跑 mini 的 10 步;
  6. 跑 Taro 3 的 6 步;
  7. 跑 WeChat demo 的 9 步;
  8. 跑调试器的 9 步。

总共 58 个浏览器步骤。每一步都检查页面状态、DOM、计算样式、像素或 console。

浏览器验收不是「打开页面截图」,而是:

  • 点击真实按钮、输入真实文本、按 Enter;
  • 读 window.__tinyRuntime 的 pageStack 和 diagnostics;
  • 检查 storage 写入、toast 调用、导航 query;
  • 检查 canvas 像素;
  • 断言 console error 为 0。

失败时保存截图,并输出页面错误和 console 错误。

11.3 117 页 probe:诊断,不是门禁

pnpm parity:probe 会编译 tests/apps/wechat-demo,在 headless Chrome 里逐页启动,记录 boot 状态、可见文本、页面错误和诊断,写进 parity-report.json。

它是诊断工具,不是门禁。原因是 117 页里有很多页面依赖未实现的 API 或组件,强行当门禁只会让 CI 长期变红。但它能回答一个很重要的问题:这次改动之后,原来能启动的页面还有多少能启动?

11.4 文档即契约

仓库里的文档分三层:

  • docs/adr/:一个决定一篇,Accepted 之后原则上不改写原文,要改就新增一篇;
  • docs/site/:面向读者的长文,站点直接渲染;
  • 各包的 CONTEXT.md:工程事实、边界、不变量。

还有一条规则:不要把「计划」当成「事实」 。docs/next.md 被删掉,就是因为它描述的路线和当前架构相反,却一直挂在 README 上当作现行方案。

11.5 构建产物不入库

tests/host/generated/、hosts/web-components/src/generated/、apps/devtool/static/、ios/Generated/、hosts/ios/dist/ 都是构建输出,gitignore。

仓库只保留源码、约定和测试 fixture。这条规则让「产物和源码不一致」的问题很难发生,也让 clone 之后的第一次构建成为唯一事实来源。


十二、还没做好的地方

写架构文章最容易犯的错,是把「设计」说成「完成」。下面这些是明确的缺口,不是 roadmap 上的漂亮话。

领域 现状
宿主 API 已有 storage、导航、system、toast/modal、canvas、selector query;浏览器宿主另接入了 network.request、clipboard、login 等;媒体、文件、位置、WebSocket 仍不完整
内置组件 24 个;video、map、rich-text、movable-view 等还没实现,已有组件的 open-type、表单提交、无障碍也不完整
页面能力 分享菜单 UI、页面栈恢复、页面转场、自定义 tabBar 还缺;子包只验证了直接启动,没有按需加载
组件高级语义 componentPlaceholder、trait behaviors / chaining API、部分内置 behavior 未实现
性能 启动、首屏、setData、导航、内存、长时运行都没有量化基线,也没有回归门槛
安全 iframe 没有 sandbox;端口转移和 ready 使用 targetOrigin: '*';线程侧不校验 origin
能力协商 握手带 capabilities,但宿主没有注入,也没有降级逻辑
构建 全量构建,没有增量依赖图;dev 模式没有实际差异;没有 HMR
原生渲染树 createNativeTreeRenderer 只有契约和 headless smoke test,没有实际原生宿主

还有一个具体的「临时例外」值得单独说:glass-easel 的命名。

glass-easel@1.1.0 把 wx-host、wx-style-scope 和默认 hostNodeTagName: 'wx-x' 硬编码在包里。hostNodeTagName 可以通过 ComponentSpace.updateComponentOptions 配置,但另外两个 DOM 属性没有公开配置项。

Tiny 的选择是:

  • 不 patch 依赖;
  • 不在 runtime 里观察或改写 DOM 属性;
  • 让 TINY_ENGINE_NAMING.hostAttribute / .styleScopeAttribute 暂时同步为 wx-host / wx-style-scope;
  • 把这个例外写进 docs/known-issues/glass-easel-naming.md,并定义退出条件。

这不是「设计得很好」,而是「知道哪里不干净,并且不让它扩散」。等 glass-easel 提供配置项,或者上游接受 Tiny 命名,就可以把这两个常量切回 tiny-host / tiny-style-scope。


十三、几条经验,以及什么时候不该自己写引擎

13.1 先定协议,再写实现

Bridge 的 ADR 写在实现之前。后面加 tree、query、canvas 域时,信封、错误码、超时、批处理规则都没有变。

协议先行最大的好处不是「优雅」,而是可以并行开发和独立测试:bridge 有 10 个单测,runtime 用内存 transport 跑双线程,宿主用 MessagePort 跑 iframe,三者不需要同时存在。

13.2 把环境赶出内核

packages/runtime 里没有 window、document、fetch、MessageChannel。所有环境能力都通过参数注入:

  • loadTemplates / loadStyle / loadArtifacts;
  • apiExtensions;
  • ServiceCanvasAdapter;
  • TreeRenderer;
  • resolveAssetUrl。

这条线一旦破了,原生宿主就得开始「假装自己是浏览器」。守住它,iOS 宿主才能只写一个 FrameRelay 和一层 canvas 适配。

13.3 用真实应用验收

单测能证明「协议是对的」,但证明不了「小程序能跑」。

所以仓库里放了 7 个 fixture:P0 demo、mini、Taro 3 产物、微信官方示例、ECharts、Vant、WeUI。它们不是假数据,是完整的小程序源码或预构建产物。

Taro 3 的 this.onReady.called、wx.webpackJsonp、循环 usingComponents 这些问题,没有一个是靠读文档能提前想到的。

13.4 诊断是一等公民

编译、transport、service、render、host 五类诊断统一收集,带 code、severity、message、details、pageId、timestamp。

未实现的 API 不会直接抛错,而是返回 undefined 并产生 UNSUPPORTED_API 警告;observer 递归、relation 建立失败、事件 handler 找不到,都有对应的诊断码。

这不是「日志好看」,而是让「页面没反应」这类问题有一个可查询的答案。

13.5 兼容输入与内部命名分离

tiny.config.json 可以配置扩展名、模板指令、API 全局、注册全局、behavior scheme;但产物内部的 tiny-page、tiny-add-button、tiny-scope、tiny-tag-*、tiny-builtin:* 是固定的。

这条规则救过很多次:切换 API namespace 不会改变 DOM 和 CSS scope,第三方微信库只需要加 api.aliases: ["wx"],而不用改渲染层。

13.6 不要过早抽象

第二版架构里,render 侧有一堆 per-tag adapter,试图「用最小的改动」支持内置组件。结果 checkbox-group 这种依赖真实父子关系的组件直接失效。

最后的结论很朴素:内置组件必须是真实组件树里的真实组件。这个抽象不是设计出来的,是被 bug 逼出来的。

13.7 什么时候不该自己写引擎

如果你只是想要「一套代码跑多个平台」,Taro、uni-app 这类编译时框架更合适;它们解决的是「怎么写」,不是「怎么跑」。

如果你需要:

  • 控制运行时(页面栈、生命周期、事件、渲染时机);
  • 接非 Web 宿主(原生、车机、IoT);
  • 做深度调试和诊断;
  • 或者单纯想理解小程序双线程到底怎么工作;

那么自己写一层引擎是值得的。但要有心理准备:引擎的复杂度不在渲染,而在边界。边界定错,后面每加一个能力都要还债。


附:如果想读代码,从这里开始

按这个顺序读,比从入口乱翻快:

想看什么 从哪里开始
编译产物长什么样 tools/cli/src/compiler/project.ts,再看 packages/template-compiler/src/group.ts
一条消息怎么定义、怎么校验 packages/bridge/src/protocol/message.ts
握手、批处理、超时 packages/bridge/src/protocol/connection.ts
首屏 flow 怎么生成 packages/runtime/src/service/thread.ts 里的 treeFlow / sendTreeSnapshot
虚拟树怎么 diff packages/runtime/src/tree/diff.ts
渲染树契约 packages/runtime/src/tree/render/thread.ts
浏览器宿主怎么起线程 hosts/browser/src/standalone-host.ts
Web 内置组件 hosts/web-components/src/render-tree.ts、hosts/web-components/src/components/
事件合成器 hosts/web-components/src/event-compositor/
iOS 宿主 ios/README.md、hosts/ios/src/service/、hosts/ios/src/web/

最后

Tiny 现在还不是一个完整的小程序平台。它没有完整的 API、没有视频/地图组件、没有性能基线,也还没有把 iframe 的安全边界做扎实。

但它已经能做到:

  • 把真实的小程序源码编译成 manifest 和可执行产物;
  • 在 service 逻辑树和宿主渲染树之间,用一份版本化协议同步;
  • 在浏览器、iOS 和测试进程里跑同一套内核;
  • 用 288 个单测、58 个浏览器步骤和 7 个真实小程序守住这些行为。

如果这篇文章只能留下一句话,我希望是:

好的引擎不是「把 DOM 操作藏起来」,而是「把语义放在正确的线程,把选择留给宿主」。

文中的代码片段和数字基于仓库当前 next 分支;接口、消息和统计会随迭代变化,以仓库为准。

如果你也在做小程序容器、跨端运行时或者双线程框架,欢迎在评论区聊聊你踩过的坑。也欢迎指出文中的事实错误。


致谢

本项目基于微信小程序团队开源的 glass-easel 构建: service 线程的逻辑树承载组件注册、模板求值、behavior、observers、relations 和生命周期; render 线程的宿主渲染树复用 glass-easel 的组件树能力,hosts/web-components 提供其中的内置组件实现。

相关推荐
super_Omni1 小时前
Chrome 插件入驻实战:从 Manifest V3 到过审上架的完整路径
大数据·前端·chrome
腾讯数据架构师1 小时前
任务跑完怎么直接出图表?CubeStudio 任务可视化 ECharts 数据格式实操(CSV/JSON + metric.json,11 种图表一键渲染)
前端·json·echarts·数据可视化
小小小米粒2 小时前
不是在构造方法「之前」修饰 Bean、注入;是构造方法跑完之后,才做属性注入,再跑 @PostConstruct。
前端
小李科技2 小时前
【性能优化】事件委托:目标定位与 React/Vue 实战
前端·vue.js·react.js·面试·性能优化·前端框架
QianhangQianping2 小时前
一块大屏的数据在浏览器里怎么变成画面:背景、子屏、前景三层结构
前端·低代码·架构·编辑器·数据可视化·大屏可视化
梦想很大很大2 小时前
让 Codex 自由发挥,给 nvm-desktop 换个 UI
前端·人工智能·设计
蓝莓味的口香糖2 小时前
【VUE】【CSS】Vue移动端适配主流方案总结
前端·css·vue.js
shennv8882 小时前
接入大模型流式输出后,文字偶尔乱码?问题可能出在前端
前端
csj502 小时前
前端基础之《React(16)—Modal组件封装》
前端·react.js