Vue3入门
前言
- 这篇是 Vue 3 的上手 + 日常使用笔记:响应式(
ref/reactive)、模板指令、computed/watch、组件与插槽、生命周期、路由(Vue Router 5)、状态管理(Pinia 4),最后给出「从 Vue 2 迁过来要改什么」和「与 React / Svelte 的对照」。 - 环境是 Node v26.13.0 + npm 12.2.0 + vue 3.5.44 + vite 8.3.5 + vue-router 5.4.0 + pinia 4.1.0(
npm ls --depth=0读出来的)。文中每条「实测」都是在临时目录里npm create vite建的真项目中跑起来、在浏览器里读 DOM 得到的,输出原样贴在示例下面。 - 一个容易忽略的点先说:Vue 模板里的双大括号插值(
{{ }})可以直接写在代码块里,Hexo 不会去解析它,所以本文大量出现的{{ count }}、{#if show}这类内容都不用额外处理。 - 但标签形式的花括号百分号不能写在行内代码里:它会被 Hexo 的标签解析吃掉。我原本在这句话里用行内代码写了 raw 标签,构建出来的 HTML 里那一处直接变成空的(
<code></code>);要展示这类标签语法,就把它放进三反引号的围栏代码块里——围栏内是安全的。
该不该学 Vue 3(现状与选型)
先给数据,别凭感觉。2026-11-03 用 https://api.npmjs.org/downloads/point/last-week/<包名> 实测的近一周下载量(接口返回的统计窗口是 2026-10-11 到 10-17):
| 框架 | 包名 | 近一周下载 | 当前最新稳定版 |
|---|---|---|---|
| React | react |
115,815,254 | 19.3.1 |
| Preact | preact |
23,145,815 | — |
| Vue | vue |
10,221,956 | 3.5.44 |
| SolidJS | solid-js |
4,623,443 | 1.9.18 |
| Svelte | svelte |
4,178,017 | 5.57.3 |
| Angular | @angular/core |
3,423,108 | 22.2.4 |
结论(我的判断,不是数据):
- React 是岗位最多、生态最大的那个,但它的「心智模型」和 Vue 差别不小(不可变数据、手动 memo、JSX)。
- Vue 是中文世界用得最广、上手最平缓的那个:模板就是 HTML,响应式是自动的,官方文档有中文版,国内资料/组件库(Element Plus、Ant Design Vue、Vant)齐全。
- Svelte 走的是第三条路(编译期干活,运行时几乎没有框架代码),值得知道,但生态和岗位都比前两个小。
- Angular / SolidJS 我这轮没单独成篇:Angular 是「全家桶 + TypeScript + RxJS」的重型方案,SolidJS 是 React 风格但细粒度响应式,两者在《Svelte5入门》和本文的对照小节里都有说明。
一句话建议:先学 Vue 3 建立「组件化 + 响应式」的直觉,再学 React 补上「不可变 + 显式」的纪律,两个都会了以后看任何新框架都很快。
环境准备与脚手架
Vue 3 官方推荐用 Vite 建项目(vue-cli 那套已经停止维护了)。实测命令:
1 | 1) 建项目(-- 后面是传给 create-vite 的参数) |
npm ls --depth=0 读出来的实际版本(^ 只是范围,装到机器上是多少就是多少):
1 | vue-demo@0.0.0 |
目录结构(每个文件是干什么的):
1 | vue-demo/ |
public/ 目录(如果建了)里的文件不经过打包,原样拷到站点根,适合放 favicon、robots.txt。
构建产物长什么样(实测)
1 | npm run build |
三个信息值得记:
Lazy-DTJ-UO94.js只有 0.22 kB,它是懒加载路由单独切出来的 chunk——说明() => import('./views/Lazy.vue')真的生效了。- 120.45 kB 是「vue + vue-router + pinia + 我这个实测台全部组件」的总量,gzip 后 47.39 kB。空模板项目只有 65.53 kB / gzip 25.72 kB,可以当作「框架本体 + 一个 HelloWorld」的量级参考。
- 构建快(118 ms),因为 Vite 生产构建用 Rollup、开发用 esbuild 预打包,不需要 webpack 那种全量编译。
SFC 编译出来的是什么
.vue 文件由 @vitejs/plugin-vue 编译成「一个组件对象 + 一个 render 函数」。把压缩关掉再构建(npx vite build --minify false --outDir dist-nomin),在产物里能直接看到编译痕迹(实测计数):
| 产物里出现的调用 | 次数 | 来自我写的哪段代码 |
|---|---|---|
createBlock |
9 | 模板里的块级元素(v-if/v-for/组件) |
createTextVNode |
7 | 插值 {{ msg }} 这类文本节点 |
useCssVars |
2 | SFC 样式里的 color: v-bind(theme) |
所以「模板」不是运行时解析字符串,它在构建期就变成函数调用了,这也是 Vue 首屏不慢的原因之一。
一个真实的坑:vue-router 和 pinia 不导出 version
我按直觉写了这一行,结果页面直接白屏:
1 | // 错:这两个包都没有导出 version,命名导入会在模块求值阶段就报错 |
浏览器控制台里不一定看得见错误(模块加载失败常常只表现为「#app 是空的」)。查版本请用 npm ls 或 npm view vue-router version;vue 本体是有 version 导出的(实测 import { version } from 'vue' 读到 3.5.44)。
响应式:ref 与 reactive
先说这是什么:Vue 的响应式 = 你改数据,视图自己更新。实现方式是 Proxy(Vue 2 用的是 defineProperty,所以 Vue 2 才需要你手动 Vue.set,Vue 3 不需要)。
ref(value):给任意值套一个盒子
1 | import { ref } from 'vue' |
- 为什么要有
.value:JS 里let x = 0; x = 1这种重新赋值没法被 Proxy 拦截,所以 Vue 把值装进一个对象的value属性里,就能监听读写。 - 模板里不用写
.value:编译器会自动解包({{ n }}直接可用)。 - 在对象字面量里放 ref 不会自动解包,那是
reactive的领域。
reactive(value):直接把对象变成 Proxy
1 | import { reactive, toRaw } from 'vue' |
reactive只能用于对象类值,不能包数字/字符串。- 别解构它:
const { user } = state会丢掉响应性,要用toRefs(state)。 toRaw(proxy)拿回原始对象,适合「传给第三方库、别让代理搅事」的场景。
两者怎么选(我的规则)
| 场景 | 用什么 | 原因 |
|---|---|---|
| 单个基本类型(数字、字符串、布尔) | ref |
reactive 包不了 |
| 表单对象、一组相关状态 | reactive |
深层属性直接改,写起来短 |
需要整体替换(state = newObj) |
ref |
只有 ref 能靠 .value 换掉整个值 |
| 从 composable 返回一堆状态 | ref |
解构安全,调用方一眼看出哪些是响应式的 |
响应式家族的其他成员(全部实测)
1 | import { ref, reactive, shallowRef, markRaw, toRef, toRefs, effectScope, watch, nextTick } from 'vue' |
ref 包数组时,变异方法直接生效(实测 arr.value.push(3) 后长度是 3),不用再像 Vue 2 那样绕。
模板语法与指令
模板就是「带指令的 HTML」。指令统一是 v- 前缀,写在元素属性位置。
插值与三种「显示」写法
1 | <template> |
v-if 与 v-show 的差别我是直接查 DOM 验证的:
1 | v-if=false 的元素在 DOM 里吗 :: false |
选择规则:频繁切换用 v-show(省销毁重建),不常出现或初始化开销大用 v-if。
指令速查(每个都标了参数)
| 指令 | 写法 | 说明 |
|---|---|---|
v-bind / : |
:href="url"、:class="{ active: isActive }"、:[attr]="name" |
把属性/特性的值绑到表达式;: 是缩写 |
v-on / @ |
@click="fn"、@keyup.enter="fn"、@click="count++" |
绑事件;值可以是方法名、内联表达式或方法调用 |
v-model |
v-model="text"、v-model.trim、v-model.number、v-model.lazy |
双向绑定;组件上绑的是 modelValue |
v-for |
v-for="(item, i) in list" :key="item.id" |
列表渲染,key 必写(见下面实测) |
v-if / v-else-if / v-else |
同 JS 的分支 | 条件渲染 |
v-once |
<p v-once>{{ staticText }}</p> |
只渲染一次,之后不再更新 |
v-memo |
:v-memo="[a]" |
依赖没变时跳过整棵子树的更新(长列表优化) |
v-cloak |
[v-cloak]{display:none} |
配合 CDN 用法,防止未编译的花括号闪一下 |
v-pre |
<Foo v-pre /> |
跳过这个元素及其子元素的编译 |
is |
<component :is="which" /> |
动态组件(见 KeepAlive 小节) |
class 与 style 绑定(实测结果)
1 | <script setup> |
渲染出来的 DOM(实测):
1 | 对象+数组 class 绑定结果 :: active btn btn-primary |
disabled: false 没有出现在 class 里——这就是对象语法的价值:「加不加」由数据决定,不用自己拼字符串。
事件修饰符(实测)
1 | <div @click.capture="onCapture"> <!-- 捕获阶段先跑 --> |
我在页面里程序化点了 .stop 一次、.once 两次,读到的计数是:
1 | 事件修饰符(点完按钮再读) :: capture=1 stop=1 once=1 |
capture=1 说明捕获监听在 .stop 之前就跑过了(.stop 只挡冒泡);once=1 说明第二次点击确实无效。
v-for 的 key 到底在管什么(实测对照)
两个列表内容完全一样,唯一区别是一个写了 :key,一个没写。先往每行的输入框里填上「输入了A / 输入了B」,再把数组 reverse():
1 | <!-- 带 key --> |
重排后读 DOM:
1 | 带 key:第一个输入框 "输入了B",同一行文字 "B" |
带 key 时输入框跟着 B 这一条数据走,一切自洽;不带 key 时 Vue 按位置复用 DOM 节点,文字更新了、输入框留在原位,于是「B 这一行显示着 A 的内容」。有状态的东西(输入框、勾选、动画、组件实例)出现在列表里,key 就必须写,而且要写数据自己的稳定标识(item.id),别用 index——index 在增删和重排时正好会变,等于没写。
computed 与 watch
computed(getter):有缓存的派生值
1 | import { ref, computed } from 'vue' |
这就是 computed 和「写个方法调用」的区别:依赖不变就不重算。
可写 computed
1 | const first = ref('苏') |
用途:表单里「一个输入框对应两个字段」时,比手写 watch 干净。
watch(source, cb, options):参数逐个说清
| 参数 | 类型 | 默认 | 说明 |
|---|---|---|---|
source |
ref / getter 函数 / reactive 对象 / 数组 | 必填 | 监听谁。getter 最精确(只盯一个字段) |
cb(newValue, oldValue) |
函数 | 必填 | 变化时执行。监听 reactive 对象时新旧值常常相同(同一个代理) |
options.immediate |
布尔 | false |
为 true 时注册就立刻跑一次 |
options.deep |
布尔 | false |
递归追踪内部变化。source 是 getter 返回对象时也需要它 |
options.flush |
'pre'|'post'|'sync' |
'pre' |
回调在什么时机跑,见下面实测 |
options.once |
布尔 | false |
触发一次后自动停止(3.5 起) |
| 返回值 | 函数 | — | 调用它就能手动停止监听 |
watchEffect(fn) 则是「自动收集依赖 + 立即执行一次」,不用列出监听谁。
实测三件事:
1 | 注册 watch 后立刻的执行次数(默认 immediate:false) :: 0 |
前两条说明「要不要加 immediate: true」是实打实的差别;后两条说明用 getter 精确监听嵌套字段时不用开 deep,效果一样但开销小得多。
flush 的时机(这条最容易踩)
1 | const msg = ref('a') |
实测输出:
1 | flush:pre 回调里读到的 DOM 文本 :: a |
要在数据变化后读 DOM(尺寸、滚动位置、给 ECharts 喂新数据),就用 flush: 'post',或者在回调里 await nextTick()。
nextTick:DOM 什么时候真的更新了
1 | import { nextTick } from 'vue' |
实测三条连起来看:
1 | 挂载时 DOM 里是 :: 初始文本 |
Vue 的更新是异步批量的:同一帧里改十次数据,DOM 只更新一次。nextTick() 就是「等这次更新做完」。
组件:props、emits 与插槽
定义组件:<script setup> 是首选
1 | <script setup> |
defineProps / defineEmits / defineModel / defineExpose / defineOptions 这几个 define* 是编译器宏:不用 import,编译时就变成组件选项。
props 是只读代理(实测)
1 | props.title = '子组件想改' |
数据只能单向向下流动,子组件要改就 emit 让父组件改。这条限制是 Vue 里「数据从哪来」永远可追的原因。
父子通信的四条路
| 方向 | 手段 | 什么时候用 |
|---|---|---|
| 父 → 子 | props |
一切初始数据 |
| 子 → 父 | emit 事件 |
子组件里的用户动作 |
| 父 → 子(双向绑定) | v-model / defineModel |
表单类组件 |
| 父 → 任意后代 | provide / inject |
跨多层(主题、当前用户、表单实例) |
| 父直接调子方法 | defineExpose + 模板 ref |
imperative 操作(聚焦、重置、播放) |
provide / inject 实测:
1 | // 祖先组件 |
1 | 子组件 inject 到 :: Hexo-NexT |
注意 inject() 必须在 setup 的同步阶段调用,放进 onMounted 里就取不到了。
插槽(实测三种写法)
1 | <!-- 子组件 --> |
从 DOM 里读回来的结果:
1 | 子组件默认插槽文本 :: 默认插槽内容 |
- 默认插槽:父组件塞进子组件标签之间的内容;子组件里
<slot>兜底</slot>的兜底文字只在父没给内容时出现。 - 具名插槽:
<template #header>对应<slot name="header" />。 - 作用域插槽:子组件把数据挂在
<slot :from="...">上,父组件用#header="{ from }"解构接收——「子组件提供数据、父组件决定怎么渲染」。
defineModel:组件上的 v-model(3.4+)
1 | <script setup> |
它把「声明 modelValue prop + 声明 update:modelValue 事件 + 手动 emit」三件事合成一行。父组件写 <MyInput v-model="kw" /> 就通了。多个绑定值用 defineModel('title') 这种带名字的形式,父组件写 v-model:title。
defineExpose:父组件调用子组件方法
1 | // 子组件:默认 <script setup> 里的东西对外不可见,必须显式暴露 |
实测:
1 | 父组件调用子组件暴露的方法 :: ping 被父组件调用,累计 1 次 |
多根节点组件要留心 class 落点
Vue 3 允许组件有多个根节点(fragment)。这时父组件传下来的 class/style/事件不知道该落到哪个根上,开发版会警告。实测抓到的原文:
1 | 开发版警告总条数 :: 1 |
这条警告每次重渲染都会再打一遍(我在交互后看到它累计出现 5 次),所以别当噪音。解决办法:单根,或者手动把 $attrs 绑到指定元素上(<p v-bind="$attrs">),或 defineOptions({ inheritAttrs: false }) 后自己控制。
生命周期
组合式 API 里的钩子要显式 import,且必须在 setup 同步阶段注册:
1 | import { onMounted, onBeforeMount, onUpdated, onBeforeUnmount, onUnmounted } from 'vue' |
父子执行顺序(实测字符串)
1 | 父子生命周期顺序 :: App setup → App beforeMount → Child setup → Child beforeMount → Child mounted → App mounted |
记两条就够:setup 早于 mounted(setup 阶段还没有 DOM,别在里面查询元素);子组件的 mounted 早于父组件的 mounted(父挂载时子树已经挂完)。
卸载时清理计时器(实测)
1 | const id = setInterval(() => { n.value++ }, 1000) |
用 v-if 把子组件卸载后读到的结果:
1 | 子组件卸载后 onUnmounted 做的事 :: onUnmounted 里 clearInterval 已执行 |
与 Vue 2 选项式的对照表
| Vue 2(选项式) | Vue 3(组合式) | 说明 |
|---|---|---|
beforeCreate / created |
setup() 本体 |
setup 就取代了这两个钩子 |
beforeMount |
onBeforeMount |
一致 |
mounted |
onMounted |
一致 |
beforeUpdate / updated |
onBeforeUpdate / onUpdated |
一致 |
beforeDestroy |
onBeforeUnmount |
改名(destroy → unmount) |
destroyed |
onUnmounted |
改名 |
errorCaptured |
onErrorCaptured |
一致 |
activated / deactivated |
onActivated / onDeactivated |
KeepAlive 内的组件才有 |
renderTracked / renderTriggered |
onRenderTracked / onRenderTriggered |
调试用 |
组合式函数(composable)
这是什么:把「一段有状态 + 有逻辑的代码」抽成一个以 use 开头的函数,任何组件都能调用它复用。它替代了 Vue 2 的 mixin(mixin 的来源不明、会撞名,官方已经不推荐)。
1 | // src/composables/useCounter.js |
1 | <script setup> |
三条约定值得守:
- 文件名
useXxx.js,函数名useXxx——社区通用约定,不是语法要求。 - 每次调用都是新的一份状态(这就是和 mixin 的差别),要共享就得交给 Pinia。
- 内部要注册钩子(
onMounted等)时,只能在同步阶段注册;放进setTimeout里注册会失效并警告。
另外「一份状态全应用共享」的写法是把 ref 提到函数外面(模块级单例),但要注意服务端渲染下会跨请求串数据——所以共享状态还是用 Pinia。
动态组件、KeepAlive 与自定义指令
<component :is>:在运行时决定渲染哪个组件
1 | <script setup> |
KeepAlive:切走时不销毁,只缓存
我让一个组件的 setup() 自己计数,然后来回切换两次,看它到底被创建了几次:
1 | KeepAlive 开着时来回切 2 次,A 的 setup 次数 :: 1 |
- 包
<KeepAlive>时 A 只被创建 1 次(第一次),之后都是复用缓存实例,状态(输入、滚动位置、已加载的数据)都还在。 - 不包时每次切回来都重新
setup,1 + 2 = 3 次。
<KeepAlive> 的两个常用属性:
| 属性 | 写法 | 作用 |
|---|---|---|
include / exclude |
:include="['TabA']" |
按组件 name 过滤要缓存谁 |
max |
:max="10" |
最多缓存几个,超了按 LRU 淘汰 |
被缓存的组件里用 onActivated / onDeactivated 感知「重新显示 / 被收起」,比 onMounted 更准(onMounted 只在第一次创建时跑)。
自定义指令:v-focus 与带参数的指令
1 | // 局部指令:在 setup 里定义,变量名以 v 开头即可自动匹配 v-xxx |
1 | <input v-focus /> |
实测:
1 | v-focus 指令后元素是否聚焦 :: true |
指令的钩子有 created / beforeMount / mounted / beforeUpdate / updated / unmounted。能用组件和 computed 解决的事别用指令——指令的适用场景是「直接操作 DOM 的独立小行为」:埋点、权限禁用、自动聚焦、拖拽。
Vue Router 5:路由
装与注册(main.js):
1 | import { createApp } from 'vue' |
路由表(src/router.js):
1 | import { createRouter, createWebHistory } from 'vue-router' |
页面里怎么用(实测)
1 | import { useRoute, useRouter } from 'vue-router' |
跳转后读到的结果:
1 | 当前路由 :: / name=home |
注意 props.id 的类型是 string——URL 段本来就是字符串,要数字得自己转(props: { id: { type: Number, cast: true } } 或在组件里 Number(props.id))。props: true 的作用是把动态段直接变成组件的 prop,这样组件不依赖路由也能复用。
导航守卫的执行顺序(实测)
1 | router.beforeEach((to, from) => { |
组件内还有 onBeforeRouteLeave(离开前)和 onBeforeRouteUpdate(同组件参数变化)。我按「首页 → about → 懒加载页 → 回首页」走了一遍,记录到的顺序:
1 | beforeEach / -> /about/42?q=x | afterEach /about/42?q=x | onBeforeRouteLeave 触发(离开 about) | beforeEach /about/42?q=x -> /lazy | afterEach /lazy | beforeEach /lazy -> / | afterEach / |
规律:全局 beforeEach → 组件内 onBeforeRouteLeave → 全局 afterEach。离开某个页面前想问「未保存要不要走」,就写在离开的那个组件里。
<RouterLink> 与 <RouterView>
1 | <RouterLink :to="{ name: 'about', params: { id: 1 } }" |
<RouterView> 还能配合嵌套路由(子路由表里写 children,父组件里再放一个 <RouterView />)。
Pinia 4:状态管理
先判断要不要它:只在「多个不相干的组件要读写同一份状态」时才引入;父子之间用 props/emit,跨层用 provide/inject 就够了。
定义 store(setup 语法,官方现在推荐这个)
1 | // src/stores/counter.js |
在组件里用(实测数值)
1 | import { storeToRefs } from 'pinia' |
实测:
1 | store.inc() 两次后 count :: 2 |
Pinia 常用 API 一览
| API | 作用 | 备注 |
|---|---|---|
useXxx() |
拿到 store 实例 | 必须在 setup 同步阶段调用 |
storeToRefs(store) |
安全解构 state/getter | 方法可以直接解构,响应式状态不行 |
store.$patch({ ... }) |
批量改状态 | 比逐条赋值更高效,devtools 里合并成一条 |
store.$patch((state) => {...}) |
用函数改 | 适合数组 push 之类 |
store.$reset() |
重置回初始值 | setup 写法的 store 需要自己实现重置 |
store.$subscribe(cb) |
订阅状态变化 | 常用于持久化到 localStorage |
store.$onAction(cb) |
订阅 action 调用 | 打点、统一错误处理 |
与 Vuex 的关系
Vuex 4 已进入维护模式,官方新文档一律推荐 Pinia:没有 mutations(直接在 action 里改)、没有嵌套 modules 的心智负担、TypeScript 推导好得多、体积更小。老项目里 Vuex 和 Pinia 可以共存,迁移是一个 store 一个 store 换的。
从 Vue 2 迁到 Vue 3 要改什么
| 项目 | Vue 2 | Vue 3 | 迁移要点 |
|---|---|---|---|
| 创建应用 | new Vue({ el }) |
createApp(App).mount('#app') |
全局配置从 Vue.config 挪到 app.config;Vue.use/use mixin 变成 app.use() |
| 响应式底层 | defineProperty |
Proxy |
新增/删除属性、数组下标赋值都能感知,Vue.set/this.$set 删掉 |
| 组织方式 | Options API(data/methods/computed…) | Composition API(<script setup>) |
Options API 仍可用,大组件才值得换 |
| 生命周期 | beforeDestroy/destroyed |
onBeforeUnmount/onUnmounted |
改名,见上一节的对照表 |
v-model |
绑 value + input 事件 |
绑 modelValue + update:modelValue |
组件上还能 v-model:title 多值绑定 |
.sync |
:title.sync |
v-model:title |
官方直接删了 .sync |
$listeners |
单独一个 | 合并进 $attrs |
事件监听器也变成普通属性透传 |
| 事件总线 | new Vue() 当 bus |
已删除 | 换成 mitt,或直接用 provide/inject、Pinia |
过滤器 | 语法 |
{{ x | date }} |
已删除 | 换成方法或 computed |
| 模板根节点 | 必须单根 | 可以多根(fragment) | 多根时 class/事件不会自动继承,会警告(实测见组件那节) |
| 函数式组件 | functional: true |
删掉,用无状态组件 | — |
key 语义 |
可省 | 列表强烈要求 | 不写会出「输入框串数据」的问题(实测见模板那节) |
| 打包/开发 | vue-cli + webpack | Vite | process.env.VUE_APP_* 变 import.meta.env.VITE_* |
迁移工具:官方给了 @vue/compat 兼容构建(在 Vue 3 里模拟 Vue 2 行为并打印差异),还有 Vue 3 迁移指南。小项目建议直接改,别长期挂着 compat。
与 React、Svelte 的对照
| 维度 | Vue 3 | React 19 | Svelte 5 |
|---|---|---|---|
| 写界面 | HTML 模板 + 指令 | JSX(JS 里写标签) | HTML 模板 + 少量指令 |
| 响应式 | 自动(Proxy 追踪依赖) | 手动:状态必须「换新对象」才重渲染 | 编译期插桩:$state 变量赋值即更新 |
| 状态更新的坑 | 改 .value 忘了写 |
直接 mutate 对象「看起来没反应」 | 解构 $state 后失去响应性 |
| 派生值 | computed(自带缓存) |
直接算,或 useMemo(要自己判断该不该用) |
$derived |
| 副作用 | watch / watchEffect + onUnmounted |
useEffect(依赖数组 + 清理函数) |
$effect |
| 组件复用逻辑 | composable(useXxx) |
自定义 Hook(useXxx) |
runes 直接写在组件里 / .svelte.js 模块 |
| 官方路由 | Vue Router 5 | 无(用 React Router 7 / Next.js) | SvelteKit 内置 |
| 官方状态管理 | Pinia 4 | 无(Zustand / Redux Toolkit / Jotai) | 无($state + context 常已够用) |
| 心智负担 | 中等:模板直观,但要理解 .value |
偏高:不可变、闭包、渲染时机都要懂 | 低:写起来最像「普通 JS」 |
| 运行时体积 | 中(含小型运行时) | 大(react-dom 是主要开销) | 极小(逻辑编译进你的代码) |
构建产物量级(三个官方模板的 demo,内容不等价,只看数量级,实测 gzip):
| 模板 | JS 体积 | gzip |
|---|---|---|
--template vue |
65.53 kB | 25.72 kB |
--template react |
222.44 kB | 69.28 kB |
--template svelte |
30.81 kB | 12.40 kB |
Vue 与 React 的最大分歧是这一个:Vue 帮你追踪依赖(改哪个字段就更新用到它的地方),React 要求你「造一个新值」并自己保证不 mutate。写熟了 React 再回 Vue 会觉得「怎么这么省事」,写熟了 Vue 再读 React 才会明白 useEffect 依赖数组为什么那么烦人。
常见坑速查
- 忘了
.value:if (someRef)永远为真(那是个对象),要写someRef.value。模板里不用写。 - 解构
reactive对象:const { a } = state之后改state.a不会更新视图,用toRefs(state)。 inject()放进了onMounted:拿不到值。它必须在 setup 同步阶段调用。useTemplateRef放进了onMounted:实测返回undefined;同样要在 setup 里调用,之后再用.value。v-for用index当 key:增删或重排时 DOM 复用错位,输入框内容串到别的行(本文有实测对照)。watch里读 DOM 读到旧值:默认flush: 'pre',DOM 还没更新。改flush: 'post'或await nextTick()。- 多根组件传
class报警告:Extraneous non-props attributes (class) ...,每次重渲染都会再打一遍。单根,或v-bind="$attrs"手动指定。 - 给
props赋值:静默失败(只读代理),别指望它生效;要改就 emit 事件让父组件改。 <script setup>里写顶层await:组件会变成异步组件,必须包在<Suspense>里,否则整页空白。import { version } from 'vue-router'/'pinia':它们不导出version,模块直接报错、页面白屏。查版本用npm ls。- Pinia 里直接解构 store:
const { count } = store丢响应性,要storeToRefs(store);方法可以直接解构。 - 计时器/监听器没在
onUnmounted清:组件卸载后还在跑(KeepAlive 缓存的组件甚至根本不会卸载,逻辑要放onDeactivated)。 v-html渲染用户输入:XSS 风险,Vue 不会帮你消毒。- 样式没生效:
<style scoped>只作用于本组件模板里的元素,插槽内容属于父组件、:deep(.child-class)才能穿透子组件。
练习
按顺序做,每一步都能在浏览器里看到结果:
- 计数器与双向绑定:
useCounter(10)写成一个 composable,两个组件各自调用,确认它们的count互不影响;再加一个readonly保护,试着在组件里count.value++看看会发生什么。 - 缓存与执行次数:照抄本文的
computed计数器实验,把void (double.value + double.value)改成在模板里引用三次{{ double }},看执行次数是不是仍然只有 1。 key复现:做一个 3 行的待办列表,每行一个输入框,先不写:key,在中间插入一条,观察输入框内容怎么错位;再补上:key="item.id"对比。flush与nextTick:写一个元素高度自适应的输入框(内容变多就长高),分别在flush: 'pre'和flush: 'post'里读scrollHeight,解释为什么一个对一个错。- 插槽三件套:做一个
<Panel>组件,支持默认插槽 +#header作用域插槽(把「折叠状态」传给父组件),再用它拼一个可折叠卡片。 - KeepAlive 验证:两个 Tab 组件里各放一个输入框,来回切换,比较加
<KeepAlive>与不加时「切回来内容还在不在」。 - 路由与守卫:给
/about/:id配props: true,做一个「未保存离开要确认」的onBeforeRouteLeave;用npx vite build --minify false看懒加载路由有没有单独成 chunk。 - Pinia + 持久化:用
store.$subscribe把状态写进localStorage,刷新页面后恢复;再试着不加storeToRefs直接解构,观察界面为什么不更新。