Vue 3 响应式系统

第 1 节 概述

Vue 是一个渐进式的前端框架,所谓渐进式就是“可以被逐步集成”的意思,按照需求可以构建简单的静态页面,也可以构建复杂的终端应用:

  • 无需构建步骤,渐进式增强静态的 HTML
  • 在任何页面中作为 Web Components 嵌入
  • 单页应用 (SPA)
  • 全栈 / 服务端渲染 (SSR)
  • Jamstack / 静态站点生成 (SSG)
  • 开发桌面端、移动端、WebGL,甚至是命令行终端中的界面

1.1 创建项目

使用以下命令创建一个 Vue 项目

1
npm create vue@latest

根据脚手架可以一步步生成项目:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 请输入项目名称:
project-name
# 请选择要包含的功能:
TypeScript
JSX 支持
Router (单页面应用开发)
Pinia (状态管理)
Vitest (单元测试)
端到端测试
ESLint (错误预防)
Prettier (代码格式化)

1.2 项目组成

项目创建时会生成 index.html,这便是网页的入口,在 index.html 中会创建一个 idappdiv,并通过 /src/main.tsdiv 中添加内容。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<!DOCTYPE html>  
<html lang="">  
<head>  
    <meta charset="UTF-8">  
    <link rel="icon" href="/favicon.ico">  
    <meta name="viewport" content="width=device-width, initial-scale=1.0">  
    <title>Vite App</title>  
</head>  
<body>  
<div id="app"></div>  
<script type="module" src="/src/main.ts"></script>  
</body>  
</html>

main. ts 中会创建如下内容,从 vue.js 中加载 createApp 函数,调用该函数,传入 ./App.vue 文件,并将加载的内容挂载到 idappdiv 中:

1
2
3
4
5
import { createApp } from 'vue'  
import App from './App.vue'  

const app = createApp(App)  
app.mount('#app')

.vue 文件称为组件,在 Vue 3 项目中有大量这样的组件,Vue 3 使用组合式 API,单独定义每个组件中的模板、事件和样式,并将它们组合起来,形成完整的项目。

在单个 .vue 文件中,一般需要定义有如下三个 html 块,在 template 块中定义该组件的模板,使用 html 语言来书写,在 script 块中定义该组件的事件,可以使用 js 或者 ts 来书写,在 style 块中定义该组件的样式,使用 css 样式来书写:

1
2
3
4
5
<template></template>

<script setup lang="ts"></script>

<style scoped></style>

1.3 生命周期

Vue 3 的组件从创建到渲染到移除有如下的生命周期,经历 setupcreatemountupdateunmount 几个步骤:

https://img.papergate.top:5000/i/2026/01/695eebdd8bc7e.webp

  • setup 是最开始的步骤,对应 <script setup lang="ts"></script> 中的 setup,在这里会初始化组合式 API。
  • create 对应 const app = createApp(App),在这里会初始化选项式 API,选项式 API 多在 Vue 2 项目中使用,通过兼容选项式 API,Vue 3 项目实现了对 Vue 2 项目的支持。
  • mount 对应 app.mount('#app'),实现了页面的渲染,也就是创建和插入 DOM 节点的过程。
  • update 在数据变化时触发,重新渲染页面。在 setupcreate 步骤中初始化的 API,对数据进行包装并加入钩子函数,实现数据变化的监听。
  • unmount 用于在取消挂载后移除页面的渲染。

第 2 节 响应式系统

2.1 setup 函数

在 Vue 3 中,最重要的就是其相应式系统,它实现了数据的双向绑定,在上面的 .vue 文件中,定义了 <script setup lang="ts"></script>,这里的写法是一种“语法糖”,完整的写法如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<template>
  {{ count }}  
</template>
<script lang="ts">
export default {
  setup() {
    const count = 0
    return { count }
  },
}
</script>

setup 函数通过 export 导出,便可以通过 import App from './App.vue' 获取,Vue 3 框架调用方法便可以获取返回值 count,并在渲染模板时将 {{ count }} 替换为 count 变量的值。

