Vue 入门指南(面向 Claude Code 开发者)
写给已经掌握 JS / HTML / CSS 基础、但没接触过任何前端框架的你。这篇文档会先带你把一个项目"跑起来",再逐步讲概念,尽量少跳步骤。
一、为什么需要 Vue:从一个具体问题说起
假设你要用原生 JS 做一个计数器:一个数字,一个"+1"按钮,点一下数字加一。原生写法大致是这样的思路:
- 用
document.getElementById或类似方法,先"抓到"页面上显示数字的那个元素; - 定义一个变量存当前的数字;
- 给按钮绑定点击事件,点击时变量加一;
- 再手动把这个变量的新值,写回刚才抓到的那个元素里(比如
.innerText = 新值)。
问题出在第 4 步:数据变了,你必须自己记得去更新对应的 DOM。页面简单时还好,一旦页面上有几十个地方的内容依赖同一批数据,你就要在每个数据变化的地方,手动更新一堆 DOM 元素,非常容易漏改、改错。
Vue 做的事情就是把第 4 步接管过去:你只要告诉 Vue "这个数字应该显示在这里",之后每次数字变化,Vue 自动帮你把页面对应的部分更新掉,你不用再手写这一步。这就是"响应式"和"声明式渲染"两个词想表达的意思——你只声明"结果应该是什么样",不用管"怎么一步步改出来"。
记住这个对比,后面所有概念都是围绕"如何声明数据、如何声明界面"展开的。
二、开发环境与项目是怎么跑起来的
在讲语法之前,先搞清楚一个 Vue 项目从零到能在浏览器里看到效果,中间发生了什么。这一步很多教程会跳过,但恰恰是最容易让人懵的地方。
1. 需要先装好的东西
- Node.js:一个能让 JS 脱离浏览器、在电脑上直接运行的环境。前端的各种工具(包括创建项目的脚手架)本质上都是用 JS 写的小程序,需要靠 Node.js 来运行。装好后,终端里可以运行
node -v查看版本确认装好了。 - npm:随 Node.js 一起安装的"包管理工具",作用是下载别人写好的代码库("包"),并帮你管理项目依赖了哪些包、版本是多少。
有了 Claude Code,这两样的安装排查它也可以帮你完成,但你需要知道它们是"运行和管理前端工具链的地基"。
2. 创建一个项目
在终端里进入你想放项目的文件夹,运行官方脚手架命令(Claude Code 会帮你执行,你只需要知道这一步在做什么):它会问你项目名、要不要加路由、要不要加状态管理等几个问题,然后生成一整套初始文件和文件夹——不是空文件夹,而是一个已经能跑起来的最小项目。
3. 项目生成后,文件夹里大致有什么
my-project/
node_modules/ 自动下载的所有依赖包(体积很大,不用管,不要手动改)
public/ 不需要处理、原样发布的静态文件(比如网站图标)
src/ 你真正会写代码的地方
App.vue 整个应用最外层的组件
main.js 入口文件:创建 Vue 应用、挂载到页面上
index.html 唯一的一个 HTML 页面("单页应用"的"单页"就是指它)
package.json 记录项目信息和依赖了哪些包
vite.config.js 构建工具的配置文件
刚开始你几乎只需要关心 src/ 文件夹里的内容,其他文件了解个大概即可。
4. 让项目跑起来
在项目文件夹里运行一个"启动开发服务器"的命令后,终端会打印一个本地网址(通常是 http://localhost:5173 之类),用浏览器打开它,就能看到项目的初始页面。
这里有个对新手很重要、但常被忽略的体验:这个开发服务器会一直"看着"你的代码文件。你在编辑器里改一行代码、保存,浏览器里的页面会自动刷新,几乎不需要你手动刷新浏览器——这个叫"热更新",是开发体验的核心部分。也就是说,你或 Claude Code 每改一处代码,直接切回浏览器就能立刻看到效果,这是你验证改动是否正确的主要方式。
5. "开发"和"上线"是两件事
你平时改代码、浏览器自动刷新看效果的过程,叫开发模式,只在你自己电脑上跑,别人访问不到。真正要给别人用,需要执行"打包/构建"命令,把所有代码处理、压缩成浏览器能直接运行的静态文件(通常会生成一个 dist 文件夹),再把这个文件夹部署到服务器或托管平台上。开发时你不需要关心打包这一步,等项目做完要发布时再考虑。
三、一个最小的 Vue 组件长什么样
有了运行环境的概念,现在看看代码本身。Vue 项目里每个界面单元写在一个 .vue 文件里,固定分三块:
<template>
<button @click="count++">点击次数:{{ count }}</button>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<style>
button { padding: 8px 16px; }
</style>
对照着看:
<template>:这块基本就是 HTML,唯一特殊的地方是{{ count }}(把变量的值显示出来)和@click(点击时执行后面的代码)。<script setup>:这块是普通 JS,唯一特殊的地方是ref(0)——它把数字0包装成一个"Vue 能监控变化"的数据。<style>:普通 CSS,只会作用在这个组件自己身上,不会影响其他组件(这点和普通 CSS 不一样,普通 CSS 是全局生效的)。
先记住这个"三段式结构"和这个计数器的样子,后面讲的所有概念,都是在给这三块里能写的东西"加功能"。
四、响应式数据:ref() 到底在做什么
回到上面的例子:const count = ref(0)。
如果不用 ref,直接写 let count = 0,count++ 之后变量确实变了,但没有任何机制通知模板"这个值变了,该更新界面了"。ref() 做的事情,就是把这个值包一层"外壳",这个外壳自带监控功能:每次通过 .value 修改它(在 <script> 里要写 count.value++;在 <template> 里 Vue 会自动帮你省略 .value,直接写 count),Vue 就知道"这个数据变了,去检查所有用到它的地方,更新界面"。
reactive() 是另一个类似的函数,区别是它直接包装一整个对象(比如 { name: '小明', age: 20 }),不需要 .value,但代价是不能把整个对象换掉,只能改里面的某个属性。实际项目里 ref() 用得更多、更通用,先掌握它就够了。
一句话记住:想让一个数据"活起来"(改了自动刷新界面),就用 ref() 包一下,读写的时候多带一个 .value(模板里除外)。
五、模板语法:数据如何显示到页面上
模板里除了 {{ }} 和 @click 之外,还有几种常用写法,都是"把 HTML 的某部分和数据关联起来"的不同方式:
| 语法 | 作用 | 类比 |
|---|---|---|
{{ 变量 }} |
把值显示在文本中 | 就是上面例子里的用法 |
:属性名(v-bind 简写) |
把 HTML 属性绑定到变量 | 比如图片的 src 跟着变量变化 |
@事件名(v-on 简写) |
绑定事件处理逻辑 | 上面例子里的 @click |
v-if |
条件成立才渲染这块 | 类似 JS 里的 if |
v-for |
遍历数组,重复渲染 | 类似 JS 里的 for 循环,常用来渲染列表 |
v-model |
表单输入和变量双向同步 | 输入框内容变了变量自动更新,反过来也一样 |
看到这张表就能明白:模板语法本质上就是把"变量/事件/循环/判断"这些 JS 里已经会的概念,用一种 HTML 能识别的写法表达出来,不是全新的知识,只是换了个写法。下面用一个稍微完整点的例子,把这几种语法串在一起看:
<template>
<input v-model="keyword" placeholder="输入图片描述" />
<img :src="imageUrl" :alt="keyword" />
<p v-if="items.length === 0">暂无数据</p>
<ul v-else>
<li v-for="item in items" :key="item.id">
{{ item.name }}
<button @click="removeItem(item.id)">删除</button>
</li>
</ul>
</template>
<script setup>
import { ref } from 'vue'
const keyword = ref('')
const imageUrl = ref('https://example.com/pic.jpg')
const items = ref([
{ id: 1, name: '苹果' },
{ id: 2, name: '香蕉' }
])
function removeItem(id) {
items.value = items.value.filter(item => item.id !== id)
}
</script>
对照着看几个细节:
v-model="keyword"让输入框和keyword双向同步,用户打字时keyword.value自动更新,不需要自己手写input事件监听。:src和:alt是同一种v-bind语法,一个标签上可以同时绑定多个属性。v-if/v-else成对出现,根据items.length是否为 0 二选一渲染。v-for="item in items"遍历数组时,必须配上:key="item.id"——这是给每一项一个唯一标识,方便 Vue 在列表增删时准确判断哪一项变了,而不是把整个列表重新渲染一遍。忘记写:key是新手最常见的报错/警告来源之一。@click="removeItem(item.id)"说明事件绑定可以直接调用一个带参数的函数,不局限于count++这种简单表达式。
六、组件:把页面拆成积木
上面的计数器只有一个组件(App.vue 本身)。真实项目会把页面拆成很多小组件,比如"导航栏组件""商品卡片组件""底部组件",再组合到一起,方便复用和维护。
组件之间需要"说话",有两个方向:
- 父传子:Props。父组件把数据传给子组件(类似调用函数时传参数)。子组件只能"使用"这份数据,不能自己改它。
- 子传父:Emit(触发事件)。子组件想告诉父组件"发生了一件事"(比如某个按钮被点了),就触发一个自定义事件,父组件监听这个事件、决定怎么反应。
记住方向:数据往下传(props),消息往上报(emit)。这是几乎所有组件化框架通用的模式,不是 Vue 独有的。
用代码看一下这个方向具体怎么写。假设有一个子组件 LikeButton.vue,父组件传入当前点赞数,子组件点击后通知父组件"点赞数应该加一":
<!-- 子组件 LikeButton.vue -->
<template>
<button @click="handleClick">👍 {{ count }}</button>
</template>
<script setup>
// defineProps 声明"我会接收哪些数据",父组件传入的值会自动出现在 props 里
const props = defineProps({
count: Number
})
// defineEmits 声明"我会触发哪些事件",相当于先登记一下事件的名字
const emit = defineEmits(['increase'])
function handleClick() {
// 触发 increase 事件,把消息交给父组件,自己并不直接修改 count
emit('increase')
}
</script>
<!-- 父组件 App.vue -->
<template>
<LikeButton :count="likeCount" @increase="likeCount++" />
</template>
<script setup>
import { ref } from 'vue'
import LikeButton from './LikeButton.vue'
const likeCount = ref(0)
</script>
这里能看出为什么"子组件不能直接改父组件传来的数据":count 这个数字真正的存储位置是父组件的 likeCount,子组件只是"借来看一眼"。子组件点击按钮后,并没有自己把数字加一,而是通过 emit('increase') 喊了一声,父组件听到后自己执行 likeCount++。这样数据始终只有一个"来源"(父组件),不会出现子组件和父组件各自维护一份、彼此不同步的情况。
defineProps 里的 Number 是什么意思
const props = defineProps({
count: Number
})
这里 Number 不是给 count 赋值,而是声明这个 prop 的类型应该是数字。Vue 在开发模式下会做类型校验——如果父组件不小心传了字符串 "5" 而不是数字 5,控制台会给出警告,帮你更早发现问题。常见类型还有 String、Boolean、Array、Object、Function。如果只是想快速写、不关心类型校验,也可以简化成只写名字的数组 defineProps(['count']),但拿不到类型检查和默认值这些好处。
父组件的 JS 里并没有定义 count,它是怎么传进来的
回顾父组件那一行:<LikeButton :count="likeCount" @increase="likeCount++" />。等号两边其实是两件不同的事:
- 左边的
count:这是"目标 prop 的名字",必须和子组件defineProps里声明的名字对上,子组件写的是count,父组件这里就必须写count,不能随便改。 - 右边的
"likeCount":这是一个 JS 表达式,取的是父组件自己ref(0)定义的那个变量当前的值。
整句话翻译成人话就是:"把我这边 likeCount 变量的值,传给子组件那个叫 count 的 prop"。父组件的变量完全可以叫任何名字,两边变量名不需要一致,就像调用函数时"形参名"和"传入的实参变量名"本来就不需要一样:
function greet(name) { ... } // 形参叫 name
const myUsername = 'Tom'
greet(myUsername) // 调用时传的变量叫 myUsername,不需要也叫 name
为什么要在模板里写,不能直接在 JS 里写吗
其实模板本身最终会被编译成 JS 代码,:count="likeCount" 已经是"在 JS 里写"了,只是换了一种更简洁的写法。去掉语法糖,这行标签编译后大致等价于:
h(LikeButton, {
count: likeCount.value, // 把当前值传进去
onIncrease: () => { likeCount++ } // 把处理函数传进去
})
h() 可以理解成"创建一个组件实例,把参数传给它",和普通函数调用长得很像。所以模板并不是"JS 之外多出来的一层",它就是 JS 的另一种写法——只是更接近 HTML、更好读,同时 Vue 还能在编译阶段基于模板结构做很多手写 JS 很难做到的性能优化,这也是几乎没人手写 h() 的原因。
如果数据是接口返回的 JSON 数组,怎么传
思路完全一样,只是把"手写的初始值"换成"网络请求回来的结果",通常有两种拆法:
拆法一:整个数组传给一个子组件,子组件自己遍历
<!-- 父组件 -->
<template>
<UserList :users="users" />
</template>
<script setup>
import { ref, onMounted } from 'vue'
import UserList from './UserList.vue'
const users = ref([]) // 先给个空数组占位,接口没返回前页面不会报错
onMounted(async () => {
const res = await fetch('/api/users')
users.value = await res.json() // 用接口返回的数组,整体替换掉 .value
})
</script>
<!-- 子组件 UserList.vue -->
<template>
<ul>
<li v-for="user in users" :key="user.id">{{ user.name }}</li>
</ul>
</template>
<script setup>
defineProps({ users: Array })
</script>
拆法二:v-for 写在父组件,每一项对应一个独立的子组件实例(适合每一项都要单独处理点击、删除等交互,做法和后面第十二节的 TodoItem 例子一致):
<template>
<UserCard v-for="user in users" :key="user.id" :user="user" />
</template>
两种拆法怎么选:如果子组件只是整体展示、不需要每一项单独交互,用拆法一更省事;如果每一项都要有自己独立的操作(勾选、删除、展开),倾向于拆法二。无论哪种,核心都不变:接口数据先存进一个 ref,等数据到位后通过 :prop名="变量名" 传下去,Vue 检测到 .value 变化会自动重新渲染。
还有一个概念叫 Slot(插槽):允许父组件往子组件"内部"塞一段自定义内容,类似普通 HTML 标签里可以嵌套任意子元素。刚开始不需要马上用到,知道有这个东西即可。
七、计算属性与侦听器
- 计算属性
computed:由已有数据自动算出的新值,比如根据购物车里的商品,自动算出总价。它会缓存结果,只有依赖的数据变化时才重新计算。 - 侦听器
watch:监听某个数据变化,一旦变化就执行一段逻辑(比如变化时去发一次网络请求)。
两者对照着看更清楚:
import { ref, computed, watch } from 'vue'
const price = ref(10)
const quantity = ref(2)
// computed:声明"总价 = 单价 × 数量",quantity 或 price 变了它自动重新算
const total = computed(() => price.value * quantity.value)
// watch:监听 quantity,变化时"顺便"做一件事——这里是打印日志,
// 实际项目里常见的是发一次网络请求,比如根据数量重新查库存
watch(quantity, (newValue, oldValue) => {
console.log(`数量从 ${oldValue} 变成了 ${newValue}`)
})
注意 total 用起来就像一个普通的 ref(模板里直接写 {{ total }} 即可),但你不需要手动更新它——只要 price 或 quantity 变了,total 自动跟着重新计算,这也是"声明式"思路的又一体现:你只声明了"总价是怎么算出来的",不需要在每个改动 price/quantity 的地方,额外去手写"顺便也把 total 更新一下"。
区分方法很简单:只是想"展示一个算出来的值" → 用 computed;数据变了要"顺便做点别的事情"(不是单纯展示)→ 用 watch。
八、生命周期:组件的"一生"
一个组件从出现在页面上、到之后被移除,会经历几个固定阶段,Vue 允许你在特定阶段插入代码,最常用的是:
- 挂载完成时:组件已经出现在页面上了,适合在这时发起网络请求、拿数据。
- 卸载前:组件即将从页面消失,适合清理定时器等,避免内存泄漏。
代码上分别对应 onMounted 和 onBeforeUnmount 两个函数:
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
const users = ref([])
let timer = null
// 组件刚出现在页面上时执行一次:常见用途是请求数据
onMounted(async () => {
const res = await fetch('/api/users')
users.value = await res.json()
// 假设还要每隔 5 秒刷新一次
timer = setInterval(() => {
console.log('刷新一次数据')
}, 5000)
})
// 组件即将被移除前执行:清理掉上面开的定时器,否则组件消失了定时器还在后台跑
onBeforeUnmount(() => {
clearInterval(timer)
})
</script>
这里能看出为什么"挂载时开的东西,卸载前要记得关掉"很重要:如果不清理 timer,即使这个组件已经从页面上消失了(比如用户切换到了别的路由),这个定时器仍然会在后台一直执行,长期积累会拖慢整个应用,这类问题叫"内存泄漏"。
刚开始只需要记住"组件出现在页面上那一刻,可以在这里发请求、做初始化;组件消失前,把这里开的东西关掉",其余生命周期阶段用到时再查。
九、单页应用为什么需要"路由"
还记得前面提到项目里只有一个 index.html 吗?"单页应用"里所谓"切换页面",其实是在不刷新、不重新加载 HTML 的情况下,动态替换当前显示的组件——这套机制由 vue-router 这个库提供:
- 你写一份"路径 → 组件"的对照表,比如
/对应首页组件,/about对应关于页组件; - 用户点击链接或地址栏变化时,路由库监听到变化,自动切换显示对应组件;
- 路径里还可以带参数,比如
/user/123里的123可以在组件里读到,用来加载指定用户的信息。
只要你的应用有"不止一个页面/视图",基本就离不开路由。
十、状态管理:数据要不要"全局共享"
组件不多的时候,用 props/emit 逐层传数据没问题。但项目变大后,经常出现"很多互不相关的组件都要用同一份数据"(比如当前登录用户信息),一层层传参会很啰嗦、很容易出错。
这时候用状态管理库(Vue 生态目前推荐 Pinia):把这类公共数据放到一个全局的地方统一管理,任何组件都能直接读取或修改,不用层层传递。
用代码感受一下"全局共享"具体是什么样。先定义一个"仓库"(Pinia 里叫 store),把用户信息放进去:
// stores/user.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUserStore = defineStore('user', () => {
const name = ref('')
const isLoggedIn = ref(false)
function login(userName) {
name.value = userName
isLoggedIn.value = true
}
function logout() {
name.value = ''
isLoggedIn.value = false
}
// 把想暴露给外部使用的数据和方法 return 出去
return { name, isLoggedIn, login, logout }
})
之后,任何组件都可以直接引入这个 store 来读取或修改用户信息,不需要经过任何中间组件传递:
<!-- 比如导航栏组件 NavBar.vue,和登录表单完全不是父子关系 -->
<template>
<span v-if="userStore.isLoggedIn">你好,{{ userStore.name }}</span>
<span v-else>未登录</span>
</template>
<script setup>
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
</script>
对比一下就能体会区别:如果不用 Pinia,NavBar.vue 要拿到用户信息,得靠某个共同的祖先组件把数据一层层通过 props 往下传,中间路过的每个组件都要"帮忙转发",即使它们自己根本用不到这份数据。有了 Pinia,NavBar.vue 直接自己去"仓库"里取,不牵扯任何中间组件。
判断标准:只有一两个相邻组件要用的数据,不需要它;很多分散在各处的组件都要用的数据,才考虑它。
十一、和 Claude Code 配合开发的建议
- 先想清楚"页面"和"数据",再让它动手:这个应用大致有哪几个页面(对应路由)、哪些数据需要跨组件共享(可能需要 Pinia),想清楚这两点,给出的需求会更清楚,生成的代码结构也会更合理。
- 按组件粒度提需求:与其说"帮我做个后台系统",不如说"做一个用户列表组件,接收 users 数组作为 props,点击删除按钮时触发事件通知父组件"——描述里带上 props / emit / v-for 这些概念,Claude Code 生成的代码会更贴近你的预期。
- 改完代码,回浏览器看热更新的效果:这是你验证一次改动对不对最直接的方式,不需要每次都读懂全部代码细节。
- 遇到报错先定位在哪一层:模板语法写错(指令用错)、响应式数据没包对(该用 ref 却当普通变量用)、组件通信断了(props 没传对、emit 没监听)——这是新手最常见的三类问题,报错信息通常会有线索,可以直接把报错贴给 Claude Code 帮你定位。
十二、完整例子:多个组件协作出一个"待办事项"页面
前面每一节都是孤立的小片段,这里把 响应式数据、模板语法、组件、props、emit 串起来,做一个真实一点的例子:一个输入框加一个按钮用来新增待办事项,下面是列表,每一项可以勾选完成、可以删除。整体拆成三个组件:
App.vue(父组件,管理数据)
├─ TodoInput.vue(子组件,负责输入和新增)
└─ TodoItem.vue(子组件,负责单条展示和操作,会被 v-for 渲染多份)
为什么这么拆:待办事项列表本身(数据)只有一份,理应放在最外层的 App.vue 统一管理;"输入新增"和"单条展示"是两个相对独立、职责不同的界面单元,拆成组件后各自逻辑更简单,也方便以后复用。
<!-- TodoInput.vue:只负责"输入 + 触发新增事件",自己不存储列表 -->
<template>
<input v-model="text" placeholder="输入待办事项,回车添加" @keyup.enter="handleAdd" />
</template>
<script setup>
import { ref } from 'vue'
const text = ref('')
const emit = defineEmits(['add'])
function handleAdd() {
if (text.value.trim() === '') return
emit('add', text.value) // 把输入的文字,通过事件交给父组件
text.value = '' // 清空输入框,自己的状态自己管
}
</script>
<!-- TodoItem.vue:只负责展示"一条"数据,以及把用户操作报告给父组件 -->
<template>
<li>
<input
type="checkbox"
:checked="todo.done"
@change="emit('toggle', todo.id)"
/>
<span :class="{ done: todo.done }">{{ todo.text }}</span>
<button @click="emit('remove', todo.id)">删除</button>
</li>
</template>
<script setup>
defineProps({
todo: Object // 接收一条待办事项 { id, text, done }
})
const emit = defineEmits(['toggle', 'remove'])
</script>
<style scoped>
.done { text-decoration: line-through; color: #999; }
</style>
<!-- App.vue:唯一真正存储列表数据的地方,负责响应两个子组件的事件 -->
<template>
<h2>待办事项({{ remainingCount }} 项未完成)</h2>
<TodoInput @add="addTodo" />
<ul>
<TodoItem
v-for="todo in todos"
:key="todo.id"
:todo="todo"
@toggle="toggleTodo"
@remove="removeTodo"
/>
</ul>
</template>
<script setup>
import { ref, computed } from 'vue'
import TodoInput from './TodoInput.vue'
import TodoItem from './TodoItem.vue'
const todos = ref([
{ id: 1, text: '学习 Vue', done: false },
{ id: 2, text: '搭建项目', done: true }
])
// 由 todos 派生出的值:未完成的数量,不需要手动维护
const remainingCount = computed(() => todos.value.filter(t => !t.done).length)
function addTodo(text) {
todos.value.push({ id: Date.now(), text, done: false })
}
function toggleTodo(id) {
const target = todos.value.find(t => t.id === id)
if (target) target.done = !target.done
}
function removeTodo(id) {
todos.value = todos.value.filter(t => t.id !== id)
}
</script>
顺着数据流走一遍,把前面几节的概念对上号:
- 新增:用户在
TodoInput里打字(v-model双向绑定到它自己的text),回车触发handleAdd,通过emit('add', text.value)向上把新内容交给App.vue;App.vue监听到@add,执行addTodo,往todos(唯一数据源)里追加一条。 - 展示:
todos这份数据通过v-for渲染出多个TodoItem,每个都通过:todo="todo"向下把自己那一条数据传下去(props)——这就是"数据往下传"。 - 勾选/删除:
TodoItem自己不负责修改done状态或从数组里删除,它只负责在用户操作时emit('toggle', todo.id)/emit('remove', todo.id)向上报告"发生了什么、是哪一条",真正的修改逻辑(toggleTodo/removeTodo)仍然写在持有数据的App.vue里——这就是"消息往上报"。 - 自动更新的部分:
remainingCount用computed声明,todos增删或某一项done变化时,它会自动重新计算,<h2>里的数字不需要任何手动更新代码。
整个例子里,todos 这份数据自始至终只存在于 App.vue 一个地方,两个子组件都只是"临时借用"(props)或"报告事件"(emit),不会出现两处数据不同步的情况——这也是为什么前面反复强调"数据往下传,消息往上报"这个方向的原因。等你熟悉这个例子后,可以试着让 Claude Code 在此基础上加一个"按完成状态筛选"的功能,用来检验自己是否看懂了这条数据流。
小结
把这篇文档串起来看,其实是一条逐渐展开的线索:原生 JS 手动改 DOM 太麻烦 → Vue 用响应式数据 + 声明式模板自动接管这件事 → 用 .vue 文件把结构/逻辑/样式装进一个组件 → 用 props/emit 把多个组件拼成一个应用 → 用路由处理多页面、用 Pinia 处理跨组件共享数据。语法细节不用死记,边用 Claude Code 开发、边对照这条线索理解代码在做什么,会比一开始就抠语法更有效。