小程序引擎最反直觉的地方在于:真正难的不是把 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 页 |
如果只想带走四个结论:
- 两棵树:逻辑和渲染分开,语义留在 service,render 只负责画;
- 一份协议:Bridge 只做传输,握手、批处理、错误和 JSON 边界都是显式的;
- 一次编译:WXML 在构建期变成 ProcGen,运行时不再解析模板;
- 多个宿主:浏览器、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
真正把这几层粘起来的不是某个函数调用,而是两份契约:
- manifest:编译器和运行时之间的产物契约,当前 schema 版本是 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() 是一次全量构建,按顺序做十一件事:
- 解析项目根目录和
miniprogramRoot; - 按兼容配置扫描脚本、模板、样式、模板脚本和配置;
- 读取
app.json,解析主包和分包页面; - 解析
tiny.config.json的compatibility; - 解析每个页面/组件的入口(文件入口或目录入口);
- 递归查找
usingComponents,记录全局/局部来源; - 从入口沿
import/require收集可达脚本,转译 TS,修正相对模块路径; - 把所有模板放进同一个
TmplGroup,处理import/include/template、WXS、作用域标记和 animation; - 编译 WXSS:加作用域、rpx 转 vw、内联
@import; - 复制图片和字体资源;
- 汇总 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 负责记住哪些表达式需要重新求值。
好处有三个:
- 运行时不再需要 WXML parser,产物更小、启动更快;
- 模板错误在构建期暴露,而不是等到用户点开页面;
- 表达式可以被静态分析,
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 的回复
握手分两步:
- 宿主发
runtime.hello,携带协议版本、角色和 capabilities; - 对端回复 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 先解决竞态
浏览器宿主的启动顺序里有一个真实的竞态:
- 宿主创建 service iframe;
- iframe 里的线程加载 bundle,安装端口监听;
- 宿主把
MessagePort转给线程; - 线程开始收消息。
如果第 3 步发生在第 2 步之前,端口消息到达时线程还没有监听器,第一帧就丢了。
解决办法不是加延时,而是让线程主动说「我准备好了」:线程先做完宿主初始化(service 侧装 canvas compat,render 侧跑 prepare 装内置样式),再安装端口监听并发出 tiny-runtime-port-ready;宿主收到后才转移端口,最多等 15 秒。端口到达后再创建 connection,做 hello/ready 握手。
这个顺序后来被证明是整个启动链路里最值得写进文档的一条。
5.2 一次 bootstrap 的完整时序
几个容易忽略的点:
- 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' },
]
这组消息做三件事:
define把组件定义带到 render(componentId、options、properties 等渲染所需元数据);create-node/props/children按稳定 id 建出真实组件树;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 事件后,做两件事:
- 归一化 detail(button 的 form 值、input 的 value、scroll-view 的 scrollTop 等);
- 发
tree.event,带 node id、事件名、detail、native 字段、touches。
service 收到后:
- 按 node id 找到逻辑节点;
- 取出带类型的 dataset;
- 走 glass-easel 的捕获/冒泡;
- 调用页面或组件方法。
这里有一个关键差异:自定义组件的 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 的链路是这样:
逐步拆开:
- 写入逻辑树 :
cloneJsonSafe防止外部对象被后续修改影响,applyDataPatch支持a.b、list[0]这样的路径; - 执行 observer :按变更路径匹配
observers和 property observer,property observer 会收到旧值; - 重新求值模板 :glass-easel 在 service 侧重新计算虚拟
RenderNode; - diff:新旧快照对比,产出 patch;
- 合批 :同一页在同一个 microtask 内的多次更新合并成一条
tree.patch; - 应用: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 的流程是:
- service 当前页
onHide; - 创建新 pageId,挂载新页逻辑树;
- 新页
onLoad → onShow; - 宿主创建新的 render root,应用新页 flow;
- 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 接入一个新宿主需要做什么
按顺序:
- 加载并校验 manifest(
schemaVersion=2、runtimeCompatibility、产物路径); - 创建 service transport 和 render connector(每页一条连接);
- 实现
TreeRenderer; - 启动线程,等连接装好再握手(ready 或同步点,避免丢帧);
- 提供宿主信息:
systemInfo、storage 快照、apiExtensions、apiHandlers; - 实现导航和
tree.ready; - 收集
diagnostic.*,按需暴露inspectTree、page.show/hide、关闭语义; - 跑真实测试小程序,至少覆盖 demo / mini / Taro 3 中的一条;
- 跑
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)。
结果是一连串移动端问题:
tap依赖浏览器的 click 补偿 。页面滚动、preventDefault()、touchend到click之间 DOM 重建,都会让 click 丢掉;catchtap不生效 。静态事件路径没有读binding.catch,冒泡停不下来;- 普通 view 没有
longpress生产者; <label>的一次点击可能产生两次 click,checkbox 被切换两次;- 调试器用鼠标、手机用触摸,走的是两条代码路径,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/Mutlistener 级别映射成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 是唯一的完整入口:
- 构建 10 个目标(css-units、manifest、wxs-runtime、stylesheet-compiler、template-compiler、bridge、cli、runtime、host-web-components、host-browser);
- 跑内核纯净度检查;
- 跑 9 个包的 288 个单元测试;
- 编译 P0 demo,在 Chromium 里跑 24 步;
- 跑 mini 的 10 步;
- 跑 Taro 3 的 6 步;
- 跑 WeChat demo 的 9 步;
- 跑调试器的 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 提供其中的内置组件实现。