Skip to content

小程序双线程模型 ​

看在前面 ​

小程序的架构对后端工程师有个反直觉的事实:它看起来像 Web,运行模型却像 CS 架构。逻辑层是"客户端",渲染层是"服务端",中间靠序列化消息通信。所有小程序的性能坑(setData 卡、长列表卡、点击响应慢),根因都在这条"进程间序列化通道"上。

素材来自 star-site-app(uni-app 编译到微信原生小程序,dist/build/mp-weixin/ 下就是 wxml/wxss/js/json 原生产物——19 个页面、tabBar 两个入口)。框架(uni-app)只是编译期的皮,跑起来就是原生小程序双线程。风格对齐 typescript/从Java视角看TypeScript-开篇,前端缓存与包体优化见 前端性能优化体系。

双线程架构:渲染与逻辑的物理隔离 ​

面试官:小程序为什么设计成双线程?和浏览器单线程模型差在哪?

┌─────────────── 渲染层(WXML/WXSS)───────────────┐
│  WebView #1(页面A)   WebView #2(页面B)  ...    │   每个页面一个 WebView,
│  只管渲染,没有业务 JS                              │   有 DOM、无逻辑代码
└──────────────────────┬───────────────────────────┘
                       │  JS Bridge(Native 中转,消息必须序列化)
┌──────────────────────┴───────────────────────────┐
│  逻辑层(App Service,独立 JsCore/V8 进程)        │   全局唯一,跑你的业务 JS,
│  无 DOM、无 window,setData 是唯一"作图指令"       │   不能 document.getElement..
└──────────────────────┬───────────────────────────┘
                       │
                微信客户端(Native):登录/支付/位置等原生能力

三个要点。一是渲染层每页一个 WebView,逻辑层全局一个进程。二是两层之间没有共享内存,一切交流走 Native 中转的序列化消息。三是逻辑层没有 DOM/BOM——想改界面只有一条路:setData 把数据发过去,渲染层拿数据套 WXML 模板。

这么设计的官方动机有两条。一是管控:不给逻辑层 DOM 和动态执行(eval/new Function 禁用、页面跳转白名单化)。微信掌握审核权,这是和开放 Web 的本质区别。二是性能:业务 JS 卡死也不阻塞渲染线程(理想情况下),页面间渲染互相隔离。代价就是通信成本,下一节展开。

追问:这架构像后端什么?

像把渲染做成了瘦客户端 + 集中式服务。渲染层是无状态的"终端",只执行指令。逻辑层是"应用服务器",状态全在这。setData 是两者间的 RPC,且参数必须可序列化(JSON)——函数、类实例、循环引用都过不去。所以小程序里"传个回调给渲染层"根本不可能,这不是 bug 是架构。

setData:唯一的"作图指令",也是最大瓶颈 ​

面试官:为什么频繁 setData 会卡?怎么优化?

每次 setData 的完整链路:逻辑层序列化数据 → Native 中转 → 渲染层反序列化 → diff 合并到 data → 重渲染。它不是函数调用,是跨进程消息 + 一次 React 式渲染,成本模型完全不同:

js
// 原生小程序写法
this.setData({ list })           // 整个 list 序列化过桥
// uni-app/Vue 写法:ref 赋值 → 运行时帮你调 setData
todos.value = up                  // demo2 首页这样写,编译后仍是 setData

优化纪律四条,每条都对着成本模型:

① 降频:高频数据(滚动位置/倒计时)合并提交,或走 selectorQuery
        直接读渲染层状态,不过桥
② 降量:只传变化的子路径
        setData({ 'list[2].status': 'done' })   // 路径写法只序列化一个字段
        而不是 setData({ list: wholeNewList })
③ 数据与展示分离:data 只放 WXML 用到的字段,
        私有/中间变量挂 this 或模块级变量,永不过桥
④ 不把 setData 当事件总线:纯逻辑层状态变化别碰它

一个量化直觉:data 里的一个字段从 1 KB 涨到 100 KB,每次提交的序列化+反序列化+diff 成本线性上涨。长列表"全量回传"是首屏后掉帧的头号元凶。虚拟列表(recycle-view/uvirtual list)从源头限制"在渲染层存在的节点数",就是治这个。

追问:Vue 开发者在 uni-app 里怎么不知不觉踩坑?

ref.value = newArr 这句在 Web 是内存赋值,在小程序是全量序列化过桥——同一个心智模型,成本模型天差地别。uni-app 的优化(编译期静态分析模板依赖、diff 后精准 setData)能兜掉大半。但"整个大数组整体替换"这种写法依然会退化成大包传输。框架抹平了语法,抹不平物理。

生命周期:页面栈上的状态机 ​

App:  onLaunch ──▶ onShow ⇄ onHide(全局一次 launch,热启动只走 show)
Page: onLoad ──▶ onShow ──▶ onReady ──▶ onHide ⇄ onShow ──▶ onUnload
      只一次      可多次     首渲染完成    切后台/跳走   回来      出栈销毁

关键区分:onLoad 一次(取路由参数放这),onShow 每次可见都触发(刷新数据放这),onReady 后才能拿节点尺寸。demo2 首页的组合式写法正是按这个纪律落位(star-site-app-demo2/src/pages/index/index.vue):

