Vue.js运行机制

全局概览

Vue运行内部运行机制 总览图:


根据vue2的源码,我不太认同整个流程。这个图我也是从网上找的。比如。Watcher应该是发生在$mount内的,具体的可以往下看…

初始化及挂载

new Vue()之后。 Vue 会调用 _init 函数进行初始化,也就是这里的 init 过程,它会初始化生命周期事件propsmethodsdatacomputedwatch 等。其中最重要的是通过 Object.defineProperty 设置 settergetter 函数,用来实现「响应式」以及「依赖收集」,后面会详细讲到,这里只要有一个印象即可。

初始化之后调用 $mount 会挂载组件,如果是运行时编译,即不存在 render function但是存在 template 的情况,需要进行「编译」步骤。
因为编译有构建时编译与运行时编译的,其目的都是将template转化炒年糕render function,所以如果运行时检查到template存在但是没有render function的情况下会把template编译成render function

编译

compile编译可以分成 parseoptimizegenerate 三个阶段,最终需要得到 render function

parse(解析)

parse 会用正则等方式解析 template 模板中的指令、classstyle等数据,形成AST

optimize(优化)

optimize 的主要作用是标记 static 静态节点,这是 Vue 在编译过程中的一处优化,后面当 update 更新界面时,会有一个 patch 的过程, diff 算法会直接跳过静态节点,从而减少了比较的过程,优化了 patch 的性能。

generate(生成)

generate 是将 AST 转化成 render function 字符串的过程,得到结果是 render 的字符串以及 staticRenderFns 字符串。
在经历过 parseoptimizegenerate 这三个阶段以后,组件中就会存在渲染 VNode 所需的 render function 了。

响应式

接下来也就是 Vue.js 响应式核心部分。
这里的 gettersetter 已经在之前介绍过了,在 init 的时候通过 Object.defineProperty 进行了绑定,它使得当被设置的对象被读取的时候会执行 getter 函数,而在当被赋值的时候会执行 setter 函数。
render function 被渲染的时候,因为会读取所需对象的值,所以会触发 getter 函数进行「依赖收集」,「依赖收集」的目的是将观察者 Watcher 对象存放到当前闭包中的订阅者 Depsubs 中。形成如下所示的这样一个关系。

在修改对象的值的时候,会触发对应的 settersetter 通知之前「依赖收集」得到的 Dep 中的每一个 Watcher ,告诉它们自己的值改变了,需要重新渲染视图。这时候这些 Watcher 就会开始调用 update 来更新视图,当然这中间还有一个 patch 的过程以及使用队列来异步更新的策略,这个我们后面再讲。

Virtual DOM

我们知道,render function 会被转化成 VNode 节点。Virtual DOM 其实就是一棵以 JavaScript 对象( VNode 节点)作为基础的树,用对象属性来描述节点,实际上它只是一层对真实 DOM 的抽象。最终可以通过一系列操作使这棵树映射到真实环境上。由于 Virtual DOM 是以 JavaScript 对象为基础而不依赖真实平台环境,所以使它具有了跨平台的能力,比如说浏览器平台、WeexNode 等。
比如说下面这样一个例子:

{
tag: 'div', /*说明这是一个div标签*/
children: [ /*存放该标签的子节点*/
{
tag: 'a', /*说明这是一个a标签*/
text: 'click me' /*标签的内容*/
}
]
}

渲染后可以得到

<div>
<a>click me</a>
</div>

这只是一个简单的例子,实际上的节点有更多的属性来标志节点,比如 isStatic (代表是否为静态节点)、 isComment (代表是否为注释节点)等。

更新视图

前面我们说到,在修改一个对象值的时候,会通过 setter -> Watcher -> update 的流程来修改对应的视图,那么最终是如何更新视图的呢?

当数据变化后,执行 render function 就可以得到一个新的 VNode 节点,我们如果想要得到新的视图,最简单粗暴的方法就是直接解析这个新的 VNode 节点,然后用 innerHTML 直接全部渲染到真实 DOM 中。但是其实我们只对其中的一小块内容进行了修改,这样做似乎有些「浪费」。