通过语法糖,上面的代码可以简写为:

1
2
3
4
5
6
7
8
9
<template>  
  {{ count }}  
</template>  
  
<script setup lang="ts">  
import { ref } from 'vue'  
  
const count = ref(0)  
</script>

2.2 ref 函数

上面的代码中用到了 ref 函数,ref 函数对其包裹的变量进行包装,函数会创建一个名为 RefImpl 的对象,对象的 value 就是包裹的变量。

https://img.papergate.top:5000/i/2026/01/695f095d8f16b.webp

Vue 3 通过 RefImplgettersetter 函数,实现数据的双向绑定,监听到数据变化时,重新渲染页面的 DOM 节点;DOM 节点变化时,调用 setter 函数修改 RefImpl 的值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<template>  
  {{ count }}  
</template>  
  
<script setup lang="ts">  
import { ref } from 'vue'  
  
const count = ref(0)  
console.log(count.value)  
</script>

templtae 中使用 RefImpl 对象时,不需要输入 .value,Vue 3 会对 RefImpl 对象自动解包,而在 script 中,获取 RefImpl 的值需要 .value,不会自动解包。

2.3 reactive 函数

对对象类型数据进行双向绑定时,除了可以使用 ref 函数外还可以使用 reactive 函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<template>  
  <div>姓名:{{ person.name }}</div>  
  <div>智力:{{ person.ability.intelligence }}</div>  
  <div>武器:{{ person.equipments[0] }}</div>  
</template>  
  
<script setup lang="ts">  
import { reactive } from 'vue'  
  
const person = reactive({  
  name: '张三',  
  ability: {  
    strength: 80,  
    intelligence: 90,  
  },  
  equipments: ['拳套', '轻甲', '帽子'],  
})  
console.log(person.name)  
console.log(person.ability.strength)  
console.log(person.equipments[0])  
</script>

reactive 函数会创建 Proxy 对象,通过 Proxy 对象实现数据的双向绑定。在 script 中获取 Proxy 的值不需要 .value

https://img.papergate.top:5000/i/2026/01/695f0ff53f30d.webp

ref 函数也可以用于对象类型数据,这种情况下 RefImplvalue 就是 Proxy 对象,使用 ref 函数创建对象类型数据的包装类时,在 script 中获取 RefImpl 的值仍然是需要 .value 的。

https://img.papergate.top:5000/i/2026/01/695f117a25614.webp

2.4 toRefstoRef 函数

有时候我们需要对 RefImpl 或者 Proxy 进行解包,取出其中的每一个元素,并进行修改,如果我们这样写:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
const person = reactive({  
  name: '张三',  
  ability: {  
    strength: 80,  
    intelligence: 90,  
  },  
  equipments: ['拳套', '轻甲', '帽子'],  
})

let { name, ability } = person
console.log(name)  
console.log(ability)  

name = '李四'  
ability.intelligence = 70
console.log(person.name)  
console.log(person.ability.intelligence)

就会发现基本类型数据解包后失去了与原 Proxy 的关联,而对象类型数据还保留了关联。

https://img.papergate.top:5000/i/2026/01/695f15612f5e0.webp

toRefs 函数就是用来解决这一问题的:

1
2
3
const {name, ability} = toRefs(person)  
console.log(name)  
console.log(ability)

可以看到,不管是基本类型数据还是对象类型数据,都被转成了 ObjectRefImpl 对象,和原 Proxy 保留了关联。

https://img.papergate.top:5000/i/2026/01/695f179dce3e6.webp

如果 personRefImpl 类型,需要这样写:

1
2
3
const { name, ability } = toRefs(person.value)  
console.log(name)  
console.log(ability)

返回值同样是 ObjectRefImpl 对象。

信息

这里需要注意的是,如果使用 ref 函数包裹深层对象,那么使用 toRefs 进行解包后每一层的对象依然是 RefImpl 类型,在语法上具有一致性,对象不会中途变成 Proxy 类型。那么如果总是使用 ref 函数,那么 Proxy 类型就不会出现。所以不使用 reactive 函数,而是全部用 ref 来替代,就是一种常见的偷懒手段。

