AI 摘要
这篇文章详细解析了一种将 Next.js 静态网页与 React Native 应用整合为离线包的方案,解决了传统 WebView 使用中的诸多技术痛点,包括路径不一致、离线模式下的资源加载失败、字体丢失等问题。文章从 Next.js 静态产物优化入手,通过构建后脚本实现路径调整、CSS 和字体内联、多页面支持等功能,并生成适配移动端的离线包。同时,在 RN 端设计了一套版本管理机制,实现了离线包的可靠加载和热更新,包括首包内置、md5 校验、解压存储和更新回滚等步骤。文章不仅系统性梳理了整个流程,还针对真实项目中的常见问题提供了实践经验,如多页面资源路径适配、远程资源校验失败的容错处理等,为研发团队在跨端开发和性能优化上提供了明确的指导与参考。

这里的“加载”不是简单写一个 WebView 地址。线上餐厅平板要面对断网、旧包残留、多页面跳转、字体丢失、iOS Bundle 资源路径这些问题。首页能打开,只能说明第一步过了;点进购物车、订单页还能正常工作,才算这个方案完成。

技术方案

整体思路是把 Next.js 静态产物变成一个 App 可管理的离线包,再由 React Native 负责安装、校验、解压和加载入口 HTML。

text
Next.js 静态构建
-> 加工成离线 zip
-> 生成 manifest
-> RN App 内置首包 zip
-> 启动时复制到可写目录
-> md5 校验
-> 解压到 packages/version
-> 写 current.json
-> WebView 加载 file://.../index.html

Next.js 侧:产出适合 WebView 的静态包

Next.js 负责业务页面。构建时要产出静态 HTML,而不是依赖 Node Server 的页面。

next build 出来的静态目录不能直接塞进 App。需要在构建后做一次产物加工:

  • 扫描所有 HTML 页面,不只处理首页。
  • 把 CSS 尽量内联到 HTML,减少 file 协议下的路径问题。
  • 把关键字体转成 data:font/woff2;base64,...
  • /_next/... 改成相对路径。
  • 多页面按目录深度处理资源路径,比如 cart/index.html 要找 ../_next/...
  • 页面跳转链接显式指向 index.html,比如 ./cart/index.html
  • 生成 zip、md5、size、version 和 manifest。

这些处理不全在同一个地方。资源加工、压缩和 manifest 生成放在 scripts/offline-postbuild.mjs;页面跳转链接放在业务侧的 app/shared/offlineLinks.ts。原因很简单:_next、CSS、字体属于构建产物问题,适合构建后修;导航链接是业务路由的一部分,源码里直接按离线模式生成更清楚。

offline-postbuild.mjs 完整拆解

这个脚本跑在 NEXT_OFFLINE=1 next build 之后。Next.js 已经把静态页面导出到 out/,脚本接手后把它加工成 RN 可以安装的离线包。

先看顶部几个目录变量:

js
const rootDir = path.resolve(__dirname, "..");
const outDir = path.join(rootDir, "out");
const releaseDir = path.join(rootDir, "offline-release");
const mockCdnDir = path.join(rootDir, "mock-cdn", "restaurant-menu");

它们对应的含义是:

text
outDir       Next.js 静态导出目录
releaseDir   本地离线产物目录
mockCdnDir   模拟 CDN 上传目录

版本号默认用当前时间生成,也可以通过环境变量指定:

js
const version =
  process.env.OFFLINE_PACKAGE_VERSION ??
  new Date().toISOString().replace(/[-:TZ.]/g, "").slice(0, 14);

这样 CI 可以传入稳定版本号,本地 demo 不传也能自动生成一个包。

1. main():把整条流水线串起来

main() 是主流程:

js
await assertDirectory(outDir, "Next.js static export folder is missing...");
await fs.rm(releaseDir, { recursive: true, force: true });
await fs.mkdir(packageDir, { recursive: true });
await copyDir(outDir, packageDir);

const htmlFiles = await findFiles(packageDir, ".html");
for (const htmlFile of htmlFiles) {
  await processHtml(htmlFile, packageDir);
}

await assertNoRemoteReferences(packageDir);
await zipDirectory(packageDir, zipPath);

