[鸿蒙从零到一] HarmonyOS Web 组件与 JSBridge 通信实战:从页面加载到安全协议

[鸿蒙从零到一] HarmonyOS Web 组件与 JSBridge 通信实战:从页面加载到安全协议

在 HarmonyOS 应用中,Web 组件适合承载已有 H5 页面、富文本内容、活动页和需要快速迭代的业务界面。但网页和 ArkTS 页面属于两套运行环境:网页使用 JavaScript,原生侧使用 ArkTS;如果只是把 URL 放进 Web 组件,双方并不会自动共享状态。

真正可维护的方案,是把 Web 组件当成一个有边界的通信端点,明确加载策略、消息格式、调用方向、生命周期和安全策略。本文以“原生页面打开一个网页订单详情,网页请求原生能力并接收结果”为例,逐步搭建一套轻量 JSBridge。

一、先理解 Web 组件的职责边界

Web 组件负责在 ArkUI 页面中展示网页,并提供网页加载、导航、脚本执行和事件监听等能力。它不是把网页代码直接编译成 ArkTS,也不是一个可以随意访问应用内部对象的容器。

常见职责可以这样分工:

  • 网页负责展示内容、表单交互和与网页后端的协议。
  • ArkTS 负责应用身份、系统能力、页面路由和本地数据访问。
  • Bridge 负责把双方真正需要交换的数据转换成明确消息。
  • 业务层负责校验消息来源、参数和调用结果。

如果网页只是静态帮助文档,可以不引入 Bridge;如果网页需要调用相册、定位、支付或原生页面跳转,就应先设计协议,再编写具体接口。把所有能力都暴露给网页,后续很难收紧权限,也难以定位问题。

二、创建 Web 组件与加载页面

Web 组件通常需要一个 WebviewController。控制器负责加载页面、执行脚本以及访问导航相关能力。示例中的 API 名称和参数请以当前 DevEco Studio 对应 SDK 的类型定义为准,不同版本可能会有细节差异。

import web_webview from '@ohos.web.webview'

@Entry
@Component
struct OrderWebPage {
  private controller: web_webview.WebviewController =
    new web_webview.WebviewController()

  build() {
    Column() {
      Web({
        src: 'https://m.example.com/order/detail',
        controller: this.controller
      })
        .width('100%')
        .height('100%')
        .javaScriptAccess(true)
        .onPageBegin(() => {
          console.info('order page begin')
        })
        .onPageEnd(() => {
          console.info('order page ready')
        })
        .onErrorReceive((event) => {
          console.error(`web load error: ${JSON.stringify(event)}`)
        })
    }
    .width('100%')
    .height('100%')
  }
}

加载外部地址前,应在模块配置中声明网络访问权限,并确认域名、证书和重定向策略符合应用的网络安全要求。调试阶段可以使用本地页面,但发布环境应使用 HTTPS,并对允许访问的域名做白名单管理。

页面加载事件适合更新加载状态、记录耗时和展示错误页。不要把一次加载完成误认为 Bridge 已经可以调用:网页脚本可能还在初始化,通信通道应在网页主动发送 ready 消息后才认为可用。

三、设计稳定的消息协议

Bridge 最容易失控的地方不是发送消息,而是消息没有统一格式。建议让每条消息都包含版本、动作名、请求标识和参数,并区分请求、响应和事件。

interface BridgeRequest {
  type: 'request'
  version: 1
  requestId: string
  action: string
  payload: Record<string, string | number | boolean | null>
}

interface BridgeResponse {
  type: 'response'
  version: 1
  requestId: string
  ok: boolean
  data?: unknown
  error?: {
    code: string
    message: string
  }
}

requestId 用于把异步结果匹配回原始请求,不能使用时间戳加动作名这种容易碰撞的组合。version 让网页和应用可以平滑升级;okerror.codeerror.message 让调用方能稳定处理失败,而不是解析自然语言。

动作名建议采用有限集合,例如 getAppInfoopenNativePageselectImage。不要允许网页把任意字符串当成方法名直接反射调用。参数也要按动作单独校验,不能因为外层是 JSON 就认为内容可信。

