前端知库

JSBridge 是什么?WebView 页面如何与原生或小程序通信,怎样做安全校验?

说明 JSBridge 的两种调用方向和常见实现(注入对象、消息处理器、URL 拦截),小程序 web-view 的通信限制,以及来源校验、能力白名单和参数校验。

参考答案

简答

  • JSBridge 是 WebView 中网页 JS 与宿主(App 原生代码或小程序)之间的通信层:网页调用宿主能力,宿主把结果或事件回传给网页。
  • 网页调宿主常见三种方式:宿主注入的 JS 对象(Android addJavascriptInterface)、平台提供的消息接口(Android addWebMessageListener、iOS window.webkit.messageHandlers,iOS 14+ 可用带回复的处理器)、拦截自定义协议 URL;宿主调网页则在 WebView 中执行一段 JS(Android evaluateJavascript、iOS evaluateJavaScript)。
  • 两边只能传可序列化的数据,异步调用要用回调 ID 或 Promise 把请求和响应对应起来。
  • 微信小程序的 web-view 不能直接调用小程序 API,只能用 JSSDK 提供的 wx.miniProgram.navigateTo、postMessage 等接口,postMessage 的消息也只在后退、组件销毁、分享等特定时机送达。
  • 安全上要把网页当作不可信输入:宿主只在可信页面开放 bridge,能拿到来源时(Android sourceOrigin、iOS frameInfo.securityOrigin)逐条检查,只开放白名单能力、逐个校验参数,敏感操作再经过用户确认或服务端校验。

通信的两个方向

