鸿蒙 ArkWeb 混合开发全攻略:JSBridge 双向通信 + 实战登录注入

做过 H5 混合开发的同学都知道:最难受的不是写代码,而是 Native 和 Web 之间那道"墙"。

在鸿蒙里,这道墙叫 ArkWeb,打通它你需要看这篇。

一、为什么要做混合开发?

纯鸿蒙原生性能最优,但现实项目里有大量历史 H5 页面、跨平台运营活动页,全部用 ArkTS 重写成本高到离谱。混合开发(Hybrid)是工程化的最优解:

  • H5 负责:活动营销、内容展示、频繁迭代的功能
  • Native 负责:登录鉴权、支付、设备能力、高性能渲染

鸿蒙提供的 Web 组件(底层是 ArkWeb 引擎)是实现混合开发的核心,本文从零带你吃透它。


二、ArkWeb 基础:搭起骨架

2.1 最简单的 Web 组件

typescript 复制代码
// pages/WebDemo.ets
@Entry
@Component
struct WebDemo {
  // WebviewController 是控制 Web 组件的遥控器
  private webController: webview.WebviewController = new webview.WebviewController()

  build() {
    Column() {
      Web({ src: 'https://example.com', controller: this.webController })
        .width('100%')
        .height('100%')
    }
  }
}

注意:在 module.json5 中需要声明网络权限:

json 复制代码
{
  "module": {
    "requestPermissions": [
      { "name": "ohos.permission.INTERNET" }
    ]
  }
}

2.2 加载本地 HTML(rawfile)

把 H5 文件放到 resources/rawfile/ 目录:

typescript 复制代码
Web({ 
  src: $rawfile('index.html'),   // 加载 rawfile 中的本地 HTML
  controller: this.webController 
})

本地 HTML 可以使用相对路径引用同目录的 CSS、JS 文件。

2.3 核心生命周期回调

typescript 复制代码
Web({ src: 'https://example.com', controller: this.webController })
  .onPageBegin((event) => {
    // 页面开始加载,可以在这里显示 Loading
    console.log('开始加载:', event?.url)
  })
  .onPageEnd((event) => {
    // 页面加载完成,DOM 已就绪
    console.log('加载完成:', event?.url)
    // ✅ 推荐在这里执行 JS 注入
    this.injectToken()
  })
  .onErrorReceive((event) => {
    // 网络错误、资源加载失败
    console.error('加载失败:', event?.error.getErrorInfo())
  })
  .onHttpErrorReceive((event) => {
    // HTTP 状态码错误(4xx/5xx)
    console.warn('HTTP错误:', event?.response.getResponseCode())
  })

三、Native → Web:向 H5 注入能力

3.1 runJavaScript:直接执行 JS

这是最简单直接的方式,页面加载完成后调用:

typescript 复制代码
// 向 H5 注入用户 Token
private injectToken(): void {
  const token = UserService.getToken()
  const userInfo = JSON.stringify({
    token: token,
    userId: UserService.getUserId(),
    nickname: UserService.getNickname()
  })
  
  // runJavaScript 在主线程执行,返回 Promise<string>
  this.webController.runJavaScript(
    `window.__NATIVE_BRIDGE__.setUserInfo(${userInfo})`
  ).then((result) => {
    console.log('注入结果:', result) // H5 函数的返回值(字符串)
  }).catch((err: Error) => {
    console.error('注入失败:', err.message)
  })
}

H5 端配合代码:

javascript 复制代码
// H5 中提前注册好接收函数
window.__NATIVE_BRIDGE__ = {
  setUserInfo(userInfo) {
    store.commit('SET_USER', userInfo)
    console.log('收到 Native 注入的用户信息', userInfo)
    return 'ok' // 这个返回值会被 runJavaScript 的 Promise 接收到
  }
}

3.2 loadUrl + 参数:通过 URL 传参

适合简单场景(Token 不要放 URL!):

typescript 复制代码
// 页面跳转时带上必要参数
this.webController.loadUrl(
  'https://example.com/activity?source=app&version=1.2.0'
)

对于需要 SSO 登录的场景,直接往 WebView 注入 Cookie:

typescript 复制代码
import webview from '@ohos.web.webview'

