Vue 是一个 JavaScript 框架,它本质上就是一堆 .js 文件。要开始用 Vue,你需要两样东西:
- Node.js — 一个能让你在电脑上运行 JavaScript 的环境(不仅仅是浏览器里)
- Vite — 一个帮你创建 Vue 项目的工具(脚手架) 💡 Tip:类比一下 如果把写 Vue 项目比作盖房子:
- Node.js = 施工队(负责干活)
- Vite = 图纸 + 工具(帮你搭好骨架)
第一步:安装 Node.js
去 nodejs.org 下载 LTS 版本(长期支持版,最稳定),像安装普通软件一样点"下一步"装好即可。 装完后打开终端(PowerShell 或 cmd),验证一下:
node -v # 能看到版本号就说明装好了,比如 v20.11.0
npm -v # npm 是 Node 自带的包管理器,也会显示版本号
第二步:创建项目
在终端中进入你想放项目的文件夹,然后运行:
npm create vue@latest
这条命令会问你几个问题:
- Project name(项目名):给你的项目起个名字,比如
my-first-app - 是否加 TypeScript?:新手可以先选 No,先学 Vue 本身
- 是否加 Router?:新手先选 No,后面再学
- 其他选项全部回车跳过就好 💡 Tip:这些选项以后都能加,不用纠结。
第三步:安装依赖并启动
cd my-first-app # 进入项目文件夹
npm install # 下载项目需要的所有依赖(就这一次,以后不用)
npm run dev # 启动开发服务器
看到类似 Local: http://localhost:5173/ 就说明成功了。用浏览器打开这个地址,你应该能看到一个 Vue 的欢迎页。
📝 Note:开发服务器是什么?
npm run dev 会启动一个"开发服务器",它做的事情是:
- 把你写的代码实时编译成浏览器能看懂的样子
- 当你修改代码并保存时,浏览器自动刷新
- 这就是"热更新"——改代码 → 保存 → 立刻看到效果
项目文件夹里有什么?
刚创建的项目结构如下。你不需要一次性全搞懂,先认识最重要的:
my-first-app/
├── index.html ← 唯一的 HTML 页面(SPA 的入口)
├── package.json ← 项目配置 + 依赖列表(相当于购物清单)
├── vite.config.js ← Vite 的配置文件(一般不用动)
├── src/ ← 你写代码的地方,大部分时间都在这个文件夹里
│ ├── main.js ← 项目的启动文件(告诉 Vue "从哪开始")
│ ├── App.vue ← 根组件(整个页面的最外层)
│ └── components/ ← 放各种小组件的文件夹
└── public/ ← 放不经过处理的静态文件(如图片、字体)
📝 Note:.vue 文件是什么?
.vue 文件就是 Vue 的"单文件组件"。一个 .vue 文件包含三部分:
<template>— HTML 模板(你看到的样子)<script>— JavaScript 逻辑(控制行为)<style>— CSS 样式(控制外观) 后面会逐一详细讲解。
VSCode 插件
如果你用 VSCode 写代码,搜 Vue - Official 安装,它会提供:
.vue文件的语法高亮(代码有颜色)- 输入时的智能提示
- 写错时的类型检查
快速检查
装完后,打开 src/App.vue,找到 <h1> 标签,把里面的文字改成 "Hello, Vue!",保存——浏览器应该马上更新。
如果能做到这一步,环境就搭好了,可以开始学 Vue 了!
下一步:
💡 Tip:Composition API 写法(Vue 3)
环境搭建本身不涉及代码风格差异,创建项目的命令两种 API 风格通用。
详见:
Vue 到底是干什么的?
简单说,Vue 帮你做一件事:把数据和页面自动关联起来。
举个例子:你有一个变量叫 name = "小明",你想在页面上显示"你好,小明"。用原生 JavaScript 你可能要写:
document.getElementById('greeting').textContent = '你好,' + name
但如果 name 变成了 "小红",你得再写一行代码更新页面。数据一变,你得手动更新。
Vue 的思路是:你只管改数据,页面自动更新。这就是"响应式"——数据的变化会自动"响应"到页面上。
项目的启动流程
每个 Vue 项目启动时,都从 main.js 开始执行。打开 src/main.js:
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
这三行代码的意思是:
| 代码 | 做什么 |
| import { createApp } from 'vue' | 从 Vue 库里借一个 createApp 函数 |
| import App from './App.vue' | 把自己写的 App.vue 组件加载进来 |
| createApp(App).mount('#app') | 创建应用 → 把 App 组件挂到 #app 这个位置 |
画成图就是这样:
index.html main.js App.vue
┌───────────┐ 启动 ┌──────────────┐ 挂载 ┌──────────────┐
│ <div │ ◄──────── │ createApp(… │ ◄──────── │ <template> │
│ id="app">│ │ mount(#app) │ │ 整个页面的 │
│ </div> │ └──────────────┘ │ 内容都在这里 │
└───────────┘ └──────────────┘
💡 Tip:mount 是什么?
"挂载"就是把 Vue 组件"装"到 HTML 的某个位置上。mount('#app') 的意思就是:把 Vue 应用放到 id="app" 的那个 <div> 里。
根组件 App.vue 长什么样
打开 src/App.vue,你会看到这样的结构:
<template>
<h1>{{ title }}</h1>
</template>
<script>
export default {
data() {
return {
title: 'Hello Vue 3'
}
}
}
</script>
这是 Vue 最基础的写法(叫"选项式 API"),我们来拆解每一块:
<template>— 就是 HTML,但可以使用 Vue 的特殊语法{{ title }}— 双大括号叫"插值",把title这个变量的值显示在这里<script>— 写 JavaScript 的地方data()— 一个函数,return 出去的就是这个组件的"数据"export default— 把这整块代码"导出",让别的地方可以用它 📝 Note:为什么 data 是函数不是对象? 这是 Vue 的一个设计:data必须写成一个函数(data() { ... }),不能直接写data: { ... }。 原因很简单:每个组件需要有自己的数据副本,函数保证了每次用的都是"新的一份",不会互相干扰。你暂时记住这个规则就行。
SPA 是什么?
Vue 是 SPA(单页应用) 框架。SPA 的意思是:
整个网站只有一个 .html 文件,切换页面时浏览器不会真正跳转——只是 Vue 在同一个页面里"换掉"显示的内容。
对比一下传统网站和 SPA:
| | 传统网页 | SPA(Vue 项目) |
| HTML 文件 | 每个页面一个 .html | 整个网站只有一个 index.html |
| 页面切换 | 浏览器白屏→重新加载 | 丝滑切换,无白屏 |
| 数据请求 | 每次切换页面重新请求 | 按需请求,更快 |
| 用户体验 | 有延迟感 | 像 App 一样流畅 |
这就是为什么 Vue 适合做"Web App"——体验接近手机 App。
接下来学什么?
了解了项目结构后,建议按这个顺序学习:
- — 了解 Vue 的两种写法
- — 怎么把数据显示到页面上
- — 动态改 HTML 属性
- — 控制元素的显示/隐藏
- — 遍历数据显示列表 💡 Tip:Composition API 写法(Vue 3) 上面的示例用了 Options API。用 Composition API 写同一个功能是这样的:
<script setup>
import { ref } from 'vue'
const title = ref('Hello Vue 3')
</script>
详见:
Vue 提供了两种写代码的方式,官方叫"API 风格"。它们功能完全一样,只是写法不同。
选项式 API(Options API)
这种风格的特点是:用固定的"选项"来组织代码。就像填表格一样,数据放 data,方法放 methods,计算结果放 computed。
<template>
<p>点击次数:{{ count }}</p>
<button @click="addOne">点我 +1</button>
</template>
<script>
export default {
data() {
return {
count: 0 // 数据写这里
}
},
methods: {
addOne() {
this.count++ // 方法写这里,用 this. 访问数据
}
}
}
</script>
💡 Tip:为什么是 this.count?
在选项式 API 中,this 指代"当前组件"。data 里定义的数据会自动挂到 this 上,所以用 this.count 就能读到。
优点
- 结构固定,新手容易上手——所有组件长一个样
- 文档好查,每种功能有对应的固定位置
缺点
- 同一个功能的代码会散落在不同选项里(数据在
data,逻辑在methods,计算在computed),功能多了不好维护
组合式 API(Composition API)
这种风格的特点是:按功能组织代码,而不是按选项。用 Vue 提供的函数(ref、computed 等)来写逻辑。
<template>
<p>点击次数:{{ count }}</p>
<button @click="addOne">点我 +1</button>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0) // ref() 创建响应式数据
function addOne() {
count.value++ // 注意!在 JS 里用 .value 访问
}
</script>
⚠️ Warning:两个关键区别
- 用
<script setup>代替了export default - 数据用
ref()创建,在 JavaScript 里访问需要.value(但模板里不用)
优点
- 同一功能的代码写在一起,逻辑清晰
- 可以把一段逻辑抽出来复用(这是组合式 API 最大的优势)
- TypeScript 支持更好
缺点
- 新手理解"响应式"要多花一点时间
- 需要记住
ref要在 JS 里加.value(忘了会报错)
两种风格怎么选?
| 场景 | 推荐 | | 刚开始学 Vue | 选项式 API(结构清晰,照着抄就行) | | 小项目 / 简单页面 | 选项式 API(够用,不用上组合式) | | 大型项目 / 多人合作 | 组合式 API(好拆分、好复用) | | 需要复用逻辑 | 组合式 API(可以抽成函数到处用) | | 用 TypeScript | 组合式 API(类型推断更好) | 📝 Note:本笔记的教学安排 基础部分会用选项式 API 讲解(更容易理解 Vue 的核心概念),同时每篇末尾附上组合式 API 的对照写法。等你理解了基础概念后,再来看组合式 API 就会发现它只是换了一种组织方式。
一个直观的对比
同一个功能,两种写法放在一起看:
<!-- ========== 选项式 API ========== -->
<script>
export default {
data() {
return { name: '小明', age: 18 }
},
computed: {
intro() {
return `${this.name}今年${this.age}岁`
}
},
methods: {
growUp() {
this.age++
}
}
}
</script>
<!-- ========== 组合式 API ========== -->
<script setup>
import { ref, computed } from 'vue'
const name = ref('小明')
const age = ref(18)
const intro = computed(() => `${name.value}今年${age.value}岁`)
function growUp() {
age.value++
}
</script>
功能完全一样,只是"组织代码的方式"不同。
新手常见问题
Q:学了选项式还要学组合式吗?
A:先精通一种。建议从选项式入门理解 Vue 核心概念,然后再学组合式。组合式只是写法不同,核心概念(响应式、组件、指令)是一样的。
Q:能不能混着用?
A:同一个 .vue 文件里不要混用,但要学的知识点是通用的——条件渲染、列表渲染、事件处理这些在两个风格里用法完全一样。
下一步:
💡 Tip:Composition API 写法速查
详见: 和
Vue 的模板就是 HTML,但加了两种特殊语法:插值表达式 和 指令。
插值表达式 {{ }}
插值的意思是"在这里插入值"。Vue 用 双大括号 {{ }} 把数据嵌入到 HTML 中。
<template>
<p>{{ message }}</p>
<p>{{ number + 1 }}</p>
<p>{{ flag ? '是' : '否' }}</p>
</template>
<script>
export default {
data() {
return {
message: 'Hello',
number: 10,
flag: true
}
}
}
</script>
页面会显示:
Hello
11
是
💡 Tip:{{ }} 里面可以写什么?
可以写合法的 JavaScript 表达式(能算出结果的代码)。比如:
- 变量:
{{ name }} - 运算:
{{ a + b }} - 三元:
{{ ok ? '是' : '否' }} - 调用方法:
{{ message.toUpperCase() }}但不能写语句!不能写{{ if (true) { ... } }}和{{ const x = 1 }}。
原始 HTML — v-html
默认情况下,{{ }} 会把内容当作纯文本处理,HTML 标签会被转义——也就是说,<b> 不会让文字加粗。
如果你真的需要渲染 HTML 字符串,用 v-html:
<template>
<p>{{ rawHtml }}</p> <!-- 显示: <b>粗体</b> -->
<p v-html="rawHtml"></p> <!-- 显示: 粗体 -->
</template>
<script>
export default {
data() {
return {
rawHtml: '<b>粗体</b>'
}
}
}
</script>
🚫 Danger:v-html 有安全风险
v-html 要慎用!如果你把用户输入的内容直接用 v-html 渲染,恶意用户可以在里面插入 <script> 标签执行攻击代码(XSS 攻击)。
规则:永远不要对用户输入的内容使用 v-html。
什么是"指令"?
指令就是 Vue 提供的特殊属性,以 v- 开头。它们告诉 Vue "这个元素要做什么特殊处理"。
| 指令 | 作用 | 类比 |
| v-html | 渲染 HTML | 用 HTML 内容替换元素 |
| v-bind | 绑定属性 | 下篇讲 |
| v-if | 条件显示 | 后面会讲 |
| v-for | 循环渲染 | 后面会讲 |
| v-on | 绑定事件 | 后面会讲 |
| v-model | 双向绑定 | 后面会讲 |
这六个指令是 Vue 最核心的东西,后面会逐一详细讲解。
模板里只有文本?不,还有指令和表达式
总结一下,Vue 模板里你能用的东西:
<template>
<!-- 1. 普通 HTML(照搬) -->
<h1>标题</h1>
<!-- 2. 插值表达式(动态内容) -->
<p>{{ message }}</p>
<!-- 3. 指令(v- 开头,做特殊操作) -->
<a v-bind:href="url">链接</a>
</template>
这三种东西可以组合使用,构成 Vue 模板的全部能力。
新手常见问题
Q:为什么我的 {{ }} 一闪而过变成了真实数据?
A:那是 Vue 还没加载完时,模板原样显示了。解决办法:在 index.html 的 #app 上加 v-cloak(进阶内容,不急)。
Q:{{ }} 和 v-text 有什么区别?
A:效果一样,<span v-text="msg"></span> 等于 <span>{{ msg }}</span>。一般直接用插值更直观。
下一步:
💡 Tip:Composition API 写法(Vue 3)
<script setup>
import { ref } from 'vue'
const message = ref('Hello')
const rawHtml = ref('<b>粗体</b>')
</script>
模板部分完全一样,不变。 详见:
为什么需要属性绑定?
HTML 属性(如 href、src、class、disabled)在普通 HTML 里是写死的:
<a href="https://vuejs.org">官网</a> <!-- href 永远是 vuejs.org -->
但你经常需要动态改变属性值,比如:
- 切换不同图片:
src要根据数据变化 - 禁用/启用按钮:
disabled要根据状态变化 - 跳转不同链接:
href要根据用户操作变化 这时就要用v-bind。
基本用法
v-bind 让 HTML 属性的值可以跟 Vue 的数据绑定:
<template>
<a v-bind:href="url">前往官网</a>
<img v-bind:src="imageSrc" alt="logo">
</template>
<script>
export default {
data() {
return {
url: 'https://vuejs.org',
imageSrc: 'logo.png'
}
}
}
</script>
渲染出来的 HTML 就是:
<a href="https://vuejs.org">前往官网</a>
<img src="logo.png" alt="logo">
v-bind 后面跟的属性名 = HTML 属性名,等号后面是数据变量。
简写
v-bind 太长了,Vue 提供了简写——直接用冒号 ::
<a :href="url">链接</a> <!-- 等于 v-bind:href="url" -->
<img :src="imageSrc"> <!-- 等于 v-bind:src="imageSrc" -->
几乎所有 Vue 开发者都用简写,看到 : 就理解为"这是一个动态属性"。
布尔属性
有些 HTML 属性不需要值,存在就生效,比如 disabled、checked、readonly。
<template>
<button :disabled="isDisabled">提交</button>
</template>
<script>
export default {
data() {
return {
isDisabled: true // true → 按钮禁用
}
}
}
</script>
Vue 特殊处理了布尔属性:
true→ 属性存在 → 按钮禁用false→ 属性移除 → 按钮可用null或undefined→ 属性也不渲染
绑定多个属性
如果你有一个对象,想把它的所有属性都绑到元素上:
<template>
<div v-bind="attrs">内容</div>
</template>
<script>
export default {
data() {
return {
attrs: {
id: 'myDiv',
class: 'container',
title: '提示信息'
}
}
}
}
</script>
渲染结果等于:
<div id="myDiv" class="container" title="提示信息">内容</div>
总结
| 写法 | 含义 |
| v-bind:href="url" | 完整写法 |
| :href="url" | 简写(推荐) |
| :disabled="true" | 布尔属性 |
| v-bind="obj" | 批量绑定 |
记住:HTML 属性要动态改变 → 前面加冒号 :。
下一步:
💡 Tip:Composition API 写法(Vue 3)
<script setup>
import { ref } from 'vue'
const url = ref('https://vuejs.org')
const isDisabled = ref(true)
</script>
模板中的 :href、:disabled 等写法完全不变。
详见:
v-for 指令用来"循环生成多个相同的元素",就像 JavaScript 的 for...of 循环。
遍历数组
最常用的场景是把数组里的每一项渲染出来:
<template>
<ul>
<li v-for="item in items" :key="item.id">
{{ item.name }}
</li>
</ul>
</template>
<script>
export default {
data() {
return {
items: [
{ id: 1, name: 'Vue' },
{ id: 2, name: 'React' },
{ id: 3, name: 'Angular' }
]
}
}
}
</script>
页面输出:
• Vue
• React
• Angular
📝 Note:v-for 的语法
v-for="item in items" 读作:"从 items 数组里逐个取出元素,存到变量 item 里,然后用 item 渲染这一行。"
获取索引
如果你还需要知道"当前是第几个",加一个括号:
<li v-for="(item, index) in items" :key="item.id">
{{ index + 1 }}. {{ item.name }}
</li>
输出:
1. Vue
2. React
3. Angular
💡 Tip:注意
index 从 0 开始,显示的时候一般需要 index + 1。
遍历对象
v-for 也能遍历对象的属性:
<template>
<p v-for="(value, key, index) in user" :key="key">
{{ index }}. {{ key }}: {{ value }}
</p>
</template>
<script>
export default {
data() {
return {
user: { name: '张三', age: 18, city: '北京' }
}
}
}
</script>
输出:
0. name: 张三
1. age: 18
2. city: 北京
三个参数的顺序是:值 → 键 → 索引。
遍历数字范围
<span v-for="n in 5" :key="n">{{ n }} </span>
输出:1 2 3 4 5(从 1 开始,到 5)
key 属性 — 很重要!
每个 v-for 的元素都应该加上 :key,这是 Vue 识别每个元素的身份标识。
为什么需要 key?
Vue 更新列表时,需要知道"哪一行是新加的、哪一行是删掉的、哪一行改了"。没有 key,Vue 就只能"按位置对号入座",容易出错。
举个例子:你有一个列表 [A, B, C],你想在最前面插入 D 变成 [D, A, B, C]:
- 没有 key:Vue 把第一行从 A 改成 D,第二行 B 改成 A...四行都要更新
- 有 key:Vue 一看,D 是新的,放最前面;A、B、C 都在,不用动
用什么做 key?
<!-- ✅ 用唯一 ID -->
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
<!-- ❌ 不要用索引(index) -->
<li v-for="(item, index) in items" :key="index">{{ item.name }}</li>
用 index 做 key 的坏处:当你对数组做插入/删除时,元素的 index 会变,Vue 就分不清谁是谁了。
💡 Tip:记住
只要有 v-for,就一定要加 :key,且 key 要用唯一的、不变的值。
在 <template> 上使用 v-for
和 v-if 一样,v-for 也可以加在 <template> 上同时渲染多个元素:
<template v-for="item in items" :key="item.id">
<h3>{{ item.name }}</h3>
<p>{{ item.desc }}</p>
</template>
实际例子:待办事项列表
<template>
<div>
<input v-model="newTodo" @keyup.enter="addTodo" placeholder="输入待办事项">
<ul>
<li v-for="todo in todos" :key="todo.id">
{{ todo.text }}
<button @click="removeTodo(todo.id)">删除</button>
</li>
</ul>
</div>
</template>
<script>
export default {
data() {
return {
newTodo: '',
todos: [
{ id: 1, text: '学 Vue' },
{ id: 2, text: '写代码' }
],
nextId: 3
}
},
methods: {
addTodo() {
if (this.newTodo.trim()) {
this.todos.push({ id: this.nextId++, text: this.newTodo })
this.newTodo = '' // 清空输入框
}
},
removeTodo(id) {
this.todos = this.todos.filter(t => t.id !== id)
}
}
}
</script>
新手常见问题
Q:不写 :key 会怎样?
A:能运行,但 Vue 会在控制台给你一个警告,而且列表更新时可能出 bug。养成习惯,有 v-for 必有 :key。
Q:为什么我的列表没更新?
A:检查你是不是用 this.items[0] = xxx 这样直接赋值。Vue 2 不会检测这种改动,要用 splice 或整体替换数组。Vue 3 没这个问题。
下一步:
💡 Tip:Composition API 写法(Vue 3)
<script setup>
import { ref } from 'vue'
const items = ref([{ id: 1, name: 'Vue' }, { id: 2, name: 'React' }])
</script>
模板部分 (v-for、:key) 完全不变。
详见:
条件渲染就是"根据条件决定显示还是隐藏某个元素"。Vue 提供了两个指令:v-if 和 v-show。
v-if / v-else-if / v-else
和 JavaScript 的 if...else 逻辑一模一样,控制元素是否在 DOM 中存在。
<template>
<p v-if="score >= 90">优秀</p>
<p v-else-if="score >= 60">及格</p>
<p v-else>不及格</p>
</template>
<script>
export default {
data() {
return { score: 75 }
}
}
</script>
结果:页面只显示"及格"那一行。另外两行根本不在 HTML 里(你可以打开浏览器开发者工具验证)。 📝 Note:规则
v-if/v-else-if/v-else必须紧挨着,中间不能插其他元素- 只有第一个带条件(
v-if),后面的v-else-if和v-else跟着就行
v-show
v-show 也能控制显示隐藏,但原理不同——元素始终在 DOM 中,只是通过 CSS display: none 隐藏了。
<template>
<p v-show="visible">我一直都在 DOM 里</p>
</template>
<script>
export default {
data() {
return { visible: true }
}
}
</script>
当 visible 为 false 时:
<p style="display: none;">我一直都在 DOM 里</p>
v-if vs v-show,怎么选?
| | v-if | v-show |
| 原理 | 条件为假时,元素从 DOM 中完全移除 | 条件为假时,元素还在,只是隐藏(display: none) |
| 初始渲染 | 条件为假时不渲染,更快(省资源) | 始终渲染,初始时慢一点点 |
| 切换开销 | 每次切换要销毁/重建,开销大 | 只改 CSS,开销极小 |
| 适用场景 | 条件很少改变的情况(如用户是否登录) | 条件频繁切换的情况(如选项卡切换) |
筛选分类时就可以用show减少css开销
💡 Tip:记住一个口诀
少切换用 v-if,多切换用 v-show。
在 <template> 上使用 v-if
如果你想用 v-if 控制多个元素,但不想多包一层 <div>,可以用 <template>:
<template v-if="loggedIn">
<h2>欢迎回来</h2>
<p>今天天气不错</p>
<button>开始使用</button>
</template>
<template> 是一个"隐形容器"——它本身不会渲染成任何 HTML,只是用来组织逻辑。
📝 Note:注意
v-show 不能用在 <template> 上,因为 <template> 没有实际 DOM 元素。
实际例子:登录状态切换
<template>
<div>
<p v-if="user">欢迎,{{ user }}</p>
<button v-else @click="login">请登录</button>
</div>
</template>
<script>
export default {
data() {
return { user: null }
},
methods: {
login() {
this.user = '小明' // 数据一变,v-if 自动重新判断
}
}
}
</script>
点击按钮 → user 从 null 变成 "小明" → Vue 自动把"请登录"替换成"欢迎,小明"。
新手常见问题
Q:v-if 和 v-show 可以同时用吗?
A:分开用就行,别同时加一个元素上。先判断用哪个:切换频繁用 v-show,否则用 v-if。
Q:为什么 v-if 里用 v-for 会告警?
A:Vue 规定 v-if 和 v-for 不能放在同一个元素上(执行优先级会出问题)。应该先用 v-for 循环,再在里面用 v-if。
下一步:
💡 Tip:Composition API 写法(Vue 3)
<script setup>
import { ref } from 'vue'
const score = ref(75)
const visible = ref(true)
const user = ref(null)
</script>
模板部分完全不变。 详见:
用户点击按钮、输入文字、移动鼠标……这些都是"事件"。Vue 用 v-on 指令来监听这些事件。
基本用法
v-on 用于绑定事件处理函数:
<template>
<button v-on:click="handleClick">点我</button>
</template>
<script>
export default {
methods: {
handleClick() {
alert('按钮被点了!')
}
}
}
</script>
v-on:click="handleClick" 的意思是:当这个按钮被点击时,调用 handleClick 方法。
简写
v-on: 太长,日常用 @ 简写:
<button @click="handleClick">点我</button> <!-- 等于 v-on:click -->
<input @input="handleInput"> <!-- 等于 v-on:input -->
<form @submit="handleSubmit"> <!-- 等于 v-on:submit -->
看到 @ 就理解为"当...事件发生时"。
给方法传参
<template>
<button @click="say('Hello')">打招呼</button>
<button @click="say('再见')">说再见</button>
</template>
<script>
export default {
methods: {
say(msg) {
alert(msg)
}
}
}
</script>
💡 Tip:需要事件对象怎么办?
如果你既要传参,又要拿到事件对象,用 $event:
<button @click="handle('参数', $event)">点我</button>
事件修饰符
很多事件需要加上"额外操作",比如阻止表单自动提交、防止事件冒泡。Vue 提供了修饰符,在事件后面加 .xxx 就行。
| 修饰符 | 作用 | 不用 Vue 你要写 |
| .prevent | 阻止默认行为(如表单提交、a 标签跳转) | e.preventDefault() |
| .stop | 阻止事件冒泡(点击子元素不会触发父元素) | e.stopPropagation() |
| .once | 事件只触发一次 | 手动加标志位 |
| .self | 只在事件源是自身时触发 | if (e.target === e.currentTarget) |
| .capture | 在捕获阶段触发 | 第三个参数 true |
<template>
<!-- 表单提交时不会刷新页面 -->
<form @submit.prevent="onSubmit">
<input type="text">
<button type="submit">提交</button>
</form>
<!-- 点击"点我"不会触发灰色区域的点击事件 -->
<div @click="onOuterClick" style="background: gray; padding: 20px">
<button @click.stop="onInnerClick">点我</button>
</div>
</template>
按键修饰符
监听键盘事件时,可以指定按键:
<!-- 只有按回车时触发 -->
<input @keyup.enter="onEnter">
<!-- 只有按 Esc 时触发 -->
<input @keyup.escape="onCancel">
支持的按键别名:.enter、.tab、.delete、.esc、.space、.up、.down、.left、.right
也可以直接用键码:@keyup.13(13 = 回车键码,不过用别名更直观)
组合键
<!-- Ctrl + 回车 -->
<input @keyup.ctrl.enter="onCtrlEnter">
<!-- 精确匹配:只有 Ctrl 被按下(不能同时按其他键) -->
<button @click.ctrl.exact="onCtrlOnly">精确 Ctrl</button>
内联处理
简单逻辑可以直接写在模板里,不用定义方法:
<template>
<button @click="count++">点了 {{ count }} 次</button>
</template>
<script>
export default {
data() {
return { count: 0 }
}
}
</script>
适合只有一行逻辑的简单操作。复杂的逻辑还是应该写到 methods 里。
实际例子:计数器
<template>
<div>
<p>当前计数:{{ count }}</p>
<button @click="count++">+1</button>
<button @click="count--">-1</button>
<button @click="count = 0">重置</button>
</div>
</template>
<script>
export default {
data() {
return { count: 0 }
}
}
</script>
新手常见问题
Q:@click="fn" 和 @click="fn()" 有区别吗?
A:有。@click="fn" 会把事件对象传给 fn;@click="fn()" 则什么都不传。如果不需要事件对象,两种写法效果一样。
Q:事件处理函数里怎么访问 data?
A:用 this.xxx。Vue 自动把 data 里的数据挂到了 this 上。
下一步:
💡 Tip:Composition API 写法(Vue 3)
<script setup>
import { ref } from 'vue'
const count = ref(0)
function handleClick() { count.value++ }
</script>
模板中的 @click 等写法完全不变。
详见:
v-model 是 Vue 最常用的指令之一,实现表单控件和数据之间的双向绑定。
什么是双向绑定?
普通的 :value 只能做到"数据 → 页面"。v-model 能做到:
数据 ↔ 页面:改数据,页面变;用户输入,数据也变。
<template>
<input v-model="message">
<p>你输入的是:{{ message }}</p>
</template>
<script>
export default {
data() {
return { message: '' }
}
}
</script>
你在输入框里打字 → message 自动更新 → {{ message }} 自动刷新。两个方向都是自动的。
💡 Tip:v-model 的本质
v-model="message" 等价于同时做了两件事:
:value="message"— 把数据绑到输入框@input="message = $event.target.value"— 监听输入并更新数据 所以它叫"双向绑定",一行顶两行。
不同表单控件的用法
文本框
<input v-model="text">
文本域
<textarea v-model="content"></textarea>
📝 Note:注意
<textarea> 不要写 {{ }} 插值,用 v-model。
单选复选框
<input type="checkbox" v-model="checked">
<span>{{ checked ? '已选' : '未选' }}</span>
checked 的值是 true 或 false。
多选复选框(绑定到数组)
<template>
<div>
<input type="checkbox" v-model="hobbies" value="读书"> 读书
<input type="checkbox" v-model="hobbies" value="运动"> 运动
<input type="checkbox" v-model="hobbies" value="音乐"> 音乐
<p>你选了:{{ hobbies }}</p>
</div>
</template>
<script>
export default {
data() {
return {
hobbies: [] // 必须是数组
}
}
}
</script>
单选框
<input type="radio" v-model="gender" value="男"> 男
<input type="radio" v-model="gender" value="女"> 女
下拉列表
<select v-model="city">
<option disabled value="">请选择城市</option>
<option value="beijing">北京</option>
<option value="shanghai">上海</option>
</select>
修饰符
v-model 提供了三个方便的修饰符:
| 修饰符 | 作用 | 什么时候用 |
| .lazy | 失去焦点时才更新数据(而不是每次输入都更新) | 输入大段文字时减少计算 |
| .number | 自动把输入转成数字 | 输入年龄、价格 |
| .trim | 自动去掉首尾空格 | 输入用户名、邮箱 |
<input v-model.lazy="message"> <!-- 按回车或失焦才更新 -->
<input v-model.number="age"> <!-- 输入 "25" → age 是数字 25 -->
<input v-model.trim="username"> <!-- " 小明 " → "小明" -->
实际例子:登录表单
<template>
<form @submit.prevent="handleLogin">
<div>
<label>用户名:</label>
<input v-model.trim="username" placeholder="请输入用户名">
</div>
<div>
<label>密码:</label>
<input v-model="password" type="password" placeholder="请输入密码">
</div>
<div>
<label>
<input type="checkbox" v-model="remember"> 记住我
</label>
</div>
<button type="submit">登录</button>
</form>
</template>
<script>
export default {
data() {
return {
username: '',
password: '',
remember: false
}
},
methods: {
handleLogin() {
console.log(`登录:${this.username}, 记住:${this.remember}`)
// 这里发登录请求
}
}
}
</script>
新手常见问题
Q:input 事件和 v-model 冲突吗?
A:不会。你仍然可以单独用 @input 做额外的处理,v-model 会继续正常运行。
Q:为什么我的 checkbox 绑定到数组,数据没更新?
A:检查你的数组是用 ref 还是 reactive。组合式 API 中如果用 ref([]),用的时候要 .value。
Q:v-model 能绑定 computed 吗?
A:能,但需要给 computed 提供 setter。如果只是只读 computed,v-model 会报错。
下一步:
💡 Tip:Composition API 写法(Vue 3)
<script setup>
import { ref } from 'vue'
const message = ref('')
const checked = ref(false)
</script>
模板中 v-model 写法完全不变。
详见:
什么是计算属性?
计算属性(computed)是一类特殊的属性,它的值由其他数据计算得出,而且 Vue 会缓存结果。 简单说:有依赖别人才能算出来的值,就放 computed 里。
一个例子看懂
<template>
<p>原始数组:{{ numbers }}</p>
<p>偶数数组:{{ evenNumbers }}</p>
</template>
<script>
export default {
data() {
return {
numbers: [1, 2, 3, 4, 5, 6]
}
},
computed: {
evenNumbers() {
return this.numbers.filter(n => n % 2 === 0)
}
}
}
</script>
输出:
原始数组:[1, 2, 3, 4, 5, 6]
偶数数组:[2, 4, 6]
evenNumbers 不是存死的数据,而是每次根据 numbers 自动算出来的。只要 numbers 不变,evenNumbers 就用缓存的结果,不会重新算。
计算属性 vs 方法 — 关键区别
两种写法都能实现同样的功能,但行为完全不同:
<template>
<!-- 方法:每次访问都会重新执行 -->
<p>{{ getEvenNumbers() }}</p>
<p>{{ getEvenNumbers() }}</p>
<p>{{ getEvenNumbers() }}</p>
<!-- 计算属性:第一次算出后缓存,后面直接用 -->
<p>{{ evenNumbers }}</p>
<p>{{ evenNumbers }}</p>
<p>{{ evenNumbers }}</p>
</template>
<script>
export default {
data() { return { numbers: [1, 2, 3, 4, 5, 6] } },
computed: {
evenNumbers() {
console.log('computed 被调用了') // 只打印 1 次
return this.numbers.filter(n => n % 2 === 0)
}
},
methods: {
getEvenNumbers() {
console.log('method 被调用了') // 打印 3 次
return this.numbers.filter(n => n % 2 === 0)
}
}
}
</script>
| | computed | methods |
| 缓存 | 有(依赖不变就复用上次结果) | 无(每次重新计算) |
| 调用方式 | 像属性一样,不带括号:{{ evenNumbers }} | 像函数一样,带括号:{{ getEven() }} |
| 适用场景 | 需要根据数据计算的展示值 | 需要执行的动作(如事件处理) |
| 何时用 | 读数据时 | 做操作时 |
💡 Tip:记住
computed 就像 Excel 里的公式:=SUM(A1:A10)。只要 A1:A10 的值没变,结果就不会重新算。
计算属性的 getter 和 setter
计算属性默认是"只读"的(getter)。但你也可以让它可写(setter):
<template>
<p>{{ fullName }}</p>
<button @click="changeFullName">改名字</button>
</template>
<script>
export default {
data() {
return {
firstName: '张',
lastName: '三'
}
},
computed: {
fullName: {
get() { // 读取时调用
return this.firstName + this.lastName
},
set(newValue) { // 设置时调用
this.firstName = newValue[0]
this.lastName = newValue.slice(1)
}
}
},
methods: {
changeFullName() {
this.fullName = '李四' // 触发 setter
}
}
}
</script>
不过 getter+setter 的计算属性用得比较少,大部分场景下只读就够了。
什么时候用 computed?
凡是符合"这个值由其他值决定"的逻辑,都应该用 computed:
- 过滤列表(如搜索功能)
- 格式化显示(如价格显示为 ¥ 符号)
- 判断状态(如购物车是否为空)
- 拼接字符串(如全名 = 姓 + 名)
新手常见问题
Q:computed 里能写异步代码吗?
A:不行。computed 是同步的,必须立即返回值。如果需要异步操作,用 watch 或 methods。
Q:computed 里能修改 data 吗?
A:不能!computed 是"只读"的,在里面修改 data 会造成无限循环。改数据去 methods。
下一步:
💡 Tip:Composition API 写法(Vue 3)
<script setup>
import { ref, computed } from 'vue'
const numbers = ref([1, 2, 3, 4, 5, 6])
const evenNumbers = computed(() => numbers.value.filter(n => n % 2 === 0))
</script>
详见:
watch(侦听器)用来监听某个数据的变化,然后执行一些"副作用"。
和 computed 有什么区别?
| | computed | watch | | 做什么 | 根据数据算一个新值出来 | 数据变了,干一件事 | | 返回 | 必须返回一个值 | 不返回值,执行操作 | | 用途 | 格式化、过滤、拼接 | 发请求、存本地、调第三方库 | | 本质 | 派生数据 | 副作用处理 | 💡 Tip:一句话区别 computed:数据变了,算出新结果。 watch:数据变了,去做某件事。
基本用法
<template>
<input v-model="keyword" placeholder="搜索">
<p>结果:{{ result }}</p>
</template>
<script>
export default {
data() {
return {
keyword: '',
result: ''
}
},
watch: {
keyword(newVal, oldVal) { // 新值在前,旧值在后
console.log(`从 "${oldVal}" 变为 "${newVal}"`)
this.result = `搜索:${newVal}`
}
}
}
</script>
每次你输入文字 → keyword 变化 → watch 里的函数自动执行 → result 更新 → 页面刷新。
📝 Note:参数顺序
keyword(newVal, oldVal) — 新值在前,旧值在后。别写反了。
deep — 深度监听
默认情况下,watch 只监听对象引用的变化(地址变了没有),不监听对象内部属性的变化。
<script>
export default {
data() {
return {
user: { name: '张三', age: 18 }
}
},
watch: {
user: {
handler(newVal) {
console.log('user 变了', newVal)
},
deep: true // 开启深度监听
}
}
}
</script>
开启 deep: true 后,修改 user.name 或 user.age 也会触发 watch。
⚠️ Warning:deep 有性能代价
deep: true 会递归遍历对象所有属性,对象很大会影响性能。只在确实需要监听嵌套属性时才开。
immediate — 立即执行
默认情况下,watch 只在数据变化时才触发。如果你需要在页面加载时就执行一次,加 immediate: true:
<script>
export default {
watch: {
keyword: {
handler(newVal) {
console.log('当前值:', newVal)
},
immediate: true // 组件创建时立即执行一次
}
}
}
</script>
监听对象中的某个属性
如果只想监听对象的某个属性,而不是整个对象:
watch: {
'user.name'(newName) { // 用引号包起来,加点号
console.log('名字变了:', newName)
}
}
实际例子:防抖搜索
<template>
<div>
<input v-model="keyword" placeholder="搜索用户">
<ul>
<li v-for="user in filteredUsers" :key="user.id">{{ user.name }}</li>
</ul>
</div>
</template>
<script>
export default {
data() {
return {
keyword: '',
users: [
{ id: 1, name: '张三' },
{ id: 2, name: '李四' },
{ id: 3, name: '王五' }
],
filteredUsers: [],
timer: null
}
},
watch: {
keyword() {
clearTimeout(this.timer) // 清除上一次定时器
this.timer = setTimeout(() => {
this.filteredUsers = this.users.filter(
u => u.name.includes(this.keyword)
)
}, 300) // 300ms 后才真正搜索
}
},
created() {
this.filteredUsers = this.users // 初始显示全部
}
}
</script>
新手常见问题
Q:computed 和 watch,我该用哪个? A:先想"我需要的是一个值,还是一个动作"——如果是值用 computed,是动作用 watch。 Q:watch 里能监听 computed 吗? A:可以。computed 的值变了也能被 watch 监听到。 Q:能不能同时监听多个数据? A:在选项式 API 中,每个数据单独写一个 watch。在组合式 API 中可以用数组:
watch([keyword, category], ([newKw, newCat]) => { ... })
下一步: 💡 Tip:Composition API 写法(Vue 3)
<script setup>
import { ref, watch } from 'vue'
const keyword = ref('')
watch(keyword, (newVal, oldVal) => {
console.log(`从 ${oldVal} 变为 ${newVal}`)
})
</script>
watch 作为函数调用而非配置对象。另有 watchEffect(自动追踪依赖,立即执行)。
详见:
大多数时候你用 Vue 的数据绑定就够了。但偶尔你需要直接操作 DOM 元素——比如让输入框自动聚焦、获取滚动位置、操作 canvas 画布。
这时就用 ref 属性配合 $refs。
获取 DOM 元素
<template>
<input ref="inputRef" type="text" placeholder="我会自动获得焦点">
<button @click="focusInput">手动聚焦</button>
</template>
<script>
export default {
methods: {
focusInput() {
this.$refs.inputRef.focus() // 直接操作 DOM
}
}
}
</script>
步骤:
- 在元素上加
ref="名字" - 在 JavaScript 中用
this.$refs.名字获取这个元素 - 然后就能调用原生 DOM 方法了(
focus()、scrollIntoView()等)
访问时机 — mounted
$refs 在组件挂载完成后才可用。如果在 data 或 created 里访问,拿到的是 undefined。
<script>
export default {
mounted() {
this.$refs.inputRef.focus() // ✅ 这里可以
},
created() {
// this.$refs.inputRef → undefined ❌ 太早了
}
}
</script>
📝 Note:组件的生命周期顺序
data 初始化 → created(数据有了,DOM 还没)→ mounted(DOM 渲染完了)
$refs 要到 mounted 之后才能用。
在 v-for 中获取多个元素
在 v-for 的元素上使用 ref,$refs 会返回一个数组:
<template>
<ul>
<li v-for="item in list" :key="item" ref="items">{{ item }}</li>
</ul>
</template>
<script>
export default {
data() {
return { list: ['A', 'B', 'C'] }
},
mounted() {
console.log(this.$refs.items) // [li, li, li]
}
}
</script>
⚠️ Warning:注意顺序
v-for 中 ref 数组的顺序不一定和列表顺序一致,取决于你的 :key 和数据变化。不要依赖它们的顺序来做业务逻辑。
获取组件实例
ref 不仅能获取 DOM 元素,还能获取子组件实例:
<template>
<ChildComponent ref="childRef" />
<button @click="callChildMethod">调用子组件方法</button>
</template>
<script>
export default {
methods: {
callChildMethod() {
this.$refs.childRef.doSomething() // 调用子组件的方法
}
}
}
</script>
这样就能"父组件主动调用子组件的方法"。不过这种方式不建议频繁使用,尽量通过 props 和 events 来通信。
什么时候需要直接操作 DOM?
Vue 的"响应式"已经覆盖了 95% 的情况。以下场景才需要 $refs:
| 场景 | 示例 |
| 聚焦输入框 | refs.xxx.focus() |
| 获取元素尺寸/位置 | refs.xxx.getBoundingClientRect() |
| Canvas 操作 | refs.canvas.getContext('2d') |
| 集成第三方 DOM 库 | 如 Chart.js、Swiper |
| 播放控制(视频/音频) | refs.video.play() |
新手常见问题
Q:为什么我的 this.$refs.xxx 是 undefined?
A:最常见的原因是在 mounted 之前访问了。确保你的代码在 mounted 或之后(如按钮点击时)调用。
Q:ref 能在组件内部用吗?
A:能。组件内任何元素都能用 ref 标记,只要是这个组件渲染的就能通过 this.$refs 访问。
Q:ref 和 v-bind(:value)有什么区别?
| | v-bind(数据绑定) | ref(模板引用) |
| 做什么 | 把数据绑定到属性或 prop | 获取 DOM 元素或组件实例 |
| 数据流 | JS → 模板(声明式) | 模板 → JS(命令式) |
| 典型场景 | :value="msg" 显示数据 | ref="input" 然后 focus() |
| 何时可用 | 任何时刻 | 只能在 mounted 之后 |
v-bind是声明式的:你说"数据是什么,模板就渲染什么",Vue 帮你搞定ref是命令式的:你拿到元素自己操作,就像用document.querySelector
能用
v-bind解决的,优先用v-bind。ref只在你需要突破数据绑定限制(比如调用 DOM 方法)时才用。 下一步: 💡 Tip:Composition API 写法(Vue 3)
<script setup>
import { ref, onMounted } from 'vue'
const inputRef = ref(null)
onMounted(() => { inputRef.value?.focus() })
</script>
组合式 API 中 ref 的变量名要和模板中 ref="inputRef" 的名字一致。
详见:
Vue 能自动检测数据变化并更新页面——但有些操作它能侦测到,有些不行。这一篇讲清楚规则。
数组的"变更方法"
Vue 特别对待了 JavaScript 中 7 个会修改原数组的方法。只要用它们操作数组,Vue 就能自动更新视图:
| 方法 | 做什么 | 示例 |
| push() | 末尾添加 | list.push('D') |
| pop() | 末尾删除 | list.pop() |
| shift() | 开头删除 | list.shift() |
| unshift() | 开头添加 | list.unshift('A') |
| splice() | 删除/插入/替换 | list.splice(1, 1, 'X') |
| sort() | 排序 | list.sort() |
| reverse() | 反转 | list.reverse() |
<template>
<ul>
<li v-for="item in list" :key="item">{{ item }}</li>
</ul>
<button @click="addItem">添加 D</button>
</template>
<script>
export default {
data() {
return { list: ['A', 'B', 'C'] }
},
methods: {
addItem() {
this.list.push('D') // ✅ Vue 能侦测到,页面自动更新
}
}
}
</script>
数组的"非变更方法"
有些方法不会修改原数组,而是返回一个新数组:
| 方法 | 说明 |
| filter() | 过滤 |
| concat() | 拼接 |
| slice() | 截取 |
| map() | 映射 |
这些方法不会触发视图更新,因为原数组没变。解决方法是用新数组替换旧数组:
// ❌ 这样不会更新视图
this.numbers.filter(n => n > 0)
// ✅ 要整体替换
this.numbers = this.numbers.filter(n => n > 0)
this.numbers = this.numbers.concat([4, 5, 6])
📝 Note:为什么整体替换可以?
Vue 的内部机制会优化这个操作——它不会真的销毁重建整个列表,而是尽可能复用已有的 DOM 元素。所以用 filter 整体替换和用 splice 删除,性能差不多。
Vue 2 的限制(已废弃的 Vue.set)
⚠️ Warning:Vue 2 中已废弃,Vue 3 已修复 以下问题只存在于 Vue 2。在 Vue 3 中,这些问题已经被 Proxy 机制解决了,你不需要担心这些限制。 Vue 2 无法侦测到以下两种操作:
// ❌ Vue 2 无法侦测:直接通过索引设置数组元素
this.items[0] = '新值'
// ❌ Vue 2 无法侦测:直接给对象添加新属性
this.user.age = 18 // 如果 age 不在初始 data 中
Vue 2 需要用 Vue.set() 或 this.$set() 来处理:
// Vue 2 的解决方案
this.$set(this.items, 0, '新值')
this.$set(this.user, 'age', 18)
但在 Vue 3 中直接用 this.items[0] = '新值' 就行,不需要 $set。
响应式原理(通俗版)
你不需要理解底层实现,但有个概念对以后有帮助:
- Vue 在创建实例时,把你
data里的数据都"劫持"了 - 每次你读写这些数据,Vue 都能感知到
- 数据一变,Vue 就知道"哪些页面元素依赖这个数据",然后自动更新它们 就像一个管家,你只管说"茶杯放桌子上",管家会自动把旧杯子拿走、新杯子放上。你不用管"怎么更新页面",Vue 包了。
新手常见问题
Q:为什么我的列表改动后页面没反应? A:检查你是否做了以下操作:
- 直接改数组索引:
this.list[0] = 'X'(Vue 2 不行,Vue 3 可以) - 用了
filter但没赋值:this.list.filter(...)→ 应该是this.list = this.list.filter(...) - 也别忘了在 JavaScript 里组合式 API 用
ref时需要用.valueQ:对象属性变化能侦测到吗? A:Vue 3 可以,Vue 2 只能在初始data中存在的属性才能被侦测。新增属性在 Vue 2 中需要用$set。 下一步: 💡 Tip:Composition API 写法(Vue 3)
<script setup>
import { ref } from 'vue'
const list = ref(['A', 'B', 'C'])
function addItem() { list.value.push('D') }
</script>
在 Vue 3 的组合式 API 中,数组操作更直观,没有 Vue 2 的那些限制。 详见:
在 Vue 中通过数据驱动 CSS 样式,有两种方式:Class 绑定 和 Style 绑定。
Class 绑定
对象语法 — 按条件切换
最常用的方式:class 名写在左边,条件写在右边。
<template>
<p :class="{ active: isActive, error: hasError }">状态文本</p>
<button @click="isActive = !isActive">切换 active</button>
</template>
<script>
export default {
data() {
return {
isActive: false,
hasError: true
}
}
}
</script>
当 isActive 为 false、hasError 为 true 时,渲染结果:
<p class="error">状态文本</p>
规则:{ class名: 条件 } — 条件为真就加这个 class,为假就不加。
把对象放 data 里
当 class 很多时,把对象放 data 里,模板更干净:
<template>
<p :class="classObject">文本</p>
</template>
<script>
export default {
data() {
return {
classObject: {
active: false,
error: true,
'text-large': true // 带横杠的 class 名要加引号
}
}
}
}
</script>
数组语法 — 直接拼 class
<template>
<p :class="[baseClass, statusClass]">文本</p>
</template>
<script>
export default {
data() {
return {
baseClass: 'text-lg',
statusClass: 'color-red'
}
}
}
</script>
渲染结果:<p class="text-lg color-red">文本</p>
混合使用
数组和对象可以组合:
<p :class="['base', { active: isActive }]">文本</p>
'base' 始终存在,active 根据 isActive 决定。
:class 和普通 class 共存
:class 不会覆盖普通 class,它们会合并:
<button class="btn" :class="{ disabled: isDisabled }">提交</button>
<!-- 渲染: <button class="btn disabled">提交</button> -->
Style 绑定
对象语法
<template>
<p :style="{ color: textColor, fontSize: size + 'px' }">彩色文字</p>
</template>
<script>
export default {
data() {
return {
textColor: 'red',
size: 18
}
}
}
</script>
CSS 属性名有两种写法:
- 驼峰式(推荐):
fontSize - 短横线式(加引号):
'font-size'
完整对象放 data 里
<template>
<div :style="styleObj">带样式的区域</div>
</template>
<script>
export default {
data() {
return {
styleObj: {
color: 'red',
fontSize: '18px',
fontWeight: 'bold'
}
}
}
}
</script>
数组语法
<p :style="[baseStyle, highlightStyle]">文本</p>
<!-- 后面的样式会覆盖前面的同名属性 -->
Class vs Style,什么时候用哪个?
| 用 Class 绑定 | 用 Style 绑定 | | 样式是预定义好的(在 CSS 里写好) | 样式是动态算出来的(如用户滑条调颜色) | | 切换多个 class | 改个别属性 | | 更利于维护(样式和逻辑分离) | 需要精确控制像素值 |
💡 Tip:优先用 Class 绑定。 大部分场景用 Class 绑定就够了,Style 绑定只在需要动态计算数值时才用。
什么是组件?
组件是 Vue 的核心概念。把页面拆成一个个独立、可复用的小块,每个小块就是一个组件。
页面 = 拼积木
┌──────────────────────────────┐
│ Header(顶部导航组件) │
├──────────────────────────────┤
│ Main(主要内容区组件) │
├──────────────────────────────┤
│ Footer(底部组件) │
└──────────────────────────────┘
每个组件就是一个 .vue 文件,封装了 HTML、JavaScript 和 CSS。
单文件组件(SFC)的结构
<template>
<!-- 这个组件的 HTML -->
</template>
<script>
// 这个组件的 JavaScript 逻辑
</script>
<style scoped>
/* 这个组件的 CSS 样式 */
</style>
组件嵌套关系
Vue 应用本质上是一棵组件树——一个组件里放另一个组件,层层嵌套。
App.vue(根组件)
├── Header.vue(顶部导航)
├── Main.vue(主内容区)
│ ├── Article.vue(文章卡片)
│ └── Article.vue(文章卡片...复用多次)
└── Footer.vue(底部)
父组件写法
<template>
<div class="layout">
<Header />
<Main />
<Aside />
</div>
</template>
<script>
import Header from './components/Header.vue'
import Main from './components/Main.vue'
import Aside from './components/Aside.vue'
export default {
components: { Header, Main, Aside }
}
</script>
三个步骤:import 导入 → components 注册 → <template> 中使用。
数据流向:从上到下
数据从父组件流向子组件,从根往叶子流。父组件通过 props 把数据传给子组件,子组件不能直接改父组件的数据——这是 Vue 的核心设计原则。
组件注册方式
局部注册(推荐)
只在当前组件内可用:
<template>
<MyButton />
</template>
<script>
import MyButton from './MyButton.vue'
export default {
components: { MyButton }
}
</script>
优点:依赖关系清晰,支持 tree-shaking,好维护。
全局注册
在 main.js 中注册,整个应用任何地方都能用:
import { createApp } from 'vue'
import App from './App.vue'
import MyButton from './components/MyButton.vue'
const app = createApp(App)
app.component('MyButton', MyButton)
app.mount('#app')
缺点:main.js 会越来越臃肿,无法 tree-shaking。
| 场景 | 用哪种 |
|---|---|
| 通用组件(按钮、弹窗...到处用) | 全局注册 |
| 特定页面的组件 | 局部注册 |
| 不确定是否会复用 | 先局部,需要时再提升 |
💡 Tip:先用局部注册,等这个组件确实在 3 个以上地方被引用了,再考虑全局注册。
<slot> — 预留的"内容洞"
组件里的 <slot> 就像是一个"占位符":
<!-- MyCard.vue -->
<template>
<div class="card">
<slot></slot> <!-- 默认插槽 -->
<slot name="footer"></slot> <!-- 具名插槽 -->
</div>
</template>
<!-- 使用时 -->
<MyCard>
<p>这是卡片的内容</p>
<template v-slot:footer>
<button>查看详情</button>
</template>
</MyCard>
scoped — 组件样式隔离
<style scoped>
.title { color: red; } /* 只影响本组件内的 .title */
</style>
不加 scoped = 全局样式 = 影响整个应用。开发时默认加 scoped 是个好习惯。
组件命名规范
- 用多个单词:
MyButton而不是Button(避免冲突) - PascalCase:
MyButton、UserProfile - 模板中
<MyButton />或<my-button></my-button>都行
props — 父传子的"快递通道"
props 是父组件向子组件传递数据的机制,单向:父传子,子不能直接改。
父组件(有数据)
│
│ props(单向)
↓
子组件(接收数据,用在自己的模板里)
最简单的例子
父组件:
<template>
<Child title="来自父组件的数据" />
</template>
<script>
import Child from './Child.vue'
export default {
components: { Child }
}
</script>
子组件(Child.vue):
<template>
<p>父组件说的是:{{ title }}</p>
</template>
<script>
export default {
props: ['title']
}
</script>
动态绑定 — 传变量而不是固定值
传变量时加冒号:
<Child :title="parentMsg" />
| 写法 | 传给子组件的 |
|---|---|
title="hello" |
字符串 "hello" |
:title="hello" |
变量 hello 的值 |
:title="'hello'" |
字符串 "hello"(表达式) |
加了冒号,等号后面的是 JavaScript 表达式,不是字符串。
单向数据流
props 是单向的,子不能直接改父传过来的值:
// ❌ 错误
this.title = '新标题' // 会导致 Vue 报错
子组件确实需要改时:
- 把 prop 赋给本地 data(子组件内部自己用)
- 通过事件通知父组件改
传递多种数据类型
规则:传非字符串类型必须加冒号 :。
数字
<Child :age="25" /> <!-- ✅ 传数字 -->
<Child age="25" /> <!-- ❌ 传字符串 "25" -->
数组
<Child :names="['张三', '李四', '王五']" />
对象
<Child :user="userInfo" />
布尔值
<Child :is-admin="true" />
Props 校验
给 prop 加上类型、必填、默认值等规则,让组件更健壮。
从简单到完整
// 第 1 级:只声明名字
props: ['title']
// 第 2 级:指定类型
props: {
title: String
}
// 第 3 级:加更多规则
props: {
title: {
type: String,
required: true,
default: '默认标题'
}
}
完整示例
props: {
title: {
type: String,
required: true
},
age: {
type: Number,
default: 18
},
tags: {
type: Array,
default() { // ⚠️ 对象/数组必须用函数返回
return []
}
},
status: {
type: String,
validator(value) {
return ['active', 'inactive'].includes(value)
}
}
}
| 选项 | 作用 |
|---|---|
type |
期望的类型:String, Number, Boolean, Array, Object, Date, Function |
required |
是否必传 |
default |
默认值(对象/数组必须用函数返回) |
validator |
自定义校验函数,返回 true 通过 |
支持多种类型
width: {
type: [Number, String],
default: 100
}
类型校验
当 prop 类型不对时,控制台会打印 warning(不是 error,页面仍能运行):
[Vue warn]: Invalid prop: type check failed for prop "age".
Expected Number, got String with value "25".
💡 Tip:type 校验只在开发环境生效,不影响生产性能。
新手常见问题
Q:我的 prop 传了但子组件拿不到?
- 子组件有没有在
props里声明 - 父组件传的时候有没有写冒号
- prop 名字拼写是否一致
Q:prop 名字在父模板里怎么写?
- 父模板用小写横杠:
my-title - 子组件 JS 用驼峰:
myTitle
Q:为什么对象/数组的 default 必须是函数?
- 确保每个组件实例拿到的是独立副本,而不是共享同一个引用
<script setup> 是 Vue 3 的推荐写法,它是组合式 API 的"语法糖"——把繁琐的样板代码省掉了,代码更简洁。
从"老写法"到"语法糖"
<!-- ❌ 老写法(组合式 API 但不用语法糖) -->
<script>
import { ref } from 'vue'
export default {
setup() {
const count = ref(0)
function addOne() {
count.value++
}
return { count, addOne } // 必须手动 return 才能在模板中用
}
}
</script>
<!-- ✅ 语法糖(<script setup>) -->
<script setup>
import { ref } from 'vue'
const count = ref(0)
function addOne() {
count.value++
}
// 不用 return!顶层的变量和函数自动暴露给模板
</script>
💡 Tip:"语法糖"是什么意思?
语法糖 = 写起来更甜的语法。功能没变,就是让你少写几行代码。<script setup> 做的事和老写法完全一样,只是帮你去掉了 export default、setup()、return 这些样板。
核心规则
用 <script setup> 后,记住这几点:
- 导入即注册 —
import组件后直接能用,无需components: {} - 顶层变量自动暴露 — 模板中直接使用,不用
return - 顶层的
await— 可以直接用const data = await fetch(...)(需要组件外层包<Suspense>)
defineProps — 接收父组件数据
<script setup>
const props = defineProps<{
title: string
count?: number // ? 表示可选
}>()
// JS 中用 props.title 访问(不用 .value)
console.log(props.title)
</script>
<template>
<p>{{ title }}</p> <!-- 模板中可以解构,直接写 title -->
</template>
对比选项式 API:props: ['title'] → 现在写成 defineProps<{ title: string }>()。
defineEmits — 向父组件发事件
<script setup>
const emit = defineEmits<{
(e: 'update', id: number): void
(e: 'delete', name: string): void
}>()
function handleClick() {
emit('update', 1) // 触发 update 事件,带参数 1
}
</script>
<template>
<button @click="handleClick">点我更新</button>
</template>
对比选项式 API:在 methods 里用 this.$emit('event') → 现在用 defineEmits 声明并得到 emit 函数。
useSlots / useAttrs
当你不通过 props 传数据,而是用插槽或额外属性时:
<script setup>
import { useSlots, useAttrs } from 'vue'
const slots = useSlots()
const attrs = useAttrs()
console.log(slots.default?.()) // 默认插槽的内容
console.log(attrs.class) // 父组件传进来的 class
</script>
这两个用得比较少,初期了解即可。
基础篇所有知识在这里的对照
| 选项式 API | <script setup> |
| data() { return { x: 1 } } | const x = ref(1) |
| props: ['title'] | defineProps<{ title: string }>() |
| methods: { fn() {} } | function fn() {} |
| computed: { val() {} } | const val = computed(() => ...) |
| watch: { x() {} } | watch(x, (newVal) => ...) |
| this.$emit('e') | const emit = defineEmits(); emit('e') |
| components: { Comp } | 不用写,import 即可 |
新手常见问题
Q:<script setup> 比选项式 API 快吗?
A:性能几乎一样。<script setup> 的好处是开发体验(更少代码、更好 TS 支持、更好逻辑复用)。
Q:能在同一个 .vue 文件里同时用 <script> 和 <script setup> 吗?
A:可以,但很少需要这样做。一个文件里只有一个 <script setup>。
下一步:
📝 Note:基础篇回顾
如果你对 Vue 核心概念(条件渲染、事件处理、组件通信等)还不太熟,建议先回到 基础篇 看完,再来深入这里。
Vue 3 的响应式系统有多种工具函数,每个有特定的用途。本文详细介绍。
为什么需要 ref?
在 JavaScript 中,普通变量没有"通知机制":
let count = 0
count++ // 变了,但没人知道
Vue 需要在数据变化时自动更新 UI。普通变量做不到这一点,所以 Vue 提供了 ref —— 给变量包一层"响应式外壳":
const count = ref(0)
count.value++ // ✅ 变了,Vue 知道,UI 自动更新
ref 的本质:一个带有 .value 属性的响应式对象。ref(0) 返回的不是 0,而是 { value: 0 } 的代理对象。
可以把 ref 想象成快递追踪号——包裹还是那个包裹(值不变),但有了追踪号(ref),你就能实时知道它的状态变化。
ref — 基础类型首选
ref 创建一个响应式引用,适用于基本类型(数字、字符串、布尔值)。
<script setup>
import { ref } from 'vue'
const count = ref(0)
const name = ref('张三')
function increment() {
count.value++ // ⚠️ JS 中必须用 .value
}
</script>
<template>
<p>{{ count }}</p> <!-- 模板中自动解包,不用 .value -->
<button @click="increment">+1</button>
</template>
⚠️ Warning:ref 的关键规则
- JS 代码里:访问/修改需要
.value(count.value++) - 模板里:自动解包,直接写
{{ count }} - 这是新手最容易忘的地方!
reactive — 对象类型独家
reactive 创建一个响应式对象,适合包含多个属性的数据。
<script setup>
import { reactive } from 'vue'
const user = reactive({
name: '张三',
age: 25,
hobbies: ['读书', '运动']
})
function growUp() {
user.age++ // ✅ 无需 .value,直接访问属性
}
</script>
<template>
<p>{{ user.name }} - {{ user.age }}岁</p>
<button @click="growUp">长大一岁</button>
</template>
ref vs reactive 全景对比
| | ref | reactive |
| 适用类型 | 任何类型,但更推荐基础类型 | 对象、数组 |
| 访问方式 | JS 中要 .value,模板中自动解包 | 直接访问属性,无 .value |
| 重新赋值 | count.value = 5 ✅ | state = { x: 1 } ❌ 会丢失响应式 |
| 解构 | 可以(配合 toRefs) | 不能直接解构(会丢响应式) |
| 底层原理 | reactive 包装一层 | Proxy 直接代理 |
computed — 派生数据
和选项式 API 的 computed 一样,但写成函数调用:
<script setup>
import { ref, computed } from 'vue'
const price = ref(100)
const tax = computed(() => price.value * 0.1) // 只读
const total = computed({ // 可读写
get: () => price.value + tax.value,
set: (val) => { price.value = val / 1.1 }
})
</script>
watch — 监听变化
<script setup>
import { ref, watch } from 'vue'
const keyword = ref('')
// 监听单个
watch(keyword, (newVal, oldVal) => {
console.log(`${oldVal} → ${newVal}`)
})
// 监听多个
const a = ref(1)
const b = ref(2)
watch([a, b], ([newA, newB]) => {
console.log(newA, newB)
})
// 深度监听
watch(user, (newVal) => {}, { deep: true })
// 立即执行
watch(keyword, (val) => {}, { immediate: true })
</script>
watchEffect — 自动追踪 + 立即执行
watchEffect 比 watch 更"智能":它自动追踪内部用到的所有响应式数据,并且立即执行一次。
<script setup>
import { ref, watchEffect } from 'vue'
const count = ref(0)
const doubled = ref(0)
watchEffect(() => {
doubled.value = count.value * 2 // 自动追踪 count
console.log(`count 变了:${count.value}`)
})
// 组件创建时立即执行一次
// 之后 count 每变一次,就执行一次
</script>
watch vs watchEffect:
watch:你知道要监听谁,明确指定watchEffect:你不知道依赖了谁,让 Vue 自己追踪
shallowRef — 浅响应(性能优化)
ref 会深层追踪,对大对象可能浪费性能。shallowRef 只追踪 .value 的变化:
<script setup>
import { shallowRef } from 'vue'
const data = shallowRef({ name: 'Vue', version: 3 })
data.value = { name: 'New' } // ✅ 触发更新(整个 .value 换了)
data.value.name = 'New' // ❌ 不触发(内部属性变化不管)
// 需要强制更新时:
import { triggerRef } from 'vue'
data.value.name = 'New'
triggerRef(data) // 手动触发
</script>
readonly — 只读保护
把数据变成只读,防止意外修改:
<script setup>
import { reactive, readonly } from 'vue'
const original = reactive({ count: 0 })
const copy = readonly(original)
original.count++ // ✅ 改原始对象可以
copy.count++ // ❌ 警告:只读,不能改
</script>
toRef / toRefs — 保持响应式的解构
reactive 对象解构会丢失响应式,用 toRef / toRefs 保持连接:
<script setup>
import { reactive, toRef, toRefs } from 'vue'
const state = reactive({
name: 'Vue',
version: 3
})
const name = toRef(state, 'name') // 取出单个属性
const { version } = toRefs(state) // 取出多个属性(解构)
// name 和 state.name 保持同步
state.name = 'Vue 3'
console.log(name.value) // 'Vue 3'
</script>
快速选择指南
| 场景 | 用什么 |
| 基本类型(数字、字符串、布尔) | ref |
| 普通对象 | reactive 或 ref(看个人偏好) |
| 根据其他数据计算 | computed |
| 监听数据变化做操作 | watch / watchEffect |
| 大对象、性能敏感 | shallowRef |
| 防止修改 | readonly |
| reactive 解构保持响应 | toRef / toRefs |
💡 Tip:一句话口诀
- 基本类型用 ref,对象用 reactive
- 计算用 computed,变化做 watch
- reactive 解构加 toRefs,组件传参记得 defineProps 📝 Note:从选项式迁移过来? 如果你之前学的是选项式 API,建议对着 vue代码风格 里的对照表,把基础篇的概念逐一映射到组合式写法。
除了基础的 ref() / reactive(),Vue 还提供了一批 ref 工具函数 来处理特殊场景。
isRef / unref — 类型判断与自动解包
<script setup>
import { ref, isRef, unref } from 'vue'
const count = ref(0)
const plain = 42
console.log(isRef(count)) // true
console.log(isRef(plain)) // false
// unref:如果是 ref 则返回 .value,否则返回原始值
console.log(unref(count)) // 0
console.log(unref(plain)) // 42
// 等价于:
function myUnref(r) {
return isRef(r) ? r.value : r
}
</script>
适用于工具函数——你不想限制参数必须是 ref 还是普通值:
<script setup>
function formatMsg(msg) {
return `消息:${unref(msg)}`
}
const msgRef = ref('Hello')
console.log(formatMsg(msgRef)) // 消息:Hello
console.log(formatMsg('World')) // 消息:World
</script>
shallowRef — 浅响应优化
ref 会递归地把整个数据变成响应式。如果数据很大或不需要深层响应,用 shallowRef 省性能。
<script setup>
import { shallowRef, triggerRef } from 'vue'
const logs = shallowRef([])
// 直接推入元素:数组变了,但 shallowRef 没检测到
logs.value.push('新日志') // ❌ 不触发更新
// 替换整个引用:检测到
logs.value = [...logs.value, '新日志'] // ✅ 触发更新
// 或者用 triggerRef 手动通知
logs.value.push('新日志')
triggerRef(logs) // ✅ 手动触发更新
</script>
典型场景
| 场景 | 说明 | | 大型列表 | 几千条日志、表格数据,不需要逐条追踪 | | 第三方数据 | 从外部库拿到的不可变数据 | | 频繁替换 | 每次都重新赋值整个对象 |
shallowRef+triggerRef搭配使用,兼顾性能和灵活性。
customRef — 自定义 ref
customRef 让你完全控制 ref 的依赖追踪和触发更新时机。最常见的场景是防抖:
<script setup>
import { customRef } from 'vue'
function debouncedRef(value, delay = 300) {
let timer
return customRef((track, trigger) => {
return {
get() {
track() // 告诉 Vue:这个值被依赖了
return value
},
set(newValue) {
clearTimeout(timer)
timer = setTimeout(() => {
value = newValue
trigger() // 告诉 Vue:值变了,通知依赖方
}, delay)
}
}
})
}
const keyword = debouncedRef('', 500) // 500ms 防抖
</script>
<template>
<input v-model="keyword" placeholder="防抖搜索" />
</template>
customRef 接收一个工厂函数,参数是 track 和 trigger:
track()— 在get里调用,标记依赖追踪trigger()— 在set里调用,触发更新通知
useTemplateRef — 模板引用新写法(Vue 3.5+)
Vue 3.5 引入了 useTemplateRef,统一了模板 ref 的声明方式,不再要求变量名和模板 ref 名一致。
<script setup>
import { useTemplateRef, onMounted } from 'vue'
const input = useTemplateRef('myInput')
// 等价于 <script setup> 中 const input = ref(null) + ref="myInput"
onMounted(() => {
input.value?.focus()
})
</script>
<template>
<input ref="myInput" type="text" />
</template>
优点:
- 变量名和 ref 名可以不同
- 类型推导更好
- 语义清晰:一看就知道是模板引用
在 v-for 中使用
<script setup>
import { useTemplateRef, onMounted } from 'vue'
const items = useTemplateRef('items')
onMounted(() => {
console.log(items.value) // HTMLElement[]
})
</script>
<template>
<li v-for="item in list" :key="item" ref="items">{{ item }}</li>
</template>
defineExpose — 控制子组件暴露的内容
父组件通过 ref 获取子组件实例时,默认什么都拿不到(<script setup> 是关闭的)。
需要用 defineExpose 显式暴露:
<!-- Child.vue -->
<script setup>
import { ref } from 'vue'
const count = ref(0)
const internal = ref('秘密') // 不暴露
function reset() { count.value = 0 }
defineExpose({ count, reset })
</script>
<!-- Parent.vue -->
<script setup>
import { useTemplateRef } from 'vue'
import Child from './Child.vue'
const childRef = useTemplateRef('child')
function handleClick() {
childRef.value?.reset()
console.log(childRef.value?.count)
}
</script>
<template>
<Child ref="child" />
<button @click="handleClick">重置</button>
</template>
选项式 API 中,
$refs会自动暴露所有data和methods,不需要defineExpose。
函数式 ref
ref 属性也可以传一个函数,在元素挂载或卸载时调用:
<script setup>
import { ref } from 'vue'
const divEl = ref(null)
function setRef(el) {
divEl.value = el
console.log('元素挂载了', el)
}
</script>
<template>
<div :ref="setRef">内容</div>
</template>
函数接收元素本身作为参数,卸载时传 null。适合在需要动态处理 ref 时使用。
TypeScript 类型安全
ref 类型
<script setup lang="ts">
import { ref, type Ref } from 'vue'
const count = ref<number>(0)
const user = ref<{ name: string; age: number } | null>(null)
// 显式标注 Ref 类型(不常用)
const message: Ref<string> = ref('hello')
</script>
组件 ref 类型
用 InstanceType 获取组件实例类型:
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import MyInput from './MyInput.vue'
type MyInputInstance = InstanceType<typeof MyInput>
const inputRef = useTemplateRef<MyInputInstance>('input')
onMounted(() => {
inputRef.value?.focus()
})
</script>
<template>
<MyInput ref="input" />
</template>
或者用 typeof + defineExpose 配合:
<script setup lang="ts">
// 只声明暴露的类型,不暴露实现细节
defineExpose({
count: ref(0),
reset: () => {}
})
</script>
模板 ref 类型
<script setup lang="ts">
const divRef = useTemplateRef<HTMLDivElement>('div')
const inputRef = useTemplateRef<HTMLInputElement>('input')
const canvasRef = useTemplateRef<HTMLCanvasElement>('canvas')
</script>
<template>
<div ref="div">块</div>
<input ref="input" />
<canvas ref="canvas"></canvas>
</template>
速查表
| API | 用途 | 层级 |
| isRef() | 判断是否是 ref | 工具 |
| unref() | 自动解包(ref 取 .value,否则原值) | 工具 |
| shallowRef() | 浅响应,大对象性能优化 | 响应式 |
| triggerRef() | 手动触发 shallowRef 更新 | 响应式 |
| customRef() | 自定义追踪/触发逻辑(防抖等) | 自定义 |
| useTemplateRef() | 声明模板引用(Vue 3.5+) | 模板 |
| defineExpose() | 控制组件暴露给父级的属性 | 组件 |
| 函数式 :ref | 动态处理 ref 回调 | 模板 |
📝 Note
基础 ref() 用法见 ,模板 ref 基础见 。
推荐几个 Vue 开发必备的 VSCode 插件。
Vue - Official(必装)
这是 Vue 官方出品的 VSCode 插件,提供:
.vue文件语法高亮(模板、脚本、样式分颜色显示)- 代码智能提示(输入时自动补全组件名、属性名)
- 类型检查(TypeScript 集成)
<slot>名称提示- CSS 类名补全 安装: VSCode 扩展商店搜索 Vue - Official。
能获得什么?
安装后:
- 输入
v-会提示所有 Vue 指令(v-if、v-for、v-model...) - 写
<MyButton自动提示导入路径 defineProps的类型会即时检查- 模板中鼠标悬停变量显示类型

其他推荐插件
| 插件 | 作用 | | Error Lens | 把错误信息直接显示在行尾,不用鼠标悬停 | | Auto Rename Tag | 改标签名时自动同步改闭合标签 | | ESLint | 代码规范检查和自动修复 | | Prettier | 代码自动格式化 | | Path Intellisense | 文件路径自动补全 |
与 ESLint 配合
Vue - Official 内置了格式化能力,但如果你同时装了 Prettier,建议在 VSCode 设置中指定:
.vscode/settings.json:
{
"editor.defaultFormatter": "Vue.volar",
"[javascript]": {
"editor.defaultFormatter": "Vue.volar"
}
}
这样 .vue 文件就用 Vue - Official 的格式化,避免和 Prettier 打架。
整理 Vue 开发中最常遇到的报错,帮你快速定位问题。
找不到模块 ./App.vue 的声明文件
无法找到模块"./App.vue"的声明文件。"path/App.vue"隐式拥有 "any" 类型。
这是 TypeScript 不认识 .vue 文件导致的。在 src/ 下创建文件 env.d.ts:
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
如果项目是用 npm create vue@latest 创建的,这个文件已经自带了,不用手动加。
ref 忘了用 .value
const count = ref(0)
// ❌ console.log(count) // 打印 RefImpl { ... } 对象,不是数字
// ✅ console.log(count.value) // 打印 0
// ❌ count++ // 语法上能过但不会触发响应式
// ✅ count.value++
💡 Tip:记住
在 <script> 里操作 ref 必须用 .value,模板里不需要。
reactive 整体替换丢失响应式
// ❌ 这样会丢响应式
let state = reactive({ count: 0 })
state = { count: 1 } // state 指向了新对象,Proxy 包装没了
// ✅ 修改属性
state.count = 1
// 或者用 ref 代替,支持整体替换
const state = ref({ count: 0 })
state.value = { count: 1 } // ✅ ref 支持替换
defineProps 类型声明错误
// ✅ 纯类型声明
const props = defineProps<{ title: string }>()
// ✅ 运行时声明(不用 TS 时可以这样)
const props = defineProps({ title: { type: String, required: true } })
// ❌ 不能混合使用
const props = defineProps({ title: String })<{ title: string }>
两种声明方式只能选一种。
v-for 缺少 :key
[vue/require-v-for-key] Elements in iteration expect to have 'v-bind:key' directives.
写 v-for 但没加 :key。加上就好了:
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
组件命名冲突
[Vue warn]: Do not use built-in or reserved HTML elements as component id: header
组件名和 HTML 原生标签重名了。解决:用多单词命名,如 AppHeader 而不是 Header。
$refs 访问时机错误
TypeError: Cannot read properties of undefined (reading 'focus')
在 mounted 之前访问了 $refs。确保在 mounted() 生命周期或之后(如事件回调中)访问。
v-html 没有任何内容显示
<div v-html="htmlContent"></div>
检查 htmlContent 的值——如果它是 null 或空字符串,自然什么都不显示。另外注意:v-html 会覆盖元素内的所有内容。
双向绑定不生效
<input v-model="formData.name" /> <!-- ❌ formData 不是响应式的 -->
formData 必须是用 reactive 创建的,或 formData.name 是 ref。如果 formData 是个普通对象,v-model 不会生效。
快速排错清单
| 症状 | 首先检查 |
| 页面没更新 | 数据是不是响应式的(ref/reactive/data) |
| 子组件收不到数据 | props 声明了吗?传值加冒号了吗? |
| 事件不触发 | 方法名拼写对不对?@click 写了吗? |
| 样式不生效 | scoped 了吗?class 名对不对? |
| 组件报错不显示 | 检查浏览器控制台(F12)的错误信息 |