源码篇 实例方法

电梯

Vue 源码

源码篇 剖析 Vue2 双向绑定原理

源码篇 使用及分析 Vue 全局 API

源码篇 虚拟DOM

源码篇 模板编译

源码篇 实例方法

持续更新中...

Vue Router 4 源码

源码篇 Vue Router 4 上篇

源码篇 Vue Router 4 中篇

源码篇 Vue Router 4 下篇

源码拉取步骤可以看这篇文章有讲解,下面直接进入正题:

源码篇 剖析 Vue 双向绑定原理-CSDN博客

前言

我们在 源码篇 使用及分析 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 方法
TypeScript 复制代码
Object.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)

javascript 复制代码
Object.keys(data).forEach(key => {
  proxy(vm, '_data', key)
})

因此我们通过 vm.count 可以直接访问 vm._data.count,并同时触发响应式更新

  • vm.$data.count = 2

$data 是 Vue2 用 Object.defineProperty 定义的 getter

TypeScript 复制代码
Object.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 方法
TypeScript 复制代码
Object.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

TypeScript 复制代码
if (isPlainObject(handler)) {
  options = handler
  handler = handler.handler
}

这里就是为了处理这种写法:

TypeScript 复制代码
watch: {
  count: {
    handler(newVal) {},
    immediate: true,
    deep: true
  }
}

此时,handler 是一个对象,最终这个整体就会被处理成:

  • options = 整个对象(含 deep、immediate)
  • handler

即:

TypeScript 复制代码
vm.$watch('count', handler, { deep: true, immediate: true })

TypeScript 复制代码
if (typeof handler === 'string') {
  handler = vm[handler]
}

这里则是为了处理这种写法:

TypeScript 复制代码
methods: {
  handleChange() {}
},
watch: {
  count: 'handleChange'
}

此时 handler 是字符串,需要转成实例方法 "handleChange":

TypeScript 复制代码
handler = vm['handleChange']

TypeScript 复制代码
return vm.$watch(expOrFn, handler, options)

最终调用 $watch,无论写什么形式的 watch,最终都会统一为:

TypeScript 复制代码
vm.$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

TypeScript 复制代码
export 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
}

在这段代码中我们找到这一部分:

TypeScript 复制代码
this.deep = !!options.deep

get() {
    if (this.deep) {
        traverse(value)
    }
    return value
}

this.deep 是从 options 里来的

如果 this.deep 为 true,调用 traverse 递归访问对象所有属性,从而触发依赖收集

下面我们看看 traverse 函数内部:

源码位置:src/core/observer/traverse.ts

TypeScript 复制代码
const seenObjects = new Set()

export function traverse(val: any) {
  _traverse(val, seenObjects)
  seenObjects.clear()
  return val
}
  • 创建一个全局 seenObjects Set,用来避免重复遍历导致死循环
  • 调用 _traverse(val) ------ 执行 DFS(深度优先遍历)来访问所有子属性
  • 清空 Set
  • 返回原值
TypeScript 复制代码
function _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.过滤掉不需要递归的情况

TypeScript 复制代码
if (
  (!isA && !isObject(val)) ||      // 基本类型(数字、字符串等)
  val.__v_skip ||                  // 被显式跳过的响应式对象
  Object.isFrozen(val) ||          // 冻结对象(无法触发 getter)
  val instanceof VNode              // 虚拟节点,不需要观察
) {
  return
}

2.去重处理(避免死循环)

TypeScript 复制代码
if (val.__ob__) {
  const depId = val.__ob__.dep.id
  if (seen.has(depId)) {
    return
  }
  seen.add(depId)
}

如果对象已经被递归过,就跳过。这是为了解决:

  • 循环引用(A->B->A)
  • 同一个对象多次出现

3.根据类型继续递归

  • 如果是数组 → 遍历每一个元素:
TypeScript 复制代码
while (i--) _traverse(val[i], seen)
  • 如果是 ref → 递归其 value:
TypeScript 复制代码
_traverse(val.value, seen)
  • 如果是普通对象 → 遍历所有 key:
TypeScript 复制代码
keys = 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:

  • 注册用户登录事件监听器
javascript 复制代码
this.$on('user-login', (user) => {
  console.log('用户登录:', user)
})
  • 触发用户登录事件
javascript 复制代码
this.$emit('user-login', { id: 1, name: 'Tom' })

