14
0
0

点下「导出源码」之后:一次 H5 应用自托管的排障实录

2026-09-28
2026-09-28
点下「导出源码」之后:一次 H5 应用自托管的排障实录
文章摘要

你花几个月在低代码平台上做了一个 H5 应用,点「导出源码」拿到一个压缩包,以为终于自由了。
解压、npm i、打开浏览器——白屏。


写在前面:导出 ≠ 独立运行

低代码平台(秒哒之类)导出的源码有一个共同特点:它是为平台的运行时写的,不是为你的浏览器写的。

导出包里通常有这三样依赖,缺一样都不算部署完成:

依赖层 导出时 平台上的承担者 本地谁来接
构建 ✅ 有源码 平台的云端构建机 你自己跑 Vite
数据 ✅ 有 dump 平台内置 Supabase 自建 / 云端 Supabase
AI 能力 ⚠️ 有调用代码 平台的网关和额度 你自己的 API Key
静态托管 ❌ 没有 平台的 CDN 你自己起一个静态服务器

前三样容易被忽略,第四样没有人替你做。所以本地部署说白了就是:

把这四层依赖一个个换成自己能控制的东西,并让产物在目标环境(浏览器 / APK)里真的能跑。

所以准确地说,这不是搬家,是搬完之后自己接水管和电线——家具是平台给的,水电得你自己拉。

下面全程用一个「React + Vite + Supabase」的应用举例,这类平台的导出包基本都长这个样。


总览:一张图看清要搬哪几层

┌─────────────────────────────────────────────────────────┐
│                        终端                              │
│   手机浏览器  /  HBuilderX 打包的 APK(file://)          │
└───────────────┬─────────────────────────────────────────┘
                │
    ┌───────────┴───────────┐
    │                       │
┌───▼─────────┐     ┌───────▼──────────┐
│  静态站点    │     │   前端产物        │
│  :8080       │◄────│   dist/          │
│  SPA 兜底    │     │   dist-app/      │
└─────────────┘     └───────┬──────────┘
                            │ HTTPS
        ┌───────────────────┼───────────────────┐
        │                   │                   │
┌───────▼──────┐  ┌─────────▼────────┐  ┌───────▼───────┐
│  Postgres    │  │  Edge Functions   │  │  第三方 AI API │
│  表结构+数据  │  │  转发 / 鉴权 / 流 │  │  对话 / 生图   │
└──────────────┘  └──────────────────┘  └───────────────┘
        └───────────────────┴───────────────────┘
                    自建 Supabase 项目

(:8080 是第五步里那个静态服务器的端口。)

开工前先记住两件事:

  1. 先解耦,再迁移。 别一上来就搬家,先把源码里所有指向平台的硬引用找出来,列成清单。
  2. 每改一层就验一次。 攒到最后一起验,出问题时你分不清是哪层的锅。

第一步:让代码能构建

1.1 先摸清导出包的家底

解压后先别急着装依赖,用这几条命令看看拿到了什么:

# 顶层结构
ls -la

# 依赖清单与构建脚本
cat package.json

# 后端形态:有没有边缘函数(supabase/functions)?表结构在哪?
find . -maxdepth 4 -type d -name functions
ls *.dump *.sql 2>/dev/null

重点看 package.json 的 scripts。导出包里的构建入口常常被改写,比如:

{
  "scripts": {
    "dev": "echo 'Do not use this command, only use lint to check'",
    "build": "echo 'Do not use this command, only use lint to check'"
  }
}

这不是你的包坏了,是这个包本来就没打算让你在本地直接 build。 绕过它只要一步:不走 npm script,直接调用二进制:

# 开发预览
node node_modules/vite/bin/vite.js

# 生产构建
node node_modules/vite/bin/vite.js build

# 类型检查(很多导出包只留了这个)
node node_modules/typescript/bin/tsc --noEmit -p tsconfig.json

省事技巧:如果导出包里已经有 node_modules(或你能找到同版本的另一个副本),可以用
目录联接复用,省掉几百个包的下载。Windows 用 mklink /J,macOS/Linux 用 ln -s。

1.2 建立「不许丢」的基线

在动任何代码之前,先把能跑的状态存下来:

cp -r your-app your-app.orig      # 原件只读,后面不再动它
git init && git add . && git commit -m "baseline: 导出未改"

后面你会做几十次「改一半发现方向错了」的回退,这一步能救命。


第二步:前端脱平台化清洗

这一层最琐碎,但必须做——否则打出来的包里全是平台的痕迹。建议分三层各做各的提交,出问题好定位。

2.1 第一层:品牌与元信息

全文搜索平台域名、品牌词、项目 slug:

grep -rn "platform-domain\|平台名\|app-xxxxx" src/ public/ index.html --include="*"

(这几个是占位符,换成你自己平台的域名、品牌词、项目 slug。)

要改的位置通常是:index.html 的 <title> / <meta>、启动页和「关于」页文案、环境变量里的项目标识。

2.2 第二层:外部资源本地化

导出包里的图片往往一半以上还是热链接,指向平台的 CDN 或搜索引擎缓存图。这些链接有三个问题:随时失效、有水印、跟内容对不上。

处理方式:

# 先统计有多少外部资源
grep -rnoE "https?://[^\"' )]+\.(png|jpe?g|webp|svg|woff2?|ttf)" src/ | wc -l

# 按域名归个类,看看都来自哪
grep -rnoE "https?://[^\"' )]+\.(png|jpe?g|webp)" src/ \
  | sed -E 's#.*//([^/]+)/.*#\1#' | sort | uniq -c | sort -rn

然后建一张映射表(JSON 就行),记录「原始 URL → 本地路径」,按表批量替换。保留这张表的好处是:以后数据源变了可以重跑,不用再人肉 grep。

落地的易错点:

  • CSS 里的 url() 基准是 CSS 文件本身,不是 HTML。打包后 CSS 在 assets/ 下,要写 ../images/x.png。
  • JSX 内联 style 的基准是文档本身,和上面不是同一套。
  • 两者写法不一样,别统一套用。
  • 图片统一下游规格(尺寸、比例、体积)。带 EXIF 或超大尺寸的原图(动辄 3000px 宽)在低端安卓上解码会直接失败。
  • 字体同理,.woff2 / .ttf 一并拉下来。

2.3 第三层:接口地址换道岔(一处改,全线改)

搜索平台网关的关键字(通常是某个固定域名或 header),把请求导向你自己的后端:

grep -rn "gateway\|平台网关域名\|INTEGRATIONS_API_KEY" src/ supabase/functions/

道岔的意思是:别让每个页面各自记一份后端地址,而是在一个地方把请求扳向新轨道。

建议在这一层就引入一个集中配置文件,把后端地址、Key 全部收敛进去。别散落在各个页面里——后面迁成本地、切 APK、换环境都要改,散着改必漏。


第三步:数据层迁移

这是最容易翻车的一层。dump 导进去只是第一步——函数和触发器根本不会跟着过来。

3.1 先把 dump 转成能读的 SQL

平台给的 database.dump 常常是高版本 pg_dump 生成的定制格式,本地 pg_restore 版本低了会直接拒绝:

pg_restore: error: unsupported version (1.16) in file header

最省事的绕法是用同版本 Docker 容器把它转成 plain SQL,不用来回装 Postgres:

docker run --rm -v "$PWD:/dump" postgres:18 \
  pg_restore -f /dump/database.sql /dump/database.dump

拿到 plain SQL 后切成两份,方便排查和重放:

01-schema-only.sql   ← 表结构、约束、索引
02-data-only.sql     ← COPY 数据

3.2 两个坑,两条规则(我都踩过)

前两个是平台埋的,后两个是我自己撞的——后两个严格说不算坑,是两条动手前就该知道的规则。

坑 1:pg_dump 不导出函数和触发器。

表现很隐蔽:站点看着正常,一到注册就报 404,服务端日志是 PGRST202 Could not find the function。

必查清单:

-- 看看函数、触发器、自定义类型是不是都过来了
SELECT routine_schema, routine_name
FROM information_schema.routines
WHERE routine_schema NOT IN ('pg_catalog','information_schema');

SELECT tgname, relname FROM pg_trigger t
JOIN pg_class c ON c.oid = t.tgrelid WHERE NOT t.tgisinternal;

少了就手写补丁 SQL 补回去,特别是新用户自动写入档案表的那个触发器——没有它,注册流程会走到一半断掉。

坑 2:迁移文件里声明的类型,跟实际库不一样。

比如迁移 SQL 写着某个字段是枚举 user_role,实际库里却是 text——schema 下根本没有这个枚举类型。你照着迁移文件写函数返回值,就会得到:

ERROR: 42P13: return type mismatch in function declaration

原则:以实际库为准,不信文档不信注释。 动手前先 \d+ 表名 看一眼真实类型。

规则 3:SQL Editor 的执行语义。

在 Supabase 控制台里粘贴一段 SQL,它是整段作为一个事务跑的——中间任何一条报错,前面执行过的全部回滚。而且结果面板只显示最后一条语句的输出,前面的查询结果你根本看不到。

所以写校验时,把所有核对条目并成一条 UNION ALL 查询:

SELECT 'users' AS tbl, count(*)::text AS cnt FROM public.users
UNION ALL SELECT 'poets',        count(*)::text FROM public.poets
UNION ALL SELECT 'poem_cards',   count(*)::text FROM public.poem_cards
UNION ALL SELECT 'moments',      count(*)::text FROM public.poet_moments;

另外每份补丁脚本都要能重复执行(幂等)。第一次跑成功不代表第二次也成功,而换个环境部署时,多半要再跑一遍。

规则 4:判断库是否就绪,不能查根路径。

# 错误:用 anon key 打根路径,必返回 401
curl "$SUPA/rest/v1/"

# 正确:直接查一张具体的表
curl -H "apikey: $ANON" -H "Authorization: Bearer $ANON" \
     "$SUPA/rest/v1/poets?select=id&limit=1"

顺带一提,数行数别用返回数组的长度。接口报错时返回的 JSON 有固定 4 个键,len(response) 就一直是 4,会让你误判。正确读法是:

curl -H "apikey: $ANON" -H "Authorization: Bearer $ANON" \
     -H "Prefer: count=exact" -H "Range: 0-0" \
     -I "$SUPA/rest/v1/poets?select=id"
# 读响应头 Content-Range: 0-0/15  ← 斜杠后面才是总数

3.3 别忘了清理数据里的路径前缀

迁移过来的表,图片字段往往带着前导斜杠(/images/x.png)。在 Web 端这没问题,一旦打包进 APK 变成 file:// 协议,绝对路径会指向存储根目录,图全裂。

统一刷一遍:

UPDATE public.poets
SET avatar_url = regexp_replace(avatar_url, '^/+', '')
WHERE avatar_url LIKE '/%';

改完务必用一条查询验证结果为 0:

SELECT count(*) FROM public.poets WHERE avatar_url LIKE '/%';  -- 应为 0

第四步:把 AI 能力接回来

如果你的应用用了平台的 AI(对话、生图),导出后这部分会调向平台的付费网关,没额度就是 401/402。

4.1 策略:保留代码骨架,换掉上游

不需要重写业务逻辑。平台通常把 AI 能力包成了 Edge Function(边缘函数),你要做的是保留函数的对外接口,替换里面的上游地址和鉴权。

推荐做成双通道:主模型挂了自动切备份。投入很小,收益是再也不用半夜起来修服务。

对话   主:某厂商 7B 级 chat 模型      备:另一家开源 7B
生图   主:某厂商 Kolors 类(无水印)  备:cogview 类(有水印,慢 ~50%)

挑生图主通道时别只看速度。有没有水印这件事对最终观感的影响,比快三秒大得多。

4.2 部署顺序:先配 Secret,再部署函数

在 Supabase 控制台里,Secrets 变更后已部署的函数不会自动读到新值,必须重新部署。所以顺序固定为:

  1. Edge Functions → Secrets → 添加环境变量
    (注意是函数级别的 Secrets,不是项目设置里的 API keys)
  2. 粘贴函数代码、部署
  3. 改了任何 Secret → 重新部署一遍

平台自带的 SUPABASE_URL / SUPABASE_ANON_KEY / SUPABASE_SERVICE_ROLE_KEY 不用你配,是注入的。

4.3 Enforce JWT(强制 JWT 校验):按调用方式决定,不是拍脑袋

每个边缘函数都有一个「强制 JWT 校验」开关。规则很简单:

  • 用 SDK 的 .invoke()(自动带 Authorization)→ 开关打开
  • 裸 fetch()(只有 Content-Type,没有鉴权头)→ 开关关掉

设错了的表现是前端拿到 401,页面上显示为「生成失败」,很容易被误判成模型 Key 配错了。

# 快速判断前端到底是哪种调用
grep -rn "Authorization" src/pages/ | head

4.4 流式响应:这个坑卡了我最久

如果你要转发 SSE 流式响应(打字机效果),在 Edge Runtime 里必须用 start + 主动 while 泵:

const stream = new ReadableStream({
  async start(controller) {                    // ← 注意是 start,不是 pull
    const reader = upstream.body!.getReader();
    const deadline = Date.now() + 40_000;      // 兜底:别让前端无限转圈
    try {
      controller.enqueue(encoder.encode(": open\n\n"));  // 先撑开连接
      while (Date.now() < deadline) {
        const { done, value } = await reader.read();
        if (done) break;
        // 按行解析 SSE,转写后 enqueue
        for (const line of buf.split("\n")) {
          /* ... */
        }
      }
    } catch (e) {
      console.error("流读取中断", e);
    } finally {
      controller.enqueue(encoder.encode("data: [DONE]\n\n"));
      controller.close();
    }
  },
});

千万不要用 async pull(controller)。 在这个运行时里 pull 不会被持续触发,症状是:HTTP 200 秒回、首字节很快、body 永远不来,客户端挂到超时(常见表现是 91 秒后失败)。这个坑特别恶心,因为它看起来像「模型很慢」。

(40 秒是我在泵循环里设的兜底,91 秒是那次客户端挂到超时的实际数字。)


第五步:静态托管(别用 python3 -m http.server)

SPA 应用有个硬性需求:深链要能刷新。用户在 /moments 页面上按 F5,服务器必须把 index.html 返回回去,而不是 404。

python3 -m http.server 做不到这件事。写个 30 行的小服务器:

覆写 send_head 就够了,其余交给标准库(下面是片段,补上 import 和 HTTPServer(...).serve_forever() 就能跑):

class Handler(SimpleHTTPRequestHandler):
    def send_head(self):
        raw = urllib.parse.urlsplit(self.path).path
        target = self.translate_path(self.path)
        if not os.path.exists(target):
            # 无扩展名 => 当成前端路由,返回 index.html
            # 带扩展名(如 /x.js 缺失)=> 老实 404,别把错误藏起来
            if not posixpath.splitext(raw)[1]:
                return self._serve_index()
        return super().send_head()

再加两条实践:

  • HTML/JS/CSS 发 Cache-Control: no-store。否则改完代码刷新页面还是旧的,你会怀疑人生。
  • 用 systemd 用户级服务托管,loginctl enable-linger 让它开机自启:
systemctl --user enable --now my-site.service

⚠️ 注意:配了开机自启之后,就别再手动 nohup 跑启动脚本了——两个进程抢同一个端口,症状是「时好时坏」。


第六步:打包成 APK(让朋友能装到手机上的那一步)

要让朋友手机装上,推荐 HBuilderX 的 5+App 云打包:资源打进包里,不用装 Android SDK,也不用公网网址。
另一条 Wap2App 路线需要公网 URL,这种场景用不上。

6.1 头号坑:APK 内是 file:// 协议

这是两套完全不同世界的加载环境:

https:// 网页 file:// APK
<script type="module"> ✅ ❌ 被当跨域拦截 → 白屏
crossorigin 属性 ✅ ❌ 同上
绝对路径 /assets/x.js ✅ 指向站点根 ❌ 指向存储根 → 裂图
History 路由刷新 服务器兜底 ❌ 没有服务器 → 404

所以默认的 Vite 产物不能直接进 APK。加一个专属构建模式:

改动其实只有三处:base 改成相对路径、产物改成 iife 单文件、HTML 里的 type="module" 和 crossorigin 去掉。配置如下:

// vite.config.ts
export default defineConfig(({ mode }) => {
  const isApp = mode === "app";
  return {
    base: "./",                                   // 相对路径
    plugins: [react(), {
      name: "app-html",
      enforce: "post",
      transformIndexHtml: (html) => html
        .replace(/ type="module"/g, "")
        .replace(/ crossorigin/g, ""),            // 改写成经典 script
    }],
    build: isApp ? {
      outDir: "dist-app",
      rollupOptions: {
        output: {
          format: "iife",                         // 单文件,无 import/export
          inlineDynamicImports: true,
        },
      },
      modulePreload: false,
    } : {},
  };
});

配完先别急着打包——下面三个细节漏掉任何一个,装到手机上还是白屏:

三个必须同时满足的细节:

  1. transformIndexHtml 里去掉 type="module" 后,要确认最终标签带 defer。经典 script 不带 defer 会同步执行,此时 DOM 还没准备好。React 会抛 #299 CreateRoot(...): Target container is not a DOM element——又是一个白屏。
  2. BrowserRouter 换 HashRouter。没有服务器兜底,History 路由在 APK 里刷新会 404。
  3. 所有 /xxx.png 改成 ./xxx.png,包括 <img src>、JSX 内联 style、以及 CSS 里的 url()(基准不同,见 2.2)。

构建命令:

node node_modules/vite/bin/vite.js build --mode app   # → dist-app/  给 APK 用
node node_modules/vite/bin/vite.js build              # → dist/      给网页用

两套产物并存,互不影响。

6.2 manifest 与打包的三条纪律

纪律一:版本号必须递增。

安卓不允许降级安装。version.code 只要没往上走,用户覆盖安装就直接报「应用未安装」。每次出包前先把 code 往上加,只增不减。 中间跳号没关系,但绝对不能往回退。

100 → 102 → 103 → 104 → 105

纪律二:module 声明会偷偷给你加权限。

manifest 顶层的 permissions 是模块声明,HBuilderX 会按模块往安卓清单里注入对应权限。这和 google 段下的 permissions 数组是两套东西。

判断某个模块有没有被勾上,别用全文 grep(会因为注释或字符串误报),要先定位对象区间再正则抽 key。

纪律三:出包前清空 assets/。

HBuilderX 会把项目目录整个打进 APK。所以 assets/ 里任何历史构建残留(旧的 index-*.js、style-*.css)都会进去。我就因此多塞过 3.2 MB,而且那些文件明明没有任何引用。

6.3 判断用户装的是哪一版:别信时间戳

云打包产物的文件名用的是提交时间,但文件落地有延迟(几分钟)。我 10:33 刷目录没看到包,就以为它没打出来——其实只是还没落地。

我试下来最靠谱的办法是解包看 index.html 引用了哪个 js:

python -c "
import zipfile
z = zipfile.ZipFile('your.apk')
print(z.read('assets/apps/<AppID>/www/index.html').decode())
"

看输出里的 assets/index-XXXX.js,跟本地产物对得上就说明是新版。

我就因为偷懒看时间戳,误判用户「没装新版」,被当场拿着截图纠正。


第七步:验收清单

部署完别急着收工。下面每一项都用数值断言验一遍,肉眼看截图会漏。

基础可用性

  • ☐ tsc --noEmit 0 错误
  • ☐ 两种模式都能构建:dist/ 和 dist-app/
  • ☐ 静态服务器下深链刷新(/任意路由 + F5)不 404
  • ☐ 断开外网后,所有资源(图片、字体)仍然正常(证明已本地化)

数据与 AI

  • ☐ 各表行数与原库一致
  • ☐ 注册全流程走通(尤其验证触发器有没有双写)
  • ☐ AI 对话有流式返回,且不挂到超时
  • ☐ AI 生图返回正常,检查有无水印

客户端特定

  • ☐ 用 file:// 加载 index.html 冒烟——这是我找到的、最能复现真机环境的手段,http:// 下测不出来 module 被拦的问题
  • ☐ 所有图片 img.complete && naturalWidth > 0
  • ☐ 关闭 / 返回入口在各类机型上都点得到(别只验自己手上那台机器)
  • ☐ 图片加载失败时有兜底占位,不会白着一块

踩坑速查表

按现象反查,省得翻日志:

渲染与客户端

现象 根因 解法
白屏,控制台报 React #299 去掉 type="module" 后没加 defer 让 script 标签带 defer
白屏,无任何报错 file:// 下 module 被拦 走 IIFE 构建模式
页面只剩顶部一条图,其余全白 inset: 0 简写在老 WebView 被整条丢弃 展开为 top/right/bottom/left 四边写法
高度链塌陷、元素跑出屏幕 min() / clamp() 数学函数不支持 改固定值 + 媒体查询降级
关闭/返回按钮摸不到 position: fixed 被祖先的 transform / perspective 捕获了包含块 createPortal(节点, document.body) 断开祖先链
图片四周有白边 object-fit: contain + width:auto 改 cover + width:100%;height:100%
图片一直白着,但没报错 file:// 下部分 WebView 不派发 onError 三重兜底:onError + 超时 + naturalWidth===0

后端、AI 与打包

现象 根因 解法
注册后无法登录 / 按钮无反应 应用把「档案表有行」当登录凭据,触发器没写进去 补触发器,且清库时绝不能删档案行
AI 响应 200 但 body 永远不来 Edge Runtime 里用了 async pull 改 start + while(await reader.read()) 主动泵
AI 调用 401 裸 fetch 的函数开着 JWT 校验 关掉该函数的强制 JWT 校验(Enforce JWT)
改了环境变量但没生效 Secret 变更后没重新部署函数 重新部署一遍
覆盖安装报「应用未安装」 version.code 没有往上走 code 往上加,别往回退
注册按钮一直转圈 请求既没 resolve 也没 reject(网络层挂起) 加超时兜底;排查外网可达性
CDP(Chrome DevTools Protocol)截图看起来一模一样 截图对同一视口可能返回字节相同的数据 断言必须走 getBoundingClientRect() 数值测量

上面两张表里,加粗那两条最容易被误判成「功能没写」——实际上代码逻辑都对,是渲染和定位的行为差异。


最后

折腾完这一趟,我发现「本地部署」难的从来不是命令行,而是你得把每一层隐性依赖都显性化。

在平台上这些都是白拿的:构建机会自己跑,数据库一直在线,AI 额度花不完,CDN 自带路由兜底。搬回家之后,每一条都得你自己给出答案。

而回答这些问题的过程,其实就是把这个应用真正搞懂的过程。

留三条我觉得最值得记住的经验:

  1. 先解耦,再迁移。 动手前把源码里指向平台的硬引用全 grep 出来列成清单——这一步省下的时间,比后面任何一次调试都多。
  2. 不确定的地方用数值断言,别用截图。 截图会骗你(字面意义上的:同一视口不同内容可能返回完全相同的字节)。
  3. 保守写法优先于「先测测看」。 桌面浏览器是现代内核,很多兼容性问题在那里根本复现不出来。与其赌它是支持的,不如一开始就写所有环境都认的形式。

文章里的命令我都是在那台虚拟机上一条条试出来的。换个平台,套路是通用的,关键词得你自己换。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或者给予支持!

点下「导出源码」之后:一次 H5 应用自托管的排障实录
/archives/h5-app-self-hosting
作者
簌胡思
发布于
2026-09-28
许可协议
CC BY-NC-SA 4.0