阅读 1757

Vue2 源码总结梳理

前言

这段时间利用课余时间夹杂了很多很多事把 Vue2 源码学习了一遍,但很多都是跟着视频大概过了一遍,也都画了自己的思维导图。但还是对详情的感念模糊不清,故这段时间对源码进行了总结梳理。

本篇文章更合适于已看过 Vue2 源码,进一步总结加深概念的人群。若还未读过源码或零碎一知半解的小伙伴,也可以挑选阶段进行总结梳理,个人还是强烈认为需要过一遍源码

目录结构

├── benchmarks                  性能、基准测试
├── dist                        构建打包的输出目录
├── examples                    案例目录
├── flow                        flow 语法的类型声明
├── packages                    一些额外的包,比如:负责服务端渲染的包 vue-server-renderer、配合 vue-loader 使用的的 vue-template-compiler,还有 weex 相关的
│   ├── vue-server-renderer
│   ├── vue-template-compiler
│   ├── weex-template-compiler
│   └── weex-vue-framework
├── scripts                     所有的配置文件的存放位置,比如 rollup 的配置文件
├── src                         vue 源码目录
│   ├── compiler                编译器
│   ├── core                    运行时的核心包
│   │   ├── components          全局组件,比如 keep-alive
│   │   ├── config.js           一些默认配置项
│   │   ├── global-api          全局 API,比如熟悉的:Vue.use()、Vue.component() 等
│   │   ├── instance            Vue 实例相关的,比如 Vue 构造函数就在这个目录下
│   │   ├── observer            响应式原理
│   │   ├── util                工具方法
│   │   └── vdom                虚拟 DOM 相关,比如熟悉的 patch 算法就在这儿
│   ├── platforms               平台相关的编译器代码
│   │   ├── web
│   │   └── weex
│   ├── server                  服务端渲染相关
├── test                        测试目录
├── types                       TS 类型声明

复制代码

来自 juejin.cn/post/694937…

Vue 初始化

位置:/src/core/instance/index.js

入口

// Vue 的构造函数
function Vue (options) {
  if (process.env.NODE_ENV !== 'production' &&
    !(this instanceof Vue)
  ) {
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  // 在 /src/core/instance/init.js,
  // 1.初始化组件实例关系属性
  // 2.自定义事件的监听
  // 3.插槽和渲染函数
  // 4.触发 beforeCreate 钩子函数
  // 5.初始化 inject 配置项
  // 6.初始化响应式数据,如 props, methods, data, computed, watch
  // 7.初始化解析 provide
  // 8.触发 created 钩子函数
  this._init(options)
}
复制代码

核心代码

源码核心代码顺序以深度遍历形式

initMixin

位置:/src/core/instance/init.js

export function initMixin (Vue: Class<Component>) {
  // 负责 Vue 的初始化过程
  Vue.prototype._init = function (options?: Object) {
    vm._self = vm	// 将 vm 挂载到实例 _self 上

    // 初始化组件实例关系属性,比如 $parent、$children、$root、$refs...
    initLifecycle(vm)

    // 自定义事件的监听:谁注册,谁监听
    initEvents(vm)

    // 插槽信息:vm.$slot
    // 渲染函数:vm.$createElement(创建元素)
    initRender(vm)

    // beforeCreate 钩子函数
    callHook(vm, 'beforeCreate')

    // 初始化组件的 inject 配置项
    initInjections(vm)

    // 数据响应式:props、methods、data、computed、watch
    initState(vm)

    // 解析实例 vm.$options.provide 对象,挂载到 vm._provided 上,和 inject 对应。
    initProvide(vm)

    // 调用 created 钩子函数
    callHook(vm, 'created')
  }
}

复制代码

致命五问

Vue 源码「初始化」致命五问。

  1. beforeCreate 钩子函数前完成了什么?
  2. 父子组件中,子组件调用执行本身注册的自定义事件 A(),那么父子组件中,谁监听事件 A() 的执行调用?
  3. created 钩子函数前完成了什么?
  4. initInjections(vm)initState(vm)initProvide(vm) 三者的执行顺序可否变化?
  5. Vue 的初始化过程?

思考问题后,答案在下方,根据自己阅读整理源码,对自己提出有意义的问题并自我回答。不确保是面试热点题噢(切勿入题太深)

致命五答

一答

问:beforeCreate 钩子函数前完成了什么?

答:beforeCreate 之前,主要是在处理 vm 实例上的各种属性配置和自定义事件属性,也就是将 Vue 的壳初始化完成
首先合并了组件的配置项挂载到全局 vm.$options 上。初始化组件实例关系属性,如:$parent、$children、$root、$refs 等等,然后初始化自定义的事件监听,最后初始化组件的插槽 slot 和作用域插槽scopedSlots,createElement(即 render 函数,同时定义了组件 attrs 和 $listeners属性。)

二答

问:父子组件中,子组件调用执行本身注册的自定义事件 A(),那么父子组件中,谁监听事件 A() 的执行调用?

答:谁注册了自定义事件,则谁监听自定义事件。故是子组件监听事件。

三答

问:created 钩子函数前完成了什么?

答:created 钩子函数是在 Vue 壳构建完成后,开始初始化实例的响应式数据和方法。
首先初始化好 inject 配置项,再初始化各种响应式数据和方法如:props、methods、data、computed、watch,最后初始化 vm._provided 属性。

四答

问:initInjections(vm)、initState(vm)、initProvide(vm) 三者的执行顺序可否变化?

答:不可以,源码中有官方注释。
inject 配置项是注入数据,在后续的 computed 和 data 中均可以或需要使用注入数据,故解析 injections 需要在 data/props 前。
解析 provide 实际上只是将 vm.$options.provide 挂载到 vm._providedinject 上,需要等响应式数据和方法初始化完毕后再执行。inject 和 provide 是成对出现的,一个注入,一个接收。

    initInjections(vm) // resolve injections before data/props
    initState(vm)
    initProvide(vm) // resolve provide after data/props
复制代码

五答

问:Vue 的初始化过程?

答:Vue 初始化过程其实就是 beforeCreate 钩子函数和 created 钩子函数前执行的内容。

  • 在 beforeCreate 前,主要先初始化搭建了 Vue 实例的壳,如组件的 options 配置项,组件实例的关系属性,处理了自定义事件。
  • 在 created 前,主要是初始化实例的响应式数据和方法,首先初始化 inject 配置项,再初始化数据响应式和方法,最后解析组件配置项上的 provide 对象。总结来说构建初始化 Vue 实例对象 vm。

响应式原理

位置:/src/core/instance/index.js

入口

// 初始化数据响应式:props、methods、data、computed、watch
export function initState (vm: Component) {
  // 初始化当前实例的 watchers 数组
  vm._watchers = []
  // 拿到上边初始化合并后的 options 配置项
  const opts = vm.$options
  
  // props 响应式,挂载到 vm
  if (opts.props) initProps(vm, opts.props)
  
  // 1. 判断 methods 是否为函数
  // 2. 方法名与 props 判重
  // 3. 挂载到 vm
  if (opts.methods) initMethods(vm, opts.methods)
  
  if (opts.data) {
    // 初始化 data 并挂载到 vm
    initData(vm)
  } else {
    // 响应式 data 上的数据
    observe(vm._data = {}, true /* asRootData */)
  }
  
	// 1. 创建 watcher 实例,默认是懒执行,并挂载到 vm 上
  // 2. computed 与上列 props、methods、data 判重
  if (opts.computed) initComputed(vm, opts.computed)
  
  // 1. 处理 watch 对象与 watcher 实例的关系(一对一、一对多)
  // 2. watch 的格式化和配置项
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}

复制代码

核心代码

源码核心代码顺序以深度遍历形式

observe

位置:/src/core/observer/index.js

// 为对象创建观察者 Observe
export function observe (value: any, asRootData: ?boolean): Observer | void {
  // 非对象和 VNode 实例不做响应式处理
  if (!isObject(value) || value instanceof VNode) {
    return
  }
  let ob: Observer | void
  // 若 value 对象上存在 __ob__ 属性并且实例是 Observer 则表示已经做过观察了,直接返回 __ob__ 属性。
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else if (
    // 一堆判断对象的条件
    shouldObserve &&
    !isServerRendering() &&
    (Array.isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    !value._isVue
  ) {
    // 创建观察者实例
    ob = new Observer(value)
  }
	// 
  if (asRootData && ob) {
    ob.vmCount++
  }
  return ob
}

复制代码

Observer

位置:/src/core/observer/index.js

// 监听器类
export class Observer {
  // ... 配置
  constructor (value: any) {
    this.value = value
    // 实例化一个发布者 Dep
    this.dep = new Dep()
    this.vmCount = 0
    def(value, '__ob__', this)
    if (Array.isArray(value)) {
      // ...处理数组
    } else {
      // value 为对象,为对象的每个属性设置响应式
      // 也就是为啥响应式对象属性的对象也是响应式
      this.walk(value)
    }
  }
	
	// 值为对象时
  walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      // 设置响应式对象
      defineReactive(obj, keys[i])
    }
  }

	// 值为数组时
  observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      // 判断,优化,创建观察者实例
      observe(items[i])
    }
  }
}
复制代码

Dep

位置:/src/core/observer/dep.js

// 订阅器类
export default class Dep {
  constructor () {
    // 该 dep 发布者的 id
    this.id = uid++
    // 存放订阅者
    this.subs = []
  }

  // 添加订阅者
  addSub (sub: Watcher) {
    this.subs.push(sub)
  }

  // 添加订阅者
  removeSub (sub: Watcher) {
    remove(this.subs, sub)
  }

  // 向订阅者中添加当前 dep
  // 在 Watcher 中也有这个操作,实现双向绑定
  depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

  // 通知 dep 中的所有 watcher,执行 watcher.update() 方法
  notify () {
    	// ...省略代码
  }
}

复制代码

Watcher

位置:/src/core/observer/watcher.js

// 订阅者类,一个组件一个 watcher,订阅的数据改变时执行相应的回调函数
export default class Watcher {
  ...代码省略:constructor() 构造配置一个 watcher

  get () {
    // 打开 Dep.target,Dep.target = this
    pushTarget(this)
    // value 为回调函数执行的结果
    let value
    const vm = this.vm
    try {
      // 这里执行 updateComponent,进入 patch 阶段更新视图。
      value = this.getter.call(vm, vm)
    } catch (e) {
      // ...捕获异常
    } finally {
      // "touch" every property so they are all tracked as
      // dependencies for deep watching
      if (this.deep) {
        traverse(value)
      }
      // 最后清除 watcher 实例的各种依赖收集
      popTarget()
      this.cleanupDeps()
    }
    return value
  }

  addDep (dep: Dep) {
    const id = dep.id
    // watcher 订阅着 dep 发布者并进行缓存判重
    if (!this.newDepIds.has(id)) {
      // 缓存 dep 发布者
      this.newDepIds.add(id)
      this.newDeps.push(dep)
      
      // 发布者收集订阅者 watcher
      // 在 dep 中也有这个操作,实现双向绑定
      if (!this.depIds.has(id)) {
        dep.addSub(this)
      }
    }
  }

  /**
   * Clean up for dependency collection.
   */
  cleanupDeps () {
    // ...代码省略
    // 清除 dep 发布者的依赖收集
  }

	// 订阅者 update() 更新
  update () {
    /* istanbul ignore else */
    // // 懒执行如 computed
    if (this.lazy) {
      this.dirty = true
      
    // 同步执行,watcher 实例的一个配置项
    } else if (this.sync) {
      // 同步执行,在使用 vm.$watch 或者 watch 选项时可以传一个 sync 选项,
      this.run()
    } else {
      // 大部分 watcher 更新进入 watcher 的队列
      queueWatcher(this)
    }
  }

	// 1. 同步执行时会调用
	// 2. 浏览器异步队列刷新 flushSchedulerQueue() 会调用
  run () {
    // ...代码省略,active = false 直接返回
    // 使用 this.get() 获取新值来更新旧值
    // 并且执行 cb 回调函数,将新值和旧值返回。
  }

	// 订阅者 watcher 懒执行
  evaluate () {
    this.value = this.get()
    this.dirty = false
  }

  /**
   * Depend on all deps collected by this watcher.
   */
  depend () {
    // 调用当前 watcher 依赖的所有 dep 发布者的 depend()
    let i = this.deps.length
    while (i--) {
      this.deps[i].depend()
    }
  }

  /**
   * Remove self from all dependencies' subscriber list.
   */
  teardown () {
    	// ...销毁该 watcher 实例
  }
}

复制代码

defineReactive

位置:/src/core/observer/index.js

// 设置响应式对象
export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
	...省略
  // 响应式核心
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    
    // get 拦截对象的读取操作
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
        
        // 依赖收集并通知实现发布者 dep 和订阅者 watcher 的双向绑定
        dep.depend()
        
        // 依赖收集对象属性中的对象
        if (childOb) {
          childOb.dep.depend()
          // 数组情况
          if (Array.isArray(value)) {
            // 为数组项为对象的项添加依赖
            dependArray(value)
          }
        }
      }
      return value
    },
    
    // set 拦截对对象的设置操作
    set: function reactiveSetter (newVal) {
      const value = getter ? getter.call(obj) : val
      // 无新值,不用更新则直接 return
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      // 没有 setter,只读属性,则直接 return
      if (getter && !setter) return
      
      // 设置新值
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      // 将新值进行响应式
      childOb = !shallow && observe(newVal)
      // dep 发布者通知更新
      dep.notify()
    }
  })
}
复制代码

proxy

位置:/src/core/instance/state.js

const sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: noop,
  set: noop
}

