Administrator
发布于 2026-09-02 / 9,895 阅读
0
0

鸿蒙 H5 容器化:从 Web 组件到企业级混合开发框架

系列文章《鸿蒙开发从入门到专家》第 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 控件"升级为"一个开发框架"。

组件

职责

H5Container

统一容器组件,封装 Web 组件 + 生命周期 + 进度条

BridgeManager

插件式 Bridge,按模块注册和分发

OfflinePackageManager

离线包下载、解压、版本管理、资源拦截

RouteDispatcher

原生/Web 路由分发

SecurityGuard

URL 白名单、参数校验

容器化的收益在规模上体现——5 个 H5 页面不值得做容器化,50 个值得。量力而行。

下一篇是第 50 篇,也是"基础补强"阶段的收官。聊聊设计模式在鸿蒙开发中的实际应用。


评论