Vue3 完整知识体系交互讲解

全景:Vue 的定位与现状

钻进 API 之前先回答三个问题:它的心智模型是什么、生态走到了哪里、四大框架怎么选。本页假设你已经会 HTML/CSS 与现代 JavaScript;完整的前置知识与工具链见 01 章。

Vue 的立身之本是「渐进式」:你改数据,视图自己更新,不用手写任何 DOM 操作。

心智模型一句话

  • 响应式代理 + 编译优化的虚拟 DOMref / reactiveProxy(JS 拦截对象读写的内置能力)记下「谁读了我」,改动时就通知谁;
  • 所以在 Vue 里你几乎不用考虑「什么时候该重渲染」——改数据即可,框架知道该更新哪里
  • 「渐进式」指它能只用核心库给旧页面加一块交互,也能上 Nuxt 做全栈,中间没有断层。
// Vue 的心智模型:改数据,视图自己更新
<script setup>
import { ref } from 'vue';
const count = ref(0);   // Proxy 响应式:谁用到它,它变时就更新谁
</script>

<template>
  <button @click="count++">点击 {{ count }} 次</button>
</template>
「改数据视图自动更新」的前提是你改的是代理对象。reactive(raw) 返回的是 Proxy,proxy === rawfalse;如果你留着原始对象的引用去改,视图不会有任何反应,也不会报错。
先把 02–04 章的「响应式为什么能自动更新」想明白(Proxy 追踪依赖),再学组件与生态——Vue 的绝大多数坑(.value、解构失去响应性、reactive 整体替换)都是同一个原理的不同表现

先把版本基准与生态位置钉住,后面每一章的写法与报错原文才有明确的参照系。

今天的疆域与版本基准

  • Vue 3.5 为基准,<script setup> + 组合式 API 是默认范式;
  • 全栈元框架是 Nuxt,路由 vue-router、状态 Pinia,都是官方维护——生态收敛,选型分歧少

和另外三家怎么选

  • 本页赢在上手曲线平滑、模板贴近 HTML、中文资料最全,输在全球生态与招聘体量;
  • React 生态与人才池最大;Svelte 产物最小;Solid 无重渲染但生态最小。
中文搜索结果里大量是 Vue 2 的资料,照抄会踩空:this.$set、过滤器 filters.sync 修饰符、用 $on/$off 做事件总线,在 Vue 3 里都已移除或改名。识别法:文中出现 new Vue(...) 就是 2.x。
选型别看 benchmark,看三件事:团队现有技能、是否需要 SEO/SSR、要不要渐进接入既有页面。三者若指向「中文团队 + 老项目改造」,Vue 几乎不用犹豫。

跑通第一个项目

在讲任何语法之前,先让浏览器把你写的东西跑起来。这一章只做三件事:用官方脚手架建出项目、看懂它生成的目录、把第一个组件改到能在页面上动。后面章节的代码基本都是片段,默认你已经有一个跑着的开发服务器可以随时粘进去试。

Vue 的 .vue 单文件组件浏览器并不认识——它需要 Vite 在背后编译成普通 JS。所以第一步不是写代码,而是搭环境。

装 Node,然后建项目

  • 到 nodejs.org 下当前 LTScreate-vue 对 Node 版本要求跟得很紧,太旧会直接建不出项目);
  • npm create vue@latest:先问项目名,接着单独问一次是否用 TypeScript,然后才是功能清单;
  • TypeScript 那一问默认是 Yes,第一次学请手动选 No——否则入口会变成 main.ts,和本页示例对不上。

开发服务器一直开着

  • npm run dev 打印一个地址,它一直运行着不要关:改完保存,页面自动更新且不丢当前状态(HMR);
  • 本页假设你会 HTML/CSS 和现代 JavaScript,不要求会 TypeScript
// ── 终端里依次执行 ──────────────────
// npm create vue@latest     交互式建项目
//   ? Add TypeScript?  → 第一次选 No(默认是 Yes,要手动改)
//   ? 功能清单          → 空格勾选,第一次全不勾,直接回车
// cd my-vue-app             进入生成的目录
// npm install               装依赖(生成 node_modules)
// npm run dev               启动开发服务器,别关
//
//   VITE ready in 312 ms
//   ➜  Local:   http://localhost:5173/   ← 浏览器打开这个

// ── 生成的目录,只需先认识这四个 ──────
// my-vue-app/
// ├─ index.html          唯一的 HTML,里面有 <div id="app"></div>
// ├─ package.json        依赖清单与 npm run 的脚本都在这
// ├─ vite.config.js      构建配置,暂时不用动
// ├─ public/             不经构建、原样拷到 dist 的静态文件
// └─ src/
//    ├─ main.js          程序入口:把根组件挂到 #app 上
//    ├─ App.vue          根组件 ← 先在这里改
//    ├─ assets/          样式与图片(main.js 默认 import 了这里的 CSS)
//    └─ components/      其余组件放这

// ── 其他常用脚本 ────────────────────
// npm run build     打包出可部署的静态文件(到 dist/)
// npm run preview   本地预览打包后的结果
npm run dev 启动的是开发服务器,只在你本机跑着,关掉终端页面就打不开了——它不是「部署」。要给别人看得先 npm run build 出静态产物再传上去。
端口被占用时 Vite 会自动换一个(5174、5175…),以终端实际打印的地址为准。想让同局域网的手机也能访问,加 --host

脚手架生成的文件看着不少,但把页面渲染出来的只有三个,且串成一条直线

三个文件,一条直线

  • index.html 是个空壳:关键就两行——一个空的 <div id="app"> 和一行 <script type="module">那个空 div 就是挂载点
  • main.js 把组件接上去createApp(App).mount('#app')。「挂载」=首次生成真实 DOM 并插进页面,此后由 Vue 自动更新;
  • App.vue 一个文件就是一个组件<script setup> 写逻辑、模板写结构、<style scoped> 写只作用于本组件的样式。

动手改一处,确认闭环

  • App.vue 换成右侧代码保存,不要刷新浏览器——页面应立刻变成一个按钮。看到这个,构建 → 挂载 → 响应式 → 重渲染整条链就都通了。
<!-- index.html:整个应用唯一的 HTML -->
<body>
  <div id="app"></div>              <!-- ① 挂载点,空的 -->
  <script type="module" src="/src/main.js"></script>
</body>

// src/main.js:把根组件挂到 #app 上
import './assets/main.css';      // 脚手架默认带的样式,可删
import { createApp } from 'vue';
import App from './App.vue';

createApp(App).mount('#app');   // ② 创建 + 挂载

<!-- src/App.vue:③ 改成这样,保存后看浏览器 -->
<script setup>
import { ref } from 'vue';

const count = ref(0);        // 响应式数据
const inc = () => count.value++;   // JS 里读写要加 .value
</script>

<template>
  <!-- 模板里不用写 .value -->
  <button @click="inc">点了 {{ count }} 次</button>
</template>

<style scoped>
button { padding: 8px 16px; }   /* 只影响本组件 */
</style>
<style scoped>scoped 不能漏:漏了就是全局样式,在别处冒出来的错乱极难定位。另一处是 ref<script> 里读写必须带 .value,而模板里不用——这个不一致是 Vue 新手最常忘的一件事(03 章)。
保存后页面没变化,按这个顺序排查:① 终端里 npm run dev 还在跑吗、有没有红字;② 浏览器 Console 有没有报错;③ 确认改的是 src/App.vue

Vue 的运行时警告几乎全部以 [Vue warn] 开头、只在开发模式打印,且大多不是「错误」——页面照常渲染,只是渲染出了错的东西。把控制台当第一现场,比盯着页面猜快得多。下面三条是新手头一周必撞的(3.5.40 原文)。

① 模板用了不存在的名字

[Vue warn]: Property "username" was accessed during render but is not defined on instance.

名字拼错、变量声明在函数体里而不是 <script setup> 顶层、或想在模板里直接用 localStorage 这类浏览器全局(模板表达式是沙箱,02 章)。页面表现是插值处渲染成空白——不报错,只是空,所以「数据没显示出来」十有八九是它。

② 组件解析不到 ③ prop 类型不符

[Vue warn]: Failed to resolve component: MyWidget

[Vue warn]: Invalid prop: type check failed for prop "count".
            Expected Number with value NaN, got String with value "abc".
  • 忘了 import<script setup> 里 import 即注册),或全局组件在 mount 之后才注册。页面表现是该位置渲染成一个原样的未知标签;
  • 九成是忘了加冒号——count="abc" 传的是字符串字面量,:count="n" 才是绑定表达式(06 章)。值照样会传进去(Vue 不拦截),后续运算悄悄出错。
// 三条警告各自的最小复现,粘进 App.vue 一次看全:
<script setup>
function init() {
  const username = '内层变量';   // 非顶层 → 警告①
}
</script>

<template>
  <p>{{ username }}</p>          <!-- ① 渲染成空 + 警告 -->
  <MyWidget />                  <!-- ② 没 import → 警告 -->
</template>
警告和错误的最大区别是警告不打断渲染——Vue 会尽量兜底(渲染成空、跳过该组件),页面「看起来还行」,于是很容易被忽略过去。控制台里的黄字要当红字看。
让控制台保持干净是最省钱的调试习惯:每次保存后扫一眼有没有新的 [Vue warn],出现就当场处理。警告原文里通常带着组件层级(at <App> 这样的调用链),从上往下找到你自己的组件名,问题就在那个文件里。

模板与响应式基础

Vue 3 推荐 <script setup> 单文件组件:逻辑、模板、样式三块各司其职。响应式数据驱动视图,模板用声明式语法描述 UI。文末附 Options API 速览,帮你读懂存量代码。

01 章已经把 index.html → main.js → App.vue 跑通了,这里补当时略过的两件事:应用实例能先存后配,以及 SFC 三个块各自的边界。

应用实例与 SFC 的三块

01 章已经把 index.html → main.js → App.vue 这条链路走通了,这里补两件当时略过的事。① 应用实例可以先存下来再配置const app = createApp(App) 之后可以链式 .use() 装插件、.component() 注册全局组件、.directive() 注册全局指令,.mount() 放在最后——首次渲染时解析不到的全局组件会警告 Failed to resolve component 并渲染成空;mount 之后补注册要等下一次重渲染才生效,纯静态内容没有重渲染契机就一直缺着,所以别赌时机,注册一律放 mount 前。<script setup> 的暴露规则:顶层声明的变量与函数自动可在模板中使用,无需 return,也没有 this;但只有顶层算数,写在函数体里的局部变量模板取不到(模板里用它会警告 Property "xxx" was accessed during render but is not defined on instance,见 01 章读警告卡)。

// main.js —— 装插件的完整形态(01 章那个是它的最简版)
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import { createPinia } from 'pinia';

const app = createApp(App);   // 先拿到实例,别急着 mount

app.use(router);              // Vue Router —— 见 10 章
app.use(createPinia());     // Pinia —— 见 09 章
app.component('BaseIcon', BaseIcon);   // 全局组件
app.directive('focus', focusDirective); // 全局指令 —— 见 11 章

app.mount('#app');          // ⚠️ 放最后:首次渲染前要能解析到全部注册

// ── <script setup> 的暴露规则 ──
<script setup>
const count = ref(0);            // ✅ 顶层 → 模板里可直接用 count

function setup2() {
  const inner = ref(0);        // ❌ 非顶层 → 模板取不到 inner
}
</script>
最常见的写法事故是把 mount 的返回值当成 appconst app = createApp(App).mount('#app') 拿到的是根组件实例而不是应用实例,紧接着 app.use(router) 会直接报 app.use is not a function。正确写法是分两步:先 const app = createApp(App),装完插件与全局组件后再单独 app.mount('#app')。另外 .mount() 会用组件内容替换掉挂载点内原有的 HTML,别在 #app 里放你还想留着的东西。
<script setup> 顶层代码在组件实例创建阶段同步执行,时机早于 Options API 的 beforeCreate/created(混写时打印顺序:setup → beforeCreate → created)——所以 Composition API 没有 onCreated 钩子,要在「创建时」做的事直接写在顶层即可。

{{ }} 文本插值内可写任意 JS 表达式(不能是语句),v-bind(简写 :)把属性绑定到表达式,v-html 渲染原始 HTML。三者覆盖了「把数据放进页面」的全部方式。

插值与绑定的分工

  • {{ expr }} 只管文本内容;标签的属性里不能用插值(src="{{ url }}" 是 Vue 2 之前的老语法),要用 :src="url"
  • :class / :style 有专门的对象/数组语法::class="{ active: isActive }" 按值的真假决定类名是否出现,和字符串拼接说再见;
  • 没有冒号的属性是静态字符串,有冒号才是 JS 表达式——这条规律贯穿 props 传值(06 章)。

v-html 的边界

  • v-html 直接把字符串塞成 HTML,绝不要用于用户输入(XSS 风险),只用于自己可控的富文本;
  • 它渲染的内容不参与模板编译(里面写 {{ }}、指令都不生效),也不受 scoped 样式约束(07 章细讲)。
<template>
  <p>{{ message.toUpperCase() }}</p>
  <p>{{ isActive ? '在线' : '离线' }}</p>

  <!-- :attr 绑定 -->
  <img :src="imageUrl" :alt="message">

  <!-- class 对象语法:键名是否生效取决于值 -->
  <div :class="{ active: isActive, error: hasError }"></div>
  <!-- style 对象语法 -->
  <div :style="{ color: 'red', fontSize: size + 'px' }"></div>

  <!-- ⚠️ v-html 有 XSS 风险,勿用于用户输入 -->
  <div v-html="trustedHtml"></div>
</template>
模板表达式是沙箱化的,只能访问一份内置全局白名单(MathDate 这些)。于是 {{ localStorage.getItem('name') }}{{ window.APP_VERSION }} 这类写法必然失败,且失败得很安静——{{ localStorage ? 'yes' : 'no' }} 渲染出 "no"(表达式里它是 undefined,走了假值分支),控制台警告 Property "localStorage" was accessed during render but is not defined on instance,页面不报错。你会以为是「数据没取到」,其实是根本取不到。要用这些值,先在 <script setup> 里读出来赋给一个 ref,或者挂到 app.config.globalProperties 上。
模板表达式只能是单个表达式(不能写 if、不能声明变量),而且每次组件更新都会重新求值——插值里调一个与更新无关的函数,别的数据变两次,函数被调了 3 次(初始 + 每次更新各一次)。所以一旦插值里出现三目套三目、或者调用了函数,就把它挪进 computed——既有缓存,也让模板只剩「显示什么」。反过来说,插值里绝不要调用有副作用的函数(改数据、发请求):它会在你想不到的时刻被反复执行。

本页全程用组合式 API,但存量代码里到处是 Options API——这一卡的目的是让你读得懂,不是让你去写

两套 API 的对应关系

本页全程用 <script setup> + 组合式 API(新项目首选)。但你会在大量存量代码、老教程、第三方组件里遇到 Options API——它按「选项对象」组织组件:data() 返回响应式状态、methods 放方法、computed 放计算属性、watch 放侦听,生命周期是 created/mounted 等同名选项。关键区别:选项里一律用 this.xxx 访问状态与方法(框架自动把它们挂到组件实例上,故也没有 .value),而模板里和组合式写法一样直接写名字、不带 this。这张卡只求「看懂」:把每个选项映射到你已学的组合式 API 即可。

读旧代码时的对应关系

Options API组合式 API关键差别
data() 返回对象ref / reactive组合式要写 .value,Options 里没有
computed: {}computed()
methods: {}普通函数Options 里必须 this.xxx 调用
watch: {}watch() / watchEffect()组合式能拿到停止句柄
created/mountedonMountedsetup 本身相当于 created
mixins组合式函数mixin 的来源不明与命名冲突问题不复存在
  • 最大的心智差别是 this:Options API 把状态和方法都挂在组件实例上,所以到处是 this.;组合式 API 里它们就是普通变量与函数,因此也能被抽到组件外复用——这正是组合式函数存在的根据;
  • 两套可以共存:老项目不必一次性重写,新组件用组合式、旧组件放着即可。
<script> // 注意:是 script,不是 script setup
export default {
  props: ['title'],              // ≈ defineProps(['title'])
  data() {
    return { count: 0 };        // ≈ const count = ref(0)
  },
  computed: {
    double() { return this.count * 2; }, // ≈ computed(() => count.value * 2)
  },
  methods: {
    inc() { this.count++; },          // ≈ function inc(){ count.value++ }
  },
  watch: {
    count(val, old) {},              // ≈ watch(count, (val, old) => {})
  },
  mounted() {                        // ≈ onMounted(() => {})
    console.log(this.count);
  },
};
</script>

<template>
  <!-- 模板里同样直接写名字,不带 this -->
  <button @click="inc">{{ title }}:{{ double }}</button>
</template>
watch 选项监听对象或数组时有两个静默陷阱:不写 deep: true,内部属性变化根本不触发回调;写了 deep: true,回调里的新值和旧值是同一个对象引用val === oldtrue),于是 if (val.x !== old.x) 这种「变了才处理」的判断永远为假,逻辑整段失效且毫无报错。要拿到真正的旧值,就监听具体属性('form.name' 字符串路径)或在回调里自己深拷贝一份留底。
别在 data/methods 里用箭头函数——箭头函数没有自己的 this,会拿不到组件实例。选项里的方法一律用普通函数写法。迁移到组合式时逐项搬进 setupdatarefcomputedcomputed()methods→普通函数、mountedonMounted

ref vs reactive

Vue 3 响应式有两个核心 API:ref 包裹任意值(JS 中需 .value),reactive 只包对象(返回 Proxy)。官方默认推荐 ref,因为行为一致且可整体替换。

ref() 创建响应式引用,本质是带 getter/setter 的 { value } 容器。JS 中读写必须加 .value,模板里自动解包无需写。

为什么必须 .value

  • 数字、字符串等原始值按值传递、无法被拦截,Vue 只能把它包进对象、拦截对 .value 的读写来追踪依赖——.value 不是设计失误,是 JS 语言约束下的必然;
  • 对象也能用 ref 包:内部会转成深层响应式(改 user.value.age 照样触发更新),还能整体替换 user.value = {...}——这是 reactive 做不到的。

模板解包的精确边界

  • 顶层 ref 全解包{{ count }}{{ count + 1 }} 都正常(后者得到 11,不是拼接);
  • 非顶层 ref 只在「直接插值」时解包obj.inner 是 ref 时,{{ obj.inner }} 能显示值(插值出口会解 ref),但一参与运算就现形——{{ obj.inner + 1 }} 渲染 "[object Object]1"。要运算就先解构到顶层。
import { ref } from 'vue';

const count = ref(0);
const user  = ref({ name: 'Bob', age: 25 }); // 对象也能用 ref

count.value++;              // JS 中必须 .value
user.value.age = 26;        // 内部属性自动 reactive
user.value = { name: 'Carol', age: 30 }; // 可整体替换

// 模板里直接写 count,不要 count.value
// <p>{{ count }}</p>
解构会丢失响应性:const { value } = count 只是取了当前快照,之后不再跟踪变化。需要解构时用 toRefs(reactive)或直接传递 ref 本身。同理,把 ref 放进普通对象再解构也会断开响应。
官方推荐ref() 为主要 API,全项目统一用它,别 ref/reactive 混着写——省掉「这个变量到底要不要 .value」的心智负担。忘写 .value 的表现很好认:在 <script> 里对 ref 做运算或比较,拿到的是那个容器对象,于是 count > 5 恒为 falsecount + 1 变成 "[object Object]1",而不是报错。看到这类「结果诡异但不报错」的表达式,第一反应就是查 .value

reactive() 只接受对象/数组/Map/Set,返回 Proxy,访问/修改无需 .value