// 为每个属性设置拦截代理,并且挂载到 vm 上(target)
// 如 proxy(vm, `_props`, key)、proxy(vm, `_data`, key)
export function proxy (target: Object, sourceKey: string, key: string) {
  sharedPropertyDefinition.get = function proxyGetter () {
    return this[sourceKey][key]
  }
  sharedPropertyDefinition.set = function proxySetter (val) {
    this[sourceKey][key] = val
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}

复制代码

致命五问

Vue 源码「响应式原理」致命五问。

  1. 什么是 MVVM 模式?
  2. Vue 的双向绑定原理?
  3. Vue 如何处理响应式数据?
  4. computed 和 watch 的特性区别?
  5. computed 和 watch 的使用场景区别?

思考问题后,答案在下方,根据自己阅读整理源码,对自己提出有意义的问题并自我回答。不确保是面试热点题噢(切勿入题太深)

致命五答

一答

问:什么是 MVVM 模式?

答:MVVM(Model–View–ViewModel ) 是一个软件架构设计模式。其进了前端开发与后端业务逻辑的分离,极大地提高了前端开发效率,MVVM 分为以下三层

  • 1.View 视图层,也就是构建出来的用户页面。
  • 2.Model 数据层,就是存放数据状态。
  • 3.ViewModel 视图数据层,是 MVVM 模式的核心层,作为其余两层的中间枢纽,更新视图层且操作改变数据层的状态。

二答

问:Vue 的双向绑定原理?

答:Vue 双向绑定采用的是 MVVM 模式。监听器 Observer 、订阅器 Dep、订阅者 Watcher、解析器 Compile

  • Compile 解析器:扫描和解析每个节点的相关指令,并根据初始化模板数据以及初始化相应的订阅器。
  • Observer 监听器:调用 defineReactive 劫持并监听所有属性,getter 向 Dep 依赖。
  • Dep 订阅器:收集观察者 Watcher 和通知观察者目标更新。每个属性拥有自己的消息订阅器dep,用于存放所有订阅了该属性的观察者对象,当数据发生改变时,通知所有的 watch 执行自己的update逻辑。
  • Watcher 订阅者:观察属性提供回调函数以及收集依赖(如计算属性computed,vue会把该属性所依赖数据的dep添加到自身的deps中),当被观察的值发生变化时,会接收到来自dep的通知,从而触发回调函数。
    • Watcher类的实现比较复杂,因为他的实例分为渲染 watcher(render-watcher)、计算属性 watcher(computed-watcher)、侦听器 watcher(normal-watcher)三种。
      • computed-watcher:我们在组件钩子函数computed中定义,这类 watcher 有个特点:当计算属性依赖于其他数据时,属性并不会立即重新计算,只有之后其他地方需要读取属性的时候,它才会真正计算,即具备 lazy(懒计算)特性。
      • normal-watcher:我们在组件钩子函数watch 中定义,即只要监听的属性改变了,都会触发定义好的回调函数。
      • render-watcher:每一个组件都会有一个 render-watcher,当 data/computed 中的属性改变的时候,会调用该 render-watcher 来更新组件的视图。
      • 这三种 watcher 也有固定的执行顺序,分别是:computed-render -> normal-watcher -> render-watcher。尽可能的保证,在更新组件视图的时候,computed 属性已经是最新值了,如果 render-watcher 排在 computed-render 前面,就会导致页面更新的时候 computed 值为旧数据。
  • 而 Dep 订阅器和 Watcher 订阅者又是一种观察者模式。Watcher 用来订阅属性的变化通,从而更新视图。Dep 用来收集 Watcher 的依赖,当 Observer 更新时,通过 dep.notify() 统一派发给 Watcher,实现了双向绑定。
  • 综上:简单来说通过数据劫持+发布订阅模式,通过以下初始化和更新的过程来实现双向绑定,也就是响应式原理。
  • 初始化:
    • 1.Observer 对数据进行响应式绑定
    • 2.Compiler 编译解析模块指令,初始化渲染页面,并将每个指令的节点绑上更新函数,实例化监听监听数据的订阅者 Watcher。
    • 3.数据 getter 时,执行对应数据的 dep 收集所有 watcher 依赖
  • 更新:
    • 1.更新时触发 dep.notify(),派发通知所有订阅者 watcher
    • 2.订阅者 watcher 执行 update() 回调函数
    • 3.调用对应 Compiler 编译解析模块,重新更新视图

image.png

三答

问:Vue 如何处理响应式数据?

答:响应式的数据主要分为两类:Object 和 Array

  • Object 对象则利用 defineReactive(),来循环遍历整个对象,通过 Object.defineProperty 设置 getter 和 setter 的拦截,再通过观察者模式双向绑定来实现对象响应式原理
  • Array 数组则利用 def() 方法对 Array.prototype.push()/pop()/shift()/unshift()/splice()/sort()/reverse() 进行 Object.defineProperty 拦截,实现响应式。(感谢「故心」大佬提醒纰漏)
    • Vue.set()/delete() 方法处理数组异步更新利用的是 Array.splice()

四答

问:computed 和 watch 的特性区别?

答:通过源码阅读 computed 和 watch 在本质是没有区别的,都是通过 Watcher 的实例去实现的响应式,主要有以下特性区别。

  1. computed 默认为懒执行,dirty 为 true。watch 有 immediate 配置,可以实现立即执行一次 cb。
  2. computed 支持缓存,依赖数据发生改变,才会重新进行计算。watch 不支持缓存,立即响应式变化。
  3. computed 不支持异步。watch 支持异步。
  4. computed 的 cb 函数默认走 get 方法。watch 的 cb 函数第一个参数是新值,第二个参数是旧值。

五答

问:computed 和 watch 的使用场景区别?

答:computed 和 watch 使用场景的区别根本原因是因它们的特性不同,大致有以下的场景区别。

  • 选择 computed
    1. 当数据需要缓存时
    2. 当数据依赖其他数据计算得到时
    3. 逻辑较为简单并无需异步操作时(watch 消耗较大)
  • 选择 watch
    1. 当执行异步操作时
    2. 即时监听数据完成较为复杂的回调函数时

异步更新

Vue 源码的异步更新也就是响应式原理的进一步深入,下面引用以下官方对于异步更新的介绍来进一步了解这个概念。

可能你还没有注意到,Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。然后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。Vue 在内部对异步队列尝试使用原生的 Promise.thenMutationObserversetImmediate,如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替。

例如,当你设置 vm.someData = 'new value',该组件不会立即重新渲染。当刷新队列时,组件会在下一个事件循环“tick”中更新。多数情况我们不需要关心这个过程,但是如果你想基于更新后的 DOM 状态来做点什么,这就可能会有些棘手。虽然 Vue.js 通常鼓励开发人员使用“数据驱动”的方式思考,避免直接接触 DOM,但是有时我们必须要这么做。为了在数据变化之后等待 Vue 完成更新 DOM,可以在数据变化之后立即使用 Vue.nextTick(callback)。这样回调函数将在 DOM 更新完成后被调用。

入口

异步更新发生在响应式原理更新 dep.notify() 派发通知给 watcher 调用 update() 更新回调方法。

位置:/src/core/observer/watcher.js

// watcher 异步更新入口
update () {
  // computed 懒加载走这
  if (this.lazy) {
    this.dirty = true
  } else if (this.sync) {
    // 当给 watcher 实例设置同步选项,也就是不走异步更新队列,直接执行 this.run() 调用更新
    // 这个属性在官方文档中没有出现
    this.run()
  } else {
    // 大部分都走 queueWatcher() 异步更新队列
    queueWatcher(this)
  }
}

复制代码

核心代码

源码核心代码顺序以深度遍历形式

queueWatcher

位置:/src/core/observer/scheduler.js

// 将当前 watcher 放入 watcher 的异步更新队列 
export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
	// 避免重复添加相同 watcher 进异步更新队列
  if (has[id] == null) {
    // 缓存标记
    has[id] = true
    // flushing 正在刷新队列
    if (!flushing) {
      // 直接入队
      queue.push(watcher)
    } else {
      // 正在刷新队列
      // 将 watcher 按 id 递增顺序放入更新队列中。
      let i = queue.length - 1
      while (i > index && queue[i].id > watcher.id) {
        i--
      }
      // 用数组切割方法
      queue.splice(i + 1, 0, watcher)
    }
    // queue the flush
    // 正在刷新队列
    if (!waiting) {
      // 设置标记,确保只有一条异步更新队列
      waiting = true
      if (process.env.NODE_ENV !== 'production' && !config.async) {
        // 直接刷新队列:
        // 1.异步更新队列 queue 升序排序,确保按 id 顺序执行
        // 2.遍历队列调用每个 watcher 的 before()、run() 方法并清除当前 watcher 缓存(也就是 id 置为空)
        // 3.调用 resetSchedulerState(),重置异步更新队列,等待下一次更新。(也就是清除缓存,初始化下标,俩标志设为 false)
        flushSchedulerQueue()
        return
      }
      // 也就是 vm.$nextTick、Vue.nextTick
      // 做了两件事:
      // 1.将回调函数(flushSchedulerQueue) 放入 callbacks 数组。
      // 2.向浏览器任务队列中添加 flushCallbacks 函数,达到下次 DOM 渲染更新后立即调用
      nextTick(flushSchedulerQueue)
    }
  }
}
复制代码

run

位置:/src/core/observer/watcher.js

调用:flushSchedulerQueue() 遍历调用每个 watcher 的 run()

/**
 * 由 刷新队列函数 flushSchedulerQueue 调用,如果是同步 watch,则由 this.update 直接调用,完成如下几件事:
 *   1、执行实例化 watcher 传递的第二个参数,updateComponent 或者 获取 this.xx 的一个函数(parsePath 返回的函数)
 *   2、更新旧值为新值
 *   3、执行实例化 watcher 时传递的第三个参数,比如用户 watcher 的回调函数
 */
run () {
  if (this.active) {
    // 调用 watcher.get() 获取当前 watcher 的值。
    const value = this.get()
    if (
      value !== this.value ||
      // Deep watchers and watchers on Object/Arrays should fire even
      // when the value is the same, because the value may
      // have mutated.
      isObject(value) ||
      this.deep
    ) {
      // 更新值
      const oldValue = this.value
      this.value = value
			// 若果是用户定义的 watcher,执行用户 cb 函数,传递新值和旧值。
      if (this.user) {
        try {
          this.cb.call(this.vm, value, oldValue)
        } catch (e) {
          handleError(e, this.vm, `callback for watcher "${this.expression}"`)
        }
      } else {
        // 其余走渲染 watcher,this.cb 默认为 noop(空函数)
        this.cb.call(this.vm, value, oldValue)
      }
    }
  }
}
复制代码

nextTick

位置:/src/core/util/next-tick.js

const callbacks = [] 
let pending = false

// cb 函数是 flushSchedulerQueue 异步函数队列
export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  // callbacks 数组推进 try/catch 封装的 cb(避免异步队列中某个 watcher 回调函数发生错误无法排查)
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  // 执行了 flushCallbacks() 函数,表示当前浏览器异步任务队列无 flushCallbacks 函数
  if (!pending) {
    pending = true
    // nextTick() 的重点!
    // 执行 timerFunc,重新在浏览器的异步任务队列中放入 flushCallbacks 函数
    timerFunc()
  }
  // 做 Promise 异常处理
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}


// timerFunc 将 flushCallbacks 函数放入浏览器的异步任务队列中。
// 关键在于放入浏览器异步任务队列的优先级!
// 1.Promise.resolve().then(flushCallbacks)
// 2.new MutationObserver(flushCallbacks)
// 3.setImmediate(flushCallbacks)
// 4.setTimeout(flushCallbacks, 0)
let timerFunc
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  timerFunc = () => {
  	// 第一选 Promise.resolve().then() 放入 flushCallbacks
    p.then(flushCallbacks)
    // 若挂掉了,采用添加空计时器来“强制”刷新微任务队列。
    if (isIOS) setTimeout(noop)
  }
  isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) ||
  // PhantomJS and iOS 7.x
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  // Use MutationObserver where native Promise is not available,
  // e.g. PhantomJS, iOS7, Android 4.4
  // (#6466 MutationObserver is unreliable in IE11)
  let counter = 1
  
  // 第二选 new MutationObserver(flushCallbacks)
  // 创建并返回一个新的 MutationObserver 它会在指定的DOM发生变化时被调用。
  // [MDN](https://developer.mozilla.org/zh-CN/docs/Web/API/MutationObserver)
  const observer = new MutationObserver(flushCallbacks)
  const textNode = document.createTextNode(String(counter))
  observer.observe(textNode, {
    characterData: true
  })
  timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
  }
  isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  // 第三选 setImmediate()
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  // 第四选 setTimeout() 定时器
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}


// 最终一条浏览器异步队列执行 callbacks 数组中的方法来达到 nextTick() 异步更新调用方法。
function flushCallbacks () {
  // 设置标记,开启下一次浏览器异步队列更新
  pending = false
  const copies = callbacks.slice(0)
  // 清空 callbacks 数组
  callbacks.length = 0
  // 执行异步更新队列其中存储的每个 flushSchedulerQueue 函数
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}
复制代码

致命五问

Vue 源码「异步更新」致命五问。

  1. Vue 响应式原理中的异步更新是如何实现?
  2. Vue 默认更新是同步的还是异步的?
  3. Vue 是如何避免重复执行同一次异步更新?
  4. Vue 的 nextTick 全局 API 是如何实现的?
  5. Vue 是如何将刷新 callbacks 数组的函数放入浏览器任务队列进行异步更新的?

思考问题后,答案在下方,根据自己阅读整理源码,对自己提出有意义的问题并自我回答。不确保是面试热点题噢(切勿入题太深)

致命五答

一答

问:Vue 响应式原理中的异步更新是如何实现?

答:Dep 订阅器派发通知给每个 watcher 订阅器,执行 update() 方法开始异步更新。
异步更新原理总体来说是:将 每个 watcher 放入 queue 全局队列中 => 调用 nextTick() 方法将刷新 watcher 队列的方法 flushSchedulerQueue 放入 callbacks 数组中 => 将刷新 callbacks 数组的函数 flushCallbacks 通过 timerFunc() 方法放进浏览器的异步任务队列中 => 最后浏览器遍历执行 callbacks 数组中的刷新 watcher 队列方法 flushSchedulerQueue => 刷新 watcher 队列方法遍历执行 queue 队列的每个 watcher.before() 和 watcher.run() 方法 => 继续下一次异步更新
以下是 update() 方法详情:

  1. 首先判断两个特殊标记
    • 是否为 lazy 懒更新,则设置 dirty 为 true,以标记当前 watcher 为懒更新
    • 再判断是否有 sync 同步更新标记,直接执行 watcher.run(),Vue 官方不推荐使用,文档没有该属性。
  2. 然后将 watcher 放入 queue 队列中,放入队列有两种方式,以 flushing 标志判断
    • 若无在刷新队列中,直接 push 进 queue 队列
    • 若正在刷新队列中,按 watcher.id 进行升序排序,确保更新的顺序
  3. 然后调用 nextTick(),将 flushSchedulerQueue(刷新当前 watcher 队列的方法)放入 callbacks 数组中。若浏览器的任务队列中无 flushCallbacks 函数,则执行 timerFunc()。(用 pending 来判断控制)
  4. timerFunc() 将 flushCallbacks 函数(执行第 3 点中 callbacks 数组中的所有 flushSchedulerQueue 方法)放入浏览器的异步任务队列中
  5. 等待浏览器异步任务队列执行 callbacks 数组中的 flushSchedulerQueue 方法。
  6. 每个 flushSchedulerQueue 方法中先将 queue 队列排序,再遍历 queue 执行 watcher.before() 和 watcher.run() 方法,而后再初始化异步更新队列,自此异步更新完成。

二答

问:Vue 默认更新是同步的还是异步的?

答:Vue 默认异步更新,通过 watcher.async。Vue 源码还设置了开启同步更新的操作,可以通过设置 watcher.sync 的属性,在 watcher.update() 方法时并直接执行 watcher.run() 方法进行更新操作。但 Vue 官方不推荐使用该属性,因同步更新机制将阻塞后续任务的执行,整个组件更新将大打折扣。

三答

问:Vue 是如何避免重复执行同一次异步更新?

答:通过三个标识符的操作来进行避免重复执行同一次的异步更新。

  1. 在将 watcher 放入 watcher 队列时,进行了 id 的缓存,避免重复 watcher 添加到 queue 数组。
  2. 通过 waiting 判断是否正在刷新 queue 队列,避免重复执行刷新 queue 队列。
  3. 通过 pending 判断浏览器的异步任务队列中是否有刷新 callbacks(放的是刷新 queue 队列的任务) 数组的任务,避免浏览器异步任务队列重复执行刷新 callbacks 数组的任务。

四答

问:Vue 的 nextTick 全局 API 是如何实现的?

答:Vue.nextTick 将传递的刷新 watcher 队列的回调函数 用 try catch 包裹然后放入 callbacks 数组。
在浏览器异步任务队列无其他刷新 callbacks 数组的方法时,执行 timerFunc 函数,放入当前刷新 callbacks 数组的方法。
进而达到在下次 DOM 更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM。 的功能

五答

问:Vue 是如何将刷新 callbacks 数组的函数放入浏览器任务队列进行异步更新的?

答:根据浏览器任务队列异步执行的效率来选择放入方法的优先级,分别为:

  1. Promise.resolve().then(flushCallbacks)
  2. new MutationObserver(flushCallbacks)
    • 提供了监视对DOM树所做更改的能力(HTML5 中的新特性)
  3. setImmediate(flushCallbacks)
  4. setTimeout(flushCallbacks, 0)

Vue 全局 API

位置:/src/core/global-api/index.js

调用: /src/core/index.js

入口

// 初始化全局配置和 API
export function initGlobalAPI (Vue: GlobalAPI) {
  // 全局配置 config
  const configDef = {}
  configDef.get = () => config
  if (process.env.NODE_ENV !== 'production') {
    configDef.set = () => {
      warn(
        'Do not replace the Vue.config object, set individual fields instead.'
      )
    }
  }
  // 给 Vue 挂载全局配置,并拦截。
  Object.defineProperty(Vue, 'config', configDef)

  // Vue 的全局工具方法: Vue.util.xx
  Vue.util = {
    // 警告
    warn,
    // 选项扩展
    extend,
    // 选项合并
    mergeOptions,
    // 设置响应式
    defineReactive
  }

  // Vue.set()
  Vue.set = set
  
  // Vue.delete()
  // 处理操作与下列 set() 基本一致。
  // target 为对象时,采用运算符 delete
  Vue.delete = del
  
  // Vue.nextTick()
  // 不多 BB 就是上节 异步更新原理中的 nextTick
	// 1.将回调函数(flushSchedulerQueue) 放入 callbacks 数组。
	// 2.向浏览器任务队列中添加 flushCallbacks 函数,达到下次 DOM 渲染更新后立即调用
  Vue.nextTick = nextTick

  // Vue.observable() 响应式方法
  // 也不多 BB 就是上上节 响应式原理中的 observe
  // 为对象创建一个 Oberver 监听器实例,并监听
  Vue.observable = <T>(obj: T): T => {
    observe(obj)
    return obj
  }

  Vue.options = Object.create(null)
  // ASSET_TYPES = ['component', 'directive', 'filter']
  ASSET_TYPES.forEach(type => {
    // 初始化挂载 Vue.options.xx 实例对象
    Vue.options[type + 's'] = Object.create(null)
  })

  // Vue.options._base 挂载 Vue 的构造函数
  Vue.options._base = Vue

  // 在 Vue.options.components 中扩展内置组件,比如 keep-alive
  // 在 /src/shared/utils.js:(for in 挂载)
  extend(Vue.options.components, builtInComponents)

  // Vue.use 全局 API:安装 plugin 插件
  // 1.installedPlugins 缓存判断当前 plugin 是否已安装
  // 2.调用 plugin 的安装并缓存
  initUse(Vue)
  
  // Vue.mixin 全局 API:混合配置
  // this.options = mergeOptions(this.options, mixin)
  // 出现相同配置项时,子选项会覆盖父选项的配置:options[key] = strat(parent[key], child[key], vm, key)
  initMixin(Vue)
  
  // Vue.extend 全局 API:扩展一些公共配置或方法
  initExtend(Vue)
  
  // Vue.component/directive/filter 全局 API:创造组件实例注册方法
  initAssetRegisters(Vue)
}
复制代码

核心代码

源码核心代码顺序以深度遍历形式

set()

位置:/src/core/observer/index.js

