15

跨端与多端复用

1,148 行约 53 分钟33 个代码块30 张表格更新于 2026-11-03

15 · 跨端与多端复用(详解版)

一句话定位:跨端不是「一套代码跑所有地方」,而是在明确各端能力差异的前提下,最大化复用、最小化差异成本。

读完本篇你应该能做到

  1. 说清 H5、小程序、React Native、Flutter、桌面端、PWA 的架构与适用边界;
  2. 独立设计 JSBridge 与端能力抽象层,处理回调、超时与降级;
  3. 判断哪些代码该复用、哪些必须分端实现;
  4. 为团队做出可解释的跨端选型与演进路线。

0. 领域导论

0.1 这个领域解决什么问题

现象根因归属
小程序打开白屏,真机才复现主包超限 / 低版本不支持某 API平台限制
调用原生能力偶尔「没有回调」Bridge 回调丢失、无超时通信设计
RN 长列表滑动掉帧跨线程通信频繁 / 未虚拟化渲染模型
同一页面三端样式不一致单位、字体、安全区、内核差异样式适配
一套代码三端都能跑,但每端体验都差强行抹平差异选型与分层

跨端的核心矛盾:复用度越高,越难照顾每端的独特性。好的方案不是「100% 复用」,而是「把复用放在正确的层」。

0.2 一个类比

把跨端想成同一部剧本在不同剧院上演:

  • 剧本是业务逻辑:可以完全复用。
  • 舞台机械是渲染层:每个剧院不同(WebView、原生、自绘引擎)。
  • 演员调度是状态与交互:大致相同,但走位要按舞台调整。
  • 字幕与音效是端能力:有的剧院有,有的没有,需要降级。

如果你要求「所有剧院用同一套机械」,结果就是每场演出都很别扭。

0.3 学习地图

15 跨端与多端复用
  ├─ 前置:06 框架与组件化、08 路由与渲染
  ├─ 紧接:16 可视化(自绘引擎)、11 性能(端上指标)
  ├─ 交叉:05 浏览器(内核差异)、12 安全(WebView 安全)
  └─ 长期:17 架构(多端架构治理)

0.4 分级标尺

级别能力特征典型产出自检标准
L1 基础会做响应式、平台判断、简单 Bridge 调用多端页面能解释为什么某端不生效
L2 进阶会 Hybrid/小程序/RN 开发与调试端能力封装、分包方案能定位端差异问题
L3 高级懂渲染模型与 Bridge 架构跨端框架/SDK能设计复用分层
L4 专家做选型、治理与演进多端技术战略能对体验与成本负责

1. L1 基础:先认清每个「端」

1.1 跨端方案全景

方案渲染方式复用度体验适合
H5(WebView)浏览器内核高(Web 标准)中(受容器影响)活动页、轻交互、跨端兜底
小程序双线程 + 原生组件中(各家方言)中高平台内轻应用
React Native原生组件 + JS 驱动中高(JS 共享)高(接近原生)App 内多页业务
Flutter自绘引擎(Skia/Impeller)高(Dart)高且一致强一致 UI、动效复杂
ElectronChromium + Node高一致但重桌面工具
Tauri系统 WebView + Rust高轻但依赖系统内核轻量桌面应用
PWA浏览器高接近原生(受限)可安装 Web

一句话选择:

要最快触达、可分享 → H5
要平台流量与能力 → 小程序
要 App 内高性能多页 → RN
要强一致 UI 与动效 → Flutter
要桌面 → Electron(重)/ Tauri(轻)

1.2 WebView 与 JSBridge:跨端的第一课

H5 页面 ──调用──▶ Bridge(JS 侧)──▶ 原生容器 ──▶ 系统能力
        ◀──回调──                  ◀──

两种注入方式:

// 1) 原生向 WebView 注入全局对象(Android addJavascriptInterface / iOS WKScriptMessageHandler)
window.NativeBridge.invoke('camera.open', { quality: 'high' }, callbackId);

// 2) URL Scheme 拦截(老方案,兼容好但性能差)
location.href = 'app://camera/open?callbackId=123';
/**
 * 一个健壮的 Bridge 封装:回调、超时、异常、队列。
 * 这是 Hybrid 应用最容易出「偶发无响应」的地方。
 */
const pending = new Map();
let seq = 0;

function invoke(method, params = {}, { timeout = 5000 } = {}) {
  return new Promise((resolve, reject) => {
    const id = `cb_${++seq}`;

    const timer = setTimeout(() => {
      pending.delete(id);
      reject(new Error(`Bridge 超时: ${method}`));
    }, timeout);

    pending.set(id, {
      resolve: (data) => { clearTimeout(timer); resolve(data); },
      reject: (err) => { clearTimeout(timer); reject(err); },
    });

    try {
      // 真实环境:Android/iOS 注入的对象名不同,需要适配
      window.NativeBridge?.invoke(method, JSON.stringify(params), id);
      if (!window.NativeBridge) {
        // 没有容器时降级为 H5 能力
        window.NativeBridgeFallback?.(method, params);
      }
    } catch (err) {
      clearTimeout(timer);
      pending.delete(id);
      reject(err);
    }
  });
}

/** 原生调用此方法回传结果。 */
window.__bridgeCallback = (id, ok, payload) => {
  const cb = pending.get(id);
  if (!cb) return;          // 已超时,丢弃
  pending.delete(id);
  ok ? cb.resolve(payload) : cb.reject(new Error(payload?.message ?? 'native error'));
};

四个必备能力:

能力为什么
超时原生不回调时不能永远挂起
幂等回调原生可能重复回调
降级无容器时用 H5 能力
错误分类用户取消 vs 权限拒绝 vs 系统错误

