跳转到内容

阅读框架源码方法

刀刀

4/8/2025

0 字

0 分钟

源码阅读

打包入口文件

首先前往 Vue2 的 github 下载源码项目,安装依赖。

然后从代码的目录结构入手,先分析每一个文件夹的功能以及包含的内容:

  • bechmarks :性能测试

  • dist :最终打包出的结果

  • examples :官方例子 demo

  • flow :类型检测,Vue2 当初没采用 TS,而是采用这个微软出品的类型检测,不过目前没人使用了

  • packages :一些写好的包(Vue 源码中包含 weex

  • scripts :所有打包的脚本文件

  • src :源代码目录

    一般情况下我们的代码都是放在 src 文件夹下,因此它是重点文件夹。打开 src 文件夹,分析其下的每个文件夹的功用:

    • compiler :专门用作模板编译
    • platform
    • server :服务端渲染相关
    • sfc :解析单文件组件,如样式 style 、模板 template 、脚本 script 等(要结合 vue-loader 使用)
    • shared :模板之间的共享属性和方法

找到 package.json 文件,查看命令找到打包的入口,在 srcipts 文件夹下的 config.js 文件。该文件最后的代码如下:

js
if(process.env.TARGET) {
    module.exports = genConfig(process.env.TARGET)
} else {
    exports.getBuild = getConfig
    exports.getAllBuilds = () => Object.keys(build).map(genConfig)
}

从代码可以看成它做了一个判断,是否有打包目标 TARGET ,如果有直接使用,没有才通过遍历对象寻找。

查看运行的命令,有两种:运行时如果无法解析 new Vue 传入的 template ,则会走 web-runtime ;否则会执行 web-fullweb-runtime + 模板解析)。

它调用了 genConfig 方法,该方法会去 builds 对象中找到对象打包目标对象中找到入口文件,部分源码如下:

js
const builds = {
  // runtime-only build (Browser)
  'runtime-dev': {
    entry: resolve('web/entry-runtime.ts'),
    dest: resolve('dist/vue.runtime.js'),
    format: 'umd',
    env: 'development',
    banner
  },
  // Runtime+compiler development build (Browser)
  'full-dev': {
    entry: resolve('web/entry-runtime-with-compiler.ts'),
    dest: resolve('dist/vue.js'),
    format: 'umd',
    env: 'development',
    alias: { he: './entity-decoder' },
    banner
  },
}

其中 entry 是打包的文件文件路径,在 web 文件夹下的相应文件。但是 scripts 以及外部都没有这个 web 文件夹,因此还需要继续寻找。

调用了 resolve 函数,把路径传了过去,源码如下:

js
const aliases = require('./alias')
const resolve = p => {
  const base = p.split('/')[0]
  if (aliases[base]) {
    return path.resolve(aliases[base], p.slice(base.length + 1))
  } else {
    return path.resolve(__dirname, '../', p)
  }
}

它拿到路径后和 aliases 做了拼接处理,因此根据路径查看 aliases.js 的代码,如下:

js
module.exports = {
  vue: resolve('src/platforms/web/entry-runtime-with-compiler'),
  compiler: resolve('src/compiler'),
  core: resolve('src/core'),
  shared: resolve('src/shared'),
  web: resolve('src/platforms/web'),
  server: resolve('packages/server-renderer/src'),
  sfc: resolve('packages/compiler-sfc/src')
}

可以看到其 web 实际上是 src/platforms/web 路径,现在能够通过拼接,找到对应的打包文件的入口路径了。

查看源码,发现它们都使用了 runtime

js
import Vue from './runtime/index'

entry-runtime-with-compiler 的区别是多实现了一个 compile API,把 template 转化成 render 函数。

下面先来看看这个 compile API 做了什么处理。源代码如下所示:

js
// 函数劫持,将原来的mount函数获取到后重写mount函数
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  // 获取el元素
  el = el && query(el)

  /* istanbul ignore if */
  // 挂载的流程是用一个新生成的dom替换掉老的dom元素
  if (el === document.body || el === document.documentElement) {
    __DEV__ &&
      warn(
        `Do not mount Vue to <html> or <body> - mount to normal elements instead.`
      )
    return this
  }

  const options = this.$options
  // resolve template/el and convert to render function
  if (!options.render) { // 如果有render函数则直接使用用户的render函数
    let template = options.template
    if (template) { // 如果没有render,查看是否有模板
      if (typeof template === 'string') {
        if (template.charAt(0) === '#') {
          // 考虑到 {template: '#template'} 的情况
          template = idToTemplate(template)
          /* istanbul ignore if */
          if (__DEV__ && !template) {
            warn(
              `Template element not found or is empty: ${options.template}`,
              this
            )
          }
        }
      } else if (template.nodeType) {
        // 如果给的模板是一个dom元素,则拿到模板中的内容
        template = template.innerHTML
      } else {
        if (__DEV__) {
          warn('invalid template option:' + template, this)
        }
        return this
      }
    } else if (el) {
      // @ts-expect-error
      // 如果没有模板,则使用el对应的template
      template = getOuterHTML(el)
    }
    if (template) {
      /* istanbul ignore if */
      if (__DEV__ && config.performance && mark) {
        mark('compile')
      }

      // 直接将模板变成render函数
      const { render, staticRenderFns } = compileToFunctions(
        template,
        {
          outputSourceRange: __DEV__,
          shouldDecodeNewlines,
          shouldDecodeNewlinesForHref,
          delimiters: options.delimiters,
          comments: options.comments
        },
        this
      )
      options.render = render
      options.staticRenderFns = staticRenderFns

      /* istanbul ignore if */
      if (__DEV__ && config.performance && mark) {
        mark('compile end')
        measure(`vue ${this._name} compile`, 'compile', 'compile end')
      }
    }
  }
  // 调用了挂载
  return mount.call(this, el, hydrating)
}

查看其源码不难发现,他只做了一件事情,就是重写了 $mount 函数,将 template 变成 render 函数。这就是 entry-runtime-width-compilerentry-runtime 命令多出来的区别。

接着我们再往上面看,看一下双方都有的 runtime/index 文件内的方法做了什么处理。它是运行时,所谓运行时,就是提供一些 DOM 操作的 API ,比如属性操作、元素操作等,还有一些指令和组件。

其中,部分源码如下所示:

js
// install platform runtime directives & components
extend(Vue.options.directives, platformDirectives) // 添加平台对应的指令和组件
extend(Vue.options.components, platformComponents)

// install platform patch function
Vue.prototype.__patch__ = inBrowser ? patch : noop // 渲染时会调用的方法,更新时会调用的方法。noop是一个空函数,如果在服务端渲染,则不需要调用方法,因此给一个空函数即可

// public mount method
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined

  // 组件的挂载
  return mountComponent(this, el, hydrating)
}

主要是为原型上安装一个 __patch__ 的方法,判断当前的渲染环境是否在服务器端,如果在服务器端,则执行空函数 noop

在原型上挂载一个 $mount 函数,最终 return 导出一个组件的挂载的方法。

但是这还不是入口文件,往上滑发现它从 core/index 文件导入方法,因此前往查看它做了什么处理。

打开 core/index.js 文件,发现它依旧不是入口文件,做了一个导入导出。该文件增加了两个标识:是否是服务端渲染、服务端渲染的上下文,并且初始化 Vue 的全局 API 。部分源码如下:

js
initGlobalAPI(Vue)

Object.defineProperty(Vue.prototype, '$isServer', {
  get: isServerRendering
})

Object.defineProperty(Vue.prototype, '$ssrContext', {
  get() {
    /* istanbul ignore next */
    return this.$vnode && this.$vnode.ssrContext
  }
})

根据文件路径继续网上找,查看 core/instance/index 文件,发现他没有外部导入 Vue 方法,而是创建了一个 Vue 的构造函数。其源码如下所示:

js
function Vue(options) {
  if (__DEV__ && !(this instanceof Vue)) {
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  this._init(options)
}

initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)

可以看到其本质上在函数中调用了 _init() 初始化的方法,然后调用了几个函数,在 Vue 的原型 prototype 上挂载方法。作用分别是:

  • initMixin :挂载初始化方法 _init
  • stateMixin :挂载 $set$delete$watch 方法
  • eventsMixin :挂载 $on$off$emit 方法
  • lifecycleMixin :挂载 _update$forceUpdate 强制刷新、$destory 销毁方法、
  • renderMixin :挂载 $nextTick_render 方法

总结出一句就是在扩展原型上的方法。

一图流如下所示:

一图流

里面每一个具体怎么实现?

  1. 如果找了核心流程,可以单独打开源码查看
  2. 如果不清楚流程,可以写一些测试用例和案例来调试。通过为编译命令行添加 --sourcemap 让编译后的源文件可以 debuggr 调试

全局API分析

看看它的全局 API 是怎么实现的。找到 src/core/global-api/index.js 文件,里面有一个 initClobalAPI() 函数。

首先它代理了 config 方法,用于配置信息,源码如下:

js
const configDef: Record<string, any> = {}
configDef.get = () => config
if (__DEV__) {
  configDef.set = () => {
    warn(
      'Do not replace the Vue.config object, set individual fields instead.'
    )
  }
}
Object.defineProperty(Vue, 'config', configDef)

然后配置了 Vue 中的工具方法,如合并 extend 、合并策略 mergeOptions 、定义响应式 defineReactive 等。还有一些常用的方法,如 setdeletenextTick 等。源码如下:

js
Vue.util = {
  warn,
  extend,
  mergeOptions,
  defineReactive
}

Vue.set = set
Vue.delete = del
Vue.nextTick = nextTick

其中,关于 nextTick 可以查看我的博客文档 nextTick

再往下是让一个对象变成响应式的方法,源码如下:

js
Vue.observable = <T>(obj: T): T => {
  observe(obj)
  return obj
}

最后在 Vue 方法上通过 Object.create 设置空对象 options ,并通过循环赋值空对象,并让 _base 一直指向 Vue 构造函数。

使用 extend 方法挂载 keep-alive 方法。

再从其他页面导入对应的方法注册,分别是:

  • Vue.use
  • Vue.mixin
  • Vue.extend
  • Vue.component、Vue.directive、Vue.filter

源码如下:

js
Vue.options = Object.create(null)
ASSET_TYPES.forEach(type => {
  Vue.options[type + 's'] = Object.create(null)
})

// this is used to identify the "base" constructor to extend all plain-object
// components with in Weex's multi-instance scenarios.
Vue.options._base = Vue

extend(Vue.options.components, builtInComponents)

initUse(Vue)
initMixin(Vue)
initExtend(Vue)
initAssetRegisters(Vue)

响应式数组

  1. 响应式数据的理解

    initData 函数中调用 observe 方法回调,对数据作监听。调用 defineReactive 方法劫持代理(内部重写了所有属性)。递归增加对象中的对象增加 getersetter

    如果层级过深,则需要考虑优化(递归代理消耗性能)。如果数据不是响应式的就不要放在 data 中了。在取值的时候要避免多次取值,如果有些对象放到 data 内但不是响应式的,可以考虑 Object.freeze() 来冻结对象。

  2. 检测数组变化

    Vue2 中检测数组变化没有采用 defineProperty ,因为修改索引的情况不多(如果直接采用 defineProperty 会浪费大量性能)。采用重写数组的变异方法来实现(函数劫持)。

    js
    if (isArray(value)) {
      // 如果是数组,则判断它有没有__proto__对应方法,如果有说明已经代理过了,没有则没代理过,通过拷贝为其赋值
      if (!mock) {
        if (hasProto) {
          ;(value as any).__proto__ = arrayMethods
        } else {
          for (let i = 0, l = arrayKeys.length; i < l; i++) {
            const key = arrayKeys[i]
            def(value, key, arrayMethods[key])
          }
        }
      }
      if (!shallow) {
        this.observeArray(value)
      }
    } else {
      const keys = Object.keys(value)
      for (let i = 0; i < keys.length; i++) {
        const key = keys[i]
        defineReactive(value, key, NO_INITIAL_VALUE, undefined, shallow, mock)
      }
    }

    通过阅读源码可以看出其步骤顺序如下:调用 initData 函数,调用 observe 回调,对传入的数组进行原型链修改,后续调用的方法都是重写后的方法,对数组中每个对象也再次代理。

依赖收集

所谓的依赖收集,实际上是观察者模式。被观察者指代的是数据(即观察数据的变化);观察者是 watcher ,里面可能有渲染逻辑、计算属性、用户 watcher

观察者要观察数据,则需要通过 dep 先收集数据。一个 watcher 中需要先可能对应多个数据,watcher 中还需要保存 dep 。重写渲染的时候可以让属性重新记录 watcher

depwatcher 是多对多的关系,一个 dep 对应多个 watcher ,一个 watcher 内有多个 dep

默认渲染时会收集依赖,触发 get 方法。数据更新了就找到属性对应的 watcher 去触发更新。数据更新了就找到对应的 watcher 触发更新。

如果不触发 $mount ,就不会 render 更新,也就不会收集依赖。

一图流如下:

一图流

一个视图渲染的属性有可能不一样,因此每次都会先清理,然后重新收集依赖。如果不清理,改旧的、现在没用到的属性也会造成视图更新。每次重新渲染都会触发 getter 方法,在这个方法收集依赖。

模板编译

用户传递的是 template 属性,需要将这个 template 编译成 render 函数。

  • 首先转换成 AST 语法树
  • 对语法树进行标记(静态节点)
  • AST语法树生成 render 函数

生命周期

src/core/util/options.js 文件中的 444 行开始,循环父亲和儿子的所有属性,然后调用 mergeField 函数做合并操作。源码如下:

js
const options: ComponentOptions = {} as any
let key
// 循环父亲所有属性
for (key in parent) {
  mergeField(key)
}
// 循环儿子所有属性
for (key in child) {
  if (!hasOwn(parent, key)) {
    mergeField(key)
  }
}
// 策略模式
function mergeField(key: any) {
  const strat = strats[key] || defaultStrat
  options[key] = strat(parent[key], child[key], vm, key)
}

而策略模式中默认如果有儿子的属性方法则采用儿子的,没有才使用父亲的属性方法,源码如下:

js
const defaultStrat = function (parentVal: any, childVal: any): any {
  // 以儿子为主
  return childVal === undefined ? parentVal : childVal
}

接下来看看 stratshook 内封装了啥方法,源码如下:

js
export function mergeLifecycleHook(
  parentVal: Array<Function> | null,
  childVal: Function | Array<Function> | null
): Array<Function> | null {
  // 获取父亲和儿子的
  const res = childVal
    ? parentVal
      ? parentVal.concat(childVal) // 都有就合并
      : isArray(childVal) // 否则看儿子是不是数组,是数组直接用,不是则用数组包裹
      ? childVal
      : [childVal]
    : parentVal
  return res ? dedupeHooks(res) : res
}

去到 src/core/instance/lifecycle.js 文件,找到生命周期的钩子回调函数,源码如下:

js
export function callHook(
  vm: Component,
  hook: string,
  args?: any[],
  setContext = true
) {
  // #7573 disable dep collection when invoking lifecycle hooks
  pushTarget()
  const prev = currentInstance
  setContext && setCurrentInstance(vm)
  const handlers = vm.$options[hook]
  const info = `${hook} hook`
  if (handlers) {
    for (let i = 0, j = handlers.length; i < j; i++) {
      invokeWithErrorHandling(handlers[i], vm, args || null, vm, info)
    }
  }
  if (vm._hasHookEvent) {
    vm.$emit('hook:' + hook)
  }
  setContext && setCurrentInstance(prev)
  popTarget()
}

实现原理是利用发布订阅模式,将用户写的钩子维护成一个数组,后续依次调用 callHook 。主要依靠的是 mergeOptions 方法。

为什么有些钩子是先子后父,有些是先父后子?

主要是看组件是如何渲染的。看下方代码:

vue
<div id="app">
<my-button></my-button>
</div>

遇到父组件 #app ,开始渲染父组件,触发其 beforeCreate 钩子函数。然后遇到子组件 my-button ,开始渲染子组件,触发子组件的 beforeCreate 钩子函数。

渲染完毕后触发子组件的 mounted 钩子函数,继续往下,渲染父组件,渲染完毕后触发父组件的 mounted

题外话

一般在哪里发送请求?

beforeCreate 生命周期的数据还未实现响应式,从源码下手证明这一点。

src/core/instance/init.js 文件的 59行左右,函数调用顺序如下:

js
initLifecycle(vm)
initEvents(vm)
initRender(vm)
callHook(vm, 'beforeCreate', undefined, false /* setContext */)
initInjections(vm) // resolve injections before data/props
initState(vm)
initProvide(vm) // resolve provide after data/props
callHook(vm, 'created')

首先调用 initLifecycle 函数,该方法源码如下:

js
export function initLifecycle(vm: Component) {
  const options = vm.$options

  // locate first non-abstract parent
  let parent = options.parent
  if (parent && !options.abstract) {
    while (parent.$options.abstract && parent.$parent) {
      parent = parent.$parent
    }
    parent.$children.push(vm)
  }

  vm.$parent = parent
  vm.$root = parent ? parent.$root : vm

  vm.$children = []
  vm.$refs = {}

  vm._provided = parent ? parent._provided : Object.create(null)
  vm._watcher = null
  vm._inactive = null
  vm._directInactive = false
  vm._isMounted = false
  vm._isDestroyed = false
  vm._isBeingDestroyed = false
}

总结下来就是处理组件父子关系,如 $parent$children 等。

然后调用了 initEvents 方法函数,该方法主要初始化一些全局方法,如 $on$off$emit 等。源码如下所示:

js
export function eventsMixin(Vue: typeof Component) {
  const hookRE = /^hook:/
  Vue.prototype.$on = function() {}
  Vue.prototype.$once = function() {}
  Vue.prototype.$off = function() {}
  Vue.prototype.$emit = function() {}
}

然后调用了 beforeCreate 生命周期钩子函数,该函数无法获取数据,作用不大,因此 Vue3 把它剔除了。

然后调用了 initInjections 方法,该方法主要是注册初始化 resolveinjectionsbeforedata/propsinject 等方法。紧接着调用 initState 函数方法,此时劫持数据并做响应式处理。执行完毕后调用 initProvide 函数,主要实现 provide 方法。

再然后才调用 created 生命函数,此时拿到的是响应式的属性(不涉及 DOM 渲染),这个 API 可以在服务端渲染中使用。在 Vue3 中改为 setup

然后代码执行到 src/core/instance/lifecycle.js 文件中的 146 行,触发 beforeMount 生命周期回调。源码如下:

js
  vm.$el = el
  if (!vm.$options.render) {
    // @ts-expect-error invalid type
    vm.$options.render = createEmptyVNode
    if (__DEV__) {
      /* istanbul ignore if */
      if (
        (vm.$options.template && vm.$options.template.charAt(0) !== '#') ||
        vm.$options.el ||
        el
      ) {
        warn(
          'You are using the runtime-only build of Vue where the template ' +
            'compiler is not available. Either pre-compile the templates into ' +
            'render functions, or use the compiler-included build.',
          vm
        )
      } else {
        warn(
          'Failed to mount component: template or render function not defined.',
          vm
        )
      }
    }
  }
  callHook(vm, 'beforeMount')

该方法的意义不大,逻辑可以写到 create 钩子函数内,获取 DOM 元素并操作的方法可以写到 mounted 钩子函数内。

等到 DOM 元素渲染完毕,判断通过,才调用 mounted 回调函数。源码如下:

js
if (vm.$vnode == null) {
  vm._isMounted = true
  callHook(vm, 'mounted')
}

当有数据更新时,就会重新渲染,此时会被 Watcher 监听到,调用 beforeUpdate 钩子函数。源码如下:

js
const watcherOptions: WatcherOptions = {
  before() {
    if (vm._isMounted && !vm._isDestroyed) {
      callHook(vm, 'beforeUpdate')
    }
  }
}

执行 watcher.before() 方法调用更新,源码如下:

js
watcher = queue[index]
if (watcher.before) {
  watcher.before()
}
id = watcher.id
has[id] = null
watcher.run()

组件更新完毕后调用 updated 钩子函数,源码如下:

js
function callUpdatedHooks(queue: Watcher[]) {
  let i = queue.length
  while (i--) {
    const watcher = queue[i]
    const vm = watcher.vm
    if (vm && vm._watcher === watcher && vm._isMounted && !vm._isDestroyed) {
      callHook(vm, 'updated')
    }
  }
}

如果销毁组件,最好在 beforeDestory 钩子函数上移除定时器等,此时数据还是响应式的。清理完之后调用 destoryed 钩子函数,此时数据没有响应式了,但是真实 DOM 还在。源码如下:

js
Vue.prototype.$destroy = function () {
  const vm: Component = this
  if (vm._isBeingDestroyed) {
    return
  }
  // 调用销毁前
  callHook(vm, 'beforeDestroy')
  vm._isBeingDestroyed = true
  // 移除 $children
  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
  // 把最新的虚拟节点标记为null
  vm.__patch__(vm._vnode, null)
  callHook(vm, 'destroyed')
  // 关闭所有事件
  vm.$off()
  if (vm.$el) {
    vm.$el.__vue__ = null
  }
  if (vm.$vnode) {
    vm.$vnode.parent = null
  }
}

从上所述,一般最多在 mounted 上发请求。代码是同步执行的,请求是异步的,都会等待 mounted 之后才获取数据,因此不差 created 那点时间。

服务端渲染不都是在 created 中(服务端没有 DOM,也没有 mounted 钩子)。在哪发请求主要看需要做什么操作。

mixin原理及作用

通过 Vue.mixin 实现逻辑的复用,代码如下:

js
const request = () => {}

Vue.mixin({
    beforeCreate() {
        this.$request = request()
    },
    beforeDestory() {
        // ...
    }
})

new Vue({
    el: '#app',
    components: {
        my: {
            template: '<button @click="handle"></button>',
            methods: {
                handle() {
                    console.log(this.$request)
                }
            }
        }
    }
})

虽然实现了复用,但还是有一点问题:

  1. 数据来源不明确。全局 mixin 每个组件都可调用,项目大时后续的维护会比较诧异变量的来源
  2. 声明的时候可能会导致命名冲突

在 Vue3 中,采用高阶组件的形式,规避这一缺点。

从源码入手分析,在 src/core/global-api/mixin.js 文件内,源码如下所示:

js
export function initMixin(Vue: GlobalAPI) {
  Vue.mixin = function (mixin: Object) {
    this.options = mergeOptions(this.options, mixin)
    return this
  }
}

这里的 this 是谁调用就指向谁,由于该 mixin 方法是 Vue 调用的,因此 this 实际上是 Vue。最终将 mixin 对象和 Vue.options 合并在一起。

查看 mergeOptions 函数的源码,如下所示:

js
export function mergeOptions(
  parent: Record<string, any>,
  child: Record<string, any>,
  vm?: Component | null
): ComponentOptions {
  if (__DEV__) {
    checkComponents(child)
  }

  // 合并的是一个组件的构造函数
  if (isFunction(child)) {
    child = child.options
  }

  normalizeProps(child, vm)
  normalizeInject(child, vm)
  normalizeDirectives(child)

  if (!child._base) {
    // 针对组件的 extends 属性来进行合并
    if (child.extends) {
      parent = mergeOptions(parent, child.extends, vm)
    }
    // 针对组件内部的 mixins 来进行合并,即组件通过 mixins: [xxx] 来注册局部 mixin
    if (child.mixins) {
      for (let i = 0, l = child.mixins.length; i < l; i++) {
        parent = mergeOptions(parent, child.mixins[i], vm)
      }
    }
  }

  const options: ComponentOptions = {} as any
  let key
  for (key in parent) {
    mergeField(key)
  }
  for (key in child) {
    if (!hasOwn(parent, key)) {
      mergeField(key)
    }
  }
  function mergeField(key: any) {
    const strat = strats[key] || defaultStrat
    options[key] = strat(parent[key], child[key], vm, key)
  }
  return options
}

总结下来,mixin 核心就是合并属性(内部采用策略模式进行合并),全局 mixin 与局部 mixin 针对不同的属性有不同的合并策略。

data为什么是函数

Vue 内部调用的是 Vue.extend() 方法,会将用户的选项放到子类上。代码如下所示:

js
const Vue = {}
Vue.extend = function(options) {
  function Sub() {
    this.data = this.constructor.options.data
  }
  Sub.options = options
  return Sub
}

let Child = Vue.extend({
  data: {
    a: 1
  }
})

let c1 = new Child
let c2 = new Child

console.log(c1.data.a, c2.data.a)

但是如果多次调用 Vue.extend 方法,不可能每次都返回全新的,应该有一个缓存处理。到源码 src/core/global-api/extend.js 文件 20 行处查看,如下:

js
extendOptions = extendOptions || {}
const Super = this
const SuperId = Super.cid
const cachedCtors = extendOptions._Ctor || (extendOptions._Ctor = {})
if (cachedCtors[SuperId]) {
  return cachedCtors[SuperId]
}

它通过 ID 查找对应的缓存的值,如果有则取缓存的值。没有才赋新值。

现在返回刚刚我们写的代码,此时输出两个构造函数的 a 都是1,但是会有一个问题,他们共享=了同一个数据对象 data ,因此修改后会干扰到其他构造函数内的数据。

修改为函数返回对象的形式,确保每一个构造函数的 data 都是独立的、互不干扰的数据对象,修改不会造成数据干扰。

Vue 对此做了校验处理,如果是 newVue 说明是根组件,可以用对象的形式;如果是组件的形式,则只能是函数的形式。下面看看他的源码:

js
export function mergeDataOrFn(
  parentVal: any,
  childVal: any,
  vm?: Component
): Function | null {
  if (!vm) {
    // in a Vue.extend merge, both should be functions
    if (!childVal) {
      return parentVal
    }
    if (!parentVal) {
      return childVal
    }
    return function mergedDataFn() {
      return mergeData(
        isFunction(childVal) ? childVal.call(this, this) : childVal,
        isFunction(parentVal) ? parentVal.call(this, this) : parentVal
      )
    }
  } else {
    return function mergedInstanceDataFn() {
      const instanceData = isFunction(childVal)
        ? childVal.call(vm, vm)
        : childVal
      const defaultData = isFunction(parentVal)
        ? parentVal.call(vm, vm)
        : parentVal
      if (instanceData) {
        return mergeData(instanceData, defaultData)
      } else {
        return defaultData
      }
    }
  }
}

这是最开始 newVue 时初始化 data 数据的方法,会判断是对象形式还是函数形式。而组件会判断其 data 的数据形式,源码如下:

js
strats.data = function (
  parentVal: any,
  childVal: any,
  vm?: Component
): Function | null {
  if (!vm) {
    if (childVal && typeof childVal !== 'function') {
      __DEV__ &&
        warn(
          'The "data" option should be a function ' +
            'that returns a per-instance value in component ' +
            'definitions.',
          vm
        )

      return parentVal
    }
    return mergeDataOrFn(parentVal, childVal)
  }

  return mergeDataOrFn(parentVal, childVal, vm)
}

由此可以总结一下,对于根实例而言,new Vue 组件是通过同一个构造函数多次创建实例,如果是同一个对象的话那么会被互相影响。每个组件的数据源都是独立的,则每次都调用 data 返回一个新的对象。

nextTick实现原理

nextTick 内部采用了异步任务进行包装(多个 nextTick 调用会被合并成一次,内部会合并回调)。最后在异步任务中批次处理。

主要应用场景就是异步更新(默认调度的时候,就会添加一个 nextTick 任务)用户为了获取最终的渲染结果需要在内部任务执行之后再执行,这时用户需要把对应的逻辑放到 nextTick 中。

每次触发更新都会执行 Watcher 类的 update 方法,源码如下:

js
update() {
  if (this.lazy) {
    this.dirty = true
  } else if (this.sync) {
    this.run()
  } else {
    queueWatcher(this)
  }
}

而方法 queueWatcher 则负责过滤同名 watcher ,并将多个渲染 watcher 去重后放到队列中。源码如下:

js
export function queueWatcher(watcher: Watcher) {
  // 负责过滤同名 `watcher`
  const id = watcher.id
  if (has[id] != null) {
    return
  }

  if (watcher === Dep.target && watcher.noRecurse) {
    return
  }

  has[id] = true
  if (!flushing) {
    // 多个渲染 `watcher` 去重后放到队列
    queue.push(watcher)
  } else {
    let i = queue.length - 1
    while (i > index && queue[i].id > watcher.id) {
      i--
    }
    queue.splice(i + 1, 0, watcher)
  }
  if (!waiting) {
    waiting = true

    if (__DEV__ && !config.async) {
      flushSchedulerQueue()
      return
    }
   
    // 每一次更新视图后内部会开启一个nextTick
    nextTick(flushSchedulerQueue)
  }
}

前往文件 src/core/util/next-tick.js 文件中,找到 necttick 函数,源码如下:

js
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()
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

它还对当前浏览器能否使用 Promise 做了判断,如果可以使用则为微任务,不可使用则改为宏任务。源码如下:

js
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  timerFunc = () => {
    p.then(flushCallbacks)
    // In problematic UIWebViews, Promise.then doesn't completely break, but
    // it can get stuck in a weird state where callbacks are pushed into the
    // microtask queue but the queue isn't being flushed, until the browser
    // needs to do some other work, e.g. handle a timer. Therefore we can
    // "force" the microtask queue to be flushed by adding an empty timer.
    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
  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)) {
  // Fallback to setImmediate.
  // Technically it leverages the (macro) task queue,
  // but it is still a better choice than setTimeout.
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  // Fallback to setTimeout.
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

了解完源码后下方这道题就浅显易懂了,先看代码:

js
const vm = new Vue({
    data() {
        return {
            a: 1
        }
    }
})

vm.$nextTick(() => {
    console.log(vm.a)
})

vm.a = 100

vm.$nextTick(() => {
    console.log(vm.a)
})

根据源码可知,他会开辟一个数组队列,一次把任务推送到数组队列中。首先推送第一次的 nextTick ,然后推送更新操作,最后推送第二次的 nextTick

因此先打印1,然后修改值为100,再打印100。

watch与computed

原理

下面来看看 computedwatch 的源码。首先来到 src/core/instance/state.js 文件内,找到 initState 函数,里面有计算属性和侦听器的挂载,源码如下:

js
if (opts.computed) initComputed(vm, opts.computed)
if (opts.watch && opts.watch !== nativeWatch) {
  initWatch(vm, opts.watch)
}

可以看到计算属性的方法是在侦听器前挂载的,因为侦听器能够侦听计算属性变量的变化,因此需要计算属性方法先执行。

接着执行 initComputed 方法,源码如下:

js
function initComputed(vm: Component, computed: Object) {
  const watchers = (vm._computedWatchers = Object.create(null))
  const isSSR = isServerRendering()

  for (const key in computed) {
    const userDef = computed[key]
    const getter = isFunction(userDef) ? userDef : userDef.get
    if (__DEV__ && getter == null) {
      warn(`Getter is missing for computed property "${key}".`, vm)
    }

    if (!isSSR) {
      // create internal watcher for the computed property.
      watchers[key] = new Watcher(
        vm,
        getter || noop,
        noop,
        computedWatcherOptions
      )
    }

    if (!(key in vm)) {
      defineComputed(vm, key, userDef)
    } else if (__DEV__) {
      if (key in vm.$data) {
        warn(`The computed property "${key}" is already defined in data.`, vm)
      } else if (vm.$options.props && key in vm.$options.props) {
        warn(`The computed property "${key}" is already defined as a prop.`, vm)
      } else if (vm.$options.methods && key in vm.$options.methods) {
        warn(
          `The computed property "${key}" is already defined as a method.`,
          vm
        )
      }
    }
  }
}

可以看到它首先创建一个空对象 watchers ,用于后续保存每一项计算属性。循环遍历 computed 计算属性对象,判断其类型是函数型还是对象型,获取相对应的值。

紧接着判断其是否是 dirty ,如果是 dirty 说明该属性已经被修改了,走正常流程更新数据;否则值没变化,用之前缓存的数据即可。源码如下:

js
function createComputedGetter(key) {
  return function computedGetter() {
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      if (watcher.dirty) {
        watcher.evaluate()
      }
      if (Dep.target) {
        if (__DEV__ && Dep.target.onTrack) {
          Dep.target.onTrack({
            effect: Dep.target,
            target: this,
            type: TrackOpTypes.GET,
            key
          })
        }
        watcher.depend()
      }
      return watcher.value
    }
  }
}

判断当前的变量是计算属性还是 data 内的变量则通过传值处理,如果是计算属性,底层会传递一个 lazy 的参数,判断 lazy 是否为 true ,如果为真说明当前的变量是计算属性。源码如下:

js
// 为真说明是计算属性,不立即执行;为假说明是data内的数据,调用get方法劫持代理
this.value = this.lazy ? undefined : this.get()

// ...

update() {
  /* istanbul ignore else */
  if (this.lazy) {
    this.dirty = true
  } else if (this.sync) {
    this.run()
  } else {
    queueWatcher(this)
  }
}

计算属性的底层逻辑梳理了一遍,现在看看侦听器 watch 的底层逻辑。同样在 state.js 文件中,找到 initWatch 方法,源码如下:

js
function initWatch(vm: Component, watch: Object) {
  for (const key in watch) {
    const handler = watch[key]
    if (isArray(handler)) {
      for (let i = 0; i < handler.length; i++) {
        createWatcher(vm, key, handler[i])
      }
    } else {
      createWatcher(vm, key, handler)
    }
  }
}

function createWatcher(
  vm: Component,
  expOrFn: string | (() => any),
  handler: any,
  options?: Object
) {
  if (isPlainObject(handler)) {
    options = handler
    handler = handler.handler
  }
  if (typeof handler === 'string') {
    handler = vm[handler]
  }
  return vm.$watch(expOrFn, handler, options)
}

它也是循环遍历 watch 侦听器对象,如果是数组,循环创建 watcher ;如果是一个则直接创建 watcher

把每一项侦听器方法判断其类型,获取到对应的值后最终走 vm.$watch() 方法。方法源码如下:

js
Vue.prototype.$watch = function (
  expOrFn: string | (() => any),
  cb: any,
  options?: Record<string, any>
): Function {
  const vm: Component = this
  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()
  }
}

可以看到,首先调用 Watcher 类收集依赖,然后判断是否需要立即执行 immediate ,最后返回一个销毁侦听的事件。deep 属性的实现原理源码在 src/core/observe/watcher.js 文件内,源码如下:

js
if (this.deep) {
  traverse(value)
}

通过递归实现深度监听。

异同点

相同点:二者底层都会创建一个 watcher (用法的区别,computed 定义的属性可以在模板中使用,watch 不能在视图中使用)

不同点:

  • computed 默认不会立即执行,只有取值的时候才会执行。内部会有唯一一个 dirty 属性,来控制依赖的值是否发生变化,默认计算属性需要同步返回结果(有个包就是让 computed 变成异步的)
  • watch 默认用户会提供一个回调函数,数据变化了就调用这个回调。通过监控某个数据的变化,数据变化执行某些操作。

set的实现

Vue.set 方法是 Vue 中的一个补丁方法,正常情况下添加属性是不会触发更新的,数组无法监控到索引和长度的变化。

底层给每个对象都添加了一个 Dep 属性,触发更新时会调用 dep.notify() 方法,更新数据,代码如下:

js
const vm = new Vue({
    data() {
        return {
            obj: {
                a: 1
            }
        }
    }
})

vm.obj.b = 100 // js可以获取,视图不会更新
vm.obj.__ob__.dep.notify() // 实现视图更新

因此 Vue.set 方法无非时通过上方的逻辑思路实现的。源码也是这么处理的,如下所示:

js
export function set(
  target: any[] | Record<string, any>,
  key: any,
  val: any
): any {
  if (__DEV__ && (isUndef(target) || isPrimitive(target))) {
    warn(
      `Cannot set reactive property on undefined, null, or primitive value: ${target}`
    )
  }
  if (isReadonly(target)) {
    __DEV__ && warn(`Set operation on key "${key}" failed: target is readonly.`)
    return
  }
  const ob = (target as any).__ob__
  // 如果是数组的修改,如 vm.arr[0] = 100 ,修改为 vm.arr.splice(0,1,100) 因为 splice 方法是重写过的了
  if (isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val)
    if (ob && !ob.shallow && ob.mock) {
      observe(val, false, true)
    }
    return val
  }

  // 已有的属性直接修改即可
  if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
  }

  // 不建议重写整个data对象,如 $set(vm, 'data', {}) 因为性能消耗很严重
  if ((target as any)._isVue || (ob && ob.vmCount)) {
    __DEV__ &&
      warn(
        'Avoid adding reactive properties to a Vue instance or its root $data ' +
          'at runtime - declare it upfront in the data option.'
      )
    return val
  }
  // 不是响应式数据,直接添加
  if (!ob) {
    target[key] = val
    return val
  }
  // 变成响应式数据
  defineReactive(ob.value, key, val, undefined, ob.shallow, ob.mock)
  // 触发视图更新
  if (__DEV__) {
    ob.dep.notify({
      type: TriggerOpTypes.ADD,
      target: target,
      key,
      newValue: val,
      oldValue: undefined
    })
  } else {
    ob.dep.notify()
  }
  return val
}

Vue3 不需要此方法了,修改为 proxy 代理对象本身。

虚拟DOM的意义

实际业务中可能会针对不同的平台来使用不同的标签文本(weexweb 、小程序),虚拟 DOM 可以跨平台,不需要考虑平台问题。

不用关心兼容性,可以在上层将对应的渲染方法传递过来,通知虚拟 DOM 渲染即可。

Diff 算法针对更新的时候,有了虚拟 DOM 之后可以通过 Diff 算法来找到最后的差异进行修改真实 DOM 。(类似于在真实 DOM 之间做了一个缓存)

diff算法

Diff 算法特点就是平级比较,内部采用了双指针方式进行优化,优化了常见的操作。采用了递归比较的方式。

针对一个节点的diff算法

先拿出根节点来进行比较,如果是同一个节点则比较属性,如果不是同一个节点则直接换成最新的即可。

同一个节点比较属性后,复用老节点。

比较儿子

一方有儿子,另一方没有,则作删除或添加的操作。

两方都有儿子:

  • 优先比较头头、尾尾、头尾尾头的交叉对比
  • 做一个映射表,用心的去映射表中查找此元素是否存在,存在则移动不存在则插入,最后删除多余的
  • 会有多移动的情况。O(n) 复杂度的比较

源码如下:

js
const oldCh = oldVnode.children // 老的儿子
const ch = vnode.children // 新儿子
// 调用一系列的更新方法
if (isDef(data) && isPatchable(vnode)) {
  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)
}
// vnode不是文本
if (isUndef(vnode.text)) {
  // 两边都有儿子
  if (isDef(oldCh) && isDef(ch)) {
    if (oldCh !== ch)
      updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
  } else if (isDef(ch)) {
    if (__DEV__) {
      checkDuplicateKeys(ch)
    }
    // 新的有,老的没有,添加节点
    if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
    addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
  } else if (isDef(oldCh)) {
    // 如果老的有,新的没有,则移除
    removeVnodes(oldCh, 0, oldCh.length - 1)
  } else if (isDef(oldVnode.text)) {
    // 如果老的是文本,则清空文本
    nodeOps.setTextContent(elm, '')
  }
} else if (oldVnode.text !== vnode.text) {
  // 文本不一致,则设置文本
  nodeOps.setTextContent(elm, vnode.text)
}
if (isDef(data)) {
  if (isDef((i = data.hook)) && isDef((i = i.postpatch))) i(oldVnode, vnode)
}

题外话

既然数据劫持能够保持数据的响应式,那么为什么还需要虚拟 DOM 进行 Diff 检测差异呢?

如果给每一个属性都增加 watcher ,粒度太小不好控制;降低 watcher 数量(每一个组件都有一个 watcher ,一个变量变化更新该组件的视图)。再通过 Diff 算法优化渲染过程,减少视图更新的消耗,通过 Diff 算法和响应式原理折中处理。

key的原理和作用?

方法 isSameVnode 中会根据 key 来判断两个元素是否是同一个元素,key 不同说明不是同一个 元素。key 在动态列表中不要使用索引,使用 key 尽量保证其唯一性,可以优化 diff 算法。

组件化

特点

组件的优点:组件的复用可以根据数据渲染对应的组件,把组件相关的内容放在一起(方便复用)。合理规划组件,可以做到更新的时候是组件级更新(特性、属性、事件、插槽)。

Vue 中怎么样处理组件?

  1. Vue.extend 根据用户传入的对象生成一个组件的构造函数
  2. 根据组件产生对应的虚拟节点
  3. 做组件初始化,将虚拟节点转化成真实节点(组件的 init 方法)

渲染流程

先写一段组件化的 Vue 代码,如下:

vue
<div id="app">
  <my></my>
</div>

<script>
const vm = new Vue({
  el: '#app',
  // vm.$options.components = {my: 模板}
  components: {
    my: {
      template: '<div>my-component</div>'
    }
  }
})
</script>
  1. vm.$options.components = {my: 模板}
  2. 创建对应的虚拟节点,例如 {tag: 'my', data: {}, componentOptions: {Ctor:Vue.extend({my: 模板})}}
  3. 调用 createComponent 方法函数创建真实节点,然后调用 init 方法挂载
  4. vm.$el 插入到父元素中

更新流程

更新的几种情况:

  1. 父传子数据更新,收集依赖
  2. 属性更新,给组件传入属性,属性变化后触发更新
  3. 插槽变化

重点聊聊属性更新,更新逻辑都在 patch 函数中。去到 src/core/vdom/patch.js 文件,找到 patch 函数,部分源码如下:

js
if (!isRealElement && sameVnode(oldVnode, vnode)) {
  // patch existing root node
  patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
}

调用 patchVnode 方法判断新旧子节点的异同,如果父传子的数据发生变化,,则调用 prepatch 方法。源码如下所示:

js
function isDef<T>(v: T): v is NonNullable<T> {
  return v !== undefined && v !== null
}

let i
const data = vnode.data
if (isDef(data) && isDef((i = data.hook)) && isDef((i = i.prepatch))) {
  i(oldVnode, vnode)
}

prepatch 方法则非常暴力的做一件事情:把旧的实例赋值给新的实例,实例复用了也就意味着 componsnetInstance.$el 也复用了。其本质的作用就是复用组件的实例,并且可以去更新属性、事件、插槽。

复用完之后把对应的孩子、数据、事件等传参调用 updateChildComponent 方法。源码如下:

js
prepatch(oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
  const options = vnode.componentOptions
  const child = (vnode.componentInstance = oldVnode.componentInstance)
  updateChildComponent(
    child,
    options.propsData, // updated props
    options.listeners, // updated listeners
    vnode, // new parent vnode
    options.children // new children
  )
},

updateChildComponent 方法主要做以下几件事情:

  1. 把变量的响应式先去掉,因为属性是在父组件中定义的,传递给子组件。父组件定义的数据已经是响应式了,数据变化会自动更新
  2. 获取到父传子的数据对象
  3. 循环遍历,一一校验所传的属性名和值
  4. 更新完毕后恢复响应式

总结

  • 组件更新会触发组件的 prepatch 方法,会复用组件,并且比较组件的属性、事件、插槽
  • 父组件给子组件传递的属性 prop 是响应式的,模板中使用会做依赖收集,手机自己的组件 watcher
  • 稍后组件更新了会重新给 props 赋值,赋值完成后会触发 watcher 重新更新

异步组件原理

Vue 中异步组件写法有很多,主要用作大的组件可以异步加载比较大的组件,如 markdown 组件、editor 组件等。

先渲染一个注释标签,等组件加载完毕,最后再重新渲染 forceUpdate (图片懒加载)。使用异步组件会配合 webpack

Vue2 官方文档写法如下:

js
Vue.component('async-example', function(resolve, reject) {
    setTimeout(function() {
        resolve({
            template: `<div>I am async!</div>`
        })
    }, 1000)
})

可以看到,其参数二不再是一个对象,而是一个工厂函数,收到一个 resolve 回调,也可以调用 reject 表示调用失败。

也可以在工厂函数中返回一个 Promise ,写法如下:

js
Vue.component('async-example', () => {
    component: import('./my-async-example'),
    loading: {
        template: '<div>loading</div>'
    }, // 加载中的组件
    error: ErrorComponent, // 加载失败的组件
    delay: 200, // 加载时间
    timeout: 3000 // 超时时间
})

从源码入手,梳理整个流程。前面介绍到,Vue 创建组件时,会调用 Vue.extend 方法,那是因为底层代码有做了一个类型判断,如果是对象类型,直接调用 extend 方法。但异步组件是函数型,因此不会走这一步。源码如下:

js
if (isObject(Ctor)) {
  Ctor = baseCtor.extend(Ctor as typeof Component)
}

它会继续往下走,判断是否有 cid ,没有 cid 说明是异步组件,调用 resolveAsyncComponent 方法,返回值赋值给 Ctor 。然后再做判断,如果此时 Ctor 还是 undefined ,则调用 createAsyncPlaceholder 方法。源码如下:

js
if (isUndef(Ctor.cid)) {
  asyncFactory = Ctor
  Ctor = resolveAsyncComponent(asyncFactory, baseCtor)
  if (Ctor === undefined) {
    return createAsyncPlaceholder(asyncFactory, data, context, children, tag)
  }
}

resolveAsyncComponent 函数源码如下所示:

js
export function resolveAsyncComponent(
  factory: { (...args: any[]): any; [keye: string]: any },
  baseCtor: typeof Component
): typeof Component | void {
  // 如果factory.error,就直接渲染错误组件
  if (isTrue(factory.error) && isDef(factory.errorComp)) {
    return factory.errorComp
  }

  // 如果factory.resolved,就直接渲染成功
  if (isDef(factory.resolved)) {
    return factory.resolved
  }

  const owner = currentRenderingInstance
  if (owner && isDef(factory.owners) && factory.owners.indexOf(owner) === -1) {
    factory.owners.push(owner)
  }

  // 如果factory.loading,就直接渲染loading组件
  if (isTrue(factory.loading) && isDef(factory.loadingComp)) {
    return factory.loadingComp
  }

  if (owner && !isDef(factory.owners)) {
    const owners = (factory.owners = [owner])
    let sync = true
    let timerLoading: number | null = null
    let timerTimeout: number | null = null

    owner.$on('hook:destroyed', () => remove(owners, owner))

    // 强制更新
    const forceRender = (renderCompleted: boolean) => {
      for (let i = 0, l = owners.length; i < l; i++) {
        owners[i].$forceUpdate()
      }

      if (renderCompleted) {
        owners.length = 0
        if (timerLoading !== null) {
          clearTimeout(timerLoading)
          timerLoading = null
        }
        if (timerTimeout !== null) {
          clearTimeout(timerTimeout)
          timerTimeout = null
        }
      }
    }

    // 传递给用户的resolve
    const resolve = once((res: Object | Component) => {
      // cache resolved
      factory.resolved = ensureCtor(res, baseCtor)
      // invoke callbacks only if this is not a synchronous resolve
      // (async resolves are shimmed as synchronous during SSR)
      if (!sync) {
        forceRender(true)
      } else {
        owners.length = 0
      }
    })

    // 传递给用户的reject
    const reject = once(reason => {
      __DEV__ &&
        warn(
          `Failed to resolve async component: ${String(factory)}` +
            (reason ? `\nReason: ${reason}` : '')
        )
      if (isDef(factory.errorComp)) {
        factory.error = true
        forceRender(true)
      }
    })

    // 让函数执行,返回resolve或reject
    const res = factory(resolve, reject)

    if (isObject(res)) {
      if (isPromise(res)) {
        // 是promise就调用promise.then。如果成功则将获取到的结果赋予给 factory.resolved,即对应的组件
        if (isUndef(factory.resolved)) {
          res.then(resolve, reject)
        }
      } else if (isPromise(res.component)) {
        // 用户自己写的特殊的组件,是一个promise
        res.component.then(resolve, reject)

        if (isDef(res.error)) {
          factory.errorComp = ensureCtor(res.error, baseCtor)
        }

        if (isDef(res.loading)) {
          // 将组件的loading赋予给factory.loadingComp
          factory.loadingComp = ensureCtor(res.loading, baseCtor)
          if (res.delay === 0) {
            factory.loading = true
          } else {
            // @ts-expect-error NodeJS timeout type
            timerLoading = setTimeout(() => {
              timerLoading = null
              if (isUndef(factory.resolved) && isUndef(factory.error)) {
                factory.loading = true
                forceRender(false)
              }
            }, res.delay || 200)
          }
        }

        if (isDef(res.timeout)) {
          timerTimeout = setTimeout(() => {
            timerTimeout = null
            if (isUndef(factory.resolved)) {
              reject(__DEV__ ? `timeout (${res.timeout}ms)` : null)
            }
          }, res.timeout)
        }
      }
    }

    sync = false
    // 默认返回一个loading
    return factory.loading ? factory.loadingComp : factory.resolved
  }
}

总结

异步组件默认不会调用 Vue.extend 方法,所有 Ctor 上没有 cid 属性,没有 cid 属性的就是异步组件。

先渲染一个占位符组件,但是如果有 loading 会先渲染 loading ,第一轮就结束了。

如果用户调用了 resolve ,会将结果赋予给 factory.resolved 上面,强制重新渲染。重新渲染时候再次进入到 resolveAsyncComponent 中,会直接到 factory.resolved 结果来渲染。

函数式组件优势及原理

函数组件与类组件相比,没有 this ,没有所谓的状态,没有 sub 实例,没有生命周期 beforeCreatecreated 等等。优点在于性能好,不需要创建 watcher

由上方的描述不难看出,函数式组件是单纯的无状态组件,没有其他的操作,因此其性能高。源码如下:

js
export function createFunctionalComponent(
  Ctor: typeof Component,
  propsData: Object | undefined,
  data: VNodeData,
  contextVm: Component,
  children?: Array<VNode>
): VNode | Array<VNode> | void {
  const options = Ctor.options
  const props = {}
  const propOptions = options.props
  if (isDef(propOptions)) {
    for (const key in propOptions) {
      props[key] = validateProp(key, propOptions, propsData || emptyObject)
    }
  } else {
    if (isDef(data.attrs)) mergeProps(props, data.attrs)
    if (isDef(data.props)) mergeProps(props, data.props)
  }

  // 创建属性上下文
  const renderContext = new FunctionalRenderContext(
    data,
    props,
    children,
    contextVm,
    Ctor
  )

  // 函数式组件对应的vnode
  const vnode = options.render.call(null, renderContext._c, renderContext)

  // 如果符合,直接返回
  if (vnode instanceof VNode) {
    return cloneAndMarkFunctionalResult(
      vnode,
      data,
      renderContext.parent,
      options,
      renderContext
    )
  } else if (isArray(vnode)) {
    const vnodes = normalizeChildren(vnode) || []
    const res = new Array(vnodes.length)
    for (let i = 0; i < vnodes.length; i++) {
      res[i] = cloneAndMarkFunctionalResult(
        vnodes[i],
        data,
        renderContext.parent,
        options,
        renderContext
      )
    }
    return res
  }
}

总结

函数式组件就是调用 render 拿到返回结果渲染,所以性能高。

组件传值的方式及之间区别

  • props 父传子

    props 会分类,在父组件传递的参数中,只有子组件内 props 声明了的才会归为 props 一类,剩下的归为 $attrs 一类。如下:

    vue
    <div id="app">
      <son a=1 b=2 c=3 />
    </div>
    
    <script>
    new Vue({
      el: '#app',
      components: {
        son: {
          props: ['a', 'b'],
          template: '<div>son</div>'
        }
      }
    })
    </script>

    上方示例代码中 abprops 一类,c$attes 一类。源码如下所示:

    js
    export function extractPropsFromVNodeData(
      data: VNodeData,
      Ctor: typeof Component,
      tag?: string
    ): object | undefined {
      const propOptions = Ctor.options.props
      if (isUndef(propOptions)) {
        return
      }
      const res = {}
      const { attrs, props } = data
      if (isDef(attrs) || isDef(props)) {
        for (const key in propOptions) {
          const altKey = hyphenate(key)
          if (__DEV__) {
            const keyInLowerCase = key.toLowerCase()
            if (key !== keyInLowerCase && attrs && hasOwn(attrs, keyInLowerCase)) {
              tip(
                `Prop "${keyInLowerCase}" is passed to component ` +
                  `${formatComponentName(
                    // @ts-expect-error tag is string
                    tag || Ctor
                  )}, but the declared prop name is` +
                  ` "${key}". ` +
                  `Note that HTML attributes are case-insensitive and camelCased ` +
                  `props need to use their kebab-case equivalents when using in-DOM ` +
                  `templates. You should probably use "${altKey}" instead of "${key}".`
              )
            }
          }
          checkProp(res, props, key, altKey, true) ||
            checkProp(res, attrs, key, altKey, false)
        }
      }
      return res
    }

    拿到父传子的数据后循环遍历校验(如转为小写后查看是否存在该属性等),然后分类出 propsattrs

    接着创建好组件的虚拟节点,有一个属性 {componentOptions: {propsData}} 。初始化的时候就要对 propsData 做处理,挂载到 vm.$options.propsData 。源码如下:

    js
    const name = getComponentName(Ctor.options) || tag
    const vnode = new VNode(
      `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
      data,
      undefined,
      undefined,
      undefined,
      context,
      { Ctor, propsData, listeners, tag, children },
      asyncFactory
    )
    
    return vnode

    初始化属性时会走到 initProps 方法,拿到 propsData ,并声明一个 vm._props ,该属性类似于 vm._data 。声明一个数组存放所有的 key 。判断是不是根属性,只有根属性会被进行观测,其他父组件传递给的不需要观测。

    循环遍历子组件接收父组件的传值 props 对象,把所有的键名 push 放到存放 key 的数组内,校验值是否合法。源码如下:

    js
    function initProps(vm: Component, propsOptions: Object) {
      const propsData = vm.$options.propsData || {}
      const props = (vm._props = shallowReactive({}))
      // keys 数组用于存放 props 对象的键名key
      const keys: string[] = (vm.$options._propKeys = [])
      const isRoot = !vm.$parent
      // 判断是不是根属性
      if (!isRoot) {
        toggleObserving(false)
      }
      // 循环遍历propsOptions对象
      for (const key in propsOptions) {
        // 保存键名
        keys.push(key)
        // 校验键值是否合法
        const value = validateProp(key, propsOptions, propsData, vm)
        /* istanbul ignore else */
        if (__DEV__) {
          const hyphenatedKey = hyphenate(key)
          if (
            isReservedAttribute(hyphenatedKey) ||
            config.isReservedAttr(hyphenatedKey)
          ) {
            warn(
              `"${hyphenatedKey}" is a reserved attribute and cannot be used as component prop.`,
              vm
            )
          }
          // 把值变为响应式
          defineReactive(props, key, value, () => {
            if (!isRoot && !isUpdatingChildComponent) {
              warn(
                `Avoid mutating a prop directly since the value will be ` +
                  `overwritten whenever the parent component re-renders. ` +
                  `Instead, use a data or computed property based on the prop's ` +
                  `value. Prop being mutated: "${key}"`,
                vm
              )
            }
          })
        } else {
          defineReactive(props, key, value)
        }
        if (!(key in vm)) {
          proxy(vm, `_props`, key)
        }
      }
      toggleObserving(true)
    }

    校验成功后调用 defineReactive 方法,该方法把该属性转为响应式。源码如下:

    js
    export function defineReactive(
      obj: object,
      key: string,
      val?: any,
      customSetter?: Function | null,
      shallow?: boolean,
      mock?: boolean
    ) {
      const dep = new Dep()
    
      // ...
      
      Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get: function reactiveGetter() {
          const value = getter ? getter.call(obj) : val
          if (Dep.target) {
            if (__DEV__) {
              dep.depend({
                target: obj,
                type: TrackOpTypes.GET,
                key
              })
            } else {
              dep.depend()
            }
            if (childOb) {
              childOb.dep.depend()
              if (isArray(value)) {
                dependArray(value)
              }
            }
          }
          return isRef(value) && !shallow ? value.value : value
        },
        set: function reactiveSetter(newVal) {
          const value = getter ? getter.call(obj) : val
          if (!hasChanged(value, newVal)) {
            return
          }
          if (__DEV__ && customSetter) {
            customSetter()
          }
          if (setter) {
            setter.call(obj, newVal)
          } else if (getter) {
            return
          } else if (!shallow && isRef(value) && !isRef(newVal)) {
            value.value = newVal
            return
          } else {
            val = newVal
          }
          childOb = !shallow && observe(newVal, false, mock)
          if (__DEV__) {
            dep.notify({
              type: TriggerOpTypes.SET,
              target: obj,
              key,
              newValue: newVal,
              oldValue: value
            })
          } else {
            dep.notify()
          }
        }
      })
    
      return dep
    }

    设为响应式完毕后把所有属性定义到 vm._props 上,并通过映射实现 vm.b => vm._props.b ,因此可以通过 vm.b 获取 propsb 的变量数据。

    父组件的值发生了变化,则去更新 vm._props ,然后更新视图。

  • emit 子传父

    子传父通过自定义事件传参来实现,代码如下:

    html
    <son @xxx="handler" />

    该方法实际上是给子组件绑定一个方法,如下:

    js
    son.$on('xxx', handler)

    这是典型的发布订阅模式。源码中会给组件虚拟节点 data 上的 on 属性绑定该函数,效果如下图所示:

    效果

    还有另外一种写法:为组件绑定原生事件,在 Vue2 中,组件想要实现原生事件,需要添加 .native 修饰符,代码如下:

    html
    <son @click.native="handler" />

    底层逻辑会给组件虚拟节点 data 对象中添加一个 nativeOn 属性,并绑定相对应的 click 函数,如下图所示:

    mative效果

  • ref 获取组件实例

    通过 this.$refs.xxx 可以获取 DOM 元素和组件实例(虚拟 DOM 没有 DOM 元素,也没有组件实例,因此没有处理 ref )。

    底层逻辑会走 createPatchFunction 函数,形参接收到的是节点操作和属性操作,如下图所示:

    形参效果

    里面的属性包含了针对浏览器的属性和虚拟 DOM 内核中定义的逻辑。把 ref 属性保存到 data 中,代码与效果如下:

    js
    export function createPatchFunction(backend) {
      let i, j
      const cbs: any = {}
        
      // ...
      const data = vnode.data
    }

    ref在data中

    调用 invokeCreateHooks 函数方法,把相应的数据传过去,

    js
    if (isDef(data)) {
    	invokeCreateHooks(vnode, insertedVnodeQueue)
    }

    该方法会循环遍历,找到对应有 ref 相关方法的属性,代码如下:

    js
    function invokeCreateHooks(vnode, insertedVnodeQueue) {
      for (let i = 0; i < cbs.create.length; ++i) {
        cbs.create[i](emptyNode, vnode)
      }
      i = vnode.data.hook // Reuse variable
      if (isDef(i)) {
        if (isDef(i.create)) i.create(emptyNode, vnode)
        if (isDef(i.insert)) insertedVnodeQueue.push(vnode)
      }
    }

    循环

    ref 对应执行的方法是 registerRef 方法,在 src/core/vdom/modules/ref.js 文件内,做如下操作:

    1. 取出 ref
    2. 拿到实例
    3. 如果是组件,那么 ref 是组件的实例;如果是元素,ref 是真实 DOM
    4. 获取 vm.$refs
    5. 判断,如果是删除方法,则把它从数组中删除;否则在 refs 对象内把用户设置的实例名称作为对象的键名,对应的 DOM 作为键值

    源码如下:

    js
    export function registerRef(vnode: VNodeWithData, isRemoval?: boolean) {
      const ref = vnode.data.ref
      if (!isDef(ref)) return
    
      const vm = vnode.context
      const refValue = vnode.componentInstance || vnode.elm
      const value = isRemoval ? null : refValue
      const $refsValue = isRemoval ? undefined : refValue
    
      if (isFunction(ref)) {
        invokeWithErrorHandling(ref, vm, [value], vm, `template ref function`)
        return
      }
    
      const isFor = vnode.data.refInFor
      const _isString = typeof ref === 'string' || typeof ref === 'number'
      const _isRef = isRef(ref)
      const refs = vm.$refs
    
      if (_isString || _isRef) {
        if (isFor) {
          const existing = _isString ? refs[ref] : ref.value
          if (isRemoval) {
            isArray(existing) && remove(existing, refValue)
          } else {
            if (!isArray(existing)) {
              if (_isString) {
                refs[ref] = [refValue]
                setSetupRef(vm, ref, refs[ref])
              } else {
                ref.value = [refValue]
              }
            } else if (!existing.includes(refValue)) {
              existing.push(refValue)
            }
          }
        } else if (_isString) {
          if (isRemoval && refs[ref] !== refValue) {
            return
          }
          refs[ref] = $refsValue
          setSetupRef(vm, ref, value)
        } else if (_isRef) {
          if (isRemoval && ref.value !== refValue) {
            return
          }
          ref.value = value
        } else if (__DEV__) {
          warn(`Invalid template ref type: ${typeof ref}`)
        }
      }
    }

props 总结

  1. 子组件更新原理,即父组件的属性修改后会调用子组件的 prepatch 方法,接收到后更新 vm._props.变量 = 新值

  2. 组件重新更新时都会执行 prepatch 方法,在更新时如果是开发环境还会把标识变为 true ,源码如下:

    js
    if (__DEV__) {
      isUpdatingChildComponent = true
    }

    在调用defineReactive 方法时会做判断,如果是开发环境则报错,不允许直接通过 props 修改变量,保持单项数据的规范。源码如下:

    js
    defineReactive(props, key, value, () => {
      if (!isRoot && !isUpdatingChildComponent) {
        warn(
          `Avoid mutating a prop directly since the value will be ` +
            `overwritten whenever the parent component re-renders. ` +
            `Instead, use a data or computed property based on the prop's ` +
            `value. Prop being mutated: "${key}"`,
          vm
        )
      }
    })

    属性的原理就是把解析后的 props ,验证后就会将属性定义在当前的实例 vm._props 上(这个对象上的属性都是通过 defineReactive 来定义的响应式数据。组件在渲染的过程中会去 vm 上取值,_props 属性会被代理到 vm 上)

emit 总结

在创建虚拟节点的时候将所有事件绑定到 listeners ,通过 $on 方法绑定事件,通过 $emit 解绑事件。源码如下:

js
Vue.prototype.$on = function (
  event: string | Array<string>,
  fn: Function
): Component {
  // vm._events.xxx = [fn]
  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
}

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
}

ref 总结

创建 ref 时,会将用户所有的 DOM 操作以及属性,都维护到一个 cbs 属性中(createupdateinsertdestory 等等)。依次调用 cbscreate 方法,这里就包含 ref 相关的操作,会操作 ref 并且赋值。

provide与inject

provide 就是在当前组件上通过一个 _provide 属性,对应的就是提供的对象。源码在 src/core/instance/inject.js 文件内,代码如下所示:

js
export function initProvide(vm: Component) {
  const provideOption = vm.$options.provide
  if (provideOption) {
    const provided = isFunction(provideOption)
      ? provideOption.call(vm)
      : provideOption
    if (!isObject(provided)) {
      return
    }
    const source = resolveProvided(vm)
    // IE9 doesn't support Object.getOwnPropertyDescriptors so we have to
    // iterate the keys ourselves.
    const keys = hasSymbol ? Reflect.ownKeys(provided) : Object.keys(provided)
    for (let i = 0; i < keys.length; i++) {
      const key = keys[i]
      Object.defineProperty(
        source,
        key,
        Object.getOwnPropertyDescriptor(provided, key)!
      )
    }
  }
}