两个硬限制

  • 不能整体替换state = {...} 换掉的是 Proxy 本身,所有依赖旧 Proxy 的视图从此失联(const 声明时这句在运行时当场抛 TypeError: Assignment to constant variable.,反而帮你拦住了);
  • 解构原始值丢响应:解出的是普通拷贝,与原对象彻底脱钩,需 toRefs——两条的成因其实是同一个,见 pitfall。

它适合什么

  • 「结构固定、就地修改」的成组状态:表单对象、配置块——字段多、总是改属性而非换整体时,省掉一片 .value 确实顺手;
  • 要响应式的 Map/Set 时用它最自然(ref 包 Map 也行,但每次操作都要过 .value);
  • 拿不准就用 ref(见 tip)——本页除本卡外的示例也默认 ref。
import { reactive, toRefs } from 'vue';

const state = reactive({
  count: 0,
  user: { name: 'Alice', age: 28 },
  todos: [],
});
state.user.age = 29;       // ✅ 直接改
state.todos.push({ text: '写代码' });

// ❌ state = {...}  整体替换会丢响应(const 也不允许)
// ❌ const { count } = state  原始值解构 = 普通拷贝,彻底断开
// ⚠️ const { user } = state   user 仍是响应式代理(改 user.age 有效),
//    但 state.user = 新对象 之后本地 user 不再跟随
const { count, user } = toRefs(state); // ✅ 解出 ref,两种都保联动
正文那两条限制的成因是同一个:响应式能力长在 reactive() 返回的那个 Proxy 对象上,不在里面的值上。所以只要你的操作「换掉了那个 Proxy」或「把值取出了 Proxy」,联系就断了——整体替换是前者,解构是后者。据此记两个解法即可:想整体换数据用 Object.assign(state, newObj) 逐字段写回(Proxy 本身不变;watchEffect 能看到 assign 进来的新值);想解构出来用 toRefs(state)(解出的是 ref,仍连着原对象)。想省心就一律用 ref:它两个问题都没有。
两者的选择:官方推荐默认用 ref(哪怕包对象):行为一致(永远 .value)、可整体替换、可自由传递。reactive 适合「结构固定、就地修改」的局部状态。团队里统一一种风格最省心。

ref/reactive 之外还有一圈工具函数,它们各自解决一个具体问题:不想深追、只想只读、想彻底脱离响应式。

六个工具,各管一件事

进阶工具:shallowRef 只追踪 .value 整体替换、不深追内部(改 s.value.x 不触发 effect,整体替换 s.value = {...} 才触发);readonly 创建只读副本(开发模式改它警告 Set operation on key "a" failed: target is readonly.,值保持不变);toRaw/markRaw 脱离响应式(传给第三方库、标记永不响应);isRef/unref/toValue 在组合式函数里统一处理 ref 与普通值。

什么时候用哪个

诉求
普通响应式值(默认选它)ref
数据很大、只会整体替换shallowRef
对象很大、只改第一层shallowReactive
传出去不许改readonly
第三方实例(图表、地图、编辑器)markRaw
解构 reactive 又想保住响应toRefs / toRef
  • markRaw 那一行是最实用的一条:把 ECharts 实例、地图对象放进 reactive 会让 Vue 递归代理它内部成千上万个属性——不但慢,还可能把第三方库改坏
  • shallowRef 的语义是「改了不通知」而不是「改不动」:s.value.x = 1 值真的变了,只是不触发更新——所以它造成的 bug 表现为「数据对了但界面没动」
import { reactive, computed, shallowRef, readonly, toRaw, markRaw, toValue } from 'vue';

// 大对象只在整体替换时更新(不深追内部)
const big = shallowRef({ huge: 'data' });
big.value = { huge: 'new' };   // 才触发更新

const state = reactive({ count: 0 });
const ro = readonly(state);    // 改它开发模式会警告
const chart = markRaw(new ChartLib()); // 永不变响应式

// toValue:兼容 ref / getter / 普通值(写组合式函数必备)
function useDouble(input) {
  return computed(() => toValue(input) * 2);
}
ref 的自动解包有两套规则,别混淆:① 在 reactive 对象里,ref 作为属性自动解包(读写都直通原 ref);但放进 reactive 数组或 Map 里,访问元素不会解包——arr[0]m.get('k') 拿到的仍是 Ref 对象,需手动 .value。② 模板里只有 setup 顶层绑定能在表达式中自动解包——{{ obj.count + 1 }}obj.count 是 ref 时渲染 "[object Object]1";隐蔽的是直接插值 {{ obj.count }} 反而能显示值(插值出口会解 ref),于是「显示正常、一运算就坏」。
第三方库实例(图表、地图、编辑器、WebSocket 连接)一律用 markRaw() 包一层再存,或者干脆存进普通变量、shallowRef。因为 reactive/ref递归代理整个对象树,把一个 ECharts 实例代理一遍不但开销巨大,还会让库内部依赖对象身份(thisinstanceof、内部缓存比对)的逻辑出现难查的怪异行为。经验法则:你不打算让它驱动视图的东西,就别让它进响应式系统

计算属性与监听

computed 声明派生数据(带缓存),watch/watchEffect 执行副作用。展示派生值用 computed,「数据变化时做某事」用 watch。

computed 的「缓存」不是附加功能,而是「惰性的响应式副作用」这个本质的自然结果——从这一点能推出它的全部行为。

从「惰性副作用」往下推

探源:computed 的「缓存」不是额外加的功能,而是它本质的自然结果——computed 是一个惰性的响应式副作用:首次访问 .value 才运行一次求值函数(创建后不访问,计算 0 次),运行中自动记下读过哪些响应式依赖,把结果缓存;只有当这些依赖真的变了,缓存才失效、下次访问重算。由此推出:依赖没变时反复访问都返回缓存(连读 3 次只算 1 次——这就是它比「每次都执行」的方法快的原因);也因为它是「由数据算出数据」,默认只读——直接赋值警告 Write operation failed: computed value is readonly 且值不变,需要反向写回才提供 get/set。对比 watch:computed 是「拉」(用到才算、要有返回值),watch 是「推」(依赖一变就主动跑副作用、不返回值)。访问时也要 .value(模板自动解包)。

import { ref, computed } from 'vue';

const first = ref('三'), last = ref('张');

// 只读:依赖变才重算并缓存
const fullName = computed(() => `${last.value}${first.value}`);

// 可写计算属性
const name = computed({
  get: () => `${last.value}${first.value}`,
  set(v) { [last.value, first.value] = v.split(''); },
});
name.value = '李四';          // 自动拆分回写

// 派生数据的典型用法(cart 是个装商品的 ref 数组)
const cart = ref([{ price: 10, qty: 2 }]);
const total = computed(() =>
  cart.value.reduce((s, i) => s + i.price * i.qty, 0));
computed 里绝对不要写副作用——不要在求值函数里发请求、改别的响应式数据、操作 DOM。它是「由数据算出数据」,会被缓存、也可能因依赖未变而根本不执行,把副作用放进去会得到时灵时不灵的行为。需要「数据一变就去做点什么」用 watch/watchEffect。另外别去改 computed 返回的对象/数组:它是算出来的派生结果,改它不会回写到源数据——改完立刻读「还在」(缓存里的正是同一个对象),依赖一变重算就丢,于是 bug 呈现为「有时生效有时被重置」。
展示「派生数据」优先 computed 而非模板里调方法 {{ getTotal() }}:computed 缓存、只在依赖变时重算;方法每次重渲染都执行。

两者的分界线很清楚:要新旧值、要精确指定源watch只想「用到什么就监听什么」watchEffect

选哪个,以及监听源怎么写

watch 显式指定源、能拿新旧值,适合「某值变化时做某事」;监听 reactive 属性要用 getter () => state.x——直接写 watch(state.x, ...) 传进去的是当下的原始值,警告 Invalid watch source: ... A watch source can only be a getter/effect function, a ref, a reactive object, or an array of these types. 且永不触发;直接监听 reactive 对象是隐式深度监听(嵌套属性变化即触发),ref 包对象则需 deep: true(不加 deep,改 user.value.age 触发 0 次,加了才触发)——deep 要遍历全部嵌套属性,大对象上有性能代价,能用 getter 精确到某属性就别整棵深度监听。立即跑一次用 immediatewatchEffect 自动收集回调内用到的依赖、立即执行,但意图不如 watch 明确。两者都支持 清理回调清理副作用、防竞态。
flush 时机(何时触发回调):默认 flush: 'pre'——在组件 DOM 更新之前触发,所以回调里读到的 DOM 还是旧的;要在回调里访问更新后的 DOM,用 flush: 'post'(等价于内置的 watchPostEffect),这正是「数据变→DOM 刷新→读新 DOM」场景不必手写 nextTick 的做法;flush: 'sync' 让每次数据变都同步立即触发、不做批处理,多次连改会重复执行,仅调试/特殊需求用,慎选。三种时机的触发顺序:sync 回调 → 改数据那行之后的同步代码 → pre 回调(微任务里、DOM 更新前)。

两者的分工

watchwatchEffect
依赖怎么来显式指定源自动收集(回调里读到什么就依赖什么)
能拿新旧值不能
首次是否执行默认不执行(要 immediate: true立即执行一次
适合「某个值变了要做某事」「这段副作用用到什么就跟着什么」
  • 选择判据:需要旧值,或者只想盯住一两个源 → watch副作用里读了一堆响应式数据、懒得一一列举 → watchEffect
  • watchEffect 的代价是依赖是隐式的:以后在回调里多读一个 ref,依赖就悄悄变多了——排查「为什么它又跑了」时要把回调整个读一遍。
import { watch, watchEffect, watchPostEffect, onWatcherCleanup } from 'vue';

// 下面出现的 query / state / form / list / scroller / userId
// 均为已声明的 ref 或 reactive,此处省略声明以聚焦 watch 本身

// watch:能访问新旧值
watch(query, (val, old) => { search(val); });

// 监听 reactive 属性用 getter;多源用数组
watch(() => state.count, (n) => {});
watch([a, b], ([na, nb]) => {});

// ref 包对象需 deep;immediate 立即跑一次
watch(form, () => {}, { deep: true, immediate: true });

// flush:'post':等 DOM 更新后再跑(回调里能读到新 DOM)
watch(list, () => {
  scroller.value.scrollTop = scroller.value.scrollHeight; // 已是新高度
}, { flush: 'post' });
watchPostEffect(() => {});   // 自动追踪依赖 + post 时机

// watchEffect:自动追踪依赖 + 清理副作用防竞态
watchEffect((onCleanup) => {
  const ctrl = new AbortController();
  fetch(`/api/u/${userId.value}`, { signal: ctrl.signal });
  onCleanup(() => ctrl.abort()); // 下次执行前/卸载时调用
});
watch(state, (n, o) => {}) 直接传 reactive 对象时,Vue 会隐式开深度监听,而回调拿到的 no 指向同一个对象n === otrue)——因为对象是原地改的,没有「旧的那份」。结果是 n.count !== o.count 这类差异判断恒为假,回调看似执行了却什么都不做,且没有任何警告。要拿到可用的新旧值,就用 getter 精确到某个属性:watch(() => state.count, (n, o) => {})
清理副作用有两种写法:① 回调首参 onCleanup(如上;3.5 在 await 之后注册也生效,但为避免竞态窗口仍建议放最前);② Vue 3.5+ 的 onWatcherCleanup()——从 'vue' 导入,但必须在副作用同步执行期间调用(异步里调用警告 onWatcherCleanup() was called when there was no active watcher to associate with.)。二者作用相同:下次触发前 / 侦听停止时执行。3.5 还给 watch 的返回值加了句柄方法const w = watch(...) 后可 w.pause() / w.resume() / w.stop()——暂停期间的变化不会补触发,恢复后只响应新变化,做「表单编辑时暂停自动保存」这类需求不用再手写开关变量。

生命周期

Composition API 的生命周期钩子都以 on 开头,在 setup 中注册。掌握挂载/更新/卸载时机,配合 nextTick 处理 DOM;后两卡专讲错误处理——onErrorCaptured 能接住什么、漏掉什么(矩阵),以及 ErrorBoundary 与全局上报怎么组合。

时间线:setup 顶层(≈created)→ onBeforeMount → 挂载 → onMounted → 数据变 → onBeforeUpdate/onUpdatedonBeforeUnmountonUnmounted

各钩子的分工

  • onMounted 最常用:DOM 已就绪,访问模板 ref、初始化第三方库(图表/编辑器)、只在浏览器做的事(读 localStorage)都放这——它天然是「仅客户端」的(SSR 不执行);
  • 发首屏请求放 setup 顶层还是 onMounted 都行(纯 SPA 差别只在早几毫秒),但 Nuxt/SSR 项目必须用 useFetch 一类(14 章)——只写在 onMounted 里,服务端直出的 HTML 就是空的;
  • onUpdated 别拿来「响应某个数据变化」——它在任何更新后都触发且拿不到「谁变了」,那是 watch 的活(04 章);它只适合「每次 DOM 变完都要做」的少数场景(如同步第三方库的布局);
  • onUnmounted 做清理,和 onMounted 成对写(见 tip)。
import { onMounted, onUnmounted } from 'vue';

onMounted(() => {
  // DOM 已就绪:访问元素、发请求、初始化图表
  fetchData();
  chart = new Chart(chartRef.value);
});

onUnmounted(() => {
  // 清理:定时器、订阅、第三方实例
  clearInterval(timer);
  chart?.destroy();
  ws?.close();
});

// 注意:没有 onCreated!setup 顶层代码就是 created 时机
所有生命周期钩子必须在 setup 的同步执行阶段注册。写在 await 之后、setTimeout 回调里、或某个异步分支中的 onMounted(...) 不会执行,但并非无声无息——有完整警告:onMounted is called when there is no active component instance to be associated with. ... make sure to register lifecycle hooks before the first await statement.,连修法都写在警告里了,可惜它最常被当噪音划走。另外 onMounted服务端渲染时完全不执行(SSR 只跑 onServerPrefetch),Nuxt 项目里把必需的数据获取只塞进 onMounted,直出的 HTML 就是空的。
onMountedonUnmounted 成对写:写下 addEventListenersetIntervalnew SomeLib() 的同一分钟里就把对应的 removeEventListenerclearIntervaldestroy() 补进 onUnmounted。组件反复挂载卸载(路由切换、列表增删)时,漏掉的清理会累积成看不见的内存泄漏和「事件被触发了好几次」的怪事。更省事的做法是用 VueUse 里自带清理的 useEventListener 之类。

Vue 的响应式更新是异步批处理的:同一轮事件循环内多次改数据,只触发一次 DOM 更新。

count 连改三次(1、2、3):改完立即读 DOM 还是 "0"await nextTick() 之后直接是 "3"——中间的 1 和 2 从未上过屏。这就是批处理的含义:Vue 攒着所有变更,在微任务里一次算清。所以改完数据立即读 DOM 仍是旧值nextTick() 返回 Promise,在本轮 DOM 更新完成后 resolve。
import { nextTick } from 'vue';

// message / el / items 为已声明的 ref(el 通过模板引用绑定,见 06 章)

async function updateAndRead() {
  message.value = '新内容';
  console.log(el.value.textContent); // ❌ 还是旧内容

  await nextTick();                // 等 DOM 刷新
  console.log(el.value.textContent); // ✅ 新内容
}

// 典型场景:加元素后滚到底部 / 让新元素获得焦点
async function addAndScroll() {
  items.value.push(item);
  await nextTick();
  list.value.scrollTop = list.value.scrollHeight;
}
两个高频写法事故:一是忘了 await——写成 nextTick(); el.value.scrollTop = ...,第二行依然同步执行,读到的还是旧 DOM,行为和没写一样。二是 await nextTick() 之后组件可能已经被 v-if 卸载或列表项已被删除,此时模板 ref 已置回 null,直接 el.value.focus() 就是 Cannot read properties of null。等待之后访问 ref,先做一次 if (!el.value) return
如果你发现自己在 watch 回调里到处写 await nextTick(),改用 watch(src, cb, { flush: 'post' })(或 watchPostEffect)——它本来就在 DOM 更新之后触发,回调里读到的就是新 DOM,一行配置省掉所有手动等待。nextTick 更适合事件处理函数这种一次性场景,比如「push 一条数据后滚到底部」。

onErrorCaptured 只接得住走 Vue 调用栈的错误。哪些算、哪些不算,别背清单——下面这张矩阵每一行都在 3.5.40 上验证过,info 参数(回调第三参)会直接告诉你错误来自哪一类。

接得住(info 值)

  • 渲染函数/模板里抛错 → info = "render function"
  • 事件处理器里抛错 → info = "native event handler"(组件自定义事件则是 component event handler);
  • watch 回调里抛错 → info = "watcher callback"
  • 生命周期钩子里抛错 → info = "mounted hook"(按钩子名变化);
  • 此外 setup 抛错、指令与过渡钩子抛错同样在覆盖范围内。

漏网的三类

  • setTimeout/setInterval 回调:定时器里抛错,errorCaptured 接住 0 次——回调执行时早已离开 Vue 的调用栈;
  • 游离的 Promise(既没被 return 也没被 await、又没 .catch()):reject 只进浏览器的 unhandledrejection,ErrorBoundary 一动不动(见本卡 pitfall 与下一卡);
  • 你自己 addEventListener 挂的监听器:不经 Vue 的事件包装,错误直达 window.onerror

三类的共同修法:入口处自己 try/catch(或 .catch()),把错误交给响应式状态error.value = e)让 UI 渲染它——一旦错误变成了数据,就回到了 Vue 的世界。

// info 参数速查(3.5.40)
onErrorCaptured((e, instance, info) => {
  // 模板/渲染抛错     → info === 'render function'
  // @click 处理器抛错  → info === 'native event handler'
  // watch 回调抛错     → info === 'watcher callback'
  // mounted 钩子抛错   → info === 'mounted hook'
});

// 漏网场景的标准修法:把错误变成数据
const error = ref(null);
setTimeout(() => {
  try { risky(); }
  catch (e) { error.value = e; }  // UI 由它驱动,回到 Vue 世界
}, 1000);
最迷惑的一类是 Promise:Vue 会给事件处理器返回的 Promise 挂上错误处理,所以 async 处理器从头到尾都在覆盖范围内——await 之后抛错照样进 errorCaptured(info 仍是 native event handler)。真正漏网的是函数内部另起、既没 return 也没 await 的游离 Promiseboom() { fetch(...).then(...) } 的 then 链里抛错,只进 unhandledrejection、errorCaptured 收到 0 次。一字之差:await fetch(...) 在保护伞下,裸起的 fetch(...) 不在。
info 的取值在排障时很值钱:收到上报后先看 info——是 "watcher callback" 就去查 watch 逻辑,是 "render function" 就去查模板里的空引用(十有八九是 xxx.yyy 里 xxx 还是 null)。把 info 一起上报,比裸 message 好定位得多。

Vue 的错误处理是两层:组件树内用 onErrorCaptured 做边界,全局用 app.config.errorHandler 兜底。

两层错误捕获

onErrorCaptured 捕获子组件树抛出的错误(Vue 版 Error Boundary),返回 false 阻止继续向上冒泡——孙组件事件处理器里 throw,中层 errorCaptured 返回 false 后,根组件的 errorCapturedapp.config.errorHandler 都不再收到,链路在中层被剪断。全局兜底用 app.config.errorHandler(接到错误上报 Sentry 等)。组合二者即可做出 ErrorBoundary 组件。

import { onErrorCaptured, ref } from 'vue';

const err = ref(null);
onErrorCaptured((e, instance, info) => {
  err.value = e;            // info:错误来源,如 "render function"(取值见上一卡)
  return false;             // 阻止向上传播
});

// 全局:main.js
// app.config.errorHandler = (e, vm, info) => reportError(e, info)

