从 Web 容器到本地 HAR:我做了一个 HarmonyOS JSBridge 运行时框架 Demo
从 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 的职责是把 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 内部如何创建 BridgeController、BridgeDispatcher、HandlerRegistry,只需要把 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 使用文档、架构图、截图和系列文章,而不是急着继续堆功能。
项目地址:
更多推荐



所有评论(0)