Skip to content

Vue2:经典组件化框架与 Options API

Vue2 可以看成 前端组件化框架进入工程主流阶段时,一套非常经典、非常有代表性的方案

它的重要性不只是“历史上很流行”,而是直到现在,很多中后台、运营系统、管理系统里仍然有大量 Vue2 项目在稳定运行。

1. Vue2 是什么

Vue2 是 Vue 框架的第二代主流版本。

它最核心的能力包括:

  1. 用组件组织页面
  2. 用响应式系统让数据变化驱动视图更新
  3. 用模板语法描述界面
  4. Options API 组织组件逻辑

如果用一句更工程化的话说:Vue2 解决的是“页面越来越复杂后,如何把 DOM 操作、状态和交互逻辑从手写代码里抽出来,用更稳定的组件模型来管理”。

2. Vue2 主要解决什么问题

在组件化框架普及之前,前端页面很容易出现这些问题:

  1. 结构、样式、行为耦合在一起
  2. 页面状态变化后,需要手动更新 DOM
  3. 多个模块之间逻辑边界不清
  4. 页面一复杂,维护成本迅速上升

Vue2 的思路是:

  1. 把页面拆成组件
  2. 用声明式模板替代大量手写 DOM 更新
  3. 把状态、方法、计算属性、侦听逻辑集中到组件里

3. Vue2 的核心模型是什么

Vue2 最适合抓住 4 个关键词:

  1. 组件
  2. 模板
  3. 响应式
  4. Options API

3.1 组件

组件可以看成 页面里的可复用功能单元

一个列表页,通常可以拆成:

  1. 筛选区组件
  2. 表格组件
  3. 分页组件
  4. 弹窗组件

3.2 模板

Vue2 使用模板语法来描述视图,例如:

vue
<template>
  <div>
    <h2>{{ title }}</h2>
    <button @click="count++">点击 {{ count }}</button>
  </div>
</template>

这类写法的价值是:你更多是在描述“状态和视图的关系”,而不是手动写 DOM 更新步骤。

3.3 响应式

Vue2 的响应式核心是:数据变了,依赖这份数据的视图会重新更新。

3.4 Options API

Vue2 最典型的组件组织方式是:

js
export default {
  data() {
    return {
      keyword: '',
      list: []
    }
  },
  computed: {
    total() {
      return this.list.length
    }
  },
  methods: {
    fetchList() {}
  }
}

这里的 datacomputedmethodswatch、生命周期钩子,就是 Vue2 最经典的组织方式。

4. Vue2 的响应式原理是什么

Vue2 主要基于 Object.defineProperty 做响应式。

把这条链路拆开:

  1. 在初始化阶段遍历对象属性
  2. 通过 getter 收集依赖
  3. 通过 setter 触发更新

这套机制在当时很实用,但也有边界:

  1. 对对象新增属性支持不自然
  2. 对删除属性支持不自然
  3. 数组某些变化处理不够直接
  4. 初始化时递归遍历有成本

这也是后面 Vue3 改用 Proxy 的重要背景之一。

4.1 Vue2 的数据绑定到底是什么

很多人会把“数据绑定”理解成一句口号:数据变了,页面就变了。

这句话没错,但如果不继续往下拆,后面就很难解释:

  1. 为什么模板里写了 {{ count }} 就能联动
  2. 为什么 v-model 可以双向更新
  3. 为什么改完数据后 DOM 不是每次都立刻同步更新

Vue2 的数据绑定其实包含两层:

  1. 单向数据流:状态驱动视图渲染
  2. 输入事件回写:像 v-model 这种语法糖,再把用户输入回写到状态

例如:

vue
<template>
  <div>
    <p>{{ count }}</p>
    <input v-model="keyword" />
  </div>
</template>

这里至少发生了两件事:

  1. 模板读取 countkeyword,建立“视图依赖哪些数据”的关系
  2. 输入框触发输入事件时,再把最新值写回 keyword

所以更稳的理解是:Vue2 不是“神奇双向绑定框架”,而是以单向渲染为主,再对表单场景提供了更方便的双向语法糖。

4.2 Vue2 是怎么做到“数据改了,视图跟着变”的

可以把 Vue2 的核心链路先压缩成 5 步:

  1. 初始化组件时,把 data 里的字段转换成响应式属性
  2. 模板渲染时读取这些属性
  3. 读取过程触发 getter,收集当前渲染 watcher 依赖
  4. 修改数据时触发 setter,通知相关 watcher
  5. watcher 进入异步更新队列,最终重新渲染并 patch DOM

如果只记一条主线,可以记成:defineProperty 劫持 -> getter 收集依赖 -> setter 通知 watcher -> watcher 重新渲染 -> patch 更新 DOM

下面这段可以当成“源码风格的简化版思路”来看:

