从 Web 容器到本地 HAR:我做了一个 HarmonyOS JSBridge 运行时框架 Demo

项目地址:https://github.com/lichenyang5/MiniAppRuntime-Harmony
技术栈:HarmonyOS / ArkTS / ArkWeb / JavaScriptProxy / runJavaScript / HAR / H5
说明:本文介绍的是个人开源学习与工程实践项目,基于公开 HarmonyOS、ArkTS、ArkWeb 能力实现,不包含任何公司内部源码、内部接口、内部文档或非公开实现。


前言

最近我在做一个 HarmonyOS 方向的个人开源项目:MiniAppRuntime-Harmony

它不是一个完整的小程序平台,也不是生产级容器,而是一个围绕 HarmonyOS Web 容器和 JSBridge 调用链路做的工程实践项目。

我一开始的目标很简单:

H5 页面能不能通过一个统一的 JSBridge 调用 ArkTS 侧能力?

但真正开始做之后,发现如果只是让 H5 调一个 Toast,其实很容易;难的是如何把它做成一个有层次、有扩展能力、能被其他 Demo 复用的运行时框架雏形。

所以这个项目逐步做到了下面这些能力:

  • ArkWeb 加载本地 H5 页面;
  • H5 通过 window.myascf.send(...) 以 Promise 形式调用 ArkTS;
  • 使用 requestId + callback map 管理异步回调;
  • 通过 JavaScriptProxy 接收 H5 消息;
  • 通过 runJavaScript 将 ArkTS 处理结果回调给 H5;
  • 引入 BridgeController / BridgeDispatcher / HandlerRegistry 拆分调用链路;
  • 引入 Biz / Imp 分层拆分参数校验和系统能力调用;
  • 支持 TIMEOUT / CALLBACK_LOST / UNKNOWN_ACTION / PARAM_ERROR 等错误处理;
  • 实现 Toast、Clipboard、Storage 三组真实 API;
  • 提供 H5 DebugPanel 展示调用链路;
  • 将 runtime 抽成 myascf_runtime 本地 HAR 包;
  • 封装 MyASCFRuntime 对外门面类;
  • 补充 Web 容器加载进度、URL Guard 和错误页。

这篇文章主要分享这个项目目前的整体设计和实现思路。


一、为什么要做这个项目

在 HarmonyOS 应用里,ArkTS 原生页面和 H5 页面经常会同时存在。

比如一个页面由 H5 实现,但它需要调用一些原生能力:

  • 弹 Toast;
  • 读写剪贴板;
  • 读写本地存储;
  • 获取设备信息;
  • 发起网络请求;
  • 打开原生页面;
  • 访问系统能力。

这类场景的核心问题是:

H5 如何安全、稳定、可扩展地调用 ArkTS 能力?

如果只是写一个 Demo,可以这样做:

H5 点击按钮
→ ArkTS 收到消息
→ ArkTS 弹 Toast

但如果要做成一个框架,就会遇到更多问题:

  • H5 怎么描述一次请求?
  • 多个请求并发时,如何知道响应对应哪个请求?
  • ArkTS 收到 action 后怎么分发?
  • 新增 API 时要不要修改主链路?
  • 参数校验放在哪里?
  • 系统能力调用放在哪里?
  • ArkTS 回调 H5 时如何统一处理?
  • 超时怎么办?
  • 响应回来时 callback 已经不存在怎么办?
  • 这个 runtime 能不能从 Demo 里抽出来,变成一个本地 HAR 包?

MiniAppRuntime-Harmony 就是围绕这些问题一步步做出来的。


二、项目整体架构

当前项目大致分成两部分:

entry
  示例应用,负责 ArkWeb 页面、H5 Demo、DebugPanel 展示

myascf_runtime
  本地 HAR 运行时框架包,负责 JSBridge 主链路、API 分发、系统能力封装

整体结构可以理解为:

entry 示例应用

ArkWeb Container

rawfile/web H5 Demo

myascf_runtime HAR

MyASCFRuntime

JavaScriptProxy

BridgeController

BridgeDispatcher

HandlerRegistry

Biz Layer

Imp Layer

HarmonyOS Public Kit

BridgeCallbackExecutor

runJavaScript

entry 的职责是把 Demo 跑起来。

myascf_runtime 的职责是让 JSBridge 调用链路可复用。

这样做之后,Demo 页面可以变化,但 runtime 核心不会散落在应用代码里。


三、一次完整调用链路

以 H5 调用 Toast 为例:

window.myascf.send("ui.showToast", {
  message: "hello from h5"
})

完整链路是:

H5 button
→ window.myascf.send
→ 生成 requestId
→ 保存 callback
→ window.MyASCFNative.postMessage
→ JavaScriptProxy
→ BridgeController
→ BridgeDispatcher
→ HandlerRegistry
→ ToastBiz
→ ToastImp
→ promptAction.showToast
→ BridgeResponse
→ BridgeCallbackExecutor
→ runJavaScript
→ H5 callback map
→ Promise resolve / reject

这里有几个关键点。

第一,H5 不是直接调用 ArkTS 函数,而是通过 window.MyASCFNative.postMessage(...) 把请求字符串发给 ArkTS。

第二,每次请求都有一个 requestId,这样多个异步请求并发时,H5 可以根据 requestId 找到对应的 Promise。

第三,ArkTS 不直接在入口层写业务逻辑,而是通过 Dispatcher、Registry、Biz、Imp 分层处理。

第四,ArkTS 回调 H5 不散落在各个业务类里,而是统一交给 BridgeCallbackExecutor 执行 runJavaScript


四、H5 侧:requestId + callback map

H5 侧的入口是:

window.myascf.send(action, params, options)

它大致做几件事:

生成 requestId
组装 BridgeRequest
保存 callback
启动 timeout timer
调用 window.MyASCFNative.postMessage
等待 ArkTS 回调

请求对象类似:

{
  requestId: "req_001",
  action: "ui.showToast",
  params: {
    message: "hello from h5"
  }
}

为什么需要 requestId

因为 H5 可能连续发多个请求:

window.myascf.send("ui.showToast", { message: "A" })
window.myascf.send("system.clipboard.readText", {})
window.myascf.send("system.storage.getItem", { key: "username" })

这些请求的响应返回顺序不一定和发送顺序一致。

所以 H5 侧需要维护一个 callback map:

requestId -> { resolve, reject, timer, action, createdAt }

发送前保存 callback,响应回来后根据 response.requestId 查 callback。

如果找到:

code === SUCCESS -> resolve(response)
code !== SUCCESS -> reject(response)

如果找不到,就说明 callback 可能已经因为超时被删除,这时记录 CALLBACK_LOST


五、ArkTS 入口:JavaScriptProxy

H5 调用:

window.MyASCFNative.postMessage(JSON.stringify(request))

这个 MyASCFNative 是 ArkTS 通过 ArkWeb 的 .javaScriptProxy(...) 注入到 H5 环境里的。

entry 页面中大致会这样注册:

.javaScriptProxy({
  object: this.runtime.getNativeProxy(),
  name: this.runtime.getProxyName(),
  methodList: this.runtime.getMethodList(),
  controller: this.controller
})

这里我通过 MyASCFRuntime 提供三个对外方法:

getNativeProxy()
getProxyName()
getMethodList()

外部 Demo 不需要知道 runtime 内部如何创建 BridgeControllerBridgeDispatcherHandlerRegistry,只需要把 runtime 提供的 NativeProxy 注册给 ArkWeb。

JavaScriptProxy 接收到 H5 消息后,只做一件事:

this.bridgeController.handleMessage(message)

它不判断 action,不校验参数,也不调用系统能力。

因为它只是通信入口,不是业务处理层。


六、BridgeController 为什么不能变成大杂烩