1.3 响应式与单位适配

单位依据适合问题
px物理像素边框、图标不同 DPR 下视觉不一致
rem根字体整体缩放需要 JS 或 PostCSS 换算
vw/vh视口布局移动端地址栏导致抖动
rpx(小程序)750 设计稿小程序仅小程序
dvh/svh动态视口移动端全屏兼容性需检查
/* 现代方案:优先用视口单位 + clamp,减少 JS 计算 */
.title {
  font-size: clamp(16px, 4vw, 22px);
}

/* 安全区:刘海屏/底部横条 */
.footer {
  padding-bottom: calc(12px + env(safe-area-inset-bottom));
}

1.4 平台判断:能力探测优于 UA

/**
 * ❌ UA 判断脆弱:容器可以伪装、版本会变
 */
const isIOS = /iPhone/.test(navigator.userAgent);

/**
 * ✅ 能力探测:关心「能不能用」,而不是「是什么」
 */
const canUseCamera = Boolean(navigator.mediaDevices?.getUserMedia);
const canUseShare = Boolean(navigator.share);

/** 平台只用于「交互习惯」这类无法探测的差异。 */
function getPlatform() {
  if (typeof wx !== 'undefined' && wx.getSystemInfoSync) return 'miniprogram';
  if (window.ReactNativeWebView) return 'rn-webview';
  if (/Electron/.test(navigator.userAgent)) return 'electron';
  return 'web';
}

1.5 小程序基础:双线程与限制

渲染层(WebView):WXML + WXSS
逻辑层(JSCore):JS
两者通过 setData 通信(跨线程,数据要序列化)
限制数值/说明影响
主包大小约 2MB首屏必须精简
总包大小约 20MB(分包)需要分包策略
setData 数据量建议 < 1MB大数据会卡
频繁 setData跨线程开销合并更新
DOM 操作不支持只能用数据驱动
/**
 * setData 优化:只传变化的字段,用路径语法。
 */
// ❌ 整棵树重传
this.setData({ list: newList });

// ✅ 只更新某一项
this.setData({ [`list[${index}].status`]: 'done' });

1.6 PWA:可安装的 Web

<link rel="manifest" href="/manifest.webmanifest" />
{
  "name": "订单助手",
  "short_name": "订单",
  "start_url": "/",
  "display": "standalone",
  "icons": [{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }]
}
/** Service Worker:离线缓存与后台能力 */
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => cached ?? fetch(event.request)),
  );
});

注意:Service Worker 更新是「等待中」的,必须显式 skipWaiting + 提示用户刷新,否则用户会长期看到旧版本。

1.7 JSBridge 的可靠性设计

跨端最容易被低估的不是「能不能调通」,而是调用失败时怎么办。

问题表现设计要点
注入时机页面 early 调用时 window.bridge 不存在提供 ready Promise + 超时 + 队列
回调丢失原生没回调、页面已卸载回调 ID + 超时清理 + 幂等
多端差异iOS 有、Android 没有;参数类型不同能力探测 + 统一适配层
错误不可读只返回 fail统一错误码 + 可读 message + traceId
安全任意页面可调敏感能力域名白名单 + 签名 + 最小权限
/**
 * JSBridge 调用封装:注入等待、超时、回调管理与错误规范化。
 * @param {{call: (method: string, params: object, cb: Function) => void}} native
 */
function createBridge(native) {
  const pending = new Map();
  let seq = 0;

  /** 等待原生注入,最多 3 秒 */
  const ready = new Promise((resolve, reject) => {
    const start = Date.now();
    const timer = setInterval(() => {
      if (native?.call) {
        clearInterval(timer);
        resolve();
      } else if (Date.now() - start > 3000) {
        clearInterval(timer);
        reject(new Error('bridge timeout'));
      }
    }, 50);
  });

  /**
   * 调用原生能力。
   * @param {string} method 方法名
   * @param {object} [params] 参数
   * @param {number} [timeout=8000] 超时毫秒
   */
  const invoke = async (method, params = {}, timeout = 8000) => {
    await ready;
    const id = ++seq;

    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => {
        pending.delete(id);
        reject(new Error(`bridge timeout: ${method}`));
      }, timeout);

      pending.set(id, { resolve, reject, timer });

      native.call(method, { ...params, callbackId: id }, (result) => {
        const task = pending.get(id);
        if (!task) return; // 已超时,忽略迟到回调
        pending.delete(id);
        clearTimeout(task.timer);
        if (result?.code === 0) task.resolve(result.data);
        else task.reject(new Error(result?.message ?? 'native error'));
      });
    });
  };

  return { ready, invoke };
}

三条工程纪律:

  1. 能力探测优先于 UA 判断:同一个 App 版本在不同机型/系统上能力可能不同;
  2. 所有原生调用都要有超时:没有超时的 Promise 会永远挂起,阻塞后续流程;
  3. 敏感能力必须鉴权:Bridge 暴露给 WebView 的每个方法都等于一个 API,域名白名单与签名缺一不可。

1.8 L1 小结与自检

主题关键结论
方案按体验要求与分发渠道选,不按热度
Bridge超时、幂等、降级、错误分类
适配视口单位 + 安全区,少用 UA
小程序双线程 + 包体限制 + setData 成本
PWA离线能力靠 SW,更新要显式处理