js
/**
 * defineReactive
 * 功能:把对象上的某个字段转换成响应式属性。
 * 核心职责:
 * 1. 在 getter 中做依赖收集
 * 2. 在 setter 中做变更通知
 * 参数:
 * - obj: 要被处理的目标对象
 * - key: 需要变成响应式的字段名
 * - val: 字段当前值,会被闭包保存
 * 返回值:
 * - 无显式返回值,副作用是直接改写 obj[key] 的访问行为
 */
function defineReactive(obj, key, val) {
  // 每个响应式属性都维护一份自己的依赖列表
  const dep = new Dep()

  Object.defineProperty(obj, key, {
    get() {
      // 渲染或计算过程中如果存在当前活跃 watcher,就把它收集进来
      if (Dep.target) {
        dep.depend()
      }
      return val
    },
    set(newVal) {
      if (newVal === val) return
      val = newVal
      // 值变化后,通知所有依赖这个属性的 watcher
      dep.notify()
    }
  })
}

这段代码最值得看懂的是:

  1. getter 里做依赖收集
  2. setter 里做变更通知
  3. 每个响应式属性背后都挂着一组依赖

4.2.1 Vue2 为什么要单独处理数组响应式

对象和数组在 Vue2 里虽然都属于响应式数据,但处理方式并不完全一样。

原因在于:

  1. 普通对象可以在初始化阶段对每个字段做 defineProperty
  2. 数组如果也想按“每个下标都劫持”来处理,成本高,而且很多操作并不是简单的 arr[index] = value
  3. 数组更常见的变化,其实是 pushpopsplice 这类变异方法

所以 Vue2 对数组的核心思路不是:把每个数组下标都像对象属性一样完整劫持。

而是:拦截数组的变异方法,在方法执行后继续观测新元素,并主动通知依赖更新。

4.2.2 Vue2 是怎么处理数组变异方法的

Vue2 里最关键的做法,是重写一批会修改原数组的方法,例如:

  1. push
  2. pop
  3. shift
  4. unshift
  5. splice
  6. sort
  7. reverse

这些方法之所以要单独处理,是因为它们会直接改变数组内容和结构。

更工程化地说,Vue2 在数组这条线上主要做两件事:

  1. 如果新插入了元素,要继续把这些新元素变成可观测数据
  2. 数组结构变化后,要通知依赖这个数组的 watcher 更新

把这一段压成一句话:对象主要靠 getter/setter,数组主要靠改写原型方法。

4.2.3 一个“源码风格”的数组响应式简化示例

下面这段不是 Vue2 官方源码原样,而是方便理解主线的简化版本:

js
const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)

;['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(method => {
  const original = arrayProto[method]

  /**
   * 改写数组变异方法的简化示例
   * 功能:
   * - 拦截会修改原数组的方法
   * - 对新插入的数据继续做观测
   * - 在方法执行后通知依赖更新
   * 参数:
   * - ...args: 当前数组方法收到的参数
   * 返回值:
   * - result: 原始数组方法的返回结果
   */
  arrayMethods[method] = function (...args) {
    const result = original.apply(this, args)
    const ob = this.__ob__

    let inserted

    if (method === 'push' || method === 'unshift') {
      inserted = args
    } else if (method === 'splice') {
      inserted = args.slice(2)
    }

    if (inserted) {
      // 新插入的元素也要继续转成响应式
      ob.observeArray(inserted)
    }

    // 数组结构变化后,通知依赖这个数组的 watcher
    ob.dep.notify()

    return result
  }
})

这段代码最值得抓住的是:

  1. Vue2 不是去劫持每个数组方法调用点,而是直接改写数组原型上的关键方法
  2. pushunshiftsplice 这类方法可能引入新元素,所以要继续 observe
  3. 最后仍然要回到 dep.notify(),把更新机会交给 watcher

4.2.4 一张图看清数组响应式链路

mermaid
flowchart TD
    A[data 中存在数组] --> B[给数组挂上改写后的原型方法]
    B --> C[模板或计算属性读取数组]
    C --> D[收集依赖 watcher]
    E[调用 push/splice 等方法] --> F[进入改写后的数组方法]
    F --> G[执行原始数组方法]
    G --> H{是否插入了新元素}
    H -- 是 --> I[observeArray 继续观测新元素]
    H -- 否 --> J[跳过新元素观测]
    I --> K[dep.notify 通知更新]
    J --> K
    K --> L[watcher 进入更新队列]
    L --> M[重新渲染并 patch DOM]

4.2.5 Vue2 数组响应式的经典边界

这部分很重要,因为它正好解释了为什么很多人会觉得:数组明明改了,页面怎么没更新。

