后端工程师学前端:从CCS项目搞懂前后端分离全流程

我写了十多年 Go 后端。CCS(内容创作系统)这个项目,前端是用 AI 生成的,我能看懂大概,但始终有种”隔了一层”的感觉。相信很多后端同行都有类似体会:会写 API,会配 Nginx,但前端从写代码到浏览器里跑起来,中间到底发生了什么,总有点模糊。

这篇文章以 CCS 的真实代码为例,把前端这件事一次讲透。不讲 Vue 语法,只讲流程和架构——后端工程师最关心的那些事。

一、整体流程:从浏览器输入密码到进入系统

以 CCS 的登录为例,用一张图概括整个过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
浏览器                    Nginx                     Go后端
| | |
| 1. 输入 ccs.kentonly.com | |
|------------------------->| |
| 2. 返回 index.html | |
|<-------------------------| |
| 3. 加载 JS/CSS (assets) | |
|------------------------->| |
|<-------------------------| |
| 4. Vue应用启动,渲染登录页 | |
| | |
| 5. 输入用户名密码,点登录 | |
| POST /api/v1/auth/login |
|------------------------->| 6. 转发到后端:8082 |
| |------------------------->|
| | 7. 查MySQL,生成JWT |
| |<-------------------------|
| | 8. 返回JSON {token,...} |
|<-------------------------| |
| 9. JS存token到localStorage |
| 10. 路由跳转到 /contents | |
| 11. 加载内容列表页 | |
| GET /api/v1/contents | |
|------------------------->|------------------------->|
|<-------------------------|<-------------------------|
| 12. 渲染内容列表 | |

逐步拆解。

1.1 浏览器请求页面

你在浏览器输入 ccs.kentonly.com,浏览器发出 GET / 请求。这个请求先到达 Nginx。

Nginx 配置里有这么一行:

1
2
3
location / {
try_files $uri $uri/ /index.html;
}

try_files 的意思是:先找文件 $uri,找不到就找目录 $uri/,都找不到就返回 index.html。对于 GET /,直接命中 index.html

这个 index.html 就是前端打包后的入口文件,长这样:

1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<title>内容创作系统</title>
<script type="module" crossorigin src="/assets/index-DYmUXCPZ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B68ugBY9.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

注意那个 <div id="app"></div>——空的。Vue 应用会挂载到这个 div 上,把整个页面”塞”进去。这跟后端模板渲染(Go template、JSP)完全不同:HTML 只是个空壳,真正的页面内容由 JavaScript 生成

1.2 浏览器加载 JS

浏览器看到 <script src="/assets/index-DYmUXCPZ.js">,再发一个请求拿这个 JS 文件。这个文件就是前端所有代码打包后的产物——Vue 框架、业务代码、Element Plus 组件库,全在里面。

JS 下载完毕后,浏览器执行它。入口是 main.js(已被打包进那个大 JS 文件):

1
2
3
4
5
6
7
8
9
10
11
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import App from './App.vue'
import router from './router'

const app = createApp(App)
app.use(createPinia()) // 状态管理
app.use(router) // 路由
app.use(ElementPlus) // UI组件库
app.mount('#app') // 挂载到 <div id="app">

app.mount('#app') 执行后,Vue 接管了那个空 div,开始根据当前 URL 渲染页面。

1.3 路由守卫:判断是否已登录

Vue Router 配置了全局守卫:

1
2
3
4
5
6
7
// router/index.js
router.beforeEach((to) => {
const auth = useAuthStore()
if (!to.meta.public && !auth.token) {
return '/login' // 没登录,跳转到登录页
}
})

你第一次访问,localStorage 里没有 token,所以被重定向到 /login,渲染登录组件。

1.4 登录页:用户输入并提交

登录页 Login.vue 的核心逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<template>
<el-form :model="form" @keyup.enter="submit">
<el-input v-model="form.username" placeholder="用户名" />
<el-input v-model="form.password" type="password" />
<el-button @click="submit" :loading="loading">登录</el-button>
</el-form>
</template>

<script setup>
import { useAuthStore } from '../stores/auth'

async function submit() {
await auth.login(form.value.username, form.value.password)
router.push('/') // 登录成功,跳转首页
}
</script>

