靠编译器加运行时两部分配合,这是回答这题的核心框架。
编译期做的是把 Vue 单文件组件拆分并转译成各平台的原生格式。以微信小程序为例,一个 .vue 文件会被拆成 wxml、wxss、js、json 四个文件:template 部分按小程序的模板语法重写(v-if 变成 wx:if,v-for 变成 wx:for),style 转成 wxss 并处理单位,script 里的组件选项映射成小程序的 Page 或 Component 结构。这一步是静态转换,所以性能损耗很小。
运行时负责补齐编译期做不到的事:一是抹平 API 差异,你调 uni.request,运行时判断当前平台再转成 wx.request 或者 my.request;二是提供 Vue 的运行时能力,因为小程序原生没有 Vue 的响应式和组件通信机制,需要一层适配把 Vue 的数据变更映射成小程序的 setData;三是统一生命周期,把 Vue 的生命周期和小程序的生命周期对应起来。
不同平台的侧重不一样。H5 端基本就是标准 Vue 项目,运行时最薄。小程序端编译占主要工作量。App 端则是把 Vue 代码跑在内置的 JS 引擎里,通过原生渲染层展示。
理解这个架构最实际的价值是知道问题该往哪查:样式和模板不生效多半是编译转换的限制(比如小程序不支持某些选择器),API 行为不一致要查运行时的平台适配,性能问题要看具体端的渲染机制。
setData 是性能瓶颈?★★★小程序把逻辑层和渲染层分成两个线程:逻辑层跑在 JSCore(iOS)或 V8(Android)里执行你的 JS,渲染层用 WebView 渲染界面。两者不能直接通信,必须经过原生层(Native)转发。
这么设计主要是为了安全和管控:逻辑层没有 DOM 和 BOM,拿不到 window 和 document,也就无法操作页面、无法动态执行代码、无法跳出小程序的沙箱。副作用是所有界面更新都必须跨线程传递。
setData 就是这个跨线程通信的唯一通道,它的开销包含三步:把数据序列化成字符串(JSON.stringify)、经原生层转发到渲染层、渲染层反序列化并触发 diff 和重渲染。所以它慢的根源不是渲染,而是序列化和跨线程传输。
由此得出几条明确的优化规则。只传变化的数据,不要把整个大对象整体 setData,用路径写法精确更新,比如 this.setData({'list[0].checked': true}),而不是把整个 list 传一遍。不要传无关数据,data 里不参与渲染的字段应该放在普通属性上,因为 data 里的东西都会参与序列化。合并调用,避免在循环里反复 setData,攒起来一次性提交。控制数据量,长列表分页加载而不是一次塞几千条。
uni-app 在这块做了自动优化:它在运行时对比 Vue 的新旧数据,只把 diff 出来的差异传给 setData,所以你正常写 Vue 的响应式代码,框架帮你规避了大部分粗糙的整体更新。但如果你直接操作了很大的数据结构,该有的开销还是有,所以数据结构设计上仍要注意。
条件编译本质是编译期的代码裁剪。写法是特殊格式的注释,比如 #ifdef MP-WEIXIN 到 #endif,编译到微信小程序时保留这段,编译到其他平台时直接从产物里删掉。所以它不是运行时判断,被裁掉的代码压根不会进包,既不影响体积也没有运行时开销。
它可以用在 js、template、style、pages.json 甚至整个文件和目录上(按平台命名的目录会被整体裁剪)。这是它比运行时判断平台更优的地方——运行时判断的话,所有平台的代码都得打进包里。
处理跨端差异我的分层做法是这样。能抹平的就封装掉:把有差异的能力包成统一的工具方法,条件编译写在方法内部,业务代码只调统一接口,这样差异被收敿在一个地方,将来加新平台只改一处。API 差异优先用 uni 的统一接口,只有 uni 没覆盖的能力才自己写条件编译。样式差异尽量用兼容写法,比如用 flex 而不是依赖某些端才支持的属性。
常见的差异点值得知道:小程序不支持部分 CSS 选择器(通配符、属性选择器、标签选择器在自定义组件里受限);小程序没有 DOM 操作,需要用 uni.createSelectorQuery 异步获取节点信息;页面栈层数限制(微信是 10 层);本地存储容量限制;登录鉴权流程完全不同;App 端有 nvue 的额外限制。
最后一条实践建议:不要幻想完全一套代码不做任何适配。跨端框架解决的是「大部分代码复用」,剩下百分之十几的平台特性差异必须正面处理。真实项目里把这部分差异管理好,比追求百分之百复用要现实得多。
web-view 怎么和小程序通信,限制在哪?★★★先说结论:小程序和 web-view 里的 H5 做不到实时双向通信,这是平台机制的硬限制,不是能力没做完。
小程序传给 H5 只有一条路:把参数拼在 url 上,H5 自己解析 query。想在 H5 已经加载后再推数据过去,只能改 url 触发重新加载,或者让 H5 主动去服务端拉。
H5 传给小程序用 JSSDK 的 wx.miniProgram.postMessage,但关键在于它不是即时送达的。消息会被暂存,只在几个特定时机才触发小程序侧 web-view 组件的 message 事件:小程序后退、组件被销毁、用户分享、复制链接。也就是说 H5 里 postMessage 之后,小程序当场收不到,得等到用户离开这个页面。
这么设计是因为双线程架构和安全隔离——H5 跑在一个独立的 WebView 里,平台不希望它能随意驱动小程序逻辑层。
所以需要「H5 里点一下,小程序立刻响应」的场景,只能绕过去:用 wx.miniProgram.navigateTo 带上参数直接跳回小程序页面,这个是即时生效的,也是最常用的做法;或者走服务端中转,H5 写入后端,小程序侧拉取。
其他要记住的限制:域名必须配在业务域名白名单里并且要把校验文件放到服务器根目录,个人主体的小程序不能用 web-view,H5 要调小程序能力必须引入官方 JSSDK。
还有个跨端差异值得提:App 端的 web-view 能力强得多,可以用 evalJS 直接执行 H5 里的脚本,实现真正的双向实时通信。所以同一套 H5 嵌入逻辑在小程序和 App 上行为完全不同,这块必须条件编译分开写。
最直接的差别是页面生命周期不能再写在选项里,要从 @dcloudio/uni-app 导入后在 setup 里调用:
import { onLoad, onShow, onReachBottom } from '@dcloudio/uni-app'
onLoad((query) => { /* 拿路由参数 */ })
onReachBottom(() => { /* 触底加载 */ })
组件生命周期还是 Vue3 那套 onMounted、onUnmounted。这个区分很多人一开始会搞混,把 onLoad 当组件钩子写在选项里,结果压根不触发。
几个不能用的旧写法:$children 被移除,filters 被移除(改用计算属性或方法),实例上的 $on / $off / $emit 事件 API 被移除,所以 Vue2 里拿 this.$on 做事件总线的写法失效了,要改用 uni.$on 或者引 mitt。状态管理从 Vuex 换成 Pinia。<script setup> 支持,但要注意用了它之后组件内部默认是封闭的,父组件要通过 ref 调子组件方法必须 defineExpose 暴露出来。
响应式底层从 Object.defineProperty 换成了 Proxy,好处是新增属性和数组下标赋值都能被侦听,不再需要 $set。但要清楚小程序端最终还是要走 setData,框架把 Proxy 收集到的变更做 diff 之后再提交,所以数据结构设计上该注意的还是要注意。
工程上 Vue3 版本用 Vite 构建,冷启动和热更新比 Vue2 的 webpack 快很多,这是升级最直接的体感收益。
支持度上 H5 和小程序基本完整,App 端需要较新的版本,nvue 对 Vue3 的支持相对滞后。所以新项目我会直接上 Vue3 加 Vite 加 Pinia;老项目迁移的主要成本就在生命周期写法和被移除的那几个 API 上。
vue 页面在 App 端是跑在 WebView 里的,渲染方式和 H5 一样,所以 CSS 支持完整、写法灵活、生态组件都能用。缺点是 WebView 渲染性能有上限,长列表滚动、复杂动画会比原生差,而且首次加载 WebView 有开销。
nvue 页面用的是 Weex 那套原生渲染,界面由原生控件绘制,所以滚动和动画性能接近原生。代价很明确:CSS 支持大幅受限,只支持 flex 布局,不支持很多常见属性(部分选择器、display 的其他值、级联选择器都不行),组件也只能用 nvue 支持的那一套,很多第三方 Vue 组件库直接不能用。所以 nvue 的开发体验和调试成本明显更高。
选择策略是按页面而不是按项目选,两者可以在一个项目里混用,互相跳转,通过 uni.$emit 或者 storage 通信。具体判断:需要原生级滚动性能的页面用 nvue,典型是超长信息流列表、需要复杂手势的页面;其他页面用 vue,因为开发效率高得多。
还有个混合方案是 subNVue:在 vue 页面上叠加一层原生子窗体,用来做那些 WebView 里性能不好或者层级压不住的部分,比如原生的弹窗、悬浮播放器、需要盖在视频上的控件(WebView 元素盖不住原生视频)。
要注意 nvue 和 vue 的样式和组件行为并不完全一致,同一个组件在两种页面里表现可能有差别,所以不要指望一个组件写好后随便放。实践中我倾向默认全用 vue,只有明确遇到性能问题时才把那个页面改成 nvue,而不是一开始就为了性能选 nvue,那样开发成本会失控。
最本质的区别是没有 JS 引擎了。传统 uni-app 在 App 端是把 JS 跑在内置引擎里,再通过桥接去操作原生渲染,所以存在「JS 执行加通信桥」这一层开销。uni-app x 的做法是把代码直接编译成平台原生语言——Android 编译成 Kotlin、iOS 编译成 Swift,运行时就是纯原生应用,没有 JS 引擎也没有桥。
为此引入了两个新东西:UTS(uni type script),一门 TypeScript 语法风格的强类型语言,因为要编译成 Kotlin 和 Swift,就必须有严格的类型信息,JS 那种动态类型没法直接映射;UVUE,用 Vue 的语法写界面,但编译成原生的界面代码。
带来的收益是性能接近原生:启动更快、内存占用更低、长列表和动画流畅度明显改善,也不再需要为了性能去用 nvue 那套受限的方案。
代价也很实际。强类型意味着写法约束更多,很多 JS 的灵活写法(随意改变量类型、动态属性、部分动态特性)不能用,老项目迁移不是改改就行。生态需要重建,大量基于 JS 的插件和组件库不能直接用,得用 UTS 重写。能力覆盖需要时间,虽然它也支持编译到 Web 和小程序,但各端的完整度和成熟度是逐步补齐的。
所以我的判断是:新项目、对 App 性能要求高、团队能接受强类型约束的可以上;存量项目和以小程序为主的项目用传统 uni-app 更稳妥。面试时能把「为什么要重做」讲清楚(就是要去掉 JS 引擎和通信桥这层开销)比记住名词更重要。
应用级在 App.vue 里:onLaunch 初始化时触发一次,onShow 应用启动或从后台进入前台,onHide 进入后台,还有 onError。
页面级:onLoad(页面加载,能拿到路由参数,只触发一次)、onShow(页面显示,每次进入都触发)、onReady(首次渲染完成,只触发一次)、onHide、onUnload。还有交互相关的 onPullDownRefresh、onReachBottom、onShareAppMessage、onPageScroll。
组件级就是 Vue 的那套:created、mounted、updated、destroyed(Vue 3 里是 onMounted 这些)。注意组件里没有页面生命周期,组件想感知页面显示隐藏,Vue 2 里可以用 onShow 需要特殊处理,通常做法是页面通过 props 或者事件通知组件。
首次进入页面的顺序是:onLoad → onShow → 组件 created → 组件 mounted → 页面 onReady。关键理解是 onLoad 和 onShow 在渲染之前,而 onReady 在渲染完成之后。
由此产生几条实践规则。获取页面元素信息必须在 onReady 之后,在 onLoad 里调 createSelectorQuery 拿不到节点,因为还没渲染。接口请求放 onLoad,能和渲染并行,比放 onReady 更快。需要每次进入都刷新的数据放 onShow,比如从详情页返回列表页要刷新状态,放 onLoad 就只会执行一次。onUnload 里清理定时器和事件监听,否则页面关了逻辑还在跑。
navigateTo 保留当前页面、打开新页面,页面栈加一层,可以返回。redirectTo 关闭当前页面再打开新页面,栈层数不变,返回时回到上一层。reLaunch 关闭所有页面打开新页面,栈被清空重置。switchTab 跳转到 tabBar 页面,并且会关闭所有非 tabBar 页面。navigateBack 返回,可以指定返回几层。
关键限制是页面栈最多 10 层(微信小程序),超过之后 navigateTo 就不生效了,而且往往不报明显的错误,表现为「点了没反应」,这是个很典型的线上问题。最容易触发的场景是列表页和详情页互相跳:详情页里点相关推荐又打开一个详情页,用户点几次就满了。
处理办法几种。同类页面互跳用 redirectTo,详情页跳详情页不需要保留上一个详情页,这是最直接的解法。跳转前判断栈深度,用 getCurrentPages() 拿到当前页面栈,接近上限就改用 redirect。封装统一的跳转方法,把这个判断收敿到一处,业务代码只调封装后的方法,这是我实际会做的——把栈管理逻辑散落到各个页面里迟早会漏。回到首页用 reLaunch 或 switchTab,顺手清栈。
还有几个相关的点:switchTab 不能带参数(url 上的 query 会被忽略),要传数据得用全局状态或者 storage。跳转的 url 有长度限制,传大对象要么用状态管理要么用 storage,不要拼在 url 上。返回时给上一页传数据,Vue 2 里常用 getCurrentPages() 直接改上一页实例的数据,或者用 uni.$emit 事件,后者更规范。
父传子用 props,子传父用 $emit,这是首选,数据流清晰。父组件调子组件方法用 ref,但要注意小程序端 ref 的支持和 H5 有差异,跨层级的 ref 尽量别用。
跨层级或者无关联组件之间,有几种方式。uni.$emit 和 uni.$on 是全局事件总线,简单直接,但问题很明显:必须记得在 onUnload 里 $off,不然页面反复进出会重复注册,一个事件触发多次回调,这是最常见的坑;而且事件名是字符串,全局散落着难以维护,数据流向不可追踪。所以我只在简单的一次性通知场景用它,比如「支付成功了刷新一下列表」。
globalData(挂在 App 实例上)适合存少量全局配置,但它不是响应式的,改了界面不会自动更新,只能作为普通数据容器。
Vuex 或 Pinia 是正规方案,响应式、有明确的修改入口、可追踪、可调试。Vue 3 项目直接用 Pinia,写法更简洁也没有 mutation 那套样板代码。适合用户信息、登录态、购物车这类多个页面共享且需要响应式的状态。
uni.setStorage 用于需要持久化的数据,比如 token、用户偏好。注意它有容量限制(微信单个 key 1MB、总共 10MB),别拿它当缓存数据库用。同步版本 getStorageSync 会阻塞,频繁调用有性能影响,启动时读一堆 storage 会拖慢首屏。
我的选择顺序是:能用 props 和 emit 就用它们,跨页面共享状态用 Pinia,需要持久化的用 storage 并在启动时同步到 Pinia,全局事件总线只用于简单通知。混用多种方式管理同一份状态是最容易出 bug 的做法。
三个东西的作用完全不同,混淆了会导致账号体系设计错误。
openid 是用户在单个应用内的唯一标识——同一个人在你的小程序和你的 App 里,openid 是不同的。所以只用 openid 做用户主键,用户换个端进来就变成新用户了。
unionid 是用户在同一个开放平台账号下所有应用里的统一标识。只要你的小程序、公众号、App 都绑定在同一个开放平台账号下,同一个人拿到的 unionid 就是一样的。所以多端账号打通必须靠 unionid,这是唯一正确的做法。
session_key 是会话密钥,用来解密敏感数据和校验数据签名。它绝对不能下发给前端——泄露了别人就能伪造用户数据。它有有效期,而且用户重新登录会导致旧的失效,所以要处理「解密失败就引导重新登录」的分支。
登录流程:前端 uni.login 拿到临时 code(只能用一次、五分钟过期)→ 传给自己的后端 → 后端用 code 加 appid 加 secret 去换 openid、unionid、session_key(secret 只能在服务端,这是安全底线)→ 后端建立或关联用户记录 → 签发自己的 token 返回。
账号体系的设计要点:用户表主键用自己的 user_id,另建一张「第三方身份表」存 unionid、各端的 openid、以及其他登录方式(手机号、邮箱、Apple ID)。unionid 作为关联的桥梁。这样一个用户可以有多个登录身份,将来加新端或新登录方式不用改主表结构。
要注意 unionid 不是总能拿到。如果小程序没有绑定开放平台账号,就只有 openid。所以要处理这个降级情况:先按 unionid 找用户,拿不到 unionid 就按 openid 找,后续补上绑定后要能合并账号——这个合并逻辑很麻烦(两个账号各有订单和余额怎么处理),所以最好一开始就配好开放平台绑定。
手机号获取也是常见需求:新版本要用 getPhoneNumber 的按钮授权,后端拿 code 去换手机号(老的靠 session_key 解密的方式已经废弃了)。注意这是付费接口,而且国际业务里非中国大陆手机号可能拿不到,要有备选的注册路径。
看起来简单,但实际有一堆边界情况,写不好会出现重复加载、数据错乱、加载不动。
两种实现路径要先分清:用页面级的 onPullDownRefresh 和 onReachBottom(需要在 pages.json 里开 enablePullDownRefresh),这是首选,性能最好、体验最接近原生;或者用 scroll-view 的 scrolltolower 和 refresher 属性,适合列表只占页面一部分的情况。注意 scroll-view 必须有确定的高度,否则滚动和触底事件都不触发,这是最常见的「为什么没反应」。
状态管理是核心,至少要维护四个状态:page(当前页码或游标)、loading(是否正在请求)、hasMore(是否还有更多)、isEmpty(是否空列表)。
必须处理的坑:
并发触发。onReachBottom 在快速滑动时会连续触发多次,如果不加锁就会同时发出多个请求,导致同一页数据被追加多次。所以进来第一件事就是 if (loading || !hasMore) return`。而且 loading 必须在 finally 里复位——漏了这一步的表现是「请求失败一次之后再也加载不了了」,这个 bug 非常常见。
下拉刷新要重置状态。页码归 1、hasMore 归 true、并且要替换而不是追加数据。还要取消正在进行的加载更多请求,否则旧请求返回后会把刷新的数据又追加一遍,造成重复。
必须手动停止下拉动画。uni.stopPullDownRefresh() 要在 finally 里调,失败了也得停,否则转圈一直不停用户以为卡死了。
判断还有没有更多:不要用「返回数组长度是否等于 pageSize」——最后一页刚好是整数倍时会多请求一次(虽然不致命)。更可靠的是后端返回 total 或者 hasMore 标记。
分页方式的选择:用 page 加 size 的传统分页,在数据频繁变动时会重复或漏数据(前面插入了新数据会导致后一页的开头和前一页末尾重复)。所以信息流类的列表要用游标分页(传上一页最后一条的 ID 或时间戳)。这是设计层面的选择,比修补前端逻辑更根本。
体验细节:要有空状态、加载中、加载失败可重试、没有更多了这四种提示;失败要能重试而不是卡在加载中;列表很长时要配合虚拟列表并同时裁剪数据数组(只保留窗口附近的数据),否则刷了几百条之后内存和渲染都会出问题。
rpx 是响应式像素,规则是把屏幕宽度固定看成 750rpx,然后按实际屏幕宽度等比换算。所以设计稿按 750 宽度出图时,量出来多少 px 就写多少 rpx,非常省事。换算是编译期加运行时配合完成的。
要注意几个实际问题。大屏上会过度放大,因为它是纯等比缩放,在 iPad 或者折叠屏展开时,按 750 换算出来的元素会变得很大,文字尤其明显。解法是字体用 px 或者做尺寸上限控制,pages.json 里可以配 rpxCalcMaxDeviceWidth 限制换算的最大宽度。1rpx 的边框在某些设备上会消失或者不均匀,因为换算后不足一个物理像素,细线建议用 0.5px 配合 transform: scale 或者用 1px。
样式限制方面,小程序端比较多。不支持通配符 * 选择器,所以常见的全局 reset 写法用不了。自定义组件默认有样式隔离(styleIsolation),外部样式进不去、内部样式出不来,想穿透要用 ::v-deep 或者配置隔离选项,但要注意各小程序平台的支持程度不同。不支持部分伪类和属性选择器。不能用标签选择器影响自定义组件内部。
还有层级问题:小程序里 video、map、canvas、input 这些是原生组件,它们的层级永远在普通组件之上,z-index 压不住。想在视频上盖东西必须用 cover-view 和 cover-image,而这两个组件支持的样式和嵌套能力很有限。App 的 nvue 端也有类似问题,解法是 subNVue。这类问题在做播放器、地图选点这些功能时一定会遇到。
video、map 这些组件为什么 z-index 压不住?★★因为它们是原生组件,不是 WebView 里的 DOM 元素。小程序的普通组件由渲染层的 WebView 绘制,而 video、map、canvas、textarea、live-player 这些是由客户端原生层直接绘制并浮在 WebView 之上的。两者压根不在同一个渲染体系里,所以 CSS 的 z-index 完全管不到——它只能排列同一层内的元素。
这个认知很重要:它说明小程序的界面不是单一渲染体系,而是 WebView 层加原生层的叠加。理解了这一点,很多「样式莫名失效」的问题就能预判。
解法有三种。一是 cover-view 和 cover-image,这两个也是原生组件,专门用来覆盖在原生组件之上。但限制很多:只支持有限的样式(不支持背景图、部分 flex 特性),嵌套层级受限,事件支持也不完整,复杂的浮层做起来很痛苦。
二是同层渲染(same layer rendering),这是平台后来提供的方案,把原生组件真正渲染进 WebView 的层级里,这样 z-index 就正常生效了,也能直接用普通组件覆盖。video 和 map 在较新的基础库上已经支持,但要注意基础库版本兼容,而且 iOS 和 Android 的实现和表现有差异,做兼容时要真机分别验证。
三是从交互设计上绕开,比如弹窗出现前先把视频隐藏或暂停。这个方案最土但最稳,兼容性问题最少,实际项目里用得不少。
App 端有对应的问题:nvue 的原生渲染和 WebView 混排时层级同样压不住,解法是用 subNVue 原生子窗体。
先定位卡在哪:是数据量导致的 setData 开销,还是节点数量导致的渲染压力,还是滚动时的频繁通信。三者的解法完全不同。
数据层面:分页加载是基础,一次不要超过二三十条。追加数据时不要把整个数组重新赋值,那等于把已有的几百条重新序列化一遍传过去,正确做法是用路径写法只追加新的部分(小程序原生有 setData 的数组下标写法,uni-app 里则要注意别整体替换引用)。data 里只放渲染需要的字段,接口返回的完整对象里那些不显示的字段应该剔掉再放进 data,长列表里这一项能省掉很可观的序列化量。
节点层面:节点数量是小程序渲染性能的硬约束,几千个节点必然卡。手段是虚拟列表——只渲染可视区域内的节点,滚出去的回收掉。可以用官方的 recycle-view,或者社区的虚拟列表组件,也可以自己实现(监听滚动算出可视区间,只渲染那一段,用占位元素撑起总高度)。另外简化单个 item 的结构,减少嵌套层级和不必要的容器,几百个 item 乘以每个省下的几个节点,总量差别很大。
滚动层面:onPageScroll 和 scroll-view 的 scroll 事件触发极其频繁,而且每次都是跨线程通信,在里面做 setData 是最典型的卡顿原因。必须节流,或者改用 IntersectionObserver(uni.createIntersectionObserver)来做曝光检测和懒加载,它在渲染层判断,不需要频繁回传逻辑层。
还有两个实用点:图片懒加载,image 组件加 lazy-load,长列表里的图片是内存和流量大户;用 scroll-view 而不是页面滚动时注意它的高度必须确定,否则滚动区域算不对。
分包是为了绕过体积限制:微信小程序主包不能超过 2MB,整个小程序(含所有分包)不超过 20MB。把非首屏用到的页面拆进分包,只有用户真正访问时才下载,这样既满足限制又加快了首次启动速度——启动只需下载主包。
配置在 pages.json 的 subPackages 里,指定 root 目录和页面列表。要注意主包和分包的引用规则:分包可以引用主包的资源,但分包之间不能互相引用,这条限制经常在拆分时踩到,几个分包共用的组件必须放主包,而放主包又会增加主包体积,需要权衡。
几个进阶能力值得说。分包预下载(preloadRule):配置进入某个页面时后台预先下载指定分包,这样用户点过去的时候已经下好了,体验上没有等待,这是很实用的优化。独立分包:可以脱离主包单独运行,用户从分享链接直接进入这个页面时,不需要下载主包,启动速度最快。适合活动页、分享落地页这类独立入口。但独立分包里不能用主包的任何东西,包括 App.vue 里的全局逻辑和 globalData,所以要自己处理初始化,这一点很容易漏。
实践上的拆分思路:按业务模块拆,主包只放 tabBar 页面和真正公用的东西。要检查主包里有没有误打进来的大资源——图片、字体、静态 JSON 数据是主包超限的常见原因,图片应该上 CDN。用微信开发者工具的代码依赖分析能看到每个文件的体积占比,找出真正的大头,比凭感觉删代码有效得多。
先把启动过程拆开看:下载代码包 → 加载和初始化框架 → 执行 App.onLaunch 和页面逻辑 → 首屏渲染。每一段都有对应的优化手段。
下载阶段:核心是减小主包体积,靠分包、资源上 CDN、代码压缩、删掉未使用的组件和依赖。这一步收益最直接,尤其对弱网用户。
初始化阶段:减少 App.vue 里的同步逻辑。常见问题是在 onLaunch 里串行做一堆事——检查更新、读取 storage、初始化 SDK、请求配置、然后才登录,每一步都等着,首屏就被拖到几秒后。优化是只保留必须同步的,其余改成异步或者延后,能并发的用 Promise.all。getStorageSync 少用,启动时连续读十几个 key 是会有明显耗时的。
首屏渲染:请求要尽早发起,放在 onLoad(甚至 onLaunch 里提前发起并缓存 Promise),不要等 onReady。首屏用骨架屏,让用户立刻看到结构而不是白屏,这是感知优化里性价比最高的。首屏数据可以做本地缓存,先渲染上次的缓存数据再用新数据替换,秒开效果明显。非首屏内容延后渲染,用 v-if 控制,别一次性把整页节点全建出来。
还有两个平台能力:分包预下载提前准备后续页面,数据预拉取(微信的 periodicUpdate 和 preFetch)可以让微信服务器在小程序启动前就帮你请求接口,启动后直接从本地取结果,省掉一个网络往返。
最后要说的是先测量再优化:用开发者工具的性能面板和真机体验评分看具体每一段耗时,别凭猜测动手。很多时候瓶颈在一个意想不到的地方,比如某个第三方 SDK 的初始化。
先说那个容易被忽略的硬限制:小程序的并发请求数有上限,微信是同时最多 10 个 request(历史版本是 5 个),超出的会失败或者排队。uploadFile 和 downloadFile 也各有自己的并发限制。
这个限制在实际项目里很容易撞上:一个页面里十几个组件各自在 onLoad 里发请求、或者列表里每个 item 都去查一次详情、或者用 Promise.all 一次并发几十个请求——结果部分请求直接失败,而且失败得很莫名,本地调试还不一定复现。
所以请求封装里必须自己做并发控制:维护一个请求队列和一个计数器,同时在飞的请求不超过一个安全值(一般设 6 到 8),超出的先入队,有请求完成了再出队发送。这一层做好之后,业务代码就可以放心随便调。
封装还要统一处理这些事:baseURL 和多环境切换(开发、测试、生产用条件编译或配置文件区分);token 自动注入;loading 的显示与合并(多个并发请求不要弹多个 loading,要计数,全部结束才关);统一错误提示;登录态失效的拦截与跳转(配合前面说的无感刷新);请求重试(只重试幂等的 GET,且要限次数);超时设置(uni.request 默认超时较长,要按业务收紧)。
还有两个实用细节。一是请求取消:页面都退出了请求还在跑、回来之后还去 setData,会报错或者更新到已销毁的页面。uni.request 返回的对象有 abort 方法,要在页面 onUnload 里把未完成的请求取消掉。二是请求去重:用户快速连点提交按钮会发出多个相同请求,用「请求特征做 key」的方式拦掉重复的,比单纯禁用按钮更可靠。
最后,把 uni.request 包成 Promise 是基础操作,因为原生 API 是回调风格的,配合 async/await 写起来才干净。
小程序登录的标准流程是:uni.login 拿到临时 code,发给自己的后端,后端用 code 加 appid 和 secret 去微信服务器换 openid 和 session_key,然后由后端签发自己的 token 返回给前端。secret 绝对不能放在前端,换取过程必须在服务端完成,这是安全底线。
之后每次请求带上 token。一般设计成双 token:accessToken 有效期短(比如两小时),refreshToken 有效期长(比如七天)。accessToken 过期时用 refreshToken 去换一对新的,refreshToken 也过期了才要求重新登录。
无感刷新的实现要点在请求拦截器里。封装一个统一的请求方法,响应里发现 401 或者约定的过期错误码时,不要直接抛给业务,而是先去刷新 token,然后把原请求重新发一次,业务代码完全感知不到这个过程。
这里有两个必须处理的细节,也是面试的考点。一是并发请求的重复刷新:同时发出五个请求都返回 401,如果各自去刷新,就会调五次刷新接口,而且后面几次可能因为 refreshToken 已被使用而失败。解法是加一个刷新中的标志位和一个等待队列:第一个请求去刷新,其余请求把自己的 resolve 存进队列挂起,刷新成功后统一用新 token 重放队列里的所有请求。二是防止无限循环:刷新接口本身返回 401 时不能再触发刷新,要给它打个标记跳过拦截器,或者限制重试次数,否则会陷入死循环。
另外几个实践点:token 存 storage 并在启动时读入内存,避免每次请求都同步读;登录态失效时要清理干净(token、用户信息、相关缓存)再跳登录页,否则容易出现「显示已登录但接口全 401」的状态;跳登录页前记下当前页面路径,登录成功后跳回去,这是体验细节但用户感受很明显。
目录和复用:业务代码按模块组织,跨端差异用条件编译收敿到工具层。公共组件用 easycom 规范放在 components 下按约定命名,这样不用手动 import 和注册,能省掉大量样板代码,而且它是编译期按需引入的,没用到的组件不会打进包。
环境和配置:区分开发、测试、生产环境的接口地址和各种 key。可以用 .env 文件(HBuilderX 和 CLI 方式支持程度不同)或者自己写一个配置文件加条件编译。不要把不同环境的配置靠手动改代码切换,那迟早会把测试地址发到生产上去。
请求封装是必做的:统一处理 baseURL、超时、loading、错误提示、token 注入、登录态失效跳转、以及埋点。业务代码不应该直接调 uni.request。
构建和发布:用 CLI 方式(@dcloudio/uni-cli)而不是只依赖 HBuilderX 的界面操作,这样才能接入 CI。各端的发布命令不同(build:mp-weixin、build:h5 等),小程序端产物再用各平台的 CI 工具上传(微信有 miniprogram-ci,可以命令行上传并生成体验版),App 端用云打包或者本地离线打包。把这些串成流水线,一次提交自动构建多端并推送到各平台,这是团队协作的关键,手动发布多端极容易出错和漏更。
质量保障:ESLint 加 Prettier 统一代码风格,配合 husky 做提交前检查。真机测试不能省,小程序在开发者工具里正常、真机上出问题是很常见的,尤其是 iOS 和 Android 的表现差异、以及低端机的性能表现。
版本更新:小程序要处理 uni.getUpdateManager,检测到新版本提示用户重启;App 端要做整包更新和热更新(wgt)的策略,注意热更新只能改 JS 和资源,改了原生插件必须发整包。
原理决定了边界。uni-app 打出的 App 是「原生壳加一包 JS 和资源」,代码运行在壳里的 JS 引擎和 WebView 中。wgt 更新做的事就是下载一个新的资源包替换掉本地那一份,重启后生效。所以判断标准很简单:只要改动不涉及原生层,就能热更;一旦碰到原生层,就必须发整包。
能热更的:Vue 页面、JS 业务逻辑、样式、图片字体等静态资源、pages.json 里的页面和路由配置。
必须发整包的:原生插件的增删改、manifest.json 里的 appid、权限声明、SDK 配置、App 图标和启动图、任何原生模块依赖的变化。因为这些东西在打包时就编译进原生壳了,资源包替换不掉。
实现上要处理几件事:比对服务端版本号和本地 plus.runtime.version,小版本差异下 wgt、大版本差异就提示用户去应用市场下整包;下载完要校验包完整性再安装,否则半个包装上去直接白屏;要有失败回滚;更新时机放在启动或者从后台切回前台,不要在用户正操作时强制重启。
最后必须知道的是合规边界,这一点比技术实现更重要:苹果不允许通过热更新改变应用的主要功能和用途,热更新的正当用途是修 bug 和小幅调整。有些团队用它绕审核——上架时藏起功能,审核通过后热更放出来——这属于明确违规,被发现会导致下架甚至开发者账号受影响。Android 侧各应用市场相对宽松,但同样有各自的规则。所以热更新要当成修复手段而不是发版手段来用。
三点差异要清楚。容量上限:小程序端有明确的硬上限(各平台不同,量级在个位数到十几 MB),App 端受设备存储限制、几乎不设上限,H5 受浏览器策略限制且可能被用户或浏览器清理。持久性:小程序的存储可能被平台在空间不足时清理,H5 的存储在隐私模式或清缓存时直接消失,App 端最可靠。API 形态:uni.setStorage 系列在各端行为一致,但底层实现不同,而同步与异步版本的性能差异在数据量大时很明显。
最需要讲的是「写入失败」这件事,因为它常常是静默的。容量超限时写入会失败,而如果代码里用了同步 API 又没有 try-catch、或者用异步 API 没有处理 fail 回调,失败就被吞掉了——表现是「用户重启后最近的数据全没了」,而当时一切看起来正常。有配额上限的资源,写入结果必须检查,这条比任何优化都重要。
正确的做法有四条。一、按业务重要性分级:登录态、用户未提交的输入(草稿)优先级最高;缓存类数据可随时淘汰。二、写入失败要能感知并触发清理再重试,而不是直接失败。三、大对象要分片存——不要一个 key 存一个大数组,因为每次写入都要把整个对象序列化一遍,几 MB 的序列化会卡住主线程;按业务维度加时间段分片,新数据只写最新那一片。四、要有主动的容量治理:定期清理过期数据、按活跃度淘汰,而不是等写满。
另外敏感数据不能明文存(存储在越狱或调试环境下是可读的),而且不要把长期凭证存进本地存储——用短时效的凭证加刷新机制。
差异主要在三个维度。授权时机:小程序是「调用能力时由平台弹窗」,而且用户拒绝一次之后再调不会重复弹,要引导他去设置页开启;App 端可以控制申请时机,但系统层面同样有「拒绝后不再询问」的状态;H5 的能力最弱且必须在 HTTPS 下。粒度:定位有「精确/模糊」「使用时/始终」等档位,各端和各系统版本的档位不同。可查询性:能否查询当前授权状态、能否直接跳转到设置页,各端能力不一致。
所以工程上必须抽象成统一的权限接口:检查状态 → 申请 → 处理结果 → 引导设置,各端各自实现,业务代码只面对这一层。否则权限判断的分支会散落在每个功能里,而且各端行为逐渐不一致。
被拒绝后的处理是这题真正的重点,三条原则。
一、不能死在这里。绝不要用「不给权限就不能用」去强迫用户——那是把系统的需要凌驾于用户的选择之上,而且会引起更强的反弹(用户会卸载)。要给降级路径:定位被拒就让用户手动选城市或输入地址;相册被拒就允许只发文字;通知被拒就在应用内做醒目提醒。
二、申请前要说明理由,而且要具体。「开启后我们会在登机前提醒你」的通过率明显高于一个突然弹出的系统弹窗。更好的做法是在真正需要该能力的动作之前才申请,而不是一进应用就申请一堆——用户在有明确诉求时最愿意授权。
三、要能感知「权限被拒导致功能静默失效」。通知权限最典型:我们的提醒系统正常运行、日志显示已发送,但用户压根没收到,而我们以为已经提醒过他了。依赖系统能力的功能必须检查该能力是否可用,「不可用」要被当成一种需要处理的状态而不是忽略。
差异:小程序有原生的分享回调与小程序码,App 要调各平台 SDK,H5 只能复制链接或引导用户用浏览器菜单分享(能力最弱)。所以要抽象成统一的分享接口(传入内容与参数,各端各自实现),业务代码只调这一个——否则条件编译分支会散落在业务里,而且横切逻辑(埋点、错误处理)一定会写得不一致。
参数丢失是这题的核心,而它的根源是「链路是跨应用的」:我们的页面 → 微信或短信 → 另一个人的设备 → 回到我们,中间经过不受我们控制的环节,每一跳都可能丢东西。
最重要的一条:不要在端上拼长参数,改用服务端签发的短标识承载。小程序页面路径有长度限制、小程序码的参数字段更短,塞四五个参数后某些渠道会被截断——表现是邀请人标识变空、裂变关系断了,而这类问题只在参数较长的组合下出现、极难排查。改法是分享时把全部参数提交服务端换一个短串,端上只传这个短串,打开时换回完整参数。两个额外收益:绕开所有端的长度限制、参数结构以后怎么变都不影响端上。
即使用了短标识仍会丢(H5 分享被用户手动复制时截断、App 冷启动参数没被正确接收、某些渠道会重写链接),所以要做多级兜底:页面参数 → 首次启动参数 → 剪贴板识别(只认自己格式的码)→ 服务端最近分享记录。而且要埋点区分「兜底命中」与「主路径命中」——某个渠道兜底命中率异常高,就针对性去修那个渠道。
还有两条工程细节:小程序码生成要走服务端并缓存(同一个对象的码可复用,不要每次分享都生成);分享凭证要有有效期——长期有效的凭证既是安全隐患,也会让归因窗口失去意义。
先接受一个前提:现代移动系统的方向就是限制后台,所以「保活」的正确目标不是「一直活着」,而是「该活的时候活着,不该活的时候优雅退出并能恢复」。
限制的表现:切后台后 JS 线程可能被挂起(定时器停摆)、网络请求被限制、进程可能被系统回收;小程序端后台运行有明确时限,之后长连接会被平台断开;各厂商的省电策略还会额外加严。
所以关键不是对抗,是三件事。
一、被挂起之后要能正确恢复,而不是接着用挂起前的状态继续走。回前台时要重新校准时间、重新拉状态、探活长连接。举两个具体的坑:基于定时器的倒计时在挂起期间不走,回来时剩余时间会偏多(要按服务端时间校准值重算而不是接着倒);长连接的 socket 状态可能还是 open 但实际早断了(要立刻发一次心跳探活,不能等下一个周期)。任何「基于时间推进」或「基于长连接同步」的界面,回前台都不能接着用挂起前的状态。
二、真正需要后台运行的场景要用系统提供的正规机制(前台服务、后台任务、静默推送唤醒),并且必须向用户说明用途——而不是用各种技巧硬撑,那既不可靠也会被应用市场审核挡下。
三、后台期间的资源开销要主动降下来:心跳间隔降频(后台本来也收不到消息,维持高频心跳只是耗电)、暂停动画与轮询、停止不必要的定位。「主动降级」比「被系统杀掉」好得多。
还有一条容易漏的:后台重试要有退避与次数上限。我们见过因为客户网络策略导致上传一直失败、应用在后台不停重试而被投诉耗电——而且「放弃」之后要给用户明确的下一步,只是停下来不告诉他,那条数据就永远悬着。
先说清为什么必须抽象。这类能力的差异不只在 API 名字:调用参数不同、回调时机与形态不同(有的同步返回、有的走事件、有的要跳出应用再回来)、错误码体系完全不同、有的端压根不支持某个渠道。如果直接在业务里写条件编译,很快就没人敢改了——而且横切逻辑(埋点、错误处理、重试)会在各端写得不一致,我们踩过「其中一个端漏了埋点」这种问题。
抽象的做法是三层。
一、定义统一的能力接口:入参归一(业务只提供业务参数)、出参归一(统一的成功/失败/取消三态,以及归一化的错误码)。「取消」必须作为一个独立的状态而不是失败的一种——用户主动取消支付和支付失败,后续处理完全不同。
二、各端适配层各自实现,把参数转换、回调形态转换、错误码映射全部关在里面。新增一个端只需要加一个适配实现,业务代码不动。
三、把「能力是否可用」也做成接口的一部分。某个端不支持某个支付渠道时,业务应该能提前查询到并不展示那个入口,而不是让用户点了才失败。
支付还有两条特有的要点。一、绝不能以客户端回调作为支付成功的依据。客户端回调可能丢失(用户直接杀掉应用、跳转没回来),必须以服务端的支付状态为准,客户端回调只用来触发一次查询。二、要有兜底查询:回跳丢失时,本地记录「待确认支付」,用户回到应用时主动查一次,订单列表进入时也拉最新状态——如果这些兜底压根没实现,那是设计缺陷而不是 bug。
登录的要点是「统一成内部会话」:各端拿到的凭证形态不同(小程序的 code、App 的第三方 token),适配层负责换成我们自己的会话,之后所有业务只认这个会话。不要让业务代码直接依赖某一端的凭证形态。
差异的来源要先分清,因为处理方式完全不同。
一是渲染引擎不同:小程序端是平台自己的渲染层、App 的 nvue 是原生渲染、H5 是浏览器。nvue 只支持部分 flex 布局与有限的 CSS 属性,很多 web 上习惯的写法直接无效。二是组件实现不同:同名组件在各端的默认样式、层级行为、事件冒泡都可能不同(典型的是原生组件的层级压不住)。三是设备与系统差异:安全区域、状态栏高度、刘海屏、软键盘弹起的行为。
处理策略按优先级排。
一、优先用能力交集,而不是先写一端再去适配其他端。这是最省事的策略——只用各端都稳定支持的布局与属性,代价是放弃一些高级效果。先写一端再适配的做法成本高得多,因为你会不断发现「这个属性那边不支持」而返工。
二、差异集中在少数地方时用条件编译,但要把它收在组件内部,不要散落在页面里。判断标准是:条件编译应该出现在「基础组件」和「适配层」,不该出现在业务页面。
三、安全区域与状态栏一律用系统信息动态计算,不要写死数值。写死的值在新机型上必然错。
四、原生组件的层级问题要靠「换实现」而不是「调 z-index」。层级压不住是渲染机制决定的,调 z-index 无效——正确做法是用平台提供的覆盖组件,或者在需要浮层时先隐藏原生组件。
五、rpx 只解决等比缩放,不解决布局差异。大屏平板上等比放大会让元素变得很大、留白很怪,所以关键页面还是要用弹性布局加最大宽度约束,而不是只靠 rpx。
六、建立跨端回归清单。把已经踩过的差异(键盘遮挡、层级、安全区、长列表滚动)写成固定的验收项,每次发版在各端都过一遍——这类问题的特点是「改一个端可能破坏另一个端」,只靠开发时自测一定会漏。
这题问的是工程保障,而它的难点在于「问题的暴露是延迟的」——你改了小程序端的一个样式,App 端可能到下一次发版才被人发现。所以核心思路是把跨端问题的发现提前,而不是靠人记得去验。
一、把跨端差异收敛在少数文件里。条件编译只出现在基础组件与适配层,业务页面里不出现。这样「可能出问题的地方」是可枚举的,review 时重点看这些文件。
二、建立跨端回归清单并且只放「踩过的坑」。不要写成一份大而全的测试文档(没人会执行),而是把每次真实踩过的跨端问题固化成一条验收项:键盘遮挡输入区、原生组件层级、安全区适配、长列表滚动、分享参数、支付回跳。清单来自事故,所以每一条都有真实价值,执行意愿也高。
三、构建产物的自动检查:各端包体积(小程序有主包与分包的硬上限,超了压根发不出去)、分包配置是否正确、条件编译是否残留了不该有的代码。这些能自动化就不要靠人看。
四、发布流程上分端灰度。不要三端同时全量发。小程序端有平台的灰度能力,App 端可以按版本灰度,先放小比例观察关键指标(启动成功率、白屏率、支付成功率)。
五、线上要有分端可观测。错误上报、性能数据、关键路径转化都要能按端拆分看——否则某一端出了问题,整体指标可能被其他端稀释掉而发现不了。这一条最容易被忽略,而它决定了你能不能在用户投诉之前发现问题。
六、热更新要清楚边界。App 端的资源热更新只能改前端资源,改不了原生插件与配置;而且要有版本校验与回滚能力——热更新推错了比不推更危险,因为它直接作用于已安装的用户。
没有匹配的题目,换个关键词试试。
模块 07 · 共 28 题 · 题目与答案分离,建议先自答再展开