前端面试题:vue响应式原理 Vdom diff

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方法。

前端面试题:vue响应式原理 Vdom diff_第1张图片

这个呢是我在控制台里直接运行的结果,在我直接调用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标签显示什么了。

前端面试题:vue响应式原理 Vdom diff_第2张图片

下面我就要说一下观察者模式了,主要分为注册环节和发布环节。

蒙了吧,脑瓜子是不是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操作来更新视图。

前端面试题:vue响应式原理 Vdom diff_第3张图片

渲染函数:渲染函数是用来生成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) 
       }
    }
)}

 

你可能感兴趣的:(三大基础,个人总结,面试题库,vue,面试,diff)