前言
在看vue源码的时候,觉得这几个vue的核心理念需要总结一下,遂写篇文章,自己忘记的时候再回来看看。
模板渲染
Vue
采用的是声明式渲染,与命令式渲染不同,声明式渲染只需要告诉程序,我们想要的什么效果,其他的事情让程序自己去做。而命令式渲染,需要命令程序一步一步根据命令执行渲染。例如:
let arr = [1, 2, 3, 4, 5];
// 命令式渲染,关心每一步、关心流程。用命令去实现
let newArr = [];
for (let i = 0; i < arr.length; i++) {
newArr.push(arr[i] * 2);
}
// 声明式渲染,不用关心中间流程,只需要关心结果和实现的条件
let newArr1 = arr.map(function (item) {
return item * 2;
});
Vue
实现了if
、for
、事件
、数据绑定
等指令,允许采用简洁的模板语法来声明式地将数据渲染出视图。
为什么要进行模板编译?实际组件中的template
语法是无法被浏览器解析的,因为它不是正确的HTML
语法,而模板编译,就是将组件的template
编译成可执行的JavaScript
代码,即将template
转化为真正的渲染函数。
模板编译分三个阶段,parse
、optimize
、generate
,最终生成render
函数。
parse阶段
:使用正在表达式将template
进行字符串解析,得到指令
、class
、style
等数据,生成抽象语法树AST
。optimize阶段
:寻找 AST 中的静态节点进行标记,为后面VNode
的patch
过程中对比做优化。被标记为static
的节点在后面的diff
算法中会被直接忽略,不做详细的比较。generate阶段
:根据AST
结构拼接生成render
函数的字符串。
预编译
对于 Vue
组件来说,模板编译只会在组件实例化的时候编译一次,生成 渲染函数
之后在也不会进行编译。因此,编译对组件的 runtime
是一种性能损耗。而模板编译的目的仅仅是将template
转化为render function
,而这个过程,正好可以在项目构建的过程中完成。
比如webpack
的vue-loader
依赖了vue-template-compiler
模块,在 webpack
构建过程中,将template
预编译成 render
函数,在 runtime
可直接跳过模板编译过程。
/*回过头看,runtime 需要是仅仅是 render 函数,而我们有了预编译之后,我们只需要保证构建过程中
生成 render 函数。与 React 类似,在添加JSX的语法糖编译器babel-plugin-transform-vue-jsx
之后,我们可以在 Vue 组件中使用JSX语法直接书写 render 函数。*/
当然,假如同时声明了 template
标签和 render
函数,构建过程中,template
编译的结果将覆盖原有的 render
函数,即 template
的优先级高于直接书写的 render
函数。
响应式系统
Vue
是一款MVVM
的JS
框架,当对数据模型data
进行修改时,视图会自动得到更新,即框架帮我们完成了更新DOM
的操作,而不需要我们手动的操作DOM
。可以这么理解,当我们对数据进行赋值的时候,Vue
告诉了所有依赖该数据模型的组件,你依赖的数据有更新,你需要进行重渲染了,这个时候,组件就会重渲染,完成了视图的更新。
整个流程梳理
- 首先实例化
Vue
类; - 在实例化时,先触发
observe
,递归地对所有data
中的变量进行订阅; - 每次订阅之前,都会生成一个
dep
实例,dep
是指依赖; - 每一个只要是
Object
类型的变量都有一个dep
实例; - 这个
dep
是闭包产生的,因此所有与dep
有关的操作,都要放到defineReactive
函数内部执行;
window.myapp = new Vue({
el: "#app",
data: {
number: {
big: 999
},
},
});
export default class Vue {
constructor (options: any = {}) {
this.$options = options;
this.$el = options.el;
this.$data = options.data;
this.$methods = options.methods;
this.observe(this.$data);
new Compiler(this.$el, this);
}
observe (data) {
if (!data || typeof data !== "object") {
return;
}
Object.keys(data).forEach(key => {
this.defineReactive(data, key, data[key]);
})
}
defineReactive(data, key, value) {
this.observe(value);
let dep = new Dep();
this.$dps.push(dep);
Object.defineProperty(data, key, {
enumerable: true,
configurable: false,
get () {
// 由于需要在闭包内添加watcher,所以通过Dep定义一个全局target属性,
// 暂存watcher, 添加完移除
if (Dep.target)
// dep.addSub(Dep.target);
dep.depend();
/**
* dep.depend();
* 两种写法一致
*/
return value;
},
})
}
}
Dep
类
- 先定义一个全局的
uid
,便于分别每一个dep
实例,在创建dep
的时候绑定并自加1,每一个dep
,都会有一个subs
队列,里面存放的是watcher
。 - 每一个
data
以及其中凡是对象的变量,都唯一对应一个dep
。 - 如果想要实现从
model
->View
的绑定,只需要这样做,把所有的发布者watcher
都放到一个dep中。 当改变一个变量时,只需要拿到这个变量对应的
dep
即可,因为dep
有一个subs
队列,存放的全是相关的发布者watcher
,只需要遍历subs
并且调用其中发布者的update
方法即可更新页面,这就是设计dep
类的思想。let guid = 0; export default class Dep { static target: any = null; subs: Array
; uid: number; constructor() { this.subs = []; this.uid = guid ++; } addSub(sub) { this.subs.push(sub); } depend () { Dep.target.addDep(this); } notify() { this.subs.forEach(sub => { sub.update(); }) } } Dep.target = null;
Dep.target
是一个静态变量,所有的dep
实例的target
都指向同一个东西,也就是说这个target
是全局唯一的,理解为全局变量即可,其实就是一个watcher
。在defineProperty
的get
事件被触发时会进行依赖收集。
编译模板compiler
compiler
的主要作用是把html
节点和watcher
关联起来,至于html
的内容如何更新,都由watcher
的callback/updater
函数决定。这里暂时不做深入,这里只需要知道它是watcher
来更新DOM
的。
watcher和dep绑定
到了这一步,model
已经闭包地拥有了自己的dep
,html
节点也和watcher
关联了起来,就差把watcher
推到对应的dep
里了。然后先看看Watcher
类
export default class Watcher {
private vm;
private exp;
private cb;
private value;
private depIds = {};
constructor (vm, exp, cb) {
this.vm = vm;
this.exp = exp;
this.cb = cb;
// 创建时必须触发一次依赖收集
this.triggerDepCollection();
}
update () {
this.triggerDepCollection();
this.cb(this.value);
}
addDep (dep) {
if (!this.depIds.hasOwnProperty(dep.uid)) {
dep.addSub(this);
this.depIds[dep.uid] = dep;
}
}
// 收集依赖,因为触发了definePropreity的get()
// or re-collection
triggerDepCollection () {
Dep.target = this;
this.value = this.getDeepValue();
Dep.target = null;
}
getDeepValue () {
let data = this.vm.$data;
this.exp.split(".").forEach(key => {
data = data[key];
})
return data;
}
}
当编译html
代码时,我们碰到了一个需要收集的变量,现在为其创建一个watcher
,并在watcher
内部与dep
建立联系。我们称这步为依赖收集,我们可以看到,在构造函数的最后一行,triggerDepCollection()
意味这个watcher
自己触发依赖收集,这个函数先把我们先前提到的Dep.target
设为watcher
自身,就是把自己作为一块砖头放在手上,然后getDeepValue()
这里你只需要知道去访问了一次exp
变量,这就触发了exp
变量的get
事件,就是提醒exp
的dep
,“你可以收集我了”,get
事件的主要内容就是收集这个依赖,然后再结合最开始提到的代码,触发dep.depend()
。
前文的 defineReactive
方法里面的 get
方法中的 if (Dep.target) dep.depend()
,它又调用了dep
的Dep.target.addDep(this)
,也就是当前的watcher
的addDep(this)
,watcher
的addDep(this)
又调用了这个dep
的addSub()
。
意思就是,我现在要收集依赖,只需要dep
调用自己的addSub(watcher)
,把watcher
推到自己的subs
队列就完事了,但现在,dep
把自己传给watcher
,然后watcher
再把自己传给dep
,dep
再把watcher
加到自己的队列,这样岂不是多此一举?其实不然。就在于watcher
的addDep
这一步,关键在于判断这个dep
的uid
是不是自己加入过的dep
,也可以用defineReactive
方法里面的set
实现。
每次调用update()
的时候会触发相应属性的getDeepvalue
,getDeepvalue
里面会触发dep.depend()
,继而触发这里的addDep
。
1、假如相应属性的dep.id
已经在当前watcher
的depIds
里,说明不是一个新的属性,仅仅是改变了其值而已,则不需要将当前watcher
添加到该属性的dep
里。
2、假如相应属性是新的属性,则将当前watcher
添加到新属性的dep
里,,因为新属性之前的setter
、dep
都已经失效,如果不把 watcher
加入到新属性的dep
中,通过 obj.xxx = xxx
赋值的时候,对应的 watcher
就收不到通知,等于失效了。因此每次更新都要重新收集依赖。
3、每个子属性的watcher
在添加到子属性的dep
的同时,也会添加到父属性的dep
,监听子属性的同时监听父属性的变更,这样,父属性改变时,子属性的watcher
也能收到通知进行update
,这一步是在 this.get()
--> this.getVMVal()
里面完成,forEach
时会从父级开始取值,间接调用了它的getter
,触发了addDep()
, 在整个forEach
过程,当前wacher
都会加入到每个父级过程属性的dep
,例如:当前watcher
的是child.child.name
, 那么child
,child.child
, child.child.name
这三个属性的dep
都会加入当前watcher
。
至此,所有的内容就完成了,watcher
也和dep
绑定完毕。
Virtual DOM
在Vue
中,template
被编译成浏览器可执行的render function
,然后配合响应式系统,将render function
挂载在render-watcher
中,当有数据更改的时候,调度中心Dep
通知该render-watcher
执行render function
,完成视图的渲染与更新。
整个流程看似通顺,但是当执行render function
时,如果每次都全量删除并重建DOM
,这对执行性能来说,无疑是一种巨大的损耗,因为我们知道,浏览器的DOM
很“昂贵”的,当我们频繁的更新DOM
,会产生一定的性能问题。
为了解决这个问题,Vue
使用JS
对象将浏览器的 DOM 进行的抽象,这个抽象被称为Virtual
DOM
。Virtual DOM
的每个节点被定义为VNode
,当每次执行render function
时,Vue
对更新前后的VNode
进行Diff
对比,找出尽可能少的需要更新的真实DOM
节点,然后只更新需要更新的节点,从而解决频繁更新DOM
产生的性能问题。
VNode
VNode,全称virtual node,即虚拟节点,对真实 DOM 节点的虚拟描述,在 Vue 的每一个组件实例中,会挂载一个$createElement函数,所有的VNode都是由这个函数创建的。
比如创建一个 div:
// 声明 render function
render: function (createElement) {
// 也可以使用 this.$createElement 创建 VNode
return createElement('div', 'hellow world');
}
// 以上 render 方法返回html片段 hellow world
render 函数执行后,会根据VNode Tree将 VNode 映射生成真实 DOM,从而完成视图的渲染.
Diff
Diff
将新老 VNode
节点进行比对,然后将根据两者的比较结果进行最小单位地修改视图,而不是将整个视图根据新的 VNode
重绘,进而达到提升性能的目的。
patch
Vue
内部的 diff
被称为patch
。其 diff
算法的是通过同层的树节点进行比较,而非对树进行逐层搜索遍历的方式,所以时间复杂度只有O(n)
,是一种相当高效的算法。
首先定义新老节点是否相同判定函数sameVnode
:满足键值key
和标签名tag
必须一致等条件,返回true
,否则false
。
在进行patch
之前,新老 VNode
是否满足条件sameVnode(oldVnode, newVnode)
,满足条件之后,进入流程patchVnode
,否则被判定为不相同节点,此时会移除老节点,创建新节点。
patchVnode
patchVnode
的主要作用是判定如何对子节点进行更新,
如果新旧VNode
都是静态的,同时它们的key
相同(代表同一节点),并且新的 VNode
是 clone
或者是标记了 once
(标记v-once
属性,只渲染一次),那么只需要替换 DOM
以及 VNode
即可。
新老节点均有子节点,则对子节点进行 diff
操作,进行updateChildren
,这个 updateChildren
也是 diff
的核心。
1.如果老节点没有子节点而新节点存在子节点,先清空老节点DOM
的文本内容,然后为当前DOM
节点加入子节点。
2.当新节点没有子节点而老节点有子节点的时候,则移除该DOM
节点的所有子节点。
3.当新老节点都无子节点的时候,只是文本的替换。
updateChildren
Diff
的核心,对比新老子节点数据,判定如何对子节点进行操作,在对比过程中,由于老的子节点存在对当前真实 DOM
的引用,新的子节点只是一个 VNode
数组,所以在进行遍历的过程中,若发现需要更新真实 DOM
的地方,则会直接在老的子节点上进行真实 DOM
的操作,等到遍历结束,新老子节点则已同步结束。
1、updateChildren
内部定义了4个变量,分别是oldStartIdx
、oldEndIdx
、newStartIdx
、newEndIdx
,分别表示正在Diff
对比的新老子节点的左右边界点索引,在老子节点数组中,索引在oldStartIdx
与oldEndIdx
中间的节点,表示老子节点中为被遍历处理的节点,所以小于oldStartIdx
或大于oldEndIdx
的表示未被遍历处理的节点。
2、同理,在新的子节点数组中,索引在newStartIdx
与newEndIdx
中间的节点,表示老子节点中为被遍历处理的节点,所以小于newStartIdx
或大于newEndIdx
的表示未被遍历处理的节点。
3、每一次遍历,oldStartIdx
和oldEndIdx
与newStartIdx
和newEndIdx
之间的距离会向中间靠拢。当oldStartIdx
>oldEndIdx
或者newStartIdx
>newEndIdx
时结束循环。
4、在遍历中,取出4索引对应的 Vnode节点:
(1). oldStartIdx:oldStartVnode
(2). oldEndIdx:oldEndVnode
(3). newStartIdx:newStartVnode
(4). newEndIdx:newEndVnode
5、diff
过程中,如果存在key
,并且满足sameVnode
,会将该DOM
节点进行复用,否则则会创建一个新的DOM
节点。
比较过程
首先,oldStartVnode
、oldEndVnode
与newStartVnode
、newEndVnode
两两比较,一共有 2*2=4 种比较方法。
- 情况一:当
oldStartVnode
与newStartVnode
满足sameVnode
,则oldStartVnode
与newStartVnode
进行patchVnode
,并且oldStartIdx
与newStartIdx
右移动。 - 情况二:与情况一类似,当
oldEndVnode
与newEndVnode
满足sameVnode
,则oldEndVnode
与newEndVnode
进行patchVnode
,并且oldEndIdx
与newEndIdx
左移动。 - 情况三:当
oldStartVnode
与newEndVnode
满足sameVnode
,则说明oldStartVnode
已经跑到了oldEndVnode
后面去了,此时oldStartVnode
与newEndVnode
进行patchVnode
的同时,还需要将oldStartVnode
的真实DOM
节点移动到oldEndVnode
的后面,并且oldStartIdx
右移,newEndIdx
左移。 - 情况四:与情况三类似,当
oldEndVnode
与newStartVnode
满足sameVnode
,则说明oldEndVnode
已经跑到了oldStartVnode
前面去了,此时oldEndVnode
与newStartVnode
进行patchVnode
的同时,还需要将oldEndVnode
的真实DOM
节点移动到oldStartVnode
的前面,并且oldStartIdx
右移,newEndIdx
左移。 - 若不存在,说明
newStartVnode
为新节点,创建新节点放在oldStartVnode
前面即可。 - 当
oldStartIdx
>oldEndIdx
或者newStartIdx
>newEndIdx
,循环结束,这个时候我们需要处理那些未被遍历到的VNode
。 - 当
oldStartIdx
>oldEndIdx
时,说明老的节点已经遍历完,而新的节点没遍历完,这个时候需要将新的节点创建之后放在oldEndVnode
后面。 - 当
newStartIdx
>newEndIdx
时,说明新的节点已经遍历完,而老的节点没遍历完,这个时候要将没遍历的老的节点全都删除。
借用网上的一个动图
说明
以上部分内容来源与自己复习时的网络查找,也主要用于个人学习,相当于记事本的存在,暂不列举链接文章。如果有作者看到,可以联系我将原文链接贴出。