Vue3核心与响应式

Vue 3 核心与响应式

Vue3 快速上手那篇解决的是”能跑起来”, 这篇开始进深水区: 把响应式这套东西真正吃透。响应式是 Vue 的地基, ref、computed、watch、生命周期全在这条线上, 后面组件、路由、状态管理的一切”为什么”, 根都在这里。

先说明一件事: 从这篇起, 示例代码会带上 lang="ts", 表示 script 块用 TypeScript 写 (给 JS 加静态类型检查的方言, 工具链篇提过)。
脚手架默认就生成 TS 项目, 我干脆跟着用。看到 : string<{ ... }> 这类标注, 暂时看不懂就当注释忽略, 不影响理解主线。

1. 先建立最小心智模型

我理解的 Vue 组件, 拆开就四样东西:

  • 状态: 页面上会变的数据, 比如输入框内容、任务列表、loading 开关。
  • 派生数据: 能从状态算出来的值, 比如”未完成任务数”, 它自己没有独立存在的必要。
  • 副作用: 状态变化后要顺带干的事, 比如发请求、写 localStorage。
  • 模板: 声明”状态长什么样就渲染成什么样”, 我不再亲手改 DOM。

对应到 API: 状态用 ref/reactive, 派生数据用 computed, 副作用用 watch, 模板就是 <template>
写惯了 JS 篇里那种 document.querySelector 加事件回调手动改页面的代码, 转到这个模型最大的不适应是”我不碰 DOM 了”, 我只改数据, 页面自己变。适应之后再也回不去。

2. 创建项目与目录

创建命令在快速上手篇逐词拆过, 这里只留个存档: npm create vue@latest, 勾选时我一般选上 TypeScript、Router、Pinia、ESLint、Prettier。装完的完整目录长这样:

Pasted image 20260720125120

粗分一下: src/ 是日常写代码的地方 (main.ts 入口、App.vue 根组件、components/ 组件); index.html 是浏览器入口; public/ 放不经构建的静态文件; 剩下 package.jsonvite.config.tstsconfig 一堆是工程配置。
每个文件具体干嘛、启动链路怎么串起来的, 前端文件架构整篇都在讲, 这里不重复。

唯一值得在这里背下来的是入口 main.ts, 因为它是理解一切的起点:

代码块TS · 5 行收起展开
import { createApp } from 'vue'   // createApp 是 vue 包导出的工厂函数
import App from './App.vue'       // 根组件, Vite 的 vue 插件负责编译 .vue 文件
import './assets/main.css'        // 全局样式, 在这里 import 就对整个应用生效

createApp(App).mount('#app')      // 创建应用实例, 挂到 index.html 的 <div id="app"> 上

对我来说它就是 Spring Boot 的启动类: 组装好一切, 然后一行启动。以后装了 Router 和 Pinia, 也是在 mount 之前 app.use() 进来。

3. ref: 为什么非要 .value

代码块VUE · 14 行收起展开
<script setup lang="ts">
import { ref } from 'vue'

const keyword = ref('')

function clear() {
  keyword.value = ''      // JS 里读写都要 .value
}
</script>

<template>
  <input v-model="keyword" />
  <p>{{ keyword }}</p>     <!-- 模板里不用 .value, Vue 自动拆 -->
</template>

刚学时我觉得 .value 很多余, 直到想通它的原理: JS 没办法监听”一个普通变量被重新赋值”这件事, 但能监听”对象的属性被读写”。
所以 ref 把值装进一个盒子对象, 你所有的读写都走 .value 这个属性, Vue 在属性的 get/set 里埋了钩子: 谁读了我, 我记下来 (依赖收集); 我被改了, 就通知刚才记下的那些地方重新执行 (触发更新)。
和 Java 里 AtomicInteger 必须 get()/set() 是一个道理, 包一层才有做手脚的地方。

const 声明也是同理: 变量本身永远指向同一个盒子, 变的是盒子里的东西, 所以响应式变量几乎全用 const

4. reactive 与它的解构陷阱

对象形态的状态还有另一个选择 reactive, 它直接代理整个对象, 访问属性不需要 .value:

代码块TS · 4 行收起展开
import { reactive } from 'vue'

const form = reactive({ title: '', priority: 1 })
form.title = '写周报'    // 直接改属性, 页面会更新

但它有个著名的坑, 解构会丢响应式:

代码块TS · 1 行收起展开
const { title } = form   // title 现在只是个普通字符串, 和 form 断开了

解构那一刻拿到的是当时的值的拷贝, Vue 的钩子埋在 form 这个代理对象的属性访问上, 你绕开它当然就没反应了。要把属性单独传出去, 用 toRefs(form) 把每个属性包成 ref。

