鸿蒙 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 资产无缝复用,新功能用原生拿到最优性能。

相关推荐
mldong6 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排6 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60617 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角8 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!8 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰8 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi10 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen10 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马11 小时前
AI源码分析:拆解模型底层实现逻辑
前端
IT_陈寒12 小时前
React子组件莫名其妙重渲染?你可能漏了这个Hook
前端·人工智能·后端