跳到主要内容

JSBridge

JSBridge 是 JavaScript 与宿主代码之间的通信机制。在 WebView 应用中,网页通过它申请原生能力,宿主再把结果交回页面。例如页面展示分享按钮,真正打开系统分享界面的动作由 Android 或 iOS 应用完成。

接口看起来可能只有一个 postMessage 或 call,但完整调用跨越了两套运行环境。要解释它的原理,需要区分消息怎样传输、异步结果怎样找到调用者,以及宿主为什么允许这次操作。本文围绕 WebView 中的 Web 与原生通信;React Native 的渲染体系和原生模块机制不能直接等同于 WebView JSBridge。

1. 面试核心​

先记住这几条,再读正文
  • JSBridge 由底层通信入口和上层调用协议共同组成。注入对象、脚本消息处理器、消息端口等负责传输;方法名、参数、请求 ID 和错误约定负责表达业务调用。卡 · 传输入口与业务协议
  • URL Scheme 可以用于唤起应用或被宿主拦截,但不等于完整的 JSBridge。它没有自动解决调用结果、权限、参数校验和页面生命周期。卡 · URL Scheme 与环境识别
  • 请求 ID 关联一次调用,会话 ID 区分一次页面会话。回调函数留在当前 JavaScript 环境,通过标识找到它,不能把函数本身当作 JSON 传给原生。卡 · 最小异步客户端
  • 超时只表示调用方没有等到结果,不表示原生操作已取消。重试有副作用的操作需要业务幂等;页面退出时还要清理待处理回调和旧会话。卡 · 超时与副作用
  • 能力存在不代表调用者拥有权限。宿主应验证来源、frame、当前会话、方法和参数,再执行系统权限及业务授权判断;页面自报的身份不可信。卡 · 权限与信任边界
  • 兼容与性能要围绕调用链处理。明确能力协商、就绪时机、错误码和负载限制,再观察传输、排队、原生工作与返回各阶段,不能只归因于“桥慢”。卡 · 兼容性与性能

2. 传输入口与业务协议​

2.1 两个调用方向​

Web 调用原生时,JavaScript 把操作与参数交给宿主暴露的入口。宿主接到消息后解析、验证并分发,必要时切换到合适线程执行,再通过回复接口或页面回调返回结果。

原生调用 Web 时,宿主可以执行固定的 JavaScript 入口,或向已建立的消息端口发送数据。这里应把代码与数据分开:不要把用户输入直接拼接成一段待执行脚本,也不要让页面传入任意原生类名和方法名后反射执行。

平台入口负责什么不能据此推断什么
Android 的 addJavascriptInterface向 WebView 暴露受限定的对象方法方法注解不等于来源验证;对象可能暴露给多个 frame
AndroidX WebKit 的消息监听接口按来源规则建立消息入口,并提供来源、主 frame 等上下文仍需检查参数、业务权限及特性支持
WKWebView 的脚本消息处理器把 JavaScript 消息交给原生处理器处理器名称不是身份凭据
WKWebView 的带回复处理器接收消息并向 JavaScript 返回异步结果返回 Promise 不等于自动具备业务取消和幂等
消息端口在已建立的双方之间交换消息端口持有者身份和授权仍由建立过程保证

Android 的线程与来源检查见 Android WebView,iOS 的处理器与回复入口见 WKWebView。浏览器窗口的 postMessage 也可以用于通道初始化,但它本身不会让普通网页凭空获得原生能力,窗口验证规则见 浏览器上下文通信。

2.2 URL Scheme 与环境识别​

URL Scheme 是 URL 中冒号前的协议部分。应用可以注册自定义 Scheme 处理唤起链接;WebView 宿主也可以拦截约定的导航,把其中的动作交给原生。这是传递调用意图的一种办法,不是跨平台通用的标准 RPC 协议。

如果把参数编码到 URL 中,就要处理转义、长度、日志泄露和返回结果。拦截路径也不能承担所有资源请求的鉴权。唤起应用与 WebView 内调用还要分开:打开应用成功,不表示其中某项业务操作已经完成。

userAgent 可以辅助选择界面适配,但它可变化、可伪装,也不能证明某个桥已安装。功能判断应检查约定入口及能力协商结果;安全判断必须由宿主根据可信上下文完成,不能根据页面的 isApp: true 决定授权。

3. 消息协议与回调关联​

3.1 请求与响应​

