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
2
3
4
5
6
7
8
9
10
11
# 1) 建项目(-- 后面是传给 create-vite 的参数)
npm create vite@latest vue-demo -- --template vue

# 2) 装依赖
cd vue-demo
npm install

# 3) 三个常用脚本
npm run dev # 开发服务器,默认 http://localhost:5173
npm run build # 生产构建,产物在 dist/
npm run preview # 本地预览 dist/ 的构建结果

npm ls --depth=0 读出来的实际版本(^ 只是范围,装到机器上是多少就是多少):

1
2
3
4
5
6
vue-demo@0.0.0
├── @vitejs/plugin-vue@6.0.10
├── pinia@4.1.0
├── vite@8.3.5
├── vue-router@5.4.0
└── vue@3.5.44

目录结构(每个文件是干什么的):

1
2
3
4
5
6
7
8
9
10
11
vue-demo/
├── index.html # 真正的入口页,里面有 <div id="app"> 和 <script type="module" src="/src/main.js">
├── vite.config.js # Vite 配置:注册 @vitejs/plugin-vue 才能解析 .vue 文件
├── package.json # 脚本与依赖
├── public/ # 不经过打包、原样拷到站点根的静态文件
└── src/
├── main.js # createApp(App).mount('#app'),插件(router/pinia)在这里 use
├── App.vue # 根组件
├── components/ # 自定义组件(约定:一个组件一个 .vue 文件)
├── assets/ # 会被打包器处理的静态资源(图片、字体)
└── style.css # 全局样式(不走 scoped)

public/ 目录(如果建了)里的文件不经过打包,原样拷到站点根,适合放 favicon、robots.txt。

构建产物长什么样(实测)

1
2
3
4
5
6
7
8
9
10
11
12
$ npm run build
vite v8.3.5 building client environment for production...
transforming...
✓ 33 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.45 kB │ gzip: 0.29 kB
dist/assets/index-BLiPCDe0.css 4.15 kB │ gzip: 1.50 kB
dist/assets/Lazy-DTJ-UO94.js 0.22 kB │ gzip: 0.23 kB
dist/assets/index-Dx_F4eIb.js 120.45 kB │ gzip: 47.39 kB

✓ built in 118 ms

三个信息值得记:

  • 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
2
3
// 错:这两个包都没有导出 version,命名导入会在模块求值阶段就报错
import { version } from 'vue-router'
import { version as piniaVersion } from 'pinia'