inject 写法为 inject: ['a'] 。在底层逻辑中,以以下步骤实现业务功能:

  1. 在父组件内找到对应的属性
  2. 把这个属性定义到自己的身上

源码如下所示:

js
export function initInjections(vm: Component) {
  const result = resolveInject(vm.$options.inject, vm)
  if (result) {
    toggleObserving(false)
    Object.keys(result).forEach(key => {
      /* istanbul ignore else */
      if (__DEV__) {
        defineReactive(vm, key, result[key], () => {
          warn(
            `Avoid mutating an injected value directly since the changes will be ` +
              `overwritten whenever the provided component re-renders. ` +
              `injection being mutated: "${key}"`,
            vm
          )
        })
      } else {
        defineReactive(vm, key, result[key])
      }
    })
    toggleObserving(true)
  }
}

上述源码中,方法 resolveInject 就是实现查找父组件上的属性,它的步骤为:

  1. 声明一个空对象
  2. 拿着 key ,遍历对象一个一个查找
  3. 将当前实例作为开头,拿到结果保存至 result ,找到一个就停止
  4. 向上查找父组件

源码如下:

js
export function resolveInject(
  inject: any,
  vm: Component
): Record<string, any> | undefined | null {
  if (inject) {
    // inject is :any because flow is not smart enough to figure out cached
    const result = Object.create(null)
    const keys = hasSymbol ? Reflect.ownKeys(inject) : Object.keys(inject)

    for (let i = 0; i < keys.length; i++) {
      const key = keys[i]
      // #6574 in case the inject object is observed...
      if (key === '__ob__') continue
      const provideKey = inject[key].from
      if (provideKey in vm._provided) {
        result[key] = vm._provided[provideKey]
      } else if ('default' in inject[key]) {
        const provideDefault = inject[key].default
        result[key] = isFunction(provideDefault)
          ? provideDefault.call(vm)
          : provideDefault
      } else if (__DEV__) {
        warn(`Injection "${key as string}" not found`, vm)
      }
    }
    return result
  }
}

总结

provide 在父组件中将属性暴露出来,在父组件中提供数据;inject 在后代组件中注入属性,通过递归向上查找。

$attrs与$listeners

在实际业务中,$attrs 可以接收父组件通过 v-bind 传输的所有变量,$listeners 可以接收父组件通过 v-on 传递的所有方法。

从源码下手查看其底层逻辑,在 src/core/instance/render.js 文件中,代码如下:

js
if (__DEV__) {
  defineReactive(
    vm,
    '$attrs',
    (parentData && parentData.attrs) || emptyObject,
    () => {
      !isUpdatingChildComponent && warn(`$attrs is readonly.`, vm)
    },
    true
  )
  defineReactive(
    vm,
    '$listeners',
    options._parentListeners || emptyObject,
    () => {
      !isUpdatingChildComponent && warn(`$listeners is readonly.`, vm)
    },
    true
  )
} else {
  defineReactive(
    vm,
    '$attrs',
    (parentData && parentData.attrs) || emptyObject,
    null,
    true
  )
  defineReactive(
    vm,
    '$listeners',
    options._parentListeners || emptyObject,
    null,
    true
  )
}

源码主要区分了开发环境与生产环境,把从父节点那里接收到的值分别保存。这就是它的底层逻辑。

下面来看实际业务中它是如何使用的。父组件传递参数,子组件接收数据,此时父组件传递的参数会都保存在子组件的根标签。如果想要在二级以下的标签使用,则需要先设置 inheritAttrsfalse ,然后再需要使用的标签上获取对应的值。代码如下:

html
<div id="app">
    <son :a="1" @click="handler" />
</div>

<script>
new Vue({
    el: '#app',
    components: {
        inheritAttrs: false,
        son: {
            template: `<div><input v-bind="${$attrs.a}" /></div>`
        }
    }
})
</script>

组件中 name 属性的好处与作用

在 Vue 中有 name 属性的组件可以被递归调用(在写模板语法的时候,可以通过 name 属性来递归调用自己)。

在声明组件时通过 Sub.options.components[name] = 组件 的形式,把自己的构造函数放上去。

该属性还可以用来标识组件,通过 name 来找到对应的组件,自己封装跨级通信。

  • 父组件调用子组件的方法

    思路如下:

    1. 遍历组件的 name 是否等于需要的 name 组件
    2. 如果相等,则调用其相应的函数方法

    代码如下:

    js
    Vue.prototype.$dispatch = function(componentName, data, fnName) {
        let vm = this
        while(vm) {
            if(vm.$options.name === componentName) {
                vm[fnName](data)
                break
            }
            vm = vm.$parent
        }
    }
  • 子组件给父组件传自定义事件

    js
    Vue.prototype.$broadcast = function(conponentName, data, fnName) {
        let components = []
        let vm = this
        function findChildren(children) {
            children.forEach(child => {
                // 找到符合名称的组件
                if(child.$options.name === componentName) {
                    components.push(child)
                }
                child.$children && findChildren(child.$children)
            })
        }
        findChildren(vm.$children)
        // 遍历符合条件的组件数组,依次调用 emit 方法
        components.forEach(child => {
            child.$emit(fnName, data)
        })
    }

name 属性还可以作 devtool 调试工具来标明具体的组件。

v-if、v-for、v-model

v-if和v-for优先级

前往 template 源码翻译的在线网站,输入对应的 template 结构,翻译成源码如下所示:

结果

由此可以推断出 v-for 的优先级高于 v-if 。在编译的时候,会将 v-for 渲染成 _l 函数,v-if 会渲染成三元表达式。

从源码下手,去往 src/compiler/codegen/index.js 文件,该部分的源码如下所示:

js
else if (el.for && !el.forProcessed) {
  return genFor(el, state)
} else if (el.if && !el.ifProcessed) {
  return genIf(el, state)
}

可以发现,它是先处理 el.for 循环,然后再执行 el.if 判断。

先来看看 v-for 调用的 genFor 方法的源码,代码如下所示:

js
export function genFor(
  el: any,
  state: CodegenState,
  altGen?: Function,
  altHelper?: string
): string {
  const exp = el.for
  const alias = el.alias
  const iterator1 = el.iterator1 ? `,${el.iterator1}` : ''
  const iterator2 = el.iterator2 ? `,${el.iterator2}` : ''

  if (
    __DEV__ &&
    state.maybeComponent(el) &&
    el.tag !== 'slot' &&
    el.tag !== 'template' &&
    !el.key
  ) {
    state.warn(
      `<${el.tag} v-for="${alias} in ${exp}">: component lists rendered with ` +
        `v-for should have explicit keys. ` +
        `See https://v2.vuejs.org/v2/guide/list.html#key for more info.`,
      el.rawAttrsMap['v-for'],
      true /* tip */
    )
  }

  el.forProcessed = true // avoid recursion
  return (
    `${altHelper || '_l'}((${exp}),` +
    `function(${alias}${iterator1}${iterator2}){` +
    `return ${(altGen || genElement)(el, state)}` +
    '})'
  )
}

以上方截图为例,可以看到,该方法首先获取到循环的次数 el.for ,然后获取到循环的每一项 i 、每一项的索引 index 、第三个循环参数。

接着判断是否有 key 变量,没有则弹出错误提示,最后转成 AST 树并返回。

el 变量内拥有的属性如下图所示:

el

接下来看看 v-if 的源码,调用了 genIf 方法,代码如下:

js
export function genIf(
  el: any,
  state: CodegenState,
  altGen?: Function,
  altEmpty?: string
): string {
  el.ifProcessed = true // avoid recursion
  return genIfConditions(el.ifConditions.slice(), state, altGen, altEmpty)
}

它调用了 genIfConditions 方法,其源码如下:

js
function genIfConditions(
  conditions: ASTIfConditions,
  state: CodegenState,
  altGen?: Function,
  altEmpty?: string
): string {
  if (!conditions.length) {
    return altEmpty || '_e()'
  }

  const condition = conditions.shift()!
  if (condition.exp) {
    return `(${condition.exp})?${genTernaryExp(
      condition.block
    )}:${genIfConditions(conditions, state, altGen, altEmpty)}`
  } else {
    return `${genTernaryExp(condition.block)}`
  }

  // v-if with v-once should generate code like (a)?_m(0):_m(1)
  function genTernaryExp(el) {
    return altGen
      ? altGen(el, state)
      : el.once
      ? genOnce(el, state)
      : genElement(el, state)
  }
}

该方法的本质是截取出判断的条件,再放到 AST 树内,最后返回出去。

v-if和v-show区别

面试时经常会有人问到,v-ifv-show 的区别。网上很多回答都是:v-if 控制组件是否创建,v-show 控制组件 display 属性是否显隐。那么,如果 v-showfalse 改为 true<span> 标签的 display 属性会设置为 block 么?我们从源码入手。

从底层逻辑上我们已经知道了 v-if 实际上是通过三元表达式判断它需不需要编译,而 v-show 则是在编译的时候添加一条指令。如下图所示:

v-show效果

接下来看看 v-show 的源码,在 sec/platforms/web/runtime/directives/show.js 文件内,代码如下所示:

js
bind(el: any, { value }: VNodeDirective, vnode: VNodeWithData) {
  vnode = locateNode(vnode)
  const transition = vnode.data && vnode.data.transition
  const originalDisplay = (el.__vOriginalDisplay =
    el.style.display === 'none' ? '' : el.style.display)
  if (value && transition) {
    vnode.data.show = true
    enter(vnode, () => {
      el.style.display = originalDisplay
    })
  } else {
    el.style.display = value ? originalDisplay : 'none'
  }
},

可以看到,他会先获取到原来标签的 display 属性,保存到 originalDisplay 变量中,后续切换 v-show 的值为真时则拿到 originalDisplay 变量内的 display 属性值。

题外话

为什么用 display ,不用 visbility: hiddenopacity: 0 呢?

这两个方法都会占位,可以触发相应的事件。

v-if实现原理

总结一下:v-if 会编译成三元表达式,如果为真则编译渲染,如果为假则不编译。

v-for实现原理

v-for 是通过 _l AST 语法树来实现的。下面查看一下 _l 底层源码。来到 src/core/instance/render-helpers/index.js 文件中,代码如下所示:

js
export function installRenderHelpers(target: any) {
  target._o = markOnce
  target._n = toNumber
  target._s = toString
  target._l = renderList
  target._t = renderSlot
  target._q = looseEqual
  target._i = looseIndexOf
  target._m = renderStatic
  target._f = resolveFilter
  target._k = checkKeyCodes
  target._b = bindObjectProps
  target._v = createTextVNode
  target._e = createEmptyVNode
  target._u = resolveScopedSlots
  target._g = bindObjectListeners
  target._d = bindDynamicKeys
  target._p = prependModifier
}

可以看到,它每一个 AST 树的语法标识都有对应的方法。下面查看其对应方法 renderList 。代码如下所示:

js
export function renderList(
  val: any,
  render: (val: any, keyOrIndex: string | number, index?: number) => VNode
): Array<VNode> | null {
  let ret: Array<VNode> | null = null,
    i,
    l,
    keys,
    key
  if (isArray(val) || typeof val === 'string') {
    ret = new Array(val.length)
    for (i = 0, l = val.length; i < l; i++) {
      ret[i] = render(val[i], i)
    }
  } else if (typeof val === 'number') {
    ret = new Array(val)
    for (i = 0; i < val; i++) {
      ret[i] = render(i + 1, i)
    }
  } else if (isObject(val)) {
    if (hasSymbol && val[Symbol.iterator]) {
      ret = []
      const iterator: Iterator<any> = val[Symbol.iterator]()
      let result = iterator.next()
      while (!result.done) {
        ret.push(render(result.value, ret.length))
        result = iterator.next()
      }
    } else {
      keys = Object.keys(val)
      ret = new Array(keys.length)
      for (i = 0, l = keys.length; i < l; i++) {
        key = keys[i]
        ret[i] = render(val[key], key, i)
      }
    }
  }
  if (!isDef(ret)) {
    ret = []
  }
  ;(ret as any)._isVList = true
  return ret
}

它做的事情很简单,判断循环条件的类型,如果是数值型,直接循环即可;如果是数组型,则循环其长度;如果是对象型,则通过迭代遍历循环。最后返回出去。

v-model实现原理

绑定到普通元素上

v-model 放在不同的元素上会编译出不同的结果,针对文本来说会处理文本,编译成 value + input + 指令处理。

代码源码翻译图如下所示:

v-model翻译

接下来看它的底层逻辑,源码在 src/platforms/web/compiler/directives/model.js 文件中,代码如下所示:

js
export default function model(
  el: ASTElement,
  dir: ASTDirective,
  _warn: Function
): boolean | undefined {
  warn = _warn
  const value = dir.value
  const modifiers = dir.modifiers
  const tag = el.tag
  const type = el.attrsMap.type

  if (__DEV__) {
    // inputs with type="file" are read only and setting the input's
    // value will throw an error.
    if (tag === 'input' && type === 'file') {
      warn(
        `<${el.tag} v-model="${value}" type="file">:\n` +
          `File inputs are read only. Use a v-on:change listener instead.`,
        el.rawAttrsMap['v-model']
      )
    }
  }

  if (el.component) {
    genComponentModel(el, value, modifiers)
    // component v-model doesn't need extra runtime
    return false
  } else if (tag === 'select') {
    genSelect(el, value, modifiers)
  } else if (tag === 'input' && type === 'checkbox') {
    genCheckboxModel(el, value, modifiers)
  } else if (tag === 'input' && type === 'radio') {
    genRadioModel(el, value, modifiers)
  } else if (tag === 'input' || tag === 'textarea') {
    genDefaultModel(el, value, modifiers)
  } else if (!config.isReservedTag(tag)) {
    genComponentModel(el, value, modifiers)
    // component v-model doesn't need extra runtime
    return false
  } else if (__DEV__) {
    warn(
      `<${el.tag} v-model="${value}">: ` +
        `v-model is not supported on this element type. ` +
        "If you are working with contenteditable, it's recommended to " +
        'wrap a library dedicated for that purpose inside a custom component.',
      el.rawAttrsMap['v-model']
    )
  }

  // ensure runtime directive metadata
  return true
}

可以看到,该方法做了以下几件事情:

  1. 获取 v-model 双向绑定的内容、标签、类型等
  2. 判断是否是文件上传组件 <input type="file" /> ,如果是则返回报错
  3. 根据 v-model 绑定的 DOM 元素的类型,调用不同的方法
  4. 如果不是组件、 input 类型,则报错

下拉框、单选框、复选框的事件都是为元素绑定一个 change 事件,这里不做过多赘述,着重看一下 text 文本框的事件处理。源码如下:

js
const directive = {
  inserted(el, binding, vnode, oldVnode) {
    if (vnode.tag === 'select') {
      // #6903
      if (oldVnode.elm && !oldVnode.elm._vOptions) {
        mergeVNodeHook(vnode, 'postpatch', () => {
          directive.componentUpdated(el, binding, vnode)
        })
      } else {
        setSelected(el, binding, vnode.context)
      }
      el._vOptions = [].map.call(el.options, getValue)
    } else if (vnode.tag === 'textarea' || isTextInputType(el.type)) {
      el._vModifiers = binding.modifiers
      if (!binding.modifiers.lazy) {
        el.addEventListener('compositionstart', onCompositionStart)
        el.addEventListener('compositionend', onCompositionEnd)
        // Safari < 10.2 & UIWebView doesn't fire compositionend when
        // switching focus before confirming composition choice
        // this also fixes the issue where some browsers e.g. iOS Chrome
        // fires "change" instead of "input" on autocomplete.
        el.addEventListener('change', onCompositionEnd)
        /* istanbul ignore if */
        if (isIE9) {
          el.vmodel = true
        }
      }
    }
  },

  componentUpdated(el, binding, vnode) {
    if (vnode.tag === 'select') {
      setSelected(el, binding, vnode.context)
      // in case the options rendered by v-for have changed,
      // it's possible that the value is out-of-sync with the rendered options.
      // detect such cases and filter out values that no longer has a matching
      // option in the DOM.
      const prevOptions = el._vOptions
      const curOptions = (el._vOptions = [].map.call(el.options, getValue))
      if (curOptions.some((o, i) => !looseEqual(o, prevOptions[i]))) {
        // trigger change event if
        // no matching option found for at least one value
        const needReset = el.multiple
          ? binding.value.some(v => hasNoMatchingOption(v, curOptions))
          : binding.value !== binding.oldValue &&
            hasNoMatchingOption(binding.value, curOptions)
        if (needReset) {
          trigger(el, 'change')
        }
      }
    }
  }
}

function onCompositionEnd(e) {
  // prevent triggering an input event for no reason
  if (!e.target.composing) return
  e.target.composing = false
  trigger(e.target, 'input')
}

其中,它会监听中文输入完毕的事件,中文输入完毕后,再把标识变为 false ,触发 input 事件,手动更新。

总结一下, valueinput 实现双向绑定阻止中文的触发,指令的作用就是处理中文输入完毕后手动触发更新。

绑定到组件上

v-model 绑定到组件上会被编译成一个 model 对象,组件在创建虚拟节点的时候会有这个对象。逻辑如下图所示:

model逻辑

可以看出来,它实质上是 valueinput 的语法糖。

修饰符 .sync 的作用、用法及原理

作用

一个组件如果想要实现多个组件响应式数据,但是在 Vue2 中不允许一个组件使用多个 v-model ,因此推出 .sync 语法糖,实现子组件改变父组件的变量。

在 Vue3 中该修饰符废除。

用法

父组件在给子组件传递参数时使用 .sync 修饰符;子组件给父组件传递自定义事件时传递 update:父组件传递的变量名称, 需要传递的变量 实现子组件改变父组件的变量。

代码如下所示:

vue
<div id="app">
    <my :xx.sync="info" />
</div>

<script>
    Vue.component('my', {
        props: [xx],
        template: `<div>{{xx}}<button @click="$emit('update:xx', world)">click me change xx</button></div>`
    })
    
    const vm = new Vue({
        el: '#app',
        data() {
            return {
                info: 'hello'
            }
        }
    })
</script>

原理

具体原理如下图所示:

原理

绑定一个属性 xx ,并且生成一个事件 update:xx

Vue.use 原理

首先需要知道 Vue.use 方法是干嘛用的,该方法主要用于注册插件,其使用方法代码如下所示:

js
// 写法1
let plugin1 = function(Vue, options) {
    console.log(Vue, options)
}

Vue.use(plugin1, {a: 1, b: 2})

// 写法2
let plugin2 = {
    install: function(Vue, options) {
    	console.log(Vue, options)
	}
}

Vue.use(plugin2, {a: 1, b: 2})

接受两个参数,参数一是 Vue 实例,参数二是传的数据参数,如下图所示:

打印结果

其底层逻辑主要做了以下几步的处理:

  1. 判断缓存中是否有该插件,如果有则直接使用缓存中的插件
  2. 调用内部的 toArray 方法把传递的参数从索引为1开始提取出来作为一个数组(即不要索引为0的 Vue 参数)
  3. 在该数组中第一项添加 this
  4. 判断写法,如果是函数写法直接调用,如果是 install 写法则通过 applythis 指向改为 plugin
  5. 添加到缓存的数组中
  6. 返回

源码在文件 src/core/global-api/use.js 文件中,代码如下所示:

js
export function initUse(Vue: GlobalAPI) {
  Vue.use = function (plugin: Function | any) {
    const installedPlugins =
      this._installedPlugins || (this._installedPlugins = [])
    if (installedPlugins.indexOf(plugin) > -1) {
      return this
    }

    // additional parameters
    const args = toArray(arguments, 1)
    args.unshift(this)
    if (isFunction(plugin.install)) {
      plugin.install.apply(plugin, args)
    } else if (isFunction(plugin)) {
      plugin.apply(null, args)
    }
    installedPlugins.push(plugin)
    return this
  }
}

export function toArray(list: any, start?: number): Array<any> {
  start = start || 0
  let i = list.length - start
  const ret: Array<any> = new Array(i)
  while (i--) {
    ret[i] = list[i + start]
  }
  return ret
}

对于第一个参数要传 Vue 实例或许会有疑问,它是为了处理版本问题而存在的。以刚刚的案例为例,假设 plugin 的 Vue 版本是 1.4, 而 Vue.use 的版本是 2.4,此时版本不一致。

因此传 Vue 的目的就是将 Vue 的构造函数传递给插件中,让所有插件依赖的 Vue 是同一个版本。

总结一下:

  • use 方法目的是将 Vue 的构造函数传递给插件中,让所有插件依赖的 Vue 是同一个版本
  • 默认调用插件或插件的 install 方法
  • vuexvue-routerpackage.json 的依赖里面没有 Vue ,是通过参数传进去的

复习一下

源码中 this 的指向是调用了 use() 方法的 Vue 构造实例。 this 的指向分以下几种:

  • 对象.xxx() ,this 指代对象
  • xxxx() ,this 指代全局
  • new Xxx() ,this 指代的是实例或者构造函数的返回结果
  • call、bind 改 this 指向

插槽

如何实现

何时使用

普通插槽

普通插槽的使用方式如下所示:

html
<div id="app">
    <my>
        <div>
            {{msg}}
        </div>
    </my>
</div>

<script>
    new Vue({
        el: '#app',
        data: {
            msg: 'outer'
        },
        components: {
            my: {
                data() {
                    return {
                        msg: 'inner'
                    }
                },
                template: `<div class="my"><slot></slot></my>`
            }
        }
    })
</script>

此时页面显示的字段是 outer 父组件的变量,可以总结出一点:普通插槽渲染数据采用的是父组件。

为什么会产生这种效果呢?下面我们从原理下手。引入第三方库 vue-template-compiler ,调用其 compile 方法查看 template 模板编译后的 render 长什么样。代码如下:

js
const templateCompiler. = require('vue-template-compiler')

let result = templateCompiler.compile(`<my>
	<div>{{msg}}</div>
</my>`)
console.log(result)

最终打印出来的 render 函数为 _c('my', [_c('div', [_v(_s(msg))])]) 。此代码会立即执行,因此会使用父组件的 data 内的变量。

接着拿到组件的孩子,也就是插槽,调用 Vnode 构造函数,代码如下:

js
new Vnode = {
    'tag': 'my',
    'componentOptions': {
        children: {
            tag: 'div', 'hello'
        }
    }
}

注意

组件的孩子叫插槽,元素的孩子就是孩子

然后创建组件的真实节点,走组件的初始化流程,调用 resolveSlots() 方法,把最终返回结果保存到 vm.$slots 上。该方法的源码如下所示:

js
export function resolveSlots(
  children: Array<VNode> | null | undefined,
  context: Component | null
): { [key: string]: Array<VNode> } {
  // 判断它有没有孩子
  if (!children || !children.length) {
    return {}
  }
  const slots: Record<string, any> = {}
  // 循环所有孩子
  for (let i = 0, l = children.length; i < l; i++) {
    // 拿到每个孩子和其属性
    const child = children[i]
    const data = child.data
    // remove slot attribute if the node is resolved as a Vue slot node
    if (data && data.attrs && data.attrs.slot) {
      delete data.attrs.slot
    }
    // named slots should only be respected if the vnode was rendered in the. same context.
    // 查看孩子的上下文
    if (
      (child.context === context || child.fnContext === context) &&
      data &&
      data.slot != null
    ) {
      const name = data.slot
      const slot = slots[name] || (slots[name] = [])
      if (child.tag === 'template') {
        slot.push.apply(slot, child.children || [])
      } else {
        slot.push(child)
      }
    } else {
      ;(slots.default || (slots.default = [])).push(child)
    }
  }
  // ignore slots that contains only whitespace
  // 循环遍历,查看当前插槽是否有名字
  for (const name in slots) {
    if (slots[name].every(isWhitespace)) {
      delete slots[name]
    }
  }
  return slots
}

最终返回一个映射关系 this.$slots = {default: [儿子虚拟节点]}

在渲染组件真实节点的时候,会采用组件的模板 _t('default') ,源码如下,看看他具体做了什么:

js
// 传了名字 name 为 default
export function renderSlot(
  name: string,
  fallbackRender: ((() => Array<VNode>) | Array<VNode>) | null,
  props: Record<string, any> | null,
  bindObject: object | null
): Array<VNode> | null {
  // 查找作用域插槽的名字,默认default
  const scopedSlotFn = this.$scopedSlots[name]
  let nodes
  if (scopedSlotFn) {
    // scoped slot
    props = props || {}
    if (bindObject) {
      if (__DEV__ && !isObject(bindObject)) {
        warn('slot v-bind without argument expects an Object', this)
      }
      props = extend(extend({}, bindObject), props)
    }
    nodes =
      scopedSlotFn(props) ||
      (isFunction(fallbackRender) ? fallbackRender() : fallbackRender)
  } else {
    nodes =
      this.$slots[name] ||
      (isFunction(fallbackRender) ? fallbackRender() : fallbackRender)
  }

  // 判断有没有slot,有则调用方法,没有直接返回node
  const target = props && props.slot
  if (target) {
    return this.$createElement('template', { slot: target }, nodes)
  } else {
    return nodes
  }
}

总结下来,就是在渲染组件时关联插槽。渲染到插槽后,会转为 _t('default') 之类的 AST 树,再去到 this.$slots 对象内,通过名称 name 找到对应的变量,即儿子的虚拟节点。

总结一下

在解析组件时,会将组件的 children 放到 componentOptions 上作为虚拟节点的属性。

children 取出来放到组件的 vm.$options._renderChildren 中。

做出一个映射表放到 vm.$slots 上,将结果放到 vm.$scopedSlots 上。vm.$scopedSlots = {a:fn(),b:fn,default:fn()}

