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() {}
  }
}

这里的 data、computed、methods、watch、生命周期钩子,就是 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. 模板读取 count、keyword,建立“视图依赖哪些数据”的关系
  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. 数组更常见的变化,其实是 push、pop、splice 这类变异方法

所以 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. push、unshift、splice 这类方法可能引入新元素,所以要继续 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 一张图看清 props 和 slot 的分工 ​

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. 事件负责输出,语义明确,例如 submit、change、close
  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. 多个组件里有重复的 data、methods、生命周期逻辑
  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: ''
    }
  }
}

这时组件实例上就会合并进 loading、fetchList 和 created 里的逻辑。

不过要注意的是: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 学习和理解成本相对友好 ​

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

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 构建的个人技术笔记。