Appearance
Vue2:经典组件化框架与 Options API
Vue2 可以看成 前端组件化框架进入工程主流阶段时,一套非常经典、非常有代表性的方案。
它的重要性不只是“历史上很流行”,而是直到现在,很多中后台、运营系统、管理系统里仍然有大量 Vue2 项目在稳定运行。
1. Vue2 是什么
Vue2 是 Vue 框架的第二代主流版本。
它最核心的能力包括:
- 用组件组织页面
- 用响应式系统让数据变化驱动视图更新
- 用模板语法描述界面
- 用
Options API组织组件逻辑
如果用一句更工程化的话说:Vue2 解决的是“页面越来越复杂后,如何把 DOM 操作、状态和交互逻辑从手写代码里抽出来,用更稳定的组件模型来管理”。
2. Vue2 主要解决什么问题
在组件化框架普及之前,前端页面很容易出现这些问题:
- 结构、样式、行为耦合在一起
- 页面状态变化后,需要手动更新 DOM
- 多个模块之间逻辑边界不清
- 页面一复杂,维护成本迅速上升
Vue2 的思路是:
- 把页面拆成组件
- 用声明式模板替代大量手写 DOM 更新
- 把状态、方法、计算属性、侦听逻辑集中到组件里
3. Vue2 的核心模型是什么
Vue2 最适合抓住 4 个关键词:
- 组件
- 模板
- 响应式
Options API
3.1 组件
组件可以看成 页面里的可复用功能单元。
一个列表页,通常可以拆成:
- 筛选区组件
- 表格组件
- 分页组件
- 弹窗组件
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 做响应式。
把这条链路拆开:
- 在初始化阶段遍历对象属性
- 通过 getter 收集依赖
- 通过 setter 触发更新
这套机制在当时很实用,但也有边界:
- 对对象新增属性支持不自然
- 对删除属性支持不自然
- 数组某些变化处理不够直接
- 初始化时递归遍历有成本
这也是后面 Vue3 改用 Proxy 的重要背景之一。
4.1 Vue2 的数据绑定到底是什么
很多人会把“数据绑定”理解成一句口号:数据变了,页面就变了。
这句话没错,但如果不继续往下拆,后面就很难解释:
- 为什么模板里写了
{{ count }}就能联动 - 为什么
v-model可以双向更新 - 为什么改完数据后 DOM 不是每次都立刻同步更新
Vue2 的数据绑定其实包含两层:
单向数据流:状态驱动视图渲染输入事件回写:像v-model这种语法糖,再把用户输入回写到状态
例如:
vue
<template>
<div>
<p>{{ count }}</p>
<input v-model="keyword" />
</div>
</template>这里至少发生了两件事:
- 模板读取
count、keyword,建立“视图依赖哪些数据”的关系 - 输入框触发输入事件时,再把最新值写回
keyword
所以更稳的理解是:Vue2 不是“神奇双向绑定框架”,而是以单向渲染为主,再对表单场景提供了更方便的双向语法糖。
4.2 Vue2 是怎么做到“数据改了,视图跟着变”的
可以把 Vue2 的核心链路先压缩成 5 步:
- 初始化组件时,把
data里的字段转换成响应式属性 - 模板渲染时读取这些属性
- 读取过程触发 getter,收集当前渲染 watcher 依赖
- 修改数据时触发 setter,通知相关 watcher
- 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()
}
})
}这段代码最值得看懂的是:
getter里做依赖收集setter里做变更通知- 每个响应式属性背后都挂着一组依赖
4.2.1 Vue2 为什么要单独处理数组响应式
对象和数组在 Vue2 里虽然都属于响应式数据,但处理方式并不完全一样。
原因在于:
- 普通对象可以在初始化阶段对每个字段做
defineProperty - 数组如果也想按“每个下标都劫持”来处理,成本高,而且很多操作并不是简单的
arr[index] = value - 数组更常见的变化,其实是
push、pop、splice这类变异方法
所以 Vue2 对数组的核心思路不是:把每个数组下标都像对象属性一样完整劫持。
而是:拦截数组的变异方法,在方法执行后继续观测新元素,并主动通知依赖更新。
4.2.2 Vue2 是怎么处理数组变异方法的
Vue2 里最关键的做法,是重写一批会修改原数组的方法,例如:
pushpopshiftunshiftsplicesortreverse
这些方法之所以要单独处理,是因为它们会直接改变数组内容和结构。
更工程化地说,Vue2 在数组这条线上主要做两件事:
- 如果新插入了元素,要继续把这些新元素变成可观测数据
- 数组结构变化后,要通知依赖这个数组的 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
}
})这段代码最值得抓住的是:
- Vue2 不是去劫持每个数组方法调用点,而是直接改写数组原型上的关键方法
push、unshift、splice这类方法可能引入新元素,所以要继续observe- 最后仍然要回到
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 里最经典的几个边界是:
- 直接通过下标赋值:
arr[index] = newValue - 直接修改长度:
arr.length = 0 - 某些深层嵌套结构没有在合适时机被观测到
这些写法的问题在于:它们没有走到 Vue2 已经改写好的数组变异方法链路里。
所以工程上更常见的做法是:
- 用
arr.splice(index, 1, newValue)替代直接下标赋值 - 用
Vue.set(arr, index, newValue)显式补上响应式更新 - 清空数组时,更倾向于用能触发更新的方式处理,例如重新赋新数组或配合变异方法
4.2.6 数组响应式这件事真正说明了什么
Vue2 对数组的特殊处理,本质上说明了一件事:Object.defineProperty 更擅长拦截“现有属性的读取和写入”,但对数组这种结构性变化频繁的数据模型,并不天然顺手。`
这也是为什么后来 Vue3 改用 Proxy 后,数组、对象新增删除以及集合类型的处理会自然很多。
4.3 依赖收集、Watcher 和更新调度
这部分是 Vue2 响应式真正的中枢。
可以用 3 个关键词来理解:
DepWatcher- 异步更新队列
4.3.1 Dep
可以看成 某个响应式属性对应的一组依赖收集器。
它更像在记录:谁依赖了我。
4.3.2 Watcher
可以看成 当依赖变化时,需要重新执行的一段观察逻辑。
在 Vue2 里,常见 watcher 可以理解成几类:
- 渲染 watcher:负责组件重新渲染
- 计算属性 watcher:负责缓存和惰性求值
- 用户 watcher:也就是
watch选项里声明的监听逻辑
4.3.3 异步更新队列
Vue2 并不是每改一次数据就立刻同步操作一次 DOM。
更常见的做法是:
- 把需要更新的 watcher 放进队列
- 对同一轮事件循环里的多次变更做去重和合并
- 在合适时机统一刷新
这也是为什么你经常会看到:数据已经改了,但 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
}这段代码真正想表达的是:
- watcher 不会每次都立刻执行
- 同一轮里会先入队、去重、再统一刷新
- 这正是 Vue2 批量更新 DOM 的关键原因
4.4 Vue2 的渲染流程是怎样的
如果把 Vue2 的渲染过程展开成一条更完整的线,大致可以这样理解:
- 模板先被编译成渲染函数
- 组件初始化时执行渲染函数,生成虚拟 DOM
- 虚拟 DOM 首次挂载成真实 DOM
- 后续响应式数据变化时,重新执行渲染函数得到新的虚拟 DOM
- 新旧虚拟 DOM 做对比
- 只把差异 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 渲染更新最关键的两步串起来了:
_render()负责生成新的虚拟 DOM_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" />这段等价思路最重要的价值是:
- 视图仍然由状态驱动
- 用户输入再通过事件回写状态
所以它不是脱离单向数据流的另一套机制,而是:在表单场景上,把读和写这两步语法包在一起。
如果第一次看 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 --> B4.7 Vue2 的设计模式可以怎么理解
这部分不建议背成术语列表,但确实值得建立认识。
4.7.1 MVVM
Vue2 很常被放到 MVVM 语境里理解。
把这个语境摆清楚:
Model:状态数据View:模板和 DOMViewModel:Vue 实例,负责把数据和视图关联起来
它的价值不在于背缩写,而在于理解:开发者主要在操作状态和声明视图关系,而不是手动操纵 DOM。
4.7.2 观察者 / 发布订阅思路
Vue2 的响应式和 watcher 机制,本质上就带有很强的观察者思路:
- 数据变化
- 通知依赖者
- 依赖者重新执行
工程里实际写项目时,更多是这样,它不是教科书里单一模式的死板复刻,而是:依赖收集 + 通知更新 这套观察者式机制。`
4.7.3 组件模式
Vue2 的另一个核心设计就是组件化。
也就是:
- 按职责拆页面
- 用 props / events 建立边界
- 用插槽保留扩展点
这本质上是在用组件模式控制复杂 UI 的组织方式。
5. Vue2 组件里最常见的几类能力
5.1 computed
computed 更适合表达派生状态。
js
computed: {
filteredList() {
return this.list.filter(item => item.visible)
}
}它的重点是:
- 从已有状态推导结果
- 有缓存
- 更偏“算值”
5.2 watch
watch 更适合做副作用。
js
watch: {
keyword(newValue) {
this.fetchList(newValue)
}
}它更适合:
- 发请求
- 同步外部状态
- 监听值变化后做动作
5.3 生命周期
Vue2 常见生命周期包括:
createdmountedupdatedbeforeDestroydestroyed
这些生命周期的作用,不是为了背诵,而是帮助你把:
- 初始化数据
- DOM 挂载后操作
- 清理定时器或监听器
放到正确时机。
5.4 组件通信与跨组件通信
只要页面一拆成多个组件,通信问题就一定会出现。
最常见的问题通常不是“会不会传值”,而是:
- 父组件把数据怎么交给子组件
- 子组件里的交互结果怎么通知父组件
- 两个没有直接父子关系的组件怎么共享信息
- 一层层透传已经很深时,是否还应该继续用
props - 共享状态到底应该放局部,还是提升到全局
如果先压缩成一句话,可以记成: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
}
}
}这里最重要的认识是:
- 数据源通常仍然在父组件
- 子组件通过
props读取外部输入 - 这本质上还是单向数据流
也就是说,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 组件边界的基本协作方式:
- 父组件通过
props往下传 - 子组件通过
$emit往上通知 - 真正改状态的人,通常还是状态拥有者
5.4.3 兄弟组件:看“共同父组件”,再考虑事件总线
两个兄弟组件之间通信时,最稳的第一选择通常不是“直接互相找”,而是:把共享状态提升到共同父组件,再由父组件统一下发。
把通信顺序摊开看:
- A 组件把结果通知父组件
- 父组件更新共享状态
- 父组件再把新状态传给 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
}
}
}它确实能解决问题,但代价也很明显:
- 数据来源不直观
- 事件名一多就难维护
- 容易忘记解绑监听
- 页面一复杂,排查链路比较绕
所以更工程化的建议通常是:
- 兄弟组件先考虑状态提升
- 临时的小范围解耦可以用事件总线
- 一旦变成共享业务状态,就应该考虑状态管理
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)
}
}它最适合的场景通常是:
- 表单容器和表单项之间的上下文共享
- 主题、配置、注册关系这类基础能力传递
- 插件或组件库内部的上下文注入
但要注意的是:provide / inject 更像“依赖注入”,不等于完整的全局状态管理。`
5.4.5 跨页面或多组件共享状态:Vuex
如果数据要被很多页面、很多组件共同使用,例如:
- 当前登录用户
- 权限信息
- 菜单树
- 购物车
- 全局筛选条件
这时更适合考虑 Vuex 这样的集中式状态管理方案。
也就是说,跨组件通信发展到一定规模后,问题已经不只是“怎么传值”,而是:共享状态该由谁维护,修改链路是否可追踪。
Vue2 时代最典型的答案就是:
- 把共享状态放进
store - 组件通过
state/getters读取 - 通过
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]这张图真正想表达的是:
- 通信方式不是越“高级”越好
- 看组件关系,再看状态影响范围
- 选型核心不是 API 名字,而是数据边界
5.4.7 Vue2 通信里最容易踩的坑
最常见的问题通常有这几类:
- 直接在子组件里修改
props - 明明只是兄弟组件共享,却把事件总线用成全局消息中心
- 一层层透传已经很深,仍然不拆状态层级
- 本地状态和全局状态边界不清,什么都想放进
Vuex
其中最值得先记住的一条是:通信方案的选择,本质上是在控制状态归属和维护成本。
5.5 插槽与组件设计
如果说 props 解决的是:父组件把数据交给子组件。
那么 slot 更像是在解决:父组件如何把一部分视图结构交给子组件内部的某个位置去渲染。
也就是说,组件化不只是“传数据”,还包括:
- 组件自己负责哪些结构
- 调用方可以插入哪些内容
- 哪些能力应该固定,哪些能力应该开放扩展
这正是插槽在 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>这里最重要的认识是:
BaseCard决定外层结构和样式骨架- 调用方决定正文里具体放什么
- 组件既有边界,又保留了扩展点
5.5.2 具名插槽:一个组件里有多个扩展区域
当一个组件不只需要一个内容入口时,就可以使用具名插槽。
例如弹窗组件通常会有:
- 标题区
- 内容区
- 底部操作区
这时可以这样写:
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 插槽里最容易一开始看不透的地方。
把这层关系拉开:
- 普通插槽是“父组件传结构给子组件”
- 作用域插槽是在这个基础上,子组件再把一部分内部数据提供给父组件使用
例如列表组件内部负责循环,但每一项怎么渲染,由外部决定:
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>这里的核心不是语法本身,而是这层关系:
- 子组件掌握内部循环和组织逻辑
- 子组件通过插槽参数把
item暴露出去 - 父组件拿到
item后,决定具体展示样式
所以作用域插槽解决的是:逻辑由子组件控制,渲染细节由外部模板接管。
5.5.4 一张图看清 props 和 slot 的分工
mermaid
flowchart LR
A[父组件] --> B[通过 props 向下传数据]
A --> C[通过 slot 传入内容结构]
B --> D[子组件读取配置和状态]
C --> E[子组件在指定位置渲染外部内容]
D --> F[决定组件行为]
E --> G[决定组件可扩展区域]把这组关系压成一句话:
props更像“传数据和配置”slot更像“传内容和扩展点”
5.5.5 Vue2 里什么样的组件才算“设计得比较好”
组件设计真正难的地方,通常不是 API 会不会写,而是边界定得准不准。
在 Vue2 里,一个更稳的组件设计通常会满足下面几条:
props负责输入,命名清晰,类型明确- 事件负责输出,语义明确,例如
submit、change、close - 插槽只开放真正需要扩展的区域
- 不把太多业务状态硬塞进基础组件
- 尽量让“结构骨架稳定,内容表达可扩展”
例如一个好的表格容器组件,通常会固定:
- 外层布局
- 加载态
- 空状态
- 分页区域
但它可能会把:
- 列内容
- 行内操作
- 某些头部区域
交给插槽来扩展。
这背后的思路是:基础组件负责稳定骨架,业务页面负责填具体内容。
5.5.6 常见误区:什么时候不该滥用插槽
插槽很灵活,但不是所有问题都该往插槽上推。
下面几类误区在 Vue2 项目里很常见:
- 明明只是一个简单文本配置,也硬要做成插槽
- 一个基础组件开放了过多插槽,结果 API 变得很重
- 把本该由
props控制的行为改成插槽拼装,导致可维护性下降 - 用作用域插槽承载过多业务逻辑,最后模板非常难读
更稳的经验通常是:
- 简单值传递优先用
props - 内容区替换优先考虑
slot - 需要把内部上下文暴露给外部模板时,再考虑作用域插槽
5.5.7 Vue2 插槽和组件设计的工程边界
Vue2 时代很多组件库都大量依赖插槽和作用域插槽,这非常常见,也很有效。
但继续往大项目走时,也会慢慢遇到边界:
- 插槽层级一深,模板可读性会下降
- 作用域插槽一多,数据来源不够直观
- 复杂逻辑复用时,单靠插槽不够,还要配合状态管理、抽象组件或
mixin
所以边界要看清:插槽解决的是组件扩展问题,不是所有复用问题。
5.6 mixin
mixin 可以看成 把一组可复用的组件选项,合并进多个组件里。
它在 Vue2 时代很常见,因为当时还没有 Composition API 这套更自然的逻辑组合方式。
所以 mixin 主要想解决的是:
- 多个组件里有重复的
data、methods、生命周期逻辑 - 想把这部分公共逻辑抽出来复用
例如:
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 解决了复用问题,但也带来了“行为来源不透明”的问题。`
最常见的代价包括:
- 组件里明明没写某个字段或方法,但运行时却存在
- 多个
mixin同时存在时,命名冲突不容易看出来 - 生命周期会合并执行,排查行为来源比较绕
所以边界还是要看清:mixin 是 Vue2 时代很重要的逻辑复用手段,但它更适合中小规模、边界清晰的复用,不适合把大量复杂业务逻辑全塞进去。`
5.7 自定义指令 v-xxxx
它可以看成 把一段和 DOM 操作强相关的复用逻辑,封装成像 v-focus 这样的指令。
它主要解决的是:
- 某些 DOM 行为会在很多地方重复出现
- 这些逻辑更接近“元素怎么操作”,而不是“组件怎么组织”
例如最常见的自动聚焦指令:
js
Vue.directive('focus', {
inserted(el) {
el.focus()
}
})使用时:
vue
<input v-focus />如果只记核心定位,可以记成:自定义指令更适合复用 DOM 级行为,mixin 更适合复用组件选项逻辑。
5.7.1 Vue2 自定义指令常见钩子
Vue2 里常见的指令钩子包括:
bind:指令第一次绑定到元素时inserted:元素插入父节点后update:组件更新时componentUpdated:组件及其子组件更新完成后unbind:指令和元素解绑时
如果只是做初始化 DOM 操作,最常见的是:
- 在
inserted里做首次处理 - 在
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 自定义指令的边界
自定义指令很有用,但不要把它滥用成“万能扩展点”。
更适合用自定义指令的场景通常是:
- 自动聚焦
- 点击外部关闭
- 拖拽
- 权限控制
- 元素级埋点或观察
不太适合用自定义指令的场景通常是:
- 复杂业务状态流转
- 多组件之间的数据协同
- 大段可复用业务逻辑
因为这些更接近组件逻辑或状态管理问题,而不是 DOM 指令问题。
6. Vue2 常见应用场景
Vue2 在工程上最常见的场景包括:
- 后台管理系统
- 运营平台
- 表单密集型系统
- 中型业务前台项目
它为什么很适合这类场景?
因为它:
- 上手门槛相对低
- 模板和
Options API比较直观 - 生态长期成熟
7. Vue2 的优点是什么
7.1 学习和理解成本相对友好
data、methods、模板、组件通信这条线比较直观。
7.2 生态成熟
Vue2 时代的脚手架、组件库、后台模板和历史项目非常多。
7.3 简单到中等复杂度页面写起来顺手
对很多标准 CRUD 页面来说,Vue2 的开发体验是足够好的。
8. Vue2 的边界和代价是什么
这部分同样重要。
8.1 复杂组件里逻辑容易分散
同一块业务逻辑可能散落在:
datacomputedwatchmethods- 生命周期
8.2 逻辑复用不够自然
Vue2 时代常见的复用方式包括:
- mixin
- 高阶组件
- 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>这个例子最值得注意的是:
- 原始状态放在
data - 派生结果放在
computed - 模板负责表达视图
10. 工程里最容易踩的坑
10.1 把太多逻辑堆进一个组件
最后组件会越来越像一个“大对象”。
10.2 该用 computed 的地方滥用 watch
结果依赖链会变绕。
10.3 过度依赖 mixin
最终组件行为来源不清晰。
10.4 对响应式边界不敏感
例如对象新增字段、数组修改方式等问题,Vue2 里要更小心。
11. 什么时候应该继续理解 Vue2
即使现在新项目更常走 Vue3,Vue2 仍然值得系统掌握,因为:
- 老项目很多
- 很多现有资料、代码和排错经验,还是建立在 Vue2 上
- Vue3 的很多设计动机,恰恰要从 Vue2 的边界里理解
12. 小结
如果把 Vue2 压缩成一句话,可以记住:Vue2 是一套以组件、模板、响应式和 Options API 为核心的经典前端框架,它非常适合理解 Vue 的起点,也能帮助你看清 Vue3 为什么会往新的方向演进。
继续往下读,比较自然的下一步是进入 Vue3:响应式重构、Composition API 与现代工程化 和 Vue2 与 Vue3 对比。