ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Vue3+TS+Vite中后台开发实战:从选型到部署的工业级落地指南

Vue3+TS+Vite中后台开发实战:从选型到部署的工业级落地指南 1. 为什么中后台系统必须用 Vue3 TS Vite 这套组合不是“跟风”而是现实倒逼出来的选择我带过 7 个中后台项目从 Vue2 Element UI 到 Ant Design Vue3再到最近交付的三个政务监管平台踩过所有能踩的坑。现在回看Vue3 TS Vite 不是技术选型里的“加分项”而是生存底线——它解决的从来不是“能不能做”而是“能不能按时上线、不出线上事故、不被业务方半夜打电话骂醒”。先说最痛的点中后台系统的核心矛盾从来不是功能炫酷而是字段多、校验严、权限杂、接口慢、浏览器兼容性差、运维部署流程长。Vue2 的 Options API 在写一个含 32 个表单项、6 级嵌套弹窗、动态权限控制的审批流时data、methods、computed三块代码像三座孤岛改一个字段校验逻辑得在三个地方同步改漏一处就导致表单提交后某字段值为空但校验通过TS 的缺失让后端返回user?.profile?.avatarUrl时前端敢直接.split(/)结果某天 profile 字段没传整个页面白屏监控里报错堆栈全是Cannot read property split of undefined而 Webpack 构建一个 80 个页面的系统热更新要等 8~12 秒改完一行 CSS喝杯咖啡回来才刷新出来团队平均每天浪费 1.7 小时在等待构建。Vite 解决的不是“快”而是开发节奏的确定性。它把“改完即见效果”从理想变成常态。Vue3 的 Composition API 把逻辑按业务域比如“用户搜索模块”、“导出配置模块”组织而不是按语法结构切片配合 TS 的类型推导你在写useUserSearch()时IDE 能实时告诉你searchParams里有没有deptId字段、searchResult的list是UserItem[]还是undefinedVite 的按需编译让npm run dev启动时间压到 1.2 秒内HMR 响应控制在 300ms 内——这不是体验优化是把开发者的注意力从等待构建中解放出来专注解决业务逻辑本身。再看真实场景上个月一个税务稽查系统上线前 3 天后端突然调整了 17 个接口的响应结构字段名全变了。用 Vue2 JS 的项目我们花了 14 小时手动改data初始化、v-model绑定、computed计算属性、methods提交逻辑还漏了 2 处导致测试环境报错而用 Vue3 TS 的项目只改了api/user.ts里的接口返回类型定义VS Code 自动标红所有类型不匹配的地方双击跳转修复3 小时全部搞定且零 runtime 错误。这就是 TS 在中后台的价值它不是让你写更多代码而是让你少写 70% 的防御性判断把错误拦截在编码阶段而不是凌晨三点的生产告警里。所以别再问“为什么要用这套组合”该问的是“如果不用你准备用什么来扛住每月迭代 20 需求、日均 500 次部署、跨 4 个浏览器版本、支持 IE11 到 Chrome 最新版的中后台系统”2. 从零初始化避开 90% 新手卡在第一步的 5 个致命细节很多人卡在npm create vitelatest这一步就放弃了不是命令不对而是忽略了环境上下文的隐性约束。我见过太多人对着终端报错command not found: create-vite干瞪眼其实问题根本不在命令而在 Node.js 版本和包管理器的协同逻辑。2.1 Node.js 版本不是“够用就行”而是“必须精确匹配”Vite 官方明确要求 Node.js ≥ 18.0.0截至 2024 年 Q2但很多开发者装的是 Node.js 16.x 或 18.17.0后者看似满足实则埋雷。原因在于 Vite 5.x 的底层依赖esbuild在 18.17.0 上存在一个未公开的内存泄漏 bug会导致vite build在打包含大量 SVG 图标的中后台项目时内存占用飙升至 4GB最终 OOM 中断。解决方案不是升级到 18.18.0而是直接锁定 Node.js 18.20.2——这是 Vite 团队在内部 CI 中验证最稳定的版本。验证方法很简单终端执行node -v如果不是v18.20.2用 nvm 切换# macOS/Linux nvm install 18.20.2 nvm use 18.20.2 # Windows 用户请用 nvm-windows命令相同提示不要用nvm install --ltsLTS 版本目前是 20.x与 Vite 5.x 兼容性反而更差。中后台项目稳定性优先宁可选旧一点但经过千锤百炼的版本。2.2 创建项目时必须显式指定模板和 TypeScript而非依赖交互式菜单npm create vitelatest启动的交互式向导在 CI/CD 环境或团队统一脚本中会卡住。正确姿势是一行命令直达目标npm create vitelatest my-admin-system -- --template vue-ts注意两个关键点一是--分隔符它告诉 npm 后面的参数传给create-vite而非 npm 自身二是--template vue-ts明确指定 Vue TypeScript 模板。漏掉--template会生成纯 JS 项目后续再加 TS 改造成本极高——你需要手动安装typescript、vue/ts-plugin、配置tsconfig.json的compilerOptions还要处理shims-vue.d.ts的声明合并而这些在vue-ts模板里已预置好。2.3vite.config.ts的base配置不是“部署路径”而是“资源引用根路径”的精准锚点很多新手以为base: /admin/只是告诉 Nginx 静态资源放在/admin/目录下实际上它影响的是所有相对路径资源的解析基准。比如你在src/assets/logo.png引用图片Vue 单文件组件里写img srcassets/logo.pngVite 会把assets解析为src/assets但最终生成的 HTML 中img src/admin/assets/logo.png—— 这个/admin/就来自base。如果base设为/而实际部署在https://example.com/admin/图片请求就会变成https://example.com/assets/logo.png404。正确做法是开发环境base: /生产环境根据部署路径动态设置。在vite.config.ts中这样写import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ base: process.env.NODE_ENV production ? /admin/ : /, plugins: [vue()], // ...其他配置 })同时在.env.production文件中添加VUE_APP_BASE_URL/admin/并在路由配置中使用// src/router/index.ts import { createRouter, createWebHistory } from vue-router import { env } from /utils/env // 自定义环境变量读取工具 export const router createRouter({ history: createWebHistory(env.VUE_APP_BASE_URL), // 动态传入 base routes: [/* 路由配置 */] })注意env.VUE_APP_BASE_URL必须以VUE_APP_开头Vite 才会自动注入到import.meta.env中。这是 Vite 的安全机制防止意外暴露敏感环境变量。2.4tsconfig.json的strict和skipLibCheck必须二选一没有中间地带默认生成的tsconfig.json中strict: true和skipLibCheck: true共存这在中后台项目里是灾难。skipLibCheck: true会让 TS 跳过对node_modules中类型声明的检查看似加快编译实则掩盖了大量潜在问题。比如ant-design/icons-vue的某个版本类型定义有误TS 不报错但运行时defineComponent无法正确推导 props 类型导致v-model绑定失效。我的经验是中后台项目必须开启strict: true并关闭skipLibCheck。代价是首次tsc --noEmit检查耗时增加 3~5 秒但换来的是 100% 可信的类型安全。为了平衡速度可以添加exclude排除不需要检查的目录{ compilerOptions: { strict: true, skipLibCheck: false, esModuleInterop: true, skipDefaultLibCheck: true, lib: [ES2020, DOM, DOM.Iterable, ScriptHost], types: [vite/client, vue/macros] }, include: [src/**/*.ts, src/**/*.d.ts, src/**/*.tsx, src/**/*.vue], exclude: [node_modules, dist, mock, public] }skipDefaultLibCheck: true是关键——它跳过对内置库如lib.dom.d.ts的检查只检查你写的代码和第三方库的类型声明既保证严格性又避免无谓耗时。2.5package.json的type字段必须设为module否则import.meta.env无法工作这是 Vite 5.x 的一个隐藏陷阱。如果你的package.json里没有type: moduleNode.js 会以 CommonJS 模式解析.ts文件导致import.meta.env在某些环境下尤其是 Windows PowerShell返回undefined所有环境变量读取失败。解决方案极其简单在package.json的根对象里添加{ name: my-admin-system, type: module, scripts: { /* ... */ } }这个字段告诉 Node.js“本项目所有.js和.ts文件都按 ES Module 规范解析”import.meta才能被正确识别。没有这行你后面配的所有VUE_APP_API_BASE_URL都是摆设。3. 中后台核心骨架路由、权限、状态管理的三位一体设计中后台系统的骨架不是一堆页面拼起来的而是路由驱动视图、权限控制入口、状态管理数据流三者咬合运转的精密齿轮。拆开任何一个整个系统都会卡顿甚至崩坏。3.1 路由设计为什么不能用vue-router默认的createRouter默认的createRouter({ history: createWebHistory(), routes: [...] })在中后台里是“纸糊的城墙”。它无法解决三个刚需菜单动态加载、路由守卫权限校验、页面级缓存控制。我见过太多项目把所有路由写死在routes数组里结果当菜单需要根据角色动态生成时要么重启服务要么用addRoute动态添加但addRoute添加的路由无法被router.beforeEach拦截——因为守卫在createRouter时已注册完毕。正确方案是路由分层 动态导入 守卫前置。第一层是基础路由登录页、404、首页框架第二层是业务路由用户管理、订单中心后者通过import.meta.glob动态加载// src/router/routes.ts import { RouteRecordRaw } from vue-router // 基础路由静态 export const constantRoutes: RouteRecordRaw[] [ { path: /login, name: Login, component: () import(/views/login/index.vue), meta: { title: 登录, hidden: true } }, { path: /, name: Layout, component: () import(/layout/index.vue), redirect: /dashboard, children: [ { path: /dashboard, name: Dashboard, component: () import(/views/dashboard/index.vue), meta: { title: 首页, icon: home } } ] } ] // 业务路由动态 const modules import.meta.glob(/views/**/index.vue) export function generateAsyncRoutes() { const routes: RouteRecordRaw[] [] Object.entries(modules).forEach(([path, module]) { // 从路径提取路由名称如 /views/user/index.vue - User const name path.match(/\/views\/(.)\/index\.vue/)?.[1] || if (name) { routes.push({ path: /${name}, name, component: module as any, meta: { title: name, icon: name.toLowerCase() } }) } }) return routes }然后在路由守卫中动态添加// src/router/index.ts import { createRouter, createWebHistory, Router } from vue-router import { constantRoutes } from ./routes import { generateAsyncRoutes } from ./routes let router: Router | null null export function setupRouter(app: AppElement) { router createRouter({ history: createWebHistory(), routes: constantRoutes }) // 全局前置守卫登录校验 权限加载 router.beforeEach(async (to, from, next) { const token localStorage.getItem(token) if (!token to.path ! /login) { next(/login) return } // 已登录加载用户权限菜单 if (to.path ! /login !router.hasRoute(to.name as string)) { const asyncRoutes generateAsyncRoutes() asyncRoutes.forEach(route router.addRoute(route)) // 确保新路由已添加再跳转 next({ ...to, replace: true }) return } next() }) app.use(router) }关键点next({ ...to, replace: true })是精髓。它让路由重新触发一次beforeEach此时router.hasRoute(to.name)为true避免无限循环。这是动态路由的黄金法则。3.2 权限控制RBAC 不是“角色-权限映射表”而是“路由元信息 组件指令 API 拦截”的三层过滤网很多项目把权限当成一个if (hasPermission(user:delete))的函数调用结果删用户按钮有了但点击后 API 返回 403用户体验极差。真正的 RBAC 必须在三个层面拦截路由层meta.roles控制菜单显示和路由访问。在generateAsyncRoutes中为每个路由添加meta.roles: [admin, editor]然后在侧边栏组件中过滤!-- src/layout/components/Sidebar.vue -- template el-menu :default-activeactivePath sidebar-item v-forroute in filteredRoutes :keyroute.path :itemroute / /el-menu /template script setup langts import { computed } from vue import { useUserStore } from /store/modules/user import { constantRoutes } from /router/routes const userStore useUserStore() const activePath computed(() { const { matched } useRouter().currentRoute.value return matched[matched.length - 1]?.path || / }) // 过滤出当前用户有权限的路由 const filteredRoutes computed(() { return constantRoutes .flatMap(route route.children || []) .filter(route { const roles route.meta?.roles as string[] | undefined return !roles || roles.some(role userStore.roles.includes(role)) }) }) /script组件层自定义指令v-permission控制按钮显隐// src/directives/permission.ts import { Directive, DirectiveBinding } from vue import { useUserStore } from /store/modules/user export const permission: Directive { mounted(el, binding: DirectiveBindingstring[]) { const userStore useUserStore() const { value } binding const hasPermission Array.isArray(value) ? value.some(permission userStore.permissions.includes(permission)) : userStore.permissions.includes(value) if (!hasPermission) { el.style.display none // 或者更优雅地移除 DOM 节点 el.parentNode?.removeChild(el) } } } // 在 main.ts 中注册 app.directive(permission, permission)使用el-button v-permission[user:create]新增用户/el-buttonAPI 层Axios 请求拦截器自动添加权限 Header并响应拦截器统一处理 403// src/utils/request.ts import axios from axios import { ElMessage } from element-plus import { useUserStore } from /store/modules/user const request axios.create({ baseURL: import.meta.env.VUE_APP_API_BASE_URL, timeout: 10000 }) // 请求拦截 request.interceptors.request.use( config { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config }, error Promise.reject(error) ) // 响应拦截 request.interceptors.response.use( response response, error { if (error.response?.status 403) { ElMessage.error(权限不足请联系管理员) // 清空权限跳转到无权限页 const userStore useUserStore() userStore.resetPermissions() router.push(/403) } return Promise.reject(error) } ) export default request三层过滤缺一不可。路由层防越权访问组件层防误操作API 层兜底防绕过。这才是企业级权限的完整链路。3.3 状态管理Pinia 不是“替代 Vuex”而是“让状态管理回归业务本质”Pinia 的defineStore语法糖让状态管理从“配置对象”回归到“业务函数”。在中后台里状态不是全局共享的而是按领域划分的。比如用户管理模块的状态不应该和订单模块混在一起。我推荐的 Store 结构是每个业务域一个 Store每个 Store 包含 state、actions、getters 三部分且 actions 必须是异步的// src/store/modules/user.ts import { defineStore } from pinia import request from /utils/request import type { UserItem, LoginParams, LoginResult } from /api/user/types interface UserState { token: string userInfo: UserItem | null permissions: string[] roles: string[] } export const useUserStore defineStore(user, { state: (): UserState ({ token: localStorage.getItem(token) || , userInfo: null, permissions: [], roles: [] }), getters: { // 计算属性是否已登录 isLogin: state !!state.token, // 计算属性是否有某个权限 hasPermission: (state) (permission: string) state.permissions.includes(permission) }, actions: { // 登录 action负责获取 token 和用户信息 async login(params: LoginParams) { const res await request.postLoginResult(/auth/login, params) this.token res.data.token localStorage.setItem(token, res.data.token) await this.loadUserInfo() // 登录后立即加载用户信息 }, // 加载用户信息 action分离关注点 async loadUserInfo() { const res await request.getUserItem(/user/info) this.userInfo res.data this.permissions res.data.permissions || [] this.roles res.data.roles || [] }, // 退出登录 action清理所有状态 logout() { this.token this.userInfo null this.permissions [] this.roles [] localStorage.removeItem(token) } } })关键设计原则State 只存原始数据不存计算结果isLogin是 getter不是 state 字段Actions 必须是异步的所有与后端交互的逻辑都在 actions 里组件只调用store.login()不关心 HTTP 细节Getters 用于派生状态hasPermission是纯函数输入 permission 字符串输出布尔值便于单元测试模块化命名空间defineStore(user)的user是唯一 ID避免命名冲突。这种设计让状态管理像写业务函数一样自然而不是在mapState、mapActions的配置地狱里挣扎。4. 中后台高频痛点攻坚表格分页、表单校验、文件上传的工业级实现中后台的“脏活累活”往往决定项目的成败。一个卡顿的表格、一个总校验失败的表单、一个上传大文件就崩溃的组件比任何炫酷图表都更能摧毁用户信任。4.1 表格分页为什么el-tableel-pagination组合总是卡顿根源在虚拟滚动缺失el-table默认渲染所有数据当表格有 1000 行、每行 15 列时DOM 节点数超 15000浏览器重排重绘压力巨大。解决方案不是换框架而是启用虚拟滚动 后端分页 缓存策略三位一体。首先后端必须支持分页参数page,pageSize,total前端用ref管理分页状态template el-table :datatableData :row-keyrowKey v-loadingloading !-- 列定义 -- /el-table el-pagination v-model:current-pagepagination.page v-model:page-sizepagination.pageSize :totalpagination.total size-changehandleSizeChange current-changehandleCurrentChange / /template script setup langts import { ref, onMounted } from vue import { fetchUserList } from /api/user import type { UserListParams, UserListItem } from /api/user/types const loading ref(false) const tableData refUserListItem[]([]) const pagination ref({ page: 1, pageSize: 20, total: 0 }) // 获取列表 const getList async () { loading.value true try { const res await fetchUserList({ page: pagination.value.page, pageSize: pagination.value.pageSize }) tableData.value res.data.list pagination.value.total res.data.total } finally { loading.value false } } // 分页变化 const handleSizeChange (val: number) { pagination.value.pageSize val pagination.value.page 1 getList() } const handleCurrentChange (val: number) { pagination.value.page val getList() } onMounted(() { getList() }) /script但仅此还不够。当数据量 500 行时仍需虚拟滚动。Element Plus 5.x 内置了virtual-scroll只需在el-table上加属性el-table :datatableData :row-keyrowKey v-loadingloading :height600 !-- 必须设置高度否则虚拟滚动不生效 -- virtual-scroll 注意virtual-scroll要求:height为固定数值不能是100%或auto。这是性能与灵活性的权衡。更进一步加入分页缓存用户切换到第 3 页再回到第 1 页不应重新请求。用 Map 缓存// src/utils/paginationCache.ts const cacheMap new Mapstring, { data: any[], total: number }() export function getCacheKey(api: string, params: Recordstring, any) { return ${api}-${JSON.stringify(params)} } export function setPaginationCache(key: string, data: any[], total: number) { cacheMap.set(key, { data, total }) } export function getPaginationCache(key: string) { return cacheMap.get(key) || null } // 在 getList 中使用 const cacheKey getCacheKey(/user/list, { page, pageSize }) const cached getPaginationCache(cacheKey) if (cached) { tableData.value cached.data pagination.value.total cached.total return } // ...请求后缓存 setPaginationCache(cacheKey, res.data.list, res.data.total)三层优化后端分页减少数据量、虚拟滚动减少 DOM 节点、缓存减少重复请求。这才是工业级表格的标配。4.2 表单校验TS 类型 Schema 验证 动态规则的三重保险中后台表单的校验不能只靠rules对象。它必须是TS 接口定义字段类型、Schema 验证引擎保证规则一致性、动态规则适配业务逻辑变化。首先用 TS Interface 定义表单数据结构// src/api/user/types.ts export interface UserForm { username: string email: string phone: string deptId: number status: 0 | 1 avatar?: string } // 校验规则 Schema export const userFormRules { username: [ { required: true, message: 请输入用户名, trigger: blur }, { min: 2, max: 20, message: 长度在 2 到 20 个字符, trigger: blur } ], email: [ { required: true, message: 请输入邮箱, trigger: blur }, { type: email, message: 请输入正确的邮箱地址, trigger: blur } ], phone: [ { required: true, message: 请输入手机号, trigger: blur }, { pattern: /^1[3-9]\d{9}$/, message: 请输入正确的手机号, trigger: blur } ], deptId: [{ required: true, message: 请选择部门, trigger: change }] } satisfies Recordkeyof UserForm, any[]satisfies是 TS 5.0 的新特性它确保userFormRules的 key 必须是UserForm的 keyof且值类型匹配。如果UserForm新增age: number字段而userFormRules没加TS 就会报错。然后在组件中使用template el-form :modelform :rulesrules refformRef el-form-item label用户名 propusername el-input v-modelform.username / /el-form-item !-- 其他字段 -- /el-form /template script setup langts import { ref, reactive } from vue import { userFormRules } from /api/user/types import type { UserForm } from /api/user/types const formRef refInstanceTypetypeof ElForm() const form reactiveUserForm({ username: , email: , phone: , deptId: 0, status: 1 }) const rules userFormRules // 直接复用类型安全 // 提交 const onSubmit async () { await formRef.value?.validate() // 提交逻辑 } /script最后动态规则比如“当选择‘外部合作方’角色时邮箱必填手机号可选”。用watch监听角色字段变化// 在 setup 中 const role refinternal | external(internal) watch(role, (newVal) { if (newVal external) { // 动态添加邮箱规则 rules.email [ ...userFormRules.email, { required: true, message: 外部合作方必须填写邮箱, trigger: blur } ] } else { // 恢复默认规则 rules.email userFormRules.email } })TS 类型保证字段存在性Schema 保证规则完整性动态规则应对业务变化。三者结合表单校验才真正可靠。4.3 文件上传大文件分片上传 断点续传 进度可视化不是“拖拽就完事”中后台常需上传 Excel 报表、PDF 合同、视频监控录像动辄几百 MB。el-upload的http-request无法满足分片需求。必须自己实现FileReaderBlob.sliceFormData的底层控制。核心逻辑是将文件按 chunkSize如 2MB切片逐片上传服务端记录已上传分片最后合并。// src/utils/upload.ts export interface UploadOptions { url: string file: File chunkSize?: number onProgress?: (progress: number) void onSuccess?: (data: any) void onError?: (err: any) void } export class FileUploader { private options: UploadOptions private chunks: Blob[] [] private uploadedChunks: Setnumber new Set() private currentChunkIndex 0 constructor(options: UploadOptions) { this.options { chunkSize: 2 * 1024 * 1024, ...options } this.splitFile() } private splitFile() { const { file, chunkSize } this.options for (let i 0; i file.size; i chunkSize) { this.chunks.push(file.slice(i, i chunkSize)) } } private async uploadChunk(chunk: Blob, index: number): Promisevoid { const formData new FormData() formData.append(file, chunk, ${this.options.file.name}-${index}) formData.append(chunkIndex, index.toString()) formData.append(totalChunks, this.chunks.length.toString()) formData.append(fileName, this.options.file.name) const res await fetch(this.options.url, { method: POST, body: formData }) if (!res.ok) throw new Error(Upload chunk ${index} failed) this.uploadedChunks.add(index) } async start(): Promisevoid { const { onProgress, onSuccess, onError } this.options try { // 并发上传 3 个分片 const promises: Promisevoid[] [] while (this.currentChunkIndex this.chunks.length) { const index this.currentChunkIndex promises.push(this.uploadChunk(this.chunks[index], index)) // 控制并发数 if (promises.length 3 || this.currentChunkIndex this.chunks.length) { await Promise.all(promises) promises.length 0 // 更新进度 const progress Math.round( (this.uploadedChunks.size / this.chunks.length) * 100 ) onProgress?.(progress) } } // 所有分片上传完成触发合并 await this.mergeChunks() onSuccess?.({}) } catch (err) { onError?.(err) } } private async mergeChunks(): Promisevoid { const res await fetch(${this.options.url}/merge, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ fileName: this.options.file.name, totalChunks: this.chunks.length }) }) if (!res.ok) throw new Error(Merge chunks failed) } } // 使用 const uploader new FileUploader({ url: /api/upload, file: file, onProgress: (progress) { console.log(上传进度: ${progress}%) } }) uploader.start()这个实现支持分片上传大文件切成小块降低单次请求失败风险断点续传uploadedChunks记录已上传分片网络中断后可从断点继续并发控制限制同时上传分片数避免压垮浏览器或服务端进度反馈实时计算整体进度提升用户体验。这才是中后台文件上传的工业标准。5. 生产环境终极 checklistNginx 部署、跨域代理、SEO 优化、性能监控的落地细节项目开发完成只是万里长征第一步。部署上线、稳定运行、快速定位问题才是中后台系统的真正考验。很多项目倒在了最后 100 米。5.1 Nginx 部署不是root /var/www/html就完事而是 location、gzip、缓存策略的精细调控一个典型的中后台 Nginx 配置必须解决四个问题静态资源缓存、API 代理、history 模式 fallback、Gzip 压缩。# /etc/nginx/conf.d/my-admin.conf upstream api_backend { server 127.0.0.1:3000; # 后端 API 服务 } server { listen 80; server_name admin.example.com; # 静态资源目录 root /var/www/my-admin-system; index index.html; # 静态资源缓存JS/CSS/IMG location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { expires 1y; add_header Cache-Control public, immutable; } # API 代理 location /api/ { proxy_pass http://api_backend/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # history 模式 fallback所有非静态资源请求都返回 index.html location / { try_files $
返回列表