多端统一你真的会了吗?

215 阅读6分钟

多端统一适配指南:告别 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 的地方,判断当前环境,然后执行对应的代码:

javascript

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。

代码示例(简化版)

javascript

// 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;
// ... 其他统一方法

各适配器实现:

javascript

// 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 分享实现
  }
};

javascript

// adapters/miniAdapter.js
export default {
  pay(orderInfo) {
    return new Promise((resolve, reject) => {
      wx.requestPayment({
        ...orderInfo,
        success: resolve,
        fail: reject
      });
    });
  }
  // ...
};

业务代码调用:

javascript

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(),只在特定环境加载对应适配器代码,减少主包体积。


五、适配器编写的深化:高阶函数与柯里化批量生成

当适配器需要暴露大量 API 时,手动为每个 API 编写 Promise 包装函数会变得冗长且容易出错。这时,我们可以利用高阶函数和柯里化来批量生成适配器方法。

5.1 常见 SDK 调用模式分析

不同容器的 SDK 调用方式主要有以下几种:

模式特征示例
模块调用统一入口(如 invokecall),第一个参数是模块名,后续是参数和回调window.App1JSBridge.invoke('pay', params, callback) window.Native.call('PayOrder', JSON.stringify(params))
直接调用(带 success/fail)全局对象下直接挂载方法,参数对象内包含 success 和 fail 回调wx.requestPayment({ ..., success, fail })
直接调用(单一回调)方法接收参数 + 一个回调函数,回调中通过错误码区分成功失败AlipayJSBridge.call('tradePay', params, callback) window.AppXBridge.share(params, callback)
直接调用(返回 Promise)方法直接返回 Promise某些现代浏览器 API 或封装过的 SDK

同一个端内可能混合多种模式。例如,微信小程序主要使用直接调用(success/fail),但某些插件可能提供模块调用方式;而某些 App 的 JSBridge 既有 invoke 用于通用功能,也有暴露的快捷方法如 share()

5.2 设计生成器函数

5.2.1 直接调用模式的生成器

针对直接调用模式,我们设计一个通用的 createDirectCaller 函数,通过传入方法获取器和回调类型来适应不同变体。

javascript

/**
 * 生成直接调用方式的适配函数
 * @param {Function} methodGetter - 返回实际要调用的方法(如 () => wx.requestPayment)
 * @param {Object} options
 * @param {Function} options.paramMapper - 参数映射(可选)
 * @param {Function} options.resultMapper - 结果映射(可选)
 * @param {string} options.callbackType - 'successFail' | 'singleCallback' | 'promise'
 */
function createDirectCaller(methodGetter, options = {}) {
  const {
    paramMapper = p => p,
    resultMapper = r => r,
    callbackType = 'successFail',
  } = options;

  return (params) => {
    const method = methodGetter();
    if (!method) {
      return Promise.reject(new Error('Method not available'));
    }

    const mappedParams = paramMapper(params);

    if (callbackType === 'promise') {
      // 方法本身返回 Promise
      return method(mappedParams).then(resultMapper);
    }

    // Promise 化包装
    return new Promise((resolve, reject) => {
      if (callbackType === 'successFail') {
        method(mappedParams, (res) => resolve(resultMapper(res)), (err) => reject(err));
      } else if (callbackType === 'singleCallback') {
        method(mappedParams, (res) => {
          // 假设成功时 res.code === 0
          if (res.code === 0) {
            resolve(resultMapper(res.data));
          } else {
            reject(res);
          }
        });
      }
    });
  };
}
5.2.2 模块调用模式的生成器

针对模块调用模式,设计 createModuleInvoker 函数,通过指定桥接对象、调用方法和模块名来生成适配函数。

javascript

/**
 * 生成模块调用方式的适配函数
 * @param {Object} bridge - 入口对象,如 window.App1JSBridge
 * @param {string} invokeMethod - 调用方法名,如 'invoke' 或 'call'
 * @param {string} moduleName - 模块名
 * @param {Object} options
 * @param {Function} options.paramMapper - 参数映射
 * @param {Function} options.resultMapper - 结果映射
 * @param {string} options.callbackPosition - 'last' | 'second',回调在参数列表中的位置
 */
function createModuleInvoker(bridge, invokeMethod, moduleName, options = {}) {
  const {
    paramMapper = p => p,
    resultMapper = r => r,
    callbackPosition = 'last',
  } = options;

  return (params) => {
    if (!bridge || typeof bridge[invokeMethod] !== 'function') {
      return Promise.reject(new Error('Bridge not available'));
    }

    const mappedParams = paramMapper(params);

    return new Promise((resolve, reject) => {
      const callback = (res) => {
        if (res.code === 0) {
          resolve(resultMapper(res.data));
        } else {
          reject(res);
        }
      };

      if (callbackPosition === 'last') {
        bridge[invokeMethod](moduleName, mappedParams, callback);
      } else if (callbackPosition === 'second') {
        bridge[invokeMethod](moduleName, callback, mappedParams);
      }
    });
  };
}

5.3 参数与返回值归一化

通过注入 paramMapper 和 resultMapper,可以在生成器层面统一处理格式转换,避免在适配器代码中重复编写转换逻辑。

例如,业务层支付参数统一为 { orderId, amount },而 App1 需要 { order_id, total_fee },App2 需要 { OrderId: ..., TotalFee: ... } 的 JSON 字符串。我们可以在配置时指定转换函数:

javascript

// App1 适配器
export default {
  pay: createModuleInvoker(window.App1JSBridge, 'invoke', 'pay', {
    paramMapper: (p) => ({ order_id: p.orderId, total_fee: p.amount }),
    resultMapper: (r) => ({ transactionId: r.transaction_id }),
  }),
};

// App2 适配器
export default {
  pay: createModuleInvoker(window.Native, 'call', 'PayOrder', {
    paramMapper: (p) => JSON.stringify({ OrderId: p.orderId, TotalFee: p.amount }),
    resultMapper: (r) => ({ transactionId: r.TransactionId }),
  }),
};

5.4 柯里化实现动态适配器加载

我们可以将环境识别与适配器生成结合,通过柯里化预先注入环境信息,避免每次调用都判断。

javascript

// sdk/core.js
let currentAdapter = null;

async function initSDK() {
  const env = detectEnv();
  switch (env) {
    case 'app1':
      currentAdapter = await import('./adapters/app1.js');
      break;
    case 'app2':
      currentAdapter = await import('./adapters/app2.js');
      break;
    // ...
  }
  // 返回代理对象,直接调用适配器方法
  return new Proxy(currentAdapter, {
    get(target, prop) {
      if (typeof target[prop] === 'function') {
        return target[prop];
      }
      throw new Error(`Method ${prop} not supported in current env`);
    },
  });
}

// 业务层使用
const sdk = await initSDK();
sdk.pay({ orderId: '123' }).then(...);

六、更进一步的配置化:混合调用模式的统一处理

当同一个端内同时存在模块调用和直接调用两种模式时,我们需要一套配置驱动的机制来统一管理。下面通过一个具体示例来展示如何实现。

6.1 识别调用模式

假设某端(AppX)的 JSBridge 同时提供两种调用方式:

javascript

// 模块调用方式
window.AppXBridge.invoke('pay', params, callback);

// 直接调用方式
window.AppXBridge.share(params, callback);

我们需要为这两个 API 分别适配,并且未来可能增加更多 API。

6.2 配置化生成适配器

首先定义 API 配置表,描述每个业务方法对应的调用模式及细节。

javascript

// adapters/appx.js
const bridge = window.AppXBridge;

const apiConfigs = {
  pay: {
    type: 'module',               // 模块调用
    moduleName: 'pay',
    paramMapper: (p) => ({ order_id: p.orderId, amount: p.amount }),
    resultMapper: (r) => ({ transactionId: r.transaction_id }),
  },
  share: {
    type: 'direct',               // 直接调用
    methodGetter: () => bridge.share,
    callbackType: 'singleCallback',
    paramMapper: (p) => ({ title: p.title, url: p.link }),
  },
  getDeviceInfo: {
    type: 'direct',
    methodGetter: () => bridge.getDeviceInfo,
    callbackType: 'successFail',  // 假设有 success/fail 回调
  },
};

// 构建适配器对象
const adapter = {};

for (const [apiName, config] of Object.entries(apiConfigs)) {
  if (config.type === 'module') {
    adapter[apiName] = createModuleInvoker(
      bridge,
      'invoke',
      config.moduleName,
      {
        paramMapper: config.paramMapper,
        resultMapper: config.resultMapper,
        callbackPosition: config.callbackPosition || 'last',
      }
    );
  } else if (config.type === 'direct') {
    adapter[apiName] = createDirectCaller(
      config.methodGetter,
      {
        paramMapper: config.paramMapper,
        resultMapper: config.resultMapper,
        callbackType: config.callbackType,
      }
    );
  }
}

export default adapter;

6.3 动态识别调用模式

如果某个端的 API 非常庞杂,手动维护配置表仍显繁琐,我们可以编写一个自动适配器生成函数,通过传入调用入口和方法映射规则,动态生成大部分方法,然后手动覆盖特殊方法。

javascript

// 自动生成大部分 API(假设都是模块调用)
const defaultMethods = ['pay', 'share', 'getLocation', 'openWebView'];
const autoAdapter = {};
defaultMethods.forEach(method => {
  autoAdapter[method] = createModuleInvoker(window.JSBridge, 'call', method);
});

// 手动覆盖特殊方法
autoAdapter.getDeviceInfo = createDirectCaller(() => window.JSBridge.getDeviceInfo, {
  callbackType: 'singleCallback',
});

export default autoAdapter;

6.4 完整示例:混合模式适配器

下面是一个完整的适配器文件,展示了如何同时处理模块调用和直接调用,并支持参数/结果映射。

javascript

// adapters/appx.js
import { createModuleInvoker, createDirectCaller } from './generators';

const bridge = window.AppXBridge;

// 配置表
const configs = {
  pay: {
    type: 'module',
    moduleName: 'pay',
    paramMapper: (p) => ({ order_id: p.orderId, amount: p.amount }),
    resultMapper: (r) => ({ transactionId: r.transaction_id }),
  },
  share: {
    type: 'direct',
    methodGetter: () => bridge.share,
    callbackType: 'singleCallback',
  },
  getLocation: {
    type: 'module',
    moduleName: 'getLocation',
    resultMapper: (r) => ({ lat: r.latitude, lng: r.longitude }),
  },
  login: {
    type: 'direct',
    methodGetter: () => bridge.login,
    callbackType: 'promise', // 假设 login 返回 Promise
  },
};

// 生成适配器
const adapter = {};
for (const [name, cfg] of Object.entries(configs)) {
  if (cfg.type === 'module') {
    adapter[name] = createModuleInvoker(bridge, 'invoke', cfg.moduleName, {
      paramMapper: cfg.paramMapper,
      resultMapper: cfg.resultMapper,
    });
  } else if (cfg.type === 'direct') {
    adapter[name] = createDirectCaller(cfg.methodGetter, {
      paramMapper: cfg.paramMapper,
      resultMapper: cfg.resultMapper,
      callbackType: cfg.callbackType,
    });
  }
}

export default adapter;

业务层使用:

javascript

import { pay, share, login } from '@/adapters/appx';

pay({ orderId: '123', amount: 100 }).then(res => {
  console.log('支付成功', res.transactionId);
});

share({ title: '分享标题', link: 'https://example.com' });

login().then(userInfo => {
  console.log('登录成功', userInfo);
});

七、最佳实践建议

  1. 环境识别要健壮:不仅靠 UA,还要探测特有对象,并处理边缘情况(如 iOS 与 Android 的差异)。识别顺序应当先判断最特定容器,再回退到通用环境。
  2. 统一错误处理:定义一套错误码,例如 E_PAY_FAILEDE_NOT_SUPPORTED,便于业务方统一处理。可以在生成器内部将原始错误包装为自定义错误对象。
  3. 提供同步与异步:所有适配方法统一返回 Promise,保持一致性。
  4. 编写单元测试:使用 Jest 等工具 mock 不同端的全局对象,测试适配器逻辑。可以针对每个生成器编写独立测试用例。
  5. 文档完善:详细说明每个 API 的参数、返回值、各端支持情况,以及降级策略。建议生成 API 文档时附上各端支持矩阵。
  6. 考虑扩展性:未来可能出现新端,设计时要预留扩展点,比如通过插件机制注册新适配器,或通过配置表动态加载。
  7. 性能优化:对于体积较大的适配器,可以使用动态 import() 按需加载。对于频繁调用的 API,可以缓存适配器实例。
  8. 类型支持:提供 TypeScript 类型定义,增强开发体验。可以定义统一的参数和返回值类型,并在各适配器中继承。

八、总结:从混乱到优雅的演进之路

多端统一适配的核心思想是分离变化。从业务代码中抽离出环境差异,通过适配器模式封装变化,再进一步利用高阶函数和配置化生成器来处理同一个端内混合调用模式的复杂场景,最终可以封装成开箱即用的 SDK,实现从混乱到清晰、从耦合到解耦的演进。

方案优点缺点适用场景
if else简单直接维护成本高、代码冗余仅 1-2 个端,极少调用点
适配器模式业务统一、易扩展初期设计成本多端并存,调用点多
高阶函数+配置化高效生成、灵活应对混合模式需要一定的抽象能力中大型项目,API 数量多,模式复杂
SDK 封装复用、解耦、易维护需要额外打包发布中大型项目,多个业务复用

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