二. Vue源码之初始化上篇

物有本末,事有终始,知所先后,则近道矣 ---《大学》

在分析Vue初始化之前,我们先看看Vue源码的目录结构:


二. Vue源码之初始化上篇_第1张图片
Vue源码结构.png

其中我们重点关注的是compiler(编译部分)、core(核心模块)、platforms(平台相关的 我们重点看web),这三部分就已经包含了我们最为常用的功能。我们先从入口看起,不过在此之前,我们先从最简单的一个例子看起:

html:
 
{{message}}
js: new Vue({ el: '#demo', data: { message:"hello Vue!" } })

执行完毕之后页面展示hello Vue,显然data里面的message和html似乎建立起了一种关联关系,当我们修改data里面的message时候便会发现html元素也会变化(甚至还有双向绑定---即html元素变化,data里面的message也会变化;这个后面单独拿一篇文章细聊实现)。这个思路和我们之前用Jquery操作完全不同,之前我们都是指哪儿打哪儿的,想修改dom直接选择dom节点操作,现在Vue不是这样了,他相当于给dom节点建立一个数据模型,我们不能直接动dom了,想修改dom操作数据模型就可以了。那我们就从入口说起,显然new Vue相当于实例化一个对象,那开头先执行的必定是构造函数了,如下所示(core/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)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)

Vue源码中我们可能会碰到很多process这个对象,这个其实是nodejs中的对象,主要是npm在构建最终vue包时可以根据当前环境给一些提示,对我们理解源码其实没啥太大作用,所以实际上如果去掉process部分,上面Vue的构造函数就一行代码:this._init(options)
然后下面有五行***Mixin,这五个函数分别在Vue原型上添加了各种方法,比如我们调用this._init,这个__init方法为啥能调用呢?就是因为在initMixin中将__init方法加到Vue原型上了:

function initMixin (Vue) {
  Vue.prototype._init = function (options) {
      ....
  }
}

其他几个函数也是类似,在Vue.prototype上挂了很多方法,比如stateMixin中:

function stateMixin (Vue) {
... 
  Object.defineProperty(Vue.prototype, '$data', dataDef);
  Object.defineProperty(Vue.prototype, '$props', propsDef);

  Vue.prototype.$set = set;
  Vue.prototype.$delete = del;

  Vue.prototype.$watch = function ()
....
}

我们可以看到在这里面在Vue原型挂了很多我们熟悉的方法,比如$set/$watch等等,所以我们经常才能这么用this.$watch(...)。
我们继续看this._init(options),那options是什么呢?在我们给的例子当中,options就是传入的:

{
  el: '#demo',
  data: {
      message:"hello Vue!"
  }
}

上文分析过this._init方法是在initMixin里面定义的,所以下一步就看core/instance/init.js中的_init函数了(如果是用VSCode看源码的话 按住Ctrl键 然后鼠标点击某个函数就进入了这个函数的实现):

  Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
    // a uid
    vm._uid = uid++

    let startTag, endTag
    /* istanbul ignore if */
    if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
      startTag = `vue-perf-start:${vm._uid}`
      endTag = `vue-perf-end:${vm._uid}`
      mark(startTag)
    }

    // a flag to avoid this being observed
    vm._isVue = true
    // merge options
    if (options && options._isComponent) {
      // optimize internal component instantiation
      // since dynamic options merging is pretty slow, and none of the
      // internal component options needs special treatment.
      initInternalComponent(vm, options)
    } else {
      vm.$options = mergeOptions(
        resolveConstructorOptions(vm.constructor),
        options || {},
        vm
      )
    }
    /* istanbul ignore else */
    if (process.env.NODE_ENV !== 'production') {
      initProxy(vm)
    } else {
      vm._renderProxy = vm
    }
    // expose real self
    vm._self = vm
    initLifecycle(vm)
    initEvents(vm)
    initRender(vm)
    callHook(vm, 'beforeCreate')
    initInjections(vm) // resolve injections before data/props
    initState(vm)
    initProvide(vm) // resolve provide after data/props
    callHook(vm, 'created')

    /* istanbul ignore if */
    if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
      vm._name = formatComponentName(vm, false)
      mark(endTag)
      measure(`vue ${vm._name} init`, startTag, endTag)
    }

    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }

看上去很长,但是去掉process以及部分打日志的代码,其实这个函数干的事主要是:

    if (options && options._isComponent) {
      // optimize internal component instantiation
      // since dynamic options merging is pretty slow, and none of the
      // internal component options needs special treatment.
      initInternalComponent(vm, options)
    } else {
      vm.$options = mergeOptions(
        resolveConstructorOptions(vm.constructor),
        options || {},
        vm
      )
    }
    vm._renderProxy = vm
    vm._self = vm
    initLifecycle(vm)
    initEvents(vm)
    initRender(vm)
    callHook(vm, 'beforeCreate')
    initInjections(vm) // resolve injections before data/props
    initState(vm)
    initProvide(vm) // resolve provide after data/props
    callHook(vm, 'created')

可以看到我们传入的options是没有_isComponent的,所以我们上文的例子会走到else里面,那啥时可以走到_isComponent里面呢?我们知道Vue里面还有一个强大的功能--组件,我们可以通过自己自定义一些基础组件然后通过拼装组件构成最终页面达到最高的代码复用率,这部分初始化我们将在下一篇文章中介绍。
我们接着说,上文例子中会走到else里面,可以看到这里面是将传入的options和Vue里面的options进行合并,毕竟传入的是用户想用设置的值,Vue里面肯定很多选项,如果用户没有传入,就会取默认值。上文我们的例子就传入el和data两个。然后下面又是一堆初始化函数,同时可以看到两个调用钩子的时间点:beforeCreate和created;在Vue官网文档中有一个很醒目的Vue声明周期的图:

二. Vue源码之初始化上篇_第2张图片
lifecycle.png

可以看到前两步正和我们现在的代码相对应,initLifecycle是初始化生命周期(core/instance/lifecycle.js),initEvent是初始化事件处理机制(core/instance/events.js),这两步完成之后就认为是beforeCreate状态了,紧接着进行的是initInjections(core/instance/inject.js)、 initState(core/instance/state.js)以及initProvide(core/instance/inject.js)了,其中initInjections和initProvide是跟父子组件通信相关的,这里不细说,而 initState是重中之重,在这里面把data、props、watch、计算属性等等全部初始化完毕,所以这个 initState是初始化过程要重点关注的一个函数,上代码:

export function initState (vm: Component) {
  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)
  }
}

我们传入的目前只有data,所以会调用到initData(vm)这个方法中:

function initData (vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}
  if (!isPlainObject(data)) {
    data = {}
    process.env.NODE_ENV !== 'production' && warn(
      'data functions should return an object:\n' +
      'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',
      vm
    )
  }
  // proxy data on instance
  const keys = Object.keys(data)
  const props = vm.$options.props
  const methods = vm.$options.methods
  let i = keys.length
  while (i--) {
    const key = keys[i]
    if (process.env.NODE_ENV !== 'production') {
      if (methods && hasOwn(methods, key)) {
        warn(
          `Method "${key}" has already been defined as a data property.`,
          vm
        )
      }
    }
    if (props && hasOwn(props, key)) {
      process.env.NODE_ENV !== 'production' && warn(
        `The data property "${key}" is already declared as a prop. ` +
        `Use prop default value instead.`,
        vm
      )
    } else if (!isReserved(key)) {
      proxy(vm, `_data`, key)
    }
  }
  // observe data
  observe(data, true /* asRootData */)
}

这里开头判断data是一个对象还是函数,如果是函数,那么就调用这个函数得到一个对象。我们例子中传入的是对象,所以会继续走到while循环中,循环里面会判断一定data数据的key是否在methods或者props里面出现,毕竟不能重复嘛!我们data对象中只有一个"message":"hello Vue",key即message,没有重复,所以会走到isReserved(key)这个逻辑中,这个函数在core/utils/lang.js中:

export function isReserved (str: string): boolean {
  const c = (str + '').charCodeAt(0)
  return c === 0x24 || c === 0x5F
}

其中0x24以及0x5F分别是$和_的ASCII码,vue实例中有些变量会以$开头表示是特定用途的,比如$data/$props/$methods等等,而_在js中通常表示私有属性(private),所以我们自己在data中定义的数据的key不允许以上述两个ASCII码开头;而示例程序中的key为message显然isReserved判断之后返回false,再取非返回true,所以会走入proxy(vm, '_data', key)中,这里面是实现了代理模式,代码如下:

export function proxy (target: Object, sourceKey: string, key: string) {
  sharedPropertyDefinition.get = function proxyGetter () {
    return this[sourceKey][key]
  }
  sharedPropertyDefinition.set = function proxySetter (val) {
    this[sourceKey][key] = val
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}

我们上文已经介绍过Object.defineProperty了,这里实现的功能是啥呢?比如我们经常像这样在Vue里面访问data里面的数据--console.log(this.message),可是我们知道这个message明明是在data下面的,不应该是这么使用嘛---console.log(this.data.message)?确实这么调用肯定是可以的,不过太过繁琐,所以Vue这里实现了对message的代理,当我们访问this.message的时候,就会调用到

 sharedPropertyDefinition.get = function proxyGetter () {
    return this[sourceKey][key]
  }

而这里返回的是this._data.key,在程序开头可以看到this._data就是data,因此我们便可以通过this.message直接访问data里面的message了,这里面其实就一个知识点:Object.defineProperty,搞明白这个就理解这个设计了。
接下来就走到了 observe(data, true /* asRootData */),observe函数如下所示:

export function observe (value: any, asRootData: ?boolean): Observer | void {
  if (!isObject(value) || value instanceof VNode) {
    return
  }
  let ob: Observer | void
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else if (
    shouldObserve &&
    !isServerRendering() &&
    (Array.isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    !value._isVue
  ) {
    ob = new Observer(value)
  }
  if (asRootData && ob) {
    ob.vmCount++
  }
  return ob
}

其实核心代码就一句: ob = new Observer(value)
啥意思呢? 这里是利用观察者模式实现的数据变化然后通知视图(html)变化的,这里面涉及到Vue对观察者模式的实现,这部分代码说实话细节颇多,稍微比较难懂,我们单独拿出一次文章聊这个事情。我们现在继续我们的初始化流程。可能大家已经忘了上面是从哪儿走到这里了,其实这是看源码的一个困难所在。由于现在开源项目通常代码规模都比较高,在看源码时经常会出现读着读着已经不知道读到哪儿了这种问题,我觉得这个是正常的。我个人是这么解决这个问题的:
1.从上至下。我会先忽略与主流程无关的代码(在看源码前要知道这个软件干啥的,最起码对主流程很熟悉),先将主流程看明白,或者退一步,主流程上的代码看懂一句是一句,看不懂先略过。然后结合最简单的一个例子,通过debug的方式,先走一遍混个脸熟,我们在准备篇最后曾经给过一个Vue源码调试环境搭建的视频,按照视频做完就可以实现在Vue源码中任何一处加入断点调试Vue源码啦。
2.从下至上。有些源码虽然主流程好懂,可是顺着看就是看不懂或者说总是有不顺的地方。比如我在看Activemq的源码时,主链路总是缺一环。所以我采取的方式是从下至上,先看懂某个模块的代码,比如kahadb的实现,这部分代码相比总体源码代码量少很多,较容易看懂,在看懂这个模块的基础上,我再考虑这个模块在总体代码中的位置,逐渐去理解总体源码。
这两种方式,我通常更喜欢第一种,偶尔碰到困难就从第二种入手,Vue采用的就是第一种,Activemq或者Linux源码就是第二种。读者可以按照我说的试试,我感觉总会有收获的。
继续我们的初始化流程,刚才是_init里面的initState走到initData中的链路,其实_init还没有执行完毕呢,还有最后一句very重要的:

if (vm.$options.el) {
    vm.$mount(vm.$options.el)
}

这里面el我们传入的是"#demo", 所以上面便是vm.$mount("#demo"),这里又有知识点了:如果你在Vue源码中查询Vue.prototype.$mount的话,会发现这个出现了多次:
platforms/web/runtime/index.js
platforms/weex/runtime/index.js
platforms/web/entry-runtime-with-compiler.js
platforms下面存储的都是和平台相关的,我们多数使用的web,所以只关注web目录下的js文件,依然还剩两个文件,这两个文件里面都有Vue.prototype.$mount = function(){....},而且两个函数还不一样,这到底是怎么回事?
Vue其实是有两个版本----Vue runtime only和Vue runtime + compiler,两个版本什么区别呢? 区别在于编译的时机不同。当我们真正用Vue进行开发时,如果采用Vue runtime + compiler的版本即带着编译功能代码的版本,那么编译的行为会发生在用户在浏览器访问网页的那时,显然编译还是比较浪费时间的;而Vue runtime only版本是不带编译代码的,这个时候模板编译为render函数这个行为是发生在打包那一刻(这也是为啥我们开发时会安装一个叫做vue-loader的插件),也就是说vue-loader这个插件在打包时会进行编译行为,最终生成的vue源码就不含编译代码了,这样用户访问网页就快了。 我们怎么验证呢? 读者可以用VSCode打开Vue源码之后,修改一下Vue源码路径下的scripts/config.js:

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

我们在运行npm run dev的时候实际上这里可以选择带不带编译器,with-compiler自然是带了,下面的entry-runtime.js是不带的,那两者生成的最终的Vue.js有啥区别呢?见下图:


二. Vue源码之初始化上篇_第3张图片
[图片上传中...(vue $mount2.png-fc3147-1540201470811-0)]
二. Vue源码之初始化上篇_第4张图片
vue $mount2.png

上面两张图所表示的代码是带有编译器打包之后的Vue.js源码,我们会发现这个时候Vue源码中出现过两次对Vue.prototype.$mount的定义,而如果不带编译器打包之后的Vue.js源码中是不会出现下面第二个图中的代码的-即整体代码里面只有一个Vue.prototype.$mount的定义。这里我们为了能够讲清楚Vue源码编译的过程,所以采用的是带有编译器的Vue版本。那问题来了,Vue.prototype.$mount定义了两次,那么执行的是哪一个呢?答案是:两个都执行了,且先执行的是第二个图的$mount,然后又调用了第一个图的$mount(而不带编译版本的Vue只会执行第一个图的$mount),为啥呢? 其实道理很简单,我们在用Vue之前,必然会先导入vue--

你可能感兴趣的:(二. Vue源码之初始化上篇)