浏览器控制台里不一定看得见错误(模块加载失败常常只表现为「#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
2
3
4
5
6
7
import { ref } from 'vue'

const n = ref(0) // 参数:初始值;返回一个 { value: 0 } 形状的对象
n.value++ // 脚本里必须写 .value —— 这是 ref 唯一的「代价」
console.log(n.value) // 实测:1

const msg = ref('hi') // 字符串、对象、数组都能包
  • 为什么要有 .value:JS 里 let x = 0; x = 1 这种重新赋值没法被 Proxy 拦截,所以 Vue 把值装进一个对象的 value 属性里,就能监听读写。
  • 模板里不用写 .value:编译器会自动解包({{ n }} 直接可用)。
  • 在对象字面量里放 ref 不会自动解包,那是 reactive 的领域。

reactive(value):直接把对象变成 Proxy

1
2
3
4
5
6
7
import { reactive, toRaw } from 'vue'

const state = reactive({ user: { name: '苏寅' }, tags: ['a'] }) // 参数必须是对象(含数组/Map)
console.log(state !== toRaw(state)) // 实测:true —— reactive 返回的是 Proxy,不是原对象

state.user.name = '张三' // 深层属性也能直接改,视图跟着更新
console.log(toRaw(state).user.name) // 实测:张三(改动确实落在原对象上)
  • reactive 只能用于对象类值,不能包数字/字符串。
  • 别解构它:const { user } = state 会丢掉响应性,要用 toRefs(state)。
  • toRaw(proxy) 拿回原始对象,适合「传给第三方库、别让代理搅事」的场景。

两者怎么选(我的规则)

场景 用什么 原因
单个基本类型(数字、字符串、布尔) ref reactive 包不了
表单对象、一组相关状态 reactive 深层属性直接改,写起来短
需要整体替换(state = newObj) ref 只有 ref 能靠 .value 换掉整个值
从 composable 返回一堆状态 ref 解构安全,调用方一眼看出哪些是响应式的

响应式家族的其他成员(全部实测)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import { ref, reactive, shallowRef, markRaw, toRef, toRefs, effectScope, watch, nextTick } from 'vue'

// 1) shallowRef:只监听 .value 本身,深层改动不触发更新
const deep = shallowRef({ list: [1, 2] })
let hits = 0
watch(deep, () => { hits++ })
deep.value.list.push(3)
await nextTick()
console.log(hits) // 实测:0 —— 深层改动没触发

deep.value = { list: [1, 2, 3] } // 整体替换才触发
await nextTick()
console.log(hits) // 实测:1
// 用途:大对象(图表数据、ECharts 实例)用 shallowRef 能省下大量代理开销

// 2) markRaw:给对象打「不要转成响应式」的标记
const rawObj = markRaw({ nested: {} })
console.log(!!rawObj.__v_skip) // 实测:true(内部就是靠这个标记跳过的)

// 3) toRef / toRefs:从 reactive 对象上「接一根线」出来,解构不丢响应性
const src = reactive({ a: 1 })
const aRef = toRef(src, 'a')
src.a = 7
console.log(aRef.value) // 实测:7

const state = reactive({ count: 5 })
const { count } = toRefs(state)
state.count = 6
console.log(count.value) // 实测:6

// 4) effectScope:批量管理副作用,stop() 一次全停
const scope = effectScope()
const tick = ref(0)
let scopeHits = 0
scope.run(() => { watch(tick, () => { scopeHits++ }) })
tick.value++
await nextTick()
console.log(scopeHits) // 实测:1
scope.stop()
tick.value++
await nextTick()
console.log(scopeHits) // 实测:1(已经停了,不再增加)

ref 包数组时,变异方法直接生效(实测 arr.value.push(3) 后长度是 3),不用再像 Vue 2 那样绕。

模板语法与指令

模板就是「带指令的 HTML」。指令统一是 v- 前缀,写在元素属性位置。

插值与三种「显示」写法

1
2
3
4
5
6
7
<template>
<p>{{ msg }}</p> <!-- 插值:输出转义后的文本 -->
<p v-text="msg" /> <!-- 等价于插值 -->
<p v-html="html" /> <!-- 输出 HTML(有风险:别拿来渲染用户输入) -->
<p v-if="show">我用了 v-if</p> <!-- 条件为假:元素根本不存在 -->
<p v-show="show">我用了 v-show</p> <!-- 条件为假:元素还在,只是 display:none -->
</template>

v-if 与 v-show 的差别我是直接查 DOM 验证的:

1
2
v-if=false 的元素在 DOM 里吗 :: false
v-show=false 的元素在 DOM 里吗 :: true

选择规则:频繁切换用 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
2
3
4
5
6
7
8
9
10
<script setup>
import { reactive } from 'vue'
const cls = { active: true, disabled: false } // 对象语法:值为真才加上类名
const arr = ['btn', 'btn-primary'] // 数组语法:多个来源合并
const sty = reactive({ color: 'rgb(214, 86, 32)', fontSize: '18px' })
</script>

<template>
<p :class="[cls, arr]" :style="sty">class 与 style 绑定</p>
</template>

渲染出来的 DOM(实测):

1
2
对象+数组 class 绑定结果 :: active btn btn-primary
style 绑定后的行内样式 :: color: rgb(214, 86, 32); font-size: 18px;

disabled: false 没有出现在 class 里——这就是对象语法的价值:「加不加」由数据决定,不用自己拼字符串。

事件修饰符(实测)

1
2
3
4
5
6
<div @click.capture="onCapture">                     <!-- 捕获阶段先跑 -->
<button @click.stop="onInner">.stop 按钮</button> <!-- 阻止冒泡,外层普通监听收不到 -->
</div>
<button @click.once="onOnce">.once 按钮</button> <!-- 只触发一次 -->
<button @click.prevent>提交</button> <!-- 阻止默认行为 -->
<button @click.self="onSelf">只认自己</button> <!-- event.target 必须是本元素才触发 -->

我在页面里程序化点了 .stop 一次、.once 两次,读到的计数是:

1
事件修饰符(点完按钮再读) :: capture=1 stop=1 once=1

capture=1 说明捕获监听在 .stop 之前就跑过了(.stop 只挡冒泡);once=1 说明第二次点击确实无效。

v-for 的 key 到底在管什么(实测对照)

两个列表内容完全一样,唯一区别是一个写了 :key,一个没写。先往每行的输入框里填上「输入了A / 输入了B」,再把数组 reverse():

1
2
3
4
5
<!-- 带 key -->
<label v-for="it in items" :key="it.id">{{ it.text }} <input /></label>

<!-- 不带 key -->
<label v-for="it in items2">{{ it.text }} <input /></label>

重排后读 DOM:

1
2
带 key:第一个输入框 "输入了B",同一行文字 "B"
无 key:第一个输入框 "输入了A",同一行文字 "B"

带 key 时输入框跟着 B 这一条数据走,一切自洽;不带 key 时 Vue 按位置复用 DOM 节点,文字更新了、输入框留在原位,于是「B 这一行显示着 A 的内容」。有状态的东西(输入框、勾选、动画、组件实例)出现在列表里,key 就必须写,而且要写数据自己的稳定标识(item.id),别用 index——index 在增删和重排时正好会变,等于没写。

computed 与 watch

computed(getter):有缓存的派生值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { ref, computed } from 'vue'

let runs = 0
const n = ref(0)
const double = computed(() => {
runs++ // 用计数器证明缓存行为
return n.value * 2
})

void (double.value + double.value + double.value)
console.log(runs) // 实测:1 —— 读 3 次只算 1 次

n.value++ // 依赖变了
void double.value
console.log(runs) // 实测:2 —— 再读才重新算

这就是 computed 和「写个方法调用」的区别:依赖不变就不重算。

可写 computed

1
2
3
4
5
6
7
8
9
const first = ref('苏')
const last = ref('寅')
const full = computed({
get: () => first.value + last.value,
set: (v) => { first.value = v[0]; last.value = v.slice(1) }, // 参数:赋给它的新值
})

full.value = '张三丰'
console.log(first.value, last.value) // 实测:张 / 三丰

用途:表单里「一个输入框对应两个字段」时,比手写 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
2
3
4
注册 watch 后立刻的执行次数(默认 immediate:false) :: 0
注册 watchEffect 后立刻的执行次数 :: 1
watch(getter 指向嵌套字段) 触发次数 :: 1
watch(对象, {deep:true}) 触发次数 :: 1

前两条说明「要不要加 immediate: true」是实打实的差别;后两条说明用 getter 精确监听嵌套字段时不用开 deep,效果一样但开销小得多。

flush 的时机(这条最容易踩)

1
2
3
4
5
6
7
8
9
10
11
12
13
const msg = ref('a')

watch(msg, () => {
// flush: 'pre'(默认)—— DOM 还没更新
console.log(document.querySelector('#demo').textContent)
}, { flush: 'pre' })

watch(msg, () => {
// flush: 'post' —— DOM 已更新,适合读尺寸、初始化第三方库
console.log(document.querySelector('#demo').textContent)
}, { flush: 'post' })

msg.value = 'b'

实测输出:

1
2
flush:pre 回调里读到的 DOM 文本 :: a
flush:post 回调里读到的 DOM 文本 :: b

要在数据变化后读 DOM(尺寸、滚动位置、给 ECharts 喂新数据),就用 flush: 'post',或者在回调里 await nextTick()。

nextTick:DOM 什么时候真的更新了

1
2
3
4
5
6
import { nextTick } from 'vue'

tickText.value = '更新后的文本'
console.log(document.querySelector('#box').textContent) // 实测:初始文本(同帧还没更新)
await nextTick()
console.log(document.querySelector('#box').textContent) // 实测:更新后的文本

实测三条连起来看:

1
2
3
挂载时 DOM 里是 :: 初始文本
改完同帧读到 :: 初始文本
await nextTick() 后读到 :: 更新后的文本

Vue 的更新是异步批量的:同一帧里改十次数据,DOM 只更新一次。nextTick() 就是「等这次更新做完」。

组件:props、emits 与插槽

定义组件:<script setup> 是首选

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script setup>
// 一个 .vue 文件 = 模板 + 逻辑 + 样式,编译后就是一个组件对象
const props = defineProps({
title: String, // 类型校验
size: { type: Number, default: 14 }, // 带默认值
list: { type: Array, required: true }, // 必填
})

const emit = defineEmits(['inc', 'done']) // 声明会向外发什么事件
emit('inc') // 无载荷
emit('done', { ok: true }) // 带载荷
</script>

<template>
<p>{{ props.title }}</p>
</template>

defineProps / defineEmits / defineModel / defineExpose / defineOptions 这几个 define* 是编译器宏:不用 import,编译时就变成组件选项。

props 是只读代理(实测)

1
2
props.title = '子组件想改'
console.log(props.title) // 实测:仍是「父传子」——写不进去

数据只能单向向下流动,子组件要改就 emit 让父组件改。这条限制是 Vue 里「数据从哪来」永远可追的原因。

父子通信的四条路

方向 手段 什么时候用
父 → 子 props 一切初始数据
子 → 父 emit 事件 子组件里的用户动作
父 → 子(双向绑定) v-model / defineModel 表单类组件
父 → 任意后代 provide / inject 跨多层(主题、当前用户、表单实例)
父直接调子方法 defineExpose + 模板 ref imperative 操作(聚焦、重置、播放)

provide / inject 实测:

1
2
3
4
5
// 祖先组件
provide('site', 'Hexo-NexT') // 参数:key(字符串或 Symbol)、值

// 任意后代(哪怕隔了好几层)
const site = inject('site', '(没拿到)') // 第二个参数是「找不到时的默认值」
1
子组件 inject 到 :: Hexo-NexT

注意 inject() 必须在 setup 的同步阶段调用,放进 onMounted 里就取不到了。

插槽(实测三种写法)

1
2
3
4
5
6
7
8
9
10
11
<!-- 子组件 -->
<div class="child">
<div class="slot-default"><slot>插槽兜底内容</slot></div>
<div class="scoped-slot"><slot name="header" :from="'子组件传出的数据'" /></div>
</div>

<!-- 父组件 -->
<ChildProbe title="父传子" @inc="onInc">
<template #header="{ from }">作用域插槽拿到 {{ from }}</template>
<p>默认插槽内容</p>
</ChildProbe>

从 DOM 里读回来的结果:

1
2
3
子组件默认插槽文本 :: 默认插槽内容
作用域插槽文本 :: 作用域插槽拿到 子组件传出的数据
子组件 emit 两次后父计数 :: 2
  • 默认插槽:父组件塞进子组件标签之间的内容;子组件里 <slot>兜底</slot> 的兜底文字只在父没给内容时出现。
  • 具名插槽:<template #header> 对应 <slot name="header" />。
  • 作用域插槽:子组件把数据挂在 <slot :from="..."> 上,父组件用 #header="{ from }" 解构接收——「子组件提供数据、父组件决定怎么渲染」。

defineModel:组件上的 v-model(3.4+)

1
2
3
4
<script setup>
const model = defineModel({ type: String, default: '' }) // 不带参数就是 modelValue
model.value = '来自 defineModel'
</script>

它把「声明 modelValue prop + 声明 update:modelValue 事件 + 手动 emit」三件事合成一行。父组件写 <MyInput v-model="kw" /> 就通了。多个绑定值用 defineModel('title') 这种带名字的形式,父组件写 v-model:title。

defineExpose:父组件调用子组件方法

1
2
3
4
5
6
// 子组件:默认 <script setup> 里的东西对外不可见,必须显式暴露
defineExpose({ ping: () => 'ping 被父组件调用,累计 1 次' })

// 父组件
const childApi = ref(null) // <ChildApi ref="childApi" />
childApi.value.ping()

实测:

1
父组件调用子组件暴露的方法 :: ping 被父组件调用,累计 1 次

多根节点组件要留心 class 落点

Vue 3 允许组件有多个根节点(fragment)。这时父组件传下来的 class/style/事件不知道该落到哪个根上,开发版会警告。实测抓到的原文:

1
2
开发版警告总条数 :: 1
警告1 :: Extraneous non-props attributes (class) were passed to component but could not be automatically inherited because component renders fragment or text or teleport root nodes.

这条警告每次重渲染都会再打一遍(我在交互后看到它累计出现 5 次),所以别当噪音。解决办法:单根,或者手动把 $attrs 绑到指定元素上(<p v-bind="$attrs">),或 defineOptions({ inheritAttrs: false }) 后自己控制。

生命周期

组合式 API 里的钩子要显式 import,且必须在 setup 同步阶段注册:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { onMounted, onBeforeMount, onUpdated, onBeforeUnmount, onUnmounted } from 'vue'

onMounted(() => {
// DOM 已就绪:可以读尺寸、初始化图表、加原生事件监听
})
onUpdated(() => {
// 每次依赖变化导致 DOM 重渲染后触发(别在这里改状态,容易循环)
})
onBeforeUnmount(() => {
// 卸载前:还能碰到 DOM
})
onUnmounted(() => {
// 卸载后:清计时器、去掉监听、断开 WebSocket —— 这里不清就是内存泄漏
})

父子执行顺序(实测字符串)

1
父子生命周期顺序 :: App setup → App beforeMount → Child setup → Child beforeMount → Child mounted → App mounted

记两条就够:setup 早于 mounted(setup 阶段还没有 DOM,别在里面查询元素);子组件的 mounted 早于父组件的 mounted(父挂载时子树已经挂完)。

卸载时清理计时器(实测)

1
2
3
4
const id = setInterval(() => { n.value++ }, 1000)
onUnmounted(() => {
clearInterval(id) // 不写这行,组件没了计时器还在跑
})

用 v-if 把子组件卸载后读到的结果:

1
2
子组件卸载后 onUnmounted 做的事 :: onUnmounted 里 clearInterval 已执行
卸载后 #timer-demo 还在 DOM 里吗 :: false

与 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
2
3
4
5
6
7
8
9
10
11
12
13
// src/composables/useCounter.js
import { computed, readonly, ref } from 'vue'

export function useCounter(initial = 0) { // 参数:起始值
const count = ref(initial)
const double = computed(() => count.value * 2)

function inc() { count.value++ }
function reset() { count.value = initial }

// 约定:返回的对象里,状态用 readonly 包一层,只暴露修改方法
return { count: readonly(count), double, inc, reset }
}
1
2
3
4
5
6
7
8
<script setup>
import { useCounter } from '../composables/useCounter'
const { count, double, inc } = useCounter(10) // 每个组件调用都得到独立的一份状态
</script>

<template>
<button @click="inc">{{ count }} × 2 = {{ double }}</button>
</template>

三条约定值得守:

  • 文件名 useXxx.js,函数名 useXxx——社区通用约定,不是语法要求。
  • 每次调用都是新的一份状态(这就是和 mixin 的差别),要共享就得交给 Pinia。
  • 内部要注册钩子(onMounted 等)时,只能在同步阶段注册;放进 setTimeout 里注册会失效并警告。

另外「一份状态全应用共享」的写法是把 ref 提到函数外面(模块级单例),但要注意服务端渲染下会跨请求串数据——所以共享状态还是用 Pinia。

动态组件、KeepAlive 与自定义指令

<component :is>:在运行时决定渲染哪个组件

1
2
3
4
5
6
7
8
9
10
11
<script setup>
import { ref } from 'vue'
import TabA from './TabA.vue'
import TabB from './TabB.vue'
const current = ref(TabA) // 值就是组件对象(或用字符串配 components 选项)
</script>

<template>
<component :is="current" />
<button @click="current = current === TabA ? TabB : TabA">切换</button>
</template>

KeepAlive:切走时不销毁,只缓存

我让一个组件的 setup() 自己计数,然后来回切换两次,看它到底被创建了几次:

1
2
KeepAlive 开着时来回切 2 次,A 的 setup 次数 :: 1
关掉 KeepAlive 后再切 2 次,A 的 setup 次数 :: 3
  • 包 <KeepAlive> 时 A 只被创建 1 次(第一次),之后都是复用缓存实例,状态(输入、滚动位置、已加载的数据)都还在。
  • 不包时每次切回来都重新 setup,1 + 2 = 3 次。

<KeepAlive> 的两个常用属性:

属性 写法 作用
include / exclude :include="['TabA']" 按组件 name 过滤要缓存谁
max :max="10" 最多缓存几个,超了按 LRU 淘汰

被缓存的组件里用 onActivated / onDeactivated 感知「重新显示 / 被收起」,比 onMounted 更准(onMounted 只在第一次创建时跑)。

自定义指令:v-focus 与带参数的指令

1
2
3
4
5
6
7
8
9
// 局部指令:在 setup 里定义,变量名以 v 开头即可自动匹配 v-xxx
const vFocus = {
mounted: (el) => { el.focus() }, // el:指令绑定的元素
}
const vReddot = {
mounted: (el, binding) => {
el.style.backgroundColor = binding.value || 'rgb(255, 0, 0)' // binding.value:传入的值
},
}
1
2
<input v-focus />
<span v-reddot="'rgb(255, 0, 0)'">红点</span>

实测:

1
2
v-focus 指令后元素是否聚焦 :: true
v-reddot 指令的背景色 :: rgb(255, 0, 0)

指令的钩子有 created / beforeMount / mounted / beforeUpdate / updated / unmounted。能用组件和 computed 解决的事别用指令——指令的适用场景是「直接操作 DOM 的独立小行为」:埋点、权限禁用、自动聚焦、拖拽。

Vue Router 5:路由

装与注册(main.js):

1
2
3
4
5
6
7
8
9
10
11
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'

const app = createApp(App)
app.use(createPinia())
app.use(router)

// 等首屏路由解析完成再挂载,避免先渲染空白页再跳
router.isReady().then(() => app.mount('#app'))

路由表(src/router.js):

1
2
3
4
5
6
7
8
9
10
11
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
history: createWebHistory(), // 用 HTML5 History API,URL 里没有 #
routes: [
{ path: '/', name: 'home', component: Home },
{ path: '/about/:id', name: 'about', component: About, props: true },
{ path: '/lazy', component: () => import('./views/Lazy.vue') }, // 懒加载:单独打成一个 chunk
{ path: '/:any(.*)*', redirect: '/' }, // 兜底
],
})

