Vue3进阶实战:手写响应式内核、路由权限与Pinia工程化
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和reactivefunction 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和computedfunction 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?page2kwvue保留是分页、筛选、可分享的搜索态paramspush({ name:detail, params:{ id:1 } })不保留否临时跳转、非持久态路径参数/detail/:id配合/detail/1保留是详情页、资源 ID 这类天然标识很多人第一次遇到刷新后params变空会以为是自己写错了其实是设计使然params并没有被编进 URL刷新相当于重新按 URL 解析路由自然就丢了。解决办法有两种要么改用路径参数要么把关键标识放进query。我的习惯是凡是刷新后还需要的数据一律走 URL也就是路径参数或 queryparams只用来传那种丢了也无所谓的临时状态。还有一种更隐蔽的情况路径参数变了但组件没重新创建。比如从/user/1跳到/user/2同一个组件实例被复用created不会二次触发。这时候得在watch里监听route.params.idimport { 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-permissionuser: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_URLhttps://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子页面刷新 404Nginx 缺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 放进去。这件事花不了多少时间但当新人加入、或者你半年后回头看这个项目时它能省下大量的来回沟通和回忆成本。代码会变但为什么这么设计的记录往往才是项目里最有价值的部分。