Vue.js源码学习 —— 起步

Vue.js 源码的重要性不必多说了,无论是出于对技术的好奇,还是想要更好的使用 Vue.js,还是要应付面试,都需要我们沉浸下来好好琢磨一下它的源码。Vue.js 的代码经过了全世界这么多的程序员的审视,代码的设计性、规范性、严谨性等等,一定有很多值得我们学习借鉴的地方,自己在看的时候还是要多用心思考。

我目前找到两份总结的比较系统全面文档,一份是Vue.js技术解密,划分了每一章,可以一目了然我们当下分析的目标是什么。还有一份是Vue技术内幕,真的是带着读着从头开始几乎逐行代码解读。感谢大神们对社区的贡献!但是我们仍有必要整理一份自己分析的笔记,在梳理的过程中,这些知识会逐渐内化成自己掌握的。我们不是在看小说,完全靠读别人的文章会大大减少去思考的机会,最终时间花了,却收效甚微。对我来说,写Demo,然后调试验证,会理解的深一些。后面的系列文章中我也会尽量给出例子,希望你能跟着看下去,并拿例子到浏览器中打断点调试一遍。

这篇文章会先说一下开始看代码前的准备工作,介绍一下 Vue.js 项目的整体结构,然后再讲一下后面会怎样着手看代码。

准备工作

clone代码

clone Vue.js 源码 到本地。我clone的是2.6.11版本,后面的分析也会基于该版本。

以前写分享源码的文章,基本上是贴源代码,再加上注释说明,这次我会尽量不去贴源代码,所以你一定要将代码clone下来,打开放在旁边。要看源码,如果连代码都不clone下来,实在说不过去啦。

了解项目结构。

Vue.js Contributing Guide介绍了每个目录放的内容,我翻译了一下如下:

  • scripts: 包括与打包相关的脚本和配置文件。通常你不需要接触它们。但是,熟悉下面的文件会对你自己有帮助:

    • scripts/alias.js: 用到的所有源代码和测试的别名。

    • scripts/config.js: 包括dist/中所有文件的打包配置。查看这个文件,如果你想要找一个打包出来的文件的源代码入口文件。

  • dist: 包括用于发布的打包后的文件。注意,这个目录只会在发布时才会更新,他们不会反应开发环境分支中的代码改动。查看 dist/README.md 获取更多相关细节。

  • flow: 包括给 Flow 的类型声明。这些声明会在全局加载,你会在源代码中看到将它们用于类型注释中。

  • packages: 包括 vue-server-renderervue-template-compiler, 它们会作为单独的NPM包发布。它们自动从源码生成,并总有和 vue 包相同的版本。

  • test: 包括所有的测试用例。

    单元测试是用 Jasmine 写的。使用 Karma运行。使用 Nightwatch.js 进行e2e测试。

  • src: 包括源代码。代码是用ES2015写的,并加上了 Flow 类型注解。

    • compiler: 包括 template-to-render-function编译器代码。

      该编译器由解析器(将 template strings 转换成 ASTs 元素),优化器(检测 static trees 用于虚拟DOM渲染优化),代码生成器(从 ASTs 元素生成 render函数)。注意 codegen 会直接从 AST 元素生成代码字符串 - 这样做是为了减少代码体积,因为编译器以独立版本的形式提供给浏览器。

    • core: 包括通用的,平台无关的运行时代码。

      Vue 2.0 的核心是平台无关的。这就是说,在 core 中的代码可以运行于任何JavaScript环境,可以是浏览器,Node.js,或者嵌入到原生应用的JavaScript运行环境。

      • observer: 包括与响应式系统相关的代码。

      • vdom: 包括与虚拟DOM创建和补丁相关的代码。

      • instance: 包括 Vue 实例构造器和原型方法。

      • global-api: 包括 Vue 全局 api。

      • components: 包括通用的抽象组件。

    • server: 包括服务端渲染的相关代码。

    • platforms: 包括平台相关的特定代码。

      每个打包后的入口文件在它们单独的平台文件夹中。

      每个平台模块包含3部分:compiler, runtimeserver,对应这3个文件夹。每一部分包含平台特定的模块或使用工具,会在平台特定的入口中,被导入和注入到核心部分。例如,实现v-bind:class背后逻辑的代码在platforms/web/runtime/modules/class.js中 - 它被导入到了entries/web-runtime.js中,并用于创建浏览器特定的虚拟DOM补丁功能。

    • sfc: 包括单文件组件(*.vue 文件)解析逻辑。这被用于vue-template-compiler包中。

    • shared: 包括被整个代码库共享的使用工具。

    • types: 包括TypeScript类型定义。

      • test: 放类型定义的测试用例。