v-model="form.username" 是 Vue 的双向绑定:输入框里打字,form.username 自动更新。点登录时调用 auth.login()

1.5 API 调用链:从组件到 HTTP

auth.login() 的调用链,是理解前端架构的关键。一层一层看:

第一层:Pinia Store(状态管理)——stores/auth.js

1
2
3
4
5
async function login(username, password) {
const res = await authApi.login(username, password)
setAuth(res.access_token, res.user) // 存到 localStorage
return res
}

Store 负责”状态”——登录后 token 存哪、怎么取、怎么清。它不直接发 HTTP 请求,而是调用 API 层。

第二层:API 封装——api/auth.js

1
2
3
4
5
import http from './index'

export const authApi = {
login: (username, password) => http.post('/auth/login', { username, password }),
}

API 层负责定义”调哪个接口”。注意 URL 是 /auth/login,不是完整路径——因为 http 实例配了 baseURL: '/api/v1',最终请求路径是 /api/v1/auth/login

第三层:Axios 实例——api/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const http = axios.create({ baseURL: '/api/v1', timeout: 30000 })

// 请求拦截器:自动带 token
http.interceptors.request.use(config => {
const token = localStorage.getItem('access_token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})

// 响应拦截器:统一处理错误
http.interceptors.response.use(
res => {
if (res.data.code !== 0) {
ElMessage.error(res.data.message)
return Promise.reject(new Error(res.data.message))
}
return res.data.data // 剥掉外层包装,直接返回 data
},
err => {
if (err.response?.status === 401) {
// token过期,尝试refresh,失败则跳转登录
}
}
)

Axios 实例是所有 HTTP 请求的统一出口。拦截器做了两件事:

  • 请求拦截:自动从 localStorage 取 token 塞进 Header(后端工程师可以理解为中间件)
  • 响应拦截:统一拆包,业务代码拿到的直接是 data 字段,不用每次写 res.data.data

1.6 后端处理:Go Gin

请求到达后端,走的是熟悉的 Go 流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// handler/auth.go
func (h *AuthHandler) Login(c *gin.Context) {
var req struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
c.ShouldBindJSON(&req) // 解析参数
result, err := h.svc.Login(...) // 调 service
if err != nil {
response.Fail(c, 200, 401, err.Error())
return
}
c.SetCookie("refresh_token", result.RefreshToken, ...)
response.OK(c, gin.H{
"access_token": result.AccessToken,
"user": gin.H{...},
})
}

后端返回的 JSON 格式是统一的:

1
2
3
4
5
6
7
8
{
"code": 0,
"message": "ok",
"data": {
"access_token": "eyJhbGci...",
"user": { "id": 1, "username": "admin" }
}
}

前端响应拦截器会检查 code:0 表示成功,非 0 弹错误提示。成功的 data 字段会被剥离出来,返回给 Store。

1.7 登录完成:token 存储 + 页面跳转

Store 拿到 token 后:

1
2
3
4
5
function setAuth(accessToken, userInfo) {
token.value = accessToken
localStorage.setItem('access_token', accessToken)
localStorage.setItem('user', JSON.stringify(userInfo))
}

localStorage 是浏览器的本地存储,关页面也不会丢。之后每次 API 请求,Axios 拦截器都会从 localStorage 取 token 放到 Header 里。

然后 router.push('/') 触发路由跳转。这次有 token 了,路由守卫放行,渲染 Layout 组件——侧边栏 + 内容区,CCS 系统主界面出来了。


二、Nginx 的作用

后端工程师对 Nginx 不陌生,但前后端分离场景下,Nginx 的角色有点变化。

2.1 静态文件服务器

传统后端渲染,Nginx 主要做反向代理,把请求转给后端。前后端分离后,Nginx 还多了一个角色:静态文件服务器

前端打包后就是一堆静态文件(HTML、JS、CSS、图片),Nginx 直接把这些文件返回给浏览器,不需要经过后端。

2.2 反向代理

API 请求需要转发给后端。看 CCS 的 Nginx 配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
server {
listen 80;
server_name ccs.kentonly.com;
root /var/www/ccs.kentonly.com;
index index.html;

# 静态文件:前端页面
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, must-revalidate";
}

# API 请求:转发给后端
location /api {
proxy_pass http://172.28.247.174:8082;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 300s;
}
}

两个 location,职责分明:

请求路径 匹配规则 去向
//assets/xxx.js location / Nginx 直接返回静态文件
/api/v1/auth/login location /api 转发到 Go 后端 :8082

2.3 为什么不能前端直接连后端

前端代码里 Axios 的 baseURL/api/v1——相对路径,没有域名和端口。

如果浏览器从 ccs.kentonly.com 加载页面,Ajax 请求也发到 ccs.kentonly.com/api/v1/...。如果没有 Nginx,这个请求没人处理。

有了 Nginx,请求到 80 端口,Nginx 根据 /api 前缀转发到后端。浏览器始终只跟一个域名通信,不存在跨域问题。

开发模式下,Vite 的 proxy 配置做同样的事:把 /api 转发到 localhost:8080

2.4 SPA 路由的兜底

1
2
3
location / {
try_files $uri $uri/ /index.html;
}

最后那个 /index.html 很关键。Vue Router 用的是 History 模式,URL 长这样:ccs.kentonly.com/contents/create

如果用户刷新页面,浏览器向 Nginx 请求 /contents/create,但服务器上没有这个文件——这是一个前端路由,不是物理文件。try_files 找不到文件就返回 index.html,让 Vue Router 自己在前端处理路由。


三、前端代码如何到达浏览器、如何跟后端通信

3.1 代码到达浏览器的过程

1
源码 (.vue/.js)  →  Vite打包  →  dist/  →  复制到Nginx  →  浏览器下载

开发时和上线后,代码到达浏览器的方式不同:

开发模式(npm run dev

Vite 启动一个开发服务器(端口 80),你访问 localhost,Vite 实时编译 .vue 文件,按需把模块转成浏览器能执行的 JS。改代码自动刷新(HMR)。这个阶段代码不落盘,全在内存里。

生产模式(npm run build

Vite 把所有 .vue.js.css 编译、压缩、打包成静态文件,输出到 dist/ 目录。然后把这些文件复制到 Nginx 的网站目录,Nginx 直接当静态文件返回。

3.2 前后端通信:HTTP + JSON

前端和后端之间唯一的通信方式就是 HTTP 请求 + JSON 响应。没有魔法。

以获取内容列表为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
前端                              后端
| |
| GET /api/v1/contents |
| Header: Authorization: Bearer xxx
|--------------------------------->|
| | 1. JWT中间件验证token
| | 2. handler调service
| | 3. service调repository
| | 4. repository查MySQL
| | 5. 返回[]Content
|<---------------------------------|
| 200 OK |
| {"code":0,"data":[{...},{...}]} |
| |
| Axios拦截器剥掉外层,返回data数组 |
| Vue用数据渲染表格 |

跟 Postman 调接口一模一样,只是这个过程被封装在 Axios 里,对前端开发者透明。

3.3 开发时的跨域问题

开发时前端在 localhost:80,后端在 localhost:8080,端口不同,浏览器会拦截请求(同源策略)。

Vite 的 proxy 解决这个问题:

1
2
3
4
5
6
7
8
9
// vite.config.js
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}

前端请求 localhost/api/v1/...,Vite 开发服务器拦截 /api 开头的请求,转发到 localhost:8080。跟 Nginx 的 proxy_pass 一个道理。

上线后不存在这个问题——Nginx 同时服务静态文件和代理 API,浏览器只跟 ccs.kentonly.com:80 通信。


四、前端编译打包流程:到底发布的是什么

这是后端工程师最困惑的部分。后端发布很简单:go build 产出一个二进制,扔到服务器跑起来。前端呢?

4.1 前端源码长什么样

CCS 前端的源码目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
frontend/
├── index.html # 入口HTML(空壳)
├── package.json # 依赖声明(类似go.mod)
├── vite.config.js # 构建配置
├── src/
│ ├── main.js # JS入口
│ ├── App.vue # 根组件
│ ├── router/index.js # 路由配置
│ ├── api/ # API封装(Axios)
│ ├── stores/ # 状态管理(Pinia)
│ ├── views/ # 页面组件
│ └── components/ # 通用组件
└── node_modules/ # 依赖(类似vendor,但不入库)

.vue 文件浏览器不认识,必须经过编译。

4.2 编译打包过程

1
2
3
cd frontend
npm install # 安装依赖(类似 go mod download)
npm run build # 打包(类似 go build)

npm run build 实际执行的是 vite build,它做了这些事:

  1. 编译 .vue 文件:把 <template> 编译成 JS 渲染函数,把 <style> 提取成 CSS
  2. 处理 ES模块:把 import/export 转成浏览器能执行的代码
  3. Tree-shaking:去掉没用到的代码
  4. Code-splitting:按路由拆分,每个页面一个 JS 文件(懒加载)
  5. 压缩混淆:变量名缩短、去掉注释空白
  6. Hash命名:文件名加内容哈希,如 index-DYmUXCPZ.js

4.3 打包产物

打包后生成 dist/ 目录:

1
2
3
4
5
6
7
8
9
dist/
├── index.html # 467字节,入口HTML
└── assets/
├── index-DYmUXCPZ.js # 主包(Vue框架+公共代码),1.2MB
├── index-B68ugBY9.css # 主样式,357KB
├── Login-C6TowbTf.js # 登录页(懒加载,访问时才下载)
├── ContentList-Bue.js # 内容列表页
├── ContentDetail-CPo.js # 内容详情页
└── ... # 每个页面一个JS+CSS

对比一下打包前后的 index.html

打包前(源码):

1
2
<div id="app"></div>
<script type="module" src="/src/main.js"></script>

打包后(dist):

1
2
3
<div id="app"></div>
<script type="module" crossorigin src="/assets/index-DYmUXCPZ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B68ugBY9.css">

Vite 把 /src/main.js 替换成了打包后的 /assets/index-DYmUXCPZ.js,并自动加上了 CSS 链接。

4.4 后端发布 vs 前端发布

后端(Go) 前端(Vue)
源码 .go 文件 .vue / .js 文件
编译 go build npm run build (vite build)
产物 一个二进制文件 dist/ 目录(HTML + JS + CSS)
运行环境 服务器进程 浏览器
部署方式 二进制 + 配置文件 → 启动进程 静态文件 → Nginx 托管
更新方式 替换二进制,重启进程 替换 dist 目录,Nginx reload
配置文件 config.yaml 编译时写死(环境变量通过构建参数注入)

核心区别:后端发布是”部署一个运行的程序”,前端发布是”部署一堆静态文件”。前端没有进程,没有运行时,就是一堆被 Nginx 直接返回给浏览器的文件。

4.5 CCS 的完整发布流程

1
2
3
4
5
6
7
8
9
10
11
12
13
# 后端:编译 + 部署
cd /root/github.com/ccs/backend
CGO_ENABLED=0 go build -o server ./cmd/server
docker cp server ccs-backend:/app/ccs-server
docker restart ccs-backend

# 前端:编译 + 部署
cd /root/github.com/ccs/frontend
npm install
npm run build
rm -rf /root/nginx-docker/websites/ccs.kentonly.com/*
cp -r dist/* /root/nginx-docker/websites/ccs.kentonly.com/
docker exec website-nginx nginx -s reload

后端三步:编译 → 复制二进制 → 重启容器。
前端四步:安装依赖 → 编译打包 → 替换静态文件 → Nginx reload。


总结

用一张表把四个问题串起来:

问题 答案
整体流程 浏览器拿 HTML → 加载 JS → Vue 渲染页面 → Axios 调 API → Nginx 转发后端 → 后端返回 JSON → Vue 更新页面
Nginx 的作用 静态文件服务器 + API 反向代理 + SPA 路由兜底
代码如何到达浏览器 源码经 Vite 编译打包成静态文件,Nginx 直接返回给浏览器
前端发布什么 发布 dist/ 目录——一堆被 Nginx 托管的 HTML/JS/CSS 静态文件

如果你也是后端工程师,理解了这些,再去看前端代码就不会觉得陌生了。Vue 组件的 <template> 就是 HTML,<script> 就是 JavaScript,Axios 就是你用 Postman 调接口的代码版,Nginx 配置你已经会了。

剩下的 Vue 语法、组件化、响应式——那些是术,不是道。道就是这一张请求流转图,这篇文章讲的这些。