多端统一你真的会了吗?

0 阅读9分钟

多端统一适配指南:告别 if else

引言:多端 H5 的「分裂」之痛

随着移动互联网的发展,H5 页面早已不只在手机浏览器中运行。它可能被嵌入多个不同的 App(如公司主 App、合作方 App)、运行在微信/支付宝/字节等小程序容器中,甚至还要兼容 PC 浏览器。每个端都有一套自己的 JS-SDK,用来调用原生能力(支付、分享、登录、地理位置等)。这些 SDK 的方法名、参数格式、返回值规范往往各不相同,甚至有些端根本不存在某些能力。

当业务方要求「一套 H5 代码,多端运行」时,开发者面临的第一道坎就是:如何优雅地处理这些差异?

直接 if else 判断环境可能是最先想到的方案,但这是最优解吗?本文将带你从原始 if else 出发,逐步演进到适配器模式,并最终封装成开箱即用的 SDK,彻底解决多端 API 调用的混乱局面。


一、问题复现:不同端的 API 差异有多大?

假设我们需要实现一个支付功能,在三端调用方式如下:

  • App1:通过 window.App1JSBridge.invoke('pay', params, callback) 调用,参数是对象,回调返回结果。
  • App2:通过 window.Native.call('PayOrder', JSON.stringify(params)) 调用,参数是 JSON 字符串,返回值是 Promise。
  • 小程序:使用 wx.requestPayment(params),参数格式完全不同,且返回格式也与 App 不同。

如果业务代码直接写死某端的调用,那在其他端就会报错。于是,第一个朴素的想法诞生了:

二、方案一:业务代码中的「万能 if else」

实现方式

在每个需要调用 SDK 的地方,判断当前环境,然后执行对应的代码:

function pay(orderInfo) {
  const env = getEnv(); // 返回 'app1' | 'app2' | 'mini' | 'web'
  
  if (env === 'app1') {
    window.App1JSBridge.invoke('pay', orderInfo, (res) => {
      if (res.code === 0) console.log('支付成功');
    });
  } else if (env === 'app2') {
    window.Native.call('PayOrder', JSON.stringify(orderInfo))
      .then(res => console.log('支付成功'));
  } else if (env === 'mini') {
    wx.requestPayment({
      timeStamp: orderInfo.timeStamp,
      nonceStr: orderInfo.nonceStr,
      package: orderInfo.package,
      signType: 'MD5',
      paySign: orderInfo.paySign,
      success: () => console.log('支付成功')
    });
  } else {
    // Web 端没有原生支付,可能跳转 H5 支付页面
    window.location.href = `https://pay.example.com?order=${orderInfo.id}`;
  }
}

优点

  • 简单直观:新手也能立即上手,无需设计额外抽象。
  • 快速实现:对于少量调用点,能迅速完成适配。

缺点

  • 代码膨胀:每个需要适配的地方都要写一堆 if else,随着调用点增多,代码行数爆炸。
  • 维护噩梦:当新增一个端(比如 App3),你需要搜索整个项目,找到所有用到相关 API 的地方,逐个添加 else if。极易遗漏。
  • 违反开闭原则:对修改是开放的,对扩展却是封闭的——每增加新端,必须修改已有业务代码。
  • 可读性差:业务逻辑与适配逻辑高度耦合,阅读者需要同时理解业务和所有端的 API 细节。
  • 测试困难:无法轻松模拟某个端的返回值,单元测试需要 mock 多个环境。

显然,if else 只适合极少数、极简单的场景。当项目发展到一定规模,必须寻找更优雅的方案。


三、方案二:适配器模式——将变化封装起来

适配器模式(Adapter Pattern)的核心思想是:定义一个统一接口,内部封装不同端的实现细节,对外提供一致的方法调用。  业务代码只需依赖这个接口,无需关心具体是哪个端。

3.1 定义统一接口

首先,根据业务需求定义一套“理想”的 API,例如支付功能统一为 pay(orderInfo) 方法,返回 Promise。