// 注意:setCookie 是同步操作,建议在 onPageBegin 前调用
async function injectCookieForSso(domain: string, token: string): Promise<void> {
  try {
    webview.WebCookieManager.setCookie(
      domain,
      `auth_token=${token}; Path=/; Secure; HttpOnly`
    )
    // 如果需要立刻生效(某些机型有缓存),强制刷新 Cookie Store
    await webview.WebCookieManager.saveCookieAsync()
    console.log('Cookie 注入成功')
  } catch (err) {
    console.error('Cookie 注入失败:', (err as Error).message)
  }
}

四、Web → Native:H5 反向调用原生能力

这是混合开发的精髓,也是最容易踩坑的地方。

4.1 javaScriptProxy:注册 JS 对象(推荐)

通过 javaScriptProxy 属性,将一个 ArkTS 对象暴露给 H5 的 window 对象:

typescript 复制代码
// 定义要暴露给 H5 的 Native 接口对象
class NativeBridge {
  // ✅ 方法必须是同步的
  openCamera(): string {
    // 触发摄像头
    router.pushUrl({ url: 'pages/CameraPage' })
    return JSON.stringify({ code: 0, msg: 'ok' })
  }
  
  getDeviceInfo(): string {
    // 返回设备信息
    return JSON.stringify({
      brand: deviceInfo.brand,
      model: deviceInfo.productModel,
      osVersion: deviceInfo.osFullName
    })
  }
  
  // 支付接口(H5 调起 Native 支付)
  launchPay(orderJson: string): string {
    const order = JSON.parse(orderJson) as OrderInfo
    // 触发 Native 支付流程
    PayService.launch(order)
    return JSON.stringify({ code: 0, msg: 'launched' })
  }
  
  // 关闭 WebView(H5 触发 Native 返回)
  closeWebView(): string {
    router.back()
    return JSON.stringify({ code: 0 })
  }
}

@Entry
@Component
struct HybridPage {
  private webController: webview.WebviewController = new webview.WebviewController()
  private nativeBridge: NativeBridge = new NativeBridge()

  build() {
    Web({ src: $rawfile('h5/index.html'), controller: this.webController })
      .javaScriptProxy({
        object: this.nativeBridge,    // 要注入的对象
        name: 'NativeBridge',         // H5 中使用的名称:window.NativeBridge
        methodList: [                 // 白名单:只暴露这些方法
          'openCamera',
          'getDeviceInfo',
          'launchPay',
          'closeWebView'
        ],
        controller: this.webController
      })
  }
}

H5 端调用方式:

javascript 复制代码
// H5 直接调用 window.NativeBridge 上的方法
function openCamera() {
  const result = window.NativeBridge.openCamera()
  const res = JSON.parse(result)
  if (res.code === 0) {
    console.log('摄像头已打开')
  }
}

// 获取设备信息
const deviceInfo = JSON.parse(window.NativeBridge.getDeviceInfo())
console.log('设备型号:', deviceInfo.model)

⚠️ 关键坑点javaScriptProxy 注册的方法只能同步返回字符串。如果你需要异步操作(比如等待支付结果),需要用回调方案(见下文)。

4.2 异步回调:用 runJavaScript 反向通知

对于需要异步结果的场景(支付回调、拍照完成后返回图片),流程是:

  1. H5 调用 Native 方法,Native 开始异步操作
  2. Native 异步操作完成后,用 runJavaScript 将结果推给 H5
typescript 复制代码
// NativeBridge 中的支付方法(异步流程)
launchPay(orderJson: string): string {
  const order = JSON.parse(orderJson) as OrderInfo
  
  // 开启异步支付,完成后回调 H5
  PayService.launch(order, (payResult: PayResult) => {
    const resultJson = JSON.stringify(payResult)
    // 支付完成,调用 H5 的回调函数
    this.webController.runJavaScript(
      `window.__NATIVE_CALLBACK__.onPayResult(${resultJson})`
    )
  })
  
  return JSON.stringify({ code: 0, msg: 'payment_launched' })
}

H5 端:

javascript 复制代码
// 注册 Native 回调接收点
window.__NATIVE_CALLBACK__ = {
  onPayResult(result) {
    if (result.code === 0) {
      showSuccessToast('支付成功')
      refreshOrderList()
    } else {
      showErrorToast('支付失败: ' + result.msg)
    }
  }
}

4.3 URL 拦截(shouldOverrideUrlLoading)

适合"协议化"的 JSBridge 方案,通过自定义 URL Scheme 拦截:

typescript 复制代码
Web({ src: ..., controller: this.webController })
  .onInterceptRequest((event) => {
    const url = event?.request.getRequestUrl() ?? ''
    
    // 拦截自定义协议
    if (url.startsWith('hybrid://')) {
      this.handleHybridScheme(url)
      // 返回空响应,阻止 WebView 真正加载这个 URL
      return new WebResourceResponse()
    }
    
    // 其他请求正常处理
    return null
  })

private handleHybridScheme(url: string): void {
  const uri = new URL(url)
  const action = uri.hostname // hybrid://openCamera
  const params = Object.fromEntries(uri.searchParams)
  
  switch (action) {
    case 'openCamera':
      router.pushUrl({ url: 'pages/CameraPage' })
      break
    case 'share':
      this.triggerShare(params)
      break
    case 'close':
      router.back()
      break
  }
}

H5 端:

javascript 复制代码
// 通过 iframe src 或 location.href 触发协议
function callNative(action, params) {
  const query = new URLSearchParams(params).toString()
  location.href = `hybrid://${action}?${query}`
}

// 示例:调起分享
callNative('share', { title: '我的文章', url: 'https://...' })

五、完整实战:一个有登录、有支付的混合页面

下面是整合了上述技术点的完整实战代码:

typescript 复制代码
// pages/ShopHybridPage.ets
import webview from '@ohos.web.webview'
import router from '@ohos.router'
import { UserService } from '../services/UserService'
import { PayService, PayResult } from '../services/PayService'

class ShopNativeBridge {
  // webController 引用,用于异步回调
  private controller: webview.WebviewController

  constructor(controller: webview.WebviewController) {
    this.controller = controller
  }

  // 获取登录态
  getLoginInfo(): string {
    if (!UserService.isLoggedIn()) {
      return JSON.stringify({ code: 401, msg: 'not_logged_in' })
    }
    return JSON.stringify({
      code: 0,
      data: {
        token: UserService.getToken(),
        userId: UserService.getUserId(),
        avatarUrl: UserService.getAvatarUrl()
      }
    })
  }

  // 唤起登录页(异步流程)
  launchLogin(): string {
    router.pushUrl({
      url: 'pages/LoginPage',
      params: { fromHybrid: true }
    })
    // 登录完成后通过 EventBus 通知
    EventBus.once('loginSuccess', () => {
      const loginInfo = this.getLoginInfo()
      this.controller.runJavaScript(
        `window.HybridCallback.onLoginResult(${loginInfo})`
      )
    })
    return JSON.stringify({ code: 0, msg: 'login_page_opened' })
  }

  // 唤起支付
  launchPay(orderJson: string): string {
    const order = JSON.parse(orderJson)
    PayService.launch(order, (result: PayResult) => {
      this.controller.runJavaScript(
        `window.HybridCallback.onPayResult(${JSON.stringify(result)})`
      )
    })
    return JSON.stringify({ code: 0, msg: 'pay_launched' })
  }

  // 复制到剪贴板
  copyToClipboard(text: string): string {
    pasteboard.getSystemPasteboard().setData(
      pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text)
    )
    return JSON.stringify({ code: 0 })
  }
}

@Entry
@Component
struct ShopHybridPage {
  @State isLoading: boolean = true
  private webController: webview.WebviewController = new webview.WebviewController()
  private bridge: ShopNativeBridge = new ShopNativeBridge(this.webController)

  // 获取路由参数
  private pageUrl: string = (router.getParams() as Record<string, string>)?.url ?? 'https://shop.example.com'

  aboutToAppear(): void {
    // 提前注入登录 Cookie(如果已登录)
    if (UserService.isLoggedIn()) {
      webview.WebCookieManager.setCookie(
        'shop.example.com',
        `auth=${UserService.getToken()}; Path=/`
      )
    }
  }