了解代码风格。

Vue.js 使用了 Flow 做类型注解,虽然跟写纯JavaScript代码有点不一样,但其实是有助于我们阅读代码的,可以明确地知道比如一个函数的传参和返回是什么类型。我在代码中随便找了一处看一下:

1
2
3
4
5
6
7
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
// ...
return mount.call(this, el, hydrating)
}

el?: string | Element 表示 el可以不传,如果传了必须是 stringElement 类型的值。

hydrating?: boolean 表示 hydrating可以不传,如果传了必须是 boolean类型的值。

: Component 表示函数返回的值为 Component类型。

项目中的自定义类型都放在了 flow 文件夹中。

在书写上,对于函数的定义,很多地方都是像上面的代码一样,将参数换行写,返回值也换一行。if中的判断条件多的时候也会换行。

了解项目是如何打包的。

打开 package.jsonscripts 部分:

1
2
3
4
5
6
"scripts": {
"dev": "rollup -w -c scripts/config.js --environment TARGET:web-full-dev",
"dev:cjs": "rollup -w -c scripts/config.js --environment TARGET:web-runtime-cjs-dev",
"dev:esm": "rollup -w -c scripts/config.js --environment TARGET:web-runtime-esm",
......
}

vue.js是使用 rollup 打包的,rollup会按照ES6支持的标准模块化格式打包,并且能够分析出项目中未被使用的代码,在打包时去除掉,减少打包后的体积。干的活跟webpack很像,通常开发 Web 应用时使用 webpack 打包,开发库时使用 rollup 打包。

我们后面分析基于dev这个脚本打包出来的 vue.js,所以来具体看下该脚本内容:

1
"rollup -w -c scripts/config.js --environment TARGET:web-full-dev"

-w 表示 rollup会一直监听源码文件是否有改动,如果有改动就重新打包项目。-c scripts/config.js 表示使用 scripts 文件夹下的 config.js 配置文件进行打包。--environment TARGET:web-full-dev 表示打包出完整版本的 vue.js

啥是完整版本的呢?

完整版本的 vue.js 除了基本功能外,还带有编译器功能。你也可以将带有template选项的Vue实例代码发布上线,在运行时遇到 template,完整版本的 vue.js 会先将其编译成JavaScript代码(也就是render函数,这块留在以后再细说)。

scripts 中的其他脚本的意思也都类似,TARGET 后指明了要打包的vue.js的版本,在 scripts 文件夹下的 config.js文件中有对应的版本的入口文件路径。

安装依赖包

1
npm install

准备调试

rollup 打包时产生 vue.js.map 方便调试。后面的分析都基于完整版本的 vue.js,所以在上面第4点中说的dev脚本后面加上 --sourcemap

1
"dev": "rollup -w -c scripts/config.js --environment TARGET:web-full-dev --sourcemap"

运行项目

1
npm run dev

之后会看到 dist 文件夹下产生了一个 vue.js.map 文件,这使得我们可以在浏览器中看到src文件夹,这样就可以直接在具体的位置打断点调试了。

找个例子,开始调试!examples 文件夹下有很多例子,随便找一个,然后将对 vue.min.js 的引用改为对 vue.js 的引用,在浏览器中打开页面,再打开 devtools ,在 Sources 板块会看到 scr 文件夹被列出来了。后面随便你怎么打断点调试了。

准备官方API文档