const md5 = await hashFile(zipPath, "md5");
const size = (await fs.stat(zipPath)).size;
const routes = (await findFiles(packageDir, ".html"))
  .map((file) => path.relative(packageDir, file).replaceAll(path.sep, "/"))
  .sort();

这段代码做了几件事:

text
确认 out/ 存在
-> 清理旧的 offline-release/
-> 创建本次版本目录
-> 把 out/ 复制进去
-> 扫描所有 HTML
-> 逐个加工 HTML
-> 检查是否还有远程资源
-> 压缩 zip
-> 计算 md5 和 size
-> 收集 routes
-> 生成 manifest
-> mock 上传

这里的 findFiles(packageDir, ".html") 是递归扫描,所以它处理的是整个静态站点,不只是首页:

text
index.html
cart/index.html
orders/index.html
settings/index.html

manifest 也是在 main() 里生成的:

js
const manifest = {
  appId: "restaurant-ipad-menu",
  version,
  entry: "index.html",
  routes,
  md5,
  size,
  fileName: packageName,
  url: `${cdnBaseUrl}/${version}/${packageName}`,
  createdAt: new Date().toISOString()
};

appId 用来防止 App 下载错业务包;version 用来判断是否需要更新;md5 用来校验 zip 是否完整;entry 告诉 RN WebView 加载哪个 HTML。

2. processHtml():每个 HTML 的加工入口

每个 HTML 都会进入这个函数:

js
html = await inlineStylesheets(html, htmlPath, packageRoot);
html = rewritePackageRootAssetReferences(html, htmlPath, packageRoot);
html = rewriteRootRelativeAttributes(html, htmlPath, packageRoot);

顺序不能随便换。

先内联 CSS,因为 CSS 里可能还有字体、图片这类 url(...)。再修 _next 路径,因为 HTML 和 RSC payload 里都可能出现 _next。最后修根路径属性,比如 src="/xxx"href="/xxx"

3. inlineStylesheets():把 CSS link 变成 style

Next.js 导出的 HTML 里通常有:

html
<link rel="stylesheet" href="./_next/static/chunks/xxx.css">

在 HTTP Server 下它没问题。放到 WebView 的 file:// 环境里,多一层目录就可能读不到。脚本用正则找所有 stylesheet:

js
const linkRegex = /<link\b(?=[^>]*\brel=["']stylesheet["'])([^>]*)>/gi;

找到以后先取 href,再把这个相对路径解析成本地文件路径:

js
const href = getAttribute(tag, "href");
const cssPath = resolveLocalReference(href, htmlPath, packageRoot);

如果 CSS 文件存在,就读取内容,并替换成内联 style:

html
<style data-offline-inlined="./_next/static/chunks/xxx.css">
  ...
</style>

data-offline-inlined 不是给浏览器用的,是给我们调试用的。看到这个属性,就知道 CSS 已经被 postbuild 处理过。

4. inlineCssUrls():把 CSS 里的本地资源转成 data URI

CSS 内部还可能有:

css
src: url("../fonts/inter-latin-700.woff2") format("woff2");

脚本继续扫描 CSS 里的 url(...)

js
const urlRegex = /url\((["']?)([^"')]+)\1\)/gi;

如果是外链、已经是 data:、或者是锚点,就跳过:

js
if (!ref || isExternalUrl(ref) || ref.startsWith("data:") || ref.startsWith("#")) {
  continue;
}

如果是本地文件,就读取字节,按后缀推断 MIME,再转成 base64:

css
url("data:font/woff2;base64,...")

这一步主要是为了字体。字体在 WebView 的 file 协议下容易受路径和读取权限影响,内联以后稳定很多。

5. rewritePackageRootAssetReferences():修 _next 资源路径

Next.js 的 JS、CSS、RSC payload 里会出现 _next 资源引用。脚本处理了几种常见形态:

js
.replaceAll('"/_next/', `"${prefix}_next/`)
.replaceAll("'/_next/", `'${prefix}_next/`)
.replaceAll("(/_next/", `(${prefix}_next/`)
.replaceAll('"./_next/', `"${prefix}_next/`)
.replaceAll("'./_next/", `'${prefix}_next/`)
.replaceAll("(./_next/", `(${prefix}_next/`)
.replaceAll('\\"./_next/', `\\"${prefix}_next/`);

这里不是只改 <script src>。Next.js App Router 的静态 HTML 里还有 RSC payload,里面也可能包含 _next 字符串,所以要处理普通引号、括号、转义引号这些情况。

prefix 是按当前 HTML 路径算出来的:

js
const prefix = relativePrefixToPackageRoot(htmlPath, packageRoot);

所以不同页面会得到不同结果:

text
index.html        -> ./_next/...
cart/index.html   -> ../_next/...
orders/index.html -> ../_next/...

这就是多页面离线包的核心。如果写死成 ./_next,首页能打开,cart/index.html 就会去找 cart/_next,页面很容易丢 JS 或样式。

6. rewriteRootRelativeAttributes():修普通 HTML 属性里的根路径

还有一类资源不是 _next payload,而是普通 HTML 属性:

html
<script src="/_next/static/..."></script>
<a href="/cart/"></a>

脚本用这个正则找根路径属性:

js
/\b(src|href)=["']\/([^"']+)["']/g

然后把它改成相对当前 HTML 的路径:

js
const targetPath = path.join(packageRoot, ref);
let relative = path.relative(path.dirname(htmlPath), targetPath).replaceAll(path.sep, "/");

这样 WebView 加载本地文件时,不会再去找一个不存在的文件系统根路径。

不过有一点要分清:这个函数只处理已经出现在 HTML 里的根路径属性。业务导航链接最终还是由 offlineLinks.ts 在源码侧生成,这样更可控。

7. assertNoRemoteReferences():构建期直接拦截远程资源

离线包最怕混进一个远程资源。平时有网看不出来,门店断网就挂。

这个函数会扫描 HTML 和 CSS:

js
const files = await findFiles(packageRoot, ".html", ".css");

CSS 里检查:

js
url(...) 里是否有 http:// 或 https://

HTML 里检查:

js
src="https://..."
href="https://..."

如果发现远程资源,默认直接抛错:

js
throw new Error(`Remote asset references remain in offline package...`);

这里故意没有扫描所有 JS 字符串。因为 Next.js/React 运行时代码里可能带文档地址、namespace 或字符串常量,全部扫会误报。这个检查只盯真正会触发资源加载的位置。

8. zipDirectory()hashFile():打包和校验信息

zipDirectory() 用系统 zip 命令把版本目录压成 zip:

js
execFileSync("zip", ["-qry", targetZip, "."], { cwd: sourceDir, stdio: "inherit" });

注意它的 cwdsourceDir,所以 zip 解开后不会多包一层绝对路径或版本目录。RN 解压后可以直接在目标目录里找到:

text
index.html
cart/index.html
_next/

hashFile() 用 Node 的 crypto 计算 md5:

js
const hash = createHash(algorithm);
hash.update(await fs.readFile(filePath));
return hash.digest("hex");

这个 md5 会写进 manifest。RN 安装包时也算一次 md5,只有一致才会解压和切换。

9. mockUpload():模拟 CDN 发布

demo 里没有真的上传 S3 或 OSS,而是把 zip 和 manifest 复制到 mock-cdn

text
mock-cdn/restaurant-menu/
  manifest.json
  20260604083902/
    restaurant-menu-20260604083902.zip
    manifest.json

真实项目里,这个函数可以替换成上传 CDN 的逻辑。顺序建议保持一致:先上传 zip,再发布 manifest。否则 App 可能先读到新 manifest,但 zip 还没准备好。

10. 辅助函数:路径解析和文件扫描

copyDir() 递归复制 out/findFiles() 递归找指定后缀文件。exists() 用来判断本地资源是否存在。

resolveLocalReference() 负责把 HTML/CSS 里的引用转成真实文件路径。它处理了几类情况:

text
/xxx              -> packageRoot/xxx
./_next/xxx       -> packageRoot/_next/xxx
_next/xxx         -> packageRoot/_next/xxx
../fonts/a.woff2  -> 相对当前文件解析

relativePrefixToPackageRoot() 负责算当前 HTML 到包根目录的相对前缀:

text
packageRoot/index.html          -> ./
packageRoot/cart/index.html     -> ../
packageRoot/settings/index.html -> ../

mimeFromExtension() 负责把后缀转成 data URI 需要的 MIME:

text
.woff2 -> font/woff2
.woff  -> font/woff
.ttf   -> font/ttf
.png   -> image/png

最后的:

js
main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

保证构建脚本失败时能让 CI 失败。离线包这种东西不能“带病发布”,构建期发现问题就应该中断。

页面跳转链接在哪里处理

页面跳转链接不是在 offline-postbuild.mjs 里处理的,而是在 offlineLinks.ts 里生成:

ts
export function hrefFor(target: RouteKey, depth = 0) {
  if (!isOffline) {
    return routes[target];
  }

  const prefix = depth === 0 ? "./" : "../".repeat(depth);
  if (target === "menu") {
    return `${prefix}index.html`;
  }
  return `${prefix}${target}/index.html`;
}

在线模式还是正常路由:

text
/cart/
/orders/

离线模式会变成具体文件:

text
./cart/index.html
../orders/index.html
../index.html

最后脚本会把目录压成 zip,并基于 zip 文件生成 md5、size、routes 和 manifest。也就是说,App 端拿到 manifest 后,可以先校验包是否完整,再决定要不要解压和切换版本。

manifest 里至少要有这些字段:

json
{
  "appId": "restaurant-ipad-menu",
  "version": "20260604083902",
  "entry": "index.html",
  "routes": [
    "index.html",
    "cart/index.html",
    "orders/index.html",
    "settings/index.html"
  ],
  "md5": "5bbfb2c86c81d92ed11f7c27d86a9dd4",
  "size": 646769,
  "fileName": "restaurant-menu-20260604083902.zip"
}

React Native 侧:不要直接读 Bundle 里的 HTML

RN App 可以内置 zip,但不要让 WebView 直接读 Bundle 里的散文件。更稳的做法是把内置 zip 复制到 App 的可写目录,再走同一套安装流程。

推荐目录结构:

text
DocumentDirectory/offline-menu/
  current.json
  downloads/
    menu-offline.zip
  staging/
    20260604083902/
  packages/
    20260604083902/
      index.html
      cart/index.html
      orders/index.html
      settings/index.html
      _next/

这样首包和后续热更新可以复用同一套逻辑:

text
复制或下载 zip
-> 校验 md5
-> 解压到 staging
-> 校验 entry 是否存在
-> 移动到 packages/version
-> 写 current.json

WebView 最后只关心两个路径:

text
entryUri: file://.../packages/version/index.html
readAccessUrl: file://.../packages/version

iOS 上要给 WebView 明确的读取范围,也就是 allowingReadAccessToURL。否则 HTML 打开了,里面的 JS、CSS、字体不一定能读到。

首包和热更新怎么取舍

首包一定要有。餐厅平板这类设备不能假设首次启动就有网络。App 发版时内置一个可用 zip,至少能保证点餐主流程打开。

热更新是第二层能力。App 启动后请求远程 manifest,发现新版本再下载 zip。下载成功不代表可以切换,必须先校验 md5,再解压和检查入口文件。任何一步失败,都继续使用当前包。

这套方案的重点不是“能更新”,而是“更新失败不影响点餐”。

遇到的坑和解决

坑 1:首页能打开,但页面跳转不了

一开始离线页面里的链接是这样的:

html
<a href="./cart/">购物车</a>

在 HTTP Server 下,这种链接通常会自动落到 cart/index.html。但 WebView 直接加载本地 file:// 时,不一定会做目录索引。结果就是首页正常,点购物车没反应或者跳转失败。

解决办法:离线模式下所有内部链接都指向具体 HTML 文件。

text
./cart/          -> ./cart/index.html
../orders/      -> ../orders/index.html
../             -> ../index.html

坑 2:多页面的 _next 路径不一样

首页引用资源可以是:

text
./_next/static/...

cart/index.html 如果也这么写,就会去找:

text
cart/_next/static/...

实际资源在包根目录:

text
_next/static/...

所以二级页面应该引用:

text
../_next/static/...

解决办法:构建后处理 HTML 时,按页面所在目录计算到包根目录的相对路径。首页用 ./_next,二级页用 ../_next。不要写死。

坑 3:只改 HTML 标签不够,RSC payload 里也有路径

Next.js App Router 的静态页面里,不只有普通的 <script src="..."><link href="...">。页面里还会有 React Server Components 相关的 payload 字符串,里面也可能出现资源路径。

如果只改 HTML 标签,首屏可能正常,但客户端运行时或页面跳转时仍然会用到旧路径。

解决办法:离线加工脚本要同时处理 HTML 里的标签路径和 payload 字符串中的资源路径。处理时也要小心,不能把所有字符串都乱改,只改本地资源相关的 _next 引用。

坑 4:字体在 HTTP 下正常,file 协议下丢了

字体是最容易被忽视的资源。HTTP 环境下字体加载正常,放到 WebView 的 file 协议下可能因为路径、权限或 MIME 推断失败。

解决办法:关键字体直接内联成 base64。

css
@font-face {
  font-family: "Inter";
  src: url("data:font/woff2;base64,...") format("woff2");
}

这样页面打开时就能拿到字体,不需要 WebView 再发一次本地文件请求。代价是 HTML/CSS 体积变大一点,但餐厅离线包通常更看重稳定。

坑 5:md5 对不上,但源 zip 明明是新的

我们遇到过一次 md5 mismatch。manifest 里是新 md5,RN iOS Bundle 里的 zip 也是新 md5,但 App 运行时报出来的实际 md5 是旧值。

最后发现问题在沙盒:

text
Documents/offline-menu/downloads/menu-offline.zip

这个文件还是旧包。App 复制内置包时没有把旧文件状态处理干净。

解决办法:复制内置 zip 时先写临时文件,临时文件 md5 通过后再替换目标文件。

text
copy bundle zip -> temp
-> hash temp
-> remove old destination
-> move temp to destination

不要直接覆盖当前包,也不要在校验前切换 current 指针。

坑 6:current.json 指向旧容器

iOS 模拟器或重装场景下,App 的 Documents 容器路径可能变了。旧的 current.json 可能还在,里面的 rootPath 指向上一次安装的容器。

如果只判断入口文件是否存在,就可能误把旧路径当成当前可用包。

解决办法:读取 current.json 时多校验一层:

text
current.rootPath 必须属于当前 App 的 DocumentDirectory/offline-menu/packages

不属于当前目录,就把它当成无效状态,删除 current.json,重新安装内置包。

坑 7:iOS Bundle 资源没配对,运行时只说文件不存在

iOS 里 zip 要加入 Copy Bundle Resources。否则开发时你可能看到项目目录里有 menu-offline.zip,但 .app 包里没有。

运行时报错通常很短:

text
The file "menu-offline.zip" couldn't be opened because there is no such file.

解决办法:启动安装内置包前,先检查:

text
RNFS.MainBundlePath/menu-offline.zip

不存在就抛出带完整路径的错误。这样一眼就能知道是 Xcode 资源配置问题,而不是解压或 md5 问题。

坑 8:远程 manifest 失败不应该打断首包

demo 里最开始默认请求了一个占位 CDN 地址。结果 App 明明可以离线加载首包,却因为远程 manifest 请求失败冒出 warning。

解决办法:首包加载和远程更新分开。首包是基础能力,远程 manifest 是增强能力。

text
先确保本地包可用
-> 如果配置了 remoteManifestUrl,再尝试热更新
-> 热更新失败只记录日志,不影响当前包

demo 默认把远程 manifest URL 置空,这样可以纯离线验证。生产环境再通过配置注入真实地址。

方案小结

React Native 加载 Next.js 静态网页,真正要解决的是“本地文件环境”和“网页构建产物”之间的不匹配。

Next.js 侧要把静态产物加工成适合 file 协议的包:路径改对、CSS 内联、字体内联、多页面链接指向具体文件。

RN 侧要把包当成一个可管理的版本资产:内置首包、复制到可写目录、md5 校验、staging 解压、current 指针切换、失败回滚。