从 Timeline 探索 Vue2 源码(一)

1,703 阅读6分钟

前言

React、Angular、Vue可以说是国内比较流行的三种 Web 框架

trends

来自谷歌指数

其中 Vue 作为后起之秀,以其易上手、低侵入等特点,受到了开发者们的青睐。社区中对 Vue 源码进行剖析的文章也是不少,比如 @xiaofuzi 200行代码实现精简版 Vue、前同事 @youngwindVue 早期源码探索等文章,让笔者也是获益良多。于是按捺不住探索的欲望,也开始的读源码的过程。

开始阅读的时候,我当然是懵逼的,完全不知道从哪里下手,硬着头皮读完了初始化函数,脑图记了一大片,却依然对整个框架没有整体概念,而面对更加复杂的后续源码,实在是没有驱动力继续读下去了。后来参考了少峰的《 Vue 早期源码探索》系列中新颖的源码阅读方式,我也试了一下,不过读来之后又是只知其味,不得其法,数据绑定更新的原理是知道了,但是依然对整个框架的运行过程云里雾里,继续看下去依然几千个 commit,又包含若干 breakchange ,so 又烂尾了...

后来笔者注意到调试工具里面的 Timeline 工具,这个工具一般是用来分析前端性能的,我之前也用它来调试过一些奇怪的 bug(比如 Vue 2.1.17和2.1.18版本对动画处理的不同),在阅读源码工作中,Timeline 能够图形化的显示调用栈,你能够很清晰的阅读在特定场景中,整个框架是如何运行的。在知道整体运行框架之后,再去阅读某个小模块的源码才能做到有的放矢,也能知道模块与模块之间的关系,所谓“场景驱动阅读”。你还能调整场景,看在另一个场景中,调用栈是不是改变了,为何而变,涉及到了什么知识点,目前看来是一种可取的阅读源码的方案。

下面我们来实践一下。

环境说明:

为了统一读者的运行环境,下面列出本文所用的 Vue 版本及构建方式:

  • Vue 版本:v2.2.4

  • 构建:vue-cli

  • 初始化:$ vue init simple learn-vue-source

  • 工具:

    • Chrome DevTool 用来查看函数调用栈及断点调试,为了保证时间线的纯净,减少浏览器插件脚本对时间线造成“污染”,请使用隐私模式
    • WebStorm 用来在打包前的代码中搜索及跳转模块
    • lambda-view 调试工具中的Source 面板没有语法高亮,用它来实现更好的源码阅读体验

场景一

<div id="app">
  {{ message }}
</div>
const app = new Vue({
      el: '#app',
      data: {
        message: 'Hello, Vue!'
      },
    });
    console.log(app);

这个就是官方起步分档中的例子,下面我们在 Timeline 中看一下,这个应用是怎么跑起来的。

首先设置你的 Timeline 如下,这样方便你通过截图来判断,程序开始时间(当然你也可以通过下面的资源占用情况来判断)

setup

刷新页面,等一会我们就能看见生成好的 Timeline 了。

overview

前面一部分有几个匿名函数执行,通过我们的 HTML 我们可以知道,这里是 vue.js 释放的过程,即做一些环境判断、一些预处理、最后把 Vue 挂载到 window 的过程,最后红框内是生成 Timeline 的过程,这两个部分我们就不深究了。

ParseHTML 和 EvaluateScript是浏览器自身的行为,解析 HTML 和 JS,重点关注中间的 Vue 的运行过程,放大中间部分,能够看到中间这大概20ms的部分就是 Vue 干活的时间了。

process

图中绿色的部分是 vue.js 运行时的调用栈,所谓调用栈通俗理解(我就不放学院派的定义了)就是函数调用的顺序,函数都是从顶层向下调用,调用到最下面之后,相邻的同级别的函数执行,继续从上向下调用,类似于下图的方式:

callstack

构造函数

明白了调用栈,我们就看一下我们应用的启动过程吧!从我们的代码上来看,我们先是用new Vue(xxx)生成的一个 Vue 的实例,毫无疑问会调用 Vue 的构造函数,在 Timeline 上点击 Vue$3 ,在下面的 Summary 面板上通过点击代码行,我们能够跳转到 Source 面板查看源码。

newvue

这就是我们的构造函数的真面目:

constructor

(至于为什么是Vue$3,在这里我还不太明白,可能是不同编译 target 导致的不同吧(从编译后的源码看,runtime 版本的是$2),不过从log出的实例和 window.Vue上来看,Vue$3确实是我们的实例。)

$instance

初始化函数 _init

从函数上我们看到,构造函数调用了实例上面的_init方法,这时实例还没有创建,哪里来的_init方法呢?一定是沿着原型链找到了实例公共方法上面去了,即调用的是Vue.proptotype._init()。

沿着这个线索,我们在Source窗口中command+F搜索(windows用户使用xxx+F).init,于是我们在3661行找到了它的初始定义:

search-init

我们发现,Vue.proptotype._init()是定义在一个initMixin函数中的,这个函数又是从哪里运行的呢?继续搜索initMixin:

init-mixin

在构造函数下方,我们看到了他的身影,顺便我们还看到了stateMixineventsMixinlifecycleMixinrenderMixin这几个函数调用,从命名上面看,他们分别初始化了状态相关事件相关生命周期相关渲染相关的东西。他们都发生在匿名函数执行时,在我们使用Vue类时,他们已经初始化完成了,所以我们先往后面看,待需要的时候回头来看匿名函数都做了什么。

我们继续来看Timeline:

init-all

从Timeline上我们看到Vue._init一共做了这么几件事情:
mergeOptionsinitRenderinitState,然后就是一个长长的 Vue$3.$mount 直到视图渲染完成。

在Timeline点击Vue._init,然后在下面Summy面板中点击源码位置,进入Source面板:

enter-init

init-in-source

前面的if判断似乎是,判断实例是否是一个组件,如果不是组件的话(是根实例),就执行mergeOptions。(在Source中搜索_isComponent,确实搜到了createComponentInstanceForVnode方法,与创建实例有关。从注释上看似乎是由于merge操作缓慢,而组件实例又没有必要做这步操作,所以有了这有么一个判断)

选项合并与格式化 mergeOptions

我们打上断点看看mergeOptions做了些什么:

break-merge

mergeOptions传入了三个参数:Vue构造器的options(包括Vue的默认option,全局中使用Vue.config/mixin等等设置的选项)、我们 new Vue 时传入的options、当前vm实例。

通过点击源码位置,我们找到了mergeOptions的函数定义(编译之后的),通过在各个函数(checkComponents, normalizeProps, normalizeDirectives)上打断点,我们大概理清楚了mergeOptions是做什么的:

/**
 * Merge two option objects into a new one.
 * Core utility used in both instantiation and inheritance.
 */
function mergeOptions (
  parent, // Vue的默认option,全局中使用Vue.config/mixin等等设置的选项
  child, // 我们new Vue时传入的options
  vm // 当前vm实例
) {
  {
    // 检测我们输入的options.component中是否有Vue的保留关键字
    // 如组件不能命名成slot,component,也不能命名成html和svg中已有的标签名
    // 我们的这个场景是根元素,所以这个部分就不跑了
    checkComponents(child);
  }
  // 这个函数是对我们自己传入的options.props进行格式化,用来支持数组和对象另种props形式
  // 经过他的处理,props都变成了对象形式,同时对prop的类型做了处理
  // 需要注意的是
  // 1. 数组声明的方式下,prop的类型声明在这里被统一为null(编译后的第1161行)
  // 2. 数组声明方式下,每个prop声明必须是字符串类型的,否则会报警告
  // 3. 数组/对象声明方式下,每个prop命名在这里都被转换成了驼峰命名风格
  // 4. 转换结果:
  //    ['one-prop'] => { oneProp: { type: null } }
  //    { one: Number, two: { type: Number, default: 1, .... } }
  //    => { one: { type: Number }, two: { type: Number, default: 1, .... } }
  // 当前场景中并没有用到,所以跳过
  normalizeProps(child);

  // 格式化用户在实例上自定义的指令,即 options.directive,支持对象方式定义,和函数方式定义
  // 如果是直接用函数的方式定义的话,会在这里被转换成对象形式
  // 转换结果:
  // directives: { direc: function() {} }
  // =>
  // directives: { direc: { bind: function() {}, update: function() {} }}
  // 当前场景中并没有用到,所以跳过
  normalizeDirectives(child);
  var extendsFrom = child.extends;

  // 处理在options中使用extends写法,用以支持声明式扩展/继承另一个组件,而不必使用Vue.extend
  // 自然,被扩展的组件也需要mergeOptions
  // 当前场景中并没有用到,所以跳过
  if (extendsFrom) {
    parent = typeof extendsFrom === 'function'
      ? mergeOptions(parent, extendsFrom.options, vm)
      : mergeOptions(parent, extendsFrom, vm);
  }

  // 处理使用mixin的情况,方便做细粒度的组件复用
  // 当前场景中并没有用到,所以跳过
  if (child.mixins) {
    for (var i = 0, l = child.mixins.length; i < l; i++) {
      var mixin = child.mixins[i];
      if (mixin.prototype instanceof Vue$3) {
        mixin = mixin.options;
      }
      parent = mergeOptions(parent, mixin, vm);
    }
  }
  var options = {};
  var key;
  for (key in parent) {
    mergeField(key);
  }
  for (key in child) {
    if (!hasOwn(parent, key)) {
      mergeField(key);
    }
  }
  function mergeField (key) {
    var strat = strats[key] || defaultStrat;
    options[key] = strat(parent[key], child[key], vm, key);
  }
  return options
}

我们刚才一连跳过了5个函数,跳过是因为我们既没有子组件、也没扩展构造器、也没使用混入、也没自定义指令及props,这也就解答了我们在Timeline上为何只看到了mergeFields一个函数执行的原因,Timeline忠实地为我们记录了一切。这也是使用Timeline查看源码的好处:跳过在当前场景无用的函数,专注于对整个框架运行的理解,同时也能减少读源码的压力。

策略模式与闭包

接下来就是看看这唯一执行的 mergeField做了什么吧:

这里面有个变量 strat 让人比较疑惑:其实这里运用了一个策略模式,就是我们打算合并的 key 不同,是有不同的合并策略的(举例说一下有哪些不同,比如说某些是 child 覆盖 parent,有些则是 parent 覆盖 child),这里的 strat 其实就是 stratgy 的意思,比如合并 component 属性时,根据 key 使用合并 component 时的策略。然后让策略执行,传入 mergeOptions 传入的参数。

还记得 mergeOption 传入了什么参数吗?

mergeOptions传入了三个参数:parent:Vue构造器的options、child:我们new Vue时传入的options || {}、vm:当前vm实例。

通过 mergeFiled,我们的 options 变成了如下结构:

merge之前:

merge-before

之后:

merge-res

然后问题来了:我们的 message 哪去了?

这就是策略搞的鬼,合并data字段的处理策略在994行,在我们的场景下,代码走了1028行的分支,策略执行完直接返回了一个mergedInstanceDataFn函数,我们的message在策略执行的函数的闭包中被保存了下来,option.data现在是一个函数,它将在后续处理中被调用。

data-func

为了观察到后面的调用过程,我们在这个返回的函数中打一个断点(1030行)。

向实例上挂载 render

根据 Timeline,mergeoptions 运行之后,接下来就是 initRender 部分了:

这里Vue实例挂载了两个createElement函数,vm._cvm.$createElement,其中按照Vue的编程代码风格,带_都是内部方法,$都是外部方法。

  ...
  // 用来支持slot
  vm.$slots = resolveSlots(vm.$options._renderChildren, renderContext);
  // 用来支持带作用域的slot
  vm.$scopedSlots = emptyObject;
  // 把createElement函数绑定在vm自身上
  // 参数列表: tag, data, children, normalizationType, alwaysNormalize
  // 内部使用vm._c时,由于没有外来的干扰,不进行normalization过程,以提高性能,所以最后一个参数是false
  vm._c = function (a, b, c, d) { return createElement(vm, a, b, c, d, false); };
  // 当外部使用vm.$createElement时,则必须要对开发者输入的内容进行normalization过程,所以随后一个参数为true
  vm.$createElement = function (a, b, c, d) { return createElement(vm, a, b, c, d, true); };

beforeCreate 生命周期触发

initRender 后便运行了 callHook 方法,来触发 beforeCreate 生命周期,如果 vm.$options 中包含了相应字段(如$options.mounted),则将函数this改成当前vm并运行之。

此时,数据观测(data observer) 和 event/watcher 事件都还没有进行。

谜一样的 callHook

callHook 方法中,从代码上看似乎生命周期函数支持数组形式:

  ...
  var handlers = vm.$options[hook];
  if (handlers) {
    for (var i = 0, j = handlers.length; i < j; i++) {
      try {
        handlers[i].call(vm);
      } catch (e) {
        handleError(e, vm, (hook + " hook"));
      }
    }
  }
  ...

于是用下面的代码验证之:

const app = new Vue({
  el: '#app',
  mounted: [
    function() {
      console.log(1);
    },
    function() {
      console.log(2);
    },
    () => {console.log(3)}
  ]
})

// -> 1,2,3

这时又意识到,源码中的写法应该是只支持数组形式,这与我们平时的使用方式就不太一样了,我们平时使用基本都是直接一个函数,这时.length应该是0,函数不执行,那么Vue又使用了什么来把我传入函数放入数组的呢?

通过打断点来确认,最后发现还是 mergeFields 中的策略捣的鬼,在 merge 生命周期函数时,统一在这里被转换为数组形式。代码在 1048 行,mergeHook 函数中。实现如下:

/**
 * Hooks and props are merged as arrays.
 */
function mergeHook (
  parentVal, // 以合并beforeCreate为例,这里是父级vm的befoCreate
  childVal  // 这里是当前vm的befoCreate
) {
  return childVal
    ? parentVal
      ? parentVal.concat(childVal)
      : Array.isArray(childVal)
        ? childVal
        : [childVal]
    : parentVal
  // 如果存在父子级关系,就把父子级的生命周期函数数组合并
  // 问题是:数组中的函数执行顺序,和触发机制,是由根vm统一按顺序触发吗?还是由各个子vm分别触发?
}

山雨欲来:initState 拦截$options.data的存取

init-state-time

根据 Timeline 我们继续往下走,到了initStateinitState 传入了当前的vm作为参数,我们先看一下当前vm的样子:

before-init-state

在当前场景下,initState 执行了 initData,这里 Vue 对数据进行了响应式改造,这里就接近 Vue 进行数据绑定的核心部分了。

接下来把断点打在2648行,准备进入 initData

函数形式的 $options.data

initData 中,我们发现了 $options.data 是函数的情况(2704-2706),这个 $options.data 就是在 mergeOptions 函数中被返回的函数。

init-data

这个函数做了一次数据合并,问题是:为什么要做数据合并?为何要延迟到现在才合并?

merge-data

合并之后的数据就变成了对象,返回出来。

拦截对$options.props中键值的存取

proxy

这里拦截对$options.props中键值的访问,全部映射到vm._data[对应键值上]

proxy的实现如下:

function proxy (target, sourceKey, key) {
  // 配置Object.defineProperty的第三参数,把访问都映射到_data[key]
  sharedPropertyDefinition.get = function proxyGetter () {
    return this[sourceKey][key]
  };
  // 配置Object.defineProperty的第三参数,把写入都映射到_data[key]
  sharedPropertyDefinition.set = function proxySetter (val) {
    this[sourceKey][key] = val;
  };
  Object.defineProperty(target, key, sharedPropertyDefinition);
}

Vue 通过这里完成了内外数据存取的分离,当我们在操作$options.props.xx时,Vue内部则在操作_data.xx。举个例子:
当我访问vm.message的时候返回vm._data.message,当我设置vm.message的时候,返回vm._data.message,完成了内部属性_data.message和外部属性data.message的连接。

接下来就是重头戏:对$options.data进行观察(observe) #5,请待下期分解!