// ErrorBoundary 模板:
// <div v-if="err">出错了</div>
// <slot v-else />
这套机制只覆盖走 Vue 调用栈的错误:渲染、事件处理器、生命周期钩子、setup、侦听器、指令与过渡钩子。游离在外的异步错误捕获不到——最典型的就是没有 await、也没有 .catch()fetch(),它 reject 后错误只会进浏览器的 unhandledrejection,你的 ErrorBoundary 一动不动、页面停在加载中。修法:async 逻辑要么被 await 进有 try/catch 的调用链,要么额外挂一个 window.addEventListener('unhandledrejection', ...)
onErrorCaptured 返回 false同时切断向上冒泡和 app.config.errorHandler。所以做 ErrorBoundary 时顺序很重要:先上报(Sentry 等)、渲染兜底 UI,最后才 return false;顺手写个 return false 的结果往往是「用户看到了友好提示,而你的监控一条错误都没收到」。真想让全局兜底也知道,就别返回值。

组件通信

组件间数据流:props 向下、emit 向上、slots 分发内容、v-model 双向。Vue 3.4+ 的 defineModel 让双向绑定一行搞定。

defineProps编译宏而不是普通函数——这解释了它为什么不用 import、也不能写在嵌套作用域里。

四条核心规则

defineProps 声明子组件接收的参数(它是编译宏——由 Vue 在编译 <script setup> 时就地展开的特殊函数,因此无需也不能 import,且只能写在顶层),可为每个 prop 指定类型 / 必填 / 默认值 / 校验器。四条核心规则:
只读 + 单向数据流——父数据变会自上而下流入并覆盖,子组件不能反向直接改;
访问方式:模板里直接写名字({{ title }}),JS 里必须经由 props.title(3.5+ 解构仍保持响应式,老版本解构会丢);
命名自动转换:声明用 camelCase(maxLength),父组件模板里传 kebab-case(:max-length),Vue 自动对应;
类型可多选[String, Number]);对象 / 数组默认值必须用工厂函数返回,否则所有组件实例共享同一个引用——写 default: {} 时两个实例拿到的是同一个对象,且 3.5.40 没有任何警告,共享是静默发生的,一个实例改了另一个跟着变。Boolean 类型有特殊转换:只写属性名(<Comp disabled>)即等价传 true

<script setup>
const props = defineProps({
  title: { type: String, required: true },
  count: { type: Number, default: 0 },
  items: { type: Array, default: () => [] }, // 默认值用函数
  variant: { type: String, default: 'primary',
    validator: (v) => ['primary', 'danger'].includes(v) },
});
// TS 推荐:const props = defineProps<Props>()

// JS 中经由 props.xxx 访问
const isEmpty = computed(() => props.items.length === 0);
</script>

<template>
  <!-- 模板中直接用名字,无需 props. 前缀 -->
  <h3>{{ title }}({{ count }})</h3>
</template>

<!-- 父组件先 import ChildCard from './ChildCard.vue'(script setup 下 import 即注册) -->
<ChildCard
  title="静态字符串"            <!-- 无冒号 = 字面量字符串 -->
  :count="5"                    <!-- 有冒号 = JS 表达式,传数字 5 -->
  :items="list"                 <!-- 绑定变量 -->
  :max-length="20"             <!-- kebab-case → prop maxLength -->
/>

<!-- 一次性透传整个对象的所有属性 -->
<ChildCard v-bind="cardProps" />
直接修改 props 有两条路径、两种下场:<script setup>props.msg = 'x' 警告 Set operation on key "msg" failed: target is readonly.,写入被静默丢弃、不抛错,值纹丝不动;Options API 的 this.msg = 'x' 走实例代理,先警告 Attempting to mutate prop "msg". Props are readonly.,再在严格模式下抛 TypeError: 'set' on proxy: trap returned falsish for property 'msg',直接中断当前调用链。无论哪条路,值都改不进去。要「基于 prop 的可变本地值」,用 const local = ref(props.count) 再配 watch 同步,或改成 defineModel 双向绑定。
传值 vs 传字符串是最常见的坑:count="5"(无冒号)传的是字符串 "5":count="5"v-bind 简写)才把 5 当 JS 表达式求值、传数字。同理 :disabled="true" 传布尔,而 disabled="true" 传的是字符串 "true"(恒为真)。规律:凡是想传非字符串(数字 / 布尔 / 数组 / 对象 / 变量),一律加冒号

props 单向向下,事件单向向上——这一对合起来就是 Vue 的完整数据流,没有第三种官方通道。

声明、抛出与命名约定

子组件不能改 props,要「通知父组件」就靠抛事件defineEmits 声明子组件能触发的事件(编译宏),调用返回的 emit(name, ...args) 抛出事件并可携带任意多个参数作为 payload;父组件用 @event="handler" 监听,回调收到的参数就是这些 payload。
命名约定:声明用 camelCase(updateCount),父组件模板监听推荐写 kebab-case(@update-count),Vue 自动对应。
两种声明形式:数组式(只列名字,最简)或对象式(值是校验函数,返回 false 会在控制台告警,用来在开发期校验 payload 结构)。TS 下用泛型 defineEmits<{ submit: [data: string] }>() 获得事件名与参数的编译期校验。

<script setup>
// ① 数组式:最简
const emit = defineEmits(['submit', 'cancel']);

// ② 对象式:带 payload 校验(返回 false 触发告警)
const emit = defineEmits({
  submit: (payload) => payload.length > 0, // 校验非空
  cancel: null,                          // null = 不校验
});
// TS:defineEmits<{ submit: [data: string]; cancel: [] }>()

function onSubmit() {
  emit('submit', input.value, Date.now()); // 可传多个参数
}
</script>

<template>
  <button @click="onSubmit">提交</button>
  <button @click="emit('cancel')">取消</button> <!-- 模板里可直接 emit -->
</template>

<!-- 父组件:声明 camelCase → 监听 kebab-case -->
<ChildForm
  @submit="(data, t) => save(data)" <!-- 接收多个 payload -->
  @cancel="onCancel"
  @submit.once="log"             <!-- 事件修饰符 .once 只触发一次 -->
/>
子组件根元素上的原生事件会自动透传(fallthrough),但一旦你 defineEmits(['click']) 声明了同名事件,Vue 就把它当作组件自定义事件——原生 @click 不再自动触发(声明后点击子组件内的按钮,父组件 @click 收到 0 次),需要你在代码里手动 emit('click'),否则父组件的监听静默失效。别无意间声明了原生事件名
约定俗成:把事件名取为 update:xxx,父组件就能用 v-model:xxx 监听——这正是 defineModel 双向绑定背后的机制(见后面的卡片)。

没在 defineProps/defineEmits 里声明的属性会自动落到根元素上——「给封装组件加个 class 居然生效了」就是这条规则。

透传规则与两种接管方式

父组件传给子组件、但子组件没在 defineProps / defineEmits 里声明的属性(classstyleid@click 等原生监听),会自动落到子组件的根元素上——这叫透传 attribute(fallthrough),是「给封装组件加 class / 绑原生事件仍然生效」的原因。
当子组件是多根节点,或你想把这些属性精确落到内部某个元素(而非根节点)时,用 defineOptions({ inheritAttrs: false }) 关闭自动透传,再用 v-bind="$attrs" 手动绑定到目标元素。$attrs 是个对象,含所有透传的属性和事件监听器。

<!-- BaseInput.vue:把外部 attribute 落到真正的 input 上 -->
<script setup>
defineOptions({ inheritAttrs: false }); // 关掉根节点自动透传
defineProps(['label']);
</script>

<template>
  <label>
    {{ label }}
    <!-- placeholder、@input、class 等全落到 input 而非外层 label -->
    <input v-bind="$attrs">
  </label>
</template>

<!-- 父组件:这些都会透传进内部 input -->
<BaseInput
  label="邮箱"
  placeholder="you@mail.com"
  @input="onInput"
  class="w-full" />
多根节点组件不会自动透传:给它加 class@click 之后,Vue 不知道该落到哪个根上,于是全部丢弃,并在控制台给一条运行时警告(报错原文:Extraneous non-props attributes (class) were passed to component but could not be automatically inherited because component renders fragment or text or teleport root nodes.)——症状就是「样式加了没用、点击没反应」。解法是显式把 v-bind="$attrs" 绑到你想要的那个根元素上。另外 $attrs 本身不是响应式的(出于性能考虑),watch($attrs, ...)watch(() => attrs.foo, ...) 都不会触发;真需要监听变化,就把它声明成 prop
<script setup> 里 JS 中访问透传属性用 useAttrs()import { useAttrs } from 'vue')。注意 $attrs 响应式解构——需要响应式时保留 useAttrs() 返回的对象整体使用。

插槽让父组件把内容注入子组件:默认插槽 <slot />、具名插槽 <slot name="x" />(父用 #x 简写填充)、作用域插槽把子组件数据传回父组件渲染。

作用域的铁律

  • 插槽内容在父组件作用域编译:它看得到父的数据、看不到子的——「内容长什么样」父说了算,「放在哪、给什么数据」子说了算;
  • 要让父拿到子的数据,唯一通道是作用域插槽:子组件 <slot :count="total" /> 把数据挂到插槽上,父组件 #footer="{ count }" 解构接收。这是 headless(无样式)组件库的基石:表格组件管分页排序、每个单元格怎么渲染全权交给使用者。

判断插槽有没有被填

  • $slots.header 在父组件没传对应模板时是 undefined,配 v-if="$slots.header" 决定要不要渲染外层容器——否则没人填 header 时也渲染出一个带 padding 和边框的空壳;
  • <slot>默认内容</slot> 标签内写兜底,父没填时显示。
<!-- BaseCard.vue -->
<template>
  <header><slot name="header">默认标题</slot></header>
  <main><slot /></main>           <!-- 默认插槽 -->
  <!-- 作用域插槽:把数据传回父组件 -->
  <footer><slot name="footer" :count="total"/></footer>
</template>

<!-- 父组件使用 -->
<BaseCard>
  <template #header><h2>自定义标题</h2></template>
  <p>主要内容</p>
  <!-- 接收作用域插槽传回的数据 -->
  <template #footer="{ count }">共 {{ count }} 项</template>
</BaseCard>
插槽内容在父组件作用域里编译,拿不到子组件的数据。所以在 <BaseCard> 标签内部写 {{ total }} 想取子组件的 total,结果是渲染为空(或报未定义),而不是任何提示——要拿子组件的数据只能走作用域插槽(子组件 <slot :count="total" />,父组件 #footer="{ count }")。另外默认插槽的简写 <BaseCard v-slot="{ x }"> 不能和具名插槽混用——3.5.40 这种混用直接让编译器崩溃,抛的还是一条内部错误 Codegen node is missing for element/if/for node. Apply appropriate transforms first. 而非友好提示;构建时看到这句没头没脑的报错,先检查插槽语法,改写成显式的 <template #default="{ x }">
v-if="$slots.header" 判断父组件是否真的传了这个插槽,再决定要不要渲染外层容器——否则没人填 header 时你依然渲染出一个带 padding 和边框的空壳。这是封装 Card / Modal / Table 这类布局组件的标准做法,配上 <slot>默认内容</slot> 兜底即可。

v-model 一直是 :modelValue + @update:modelValue 的语法糖,3.4 的 defineModel 只是把子组件侧那份样板也一并省掉了。

它展开成了什么

v-model 本质是 :modelValue + @update:modelValue 的语法糖。Vue 3.4+ 的 defineModel() 宏自动生成这对 props/emit,返回一个 ref,子组件读写它就自动同步父组件——彻底告别手写样板。支持命名多个 v-model。把编译产物打出来看(@vue/compiler-sfc):一行 defineModel({ default: '' }) 展开成 props: { "modelValue": { default: '' }, "modelModifiers": {} } + emits: ["update:modelValue"] + 一个 useModel 调用——宏只是糖,底下的父子契约一点没变,所以它能和手写 :modelValue 的旧组件无缝互操作。

<!-- CustomInput.vue -->
<script setup>
const model = defineModel();        // 等价 props+emit 一行搞定
// 命名多个:
const first = defineModel('firstName');
const last  = defineModel('lastName');
// TS:const model = defineModel<string>()
</script>

<template>
  <input v-model="model">       <!-- 读写自动同步父组件 -->
</template>

<!-- 父:<CustomInput v-model="username" /> -->
<!-- 多个:<NameInput v-model:first-name="f" v-model:last-name="l" /> -->
defineModel({ default: 1 }) 设默认值会造成父子不同步:当父组件绑的那个 ref 初始是 undefined 时,子组件里 model.value 是 1,而父组件的 ref 仍然是 undefined——两边显示的值不一样,且不报任何错。表现就是「子组件明明显示 1,父组件提交上去却是空」。规则:默认值交给父组件在声明 ref 时给,子组件的 default 只在确定父组件不会绑 v-model 时才用。
需要在写回父组件前做转换(去空格、转数字、格式化),别在 watch 里改自己造成循环,直接给 defineModelget / set 选项:defineModel({ set: (v) => v.trim() }),读写两侧各转一次,逻辑集中在一处。要求父组件必须绑定时用 defineModel({ required: true }),缺失会有明确警告,比运行时才发现 undefined 强。

拿真实 DOM 或子组件实例是「逃生舱」——而 <script setup> 默认什么都不暴露,比 Options API 的默认全暴露安全得多。

取引用与显式开放

获取真实 DOM 或子组件实例:Vue 3.5+ 用 useTemplateRef('name'),或经典写法让 ref 变量名匹配模板 ref="x"<script setup> 默认对父组件暴露任何东西,子组件须用 defineExpose 显式开放方法(比 Options API 默认全暴露更安全)。

<script setup>
import { useTemplateRef, onMounted } from 'vue';

const inputEl = useTemplateRef('my-input'); // 3.5+
onMounted(() => inputEl.value.focus()); // 挂载后才非 null
</script>

<template>
  <input ref="my-input">
  <ChildForm ref="formRef" />
</template>

// ── 子组件 ChildForm.vue 暴露方法 ──
function validate() { return true; }
defineExpose({ validate });   // 父:formRef.value.validate()
模板 ref 在挂载完成前是 null,在 setup 顶层直接 inputEl.value.focus() 必然报错,要放进 onMounted。更隐蔽的是被 v-if 控制的元素:条件为假时 ref 会被重新置为 null,之前存下来的引用就失效了,用前都得判空。还有一条:v-for 上的 ref 会得到一个数组,但数组顺序不保证与源数据一致——把源数组整个倒序,DOM 顺序如期倒过来了,ref 数组却保持旧序(keyed diff 只移动元素、不重跑 ref 收集),此时按下标 refs.value[i] 对应第 i 项全是错位的。需要精确对应就改用函数式 ref 自己建映射。
对子组件只 defineExpose 命令式动作focus()validate()reset()),别把内部的 ref 状态暴露出去。一旦父组件能 formRef.value.count = 5,props/emit 这条清晰的数据流就形同虚设,之后没人能说清某个状态是谁改的。想让父组件读到值,就用 defineModel 或 emit 事件。

指令与渲染

内置指令声明式地控制渲染:v-if/v-for 条件与列表、v-model 表单绑定、<component :is> 动态切换、Transition 动画、scoped 样式隔离。

v-if 真正销毁/重建 DOM,v-show 只切 display:nonev-for 渲染列表,必须绑稳定唯一的 :key。这几个指令的选择题都围绕同一个问题:节点的「身份」归谁管。

v-if vs v-show

  • v-if 是惰性的:条件首次为真才渲染,切换时整棵子树销毁重建(组件生命周期完整走一遍);v-show 始终渲染、只改 CSS——频繁切换用 v-show,条件很少变用 v-if
  • v-if 能和 v-else-if/v-else 组链,v-show 不能。

:key 是身份证,不是形式要求

  • diff 拿 key 判断「这还是不是原来那个节点」:key 相同就地复用(只更新变了的绑定),key 变了销毁重建;
  • 用 index 当 key,删掉第一行后每行的 key 没变、内容却整体上移——输入框、焦点、动画这些 DOM 自带状态会跟着位置走而不是跟着数据走;
  • 重复 key 不会静默放行:更新期警告 Duplicate keys found during update: 5 Make sure keys are unique.
<template>
  <p v-if="status === 'loading'">加载中</p>
  <p v-else-if="status === 'error'">出错</p>
  <p v-else>完成</p>

  <!-- v-show:频繁切换用它,不销毁 DOM -->
  <div v-show="open">面板</div>

  <!-- v-for 必须有 :key -->
  <li v-for="(u, i) in users" :key="u.id">
    {{ i + 1 }}. {{ u.name }}
  </li>
</template>
别在同一元素上同时用 v-forv-if——Vue 3 里 v-if 优先级更高,拿不到 v-for 的循环变量:v-for="i in [1,2]" v-if="i > 1" 警告 Property "i" was accessed during render but is not defined on instance,列表一行都渲染不出。正确做法:先用 computed 过滤出列表,再 v-for;或把 v-if 提到外层 <template> 上。
没有天然 id 时,用内容派生出的稳定字符串做 key(如 `${row.date}-${row.type}`),也别退回 index——只要列表会排序、插入、删除,index 就会让「第 3 行的输入框内容」跟着位置而不是数据走。反过来,:key 变化会强制 Vue 销毁重建,这一点可以主动利用:给需要「换个 id 就彻底重来」的组件绑 :key="route.params.id",比手写一堆重置逻辑干净得多。

<component :is> 把「渲染哪个组件」变成一个普通的响应式值——Tab、分步向导、按类型渲染表单都靠它。

:is 能接受什么

<component :is="..."> 是内置的动态组件:is 的值决定此刻渲染谁。值可以是导入的组件对象<script setup> 里最常用,直接绑组件变量),也可以是全局注册的组件名字符串,还能是普通 HTML 标签名(如 'a''div')。典型场景:Tab 页签分步向导按数据类型渲染不同表单控件。默认切换即销毁旧组件、创建新组件,想保留被切走组件的状态就套 <KeepAlive>(见 11 章)。

<script setup>
import Home from './Home.vue';
import Profile from './Profile.vue';
import { shallowRef } from 'vue';

// 把"当前组件"存进 ref 时用 shallowRef:组件对象不必深度代理
const view = shallowRef(Home);
</script>

<template>
  <button @click="view = Home">首页</button>
  <button @click="view = Profile">资料</button>

  <!-- :is 绑组件对象(script setup 首选) -->
  <component :is="view" />

  <!-- :is 也能是全局注册名,或 'a'/'div' 等标签名 -->
  <component :is="ok ? 'a' : 'span'">文本</component>
</template>
别把组件对象放进 reactive 或普通 ref——深度响应式会代理整个组件定义,直接告警:Vue received a Component that was made a reactive object. This can lead to unnecessary performance overhead and should be avoided by marking the component with `markRaw` or using `shallowRef` instead of `ref`.——两种修法警告里都给了。存「当前组件」用 shallowRef(或 markRaw 标记)。另外切换即销毁重建,要保留输入/滚动等状态必须外套 <KeepAlive>(见 11 章)。
传标签名可动态渲染层级元素:<component :is="'h' + level">level 渲染 h1~h6,做标题组件很方便。

事件用 @(v-on 简写)绑定,表单用 v-model 双向绑定;两者的修饰符系统替你消化了 preventDefault、按键判断、类型转换这些样板。

修饰符分三族

  • 事件行为.prevent(preventDefault)、.stop(stopPropagation)、.self(仅自身触发才响应)、.once——可链式 @submit.prevent.once
  • 按键过滤.enter/.esc/.tab 及组合 .ctrl.enter——省掉手写 if (e.key === ...)
  • v-model 值处理.trim 去首尾空格、.number 转数字(脾气见 pitfall)、.lazy 把同步时机从 input 改到 change——失焦/回车才更新,做「输完整再校验」很顺手。