自检题

  1. 为什么 Bridge 调用必须加超时?——原生可能未注入、崩溃或忘记回调,Promise 会永久 pending 并泄漏回调表;超时才能给出可处理的失败。
  2. 能力探测比 UA 判断好在哪?——UA 可伪造且随版本变化,探测具体 API 或注入对象是否存在更准确,也能自然写出降级路径。
  3. 小程序为什么不能直接操作 DOM?——逻辑层跑在 JSCore、渲染层跑在 WebView,两者隔离,只能通过 setData 驱动视图更新。
  4. Bridge 的回调 ID 为什么不能只用自增数字?——页面刷新/多 WebView 场景下会重复或串号;应加上会话前缀或随机串,并在页面卸载时清理整张回调表。

2. L2 进阶:Hybrid、RN 与复用分层

2.1 Hybrid 的三种加载策略

策略首屏更新适用
远程加载慢(依赖网络)即时活动页
离线包快(本地)需下发高频核心页
预加载/预渲染最快复杂首页、关键路径
离线包流程:
构建 → 上传 CDN → 客户端检查版本 → 下载 → 校验 → 解压 → WebView 加载本地文件

离线包的关键设计:

  1. 版本与灰度:按用户/设备分批下发;
  2. 完整性校验:hash/签名,防止半包;
  3. 回滚:保留上一版本,出问题立即切回;
  4. 降级:离线包损坏时回退到远程 H5。

2.2 RN 的渲染模型

旧架构:JS 线程 ──Bridge(异步、序列化)──▶ 原生 UI 线程
新架构(Fabric + JSI):
  JS ←─ 同步调用(JSI,C++ 层)─→ 原生
  渲染用 Shadow Tree + 并发特性
架构通信影响
旧 Bridge异步、批量、JSON 序列化高频交互有延迟
JSI同步、直接调用减少序列化开销
FabricC++ 渲染层支持并发与更细更新
Hermes字节码引擎启动快、内存低

性能要点:

  1. 长列表用 FlatList/FlashList,必须虚拟化;
  2. 动画优先用 Animated/Reanimated(原生驱动),避免每帧过 Bridge;
  3. 图片按尺寸加载,避免大图解码;
  4. 减少不必要的重渲染(同 React)。

2.3 复用分层:哪些该共享

┌─────────────────────────────┐
│ 业务逻辑 / 状态 / 校验       │  100% 复用(纯 JS/TS)
├─────────────────────────────┤
│ 数据请求 / 领域模型          │  90% 复用(适配层隔离差异)
├─────────────────────────────┤
│ 组件结构 / 交互逻辑          │  60% 复用(平台组件映射)
├─────────────────────────────┤
│ 样式 / 布局 / 动效           │  30% 复用(单位与能力差异)
├─────────────────────────────┤
│ 端能力(相机/支付/分享)      │  0% 复用(抽象接口 + 多实现)
└─────────────────────────────┘
/**
 * 端能力抽象层:业务只依赖接口,各端提供实现。
 * 这是跨端项目最重要的架构决策。
 */
export interface PlatformAdapter {
  openCamera(options: { quality: 'low' | 'high' }): Promise<string>;
  share(payload: { title: string; url: string }): Promise<void>;
  getSafeArea(): { top: number; bottom: number };
  toast(message: string): void;
}

// web.ts / miniprogram.ts / native.ts 各自实现
export const platform: PlatformAdapter = createAdapter();

判断标准:如果一段代码里有 if (isMiniProgram),它就该被移到适配层,而不是散落在业务里。

2.4 组件映射:同一套 API,不同实现

/**
 * 用「同名组件 + 平台实现」屏蔽差异。
 * 业务只写 <Page><Button/></Page>。
 */
// components/index.web.tsx
export const Button = ({ children, onClick }) => (
  <button className="btn" onClick={onClick}>{children}</button>
);

// components/index.miniprogram.tsx
export const Button = ({ children, onClick }) => (
  <View className="btn" onClick={onClick}>{children}</View>
);

代价:样式与交互细节仍需分端调;构建工具要支持平台后缀解析。

2.5 样式差异的四个来源

来源表现处理
单位小程序无 vw,RN 无 px设计 token + 单位转换
字体系统字体不同,行高不同统一字体栈 + 显式行高
安全区刘海/底部横条safe-area-inset-*
滚动小程序/RN 滚动容器不同抽象滚动组件
默认样式浏览器 UA 样式reset + 显式声明

2.6 调试:真机才是真相

端工具注意
H5Chrome DevTools / vConsole容器内需开启调试开关
小程序开发者工具 + 真机预览工具与真机仍有差异
RNMetro + Flipper/DevTools发布包无远程调试
ElectronDevTools + 主进程日志区分渲染进程与主进程

原则:模拟器通过 ≠ 真机通过。低端机、弱网、老系统必须真机验证。

2.7 分包与按需加载(小程序)

{
  "pages": ["pages/index/index"],
  "subpackages": [
    { "root": "packageOrder", "pages": ["list/index", "detail/index"] },
    { "root": "packageUser", "pages": ["profile/index"] }
  ],
  "preloadRule": {
    "pages/index/index": { "network": "all", "packages": ["packageOrder"] }
  }
}

策略:

  1. 主包只放首页与公共依赖;
  2. 按业务域分包;
  3. 用 preloadRule 在空闲时预下载;
  4. 独立分包(不依赖主包)用于强隔离场景。

2.8 多端状态与数据同步

场景方案
H5 与 App 共享登录态原生注入 token / Cookie 同步
小程序与 H5 共享服务端会话 + 一次性 code 换 token
多端数据一致服务端为源 + 版本号 + 增量同步
端内多 WebViewpostMessage + 统一事件协议

2.9 多端适配:单位、安全区与设计令牌

跨端样式最难的不是「写一套 CSS」,而是同一套设计在不同屏幕、系统、字号设置下都不破。