在很多 Demo 里,收到 H5 消息后可能会直接写:

if (request.action === 'ui.showToast') {
  // 调 Toast
} else if (request.action === 'system.clipboard.readText') {
  // 读剪贴板
}

这种方式短期能跑,但很快会变得难维护。

如果所有逻辑都写在 Controller 里,它会同时负责:

  • JSON 解析;
  • action 判断;
  • 参数校验;
  • 系统能力调用;
  • 错误处理;
  • 回调 H5;
  • 日志记录。

所以我把 Controller 的职责收窄为:

接收 raw message
解析 BridgeRequest
调用 BridgeDispatcher
拿到 BridgeResponse
交给 BridgeCallbackExecutor 回调 H5

这样 Controller 不关心有哪些 API,也不关心每个 API 的具体实现。


七、Dispatcher / Registry:让 action 扩展变成注册行为

BridgeDispatcher 负责根据 action 找 handler:

const handler = this.registry.get(request.action)

如果找不到,就返回:

UNKNOWN_ACTION

如果找到了,就执行 handler。

HandlerRegistry 负责保存 action 和 handler 的映射关系:

ui.showToast -> toastBiz.handle
system.clipboard.writeText -> clipboardBiz.writeText
system.clipboard.readText -> clipboardBiz.readText
system.storage.setItem -> storageBiz.setItem
system.storage.getItem -> storageBiz.getItem
system.storage.removeItem -> storageBiz.removeItem
system.storage.clear -> storageBiz.clear

这些映射不是每次请求时才创建,而是在 runtime 初始化时由 RuntimeBootstrap 注册。

这样新增一个 API 时,不需要改 Controller,也不需要改 Dispatcher 主逻辑,只需要:

新增 ActionName
新增 Biz
新增 Imp
在 RuntimeBootstrap 注册
更新 H5 Demo
更新 API 文档

这就是框架可扩展性的基础。


八、Biz / Imp:参数校验和系统能力调用分开

我把每个 API 的实现拆成两层:

Biz:参数校验、业务语义、响应结构
Imp:调用 HarmonyOS 公开系统能力

以 Toast 为例:

ToastBiz
→ 校验 params.message
→ ToastImp.showToast(message)
→ promptAction.showToast
→ BridgeResponse

以 Storage 为例:

StorageBiz
→ 校验 params.key / params.value
→ StorageImp.setItem / getItem / removeItem / clear
→ Preferences 本地存储能力
→ BridgeResponse

这样做的好处是:

  • Dispatcher 不需要知道每个 API 的参数规则;
  • Imp 不需要知道 requestId 和 BridgeResponse;
  • 参数协议和系统能力调用不会混在一起;
  • 新增 API 时结构更稳定。

九、CallbackExecutor:统一 ArkTS 回调 H5

ArkTS 处理完成后,需要通过 runJavaScript 回调 H5。

如果每个 API 都自己拼接 JS 字符串,代码会很容易散。

所以我抽出了:

BridgeCallbackExecutor

它负责:

BridgeResponse 序列化
JSON 字符串处理
拼接 H5 回调函数
执行 runJavaScript
记录回调日志
处理回调失败风险

核心思路类似:

const script: string = `if (${H5_RESPONSE_CALLBACK}) { ${H5_RESPONSE_CALLBACK}(${responseArg}); }`

await this.controller.runJavaScript(script)

这样 ArkTS -> H5 的回调出口就统一了。

Controller 只需要把 response 交给 CallbackExecutor,不需要自己拼 JS。


十、TIMEOUT 和 CALLBACK_LOST

H5 调用时可以传 timeout:

window.myascf.send("ui.showToast", {
  message: "hello"
}, {
  timeout: 5000
})

这里的 5000 表示 5000ms。

TIMEOUT 是 H5 侧判断的:

H5 发请求
→ 保存 callback
→ 启动 timer
→ 5 秒内收到响应:清理 timer,resolve / reject
→ 5 秒内没收到响应:删除 callback,reject TIMEOUT

CALLBACK_LOST 是另一种情况:

第 0 秒:H5 发请求
第 5 秒:H5 超时,删除 callback
第 6 秒:ArkTS 才回调 H5
第 6 秒:H5 根据 requestId 查 callback
第 6 秒:找不到 callback

这时就是 CALLBACK_LOST

它不是致命错误,而是一种可观察的异常状态。

DebugPanel 会记录这种情况,方便调试。


十一、目前已经实现的 API

当前项目已经实现了三组 API。

1. Toast

await window.myascf.send("ui.showToast", {
  message: "hello from h5"
})

对应链路:

ui.showToast
→ ToastBiz
→ ToastImp
→ promptAction.showToast

2. Clipboard

await window.myascf.send("system.clipboard.writeText", {
  text: "hello clipboard"
})

const res = await window.myascf.send("system.clipboard.readText", {})

对应链路:

system.clipboard.writeText / readText
→ ClipboardBiz
→ ClipboardImp
→ 剪贴板能力

3. Storage

await window.myascf.send("system.storage.setItem", {
  key: "username",
  value: "lichenyang"
})

const res = await window.myascf.send("system.storage.getItem", {
  key: "username"
})

await window.myascf.send("system.storage.removeItem", {
  key: "username"
})

await window.myascf.send("system.storage.clear", {})

对应链路:

system.storage.*
→ StorageBiz
→ StorageImp
→ Preferences 本地存储能力

Storage 这组 API 的意义比较大。

因为 Toast 和 Clipboard 更像一次性调用,而 Storage 是带状态的数据能力。它验证了当前 Biz / Imp 分层不仅能处理简单系统调用,也能处理本地数据读写。


十二、为什么要抽成本地 HAR 包

项目早期,runtime 代码放在 entry 里:

entry/src/main/ets/runtime/

这样可以跑 Demo,但复用性不够。

后来我把 runtime 抽成了本地 HAR 模块:

myascf_runtime/
  src/main/ets/
    bridge/
    dispatcher/
    registry/
    api/
    biz/
    imp/
    error/
    logger/
    container/
    Index.ets

抽取后职责变成:

entry
→ 示例应用,负责 ArkWeb 页面、H5 Demo、DebugPanel

myascf_runtime
→ 本地 HAR 框架包,负责 JSBridge 运行时核心

外部新建一个 HarmonyOS Demo,也可以通过本地依赖方式接入:

{
  "dependencies": {
    "myascf_runtime": "file:../myascf_runtime"
  }
}

然后在页面里导入:

import { MyASCFRuntime } from 'myascf_runtime'

这一步让项目从“当前 Demo 能跑”,变成了“runtime 可以被其他 Demo 复用”。


十三、MyASCFRuntime:对外入口收敛

如果外部 Demo 需要自己组装这些对象:

BridgeController
BridgeDispatcher
HandlerRegistry
RuntimeBootstrap
BridgeCallbackExecutor
JavaScriptProxy
ToastBiz
ClipboardBiz
StorageBiz

那接入成本还是很高。

所以我封装了一个门面类:

MyASCFRuntime

外部 Demo 只需要:

private controller: webview.WebviewController = new webview.WebviewController()

private runtime: MyASCFRuntime = new MyASCFRuntime(
  this.controller,
  getContext(this)
)

然后把它注册给 ArkWeb:

.javaScriptProxy({
  object: this.runtime.getNativeProxy(),
  name: this.runtime.getProxyName(),
  methodList: this.runtime.getMethodList(),
  controller: this.controller
})

这里两个参数也很关键:

controller:用于 Web 页面控制和 runJavaScript 回调 H5
context:用于访问当前宿主应用环境,例如 Storage 底层 Preferences 需要 Context

HAR 包本身是库,不是页面,也不是 Ability,所以它不能自己凭空拿到应用上下文。

