我写了十多年 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 ) app.mount ('#app' )
app.mount('#app') 执行后,Vue 接管了那个空 div,开始根据当前 URL 渲染页面。
1.3 路由守卫:判断是否已登录 Vue Router 配置了全局守卫:
1 2 3 4 5 6 7 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 ) 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 })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 }, err => { if (err.response ?.status === 401 ) { } } )
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 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(...) 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" ; } 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 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 frontendnpm install npm run build
npm run build 实际执行的是 vite build,它做了这些事:
编译 .vue 文件 :把 <template> 编译成 JS 渲染函数,把 <style> 提取成 CSS
处理 ES模块 :把 import/export 转成浏览器能执行的代码
Tree-shaking :去掉没用到的代码
Code-splitting :按路由拆分,每个页面一个 JS 文件(懒加载)
压缩混淆 :变量名缩短、去掉注释空白
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/backendCGO_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/frontendnpm 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 语法、组件化、响应式——那些是术,不是道。道就是这一张请求流转图,这篇文章讲的这些。