---
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 不是最新的

对于前端+后端合体的场景，这是性价比最高的方案——不需要额外的打包工具，纯标准库搞定。
