Vue源码系列(二):Vue初始化都做了什么?🔥🔥

7,834 阅读8分钟

只有特别努力,才能看起来毫不费力!

前言

这是一个Vue源码系列文章,建议从第一篇文章 Vue源码系列(一):Vue源码解读的正确姿势 开始阅读。 文章是我个人学习源码的一个历程,这边分享出来希望对大家有所帮助。

文章持续更新中,期望点赞加关注 🙏

查找Vue构造函数

在上一篇文章 (Vue源码系列(一):Vue源码解读的正确姿势) 中已经写了一个测试案例,并且关联到了源码文件。

接下来将说两种找到vue构造函数的方法:

debugger 查找

debugger查找这种方法比较简单,但很不程序员,不用一步一步的去查找,逻辑不够严谨。
具体如何debugger查找,从上一篇文章中的断点(debugger)开始说起。如图就是执行到断点的代码

image.png

接下来为了照顾不会打断点的同学,说一下上图中,右上角的几个断点调试涉及到的图标的作用

WX20210923-111200@2x.jpg

  • 图标1:resume script execution - 恢复脚本执行,也就是跳过断点(debugger)继续执行脚本的意思
  • 图标2:step over next function call - 遇到函数,会直接执行完这个函数,进入下一步,不显示具体执行函数的细节。
  • 图标3:step into next function call - 遇到函数,不会直接运行完这个函数,而是进入函数内部,一步一步地执行,显示具体执行函数的细节。
  • 图标4:step out of current function - 利用图标3进入函数内部,我们可以通过此图标功能来执行完函数内部剩下的代码。
  • 图标5:step - 一步一步的来且遇到函数进入,可理解为图标2图标3 的结合
  • 图标6:deactivate breakpoints - 停用断点,就是debugger不起作用了😉
  • 图标7:don’t pause on exceptions - 不暂停异常,就是取消谷歌浏览器的异常捕获,需要勾选☑️ Pasue on caught exceptions

如图点击两次图标5我们就找到了Vue的构造函数

image.png

那么如何找到图中 Vue的构造函数 在源码中的位置呢?
只需要右键选择 Reveal in sidebar (在侧边栏中显示) 就可以看到源码中 Vue的构造函数 的位置了。

image.png

如图源码位置在:vue —> src —> core —> instance —> index.js 中。

...
function Vue (options) {
    // 在非生产环境(开发环境)下, 如果不是使用new来调用这个构造器则会报警告 忽略
    // if (process.env.NODE_ENV !== 'production' &&
    //   !(this instanceof Vue)
    // ) {
    //   warn('Vue is a constructor and should be called with the `new` keyword')
    // }
    this._init(options)
}
...

然后继续点击两次图标5,再右键选择 Reveal in sidebar (在侧边栏中显示) 就可以看到源码中 this._init(options) 被定义的位置了。

image.png

这种方法轻松简单就能找到我们的Vue构造函数。

从打包的入口文件开始层层查找

首先先看一下咱们运行项目的命令相关代码,找一下打包的入口文件:

// package.json
"scripts": {
    "dev": "rollup -w -c scripts/config.js --environment TARGET:web-full-dev --sourcemap"
    ......
}

从代码可以看出运行 npm run dev 是的一些信息,打包使用的是:rollup, 配置文件走的是:scripts/config.js,环境变量中 TARGET中的值为:web-full-dev

那么接下来找到 config.js 中的 web-full-dev,我们就能找到打包的入口文件:

image.png

做一下代码分析:

// 运行时+编译器的一个开发版本(代码比较大,信息比较全,适合学习)

...
'web-full-dev': {
    // 入口文件
    entry: resolve('web/entry-runtime-with-compiler.js'),
    dest: resolve('dist/vue.js'),
    format: 'umd',
    env: 'development',
    alias: { he: './entity-decoder' },
    banner
},
...

针对上面的resolve方法也做一下解析

const aliases = require('./alias')
// 整个resolve方法底层依赖的是path.resolve()方法
const resolve = p => {
  const base = p.split('/')[0] // 根据上面的传参"/"前面的就是"web" 所以 base = web
  if (aliases[base]) {
    // 映射的 "aliases[base]" 需要到 "require('./alias')" 中查看,具体映射的地址是:"src/platforms/web"
    // "p.slice(base.length + 1)"其实就是上面的传参 "/" 后面的内容
    return path.resolve(aliases[base], p.slice(base.length + 1))
  } else {
    return path.resolve(__dirname, '../', p)
  }
}

因此真正的打包入口地址就是:src/platforms/web/entry-runtime-with-compiler.js

接着继续分析一下打包的入口文件: 首先说明一下,文件分析以注释为主,且所有性能监控的代码都不做考虑 istanbul ignore if,所有的提示文案( 如:warn() )直接删除,不做解释。

// src/platforms/web/entry-runtime-with-compiler.js

import config from 'core/config'
import { warn, cached } from 'core/util/index'
import { mark, measure } from 'core/util/perf'
// 文件本身没有定义 Vue, 而是依赖./runtime/index的Vue
import Vue from './runtime/index'
import { query } from './util/index'
import { compileToFunctions } from './compiler/index'
import { shouldDecodeNewlines, shouldDecodeNewlinesForHref } from './util/compat'