那么我们为什么不能只修改那些「改变了的地方」呢?这个时候就要介绍「patch」了。我们会将新的 VNode 与旧的 VNode 一起传入 patch 进行比较,经过 diff 算法得出它们的「差异」。最后我们只需要将这些「差异」的对应 DOM 进行修改即可。

响应式系统的基本原理

响应式系统

Vue.js 是一款 MVVM 框架,数据模型仅仅是普通的 JavaScript 对象,但是对这些对象进行操作时,却能影响对应视图,它的核心实现就是「响应式系统」。尽管我们在使用 Vue.js 进行开发时不会直接修改「响应式系统」,但是理解它的实现有助于避开一些常见的「坑」,也有助于在遇见一些琢磨不透的问题时可以深入其原理来解决它。
Object.defineProperty
首先我们来介绍一下 Object.definePropertyVue.js就是基于它实现「响应式系统」的。

首先是使用方法:

/*
obj: 目标对象
prop: 需要操作的目标对象的属性名
descriptor: 描述符=>{
enumerable: false, //对象的属性是否可以在 for...in 循环和 Object.keys() 中被枚举
configurable: false, //对象的属性是否可以被删除,以及除writable特性外的其他特性是否可以被修改。
writable: false, //为true时,value才能被赋值运算符改变。默认为 false。
value: "static", //该属性对应的值。可以是任何有效的 JavaScript 值(数值,对象,函数等)。默认为 undefined。
get : function(){ //一个给属性提供 getter 的方法,如果没有 getter 则为 undefined。该方法返回值被用作属性值。默认为 undefined。
return this.value;
},
set : function(newValue){ //提供 setter 的方法,如果没有 setter 则为 undefined。将该参数的新值分配给该属性。默认为 undefined。
this.value = newValue;
},
}
return value 传入对象
*/
Object.defineProperty(obj, prop, descriptor)

// 举个栗子

// 使用 __proto__
var obj = {};
var descriptor = Object.create(null); // 没有继承的属性
// 默认没有 enumerable,没有 configurable,没有 writable
descriptor.value = 'static';
Object.defineProperty(obj, 'key', descriptor);

// 显式
Object.defineProperty(obj, "key", {
enumerable: false,
configurable: false,
writable: false,
value: "static"
});
// 在对象中添加一个属性与存取描述符的示例
var bValue;
Object.defineProperty(o, "b", {
get : function(){
return bValue;
},
set : function(newValue){
bValue = newValue;
},
enumerable : true,
configurable : true
});

要熟悉Object.defineProperty可以去MDN文档复习示例。

实现 observer(可观察的)

知道了 Object.defineProperty 以后,我们来用它使对象变成可观察的。
这一部分的内容我们在第二小节中已经初步介绍过,在 init 的阶段会进行初始化,对数据进行「响应式化」。

为了便于理解,我们不考虑数组等复杂的情况,只对对象进行处理。

首先我们定义一个 cb 函数,这个函数用来模拟视图更新,调用它即代表更新视图,内部可以是一些更新视图的方法。

function cb (val) {
/* 渲染视图 */
console.log("视图更新啦~");
}

然后我们定义一个 defineReactive ,这个方法通过 Object.defineProperty 来实现对对象的「响应式」化,入参是一个 obj(需要绑定的对象)、keyobj的某一个属性),val(具体的值)。经过 defineReactive 处理以后,我们的 objkey 属性在「读」的时候会触发 reactiveGetter 方法,而在该属性被「写」的时候则会触发 reactiveSetter 方法。

function defineReactive (obj, key, val) {
Object.defineProperty(obj, key, {
enumerable: true, /* 属性可枚举 */
configurable: true, /* 属性可被修改或删除 */
get: function reactiveGetter () {
return val; /* 实际上会依赖收集,下一小节会讲 */
},
set: function reactiveSetter (newVal) {
if (newVal === val) return;
cb(newVal);
}
});
}

当然这是不够的,我们需要在上面再封装一层 observer 。这个函数传入一个 value(需要「响应式」化的对象),通过遍历所有属性的方式对该对象的每一个属性都通过 defineReactive 处理。

