vue的响应式原理,也算是面试中再常见不过的题目了,之前遇见这道题目只会说:利用的是Object.defineProperty进行的数据劫持,监听数据的变化,通知watcher进行的数据更新。总的来说这是没错的,但是只要面试官进一步的问,那一定是满脸的问号。昨天一天也是没有面试机会,所以就研究了一天这个东西,算是搞明白了(自我感觉),今天就把他来写成文章,希望大佬看到哪里不对给出指导,本文可能会有点长。上正文。
现在最流行的框架非vue,react莫属,他们流行起来的原因,离不开响应式,因为它在做一些数据更新的时候,会去更新相应的视图,把我们从操作DOM中释放出来,让我们不再去自己操作dom,这也就是所说的数据驱动吧。
React是通过this.setState去改变数据,然后根据新的数据重新渲染出虚拟DOM,最后通过对比虚拟DOM找到需要更新的节点进行更新。也就是说React是依靠着虚拟DOM以及DOM的diff算法做到这一点的。
vue在2.0中依赖的是Object.defineProperty,那我们就先来介绍一下Object.defineProperty,这个方法是干什么的呢?我也是去看了一下它的文档,它主要是用来给一个对象添加属性,或者修改它现有的属性的,然后把这个对象返回,然后呢,在defineProperty中,有set和get,set在设置(修改)属性的值的时候被触发,get在获取属性的值的时候被触发。
举个例子(可以试着自己写一下)
var oldValue;
var obj = {};
Object.defineProperty(obj,"text",{
get : function(){
console.log('get被调用');
return oldValue
},
set : function(newValue){
console.log('set被调用')
oldValue = newValue
}
})
为了验证上边加粗的地方那句话,我们以上边为例,就是在obj对象上添加text属性,并对它进行监听,看看是不是我们获取或修改text的值的时候会调用get和set方法。
这个呢是我在控制台里直接运行的结果,在我直接调用Object.defineProperty的时候,会返回这个对象,返回空对象obj,这个是没错的,在我设置obj.text的时候,打印set被调用是没错的,紧接着通过obj.text获取值的时候,会去调用get方法并打印。
我们了解了Object.defineProperty的用法之后,就可以实现一个简单的绑定了,不知道大家还记不记得刚开始学vue的时候,一个input和一个p的故事,随着input内容的改变,p的内容跟着改变。接下来我们就通过Object.defineProperty,来实现一个简单的绑定。
var inPut = document.getElementById('input');
var p = document.getElementById('p');
var obj = {}
Object.defineProperty(obj, 'msg', {
set: function (newVal) {
input.value = newVal
p.innerText = newVal
}
})
Object.defineProperty(obj,'text',{
})
inPut.addEventListener('keyup', function (event) {
obj.msg = event.target.value
})
这时候一个绑定就实现了,如果面试官让手写实现一个双向绑定,我觉得这些应该是可以通过的了。这时候的效果就是input输入什么p标签显示什么了。
下面我就要说一下观察者模式了,主要分为注册环节和发布环节。
蒙了吧,脑瓜子是不是WongWong的,那就对了,举个简单的例子:
我们在准备秋招的时候,会给公司投递自己的简历,然后就会进入安排笔试的环节(筛学历的就不说啦,都是泪),这个时候我们总不能隔一小段时间来问公司一下什么时间安排笔试,隔一段时间问一次对我们来说是特别麻烦的呀,但是公司也要招人啊,这个时候呢公司的处理就和观察者模式特别的像了,为什么这么说呢?
当公司有人投递简历的时候,他会让你填写手机号,邮箱等一些信息,就是观察者模式的注册环节。
等笔试安排的时候,他会给所有投简历的人发短信,发邮件,这就是观察者模式的发布环节。
function Recruit() {
this.dep = [];
resume(paper) {
this.dep.push(paper)
}
writtenExamination() {
this.dep.forEach(item => item())
}
}
var recruit = new Recruit()
//张三李四王五投简历并留下电话
recruit.resume(()=>{'console.log("张三")'})
recruit.resume(()=>{'console.log("李四")'})
recruit.resume(()=>{'console.log("王五")'})
//通知他仨参加笔试
wantCake.notify()
了解完相关的预备知识之后,我们来正式的说响应式的原理。
我们也是跟着生命周期的路线来介绍,首先是init阶段, vue 的 data的属性都会被reactive化,也就是加上 setter/getter函数。
function defineReactive(obj: Object, key: string, ...) {
const dep = new Dep()
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
....
dep.depend()
return value
....
},
set: function reactiveSetter (newVal) {
...
val = newVal
dep.notify()
...
}
})
}
class Dep {
static target: ?Watcher;
subs: Array;
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
notify () {
const subs = this.subs.slice()
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
}
其中这里的Dep就是一个观察者类,每一个data的属性都会有一个dep对象。当getter调用的时候,去dep里注册函数,然后在数据变化(setter)的时候,去通知刚才注册的函数。
接下来说一下Mount阶段发生了什么
mountComponent(vm: Component, el: ?Element, ...) {
vm.$el = el
...
updateComponent = () => {
vm._update(vm._render(), ...)
}
new Watcher(vm, updateComponent, ...)
...
}
class Watcher {
getter: Function;
constructor(vm: Component, expOrFn: string | Function, ...) {
...
this.getter = expOrFn
Dep.target = this
this.value = this.getter.call(vm, vm)
...
}
}
mount 阶段的时候,会创建一个Watcher类的对象。这个Watcher实际上是连接Vue组件与Dep的桥梁。每一个Watcher对应一个vue component。
这里可以看出new Watcher的时候,constructor 里的this.getter.call(vm, vm)函数会被执行。getter就是updateComponent。这个函数会调用组件的render函数来更新重新渲染。
而render函数里,会访问data的属性,比如说访问到上边例子
render: function (createElement) {
return createElement('h1', this.text)
}
这个时候呢会去调用text的getter函数
// getter函数
get: function reactiveGetter () {
....
dep.depend()
return value
....
},
// dep的depend函数
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
在depend的函数里,Dep.target就是watcher本身(我们在class Watch里讲过,不记得可以往上第三段代码),这里做的事情就是给text注册了Watcher这个对象。这样每次render一个vue 组件的时候,如果这个组件用到了text,那么这个组件相对应的Watcher对象都会被注册到text的Dep中。
这个过程就叫做依赖收集。
收集完所有依赖text属性的组件所对应的Watcher之后,当它发生改变的时候,就会去通知Watcher更新关联的组件。
updata(更新)阶段
当text发生改变的时候,就去调用Dep的notify函数,然后通知所有的Watcher调用update函数更新。
notify () {
const subs = this.subs.slice()
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
什么意思呢:就是说有多少个数据被监听,就会有多少个Dep去进行收集事件,然后紧接着创建watcher,每一个用到这个数据的地方(component)都会有一个watcher,然后watcher链接Dep和component。
总结一下:组件初始化的时候,先给每一个Data属性都注册getter,setter,也就是reactive化。然后再new 一个自己的Watcher对象,此时watcher会立即调用组件的render函数去生成虚拟DOM。在调用render的时候,就会需要用到data的属性值,此时会触发getter函数,将当前的Watcher函数注册进sub里。
当data属性发生改变之后,就会遍历sub里所有的watcher对象,通知它们去重新渲染组件。
响应式原理差不多就是这样的啦,下面说一下vDom,都说添加了vDom的vue比之前的渲染速度提升了好几倍,但是也没有准确的数据吧,并且大大地降低了内存的消耗,接下来介绍一下什么是vDom,为什么用到他。
我们先说一下模板转换成视图的过程:
Vue.js通过编译将template 模板转换成渲染函数(render ) ,执行渲染函数就可以得到一个虚拟节点树。在对 Model 进行操作的时候,会触发对应 Dep 中的 Watcher 对象。Watcher 对象会调用对应的 update 来修改视图。这个过程主要是将新旧虚拟节点进行差异对比,然后根据对比结果进行DOM操作来更新视图。
渲染函数:渲染函数是用来生成Virtual DOM的。Vue推荐使用模板来构建我们的应用界面,在底层实现中Vue会将模板编译成渲染函数,当然我们也可以不写模板,直接写渲染函数,以获得更好的控制。
VNode 虚拟节点:它可以代表一个真实的 dom 节点。通过 createElement 方法能将 VNode 渲染成 dom 节点。简单地说,vnode可以理解成节点描述对象,它描述了应该怎样去创建真实的DOM节点。
patch(也叫做patching算法):虚拟DOM最核心的部分,它可以将vnode渲染成真实的DOM,这个过程是对比新旧虚拟节点之间有哪些不同,然后根据对比结果找出需要更新的的节点进行更新。这点我们从单词含义就可以看出, patch本身就有补丁、修补的意思,其实际作用是在现有DOM上进行修改来实现更新视图的目的。Vue的Virtual DOM Patching算法是基于Snabbdom的实现,并在些基础上作了很多的调整和改进。
Virtual DOM 其实就是一棵以 JavaScript 对象( VNode 节点)作为基础的树,用对象属性来描述节点,实际上它只是一层对真实 DOM 的抽象。最终可以通过一系列操作使这棵树映射到真实环境上。简单来说,可以把Virtual DOM 理解为一个简单的JS对象,并且最少包含标签名( tag)、属性(attrs)和子元素对象( children)三个属性。不同的框架对这三个属性的命名会有点差别。
let element={
tagName:'ul',//节点标签名
props:{//dom的属性,用一个对象存储键值对
id:'list'
},
children:[//该节点的子节点
{tagName:'li',props:{class:'item'},children:['aa']},
{tagName:'li',props:{class:'item'},children:['bb']},
{tagName:'li',props:{class:'item'},children:['cc']}
]
}
对应的html写法是:
- aa
- aa
- aa
虚拟dom有什么用呢?
虚拟DOM的最终目标是将虚拟节点渲染到视图上。但是如果直接使用虚拟节点覆盖旧节点的话,会有很多不必要的DOM操作。例如,一个ul标签下很多个li标签,其中只有一个li有变化,这种情况下如果使用新的ul去替代旧的ul,因为这些不必要的DOM操作而造成了性能上的浪费。
为了避免不必要的DOM操作,虚拟DOM在虚拟节点映射到视图的过程中,将虚拟节点与上一次渲染视图所使用的旧虚拟节点(oldVnode)做对比,找出真正需要更新的节点来进行DOM操作,从而避免操作其他无需改动的DOM。
其实虚拟DOM在Vue.js主要做了两件事:
1、提供与真实DOM节点所对应的虚拟节点vnode
2、将虚拟节点vnode和旧虚拟节点oldVnode进行对比,然后更新视图
为什么要用到虚拟dom呢?
1、具备跨平台的优势
由于 Virtual DOM 是以 JavaScript 对象为基础而不依赖真实平台环境,所以使它具有了跨平台的能力,比如说浏览器平台、Weex、Node 等。
2、操作 DOM 慢,js运行效率高。我们可以将DOM对比操作放在JS层,提高效率。
因为DOM操作的执行速度远不如Javascript的运算速度快,因此,把大量的DOM操作搬运到Javascript中,运用patching算法来计算出真正需要更新的节点,最大限度地减少DOM操作,从而显著提高性能。
Virtual DOM 本质上就是在 JS 和 DOM 之间做了一个缓存。可以类比 CPU 和硬盘,既然硬盘这么慢,我们就在它们之间加个缓存:既然 DOM 这么慢,我们就在它们 JS 和 DOM 之间加个缓存。CPU(JS)只操作内存(Virtual DOM),最后的时候再把变更写入硬盘(DOM)
3、提升渲染性能
Virtual DOM的优势不在于单次的操作,而是在大量、频繁的数据更新下,能够对视图进行合理、高效的更新。
为了实现高效的DOM操作,一套高效的虚拟DOM diff算法显得很有必要。我们通过patch 的核心----diff 算法,找出本次DOM需要更新的节点来更新,其他的不更新。比如修改某个model 100次,从1加到100,那么有了Virtual DOM的缓存之后,只会把最后一次修改patch到view上。
Vue的diff算法是基于snabbdom改造过来的,仅在同级的vnode间做diff,递归地进行同级vnode的diff,最终实现整个DOM树的更新。因为跨层级的操作是非常少的,忽略不计,这样时间复杂度就从O(n3)变成O(n)。
diff 算法包括几个步骤:
1、用 JavaScript 对象结构表示 DOM 树的结构;然后用这个树构建一个真正的 DOM 树,插到文档当中
2、当状态变更的时候,重新构造一棵新的对象树。然后用新的树和旧的树进行比较,记录两棵树差异
3、把所记录的差异应用到所构建的真正的DOM树上,视图就更新了
diff算法的实现,diff 算法本身非常复杂,实现难度很大。两个核心函数实现流程:
patch(container,vnode) :初次渲染的时候,将VDOM渲染成真正的DOM然后插入到容器里面。
patch(vnode,newVnode):再次渲染的时候,将新的vnode和旧的vnode相对比,然后之间差异应用到所构建的真正的DOM树上。
function createElement(vnode) {
var tag = vnode.tag
var attrs = vnode.attrs || {}
var children = vnode.children || []
if (!tag) {
return null
}
// 创建真实的 DOM 元素
var elem = document.createElement(tag)
// 属性
var attrName
for (attrName in attrs) {
if (attrs.hasOwnProperty(attrName)) {
// 给 elem 添加属性
elem.setAttribute(attrName, attrs[attrName])
}
}
// 子元素
children.forEach(function (childVnode) {
// 给 elem 添加子元素,如果还有子节点,则递归的生成子节点。
elem.appendChild(createElement(childVnode)) // 递归
}) // 返回真实的 DOM 元素
return elem
}
第二种情况,这里我们只考虑vnode与newVnode如何对比的情况:
function updateChildren(vnode, newVnode) {
var children = vnode.children || []
var newChildren = newVnode.children || []
// 遍历现有的children
children.forEach(function (childVnode, index) {
var newChildVnode = newChildren[index]
// 两者tag一样
if (childVnode.tag === newChildVnode.tag) {
// 深层次对比,递归
updateChildren(childVnode, newChildVnode)
} else {
// 两者tag不一样
replaceNode(childVnode, newChildVnode)
}
}
)}