本节导读:本章收束在引擎室。先对照 Vue2 属性劫持与 Vue3 代理拦截各自管不到的角落,再讲组合式 API 里页面生命周期的注册位置,最后引入 TypeScript,看类型系统如何把跨端代码里"参数是什么、接口回什么"这类口头约定变成编译期检查。
响应式系统的两代实现在面试里被讲烂了,这里只讲与跨端开发真正相关的部分:边界场景在各端的表现是否一致。好消息是,响应式差异属于纯语言层,与宿主平台无关——Vue2 的坑在微信端和 H5 端一模一样。坏消息是,正因为与端无关,团队常忘记把它列入跨端检查清单,老项目里数组下标赋值不刷新这类问题依然高发。
| 场景 | Vue2 属性劫持 | Vue3 代理拦截 |
|---|---|---|
数组下标赋值 list[0] = x |
视图不更新,需用变异方法或静态方法 | 正常响应 |
数组长度修改 list.length = 0 |
不响应,需重新赋值 | 正常响应 |
| 对象新增属性 | 需用静态添加方法手动声明 | 正常响应 |
| 对象删除属性 | 需用静态删除方法 | 正常响应 |
| Map 与 Set 结构 | 不支持 | 支持 |
Vue2 工程里的安全写法是"整体替换":修改列表时构造新数组重新赋值,修改对象时用展开运算符生成新对象。这写法在 Vue3 里同样合法,因此可以作为跨版本通用公约——团队同时维护两条语法线时,统一按整体替换书写,能消除一大类"为什么界面不刷新"的排查成本。
2.1 节给过写法,这里补上最容易出错的次序关系。页面生命周期(onLoad、onShow)由 uni-app 的页面运行时触发,组件生命周期(setup 执行、onMounted)由 Vue 引擎触发,两者交错但有序:
import { onLoad, onShow, onReady } from '@dcloudio/uni-app'; import { ref, onMounted } from 'vue'; export default { setup() { const list = ref([]); const ready = ref(false); onLoad(async (query) => { // 页面参数最早在这里拿到,适合发首屏请求 list.value = await fetchGoods(query.cat); }); onReady(() => { // 首次渲染完成,适合量取节点尺寸 ready.value = true; }); onMounted(() => { // 组件级挂载完成,操作自定义渲染内容 }); onShow(() => { // 每次回到本页触发,刷新角标、续期会话 }); return { list, ready }; } };
一个实用的辨析:首屏请求放 onLoad 而非 onMounted。onLoad 早在渲染前就拿到页面参数,能让请求与渲染并行;放 onMounted 则要等首次渲染完成,白等一拍。而"依赖节点尺寸"的逻辑必须等 onReady 或 onMounted,因为在真机上节点还没量出来。
跨端项目的类型价值有两块:常规的参数与返回值检查,以及平台差异 API 的调用约束。先看常规块:
// types/goods.ts export interface Goods { id: string; name: string; price: number; tags?: string[]; } export async function fetchGoods(cat: string): Promise<Goods[]> { const res = await uni.request({ url: `https://api.example.test/goods`, data: { cat } }); return (res.data as { list: Goods[] }).list; }
类型块的价值在 as 断言处显形:uni.request 的返回结构是宽泛的,接口返回什么只有接口文档知道,用接口类型把它钉住后,字段拼写错误在编译期就报出来。第二块是平台差异的收敛——把"某端才有"的调用封装成带类型守卫的函数:
// utils/platform.ts export function isWeixin(): boolean { // #ifdef MP-WEIXIN return true; // #endif // #ifndef MP-WEIXIN return false; // #endif } export function payFee(orderId: string): void { if (isWeixin()) { // #ifdef MP-WEIXIN uni.requestPayment({ provider: 'wxpay', // #ifdef MP-WEIXIN timeStamp: String(Math.floor(Date.now() / 1000)), nonceStr: makeNonce(), package: `prepay_id=${orderId}`, signType: 'RSA', paySign: signOf(orderId), // #endif success: () => uni.showToast({ title: '支付成功' }) }); // #endif } }
这个例子展示了一种工程习惯:条件编译不散落在业务里,而是收进平台适配函数,业务代码只面对 payFee 这样的统一签名。类型系统再给统一签名上保险,两端差异被压缩在适配层内部。第 6 章的 API 体系会把这种适配层思路系统化。
把"响应式边界"讲成案例更贴身。商城购物车第一版这样写数量加减:
// Vue2 工程真实翻车代码 export default { data() { return { cart: { items: [], summary: { count: 0 } } }; }, methods: { addItem(g) { this.cart.items.push({ ...g, num: 1 }); // 生效:push 被劫持 this.cart.summary.count = this.cart.items.length; }, addExtra(g, key, val) { this.cart.items[0][key] = val; // 失效:新键不会被劫持 } } };
现象是列表能更新、汇总行不动、新加的字段不渲染——同一个对象上三种表现。Vue2 的劫持只在初始化时遍历既有属性,items 数组的变异方法被接管所以生效,而事后添加的新键不在劫持名单里。当年的补救是 this.$set 或整体替换对象;Vue3 的代理在读取时拦截,新键天然可响应,这类问题随之消失。但商城仓库升级期两种写法并存,公约仍是对嵌套结构"整体替换优先"——它在这两条线上都成立,还省去逐字段判断的心智负担。
这个案例的通用教训:响应式问题不要靠"再补一次赋值"蒙混,要回到各版本响应式的实现原理,判断哪条更新路径在劫持范围内。判断不了的,用整体替换兜底,一行顶十行排查。
语言层到此收官。下一章进入界面层:内置组件的选择与各端的渲染差异。