v-model 对控件的适配

  • 文本框绑字符串、checkbox 绑布尔(多个同名绑数组)、radio 绑选中值、select 绑选中 option 的 value——同一个指令按控件类型自动选对「值属性 + 事件」;
  • 它本质是 :value + @input 的语法糖,要更细的控制(如 IME 场景,见 tip)随时退回手写形态。
<template>
  <form @submit.prevent="onSubmit">   <!-- 自动 preventDefault -->
    <input @keyup.enter="onSubmit"> <!-- 仅 Enter -->

    <input v-model.trim="name">     <!-- 去首尾空格 -->
    <!-- .number 转数字;type="number" 时已自动应用,文本框才需手写 -->
    <input v-model.number="age">

    <input type="checkbox" v-model="agreed"> <!-- 布尔 -->
    <select v-model="city">
      <option value="bj">北京</option>
    </select>
  </form>
</template>
@click="fn"@click="fn(id)" 的差别在「拿不拿得到事件对象」:写函数名时 Vue 把它当方法处理器,调用时自动传入原生事件对象;一旦写成带括号的调用(或任何表达式),Vue 会把整段包进一个内联函数,此时形参位置被你占了,事件对象得显式用 $event——@click="fn(id, $event)"。注意这里和 React 不同:@click="fn()" 不会在渲染时就把 fn 执行掉。
另一个高频坑:v-model 默认拿到的是字符串,文本框输入 18 后 age + 1"181"——要数字得手写 .number。但 .number 有自己的脾气:它用 parseFloat 尽力解析,.trim.number 输入 " 42 " 得数字 42;输入 "4x2" 却得到 4(parseFloat 停在首个非法字符),只有完全解析不出才原样保留字符串——「用户输错了、没报错、值还悄悄变了」说的就是它,涉及金额一类的输入要自己再校验。(<input type="number"> 例外,自动应用 .number。)
做中文输入的实时搜索时要知道:v-model 在输入法(IME)拼字过程中不会更新,只有选词上屏后才同步一次。这通常是好事(避免拼音串触发一堆请求),但如果你要的就是「边打拼音边给候选」,得放弃 v-model,改成手写 :value + @input。另外 type="number" 的输入框已自动应用 .number,只有普通文本框才需要手写。

<Transition> 本身不做动画——它只在元素进入/离开时按固定时序挂上/摘下六个 CSS 类,动画效果全由你的 CSS 决定。

六个类的时序

  • 进入:插入前挂 v-enter-from(起点)+ v-enter-active(全程,transition 属性写这)→ 下一帧 from 换成 v-enter-to(终点)→ 过渡结束全部摘掉;
  • 离开:对称的 v-leave-from/active/to,且过渡结束才真正移除 DOM——这就是离开动画能播完的原因;
  • name="fade" 把类名前缀 v- 换成 fade-。实践中写四个类就够:两个 active 定时长缓动,*-enter-from/*-leave-to 定起止状态。

TransitionGroup 的不同之处

  • v-for 列表用,子项必须带 :key;默认不渲染包裹元素,要的话 tag="ul"
  • 独有 .v-move 类:增删或重排导致其余项换位时自动应用(内部是 FLIP 技术),写一行 .list-move { transition: transform .3s },列表就会平滑「让位」而不是跳变。
<template>
  <Transition name="fade" mode="out-in">
    <p v-if="show">淡入淡出</p>
  </Transition>

  <TransitionGroup name="list" tag="ul">
    <li v-for="i in items" :key="i">{{ i }}</li>
  </TransitionGroup>
</template>

<style scoped>
.fade-enter-active, .fade-leave-active { transition: opacity .3s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.list-move { transition: transform .3s; } /* 位移动画 */
</style>
<Transition> 只接受单个元素或组件作为内容,塞多了不是静默失效而是两头报警:并排放两个元素、或里面套 v-for,都会同时收到编译错 <Transition> expects exactly one child element or component. 和运行时警告 <transition> can only be used on a single element or component. Use <transition-group> for lists.。放组件时那个组件也必须是单根。真正静默失效的场景是在同标签元素间切换,比如 <span>{{ count }}</span>——Vue 判定是同一个节点、只更新文本,不触发进出场;必须加 :key="count" 强制它当成新元素。
组件切换用 mode="out-in":默认模式下新旧元素会有一瞬间同时存在,页面高度突然翻倍再弹回来,看着像抖了一下。想让元素首次渲染也播放动画(而不是只在后续切换时动),加 appear 属性。CSS 只需定义 *-enter-active/*-leave-active 的时长缓动,加上 *-enter-from/*-leave-to 的起止状态,四个类就够用。

scoped 的隔离是编译期加哈希做到的,不是运行时——所以它的穿透规则也就只能是编译期的 :deep()

隔离、穿透与 v-bind

<style scoped> 自动加哈希、样式仅作用本组件。CSS 里 v-bind(变量) 直接引用响应式数据(底层走 CSS 变量,适合主题切换)。:deep() 穿透 scoped 改子组件内部样式(覆盖第三方 UI 库的标准姿势,取代废弃的 /deep/)。

<script setup>
const theme = ref('#42b883');
</script>

<style scoped>
.title {
  color: v-bind(theme);     /* 响应式变量,变了自动更新 */
}
/* :deep() 穿透改子组件 / 第三方库内部样式 */
.wrapper :deep(.child) { color: red; }
:slotted(.item) { font-weight: bold; } /* 选中插槽内容 */
:global(.modal) { z-index: 9999; }      /* 声明全局样式 */
</style>
两个「样式写了没效果 / 效果多了」的经典来源:① 子组件的根元素同时受父子两套 scoped 样式约束(这是官方有意设计的,方便父组件调子组件的布局),所以你在父组件里为自己写的 .card { margin: 0 } 有可能意外命中子组件根节点;② v-html 渲染出来的内容完全不受 scoped 样式影响——给富文本写的 .content p { line-height: 2 } 一行都不会生效,必须改用 :deep() 或把这段样式放到非 scoped 的 style 块里。
覆盖第三方 UI 库时,:deep() 前面务必带一层自己的类名(写 .my-table :deep(.el-input) {},不要写 :deep(.el-input) {})。因为 :deep() 编译后哈希只落在它左边的选择器上——编译产物:.title 变成 .title[data-v-abc123],而 :deep(.child) 变成 [data-v-abc123] .child,后代选择器本身不带哈希、全靠左边那截锚定;左边空着就只剩 [data-v-hash] .el-input,等于对本组件子树里的一切同名库内元素生效(含深层嵌套的其它组件),组件一复用,污染就跟着去了别的页面——这类问题往往几周后才暴露。

组合式函数

把有状态逻辑抽成以 use 开头的函数复用,是 Vue 3 取代 mixins 的官方方案。provide/inject 解决跨层级依赖注入。

provide(key, value)所有后代提供数据,任意深度的后代用 inject(key, default) 取——中间层组件完全不用知情,这是它和逐层传 props 的本质区别。

用法要点

  • key 用 Symbol(放独立的 keys.js 导出)避免命名冲突,TS 下配 InjectionKey<T> 连注入值的类型都能推出来;
  • provide 一个 ref 本身(而不是 .value),后代拿到的才是「活的」——随祖先更新;配套的修改函数一起 provide(如 { theme, toggle }),让「谁能改」有唯一入口;
  • 不想让后代改就包一层 readonly()(03 章)。

和 Pinia 怎么分工

  • provide/inject 是组件树内的依赖注入:主题、语言、「当前表单上下文」这类跟着某棵子树走的数据——组件库内部大量用它(Form 和 FormItem 之间就是这么通信的);
  • Pinia 是应用级单例:登录用户、购物车这类全局、要跨路由存活的状态(09 章);
  • 判据一句话:这份数据的生命周期跟着「这棵子树」还是跟着「整个应用」。
// keys.js —— Symbol 作 key 避免冲突
export const themeKey = Symbol('theme');

// 祖先组件提供
import { provide, ref } from 'vue';
const theme = ref('dark');
const toggle = () => { theme.value = theme.value === 'dark' ? 'light' : 'dark'; };
provide(themeKey, { theme, toggle });

// 任意深度后代注入
import { inject } from 'vue';
const { theme, toggle } = inject(themeKey);
inject() 必须在 setup 的同步阶段调用。写进事件处理函数、onMounted 回调、或 await 之后,就拿不到注入值——返回 undefined 外加警告(报错原文 inject() can only be used inside setup() or functional components.),紧接着 const { theme } = inject(key) 这一行直接抛「无法解构 undefined」。同样地,key 打错、或祖先组件根本没 provide 时,行为也是「警告 + undefined」而不是报错(报错原文 injection "missingKey" not found.);养成习惯给个默认值 inject(themeKey, defaultTheme),或注入后先判空再解构。
provide 一个响应式 ref(而非它的 .value),后代拿到后能随祖先变化而更新。若想限制后代不能改,provide 时包一层 readonly()

组合式函数就是「把有状态的逻辑抽成一个返回响应式数据的普通函数」——它完全取代了 mixins,且没有来源不明的隐式注入。

怎么抽,抽出来长什么样

把「鼠标位置/窗口尺寸/数据请求/表单校验」等有状态逻辑抽成 useXxx() 函数:内部用 ref/computed/生命周期,return 出响应式状态。组件调用并解构即可,逻辑边界清晰,完全取代 mixins。下面手写一个 useFetch——14 章 Nuxt 有个同名的内置 useFetch,是加了 SSR payload 传递与请求去重的框架版,先看懂手写版就懂了它的原理。

四条组件通信路径,怎么选

路径方向适合
props父 → 子默认选它
emit子 → 父默认选它
provide / inject祖先 → 任意后代穿透多层的配置(主题、locale)
Pinia store任意 ↔ 任意跨页面共享的业务状态
  • 判据是作用域而不是层数:只有父子关心就 props/emit;整棵子树都要读的配置用 provide;跨路由、跨页面存活的才放进 store
  • 组合式函数不在这张表里——它复用的是逻辑,不是状态。每个调用它的组件各拿一份独立状态;要共享状态得把 ref 提到函数外面(那时它就成了一个模块级单例,SSR 下要当心串请求,见 14 章)。
// composables/useFetch.js
import { ref, watchEffect, toValue } from 'vue';

export function useFetch(url) {
  const data = ref(null);
  const error = ref(null);
  const loading = ref(true);

  watchEffect(async (onCleanup) => {
    const ctrl = new AbortController();
    onCleanup(() => ctrl.abort());   // 下次执行前中止旧请求,防竞态
    const target = toValue(url);     // 依赖要在首个 await 之前读!
    loading.value = true;
    error.value = null;
    try {
      const res = await fetch(target, { signal: ctrl.signal });
      data.value = await res.json();
    } catch (e) {
      if (!ctrl.signal.aborted) error.value = e;  // 被中止不算错误
    } finally {
      if (!ctrl.signal.aborted) loading.value = false;
    }
  });

  return { data, error, loading };
}

// 用:const { data, loading } = useFetch(() => `/api/u/${id.value}`)
// id 变化时自动重新请求
watchEffectasync 回调有两条硬规则:依赖只在首个 await 之前收集——await 之后才读的 ref,改它触发 0 次,await 之前读的照常触发,监听就这样静默瘸了一半;onCleanup 也应在首个 await 之前注册——3.5 迟注册不报错也仍然生效,但注册完成前的那段异步间隙是竞态窗口:新一轮触发若赶在上一轮注册完成之前,旧请求就没人中止了。所以上面把 toValue(url)onCleanup 都放在最前面。
参数一律设计成「ref / getter / 普通值都能传」,内部统一用 toValue() 读取。这样调用方既可以 useFetch('/api/list') 传死值,也可以 useFetch(() => `/api/u/${id.value}`) 让它随依赖自动重跑,不用你写两个版本。返回值则相反:返回一个包含若干 ref 的普通对象,让调用方能安心解构 const { data, loading } = useXxx()——如果返回 reactive({...}),调用方一解构就丢响应性。

Pinia 状态管理

Pinia 是 Vue 3 官方推荐的状态管理库(取代 Vuex)。Setup Store 风格用 ref/computed/函数定义状态,学习成本几乎为零。

推荐 Setup Store 写法:defineStore(id, setup)——setup 函数里 ref 就是 state、computed 就是 getter、普通函数就是 action(可异步),最后 return 要暴露的部分。组件里 useXxxStore() 拿到实例直接用。

为什么说学习成本几乎为零

  • 它就是「被 Pinia 托管的组合式函数」:03/04 章的知识原样适用,没有 mutations、没有魔法字符串;
  • 托管带来的增量才是价值:跨组件单例(多处调用 useCounter() 拿到同一份)、devtools 状态时间线、插件生态(持久化)、SSR 序列化——这些正是「自己写个模块级 composable 共享状态」缺的东西(模块级 ref 在 SSR 下还会跨请求串数据,14 章有最小复现)。

Option Store 也要认识

  • 另一种写法 defineStore(id, { state: () => ({...}), getters: {...}, actions: {...} }),形似 Options API,存量项目大量在用;
  • 两者能力基本等价,差异在细节(如 $reset 只有 Option Store 内置——下一卡有报错),团队统一一种即可。
// stores/counter.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export const useCounter = defineStore('counter', () => {
  const count = ref(0);               // state
  const double = computed(() => count.value * 2); // getter
  function inc() { count.value++; }     // action
  async function load() { count.value = await api(); }
  return { count, double, inc, load }; // 必须 return
});

// 组件中使用
const store = useCounter();
store.inc();
// 模板:{{ store.count }} / {{ store.double }}
useCounter() 不能在模块顶层调用——写成 const store = useCounter() 放在文件最外层(比如某个 utils.js 或路由文件里),它会在 app.use(createPinia()) 之前执行,直接抛错(pinia 4.0.2 报错原文:[🍍]: "getActivePinia()" was called but there was no active Pinia. Are you trying to use a store before calling "app.use(pinia)"?——连排查方向都在报错里)。正确做法是把调用挪进函数体内部:路由守卫写在守卫回调里、工具函数写在函数第一行,此时 pinia 早已安装完毕。
Setup Store 里只有 return 出去的才是 store 的一部分,这正好可以用来做「私有状态」:内部的缓存、锁标志、定时器 id 就别 return,外部改不到也就不会被误用。另外 store 的 id 保持与文件名一致(stores/counter.js'counter'),devtools 和持久化插件都按 id 索引,对不上时排查起来很痛苦。

直接解构 store 会丢响应性——这是 Pinia 最高频的一个坑,根因和「解构 reactive 会丢响应」完全一样。

storeToRefs 与持久化

直接解构 store 会丢响应性。state 与 getter 用 storeToRefs 解构保持响应;action 是普通函数可直接解构。store 之间可在 action 内互相调用。持久化用 pinia-plugin-persistedstate 插件。

import { storeToRefs } from 'pinia';

const store = useCounter();
// ❌ const { count } = store  丢响应
const { count, double } = storeToRefs(store); // ✅ state/getter
const { inc } = store;                  // ✅ action 直接解构

// action 内调用其它 store
function checkout() {
  const user = useUser();
  if (!user.isLoggedIn) throw new Error('请先登录');
}

// 持久化:main.js 里 pinia.use(piniaPluginPersistedstate)
// defineStore('user', setup, { persist: { pick: ['token'] } })
Setup Store 没有内置的 $reset()——Option Store 有,于是很多人照着别处的代码写 store.$reset(),运行时才报错(pinia 4 报错原文:🍍: Store "counter" is built using the setup syntax and does not implement $reset().)。需要重置就在 store 内部自己写一个 function $reset() { count.value = 0 }return 出去。另外 store.$state = { ... } 整体替换是合法操作(生效),但语义是「换掉整份状态」;批量局部修改用 store.$patch({ ... }) 更精确、devtools 里也只记一次变更。
记住 storeToRefs 只处理 state 和 getter(返回的对象里根本没有 action 的键;裸解构的 state 则停在解构那一刻的值上不再动),所以标准写法就是固定的两行:const { count, double } = storeToRefs(store) 取数据,const { inc, load } = store 取方法。别试图用 storeToRefs 一次性解构全部,也别把 action 再包一层——它们已经绑定到 store 上,直接解构出来调用就是对的。

Vue Router

Vue Router 官方路由:声明式路由表 + 编程式导航 + 导航守卫。配合懒加载实现路由级代码分割,进阶还有独享守卫、滚动行为与 props 解耦。

路由表是一个普通的数组,守卫是普通的函数——vue-router 没有魔法,理解它的成本主要在「导航流程有几个阶段」。

路由表、懒加载与全局守卫

createRouter + createWebHistory(无 # 的 history 模式)定义路由表。component: () => import(...) 实现懒加载(路由级代码分割)。动态段 :id、嵌套 childrenmeta 元信息、/:pathMatch(.*)* 兜底 404。全局守卫 beforeEach 做鉴权。

import { createRouter, createWebHistory } from 'vue-router';

const routes = [
  { path: '/', name: 'home', component: () => import('@/views/Home.vue') },
  { path: '/users/:id', component: () => import('@/views/User.vue') },
  { path: '/dash', meta: { requiresAuth: true },
    children: [ { path: 'settings', component: Settings } ] },
  { path: '/:pathMatch(.*)*', component: NotFound }, // 404
];

const router = createRouter({ history: createWebHistory(), routes });

router.beforeEach((to) => {
  if (to.meta.requiresAuth && !isLoggedIn())
    return { name: 'login', query: { redirect: to.fullPath } };
});
beforeEach 里无条件把未登录用户重定向到登录页,会造成无限重定向:导航到 /login 时守卫再次执行、依然判定未登录、再跳 /login……vue-router 5.2.0 导航 Promise 直接 reject Error: Infinite redirect in navigation guard,并警告 [VUE_ROUTER_R0009] Detected a possibly infinite redirection in a navigation guard when going from "/" to "/login". Aborting to avoid a Stack Overflow. This might break in production if not fixed.。必须先排除目标路由本身if (to.name !== 'login' && ...),或只对 to.meta.requiresAuth 生效)。另外:旧的 next() 回调在 vue-router 5 已正式弃用——守卫里一调用就警告 [VUE_ROUTER_R0025] The `next()` callback in navigation guards is deprecated.,一律改用返回值风格(返回 false 取消、返回路由对象重定向、无返回放行);网上大量教程还在教 next,照抄就是一控制台警告。
给每条路由起 name,导航一律用 router.push({ name: 'user', params: { id } })。这样以后改 URL 结构(/users/:id/u/:id)只需动路由表一处,不必全项目搜字符串;配合 meta 承载 requiresAuthtitlelayout 这类信息,守卫里读 to.meta 就能统一处理,不用为每个页面写 if。

useRoute() 拿当前路由信息,useRouter() 拿路由器实例——一个回答「我在哪」,一个负责「带我去」。

两个对象各管什么

  • route:只读的当前状态——params(路径动态段)、query(? 后的参数)、fullPathmeta;它是响应式的,watch(() => route.params.id, fetchUser, { immediate: true }) 是「同组件换参数」的标准解法(原因见 pitfall);
  • router:动作集合——push(压入新历史记录)、replace(替换当前,登录跳转后防回退用它)、back/forward;push 优先用对象形式 name + params,改 URL 结构时不必全局搜字符串。

模板侧

  • <RouterLink to> 声明式跳转(渲染成 a 标签,自动加 active 类,可做导航高亮);<RouterView /> 是当前路由组件的渲染出口;
  • 嵌套路由的 children 渲染在父路由组件的 RouterView 里——出口套出口,一层管一层,布局天然随路由分层。
<script setup>
import { useRoute, useRouter } from 'vue-router';
import { watch } from 'vue';

const route = useRoute(), router = useRouter();
console.log(route.params.id, route.query.page);

// 同组件换 id 时重新取数据
watch(() => route.params.id, (id) => fetchUser(id));

router.push({ name: 'user', params: { id: 123 } });
</script>

<template>
  <RouterLink to="/about">关于</RouterLink>
  <RouterView />
</template>
/users/1 跳到 /users/2 时,组件实例会被复用而不是重建:两次跳转后 onMounted 总共只执行过 1 次,页面上还挂着上一个用户的数据,看起来像「点了没反应」。修法有两条——要么 watch(() => route.params.id, fetchUser, { immediate: true }) 主动重取,要么给 <RouterView :key="$route.fullPath" /> 加 key 强制重建(简单但会丢掉复用带来的性能优势)。
route.paramsroute.query 里的值永远是字符串/users/2params.id === 2false,值是 "2";重复的 query 参数会是字符串数组)。所以数字比较、加法全会出拼接类怪账——取出来后立刻 Number(...) 转换,或者在路由上用 props 函数形式转好再传进组件。另外 push 一个路由表没有的路径不报错,只警告 [VUE_ROUTER_R0004] No match found for location with path "/no/such/route" 并渲染空的 RouterView——404 兜底路由必须自己配(见上一卡路由表)。

三个入门教程常漏、真实项目里天天要用的点:路由独享守卫、滚动行为、把参数当 props 传。

三个高频但常漏的点

三个真实项目高频、入门却常漏的点:
路由独享守卫 beforeEnter:写在单条路由上,只在进入该路由时触发,适合「仅这个页面要鉴权/校验」,比在全局 beforeEach 里堆 if 更聚焦。
scrollBehaviorcreateRouter 的选项,控制导航后滚到哪——返回 { top: 0 } 回顶部;返回 savedPosition(仅浏览器前进/后退时有值)可恢复上次滚动位置;返回 { el: to.hash } 滚到锚点。
props: true:让路由把 params 作为 props 传进组件,组件用 defineProps 接收即可,不必useRoute()route.params.id——组件与路由解耦、更易测试复用。

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/users/:id',
      component: User,
      props: true,             // params.id 变成组件的 props
      beforeEnter: (to, from) => {   // 路由独享守卫
        if (!canView(to.params.id)) return false; // 取消导航
      },
    },
  ],
  // 导航后的滚动行为
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) return savedPosition; // 后退时恢复位置
    if (to.hash) return { el: to.hash };  // 滚到锚点
    return { top: 0 };                   // 否则回到顶部
  },
});

// User.vue:props:true 后直接用 props 接收,无需 useRoute()
// <script setup> const props = defineProps(['id'])
beforeEnter 只在「进入这条路由记录」时触发,从 /users/2/users/3 这种只有 params(或 query、hash)变化的导航不会再执行它。于是把「这个用户我有没有权限看」的校验只写在 beforeEnter 里,用户改一下地址栏的 id 就绕过去了,而且毫无报错。需要覆盖参数变化的校验,请放到 beforeEach 或组件内的 beforeRouteUpdate。另外 props: true 只映射 params,想把 query 也变成 prop 必须改用函数形式。
props 还能写成对象props: { id: 1 } 传静态值)或函数props: (route) => ({ q: route.query.q }) 把 query 也映射成 prop),比只映射 params 更灵活。

性能与高级特性

真正的性能手段(v-memo/v-once/shallow、大列表策略)、异步组件与 Suspense、KeepAlive/Teleport、动态渲染的逃生口渲染函数、以及自定义指令/插件。(TypeScript 见 12 章,测试见 13 章。)

异步组件解决的是「首屏别把整个应用打包进来」;<Suspense> 解决的是「等它们加载完之前显示什么」。

按需加载与加载态

defineAsyncComponent(() => import(...)) 按需加载组件,减小首屏体积,可配 loadingComponent/errorComponent/delay/timeout<Suspense> 协调含顶层 await 的异步组件,在其 resolve 前显示 #fallback——注意它至今是实验性 API,行为可能调整,生产使用要有心理预期。

import { defineAsyncComponent } from 'vue';

const Heavy = defineAsyncComponent(() => import('./Heavy.vue'));

const Admin = defineAsyncComponent({
  loader: () => import('./Admin.vue'),
  loadingComponent: Spinner,
  delay: 200,        // 200ms 后才显示 loading(防闪烁)
  timeout: 3000,
});

// ── 模板 ──
<!-- Suspense:异步 setup resolve 前显示 fallback -->
<Suspense>
  <template #default><AsyncProfile /></template>
  <template #fallback>加载中…</template>
</Suspense>
<Suspense> 自身不提供任何错误处理:异步 setup 里的请求一旦 reject,fallback 消失、default 也渲染不出来,页面就是一片空白且控制台之外毫无提示。必须在 <Suspense>父组件里用 onErrorCaptured 接住并渲染兜底 UI。还要记住它至今是实验性 API——3.5.40 一挂载就自己打印 <Suspense> is an experimental feature and its API will likely change.,行为(尤其是嵌套与更新时的 fallback 时机)可能变,核心链路上慎用。
delay 默认 200ms(源码实查 delay = 200;这段时间内加载完就不闪 loading,通常保持默认即可),但 timeout 没有默认值 = 永不超时(源码实查无默认)——意味着弱网或 chunk 404 时页面会永远转圈,用户只能刷新。生产环境的异步组件建议成对配上 timeout(几秒)和 errorComponent,至少让用户看到「加载失败,点击重试」。

两个「打破默认渲染规则」的内置组件:<KeepAlive> 让组件切走时不销毁(状态、滚动位置全保留),<Teleport> 让内容渲染到 DOM 树的另一个位置

KeepAlive:缓存的精确语义

  • 缓存的是组件实例:切走走 onDeactivated(不走 onUnmounted),切回走 onActivated(不再走 setup/onMounted)——生命周期换了一套,「每次进来都要刷新」的逻辑要跟着搬家(见 tip);
  • :include/:exclude 按组件 name 匹配(坑见 pitfall),:max 是 LRU 上限;
  • 典型场景:列表页 ↔ 详情页往返保住滚动位置和筛选条件、多 Tab 表单互切不丢输入。

Teleport:DOM 位置与组件逻辑解耦

  • to="body"(CSS 选择器)把内容挂到目标元素下,但组件关系不变:props、事件、provide/inject 照常工作,只有 DOM 换了地方;
  • 解决的是纯 CSS 困境:弹窗/Toast 逻辑上属于深层组件,DOM 上却必须逃出父级的 overflow:hiddentransform(它会劫持 fixed 定位)和 z-index 层叠上下文;
  • 3.5 新增 defer:目标元素可以在模板后面才出现(见本章 3.5 新特性卡)。
<template>
  <!-- KeepAlive:返回列表时保留状态 -->
  <RouterView v-slot="{ Component }">
    <KeepAlive :include="['UserList']">
      <component :is="Component" />
    </KeepAlive>
  </RouterView>

  <!-- Teleport:弹窗渲染到 body 下 -->
  <Teleport to="body">
    <div v-if="showModal" class="modal"></div>
  </Teleport>
</template>
:include 按组件 name 匹配。<script setup> 组件的 name 默认取自文件名(UserList.vue → 'UserList');名字对不上时缓存静默失效且无任何警告——include 匹配时来回切换 created 只执行 1 次(缓存命中)、onActivated 每次进入都触发;include 写错名字后同样的切换 created 执行了 2 次,控制台干干净净。这是 KeepAlive 最常见的出错原因。不放心就用 defineOptions({ name: 'UserList' }) 显式指定。
被缓存的组件再次进入时 onMounted 不会重新执行(它只在首次挂载跑一次),所以「每次回到这个页面都要刷新一次数据」的逻辑必须写进 onActivated,清理写进 onDeactivated。另外别无限制地缓存,加 :max="10"KeepAlive 是 LRU 缓存,超出后自动销毁最久未访问的那个,避免缓存一堆重页面把内存吃满。

Vue 的编译期靶向更新已经很快,真正需要手动优化的只剩大列表与大对象两类场景

三个手动优化手段

响应式默认已经很快(编译期靶向更新),真正需要手动优化的场景集中在大列表大对象
v-once:元素/子树只渲染一次、之后永不更新,适合纯静态内容。
v-memo="[dep]":依赖数组不变就跳过该子树的更新与 diff,常配 v-for 用在长列表——只有真正变化的行才重渲染(如给「选中项」高亮的万行表格)。
shallowRef/shallowReactive:只对第一层做响应式,深层改动不追踪。存「很大但整体替换」的数据(图表配置、后端拉的大 JSON、第三方实例)用它,省掉深度代理开销。
④ 真正的大列表(成千上万行)别全渲染——用虚拟滚动(只渲染可视区,如 vue-virtual-scroller)。
此外老规矩:展示派生值用 computed(缓存)而非模板里调方法;v-show vs v-if 按切换频率选(见 07 章)。
两组数字(3.5.40):① v-memo 的收益是可数的——10000 行列表改一次选中项,带 v-memo="[i === sel]" 只有 1~2 行(新旧选中项)重新 patch,其余全部跳过;去掉 v-memo,同一操作 10000 行全部走了一遍更新(用指令 updated 钩子逐行计数,确定性结果,谁跑都一样)。② 体积的账:vue.runtime.global.prod.js 104.4 KB(gzip 39.5 KB),含模板编译器的 vue.global.prod.js 161.7 KB(gzip 59.1 KB)——用 Vite 构建时模板在构建期编译、浏览器只付前者的账,这约 20 KB gzip 差距也是「生产别用 CDN 完整版」的理由。

<template>
  <!-- v-once:只渲染一次,之后永不更新 -->
  <footer v-once>© {{ year }} 静态页脚</footer>

  <!-- v-memo:selected 未变的行跳过更新(长列表提速) -->
  <li
    v-for="item in list"
    :key="item.id"
    v-memo="[item.id === selected]">
    {{ item.name }}
    <span v-if="item.id === selected"></span>
  </li>
</template>

<script setup>
import { shallowRef, shallowReactive } from 'vue';

// 大对象/第三方实例:只在整体替换时更新,不深追内部
const chartData = shallowRef({ /* 几万个点 */ });
chartData.value = fetchNext();  // ✅ 触发;chartData.value.x=1 不触发

const state = shallowReactive({ list: [], page: 1 }); // 只第一层响应
</script>
v-memo 是「手动挡」、易写错——依赖数组漏列某个用到的值,会导致该更新时不更新(显示旧数据)。只在确有性能问题的大列表上用,别过早优化;小列表用它反而是负担。同理 shallowRef 深层改了不刷新是预期行为,必须整体替换 .value 才生效。
先用 Vue DevTools 的组件渲染高亮 / 时间线,或浏览器 Performance 面板定位真正的热点,再决定优化点——凭感觉乱加 v-memo 往往无效甚至有害。

模板覆盖 99% 的场景;剩下 1%(按配置生成标签、递归树、把渲染方式当参数传)用渲染函数更直接。

什么时候放弃模板

模板覆盖 99% 场景,但遇到高度动态的结构(按配置生成不同标签、递归树、把「怎么渲染」当参数传递)时,模板会很别扭——这时用渲染函数直接返回虚拟 DOM。h(type, props, children) 是创建 vnode 的函数:type 是标签名或组件,props 是属性/事件对象(事件写成 onClick 这样的 onXxx),children 是字符串或 vnode 数组。在 <script setup> 里让 setup 返回一个渲染函数即可(或用普通 setup() 选项 return 它)。装上 @vitejs/plugin-vue-jsx 后还能用 JSX/TSX 写,更接近模板的直观。

import { h, ref } from 'vue';

const count = ref(0);

// 按 level 动态生成 h1~h6(模板里要写一长串 v-if)
function Heading(props) {
  return h(
    'h' + props.level,          // type:标签名或组件
    { class: 'title' },         // props:属性 / 事件
    props.text,                   // children
  );
}

// 事件写成 onXxx;子节点用数组
h('button', { onClick: () => count.value++ }, [
  '点击 ', h('b', count.value),
]);

// —— JSX(需 @vitejs/plugin-vue-jsx)——
// const App = () => <button onClick={inc}>{count.value}</button>
进了渲染函数就没有模板的自动解包了,而且坑比想象的深:h('span', count) 里的 ref 落在第二参数位置,被当成 props 对象——span 上凭空多出一堆属性、内容为空;h('span', null, count) 把 ref 放 children 位置也渲染成。两种写法都不报错、只是显示不出来,必须写 count.value。第二个坑是给组件传插槽要传函数h(MyComp, null, { default: () => [...] }),写成数组警告 Non-function value encountered for default slot. Prefer function slots for better performance.
别为炫技放弃模板——模板能被编译器做靶向更新、静态提升等优化,可读性也更好。只有模板确实笨拙(渲染逻辑动态到无法声明式表达)时才下沉到 h() / JSX。

指令封装的是对 DOM 的可复用操作,插件封装的是对应用实例的一次性配置——两者常被混用。

各自封装什么

自定义指令封装可复用 DOM 操作,用生命周期钩子 mounted/unmounted 等,<script setup> 里以 vXxx 命名即可用。插件是带 install(app, options) 的对象,在 install 里注册全局方法/组件/指令、provide 全局数据,app.use(plugin, opts) 安装。

// 自定义指令:点击外部关闭
const vClickOutside = {
  mounted(el, binding) {
    el._h = (e) => { if (!el.contains(e.target)) binding.value(e); };
    document.addEventListener('click', el._h);
  },
  unmounted(el) { document.removeEventListener('click', el._h); },
};
// 用:<div v-click-outside="close">

// 插件:一个简单 i18n
export const i18n = {
  install(app, options) {
    app.config.globalProperties.$t = (k) => options.messages[k] || k;
    app.provide('i18n', options);
  },
};
// main.js:app.use(i18n, { messages: {...} })
把自定义指令用在组件上(<MyComponent v-focus />)行为很容易出乎意料:它只会作用到该组件的根节点,而如果组件是多根节点,指令会被直接忽略并抛警告(报错原文:Runtime directive used on component with non-element root node. The directives will not function as intended.);更麻烦的是指令无法像 v-bind="$attrs" 那样转发到组件内部你想要的那个元素上。想在封装组件内部用指令,就在组件自己的模板里写。
指令只做纯 DOM 层的事(聚焦、拖拽、懒加载、点击外部关闭),一旦逻辑里出现状态、请求、跨组件共享,就该换成组合式函数。另外像示例那样把 handler 挂到 elel._h = ...)是必要技巧——unmounted 里必须拿到同一个函数引用才能成功 removeEventListener,重新写一遍匿名函数是移除不掉的。

3.5 是近年最实用的一个小版本:没有破坏性变更,但把几处「一直要绕着写」的地方补齐了。下面按实用度排序,均基于 3.5.40。

props 响应式解构(最大的一个)

  • 3.5 起 const { msg = 'hi' } = defineProps(['msg']) 解构不再丢响应性,默认值也直接写在解构里——编译产物:函数体里的 msg 被编译成 __props.msg,每次访问都走 props 对象,这就是响应性保住的原理;
  • 推论也来自这个原理:msg 只是「看起来像变量」,把它直接传给 watch 传的是当下的值——编译器会当场拦下(报错原文:[@vue/compiler-sfc] "msg" is a destructured prop and should not be passed directly to watch(). Pass a getter () => msg instead.)。

其余四件

  • useTemplateRef('name'):按名字取模板引用,比「变量名匹配」明确(06 章模板引用卡已用);
  • useId():生成组件树内稳定唯一的 id(形如 v-0),SSR 两端一致,专治表单 label for/aria 关联在服务端渲染下的 id 不匹配;
  • watch 句柄:返回值上有 pause()/resume()/stop()(04 章 tip 已细讲);onWatcherCleanup() 同版本加入;
  • <Teleport defer>:延迟到本轮渲染结束再找目标,允许把内容传送到模板里排在后面的元素(老版本必须目标先存在);
  • 异步组件懒水合defineAsyncComponent 支持 hydrateOnVisible()/hydrateOnIdle() 等策略,SSR 页面上折叠区域的组件可以等滚到可视区再水合。
// props 响应式解构(3.5+):默认值直接写解构里
<script setup>
const { msg = 'hi', count = 0 } = defineProps(['msg', 'count']);

// ✅ 函数里用 msg:编译成 __props.msg,响应性保留
const upper = computed(() => msg.toUpperCase());

// ❌ watch(msg, ...) 编译报错;要包 getter:
watch(() => msg, (v) => {});

// useId:SSR 安全的稳定 id
const id = useId();   // 形如 "v-0"
</script>

<template>
  <label :for="id">邮箱</label><input :id="id">
</template>
props 解构的响应性只在本组件的编译单元内成立:编译器改写的是「本文件里对 msg 的访问」,把解构出的值再传给别的函数、存进对象,传出去的就是普通值——和 03 章「解构丢响应」是同一根源,只是编译器把最常见的一层帮你兜住了。跨函数传递仍要传 getter 或整个 props。另外这套改写只认 defineProps 的解构,对 reactive 的解构没有任何魔法。
升级备忘:这些 API 全部向后兼容,3.4 的写法(props.xxx、变量名匹配 ref、手写 id)在 3.5 照常工作——新项目直接用新写法,老项目不必迁移。搜资料时注意区分版本:3.5 之前的文章会言之凿凿地说「props 解构会丢响应性」,在今天的默认配置下已经不成立。

Vue 3 的 API 分四类:写在模板里的指令、开箱即用的内置组件<script setup> 里的组合式函数,以及生命周期钩子。下面把每个各配一行最小示例;各条目的展开讲解散落全书,见下方 tip 指引。

// —— 模板指令(写在 template 内)——
<p v-if="ok">A</p><p v-else-if="y">B</p><p v-else>C</p>  // 条件渲染,惰性、切换即销毁
<li v-for="x in xs" :key="x.id">{{ x.name }}</li>  // 列表渲染,:key 用稳定唯一值
<img :src="url" :class="{ on: active }">  // v-bind 缩写 :,动态绑定属性/prop
<button @click="inc">+1</button>  // v-on 缩写 @,监听事件
<input v-model="text">  // 双向绑定(v-bind + v-on 语法糖)
<div v-show="open">…</div>  // 切换 display,始终渲染,频繁切换用它
<template #item="{ row }">…</template>  // v-slot 缩写 #,具名/作用域插槽
<div v-html="raw"></div>  // 输出原始 HTML,慎防 XSS
<div v-once></div><div v-memo="[id]">…</div>  // 一次性渲染 / 依赖不变则跳过更新

// —— 内置组件 ——
<Transition>…</Transition> <TransitionGroup>…</TransitionGroup>  // 单元素 / 列表进出场过渡
<KeepAlive><component :is="view" /></KeepAlive>  // 缓存组件实例,保留状态
<Teleport to="body">…</Teleport>  // 把内容渲染到 DOM 别处(如 body)
<Suspense>…</Suspense>  // 协调异步依赖,resolve 前显示 fallback(实验性)
<component :is="tab" />  // 动态组件,按值切换渲染目标

// —— 组合式 API(<script setup> 内)——
const n = ref(0); const s = reactive({ a: 1 });  // 响应式核心:基本值 / 对象
const c = computed(() => n.value * 2);  // 派生值,按依赖自动缓存
watch(n, (val, old) => {});  // 侦听指定源,回调拿到新旧值
watchEffect(() => console.log(n.value));  // 自动收集依赖并立即执行
const { a } = toRefs(s); toRef(s, 'a'); unref(a);  // ref 与普通值互转 / 解包
provide('key', n); const x = inject('key');  // 跨层级依赖注入
const p = defineProps(['id']); const emit = defineEmits(['save']); defineModel(); defineExpose({ n });  // 组件契约(编译宏,免 import)
await nextTick();  // 等待下一次 DOM 更新完成
const el = useTemplateRef('box'); const uid = useId(); const w = watch(n, fn); w.pause(); w.resume();  // 3.5+:具名模板引用 / SSR 安全 id / watch 句柄(详见本章 3.5 卡)

// —— 生命周期钩子 ——
onMounted(() => {}); onUpdated(() => {}); onUnmounted(() => {});  // 挂载后 / 更新后 / 卸载后
onBeforeMount(() => {}); onBeforeUnmount(() => {});  // 挂载前 / 卸载前
onErrorCaptured((err) => false);  // 捕获后代组件抛出的错误,返回 false 阻止继续冒泡
v-memo 是这张表里最容易白写的一条:它必须和 v-for 落在同一个元素上,写到 v-for 内部的子元素上完全不起作用(官方明确说 v-memo does not work inside v-for),而且没有任何提示,你只会觉得「优化了但没变快」。另外依赖数组必须长度固定v-memo="[]" 等价于 v-once(永不更新)——不小心写成空数组就是「数据变了界面不动」。这类微优化只在千行级长列表才值得上,日常别用。
各条目的展开讲解散落全书:模板指令详见 02/07 章,ref/reactive 详见 03 章、computed/watch 详见 04 章,生命周期钩子详见 05 章,provide/inject 等跨层级注入详见 08 章、其余组件通信详见 06 章,<Transition>/KeepAlive/Teleport/Suspense 详见本章前面几张卡。

TypeScript + Vue

推断优先、类型写在组件契约上;模板也在检查范围内(vue-tsc / Volar),事件与插槽同样可以标类型,通用组件用 generic 泛型化。

Vue 3 本身就是 TS 写的,<script setup lang="ts"> 即启用——多数代码不用写类型,推断自动到位;要写的集中在组件契约上。

类型从哪来

  • ref(0) 推断为 Ref<number>,computed 跟随返回值——日常零标注;要显式时最常见的是 ref<User | null>(null)(初始还没有值的联合类型);
  • 模板也在检查范围内:构建期靠 vue-tsc、编辑器里靠 Volar(VS Code 装官方 Vue 扩展即得)——模板里传错 prop 类型、拼错事件名都会标红,这是「SFC 比 JSX 弱」的刻板印象里最先过时的一条;
  • 组件契约三件套全支持泛型:defineProps<Props>()(默认值用 3.5 解构或 withDefaults)、defineEmits<{ submit: [data: User] }>()defineModel<string>()
<script setup lang="ts">
import { ref, computed } from 'vue';

const count = ref(0);            // Ref<number> 推断
const user = ref<User | null>(null); // 联合类型

interface Props { title: string; count?: number; }
const props = withDefaults(defineProps<Props>(), {
  count: 0,                  // 泛型 props 的默认值
});

const emit = defineEmits<{
  submit: [data: User];     // 事件名:[参数类型]
  cancel: [];
}>();
</script>
defineProps / defineEmits编译宏,参数会被提升到模块作用域,因此不能引用 <script setup> 里声明的局部变量——写 const T = String; defineProps({ foo: T }) 直接编译报错(报错原文:`defineProps()` in <script setup> cannot reference locally declared variables because it will be hoisted outside of the setup() function. ...;引用 import 进来的类型/常量则没问题)。同理它们必须在顶层调用一次,塞进 if 分支或函数体里都不成立。看到「编译期就红」的报错先往这个方向想,它不是类型错误。
组件与组合式函数的测试入口:Vitest(Vite 同门的测试运行器)+ Vue Test Utils(官方组件挂载库),与本章的 TS / Vite 工具链无缝衔接——最小可照抄示例见 13 章,进阶学习路径见 16 章路线图。

「SFC 的模板是个字符串,类型检查管不到」——这是最常被重复的一条过时印象。实际上模板里传错 prop、拼错插槽参数都会被拦下,只是这件事不由 tsc,得走 vue-tsc(命令行)或 Volar(编辑器)。

四种写错,vue-tsc 的实际反应

  • prop 类型不对<Child :title="123" /> 而 title 声明为 string → error TS2322: Type 'number' is not assignable to type 'string'.
  • 漏了必填 properror TS2345: … Property 'title' is missing in type '{ tags: string[]; }' but required in type …,报错里会把这个组件完整的 props 类型(含自动生成的 onChange / onClose)整个列出来。
  • 插槽参数名拼错<template #default="{ rowX }"> 而实际提供的是 rowerror TS2339: Property 'rowX' does not exist on type '{ row: string; }'.
  • 事件处理器少写参数@change="(id: number) => …" 而事件签名是 [id: number, name: string]不报错。这是 TypeScript 的既有规则(回调可以少收参数),不是 Vue 漏检。

分工:谁负责哪一步

  • Volar(VS Code 装官方 Vue 扩展即得)负责编辑器里的红波浪线与跳转,改一行立刻反馈。
  • vue-tsc --noEmit 负责 CI / 构建期的全量把关。tsc 单独跑看不懂 .vue 文件,所以两者不能互相替代——项目脚本里通常写成 "type-check": "vue-tsc --noEmit"
  • Vite 自己只擦类型不检查(和 esbuild 一个路子),所以 vite build 通过不代表类型没问题——「能跑」不等于「类型对」,这一点和 Node 直接跑 .ts 是同一回事。

版本坑:vue-tsc 与 TypeScript 7

  • vue-tsc 3.3.8typescript 7.0.2 直接崩Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './lib/tsc' is not defined by "exports"——TS 7 改了包的导出结构,vue-tsc 还在按老路径找编译器入口。
  • 退回 typescript 5.9.3 后一切正常。升 TS 大版本前先确认 vue-tsc 跟上了,这类工具链断层通常要滞后一两个月。
// package.json
"scripts": {
  "type-check": "vue-tsc --noEmit",   // tsc 看不懂 .vue
  "build": "vue-tsc --noEmit && vite build"
}

// Child.vue —— 契约声明在这里
interface Props { title: string; count?: number; tags: string[] }
defineProps<Props>()

// Parent.vue —— 这四行里前三行会被 vue-tsc 拦下
<Child :title="123" :tags="['a']" />      // TS2322
<Child :tags="['a']" />                    // TS2345 缺 title
<Child ...><template #default="{ rowX }">   // TS2339
<Child ... @change="(id: number) => id"/>  // 不报错(TS 允许少收参数)
模板里的类型错误不会阻断 vite devvite build——页面照常跑起来,错的值一路飘到运行时。见过最典型的是把 :count="'3'" 传成字符串,组件里 count + 1 得到 "31",界面上多一位数字没人当回事。类型检查是独立的一步,必须自己去跑。
vue-tsc --noEmit 挂进 CI,别指望 vite build 帮你把关——它和 esbuild 一样只做类型擦除。本地开发靠 Volar 的即时反馈就够,提交前跑一次全量即可;这套「编辑器快反馈 + CI 全量」的分工和 React/Svelte 那两页是一样的。

props 有类型是常识,事件和插槽也能有——这是 <script setup lang="ts"> 相比 Options API 最实在的一处增益:组件的三面契约(进什么、抛什么、留什么口子)全部可检查。

defineEmits:元组语法最省事

  • 推荐写法是「事件名: [参数类型...]」的对象元组形式:defineEmits<{ change: [id: number, name: string]; close: [] }>()。无参数的事件写空数组 []
  • 老一些的写法是调用签名式 defineEmits<{ (e: 'change', id: number): void }>(),等价但啰嗦,新代码用元组式。
  • 好处是父组件写 @change 时参数类型会被推断出来,事件名拼错也直接标红。

defineSlots:给插槽的作用域参数标类型

  • defineSlots<{ default(props: { row: string }): any; header?(): any }>()——每个键是插槽名,函数参数就是这个插槽向外提供的作用域 props,加 ? 表示可选插槽。
  • 只声明类型不产生运行时代码,模板里照旧写 <slot :row="..." />。声明之后,父组件里 #default="{ 拼错的名字 }" 就会被 vue-tsc 拦下(上一卡有报错)。
  • 不写 defineSlots 时插槽参数是 any——不报错,但也一点保护都没有。对外发布的组件值得补上。

三个宏的共同限制

  • defineProps / defineEmits / defineSlots 都是编译宏,不是函数:不用 import、必须在 <script setup> 顶层直接调用,塞进 if 或函数体里都不成立。
  • 它们的参数会被提升到模块作用域,所以不能引用 <script setup> 里声明的局部变量(引用 import 进来的类型或常量则没问题)。这条在本页 TS 那张卡的陷阱里有报错原文。
  • 泛型参数里只能用能被静态分析的类型:接口、类型别名、导入的类型都行;即时计算出来的复杂条件类型可能让编译器放弃推断。
<script setup lang="ts">
// 事件:元组语法(推荐)
const emit = defineEmits<{
  change: [id: number, name: string]
  close: []                       // 无参数事件
}>()

// 插槽:键是插槽名,参数是作用域 props
defineSlots<{
  default(props: { row: string }): any
  header?(): any            // ? = 可选插槽
}>()
</script>

<template>
  <button @click="emit('change', 1, 'x')">ok</button>
  <slot :row="tags[0]" />
</template>
别把 defineEmits类型声明运行时声明混用——defineEmits<T>(['change']) 这种两边都给的写法直接编译报错。同一个宏只能选一种:要类型检查就用泛型参数,要运行时校验(validator)就用参数对象。defineProps 同理,这也是从 Options API 迁过来时最常见的一处混写。
判断要不要写 defineSlots组件是给别人用的就写,页面里一次性的容器组件不必。它的收益全在「使用方」那一侧——插槽参数有补全、写错名字当场红,而作者这边多写四五行。同理 defineEmits 的类型版本比运行时数组版本值钱得多,因为事件名是最容易拼错又最不容易被发现的东西。

写一个「传什么类型的列表进来,插槽里就拿到什么类型的项」的通用组件,靠普通类型标注做不到——需要组件本身带类型参数。Vue 3.3 起 <script setup> 支持 generic 属性,写法和函数泛型一样。

怎么写

  • <script setup lang="ts" generic="T extends { id: number }">——generic 里的内容会被原样搬进生成的泛型签名,可以有多个参数、可以带 extends 约束和默认值。
  • 之后 T 在这个 <script setup> 里随便用:defineProps<{ items: T[] }>()defineSlots<{ row(props: { item: T }): any }>()
  • 必须写 lang="ts",否则 generic 属性不生效(也不会报错,只是静默失去类型)。

T 一路推断到插槽里

  • 父组件传 :items="users"users{ id: number; name: string }[]),T 被推断成 { id: number; name: string }
  • 插槽里访问不存在的字段 → error TS2339: Property 'nope' does not exist on type '{ id: number; name: string; }'. ——类型确实穿过了插槽边界,这是泛型组件最主要的价值。
  • 约束也真的生效:传 :items="[{ name: 'x' }]"(缺 id)→ error TS2353: Object literal may only specify known properties, and 'name' does not exist in type '{ id: number; }'.

什么时候该收手

  • 值得上泛型的只有一类组件:它对数据的具体形状不关心,只负责编排——列表、表格、下拉选择、虚拟滚动容器。
  • 反过来,业务组件里那些「用户卡片」「订单行」本来就绑定了具体类型,硬套泛型只会让签名难读。先写具体类型,等真的出现第二种数据形状再泛型化,成本很低。
<!-- List.vue -->
<script setup lang="ts" generic="T extends { id: number }">
defineProps<{ items: T[] }>()
defineSlots<{ row(props: { item: T }): any }>()
</script>

<template>
  <div v-for="it in items" :key="it.id">
    <slot name="row" :item="it" />
  </div>
</template>

<!-- 用它:T 自动推断成 { id: number; name: string } -->
<List :items="users">
  <template #row="{ item }">{{ item.name }}</template>
</List>

<!-- item.nope → TS2339;缺 id 的元素 → TS2353 -->
generic 属性里不能引用同文件 <script setup> 中定义的类型——它在编译时被提到组件签名上,那时候文件内的声明还不可见。要用自定义类型就从别的文件 import type 进来。另外泛型组件在递归引用自己时容易把类型推断卡死(编辑器转圈、vue-tsc 变慢),树形组件尤其要留意,必要时给递归那一层显式标注类型。
generic 是 Vue 特有的语法糖,Svelte 有对应的 generics 属性、React 直接写函数泛型——三家都是同一个需求的不同表达。跨框架迁移这类通用组件时,泛型签名往往是唯一需要手工翻译的部分,其余逻辑基本能照搬。

测试与调试

Vitest + Vue Test Utils 搭起来、断言前为什么必须 await、组合式函数按依赖分三档测法,以及「测行为不测实现」的取舍。

Vue 的官方测试栈只有两样东西,而最容易出错的不是 API,是忘了 await——视图更新是异步的。

挂载、交互与断言

Vue 的官方测试栈:Vitest(与 Vite 同门的测试运行器,零额外配置)+ Vue Test Utils(官方组件挂载库,简称 VTU)。mount(Comp, { props }) 把组件挂到内存里,返回 wrapper:用 find() 选元素、trigger() 触发事件、text() 断言文本、emitted() 断言抛出的事件。改动数据后视图更新是异步的,断言前要 awaittrigger() 返回的 Promise 已内含 nextTick)。而组合式函数是纯函数,多数可直接调用、断言返回的 ref,无需挂载组件——这正是 Composition API 好测的原因。

// Counter.test.js
import { mount } from '@vue/test-utils';
import { describe, it, expect } from 'vitest';
import Counter from './Counter.vue';

describe('Counter', () => {
  it('点击后计数 +1', async () => {
    const wrapper = mount(Counter, { props: { start: 0 } });
    expect(wrapper.text()).toContain('0');

    await wrapper.find('button').trigger('click'); // 已含 nextTick
    expect(wrapper.text()).toContain('1');
    expect(wrapper.emitted()).toHaveProperty('change'); // 断言抛了事件
  });
});

// 组合式函数:无需挂载组件,直接调用断言
it('useCounter', () => {
  const { count, inc } = useCounter();
  inc();
  expect(count.value).toBe(1);
});
最常见的假失败是断言前忘了 await:DOM 更新是异步批处理的,wrapper.find('button').trigger('click') 之后紧跟 expect(wrapper.text()).toContain('1'),读到的还是点击前的旧文本,测试莫名其妙红了、代码其实没问题。trigger()setProps() 返回的 Promise 内部已经 awaitnextTick,直接 await 它们即可;测试函数记得写成 async
组合式函数若用到生命周期钩子或 inject(如内部 onMounted),脱离组件直接调会失效——这类要包一个「宿主组件」再 mount 来测。Nuxt 项目改用 @nuxt/test-utils,以获得自动导入与运行时环境。

Vue 的 DOM 更新是异步批处理的:改完数据不会立刻反映到 DOM 上,而是攒到下一个微任务再统一刷。测试里忘掉这一步,就会得到「代码是对的、测试是红的」这种最耗时间的假失败。

同一次点击,三种写法三个结果

  • 组件初始显示 0,点一下按钮该变成 1
  • 不 awaitw.get('button').trigger('click') 之后直接读 → 拿到 "0"。旧值。
  • 补一句 await nextTick() 再读 → "1"
  • await w.get('button').trigger('click') 再读 → "1",同一行里连派生的 computed 也已是 "2"

哪些方法返回的 Promise 值得 await

  • trigger()setProps()setValue() 这些 Vue Test Utils 的改动型方法返回的 Promise 内部已经 awaitnextTick——直接 await 它们最省事,不用再手写 nextTick
  • 不经过 VTU 直接改 ref(vm.count++、store 里的 action)时没有这层封装,得自己 await nextTick()
  • 涉及真实异步(fetchsetTimeout)时 nextTick 不够——那是「等 Vue 刷新」,不是「等你的 Promise 完成」。要用 await flushPromises()(VTU 提供)或 vi.advanceTimersByTime

把它变成习惯

  • 测试函数一律写成 async,改动型调用一律加 await——多写的这几个字符不会有任何副作用,而漏掉时的排查成本极高(报错指向断言,真凶在上一行)。
  • 可以在 ESLint 里开 require-await / no-floating-promises 类规则兜底,让漏掉的 await 在编辑器里就亮起来。
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'

// ✗ 读到旧值 "0"
const w = mount(Counter)
w.get('button').trigger('click')
expect(w.get('.n').text()).toBe('1')   // 失败,实际 "0"

// ✓ 补一句 nextTick
w.get('button').trigger('click')
await nextTick()
expect(w.get('.n').text()).toBe('1')   // 通过

// ✓ 最省事:直接 await trigger(内含 nextTick)
await w.get('button').trigger('click')
expect(w.get('.n').text()).toBe('1')
expect(w.get('.d').text()).toBe('2')   // computed 也已更新

// 真实异步:nextTick 不够
import { flushPromises } from '@vue/test-utils'
await flushPromises()
这个坑在测试通过时同样存在——如果你的断言恰好在一个已经 await 过的语句之后,漏掉的那个 await 不会暴露,直到某天有人在中间插了一行代码,测试才突然开始红。所以别靠「测试是绿的」来证明 await 写全了,靠习惯:每个改动型调用都写 await
记一条判据:「等 Vue 重新渲染」用 nextTick,「等我的 Promise 完成」用 flushPromises。两者解决的是不同的等待,混用时症状一样(读到旧值),但换错工具不会好。测异步组件、测带 awaitonMounted 时通常两个都要。

组合式函数好测,是 Composition API 相对 Options API 最实在的一个卖点——但「好测」的程度取决于它依赖了什么。按依赖分三档,成本递增。

第一档:纯的,直接调用

  • 只用 ref / computed / 普通函数、不碰生命周期和 inject 的组合式函数,不需要挂载任何组件,当普通函数调就行。
  • useCounter() 调两次 inc() 之后 n.value2double.value4——computed 在组件之外照常工作。
  • 大部分组合式函数都该属于这一档。如果你发现某个组合式函数非挂组件不可,那通常是个设计信号:它把「取数据」和「跟生命周期打交道」混在了一起,可以拆。

第二档:需要可回收的作用域,用 effectScope

  • 组合式函数内部起了 watch / watchEffect 时,脱离组件调用它们不会被自动停掉,测试之间会互相污染。用 effectScope() 圈起来,测完 scope.stop() 一次性回收。
  • 一个细节:scope.stop() 之后再调 inc()n.value 照常变成 2(ref 本身没被销毁),但 double.value 停在 4 不再更新——被回收的是 computed 与 watch,不是 ref。这条区分在排查「stop 之后为什么还有值」时很有用。

第三档:用到生命周期或 inject,套一个宿主组件

  • 内部有 onMounted / onUnmounted / inject 的,脱离组件直接调会失效(钩子拿不到实例,inject 拿不到 provide)。
  • 做法是写一个只有 setup 的最小宿主组件,在里面调用被测函数并把结果暴露出来,再 mount 它。需要 provide 时用 mount(Host, { global: { provide: { key: value } } })
  • Nuxt 项目里还要考虑自动导入与运行时环境,改用 @nuxt/test-utils 会省很多事(14 章)。
// 一档:纯的,直接调(n=2, double=4)
const { n, double, inc } = useCounter()
inc(); inc()
expect(double.value).toBe(4)

// 二档:有 watch,用 effectScope 圈起来
import { effectScope } from 'vue'
const scope = effectScope()
let r
scope.run(() => { r = useCounter() })
// … 断言 …
scope.stop()          // 回收 computed 与 watch
// 之后 r.inc() 仍改得动 ref,但 computed 不再跟着变

// 三档:用到生命周期 / inject,套宿主组件
const Host = { setup: () => useThing(), template: '<div/>' }
const w = mount(Host, {
  global: { provide: { theme: 'dark' } },
})
第一档最容易踩的是共享状态没隔离:如果组合式函数把 ref 定义在模块顶层(而不是函数体里),那它就是个单例——多个测试用例共用同一份状态,前一个测试改过的值会漏进后一个,表现为「单跑通过、一起跑就红」。这类顺序依赖的失败特别难查,写的时候就确认 ref 在函数体内部。
写组合式函数时就为可测性做一个小决定:把「算什么」和「什么时候算」分开。前者是纯逻辑,直接调就能测;后者(onMounted 里发请求、onUnmounted 里退订)尽量薄,薄到不值得单独测。这样绝大部分测试都能停在第一档,跑得也快。

