diff --git a/content/posts/go-embed-frontend/img/cover.jpg b/content/posts/go-embed-frontend/img/cover.jpg new file mode 100644 index 0000000..b6752ab Binary files /dev/null and b/content/posts/go-embed-frontend/img/cover.jpg differ diff --git a/content/posts/go-embed-frontend/index.md b/content/posts/go-embed-frontend/index.md new file mode 100644 index 0000000..ca53ecb --- /dev/null +++ b/content/posts/go-embed-frontend/index.md @@ -0,0 +1,165 @@ +--- +title: Go 单文件打包实战:把前端塞进二进制 +published: 2026-06-06T16:00:00 +description: 记录将 Vue 3 前端嵌入 Go 二进制实现单 exe 发布的完整流程,以及遇到的坑和解决方案。 +image: /posts/go-embed-frontend/img/cover.jpg +tags: ['Go', 'Vue', '编译', '埋坑'] +draft: false +--- + +## 背景 + +一个 Gin + Vue 3 的全栈项目,之前的发布方式是:一个 Go 二进制 + 一个 `web/` 前端静态目录,必须放一起才能运行。 + +为了让部署更丝滑——一个 exe 拖哪都能跑——决定把前端构建产物嵌入到 Go 二进制里。 + +## 核心思路 + +利用 Go 1.16 引入的 `//go:embed` 指令,在编译时将前端 `dist/` 目录嵌入二进制,运行时通过嵌入的文件系统提供 SPA 服务。 + +## 逐步实现 + +### 1. 创建嵌入模块 + +```go +// service/web/embed.go +package web + +import ( + "embed" + "io/fs" + "mime" + "net/http" + "path/filepath" + "strings" + + "github.com/gin-gonic/gin" +) + +//go:embed dist +var embedFS embed.FS + +func SPAHandler() gin.HandlerFunc { + sub, _ := fs.Sub(embedFS, "dist") + + return func(c *gin.Context) { + p := strings.TrimPrefix(c.Request.URL.Path, "/") + if p == "" { + p = "index.html" + } + + data, err := fs.ReadFile(sub, p) + if err != nil { + // SPA fallback:文件不存在则返回 index.html + data, _ = fs.ReadFile(sub, "index.html") + } + + c.Data(http.StatusOK, mime.TypeByExtension(filepath.Ext(p)), data) + } +} +``` + +### 2. 改造路由 + +```go +// router.go +import "myproject/web" + +// 替换原来的磁盘静态文件服务 +webHandler := web.SPAHandler() +router.GET("/", webHandler) +router.GET("/assets/*filepath", webHandler) +router.GET("/favicon.ico", webHandler) +router.GET("/favicon.svg", webHandler) +router.NoRoute(webHandler) +``` + +### 3. 一键构建脚本 + +```bash +#!/bin/bash +# build-standalone.sh + +set -e + +# 1. 构建前端 +pnpm install --no-frozen-lockfile +npx vite build + +# 2. 复制到嵌入目录 +rm -rf service/web/dist +cp -r dist service/web/dist + +# 3. 编译 Go 二进制 +cd service +go build -o myapp.exe \ + -ldflags="-X myproject/global.RUNCODE=release" \ + main.go +``` + +## 踩坑记录 + +### 坑一:路由不匹配,静态资源 404 + +首次实现时用了 Gin 的 `NoRoute` 兜底,但部分静态资源返回 404。排查发现 `NoRoute` 在某些路由未显式注册时不会按预期触发。 + +**解决**:显式注册 `/assets/*filepath`、`/favicon.ico` 等路由,`NoRoute` 仅作为兜底。 + +### 坑二:`//go:embed` 排除 `_` 开头文件 + +这是最坑的。Vite 构建产出了一个 `_plugin-vue_export-helper-xxx.js`,Go 的 `//go:embed` 当模式为目录时,**硬编码排除了 `_` 和 `.` 开头的文件**。 + +```go +// 这行会跳过 dist/assets/_plugin-xxx.js +//go:embed dist +``` + +错误表现:JS 文件返回的是 HTML 内容(SPA fallback),浏览器报 `Unexpected token '<'`。 + +**解决**:改 Vite 配置,去除了 `_` 前缀: + +```ts +// vite.config.ts +build: { + rollupOptions: { + output: { + chunkFileNames(chunkInfo) { + return `assets/${chunkInfo.name.replace(/^_/, '')}-[hash].js` + }, + }, + }, +}, +``` + +这样就避免产生 `_` 前缀文件名的同时,保持构建产物的一致性和引用关系的正确性。 + +### 坑三:构建缓存导致 embed 内容未更新 + +多次 `go build` 后,Go 的构建缓存可能使用了旧版本的 embed 内容,导致实际运行的文件不是最新的。 + +**解决**:使用 `go build -a` 强制重新编译所有包。 + +## 最终效果 + +```bash +$ bash build-standalone.sh +=== 构建成功 === +输出: service/myapp.exe 41M +``` + +一个 41MB 的 exe,内置前端页面、API 后端、SQLite 数据库(运行时初始化),拖到哪都能跑。 + +**部署对比:** + +| 方式 | 文件数 | 操作 | +|------|--------|------| +| 旧方案 | 1 exe + 1 web 目录 | 解压→保持目录结构 | +| 新方案 | 1 个 exe | 直接扔到服务器运行 | + +## 总结 + +Go 的 `//go:embed` 是一个强大的工具,但有两个隐藏行为需要注意: +1. 嵌入目录时自动排除 `_` 和 `.` 开头的文件 +2. 构建缓存可能导致 embed 不是最新的 + +对于前端+后端合体的场景,这是性价比最高的方案——不需要额外的打包工具,纯标准库搞定。