Vue2 里最经典的几个边界是:

  1. 直接通过下标赋值:arr[index] = newValue
  2. 直接修改长度:arr.length = 0
  3. 某些深层嵌套结构没有在合适时机被观测到

这些写法的问题在于:它们没有走到 Vue2 已经改写好的数组变异方法链路里。

所以工程上更常见的做法是:

  1. arr.splice(index, 1, newValue) 替代直接下标赋值
  2. Vue.set(arr, index, newValue) 显式补上响应式更新
  3. 清空数组时,更倾向于用能触发更新的方式处理,例如重新赋新数组或配合变异方法

4.2.6 数组响应式这件事真正说明了什么

Vue2 对数组的特殊处理,本质上说明了一件事:Object.defineProperty 更擅长拦截“现有属性的读取和写入”,但对数组这种结构性变化频繁的数据模型,并不天然顺手。`

这也是为什么后来 Vue3 改用 Proxy 后,数组、对象新增删除以及集合类型的处理会自然很多。

4.3 依赖收集、Watcher 和更新调度

这部分是 Vue2 响应式真正的中枢。

可以用 3 个关键词来理解:

  1. Dep
  2. Watcher
  3. 异步更新队列

4.3.1 Dep

可以看成 某个响应式属性对应的一组依赖收集器

它更像在记录:谁依赖了我。

4.3.2 Watcher

可以看成 当依赖变化时,需要重新执行的一段观察逻辑

在 Vue2 里,常见 watcher 可以理解成几类:

  1. 渲染 watcher:负责组件重新渲染
  2. 计算属性 watcher:负责缓存和惰性求值
  3. 用户 watcher:也就是 watch 选项里声明的监听逻辑

4.3.3 异步更新队列

Vue2 并不是每改一次数据就立刻同步操作一次 DOM。

更常见的做法是:

  1. 把需要更新的 watcher 放进队列
  2. 对同一轮事件循环里的多次变更做去重和合并
  3. 在合适时机统一刷新

这也是为什么你经常会看到:数据已经改了,但 DOM 还没立刻变。

对应到“源码风格”的简化思路,大致像这样:

js
const queue = []
const has = new Set()
let waiting = false

/**
 * queueWatcher
 * 功能:把 watcher 放进异步更新队列,避免每次数据变化都立刻同步刷新。
 * 核心职责:
 * 1. 对同一个 watcher 做去重
 * 2. 把 watcher 收集进本轮更新队列
 * 3. 通过 nextTick 安排统一刷新
 * 参数:
 * - watcher: 当前需要更新的观察者对象,通常至少包含 id 和 run 方法
 * 返回值:
 * - 无显式返回值,副作用是修改调度队列与调度状态
 */
function queueWatcher(watcher) {
  // 同一个 watcher 在一轮刷新里只进队一次
  if (has.has(watcher.id)) return
  has.add(watcher.id)
  queue.push(watcher)

  if (!waiting) {
    waiting = true
    // 不立刻执行,而是放到 nextTick 统一刷新
    nextTick(flushSchedulerQueue)
  }
}

/**
 * flushSchedulerQueue
 * 功能:统一执行当前队列中的 watcher。
 * 核心职责:
 * 1. 按顺序重跑本轮已入队的 watcher
 * 2. 清空队列和去重集合
 * 3. 重置调度状态,等待下一轮更新
 * 参数:
 * - 无
 * 返回值:
 * - 无显式返回值,副作用是触发 watcher.run(),进而可能导致组件重新渲染
 */
function flushSchedulerQueue() {
  for (const watcher of queue) {
    // 统一重跑 watcher,里面可能触发重新渲染
    watcher.run()
  }
  queue.length = 0
  has.clear()
  waiting = false
}

这段代码真正想表达的是:

  1. watcher 不会每次都立刻执行
  2. 同一轮里会先入队、去重、再统一刷新
  3. 这正是 Vue2 批量更新 DOM 的关键原因

4.4 Vue2 的渲染流程是怎样的

如果把 Vue2 的渲染过程展开成一条更完整的线,大致可以这样理解:

  1. 模板先被编译成渲染函数
  2. 组件初始化时执行渲染函数,生成虚拟 DOM
  3. 虚拟 DOM 首次挂载成真实 DOM
  4. 后续响应式数据变化时,重新执行渲染函数得到新的虚拟 DOM
  5. 新旧虚拟 DOM 做对比
  6. 只把差异 patch 到真实 DOM

也就是说,Vue2 并不是“数据一改就直接手改 DOM”,而是:状态变化 -> 重新生成 vnode -> diff -> patch DOM

如果再往源码感受上靠一步,可以把渲染 watcher 理解成下面这种简化形式:

js
/**
 * 渲染 Watcher 的简化示例
 * 名词解释:
 * - Watcher:Vue2 里负责“依赖变化后重新执行某段逻辑”的观察者对象
 * - vm:当前组件实例(ViewModel)
 * 功能:
 * - 把“组件渲染”包装成一个可被依赖系统重新触发的 watcher
 * 参数:
 * - 第一个参数 vm:当前组件实例
 * - 第二个参数回调函数:真正的渲染更新逻辑
 * 返回值:
 * - 这里没有接收返回值,但真实情况下会得到一个渲染 watcher 实例
 */
new Watcher(vm, () => {
  // 先执行渲染函数,拿到新的 vnode 树
  const vnode = vm._render()
  // 再把 vnode 同步到真实 DOM
  vm._update(vnode)
})

这段虽然很短,但它把 Vue2 渲染更新最关键的两步串起来了:

  1. _render() 负责生成新的虚拟 DOM
  2. _update() 负责把虚拟 DOM 更新到真实页面

4.5 一张流程图看清楚整条链路

mermaid
flowchart TD
    A[data 初始化] --> B[defineProperty 劫持属性]
    B --> C[模板渲染读取数据]
    C --> D[getter 收集依赖]
    D --> E[生成渲染 watcher]
    F[数据被修改] --> G[setter 通知 Dep]
    G --> H[相关 watcher 入队]
    H --> I[异步调度刷新]
    I --> J[重新执行渲染函数]
    J --> K[生成新 vnode]
    K --> L[diff + patch]
    L --> M[DOM 更新]

4.6 v-model 在 Vue2 里是怎么工作的

很多人会把 v-model 理解成 Vue 天生支持双向绑定,但更准确的说法是:

v-model 本质上是“值绑定 + 事件监听”的语法糖。`