测试写多了会变成负担——每次重构都要改一遍测试,久了就没人维护。避免这件事的关键不在工具,在断言的对象:断言用户看得见的行为,别断言组件内部长什么样。

三条选择器优先级

  • 最好:按用户能感知的东西找——文字、role、label。wrapper.get('button')、按文本查找,或者引入 @testing-library/vuegetByRole('button', { name: '提交' })。这类查询在重构 DOM 结构时不会碎。
  • 可接受data-testid。丑,但它是显式契约——看到它就知道有测试依赖这里,删之前会想一下。
  • 尽量别:CSS 类名、组件内部的 vm.xxxwrapper.findComponent(Child).vm。类名是给样式用的,改一次样式碎一片测试;断言内部状态则等于把实现细节固化,重构时全得跟着改。

单测与 E2E 各管一段

  • 组件单测(Vitest + VTU):一个组件的输入输出——传 props 渲染对不对、点了抛不抛事件、边界值怎么处理。跑得快,可以写很多。
  • E2E(Playwright / Cypress):跨页面的真实流程——登录、下单、支付回跳。跑得慢,只写主干路径,别拿它覆盖分支。
  • 中间那层「多个组件拼起来对不对」,用挂载父组件的方式测,比拉起整个浏览器便宜得多。

几个具体场景

  • Pinia storesetActivePinia(createPinia()) 之后 store 就是普通对象,直接调 action 断言 state,不用挂组件(09 章)。
  • 路由:需要 <RouterLink> 时用 global.stubs 打桩,或用 createRouter 造一个内存路由;别为了测一个按钮就拉起真实路由。
  • Nuxt:自动导入和运行时环境让普通 Vitest 配置跑不通,改用 @nuxt/test-utils(14 章)。
// ✗ 断言实现:改个类名就碎
expect(w.find('.btn-primary').exists()).toBe(true)
expect(w.vm.internalFlag).toBe(true)

// ✓ 断言行为:用户看得见什么
expect(w.text()).toContain('提交成功')
expect(w.emitted()).toHaveProperty('submit')

// Pinia:不用挂组件
import { setActivePinia, createPinia } from 'pinia'
beforeEach(() => setActivePinia(createPinia()))
it('加入购物车', () => {
  const cart = useCartStore()
  cart.add({ id: 1 })
  expect(cart.count).toBe(1)
})

// 路由:打桩就够
mount(Comp, { global: { stubs: { RouterLink: true } } })
别用 findComponent + vm 去伸手改子组件的内部状态来「制造场景」——这样测出来的是「我把它掰成这样它会怎样」,而不是「用户这么操作它会怎样」。真实路径达不到的状态,本来也不需要测;如果达得到,就按真实路径去触发。这类测试在重构时全军覆没,是测试维护成本失控的头号来源。
起步别追覆盖率数字。先给「改坏了会出事、又不容易手工验」的地方写测试——价格计算、权限判断、表单校验、状态机流转。UI 长什么样交给人眼和截图对比,写成断言性价比很低。等这批核心逻辑稳住了,再考虑往外扩。

Nuxt 全栈框架

Nuxt 是 Vue 的官方元框架(当前主版本 Nuxt 4):约定式文件路由、自动导入、SSR/SSG/混合渲染、内置服务端 API。九卡覆盖:起步、路由、数据获取、服务端 API、表单校验、状态与中间件、鉴权、错误页、渲染与部署。本章按 Nuxt 4 文档编写,未逐条。

Nuxt 最大的特点是自动导入约定式路由:少写 import、少写路由表,代价是「这个函数从哪来的」需要靠约定去记。

目录约定与自动导入

Nuxt 4 用 app/ 目录组织源码,nuxt.config.ts 配置。最大特点是自动导入:Vue API(ref/computed)、组件、composables 都无需 import。路由约定式:app/pages/ 下的文件结构即路由,<NuxtPage> 渲染当前页、<NuxtLink> 导航。

# 创建项目(Nuxt 4,官方推荐)
npm create nuxt@latest my-app

# 目录结构(约定式)
# app/
#   app.vue              根组件
#   pages/index.vue      → /
#   pages/users/[id].vue → /users/:id(动态段用 [])
#   layouts/default.vue  布局
#   components/          自动注册,无需 import
#   composables/         自动导入的 useXxx
# server/api/           服务端接口(Nitro)
# nuxt.config.ts

// pages/index.vue —— 注意:ref 无需 import
<script setup>
const count = ref(0);   // 自动导入
</script>
<template><NuxtLink to="/users/1">去用户页</NuxtLink></template>
自动导入是分上下文的app/ 下能白拿 refuseFetch 和你自己写的 composables,但 server/ 目录跑在完全不同的上下文里,那套自动导入一个都不生效。于是在 server/api/xxx.ts 里顺手用了 app/composables 里的函数,会直接报 xxx is not defined——服务端只自动导入 h3 与 Nitro 的工具(defineEventHandlerreadBodyuseRuntimeConfig 等)。前后端要共用的纯逻辑,放到能被双方显式 import 的共享目录里。
app.vue 是唯一入口,里面放 <NuxtLayout><NuxtPage /></NuxtLayout>。需要每页不同布局时,在页面里 definePageMeta({ layout: 'admin' }) 指定。

文件名就是路由——动态段、捕获全部、页面元信息都由文件名和 definePageMeta 表达。

文件名即路由

文件名即路由:[id].vue 动态段、[...slug].vue 捕获全部。页面内 definePageMeta 设布局/中间件等元信息;useRoute()params/query;编程式导航用 navigateTo()(SSR 安全,替代直接 router.push)。

// app/pages/users/[id].vue
<script setup>
definePageMeta({
  layout: 'admin',
  middleware: ['auth'],   // 见下文中间件
});

const route = useRoute();
const id = route.params.id;

// 编程式导航(SSR 安全)
async function go() {
  await navigateTo(`/users/${id}/edit`);
}
</script>

<template>
  <h1>用户 {{ id }}</h1>
  <NuxtLink to="/">首页</NuxtLink>
</template>
在路由中间件里调用 navigateTo() 必须 return。只写 navigateTo('/login') 而不返回,中间件会继续往下执行,重定向要么不生效、要么和后续导航打架,表现成「有时跳有时不跳」这种最难查的偶发问题。规则很简单:看到 navigateTo 就检查前面有没有 returnawait。另外 definePageMeta 只对 app/pages/ 下的页面组件有效,写进普通组件里静默无效。
站内跳转一律用 <NuxtLink> 而不是 <a>——前者走客户端导航(不整页刷新)并会在链接进入视口时预取目标页的 chunk,体感快很多。外链才用原生 <a>;如果要在代码里跳外部地址,navigateTo 默认会拒绝并抛错,必须显式写 navigateTo(url, { external: true })

SSR 下取数的核心问题是别取两遍:服务端取一次,把结果随 payload 带给客户端。$fetch 裸用就会重复请求。

为什么不能裸用 $fetch

核心:用 useFetch/useAsyncData 而非裸 $fetch 取数据。它们在服务端取一次、把结果随 payload 传给客户端,避免 SSR 水合(hydration:服务端先把 HTML 渲染好发给浏览器,客户端 Vue 再接管这份已有 DOM、给它绑上事件与响应式,让页面「活」过来)时重复请求。返回的 data/status/error 是 ref。Nuxt 4 起相同 key 自动共享、去重。URL 用 getter 可随依赖自动重新请求。它与 08 章手写的 useFetch 同名,但这是 Nuxt 内置版:多了 SSR payload 传递与请求去重。

<script setup>
// useFetch = useAsyncData + $fetch,自动按 URL 生成 key
const { data: user, status, error, refresh } =
  await useFetch('/api/user');

// 响应式 URL:id 变化自动重新请求
const id = ref(1);
const { data: post } = await useFetch(() => `/api/posts/${id.value}`);

// useAsyncData:包裹任意异步逻辑(非纯 HTTP)
const { data } = await useAsyncData('stats', () => computeStats());

// $fetch:仅用于事件处理等"客户端动作"(如提交表单)
async function submit() {
  await $fetch('/api/contact', { method: 'POST', body: form });
}
</script>
别在 setup 里直接 await $fetch('/api/x') 取页面数据——SSR 时会请求两次(服务端 + 客户端水合),引发水合不一致。水合不一致在控制台长这样(用 @vue/server-renderer 最小复现):[Vue warn]: Hydration text content mismatch on <span> 加一句 Hydration completed but contains mismatches.——见到就去查「服务端和客户端各自渲染出了什么」。取数据用 useFetch/useAsyncData$fetch 只用于点击/提交这类客户端动作。
useAsyncData 显式传一个稳定的 key。不传时 Nuxt 按调用位置生成 key,于是两个组件请求同一个 URL 会各发一次请求,而不是共享——列表页和详情页取同一份配置时尤其浪费。反过来,一旦多处用了相同 key,它们就共享同一份 data/status/error,此时 handlertransformdeepdefault 这些选项必须完全一致,否则 Nuxt 会警告;而 lazyserverimmediate 允许各处不同。

Nuxt 自带服务端(Nitro):server/api/ 下的文件就是接口,前后端共用同一套 TypeScript 类型。

文件即接口

Nuxt 内置 Nitro 服务端:server/api/ 下的文件就是接口(users.get.ts 按方法命名)。用 defineEventHandler 写处理函数,getQuery/getRouterParam/readBody 取入参,直接 return 对象自动 JSON 化。前端用 useFetch('/api/...') 调用,端到端同一套 TS 类型

// server/api/users/[id].get.ts
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id');
  const user = await db.findUser(id);
  if (!user) throw createError({ statusCode: 404, message: '不存在' });
  return user;          // 自动序列化为 JSON
});

// server/api/users.post.ts —— 读 body
export default defineEventHandler(async (event) => {
  const body = await readBody(event);
  const query = getQuery(event);
  return await db.create(body);
});

// 前端:const { data } = await useFetch(`/api/users/${id}`)
server/api 返回的数据要经过 JSON 序列化才能到前端,类型信息会悄悄丢失Date 变成字符串、值为 undefined 的字段直接消失、Map/Set/类实例退化成普通对象。于是前端 TS 类型提示明明写着 createdAt: Date,运行时 data.createdAt.getFullYear() 却报「不是函数」。稳妥做法是在服务端就把日期转成 ISO 字符串、把要保留的结构摊平成普通对象,让传输契约和类型一致。
密钥/数据库连接等放 nuxt.config.tsruntimeConfig,服务端用 useRuntimeConfig() 读取,不会泄漏到客户端(只有 public 字段才会暴露给前端)。

表单是前后端的合奏,而 Nuxt 里两端在同一个项目:页面收集输入,server/api 校验并落库。关键认知只有一条——校验的安全线在服务端

为什么前端校验不算数

  • server/api 是公开的 HTTP 接口,绕过你的页面直接 curl 就能打——前端校验只是体验优化(即时反馈、少一次往返),服务端校验才是防线;
  • h3 内置 readValidatedBody(event, schema.parse):传一个 zod schema,解析 + 校验一步完成,非法输入自动变成 400 响应,处理函数里拿到的一定是合法数据。

客户端提交与错误呈现

  • 提交动作用 $fetch(本章数据获取卡讲过:事件处理里的客户端动作归它管),pending 状态自己用 ref 管;
  • 失败时 $fetchFetchErrorerr.statusCode 是状态码,而 err.data整个服务端错误响应体(状态码、提示信息都在里面;具体字段名随 h3 版本,v1 是 statusCode/statusMessage、v2 起 status/statusText)——createError 塞进 data 的内容在 err.data.data,差一层(h3 v1/v2 一致)。把字段级错误放这里传回,前端取 err.data.data 按字段渲染。
// server/api/contact.post.ts —— 校验在服务端
import { z } from 'zod';
const schema = z.object({
  email: z.string().email(),
  message: z.string().min(10),
});

export default defineEventHandler(async (event) => {
  // 不合法直接 400,进不到下一行
  const body = await readValidatedBody(event, schema.parse);
  await db.save(body);
  return { ok: true };
});

// 页面:提交 + 呈现错误
const pending = ref(false), errorMsg = ref('');
async function submit() {
  pending.value = true; errorMsg.value = '';
  try {
    await $fetch('/api/contact', { method: 'POST', body: form });
  } catch (err) {
    // createError 塞的 data 在 err.data.data —— 差一层
    errorMsg.value = err.data?.data?.msg
      ?? (err.statusCode === 400 ? '输入有误,请检查' : '提交失败,稍后再试');
  } finally { pending.value = false; }
}
$fetch 和原生 fetch 对「失败」的定义不同:原生 fetch 只有网络层失败才 reject,404/500 响应照样 resolve(要自查 res.ok);$fetch 则在响应非 2xx 时直接抛 FetchError。从原生 fetch 迁过来的代码若没包 try/catch,一个 400 校验失败就成了未捕获异常。反过来这也是好事:错误处理只有 try/catch 一条路,不会漏。
校验 schema 别写两份:放进 Nuxt 4 的 shared/ 目录(前后端都能显式 import 的约定位置),服务端用它守门,前端用同一个 schema 做即时校验提示——规则永远一致,改一处两端生效。

useState 存在的唯一理由是 SSR:组件外的模块级 ref 会在服务端被所有请求共享,直接串用户数据。

跨请求安全的状态

useState 创建 SSR 友好的跨组件共享状态。会串用户的是模块作用域(组件外)的 ref——服务端所有请求共享同一模块实例;组件 setup 内的 ref 每请求新建,本身安全。需要跨组件共享的全局态才用 useState。useSeoMeta/useHead 设置标题、meta、OG 标签——SSR 直出利于 SEO。路由中间件 defineNuxtRouteMiddlewareapp/middleware/,做鉴权重定向。

// SSR 安全的共享状态(模块作用域的裸 ref 会跨请求泄漏)
const counter = useState('counter', () => 0);

// SEO:SSR 直出 meta
useSeoMeta({
  title: '用户主页',
  description: '用户详细信息',
  ogImage: '/og.png',
});

// app/middleware/auth.ts —— 路由中间件
export default defineNuxtRouteMiddleware((to) => {
  const user = useState('user');
  if (!user.value)
    return navigateTo('/login');   // 拦截重定向
});
两条 SSR 专属的坑:① useState 里的值必须能 JSON 序列化,塞进函数、类实例、Symbol 会在 payload 传给客户端时丢失或直接报错;② 更严重的是在组件外的模块顶层写 const user = ref(null) 当全局状态——服务端所有请求共享同一个模块实例,A 用户的登录信息会出现在 B 用户的页面上。这不难亲手复现(@vue/server-renderer):模块顶层 ref 设为「用户A」后连续两次 renderToString,第二个「请求」直出的 HTML 里就是用户A 的数据。本地开发单人访问时完全测不出来,上线才爆。跨组件共享状态一律走 useState 或 Pinia。
别在业务代码里到处裸写 useState('user')——key 是全局唯一标识,某处拼错一个字母就会静默地拿到另一份空状态,查起来很折磨。标准做法是包一层 composable:export const useUser = () => useState('user', () => null),之后全项目只调用 useUser(),key 和默认值都只写一次。

登录态的载体是 cookie 会话;Nuxt 的鉴权是三道门的组合,每道门防的东西不同,只装一道就是漏的。

useCookie:SSR 同构的 cookie

  • useCookie('name') 返回一个 ref:服务端渲染时从请求头读、客户端从 document.cookie 读,同一个 API 两端一致——这是「SSR 下怎么在服务端知道用户是谁」的答案;
  • 但会话 cookie 应设 httpOnly(防 XSS 偷 token),此时客户端 JS 根本读不到它——判断登录态要么问服务端(/api/me),要么用服务端塞进 payload 的状态(见 pitfall)。

三道门

  • 路由中间件defineNuxtRouteMiddleware + navigateTo('/login')):管体验——没登录别让用户看到半个后台再弹走;它跑在客户端也能被绕过,不是安全边界
  • 服务端校验:每个需要授权的 server/api 接口自查会话(工具函数或 server/middleware 统一做)——这才是真正的门,绕过页面直接打接口也过不去;
  • 页面内条件渲染:同一页面对不同角色显示不同区块,纯 UI 粒度。
// server/api/login.post.ts —— 登录:种 httpOnly 会话
export default defineEventHandler(async (event) => {
  const { user } = await verify(await readBody(event));
  setCookie(event, 'session', await seal(user), {
    httpOnly: true, secure: true, sameSite: 'lax', maxAge: 60 * 60 * 24 * 7,
  });
  return { ok: true };
});

// server/api/admin/stats.get.ts —— 第二道门:接口自查
export default defineEventHandler(async (event) => {
  const user = await getUserFromCookie(event);   // 解不开就 401
  if (!user) throw createError({ statusCode: 401 });
  return db.stats();
});

// app/middleware/auth.ts —— 第一道门:体验层
export default defineNuxtRouteMiddleware(async () => {
  const me = await useAuth();   // 自己包的 composable:问 /api/me
  if (!me.loggedIn) return navigateTo('/login');
});
httpOnly 的会话 cookie 在客户端 useCookie('session') 读出来是空——不是 bug,是它存在的意义(JS 读不到才偷不走)。把「客户端读不到 cookie」当「没登录」,会把所有已登录用户踢回登录页。正确姿势:登录态通过 /api/me 或 SSR payload 传给前端,cookie 只在服务端解。另一个方向的错误是只装第一道门:路由中间件拦得住浏览器拦不住 curl,见过太多「后台页面进不去、后台接口裸奔」的项目。
别手搓会话加密与 OAuth——Nuxt 作者维护的社区模块 nuxt-auth-utils 提供 setUserSession/getUserSession/requireUserSession(服务端一行完成第二道门)和主流 OAuth 登录,密封(加密签名)会话开箱即用;自己 seal/unseal 很容易造出能被伪造的会话。

Nuxt 的错误呈现分两层:致命错误进全屏的 error.vue局部错误<NuxtErrorBoundary> 圈在组件范围内。没配置时用户看到的是 Nuxt 默认错误页——上线前该换成自己的。

error.vue:全屏兜底

  • app/ 目录下放一个 error.vue(与 app.vue 同级),useError() 拿到 { statusCode, message } 渲染;clearError({ redirect: '/' }) 清掉错误态并回家;
  • 不是页面:不在 pages/ 里、没有路由、默认不套布局——要和站内一致的页头页脚,自己包 <NuxtLayout>

抛错的姿势决定去向

  • createError({ statusCode: 404, statusMessage: '文章不存在' })服务端渲染期抛出 → 直出错误页且响应码正确(SEO 关心这个);
  • 客户端抛出的 createError 默认只是个异常,加 fatal: true 才触发全屏 error.vue;showError() 是命令式的同款;
  • 命名口径:Nuxt 4 文档已改推 status/statusTextstatusCode/statusMessage 是仍然可用的兼容旧名(5.x 计划移除)——读新文档对不上号时想起这条。
  • <NuxtErrorBoundary>:默认插槽渲染出错时切到 #error 插槽(拿到 error 对象可展示/重试),@error 事件用来上报——只覆盖客户端渲染错误,SSR 期错误仍走 error.vue。
<!-- error.vue(与 app.vue 同级)-->
<script setup>
const error = useError();   // { statusCode, message, ... }
</script>
<template>
  <NuxtLayout>
    <h1>{{ error.statusCode === 404 ? '页面不存在' : '出错了' }}</h1>
    <button @click="clearError({ redirect: '/' })">回首页</button>
  </NuxtLayout>
