feat: add Go embed React single binary article (Go 工程实践 #3)
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1067" height="600" viewBox="0 0 1067 600">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#f6f4fb"/>
|
||||
<stop offset="1" stop-color="#e7f0f7"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="go" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#00add8"/>
|
||||
<stop offset="1" stop-color="#0087a8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1067" height="600" fill="url(#bg)"/>
|
||||
<text x="533" y="84" text-anchor="middle" font-family="sans-serif" font-size="40" font-weight="700" fill="#2b3340">Go 单文件打包进阶</text>
|
||||
<text x="533" y="126" text-anchor="middle" font-family="sans-serif" font-size="24" fill="#8090a0">React 前端 + go:embed + 交叉编译</text>
|
||||
<rect x="96" y="196" width="230" height="96" rx="16" fill="#ffffff" stroke="#d8d2ec" stroke-width="3"/>
|
||||
<text x="211" y="238" text-anchor="middle" font-family="sans-serif" font-size="28" font-weight="700" fill="#5b5568">dist/</text>
|
||||
<text x="211" y="266" text-anchor="middle" font-family="sans-serif" font-size="15" fill="#8a84a0">Vite 构建产物</text>
|
||||
<path d="M346 244 h64" stroke="#7c5cff" stroke-width="4" fill="none"/>
|
||||
<path d="M402 238 l12 6 l-12 6 z" fill="#7c5cff"/>
|
||||
<rect x="418" y="196" width="230" height="96" rx="16" fill="url(#go)"/>
|
||||
<text x="533" y="236" text-anchor="middle" font-family="monospace" font-size="24" font-weight="700" fill="#ffffff">go:embed all:dist</text>
|
||||
<text x="533" y="266" text-anchor="middle" font-family="sans-serif" font-size="15" fill="#d8f4fb">编译期塞进二进制</text>
|
||||
<path d="M668 244 h64" stroke="#7c5cff" stroke-width="4" fill="none"/>
|
||||
<path d="M724 238 l12 6 l-12 6 z" fill="#7c5cff"/>
|
||||
<rect x="740" y="176" width="240" height="136" rx="18" fill="#ffffff" stroke="#7c5cff" stroke-width="4"/>
|
||||
<text x="860" y="224" text-anchor="middle" font-family="monospace" font-size="24" font-weight="700" fill="#5b3fd4">magic-img</text>
|
||||
<text x="860" y="256" text-anchor="middle" font-family="sans-serif" font-size="15" fill="#5b5568">单文件 · 约 26MB</text>
|
||||
<text x="860" y="284" text-anchor="middle" font-family="monospace" font-size="14" fill="#8a84a0">windows/amd64 · linux/amd64</text>
|
||||
<g font-family="sans-serif" font-size="18" fill="#5b5568">
|
||||
<rect x="96" y="404" width="275" height="74" rx="12" fill="#ffffff" stroke="#e3ddf3" stroke-width="2"/>
|
||||
<text x="233" y="434" text-anchor="middle" font-weight="700">HashRouter + base /dist</text>
|
||||
<text x="233" y="460" text-anchor="middle" font-size="14" fill="#8a84a0">无需 SPA fallback</text>
|
||||
<rect x="396" y="404" width="275" height="74" rx="12" fill="#ffffff" stroke="#e3ddf3" stroke-width="2"/>
|
||||
<text x="533" y="434" text-anchor="middle" font-weight="700">index.html no-store</text>
|
||||
<text x="533" y="460" text-anchor="middle" font-size="14" fill="#8a84a0">升级立即生效</text>
|
||||
<rect x="696" y="404" width="275" height="74" rx="12" fill="#ffffff" stroke="#e3ddf3" stroke-width="2"/>
|
||||
<text x="833" y="434" text-anchor="middle" font-weight="700">GOOS=linux GOARCH=amd64</text>
|
||||
<text x="833" y="460" text-anchor="middle" font-size="14" fill="#8a84a0">无 cgo,任意平台交叉编译</text>
|
||||
</g>
|
||||
<text x="533" y="548" text-anchor="middle" font-family="sans-serif" font-size="18" fill="#a49cc0">纯 Go 依赖(modernc SQLite)是跨平台交叉编译的前提</text>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,225 @@
|
||||
---
|
||||
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** 的缓存策略,单文件分发的体验才算闭环——不然每次升级都要和浏览器缓存打架。
|
||||
Reference in New Issue
Block a user