隔了好多个月,终于想去学习 Vue.js 的源码了。为此我特地去重温 JS 基础点(希望阅读源码之路不要太坎坷),本文不定时更新,且时间跨度较大(大约半年到一年的时间)。加油,奥里给!!!!!!!

本文将参考自慕课网的 Vuejs 源码阅读的实战课程。
以及相关电子书 https://ustbhuangyi.github.io/vue-analysis/

切记!阅读源码切勿心急,别过度最求明白各个细节,第一遍先了解个大概会更好!否则你会卡在一个无底洞的深渊!

源码的版本(特指浏览器运行的版本)

1,runtime 版本和 runtime + compiler 版本的区别?// TODO:仍有疑惑(编译过程的产物各是什么?)

首先从模板(template)到 Dom 的过程是这样的

template --> ast --> render --> vDom --> Dom

1,runtime only

一般我们使用 vue-cli 去构建项目的时候会去使用这个版本,因为我们可以靠 webpack 的 vue-loader 去编译 .vue 文件中的模板(template)最后得到的是一个 render function。

要是我们在实际运行的时候就把 template 和 ast 这两个过程省去,岂不是提高页面性能!没错,正是这样,所以我们会在实际开发中会去使用 runtime only 版本。

2,runtime & compiler

看代码的区别

// 需要编译器的版本,因为有编译过程
new Vue({
  template: '<div>{{ hi }}</div>'
})

// 这种情况不需要,没有编译过程,vue 直接渲染,提升性能。
new Vue({
  render (h) {
    return h('div', {})
  }
})

// render function 的具体用法 https://cn.vuejs.org/v2/guide/render-function.html#createElement-%E5%8F%82%E6%95%B0

2,Vue 定义(通过什么类型去封装的?)

从 src/core/instance/index.js 得知 Vue 是通过 Function 实现的的一个类。

这里有一个小技巧:判断实例是否通过 new 的方式去返回一个对象。

贴上源码:

function Vue (options) {
  if (process.env.NODE_ENV !== 'production' &&
    // 通过 this instanceof Vue 的方式去判断
    !(this instanceof Vue)
  ) {
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  this._init(options)
}

// 将 Vue 作为参数传递到各个不同文件的方法去扩展自身,方便代码的维护。
initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)

export default Vue

如果遇到直接使用 Vue(options) 就会遇到 warning。

为什么这么做?

因为 function Vue 是一个构造函数,而构造函数一般不允许带有返回值,所以需要通过 new 的形式获取一个新的实例。

那么,为什么 Vue 要通过 Function 类型去封装,而不是通过 ES6 的 class 去封装呢?

1,由于构造函数的内容是一系列对 Vue 原型的扩展,如果用 class 实现难度较高。(TODO:以后有机会自己再尝试一下)

2,这么做的好处是非常方便代码的维护和管理。

3,具体都干了啥?(TODO:后续补上)

4,补充:如何调试代码

TODO

Vue 是如何通过数据驱动的方式去修改 Dom ?

1,new Vue(options) 后都发生了什么事?

1,一般开发中我们是如何读取到 data 里的数据?

比如以下代码:

{
  data() {
    message: "hello world"
  },
  mounted() {
    console.log(this.message)
  }
}

从 src/core/instance/index.js 得知在引入 Vue 的时候第一个调用的方法是 initMixin() 这个方法会给 Vue 的原型添加一个 _init() 方法,以供 Vue 进行初始化。

在 initMixin() 里会有一个 mergeOptions() 方法合并示例代码中的 options ,最后返回的结果将挂载在 vm.$options 上。而 data 则被合并到 vm.$options._data 下。

然后再调用 initState() 的方法,里面会有一个 initData() 的方法,里面会通过获取到的 data 对 vm._data 进行赋值。

注意:在此之前会对 data 的类型进行判断。(这就是为什么 data 要使用函数返回对象的形式进行赋值,否则就会触发警告。)而且会对 props 和 methods 的 key 值进行对比(data 的优先级高 其次是 methods 和 props ),因为这些属性都会挂载在当前示例 vm 即 this 上,所以不能重名。

最后就会通过 proxy() 的形式对 vm._data 作一层映射

如代码所示(ps:你需要提前了解 Object.defineProperty() 这个方法):

proxy(vm, "_data", key);

function proxy (target, sourceKey, key) {
  sharedPropertyDefinition.get = function proxyGetter () {
    return this[sourceKey][key]
  };
  sharedPropertyDefinition.set = function proxySetter (val) {
    this[sourceKey][key] = val;
  };
  // 给 vm 的实例对象 添加一个 key 值,当访问到 vm.key 这个值就会触发 sharedPropertyDefinition 中的 getter 和 setter 的方法。
  // 其中的 this 就是指向 target 即 vm 实例。
  // 最后达到通过代理的形式去快速获取 data 中的属性
  Object.defineProperty(target, key, sharedPropertyDefinition);
}

从而实现 this._data.key === this.key 。(PS:一般情况下不建议直接使用 this._data 因为这相当于是私有属性,不符合开发规范!)

2,Vue 是怎么把模板的内容挂载到 Dom 上呢?

官方的 $mount() 的文档说明。https://cn.vuejs.org/v2/api/#vm-mount

前提要点:从 vue 2.0 开始,组件的渲染最终都是要靠 render funtion 。(不管你是用单文件模板,还是使用 template 属性,其结果都是一样)

关键方法 $mount() (但在不同的平台的 vue 代码会有实现上的差异。比如:runtime-with-compiler,runtime-only,weex。)

其实不用想得太复杂,这就是一个把组件的模板内容挂载到某个 Dom 节点上。

因为 runtime-only 版本中没有编译的功能(不能使用 template ),是直接使用 render function 去渲染的。
所以这个版本的 $mount() 是属于公有方法,
其它版本是通过缓存公有方法,再通过重新定义 $mount() 方式根据版本的差异进行自身对象的优化修改,最后通过 call() 的形式去调用回缓存方法以达到代码的复用。(这是一种很棒的编程思想!!!!!!!

公用的 $mount() 部分(即:runtime-only的版本)

// public mount method
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined
  // 返回一个当前渲染组件的实例,且已经将组件渲染到 Dom 上。
  // TODO:需要继续了解 mountComponent 的内部运作原理!
  return mountComponent(this, el, hydrating)
}

这里重点讲 runtime-with-compiler 的版本

// 缓存公用的 $mount() 方法
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
  // el 可以是字符串类型或 dom 类型。
  el?: string | Element,
  hydrating?: boolean
): Component {
  // TODO:查询元素
  el = el && query(el)

  /* istanbul ignore if */
  // 这个判断和警告语可以很清晰的看出不能把组件挂载到 body 以及 html 根元素上。
  if (el === document.body || el === document.documentElement) {
    process.env.NODE_ENV !== 'production' && 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
  // 先判断有没有 render function。若有,直接跳过判断调用 $mount() 。
  if (!options.render) {
    let template = options.template
    if (template) {
      if (typeof template === 'string') {
        // TODO:这是啥?
        if (template.charAt(0) === '#') {
          template = idToTemplate(template)
          /* istanbul ignore if */
          if (process.env.NODE_ENV !== 'production' && !template) {
            warn(
              `Template element not found or is empty: ${options.template}`,
              this
            )
          }
        }
        // TODO:nodeType 是哪里来的
      } else if (template.nodeType) {
        template = template.innerHTML
      } else {
        if (process.env.NODE_ENV !== 'production') {
          warn('invalid template option:' + template, this)
        }
        return this
      }
    } else if (el) {
      // 如果没有 template 只有 el 的话,通过选择器工具获得整个 dom 对象,比如 id=app 的标签,就会直接返回 id=app 的整个标签。
      // TODO:需要自行了解 getOuterHTML 的内部实现原理
      template = getOuterHTML(el)
    }
    if (template) {
      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile')
      }
      // 这里生成 render function
      // TODO:还有细节未完全了解。
      const { render, staticRenderFns } = compileToFunctions(template, {
        outputSourceRange: process.env.NODE_ENV !== 'production',
        shouldDecodeNewlines,
        shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
      }, this)
      options.render = render
      options.staticRenderFns = staticRenderFns

      /* istanbul ignore if */
      // 性能埋点设置
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile end')
        measure(`vue ${this._name} compile`, 'compile', 'compile end')
      }
    }
  }
  // 复用回缓存的 $mount()
  // 巧妙地复用代码
  return mount.call(this, el, hydrating)
}

