资讯动态

深入Vue3核心机制:从computed缓存到动态路由与富文本封装实践

发布时间:2026/9/10 0:40:18 来源:尧图企业网站定制
1. 响应式机制的次深层理解computed 的缓存策略与依赖追踪学习 Vue3 到第六天正好是项目从“能跑”往“跑得漂亮”过渡的阶段。前五天我基本把模板语法、组件注册、生命周期、路由和 Pinia 过了一遍能做出一个带登录和列表页的简单后台。但第六天开始我明显感觉到瓶颈不在“会写 API”而在“理解背后的机制”。就拿 computed 来说文档上的例子人人会写可真正到项目里用起来缓存失效、依赖追踪不到、返回函数和返回值的取舍每一个都是坑。1.1 computed 到底缓存了什么先看一个最简单的例子const { ref, computed } Vue const keyword ref() const filteredList computed(() { console.log(computed run) return list.value.filter(item item.name.includes(keyword.value)) })这里 computed 缓存的是filteredList.value这个值而不是filteredList这个对象。模板里多处引用filteredList时计算函数只执行一次。只有当keyword.value或list.value发生变化计算函数才会重新执行。但我在项目中踩过一个坑计算属性里依赖了多个 ref其中一个 ref 是对象类型我直接修改了对象的属性而没有替换整个对象引用。const state ref({ name: vue3, count: 0 }) const countMsg computed(() 当前计数${state.value.count}) // 这样改countMsg 不会更新 state.value.count // 这样改countMsg 才会更新 state.value { ...state.value, count: state.value.count 1 }原因在于 Vue3 的依赖追踪是基于 Proxy 的属性级响应式computed 内部会收集state.value.count这个属性的依赖。上面第一种写法虽然触发了 Proxy 的 set 拦截但 computed 只在它依赖的属性被读取时标记 dirty而这里state.value.count的读取路径没有变化所以 Vue 的响应式系统判定依赖项没有改变。这个细节大部分人一开始都会忽略。1.2 computed 函数体里的副作用问题第六天的笔记里我特意记了一条computed 里不要写副作用。不是不能写而是写了会出难以排查的问题。// 错误示范 const total computed(() { localStorage.setItem(total, value.value) // 副作用 return value.value * 2 })computed 可能会被多次执行也可能在不在 UI 中使用时完全不被执行依赖缓存机制导致它的执行次数不可预测。如果要在值变化时执行副作用正确做法是配合watchconst total computed(() value.value * 2) watch(total, (newVal) { localStorage.setItem(total, newVal) })这两者的分工是computed 负责派生状态watch 负责响应变化。第六天之后我基本按照这个原则来写再没有出现过状态不一致的问题。1.3 computed 与 watch、watchEffect 的边界很多人会把 computed、watch、watchEffect 混用实际上它们的定位非常清晰特性computedwatchwatchEffect返回值有衍生值无无执行时机懒执行依赖变化时被监听源变化时立即执行依赖变化时适用场景派生状态、数据转换异步请求、复杂逻辑、数据持久化副作用同步执行、不关心具体依赖是否需要手动指定依赖不需要自动收集需要显式指定不需要自动收集在第六天的项目里我遇到一个需求搜索框输入关键词不仅要实时筛选列表还要把筛选后的总数展示在标题上。我一开始用 watch 手动变量去存总数后来改成 computed 直接把总数和列表一次性返回const searchResult computed(() { const list allList.value.filter(...) return { list, total: list.length } })这样模板里只需要searchResult.value.list和searchResult.value.total状态源唯一逻辑清晰。2. 组件状态共享的三种姿势provide/inject 的使用边界第六天项目里要做一个“全局主题色”的功能。用户在主页面选择一个颜色所有子组件里的按钮、标题、边框都要同步变化。起初我觉得用 Pinia 做全局状态管理是天经地义的事但后来发现对于“父组件向后代组件传递配置信息”这种场景provide/inject 反而是更轻量、更合适的选择。2.1 provide/inject 与 props 的本质区别props 是父组件显式声明传给子组件的、单向的、具名参数。inject 则允许后代组件直接向任意层级祖先“取数据”中间隔了多少层都能拿到。props的问题在于中间层组件必须手动转发。!-- 中间层组件 -- template Child :themetheme / /template script setup defineProps({ theme: String }) /script如果组件层级较深每一层都要透传一次代码冗余且维护成本高。provide/inject 允许跳过中间层// 祖先组件 provide(theme, themeColor) // 深层后代组件 const themeColor inject(theme)这是不是意味着可以完全替代 props不是。provide/inject 是非响应式的默认情况下而且注入关系是隐式的组件无法从 props 列表里看出它依赖了哪些外部数据。所以我的使用准则是配置类、主题类、环境信息类的全局数据用 provide/inject业务数据、父子组件的核心数据流用 props emit跨页面、跨路由的共享数据用 Pinia2.2 响应式注入的正确写法在 Vue3 中 provide 的默认响应式行为有一个常见的坑。看这段代码// 错误示范提供一个非响应式的原始值 provide(theme, blue) const theme inject(theme) theme red // 不会报错但修改无效且不会影响注入方要让它响应式必须传入 ref 或 reactive 对象const themeColor ref(blue) provide(theme, themeColor) // 后代组件 const themeColor inject(theme) themeColor.value red // 这个修改会传递到所有注入组件还有一个更精细的问题有些场景我只想让后代读取数据不允许它们直接修改。比如主题色是在根组件里统一管理的子组件不应该有权限去改。这时可以用readonly包装provide(theme, readonly(themeColor))这样后代组件读取时可以拿到值但修改会被 Vue 警告。第六天我用这个模式做主题色配置后团队成员都说比之前用 emit 层层上报的写法清爽太多了。2.3 用 provide 配合函数传递操作能力主题切换不只是改颜色还要切换暗色/亮色模式。我一开始用 provide 传主题色值 一个布尔值 一个方法结果发现参数越来越多。后来改成传一个聚合对象// 根组件 const themeConfig reactive({ mode: light, primaryColor: #4f46e5, toggleMode: (mode) { themeConfig.mode mode // 同步修改 CSS 变量 document.documentElement.style.setProperty(--el-color-primary, themeConfig.primaryColor) } }) provide(themeConfig, themeConfig)后代组件只需要inject(themeConfig)然后调用themeConfig.toggleMode(dark)状态和操作都集中在一处。这种写法适合“配置型”共享但要注意不要在 inject 出来的对象上直接赋值属性最好通过暴露的方法去修改避免状态修改路径失控。3. 样式定制Tabs 标签页样式修改的完整排查思路热搜词里有“vue3修改tabs标签页样式”这确实是后台管理系统里最常被问到的问题。第六天我正好在处理项目的标签页导航栏就是上方那个像浏览器标签栏一样的东西把样式修改的完整过程记录下来。3.1 为什么直接改样式不生效先看一个典型的失败案例。我用的组件库是 Element Plus它的 Tabs 结构大概是div classel-tabs el-tabs--top div classel-tabs__header div classel-tabs__nav-wrap div classel-tabs__nav-scroll div classel-tabs__nav div classel-tabs__item is-active标签项/div /div /div /div /div div classel-tabs__content.../div /div类名嵌套较深。默认样式表里--el-color-primary这个 CSS 变量控制着激活标签的文字颜色和底部条颜色。如果我直接把颜色写在全局样式中.el-tabs__item.is-active { color: #f00; }如果这个全局样式没有加:deep()并且是写在 scoped 样式里就不会生效。因为.el-tabs__item是子组件内部的元素scoped 样式会给选择器加上>style scoped .tabs-container :deep(.el-tabs__item) { font-size: 14px; } .tabs-container :deep(.el-tabs__item.is-active) { color: #f00; font-weight: 600; } .tabs-container :deep(.el-tabs__active-bar) { background-color: #f00; height: 3px; } /style方案二直接覆盖 CSS 变量。.tabs-container { --el-color-primary: #f00; }第二种方式更优雅因为 Element Plus 的激活状态颜色、hover 颜色、下划线颜色基本上都取自这个变量改一处即可全局生效。3.3 修改组件库默认样式的通用方法论第六天之后我总结了一套修改组件库样式的方法论打开浏览器 DevTools查看实际 DOM 结构和类名在 DevTools 里直接改样式确认哪一条规则生效判断生效的规则属于“变量驱动”还是“静态样式”如果是变量驱动优先定义 CSS 变量覆盖如果是静态样式用:deep()修饰后代选择器并配合更高优先级选择器重要不要在全局样式里直接覆盖组件类名比如直接写.el-tabs__item {}因为会影响所有页面的 Tabs。务必给容器加一个业务类名然后在这个类名作用域内改样式这套方法至今有效几乎覆盖了 Element Plus 所有组件的样式定制。实际做标签页时我还发现一个细节切换 tab 时希望保留每个 tab 下的组件状态而默认的 Tabs 会销毁非激活面板。需要加上v-if或keep-alive来处理但这是另一个话题。单纯样式层面上面的方法完全足够。4. 路由层进阶动态路由权限控制与跳转不刷新的排查记录第六天最重要的一项任务是给后台管理系统加上动态路由权限控制。也就是说用户登录后根据 Ta 的角色权限渲染不同的菜单和路由。这个需求在热搜词里也高频出现“vue3 vite 动态路由”、“路由跳转不刷新页面”我一起把这两件事捋清楚。4.1 动态添加路由的完整流程我采用的方式是登录成功后拿到用户角色前端维护一个“全部路由表”再根据角色过滤出允许访问的路由最后用router.addRoute()动态注册。先定义基础路由和动态路由的分离结构// 静态路由所有用户都能访问 const constantRoutes [ { path: /login, component: () import(/views/login/index.vue) }, { path: /404, component: () import(/views/error/404.vue) } ] // 动态路由表按角色配置访问权限 const asyncRoutes [ { path: /dashboard, component: () import(/layout/index.vue), meta: { roles: [admin] }, children: [...] }, { path: /user, component: () import(/views/system/user/index.vue), meta: { roles: [admin, editor] } } ]在路由守卫里做权限判断router.beforeEach(async (to, from, next) { const token localStorage.getItem(token) const userStore useUserStore() if (token) { if (!userStore.rolesLoaded) { const roles await userStore.fetchRoles() const accessibleRoutes filterAsyncRoutes(asyncRoutes, roles) accessibleRoutes.forEach(route { router.addRoute(route) }) // 添加兜底 404 路由 router.addRoute({ path: /:pathMatch(.*)*, redirect: /404 }) next({ ...to, replace: true }) } else { next() } } else { next(/login) } })两个细节值得注意第一next({ ...to, replace: true })的作用。路由本来是去/dashboard的但由于当时路由还没注册Vue Router 会警告“No match found for location”。用{ ...to, replace: true }重新导航一次此时路由已经完成注册就能正常匹配。不加这一行用户要么看到空白页要么卡在登录页。第二404 路由必须在动态路由添加之后再 addRoute。如果一开始就注册了/:pathMatch(.*)*的通配符动态路由添加时会被它提前拦截。我这边把通配符放在动态路由全部注册完才加才能正常匹配。4.2 退出登录时路由没有清理的问题做权限控制遇到的第二个问题是用户 A 是 admin登录后动态路由里有/dashboard退出登录后用户 B 是 editor 登录页面多出来 A 的菜单。这是动态路由没清干净导致的。我的解决方案// 退出登录 function resetRouter() { const newRouter createRouter() router.getRoutes().forEach(route { const name route.name if (name !constantRoutes.some(r r.name name)) { router.removeRoute(name) } }) }实际上更省心的方式是在 logout 时调用window.location.reload()强制刷新让整个应用重新初始化。但这样不优雅体验也有跳跃感。采用removeRoute按名称清理的方式更可控。要注意Vue Router 4 中router.removeRoute(name)只接受路由 name 字符串不能传 route 对象。4.3 路由跳转后组件不刷新的三连排查项目中又一个高频问题“vue3路由跳转不刷新页面”。常见触发场景是从/detail/1跳到/detail/2URL 变了页面内容没变。原因是当路由变化时复用的组件实例不会重新执行生命周期钩子。如果Detail.vue组件同时被/detail/:id的参数变化复用Vue Router 不会销毁重建这个组件onMounted只会在第一次进入时执行。解决方案有四种在路由视图上用:key强制重建router-view :key$route.fullPath /这个方案简单粗暴但会丢失组件内部状态不能在路由切换时保留滚动位置等。在组件内监听$route变化watch( () useRoute().params.id, async (newId) { await fetchData(newId) } )适合只有数据变化组件 DOM 结构不变的情况。在导航守卫中处理router.beforeEach((to, from, next) { if (from.path.startsWith(/detail) to.path.startsWith(/detail)) { // 不取消导航仅做额外操作 } next() })直接在下一次 tick 重新请求数据当参数变化时通过watch触发请求同时保留原组件内部状态。我最终采用的是第二种 第四种组合组件内watch路由参数触发数据请求对于需要重置的本地状态在 watch 回调中手动初始化。4.4 动态路由时刷新页面出现 404这是权限系统中另一个高频坑。刷新页面后路由表恢复到初始状态动态路由还没注册此时访问/dashboard会被 404 通配符拦截。解决思路是在路由守卫中如果发现目标路径存在于动态路由表但尚未注册就注册后再放行。核心代码就是前面 4.1 的next({ ...to, replace: true })。这个方案的前置条件是路由守卫要等待动态路由注册完毕才能放行。所以我在store里加了一个routesLoaded标志位防止每刷新一次就重复注册路由。第六天我在这块花了整整两个多小时排查。最终的代码结构是permission.js里做守卫判断store/modules/user.js里保存用户信息和路由权限route/index.js负责静态路由、动态路由表定义。分工明确后面再遇到路由问题基本能定位到具体模块。5. 富文本组件的接入与二次封装vue-quill-editor 在 Vue3 中的兼容实践热搜词里有“vue-quill-editor vue3”这说明很多人还在为这个组件如何在 Vue3 中使用而头疼。第六天项目里正好要加一个公告编辑器的功能我把这个问题的完整解法整理出来。5.1 vue-quill-editor 与 Vue3 的兼容情况vue-quill-editor 这个库本身是针对 Vue2 写的在 Vue3 里直接使用会报不同类型的错误。Vue3 官方没有直接维护富文本组件目前社区里比较主流的选择有两个第一个是vueup/vue-quill它是官方 Quill 2.0 的 Vue3 封装API 风格与 vue-quill-editor 相近迁移成本最低。第二个是editor系列的其他库比如 TipTap、TinyMCE、CKEditor 5。对于大多数后台管理系统vueup/vue-quill的功能足够且体积相对较小。5.2 安装与基础接入npm install vueup/vue-quilllatest quill^2.0.0注册方式import { QuillEditor } from vueup/vue-quill import vueup/vue-quill/dist/vue-quill.snow.css // 在组件中 import { QuillEditor } from vueup/vue-quill import vueup/vue-quill/dist/vue-quill.snow.css模板中使用template QuillEditor v-model:contentcontent content-typehtml themesnow toolbarfull / /template注意 Vue3 的v-model:content是带参数的 v-model 写法和 Vue2 时代v-model直接绑定内容不一样。vueup/vue-quill对外暴露的是content事件所以父组件要用v-model:content而不是v-model。5.3 富文本组件二次封装的关键点富文本不能每次用都写一遍完整配置所以我封装了一个RichEditor.vue组件template div classrich-editor QuillEditor :model-valuemodelValue content-typehtml themesnow :toolbartoolbarOptions update:model-valueonUpdate / /div /template script setup import { QuillEditor } from vueup/vue-quill const props defineProps({ modelValue: { type: String, default: } }) const emit defineEmits([update:modelValue]) const toolbarOptions [ [bold, italic, underline, strike], [{ header: 1 }, { header: 2 }], [{ list: ordered }, { list: bullet }], [{ align: [] }], [link, image], [clean] ] const onUpdate (value) { emit(update:modelValue, value) } /script这里要特别强调不要用 v-model 直接绑定 props.modelValue。props 是只读的直接改 props 不仅不会生效还会收到 Vue 的警告。正确做法是通过事件向父组件传值。5.4 上传图片到服务器而不是转 base64富文本里插入图片默认行为是转 base64 直接嵌入 HTML。在开发阶段没问题但生产环境这样做会有两个隐患第一base64 体积大编辑器内容动辄几百 KB 到几 MB数据库和网络传输压力大第二渲染到正文时图片加载慢体验差。我的做法是拦截图片插入事件改成上传到服务器再插入 URLimport ImageUploader from quill-image-uploader Quill.register(modules/imageUploader, ImageUploader) const modules { imageUploader: { upload: (file) { return new Promise((resolve, reject) { const formData new FormData() formData.append(file, file) uploadApi(formData).then(res { resolve(res.data.url) }).catch(err reject(err)) }) } } }在封装组件里传入modules配置即可。这个功能在第六天下午花了不少时间调试核心原因是我一开始没有注册quill-image-uploader模块直接传入modules导致 Quill 不认识这个模块名。另外 Quill 2.0 已经内置了部分图片处理能力但上传服务器这种还是得靠自定义模块。5.5 字数限制与内容清理后台管理系统里公告标题往往有字数限制。富文本的输出是 HTML 字符串直接判断content.length会被标签字符干扰。我写了一个简单工具函数function getTextSummary(html, maxLength 100) { const div document.createElement(div) div.innerHTML html const text div.textContent.replace(/\s/g, ) return text.length maxLength ? text.slice(0, maxLength) ... : text }这个函数在列表页渲染摘要时很有用。富文本组件接入的坑点还有不少比如 SSR 环境下不支持Vue3 的项目基本不走 SSR 就不太用担心、样式污染需要加作用域、XSS 风险需要配合 sanitize 库这些都在项目上线前逐一处理了。6. 第六天的经验和踩坑清单第六天是从“会写功能”到“会写工程”的转折点。总结几条我实际体验最深的点。6.1 写 Vue3 要有一个“状态来源”意识不管是 computed、provide/inject、还是 Pinia核心都是“数据从哪来、谁来改、如何流出去”。我在项目里看到很多同事把 ref 定义得满天飞到处直接改xxx.value ...结果页面出现问题时根本不知道是哪一行改的。我的习惯是每个状态尽可能只有一个“拥有者”其他组件通过 props 或事件获取而不是各自持有一份副本。这个习惯在第六天项目的“主题色”和“路由权限”里都体现得很明显。6.2 官方文档之外多看源码第六天排查问题时我大量使用了 DevTools 的__VUE__和useRouter()返回值来检查路由记录。看 Vue 源码中的packages/runtime-core/src/componentOptions.ts能理解computed的内部实现。虽然不需要每次都看源码但遇到“为什么 computed 没更新”“为什么 inject 不是响应式”这类问题时直接看源码定位速度远快于网上搜索。6.3 对组件库的样式修改要规划统一入口项目中我建了一个styles/element-reset.scss专门放 Element Plus 组件样式覆盖。每个组件的覆盖代码都按“组件名 / 变量 / 具体类名”分组并写好注释。这样后续项目维护时想改按钮风格、表格风格、标签页风格直接进这个文件找不用在业务组件里翻来翻去。第六天改 Tabs 样式的时候我把这个文件也整理了后续做暗色主题时特别方便。6.4 版本锁定与升级策略项目使用 Vue3 已经半年多我的经验是Vue3 Vite Element Plus 这一套生态升级版本要谨慎。不是不能用新功能而是每个版本升级都可能带来响应式边界行为的变化。比如 Vue 3.4 对defineModel的稳定化Vue 3.5 对watch的性能优化这些都是好事但项目里已有的代码未必能立刻平滑迁移。我的策略是小版本稳定运行两个月后再考虑升级大版本升级必须有专门的时间窗口做回归测试。第六天整体来说任务量不重但深度明显比前几天高了。从单纯写界面到开始思考“状态怎么组织、权限怎么控制、组件怎么复用、样式怎么定制”这些才是后台管理系统开发的真正核心。如果你也是刚接触 Vue3 不久建议按这个顺序去练习先做一个完整的后台服务再逐步加权限、动态路由、富文本、样式定制这些功能遇到问题不要急着搜答案先想清楚原理再动手。这条路走下来你的 Vue3 熟练度会有一个质的提升。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价