例如在输入框上,它大致可以理解成:

vue
<input :value="keyword" @input="keyword = $event.target.value" />

这段等价思路最重要的价值是:

  1. 视图仍然由状态驱动
  2. 用户输入再通过事件回写状态

所以它不是脱离单向数据流的另一套机制,而是:在表单场景上,把读和写这两步语法包在一起。

如果第一次看 v-model 还是容易发懵,看这张图:

mermaid
flowchart LR
    A[data.keyword] --> B[:value 绑定到 input]
    B --> C[页面显示当前值]
    D[用户输入新内容] --> E[input 事件触发]
    E --> F[keyword = event.target.value]
    F --> G[setter 通知 watcher]
    G --> H[异步调度更新]
    H --> I[重新渲染]
    I --> B

4.7 Vue2 的设计模式可以怎么理解

这部分不建议背成术语列表,但确实值得建立认识。

4.7.1 MVVM

Vue2 很常被放到 MVVM 语境里理解。

把这个语境摆清楚:

  1. Model:状态数据
  2. View:模板和 DOM
  3. ViewModel:Vue 实例,负责把数据和视图关联起来

它的价值不在于背缩写,而在于理解:开发者主要在操作状态和声明视图关系,而不是手动操纵 DOM。

4.7.2 观察者 / 发布订阅思路

Vue2 的响应式和 watcher 机制,本质上就带有很强的观察者思路:

  1. 数据变化
  2. 通知依赖者
  3. 依赖者重新执行