总结:

1,先判断有没有 render function?若有则直接调用 $mount()

2,然后判断有没有 template 属性,再根据 template 获取到相应的需要渲染的 dom 。(TODO:需要再确定)

3,如果没有 template 只能通过 el 去查找要需要挂载的节点。

4,最后将指定元素进行替换。

3,render function 是啥?

要搭配 Virtual Dom 以及 VNode 联合一起说明会更加清晰。

本节先不贴代码,只讲执行过程,因为内部实现较复杂,以后再回来看也是可以的,刚接触源码先知道其内部的流程就好了。

个人理解:其实 render function 返回的就是一个 VNode 对象,而 VNode 就是 Virtual Dom(概念) 所体现的东西。

首先 render(createElement) 是方便我们去手写一个 VNode,一般的话我们是很少用的,一般都是通过 template 的方式去编译转换成 render() 得到 VNode。

再者通过 createElement() 去描绘一个 dom 标签,或者一个 vue 实例。

官方文档 https://cn.vuejs.org/v2/guide/render-function.html#createElement-%E5%8F%82%E6%95%B0

最后通过 update() 的形式去渲染 dom 到浏览器上。

4,update() 将 VNode 渲染(映射)到 Dom 上。

update 是一个过程,调用的时机有两个(初次渲染、数据更新的时候。这里主要是先讲初次渲染),
其中主要是靠运行 _patch() 去将 VNode 转换成 Dom ,最后通过 insert() 的方式插入到 Dom,最会替换到指定的标签上。

其中 patch 涉及到 createElm 将 VNode 转成 Dom ,如果 VNode 有 Vue 组件还会调用 createComponent 来创建 VNode (其实内部还是通过调用 createElement 去获取 VNode)

最后获取一个完整的 Dom 最后替换到指定位置。

总结:可以从程序流程图看到,Vue 是怎么一步步去修改试图的。

Vue内部运作流程.png

Vue 组件化

1,$createElement() 中的 createComponent()

从文档得知 $createElement() 可以传 string 或 object 的如:

$createElement('div', {
  attrs: {
    id: "app"
  }
}, "hello world!")

$createElement(VueComponent)

都是返回一个 VNode ,而 $createElement() 中就包含了 createComponent 的使用,仅仅是做了一些判断再去调用此方法。

createComponent 会去判断该标签是否合法,比如不能使用:header、h1 这些原生标签。否者会触发警告!

最后再走回 update。

2,深入了解 patch 过程(难度过高,知道其结果就好了,往后回来再看)

3,合并配置(mergeOptions)

分两种场景:1,外部使用 new Vue() 的时候。2,内部创建组件调用 new Vue() 的时候。

其中会有 resolveConstructorOptions() 自动创建内部组件(keep-alive、component、transition、transition-group),这就是为什么这些组件可以直接使用了。

最后会把配置合并挂载在 $options

生命周期

1,各个生命周期具体都干了些什么?

** beforeCreate() 与 created() **

在 _init() 中可以得知

    initLifecycle(vm)
    initEvents(vm)
    initRender(vm)
    callHook(vm, 'beforeCreate')
    initInjections(vm) // resolve injections before data/props
    initState(vm)   // 初始化 props、data、methods、watch、computed
    initProvide(vm) // resolve provide after data/props
    callHook(vm, 'created')

在 beforeCreated() 是无法获得 data method 。

一般发起异步请求,最快的也要在 created() 里发起才行,不过这里还未执行完 $mount() 所以没法操作 dom。

** beforeMount() 与 mounted() **

在 _init() 会调用 $mount() 而该函数在 platforms 下的平台有定义,其中会返回 core/instance/lifecycle 中的 mountComponent() 。
在该方法中会调用到 beforeMount 。最后经历了 patch 到 Dom 后就调用 mounted() 。

** beforeUpdate() 与 updated() **

