一、SFC 与模板语法

1.1 Vue 单文件组件(SFC)结构

语法:

<template>
  <!-- 放 HTML 模板 —— 写页面结构和样式绑定 -->
</template>

<script setup>
// 放 JavaScript 逻辑 —— 写数据、函数、生命周期
</script>

<style scoped>
/* 放 CSS 样式 —— 只对当前组件生效 */
</style>

一个 .vue 文件就是一个单文件组件(Single File Component,简称 SFC)。它把三个东西写在一个文件里:

  • <template>:长得像 HTML,但可以嵌入 Vue 的特殊语法
  • <script setup>:JavaScript/TypeScript 逻辑
  • <style scoped>:CSS 样式,scoped 让样式只作用于当前组件

详细讲解:

传统 Web 开发中,HTML、JS、CSS 分三个文件:

index.html        style.css         script.js
(结构)            (样式)            (逻辑)

Vue 的 SFC 把三者合进一个文件,因为一个组件就是一个独立单元——它的模板、逻辑、样式天然绑定在一起。这叫单文件组件,是 Vue 的核心组织方式。

<template>
  <div class="card">
    <h1>{{ title }}</h1>
    <p>{{ content }}</p>
  </div>
</template>

<script setup>
const title = 'Hello Vue3';
const content = '这是我的第一个组件';
</script>

<style scoped>
.card {
  border: 1px solid #ddd;
  padding: 16px;
}
.card h1 {
  color: #42b883;
}
</style>

1.2 <script setup> 语法糖

语法:

<script setup>
// 所有顶层变量、函数都自动暴露给模板使用
const count = ref(0);
function increment() { count.value++; }
</script>

<template>
  <button @click="increment">{{ count }}</button>
</template>

<script setup> 是 Vue3 推荐写法,在 <script> 标签上加 setup 关键字,里面声明的所有变量和函数都自动能在模板中使用,不需要手动 return

详细讲解:

Vue3 有两种写法:

<!-- 写法一:选项式 API(Vue2 风格,Vue3 仍支持但不推荐) -->
<script>
export default {
  data() {
    return { count: 0 };
  },
  methods: {
    increment() { this.count++; }
  }
}
</script>

<!-- 写法二:组合式 API + <script setup>(Vue3 推荐写法) -->
<script setup>
const count = ref(0);
function increment() { count.value++; }
</script>

<script setup> 对比选项式 API 的好处:

选项式 API <script setup>
写法 必须 export default 对象 直接写变量和函数
暴露给模板 必须写在 data/methods 里 顶层声明自动暴露
this 大量使用 this 不需要 this
逻辑复用 mixins(容易冲突) composables(灵活)
TypeScript 支持 一般 更好

为什么不推荐选项式 API 了?因为当组件变大时,data、methods、computed 被分散在不同的选项中,同一个功能的代码被拆散。<script setup> 让相关代码聚在一起,按功能组织而不是按类型组织。


1.3 <template> 多根节点

语法:

<!-- Vue2:必须用一个根元素包裹 -->
<template>
  <div>
    <h1>标题</h1>
    <p>内容</p>
  </div>
</template>

<!-- Vue3:可以直接写多个兄弟节点,不需要外层包裹 -->
<template>
  <h1>标题</h1>
  <p>内容</p>
</template>

Vue2 中 <template> 下必须有一个根元素(比如一个 <div> 包住所有内容)。Vue3 取消了这一限制,可以写多个平级节点。

详细讲解:

<template>
  <header>网站头部</header>
  <main>主要内容区域</main>
  <footer>网站底部</footer>
</template>

好处是少一层无意义的 DOM 嵌套,生成的 HTML 更干净。Vue 内部用 Fragment(片段)来渲染多个根节点。


1.4 <style scoped>

语法:

<style scoped>
/* 这里的 CSS 只对当前组件生效,不会影响其他组件 */
.button { background: blue; }
</style>

<style>
/* 不加 scoped:全局 CSS,会影响所有组件 */
.button { background: red; }
</style>

scoped 是 Vue 提供的样式隔离机制。加了 scoped 后,Vue 会自动给当前组件的每个 DOM 元素加上一个独特的数据属性(如 data-v-abc123),并修改你的 CSS 选择器来匹配这个属性。

详细讲解:

不用的后果(全局样式污染):

/* 不加 scoped:你在组件 A 中写的 .title 样式,
   会影响组件 B 中同样叫 .title 的元素 */
.title { color: red; }

加了 scoped 后:

<template>
  <h1 class="title">标题</h1>
</template>

<style scoped>
/* 编译后实际变成:.title[data-v-7ba5bd90] { color: red; } */
/* 自动加了属性选择器,只匹配当前组件的元素 */
.title { color: red; }
</style>

父子组件样式穿透(进阶,了解即可):有时候你需要在父组件中修改子组件的内部样式,可以用 :deep()

<style scoped>
/* 穿透 scoped 限制,影响子组件内部元素 */
:deep(.child-class) { color: red; }
</style>

1.5 v-bind 与 v-on

v-bind —— 动态绑定 HTML 属性

语法:

v-bind:属性名="JavaScript表达式"

// 简写(推荐)
:属性名="JavaScript表达式"

v-bind 把 HTML 属性的值和一个 JavaScript 表达式绑定起来。表达式的结果会作为属性的值。

详细讲解:

<template>
  <!-- 静态属性:属性值写死 -->
  <img src="/logo.png" alt="Logo">

  <!-- 动态属性:属性值从 data 中来 -->
  <img :src="imageUrl" :alt="imageAlt">

  <!-- 布尔属性:表达式为真时才添加该属性 -->
  <button :disabled="isSubmitting">提交</button>

  <!-- 绑定多个属性(一个对象) -->
  <div v-bind="attrs"></div>
  <!-- 相当于 :id="attrs.id" :class="attrs.class" :style="attrs.style" -->
</template>

<script setup>
const imageUrl = ref('https://example.com/photo.jpg');
const imageAlt = ref('风景照片');
const isSubmitting = ref(false);
const attrs = { id: 'main', class: 'container', style: 'color: red' };
</script>

为什么要用 v-bind?没有它,<img src="imageUrl"> 中的 imageUrl 会被当作字符串字面量处理,而不是变量。:src="imageUrl" 才表示"取 JavaScript 变量 imageUrl 的值"。

v-on —— 监听 DOM 事件

语法:

v-on:事件名="事件处理函数"

// 简写(推荐)
@事件名="事件处理函数"

// 带参数
@事件名="处理函数(参数)"

// 内联语句
@事件名="count++"

v-on 监听 DOM 事件(点击、输入、鼠标移动等),触发时执行对应的 JavaScript。

详细讲解:

<template>
  <!-- 基本用法:点击执行函数 -->
  <button @click="handleClick">点击</button>

  <!-- 带参数 -->
  <button @click="deleteItem(item.id)">删除</button>

  <!-- 传事件对象 $event -->
  <input @input="handleInput($event)">

  <!-- 内联语句(简单逻辑直接写,不需要定义函数) -->
  <button @click="count++">+1</button>

  <!-- 事件修饰符 -->
  <form @submit.prevent="onSubmit">  <!-- .prevent 阻止默认行为 -->
  <a @click.stop="goHome">返回</a>     <!-- .stop 阻止事件冒泡 -->
  <input @keyup.enter="submit">         <!-- .enter 只在按回车时触发 -->
</template>

事件修饰符一览:

修饰符 作用 等价于
.stop 阻止事件冒泡 event.stopPropagation()
.prevent 阻止默认行为 event.preventDefault()
.once 事件只触发一次
.enter 只在按回车键时触发
.esc 只在按 Esc 时触发

1.6 动态 class / style 绑定

语法:

// class 绑定
:class="{ 类名: 布尔条件 }"           ← 对象语法
:class="[ 类名1, 类名2 ]"             ← 数组语法
:class="计算属性或变量"                ← 直接绑定

// style 绑定
:style="{ CSS属性: 值 }"              ← 对象语法
:style="[ 样式对象1, 样式对象2 ]"      ← 数组语法

这是 Vue 模板中最常用的功能之一——根据数据状态动态切换样式。

详细讲解:

<template>
  <!-- ===== class 对象语法 ===== -->
  <!-- active 为 true 时添加 active 类,否则去掉 -->
  <div :class="{ active: isActive, disabled: isDisabled }">
    内容
  </div>

  <!-- ===== class 数组语法 ===== -->
  <div :class="['base-class', isActive ? 'active' : '', themeClass]">
    内容
  </div>

  <!-- ===== style 对象语法 ===== -->
  <!-- CSS 属性用驼峰命名(backgroundColor 而非 background-color) -->
  <div :style="{ color: textColor, fontSize: fontSize + 'px', background: bgColor }">
    内容
  </div>

  <!-- ===== style 数组语法(多个样式对象合并) ===== -->
  <div :style="[baseStyles, activeStyles]">内容</div>
</template>

