提交文章
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -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 不是最新的
|
||||||
|
|
||||||
|
对于前端+后端合体的场景,这是性价比最高的方案——不需要额外的打包工具,纯标准库搞定。
|
||||||
Reference in New Issue
Block a user