网页调用宿主:

  • 注入对象:Android 用 webView.addJavascriptInterface(obj, "bridge") 把 Java 对象挂到页面上,网页直接调用它的方法。官方文档说明:对象会注入到页面的所有 frame(包括 iframe);目标 API 17(Android 4.2)及以上时只有加了 @JavascriptInterface 注解的公共方法可调用;注入要到下一次页面加载后才生效;JS 调用发生在 WebView 的后台线程上。
  • 带来源的消息接口:AndroidX 的 WebViewCompat.addWebMessageListener(webView, "bridge", allowedOriginRules, listener) 只向 origin 匹配规则的 frame 注入对象,网页调用 bridge.postMessage(data),原生回调 onPostMessage 会拿到 sourceOrigin、isMainFrame,并通过 replyProxy.postMessage 回复。使用前要用 WebViewFeature.isFeatureSupported(WEB_MESSAGE_LISTENER) 检查。
  • 消息处理器:iOS 用 userContentController.add(handler, name: "bridge") 安装实现 WKScriptMessageHandler 的处理器,网页调用 window.webkit.messageHandlers.bridge.postMessage(data),WebKit 回调 userContentController(_:didReceive:)。name 在同一个 WKUserContentController 内必须唯一且不能为空,函数定义在页面的默认 content world(WKContentWorld.page)。收到的 WKScriptMessage.body 只允许 NSNumber、NSString、NSDate、NSArray、NSDictionary、NSNull;frameInfo 是发消息的 frame,可读取 isMainFrame 和 securityOrigin(协议、主机名、端口;iOS 9+)。
  • 带回复的消息处理器(iOS 14+):用 addScriptMessageHandler(_:contentWorld:name:) 安装 WKScriptMessageHandlerWithReply,函数会定义在指定 content world 的所有 frame 中。原生在 userContentController(_:didReceive:replyHandler:) 里调用 replyHandler(reply, nil) 回复,出错时 reply 传 nil 并给出错误字符串;reply 同样限于上述几种类型。不需要回复时用 WKScriptMessageHandler 即可。
  • 预先注入脚本:iOS 的 WKUserScript(source:injectionTime:forMainFrameOnly:) 可以在页面里先挂好 bridge 封装。atDocumentStart 在文档元素创建后、其他内容加载前注入,atDocumentEnd 在文档加载完成后、其他子资源加载前注入;forMainFrameOnly 为 true 只注入主 frame,为 false 注入所有 frame。官方要求在创建 WebView 之前把脚本加到配置的 WKUserContentController 中。
  • URL 拦截:网页跳转一个自定义协议地址(如 myapp://share?...),宿主在导航回调中拦截并解析。这种方式受 URL 长度限制,现在多作为兼容方案。

宿主调用网页:宿主在 WebView 中执行 JS,例如调用网页事先挂好的 window.onNativeEvent(json)。Android 用 evaluateJavascript(script, callback),它在当前页面上下文中异步执行,必须在 UI 线程调用,回调也在 UI 线程。iOS 用 webView.evaluateJavaScript(script, completionHandler:)(另有 async 版本),执行结束后无论成功或失败都会调用回调,回调总在主线程运行,参数是执行结果或错误。拼接脚本时要把数据先编码成 JSON,不要直接把字符串拼进代码。

一个最小的请求-响应封装

JavaScript
// 网页侧:给每次调用分配 ID,宿主处理完后按 ID 回调
const pending = new Map();
let seq = 0;

export function callNative(method, params) {
  return new Promise((resolve, reject) => {
    const id = ++seq;
    pending.set(id, { resolve, reject });
    window.webkit.messageHandlers.bridge.postMessage({ id, method, params });
  });
}

// 宿主执行 window.__bridgeReply({ id, error, result }) 回传结果
window.__bridgeReply = ({ id, error, result }) => {
  const task = pending.get(id);
  if (!task) return;
  pending.delete(id);
  error ? task.reject(new Error(error)) : task.resolve(result);
};

实际项目还需要超时清理 pending,并在宿主不存在时(普通浏览器里打开)给出降级。

小程序 web-view 的边界

  • web-view 加载的网页要在小程序管理后台配置业务域名,个人类型小程序暂不支持。
  • 网页引入 JSSDK 后,只能使用 wx.miniProgram.navigateTo、navigateBack、switchTab、reLaunch、redirectTo、postMessage、getEnv 等接口,以及部分 JSSDK 能力(如图片、定位、扫一扫)。
  • wx.miniProgram.postMessage 不会实时送达,组件的 bindmessage 只在小程序后退、组件销毁、分享、复制链接等时机触发,e.detail.data 是多次消息组成的数组。需要实时交互时,常见做法是让网页跳转到小程序页面,或通过服务端中转。
  • 网页可以通过 window.__wxjs_environment === 'miniprogram' 或 wx.miniProgram.getEnv 判断自己是否运行在小程序中。

安全校验

WebView 里可能加载第三方页面、广告 iframe,或者页面存在 XSS。只要 bridge 对所有页面开放,攻击者就能调用宿主能力。

  • 校验来源:能拿到调用方来源时,检查发起页面或 frame 的 origin 是否在白名单内。addJavascriptInterface 注入到所有 frame,回调里拿不到调用方 origin,只能靠控制加载的页面(只加载白名单地址、拦截跳转)来限制。需要按来源区分时改用 addWebMessageListener:用 allowedOriginRules 限定注入范围(官方建议只用 HTTPS 规则,通配符 * 会注入所有 frame,消息要按不可信数据处理),并在 onPostMessage 里再检查 sourceOrigin 和 isMainFrame。iOS 在处理器里检查 message.frameInfo.isMainFrame 和 message.frameInfo.securityOrigin 的协议、主机名、端口;WKUserScript 不需要进入 iframe 时设 forMainFrameOnly: true。只比较 URL 前缀容易被 https://trusted.com.evil.com 之类的地址绕过,应解析后比较协议和主机名。
  • 最小能力:按页面来源开放不同的方法集合,不提供"执行任意命令""读写任意文件"之类的通用方法。
  • 校验参数:类型、长度、枚举值逐一检查;文件路径、跳转地址同样按白名单校验。
  • 敏感操作二次确认:支付、获取手机号等操作由宿主弹出原生确认,关键结果以服务端校验为准,不信任网页传回的"已支付"状态。
  • 限制导航:WebView 只允许跳转白名单域名,防止页面被导航到恶意站点后继续使用 bridge。

Electron 中的 preload 和 IPC 是同一类问题,见《Electron 应用的主进程、渲染进程和 IPC 有哪些安全边界?》。

常见追问

  • 怎么判断 bridge 是否已就绪?宿主注入通常有时机差,可以由宿主在注入完成后派发一个自定义事件,网页监听该事件后再调用;微信 web-view 中可以监听 WeixinJSBridgeReady。
  • addJavascriptInterface 在旧系统上有什么风险?官方文档指出,目标 API 16(Android 4.1)及以下的应用,或运行在 Android 4.2 以下设备上的应用,页面 JS 可以通过反射访问注入对象,以宿主权限执行 Java 代码;包含不可信内容的 WebView 中要格外谨慎。
  • 用 prompt、alert 拦截做通信可以吗?早期有这种做法,但会和真实弹窗冲突,且同步阻塞页面,优先使用平台提供的消息接口。