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[]还是undefined;Vite 的按需编译让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 vite@latest这一步就放弃了,不是命令不对,而是忽略了环境上下文的隐性约束。我见过太多人对着终端报错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 --lts,LTS 版本目前是 20.x,与 Vite 5.x 兼容性反而更差。中后台项目稳定性优先,宁可选旧一点但经过千锤百炼的版本。
2.2 创建项目时,必须显式指定模板和 TypeScript,而非依赖交互式菜单
npm create vite@latest启动的交互式向导,在 CI/CD 环境或团队统一脚本中会卡住。正确姿势是一行命令直达目标:
npm create vite@latest 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 src="@assets/logo.png">,Vite 会把@assets解析为src/assets,但最终生成的 HTML 中,<img src="/admin/assets/logo.png">—— 这个/admin/就来自base。如果base设为/,而实际部署在https://example.com/admin/,图片请求就会变成https://example.com/assets/logo.png,404。
正确做法是:开发环境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": "module",Node.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: App<Element>) { 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-active="activePath"> <sidebar-item v-for="route in filteredRoutes" :key="route.path" :item="route" /> </el-menu> </template> <script setup lang="ts"> 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: DirectiveBinding<string[]>) { 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-button>
- API 层: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.post<LoginResult>('/auth/login', params) this.token = res.data.token localStorage.setItem('token', res.data.token) await this.loadUserInfo() // 登录后立即加载用户信息 }, // 加载用户信息 action:分离关注点 async loadUserInfo() { const res = await request.get<UserItem>('/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-table+el-pagination组合总是卡顿?根源在虚拟滚动缺失
el-table默认渲染所有数据,当表格有 1000 行、每行 15 列时,DOM 节点数超 15000,浏览器重排重绘压力巨大。解决方案不是换框架,而是启用虚拟滚动 + 后端分页 + 缓存策略三位一体。
首先,后端必须支持分页参数(page,pageSize,total),前端用ref管理分页状态:
<template> <el-table :data="tableData" :row-key="rowKey" v-loading="loading" > <!-- 列定义 --> </el-table> <el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.pageSize" :total="pagination.total" @size-change="handleSizeChange" @current-change="handleCurrentChange" /> </template> <script setup lang="ts"> import { ref, onMounted } from 'vue' import { fetchUserList } from '@/api/user' import type { UserListParams, UserListItem } from '@/api/user/types' const loading = ref(false) const tableData = ref<UserListItem[]>([]) 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 :data="tableData" :row-key="rowKey" v-loading="loading" :height="600" <!-- 必须设置高度,否则虚拟滚动不生效 --> virtual-scroll >注意:
virtual-scroll要求:height为固定数值,不能是100%或auto。这是性能与灵活性的权衡。
更进一步,加入分页缓存:用户切换到第 3 页,再回到第 1 页,不应重新请求。用 Map 缓存:
// src/utils/paginationCache.ts const cacheMap = new Map<string, { data: any[], total: number }>() export function getCacheKey(api: string, params: Record<string, 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 Record<keyof UserForm, any[]>satisfies是 TS 5.0+ 的新特性,它确保userFormRules的 key 必须是UserForm的 keyof,且值类型匹配。如果UserForm新增age: number字段,而userFormRules没加,TS 就会报错。
然后,在组件中使用:
<template> <el-form :model="form" :rules="rules" ref="formRef"> <el-form-item label="用户名" prop="username"> <el-input v-model="form.username" /> </el-form-item> <!-- 其他字段 --> </el-form> </template> <script setup lang="ts"> import { ref, reactive } from 'vue' import { userFormRules } from '@/api/user/types' import type { UserForm } from '@/api/user/types' const formRef = ref<InstanceType<typeof ElForm>>() const form = reactive<UserForm>({ username: '', email: '', phone: '', deptId: 0, status: 1 }) const rules = userFormRules // 直接复用,类型安全 // 提交 const onSubmit = async () => { await formRef.value?.validate() // 提交逻辑 } </script>最后,动态规则:比如“当选择‘外部合作方’角色时,邮箱必填,手机号可选”。用watch监听角色字段变化:
// 在 setup 中 const role = ref<'internal' | '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无法满足分片需求。必须自己实现FileReader+Blob.slice+FormData的底层控制。
核心逻辑是:将文件按 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: Set<number> = 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): Promise<void> { 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(): Promise<void> { const { onProgress, onSuccess, onError } = this.options try { // 并发上传 3 个分片 const promises: Promise<void>[] = [] 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(): Promise<void> { 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. 生产环境终极 checklist:Nginx 部署、跨域代理、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 $