点下「导出源码」之后:一次 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.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 变更后已部署的函数不会自动读到新值,必须重新部署。所以顺序固定为:
- Edge Functions → Secrets → 添加环境变量
(注意是函数级别的 Secrets,不是项目设置里的 API keys) - 粘贴函数代码、部署
- 改了任何 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,
} : {},
};
});
配完先别急着打包——下面三个细节漏掉任何一个,装到手机上还是白屏:
三个必须同时满足的细节:
transformIndexHtml里去掉type="module"后,要确认最终标签带defer。经典 script 不带defer会同步执行,此时 DOM 还没准备好。React 会抛#299 CreateRoot(...): Target container is not a DOM element——又是一个白屏。BrowserRouter换HashRouter。没有服务器兜底,History 路由在 APK 里刷新会 404。- 所有
/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 --noEmit0 错误 - ☐ 两种模式都能构建:
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 自带路由兜底。搬回家之后,每一条都得你自己给出答案。
而回答这些问题的过程,其实就是把这个应用真正搞懂的过程。
留三条我觉得最值得记住的经验:
- 先解耦,再迁移。 动手前把源码里指向平台的硬引用全 grep 出来列成清单——这一步省下的时间,比后面任何一次调试都多。
- 不确定的地方用数值断言,别用截图。 截图会骗你(字面意义上的:同一视口不同内容可能返回完全相同的字节)。
- 保守写法优先于「先测测看」。 桌面浏览器是现代内核,很多兼容性问题在那里根本复现不出来。与其赌它是支持的,不如一开始就写所有环境都认的形式。
文章里的命令我都是在那台虚拟机上一条条试出来的。换个平台,套路是通用的,关键词得你自己换。