工程里实际写项目时,更多是这样,它不是教科书里单一模式的死板复刻,而是:依赖收集 + 通知更新 这套观察者式机制。`

4.7.3 组件模式

Vue2 的另一个核心设计就是组件化。

也就是:

  1. 按职责拆页面
  2. 用 props / events 建立边界
  3. 用插槽保留扩展点

这本质上是在用组件模式控制复杂 UI 的组织方式。

5. Vue2 组件里最常见的几类能力

5.1 computed

computed 更适合表达派生状态。

js
computed: {
  filteredList() {
    return this.list.filter(item => item.visible)
  }
}

它的重点是:

  1. 从已有状态推导结果
  2. 有缓存
  3. 更偏“算值”

5.2 watch

watch 更适合做副作用。

js
watch: {
  keyword(newValue) {
    this.fetchList(newValue)
  }
}

它更适合:

  1. 发请求
  2. 同步外部状态
  3. 监听值变化后做动作

5.3 生命周期

Vue2 常见生命周期包括:

  1. created
  2. mounted
  3. updated
  4. beforeDestroy
  5. destroyed

这些生命周期的作用,不是为了背诵,而是帮助你把:

  1. 初始化数据
  2. DOM 挂载后操作
  3. 清理定时器或监听器

放到正确时机。

5.4 组件通信与跨组件通信

只要页面一拆成多个组件,通信问题就一定会出现。

最常见的问题通常不是“会不会传值”,而是:

  1. 父组件把数据怎么交给子组件
  2. 子组件里的交互结果怎么通知父组件
  3. 两个没有直接父子关系的组件怎么共享信息
  4. 一层层透传已经很深时,是否还应该继续用 props
  5. 共享状态到底应该放局部,还是提升到全局

如果先压缩成一句话,可以记成:Vue2 里的组件通信,本质上是在给数据流和职责边界找合适的位置。

5.4.1 父传子:props

父组件把数据交给子组件,最常见的方式就是 props

例如:

vue
<!-- Parent.vue -->
<template>
  <UserTable :list="userList" :loading="loading" />
</template>
js
// UserTable.vue
export default {
  props: {
    list: {
      type: Array,
      default: () => []
    },
    loading: {
      type: Boolean,
      default: false
    }
  }
}

这里最重要的认识是:

  1. 数据源通常仍然在父组件
  2. 子组件通过 props 读取外部输入
  3. 这本质上还是单向数据流

也就是说,props 解决的是:父组件把状态或配置传给子组件。

5.4.2 子传父:事件与 $emit

子组件不能直接反向改父组件状态,常见做法是:子组件触发事件,父组件监听事件后自己更新状态。

例如:

vue
<!-- ChildForm.vue -->
<template>
  <button @click="$emit('submit', formData)">提交</button>
</template>
vue
<!-- Parent.vue -->
<template>
  <ChildForm @submit="handleSubmit" />
</template>
js
export default {
  methods: {
    handleSubmit(payload) {
      this.formResult = payload
    }
  }
}

这条线非常重要,因为它决定了 Vue2 组件边界的基本协作方式:

  1. 父组件通过 props 往下传
  2. 子组件通过 $emit 往上通知
  3. 真正改状态的人,通常还是状态拥有者

5.4.3 兄弟组件:看“共同父组件”,再考虑事件总线

两个兄弟组件之间通信时,最稳的第一选择通常不是“直接互相找”,而是:把共享状态提升到共同父组件,再由父组件统一下发。

把通信顺序摊开看:

  1. A 组件把结果通知父组件
  2. 父组件更新共享状态
  3. 父组件再把新状态传给 B 组件

这其实就是经典的“状态提升”。

在 Vue2 项目里,也经常会看到事件总线 event bus 的写法,例如:

js
// event-bus.js
import Vue from 'vue'

export default new Vue()
js
// Sender.vue
import bus from './event-bus'

bus.$emit('user-selected', { id: 1, name: '张三' })
js
// Receiver.vue
import bus from './event-bus'

export default {
  created() {
    bus.$on('user-selected', this.handleUserSelected)
  },
  beforeDestroy() {
    bus.$off('user-selected', this.handleUserSelected)
  },
  methods: {
    handleUserSelected(user) {
      this.currentUser = user
    }
  }
}

它确实能解决问题,但代价也很明显:

  1. 数据来源不直观
  2. 事件名一多就难维护
  3. 容易忘记解绑监听
  4. 页面一复杂,排查链路比较绕

所以更工程化的建议通常是:

  1. 兄弟组件先考虑状态提升
  2. 临时的小范围解耦可以用事件总线
  3. 一旦变成共享业务状态,就应该考虑状态管理

5.4.4 跨层组件:provide / inject

当组件层级比较深时,如果还靠一层层 props 往下传,就会出现常见的“逐层透传”问题。

这时 Vue2 提供了 provide / inject

它可以看成 祖先组件向后代组件提供依赖,中间层不用层层声明转发

例如:

js
// Ancestor.vue
export default {
  provide() {
    return {
      theme: 'dark'
    }
  }
}
js
// DeepChild.vue
export default {
  inject: ['theme'],
  mounted() {
    console.log(this.theme)
  }
}

它最适合的场景通常是:

  1. 表单容器和表单项之间的上下文共享
  2. 主题、配置、注册关系这类基础能力传递
  3. 插件或组件库内部的上下文注入

但要注意的是:provide / inject 更像“依赖注入”,不等于完整的全局状态管理。`

5.4.5 跨页面或多组件共享状态:Vuex

如果数据要被很多页面、很多组件共同使用,例如:

  1. 当前登录用户
  2. 权限信息
  3. 菜单树
  4. 购物车
  5. 全局筛选条件

这时更适合考虑 Vuex 这样的集中式状态管理方案。

也就是说,跨组件通信发展到一定规模后,问题已经不只是“怎么传值”,而是:共享状态该由谁维护,修改链路是否可追踪。

Vue2 时代最典型的答案就是:

  1. 把共享状态放进 store
  2. 组件通过 state / getters 读取
  3. 通过 mutation / action 统一修改

如果只是当前这篇里先建立概念,可以把它理解成:本地通信靠 props / emit,跨层依赖靠 provide / inject,大范围共享状态交给 Vuex。

更完整的状态管理主线,可以继续看 Vuex 与 Pinia:状态管理的演进、差异与选型