示例2:

  • 注册两个用于更新的监听函数,使用 emit 触发
javascript 复制代码
this.$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
}

下面我们分别对各个部分进行解析:

TypeScript 复制代码
Vue.prototype.$on = function (
  event: string | Array<string>,
  fn: Function
): Component

1.在当前 Vue 实例上注册自定义事件监听器

2.支持两种事件形态:

  • 单个事件名:string
  • 多个事件名:string[]

TypeScript 复制代码
if (isArray(event)) {
  for (let i = 0, l = event.length; i < l; i++) {
    vm.$on(event[i], fn)
  }
}

用于判断:是否为数组事件名,当传入的是:

TypeScript 复制代码
vm.$on(['a', 'b', 'c'], fn)

等价于:

TypeScript 复制代码
vm.$on('a', fn)
vm.$on('b', fn)
vm.$on('c', fn)

TypeScript 复制代码
else {
  ;(vm._events[event] || (vm._events[event] = [])).push(fn)

这段代码主要用于 单事件监听注册,这段代码的执行流程:

1.当我们首次注册:

TypeScript 复制代码
vm._events = {}
vm.$on('update', fn)

执行顺序:

  • vm._events['update'] 不存在
  • 执行 (vm._events['update'] = [])
  • 得到一个空数组
  • .push(fn)

结果:

TypeScript 复制代码
vm._events = {
  update: [fn]
}

2.再次注册同名事件:

TypeScript 复制代码
vm.$on('update', fn2)

结果:

TypeScript 复制代码
vm._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 的"对偶实现",负责发布事件,下面我们对它的内部原理进行解析:

TypeScript 复制代码
let cbs = vm._events[event]
  • 从 _events 中取出事件回调列表
TypeScript 复制代码
if (cbs) { ... }
  • 如果事件存在就继续执行

TypeScript 复制代码
cbs = cbs.length > 1 ? toArray(cbs) : cbs

这句代码的核心就是:是否复制回调数组,这里的 toArray 做了什么?

源码位置:src/shared/util.ts

TypeScript 复制代码
function 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),就会破坏当前的遍历过程,导致回调漏执行或行为不确定。比如:

TypeScript 复制代码
function fn1() {
  console.log('fn1 执行')
  vm.$off('test', fn2)
}

function fn2() {
  console.log('fn2 执行')
}

vm.$on('test', fn1)
vm.$on('test', fn2)

此时的内部结构是:

TypeScript 复制代码
vm._events.test = [fn1, fn2]

如果不拷贝,在执行 fn1 的时候会因为执行了 $off 直接修改原数组,那么 fn2 就被跳过了,不会被执行。

但如果进行拷贝:

TypeScript 复制代码
const copiedCbs = [fn1, fn2]

fn1 内部仍然执行 $off,真实事件列表为

TypeScript 复制代码
vm._events.test = [fn1]

但 copiedCbs 不受影响,fn2 还会正常执行

2.为什么是在 cbs.length > 1 的时候才拷贝?

  • 单个回调时,不存在遍历期间修改数组的风险
  • 减少不必要的内存分配,属于一次微性能优化

TypeScript 复制代码
const args = toArray(arguments, 1)

从 arguments 中提取参数,跳过第一个参数 event,比如:

TypeScript 复制代码
vm.$emit('update', 1, 2, 3)

args 等于:

TypeScript 复制代码
[1, 2, 3]

TypeScript 复制代码
for (let i = 0, l = cbs.length; i < l; i++) {
  invokeWithErrorHandling(cbs[i], vm, args, vm, info)
}

严格按注册顺序执行,与 $on 的 push(fn) 顺序一致。

vm.$once

注册一个只会执行一次的事件监听器,执行完成后自动解绑

示例:

javascript 复制代码
this.$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
}

核心代码:

TypeScript 复制代码
function on() {
  vm.$off(event, on)
  fn.apply(vm, arguments)
}
  • 先解绑自己
  • 再执行真正的回调 fn

为什么要先 $off 再执行?比如:

TypeScript 复制代码
vm.$once('test', () => {
  vm.$emit('test')
})

如果顺序反了:

TypeScript 复制代码
fn()
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.不传任何参数(清空所有事件)

TypeScript 复制代码
if (!arguments.length) {
  vm._events = Object.create(null)
  return vm
}
  • 直接丢弃旧的 _events
  • 创建一个干净的事件容器
TypeScript 复制代码
vm._events = {}

这里为什么执行的是 Object.create(null),,而不是用 vm._events = {}?

  • 没有原型
  • 不会被 hasOwnProperty、proto 等干扰
  • 与 Vue 内部的事件对象保持一致

2.event 是数组(批量解绑)

TypeScript 复制代码
if (isArray(event)) {
  for (let i = 0, l = event.length; i < l; i++) {
    vm.$off(event[i], fn)
  }
  return vm
}

3.指定事件名,但该事件不存在

TypeScript 复制代码
const cbs = vm._events[event!]
if (!cbs) {
  return vm
}

4.只传 event,不传 fn(移除该事件所有监听)

TypeScript 复制代码
if (!fn) {
  vm._events[event!] = null
  return vm
}

示例:

TypeScript 复制代码
vm.$off('update')

相当于:

TypeScript 复制代码
vm._events.update = null

这里是设置为 null,而不是 [],后续 $emit 会直接跳过

5.同时传 event + fn(精准解绑)

这里我们发现是从后往前遍历的:

TypeScript 复制代码
let 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
    }
}
TypeScript 复制代码
if (vm._isBeingDestroyed) {
  return
}

这里的判断是为了防止重复销毁


TypeScript 复制代码
callHook(vm, 'beforeDestroy')

触发生命周期:beforeDestroy,此时可以做:

  • 清定时器
  • 解绑事件
  • 停止轮询
TypeScript 复制代码
vm._isBeingDestroyed = true
  • 标记"正在销毁"

TypeScript 复制代码
const 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 管理


TypeScript 复制代码
vm._isDestroyed = true
  • 标记已销毁
TypeScript 复制代码
vm.__patch__(vm._vnode, null)
  • 移除 DOM(patch 为 null)
TypeScript 复制代码
callHook(vm, 'destroyed')
  • 触发生命周期:destroyed
TypeScript 复制代码
vm.$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 形式
TypeScript 复制代码
const callbacks: Array<Function> = []
let pending = false

这里定义了 callbacks 队列

  • 用来收集本轮事件循环中所有 nextTick 注册的回调
  • 不会立刻执行,而是统一在一次异步任务中执行

还定义了 pending 标记位

  • 防止重复调度异步任务
  • 确保 同一轮 tick 中只触发一次 timerFunc()
TypeScript 复制代码
if (!pending) {
  pending = true
  timerFunc()
}

TypeScript 复制代码
function flushCallbacks() {
  pending = false // 这一轮异步任务已经执行完成,重置异步标记
  const copies = callbacks.slice(0) // 拷贝一份数组,防止执行回调时又调用了 nextTick
  callbacks.length = 0 // 清空原数组,新的 nextTick 会进入下一轮
  for (let i = 0; i < copies.length; i++) {
    copies[i]() // 按注册顺序执行
  }
}
TypeScript 复制代码
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) { // 如果异步标记不存在,设置标记为 true,调用异步函数,准备等同步函数执行完后,就开始执行回调函数队列
    pending = true
    timerFunc()
  }
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

至此,我们的实例方法篇完结,开年第一篇源码篇,大家新年快乐~

相关推荐
一只爱吃糖的小羊1 天前
Web Worker 性能优化实战:将计算密集型逻辑从主线程剥离的正确姿势
前端·性能优化
你真的可爱呀1 天前
自定义颜色选择功能
开发语言·前端·javascript
小王和八蛋1 天前
JS中 escape urlencodeComponent urlencode 区别
前端·javascript
奔跑的web.1 天前
TypeScript类型系统核心速通:从基础到常用复合类型包装类
开发语言·前端·javascript·typescript·vue
Misnice1 天前
Webpack、Vite 、Rsbuild 区别
前端·webpack·node.js
Kagol1 天前
🎉历时1年,TinyEditor v4.0 正式发布!
前端·typescript·开源
丶一派胡言丶1 天前
02-VUE介绍和指令
前端·javascript·vue.js
C_心欲无痕1 天前
网络相关 - 跨域解决方式
前端·网络
天蓝色的鱼鱼1 天前
Vue开发必考:defineComponent与defineAsyncComponent,你真的掌握吗?
前端·vue.js