一、Vue
源码解析–响应式原理
1、课程目标
Vue.js
的静态成员和实例成员初始化过程- 首次渲染的过程
- 数据响应式原理
2、准备工作
Vue
源码的获取
项目地址:https://github.com/vuejs/vue
为什么分析Vue2.6
? 新的版本发布后,现有项目不会升级到3.0
,2.x
还有很长的一段过渡期。
3.0
项目地址https://github.com/vuejs/vue-next
源码目录结构(在src
目录下面定义的就是源码内容):
compiler: 编译相关(主要作用:就是把模板转换成render函数,在render函数中创建虚拟DOM)
core:Vue核心库
platforms:平台相关代码,web:基于web的开发,weex是基于移动端的开发
server:SSR,服务端渲染
sfc:将.vue文件编译为js对象
shared:公共的代码
在core
目录是Vue
的核心库,在core
目录下面,也定义了很多的文件夹,下面我们先简单来看一下。
components
目录下面定义的是keep-alive.js
组件。
global-api
:定义的是Vue
中的静态方法。vue.filter
,vue.extend
,vue.mixin
,vue.use
等。
Instance
:创建vue
的实例,定义了Vue
的构造函数,初始化,以及生命周期的钩子函数等。
observer
:定义响应式机制的位置,
util
:定义公共成员。
vodom
:定义虚拟DOM
3、打包
这里我们来介绍一下,关于Vue
源码中使用的打包方式。
打包工具Rollup
Vue.js
所使用的打包工具为Rollup
,Rollup
比Webpack
更加轻量,Webpack
是把所有的文件(例如:图片文件,样式等)当作模块进行打包,Rollup
只处理js
文件,所以Rollup
更适合在Vue.js
这样的库中进行使用。
Rollup
打包不会生成冗余的代码,如果是Webpack
打包,那么会生成一些浏览器支持模块化的代码。
以上就是Webpack
与Rollup
之间的区别。根据以上的讲解,其实我们可以总结出,Rollup
更适合在库的开发中使用,Webpack
更适合在项目开发中使用,所以它们各自有自己的应用场景。
下面看一下打包的步骤:
第一步:安装依赖
npm i
第二步设置:sourcemap
sourcemap
是代码地图,在sourcemap
中记录了打包后的代码与源码之间的对应关系。如果出错了,也会告诉我们源码中的第几行出错了。怎样设置sourcemap
呢?在package.json
文件中的dev
脚本中添加--sourcemap
.
"dev": "rollup -w -c scripts/config.js --sourcemap --environment TARGET:web-full-dev",
第三步:执行dev
,运行npm run dev
执行打包,用的是rollup
,-w
参数是监听源码文件的变化,源码文件变化后自动的重新进行打包。-c
是设置配置文件,scripts/config.js
就是配置文件,environment
环境变量,通过后面设置的值,来打包生成不同版本的Vue
.web-full-dev
:web
:指的是打包web
平台下的,full
:表示完整版,包含了编译器与运行时,dev
:表示的是开发版本,不会对代码进行压缩。
web-runtime-cjs-dev
: runtime
:表示运行时,cjs
:表示CommonJS
模块。
在执行npm run dev
进行打包之前,可以先来看一下dist
目录,该目录下面已经有很多的js
文件,这些文件针对的是不同版本的Vue
.那么为了更好的看到,执行npm run dev
命令后的打包效果,在这里可以将这些文件先删除掉。
4、Vue不同版本说明
https://cn.vuejs.org/v2/guide/installation.html#对不同构建版本的解释
完整版:同时包含编译器
和运行时
版本。
什么是编译器?用来将模板字符串编译成为javascript
渲染函数(render
函数,render
函数用来生成虚拟DOM
)的代码,体积大,效率低。
什么是运行时?用来创建`Vue`实例,渲染并处理虚拟`DOM`等的代码,体积小,效率高,基本上就是除去编译器的代码。
还有一点需要说明的是:Vue
包含了不同的模块化方式。
UMD
:指的是通用的模块版本,支持多种模块方式,UMD
版本可以通过 <script>
标签直接用在浏览器中
CommonJS:CommonJS
版本用来配合老的打包工具比如[Browserify
](http://browserify.org/) 或[webpack 1
](https://webpack.github.io/)
ES Module
:从 2.6 开始 Vue
会提供两个 ES Modules (ESM,也是ES6的模块化方式,这时标准的模块化方式,后期会使用该方式替换其它的模块化方式)
构建文件:
- 为打包工具提供的
ESM
:为诸如[webpack 2](https://webpack.js.org/)
或[Rollup]
(https://rollupjs.org/) 提供的现代打包工具。ESM
格式被设计为可以被静态分析(在编译的时候进行代码的处理也就是解析模块之间的依赖,而不是运行时),所以打包工具可以利用这一点来进行“tree-shaking”并将用不到的代码排除出最终的包。为这些打包工具提供的默认文件 (pkg.module
) 是只有运行时的ES Module
构建 (vue.runtime.esm.js
)。 - 为浏览器提供的
ESM
(2.6+):用于在现代浏览器中通过<script type="module">
直接导入。
如果使用vue-cli
创建的项目,默认的就是运行时版本,并且使用的是ES6
的模块化方式。
同时使用vue-cli
创建的项目中,有很多的.vue
文件,而这些文件浏览器是不支持的,所以在打包的时候,会将这些单文件转换成js
对象,在转换js
对象的过程中,会将.vue
文件中的template
转换成render
函数。
所以单文件组件在运行的时候也是不需要编译器的。
5、寻找入口文件
查看vue
的源码,就需要找到对应的入口文件。
"dev": "rollup -w -c scripts/config.js --sourcemap --environment TARGET:web-full-dev",
可以从srcipts/config.js
这个配置文件中进行查找。
该配置文件中的内容是比较多的。所以可以看一下文件的底部,底部导出了相应的内容、
如下所示:
//判断环境变量是否有`TARGET`
//如果有的话,使用`genConfig()`生成`rollup`配置文件。
if (process.env.TARGET) {
module.exports = genConfig(process.env.TARGET)
} else {
exports.getBuild = genConfig
exports.getAllBuilds = () => Object.keys(builds).map(genConfig)
}
下面,我们看一下genConfig
方法的代码实现
在getConfig
方法中有一行代码如下所示:
const opts = builds[name]
我们看到在builds
这个对象中,该对象中的属性就是:环境变量的值。
由于在package.json
文件中,关于dev
的配置中的环境变量的值为web-full-dev
.
所以下面,我们在builds
对象中查找该属性对应的内容。
具体内容如下:
// Runtime+compiler development build (Browser)
'web-full-dev': {
//表示入口文件,我们查找的就是该文件。
entry: resolve('web/entry-runtime-with-compiler.js'),
//出口,打包后的目标文件
dest: resolve('dist/vue.js'),
//模块化的方式,这里是umd
format: 'umd',
//打包方式,env的取值可以是开发模式或者是生产模式
env: 'development',
//别名,这里先不用关系
alias: {
he: './entity-decoder' },
//表示的就是文件的头,打包好的文件的头部信息。
banner
},
在web-full-dev
中定义的就是在打包的时候,需要的一些配置的基本信息。
通过以上代码的注意,我们知道,web-full-dev
打包的是完整版,包含了运行时与编译器。
下面我们来看一下,入口文件,入口文件的地址为web/entry-runtime-with-compiler.js
, 但是问题是在scripts
目录中,我们没有发现web
目录,我们进入reslove
方法看一下,
const aliases = require('./alias')//导入alias模块
const resolve = p => {
//根据传递过来的参数,安装`/`进行分隔,然后获取第一项内容。
//很明显这里获取的是 web
const base = p.split('/')[0]
//根据获取到的`web`,从aliases中获取一个值,下面看一下aliases中的内容。
if (aliases[base]) {
//aliases[base]的值:src/platforms/web
// p的值为:web/entry-runtime-with-compiler.js
//p.slice(base.length + 1):获取到的就是entry-runtime-with-compiler.js
//整个返回的内容是:src/platforms/web/entry-runtime-with-compiler.js 的绝对路径并返回
return path.resolve(aliases[base], p.slice(base.length + 1))
} else {
return path.resolve(__dirname, '../', p)
}
}
aliases
中的内容定义在scripts/alias.js
文件,具体的代码如下:
const path = require('path')
const resolve = p => path.resolve(__dirname, '../', p)
module.exports = {
vue: resolve('src/platforms/web/entry-runtime-with-compiler'),
compiler: resolve('src/compiler'),
core: resolve('src/core'),
shared: resolve('src/shared'),
web: resolve('src/platforms/web'),
weex: resolve('src/platforms/weex'),
server: resolve('src/server'),
sfc: resolve('src/sfc')
}
通过上面的代码,我们可以看到这里是通过path.resolve
获取到了当前的绝对路径,并且是在scripts
目录的上一级src
下面去查找platforms/web
目录中的内容。
下面我们继续来看一下genConfig
方法。
function genConfig (name) {
//获取到了关于配置的基础信息
const opts = builds[name]
//config对象就是所有的配置信息
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)
}
}
}
// built-in vars
const vars = {
__WEEX__: !!opts.weex,
__WEEX_VERSION__: weexVersion,
__VERSION__: version
}
// feature flags
Object.keys(featureFlags).forEach(key => {
vars[`process.env.${
key}`] = featureFlags[key]
})
// build-specific env
if (opts.env) {
vars['process.env.NODE_ENV'] = JSON.stringify(opts.env)
}
config.plugins.push(replace(vars))
if (opts.transpile !== false) {
config.plugins.push(buble())
}
Object.defineProperty(config, '_name', {
enumerable: false,
value: name
})
//将配置信息返回
return config
}
6、从入口开始
通过上一小节的内容,我们已经找到了对应的入口文件src/platform/web/entry-runtime-with-compiler.js
下面我们要对入口文件进行分析,在分析的过程中,我们要解决一个问题,如下代码所示:
const vm=new Vue({
el:'#app',
template:'<h3>hello template</h3>'
render(h){
return h('h4','hello render')
}
})
在上面的代码中,我们在创建Vue
的实例的时候,同时指定了template
与render
,那么会渲染执行哪个内容?
一会我们通过查看源码来解决这个问题。
下面我们打开入口文件。
先来看一下$mount
//保留Vue实例的$mount方法
const mount = Vue.prototype.$mount;
//$mout:挂载,作用就是把生成的DOM挂载到页面中。
Vue.prototype.$mount = function (
el?: string | Element,
//非ssr情况下为false,ssr的时候为true
hydrating?: boolean
): Component {
//获取el选项,创建vue实例的时候传递过来的选项。
//el就是DOM对象。
el = el && query(el);
/* istanbul ignore if */
//如果el为body或者是html,并且是开发环境,那么会在浏览器的控制台
//中输出不能将Vue的实例挂载到<html>或者是<body>标签上
if (el === document.body || el === document.documentElement) {
process.env.NODE_ENV !== "production" &&
warn(
`Do not mount Vue to <html> or <body> - mount to normal elements instead.`
);
//直接返回vue的实例
return this;
}
//获取options选项
const options = this.$options;
// resolve template/el and convert to render function
//判断options中是否有render(在创建vue实例的时候,也就new Vue的时候是否传递了render函数)
if (!options.render) {
//没有传递render函数。获取template模板,然后将其转换成render函数
//关于将`template`转换成render的代码比较多,目录先知道其主要作用就可以了
let template = options.template;
if (template) {
if (typeof template === "string") {
//如果是id选择器
if (template.charAt(0) === "#") {
//获取对应的DOM对象的innerHTML,作为模板
template = idToTemplate(template);
/* istanbul ignore if */
if (process.env.NODE_ENV !== "production" && !template) {
warn(
`Template element not found or is empty: ${
options.template}`,
this
);
}
}
} else if (template.nodeType) {
//如果模板是元素,返回元素的innerHTML
template = template.innerHTML;
} else {
//如果不是字符串,也不是元素,在开发环境中会给出警告信息,模板不合法
if (process.env.NODE_ENV !== "production") {
warn("invalid template option:" + template, this);
}
//返回Vue实例。
return this;
}
} else if (el) {
//如果选项中没有设置template模板,那么获取el的outerHTML 作为模板。
template = getOuterHTML(el);
}
if (template) {
/* istanbul ignore if */
if (process.env.NODE_ENV !== "production" && config.performance && mark) {
mark("compile");
}
//把template模板编译成render函数
const {
render, staticRenderFns } = compileToFunctions(
template,
{
outputSourceRange: process.env.NODE_ENV !== "production",
shouldDecodeNewlines,
shouldDecodeNewlinesForHref,
delimiters: options.delimiters,
comments: options.comments,
},
this
);
options.render = render;
options.staticRenderFns = staticRenderFns;
/* istanbul ignore if */
if (process.env.NODE_ENV !== "production" && config.performance && mark) {
mark("compile end");
measure(`vue ${
this._name} compile`, "compile", "compile end");
}
}
}
//如果创建 Vue实例的时候,传递了render函数,这时会直接调用mount方法。
// mount方法的作用就是渲染DOM,这块内容在下一小节会讲解到。
return mount.call(this, el, hydrating);
};
下面代码是query
方法实现的代码。
/**
* Query an element selector if it's not an element already.
*/
export function query(el: string | Element): Element {
// 如果el等于字符串,表明是选择器。
//否则是DOM对象,直接返回
if (typeof el === "string") {
//获取对应的DOM元素
const selected = document.querySelector(el);
if (!selected) {
//如果没有找到,判断是否为开发模式,如果是开发模式
//在控制台打印“找不到元素”
process.env.NODE_ENV !== "production" &&
warn("Cannot find element: " + el);
//这时会创建一个`div`元素返回。
return document.createElement("div");
}
//返回找到的dom元素
return selected;
} else {
return el;
}
}
看完上面的代码后,我们就可以回答最开始的时候,提出的问题,如果传递了render
函数,是不会处理template
这个模板的,直接调用mount
方法渲染dom
现在面临的一个问题就是$mount
这个方法是在哪儿被调用的呢?
在core/instance/init.js
文件中,查找到如下代码:
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
通过以上代码,可以看到调用了$mount
方法。
也就是在Vue._init
方法中调用的。
那么_init
方法是在哪被调用的呢?
在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
方法,而Vue
方法,是在创建Vue
实例的时候被调用的。所以上面的Vue
方法就是一个构造函数。
现在我们将以上的内容做一个总结,重点是以下三点内容。
el
不能是body
或者是html
标签- 如果没有
render
,把template
转换成render
函数。 - 如果有
render
方法,直接调用mount
挂载DOM
7、Vue的初始化过程
在这一小节中,我们需要考虑如下的一个问题。
Vue
实例成员和Vue
的静态成员是从哪里来的?
在src/platforms/web
目录下面定义的文件都是与平台有关的文件。
下面我们还是看一下开始文件:entry-runtime-with-compiler.js
文件。
/* @flow */
import config from "core/config";
import {
warn, cached } from "core/util/index";
import {
mark, measure } from "core/util/perf";
//导入Vue的构造函数
import Vue from "./runtime/index";
import {
query } from "./util/index";
import {
compileToFunctions } from "./compiler/index";
import {
shouldDecodeNewlines,
shouldDecodeNewlinesForHref,
} from "./util/compat";
const idToTemplate = cached((id) => {
const el = query(id);
return el && el.innerHTML;
});
//保留Vue实例的$mount方法,方便下面重写$mount的功能
const mount = Vue.prototype.$mount;
//$mout:挂载,作用就是把生成的DOM挂载到页面中。
Vue.prototype.$mount = function (
el?: string | Element,
//非ssr情况下为false,ssr的时候为true
hydrating?: boolean
): Component {
//获取el选项,创建vue实例的时候传递过来的选项。
//el就是DOM对象。
el = el && query(el);
/* istanbul ignore if */
//如果el为body或者是html,并且是开发环境,那么会在浏览器的控制台
//中输出不能将Vue的实例挂载到<html>或者是<body>标签上
if (el === document.body || el === document.documentElement) {
process.env.NODE_ENV !== "production" &&
warn(
`Do not mount Vue to <html> or <body> - mount to normal elements instead.`
);
//直接返回vue的实例
return this;
}
//获取options选项
const options = this.$options;
// resolve template/el and convert to render function
//判断options中是否有render(在创建vue实例的时候,也就new Vue的时候是否传递了render函数)
if (!options.render) {
//没有传递render函数。获取template模板,然后将其转换成render函数
//关于将`template`转换成render的代码比较多,目录先知道其主要作用就可以了
let template = options.template;
// 如果模板存在
if (template) {
//判断对应的类型如果是字符串
if (typeof template === "string") {
//如果模板是id选择器
if (template.charAt(0) === "#") {
//获取对应的DOM对象的innerHTML
template = idToTemplate(template);
/* istanbul ignore if */
if (process.env.NODE_ENV !== "production" && !template) {
warn(
`Template element not found or is empty: ${
options.template}`,
this
);
}
}
} else if (template.nodeType) {
//如果模板是元素,返回元素的innerHTML
template = template.innerHTML;
} else {
if (process.env.NODE_ENV !== "production") {
warn("invalid template option:" + template, this);
}
return this;
}
} else if (el) {
//如果模板不存在
template = getOuterHTML(el);
}
if (template) {
/* istanbul ignore if */
if (process.env.NODE_ENV !== "production" && config.performance && mark) {
mark("compile");
}
//把template模板编译成render函数
const {
render, staticRenderFns } = compileToFunctions(
template,
{
outputSourceRange: process.env.NODE_ENV !== "production",
shouldDecodeNewlines,
shouldDecodeNewlinesForHref,
delimiters: options.delimiters,
comments: options.comments,
},
this
);
options.render = render;
options.staticRenderFns = staticRenderFns;
/* istanbul ignore if */
if (process.env.NODE_ENV !== "production" && config.performance && mark) {
mark("compile end");
measure(`vue ${
this._name} compile`, "compile", "compile end");
}
}
}
//如果创建 Vue实例的时候,传递了render函数,这时会直接调用mount方法。
// mount方法的作用就是渲染DOM,这里的mount就是下面我们要看的./runtime/index文件中的$mount,只不过
// 在当前的文件中重写了。
return mount.call(this, el, hydrating);
};
/**
* Get outerHTML of elements, taking care
* of SVG elements in IE as well.
*/
function getOuterHTML(el: Element): string {
if (el.outerHTML) {
//如果有outerHTML属性,返回内容的HTML形式
return el.outerHTML;
} else {
//创建div
const container = document.createElement("div");
//把el的内容克隆,然后追加到div中
container.appendChild(el.cloneNode(true));
//返回div的innerHTML
return container.innerHTML;
}
}
//注册Vue.compile方法,根据HTML字符串返回render函数
Vue.compile = compileToFunctions;
export default Vue;
通过查看该文件中的代码,可以发现,在该文件中并没有创建Vue
的实例,关于实例的创建在如下导入的文件中。
//导入Vue的构造函数
import Vue from "./runtime/index";
通过前面的讲解,我们知道在入口文件中,最主要的方法是mount
.
//保留Vue实例的$mount方法,方便下面重写$mount的功能
const mount = Vue.prototype.$mount;
在该方法中,很重要的一个操作就是将template
模板,转换成render
函数。
下面,我们先来看一下./runtime/index
文件中的内容。
// install platform specific utils
//给Vue.config注册了方法,这些方法都是与平台相关的方法。这些方法是在Vue内部使用的。
Vue.config.mustUseProp = mustUseProp;
//是否为保留的标签,也就是说,传递过来的内容是否为HTML中特有的标签
Vue.config.isReservedTag = isReservedTag;
//是否是保留的属性,也就是说,传递过来的内容是否为HTML中特有的属性
Vue.config.isReservedAttr = isReservedAttr;
Vue.config.getTagNamespace = getTagNamespace;
Vue.config.isUnknownElement = isUnknownElement;
以上内容简短了解一下就可以。
下面我们继续查看如下内容:
// install platform runtime directives & components
//通过extend方法注册了与平台相关的全局的指令与组件。
//extend的作用就是将第二个参数的成员全部拷贝到第一个参数中
//那么问题是注册了哪些指令与组件呢?
extend(Vue.options.directives, platformDirectives);
extend(Vue.op