📚 前言
上一篇我们从整体链路理解了微信小程序和小游戏:
1 | 开发者代码 |
这一篇开始详细讲小程序最核心的运行模型:
1 | 双线程架构 |
小程序很多问题都和它有关:
- 为什么不能直接操作 DOM?
- 为什么要用
setData? - 为什么
setData太频繁会卡? - 为什么事件回调和页面渲染不是同一个环境?
- 为什么小程序性能优化总是在强调减少通信成本?
先把一句话放在前面:
小程序不是 JS 直接驱动 DOM,而是逻辑层通过数据通信驱动渲染层。
一、什么是双线程架构
普通 Web 页面里,JS 和 DOM 基本都在浏览器页面环境里。
你可以写:
1 | document.querySelector('.title').textContent = 'hello' |
JS 可以直接找到 DOM,然后修改 DOM。
但小程序不是这样。
小程序大致分为两层:
1 | 逻辑层 AppService |
两层不是同一个线程,也不是同一个 JS 上下文。
可以先画成这样:
1 | ┌──────────────────────┐ |
这就是所谓的双线程。
还有一个容易忽略的细节——两层的数量关系并不对称:
1 | 逻辑层:整个小程序共享「1 个」JS Runtime |
这能顺带解释两件事:为什么页面之间可以通过逻辑层共享数据;以及为什么页面不能无限往下跳(WebView 数量是有上限的)。
一个常见误解:「双线程」不等于「只有两条线程」
“双线程”这个名字很容易让人以为:全局总共只有逻辑、渲染两条线程。其实不是。
它指的是两类角色(两类执行环境),而不是“严格只有两条线程”。真实情况是:
1 | 逻辑层:1 个 JS Runtime(所有页面共享) |
所以如果你打开了 3 个页面,实际是:
1 | 1 个逻辑层 Runtime |
也就是说,至少有 4 个执行环境,而不是 2 个。那为什么还叫“双线程”?因为不管开几个页面,角色永远只有两类:
1 | ┌─────────────────────────────┐ |
- 逻辑层:无论几个页面,始终只有 1 个,所有页面的 JS 都跑在这里。
- 渲染层:是一类角色,但实例数 = 当前页面栈里的页面数,每页一个独立 WebView。
所以“双”指的是“逻辑 vs 渲染”这两类角色的分离,而不是“全局只有两条线程”。也正因为每个页面是独立 WebView(创建有开销、DOM 互相隔离),页面栈才有约 10 层的上限。
二、逻辑层负责什么
逻辑层可以理解为小程序的“业务大脑”。
你写的这些 JS,大部分都运行在逻辑层:
1 | Page({ |
逻辑层主要负责:
- 执行页面 JS
- 执行组件 JS
- 管理页面和组件实例
- 保存
data - 执行生命周期
- 执行事件处理函数
- 调用
wx.request、wx.login、wx.getLocation等 API
但逻辑层不负责直接渲染页面。
它不能像浏览器页面一样:
1 | document.body.appendChild(...) |
因为它拿不到真实 DOM。
它只能通过框架提供的方式,把数据变化告诉渲染层。
这个方式就是:
1 | this.setData(...) |
三、渲染层负责什么
渲染层可以理解为小程序的“显示窗口”。
你写的:
1 | <view class="counter"> |
和:
1 | .counter { |
对应的是:
1 | WXML |
它们工作在渲染层。
渲染层主要负责:
- 把 WXML 结构渲染成页面
- 应用 WXSS 样式
- 展示数据绑定结果
- 捕获用户点击、输入、滚动等事件
- 接收逻辑层传来的数据更新
这里要澄清一个常见误解:渲染层并不是“纯静态、不含 JS”的。它内部同样跑着一套微信注入的 JS(通常叫 WAWebview),负责:
1 | 接收逻辑层发来的数据 |
也就是说,小程序其实有「两个 JS 环境」:逻辑层的 WAService 和渲染层的 WAWebview。逻辑层那套 JS 管业务和数据,渲染层这套 JS 管 diff 和事件捕获。
但渲染层不负责业务逻辑。
比如用户点击按钮:
1 | <button bindtap="handleTap">+1</button> |
点击事件最开始发生在渲染层。
但真正执行 handleTap 的地方,是逻辑层。
所以事件需要跨层传递。
四、Native 层负责什么
在这张图里,中间的 Native 层非常关键:
1 | 逻辑层 AppService |
这里的 Native,可以先理解成:
1 | 微信客户端本身 |
在 iOS 上,它是微信 iOS App 的原生实现。
在 Android 上,它是微信 Android App 的原生实现。
它不是你写的小程序代码,而是微信客户端提供的宿主能力。
Native 层主要负责三类事情。
1. 做逻辑层和渲染层的中转
逻辑层和渲染层不是直接通信的。
它们之间需要经过 Native 层。
比如事件链路:
1 | 用户点击按钮 |
比如视图更新链路:
1 | 逻辑层调用 setData |
所以 Native 层像一个交通枢纽:
1 | View → Native → AppService |
2. 调用系统和微信原生能力
很多能力不是 JS 或 WebView 自己能完成的。
例如:
- 登录
- 支付
- 扫码
- 定位
- 相机
- 蓝牙
- 文件系统
- 剪贴板
- 分享
当你写:
1 | wx.scanCode() |
背后大概是:
1 | JS 调用 wx.scanCode |
所以 wx.xxx API 的本质不是普通 JS 工具函数。
它更像是:
1 | JS 向微信客户端申请调用某个原生能力 |
3. 做权限、安全和平台适配
微信不会让页面随便调用系统能力。
Native 层会负责判断:
1 | 这个小程序有没有权限? |
比如定位、相机、蓝牙、支付,都需要微信客户端控制边界。
同时,Native 层还负责抹平平台差异。
你写:
1 | wx.getLocation() |
不需要关心 iOS 和 Android 底层定位 API 分别怎么调用。
微信 Native 层会在不同平台上分别适配:
1 | iOS → 系统定位能力 |
最后给开发者返回一套统一结果:
1 | { |
这就是平台适配。
4. JSBridge 在这里扮演什么角色
上面一直说「Native 中转」,但真正落到实现上,逻辑层、渲染层和 Native 之间并不是直接互相调用函数,而是通过一套桥接机制传消息。
这套桥接机制通常就叫:
1 | JSBridge |
可以把它理解成 JS 世界和 Native 世界之间的「通信通道 + 消息协议」。
比如逻辑层调用:
1 | wx.scanCode() |
它不是 JS 自己打开摄像头,而是大概经历:
1 | 逻辑层 JS |
再比如 setData:
1 | 逻辑层 setData |
所以 JSBridge 的角色不是「第三个业务层」,而是跨运行环境通信的基础设施。它负责让 JS 能够向 Native 发请求,也让 Native 能够把事件、结果、数据更新回传给 JS 环境。
那它和 Native Bridge 是什么关系?
可以简单区分成:
1 | JSBridge:更偏 JS 侧视角,强调 JS 怎么和 Native 通信 |
很多时候两者说的是同一条桥的两个侧面:JS 侧叫 JSBridge,Native 侧叫 Native Bridge。真正重要的不是名字,而是这件事:
1 | 逻辑层、渲染层、系统能力不在同一个运行环境里 |
在小程序里,这座桥主要承担三类消息:
- 事件消息:渲染层捕获点击、输入、滚动,再转给逻辑层。
- 数据消息:逻辑层
setData,再转给渲染层更新视图。 - 能力调用消息:逻辑层调用
wx.xxx,再交给 Native 执行系统或微信能力。
五、为什么隔离逻辑层和渲染层有利于平台适配
核心原因是:
1 | 开发者面对的是微信定义的一套统一模型 |
如果小程序允许开发者直接操作底层 UI 或完整 WebView DOM,那么很多差异都会暴露出来:
1 | iOS WebView 某个布局行为不同 |
这样开发者就要自己处理大量兼容问题。
而小程序选择暴露:
1 | WXML |
这些是微信定义出来的统一抽象。
于是平台差异可以被压到下面:
1 | 开发者写统一小程序模型 |
比如:
1 | 你写 wx.getLocation |
比如:
1 | 你写 WXML / WXSS |
所以隔离带来的好处是:
1 | 开发者不用直接面对平台底层 |
一句话:
逻辑层和渲染层隔离之后,开发者写的是统一模型;不同平台的差异,由微信 Native 和基础库在底下适配。
当然,这种适配不是没有代价。
它带来的代价就是:
1 | 不能直接操作 DOM |
这就是小程序运行时的核心取舍:
1 | 用受控模型换平台一致性和安全边界 |
六、事件是怎么从页面传到 JS 的
看一个最简单的按钮:
1 | <button bindtap="handleTap">+1</button> |
1 | Page({ |
底层大概是:
1 | 用户点击按钮 |
也就是:
1 | View → Native → AppService |
注意,这里不是:
1 | DOM 事件直接冒泡到 JS |
而是:
1 | 渲染层事件 → Native 中转 → 逻辑层 JS |
这就是为什么小程序事件模型看起来像 Web,但底层不是普通 Web DOM 事件。
七、setData 是怎么更新页面的
事件到了逻辑层以后,通常会修改数据:
1 | Page({ |
setData 做的不是简单赋值。
它大概经历:
1 | 逻辑层执行 setData |
也就是:
1 | AppService → Native → View |
如果把一次点击和一次页面更新连起来,就是:
1 | 用户点击 |
完整链路是:
1 | View → Native → AppService → Native → View |
这条链路就是小程序性能优化的核心。
八、为什么 setData 有成本
现在就能解释一个常见问题:
为什么频繁 setData 会卡?
因为 setData 不是在同一个执行环境里直接改 UI。
它涉及:
- 数据合并
- 数据序列化
- 跨线程通信
- Native 中转
- 渲染层接收
- 视图更新
所以每一次 setData 都有通信成本。
而且这个序列化是「双向收费」的:逻辑层要 JSON.stringify 把数据打包,渲染层收到后还要 JSON.parse 解开。数据越大,两端都越慢——这也是为什么“少传、只传变化字段”这么重要。
顺带补一个实用点:
setData的第二个参数是回调,它会在渲染层真正渲染完成后触发。想拿到“界面已更新”的准确时机,应该用它,而不是setTimeout:
1
2
3 this.setData({ count: 1 }, () => {
// 此时渲染层已经更新完毕
})
如果你这样写:
1 | for (let i = 0; i < 100; i++) { |
就相当于短时间内向渲染层发送很多次更新消息。
更好的方式是:
1 | this.setData({ |
也就是:
1 | 能合并就合并 |
九、setData 的常见性能问题
1. 传太多数据
不推荐:
1 | this.setData({ |
如果 hugeList 很大,每次都整体传输,通信成本会很高。
更好的方式是只更新变化部分:
1 | this.setData({ |
核心原则:
1 | 不要每次把整棵数据树都发给渲染层 |
2. 调用太频繁
比如滚动、拖拽、输入时频繁更新:
1 | onPageScroll(e) { |
如果每一帧都 setData,就容易造成通信拥堵。
应该考虑:
- 节流
- 防抖
- 只在关键状态变化时更新
- 能用 CSS / 原生组件能力解决就不要频繁走 JS
3. 更新不参与渲染的数据
不推荐把所有状态都放进 data。
因为 data 更适合放:
1 | 需要驱动视图渲染的数据 |
如果只是逻辑内部变量,可以放在实例字段上:
1 | Page({ |
不要什么都:
1 | this.setData({ |
十、为什么不能直接操作 DOM
现在再看这个问题:
小程序为什么不能直接操作 DOM?
因为小程序的逻辑层和渲染层是隔离的。
逻辑层没有浏览器页面里的 DOM 对象。
你不能假设:
1 | document.querySelector('.box') |
可以拿到页面节点。
小程序更希望你使用:
1 | 数据驱动 UI |
也就是:
1 | data 变化 |
而不是:
1 | JS 直接拿 DOM |
这让小程序可以:
- 控制运行环境
- 控制安全边界
- 做跨平台适配
- 统一渲染模型
- 限制危险能力
代价是:
1 | 开发者失去了直接操作 DOM 的自由 |
十一、双线程架构带来的好处
双线程不是只有限制。
它也带来一些好处:
不过这里要先澄清一个容易混淆的点:
1 | 双线程不是让每一次调用都更快 |
也就是说,它提升的不是「单次 setData 的速度」,而是整个小程序运行模型的可控性。
1. 安全性更强
开发者代码不会直接接触完整浏览器环境。
微信可以控制哪些能力开放,哪些能力不开放。
更具体地说,逻辑层不能直接操作真实 DOM,渲染层也不能绕过逻辑层随便调用业务能力。所有跨边界行为都要经过 Native / JSBridge,微信才有机会做权限判断、参数校验、平台规则限制和用户授权确认。
2. 更容易跨平台
微信要同时运行在 iOS、Android、Windows、macOS 等环境。
统一的运行模型可以抹平一部分平台差异。
3. 原生能力更容易接入
所有 wx.xxx 能力都可以通过微信客户端统一管理。
比如:
- 登录
- 支付
- 扫码
- 定位
- 文件
- 蓝牙
- 相机
这些都不需要直接暴露底层系统 API 给页面。
4. 渲染和逻辑可以隔离
逻辑层主要处理业务,渲染层主要处理显示。
这种分层能让微信框架更容易管理页面生命周期和通信。
5. 渲染更不容易被业务 JS 拖死
普通 Web 页面里,业务 JS、DOM 操作、样式计算、布局和绘制往往都挤在同一个页面环境里。只要 JS 里有复杂计算、大循环、频繁同步读写 DOM,就可能阻塞页面渲染,表现出来就是点击没反应、滚动卡顿、动画掉帧。
小程序把逻辑层和渲染层拆开后,业务 JS 不再直接控制真实 DOM,也不能随意插入大量同步 DOM 操作。页面更新必须变成数据消息:
1 | 逻辑层改数据 |
这样一来,微信框架可以把页面更新收敛到受控链路里,做数据合并、调度、节流和渲染层 diff。代价是通信变多了,但好处是渲染节奏更可控,不容易被开发者的业务 JS 直接拖垮。
所以这里的性能收益不是:
1 | 通信更少,所以更快 |
而是:
1 | 通信更多,但渲染和业务逻辑互相隔离,整体卡顿风险更可控 |
这也解释了为什么小程序一边采用双线程,一边又反复强调减少 setData:双线程解决的是架构隔离问题,不代表跨线程通信没有成本。
十二、双线程架构带来的代价
当然,它也有明显代价。
1. 通信成本
逻辑层和渲染层之间不能直接共享数据。
所有更新都要经过通信。
这就是 setData 成本的来源。
2. 调试心智更复杂
普通 Web 出问题时,可能只需要看 DOM 和 JS。
小程序出问题时,需要想清楚:
1 | 是逻辑层数据错了? |
3. 框架封装会增加一层心智
如果再叠加 uni-app、Taro:
1 | Vue / React 源码 |
问题定位会再多一层。
所以使用跨端框架时,更要理解底层链路。
十三、uni-app / Taro 下的 setData 心智
用 uni-app 或 Taro 时,你可能不会直接写很多 this.setData。
比如 uni-app 里更像 Vue:
1 | <template> |
Taro 里更像 React:
1 | function Page() { |
但在微信小程序端,最终还是要把状态变化同步给小程序渲染层。
也就是说:
1 | Vue / React 状态变化 |
所以你不直接写 setData,不代表底层没有类似成本。
跨端框架只是帮你隐藏了一部分细节。
底层约束仍然存在:
- 状态更新太频繁仍然可能卡
- 渲染节点太多仍然会慢
- 大数据更新仍然有通信成本
- 平台能力仍然受微信宿主限制
十四、怎么判断问题出在哪一层
遇到小程序页面卡顿或更新异常,可以按这个顺序排查:
1 | 1. 数据是否真的变化了? |
如果用链路图表示:
1 | 业务代码 |
你要做的是沿着这条链路向下定位。
不要一上来就只怀疑:
1 | 是不是微信抽风? |
当然它们可能有 bug。
但更常见的问题,是我们没有意识到:
1 | 上层写法最终还是要落到小程序双线程模型里 |
十五、新趋势:Skyline 渲染引擎
前面整篇都基于一个前提:渲染层 = WebView。这是小程序最初、也是目前默认的渲染方式。但要知道,微信已经推出了新的渲染引擎——Skyline。
它和 WebView 模式最大的区别是:
1 | WebView 模式:渲染层是 WebView,用浏览器内核渲染 WXML / WXSS |
Skyline 的目标是让渲染更接近原生——更流畅的动画、更低的内存开销。
但关键要记住一点:
双线程模型本身没有变。 逻辑层(JS、数据)和渲染层(显示)依旧是分离的,依旧通过通信驱动渲染。变的只是渲染层的「实现」——从 WebView 换成了 Skyline。
所以本篇讲的 setData 通信链路、数据驱动、不能直接操作 DOM 这些核心心智,在 Skyline 下同样成立。Skyline 优化的是“渲染层怎么把画面画出来”,而不是“双线程怎么通信”这件事本身。
Worklet:给双线程开一条「绿色通道」
双线程有个天生的死穴:动画和手势对帧率极其敏感,但每一帧都要走 渲染层 → Native → 逻辑层 → setData → Native → 渲染层 这一大圈。60fps 就是每秒 60 次跨线程通信,根本跟不上手指。
1 | 传统:手指拖动 → 渲染层捕获 → Native → 逻辑层算位置 → setData → Native → 渲染层 |
Skyline 用 Worklet 解决这个问题。它实现的效果是:让一小段 JS 直接跑在渲染线程上,动画/手势不再回逻辑层。
1 | Skyline:手指拖动 → 渲染线程上的 worklet 直接算 + 直接更新画面 |
它具体怎么做到的:
- 用
'worklet'指令标记函数:被标记的函数会被单独抽取,能在渲染线程的 JS 环境里执行(这套设计和 React Native 的 Reanimated 几乎一致)。 - 用「共享值」(SharedValue) 跨线程传数据:共享值两个线程都能读写,UI 线程直接改它来驱动动画,全程不碰逻辑层。
- 需要时再用
runOnJS回逻辑层:比如手势结束、要发请求时,才跨一次线程通知业务逻辑。
所以 Worklet 不是推翻双线程,而是给它开了条绿色通道:绝大部分业务逻辑仍在逻辑层,只有对帧率敏感的动画/手势被下放到渲染线程,绕开通信瓶颈。
代价:需要渐进迁移
Skyline 不是开个开关就免费用:
- 需主动开启:在
app.json配"renderer": "skyline",可按页面选择。 - CSS / 组件有限制:不支持全部 CSS(布局更偏原生,默认 flex 等行为不同),部分老组件要换成 Skyline 适配版。
- 有基础库版本要求:低版本微信会回退到 WebView。
所以微信支持两种模式混用——新页面用 Skyline、老页面留 WebView,逐步迁移。
🧠 一句话总结
小程序双线程架构的核心是:
1 | 逻辑层负责 JS 和数据 |
所以页面更新不是:
1 | JS 直接改 DOM |
而是:
1 | JS 更新数据 |
理解这条链路,就能解释:
- 为什么不能直接操作 DOM
- 为什么
setData有成本 - 为什么频繁更新会卡
- 为什么跨端框架仍然绕不开小程序底层模型
下一篇可以继续讲:
1 | JSBridge 和 wx API:小程序如何调用微信原生能力 |