四、网页调用原生能力

一种常见做法是由网页调用原生注入的 JavaScript 方法,原生侧收到 JSON 后解析并分发。网页侧可以封装成 Promise,让业务代码不必关心 requestId。

const pending = new Map()

function callNative(action, payload = {}) {
  const requestId = `${Date.now()}_${Math.random().toString(16).slice(2)}`
  return new Promise((resolve, reject) => {
    pending.set(requestId, { resolve, reject })
    window.arkBridge.postMessage(JSON.stringify({
      type: 'request',
      version: 1,
      requestId,
      action,
      payload
    }))
  })
}

function receiveNativeMessage(raw) {
  const message = typeof raw === 'string' ? JSON.parse(raw) : raw
  if (message.type !== 'response') return
  const task = pending.get(message.requestId)
  if (!task) return
  pending.delete(message.requestId)
  message.ok ? task.resolve(message.data) :
    task.reject(new Error(message.error?.message || 'native call failed'))
}

这里的 window.arkBridge.postMessage 代表双方约定的通信入口,具体注入方式依赖当前 Web 组件 SDK。无论采用脚本注入、网页消息回调还是自定义 URL 协议,都应该把底层差异收敛在 Bridge 适配层,业务代码只依赖 callNative

原生侧的处理流程可以抽象为:读取原始消息、解析 JSON、校验公共字段、校验动作参数、执行能力、返回同一个 requestId。任何一步失败都返回结构化错误,不能让异常直接穿透到 Web 组件回调。

private async handleBridgeMessage(raw: string): Promise<void> {
  let request: BridgeRequest
  try {
    request = JSON.parse(raw) as BridgeRequest
    this.validateRequest(request)
  } catch (error) {
    console.error(`invalid bridge request: ${JSON.stringify(error)}`)
    return
  }

  try {
    const data = await this.dispatchAction(request.action, request.payload)
    this.sendResponse({
      type: 'response', version: 1, requestId: request.requestId,
      ok: true, data
    })
  } catch (error) {
    this.sendResponse({
      type: 'response', version: 1, requestId: request.requestId,
      ok: false,
      error: { code: 'NATIVE_CALL_FAILED', message: this.toSafeMessage(error) }
    })
  }
}

五、原生侧调用网页方法

反方向的调用也很常见,例如原生完成登录后通知网页刷新用户信息。原生侧可以通过控制器执行 JavaScript,但不要直接拼接用户输入到脚本字符串中。

private notifyWebLogin(token: string): void {
  const safeToken = JSON.stringify(token)
  const script = `window.appEvents && window.appEvents.onLogin(${safeToken})`
  this.controller.runJavaScript(script, (result) => {
    console.info(`login event delivered: ${JSON.stringify(result)}`)
  })
}

使用 JSON.stringify 做字符串字面量编码,可以避免引号、换行或脚本片段破坏 JavaScript 语法。更复杂的数据应先序列化为 JSON,再在网页侧解析。不要采用字符串拼接的方式拼出对象,也不要把服务端返回的 HTML 当作脚本执行。

原生调用必须等待网页 ready。可以维护一个待发送队列,在网页发送 ready 事件后刷新;页面重新加载时清空旧队列和旧请求,避免把上一页面的响应交给新页面。

六、处理生命周期与并发

Web 页面会经历加载、跳转、刷新和销毁,Bridge 不能只考虑“打开后点击按钮”的理想路径。建议把以下状态作为组件内部状态机管理:

  • idle:控制器已创建,但页面还未准备好。
  • loading:页面正在加载,暂不处理业务调用。
  • ready:网页已发送协议版本匹配的 ready 消息。
  • failed:加载或协议协商失败,拒绝新的调用。
  • destroyed:组件销毁,清理监听器、队列和超时任务。

每个请求都应设置超时和取消策略。页面跳转或销毁时,未完成 Promise 必须统一 reject,并清理 Map,否则长时间运行的应用会积累闭包和请求对象。对于高频事件,例如滚动、输入和进度通知,使用事件消息而不是为每个变化创建一个请求。