单位基准适合问题
px物理像素(逻辑)边框、图标不随屏幕缩放
rem根字号H5 整体缩放需动态设置根字号,可能与用户字号冲突
vw视口宽度全屏布局、字号极端宽屏下会过大
rpx小程序 750 设计稿小程序仅小程序有效
pt逻辑点RN与 px 近似但语义不同

安全区(刘海屏、底部手势条):

/* 必须配合 viewport-fit=cover 才生效 */
.page {
  padding-top: env(safe-area-inset-top);
  padding-bottom: calc(env(safe-area-inset-bottom) + 16px);
}

/* 底部固定操作栏 */
.action-bar {
  position: fixed;
  inset: auto 0 0 0;
  padding-bottom: env(safe-area-inset-bottom);
}

设计令牌跨端落地:颜色、间距、圆角、字号、动效时长用同一套命名,平台差异用覆盖层解决,而不是各端各写一套。

:root {
  --color-bg: #ffffff;
  --color-text: #1f2329;
  --space-2: 8px;
  --radius-md: 8px;
  --font-body: clamp(14px, 3.8vw, 16px);
  --duration-fast: 150ms;
}

@media (prefers-color-scheme: dark) {
  :root {
    --color-bg: #111418;
    --color-text: #e6e8eb;
  }
}

/* 逻辑属性适配 RTL,不用写两套 */
.card {
  padding-inline: var(--space-2);
  border-start-start-radius: var(--radius-md);
}

四个高频差异来源:

差异表现对策
系统字号缩放用户调大字体后布局溢出用 rem/em + 允许换行,避免固定高度
1px 边框高 DPR 下过粗/过细transform: scaleY(0.5) 或 border-image
键盘弹起底部按钮被遮挡监听 visualViewport,动态偏移
滚动行为iOS 回弹、Android 不触发统一用容器滚动,谨慎用 overflow: hidden 锁 body

一条经验:跨端适配的 bug 大多不是「没写样式」,而是用了固定尺寸 + 隐藏溢出,把差异藏起来,直到某个机型暴露。

2.10 L2 小结与自检

主题关键结论
Hybrid离线包 + 灰度 + 校验 + 回滚
RN新架构减少 Bridge 开销,但仍需虚拟化
复用逻辑全复用,端能力零复用,中间靠适配层
样式单位/字体/安全区是主要差异源
调试真机为准

自检题

  1. 离线包为什么必须保留上一版本?——新包下载或校验失败、启动异常时可立即回滚,也支撑灰度与快速止损。
  2. RN 里为什么动画要走原生驱动?——useNativeDriver 把动画交给 UI 线程,JS 线程卡顿或繁忙时动画仍能保持帧率。
  3. 业务代码里出现 if (isMiniProgram) 说明什么?——平台差异泄漏到了业务层,应该下沉到适配层或能力注册表,由平台实现各自逻辑。
  4. 为什么跨端样式应优先用逻辑属性与令牌,而不是按平台写两套?——逻辑属性(padding-inline)自动适配 RTL,令牌统一视觉语言;按平台分叉会随端数增加而维护成本翻倍。

3. L3 高级:渲染引擎与跨端架构

3.1 三种渲染路径

路径机制代表特点
Web 渲染浏览器内核H5、Electron、小程序渲染层生态成熟、性能受内核限制
原生组件映射JS 驱动原生 ViewRN体验接近原生,桥接有成本
自绘引擎直接绘制Flutter、Skia、Canvas一致性强,包体大

3.2 WebView 内核差异

差异表现处理
内核版本老 Android 不支持新 CSS/JS特性检测 + polyfill
字体渲染行高、字重不同显式行高,避免依赖默认
滚动行为惯性、回弹不同统一滚动容器
视频全屏/自动播放策略不同用户手势触发
存储分区、配额不同能力检测 + 降级

原则:以最低支持版本为基线,用 @supports 与特性检测渐进增强。

3.3 自绘引擎的代价与收益

收益:像素级一致、复杂动效流畅、跨平台行为统一
代价:包体大(引擎)、原生控件需自实现、无障碍与输入法适配难、生态较小

适合:设计驱动、动效复杂、需要强一致品牌体验的产品。 不适合:以表单/列表为主、依赖大量原生能力的业务。

3.4 端能力抽象层的进阶设计

/**
 * 能力注册表:支持多实现、按环境选择、可测试。
 */
type CapabilityMap = {
  camera: { open(opts: { quality: 'low' | 'high' }): Promise<string> };
  payment: { pay(orderId: string): Promise<{ status: 'ok' | 'cancel' | 'fail' }> };
};

const registry = new Map<string, unknown>();

export function register<K extends keyof CapabilityMap>(name: K, impl: CapabilityMap[K]) {
  registry.set(name, impl);
}

export function use<K extends keyof CapabilityMap>(name: K): CapabilityMap[K] {
  const impl = registry.get(name);
  if (!impl) throw new Error(`能力未注册: ${String(name)}`);
  return impl as CapabilityMap[K];
}

价值:业务不感知端;测试可注入 mock;新增端只需注册实现。

3.5 热更新与合规

方案机制风险
H5 远程加载直接更新低(但需容器支持)
离线包下发新包低
RN 热更新替换 JS Bundle平台政策限制(iOS 审核)
小程序平台审核发布受平台规则约束

合规红线:不得通过热更新改变应用核心功能或规避审核;要保留回滚与版本审计。

3.6 首屏性能的端上优化

手段收益注意
WebView 预创建省容器初始化内存占用
离线包省网络更新策略
骨架屏感知快与真实结构一致
接口预取省等待命中率
资源预加载省解析别抢关键资源
SSR/预渲染首屏直出容器支持
端上首屏链路:
点击 → 容器创建 → WebView 初始化 → 加载 HTML → 解析 → JS 执行 → 请求数据 → 渲染
      ↑ 预创建可省            ↑ 离线包可省        ↑ 预取可省