// 获取宿主元素的方法
const idToTemplate = cached(id => {
  const el = query(id)
  return el && el.innerHTML
})

// 扩展 $mount 方法
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && query(el)
  // 获取选项 $options 
  const options = this.$options
  // resolve template/el and convert to render function
  // 如果 render 选项不存在
  if (!options.render) {
    // 则查找 template
    let template = options.template
    // 如果 template 存在,
    if (template) {
      // 则判断一下 template 的写法
      if (typeof template === 'string') { // 如果是字符串模板 例如:"<div> template </div>"
        if (template.charAt(0) === '#') { // 如果是宿主元素的选择器,例如:"#app"
          // 则调用上面的 idToTemplate() 方法查找
          template = idToTemplate(template)
        }
      // 如果是一个dom元素
      } else if (template.nodeType) {
        // 则使用它的 innerHTML
        template = template.innerHTML
      } else {
        return this
      }
    // 如果设置了 el 
    } else if (el) {
      // 则以 el 的 outerHTML 作为 template
      template = getOuterHTML(el)
    }
    // 如果存在 template 选项,则编译它获取 render 函数
    if (template) {
      // 编译的过程:把 template 变为 render 函数
      const { render, staticRenderFns } = compileToFunctions(template, {
        outputSourceRange: process.env.NODE_ENV !== 'production',
        shouldDecodeNewlines,
        shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
      }, this)
      // 最终获得的 render 函数将赋值给 选项 options
      options.render = render
      options.staticRenderFns = staticRenderFns
    }
  }
  // 执行默认的挂载
  return mount.call(this, el, hydrating)
}
/**
 * 总结一下:
 * new Vue({
 *    el: "#app",
 *    template: "<div> template </div>",
 *    template: "#app",
 *    render(h){ return h("div", "render")},
 *    data: {}
 * })
 *  在用户同时设置了 el、template、render的时候,优先级的判断为:render > template > el
 */ 

// 获取 outerHTML 的方法
function getOuterHTML (el: Element): string {
  if (el.outerHTML) {
    return el.outerHTML
  } else {
    const container = document.createElement('div')
    container.appendChild(el.cloneNode(true))
    return container.innerHTML
  }
}

Vue.compile = compileToFunctions

export default Vue

上面 entry-runtime-with-compiler.js 文件中的Vue来自于 './runtime/index',那我们自己分析 './runtime/index' 文件

// 能看到 Vue也不是在这里定义的,一样是导入的,那么这个文件主要做了什么呢?

import Vue from 'core/index'
import config from 'core/config'
import { extend, noop } from 'shared/util'
import { mountComponent } from 'core/instance/lifecycle'
import { devtools, inBrowser } from 'core/util/index'

import {
  query,
  mustUseProp,
  isReservedTag,
  isReservedAttr,
  getTagNamespace,
  isUnknownElement
} from 'web/util/index'

import { patch } from './patch'
import platformDirectives from './directives/index'
import platformComponents from './components/index'

// install platform specific utils
// 这是一些配置,不重要,直接跳过
Vue.config.mustUseProp = mustUseProp
Vue.config.isReservedTag = isReservedTag
Vue.config.isReservedAttr = isReservedAttr
Vue.config.getTagNamespace = getTagNamespace
Vue.config.isUnknownElement = isUnknownElement

// install platform runtime directives & components
// 这是一些扩展,不重要,直接跳过
extend(Vue.options.directives, platformDirectives)
extend(Vue.options.components, platformComponents)

// install platform patch function
// 安装了一个 patch 函数,也可以叫补丁函数或者更新函数。主要的作用就是把:虚拟dom 转化为真实的dom(vdom => dom)
Vue.prototype.__patch__ = inBrowser ? patch : noop

// public mount method
// 实现了 $mount 方法:其实就只调用了一个mountComponent()方法
// $mount的最终目的就是:把虚拟dom 转化为真实的dom,并且追加到宿主元素中去(vdom => dom => append)
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}

继续查看文件中 Vue的依赖文件 "core/index"

// src/core/index.js 

// 同样的 Vue 不是在文件中定义的,一样是导入的,同样的也看看这个文件主要做了什么呢?
import Vue from './instance/index'
import { initGlobalAPI } from './global-api/index'
import { isServerRendering } from 'core/util/env'
import { FunctionalRenderContext } from 'core/vdom/create-functional-component'

// 整个文件其实主要就做了一件事:初始化全局的静态API:Vue.use/set/component/delete......等方法
initGlobalAPI(Vue)
...

继续查看文件中 Vue的依赖文件 "./instance/index"

// src/core/instance/index.js

import { initMixin } from './init'
import { stateMixin } from './state'
import { renderMixin } from './render'
import { eventsMixin } from './events'
import { lifecycleMixin } from './lifecycle'
import { warn } from '../util/index'

// 没有 Vue 的导入,所以可以判定这个文件就是对 Vue的定义
// Vue构造函数的声明
function Vue (options) {
  // 初始化方法,
  this._init(options)
}