渲染组件的时候会调用 _t 方法。此时会去 vm.$scopedSlots 找到对应的函数来渲染内容。

具名插槽

把上方案例修改一下,代码如下所示:

html
<div id="app">
    <my>
        <div>
            <p slot="a">{{msg}}</p>
            <p slot="b">{{msg}}</p>
            
            {{msg}}
        </div>
    </my>
</div>

<script>
    new Vue({
        el: '#app',
        data: {
            msg: 'outer'
        },
        components: {
            my: {
                data() {
                    return {
                        msg: 'inner'
                    }
                },
                template: `<div class="my"><slot name="a"></slot><slot name="b"></slot></my>`
            }
        }
    })
</script>

解析 slot 时,会根据标签的 slot 属性来设置对应的对象键名,没有设置的则放到 default 中,如下图所示:

slot

返回之后在 vm.$scopedSlots 对象内就能拿到对应的函数,如下图所示:

返回的函数

后续再去渲染的话就能找到对应的函数。

总结一下

与普通插槽操作一样,不同之处在于多了一个名称 name

作用域插槽

总结一下

作用域插槽渲染的时候不会作为 children ,将作用域插槽做成一个属性 scopedSlots

制作一个映射关系 $scopedSlots = {default:fn:function({msg}) {return _c('div', {}, [_v(_s(msg))])}} 。即把渲染逻辑放到一个函数中。

稍后渲染组件模板的时候,会通过 name 找到对应的函数,将数据传入到函数中,此时才渲染虚拟节点,用这个虚拟节点替换 _t('default')

普通插槽会在父组件内渲染;作用域插槽会在子组件内渲染。

keep-alive 使用与原理

使用

下面看一个场景:

html
<div id="app">
    <button @click="component = 'aa'">
        切换组件a
    </button>
    <button @click="component = 'bb'">
        切换组件b
    </button>
    <keep-alive>
        <component :is="component"></component>
    </keep-alive>
</div>

<script>
    new Vue({
        el: '#app',
        data: {
            component: 'a'
        },
        components: {
            aa: {
                template: `<div>a</div>`
            },
            bb: {
                template: `<div>b</div>`
            }
        }
    })
</script>

原理

其源码如下所示:

js
export default {
  name: 'keep-alive', // 组件名
  abstract: true, // 抽象组件,即不会被记录到 $children 和 $parent 上

  props: {
    include: patternTypes, // 可以缓存哪些组件 ['a','b']
    exclude: patternTypes, // 可以排除哪些组件 ['c']
    max: [String, Number] // 最大缓存个数 6
  },

  methods: {
    cacheVNode() {
      const { cache, keys, vnodeToCache, keyToCache } = this
      if (vnodeToCache) {
        const { tag, componentInstance, componentOptions } = vnodeToCache
        // 缓存中,放置需要缓存的组件。存放组件的实例
        cache[keyToCache] = {
          name: _getComponentName(componentOptions),
          tag,
          componentInstance // 组件实例渲染过会有 $el 属性,下次可以直接复用 $el 属性
        }
        keys.push(keyToCache) // 放入key
        // 超过最大长度会移出第一个
        if (this.max && keys.length > parseInt(this.max)) {
          pruneCacheEntry(cache, keys[0], keys, this._vnode)
        }
        this.vnodeToCache = null
      }
    }
  },

  created() {
    this.cache = Object.create(null) // 弄一个缓存区 {}
    this.keys = [] // 缓存组件的名字有哪些 []
  },

  destroyed() {
    for (const key in this.cache) {
      pruneCacheEntry(this.cache, key, this.keys)
    }
  },

  mounted() {
    // 缓存实例
    this.cacheVNode()
    this.$watch('include', val => {
      pruneCache(this, name => matches(val, name))
    })
    this.$watch('exclude', val => {
      pruneCache(this, name => !matches(val, name))
    })
  },

  updated() {
    this.cacheVNode()
  },

  // 走完created接下来走render,先渲染才能挂载
  render() {
    const slot = this.$slots.default // 取得是默认插槽
    const vnode = getFirstComponentChild(slot) // 获取插槽中第一个虚拟节点
    const componentOptions = vnode && vnode.componentOptions
    if (componentOptions) {
      // 该方法返回 opts.Ctor.options.name ,获取组件的名字
      const name = _getComponentName(componentOptions)
      // 校验是否需要缓存
      const { include, exclude } = this
      // 以下这些情况不复用
      if (
        // not included 不包含
        (include && (!name || !matches(include, name))) ||
        // excluded 排除的
        (exclude && name && matches(exclude, name))
      ) {
        return vnode
      }

      // cache是对象;keys是数组
      const { cache, keys } = this
      // 生成一个唯一的key
      const key =
        vnode.key == null
          ? // same constructor may get registered as different local components
            // so cid alone is not enough (#3269)
            componentOptions.Ctor.cid +
            (componentOptions.tag ? `::${componentOptions.tag}` : '')
          : vnode.key
      // 这个key是否缓存过
      if (cache[key]) {
        // 获取缓存的实例
        vnode.componentInstance = cache[key].componentInstance
        // make current key freshest 把当前的key作为最新的(缓存策略)
        remove(keys, key)
        keys.push(key)
      } else {
        // 没缓存过的则缓存当前的vnode和key
        this.vnodeToCache = vnode
        this.keyToCache = key
      }

      // 给虚拟节点添加 data: keep-alive
      vnode.data.keepAlive = true
    }
    // 最终返回vnode。vnode上有data.keepAlive 和componentInstance,说明组件是缓存过的
    return vnode || (slot && slot[0])
  }
}

其中组件缓存的思路如下图所示:

Least Recently Used

有 a、b、c、d 四个组件,最大缓存数是3:

  • 先访问 a、b、c 三个组件,依次缓存,最久的是a,最近一次的是c
  • 接下来访问d组件,最久的a被销毁,新增d
  • 然后再次访问c,则原先的c销毁,往后新增c,此时从旧到近依次为 b、d、c
  • 再次访问d,则原先的d销毁,后面新增d

当一个组件被 keep-alive 包裹,说明该组件需要被缓存,则其 data 对象内会有一个 keepAlive 属性为 true 。此时 render 方法执行完毕。

接着走到 mounted 方法,缓存组件与其 $el 属性,如果缓存过,后续则直接获取对应的 $el 属性与 vnode 使用。

最后在创建组件时会判断,如果实例上有老的属性,说明该实例是可复用的,直接将缓存的 dom 元素插入,代码如下:

js
function createComponent(vnode, insertedVnodeQueue, parentElm, refElm) {
  let i = vnode.data
  if (isDef(i)) {
    const isReactivated = isDef(vnode.componentInstance) && i.keepAlive
    if (isDef((i = i.hook)) && isDef((i = i.init))) {
      i(vnode, false /* hydrating */)
    }
    if (isDef(vnode.componentInstance)) {
      initComponent(vnode, insertedVnodeQueue)
      insert(parentElm, vnode.elm, refElm)
      if (isTrue(isReactivated)) {
        reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
      }
      return true
    }
  }
}

keep-alive 缓存过的组件有两个新的生命周期,源码如下:

js
insert(vnode: MountedComponentVNode) {
  const { context, componentInstance } = vnode
  if (!componentInstance._isMounted) {
    componentInstance._isMounted = true
    callHook(componentInstance, 'mounted')
  }
  if (vnode.data.keepAlive) {
    if (context._isMounted) {
      queueActivatedComponent(componentInstance)
    } else {
      activateChildComponent(componentInstance, true /* direct */)
    }
  }
},

destroy(vnode: MountedComponentVNode) {
  const { componentInstance } = vnode
  if (!componentInstance._isDestroyed) {
    if (!vnode.data.keepAlive) {
      // 之前是销毁组件
      componentInstance.$destroy()
    } else {
      // 现在调用deactived钩子
      deactivateChildComponent(componentInstance, true /* direct */)
    }
  }
}

总结一下

keep-alive 的原理是默认缓存加载过的组件对应的实例,内部采用了 LRU 算法。

下次组件切换加载的时候,此时会找到对应缓存的节点来进行初始化,但是会采用上次缓存 $el 来触发。(不需要虚拟节点转化成真实节点)

更新和销毁会触发 activeddeactived

自定义指令

源码如下:

js
function _update(oldVnode, vnode) {
  const isCreate = oldVnode === emptyNode // 根据虚拟节点来判断是创建还是删除
  const isDestroy = vnode === emptyNode

  // 拿到老的指令和新的指令
  const oldDirs = normalizeDirectives(
    oldVnode.data.directives,
    oldVnode.context
  )
  const newDirs = normalizeDirectives(vnode.data.directives, vnode.context)

  // 将插入的钩子缓存,等待元素插入后调用
  const dirsWithInsert: any[] = []
  // 缓存componentUpdated
  const dirsWithPostpatch: any[] = []

  let key, oldDir, dir
  for (key in newDirs) {
    oldDir = oldDirs[key]
    dir = newDirs[key]
    if (!oldDir) {
      // 如果没有老的指令,调用bind方法
      callHook(dir, 'bind', vnode, oldVnode)
      if (dir.def && dir.def.inserted) {
        dirsWithInsert.push(dir) // 将instarted钩子存到队列中
      }
    } else {
      // existing directive, update
      dir.oldValue = oldDir.value
      dir.oldArg = oldDir.arg
      callHook(dir, 'update', vnode, oldVnode) // 调用更新
      if (dir.def && dir.def.componentUpdated) {
        dirsWithPostpatch.push(dir) // 缓存componentUpdated钩子
      }
    }
  }

  if (dirsWithInsert.length) {
    const callInsert = () => {
      for (let i = 0; i < dirsWithInsert.length; i++) {
        callHook(dirsWithInsert[i], 'inserted', vnode, oldVnode)
      }
    }
    if (isCreate) {
      mergeVNodeHook(vnode, 'insert', callInsert)
    } else {
      callInsert()
    }
  }

  if (dirsWithPostpatch.length) {
    mergeVNodeHook(vnode, 'postpatch', () => {
      for (let i = 0; i < dirsWithPostpatch.length; i++) {
        callHook(dirsWithPostpatch[i], 'componentUpdated', vnode, oldVnode)
      }
    })
  }

  if (!isCreate) {
    // 删除的话调用unbind方法
    for (key in oldDirs) {
      if (!newDirs[key]) {
        // no longer present, unbind
        callHook(oldDirs[key], 'unbind', oldVnode, oldVnode, isDestroy)
      }
    }
  }
}

总结一下

自定义指令就是用户定义好对应的钩子,当元素在不同的状态时会调用对应的钩子(所有钩子会被合并到 cbs 对应的方法上,到时候依次调用)

事件修饰符

修饰符实现主要靠的是模板编译原理,如 .stop.prevent 等为 addEventListener( stop, defaultPrevent ) 等。编译的时候直接编译到事件内部中。

.passive.captureone 编译的时候会添加标识符。

键盘事件通过源码翻译如下图所示:

键盘

可以看出它在编译的时候做了处理,源码如下所示:

js
const keyCodes: { [key: string]: number | Array<number> } = {
  esc: 27,
  tab: 9,
  enter: 13,
  space: 32,
  up: 38,
  left: 37,
  right: 39,
  down: 40,
  delete: [8, 46]
}

// KeyboardEvent.key aliases
const keyNames: { [key: string]: string | Array<string> } = {
  // #7880: IE11 and Edge use `Esc` for Escape key name.
  esc: ['Esc', 'Escape'],
  tab: 'Tab',
  enter: 'Enter',
  // #9112: IE11 uses `Spacebar` for Space key name.
  space: [' ', 'Spacebar'],
  // #7806: IE11 uses key names without `Arrow` prefix for arrow keys.
  up: ['Up', 'ArrowUp'],
  left: ['Left', 'ArrowLeft'],
  right: ['Right', 'ArrowRight'],
  down: ['Down', 'ArrowDown'],
  // #9112: IE11 uses `Del` for Delete key name.
  delete: ['Backspace', 'Delete', 'Del']
}

// #4868: modifiers that prevent the execution of the listener
// need to explicitly return null so that we can determine whether to remove
// the listener for .once
const genGuard = condition => `if(${condition})return null;`

const modifierCode: { [key: string]: string } = {
  stop: '$event.stopPropagation();',
  prevent: '$event.preventDefault();',
  self: genGuard(`$event.target !== $event.currentTarget`),
  ctrl: genGuard(`!$event.ctrlKey`),
  shift: genGuard(`!$event.shiftKey`),
  alt: genGuard(`!$event.altKey`),
  meta: genGuard(`!$event.metaKey`),
  left: genGuard(`'button' in $event && $event.button !== 0`),
  middle: genGuard(`'button' in $event && $event.button !== 1`),
  right: genGuard(`'button' in $event && $event.button !== 2`)
}

阅读方法

如果是初次阅读,则先了解其目录结构,知道哪个文件夹主要负责什么功能。

然后通过 debugger 打断点,了解API 执行时走了哪些方法。