3.7 多端一致性测试

测试做法
视觉一致性各端截图对比(同数据、同时钟)
交互一致性关键流程脚本化(Appium/Playwright/小程序自动化)
能力降级Mock 无相机/无支付,验证兜底
弱网限速 + 断网,验证离线态

3.8 多端发布:版本矩阵与灰度

跨端最危险的不是单个端出错,而是版本组合出错:新前端配旧容器、新容器配旧接口、灰度用户命中不兼容组合。

端发布方式生效速度回滚难度关键约束
H5CDN + HTML 入口分钟级低(切回旧版本)旧 chunk 要保留一个周期
App 离线包包管理平台下发小时级中(下发旧包)校验、签名、差量、回滚包
小程序平台审核 + 分阶段发布天级中(回退版本)审核周期、包体限制
RNJS bundle 热更新分钟级中(bundle 回滚)与原生模块版本绑定
原生 App应用商店天到周低(无法强制降级)必须兼容 N-2 个旧版本

版本矩阵(发布前必须画出来):

App 容器版本:8.0 / 8.1 / 8.2
  × JS bundle:b120 / b121 / b122
  × 接口版本:v1 / v2
  × 配置开关:on / off

规则:

  1. 前端至少兼容 N-2 个容器版本;
  2. 接口变更向后兼容,新增字段而不是改语义;
  3. 新能力用能力探测 + 开关,而不是假设容器已升级;
  4. 每个组合都要有降级路径。

灰度决策:用稳定哈希分桶,保证同一用户始终落在同一组。

/**
 * 稳定分桶:同一 userId 在任意端、任意次都得到同一结果。
 * @param {string} userId
 * @param {number} percent 0–100
 */
function inGray(userId, percent) {
  let hash = 0;
  for (let i = 0; i < userId.length; i += 1) {
    hash = (hash * 31 + userId.charCodeAt(i)) >>> 0;
  }
  return hash % 100 < percent;
}

灰度的四个观察指标:崩溃率、白屏率、核心接口成功率、关键转化率。任一显著恶化即停止放量并回滚,而不是「再观察一下」。

坑后果修复
只按随机数分桶同一用户每次结果不同,体验割裂用 userId/deviceId 稳定哈希
新旧接口同时在线但语义不同数据错乱接口版本化 + 契约测试
离线包不回滚坏包导致大面积不可用保留上一版本 + 启动失败自动回退
小程序审核期不可控紧急修复赶不上关键逻辑放服务端/配置,前端留开关

3.9 L3 小结与自检

主题关键结论
渲染Web / 原生映射 / 自绘,各有代价
内核以最低版本为基线,特性检测
能力层注册表 + 接口,业务不感知端
热更新能力越强,合规风险越高
首屏容器/资源/数据三段都可前置

自检题

  1. 自绘引擎为什么包体大、无障碍难?——自带渲染引擎与 Skia 等运行时,包体自然更大;控件自绘不映射系统无障碍树,需要额外补齐语义与焦点顺序。
  2. 能力注册表比 if/else 好在哪?——新增端只注册实现,业务不感知分支,便于测试、按端裁剪与统一降级。
  3. 端上首屏的三段优化分别对应什么手段?——下载段用分包、预下载与压缩;初始化段减少同步 API、延迟非关键逻辑;渲染段用骨架、减少首屏节点、控制图片优先级。
  4. 为什么跨端发布要画「容器 × bundle × 接口」矩阵?——用户手里的组合不受你控制,只有把兼容窗口与降级路径显式列出,才能提前发现不兼容组合,而不是等线上按机型爆雷。

4. L4 专家:选型、治理与演进

4.1 选型决策表

维度权重问题
分发渠道需要应用商店/平台流量吗?
体验要求动效/手势/一致性要求多高?
团队能力现有成员会什么?招聘供给?
复用诉求已有几端?未来还要几端?
迭代速度是否要绕过审核快速发版?
长期成本包体、人力、维护、生态

结论模板:

我们选择 H5 + 离线包,因为核心诉求是快速迭代与跨渠道分发,体验要求以内容浏览为主;放弃 RN 是因为团队无原生经验且页面交互简单,投入产出不划算。

4.2 治理:避免「多端各写一遍」

问题治理手段
组件重复实现统一组件库 + 平台适配层
逻辑漂移业务逻辑抽到独立包,多端同版本
样式不一致设计 token + 平台映射
能力碎片能力注册表 + 版本矩阵
发布不同步多端发布看板 + 灰度对齐

关键指标:复用率(共享代码行数占比)、差异代码行数、多端缺陷率。

4.3 成本模型

成本项一套代码分端实现
初始开发低高(×端数)
迭代维护低(但要处理差异)高(重复改动)
体验上限受框架限制每端最优
人力要求需要跨端能力需要各端专家
长期风险框架演进/生态一致性

判断:页面数量 × 迭代频率 × 端数 越大,复用价值越高;体验要求越极致,分端越合理。

4.4 演进与迁移

阶段 1:H5 兜底(快速上线)
阶段 2:核心页做离线包/RN(提升体验)
阶段 3:抽公共逻辑与组件库(降低重复)
阶段 4:能力层标准化(新增端成本最低)

迁移原则:按页面流量排序,先做高频低风险页面;保留回退路径;每步都有指标对比。