在看代码时,将官方的[API文档]放在旁边,遇到没有用过的API和方法一定记得先看文档里的说明,先了解API是干什么用的,再回头看代码会容易很多。其实整个项目几乎就是在实现这些API。

找到 Vue 实例被初始化的地方

我们肯定不能这个文件打开看看,那个文件打开看看,一顿乱看。最好是给自己理出一条线路,然后带有目的去看。接下来我们一步步先找到 Vue 实例的初始化过程,大致了解一下src文件夹中的代码是如何联系起来的,然后确定一下后面看代码的思路。

找到入口文件

上一节中提到,打开scripts/config.js可以查看入口文件,那我们就去看一下web-full-dev的入口文件在哪。

1
2
3
4
5
6
7
8
'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('web/entry-runtime-with-compiler.js')

resolve函数干的活,是将参数用/分割取第一个元素(这里得到的就是web啦),然后去alias中找(这里找到的是../src/platforms/web),然后再拼接上后面的元素,返回拼接后的结果。所以这里返回的就是../src/platforms/web/entry-runtime-with-compiler.js,入口文件找到了。

Vue实例的初始化过程

entry-runtime-with-compiler.js

打开entry-runtime-with-compiler.js看一下吧,开始是一堆的导入,其中有我们比较关心的一个:

1
import Vue from './runtime/index'

等会儿再进去看它,先来大致看一下entry-runtime-with-compiler.js都干了什么事儿:

  • 重新Vue.prototype.$mount

    • 先将其缓存到mount

    • 如果创建Vue实例时没有传入render选项,就使用template选项,然后调用compileToFunctionstemplate转成render函数,让到选项中。

    没有template选项的话,会通过getOuterHTML函数生成template的内容。

    • 返回时调用之前缓存的mount
  • Vue加上全局API compile,开发者可是直接使用的。

  • 导出Vue

所以entry-runtime-with-compiler.js主要干了一件事儿:将template转成render函数。可以对比entry-runtime.js,它的里面就超级简单,导入Vue后,直接导出了。

./runtime/index

一开始还是一堆的导入,其中又发现了一条导入Vue,等会儿过去看看:

1
import Vue from 'core/index'

先看一下./runtime/index干的事儿:

  • 在Vue的全局配置Vue.config中加上了与平台相关的工具方法,这些方法来自于web/util/index

    可以仔细看一下这些方法的作用,然后加上注释,但现在先不要做,集中经历在主线上~

  • Vue.options.directivesVue.options.components扩展了与平台相关的字段。

    • directives加上了showmodel字段。

    • components加上了TransitionTransitionGroup字段。

  • Vue的原型上加上__patch__方法。

  • 实现Vue原型上的方法$mount,先记住在这,后面再详细分析挂载的过程。

  • 如果是浏览器环境,会打印一些日志到Console中。

  • 最后再导出Vue

所以./runtime/index中主要是给Vue补充了跟平台相关的功能特性。打开weex文件夹中对应的位置可以对比看一下。在weex环境下的Vue是没有提供v-showv-model指令的,component还多提供了一个Richtext组件。

core/index

打开后发现这里的Vue也是导入进来的:

1
import Vue from './instance/index'

core/index中主要做的事情包括:

  • 调用initGlobalAPIVue加上全局方法,因为这一部分代码对应了大部分Vue暴露给开发者使用的全局API,所以后面要重点的分析

  • Vue原型加上$isServer$ssrContext方法。

  • Vue加上FunctionalRenderContext方法,主要用于ssr

  • Vue加上version属性。

  • 最后导出Vue

在阅读代码时一定要注意方法或属性是加在Vue上的,还是加在Vue.prototype上的。

./instance/index

终于看到了定义 Vue 的地方,它是一个构造函数,所以在开发环境中判断了是否用new来调用,没用的话给出警告提示。然后调用了_init方法。

下面调用了一堆**Mixin方法,上面的_init方法就是在initMixin中加上的。这里可以看到一个Vue实例大致包括:状态、事件、生命周期、渲染等几个部分,打开各个文件就会发现API文档中大部分实例方法的实现过程,以及各选项的用途。所以这些地方是我们后面要重点看的