3.2 创建各端适配器

分别为 App1、App2、小程序等编写适配器,实现上述统一接口,内部调用各自的 SDK。

3.3 根据环境选择适配器

在应用启动时,通过环境识别函数,决定使用哪个适配器,并导出统一 API。

代码示例(简化版)
// adapters/index.js
import app1Adapter from './app1Adapter';
import app2Adapter from './app2Adapter';
import miniAdapter from './miniAdapter';
import webAdapter from './webAdapter';

const env = getEnv(); // 返回 'app1' | 'app2' | 'mini' | 'web'

let adapter;
switch (env) {
  case 'app1':
    adapter = app1Adapter;
    break;
  case 'app2':
    adapter = app2Adapter;
    break;
  case 'mini':
    adapter = miniAdapter;
    break;
  default:
    adapter = webAdapter;
}

export const pay = adapter.pay;
export const share = adapter.share;
// ... 其他统一方法

各适配器实现:

// adapters/app1Adapter.js
export default {
  pay(orderInfo) {
    return new Promise((resolve, reject) => {
      window.App1JSBridge.invoke('pay', orderInfo, (res) => {
        res.code === 0 ? resolve(res) : reject(res);
      });
    });
  },
  share(shareData) {
    // ... App1 分享实现
  }
};
// adapters/miniAdapter.js
export default {
  pay(orderInfo) {
    return new Promise((resolve, reject) => {
      wx.requestPayment({
        ...orderInfo,
        success: resolve,
        fail: reject
      });
    });
  }
  // ...
};

业务代码调用:

import { pay } from '@/adapters';

async function checkout() {
  try {
    await pay({ orderId: '123', amount: 100 });
    showSuccess('支付成功');
  } catch (err) {
    showError('支付失败');
  }
}

优点

  • 业务代码统一:所有调用处只有一行 pay(),无需任何 if else。
  • 易于维护:新增端只需新建适配器,修改原有端的实现也只影响适配器文件,业务代码无感知。
  • 可测试性:可以轻松 mock 适配器,进行单元测试。
  • 符合开闭原则:对扩展开放(加新端只需加适配器),对修改封闭(业务代码不动)。

缺点

  • 初期设计成本:需要抽象出统一接口,并考虑各端差异(比如参数格式转换、错误码归一化)。
  • 可能引入间接层:如果适配器实现过于复杂,可能带来性能损耗(通常可忽略)。

3.4 适配器架构图

deepseek_mermaid_20260313_ac30c6.png


四、适配器模式就够了吗?还得考虑这些!

适配器模式解决了 API 调用的统一,但在实际项目中,我们还需要关注以下问题:

4.1 环境识别

getEnv() 如何实现?通常需要结合 UserAgent、全局变量、容器特性等综合判断。例如:

  • 判断是否在微信小程序:typeof wx !== 'undefined' && wx.requestPayment
  • 判断是否在 App1:window.App1JSBridge 是否存在
  • 判断是否在 App2:window.Native 是否存在

注意识别顺序(有些容器可能同时满足多个条件),一般优先级高的先判断。

4.2 参数格式转换

不同端的参数格式差异可能很大,适配器内部需要做转换。例如 App2 需要 JSON 字符串,而统一接口接收的是对象,适配器里要 JSON.stringify

4.3 返回值统一

各端成功/失败的回调形式不同,有的用回调,有的用 Promise,有的错误码不同。适配器应统一返回 Promise,并将错误标准化(如统一抛出特定错误码)。

4.4 能力降级

某些端可能不支持某个功能(比如 Web 端没有原生支付),适配器可以优雅降级:例如跳转 H5 支付页,或者抛出一个特殊错误,让业务方决定如何处理。

4.5 初始化与生命周期

有些 SDK 需要先初始化(如监听 ready 事件),适配器可能需要提供 init() 方法,并在内部管理状态。

4.6 按需加载