4.5 L4 自检

  1. 公司要同时做 App、小程序、H5,怎么定复用边界?——业务逻辑与数据层尽量共享,UI 按端实现但共享 token 与交互规范,能力走适配器;按收益分级,不追求单套代码跑三端。
  2. 一套代码三端体验都平庸,如何说服老板投入分端优化?——用分端转化率、留存、崩溃率与性能数据说明差异,给出投入与收益预估,先做一个端的试点对照。
  3. 新增一个端,理想情况下需要改多少业务代码?——只实现适配器与少量端特有组件,业务代码改动接近 0;若需要大量分支,说明抽象层失败。

5. 综合实战:一套业务跑 H5 / 小程序 / App 内嵌

背景:会员中心,包含资料、等级、权益、订单入口。

步骤 1 · 分层设计

packages/
├─ domain/          纯 TS:会员模型、等级计算、权益规则(100% 复用)
├─ data/            请求与缓存(复用,适配不同网关)
├─ ui/              组件(平台映射:View/div,Button 双实现)
├─ platform/        能力实现(share/pay/toast/safeArea)
└─ apps/
   ├─ web/          H5 入口
   ├─ miniprogram/  小程序入口
   └─ app-webview/  App 内嵌入口

步骤 2 · 关键代码

/**
 * 等级计算放 domain:三端共用同一套规则,避免「H5 说 gold,小程序说 silver」。
 */
export function calcLevel(points: number): 'bronze' | 'silver' | 'gold' {
  if (points >= 5000) return 'gold';
  if (points >= 1000) return 'silver';
  return 'bronze';
}
/**
 * 能力差异只在 platform 层出现。
 */
export const platform = {
  async share(payload: { title: string; url: string }) {
    if (typeof wx !== 'undefined') {
      return wx.shareAppMessage({ title: payload.title, path: payload.url });
    }
    if (navigator.share) return navigator.share(payload);
    await copyToClipboard(payload.url); // 降级
  },
};

步骤 3 · 样式适配

:root {
  --space-4: 16px;
  --font-title: 20px;
}
/* 小程序:rpx 映射由构建处理;RN:StyleSheet 由适配层转换 */

步骤 4 · 验收清单

  • 三端等级/权益规则一致(同一份测试用例跑三端)
  • 分享能力在无容器时降级为复制链接
  • 安全区适配(刘海/底部横条)
  • 小程序主包 < 2MB,首屏 < 1.5s
  • App 内嵌首屏(含容器)< 2s
  • 新增一个端只需实现 platform 层

6. 常见误区对照表

误区事实
「一套代码就能多端一致」逻辑可复用,渲染与能力必须适配
「UA 判断最可靠」可伪装、会变;优先能力探测
「小程序就是小号 H5」双线程、无 DOM、包体限制、setData 成本
「RN 性能天然接近原生」高频跨线程通信与未虚拟化列表仍会卡
「离线包发了就生效」需要版本检查、灰度、校验、回滚
「热更新没有风险」平台合规与审核风险很高
「Flutter 一定更流畅」自绘有开销,重度列表仍需优化
「跨端就是少写代码」差异处理与调试成本常被低估
「模拟器通过就行」真机、低端机、老系统才是真相
「复用率越高越好」强行复用会牺牲每端体验

7. 面试高频题(附答题思路)

Q1:H5、小程序、RN 的本质区别? 渲染路径(WebView/双线程+原生组件/原生映射)→ 能力边界 → 性能特征 → 适用场景。

Q2:JSBridge 怎么设计才可靠? 回调 id + 超时 + 幂等 + 错误分类 + 降级 + 队列;再说 URL Scheme 与注入对象的差异。

Q3:跨端复用怎么分层? 逻辑/数据/组件/样式/能力五层;给复用比例;强调能力层零复用。

Q4:RN 新架构解决了什么? Bridge 异步序列化 → JSI 同步调用;Fabric 渲染;Hermes 启动;仍要虚拟化与原生驱动动画。

Q5:小程序性能怎么优化? 分包 + 预加载 + setData 最小化 + 图片优化 + 减少节点层级 + 骨架屏。

Q6:如何保证多端一致? 共享 domain 层 + 设计 token + 平台适配层 + 三端同一套用例 + 截图对比。

Q7:离线包方案要点? 版本/灰度/校验/回滚/降级;再说与 Service Worker 的区别。

Q8:如何选跨端方案? 渠道、体验、团队、复用、迭代速度、长期成本六维;给结论模板。


8. 面试深挖

8.1 面试官在考察什么

维度看什么加分回答减分回答
容器认知各端的能力边界说清 WebView 内核、JSCore、小程序线程模型「都是 Web」
通信设计桥接的可靠性与时序回调表、超时、ready、错误码「postMessage 就行」
性能归因首屏与交互的端上瓶颈区分下载、解析、渲染、通信「优化包体积」
复用分层哪些共享、哪些分端按「业务逻辑 / UI / 能力」三层切「一套代码跑所有端」
合规热更新与审核红线知道各平台限制与替代方案「热更新随便发」

8.2 追问链

链 1 · WebView → JSBridge → 可靠通信

  • 面试官:H5 怎么调用原生能力?
    • 要点:iOS 用 WKWebView 的 messageHandlers 注入,Android 用 addJavascriptInterface 注入对象;H5 发起调用,原生执行后通过 evaluateJavascript 回调或 URL Scheme 回传。
  • 追问:回调怎么对上号?
    • 要点:每次调用生成唯一 callbackId 存入 Map,原生回传时按 ID 取出并删除;必须设超时清理,否则回调丢失会导致 Promise 永久 pending 与内存泄漏。
  • 再追问:为什么桥接调用会「偶发失败」?
    • 要点:页面加载时原生对象尚未注入(需 ready 握手并排队)、WebView 销毁导致回调丢失、evaluateJavascript 在非主线程调用、参数被字符串化后超长截断。要做好「注入检测 + 队列 + 超时 + 重试上限」。