// 从文件中可以看出 上面的 _init() 是从下面的混入中获得的,那么具体从哪个中得到的咱们分析一下

// 初始化混入
initMixin(Vue)
// state的混入
stateMixin(Vue)
// events的混入
eventsMixin(Vue)
// 生命周期的混入
lifecycleMixin(Vue)
// 渲染函数的混入
renderMixin(Vue)

// 上面的这些混入其实就是初始化实例的方法和属性
// 其实通过名字咱们就不难发现,  _init() 方法肯定是在 初始化的混入中:initMixin()

export default Vue

其实通过名字咱们就不难发现,_init() 方法肯定是在 初始化的混入中:initMixin(), 那就继续看 initMixin() 所在的文件。

源码分析

如下代码中 Vue.prototype._init 方法,也就是今天我们聊的主题:Vue初始化都做了什么?所要说的东西。

// src/core/instance/init.js

...
export function initMixin (Vue: Class<Component>) {

  // 接受用户传进来的选项:options
  Vue.prototype._init = function (options?: Object) {
  
    // vue的实例
    const vm: Component = this
    
    // vue实例标识符 _uid标志
    vm._uid = uid++

    // vue标志, 避免被 Observe 观察
    vm._isVue = true
    
    // 选项合并:用户选项和系统默认的选项需要合并
    // 处理组件的配置内容,将传入的options与构造函数本身的options进行合并(插件的策略都是默认配置和传入配置进行合并)
    if (options && options._isComponent) {
      // 子组件:优化内部组件(子组件)实例化,且动态的options合并相当慢,这里只有需要处理一些特殊的参数属性。减少原型链的动态查找,提高执行效率
      initInternalComponent(vm, options)
    } else {
      // 根组件: 将全局配置选项合并到根组件的配置上,其实就是一个选项合并
      vm.$options = mergeOptions(
        // 获取当前构造函数的基本options
        resolveConstructorOptions(vm.constructor), 
        options || {},
        vm
      )
    }
    
    // expose real self
    vm._self = vm
    
    // 下面的方法才是整个初始化最重要的核心代码
    initLifecycle(vm) // 初始化vue实例生命周期相关的属性,组件关系属性的初始化,定义了比如:$root、$parent、$children、$refs
    initEvents(vm) // 初始化自定义组件事件的监听,若存在父监听事件,则添加到该实例上
    initRender(vm) // 初始化render渲染所需的slots、渲染函数等。其实就两件事1、插槽的处理、2、$createElm 也就是 render 函数中的 h 的声明
    
    callHook(vm, 'beforeCreate') // 调用生命周期的钩子函数,在这里就能看出一个组件在创建之前和之后分别做了哪些初始化
    
    // provide/inject 隔代传参
    // provide:在祖辈中可以直接提供一个数据 
    // inject:在后代中可以通过inject注入后直接使用
    initInjections(vm) // 隔代传参时 先inject。作为一个组件,在要给后辈组件提供数据之前,需要先把祖辈传下来的数据注入进来
    initState(vm) // 对props,methods,data,computed,watch进行初始化,包括响应式的处理
    initProvide(vm) // 在把祖辈传下来的数据注入进来以后 再provide
    // 总而言之,上面的三个初始化其实就是:对组件的数据和状态的初始化
    
    callHook(vm, 'created') // created 初始化完成,可以执行挂载了
    
    // 例绑定到对应DOM元素上
    // 组件构造函数设置了el选项,会自动挂载,所以就不用再$mount去挂载
    // 组件构造函数没有设置el的, $mount过程不在这里
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }
}
...

Vue初始化都做了什么?

到此整个Vue初始化就完成了,具体的细化代码之后的文章会依依总结。
下面将对 Vue初始化都做了什么,也就是本篇文章做一个总结:

1、选项合并,处理组件的配置内容,将传入的options与构造函数本身的options进行合并(用户选项和系统默认的选项进行合并)

2、初始化vue实例生命周期相关的属性,定义了比如:rootroot、parent、childrenchildren、refs

3、初始化自定义组件事件的监听,若存在父监听事件,则添加到该实例上

4、初始化render渲染所需的slots、渲染函数等。其实就两件事:插槽的处理 和 $createElm的声明,也就是 render 函数中的 h 的声明

5、调用 beforeCreate 钩子函数,在这里就能看出一个组件在创建前和后分别做了哪些初始化

6、初始化注入数据,隔代传参时 先inject。作为一个组件,在要给后辈组件提供数据之前,需要先把祖辈传下来的数据注入进来

7、对props,methods,data,computed,watch进行初始化,包括响应式的处理

8、在把祖辈传下来的数据注入进来以后 再初始化provide

9、调用 created 钩子函数,初始化完成,可以执行挂载了

10、挂载到对应DOM元素上。如果组件构造函数设置了el选项,会自动挂载,所以就不用再手动调用$mount去挂载


结语

到此整个初始化过程就结束了,下一章将是 响应式原理的解析

文章完成再把链接贴上来,希望大家多多支持,欢迎点赞加关注🙏