背景
Vue作为目前前端三剑客来说,基本是人手必会的了,并且越来越多的公司开始使用Vue框架进行前端业务的开发。但是更多的开发者都停留在组件的搬运和浅显的Vue基础使用,没有深究Vue本身所蕴含的思想和实现原理。这短时间看来对于业务开发并没有什么帮助,但是长久上看,要想成为一名高级前端工程师,深究框架实现原理是进阶的资粮。
另外,在之前部门内部分享中,一名同事分享了如何手动实现一个简易Vue框架。其中讲了一些数据劫持、数据绑定等知识,让我大开眼界的同时也深深怀疑自己,自己实在是太菜了。不过我这时候发现了,以前阅读的《JavaScript高级程序设计》中很多枯燥的知识在这里得到了使用。以前读这本书的时候就很怀疑,这些基础知识到底能被用在什么地方呢?在同事的分享中,找到了答案,那就是运用在前端底层框架内的开发上。我们平常工作书写的业务代码,都是讲js作为实现逻辑的工具,并没有在js内部去寻找一些东西。进入内部去结合知识去理解,才能更自然地理解一些复杂的问题。
正好公司需要上缴四季度的绩效考核文件,所以将Vue源码阅读作为个人成长的一部分,并书写博客文章记录下来。
正文
在这次阅读源码中,希望能够摸索出适合自己的学习新知识的方式,首先说明一下源码的版本是19年12月的最新版,版本号是2.6.11:
对于Vue源码阅读,我是一点思路都没有的。同事的内部分享中抛出了很多函数与代码,勉强理解了其中一点,剩下的就不懂了。正好也趁此机会,归纳一下自己的学习方法。首先去github下载了Vue的库,准备在本地一点点阅读。打开文件一看,有点懵逼了,这个文件结构我没见过啊!
但是,不慌!那么多的项目,结构肯定千差万别的,但是其中文件的角色是相同的,重点要放在文件的所起的作用上。通过查资料和看代码 基本确定了这些主要目录的作用和属性:
├── scripts ------------------------------- 包含与构建相关的脚本和配置文件
│ ├── alias.js -------------------------- 源码中使用到的模块导入别名
│ ├── config.js ------------------------- 项目的构建配置
├── build --------------------------------- 构建相关的文件,一般情况下我们不需要动
├── dist ---------------------------------- 构建后文件的输出目录
├── examples ------------------------------ 存放一些使用Vue开发的应用案例
├── flow ---------------------------------- JS静态类型检查工具[Flow](https://flowtype.org/)的类型声明
├── package.json
├── test ---------------------------------- 测试文件
├── src ----------------------------------- 源码目录
│ ├── compiler -------------------------- 编译器代码,用来将 template 编译为 render 函数
│ │ ├── parser ------------------------ 存放将模板字符串转换成元素抽象语法树的代码
│ │ ├── codegen ----------------------- 存放从抽象语法树(AST)生成render函数的代码
│ │ ├── optimizer.js ------------------ 分析静态树,优化vdom渲染
│ ├── core ------------------------------ 存放通用的,平台无关的运行时代码
│ │ ├── observer ---------------------- 响应式实现,包含数据观测的核心代码
│ │ ├── vdom -------------------------- 虚拟DOM的 creation 和 patching 的代码
│ │ ├── instance ---------------------- Vue构造函数与原型相关代码
│ │ ├── global-api -------------------- 给Vue构造函数挂载全局方法(静态方法)或属性的代码
│ │ ├── components -------------------- 包含抽象出来的通用组件,目前只有keep-alive
│ ├── server ---------------------------- 服务端渲染(server-side rendering)的相关代码
│ ├── platforms ------------------------- 不同平台特有的相关代码
│ │ ├── weex -------------------------- weex平台支持
│ │ ├── web --------------------------- web平台支持
│ │ │ ├── entry-runtime.js ---------------- 运行时构建的入口
│ │ │ ├── entry-runtime-with-compiler.js -- 独立构建版本的入口
│ │ │ ├── entry-compiler.js --------------- vue-template-compiler 包的入口文件
│ │ │ ├── entry-server-renderer.js -------- vue-server-renderer 包的入口文件
│ ├── sfc ------------------------------- 包含单文件组件.vue文件的解析逻辑,用于vue-template-compiler包
│ ├── shared ---------------------------- 整个代码库通用的代码
看到这么多的目录 以及一大堆的专业术语 肯定是一脸懵逼的进来 一脸懵逼的出去 也就是说平时我们接触的Vue的实例等等 都是表面最终生成的构造函数或者方法。这里先抓住主要的内容,把重要的几个目录先拎出来看看:
- compiler:编译器,用来将template转化为render函数
- core: Vue的核心代码,包括响应式实现、虚拟DOM、Vue实例方法的挂载、全局方法、抽象出来的通用组件等
- platform:不同平台的入口文件,主要是 web 平台和 weex 平台的,不同平台有其特殊的构建过程,当然我们的重点是 web 平台
- server:服务端渲染(SSR)的相关代码,SSR 主要把组件直接渲染为 HTML 并由 Server 端直接提供给 Client 端
- sfc:主要是 .vue 文件解析的逻辑
- shared:一些通用的工具方法,有一些是为了增加代码可读性而设置的
然后接下来要从哪个文件开始看起呢?这里看了一些同行的博文,找到了一个好的方法。从package.json
文件往上回溯找到核心代码。
博文中说任何前端项目都可以从 package.json
文件看起,先来看看它的 script.dev
就是我们运行 npm run dev
的时候它的命令行:
"scripts": {
"dev": "rollup -w -c scripts/config.js --environment TARGET:web-full-dev"
}
这里的 rollup 是一个类似于 webpack 的JS模块打包器,事实上
Vue - v1.0.10
版本之前用的还是 webpack ,其后改成了 rollup ,如果想知道为什么换成 rollup ,可以看看 尤雨溪本人的回答,总的来说就是为了打出来的包体积小一点,初始化速度快一点。
可以看到这里 rollup 去运行 scripts/config.js
文件,并且给了个参数 TARGET:web-full-dev
,那来看看 scripts/config.js
里面是什么:
// scripts/config.js
const builds = {
....
// Runtime+compiler development build (Browser)
'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
},
...
}
这里的 web-full-dev
就是对应刚刚我们在命令行里传入的命令,那么 rollup 就会按下面的 entry 入口文件开始去打包,还有其他很多命令和其他各种输出方式和格式可以自行查看一下源码。那么这里的重点是web/entry-runtime-with-compiler.js
文件,全局搜索一下,找到了这个文件的位置:src/platforms/web/web/entry-runtime-with-compiler.js
。打开这个文件,发现导入了一个Vue:
顺着这个引入路径找到runtime/index
文件,发现这里的Vue也是导入的:
继续回溯,找到src/core/index.js
文件。上面就说了这个文件里面是Vue的核心代码,一些Vue的关键特性就是在这个文件里面编写的,这说明我们的回溯路径是正确的。
但遗憾的是,这里的Vue还是引入的,不着急继续找。打开instance/index
文件,终于找到目标了!!
这里的Vue构造函数就是我们追寻多久的,当我们 new Vue( ) 的时候,实际上调用的就是这个构造函数,可以从这里开始看了。
这里是构造函数的核心文件,先是引入依赖,然后定义名字为Vue的构造函数。然后调用五个方法,把构造函数作为参数传入进去。这五个方法就是在Vue构造函数的原型Prototype上挂载方法或属性,也就是说这个五个方法所挂载的书写构成了Vue的构造函数。形象的说就像夹心雪糕一样,一层层的包裹,最终成为了完整的构造函数。
内部的包装已经完毕,沿着路径寻找到了下一步,到了core层下的index.js
:
在这一层又挂载和添加了什么东西?可以看到在这一层又给vue的构造函数挂载initGlobalAPI 和 isServerRendering 以及版本信息, 我们先不去扣这一系列的挂载都起了什么作用,先走完这整体流程。(当然命名的文件名基本上就是所挂载的东西、很直观)
当然,最主要的还是整体,避免一叶障目。
// 引入了之前的构造函数
import Vue from './instance/index'
import { initGlobalAPI } from './global-api/index'
import { isServerRendering } from 'core/util/env'
import { FunctionalRenderContext } from 'core/vdom/create-functional-component'
// 将之前的构造函数Vue作为参数传进去
initGlobalAPI(Vue)
// 挂载isServer
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__'
export default Vue
到这里基本上vue上该挂载的都挂载上了,那么下一步的话就到了platforms这里,也就是平台划分,安装不同平台特有的方法,而且整体的划分了web端以及weex端。
那么在这个platform里又干了什么? 以为web端为例:
- 覆盖vue.config属性 替换为平台特有的属性和方法
- extend 安装相应的指令和组
- 在vue.prototype 上定义patch 以及$mount
- 关于vue devtools的一些设置
接下来就到了最后一个处理Vue的地方 entry-runtime-with-compiler
:
最后一阶段主要是重写挂载以及添加编译器,也就是将模板template编译为render函数 。
到这里vue的构造函数才算是真正的新鲜出炉。总结一下:
1. 在第一阶段,整体注入了五个部分,vue构造函数主体部分完成,包括各项初始化,以及发布订阅模式等等
- initMixin => created周期函数之前的操作,即各项初始化,期间调用 beforeCreate 钩子
- stateMixin => 利用 definedProperty 进行静态数据的订阅发布,并在其中实现几项实例
api $set、$delete、 $watch, - eventsMixin => 实例事件流的注入, 利用的是订阅发布模式的事件流构造
- lifecycleMixin => 注入几个Vue原型函数
- renderMixin => 实现实例api $nextTick,后续详解,实现 _render 渲染虚拟dom
- Vue.prototype._update => 调用生命周期钩子 beforeUpdate,其后实现 virtual dom 的更
新; - Vue.prototype.$forceUpdate => 实现实例 api forceUpdate 强制重新渲染实例,包括其下
的子组件(更新了 watcher 队列); - Vue.prototype.$destroy => 调用生命周期钩子 beforeDestroy , 其后移除各项实例子组件,
拆卸实例的watcher队列及调用实例的 patch 方法将 virtual dom 置空(null),最后调用
钩子 destroyed 并解除(实例api:$off)实例所有事件;
- Vue.prototype._update => 调用生命周期钩子 beforeUpdate,其后实现 virtual dom 的更
2. 在第二阶段挂载静态的属性和方法
- 第三阶段 添加web平台所需要的配置、组件和指令,以及编译等。