前端开发这条路上,Vue3 大概是这几年被问得最密集的一块。不管是刚转行做 web 前端开发的朋友,还是写了几年 Vue2 想升级的老手,绕不开的一个问题就是:Vue3 到底该怎么系统学?网上零散的 Vue3 入门教程很多,但大多是"讲一半",要么只讲语法不讲工程化,要么上来就甩一堆组合式 API 把人劝退。这篇我按自己的实际走法,从环境搭建、核心语法、组件通信、路由状态管理,一路写到后台管理系统实战和踩坑排查,尽量把"从入门到熟练使用"这条线拉通。内容偏实战,适合零基础想入门 Vue3 的、Vue2 转 Vue3 的、以及准备前端开发面试题想查漏补缺的同学。看完你至少能独立搭一个能跑的项目,并且知道遇到问题该往哪个方向查。
1. 先把 Vue3 是什么、能干什么讲明白
1.1 从操作 DOM 到操作数据,思路得先换过来
很多人学 Vue3 卡住,不是卡在语法,是卡在思路。早些年写页面,习惯是"我要改这个按钮的文字",于是先document.getElementById拿到元素,再改innerText。这套写法在页面小的时候没问题,一旦页面上有十几个地方要联动,代码就会变成一团互相调用的面条。
Vue3 的核心思路反过来:你只管维护数据,数据变了,页面自己会跟着变。这种机制叫响应式。举个生活里的例子,传统 DOM 操作像是你手动去拨钟表的指针,指针走到几点全靠你拨;响应式像是钟表接了电,时间这个"数据"一变,指针自己就转过去了。
所以学 Vue3 的第一件事,是强迫自己不要再去想"怎么改 DOM",而是想"哪个数据变了,谁依赖这个数据"。这个思维转换过来之后,后面的ref、reactive、computed、watch全都会顺理成章。
1.2 三个必须搞懂的核心概念
Vue3 的东西看着多,但骨架就三根:响应式系统、组件化、单文件组件(SFC)。
响应式系统负责"数据变页面变",这是 Vue 的发动机。Vue3 用Proxy重写了这部分(Vue2 用的是Object.defineProperty),带来的直接好处是:可以监听数组下标赋值、可以监听对象属性的新增和删除、性能也更好。你在 Vue2 里写this.arr[0] = 'x'页面不更新、必须用$set的那种憋屈,在 Vue3 里基本没有了。
组件化是把页面拆成一块块可复用的积木。一个按钮、一个表格、一个弹窗,都可以是组件。组件之间通过 props 往下传数据、通过 emit 往上抛事件,这是 Vue 生态里最基础的通信方式。
单文件组件就是那个.vue文件,一个文件里同时写<script>、<template>、<style>,逻辑、结构、样式三件套放一起,改起来不用满项目找文件。这是 Vue 相比 React 在"新手友好度"上最大的优势之一。
1.3 学习顺序别搞反,这几样可以往后放
新手最容易犯的错是"什么都想一次学完",结果Teleport、Suspense、自定义渲染器、h函数全塞进脑子里,两天就放弃了。我的建议是按这个顺序走:
| 阶段 | 该学的内容 | 可以暂时跳过 |
|---|---|---|
| 第一周 | 模板语法、ref、computed、v-for、v-if | defineAsyncComponent、Suspense |
| 第二周 | 组件拆分、props/emit、插槽、生命周期 | 自定义指令、渲染函数 |
| 第三周 | Vue Router、Pinia、axios 封装 | SSR、Nuxt |
| 第四周 | 完整项目实战、打包部署 | 源码级 diff 算法 |
这个顺序的逻辑很简单:先把"能写出一个页面"的最小闭环搭起来,再往上加工程化的东西。一上来就啃mini-vue源码实现,属于顺序反了,容易劝退。
提示:官方文档是第一手资料,遇到 API 细节拿不准,直接查官方文档比搜二手博客靠谱得多,二手文章经常停留在旧版本写法。
2. Windows 下把 Vue3 开发环境一次装好
2.1 Node.js 与包管理器的选择
Vue3 项目离不开 Node.js,因为构建工具、依赖安装全靠它。去 Node.js 官网下 LTS 版本就行,别追求最新版,LTS 更稳。装完之后开命令行验证:
node -v npm -v两条命令都能打出类似v20.x.x和10.x.x的版本号,说明装好了。这里有个新手常踩的坑:装完 Node 之后必须重开一个命令行窗口,否则环境变量没刷新,敲node -v会提示"不是内部或外部命令"。
包管理器方面,npm 是自带的,够用。但我个人更推荐pnpm,原因是它用硬链接共享依赖,装包速度快、磁盘占用小。装法:
npm install -g pnpm pnpm -v如果公司项目要求统一用 npm,那就别折腾,跟着团队走。工具选型这件事,团队一致性比个人偏好重要。
2.2 用 Vite 创建项目,五分钟跑起来
早些年用vue-cli建项目,基于 webpack,启动一次要等十几秒。现在主流是Vite,冷启动基本一两秒,热更新几乎是即时的。创建命令:
pnpm create vite my-vue-app --template vue cd my-vue-app pnpm install pnpm dev--template vue表示用 JavaScript 版本;如果你确定要上 TypeScript,就用--template vue-ts。我的建议是:第一次学 Vue3 用 JS 版本,别一上来 JS 和 TS 一起学,两套心智负担叠加,容易崩。等语法熟了再切 TS 版本,那时候你会发现 TS 带来的类型提示确实香,但它是加分项,不是入门门槛。
跑起来之后浏览器打开终端里提示的地址(一般是http://localhost:5173),看到 Vue 的欢迎页就算成功了。
2.3 VS Code 插件与"怎么看网页界面由什么代码构成"
做前端开发,VS Code 基本是标配。必装的插件就两个:
- Vue - Official(原 Volar):提供
.vue文件的语法高亮、模板内补全、类型提示。装了它之后,一定要把旧的 Vetur 禁用掉,两个插件同时开会在模板里报一堆莫名其妙的错。 - ESLint + Prettier:代码规范和格式化,团队协作必备。
然后回答一个被问得特别多的热词问题:做网页前端开发,怎么看一个网页界面是由什么代码构成的?
答案是浏览器自带的开发者工具。在页面上按F12,或者右键选"检查",打开 Elements 面板。鼠标在上面移动,页面上对应区域会高亮,反过来在页面上右键"检查",也能直接定位到那个元素。这个操作要养成肌肉记忆,因为它解决的问题是:你看到一个效果,想知道它是怎么实现的。
具体几个常用用法。一是看样式:选中元素后右侧 Styles 面板会列出所有生效的 CSS 规则,被划掉的表示被覆盖了,这能帮你快速定位样式冲突。二是看层级:div套div套了几层,一眼能看出来,很多"点了没反应"的问题,就是因为上面盖了一个透明的遮罩层。三是看网络:Network 面板能看到每个请求的状态码、返回内容、耗时,接口联调时全靠它。
VS Code 这边虽然也能通过 Live Preview 之类的插件预览页面,但调试能力远不如浏览器原生工具。我的习惯是:写代码在 VS Code,调样式和查接口在浏览器 F12,两个窗口并排放。
2.4 项目目录结构逐个说清楚
Vite 创建出来的项目长这样:
my-vue-app ├── public/ 静态资源,原样拷贝到打包目录 ├── src/ │ ├── assets/ 需要被构建处理的资源,比如图片、字体 │ ├── components/ 公共组件 │ ├── App.vue 根组件 │ └── main.js 入口文件 ├── index.html 页面模板 ├── vite.config.js 构建配置 └── package.json 依赖与脚本几个关键点解释一下。main.js是入口,里面的createApp(App).mount('#app')干的事就是:创建一个 Vue 应用,把它挂到index.html里 id 为app的那个节点上。index.html里那个<div id="app"></div>就是整个应用的"容器"。
public和assets的区别经常有人搞混:public里的文件会被原封不动复制到打包输出目录,必须用绝对路径引用,比如/logo.png;assets里的文件会被 Vite 处理(压缩、加哈希、转 base64),需要用import或相对路径引用。放图片时想清楚要不要走构建处理,选错了会出现"开发环境能显示、打包后 404"的经典问题。
3. 核心语法打地基:这四组 API 决定你写得多顺
3.1 模板语法与常用指令,先混个脸熟
模板就是<template>里写的那部分,它长得像 HTML,但多了指令。核心的指令其实没几个:
<script setup> import { ref } from 'vue' const msg = ref('你好') const list = ref(['苹果', '香蕉', '橙子']) const show = ref(true) const handleClick = () => { msg.value = '被点了' } </script> <template> <p>{{ msg }}</p> <button @click="handleClick">点我</button> <ul> <li v-for="(item, index) in list" :key="index">{{ item }}</li> </ul> <p v-if="show">这行会显示</p> </template>{{ }}是插值,把数据显示到页面上。@click是v-on:click的简写,绑定事件。v-for做列表渲染,:key前面那个冒号是v-bind的简写,表示绑定一个动态值而不是写死的字符串。v-if控制元素是否渲染。
这里有两个新手必踩的坑。第一,v-for的:key不要随便用 index,如果列表会做增删排序,用 index 会导致复用错位,尽量用数据里唯一的 id。第二,v-if和v-for不要写在同一个元素上,Vue3 里v-if的优先级比v-for高,你在v-if里想用v-for的循环变量是拿不到的,正确做法是在外层套一个<template v-for>再在里面写v-if。
3.2 ref 和 reactive 到底用哪个
这是组合式 API 里最让人迷糊的一对。简单说:ref什么类型都能装,reactive只能装对象和数组。
import { ref, reactive } from 'vue' const count = ref(0) const user = reactive({ name: '张三', age: 18 }) count.value++ // ref 要 .value user.age = 19 // reactive 直接改注意那个.value。很多人问为什么ref非要加.value,模板里又不用加?原因是ref返回的是一个带.value属性的包装对象,靠这个包装才能对基本类型做响应式追踪;而模板里 Vue 会自动帮你解包,所以写起来没感觉。
那到底怎么选?我自己的经验是:统一用ref,只在明确需要一个对象聚合、且不想到处写.value的时候用reactive。理由是ref可以被整体替换(count.value = 新对象),reactive一旦被整个替换就丢失响应性了;另外ref传递和解构时更安全,reactive直接解构会丢响应性,得配合toRefs。统一用ref能减少团队协作时的决策成本。
注意:
reactive返回的对象,const { name } = user这样解构出来的是普通字符串,改它不会触发更新,必须const { name } = toRefs(user)。
3.3 computed 和 watch 的取舍
这两个都是"根据数据变化做点事",但定位完全不同。
computed是计算属性,本质是"派生数据"。它的结果会被缓存,依赖不变就不会重新计算,适合做数据加工:过滤数组、拼接展示文本、计算总价。
watch是侦听器,本质是"副作用"。它不产生新值,而是数据变化时去执行一段逻辑:发请求、写本地存储、操作 DOM、调第三方库。
| 维度 | computed | watch |
|---|---|---|
| 是否有返回值 | 有,返回新值 | 没有,只执行逻辑 |
| 是否缓存 | 有缓存 | 无缓存,变一次跑一次 |
| 典型场景 | 总价、过滤后的列表、状态文案 | 请求接口、存 localStorage、同步父组件 |
| 能否异步 | 不适合 | 适合,但要注意竞态 |
竞态是watch里最容易被忽略的坑。比如搜索框输入关键词后发请求,输入快了会连续发好几个请求,返回顺序不保证,可能出现"后发先至",页面显示的是旧关键词的结果。解决办法是做防抖,或者给每次请求打个序号,只接受最后一次的结果。
computed也有个隐藏坑:不要在computed里做赋值或者发请求。它应该是纯粹的"输入到输出",一旦掺进副作用,缓存逻辑就会变得不可预测,调试能把你逼疯。
3.4 生命周期钩子,记住常用的那几个就够
组件从创建到销毁会经历一系列阶段,每个阶段有对应的钩子函数。组合式 API 里这些钩子都以on开头:
<script setup> import { onMounted, onUnmounted, onUpdated } from 'vue' onMounted(() => { console.log('组件挂载完成,DOM 可以访问了') window.addEventListener('resize', handleResize) }) onUpdated(() => { console.log('数据更新导致 DOM 更新完成') }) onUnmounted(() => { window.removeEventListener('resize', handleResize) console.log('组件销毁,清理工作在这里做') }) </script>实际项目里用得最多的是onMounted和onUnmounted。前者用来初始化:请求首屏数据、初始化图表实例、绑定全局事件。后者用来清理:解绑事件、清除定时器、销毁第三方实例。
我见过太多内存泄漏的案例,根源都是"绑了没解"。典型场景是在onMounted里setInterval轮询接口,组件销毁了定时器还在跑,越切页面越卡。绑定的东西必须在onUnmounted里对应解绑,这条要刻进习惯里。
4. 组件化开发:把页面拆开再装回去
4.1 父子通信的四种常用姿势
组件通信的核心就一句话:数据往下传,事件往上传。
<!-- 子组件 Child.vue --> <script setup> const props = defineProps({ title: String, count: Number }) const emit = defineEmits(['update']) const handleClick = () => emit('update', props.count + 1) </script> <template> <div> <h3>{{ title }}</h3> <button @click="handleClick">加一</button> </div> </template><!-- 父组件 --> <script setup> import { ref } from 'vue' import Child from './Child.vue' const num = ref(0) </script> <template> <Child title="计数器" :count="num" @update="val => num = val" /> </template>defineProps声明父组件传进来的数据,defineEmits声明可以往外抛的事件。这两个宏不需要 import,在<script setup>里直接用。
除了这套基础玩法,还有两个进阶的:v-model双向绑定(本质是 props 加 emit 的语法糖)和插槽 slot。插槽解决的是"内容由父组件决定、布局由子组件决定"的场景,比如做一个弹窗组件,弹窗的壳子固定,里面放什么内容交给调用方。默认插槽、具名插槽、作用域插槽三兄弟,作用域插槽最有用也最容易忘,它能让你在父组件里拿到子组件的数据来渲染。
经验:props 是只读的,子组件里直接改 props 会报警告。要改的话,要么 emit 出去让父组件改,要么在子组件里用
computed包一层做本地化。
4.2 provide/inject 和状态提升怎么选
当层级比较深的时候,比如祖孙组件,用 props 一层层传会非常难受(业界叫"prop drilling")。这时候有两种方案。
一种是provide/inject,祖先组件提供数据,任意深度的后代都能注入:
// 祖先 import { provide, ref } from 'vue' const theme = ref('dark') provide('theme', theme) // 后代 import { inject } from 'vue' const theme = inject('theme')另一种是状态提升,把共享数据提到最近公共祖先,或者干脆交给 Pinia 全局管理。
怎么选?我的判断标准是:如果这个数据只在一个"局部树"里共享,比如一个表单页和它内部的若干子组件,用 provide/inject 就够;如果这个数据跨页面、跨模块,比如登录用户信息、权限列表、主题配置,那别犹豫,直接上 Pinia。
provide/inject 有个缺点:数据从哪来不好追溯,调试时看到inject('theme')得满项目搜provide('theme')。所以我在团队里会约定:provide 的 key 统一在一个常量文件里定义,别用裸字符串。
4.3 Vue Router:单页应用的路由骨架
Vue 做的是单页应用,页面切换实际是组件替换,这套机制靠 Vue Router 实现。安装和配置:
pnpm add vue-router@4// src/router/index.js import { createRouter, createWebHistory } from 'vue-router' const routes = [ { path: '/', component: () => import('../views/Home.vue') }, { path: '/login', component: () => import('../views/Login.vue') }, { path: '/user/:id', component: () => import('../views/User.vue') } ] const router = createRouter({ history: createWebHistory(), routes }) export default router这里用() => import()做的叫路由懒加载,效果是访问到那个路由时才加载对应代码,首屏体积能小一大截。做后台管理系统的时候,这一招基本是必用的。
路由守卫是另一个重点,用来做登录拦截:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path !== '/login' && !token) { next('/login') } else { next() } })这段逻辑看着简单,但新手常踩的坑是死循环:如果/login页面也被守卫拦了,就会一直跳登录页。所以判断里一定要排除登录页本身。另外要小心next()被调用多次,Vue Router 会报错。
4.4 Pinia:比 Vuex 好用不止一点点
Vue3 时代的状态管理首选Pinia,它是官方推荐的 Vuex 替代品。优势很直接:没有 mutations 那层嵌套,state、getters、actions三段式清爽,TypeScript 支持也好得多。
// src/stores/user.js import { defineStore } from 'pinia' import { ref, computed } from 'vue' export const useUserStore = defineStore('user', () => { const token = ref('') const userInfo = ref(null) const isLogin = computed(() => !!token.value) async function login(form) { // 假装调接口 const res = await fetch('/api/login', { method: 'POST', body: JSON.stringify(form) }) const data = await res.json() token.value = data.token userInfo.value = data.user } function logout() { token.value = '' userInfo.value = null } return { token, userInfo, isLogin, login, logout } })这种"组合式写法"的 Pinia store,看起来就跟写一个普通组件差不多,很符合 Vue3 的直觉。用的时候:
import { useUserStore } from '@/stores/user' const userStore = useUserStore() console.log(userStore.isLogin)有个细节要注意:store 的实例要在setup作用域里调用,别在模块顶层直接useUserStore()然后到处引用,容易出初始化顺序问题。
5. 实战:从零搭一个后台管理系统雏形
学完语法不动手,一周就忘光。我建议第一个实战项目就做后台管理系统,因为它把路由、权限、表格、表单、请求封装全都囊括了,是目前前端开发岗位上需求量最大的项目类型之一。
5.1 登录页:先把风格做出彩
后台系统第一眼就是登录页。现在挺流行的一种做法是点线动态背景:一堆小点在页面上缓慢漂移,距离近的点之间连一条线,鼠标靠近时线条会跟着变化。这个效果不复杂,用 Canvas 画就行,核心思路三步:生成 N 个随机位置的点、每帧让点按速度移动并反弹边界、双重循环计算点之间的距离,小于阈值就画线,线的透明度按距离线性插值。
关键代码大概这样:
function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height) points.forEach(p => { p.x += p.vx p.y += p.vy if (p.x < 0 || p.x > canvas.width) p.vx *= -1 if (p.y < 0 || p.y > canvas.height) p.vy *= -1 ctx.beginPath() ctx.arc(p.x, p.y, 2, 0, Math.PI * 2) ctx.fill() }) for (let i = 0; i < points.length; i++) { for (let j = i + 1; j < points.length; j++) { const d = Math.hypot(points[i].x - points[j].x, points[i].y - points[j].y) if (d < 120) { ctx.globalAlpha = 1 - d / 120 ctx.beginPath() ctx.moveTo(points[i].x, points[i].y) ctx.lineTo(points[j].x, points[j].y) ctx.stroke() } } } requestAnimationFrame(draw) }有两个实际要注意的地方。一是点的数量要控制,超过 150 个点,双重循环就是上万次距离计算,低配机器会掉帧,一般 60 到 100 个就够看了。二是必须在组件卸载时取消动画,用onUnmounted里cancelAnimationFrame停掉,否则路由切走了它还在后台空转,这就是前面说的"绑了没解"的典型。
5.2 布局、菜单和 Tabs 标签页样式改造
后台管理系统的布局基本是固定的:左侧菜单栏、顶部面包屑和用户信息、中间内容区。内容区上面常常加一排Tabs 标签页,点过的菜单会在这里留下标签,方便来回切。
默认样式的 Tabs 一般不够好看,改样式时就会遇到一个高频问题:组件库的样式改不动。原因是组件库的样式带作用域,你在<style scoped>里写的选择器命中不了。
解决办法是用深度选择器:deep():
<style scoped> :deep(.el-tabs__item) { font-size: 13px; height: 34px; line-height: 34px; } :deep(.el-tabs__active-bar) { height: 2px; } :deep(.el-tabs__nav-wrap::after) { display: none; } </style>:deep()的含义是"穿透 scoped 限制,往子组件内部找"。用它的原则是只穿透到必要的层级,别写:deep(.el-tabs .el-tabs__nav .el-tabs__item span)这种超长链,组件库一升级类名变了就全废。
心得:改组件库样式之前,先按 F12 看一眼实际的类名和层叠关系,很多时候不用
:deep(),只是你的选择器权重不够,加个父级类名就够了。
5.3 请求封装:别在每个组件里裸写 axios
新手写项目最容易出现的代码是:每个页面里都import axios然后重新配一遍 baseURL、重新写一遍错误处理。这在后期是灾难,接口域名换了要改几十个文件。
正确做法是统一封装:
// src/utils/request.js import axios from 'axios' const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE, timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) config.headers.Authorization = `Bearer ${token}` return config }) service.interceptors.response.use( res => { const { code, data, message } = res.data if (code === 200) return data if (code === 401) { localStorage.removeItem('token') location.href = '/login' } return Promise.reject(new Error(message || '请求失败')) }, err => { if (err.code === 'ECONNABORTED') console.error('请求超时') return Promise.reject(err) } ) export default service这里有几个设计考虑值得说。baseURL用import.meta.env.VITE_API_BASE从环境变量读,这样开发、测试、生产环境可以用不同的配置文件,不用改代码。请求拦截器统一带 token,省得每个接口手动传。响应拦截器统一处理业务错误码,把data直接返回出去,业务层拿到的就是干净的数据,不用写res.data.data这种套娃。
超时时间设 10 秒是有讲究的:太短了网络稍微抖一下就报错,太长了用户等得心焦。除了文件上传这种特殊接口单独调大,其余接口 10 到 15 秒比较平衡。
5.4 打包与部署,最后这一公里
开发完了要打包:
pnpm build产物在dist目录。这时候常见的问题来了:部署到服务器后,刷新页面 404。原因是单页应用只有index.html一个入口,路由是前端控制的,服务器上并不存在/user/123这个真实文件。解决办法是在服务器配置里加一条回退规则,把找不到的路径统统指回index.html。Nginx 的写法是:
location / { try_files $uri $uri/ /index.html; }另一个常见问题是资源路径不对,尤其是部署在子目录下时。这时候要在vite.config.js里设置base:
export default defineConfig({ base: '/my-app/', plugins: [vue()] })打包体积也是个绕不开的话题。第一招是路由懒加载,前面说过。第二招是看构建产物分析,把体积异常的依赖找出来。第三招是把 echarts、moment 这类大库按需引入,moment 尤其坑,换成 dayjs 能省一大截。我见过一个项目因为全量引入了 echarts 又没做拆分,首屏包 2MB 多,优化完降到 600KB 左右,体验差别非常明显。
6. 常见坑与排查技巧实录
6.1 环境和依赖类问题速查
这类问题的特点是你代码没写错,但项目就是起不来。我把常遇到的整理成表:
| 现象 | 大概率原因 | 处理办法 |
|---|---|---|
| 敲 node 提示不是内部命令 | 装完没重开终端 | 关掉命令行重开 |
| 装依赖卡住不动 | 镜像源慢或网络问题 | 切国内镜像源 |
| 启动报 EADDRINUSE | 端口被占用 | 换端口或杀掉占用进程 |
| 多人协作后依赖报错 | lock 文件冲突 | 删掉 node_modules 重新装 |
| TS 项目满屏类型报错 | 依赖版本或 tsconfig 不匹配 | 检查版本对齐,别乱升 |
关于类型报错,有个很典型的场景:在若依、jeecg 这类基于 Vue3 + TypeScript 的后台管理系统脚手架上做二次开发,改动依赖后突然出现一堆类型报错。这种情况九成是依赖版本没对齐,或者tsconfig.json里的types配置少了东西。我的处理顺序是:先看报错信息指向哪个库,再看package.json里这个库和它的类型包版本是否一致,最后再怀疑自己写的代码。别一上来就怀疑业务逻辑。
6.2 响应式失效的几种典型表现
"数据改了页面不动"是 Vue 新手最常见的困惑。按我遇到的频率排:
第一种,解构丢了响应性。前面说过reactive直接解构会失效,ref解构也会失效。解决办法是用toRefs或者干脆不解构。
第二种,把值存进了普通变量。比如const c = count.value,之后改count.value,c永远不变,因为它就是个普通数字拷贝。想保留联动就必须保留.value的引用。
第三种,整体替换了 reactive 对象。let state = reactive({...}),然后state = reactive({...})重新赋值,原来的引用断了。要整体替换就用ref,再state.value = {...}。
第四种,数组操作用了下标赋值或改 length。Vue3 用 Proxy 之后这个其实已经修好了,但如果你的响应式数据来源是Object.freeze过的配置对象,还是会失效。
排查思路很简单:先确认数据真的变了(在修改后console.log一下),再用 Vue Devtools 看组件里的值有没有跟着变。数据变了但视图没变,才叫响应式问题;数据压根没变,那是逻辑问题,两回事。
6.3 第三方库和样式的两个经典冲突
第一个:pxtorem 对 ECharts 没效果。这个问题在 Vue3 项目里被问得非常多。原因其实很好理解:postcss-pxtorem 是在 CSS 编译阶段把px转成rem,而 ECharts 是画在 Canvas 上的,它的尺寸、字体大小都是 JavaScript 运行时算出来的数值,压根不经过 CSS 编译。所以插件对它无效,这不是配置写错了。
解决办法有三个,按推荐度排:一是给 ECharts 的容器用百分比或vw宽度,让它自己适应;二是根据根节点的font-size手动换算,写个工具函数把设计稿的 px 值转成实际需要的数值,在option里用;三是监听窗口 resize,在回调里重新计算并setOption,同时调用chart.resize()。我一般用第一种加第三种组合,最省事。
第二个:浏览器卡到连窗口按钮都点不动。有同学反馈 Vue3 项目在 Edge 里偶尔会出现右上角最小化按钮点不了的情况。这个现象听起来像浏览器的问题,实际上绝大多数是主线程被阻塞了。浏览器窗口的按钮响应也依赖主线程,如果你的代码里有死循环、超大的同步计算、或者一次渲染了几万个 DOM 节点,主线程就一直被占着,浏览器自然"僵住"。
排查方向是打开性能面板录制一段,看有没有超长的任务。常见的元凶有:watch里写了会互相触发的逻辑形成死循环、大数组渲染没做虚拟列表、递归函数没写好边界条件。找到之后做三件事:把大计算拆成小块用requestIdleCallback或分片执行、列表用虚拟滚动、给递归加明确的终止条件。
提示:判断"是浏览器问题还是页面问题"有个简单办法,开一个空白标签页对比。空白页也卡,那是环境问题;空白页流畅,那就是你的页面把主线程拖死了。
6.4 面试常问的几个点,顺带把原理过一遍
准备前端开发面试题的时候,Vue3 相关的问法无非这么几类,我按被问频率说说怎么答。
diff 算法:Vue3 对比的是新旧两组虚拟节点,尽量复用。它做了几个优化——同层比较不跨层、用 key 建立映射、处理头尾相同节点的情况、找出最长递增子序列来减少真实 DOM 的移动。核心目标就一个:用最少的 DOM 操作把视图更新到位。
v-if 和 v-show 的区别:v-if是真正的条件渲染,切换时销毁和重建组件;v-show只是切display。频繁切换用v-show,条件很少变的用v-if。
Vue2 和 Vue3 的差异:响应式实现从defineProperty换成Proxy;逻辑复用从 mixin 换成组合式函数;新增了 Fragment、Teleport、Suspense;性能上做了静态提升、补丁标志等编译优化。
组合式 API 相比 Options API 好在哪:相关逻辑能写在一起,而不是散在 data、methods、computed 里;逻辑可以抽成函数复用,比 mixin 清晰,不会有命名冲突和来源不明的变量。
回答这类问题有个技巧:先给结论,再说为什么,最后补一个自己踩过的例子。纯粹背概念很干,加一个真实场景的对照,面试官印象会深很多。
7. 后面怎么继续往上走
语法和项目跑通之后,往上走大致有这么几个方向,你可以按兴趣挑。
想更深入框架本身,可以尝试手写一个简化版实现,也就是常说的 mini-vue。不用追求完整,先实现一个能跑通ref加模板渲染的最小版本,你会对"依赖收集"和"派发更新"这两个词有完全不一样的理解。这个过程有点烧脑,但收益很大,之后再去看源码会轻松很多。
想拓宽写法,可以了解一下在 Vue3 里用 JSX。模板语法覆盖了绝大多数场景,但遇到需要高度动态渲染的逻辑,JSX 会更灵活。别为了用而用,先用模板把业务写顺了,遇到模板表达别扭的地方再考虑。
想做更深度的应用,下一步就是性能优化和工程化:构建产物分析、按需加载、虚拟列表、首屏指标监控、CI 自动部署。这些内容在中小项目里不一定用得上,但一旦项目规模上去了,会不会这些直接决定你能不能在团队里扛事。
最后说一个我自己觉得最有用的习惯:每学一个新 API,就在当前项目里找一个真实场景用一次。看十篇教程不如自己动手改一次。我当初学watch的deep和immediate选项,就是因为在做菜单权限同步时踩了坑才彻底记住的——那会儿菜单数据是异步来的,组件挂载时还是空的,监听没加immediate就永远不触发,排查了半天才反应过来。这种坑踩过一次,比看文档记十遍都牢。