Files
SuBlog/content/posts/go-embed-react-single-binary/index.md
T

226 lines
9.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: Go 单文件打包进阶:React 前端 + 交叉编译,一次踩完所有坑
published: 2026-08-16T00:00:00
updated: 2026-08-16
description: 以 Magic Img(React 19 + Vite + Gin + SQLite)为实例,记录把前端塞进 Go 二进制、并在 Windows 上交叉编译 Linux 产物的完整过程:embed 的 all 前缀、hash 路由、缓存策略、GOOS 交叉编译等实操坑。
image: /posts/go-embed-react-single-binary/img/cover.svg
tags: ['Go', 'React', 'embed', '交叉编译', '打包']
draft: false
series: Go 工程实践
seriesOrder: 3
difficulty: 进阶
---
## 背景
这是「Go 单文件打包」系列第三篇。上一篇([把前端塞进二进制](/posts/go-embed-frontend))记录了一个 Gin + Vue 3 项目的 embed 方案。这次换了个项目继续踩坑:**Magic Img**——一个自托管、本地优先的 AI 生图提示词管理工具,技术栈是:
- 前端:React 19 + TypeScript + Vite + Tailwind v4(UI 库 Magic UI + shadcn/ui)
- 后端:Go + Gin + GORM + SQLite(**modernc 纯 Go 驱动,无 cgo**)
目标一样:**一个二进制拖到任何机器上就能跑**——前端、API、数据库全部内嵌。
和上篇不同的地方有三个,正好是这篇要讲的重点:
1. 前端用了 **HashRouter + Vite base `/dist`**,服务端**不再需要 SPA fallback**(上篇坑一的新解法);
2. embed 用 **`all:` 前缀**一劳永逸解决 `_` 开头文件被排除的问题(上篇坑二的升级解法);
3. 需要**在 Windows 上交叉编译 Linux 产物**,踩了「生成的文件无法在 Linux 执行」的坑。
## 核心思路
和上篇一致:`//go:embed` 在编译期把前端 `dist/` 塞进二进制,运行时由 Go 直接提供静态服务。差异在于路由策略和缓存策略,后面细说。
## 逐步实现
### 1. 前端:hash 路由 + base /dist
vite.config.ts 里固定 `base: "/dist/"`,路由用 `HashRouter`:
```ts
// vite.config.ts
export default defineConfig({
base: "/dist/", // 所有资源路径以 /dist/ 开头
...
})
```
```tsx
// App.tsx —— HashRouter:路径信息全在 location.hash 里
<HashRouter>
<Routes>...</Routes>
</HashRouter>
```
**为什么这样选**:history 路由需要服务端把未知路径全部 fallback 回 `index.html`(上篇坑一)。hash 路由下浏览器请求的永远是 `/dist/` 这一个入口,**静态服务直接返回 index.html 就完事,一个 fallback 都不用写**。内嵌场景里这是最省心的组合。
### 2. 构建产物复制进后端目录
`npm run build` 除了编译,还会把 `dist/` 复制到 `backend/web/dist`(一个小脚本 `scripts/copy-dist.mjs` 干这个活),供 `go:embed` 使用:
```json
// frontend/package.json
"scripts": {
"build": "tsc -b && vite build && node scripts/copy-dist.mjs"
}
```
### 3. 后端:embed + http.FileSystem 包装
```go
// backend/web/embed.go
package web
import (
"embed"
"net/http"
"strings"
)
//go:embed all:dist
var distFS embed.FS
// Dist 内嵌前端静态文件系统
var Dist http.FileSystem = &fileSystem{prefix: "dist", fs: &distFS}
type fileSystem struct {
prefix string
fs *embed.FS
}
func (f *fileSystem) Open(name string) (http.File, error) {
if name == "/" {
name = f.prefix
} else {
name = f.prefix + "/" + strings.TrimPrefix(name, "/")
}
return http.FS(f.fs).Open(name)
}
```
两个细节:
- **`all:dist` 里的 `all:` 前缀**:embed 目录时默认会排除 `_` 和 `.` 开头的文件(上篇坑二就是被这个坑了,当时靠改 Vite 输出名绕开)。`all:` 前缀表示「全部包含」,前端构建产物里那些 `_xxx.js` 助手文件直接安全落地,不用再改前端配置;
- `http.FileSystem` 包装:加了一层 `dist/` 前缀映射,`http.FileServer` 和 Gin 都能直接用。
### 4. 路由:静态服务 + 缓存策略
```go
// backend/internal/handler/router.go
func NewRouter(db *gorm.DB, uploadDir string) *gin.Engine {
r := gin.Default()
...
dist := http.FileServer(web.Dist)
r.GET("/dist", func(c *gin.Context) { c.Redirect(302, "/dist/") })
r.GET("/dist/*filepath", func(c *gin.Context) {
p := c.Param("filepath")
if p == "/" || p == "/index.html" {
// index.html 禁缓存:升级后浏览器立刻拿到新入口
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
} else if strings.HasPrefix(p, "/assets/") {
// 带内容 hash 的资源可放心长缓存
c.Header("Cache-Control", "public, max-age=31536000, immutable")
}
http.StripPrefix("/dist", dist).ServeHTTP(c.Writer, c.Request)
})
r.GET("/", func(c *gin.Context) { c.Redirect(302, "/dist/") })
...
}
```
缓存策略是内嵌发布的必修课(坑二细说):入口禁缓存、hash 资源长缓存。
### 5. 打包脚本:Windows 版 + Linux 交叉编译版
```bash
#!/usr/bin/env bash
# build-linux.sh —— 在 Windows/macOS/Linux 任意平台交叉编译 linux/amd64 产物
set -euo pipefail
cd "$(dirname "$0")"
BINARY="${BINARY:-magic-img}"
echo "[1/3] 构建前端并复制产物到 backend/web/dist ..."
(cd frontend && npm run build)
echo "[2/3] 交叉编译后端并内嵌前端 (CGO_ENABLED=0 GOOS=linux GOARCH=amd64) ..."
(cd backend && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o "$BINARY" -ldflags="-s -w" ./cmd/server)
chmod +x "backend/$BINARY"
echo "[3/3] 打包完成: backend/$BINARY"
```
Windows 版(`build-win.bat`)同理,只是产物叫 `magic-img.exe`。
## 踩坑记录
### 坑一:`//go:embed dist` 静默漏掉 `_` 开头文件(升级解法)
上篇说过:embed 目录时**硬编码排除 `_` 和 `.` 开头文件**。上篇的解法是改 Vite 输出文件名去掉 `_` 前缀;这次直接用:
```go
//go:embed all:dist // all: 前缀 = 包含全部文件,不再静默跳过
```
**结论:能用 `all:` 就用 `all:`,从根上消灭这类"文件莫名 404"的问题。**
### 坑二:开发版是新的、打包版却是旧的
现象:Vite 开发服务一切正常,`:8080/dist/` 打包版却是旧界面,强刷也不一定好。
排查过程值得记录:先是怀疑 `go build` 缓存了旧 embed(上篇坑三的思路),重编重启无效;然后直接抓取 8080 返回的 HTML 和 JS,发现**服务端内容已经是新的**——问题出在**浏览器缓存了 `index.html`**:它引用旧 hash 的资源,一直不更新。
**解决**:入口文件响应 `Cache-Control: no-store`(见上面路由代码),带 hash 的资源给 `immutable` 长缓存。升级后浏览器每次拉新入口,资源文件名一变就自动换新,两全其美。
> 教训:排查"新旧不一致"先分清是哪一层——服务端产物、浏览器入口缓存、还是资源缓存。抓包看响应内容再动手。
### 坑三:Windows 上跑的 build-linux.sh,产出的文件 Linux 却执行不了
最初脚本只写了 `CGO_ENABLED=0 go build`,**没指定 `GOOS/GOARCH`**。在 Windows 上跑,产出的其实是个 Windows PE 文件(只是没叫 `.exe`),传到 Linux 自然「无法执行」。
**解决**:脚本里显式交叉编译:
```bash
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o "$BINARY" -ldflags="-s -w" ./cmd/server
```
能这么干的前提是**全依赖树无 cgo**——SQLite 用的是 modernc 纯 Go 驱动,bcrypt 用 `x/crypto`。这也是选型时就要留意的点:**想跨平台交叉编译,先把 cgo 依赖清干净。**
另外两个小提醒:Windows 上编出的文件没有 Unix 执行位,上传服务器后记得 `chmod +x`;`-ldflags="-s -w"` 剥离符号,体积从 30MB+ 降到 26MB。
### 坑四:dist 被 gitignore 后,embed 直接编译失败
`backend/web/dist` 是构建产物、入了 .gitignore,但 `//go:embed` 在**编译期**就要读它——刚 clone 下来直接 `go build` 会报 embed 找不到文件。
**解决**:gitignore 里保留 `dist/.gitkeep`(一个空文件),让目录存在于版本库中:
```gitignore
backend/web/dist/*
!backend/web/dist/
!backend/web/dist/.gitkeep
```
这样 clone 后可以先 `go build` 通过,再 `npm run build` 灌入真实前端。
### 坑五:>500KB chunk 警告,别慌
Vite 构建会警告单 chunk 超过 500KB。这个项目里 Base UI 组件库全量引入,单 chunk 约 640KB——**属正常,不要为了消警告去拆配置**。真要优化就上路由级 `React.lazy`。
## 最终效果
```bash
$ build-win.bat # backendmagic-img.exe
$ bash build-linux.sh # backendmagic-img(linux/amd64 ELF)
```
一个约 26MB 的二进制,内嵌 React 前端、Gin API、SQLite 数据初始化,拷到 Windows/Linux 机器直接跑,浏览器访问 `http://localhost:8080` 自动重定向到 `/dist/`。
## 总结
这轮相比上篇的三点升级:
1. **hash 路由 + base /dist**:省掉 SPA fallback,静态服务即开即用;
2. **`all:` embed 前缀**:`_` 开头文件问题一劳永逸;
3. **显式 `GOOS/GOARCH` 交叉编译**:让「一个脚本、任意平台、产出 Linux 二进制」成为现实。
加上**入口 no-store / 资源 immutable** 的缓存策略,单文件分发的体验才算闭环——不然每次升级都要和浏览器缓存打架。