拓冰建站拓冰建站
首页 / 资讯中心 / 正文

uni-app跨端开发:用Proxy与Wrapper优雅抹平平台差异

提到 uni-app 跨端开发就绕不开平台差异。同样一句uni.setStorageSync在微信小程序里写进的是wx的 storage在 H5 里落到localStorage到 App 端又变成plus.storage的天下。表面上看框架帮你把这些 API 对齐了但只要你真的在真机上跑过几个复杂页面就会意识到“抹平差异”这四个字背后藏着不少 JavaScript 元编程的功夫。这篇文章想聊的就是其中两个常被提起又常常说不清的关键角色Proxy和wrapper。这里的“代理”特指 ECMAScript 语言层面的Proxy对象跟网络代理没有任何关系全文也不涉及任何网络代理工具。文章适合三类人看一是被多端 API 搞得焦头烂额的 uni-app 业务开发者二是想搞懂跨端框架内部设计思路的进阶前端三是对 JavaScript 元编程感兴趣、想看看 Proxy 在真实项目里怎么落地的同学。我会从“为什么要抹平差异”讲起再拆解 Proxy 的运行时分发原理最后给出一整套 wrapper 封装实战和避坑经验。1. 先搞清楚uni-app 到底要抹平哪些“差异”1.1 同一个 API三个平台三种脾气很多人第一次用 uni-app 时会觉得“这不就是一套 API 到处跑嘛”。等真正上了生产环境才发现问题远不止“API 名称不一样”这么简单。同样是存一个值三个平台给出的数据类型都完全不一样。我做了一张对照表列一下最常见的三类差异看完你就明白为什么要做额外封装能力H5 端微信小程序端App 端存储 APIlocalStorage.getItem天生只存字符串wx.getStorageSync直接返回原对象/任意类型plus.storage.getItem返回字符串设置导航标题document.title控制浏览器标签页wx.setNavigationBarTitle需要回调函数plus.navigator.setStatusBarTitle网络请求fetch/XMLHttpRequest受 CORS 限制wx.request无跨域概念原生网络栈封装header 结构不同获取设备信息navigator.userAgent 一些字段缺失wx.getSystemInfoSync字段最全plus.device字段命名差异大就拿存储来说如果你在小程序端用uni.getStorageSync(user)拿到的可能直接就是一个对象在 H5 端跑同一行代码拿到的却是一个需要JSON.parse的字符串。如果业务代码里不做类型判断同样的逻辑在两个端跑出来的结果就是两套行为。这种“同样代码不同结果”的问题比“API 直接报错”更隐蔽也更难排查。1.2 为什么是 Proxy 和 wrapper而不是堆满 if/else面对这么多种差异最朴素的做法当然是在每个业务函数里写if (platform h5)分支。这种写法在小项目里没问题但项目一旦膨胀你就会发现业务代码被平台判断塞满完全没法看新接一个平台比如支付宝小程序所有分支都要翻一遍框架层真正的“平台适配逻辑”和业务逻辑混在一起谁维护谁崩溃。所以正确思路是把“差异”集中收敛到两层wrapper 负责统一签名Proxy 负责统一分发。wrapper 的意思是包装器它把不同平台的不同实现包装成同一个函数签名业务层只调这个被包装过的函数。比如getStorage这个函数内部判断平台但对外暴露的参数和返回值完全一致。Proxy 则更进一步它连“访问哪个 API”的决策都收走了——我不用在代码里写const fn platform h5 ? fnH5 : fnWx而是让 Proxy 拦截属性访问当你访问adapter.getStorage时它自动帮你返回当前平台对应的真实函数。这两个东西不是二选一而是配合使用Proxy 是“访问层”的调度员wrapper 是“实现层”的翻译官。另外还有一道编译期手段叫条件编译#ifdef它解决的是“这个 API 在另一个平台根本不存在”的问题比如微信小程序的wx.login在 H5 就没有对应物Proxy 和 wrapper 解决的则是“同一个 API 在另一个平台存在但行为不同”的问题。把这三层理解成三道关卡平台差异就被层层过滤掉了。2. Proxy 原理拆解运行时拦截如何在 uni-app 里做平台分发2.1 从一次属性访问说起Proxy 的 get 陷阱怎么工作要理解 Proxy 在跨端分发里的价值先看它最原始的形态。Proxy 是 ES6 提供的对象它可以拦截一个对象上的基础操作比如属性读取、属性赋值、函数调用等。其中最常用的就是get陷阱——每当有人访问这个对象的某个属性时get函数就会先被触发你可以在里面决定“到底返回什么”。const handler { get(target, prop) { console.log(有人访问了属性:, prop); return prop in target ? target[prop] : 未定义; } }; const obj new Proxy({ name: 小A }, handler); console.log(obj.name);上面这段代码在终端里运行会先打印“有人访问了属性: name”然后再打印“小A”。这意味着什么意味着属性访问本身变成了一道可编程的关卡。如果你在get陷阱里写上“当访问getStorage时根据当前平台返回不同函数”那么业务代码里只要写adapter.getStorage就能自动拿到当前平台合适的实现完全不需要知道背后发生了什么。用生活化的话说Proxy 就像一个前台接线员。你打电话进去说“我要找技术部”前台根据今天是周几、公司在哪个城市帮你转接到对应的分机。你不需要知道分机号码也不需要知道技术部在几楼你只需要记住一个总机号。这个总机号就是 Proxy 代理出来的对象。2.2 在 uni 对象上挂一个“平台分发器”理解了get陷阱接下来就可以实现一个简单的平台分发器。核心思路是维护一个“平台名 - 实现集合”的映射表然后创建一个 Proxy当用户访问任意 API 名称时自动从映射表中找到当前平台对应的函数并返回。// platform-impls.js const platformImpls { h5: { getStorage(key) { const raw localStorage.getItem(key); return raw ? JSON.parse(raw) : raw; }, setStorage(key, data) { localStorage.setItem(key, JSON.stringify(data)); }, setPageTitle(title) { document.title title; } }, mp-weixin: { getStorage(key) { return wx.getStorageSync(key); }, setStorage(key, data) { wx.setStorageSync(key, data); }, setPageTitle(title) { return new Promise((resolve, reject) { wx.setNavigationBarTitle({ title, success: resolve, fail: reject }); }); } }, app: { getStorage(key) { const value plus.storage.getItem(key); return value ? JSON.parse(value) : value; }, setStorage(key, data) { plus.storage.setItem(key, JSON.stringify(data)); }, setPageTitle(title) { plus.navigator.setStatusBarTitle(title); } } }; // adapter.js function createUniAdapter(platform) { const impl platformImpls[platform]; return new Proxy({}, { get(_target, prop) { if (impl typeof impl[prop] function) { return impl[prop].bind(impl); } return undefined; } }); } const currentPlatform uni.getSystemInfoSync().uniPlatform; // h5 / mp-weixin / app export const adapter createUniAdapter(currentPlatform); // 业务里直接这样用 adapter.setPageTitle(首页); const user adapter.getStorage(user);这里有几个细节值得展开说。第一为什么get里要bind(impl)因为impl[prop]取出来的函数直接 return 给调用方后函数内部的this会变成undefined或者全局对象。在 H5 端如果你写localStorage没关系但如果某个实现内部依赖this.setStorage之类的兄弟方法this丢了就报错。所以提前bind一下让函数内部的this始终指向实现集合。第二如果访问的prop在实现集合里不存在返回undefined就完了吗千万别这么草率。一旦某个 API 漏配业务层拿到undefined后直接调用会抛出“not a function”的错误。经验做法是在get陷阱里加一个console.warn至少让漏配问题在开发阶段就暴露出来后面我会在第四节专门讲调试技巧。第三这套方案要不要直接替换全局的uni对象我个人的建议是尽量分开。你可以在自己的模块里导出一个adapter但不要动不动就uni.xxx adapter.xxx。因为 uni-app 内部很多组件和插件会直接调用全局uniAPI一旦你覆盖了某个函数可能连带影响框架自身的运行逻辑。真要扩展也应该在最顶层入口统一处理并且保留原始对象引用。2.3 Proxy 不是万能的这样写才不会踩性能坑Proxy 很灵活但绝不是银弹尤其在移动端环境里用不好反而会引入性能问题。最常见的一个坑是过度分发。假设你在业务代码里高频调用uni.getSystemInfoSync()如果每次属性访问都走一遍 Proxy 的get陷阱叠加typeof判断、平台匹配、bind操作虽然单次耗时微乎其微但乘以几百上千次调用在低端安卓机上就能感知到卡顿。更糟糕的是bind每次会生成一个新函数如果你把adapter.getStorage作为参数传给某个事件监听函数函数引用每次都不同还可能引发“监听器重复添加、移除无效”这种疑难杂症。我的习惯是“冷热分离”启动时把高频 API 直接从 adapter 里解构出来缓存成局部变量Proxy 只负责低频 API 的动态分发。这样既保留了读代码时的优雅也不至于让性能为技巧买单。// 高频 API 从 adapter 中一次性取出并缓存 const { getStorage, setStorage } adapter; // 低频、不常访问的 API 仍然走 Proxy 动态分发 adapter.setPageTitle(我的页面);另一个容易踩的坑是set陷阱被滥用。很多人看到 Proxy 觉得什么都能拦截于是想用set做“设置即存储”的魔法。但移动端对象赋值非常频繁set陷阱里一旦做了同步存储或序列化操作性能会直线下降。所以我的原则是set陷阱除非必要比如权限控制、数据脱敏否则尽量不要加逻辑能不用就不用。3. wrapper 实战三个跨端模块从封装到落地3.1 统一 storage把四种存储协议收敛成一套接口如果只做一个 wrapper我一定建议先封装 storage。因为它是最常见、又最容易踩数据格式坑的模块。我的封装会考虑四件事命名空间隔离、JSON 序列化兼容、旧数据容错、可选过期时间。const STORAGE_PREFIX app_demo_; export function getStorage(key) { const fullKey STORAGE_PREFIX key; try { const value uni.getStorageSync(fullKey); if (typeof value string) { try { return JSON.parse(value); } catch (e) { // 兼容旧数据不是 JSON 格式原样返回字符串 return value; } } return value; } catch (e) { return null; } } export function setStorage(key, data, expires 0) { const fullKey STORAGE_PREFIX key; const payload JSON.stringify({ data, expiresAt: expires ? Date.now() expires : 0 }); uni.setStorageSync(fullKey, payload); } export function removeStorage(key) { const fullKey STORAGE_PREFIX key; uni.removeStorageSync(fullKey); }这套封装的核心思路是无论底层是 localStorage 还是 wx storage业务层拿到的一定是“已经解析好的对象或原始类型”。因为 H5 的localStorage只支持字符串而微信小程序端返回的是原对象为了两端统一我在写入时主动JSON.stringify读取时再JSON.parse。同时用try/catch兜底如果你之前用别的方式存过纯字符串JSON.parse会抛错这时候原样返回字符串保证老数据不会被误伤。为什么加expires过期时间因为小程序端 localStorage 和 App 端 storage 本身都没有过期概念而业务里“登录态有效期 7 天”“验证码 5 分钟有效”这类需求非常常见。与其在每个页面里手动比较时间戳不如在封装层统一处理。这个能力在 H5 端也能用等于把平台缺失的能力补齐了。3.2 包装 request把回调式 API 改造成 Promise 且统一错误uni.request在微信小程序端是回调式的虽然提供了success/fail/complete但业务代码一旦涉及多个串行请求回调嵌套就能写到怀疑人生。我的项目里早就把所有请求都 wrapper 成了 Promise 风格顺带把状态码判断、超时处理、错误信息清洗一起做掉。export function request(options) { return new Promise((resolve, reject) { uni.request({ url: options.url, method: options.method || GET, data: options.data || {}, header: Object.assign( { Content-Type: application/json }, options.header || {} ), timeout: options.timeout || 10000, success(res) { if (res.statusCode 200 res.statusCode 300) { resolve(res.data); } else { reject({ code: res.statusCode, message: 请求失败状态码 ${res.statusCode}, data: res.data }); } }, fail(err) { // 清洗 errMsg把 request:fail xxx 这种前缀去掉 const message (err.errMsg || 网络异常).replace(/^request:fail\s*/, ); reject({ code: -1, message }); } }); }); } // 业务用法 try { const data await request({ url: /api/user/list, method: GET }); // 到这里 data 已经是后端返回的业务数据 } catch (e) { console.error(e.message); }这里有几个值得所有跨端开发者注意的细节。状态码判断必须在 wrapper 层做掉不能让业务代码到处判断statusCode 200。因为不同端的statusCode字段基本类似但data层级可能不同统一在 wrapper 里 normalize 以后业务才能安心使用。fail 分支必须处理这是最容易漏的。很多 callback 式 API 用户写 demo 时只传 success上线后网络一波动就找不到原因。封装成 Promise 后强制要求调用方 catch反而把错误处理习惯养成了。另外还要说一个真实存在、但 wrapper 无法解决的差异H5 端请求会受跨域限制小程序端不会。你在微信开发者工具里跑通的接口放到浏览器 H5 环境可能直接被 CORS 拦下。这不是 uni-app 的锅是浏览器安全策略决定的所以后端必须正确配置跨域白名单。这一点想清楚排查线上问题时能少走很多弯路。3.3 动态设置导航栏标题Proxy 和 wrapper 是怎么配合的动态设置页面标题是个典型场景因为它最能体现 Proxy 和 wrapper 的联动关系。在微信小程序里改标题要走wx.setNavigationBarTitle在 H5 里你改的是document.title在 App 端又是另一套plus.navigator接口。如果业务代码直接调uni.setNavigationBarTitle在 H5 端往往得不到你预期的效果因为网页标签页和小程序导航栏根本不是同一个东西。我先用 wrapper 把设置标题统一成一个业务函数export function setPageTitle(title) { // #ifdef H5 document.title title; return Promise.resolve(); // #endif // #ifndef H5 return new Promise((resolve, reject) { uni.setNavigationBarTitle({ title, success: resolve, fail: reject }); }); // #endif }这个 wrapper 的好处是业务层永远只需要调setPageTitle(订单详情)不用关心自己在哪个端。但问题是每个页面都得手动 import 这个函数一旦换项目或者换人维护容易漏。这时候 Proxy 就能补上“最后一块拼图”把setPageTitle也放进平台实现表通过统一的adapter对象访问。// platform-impls.js 里加一个实现 const impl { h5: { async setPageTitle(title) { document.title title; } }, mp-weixin: { setPageTitle(title) { return new Promise((resolve, reject) { uni.setNavigationBarTitle({ title, success: resolve, fail: reject }); }); } } };这样业务里就能写await adapter.setPageTitle(我的页面)这行代码在不同端自动路由到不同的实现。Proxy 负责“告诉业务层只有一个入口”wrapper 负责“在每个实现内部把细节处理好”二者各司其职又互相成就。如果你项目里页面标题经常要随数据变化这套组合会非常顺手。4. 常见问题与排查技巧实录4.1 基础库太低导致 Proxy 报错怎么办Proxy 是 ES6 特性它本身需要运行环境支持。在 H5 端Proxy对浏览器版本有要求IE11 完全不支持iOS 9 及以下的旧 WebView 也有兼容问题。在微信小程序里基础库版本太低时同样可能因为编译到 ES5 而丢失 Proxy 能力。这可不是概率问题真机上一旦报undefined is not a constructor (evaluating new Proxy(...))整个页面都起不来。我的做法是给 adapter 加一个能力检测 fallbackfunction createUniAdapter(platform) { const impl getPlatformImpl(platform); if (typeof Proxy ! undefined) { return new Proxy({}, { get(_target, prop) { if (impl typeof impl[prop] function) { return impl[prop].bind(impl); } return undefined; } }); } // 兜底方案直接把所有实现摊开成普通对象 return Object.assign({}, impl); }兜底方案虽然丢失了“动态分发”的能力但至少 API 还能正常工作。业务层在兼容模式下不要依赖“访问不存在的属性返回 undefined”这种方式判断 API 是否存在而应该用typeof adapter.xxx function来检查。想彻底规避这个问题还可以在 manifest.json 配置好最低基础库版本并在文档里明确要求 iOS 10。另外补充一点App 端使用不同渲染引擎时对 Proxy 的支持也有差异。如果你的产品需要兼容很老的安卓 WebView建议 App 端以 wrapper 为主、Proxy 为辅尽量别在 App 启动阶段大量使用 Proxy。4.2 回调式 wrapper 最容易漏掉的 fail 分支封装 callback 风格 API 成 Promise 时最常见的坑是只处理了 success没处理 fail。尤其像uni.setNavigationBarTitle、uni.showLoading这类“看起来一定成功”的 API大家潜意识里觉得不会失败于是 fail 里就随便传个空函数。实际生产环境里setNavigationBarTitle可能因为页面栈异常而失败showLoading在频繁调用时也可能因为未关闭而产生诡异行为。我的建议是所有 wrapper 一律返回 Promise并且reject时带上原始错误对象export function hideLoading() { return new Promise((resolve) { uni.hideLoading({ success: resolve, fail: (err) resolve({ code: -1, ...err }) }); }); }注意我这里hideLoading用了resolve而不是reject。为什么因为 loading 关闭失败通常不影响主流程如果reject出去调用方一旦忘记catch控制台会刷一堆 Unhandled Promise Rejection反而掩盖真正的问题。所以封装时要想清楚“这个 API 的失败是否真的需要业务感知”不是所有失败都要往上抛。4.3 热路径性能问题什么时候不要用 ProxyProxy 每次 get 拦截都有额外开销而且在 2.3 节提到过bind制造新函数的问题。举个例子如果你在列表页的onShow里访问adapter.getStorageSync拿了设置项然后在模板渲染里高频使用每次访问都走 Proxy 就很不划算。经验法则是对调用频率高、返回结果相对稳定的 API从 adapter 解构到局部变量或者模块顶层。// 顶层一次性取出 const api { getStorageSync: adapter.getStorageSync, getSystemInfoSync: adapter.getSystemInfoSync }; // 页面里直接用 api.getSystemInfoSync()这样既保留了一处修改、处处生效的好处又让最热路径上的调用退化成普通函数调用。说到底Proxy 适合做“低频但需要统一入口”的分发不适合做“每帧都在跑”的热点逻辑。这个度把握好代码既优雅又稳。4.4 调试平台分发让 Proxy 把命中过程打印出来我最喜欢的 Proxy 使用场景之一是调试平台差异的时候。很多跨端问题让人抓狂是因为“不知道当前到底走的哪套实现”。既然 Proxy 拦截了所有属性访问那让它顺手打个日志不是正好吗const debug true; function createUniAdapter(platform) { const impl getPlatformImpl(platform); return new Proxy({}, { get(_target, prop) { if (debug) { console.warn([adapter] 访问 ${String(prop)}平台 ${platform}命中${typeof impl?.[prop] function ? 是 : 否}); } if (impl typeof impl[prop] function) { return impl[prop].bind(impl); } return undefined; } }); }把所有 API 的访问日志打到控制台之后你可以在开发者工具里看到完整的分发轨迹比如“某个 API 一直 miss说明实现表里没配这段逻辑”。等查完问题把debug开关关掉就行不用到处删代码。如果想更进一步还可以用 Proxy 的ownKeys陷阱做“API 清单完整性检查”。把期望的最少 API 清单列出来用Reflect.ownKeys(adapter)和清单做 diff启动时一次性输出缺失项。这种元编程的用法在生产环境里做健康检查效果出其不意地好。5. 一点个人体会这套 Proxy wrapper 的方案我最初并不是一次设计出来的。早期项目里我只是在业务层封装了各种工具函数结果发现 uni-app 内部的组件、插件有时也会直接调全局uniAPI我封装的工具根本管不到它们。后来我才把思路收敛到“在框架入口统一补 API 在访问层做代理”才真正体会到什么叫“一处治理处处生效”。另外还有个经验之谈Proxy 虽然强大但不要用得太花哨。我见过有人用 Proxy 做“魔法对象”访问任意属性都自动拼接字符串、自动发请求结果代码读起来像天书调试时根本不知道哪个属性被访问了。元编程是把复杂度收进封装层让业务层更简单不是反过来制造新的魔法。如果你维护的团队里有后端转前端的新人多写几行朴素的 wrapper少玩一点 Proxy 的骚操作反而更稳。真正走到生产环境之后你会发现跨端开发里的很多“坑”其实都不是框架的 bug而是平台本身的特性。理解了 Proxy 和 wrapper 这套组合拳你就等于有了一个统一视角不管底层平台怎么变我的业务代码始终只面对一套稳定的接口剩下的差异全交给调度层去处理。这大概就是“抹平平台差异”最实在的落地方式了。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门