vue框架

最基本的示例:

javascript 复制代码
import { createApp, ref } from 'vue'

createApp({
  setup() {
    return {
      count: ref(0)
    }
  }
}).mount('#app')
html 复制代码
<div id="app">
  <button @click="count++">
    Count is: {{ count }}
  </button>
</div>

一、先看 HTML 部分(界面)

html 复制代码
<div id="app">
  <button @click="count++">
    Count is: {{ count }}
  </button>
</div>

这个 <div id="app"> 是一个容器,里面有一个按钮。

  • @click="count++" 是 Vue 的指令,意思是"当按钮被点击时,把 count 这个变量的值加 1"。

  • {``{ count }} 是 Vue 的插值语法,意思是"把 count 当前的值显示在这里"。

简单理解: 这个 HTML 定义了一个模板 (界面长什么样、点击后要做什么),但它本身只是一个静态的描述,还没有真正能动的数据。


二、再看 JavaScript 部分(逻辑与数据)

javascript 复制代码
import { createApp, ref } from 'vue'   // 从 Vue 库中引入两个工具

createApp({            // 创建一个 Vue 应用
  setup() {            // 组件初始化时执行的函数
    return {
      count: ref(0)    // 定义一个响应式的数据 count,初始值为 0
    }
  }
}).mount('#app')       // 把应用挂载到 id="app" 的 DOM 元素上
  • createApp({...}):创建一个 Vue 应用实例。

  • setup() 函数返回一个对象,这个对象里有一个 count,通过 ref(0) 包装。refcount 变成一个响应式数据------当它的值改变时,所有用到它的地方都会自动更新。

  • .mount('#app'):把上面创建的应用"挂载"到页面上 id="app" 的那个 DOM 元素上。这一步是关键,它把 JavaScript 和 HTML 连接起来了


三、它们是怎么关联的?

  • 通过 id="app" 关联

    .mount('#app') 会找到 HTML 中 <div id="app"> 这个元素,然后 Vue 会接管这个元素内部的所有内容。

  • Vue 把 HTML 作为模板

    当你没有在 createApp 里提供 template 选项时,Vue 会直接使用 <div id="app"> 内部已有的 HTML 作为模板。

    所以这个 <button> 里面的 @click{``{ count }} 就能被 Vue 识别和处理。

  • 数据共享

    setup() 中返回的 count 会自动暴露给模板使用。

    因此模板中的 {``{ count }} 会显示当前 count 的值,@click="count++" 会修改 count 的值。因为 count 是响应式的,每次修改都会立刻刷新页面上的显示。


四、用小白能懂的例子总结

想象一下:

  • HTML 部分 就像一张设计图纸,上面画了一个按钮,写着"点击我,数字会增加",并且留了一个位置"这里显示数字"。

  • JavaScript 部分 是一个施工队 + 智能芯片 。施工队(createApp)拿着图纸(HTML 模板),芯片(响应式数据 count)负责记住当前数字是多少。

  • .mount('#app') 就是施工队把芯片和图纸粘在一起,开始工作。

    一开始芯片里存的是 0,所以图纸上显示 Count is: 0

    每次点击按钮,芯片接到指令把数字加 1,然后自动告诉图纸:"数字变了,快重新显示!" 于是你看到数字在变化。


五、两个代码分别做了什么(一句话版)

  • HTML 代码 :画出了界面的样子,并标记了哪里要显示数据({``{ count }}),以及点击时要做什么(count++)。

  • JavaScript 代码 :创建 Vue 应用,定义响应式数据 count(初始值 0),并把这个应用挂载到 id="app" 的元素上,从而让 HTML 中的"指令"和"插值"活起来。


六、你可能会有的疑惑

Q:为什么没有看到 template 选项?

A:Vue 允许直接把挂载元素内的 HTML 当作模板,这种写法简单直观,适合学习或很小的例子。

Q:ref(0) 是干什么的?

A:没有 ref 的话,count 只是一个普通数字,修改它不会自动刷新页面。ref 把它变成了"被 Vue 监视的数据",一旦变化,页面就自动重新渲染。

Q:setup() 返回的对象为什么会自动给模板用?

A:这是 Vue 的约定。setup() 返回的所有属性和方法,都可以直接在模板中使用(比如 {``{ count }}@click="count++")。


实际开发中,更常见的写法会把模板放在 template 选项里,或者使用单文件组件(.vue 文件),但原理和上面展示的完全一样:数据驱动界面,界面通过指令和插值与数据绑定

单文件组件

在大多数启用了构建工具的 Vue 项目中,我们可以使用一种类似 HTML 格式的文件来书写 Vue 组件,它被称为单文件组件 (也被称为 *.vue 文件,英文 Single-File Components,缩写为 SFC)。顾名思义,Vue 的单文件组件会将一个组件的逻辑 (JavaScript),模板 (HTML) 和样式 (CSS) 封装在同一个文件里。下面我们将用单文件组件的格式重写上面的计数器示例:

html 复制代码
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>

<template>
  <button @click="count++">Count is: {{ count }}</button>
</template>

<style scoped>
button {
  font-weight: bold;
}
</style>

单文件组件是 Vue 的标志性功能。如果你的用例需要进行构建,我们推荐用它来编写 Vue 组件。你可以在后续相关章节里了解更多关于单文件组件的用法及用途。但你暂时只需要知道 Vue 会帮忙处理所有这些构建工具的配置就好。

API 风格

Vue 的组件可以按两种不同的风格书写:选项式 API组合式 API

选项式 API (Options API)

使用选项式 API,我们可以用包含多个选项的对象来描述组件的逻辑,例如 datamethodsmounted。选项所定义的属性都会暴露在函数内部的 this 上,它会指向当前的组件实例。

html 复制代码
<script>
export default {
  // data() 返回的属性将会成为响应式的状态
  // 并且暴露在 `this` 上
  data() {
    return {
      count: 0
    }
  },

  // methods 是一些用来更改状态与触发更新的函数
  // 它们可以在模板中作为事件处理器绑定
  methods: {
    increment() {
      this.count++
    }
  },

  // 生命周期钩子会在组件生命周期的各个不同阶段被调用
  // 例如这个函数就会在组件挂载完成后被调用
  mounted() {
    console.log(`The initial count is ${this.count}.`)
  }
}
</script>

<template>
  <button @click="increment">Count is: {{ count }}</button>
</template>
疑问:

第一个是 data()methods: {}mounted() {} 这几个是固定的吗?

是的,这些名字是 Vue 框架固定好的"选项名称"

当你使用 Options API 时,Vue 内部会去检查组件对象里有没有叫 datamethodsmountedcomputedwatch 等属性,如果有,就会按特定的规则处理它们。

  • data :必须是函数,返回一个对象,里面的属性会变成响应式数据。

  • methods :必须是对象,里面可以定义多个方法,这些方法会自动绑定到组件实例上,并且可以在模板中直接使用。

  • mounted :必须是函数,Vue 会在组件挂载完成后自动调用它(不会让你手动调用)。

就是说 data 是声明变量的地方,methods 是定义自己方法的地方,mounted 是固定执行完后会回调的地方?

理解完全正确! 用更口语的话说:

  • data:放数据(状态)的地方,比如计数器的数字、输入框的内容、列表数据等。

  • methods :放函数的地方,这些函数通常用来修改 data 里的数据,或者处理用户交互。

  • mounted:组件挂载到页面上之后,Vue 自动调用这里的代码,适合做一些初始化操作(比如请求后端数据、操作 DOM 等)。

总结一句话:

  • datamethodsmounted 是 Vue 固定的选项名,分别用来声明数据、定义方法、注册挂载后的回调。

  • 你只需要按照这个"格式"填写内容,Vue 就会自动帮你处理好一切。

组合式 API (Composition API)

通过组合式 API,我们可以使用导入的 API 函数来描述组件逻辑。在单文件组件中,组合式 API 通常会与 <script setup> 搭配使用。这个 setup attribute 是一个标识,告诉 Vue 需要在编译时进行一些处理,让我们可以更简洁地使用组合式 API。比如,<script setup> 中的导入和顶层变量/函数都能够在模板中直接使用。

下面是使用了组合式 API 与 <script setup> 改造后和上面的模板完全一样的组件:

html 复制代码
<script setup>
import { ref, onMounted } from 'vue'

// 响应式状态
const count = ref(0)

// 用来修改状态、触发更新的函数
function increment() {
  count.value++
}

// 生命周期钩子
onMounted(() => {
  console.log(`The initial count is ${count.value}.`)
})
</script>

<template>
  <button @click="increment">Count is: {{ count }}</button>
</template>
一、逐部分解释
1. 导入需要的功能

js

复制代码
import { ref, onMounted } from 'vue'
  • ref:用来创建一个响应式数据(相当于 Options API 中 data() 返回的属性的功能)。

  • onMounted:生命周期钩子,相当于 Options API 里的 mounted()

2. 定义响应式状态

js

复制代码
const count = ref(0)
  • ref(0) 创建一个初始值为 0 的响应式引用(对象)。

  • <script setup> 中,count自动暴露给模板 ,所以在模板里可以直接写 {``{ count }} 而不用 .value

  • 注意 :在 JavaScript 代码中(如 increment 函数里)修改 count 的值,必须用 count.value = ...,因为 ref 包装后的值放在 .value 属性上。

3. 定义修改状态的方法

js

复制代码
function increment() {
  count.value++
}
  • 这是一个普通的 JavaScript 函数,用来修改 count 的值。

  • 在模板中绑定点击事件:<button @click="increment">,点击时会执行这个函数。

  • 因为 count 是响应式的,count.value++ 后,模板中 {``{ count }} 会自动更新。

4. 生命周期钩子

js

复制代码
onMounted(() => {
  console.log(`The initial count is ${count.value}.`)
})
  • onMounted 接收一个函数,这个函数会在组件挂载到 DOM 后执行。

  • 这里只是打印当前的 count 值(初始 0)。

  • 如果需要组件一加载就去请求数据、操作 DOM 等,通常放在 onMounted 里。

5. 模板部分

html

复制代码
<template>
  <button @click="increment">Count is: {{ count }}</button>
</template>
  • 与之前的 Options API 完全一样:显示一个按钮,文字为 Count is: X,点击触发 increment 函数。
二、为什么这种写法更好?
  • 代码更紧凑 :不需要 export default { ... } 的包裹,不需要 data()methodsmounted 这些固定的分区。

  • 逻辑更集中 :相关数据和方法可以放在一起(比如 countincrement 紧挨着),而不是分散在不同选项中。

  • 更好的 TypeScript 支持<script setup> 天然对 TS 友好。

  • 性能略优:编译时做了一些优化。

对比之前你见过的 Options API:

功能 Options API Composition API (<script setup>)
响应式数据 data() 返回 ref(0) / reactive({})
方法 methods: { ... } 普通函数
生命周期 mounted() { ... } onMounted(() => { ... })
访问数据 this.count count.value(JS)/ count(模板)
三、常见疑问

Q:为什么模板里可以直接写 {``{ count }},而 JS 里必须写 count.value

A:Vue 在编译 <script setup> 时,会自动"解开"顶层 ref.value,所以在模板中直接使用值。但在 JS 代码中,你需要明确知道 count 是一个 Ref 对象,修改它必须用 .value

Q:onMounted 和之前的 mounted 是什么关系?

A:完全对应。onMounted 就是 Composition API 中的 mounted 钩子。如果你熟悉 Options API 的 mounted(),就把 onMounted 当作它的"函数版"。

Q:可以用多个 onMounted 吗?

A:可以,它们会按顺序依次执行。这一点比 Options API 更灵活。

Q:必须导入 refonMounted 吗?

A:是的,因为这些 API 没有全局暴露(为了更好的 tree-shaking 和依赖追踪)。需要什么就从 vue 里导入什么。

Q:onMounted 的回调函数是在"所有执行之前"还是"之后"?

答案是:在几乎所有初始化执行完之后

具体来说,onMounted 的回调函数会在组件挂载到 DOM 之后 才执行。

在此之前,Vue 已经完成了:

  1. 执行 <script setup> 里的所有顶层代码 (包括定义 ref、函数、调用 onMounted 本身等)。

  2. 渲染组件模板 (即把 <template> 变成真实的 DOM 元素)。

  3. 把真实 DOM 插入到页面中

所以执行顺序是:

<script setup> 里的普通代码(比如 const count = ref(0))→ 模板渲染 → DOM 挂载完成 → 执行 onMounted 里的回调。

一个直观的例子:

javascript 复制代码
<script setup>
import { ref, onMounted } from 'vue'

console.log('1. setup 中的普通代码执行')
const count = ref(0)

onMounted(() => {
  console.log('3. onMounted 回调执行,此时 DOM 已存在')
})

console.log('2. setup 中的代码继续执行,onMounted 只是注册,不会立即执行')
</script>

<template>
  <div>{{ count }}</div>
</template>

控制台输出顺序是:

  1. setup 中的普通代码执行

  2. setup 中的代码继续执行,onMounted 只是注册,不会立即执行

  3. onMounted 回调执行,此时 DOM 已存在

Q:那如果用户操作了,这时候也代表dom肯定存在了,那么这时候是用户操作的是不是就是先打印回调函数的了,用户操作的后打印?

你这个问题问得特别好,正好能帮我们理清 生命周期钩子用户事件回调 的区别。

先直接回答你:

不是。用户操作时,onMounted 的回调早就执行完了(在页面刚加载时执行)。用户操作触发的是别的东西(比如 @click 绑定的函数),这些函数会在用户点击的那一刻执行。它们是两回事,不会"抢先"或者"后于"对方,因为 onMounted 只运行一次,且发生在用户操作之前。

用户操作时发生了什么:

用户点击按钮 → 触发 @click="increment" → 执行 increment 函数(里面可能修改 count.value)→ 页面更新。

onMounted 不会再被调用。

所以你在控制台看到的打印,只会是页面加载时的那一条(来自 onMounted),而不会是用户点击时打印的(除非你在 increment 里也写了 console.log)。

四、小练习帮你巩固

试着把下面这段 Options API 代码改写成 <script setup>

javascript 复制代码
export default {
  data() {
    return { msg: 'Hello', count: 0 }
  },
  methods: {
    sayHi() { alert(this.msg) }
  },
  mounted() {
    console.log('mounted')
  }
}

答案提示:

javascript 复制代码
<script setup>
import { ref, onMounted } from 'vue'
const msg = ref('Hello')
const count = ref(0)
function sayHi() { alert(msg.value) }
onMounted(() => console.log('mounted'))
</script>

总结一句:<script setup> 让你像写普通 JavaScript 一样写 Vue 组件,响应式数据用 ref,生命周期用 onXxx,定义函数就直接 function,然后用 @click 等指令绑定到模板。

创建一个 Vue 应用

每个 Vue 应用都是通过 createApp 函数创建一个新的 应用实例

javascript 复制代码
import { createApp } from 'vue'

const app = createApp({
  /* 根组件选项 */
})

根组件

我们传入 createApp 的对象实际上是一个组件,每个应用都需要一个"根组件",其他组件将作为其子组件。

如果你使用的是单文件组件,我们可以直接从另一个文件中导入根组件。

javascript 复制代码
import { createApp } from 'vue'
// 从一个单文件组件中导入根组件
import App from './App.vue'

const app = createApp(App)

理解:

  • createApp(...) 就像你开了一家公司

  • 这个公司必须有一个总经理(根组件),总经理负责整个公司的运转。

  • 总经理下面可以招各种部门经理、员工(子组件),大家一起干活。

代码示例:

复制代码
import { createApp } from 'vue'
import App from './App.vue'   // App.vue 就是总经理(根组件)

const app = createApp(App)     // 创建公司,并任命 App 为总经理

App.vue 是一个单文件组件,它里面可以写模板、逻辑、样式,然后被 createApp 加载。

虽然本指南中的许多示例只需要一个组件,但大多数真实的应用都是由一棵嵌套的、可重用的组件树组成的。例如,一个待办事项 (Todos) 应用的组件树可能是这样的:

javascript 复制代码
App (root component)
├─ TodoList
│  └─ TodoItem
│     ├─ TodoDeleteButton
│     └─ TodoEditButton
└─ TodoFooter
   ├─ TodoClearButton
   └─ TodoStatistics

理解:

  • 简单教程/示例里,可能只有一个组件(比如只有一个按钮)。

  • 但真正做项目时,你会把页面拆成很多小零件(组件),每个零件负责一块功能,然后像搭积木一样把它们拼成一棵"树"。

为什么要拆?

  • 方便复用(比如一个"点赞按钮"可以在很多地方使用)。

  • 方便维护(每个零件单独改,不影响其他零件)。

  • 方便多人协作(不同人开发不同零件)。

  • App :根组件,是整个应用的入口容器

  • TodoList:App 的一个子组件,负责显示待办事项列表。

  • TodoItem :TodoList 的子组件,代表列表中的每一项

  • TodoDeleteButtonTodoEditButton:每个 TodoItem 里面的两个按钮(删除、编辑)。

  • TodoFooter:App 的另一个子组件,页面底部的区域。

  • TodoClearButtonTodoStatistics:TodoFooter 内部的两个小组件(清除所有事项按钮、统计信息)。

疑问:

Q:为什么需要"根组件"?

因为 createApp(App) 需要一个"起点"。

Vue 会从这个根组件开始,递归地渲染它的子组件、子子组件......最终生成完整的页面。

如果你没有根组件,Vue 就不知道从哪开始画。

挂载应用

应用实例必须在调用了 .mount() 方法后才会渲染出来。该方法接收一个"容器"参数,可以是一个实际的 DOM 元素或是一个 CSS 选择器字符串:

javascript 复制代码
<div id="app"></div>
javascript 复制代码
app.mount('#app')

应用根组件的内容将会被渲染在容器元素里面。容器元素自己将不会被视为应用的一部分。

.mount() 方法应该始终在整个应用配置和资源注册完成后被调用。同时请注意,不同于其他资源注册方法,它的返回值是根组件实例而非应用实例。

理解:

  1. "应用实例必须在调用了 .mount() 方法后才会渲染出来"
  • 创建 app 只是准备,就像写好了菜谱。

  • .mount() 才是开始做菜,把菜装盘(渲染到页面)。

  • 不调用 mount,页面上啥也没有。

  1. "该方法接收一个'容器'参数,可以是 DOM 元素或 CSS 选择器"
  • 容器就是"放内容的盒子"。

  • 你可以用 #app(像 CSS 选 id)或直接传 document.getElementById('app')

  • 告诉 Vue:"把东西放到这个盒子里。"

  1. "应用根组件的内容将会被渲染在容器元素里面。容器元素自己将不会被视为应用的一部分。"
  • 容器 <div id="app"> 只是一个外壳,Vue 不会替换或删除它。

  • Vue 把根组件生成的 HTML 当作孩子放进这个容器内部。

  • 重要:容器本身不属于 Vue 管理的范围,它只是占位符。

    • 例子:如果容器里原本有文字 <div id="app">旧文字</div>,挂载后旧文字会被覆盖,换成 Vue 的内容。
  1. ".mount() 方法应该始终在整个应用配置和资源注册完成后被调用"
  • 配置指的是:安装路由、状态管理、全局组件等(比如 app.use(router))。

  • 顺序固定 :创建 app → 做各种配置(如注册插件)→ 最后才 mount

  • 如果先挂载再配置,有些功能可能不生效。

  1. "它的返回值是根组件实例而非应用实例"
  • 通常我们写 app.mount('#app') 不接收返回值。

  • 如果你需要操作根组件内部的数据或方法,可以接收返回值:

    复制代码
    const root = app.mount('#app')
    console.log(root.count)  // 假设根组件有一个 count 数据
  • 但大多数时候用不到,因为 Vue 鼓励用响应式数据自动更新,而不是手动调用实例方法。

注意两种情况的区别

Vue 在挂载时,对于容器内的内容有两种处理方式:

情况1:你没有提供 template 选项(例如本文开头 最基本的示例 例子)

  • Vue 会把容器内部的 HTML 当作模板来使用。

  • 也就是说,它不会删掉按钮,而是直接利用这个按钮,给它加上数据绑定和事件监听。

  • 此时旧内容没有被覆盖,而是被"激活"了。

情况2:你提供了 templaterender 选项

  • Vue 会完全忽略容器内部原本的内容,用你提供的模板或渲染函数生成全新的 DOM,并替换掉容器内部的所有东西。

  • 此时旧内容会被覆盖。

例如:

复制代码
createApp({
  template: '<button>新按钮</button>'   // 显式提供模板
}).mount('#app')

不管 <div id="app"> 里原来写了什么,都会被 <button>新按钮</button> 完全替换掉。

DOM 中的根组件模板

根组件的模板通常是组件本身的一部分,但也可以直接通过在挂载容器内编写模板来单独提供:

javascript 复制代码
<div id="app">
  <button @click="count++">{{ count }}</button>
</div>
javascript 复制代码
import { createApp } from 'vue'

const app = createApp({
  data() {
    return {
      count: 0
    }
  }
})

app.mount('#app')

当根组件没有设置 template 选项时,Vue 将自动使用容器的 innerHTML 作为模板。

DOM 内模板通常用于无构建步骤的 Vue 应用程序。它们也可以与服务器端框架一起使用,其中根模板可能是由服务器动态生成的

理解:

为什么要这样设计?

  1. 省去构建步骤

    如果你只是写一个小页面,不想用 npm run build、不想写 .vue 文件,直接在 HTML 里写 Vue 模板是最简单的。

    引入 Vue 的 CDN 后,就可以用这种写法。

  2. 服务端动态生成模板

    比如后端使用 PHP/Node 渲染页面,可以在 <div id="app"> 里动态输出不同的 HTML 结构,而 Vue 只负责把它变成响应式的。

这种写法的限制(知道就好)

  • 浏览器解析 HTML 时会有一些限制,比如 <table> 里不能直接放 <div>,这时直接用容器内模板可能出问题。

  • 编译器无法对 DOM 内模板做优化,性能略差。

  • 不适合大型应用(代码难以组织)。

但对于学习、简单页面或后端渲染场景,它很方便。

应用配置

应用实例会暴露一个 .config 对象允许我们配置一些应用级的选项,例如定义一个应用级的错误处理器,用来捕获所有子组件上的错误:

javascript 复制代码
app.config.errorHandler = (err) => {
  /* 处理错误 */
}

应用实例还提供了一些方法来注册应用范围内可用的资源,例如注册一个组件:

javascript 复制代码
app.component('TodoDeleteButton', TodoDeleteButton)

这使得 TodoDeleteButton 在应用的任何地方都是可用的。我们会在指南的后续章节中讨论关于组件和其他资源的注册。你也可以在 API 参考中浏览应用实例 API 的完整列表。

确保在挂载应用实例之前完成所有应用配置!

理解:

一、什么是"应用配置"?

创建应用实例后(const app = createApp(App)),你可以给这个应用设置一些全局行为,这些设置会影响应用内的所有组件。

类似开公司:

  • createApp = 注册公司,领了营业执照。

  • 配置 = 制定公司制度(比如"所有员工出错都走同一个汇报流程")。

  • mount = 正式开门营业。

关键:制度必须在营业前定好,营业后就不能改了。

二、.config 对象

javascript 复制代码
app.config.errorHandler = (err) => {
  console.log('捕获到错误:', err)
  // 可以上报给服务器或弹个提示
}
  • app.config 是一个专门放全局设置的对象。

  • errorHandler 是一个函数,当应用中任何组件(包括子组件)抛出错误时,这个函数会被自动调用。

  • 你不需要在每个组件里写 try/catch,统一在这里处理。

类比:公司设了一个"总投诉信箱",不管哪个部门出错,都自动把错误信息发到这个信箱,你不用每个部门单独设信箱。

三、注册全局组件

javascript 复制代码
app.component('TodoDeleteButton', TodoDeleteButton)
  • app.component 用来注册一个全局组件

  • 第一个参数是组件名(字符串),第二个参数是组件对象(或从 .vue 文件导入的组件)。

  • 注册后,你可以在应用内任何其他组件的模板 中直接使用 <TodoDeleteButton />,而不用再单独导入。

如果不注册全局 :你需要在每个用到这个组件的文件里 import 并注册到 components 选项,比较麻烦。

注册全局:一次注册,到处使用。

类比:公司采购了一批公用笔记本电脑(全局组件),任何部门需要都可以直接领用,不用每个部门自己去买。

四、为什么必须在挂载前完成所有配置?

javascript 复制代码
app.component('MyButton', MyButton)   // ✅ 正确:挂载前配置
app.config.errorHandler = myHandler   // ✅ 正确
app.mount('#app')                     // 挂载

app.component('Another', Another)     // ❌ 错误:挂载后再配置无效(或报错)

原因

  • 挂载时 Vue 会把根组件渲染出来,并递归生成所有子组件。

  • 如果在渲染过程中遇到一个全局组件(比如 <MyButton />),Vue 会去已注册的全局组件列表里查找。

  • 如果你在挂载之后才注册,渲染已经完成了,新的注册来不及生效。

类比:公司营业前,你要把规章制度定好、把公用设备买好。等员工已经开工了,你才说"我们新增一个报销流程",那之前已经发生的报销就享受不到了。

五、完整的小例子

javascript 复制代码
<div id="app">
  <!-- 这个按钮是全局组件 -->
  <GlobalButton />
</div>
javascript 复制代码
// 假设 GlobalButton 是一个简单的按钮组件
const GlobalButton = {
  template: '<button>全局按钮</button>'
}

const app = createApp({
  template: '<div><GlobalButton /></div>'
})

// 配置全局错误处理
app.config.errorHandler = (err) => {
  alert('出错了:' + err.message)
}

// 注册全局组件
app.component('GlobalButton', GlobalButton)

// 最后挂载
app.mount('#app')

如果顺序反了(先 mountcomponent),页面就会报错,因为渲染 <GlobalButton /> 时还没注册

六、一句话记忆

应用配置(.config、全局组件等)就像装修房子:必须在入住(mount)之前全部弄好,入住后就不能改了。配置的内容会影响整个房子里所有房间(组件)。

多个应用实例

应用实例并不只限于一个。createApp API 允许你在同一个页面中创建多个共存的 Vue 应用,而且每个应用都拥有自己的用于配置和全局资源的作用域。

javascript 复制代码
const app1 = createApp({
  /* ... */
})
app1.mount('#container-1')

const app2 = createApp({
  /* ... */
})
app2.mount('#container-2')

如果你正在使用 Vue 来增强服务端渲染 HTML,并且只想要 Vue 去控制一个大型页面中特殊的一小部分,应避免将一个单独的 Vue 应用实例挂载到整个页面上,而是应该创建多个小的应用实例,将它们分别挂载到所需的元素上去。

理解:

一、用生活例子理解

想象你有一个大房子(一个 HTML 页面)。

  • 你可以让一个管家(Vue 应用)负责整个房子的所有房间。

  • 你也可以请多个管家,每个管家只负责一个房间:

    • 管家 A 管厨房(挂载到 #kitchen

    • 管家 B 管卧室(挂载到 #bedroom

    • 管家 C 管书房(挂载到 #study

每个管家都有自己的管理方式(配置、全局组件、错误处理),他们之间不会搞混。

二、代码示例

javascript 复制代码
<div id="app1">
  <button @click="count++">{{ count }}</button>
</div>

<div id="app2">
  <input v-model="text" placeholder="输入内容" />
  <p>你输入了:{{ text }}</p>
</div>
javascript 复制代码
// 第一个应用:计数器
const app1 = createApp({
  data() { return { count: 0 } }
})
app1.mount('#app1')

// 第二个应用:输入框绑定
const app2 = createApp({
  data() { return { text: '' } }
})
app2.mount('#app2')

这两个应用完全独立

  • app1 只管它自己的按钮和 count

  • app2 只管它自己的输入框和 text

  • 它们的数据、方法、生命周期、全局配置(比如 app1.config.errorHandler)互不干扰。

三、为什么要用多个应用实例?

文档特别提到一个常见场景:用 Vue 来增强一个已经由服务端渲染好的大型页面(比如 PHP、Java、Node 后端生成的 HTML)。

传统做法可能会写一个巨大的 Vue 应用,把整个页面 #app 都接管了。但这样有几个问题:

  • 后端可能已经生成了很多静态内容或复杂的 DOM 结构,Vue 接管后会把它们全部替换掉,导致后端的工作白费。

  • 页面中只有一小块区域需要交互(比如一个评论框、一个点赞按钮),没必要让 Vue 控制整个页面。

更好的做法:只给那些需要交互的小区域分别创建独立的 Vue 应用,每个应用只负责自己的那部分。

  • 优点:性能更好,更轻量,不影响页面其他部分,也方便后端渐进式改造。

四、多个应用 vs 一个应用的组件树

  • 一个应用 + 组件树:适合整个页面或整个单页应用(SPA),所有组件共享同一个全局配置、路由、状态管理。

  • 多个独立应用:适合在传统多页应用或混合页面中,给不同的"小部件"分别加 Vue 功能,它们彼此不需要通信。

如果两个应用之间确实需要通信,可以用浏览器的自定义事件、全局变量、消息总线等方式,但一般情况下它们应该保持独立。

五、注意事项

  • 每个应用必须挂载到不同的 DOM 元素上,不能重叠。

  • 资源(组件、指令、插件)是应用级别隔离 的:app1.component('MyButton', ...) 不会让 app2 能用 <MyButton>

  • 配置(如错误处理器)也是独立的,每个应用可以有不同的错误处理策略。

六、一句话总结

createApp 可以创建多个 Vue 应用,每个应用挂载到不同的 DOM 元素上,各自独立运行。这很适合在大型、后端渲染的页面里只控制局部动态区域,而不是把整个页面交给 Vue。
一、场景假设

一个后端渲染的新闻页面:

html 复制代码
<!DOCTYPE html>
<html>
<body>
  <!-- 后端生成的导航栏 -->
  <header>...</header>

  <!-- 后端生成的文章正文(含SEO关键内容) -->
  <article>
    <h1>重要新闻</h1>
    <p>大量后端渲染的文字...</p>
  </article>

  <!-- 需要交互的小区域:点赞按钮 -->
  <div id="like-section">
    <button>👍 点赞 <span>0</span></button>
  </div>

  <!-- 后端生成的页脚 -->
  <footer>...</footer>
</body>
</html>

后端已经生成了完整的、有意义的静态内容(导航、文章、页脚),这些内容对 SEO、首屏加载速度都很重要。

二、错误做法:一个 Vue 应用接管整个页面

javascript 复制代码
// 挂载到整个 body 或一个大容器
createApp({...}).mount('#body')   // 错误示范,假设这里挂载到了 body

问题:

  • 如果 Vue 应用接管了整个页面,它默认会重新渲染整个容器内的内容

  • 如果你没有提供 template,Vue 虽然会复用现有 DOM,但仍然会完全控制这些区域,可能导致事件监听冲突、样式混乱。

  • 如果你提供了 template(更常见),Vue 会用你的模板替换掉容器内所有原生内容

    • 结果:后端辛苦生成的文章正文、导航栏、页脚全部被 Vue 生成的 DOM 覆盖,页面上只剩你在模板里写的东西。

    • 后端的工作(渲染静态内容、SEO 优化)就白费了。用户看到的都是 Vue 重新渲染的内容,而不是后端直接返回的原始 HTML。

三、正确做法:只让 Vue 接管需要交互的小区域

javascript 复制代码
// 只针对点赞按钮区域创建独立的小应用
createApp({
  data() {
    return { likeCount: 0 }
  },
  methods: {
    like() { this.likeCount++; }
  }
}).mount('#like-section')

效果:

  • 后端生成的导航栏、文章、页脚完全保留,Vue 不会动它们。

  • 只有 <div id="like-section"> 内部被 Vue 接管,变成动态交互区域。

  • 后端的所有工作都被保留了,页面加载时立即显示完整内容,然后 Vue 只增强那一小块。

四、为什么说"没必要让 Vue 控制整个页面"?

  • 性能浪费:Vue 不需要为静态的大段文本创建虚拟 DOM、监听变化。

  • 维护成本:你不需要在 Vue 模板里重新写一遍后端已经生成的 HTML。

  • 风险:如果后端动态生成某些内容(如用户个性化数据),Vue 重新渲染可能丢失它们。

  • SEO 影响:搜索引擎直接拿到后端生成的完整 HTML,而不是等待 Vue 执行 JS 后才生成内容。

  • 简单性:一个页面有多个独立小部件时,各自为政比一个庞大的单页应用简单得多。

五、一句话总结

当页面大部分是后端渲染的静态内容,只有少数地方需要动态交互时,用多个小的 Vue 应用分别接管这些局部区域,而不是用一个大的 Vue 应用覆盖整个页面。这样可以保留后端的工作成果,避免不必要的性能开销和复杂性。

模板语法

Vue 使用一种基于 HTML 的模板语法,使我们能够声明式地将其组件实例的数据绑定到呈现的 DOM 上。所有的 Vue 模板都是语法层面合法的 HTML,可以被符合规范的浏览器和 HTML 解析器解析。

在底层机制中,Vue 会将模板编译成高度优化的 JavaScript 代码。结合响应式系统,当应用状态变更时,Vue 能够智能地推导出需要重新渲染的组件的最少数量,并应用最少的 DOM 操作。

如果你对虚拟 DOM 的概念比较熟悉,并且偏好直接使用 JavaScript,你也可以结合可选的 JSX 支持直接手写渲染函数而不采用模板。但请注意,这将不会享受到和模板同等级别的编译时优化。

理解:

一、Vue 模板语法是什么?

Vue 使用一种基于 HTML 的模板语法,使我们能够声明式地将其组件实例的数据绑定到呈现的 DOM 上。所有的 Vue 模板都是语法层面合法的 HTML,可以被符合规范的浏览器和 HTML 解析器解析。

大白话:

  • 你在 .vue 文件的 <template> 里写的那些 {``{ count }}@clickv-if 等,本质上还是 HTML

  • 浏览器能直接读懂这些标签(即使不认识 @click,也会把它当作一个普通属性,不会报错)。

  • 你只需要 声明 数据和模板的关系,Vue 会帮你把数据填到正确的位置,并监听变化自动更新。

对比传统 JS 操作 DOM(命令式):

你要写 document.getElementById('count').innerText = count,每一步都要告诉浏览器"怎么做"。

Vue 是声明式:你只需说"这个按钮的文字是 {``{ count }}",Vue 自动同步。

二、底层编译和优化

在底层机制中,Vue 会将模板编译成高度优化的 JavaScript 代码。结合响应式系统,当应用状态变更时,Vue 能够智能地推导出需要重新渲染的组件的最少数量,并应用最少的 DOM 操作。

大白话:

  • 你在 <template> 里写的 HTML 并不是直接丢给浏览器,而是先被 Vue 编译器 翻译成高效的 JS 函数(称为渲染函数)。

  • 这个渲染函数会生成 虚拟 DOM(一个 JS 对象,描述界面结构)。

  • 当数据变化时,Vue 不会傻傻地重新渲染整个页面。它会:

    1. 用新的数据生成新的虚拟 DOM。

    2. 对比新旧虚拟 DOM,精确找出哪里变了。

    3. 只更新那些变化的部分(比如只改一个按钮的文字,而不是整个列表)。

结果: 性能很高,你不用担心手动优化。

三、如果你熟悉虚拟 DOM 并喜欢 JS

如果你对虚拟 DOM 的概念比较熟悉,并且偏好直接使用 JavaScript,你也可以结合可选的 JSX 支持直接手写渲染函数而不采用模板。但请注意,这将不会享受到和模板同等级别的编译时优化。

大白话:

  • 高级开发者可以 跳过写 <template> ,直接用 JS 写渲染函数(类似 React 的 createElement 或 JSX)。

  • 这样做更灵活,因为 JS 拥有完全的程序能力(循环、条件、函数调用等)。

  • 但代价是:Vue 团队为模板做了很多 编译时优化(比如静态提升、预编译),手写渲染函数会失去这些优化,性能可能稍差。

  • 除非你非常熟悉虚拟 DOM 且有特殊需求,否则推荐使用模板。

四、整体总结(一句话记忆)

Vue 模板 = 合法 HTML + 声明式绑定 → 被编译成高效 JS → 配合响应式系统,只更新必要部分。也可以手写 JS 渲染函数(JSX),但会失去模板的编译优化。

文本插值

最基本的数据绑定形式是文本插值,它使用的是"Mustache"语法 (即双大括号):

javascript 复制代码
<span>Message: {{ msg }}</span>

双大括号标签会被替换为相应组件实例中 msg 属性的值。同时每次 msg 属性更改时它也会同步更新。

原始 HTML

双大括号会将数据解释为纯文本,而不是 HTML。若想插入 HTML,你需要使用 v-html 指令

javascript 复制代码
<p>Using text interpolation: {{ rawHtml }}</p>
<p>Using v-html directive: <span v-html="rawHtml"></span></p>

这里我们遇到了一个新的概念。这里看到的 v-html attribute 被称为一个指令 。指令由 v- 作为前缀,表明它们是一些由 Vue 提供的特殊 attribute,你可能已经猜到了,它们将为渲染的 DOM 应用特殊的响应式行为。这里我们做的事情简单来说就是:在当前组件实例上,将此元素的 innerHTML 与 rawHtml 属性保持同步。

span 的内容将会被替换为 rawHtml 属性的值,插值为纯 HTML------数据绑定将会被忽略。注意,你不能使用 v-html 来拼接组合模板,因为 Vue 不是一个基于字符串的模板引擎。在使用 Vue 时,应当使用组件作为 UI 重用和组合的基本单元。

安全警告:

在网站上动态渲染任意 HTML 是非常危险的,因为这非常容易造成 XSS 漏洞。请仅在内容安全可信时再使用 v-html,并且永远不要使用用户提供的 HTML 内容。

理解:

一、双大括号 {``{ }} 只能放纯文本

html 复制代码
<p>{{ rawHtml }}</p>

假设你的 rawHtml 的值是:'<span style="color: red">红色文字</span>'

浏览器会原样显示:<span style="color: red">红色文字</span>

不会<span> 解析成 HTML 标签,而是当作普通字符串显示出来。

为什么? 为了防止安全问题(后面会讲),Vue 默认把所有数据当作纯文本转义。

二、v-html 可以渲染真正的 HTML

html 复制代码
<p><span v-html="rawHtml"></span></p>

此时,rawHtml 里的 <span style="color: red">红色文字</span> 会被浏览器解析成真正的 HTML 元素,页面上会显示红色的"红色文字"。

效果对比

  • {``{ rawHtml }} → 显示字符串本身(包括尖括号)

  • v-html → 字符串被当作 HTML 代码执行,生成对应的 DOM 元素

三、什么是"指令"?

v-html 是 Vue 提供的一个指令

  • 所有以 v- 开头的特殊属性都是指令,例如 v-ifv-forv-bindv-on(简写 @)。

  • 指令的作用是告诉 Vue:对这个 DOM 元素做点什么特殊处理

  • v-html 的意思是:把这个元素的 innerHTML 设置为所给的值,并且当数据变化时自动更新。

四、安全警告(非常重要!)

在网站上动态渲染任意 HTML 是非常危险的,因为这非常容易造成 XSS 漏洞。

什么是 XSS?

恶意用户提交一段包含 <script>alert('坏蛋')</script> 的评论,如果你用 v-html 原样输出,这段脚本就会在别人的浏览器里执行,可能偷取 cookie、篡改页面等。

所以规则很简单:

  • 只对你自己完全可控的、信任的内容使用 v-html

  • 绝对不要 把用户输入的内容(比如表单提交、URL参数)直接用 v-html 渲染。

如果不确定是否安全,就用 {``{ }} 纯文本显示,Vue 会自动转义特殊字符,避免脚本执行。

五、为什么不能用 v-html 拼接组件?

文档说:"你不能使用 v-html 来拼接组合模板,因为 Vue 不是一个基于字符串的模板引擎。"

意思是:

  • 有些人可能想用字符串拼接出 <MyComponent /> 然后塞进 v-html,希望 Vue 能识别并渲染这个组件。

  • 不行v-html 只是把字符串当作普通 HTML 插入,Vue 不会解析其中的指令或组件。

  • 要动态生成组件,应该使用 Vue 的组件系统(如 <component :is="...">),而不是字符串拼接。

六、总结

方法 作用 安全风险 能否放组件
{``{ rawHtml }} 纯文本显示,HTML 标签被转义 安全
v-html="rawHtml" 真正解析 HTML 标签 (易导致 XSS) 否(只认原生 HTML)

一句话记忆:

默认用双大括号,绝对安全;只有在你 100% 确定内容是可信的 HTML 时,才用 v-html,且不能用它来拼 Vue 组件。

Attribute 绑定

双大括号不能在 HTML attributes 中使用。想要响应式地绑定一个 attribute,应该使用 v-bind 指令

html 复制代码
<div v-bind:id="dynamicId"></div>

v-bind 指令指示 Vue 将元素的 id attribute 与组件的 dynamicId 属性保持一致。如果绑定的值是 null 或者 undefined,那么该 attribute 将会从渲染的元素上移除。

简写

因为 v-bind 非常常用,我们提供了特定的简写语法:

html 复制代码
<div :id="dynamicId"></div>

开头为 : 的 attribute 可能和一般的 HTML attribute 看起来不太一样,但它的确是合法的 attribute 名称字符,并且所有支持 Vue 的浏览器都能正确解析它。此外,他们不会出现在最终渲染的 DOM 中。简写语法是可选的,但相信在你了解了它更多的用处后,你应该会更喜欢它。

接下来的指引中,我们都将在示例中使用简写语法,因为这是在实际开发中更常见的用法。

同名简写

  • 仅支持 3.4 版本及以上

如果 attribute 的名称与绑定的 JavaScript 变量的名称相同,那么可以进一步简化语法,省略 attribute 值:

html 复制代码
<!-- 与 :id="id" 相同 -->
<div :id></div>

<!-- 这也同样有效 -->
<div v-bind:id></div>

这与在 JavaScript 中声明对象时使用的属性简写语法类似。请注意,这是一个只在 Vue 3.4 及以上版本中可用的特性。

布尔型 Attribute

布尔型 attribute 依据 true / false 值来决定 attribute 是否应该存在于该元素上。disabled 就是最常见的例子之一。

v-bind 在这种场景下的行为略有不同:

html 复制代码
<button :disabled="isButtonDisabled">Button</button>

isButtonDisabled真值或一个空字符串 (即 <button disabled="">) 时,元素会包含这个 disabled attribute。而当其为其他假值时 attribute 将被忽略。

动态绑定多个值

如果你有像这样的一个包含多个 attribute 的 JavaScript 对象:

javascript 复制代码
const objectOfAttrs = {
  id: 'container',
  class: 'wrapper',
  style: 'background-color:green'
}

通过不带参数的 v-bind,你可以将它们绑定到单个元素上:

html 复制代码
<div v-bind="objectOfAttrs"></div>

理解:

一、为什么不能用双大括号 {``{ }} 来绑定属性?

html 复制代码
<!-- 错误示范 -->
<div id="{{ dynamicId }}"></div>

{``{ }} 只能用在元素内容 中(比如 <div>{``{ msg }}</div>),不能用在属性值里。因为浏览器解析 HTML 时,属性里的 {``{ }} 会被当作普通字符串,不会交给 Vue 处理。

正确方法 :使用 v-bind 指令。

html 复制代码
<div v-bind:id="dynamicId"></div>

这表示:把 id 属性与组件中 dynamicId 变量的值绑定 起来。当 dynamicId 变化时,id 属性会自动更新。

二、v-bind 的简写

v-bind 非常常用,所以 Vue 提供了简写 :(冒号)。

html 复制代码
<!-- 完整写法 -->
<div v-bind:id="dynamicId"></div>

<!-- 简写 -->
<div :id="dynamicId"></div>

:v-bind: 完全等价。

注意:虽然 : 看起来不像标准 HTML 属性,但浏览器能正常解析,且最终渲染的 DOM 中不会出现 :,只会出现展开后的真实属性。

三、同名简写(Vue 3.4+)

如果你要绑定的属性名变量名相同,可以进一步省略变量值:

javascript 复制代码
const id = 'myDiv'
html 复制代码
<!-- 完整写法 -->
<div :id="id"></div>

<!-- 同名简写 -->
<div :id></div>

这个语法就像 JavaScript 对象中的属性简写 { id } 相当于 { id: id }

只在 Vue 3.4 及以上版本可用。

四、布尔型属性(如 disabled

布尔属性只要出现在元素上就表示 true(无论值是什么)。

v-bind 做了智能处理:

html 复制代码
<button :disabled="isButtonDisabled">按钮</button>
  • 如果 isButtonDisabled真值true1"true" 等)或 空字符串""),则按钮会包含 disabled 属性 → 按钮禁用。

  • 如果 isButtonDisabled假值false0nullundefined),则 disabled 属性会被移除 → 按钮可用。

特殊情况:空字符串 "" 被视为真,因为 <button disabled=""> 在 HTML 中表示禁用。

五、动态绑定多个属性(批量绑定)

如果你有一个对象,里面包含多个属性名和值,可以使用无参数的 v-bind 一次性绑定到元素上。

javascript 复制代码
const objectOfAttrs = {
  id: 'container',
  class: 'wrapper',
  style: 'background-color:green'
}
html 复制代码
<div v-bind="objectOfAttrs"></div>

等价于:

html 复制代码
<div id="container" class="wrapper" style="background-color:green"></div>

这很方便,比如动态传递一个配置对象给组件。

六、一句话记忆

属性绑定用 v-bind(简写 :),不能使用双大括号。布尔属性会根据真假自动添加/移除属性。同名可省略值。多个属性可一次性绑定对象。

使用 JavaScript 表达式

至此,我们仅在模板中绑定了一些简单的属性名。但是 Vue 实际上在所有的数据绑定中都支持完整的 JavaScript 表达式:

javascript 复制代码
{{ number + 1 }}

{{ ok ? 'YES' : 'NO' }}

{{ message.split('').reverse().join('') }}

<div :id="`list-${id}`"></div>

这些表达式都会被作为 JavaScript ,以当前组件实例为作用域解析执行。

在 Vue 模板内,JavaScript 表达式可以被使用在如下场景上:

  • 在文本插值中 (双大括号)
  • 在任何 Vue 指令 (以 v- 开头的特殊 attribute) attribute 的值中

仅支持表达式

每个绑定仅支持单一表达式 ,也就是一段能够被求值的 JavaScript 代码。一个简单的判断方法是是否可以合法地写在 return 后面。

因此,下面的例子都是无效的:

javascript 复制代码
<!-- 这是一个语句,而非表达式 -->
{{ var a = 1 }}

<!-- 条件控制也不支持,请使用三元表达式 -->
{{ if (ok) { return message } }}

调用函数

可以在绑定的表达式中使用一个组件暴露的方法:

javascript 复制代码
<time :title="toTitleDate(date)" :datetime="date">
  {{ formatDate(date) }}
</time>

TIP:

绑定在表达式中的方法在组件每次更新时都会被重新调用,因此应该产生任何副作用,比如改变数据或触发异步操作。

受限的全局访问

模板中的表达式将被沙盒化,仅能够访问到有限的全局对象列表。该列表中会暴露常用的内置全局对象,比如 MathDate

没有显式包含在列表中的全局对象将不能在模板内表达式中访问,例如用户附加在 window 上的属性。然而,你也可以自行在 app.config.globalProperties 上显式地添加它们,供所有的 Vue 表达式使用。

理解:

一、模板里可以写简单的 JS 表达式

你可以在 {``{ }} 里或指令的属性值里(如 :id)写 JavaScript 表达式,而不是单纯的变量名。

允许的例子:

javascript 复制代码
{{ number + 1 }}              <!-- 加法 -->
{{ ok ? 'YES' : 'NO' }}       <!-- 三元运算符 -->
{{ message.split('').reverse().join('') }}   <!-- 调用方法 -->
<div :id="`list-${id}`"></div> <!-- 模板字符串 -->

Vue 会把这些表达式当作 JS 代码执行,执行时 this 指向当前组件实例(所以你能访问到 numberokmessageid 等数据)。

适用位置:

  • 双大括号内(文本插值)

  • 指令的值中,如 v-bind:属性="表达式"v-on:事件="表达式"

二、只能写"表达式",不能写"语句"

什么是表达式?

一段可以被求值并返回结果的代码。简单判断:能否写在 return 后面

javascript 复制代码
// ✅ 表达式
1 + 1
message.split('').reverse().join('')
ok ? 'YES' : 'NO'

// ❌ 不是表达式(是语句)
var a = 1
if (ok) { return message }

错误示例:

javascript 复制代码
<!-- 赋值语句,不行 -->
{{ var a = 1 }}

<!-- 条件语句,不行 -->
{{ if (ok) { return message } }}

如果想做条件判断,用三元表达式 ok ? message : '' 或者使用 Vue 提供的指令如 v-if

三、可以在表达式中调用组件暴露的方法

javascript 复制代码
<time :title="toTitleDate(date)" :datetime="date">
  {{ formatDate(date) }}
</time>

假设组件中有 toTitleDateformatDate 方法,模板里可以直接调用。

⚠️ 重要提醒:

这些方法在组件每次重新渲染时都会被调用 (比如数据变化导致视图更新)。

因此,这些方法不应该有副作用 (比如修改数据、发起异步请求)。它们应该是纯函数------只根据输入返回输出,不改变任何状态。

错误示范:

javascript 复制代码
methods: {
  formatDate(date) {
    this.count++  // ❌ 副作用:修改了数据,会导致无限循环或性能问题
    return date.toLocaleString()
  }
}

如果确实需要做异步或修改数据,应该放在生命周期钩子(如 mounted)或事件处理函数中,而不是模板表达式中。

四、模板中的表达式是"沙盒化"的

为了安全,Vue 限制了模板中能访问的全局对象。你不能 直接访问 window 上的自定义属性,例如:

vue

javascript 复制代码
{{ window.innerWidth }}   <!-- ❌ 报错,window 不可访问 -->
{{ localStorage.getItem('key') }}  <!-- ❌ 报错 -->

但一些常用的内置对象是允许的,比如 MathDateparseInt 等。

javascript 复制代码
{{ Math.max(1, 2) }}   <!-- ✅ 可以 -->
{{ Date.now() }}       <!-- ✅ 可以 -->

如果你确实需要暴露某个全局对象 (例如自己挂载到 window 上的工具库),可以通过 app.config.globalProperties 添加:

javascript 复制代码
// main.js
app.config.globalProperties.$myGlobal = someGlobalObject

然后在模板中可以用 $myGlobal 访问(但不推荐滥用,最好通过组件的数据和方法来间接使用)。

五、总结(一句话记忆)

Vue 模板中只能写单一表达式(能放在 return 后面的代码),不能写语句。可以调用组件的方法,但这些方法不应有副作用。表达式只能访问有限的全局对象(Math、Date 等),不能直接访问 window

指令 Directives

指令是带有 v- 前缀的特殊 attribute。Vue 提供了许多内置指令,包括上面我们所介绍的 v-bindv-html

指令 attribute 的期望值为一个 JavaScript 表达式 (除了少数几个例外,即之后要讨论到的 v-forv-onv-slot)。一个指令的任务是在其表达式的值变化时响应式地更新 DOM。以 v-if 为例:

html 复制代码
<p v-if="seen">Now you see me</p>

这里,v-if 指令会基于表达式 seen 的值的真假来移除/插入该 <p> 元素。

参数 Arguments

某些指令会需要一个"参数",在指令名后通过一个冒号隔开做标识。例如用 v-bind 指令来响应式地更新一个 HTML attribute:

html 复制代码
<a v-bind:href="url"> ... </a>

<!-- 简写 -->
<a :href="url"> ... </a>

这里 href 就是一个参数,它告诉 v-bind 指令将表达式 url 的值绑定到元素的 href attribute 上。在简写中,参数前的一切 (例如 v-bind:) 都会被缩略为一个 : 字符。

另一个例子是 v-on 指令,它将监听 DOM 事件:

html 复制代码
<a v-on:click="doSomething"> ... </a>

<!-- 简写 -->
<a @click="doSomething"> ... </a>

这里的参数是要监听的事件名称:clickv-on 有一个相应的缩写,即 @ 字符。我们之后也会讨论关于事件处理的更多细节。

动态参数

同样在指令参数上也可以使用一个 JavaScript 表达式,需要包含在一对方括号内:

html 复制代码
<!--
注意,参数表达式有一些约束,
参见下面"动态参数值的限制"与"动态参数语法的限制"章节的解释
-->
<a v-bind:[attributeName]="url"> ... </a>

<!-- 简写 -->
<a :[attributeName]="url"> ... </a>

这里的 attributeName 会作为一个 JavaScript 表达式被动态执行,计算得到的值会被用作最终的参数。举例来说,如果你的组件实例有一个数据属性 attributeName,其值为 "href",那么这个绑定就等价于 v-bind:href

相似地,你还可以将一个函数绑定到动态的事件名称上:

html 复制代码
<a v-on:[eventName]="doSomething"> ... </a>

<!-- 简写 -->
<a @[eventName]="doSomething"> ... </a>

在此示例中,当 eventName 的值是 "focus" 时,v-on:[eventName] 就等价于 v-on:focus

动态参数值的限制

动态参数中表达式的值应当是一个字符串,或者是 null。特殊值 null 意为显式移除该绑定。其他非字符串的值会触发警告。

动态参数语法的限制

动态参数表达式因为某些字符的缘故有一些语法限制,比如空格和引号,在 HTML attribute 名称中都是不合法的。例如下面的示例:

html 复制代码
<!-- 这会触发一个编译器警告 -->
<a :['foo' + bar]="value"> ... </a>

如果你需要传入一个复杂的动态参数,我们推荐使用计算属性替换复杂的表达式,也是 Vue 最基础的概念之一,我们很快就会讲到。

当使用 DOM 内嵌模板 (直接写在 HTML 文件里的模板) 时,我们需要避免在名称中使用大写字母,因为浏览器会强制将其转换为小写:

html 复制代码
<a :[someAttr]="value"> ... </a>

上面的例子将会在 DOM 内嵌模板中被转换为 :[someattr]。如果你的组件拥有 "someAttr" 属性而非 "someattr",这段代码将不会工作。单文件组件内的模板受此限制。

修饰符 Modifiers

修饰符是以点开头的特殊后缀,表明指令需要以一些特殊的方式被绑定。例如 .prevent 修饰符会告知 v-on 指令对触发的事件调用 event.preventDefault()

html 复制代码
<form @submit.prevent="onSubmit">...</form>

之后在讲到 v-onv-model 的功能时,你将会看到其他修饰符的例子。

最后,在这里你可以直观地看到完整的指令语法:

理解:

一、指令的完整结构(看图片)

复制代码
v-on:submit.prevent="onSubmit"
│    │      │        │
│    │      │        └─ 值(JavaScript 表达式)
│    │      └─ 修饰符(以点开头,如 .prevent)
│    └─ 参数(冒号后面的部分,如 submit)
└─ 指令名(v- 开头,可简写)
  1. 指令名 (Directive Name)
  • 就是 v- 开头的那个名字,比如 v-bindv-onv-if

  • 常用指令有简写:

    • v-bind::

    • v-on:@

例子

v-bind:href="url" 可以简写为 :href="url"

v-on:click="doIt" 可以简写为 @click="doIt"

  1. 参数 (Argument)
  • 写在指令名后面,用冒号 : 隔开。

  • 它的作用是告诉指令"你要操作哪个具体的东西"。

  • 例如:

    • v-bind:href 中的 href 表示"要绑定的是 href 属性"

    • v-on:click 中的 click 表示"要监听的是 click 事件"

动态参数:参数可以是 JS 表达式,放在方括号里。

html 复制代码
<a :[attributeName]="url">   <!-- attributeName 是变量,值为 'href' 时等价于 :href="url" -->
<button @[eventName]="doIt"> <!-- eventName 是变量,值为 'click' 时等价于 @click="doIt" -->

限制 :动态参数的值必须是字符串或 null,不能有空格、引号等特殊字符。在 HTML 文件中直接写模板时,不能用大写字母(会被浏览器转成小写)。

  1. 修饰符 (Modifier)
  • 以点 . 开头,写在参数后面(或直接写在指令名后面,如果没有参数)。

  • 作用:让指令以特殊方式处理。例如:

    • .prevent:调用 event.preventDefault(),阻止表单提交的默认行为。

    • .stop:调用 event.stopPropagation(),阻止事件冒泡。

    • .once:事件只触发一次。

例子

html 复制代码
<form @submit.prevent="onSubmit">   <!-- 提交时不刷新页面 -->
<a @click.stop="doThis">            <!-- 点击事件不会向上冒泡 -->
  1. 值 (Value)
  • 等号后面的部分,通常是一个 JavaScript 表达式 (像 count + 1user.nameok ? 'yes' : 'no' 等)。

  • 表达式的求值结果会被指令使用:

    • v-if="seen" → 根据 seen 的布尔值决定是否显示元素。

    • :disabled="isButtonDisabled" → 根据 isButtonDisabled 的真假决定是否添加 disabled 属性。

例外 :少数指令(如 v-forv-onv-slot)的值的语法略有不同,但大多数指令都要求是 JS 表达式。

二、指令的执行逻辑

一句话:当表达式的值变化时,Vue 会自动执行指令所对应的 DOM 操作

例如:

html 复制代码
<p v-if="seen">你看到我了</p>
  • 如果 seen 变成 false,Vue 会从 DOM 中移除这个 <p> 元素。

  • 如果 seen 变回 true,Vue 会重新插入这个 <p> 元素。

你不需要手动写 if (seen) { 插入p } else { 删除p },Vue 替你做了。

三、完整示例(结合所有部分)

html 复制代码
<form @submit.prevent="onSubmit">
  <input :disabled="isSending" type="text" v-model="name">
  <button :class="buttonClass">提交</button>
</form>
  • @submit.prevent → 监听 submit 事件,加上 .prevent 修饰符阻止页面刷新,执行 onSubmit 方法。

  • :disabled="isSending" → 动态绑定 disabled 属性,当 isSendingtrue 时禁用输入框。

  • v-model="name" → 双向绑定输入框的值到变量 name

  • :class="buttonClass" → 动态绑定 CSS 类名(buttonClass 可以是字符串、数组或对象)。

四、易错点提醒

  1. 值必须是表达式,不能是语句

    错误:v-if="let a = 1"

    正确:v-if="a > 0"

  2. 动态参数不能用空格或引号

    错误::['foo' + 'bar']="value" ❌(会编译警告)

    正确:使用计算属性代替复杂的动态参数。

  3. 在 HTML 文件中直接写模板时,动态参数不要用大写

    :[someAttr]="value" → 浏览器会变成 :[someattr],注意大小写问题。

  4. 修饰符可以连用

    @click.stop.prevent="doIt" → 既阻止冒泡又阻止默认行为。

五、一句话总结

指令(v-) = 告诉 Vue "你要做什么" + 参数(:)告诉 Vue "对哪个属性/事件做" + 修饰符(.)告诉 Vue "怎么做"(如阻止冒泡) + 值(=)告诉 Vue "依据哪个数据来做"。

最常用的就是 v-bind(简写 :)和 v-on(简写 @)。

响应式基础

声明响应式状态

ref()

在组合式 API 中,推荐使用 ref() 函数来声明响应式状态:

javascript 复制代码
import { ref } from 'vue'

const count = ref(0)

ref() 接收参数,并将其包裹在一个带有 .value 属性的 ref 对象中返回:

javascript 复制代码
const count = ref(0)

console.log(count) // { value: 0 }
console.log(count.value) // 0

count.value++
console.log(count.value) // 1

参考:为 refs 标注类型

要在组件模板中访问 ref,请从组件的 setup() 函数中声明并返回它们:

javascript 复制代码
import { ref } from 'vue'

export default {
  // `setup` 是一个特殊的钩子,专门用于组合式 API。
  setup() {
    const count = ref(0)

    // 将 ref 暴露给模板
    return {
      count
    }
  }
}
html 复制代码
<div>{{ count }}</div>

注意,在模板中使用 ref 时,我们 需要附加 .value。为了方便起见,当在模板中使用时,ref 会自动解包 (有一些注意事项)。

你也可以直接在事件监听器中改变一个 ref:

html 复制代码
<button @click="count++">
  {{ count }}
</button>

对于更复杂的逻辑,我们可以在同一作用域内声明更改 ref 的函数,并将它们作为方法与状态一起公开:

javascript 复制代码
import { ref } from 'vue'

export default {
  setup() {
    const count = ref(0)

    function increment() {
      // 在 JavaScript 中需要 .value
      count.value++
    }

    // 不要忘记同时暴露 increment 函数
    return {
      count,
      increment
    }
  }
}

然后,暴露的方法可以被用作事件监听器:

html 复制代码
<button @click="increment">
  {{ count }}
</button>

这里是 Codepen 上的例子,没有使用任何构建工具。

<script setup>

setup() 函数中手动暴露大量的状态和方法非常繁琐。幸运的是,我们可以通过使用单文件组件 (SFC) 来避免这种情况。我们可以使用 <script setup> 来大幅度地简化代码:

javascript 复制代码
<script setup>
import { ref } from 'vue'

const count = ref(0)

function increment() {
  count.value++
}
</script>

<template>
  <button @click="increment">
    {{ count }}
  </button>
</template>

<script setup> 中的顶层的导入、声明的变量和函数可在同一组件的模板中直接使用。你可以理解为模板是在同一作用域内声明的一个 JavaScript 函数------它自然可以访问与它一起声明的所有内容。

TIP:

在指南的后续章节中,我们基本上都会在组合式 API 示例中使用单文件组件 + <script setup> 的语法,因为大多数 Vue 开发者都会这样使用。

如果你没有使用单文件组件,你仍然可以在 setup() 选项中使用组合式 API。

为什么要使用 ref?

你可能会好奇:为什么我们需要使用带有 .value 的 ref,而不是普通的变量?为了解释这一点,我们需要简单地讨论一下 Vue 的响应式系统是如何工作的。

当你在模板中使用了一个 ref,然后改变了这个 ref 的值时,Vue 会自动检测到这个变化,并且相应地更新 DOM。这是通过一个基于依赖追踪的响应式系统实现的。当一个组件首次渲染时,Vue 会追踪 在渲染过程中使用的每一个 ref。然后,当一个 ref 被修改时,它会触发追踪它的组件的一次重新渲染。

在标准的 JavaScript 中,检测普通变量的访问或修改是行不通的。然而,我们可以通过 getter 和 setter 方法来拦截对象属性的 get 和 set 操作。

.value 属性给予了 Vue 一个机会来检测 ref 何时被访问或修改。在其内部,Vue 在它的 getter 中执行追踪,在它的 setter 中执行触发。从概念上讲,你可以将 ref 看作是一个像这样的对象:

javascript 复制代码
// 伪代码,不是真正的实现
const myRef = {
  _value: 0,
  get value() {
    track()
    return this._value
  },
  set value(newValue) {
    this._value = newValue
    trigger()
  }
}

另一个 ref 的好处是,与普通变量不同,你可以将 ref 传递给函数,同时保留对最新值和响应式连接的访问。当将复杂的逻辑重构为可重用的代码时,这将非常有用。

该响应性系统在深入响应式系统章节中有更详细的讨论。

深层响应性

Ref 可以持有任何类型的值,包括深层嵌套的对象、数组或者 JavaScript 内置的数据结构,比如 Map

Ref 会使它的值具有深层响应性。这意味着即使改变嵌套对象或数组时,变化也会被检测到:

javascript 复制代码
import { ref } from 'vue'

const obj = ref({
  nested: { count: 0 },
  arr: ['foo', 'bar']
})

function mutateDeeply() {
  // 以下都会按照期望工作
  obj.value.nested.count++
  obj.value.arr.push('baz')
}

非原始值将通过 reactive() 转换为响应式代理,该函数将在后面讨论。

也可以通过 shallow ref 来放弃深层响应性。对于浅层 ref,只有 .value 的访问会被追踪。浅层 ref 可以用于避免对大型数据的响应性开销来优化性能、或者有外部库管理其内部状态的情况。

阅读更多:

DOM 更新时机

当你修改了响应式状态时,DOM 会被自动更新。但是需要注意的是,DOM 更新不是同步的。Vue 会在"next tick"更新周期中缓冲所有状态的修改,以确保不管你进行了多少次状态修改,每个组件都只会被更新一次。

要等待 DOM 更新完成后再执行额外的代码,可以使用 nextTick() 全局 API:

javascript 复制代码
import { nextTick } from 'vue'

async function increment() {
  count.value++
  await nextTick()
  // 现在 DOM 已经更新了
}

reactive()

还有另一种声明响应式状态的方式,即使用 reactive() API。与将内部值包装在特殊对象中的 ref 不同,reactive() 将使对象本身具有响应性:

javascript 复制代码
import { reactive } from 'vue'

const state = reactive({ count: 0 })

参考:为 reactive() 标注类型

在模板中使用:

html 复制代码
<button @click="state.count++">
  {{ state.count }}
</button>

响应式对象是 JavaScript 代理,其行为就和普通对象一样。不同的是,Vue 能够拦截对响应式对象所有属性的访问和修改,以便进行依赖追踪和触发更新。

reactive() 将深层地转换对象:当访问嵌套对象时,它们也会被 reactive() 包装。当 ref 的值是一个对象时,ref() 也会在内部调用它。与浅层 ref 类似,这里也有一个 shallowReactive() API 可以选择退出深层响应性。

Reactive Proxy vs. Original

值得注意的是,reactive() 返回的是一个原始对象的 Proxy,它和原始对象是不相等的:

javascript 复制代码
const raw = {}
const proxy = reactive(raw)

// 代理对象和原始对象不是全等的
console.log(proxy === raw) // false

只有代理对象是响应式的,更改原始对象不会触发更新。因此,使用 Vue 的响应式系统的最佳实践是仅使用你声明对象的代理版本

为保证访问代理的一致性,对同一个原始对象调用 reactive() 会总是返回同样的代理对象,而对一个已存在的代理对象调用 reactive() 会返回其本身:

javascript 复制代码
// 在同一个对象上调用 reactive() 会返回相同的代理
console.log(reactive(raw) === proxy) // true

// 在一个代理上调用 reactive() 会返回它自己
console.log(reactive(proxy) === proxy) // true

这个规则对嵌套对象也适用。依靠深层响应性,响应式对象内的嵌套对象依然是代理:

javascript 复制代码
const proxy = reactive({})

const raw = {}
proxy.nested = raw

console.log(proxy.nested === raw) // false

reactive() 的局限性

reactive() API 有一些局限性:

  1. 有限的值类型 :它只能用于对象类型 (对象、数组和如 MapSet 这样的集合类型)。它不能持有如 stringnumberboolean 这样的原始类型

  2. 不能替换整个对象:由于 Vue 的响应式跟踪是通过属性访问实现的,因此我们必须始终保持对响应式对象的相同引用。这意味着我们不能轻易地"替换"响应式对象,因为这样的话与第一个引用的响应性连接将丢失:

    js

    复制代码
    let state = reactive({ count: 0 })
    
    // 上面的 ({ count: 0 }) 引用将不再被追踪
    // (响应性连接已丢失!)
    state = reactive({ count: 1 })
  3. 对解构操作不友好:当我们将响应式对象的原始类型属性解构为本地变量时,或者将该属性传递给函数时,我们将丢失响应性连接:

    js

    复制代码
    const state = reactive({ count: 0 })
    
    // 当解构时,count 已经与 state.count 断开连接
    let { count } = state
    // 不会影响原始的 state
    count++
    
    // 该函数接收到的是一个普通的数字
    // 并且无法追踪 state.count 的变化
    // 我们必须传入整个对象以保持响应性
    callSomeFunction(state.count)

由于这些限制,我们建议使用 ref() 作为声明响应式状态的主要 API。

额外的 ref 解包细节

作为 reactive 对象的属性

一个 ref 会在作为响应式对象的属性被访问或修改时自动解包。换句话说,它的行为就像一个普通的属性:

javascript 复制代码
const count = ref(0)
const state = reactive({
  count
})

console.log(state.count) // 0

state.count = 1
console.log(count.value) // 1

如果将一个新的 ref 赋值给一个关联了已有 ref 的属性,那么它会替换掉旧的 ref:

javascript 复制代码
const otherCount = ref(2)

state.count = otherCount
console.log(state.count) // 2
// 原始 ref 现在已经和 state.count 失去联系
console.log(count.value) // 1

只有当嵌套在一个深层响应式对象内时,才会发生 ref 解包。当其作为浅层响应式对象的属性被访问时不会解包。

数组和集合的注意事项

与 reactive 对象不同的是,当 ref 作为响应式数组或原生集合类型 (如 Map) 中的元素被访问时,它不会被解包:

javascript 复制代码
const books = reactive([ref('Vue 3 Guide')])
// 这里需要 .value
console.log(books[0].value)

const map = reactive(new Map([['count', ref(0)]]))
// 这里需要 .value
console.log(map.get('count').value)

在模板中解包的注意事项

在模板渲染上下文中,只有顶级的 ref 属性才会被解包。

在下面的例子中,countobject 是顶级属性,但 object.id 不是:

javascript 复制代码
const count = ref(0)
const object = { id: ref(1) }

因此,这个表达式按预期工作:

javascript 复制代码
{{ count + 1 }}

...但这个不会

javascript 复制代码
{{ object.id + 1 }}

渲染的结果将是 [object Object]1,因为在计算表达式时 object.id 没有被解包,仍然是一个 ref 对象。为了解决这个问题,我们可以将 id 解构为一个顶级属性:

javascript 复制代码
const { id } = object
javascript 复制代码
{{ id + 1 }}

现在渲染的结果将是 2

另一个需要注意的点是,如果 ref 是文本插值的最终计算值 (即 {``{ }} 标签),那么它将被解包,因此以下内容将渲染为 1

javascript 复制代码
{{ object.id }}

该特性仅仅是文本插值的一个便利特性,等价于 {``{ object.id.value }}

解读:

一、ref() 是什么?

一句话ref() 把一个普通值变成"响应式的引用对象",这样 Vue 才能追踪它的变化并自动更新页面。

javascript 复制代码
import { ref } from 'vue'
const count = ref(0)      // count 是一个对象 { value: 0 }
  • JavaScript 中读取/修改要用 .valuecount.value++

  • 模板 中直接用变量名:{``{ count }}(Vue 自动解包)

为什么需要 .value

因为普通 JS 变量无法被 Vue 追踪。通过 .value 的 getter/setter,Vue 才能知道何时访问了数据、何时修改了数据,从而触发更新。

二、ref() 的深层响应性

ref 可以包裹对象、数组等复杂类型,且是深层响应式------修改嵌套属性也会触发更新。

javascript 复制代码
const obj = ref({ nested: { count: 0 } })
obj.value.nested.count++   // 页面会更新

如果数据很大且不需要深层响应(比如长列表只读),可以用 shallowRef 优化性能。

三、reactive() 是什么?

另一种声明响应式状态的方式 ,直接让对象本身 变成响应式(无需 .value)。

javascript 复制代码
import { reactive } from 'vue'
const state = reactive({ count: 0 })
state.count++   // 直接修改,不用 .value

局限性(与 ref 对比)

  1. 只能用于对象(包括数组、Map、Set),不能用于原始类型(string、number 等)。

  2. 不能替换整个对象state = reactive({ count: 1 }) 会丢失响应性。

  3. 解构会丢失响应性let { count } = state 之后 count 就不再是响应式的了。

因此官方推荐:默认使用 ref() ,除非你有明确的理由(比如想避免到处写 .value)且知道 reactive 的限制。

四、refreactive 的自动解包关系

  • ref 作为 reactive 对象的属性时 ,会自动解包:state.count 直接是数值,无需 .value

  • 在数组或原生集合(Map、Set)中ref 不会自动解包 ,必须用 .valuebooks[0].value

  • 在模板中 ,只有顶级 ref 才会解包。例如 { id: ref(1) },直接 {``{ object.id }} 会显示 [object Object],需要解构成顶级变量。

五、DOM 更新时机与 nextTick

Vue 的 DOM 更新是异步 的(缓冲所有修改,批量更新)。如果你需要在 DOM 更新后执行代码,用 nextTick

javascript 复制代码
import { nextTick } from 'vue'

async function increment() {
  count.value++
  await nextTick()
  // 这里 DOM 已经更新完毕
}

六、<script setup> 简化写法

之前需要用 setup() 函数并手动返回所有东西,现在用 <script setup> 更简洁:

html 复制代码
<script setup>
import { ref } from 'vue'
const count = ref(0)
function increment() { count.value++ }
</script>

<template>
  <button @click="increment">{{ count }}</button>
</template>

顶层声明的变量、函数自动暴露给模板,不用写 return

七、总结:什么时候用 ref,什么时候用 reactive

场景 推荐
声明原始类型(数字、字符串、布尔) ref
声明对象/数组,且需要整体替换 ref(因为可以 obj.value = newObj
声明对象/数组,且不需要整体替换,也不想写 .value reactive(但要小心解构和替换)
需要将响应式数据传入函数并保持连接 ref(传递 ref 对象本身,而不是 .value

官方建议:默认用 ref,除非你熟悉 reactive 的限制且不需要整体替换。

如果你对这些概念还觉得抽象,可以记住一个简单的经验法则:

<script setup> 里,用 const count = ref(0)count.value++;在模板里,直接写 {``{ count }}@click="count++" 这样 90% 的场景都够了。

计算属性

基础示例

模板中的表达式虽然方便,但也只能用来做简单的操作。如果在模板中写太多逻辑,会让模板变得臃肿,难以维护。比如说,我们有这样一个包含嵌套数组的对象:

javascript 复制代码
const author = reactive({
  name: 'John Doe',
  books: [
    'Vue 2 - Advanced Guide',
    'Vue 3 - Basic Guide',
    'Vue 4 - The Mystery'
  ]
})

我们想根据 author 是否已有一些书籍来展示不同的信息:

html 复制代码
<p>Has published books:</p>
<span>{{ author.books.length > 0 ? 'Yes' : 'No' }}</span>

这里的模板看起来有些复杂。我们必须认真看好一会儿才能明白它的计算依赖于 author.books。更重要的是,如果在模板中需要不止一次这样的计算,我们可不想将这样的代码在模板里重复好多遍。

因此我们推荐使用计算属性来描述依赖响应式状态的复杂逻辑。这是重构后的示例:

javascript 复制代码
<script setup>
import { reactive, computed } from 'vue'

const author = reactive({
  name: 'John Doe',
  books: [
    'Vue 2 - Advanced Guide',
    'Vue 3 - Basic Guide',
    'Vue 4 - The Mystery'
  ]
})

// 一个计算属性 ref
const publishedBooksMessage = computed(() => {
  return author.books.length > 0 ? 'Yes' : 'No'
})
</script>

<template>
  <p>Has published books:</p>
  <span>{{ publishedBooksMessage }}</span>
</template>

在演练场中尝试一下

我们在这里定义了一个计算属性 publishedBooksMessagecomputed() 方法期望接收一个 getter 函数,返回值为一个计算属性 ref 。和其他一般的 ref 类似,你可以通过 publishedBooksMessage.value 访问计算结果。计算属性 ref 也会在模板中自动解包,因此在模板表达式中引用时无需添加 .value

Vue 的计算属性会自动追踪响应式依赖。它会检测到 publishedBooksMessage 依赖于 author.books,所以当 author.books 改变时,任何依赖于 publishedBooksMessage 的绑定都会同时更新。

也可参考:为计算属性标注类型

计算属性缓存 vs 方法

你可能注意到我们在表达式中像这样调用一个函数也会获得和计算属性相同的结果:

html 复制代码
<p>{{ calculateBooksMessage() }}</p>
javascript 复制代码
// 组件中
function calculateBooksMessage() {
  return author.books.length > 0 ? 'Yes' : 'No'
}

若我们将同样的函数定义为一个方法而不是计算属性,两种方式在结果上确实是完全相同的,然而,不同之处在于计算属性值会基于其响应式依赖被缓存 。一个计算属性仅会在其响应式依赖更新时才重新计算。这意味着只要 author.books 不改变,无论多少次访问 publishedBooksMessage 都会立即返回先前的计算结果,而不用重复执行 getter 函数。

这也解释了为什么下面的计算属性永远不会更新,因为 Date.now() 并不是一个响应式依赖:

javascript 复制代码
const now = computed(() => Date.now())

相比之下,方法调用总是会在重渲染发生时再次执行函数。

为什么需要缓存呢?想象一下我们有一个非常耗性能的计算属性 list,需要循环一个巨大的数组并做许多计算逻辑,并且可能也有其他计算属性依赖于 list。没有缓存的话,我们会重复执行非常多次 list 的 getter,然而这实际上没有必要!如果你确定不需要缓存,那么也可以使用方法调用。

可写计算属性

计算属性默认是只读的。当你尝试修改一个计算属性时,你会收到一个运行时警告。只在某些特殊场景中你可能才需要用到"可写"的属性,你可以通过同时提供 getter 和 setter 来创建:

javascript 复制代码
<script setup>
import { ref, computed } from 'vue'

const firstName = ref('John')
const lastName = ref('Doe')

const fullName = computed({
  // getter
  get() {
    return firstName.value + ' ' + lastName.value
  },
  // setter
  set(newValue) {
    // 注意:我们这里使用的是解构赋值语法
    [firstName.value, lastName.value] = newValue.split(' ')
  }
})
</script>

现在当你再运行 fullName.value = 'John Doe' 时,setter 会被调用而 firstNamelastName 会随之更新。

获取上一个值

  • 仅 3.4+ 支持

如果需要,可以通过访问计算属性的 getter 的第一个参数来获取计算属性返回的上一个值:

javascript 复制代码
<script setup>
import { ref, computed } from 'vue'

const count = ref(2)

// 这个计算属性在 count 的值小于或等于 3 时,将返回 count 的值。
// 当 count 的值大于等于 4 时,将会返回满足我们条件的最后一个值
// 直到 count 的值再次小于或等于 3 为止。
const alwaysSmall = computed((previous) => {
  if (count.value <= 3) {
    return count.value
  }

  return previous
})
</script>

如果你正在使用可写的计算属性的话:

javascript 复制代码
<script setup>
import { ref, computed } from 'vue'

const count = ref(2)

const alwaysSmall = computed({
  get(previous) {
    if (count.value <= 3) {
      return count.value
    }

    return previous
  },
  set(newValue) {
    count.value = newValue * 2
  }
})
</script>

最佳实践

Getter 不应有副作用

计算属性的 getter 应只做计算而没有任何其他的副作用,这一点非常重要,请务必牢记。举例来说,不要改变其他状态、在 getter 中做异步请求或者更改 DOM !一个计算属性的声明中描述的是如何根据其他值派生一个值。因此 getter 的职责应该仅为计算和返回该值。在之后的指引中我们会讨论如何使用侦听器根据其他响应式状态的变更来创建副作用。

避免直接修改计算属性值

从计算属性返回的值是派生状态。可以把它看作是一个"临时快照",每当源状态发生变化时,就会创建一个新的快照。更改快照是没有意义的,因此计算属性的返回值应该被视为只读的,并且永远不应该被更改------应该更新它所依赖的源状态以触发新的计算。

理解:

一、为什么需要计算属性?

问题:模板里写复杂逻辑会让代码难以阅读和维护。

html 复制代码
<span>{{ author.books.length > 0 ? 'Yes' : 'No' }}</span>

这个三元表达式还好,但如果逻辑更复杂(比如多个条件、函数调用),模板就会变得臃肿。

解决 :把这种依赖其它数据的计算逻辑抽出来,放到 计算属性 里。

html 复制代码
<script setup>
import { computed } from 'vue'
const publishedBooksMessage = computed(() => {
  return author.books.length > 0 ? 'Yes' : 'No'
})
</script>

<template>
  <span>{{ publishedBooksMessage }}</span>
</template>

现在模板只需要写 publishedBooksMessage,清晰明了。

二、计算属性 vs 普通方法

你可能想:用函数也一样啊?

html 复制代码
<template>
  <span>{{ getMessage() }}</span>
</template>

<script setup>
function getMessage() {
  return author.books.length > 0 ? 'Yes' : 'No'
}
</script>

区别在于:缓存

  • 计算属性 :只有当它依赖的数据(这里是 author.books)发生变化时,才会重新计算。否则多次访问直接返回之前缓存的结果。

  • 方法 :每次重新渲染(比如页面其他数据变化导致组件更新)时,都会重新执行函数,即使依赖的数据没变。

举例

如果 author.books 没变,但页面上有 {``{ now }}(一个时间戳)变化导致组件重新渲染:

  • 计算属性 publishedBooksMessage 不会重新计算,直接返回上次结果。

  • 方法 getMessage() 会重新执行一次(浪费性能)。

对于复杂计算(比如遍历大数组),缓存带来的性能提升非常明显。

什么时候用方法?

当你不想要缓存,或者逻辑依赖非响应式数据(比如 Date.now())时,就使用方法。

三、计算属性默认只读

计算属性返回的是一个只读的 ref 。如果你尝试修改它(如 publishedBooksMessage.value = 'No'),Vue 会警告。

如果想修改计算属性怎么办?

那就需要"可写计算属性",同时提供 getset 方法。

javascript 复制代码
const firstName = ref('John')
const lastName = ref('Doe')

const fullName = computed({
  get() {
    return firstName.value + ' ' + lastName.value
  },
  set(newValue) {
    [firstName.value, lastName.value] = newValue.split(' ')
  }
})

现在执行 fullName.value = 'Jane Smith',会触发 set 方法,自动更新 firstNamelastName

注意:你仍然是通过修改源数据(firstName/lastName)来触发变更,而不是直接修改计算属性返回的值(它只是个"派生的快照")。

四、获取上一个值(Vue 3.4+)

计算属性的 getter 可以接收一个参数 previous,表示上一次计算的结果

javascript 复制代码
const alwaysSmall = computed((previous) => {
  if (count.value <= 3) {
    return count.value
  }
  return previous   // 超过 3 时,保持上一次的值
})

这个特性可以用来根据前后变化做决策,比如限制数值范围、忽略某些突变等。

五、最佳实践(重要提醒)

1. getter 不能有副作用

  • ❌ 不要在计算属性的 getter 里修改其它状态、发送网络请求、操作 DOM。

  • ✅ 只做计算并返回一个新值。

原因:计算属性可能在你不注意的时候被多次调用(比如依赖变化时),副作用会导致难以预料的行为。

2. 不要直接修改计算属性的值

计算属性是派生状态,你应该修改它的源依赖 ,而不是试图修改计算结果。

比如,要改变 fullName,就修改 firstNamelastName,而不是写 fullName.value = 'xxx'(除非你定义了 setter)。

六、总结(一句话记忆)

计算属性 = 根据响应式数据自动派生的值 + 缓存结果(依赖不变则不重新计算)。

默认只读,需要修改时可定义 setter。getter 内不要做副作用,永远修改源数据而不是计算属性本身。

对比表格

特性 计算属性 (computed) 普通方法
缓存 ✅ 是 ❌ 否
适用于复杂计算 ✅ 非常合适 ⚠️ 浪费性能
依赖非响应式数据 ❌ 不会更新 ✅ 每次都会执行
写法 computed(() => 值) function fn() { return 值 }

如果你是初学者,记住:任何需要从现有数据派生的值,优先考虑计算属性;只有当你明确不需要缓存(比如依赖时间戳)时才用方法。

Class 与 Style 绑定

数据绑定的一个常见需求场景是操纵元素的 CSS class 列表和内联样式。因为 classstyle 都是 attribute,我们可以和其他 attribute 一样使用 v-bind 将它们和动态的字符串绑定。但是,在处理比较复杂的绑定时,通过拼接生成字符串是麻烦且易出错的。因此,Vue 专门为 classstylev-bind 用法提供了特殊的功能增强。除了字符串外,表达式的值也可以是对象或数组。

绑定 HTML class

绑定对象

我们可以给 :class (v-bind:class 的缩写) 传递一个对象来动态切换 class:

html 复制代码
<div :class="{ active: isActive }"></div>

上面的语法表示 active 是否存在取决于数据属性 isActive真假值

你可以在对象中写多个字段来操作多个 class。此外,:class 指令也可以和一般的 class attribute 共存。举例来说,下面这样的状态:

javascript 复制代码
const isActive = ref(true)
const hasError = ref(false)

配合以下模板:

html 复制代码
<div
  class="static"
  :class="{ active: isActive, 'text-danger': hasError }"
></div>

渲染的结果会是:

html 复制代码
<div class="static active"></div>

isActive 或者 hasError 改变时,class 列表会随之更新。举例来说,如果 hasError 变为 true,class 列表也会变成 "static active text-danger"

绑定的对象并不一定需要写成内联字面量的形式,也可以直接绑定一个对象:

javascript 复制代码
const classObject = reactive({
  active: true,
  'text-danger': false
})
html 复制代码
<div :class="classObject"></div>

这将渲染:

html 复制代码
<div class="active"></div>

我们也可以绑定一个返回对象的计算属性。这是一个常见且很有用的技巧:

javascript 复制代码
const isActive = ref(true)
const error = ref(null)

const classObject = computed(() => ({
  active: isActive.value && !error.value,
  'text-danger': error.value && error.value.type === 'fatal'
}))
html 复制代码
<div :class="classObject"></div>

绑定数组

我们可以给 :class 绑定一个数组来渲染多个 CSS class:

javascript 复制代码
const activeClass = ref('active')
const errorClass = ref('text-danger')
html 复制代码
<div :class="[activeClass, errorClass]"></div>

渲染的结果是:

html 复制代码
<div class="active text-danger"></div>

如果你也想在数组中有条件地渲染某个 class,你可以使用三元表达式:

html 复制代码
<div :class="[isActive ? activeClass : '', errorClass]"></div>

errorClass 会一直存在,但 activeClass 只会在 isActive 为真时才存在。

然而,这可能在有多个依赖条件的 class 时会有些冗长。因此也可以在数组中嵌套对象:

html 复制代码
<div :class="[{ [activeClass]: isActive }, errorClass]"></div>

在组件上使用

本节假设你已经有 Vue 组件的知识基础。如果没有,你也可以暂时跳过,以后再阅读。

对于只有一个根元素的组件,当你使用了 class attribute 时,这些 class 会被添加到根元素上并与该元素上已有的 class 合并。

举例来说,如果你声明了一个组件名叫 MyComponent,模板如下:

html 复制代码
<!-- 子组件模板 -->
<p class="foo bar">Hi!</p>

在使用时添加一些 class:

html 复制代码
<!-- 在使用组件时 -->
<MyComponent class="baz boo" />

渲染出的 HTML 为:

html 复制代码
<p class="foo bar baz boo">Hi!</p>

Class 的绑定也是同样的:

html 复制代码
<MyComponent :class="{ active: isActive }" />

isActive 为真时,被渲染的 HTML 会是:

html 复制代码
<p class="foo bar active">Hi!</p>

如果你的组件有多个根元素,你将需要指定哪个根元素来接收这个 class。你可以通过组件的 $attrs 属性来指定接收的元素:

html 复制代码
<!-- MyComponent 模板使用 $attrs 时 -->
<p :class="$attrs.class">Hi!</p>
<span>This is a child component</span>
html 复制代码
<MyComponent class="baz" />

这将被渲染为:

html 复制代码
<p class="baz">Hi!</p>
<span>This is a child component</span>

你可以在透传 Attribute 一章中了解更多组件的 attribute 继承的细节。

绑定内联样式

绑定对象

:style 支持绑定 JavaScript 对象值,对应的是 HTML 元素的 style 属性

javascript 复制代码
const activeColor = ref('red')
const fontSize = ref(30)
html 复制代码
<div :style="{ color: activeColor, fontSize: fontSize + 'px' }"></div>

尽管推荐使用 camelCase,但 :style 也支持 kebab-cased 形式的 CSS 属性 key (对应其 CSS 中的实际名称),例如:

html 复制代码
<div :style="{ 'font-size': fontSize + 'px' }"></div>

直接绑定一个样式对象通常是一个好主意,这样可以使模板更加简洁:

javascript 复制代码
const styleObject = reactive({
  color: 'red',
  fontSize: '30px'
})
html 复制代码
<div :style="styleObject"></div>

同样的,如果样式对象需要更复杂的逻辑,也可以使用返回样式对象的计算属性。

:style 指令也可以和常规的 style attribute 共存,就像 :class

模板:

html 复制代码
<h1 style="color: red" :style="'font-size: 1em'">hello</h1>

这将被渲染为:

html 复制代码
<h1 style="color: red; font-size: 1em;">hello</h1>

绑定数组

我们还可以给 :style 绑定一个包含多个样式对象的数组。这些对象会被合并后渲染到同一元素上:

html 复制代码
<div :style="[baseStyles, overridingStyles]"></div>

自动前缀

当你在 :style 中使用了需要浏览器特殊前缀的 CSS 属性时,Vue 会自动为他们加上相应的前缀。Vue 是在运行时检查该属性是否支持在当前浏览器中使用。如果浏览器不支持某个属性,那么将尝试加上各个浏览器特殊前缀,以找到哪一个是被支持的。

样式多值

你可以对一个样式属性提供多个 (不同前缀的) 值,举例来说:

html 复制代码
<div :style="{ display: ['-webkit-box', '-ms-flexbox', 'flex'] }"></div>

数组仅会渲染浏览器支持的最后一个值。在这个示例中,在支持不需要特别前缀的浏览器中都会渲染为 display: flex

理解:

一、为什么需要特殊的 Class 与 Style 绑定?

正常情况下,你可以用 v-bind 把 class 写成字符串:

<div :class="'active text-danger'"></div>

但这就像你要穿衣服,却只能穿一件整套的连体衣(一个字符串里写死所有类名),想换一件就很麻烦。

Vue 提供了两种更灵活的方式:对象语法 (根据条件开关类)和 数组语法(组合多个类)。这样你就可以像搭积木一样动态地组合样式。

二、绑定 HTML Class(对象语法)

  1. 基本对象语法:根据条件开关类
html 复制代码
<div :class="{ active: isActive }"></div>
  • { active: isActive } 表示:如果 isActivetrue,就加上 active 这个 class;否则不加

  • 你可以在对象里写多个字段:{ active: isActive, 'text-danger': hasError }

  1. 与普通 class 共存
html 复制代码
<div class="static" :class="{ active: isActive }"></div>
  • 最终 class 会是 "static" 加上条件性的 "active",互不干扰。
  1. 绑定一个对象变量(而不是内联对象)
javascript 复制代码
const classObject = reactive({ active: true, 'text-danger': false })
html 复制代码
<div :class="classObject"></div>
  • 当你需要根据多个条件动态改变这个对象时很有用。
  1. 使用计算属性返回对象
javascript 复制代码
const isActive = ref(true)
const error = ref(null)
const classObject = computed(() => ({
  active: isActive.value && !error.value,
  'text-danger': error.value && error.value.type === 'fatal'
}))
  • 当逻辑复杂时,用计算属性可以清晰地表达"什么时候加 active"、"什么时候加 text-danger"。

三、绑定 HTML Class(数组语法)

  1. 基本数组语法:渲染多个确定的 class
javascript 复制代码
const activeClass = ref('active')
const errorClass = ref('text-danger')
html 复制代码
<div :class="[activeClass, errorClass]"></div>
  • 结果:class="active text-danger"

  • 数组里的元素可以来自变量,也可以直接写字符串::class="['active', errorClass]"

  1. 条件性 class(用三元表达式)
html 复制代码
<div :class="[isActive ? activeClass : '', errorClass]"></div>
  • 如果 isActive 为假,activeClass 那一项变成空字符串,不会渲染多余 class。
  1. 更优雅的条件 class(数组 + 对象)
html 复制代码
<div :class="[{ [activeClass]: isActive }, errorClass]"></div>
  • 这里 { [activeClass]: isActive } 表示:如果 isActive 为真,则加上名为 activeClass 变量值的 class。

四、在组件上使用 Class(单根元素组件)

假设你有一个子组件 MyComponent,它的模板是:

html 复制代码
<p class="foo bar">Hi!</p>

当你使用这个组件时:

html 复制代码
<MyComponent class="baz boo" />

渲染结果:

html 复制代码
<p class="foo bar baz boo">Hi!</p>
  • 组件根元素原有的 class 和你传进去的 class 会自动合并,不会覆盖。

如果组件有多个根元素 ,你需要告诉 Vue 把 class 放到哪个元素上,使用 $attrs.class

html 复制代码
<!-- MyComponent 模板 -->
<p :class="$attrs.class">Hi!</p>
<span>This is a child component</span>

这样 class 只会添加到那个 <p> 上。

五、绑定内联样式(:style

  1. 对象语法:写 CSS 属性
html 复制代码
<div :style="{ color: activeColor, fontSize: fontSize + 'px' }"></div>
  • 属性名推荐驼峰式(fontSize),也支持连字符('font-size')。

  • 值可以是变量,也可以拼接单位。

  1. 绑定一个样式对象
javascript 复制代码
const styleObject = reactive({ color: 'red', fontSize: '30px' })
html 复制代码
<div :style="styleObject"></div>
  • 让模板更干净。
  1. 数组语法:合并多个样式对象
html 复制代码
<div :style="[baseStyles, overridingStyles]"></div>
  • 后面的对象会覆盖前面相同的属性。
  1. 自动添加浏览器前缀
  • 比如你写 transform: rotate(10deg),Vue 会自动判断是否需要加 -webkit--ms- 等前缀。
  1. 多值(兼容不同浏览器)
html 复制代码
<div :style="{ display: ['-webkit-box', '-ms-flexbox', 'flex'] }"></div>
  • Vue 会从数组里挑出浏览器支持的最后那个值 来用。比如支持 flex 的浏览器就直接用 flex

六、总结(小白记忆口诀)

  • class 对象{ 类名: 条件 } ------ 想开关某个类,就用这个。

  • class 数组[变量1, 变量2] ------ 想组合多个类,就用这个。

  • style 对象{ 属性: 值 } ------ 动态改样式,和写 JS 对象一样。

  • 组件上:你传的 class 会合并到组件根元素上(如果是单根)。

  • 不用担心前缀:Vue 帮你自动处理兼容性。

最关键的一句话

:class:style 就是让我们可以用 JavaScript 的逻辑(对象、数组、条件)来动态控制 CSS,比拼接字符串聪明得多。

条件渲染

v-if

v-if 指令用于条件性地渲染一块内容。这块内容只会在指令的表达式返回真值时才被渲染。

html 复制代码
<h1 v-if="awesome">Vue is awesome!</h1>

v-else

你也可以使用 v-elsev-if 添加一个"else 区块"。

html 复制代码
<button @click="awesome = !awesome">Toggle</button>

<h1 v-if="awesome">Vue is awesome!</h1>
<h1 v-else>Oh no 😢</h1>

一个 v-else 元素必须跟在一个 v-if 或者 v-else-if 元素后面,否则它将不会被识别。

v-else-if

顾名思义,v-else-if 提供的是相应于 v-if 的"else if 区块"。它可以连续多次重复使用:

html 复制代码
<div v-if="type === 'A'">
  A
</div>
<div v-else-if="type === 'B'">
  B
</div>
<div v-else-if="type === 'C'">
  C
</div>
<div v-else>
  Not A/B/C
</div>

v-else 类似,一个使用 v-else-if 的元素必须紧跟在一个 v-if 或一个 v-else-if 元素后面。

<template> 上的 v-if

因为 v-if 是一个指令,他必须依附于某个元素。但如果我们想要切换不止一个元素呢?在这种情况下我们可以在一个 <template> 元素上使用 v-if,这只是一个不可见的包装器元素,最后渲染的结果并不会包含这个 <template> 元素。

html 复制代码
<template v-if="ok">
  <h1>Title</h1>
  <p>Paragraph 1</p>
  <p>Paragraph 2</p>
</template>

v-elsev-else-if 也可以在 <template> 上使用。

v-show

另一个可以用来按条件显示一个元素的指令是 v-show。其用法基本一样:

html 复制代码
<h1 v-show="ok">Hello!</h1>

不同之处在于 v-show 会在 DOM 渲染中保留该元素;v-show 仅切换了该元素上名为 display 的 CSS 属性。

v-show 不支持在 <template> 元素上使用,也不能和 v-else 搭配使用。

v-if vs. v-show

v-if 是"真实的"按条件渲染,因为它确保了在切换时,条件区块内的事件监听器和子组件都会被销毁与重建。

v-if 也是惰性的:如果在初次渲染时条件值为 false,则不会做任何事。条件区块只有当条件首次变为 true 时才被渲染。

相比之下,v-show 简单许多,元素无论初始条件如何,始终会被渲染,只有 CSS display 属性会被切换。

总的来说,v-if 有更高的切换开销,而 v-show 有更高的初始渲染开销。因此,如果需要频繁切换,则使用 v-show 较好;如果在运行时绑定条件很少改变,则 v-if 会更合适。

v-ifv-for

v-ifv-for 同时存在于一个元素上的时候,v-if 会首先被执行。请查看列表渲染指南获取更多细节。

警告:

同时使用 v-ifv-for不推荐的 ,因为这样二者的优先级不明显。

理解:

一、v-if:真值才渲染

html 复制代码
<h1 v-if="awesome">Vue is awesome!</h1>
  • awesome 是一个变量(比如 truefalse)。

  • 如果 awesometrue,这个 <h1> 就会出现在 DOM 中。

  • 如果 awesomefalse,这个 <h1> 不会被创建,就好像根本没写过一样。

特点:真正的"按需创建",节省资源,但是切换时开销较大(需要创建/销毁元素)。

二、v-else:否则显示另一块

html 复制代码
<h1 v-if="awesome">Vue is awesome!</h1>
<h1 v-else>Oh no 😢</h1>
  • 必须跟在 v-if(或 v-else-if)后面,中间不能有其他元素。

  • 当前面的条件为 false 时,v-else 的内容才会显示。

三、v-else-if:多个分支

html 复制代码
<div v-if="type === 'A'">A</div>
<div v-else-if="type === 'B'">B</div>
<div v-else-if="type === 'C'">C</div>
<div v-else>Not A/B/C</div>

就像 if ... else if ... else,从上往下匹配,只渲染第一个条件成立的那个块。

四、<template> 上的 v-if

如果你想要一次性切换多个元素 ,又不想额外加一个无意义的包裹标签(比如 <div>),可以用 <template>

html 复制代码
<template v-if="ok">
  <h1>Title</h1>
  <p>Paragraph 1</p>
  <p>Paragraph 2</p>
</template>
  • <template> 本身不会出现在最终的 DOM 中,只是逻辑上的容器。

  • v-else / v-else-if 也可以用在 <template> 上。

五、v-show:只是切换 CSS 的 display

html 复制代码
<h1 v-show="ok">Hello!</h1>
  • 无论 oktrue 还是 false,这个 <h1> 始终会被渲染在 DOM 中

  • okfalse 时,Vue 给它加上 style="display: none;",隐藏掉;为 true 时去掉这个样式。

  • 不支持 <template>,也不支持 v-else

特点:切换开销很小(只改一个 CSS 属性),但初始渲染开销较大(因为元素总是会被创建)。

六、v-if vs v-show 怎么选?

场景 推荐
需要频繁切换(比如 tab 切换、显示/隐藏按钮) v-show
运行时条件很少改变(比如根据用户权限显示某个区域) v-if
初始条件为 false,并且内部有复杂组件或大量 DOM v-if(避免一开始就创建无用内容)

简单记忆

  • v-if 是"真·创建/销毁",适合不常变的情况。

  • v-show 是"假·隐藏/显示",适合频繁切换。

七、v-ifv-for 一起用(不推荐)

文档说"不推荐同时使用"。

因为当它们在一个元素上同时出现时,v-if 优先级更高,可能导致你意想不到的结果。

实践中,要么把 v-if 放在外层 <template> 上,要么改用计算属性提前过滤数据。

八、总结一句话

v-if 是"要还是不要这个元素"(真正的条件渲染),v-show 是"只让不让它看见"(CSS 隐藏)。频繁切换用 v-show,很少变用 v-if

你只要记住这两个指令的区别,就掌握了条件渲染的核心。以后写 tab、权限控制、加载状态都会用到它们。

列表渲染

v-for

我们可以使用 v-for 指令基于一个数组来渲染一个列表。v-for 指令的值需要使用 item in items 形式的特殊语法,其中 items 是源数据的数组,而 item 是迭代项的别名

javascript 复制代码
const items = ref([{ message: 'Foo' }, { message: 'Bar' }])
html 复制代码
<li v-for="item in items">
  {{ item.message }}
</li>

v-for 块中可以完整地访问父作用域内的属性和变量。v-for 也支持使用可选的第二个参数表示当前项的位置索引。

javascript 复制代码
const parentMessage = ref('Parent')
const items = ref([{ message: 'Foo' }, { message: 'Bar' }])
html 复制代码
<li v-for="(item, index) in items">
  {{ parentMessage }} - {{ index }} - {{ item.message }}
</li>
  • Parent - 0 - Foo
  • Parent - 1 - Bar

在演练场中尝试一下

v-for 变量的作用域和下面的 JavaScript 代码很类似:

javascript 复制代码
const parentMessage = 'Parent'
const items = [
  /* ... */
]

items.forEach((item, index) => {
  // 可以访问外层的 `parentMessage`
  // 而 `item` 和 `index` 只在这个作用域可用
  console.log(parentMessage, item.message, index)
})

注意 v-for 是如何对应 forEach 回调的函数签名的。实际上,你也可以在定义 v-for 的变量别名时使用解构,和解构函数参数类似:

html 复制代码
<li v-for="{ message } in items">
  {{ message }}
</li>

<!-- 有 index 索引时 -->
<li v-for="({ message }, index) in items">
  {{ message }} {{ index }}
</li>

对于多层嵌套的 v-for,作用域的工作方式和函数的作用域很类似。每个 v-for 作用域都可以访问到父级作用域:

html 复制代码
<li v-for="item in items">
  <span v-for="childItem in item.children">
    {{ item.message }} {{ childItem }}
  </span>
</li>

你也可以使用 of 作为分隔符来替代 in,这更接近 JavaScript 的迭代器语法:

html 复制代码
<div v-for="item of items"></div>

v-for 与对象

你也可以使用 v-for 来遍历一个对象的所有属性。遍历的顺序会基于对该对象调用 Object.values() 的返回值来决定。

javascript 复制代码
const myObject = reactive({
  title: 'How to do lists in Vue',
  author: 'Jane Doe',
  publishedAt: '2016-04-10'
})
html 复制代码
<ul>
  <li v-for="value in myObject">
    {{ value }}
  </li>
</ul>

可以通过提供第二个参数表示属性名 (例如 key):

html 复制代码
<li v-for="(value, key) in myObject">
  {{ key }}: {{ value }}
</li>

第三个参数表示位置索引:

html 复制代码
<li v-for="(value, key, index) in myObject">
  {{ index }}. {{ key }}: {{ value }}
</li>

在演练场中尝试一下

v-for 里使用范围值

v-for 可以直接接受一个整数值。在这种用例中,会将该模板基于 1...n 的取值范围重复多次。

html 复制代码
<span v-for="n in 10">{{ n }}</span>

注意此处 n 的初值是从 1 开始而非 0

<template> 上的 v-for

与模板上的 v-if 类似,你也可以在 <template> 标签上使用 v-for 来渲染一个包含多个元素的块。例如:

html 复制代码
<ul>
  <template v-for="item in items">
    <li>{{ item.msg }}</li>
    <li class="divider" role="presentation"></li>
  </template>
</ul>

v-forv-if

当它们同时存在于一个节点上时,v-ifv-for 的优先级更高。这意味着 v-if 的条件将无法访问到 v-for 作用域内定义的变量别名:

html 复制代码
<!--
 这会抛出一个错误,因为属性 todo 此时
 没有在该实例上定义
-->
<li v-for="todo in todos" v-if="!todo.isComplete">
  {{ todo.name }}
</li>

在外先包装一层 <template> 再在其上使用 v-for 可以解决这个问题 (这也更加明显易读):

html 复制代码
<template v-for="todo in todos">
  <li v-if="!todo.isComplete">
    {{ todo.name }}
  </li>
</template>

注意

同时使用 v-ifv-for不推荐的,因为这样二者的优先级不明显。

两种常见的情况可能导致这种用法:

  • 过滤列表中的项目 (例如,v-for="user in users" v-if="user.isActive")。在这种情况下,可以用一个新的计算属性来替换 users,该属性返回过滤后的列表 (例如 activeUsers)。

  • 避免渲染应该隐藏的列表 (例如 v-for="user in users" v-if="shouldShowUsers")。在这种情况下,将 v-if 移至容器元素 (如 ulol)。

通过 key 管理状态

Vue 默认按照"就地更新"的策略来更新通过 v-for 渲染的元素列表。当数据项的顺序改变时,Vue 不会随之移动 DOM 元素的顺序,而是就地更新每个元素,确保它们在原本指定的索引位置上渲染。

默认模式是高效的,但只适用于列表渲染输出的结果不依赖子组件状态或者临时 DOM 状态 (例如表单输入值) 的情况

为了给 Vue 一个提示,以便它可以跟踪每个节点的标识,从而重用和重新排序现有的元素,你需要为每个元素对应的块提供一个唯一的 key attribute:

html 复制代码
<div v-for="item in items" :key="item.id">
  <!-- 内容 -->
</div>

当你使用 <template v-for> 时,key 应该被放置在这个 <template> 容器上:

html 复制代码
<template v-for="todo in todos" :key="todo.name">
  <li>{{ todo.name }}</li>
</template>

注意

key 在这里是一个通过 v-bind 绑定的特殊 attribute。请不要和在 v-for 中使用对象里所提到的对象属性名相混淆。

推荐在任何可行的时候为 v-for 提供一个 key attribute,除非所迭代的 DOM 内容非常简单 (例如:不包含组件或有状态的 DOM 元素),或者你想有意采用默认行为来提高性能。

key 绑定的值期望是一个基础类型的值,例如字符串或 number 类型。不要用对象作为 v-for 的 key。关于 key attribute 的更多用途细节,请参阅 key API 文档

组件上使用 v-for

这一小节假设你已了解组件的相关知识,或者你也可以先跳过这里,之后再回来看。

我们可以直接在组件上使用 v-for,和在一般的元素上使用没有区别 (别忘记提供一个 key):

html 复制代码
<MyComponent v-for="item in items" :key="item.id" />

但是,这不会自动将任何数据传递给组件,因为组件有自己独立的作用域。为了将迭代后的数据传递到组件中,我们还需要传递 props:

html 复制代码
<MyComponent
  v-for="(item, index) in items"
  :item="item"
  :index="index"
  :key="item.id"
/>

不自动将 item 注入组件的原因是,这会使组件与 v-for 的工作方式紧密耦合。明确其数据的来源可以使组件在其他情况下重用。

这里是一个简单的 Todo List 的例子,展示了如何通过 v-for 来渲染一个组件列表,并向每个实例中传入不同的数据。

数组变化侦测

变更方法

Vue 能够侦听响应式数组的变更方法,并在它们被调用时触发相关的更新。这些变更方法包括:

  • push()
  • pop()
  • shift()
  • unshift()
  • splice()
  • sort()
  • reverse()

替换一个数组

变更方法,顾名思义,就是会对调用它们的原数组进行变更。相对地,也有一些不可变 (immutable) 方法,例如 filter()concat()slice(),这些都不会更改原数组,而总是返回一个新数组。当遇到的是非变更方法时,我们需要将旧的数组替换为新的:

javascript 复制代码
// `items` 是一个数组的 ref
items.value = items.value.filter((item) => item.message.match(/Foo/))

你可能认为这将导致 Vue 丢弃现有的 DOM 并重新渲染整个列表------幸运的是,情况并非如此。Vue 实现了一些巧妙的方法来最大化对 DOM 元素的重用,因此用另一个包含部分重叠对象的数组来做替换,仍会是一种非常高效的操作。

展示过滤或排序后的结果

有时,我们希望显示数组经过过滤或排序后的内容,而不实际变更或重置原始数据。在这种情况下,你可以创建返回已过滤或已排序数组的计算属性。

举例来说:

javascript 复制代码
const numbers = ref([1, 2, 3, 4, 5])

const evenNumbers = computed(() => {
  return numbers.value.filter((n) => n % 2 === 0)
})
html 复制代码
<li v-for="n in evenNumbers">{{ n }}</li>

在计算属性不可行的情况下 (例如在多层嵌套的 v-for 循环中),你可以使用以下方法:

javascript 复制代码
const sets = ref([
  [1, 2, 3, 4, 5],
  [6, 7, 8, 9, 10]
])

function even(numbers) {
  return numbers.filter((number) => number % 2 === 0)
}
html 复制代码
<ul v-for="numbers in sets">
  <li v-for="n in even(numbers)">{{ n }}</li>
</ul>

在计算属性中使用 reverse()sort() 的时候务必小心!这两个方法将变更原始数组,计算函数中不应该这么做。请在调用这些方法之前创建一个原数组的副本:

javascript 复制代码
- return numbers.reverse()
+ return [...numbers].reverse()

理解:

我们来看 Vue 中"列表渲染"的用法 ------ 也就是如何用 v-for 把一个数组或对象循环输出成一组 DOM 元素

下面是逐段大白话解释。

一、v-for 的基本写法

html 复制代码
<li v-for="item in items">{{ item.message }}</li>
  • items 是一个数组。

  • item当前循环到的这一项 (你可以自己起名,比如 itemtodouser 都行)。

  • 最终结果:数组里有多少条数据,就生成多少个 <li>

相当于 JS 里的items.forEach(item => { 生成一个<li> })

二、获取索引(第几个)

html 复制代码
<li v-for="(item, index) in items">
  {{ parentMessage }} - {{ index }} - {{ item.message }}
</li>
  • 第二个参数 index 就是当前项的索引(从 0 开始)。

  • v-for 里面可以直接访问外面定义的变量(比如 parentMessage)。

类比:你在点名,"第 0 个是张三,第 1 个是李四"。

三、解构写法

html 复制代码
<li v-for="{ message } in items">{{ message }}</li>
  • 如果你只需要对象的某个属性,可以直接用 { message } 解构出来,不用写 item.message

  • 跟 JS 函数参数的解构一样,方便。

四、遍历对象

html 复制代码
<li v-for="(value, key, index) in myObject">
  {{ index }}. {{ key }}: {{ value }}
</li>
  • 遍历对象时:第一个是 ,第二个是键名 ,第三个是索引 (按 Object.values() 的顺序)。

  • 比如 { title: 'How to do lists', author: 'Jane Doe' } 会输出 0. title: How to do lists1. author: Jane Doe

五、直接给一个数字范围

html 复制代码
<span v-for="n in 10">{{ n }}</span>
  • n in 10 会让 n 从 1 循环到 10(不是 0 到 9)。

  • 常用于生成固定数量的占位或分页按钮。

六、<template> 上的 v-for

如果你要循环输出多个元素 (比如一个 <li> 加一个分割线),又不想额外套一层无意义的父标签,就用 <template>

html 复制代码
<template v-for="item in items">
  <li>{{ item.msg }}</li>
  <li class="divider"></li>
</template>
  • <template> 本身不会出现在 DOM 中,只作为逻辑容器。

七、v-ifv-for 一起用的问题(重要!)

官方说:不建议同时放在同一个元素上。

html 复制代码
<!-- 这样会报错,因为 v-if 先执行,此时 todo 还没定义 -->
<li v-for="todo in todos" v-if="!todo.isComplete">

为什么? 因为 v-if 优先级更高,它执行的时候 v-for 还没创建 todo 变量,所以访问不到。

正确做法

  1. v-if 移到外层 <template> 上:

    html 复制代码
    <template v-for="todo in todos">
      <li v-if="!todo.isComplete">{{ todo.name }}</li>
    </template>
  2. 或者,更推荐:先用计算属性 把要显示的数据过滤好,然后直接在 v-for 里循环过滤后的数组:

    javascript 复制代码
    const activeTodos = computed(() => todos.value.filter(t => !t.isComplete))
    html 复制代码
    <li v-for="todo in activeTodos">{{ todo.name }}</li>

这样逻辑更清晰,也避免性能问题。

八、:key ------ 给每个循环项一个唯一标识

html 复制代码
<div v-for="item in items" :key="item.id">
  {{ item.name }}
</div>
  • :key 是一个特殊的属性,用来帮助 Vue 区分不同的循环项

  • 为什么需要它?因为 Vue 默认会"就地复用"元素 ------ 如果数组顺序变了,它可能只是改文字内容,而不是移动 DOM 位置。这可能导致组件状态错乱、动画失效等问题。

  • 给一个唯一的 key(如 item.id)后,Vue 就能准确地把每个数据和它对应的 DOM 元素绑定在一起,数组重新排序时会正确地移动元素。

规则

  • 必须唯一且稳定(不能用下标 index 作 key,因为下标会变)。

  • 推荐总是写 :key,除非你的列表非常简单且不涉及表单、动画、组件状态。

九、数组变化时,Vue 怎么知道要更新?

  1. 变更方法(会改变原数组)

pushpopshiftunshiftsplicesortreverse

Vue 能侦听到这些方法,并自动更新页面。

  1. 替换数组(不改变原数组,而是返回新数组)

filterconcatslice 等。因为这些方法不会修改原数组,所以你需要把新数组赋值回去

javascript 复制代码
items.value = items.value.filter(item => item.message.match(/Foo/))

Vue 并不会整个重新渲染列表,而是智能地复用已有的 DOM,性能很好。

十、展示过滤或排序后的结果

不要直接修改原数组,而是用计算属性返回一个新数组:

javascript 复制代码
const numbers = ref([1,2,3,4,5])
const evenNumbers = computed(() => numbers.value.filter(n => n % 2 === 0))
html 复制代码
<li v-for="n in evenNumbers">{{ n }}</li>

如果需要在多层循环中临时过滤,可以用一个方法(但在模板里调用方法每次都会执行,注意性能):

html 复制代码
<li v-for="n in even(numbers)">{{ n }}</li>

注意 :如果你在计算属性里用 reverse()sort(),它们会改变原数组,最好先复制一份:

javascript 复制代码
return [...numbers].reverse()

十一、组件上使用 v-for

html 复制代码
<MyComponent v-for="item in items" :key="item.id" :item="item" />
  • 组件不会自动接收到 item,必须通过 props 手动传递(比如 :item="item")。

  • 这样组件可以独立复用,不依赖 v-for 的特殊变量。

总结(一句话记全部)

v-for 用来循环数组 / 对象,记得带上 :key 给每个项一个唯一 ID。不要跟 v-if 放同一元素上,优先用计算属性过滤。数组变化时,变更方法自动更新,非变更方法需重新赋值。

事件处理

监听事件

我们可以使用 v-on 指令 (简写为 @) 来监听 DOM 事件,并在事件触发时执行对应的 JavaScript。用法:v-on:click="handler"@click="handler"

事件处理器 (handler) 的值可以是:

  1. 内联事件处理器 :事件被触发时执行的内联 JavaScript 语句 (与 onclick 类似)。

  2. 方法事件处理器:一个指向组件上定义的方法的属性名或是路径。

内联事件处理器

内联事件处理器通常用于简单场景,例如:

javascript 复制代码
const count = ref(0)
html 复制代码
<button @click="count++">Add 1</button>
<p>Count is: {{ count }}</p>

在演练场中尝试一下

方法事件处理器

随着事件处理器的逻辑变得愈发复杂,内联代码方式变得不够灵活。因此 v-on 也可以接受一个方法名或对某个方法的调用。

举例来说:

javascript 复制代码
const name = ref('Vue.js')

function greet(event) {
  alert(`Hello ${name.value}!`)
  // `event` 是 DOM 原生事件
  if (event) {
    alert(event.target.tagName)
  }
}
html 复制代码
<!-- `greet` 是上面定义过的方法名 -->
<button @click="greet">Greet</button>

在演练场中尝试一下

方法事件处理器会自动接收原生 DOM 事件并触发执行。在上面的例子中,我们能够通过被触发事件的 event.target 访问到该 DOM 元素。

你也可以看看为事件处理器标注类型这一章了解更多。

方法与内联事件判断

模板编译器会通过检查 v-on 的值是否是合法的 JavaScript 标识符或属性访问路径来断定是何种形式的事件处理器。举例来说,foofoo.barfoo['bar'] 会被视为方法事件处理器,而 foo()count++ 会被视为内联事件处理器。

在内联处理器中调用方法

除了直接绑定方法名,你还可以在内联事件处理器中调用方法。这允许我们向方法传入自定义参数以代替原生事件:

javascript 复制代码
function say(message) {
  alert(message)
}
html 复制代码
<button @click="say('hello')">Say hello</button>
<button @click="say('bye')">Say bye</button>

在演练场中尝试一下

在内联事件处理器中访问事件参数

有时我们需要在内联事件处理器中访问原生 DOM 事件。你可以向该处理器方法传入一个特殊的 $event 变量,或者使用内联箭头函数:

html 复制代码
<!-- 使用特殊的 $event 变量 -->
<button @click="warn('Form cannot be submitted yet.', $event)">
  Submit
</button>

<!-- 使用内联箭头函数 -->
<button @click="(event) => warn('Form cannot be submitted yet.', event)">
  Submit
</button>
javascript 复制代码
function warn(message, event) {
  // 这里可以访问原生事件
  if (event) {
    event.preventDefault()
  }
  alert(message)
}

事件修饰符

在处理事件时调用 event.preventDefault()event.stopPropagation() 是很常见的。尽管我们可以直接在方法内调用,但如果方法能更专注于数据逻辑而不用去处理 DOM 事件的细节会更好。

为解决这一问题,Vue 为 v-on 提供了事件修饰符 。修饰符是用 . 表示的指令后缀,包含以下这些:

  • .stop
  • .prevent
  • .self
  • .capture
  • .once
  • .passive
html 复制代码
<!-- 单击事件将停止传递 -->
<a @click.stop="doThis"></a>

<!-- 提交事件将不再重新加载页面 -->
<form @submit.prevent="onSubmit"></form>

<!-- 修饰语可以使用链式书写 -->
<a @click.stop.prevent="doThat"></a>

<!-- 也可以只有修饰符 -->
<form @submit.prevent></form>

<!-- 仅当 event.target 是元素本身时才会触发事件处理器 -->
<!-- 例如:事件处理器不来自子元素 -->
<div @click.self="doThat">...</div>

TIP

使用修饰符时需要注意调用顺序,因为相关代码是以相同的顺序生成的。因此使用 @click.prevent.self 会阻止元素及其子元素的所有点击事件的默认行为 ,而 @click.self.prevent 则只会阻止对元素本身的点击事件的默认行为。

.capture.once.passive 修饰符与原生 addEventListener 事件相对应:

html 复制代码
<!-- 添加事件监听器时,使用 `capture` 捕获模式 -->
<!-- 例如:指向内部元素的事件,在被内部元素处理前,先被外部处理 -->
<div @click.capture="doThis">...</div>

<!-- 点击事件最多被触发一次 -->
<a @click.once="doThis"></a>

<!-- 滚动事件的默认行为 (scrolling) 将立即发生而非等待 `onScroll` 完成 -->
<!-- 以防其中包含 `event.preventDefault()` -->
<div @scroll.passive="onScroll">...</div>

.passive 修饰符一般用于触摸事件的监听器,可以用来改善移动端设备的滚屏性能

TIP:

请勿同时使用 .passive.prevent,因为 .passive 已经向浏览器表明了你不想 阻止事件的默认行为。如果你这么做了,则 .prevent 会被忽略,并且浏览器会抛出警告。

按键修饰符

在监听键盘事件时,我们经常需要检查特定的按键。Vue 允许在 v-on@ 监听按键事件时添加按键修饰符。

html 复制代码
<!-- 仅在 `key` 为 `Enter` 时调用 `submit` -->
<input @keyup.enter="submit" />

你可以直接使用 KeyboardEvent.key 暴露的按键名称作为修饰符,但需要转为 kebab-case 形式。

html 复制代码
<input @keyup.page-down="onPageDown" />

在上面的例子中,仅会在 $event.key'PageDown' 时调用事件处理。

按键别名

Vue 为一些常用的按键提供了别名:

  • .enter
  • .tab
  • .delete (捕获"Delete"和"Backspace"两个按键)
  • .esc
  • .space
  • .up
  • .down
  • .left
  • .right

系统按键修饰符

你可以使用以下系统按键修饰符来触发鼠标或键盘事件监听器,只有当按键被按下时才会触发。

  • .ctrl
  • .alt
  • .shift
  • .meta

注意

在 Mac 键盘上,meta 是 Command 键 (⌘)。在 Windows 键盘上,meta 键是 Windows 键 (⊞)。在 Sun 微机系统键盘上,meta 是钻石键 (◆)。在某些键盘上,特别是 MIT 和 Lisp 机器的键盘及其后代版本的键盘,如 Knight 键盘,space-cadet 键盘,meta 都被标记为"META"。在 Symbolics 键盘上,meta 也被标识为"META"或"Meta"。

举例来说:

html 复制代码
<!-- Alt + Enter -->
<input @keyup.alt.enter="clear" />

<!-- Ctrl + 点击 -->
<div @click.ctrl="doSomething">Do something</div>

TIP:

请注意,系统按键修饰符和常规按键不同。与 keyup 事件一起使用时,该按键必须在事件发出时处于按下状态。换句话说,keyup.ctrl 只会在你仍然按住 ctrl 但松开了另一个键时被触发。若你单独松开 ctrl 键将不会触发。

.exact 修饰符

.exact 修饰符允许精确控制触发事件所需的系统修饰符的组合。

html 复制代码
<!-- 当按下 Ctrl 时,即使同时按下 Alt 或 Shift 也会触发 -->
<button @click.ctrl="onClick">A</button>

<!-- 仅当按下 Ctrl 且未按任何其他键时才会触发 -->
<button @click.ctrl.exact="onCtrlClick">A</button>

<!-- 仅当没有按下任何系统按键时触发 -->
<button @click.exact="onClick">A</button>

鼠标按键修饰符

  • .left
  • .right
  • .middle

这些修饰符将处理程序限定为由特定鼠标按键触发的事件。

但请注意,.left.right.middle 这些修饰符名称是基于常见的右手用鼠标布局设定的,但实际上它们分别指代设备事件触发器的"主"、"次","辅助",而非实际的物理按键。因此,对于左手用鼠标布局而言,"主"按键在物理上可能是右边的按键,但却会触发 .left 修饰符对应的处理程序。又或者,触控板可能通过单指点击触发 .left 处理程序,通过双指点击触发 .right 处理程序,通过三指点击触发 .middle 处理程序。同样,产生"鼠标"事件的其他设备和事件源,也可能具有与"左","右"完全无关的触发模式。

理解:

一、基本写法:v-on@

html 复制代码
<button v-on:click="handler">点击</button>
<!-- 简写 -->
<button @click="handler">点击</button>
  • v-on:click 就是"监听点击事件"。

  • 日常写代码几乎都用 @click,简洁。

事件处理器的两种形式

  1. 内联事件处理器 :直接写 JavaScript 语句,比如 count++

  2. 方法事件处理器 :写一个组件里定义的方法名,比如 greet

二、内联事件处理器(适合简单逻辑)

html 复制代码
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>

<template>
  <button @click="count++">点我加1</button>
  <p>Count is: {{ count }}</p>
</template>
  • @click="count++" 直接写表达式,Vue 会帮你执行。

  • 注意:表达式里可以调用方法、做运算,但不能写 if 等多条语句。

三、方法事件处理器(适合复杂逻辑)

html 复制代码
<script setup>
import { ref } from 'vue'
const name = ref('Vue.js')

function greet(event) {
  alert(`Hello ${name.value}!`)
  // 自动接收原生 DOM 事件对象
  console.log(event.target.tagName)  // 输出 BUTTON
}
</script>

<template>
  <button @click="greet">打招呼</button>
</template>
  • @click="greet" 这里写的是方法名,不是调用(没有括号)。

  • Vue 会自动把原生 DOM 事件对象作为第一个参数传给 greet,所以我们可以在方法里拿到 event

四、在内联处理器中调用方法并传参

html 复制代码
<script setup>
function say(message) {
  alert(message)
}
</script>

<template>
  <button @click="say('hello')">Say hello</button>
  <button @click="say('bye')">Say bye</button>
</template>
  • 这种写法其实也属于内联处理器,因为它执行的是 say('hello') 这个表达式。

  • 好处:可以传自定义参数,但此时原生事件对象不会自动传递,需要手动传入。

五、在内联处理器中同时访问原生事件对象

有时你既想传自定义参数,又想拿到 event,有两个办法:

方法1:使用 $event 特殊变量

html 复制代码
<button @click="warn('表单未完成', $event)">提交</button>
javascript 复制代码
function warn(message, event) {
  event.preventDefault()
  alert(message)
}

方法2:使用内联箭头函数

html 复制代码
<button @click="(event) => warn('表单未完成', event)">提交</button>
  • 箭头函数里 event 就是原生事件对象,再传给 warn

六、事件修饰符(最实用)

很多时候我们需要阻止默认行为(如表单提交刷新页面)、阻止事件冒泡等。Vue 提供了修饰符来简化。

修饰符 作用 例子
.stop 阻止事件冒泡(相当于 event.stopPropagation() @click.stop="handle"
.prevent 阻止默认行为(如表单提交不刷新) @submit.prevent="onSubmit"
.self 只有 event.target 是当前元素时才触发 @click.self="handle"
.capture 使用捕获模式(先外后内) @click.capture="handle"
.once 事件只触发一次 @click.once="handle"
.passive 提升滚动性能,不调用 preventDefault @scroll.passive="onScroll"

链式使用

html 复制代码
<a @click.stop.prevent="doThat">点我</a>
  • .stop.prevent,顺序很重要:.prevent.self.self.prevent 效果不同。

只用修饰符(不写处理函数):

html 复制代码
<form @submit.prevent></form>   <!-- 只阻止提交,不执行额外函数 -->

七、按键修饰符(处理键盘事件)

html 复制代码
<input @keyup.enter="submit" />
  • 只有当用户按下 回车键 时才调用 submit

  • 常用别名:.enter.tab.delete(捕获 Delete 和 Backspace)、.esc.space.up.down.left.right

你也可以直接使用 KeyboardEvent.key 的值,但要转成 kebab-case

html 复制代码
<input @keyup.page-down="onPageDown" />

八、系统按键修饰符(Ctrl、Alt、Shift、Meta)

修饰符 对应键
.ctrl Ctrl 键
.alt Alt 键
.shift Shift 键
.meta Mac 的 Command(⌘)或 Windows 的 Windows 键(⊞)

组合使用

html 复制代码
<!-- Alt + Enter -->
<input @keyup.alt.enter="clear" />

<!-- Ctrl + 点击 -->
<div @click.ctrl="doSomething">点我</div>

注意:keyup.ctrl 只在你按住 Ctrl 键不放,并松开另一个键时触发;单独松开 Ctrl 不会触发。

九、.exact 修饰符(精确组合)

有时你希望"只按下 Ctrl,不能同时按 Alt 或 Shift",就用 .exact

html 复制代码
<!-- 按下 Ctrl 时,即使同时按了 Alt 或 Shift 也会触发 -->
<button @click.ctrl="onClick">A</button>

<!-- 仅当只按下 Ctrl(没有其他系统键)时触发 -->
<button @click.ctrl.exact="onCtrlClick">A</button>

<!-- 仅当没有按下任何系统键时触发 -->
<button @click.exact="onClick">A</button>

十、鼠标按键修饰符

html 复制代码
<button @click.left="leftHandler">左键</button>
<button @click.right="rightHandler">右键</button>
<button @click.middle="middleHandler">中键</button>
  • .left:鼠标左键(主按键)

  • .right:鼠标右键(次按键)

  • .middle:鼠标中键(滚轮按下)

注意:这些修饰符的逻辑是基于"主/次/辅助"按键,而不是物理位置。左手鼠标用户的主按键可能是物理右键,但依然会触发 .left 修饰符。

十一、综合总结(小白版)

@事件名 用来监听事件。

  • 简单逻辑直接写表达式:@click="count++"

  • 复杂逻辑写方法名:@click="handle"(方法会自动收到 event

  • 需要传自定义参数又想用 event 时,用 $event 或箭头函数。

  • 修饰符 帮你简化 stopPropagationpreventDefault、按键过滤等常见操作。

  • 常用:@click.stop@submit.prevent@keyup.enter@click.ctrl

你不需要一下子记住所有修饰符,只要知道"有这些工具,遇到需求时回来查文档"就好。现在,你可以先试着自己写一个按钮,点一下弹个 Hello,然后试着阻止表单提交刷新页面------这两步走通了,事件处理就算入门了。

表单输入绑定

在前端处理表单时,我们常常需要将表单输入框的内容同步给 JavaScript 中相应的变量。手动连接值绑定和更改事件监听器可能会很麻烦:

html 复制代码
<input
  :value="text"
  @input="event => text = event.target.value">

v-model 指令帮我们简化了这一步骤:

html 复制代码
<input v-model="text">

另外,v-model 还可以用于各种不同类型的输入,<textarea><select> 元素。它会根据所使用的元素自动使用对应的 DOM 属性和事件组合:

  • 文本类型的 <input><textarea> 元素会绑定 value property 并侦听 input 事件;
  • <input type="checkbox"><input type="radio"> 会绑定 checked property 并侦听 change 事件;
  • <select> 会绑定 value property 并侦听 change 事件。

注意

v-model 会忽略任何表单元素上初始的 valuecheckedselected attribute。它将始终将当前绑定的 JavaScript 状态视为数据的正确来源。你应该在 JavaScript 中使用响应式系统的 API来声明该初始值。

基本用法

文本

html 复制代码
<p>Message is: {{ message }}</p>
<input v-model="message" placeholder="edit me" />

在演练场中尝试一下

注意:

对于需要使用 IME 的语言 (中文,日文和韩文等),你会发现 v-model 不会在 IME 输入还在拼字阶段时触发更新。如果你的确想在拼字阶段也触发更新,请直接使用自己的 input 事件监听器和 value 绑定而不要使用 v-model

多行文本

html 复制代码
<span>Multiline message is:</span>
<p style="white-space: pre-line;">{{ message }}</p>
<textarea v-model="message" placeholder="add multiple lines"></textarea>

在演练场中尝试一下

注意在 <textarea> 中是不支持插值表达式的。请使用 v-model 来替代:

html 复制代码
<!-- 错误 -->
<textarea>{{ text }}</textarea>

<!-- 正确 -->
<textarea v-model="text"></textarea>

复选框

单一的复选框,绑定布尔类型值:

html 复制代码
<input type="checkbox" id="checkbox" v-model="checked" />
<label for="checkbox">{{ checked }}</label>

在演练场中尝试一下

我们也可以将多个复选框绑定到同一个数组或集合的值:

javascript 复制代码
const checkedNames = ref([])
html 复制代码
<div>Checked names: {{ checkedNames }}</div>

<input type="checkbox" id="jack" value="Jack" v-model="checkedNames" />
<label for="jack">Jack</label>

<input type="checkbox" id="john" value="John" v-model="checkedNames" />
<label for="john">John</label>

<input type="checkbox" id="mike" value="Mike" v-model="checkedNames" />
<label for="mike">Mike</label>

在这个例子中,checkedNames 数组将始终包含所有当前被选中的框的值。

在演练场中尝试一下

单选按钮

html 复制代码
<div>Picked: {{ picked }}</div>

<input type="radio" id="one" value="One" v-model="picked" />
<label for="one">One</label>

<input type="radio" id="two" value="Two" v-model="picked" />
<label for="two">Two</label>

在演练场中尝试一下

选择器

单个选择器的示例如下:

html 复制代码
<div>Selected: {{ selected }}</div>

<select v-model="selected">
  <option disabled value="">Please select one</option>
  <option>A</option>
  <option>B</option>
  <option>C</option>
</select>

在演练场中尝试一下

注意

如果 v-model 表达式的初始值不匹配任何一个选择项,<select> 元素会渲染成一个"未选择"的状态。在 iOS 上,这将导致用户无法选择第一项,因为 iOS 在这种情况下不会触发一个 change 事件。因此,我们建议提供一个空值的禁用选项,如上面的例子所示。

多选 (值绑定到一个数组):

html 复制代码
<div>Selected: {{ selected }}</div>

<select v-model="selected" multiple>
  <option>A</option>
  <option>B</option>
  <option>C</option>
</select>

在演练场中尝试一下

选择器的选项可以使用 v-for 动态渲染:

javascript 复制代码
const selected = ref('A')

const options = ref([
  { text: 'One', value: 'A' },
  { text: 'Two', value: 'B' },
  { text: 'Three', value: 'C' }
])
html 复制代码
<div>Selected: {{ selected }}</div>

<select v-model="selected">
  <option v-for="option in options" :value="option.value">
    {{ option.text }}
  </option>
</select>

在演练场中尝试一下

值绑定

对于单选按钮,复选框和选择器选项,v-model 绑定的值通常是静态的字符串 (或者对复选框是布尔值):

html 复制代码
<!-- `picked` 在被选择时是字符串 "a" -->
<input type="radio" v-model="picked" value="a" />

<!-- `toggle` 只会为 true 或 false -->
<input type="checkbox" v-model="toggle" />

<!-- `selected` 在第一项被选中时为字符串 "abc" -->
<select v-model="selected">
  <option value="abc">ABC</option>
</select>

但有时我们可能希望将该值绑定到当前组件实例上的动态数据。这可以通过使用 v-bind 来实现。此外,使用 v-bind 还使我们可以将选项值绑定为非字符串的数据类型。

复选框

html 复制代码
<input
  type="checkbox"
  v-model="toggle"
  true-value="yes"
  false-value="no" />

true-valuefalse-value 是 Vue 特有的 attributes,仅支持和 v-model 配套使用。这里 toggle 属性的值会在选中时被设为 'yes',取消选择时设为 'no'。你同样可以通过 v-bind 将其绑定为其他动态值:

html 复制代码
<input
  type="checkbox"
  v-model="toggle"
  :true-value="dynamicTrueValue"
  :false-value="dynamicFalseValue" />

提示

true-valuefalse-value attributes 不会影响 value attribute,因为浏览器在表单提交时,并不会包含未选择的复选框。为了保证这两个值 (例如:"yes"和"no") 的其中之一被表单提交,请使用单选按钮作为替代。

单选按钮

html 复制代码
<input type="radio" v-model="pick" :value="first" />
<input type="radio" v-model="pick" :value="second" />

pick 会在第一个按钮选中时被设为 first,在第二个按钮选中时被设为 second

选择器选项

html 复制代码
<select v-model="selected">
  <!-- 内联对象字面量 -->
  <option :value="{ number: 123 }">123</option>
</select>

v-model 同样也支持非字符串类型的值绑定!在上面这个例子中,当某个选项被选中,selected 会被设为该对象字面量值 { number: 123 }

修饰符

.lazy

默认情况下,v-model 会在每次 input 事件后更新数据 (IME 拼字阶段的状态例外)。你可以添加 lazy 修饰符来改为在每次 change 事件后更新数据:

html 复制代码
<!-- 在 "change" 事件后同步更新而不是 "input" -->
<input v-model.lazy="msg" />

.number

如果你想让用户输入自动转换为数字,你可以在 v-model 后添加 .number 修饰符来管理输入:

html 复制代码
<input v-model.number="age" />

如果该值无法被 parseFloat() 处理,那么将返回原始值。特别是当输入为空时 (例如用户清空输入字段之后),会返回一个空字符串。这种行为与 DOM 属性 valueAsNumber 有所不同。

number 修饰符会在输入框有 type="number" 时自动启用。

.trim

如果你想要默认自动去除用户输入内容中两端的空格,你可以在 v-model 后添加 .trim 修饰符:

html 复制代码
<input v-model.trim="msg" />

组件上的 v-model

如果你还不熟悉 Vue 的组件,那么现在可以跳过这个部分。

HTML 的内置表单输入类型并不总能满足所有需求。幸运的是,我们可以使用 Vue 构建具有自定义行为的可复用输入组件,并且这些输入组件也支持 v-model!要了解更多关于此的内容,请在组件指引中阅读配合 v-model 使用

理解:

一、为什么要用 v-model

假设你有一个输入框,希望用户输入的内容实时显示在页面上。

不用 v-model 时,你需要手动绑定 value 和监听 input 事件:

html 复制代码
<input :value="text" @input="event => text = event.target.value" />
<p>你输入了:{{ text }}</p>

这有点麻烦,每次都要写 event.target.value

v-model 的出现就是为了简化这个过程:

html 复制代码
<input v-model="text" />
<p>你输入了:{{ text }}</p>

v-model 的本质

  • 对于文本输入框,它等于 :value + @input 的语法糖。

  • Vue 会根据不同的表单元素自动使用正确的属性和事件(比如复选框用 checkedchange)。

二、不同表单元素的基本用法

  1. 文本输入框 (<input type="text">) 和 多行文本框 (<textarea>)
html 复制代码
<input v-model="message" placeholder="编辑我" />
<p>消息:{{ message }}</p>

<textarea v-model="content"></textarea>
  • 数据实时同步(每次按键都会更新变量)。

  • 注意<textarea> 里不要写插值 {``{ }},只能用 v-model

关于中文输入法(IME)

在使用拼音输入法时,v-model 默认在"选字完成"后才更新数据,不会在拼写过程中更新。如果你想在拼写过程中也更新,需要手动用 :value + @input 实现。

  1. 单个复选框(绑定布尔值)
html 复制代码
<input type="checkbox" v-model="checked" />
<label>已选中:{{ checked }}</label>
  • checked 是一个布尔变量(true / false)。

  • 勾选 → true,取消 → false

  1. 多个复选框(绑定数组)
html 复制代码
<div>选中的值:{{ checkedNames }}</div>

<input type="checkbox" value="Jack" v-model="checkedNames" /> Jack
<input type="checkbox" value="John" v-model="checkedNames" /> John
<input type="checkbox" value="Mike" v-model="checkedNames" /> Mike
  • checkedNames 是一个数组

  • 勾选某个框,它的 value 会被加入数组;取消勾选,会从数组里移除。

  1. 单选按钮(绑定同一个变量)
html 复制代码
<div>选中的值:{{ picked }}</div>

<input type="radio" value="One" v-model="picked" /> One
<input type="radio" value="Two" v-model="picked" /> Two
  • picked 会变成被选中按钮的 value 值("One""Two")。
  1. 下拉选择框(<select>

单选(绑定一个变量):

html 复制代码
<select v-model="selected">
  <option disabled value="">请选择</option>
  <option>A</option>
  <option>B</option>
  <option>C</option>
</select>
<p>已选:{{ selected }}</p>
  • selected 会变成选中 <option>value(没有写 value 时,取标签内的文本)。

  • 建议 :提供一个禁用的空选项(如 disabled value=""),避免 iOS 上无法选第一项的问题。

多选 (绑定一个数组,添加 multiple 属性):

html 复制代码
<select v-model="selected" multiple>
  <option>A</option>
  <option>B</option>
  <option>C</option>
</select>
  • 按住 Ctrl(或 Command)可多选,selected 数组会包含所有选中项的值。

动态渲染选项 (用 v-for):

html 复制代码
<select v-model="selected">
  <option v-for="opt in options" :value="opt.value">
    {{ opt.text }}
  </option>
</select>
  • :value 绑定自定义值(可以是对象、数字等)。

三、值绑定(把选项的值绑定到动态数据)

默认情况下,v-model 绑定的值都是字符串(复选框是布尔)。但有时你想让选中时的值是对象、数字等,可以通过 :value 实现。

例子:复选框绑定非布尔值(自定义选中/未选中的值)

html 复制代码
<input type="checkbox" v-model="toggle" true-value="yes" false-value="no" />
  • true-valuefalse-value 是 Vue 特有的属性,仅在 v-model 中使用。

  • 选中时 toggle = 'yes',未选中时 toggle = 'no'

  • 也可以用 :true-value="someVar" 绑定动态值。

单选按钮绑定对象

html 复制代码
<input type="radio" v-model="pick" :value="firstObj" />
<input type="radio" v-model="pick" :value="secondObj" />
  • pick 会被设置为 firstObjsecondObj 这个对象本身。

下拉选项绑定对象

html 复制代码
<option :value="{ number: 123 }">123</option>
  • 选中后 selected 就是 { number: 123 }

四、修饰符(修饰符让你微调 v-model 的行为)

修饰符 作用 例子
.lazy 原本每次 input 事件更新,改为 change 事件后更新(即失去焦点或回车时才更新) <input v-model.lazy="msg">
.number 自动将用户输入转为数字(相当于 parseFloat()),如果转不了则保留原值 <input v-model.number="age">
.trim 自动去掉输入内容首尾的空格 <input v-model.trim="msg">

注意 :当 <input type="number"> 时,.number 会自动启用,但你仍然可以手动加。

五、一些重要细节

  • 初始值来源v-model 会忽略 HTML 上原本的 valuecheckedselected 属性。你应该在 JS 中用 refreactive 定义初始值。

  • <textarea> 不支持插值 :不要写 <textarea>{``{ text }}</textarea>,要用 v-model

  • IME 输入问题 :对于中文/日文/韩文输入法,v-model 在拼字阶段不更新。如果你需要实时更新(比如做搜索建议),请改用 :value + @input 手动处理。

  • 组件上的 v-model :自定义组件也可以使用 v-model,但这个属于组件进阶内容,暂时可以先跳过。

六、总结(小白记忆版)

v-model 就是"输入框 ↔ 变量"的双向自动同步器。

  • 文本框、多行文本 → 同步字符串

  • 单个复选框 → 同步布尔值

  • 多个复选框 → 同步数组

  • 单选按钮、下拉列表 → 同步选中的值

  • 修饰符 .lazy.number.trim 可以微调更新时机或数据格式

  • true-value / false-value:value 绑定非字符串的值

你只要记住:在大多数表单场景下,直接写 v-model="变量名" 就能自动保持同步,不需要手动操作 DOM。

现在你可以试着自己写一个输入框,下面显示你输入的内容,然后给它加上 .trim 修饰符看看效果。

侦听器

基本示例

计算属性允许我们声明性地计算衍生值。然而在有些情况下,我们需要在状态变化时执行一些"副作用":例如更改 DOM,或是根据异步操作的结果去修改另一处的状态。

在组合式 API 中,我们可以使用 watch 函数在每次响应式状态发生变化时触发回调函数:

html 复制代码
<script setup>
import { ref, watch } from 'vue'

const question = ref('')
const answer = ref('Questions usually contain a question mark. ;-)')
const loading = ref(false)

// 可以直接侦听一个 ref
watch(question, async (newQuestion, oldQuestion) => {
  if (newQuestion.includes('?')) {
    loading.value = true
    answer.value = 'Thinking...'
    try {
      const res = await fetch('https://yesno.wtf/api')
      answer.value = (await res.json()).answer
    } catch (error) {
      answer.value = 'Error! Could not reach the API. ' + error
    } finally {
      loading.value = false
    }
  }
})
</script>

<template>
  <p>
    Ask a yes/no question:
    <input v-model="question" :disabled="loading" />
  </p>
  <p>{{ answer }}</p>
</template>

在演练场中尝试一下

侦听数据源类型

watch 的第一个参数可以是不同形式的"数据源":它可以是一个 ref (包括计算属性)、一个响应式对象、一个 getter 函数、或多个数据源组成的数组:

javascript 复制代码
const x = ref(0)
const y = ref(0)

// 单个 ref
watch(x, (newX) => {
  console.log(`x is ${newX}`)
})

// getter 函数
watch(
  () => x.value + y.value,
  (sum) => {
    console.log(`sum of x + y is: ${sum}`)
  }
)

// 多个来源组成的数组
watch([x, () => y.value], ([newX, newY]) => {
  console.log(`x is ${newX} and y is ${newY}`)
})

注意,你不能直接侦听响应式对象的属性值,例如:

javascript 复制代码
const obj = reactive({ count: 0 })

// 错误,因为 watch() 得到的参数是一个 number
watch(obj.count, (count) => {
  console.log(`Count is: ${count}`)
})

这里需要用一个返回该属性的 getter 函数:

javascript 复制代码
// 提供一个 getter 函数
watch(
  () => obj.count,
  (count) => {
    console.log(`Count is: ${count}`)
  }
)

深层侦听器

直接给 watch() 传入一个响应式对象,会隐式地创建一个深层侦听器------该回调函数在所有嵌套的变更时都会被触发:

javascript 复制代码
const obj = reactive({ count: 0 })

watch(obj, (newValue, oldValue) => {
  // 在嵌套的属性变更时触发
  // 注意:`newValue` 此处和 `oldValue` 是相等的
  // 因为它们是同一个对象!
})

obj.count++

相比之下,一个返回响应式对象的 getter 函数,只有在返回不同的对象时,才会触发回调:

javascript 复制代码
watch(
  () => state.someObject,
  () => {
    // 仅当 state.someObject 被替换时触发
  }
)

你也可以给上面这个例子显式地加上 deep 选项,强制转成深层侦听器:

javascript 复制代码
watch(
  () => state.someObject,
  (newValue, oldValue) => {
    // 注意:`newValue` 此处和 `oldValue` 是相等的
    // *除非* state.someObject 被整个替换了
  },
  { deep: true }
)

在 Vue 3.5+ 中,deep 选项还可以是一个数字,表示最大遍历深度------即 Vue 应该遍历对象嵌套属性的级数。

谨慎使用

深度侦听需要遍历被侦听对象中的所有嵌套的属性,当用于大型数据结构时,开销很大。因此请只在必要时才使用它,并且要留意性能。

即时回调的侦听器

watch 默认是懒执行的:仅当数据源变化时,才会执行回调。但在某些场景中,我们希望在创建侦听器时,立即执行一遍回调。举例来说,我们想请求一些初始数据,然后在相关状态更改时重新请求数据。

我们可以通过传入 immediate: true 选项来强制侦听器的回调立即执行:

javascript 复制代码
watch(
  source,
  (newValue, oldValue) => {
    // 立即执行,且当 `source` 改变时再次执行
  },
  { immediate: true }
)

一次性侦听器

  • 仅支持 3.4 及以上版本

每当被侦听源发生变化时,侦听器的回调就会执行。如果希望回调只在源变化时触发一次,请使用 once: true 选项。

javascript 复制代码
watch(
  source,
  (newValue, oldValue) => {
    // 当 `source` 变化时,仅触发一次
  },
  { once: true }
)

watchEffect()

侦听器的回调使用与源完全相同的响应式状态是很常见的。例如下面的代码,在每当 todoId 的引用发生变化时使用侦听器来加载一个远程资源:

javascript 复制代码
const todoId = ref(1)
const data = ref(null)

watch(
  todoId,
  async () => {
    const response = await fetch(
      `https://jsonplaceholder.typicode.com/todos/${todoId.value}`
    )
    data.value = await response.json()
  },
  { immediate: true }
)

特别是注意侦听器是如何两次使用 todoId 的,一次是作为源,另一次是在回调中。

我们可以用 watchEffect 函数 来简化上面的代码。watchEffect() 允许我们自动跟踪回调的响应式依赖。上面的侦听器可以重写为:

javascript 复制代码
watchEffect(async () => {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/todos/${todoId.value}`
  )
  data.value = await response.json()
})

这个例子中,回调会立即执行,不需要指定 immediate: true。在执行期间,它会自动追踪 todoId.value 作为依赖(和计算属性类似)。每当 todoId.value 变化时,回调会再次执行。有了 watchEffect(),我们不再需要明确传递 todoId 作为源值。

你可以参考一下这个例子watchEffect 和响应式的数据请求的操作。

对于这种只有一个依赖项的例子来说,watchEffect() 的好处相对较小。但是对于有多个依赖项的侦听器来说,使用 watchEffect() 可以消除手动维护依赖列表的负担。此外,如果你需要侦听一个嵌套数据结构中的几个属性,watchEffect() 可能会比深度侦听器更有效,因为它将只跟踪回调中被使用到的属性,而不是递归地跟踪所有的属性。

TIP:

watchEffect 仅会在其同步 执行期间,才追踪依赖。在使用异步回调时,只有在第一个 await 正常工作前访问到的属性才会被追踪。

watch vs. watchEffect

watchwatchEffect 都能响应式地执行有副作用的回调。它们之间的主要区别是追踪响应式依赖的方式:

  • watch 只追踪明确侦听的数据源。它不会追踪任何在回调中访问到的东西。另外,仅在数据源确实改变时才会触发回调。watch 会避免在发生副作用时追踪依赖,因此,我们能更加精确地控制回调函数的触发时机。

  • watchEffect,则会在副作用发生期间追踪依赖。它会在同步执行过程中,自动追踪所有能访问到的响应式属性。这更方便,而且代码往往更简洁,但有时其响应性依赖关系会不那么明确。

副作用清理

有时我们可能会在侦听器中执行副作用,例如异步请求:

javascript 复制代码
watch(id, (newId) => {
  fetch(`/api/${newId}`).then(() => {
    // 回调逻辑
  })
})

但是如果在请求完成之前 id 发生了变化怎么办?当上一个请求完成时,它仍会使用已经过时的 ID 值触发回调。理想情况下,我们希望能够在 id 变为新值时取消过时的请求。

我们可以使用 onWatcherCleanup() API 来注册一个清理函数,当侦听器失效并准备重新运行时会被调用:

javascript 复制代码
import { watch, onWatcherCleanup } from 'vue'

watch(id, (newId) => {
  const controller = new AbortController()

  fetch(`/api/${newId}`, { signal: controller.signal }).then(() => {
    // 回调逻辑
  })

  onWatcherCleanup(() => {
    // 终止过期请求
    controller.abort()
  })
})

请注意,onWatcherCleanup 仅在 Vue 3.5+ 中支持,并且必须在 watchEffect 效果函数或 watch 回调函数的同步执行期间调用:你不能在异步函数的 await 语句之后调用它。

作为替代,onCleanup 函数还作为第三个参数传递给侦听器回调,以及 watchEffect 作用函数的第一个参数:

javascript 复制代码
watch(id, (newId, oldId, onCleanup) => {
  // ...
  onCleanup(() => {
    // 清理逻辑
  })
})

watchEffect((onCleanup) => {
  // ...
  onCleanup(() => {
    // 清理逻辑
  })
})

这在 3.5 之前的版本有效。此外,通过函数参数传递的 onCleanup 与侦听器实例相绑定,因此不受 onWatcherCleanup 的同步限制。

回调的触发时机

当你更改了响应式状态,它可能会同时触发 Vue 组件更新和侦听器回调。

类似于组件更新,用户创建的侦听器回调函数也会被批量处理以避免重复调用。例如,如果我们同步将一千个项目推入被侦听的数组中,我们可能不希望侦听器触发一千次。

默认情况下,侦听器回调会在父组件更新 (如有) 之后 、所属组件的 DOM 更新之前被调用。这意味着如果你尝试在侦听器回调中访问所属组件的 DOM,那么 DOM 将处于更新前的状态。

Post Watchers

如果想在侦听器回调中能访问被 Vue 更新之后 的所属组件的 DOM,你需要指明 flush: 'post' 选项:

javascript 复制代码
watch(source, callback, {
  flush: 'post'
})

watchEffect(callback, {
  flush: 'post'
})

后置刷新的 watchEffect() 有个更方便的别名 watchPostEffect()

javascript 复制代码
import { watchPostEffect } from 'vue'

watchPostEffect(() => {
  /* 在 Vue 更新后执行 */
})

同步侦听器

你还可以创建一个同步触发的侦听器,它会在 Vue 进行任何更新之前触发:

javascript 复制代码
watch(source, callback, {
  flush: 'sync'
})

watchEffect(callback, {
  flush: 'sync'
})

同步触发的 watchEffect() 有个更方便的别名 watchSyncEffect()

javascript 复制代码
import { watchSyncEffect } from 'vue'

watchSyncEffect(() => {
  /* 在响应式数据变化时同步执行 */
})

谨慎使用

同步侦听器不会进行批处理,每当检测到响应式数据发生变化时就会触发。可以使用它来监视简单的布尔值,但应避免在可能多次同步修改的数据源 (如数组) 上使用。

停止侦听器

setup()<script setup> 中用同步语句创建的侦听器,会自动绑定到宿主组件实例上,并且会在宿主组件卸载时自动停止。因此,在大多数情况下,你无需关心怎么停止一个侦听器。

一个关键点是,侦听器必须用同步语句创建:如果用异步回调创建一个侦听器,那么它不会绑定到当前组件上,你必须手动停止它,以防内存泄漏。如下方这个例子:

html 复制代码
<script setup>
import { watchEffect } from 'vue'

// 它会自动停止
watchEffect(() => {})

// ...这个则不会!
setTimeout(() => {
  watchEffect(() => {})
}, 100)
</script>

要手动停止一个侦听器,请调用 watchwatchEffect 返回的函数:

javascript 复制代码
const unwatch = watchEffect(() => {})

// ...当该侦听器不再需要时
unwatch()

注意,需要异步创建侦听器的情况很少,请尽可能选择同步创建。如果需要等待一些异步数据,你可以使用条件式的侦听逻辑:

javascript 复制代码
// 需要异步请求得到的数据
const data = ref(null)

watchEffect(() => {
  if (data.value) {
    // 数据加载后执行某些操作...
  }
})

理解:

一、为什么需要侦听器?

比如你有一个输入框,用户输入 ? 时,你想去请求一个 API 获取回答。

这种"数据变了,我要去做点异步或开销较大的事"的场景,就需要侦听器。

html 复制代码
<script setup>
import { ref, watch } from 'vue'

const question = ref('')
const answer = ref('')

// 侦听 question 的变化
watch(question, async (newQuestion, oldQuestion) => {
  if (newQuestion.includes('?')) {
    const res = await fetch('https://yesno.wtf/api')
    answer.value = (await res.json()).answer
  }
})
</script>

<template>
  <input v-model="question" />
  <p>{{ answer }}</p>
</template>
  • watch(数据源, 回调函数)

  • 每当 question 变化,回调就会执行。

  • 回调里可以拿到新值和旧值(本例中没用 oldQuestion)。

二、侦听的数据源有哪些?

watch 的第一个参数可以是:

  1. 一个 refwatch(myRef, ...)

  2. 一个 getter 函数watch(() => x.value + y.value, ...)

  3. 一个响应式对象watch(obj, ...) 会隐式深度侦听

  4. 数组 :同时侦听多个源 watch([x, y], ...)

注意:不能直接侦听响应式对象的属性,而要写成 getter:

javascript 复制代码
const obj = reactive({ count: 0 })
// 错误:watch(obj.count, ...)   // 传入的是数字,不是响应式源
// 正确:
watch(() => obj.count, ...)

三、深层侦听器

  • 当你 watch 一个响应式对象 时,默认就是深层的:对象内部任何属性变化都会触发回调。

  • 但注意:newValueoldValue 是同一个对象引用,所以它们相等(除非你替换了整个对象)。

javascript 复制代码
const obj = reactive({ nested: { count: 0 } })
watch(obj, (newVal, oldVal) => {
  // obj.nested.count++ 会触发这里
})
  • 如果你只侦听一个 getter 函数,默认不是深层的。要深层侦听,加 { deep: true }

四、即时回调(immediate: true

默认情况下,watch 是懒执行的:数据第一次绑定时不会执行回调,只有变化时才执行。

如果你希望立即执行一次 (比如页面加载时先请求一次数据),加选项 { immediate: true }

javascript 复制代码
watch(source, callback, { immediate: true })

五、一次性侦听器(once: true,Vue 3.4+)

如果你只想在数据第一次变化时执行回调,后面再变化就不执行了,用 { once: true }

javascript 复制代码
watch(source, callback, { once: true })

六、watchEffect ------ 自动收集依赖

watchEffect 更简单:你写一个函数,Vue 自动追踪函数里用到的响应式数据,当任何依赖变化时,重新运行这个函数。

javascript 复制代码
import { watchEffect } from 'vue'

const todoId = ref(1)
const data = ref(null)

watchEffect(async () => {
  // 自动追踪 todoId.value
  const res = await fetch(`/api/todos/${todoId.value}`)
  data.value = await res.json()
})
  • 相当于 watch + immediate: true + 自动推断依赖。

  • 适合依赖比较多依赖关系不固定的场景。

  • 注意:异步回调中只有第一个 await 之前的依赖会被追踪。

七、watch vs watchEffect

特点 watch watchEffect
明确指定要侦听的数据 ✅ 是 ❌ 否(自动收集)
可以拿到新旧值 ✅ 是 ❌ 否(只有新状态)
懒执行(不立即运行) ✅ 默认 ❌ 立即运行
适用场景 需要精确控制触发时机、需要旧值 简单逻辑、依赖较多、不需要旧值

八、副作用清理(处理过时请求)

当你连续快速改变数据,可能会发起多个异步请求,后发出的请求可能比前一个慢,导致最终显示过时的结果。

你可以在新请求开始前取消旧请求。Vue 提供了清理函数。

方法一 (Vue 3.5+):在回调内调用 onWatcherCleanup

javascript 复制代码
import { watch, onWatcherCleanup } from 'vue'

watch(id, (newId) => {
  const controller = new AbortController()
  fetch(`/api/${newId}`, { signal: controller.signal }).then(...)
  onWatcherCleanup(() => controller.abort())
})

方法二 (兼容旧版):使用回调的第三个参数 onCleanup

javascript 复制代码
watch(id, (newId, oldId, onCleanup) => {
  const controller = new AbortController()
  fetch(...)
  onCleanup(() => controller.abort())
})
  • 每次回调执行前,会自动调用上一次的清理函数,确保不会有过时的副作用。

九、回调的触发时机(flush

Vue 默认的更新机制是:数据变化 → 父组件更新 → 当前组件 DOM 更新 → 侦听器回调。

因此,如果在侦听器里访问 DOM,拿到的是更新前的 DOM。

如果你需要在 DOM 更新后 执行侦听器,设置 flush: 'post'

javascript 复制代码
watch(source, callback, { flush: 'post' })
// 或使用别名 watchPostEffect

如果你需要同步 (不批量,立即)执行,用 flush: 'sync'(谨慎使用,可能影响性能)。

十、停止侦听器

  • setup()<script setup>同步创建的侦听器,会自动随组件卸载而停止。

  • 异步创建 的(比如在 setTimeout 里),需要手动停止:const stop = watch(...); stop()

十一、总结(小白记忆)

watch 用来"当某个数据变化时,执行一段逻辑(如请求、打印、保存)"。

  • 基本用法:watch(数据, (新值, 旧值) => { ... })

  • 侦听对象属性要写成 getter:watch(() => obj.prop, ...)

  • 需要立刻执行一次:{ immediate: true }

  • 只执行一次:{ once: true }

  • 自动收集依赖,更简单:watchEffect(() => { ... })

  • 清理副作用:使用 onWatcherCleanuponCleanup

  • DOM 更新后执行:{ flush: 'post' }

你现在可以试着自己写一个 watch,监听输入框的内容,当输入超过 10 个字符时弹出提示。这样就能实际体会到侦听器的用法了。

模板引用

虽然 Vue 的声明性渲染模型为你抽象了大部分对 DOM 的直接操作,但在某些情况下,我们仍然需要直接访问底层 DOM 元素。要实现这一点,我们可以使用特殊的 ref attribute:

html 复制代码
<input ref="input">

ref 是一个特殊的 attribute,和 v-for 章节中提到的 key 类似。它允许我们在一个特定的 DOM 元素或子组件实例被挂载后,获得对它的直接引用。这可能很有用,比如说在组件挂载时将焦点设置到一个 input 元素上,或在一个元素上初始化一个第三方库。

访问模板引用

要在组合式 API 中获取引用,我们可以使用辅助函数 useTemplateRef()

html 复制代码
<script setup>
import { useTemplateRef, onMounted } from 'vue'

// 第一个参数必须与模板中的 ref 值匹配
const input = useTemplateRef('my-input')

onMounted(() => {
  input.value.focus()
})
</script>

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

在使用 TypeScript 时,Vue 的 IDE 支持和 vue-tsc 将根据匹配的 ref attribute 所用的元素或组件自动推断 input.value 的类型。

3.5 前的用法

在 3.5 之前的版本尚未引入 useTemplateRef(),我们需要声明一个与模板里 ref attribute 匹配的引用:

html 复制代码
<script setup>
import { ref, onMounted } from 'vue'

// 声明一个 ref 来存放该元素的引用
// 必须和模板里的 ref 同名
const input = ref(null)

onMounted(() => {
  input.value.focus()
})
</script>

<template>
  <input ref="input" />
</template>

如果不使用 <script setup>,需确保从 setup() 返回 ref:

javascript 复制代码
export default {
  setup() {
    const input = ref(null)
    // ...
    return {
      input
    }
  }
}

注意,你只可以在组件挂载后 才能访问模板引用。如果你想在模板中的表达式上访问 input,在初次渲染时会是 null。这是因为在初次渲染前这个元素还不存在呢!

如果你需要侦听一个模板引用 ref 的变化,确保考虑到其值为 null 的情况:

javascript 复制代码
watchEffect(() => {
  if (input.value) {
    input.value.focus()
  } else {
    // 此时还未挂载,或此元素已经被卸载(例如通过 v-if 控制)
  }
})

也可参考:为模板引用标注类型

组件上的 ref

这一小节假设你已了解组件的相关知识,或者你也可以先跳过这里,之后再回来看。

模板引用也可以被用在一个子组件上。这种情况下引用中获得的值是组件实例:

html 复制代码
<script setup>
import { useTemplateRef, onMounted } from 'vue'
import Child from './Child.vue'

const childRef = useTemplateRef('child')

onMounted(() => {
  // childRef.value 将持有 <Child /> 的实例
})
</script>

<template>
  <Child ref="child" />
</template>

3.5 前的用法

复制代码

如果一个子组件使用的是选项式 API 或没有使用 <script setup>,被引用的组件实例和该子组件的 this 完全一致,这意味着父组件对子组件的每一个属性和方法都有完全的访问权。这使得在父组件和子组件之间创建紧密耦合的实现细节变得很容易,当然也因此,应该只在绝对需要时才使用组件引用。大多数情况下,你应该首先使用标准的 props 和 emit 接口来实现父子组件交互。

有一个例外的情况,使用了 <script setup> 的组件是默认私有 的:一个父组件无法访问到一个使用了 <script setup> 的子组件中的任何东西,除非子组件在其中通过 defineExpose 宏显式暴露:

html 复制代码
<script setup>
import { ref } from 'vue'

const a = 1
const b = ref(2)

// 像 defineExpose 这样的编译器宏不需要导入
defineExpose({
  a,
  b
})
</script>

当父组件通过模板引用获取到了该组件的实例时,得到的实例类型为 { a: number, b: number } (ref 都会自动解包,和一般的实例一样)。

请注意,defineExpose 必须在任何 await 操作之前调用。否则,在 await 操作后暴露的属性和方法将无法访问。

TypeScript 用户请参考:为组件的模板引用标注类型

v-for 中的模板引用

需要 v3.5 及以上版本

当在 v-for 中使用模板引用时,对应的 ref 中包含的值是一个数组,它将在元素被挂载后包含对应整个列表的所有元素:

html 复制代码
<script setup>
import { ref, useTemplateRef, onMounted } from 'vue'

const list = ref([
  /* ... */
])

const itemRefs = useTemplateRef('items')

onMounted(() => console.log(itemRefs.value))
</script>

<template>
  <ul>
    <li v-for="item in list" ref="items">
      {{ item }}
    </li>
  </ul>
</template>

在演练场中尝试一下

3.5 前的用法

在 3.5 版本以前,useTemplateRef() 尚未引入,需要声明一个与模板引用 attribute 同名的 ref。该 ref 的值需要是一个数组。

html 复制代码
<script setup>
import { ref, onMounted } from 'vue'

const list = ref([
  /* ... */
])

const itemRefs = ref([])

onMounted(() => console.log(itemRefs.value))
</script>

<template>
  <ul>
    <li v-for="item in list" ref="itemRefs">
      {{ item }}
    </li>
  </ul>
</template>

应该注意的是,ref 数组并不保证与源数组相同的顺序。

函数模板引用

除了使用字符串值作名字,ref attribute 还可以绑定为一个函数,会在每次组件更新时都被调用。该函数会收到元素引用作为其第一个参数:

html 复制代码
<input :ref="(el) => { /* 将 el 赋值给一个数据属性或 ref 变量 */ }">

注意我们这里需要使用动态的 :ref 绑定才能够传入一个函数。当绑定的元素被卸载时,函数也会被调用一次,此时的 el 参数会是 null。你当然也可以绑定一个组件方法而不是内联函数。

理解:

一、为什么需要模板引用?

正常情况下,你不需要手动操作 DOM,因为 Vue 的数据绑定会自动更新界面。

但有些需求必须直接操作 DOM,例如:

  • 组件挂载后,自动让一个输入框获得焦点(input.focus()

  • 获取元素的宽度、高度

  • 初始化一个第三方库(如 ECharts、SortableJS)

这些就需要 拿到真实的 DOM 元素

二、基本用法:给元素起个名字

在模板中,你可以给任意元素添加 ref 属性,就像一个 ID

html 复制代码
<template>
  <input ref="my-input" />
</template>

然后,在 <script setup> 中使用 useTemplateRef() 来获取它:

html 复制代码
<script setup>
import { useTemplateRef, onMounted } from 'vue'

// 参数必须和模板里的 ref 值一致
const input = useTemplateRef('my-input')

// 组件挂载后才能访问 DOM
onMounted(() => {
  input.value.focus()   // 让输入框获得焦点
})
</script>

重要input.value 在组件挂载前null(因为 DOM 还没生成)。所以只能在 onMounted 或之后的操作里访问它。

Vue 3.5 之前:写法略有不同,需要自己声明 ref 变量并与模板的 ref 同名:

javascript 复制代码
const input = ref(null)   // 名字必须与模板中的 ref 一致

现在推荐使用 useTemplateRef,更明确且类型推断更好。

三、侦听模板引用的变化

因为 ref 的值从 null 变成 DOM 元素,你也可以用 watchEffect 来响应它的变化:

javascript 复制代码
watchEffect(() => {
  if (input.value) {
    // 当元素出现时(比如 v-if 变为 true)执行操作
    input.value.focus()
  } else {
    // 元素被卸载了
  }
})

四、在子组件上使用 ref(获取组件实例)

你也可以给子组件 添加 ref,这样就能获取到子组件的实例,从而调用它的方法或访问它的数据。

html 复制代码
<template>
  <Child ref="child" />
</template>

<script setup>
import Child from './Child.vue'
import { useTemplateRef, onMounted } from 'vue'

const childRef = useTemplateRef('child')

onMounted(() => {
  // 可以调用子组件的方法(如果子组件暴露了的话)
  childRef.value?.someMethod()
})
</script>

但要注意 :如果子组件使用了 <script setup>,它默认是私有的 ,父组件无法直接访问它的任何东西。

子组件必须用 defineExpose 显式暴露一些属性和方法:

html 复制代码
<!-- Child.vue -->
<script setup>
import { ref } from 'vue'
const count = ref(0)
function reset() { count.value = 0 }

// 暴露给父组件
defineExpose({ count, reset })
</script>

这样父组件通过 childRef.value.countchildRef.value.reset() 就能访问了。

如果子组件用的是选项式 API(export default { ... }),父组件默认可以访问所有属性和方法,但这样耦合太强,不推荐。

五、在 v-for 中使用模板引用(Vue 3.5+)

当你在一个 v-for 循环里给多个元素相同的 ref 名称时,Vue 会自动把所有这些元素收集到一个数组中:

html 复制代码
<template>
  <ul>
    <li v-for="item in list" ref="items">{{ item }}</li>
  </ul>
</template>

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

const itemsRef = useTemplateRef('items')

onMounted(() => {
  // itemsRef.value 是一个数组,包含所有 li 元素
  console.log(itemsRef.value.length)
})
</script>

在 Vue 3.5 之前,你需要自己声明一个 ref 数组并手动处理,比较麻烦。

六、函数式模板引用(高级用法)

除了给一个固定的名字,你还可以给 ref 绑定一个函数,这个函数会在元素挂载或卸载时被调用:

html 复制代码
<template>
  <input :ref="(el) => { if (el) el.focus() }" />
</template>
  • 当元素被挂载时,el 是 DOM 元素。

  • 当元素被卸载时,elnull

    这种写法可以让你不用 useTemplateRef,直接处理元素。但日常开发中用命名引用就足够了。

七、总结(小白记忆)

模板引用 = 给 DOM 元素或子组件起个名字,然后用 useTemplateRef('名字') 拿到它。

  • 只能在挂载后访问 (如 onMounted 里)。

  • 子组件如果用了 <script setup>,必须 defineExpose 才能让父组件访问。

  • v-for 中同名 ref 会自动变成一个数组。

  • 还可以用 watchEffect 来监听 ref 的变化。

现在你可以试着自己写一个例子:

  • 一个按钮,点击后让某个输入框获得焦点。

  • 一个子组件,暴露一个 reset 方法,父组件通过 ref 调用它。

这样你就能真正掌握模板引用了。

组件基础

观看 Scrimba 的互动视频课程

组件允许我们将 UI 划分为独立的、可重用的部分,并且可以对每个部分进行单独的思考。在实际应用中,组件常常被组织成一个层层嵌套的树状结构:

这和我们嵌套 HTML 元素的方式类似,Vue 实现了自己的组件模型,使我们可以在每个组件内封装自定义内容与逻辑。Vue 同样也能很好地配合原生 Web Component。如果你想知道 Vue 组件与原生 Web Components 之间的关系,可以阅读此章节

定义一个组件

当使用构建步骤时,我们一般会将 Vue 组件定义在一个单独的 .vue 文件中,这被叫做单文件组件 (简称 SFC):

html 复制代码
<script setup>
import { ref } from 'vue'

const count = ref(0)
</script>

<template>
  <button @click="count++">You clicked me {{ count }} times.</button>
</template>

当不使用构建步骤时,一个 Vue 组件以一个包含 Vue 特定选项的 JavaScript 对象来定义:

javascript 复制代码
import { ref } from 'vue'

export default {
  setup() {
    const count = ref(0)
    return { count }
  },
  template: `
    <button @click="count++">
      You clicked me {{ count }} times.
    </button>`
  // 也可以针对一个 DOM 内联模板:
  // template: '#my-template-element'
}

这里的模板是一个内联的 JavaScript 字符串,Vue 将会在运行时编译它。你也可以使用 ID 选择器来指向一个元素 (通常是原生的 <template> 元素),Vue 将会使用其内容作为模板来源。

上面的例子中定义了一个组件,并在一个 .js 文件里默认导出了它自己,但你也可以通过具名导出在一个文件中导出多个组件。

使用组件

TIP:

我们会在接下来的指引中使用单文件组件语法,无论你是否使用构建步骤,组件相关的概念都是相同的。示例一节中展示了两种场景中的组件使用情况。

要使用一个子组件,我们需要在父组件中导入它。假设我们把计数器组件放在了一个叫做 ButtonCounter.vue 的文件中,这个组件将会以默认导出的形式被暴露给外部。

html 复制代码
<script setup>
import ButtonCounter from './ButtonCounter.vue'
</script>

<template>
  <h1>Here is a child component!</h1>
  <ButtonCounter />
</template>

通过 <script setup>,导入的组件都在模板中直接可用。

当然,你也可以全局地注册一个组件,使得它在当前应用中的任何组件上都可以使用,而不需要额外再导入。关于组件的全局注册和局部注册两种方式的利弊,我们放在了组件注册这一章节中专门讨论。

组件可以被重用任意多次:

html 复制代码
<h1>Here is a child component!</h1>
<ButtonCounter />
<ButtonCounter />
<ButtonCounter />

在演练场中尝试一下

你会注意到,每当点击这些按钮时,每一个组件都维护着自己的状态,是不同的 count。这是因为每当你使用一个组件,就创建了一个新的实例

在单文件组件中,推荐为子组件使用 PascalCase 的标签名,以此来和原生的 HTML 元素作区分。虽然原生 HTML 标签名是不区分大小写的,但 Vue 单文件组件是可以在编译中区分大小写的。我们也可以使用 /> 来关闭一个标签。

如果你是直接在 DOM 中书写模板 (例如原生 <template> 元素的内容),模板的编译需要遵从浏览器中 HTML 的解析行为。在这种情况下,你应该需要使用 kebab-case 形式并显式地关闭这些组件的标签。

html 复制代码
<!-- 如果是在 DOM 中书写该模板 -->
<button-counter></button-counter>
<button-counter></button-counter>
<button-counter></button-counter>

请看 DOM 内模板解析注意事项了解更多细节。

传递 props

如果我们正在构建一个博客,我们可能需要一个表示博客文章的组件。我们希望所有的博客文章分享相同的视觉布局,但有不同的内容。要实现这样的效果自然必须向组件中传递数据,例如每篇文章标题和内容,这就会使用到 props。

Props 是一种特别的 attributes,你可以在组件上声明注册。要传递给博客文章组件一个标题,我们必须在组件的 props 列表上声明它。这里要用到 defineProps 宏:

html 复制代码
<script setup>
defineProps(['title'])
</script>

<template>
  <h4>{{ title }}</h4>
</template>

defineProps 是一个仅 <script setup> 中可用的编译宏命令,并不需要显式地导入。声明的 props 会自动暴露给模板。defineProps 会返回一个对象,其中包含了可以传递给组件的所有 props:

javascript 复制代码
const props = defineProps(['title'])
console.log(props.title)

TypeScript 用户请参考:为组件 props 标注类型

如果你没有使用 <script setup>,props 必须以 props 选项的方式声明,props 对象会作为 setup() 函数的第一个参数被传入:

javascript 复制代码
export default {
  props: ['title'],
  setup(props) {
    console.log(props.title)
  }
}

一个组件可以有任意多的 props,默认情况下,所有 prop 都接受任意类型的值。

当一个 prop 被注册后,可以像这样以自定义 attribute 的形式传递数据给它:

html 复制代码
<BlogPost title="My journey with Vue" />
<BlogPost title="Blogging with Vue" />
<BlogPost title="Why Vue is so fun" />

在实际应用中,我们可能在父组件中会有如下的一个博客文章数组:

javascript 复制代码
const posts = ref([
  { id: 1, title: 'My journey with Vue' },
  { id: 2, title: 'Blogging with Vue' },
  { id: 3, title: 'Why Vue is so fun' }
])

这种情况下,我们可以使用 v-for 来渲染它们:

html 复制代码
<BlogPost
  v-for="post in posts"
  :key="post.id"
  :title="post.title"
 />

在演练场中尝试一下

留意我们是如何使用 v-bind 语法 (:title="post.title") 来传递动态 prop 值的。当事先不知道要渲染的确切内容时,这一点特别有用。

以上就是目前你需要了解的关于 props 的全部了。如果你看完本章节后还想知道更多细节,我们推荐你深入阅读关于 props 的完整指引

监听事件

让我们继续关注我们的 <BlogPost> 组件。我们会发现有时候它需要与父组件进行交互。例如,要在此处实现无障碍访问的需求,将博客文章的文字能够放大,而页面的其余部分仍使用默认字号。

在父组件中,我们可以添加一个 postFontSize ref 来实现这个效果:

javascript 复制代码
const posts = ref([
  /* ... */
])

const postFontSize = ref(1)

在模板中用它来控制所有博客文章的字体大小:

html 复制代码
<div :style="{ fontSize: postFontSize + 'em' }">
  <BlogPost
    v-for="post in posts"
    :key="post.id"
    :title="post.title"
   />
</div>

然后,给 <BlogPost> 组件添加一个按钮:

html 复制代码
<!-- 省略了 <script> -->
<template>
  <div class="blog-post">
    <h4>{{ title }}</h4>
    <button>Enlarge text</button>
  </div>
</template>

这个按钮目前还没有做任何事情,我们想要点击这个按钮来告诉父组件它应该放大所有博客文章的文字。要解决这个问题,组件实例提供了一个自定义事件系统。父组件可以通过 v-on@ 来选择性地监听子组件上抛的事件,就像监听原生 DOM 事件那样:

html 复制代码
<BlogPost
  ...
  @enlarge-text="postFontSize += 0.1"
 />

子组件可以通过调用内置的 [emit 方法](https://cn.vuejs.org/api/component-instance.html#emit "emit 方法"),通过传入事件名称来抛出一个事件:

html 复制代码
<!-- 省略了 <script> -->
<template>
  <div class="blog-post">
    <h4>{{ title }}</h4>
    <button @click="$emit('enlarge-text')">Enlarge text</button>
  </div>
</template>

因为有了 @enlarge-text="postFontSize += 0.1" 的监听,父组件会接收这一事件,从而更新 postFontSize 的值。

在演练场中尝试一下

我们可以通过 defineEmits 宏来声明需要抛出的事件:

html 复制代码
<script setup>
defineProps(['title'])
defineEmits(['enlarge-text'])
</script>

这声明了一个组件可能触发的所有事件,还可以对事件的参数进行验证。同时,这还可以让 Vue 避免将它们作为原生事件监听器隐式地应用于子组件的根元素。

defineProps 类似,defineEmits 仅可用于 <script setup> 之中,并且不需要导入,它返回一个等同于 $emit 方法的 emit 函数。它可以被用于在组件的 <script setup> 中抛出事件,因为此处无法直接访问 $emit

html 复制代码
<script setup>
const emit = defineEmits(['enlarge-text'])

emit('enlarge-text')
</script>

TypeScript 用户请参考:为组件 emits 标注类型

如果你没有在使用 <script setup>,你可以通过 emits 选项定义组件会抛出的事件。你可以从 setup() 函数的第二个参数,即 setup 上下文对象上访问到 emit 函数:

javascript 复制代码
export default {
  emits: ['enlarge-text'],
  setup(props, ctx) {
    ctx.emit('enlarge-text')
  }
}

以上就是目前你需要了解的关于组件自定义事件的所有知识了。如果你看完本章节后还想知道更多细节,请深入阅读组件事件章节。

通过插槽来分配内容

一些情况下我们会希望能和 HTML 元素一样向组件中传递内容:

html 复制代码
<AlertBox>
  Something bad happened.
</AlertBox>

我们期望能渲染成这样:

这可以通过 Vue 的自定义 <slot> 元素来实现:

html 复制代码
<template>
  <div class="alert-box">
    <strong>This is an Error for Demo Purposes</strong>
    <slot />
  </div>
</template>

<style scoped>
.alert-box {
  /* ... */
}
</style>

如上所示,我们使用 <slot> 作为一个占位符,父组件传递进来的内容就会渲染在这里。

在演练场中尝试一下

以上就是目前你需要了解的关于插槽的所有知识了。如果你看完本章节后还想知道更多细节,请深入阅读组件插槽章节。

动态组件

有些场景会需要在两个组件间来回切换,比如 Tab 界面:

在演练场中查看示例

上面的例子是通过 Vue 的 <component> 元素和特殊的 is attribute 实现的:

html 复制代码
<!-- currentTab 改变时组件也改变 -->
<component :is="tabs[currentTab]"></component>

在上面的例子中,被传给 :is 的值可以是以下几种:

  • 被注册的组件名
  • 导入的组件对象

你也可以使用 is attribute 来创建一般的 HTML 元素。

当使用 <component :is="..."> 来在多个组件间作切换时,被切换掉的组件会被卸载。我们可以通过 <KeepAlive> 组件强制被切换掉的组件仍然保持"存活"的状态。

DOM 内模板解析注意事项

如果你想在 DOM 中直接书写 Vue 模板,Vue 则必须从 DOM 中获取模板字符串。由于浏览器的原生 HTML 解析行为限制,有一些需要注意的事项。

TIP:

请注意下面讨论只适用于直接在 DOM 中编写模板的情况。如果你使用来自以下来源的字符串模板,就不需要顾虑这些限制了:

  • 单文件组件
  • 内联模板字符串 (例如 template: '...')
  • <script type="text/x-template">

大小写区分

HTML 标签和属性名称是不分大小写的,所以浏览器会把任何大写的字符解释为小写。这意味着当你使用 DOM 内的模板时,无论是 PascalCase 形式的组件名称、camelCase 形式的 prop 名称还是 v-on 的事件名称,都需要转换为相应等价的 kebab-case (短横线连字符) 形式:

javascript 复制代码
// JavaScript 中的 camelCase
const BlogPost = {
  props: ['postTitle'],
  emits: ['updatePost'],
  template: `
    <h3>{{ postTitle }}</h3>
  `
}
html 复制代码
<!-- HTML 中的 kebab-case -->
<blog-post post-title="hello!" @update-post="onUpdatePost"></blog-post>

闭合标签

我们在上面的例子中已经使用过了闭合标签 (self-closing tag):

html 复制代码
<MyComponent />

这是因为 Vue 的模板解析器支持任意标签使用 /> 作为标签关闭的标志。

然而在 DOM 内模板中,我们必须显式地写出关闭标签:

html 复制代码
<my-component></my-component>

这是由于 HTML 只允许一小部分特殊的元素省略其关闭标签,最常见的就是 <input><img>。对于其他的元素来说,如果你省略了关闭标签,原生的 HTML 解析器会认为开启的标签永远没有结束,用下面这个代码片段举例来说:

html 复制代码
<my-component /> <!-- 我们想要在这里关闭标签... -->
<span>hello</span>

将被解析为:

html 复制代码
<my-component>
  <span>hello</span>
</my-component> <!-- 但浏览器会在这里关闭标签 -->

元素位置限制

某些 HTML 元素对于放在其中的元素类型有限制,例如 <ul><ol><table><select>,相应的,某些元素仅在放置于特定元素中时才会显示,例如 <li><tr><option>

这将导致在使用带有此类限制元素的组件时出现问题。例如:

html 复制代码
<table>
  <blog-post-row></blog-post-row>
</table>

自定义的组件 <blog-post-row> 将作为无效的内容被忽略,因而在最终呈现的输出中造成错误。我们可以使用特殊的 is attribute 作为一种解决方案:

html 复制代码
<table>
  <tr is="vue:blog-post-row"></tr>
</table>

TIP:

当使用在原生 HTML 元素上时,is 的值必须加上前缀 vue: 才可以被解析为一个 Vue 组件。这一点是必要的,为了避免和原生的自定义内置元素相混淆。

以上就是你需要了解的关于 DOM 内模板解析的所有注意事项,同时也是 Vue 基础 部分的所有内容。祝贺你!虽然还有很多需要学习的,但你可以先暂停一下,去用 Vue 做一些有趣的东西,或者研究一些示例

完成了本页的阅读后,回顾一下你刚才所学到的知识,如果还想知道更多细节,我们推荐你继续阅读关于组件的完整指引。

理解:

一、什么是"组件"?

组件 = 一个独立、可复用的 UI 小零件

想象你玩乐高积木:

  • 乐高有各种小零件(轮子、窗户、小人)。

  • 每个零件都有自己的样子和功能。

  • 你可以拿起一个零件,在多个地方重复使用它。

Vue 组件就是网页上的"乐高零件"。

比如:一个按钮组件、一个博客文章卡片组件、一个视频播放器组件。

每个组件自己管理自己的数据和显示,你可以把它们拼在一起,组成整个页面。

二、组件长什么样?(单文件组件)

Vue 推荐把每个组件写在一个 .vue 文件里。这个文件包含三部分:

  1. <script setup>:写 JavaScript 逻辑(数据、方法等)

  2. <template>:写 HTML 结构(长得什么样)

  3. <style>:写 CSS 样式(可选,让组件好看)

举个最简单的按钮组件例子:ButtonCounter.vue

html 复制代码
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>

<template>
  <button @click="count++">你点了 {{ count }} 次</button>
</template>

这个组件自己维护了一个 count 计数,点击按钮数字就增加。

三、如何使用组件?

在另一个文件(比如父组件)里,先导入 这个组件,然后在 <template> 里像写 HTML 标签一样用它:

html 复制代码
<script setup>
import ButtonCounter from './ButtonCounter.vue'
</script>

<template>
  <h1>我的页面</h1>
  <ButtonCounter />   <!-- 使用组件 -->
  <ButtonCounter />   <!-- 又可以再用一次 -->
  <ButtonCounter />   <!-- 三次 -->
</template>

页面上会出现三个独立的按钮,每个按钮有自己的计数(互不影响)。

因为每用一次 <ButtonCounter />,就相当于创建了一个新的"零件实例"。

注意 :在 Vue 单文件组件中,推荐用 PascalCase 命名(单词首字母大写,如 ButtonCounter),这样容易和普通 HTML 标签区分。

四、怎么给组件传递不同的数据?(Props)

假设你要做一个"博客文章组件" BlogPost.vue

这个组件的结构(标题 + 正文)是相同的,但每篇文章的标题和内容不一样。

你可以通过 props 来向组件传递数据,就像给函数传参数一样。

第一步:在子组件里声明需要哪些 props

html 复制代码
<!-- BlogPost.vue -->
<script setup>
defineProps(['title', 'content'])   // 声明接收 title 和 content
</script>

<template>
  <div class="post">
    <h2>{{ title }}</h2>
    <p>{{ content }}</p>
  </div>
</template>

第二步:在父组件里使用,并通过属性传递具体值

html 复制代码
<script setup>
import BlogPost from './BlogPost.vue'
</script>

<template>
  <BlogPost title="我的Vue学习" content="今天学了组件..." />
  <BlogPost title="第二篇" content="继续学习..." />
</template>

你也可以用 v-for 循环一个文章数组来动态渲染:

html 复制代码
<template>
  <BlogPost
    v-for="post in posts"
    :key="post.id"
    :title="post.title"
    :content="post.content"
  />
</template>

注意 :titlev-bind:title 的简写,用于绑定动态值。

五、子组件怎么和父组件通信?(自定义事件)

有时子组件需要告诉父组件:"发生了什么事,你处理一下"。

比如博客文章组件里有个"放大文字"按钮,点击后希望父组件把整个页面的文字变大。

子组件通过 $emit 抛出一个事件

html 复制代码
<!-- BlogPost.vue -->
<template>
  <h4>{{ title }}</h4>
  <button @click="$emit('enlarge-text')">放大文字</button>
</template>

父组件监听这个事件

html 复制代码
<template>
  <BlogPost @enlarge-text="postFontSize += 0.1" />
</template>

也可以先用 defineEmits 声明事件(更好的写法):

html 复制代码
<script setup>
const emit = defineEmits(['enlarge-text'])
</script>

<template>
  <button @click="emit('enlarge-text')">放大</button>
</template>

这样,父组件就能响应子组件的动作了。

六、怎么给组件传递"内容"? (插槽 slot)

有时你希望组件像普通的 HTML 标签一样,可以在标签中间写内容。

比如:

html 复制代码
<AlertBox>这里写提示信息</AlertBox>

组件内部用 <slot> 作为占位符,父组件传入的内容就会填到这里。

AlertBox.vue

html 复制代码
<template>
  <div class="alert">
    <strong>错误提示</strong>
    <slot />   <!-- 父组件写在这里的内容会显示在这儿 -->
  </div>
</template>

使用

html 复制代码
<AlertBox> 网络连接失败,请重试。 </AlertBox>

渲染结果:

错误提示

网络连接失败,请重试。

七、动态组件(在不同组件间切换)

比如你有两个组件:HomeAbout,你想点击按钮切换显示哪一个。

可以用 <component :is="当前组件名"> 来实现。

html 复制代码
<script setup>
import Home from './Home.vue'
import About from './About.vue'
import { ref } from 'vue'

const currentTab = ref('Home')   // 'Home' 或 'About'
</script>

<template>
  <button @click="currentTab = 'Home'">首页</button>
  <button @click="currentTab = 'About'">关于</button>
  <component :is="currentTab"></component>
</template>

:is 可以绑定组件名(字符串)或者直接导入的组件对象。

注意:切换时,之前的组件会被销毁(状态丢失)。如果想保持状态,用 <KeepAlive> 包裹。

八、直接在 HTML 文件中写模板的注意事项(了解即可)

如果你没有用构建工具,而是直接在 HTML 页面里写 Vue 模板(比如 <div id="app"> 里面直接写),会有些小限制:

  • 组件名要用 kebab-case (如 blog-post 而不是 BlogPost

  • 必须显式闭合标签<blog-post></blog-post> 不能写成 <blog-post />

  • 某些元素内不能放自定义组件 :例如 <table> 里直接写 <blog-post-row> 是无效的,要用 <tr is="vue:blog-post-row"> 来绕过。

不过现在大多数项目都用单文件组件或构建工具,这些限制很少遇到,你可以先跳过。

九、总结(小白版)

  • 组件:独立的小零件,可以重复使用。

  • 定义 :写在 .vue 文件里,包含 template + script + style

  • 使用 :先 import,然后像 <MyComponent /> 一样写在模板里。

  • 传数据 :用 props,父组件像传 HTML 属性一样传给子组件。

  • 子传父 :用 $emit 抛事件,父组件用 @事件名 监听。

  • 传内容 :用 <slot>,像普通标签一样在组件中间写内容。

  • 动态切换 :用 <component :is="...">

你不需要一下子全记住,可以边写边查。现在试着创建一个最简单的计数器组件,然后在父组件里用几次,就能体会到组件的乐趣了。如果还有不懂的术语,随时问我。

生命周期钩子

每个 Vue 组件实例在创建时都需要经历一系列的初始化步骤,比如设置好数据侦听,编译模板,挂载实例到 DOM,以及在数据改变时更新 DOM。在此过程中,它也会运行被称为生命周期钩子的函数,让开发者有机会在特定阶段运行自己的代码。

注册周期钩子

举例来说,onMounted 钩子可以用来在组件完成初始渲染并创建 DOM 节点后运行代码:

html 复制代码
<script setup>
import { onMounted } from 'vue'

onMounted(() => {
  console.log(`the component is now mounted.`)
})
</script>

还有其他一些钩子,会在实例生命周期的不同阶段被调用,最常用的是 onMountedonUpdatedonUnmounted。所有生命周期钩子的完整参考及其用法请参考 API 索引

当调用 onMounted 时,Vue 会自动将回调函数注册到当前正被初始化的组件实例上。这意味着这些钩子应当在组件初始化时被同步注册。例如,请不要这样做:

javascript 复制代码
setTimeout(() => {
  onMounted(() => {
    // 异步注册时当前组件实例已丢失
    // 这将不会正常工作
  })
}, 100)

注意这并不意味着对 onMounted 的调用必须放在 setup()<script setup> 内的词法上下文中。onMounted() 也可以在一个外部函数中调用,只要调用栈是同步的,且最终起源自 setup() 就可以。

生命周期图示

下面是实例生命周期的图表。你现在并不需要完全理解图中的所有内容,但以后它将是一个有用的参考。

有关所有生命周期钩子及其各自用例的详细信息,请参考生命周期钩子 API 索引

理解:

一、什么是生命周期?

任何一个 Vue 组件,从 被创建显示在页面上 ,再到 后来可能被修改、最后被销毁 ,都会经历一系列固定的阶段。

就像一个人从 出生 → 成长 → 工作 → 去世 一样。

每个阶段开始或结束时,Vue 都会自动调用一个 函数 (钩子)。

你可以在这些函数里写自己的代码,比如:"当组件被挂载到页面上时,请求数据"或"当组件要被销毁时,清理定时器"。

二、常用的生命周期钩子(小白版)

钩子名 发生时机 可以用来做什么?
onMounted 组件已经被挂载到 DOM(页面上真正显示出来了) 发送网络请求、操作 DOM(比如让输入框自动获得焦点)、启动计时器
onUpdated 组件因为数据变化而重新渲染了 DOM 在 DOM 更新后做一些操作(比如获取更新后的元素尺寸)
onUnmounted 组件即将被销毁(从页面上移除) 清理定时器、取消网络请求、解绑事件,避免内存泄漏

另外还有几个不常用的,比如 onBeforeMount(挂载前)、onBeforeUpdate(更新前)、onBeforeUnmount(销毁前),暂时可以先不管。

三、怎么注册生命周期钩子?

<script setup> 里,从 vue 导入对应的函数,然后调用它,传入一个箭头函数(回调):

html 复制代码
<script setup>
import { onMounted, onUpdated, onUnmounted } from 'vue'

onMounted(() => {
  console.log('组件已经显示在页面上了')
})

onUpdated(() => {
  console.log('组件的数据变了,DOM 重新渲染了')
})

onUnmounted(() => {
  console.log('组件即将被移除,可以在这里做清理')
})
</script>

你不需要做任何额外的事情,Vue 会在正确的时机自动执行这些函数。

四、为什么文档强调"钩子必须在同步环境下注册"?

因为 Vue 需要知道当前是哪个组件在注册钩子。

如果你把 onMounted 放在 setTimeout 里,或者放在一个异步请求的回调里,Vue 已经丢失了当前组件的上下文,钩子就注册不到正确的组件上,导致不生效。

javascript 复制代码
// ❌ 错误:在异步回调里注册
setTimeout(() => {
  onMounted(() => { ... })   // 不会正常工作
}, 100)

// ✅ 正确:直接在顶层同步调用
onMounted(() => { ... })

这并不意味着你必须把钩子写在 <script setup> 的顶层,你也可以把钩子放在一个普通函数里,只要这个函数是同步被调用的(最终调用栈源自 setup)。

但作为小白,你只需要记住:onMounted 等钩子直接写在 <script setup> 的顶层,不要写在 setTimeoutPromise.then 或异步函数里

五、生命周期图示怎么看?

官方有一张图,画了组件从创建到销毁的完整流程,有很多箭头和方块。

你现在不需要完全看懂每个细节,只需要知道:

  • 有两条主要路径:挂载 → 更新 → 卸载

  • 每个方块旁边都有对应的钩子名称

  • 以后遇到问题时,可以回到这张图看看当前处在哪个阶段,该用哪个钩子

六、一句话总结

生命周期钩子 = Vue 在组件不同阶段自动调用的"闹钟"。你只需要把代码放到对应的钩子函数里,就能在正确的时间执行。

最常用的就是 onMounted

  • 打开页面后,等组件显示出来,就自动执行 onMounted 里的代码。

  • 适合在这里请求数据、启动动画、操作 DOM。

现在你可以试一下:在你的组件里加一个 onMounted,打印一条消息,打开页面看控制台,消息就出来了。

组件注册

此章节假设你已经看过了组件基础。若你还不了解组件是什么,请先阅读该章节。

一个 Vue 组件在使用前需要先被"注册",这样 Vue 才能在渲染模板时找到其对应的实现。组件注册有两种方式:全局注册和局部注册。

全局注册

我们可以使用 Vue 应用实例.component() 方法,让组件在当前 Vue 应用中全局可用。

javascript 复制代码
import { createApp } from 'vue'

const app = createApp({})

app.component(
  // 注册的名字
  'MyComponent',
  // 组件的实现
  {
    /* ... */
  }
)

如果使用单文件组件,你可以注册被导入的 .vue 文件:

javascript 复制代码
import MyComponent from './App.vue'

app.component('MyComponent', MyComponent)

.component() 方法可以被链式调用:

javascript 复制代码
app
  .component('ComponentA', ComponentA)
  .component('ComponentB', ComponentB)
  .component('ComponentC', ComponentC)

全局注册的组件可以在此应用的任意组件的模板中使用:

html 复制代码
<!-- 这在当前应用的任意组件中都可用 -->
<ComponentA/>
<ComponentB/>
<ComponentC/>

所有的子组件也可以使用全局注册的组件,这意味着这三个组件也都可以在彼此内部使用。

局部注册

全局注册虽然很方便,但有以下几个问题:

  1. 全局注册,但并没有被使用的组件无法在生产打包时被自动移除 (也叫"tree-shaking")。如果你全局注册了一个组件,即使它并没有被实际使用,它仍然会出现在打包后的 JS 文件中。

  2. 全局注册在大型项目中使项目的依赖关系变得不那么明确。在父组件中使用子组件时,不太容易定位子组件的实现。和使用过多的全局变量一样,这可能会影响应用长期的可维护性。

相比之下,局部注册的组件需要在使用它的父组件中显式导入,并且只能在该父组件中使用。它的优点是使组件之间的依赖关系更加明确,并且对 tree-shaking 更加友好。

在使用 <script setup> 的单文件组件中,导入的组件可以直接在模板中使用,无需注册:

html 复制代码
<script setup>
import ComponentA from './ComponentA.vue'
</script>

<template>
  <ComponentA />
</template>

如果没有使用 <script setup>,则需要使用 components 选项来显式注册:

javascript 复制代码
import ComponentA from './ComponentA.js'

export default {
  components: {
    ComponentA
  },
  setup() {
    // ...
  }
}

对于每个 components 对象里的属性,它们的 key 名就是注册的组件名,而值就是相应组件的实现。上面的例子中使用的是 ES2015 的缩写语法,等价于:

javascript 复制代码
export default {
  components: {
    ComponentA: ComponentA
  }
  // ...
}

请注意:局部注册的组件在后代组件中可用 。在这个例子中,ComponentA 注册后仅在当前组件可用,而在任何的子组件或更深层的子组件中都不可用。

组件名格式

在整个指引中,我们都使用 PascalCase 作为组件名的注册格式,这是因为:

  1. PascalCase 是合法的 JavaScript 标识符。这使得在 JavaScript 中导入和注册组件都很容易,同时 IDE 也能提供较好的自动补全。

  2. <PascalCase /> 在模板中更明显地表明了这是一个 Vue 组件,而不是原生 HTML 元素。同时也能够将 Vue 组件和自定义元素 (web components) 区分开来。

在单文件组件和内联字符串模板中,我们都推荐这样做。但是,PascalCase 的标签名在 DOM 内模板中是不可用的,详情参见 DOM 内模板解析注意事项

为了方便,Vue 支持将模板中使用 kebab-case 的标签解析为使用 PascalCase 注册的组件。这意味着一个以 MyComponent 为名注册的组件,在模板 (或由 Vue 渲染的 HTML 元素) 中可以通过 <MyComponent><my-component> 引用。这让我们能够使用同样的 JavaScript 组件注册代码来配合不同来源的模板。

理解:

一、为什么需要"注册"?

想象你是一个仓库管理员,仓库里有很多种零件(组件)。

如果你不告诉管理员"这个零件叫什么名字、放在哪里",那么当工人(模板)想要用这个零件时,就找不到它。

注册 就是这个"告诉管理员"的过程:

  • 你要给组件起个名字

  • 把组件实际代码(从哪里导入、或者直接写的对象)告诉 Vue

  • 这样,在模板里写 <组件名 /> 时,Vue 就知道该渲染什么了。

二、两种注册方式

Vue 提供了两种注册方式:全局注册局部注册

  1. 全局注册 ------ 整个应用都可以用

做法 :在创建应用实例(app)后,调用 .component() 方法。

javascript 复制代码
import { createApp } from 'vue'
import MyComponent from './MyComponent.vue'

const app = createApp({})

app.component('MyComponent', MyComponent)   // 全局注册

之后,在应用的任何 组件模板里,都可以直接写 <MyComponent />,不需要再导入。

优点 :方便,不用在每个文件里重复导入。

缺点

  • 即使某个组件从未被使用,它也会被打包进最终代码(无法被"摇树优化" tree-shaking 移除),增加文件体积。

  • 大型项目中,依赖关系不清晰:你看到一个 <MyComponent />,很难一下子知道它来自哪里。

适合场景:小型项目、原型开发、或者一些基础通用组件(比如一个按钮库)。

  1. 局部注册 ------ 只在当前组件内可用

做法 :在使用它的父组件里显式导入 ,然后放在 components 选项里(如果不用 <script setup>),或者直接在 <script setup> 中导入并使用。

使用 <script setup>(推荐,自动局部注册)
html 复制代码
<script setup>
import ComponentA from './ComponentA.vue'
import ComponentB from './ComponentB.vue'
</script>

<template>
  <ComponentA />
  <ComponentB />
</template>

<script setup> 中,导入的组件自动就可用,不需要额外注册。这就是"局部注册":只有当前这个组件能使用它们,子组件或兄弟组件不能。

如果没有 <script setup>(选项式 API)
javascript 复制代码
import ComponentA from './ComponentA.js'

export default {
  components: {
    ComponentA   // 等价于 ComponentA: ComponentA
  },
  setup() {
    // ...
  }
}

优点

  • 依赖关系明确:在文件头部就能看到用了哪些子组件。

  • 打包时,没被用到的组件会被自动移除(tree-shaking),节省体积。

  • 组件的作用域被限制,不会全局污染。

缺点:每个使用的地方都要重复导入(但这也是明确性的代价)。

适合场景:几乎所有实际项目都应该优先使用局部注册。除非有明确的理由(比如一个非常通用的 UI 库按钮),否则不要用全局注册。

三、组件名怎么写?(大小写和连字符)

Vue 推荐组件名使用 PascalCase (每个单词首字母大写,如 MyComponent)。

原因:

  • 在 JavaScript 里是合法的标识符,导入和注册都很自然。

  • 在模板中 <MyComponent /> 一眼就能看出是 Vue 组件,而不是原生 HTML(原生都是小写)。

但是,在 DOM 内模板 (比如直接写在 index.html<div id="app"> 里)中,HTML 是大小写不敏感的,所以写 <MyComponent /> 可能会被浏览器当作 <mycomponent />,导致找不到组件。

因此,在 DOM 内模板中要用 kebab-case (小写字母 + 连字符),如 <my-component></my-component>

Vue 的灵活性 :用 PascalCase 注册的组件,在模板中既可以写 <MyComponent> 也可以写 <my-component>(除了 DOM 内模板有大小写限制)。但是推荐统一用 PascalCase 书写模板,除非你被迫使用 DOM 内模板。

四、总结(小白版)

  • 全局注册

    • app.component('名字', 组件)

    • 一次注册,整个应用任何地方都能用

    • 缺点:即使没用也会打包进去,依赖不清晰

  • 局部注册

    • 在父组件里 import 子组件

    • <script setup> 时导入即用

    • 依赖清楚,打包时未使用的组件会被扔掉

    • 推荐日常开发使用

  • 组件名

    • 推荐 PascalCase(如 MyButton

    • 在模板里写 <MyButton />

    • 如果必须在 HTML 文件里直接写模板,改用 <my-button></my-button>

一句话实践指南

.vue 文件里,直接 import 组件然后在模板里用 <PascalCaseName /> 就可以了,这就是局部注册,也是 Vue 最推荐的用法。

不用主动调用 .component(),除非你要做一个全局的组件库。

现在你可以检查一下自己写的 Vue 代码:是不是每个组件都先 import 再使用?如果是,那就已经用上了局部注册,完全正确。

Props

此章节假设你已经看过了组件基础。若你还不了解组件是什么,请先阅读该章节。

Props 声明

一个组件需要显式声明它所接受的 props,这样 Vue 才能知道外部传入的哪些是 props,哪些是透传 attribute (关于透传 attribute,我们会在专门的章节中讨论)。

在使用 <script setup> 的单文件组件中,props 可以使用 defineProps() 宏来声明:

html 复制代码
<script setup>
const props = defineProps(['foo'])

console.log(props.foo)
</script>

在没有使用 <script setup> 的组件中,props 可以使用 props 选项来声明:

javascript 复制代码
export default {
  props: ['foo'],
  setup(props) {
    // setup() 接收 props 作为第一个参数
    console.log(props.foo)
  }
}

注意传递给 defineProps() 的参数和提供给 props 选项的值是相同的,两种声明方式背后其实使用的都是 props 选项。

除了使用字符串数组来声明 props 外,还可以使用对象的形式:

javascript 复制代码
// 使用 <script setup>
defineProps({
  title: String,
  likes: Number
})
javascript 复制代码
// 非 <script setup>
export default {
  props: {
    title: String,
    likes: Number
  }
}

对于以对象形式声明的每个属性,key 是 prop 的名称,而值则是该 prop 预期类型的构造函数。比如,如果要求一个 prop 的值是 number 类型,则可使用 Number 构造函数作为其声明的值。

对象形式的 props 声明不仅可以一定程度上作为组件的文档,而且如果其他开发者在使用你的组件时传递了错误的类型,也会在浏览器控制台中抛出警告。我们将在本章节稍后进一步讨论有关 prop 校验的更多细节。

如果你正在搭配 TypeScript 使用 <script setup>,也可以使用类型标注来声明 props:

html 复制代码
<script setup lang="ts">
defineProps<{
  title?: string
  likes?: number
}>()
</script>

更多关于基于类型的声明的细节请参考组件 props 类型标注

响应式 Props 解构

Vue 的响应系统基于属性访问跟踪状态的使用情况。例如,在计算属性或侦听器中访问 props.foo 时,foo 属性将被跟踪为依赖项。

因此,在以下代码的情况下:

javascript 复制代码
const { foo } = defineProps(['foo'])

watchEffect(() => {
  // 在 3.5 之前只运行一次
  // 在 3.5+ 中在 "foo" prop 变化时重新执行
  console.log(foo)
})

在 3.4 及以下版本,foo 是一个实际的常量,永远不会改变。在 3.5 及以上版本,当在同一个 <script setup> 代码块中访问由 defineProps 解构的变量时,Vue 编译器会自动在前面添加 props.。因此,上面的代码等同于以下代码:

javascript 复制代码
const props = defineProps(['foo'])

watchEffect(() => {
  // `foo` 由编译器转换为 `props.foo`
  console.log(props.foo)
})

此外,你可以使用 JavaScript 原生的默认值语法声明 props 默认值。这在使用基于类型的 props 声明时特别有用。

javascript 复制代码
const { foo = 'hello' } = defineProps<{ foo?: string }>()

如果你希望在 IDE 中在解构的 props 和普通变量之间有更多视觉上的区分,Vue 的 VSCode 扩展提供了一个设置来启用解构 props 的内联提示。

将解构的 props 传递到函数中

当我们将解构的 prop 传递到函数中时,例如:

javascript 复制代码
const { foo } = defineProps(['foo'])

watch(foo, /* ... */)

这并不会按预期工作,因为它等价于 watch(props.foo, ...)------我们给 watch 传递的是一个值而不是响应式数据源。实际上,Vue 的编译器会捕捉这种情况并发出警告。

与使用 watch(() => props.foo, ...) 来侦听普通 prop 类似,我们也可以通过将其包装在 getter 中来侦听解构的 prop:

javascript 复制代码
watch(() => foo, /* ... */)

此外,当我们需要传递解构的 prop 到外部函数中并保持响应性时,这是推荐做法:

javascript 复制代码
useComposable(() => foo)

外部函数可以调用 getter (或使用 toValue 进行规范化) 来追踪提供的 prop 变更。例如,在计算属性或侦听器的 getter 中。

传递 prop 的细节

Prop 名字格式

如果一个 prop 的名字很长,应使用 camelCase 形式,因为它们是合法的 JavaScript 标识符,可以直接在模板的表达式中使用,也可以避免在作为属性 key 名时必须加上引号。

javascript 复制代码
defineProps({
  greetingMessage: String
})
html 复制代码
<span>{{ greetingMessage }}</span>

虽然理论上你也可以在向子组件传递 props 时使用 camelCase 形式 (使用 DOM 内模板时例外),但实际上为了和 HTML attribute 对齐,我们通常会将其写为 kebab-case 形式:

html 复制代码
<MyComponent greeting-message="hello" />

对于组件名我们推荐使用 PascalCase,因为这提高了模板的可读性,能帮助我们区分 Vue 组件和原生 HTML 元素。然而对于传递 props 来说,使用 camelCase 并没有太多优势,因此我们推荐更贴近 HTML 的书写风格。

静态 vs. 动态 Props

至此,你已经见过了很多像这样的静态值形式的 props:

html 复制代码
<BlogPost title="My journey with Vue" />

相应地,还有使用 v-bind 或缩写 : 来进行动态绑定的 props:

html 复制代码
<!-- 根据一个变量的值动态传入 -->
<BlogPost :title="post.title" />

<!-- 根据一个更复杂表达式的值动态传入 -->
<BlogPost :title="post.title + ' by ' + post.author.name" />

传递不同的值类型

在上述的两个例子中,我们只传入了字符串值,但实际上任何类型的值都可以作为 props 的值被传递。

Number
html 复制代码
<!-- 虽然 `42` 是个常量,我们还是需要使用 v-bind -->
<!-- 因为这是一个 JavaScript 表达式而不是一个字符串 -->
<BlogPost :likes="42" />

<!-- 根据一个变量的值动态传入 -->
<BlogPost :likes="post.likes" />
Boolean
html 复制代码
<!-- 仅写上 prop 但不传值,会隐式转换为 `true` -->
<BlogPost is-published />

<!-- 虽然 `false` 是静态的值,我们还是需要使用 v-bind -->
<!-- 因为这是一个 JavaScript 表达式而不是一个字符串 -->
<BlogPost :is-published="false" />

<!-- 根据一个变量的值动态传入 -->
<BlogPost :is-published="post.isPublished" />
Array
html 复制代码
<!-- 虽然这个数组是个常量,我们还是需要使用 v-bind -->
<!-- 因为这是一个 JavaScript 表达式而不是一个字符串 -->
<BlogPost :comment-ids="[234, 266, 273]" />

<!-- 根据一个变量的值动态传入 -->
<BlogPost :comment-ids="post.commentIds" />
Object
html 复制代码
<!-- 虽然这个对象字面量是个常量,我们还是需要使用 v-bind -->
<!-- 因为这是一个 JavaScript 表达式而不是一个字符串 -->
<BlogPost
  :author="{
    name: 'Veronica',
    company: 'Veridian Dynamics'
  }"
 />

<!-- 根据一个变量的值动态传入 -->
<BlogPost :author="post.author" />

使用一个对象绑定多个 prop

如果你想要将一个对象的所有属性都当作 props 传入,你可以使用没有参数的 v-bind,即只使用 v-bind 而非 :prop-name。例如,这里有一个 post 对象:

javascript 复制代码
const post = {
  id: 1,
  title: 'My Journey with Vue'
}

以及下面的模板:

html 复制代码
<BlogPost v-bind="post" />

而这实际上等价于:

html 复制代码
<BlogPost :id="post.id" :title="post.title" />

单向数据流

所有的 props 都遵循着单向绑定原则,props 因父组件的更新而变化,自然地将新的状态向下流往子组件,而不会逆向传递。这避免了子组件意外修改父组件的状态的情况,不然应用的数据流将很容易变得混乱而难以理解。

另外,每次父组件更新后,所有的子组件中的 props 都会被更新到最新值,这意味着你不应该在子组件中去更改一个 prop。若你这么做了,Vue 会在控制台上向你抛出警告:

javascript 复制代码
const props = defineProps(['foo'])

// ❌ 警告!prop 是只读的!
props.foo = 'bar'

导致你想要更改一个 prop 的需求通常来源于以下两种场景:

  1. prop 被用于传入初始值;而子组件想在之后将其作为一个局部数据属性。在这种情况下,最好是新定义一个局部数据属性,从 props 上获取初始值即可:

    javascript 复制代码
    const props = defineProps(['initialCounter'])
    
    // 计数器只是将 props.initialCounter 作为初始值
    // 像下面这样做就使 prop 和后续更新无关了
    const counter = ref(props.initialCounter)
  2. 需要对传入的 prop 值做进一步的转换。在这种情况中,最好是基于该 prop 值定义一个计算属性:

    javascript 复制代码
    const props = defineProps(['size'])
    
    // 该 prop 变更时计算属性也会自动更新
    const normalizedSize = computed(() => props.size.trim().toLowerCase())

更改对象 / 数组类型的 props

当对象或数组作为 props 被传入时,虽然子组件无法更改 props 绑定,但仍然可以更改对象或数组内部的值。这是因为 JavaScript 的对象和数组是按引用传递,对 Vue 来说,阻止这种更改需要付出的代价异常昂贵。

这种更改的主要缺陷是它允许了子组件以某种不明显的方式影响父组件的状态,可能会使数据流在将来变得更难以理解。在最佳实践中,你应该尽可能避免这样的更改,除非父子组件在设计上本来就需要紧密耦合。在大多数场景下,子组件应该抛出一个事件来通知父组件做出改变。

Prop 校验

Vue 组件可以更细致地声明对传入的 props 的校验要求。比如我们上面已经看到过的类型声明,如果传入的值不满足类型要求,Vue 会在浏览器控制台中抛出警告来提醒使用者。这在开发给其他开发者使用的组件时非常有用。

要声明对 props 的校验,你可以向 defineProps() 宏提供一个带有 props 校验选项的对象,例如:

javascript 复制代码
defineProps({
  // 基础类型检查
  // (给出 `null` 和 `undefined` 值则会跳过任何类型检查)
  propA: Number,
  // 多种可能的类型
  propB: [String, Number],
  // 必传,且为 String 类型
  propC: {
    type: String,
    required: true
  },
  // 必传但可为 null 的字符串
  propD: {
    type: [String, null],
    required: true
  },
  // Number 类型的默认值
  propE: {
    type: Number,
    default: 100
  },
  // 对象类型的默认值
  propF: {
    type: Object,
    // 对象或数组的默认值
    // 必须从一个工厂函数返回。
    // 该函数接收组件所接收到的原始 prop 作为参数。
    default(rawProps) {
      return { message: 'hello' }
    }
  },
  // 自定义类型校验函数
  // 在 3.4+ 中完整的 props 作为第二个参数传入
  propG: {
    validator(value, props) {
      // The value must match one of these strings
      return ['success', 'warning', 'danger'].includes(value)
    }
  },
  // 函数类型的默认值
  propH: {
    type: Function,
    // 不像对象或数组的默认,这不是一个
    // 工厂函数。这会是一个用来作为默认值的函数
    default() {
      return 'Default function'
    }
  }
})

TIP

defineProps() 宏中的参数不可以访问 <script setup> 中定义的其他变量,因为在编译时整个表达式都会被移到外部的函数中。

一些补充细节:

  • 所有 prop 默认都是可选的,除非声明了 required: true

  • Boolean 外的未传递的可选 prop 将会有一个默认值 undefined

  • Boolean 类型的未传递 prop 将被转换为 false。这可以通过为它设置 default 来更改------例如:设置为 default: undefined 将与非布尔类型的 prop 的行为保持一致。

  • 如果声明了 default 值,那么在 prop 的值被解析为 undefined 时,无论 prop 是未被传递还是显式指明的 undefined,都会改为 default 值。

当 prop 的校验失败后,Vue 会抛出一个控制台警告 (在开发模式下)。

如果使用了基于类型的 prop 声明 ,Vue 会尽最大努力在运行时按照 prop 的类型标注进行编译。举例来说,defineProps<{ msg: string }> 会被编译为 { msg: { type: String, required: true }}

运行时类型检查

校验选项中的 type 可以是下列这些原生构造函数:

  • String
  • Number
  • Boolean
  • Array
  • Object
  • Date
  • Function
  • Symbol
  • Error

另外,type 也可以是自定义的类或构造函数,Vue 将会通过 instanceof 来检查类型是否匹配。例如下面这个类:

javascript 复制代码
class Person {
  constructor(firstName, lastName) {
    this.firstName = firstName
    this.lastName = lastName
  }
}

你可以将其作为一个 prop 的类型:

javascript 复制代码
defineProps({
  author: Person
})

Vue 会通过 instanceof Person 来校验 author prop 的值是否是 Person 类的一个实例。

可为 null 的类型

如果该类型是必传但可为 null 的,你可以用一个包含 null 的数组语法:

javascript 复制代码
defineProps({
  id: {
    type: [String, null],
    required: true
  }
})

注意如果 type 仅为 null 而非使用数组语法,它将允许任何类型。

Boolean 类型转换

为了更贴近原生 boolean attributes 的行为,声明为 Boolean 类型的 props 有特别的类型转换规则。以带有如下声明的 <MyComponent> 组件为例:

javascript 复制代码
defineProps({
  disabled: Boolean
})

该组件可以被这样使用:

html 复制代码
<!-- 等同于传入 :disabled="true" -->
<MyComponent disabled />

<!-- 等同于传入 :disabled="false" -->
<MyComponent />

当一个 prop 被声明为允许多种类型时,Boolean 的转换规则也将被应用。然而,当同时允许 StringBoolean 时,有一种边缘情况------只有当 Boolean 出现在 String 之前时,Boolean 转换规则才适用:

javascript 复制代码
// disabled 将被转换为 true
defineProps({
  disabled: [Boolean, Number]
})

// disabled 将被转换为 true
defineProps({
  disabled: [Boolean, String]
})

// disabled 将被转换为 true
defineProps({
  disabled: [Number, Boolean]
})

// disabled 将被解析为空字符串 (disabled="")
defineProps({
  disabled: [String, Boolean]
})

组件事件

此章节假设你已经看过了组件基础。若你还不了解组件是什么,请先阅读该章节。

触发与监听事件

在组件的模板表达式中,可以直接使用 $emit 方法触发自定义事件 (例如:在 v-on 的处理函数中):

html 复制代码
<!-- MyComponent -->
<button @click="$emit('someEvent')">Click Me</button>

父组件可以通过 v-on (缩写为 @) 来监听事件:

html 复制代码
<MyComponent @some-event="callback" />

同样,组件的事件监听器也支持 .once 修饰符:

html 复制代码
<MyComponent @some-event.once="callback" />

像组件与 prop 一样,事件的名字也提供了自动的格式转换。注意这里我们触发了一个以 camelCase 形式命名的事件,但在父组件中可以使用 kebab-case 形式来监听。与 prop 大小写格式一样,在模板中我们也推荐使用 kebab-case 形式来编写监听器。

TIP:

和原生 DOM 事件不一样,组件触发的事件没有冒泡机制 。你只能监听直接子组件触发的事件。平级组件或是跨越多层嵌套的组件间通信,应使用一个外部的事件总线,或是使用一个全局状态管理方案

事件参数

有时候我们会需要在触发事件时附带一个特定的值。举例来说,我们想要 <BlogPost> 组件来管理文本会缩放得多大。在这个场景下,我们可以给 $emit 提供一个额外的参数:

html 复制代码
<button @click="$emit('increaseBy', 1)">
  Increase by 1
</button>

然后我们在父组件中监听事件,我们可以先简单写一个内联的箭头函数作为监听器,此函数会接收到事件附带的参数:

html 复制代码
<MyButton @increase-by="(n) => count += n" />

或者,也可以用一个组件方法来作为事件处理函数:

html 复制代码
<MyButton @increase-by="increaseCount" />

该方法也会接收到事件所传递的参数:

javascript 复制代码
function increaseCount(n) {
  count.value += n
}

TIP:

所有传入 $emit() 的额外参数都会被直接传向监听器。举例来说,$emit('foo', 1, 2, 3) 触发后,监听器函数将会收到这三个参数值。

声明触发的事件

组件可以显式地通过 defineEmits() 宏来声明它要触发的事件:

html 复制代码
<script setup>
defineEmits(['inFocus', 'submit'])
</script>

我们在 <template> 中使用的 $emit 方法不能在组件的 <script setup> 部分中使用,但 defineEmits() 会返回一个相同作用的函数供我们使用:

html 复制代码
<script setup>
const emit = defineEmits(['inFocus', 'submit'])

function buttonClick() {
  emit('submit')
}
</script>

defineEmits()不能 在子函数中使用。如上所示,它必须直接放置在 <script setup> 的顶级作用域下。

如果你显式地使用了 setup 函数而不是 <script setup>,则事件需要通过 emits 选项来定义,emit 函数也被暴露在 setup() 的上下文对象上:

javascript 复制代码
export default {
  emits: ['inFocus', 'submit'],
  setup(props, ctx) {
    ctx.emit('submit')
  }
}

setup() 上下文对象中的其他属性一样,emit 可以安全地被解构:

javascript 复制代码
export default {
  emits: ['inFocus', 'submit'],
  setup(props, { emit }) {
    emit('submit')
  }
}

这个 emits 选项和 defineEmits() 宏还支持对象语法。通过 TypeScript 为参数指定类型,它允许我们对触发事件的参数进行验证:

html 复制代码
<script setup lang="ts">
const emit = defineEmits({
  submit(payload: { email: string, password: string }) {
    // 通过返回值为 `true` 还是为 `false` 来判断
    // 验证是否通过
  }
})
</script>

如果你正在搭配 TypeScript 使用 <script setup>,也可以使用纯类型标注来声明触发的事件:

html 复制代码
<script setup lang="ts">
const emit = defineEmits<{
  (e: 'change', id: number): void
  (e: 'update', value: string): void
}>()
</script>

TypeScript 用户请参考:如何为组件所抛出事件标注类型

尽管事件声明是可选的,我们还是推荐你完整地声明所有要触发的事件,以此在代码中作为文档记录组件的用法。同时,事件声明能让 Vue 更好地将事件和透传 attribute 作出区分,从而避免一些由第三方代码触发的自定义 DOM 事件所导致的边界情况。

TIP:

如果一个原生事件的名字 (例如 click) 被定义在 emits 选项中,则监听器只会监听组件触发的 click 事件而不会再响应原生的 click 事件。

事件校验

和对 props 添加类型校验的方式类似,所有触发的事件也可以使用对象形式来描述。

要为事件添加校验,那么事件可以被赋值为一个函数,接受的参数就是抛出事件时传入 emit 的内容,返回一个布尔值来表明事件是否合法。

html 复制代码
<script setup>
const emit = defineEmits({
  // 没有校验
  click: null,

  // 校验 submit 事件
  submit: ({ email, password }) => {
    if (email && password) {
      return true
    } else {
      console.warn('Invalid submit event payload!')
      return false
    }
  }
})

function submitForm(email, password) {
  emit('submit', { email, password })
}
</script>

理解:

一、为什么需要组件事件?

Props 是 父 → 子 传递数据的。

但有时候,子组件 需要告诉 父组件 一些事情,比如:"我被点击了"、"我里面的值变了"、"你该做点什么了"。

这就是 组件事件 的作用:子组件主动抛出一个消息,父组件监听这个消息并做出反应

二、子组件如何触发事件?(发射事件)

在子组件的模板里,可以用内置的 $emit 方法。

$emit 的第一个参数是 事件名(你随便起,就像起个信号名字),后面可以跟着要传递的数据。

vue

html 复制代码
<!-- 子组件 Child.vue -->
<template>
  <button @click="$emit('someEvent')">点我</button>
</template>
  • 当按钮被点击时,子组件会发射一个叫 someEvent 的事件。

  • 这个事件就像一声喊叫:"父组件,我这儿发生点击啦!"

如果还想传点数据:

vue

html 复制代码
<button @click="$emit('increaseBy', 1)">增加1</button>

这里发射的是 increaseBy 事件,还附带了一个参数 1

三、父组件如何监听事件?

在父组件的模板里,使用 v-on(简写 @)加上事件名来监听:

vue

html 复制代码
<template>
  <Child @some-event="handleSomeEvent" />
  <Child @increase-by="handleIncrease" />
</template>
  • @some-event="handleSomeEvent":监听 someEvent 事件,触发时执行 handleSomeEvent 方法。

  • 注意:事件名在子组件里可以是驼峰(someEvent),但在父组件模板里推荐用短横线(some-event),Vue 会自动匹配。

父组件里定义方法

js

javascript 复制代码
function handleSomeEvent() {
  console.log('收到了子组件的事件')
}

function handleIncrease(n) {
  count.value += n   // 用子组件传过来的数字增加计数
}

如果事件带了参数,父组件的处理方法可以直接接收。

四、内联监听(简单场景)

你也可以直接在模板里写表达式,不单独定义方法:

vue

html 复制代码
<Child @increase-by="count += $event" />
  • $event 是 Vue 提供的特殊变量,代表事件附带的值(这里是 1)。

或者用箭头函数(更清晰):

vue

html 复制代码
<Child @increase-by="(n) => count += n" />

五、声明组件会触发哪些事件(defineEmits

虽然不声明也可以触发事件,但 推荐声明,好处:

  • 让代码更易读,别人一看就知道这个组件会发出哪些事件。

  • 避免原生 DOM 事件的干扰(比如你声明了 click 事件,组件就会忽略原生的 click 事件,只响应你自己触发的)。

<script setup> 里用 defineEmits 宏:

vue

html 复制代码
<script setup>
const emit = defineEmits(['someEvent', 'increaseBy'])
</script>

<template>
  <button @click="emit('someEvent')">点我</button>
  <button @click="emit('increaseBy', 1)">增加1</button>
</template>
  • defineEmits 返回一个 emit 函数,用 emit('事件名', 参数) 来触发。

  • 这样就不必在模板里写 $emit 了,逻辑更集中。

六、事件校验(类似 props 校验)

你可以为事件参数添加校验,就像给函数参数做检查。

用法:给 defineEmits 传入一个对象,而不是数组。

vue

html 复制代码
<script setup>
const emit = defineEmits({
  // 没有校验,直接允许
  click: null,

  // 校验 submit 事件:必须传入一个包含 email 和 password 的对象
  submit: ({ email, password }) => {
    if (email && password) {
      return true   // 校验通过
    } else {
      console.warn('校验失败:必须提供 email 和 password')
      return false  // 校验失败,Vue 会在控制台警告
    }
  }
})

function submitForm() {
  emit('submit', { email: 'test@example.com', password: '123' })
}
</script>
  • 校验函数返回 true 表示通过,返回 false 表示不通过(Vue 会警告,但事件仍会触发,只是用于提示开发者)。

  • 主要用于开发阶段保证事件参数格式正确。

七、重要注意事项

  1. 组件事件不会冒泡:只能监听直接子组件的事件,孙子组件触发的事件父组件是收不到的。如果需要跨级通信,要用全局事件总线或状态管理(如 Pinia)。

  2. 事件名大小写 :推荐子组件用驼峰(increaseBy),父组件用短横线(increase-by),Vue 会自动转换。

  3. emit 必须在同步代码中调用 :不能在异步回调里直接调用 emit(比如 setTimeout 里),但可以把 emit 存起来,异步里再用。

  4. 如果同时存在原生 DOM 事件和自定义事件

    • 假如子组件触发了 click 事件,并且也在 emits 里声明了 click,那么父组件的 @click 只会响应子组件的自定义事件,而不会响应原生的按钮点击(除非子组件也保留了原生 click 的触发)。

      为了避免混淆,自定义事件名最好不要与原生 DOM 事件重名。

八、总结(小白版)

组件事件 = 子组件给父组件打电话

  • 子组件用 $emit('事件名', 参数)emit('事件名', 参数) 发出信号

  • 父组件用 @事件名="处理函数" 接听

  • 可以传参数,父组件在函数里接收

  • 推荐用 defineEmits 提前声明有哪些事件,方便别人阅读

  • 还可以给事件参数加校验,就像检查快递物品有没有损坏

现在你可以试着写一个小例子:

  • 子组件里有一个按钮,点击时发出 add 事件,并传递一个数字。

  • 父组件监听到后,累加到一个总数上并显示出来。

    这样你就能完全理解组件事件了。

组件 v-model

观看 Scrimba 的互动视频课程

基本用法

v-model 可以在组件上使用以实现双向绑定。

从 Vue 3.4 开始,推荐的实现方式是使用 defineModel() 宏:

Child.vue

html 复制代码
<script setup>
const model = defineModel()

function update() {
  model.value++
}
</script>

<template>
  <div>Parent bound v-model is: {{ model }}</div>
  <button @click="update">Increment</button>
</template>

父组件可以用 v-model 绑定一个值:

Parent.vue

html 复制代码
<Child v-model="countModel" />

defineModel() 返回的值是一个 ref。它可以像其他 ref 一样被访问以及修改,不过它能起到在父组件和当前变量之间的双向绑定的作用:

  • 它的 .value 和父组件的 v-model 的值同步;
  • 当它被子组件变更了,会触发父组件绑定的值一起更新。

这意味着你也可以用 v-model 把这个 ref 绑定到一个原生 input 元素上,在提供相同的 v-model 用法的同时轻松包装原生 input 元素:

html 复制代码
<script setup>
const model = defineModel()
</script>

<template>
  <input v-model="model" />
</template>

演练场示例

底层机制

defineModel 是一个便利宏。编译器将其展开为以下内容:

  • 一个名为 modelValue 的 prop,本地 ref 的值与其同步;
  • 一个名为 update:modelValue 的事件,当本地 ref 的值发生变更时触发。

在 3.4 版本之前,你一般会按照如下的方式来实现上述相同的子组件:

Child.vue

html 复制代码
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
</script>

<template>
  <input
    :value="props.modelValue"
    @input="emit('update:modelValue', $event.target.value)"
  />
</template>

然后,父组件中的 v-model="foo" 将被编译为:

Parent.vue

html 复制代码
<Child
  :modelValue="foo"
  @update:modelValue="$event => (foo = $event)"
/>

如你所见,这显得冗长得多。然而,这样写有助于理解其底层机制。

因为 defineModel 声明了一个 prop,你可以通过给 defineModel 传递选项,来声明底层 prop 的选项:

javascript 复制代码
// 使 v-model 必填
const model = defineModel({ required: true })

// 提供一个默认值
const model = defineModel({ default: 0 })

WARNING

如果为 defineModel prop 设置了一个 default 值且父组件没有为该 prop 提供任何值,会导致父组件与子组件之间不同步。在下面的示例中,父组件的 myRef 是 undefined,而子组件的 model 是 1:

Child.vue

html 复制代码
<script setup>
const model = defineModel({ default: 1 })
</script>

Parent.vue

html 复制代码
<script setup>
const myRef = ref()
</script>

<template>
  <Child v-model="myRef"></Child>
</template>

v-model 的参数

组件上的 v-model 也可以接受一个参数:

html 复制代码
<MyComponent v-model:title="bookTitle" />

在子组件中,我们可以通过将字符串作为第一个参数传递给 defineModel() 来支持相应的参数:

MyComponent.vue

html 复制代码
<script setup>
const title = defineModel('title')
</script>

<template>
  <input type="text" v-model="title" />
</template>

在演练场中尝试一下

如果需要额外的 prop 选项,应该在 model 名称之后传递:

javascript 复制代码
const title = defineModel('title', { required: true })

3.4 之前的用法

MyComponent.vue

html 复制代码
<script setup>
defineProps({
  title: {
    required: true
  }
})
defineEmits(['update:title'])
</script>

<template>
  <input
    type="text"
    :value="title"
    @input="$emit('update:title', $event.target.value)"
  />
</template>

在演练场中尝试一下

多个 v-model 绑定

利用刚才在 v-model 的参数小节中学到的指定参数与事件名的技巧,我们可以在单个组件实例上创建多个 v-model 双向绑定。

组件上的每一个 v-model 都会同步不同的 prop,而无需额外的选项:

html 复制代码
<UserName
  v-model:first-name="first"
  v-model:last-name="last"
/>
html 复制代码
<script setup>
const firstName = defineModel('firstName')
const lastName = defineModel('lastName')
</script>

<template>
  <input type="text" v-model="firstName" />
  <input type="text" v-model="lastName" />
</template>

在演练场中尝试一下

3.4 之前的用法vue

html 复制代码
<script setup>
defineProps({
  firstName: String,
  lastName: String
})

defineEmits(['update:firstName', 'update:lastName'])
</script>

<template>
  <input
    type="text"
    :value="firstName"
    @input="$emit('update:firstName', $event.target.value)"
  />
  <input
    type="text"
    :value="lastName"
    @input="$emit('update:lastName', $event.target.value)"
  />
</template>

在演练场中尝试一下

处理 v-model 修饰符

在学习输入绑定时,我们知道了 v-model 有一些内置的修饰符,例如 .trim.number.lazy。在某些场景下,你可能想要一个自定义组件的 v-model 支持自定义的修饰符。

我们来创建一个自定义的修饰符 capitalize,它会自动将 v-model 绑定输入的字符串值第一个字母转为大写:

html 复制代码
<MyComponent v-model.capitalize="myText" />

通过像这样解构 defineModel() 的返回值,可以在子组件中访问添加到组件 v-model 的修饰符:

html 复制代码
<script setup>
const [model, modifiers] = defineModel()

console.log(modifiers) // { capitalize: true }
</script>

<template>
  <input type="text" v-model="model" />
</template>

为了能够基于修饰符选择性地调节值的读取和写入方式,我们可以给 defineModel() 传入 getset 这两个选项。这两个选项在从模型引用中读取或设置值时会接收到当前的值,并且它们都应该返回一个经过处理的新值。下面是一个例子,展示了如何利用 set 选项来应用 capitalize (首字母大写) 修饰符:

html 复制代码
<script setup>
const [model, modifiers] = defineModel({
  set(value) {
    if (modifiers.capitalize) {
      return value.charAt(0).toUpperCase() + value.slice(1)
    }
    return value
  }
})
</script>

<template>
  <input type="text" v-model="model" />
</template>

在演练场中尝试一下

3.4 之前的用法vue

html 复制代码
<script setup>
const props = defineProps({
  modelValue: String,
  modelModifiers: { default: () => ({}) }
})

const emit = defineEmits(['update:modelValue'])

function emitValue(e) {
  let value = e.target.value
  if (props.modelModifiers.capitalize) {
    value = value.charAt(0).toUpperCase() + value.slice(1)
  }
  emit('update:modelValue', value)
}
</script>

<template>
  <input type="text" :value="props.modelValue" @input="emitValue" />
</template>

在演练场中尝试一下

带参数的 v-model 修饰符

这里是另一个例子,展示了如何在使用多个不同参数的 v-model 时使用修饰符:

html 复制代码
<UserName
  v-model:first-name.capitalize="first"
  v-model:last-name.uppercase="last"
/>
html 复制代码
<script setup>
const [firstName, firstNameModifiers] = defineModel('firstName')
const [lastName, lastNameModifiers] = defineModel('lastName')

console.log(firstNameModifiers) // { capitalize: true }
console.log(lastNameModifiers) // { uppercase: true }
</script>

3.4 之前的用法vue

html 复制代码
<script setup>
const props = defineProps({
  firstName: String,
  lastName: String,
  firstNameModifiers: { default: () => ({}) },
  lastNameModifiers: { default: () => ({}) }
})
defineEmits(['update:firstName', 'update:lastName'])

console.log(props.firstNameModifiers) // { capitalize: true }
console.log(props.lastNameModifiers) // { uppercase: true }
</script>

理解:

一、什么是双向绑定?

普通情况:

  • 父组件通过 props 传数据给子组件(单向)。

  • 子组件通过 事件(emit) 通知父组件修改数据(另一方向)。

如果数据需要经常同步(比如表单输入框),每次都手动写 :value + @input 会很麻烦。

v-model 就是 Vue 提供的语法糖,帮你自动完成"父传子 + 子传父"的流程。

原生 input 上:

<input v-model="text"> 等于 :value="text" + @input="e => text = e.target.value"

组件 上:

<Child v-model="parentData"> 会让子组件能够修改 parentData 并同步回父组件。

二、以前(Vue 3.4 之前)怎么做?

子组件需要:

  • 接收一个 modelValue prop

  • 当值变化时,触发 update:modelValue 事件,把新值传回去

vue

javascript 复制代码
<!-- Child.vue (旧写法) -->
<script setup>
defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
</script>

<template>
  <input 
    :value="modelValue"
    @input="emit('update:modelValue', $event.target.value)"
  />
</template>

父组件用:<Child v-model="parentText" />

这样写起来比较啰嗦,但能让你理解底层原理。

三、新版(Vue 3.4+)推荐:defineModel

现在用 defineModel 宏,一句话搞定:

vue

javascript 复制代码
<!-- Child.vue -->
<script setup>
const model = defineModel()   // 返回一个 ref
</script>

<template>
  <input v-model="model" />    <!-- 直接用 v-model 绑定这个 ref -->
</template>

父组件依然写:<Child v-model="parentText" />

defineModel 做了什么?

  • 自动声明一个名为 modelValue 的 prop

  • 自动声明一个名为 update:modelValue 的事件

  • 返回一个 ref,对这个 ref 的读写会同步到父组件的值

你不需要手动写 prop 和 emit,代码简洁很多。

四、如果需要指定不同的 prop 名称(v-model 参数)

默认情况下,v-model 绑定的 prop 名是 modelValue,事件是 update:modelValue

如果你想要自定义名字,比如 title,可以这样:

父组件:

vue

javascript 复制代码
<MyComponent v-model:title="bookTitle" />

子组件:

vue

javascript 复制代码
<script setup>
const title = defineModel('title')   // 参数 'title' 表示 prop 名
</script>

<template>
  <input v-model="title" />
</template>

这样,defineModel('title') 会处理 title prop 和 update:title 事件。

五、多个 v-model 绑定

一个组件可以有多个 v-model,绑定不同的 prop:

vue

javascript 复制代码
<UserName
  v-model:first-name="firstName"
  v-model:last-name="lastName"
/>

子组件:

vue

javascript 复制代码
<script setup>
const firstName = defineModel('firstName')
const lastName = defineModel('lastName')
</script>

<template>
  <input v-model="firstName" />
  <input v-model="lastName" />
</template>

六、修饰符(modifiers)

修饰符是在 v-model 后面加的点后缀,比如 .trim.number.lazy

你也可以自定义修饰符,例如 .capitalize(首字母大写)。

使用自定义修饰符

父组件:

vue

javascript 复制代码
<MyComponent v-model.capitalize="myText" />

子组件要拿到修饰符信息:

vue

javascript 复制代码
<script setup>
// defineModel 返回一个数组:[ref, modifiers]
const [model, modifiers] = defineModel()

console.log(modifiers) // { capitalize: true }

// 可以通过 set 选项应用修饰符逻辑
const [model, modifiers] = defineModel({
  set(value) {
    if (modifiers.capitalize) {
      return value.charAt(0).toUpperCase() + value.slice(1)
    }
    return value
  }
})
</script>

<template>
  <input v-model="model" />
</template>

带参数的 v-model 修饰符

父组件:

vue

javascript 复制代码
<UserName
  v-model:first-name.capitalize="first"
  v-model:last-name.uppercase="last"
/>

子组件:

vue

javascript 复制代码
<script setup>
const [firstName, firstNameModifiers] = defineModel('firstName')
const [lastName, lastNameModifiers] = defineModel('lastName')
// firstNameModifiers = { capitalize: true }
// lastNameModifiers = { uppercase: true }
</script>

七、总结(小白版)

v-model 在组件上 = 父组件和子组件之间自动同步一个值

  • 以前需要手写 modelValue prop + update:modelValue 事件

  • Vue 3.4+ 用 defineModel() 宏,返回一个 ref,直接使用即可

  • 可以指定参数 v-model:propName 来绑定不同的 prop

  • 可以有多个 v-model,绑定不同的 prop

  • 修饰符可以通过 defineModel 的返回值拿到,并在 set 选项里处理

一句话defineModel 让你在子组件里像用普通 ref 一样使用双向绑定,不用操心 prop 和事件的细节。

现在你可以试着做一个简单的计数器组件,父组件用 v-model 绑定一个数字,子组件显示并可以加减,体验一下双向绑定的便利。

透传 Attributes

此章节假设你已经看过了组件基础。若你还不了解组件是什么,请先阅读该章节。

Attributes 继承

"透传 attribute"指的是传递给一个组件,却没有被该组件声明为 propsemits 的 attribute 或者 v-on 事件监听器。最常见的例子就是 classstyleid

当一个组件以单个元素为根作渲染时,透传的 attribute 会自动被添加到根元素上。举例来说,假如我们有一个 <MyButton> 组件,它的模板长这样:

javascript 复制代码
<!-- <MyButton> 的模板 -->
<button>Click Me</button>

一个父组件使用了这个组件,并且传入了 class

javascript 复制代码
<MyButton class="large" />

最后渲染出的 DOM 结果是:

javascript 复制代码
<button class="large">Click Me</button>

这里,<MyButton> 并没有将 class 声明为一个它所接受的 prop,所以 class 被视作透传 attribute,自动透传到了 <MyButton> 的根元素上。

classstyle 的合并

如果一个子组件的根元素已经有了 classstyle attribute,它会和从父组件上继承的值合并。如果我们将之前的 <MyButton> 组件的模板改成这样:

javascript 复制代码
<!-- <MyButton> 的模板 -->
<button class="btn">Click Me</button>

则最后渲染出的 DOM 结果会变成:

javascript 复制代码
<button class="btn large">Click Me</button>

v-on 监听器继承

同样的规则也适用于 v-on 事件监听器:

javascript 复制代码
<MyButton @click="onClick" />

click 监听器会被添加到 <MyButton> 的根元素,即那个原生的 <button> 元素之上。当原生的 <button> 被点击,会触发父组件的 onClick 方法。同样的,如果原生 button 元素自身也通过 v-on 绑定了一个事件监听器,则这个监听器和从父组件继承的监听器都会被触发。

深层组件继承

有些情况下一个组件会在根节点上渲染另一个组件。例如,我们重构一下 <MyButton>,让它在根节点上渲染 <BaseButton>

javascript 复制代码
<!-- <MyButton/> 的模板,只是渲染另一个组件 -->
<BaseButton />

此时 <MyButton> 接收的透传 attribute 会直接继续传给 <BaseButton>

请注意:

  1. 透传的 attribute 不会包含 <MyButton> 上声明过的 props 或是针对 emits 声明事件的 v-on 侦听函数,换句话说,声明过的 props 和侦听函数被 <MyButton>"消费"了。

  2. 透传的 attribute 若符合声明,也可以作为 props 传入 <BaseButton>

禁用 Attributes 继承

如果你不想要 一个组件自动地继承 attribute,你可以在组件选项中设置 inheritAttrs: false

从 3.3 开始你也可以直接在 <script setup> 中使用 defineOptions

javascript 复制代码
<script setup>
defineOptions({
  inheritAttrs: false
})
// ...setup 逻辑
</script>

最常见的需要禁用 attribute 继承的场景就是 attribute 需要应用在根节点以外的其他元素上。通过设置 inheritAttrs 选项为 false,你可以完全控制透传进来的 attribute 被如何使用。

这些透传进来的 attribute 可以在模板的表达式中直接用 $attrs 访问到。

javascript 复制代码
<span>Fallthrough attribute: {{ $attrs }}</span>

这个 $attrs 对象包含了除组件所声明的 propsemits 之外的所有其他 attribute,例如 classstylev-on 监听器等等。

有几点需要注意:

  • 和 props 有所不同,透传 attributes 在 JavaScript 中保留了它们原始的大小写,所以像 foo-bar 这样的一个 attribute 需要通过 $attrs['foo-bar'] 来访问。

  • @click 这样的一个 v-on 事件监听器将在此对象下被暴露为一个函数 $attrs.onClick

现在我们要再次使用一下之前小节中的 <MyButton> 组件例子。有时候我们可能为了样式,需要在 <button> 元素外包装一层 <div>

javascript 复制代码
<div class="btn-wrapper">
  <button class="btn">Click Me</button>
</div>

我们想要所有像 classv-on 监听器这样的透传 attribute 都应用在内部的 <button> 上而不是外层的 <div> 上。我们可以通过设定 inheritAttrs: false 和使用 v-bind="$attrs" 来实现:

javascript 复制代码
<div class="btn-wrapper">
  <button class="btn" v-bind="$attrs">Click Me</button>
</div>

小提示:没有参数的 v-bind 会将一个对象的所有属性都作为 attribute 应用到目标元素上。

多根节点的 Attributes 继承

和单根节点组件有所不同,有着多个根节点的组件没有自动 attribute 透传行为。如果 $attrs 没有被显式绑定,将会抛出一个运行时警告。

javascript 复制代码
<CustomLayout id="custom-layout" @click="changeValue" />

如果 <CustomLayout> 有下面这样的多根节点模板,由于 Vue 不知道要将 attribute 透传到哪里,所以会抛出一个警告。

javascript 复制代码
<header>...</header>
<main>...</main>
<footer>...</footer>

如果 $attrs 被显式绑定,则不会有警告:

javascript 复制代码
<header>...</header>
<main v-bind="$attrs">...</main>
<footer>...</footer>

在 JavaScript 中访问透传 Attributes

如果需要,你可以在 <script setup> 中使用 useAttrs() API 来访问一个组件的所有透传 attribute:

javascript 复制代码
<script setup>
import { useAttrs } from 'vue'

const attrs = useAttrs()
</script>

如果没有使用 <script setup>attrs 会作为 setup() 上下文对象的一个属性暴露:

javascript 复制代码
export default {
  setup(props, ctx) {
    // 透传 attribute 被暴露为 ctx.attrs
    console.log(ctx.attrs)
  }
}

需要注意的是,虽然这里的 attrs 对象总是反映为最新的透传 attribute,但它并不是响应式的 (考虑到性能因素)。你不能通过侦听器去监听它的变化。如果你需要响应性,可以使用 prop。或者你也可以使用 onUpdated() 使得在每次更新时结合最新的 attrs 执行副作用。

理解:

一、什么是"透传 attribute"?

当你使用一个子组件时,你会给它的标签写一些属性,比如 classstyleid,或者自定义属性,甚至事件监听(如 @click)。

子组件内部可以用 props 声明一些属性来接收。但那些 没有在 props 里声明 的属性,Vue 会如何处理呢?它们会 自动传递(透传)到子组件的根元素上

举例:

vue

javascript 复制代码
<!-- 父组件 -->
<MyButton class="large" data-id="123" @click="handleClick" />

vue

javascript 复制代码
<!-- 子组件 MyButton.vue 的模板 -->
<button>点我</button>

子组件的 <button> 是根元素。父组件传进来的 class="large"data-id="123"@click 都没有在子组件的 propsemits 中声明,所以它们会成为"透传 attribute",自动加到子组件的根 <button> 上。

最终渲染的 DOM:

html

javascript 复制代码
<button class="large" data-id="123">点我</button>

并且 @click 事件也会绑定到这个按钮上,点击时会触发父组件的 handleClick

二、为什么要用透传 attribute?

你不需要在子组件里手动写 props: ['class', 'data-id', ...]emits: ['click'],就能让父组件直接控制子组件根元素的样式、属性和事件。

常见用途:

  • 给子组件添加 classstyle 来定制外观。

  • 给子组件绑定原生事件(如 clickmouseenter)。

  • 传递一些自定义数据属性(如 data-*)。

三、class 和 style 的合并规则

如果子组件的根元素本身已经有 classstyle,父组件传过来的会 合并,而不是覆盖。

子组件模板:

html

javascript 复制代码
<button class="btn">点我</button>

父组件:<MyButton class="large" />

最终:<button class="btn large">点我</button>(两个 class 都存在)

style 的合并类似。

四、事件监听器的继承

父组件的 @click 等事件也会透传到根元素上。如果根元素本身也有 @click 监听,两者都会触发。

五、深层组件的透传

如果子组件的根元素本身又是另一个组件 (比如 <MyButton> 的模板是 <BaseButton />),那么透传的 attribute 会继续传给 <BaseButton> 的根元素(如果 <BaseButton> 也没有声明这些属性)。

六、如何禁用自动透传?

有时你不想让透传 attribute 自动加到根元素上,而是想手动控制放在哪里。可以通过设置 inheritAttrs: false 来禁用自动继承。

<script setup> 中使用 defineOptions

vue

javascript 复制代码
<script setup>
defineOptions({
  inheritAttrs: false
})
</script>

然后你可以在模板中通过 $attrs 对象来访问所有透传属性,并手动绑定到任何元素上。

例如,你希望这些属性加到内部的 <button> 上,而不是外层 <div>

vue

javascript 复制代码
<template>
  <div class="wrapper">
    <button v-bind="$attrs">点我</button>
  </div>
</template>

v-bind="$attrs" 会把 $attrs 对象里的所有属性(class、style、事件等)一次性绑定到按钮上。

七、多根节点组件的透传

如果组件有多个根元素(例如模板最外层有 <header><main><footer> 三个并列标签),Vue 不知道应该把透传 attribute 加到哪个根上,所以会禁止自动透传,并发出警告。

你必须手动用 v-bind="$attrs" 指定一个元素来接收这些属性。例如:

vue

javascript 复制代码
<header>...</header>
<main v-bind="$attrs">...</main>
<footer>...</footer>

这样所有透传属性都会传给 <main>

八、在 JavaScript 中访问透传属性

<script setup> 中,可以用 useAttrs() 函数拿到 $attrs 对象:

js

javascript 复制代码
import { useAttrs } from 'vue'
const attrs = useAttrs()
console.log(attrs.class)   // 拿到父组件传过来的 class

注意:attrs 对象不是响应式 的(为了性能),你不能用 watch 监听它的变化。如果你需要响应式,还是用 props 声明。

九、总结(小白版)

透传 attribute = 父组件给的、子组件没说要收的"额外礼物"

  • 默认情况下,这些礼物会自动包装到子组件的根元素上。

  • 如果根元素已经有 class/style,就合并;事件也合并。

  • 如果不想要自动包装,可以禁用 inheritAttrs,然后手动用 v-bind="$attrs" 放到你希望的任意元素上。

  • 多根节点的组件没有自动透传,必须手动指定接收者。

  • 可以在 JS 里用 useAttrs() 读取这些属性(但不要指望它自动响应变化)。

现在你应该能理解为什么给组件加 class@click 不需要在组件内部写任何代码了 ------ 这些都是透传 attribute 的功劳。

插槽 Slots

此章节假设你已经看过了组件基础。若你还不了解组件是什么,请先阅读该章节。

插槽内容与出口

在之前的章节中,我们已经了解到组件能够接收任意类型的 JavaScript 值作为 props,但组件要如何接收模板内容呢?在某些场景中,我们可能想要为子组件传递一些模板片段,让子组件在它们的组件中渲染这些片段。

举例来说,这里有一个 <FancyButton> 组件,可以像这样使用:

javascript 复制代码
<FancyButton>
  Click me! <!-- 插槽内容 -->
</FancyButton>

<FancyButton> 的模板是这样的:

javascript 复制代码
<button class="fancy-btn">
  <slot></slot> <!-- 插槽出口 -->
</button>

<slot> 元素是一个插槽出口 (slot outlet),标示了父元素提供的插槽内容 (slot content) 将在哪里被渲染。

最终渲染出的 DOM 是这样:

javascript 复制代码
<button class="fancy-btn">Click me!</button>

在演练场中尝试一下

通过使用插槽,<FancyButton> 仅负责渲染外层的 <button> (以及相应的样式),而其内部的内容由父组件提供。

理解插槽的另一种方式是和下面的 JavaScript 函数作类比,其概念是类似的:

javascript 复制代码
// 父元素传入插槽内容
FancyButton('Click me!')

// FancyButton 在自己的模板中渲染插槽内容
function FancyButton(slotContent) {
  return `<button class="fancy-btn">
      ${slotContent}
    </button>`
}

插槽内容可以是任意合法的模板内容,不局限于文本。例如我们可以传入多个元素,甚至是组件:

html 复制代码
<FancyButton>
  <span style="color:red">Click me!</span>
  <AwesomeIcon name="plus" />
</FancyButton>

在演练场中尝试一下

通过使用插槽,<FancyButton> 组件更加灵活和具有可复用性。现在组件可以用在不同的地方渲染各异的内容,但同时还保证都具有相同的样式。

Vue 组件的插槽机制是受原生 Web Component <slot> 元素的启发而诞生,同时还做了一些功能拓展,这些拓展的功能我们后面会学习到。

渲染作用域

插槽内容可以访问到父组件的数据作用域,因为插槽内容本身是在父组件模板中定义的。举例来说:

html 复制代码
<span>{{ message }}</span>
<FancyButton>{{ message }}</FancyButton>

这里的两个 {``{ message }} 插值表达式渲染的内容都是一样的。

插槽内容无法访问子组件的数据。Vue 模板中的表达式只能访问其定义时所处的作用域,这和 JavaScript 的词法作用域规则是一致的。换言之:

父组件模板中的表达式只能访问父组件的作用域;子组件模板中的表达式只能访问子组件的作用域。

默认内容

在外部没有提供任何内容的情况下,可以为插槽指定默认内容。比如有这样一个 <SubmitButton> 组件:

template

html 复制代码
<button type="submit">
  <slot></slot>
</button>

如果我们想在父组件没有提供任何插槽内容时在 <button> 内渲染"Submit",只需要将"Submit"写在 <slot> 标签之间来作为默认内容:

html 复制代码
<button type="submit">
  <slot>
    Submit <!-- 默认内容 -->
  </slot>
</button>

现在,当我们在父组件中使用 <SubmitButton> 且没有提供任何插槽内容时:

html 复制代码
<SubmitButton />

"Submit"将会被作为默认内容渲染:

html 复制代码
<button type="submit">Submit</button>

但如果我们提供了插槽内容:

html 复制代码
<SubmitButton>Save</SubmitButton>

那么被显式提供的内容会取代默认内容:

html 复制代码
<button type="submit">Save</button>

在演练场中尝试一下

具名插槽

有时在一个组件中包含多个插槽出口是很有用的。举例来说,在一个 <BaseLayout> 组件中,有如下模板:

html 复制代码
<div class="container">
  <header>
    <!-- 标题内容放这里 -->
  </header>
  <main>
    <!-- 主要内容放这里 -->
  </main>
  <footer>
    <!-- 底部内容放这里 -->
  </footer>
</div>

对于这种场景,<slot> 元素可以有一个特殊的 attribute name,用来给各个插槽分配唯一的 ID,以确定每一处要渲染的内容:

html 复制代码
<div class="container">
  <header>
    <slot name="header"></slot>
  </header>
  <main>
    <slot></slot>
  </main>
  <footer>
    <slot name="footer"></slot>
  </footer>
</div>

这类带 name 的插槽被称为具名插槽 (named slots)。没有提供 name<slot> 出口会隐式地命名为"default"。

在父组件中使用 <BaseLayout> 时,我们需要一种方式将多个插槽内容传入到各自目标插槽的出口。此时就需要用到具名插槽了:

要为具名插槽传入内容,我们需要使用一个含 v-slot 指令的 <template> 元素,并将目标插槽的名字传给该指令:

html 复制代码
<BaseLayout>
  <template v-slot:header>
    <!-- header 插槽的内容放这里 -->
  </template>
</BaseLayout>

v-slot 有对应的简写 #,因此 <template v-slot:header> 可以简写为 <template #header>。其意思就是"将这部分模板片段传入子组件的 header 插槽中"。

下面我们给出完整的、向 <BaseLayout> 传递插槽内容的代码,指令均使用的是缩写形式:

html 复制代码
<BaseLayout>
  <template #header>
    <h1>Here might be a page title</h1>
  </template>

  <template #default>
    <p>A paragraph for the main content.</p>
    <p>And another one.</p>
  </template>

  <template #footer>
    <p>Here's some contact info</p>
  </template>
</BaseLayout>

当一个组件同时接收默认插槽和具名插槽时,所有位于顶级的非 <template> 节点都被隐式地视为默认插槽的内容。所以上面也可以写成:

html 复制代码
<BaseLayout>
  <template #header>
    <h1>Here might be a page title</h1>
  </template>

  <!-- 隐式的默认插槽 -->
  <p>A paragraph for the main content.</p>
  <p>And another one.</p>

  <template #footer>
    <p>Here's some contact info</p>
  </template>
</BaseLayout>

现在 <template> 元素中的所有内容都将被传递到相应的插槽。最终渲染出的 HTML 如下:

html 复制代码
<div class="container">
  <header>
    <h1>Here might be a page title</h1>
  </header>
  <main>
    <p>A paragraph for the main content.</p>
    <p>And another one.</p>
  </main>
  <footer>
    <p>Here's some contact info</p>
  </footer>
</div>

在演练场中尝试一下

使用 JavaScript 函数来类比可能更有助于你来理解具名插槽:

javascript 复制代码
// 传入不同的内容给不同名字的插槽
BaseLayout({
  header: `...`,
  default: `...`,
  footer: `...`
})

// <BaseLayout> 渲染插槽内容到对应位置
function BaseLayout(slots) {
  return `<div class="container">
      <header>${slots.header}</header>
      <main>${slots.default}</main>
      <footer>${slots.footer}</footer>
    </div>`
}

条件插槽

有时你需要根据内容是否被传入了插槽来渲染某些内容。

你可以结合使用 [slots](https://cn.vuejs.org/api/component-instance.html#slots "slots") 属性与 v-if 来实现。

在下面的示例中,我们定义了一个卡片组件,它拥有三个条件插槽:headerfooterdefault。 当 header、footer 或 default 的内容存在时,我们希望包装它以提供额外的样式:

html 复制代码
<template>
  <div class="card">
    <div v-if="$slots.header" class="card-header">
      <slot name="header" />
    </div>
    
    <div v-if="$slots.default" class="card-content">
      <slot />
    </div>
    
    <div v-if="$slots.footer" class="card-footer">
      <slot name="footer" />
    </div>
  </div>
</template>

在演练场中尝试一下

动态插槽名

动态指令参数v-slot 上也是有效的,即可以定义下面这样的动态插槽名:

html 复制代码
<base-layout>
  <template v-slot:[dynamicSlotName]>
    ...
  </template>

  <!-- 缩写为 -->
  <template #[dynamicSlotName]>
    ...
  </template>
</base-layout>

注意这里的表达式和动态指令参数受相同的语法限制

作用域插槽

在上面的渲染作用域中我们讨论到,插槽的内容无法访问到子组件的状态。

然而在某些场景下插槽的内容可能想要同时使用父组件域内和子组件域内的数据。要做到这一点,我们需要一种方法来让子组件在渲染时将一部分数据提供给插槽。

我们也确实有办法这么做!可以像对组件传递 props 那样,向一个插槽的出口上传递 attributes:

html 复制代码
<!-- <MyComponent> 的模板 -->
<div>
  <slot :text="greetingMessage" :count="1"></slot>
</div>

当需要接收插槽 props 时,默认插槽和具名插槽的使用方式有一些小区别。下面我们将先展示默认插槽如何接受 props,通过子组件标签上的 v-slot 指令,直接接收到了一个插槽 props 对象:

html 复制代码
<MyComponent v-slot="slotProps">
  {{ slotProps.text }} {{ slotProps.count }}
</MyComponent>

在演练场中尝试一下

子组件传入插槽的 props 作为了 v-slot 指令的值,可以在插槽内的表达式中访问。

你可以将作用域插槽类比为一个传入子组件的函数。子组件会将相应的 props 作为参数传给它:

javascript 复制代码
MyComponent({
  // 类比默认插槽,将其想成一个函数
  default: (slotProps) => {
    return `${slotProps.text} ${slotProps.count}`
  }
})

function MyComponent(slots) {
  const greetingMessage = 'hello'
  return `<div>${
    // 在插槽函数调用时传入 props
    slots.default({ text: greetingMessage, count: 1 })
  }</div>`
}

实际上,这已经和作用域插槽的最终代码编译结果、以及手动编写渲染函数时使用作用域插槽的方式非常类似了。

v-slot="slotProps" 可以类比这里的函数签名,和函数的参数类似,我们也可以在 v-slot 中使用解构:

html 复制代码
<MyComponent v-slot="{ text, count }">
  {{ text }} {{ count }}
</MyComponent>

具名作用域插槽

具名作用域插槽的工作方式也是类似的,插槽 props 可以作为 v-slot 指令的值被访问到:v-slot:name="slotProps"。当使用缩写时是这样:

html 复制代码
<MyComponent>
  <template #header="headerProps">
    {{ headerProps }}
  </template>

  <template #default="defaultProps">
    {{ defaultProps }}
  </template>

  <template #footer="footerProps">
    {{ footerProps }}
  </template>
</MyComponent>

向具名插槽中传入 props:

html 复制代码
<slot name="header" message="hello"></slot>

注意插槽上的 name 是一个 Vue 特别保留的 attribute,不会作为 props 传递给插槽。因此最终 headerProps 的结果是 { message: 'hello' }

如果你同时使用了具名插槽与默认插槽,则需要为默认插槽使用显式的 <template> 标签。尝试直接为组件添加 v-slot 指令将导致编译错误。这是为了避免因默认插槽的 props 的作用域而困惑。举例:

html 复制代码
<!-- <MyComponent> template -->
<div>
  <slot :message="hello"></slot>
  <slot name="footer" />
</div>
html 复制代码
<!-- 该模板无法编译 -->
<MyComponent v-slot="{ message }">
  <p>{{ message }}</p>
  <template #footer>
    <!-- message 属于默认插槽,此处不可用 -->
    <p>{{ message }}</p>
  </template>
</MyComponent>

为默认插槽使用显式的 <template> 标签有助于更清晰地指出 message 属性在其他插槽中不可用:

html 复制代码
<MyComponent>
  <!-- 使用显式的默认插槽 -->
  <template #default="{ message }">
    <p>{{ message }}</p>
  </template>

  <template #footer>
    <p>Here's some contact info</p>
  </template>
</MyComponent>

高级列表组件示例

你可能想问什么样的场景才适合用到作用域插槽,这里我们来看一个 <FancyList> 组件的例子。它会渲染一个列表,并同时会封装一些加载远端数据的逻辑、使用数据进行列表渲染、或者是像分页或无限滚动这样更进阶的功能。然而我们希望它能够保留足够的灵活性,将对单个列表元素内容和样式的控制权留给使用它的父组件。我们期望的用法可能是这样的:

html 复制代码
<FancyList :api-url="url" :per-page="10">
  <template #item="{ body, username, likes }">
    <div class="item">
      <p>{{ body }}</p>
      <p>by {{ username }} | {{ likes }} likes</p>
    </div>
  </template>
</FancyList>

<FancyList> 之中,我们可以多次渲染 <slot> 并每次都提供不同的数据 (注意我们这里使用了 v-bind 来传递插槽的 props):

html 复制代码
<ul>
  <li v-for="item in items">
    <slot name="item" v-bind="item"></slot>
  </li>
</ul>

在演练场中尝试一下

无渲染组件

上面的 <FancyList> 案例同时封装了可重用的逻辑 (数据获取、分页等) 和视图输出,但也将部分视图输出通过作用域插槽交给了消费者组件来管理。

如果我们将这个概念拓展一下,可以想象的是,一些组件可能只包括了逻辑而不需要自己渲染内容,视图输出通过作用域插槽全权交给了消费者组件。我们将这种类型的组件称为无渲染组件

这里有一个无渲染组件的例子,一个封装了追踪当前鼠标位置逻辑的组件:

html 复制代码
<MouseTracker v-slot="{ x, y }">
  Mouse is at: {{ x }}, {{ y }}
</MouseTracker>

在演练场中尝试一下

虽然这个模式很有趣,但大部分能用无渲染组件实现的功能都可以通过组合式 API 以另一种更高效的方式实现,并且还不会带来额外组件嵌套的开销。之后我们会在组合式函数一章中介绍如何更高效地实现追踪鼠标位置的功能。

尽管如此,作用域插槽在需要同时 封装逻辑、组合视图界面时还是很有用,就像上面的 <FancyList> 组件那样。

理解:

一、为什么需要插槽?

大多数组件只是通过 props 接收数据,然后按照自己的模板渲染。

但有些组件,比如一个"卡片"组件、一个"对话框"组件,我们希望 里面的内容不是固定的,而是由使用者决定

例如:

html

html 复制代码
<Card> 这里放任何自定义内容,比如图片、文字、按钮 </Card>

Vue 的 插槽 就是用来实现这种"内容自定义"的机制。

二、基础用法:<slot> 出口

子组件里放一个 <slot> 标签,表示 这里将来会被父组件传入的内容替换

子组件 FancyButton.vue

vue

html 复制代码
<template>
  <button class="fancy-btn">
    <slot></slot>   <!-- 插槽出口 -->
  </button>
</template>

父组件使用

vue

html 复制代码
<FancyButton>
  Click me!        <!-- 插槽内容 -->
</FancyButton>

最终渲染结果:

html

html 复制代码
<button class="fancy-btn">Click me!</button>

如果没有父组件提供内容,<slot> 里面可以写默认内容:

vue

html 复制代码
<button>
  <slot>默认按钮文字</slot>
</button>

当父组件不传内容时,就会显示"默认按钮文字"。

三、具名插槽:多个插槽

当一个组件需要多个不同的占位区域时(比如一个布局组件有 header、main、footer),就要给每个插槽起个名字。

子组件 BaseLayout.vue

vue

html 复制代码
<template>
  <div class="container">
    <header>
      <slot name="header"></slot>
    </header>
    <main>
      <slot></slot>       <!-- 没有 name 的默认插槽 -->
    </main>
    <footer>
      <slot name="footer"></slot>
    </footer>
  </div>
</template>

父组件使用 :用 <template> 配合 v-slot:name#name 来填充对应插槽。

vue

html 复制代码
<BaseLayout>
  <template #header>
    <h1>页头</h1>
  </template>

  <!-- 默认插槽的缩写:不用写 template,直接放内容 -->
  <p>主要内容</p>
  <p>更多内容</p>

  <template #footer>
    <p>页脚</p>
  </template>
</BaseLayout>

渲染结果:

html

html 复制代码
<div class="container">
  <header><h1>页头</h1></header>
  <main><p>主要内容</p><p>更多内容</p></main>
  <footer><p>页脚</p></footer>
</div>

四、作用域插槽(让插槽能使用子组件的数据)

默认情况下,插槽里只能使用父组件的数据(因为内容是在父组件里写的)。

但有时候我们希望子组件能把一些数据传给插槽,让父组件在写内容时使用

例如,一个列表组件 <FancyList> 负责从 API 获取数据、处理分页,但列表每一项怎么显示交给父组件来决定。

子组件 :在 <slot> 上绑定一些属性,就像传递 props 一样。

vue

html 复制代码
<!-- FancyList.vue -->
<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      <slot name="item" :item="item" :index="index"></slot>
    </li>
  </ul>
</template>

父组件 :使用 v-slot:item="slotProps" 接收这些数据,然后就可以在模板里使用 slotProps.itemslotProps.index

vue

html 复制代码
<FancyList :items="posts">
  <template #item="{ item, index }">   <!-- 解构写法 -->
    <div>
      {{ index + 1 }}. {{ item.title }}
    </div>
  </template>
</FancyList>

这就是 作用域插槽:子组件向插槽暴露数据,父组件决定如何渲染。

五、动态插槽名

插槽名也可以动态决定,用 [变量] 语法:

vue

html 复制代码
<template #[dynamicSlotName]>
  ...
</template>

与动态参数一样,需要遵守一些语法限制,但你知道有这个功能即可。

六、条件插槽

有时你想根据父组件是否传入了某个插槽 来决定是否渲染某个区域。

可以通过 $slots 对象来检查。

vue

html 复制代码
<div v-if="$slots.header" class="card-header">
  <slot name="header" />
</div>

这样如果没有传入 header 插槽,那个包裹的 <div> 就不会渲染。

七、无渲染组件(了解概念)

如果一个组件只包含逻辑,不渲染任何固定的 HTML ,而是把所有渲染都交给插槽,就叫"无渲染组件"。

比如一个跟踪鼠标位置的组件:

vue

html 复制代码
<MouseTracker v-slot="{ x, y }">
  鼠标位置:{{ x }}, {{ y }}
</MouseTracker>

但这在 Vue 3 中更推荐用组合式函数(composables)实现,不需要组件嵌套。所以先知道这个概念即可。

八、总结(小白版)

插槽 = 组件里留的"洞",父组件可以在洞里面填任何内容。

  • 单个洞:<slot>

  • 多个洞:给 <slot name="xxx"> 起名字,父组件用 <template #xxx> 填充。

  • 如果想让父组件在填内容时能用到子组件的数据(比如列表的每一项),用作用域插槽 :子组件在 <slot> 上绑定数据,父组件用 v-slot 接收。

  • 插槽也可以有默认内容。

  • 可以用 $slots 判断父组件有没有填某个插槽。

现在你可以试着写一个 Card 组件,它有一个默认插槽和一个 footer 插槽,在父组件里给两个插槽分别填内容,亲自体验一下插槽的用法。

依赖注入

此章节假设你已经看过了组件基础。若你还不了解组件是什么,请先阅读该章节。

Prop 逐级透传问题

通常情况下,当我们需要从父组件向子组件传递数据时,会使用 props。想象一下这样的结构:有一些多层级嵌套的组件,形成了一棵巨大的组件树,而某个深层的子组件需要一个较远的祖先组件中的部分数据。在这种情况下,如果仅使用 props 则必须将其沿着组件链逐级传递下去,这会非常麻烦:

注意,虽然这里的 <Footer> 组件可能根本不关心这些 props,但为了使 <DeepChild> 能访问到它们,仍然需要定义并向下传递。如果组件链路非常长,可能会影响到更多这条路上的组件。这一问题被称为"prop 逐级透传",显然是我们希望尽量避免的情况。

provideinject 可以帮助我们解决这一问题 1。一个父组件相对于其所有的后代组件,会作为依赖提供者 。任何后代的组件树,无论层级有多深,都可以注入由父组件提供给整条链路的依赖。

Provide (提供)

要为组件后代提供数据,需要使用到 provide() 函数:

html 复制代码
<script setup>
import { provide } from 'vue'

provide(/* 注入名 */ 'message', /* 值 */ 'hello!')
</script>

如果不使用 <script setup>,请确保 provide() 是在 setup() 同步调用的:

javascript 复制代码
import { provide } from 'vue'

export default {
  setup() {
    provide(/* 注入名 */ 'message', /* 值 */ 'hello!')
  }
}

provide() 函数接收两个参数。第一个参数被称为注入名 ,可以是一个字符串或是一个 Symbol。后代组件会用注入名来查找期望注入的值。一个组件可以多次调用 provide(),使用不同的注入名,注入不同的依赖值。

第二个参数是提供的值,值可以是任意类型,包括响应式的状态,比如一个 ref:

javascript 复制代码
import { ref, provide } from 'vue'

const count = ref(0)
provide('key', count)

提供的响应式状态使后代组件可以由此和提供者建立响应式的联系。

应用层 Provide

除了在一个组件中提供依赖,我们还可以在整个应用层面提供依赖:

javascript 复制代码
import { createApp } from 'vue'

const app = createApp({})

app.provide(/* 注入名 */ 'message', /* 值 */ 'hello!')

在应用级别提供的数据在该应用内的所有组件中都可以注入。这在你编写插件时会特别有用,因为插件一般都不会使用组件形式来提供值。

Inject (注入)

要注入上层组件提供的数据,需使用 inject() 函数:

html 复制代码
<script setup>
import { inject } from 'vue'

const message = inject('message')
</script>

如果有多个父组件提供了相同键的数据,注入将解析为组件链上最近的父组件所注入的值。

如果提供的值是一个 ref,注入进来的会是该 ref 对象,而不会自动解包为其内部的值。这使得注入方组件能够通过 ref 对象保持了和供给方的响应性链接。

带有响应性的 provide + inject 完整示例

同样的,如果没有使用 <script setup>inject() 需要在 setup() 内同步调用:

javascript 复制代码
import { inject } from 'vue'

export default {
  setup() {
    const message = inject('message')
    return { message }
  }
}

注入默认值

默认情况下,inject 假设传入的注入名会被某个祖先链上的组件提供。如果该注入名的确没有任何组件提供,则会抛出一个运行时警告。

如果在注入一个值时不要求必须有提供者,那么我们应该声明一个默认值,和 props 类似:

javascript 复制代码
// 如果没有祖先组件提供 "message"
// `value` 会是 "这是默认值"
const value = inject('message', '这是默认值')

在一些场景中,默认值可能需要通过调用一个函数或初始化一个类来取得。为了避免在用不到默认值的情况下进行不必要的计算或产生副作用,我们可以使用工厂函数来创建默认值:

javascript 复制代码
const value = inject('key', () => new ExpensiveClass(), true)

第三个参数表示默认值应该被当作一个工厂函数。

和响应式数据配合使用

当提供 / 注入响应式的数据时,建议尽可能将任何对响应式状态的变更都保持在供给方组件中。这样可以确保所提供状态的声明和变更操作都内聚在同一个组件内,使其更容易维护。

有的时候,我们可能需要在注入方组件中更改数据。在这种情况下,我们推荐在供给方组件内声明并提供一个更改数据的方法函数:

html 复制代码
<!-- 在供给方组件内 -->
<script setup>
import { provide, ref } from 'vue'

const location = ref('North Pole')

function updateLocation() {
  location.value = 'South Pole'
}

provide('location', {
  location,
  updateLocation
})
</script>
html 复制代码
<!-- 在注入方组件 -->
<script setup>
import { inject } from 'vue'

const { location, updateLocation } = inject('location')
</script>

<template>
  <button @click="updateLocation">{{ location }}</button>
</template>

最后,如果你想确保提供的数据不能被注入方的组件更改,你可以使用 readonly() 来包装提供的值。

html 复制代码
<script setup>
import { ref, provide, readonly } from 'vue'

const count = ref(0)
provide('read-only-count', readonly(count))
</script>

使用 Symbol 作注入名

至此,我们已经了解了如何使用字符串作为注入名。但如果你正在构建大型的应用,包含非常多的依赖提供,或者你正在编写提供给其他开发者使用的组件库,建议最好使用 Symbol 来作为注入名以避免潜在的冲突。

我们通常推荐在一个单独的文件中导出这些注入名 Symbol:

keys.js

javascript 复制代码
export const myInjectionKey = Symbol()
javascript 复制代码
// 在供给方组件中
import { provide } from 'vue'
import { myInjectionKey } from './keys.js'

provide(myInjectionKey, { 
  /* 要提供的数据 */
})
javascript 复制代码
// 注入方组件
import { inject } from 'vue'
import { myInjectionKey } from './keys.js'

const injected = inject(myInjectionKey)

TypeScript 用户请参考:为 Provide / Inject 标注类型

理解:

一、为什么需要依赖注入?

假设你有一个深层嵌套的组件树:爷爷 → 爸爸 → 儿子 → 孙子。

孙子组件需要用到爷爷组件里的数据。如果只用 props,就必须:爷爷传给爸爸,爸爸再传给儿子,儿子再传给孙子。中间那些组件明明不需要这个数据,却要被迫帮忙传递,非常麻烦,也容易出错。

这就像你想把一封信送给村里的某个人,但你必须经过中间的每一个人,每个人都要打开信封看一下再转交,效率很低。

依赖注入 就是 Vue 提供的"快递直达"服务:

  • 祖先组件使用 provide 提供数据(把信放进一个公共信箱)。

  • 后代组件使用 inject 接收数据(直接从信箱取信),不需要中间人插手。

二、基本用法:provide 提供,inject 接收

1. 祖先组件(提供数据)

javascript 复制代码
<!-- 爷爷组件 -->
<script setup>
import { provide } from 'vue'

provide('message', 'Hello from Grandpa!')
provide('count', 100)
</script>

<template>
  <Child />   <!-- 随便一个后代 -->
</template>
  • provide 接受两个参数:注入名 (相当于钥匙,可以是字符串或 Symbol)和 (任意类型的数据)。

2. 后代组件(接收数据)

javascript 复制代码
<!-- 孙子组件 -->
<script setup>
import { inject } from 'vue'

const message = inject('message')   // 拿到 'Hello from Grandpa!'
const count = inject('count')       // 拿到 100
</script>

<template>
  <p>{{ message }} - count: {{ count }}</p>
</template>
  • inject 的参数就是注入名,Vue 会沿着祖先链向上查找最近提供的值。

中间组件(爸爸、儿子)完全不需要写任何代码,数据直接透传。

三、提供响应式数据(实现自动更新)

如果提供的是一个 响应式数据 (比如 ref),后代组件拿到后可以直接使用,并且当祖先组件修改这个数据时,所有后代组件都会自动更新。

javascript 复制代码
<!-- 祖先组件 -->
<script setup>
import { ref, provide } from 'vue'

const count = ref(0)
provide('count', count)

function increment() {
  count.value++
}
</script>

<template>
  <button @click="increment">增加</button>
  <DeepChild />
</template>
javascript 复制代码
<!-- 后代组件 -->
<script setup>
import { inject } from 'vue'

const count = inject('count')   // 这是一个 ref 对象
</script>

<template>
  <p>当前计数:{{ count }}</p>   <!-- 模板中自动解包,显示数值 -->
</template>

当祖先的 count 变化,后代的显示也会变化 ------ 建立了响应式连接。

四、提供方法(让后代也能修改数据)

为了保持数据修改的可控性,通常建议把修改数据的方法也一起提供出去,这样后代可以调用方法,而不是直接修改数据(防止乱改)。

javascript 复制代码
<!-- 祖先组件 -->
<script setup>
import { ref, provide } from 'vue'

const count = ref(0)
function increment() { count.value++ }

provide('countContext', {
  count,
  increment
})
</script>
javascript 复制代码
<!-- 后代组件 -->
<script setup>
import { inject } from 'vue'

const { count, increment } = inject('countContext')
</script>

<template>
  <p>{{ count }}</p>
  <button @click="increment">增加</button>
</template>

这样做,后代只能通过你提供的方法来修改数据,逻辑更清晰。

五、注入默认值(防止找不到提供者)

如果某个注入名可能没有祖先提供(比如组件可能被用在没有提供者的地方),你可以设置一个默认值,避免报错。

javascript 复制代码
const message = inject('message', '默认消息')

如果默认值需要复杂计算,可以用工厂函数,并传第三个参数 true

javascript 复制代码
const value = inject('key', () => new ExpensiveClass(), true)

六、使用 Symbol 作为注入名(避免命名冲突)

在大型项目或组件库中,如果你使用字符串作为注入名,可能会不小心和别的组件重名,导致奇怪的 bug。

Symbol 可以保证唯一性。

javascript 复制代码
// keys.js
export const myKey = Symbol()
html 复制代码
<!-- 提供 -->
<script setup>
import { provide } from 'vue'
import { myKey } from './keys.js'
provide(myKey, 'some value')
</script>
html 复制代码
<!-- 注入 -->
<script setup>
import { inject } from 'vue'
import { myKey } from './keys.js'
const value = inject(myKey)
</script>

七、应用层提供(全局数据)

你也可以在创建应用时直接 provide,这样整个应用的所有组件都能注入。

javascript 复制代码
import { createApp } from 'vue'
const app = createApp({})
app.provide('globalMessage', 'Hello World')

这在编写插件时很有用。

八、只读提供(防止后代修改)

如果你希望后代只能读取,不能修改,可以用 readonly 包装。

javascript 复制代码
import { ref, provide, readonly } from 'vue'
const count = ref(0)
provide('readonlyCount', readonly(count))

后代用 inject 拿到的是一个只读的 ref,尝试修改会发出警告。

九、总结(小白版)

依赖注入 = 祖先组件直接把数据(或方法)传给任意后代组件,中间组件不需要参与。

  • provide:在祖先里提供数据,给数据起个名字(注入名)。

  • inject:在后代里获取数据,通过名字取出来。

  • 可以提供响应式数据(ref),后代能自动响应变化。

  • 一般同时提供修改数据的方法,防止后代乱改。

  • 可以设置默认值,避免找不到提供者时报错。

  • 大型项目建议用 Symbol 作名字,避免冲突。

什么时候用?

当你发现 props 需要"逐级传递"跨越很多层时,就可以考虑用 provide/inject 来简化。但注意:它会让组件之间的耦合变隐蔽,不要滥用,只用在真正跨越多层的共享数据上(如主题、语言、用户信息等)。

现在你可以试着写一个简单的例子:祖组件提供一个主题颜色(比如 'red'),孙组件显示一段文字并应用这个颜色。这样就能立刻体会到依赖注入的便利了。

异步组件

基本用法

在大型项目中,我们可能需要拆分应用为更小的块,并仅在需要时再从服务器加载相关组件。Vue 提供了 defineAsyncComponent 方法来实现此功能:

javascript 复制代码
import { defineAsyncComponent } from 'vue'

const AsyncComp = defineAsyncComponent(() => {
  return new Promise((resolve, reject) => {
    // ...从服务器获取组件
    resolve(/* 获取到的组件 */)
  })
})
// ... 像使用其他一般组件一样使用 `AsyncComp`

如你所见,defineAsyncComponent 方法接收一个返回 Promise 的加载函数。这个 Promise 的 resolve 回调方法应该在从服务器获得组件定义时调用。你也可以调用 reject(reason) 表明加载失败。

ES 模块动态导入也会返回一个 Promise,所以多数情况下我们会将它和 defineAsyncComponent 搭配使用。类似 Vite 和 Webpack 这样的构建工具也支持此语法 (并且会将它们作为打包时的代码分割点),因此我们也可以用它来导入 Vue 单文件组件:

javascript 复制代码
import { defineAsyncComponent } from 'vue'

const AsyncComp = defineAsyncComponent(() =>
  import('./components/MyComponent.vue')
)

最后得到的 AsyncComp 是一个外层包装过的组件,仅在页面需要它渲染时才会调用加载内部实际组件的函数。它会将接收到的 props 和插槽传给内部组件,所以你可以使用这个异步的包装组件无缝地替换原始组件,同时实现延迟加载。

与普通组件一样,异步组件可以使用 app.component() 全局注册

javascript 复制代码
app.component('MyComponent', defineAsyncComponent(() =>
  import('./components/MyComponent.vue')
))

也可以直接在父组件中直接定义它们:

html 复制代码
<script setup>
import { defineAsyncComponent } from 'vue'

const AdminPage = defineAsyncComponent(() =>
  import('./components/AdminPageComponent.vue')
)
</script>

<template>
  <AdminPage />
</template>

加载与错误状态

异步操作不可避免地会涉及到加载和错误状态,因此 defineAsyncComponent() 也支持在高级选项中处理这些状态:

javascript 复制代码
const AsyncComp = defineAsyncComponent({
  // 加载函数
  loader: () => import('./Foo.vue'),

  // 加载异步组件时使用的组件
  loadingComponent: LoadingComponent,
  // 展示加载组件前的延迟时间,默认为 200ms
  delay: 200,

  // 加载失败后展示的组件
  errorComponent: ErrorComponent,
  // 如果提供了一个 timeout 时间限制,并超时了
  // 也会显示这里配置的报错组件,默认值是:Infinity
  timeout: 3000
})

如果提供了一个加载组件,它将在内部组件加载时先行显示。在加载组件显示之前有一个默认的 200ms 延迟------这是因为在网络状况较好时,加载完成得很快,加载组件和最终组件之间的替换太快可能产生闪烁,反而影响用户感受。

如果提供了一个报错组件,则它会在加载器函数返回的 Promise 抛错时被渲染。你还可以指定一个超时时间,在请求耗时超过指定时间时也会渲染报错组件。

惰性激活

如果你正在使用服务器端渲染,这一部分才会适用。

在 Vue 3.5+ 中,异步组件可以通过提供激活策略来控制何时进行激活。

  • Vue 提供了一些内置的激活策略。这些内置策略需要分别导入,以便在未使用时进行 tree-shake。

  • 该设计有意保持在底层,以确保灵活性。将来可以在此基础上构建编译器语法糖,无论是在核心还是更上层的解决方案 (如 Nuxt) 中实现。

在空闲时进行激活

通过 requestIdleCallback 进行激活:

javascript 复制代码
import { defineAsyncComponent, hydrateOnIdle } from 'vue'

const AsyncComp = defineAsyncComponent({
  loader: () => import('./Comp.vue'),
  hydrate: hydrateOnIdle(/* 传递可选的最大超时 */)
})

在可见时激活

通过 IntersectionObserver 在元素变为可见时进行激活。

javascript 复制代码
import { defineAsyncComponent, hydrateOnVisible } from 'vue'

const AsyncComp = defineAsyncComponent({
  loader: () => import('./Comp.vue'),
  hydrate: hydrateOnVisible()
})

可以选择传递一个侦听器的选项对象值:

javascript 复制代码
hydrateOnVisible({ rootMargin: '100px' })

在媒体查询匹配时进行激活

当指定的媒体查询匹配时进行激活。

javascript 复制代码
import { defineAsyncComponent, hydrateOnMediaQuery } from 'vue'

const AsyncComp = defineAsyncComponent({
  loader: () => import('./Comp.vue'),
  hydrate: hydrateOnMediaQuery('(max-width:500px)')
})

交互时激活

当组件元素上触发指定事件时进行激活。完成激活后,触发激活的事件也将被重放。

javascript 复制代码
import { defineAsyncComponent, hydrateOnInteraction } from 'vue'

const AsyncComp = defineAsyncComponent({
  loader: () => import('./Comp.vue'),
  hydrate: hydrateOnInteraction('click')
})

也可以是多个事件类型的列表:

javascript 复制代码
hydrateOnInteraction(['wheel', 'mouseover'])

自定义策略

javascript 复制代码
import { defineAsyncComponent, type HydrationStrategy } from 'vue'

const myStrategy: HydrationStrategy = (hydrate, forEachElement) => {
  // forEachElement 是一个遍历组件未激活的 DOM 中所有根元素的辅助函数,
  // 因为根元素可能是一个片段而非单个元素
  forEachElement(el => {
    // ...
  })
  // 准备好时调用 `hydrate`
  hydrate()
  return () => {
    // 如必要,返回一个销毁函数
  }
}

const AsyncComp = defineAsyncComponent({
  loader: () => import('./Comp.vue'),
  hydrate: myStrategy
})

搭配 Suspense 使用

异步组件可以搭配内置的 <Suspense> 组件一起使用,若想了解 <Suspense> 和异步组件之间交互,请参阅 <Suspense> 章节。

理解:

一、为什么需要异步组件?

当你做一个很大的网页应用时,所有组件如果都打包到一个文件里,这个文件会非常巨大,导致用户第一次打开页面时加载很慢。

异步组件 就是用来解决这个问题的:

  • 先把应用拆分成多个小块。

  • 只有当用户真正需要用到某个组件时(比如点击某个按钮、切换到某个路由),才从服务器去加载这个组件的代码。

这就好比一本很厚的百科全书,你不需要一次性把所有内容都拿到手里,而是想看哪一页,再翻到那一页去读。异步组件 = 按需加载

二、基本用法:defineAsyncComponent

Vue 提供了 defineAsyncComponent 方法,配合动态 import 来实现按需加载。

javascript 复制代码
import { defineAsyncComponent } from 'vue'

const AsyncComp = defineAsyncComponent(() =>
  import('./components/MyComponent.vue')
)
  • import('./components/MyComponent.vue') 会返回一个 Promise。Vue 在需要渲染这个组件时,才会去执行这个 import,加载组件的代码。

  • AsyncComp 是一个包装组件,你可以像使用普通组件一样使用它:<AsyncComp />

  • 当组件正在加载时,Vue 会什么也不显示(或者你可以配置一个"加载中"的提示组件)。

全局注册

javascript 复制代码
app.component('MyComponent', defineAsyncComponent(() => import('./MyComp.vue')))

局部注册(在 <script setup> 中)

html 复制代码
<script setup>
import { defineAsyncComponent } from 'vue'
const AdminPage = defineAsyncComponent(() => import('./AdminPage.vue'))
</script>
<template>
  <AdminPage />
</template>

三、加载与错误状态(更好的用户体验)

有时网络较慢,加载组件需要几秒钟;或者加载失败。为了改善体验,我们可以配置:

  • 加载组件:在目标组件还在加载时,先显示一个"加载中..."的提示。

  • 延迟时间:默认 200ms 后才显示加载组件,避免加载太快一闪而过。

  • 错误组件:如果加载失败,显示一个出错提示。

  • 超时时间:超过指定时间仍未加载完成,就显示错误组件。

javascript 复制代码
const AsyncComp = defineAsyncComponent({
  loader: () => import('./Foo.vue'),
  loadingComponent: LoadingComponent,   // 你自定义的加载中组件
  delay: 200,                           // 延迟 200ms 后显示加载中组件
  errorComponent: ErrorComponent,       // 加载失败时显示的组件
  timeout: 3000                         // 超时 3 秒后显示错误组件
})

四、惰性激活(仅服务端渲染,SSR)

这部分是针对 服务端渲染(SSR) 的高级优化。

简单说:服务端已经生成了整个页面的 HTML,客户端需要"激活"(hydrate)这些静态 HTML,使其变成可交互的 Vue 组件。

如果页面中有一些不重要的区域(比如页面底部),我们可以等浏览器空闲了、或者用户滚动到那里时,再去激活它们,从而提高页面的首屏加载性能。

Vue 3.5+ 提供了几种内置策略:

  • hydrateOnIdle:浏览器空闲时激活。

  • hydrateOnVisible:元素可见时激活(比如滚动到视野内)。

  • hydrateOnMediaQuery:匹配特定媒体查询时激活(例如屏幕宽度小于 500px)。

  • hydrateOnInteraction:用户交互时激活(如点击、鼠标移动等)。

注意:如果你不做 SSR,可以完全忽略这部分。

五、搭配 <Suspense> 使用(了解)

<Suspense> 是一个内置组件,用来处理异步组件加载时的等待状态。它允许你在一个父组件中统一管理多个异步组件的加载状态(比如显示一个整体的 loading 动画)。

文档中提到"可以参考 Suspense 章节",但对于小白来说,暂时不用深究,知道有这个东西即可。

六、总结(小白版)

异步组件 = 懒加载的组件,需要时才会从服务器下载代码。

  • 使用 defineAsyncComponent(() => import('./Comp.vue')) 定义。

  • 可以配置加载中、加载失败的反馈组件。

  • 能显著减少初次加载的代码体积,提升首屏速度。

  • 适合体积较大、不是立即需要的组件,比如弹窗、后台管理页面、图表库等。

现在你可以想象:你的页面中有一个很复杂的图表组件,用户只有点击"显示图表"按钮后才需要它。这时候就可以把图表组件定义成异步的,这样页面一开始就不会加载图表代码,只有用户点击按钮后才会下载并渲染。

组合式函数

什么是"组合式函数"?

在 Vue 应用的概念中,"组合式函数"(Composables) 是一个利用 Vue 的组合式 API 来封装和复用有状态逻辑的函数。

当构建前端应用时,我们常常需要复用公共任务的逻辑。例如为了在不同地方格式化时间,我们可能会抽取一个可复用的日期格式化函数。这个函数封装了无状态的逻辑 :它在接收一些输入后立刻返回所期望的输出。复用无状态逻辑的库有很多,比如你可能已经用过的 lodash 或是 date-fns

相比之下,有状态逻辑负责管理会随时间而变化的状态。一个简单的例子是跟踪当前鼠标在页面中的位置。在实际应用中,也可能是像触摸手势或与数据库的连接状态这样的更复杂的逻辑。

鼠标跟踪器示例

如果我们要直接在组件中使用组合式 API 实现鼠标跟踪功能,它会是这样的:

html 复制代码
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

const x = ref(0)
const y = ref(0)

function update(event) {
  x.value = event.pageX
  y.value = event.pageY
}

onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
</script>

<template>Mouse position is at: {{ x }}, {{ y }}</template>

但是,如果我们想在多个组件中复用这个相同的逻辑呢?我们可以把这个逻辑以一个组合式函数的形式提取到外部文件中:

javascript 复制代码
import { ref, onMounted, onUnmounted } from 'vue'

// 按照惯例,组合式函数名以"use"开头
export function useMouse() {
  // 被组合式函数封装和管理的状态
  const x = ref(0)
  const y = ref(0)

  // 组合式函数可以随时更改其状态。
  function update(event) {
    x.value = event.pageX
    y.value = event.pageY
  }

  // 一个组合式函数也可以挂靠在所属组件的生命周期上
  // 来启动和卸载副作用
  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  // 通过返回值暴露所管理的状态
  return { x, y }
}

下面是它在组件中使用的方式:

html 复制代码
<script setup>
import { useMouse } from './mouse.js'

const { x, y } = useMouse()
</script>

<template>Mouse position is at: {{ x }}, {{ y }}</template>

如你所见,核心逻辑完全一致,我们做的只是把它移到一个外部函数中去,并返回需要暴露的状态。和在组件中一样,你也可以在组合式函数中使用所有的组合式 API。现在,useMouse() 的功能可以在任何组件中轻易复用了。

更酷的是,你还可以嵌套多个组合式函数:一个组合式函数可以调用一个或多个其他的组合式函数。这使得我们可以像使用多个组件组合成整个应用一样,用多个较小且逻辑独立的单元来组合形成复杂的逻辑。实际上,这正是为什么我们决定将实现了这一设计模式的 API 集合命名为组合式 API。

举例来说,我们可以将添加和清除 DOM 事件监听器的逻辑也封装进一个组合式函数中:

javascript 复制代码
import { onMounted, onUnmounted } from 'vue'

export function useEventListener(target, event, callback) {
  // 如果你想的话,
  // 也可以用字符串形式的 CSS 选择器来寻找目标 DOM 元素
  onMounted(() => target.addEventListener(event, callback))
  onUnmounted(() => target.removeEventListener(event, callback))
}

有了它,之前的 useMouse() 组合式函数可以被简化为:

javascript 复制代码
import { ref } from 'vue'
import { useEventListener } from './event'

export function useMouse() {
  const x = ref(0)
  const y = ref(0)

  useEventListener(window, 'mousemove', (event) => {
    x.value = event.pageX
    y.value = event.pageY
  })

  return { x, y }
}

TIP:

每一个调用 useMouse() 的组件实例会创建其独有的 xy 状态拷贝,因此他们不会互相影响。如果你想要在组件之间共享状态,请阅读状态管理这一章。

异步状态示例

useMouse() 组合式函数没有接收任何参数,因此让我们再来看一个需要接收一个参数的组合式函数示例。在做异步数据请求时,我们常常需要处理不同的状态:加载中、加载成功和加载失败。

html 复制代码
<script setup>
import { ref } from 'vue'

const data = ref(null)
const error = ref(null)

fetch('...')
  .then((res) => res.json())
  .then((json) => (data.value = json))
  .catch((err) => (error.value = err))
</script>

<template>
  <div v-if="error">Oops! Error encountered: {{ error.message }}</div>
  <div v-else-if="data">
    Data loaded:
    <pre>{{ data }}</pre>
  </div>
  <div v-else>Loading...</div>
</template>

如果在每个需要获取数据的组件中都要重复这种模式,那就太繁琐了。让我们把它抽取成一个组合式函数:

javascript 复制代码
import { ref } from 'vue'

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

  fetch(url)
    .then((res) => res.json())
    .then((json) => (data.value = json))
    .catch((err) => (error.value = err))

  return { data, error }
}

现在我们在组件里只需要:

html 复制代码
<script setup>
import { useFetch } from './fetch.js'

const { data, error } = useFetch('...')
</script>

接收响应式状态

useFetch() 接收一个静态 URL 字符串作为输入------因此它只会执行一次 fetch 并且就此结束。如果我们想要在 URL 改变时重新 fetch 呢?为了实现这一点,我们需要将响应式状态传入组合式函数,并让它基于传入的状态来创建执行操作的侦听器。

举例来说,useFetch() 应该能够接收一个 ref:

javascript 复制代码
const url = ref('/initial-url')

const { data, error } = useFetch(url)

// 这将会重新触发 fetch
url.value = '/new-url'

或者接收一个 getter 函数

javascript 复制代码
// 当 props.id 改变时重新 fetch
const { data, error } = useFetch(() => `/posts/${props.id}`)

我们可以用 watchEffect()toValue() API 来重构我们现有的实现:

javascript 复制代码
import { ref, watchEffect, toValue } from 'vue'

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

  const fetchData = () => {
    // reset state before fetching..
    data.value = null
    error.value = null

    fetch(toValue(url))
      .then((res) => res.json())
      .then((json) => (data.value = json))
      .catch((err) => (error.value = err))
  }

  watchEffect(() => {
    fetchData()
  })

  return { data, error }
}

toValue() 是一个在 3.3 版本中新增的 API。它的设计目的是将 ref 或 getter 规范化为值。如果参数是 ref,它会返回 ref 的值;如果参数是函数,它会调用函数并返回其返回值。否则,它会原样返回参数。它的工作方式类似于 unref(),但对函数有特殊处理。

注意 toValue(url) 是在 watchEffect 回调函数的内部 调用的。这确保了在 toValue() 规范化期间访问的任何响应式依赖项都会被侦听器跟踪。

这个版本的 useFetch() 现在能接收静态 URL 字符串、ref 和 getter,使其更加灵活。watch effect 会立即运行,并且会跟踪 toValue(url) 期间访问的任何依赖项。如果没有跟踪到依赖项 (例如 url 已经是字符串),则 effect 只会运行一次;否则,它将在跟踪到的任何依赖项更改时重新运行。

这是更新后的 useFetch(),为了便于演示,添加了人为延迟和随机错误。

约定和最佳实践

命名

组合式函数约定用驼峰命名法命名,并以"use"作为开头。

输入参数

即便不依赖于 ref 或 getter 的响应性,组合式函数也可以接收它们作为参数。如果你正在编写一个可能被其他开发者使用的组合式函数,最好处理一下输入参数是 ref 或 getter 而非原始值的情况。可以利用 toValue() 工具函数来实现:

javascript 复制代码
import { toValue } from 'vue'

function useFeature(maybeRefOrGetter) {
  // 如果 maybeRefOrGetter 是一个 ref 或 getter,
  // 将返回它的规范化值。
  // 否则原样返回。
  const value = toValue(maybeRefOrGetter)
}

如果你的组合式函数在输入参数是 ref 或 getter 的情况下创建了响应式 effect,为了让它能够被正确追踪,请确保要么使用 watch() 显式地监视 ref 或 getter,要么在 watchEffect() 中调用 toValue()

前面讨论过的 useFetch() 实现提供了一个接受 ref、getter 或普通值作为输入参数的组合式函数的具体示例。

返回值

你可能已经注意到了,我们一直在组合式函数中使用 ref() 而不是 reactive()。我们推荐的约定是组合式函数始终返回一个包含多个 ref 的普通的非响应式对象,这样该对象在组件中被解构为 ref 之后仍可以保持响应性:

javascript 复制代码
// x 和 y 是两个 ref
const { x, y } = useMouse()

从组合式函数返回一个响应式对象会导致在对象解构过程中丢失与组合式函数内状态的响应性连接。与之相反,ref 则可以维持这一响应性连接。

如果你更希望以对象属性的形式来使用组合式函数中返回的状态,你可以将返回的对象用 reactive() 包装一次,这样其中的 ref 会被自动解包,例如:

javascript 复制代码
const mouse = reactive(useMouse())
// mouse.x 链接到了原来的 x ref
console.log(mouse.x)
javascript 复制代码
Mouse position is at: {{ mouse.x }}, {{ mouse.y }}

副作用

在组合式函数中的确可以执行副作用 (例如:添加 DOM 事件监听器或者请求数据),但请注意以下规则:

  • 如果你的应用用到了服务端渲染 (SSR),请确保在组件挂载后才调用的生命周期钩子中执行 DOM 相关的副作用,例如:onMounted()。这些钩子仅会在浏览器中被调用,因此可以确保能访问到 DOM。

  • 确保在 onUnmounted() 时清理副作用。举例来说,如果一个组合式函数设置了一个事件监听器,它就应该在 onUnmounted() 中被移除 (就像我们在 useMouse() 示例中看到的一样)。当然也可以像之前的 useEventListener() 示例那样,使用一个组合式函数来自动帮你做这些事。

使用限制

组合式函数只能在 <script setup>setup() 钩子中被调用。在这些上下文中,它们也只能被同步 调用。在某些情况下,你也可以在像 onMounted() 这样的生命周期钩子中调用它们。

这些限制很重要,因为这些是 Vue 用于确定当前活跃的组件实例的上下文。访问活跃的组件实例很有必要,这样才能:

  1. 将生命周期钩子注册到该组件实例上

  2. 将计算属性和监听器注册到该组件实例上,以便在该组件被卸载时停止监听,避免内存泄漏。

TIP

<script setup> 是唯一在调用 await 之后仍可调用组合式函数的地方。编译器会在异步操作之后自动为你恢复当前的组件实例。

通过抽取组合式函数改善代码结构

抽取组合式函数不仅是为了复用,也是为了代码组织。随着组件复杂度的增高,你可能会最终发现组件多得难以查询和理解。组合式 API 会给予你足够的灵活性,让你可以基于逻辑问题将组件代码拆分成更小的函数:

html 复制代码
<script setup>
import { useFeatureA } from './featureA.js'
import { useFeatureB } from './featureB.js'
import { useFeatureC } from './featureC.js'

const { foo, bar } = useFeatureA()
const { baz } = useFeatureB(foo)
const { qux } = useFeatureC(baz)
</script>

在某种程度上,你可以将这些提取出的组合式函数看作是可以相互通信的组件范围内的服务。

在选项式 API 中使用组合式函数

如果你正在使用选项式 API,组合式函数必须在 setup() 中调用。且其返回的绑定必须在 setup() 中返回,以便暴露给 this 及其模板:

javascript 复制代码
import { useMouse } from './mouse.js'
import { useFetch } from './fetch.js'

export default {
  setup() {
    const { x, y } = useMouse()
    const { data, error } = useFetch('...')
    return { x, y, data, error }
  },
  mounted() {
    // setup() 暴露的属性可以在通过 `this` 访问到
    console.log(this.x)
  }
  // ...其他选项
}

与其他模式的比较

和 Mixin 的对比

Vue 2 的用户可能会对 mixins 选项比较熟悉。它也让我们能够把组件逻辑提取到可复用的单元里。然而 mixins 有三个主要的短板:

  1. 不清晰的数据来源:当使用了多个 mixin 时,实例上的数据属性来自哪个 mixin 变得不清晰,这使追溯实现和理解组件行为变得困难。这也是我们推荐在组合式函数中使用 ref + 解构模式的理由:让属性的来源在消费组件时一目了然。

  2. 命名空间冲突:多个来自不同作者的 mixin 可能会注册相同的属性名,造成命名冲突。若使用组合式函数,你可以通过在解构变量时对变量进行重命名来避免相同的键名。

  3. 隐式的跨 mixin 交流:多个 mixin 需要依赖共享的属性名来进行相互作用,这使得它们隐性地耦合在一起。而一个组合式函数的返回值可以作为另一个组合式函数的参数被传入,像普通函数那样。

基于上述理由,我们不再推荐在 Vue 3 中继续使用 mixin。保留该功能只是为了项目迁移的需求和照顾熟悉它的用户。

和无渲染组件的对比

在组件插槽一章中,我们讨论过了基于作用域插槽的无渲染组件。我们甚至用它实现了一样的鼠标追踪器示例。

组合式函数相对于无渲染组件的主要优势是:组合式函数不会产生额外的组件实例开销。当在整个应用中使用时,由无渲染组件产生的额外组件实例会带来无法忽视的性能开销。

我们推荐在纯逻辑复用时使用组合式函数,在需要同时复用逻辑和视图布局时使用无渲染组件。

和 React Hooks 的对比

如果你有 React 的开发经验,你可能注意到组合式函数和自定义 React hooks 非常相似。组合式 API 的一部分灵感正来自于 React hooks,Vue 的组合式函数也的确在逻辑组合能力上与 React hooks 相近。然而,Vue 的组合式函数是基于 Vue 细粒度的响应性系统,这和 React hooks 的执行模型有本质上的不同。这一话题在组合式 API 的常见问题中有更细致的讨论。

理解:

一、组合式函数是什么?

先看两个概念:

  • 无状态逻辑 :像 formatDate(date) 这种,你给它一个输入,它立刻返回输出。不保存任何变化的状态。

  • 有状态逻辑:像"鼠标当前的位置",这个位置会随时间变化,并且你需要在多个组件里都用到这个位置信息。

组合式函数 就是专门用来封装 有状态逻辑 的函数。它利用 Vue 的组合式 API(比如 refonMounted),把数据和操作数据的方法打包在一起,然后在不同组件中复用。

举个例子:你想在很多组件里都获取鼠标的实时位置。如果不封装,每个组件里都要写一遍 ref(x,y)addEventListenerremoveEventListener。用组合式函数,你只需要写一次,然后在各个组件里 const { x, y } = useMouse() 就搞定了。

二、一个简单的鼠标追踪器

原始写法(在单个组件里)

javascript 复制代码
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const x = ref(0)
const y = ref(0)
function update(e) { x.value = e.pageX; y.value = e.pageY }
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
</script>
<template>鼠标位置:{{ x }}, {{ y }}</template>

抽成组合式函数 (新建 mouse.js):

javascript 复制代码
import { ref, onMounted, onUnmounted } from 'vue'

export function useMouse() {
  const x = ref(0)
  const y = ref(0)
  function update(e) { x.value = e.pageX; y.value = e.pageY }
  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))
  return { x, y }
}

在组件中使用

javascript 复制代码
<script setup>
import { useMouse } from './mouse.js'
const { x, y } = useMouse()
</script>
<template>鼠标位置:{{ x }}, {{ y }}</template>

结果 :逻辑完全一样,但你现在可以在任何组件里调用 useMouse(),非常方便。而且每个组件实例都会拥有自己独立的 xy 状态,互不干扰。

三、组合式函数可以嵌套

你可以像搭积木一样,用一个小组合式函数去构建更大的组合式函数。

比如,我们把事件监听也封装成一个通用的 useEventListener

javascript 复制代码
// event.js
import { onMounted, onUnmounted } from 'vue'
export function useEventListener(target, event, callback) {
  onMounted(() => target.addEventListener(event, callback))
  onUnmounted(() => target.removeEventListener(event, callback))
}

然后 useMouse 就可以变得更简洁:

javascript 复制代码
// mouse.js
import { ref } from 'vue'
import { useEventListener } from './event'

export function useMouse() {
  const x = ref(0)
  const y = ref(0)
  useEventListener(window, 'mousemove', (e) => {
    x.value = e.pageX
    y.value = e.pageY
  })
  return { x, y }
}

这样,你用更小的单元组合出复杂功能,代码更清晰、更容易测试。

四、接收参数:让组合式函数更灵活

很多组合式函数需要接收参数。比如一个通用的数据请求函数 useFetch,它应该能根据不同的 URL 去拉取数据,并且当 URL 变化时重新拉取。

基本版(只接收静态 URL)

javascript 复制代码
export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)
  fetch(url)
    .then(res => res.json())
    .then(json => data.value = json)
    .catch(err => error.value = err)
  return { data, error }
}

但这样 URL 不能动态改变。我们希望传入 refgetter,让组合式函数能响应 URL 的变化。

Vue 提供了一个工具函数 toValue(),它能把 refgetter 变成普通值:

javascript 复制代码
import { ref, watchEffect, toValue } from 'vue'

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

  const fetchData = () => {
    data.value = null
    error.value = null
    const currentUrl = toValue(url)  // 如果 url 是 ref,则取 .value;如果是函数,则调用它
    fetch(currentUrl)
      .then(res => res.json())
      .then(json => data.value = json)
      .catch(err => error.value = err)
  }

  watchEffect(() => {
    fetchData()
  })

  return { data, error }
}

现在你可以这样使用:

javascript 复制代码
// 静态字符串
const { data } = useFetch('/api/user')

// ref
const url = ref('/api/user')
const { data } = useFetch(url)
url.value = '/api/posts'  // 会自动重新请求

// getter 函数
const { data } = useFetch(() => `/api/users/${userId.value}`)

这样 useFetch 就非常强大了。

五、约定和最佳实践

  1. 命名 :组合式函数名用 use 开头,后面跟驼峰(如 useMouseuseFetch)。

  2. 返回值 :推荐返回一个包含多个 ref 的普通对象(不是 reactive 对象)。因为解构 ref 对象不会丢失响应性,而解构 reactive 对象会。

    js

    复制代码
    const { x, y } = useMouse()   // x, y 都是 ref,在模板中自动解包
  3. 参数 :如果参数可能是一个 refgetter,使用 toValue() 来处理,并且在 watchEffectwatch 中调用 toValue(),这样依赖才能被正确追踪。

  4. 副作用 :如果需要在组件挂载后操作 DOM(比如添加事件监听),请放在 onMounted 里,并在 onUnmounted 里清理。

  5. 使用限制 :组合式函数只能在 setup()<script setup> 中同步调用,不能在异步回调里调用(除非你在 <script setup> 里用了 await,Vue 编译器能自动恢复上下文)。

六、组合式函数 vs 其他模式

  • vs Mixin(Vue 2 的混合)

    Mixin 容易导致命名冲突、数据来源不清晰、隐式耦合。组合式函数用函数参数和返回值显式传递依赖,没有这些问题。

  • vs 无渲染组件(作用域插槽)

    无渲染组件也能复用逻辑,但会产生额外的组件实例开销。纯逻辑复用建议用组合式函数;如果需要同时复用视图布局,再用无渲染组件。

  • vs React Hooks

    看起来很相似,但 Vue 的组合式函数基于响应式系统(自动依赖追踪),而 React Hooks 每次渲染都会重新运行,规则更严格。

七、总结(小白版)

组合式函数 = 把组件里那些"需要保存状态、需要生命周期"的逻辑抽出来,做成一个可复用的函数。

  • use 开头命名。

  • 内部可以使用 refonMountedwatch 等所有组合式 API。

  • 返回你想要暴露的数据(通常是 ref)。

  • 在组件中调用它,就像使用普通函数一样。

  • 每个组件实例都会获得自己独立的一份状态副本。

  • 如果函数依赖外部的响应式数据(比如 URL 可能变化),记得用 toValuewatchEffect 来处理。

你可以把组合式函数想象成 乐高的标准零件:你可以在很多地方随意拼插,而不用每次都自己开模做零件。这能让你的代码更干净、更容易维护。

现在你可以试着把自己项目里重复的逻辑(比如从 localStorage 读写数据、监听窗口大小变化)抽成一个组合式函数,你会立刻体会到它的好处。

自定义指令

介绍

除了 Vue 内置的一系列指令 (比如 v-modelv-show) 之外,Vue 还允许你注册自定义的指令 (Custom Directives)。

我们已经介绍了两种在 Vue 中重用代码的方式:组件组合式函数。组件是主要的构建模块,而组合式函数则侧重于有状态的逻辑。另一方面,自定义指令主要是为了重用涉及普通元素的底层 DOM 访问的逻辑。

一个自定义指令由一个包含类似组件生命周期钩子的对象来定义。钩子函数会接收到指令所绑定元素作为其参数。下面是一个自定义指令的例子,当 Vue 将元素插入到 DOM 中后,该指令会将一个 class 添加到元素中:

html 复制代码
<script setup>
// 在模板中启用 v-highlight
const vHighlight = {
  mounted: (el) => {
    el.classList.add('is-highlight')
  }
}
</script>

<template>
  <p v-highlight>This sentence is important!</p>
</template>

This sentence is important!

<script setup> 中,任何以 v 开头的驼峰式命名的变量都可以当作自定义指令使用。在上述例子中,vHighlight 可以在模板中以 v-highlight 的形式使用。

在不使用 <script setup> 的情况下,自定义指令需要通过 directives 选项注册:

javascript 复制代码
export default {
  setup() {
    /*...*/
  },
  directives: {
    // 在模板中启用 v-highlight
    highlight: {
      /* ... */
    }
  }
}

将一个自定义指令全局注册到应用层级也是一种常见的做法:

javascript 复制代码
const app = createApp({})

// 使 v-highlight 在所有组件中都可用
app.directive('highlight', {
  /* ... */
})

通过扩展 vue 中的 ComponentCustomProperties 接口,可以为自定义的全局指令添加类型。

更多细节参考:为自定义全局指令添加类型

自定义指令的使用时机

只有当所需功能只能通过直接的 DOM 操作来实现时,才应该使用自定义指令。

一个常见例子是使元素获取焦点的 v-focus 指令。

html 复制代码
<script setup>
// 在模板中启用 v-focus
const vFocus = {
  mounted: (el) => el.focus()
}
</script>

<template>
  <input v-focus />
</template>

该指令比 autofocus 属性更有用,因为它不仅在页面加载时有效,而且在 Vue 动态插入元素时也有效!

建议尽可能使用 v-bind 等内置指令声明模板,因为它们更高效,对服务端渲染也更友好。

指令钩子

一个指令的定义对象可以提供几种钩子函数 (都是可选的):

javascript 复制代码
const myDirective = {
  // 在绑定元素的 attribute 前
  // 或事件监听器应用前调用
  created(el, binding, vnode) {
    // 下面会介绍各个参数的细节
  },
  // 在元素被插入到 DOM 前调用
  beforeMount(el, binding, vnode) {},
  // 在绑定元素的父组件
  // 及他自己的所有子节点都挂载完成后调用
  mounted(el, binding, vnode) {},
  // 绑定元素的父组件更新前调用
  beforeUpdate(el, binding, vnode, prevVnode) {},
  // 在绑定元素的父组件
  // 及他自己的所有子节点都更新后调用
  updated(el, binding, vnode, prevVnode) {},
  // 绑定元素的父组件卸载前调用
  beforeUnmount(el, binding, vnode) {},
  // 绑定元素的父组件卸载后调用
  unmounted(el, binding, vnode) {}
}

钩子参数

指令的钩子会传递以下几种参数:

  • el:指令绑定到的元素。这可以用于直接操作 DOM。

  • binding:一个对象,包含以下属性。

    • value:传递给指令的值。例如在 v-my-directive="1 + 1" 中,值是 2
    • oldValue:之前的值,仅在 beforeUpdateupdated 中可用。无论值是否更改,它都可用。
    • arg:传递给指令的参数 (如果有的话)。例如在 v-my-directive:foo 中,参数是 "foo"
    • modifiers:一个包含修饰符的对象 (如果有的话)。例如在 v-my-directive.foo.bar 中,修饰符对象是 { foo: true, bar: true }
    • instance:使用该指令的组件实例。
    • dir:指令的定义对象。
  • vnode:代表绑定元素的底层 VNode。

  • prevVnode:代表之前的渲染中指令所绑定元素的 VNode。仅在 beforeUpdateupdated 钩子中可用。

举例来说,像下面这样使用指令:

html 复制代码
<div v-example:foo.bar="baz">

binding 参数会是一个这样的对象:

javascript 复制代码
{
  arg: 'foo',
  modifiers: { bar: true },
  value: /* `baz` 的值 */,
  oldValue: /* 上一次更新时 `baz` 的值 */
}

和内置指令类似,自定义指令的参数也可以是动态的。举例来说:

html 复制代码
<div v-example:[arg]="value"></div>

这里指令的参数会基于组件的 arg 数据属性响应式地更新。

除了 el 外,其他参数都是只读的,不要更改它们。若你需要在不同的钩子间共享信息,推荐通过元素的 dataset attribute 实现。

简化形式

对于自定义指令来说,一个很常见的情况是仅仅需要在 mountedupdated 上实现相同的行为,除此之外并不需要其他钩子。这种情况下我们可以直接用一个函数来定义指令,如下所示:

html 复制代码
<div v-color="color"></div>
javascript 复制代码
app.directive('color', (el, binding) => {
  // 这会在 `mounted` 和 `updated` 时都调用
  el.style.color = binding.value
})

对象字面量

如果你的指令需要多个值,你可以向它传递一个 JavaScript 对象字面量。别忘了,指令也可以接收任何合法的 JavaScript 表达式。

html 复制代码
<div v-demo="{ color: 'white', text: 'hello!' }"></div>
javascript 复制代码
app.directive('demo', (el, binding) => {
  console.log(binding.value.color) // => "white"
  console.log(binding.value.text) // => "hello!"
})

在组件上使用

不推荐

不推荐在组件上使用自定义指令。当组件具有多个根节点时可能会出现预期外的行为。

当在组件上使用自定义指令时,它会始终应用于组件的根节点,和透传 attributes 类似。

html 复制代码
<MyComponent v-demo="test" />
html 复制代码
<!-- MyComponent 的模板 -->

<div> <!-- v-demo 指令会被应用在此处 -->
  <span>My component content</span>
</div>

需要注意的是组件可能含有多个根节点。当应用到一个多根组件时,指令将会被忽略且抛出一个警告。和 attribute 不同,指令不能通过 v-bind="$attrs" 来传递给一个不同的元素。

理解:

一、为什么需要自定义指令?

Vue 内置了很多指令,比如 v-ifv-showv-modelv-bind 等,它们帮你处理常见的 DOM 更新需求。

但有些时候,你需要 直接操作原生 DOM 元素,比如:

  • 让一个输入框自动获得焦点(focus()

  • 给元素添加一个特殊的 class 或样式

  • 用第三方库初始化一个图表容器

虽然你可以直接在组件里用 ref 拿到元素再操作,但如果这个逻辑要在多个地方复用,每次都写一堆代码很麻烦。

自定义指令 就是把这些 DOM 操作封装起来,变成一个可复用的 v-xxx 指令。

核心原则 :只有当功能 必须通过直接 DOM 操作 实现时,才用自定义指令。能用内置指令(如 v-bind)或组件实现的,优先用它们。

二、一个简单的自定义指令

假设你希望一个元素插入页面后自动获得焦点(比原生 autofocus 更强,因为 Vue 动态插入的元素也能生效)。

你可以定义一个 v-focus 指令:

html 复制代码
<script setup>
// 定义一个名为 vFocus 的变量(注意:v 开头,驼峰命名)
const vFocus = {
  mounted: (el) => el.focus()   // 元素挂载到 DOM 后,调用它的 focus 方法
}
</script>

<template>
  <input v-focus />   <!-- 使用 v-focus 指令 -->
</template>

解释

  • <script setup> 中,任何 v 开头 + 驼峰命名 的变量(如 vFocus)都会自动成为自定义指令。

  • 在模板中,你使用 短横线 形式:v-focus

  • mounted 是一个钩子,在元素被插入 DOM 后执行。钩子函数的第一个参数 el 就是指令绑定的那个 DOM 元素。

三、指令的钩子函数(生命周期)

一个自定义指令可以定义多个钩子,对应元素的不同阶段:

钩子 触发时机 常用场景
created 元素绑定指令时(还没插入 DOM) 极少用
beforeMount 元素即将插入 DOM 前 极少用
mounted 元素插入 DOM 后 最常用:获取焦点、添加 class、初始化第三方库
beforeUpdate 元素所在的组件更新前 更新前清理一些东西
updated 组件更新完成后 根据新值更新 DOM
beforeUnmount 元素卸载前 清理定时器、事件等
unmounted 元素卸载后 清理完成

最常用的是 mountedupdated,很多时候两者逻辑相同,可以用简化写法。

四、钩子函数的参数

每个钩子都会收到一些参数,最重要的有:

  • el:指令绑定的 DOM 元素,可以直接操作它。

  • binding:一个对象,包含很多有用的信息:

    • value:传给指令的值(如 v-my-directive="1+1" 中的 2

    • oldValue:上一次的值(仅在 beforeUpdate/updated 中有)

    • arg:参数(如 v-my-directive:foo 中的 'foo'

    • modifiers:修饰符对象(如 v-my-directive.foo.bar 得到 { foo: true, bar: true }

  • vnode:底层虚拟节点,一般用不到。

例子

html 复制代码
<div v-example:foo.bar="42"></div>

在指令钩子里:

复制代码
binding.value    // 42
binding.arg      // 'foo'
binding.modifiers // { bar: true }

动态参数也支持:

html 复制代码
<div v-example:[dynamicArg]="value"></div>

五、简化形式(函数)

如果你的指令在 mountedupdated 中需要做相同的事情,可以直接传一个函数,而不是一个对象:

javascript 复制代码
app.directive('color', (el, binding) => {
  el.style.color = binding.value
})

这个函数会在 mountedupdated 时都被调用。

<script setup> 中,你也可以用一个函数变量:

html 复制代码
<script setup>
const vColor = (el, binding) => {
  el.style.color = binding.value
}
</script>
<template>
  <span v-color="'red'">红色文字</span>
</template>

六、传递多个值(对象字面量)

如果指令需要多个值,你可以传递一个对象字面量:

html 复制代码
<div v-demo="{ color: 'white', text: 'hello' }"></div>

在指令中通过 binding.value.colorbinding.value.text 获取。

七、在组件上使用自定义指令(不推荐)

理论上你可以给组件标签加自定义指令,比如 <MyComponent v-focus />

它会自动应用到组件的根元素上(类似透传 attribute)。

但是,如果组件有多个根元素 ,Vue 不知道应该放在哪个根上,会忽略指令并报警告。

而且指令不能像 attribute 那样通过 v-bind="$attrs" 手动传递到指定元素。

所以官方不推荐在组件上使用自定义指令,最好只用在原生 HTML 元素上。

八、全局注册自定义指令

如果你想让一个指令在所有组件中都能用,可以在应用实例上全局注册:

javascript 复制代码
const app = createApp({})
app.directive('focus', {
  mounted: (el) => el.focus()
})

之后任何组件的模板里都可以写 v-focus,无需再局部定义。

九、总结(小白版)

自定义指令 = 你自己造的 v-xxx,用来直接操作 DOM

  • 什么时候用?当你需要复用纯 DOM 操作逻辑,且不能用内置指令或组件实现时。

  • 怎么定义?在 <script setup> 里定义一个 vXxx 变量(对象或函数),对象里可以写 mountedupdated 等钩子。

  • 钩子参数:el(元素)、binding(值、参数、修饰符等)。

  • 可以全局注册 app.directive()

  • 不推荐用在组件上(尤其多根节点组件)。

现在你可以试着写一个 v-blink 指令,让元素每隔一秒闪烁一次(用 setInterval 切换 opacity),并在 unmounted 时清除定时器。这样你就能完全掌握自定义指令了。

插件

介绍

插件 (Plugins) 是一种能为 Vue 添加全局功能的工具代码。下面是如何安装一个插件的示例:

javascript 复制代码
import { createApp } from 'vue'

const app = createApp({})

app.use(myPlugin, {
  /* 可选的选项 */
})

一个插件可以是一个拥有 install() 方法的对象,也可以直接是一个安装函数本身。安装函数会接收到安装它的应用实例和传递给 app.use() 的额外选项作为参数:

javascript 复制代码
const myPlugin = {
  install(app, options) {
    // 配置此应用
  }
}

插件没有严格定义的使用范围,但是插件发挥作用的常见场景主要包括以下几种:

  1. 通过 app.component()app.directive() 注册一到多个全局组件或自定义指令。

  2. 通过 app.provide() 使一个资源可被注入进整个应用。

  3. app.config.globalProperties 中添加一些全局实例属性或方法

  4. 一个可能上述三种都包含了的功能库 (例如 vue-router)。

编写一个插件

为了更好地理解如何构建 Vue.js 插件,我们可以试着写一个简单的 i18n (国际化 (Internationalization) 的缩写) 插件。

让我们从设置插件对象开始。建议在一个单独的文件中创建并导出它,以保证更好地管理逻辑,如下所示:

plugins/i18n.js

javascript 复制代码
export default {
  install: (app, options) => {
    // 在这里编写插件代码
  }
}

我们希望有一个翻译函数,这个函数接收一个以 . 作为分隔符的 key 字符串,用来在用户提供的翻译字典中查找对应语言的文本。期望的使用方式如下:

html 复制代码
<h1>{{ $translate('greetings.hello') }}</h1>

这个函数应当能够在任意模板中被全局调用。这一点可以通过在插件中将它添加到 app.config.globalProperties 上来实现:

plugins/i18n.js

javascript 复制代码
export default {
  install: (app, options) => {
    // 注入一个全局可用的 $translate() 方法
    app.config.globalProperties.$translate = (key) => {
      // 获取 `options` 对象的深层属性
      // 使用 `key` 作为索引
      return key.split('.').reduce((o, i) => {
        if (o) return o[i]
      }, options)
    }
  }
}

我们的 $translate 函数会接收一个例如 greetings.hello 的字符串,在用户提供的翻译字典中查找,并返回翻译得到的值。

用于查找的翻译字典对象则应当在插件被安装时作为 app.use() 的额外参数被传入:

javascript 复制代码
import i18nPlugin from './plugins/i18n'

app.use(i18nPlugin, {
  greetings: {
    hello: 'Bonjour!'
  }
})

这样,我们一开始的表达式 $translate('greetings.hello') 就会在运行时被替换为 Bonjour! 了。

TypeScript 用户请参考:扩展全局属性

TIP

请谨慎使用全局属性,如果在整个应用中使用不同插件注入的太多全局属性,很容易让应用变得难以理解和维护。

插件中的 Provide / Inject

在插件中,我们可以通过 provide 来为插件用户提供访问某个函数或属性的能力。举例来说,我们可以将插件接收到的 options 参数提供给整个应用,让任何组件都能使用这个翻译字典对象。

plugins/i18n.js

javascript 复制代码
export default {
  install: (app, options) => {
    app.provide('i18n', options)
  }
}

现在,插件用户就可以在他们的组件中以 i18n 为 key 注入并访问插件的选项对象了。

javascript 复制代码
<script setup>
import { inject } from 'vue'

const i18n = inject('i18n')

console.log(i18n.greetings.hello)
</script>

为 NPM 打包

如果你想进一步打包并发布插件给他人使用,请参阅 Vite 库模式

理解:

一、插件是什么?为什么需要插件?

你已经知道,组件可以复用 UI 和逻辑,组合式函数可以复用有状态的逻辑,自定义指令可以复用 DOM 操作。

但这些都局限在 单个组件个别模块 中。

有时你需要为 整个应用 添加一些能力,比如:

  • 在所有组件中都能使用某个翻译函数(国际化)

  • 给 Vue 添加一个全局的 Loading 加载指示器

  • 注册一些通用的全局组件(比如 <Modal /><Button />

这种 作用域是整个应用 的功能扩展,就是 插件 要做的事。

形象类比

  • 组件就像一个个家具(沙发、桌子)。

  • 组合式函数就像工具箱里的扳手、螺丝刀。

  • 插件就像给整个房子加装 中央空调智能照明系统 ------ 一装好,整个房子都受益,不需要每个房间单独装。

二、如何使用插件?

使用一个插件,需要先得到应用实例 app,然后调用 app.use(插件, 可选参数)

javascript 复制代码
import { createApp } from 'vue'
import MyPlugin from './my-plugin'

const app = createApp({})
app.use(MyPlugin, { /* 一些配置选项 */ })
app.mount('#app')

app.use 会在应用上安装插件,之后插件提供的功能就可以在整个应用中使用了。

三、插件长什么样?

一个插件可以是:

  • 一个拥有 install 方法的对象

  • 或者直接是一个安装函数(等价于 install 方法)

当调用 app.use(插件) 时,Vue 会执行插件的 install 方法,并传入两个参数:

  1. app:当前 Vue 应用实例

  2. options:调用 app.use 时传入的第二个参数(可选的配置对象)

javascript 复制代码
// 插件定义:对象形式
const MyPlugin = {
  install(app, options) {
    // 在这里写插件的逻辑
    console.log('插件安装了', options)
  }
}

// 或者用函数形式(等价)
function MyPlugin(app, options) {
  console.log('插件安装了', options)
}

四、插件能做什么?(常见场景)

install 函数里,你可以利用 Vue 的全局 API 来做很多事情:

1. 注册全局组件或自定义指令

javascript 复制代码
install(app, options) {
  app.component('GlobalButton', GlobalButtonComponent)
  app.directive('focus', { mounted: (el) => el.focus() })
}

这样整个应用的任何组件模板里都可以直接使用 <GlobalButton />v-focus

2. 注入全局资源(provide)

javascript 复制代码
install(app, options) {
  app.provide('i18n', options)   // 提供翻译对象
}

然后任何后代组件都可以通过 inject('i18n') 拿到该资源。

3. 添加全局实例属性或方法(挂载到 app.config.globalProperties

javascript 复制代码
install(app, options) {
  app.config.globalProperties.$http = axios   // 所有组件里可以用 this.$http 或直接 $http
  app.config.globalProperties.$translate = (key) => { /* 翻译逻辑 */ }
}

这样在模板中就可以写 {``{ $translate('hello') }},或者在组件内部用 getCurrentInstance().proxy.$translate()(但通常更推荐用 provide/inject 或组合式函数)。

4. 混合上述多种

很多官方或第三方插件(如 vue-routerpinia)就是这样做的。

五、完整示例:写一个简单的 i18n 插件

假设你想让整个应用支持国际化(多语言)。

插件文件plugins/i18n.js

javascript 复制代码
export default {
  install(app, options) {
    // 1. 提供一个全局翻译方法
    app.config.globalProperties.$translate = (key) => {
      // key 类似 'greetings.hello'
      return key.split('.').reduce((obj, prop) => obj?.[prop], options)
    }

    // 2. 也可以 provide,让组件通过 inject 使用
    app.provide('i18n', options)
  }
}

在 main.js 中安装插件

javascript 复制代码
import { createApp } from 'vue'
import App from './App.vue'
import i18nPlugin from './plugins/i18n'

const app = createApp(App)

app.use(i18nPlugin, {
  greetings: {
    hello: '你好!',
    bye: '再见'
  },
  messages: {
    welcome: '欢迎'
  }
})

app.mount('#app')

在任意组件中使用

html 复制代码
<template>
  <!-- 方式1:使用全局属性 -->
  <p>{{ $translate('greetings.hello') }}</p>

  <!-- 方式2:通过 inject 使用 -->
  <p>{{ i18n.messages.welcome }}</p>
</template>

<script setup>
import { inject } from 'vue'
const i18n = inject('i18n')
console.log(i18n.greetings.bye)
</script>

这样,整个应用的组件都能共享同一个翻译字典,并且翻译函数可以在模板中随时调用。

六、注意事项

  • 全局属性要谨慎使用 :太多全局属性会让应用难以维护,优先使用 provide/inject 或组合式函数。

  • 插件安装时机 :必须在 app.mount() 之前 安装插件,否则挂载后再安装可能不会生效(取决于插件实现)。

  • 插件可以依赖其他插件 :比如你的插件可能需要用到 vue-router,你可以要求用户先安装 router。

  • 为 NPM 打包:如果你写了一个好插件想发布,可以用 Vite 库模式打包。

七、总结(小白版)

插件 = 给整个 Vue 应用装上"全局增强包"

  • 通过 app.use(插件) 安装。

  • 插件是一个对象或函数,它必须有一个 install 方法(如果是函数,函数本身当作 install)。

  • install 里你可以做:

    • 注册全局组件 / 指令

    • 提供全局资源(provide

    • 添加全局属性 / 方法

  • 最常见的插件例子:vue-router(路由)、pinia(状态管理)。

现在你可以尝试自己写一个 LoadingPlugin:它会在所有组件中注入一个 $showLoading()$hideLoading() 方法,用于显示和隐藏全屏加载动画。这样能帮你更好地理解插件的威力。

内置主键 应用规模化 最佳实践等见官网文档

Transition | Vue.js

相关推荐
恋猫de小郭1 天前
Redis 作者反驳「中国模型之所以强,是因为通过 API 蒸馏了美国模型」
前端·人工智能·ai编程
Darling噜啦啦1 天前
Canvas 游戏开发与数据可视化实战:从飞机大战到 ECharts 报表
前端·echarts·canvas
OpenTiny社区1 天前
这次更新太良心!GenUI SDK v1.2.0 轻量化 + 稳流式 + 超强 Playground
前端·vue.js·ai编程
梨子同志1 天前
WebGL test
前端
m0_547486661 天前
《HTML+CSS+JavaScript+Vue前端开发技术教程》全套PPT课件
javascript·css·html
程序员黑豆1 天前
AI全栈开发系列开篇:从Java全栈到AI应用实战
前端·ai编程·全栈
yangyj1 天前
从 PDR 到落地:用 Codex 完成一次 Rspack 升级
前端
程序员鱼皮1 天前
提示词工程已死,Loop Engineering 称王!保姆级教程 + 项目实战
前端·后端·ai编程
FliPPeDround1 天前
告别离线 Agent:deepseek-kit 内置 Web Search,零配置联网搜索
javascript·agent·deepseek
小爷毛毛_卓寿杰1 天前
给 Embedding 模型也加一块“游乐场“—— Xinference 是怎么把 vector 变成肉眼可见的体验的
前端