页面里怎么用(实测)

1
2
3
4
5
6
7
8
9
10
11
12
import { useRoute, useRouter } from 'vue-router'

const route = useRoute() // 只读:当前路由信息
const router = useRouter() // 用来跳转

route.path // '/'
route.name // 'home'
route.params // 动态段
route.query // ?a=1
route.fullPath // 含 query/hash 的完整路径

router.push({ name: 'about', params: { id: 42 }, query: { q: 'x' } })

跳转后读到的结果:

1
2
3
4
当前路由 :: / name=home
跳到 about 后 props.id 渲染文本 :: About 页拿到 props.id = 42(类型 string)
about 的 route.query :: {"q":"x"}
懒加载路由渲染出的内容 :: 我是懒加载路由组件(单独打成一个 chunk)

注意 props.id 的类型是 string——URL 段本来就是字符串,要数字得自己转(props: { id: { type: Number, cast: true } } 或在组件里 Number(props.id))。props: true 的作用是把动态段直接变成组件的 prop,这样组件不依赖路由也能复用。

导航守卫的执行顺序(实测)

1
2
3
4
5
6
router.beforeEach((to, from) => {
// 返回 false 或一个路由地址就能拦下/改道这次导航
})
router.afterEach((to) => {
// 导航完成后跑,适合发页面浏览统计
})