  build() {
    Stack() {
      // Web 主体
      Web({ src: this.pageUrl, controller: this.webController })
        .width('100%')
        .height('100%')
        .javaScriptProxy({
          object: this.bridge,
          name: 'NativeBridge',
          methodList: ['getLoginInfo', 'launchLogin', 'launchPay', 'copyToClipboard'],
          controller: this.webController
        })
        .onPageBegin(() => {
          this.isLoading = true
        })
        .onPageEnd(() => {
          this.isLoading = false
        })
        .onInterceptRequest((event) => {
          const url = event?.request.getRequestUrl() ?? ''
          if (url.startsWith('hybrid://close')) {
            router.back()
            return new WebResourceResponse()
          }
          return null
        })

      // Loading 遮罩
      if (this.isLoading) {
        Column() {
          LoadingProgress()
            .width(40)
            .height(40)
            .color('#007DFF')
          Text('加载中...')
            .fontSize(14)
            .fontColor('#999999')
            .margin({ top: 8 })
        }
        .width('100%')
        .height('100%')
        .backgroundColor('#FFFFFF')
        .justifyContent(FlexAlign.Center)
      }
    }
    .width('100%')
    .height('100%')
  }
}

六、常见坑与解法速查

坑位 现象 原因 解法
javaScriptProxy 方法不触发 H5 调用无响应 methodList 未包含该方法 在 methodList 中添加方法名
runJavaScript 报空指针 偶发崩溃 在 onPageBegin 前调用 移至 onPageEnd 回调中
Cookie 注入不生效 H5 仍跳登录 时序问题,WebView 已启动 在 Web 组件创建前注入 Cookie
H5 内跳转 Native 页面被拦截 路由失效 URL 被 WebView 加载而非拦截 在 onInterceptRequest 中处理
javaScriptProxy 异步不支持 返回 undefined 接口只支持同步返回字符串 改用 runJavaScript 反向通知
HTTPS 混合内容警告 图片/资源不加载 HTTP 资源在 HTTPS 页面被阻止 .mixedMode(MixedMode.All)
调试看不到日志 不知道 H5 报什么错 DevTools 未开启 .webDebuggingAccess(true)
typescript 复制代码
// 开启 DevTools(仅 Debug 构建)
Web({ ... })
  .webDebuggingAccess(true)    // 允许 Chrome DevTools 调试
  .mixedMode(MixedMode.All)    // 允许 HTTP 混合内容(慎用)
  .domStorageAccess(true)      // 允许 localStorage/sessionStorage
  .fileAccess(true)            // 允许访问文件系统

七、安全注意事项

混合开发有几条安全红线,碰了会出大事:

  1. 不要把 Token 放 URL 参数:URL 会出现在日志、浏览器历史里,用 Cookie 或 JS 注入代替
  2. methodList 要精确:暴露最小必要接口,不要把整个对象都暴露
  3. 域名白名单 :对 javaScriptProxy 只对信任域名开启
  4. 输入校验launchPay 等接口的 orderJson 一定要做类型校验,防止 H5 被 XSS 注入后攻击 Native
  5. 不要在 Release 包开启 webDebuggingAccess

总结

鸿蒙 ArkWeb 混合开发的核心通信方案:

css 复制代码
Native → H5:runJavaScript()  /  Cookie 注入
H5 → Native:javaScriptProxy  /  URL 拦截(onInterceptRequest)
异步回调:H5调Native启动异步 → Native完成后runJavaScript推结果

掌握这几把钥匙,你就能在鸿蒙上搭出一套稳健的 Hybrid 架构,让历史 H5 资产无缝复用,新功能用原生拿到最优性能。

相关推荐
sun_weitao1 小时前
微前端框架之qiankun 怎么用?vue3+vite+qiankun 和 react + qiankun
前端·react.js·前端框架
CodeSheep1 小时前
南京IT公司六小龙
前端·后端·程序员
IT_陈寒2 小时前
React useEffect依赖数组把我坑惨了,这次搞明白了
前端·人工智能·后端
gcw10242 小时前
Cron专业工具:定时任务与开发办公的一站式在线工具箱
java·前端·数据库·工具·crontab·cron·spring cron
环境栈笔记2 小时前
AI 浏览器任务失败怎么排查:Context、Session、Page State 和 Task Log 检查清单
前端·人工智能·后端·自动化
阳光是sunny2 小时前
别再让 AI 瞎找代码了!CodeGraph 这工具能让它秒懂你的项目
前端·后端·ai编程
tuddy7894642 小时前
快速创建常见的数据模型、CRUD 接口、配置文件等重复性结构
前端
●VON2 小时前
HarmonyKit | 鸿蒙新特性:pasteboard 剪贴板 API 集成与 promptAction 迁移指南
华为·harmonyos
小流苏生2 小时前
这是一篇关于忘记的文章
前端·程序员·ai编程