当数据(渲染方面的?// TODO)修改时会触发的方法。PS:只有在组件已经 mounted 之后,才会去调用这个钩子函数。

先知道是最先子组件触发 mount() 而后才是父组件。

根组件的 mounted() 和子组件的调用时机会有所不同。需要注意一下!

其中设计到 watcher 的概念。// TODO:还不清楚,先跳过

** beforeDestroy() 与 destroyed() **

TODO:调用时机是什么时候?

其实就是用 $destroy() 去把组件内的一些对象设置为 null 即可,方便 GC 回收。

** activated() 与 deactivated() **

TODO

组件的注册

1,全局注册与局部注册的区别

全局注册:

Vue.component(id, definition)

通过 Vue 构造函数中的静态方法去注册组件,经过 mergeOptions 对自身写入静态属性,可以从 Vue.options.components 中看到。之后任意一个子组件的实例都可以通过 vm.$options.components 去查找到局部组件和全局组件信息。

局部注册:

在options中注册:

{
  components: {
    componentName: Component
  }
}

在 mergeOptions 时给自身实例 vm.$options.components 写入组件即可。

2,异步组件(TODO:跳过)

响应式原理

1,啥是响应式对象?

一般而言,通过 Object.defineProperty() 的形式添加属性(即:拥有 setter 与 getter 方法)的对象就被称为响应式对象。

2,Vue 内部是如何去创建响应式对象?

在 _init() 中的 initState() 就有对数据对象进行响应式的设置。

其中的 initData() 与 initProps() 都有用到 proxy() 对数据进行代理(上文中有提到)。

然后再通过 observe() 对 object 且非 VNode 对象添加 Observer 进行观察以监测数据的变化。通过 new Observer() 的方式添加,会发现其对象类型都会多出一个 ob 的 key。

最后根据 Object 类型或 Array 类型通过 defineRactive() 进行响应式对象的设置,用于依赖收集和派发更新。(其实 Array 只是递归给 Object 元素设置响应式对象)

依赖收集

依赖收集是个啥?要它干嘛?

1,依赖收集是用作收集当前 Vue 实例中一切的响应式对象(相当于 data 中的所有值)的一个过程。其实就是在 defineReactive() 中的 setter 方法。

2,目的就是为了以后更新组件内容做好准备。

其实就在 $mount() 的过程中就通过 mountComponent() 进行第一次依赖收集,然后再走 update 以达到渲染 dom 的目的。

TODO(该环节过于复杂,能力有限,以后继续补上)

派发更新

过程是怎么样?

1,判断需要更新的是哪一个依赖,并找到目标依赖以进行 update 。

视图的更新方式 nextTick (即异步更新)

VueJS 中为什么要用异步更新?

参考自官方文档

异步更新队列:https://cn.vuejs.org/v2/guide/reactivity.html#%E5%BC%82%E6%AD%A5%E6%9B%B4%E6%96%B0%E9%98%9F%E5%88%97

如果在某一个时间不断的操作同一个 dom 节点数据被修改多次,这样的 dom 操作的性能成本是昂贵的,所以 Vue 会通过队列的形式去更新 dom ,当某个实例更新多次只取最后一次更新的 watcher 去进行更新。以避免资源浪费的情况。在下一次任务轮询的时候再去执行队列中的内容。

实现的方式(略过)

监测数据变化的弊端

Object.defineProperty() 的劣势?(TODO)

怎么解决 Array Object 对象内容新增或删除所没法监测的问题?(TODO)

计算属性 computed

还是通过 computed watcher 去监听数据变化,若有变化则重新求值。

侦听属性 watch

其实就是在调用 vm.$watch() 创建的 user watcher ,若数据变化则调用 handler callback。

好累呀!先暂停一段落。2020-07-28

先交替一下去学 TS 和 nest.js 先。过段时间后再回来。

小技巧(在阅读源码中找到的)

空函数 noop 的作用?

”发布-订阅“ 设计模式

判断 JS 对象是否存在的方案。

typeof myObj == “undefined”

最常见的方案,不会引起报错。

vue-router 的各种钩子与执行顺序。



Vue 源码

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!