toRef 函数使用方式类似,可以解包某一特定的元素:

1
2
const name = toRef(person.value, 'name')  
console.log(name)

2.5 计算属性

如果一个属性不是从页面或者后端获取到,而是通过其他属性的值计算出来的,就可以使用计算属性来定义一个全新的属性:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<template>  
  {{ fullName }}<br />  
  {{ fullName }}<br />  
  {{ fullName }}<br />  
</template>  
  
<script setup lang="ts">  
import { ref, computed } from 'vue'

const firstName = ref('zhang')  
const lastName = ref('san')  
  
const fullName = computed(() => {  
  console.log(1)  
  return `${firstName.value} ${lastName.value}`  
})
</script>

computedgettersetter 方法,上面代码中直接用箭头函数定义计算方法,属于是重写了 getter 方法,computed 函数相比于普通函数的优势在于,其能监听它自身 getter 方法内部其他的 getter 方法的调用(比如 firstName. value),当 firstNamelastName 没有变化时,即便调用多次 {{ fullName }}computedgetter 方法只会返回上一次的结果,而不会重新计算。也就是,上面的 console.log(1) 只会输出一次。

getter 方法中可以获取上一次的值,方法是在箭头函数中增加对应的参数名:

1
const fullName = computed((previous) => {})

computed 还可以设置 setter 方法,当 setter 方法调用时,可以修改其他属性的值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const fullName = computed({  
  get() {  
    return `${firstName.value} ${lastName.value}`  
  },  
  set(newValue: string) {  
    const [first = '', last = ''] = newValue.split(' ')  
    firstName.value = first  
    lastName.value = last  
  },  
})

2.6 属性绑定

template 中 DOM 节点的属性可以绑定 script 中的变量

ID 绑定

使用 v-bind:id 来进行 id 的绑定,这样 divid 就设置成了 container

1
2
3
4
5
6
7
8
9
<template>  
  <div v-bind:id="id"></div>  
</template>  
  
<script setup lang="ts">  
import { ref } from 'vue'  
  
const id = ref('container')  
</script>

因为 v-bind 非常常用,Vue 3 对 v-bind 提供了简写语法:

1
2
3
<template>  
  <div :id="id"></div>  
</template>

这里非常特殊,属性名称和 script 中的变量名相同,Vue 3 提供了进一步的简化语法:

1
2
3
<template>  
  <div :id></div>  
</template>

class 绑定

:classclass 可以同时存在,Vue 3 会自动将它们合并,大多数的布局、外观的定义可以放到 class 中,少数状态可以放到 :class 中。语法上 :class 可以传入的数据非常灵活,可以是字符串、对象、数组,也可以是它们的组合:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<template>  
  <div :class="div_class" class="text-white bg-red-500"></div>  
</template>  
  
<script setup lang="ts">  
import { ref } from 'vue'  
const div_class = ref([  
  {  
    active: true,  
  },  
  'opacity-50',  
  ['px-2', 'py-2'],  
])  
</script>

style 绑定

:stylestyle 也可以同时存在,也会自动合并,语法上,:style 可以传入的数据同样非常灵活:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<template>  
  <div :style="style"></div>  
</template>  
  
<script setup lang="ts">  
import { ref } from 'vue'  
const style = ref([  
  {  
    color: 'red',  
    fontSize: '30px',  
  },  
])  
</script>
信息

其他的 HTML 标签的属性,都可以通过前面加 : 的方式转变为 Vue 3 中可绑定的属性,两者的区别在于,不加 : 的属性是 HTML 标签的属性,会当成普通字符串进行解析,而加了 : 的属性会作为变量或者表达式进行解析,Vue 3 会获取其值并渲染到 DOM 元素中。

绑定多个属性

使用 v-bind 可以一次绑定多个属性:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<template>  
  <div v-bind="attr"></div>  
</template>  
  
<script setup lang="ts">  
import { ref } from 'vue'  
  
const attr = ref({  
  id: 'container',  
  class: 'wrapper',  
  style: 'background-color:green',  
})  
</script>

2.7 条件渲染

v-ifv-else-ifv-else 指令用于条件渲染,只有当对应的表达式为真时,对应的 DOM 节点才会渲染,否则不会出现在页面中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<template>  
  <div v-if="flag1">1</div>  
  <div v-else-if="flag2">2</div>  
  <div v-else>3</div>  
  <div v-show="flag1">2</div>  
</template>  
  
<script setup lang="ts">  
import { ref } from 'vue'  
  
const flag1 = ref(false)  
const flag2 = ref(true)  
</script>

v-show 功能类似,区别在于 v-ifv-else-ifv-else 在条件为 false 的时候不会渲染,只有当条件为 true 时才会渲染,而 v-show 无论条件是否为 true 都会渲染,但是条件为 false 时,display: none

2.8 列表渲染

对于可遍历的对象,可以使用 v-for 方法获取其每一个元素:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<template>  
  <li v-for="(value, key) in items" :key="key">{{ key }}:{{ value }}</li>  
</template>  
  
<script setup lang="ts">  
import { ref } from 'vue'  
  
const items = ref({  
  姓名: '张三',  
  年龄: '18',  
  性别: '男',  
})  
</script>

2.9 事件处理

可以使用 v-on 指令来监听 DOM 事件,并在事件触发时执行对应的 JavaScript。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<template>  
  <div>当前点击次数: {{ count }}</div>  
  <button v-on:click="handleClick">点击</button>  
</template>  
  
<script setup lang="ts">  
import { ref } from 'vue'  
  
const count = ref(0)  
const handleClick = () => {  
  count.value++  
}  
</script>

v-on 指令可以简写为 @

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<template>  
  <div>当前点击次数: {{ count }}</div>  
  <button @click="handleClick">点击</button>  
</template>  
  
<script setup lang="ts">  
import { ref } from 'vue'  
  
const count = ref(0)  
const handleClick = () => {  
  count.value++  
}  
</script>

事件修饰符

一般 DOM 事件会经历 3 个阶段:

  • 捕获阶段:window / document -> ... -> target.parent
  • 目标阶段:event.target
  • 冒泡阶段:parent -> ... -> document / window

为了简化处理 DOM 事件的细节,Vue 3 提供了事件修饰符:

修饰符 作用 常见用法
.stop 阻止冒泡 内部按钮,不想触发父级点击
.prevent 阻止默认行为 阻止表单刷新,阻止 <a> 跳转
.self 只响应自身 避免子元素误触
.capture 捕获阶段 全局拦截
.once 只触发一次 一次性操作,防止重复提交
.passive 性能优化 不会调用 preventDefault(),不能和 .prevent 一起用

事件修饰符的使用方法如下:

https://img.papergate.top:5000/i/2026/01/6960942d29ea8.webp

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<template>
  <div @click="parentClick">
    <button @click.stop="childClick">点我</button>
  </div>
</template>
<script setup lang="ts">  
const parentClick = () => {
  console.log('parentClick')
}
const childClick = () => {
  console.log('childClick')
}
</script>

按键修饰符

按键修饰符包括鼠标按键和键盘按键:

  • 鼠标按键修饰符有 .left.rightmiddle
  • 键盘按键修饰符比较多,比如有修饰键 .ctrl.alt.shift.meta,以及一些没有实体字符的按键 .esc.tab.spacedelete (捕获 DeleteBackspace 两个按键)、.up.down.left.right

比如可以有如下用法:

1
2
3
4
<input @keyup.enter="submit" />
<textarea @keyup.ctrl.enter="send" />
<div @click.right.prevent="openMenu" />
<input @keydown.alt.a.exact="onAltA" />

如果需要精确控制触发事件的修饰键,可以使用 .exact

