这里的“加载”不是简单写一个 WebView 地址。线上餐厅平板要面对断网、旧包残留、多页面跳转、字体丢失、iOS Bundle 资源路径这些问题。首页能打开,只能说明第一步过了;点进购物车、订单页还能正常工作,才算这个方案完成。
技术方案
整体思路是把 Next.js 静态产物变成一个 App 可管理的离线包,再由 React Native 负责安装、校验、解压和加载入口 HTML。
Next.js 静态构建
-> 加工成离线 zip
-> 生成 manifest
-> RN App 内置首包 zip
-> 启动时复制到可写目录
-> md5 校验
-> 解压到 packages/version
-> 写 current.json
-> WebView 加载 file://.../index.htmlNext.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 可以安装的离线包。
先看顶部几个目录变量:
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");它们对应的含义是:
outDir Next.js 静态导出目录
releaseDir 本地离线产物目录
mockCdnDir 模拟 CDN 上传目录版本号默认用当前时间生成,也可以通过环境变量指定:
const version =
process.env.OFFLINE_PACKAGE_VERSION ??
new Date().toISOString().replace(/[-:TZ.]/g, "").slice(0, 14);这样 CI 可以传入稳定版本号,本地 demo 不传也能自动生成一个包。
1. main():把整条流水线串起来
main() 是主流程:
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();这段代码做了几件事:
确认 out/ 存在
-> 清理旧的 offline-release/
-> 创建本次版本目录
-> 把 out/ 复制进去
-> 扫描所有 HTML
-> 逐个加工 HTML
-> 检查是否还有远程资源
-> 压缩 zip
-> 计算 md5 和 size
-> 收集 routes
-> 生成 manifest
-> mock 上传这里的 findFiles(packageDir, ".html") 是递归扫描,所以它处理的是整个静态站点,不只是首页:
index.html
cart/index.html
orders/index.html
settings/index.htmlmanifest 也是在 main() 里生成的:
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 都会进入这个函数:
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 里通常有:
<link rel="stylesheet" href="./_next/static/chunks/xxx.css">在 HTTP Server 下它没问题。放到 WebView 的 file:// 环境里,多一层目录就可能读不到。脚本用正则找所有 stylesheet:
const linkRegex = /<link\b(?=[^>]*\brel=["']stylesheet["'])([^>]*)>/gi;找到以后先取 href,再把这个相对路径解析成本地文件路径:
const href = getAttribute(tag, "href");
const cssPath = resolveLocalReference(href, htmlPath, packageRoot);如果 CSS 文件存在,就读取内容,并替换成内联 style:
<style data-offline-inlined="./_next/static/chunks/xxx.css">
...
</style>data-offline-inlined 不是给浏览器用的,是给我们调试用的。看到这个属性,就知道 CSS 已经被 postbuild 处理过。
4. inlineCssUrls():把 CSS 里的本地资源转成 data URI
CSS 内部还可能有:
src: url("../fonts/inter-latin-700.woff2") format("woff2");脚本继续扫描 CSS 里的 url(...):
const urlRegex = /url\((["']?)([^"')]+)\1\)/gi;如果是外链、已经是 data:、或者是锚点,就跳过:
if (!ref || isExternalUrl(ref) || ref.startsWith("data:") || ref.startsWith("#")) {
continue;
}如果是本地文件,就读取字节,按后缀推断 MIME,再转成 base64:
url("data:font/woff2;base64,...")这一步主要是为了字体。字体在 WebView 的 file 协议下容易受路径和读取权限影响,内联以后稳定很多。
5. rewritePackageRootAssetReferences():修 _next 资源路径
Next.js 的 JS、CSS、RSC payload 里会出现 _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/`)
.replaceAll('\\"./_next/', `\\"${prefix}_next/`);这里不是只改 <script src>。Next.js App Router 的静态 HTML 里还有 RSC payload,里面也可能包含 _next 字符串,所以要处理普通引号、括号、转义引号这些情况。
prefix 是按当前 HTML 路径算出来的:
const prefix = relativePrefixToPackageRoot(htmlPath, packageRoot);所以不同页面会得到不同结果:
index.html -> ./_next/...
cart/index.html -> ../_next/...
orders/index.html -> ../_next/...这就是多页面离线包的核心。如果写死成 ./_next,首页能打开,cart/index.html 就会去找 cart/_next,页面很容易丢 JS 或样式。
6. rewriteRootRelativeAttributes():修普通 HTML 属性里的根路径
还有一类资源不是 _next payload,而是普通 HTML 属性:
<script src="/_next/static/..."></script>
<a href="/cart/"></a>脚本用这个正则找根路径属性:
/\b(src|href)=["']\/([^"']+)["']/g然后把它改成相对当前 HTML 的路径:
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:
const files = await findFiles(packageRoot, ".html", ".css");CSS 里检查:
url(...) 里是否有 http:// 或 https://HTML 里检查:
src="https://..."
href="https://..."如果发现远程资源,默认直接抛错:
throw new Error(`Remote asset references remain in offline package...`);这里故意没有扫描所有 JS 字符串。因为 Next.js/React 运行时代码里可能带文档地址、namespace 或字符串常量,全部扫会误报。这个检查只盯真正会触发资源加载的位置。
8. zipDirectory()、hashFile():打包和校验信息
zipDirectory() 用系统 zip 命令把版本目录压成 zip:
execFileSync("zip", ["-qry", targetZip, "."], { cwd: sourceDir, stdio: "inherit" });注意它的 cwd 是 sourceDir,所以 zip 解开后不会多包一层绝对路径或版本目录。RN 解压后可以直接在目标目录里找到:
index.html
cart/index.html
_next/hashFile() 用 Node 的 crypto 计算 md5:
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:
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 里的引用转成真实文件路径。它处理了几类情况:
/xxx -> packageRoot/xxx
./_next/xxx -> packageRoot/_next/xxx
_next/xxx -> packageRoot/_next/xxx
../fonts/a.woff2 -> 相对当前文件解析relativePrefixToPackageRoot() 负责算当前 HTML 到包根目录的相对前缀:
packageRoot/index.html -> ./
packageRoot/cart/index.html -> ../
packageRoot/settings/index.html -> ../mimeFromExtension() 负责把后缀转成 data URI 需要的 MIME:
.woff2 -> font/woff2
.woff -> font/woff
.ttf -> font/ttf
.png -> image/png最后的:
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});保证构建脚本失败时能让 CI 失败。离线包这种东西不能“带病发布”,构建期发现问题就应该中断。
页面跳转链接在哪里处理
页面跳转链接不是在 offline-postbuild.mjs 里处理的,而是在 offlineLinks.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`;
}在线模式还是正常路由:
/cart/
/orders/离线模式会变成具体文件:
./cart/index.html
../orders/index.html
../index.html最后脚本会把目录压成 zip,并基于 zip 文件生成 md5、size、routes 和 manifest。也就是说,App 端拿到 manifest 后,可以先校验包是否完整,再决定要不要解压和切换版本。
manifest 里至少要有这些字段:
{
"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 的可写目录,再走同一套安装流程。
推荐目录结构:
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/这样首包和后续热更新可以复用同一套逻辑:
复制或下载 zip
-> 校验 md5
-> 解压到 staging
-> 校验 entry 是否存在
-> 移动到 packages/version
-> 写 current.jsonWebView 最后只关心两个路径:
entryUri: file://.../packages/version/index.html
readAccessUrl: file://.../packages/versioniOS 上要给 WebView 明确的读取范围,也就是 allowingReadAccessToURL。否则 HTML 打开了,里面的 JS、CSS、字体不一定能读到。
首包和热更新怎么取舍
首包一定要有。餐厅平板这类设备不能假设首次启动就有网络。App 发版时内置一个可用 zip,至少能保证点餐主流程打开。
热更新是第二层能力。App 启动后请求远程 manifest,发现新版本再下载 zip。下载成功不代表可以切换,必须先校验 md5,再解压和检查入口文件。任何一步失败,都继续使用当前包。
这套方案的重点不是“能更新”,而是“更新失败不影响点餐”。
遇到的坑和解决
坑 1:首页能打开,但页面跳转不了
一开始离线页面里的链接是这样的:
<a href="./cart/">购物车</a>在 HTTP Server 下,这种链接通常会自动落到 cart/index.html。但 WebView 直接加载本地 file:// 时,不一定会做目录索引。结果就是首页正常,点购物车没反应或者跳转失败。
解决办法:离线模式下所有内部链接都指向具体 HTML 文件。
./cart/ -> ./cart/index.html
../orders/ -> ../orders/index.html
../ -> ../index.html坑 2:多页面的 _next 路径不一样
首页引用资源可以是:
./_next/static/...但 cart/index.html 如果也这么写,就会去找:
cart/_next/static/...实际资源在包根目录:
_next/static/...所以二级页面应该引用:
../_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。
@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 是旧值。
最后发现问题在沙盒:
Documents/offline-menu/downloads/menu-offline.zip这个文件还是旧包。App 复制内置包时没有把旧文件状态处理干净。
解决办法:复制内置 zip 时先写临时文件,临时文件 md5 通过后再替换目标文件。
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 时多校验一层:
current.rootPath 必须属于当前 App 的 DocumentDirectory/offline-menu/packages不属于当前目录,就把它当成无效状态,删除 current.json,重新安装内置包。
坑 7:iOS Bundle 资源没配对,运行时只说文件不存在
iOS 里 zip 要加入 Copy Bundle Resources。否则开发时你可能看到项目目录里有 menu-offline.zip,但 .app 包里没有。
运行时报错通常很短:
The file "menu-offline.zip" couldn't be opened because there is no such file.解决办法:启动安装内置包前,先检查:
RNFS.MainBundlePath/menu-offline.zip不存在就抛出带完整路径的错误。这样一眼就能知道是 Xcode 资源配置问题,而不是解压或 md5 问题。
坑 8:远程 manifest 失败不应该打断首包
demo 里最开始默认请求了一个占位 CDN 地址。结果 App 明明可以离线加载首包,却因为远程 manifest 请求失败冒出 warning。
解决办法:首包加载和远程更新分开。首包是基础能力,远程 manifest 是增强能力。
先确保本地包可用
-> 如果配置了 remoteManifestUrl,再尝试热更新
-> 热更新失败只记录日志,不影响当前包demo 默认把远程 manifest URL 置空,这样可以纯离线验证。生产环境再通过配置注入真实地址。
方案小结
React Native 加载 Next.js 静态网页,真正要解决的是“本地文件环境”和“网页构建产物”之间的不匹配。
Next.js 侧要把静态产物加工成适合 file 协议的包:路径改对、CSS 内联、字体内联、多页面链接指向具体文件。
RN 侧要把包当成一个可管理的版本资产:内置首包、复制到可写目录、md5 校验、staging 解压、current 指针切换、失败回滚。