// 通过 vm.$set() 方法给对象或数组设置响应式
export function set (target: Array<any> | Object, key: any, val: any): any {
  // ...省略代码:警告
  
  // 更新数组通过 splice 方法实现响应式更新:vm.$set(array, idx, val)
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val)
    return val
  }
  
  // 更新已有属性,直接更新最新值:vm.$set(obj, key, val)
  if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
  }
  
  // 设置未定义的对象值
  // 获取当前 target 对象的 __ob__,判断是否已被 observer 设置为响应式对象。
  const ob = (target: any).__ob__
  // ...省略代码:不能向 _isVue 和 ob.vmCount = 1 的根组件添加新值
  
  // 若 target 不是响应式对象,直接往 target 设置静态属性
  if (!ob) {
    target[key] = val
    return val
  }
  // 若 target 是响应式对象
  // defineReactive() 添加上响应式属性
  // 立即调用对象上的订阅器 dep 派发更新
  defineReactive(ob.value, key, val)
  ob.dep.notify()
  return val
}

复制代码

initExtend

位置:/src/core/global-api/extend.js

export function initExtend (Vue: GlobalAPI) {
 	// 每个实例构造函数(包括Vue)都有一个唯一的 cid。这使我们能够创建包装的“子对象”,用于原型继承和缓存它们的构造函数。
  Vue.cid = 0
  let cid = 1

  // Vue 去扩展子类
  Vue.extend = function (extendOptions: Object): Function {
    extendOptions = extendOptions || {}
    const Super = this
    const SuperId = Super.cid

    // 缓存多次 Vue.extend 使用同一个配置项时
    const cachedCtors = extendOptions._Ctor || (extendOptions._Ctor = {})
    if (cachedCtors[SuperId]) {
      return cachedCtors[SuperId]
    }

    // 是否为有效的配置项名,避免重复
    const name = extendOptions.name || Super.options.name
    if (process.env.NODE_ENV !== 'production' && name) {
      validateComponentName(name)
    }

    // 定义 Sub 构造函数,准备合并
    const Sub = function VueComponent(options) {
      // 就是 Vue 实例初始化的 init() 方法
      this._init(options)
    }
    // 通过原型继承的方式继承 Vue
    Sub.prototype = Object.create(Super.prototype)
    Sub.prototype.constructor = Sub
    // 唯一标识
    Sub.cid = cid++
    // 选项合并
    Sub.options = mergeOptions(
      Super.options,
      extendOptions
    )
    // 挂载自己的父类
    Sub['super'] = Super

    // 将上边合并的配置项初始化配置代理到 Sub.prototype._props/_computed 对象上
    // 方法在下边
    if (Sub.options.props) {
      initProps(Sub)
    }
    if (Sub.options.computed) {
      initComputed(Sub)
    }

    // 实现多态方法
    Sub.extend = Super.extend
    Sub.mixin = Super.mixin
    Sub.use = Super.use

    // 实现 component、filter、directive 三个静态方法
    ASSET_TYPES.forEach(function (type) {
      Sub[type] = Super[type]
    })

    // 递归组件的原理并注册
    if (name) {
      Sub.options.components[name] = Sub
    }

    // 在扩展时保留对基类选项的引用,可以检查 Super 的选项是否是最新。
    Sub.superOptions = Super.options
    Sub.extendOptions = extendOptions
    Sub.sealedOptions = extend({}, Sub.options)

    // 缓存
    cachedCtors[SuperId] = Sub
    return Sub
  }
}

function initProps (Comp) {
  const props = Comp.options.props
  for (const key in props) {
    proxy(Comp.prototype, `_props`, key)
  }
}

function initComputed (Comp) {
  const computed = Comp.options.computed
  for (const key in computed) {
    defineComputed(Comp.prototype, key, computed[key])
  }
}

复制代码

initAssetRegisters

位置:/src/core/global-api/assets.js

export function initAssetRegisters (Vue: GlobalAPI) {
  // ASSET_TYPES = ['component', 'directive', 'filter']
  ASSET_TYPES.forEach(type => {
    // 每个 Vue 上挂载实例注册方法
    Vue[type] = function (
      id: string,
      definition: Function | Object
    ): Function | Object | void {
      // 无方法
      if (!definition) {
        // 返回空
        return this.options[type + 's'][id]
      } else {
        if (type === 'component' && isPlainObject(definition)) {
          // 组件若为 name,默认为 id
          definition.name = definition.name || id
          // 调用 Vue.extend,将该组件进行扩展,也就是可以实例化该组件
          definition = this.options._base.extend(definition)
        }
		    // bind 绑定和 update 更新指令均调用该 defintion 方法
        if (type === 'directive' && typeof definition === 'function') {
          definition = { bind: definition, update: definition }
        }
        // this.options.components[id] = definition || this.options.directives[id] = definition || this.options.filter[id] = definition
        this.options[type + 's'][id] = definition
        return definition
      }
    }
  })
}

复制代码

致命六问

Vue 源码「全局 API」致命六问。

  1. Vue 初始化全局 API 时,做了什么?
  2. Vue 全局 API 有什么作用?
  3. Vue 中当父子组件配置选项发生冲突时,是如何处理?
  4. 初始化后,自定义往 Vue 实例上的响应式对象添加属性,添加的属性是否具有响应式?
  5. 如何自定义数据实现响应式?
  6. vm.$set() 和 vm.$delete() 方法,分别如何操作对象和数组?

思考问题后,答案在下方,根据自己阅读整理源码,对自己提出有意义的问题并自我回答。不确保是面试热点题噢(切勿入题太深)

致命六答

一答

问:Vue 初始化全局 API 时,做了什么?

答:
1.Vue 初始化了全局的 config 配置并设为响应式。
2.暴露一些工具方法,如日志、选项扩展、选项合并、设置对象响应式
3.暴露全局初始化方法,如 Vue.set、Vue.delete、Vue.nextTick、Vue.observable
4.暴露组件配置注册方法,如  Vue.options.components、Vue.options.directives、Vue.options.filters、Vue.options._base
5.暴露全局方法,如 Vue.use、Vue.mixin、Vue.extend、Vue.initAssetRegisters()
复制代码

二答

问:Vue 全局 API 有什么作用?

答:

  • Vue.use(): 用来安装 plugin 插件,对插件进行缓存优化,并执行 install() 安装。
  • Vue.mixin():用来在 Vue 的全局配置上合并 options 配置。并且每个组件生成 vnode 时会合并全局配置和组件配置,因此可以作为抽离公共的业务逻辑,实现公共的业务逻辑,也就是类的继承。
  • Vue.extend():用来在 Vue 实例扩展子类,可以用于一些公共组件化配置上。与 Vue.mixin() 区别,我认为 extend 更多的是公众的组件化,也就是类的多态,外观模式。
  • Vue.initAssetRegisters():用来将实例上的 component、directive、filter 对象配置到全局的 Vue.options 上。

三答

问:Vue 中当父子组件配置选项发生冲突时,是如何处理?

答:Vue 混合父子组件配置选项时,采用配置项的 key 值作为标识,若 key 值相等冲突,则子组件的配置选项将覆盖父组件的配置选项

四答

问:初始化后,自定义往 Vue 实例上的响应式对象添加属性,添加的属性是否具有响应式?

答:Vue 响应式是在初始化过程进行双向绑定和发布订阅模式实现的,若在后续自定义手动添加属性,无论是原始数据类型还是复杂数据类型都是不具备响应式的

五答

问:如何自定义数据实现响应式?

答:首先要保证挂载的对象是响应式的,也就是有 target.\_\_ob__ 的标识符才能实现响应式,否则只能一种普通对象的静态挂载。
我们可以使用 vm.$set() 来实现自定义数据的响应式,如对象:vm.$set(obj, key, val),数组:vm.$set(array, idx, val)。

六答

问:vm.$set()vm.$delete() 方法,分别如何操作对象和数组?

答:

  • vm.$set()
    • 操作对象使用的是 defineReactive(ob.value, key, val) 方法,原理是 Object.definePrototype() 来拦截,并调用 ob.dep.notify() 通知该对象已完成操作。
    • 操作数组使用的是遍历数组,对指定下标使用 target.splice(key, 1, val),实现响应式。
  • vm.$delete()
    • 操作对象使用操作符 delete,并调用 ob.dep.notify() 通知该对象已完成操作。
    • 操作数组的方法与 vm.$set() 一致,指定下标使用 target.splice(key, 1, val) 截取删除。

Vue patch 渲染更新

位置:/src/core/instance/lifecycle.js

我根据打断点,来明确一下初始化/更新时 patch 调用的顺序逻辑