请求 ID 标识一次调用,会话 ID 标识当前这次页面与宿主的连接。请求 ID 解决并发结果对应问题,会话 ID 帮助丢弃导航、重建之后的旧结果。二者都不是权限令牌。

下面是一个应用自行约定的 JSON 协议,读取宿主当前主题:

{
"version": 1,
"type": "request",
"session": "page-session-1",
"id": "request-1",
"method": "getTheme"
}

成功响应携带同一会话与请求 ID:

{
"version": 1,
"type": "response",
"session": "page-session-1",
"id": "request-1",
"ok": true,
"data": { "theme": "dark" }
}

失败响应可以使用 ok: false 与稳定的 error.code,例如 DENIED、UNSUPPORTED、FAILED。错误码给调用方做分支判断,原生异常栈、文件路径或敏感状态不应直接暴露给网页。

JSON 不是结构化克隆:函数、循环引用、BigInt 等不能直接按这个协议传递,undefined 或非有限数值也会发生丢失或转换。业务参数应先按方法的结构验证,再编码;大文件通常交付受控文件标识或流,而不是直接塞入巨大的 JSON 字符串。

3.2 最小异步客户端​

下面的客户端只提供 readTheme(),用来说明并发关联、超时、限额和清理。传输适配器的 send(text) 同步提交字符串或抛出错误;subscribe(listener) 接收字符串消息并返回取消订阅函数。适配器必须先完成可信通道建立,客户端才可使用。

function createThemeClient(transport, { timeoutMs = 1000, maxPending = 16 } = {}) {
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2 ** 31 - 1) {
throw new TypeError('Invalid timeout');
}
if (!Number.isSafeInteger(maxPending) || maxPending < 1) {
throw new TypeError('Invalid pending limit');
}
const session = crypto.randomUUID();
const pending = new Map();
let closed = false;

function finish(id, ok, value) {
const entry = pending.get(id);
if (!entry) return;
pending.delete(id);
clearTimeout(entry.timer);
if (ok) entry.resolve(value);
else entry.reject(value);
}

const unsubscribe = transport.subscribe((text) => {
if (closed || typeof text !== 'string' || text.length > 4096) return;
let message;
try { message = JSON.parse(text); } catch { return; }
if (!message || message.version !== 1 || message.type !== 'response') return;
if (message.session !== session || typeof message.id !== 'string') return;
if (message.ok === true && ['light', 'dark'].includes(message.data?.theme)) {
finish(message.id, true, message.data.theme);
} else if (message.ok === false &&
['DENIED', 'UNSUPPORTED', 'FAILED'].includes(message.error?.code)) {
finish(message.id, false, new Error(message.error.code));
}
});

return {
readTheme() {
if (closed) return Promise.reject(new Error('DISPOSED'));
if (pending.size >= maxPending) return Promise.reject(new Error('BUSY'));
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
const timer = setTimeout(() => finish(id, false, new Error('TIMEOUT')), timeoutMs);
pending.set(id, { resolve, reject, timer });
try {
transport.send(JSON.stringify({ version: 1, type: 'request', session, id, method: 'getTheme' }));
} catch (error) {
finish(id, false, error);
}
});
},
dispose() {
if (closed) return;
closed = true;
try { unsubscribe(); } finally {
for (const id of pending.keys()) finish(id, false, new Error('DISPOSED'));
}
},
};
}

pending 只保存在本地,保存的是 Promise 的处理函数和超时句柄。发出的字符串里只有标识和操作名。代码先登记 pending 再发送,所以即使适配器立刻回复,也能找到调用者。

一条有效响应会先移除记录,再完成 Promise;重复或迟到的响应找不到记录,不会重复完成调用。错误格式、旧会话和未知 ID 都不会改变当前调用。没有有效响应的调用最终超时,名额也随之释放。

3.3 用消息端口验证协议​

下面在浏览器中用 MessageChannel 模拟两个通信端点。它是真实的异步消息传输,但宿主处理器仍是 JavaScript;它验证上面的协议逻辑,不代表已经调用了 Android 或 iOS API。