<script setup>
const isActive = ref(true);
const isDisabled = ref(false);
const themeClass = ref('dark-theme');
const textColor = ref('#333');
const fontSize = ref(16);
const bgColor = ref('#fff');
const baseStyles = { padding: '10px', margin: '5px' };
const activeStyles = { border: '1px solid blue' };
</script>

为什么需要动态 class 绑定?没有它,你需要写:

// ❌ 手动拼接 class 字符串
`base-class ${isActive ? 'active' : ''} ${themeClass}`

// ✅ Vue 的 :class 对象和数组语法更清晰可读
:class="['base-class', { active: isActive }, themeClass]"

二、响应式基础与列表渲染

2.1 ref —— 基本响应式数据

语法:

import { ref } from 'vue';

const 变量名 = ref(初始值);

// 读取值
变量名.value

// 修改值
变量名.value = 新值

ref() 把一个普通值变成"响应式"的——当 .value 改变时,任何用到这个值的地方(模板、计算属性、侦听器)都会自动更新。

详细讲解:

为什么需要 ref?普通的 JavaScript 变量不是响应式的:

// ❌ 普通变量:改了,但页面不会更新
let count = 0;
count = 1;  // 页面还是显示 0

// ✅ ref:修改 .value,页面自动更新
const count = ref(0);
count.value = 1;  // 页面自动变成 1

ref 支持任意类型:

// 基本类型
const count = ref(0);
const name = ref('Alice');
const isDone = ref(false);

// 对象类型(ref 内部会自动把对象交给 reactive 处理)
const user = ref({ name: 'Alice', age: 25 });

// 读取和修改
console.log(count.value);        // 0
count.value = 10;

console.log(user.value.name);    // 'Alice'
user.value.age = 26;

// 数组
const todos = ref([{ id: 1, text: '学习 Vue' }]);
todos.value.push({ id: 2, text: '做项目' });

.value 的规则:

场景 写法
<script setup> 中读取 变量名.value
<script setup> 中修改 变量名.value = 新值
<template> 不需要 .value,Vue 自动解包
<script setup>
const count = ref(0);
function increment() {
  count.value++;  // script 中要写 .value
}
</script>

<template>
  <!-- template 中不需要 .value,直接写变量名 -->
  <p>{{ count }}</p>
  <button @click="increment">+1</button>
</template>

为什么模板中不用 .value?Vue 在模板编译时自动对 ref 进行解包——把 count 展开为 count.value


2.2 reactive —— 深层响应式对象

语法:

import { reactive } from 'vue';

const 变量名 = reactive({1:1,2:2 });

// 直接访问属性,不需要 .value
变量名.1
变量名.1 = 新值

reactive() 把一个对象变成深层响应式的——对象中任何层级的属性发生变化,视图都会更新。与 ref 不同,reactive 不需要 .value

详细讲解:

const user = reactive({
  name: 'Alice',
  age: 25,
  address: {
    city: 'Beijing',
    zip: '100000'
  }
});

// 直接访问属性,不需要 .value
console.log(user.name);        // 'Alice'
console.log(user.address.city); // 'Beijing'

// 修改任何层级的属性,都会触发更新
user.age = 26;
user.address.city = 'Shanghai';

ref vs reactive 怎么选:

ref reactive
支持类型 任意类型(基本类型 + 对象) 只能是对象
读写方式 .value 直接读写属性
模板中 自动解包,不用 .value 直接读写属性
重新赋值 ✅ 可以 变量.value = 全新的值 ❌ 不能直接替换整个对象
解构 解构后仍保持响应式 解构后失去响应式(需要用 toRefs

实际建议: Vue 官方现在推荐优先用 ref,因为:

  1. 写法统一(全都 .value
  2. 支持基本类型
  3. 可以整体替换
  4. 解构更方便

reactive 适合你明确知道不会替换整个对象不会解构的场景。

// 大多数场景用 ref 就够了
const count = ref(0);
const user = ref({ name: 'Alice', age: 25 });
const list = ref([1, 2, 3]);

// 只有特定场景用 reactive
const state = reactive({
  items: [],
  loading: false,
  error: null
});

2.3 computed —— 计算属性

语法:

import { computed } from 'vue';

const 变量名 = computed(() => {
  // 依赖其他响应式数据,返回计算结果
  return 计算结果;
});

// 只读,不能直接赋值
// 变量名.value = xxx  ❌ 会报错

computed() 定义了一个基于其他响应式数据自动计算的值。它只在其依赖的数据发生变化时重新计算,否则返回缓存的结果。

详细讲解:

直接在模板中写表达式:

<p>{{ message.split('').reverse().join('') }}</p>

这样写的问题:如果多处用到同样的计算结果,要重复写同样的表达式,可读性差且每次渲染都要重新计算。

computed

<script setup>
const firstName = ref('张');
const lastName = ref('三');
const age = ref(25);

// computed 自动追踪依赖的响应式数据
const fullName = computed(() => `${firstName.value}${lastName.value}`);
const description = computed(() => `${fullName.value},${age.value}岁`);

// 列表过滤
const todos = ref([
  { id: 1, text: '学习 Vue', done: true },
  { id: 2, text: '做项目', done: false },
  { id: 3, text: '写文档', done: false }
]);

const activeTodos = computed(() => todos.value.filter(t => !t.done));
</script>

<template>
  <p>{{ fullName }}</p>         <!-- 张三 -->
  <p>{{ description }}</p>      <!-- 张三,25岁 -->
  <ul>
    <li v-for="todo in activeTodos" :key="todo.id">
      {{ todo.text }}
    </li>
  </ul>
</template>

computed vs 普通函数:

computed 普通函数
缓存 ✅ 依赖不变时返回缓存结果 ❌ 每次调用都重新计算
模板调用 直接写变量名 {{ fullName }} 加括号 {{ getFullName() }}
自动追踪 ✅ 自动追踪依赖 ❌ 不追踪
<template>
  <!-- computed —— 只有在 todos 或 filter 变化时才重新计算 -->
  <p>{{ filteredTodos.length }}</p>

  <!-- 普通函数 —— 每次渲染都执行一遍 -->
  <p>{{ getFilteredTodos().length }}</p>
</template>

所以当计算有性能开销(遍历、过滤、排序)时,应该用 computed


2.4 条件渲染 v-if / v-show

语法:

<!-- v-if:条件为真才渲染元素(真正的创建/销毁) -->
<元素 v-if="条件">内容</元素>
<元素 v-else-if="条件2">内容</元素>
<元素 v-else>内容</元素>

<!-- v-show:始终渲染元素,只是切换 display 的显示/隐藏 -->
<元素 v-show="条件">内容</元素>

两者都根据条件决定元素是否可见,但实现机制不同。

详细讲解:

<template>
  <!-- ===== v-if 系列 ===== -->
  <!-- 条件为 true 时 DOM 中存在,false 时 DOM 中完全不存在 -->
  <p v-if="score >= 90">优秀</p>
  <p v-else-if="score >= 60">及格</p>
  <p v-else>不及格</p>

  <!-- ===== v-show ===== -->
  <!-- DOM 中始终存在,只是 display: none -->
  <p v-show="isVisible">这条内容始终在 DOM 中,只是隐藏了</p>

  <!-- ===== <template> 上的条件渲染 ===== -->
  <!-- v-if 可以写在 <template> 上(不会额外产生 DOM 节点) -->
  <template v-if="hasHeader">
    <h1>标题</h1>
    <p>副标题</p>
  </template>
</template>

v-if vs v-show 对比:

v-if v-show
渲染机制 条件为真时才渲染到 DOM 始终渲染,切换 display
切换成本 高(销毁/重建 DOM) 低(只改 CSS)
初始渲染成本 低(条件为假时不渲染) 高(始终渲染)
适用场景 运行时很少切换 频繁切换显示/隐藏
配合 v-else ✅ 支持 ❌ 不支持

怎么选:

<!-- 用户登录状态切换 —— 不频繁,用 v-if -->
<AdminPanel v-if="user.role === 'admin'" />

<!-- 下拉菜单展开收起 —— 频繁切换,用 v-show -->
<div v-show="menuOpen">
  <!-- 菜单内容 -->
</div>

2.5 v-for —— 列表渲染

语法:

<!-- 遍历数组 -->
<元素 v-for="(当前项, 索引) in 数组" :key="唯一标识">
  {{ 当前项 }}
</元素>

<!-- 遍历对象 -->
<元素 v-for="(值, 键, 索引) in 对象" :key="键">
  {{ 键 }}: {{ 值 }}
</元素>

<!-- 遍历数字范围(1 到 N) -->
<元素 v-for="n in 10" :key="n">
  {{ n }}
</元素>

v-for 根据数据列表生成重复的 DOM 元素。:key必须的,帮助 Vue 高效追踪每个列表项的身份。

详细讲解:

<template>
  <!-- ===== 基本列表 ===== -->
  <ul>
    <li v-for="item in items" :key="item.id">
      {{ item.text }}
    </li>
  </ul>

  <!-- ===== 带索引 ===== -->
  <ul>
    <li v-for="(item, index) in items" :key="item.id">
      {{ index + 1 }}. {{ item.text }}
    </li>
  </ul>

  <!-- ===== 遍历对象 ===== -->
  <ul>
    <li v-for="(value, key, index) in user" :key="key">
      {{ index }}. {{ key }}: {{ value }}
    </li>
  </ul>
</template>

<script setup>
const items = ref([
  { id: 1, text: '学习 Vue' },
  { id: 2, text: '做项目' },
  { id: 3, text: '写文档' }
]);

const user = ref({
  name: 'Alice',
  age: 25,
  role: 'admin'
});
</script>

为什么必须用 :key

想象一个场景:列表有 3 项 [A, B, C],你在最前面插入一项 X,变成 [X, A, B, C]

没有 :key,Vue 不会知道哪些是新增、哪些是移动,会逐个更新已有元素的内容(A→X, B→A, C→B, 再新增 C),效率低且会引发 UI 闪烁。

:key(用唯一 ID),Vue 知道 X 是新的、A/B/C 只是向后移动了,它直接移动已有的 DOM 节点到新位置,只新增 X

:key 的选择:

<!-- ✅ 用数据自带的唯一 ID(最好) -->
<li v-for="item in list" :key="item.id">{{ item.text }}</li>

<!-- ✅ 用索引(仅当列表静态不变化时) -->
<li v-for="(item, index) in list" :key="index">{{ item.text }}</li>

<!-- ❌ 不要用随机数或 Math.random() -->
<!-- 每次渲染 key 都变,等于完全重新创建所有元素 -->
<li v-for="item in list" :key="Math.random()">{{ item.text }}</li>

v-forv-if 不要同时用在同一个元素上:

<!-- ❌ 错误:v-for 优先级高于 v-if,每次都会遍历全部再判断 -->
<li v-for="item in items" v-if="item.isActive" :key="item.id">
  {{ item.text }}
</li>

<!-- ✅ 正确:用 computed 先过滤再遍历 -->
<li v-for="item in activeItems" :key="item.id">
  {{ item.text }}
</li>
<script setup>
const activeItems = computed(() => items.value.filter(i => i.isActive));
</script>

2.6 综合示例 —— Todo List 核心逻辑

把本阶段学到的知识点串联起来:

<script setup>
import { ref, computed } from 'vue';

// ref 声明响应式数据
const newTodo = ref('');
const todos = ref([
  { id: 1, text: '学习 ref 和 reactive', done: true },
  { id: 2, text: '掌握 v-for 和 :key', done: false },
  { id: 3, text: '理解 computed', done: false }
]);

// computed 派生状态
const stats = computed(() => {
  const total = todos.value.length;
  const done = todos.value.filter(t => t.done).length;
  return { total, done, percent: total ? Math.round(done / total * 100) : 0 };
});

// 函数
function addTodo() {
  if (!newTodo.value.trim()) return;
  todos.value.push({ id: Date.now(), text: newTodo.value, done: false });
  newTodo.value = '';
}

function toggleTodo(id) {
  const todo = todos.value.find(t => t.id === id);
  if (todo) todo.done = !todo.done;
}

function removeTodo(id) {
  todos.value = todos.value.filter(t => t.id !== id);
}
</script>

<template>
  <div class="todo-app">
    <!-- v-model 双向绑定输入框(下阶段详解) -->
    <input v-model="newTodo" @keyup.enter="addTodo" placeholder="添加待办">
    <button @click="addTodo">添加</button>

    <!-- v-for 渲染列表,:key 绑定唯一 id -->
    <!-- :class 动态绑定完成样式 -->
    <ul>
      <li v-for="todo in todos" :key="todo.id"
          :class="{ completed: todo.done }">
        <input type="checkbox" :checked="todo.done" @change="toggleTodo(todo.id)">
        <span>{{ todo.text }}</span>
        <button @click="removeTodo(todo.id)">删除</button>
      </li>
    </ul>

    <!-- v-if/v-else 条件渲染 -->
    <p v-if="todos.length === 0">暂无待办</p>
    <p v-else>完成 {{ stats.done }}/{{ stats.total }}({{ stats.percent }}%)</p>
  </div>
</template>

<style scoped>
.completed span { text-decoration: line-through; color: #999; }
.todo-app { max-width: 400px; margin: 20px auto; }
ul { list-style: none; padding: 0; }
li { padding: 8px 0; border-bottom: 1px solid #eee; }
</style>

这个示例包含了本阶段的核心知识点:

  • ref 声明响应式数据
  • computed 派生计算结果
  • v-for 列表渲染 + :key
  • v-if/v-else 条件渲染
  • :class 动态样式绑定
  • @click/@keyup.enter 事件监听
  • <style scoped> 组件样式隔离

更多推荐