
去年我接手的那个 uni-app 商城项目第一周就把我整麻了同样一套代码微信小程序上运行好好的H5 端一打开就是白屏App 端又报各种诡异 API。排查到最后发现问题不是业务逻辑而是平台差异——同样是uni.getStorageSync小程序端 key 不存在时返回空字符串H5 端返回 nullApp 端又可能抛异常同样是uni.setNavigationBarTitle微信小程序能改顶部标题H5 端却是“改了等于没改”。真正把这些问题一次性解决的是我后来用Proxy加wrapper给uni对象做了一层“兼容补丁”。今天就把这套打法和完整代码拆开讲清楚适合被多端问题折磨的 uni-app 开发者也适合想理解跨端框架底层设计思路的朋友。1. 为什么是 Proxy 和 wrapper多端差异到底从哪来1.1 一套代码四种环境三种“脾气”uni-app 的口号是“一套代码多端运行”但实际跑起来你会发现这套代码在不同端上面对的底层运行时完全不是一回事。微信小程序跑在 JSCore 或 V8 引擎里底层是微信自己封装的wx对象API 名、参数结构、返回值类型都是微信定义的H5 端跑在浏览器里本质上是 Vue 打包出来的网页底层的window、localStorage、document.title才是归宿App 端则是通过 plus runtime 或 uni 内部桥接到原生能力。这是三套完全不同的运行时uni-app 官方能在上层抹平大部分差异但永远抹不平所有边缘情况。举几个我实战遇到的真实差异uni.getStorageSync(key)在微信小程序中如果 key 不存在返回的是空字符串而在 H5 端localStorage.getItem(key)返回的是null。如果你用if (data)做判断小程序端空字符串能跑过H5 端 null 也能跑过但要判断“是否有值”两端行为就不一致了。uni.setNavigationBarTitle({ title })在微信小程序和 App 端都能生效但 H5 端只是document.title而且如果你的项目用了 vue-router 并在路由配置里写了meta.title路由切换时会清掉你刚设的标题导致“设置了等于没设置”。事件对象差异很大微信小程序的触摸事件e.touches[0].pageX在某些 WebView 场景下 touches 数组结构是好的但 scroll 事件的e.detail.scrollTop在 H5 端根本拿不到。这些差异不会在开发期的“四端联调”里全暴露出来往往是你自信满满提测测试同学换个平台一测就翻车。我最开始的做法是到处写条件编译// #ifdef H5 document.title title // #endif // #ifdef MP-WEIXIN uni.setNavigationBarTitle({ title }) // #endif一开始还好等这种分支代码堆到几十处之后我才意识到问题的严重性条件编译会侵入所有业务页面看起来是“抹平”实际是“污染”而且每多一个平台这些分支就要全部重审一遍。1.2 差异藏在三层API 层、组件层、事件层把经验总结下来多端差异主要集中在这三层层级差异表现典型例子API 方法层同一能力在不同端的 API 名称、参数、返回值不一致storage 读不到数据的返回值类型不同组件/页面层同一组件在不同端产生不同交互行为navigationBar 标题在 H5 端失效事件对象层事件对象的字段结构在不同端有差异scroll 事件的 scrollTop 获取位置不一致你会看到传统的“哪有问题就在哪打补丁”做法解决的是第三层的问题而且越补越乱。更优雅的思路是把差异集中收口到最底层——也就是我们实际调用的 API 对象上在“访问 API 的那一刻”动态给出当前平台最合适的实现。这正是 Proxy 和 wrapper 最拿手的活儿。1.3 Proxy 和 wrapper 在跨端方案里的分工很多人把 Proxy 和 wrapper 混为一谈实际上在跨端抹平方案里两者分工很明确wrapper包装器把某个功能函数“包一层”在前后做参数标准化、返回值标准化。告诉业务层“外面的事不用你管我帮你处理好”。比如我封装一个getStorageCompat(key)内部对不同平台做不同处理返回统一格式的数据。Proxy代理在“对象属性访问”这一层做拦截。业务代码依然写uni.getStorage(key)、uni.setNavigationBarTitle(title)但uni已经不是我直接引用的那个对象了而是一个 Proxy 实例它会在get拦截器里判断你在取哪个属性如果是需要兼容的属性就把你引导到对应的 wrapper 实现上。简单理解Proxy 负责“拦路”wrapper 负责“干脏活”。Proxy 让我们不用改任何业务代码wrapper 让所有差异逻辑集中在一处。这两个配合起来相当于给 uni-app 的全局对象打了一个“运行时补丁”平台差异在入口处就被消化掉了。2. Proxy 妙用在属性访问这一层把 API 抹平2.1 先搞懂 Proxy 的拦截原理很多前端同学对 Proxy 的理解停留在“Vue 3 的响应式原理”但 Proxy 本身是一个通用能力它允许我们在 JavaScript 对象上注册拦截器只要有人访问、赋值、调用对象的属性都会先经过我们的钩子函数。这里最核心的是get钩子。比如const obj { name: uni } const proxyObj new Proxy(obj, { get(target, prop) { console.log(有人想访问属性:, prop) return target[prop] } }) console.log(proxyObj.name)运行时proxyObj.name会先触发 get 拦截器打出日志再返回真正的值。看起来很简单但把这个能力用在uni对象上效果就非常强业务代码里业务对象访问uni.xxx的那一刻我可以偷偷替换成我准备好的兼容实现。2.2 用 Proxy 给 uni 对象打补丁先搭一个兼容层明白了原理直接看代码。下面是我在项目里实际用过的兼容层基座。假设我们要解决 storage 读写在多端的行为差异。// compat-layer.js const isMP typeof wx ! undefined typeof wx.getSystemInfoSync function const isH5 typeof window ! undefined typeof window.localStorage ! undefined // 这里是所有补丁的具体实现也就是 wrapper const storagePatch { getStorage(key) { // 在小程序里不存在的 key 返回空字符串统一转成 null let raw null if (isMP) { raw uni.getStorageSync(key) } else if (isH5) { raw window.localStorage.getItem(key) } else { raw uni.getStorageSync(key) } if (raw || raw null || raw undefined) return null // 为了避免各端对对象的序列化行为不一致统一按 JSON 字符串处理 if (typeof raw string) { try { return JSON.parse(raw) } catch (e) { return raw } } return raw }, setStorage(key, value) { const data JSON.stringify(value) if (isMP) { uni.setStorageSync(key, data) } else if (isH5) { window.localStorage.setItem(key, data) } else { uni.setStorageSync(key, data) } }, removeStorage(key) { if (isMP) { uni.removeStorageSync(key) } else if (isH5) { window.localStorage.removeItem(key) } else { uni.removeStorageSync(key) } } }然后创建代理对象function createCompatUni(baseUni, patchTable) { const hasNativeProxy typeof Proxy ! undefined if (!hasNativeProxy) { // 低版本环境不支持 Proxy用降级方案 Object.keys(patchTable).forEach((key) { if (typeof patchTable[key] function) { baseUni[key] patchTable[key] } }) return baseUni } return new Proxy(baseUni, { get(target, prop) { // 命中补丁表中的属性用补丁实现 if (patchTable[prop]) { return patchTable[prop] } // 没有补丁的方法原样返回但把 this 绑定到 target 上 const value Reflect.get(target, prop) return typeof value function ? value.bind(target) : value }, set(target, prop, value) { return Reflect.set(target, prop, value) } }) } export default createCompatUni(uni, storagePatch)2.3 为什么不是直接改 uni.xxx 方法可能有人会问既然有storagePatch.setStorage为什么不直接uni.setStorage storagePatch.setStorage绕这么大一圈干嘛直接覆写方法的问题有三个。第一时机不可控。uni对象的某些属性可能是只读的或者在小程序底层是冻结对象覆写时会直接报错。第二方法绑定丢失。这是我踩过的坑uni 内部很多方法调用时依赖 this 指向 uni 自身。直接const fn uni.getStorageSync; fn(key)经常报错因为你把 this 搞丢了。但通过 Proxy 统一在处理返回函数时bind(target)可以把这个坑一次填平。第三后续新增补丁表业务代码不用动。如果你用散落的uni.xxx yyy覆写每加一个补丁就要 import、执行一次容易漏。而 Proxy 版本只需要往 patchTable 里加一个属性下次任何页面访问时自然命中。从工程化角度看Proxy 方案是“集中收口”覆写方案是“散点打补丁”长期维护成本差距非常明显。3. wrapper 包装器把底层差异封装成统一出口3.1 如果说 Proxy 是门卫wrapper 就是里面的业务员Proxy 解决了“拦路”的问题但对“拦下来之后怎么办”没有太多指导。真正干活的是 wrapper。wrapper 这个词业界用了很久本质上就是装饰器/包装器在函数外层包裹一层逻辑负责把平台差异嚼碎了吞进肚子里对外只露出一个稳定接口。一个好的 wrapper 有几个要求我一般按这个标准写输入参数标准化不管上层传什么wrapper 内部先转成自己认识的格式再干活。返回值标准化不管底层返回什么wrapper 强制转成业务约定好的格式。异常兜底底层在某端抛了异常wrapper 内部捕获并吞掉或转换。下面看两个我在实战中写过的典型 wrapper。3.2 动态设置标题的跨端 wrapper这下 H5 端也能用了动态设置页面标题是很常见的需求商品详情页要把页面标题改成商品名。这个需求在微信小程序上简单H5 端却开了个大坑。// set-title.js function setPageTitle(title) { // #ifdef H5 document.title title const meta document.querySelector(meta[nameapple-mobile-web-app-title]) if (meta) meta.content title // #endif // #ifndef H5 uni.setNavigationBarTitle({ title }) // #endif } export default setPageTitle表面看已经不错了但真正用了之后你会发现还差一步H5 端如果项目用了 vue-router路由切换后 vue-router 会拿 route.meta.title 覆盖 document.title你辛苦设置的document.title一秒就没了。所以我在 H5 分支里做了“延迟重设 路由重置拦截”效果稳了很多function setPageTitle(title) { // #ifdef H5 document.title title setTimeout(() { document.title title }, 500) // #endif // #ifndef H5 uni.setNavigationBarTitle({ title }) // #endif }这个 setTimeout 不是玄学是实测出来的很多 H5 侧的 UserAgent 解析、iOS 的 title 刷新有延迟延迟一次重设能保证 title 不闪失。3.3 事件对象标准化的 wrapperscroll 坐标的跨端统一事件对象差异是最隐蔽的坑因为它不容易在初测阶段暴露。比如 scroll-view 组件的滚动事件微信小程序端返回的 event 里一定带着detail.scrollTopH5 端就可能没有。我的做法是封装一个取滚动高度的 wrapper// scroll-helper.js export function getScrollTop(event, { fallback 0 } {}) { try { const scrollTop event?.detail?.scrollTop if (typeof scrollTop number) { return scrollTop } if (event?.target?.offsetTop) { return event.target.offsetTop } if (typeof document ! undefined) { return document.documentElement.scrollTop || window.pageYOffset || fallback } } catch (e) { return fallback } return fallback }这样不管在哪个端业务层只需要调getScrollTop(event)就能拿到一个可靠的滚动高度数字。wrapper 吞掉了所有端的特例业务层代码干干净净。4. 实操用 Proxy 加 wrapper 抹平商城项目里的真实坑前面讲的是零件这一节我们实打实组装一遍。场景我选了一个很常见的商城小程序的“购物车数量角标”。这个需求涉及的 storage 读写、全局状态同步、页面标题设置正好能把 Proxy 和 wrapper 都串起来。4.1 场景拆解购物车数量角标需求是这样的商品详情页点击“加入购物车”数量要写到本地缓存购物车 tab 上要显示数量角标并且页面标题要实时变成“购物车(3)”。业务需求平台差异风险写购物车数量到缓存小程序和 H5 对对象/数字的序列化行为不一致从缓存读购物车数量不存在的 key小程序返回 H5 返回 null动态设置购物车页标题H5 端 document.title 会被路由重置这基本就是多端开发里最典型的疑难组合。4.2 组装开工缓存模块的封装先写购物车缓存数据的读写模块设计一个稳定接口给业务层getCartCount()永远返回数字setCartCount(count)接收数字并写入缓存。// cart-storage.js import compatUni from ./compat-layer const CART_COUNT_KEY cart_count export function getCartCount() { const count compatUni.getStorage(CART_COUNT_KEY) if (typeof count number) return count if (typeof count string) return Number(count) || 0 return 0 } export function setCartCount(count) { const safeCount Number(count) || 0 compatUni.setStorage(CART_COUNT_KEY, safeCount) return safeCount }这里的关键点是compatUni.getStorage返回的已经经过 wrapper 标准化不管小程序返回空串还是 H5 返回 null最终传到 getCartCount 里的要么是数字要么是 null。业务层不用关心。4.3 接入业务页面的完整调用链然后看页面里怎么调用// pages/goods-detail.vue import { setCartCount, getCartCount } from /utils/cart-storage import setPageTitle from /utils/set-title function addToCart() { const count getCartCount() const newCount setCartCount(count 1) setPageTitle(购物车(${newCount})) }注意这里没有出现任何#ifdef条件编译。业务代码已经完全不需要知道自己是运行在哪个平台。平台差异被 proxy wrapper 在compatUni.getStorage和setPageTitle这两层拦下来处理掉了。4.4 参数验证与多端实测封装完之后记得做一轮“多端交叉验证”我的习惯是这样的微信开发者工具里清空缓存调用getCartCount()确认返回 0 而不是空字符串。H5 端直接手动把 localStorage 里cart_count删掉刷新页面确认getCartCount()也返回 0。连续点击加购超过 10 次确认每次读写数据一致不会出现字符串拼接问题比如 “1” 1 “11” 这种经典事故。在 H5 端玩命切路由确认 document.title 不会被 vue-router 重置掉。这套链路测下来基本能保证购物车角标在所有端表现一致。之后如果再上线支付宝小程序我只需要在compat-layer.js里增加isMPAlipay的判断分支业务代码一行不动这是整个方案最爽的地方。5. 常见问题与排查技巧实录5.1 问题速查表真到了团队里多人协作这套方案也会遇到各种奇奇怪怪的问题。这里把我调试过程中遇到的典型问题整理成了一张速查表。现象根本原因处理思路真机上uni.setNavigationBarTitle偶发不生效部分端对标题设置有时序限制需要页面 onReady 后再调用wrapper 里做 try-catch 和 fallback延迟一次再设置H5 端document.title设置后又变回旧标题vue-router 在路由切换时用meta.title覆盖了 document.title在路由afterEach里把 wrapper 再调用一次微信小程序端读缓存返回空字符串做if (data)判断时误判为有值小程序 getStorageSync 对不存在 key 返回而非 null在 storage wrapper 统一把、null、undefined都转成 null解构const { getStorage } uni后调用报错方法内部依赖 uni 对象的 this解构后 this 丢失Proxy get 里统一value.bind(target)低版本 iOS 报Proxy相关错误页面白屏旧版 JSCore/WebView 不支持 ES6 ProxycreateCompatUni 里做能力检测不支持时自动降级为直接覆写方法对象存到缓存里读出来变成[object Object]某些端不会自动 JSON 序列化对象统一 JSON.stringify 写入JSON.parse 读取5.2 能力降级不是可选项是必须项第一次我用 Proxy 方案时团队里一个同事在 iOS 12 的老 iPhone 上直接白屏查了半天才定位到是 Proxy 不支持。这个教训非常深刻。所以后来createCompatUni的第一行一定是能力检测const hasNativeProxy typeof Proxy ! undefined没有 Proxy 就走降级方案直接覆写方法。虽然覆写方案有 this 绑定的隐患但针对我们补丁表里的函数每个函数都是独立写好的不依赖 uni 内部 this所以降级方案是安全可用的。这也提醒我设计补丁表时每个 wrapper 必须是“自包含的”不能依赖外部 this。5.3 补丁加得越多越容易忘掉旧平台这是我自己逃过的坑补丁表越写越全覆盖的 API 越来越多。但注意有些补丁只在特定平台才有意义。比如setNavigationBarTitle补丁只对 H5 有意义对小程序的 uni.setNavigationBarTitle 其实就是原样调用。如果你在补丁表里对每个平台都硬写一遍分支反而容易在新增一个平台时漏改。我的做法是补丁函数内部先判断当前平台是否需要特殊处理不需要就直接调用原生 uni 方法。这样新增平台时只需在 isMP、isH5 这些判断里增加分支不需要重写补丁函数。最后分享一个我个人的小经验这套 Proxy wrapper 方案我一开始只是打算给商城项目救火后来我把它做成了整个团队的公共模块所有新开的 uni-app 项目都会第一时间引入。每次遇到新的平台差异我的处理流程不是说去业务代码里补条件编译而是回来问自己一个问题这个差异该由哪个 wrapper 收口收口之后业务代码是不是可以完全无感知如果答案是“可以”那就说明这个方案又进化了一步。实际开发中这套方案帮我至少省掉了十几个页面里散落的条件编译也让我在测试报告上的“多端适配缺陷”一栏干净了不少。你如果正在被 uni-app 的多端差异折磨真的建议从一个小模块开始试试。