下面看一下每一部分都做了什么事儿:

  • initMixin./instance/init.js 中,主要给Vue原型加上了_init方法。该方法主要负责对传入的选项进行处理,确保后续的过程中使用的都是合法的选项。然后分别调用了其他各部分的init方法进行初始化。

  • stateMixin./instance/state.js 中,主要给Vue原型加上了$data$props$set$delete$watch,该文件中也实现了initPropsinitMethodsinitDatainitComputedinitWatch

    可以看到这个文件中放的都是与Vue实例的数据相关的方法或属性。

  • eventsMixin./instance/event.js 中,主要给Vue原型加上了$on$once$off$emit,该文件中也实现了initEvents

    可以看到这个文件中放的都是与Vue实例的事件相关的方法。

  • lifecycleMixin./instance/lifecycle.js中,主要给Vue原型加上了_update$forceUpdate$destroy$mount虽然没有在这里定义,但是它其中调用的主要方法mountComponent是放在这里的,该文件中还实现了initLifecyclecallHook(用来在生命周期的各节点调用开发者传入的回调方法)。

    可以看到这个文件中放的都是与Vue实例的生命周期相关的方法。

  • renderMixin./instance/render.js 中,主要给Vue原型加上了$nextTick_render。该文件中还实现了initRender方法。

    可以看到这个文件中放的都是与Vue实例的渲染相关的方法,所以也涉及到了虚拟DOM的使用。

所以后面可以按照上面的每一块去看,可以说其他文件中的内容几乎都是为这几部分服务的,这里每一块都弄懂了,基本上Vue背后的原理就理解的差不多了。

此外,还有一个骨头要啃下来,就是将template编译成render函数的编译器的实现。

其他补充

  • 在对Vue的设计上可以看到,是从最开始核心的部分,到最后导出供开发者使用的Vue,一层层在给Vue拓展功能。

  • 在整个项目的设计上,每一个文件夹放的都是一个明确的模块功能,并且都会提供一个对外导入使用的index.js文件。

  • 在阅读代码时会发现,有的地方用Object.defineProperty给对象添加属性或方法,有的地方又是直接赋值,这两种方式导致的对象的属性描述不同。下面举个例子说明:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    function Cat(name) {
    this.name = name
    }
    Object.defineProperty(Cat.prototype, 'eat', {
    value: function () { console.log('eating...') }
    })
    console.log(Object.getOwnPropertyDescriptor(Cat.prototype, 'eat'))
    // {writable: false, enumerable: false, configurable: false, value: ƒ}

    Cat.prototype.getName = function() {
    return this.name
    }
    console.log(Object.getOwnPropertyDescriptor(Cat.prototype, 'getName'))
    // {writable: true, enumerable: true, configurable: true, value: ƒ}

    使用Object.defineProperty给对象添加属性,如果没有明确指出,writableenumerableconfigurable默认都是false的。

    但是直接给对象的属性赋值,writableenumerableconfigurabletrue的。

  • entry-runtime-with-compiler.js中我们看到有这样一段代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    /* istanbul ignore if */
    if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    mark('compile')
    }

    // 省略中间代码...

    /* istanbul ignore if */
    if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    mark('compile end')
    measure(`vue ${this._name} compile`, 'compile', 'compile end')
    }

    这段代码是在开发环境下,如果开发者设置了Vue.config.performance = true,会记录中间这段代码的执行时间,我们可以在Chrome的devtools中的Performance板块看到该耗时记录。上面记录的是将template转换成render函数的耗时。后面我们在看代码的过程中,还会遇到这样的代码,因为跟主线业务没有太大关系,可以忽略掉。

    但是记录代码段耗时时长的这段代码还是可以借鉴到我们平时的开发中的,markmeasure位于core/util/perf中。

好了,准备工作铺垫的差不多了,后面开始用心研究里面具体的实现逻辑吧~

本文作者:意林
本文链接:http://shinancao.cn/2020/01/05/Vue-Source-Code-00/
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!