组件内还有 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。离开某个页面前想问「未保存要不要走」,就写在离开的那个组件里。

1
2
3
<RouterLink :to="{ name: 'about', params: { id: 1 } }"
active-class="is-active">关于</RouterLink>
<RouterView /> <!-- 路由组件渲染在这里 -->

<RouterView> 还能配合嵌套路由(子路由表里写 children,父组件里再放一个 <RouterView />)。

Pinia 4:状态管理

先判断要不要它:只在「多个不相干的组件要读写同一份状态」时才引入;父子之间用 props/emit,跨层用 provide/inject 就够了。

定义 store(setup 语法,官方现在推荐这个)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/stores/counter.js
import { computed, ref } from 'vue' // 注意:ref/computed 要自己 import
import { defineStore } from 'pinia'

export const useCounter = defineStore('counter', () => { // 第一个参数是 id,devtools 与 SSR 都靠它
const count = ref(0) // state
const items = ref([]) // state
const double = computed(() => count.value * 2) // getter

function inc() { // action
count.value++
items.value.push(count.value)
}

return { count, items, double, inc }
})

在组件里用(实测数值)

1
2
3
4
5
6
7
8
9
import { storeToRefs } from 'pinia'
import { useCounter } from '../stores/counter'

const store = useCounter() // 在 setup 里调用(不是 import 一个单例)
const { count, items } = storeToRefs(store) // 解构必须过 storeToRefs,直接解构会丢响应性