async function runThemeDemo() {
const { port1, port2 } = new MessageChannel();
const transport = {
send(text) { port1.postMessage(text); },
subscribe(listener) {
const onMessage = (event) => listener(event.data);
port1.addEventListener('message', onMessage);
port1.start();
return () => port1.removeEventListener('message', onMessage);
},
};
port2.onmessage = ({ data }) => {
if (typeof data !== 'string' || data.length > 4096) return;
let request;
try { request = JSON.parse(data); } catch { return; }
if (!request || request.version !== 1 || request.type !== 'request') return;
if (typeof request.session !== 'string' || typeof request.id !== 'string') return;
if (request.method !== 'getTheme') return;
port2.postMessage(JSON.stringify({
version: 1, type: 'response', session: request.session, id: request.id,
ok: true, data: { theme: 'dark' },
}));
};
const client = createThemeClient(transport);
try {
console.log(await client.readTheme()); // dark
} finally {
client.dispose();
port1.close();
port2.close();
}
}

在安全上下文或本地 HTTP 页面中定义两个函数,再调用 runThemeDemo(),会输出 dark。crypto.randomUUID() 也需要相应环境支持。

示例端口只交给这两个受控端点,尚未实现原生来源验证、系统权限或业务授权。把 transport 换成真实平台入口时,不能只复制这个模拟处理器就认为权限问题已经解决。

4. 生命周期、超时与取消​

4.1 页面会话与就绪状态​

桥的全局对象存在,不代表宿主业务已经准备好。可以由宿主在注册处理器和能力准备完成后发送 ready,或者提供一个约定的能力查询。协议还应规定:重复就绪消息如何处理、旧页面会话何时失效,以及初始化失败时如何降级。

导航或容器重建时,宿主要让旧会话失效,Web 端结束未完成调用。原生异步工作完成后,必须核对结果仍属于原来的会话,不能把旧页面的回复投给新加载的文档。Web 随机生成的会话 ID 只能帮助关联,宿主还必须绑定它与真实页面实例或导航代次的关系。

dispose() 清理客户端的订阅、定时器和 Promise。它不自动销毁整个 WebView,也不应该关闭还被其他模块共享的底层连接;底层连接的所有者负责最终释放。页面缓存恢复等场景则应重新确认连接和状态,而不是盲目沿用旧回调表。

4.2 超时与副作用​

一次请求可能已经送达,原生操作也已经成功,只是回复丢失或页面在等待期间退出。此时客户端超时,并不能推导出操作没有发生。

读取主题这类无副作用操作可以按需要重试。支付、提交订单或写文件不能因为没收到响应就无限重发,应使用稳定的业务幂等键、结果查询或显式确认。请求 ID 通常用于一次传输调用;业务幂等键用于识别“这几次尝试是否属于同一个业务操作”,用途不同。

取消也需要协议和宿主共同支持。Web 端移除回调只表示自己不再等待;发出取消消息也只能表达请求。宿主必须判断操作能否停止,必要时返回“已经完成”或“无法取消”,不能把 Promise 被拒绝等同于系统工作已回滚。

5. 权限与信任边界​

桥把网页调用提升到宿主能力范围,因此授权必须在宿主侧执行。前端参数校验可以减少错误输入,但不能代替宿主校验。

检查层次需要确认的内容
来源与 frame完整源是否允许,是否只允许主 frame,当前真实发送者是谁
页面会话请求是否属于仍有效的页面实例,页面导航后是否已经撤销
方法白名单是否公开这个具体动作,而不是允许任意方法反射、任意脚本执行
参数范围类型、长度、枚举、文件标识和 URL 目标是否符合这个动作的要求
系统权限相机、定位等权限是否授予,是否需要用户交互
业务授权当前账号是否有权操作这个对象,敏感操作是否需要再次确认

来自网页 JSON 的 origin、userId、isMainFrame 都只是参数,不能当作平台提供的身份。平台接口若无法提供足够的来源保证,应收紧可加载内容和可暴露能力,或使用能验证来源的接口,不通过一层 JavaScript 包装假装完成认证。

可信主页面还可能嵌入第三方 iframe,或加载有问题的脚本。即使请求来自允许的源,XSS 也可能以页面身份调用桥。因此方法范围、敏感操作确认和最小返回数据仍有意义,不能把“域名在白名单”理解为所有原生操作都安全。

6. 兼容性与性能​

能力协商是双方明确当前支持哪些操作及协议版本的过程。应用版本较旧、Web 页面较新时,Web 应按实际能力决定是否显示入口,不能只比较一个 UA 字符串就调用未安装的方法。

协议可以规定向后兼容的可选字段、未知方法的稳定错误码、必填字段的验证规则。示例严格要求版本 1,没有实现跨版本协商;真正上线时,版本不兼容应有明确失败与降级路径,而不是让用户一直等待。

