2026年了,Vue3 早就不再是“新框架”,但我在团队内部做代码评审和帮人看面试题时发现,很多人对 Vue3 的掌握其实还停留在“会用 script setup 写组件”的阶段。响应式原理说不清、动态路由配不明白、富文本一接就报错、Edge 上出个怪问题排查半天——这些都是真实项目里反复出现的点。
这篇是 2026 年 3 月我做的一次知识总结,把 Vue3 从核心原理到工程实践、再到高频故障排查串了一遍。目标读者是两类人:一是准备跳槽面试、需要把 Vue2 和 Vue3 差异讲清楚的同学;二是已经在用 Vue3 写后台管理系统,但遇到具体功能不知道选什么方案、出了问题不知道从哪里下手的开发者。内容不算进阶,但覆盖很全,基本可以当手册查。
1. 先搞懂 Vue3 最核心的变化:响应式与组合式 API
很多面试题的第一题就是“Vue3 的响应式原理”,但实际业务里真正影响代码质量的,往往是响应式 API 的用法细节。这两个东西必须一起理解。
1.1 响应式系统:Proxy 如何解决 defineProperty 的三大痛点
Vue2 用 Object.defineProperty 做数据劫持,它有三个天然痛点:
第一,它只能劫持对象已有的属性。如果你在 data 里没有预先声明某个属性,后期直接this.obj.newKey = value,这个新属性不会是响应式的。所以 Vue2 才被迫提供$set和$delete。
第二,数组的原生操作劫持不了。Vue2 通过重写 push、pop、shift、unshift、splice、sort、reverse 这七个方法来模拟,但通过下标赋值arr[0] = xxx依然是盲区。
第三,初始化时必须递归遍历整个对象,把每一层属性都转成 getter/setter。对象越深,初始化开销越大,而且这个遍历是一次性的,后面新增的属性也不会自动变成响应式。
Vue3 直接用 ES6 的 Proxy 包装整个对象,解决了以上三个问题。Proxy 拦截的是对象的任何操作,包括属性新增、删除、in 操作符、遍历,数组下标和 length 的变更也全部能被拦截到。底层依赖收集的原理其实没变:读取属性时收集依赖,修改属性时触发更新,差别只是拦截层从“属性级”变成了“对象级”。
提示:Vue3 中依然存在响应式丢失的问题。原因不是 Proxy 不行,而是你把响应式对象解构赋值给普通变量,或者传给函数参数时断掉了引用链。在业务代码里,常见错误是把 reactive 对象解构出来用,比如
const { name } = state,这样 name 就是一个普通字符串。推荐用toRefs或toRef处理。
1.2 setup 时代:ref 和 reactive 到底该选谁
ref和reactive的用法差异,是新手最容易栽跟头的地方。我的建议很简单:
- 基础类型(string、number、boolean、bigint)用
ref - 对象、数组用
reactive也行,但在泛型逻辑、传参、返回、解构场景下,一律ref更省心
为什么?因为ref在底层其实也调用了reactive,它把.value指向的那个对象代理了一遍,本质上是对 reactive 的二次包装。用ref传参时,你始终传的是带.value的引用对象,传递过程中不会丢响应性;而reactive对象解构会丢。尤其是从组合式函数返回数据时,如果返回一个 reactive 对象,使用方一旦解构就得用 toRefs,不熟悉的人很容易踩坑。
还有一个细节很多人不知道:ref对象直接放进reactive里,会自动解包;但reactive对象放进ref里,不会继续深层包装,你拿到的.value依旧是那个 reactive 代理。这个行为在源码层有明确区分,业务上你只要记住:不要搞混合嵌套,要么全用 ref,要么结构清晰地区分,不然调试时看控制台会非常迷惑。我在项目里定的规范是全组件统一用ref声明业务数据,再配合解构赋值保持代码简洁真正用起来反而最少踩坑。
1.3 computed、watch 的依赖追踪与常见细节
computed在 Vue3 里有两个容易被忽略的点:一是缓存,二是只有在渲染函数里被访问时才会触发求值。如果你在 setup 里只定义了 computed 但没有任何地方读取它,它不会反复执行,也不会无意义地消耗性能。这个行为叫“惰性求值”,面试时能提到这个细节会加分。
watch和watchEffect的区别也要说清楚:watch需要明确指定监听源,拿到的回调里可以分别拿到新值和旧值,适合“前后对比”场景;watchEffect是自动收集依赖,你只要在回调里读取了某些响应式变量,这些变量变化后回调就会自动重跑,适合“副作用”场景,比如根据数据变化发请求或者写 localStorage。
实际开发里有个高频场景:监听路由参数变化重新拉数据。用watch(() => route.params.id, handler)这种方式是标准解法。千万不要在onMounted里只拉一次数据,否则从详情页 A 切到详情页 B,你看到的还是上一次的数据。另外,watch支持配置{ deep: true }来深度监听,但出于性能考虑,尽量把监听目标定到具体的字段,不要一上来就 deep 整个对象。
2. Vue2 到 Vue3:面试常考的差异点与迁移经验
Vue2 和 Vue3 的差异是面试题里最吃量的部分,但同时也是实际迁移中必须面对的问题。我把变化按“影响业务代码的程度”分成三层。
2.1 模板与 API 层面的破坏性变更
第一层是直接影响你写模板的改动:
v-model从单个变成了可以绑定多个。Vue2 的v-model等价于:value+@input,Vue3 里默认变成了:modelValue+@update:modelValue,并且支持v-model:title、v-model:content这种多属性绑定,做表单组件时特别方便。.sync修饰符被移除了,统一用v-model:propName语法替代。迁移时把update:propName事件名保持住就行。filter过滤器被移除了。团队代码里如果还留着{{ text | formatMoney }},Vue3 下直接编译报错。替代方案要么写到 computed 里,要么抽成工具函数在 setup 中引入。- 自定义指令的钩子名称全变了。
bind变成了beforeMount,inserted变成了mounted,update和componentUpdated合并成了updated,unbind变成了unmounted。迁移时对照官方映射表改一遍就行。 - 全局 API 从
Vue.use()、Vue.component()变成了app.use()、app.component(),事件总线$on、$off、$once被彻底移除,常见的跨组件通信推荐用mitt或直接上状态管理。
2.2 状态管理生态的悄然替换:Pinia 与 useXxx
Vuex 4 虽然支持 Vue3,但在新项目里已经很少有人用了。Pinia 不仅有更简洁的 API,还天然契合组合式 API 的风格。对比一下感受更明显:
| 维度 | Vuex 4 | Pinia |
|---|---|---|
| 定义方式 | state / mutations / actions / getters | defineStore,直接写 setup 风格 |
| 修改状态 | 必须走 mutation | 直接赋值或调 action 均可 |
| TypeScript | 支持但类型推导较繁琐 | 类型推导好 |
| 模块化 | 需要 modules 嵌套 | 天然按需拆分,无嵌套概念 |
Pinia 的defineStore配合 setup 风格可以这样写:
import { defineStore } from 'pinia' import { ref } from 'vue' export const useUserStore = defineStore('user', () => { const token = ref('') const roles = ref<string[]>([]) function setToken(value: string) { token.value = value } return { token, roles, setToken } })在组件里用的时候,storeToRefs负责把 store 里的响应式数据安全解构出来,action 直接调用就行。我见过不少人直接在组件里const { token } = useUserStore(),这样 token 解出来后就废了,必须用storeToRefs。这个细节面试官一问一个准。
2.3 生命周期映射与新钩子
Vue3 的组件生命周期在迁移时几乎是一一对应改名,但有几个新增钩子要注意:
| Vue2 | Vue3 |
|---|---|
| beforeCreate | 建议去掉,逻辑挪进 setup |
| created | 建议直接写 setup 顶层 |
| beforeMount | onBeforeMount |
| mounted | onMounted |
| beforeDestroy | onBeforeUnmount |
| destroyed | onUnmounted |
注意onActivated和onDeactivated是给keep-alive缓存组件用的。如果你的页面用了 keep-alive,mounted 只在第一次进入时执行,后面从缓存里恢复时触发的是onActivated。在后台管理系统里,凡是“从详情页返回列表页要刷新表格”的需求,本质上都是没处理好这个钩子。最佳做法是列表数据请求放在onActivated里,而不是onMounted里。
3. 工程化落地:从 Vite 搭建到后台管理系统
Vue3 的项目搭建已经比 Vue2 时代简单太多,但工程化配置的坑并不少,尤其是后台管理系统这种长期维护的项目,选型和配置直接决定后面半年过得舒不舒服。
3.1 用 Vite 快速创建项目,给新项目配一套方案
创建项目基本就是一行命令:
npm create vite@latest my-admin -- --template vue-ts这里我强烈建议直接选vue-ts模板。虽然团队里可能有人对 TypeScript 有抵触,但后台管理系统的接口数据结构复杂,类型推导带来的收益远大于学习成本。2026 年的组件库和 UI 生态对 TS 的支持已经非常成熟,不用 TS 反而是给自己增加维护负担。
创建完项目后,有几个配置我会第一时间加上:
// vite.config.ts import { fileURLToPath, URL } from 'node:url' import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import AutoImport from 'unplugin-auto-import/vite' import Components from 'unplugin-vue-components/vite' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' export default defineConfig({ plugins: [ vue(), AutoImport({ resolvers: [ElementPlusResolver()] }), Components({ resolvers: [ElementPlusResolver()] }) ], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }, server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } })unplugin-auto-import和unplugin-vue-components是必须装的。它们能让你不用手动import { ref } from 'vue'和import ElButton from 'element-plus',自动引入且按需打包,体积和开发体验都能兼顾。在 VSCode 里装好对应的插件后,这些自动引入的 API 也能有类型提示。
还有一点容易忽略:Vite 开发环境下import.meta.env可以拿到环境变量,.env.local、.env.production这些文件里定义的VITE_开头的变量会自动注入。注意只有VITE_前缀的变量会暴露到客户端代码里,这是安全设计,不是 bug。
提示:Vite 做懒加载分包时,动态
import()的路径在 Windows 和 Linux 下大小写敏感性不同,如果你在 Windows 上开发时没报错,部署到 Linux 上线后可能白屏,这类问题十有八九是路径大小写不一致,排查优先级要排在开发工具之前。
3.2 动态路由与权限控制:addRoute 加自定义指令
后台管理系统最核心的工程化需求就是权限控制。热搜词里“根据权限显示不同首页”“动态路由”都是这个问题。
标准做法是登录成功后拿 token,再拉取当前用户角色和菜单权限,前端根据权限动态注册路由。Vue Router 4 提供router.addRoute(),可以在运行时往路由表里追加路由。核心逻辑如下:
import router from '@/router' interface RouteItem { path: string name: string component: string meta?: { title: string; icon?: string } children?: RouteItem[] } export function setupDynamicRoutes(menus: RouteItem[]) { const modules = import.meta.glob('../views/**/*.vue') function mapRoutes(items: RouteItem[], parentPath = '') { items.forEach((item) => { const fullPath = parentPath + item.path if (item.component) { const loader = modules[`../views/${item.component}.vue`] const routeRecord = { path: item.path, name: item.name, component: loader, meta: item.meta } router.addRoute(routeRecord) } if (item.children && item.children.length) { mapRoutes(item.children, fullPath) } }) } mapRoutes(menus) }这里的关键点是import.meta.glob,它能做目录级懒加载。注意 component 字段的路径必须和 views 目录结构一致,否则这里静默失败,页面白屏且控制台只报一个找不到模块的 warning。
按钮级权限一般用自定义指令v-permission实现。注册全局指令时,指令的mounted里检查用户权限列表,没有权限就直接移出当前元素:
// src/directives/permission.ts import type { Directive } from 'vue' import { useUserStore } from '@/stores/user' export const permission: Directive<HTMLElement, string | undefined> = { mounted(el, binding) { const userStore = useUserStore() const required = binding.value if (!required) return const hasPermission = userStore.roles.includes(required) if (!hasPermission) { el.parentNode?.removeChild(el) } } }路由守卫里加拦截逻辑:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (!token && to.path !== '/login') { next('/login') } else if (token && to.path === '/login') { next('/') } else { next() } })一定记得区分“静态路由”和“动态路由”。首页、登录页、404 页适合静态注册,业务页面全部走后端返回的菜单数据动态注册。如果一上来就把所有路由写死,那“权限显示不同首页”就无从谈起。
3.3 后台模板选型:vue-pure-admin、vue-vben-admin、jeecg-b 怎么选
热搜词里有“管理系统模板比较”,我把当前常见的三个开源模板对比一下,帮大家省点调研时间:
| 模板 | 技术栈侧重点 | 适合场景 |
|---|---|---|
| vue-pure-admin | 轻量、TS、强调纯净简单 | 中小后台、需要快速交付 |
| vue-vben-admin | 功能全面、封装较多,内置权限、多标签页、Mock 体系 | 中大型系统、有定制空间 |
| jeecg-boot | 前后端一体,后端基于 Java,自带代码生成器 | 想要低代码形态、需要后端配套 |
选择上我的经验是:如果团队以纯前端为主、项目规模不大,vue-pure-admin 的起点更低,代码好读,改起来不费劲;如果业务复杂度高,需要完整的权限模型和多租户,vue-vben-admin 的封装能省很多事,但学习曲线也陡;如果你们其实需要一个完整的产品形态,而不是纯前端模板,那 jeecg-b 这类前后端一体的方案更对口,它内置的表单设计和报表工具对非研发人员也很友好。
这里说的不是“哪个最好”,而是“哪个最适合你们当前的阶段”。方案切换的成本很高,前期调研花三天下决定,比用了一半再推翻重来要划算。
3.4 国际化、状态保持与公共布局技巧
国际化在 Vue3 项目里基本就是 vue-i18n 的配置问题。Vue3 环境下要特别注意legacy: false这个选项,它决定了你用组合式风格的useI18n还是旧版的全局$t:
import { createI18n } from 'vue-i18n' const i18n = createI18n({ legacy: false, locale: localStorage.getItem('locale') || 'zh-CN', messages: { 'zh-CN': { common: { confirm: '确认' } }, 'en-US': { common: { confirm: 'Confirm' } } } })legacy: false配合 Composition API 使用时,在组件里要这样拿翻译函数:
import { useI18n } from 'vue-i18n' const { t, locale } = useI18n()注意不要在组件 setup 顶层直接用$t,那是 Options API 的写法。切换语言就是给locale.value赋值,同时记得把当前语言写入 localStorage,否则刷新就丢了。
“保存状态栏状态”这个需求,如果你指的是页面滚动位置、搜索条件、tabs 激活项这类 UI 状态的恢复,核心是两件事:一是用 key 给路由视图做精确标记,二是配合 keep-alive 缓存组件状态,在onActivated里恢复,在onDeactivated里保存。如果你指的是浏览器底部状态栏或者原生 App 接过来的 WebView 状态栏高度,那是 CSS 环境变量和布局适配的问题,注意区分。
另外,热搜里“在芋道系统中 vue3 表单中 router = useRouter() 的写法”其实就是很标准的流程:在表单页通过useRouter()拿路由实例,提交成功后用router.push('/list')回列表。核心点是在异步回调里使用路由跳转,提交按钮要加 loading 防重复点击,失败时要停留在当前页并给出提示。逻辑本身不复杂,重点是不要丢了错误分支的处理。
4. 常用功能实现速查:富文本、截图、词云、地图与文档导出
后台管理系统做久了,你会发现业务需求高度雷同。富文本、截图、词云、流程图、导出 Word、地图,每一样都有现成方案,但选型和接入细节里藏了不少坑。
4.1 富文本与截图:别再硬啃旧库
Vue2 时期大家喜欢用 vue-quill-editor,但它在 Vue3 生态里基本停止维护了。新项目接富文本,我的建议是直接选 Quill 2.x 自己封装,或者用 wangEditor 5。封装富文本组件的核心思路是同步值和 change 事件:
<template> <div ref="editorRef" class="editor"></div> </template> <script setup lang="ts"> import { onMounted, ref, watch } from 'vue' import Quill from 'quill' import 'quill/dist/quill.snow.css' const props = defineProps<{ modelValue: string }>() const emit = defineEmits<{ (e: 'update:modelValue', value: string): void }>() const editorRef = ref<HTMLElement>() let quill: Quill | null = null onMounted(() => { quill = new Quill(editorRef.value!, { theme: 'snow' }) quill.on('text-change', () => { emit('update:modelValue', quill!.root.innerHTML) }) }) watch(() => props.modelValue, (val) => { if (quill && val !== quill.root.innerHTML) { quill.root.innerHTML = val } }) </script>注意watch里必须加val !== quill.root.innerHTML的判断,否则输入时又触发赋值,会造成光标跳动。这个坑我当年浪费了整整一个下午。
截图功能的主流方案是html2canvas和html-to-image。后者基于 SVG 序列化,处理跨域图片时更顺手,但前者兼容性更老道。大图截取注意设置scale参数和backgroundColor,不然默认背景是透明的,导出 PNG 在部分浏览器里会变黑。调用截图时先await document.fonts.ready,等字体加载完成再截,否则输出文字位置会错乱。
4.2 词云不规则排布与流程图组件的选型
词云需求在数据可视化项目里很常见。Vue3 里最方便的组合是 echarts + echarts-wordcloud。不规则排布其实不是“随机”,而是通过maskImage传入一张形状图,词云会尽量填充到这个形状内部,产生你想要的轮廓效果。核心配置:
import * as echarts from 'echarts' import 'echarts-wordcloud' const chart = echarts.init(container.value) chart.setOption({ series: [{ type: 'wordCloud', shape: 'circle', maskImage: maskImg, left: 'center', top: 'center', width: '90%', height: '90%', sizeRange: [14, 60], rotationRange: [-90, 90], rotationStep: 45, gridSize: 8, drawOutOfBound: false, textStyle: { fontFamily: 'sans-serif', color: function () { return `rgb(${Math.round(Math.random() * 255)}, ${Math.round(Math.random() * 255)}, ${Math.round(Math.random() * 255)})` } }, data: wordList }] })注意maskImage需要先加载图片再初始化图表,否则排布会退化成纯圆形或矩形。另外 ecahrts-wordcloud 插件对 echarts 5 的兼容性需要确认版本,装之前先看 peerDependencies。
流程图组件,如果你只需要画节点和连线,推荐@vue-flow/core或者 AntV X6;如果需要完整的流程编排能力和自定义节点,LogicFlow 更合适。选型时先问清楚需求边界:只是展示还是能拖拽编辑?节点数量级是多少?是否需要自动布局?这三个问题问完,方案基本就定了。Vue2 时代的vue-drag-resize在 Vue3 里可以考虑 v-drag-resize 或直接基于 pointer 事件自己实现,拖拽类需求自己写也不难,核心是搞清楚元素坐标变化。
4.3 导出 Word 与高德地图集成
导出 Word 是我见得最多的“临时需求”之一。方案选择取决于你对文档格式的控制要求:
- 只是把表格、图片、简单文本导出成 doc,用
docxtemplater最稳。它本质是模板引擎,你把后端返回的数据填进一个预制的 .docx 模板里,生成的文件格式最干净。 - 如果前端要拼 HTML 再转 Word,可以用
html-docx-js或直接把 HTML 片段嵌入 Word 兼容格式中,但这种方式在不同 Office 版本里表现不稳定,图片处理尤其麻烦,不推荐在正式系统里用。 - 如果只是导出 Excel 报表,直接用
exceljs或xlsx,注意合并单元格和样式的性能。
高德地图接入 Vue3 的官方推荐方式是@amap/amap-jsapi-loader,它负责异步加载 AMap 脚本,配合onMounted初始化实例:
import AMapLoader from '@amap/amap-jsapi-loader' let map: any = null AMapLoader.load({ key: '你的Key', version: '2.0', plugins: ['AMap.Scale', 'AMap.ToolBar'] }).then((AMap) => { map = new AMap.Map(container.value, { zoom: 12, center: [116.397428, 39.90923] }) })组件卸载时记得map.destroy(),否则多次进入页面会累积多个地图实例,内存直线上升。
4.4 JSX 场景、自定义指令与 Dialog 的现代用法
Vue3 使用 JSX 属于“默认支持但需要配置”的能力。用 Vite 建项目时需要加@vitejs/plugin-vue-jsx插件,然后就能写.tsx文件了。但我的建议是:能用模板就用模板,模板的静态优化、类型推导更好。JSX 适合两类场景:一是高动态渲染,比如函数式组件里大量条件分支、递归渲染;二是你从 React 转过来、对 render 函数更熟悉,用 JSX 过渡会平滑一些。
自定义指令在 Vue3 里不仅能操作 DOM,还能配合组合式函数处理复杂逻辑。除了权限指令v-permission,我平时用得比较多的还有v-debounce(防抖提交)、v-focus(自动聚焦)、v-infinite(滚动加载)。实现方式就是在 directive 对象里写业务逻辑,注意清理副作用,比如在unmounted里移除事件监听器。
Vant 4 的 Dialog 在 Vue3 项目里推荐用函数式调用:
import { showDialog, showConfirmDialog } from 'vant' async function handleDelete() { await showConfirmDialog({ title: '删除确认', message: '是否确认删除这条数据?' }) // 用户点了确认,这里继续执行删除逻辑 }函数式调用的好处是不用在 template 里维护 showFlag,也不用关心销毁时机,代码更集中。如果你是组件式调用van-dialog,记得用v-model:show控制显隐,并且关闭事件要手动把 show 置为 false。
5. 高频问题排查实录:来自真实项目的 6 个坑
最后这部分是我从真实项目和社区问题里整理的排查经验,每一条都有具体场景,也会给出排查路径,但不确保万能,毕竟实际环境千差万别。
5.1 Edge 浏览器里浏览器按钮疑似失灵的排查思路
“vue3 项目在 Edge 浏览器中有时候无法关闭浏览器右上角的最小化按钮”这个描述我在热搜里看到过,实际遇到时多半不是按钮本身的问题,而是页面把浏览器主线程卡死了。常见诱因包括:
- 页面里有死循环或超大数据渲染。排查方式:打开 DevTools Performance,录制操作过程,看主线程占用率是否接近 100%。
- 无限发请求。如果某个 effect 没有正确清理依赖,会导致循环请求,最终把浏览器拖到无法响应。
- 页面调用了 Fullscreen API。代码里如果执行了
document.documentElement.requestFullscreen(),浏览器会进入全屏模式,窗口按钮的位置和可用性会受影响。搜一下代码里有没有 fullscreen 相关调用。 - Edge 扩展干扰或系统输入法相关问题。可以试一下 Edge 的 InPrivate 窗口是否复现,如果不复现就基本确定是扩展导致的。
这个问题的核心点在于“Edge 特有”,所以优先考虑浏览器环境和页面资源的交互,而不是 Vue3 自身的 bug。Vue3 能做的事是保证页面主线程不因我们的代码被卡死,比如列表用虚拟滚动、大数据用 worker 处理。
5.2 路由跳转页面不刷新的真相
“路由跳转不刷新页面”是 Vue Router 的一个经典问题。最常见的原因:从/detail/1跳到/detail/2,组件实例被复用了,onMounted不会重新执行,所以页面数据不更新。
解决方案有三层:
- 第一层,在组件里用
watch(() => route.params.id, reload)监听参数变化,重新拉数据。这是推荐做法,最大程度保留组件内部状态。 - 第二层,在
router-view上加:key="route.fullPath",强制每次路径变化都重新创建组件。这个方法简单粗暴,但会丢失组件内部状态,适合详情页这种无所谓状态的场景。 - 第三层,用路由钩子
onBeforeRouteUpdate做拦截处理。
在微前端场景下,Vue2 主应用 + Vue3 子应用的路由异常,多半是子应用实例被重复创建、路由实例没有做销毁清理导致的。检查子应用生命周期里的unmount钩子,把 router、app 实例都置空。另外如果用的是 history 路由,子应用必须配置正确的base,否则刷新页面时资源路径全错,表现为白屏或 404。
5.3 tabs 标签样式如何“优雅”地改
改 Element Plus / Ant Design Vue 的 tabs 样式是后台系统的日常需求。如果你只改当前页面,用:deep()选择器就行:
<style scoped> .my-tabs :deep(.el-tabs__item) { height: 36px; line-height: 36px; } .my-tabs :deep(.el-tabs__item.is-active) { color: #409eff; font-weight: 600; } .my-tabs :deep(.el-tabs__active-bar) { height: 3px; border-radius: 2px; } </style>注意:deep()前面必须写一个能定位到当前组件 DOM 的类名,否则会把全局所有 tabs 都改了。这是个很常见的失误,写 scoped 样式时以为加个:deep()就不会污染全局,但作用域其实还是全局的,只是编译加了 hash 属性选择器。如果要全局统一修改 tabs 风格,建议直接用 CSS 变量覆盖组件库的 token,而不是去改源码。
5.4 强制更新组件的几种自救方式
Vue3 里基本不需要$forceUpdate,因为响应式系统本身够用。如果你觉得数据改了但视图没更新,优先检查:
- 是否修改了响应式对象的“新增属性”而没有重新赋值。
reactive对象新增属性是可以被追踪的,但如果你给ref对象里的对象新增属性,且这个对象本身没有用reactive包裹,就可能不敏感。 - 是否修改了数组的 index。Vue3 支持
arr[0] = xxx触发更新,但如果你直接用length = 0清空数组,部分浏览器行为不一致,建议用arr.splice(0)。
真正的“强制更新”场景,我用得最多的是通过给组件变更 key 值来强制整树重建。比如某个第三方组件内部状态混乱、重置 API 不管用时,在父组件维护一个renderKey,每次需要重置就renderKey++,然后把:key="renderKey"传给子组件。这个方法简单可靠,副作用是组件内所有状态丢失,使用时想清楚。
5.5 监听 scrollHeight 的正确姿势
监听 DOM 的 scrollHeight 变化,直接在scroll事件里读取是常见做法,但如果列表内容是通过异步渲染或图片加载引起的,scroll 事件触发时机往往滞后。更可靠的办法是配合ResizeObserver观察容器尺寸变化:
const container = ref<HTMLElement>() onMounted(() => { const observer = new ResizeObserver(() => { const h = container.value?.scrollHeight ?? 0 // 根据高度变化做业务处理 }) observer.observe(container.value!) onBeforeUnmount(() => observer.disconnect()) })ResizeObserver能捕获元素内容尺寸变化,对懒加载、图片加载、异步插入 DOM 都适用。如果你要监听的是整个 document 的高度变化,用document.documentElement.scrollHeight配合MutationObserver监听 body 子节点变化,也可以,但注意 MutationObserver 回调会比较频繁,要做节流。
5.6 微前端下的路由异常:沙箱与 base 问题
微前端在 Vue3 项目里绕不开 qiankun 或 wujie。热搜词里有“Vue3 和 React 通过微前端沙箱机制”的描述,这里我补充一个排查重点:样式隔离。
qiankun 的 JS 沙箱通过 Proxy 拦截全局变量的读写,样式隔离通过给子应用容器包裹 shadow DOM 或动态处理样式表。如果你发现子应用样式错乱或路由跳转失效,先确认子应用里有没有把全局样式直接写在index.html或使用不受控制的 CSS 变量。另外 history 路由下,子应用必须显式设置base为子应用挂载路径,否则刷新时请求的资源路径全部指向主应用域名,页面直接 404。
用 wujie 的话,它的无界方案对样式隔离处理得更好,但对 Web Components 的兼容性有一定要求。我的建议是:新项目优先考虑 wujie,老项目继续用 qiankun 也完全可以,关键是路由的 base 配置和子应用实例的销毁逻辑一定要写对。还有一点,子应用的createApp实例一定要在unmount时执行app.unmount(),并清除全局事件监听,否则内存泄漏和重复渲染跑不掉。
最后分享两个小技巧
我个人在实际项目里吃了不少亏后,养成了两个习惯:一个是所有涉及第三方库接入的组件,都单独封装一层,不直接在业务代码里调二方库 API,这样库升级时只需要改封装层,业务不用动;另一个是给所有异步操作统一封装 loading 和错误处理,表单提交、列表请求、导出、下载都不例外,虽然前期代码多写一点,但线上排查问题时真的省心。
这次 Vue3 的知识点总结基本覆盖了从原理到工程的常见问题。如果你正在准备面试,建议把第 2 部分的差异点背熟,再把第 3 部分的动态路由和第 5 部分的问题排查思路用自己的话说一遍,面试官会认为你是真的有实战经验而不是只会刷题。如果后续遇到新的奇葩问题,建议先看一下控制台报错、再定位到具体机制层,通常就能找到答案。