</template>

// 页面里:数据没取到就抛 404(SSR 直出正确状态码)
const { data: post } = await useFetch(`/api/posts/${slug}`);
if (!post.value)
  throw createError({ statusCode: 404, statusMessage: '文章不存在' });

<!-- 局部兜底:第三方小挂件炸了不拖垮整页 -->
<NuxtErrorBoundary @error="report">
  <CommentWidget />
  <template #error="{ error }">评论区暂时打不开</template>
</NuxtErrorBoundary>
两条通道别混:server/api 里抛的 createError 变成 JSON 错误响应(给调用方处理,见表单卡的 FetchError),不会渲染 error.vue;只有页面/组件渲染链路上的错误才走错误页。另外部分未处理的致命错误在开发模式会被带堆栈的开发者错误页接管,和用户实际看到的不一样——上线前用 nuxt build + nuxt preview 把 404/500 各触发一遍,完整验一次自己的 error.vue。
404 要主动接:动态路由页取不到数据就 throw createError({ statusCode: 404 })(如上例),别让页面顶着 200 渲染一片空白——SEO 和 CDN 缓存都会被 200 骗。错误页里的「回首页」必须走 clearError({ redirect }):直接 NuxtLink 跳走不清错误态,用户一返回还是错误页。

Nuxt 的渲染模式不是全局二选一,而是逐路由可选——同一个站点可以一部分预渲染、一部分 SPA、一部分增量缓存。

四种模式与 routeRules

默认SSR(每请求服务端渲染,利于 SEO 与首屏)。nuxt generate纯静态 SSG。最灵活的是 nuxt.config.tsrouteRules混合渲染:逐路由选 prerender(构建期静态)、ssr:false(纯客户端 SPA)、swr/isr(增量缓存)。Nitro 一份代码可部署到 Node/Vercel/Cloudflare 等多平台。

四种模式的取舍

模式HTML 什么时候生成适合
SSR(默认)每次请求,服务端现渲内容常变、要 SEO
prerender / SSG构建期一次文档站、营销页
ssr: false(SPA)浏览器里登录后的后台,无需 SEO
swr / ISR首次请求生成后缓存并定期更新量大又不能太旧的列表页
  • 关键在于 routeRules 是逐路由的:同一个站点可以首页预渲染、商品页 ISR、后台 SPA——不必为了一个页面把整站的渲染模式定死
  • 选择顺序:能静态就静态(最快最省),需要新鲜度再上 ISR,真正每次都不同才用 SSR,纯私有页面直接 SPA。
// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/':          { prerender: true },   // 构建期静态化
    '/blog/**':   { swr: 3600 },        // 缓存 1 小时(ISR/SWR)
    '/admin/**':  { ssr: false },        // 纯客户端 SPA
    '/api/**':    { cors: true },
  },
  runtimeConfig: {
    apiSecret: '',                      // 服务端私有(env 注入)
    public: { apiBase: '/api' },          // 暴露给客户端
  },
});

# 构建
# npx nuxi build      → SSR(Node 服务)
# npx nuxi generate   → 纯静态站点
两个部署时才发现的坑:① nuxt generaterouteRules 的混合渲染不可用——你在配置里精心写的 swr/isr 会被静默忽略,得用 nuxt build 配合支持的托管平台。② 预渲染靠爬链接发现路由/posts/[id] 这类动态页只有能从已渲染页面上被链接爬到才会生成,靠接口列表渲染的详情页往往一个都没生成,线上直接 404。解法是在 nitro.prerender.routes 里显式列出这些路径,或确保 crawlLinks 能从入口页顺藤摸到它们。
渲染模式的选择:默认 SSR 适合多数 SEO 站点;后台管理这类不需要 SEO 的用 ssr:false;博客/文档用 prerenderswr。同一项目可逐路由混用,这是 Nuxt 相对纯 SPA 的最大优势。

Vue 的组件库生态

真实项目里没人从零手写表格与日期选择器。这一章讲清楚 Vue 侧的候选池、按需引入到底省了多少(差距是 6.8 倍)、主题定制的三个层次,以及什么时候该走无头库自己写样式。

Vue 3 的组件库生态相当完整。选型的第一个分岔不是「哪个更好看」,而是「要不要它的视觉」——这决定了你在成套库还是无头库里挑。

成套库(版本为 2026-07 现查 npm)

版本特点样式形态
Element Plus2.14.3中文生态最厚,文档与社区资源最多;后台系统的默认选择SCSS 编译出的普通 CSS + CSS 变量
Naive UI2.44.1TypeScript 友好、主题系统灵活,没有样式文件要引CSS-in-JS(运行时生成)
Vuetify4.1.6Material 风格、组件极全、布局系统完整SCSS,可定制变量
PrimeVue5.0.0组件数量惊人,有 React/Angular 同门可换主题预设
Ant Design Vue4.2.6antd 的 Vue 移植,适合已有 antd 设计规范的团队CSS-in-JS
Nuxt UI4.10.0基于 Reka UI + Tailwind,Nuxt 项目开箱即用Tailwind
Vant4.10.0移动端专用,触屏交互做得细普通 CSS + 变量

无头库:只要行为与无障碍

  • Reka UI 2.10.1(原 Radix Vue):Vue 侧的主力无头库,提供对话框、下拉、组合框等基元,样式一行不给——配 Tailwind 自己写;
  • Ark UI Vue:基于状态机,与 React/Svelte/Solid 版行为完全一致,适合跨框架团队;
  • 注意包名换过radix-vue 停在 1.9.17(2025-02 最后一版),新名字是 reka-ui。搜到的老教程多半还写着旧名;
  • shadcn-vue 是社区把 shadcn/ui 移植到 Vue 的版本,走「把源码复制进你的仓库」那条路,底座正是 Reka UI。

三条选择判据

  • 产品视觉要像我们自己,还是像个正常后台就行?后者选成套库,别折腾;
  • 最刁钻的那两三个组件它有没有?把项目真正需要的组件(可编辑表格、树选择、日期区间、上传队列)列成清单去对照文档,比读十篇对比文章有用
  • 渲染方式:用 Nuxt 做 SSR 时,CSS-in-JS 的库要确认它的 SSR 方案(样式提取是否配好),否则首屏会闪一下没样式的内容。
// 成套库:注册后直接用(先跑通,再改成按需,见下一卡)
import ElementPlus from "element-plus";
import "element-plus/dist/index.css";
createApp(App).use(ElementPlus).mount("#app");

// 无头库:行为归它,样式归你
<script setup>
import { DialogRoot, DialogTrigger, DialogPortal, DialogOverlay, DialogContent, DialogTitle }
  from "reka-ui";
</script>

<template>
  <DialogRoot>
    <DialogTrigger class="rounded bg-slate-900 px-4 py-2 text-white">打开</DialogTrigger>
    <DialogPortal>
      <DialogOverlay class="fixed inset-0 bg-black/40" />
      <DialogContent class="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-lg bg-white p-6">
        <DialogTitle>确认删除</DialogTitle>   <!-- 自动接上 aria-labelledby -->
      </DialogContent>
    </DialogPortal>
  </DialogRoot>
</template>
两个成套库不要并存。「表格用 Element Plus、表单用另一家」听起来只是混搭,实际会同时引入两套设计语言、两套重置样式、两套主题变量与两套弹层层级约定,收益为负。专项库(表格数据层、日期库、通知)可以混,但同一族组件必须来自同一家——否则用户会立刻感到不一致:动画时长、关闭行为、键盘习惯全都不一样。
选库时除了 star 数,看两个更有信息量的指标:最近一次发版距今多久npm view <pkg> time.modified)和它自己依赖了谁npm view <pkg> dependencies)。前者能筛掉停更的包(radix-vue 就是这么露馅的),后者能看出它的性格——依赖多不等于差,但意味着升级时的连锁面更大。

这是 Vue 项目里体积优化投入产出比最高的一件事,也是最容易被误解的一件——很多人以为「按需引入」必须靠插件才能实现,结论正相反。

(Vite 8 生产构建,Vue 3.5.40,同一个「按钮 + 弹窗」页面,gzip)

方式JSCSS合计
空白 Vue 应用(基线)22.8 KB022.8 KB
Element Plus:app.use(ElementPlus) + 全量 CSS301.1 KB46.6 KB347.7 KB
Element Plus:具名引入 + 按组件引样式46.8 KB4.6 KB51.4 KB
Element Plus:unplugin 自动按需46.8 KB4.6 KB51.4 KB
Naive UI:app.use(naive)358.3 KB0358.3 KB
Naive UI:具名引入65.1 KB065.1 KB

三条结论

  • ① 全量注册的代价是 6~7 倍:347.7 vs 51.4 KB。这是本页所有性能话题里差距最大的一项,而修法只是改几行 import
  • ② 插件与手写具名 import 的产物字节数完全一致——真正起作用的是打包器对 ESM 的 tree-shaking,插件省的是打字,不是体积;
  • ③ CSS 也要按需:全量样式 46.6 KB,只引两个组件的样式是 4.6 KB。手写具名 import 时最容易漏掉这一半收益(漏了的表现是「组件在,但完全没样式」)。

让 tree-shaking 失效的三件事

  • 任何一处 app.use(整个库):一行就把全部组件拉进依赖图,后面所有按需都白费。排查按需失效,先全局搜这一行
  • 函数式 API 不经过模板ElMessage / ElMessageBox / ElNotification 是直接调用的,插件看不见它们——要么手动 import(连同它们的 CSS),要么配 unplugin-auto-import
  • 库只提供 CommonJS 产物:老库常见,打包器无法静态分析。今天主流库都已提供 ESM。
// ① 全量:最快能跑,别带上线
import ElementPlus from "element-plus";
import "element-plus/dist/index.css";
createApp(App).use(ElementPlus);

// ② 具名引入:产物最小,样式别忘
<script setup>
import { ElButton, ElDialog } from "element-plus";
import "element-plus/theme-chalk/base.css";
import "element-plus/theme-chalk/el-button.css";
import "element-plus/theme-chalk/el-dialog.css";
</script>

// ③ 插件按需:配一次,模板里随便写 el-xxx(产物与 ② 一样大)
import Components from "unplugin-vue-components/vite";
import { ElementPlusResolver } from "unplugin-vue-components/resolvers";

export default defineConfig({
  plugins: [vue(), Components({ resolvers: [ElementPlusResolver({ importStyle: "css" })] })],
});

# 排查「为什么按需没生效」:先搜这一行
$ grep -rn "app.use(" src/ | grep -iE "element|naive|vuetify|antd"
「我配了 unplugin 所以体积没问题」是个危险的自信。证明插件不比手写 import 更小——真正决定体积的是有没有某处全量注册,而它最容易藏在两个地方:老代码里的 main.js,以及某个「为了方便」的全局注册文件。每次发版前用 vite-bundle-visualizer 之类看一眼产物构成,比任何配置都可靠。
插件按需真正的价值不在体积而在「不会漏引样式」:手写 import 时最常见的错误就是引了组件忘了引它的 CSS,而这个错误的表现(组件渲染出来但完全没样式)很容易被误判成「组件坏了」。团队人多就上插件,小项目手写完全够——但无论哪种,上线前构建一次看看产物里有没有整个库。

「能不能改成我们的设计」这个问题有三个不同深度的答案。选型时把设计稿里最刁钻的那两三个组件拿出来,判断它落在哪一层——这比读文档快得多。

三层,越往下越难

  • ① 令牌层(几乎都支持):主色、圆角、间距、字号、暗色模式。Element Plus 直接暴露 CSS 变量(--el-color-primary 一类),改一行 CSS 就生效,连 JS 都不用碰;Naive UI 走 n-config-provider 的 theme overrides 对象;
  • ② 组件插槽层(部分支持):Vue 的具名插槽在这里是优势——表格的单元格、下拉的选项、空状态、头部尾部,多数成套库都留了插槽口子,能塞进任意自定义内容而不动库的结构;
  • ③ 结构层(基本改不动):DOM 层级、交互流程、无障碍属性。到这一层就只剩「fork 或换无头库」。

暗色模式

  • Element Plus 的暗色是一套额外的 CSS 变量覆盖:引入 element-plus/theme-chalk/dark/css-vars.css,然后在 <html> 上加 class="dark"
  • 关键是让第三方库跟着你的语义层走,而不是各改各的:把 --el-color-primary 指向你自己的 --color-primary,换品牌时只改一处;
  • 别忘了 color-scheme:设了它,滚动条与原生控件才会一起变深,否则会出现「深色页面 + 亮色滚动条」的拼贴感。

不要做的两件事

  • 别用 !important 覆盖库的样式:库升级后类名一变,你的覆盖有一半静默失效(不报错,只是某些地方变丑),另一半还在生效但已经没有对应元素。正确顺序是先找 CSS 变量 → 再找组件级 API/插槽 → 最后才写 CSS
  • 别在 <style scoped> 里直接写库的内部类名:scoped 的属性选择器加不到子组件的内部节点上,写了不生效——需要时用 :deep()但这等于依赖了库的内部结构,升级时可能失效,能用插槽就别用它。
/* ① 令牌层:改 CSS 变量,最稳 */
:root {
  --color-primary: oklch(0.62 0.19 275);
  --el-color-primary: var(--color-primary);      /* 让库跟着你的语义层走 */
  --el-border-radius-base: 10px;
}

/* 暗色:一套覆盖 + color-scheme */
html.dark { color-scheme: dark; }

// ② 插槽层:不改结构也能换内容
<el-table :data="rows">
  <el-table-column label="状态">
    <template #default="{ row }">
      <MyStatusBadge :value="row.status" />      // 自定义单元格
    </template>
  </el-table-column>
  <template #empty><MyEmptyState /></template>   // 空状态也能换
</el-table>

/* ③ 迫不得已才用 :deep(),它依赖库的内部结构 */
:deep(.el-table__header th) { background: var(--color-surface); }
「先用成套库快速搭起来,以后再慢慢改成我们的设计」这条路几乎总是走不通。成套库的定制上限由它的主题系统决定(能改颜色圆角,改不了 DOM 结构与交互细节),而设计稿的深层要求恰恰卡在结构上。真到那一步,面对的是「逐个组件替换 + 两套视觉并存半年」。要么一开始就承认「像它就行」,要么一开始就选无头库——中间那条路是幻觉。
判断一个库的定制上限,有个十分钟的实验:打开它文档站里最复杂的那个组件(通常是表格或日期选择器),用 DevTools 看 DOM 结构,数一数有几层、类名稳不稳定、有没有暴露插槽。有插槽 API 说明作者预留了口子;只有一堆内部类名说明「改样式靠猜」——后者在库升级时会持续付出代价。

从这里到精通:路线图

地图铺完了,剩下的路要亲手写出来。最后这一章先把全页知识收束成一条完整链路,再给出收尾路线:难度递进的动手项目、按阶段的资料,以及一条自测标准。

把全页的机制串成一条线:你写下 count.value++,到像素变化为止,Vue 内部走了六步。每一步都对应前面某一章——能顺着讲下来,这一页就真的连通了。

六步链路

  • ① 拦截:赋值命中 ref 的 setter(reactive 则是 Proxy 的 set 陷阱)——03 章。这一步同步发生,所以 flush:'sync' 的 watcher 此刻立即执行(04 章sync 回调先于赋值行之后的同步代码);
  • ② 触发:setter 查出「谁读过我」——依赖收集时记下的组件渲染函数、watcher、computed,逐个标脏。computed 只标脏不重算(04 章不访问就是 0 次计算);
  • ③ 入队去重:脏了的 effect 进调度器队列,同一个 effect 排多少次都只留一份——这就是批处理的根源(05 章连改三次,DOM 只从 "0" 跳到 "3",中间值从未上屏);
  • ④ 微任务清算:同步代码跑完,队列按序 flush——先 pre watcher(此刻 DOM 还旧),再组件重渲染:执行编译优化过的渲染函数、diff 出最小差异、patch 真实 DOM;
  • ⑤ 收尾post watcher(此刻能读新 DOM,这就是 flush:'post' 省掉 nextTick 的原因)、onUpdated 钩子;
  • ⑥ 放行nextTick() 的 Promise resolve——所有 await nextTick() 之后的代码看到的都是新 DOM(05 章)。
// 一行赋值背后的时间线(同一轮事件循环)
count.value++;
// ① setter 拦截 → ② 依赖标脏 → ③ 调度器入队(去重)
//    ↑ flush:'sync' 的 watcher 在这里同步执行
console.log(el.textContent);   // 还是旧值——④ 还没发生

// —— 同步代码结束,微任务开始 ——
// ④ pre watcher → 重渲染 → diff → patch DOM
// ⑤ post watcher → onUpdated
await nextTick();               // ⑥ 此刻 resolve
console.log(el.textContent);   // 新值
这条链路也解释了本页的高频错误为什么会发生:「改完立即读 DOM 是旧值」——因为读的时刻在 ③ 和 ④ 之间;「watch 回调里 DOM 不对」——默认 pre 在重渲染之前跑;「解构丢响应」——解构把值带离了 ① 的拦截范围,后面五步根本不会发生。遇到「数据变了视图没动」,顺着六步找断点:值真的变了吗(①)→ 读它的地方被收集过依赖吗(②)→ 是不是把批处理当成了丢更新(③)。
想亲眼看这条链:把 04 章 flush 三兄弟的实验粘进项目,三个 watcher 各打一行日志,再夹一行同步 console.log——输出顺序 sync → 同步代码 → pre →(DOM 更新)→ post 就是这张卡的活版本。Vue DevTools 的 Timeline 面板也能看到每次 flush 的批次边界。

Vue 的知识点已经铺完,从「看懂」到「精通」之间隔着的,是几个亲手做完的项目。

动手项目(难度递进)

  • Todo 应用:ref + v-for + v-model,产出支持增删改、过滤和 localStorage 持久化的单页 Todo。
  • 多页数据应用:Vue Router + Pinia + fetch,做一个带路由、搜索与详情页的电影/图书浏览站,认真处理 loading 与错误态。
  • Nuxt 全栈项目:文件路由 + useFetch + server/api,做一个含 SSR、表单提交与登录鉴权的博客或留言板,并部署上线。
  • 深入原理:把 useDebounce/useFetch 等组合式函数抽成一个小库发布(对照 VueUse 的实现),或精读响应式系统源码(@vue/reactivity),能讲清依赖收集与触发更新的完整流程。

资料(按阶段)

  • 入门到进阶:Vue 官方中文文档(cn.vuejs.org),教程、深入指南与 API 参考都与英文版同步。
  • 动手阶段:官方互动教程(Tutorial)与 Vue School 免费课程,边写边学。
  • 全栈阶段:Nuxt 官方文档(nuxt.com)与 Pinia / Vue Router 官方文档。
  • 测试路径:Vitest + Vue Test Utils 为组件与组合式函数写单元测试,Nuxt 项目配 @nuxt/test-utils;从项目 ② 的 store 与关键组件开始补测试即可上手。
  • 原理阶段:官方博客跟进 3.x 与 Vapor Mode 动向,配合 VueUse 源码当组合式函数范本。
做到项目 ③ 时几乎人人撞一次:localStoragewindowdocument 写在 <script setup> 顶层。这段代码在 Nuxt 的服务端渲染阶段也会执行,而 Node 里没有这些浏览器对象,结果是 localStorage is not defined 直接白屏——本地 npm run dev 有时还能蒙混过去,一构建就现形。规则:只有浏览器才有的东西,一律放进 onMounted,或者用 if (import.meta.client) 守卫。
一条自测标准:给你一个「数据明明改了、视图却不更新」的组件,你能否定位出是解构丢失响应性、reactive 被整体替换还是异步时机问题,并讲清 Proxy 依赖收集到触发更新的完整链路——能做到,这一页就毕业了。