要点
ref 响应式的即时收益:状态一变,界面自动跟着变,你从头到尾没有碰过 DOM。第一课只需要讲清三件事——状态在哪里、点击如何改变状态、界面为什么会更新。其余的(组件拆分、路由、状态库)都是这三件事长大之后的组织手段。
创建工程
环境按上一篇就位后,建一个只选 ESLint 的最小工程,然后把 src/App.vue 的内容整个替换成下面的代码——第一个应用不需要脚手架自带的演示页:
npm create vue@latest vue-todo # 交互项只保留 ESLint,其余全否
cd vue-todo && npm install && npm run dev # 打开 http://localhost:5173
核心概念:响应式链路三步
定义:ref 把一个值包成响应式引用(reactive reference),读写都要经过 .value,于是 Vue 有机会记录”谁在读它、谁改了它”。链路三步:
- 组件挂载时 setup 执行一次,声明 ref;
- 模板渲染时把用到的 ref 登记为依赖——这一步是自动的;
- 代码给 .value 赋值,Vue 只更新依赖它的那部分 DOM,组件函数不会重新执行。
最小示例,五行看懂整条链路:
<script setup>
import { ref } from 'vue'
const count = ref(0) // 包一层:普通数字才有可能被追踪
</script>
<template>
<button @click="count++">点了 {{ count }} 次</button>
<!-- 模板里不写 .value:顶层 ref 自动解包(unwrap) -->
</template>
点按钮:count++ 改的是 .value,按钮文字立刻变。没有 setState,没有 render,更新就发生了。
第一个应用:待办清单
同一个 App.vue,换成完整版。输入框(v-model 双向绑定)、列表(v-for)、点行切换完成(click 事件)、剩余计数(computed 派生):
<script setup>
import { ref, computed } from 'vue'
const todos = ref([
{ id: 0, text: '读官方互动教程', done: true },
{ id: 1, text: '跑通本篇待办应用', done: false },
])
const newText = ref('')
// 派生状态:todos 不变就不重算,依赖自动收集,不用手写依赖数组
const remaining = computed(() => todos.value.filter(t => !t.done).length)
function addTodo() {
const text = newText.value.trim()
if (!text) return // 空输入不加空行
todos.value.push({ id: Date.now(), text, done: false })
newText.value = '' // v-model 让输入框同步清空
}
function toggle(todo) {
todo.done = !todo.done // ref 是深层的:嵌套属性直接改就有效
}
</script>
<template>
<main>
<h1>待办(剩 {{ remaining }} 件)</h1>
<input v-model="newText" placeholder="记点什么" @keyup.enter="addTodo" />
<button @click="addTodo">添加</button>
<ul>
<!-- :key 对齐节点与数据,增删行不串位 -->
<li v-for="todo in todos" :key="todo.id"
:class="{ done: todo.done }" @click="toggle(todo)">
{{ todo.text }}
</li>
</ul>
</main>
</template>
<style scoped>
.done { text-decoration: line-through; color: #999; }
</style>
可观察结果:输入”买牛奶”回车——新行出现、剩余计数加一、输入框清空;点任意一行——划线、计数减一。保存文件页面即热更新。打开 DevTools 的组件面板,能看到 todos 的实时值,改界面和改面板双向同步。
入口链路速读
页面从 index.html 进来,一路到组件,只有三跳:
<!-- index.html -->
<div id="app"></div> <!-- 挂载点:整个应用长在这里 -->
<script type="module" src="/src/main.js"></script>
// src/main.js:创建应用、挂载,两行办完
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
日常改动九成发生在 App.vue 与后续拆出的子组件里;main.js 只在加插件(路由、Pinia)时再碰。 是单文件组件的语法糖:顶层声明的变量、函数自动暴露给模板,不用再写 return 与 components 注册——这就是模板能直接用 count 与 addTodo 的原因。
与 React 的心智对比
写过 React 再来写 Vue,差异集中在”状态变化之后发生什么”:
| 维度 | React | Vue 3 |
|---|---|---|
| 声明状态 | const [n, setN] = useState(0) |
const n = ref(0) |
| 更新状态 | setN(n + 1),不可变风格,改对象要整份重建 |
n.value++,可变风格,深层属性直接改 |
| 更新之后 | 整个组件函数重新执行一遍 | setup 不重跑,只更新用到该状态的模板部分 |
| 派生值 | useMemo(fn, [deps]),依赖手写 |
computed(fn),依赖自动收集 |
| 副作用 | useEffect(fn, [deps]),依赖手写,漏写是经典 bug |
watch / watchEffect,依赖自动收集 |
这不是”Vue 不用管依赖”,而是把收集依赖的活从人挪给了框架:React 要求你写依赖数组、保持不可变,换来的是”任意时刻函数重跑即视图”的模型;Vue 用 .value 的读写拦截换掉这套约束。两个模型各自的自洽逻辑,见《React Hooks 心智模型》与《React 渲染与重渲染》——对照着读,哪边卡壳都知道卡在哪一层。
第一次拆组件
界面里出现重复结构(比如列表行)时,把它拆成子组件,数据用 props 传入、操作用事件抛回。新建 src/components/TodoItem.vue:
<script setup>
defineProps(['todo']) // 输入:父组件传进来的数据
const emit = defineEmits(['toggle']) // 输出:向父组件报告点击
</script>
<template>
<li :class="{ done: todo.done }" @click="emit('toggle', todo)">
{{ todo.text }}
</li>
</template>
App.vue 里模板改成 <TodoItem v-for="todo in todos" :key="todo.id" :todo="todo" @toggle="toggle" />,toggle 函数不变。功能没变,但”一行长什么样”归子组件、”列表有什么”归父组件,边界出现了。什么时候拆、props 与 emits 的完整规则,下一篇展开。
最佳实践
第一个应用只保留一个能解释清楚的界面。反模式一:一上来就拆五个子组件、引 Pinia——拆分是为了复用与边界,没有复用需求时拆分只增加跳转成本。反模式二:绕过响应式去手动操作 DOM(document.querySelector 改文字)——那等于把框架最核心的服务停用,界面状态会和数据状态分叉。
常见问题
Q1: 模板里为什么不写 .value
顶层 ref 在模板中自动解包。<p>{{ count }}</p> 里 count 已经是数字。但嵌套在普通对象或数组里的 ref 不会解包——所以约定 ref 存顶层,用 reactive 或对象包起来时再小心。
Q2: 改了数据界面没反应
九成是绕开了响应式:script 里忘了 .value(count = 1 只是换了变量绑定),或解构了 reactive 对象(见下一篇语法要点)。DevTools 面板里能看到真实状态值,先确认数据到底变没变。
Q3: 为什么直接改 props 会收到警告
props 属于父组件,单向数据流(one-way data flow)要求子组件发事件让数据的主人去改。这条规则在语法要点篇展开。
练习与实践
练习一:把待办应用加一个”清除已完成”按钮(提示:过滤后整体替换 todos.value),记录点击前后 remaining 的变化。练习二:把 todos 初始化为空数组,体验从空列表到第一条的全过程。评估标准:能口头说出每次点击后”哪个状态变了、哪些 DOM 更新了、setup 有没有重新执行”。
延伸阅读
本篇用到的 ref、computed、v-model、props 各自的”为什么”与边界,下一篇《Vue 3 语法要点》逐个展开,查询具体 API 的签名与坑则留给系列末的参考字典。
版本核实(撰写时检索,2025-03):本文 API(ref、computed、v-model、v-for :key、script setup)均为 Vue 3 稳定特性,Vue 当前稳定线为 3.5.x;defineModel 等 3.4 起新特性在后续篇章标注版本。具体以官方文档与你的锁文件为准。