初始化调用:this._init(options) => vm.$mount(vm.$options.el) => mountComponent(this, el, hydrating) => new Watcher() => watcher.get() => updateComponent() => vm._update(vm._render(), hydrating) => vm.__patch__(vm.$el, vnode, hydrating, false)

image.png

更新时调用:observe.set() => dep.notify() => watcher.update() => nextTick() => watcher.run() => watcher.get() => updateComponent() => vm._update(vm._render(), hydrating) => vm.__patch__(prevVnode, vnode)

image.png

入口

// patch 渲染更新的入口
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
  const vm: Component = this
  const prevEl = vm.$el
  
  // vm._vnode 由 vm._render() 生成
  // 老虚拟节点
  const prevVnode = vm._vnode
  const restoreActiveInstance = setActiveInstance(vm)
  // 新虚拟节点
  vm._vnode = vnode
  // Vue.prototype.__patch__ is injected in entry points
  // based on the rendering backend used.
  
  if (!prevVnode) {
    // 只有新虚拟节点,即为首次渲染,初始化页面时走这里
    vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
  } else {
    // 有新老节点,即为更新数据渲染,更新页面时走这里
    vm.$el = vm.__patch__(prevVnode, vnode)
  }
  
  // 缓存虚拟节点
  restoreActiveInstance()
  
  // update __vue__ reference
  if (prevEl) {
    prevEl.__vue__ = null
  }
  if (vm.$el) {
    vm.$el.__vue__ = vm
  }
  // if parent is an HOC, update its $el as well
  // 当父子节点的虚拟节点一致,也更新父节点的 $el
  if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
    vm.$parent.$el = vm.$el
  }
  // updated hook is called by the scheduler to ensure that children are
  // updated in a parent's updated hook.
}

复制代码

核心代码

源码核心代码顺序以深度遍历形式

patch()

位置:/src/core/observer/index.js

// patch 方法,hydrating 是否服务端渲染,removeOnly 是否使用了 <transition group> 过渡组
// 1.vnode 不存在,则摧毁 oldVnode
// 2.vnode 存在且 oldVnode 不存在,表示组件初次渲染,添加标示且创建根节点
// 3.vnode 和 oldVnode 都存在时
// 3.1.oldVnode 不是真实节点表示更新阶段(都是虚拟节点),执行 patchVnode,生成 vnode
// 3.2.oldVnode 是真实元素,表示初始化渲染,执行 createElm 基于 vnode 创建整棵 DOM 树并插入到 body 元素下,递归更新父占位符节点元素,完成更新后移除 oldnode。
// 4.最后 vnode 插入队列并生成返回 vnode
function patch(oldVnode, vnode, hydrating, removeOnly) {
  // vnode 不存在,表示删除节点,则摧毁 oldVnode
  if (isUndef(vnode)) {
    // 执行 oldVnode 也就是未更新组件生命周期 destroy 钩子
    // 执行 oldVnode 各个模块(style、class、directive 等)的 destroy 方法
		// 如果有 children 递归调用 invokeDestroyHook
    if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
    return
  }

  let isInitialPatch = false
  const insertedVnodeQueue = []

  // vnode 存在且 oldVnode 不存在
  if (isUndef(oldVnode)) {
    // empty mount (likely as component), create new root element
    // 组件初次渲染,创建根节点
    isInitialPatch = true
    createElm(vnode, insertedVnodeQueue)
  } else {
    // 判断 oldVnode 是否为真实元素
    const isRealElement = isDef(oldVnode.nodeType)
    // 不是真实元素且 oldVnode 和 vnode 是同一个节点,执行 patchVnode 直接更新节点
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
      patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
      
    // 真实元素或者新老节点不相同
    } else {
      if (isRealElement) {
        // mounting to a real element
        // check if this is server-rendered content and if we can perform
        // a successful hydration.
        // oldVnode 是元素节点且有服务器渲染的属性
        if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
          oldVnode.removeAttribute(SSR_ATTR)
          hydrating = true
        }
        // ...省略代码,服务端渲染执行 invokeInsertHook(vnode, insertedVnodeQueue, true)
        
        // either not server-rendered, or hydration failed.
        // create an empty node and replace it
        
        // 不是服务端渲染,或 hydration 失败,创建一个空的 vnode 节点
        oldVnode = emptyNodeAt(oldVnode)
      }

      // 拿到 oldVnode /父 oldVnode 的真实元素
      const oldElm = oldVnode.elm
      const parentElm = nodeOps.parentNode(oldElm)

      // 基于 vnode 创建整棵 DOM 树并插入到 body 元素下
      createElm(
        vnode,
        insertedVnodeQueue,
        // extremely rare edge case: do not insert if old element is in a
        // leaving transition. Only happens when combining transition +
        // keep-alive + HOCs. (#4590)
        oldElm._leaveCb ? null : parentElm,
        nodeOps.nextSibling(oldElm)
      )

      // 递归更新父占位符节点元素
      if (isDef(vnode.parent)) {
        let ancestor = vnode.parent
        const patchable = isPatchable(vnode)
        while (ancestor) {
          for (let i = 0; i < cbs.destroy.length; ++i) {
            cbs.destroy[i](ancestor)
          }
          ancestor.elm = vnode.elm
          if (patchable) {
            for (let i = 0; i < cbs.create.length; ++i) {
              cbs.create[i](emptyNode, ancestor)
            }
            // #6513
            // invoke insert hooks that may have been merged by create hooks.
            // e.g. for directives that uses the "inserted" hook.
            const insert = ancestor.data.hook.insert
            if (insert.merged) {
              // start at index 1 to avoid re-invoking component mounted hook
              for (let i = 1; i < insert.fns.length; i++) {
                insert.fns[i]()
              }
            }
          } else {
            registerRef(ancestor)
          }
          ancestor = ancestor.parent
        }
      }

      // 完成更新,移除 oldVnode
      // 当有父节点时,指定范围删除自己
      if (isDef(parentElm)) {
        removeVnodes([oldVnode], 0, 0)
        
      // 没有父节点时
      } else if (isDef(oldVnode.tag)) {
        invokeDestroyHook(oldVnode)
      }
    }
  }

  // 将虚拟节点插入队列中
  invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
  return vnode.elm
}


复制代码

createElm

位置:src/core/vdom/patch.js

// 基于 vnode 创建真实 DOM 树
function createElm(
  vnode,
  insertedVnodeQueue,
  parentElm,
  refElm,
  nested,
  ownerArray,
  index
) {
  // 直接复制缓存的 vnode
  if (isDef(vnode.elm) && isDef(ownerArray)) {
    vnode = ownerArray[index] = cloneVNode(vnode)
  }
  vnode.isRootInsert = !nested // for transition enter check

  // 创建 vnode 组件
  if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
    return
  }

  // 获取 data 对象
  const data = vnode.data
  // 所有的孩子节点
  const children = vnode.children
  const tag = vnode.tag
  if (isDef(tag)) {
    // ...省略代码:当标签未知时发出警告

    // 创建新节点
    vnode.elm = vnode.ns
      ? nodeOps.createElementNS(vnode.ns, tag)
      : nodeOps.createElement(tag, vnode)
    setScope(vnode)

    // 递归创建所有子节点(普通元素、组件)
    createChildren(vnode, children, insertedVnodeQueue)
    if (isDef(data)) {
      invokeCreateHooks(vnode, insertedVnodeQueue)
    }
    
    // 将节点插入父节点
    insert(parentElm, vnode.elm, refElm)

    if (process.env.NODE_ENV !== 'production' && data && data.pre) {
      creatingElmInVPre--
    }
    // 处理注释节点并插入父节点
  } else if (isTrue(vnode.isComment)) {
    vnode.elm = nodeOps.createComment(vnode.text)
    insert(parentElm, vnode.elm, refElm)
    // 处理文本节点并插入父节点
  } else {
    vnode.elm = nodeOps.createTextNode(vnode.text)
    insert(parentElm, vnode.elm, refElm)
  }
}

复制代码

patchVnode

位置:/src/core/vdom/patch.js

