← Frontend

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 />

组件化要解决的三大问题:

  1. 复用: 写一次,到处用
  2. 隔离: 作用域隔离(scoped 样式、局部状态)
  3. 组合: 小组件组合成大组件

💡 面试: 为什么 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]

📚 推荐进一步学习: