# Vue 3 响应式系统

## 概述

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

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

### 创建项目

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

```shell
npm create vue@latest
```

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

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

### 项目组成

项目创建时会生成 `index.html`，这便是网页的入口，在 `index.html` 中会创建一个 `id` 为 `app` 的 `div`，并通过 `/src/main.ts` 向 `div` 中添加内容。

```html
<!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` 文件，并将加载的内容挂载到 `id` 为 `app` 的 `div` 中：

```typescript
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` 样式来书写：

```vue
<template></template>

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

<style scoped></style>
```

### 生命周期

Vue 3 的组件从创建到渲染到移除有如下的生命周期，经历 `setup`、`create`、`mount`、`update` 和 `unmount` 几个步骤：

![](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` 在数据变化时触发，重新渲染页面。在 `setup` 和 `create` 步骤中初始化的 API，对数据进行包装并加入钩子函数，实现数据变化的监听。
- `unmount` 用于在取消挂载后移除页面的渲染。

## 响应式系统

### `setup` 函数

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

```vue
<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` 变量的值。

通过语法糖，上面的代码可以简写为：

```vue
<template>  
  {{ count }}  
</template>  
  
<script setup lang="ts">  
import { ref } from 'vue'  
  
const count = ref(0)  
</script>
```

### `ref` 函数

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

![](https://img.papergate.top:5000/i/2026/01/695f095d8f16b.webp)

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

```vue
<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`，不会自动解包。

### `reactive` 函数

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

```vue
<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` 函数也可以用于对象类型数据，这种情况下 `RefImpl` 的 `value` 就是 `Proxy` 对象，使用 `ref` 函数创建对象类型数据的包装类时，在 `script` 中获取 `RefImpl` 的值仍然是需要 `.value` 的。

![](https://img.papergate.top:5000/i/2026/01/695f117a25614.webp)

### `toRefs` 与 `toRef` 函数

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

```typescript
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` 函数就是用来解决这一问题的：

```typescript
const {name, ability} = toRefs(person)  
console.log(name)  
console.log(ability)
```

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

![](https://img.papergate.top:5000/i/2026/01/695f179dce3e6.webp)

如果 `person` 是 `RefImpl` 类型，需要这样写：

```typescript
const { name, ability } = toRefs(person.value)  
console.log(name)  
console.log(ability)
```

返回值同样是 `ObjectRefImpl` 对象。

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

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

```typescript
const name = toRef(person.value, 'name')  
console.log(name)
```

### 计算属性

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

```vue
<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>
```

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

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

```typescript
const fullName = computed((previous) => {})
```

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

```typescript
const fullName = computed({  
  get() {  
    return `${firstName.value} ${lastName.value}`  
  },  
  set(newValue: string) {  
    const [first = '', last = ''] = newValue.split(' ')  
    firstName.value = first  
    lastName.value = last  
  },  
})
```

### 属性绑定

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

#### ID 绑定

使用 `v-bind:id` 来进行 `id` 的绑定，这样 `div` 的 `id` 就设置成了 `container`：

```vue
<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` 提供了简写语法：

```vue
<template>  
  <div :id="id"></div>  
</template>
```

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

```vue
<template>  
  <div :id></div>  
</template>
```

#### class 绑定

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

```vue
<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 绑定

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

```vue
<template>  
  <div :style="style"></div>  
</template>  
  
<script setup lang="ts">  
import { ref } from 'vue'  
const style = ref([  
  {  
    color: 'red',  
    fontSize: '30px',  
  },  
])  
</script>
```

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

#### 绑定多个属性

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

```vue
<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>
```

### 条件渲染

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

```vue
<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-if`、`v-else-if`、`v-else` 在条件为 ` false ` 的时候不会渲染，只有当条件为 ` true ` 时才会渲染，而 ` v-show ` 无论条件是否为 ` true ` 都会渲染，但是条件为 ` false ` 时，` display: none `。

### 列表渲染

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

```vue
<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>
```

### 事件处理

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

```vue
<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` 指令可以简写为 `@`：

```vue
<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)

```vue
<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` 、`.right` 和 `middle`
- 键盘按键修饰符比较多，比如有修饰键 `.ctrl`、`.alt`、`.shift` 和 `.meta`，以及一些没有实体字符的按键 `.esc` 、`.tab` 、`.space`、`delete` (捕获 `Delete` 和 `Backspace` 两个按键)、`.up`、`.down`、`.left`、`.right`

比如可以有如下用法：

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

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

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

### 表单绑定

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

```vue
<input
  :value="text"
  @input="event => text = event.target.value">
```

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

```vue
<input v-model="text">
```

比如说，单行文本框可以如下：

```vue
<p>Message is: {{ message }}</p>
<input v-model="message" placeholder="edit me" />
```

> [!info]  信息
> 其他类型的表单基本相似，在通常的 HTML 表单基础上增加 `v-model` 属性，即可实现数据的双向绑定。

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

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

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

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

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

### 侦听器

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

```vue
<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` 函数创建），那么其 `value` 为 `Proxy` ，当数据变化时，`Proxy` 不会变化（除非整个替换 `person.value = { name: '李四', age: 20 }`），此时就需要使用深层侦听器，方法是在可选参数中增加 `{ deep: true }`：

```typescript
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 }`。

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

```typescript
watch(  
  () => person.value.name,  
  (value) => {  
    console.log('name变化了', value)  
  },  
)
```

原理是传入一个 `getter` 函数，这样做有两个原因：

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

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

```typescript
watchEffect(() => {  
  person.value.name = person.value.age + '岁的' + person.value.name  
})
```



---

> 作者: Aphros  
> URL: https://blog.papergate.top/posts/vue-3-%E5%93%8D%E5%BA%94%E5%BC%8F%E7%B3%BB%E7%BB%9F/  

