1. 手写响应式内核:用原生 Proxy 复刻 reactive、ref、effect、computed
写这个系列的前两篇时,我把重点放在了环境搭建、项目生成、组件通信这些"能跑起来"的事情上。到了第三篇,话题得往深里走一层。因为进入真实项目之后,你迟早会遇到一类问题:数据明明改了,视图没动;或者视图动了,控制台里的值还是旧的;又或者一个 computed 莫名其妙不更新了。这时候光翻文档是没用的,你得知道Vue 的响应式到底是怎么把"改数据"和"更新 UI"这两件事串起来的。这一章我们就脱离框架源码,用原生 JS 的Proxy和闭包,把 reactive、ref、effect、computed 这四个核心模块各写一遍最小可运行版本。写完之后再回头看 Vue 的行为,很多"玄学"会瞬间变成"理所当然"。
1.1 为什么第三篇要碰内核级的东西
我的判断标准很简单:一个概念,如果你只会用,出问题时你只能靠猜;如果你知道它内部怎么实现,出问题时你能直接定位到是哪一步断了。响应式就是典型的这种概念。它是 Vue 的命脉,data、props、computed、watch、模板上的插值,全都挂在同一套依赖收集与触发更新的机制上。
把话说透一点:Vue 的响应式本质上是依赖收集 + 派发更新两步。所谓依赖收集,就是"谁读了我的值,我就记住谁";所谓派发更新,就是"我改了值,我去通知所有读过我的人重新执行"。听起来像废话,但真正理解它,你才能明白为什么watch里拿到的旧值是引用类型会"变",为什么在setup里解构props会丢响应式——这些都是同一个机制的副作用。
我自己刚入行那会儿,面试被问到"Vue2 用Object.defineProperty、Vue3 用Proxy,区别在哪",我能背出"Proxy 能监听数组下标、能监听新增属性、是惰性的"这几条,但心里是虚的。直到我真的用Proxy把一个迷你响应式系统拼出来,才算是把这口气理顺了。所以这一章不是炫技,它是后面所有章节的底座。
1.2 四个模块的最小可运行实现
先说整体设计。整个系统只靠三样东西:一个全局变量记"当前正在执行的副作用函数"、一个WeakMap存"对象 → 属性 → 依赖集合"的映射、两个函数track(收集)和trigger(触发)。effect负责把一段函数包装成"可被追踪的副作用",reactive负责把普通对象变成 Proxy 代理,ref负责处理基本类型,computed则是"带缓存的惰性 effect"。
为什么要用WeakMap而不是普通对象?因为它对键是弱引用,一旦某个响应式对象不再被使用,它对应的依赖映射会被自动回收,不会造成内存泄漏。这个细节想清楚,你就理解了为什么 Vue 内部到处都是WeakMap。
先搭骨架:
// 记录当前正在执行的副作用函数 let activeEffect = null // 副作用函数栈,处理嵌套 effect const effectStack = [] // 对象 -> 属性 -> 依赖集合 const targetMap = new WeakMap() function track(target, key) { // 没有活跃 effect,说明这次读取发生在响应式系统之外,忽略 if (!activeEffect) return let depsMap = targetMap.get(target) if (!depsMap) { depsMap = new Map() targetMap.set(target, depsMap) } let dep = depsMap.get(key) if (!dep) { dep = new Set() depsMap.set(key, dep) } dep.add(activeEffect) } function trigger(target, key) { const depsMap = targetMap.get(target) if (!depsMap) return const dep = depsMap.get(key) if (!dep) return // 复制一份再遍历,避免触发过程中集合被修改 new Set(dep).forEach(effect => { if (effect !== activeEffect) { effect() } }) }上面这段是整个系统的地基。有个点值得强调:trigger里为什么要用new Set(dep)做一次拷贝再遍历?因为副作用函数执行时很可能又会读写依赖,导致集合被增删,直接遍历原集合会出现"边遍历边改"的经典错误。Vue 源码里也做了类似的保护。
接下来是effect和reactive:
function effect(fn, options = {}) { const effectFn = () => { // 每次执行前先清空旧依赖,避免条件分支导致的依赖残留 cleanup(effectFn) activeEffect = effectFn effectStack.push(effectFn) const res = fn() effectStack.pop() activeEffect = effectStack[effectStack.length - 1] || null return res } effectFn.deps = [] effectFn.options = options if (!options.lazy) { effectFn() } return effectFn } function cleanup(effectFn) { for (const dep of effectFn.deps) { dep.delete(effectFn) } effectFn.deps.length = 0 } function reactive(target) { return new Proxy(target, { get(obj, key, receiver) { track(obj, key) return Reflect.get(obj, key, receiver) }, set(obj, key, value, receiver) { const oldValue = obj[key] const result = Reflect.set(obj, key, value, receiver) if (oldValue !== value) { trigger(obj, key) } return result } }) }cleanup这一步是很多人手写时会漏掉的。假设你的副作用里有个if分支,第一次走 A 分支读到了a,第二次条件变了走 B 分支读到了b,如果不清理,a的依赖集合里还留着这个副作用,之后改a会触发一次毫无意义的重新执行。这个坑我在自己的工具函数里踩过,表现是"改了一个跟当前视图无关的值,结果页面闪了一下"。
再说ref和computed:
function ref(value) { const wrapper = { get value() { track(wrapper, 'value') return value }, set value(newVal) { if (newVal !== value) { value = newVal trigger(wrapper, 'value') } } } return wrapper } function computed(getter) { let value let dirty = true const effectFn = effect(getter, { lazy: true, scheduler() { // 依赖变化时不立刻重算,只把脏标记立起来 dirty = true // 通知读取 computed 的那个副作用 trigger(obj, 'value') } }) const obj = { get value() { if (dirty) { value = effectFn() dirty = false } track(obj, 'value') return value } } return obj }到这里,四个模块就齐了。reactive处理对象,ref处理基本类型——为什么基本类型不能用Proxy?因为Proxy只能代理对象,数字、字符串是原始值,没法被代理,所以只能包一层带 getter/setter 的对象,这也是ref在模板里要写.value而reactive不用写的根本原因。computed靠dirty标记实现缓存,只有依赖变了、且有人再次读取value时才重新计算,这就是它比普通函数调用高效的地方。
1.3 手写过程中最容易卡住的三个点
第一个坑是嵌套 effect 的活跃指针错乱。如果你不维护一个 effect 栈,而是在每次执行完就把activeEffect置为 null,那么当组件 A 里嵌了组件 B 的渲染时,B 执行完后activeEffect会被清空,回到 A 继续执行时读到的值就收集不到依赖了。表现就是"父组件里某个值改了,子组件不更新"。用栈来存取,保证恢复时回到上一层,是最省心的做法。
第二个坑是依赖清理的时机。清理必须在副作用函数"本次执行之前",而不是"之后"。如果在之后清理,你刚收集好的依赖立刻就被自己删光了。这个顺序错误非常隐蔽,因为初始渲染是正常的,只有更新时才会出问题,调试起来会让人怀疑人生。
第三个坑是触发更新时的重入。当trigger遍历依赖集合执行副作用时,如果副作用里又修改了同一个值,就会形成无限递归。真实场景里watch回调里改回原值导致死循环,就是这类问题的变体。加一个"当前正在执行的 effect 不重复触发"的判断,能挡掉很大一部分。
提示:手写这套东西的目的不是替代框架,而是建立直觉。你不需要背代码,但你要能画出一张图:谁在收集、谁在被收集、谁负责通知。这张图画得出来,后面路由、状态管理、性能优化里的很多选择你都能自己想明白。
2. 路由进阶:参数传递、拦截器与权限落地
响应式的底子打好了,接下来讲几乎所有中后台项目都绕不开的东西——路由。入门阶段你可能只会写router.push('/detail/1'),但真正进入项目,问题会成倍冒出来:详情页刷新之后参数没了、用户退出登录后一直转圈、权限菜单渲染出错导致整个页面白屏。这一章把路由参数、拦截器、动态权限三块拆开讲。
2.1 三种传参方式的差异,以及刷新丢失的真正原因
Vue Router 里传参常见三种:query、params、以及把参数直接编进路径。它们的差别不只在写法,而在于刷新后能不能活下来。
| 方式 | 写法示例 | 刷新后是否保留 | 是否出现在地址栏 | 适用场景 |
|---|---|---|---|---|
| query | /list?page=2&kw=vue | 保留 | 是 | 分页、筛选、可分享的搜索态 |
| params | push({ name:'detail', params:{ id:1 } }) | 不保留 | 否 | 临时跳转、非持久态 |
| 路径参数 | /detail/:id配合/detail/1 | 保留 | 是 | 详情页、资源 ID 这类天然标识 |
很多人第一次遇到"刷新后params变空",会以为是自己写错了,其实是设计使然:params并没有被编进 URL,刷新相当于重新按 URL 解析路由,自然就丢了。解决办法有两种,要么改用路径参数,要么把关键标识放进query。我的习惯是:凡是刷新后还需要的数据,一律走 URL,也就是路径参数或 query,params只用来传那种"丢了也无所谓"的临时状态。
还有一种更隐蔽的情况:路径参数变了,但组件没重新创建。比如从/user/1跳到/user/2,同一个组件实例被复用,created不会二次触发。这时候得在watch里监听route.params.id:
import { watch } from 'vue' import { useRoute } from 'vue-router' const route = useRoute() watch( () => route.params.id, (newId) => { if (newId) fetchDetail(newId) }, { immediate: true } )immediate: true很关键,它保证首次进入也有数据。少了它,你会遇到"直接刷新能显示,点列表跳转反而不显示"这种诡异现象。
2.2 拦截器分层设计与"无限重定向"的成因
路由拦截器的核心作用是把"能不能进这个页面"和"进了之后干什么"这两件事分开。我一般分三层:全局前置守卫做登录态与权限判断,全局后置钩子做埋点、标题设置、进度条收尾,组件内守卫处理"离开前提示保存"这类页面级逻辑。
写全局守卫时最容易踩的坑是死循环。看一段有问题的写法:
router.beforeEach((to, from, next) => { const token = getToken() if (!token) { next('/login') // 问题在这里 } else { next() } })当用户访问/login且没有 token 时,守卫仍然会把目标重定向到/login,于是又触发一次守卫,无限循环,控制台刷屏报错。正确做法是给放行名单留个口子:
const whiteList = ['/login', '/404'] router.beforeEach(async (to, from, next) => { const token = getToken() if (token) { if (to.path === '/login') { next('/') } else { // 已有用户信息就直接放行,否则先拉一次 if (store.userInfo) { next() } else { try { await store.fetchUserInfo() next({ ...to, replace: true }) } catch (e) { next('/login') } } } } else { whiteList.includes(to.path) ? next() : next('/login') } })这里有个细节:next({ ...to, replace: true })而不是next()。原因是动态路由还没挂载时,直接next()会让这次导航继续,出现"页面白屏但地址变了"。用{ ...to, replace: true }相当于让 Vue Router 用补全后的路由表重新跑一遍导航,是官方文档里推荐的写法。
注意:
replace: true的意义是把这次重定向替换掉历史记录,用户点返回不会又退回登录页再弹回来,体验上干净很多。
2.3 动态路由 + 权限的落地骨架
中后台系统的权限通常分两层:菜单权限(能看到哪些入口)和按钮权限(页面上哪些操作可点)。菜单权限一般靠动态路由实现,思路是:前端先把所有页面写好,但只注册公共路由(登录、404 等);登录拿到用户信息后,根据后端返回的权限标识,从完整的路由表里筛出能访问的部分,用router.addRoute动态挂载。
有一个顺序问题必须记牢:动态路由挂载是异步的,守卫里必须等它挂完再放行。否则会出现"刷新页面 404,点导航却能进"的经典 bug——因为刷新时路由表还没挂好就开始了导航。把挂载逻辑放在守卫里await掉,就能稳稳解决。
按钮权限相对简单,做一个自定义指令:
// v-permission="'user:delete'" const permission = { mounted(el, binding) { const has = store.permissions.includes(binding.value) if (!has) { el.parentNode && el.parentNode.removeChild(el) } } }删除元素而不是隐藏,是因为隐藏的按钮仍然可能被手动调出来触发,删除更彻底。当然,前端权限永远只是体验层,真正的校验必须在后端做一遍,这一点我在几个项目里都吃过教训——曾经有个"隐藏"的按钮被测试同学用开发者工具改出来点了,差点出事故。
3. 状态管理选型:Pinia 与 Vuex 的真实差距
新手在做一个中等规模项目时,最纠结的问题之一就是:状态管理到底用 Pinia 还是 Vuex。这个问题在社区里被讨论了无数遍,但很多回答只停留在"Pinia 更简单"。这一章我想给你一套能直接做决策的判断依据,而不是又一篇"新比旧好"的复读。
3.1 心智模型与写法上的差别
先看两者最核心的差异。Vuex 的核心是mutation:改状态必须走commit提交 mutation,异步逻辑放action里。这套约束的思路是"让状态变更可追踪",但代价是写起来啰嗦——一个简单的加一,你要先定义 mutation,再定义 action,再在组件里 commit。
Pinia 去掉了 mutation,只剩下 state、getters、actions,改状态直接在 action 里赋值就行。同时它天然支持 TypeScript,类型推导几乎不用手写。看一个同样的计数器对比:
// Vuex 风格 const store = createStore({ state: () => ({ count: 0 }), mutations: { INCREMENT(state, payload) { state.count += payload } }, actions: { incrementAsync({ commit }, payload) { setTimeout(() => commit('INCREMENT', payload), 1000) } } }) // Pinia 风格 const useCounter = defineStore('counter', { state: () => ({ count: 0 }), actions: { increment(payload) { this.count += payload }, incrementAsync(payload) { setTimeout(() => { this.count += payload }, 1000) } } })差别一眼可见。Pinia 里this就是整个 store,改值、调别的 action 都很自然。更重要的是,Pinia 支持把多个 store 拆成模块文件,互相引入即可,不再需要 Vuex 那种命名空间namespaced: true的嵌套结构。嵌套结构在大型项目里最直接的问题就是"路径写起来又长又容易错",store.state.user.profile.info.name这种,改个模块名能全局炸一片。
3.2 选型决策与迁移路径
具体怎么选,我整理了一张表,你可以对号入座:
| 判断维度 | 建议选择 | 理由 |
|---|---|---|
| 全新 Vue3 项目 | Pinia | 官方推荐、类型友好、写法简洁 |
| 已有 Vuex 的大型项目,稳定运行 | 暂不迁移 | 迁移收益不足以覆盖回归风险 |
| 状态逻辑复杂、需要大量 TS 推导 | Pinia | 类型体验明显更好 |
| 团队对 Vuex 非常熟悉、无 Vue3 计划 | Vuex | 学习成本优先 |
| 需要 SSR、多模块动态注册 | 两者均可,Pinia 更轻 | Pinia 的模块注册更直观 |
如果确定要从 Vuex 迁到 Pinia,别想着一把梭。我的做法是按模块灰度迁移:先在 Pinia 里建一个新 store,新写的页面直接用 Pinia,老页面仍然读 Vuex。两个状态库在同一个项目里共存是没问题的,等新 store 稳定了,再逐个把老模块搬过来,最后删掉 Vuex。这样每一步都可回滚,不会出现"迁到一半发现某个依赖链炸了,全项目停摆"的局面。
3.3 store 拆分、持久化与调试技巧
store 的拆分原则,我总结成一句话:按业务域拆,不按数据类型拆。不要建一个userStore装用户相关的所有东西,又建一个orderStore装订单相关的所有东西,然后发现用户和订单耦合得很紧。更实际的做法是按页面或功能块拆,比如"登录鉴权""购物车""审批流",每个 store 内部把 state、actions、getters 都收齐,外部只暴露必要的操作。
持久化是另一个高频需求,比如登录 token、用户偏好设置。手写的话可以在 action 里手动写localStorage,但更省事的是用插件。这里说一个关键点:不要把所有 store 都持久化。像"弹窗是否打开""当前选中的 tab"这种纯 UI 状态,持久化反而会在刷新后呈现错误状态。合理做法是只对需要跨会话保留的字段做持久化,用一个pick白名单控制。
调试方面,Pinia 和 Vue Devtools 结合得很好,你能在面板里看到每个 store 的实时 state,甚至可以直接改值观察视图反应。我常用的一个小技巧是:在setup里用storeToRefs解构出 state,避免直接解构 store 丢响应式:
import { storeToRefs } from 'pinia' const userStore = useUserStore() // 错误:直接解构会丢响应式 // const { name } = userStore // 正确 const { name, avatar } = storeToRefs(userStore)这个点是新手最容易栽的地方,因为它在初始渲染时看不出来,只有 name 后续被改时才会发现视图不更新,排查起来很费时间。
4. 工程化落地:多环境构建、离线安装与线上布局异常
前面三章聊的都是"写代码"层面的事,这一章聊聊"写完代码之后"的事。因为一个项目能不能交付,往往不取决于你代码写得多漂亮,而取决于打包、部署、环境切换这些看起来"脏活累活"的环节。我见过太多项目本地跑得好好的,一进生产环境就各种姿势翻车。
4.1 环境变量与多环境构建的规范做法
Vite 项目里,环境变量靠import.meta.env读取,配置文件按.env、.env.development、.env.production区分。这里有一个必须记牢的命名规则:只有以VITE_开头的变量才会被注入到客户端代码里。这是安全设计,防止你把服务器密钥之类的变量不小心打进前端产物。
一个规范的环境文件长这样:
# .env.production VITE_API_BASE_URL=https://api.example.com VITE_APP_TITLE=管理后台然后配合package.json里的脚本:
{ "scripts": { "dev": "vite", "build:prod": "vite build --mode production", "build:test": "vite build --mode staging" } }有个细节容易被忽略:环境变量在构建时被替换成静态字符串,而不是运行时读取。也就是说,同一份打包产物没法通过改环境变量来切换后端地址。如果你的部署流程需要"一次打包、多环境部署",就不能依赖构建期变量,得换成运行时方案——比如在入口 HTML 里注入一个全局配置对象,或者部署后由后端返回配置。这个坑我在一次大版本发布时踩过,当时是先打了一个测试包,临时想复用上线,结果前端请求全打到了测试服务器。
4.2 打包后布局异常的排查清单
"本地好好的,打包后布局乱了",这个问题我排过很多次,原因就那么几类,按出现频率排序:
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 样式全丢 | 样式被摇树裁掉或按需引入配置不当 | 检查 UI 库按需插件配置 |
| 字体图标变方框 | 字体文件路径未随打包处理 | 用url()引入并确认assetsInclude |
| 某组件样式覆盖全局 | 组件内样式未加scoped | 加scoped或用唯一的根类名 |
| 布局宽度错乱 | 第三方库与项目使用的像素基准不一致 | 统一 rem / px 基准与缩放方案 |
| 只在低版本浏览器乱 | CSS 新语法未降级 | 配置目标浏览器并加 polyfill |
排查这类问题的通用思路,其实就一句话:对比开发环境和生产环境的最终产物差异。开发环境里样式是按模块动态注入的,生产环境是抽取成单独 CSS 文件的,两者在加载顺序上可能不同,从而出现优先级差异。所以当你看到"生产环境某个样式被覆盖了",第一反应应该是去查 CSS 文件的顺序,而不是去死磕选择器权重。
还有一种特别隐蔽的:本地用了CSS Modules或者深度选择器:deep(),打包压缩后类名被改,结果样式失配。这类问题只能靠"构建后本地起一个静态服务器预览产物"来提前发现,不能只信开发服务器。
提示:养成习惯,每次要发布之前,先执行一次
build,然后本地用npx serve dist之类的静态服务预览一遍产物。这一步能拦掉八成以上的"上线才暴露"的问题,比事后救火省事太多。
4.3 离线安装与依赖版本锁定
有些环境不能直连外网,或者网速极差,这时候就得考虑离线安装。核心思路是:先在一台能联网的机器上把依赖完整下载并打包,再拷到目标机器上安装。用 npm 的话,可以先npm install完成,然后把整个node_modules连同package-lock.json一起拷过去;但这种方式对操作系统和 Node 版本敏感,跨平台容易翻车。
更稳妥的是用离线缓存或私有仓库的思路。用 pnpm 的话,.pnpm-store目录本身就是内容寻址的缓存,把缓存目录拷到目标机器并配置好store-dir,再执行pnpm install --offline,只要缓存里有对应版本的包,就能离线装上。注意这里有个前提:目标机器的lockfile必须和缓存来源完全一致,版本差一个补丁号都可能命中不了缓存。
无论用哪种方式,package-lock.json(或pnpm-lock.yaml)都必须提交进版本库。它锁定了依赖树的精确版本,是保证"我在我机器上跑得起来,你在你机器上也跑得起来"的关键文件。我见过团队里有人嫌 lockfile 冲突麻烦就把它加进.gitignore,结果就是每个人的依赖版本都略有不同,出现难复现的 bug。
4.4 前后端分离部署(与 Spring Boot 对接)
和 Spring Boot 这类后端服务配合部署是很多团队的日常。典型结构是:前端打包成静态资源,交给 Nginx 托管;后端 Spring Boot 打成 jar 独立运行;Nginx 再通过反向代理把/api开头的请求转发到后端端口。这样前端和后端可以用同一个域名,避免跨域配置带来的麻烦。
一份朴素但够用的 Nginx 配置:
server { listen 80; server_name example.com; location / { root /var/www/app/dist; index index.html; # 关键:单页应用的路由回退 try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files ... /index.html这一行是必须的。单页应用的所有路由都是前端控制的,用户直接访问/user/1时,服务器上并不存在这个真实文件,如果没有这行回退,就会返回 404。这个配置漏掉的话,表现就是"从首页点进去都正常,一刷新子页面就 404",非常典型。
前后端对接时另一个高频问题是跨域。开发阶段前端一般用 Vite 的server.proxy解决,生产阶段用 Nginx 同域代理解决。如果生产环境还报跨域,先确认请求是不是真的走了代理——很多时候是前端代码里写死了http://localhost:8080这种绝对地址,代理根本没生效。
5. 调试与提效:Devtools、日志回传与配套工具
代码能跑、能部署之后,最后一个环节是"如何高效地找到问题"。这一章聊几个我平时最常用的调试手段,以及一些能明显提升效率的配套工具。
5.1 调试三板斧
第一板斧是Vue Devtools。它能让你看到组件树、每个组件的 props / data / setup 状态、以及 Pinia 的 store 快照。我排查"数据传错了"这类问题时,第一件事就是在 Devtools 里找到目标组件,看它的 props 到底收到了什么。很多时候你以为是接口没返回,其实是父组件传参时写了错的字段名。
第二板斧是断点调试。在 VSCode 里配合浏览器调试协议,可以直接在.vue文件的script里打断点,不用再靠console.log到处撒。关键是要配置正确的 source map,否则断点会落到编译后的代码上,变量名都变了。Vite 默认就开了 source map(开发环境),所以一般开箱即用。
第三板斧是网络面板。当页面表现异常时,先看请求发出了没、状态码是多少、返回结构对不对。我有个习惯:遇到"数据不显示",先看网络,网络没问题再看状态,状态没问题再打断点。这个顺序能避免你在代码里瞎找半天,结果发现是接口 500。
5.2 把服务端日志"回传"到页面的思路
有个问题挺有意思:Node 端的console.log能不能传到 Vue 页面上?答案是能,思路并不复杂。做法是起一个轻量的通信通道——要么用 WebSocket,后端把日志实时推送过来;要么用轮询接口,前端定时拉最新的日志。前端拿到后,在一个调试面板组件里渲染出来。
这种"日志回传"在移动端调试或者远程排查用户环境时特别有用。比如用户反馈"我这边点某个按钮没反应",你又没法直接连他的电脑,就让前端把关键流程的日志通过一个隐藏的调试面板显示出来,用户截图给你,问题定位会快很多。
实现上有个注意事项:日志通道必须能被随时关掉,且不能在正式环境常开。因为它既消耗带宽,也可能暴露内部信息。我的做法是用一个环境变量或后台开关控制,默认关闭,需要排查时远程打开。
// 简易的浏览器端日志面板思路 const logs = ref([]) function pushLog(msg) { logs.value.push(`[${new Date().toLocaleTimeString()}] ${msg}`) if (logs.value.length > 200) logs.value.shift() // 防止无限增长 }logs.value.shift()这个截断很重要。如果不限制长度,长时间跑下来这个数组会越来越大,最终把内存吃满,页面卡死。这属于典型的"调试工具自己成了性能问题"。
5.3 单元测试与工程规范一体化
项目到一定规模,靠人肉回归是不现实的。这时候引入一套"规范 + 测试"的组合能把返工率压下来。常见的一体化方案是:ESLint 管代码规范,Prettier 管格式,Vitest 管单测,再用 husky 加 lint-staged 在提交前自动跑一遍。
配置这套东西的核心收益不在"测试覆盖率有多高",而在于把低级错误挡在提交之前。比如未使用的变量、拼错的导入路径、格式不一致导致的 diff 噪音,这些都能在提交时被自动修正或拦截。团队协作时,它能省下大量"你改了我的格式"这种无意义的扯皮。
对测试本身,我的建议是优先测纯逻辑,比如工具函数、数据转换、store 里的 action,而不是一上来就给每个组件写渲染测试。纯逻辑测试写起来快、跑得也快、维护成本低,性价比最高。组件测试留给那些交互复杂、容易出回归问题的部分,按需覆盖即可。
6. 常见问题速查表与踩坑实录
前面五章按主题铺开讲了不少东西,最后这一章把高频问题汇总成表,方便你卡住的时候直接翻。
6.1 高频问题速查表
| 问题现象 | 大概率原因 | 快速定位方式 |
|---|---|---|
| 改了数据视图不更新 | 解构丢了响应式 / 直接改数组下标 | 用storeToRefs,数组用push或整体替换 |
| 刷新后参数为空 | 用了params传参 | 换路径参数或 query |
| 子页面刷新 404 | Nginx 缺try_files回退 | 检查服务器路由配置 |
| 组件不重新渲染 | 路径参数变化但组件复用 | watch监听参数并加immediate |
| 属性透传失效 | $attrs被中间组件吞掉 | 检查是否设置inheritAttrs并显式v-bind="$attrs" |
| 打包产物体积巨大 | 全量引入 UI 库 / 未分包 | 开启按需引入与代码分割 |
| 生产环境样式被覆盖 | CSS 加载顺序与开发环境不同 | 构建后本地预览产物复现 |
| 登录后一直转圈 | 守卫里next调用时机或次数错误 | 检查是否有多个next或死循环 |
| 定时器导致内存泄漏 | 组件销毁未清理 | onUnmounted里clearInterval |
表格里"属性透传失效"这条值得多说一句。$attrs是用来把父组件传给孙组件的属性透传下去的,中间那层组件如果没显式v-bind="$attrs",属性就断在中间了。它的典型场景是给封装过的第三方组件加原生属性,比如给二次封装的输入框加placeholder。
6.2 我自己踩过并记住的几条经验
第一条,不要在watch里改回被监听的值。watch回调里如果对同一个值做了写操作,很容易造成无限循环或者更新丢失。真需要做这种"值归一化",用computed的 getter/setter 更合适。
第二条,组件销毁时的清理要成对写。凡是addEventListener、setInterval、WebSocket连接、IntersectionObserver,在onUnmounted里都必须有对应的清理。之前有个项目里存在一个隐蔽的内存泄漏,排查到最后发现是一个图表组件每次切换都新建了定时器却没销毁,页面开着半小时后就开始卡。这类问题在开发阶段几乎看不出来,上线后才慢慢暴露。
第三条,别迷信"新工具一定更好"。从 Vuex 到 Pinia、从 Webpack 到 Vite,这些升级确实带来了体验提升,但每次技术选型都要考虑团队现状和迁移成本。我见过一个项目为了追新,把一个已经很稳定的老项目强行重构,结果引入了十几个新 bug,修复花的时间远超预期。工具是为人服务的,判断标准永远是"能不能让当前这件事更可靠地完成"。
最后再分享一个我一直在用的小习惯:给项目建一个docs/目录,把环境变量说明、部署步骤、常见问题都写成 Markdown 放进去。这件事花不了多少时间,但当新人加入、或者你半年后回头看这个项目时,它能省下大量的来回沟通和回忆成本。代码会变,但"为什么这么设计"的记录,往往才是项目里最有价值的部分。