小程序不是小号的网页
它的坑几乎都来自同一个前提:没有 DOM,逻辑和渲染在两个线程。理解了这条,setData 为什么慢、为什么不能直接改 data、为什么原生组件的层级那么怪,都不用单独记了。
01先立住双线程模型所有性能和安全问题都能从这里推出来,别单独背结论。
02跨端框架看产物问"编译期还是运行时",答案就定了性能、能力、包体积三个取舍。
03区分三套生命周期页面、组件、应用各一套,答错顺序就是没写过。
1
双线程架构:为什么 setData 是性能关键★
一次 setData,跨了两条线程
小程序把逻辑和渲染拆到两个线程,中间隔着 Native 层转发。所以任何一次数据更新都不是"改个变量",而是序列化 + 跨线程通信 + 反序列化 + diff + 更新 DOM。setData 慢,慢在这条链路的长度,不在数据本身。
◐ 可交互:数据量一变,setData 的成本差多少
列表条数100
每条字节数80
拖动滑块,看三种写法的差距怎么被放大。数值是数量级示意,不是实测。
一次更新要多久(毫秒)
逻辑层(JSCore / V8)
- 跑你的 JS:Page、App、业务逻辑
- 没有 DOM,没有 window / document
- 没法直接操作视图
渲染层(WebView)
- 跑 WXML + WXSS,本质是一个被裁剪的 WebView
- 负责最终绘制
- 不能执行你的业务 JS
setData(obj)逻辑层收到调用
→
序列化JSON 化后通过 Native 转发
→
渲染层收到反序列化,合并进 data
→
diff + patch对比新旧 WXML 树,算出最小改动
→
绘制改真实 DOM,屏幕更新
- 为什么不能直接改 this.data 就生效:
this.data.count = 1只改了逻辑层的内存,渲染层那份副本不知道,界面不动。必须走 setData 这条路。 - 通信是异步的:setData 回调触发时,渲染层已经更新完。所以在 setData 之后立刻读 DOM 尺寸,可能拿到旧值。
- 数据量有硬上限:单次 setData 的数据不超过 1024KB,超了直接报错。大数组一次性塞进去是常见事故。
面试怎么答小程序是双线程的,逻辑层跑在 JSCore 或 V8 里,没有 DOM,渲染层是 WebView。两边的数据不共享,setData 实际上是序列化后跨线程发到渲染层,再 diff WXML 树去更新。所以它的成本跟数据量强相关,这也是为什么优化的核心是"少传、传得小、合并传"。
2
WXML 编译成什么
它不是 HTML,也不是模板引擎
WXML 在构建期被编译成一段 JS 函数,运行时靠它生成虚拟节点树。所以
wx:if 这类指令最终是 if 分支,{{ }} 是取值表达式,跟你写的模板字符串是两套东西。编译产物大致长这样(简化)function render(context) {
return {
tag: 'view', attr: { class: 'card' }, children: [
context.loading
? { tag: 'text', children: ['加载中'] }
: {
tag: 'block',
children: [
{ tag: 'text', attr: { class: 'title' }, children: [context.item.title] },
...context.tags.map(item => ({ tag: 'text', children: [item] }))
]
}
]
};
}
- wx:key 的作用和 React 的 key 一样:不写 key 时,列表更新只能按下标复用节点,中间插入一项会导致后面所有项的数据全被重写。
- wx:for 的 item / index 默认名可改:
wx:for-item="row" wx:for-index="i",嵌套循环时必须改,否则内外层同名冲突。 - block 不渲染成节点:它只是编译期的包裹,用来在不多加一层
view的前提下控制一组节点。
面试怎么答WXML 在构建时被编译成生成虚拟节点的函数,运行时执行它得到一棵树,再跟上次的树 diff,最后只把差异应用到渲染层的真实 DOM 上。所以 wx:if 是编译出来的分支,wx:for 是 map,wx:key 决定 diff 时节点能不能复用。
3
setData 的三条优化★
同一份数据,能不能只传变化的那一小块
优化就三件事:只传变化的部分(用路径写法)、合并调用(别在循环里 setData)、控制频率(滚动、输入这类高频事件要节流)。三条都不涉及"减少数据量"以外的技巧,因为瓶颈就是传输量。
- 下标的写法是字符串:
['list[3].checked']是计算属性名,不是数组字面量。写成{list: [{checked: true}]}会覆盖整个数组。 - 不要在 setData 里传函数或 undefined:序列化会丢掉 function,undefined 也被忽略,排查时非常隐蔽。
- 长列表的根治办法是虚拟列表:只渲染可视区那几十条,配
scroll-view加空白占位。传的数据量跟总条数解耦,这才是能扛住几千条的做法。
面试怎么答setData 的成本主要在跨线程传输和反序列化,所以优化分三层:能用路径写法只传变化项就别传整个对象;能把多次合并成一次就别在循环里调;高频事件用节流压频率。如果列表很长,那就上虚拟列表,让传输量跟总条数无关。
4
生命周期的执行顺序★
页面、组件、应用三套,顺序容易答反
面试常让你说"从 A 页跳到 B 页,两边的生命周期怎么走"。记住一句话:先走完当前页的 onHide,再走新页的 onLoad → onShow → onReady。返回时反过来,且旧页不会重新 onLoad。
A.onHide跳转前,当前页隐藏
→
B.onLoad新页加载,拿到路由参数
→
B.onShow新页显示
→
B.onReady新页首次渲染完成,可调 wx.createSelectorQuery
几个容易记混的对应关系onLoad 只执行一次,等于 Vue 的 created + 拿参数
onShow 每次显示都执行(含从后台切回、从 B 返回)
onReady 首次渲染完成,只一次;等于 Vue 的 mounted
onHide 跳到别页 / 切后台
onUnload 页面被销毁(redirectTo、navigateBack 返回时)
组件 attached ≈ created,但此时还不能拿节点
组件 ready ≈ mounted,可以拿节点
- 从 B 返回 A 时,A 只走 onShow,不走 onLoad。很多人把"刷新列表"写在 onLoad 里,结果返回后数据不更新,就是栽在这。
- onShow 也会在切后台再回前台时触发,如果里面做了埋点上报,要能接受重复上报。
- onReady 之后才能用选择器查节点:
wx.createSelectorQuery()在 onLoad 里调拿不到尺寸。
面试怎么答打开 B 页时,A 先 onHide,然后 B 依次 onLoad、onShow、onReady。从 B 返回 A 时,B 走 onUnload,A 只走 onShow 不走 onLoad。所以依赖页面显示时刷新的逻辑要放在 onShow 里,放在 onLoad 里返回后不会执行。
5
自定义组件与样式隔离
为什么我的样式串到组件里了
自定义组件的样式默认是隔离的:外面的 class 选择器进不去,里面的也出不来。但
app.wxss 里的全局样式能影响组件,这是最常被误解的一点。三种样式隔离模式isolated 默认。内外互不影响(但 app.wxss 的标签选择器仍可能命中)
apply-shared 外部样式可以影响组件内部,反之不行
shared 双向影响,两边互通
- 想让父级的样式进来:组件 options 里设
styleIsolation: 'apply-shared',或者用外部样式类externalClasses。 - 组件的 slot 内容归属哪边:slot 里的节点在父级的样式作用域里,用的是父级样式,不是组件内部的样式。
- behavior 用来复用逻辑:多个组件共享 methods / data / 生命周期时抽 behavior,跟 Vue 的 mixin 是同一个思路,也同样有"来源不清"的问题。
面试怎么答自定义组件默认是 isolated 样式隔离,外部选择器进不去,组件内部样式也不会外泄,但 app.wxss 的全局样式仍然可能命中组件内的标签。需要父级样式进入组件就设 apply-shared,或者用 externalClasses 暴露外部类名。逻辑复用抽 behavior,跟 Vue 的 mixin 同源,注意命名冲突。
6
路由与页面栈
十层上限,和五种跳转的区别
小程序没有"路由表",页面栈就是一个最多 10 层的栈。
navigateTo 压栈、navigateBack 出栈,redirectTo 替换栈顶,switchTab 清空栈只留 tab 页。选错方法就会出现"返回不了"或"页面越点越深"。五种跳转的行为差异wx.navigateTo 压栈,可带参数,不能跳 tabBar 页;超过 10 层失败
wx.redirectTo 替换当前页(出栈再入栈),当前页 onUnload
wx.switchTab 只能跳 tabBar 页,且会清空其它页面栈
wx.reLaunch 关闭所有页面,只留目标页
wx.navigateBack 出栈,可指定 delta 层数
参数与通信传参:url 上带 query,目标页 onLoad(options) 取
大数据:不要塞 url(长度和编码都有限制),用全局变量或 EventChannel
EventChannel:navigateTo 的 events + success 里拿 eventChannel,适合"从子页回传结果"
回流:getCurrentPages() 拿页面栈,直接调上一页的方法(能跑,但耦合高,慎用)
面试怎么答小程序用页面栈管理路由,最多 10 层。打开新页用 navigateTo 压栈,替换当前页用 redirectTo,跳 tab 页必须用 switchTab 并且会清掉栈,需要重新从首页开始用 reLaunch。参数走 url query,参数多或对象复杂就改用全局状态或 EventChannel,别硬塞 url。
7
分包加载★
为什么你的主包总是超 2MB
主包上限 2MB,所有包合计 20MB,单个分包也是 2MB。分包的意义不是"能装更多",而是首启只下载主包。所以判断标准很清晰:首屏用不到的一律挪进分包。
- 分包之间不能互相引用:A 包的页面不能 import B 包的 js、不能用 B 包的组件。公共代码只能在主包,或者抽成独立分包。
- 独立分包(
independent: true)可以脱离主包单独运行,适合"活动页"这种从分享直接进入的场景,首屏不用等主包下载。 - preloadRule 是预下载:进主包时顺手把某个分包拉下来,减少跳转时的白屏。注意它只在 wifi 或指定网络下生效。
- 主包里的"公共组件"最容易撑爆:一整库的组件都放主包,哪怕首屏只用了一个。按需引入、或把重组件放分包。
面试怎么答主包限 2MB、总包 20MB,所以要按"首屏是否需要"来分。分包之间不能互相引用,公共依赖只能放主包或独立分包。跳转白屏用 preloadRule 预下载解决,独立分包用于分享直达的活动页,能让首屏不等主包。
8
登录流程:为什么前端拿不到 openid★
这里答错,安全题就废了
整个流程的关键是 code 只能用一次、session_key 绝不能下发到前端。
wx.login 拿到的 code 交给自己的后端,后端用 appid + secret 换 openid,再把自定义的 token 返回给前端。任何"前端直接换 openid"的方案都是把 secret 暴露在客户端。wx.login()前端拿到临时 code
→
发给自己的后端code 上传,不带任何密钥
→
code2session后端用 appid + secret 换 openid / session_key
→
签发 token后端生成自己的登录态
→
前端存 token后续请求带在 header 里
- code 是一次性的,5 分钟有效,用过就废。所以不能拿 code 当登录态长期用,必须换成自己的 token。
- session_key 用来解密敏感数据(手机号、加密用户信息),一旦下发到前端就等于泄露,任何拿到它的人都能解密。
- 手机号快速验证:新版是
getPhoneNumber返回 code,后端用它调接口换手机号,前端拿不到明文,这个改动很多人还不知道。
面试怎么答前端 wx.login 拿到临时 code 传给自己的后端,后端用 appid 和 secret 调 code2session 换到 openid 和 session_key,然后签发自己的 token 返回。session_key 绝对不能下发到前端,因为它能解密手机号等敏感数据。code 是一次性的,所以必须换成长效 token。
9
uni-app 怎么做到一套代码多端★
编译期转换,不是运行时模拟
uni-app 走的是编译期路线:同一份 Vue 代码,编译到小程序时产出 WXML/WXSS,编译到 H5 时产出普通 Vue 应用,编译到 App 时走原生渲染。所以它的性能接近原生小程序,因为产物就是原生语法,运行时没有额外的中间层。
条件编译的几个平台标识MP-WEIXIN 微信小程序
MP-ALIPAY 支付宝小程序
MP 所有小程序
H5 浏览器
APP-PLUS App(iOS / Android)
APP-PLUS-NVUE App 的 nvue 原生渲染
- 条件编译是编译期删代码,不是运行时 if 判断。所以没命中的分支不会打进产物,这点对包体积很关键。
- App 端不是 WebView:默认用原生渲染,所以没有 DOM,jQuery 那套直接失效;要 WebView 得用
renderjs或直接编译成 H5 嵌进去。 - 各端差异要靠"平台判断"兜底:支付、分享、摄像头这些 API 三端签名不同,uni 封装了一层,但覆盖不全,复杂功能还是得条件编译。
面试怎么答uni-app 是编译期的方案,同一份 Vue 代码分别编译成小程序的 WXML/WXSS、H5 的普通 Vue 应用、App 的原生渲染。因为产物就是各端原生语法,运行时没有额外中间层,所以性能比运行时方案好。跨端差异用条件编译处理,它是编译期删代码,不会增加包体积。
10
uni-app 的生命周期对应关系
Vue 的钩子和小程序的钩子同时存在
uni-app 里两套生命周期并存:Vue 的(created / mounted)和小程序页面的(onLoad / onShow / onReady)。不要混着用,尤其别把"显示时刷新"写进 mounted —— 它只执行一次。
两套钩子的执行顺序(页面首次进入)beforeCreate → created
onLoad(options) ← 小程序钩子,拿路由参数
onShow
beforeMount → mounted
onReady ← 渲染完成,可查节点
再次显示(从别的页返回):
onShow ← 只有这个会走
- 要拿路由参数用 onLoad,Vue 的 created 里拿不到 options(H5 端可以拿 $route,但小程序端不行,所以统一用 onLoad)。
- 要刷数据用 onShow:从详情页返回列表,mounted 不会再跑,onLoad 也不会,只有 onShow。
- 组件的 attached / ready 对应 created / mounted,Vue 组件生命周期在小程序端也被映射过去了,但只在页面级组件可靠。
面试怎么答uni-app 同时有小程序页面生命周期和 Vue 生命周期。首次进入的顺序是 created、onLoad、onShow、mounted、onReady。要拿路由参数用 onLoad,要在页面重新显示时刷新用 onShow,因为从别的页面返回只触发 onShow,mounted 和 onLoad 都不会再执行。
11
Taro 的编译原理:三条路线★
编译期还是运行时,决定了一切取舍
Taro 1/2 是编译期:把 JSX 直接转成 WXML,产物是纯小程序代码,性能好但 React 能力受限。Taro 3 之后改成运行时:在小程序里跑一份 React,用它维护一棵 DOM 树,再映射到小程序的节点上。好处是 React 生态全能用,代价是体积和一层开销。
编译期(Taro 1/2)
- JSX 静态转成 WXML,产物接近原生
- 性能最好,包体最小
- 不能用完整 React:hooks、context 受限
- 动态逻辑难表达,很多写法要绕
运行时(Taro 3+)
- 小程序里跑真正的 React 运行时
- 维护一棵 Taro DOM 树,再同步到视图层
- React 的一切都能用:hooks、context、生态库
- 代价:包体更大,更新多一层映射
Taro 3 的运行时到底做了什么你的 React 代码 → React Reconciler → Taro DOM(模拟的节点树)
↓
转成 setData 的增量 → 小程序视图层
也就是说:React 的 diff 发生在 Taro DOM 上,
最后还是要通过 setData 到原生节点。所以 setData 的优化依然重要。
- 为什么 Taro 3 能直接用 React 生态:因为它真的在跑 React,只是把渲染目标换成了小程序节点,而不是浏览器 DOM。
- 包体积是主要代价:React 运行时本身就要占掉一部分 2MB 主包空间,小项目里这个比例很明显。
- Taro 4 又往回走了一点:部分场景支持编译优化,减少运行时代价,但主体仍是运行时方案。
面试怎么答Taro 早期是编译期方案,JSX 直接转 WXML,性能好但 React 能力受限。Taro 3 改成运行时,在小程序里跑真实的 React,用 Taro DOM 模拟节点树,再把增量通过 setData 同步到视图层,所以能用完整的 React 生态,代价是包体积和一层映射开销。
12
原生 / uni-app / Taro 怎么选★
选型题考的是判断依据,不是排名
没有普适最优解,只有匹配度。判断顺序:是否要 App → 团队技术栈 → 是否需要深度用小程序能力 → 包体积压力。把这个顺序讲出来,比背一张对比表有说服力。
原生:能力最全,成本最高
- 所有新 API 第一时间可用(同层渲染、Skyline)
- 性能可控,包体最省
- 一套代码只能跑微信(多端要重写)
- 适合:只做微信、对性能敏感、要用最新能力
跨端框架:一套代码多端
- uni-app:Vue 技术栈,编译期,App 端成熟
- Taro:React 技术栈,运行时,生态好
- 新 API 支持有滞后,复杂能力要写原生插件
- 适合:多端投放、团队已有 Web 技术栈
- 要上 App 优先 uni-app:它的 App 端(原生渲染 + 插件市场)比 Taro 成熟得多,这是最实际的一条。
- 团队是 React 就 Taro,是 Vue 就 uni-app:跨端框架的学习成本不在语法,在踩坑和生态,用熟悉的那套能省大量时间。
- 只用微信且要新能力,别用框架:Skyline、同层渲染的新特性,框架适配总是慢半拍,为跨端付出的复杂度就白费了。
- 包体积紧张时倾向编译期方案:运行时框架本身占主包,2MB 的红线下这个开销会被放大。
面试怎么答选型看四件事:要不要 App、团队是 React 还是 Vue、是否需要马上用小程序新能力、主包体积是否紧张。要 App 且团队用 Vue 就 uni-app,团队是 React 就 Taro。只做微信又要用 Skyline 这类新能力,就用原生,别为了跨端加一层复杂度。
13
rpx、样式隔离与同层渲染
三个几乎每场都问的细节
rpx 是按 750 设计稿等比缩放的单位;样式隔离决定组件边界能不能穿透;同层渲染解决原生组件盖住一切的问题。三个都不是"背结论",而是要知道什么时候会踩。
rpx 的换算750rpx = 屏幕宽度(任何机型)
iPhone 6(375px 宽):1rpx = 0.5px
iPhone 14 Pro Max(430px 宽):1rpx ≈ 0.573px
所以:设计稿 750 宽,量多少写多少 rpx,这是它唯一正确的用法。
设计稿 375 宽时不要直接写 rpx,会整体缩小一半。
- 1px 边框用 1rpx 会时粗时细:不同机型换算出来的物理像素不一样,稳妥做法是用
transform: scaleY(0.5)或者干脆接受 1px。 - 原生组件会盖住一切:
video、map、canvas曾经是原生层级,z-index 对它们无效。现在支持同层渲染,但老基础库仍会出问题,弹窗盖不住视频就是典型症状。 - 同层渲染不是所有组件都支持:
input、textarea的层级问题更顽固,通常要靠cover-view或者干脆改用view模拟。
面试怎么答rpx 以 750 为基准等比缩放,所以 750 宽的设计稿可以直接按量到的数值写。原生组件曾经层级最高,z-index 无效,现在多数支持同层渲染,但 input 和 textarea 仍容易出问题,弹窗盖不住输入框就是典型场景,要单独处理。
14
速答清单
十句话,覆盖这一份的主干
01小程序为什么是双线程?
逻辑层跑 JS 没有 DOM,渲染层是 WebView 负责绘制,两边通过 Native 转发通信。隔离带来安全(不能直接操作 DOM)和稳定(逻辑层崩了不影响渲染),代价是 setData 的跨线程成本。
02setData 慢在哪?
序列化 + 跨线程传输 + 反序列化 + WXML 树 diff,成本跟数据量正相关。优化就是只传变化项、合并调用、压频率,长列表上虚拟列表。
03wx:key 不写会怎样?
列表 diff 只能按下标复用节点,中间插入或删除会导致后续节点数据整体重写,表现为状态错乱或性能骤降。
04页面栈最多几层?
10 层。navigateTo 压栈,超过就失败;switchTab 会清空栈只留 tab 页;reLaunch 关掉所有页面。
05主包和分包的限制?
主包 2MB,单个分包 2MB,总包 20MB。分包之间不能互相引用,公共依赖放主包。用 preloadRule 预下载减少跳转白屏。
06登录为什么不能前端换 openid?
因为要用 appid + secret,secret 放在客户端等于公开。正确流程是 code 给后端,后端换 openid 和 session_key,只把自定义 token 返回前端。
07session_key 为什么不能下发?
它能解密手机号等加密数据,一旦到前端,任何拿到它的人都能解密。必须留在服务端会话里。
08uni-app 和 Taro 的本质区别?
uni-app 是编译期,产物就是各端原生语法,性能接近原生;Taro 3 是运行时,在小程序里跑真 React,能力全但多一层映射和包体开销。
09为什么从详情页返回列表页,数据不刷新?
因为返回只触发 onShow,不走 onLoad 和 mounted。刷新逻辑要放 onShow。
10原生组件盖住弹窗怎么解决?
多数组件已支持同层渲染,用真机和高版本基础库确认;input / textarea 仍顽固,用 cover-view 或改用 view 模拟,必要时先 hideKeyboard 再显示弹窗。