链 2 · 小程序双线程 → setData → 启动性能

  • 面试官:小程序为什么分逻辑层与渲染层?
    • 要点:逻辑层跑在 JSCore,渲染层跑在 WebView,二者通过 Native 转发消息;隔离了 JS 能力(无 DOM),也带来通信成本。
  • 追问:setData 为什么慢?
    • 要点:数据要序列化后跨线程传输,再在渲染层做 diff 与更新;一次传大量数据会阻塞通信与渲染。优化:只传变化的字段(路径写法)、合并多次调用、避免传大对象与函数。
  • 再追问:小程序首屏启动慢在哪?
    • 要点:代码包下载与解压、逻辑层初始化、首次渲染、数据请求。手段:分包与预下载、减少同步 API、骨架屏、把非首屏逻辑延后到 onReady 之后。

链 3 · RN 新架构 → 自绘引擎 → 一致性取舍

  • 面试官:RN 老架构的性能瓶颈在哪?
    • 要点:JS 与原生通过异步桥通信,批量传递、序列化开销大;频繁更新(手势、动画)会出现掉帧;列表与交互密集场景尤其明显。
  • 追问:新架构改了什么?
    • 要点:JSI 让 JS 直接持有 C++ 宿主对象,去掉 JSON 桥;Fabric 用 C++ 渲染器支持并发与同步布局;TurboModules 按需懒加载原生模块。收益是启动与交互延迟下降。
  • 再追问:Flutter 与 RN 的本质差异?
    • 要点:Flutter 自绘(Skia/Impeller),UI 一致性最好,但包体大、原生控件集成成本高;RN 映射原生控件,观感更「原生」,但跨版本一致性要靠组件库兜。选型看「一致性优先」还是「原生体验与生态优先」。

链 4 · 复用分层 → 抽象成本 → 多端体验

  • 面试官:一套代码跑三端,哪些该复用?
    • 要点:按「变化频率 × 平台差异」分层:领域逻辑与数据层全复用;UI 结构尽量复用;交互与端能力用适配层;导航、生命周期、支付/登录等平台强相关部分零复用。
  • 追问:抽象层为什么越写越厚?
    • 要点:每加一个端,都会带来新的能力差异与边界情况;如果没有「能力注册表 + 默认降级」,抽象层会退化成大量 if/else,最终比各写一遍更难维护。
  • 再追问:怎么判断复用是否值得?
    • 要点:算「复用节省的工时」与「抽象层维护 + 调试 + 每端适配成本」;当端数少、差异大时,复用往往不划算;端数多、逻辑稳定时才划算。

链 5 · 热更新 → 合规 → 灰度回滚

  • 面试官:热更新能解决什么,风险是什么?
    • 要点:绕过商店审核快速修复与灰度,降低发版成本;风险是平台合规(苹果对改变功能的动态代码有明确限制)、版本碎片、坏包导致大面积故障。
  • 追问:灰度怎么做才可控?
    • 要点:按稳定 ID 分桶,先小比例观察崩溃率与核心指标;准备一键停用与回滚包;灰度期间禁止同时改接口语义。
  • 再追问:怎么保证新旧版本兼容?
    • 要点:前端兼容 N-2 容器,接口只增不改,能力用探测 + 开关;把「容器版本 × bundle 版本 × 接口版本」矩阵纳入发布检查。

8.3 手写题

题 1 · 带回调与超时的 JSBridge

/**
 * 简化 JSBridge:H5 调用原生,按 callbackId 回调。
 * 原生侧需在完成后调用 window.__bridgeCallback(id, payload)。
 */
const bridge = (() => {
  let seq = 0;
  /** @type {Map<string, {resolve: Function, timer: number}>} */
  const pending = new Map();

  // 原生回调入口
  window.__bridgeCallback = (id, payload) => {
    const item = pending.get(id);
    if (!item) return;
    pending.delete(id);
    clearTimeout(item.timer);
    item.resolve(payload);
  };

  /**
   * 调用原生方法。
   * @param {string} method 方法名
   * @param {object} [params] 参数(必须可 JSON 序列化)
   * @param {{timeout?: number}} [options]
   * @returns {Promise<any>}
   */
  const call = (method, params = {}, { timeout = 5000 } = {}) =>
    new Promise((resolve, reject) => {
      const id = `cb_${Date.now()}_${++seq}`;
      const timer = setTimeout(() => {
        pending.delete(id);
        reject(new Error(`bridge timeout: ${method}`));
      }, timeout);
      pending.set(id, { resolve, timer });

      const message = JSON.stringify({ id, method, params });
      if (window.webkit?.messageHandlers?.bridge) {
        window.webkit.messageHandlers.bridge.postMessage({ id, method, params });
      } else if (window.androidBridge?.postMessage) {
        window.androidBridge.postMessage(message);
      } else {
        clearTimeout(timer);
        pending.delete(id);
        reject(new Error('bridge unavailable'));
      }
    });

  return { call };
})();

追问:

  • 为什么超时后还要 pending.delete?→ 否则 Map 持续增长,回调迟到时还会 resolve 一个已 reject 的 Promise(无效但泄漏)。
  • 页面刚加载时调用失败怎么办?→ 加 ready 握手:原生注入完成后置标志,未 ready 的调用先入队,ready 后 flush。
  • 参数里有函数/循环引用怎么办?→ 桥接只能传可序列化数据,函数要用 ID 注册成全局回调,循环引用要提前拍平。

题 2 · setData 差量合并

/**
 * 计算两次数据的最小变更路径,供 setData 使用。
 * @param {object} prev 旧数据
 * @param {object} next 新数据
 * @param {string} [prefix=''] 路径前缀
 * @returns {Record<string, any>} 路径 → 新值
 */
