# 集成前端

一般现在前端和后端主流是分离部署,也就是各自独立启动服务、各自部署。

但如果你想由 Go 后端托管前端页面,也可以这么做。本文介绍两种常见方式:外挂资源和直接打包进二进制。

假设你的后端项目结构如下,这里以前端 Vite 项目为例,将前端项目放到后端项目根目录下的 `static` 目录中:

```text
xxx
  |-- main.go
  |-- static
      |-- fe-project

Go 默认只会编译当前目录下同一个 package 的 Go 文件,以及被 import 引用到的其他 package。像 static 这种普通静态资源目录,默认不会被 go build 打包进去,所以不用担心它自动进入二进制。

方式 1:外挂资源

将前端作为外挂资源,放置到和后端打包后的二进制程序同一目录下。

// 注册静态资源
r := gin.Default()

r.Static("/static", "./static")
r.StaticFile("/", "./static/fe-project/dist/index.html")

这种方式的特点是:后端程序运行时会从磁盘读取前端文件。

所以后端打包部署到服务器后,还需要把前端构建产物一起复制到旁边的 static/fe-project/dist 目录里。

例如:

server
  |-- main
  |-- static
      |-- fe-project
          |-- dist
              |-- index.html
              |-- assets

需要注意,如果你的前端是 React / Vue / Vite 这类 SPA 应用,只配置 StaticFile("/", ".../index.html") 只能处理首页访问。

如果用户直接刷新 /dashboard/user/list 这类前端路由,后端可能会返回 404。此时需要额外做前端路由兜底,把未命中的 GET 请求返回到 index.html

方式 2:直接打包进去

把前端构建产物直接嵌入到 Go 二进制里。Go 可以用标准库 embed 实现。

这样部署时只需要发布一个后端可执行文件,不需要额外复制 static/fe-project/dist 目录。

项目结构示例:

xxx
  |-- main.go
  |-- static
      |-- fe-project
          |-- dist
              |-- index.html
              |-- assets
                  |-- index.xxx.js
                  |-- index.xxx.css

示例代码:

package main

import (
	"embed"
	"io/fs"
	"mime"
	"net/http"
	"path"
	"strings"

	"github.com/gin-gonic/gin"
)

//go:embed static/fe-project/dist
var embedFS embed.FS

func main() {
	r := gin.Default()

	frontendFS, err := fs.Sub(embedFS, "static/fe-project/dist")
	if err != nil {
		panic(err)
	}

	// 注册内嵌前端资源
	r.NoRoute(frontendHandler(frontendFS))

	r.Run(":8080")
}

func frontendHandler(frontendFS fs.FS) gin.HandlerFunc {
	return func(ctx *gin.Context) {
		// 路径规范化,可省略;并且只有 GET 请求、非 API 请求才走前端资源兜底
		requestPath := path.Clean(ctx.Request.URL.Path)
		if ctx.Request.Method != http.MethodGet || strings.HasPrefix(requestPath, "/api/") {
			ctx.Status(http.StatusNotFound)
			return
		}

		// 首页友好:如果请求路径为空或者斜杠,则默认定位到 index.html
		filePath := strings.TrimPrefix(requestPath, "/")
		if filePath == "." || filePath == "" {
			filePath = "index.html"
		}

		// 读取前端资源;如果找不到对应文件,则回退到 index.html,支持 SPA 前端路由刷新
		data, err := fs.ReadFile(frontendFS, filePath)
		if err != nil {
			data, err = fs.ReadFile(frontendFS, "index.html")
			if err != nil {
				ctx.Status(http.StatusNotFound)
				return
			}
			filePath = "index.html"
		}

		// 根据文件后缀推断 Content-Type;如果推断不到,再根据文件内容检测
		contentType := mime.TypeByExtension(path.Ext(filePath))
		if contentType == "" {
			contentType = http.DetectContentType(data)
		}

		// 返回前端资源内容
		ctx.Data(http.StatusOK, contentType, data)
	}
}

这里的核心是:

//go:embed static/fe-project/dist
var embedFS embed.FS

它会在 go build 时,把 static/fe-project/dist 目录嵌入到最终的二进制文件中。

所以流程一般是:

cd static/fe-project
npm run build

cd ../../
go build -o ./tmp/main .
./tmp/main

需要注意:

  • embed 嵌入的是编译时已经存在的文件,所以必须先执行前端构建。
  • 前端文件变更后,需要重新执行 go build,否则二进制里还是旧的前端资源。
  • 这种方式部署简单,只需要一个二进制文件,但二进制体积会变大。
  • 如果前端资源很大,直接 fs.ReadFile 会每次把文件读入内存;普通后台管理系统、小型前端项目通常没问题。

两种方式对比

外挂资源:
后端二进制 + 前端 dist 目录一起部署。
优点是前端资源可以单独替换,后端不用重新编译。
缺点是部署时需要保证静态资源目录位置正确。

直接打包:
前端 dist 目录被嵌入 Go 二进制。
优点是部署简单,只需要发布一个后端可执行文件。
缺点是前端一旦变更,就需要重新 go build。