前置阅读:01~07 篇

前面 7 篇全部是在线 H5场景:页面资源全部从网络拉取。 但很多业务需求:

  1. 弱网环境打开 H5 不白屏
  2. 部分页面完全离线可用(帮助文档、协议页)
  3. 把 H5 打包进 APP 安装包,减少首屏请求、提升加载速度
  4. 本地静态 JS/CSS/ 图片,避免 file 协议带来的跨域、CORS 限制

这就是ArkWeb 离线包。很多新手直接用file://协议加载本地 html,立刻踩坑:H5 内部 ajax 请求、页面跳转、资源引用全部跨域报错,接口无法调用,路由失效。 本篇核心就是:虚拟域名映射方案,把本地 rawfile 资源伪装成 https 域名,彻底规避 file 协议跨域,实现企业级离线包。

一、两种本地资源加载方案对比

方案 1:file:// 直接加载(不推荐)

直接读取 rawfile 或者沙箱文件,url 以file://开头。 缺点:

  • 浏览器安全策略限制,大量跨域报错
  • fetch、ajax 请求被拦截
  • 本地页面无法发起 https 接口请求
  • 很多 JS 库、前端框架不兼容 file 协议 适用场景:极简单静态页面,没有接口请求。

方案 2:虚拟 HTTPS 域名映射(推荐,企业离线包标准方案)

核心思路:

  1. H5 页面写标准 https 地址,例如 https://app-local/index.html
  2. 在 onResourceLoad 拦截这个域名下所有请求
  3. 拦截命中,读取 APP 包内 rawfile 里的 html/js/css/image 资源返回给 Web 内核
  4. 内核以为是正常 https 网页,不存在跨域问题,fetch/axios 全部正常

一句话总结:欺骗 Web 内核,让本地资源看起来是线上 HTTPS 网页。

二、离线包目录结构

把 H5 打包放到项目 rawfile/h5_offline/ 目录:

​
rawfile
└── h5_offline
    ├── index.html
    ├── css
    │   └── main.css
    ├── js
    │   └── app.js
    └── images
        └── logo.png

​

H5 内部资源引用依旧使用相对路径,不用修改前端代码。

三、虚拟域名映射完整代码实现

​
import { Web, WebController, WebLoadStatus, CacheMode } from '@kit.ArkWeb';
import { router } from '@ohos/router';
import { JsBridgeModel, SpaRouteItem } from './JsBridgeModel';
import { resourceManager } from '@kit.BasicServicesKit';

// 虚拟域名前缀,前端页面全部使用这个域名
const VIRTUAL_HOST = "https://app-local";

@Entry
@Component
struct ArkWebOfflinePage {
  private webController: WebController = new WebController();
  private jsBridge: JsBridgeModel = new JsBridgeModel();
  @State spaRouteStack: SpaRouteItem[] = [];
  @State loadStatus: string = "离线包页面待加载";

  aboutToAppear() {
    // 离线包推荐 OnlyLocal:只读取本地资源,不走网络
    this.webController.setCacheMode(CacheMode.OnlyLocal);

    this.jsBridge.onSpaRouteChange = (route) => {
      if (route.isReplace) {
        this.spaRouteStack.pop();
      }
      this.spaRouteStack.push(route);
    };
  }

  onPageShow() {
    this.webController.resume();
  }

  onPageHide() {
    this.webController.pause();
  }

  aboutToDisappear() {
    this.jsBridge.h5Callback = null;
    this.jsBridge.onSpaRouteChange = null;
    this.spaRouteStack = [];
    this.webController.destroy();
  }

  private handleSpaBack() {
    if (this.spaRouteStack.length > 1) {
      this.spaRouteStack.pop();
      this.webController.runJavaScript(`history.back()`);
    } else {
      router.back();
    }
  }

  build() {
    Column() {
      Text(`离线SPA栈深度:${this.spaRouteStack.length} | ${this.loadStatus}`)
        .fontSize(14)
        .margin(10)

      Web({
        src: `${VIRTUAL_HOST}/index.html`,
        controller: this.webController
      })
      .width('100%')
      .height('88%')
      .javaScriptAccess(true)
      .registerJavaScriptProxy(
        this.jsBridge,
        "nativeBridge",
        ["getDeviceInfo", "showNativeToast", "getNativeData", "pushSpaRoute", "syncUserState"]
      )
      // 核心:拦截虚拟域名请求,映射rawfile本地资源
      .onResourceLoad(async (resourceParam) => {
        const url = resourceParam.url;
        // 判断是否命中我们的虚拟域名
        if (url.startsWith(VIRTUAL_HOST)) {
          // 把虚拟url路径转换成rawfile路径
          let rawPath = url.replace(VIRTUAL_HOST, "h5_offline");
          // 处理根路径默认index.html
          if(rawPath.endsWith("/")){
            rawPath += "index.html";
          }
          // 读取rawfile文件
          try {
            const ctx = getContext();
            const fileData = await ctx.resourceManager.getRawFileContent(rawPath);
            resourceParam.responseData = fileData;
            // 返回true,终止网络请求,使用本地资源
            return true;
          } catch (err) {
            console.error("读取离线资源失败", rawPath, err);
            return false;
          }
        }
        // 非虚拟域名的请求放行(比如H5需要请求业务后端接口)
        return false;
      })
      .onLoadStatusChange((status) => {
        if(status === WebLoadStatus.FinishLoading){
          this.loadStatus = "离线页面加载完成";
        }else if(status === WebLoadStatus.ErrorLoading){
          this.loadStatus = "离线资源加载失败";
        }
      })
    }
    .width('100%')
    .height('100%')
    .onBackPressed(() => {
      this.handleSpaBack();
      return true;
    })
  }
}