function diffData(prev, next, prefix = '') {
  const patch = {};

  Object.keys(next).forEach((key) => {
    const path = prefix ? `${prefix}.${key}` : key;
    const a = prev?.[key];
    const b = next[key];

    if (b === a) return;

    const bothObject =
      a && b && typeof a === 'object' && typeof b === 'object' && !Array.isArray(a) && !Array.isArray(b);

    if (bothObject) {
      Object.assign(patch, diffData(a, b, path));
    } else {
      patch[path] = b;
    }
  });

  // 被删除的键要显式置 undefined
  Object.keys(prev ?? {}).forEach((key) => {
    if (!(key in (next ?? {}))) {
      patch[prefix ? `${prefix}.${key}` : key] = undefined;
    }
  });

  return patch;
}

追问:

  • 数组为什么整体替换?→ 小程序对数组下标的路径更新支持有限,且顺序变化时按下标 diff 容易错位;大数组可只传长度与前 N 项。
  • 合并多次 setData 有什么风险?→ 后一次可能覆盖前一次未提交的字段,应保证合并时以最新值为准,并限制单次体积。
  • 为什么路径写法比传整棵树快?→ 传输数据量小,渲染层只需更新对应节点,减少 diff 范围。

8.4 场景设计题

题:一套商品列表要同时跑在 H5、微信小程序、App 内嵌页,三端 UI 接近但能力不同。你怎么设计复用?

评估点:是否分层、是否承认差异、是否有独立演进能力。

参考方案:

  1. 分层:数据与业务逻辑(请求、格式化、状态)抽成平台无关包;UI 组件按端实现,共享设计 token 与交互规范;能力层用适配器接口(分享、支付、定位、埋点)。
  2. 接口先行:定义 PlatformAdapter 契约,H5 用浏览器实现、小程序用 wx.*、App 用 JSBridge,缺失能力返回明确错误而不是静默失败。
  3. 构建:同一仓库多入口,按平台做条件编译与依赖替换;样式用统一变量,差异用平台覆盖层。
  4. 治理:三端共用一套视觉走查与埋点口径;每端有 owner,共享层变更需三端回归。

加分:指出「一套代码三端跑」通常会在交互细节上付出更高代价,应按复用收益分级(逻辑 100%、UI 60%、能力 0%)。

减分:强行用一套 UI 抹平平台差异;把所有平台判断塞进业务代码;共享层随意加平台分支。

题 2:App 内嵌页在 iOS 上偶发白屏,Android 正常,日志里没有任何 JS 错误。你怎么排查?

评估点:是否理解 WebView 生命周期、Bridge 时序与资源加载差异。

参考方案:

  1. 确认范围:机型/系统版本/App 版本/网络/是否首次进入;看是「全白」还是「壳在、内容空」。
  2. 分层排查:容器层(WebView 创建、进程复用、缓存清理)→ 资源层(离线包校验、CDN、chunk 404)→ 运行时(JS 是否执行、Bridge 是否注入)→ 渲染层(CSS、字体、安全区)。
  3. 补观测:在 HTML 最早处埋点(容器 UA、资源加载、error/unhandledrejection、Bridge ready 时间),白屏检测加采样点;无错误时优先怀疑「JS 根本没跑」或「资源没到」。
  4. 常见根因:WKWebView 进程被系统回收后未重建、离线包签名/校验失败回退到空目录、evaluateJavascript 在主线程外调用、旧 chunk 被清理、CSP 拦截内联脚本。
  5. 止血与验证:启用备用 CDN/直连 H5、关闭离线包、回滚容器配置;用 TestFlight/灰度复现验证。

加分:指出 iOS WKWebView 有独立网络进程与内容进程,内存压力下会被回收,需要监听 webViewWebContentProcessDidTerminate 并重建;指出「无 JS 错误」往往意味着脚本未执行。

减分:只让用户清缓存;只看前端日志不看容器日志;把白屏归因为「网络慢」。

8.5 最容易答错的点

常见说法纠正
「小程序就是 WebView 里的网页」逻辑层与渲染层分离,无 DOM/BOM,通信有成本
「JSBridge 调用是同步的」都是异步,且可能因注入时序失败
「WebView 内核差异只影响样式」还影响 JS 特性、存储、媒体与安全策略
「RN 新架构解决了所有性能问题」降低通信开销,但列表、图片、原生模块仍需优化
「热更新可以随时发」各平台有审核与合规限制,绕过有下架风险
「一套代码复用率越高越好」过度抽象会让每端都别扭,应分级复用
「PWA 能替代原生 App」iOS 能力受限,推送、后台、安装体验差异大
「跨端只考虑首屏」交互延迟、滚动、输入法、返回键都是体验差异点
「Bridge 调通就算完成」还要处理未注入、超时、重复回调、页面卸载后的清理
「多端复用率越高越省钱」抽象层的维护与调试成本会随端数和差异增长
「WebView 白屏一定有 JS 报错」脚本未执行、资源未到、进程被回收都不会产生 JS 错误

9. 延伸资源

  • React Native 官方文档:Fabric、JSI、Hermes、性能指南
  • Flutter 官方:渲染管线、Impeller、平台通道
  • 微信/支付宝小程序文档:双线程模型、分包、性能评分
  • web.dev:PWA、Service Worker、Capacitor
  • Electron / Tauri 官方文档与安全建议
  • JSBridge 设计相关工程博客(Hybrid 容器实践)
  • 《Cross-Platform Development》相关公开课与案例
  • 各端性能工具:Metro、Flipper、小程序性能面板、Android Profiler