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

相关推荐
c2892480843s16 小时前
【鸿蒙心迹】网络请求封装实战:RCP 选型、拦截器与弱网预算
移动开发·网络请求·harmonyos·鸿蒙·rcp
卸任17 小时前
Vibe coding工具使用心得
前端·ai编程
brien918 小时前
为什么模型标称 1M,Google Antigravity 聊到 256K 却突然失忆?我们给它做了个原生行内监控圆环
前端
修炼的dance18 小时前
录屏关键帧间隔多少合适?六档实测,2秒到10秒小了75.6%
前端
Lank_M19 小时前
关键帧从1秒拉到10秒,录屏小87.3%,摄像头小10.8%
前端
补码缩写19 小时前
用 ffprobe 拆录屏,八成字节在 29 个关键帧上
前端
百思不得小李19 小时前
一个复杂的动态表单是如何实现的
前端·设计模式
陈随易19 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
风骏时光牛马19 小时前
前沿视界
前端
kyriewen19 小时前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员