function observer (value) {
if (!value || (typeof value !== 'object')) {/*只考虑对象,非对象返回*/
return;
}

Object.keys(value).forEach((key) => {
defineReactive(value, key, value[key]);
});
}

最后,让我们用 observer 来封装一个 Vue 吧!
Vue的构造函数中,对optionsdata进行处理,这里的data想必大家很熟悉,就是平时我们在写Vue项目时组件中的data属性(实际上是一个函数,这里当做一个对象来简单处理)

class Vue{
/* Vue 构造类 */
constructor(options) {
this._data = options.data;
observer(this._data);
}
}

这样我们只要 new 一个 Vue 对象,就会将 data 中的数据进行「响应式」化。如果我们对 data 的属性进行下面的操作,就会触发 cb 方法更新视图。

let o = new Vue({
data: {
test: "I am test."
}
});
o._data.test = "hello,world."; /* 视图更新啦~ */

响应式系统的依赖收集追踪原理

为什么要依赖收集?

先举个栗子🌰
我们现在有这么一个 Vue 对象。

new Vue({
template:
`<div>
<span>{{text1}}</span>
<span>{{text2}}</span>
<div>`,
data: {
text1: 'text1',
text2: 'text2',
text3: 'text3'
}
});

然后我们做了这么一个操作。

this.text3 = 'modify text3';

我们修改了 datatext3 的数据,但是因为视图中并不需要用到 text3 ,所以我们并不需要触发上一章所讲的 cb 函数来更新视图,调用 cb 显然是不正确的。
再来一个栗子🌰
假设我们现在有一个全局的对象,我们可能会在多个 Vue 对象中用到它进行展示。

let globalObj = {
text1: 'text1'
};

let o1 = new Vue({
template:
`<div>
<span>{{text1}}</span>
<div>`,
data: globalObj
});

let o2 = new Vue({
template:
`<div>
<span>{{text1}}</span>
<div>`,
data: globalObj
});

这个时候,我们执行了如下操作。

globalObj.text1 = 'hello,text1';

我们应该需要通知 o1 以及 o2 两个vm实例进行视图的更新,「依赖收集」会让 text1 这个数据知道“哦~有两个地方依赖我的数据,我变化的时候需要通知它们~”。

最终会形成数据与视图的一种对应关系,如下图.

接下来我们来介绍一下「依赖收集」是如何实现的。

订阅者 Dep

首先我们来实现一个订阅者 Dep ,它的主要作用是用来存放 Watcher 观察者对象。

class Dep {
constructor () {
/* 用来存放Watcher对象的数组 */
this.subs = [];
}

/* 在subs中添加一个Watcher对象 */
addSub (sub) {
this.subs.push(sub);
}

/* 通知所有Watcher对象更新视图 */
notify () {
this.subs.forEach((sub) => {
sub.update();
})
}
}

为了便于理解我们只实现了添加的部分代码,主要是两件事情:

addSub 方法可以在目前的 Dep 对象中增加一个 Watcher 的订阅操作;
notify 方法通知目前 Dep 对象的 subs 中的所有 Watcher 对象触发更新操作。

观察者 Watcher

class Watcher {
constructor () {
/* 在new一个Watcher对象时将该对象赋值给Dep.target,在get中会用到 */
Dep.target = this;
}

/* 更新视图的方法 */
update () {
console.log("视图更新啦~");
}
}

Dep.target = null;

依赖收集

接下来我们修改一下 defineReactive 以及 Vue 的构造函数,来完成依赖收集。

我们在闭包中增加了一个 Dep 类的对象,用来收集 Watcher 对象。在对象被「读」的时候,会触发 reactiveGetter 函数把当前的 Watcher 对象(存放在 Dep.target 中)收集到 Dep 类中去。之后如果当该对象被「写」的时候,则会触发 reactiveSetter 方法,通知 Dep 类调用 notify 来触发所有 Watcher 对象的 update 方法更新对应视图。

/*订阅者*/
class Dep {
constructor () {
/* 用来存放Watcher对象的数组 */
this.subs = [];
}

/* 在subs中添加一个Watcher对象 */
addSub (sub) {
this.subs.push(sub);
}

/* 通知所有Watcher对象更新视图 */
notify () {
this.subs.forEach((sub) => {
sub.update();
})
}
}
/*观察者*/
class Watcher {
constructor () {
/* 在new一个Watcher对象时将该对象赋值给Dep.target,在get中会用到 */
Dep.target = this;
}

/* 更新视图的方法 */
update () {
console.log("视图更新啦~");
}
}
/*依赖收集*/
function defineReactive (obj, key, val) {
/* 一个Dep类对象 */
const dep = new Dep();

Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
/* 读取的时候将Dep.target(即当前的Watcher对象存入dep的subs中) */
dep.addSub(Dep.target);
return val;
},
set: function reactiveSetter (newVal) {
if (newVal === val) return;
/* 在set的时候触发dep的notify来通知所有的Watcher对象更新视图 */
dep.notify();
}
});
}

class Vue {
constructor(options) {
this._data = options.data;
observer(this._data);
/* 新建一个Watcher观察者对象,这时候Dep.target会指向这个Watcher对象 */
new Watcher();
/* 在这里模拟render的过程,为了触发test属性的get函数 */
console.log('render~', this._data.test);
}
}

这个我们在 Vue 的构造类中处理。新建一个 Watcher 对象只需要 new 出来,这时候 Dep.target 已经指向了这个 new 出来的 Watcher 对象来。而触发 get 方法也很简单,实际上只要把 render function 进行渲染,那么其中的依赖的对象都会被「读取」,这里我们通过打印来模拟这个过程,读取 test 来触发 get 进行「依赖收集」。

本章我们介绍了「依赖收集」的过程,配合之前的响应式原理,已经把整个「响应式系统」介绍完毕了。其主要就是 get 进行「依赖收集」。set 通过观察者来更新视图,配合下图仔细捋一捋,相信一定能搞懂它!

实现 Virtual DOM 下的一个 VNode 节点

什么是VNode

我们知道,render function 会被转化成 VNode 节点。Virtual DOM 其实就是一棵以 JavaScript 对象(VNode 节点)作为基础的树,用对象属性来描述节点,实际上它只是一层对真实 DOM 的抽象。最终可以通过一系列操作使这棵树映射到真实环境上。由于 Virtual DOM 是以 JavaScript 对象为基础而不依赖真实平台环境,所以使它具有了跨平台的能力,比如说浏览器平台、WeexNode 等。

实现一个VNode

VNode 归根结底就是一个 JavaScript 对象,只要这个类的一些属性可以正确直观地描述清楚当前节点的信息即可。我们来实现一个简单的 VNode 类,加入一些基本属性,为了便于理解,我们先不考虑复杂的情况。

class VNode {
constructor (tag, data, children, text, elm) {
/*当前节点的标签名*/
this.tag = tag;
/*当前节点的一些数据信息,比如props、attrs等数据*/
this.data = data;
/*当前节点的子节点,是一个数组*/
this.children = children;
/*当前节点的文本*/
this.text = text;
/*当前虚拟节点对应的真实dom节点*/
this.elm = elm;
}
}

比如我现在有一个vue组件

<template>
<span class="demo" v-show="isShow">
This is a span.
</span>
</template>

用 JavaScript 代码形式就是这样的。

function render () {
return new VNode(
'span',
{
/* 指令集合数组 */
directives: [
{
/* v-show指令 */
rawName: 'v-show',
expression: 'isShow',
name: 'show',
value: true
}
],
/* 静态class */
staticClass: 'demo'
},
[ new VNode(undefined, undefined, undefined, 'This is a span.') ]
);
}

转化成VNode之后的情况

{
tag: 'span',
data: {
/* 指令集合数组 */
directives: [
{
/* v-show指令 */
rawName: 'v-show',
expression: 'isShow',
name: 'show',
value: true
}
],
/* 静态class */
staticClass: 'demo'
},
text: undefined,
children: [
/* 子节点是一个文本VNode节点 */
{
tag: undefined,
data: undefined,
text: 'This is a span.',
children: undefined
}
]
}

然后我们可以将 VNode 进一步封装一下,可以实现一些产生常用 VNode 的方法。

创建一个空节点

function createEmptyVNode () {
const node = new VNode();
node.text = '';
return node;
}

创建一个文本节点

function createTextVNode (val) {
return new VNode(undefined, undefined, undefined, String(val));
}

克隆一个 VNode 节点

function cloneVNode (node) {
const cloneVnode = new VNode(
node.tag,
node.data,
node.children,
node.text,
node.elm
);
return cloneVnode;
}

总的来说,VNode 就是一个 JavaScript 对象,用 JavaScript 对象的属性来描述当前节点的一些状态,用 VNode 节点的形式来模拟一棵 Virtual DOM 树。

template 模板是怎样通过 Compile 编译的

Compile

compile 编译可以分成 parseoptimizegenerate 三个阶段,最终需要得到 render function。这部分内容不算 Vue.js 的响应式核心,只是用来编译的,精力有限的情况下不需要追究其全部的实现细节,能够把握如何解析的大致流程即可。

由于解析过程比较复杂,直接上代码可能会导致不了解这部分内容的同学一头雾水。所以准备提供一个 template 的示例,通过这个示例的变化来看解析的过程。但是解析的过程及结果都是将最重要的部分抽离出来展示,希望能让你们更好地了解其核心部分的实现。

<div :class="c" class="demo" v-if="isShow">
<span v-for="item in sz">{{item}}</span>
</div>

var html = '<div :class="c" class="demo" v-if="isShow"><span v-for="item in sz">{{item}}</span></div>';

接下来的过程都会依赖这个示例来进行。

parse

首先是 parseparse 会用正则等方式将 template 模板中进行字符串解析,得到指令、classstyle等数据,形成 AST
这个过程比较复杂,会涉及到比较多的正则进行字符串解析,我们来看一下得到的 AST 的样子。

{
/* 标签属性的map,记录了标签上属性 */
'attrsMap': {
':class': 'c',
'class': 'demo',
'v-if': 'isShow'
},
/* 解析得到的:class */
'classBinding': 'c',
/* 标签属性v-if */
'if': 'isShow',
/* v-if的条件 */
'ifConditions': [
'exp': 'isShow'
],
/* 标签属性class */
'staticClass': 'demo',
/* 标签的tag */
'tag': 'div',
/* 子标签数组 */
'children': [
{
'attrsMap': {
'v-for': "item in sz"
},
/* for循环的参数 */
'alias': "item",
/* for循环的对象 */
'for': 'sz',
/* for循环是否已经被处理的标记位 */
'forProcessed': true,
'tag': 'span',
'children': [
{
/* 表达式,_s是一个转字符串的函数 */
'expression': '_s(item)',
'text': '{{item}}'
}
]
}
]
}

最终得到的 AST 通过一些特定的属性,能够比较清晰地描述出标签的属性以及依赖关系。

接下来我们用代码来讲解一下如何使用正则来把 template 编译成我们需要的 AST 的。

正则

首先我们定义一下接下来我们会用到的正则。

const ncname = '[a-zA-Z_][\\w\\-\\.]*';
const singleAttrIdentifier = /([^\s"'<>/=]+)/
const singleAttrAssign = /(?:=)/
const singleAttrValues = [
/"([^"]*)"+/.source,
/'([^']*)'+/.source,
/([^\s"'=<>`]+)/.source
]
const attribute = new RegExp(
'^\\s*' + singleAttrIdentifier.source +
'(?:\\s*(' + singleAttrAssign.source + ')' +
'\\s*(?:' + singleAttrValues.join('|') + '))?'
)

const qnameCapture = '((?:' + ncname + '\\:)?' + ncname + ')'
const startTagOpen = new RegExp('^<' + qnameCapture)
const startTagClose = /^\s*(\/?)>/

const endTag = new RegExp('^<\\/' + qnameCapture + '[^>]*>')

const defaultTagRE = /\{\{((?:.|\n)+?)\}\}/g

const forAliasRE = /(.*?)\s+(?:in|of)\s+(.*)/

advance

因为我们解析 template 采用循环进行字符串匹配的方式,所以每匹配解析完一段我们需要将已经匹配掉的去掉,头部的指针指向接下来需要匹配的部分。

function advance (n) {
index += n
html = html.substring(n)
}

举个例子,当我们把第一个 div 的头标签全部匹配完毕以后,我们需要将这部分除去,也就是向右移动 43 个字符。

调用 advance 函数

advance(43);

得到结果

parseHTML

首先我们需要定义个 parseHTML 函数,在里面我们循环解析 template 字符串。

function parseHTML () {
while(html) {
let textEnd = html.indexOf('<');//<span></span>
if (textEnd === 0) {
if (html.match(endTag)) {
//...process end tag
continue;
}
if (html.match(startTagOpen)) {
//...process start tag
continue;
}
} else {
//...process text
continue;
}
}
}

parseHTML 会用 while 来循环解析 template ,用正则在匹配到标签头、标签尾以及文本的时候分别进行不同的处理。直到整个 template 被解析完毕。

parseStartTag

我们来写一个 parseStartTag 函数,用来解析起始标签<div :class=”c” class=”demo” v-if=”isShow”> 部分的内容)。