5.4.6 一张图看清通信选型

mermaid
flowchart TD
    A[组件需要通信] --> B{是否父子关系}
    B -- 是 --> C[父传子用 props]
    C --> D[子传父用 $emit]
    B -- 否 --> E{是否只是少量兄弟协作}
    E -- 是 --> F[优先状态提升到共同父组件]
    E -- 否 --> G{是否是跨层依赖透传}
    G -- 是 --> H[考虑 provide / inject]
    G -- 否 --> I{是否为多组件共享业务状态}
    I -- 是 --> J[考虑 Vuex]
    I -- 否 --> K[小范围临时解耦可用 event bus]

这张图真正想表达的是:

  1. 通信方式不是越“高级”越好
  2. 看组件关系,再看状态影响范围
  3. 选型核心不是 API 名字,而是数据边界

5.4.7 Vue2 通信里最容易踩的坑

最常见的问题通常有这几类:

  1. 直接在子组件里修改 props
  2. 明明只是兄弟组件共享,却把事件总线用成全局消息中心
  3. 一层层透传已经很深,仍然不拆状态层级
  4. 本地状态和全局状态边界不清,什么都想放进 Vuex

其中最值得先记住的一条是:通信方案的选择,本质上是在控制状态归属和维护成本。

5.5 插槽与组件设计

如果说 props 解决的是:父组件把数据交给子组件。

那么 slot 更像是在解决:父组件如何把一部分视图结构交给子组件内部的某个位置去渲染。

也就是说,组件化不只是“传数据”,还包括:

  1. 组件自己负责哪些结构
  2. 调用方可以插入哪些内容
  3. 哪些能力应该固定,哪些能力应该开放扩展

这正是插槽在 Vue2 里最核心的价值。

5.5.1 默认插槽:给组件留一个内容入口

默认插槽可以看成 组件内部预留一个占位点,外部使用组件时可以把内容塞进来

例如一个基础卡片组件:

vue
<!-- BaseCard.vue -->
<template>
  <section class="card">
    <h3>{{ title }}</h3>
    <slot></slot>
  </section>
</template>

<script>
export default {
  props: {
    title: String
  }
}
</script>

使用时:

vue
<BaseCard title="用户信息">
  <p>这里是卡片正文</p>
</BaseCard>

这里最重要的认识是:

  1. BaseCard 决定外层结构和样式骨架
  2. 调用方决定正文里具体放什么
  3. 组件既有边界,又保留了扩展点

5.5.2 具名插槽:一个组件里有多个扩展区域

当一个组件不只需要一个内容入口时,就可以使用具名插槽。

例如弹窗组件通常会有:

  1. 标题区
  2. 内容区
  3. 底部操作区

这时可以这样写:

vue
<!-- BaseDialog.vue -->
<template>
  <div class="dialog">
    <header class="dialog-header">
      <slot name="header"></slot>
    </header>
    <main class="dialog-body">
      <slot></slot>
    </main>
    <footer class="dialog-footer">
      <slot name="footer"></slot>
    </footer>
  </div>
</template>

使用时:

vue
<BaseDialog>
  <template #header>
    <h3>确认删除</h3>
  </template>

  <p>删除后将无法恢复。</p>

  <template #footer>
    <button>取消</button>
    <button>确认</button>
  </template>
</BaseDialog>

它适合解决的问题是:组件骨架稳定,但不同区域的内容需要由外部灵活决定。

5.5.3 作用域插槽:子组件把内部数据暴露给外部模板

这部分是 Vue2 插槽里最容易一开始看不透的地方。

把这层关系拉开:

  1. 普通插槽是“父组件传结构给子组件”
  2. 作用域插槽是在这个基础上,子组件再把一部分内部数据提供给父组件使用

例如列表组件内部负责循环,但每一项怎么渲染,由外部决定:

vue
<!-- DataList.vue -->
<template>
  <ul>
    <li v-for="item in list" :key="item.id">
      <slot :item="item"></slot>
    </li>
  </ul>
</template>

<script>
export default {
  props: {
    list: {
      type: Array,
      default: () => []
    }
  }
}
</script>

使用时:

vue
<DataList :list="users">
  <template slot-scope="{ item }">
    <span>{{ item.name }} - {{ item.role }}</span>
  </template>
</DataList>

这里的核心不是语法本身,而是这层关系:

  1. 子组件掌握内部循环和组织逻辑
  2. 子组件通过插槽参数把 item 暴露出去
  3. 父组件拿到 item 后,决定具体展示样式

所以作用域插槽解决的是:逻辑由子组件控制,渲染细节由外部模板接管。

5.5.4 一张图看清 propsslot 的分工