正确做法是:

entry / 外部 Demo 提供 context
→ MyASCFRuntime 接收 context
→ StorageImp 使用 context 操作本地存储

十四、Web 容器增强:加载状态、URL Guard 和错误页

除了 JSBridge,Web 容器本身也需要增强。

目前项目已经补充了:

WebContainerConfig
WebUrlGuard
WebLoadState
加载进度
URL Guard / 白名单意识
错误页 / 错误状态
RuntimeLogger 容器日志

目标不是做生产级安全沙箱,而是建立一个 Web 容器框架应该具备的基本意识:

页面加载应该有状态
加载失败不能白屏
URL 跳转应该有边界
错误应该可以被记录

Web 容器增强后,项目不只是 JSBridge 调 API,还开始具备容器层能力。


十五、DebugPanel:让调用链路可视化

H5 Demo 中还做了一个轻量 DebugPanel。

它会展示最近调用记录,例如:

requestId
action
status
code
message
duration
params
response

它可以观察:

正常调用 resolve
参数错误 PARAM_ERROR
未知 action UNKNOWN_ACTION
超时 TIMEOUT
callback 丢失 CALLBACK_LOST

这对写博客、截图、调试和面试讲解都很有帮助。

因为别人不需要直接看控制台,也可以在页面上看到一次 JSBridge 调用经历了什么。


十六、当前项目还没有做什么

这个项目目前仍然是学习和工程实践项目,还没有做:

Network API
Device API
H5 SDK npm 化
API 文档自动生成
更完整的 DebugPanel 搜索 / 筛选 / 统计
生产级 Web 安全沙箱
完整权限系统

我觉得现在不适合继续盲目加功能。

更重要的是把已有链路彻底讲清楚,包括:

  • README;
  • HAR 使用文档;
  • 架构图;
  • 接入示例;
  • 截图;
  • 博客文章;
  • 源码阅读笔记。

一个代表项目,不只是功能多,而是别人能看懂你的设计思路。


十七、这个项目对我的意义

这个项目最大的收获不是写了几个 API,而是把一个跨端通信问题拆成了几层:

H5 SDK 层
ArkWeb 通信层
Bridge 控制层
Dispatcher 分发层
Registry 注册层
Biz 参数校验层
Imp 系统能力层
CallbackExecutor 回调层
HAR 模块化层
Web 容器增强层

这让我更清楚地理解:

  • 为什么不能把所有逻辑写在一个 Controller 里;
  • 为什么 action 分发应该和 API 注册分开;
  • 为什么参数校验不应该和系统 API 调用混在一起;
  • 为什么回调 H5 需要统一出口;
  • 为什么一个 Demo 要抽成 HAR 才更像框架;
  • 为什么文档和架构图是项目的一部分。

总结

MiniAppRuntime-Harmony 当前已经完成了一条比较完整的 HarmonyOS Web 容器与 JSBridge 运行时链路:

H5
→ window.myascf.send
→ JavaScriptProxy
→ BridgeController
→ BridgeDispatcher
→ HandlerRegistry
→ Biz
→ Imp
→ BridgeCallbackExecutor
→ runJavaScript
→ H5 Promise resolve / reject

并且已经通过 Toast、Clipboard、Storage 三组 API 验证了扩展方式,通过本地 HAR 和 MyASCFRuntime 验证了复用方式,通过 DebugPanel 和 Web 容器增强补充了调试和容器能力。

它还不是一个完整平台,但已经具备一个框架项目最核心的几个要素:

清晰的调用链路
明确的职责分层
可扩展的 API 注册方式
统一的错误处理
统一的回调出口
可复用的 HAR 包
可解释的文档和架构图

后续我会继续围绕这个项目整理 README、HAR 使用文档、架构图、截图和系列文章,而不是急着继续堆功能。

项目地址:

https://github.com/lichenyang5/MiniAppRuntime-Harmony

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