如果适配器体积较大,可以考虑使用动态 import(),只在特定环境加载对应适配器代码,减少主包体积。


五、更进一步:将适配器封装为 SDK

既然适配器层已经将多端差异隔离,为什么不把它打包成一个独立的 npm 包,让所有业务线直接安装使用呢?这样不仅避免了重复造轮子,还能统一维护和升级。

5.1 SDK 设计目标

  • 零配置或极简配置:业务方安装后,直接引入方法即可使用。
  • 自动环境识别:内部自动判断当前端,加载对应适配器。
  • 统一 API:所有功能通过命名空间导出,如 sdk.paysdk.share
  • 类型支持:提供 TypeScript 定义,增强开发体验。
  • 轻量:按功能拆分,支持 tree-shaking。

5.2 SDK 结构示例

text

multi-end-sdk/
├── src/
│   ├── adapters/          # 各端适配器实现
│   │   ├── app1.js
│   │   ├── app2.js
│   │   ├── mini.js
│   │   └── web.js
│   ├── env.js             # 环境识别
│   ├── index.js           # 统一导出
│   └── types/             # TypeScript 类型定义
├── package.json
└── README.md

index.js 核心逻辑:

import { detectEnv } from './env';

let adapter = null;

async function loadAdapter() {
  if (adapter) return adapter;
  
  const env = detectEnv();
  // 动态加载对应适配器
  switch (env) {
    case 'app1':
      adapter = await import('./adapters/app1.js');
      break;
    case 'app2':
      adapter = await import('./adapters/app2.js');
      break;
    // ...
    default:
      adapter = await import('./adapters/web.js');
  }
  return adapter;
}

// 创建代理方法,确保每次调用前适配器已加载
export async function pay(params) {
  const mod = await loadAdapter();
  return mod.pay(params);
}

export async function share(params) {
  const mod = await loadAdapter();
  return mod.share(params);
}

5.3 业务方使用

npm install @company/multi-end-sdk
import { pay } from '@company/multi-end-sdk';

pay({ orderId: '123' }).then(...);

5.4 优点

  • 复用性:一次编写,多项目使用,统一升级。
  • 解耦更彻底:业务代码完全不关心适配逻辑,只需依赖 SDK 的 API。
  • 便于团队协作:由基础设施团队维护 SDK,业务团队专注业务。
  • 版本管理:通过 npm 版本控制,可以平滑升级,降级回退。

六、最佳实践建议

  1. 环境识别要健壮:不仅靠 UA,还要探测特有对象,并处理边缘情况(如 iOS 与 Android 的差异)。
  2. 统一错误处理:定义一套错误码,例如 E_PAY_FAILEDE_NOT_SUPPORTED,便于业务方统一处理。
  3. 提供同步与异步:有些方法可能同步返回,但建议统一使用 Promise 或 async/await,保持一致性。
  4. 编写单元测试:使用 Jest 等工具 mock 不同端的全局对象,测试适配器逻辑。
  5. 文档完善:详细说明每个 API 的参数、返回值、各端支持情况,以及降级策略。
  6. 考虑扩展性:未来可能出现新端,设计时要预留扩展点,比如通过插件机制注册新适配器。

七、总结

多端统一适配的核心思想是分离变化。从业务代码中抽离出环境差异,通过适配器模式封装变化,再进一步封装成 SDK,实现了从混乱到清晰、从耦合到解耦的演进。

方案优点缺点适用场景
if else简单直接维护成本高、代码冗余仅 1-2 个端,极少调用点
适配器模式业务统一、易扩展初期设计成本多端并存,调用点多
SDK 封装复用、解耦、易维护需要额外打包发布中大型项目,多个业务复用

最终,我们得到的不仅是一套技术方案,更是一种工程思维:面向接口编程,而非面向实现编程。当你的 H5 需要跑在越来越多的端上时,这套方案将帮助你保持代码的优雅与可维护性。

希望本文能为你提供切实可行的多端适配思路。如果你有更好的实践,欢迎在评论区交流讨论!