mermaid
flowchart LR
    A[父组件] --> B[通过 props 向下传数据]
    A --> C[通过 slot 传入内容结构]
    B --> D[子组件读取配置和状态]
    C --> E[子组件在指定位置渲染外部内容]
    D --> F[决定组件行为]
    E --> G[决定组件可扩展区域]

把这组关系压成一句话:

  1. props 更像“传数据和配置”
  2. slot 更像“传内容和扩展点”

5.5.5 Vue2 里什么样的组件才算“设计得比较好”

组件设计真正难的地方,通常不是 API 会不会写,而是边界定得准不准。

在 Vue2 里,一个更稳的组件设计通常会满足下面几条:

  1. props 负责输入,命名清晰,类型明确
  2. 事件负责输出,语义明确,例如 submitchangeclose
  3. 插槽只开放真正需要扩展的区域
  4. 不把太多业务状态硬塞进基础组件
  5. 尽量让“结构骨架稳定,内容表达可扩展”

例如一个好的表格容器组件,通常会固定:

  1. 外层布局
  2. 加载态
  3. 空状态
  4. 分页区域

但它可能会把:

  1. 列内容
  2. 行内操作
  3. 某些头部区域

交给插槽来扩展。

这背后的思路是:基础组件负责稳定骨架,业务页面负责填具体内容。

5.5.6 常见误区:什么时候不该滥用插槽

插槽很灵活,但不是所有问题都该往插槽上推。

下面几类误区在 Vue2 项目里很常见:

  1. 明明只是一个简单文本配置,也硬要做成插槽
  2. 一个基础组件开放了过多插槽,结果 API 变得很重
  3. 把本该由 props 控制的行为改成插槽拼装,导致可维护性下降
  4. 用作用域插槽承载过多业务逻辑,最后模板非常难读

更稳的经验通常是:

  1. 简单值传递优先用 props
  2. 内容区替换优先考虑 slot
  3. 需要把内部上下文暴露给外部模板时,再考虑作用域插槽

5.5.7 Vue2 插槽和组件设计的工程边界

Vue2 时代很多组件库都大量依赖插槽和作用域插槽,这非常常见,也很有效。

但继续往大项目走时,也会慢慢遇到边界:

  1. 插槽层级一深,模板可读性会下降
  2. 作用域插槽一多,数据来源不够直观
  3. 复杂逻辑复用时,单靠插槽不够,还要配合状态管理、抽象组件或 mixin

所以边界要看清:插槽解决的是组件扩展问题,不是所有复用问题。

5.6 mixin

mixin 可以看成 把一组可复用的组件选项,合并进多个组件里

它在 Vue2 时代很常见,因为当时还没有 Composition API 这套更自然的逻辑组合方式。

所以 mixin 主要想解决的是:

  1. 多个组件里有重复的 datamethods、生命周期逻辑
  2. 想把这部分公共逻辑抽出来复用

例如:

js
// mixins/listMixin.js
export default {
  data() {
    return {
      loading: false
    }
  },
  methods: {
    async fetchList() {
      this.loading = true
      try {
        // 这里省略真实请求逻辑
        return []
      } finally {
        this.loading = false
      }
    }
  },
  created() {
    this.fetchList()
  }
}

组件中使用时:

js
import listMixin from './mixins/listMixin'

export default {
  mixins: [listMixin],
  data() {
    return {
      keyword: ''
    }
  }
}

这时组件实例上就会合并进 loadingfetchListcreated 里的逻辑。

不过要注意的是:mixin 解决了复用问题,但也带来了“行为来源不透明”的问题。`

最常见的代价包括:

  1. 组件里明明没写某个字段或方法,但运行时却存在
  2. 多个 mixin 同时存在时,命名冲突不容易看出来
  3. 生命周期会合并执行,排查行为来源比较绕

所以边界还是要看清:mixin 是 Vue2 时代很重要的逻辑复用手段,但它更适合中小规模、边界清晰的复用,不适合把大量复杂业务逻辑全塞进去。`

5.7 自定义指令 v-xxxx

它可以看成 把一段和 DOM 操作强相关的复用逻辑,封装成像 v-focus 这样的指令

它主要解决的是:

  1. 某些 DOM 行为会在很多地方重复出现
  2. 这些逻辑更接近“元素怎么操作”,而不是“组件怎么组织”

例如最常见的自动聚焦指令:

js
Vue.directive('focus', {
  inserted(el) {
    el.focus()
  }
})

使用时:

vue
<input v-focus />

如果只记核心定位,可以记成:自定义指令更适合复用 DOM 级行为,mixin 更适合复用组件选项逻辑。

5.7.1 Vue2 自定义指令常见钩子

Vue2 里常见的指令钩子包括:

  1. bind:指令第一次绑定到元素时
  2. inserted:元素插入父节点后
  3. update:组件更新时
  4. componentUpdated:组件及其子组件更新完成后
  5. unbind:指令和元素解绑时

