Vue 基础
Vue 核心基础知识,包括响应式、组件、模板语法和常用 API
一、Vue 简介
1.1 前端框架进化史
前端的发展可以概括为一句话:从 "操作 DOM" 进化到 "操作数据"。整个过程大致经历了三个阶段。
| 阶段 | 代表技术 | 核心思想 | 痛点 |
|---|---|---|---|
| 语义化模板 | JSP / PHP / EJS / Underscore | 字符串拼接 + 服务端渲染 | 前后端耦合严重,复用性差 |
| MVC | Backbone / Angular 1 | 视图、模型、控制器三层解耦 | View 与 Model 之间需要手动同步 |
| MVVM | Vue / React / Angular 2+ | 数据驱动视图,自动双向绑定 | 学习曲线、心智模型转变 |
1.1.1 语义化模板
最早期前端就是后端模板里挖个 "坑",把数据填进去:
<!-- 早期 EJS / Underscore 模板写法 -->
<ul>
<% list.forEach(function(item){ %>
<li><%= item.name %></li>
<% }) %>
</ul>
问题:模板只能渲染一次,数据变了要自己手动 innerHTML 重新渲染整段 DOM,性能差且容易出 bug。
1.1.2 MVC
MVC 把代码拆成三块:
- Model(模型): 数据层,负责业务数据与请求
- View(视图): 视图层,负责展示
- Controller(控制器): 控制层,作为桥梁协调 M 和 V
┌──────────┐
│ View │ ◄────── render
└────┬─────┘
│ user event
▼
┌──────────┐
│Controller│
└────┬─────┘
│ update
▼
┌──────────┐
│ Model │
└──────────┘
缺点:Model 变更后需要在 Controller 里手动调用 View 更新,同步逻辑分散在各处。
1.1.3 MVVM
MVVM 是 Vue 的灵魂。ViewModel 在中间做了一层 "桥",让 View 和 Model 自动同步:
┌───────┐ DOM Listeners ┌────────────┐ Data Bindings ┌───────┐
│ View │ ─────────────────► │ ViewModel │ ─────────────────► │ Model │
│ (DOM) │ ◄───────────────── │ (Vue 实例) │ ◄───────────────── │(data) │
└───────┘ └────────────┘ └───────┘
- Model → View: 数据变化自动反映到视图(响应式)
- View → Model: 用户输入自动写回数据(
v-model双向绑定)
💡 面试常问: MVVM 和 MVC 区别? 核心差异在于 数据与视图是否自动同步。MVC 需要 Controller 手动协调;MVVM 由 ViewModel 通过数据劫持 + 发布订阅自动完成。
1.2 Vue 简介
1.2.1 Vue 思想与发展史
- 作者: 尤雨溪(Evan You),前 Google 工程师
- 设计哲学: 渐进式框架(Progressive Framework),按需引入
- 核心思想: 数据驱动视图 + 组件化
发展时间线:
| 版本 | 时间 | 关键变化 |
|---|---|---|
| Vue 1.x | 2014 | 初版发布,引入响应式与指令系统 |
| Vue 2.x | 2016 | 引入虚拟 DOM、组件化体系成熟 |
| Vue 3.x | 2020 | Composition API、Proxy 响应式、性能大幅提升 |
| Vue 3.2+ | 2021+ | <script setup> 语法糖、性能进一步优化 |
1.2.2 Vue 项目写法介绍
Vue 提供两种主要写法:Options API 与 Composition API。
| 维度 | Options API(Vue 2 主流) | Composition API(Vue 3 推荐) |
|---|---|---|
| 组织方式 | 按"选项"分类(data/methods/computed) | 按"逻辑功能"组织 |
| 复用能力 | mixin(命名冲突、来源不清) | 组合式函数 useXxx,清晰可追踪 |
| 类型推导 | 较差 | 天然友好,对 TS 极佳 |
| 学习曲线 | 平缓,适合新手 | 灵活但需要理解响应式 API |
Options API 示例:
<script>
export default {
data() {
// 组件状态
return { count: 0 }
},
computed: {
// 计算属性
double() { return this.count * 2 }
},
methods: {
// 方法
increment() { this.count++ }
}
}
</script>
Composition API 示例(<script setup>):
<script setup>
import { ref, computed } from 'vue'
// 响应式状态
const count = ref(0)
// 计算属性
const double = computed(() => count.value * 2)
// 方法
const increment = () => count.value++
</script>
💡 提示: 新项目优先使用 Composition API +
<script setup>+ TypeScript。
二、Vue 模板与基础指令
2.1 模板化方案
2.1.1 模板化写法
Vue 使用的是 基于 HTML 的模板语法,背后会被编译成 渲染函数(render function),最终生成 虚拟 DOM。
<template>
<!-- 文本插值:使用 Mustache 语法 -->
<p>Hello, {{ name }}!</p>
<!-- 原始 HTML:使用 v-html,注意 XSS -->
<div v-html="rawHtml"></div>
<!-- 属性绑定 -->
<a :href="url">点我</a>
<!-- JavaScript 表达式(只能写表达式,不能写语句) -->
<p>{{ ok ? 'YES' : 'NO' }}</p>
</template>
编译流程:
模板字符串 ──► AST 抽象语法树 ──► render 函数 ──► VNode ──► 真实 DOM
parse transform generate patch
2.1.2 团队使用优缺点
| 维度 | 模板(Template) | JSX(React 风格) |
|---|---|---|
| 上手难度 | 低,HTML 即可 | 需要 JS + JSX 语法 |
| 静态优化 | 编译期可做静态提升、Patch Flag | 运行时优化为主 |
| 灵活性 | 受限于指令系统 | JS 表达力极强 |
| 团队协作 | 设计师/初级前端友好 | 对开发者要求更高 |
💡 建议: 中大型团队推荐模板写法,写法统一、IDE 工具链完善、编译期优化收益大。
2.2 基础指令
2.2.1 常用内置指令清单
| 指令 | 作用 | 备注 |
|---|---|---|
v-text |
设置元素文本内容 | 等价 {{ }},但会覆盖 |
v-html |
设置 innerHTML | ⚠️ XSS 风险 |
v-show |
切换 display |
频繁切换用 |
v-if / v-else-if / v-else |
条件渲染(销毁/创建) | 不常切换用 |
v-for |
列表渲染 | 必须配 key |
v-on (@) |
事件绑定 | |
v-bind (:) |
属性绑定 | 可绑定对象 |
v-model |
双向绑定 | 表单元素 / 组件 |
v-slot (#) |
插槽 | 替代旧的 slot |
v-pre |
跳过编译 | 显示原始 {{ }} |
v-once |
只渲染一次 | 静态内容优化 |
v-memo(Vue 3.2+) |
记忆化渲染 | 列表性能优化 |
v-cloak |
防止闪烁 | 配合 CSS 使用 |
典型用法示例:
<template>
<!-- 条件渲染 -->
<p v-if="status === 'success'">成功</p>
<p v-else-if="status === 'loading'">加载中</p>
<p v-else>失败</p>
<!-- 列表渲染:key 必须是稳定且唯一的 -->
<ul>
<li v-for="item in list" :key="item.id">
{{ item.name }}
</li>
</ul>
<!-- 属性绑定:动态 class -->
<div :class="{ active: isActive, 'text-danger': hasError }"></div>
<!-- 属性绑定:动态 style -->
<div :style="{ color: activeColor, fontSize: fontSize + 'px' }"></div>
<!-- 双向绑定 -->
<input v-model="message" />
</template>
2.2.2 自定义指令
当原生指令不够用、需要直接操作 DOM 时(如自动聚焦、防抖、按钮权限),可以定义自定义指令。
Vue 3 自定义指令(生命周期钩子重命名):
// 全局注册:v-focus
app.directive('focus', {
// 元素挂载到 DOM 之后调用
mounted(el) {
// 自动聚焦
el.focus()
}
})
带参数的指令——按钮权限示例:
// v-permission="'user:delete'"
app.directive('permission', {
mounted(el, binding) {
// binding.value 即指令传入的值
const permissions = store.state.user.permissions
if (!permissions.includes(binding.value)) {
// 没权限就移除元素
el.parentNode?.removeChild(el)
}
}
})
<template>
<input v-focus />
<button v-permission="'user:delete'">删除用户</button>
</template>
Vue 2 vs Vue 3 钩子对照:
| Vue 2 | Vue 3 | 时机 |
|---|---|---|
bind |
beforeMount |
指令首次绑定到元素 |
inserted |
mounted |
元素插入父节点 |
update |
beforeUpdate |
组件更新前 |
componentUpdated |
updated |
组件更新后 |
unbind |
unmounted |
指令解绑 |
2.2.3 常见面试陷阱
陷阱 1:v-if vs v-show 怎么选?
| 维度 | v-if |
v-show |
|---|---|---|
| 实现 | 真实创建/销毁 DOM | 切换 display: none |
| 初始开销 | 低(false 时不渲染) | 高(始终渲染) |
| 切换开销 | 高 | 低 |
| 编译产物 | 条件块 | 普通元素 + 样式 |
| 适用场景 | 条件很少改变 | 频繁切换 |
陷阱 2:v-for 和 v-if 能一起用吗?
<!-- ❌ Vue 2:v-for 优先级高于 v-if,会遍历后再过滤,浪费性能 -->
<!-- ❌ Vue 3:v-if 优先级高于 v-for,访问不到 item 变量,直接报错 -->
<li v-for="item in list" v-if="item.active" :key="item.id">
{{ item.name }}
</li>
正确做法:用 computed 先过滤,或外层包一层 <template v-if>。
<script setup>
import { computed } from 'vue'
// 先过滤再渲染
const activeList = computed(() => list.value.filter(i => i.active))
</script>
<template>
<li v-for="item in activeList" :key="item.id">{{ item.name }}</li>
</template>
陷阱 3:v-for 的 key 为什么不能用 index?
- 列表项顺序变化时,相同 index 会复用到错误的节点
- 表单内容会错位、动画会异常
key必须是稳定、唯一、可预测的 id
陷阱 4:v-html 的安全问题
⚠️ 永远不要对用户输入使用 v-html,会导致 XSS。必须用 DOMPurify 等工具消毒后再渲染。
三、Vue 事件及数据绑定
3.1 Vue 事件
3.1.1 事件处理
<template>
<!-- 内联表达式 -->
<button @click="count++">+1</button>
<!-- 方法引用 -->
<button @click="handleClick">Click</button>
<!-- 方法调用 + 传参 -->
<button @click="greet('hello')">Greet</button>
<!-- 传参同时拿到原生事件 -->
<button @click="say('hi', $event)">Say</button>
</template>
<script setup>
function handleClick(event) {
// event 是原生 DOM 事件
console.log(event.target)
}
function say(msg, event) {
// 同时拿到自定义参数和原生事件
console.log(msg, event.target)
}
</script>
3.1.2 事件修饰符
Vue 把"调用 event.stopPropagation()""阻止默认行为"等常见操作封装成修饰符,写在事件名后用 . 分隔。
| 修饰符 | 等价代码 | 使用场景 |
|---|---|---|
.stop |
event.stopPropagation() |
阻止冒泡 |
.prevent |
event.preventDefault() |
阻止默认行为(如表单提交) |
.capture |
添加事件监听器时使用 capture 模式 | 捕获阶段触发 |
.self |
仅当 event.target 是当前元素时触发 |
排除子元素冒泡 |
.once |
只触发一次 | 一次性事件 |
.passive |
{ passive: true } |
移动端滚动性能优化 |
按键修饰符:
<template>
<!-- 回车提交 -->
<input @keyup.enter="submit" />
<!-- 组合键:Ctrl + S -->
<input @keydown.ctrl.s.prevent="save" />
<!-- 仅当 ctrl 单独按下时触发,按了别的就不触发 -->
<input @keydown.ctrl.exact="onlyCtrl" />
</template>
鼠标修饰符: .left、.right、.middle
⚠️ 注意:
.passive和.prevent不要一起用,前者承诺不调用preventDefault,浏览器会忽略.prevent。
3.1.3 设计思想
为什么 Vue 要发明事件修饰符?
- 分离关注点:方法只关心业务,修饰符负责 DOM 细节
- 声明式:读
@click.stop.prevent就知道这里阻止了冒泡和默认行为,比方法里翻代码快得多 - 可组合:修饰符可以链式使用,组合出复杂行为
3.2 Vue 数据绑定
3.2.1 基础绑定
单向绑定 v-bind (:): 数据 → 视图
<template>
<!-- 普通属性 -->
<img :src="imgUrl" :alt="imgAlt" />
<!-- 一次性绑定多个属性(对象语法) -->
<input v-bind="{ id: 'name', type: 'text', placeholder: '请输入' }" />
<!-- 布尔属性:值为 false 会直接移除属性 -->
<button :disabled="isLoading">提交</button>
</template>
双向绑定 v-model: 数据 ⇄ 视图
<template>
<!-- 文本输入 -->
<input v-model="message" />
<!-- 多行文本 -->
<textarea v-model="comment"></textarea>
<!-- 单选 -->
<input type="radio" value="male" v-model="gender" />
<!-- 多选 -->
<input type="checkbox" value="reading" v-model="hobbies" />
<!-- 下拉 -->
<select v-model="city">
<option value="bj">北京</option>
</select>
</template>
v-model 的本质:
<!-- v-model 是这段代码的语法糖 -->
<input :value="message" @input="message = $event.target.value" />
3.2.2 数据修饰符
v-model 也支持修饰符来处理输入数据:
| 修饰符 | 作用 |
|---|---|
.lazy |
change 事件后才同步,不是 input |
.number |
自动转 Number 类型 |
.trim |
自动去除首尾空格 |
<template>
<!-- 输入数字时自动 parseFloat -->
<input v-model.number="age" />
<!-- 失焦时才更新数据 -->
<input v-model.lazy="searchKey" />
<!-- 去除空格 -->
<input v-model.trim="username" />
</template>
3.2.3 语法糖支持
Vue 2 的 .sync 修饰符:
<!-- Vue 2 -->
<Child :title.sync="title" />
<!-- 等价于 -->
<Child :title="title" @update:title="title = $event" />
Vue 3 改进: .sync 被废弃,组件可以使用多个 v-model:
<!-- Vue 3 父组件 -->
<UserForm
v-model:name="user.name"
v-model:age="user.age"
/>
<!-- 子组件 UserForm.vue -->
<script setup>
defineProps(['name', 'age'])
// emits 必须显式声明
defineEmits(['update:name', 'update:age'])
</script>
<template>
<input
:value="name"
@input="$emit('update:name', $event.target.value)"
/>
</template>
💡 面试: 为什么 Vue 不像 React 那样只支持单向数据流? Vue 本质也是单向数据流。
v-model只是:value+@input的语法糖,数据流向并没有变。它降低了写表单时的样板代码量。
四、Vue 组件化 — 面试重点 ⭐
4.1 Vue 组件化写法
Vue 推荐使用 SFC(Single File Component,单文件组件),一个 .vue 文件包含模板、逻辑、样式三部分。
<!-- UserCard.vue -->
<template>
<div class="card">
<h3>{{ user.name }}</h3>
<p>{{ user.bio }}</p>
</div>
</template>
<script setup>
// 接收父组件传入的 user
defineProps({
user: { type: Object, required: true }
})
</script>
<style scoped>
/* scoped 让样式仅作用于当前组件 */
.card {
padding: 12px;
border: 1px solid #eee;
}
</style>
组件注册:
| 方式 | 写法 | 适用场景 |
|---|---|---|
| 局部注册 | import 后直接在 <template> 使用 |
默认推荐,按需引入 |
| 全局注册 | app.component('MyComp', MyComp) |
全局公共组件(如 Button、Icon) |
| 异步组件 | defineAsyncComponent(() => import('./X.vue')) |
代码分割、按需加载 |
4.2 组件化实质
组件的本质是 一个可复用的 Vue 实例(或选项对象),背后对应一个 组件 VNode。
App.vue
│
▼
根组件实例 (root instance)
│
├── 渲染 <Header /> ──► 子组件 VNode ──► Header 实例
├── 渲染 <Sidebar /> ──► 子组件 VNode ──► Sidebar 实例
└── 渲染 <Main /> ──► 子组件 VNode ──► Main 实例
│
└── <ArticleList />
组件化要解决的三大问题:
- 复用: 写一次,到处用
- 隔离: 作用域隔离(scoped 样式、局部状态)
- 组合: 小组件组合成大组件
💡 面试: 为什么 data 必须是函数? 因为每个组件实例都需要独立的状态副本。如果
data是对象,所有实例会共享同一份引用,A 改了 B 也跟着变。函数返回新对象就能让每个实例各拿一份。
4.3 组件传参与状态维护
这是面试高频题。先看一张总览:
| 通信方式 | 方向 | 适用场景 |
|---|---|---|
| props / emit | 父 ⇄ 子 | 官方推荐,首选 |
| v-model | 父 ⇄ 子 | 表单类组件 |
$attrs / $listeners(V2)/ $attrs(V3) |
父 ↔ 后代 | 透传属性、二次封装 |
ref / defineExpose |
父 → 子 | 父调子的方法 |
| provide / inject | 祖先 → 后代(跨层) | 主题、配置、依赖注入 |
| slot | 父 → 子(结构) | 内容分发 |
| EventBus(V2) / mitt(V3) | 任意 | 小项目可用,大项目不推荐 |
| Vuex / Pinia | 任意 | 中大型项目的全局状态 |
4.3.1 父子通信:props + emit
<!-- 父组件 Parent.vue -->
<template>
<Child :title="title" @update="onUpdate" />
</template>
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'
const title = ref('Hello')
// 接收子组件抛出的事件
const onUpdate = (newTitle) => {
title.value = newTitle
}
</script>
<!-- 子组件 Child.vue -->
<template>
<h1>{{ title }}</h1>
<button @click="emit('update', 'New Title')">改标题</button>
</template>
<script setup>
// 声明 props
defineProps({
title: { type: String, required: true }
})
// 声明可以抛出的事件
const emit = defineEmits(['update'])
</script>
4.3.2 跨层级通信:provide / inject
适合祖先组件向深层后代传递不常变的数据(如主题、用户信息)。
<!-- 祖先组件 -->
<script setup>
import { provide, ref } from 'vue'
const theme = ref('dark')
// 注入数据,后代任意深度都能拿到
provide('theme', theme)
</script>
<!-- 后代组件(无论嵌套多深) -->
<script setup>
import { inject } from 'vue'
const theme = inject('theme', 'light') // 第二个参数为默认值
</script>
4.3.3 内容分发:slot
<!-- Card.vue:定义插槽 -->
<template>
<div class="card">
<header><slot name="header" /></header>
<main>
<!-- 默认插槽,可向父组件暴露数据(作用域插槽) -->
<slot :user="user">默认内容</slot>
</main>
<footer><slot name="footer" /></footer>
</div>
</template>
<!-- 使用 Card -->
<Card>
<template #header>
<h2>标题</h2>
</template>
<template #default="{ user }">
<!-- 从子组件拿到 user -->
<p>{{ user.name }}</p>
</template>
<template #footer>
<button>确认</button>
</template>
</Card>
4.3.4 全局状态:Pinia(Vue 3 官方推荐)
// stores/user.js
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({ name: '', token: '' }),
getters: {
isLogin: (state) => !!state.token
},
actions: {
async login(form) {
// 业务逻辑
const res = await api.login(form)
this.name = res.name
this.token = res.token
}
}
})
<script setup>
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// 直接读写
console.log(userStore.name)
userStore.login({ /* ... */ })
</script>
Pinia vs Vuex:
| 维度 | Vuex 4 | Pinia |
|---|---|---|
| 模块化 | namespaced 模块 | 天然多 store,无嵌套 |
| Mutation | 必须经过 mutation | action 直接改 state |
| TS 支持 | 一般 | 极佳 |
| 体积 | 较大 | ~1kb |
| Vue 官方 | Vue 3 推荐 → Pinia | ✅ 新项目首选 |
4.4 大型项目的组件管理
4.4.1 目录结构建议
src/
├── components/ # 通用业务组件
│ ├── base/ # 基础原子组件(BaseButton、BaseInput)
│ └── business/ # 业务复合组件
├── views/ # 路由级页面
├── layouts/ # 布局组件(DefaultLayout、AdminLayout)
├── composables/ # 组合式函数(useXxx)
├── stores/ # Pinia stores
├── directives/ # 自定义指令
├── api/ # 接口请求
├── utils/ # 工具函数
├── router/ # 路由配置
└── assets/ # 静态资源
4.4.2 组件分层原则
| 层级 | 命名 | 职责 |
|---|---|---|
| 原子组件 | BaseXxx |
无业务、纯展示、强复用 |
| 业务组件 | Xxx |
包含业务逻辑,可复用于多个页面 |
| 页面组件 | views/Xxx.vue |
不复用,组装业务组件 |
| 布局组件 | layouts/Xxx.vue |
框架骨架 |
4.4.3 性能与维护要点
- 路由懒加载:
component: () => import('@/views/User.vue') - 异步组件 + Suspense:处理加载/错误状态
- 组件粒度:避免"巨石组件",但也别拆得过细(防止 props drilling)
<KeepAlive>缓存:tab 切换场景保留组件状态v-memo/shallowRef:大数据列表性能优化- 禁止:组件内部直接改 props(违反单向数据流)
💡 工程化建议: 配合 ESLint + Prettier + Husky + lint-staged 强制代码规范,并用 Storybook 或类似工具维护组件文档。
五、面试高频题速查
| # | 题目 | 关键词 |
|---|---|---|
| 1 | MVVM 和 MVC 的区别? | 自动同步、ViewModel |
| 2 | v-if 和 v-show 区别? |
创建销毁 vs display |
| 3 | v-for 为什么要 key?index 可以吗? |
复用、就地更新 |
| 4 | v-for 和 v-if 同时使用? |
V2 v-for 优先、V3 v-if 优先 |
| 5 | v-model 原理? |
:value + @input 语法糖 |
| 6 | Vue 父子组件通信方式有哪些? | props/emit/v-model/ref/provide/inject |
| 7 | 为什么 data 必须是函数? | 实例隔离 |
| 8 | Options API vs Composition API? | 逻辑组织、复用、TS |
| 9 | Pinia 比 Vuex 好在哪? | 无 mutation、TS 友好、模块化 |
| 10 | scoped 样式原理? | 属性选择器 [data-v-xxx] |
📚 推荐进一步学习: