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 中使用时完全不被执行,依赖缓存机制导致它的执行次数不可预测。如果要在值变化时执行副作用,正确做法是配合watch:
const total = computed(() => value.value * 2) watch(total, (newVal) => { localStorage.setItem('total', newVal) })这两者的分工是:computed 负责派生状态,watch 负责响应变化。第六天之后我基本按照这个原则来写,再没有出现过状态不一致的问题。
1.3 computed 与 watch、watchEffect 的边界
很多人会把 computed、watch、watchEffect 混用,实际上它们的定位非常清晰:
| 特性 | computed | watch | watchEffect |
|---|---|---|---|
| 返回值 | 有,衍生值 | 无 | 无 |
| 执行时机 | 懒执行,依赖变化时 | 被监听源变化时 | 立即执行,依赖变化时 |
| 适用场景 | 派生状态、数据转换 | 异步请求、复杂逻辑、数据持久化 | 副作用同步执行、不关心具体依赖 |
| 是否需要手动指定依赖 | 不需要,自动收集 | 需要,显式指定 | 不需要,自动收集 |
在第六天的项目里,我遇到一个需求:搜索框输入关键词,不仅要实时筛选列表,还要把筛选后的总数展示在标题上。我一开始用 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 :theme="theme" /> </template> <script setup> defineProps({ theme: String }) </script>如果组件层级较深,每一层都要透传一次,代码冗余且维护成本高。provide/inject 允许跳过中间层:
// 祖先组件 provide('theme', themeColor) // 深层后代组件 const themeColor = inject('theme')这是不是意味着可以完全替代 props?不是。provide/inject 是非响应式的(默认情况下),而且注入关系是隐式的,组件无法从 props 列表里看出它依赖了哪些外部数据。所以我的使用准则是:
- 配置类、主题类、环境信息类的全局数据:用 provide/inject
- 业务数据、父子组件的核心数据流:用 props + emit
- 跨页面、跨路由的共享数据:用 Pinia
2.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 class="el-tabs el-tabs--top"> <div class="el-tabs__header"> <div class="el-tabs__nav-wrap"> <div class="el-tabs__nav-scroll"> <div class="el-tabs__nav"> <div class="el-tabs__item is-active">标签项</div> </div> </div> </div> </div> <div class="el-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/2,URL 变了,页面内容没变。
原因是:当路由变化时,复用的组件实例不会重新执行生命周期钩子。如果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-quill@latest 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:content="content" content-type="html" theme="snow" toolbar="full" /> </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 class="rich-editor"> <QuillEditor :model-value="modelValue" content-type="html" theme="snow" :toolbar="toolbarOptions" @update:model-value="onUpdate" /> </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,数据库和网络传输压力大;第二,渲染到正文时图片加载慢,体验差。
我的做法是拦截图片插入事件,改成上传到服务器再插入 URL:
import 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 熟练度会有一个质的提升。