如果只是做初始化 DOM 操作,最常见的是:

  1. inserted 里做首次处理
  2. unbind 里做清理

5.7.2 一个更完整的自定义指令示例

下面这个例子演示一个简单的权限指令思路:

js
Vue.directive('permission', {
  bind(el, binding) {
    const { value } = binding
    const userPermissions = ['article:read']

    if (!userPermissions.includes(value)) {
      el.parentNode && el.parentNode.removeChild(el)
    }
  }
})

使用时:

vue
<button v-permission="'article:delete'">删除</button>

这段代码想表达的重点不是“权限一定要这么写”,而是:当一段 DOM 层面的控制逻辑会在多个页面重复出现时,可以用自定义指令把它收起来。

5.7.3 自定义指令的边界

自定义指令很有用,但不要把它滥用成“万能扩展点”。

更适合用自定义指令的场景通常是:

  1. 自动聚焦
  2. 点击外部关闭
  3. 拖拽
  4. 权限控制
  5. 元素级埋点或观察

不太适合用自定义指令的场景通常是:

  1. 复杂业务状态流转
  2. 多组件之间的数据协同
  3. 大段可复用业务逻辑

因为这些更接近组件逻辑或状态管理问题,而不是 DOM 指令问题。

6. Vue2 常见应用场景

Vue2 在工程上最常见的场景包括:

  1. 后台管理系统
  2. 运营平台
  3. 表单密集型系统
  4. 中型业务前台项目

它为什么很适合这类场景?

因为它:

  1. 上手门槛相对低
  2. 模板和 Options API 比较直观
  3. 生态长期成熟

7. Vue2 的优点是什么

7.1 学习和理解成本相对友好

datamethods、模板、组件通信这条线比较直观。

7.2 生态成熟

Vue2 时代的脚手架、组件库、后台模板和历史项目非常多。

7.3 简单到中等复杂度页面写起来顺手

对很多标准 CRUD 页面来说,Vue2 的开发体验是足够好的。

8. Vue2 的边界和代价是什么

这部分同样重要。

8.1 复杂组件里逻辑容易分散

同一块业务逻辑可能散落在:

  1. data
  2. computed
  3. watch
  4. methods
  5. 生命周期

8.2 逻辑复用不够自然

Vue2 时代常见的复用方式包括:

  1. mixin
  2. 高阶组件
  3. render 层抽象

但这些方式在复杂项目里往往没有 Vue3 的组合式 API 那么自然。

8.3 TypeScript 体验相对一般

尤其是在 this、复杂组件约束和大型项目类型表达上,不如 Vue3 顺手。

8.4 响应式机制有历史边界

这点前面已经提过,Object.defineProperty 的能力范围本身有限。

9. 一个典型 Vue2 组件示例

vue
<template>
  <section>
    <input v-model="keyword" placeholder="请输入关键字" />
    <p>结果数量:{{ filteredList.length }}</p>
    <ul>
      <li v-for="item in filteredList" :key="item.id">
        {{ item.name }}
      </li>
    </ul>
  </section>
</template>

<script>
export default {
  data() {
    return {
      keyword: '',
      list: [
        { id: 1, name: 'Vue2' },
        { id: 2, name: 'Vue3' }
      ]
    }
  },
  computed: {
    filteredList() {
      return this.list.filter(item => item.name.includes(this.keyword))
    }
  }
}
</script>

这个例子最值得注意的是:

  1. 原始状态放在 data
  2. 派生结果放在 computed
  3. 模板负责表达视图

10. 工程里最容易踩的坑

10.1 把太多逻辑堆进一个组件

最后组件会越来越像一个“大对象”。

10.2 该用 computed 的地方滥用 watch

结果依赖链会变绕。

10.3 过度依赖 mixin

最终组件行为来源不清晰。

10.4 对响应式边界不敏感

例如对象新增字段、数组修改方式等问题,Vue2 里要更小心。

11. 什么时候应该继续理解 Vue2

即使现在新项目更常走 Vue3,Vue2 仍然值得系统掌握,因为:

  1. 老项目很多
  2. 很多现有资料、代码和排错经验,还是建立在 Vue2 上
  3. Vue3 的很多设计动机,恰恰要从 Vue2 的边界里理解

12. 小结

如果把 Vue2 压缩成一句话,可以记住:Vue2 是一套以组件、模板、响应式和 Options API 为核心的经典前端框架,它非常适合理解 Vue 的起点,也能帮助你看清 Vue3 为什么会往新的方向演进。

继续往下读,比较自然的下一步是进入 Vue3:响应式重构、Composition API 与现代工程化Vue2 与 Vue3 对比

基于 VitePress 构建的个人技术笔记。