// 更新节点
// 1.新老节点相同,直接返回
// 2.静态节点,克隆复用
// 3.全部遍历更新 vnode.data 上的属性
// 4.若是文本节点,直接更新文本
// 5.若不是文本节点
// 5.1 都有孩子,则递归执行 updateChildren 方法(diff 算法更新)
// 5.2 ch 有 oldCh 没有,则表明新增节点 addVnodes
// 5.3 ch 没有 oldCh 有,则表明删除节点 removeVnodes
function patchVnode(
  oldVnode,
  vnode,
  insertedVnodeQueue,
  ownerArray,
  index,
  removeOnly
) {
  // 老节点和新节点相同,直接返回
  if (oldVnode === vnode) {
    return
  }

  // 缓存过的 vnode,直接克隆 vnode
  if (isDef(vnode.elm) && isDef(ownerArray)) {
    // clone reused vnode
    vnode = ownerArray[index] = cloneVNode(vnode)
  }

  const elm = vnode.elm = oldVnode.elm

  // 异步占位符节点
  if (isTrue(oldVnode.isAsyncPlaceholder)) {
    if (isDef(vnode.asyncFactory.resolved)) {
      hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
    } else {
      vnode.isAsyncPlaceholder = true
    }
    return
  }

  // reuse element for static trees.
  // note we only do this if the vnode is cloned -
  // if the new node is not cloned it means the render functions have been
  // reset by the hot-reload-api and we need to do a proper re-render.
  if (isTrue(vnode.isStatic) &&
    isTrue(oldVnode.isStatic) &&
    vnode.key === oldVnode.key &&
    (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
  ) {
    // 新旧节点都是静态的而且两个节点的 key 一样,并且新节点被克隆了或者新节点有 v-once 指令,则用 oldVnode 的组件节点,且跳出,不进行 diff 更新
    vnode.componentInstance = oldVnode.componentInstance
    return
  }

  // 执行组件的 prepatch 钩子
  let i
  const data = vnode.data
  if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
    i(oldVnode, vnode)
  }

  // 孩子
  const oldCh = oldVnode.children
  const ch = vnode.children
  
  // 更新 vnode 上的属性
  if (isDef(data) && isPatchable(vnode)) {
    // 全部遍历更新(Vue3 做了大量优化)
    for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
    if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
  }
  // 新节点不是文本节点
  if (isUndef(vnode.text)) {
    if (isDef(oldCh) && isDef(ch)) {
      // 如果 oldCh 和 ch 不同,开始更新子节点(也就是 diff 算法)
      if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
      
    // 只有 ch
    } else if (isDef(ch)) {
      if (process.env.NODE_ENV !== 'production') {
        // 检查是否有重复 key 值,给予警告
        checkDuplicateKeys(ch)
      }
      // oldVnode 中有文本信息,创建文本节点并添加
      if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
      
    // 只有 oldCh
    } else if (isDef(oldCh)) {
      // 删除节点的操作
      removeVnodes(oldCh, 0, oldCh.length - 1)
      // oldVnode 上有文本
    } else if (isDef(oldVnode.text)) {
      // 置空文本
      nodeOps.setTextContent(elm, '')
    }
  
  // vnode 是文本,若 oldVnode 和 vnode 文本不相同
  } else if (oldVnode.text !== vnode.text) {
    // 更新文本节点
    nodeOps.setTextContent(elm, vnode.text)
  }
    
  // 还有 data 数据,执行组件的 prepatch 钩子
  if (isDef(data)) {
    if (isDef(i = data.hook) && isDef(i = i.postpatch)) 
      i(oldVnode, vnode)
  }
}

复制代码

removeVnodes

位置:/src/core/vdom/patch.js

// 删除 vnode 节点
function removeVnodes(vnodes, startIdx, endIdx) {
  for (; startIdx <= endIdx; ++startIdx) {
    const ch = vnodes[startIdx]
    // 有子节点
    if (isDef(ch)) {
      // 不是文本节点
      if (isDef(ch.tag)) {
        // patch() 方法中有说明
        removeAndInvokeRemoveHook(ch)
        invokeDestroyHook(ch)
      } else { // Text node
        // 直接移除该元素
        removeNode(ch.elm)
      }
    }
  }
}


复制代码

updateChildren

src/core/vdom/patch.js

// 更新子节点采用了 diff 算法
// 做了四种假设,假设新老节点开头结尾有相同节点的情况,一旦命中假设,就避免了一次循环,以提高执行效率
// 如果不幸没有命中假设,则执行遍历,从老节点中找到新开始节点
// 找到相同节点,则执行 patchVnode,然后将老节点移动到正确的位置
// 如果老节点先于新节点遍历结束,则剩余的新节点执行新增节点操作
// 如果新节点先于老节点遍历结束,则剩余的老节点执行删除操作,移除这些老节点
function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
  // 为 diff 算法假设做初始化:新老子节点的头尾下标和对应值
  let oldStartIdx = 0
  let newStartIdx = 0
  let oldEndIdx = oldCh.length - 1
  let oldStartVnode = oldCh[0]
  let oldEndVnode = oldCh[oldEndIdx]
  let newEndIdx = newCh.length - 1
  let newStartVnode = newCh[0]
  let newEndVnode = newCh[newEndIdx]
  let oldKeyToIdx, idxInOld, vnodeToMove, refElm

  // <transition-group> 的标识符
  const canMove = !removeOnly

  if (process.env.NODE_ENV !== 'production') {
    // 若重复 key 则发出警告
    checkDuplicateKeys(newCh)
  }

  // 遍历新老节点数组,直到一方取完值
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    
    // 老开始节点无值,表示更新过,向右移动下标(往后看)
    if (isUndef(oldStartVnode)) {
      oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
    // 老结束节点无值,表示更新过,向左移动下标(往后看)
    } else if (isUndef(oldEndVnode)) {
      oldEndVnode = oldCh[--oldEndIdx]
      
    // 新老的开始/结束节点是相同节点,返回 patchVnode 阶段,不更新比较
    // 因为两个都不比较,同时移动下标
    } else if (sameVnode(oldStartVnode, newStartVnode)) {
      patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
      
    // 新尾和老头/新头和老尾相等
    // 一样需要移动下标,进行 ch 数组下个节点的判断
    } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
      patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
      // <transtion-group> 包裹的组件时使用,如轮播图情况。
      canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
      oldStartVnode = oldCh[++oldStartIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
      patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
      canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
      oldEndVnode = oldCh[--oldEndIdx]
      newStartVnode = newCh[++newStartIdx]
      
    // 四种常规 web 操作假设都不成立,则不能优化,开始遍历更新
    } else {
      // 当老节点的 key 对应不上 idx 时
      // 在指定 idx 的范围内,找到 key 在老节点中的下标位置
      // 形成 map = { key1: id1, key2: id2, ...}
      if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
      
      // 若新开始节点有 key 值,在老节点的 key 和 id 映射表 map 中找到返回对应的 id 下标值
      // 若新开始节点没有 key 值,则找到老节点数组中新开始节点的值,返回 id 下标
      idxInOld = isDef(newStartVnode.key)
        ? oldKeyToIdx[newStartVnode.key]
        : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
      
      // 若新开始节点不存在老节点中,那就是新建元素
      if (isUndef(idxInOld)) { // New element
        createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        
      // 新开始节点存在老节点中,开始判断情况更新
      } else {
        vnodeToMove = oldCh[idxInOld]
        
        // 如果两个节点不但 key 相同,节点也是相同,则直接返回 patchVnode
        if (sameVnode(vnodeToMove, newStartVnode)) {
          patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
          // 将该老节点置为 空,避免新节点反复找到同一个节点
          oldCh[idxInOld] = undefined
          // 还是判断 <transition-group> 标签的情况
          canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
        } else {
          // 两个节点虽然 key 相等,但节点不相等,看作新元素,创建节点
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        }
      }
      // 老节点向后移动一个
      newStartVnode = newCh[++newStartIdx]
    }
  }
  
  // 新老节点某个数组被遍历完了
  // 新的有多余,那就是新增
  if (oldStartIdx > oldEndIdx) {
    refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
    addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
 	// 老的有多余,那就是删除
  } else if (newStartIdx > newEndIdx) {
    removeVnodes(oldCh, oldStartIdx, oldEndIdx)
  }
}

复制代码

致命七问

Vue 源码「patch」致命七问。

  1. Vue 初始化阶段和更新阶段,是如何进入 patch 阶段。(或 Vue 初始化和更新阶段分别发生什么等相关问题)
  2. Vue patch 阶段做了什么?
  3. 你知道 patch 方法有几个参数?最后两个参数分别有什么作用?
  4. diff 算法是什么?起到什么作用?
  5. 若节点 key 值相等且节点不同,新节点会覆盖旧节点吗?
  6. vnode 是什么?有什么用?
  7. Vue 如何处理 Vnode 上的属性?

思考问题后,答案在下方,根据自己阅读整理源码,对自己提出有意义的问题并自我回答。不确保是面试热点题噢(切勿入题太深)

致命七答

一答

问:Vue 初始化阶段和更新阶段,是如何进入 patch 阶段。(或 Vue 初始化和更新阶段分别发生什么等相关问题)

