Vue 3 零基础入门
这是一篇写给前端新手的长文。目标只有一个:读完它能自己动手跑起一个 Vue 3 项目,并独立完成一个功能完整的待办清单应用。
写在前面
很多人学完 HTML、CSS、JavaScript 三件套之后会卡在同一个地方:知识点都懂,但一让他们做真实项目就无从下手。Vue 就是为解决这个问题而生的——它不要求你换掉已有知识,而是在其上叠了一层"组织代码的方式"。
这篇文章是我学习和使用 Vue 3 过程中整理的入门笔记,覆盖从环境搭建、核心语法、组件通信到一个完整 TodoList 实战的全过程,最后附上新手最常踩的几个坑。
一、为什么是 Vue
先回答一个根本问题:不用框架行不行?行,但会很痛苦。
用原生 JS 或 jQuery 写页面,你的大部分代码都在做同一件事:找到某个 DOM 元素,改它的内容、样式或结构。数据一变,你就得手动把所有相关的地方都更新一遍——漏掉一处就是 bug。
Vue 的核心思路正好相反:你只管维护数据,页面是数据的投影。 数据变了,Vue 帮你把 DOM 更新好。可以类比 Excel:你改一个单元格的数值,引用它的图表自动跟着变,你不需要手动去重画图表。
| 对比项 | 原生 JS / jQuery | Vue |
|---|---|---|
| 更新页面的方式 | 手动查找并修改 DOM | 修改数据,视图自动同步 |
| 代码风格 | 命令式,一步步告诉浏览器怎么做 | 声明式,描述页面应该长什么样 |
| 代码复用 | 靠复制粘贴 | 组件化,天然复用 |
| 项目规模 | 越大越难维护 | 结构清晰,规模友好 |
至于"为什么选 Vue 而不是其他框架":
- 国内使用率极高,招聘要求里出现频率最高,社区中文资源丰富;
- 渐进式设计,可以在旧项目里局部使用,也可以全家桶整体上;
- 对新手友好,模板语法接近原生 HTML,上手曲线平缓。
后台管理系统的典型场景就是 Vue 的主场:大量表格、表单、弹窗、权限切换,用数据驱动的方式写起来最省力。
二、先记住五个词
正式写代码前,把这五个词的字面意思搞清楚,后面的学习会顺畅很多:
| 概念 | 一句话解释 |
|---|---|
| 响应式 | 数据和页面保持联动的机制,改数据等于改页面 |
| 指令 | 模板里 v- 开头的特殊属性,给 HTML 附加行为 |
| 组件 | 把一段模板 + 逻辑 + 样式封装成可复用的独立单元 |
| 模板语法 | 在 HTML 里写 JS 表达式的方式,比如 {{ }} |
| 生命周期 | 组件从创建、挂载、更新到卸载的各个阶段 |
三、搭建环境
3.1 安装 Node.js
Vue 的开发和构建工具链跑在 Node.js 上,先去官网装 LTS 版本:https://nodejs.org/
装完打开终端验证:
node -v
npm -v
两个命令都能输出版本号即可。
3.2 创建项目
Vue 官方脚手架是 create-vue,基于 Vite,一条命令搞定:
npm create vue@latest my-vue-app
命令行会依次询问可选项,新手阶段建议这样选:
TypeScript? → No (先专注 Vue 本身,TS 以后再加)
JSX Support? → No
Vue Router? → Yes (单页应用路由,迟早要用)
Pinia? → Yes (状态管理,迟早要用)
Vitest (单元测试)? → No
End-to-End Testing? → No
ESLint? → Yes
Prettier? → Yes
然后进入目录、装依赖、启动:
cd my-vue-app
npm install
npm run dev
终端输出类似这样就成功了:
VITE v5.x.x ready in xxx ms
➜ Local: http://localhost:5173/
浏览器打开 http://localhost:5173/,看到欢迎页即环境就绪。
3.3 认识项目结构
my-vue-app/
├── node_modules/ # 第三方依赖,不用手动管
├── public/ # 不参与构建的静态资源
├── src/
│ ├── assets/ # 图片、全局样式等资源
│ ├── components/ # 可复用组件
│ ├── router/ # 路由配置
│ ├── stores/ # Pinia 状态管理
│ ├── views/ # 页面级组件
│ ├── App.vue # 根组件
│ └── main.js # 应用入口
├── index.html # 单页应用的 HTML 壳
├── package.json # 依赖与脚本声明
└── vite.config.js # Vite 配置
现阶段只需要关注四个:src/App.vue(根组件)、src/components/(放组件)、src/views/(放页面)、package.json(依赖清单)。
四、核心语法
4.1 模板插值:{{ }}
双大括号里可以放任何 JS 表达式,Vue 会把结果渲染进页面:
<template>
<div>
<h1>{{ title }}</h1>
<p>总数:{{ list.length }}</p>
<p>当前状态:{{ online ? '在线' : '离线' }}</p>
<p>{{ title.toUpperCase() }}</p>
</div>
</template>
<script setup>
import { ref } from 'vue'
const title = ref('Hello Vue 3')
const list = ref(['a', 'b', 'c'])
const online = ref(true)
</script>
注意花括号里是表达式(有返回值),不能写语句(比如 if、for)。
4.2 响应式:ref 与 reactive
Vue 3 提供两个 API 把普通数据变成响应式数据:
import { ref, reactive } from 'vue'
// ref:万能选项,基本类型、对象、数组都能包
const count = ref(0)
const user = ref({ name: '老王', age: 30 })
count.value++ // script 里读写要 .value
user.value.age = 31 // ref 包对象时,改属性也是 .value 一下
// reactive:只能包对象/数组,用起来像普通对象
const form = reactive({
username: '',
password: ''
})
form.username = 'admin' // 不需要 .value
两者怎么选?我的建议是统一用 ref,规则只有一条(script 里加 .value),不容易出错。reactive 看懂别人的代码即可。
4.3 六个最常用的指令
① v-bind(简写 :)—— 绑定属性
<template>
<!-- 把变量绑到 HTML 属性上 -->
<img :src="avatarUrl" :alt="nickname">
<!-- 动态 class:对象写法,键为类名,值为布尔 -->
<button :class="{ active: selected }">按钮</button>
<!-- 动态 style -->
<p :style="{ color: danger ? 'red' : '#333' }">提示文字</p>
</template>
<script setup>
import { ref } from 'vue'
const avatarUrl = ref('/avatar.png')
const nickname = ref('我的头像')
const selected = ref(false)
const danger = ref(false)
</script>
② v-on(简写 @)—— 绑定事件
<template>
<p>当前计数:{{ count }}</p>
<button @click="count++">加一</button>
<button @click="reset(0)">清零</button>
<!-- 修饰符:阻止表单默认提交行为 -->
<form @submit.prevent="onSubmit">
<button type="submit">提交</button>
</form>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
function reset(val) {
count.value = val
}
function onSubmit() {
console.log('提交,页面不会刷新')
}
</script>
常用修饰符:.prevent(阻止默认行为)、.stop(阻止冒泡)、.once(只触发一次)。
③ v-if / v-show —— 条件渲染
<template>
<button @click="visible = !visible">切换</button>
<!-- v-if:条件不满足时元素不存在于 DOM -->
<p v-if="visible">v-if 控制的内容</p>
<!-- v-show:条件不满足时只是 display:none,元素还在 -->
<p v-show="visible">v-show 控制的内容</p>
<!-- 多分支 -->
<p v-if="score >= 90">优秀</p>
<p v-else-if="score >= 60">及格</p>
<p v-else>不及格</p>
</template>
<script setup>
import { ref } from 'vue'
const visible = ref(true)
const score = ref(72)
</script>
选择标准:切换频繁用 v-show(DOM 一直在,切换只是改样式),条件基本不变用 v-if(省掉不必要的 DOM)。
④ v-for —— 列表渲染
<template>
<ul>
<li v-for="task in tasks" :key="task.id">
{{ task.title }} —— {{ task.done ? '已完成' : '进行中' }}
</li>
</ul>
</template>
<script setup>
import { ref } from 'vue'
const tasks = ref([
{ id: 1, title: '装环境', done: true },
{ id: 2, title: '学指令', done: false },
{ id: 3, title: '写项目', done: false }
])
</script>
:key 必须加,它是 Vue 识别列表项身份的依据,优先用稳定唯一的 id,尽量不要用数组索引(索引在增删时会错位,导致渲染混乱)。
⑤ v-model —— 双向绑定
表单和数据的双向同步,一行搞定:
<template>
<input v-model="keyword" placeholder="搜索...">
<p>关键词:{{ keyword }}</p>
<label><input type="checkbox" v-model="agreed"> 我已阅读协议</label>
<p>{{ agreed }}</p>
<select v-model="city">
<option value="hz">杭州</option>
<option value="sh">上海</option>
</select>
<p>{{ city }}</p>
</template>
<script setup>
import { ref } from 'vue'
const keyword = ref('')
const agreed = ref(false)
const city = ref('hz')
</script>
原理上它是 :value + @input 的语法糖:输入框打字会更新数据,程序改数据也会更新输入框。
⑥ v-html —— 渲染富文本
数据里包含 HTML 标签、需要真实渲染时使用:
<p v-html="richContent"></p>
注意: v-html 只能用于可信内容,绝不能直接渲染用户输入,否则会被利用做 XSS 注入攻击。
4.4 computed:有缓存的派生数据
从一个(或多个)响应式数据"算"出另一个数据时用 computed,它会缓存结果,依赖不变就不重算:
<template>
<input v-model="price">
<input v-model="count" type="number">
<p>小计:¥{{ subtotal }}</p>
<p>含税(6%):¥{{ taxed }}</p>
</template>
<script setup>
import { ref, computed } from 'vue'
const price = ref(100)
const count = ref(2)
const subtotal = computed(() => price.value * count.value)
const taxed = computed(() => (subtotal.value * 1.06).toFixed(2))
</script>
computed 里也可以依赖另一个 computed(如上面的 taxed 依赖 subtotal)。
4.5 watch:监听变化执行副作用
需要在数据变化时做一些事(请求接口、打日志、弹提示)而不是算新值时,用 watch:
<script setup>
import { ref, watch } from 'vue'
const keyword = ref('')
watch(keyword, (newVal, oldVal) => {
console.log(`关键词从 ${oldVal} 变为 ${newVal}`)
// 实际项目里通常在这里做防抖搜索请求
})
</script>
两者分工一句话:要结果用 computed,要动作用 watch。
五、组件化与通信
5.1 组件是什么
组件是自带模板、逻辑、样式的独立积木。一个页面通常是这样拆的:
页面
├── 页头组件
├── 侧边栏组件
└── 内容区
├── 搜索栏组件
└── 列表组件(可复用到多个页面)
5.2 父传子:props
子组件用 defineProps 声明自己接收什么:
<!-- 子组件 TaskCard.vue -->
<template>
<div class="card">
<strong>{{ task.title }}</strong>
<span>{{ task.done ? '✅' : '⏳' }}</span>
</div>
</template>
<script setup>
defineProps({
task: {
type: Object,
required: true
}
})
</script>
<style scoped>
.card {
padding: 12px;
border: 1px solid #e2e2e2;
border-radius: 8px;
}
</style>
父组件像传 HTML 属性一样传数据:
<!-- 父组件 -->
<template>
<TaskCard v-for="t in tasks" :key="t.id" :task="t" />
</template>
<script setup>
import { ref } from 'vue'
import TaskCard from './components/TaskCard.vue'
const tasks = ref([
{ id: 1, title: '学 props', done: true },
{ id: 2, title: '学 emits', done: false }
])
</script>
props 是单向的:父传给子,子只能读不能改。
5.3 子传父:emits
子组件要改变数据时,通过事件把"意图"抛给父组件,由父组件执行:
<!-- 子组件 DeleteButton.vue -->
<template>
<button @click="onClick">删除</button>
</template>
<script setup>
const emit = defineEmits(['remove'])
function onClick() {
emit('remove', 1) // 把要删除的 id 抛出去
}
</script>
<!-- 父组件 -->
<template>
<DeleteButton @remove="handleRemove" />
</template>
<script setup>
import DeleteButton from './components/DeleteButton.vue'
function handleRemove(id) {
console.log('删除 id =', id)
}
</script>
口诀:props 向下传数据,emits 向上传事件。
六、实战:TodoList
知识点凑齐了,做一个覆盖全部核心语法的项目。功能目标:
- 输入回车添加待办
- 勾选切换完成状态
- 删除单条
- 按全部 / 未完成 / 已完成筛选
- 底部统计与一键清除已完成
6.1 完整组件代码
<!-- src/components/TodoList.vue -->
<template>
<div class="todo">
<h2>待办清单</h2>
<!-- 输入区 -->
<div class="row">
<input
v-model="draft"
placeholder="输入后回车添加"
@keyup.enter="add"
>
<button @click="add">添加</button>
</div>
<!-- 筛选区 -->
<div class="row filters">
<button
v-for="opt in filterOptions"
:key="opt.value"
:class="{ on: filter === opt.value }"
@click="filter = opt.value"
>
{{ opt.label }}
</button>
</div>
<!-- 列表区 -->
<ul>
<li v-for="t in visibleTodos" :key="t.id" :class="{ done: t.done }">
<input type="checkbox" v-model="t.done">
<span class="text">{{ t.text }}</span>
<button class="del" @click="remove(t.id)">删除</button>
</li>
<li v-if="visibleTodos.length === 0" class="empty">这里空空如也</li>
</ul>
<!-- 统计区 -->
<div class="row stats">
<span>未完成 {{ remaining }} 项 / 共 {{ todos.length }} 项</span>
<button v-if="remaining < todos.length" @click="clearDone">
清除已完成
</button>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
// ---------- 数据 ----------
const draft = ref('')
const todos = ref([
{ id: 1, text: '跑通第一个 Vue 项目', done: true },
{ id: 2, text: '学完核心语法', done: false }
])
let nextId = 3
const filter = ref('all')
const filterOptions = [
{ label: '全部', value: 'all' },
{ label: '未完成', value: 'active' },
{ label: '已完成', value: 'done' }
]
// ---------- 派生数据 ----------
const visibleTodos = computed(() => {
if (filter.value === 'active') return todos.value.filter(t => !t.done)
if (filter.value === 'done') return todos.value.filter(t => t.done)
return todos.value
})
const remaining = computed(() => todos.value.filter(t => !t.done).length)
// ---------- 操作 ----------
function add() {
const text = draft.value.trim()
if (!text) return
todos.value.push({ id: nextId++, text, done: false })
draft.value = ''
}
function remove(id) {
todos.value = todos.value.filter(t => t.id !== id)
}
function clearDone() {
todos.value = todos.value.filter(t => !t.done)
}
</script>
<style scoped>
.todo {
max-width: 480px;
margin: 40px auto;
padding: 24px;
border-radius: 12px;
background: #fafafa;
}
.row {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.row input[type='text'],
.row > input {
flex: 1;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 6px;
}
button {
padding: 8px 14px;
border: none;
border-radius: 6px;
background: #4f6ef7;
color: #fff;
cursor: pointer;
}
.filters button {
background: #fff;
color: #666;
border: 1px solid #ddd;
border-radius: 999px;
padding: 4px 14px;
}
.filters button.on {
background: #4f6ef7;
color: #fff;
border-color: #4f6ef7;
}
ul {
list-style: none;
padding: 0;
background: #fff;
border-radius: 8px;
}
li {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #f0f0f0;
}
li.done .text {
text-decoration: line-through;
color: #aaa;
}
.text {
flex: 1;
}
.del {
background: transparent;
color: #e5484d;
padding: 2px 8px;
}
.empty {
justify-content: center;
color: #bbb;
}
.stats {
justify-content: space-between;
font-size: 13px;
color: #666;
}
.stats button {
background: #fff;
color: #666;
border: 1px solid #ddd;
padding: 4px 10px;
}
</style>
6.2 挂到页面上
<!-- src/App.vue -->
<template>
<TodoList />
</template>
<script setup>
import TodoList from './components/TodoList.vue'
</script>
6.3 这个项目练到了什么
ref定义响应式数据(draft、todos、filter)v-model双向绑定(输入框、复选框)v-for+:key渲染列表v-if/v-show处理空状态和条件按钮@click、@keyup.enter事件处理computed派生筛选结果和统计数:class动态样式(筛选项高亮、完成态删除线)
能把每一行代码解释清楚,Vue 入门这一关就算过了。
七、新手常见的六个坑
坑 1:script 里忘写 .value
const count = ref(0)
count = 1 // 直接赋值会报错
count.value = 1 //
模板里不用加 .value(自动解包),只有 <script> 里需要。
坑 2:v-for 不加 key,或用索引当 key
增删列表项时索引会整体错位,Vue 就无法正确追踪每一项,出现渲染错乱。用稳定的 id 做 key。
坑 3:子组件直接改 props
props 对子组件是只读的,改了会收到警告。正确做法是 emit 事件交给父组件处理。
坑 4:解构 reactive 对象导致响应式丢失
const form = reactive({ name: '老王', age: 30 })
let { name } = form // 这里会报错 name 变成普通字符串,不再响应
要解构请用 toRefs(form),或者干脆整个对象用,不拆开。
坑 5:v-if 和 v-for 写在同一个标签上
Vue 3 中 v-if 的优先级高于 v-for,如果 v-if 里引用了循环变量会直接报错(因为执行到 v-if 时循环变量还不存在)。两个版本都明确不建议这样写,需要过滤列表时用 computed 先算好。
坑 6:style 忘加 scoped
组件里的 <style> 默认是全局的,类名一撞样式就互相污染。养成习惯,每个组件都写 <style scoped>。
八、速查表与下一步
核心速查
| 需求 | 用法 |
|---|---|
| 显示数据 | {{ expr }} |
| 定义响应式数据 | ref() / reactive() |
| 绑定属性 | :attr="value" |
| 绑定事件 | @event="handler" |
| 显示/隐藏 | v-if / v-show |
| 循环列表 | v-for="(item, i) in list" :key="item.id" |
| 表单双向绑定 | v-model |
| 派生数据(有缓存) | computed(() => ...) |
| 监听变化做副作用 | watch(source, cb) |
| 父传子 | props + defineProps |
| 子传父 | defineEmits + emit |
建议的学习路径
- 把本文的语法部分亲手敲一遍(不要复制粘贴);
- 独立复现 TodoList,再扩展两个功能练手:双击编辑内容、用
localStorage持久化数据; - 补上生命周期(
onMounted等)和 Vue Router(多页面跳转); - 学 Pinia 处理跨组件共享状态;
- 找一个真实的小需求(个人主页、记账工具)完整做下来。
入门的标志不是"看懂了",而是"能独立写出 TodoList 并解释每一行"。

浙公网安备 33010602011771号