​

四、MIME 类型配置(极易踩坑)

只返回二进制资源还不够,必须设置正确 MIME,否则浏览器无法识别 css/js/png,渲染异常。

​
// 简易MIME映射表
function getMime(fileName:string):string{
  if(fileName.endsWith(".html")) return "text/html;charset=utf-8";
  if(fileName.endsWith(".js")) return "application/javascript;charset=utf-8";
  if(fileName.endsWith(".css")) return "text/css;charset=utf-8";
  if(fileName.endsWith(".png")) return "image/png";
  if(fileName.endsWith(".jpg") || fileName.endsWith(".jpeg")) return "image/jpeg";
  return "text/plain";
}

​

拿到资源后,给 responseData 配套设置 contentType。

坑点:MIME 写错,JS 不执行、样式不生效,控制台不报明显错误,很难排查。

五、两种离线包方案:内置包 vs 动态增量离线包

5.1 内置 rawfile 离线包(静态打包)

H5 资源打包进 APP 安装包。 ✅优点:打开快,不需要下载,安装完直接可用 ❌缺点:H5 更新,必须发 APP 版本升级,不能热更新

5.2 沙箱动态离线包(增量热更新)

APP 启动时,后台下载 zip 离线包解压到应用沙箱目录 filesDir,加载沙箱内 html。 适合 H5 频繁迭代,不用升级 APP,实现 H5 热更新。 读取沙箱文件不再使用 resourceManager,改用文件模块 fs 读取沙箱路径资源。

​
// 沙箱文件读取示意(动态离线包)
import fs from '@ohos.file.fs';
let file = await fs.open(path);
let stat = await fs.stat(path);
let buffer = new ArrayBuffer(stat.size);
await fs.read(file.fd, buffer);

​

六、离线包 + 在线混合模式(最常用业务方案)

很多项目不是完全离线,而是:

  • 静态页面、图片、JS 缓存本地离线包
  • 业务接口继续请求线上后端 虚拟域名方案天然支持这种混合模式。虚拟域名资源拦截,外部接口请求直接放行,不需要任何额外改造。

七、离线包场景高频踩坑清单

坑 1:前端页面写绝对路径,路径匹配失败

离线 H5 内部资源全部用相对路径,不要写 https 全量地址。

坑 2:rawfile 资源名不能带中文、空格、特殊字符 rawfile 限制文件名,中文资源直接读取失败,打包前前端资源改名。

坑 3:忘记设置 MIME,页面样式丢失,JS 不执行

坑 4:SPA 路由刷新 404 SPA 离线包必须做路由 fallback:当请求的资源不存在,返回 index.html,由前端 vue-router/react-router 接管路由。 在 onResourceLoad 捕获文件不存在异常时,返回 index.html 资源。

坑 5:离线包体积过大 rawfile 打包会增加 APP 包体积,大资源优先放到线上 CDN,离线包只放核心页面。

八、离线包 SPA 路由 fallback 兜底代码

SPA 离线包,刷新子路由地址时,Web 内核会尝试去读取对应路径文件,rawfile 不存在,直接报错。 解决方案:捕获资源读取异常,返回 index.html,交给前端路由处理。

​
.onResourceLoad(async (resourceParam) => {
  const url = resourceParam.url;
  if (url.startsWith(VIRTUAL_HOST)) {
    let rawPath = url.replace(VIRTUAL_HOST, "h5_offline");
    if(rawPath.endsWith("/")) rawPath += "index.html";
    try {
      const ctx = getContext();
      const fileData = await ctx.resourceManager.getRawFileContent(rawPath);
      resourceParam.responseData = fileData;
      return true;
    } catch (err) {
      // 文件不存在,fallback返回首页index.html,SPA路由接管
      const ctx = getContext();
      const indexData = await ctx.resourceManager.getRawFileContent("h5_offline/index.html");
      resourceParam.responseData = indexData;
      return true;
    }
  }
  return false;
})

​

九、本篇小结 

本篇实现鸿蒙 ArkWeb 企业级离线包,使用虚拟域名方案解决本地资源跨域,支持静态内置包、沙箱动态离线包,同时兼容 SPA 前端框架。 你学会的能力:

  1. 区分 file 协议和虚拟 HTTPS 域名两种本地加载方案,理解跨域根源
  2. rawfile 资源拦截映射,构建离线 H5 页面
  3. MIME 类型配置,解决 JS/CSS 渲染失效问题
  4. SPA 离线包路由 fallback,解决子页面刷新 404
  5. 混合离线 + 在线模式,静态资源本地,接口访问线上服务端

实操指南:

  1. 把一个简单 Vue 打包产物放到 rawfile,使用虚拟域名离线加载,验证页面正常渲染
  2. 访问 SPA 子路由,测试 fallback 逻辑,验证刷新不 404
  3. 修改 MIME,故意写错,观察页面失效现象,加深理解

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