答:

  • Vue 初始化分为以下几个阶段
    1. 初始化时执行 Vue._init(),初始化组件的各种属性和事件并触发 beforeCreate 钩子函数,之后初始化响应式数据并最后触发 created 钩子函数
    2. 执行 vm.$mount(),调用 mountComponent(),初始化 render 函数和组件的框架调用 beforeMount 钩子函数,初始化 dep.target。
    3. 创建当前组件的 Watcher 实例,执行 watcher.get() 方法获取当前 watcher 上的数据。
    4. 执行 updateComponent() 回调来执行 vm.update() 方法,因初始化渲染,故直接调用 vm.__patch__ 创建空元素。生成 vnode 虚拟节点。
    5. 执行 proxy 对数据进行响应式处理,执行 dep.depend() 收集对应响应式数据上所有 watcher 的依赖,watcher 也收集 dep 的依赖实现双向绑定。
    6. 开始调用 render 渲染函数(关键是 _createElement())根据 vnode 递归遍历实现整个真实页面。
  • Vue 更新分为以下几个阶段
    1. 当数据更新时,进入数据对应的监听者 observe.set() 方法中调用 dep.notify() 发布通知所有 watcher 执行 update() 方法。
    2. 接下来就是异步更新内容,封装各种 watcher 队列和刷新函数队列,进入 nextTick() 中执行 timerFunc() 利用浏览器异步任务队列来实现异步更新。
    3. 等到浏览器异步任务队列开始执行 flushCallbacks(),便调用 callbacks 中每个 flushSchedulerQueue() 执行回调 watcher.run()
    4. watcher 通过 get() 调用 updateComponent() 中的 vm.__patch__(prevVnode, vnode) 开始进入递归遍历节点的 patch 阶段。
    5. patch 阶段通过判断新老子节点的情况,调用 updateChildren() 开始 diff 算法假设和优化,最终形成 vnode 虚拟节点。
    6. 开始调用 render 渲染函数,根据 vnode 递归遍历实现整个真实页面。

二答

问:Vue patch 阶段做了什么?

答:patch 阶段主要进行了四点内容。

  1. vnode 不存在,则摧毁 oldVnode
  2. vnode 存在且 oldVnode 不存在,表示组件初次渲染,添加标示且创建根节点
  3. vnode 和 oldVnode 都存在时
    1. oldVnode 不是真实节点表示更新阶段(都是虚拟节点),执行 patchVnode,生成 vnode
    2. oldVnode 是真实元素,表示初始化渲染,执行 createElm 基于 vnode 创建整棵 DOM 树并插入到 body 元素下,递归更新父占位符节点元素,完成更新后移除 oldnode。
  4. 最后 vnode 插入队列并生成返回 vnode。

三答

问:你知道 patch 方法有几个参数?最后两个参数分别有什么作用?

答:patch(oldVnode, vnode, hydrating, removeOnly),patch 方法共有四个参数,最后两个参数为 hydratingremoveOnly。它们的作用分别为:

  1. hydrating 判断是否服务器渲染执行。在 patch 阶段时,oldVnode 是真实元素,初始化渲染时,若 oldVnode 是元素节点且有服务器渲染的属性,则设置 hydrating 为 true,表示服务端渲染。
  2. removeOnly 判断节点是否被 <transition-group> 包裹着。在 updateChildren 中判断插入执行 nodeOps.insertBefore(),如轮播图等案例。

四答

问:diff 算法是什么?起到什么作用?

答:diff 算法是在 patch 阶段,遍历比较更新子节点时,利用 web 常规操作的思维做的四种假设,一旦命中假设,就避免了循环,以提高执行效率,起到绝大部分更新情况的优化效果

  • 四种假设分别为:
    1. 老开始和新开始节点相同
    2. 老结束和新结束节点相同
    3. 老开始和新结束节点相同
    4. 老结束和新开始节点相同

当 diff 算法阶段都未命中假设时,则利用 key 值映射 oldVnode 的下标值生成 map 对象,以此来利用 key 值快速找到新节点在旧节点中的下标位置,进行判断比对,若没有 key 值,则只能利用新节点的值暴力遍历比较旧节点的值进行判断更新。
最后新老数组中某一数组遍历完成,则进行添加或删除节点操作。

五答

问:若节点 key 值相等且节点不同,新节点会覆盖旧节点吗?

答:在 diff 算法阶段,当新节点找到在老节点相同 key 且节点不同时,会看作是创建新节点执行 createElm()

六答

问:vnode 是什么?有什么用?

答:vnode 是利用 JS 对象模拟真实 DOM 树,抽象了渲染的过程,形成一个 JS 对象。作用如下:

  1. 减少对真实DOM的操作,大大减轻了浏览器的负担。
  2. 因 JavaScript 本质是弱语言跨平台的性质,故虚拟 DOM 可以跨平台使用。
  3. 虚拟 DOM 可以快速对比两次状态的差异以便更新真实 DOM。

七答

问:Vue 如何处理 vnode 上的属性?

答:在 patchVnode 方法中,直接遍历更新 vnode 上的全部属性。Vue3 将进行大量优化更新。

最后

最后放一个 Vnode 的类,位置:/src/core/vdom/vnode.js

class VNode {
  tag: string | void;
  data: VNodeData | void;
  children: ?Array<VNode>;
  text: string | void;
  elm: Node | void;
  ns: string | void;
  context: Component | void; // rendered in this component's scope
  key: string | number | void;
  componentOptions: VNodeComponentOptions | void;
  componentInstance: Component | void; // component instance
  parent: VNode | void; // component placeholder node

  // strictly internal
  raw: boolean; // contains raw HTML? (server only)
  isStatic: boolean; // hoisted static node
  isRootInsert: boolean; // necessary for enter transition check
  isComment: boolean; // empty comment placeholder?
  isCloned: boolean; // is a cloned node?
  isOnce: boolean; // is a v-once node?
  asyncFactory: Function | void; // async component factory function
  asyncMeta: Object | void;
  isAsyncPlaceholder: boolean;
  ssrContext: Object | void;
  fnContext: Component | void; // real context vm for functional nodes
  fnOptions: ?ComponentOptions; // for SSR caching
  devtoolsMeta: ?Object; // used to store functional render context for devtools
  fnScopeId: ?string; // functional scope id support

  constructor (
    tag?: string,
    data?: VNodeData,
    children?: ?Array<VNode>,
    text?: string,
    elm?: Node,
    context?: Component,
    componentOptions?: VNodeComponentOptions,
    asyncFactory?: Function
  ) {
    this.tag = tag
    this.data = data
    this.children = children
    this.text = text
    this.elm = elm
    this.ns = undefined
    this.context = context
    this.fnContext = undefined
    this.fnOptions = undefined
    this.fnScopeId = undefined
    this.key = data && data.key
    this.componentOptions = componentOptions
    this.componentInstance = undefined
    this.parent = undefined
    this.raw = false
    this.isStatic = false
    this.isRootInsert = true
    this.isComment = false
    this.isCloned = false
    this.isOnce = false
    this.asyncFactory = asyncFactory
    this.asyncMeta = undefined
    this.isAsyncPlaceholder = false
  }

  // DEPRECATED: alias for componentInstance for backwards compat.
  /* istanbul ignore next */
  get child (): Component | void {
    return this.componentInstance
  }
}
复制代码

思维导图

记录我学习源码过程(结尾有我学习源码的链接)做的思维导图

  • Vue源码(1) - 前言

Vue源码(1) - 前言.png

  • Vue源码(2)-初始化

Vue源码(2)-Vue初始化过程.png

  • Vue源码(3)-响应式原理

Vue源码(3)-响应式原理.png

  • Vue源码(4)-异步更新

Vue源码(4)-异步更新.png

  • Vue源码(5)-全局API

Vue源码(5)-全局API.png

  • Vue源码(6)-实例方法

Vue源码(6)-实例方法.png

  • Vue源码(7)-Hook Event

Vue源码(7)-Hook Event.png

  • Vue源码(8)-编译器(解析)

Vue源码(8)-编译器(解析).png

  • Vue源码(9)-编译器之优化

Vue源码(9)-编译器之优化.png

  • Vue源码(10)—编译器之生成渲染函数

Vue源码(10)—编译器之生成渲染函数.png

  • Vue源码(11)-render helper

Vue源码(11)-render helper.png

  • Vue源码(12)-patch

Vue源码(12)-patch.png

结尾

感谢下列参考文章,以及我学习源码系列教程
参考文章:
www.jianshu.com/p/624c17c0e…

juejin.cn/post/684490…

juejin.cn/post/684490…

学习源码:

juejin.cn/column/6960…

space.bilibili.com/359669053/c…

最后希望这篇源码总结对小伙伴们有所帮助噢!若有纰漏或瑕疵,麻烦指教一二!

🌟 点赞关注(暗示)

文章分类
前端
文章标签