这门课从一张普通的静态学习卡片开始。卡片上有完成次数、剩余目标、提示文字和按钮状态;它们看起来是不同的界面部位,背后其实都在回答同一个问题:当前完成了多少?
用原生 JavaScript 做这张卡片,改完数据后还得逐个找到 DOM:重写数字,换掉提示,切换样式,再决定按钮是否禁用。交互一多,真正棘手的不是某一句 innerText,而是要保证所有位置始终对同一份状态说同样的话。Vue 正是从这个同步问题走进来的:我们只改状态,界面按照早已声明的关系跟上。
这里先在真实的 Vite 项目里把整条路走通:用 .vue 单文件组件组织一块界面,用模板声明界面与状态的关系,再让响应式数据推动它更新。此时只需要一张全景地图:第 2 章会把镜头对准模板与编译器,第 3 章再打开响应式这个“盒子”,逐步看清依赖怎样被记录、变化怎样传到界面。
全课程统一使用 Vue 3 的 Composition API、<script setup> 与单文件组件。旧项目里常见的 Options API 会在需要时对照说明,但实例始终沿着同一套写法继续生长,后面拆组件、做通信和抽取组合函数时不必重新换一种语言。

先看一个很普通的需求:页面显示当前完成的任务数,点击按钮后加一。
用原生 JavaScript 写,思路通常是“先保存数据,再找到页面元素,最后把新值写回页面”。
<p>已完成:<span id="count">0</span> 项</p>
<button id="addButton">完成一项</button>
<script>
let count = 0
const countElement = document.getElementById('count')
const addButton = document.getElementById('addButton')
addButton.addEventListener('click', () => {
count += 1
countElement.innerText = count
})
</script>这段代码没有问题,而且原生 DOM 操作仍然是前端基础。真正值得留意的是:count += 1 和 countElement.innerText = count 是两件事。前一句改数据,后一句让页面追上数据。如果页面上还有进度条、剩余数量和完成提示,我们就得记得把每个位置都更新一遍。
Vue 换了一个组织方式。我们先描述“页面在某个数据状态下应该长什么样”,再只修改数据。
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<p>已完成:{{ count }} 项</p>
<button @click="count++">完成一项</button>
</template>现在代码里的关系变成了:这段文字依赖 count,这个按钮点击时修改 count。至于哪个文本节点要改、什么时候改,由 Vue 处理。
这就是“声明式界面”最朴素的含义。你声明数据和界面的关系,不再逐条命令浏览器修改哪个 DOM 节点。它并不意味着以后完全接触不到 DOM,而是把大量重复、容易漏掉的同步工作交给框架。
Vue 的核心体验不是“少写几行代码”,而是让数据成为界面的可信来源。页面显示不对时,我们优先检查数据和绑定关系,不必先到处寻找哪一次 DOM 修改漏写了。
有些入门示例会先在 HTML 中通过 CDN 引入 Vue。这种方式适合验证一个很小的想法,也适合给已有静态页面加一点交互。但实际项目通常需要拆分组件、处理样式、安装依赖并构建生产文件,所以我们直接使用 Vite 项目和 .vue 文件。
CDN 写法只要知道它存在即可。等你以后维护没有构建流程的旧页面时,再回来使用也不迟。
先在终端确认 Node.js 和 npm 可以工作:
node -v
npm -v如果终端提示找不到命令,先安装当前维护中的 Node.js 版本。若脚手架明确提示版本过低,按照提示升级后再继续。不要在版本报错上硬绕过去,因为开发服务器和构建工具都依赖 Node.js 运行。
接着进入你准备存放项目的目录,执行:
npm create vue@latest脚手架会询问项目名和若干可选能力。第一次学习可以使用下面这组选择:
Project name: vue-first-step
Add TypeScript? No
Add JSX Support? No
Add Vue Router? No
Add Pinia? No
Add Vitest? No
Add an End-to-End Testing Solution? No
Add ESLint? Yes
Add Prettier? Yes
Add Vue DevTools extension? No提问的具体顺序可能随脚手架更新而变化,不影响我们的目标。暂时选择 No 不是说这些工具不重要,只是这一章先把注意力留给 Vue 本身。路由、状态管理和测试都可以在需要时再加入。
项目生成后,进入目录、安装依赖并启动开发服务器:
cd vue-first-step
npm install
npm run dev终端会给出一个本地访问地址。用浏览器打开它,你会看到初始页面。开发服务器保持运行时,修改并保存文件,浏览器通常会很快显示新结果,这叫热更新。
不要双击 index.html 来运行这个项目。.vue 文件需要先经过 Vite 的处理,直接用 file:// 打开 HTML 会绕过开发服务器,模块导入和资源路径也可能失败。看到页面空白时,先确认 npm run dev 仍在运行,再使用终端给出的地址。
刚生成的目录看起来比单个 HTML 文件复杂,但真正需要先认识的只有几处:
vue-first-step/
├── index.html
├── package.json
├── vite.config.js
└── src/
├── main.js
├── App.vue
├── assets/
└── components/package.json 记录项目依赖和可执行命令。运行 npm install 时,npm 会依据它安装 Vue、Vite 等依赖;运行 npm run dev 时,npm 会从这里找到开发命令。
index.html 是浏览器最先拿到的页面。它通常只保留一个挂载容器和入口脚本,不负责承载整套界面。
src/main.js 是 JavaScript 入口。它创建 Vue 应用,并把根组件接到 index.html 的容器上。
src/App.vue 是根组件。我们这一章的大部分代码都写在这里。
src/components 用来放拆出来的子组件。第一天不用急着拆;等一个页面里出现可以独立命名、重复使用或单独维护的界面块时,组件目录才真正发挥作用。
node_modules 是安装依赖后生成的目录,通常很大。不要手动修改里面的代码,也不要把它当成自己项目源码的一部分。依赖出现问题时,我们通过包管理命令处理,而不是进去改文件。
如果初始模板带了欢迎组件和示例样式,可以删除这些示例文件,再把 App.vue 改成自己的内容。删除前看一眼 App.vue 是否仍然导入它们;文件删了而导入语句还在,Vite 会清楚地报“无法解析导入”。
我们已经连续几次提到 Vite,但还没有把它放到整条链路里。它不是 Vue 的另一个名字,也不是帮我们自动写业务代码的工具。可以先把它理解成项目的“开发与打包工作台”:开发时,它接住浏览器的模块请求并转换源码;准备上线时,它再把项目整理成适合部署的静态文件。
当你执行:
npm run devVite 会启动开发服务器。浏览器请求 main.js 时,开发服务器能够处理模块导入;遇到 import App from './App.vue' 时,Vue 插件会参与解析 .vue 文件,把模板、脚本和样式转换成浏览器能执行或加载的内容。
这也解释了为什么 .vue 文件不能直接交给浏览器。浏览器认识 HTML、CSS 和 JavaScript,却没有内置“读取一个文件中的 <template>、<script setup>、<style scoped>,再把它们组装成组件”的规则。构建工具填上了这段转换流程。
开发服务器还会维护模块之间的关系。保存 App.vue 后,它知道哪个模块发生了变化,通常只把相关更新送到当前页面。这样我们不必每次都手动停止命令、重新打包、刷新并回到刚才的操作位置。
不过,开发服务器不是最终要部署给所有用户的服务器。准备生成生产文件时执行:
npm run build构建完成后,项目根目录通常会出现 dist。里面是已经转换、压缩并整理过的 HTML、JavaScript、CSS 和资源文件。源码中的 .vue 结构方便开发者维护,浏览器最终得到的仍然是它认识的网页资源。
如果想在本机检查构建后的结果,可以执行:
npm run preview这个命令适合做上线前的本地预览,不应代替正式部署方案。开发模式和生产构建的目标不同:前者看重反馈速度与错误提示,后者看重可部署的输出。
还有一个常见误会:热更新只说明新源码被开发服务器接收,不说明所有组件状态都一定保留。有些改动可以在不刷新整页的情况下替换样式或组件代码,有些结构性变化仍可能让组件重新创建。学习时不要把临时状态当作需要永久保存的数据;真正需要跨刷新保留的内容,后面要放到存储或服务端中。
现在可以把工具链记成一条短线:我们写 .vue 源码,Vite 与 Vue 插件负责转换,浏览器负责执行最终模块并显示页面。先有这张地图,以后看到“编译错误”“模块导入失败”“开发服务器端口”这些词时,就知道问题分别位于哪一段。
.vue 文件不是三种文件的随意拼接打开 src/App.vue,你会看到一种很有辨识度的结构:同一个文件里可以放脚本、模板和样式。
<script setup>
const title = '今天开始学 Vue'
</script>
<template>
<main class="lesson-card">
<h1>{{ title }}</h1>
<p>先让第一个组件出现在浏览器里。</p>
</main>
</template>
<style scoped>
.lesson-card