ts
onShow(async () => {            // 每次回到首页都刷新:登录态校验 + 数据刷新
  if (getToken()) { refresh(); return }
  const ok = await silentWxLogin()
  if (ok) refresh()
  else uni.reLaunch({ url: '/pages/login/login' })
})

首页把登录态兜底放 onShow 而非 onLoad——因为从登录页 reLaunch 回来,首页会重新加载;但从别的 tab 切回来只走 onShow。"刷新类逻辑放 onShow、初始化放 onLoad" 这条纪律,等价于后端区分"构造"与"每次请求都要做的校验"。

分包:把 2M 主包限制变成架构问题 ​

面试官:主包超 2M 怎么办?分包怎么做?

微信限制:主包 ≤ 2M(可配分包异步化后单包上限有提升),总包 ≤ 30M 级别;tabBar 页面和公共资源必须在主包。分包把低频页面拆出去,用户进到分包页面才下载:

json
// app.json(star-site-app 编译产物当前 19 页全在主包,分包示例)
{
  "pages": ["pages/index/index", "pages/mine/mine"],
  "subPackages": [
    { "root": "pkg-health", "pages": ["report/report", "trends/trends"] }
  ],
  "preloadRule": { "pages/index/index": { "network": "all", "packages": ["pkg-health"] } }
}

star-site-app 目前 19 个页面全量在主包——首页/我的两个 tab 加十几个功能页,是典型的可分包结构。健康档案、复查、孕产这类低频域拆成分包,配合 preloadRule 在用户停留首页时预取。主包瘦身与首开速度兼得。这和后端"单体按域拆模块、按需加载"是同一次重构:拆的是加载边界,不是代码组织。再补一句 skyline/渲染引擎演进(微信新一代渲染引擎绕开 WebView,逻辑层接口兼容)作为追问储备即可。别展开。

登录态:code 换 token,小程序版的 OAuth2 ​

双线程模型下连登录都是分布式时序。wx.login 拿的 code 只能交给后端换 session,前端全程摸不到密钥,与 OAuth2 授权码模式同构:

逻辑层                微信服务器              自己的后端
  │ wx.login()            │                     │
  │────临时 code────────▶│                     │
  │◀───code──────────────│                     │
  │──────────POST /login {code}──────────────▶ │
  │                      │──code2Session──────▶│
  │                      │◀─openid+session_key─│
  │◀─────────自家 token(JWT)───────────────── │   session_key 留后端,
  │  token 存 storage,后续请求带 Authorization   │  前端只见自家 token

demo2 首页 silentWxLogin() 做的就是这条链的静默版。onShow 里发现无 token 就自动走一遍,失败才 reLaunch 到登录页兜底(真实代码见上文生命周期小节)。两个高频坑。一是 code 一次性且约 5 分钟过期,不能缓存复用。二是 session_key 绝不能下发前端(微信明令禁止),解密手机号等操作必须走后端。对照后端的"客户端凭证换访问令牌",一张时序图讲完,前后端频道立刻对齐。

长列表与骨架屏:渲染预算的最后两招 ​

setData 优化到极限后,剩下的渲染瓶颈交给两个模式。虚拟列表:只把视口内/附近的条目写进 data,滚动时增删窗口(原生 recycle-view 或组件库虚拟滚动)。节点数从 O(n) 压到 O(视口),与 PC 端虚拟化同源。骨架屏:数据未到先渲染结构占位,既压 CLS(见性能篇)又给用户"正在加载"的确定性。双线程下的额外收益:骨架屏的静态结构可随页面包直接渲染,首帧不过逻辑层桥。等于把"白屏时间"换成了"线框时间"。

uni-app 视角:编译期的翻译器 ​

star-site-app 用 Vue3 + TS 写,dist/build/mp-weixin 里看到的已是编译产物。模板翻成 wxml、<script setup> 编成 JS、Vue 运行时整包在 common/vendor.js 里(app.js 第一行 require("./common/vendor.js") 可考)。框架价值 = 用 Vue 心智写小程序 + 编译期裁剪多端。框架代价 = vendor 运行时占体积、响应式到 setData 的映射层损耗。选型判断一句话:团队栈统一优先,性能极限场景回归原生 WXML。

面试官视角小结:30 秒版本 ​

  1. 架构:渲染层多 WebView + 逻辑层单进程,Bridge 序列化通信。动机是管控(无 DOM/禁 eval)+ 隔离性能。本质是瘦客户端 + 集中服务的 CS 模型。
  2. setData:跨进程 RPC 不是赋值。优化 = 降频(合并)、降量(子路径)、数据展示分离、别当事件总线。
  3. 生命周期:onLoad 一次取参、onShow 每次可见刷新、onReady 才有节点。刷新放 onShow 等价于"每次请求都校验"。
  4. 分包:主包 2M 硬限,tabBar 留主包、低频域拆分包 + preloadRule 预取;拆的是加载边界。
  5. uni-app:编译期翻译器,Vue 心态写、原生双线程跑;代价是 vendor 体积与映射层损耗。

开场模板:

"我把小程序当 CS 架构看:渲染层是无状态终端,逻辑层是应用服务器,setData 是必须 JSON 序列化的 RPC。所以它的性能优化我全按'降序列化成本'来推:降频、降量、分包控主包。"

2026-10 新增(F 轨前端 · 首批)。