电梯
Vue 源码
持续更新中...
Vue Router 4 源码
源码拉取步骤可以看这篇文章有讲解,下面直接进入正题:
前言
我们在 源码篇 使用及分析 Vue 全局 API 中介绍了直接挂载在 Vue 上的全局 API(比如:Vue.set、Vue.delete),而实例方法则是挂载 Vue 的原型上(比如:Vue.prototype.set、Vue.prototype.delete)
在 Vue2 中有 mixin 系列的函数,他们都是为了给 Vue.prototype 扩展以下功能:
| mixin 函数 | 功能 | 方法 |
|---|---|---|
| stateMixin | 添加状态相关方法 | data、props、set、delete、$watch |
| eventsMixin | 添加事件相关方法 | on 、once 、off 、emit |
| lifecycleMixin | 添加生命周期相关方法 | forceUpdate、destroy |
| renderMixin | 添加渲染相关方法 | $nextTick |
而在 Vue3 中完全重写了实例模型,组件实例不再基于 Vue.prototype,也没有 Vue 构造函数
javascript
// Vue3 写法
app.config.globalProperties
// Vue2 写法
Vue.prototype
在 Vue3 中不再支持 Vue2 中的 on、once、off、set、delete、destroy,总结对比表格如下:
| 实例方法 | Vue2 写法 | Vue3 是否支持 | Vue3 替代写法 |
|---|---|---|---|
| $data | this.$data.xxx |
支持但不推荐 | 直接使用响应式变量 ref()、reactive() |
| $props | this.$props.xxx |
支持 | setup(props) |
| $set | this.$set(obj, key, val) |
不支持 | Proxy 自动追踪新增属性 obj[key] = val |
| $delete | this.$delete(obj, key) |
不支持 | Proxy 自动追踪 delete obj[key] |
| $watch | this.$watch(exp, cb) |
支持 | watch(source, cb) |
| $on | this.$on(event, fn) |
不支持 | 不再支持事件总线 使用 $emits |
| $once | this.$once(event, fn) |
不支持 | 不再提供实例事件系统 <Child @event.once="handler" /> |
| $off | this.$off(event, fn) |
不支持 | Vue3 会自动解绑事件 |
| $emit | this.$emit('event', payload) |
支持 | Vue3 增强类型系统,需要显式声明 emits: emits: ['event'] |
| $mount | vm.$mount('#app') |
不支持 | Vue3 不再使用全局构造器 createApp(App).mount('#app') |
| $forceUpdate | this.$forceUpdate() |
不支持 | Proxy 精准触发更新 |
| $destroy | this.$destroy() |
不支持 | Vue3 自动清理组件实例,不允许手动销毁 app.unmount() |
| $nextTick | this.$nextTick(cb) |
支持 | 函数式引用,不依赖 this import { nextTick } from 'vue' |
关于 Vue3 中提到的多个属性,我们在 源码篇 剖析 Vue2 双向绑定原理 中分析响应式系统时已经详细分析(使用 proxy),因此在本篇我们主要围绕 Vue2 中的实例方法展开,当然,必要的时候我们会结合 Vue3 中的方法进行对比。
正文
我们就从前言中列出的表格,来分为四个方面展开:
| mixin 函数 | 功能 | 方法 |
| stateMixin | 添加状态相关方法 | data、props、set、delete、watch |
| eventsMixin | **添加事件相关方法** | on 、once 、off 、emit |
| lifecycleMixin | **添加生命周期相关方法** | forceUpdate、$destroy |
| renderMixin | 添加渲染相关方法 | $nextTick |
|---|
状态相关方法
源码位置:src/core/instance/state.ts

在 stateMixin 函数中,依次将 data、props、set、delete、$watch 挂载在 Vue 原型上,接下来我们依次剖析。
vm.$data
我们在 Vue2 中是这样访问并修改 data 中的数据的:
javascript
const vm = new Vue({
data() {
return {
count: 1
}
}
})
console.log(vm.count) // 1
vm.count = 2
console.log(vm.count) // 2
源码位置:src/core/instance/state.ts
TypeScript
const dataDef: any = {}
dataDef.get = function () {
return this._data
}
Object.defineProperty(Vue.prototype, '$data', dataDef)
- dataDef 对象定义了 get 方法
TypeScriptObject.defineProperty(Vue.prototype, '$data', dataDef)
- 给 Vue.prototype 上定义了 $data 访问器(getter)
- 任何 Vue 实例都可以通过 this.$data 获取 this._data
- 这里只定义了 getter,没有定义 setter,所以 $data 不能直接赋值
为什么要用 Object.defineProperty?
- Vue2 中通过 Object.defineProperty(Vue.prototype, 'data', { get() { return this._data } })定义了一个只读访问器 data,让所有 Vue 实例可以访问 _data 对象
- data 本身不能被替换,但可以通过 data 修改内部属性
- 此外,使用 Object.defineProperty 可以保证访问 $data 时总是指向最新的内部状态
因此,我们上面的示例还有另外一种写法:
javascript
const vm = new Vue({
data() {
return {
count: 1
}
}
})
console.log(vm.$data.count) // 1
console.log(vm._data.count) // 1
console.log(vm.count) // 1
// 三种写法,都可以将 vm.count 改成 2
vm.$data.count = 2
vm._data.count = 2
vm.count = 2
console.log(vm.$data.count) // 2
console.log(vm._data.count) // 2
console.log(vm.count) // 2
背后的原理:
- vm.count = 2
Vue2 在实例初始化时,会对 data 中的每个属性做代理(proxy)
javascriptObject.keys(data).forEach(key => { proxy(vm, '_data', key) })因此我们通过 vm.count 可以直接访问 vm._data.count,并同时触发响应式更新
- vm.$data.count = 2
$data 是 Vue2 用 Object.defineProperty 定义的 getter
TypeScriptObject.defineProperty(Vue.prototype, '$data', { get() { return this._data } })因此我们修改 vm.$data.count 等价于修改 vm._data.count,并同时触发响应式更新
- vm._data.count = 2
推荐使用 vm.count = 2 或 vm.$data.count = 2,但最好不要直接访问 _data,因为它是内部私有属性,直接访问或修改 _data.count 会 触发 Vue 内部 getter/setter,但这样会破坏封装。
vm.$props
我们在 Vue2 中是这样访问 props 中的数据的:
javascript
const Child = Vue.extend({
props: ['title'],
created() {
console.log(this.$props.title) // 访问 props
}
})
new Child({ propsData: { title: 'Hello' } })
- $props 返回组件实例的 _props 对象
- 用于在组件内部访问父组件传入的 props
- 和 this.title 等价,但 $props 更明确地表示"原始 props 对象"
源码位置:src/core/instance/state.ts
TypeScript
const propsDef: any = {}
propsDef.get = function () {
return this._props
}
Object.defineProperty(Vue.prototype, '$props', propsDef)
- propsDef 对象定义了 get 方法
TypeScriptObject.defineProperty(Vue.prototype, '$props', dataDef)
给 Vue.prototype 上定义了 $props 访问器(getter)
任何 Vue 实例都可以通过 this.$props 获取 this._props
这里只定义了 getter,没有定义 setter,所以 propsa 不能直接赋值 **data 和 $props 有相同之处,也有不同之处的点在于:**
访问组件 的 data 对象,响应式可写
访问组件的 props 对象,只读
vm.set \& vm.delete
在 源码篇 使用及分析 Vue 全局 API 我们介绍的全局 API 中,存在 Vue.set 和 Vue.delete

虽然他们和 vm.set \& vm.delete 的挂载位置不同:
- Vue.set(obj, key, val) → 全局调用
- vm.$set(obj, key, val) → 实例调用
- Vue.delete(obj, key, val) → 全局调用
- vm.$delete(obj, key, val) → 实例调用
但他们的用法行为完全相同,因为他们最终调用的都是同一个内部方法,入参也完全相同。
既然有了全局的 set(和 delete),为什么 Vue2 还要提供 set(和 delete)?
- 使用 vm.$set 可以在实例内部直接调用,不必每次写 Vue.set
- 语义更明确:vm.$set(vm.someData, 'key', val) → "给这个实例的数据设置新属性"
- 对于 Vue 插件可以通过实例直接调用 $set,不用依赖全局构造函数
set 方法和 delete 方法的源码位置:src/core/observer/index.js在 源码篇 使用及分析 Vue 全局 API 介绍的全局 API 中已针对这两个方法的源码进行了详细介绍,这里不再赘述,大家可以直接通过链接跳转查看。
vm.$watch
在 createWatcher 函数中,是这样调用 $watch 的

$watch 用于在 Vue 实例上观察一个表达式或函数,当其值变化时触发回调,返回一个 解除观察(unwatch)函数,它有如下特点:
- 可以动态监听实例的 data、props 或计算属性
- 可以设置 deep(深度监听对象)和 immediate(初始化立即触发回调)
- 适合在运行时动态添加监听,而不是在 watch 选项里静态定义
举个例子:
javascript
const vm = new Vue({
data() {
return {
count: 0,
info: { name: 'Alice' }
}
}
})
- 监听简单属性(并使用 immediate = true)
javascript
const unwatch = vm.$watch(
'count',
(newVal, oldVal) => {
console.log(`count 变化:${oldVal} -> ${newVal}`)
},
{ immediate: true }
)
// 使用 immediate: true,控制台立刻输出:count 变化:undefined -> 0
// 当 vm.count 变化时也会继续触发
vm.count = 1 // 控制台输出: count 从 0 变成 1
// 解除监听
unwatch()
- 监听深层对象(并使用 deep = true)
javascript
vm.$watch(
'info',
(newVal, oldVal) => {
console.log('info 发生变化', newVal)
},
{ deep: true }
)
vm.info.name = 'Bob' // 控制台输出: info 发生变化 { name: 'Bob' }
- 监听计算属性或函数返回值
javascript
vm.$watch(
function () {
return this.count * 2
},
function (newVal, oldVal) {
console.log(`计算属性变化:${oldVal} -> ${newVal}`)
}
)
vm.count = 2 // 输出: 计算属性变化:0 -> 4
源码位置:src/core/instance/state.ts
TypeScript
Vue.prototype.$watch = function (
expOrFn: string | (() => any),
cb: any,
options?: Record<string, any>
): Function {
const vm: Component = this // 表示当前调用 $watch 的实例
if (isPlainObject(cb)) {
return createWatcher(vm, expOrFn, cb, options)
}
options = options || {}
options.user = true
const watcher = new Watcher(vm, expOrFn, cb, options)
if (options.immediate) {
const info = `callback for immediate watcher "${watcher.expression}"`
pushTarget()
invokeWithErrorHandling(cb, vm, [watcher.value], vm, info)
popTarget()
}
return function unwatchFn() {
watcher.teardown()
}
}
1.处理 watch 配置写法
TypeScript
if (isPlainObject(cb)) {
return createWatcher(vm, expOrFn, cb, options)
}
如果传入下面这种形式:
TypeScript
vm.$watch('count', {
handler(newVal) { ... },
deep: true,
immediate: true
})
那么 cb 是一个对象,Vue 会调用 createWatcher 把它转成正确格式,我们来看看 createWatcher 函数内部的处理:
createWatcher 的作用就是将多种 watch 写法统一转换为 $watch 的标准形式。
源码位置:src/core/instance/state.ts
TypeScriptif (isPlainObject(handler)) { options = handler handler = handler.handler }这里就是为了处理这种写法:
TypeScriptwatch: { count: { handler(newVal) {}, immediate: true, deep: true } }此时,handler 是一个对象,最终这个整体就会被处理成:
- options = 整个对象(含 deep、immediate)
- handler
即:
TypeScriptvm.$watch('count', handler, { deep: true, immediate: true })
TypeScriptif (typeof handler === 'string') { handler = vm[handler] }这里则是为了处理这种写法:
TypeScriptmethods: { handleChange() {} }, watch: { count: 'handleChange' }此时 handler 是字符串,需要转成实例方法 "handleChange":
TypeScripthandler = vm['handleChange']
TypeScriptreturn vm.$watch(expOrFn, handler, options)最终调用 $watch,无论写什么形式的 watch,最终都会统一为:
TypeScriptvm.$watch(字符串表达式或函数, 回调函数, 选项)我们总结一张表格来说明 createWatcher 解决的 3 种 watch 写法:
2.设置默认 options
TypeScript
options = options || {}
options.user = true
通过设置 options.user = true 来区分是用户创建的 watcher 实例还是 Vue 内部创建的 watcher 实例
3.创建 Watcher 实例
TypeScript
const watcher = new Watcher(vm, expOrFn, cb, options)
4.处理 immediate: true 逻辑
TypeScript
if (options.immediate) {
const info = `callback for immediate watcher "${watcher.expression}"`
pushTarget()
invokeWithErrorHandling(cb, vm, [watcher.value], vm, info)
popTarget()
}
immediate 做了三件事:
- 马上执行回调(不等待值更新)
- 使用 invokeWithErrorHandling 包裹回调(防止用户回调报错影响系统)
- 使用 [watcher.value] 作为参数 → 回调的 oldVal 是 undefined
也就是说下面这个例子,第一次触发时 oldValue = undefined 是正常行为
TypeScript
vm.$watch('count', callback, { immediate: true })
5.返回 unwatch 函数
TypeScript
return function unwatchFn() {
watcher.teardown()
}
- 每个 watcher 在内部都登记依赖(Dep)
- teardown() 会从所有依赖中解绑
- 避免内存泄漏
我们在 源码篇 剖析 Vue2 双向绑定原理 这篇文章中曾提到过:
"谁"用到了这个数据,就是"谁"依赖了这个数据,那么"谁"就会进入到这个数据的依赖列表中,当这个数据变化时,就会通知在依赖列表中的这个"谁"。当"谁"不想依赖这个数据了,就需要将"谁"从这个数据的依赖列表中删除。而 unwatch 实际上调用的 watcher.teardown() 就是做了**"删除'谁'"**这件事:
源码位置:src/core/observer/watcher.ts
TypeScriptexport default class Watcher { constructor (...) { // ... this.deps = [] } teardown() { // 从组件作用域(effects)中移除当前 watcher if (this.vm && !this.vm._isBeingDestroyed) { remove(this.vm._scope.effects, this) } // 从每一个依赖(Dep)中移除 watcher(取消依赖追踪) if (this.active) { let i = this.deps.length while (i--) { this.deps[i].removeSub(this) } this.active = false if (this.onStop) { this.onStop() } } } }
到这里我们发现,在 Vue.prototype.$watch 这段代码里,只有对 immediate 的特殊处理,并没有对 deep 的处理逻辑,因为 deep 的核心处理在 Watcher 内部的 get() 过程中:
6.处理 deep: true 逻辑
源码位置:src/core/observer/watcher.ts
TypeScript
this.deep = !!options.deep
// ...
get() {
pushTarget(this)
let value
const vm = this.vm
try {
value = this.getter.call(vm, vm)
} catch (e: any) {
if (this.user) {
handleError(e, vm, `getter for watcher "${this.expression}"`)
} else {
throw e
}
} finally {
if (this.deep) {
traverse(value)
}
popTarget()
this.cleanupDeps()
}
return value
}
在这段代码中我们找到这一部分:
TypeScriptthis.deep = !!options.deep get() { if (this.deep) { traverse(value) } return value }this.deep 是从 options 里来的
如果 this.deep 为 true,调用 traverse 递归访问对象所有属性,从而触发依赖收集
下面我们看看 traverse 函数内部:
源码位置:src/core/observer/traverse.ts
TypeScriptconst seenObjects = new Set() export function traverse(val: any) { _traverse(val, seenObjects) seenObjects.clear() return val }
- 创建一个全局 seenObjects Set,用来避免重复遍历导致死循环
- 调用 _traverse(val) ------ 执行 DFS(深度优先遍历)来访问所有子属性
- 清空 Set
- 返回原值
TypeScriptfunction _traverse(val: any, seen: SimpleSet) { let i, keys const isA = isArray(val) if ( (!isA && !isObject(val)) || val.__v_skip || Object.isFrozen(val) || val instanceof VNode ) { return } if (val.__ob__) { const depId = val.__ob__.dep.id if (seen.has(depId)) { return } seen.add(depId) } if (isA) { i = val.length while (i--) _traverse(val[i], seen) } else if (isRef(val)) { _traverse(val.value, seen) } else { keys = Object.keys(val) i = keys.length while (i--) _traverse(val[keys[i]], seen) } }1.过滤掉不需要递归的情况
TypeScriptif ( (!isA && !isObject(val)) || // 基本类型(数字、字符串等) val.__v_skip || // 被显式跳过的响应式对象 Object.isFrozen(val) || // 冻结对象(无法触发 getter) val instanceof VNode // 虚拟节点,不需要观察 ) { return }2.去重处理(避免死循环)
TypeScriptif (val.__ob__) { const depId = val.__ob__.dep.id if (seen.has(depId)) { return } seen.add(depId) }如果对象已经被递归过,就跳过。这是为了解决:
- 循环引用(A->B->A)
- 同一个对象多次出现
3.根据类型继续递归
- 如果是数组 → 遍历每一个元素:
TypeScriptwhile (i--) _traverse(val[i], seen)
- 如果是 ref → 递归其 value:
TypeScript_traverse(val.value, seen)
- 如果是普通对象 → 遍历所有 key:
TypeScriptkeys = Object.keys(val) while (i--) _traverse(val[keys[i]], seen)
用一个简单例子验证 deep 的机制:
javascript
vm.$watch('obj', (newVal) => {
console.log('deep changed', newVal)
}, { deep: true })
Watcher 在运行 getter 时会执行 traverse(obj):
TypeScript
obj.a
obj.b
obj.b.c
obj.b.c.d
每次触发 getter → 当前 watcher 被加入 dep.subs,当修改:
TypeScript
vm.obj.b.c = 100
这一条语句的 setter 会通知 watcher,进而触发回调
事件相关方法
源码位置:src/core/instance/events.ts

eventsMixin 为 Vue 2 提供了一套实例级的发布订阅系统:
- $on:订阅
- $once:一次性订阅
- $off:取消订阅
- $emit:发布事件
Vue 3 移除了 on / off / $once,推荐使用:
- mitt
- emits
- provide / inject
在 eventsMixin 函数中,依次将 on、once、off、emit 挂载在 Vue 原型上,接下来我们依次剖析。
vm.$on
在当前 Vue 实例上注册一个事件监听器,将源码简化为:
javascript
vm._events[event].push(fn)
- _events 是一个事件名 → 回调数组的映射表
- 一个事件可以绑定多个回调
示例1:
- 注册用户登录事件监听器
javascriptthis.$on('user-login', (user) => { console.log('用户登录:', user) })
- 触发用户登录事件
javascriptthis.$emit('user-login', { id: 1, name: 'Tom' })示例2:
- 注册两个用于更新的监听函数,使用 emit 触发
javascriptthis.$on('refresh', fn1) this.$on('refresh', fn2) this.$emit('refresh')此时 fn1、fn2 都会被执行
TypeScript
Vue.prototype.$on = function (event: string | Array<string>, fn: Function): Component {
const vm: Component = this
if (isArray(event)) {
for (let i = 0, l = event.length; i < l; i++) {
vm.$on(event[i], fn)
}
} else {
;(vm._events[event] || (vm._events[event] = [])).push(fn)
if (hookRE.test(event)) {
vm._hasHookEvent = true
}
}
return vm
}
下面我们分别对各个部分进行解析:
TypeScriptVue.prototype.$on = function ( event: string | Array<string>, fn: Function ): Component1.在当前 Vue 实例上注册自定义事件监听器
2.支持两种事件形态:
- 单个事件名:string
- 多个事件名:string[]
TypeScriptif (isArray(event)) { for (let i = 0, l = event.length; i < l; i++) { vm.$on(event[i], fn) } }用于判断:是否为数组事件名,当传入的是:
TypeScriptvm.$on(['a', 'b', 'c'], fn)等价于:
TypeScriptvm.$on('a', fn) vm.$on('b', fn) vm.$on('c', fn)
TypeScriptelse { ;(vm._events[event] || (vm._events[event] = [])).push(fn)这段代码主要用于 单事件监听注册,这段代码的执行流程:
1.当我们首次注册:
TypeScriptvm._events = {} vm.$on('update', fn)执行顺序:
- vm._events['update'] 不存在
- 执行 (vm._events['update'] = [])
- 得到一个空数组
- .push(fn)
结果:
TypeScriptvm._events = { update: [fn] }2.再次注册同名事件:
TypeScriptvm.$on('update', fn2)结果:
TypeScriptvm._events = { update: [fn, fn2] }执行顺序 = 注册顺序
TypeScript;(vm._events[event] || (vm._events[event] = [])).push(fn)前面的分号 ; 是干什么的?
这是一个防止自动分号插入(ASI)问题的保护分号
vm.$emit
触发当前实例上的某个事件,并按注册顺序执行回调
TypeScript
Vue.prototype.$emit = function (event: string): Component {
const vm: Component = this
let cbs = vm._events[event]
if (cbs) {
cbs = cbs.length > 1 ? toArray(cbs) : cbs
const args = toArray(arguments, 1)
const info = `event handler for "${event}"`
for (let i = 0, l = cbs.length; i < l; i++) {
invokeWithErrorHandling(cbs[i], vm, args, vm, info)
}
}
return vm
}
这段代码是 $on 的"对偶实现",负责发布事件,下面我们对它的内部原理进行解析:
TypeScriptlet cbs = vm._events[event]
- 从 _events 中取出事件回调列表
TypeScriptif (cbs) { ... }
- 如果事件存在就继续执行
TypeScriptcbs = cbs.length > 1 ? toArray(cbs) : cbs这句代码的核心就是:是否复制回调数组,这里的 toArray 做了什么?
源码位置:src/shared/util.ts
TypeScriptfunction toArray(list, start = 0) { let i = list.length - start const ret = new Array(i) while (i--) { ret[i] = list[i + start] } return ret }它生成了一个浅拷贝数组,那为什么要拷贝?为什么是在 cbs.length > 1 的时候才拷贝?
1.为什么要拷贝?
在 emit 过程中,如果回调数组不拷贝,而回调函数里又修改了事件列表(如 off),就会破坏当前的遍历过程,导致回调漏执行或行为不确定。比如:
TypeScriptfunction fn1() { console.log('fn1 执行') vm.$off('test', fn2) } function fn2() { console.log('fn2 执行') } vm.$on('test', fn1) vm.$on('test', fn2)此时的内部结构是:
TypeScriptvm._events.test = [fn1, fn2]如果不拷贝,在执行 fn1 的时候会因为执行了 $off 直接修改原数组,那么 fn2 就被跳过了,不会被执行。
但如果进行拷贝:
TypeScriptconst copiedCbs = [fn1, fn2]fn1 内部仍然执行 $off,真实事件列表为
TypeScriptvm._events.test = [fn1]但 copiedCbs 不受影响,fn2 还会正常执行
2.为什么是在 cbs.length > 1 的时候才拷贝?
- 单个回调时,不存在遍历期间修改数组的风险
- 减少不必要的内存分配,属于一次微性能优化
TypeScriptconst args = toArray(arguments, 1)从 arguments 中提取参数,跳过第一个参数 event,比如:
TypeScriptvm.$emit('update', 1, 2, 3)args 等于:
TypeScript[1, 2, 3]
TypeScriptfor (let i = 0, l = cbs.length; i < l; i++) { invokeWithErrorHandling(cbs[i], vm, args, vm, info) }严格按注册顺序执行,与 $on 的 push(fn) 顺序一致。
vm.$once
注册一个只会执行一次的事件监听器,执行完成后自动解绑
示例:
javascriptthis.$once('init-finished', () => { console.log('只执行一次') }) this.$emit('init-finished') // 会执行 this.$emit('init-finished') // 不再执行
TypeScript
Vue.prototype.$once = function (event: string, fn: Function): Component {
const vm: Component = this
function on() {
vm.$off(event, on)
fn.apply(vm, arguments)
}
on.fn = fn
vm.$on(event, on)
return vm
}
核心代码:
TypeScriptfunction on() { vm.$off(event, on) fn.apply(vm, arguments) }
- 先解绑自己
- 再执行真正的回调 fn
为什么要先 $off 再执行?比如:
TypeScriptvm.$once('test', () => { vm.$emit('test') })如果顺序反了:
TypeScriptfn() vm.$off(event, on)
- fn 内部再次 $emit
- 监听器仍然存在
- 造成 递归触发 / 执行多次
先解绑,是为了保证"严格只执行一次"
vm.$off
移除已注册的事件监听器,它支持多种传参:
javascript
vm.$off() // 移除所有事件
vm.$off('a') // 移除 a 的所有监听
vm.$off('a', fn) // 移除 a 的指定监听
vm.$off(['a', 'b'], fn) // 批量移除
vm.$off('a', onceFn) // 移除 $once 注册的监听
TypeScript
Vue.prototype.$off = function (event?: string | Array<string>, fn?: Function): Component {
const vm: Component = this
if (!arguments.length) {
vm._events = Object.create(null)
return vm
}
if (isArray(event)) {
for (let i = 0, l = event.length; i < l; i++) {
vm.$off(event[i], fn)
}
return vm
}
const cbs = vm._events[event!]
if (!cbs) {
return vm
}
if (!fn) {
vm._events[event!] = null
return vm
}
let cb
let i = cbs.length
while (i--) {
cb = cbs[i]
if (cb === fn || cb.fn === fn) {
cbs.splice(i, 1)
break
}
}
return vm
}
因为 $off 支持多种用法,因此下面我们逐个情况进行分析:
1.不传任何参数(清空所有事件)
TypeScriptif (!arguments.length) { vm._events = Object.create(null) return vm }
- 直接丢弃旧的 _events
- 创建一个干净的事件容器
TypeScriptvm._events = {}这里为什么执行的是 Object.create(null),,而不是用 vm._events = {}?
- 没有原型
- 不会被 hasOwnProperty、proto 等干扰
- 与 Vue 内部的事件对象保持一致
2.event 是数组(批量解绑)
TypeScriptif (isArray(event)) { for (let i = 0, l = event.length; i < l; i++) { vm.$off(event[i], fn) } return vm }3.指定事件名,但该事件不存在
TypeScriptconst cbs = vm._events[event!] if (!cbs) { return vm }4.只传 event,不传 fn(移除该事件所有监听)
TypeScriptif (!fn) { vm._events[event!] = null return vm }示例:
TypeScriptvm.$off('update')相当于:
TypeScriptvm._events.update = null这里是设置为 null,而不是 [],后续 $emit 会直接跳过
5.同时传 event + fn(精准解绑)
这里我们发现是从后往前遍历的:
TypeScriptlet i = cbs.length while (i--) { ... }
- 避免 splice 后索引错乱
- 性能更好
- 删除操作安全
生命周期相关方法
源码位置:src/core/instance/lifecycle.ts
在 lifecycleMixin 函数中,依次将 forceUpdate、destroy 挂载在 Vue 原型上

源码位置:src/core/instance/render.ts
$nextTick 方法是在 renderMixin 函数中挂载到 Vue 原型上的

vm.$forceUpdate
强制重新渲染当前组件:强制当前组件执行一次 render + patch 流程,跳过响应式依赖判断
TypeScript
Vue.prototype.$forceUpdate = function () {
const vm: Component = this
if (vm._watcher) {
vm._watcher.update()
}
}
正常更新路径是:
TypeScript响应式数据变更 → dep.notify() → watcher.update() → queueWatcher → render → patch而 $forceUpdate 是:
TypeScript直接 watcher.update()绕过了依赖系统(Dep)
不过调用了 update 也不是立即重新渲染,仍然走异步批量更新队列
所以 $forceUpdate ≠ 同步刷新 DOM
vm.$destroy
彻底销毁组件实例,触发生命周期、解绑依赖、移除 DOM
TypeScript
Vue.prototype.$destroy = function () {
const vm: Component = this
if (vm._isBeingDestroyed) {
return
}
callHook(vm, 'beforeDestroy')
vm._isBeingDestroyed = true
const parent = vm.$parent
if (parent && !parent._isBeingDestroyed && !vm.$options.abstract) {
remove(parent.$children, vm)
}
vm._scope.stop()
if (vm._data.__ob__) {
vm._data.__ob__.vmCount--
}
vm._isDestroyed = true
vm.__patch__(vm._vnode, null)
callHook(vm, 'destroyed')
vm.$off()
if (vm.$el) {
vm.$el.__vue__ = null
}
if (vm.$vnode) {
vm.$vnode.parent = null
}
}
TypeScriptif (vm._isBeingDestroyed) { return }这里的判断是为了防止重复销毁
TypeScriptcallHook(vm, 'beforeDestroy')触发生命周期:beforeDestroy,此时可以做:
- 清定时器
- 解绑事件
- 停止轮询
TypeScriptvm._isBeingDestroyed = true
- 标记"正在销毁"
TypeScriptconst parent = vm.$parent if ( parent && !parent._isBeingDestroyed && !vm.$options.abstract ) { remove(parent.$children, vm) }parent
- 根组件没有 parent
!parent._isBeingDestroyed
- 父组件销毁时,会统一销毁子组件
- 避免重复 remove
!vm.$options.abstract
- abstract 组件(如 keep-alive):
**·**不渲染 DOM
· 不参与 $children 管理
TypeScriptvm._isDestroyed = true
- 标记已销毁
TypeScriptvm.__patch__(vm._vnode, null)
- 移除 DOM(patch 为 null)
TypeScriptcallHook(vm, 'destroyed')
- 触发生命周期:destroyed
TypeScriptvm.$off()
- 清空 _events
总结来说,整体的销毁顺序如下:
TypeScript
beforeDestroy
↓
标记 destroying
↓
脱离父组件
↓
停止所有副作用
↓
移除 DOM(patch null)
↓
destroyed
↓
清事件 / 清引用
vm.$nextTick
$nextTick 在当前数据变更引起的 DOM 更新完成后,执行回调。
我们知道 JS 是单线程的执行模型,并且基于事件循环,执行顺序永远是:
TypeScript
同步代码
→ 微任务
→ DOM 渲染
→ 宏任务
示例1:
javascript
this.msg = 'hello'
console.log(this.$el.textContent)
我们以为的执行顺序:
javascript
改数据 → render → patch → console.log
实际执行顺序:
javascript
改数据
→ watcher.update()
→ queueWatcher(入队)
→ console.log(此时 DOM 还没变)
因此,此时的输出还不是最新的值,因为当数据改变时:
javascript
this.msg = 'hello'
触发
setter
→ dep.notify
→ watcher.update
→ queueWatcher
接着,watcher 被放入一个"更新队列"
javascript
queueWatcher(watcher)
Vue 安排一次"异步 flush",我们注意这一步:Vue 自己也在用 nextTick
javascript
nextTick(flushSchedulerQueue)
此时想要输出最新 DOM,就需要用到示例2这种写法
示例2:
javascript
this.msg = 'hello'
this.$nextTick(() => {
console.log(this.$el.textContent)
})
javascript
① 同步代码开始
② this.msg = 'hello'
③ watcher 入队
④ 注册 nextTick 回调(push 到 callbacks)
⑤ 同步代码结束
------ 当前调用栈清空 ------
⑥ 执行 microtask 队列
├─ flushSchedulerQueue
│ ├─ watcher.run()
│ │ ├─ render()
│ │ └─ patch() → DOM 更新
│
└─ 执行 nextTick 回调
└─ console.log(this.$el.textContent)
⑦ 浏览器渲染
这就是为什么 $nextTick 能"看到最新 DOM"。
为什么不能直接用 Promise.then?
javascript
this.msg = 'hello'
Promise.resolve().then(() => {
console.log(this.$el.textContent)
})
因为不可靠,Vue 自己也在用 Promise.then,我们无法保证我们写的 Promise 在 Vue 的 flush 之前还是之后,而 $nextTick 可以保证 插队到 Vue flush 之后。
源码位置:
src/core/instance/render.ts
src/core/util/next-tick.ts
我们在 renderMixin 函数中调用了 nextTick:
TypeScript
Vue.prototype.$nextTick = function (fn: (...args: any[]) => any) {
return nextTick(fn, this)
}
而真正的 nextTick 源码如下:
TypeScript
const callbacks: Array<Function> = []
let pending = false
function flushCallbacks() {
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
export function nextTick(cb?: (...args: any[]) => any, ctx?: object) {
let _resolve
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx)
} catch (e: any) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {
_resolve(ctx)
}
})
if (!pending) {
pending = true
timerFunc()
}
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
这段代码主要做了三件事:
- 多次 nextTick 合并执行(去重调度)
- 异步执行(微任务优先)
- 同时支持 callback 形式 和 Promise 形式
TypeScriptconst callbacks: Array<Function> = [] let pending = false这里定义了 callbacks 队列
- 用来收集本轮事件循环中所有 nextTick 注册的回调
- 不会立刻执行,而是统一在一次异步任务中执行
还定义了 pending 标记位
- 防止重复调度异步任务
- 确保 同一轮 tick 中只触发一次 timerFunc()
TypeScriptif (!pending) { pending = true timerFunc() }
TypeScriptfunction flushCallbacks() { pending = false // 这一轮异步任务已经执行完成,重置异步标记 const copies = callbacks.slice(0) // 拷贝一份数组,防止执行回调时又调用了 nextTick callbacks.length = 0 // 清空原数组,新的 nextTick 会进入下一轮 for (let i = 0; i < copies.length; i++) { copies[i]() // 按注册顺序执行 } }
TypeScriptexport function nextTick(cb?: (...args: any[]) => any, ctx?: object) { let _resolve callbacks.push(() => { // 将回调函数推入回调队列 if (cb) { try { cb.call(ctx) } catch (e: any) { handleError(e, ctx, 'nextTick') } } else if (_resolve) { _resolve(ctx) } }) if (!pending) { // 如果异步标记不存在,设置标记为 true,调用异步函数,准备等同步函数执行完后,就开始执行回调函数队列 pending = true timerFunc() } if (!cb && typeof Promise !== 'undefined') { return new Promise(resolve => { _resolve = resolve }) } }
至此,我们的实例方法篇完结,开年第一篇源码篇,大家新年快乐~