这三个区域各自解决一个明确的问题。
<script setup>:准备状态和行为这里写 JavaScript。顶层声明的变量和函数可以直接在当前组件的模板中使用,不需要手动写 return,也不需要把它们挂到 this 上。
setup 不是一个运行按钮。它是一种在单文件组件中使用 Composition API 的编译期语法。以后组件每创建一个实例,与实例相关的初始化逻辑会为它执行。
<template>:描述界面模板看起来像 HTML,也确实遵循 HTML 的基本结构。区别在于它还能使用 Vue 的插值和指令,把脚本中的数据与页面连接起来。
这里的 {{ title }} 不是让浏览器自己识别的新语法。浏览器根本不认识双大括号。Vite 在开发和构建过程中调用 Vue 编译器,把模板转换为浏览器可以执行的 JavaScript 渲染代码。
<style scoped>:描述当前组件的外观这里仍然写普通 CSS。scoped 会让样式主要作用于当前组件生成的元素,减少它意外影响其他组件的概率。它不是把组件放进完全隔绝的影子 DOM 中,而是构建工具为选择器和元素添加配对标记。
把三部分放在一个文件里,并不是放弃“关注点分离”。我们只是把分离的单位从文件类型换成了组件:学习卡片的结构、行为和样式放在一起;导航栏的结构、行为和样式放进另一个组件。查一个功能时,不必在三个巨大的全局文件之间来回跳。
单文件组件是这门课程的一等公民。后续示例默认写成 .vue 文件,并优先使用 <script setup>。你从第一章形成的文件结构习惯,后面拆组件时可以直接延续。
index.html 走到 App.vue页面能显示 App.vue,中间还有一段很短但很关键的连接。先看常见的 index.html:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>我的 Vue 学习卡</title>
</head>
<body>
<div id
<div id="app"></div> 是挂载容器。它像页面中预留的一块位置,告诉 Vue:“你的根组件从这里开始管理界面。”
再看 src/main.js:
import { createApp } from 'vue'
import App from './App.vue'
import './assets/main.css'
const app = createApp(App)
app.mount('#app')
这几行可以按顺序读:
从 vue 包中导入 createApp。它用来创建一个彼此隔离的 Vue 应用实例。
导入 App.vue。经过 Vite 与 Vue 插件处理后,这个文件可以像 JavaScript 模块一样被导入,它代表根组件。
调用 createApp(App),让 App 成为整棵组件树的起点。以后拆出的导航、列表和按钮组件都会成为这棵树上的后代。
调用 app.mount('#app'),寻找 为 的容器,并把根组件渲染到容器内部。应用级配置应在挂载前完成。
createApp 和 mount 解决的是应用边界,不是每次状态变化都重新执行一遍。应用挂载后,后续界面更新由组件的响应式渲染机制处理。
如果把 mount('#app') 错写成 mount('#root'),而 HTML 中没有 id="root" 的元素,Vue 找不到落脚点,页面自然不会出现。这个错误很常见,因为不同项目可能使用不同容器名。排查时,把 index.html 的 id 与 main.js 的选择器放在一起对照,往往几秒就能发现。
一个页面也可以挂载多个小型 Vue 应用,各自管理一块区域。不过完整的单页应用通常从一个根组件开始,再向下组织子组件。现在我们只保留一个根,先把数据流看清楚。
看到 {{ title }} 时,很容易把 Vue 模板想象成一台字符串替换器:找到双大括号,把变量内容塞进去。这个想法能解释最简单的文字,却解释不了事件监听、属性绑定和高效更新。先在这里看清轮廓,第 2 章再把这些符号逐个放大。
更接近真实情况的理解是:模板是我们写给编译器看的界面描述。
<template>
<h2>{{ title }}</h2>
<button @click="increase">已练习 {{ count }} 次</button>
</template>构建工具会把模板转换成 JavaScript 渲染函数。下面只是帮助理解的概念化伪代码,不是编译器的原样输出:
function render() {
return createElements([
heading(title),
button({ onClick: increase }, `已练习 ${count} 次`)
])
}
第一次渲染时,渲染代码会读取 title 和 count。Vue 因此知道这一部分界面依赖哪些响应式状态。count 变化后,它不必把整个网站从零重画,而是重新计算受影响的组件输出,并把必要的变化应用到真实 DOM。
“模板会被编译”还能解释几个常见疑问。
因为表达式会成为渲染代码的一部分,所以可以做简单计算:
<p>还剩 {{ target - count }} 次</p>
<p>{{ count >= target ? '今日目标已完成' : '继续练习' }}</p>但这里需要的是能求出一个值的表达式,不能直接塞一整段 if、for 或变量声明。逻辑一旦变复杂,就在 <script setup> 中写函数或计算状态,让模板保持可读。
普通 HTML 属性会把内容当成固定文本:
<button title="buttonTip">查看提示</button>浏览器看到的标题就是 buttonTip 这串字。要让属性读取 JavaScript 变量,需要使用 v-bind 的简写 ::
<button :title="buttonTip">查看提示</button>@@click 是 v-on:click 的简写,它声明“点击事件发生时执行这个表达式或函数”。
<button @click="increase">完成一次</button>我们不需要先 querySelector 再 addEventListener。编译器已经从模板中获得了事件与函数的对应关系。
此处先抓住总原则:模板不是写完就原封不动交给浏览器的字符串,而是 Vue 能分析和优化的编译输入。第 2 章会沿着这个原则分清文本插值、属性绑定和事件绑定;等这套基本语法站稳后,第 4 章再让条件与列表真正改变界面结构。
现在把 src/App.vue 改成一张学习卡。先完整运行,再回来逐段读。代码中用到的 :class 和 :disabled 会在第 2 章顺着属性绑定讲清,v-if 则会在第 4 章进入条件渲染时展开。此时可以先根据名字猜它们的作用。
<script setup>
import { ref } from 'vue'
const learnerName = ref('小满')
const completed = ref(0)
const target = 3
const isTipVisible = ref(false)
function completeOne() {
if (completed.value < target) {
completed.value

保存后,页面中央会出现一张白色卡片。它初始显示“已完成 0 / 3 次练习”。每点击一次“完成一次”,数字加一;到第三次时,边框变绿、卡片轻微上移,提示文字改为完成状态,主按钮也不能继续点击。点击“重新开始”,这些变化会一起回到初始状态。
这里很适合停一下,观察我们没有写什么。completeOne 里没有修改标题,没有修改边框,没有禁用按钮,也没有替换提示段落。它只改了 completed.value。模板早已描述了各个界面部分怎样依赖 completed,Vue 负责让它们保持一致。
这也是数据驱动界面真正省心的地方:一个状态只有一个来源。若我们把进度数字、按钮是否禁用和完成提示分别保存成三个可变变量,它们迟早可能互相矛盾。现在后两者都由 completed 推导,状态更难“走散”。
界面已经可以操作,但如果只记住“点按钮,页面变化”,响应式仍然像一个黑箱。我们把“完成一次”从手指按下到页面稳定的过程慢放一遍。
用户点击按钮时,最先知道这件事的是浏览器。模板中的 @click="completeOne" 已经被编译为事件处理关系,所以 Vue 会让这次点击调用 completeOne。
如果误写成:
<button @click="completeOne()">完成一次</button>这在当前例子中也能运行。事件发生时,表达式调用函数。我们使用 @click="completeOne" 是因为这里只需把现成函数交给事件,不需要额外传参或组合表达式,读起来更直接。
函数先判断是否达到目标:
function completeOne() {
if (completed.value < target) {
completed.value += 1
}
}这里有两次关键访问。条件判断读取 completed.value,递增操作又读取旧值并写入新值。写入发生在 ref 的 .value 上,因此 Vue 能捕获这次变化。
target 为什么只是普通常量?因为本例中它创建后不会再改变。模板读取一个不会改变的普通值完全没问题;只有希望“修改后自动推动界面更新”的状态,才需要放进响应式容器。不要为了看起来像 Vue 代码,就把每个数字和字符串都包成 ref。
状态写入后,Vue 不需要立刻在这一行 JavaScript 中逐个修改真实 DOM。它会标记依赖当前状态的组件需要更新,并把更新安排到队列中。
这样做有一个实际好处。假设同一个事件里连续写三次:
function addThree() {
completed.value += 1
completed.value += 1
completed.value += 1
}数据的最终结果当然增加三,但界面更新可以合并处理,而不是每写一次就完整走一遍 DOM 更新。框架把短时间内的状态变化收拢起来,能减少不必要的重复工作。
轮到组件更新时,编译后的渲染逻辑重新计算与状态有关的输出。下面这些位置都会重新得到结果:
<section :class="{ finished: completed === target }">
<p>已完成 {{ completed }} / {{ target }} 次练习</p>
<p v-if="completed === target">目标完成</p>
<button :disabled="completed === target">完成一次</button>
</section>当 completed 从 2 变为 3,class 条件从假变真,插值数字改变,条件段落切换,disabled 也从假变真。这些不是四次互相独立的手动 DOM 命令,而是同一次状态变化后得到的四个界面结果。
渲染结果算好后,Vue 比较前后的界面描述,把需要改变的部分同步到真实 DOM。我们可以在浏览器开发者工具中看到最终元素,却不需要自己保存旧 DOM 再逐项比较。
“必要差异”不等于永远只改一个文本节点,也不等于每次把整个页面推倒重建。具体范围取决于组件结构和变化内容。入门阶段不用背内部算法,只需知道组件更新有边界,编译器和运行时会一起减少无意义的操作。
nextTick大多数业务只关心状态和最终界面,不需要在修改后的同一行立刻读取 DOM。少数情况下,我们确实要等页面完成更新,例如状态改变后测量新元素高度。此时可以等待下一次 DOM 更新:
<script setup>
import { nextTick, ref } from 'vue'
const isOpen = ref(false)
async function openAndMeasure() {
isOpen.value = true
await nextTick()
// 到这里,与 isOpen 相关的 DOM 更新已经完成
// 接下来才适合读取新元素尺寸
}
</script>不要在每次修改 ref 后都机械地加 nextTick。它不是让响应式“生效”的开关,只是在你确实需要等待真实 DOM 完成更新时提供一个明确时机。
沿着这条链再看一次:浏览器发出事件,事件函数修改响应式状态,Vue 安排组件更新,渲染逻辑读取新状态,最后把差异应用到 DOM。以后界面没有变化时,就能逐段检查,而不是把问题统称为“Vue 没反应”。
代码里最陌生的一行,多半是:
const completed = ref(0)为什么不直接写 let completed = 0?
普通局部变量可以改变,但 JavaScript 没有自动通知 Vue“这个变量刚才被谁读过,现在又变了”的机制。渲染完成后执行 completed += 1,变量本身会变,Vue 却没有稳定的入口去拦截这次读写。
ref(0) 返回的不是数字 0,而是一个带 .value 属性的响应式容器。你可以把它想成一个被看守的盒子:盒子里放着值,Vue 能在读取盒子和更换盒中内容时执行额外工作。这仍是全景地图上的简化标记;第 3 章会再沿着读取、跟踪、写入和触发的顺序,把它变成能用于排查问题的准确模型。
import { ref } from 'vue'
const completed = ref(0)
console.log(completed.value) // 0
completed.value += 1
console.log(completed.value) // 1它的概念模型大致如下。真实实现会复杂得多,这段只用于建立直觉:
function createSimpleRef(initialValue) {
let innerValue = initialValue
return {
get value() {
trackCurrentReader()
return innerValue
},
set value(nextValue) {
innerValue = nextValue
notifyReaders()
}
}
}
响应式过程可以拆成两段。
模板渲染时读取了 completed,Vue 记录“当前组件的渲染依赖这个 ref”。按钮文字、提示段落、禁用状态和 class 表达式都在读取它,但 Vue 不需要把它们理解成四套互不相关的手工更新代码;它能在组件渲染过程中收集依赖。
点击按钮时,completed.value += 1 触发 ref 的写入逻辑。Vue 找到先前记录的依赖,安排组件更新。为了避免短时间内反复改状态就立刻反复碰 DOM,更新通常会被合并到后续的更新时机中。
所以响应式的关键不只是“检测变化”。更准确的说法是:读取时追踪依赖,修改时触发依赖。没有先前的读取关系,Vue 也不会凭空知道哪个界面需要更新。
.value,模板里却没有在 JavaScript 中,completed 是 ref 对象,真正的数值放在 completed.value。显式写 .value 能让读写行为落在可拦截的属性上。
模板编译时,Vue 会对模板中顶层 ref 做方便的解包,因此写 {{ completed }} 就能显示内部值,不必写 {{ completed.value }}。记忆方法很简单:脚本中先想到 .value,模板中通常直接用名字。
对 reactive 对象的基本类型属性做普通解构,是常见的“页面怎么不更新”来源:
import { reactive } from 'vue'
const state = reactive({ completed: 0 })
let { completed } = state
completed += 1解构得到的 completed 只是当时的普通数字。后面的 completed += 1 没有修改 state.completed,自然也不会通知依赖 state.completed 的模板。
初学阶段优先使用 ref 保存状态,并通过 .value 修改,能减少这类断开连接的问题。以后需要解构响应式对象时,我们会学习 toRef、toRefs 等保持联系的做法。
如果数据在控制台里确实变了而页面没有变化,先问三个问题:它是不是响应式状态?模板是否真的读取了它?修改时是否绕开了原来的 ref 或代理对象?这比反复刷新页面更接近问题根部。
理解 ref 的盒子模型后,还要回答一个更实用的问题:组件里哪些值该成为状态,哪些值不该重复保存?状态设计清楚,模板和事件自然会简单很多。
在学习卡中,completed 会因点击而改变,并影响文字、按钮与样式,所以它是响应式状态。isTipVisible 也会改变,并决定提示是否显示,同样使用 ref。
反过来,下面这些值没有必要因为“写在 Vue 里”就自动变成 ref:
const courseName = 'Vue 入门'
const maxTitleLength = 30
function formatTitle(title) {
return title.trim()
}固定配置可以是普通常量,纯工具函数也只是普通函数。Composition API 的一个好处正是允许我们继续使用熟悉的 JavaScript,而不是把所有东西都塞进框架专用容器。
设想我们同时保存这三个状态:
const completed = ref(0)
const isFinished = ref(false)
const buttonText = ref('完成一次')点击时就必须保证三者同步:
function completeOne() {
completed.value += 1
if (completed.value === 3) {
isFinished.value = true
buttonText.value = '已经完成'
}
}问题不在于代码暂时跑不了,而在于状态出现了多个真相来源。以后新增“撤回一次”或修改目标值时,很容易只改其中两个,界面便出现“数字未完成,按钮却写着已经完成”的矛盾。
更稳妥的做法是只保存最原始的事实:完成次数和目标次数。是否完成、按钮显示什么,都根据事实推导。当前模板中的表达式已经在这样做:
<button :disabled="completed === target">
{{ completed === target ? '已经完成' : '完成一次' }}
</button>当推导逻辑变长或被多个地方使用时,可以再把它整理为计算属性。计算属性会在后续章节专门讲解。现在先形成一个习惯:新增状态前问一句“它能不能从已有状态算出来”。
const 与“值会变化”并不冲突初学者看到下面代码时常会疑惑:既然计数会变,为什么用 const?
const completed = ref(0)
completed.value += 1const 限制的是变量 completed 不能重新指向另一个对象,并不冻结对象内部的属性。我们始终保留同一个 ref 盒子,只修改盒子里的 .value,所以这两行完全一致。
这样反而能保护响应式连接。若允许随手写 completed = otherValue,原来的 ref 可能被替换,模板与函数之间的关系也更难追踪。
ref 不只装数字。它可以保存字符串、布尔值、对象和数组:
const learner = ref({
name: '小满',
completed: 0
})
const tasks = ref([
{ id: 1, title: '运行项目', done: true },
{ id: 2, title: '修改组件', done: false }
])脚本里访问这些内容时仍从 .value 开始:
learner.value.completed += 1
tasks.value.push({ id: 3, title: '观察更新', done: false })默认情况下,这些嵌套变化也能参与响应式更新。模板中使用顶层 ref 时通常会解包,因此可以写 learner.name、tasks.length。
对象很大时要不要全部深度响应、外部库管理的对象要不要转换,是性能与集成层面的进阶问题。第一章不需要提前优化。先把状态的真实形状写清楚,再在遇到具体性能证据时选择更浅的响应式工具。
ref 与 reactive 不必在第一天争输赢Vue 还提供 reactive,它直接把对象变成响应式代理:
import { reactive } from 'vue'
const state = reactive({
completed: 0,
isTipVisible: false
})
state.completed += 1它访问属性时不用 .value,适合把一组相关字段留在同一个对象上。但它不能直接承载数字、字符串这类基本值,整体替换对象和解构基本类型属性时也更容易断开原有连接。
这门课程入门阶段优先使用 ref,原因不是 reactive 错了,而是 ref 能覆盖基本值和对象,替换整个值也直观,规则更统一。等你能稳定判断状态边界后,再根据场景选择两者会轻松很多。
Vue 能让依赖界面跟着状态更新,却不会判断“完成次数能不能超过目标”“名字可不可以为空”“删除任务是否需要确认”。这些仍是我们在函数中写清楚的业务约束。
function completeOne() {
if (completed.value >= target) {
return
}
completed.value += 1
}响应式负责传播变化,业务函数负责决定什么变化合法。把这两个职责区分开,遇到错误时就能判断:是状态根本没按规则改变,还是状态已经改变但界面没有正确依赖它。
搜索 Vue 问题时,你会遇到两种长得很不一样的组件代码。它们都属于 Vue 3 支持的正式写法。
我们刚才一直使用的是 Composition API:
<script setup>
import { ref } from 'vue'
const count = ref(0)
function increase() {
count.value += 1
}
</script>
<template>
<button @click="increase">{{ count }}</button>
</template>同样的功能用 Options API 可以写成:
<script>
export default {
data() {
return {
count: 0
}
},
methods: {
increase() {
this.count += 1
}
}
}
</script>
<template>
<button @click="increase">{{ count }}</button>

Options API 按选项分类:数据放进 data,函数放进 methods,计算结果放进 computed,生命周期逻辑也有自己的选项。它的“抽屉”很明确,小组件读起来直观,许多 Vue 2 项目和不少仍在维护的 Vue 3 项目都在使用它。
Composition API 按功能关系组织普通变量和函数。一个“搜索”功能相关的状态、计算和请求逻辑可以靠近摆放;另一个“分页”功能也可以形成自己的代码块。组件变大后,这些逻辑还可以抽成可复用的组合函数。它与 TypeScript 的推断配合自然,也正是新建 Vue 项目常见的默认方向。
本课程选择 Composition API 加 <script setup>,理由很实际:
它与当前 Vite 脚手架生成的组件风格一致。
变量和函数大多保持普通 JavaScript 的形状,不必先熟悉大量 this 规则。
同一项功能的状态与行为可以放在一起,后面更容易抽出复用逻辑。
从简单 JavaScript 迁移到 TypeScript 时,整体代码结构变化较小。
这不等于 Options API 已经过时或即将消失。遇到 Options API 项目时,你需要认识 data、methods、computed 和 this 的含义;在已有代码库中,保持团队一致往往比强行混用两套风格更好。
两套 API 也不是两套响应式引擎。它们最终使用的是同一套 Vue 能力。我们现在明确选一套来教,是为了减少入门噪音,不是为了制造阵营。
刚开始看 .vue 文件时,视线很容易在模板、变量、函数和 CSS 之间来回跳。文件一长,就会觉得每一行都认识,却说不清这个组件怎样工作。
可以给自己一个固定阅读顺序。它不是语法要求,而是一条减少认知负担的路径。
在 <script setup> 中先找 ref、reactive 以及组件接收的数据。对学习卡来说,核心状态只有:
const completed = ref(0)
const isTipVisible = ref(false)看到这里先用普通话描述它们:“组件记住完成次数,也记住提示是否展开。”如果一个变量名无法让你说清它代表什么,命名可能需要调整。
再区分哪些只是固定值:
const learnerName = ref('小满')
const target = 3learnerName 当前虽然没有编辑入口,但它被设计为将来可变的页面状态;target 在本例中固定。阅读时不只看初始值,还要看后续是否被修改。
搜索 .value =、.value +=、数组的 push,或者直接阅读事件函数。给每个函数写一句口头摘要:
completeOne:未达目标时让次数加一。
resetProgress:把次数与提示状态恢复到初始值。
toggleTip:在展开与收起之间切换。
这一步相当于列出状态的入口。若页面出现一个无法解释的值,就沿着所有可能修改它的函数往回查。
拿着状态名到 <template> 中搜索。completed 出现在插值、条件、class 和 disabled 绑定中;isTipVisible 出现在按钮文字和条件段落中。
把“谁修改它”和“谁读取它”连起来,组件的数据流就浮现了:点击触发函数,函数修改 ref,模板读取 ref 并产生新界面。很多看似复杂的组件,本质上也是更多条这样的关系。
如果模板直接执行了很长的表达式,阅读关系会变困难。例如:
<p>
{{ tasks.filter(task => task.done).length >= target && user.name.trim()
? '目标完成'
: '继续练习' }}
</p>它可能能运行,却让状态规则藏在结构中。更清楚的写法是把推导逻辑移到脚本,再给结果一个能说明含义的名字。模板的主要任务是表达界面关系,不是展示我们能把多少 JavaScript 塞进一行。
CSS 放到最后读,因为样式通常不决定数据怎样变化。先找到模板上的普通 class,再找动态 class:
<section class="study-card" :class="{ finished: completed === target }">这里 study-card 始终存在,finished 只在完成时出现。接着到样式中寻找 .study-card.finished,就能解释边框为什么变绿。
如果 class 已经正确出现在开发者工具里,但页面外观不变,问题大多在 CSS 选择器、优先级或属性上;如果 class 根本没出现,则回到模板条件和响应式状态。这个分界能避免在 CSS 与 JavaScript 之间盲目来回改。
读完后,尝试不用术语复述:
这个组件记住完成次数和提示展开状态,同时有姓名与目标次数。
三个按钮分别增加次数、重置进度和切换提示。
模板用完成次数决定文字、按钮状态与完成样式,用提示状态决定说明是否出现。
样式让普通状态和完成状态有可见差异,但不负责决定何时完成。
能复述到这个程度,说明你已经看懂组件的数据流。若其中一句说不清,就回到对应区域,而不是从文件第一行重新读。随着组件变大,这个阅读方法仍然有效;后面加入 props、计算属性、侦听器和生命周期时,只是在“状态从哪里来、谁改变它、谁读取它”这张图上增加新的入口与关系。
页面没有按预期工作时,先看终端和浏览器控制台的第一条错误。后面的错误常常只是第一处问题带来的连锁反应。
症状是访问地址打不开,或直接打开 HTML 后模块加载失败。回到项目目录执行:
npm run dev终端窗口需要保持运行。按下 Ctrl + C 会停止服务器,这不是保存操作。
项目里可能同时存在根目录、src 目录和组件目录。浏览器显示完全不变时,先确认编辑的是当前项目的 src/App.vue,并检查编辑器是否已经保存。
下面的文件名只差大小写,在部分系统里也许暂时不报错,换到严格区分大小写的环境就会失败:
import StudyCard from './components/studycard.vue'如果真实文件叫 StudyCard.vue,导入也写成完全一致的名称:
import StudyCard from './components/StudyCard.vue'.valueconst count = ref(0)
function increase() {
count++
}count 是 ref 对象,不能把对象本身当数字递增。应写成:
function increase() {
count.value++
}<button disabled="isFinished">完成一次</button>HTML 布尔属性只要存在就表示启用该状态,因此按钮会一直禁用。要绑定变量,应写:
<button :disabled="isFinished">完成一次</button>.vue 模板由编译器解析,少一个 </section> 或把标签嵌套错位,会在终端给出编译错误。不要只盯着报错行;真正漏写的标签可能在它上方。使用编辑器的格式化功能,缩进突然变乱的位置通常值得检查。
报错不是对学习进度的否定,它是工具把现场信息交给你。保留开发服务器的终端窗口和浏览器控制台,先读错误类型、文件名与行号,再改最靠前的一处,调试会安静很多。
不要急着复制下一份代码。就在刚才的学习卡上动手,观察每次变化来自哪里。
找到:
const target = 3改为:
const target = 5保存后,页面的总目标和按钮禁用时机都会变化。你只改了一个数据源,多个依赖位置保持一致。
在脚本中加入:
function undoOne() {
if (completed.value > 0) {
completed.value -= 1
}
}再在 .actions 中加入按钮:
<button
class="plain-button"
:disabled="completed === 0"
@click="undoOne"
>
撤回一次
</button>试着在完成状态下后退一次。边框、提示和主按钮会一起恢复到未完成状态,因为它们都从同一个 completed 推导。
暂时把:
const isTipVisible = ref(false)改成:
let isTipVisible = false并把 toggleTip 中的 .value 去掉:
function toggleTip() {
isTipVisible = !isTipVisible
}点击提示按钮,普通变量确实发生了改变,但页面通常不会因为这次局部变量赋值而更新。完成观察后,把它恢复为 ref(false)。这个故意制造的小故障,比背一句“Vue 是响应式的”更能说明 ref 到底解决了什么。
{{ }}、: 和 @ 分别在告诉编译器什么现在的 App.vue 已经会回应点击了。index.html 提供容器,main.js 创建并挂载应用,单文件组件把结构、逻辑和样式收在一个边界里。点击“完成一次”后,事件函数修改 completed,数字、提示、边框和按钮状态随之保持一致。
但“会动”只是结果,模板中还留着三个需要读准的信号:{{ completed }} 是在声明一段文本依赖状态,:disabled="completed === target" 是在让元素属性读取表达式,@click="completeOne" 则把浏览器事件交给组件逻辑。它们都出现在 HTML 般的标记中,却不是三种临时的字符串替换,而是编译器要转换成渲染与事件逻辑的不同关系。
双大括号里为什么能写加法和三元表达式,却不能直接写 if 语句?属性少了 : 为什么就变成普通文本?@click 除了接收函数名,还能交给编译器怎样的表达式?沿着这三个问号走进第 2 章,我们会继续使用这张学习卡和同一套 .vue 写法,只把镜头从“页面会回应”拉近到“模板究竟读懂了什么”。
idapp