性能问题可以按以下链路定位:

  1. Web 编码与提交消息的成本,是否反复传递大对象。
  2. 通道与宿主任务队列的等待时间,是否被大量小消息淹没。
  3. 原生实际工作耗时,是否错误地占用 UI 线程。
  4. 返回编码、JavaScript 回调和页面更新耗时。

批量传递可以减少调用次数,但会增加单条负载、延迟和失败影响。应给消息大小、并发数与队列设置上限,记录请求 ID、方法、耗时和错误码;日志避免写入令牌和业务敏感数据。没有分段测量,就不能根据一次慢调用断定是序列化或跨进程通信造成的。

7 · 面试题卡片​

本页题目与参考回答

7.1 H5 与原生怎样通过 JSBridge 通信?​

考察重点:平台入口、请求分发、异步响应与能力边界。

相关内容:传输入口与业务协议、消息协议与回调关联。

参考回答

宿主暴露受控通信入口,Web 按约定发送方法和参数,宿主验证并执行后返回结果。 底层可使用注入对象、脚本消息处理器或消息端口;上层协议负责请求关联、错误与生命周期。

异步回调通常留在 Web 端,用请求 ID 对应返回结果。宿主还要核对来源、frame、当前会话和业务权限,通信成功不代表操作已获授权。

7.2 URL Scheme 和 JSBridge 有什么关系?​

考察重点:应用唤起、导航拦截与完整调用协议的区别。

相关内容:URL Scheme 与环境识别。

参考回答

URL Scheme 可以承载应用唤起或被宿主拦截的调用意图,但不是完整的 JSBridge 协议。 若用它实现桥,还要处理参数编码、结果回传、权限、长度和页面生命周期。能打开应用,不表示指定业务已经完成;UA 也不能证明桥可用或调用者可信。

7.3 JSBridge 怎样让多个并发请求找到各自的回调?​

考察重点:请求 ID、本地回调表、重复响应和会话区分。

相关内容:最小异步客户端、页面会话与就绪状态。

参考回答

每次调用生成请求 ID,把处理函数保存在本地表中,响应带回 ID 后查找并完成对应调用。 先登记再发送,完成时移除记录并清理定时器。不同页面会话还需要会话标识和宿主侧生命周期绑定;重复、迟到和旧会话结果应被丢弃。

7.4 调用超时后可以直接重试吗?关闭页面是否会取消原生工作?​

考察重点:等待超时、业务副作用、幂等和取消协议。

相关内容:超时与副作用。

参考回答

超时不能证明操作失败,关闭页面也不自动取消或回滚原生工作。 无副作用读取可以按需要重试;有副作用操作应依靠业务幂等键或查询结果。取消需要宿主支持,并明确已经执行或无法停止时的语义。Web 端仍应清理回调、定时器和旧会话。

7.5 主页面域名可信,为什么仍要限制桥的方法和参数?​

考察重点:来源、frame、XSS 与系统及业务授权的分工。

相关内容:权限与信任边界。

参考回答

来源白名单只解决部分调用者识别问题,不能代替每个能力的授权。 主页面可能含第三方 frame,也可能被注入脚本;某些旧接口还不能可靠区分 frame 来源。宿主必须限制方法、验证参数,核对有效会话,并执行系统权限及业务授权。页面自己传入的身份字段不可信。

7.6 Web 页面更新了,但用户的应用还是旧版本,应怎样处理?​

考察重点:就绪状态、能力协商、协议版本和降级。

相关内容:兼容性与性能。

参考回答

根据宿主实际声明的能力和协议版本决定是否调用,并为不支持的能力提供明确降级。 入口对象存在或 UA 包含应用名都不足以证明方法可用。约定稳定错误码、必填参数和向后兼容规则,避免将未知方法变成无反馈的无限等待。

7.7 JSBridge 调用较慢,怎样定位和优化?​

题型:综合题

考察重点:分段测量、队列、负载和线程。

相关内容:兼容性与性能、最小异步客户端。

参考回答

先分开测量编码传输、排队、原生工作和回调更新,再针对占用时间的阶段优化。 大数据可改为受控标识或流,小消息可按延迟要求适度批量化,同时限制并发与负载。原生耗时工作不能随意占用 UI 线程;一次慢调用不能直接证明跨环境传输是瓶颈。

8. 参考资料​

来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明