我的用法习惯: 基本类型和会被整体替换的数据用 ref, 一组固定字段的表单对象可以用 reactive; 拿不准就全用 ref, 统一比纠结重要。

5. v-model: 表单双向绑定

v-model 把”输入框的值显示状态”和”用户输入改状态”两个方向绑在一起:

代码块VUE · 18 行收起展开
<script setup lang="ts">
import { ref } from 'vue'

const userName = ref('')
const age = ref(21)
const agree = ref(false)
const gender = ref('male')
</script>

<template>
  <input v-model.trim="userName" />          <!-- .trim 自动去首尾空格 -->
  <input v-model.number="age" type="number" /> <!-- .number 把输入转成数字 -->
  <input v-model="agree" type="checkbox" />  <!-- 复选框绑布尔 -->
  <select v-model="gender">                  <!-- 下拉绑选中值 -->
    <option value="male">男</option>
    <option value="female">女</option>
  </select>
</template>

它本质是语法糖, 等价于”绑值 + 监听输入事件”两行:

代码块VUE · 1 行收起展开
<input :value="userName" @input="userName = $event.target.value" />

在 JSP 时代想做表单回显加提交, 这两个方向都得自己写, Vue 一个指令包圆。自定义组件也能支持 v-model (父子组件双向同步), 那是组件通信的内容, 放在 02 组件设计与数据流里讲。

6. computed: 会缓存的派生数据

代码块TS · 10 行收起展开
import { computed, ref } from 'vue'

const tasks = ref([
  { id: 1, title: '修复登录', done: false },
  { id: 2, title: '补测试', done: true }
])

const unfinished = computed(() =>
  tasks.value.filter(t => !t.done)
)

computed 和普通函数的区别在缓存: 它记住上次的计算结果, 只有依赖的状态 (这里是 tasks) 变了才重算; 模板里读十次也只算一遍。而写成方法的话每次渲染都要重新执行。

判断该用 computed 还是 watch, 我的口诀是: 描述”是什么”用 computed (未完成任务是过滤后的列表), 描述”要干什么”用 watch (关键词变了要发请求)。别在 computed 的函数里发请求或改别的状态, 它应该是纯计算。

7. watchwatchEffect

watch 盯住一个来源, 变化时执行副作用, 还能拿到新旧值:

代码块TS · 5 行收起展开
watch(keyword, async (newValue, oldValue, onCleanup) => {
  const controller = new AbortController()
  onCleanup(() => controller.abort())   // 下次触发前先取消上一次的请求
  results.value = await search(newValue, controller.signal)
}, { immediate: true })                  // immediate: 一上来先执行一次

有个我踩过的坑, 监听 reactive 对象的属性时来源要写成函数:

代码块TS · 2 行收起展开
watch(state.count, cb)          // 错: 传进去的是当下的数值, 一个死值
watch(() => state.count, cb)    // 对: getter 函数才能被追踪

watchEffect 是它的省事版本: 不指定来源, 立即执行一次, 自动把执行过程中读到的响应式数据全部收集为依赖。依赖多而逻辑直白时很舒服, 但依赖是隐式的, 复杂场景我还是用 watch 图个明确。另外 deep: true 深度监听大对象要慎用, 每次变化都全树遍历, 大列表上会很贵。

8. 模板指令: 条件、列表和 key

代码块VUE · 10 行收起展开
<template>
  <p v-if="loading">加载中...</p>
  <p v-else-if="error">{{ error }}</p>
  <ul v-else>
    <li v-for="task in tasks" :key="task.id">
      <input v-model="task.done" type="checkbox" />
      <span :class="{ done: task.done }">{{ task.title }}</span>
    </li>
  </ul>
</template>

几条用出来的经验:

v-if 是真的创建/销毁节点, v-show 只是切换 display: none。切换频繁的用 v-show (省得反复重建), 很少切换的用 v-if (省得一直渲染着)。

:key 必须用稳定唯一的业务 ID, 别用数组下标。Vue 靠 key 认”哪个节点还是原来那个”, 用下标的话删掉第一项, 后面所有项的下标都变了, Vue 会认错人, 典型症状是删了一行结果输入框内容串到别的行上。这个坑我在动手验收里专门造过一次, 印象深刻。

还有别在同一个元素上同时挂 v-ifv-for, 要过滤列表就先用 computed 滤好再 v-for。

9. 生命周期与清理

代码块TS · 14 行收起展开
import { onBeforeUnmount, onMounted, ref } from 'vue'

const chartEl = ref<HTMLElement | null>(null)  // 模板引用: 拿到真实 DOM 节点
let timer: number

onMounted(() => {
  // 组件挂载完成, DOM 已存在, 适合初始化图表这类第三方库
  timer = window.setInterval(refresh, 5000)
})

