系列文章《鸿蒙开发从入门到专家》第 49 篇 · 高级
第 41 篇讲了 Web 组件的基础用法——加载页面、JS Bridge、请求拦截。那篇是"怎么用"。这篇是"怎么管"。
当一个应用有几十个 H5 页面,分散在不同业务模块,每个模块各自创建 Web 组件、各自写 JS Bridge、各自处理加载状态,问题就来了:
5 个模块写了 5 套 JS Bridge 注册逻辑,接口不统一。
H5 离线包的版本管理各自为政,有的模块有缓存有的没有。
页面跳转逻辑混乱,H5 页面之间跳转有时走原生路由有时走 Web 路由。
安全校验不统一,有的模块做了 URL 白名单有的没做。
H5 容器化就是解决这些问题:把 Web 组件、JS Bridge、离线包管理、路由分发统一封装成一个可复用的容器框架。
一、容器化架构
┌──────────────────────────────────┐
│ Business Modules │ ← 业务层:只需声明 URL 和配置
│ (活动页、协议页、营销弹窗...) │
├──────────────────────────────────┤
│ H5 Container Framework │ ← 容器层:本篇核心
│ ├── Container Component │ Web 组件封装 + 生命周期管理
│ ├── Bridge Manager │ JS Bridge 统一注册和分发
│ ├── Offline Package Manager │ H5 离线包下载、解压、版本管理
│ ├── Route Dispatcher │ URL 路由分发
│ └── Security Guard │ URL 白名单、参数校验
├──────────────────────────────────┤
│ ArkWeb Kit APIs │ ← 系统层:官方 Web 组件
└──────────────────────────────────┘
二、容器组件设计
2.1 统一容器组件
容器层提供一个统一的 H5Container 组件,业务层只需要传入 URL:
// framework/h5container/H5Container.ets
import { webview } from '@kit.ArkWeb';
@Component
export struct H5Container {
@Prop url: string;
@Prop showProgressBar: boolean = true;
@State progress: number = 0;
@State isLoading: boolean = true;
private controller: webview.WebviewController = new webview.WebviewController();
private bridgeManager: BridgeManager = new BridgeManager();
private securityGuard: SecurityGuard = new SecurityGuard();
build() {
Stack() {
Web({ src: this.url, controller: this.controller })
.domStorageAccess(true)
.onControllerAttached(() => {
this.bridgeManager.register(this.controller);
})
.onLoadIntercept((event) => {
return this.securityGuard.shouldIntercept(event.data.getUrl());
})
.onInterceptRequest((event) => {
return this.bridgeManager.handleIntercept(event, this.controller);
})
.onPageBegin(() => {
this.isLoading = true;
})
.onProgressChange((event) => {
if (event.newProgress > 0) {
this.progress = event.newProgress;
}
})
.onPageEnd(() => {
this.isLoading = false;
this.progress = 100;
})
if (this.showProgressBar && this.isLoading) {
Progress({ value: this.progress, total: 100 })
.color('#007DFF')
.position({ x: 0, y: 0 })
.width('100%')
.height(2)
}
}
}
}
业务层使用极其简单:
// 业务页面
@Entry
@Component
struct ActivityPage {
@State pageUrl: string = 'https://m.example.com/activity/summer-sale';
build() {
Column() {
H5Container({ url: this.pageUrl, showProgressBar: true })
}
}
}
2.2 容器配置
不是所有 H5 页面都需要完全相同的能力。容器支持配置化:
// framework/h5container/ContainerConfig.ts
export interface ContainerConfig {
url: string;
showProgressBar: boolean;
enableOffline: boolean; // 是否启用离线包
enableBridge: boolean; // 是否启用 JS Bridge
interceptResources: boolean; // 是否拦截资源请求
allowedDomains: string[]; // 允许的域名
customBridges?: string[]; // 自定义 Bridge 模块名
}
export const DEFAULT_CONFIG: ContainerConfig = {
url: '',
showProgressBar: true,
enableOffline: false,
enableBridge: true,
interceptResources: false,
allowedDomains: ['example.com', 'cdn.example.com'],
};
三、Bridge Manager:统一 JS Bridge
3.1 问题
第 41 篇里 JS Bridge 是直接在组件里用 javaScriptProxy 注册的。当有几十个 Bridge 方法时,这种方式有几个问题:
所有方法都注册在同一个对象上,命名冲突。
不同业务模块的 Bridge 方法混在一起,维护困难。
注册和注销时机不好控制。
3.2 插件式 Bridge
把 Bridge 方法按模块拆分,每个模块注册为一个"插件",由 BridgeManager 统一管理。
// framework/h5container/BridgePlugin.ts
export interface BridgePlugin {
name: string; // 插件名,H5 侧用 bridge.pluginName.method() 调用
methods: Record<string, Function>;
asyncMethods?: string[];
}
// 设备信息插件
export class DevicePlugin implements BridgePlugin {
name = 'device';
methods = {
getModel: () => {
return deviceInfo.productModel;
},
getVersion: () => {
return deviceInfo.osReleaseVersion;
},
getNetworkType: () => {
return connection.getDefaultNetSync();
}
};
}
// 导航插件
export class NavigationPlugin implements BridgePlugin {
name = 'navigation';
methods = {
back: () => {
router.back();
},
openPage: (url: string, params?: Record<string, any>) => {
router.pushUrl({ url, params });
}
};
asyncMethods = ['openWebPage'];
async asyncOpenWebPage(url: string) {
// 异步打开新的 H5 页面
}
}
3.3 BridgeManager 注册和分发
// framework/h5container/BridgeManager.ts
export class BridgeManager {
private plugins: Map<string, BridgePlugin> = new Map();
private controller?: webview.WebviewController;
registerPlugin(plugin: BridgePlugin): void {
this.plugins.set(plugin.name, plugin);
}
// 注册到 Web 组件
register(controller: webview.WebviewController): void {
this.controller = controller;
// 为每个插件创建一个代理对象
this.plugins.forEach((plugin, name) => {
const proxy = {
object: this.createProxy(plugin),
name: `bridge_${name}`,
methodList: Object.keys(plugin.methods),
asyncMethodList: plugin.asyncMethods || [],
controller: controller,
};
// 注意:javaScriptProxy 是组件属性,需要在组件 build 中调用
// 这里改为通过 registerJavaScriptProxy 动态注册
controller.registerJavaScriptProxy(
this.createProxy(plugin),
`bridge_${name}`,
Object.keys(plugin.methods),
plugin.asyncMethods || []
);
});
}
private createProxy(plugin: BridgePlugin): any {
const proxy: any = {};
Object.keys(plugin.methods).forEach(methodName => {
proxy[methodName] = plugin.methods[methodName];
});
return proxy;
}
unregister(): void {
this.plugins.forEach((_, name) => {
this.controller?.deleteJavaScriptRegister(`bridge_${name}`);
});
}
}
H5 侧调用:
// H5 代码
bridge_device.getModel(); // 获取设备型号
bridge_device.getNetworkType(); // 获取网络类型
bridge_navigation.back(); // 返回上一页
bridge_navigation.openPage('pages/Detail', { id: 123 }); // 打开原生页面
这种设计的好处:业务模块只需要实现自己的 BridgePlugin,不需要关心注册细节。新增 Bridge 方法不影响其他模块。
四、离线包管理
4.1 离线包结构
离线包是一个 zip 压缩包,包含 H5 资源和版本信息:
offline-package-v1.2.0.zip
├── manifest.json // 包信息:版本、入口文件、资源列表
├── index.html
├── assets/
│ ├── app.js
│ ├── app.css
│ └── images/
└── pages/
├── detail.html
└── list.html
4.2 下载和解压
// framework/h5container/OfflinePackageManager.ts
export class OfflinePackageManager {
private packageDir: string;
private versions: Map<string, string> = new Map(); // businessId -> version
constructor(context: Context) {
this.packageDir = `${context.filesDir}/offline_packages`;
}
async download(url: string, businessId: string, version: string): Promise<void> {
const currentVersion = this.versions.get(businessId);
if (currentVersion === version) {
return; // 已是最新版本,跳过下载
}
// 下载 zip
const zipPath = `${this.packageDir}/${businessId}_${version}.zip`;
await this.downloadFile(url, zipPath);
// 解压
const targetDir = `${this.packageDir}/${businessId}/${version}`;
await this.unzip(zipPath, targetDir);
// 更新版本映射
this.versions.set(businessId, version);
// 清理旧版本
await this.cleanOldVersions(businessId, version);
// 删除 zip
await fs.unlink(zipPath);
}
getLocalPath(businessId: string, entryFile: string = 'index.html'): string {
const version = this.versions.get(businessId);
if (!version) return '';
return `${this.packageDir}/${businessId}/${version}/${entryFile}`;
}
}
4.3 资源拦截加载
离线包的核心是在 onInterceptRequest 中把远程请求替换为本地文件:
// 容器组件中的拦截逻辑
.onInterceptRequest((event) => {
const url = event.request.getRequestUrl();
// 如果有离线包,把远程 URL 映射到本地路径
if (this.offlineManager) {
const localPath = this.offlineManager.resolveUrl(url);
if (localPath) {
return this.createLocalResponse(localPath);
}
}
return null; // 正常远程请求
})
五、路由分发
H5 页面的跳转需要区分两类:原生页面跳转和 H5 页面跳转。
// framework/h5container/RouteDispatcher.ts
export class RouteDispatcher {
private nativeRoutes: Set<string>; // 原生路由路径集合
constructor() {
this.nativeRoutes = new Set([
'pages/home', 'pages/profile', 'pages/settings'
]);
}
// 判断 URL 应该走原生路由还是 Web 路由
shouldOpenNative(url: string): boolean {
// 协议头判断
if (url.startsWith('native://')) {
return true;
}
// 路径匹配
const path = this.extractPath(url);
return this.nativeRoutes.has(path);
}
dispatch(url: string, webController: webview.WebviewController): void {
if (this.shouldOpenNative(url)) {
const path = this.extractPath(url);
const params = this.extractParams(url);
router.pushUrl({ url: path, params });
} else {
webController.loadUrl(url);
}
}
}
这样 H5 中的链接根据配置自动决定走原生还是 Web,不需要 H5 端做特殊处理。
六、安全设计
// framework/h5container/SecurityGuard.ts
export class SecurityGuard {
private whitelist: string[];
constructor(whitelist: string[] = []) {
this.whitelist = whitelist;
}
shouldIntercept(url: string): boolean {
// 空白名单表示不拦截
if (this.whitelist.length === 0) return false;
const isAllowed = this.whitelist.some(domain => {
return url.includes(domain);
});
if (!isAllowed) {
console.warn(`Blocked URL not in whitelist: ${url}`);
return true; // 拦截
}
return false;
}
validateParams(params: any, schema: Record<string, string>): boolean {
for (const [key, type] of Object.entries(schema)) {
if (!(key in params)) continue;
if (typeof params[key] !== type) {
console.warn(`Param ${key} type mismatch: expected ${type}`);
return false;
}
}
return true;
}
}
小结
H5 容器化的本质是把 Web 组件从"一个 UI 控件"升级为"一个开发框架"。
容器化的收益在规模上体现——5 个 H5 页面不值得做容器化,50 个值得。量力而行。
下一篇是第 50 篇,也是"基础补强"阶段的收官。聊聊设计模式在鸿蒙开发中的实际应用。