Files

170 lines
4.3 KiB
Markdown
Raw Permalink 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 单文件打包实战:把前端塞进二进制
published: 2026-06-06T16:00:00
updated: 2026-06-06
description: 记录将 Vue 3 前端嵌入 Go 二进制实现单 exe 发布的完整流程,以及遇到的坑和解决方案。
image: /posts/go-embed-frontend/img/cover.jpg
tags: ['Go', 'Vue', '编译', '埋坑']
draft: false
series: Go 工程实践
seriesOrder: 2
difficulty: 进阶
---
## 背景
一个 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 不是最新的
对于前端+后端合体的场景,这是性价比最高的方案——不需要额外的打包工具,纯标准库搞定。