1
2
3
4
5
6
<!--当按下Ctrl时,即使同时按下Alt或Shift也会触发-->
<button @click.ctrl="onClick">A</button>
<!--仅当按下Ctrl且未按任何其他键时才会触发-->
<button @click.ctrl.exact="onCtrlClick">A</button>
<!--仅当没有按下任何系统按键时触发-->
<button @click.exact="onClick">A</button>

2.10 表单绑定

要实现表单的双向绑定,原本需要进行绑定值并设定监听事件:

1
2
3
<input
  :value="text"
  @input="event => text = event.target.value">

在 Vue 3 中可以使用 v-model 来简化这一步骤:

1
<input v-model="text">

比如说,单行文本框可以如下:

1
2
<p>Message is: {{ message }}</p>
<input v-model="message" placeholder="edit me" />
信息

其他类型的表单基本相似,在通常的 HTML 表单基础上增加 v-model 属性,即可实现数据的双向绑定。

表单有几个特殊的修饰符:

.number 可以将用户输入自动转换为数字

1
<input v-model.number="age" />

.trim 可以自动去除用户输入内容中两端的空格

1
<input v-model.trim="msg" />

2.11 侦听器

侦听器可以监测某一个 script 变量的变化情况,并且当变量值发生改变时,调用回调函数,在下面的例子中,watch 接收 3 个参数,第一个是需要侦听的变量或者变量的 getter 函数,第二个是回调函数,第三个是可选参数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<template>  
  <div>当前点击次数: {{ count }}</div>  
  <button v-on:click="handleClick">点击</button>  
</template>  
  
<script setup lang="ts">  
import { ref, watch } from 'vue'  
  
const count = ref(0)  
const handleClick = () => {  
  count.value++  
}  
watch(  
  count,  
  (newValue, oldValue) => {  
    console.log('count变化了', newValue, oldValue)  
  },  
  { immediate: true },  
)  
</script>

如果需要侦听的变量是 RefImpl 类型的对象类型数据(使用 ref 函数创建),那么其 valueProxy ,当数据变化时,Proxy 不会变化(除非整个替换 person.value = { name: '李四', age: 20 }),此时就需要使用深层侦听器,方法是在可选参数中增加 { deep: true }

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const person = ref({  
  name: '张三',  
  age: 18,  
})  
const handleClick = () => {  
  person.value.name = '李四'  
  person.value.age = 20  
}  
watch(  
  person,  
  (value) => {  
    console.log('person变化了', value)  
  },  
  { deep: true },  
)

对象类型数据为 Proxy 时(使用 reactive 函数创建),无需使用深层侦听器。

通常 watch 默认是懒执行的,仅当数据源变化时,才会执行回调。如果我们需要在创建侦听器时,立即执行一遍回调,需要在可选参数中增加 { immediate: true }

如果希望回调只在源变化时触发一次,可以在可选参数中增加 { once: true }

如果我们想要侦听对象类型数据中的某一个特定的值,可以使用如下方式:

1
2
3
4
5
6
watch(  
  () => person.value.name,  
  (value) => {  
    console.log('name变化了', value)  
  },  
)

原理是传入一个 getter 函数,这样做有两个原因:

  • 一是如果 name 是基本类型数据,watch 需要侦听其数值的变化(需要传入的参数有 getter 函数),上述方式可以构建出 getter 函数。
  • 二是,如果 name 是对象类型数据,虽然 RefImplProxy 类型本身有 getter 函数,但是可能被整个替换,导致 person.value.name 的地址指向新的 RefImpl 或者 Proxy 对象,如果直接传入 name,则可能依旧侦听原来的地址。

此外,Vue 3 提供了函数 watchEffect,可以简化 watch 的使用。watchEffect 的作用是立即运行一个函数,同时响应式地追踪其依赖,并在依赖更改时重新执行该函数。比如下面这个函数,watchEffect 可以自动追踪 nameage 的变化。

1
2
3
watchEffect(() => {  
  person.value.name = person.value.age + '岁的' + person.value.name  
})