onBeforeUnmount(() => {
  // 组件销毁前, 必须把挂载时占的资源还回去
  window.clearInterval(timer)
})

生命周期钩子就是 Vue 在组件”出生到销毁”各节点留的回调口, 概念上和 Spring Bean 的 @PostConstruct/@PreDestroy 一回事。
日常 90% 的场景只用两个: onMounted 做初始化, onBeforeUnmount 做清理。
定时器、事件监听、订阅这些东西挂了不清, 组件销毁后还在后台跑, 就是内存泄漏 (调试篇讲过怎么抓)。

10. nextTick: 改完数据 DOM 还没变

Vue 更新 DOM 是攒一批一起干的 (不然连改十个状态就要重渲染十次)。所以刚改完状态的下一行, DOM 还是旧的:

代码块TS · 5 行收起展开
async function startEdit() {
  editing.value = true          // 让输入框显示出来
  await nextTick()              // 等 Vue 把这一轮 DOM 更新真正做完
  inputRef.value?.focus()       // 现在输入框才真的在页面上, 能聚焦了
}

nextTick 等的是 DOM 更新, 和网络请求没关系。如果发现自己到处 nextTick 才能让逻辑正常, 多半是数据流本身设计歪了, 该修数据流。

11. 响应式原理: Proxy 在背后干了什么

把前面的碎片串起来: Vue 3 用 JS 的 Proxy 把你的状态对象包了一层, 拦截所有读和写。

sequenceDiagram
    participant C as 组件
    participant P as Proxy
    participant E as 副作用表
    C->>P: 读 state
    P->>E: 记录依赖
    C->>P: 写 state
    P->>E: 触发依赖
    E->>C: 重新渲染

渲染模板时读到了哪些状态, Proxy 就把”这个组件依赖这些状态”记在小本本上; 之后谁写了这些状态, Proxy 就翻小本本通知对应组件重渲染。reactive(raw) !== raw 的原因也在这: 你拿到的是代理, 原对象还是那个普通对象。

Vue 2 用的是 Object.defineProperty, 只能劫持已存在的属性, 所以有新增属性不响应、数组下标赋值不响应等历史坑, 得靠 Vue.set 补丁。Proxy 拦的是整个对象的操作, 这些坑天然不存在。被问到 Vue 2/3 区别时, 这是最硬的一条。

12. 实战: 带竞态处理的搜索

把本篇的东西合起来写个真实场景: 输入关键词搜索, 要处理旧请求覆盖新结果的竞态问题 (先发的请求后返回, 把后发请求的正确结果盖掉):

代码块TS · 23 行收起展开
watch(keyword, async (value, _, onCleanup) => {
  const query = value.trim()
  if (!query) {
    users.value = []
    return
  }

  const controller = new AbortController()      // JS 篇讲过的请求取消器
  onCleanup(() => controller.abort())           // 关键: 新一轮触发前, 干掉上一轮的请求
  loading.value = true
  error.value = ''
  try {
    const response = await fetch(`/api/users?q=${encodeURIComponent(query)}`, {
      signal: controller.signal
    })
    if (!response.ok) throw new Error(`HTTP ${response.status}`)
    users.value = await response.json()
  } catch (e) {
    if ((e as Error).name !== 'AbortError') error.value = '搜索失败, 请重试'
  } finally {
    if (!controller.signal.aborted) loading.value = false
  }
})

onCleanupAbortController 是这段的灵魂: watch 每次重新触发前先执行上一轮注册的清理函数, 正好用来取消上一轮的请求。空关键词、HTTP 错误码、loading 状态也都处理了, 这段可以当模板背。

13. 项目文件速查

原来这篇后半堆了十几节 create-vue 每个文件的详解, 和前端文件架构重复了, 我把它们合并过去了, 这里只留一张排错速查表:

现象先看哪里
整页白屏Console 报错; 是不是双击打开而没走 npm run dev; #appmount 是否对得上
.vue 文件解析报错vite.config.ts 里 vue 插件在不在
@/xxx 找不到模块Vite alias 和 tsconfig paths 是否两边都配了
npm run build 挂但 dev 正常类型检查没过, 读 type-check 输出
刷新二级路由 404服务器没配 history 回退, 03 路由篇

14. 动手验收

给自己留的检查单, 全能做到才算过关:

  • 写一个任务列表, 支持新增、删除、筛选、localStorage 持久化。
  • 把筛选逻辑从 watch 改成 computed, 说出为什么后者合适。
  • 用数组下标当 key 渲染可编辑列表, 删除第一项, 亲眼看一次串位。
  • 制造两个响应顺序颠倒的搜索请求, 验证旧响应盖不掉新结果。
  • 口述一遍 count.value++ 之后到页面文字变化, 中间发生了什么。

延伸阅读