Vue源码阅读笔记之项目结构和Vue对象(一)

vue是我接触到的第一个MVVM框架,在工作中使用也比较频繁。早前曾尝试过阅读源码,奈何功力不够,草草了事,收获的东西有限,现在决定重新阅读Vue及相关技术栈(vue-routervuexaxios等)源码,并整理相关知识。除了加深对于Vue的理解之外,还希望能够提升阅读源码的能力~

<!--more-->

此次阅读的是vue最新的版本是2.5.9,后续版本变更可能会导致摘取的相关代码不一致~

参考:

1. 预备知识

1.1. 平台差异

由于Vue有多个运行平台,包括浏览器、node环境(SSR渲染)、Weex等,因此在源码中有一些平台差异性的代码。这次阅读源码的首要目的是浏览器端的代码,然后了解SSR渲染的原理和实现。

1.2. 开发环境搭建

运行开发者模式

为了进行调试我们需要将项目跑起来,整个环境的搭建十分简单

  • 把项目fork并克隆到本地,
  • npm i然后npm run dev进入开发者模式,会监听文件的变化然后修改/dist/目录下的输出
  • 新建一个index.html文件然后引入源码文件/dist/vue.js
  • 修改/src/core/index.js代码,添加一个console.log("hello vue")
  • 即可在浏览器控制台发现对应的输出,OK,现在可以愉快的进行阅读和调试了

打开package.json文件,可以发现还有一些其他的开发指令,针对不同的开发者模式,诸如dev:weex等,这里暂时先不研究了。

PS:由于加载的是编译后的文件,因此无法在源文件中使用webstrom与chrome的断点工具进行调试,我采用的是手动添加debugger关键字断点。还希望有其他好的调试方法的朋友指点一下。

flow

flow官网上的介绍是

Flow is a static type checker for your JavaScript code. It does a lot of work to make you more productive. Making you code faster, smarter, more confidently, and to a bigger scale.

由于源码中均使用flow作为类型检测,因此需要掌握一些基本语法,避免阅读障碍。

知乎上尤大亲自回答了这个问题。貌似vue最近添加了对于TypeScript的支持~

rollup

rollup是一个JS模块打包工具,与webpack类似。

这里倒是不需要深入其工作原理,这里是rollup.js教程传送门。

babel和eslint

在修改源码时记得遵循eslint规范,或者直接修改其配置~

2. 项目结构

记得大佬跟我说过,阅读源码时先理解作者的设计思想,了解整体结构,切勿钻进某个细节实现。

npm run dev这个命令入手,我们一步一步追踪代码的编译过程

"dev": "rollup -w -c build/config.js --environment TARGET:web-full-dev",

找到build/config.js,这是rollup的配置文件,对应的环境变量为TARGET:web-full-dev

'web-runtime-dev': {
    entry: resolve('web/entry-runtime.js'),
    dest: resolve('dist/vue.runtime.js'),
    format: 'umd',
    env: 'development',
    banner
  },

这里resolve函数为路径设置了别名,找到了项目的入口文件web/entry-runtime-with-compiler.js,实际上是/src/platforms/web/entry-runtime-with-compiler.js

import Vue from './runtime/index'

一步一步追踪

import Vue from 'core/index'

同理,这里的路径别名是/src/core/index.js,距离真相越来越近了

import Vue from './instance/index'

最后,我们终于找到了Vue构造函数

function Vue (options) {
  this._init(options)
}

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

export default Vue

下面几个方法的作用是为Vue.prototype上添加原型属性和方法,最后导出了Vue构造函数,在构造函数内部调用了Vue.prototype._init方法。

现在回头看看上面几个文件的作用

  • /src/core/instance/index.js,向Vue.prototype添加属性和方法
  • /src/core/index.js,通过initGlobalAPI(Vue)向Vue添加静态属性和方法
  • /src/platforms/web/runtime/index.js,添加了一些平台的特性方法及Vue.config
  • /src/platforms/web/entry-runtime-with-compiler.js,覆盖了Vue.prototype.$mountVue.compile

至此,我们了解了项目的大致结构,通过模块文件组织整个项目,Vue的源码是十分整洁的。接下来再逐步深入每个模块的细节,首先来看看Vue实例对象的属性和方法是如何挂载。

3. Vue原型及对象

有趣的是Vue是通过将构造函数作为参数传入不同模块函数,从而实现在原型上挂载属性和方法。

对着前面的路径图,发掘Vue原型上的属性和方法

// /src/core/instance/index.js
function Vue (options) {
  if (process.env.NODE_ENV !== 'production' &&
    !(this instanceof Vue)
  ) {
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  this._init(options)
}

initMixin(Vue)
// Vue.prototype._init

stateMixin(Vue)
// Object.defineProperty(Vue.prototype, '$data', dataDef)
// Object.defineProperty(Vue.prototype, '$props', propsDef)
// Vue.prototype.$set
// Vue.prototype.$delete
// Vue.prototype.$watch

eventsMixin(Vue)
// Vue.prototype.$on
// Vue.prototype.$once
// Vue.prototype.$off
// Vue.prototype.$emit

lifecycleMixin(Vue)
// Vue.prototype._update
// Vue.prototype.$forceUpdate
// Vue.prototype.$destroy

renderMixin(Vue)
// Vue.prototype.$nextTick
// Vue.prototype._render

然后是Vue构造函数的全局属性和方法,这是通过initGlobalAPI(Vue)注册的

// /src/core/global-api/index.js

Object.defineProperty(Vue, 'config', configDef)
Vue.util
Vue.set
Vue.delete
Vue.nextTick
Vue.options

// ASSET_TYPES = [
//     'component',
//     'directive',
//     'filter'
// ]
ASSET_TYPES.forEach(type => {
    Vue.options[type + 's'] = Object.create(null)
})

Vue.options._base
extend(Vue.options.components, builtInComponents)

initUse(Vue)
// Vue.use
initMixin(Vue)
// Vue.mixin
initExtend(Vue)
// Vue.extend
initAssetRegisters(Vue)
// Vue[ASSET_TYPES[i]] 对应Vue.component, Vue.directive, Vue.filter

然后根据不同的运行环境,添加一些具有平台特性的属性的方法

// /src/platforms/web/runtime/index.js
Vue.config.mustUseProp
Vue.config.isReservedTag
Vue.config.isReservedAttr
Vue.config.getTagNamespace
Vue.config.isUnknownElement

extend(Vue.options.directives, platformDirectives)
extend(Vue.options.components, platformComponents)

Vue.prototype.__patch__
Vue.prototype.$mount 

快到终点啦

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

// 对mount方法进一步扩展
Vue.prototype.$mount

自此,我们顺着各个模块,从构造函数开始,基本理清了Vue及其原型相关的属性和方法。这里强烈建议先去官网API文档了解相关的属性方法的含义。

4. Vue实例

大致了解了Vue原型之后,接下来看看vue实例的属性和方法。

在构造函数中,我们发现调用了this._init(options)方法,即Vue.prototype._init方法,我们现在开始深入这个方法。

4.1. 合并配置参数

首先遇见的第一个问题就是合并配置参数

vm.$options = mergeOptions(
    resolveConstructorOptions(vm.constructor), // 实际上返回Vue.options
    options || {}, // 我们传入的配置参数
    vm
)

这个mergeOptions的工具函数就是用来合并配置参数并将结果挂载到vm.$options上的,非常重要。参看文档,Vue允许我们自定义合并策略的选项。所谓自定义策略就是允许我们自己决定如何合并Vue.options和传入的这两个配置参数。

先来看看源码

// /src/core/util/options.js
const strats = config.optionMergeStrategies

// strats的每个属性都是配置对象上相关键值的合并策略
strats.el = strats.propsData = function (parent, child, vm, key) {}
strats.data = function (parentVal: any, childVal: any, vm?: Component): ?Function {}
// 合并相关的声明周期钩子函数
LIFECYCLE_HOOKS.forEach(hook => {
  strats[hook] = mergeHook
})
// 合并component、directive和filter
ASSET_TYPES.forEach(function (type) {
  strats[type + 's'] = mergeAssets
})
// 合并watch,
// 这里注释提到Watchers hashes should not overwrite one,so we merge them as arrays.
strats.watch = function (parent, child, vm, key) {}
strats.props = strats.methods = strats.inject = strats.computed = function (parent, child, vm, key) {}

strats.provide = mergeDataOrFn

// 默认的合并策略,如果传入的配置参数存在,则直接返回,否则返回vue.options
const defaultStrat = function (parentVal: any, childVal: any): any {
  return childVal === undefined
    ? parentVal
    : childVal
}

export function mergeOptions (
  parent: Object,
  child: Object,
  vm?: Component
): Object {  const options = {}
  // ....
  for (key in parent) {
    mergeField(key)
  }
  for (key in child) {
    if (!hasOwn(parent, key)) {
      mergeField(key)
    }
  }
  // 这里就是调用相关的合并策略,然后合并字段
  function mergeField (key) {
    const strat = strats[key] || defaultStrat
    options[key] = strat(parent[key], child[key], vm, key)
  }
  return options
}

策略对象内置了基本属性的合并策略,此外我们也可以通过Vue.config.optionMergeStrategies实现自定义属性的合并策略。上面策略对象的相关属性想必大家都不陌生,不论是在组件还是在实例的构建中,上面的参数都或多或少有使用过。

配置参数的合并结果最终会以vm.$options = options的形式关联到vm实例上,至此我们对于配置参数是如何绑定到vm实例上的应该有了一个大致的印象。

那么,为什么要通过这么绕的方式来合并配置参数呢?往常用过的插件中,往往只是提供一个默认的配置参数,然后通过传入的配置参数简单覆盖即可?

我的理解是:由于Vue允许使用自定义属性,这样可以方便扩展~对应文档中的使用方法,可以精准到为每个字段设置不同的合并策略,下面是文档中的一个demo

Vue.config.optionMergeStrategies._my_option = function (parent, child, vm) {
  return child + 1
}

const Profile = Vue.extend({
  _my_option: 1
})

// Profile.options._my_option = 2

4.2. 实例属性和方法

现在我们重新回到Vue.prototype._init这个方法中,实际上整个构造函数就是为实例添加相关的属性,把代码简化之后是下面的形式,追踪到每个初始化工具函数中

// /src/core/instance/init.js

const vm = this
vm._uid = uid++
vm._isVue = true

// merge options
if (options && options._isComponent) {
  initInternalComponent(vm, options)
} else {
  vm.$options = mergeOptions(
    resolveConstructorOptions(vm.constructor),
    options || {},
    vm
  )
}

initProxy(vm)
// vm._renderProxy = new Proxy(vm, handlers)

vm._self = vm

initLifecycle(vm)
// vm.$parent = parent
// vm.$root = parent ? parent.$root : vm
//
// vm.$children = []
// vm.$refs = {}
//
// vm._watcher = null
// vm._inactive = null
// vm._directInactive = false
// vm._isMounted = false
// vm._isDestroyed = false
// vm._isBeingDestroyed = false

initEvents(vm)
// vm._events = Object.create(null)
// vm._hasHookEvent = false

initRender(vm)
// vm._vnode = null 
// vm._staticTrees
// vm.$slots 
// vm.$scopedSlots
// vm._c
// vm.$createElement
// defineReactive

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

initInjections(vm) // resolve injections before data/props
// defineReactive

initState(vm)
// vm._watchers
// initProps
// initMethods
// initData
// initComputed
// initWatch

initProvide(vm) // resolve provide after data/props
// vm._provided

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

// 将vm实例挂载到el上,进行渲染
if (vm.$options.el) {
  vm.$mount(vm.$options.el)
}

至此,我们了解到了vm实例上面相关属性的来源。

其中,在initState方法中,会根据vm.$options的相关属性,执行下面 的方法

// /src/core/instance/state.js
vm._watchers = []
const opts = vm.$options
if (opts.props) initProps(vm, opts.props)
if (opts.methods) initMethods(vm, opts.methods)
if (opts.data) {
  initData(vm)
} else {
  observe(vm._data = {}, true /* asRootData */)
}
if (opts.computed) initComputed(vm, opts.computed)
if (opts.watch && opts.watch !== nativeWatch) {
  initWatch(vm, opts.watch)
}

事实上,在最简单的Hello WorldDemo中,上面的不少工作都会被跳过

<div id="app">
  {{ msg }}
</div>
<script>
  let vm = new Vue({
    el: "#app",
    data: {
      msg: "Hello World"
    },
  })
</script>

只需要简单的改变vm.$data.msg的值,就可以观察到Vue的响应式工作了,查看框架的工作流程可以发现这里调用的是initData(vm)方法,相关的分析在下一章进行。

5. 小结

虽然在工作中一直在使用Vue,却对于内部的运行机制不是很了解,这加上Vue的入门比较简单,让我有种难以言喻的危机感。

网上现在有不少关于Vue源码分析的教程,有的写得挺不错的,但终归是别人总结的,如果没有真正阅读代码,肯定会漏掉一些东西,加上Vue的版本迭代也比较快,因此决定自己尝试进行源码分析,水平有限,慢慢来折腾吧~

###