「~vue2.0源码解读 ~」
Vue作为当前前端开发中比较重要的框架,在企业级开发中应用十分广泛。目前也是我的主要技术栈之一。在接下来的系列文章中,我将带大家一起探秘Vue.js底层源码。
本篇文章是Vue源码探秘的第一篇。在这一篇中,主要是带大家做一些准备工作。
简短的概括:
1、认识Flow
2、源码目录结构
3、源码构建流程
4、初始化过程
正文从这里开始~~~
详细如下:
认识Flow
1、Flow定义
flow是facebook 出品的 JavaScript 静态类型检查工具。Vue.js 的源码利用了 flow 来做静态类型检查,所以了解 flow 有助于我们阅读源码。
2、为什么用Flow
JavaScript 是动态类型语言,它的灵活性有目共睹,但是过于灵活的副作用就是很容易就写出非常隐蔽的隐患代码,在编译期甚至运行时看上去都不会报错,但是可能会发生各种各样奇怪的和难以解决的 bug。
项目越复杂就越需要通过工具的手段来保证项目的维护性和增强代码的可读性。Vue.js 在做 2.0 重构的时候,在 ES2015 的基础上,除了 ESLint 保证代码风格之外,也引入了 flow 做静态类型检查。之所以选择 Flow,主要是因为 Babel 和 ESLint 都有对应的 Flow 插件以支持语法,可以完全沿用现有的构建配置,非常小成本的改动就可以拥有静态类型检查的能力。
3、Flow 的工作方式
通常类型检查分成 2 种方式:
1) 类型推断:通过变量的使用上下文来推断出变量类型,然后根据这些推断来检查类型。
通过一个简单例子说明一下:
/*@flow*/function split(str) { return str.split(' ')}split(11)
说明:Flow 检查上述代码后会报错,因为函数 split 期待的参数是字符串,而我们输入了数字。
2) 类型注释:事先注释好我们期待的类型,Flow 会基于这些注释来判断。
通过一个简单例子说明一下:
/*@flow*/function add(x: number, y: number): number { return x + y}add('Hello', 11)
说明:现在 Flow 就能检查出错误,因为函数参数的期待类型为数字,而我们提供了字符串。
重要:标记不能少
// @flow 标记还可以书写为 /* @flow */
4、Flow 的工作流程
第一步:模仿 C#/Java 之类的语言,在编码过程中对类型进行定义。
这个函数接收一个 string 类型的参数,参数名为 str,函数的返回值是 number 类型。
function getStringLength(str: string):number { const len: number = str.length; return len;}
第二步:通过 Flow 提供的工具进行类型检查。如果有类型不匹配的地方,工具会提示错误。
第三步:发布到线上的代码是不能加类型声明的,因为这样不符合规范,浏览器也不认。所以,我们需要对源代码进行一次编译,将所有声明的类型去掉,像正常的 JS 代码一样了。上面的代码编译过后将会变成:
function getStringLength(str) { const len = str.length; return len;}
5、Flow 在 Vue.js 源码中的应用
Flow 提出了一个 libdef 的概念,可以用来识别这些第三方库或者是自定义类型,而 Vue.js 也利用了这一特性。
在 Vue.js 的主目录下有 .flowconfig 文件, 它是 flow 的配置文件。
flow
├── compiler.js # 编译相关
├── component.js # 组件数据结构
├── global-api.js # Global API 结构
├── modules.js # 第三方库定义
├── options.js # 选项相关
├── ssr.js # 服务端渲染相关
├── vnode.js # 虚拟 node 相关
详细的 Flow 语法可以看以下资料:
1、官方文档:https://flow.org/en/
2、Flow 的使用入门:https://zhuanlan.zhihu.com/p/26204569
* 重要说明:
Vue 的源码采用rollup和 flow至于为什么不采用typescript,主要考虑工程上成本和收益的考量,这一点尤大在知乎也有说过。(经评论区提醒,3.0+已经确定改用typescript)
详细资料:
Vue 2.0 为什么选用 Flow 进行静态代码检查而不是直接使用 TypeScript?
https://www.zhihu.com/question/46397274/answer/101193678
源码目录结构
Vue.js 的源码都在 src 目录下,其目录结构如下。
src
├── compiler # 编译相关
├── core # 核心代码
├── platforms # 不同平台的支持
├── server # 服务端渲染
├── sfc # .vue 文件解析
├── shared # 共享代码
1、compiler (编译相关)
├── compiler # 模板解析相关
├── codegen # 代码生成,把 AST(抽象语法树)转换为 render 函数
├── directives # 转换为 render 函数前要执行的指令
├── parser # 把模板解析为 AST
compiler 目录包含 Vue.js 所有编译相关的代码。它包括把模板解析成 ast 语法树,ast 语法树优化,代码生成等功能。
编译工作可以在构建项目的时候( 借助 webpack、vue-loader 等插件) 来完成,也可以在项目运行时使用 Vue 的构建功能来完成。显然,编译是一项耗性能的工作,所以更推荐前者——离线编译。
2、core (核心代码)
├── core # Vue 核心代码
├── components # 全局通用组件 Keep-Alive
├── global-api # 全局 api,即 Vue 对象上的方法,如 extend,mixin,use 等
├── instance # Vue 实例化相关代码,如初始化,事件,渲染,生命周期等
├── observer # 响应式数据修改代码
├── util # 工具函数
├── vdom # 虚拟 DOM 相关代码
core 目录存放了 Vue 的核心代码,里面包括 内置组件、全局 api,Vue 实例化、观察者(响应式数据)、虚拟 DOM、工具函数等相关代码。
3、platforms (不同平台的支持)
├── platforms # 平台相关代码
├── web # web 平台
├── compiler # 编译时相关
├── runtime # 运行时相关
├── server # 服务端渲染相关
├── util # 工具函数
├── weex # 配合 weex 运行在 native 平台
Vue 作为跨平台框架,既可以运行在 web 端,也可以配合 weex 运行在移动端。
platforms 是 Vue.js 的入口,目录下的两个文件夹就分别对应了两种不同平台对应的打包入口文件。
4、server (服务端)
Vue 从 2.0 起支持服务端渲染(SSR)。server 目录下存放的是与服务端渲染相关代码,这也就意味着这些代码是运行在服务端的 Node.js 代码,而不是运行在浏览器端。
5、sfc
sfc 下只有一个 parser.js,实际上就是一个解析器,用于将我们编写的 .vue 文件解析成一个 js 对象。
6、shared
shared 目录中定义了常量和工具函数,供其他文件引用。
总结:
看完Vue.js的目录设计,可以看到作者把功能模块拆分的非常清楚,相关的逻辑放在一个独立的目录下维护,并且把复用的代码也抽成一个独立目录。这样的目录设计使得代码阅读性变强,也更易维护,是非常值得大家学习的。
源码构建流程
Vue.js 源码是基于 Rollup 构建的,它的构建相关配置都在 scripts 目录下。
Webpack 与 Rollup,为何选择 Rollup
1) webpack更强大一些,可以处理图片、 css、js等;
2) Rollup只做js的处理,相比之下更轻量, 编译后的代码更加干净。除了vue以外,像React,Ember,D3,Three.js 以及其他很多开源库也选择了Rollup 进行构建。
1、构建过程
整个的构建流程:
1)读取./config 配置文件中的配置,再根据命令行中输入的参数,exports.getAllBuilds = () => Object.keys(builds).map(genConfig)。
2)其中getAllBuilds方法返回的是一个对象数组。
3)去到这些配置中进行过滤,拿到对应的版本编译所需要的config, 传入到buildEntry方法去执行rollup编译。
4)rollup在.then内执行输出的文件地址,以及代码压缩配置。
5)其中config里的format 构建格式。
6)最后config文件最后导出的是遵循rollup构建规则的所需要的数组对象。
说明:
Object.keys(builds) 对象key的数组:对应val的是不同版本的编译配置。
genConfig 配置rollup构建的参数格式。
详细分析:
分析:Vue版本2.6.11
分析任何库的源码一定是从它的 package.json 中进行分析。
1)script工具流分析: build命令
下面是 scripts/build.js 核心代码
"build": "node scripts/build.js",
从命令可以看出,构建命令就是执行 scripts 目录下 build.js 文件。
2)定位 script/build.js 核心代码
我们可以看到build.js中引入了很多相关模块,这些我们先不去关注。
代码地址:scripts/build.js 的 builds
// 这里的builds 就是通过getAllBuilds()方法得到
// 执行build命令的时候所有需要打包的vue版本
let builds = require('./config').getAllBuilds()
通过 script/config.js 文件的
getAllBuilds方法获取配置
代码地址:script/config.js 中的 getAllBuilds
// config最底部导出了这个方法
exports.getAllBuilds = () => Object.keys(builds).map(genConfig)
可以看出,getAllBuilds 方法首先通过 Object.keys 拿到 builds 对象所有key的组成的数组,并通过map遍历执行genConfig方法。
下面我们先看一下builds对象。
代码地址:script/config.js 中的 builds
const builds = {
// Runtime only (CommonJS). Used by bundlers e.g. Webpack & Browserify
// builds中每个对象就表示vue需要打包的每个不同的版本
'web-runtime-cjs-dev': {
// entry表示需要打包的入口文件路径
entry: resolve('web/entry-runtime.js'),
// dest表示构建打包出口的路径
dest: resolve('dist/vue.runtime.common.dev.js'),
// format表示模块,就比如AMD,CMD,ESModules
format: 'cjs', // 模式
env: 'development',
// banner其实就是打包代码的一些生成的注释~~~~
banner
},
'web-runtime-cjs-prod': {
entry: resolve('web/entry-runtime.js'),
dest: resolve('dist/vue.runtime.common.prod.js'),
format: 'cjs',
env: 'production', banner
},
// Runtime+compiler CommonJS build (CommonJS)
'web-full-cjs-dev': {
entry: resolve('web/entry-runtime-with-compiler.js'),
dest: resolve('dist/vue.common.dev.js'),
format: 'cjs',
env: 'development',
alias: { he: './entity-decoder' },
banner
},
... ]
可以看出,builds对象是不同版本vue的编译配置。具体配置项的作用,已经用注释在代码中标出。
接下来我们看下genConfig函数做了什么。
代码地址:script/config.js 中的 genConfig
function genConfig (name) {
const opts = builds[name]
const config = {
input: opts.entry,
external: opts.external,
plugins: [
flow(),
alias(Object.assign({},
aliases,
opts.alias))
].concat(opts.plugins || []),
output: {
file: opts.dest,
format: opts.format,
banner: opts.banner,
name: opts.moduleName || 'Vue'
},
onwarn: (msg, warn) => {
if (!/Circular/.test(msg)) {
warn(msg)
}
}
}
...return config
genConfig 通过 key 拿到 builds 中每个key对应的配置对象,然后根据这个对象重新定义一个 config 对象,这个 config 对象的结构才是 rollup 配置真正需要的结构。
看了 builds 对象和 genConfig 方法,我们就知道了 getAllBuilds 的目的,是通过映射把 builds 配置对象转化成 rollup 所需要的配置数据。
到这里,我们就清楚是如何构建出不同版本的vue代码了。
2、Runtime Only VS Runtime + Compiler
1)Runtime Only 运行时构建,不包含模板编译器,借助vue-loader将.vue文件编译成js
优点: 代码体积轻量。
缺点: 运行时需要借助vue-loader,把template模版编译成render函数。
2)Runtime+Compiler 包含模板编译器
优点:动态把模版编译成render函数。
缺点:体积大,对性能有损耗。
初始化过程
1、Vue初始化过程:
- 初始化构造函数 (将_init方法挂载到Vue实例中,在实例化Vue时调用)。
- 初始化Vue._init方法。
- 初始化Vue状态,data/prop的get/set,$watch方法。
- 初始化Vue事件模型,$on/$off/$emit/$once方法。
- 初始化组件生命周期方法,_update/$forceUpdate/$destroy (_update在挂载DOM时调用)
- 初始化$nextTick/_render方法 (_render在渲染DOM时调用)
- 初始化global_api。
- 初始化 Vue平台信息。
详细分析:
1)真正初始化 Vue 的地方
代码地址:src/core/index.js
initGlobalAPI(Vue)
Object.defineProperty(Vue.prototype, '$isServer', {
get: isServerRendering
})
Object.defineProperty(Vue.prototype, '$ssrContext', {
get () {
/* istanbul ignore next */
return this.$vnode && this.$vnode.ssrContext
}
})
// expose FunctionalRenderContext for ssr runtime helper installation
Object.defineProperty(Vue, 'FunctionalRenderContext', {
value: FunctionalRenderContext
})
Vue.version = '__VERSION__'
在方法initGlobalAPI中,会初始化一切Vue全局的工具方法,像我们常用的全局注册组件Vue.components,全局注册指令Vue.directives,set/delete等。
2)Vue 的庐山真面目
它实际上就是一个用 Function 实现的类,我们只能通过 new 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)
}
// 初始化vue _init方法
initMixin(Vue)
// 初始化data, prop watch等
stateMixin(Vue)
// 初始化发布-订阅事件模型
eventsMixin(Vue)
// 初始化组件生命周期钩子
lifecycleMixin(Vue)
// 初始化nextTick, render
renderMixin(Vue)
在首次项目初始化的过程中,会初始化几个Vue的私有方法,后面在实例化Vue的时候,会经常用到,比如Vue实例上会挂载_init方法,在我们实例化Vue的过程中,实际上调用的是this._init方法,接收的参数options就是我们初始化时的配置项。_render方法是在我们挂载DOM时会被调用,之后会调用_update方法,每次DOM的初始化、更新,都会执行_update方法。
* 源码如何学
学习源码时,不建议按照源码的顺序一行一行的阅读。首先要抓住主干,先梳理清楚主要的代码逻辑,再去仔细阅读具体的每行代码。另外按照源码顺序阅读可能很枯燥,很难坚持下来,可以先选择自己感兴趣的部分进行学习,最后再串联起来。
参与资料:
Vue.js 技术揭秘:
https://ustbhuangyi.github.io/vue-analysis/
Vue 源码地址 :