private readonly pending = new Map<string, (result: BridgeResponse) => void>()

private rejectAllPending(code: string): void {
  this.pending.forEach((resolve, requestId) => {
    resolve({
      type: 'response', version: 1, requestId, ok: false,
      error: { code, message: 'web page is no longer available' }
    })
  })
  this.pending.clear()
}

onPageBegin、错误回调和组件销毁阶段调用清理逻辑,并用日志记录请求数、超时数和失败码。线上问题通常不是“完全不能通信”,而是偶发超时、页面重载后响应错配或旧监听器重复触发。

七、安全边界不能省略

Web 组件会执行网页 JavaScript,因此安全策略必须和功能设计同时完成。重点包括:

  • 只加载明确允许的 HTTPS 域名,限制不必要的跳转。
  • Bridge 只暴露业务必需的动作,不暴露任意系统 API、文件路径或账号令牌。
  • 对来源、协议版本、动作名、参数类型和参数长度逐项校验。
  • 敏感操作在原生侧再次确认用户身份和业务状态,不能只相信网页传来的字段。
  • 令牌不通过 URL、日志或错误消息传递;返回网页的数据遵循最小化原则。
  • 外部网页和内嵌网页分开处理,第三方内容不要获得同等原生能力。

如果 Bridge 使用自定义 URL 拦截协议,必须严格校验 scheme、host、path 和参数,避免网页通过伪造 URL 触发敏感动作。对于脚本注入方式,应固定函数名和参数编码,拒绝直接执行来自网页的任意脚本。

安全校验不是单次上线检查。域名变更、网页前端升级、Bridge 增加新动作和 SDK 升级都应重新验证,尤其要覆盖错误页面、重定向页面和离线缓存页面。

八、错误处理与可观测性

网页端应区分网络错误、协议错误、业务错误和原生能力错误。原生端也应使用稳定错误码,例如:

  • BRIDGE_NOT_READY:网页尚未完成握手。
  • INVALID_REQUEST:消息格式或参数不符合协议。
  • ACTION_NOT_ALLOWED:当前网页来源不能调用该动作。
  • NATIVE_PERMISSION_DENIED:系统权限或用户授权不足。
  • NATIVE_TIMEOUT:原生能力在约定时间内没有完成。

日志中记录 requestId、action、耗时、结果码和页面地址摘要即可,不要记录完整 token、身份证号、手机号或用户输入。对异常数据做脱敏后再上报。开发环境可以保留详细堆栈,生产环境返回给网页的 message 应该是可理解但不泄露内部实现的安全文本。

九、封装成可测试的 Bridge 服务

不要把消息解析、业务分发和 Web 组件控制器全部写在页面 build() 中。可以拆成三个层次:

  • BridgeCodec:负责 JSON 编解码和公共字段校验。
  • BridgeRouter:负责动作白名单、参数校验和业务调用。
  • WebBridgeAdapter:负责和 Web 组件 API 对接、发送响应以及生命周期清理。

这样可以在不启动 Web 组件的情况下测试无效 JSON、未知动作、缺失 requestId、超长参数和重复响应。真正依赖控制器的部分只需要验证脚本调用、页面重载和销毁时机。

测试清单至少应包括:页面正常加载并握手、网页调用成功、原生调用失败、连续请求乱序返回、页面刷新、网络断开、超时、重复 ready、非法来源和组件销毁。若应用支持多窗口或横竖屏切换,还要确认控制器和 Bridge 状态不会被旧页面复用。

总结

HarmonyOS Web 组件解决的是网页承载问题,JSBridge 解决的是跨运行环境协作问题。工程上最重要的不是找到一个能“传字符串”的技巧,而是建立一条有版本、有 requestId、有超时、有错误码且有安全边界的通信协议。

从页面加载开始,先等待网页完成握手,再通过白名单分发能力;从原生调用网页开始,做好参数编码、生命周期清理和失败回调。把这些规则集中在适配层后,H5 和 ArkTS 就能各自保持清晰职责,业务页面也不会被大量平台细节绑架。

posted @ 2026-08-03 10:09  天总会晴的  阅读(2)  评论(0)    收藏  举报