store.inc()
store.inc()
store.$patch({ count: 100 }) // 批量改状态

实测:

1
2
3
4
store.inc() 两次后 count :: 2
storeToRefs 解构后 items 长度 :: 2
$patch 直接改 count 后 :: 100
store.double(getters/computed) :: 200

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) 才能穿透子组件。

练习

按顺序做,每一步都能在浏览器里看到结果:

  1. 计数器与双向绑定:useCounter(10) 写成一个 composable,两个组件各自调用,确认它们的 count 互不影响;再加一个 readonly 保护,试着在组件里 count.value++ 看看会发生什么。
  2. 缓存与执行次数:照抄本文的 computed 计数器实验,把 void (double.value + double.value) 改成在模板里引用三次 {{ double }},看执行次数是不是仍然只有 1。
  3. key 复现:做一个 3 行的待办列表,每行一个输入框,先不写 :key,在中间插入一条,观察输入框内容怎么错位;再补上 :key="item.id" 对比。
  4. flush 与 nextTick:写一个元素高度自适应的输入框(内容变多就长高),分别在 flush: 'pre' 和 flush: 'post' 里读 scrollHeight,解释为什么一个对一个错。
  5. 插槽三件套:做一个 <Panel> 组件,支持默认插槽 + #header 作用域插槽(把「折叠状态」传给父组件),再用它拼一个可折叠卡片。
  6. KeepAlive 验证:两个 Tab 组件里各放一个输入框,来回切换,比较加 <KeepAlive> 与不加时「切回来内容还在不在」。
  7. 路由与守卫:给 /about/:id 配 props: true,做一个「未保存离开要确认」的 onBeforeRouteLeave;用 npx vite build --minify false 看懒加载路由有没有单独成 chunk。
  8. Pinia + 持久化:用 store.$subscribe 把状态写进 localStorage,刷新页面后恢复;再试着不加 storeToRefs 直接解构,观察界面为什么不更新。

参考