function parseStartTag () {
const start = html.match(startTagOpen);
if (start) {
const match = {
tagName: start[1],
attrs: [],
start: index
}
advance(start[0].length);

let end, attr
while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
advance(attr[0].length)
match.attrs.push({
name: attr[1],
value: attr[3]
});
}
if (end) {
match.unarySlash = end[1];
advance(end[0].length);
match.end = index;
return match
}
}
}

首先用 startTagOpen 正则得到标签的头部,可以得到 tagName(标签名称),同时我们需要一个数组 attrs 用来存放标签内的属性。

生命周期

扯一下生命周期。
只要你能理解了这张图,也就对Vue的生命周期有了一个大致的了解,在谈到Vue的生命周期的时候,我们首先需要创建一个实例,也就是在 new Vue () 的对象过程当中,首先执行了initinitvue组件里面默认去执行的),在init的过程当中首先调用了beforeCreate,然后在injections(注射)和reactivity(反应性)的时候,它会再去调用created。所以在init的时候,事件已经调用了,我们在beforeCreate的时候千万不要去修改data里面赋值的数据,最早也要放在created里面去做(添加一些行为),当created完成之后,它会去判断instance(实例)里是否含有’eloption,如果有的话,直接执行下一步,如果没有的话,就进行手动挂载vm.$mount(el),紧接着下一步,编译template里的内容,转化成render function,结果是解析成render函数,

render (h) {
return h('div', {}, this.text)
}

参数hcreateElement方法,结果返回的createElement,三个参数,第一个是该标签的标签名,第二个是该节点的事件,状态,数据,第三个是标签的内容,在这里用this.text代替;在开发环境中,其实还有第四个参数,代表着当前实例的错误信息。

renderError (h, err) {
return h('div', {}, err.stack)
}
//我们可以让它主动报错
render (h) {
throw new TypeError('render error')
}

created=>beforeMount过程中,render function被编译完成之后,才会执行beforeMounted,这也说明了beforeMounted=>mounted的时候,$el还是我们在HTML里写的节点,还没有将render function解析出来的内容进行挂载,我们不能操作任何DOM节点,当有render function才会去执行mounted挂载DOM节点。挂载完毕后,这个实例算是走完流程了,剩下的就是状态变化,后续的钩子都需要外部的触发才能调用,比如数据发生变化的时候,会调用beforeUpdate,然后经过Virtual DOM,然后update更新完毕,组件销毁的时候调用的beforeDestory,以及destoryed;
最后谈一下钩子函数,什么是钩子函数,其实和回调一个意思,当系统执行到某处时,会去检查会否有hook,有则回调,说的直白点,就是每个组件都有属性、方法、事件,所有生命周期归于事件,在某个时刻自动执行。

本文标题:Vue.js运行机制

文章作者:Seven

发布时间:2018年05月05日 - 11:05

最后更新:2018年06月13日 - 16:06

原始链接:http://www.yuanziwen.cn/2018/05/05/Vue-js运行机制/

许可协议: 署名-非商业性使用 转载请保留原文链接及作者。