Vue3 入门教程:从创建项目到写出第一个组件化 Todo 案例
适合读者:会一点 HTML、CSS、JavaScript,想从零开始入门 Vue3,但不想一开始就被一堆概念绕晕。
这篇文章不准备把 Vue3 的所有知识点都塞进来,而是带你按真实学习顺序走一遍:
- 先知道 Vue3 是什么;
- 再创建一个能运行的项目;
- 然后认识
.vue单文件组件; - 接着学习
script setup、ref、reactive、computed、watch; - 最后用一个 Todo 案例把组件拆分、
props和emit串起来。
学完之后,你应该能看懂一个 Vue3 入门项目的基本结构,也知道后面该继续学什么。
一、Vue3 到底是什么
Vue 是一个用于构建用户界面的 JavaScript 框架。简单说,它主要帮我们解决三件事:
- 页面怎么根据数据自动渲染;
- 数据变了,页面怎么自动更新;
- 一个复杂页面怎么拆成多个可复用组件。
可以先用一张图建立整体印象:

新手先记住一句话:
Vue3 的核心,就是用响应式数据驱动页面,再用组件把页面拆开。
二、准备环境并创建项目
Vue 官方推荐使用 create-vue 来创建基于 Vite 的 Vue 项目。当前官方快速上手文档要求 Node.js 版本为 ^20.19.0 || >=22.12.0。
先检查 Node 版本:
node -v
创建项目:
npm create vue@latest
创建过程中会出现一些选项,新手可以这样选:
| 选项 | 建议 |
|---|---|
| Project name | 自己起一个项目名 |
| TypeScript | 先选 No,入门更轻 |
| JSX | No |
| Vue Router | 入门第一篇可以 No |
| Pinia | 入门第一篇可以 No |
| Vitest / E2E | No |
| ESLint / Prettier | 可以先 No,后面再学 |
| Vue DevTools | 可以 Yes,也可以先 No |
进入项目并启动:
cd 项目名
npm install
npm run dev
启动成功后,终端会给你一个本地地址,例如:
http://localhost:5173/
在浏览器打开它,就能看到 Vue 项目页面。
整个流程可以理解为:

三、认识 Vue 项目目录
创建后的项目里,新手最先关注这几个文件就够了:
src
├─ main.js
├─ App.vue
└─ components
└─ HelloWorld.vue
它们的作用:
| 文件 | 作用 |
|---|---|
main.js | 项目入口,负责创建 Vue 应用并挂载到页面 |
App.vue | 根组件,项目启动后最先渲染的组件 |
components | 放可复用组件的目录 |
main.js 常见内容类似这样:
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
这段代码的意思是:
- 从
vue里导入createApp; - 导入根组件
App.vue; - 创建应用;
- 挂载到
index.html里的id="app"节点上。
四、认识 .vue 单文件组件
Vue 项目里最常见的文件是 .vue 文件,它叫单文件组件。
一个 .vue 文件通常由三部分组成:
<script setup>
// 写 JavaScript 逻辑
</script>
<template>
<!-- 写页面结构 -->
</template>
<style scoped>
/* 写当前组件样式 */
</style>
三块分别对应:
| 区域 | 作用 |
|---|---|
<script setup> | 写数据、方法、导入组件等逻辑 |
<template> | 写页面结构 |
<style scoped> | 写样式,scoped 表示样式只影响当前组件 |
先写一个最小计数器:
<script setup>
import { ref } from 'vue'
const count = ref(0)
function add() {
count.value++
}
</script>
<template>
<button @click="add">当前数量:{{ count }}</button>
</template>
这里已经出现了 Vue3 入门最重要的几个东西:
ref():定义响应式数据;count.value++:在脚本中修改ref的值;{{ count }}:在模板中显示数据;@click="add":点击按钮时执行add方法。
五、为什么 Vue3 常用 script setup
Vue 组件有两种常见写法:
- 选项式 API;
- 组合式 API。
选项式 API 按选项分类组织代码:
<script>
export default {
data() {
return {
count: 0
}
},
methods: {
add() {
this.count++
}
}
}
</script>
组合式 API 更偏向按功能逻辑组织代码:
<script setup>
import { ref } from 'vue'
const count = ref(0)
function add() {
count.value++
}
</script>
在 Vue3 新项目里,常见写法就是:
<script setup>
// 组合式 API 逻辑
</script>
script setup 的好处是:
- 写法更短;
- 顶层变量和函数可以直接在模板中使用;
- 导入的组件可以直接在模板中使用;
- 和 TypeScript 配合更自然;
- 更适合后面封装自定义 hook。
新手先记住:
学 Vue3 新项目,重点掌握组合式 API 和
<script setup>。
六、响应式数据:ref 和 reactive
Vue 的响应式可以理解为:
数据变了,页面会自动跟着变。
1. 使用 ref 定义简单数据
<script setup>
import { ref } from 'vue'
const username = ref('')
function changeName() {
username.value = '小明'
}
</script>
<template>
<p>用户名:{{ username }}</p>
<button @click="changeName">修改用户名</button>
</template>
注意:
- 脚本中读取或修改
ref,要写.value; - 模板中使用
ref,通常不写.value。
2. 使用 reactive 定义对象
<script setup>
import { reactive } from 'vue'
const form = reactive({
username: '',
password: ''
})
function login() {
console.log(form.username, form.password)
}
</script>
<template>
<input v-model="form.username" placeholder="用户名">
<input v-model="form.password" placeholder="密码">
<button @click="login">登录</button>
</template>
reactive 适合对象、数组这类复杂数据。
3. ref 和 reactive 怎么选
| 场景 | 推荐 |
|---|---|
| 数字、字符串、布尔值 | ref |
| 表单输入值 | ref 或 reactive |
| 普通对象 | reactive 或 ref |
| 数组 | reactive 或 ref |
| 需要整体替换对象 | ref |
| 需要解构对象属性 | reactive + toRefs |
简单记忆:
- 简单值用
ref; - 对象数组可用
reactive; - 不确定怎么选,先用
ref; reactive解构后容易丢响应式,需要配合toRefs。
七、用 Todo 案例串起核心语法
下面写一个简单 Todo 案例,包含:
- 输入任务;
- 添加任务;
- 完成任务;
- 删除任务;
- 统计未完成数量;
- 监听任务变化。
先在 App.vue 中写单组件版:
<script setup>
import { ref, computed, watch } from 'vue'
const newTitle = ref('')
const todos = ref([
{ id: 1, title: '学习 script setup', done: true },
{ id: 2, title: '学习 ref 和 reactive', done: false }
])
const unfinishedCount = computed(() => {
return todos.value.filter(todo => !todo.done).length
})
function addTodo() {
const title = newTitle.value.trim()
if (!title) return
todos.value.push({
id: Date.now(),
title,
done: false
})
newTitle.value = ''
}
function toggleTodo(id) {
const todo = todos.value.find(item => item.id === id)
if (todo) {
todo.done = !todo.done
}
}
function removeTodo(id) {
todos.value = todos.value.filter(item => item.id !== id)
}
watch(todos, (newValue) => {
console.log('任务列表变化了:', newValue)
}, {
deep: true
})
</script>
<template>
<main class="page">
<h1>Vue3 Todo 入门案例</h1>
<section class="todo-input">
<input
v-model="newTitle"
placeholder="请输入任务"
@keyup.enter="addTodo"
>
<button @click="addTodo">添加</button>
</section>
<p>未完成任务:{{ unfinishedCount }}</p>
<ul>
<li v-for="todo in todos" :key="todo.id">
<label>
<input
type="checkbox"
:checked="todo.done"
@change="toggleTodo(todo.id)"
>
<span :class="{ done: todo.done }">{{ todo.title }}</span>
</label>
<button @click="removeTodo(todo.id)">删除</button>
</li>
</ul>
</main>
</template>
<style scoped>
.page {
max-width: 640px;
margin: 40px auto;
font-family: Arial, sans-serif;
}
.todo-input {
display: flex;
gap: 8px;
}
input {
padding: 8px;
}
button {
padding: 8px 12px;
}
li {
display: flex;
justify-content: space-between;
margin: 8px 0;
}
.done {
color: #999;
text-decoration: line-through;
}
</style>
这个案例里,几个核心 API 的作用很清楚:
| API | 在案例中的作用 |
|---|---|
ref | 保存输入框内容和任务列表 |
computed | 根据任务列表计算未完成数量 |
watch | 监听任务列表变化 |
v-model | 表单输入和数据双向绑定 |
v-for | 渲染任务列表 |
:class | 根据任务状态动态添加样式 |
@click | 绑定点击事件 |
数据流可以这样理解:

八、computed 和 watch 的区别
新手容易混淆 computed 和 watch。
1. computed 用来得到一个新数据
例如未完成任务数量:
const unfinishedCount = computed(() => {
return todos.value.filter(todo => !todo.done).length
})
它的特点:
- 依赖的数据不变,就会使用缓存;
- 适合根据已有数据计算出一个新结果;
- 不建议在
computed里发请求、改状态、操作 DOM。
2. watch 用来监听变化并做事情
例如监听任务列表变化:
watch(todos, (newValue) => {
console.log('任务变化了:', newValue)
}, {
deep: true
})
它的特点:
- 可以明确监听某个数据;
- 数据变化后执行回调;
- 适合发请求、保存本地缓存、打印日志等副作用。
一句话区分:
computed关心“算出什么值”,watch关心“变化后做什么事”。
九、把 Todo 拆成组件
单组件版能运行,但真实项目里不会把所有代码都塞进 App.vue。
可以拆成这样:
src
├─ App.vue
└─ components
├─ TodoInput.vue
├─ TodoList.vue
└─ TodoItem.vue
组件关系:
flowchart LR
A[App.vue 父组件] -- props: todos --> B[TodoList.vue]
B -- props: todo --> C[TodoItem.vue]
C -- emit: toggle/remove --> B
B -- emit: toggle/remove --> A
A -- 更新 todos --> B
1. TodoInput.vue
<script setup>
import { ref } from 'vue'
const emit = defineEmits(['add'])
const title = ref('')
function submit() {
const value = title.value.trim()
if (!value) return
emit('add', value)
title.value = ''
}
</script>
<template>
<div class="todo-input">
<input
v-model="title"
placeholder="请输入任务"
@keyup.enter="submit"
>
<button @click="submit">添加</button>
</div>
</template>
这里的重点是:
- 子组件内部维护输入框内容;
- 添加时通过
emit('add', value)通知父组件; - 真正修改任务列表的逻辑仍然放在父组件。
2. TodoItem.vue
<script setup>
const props = defineProps({
todo: {
type: Object,
required: true
}
})
const emit = defineEmits(['toggle', 'remove'])
</script>
<template>
<li>
<label>
<input
type="checkbox"
:checked="props.todo.done"
@change="emit('toggle', props.todo.id)"
>
<span :class="{ done: props.todo.done }">
{{ props.todo.title }}
</span>
</label>
<button @click="emit('remove', props.todo.id)">删除</button>
</li>
</template>
这里的重点是:
props接收父组件传来的任务;- 子组件不直接修改父组件数据;
- 通过
emit把操作通知出去。
3. TodoList.vue
<script setup>
import TodoItem from './TodoItem.vue'
const props = defineProps({
todos: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['toggle', 'remove'])
</script>
<template>
<ul>
<TodoItem
v-for="todo in props.todos"
:key="todo.id"
:todo="todo"
@toggle="emit('toggle', $event)"
@remove="emit('remove', $event)"
/>
</ul>
</template>
这里的重点是:
TodoList负责列表循环;TodoItem负责单个任务展示;- 子组件事件可以继续向上传递。
4. App.vue
<script setup>
import { ref, computed } from 'vue'
import TodoInput from './components/TodoInput.vue'
import TodoList from './components/TodoList.vue'
const todos = ref([
{ id: 1, title: '学习 script setup', done: true },
{ id: 2, title: '学习 ref 和 reactive', done: false }
])
const unfinishedCount = computed(() => {
return todos.value.filter(todo => !todo.done).length
})
function addTodo(title) {
todos.value.push({
id: Date.now(),
title,
done: false
})
}
function toggleTodo(id) {
const todo = todos.value.find(item => item.id === id)
if (todo) {
todo.done = !todo.done
}
}
function removeTodo(id) {
todos.value = todos.value.filter(item => item.id !== id)
}
</script>
<template>
<main class="page">
<h1>Vue3 Todo 入门案例</h1>
<TodoInput @add="addTodo" />
<p>未完成任务:{{ unfinishedCount }}</p>
<TodoList
:todos="todos"
@toggle="toggleTodo"
@remove="removeTodo"
/>
</main>
</template>
拆分后,每个组件职责更清楚:
| 组件 | 职责 |
|---|---|
App.vue | 保存任务数据,定义增删改逻辑 |
TodoInput.vue | 输入并提交新任务 |
TodoList.vue | 渲染任务列表 |
TodoItem.vue | 渲染单个任务并触发操作 |
十、组件通信:props 和 emit
Vue 组件通信入门先掌握两个方向:
1. 父传子:props
父组件:
<TodoList :todos="todos" />
子组件:
const props = defineProps({
todos: {
type: Array,
default: () => []
}
})
props 的本质是:父组件把数据传给子组件。
注意:
- 子组件要先声明 props;
- props 是单向数据流;
- 子组件不建议直接修改 props;
- 数组和对象默认值要写成函数,例如
default: () => []。
2. 子传父:emit
子组件:
const emit = defineEmits(['add'])
emit('add', title)
父组件:
<TodoInput @add="addTodo" />
emit 的本质是:子组件触发事件,父组件监听事件。
新手可以这样记:
父传子用
props,子通知父用emit。
十一、生命周期:什么时候执行初始化逻辑
Vue 组件从创建到销毁,会经历一系列阶段。入门阶段先掌握两个钩子:
| 钩子 | 常见用途 |
|---|---|
onMounted | 组件挂载后执行,比如请求数据、访问 DOM |
onBeforeUnmount | 组件卸载前执行,比如清理定时器、事件监听 |
示例:
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
const seconds = ref(0)
let timer = null
onMounted(() => {
timer = setInterval(() => {
seconds.value++
}, 1000)
})
onBeforeUnmount(() => {
clearInterval(timer)
})
</script>
<template>
<p>页面停留时间:{{ seconds }} 秒</p>
</template>
注意:
- 生命周期钩子要在
setup阶段同步注册; - 定时器、事件监听、订阅类逻辑要记得清理;
- 不要在
onUpdated里随便修改会触发更新的数据,否则容易造成循环更新。
十二、常见新手坑
1. 脚本中修改 ref 忘记写 .value
错误:
count++
正确:
count.value++
2. 模板中多写 .value
模板中通常直接写:
<p>{{ count }}</p>
不用写:
<p>{{ count.value }}</p>
3. reactive 解构后丢响应式
const state = reactive({ count: 0 })
const { count } = state
如果需要解构,使用 toRefs:
const { count } = toRefs(state)
4. watch 监听对象属性时写错
监听对象中的某个属性,常用 getter:
watch(() => person.value.name, (newValue, oldValue) => {
console.log(newValue, oldValue)
})
5. 子组件直接修改 props
不要这样:
props.todo.done = true
更推荐:
emit('toggle', props.todo.id)
让父组件去修改自己的数据。
十三、后面继续学什么
学完这篇文章后,可以按这个顺序继续:


推荐路线:
| 学习内容 | 作用 |
|---|---|
| Vue Router | 根据 URL 切换页面组件,适合多页面 SPA |
| Pinia | 管理多个组件共享的数据 |
| 自定义 hook | 封装和复用组合式 API 逻辑 |
| 插槽 | 让父组件决定子组件内部某块结构 |
| TypeScript | 给数据、props、事件增加类型约束 |
入门阶段不要急着同时学完所有东西。先把 script setup、ref、computed、props、emit 这些常用能力练熟,再进入 Router 和 Pinia,会顺很多。
十四、总结
这篇文章从创建项目开始,带你走了一遍 Vue3 入门主线:
- Vue3 主要解决声明式渲染、响应式、组件化;
- Vue3 新项目常用单文件组件和
<script setup>; ref、reactive用来定义响应式数据;computed适合计算派生数据;watch适合监听变化后执行副作用;- 父传子用
props; - 子通知父用
emit; - 复杂页面要拆成多个组件;
- 后续再继续学习 Vue Router、Pinia、自定义 hook。
最后再用一句话收尾:
Vue3 入门不是背 API,而是先理解“数据驱动页面、组件拆分页面、事件连接组件”这条主线。
参考资料
更多推荐
所有评论(0)