重温Vue响应式绑定原理

773 阅读6分钟

简介

文章的背景,在一个月黑风高夜,项目经理把前端程序员叫到了一起,用愤青的表情说道,这个树的操作为什么这么卡,项目经理三下五除二的将流程演示一遍,每次操作在IE11下大概延迟1s左右,之前从未有过类似的情况,我们优先排出数量的问题,发现这棵树的数据大小为5M,数据量大约在1万条,按照正常理解,不应该在同步的情况下做如此大的数据量处理,但是项目紧急,后端童鞋不可能在这个时间内支持我们改异步结构,那么只能前端去调整结构了,既然用的是Iview的树,那么我们就得去分析一下Iview的源码了

初步分析

在分析iview的树之后发现每次节点的check或者select,都会引起一次大的Diff计算,当时以为是iview这块做的有问题,其实是对Vue原理的认知浅薄,于是和element-ui做了一次对比,element-ui的tree基本上抽象了一个节点类,每个节点转换过来都是一个节点实例,于是决定对element-ui和iview的tree做一个对比

对比

先把线上的5M大小的数据拿过来,分别对element-ui和iview的树做数据转化赋值,在IE11下进行测试,发现两者在IE11上表现基本一致,不会有1s左右的延迟卡顿,iview首次点击会有1s延迟卡顿,其余操作都比较流畅,那么我们的问题在哪呢,问题焦点回到了我们项目中对iview做二次封装的tree上

自己的坑自己填

既然证明iview的树没问题,那么我们开始梳理自己的树,发现我们在扩展iview的树的同时,在每一个节点上面做了一个数据转换,如下所示

<Tree :data-data="JSON.stringify(node)" />

这个有点厉害了,每一个节点的子节点量是相当大的,如果每次改变岂不是要大量遍历转换字符串,于是果断去掉,采用数据传值方式,优化此方式,到此我们的树基本上海加尔iview的同步了,但是我们回到上文,第一次点击iview树的时候会卡顿,那么问题是为什么呢,我们接下来进入本文的正题

观察者

关于响应式原理,网上一抓一大把,接下来我会按照我的理解来讲解这个响应式原理,如有不对,欢迎大家指正,我们知道在Vue中,整个响应式过程主要依赖于Observer、Dep、Watch这三个类来做关联,面向对象中叫做建模,这里面用到了经典的观察者模式,我们先来看一下Dep和Watch是如何做关联的,如下所示

//Dep
interface Watcher {...}
class Dep {
  static target: ?Watcher  
  constructor () {    
    this.id = uid++    
    this.subs = []  
  }
  addSub (sub: Watcher) {    
    this.subs.push(sub)  
  }
  depend () {    
    if (Dep.target) {      
       Dep.target.addDep(this)    
    }  
   }
  notify () {
    //触发观察者
  }}

// Watcher
interface Dep {}
class Watcher {
  constructor () {
   ...
  }
  addDep (dep: Dep) {    const id = dep.id    if (!this.newDepIds.has(id)) {      this.newDepIds.add(id)      this.newDeps.push(dep)      if (!this.depIds.has(id)) {
        // 还是Dep在收集Watcher        dep.addSub(this)      }    }  }  depend () {    let i = this.deps.length    while (i--) {      this.deps[i].depend()    }  }
  ...}

这里观察两个重点,一个是Dep中的addSub方法,参数为Watcher的实例,而Watcher中的addDep参数为Dep实例, 不过始终是Dep在自己的依赖集合中收集Watcher,这样依赖模式,熟悉的朋友一定很happy。这不就是经典的观察者模式吗,那么什么是观察者,观察者和发布订阅者模式有什么区别呢,那么先让我们来揭秘这个

发布订阅者VS观察者

这里得祭出 Addy Osmani的《JavaScript设计模式》,我们先来看一下观察者的流程图,如下所示


Observer模式要求希望接收到主体通知的观察者,必须订阅内容改变的事件,使用Observer模式背后的动机是我们需要在那里维护相关对象的一致性,无需使类紧密耦合。例如当一个对象能够统治其他对象,而不需要在这些方面做假设。主题对象和观察者是一对一注册和监听,在错误发生时能够快速对依赖进行追溯

对比上面提到的Dep和Watcher,是不是Dep和Subject对应,Watcher和Observer对应,在特定时间,Watcher.addDep将自己加入到Dep的收集中,然后在特定时间,Dep通过notify通知Watcher做出改变,如上图中的Fire Event

观察者模式我感觉更多是耦合性较高,两个类已经完全耦合到一起,接下来我们去看一下发布订阅者,如图


我想大家常用的模式应该这是这种吧,从上图中可以看到发布订阅者使用了一个事件通道Channel,这个通道介于希望接受到命名空间和回调函数,我们来看一下简版的实现

let EventChannel = {}
let Publish = {
  $emit (name, args) {
    if (EventChannel[name]) {
       EventChannel[name](args)
       return
    }
     console.warn(`${name} is not exist`)
  },
  $once (name, args) {...},
  $on (name, cb) {
    EventChannel[name] || (EventChannel[name] = []).push(cb)
  }}

简版实现基本上是基于一个消息通道进行互相通信,Vue在事件触发时候用到了这个模式,$on和$emit

这个模式的缺点在于订阅者和发布者经常是无视对方的,它们之间是没有任何关系的,很难追踪依赖更新

响应式原理Observer

看到这个Observer不要懵,这个Observer是Vue源码中的一个双向绑定的重要事件,也是推动Watcher和Dep相互关联的重要媒介,我们先看看Observer都有哪些功能

class Observer {
  constructor (value: any) {    this.value = value    this.dep = new Dep()    this.vmCount = 0    def(value, '__ob__', this)    if (Array.isArray(value)) {      if (hasProto) {        protoAugment(value, arrayMethods)      } else {        copyAugment(value, arrayMethods, arrayKeys)      }      this.observeArray(value)    } else {      this.walk(value)    }  } 
  walk () {}
  observerArray () {} 
}

Observer类中包含的方法,其实不多,注意这里的__ob__,一会要用到,更多核心实现在它依赖的工具函数,例如set、del、defineReactive等,我们看一下defineReactive函数实现,这个是Vue中双绑的核心Core

function defineReactive (obj, key, val, customSetter, shallow) {
  const dep = new Dep()
  let childOb = observer(val)
  Object.defaineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get () {
      if (Dep.target) {
         dep.depend()
         if (childOb) {
           childOb.dep.depend()
           if (Array.isArray(val)) {
             dependArray(val)
           }
         }
      }
    },
    get () {
      dep.notify()
    }
  })
}

一个简版的defineReactive实现,基本上可以看到都是围绕在Dep和Watcher,很多童鞋可能有点郁闷,哪里有Watcher,注意代码中有一个Dep.target这个引用,再回到上面Dep的定义,我们可以看到Dep上有一个target的静态属性,值为Watcher的实例,所以Dep.target为Watcher实例,到这一步,我们可以看到基本上所有定义到data中的属性都会走到这一步,为它们所有的属性都创建一个Dep实例用来收集自己的Watcher

到这我们基本分析了,通过Observer的实现,我们已经打通了Dep和Watcher,那么Watcher到底是什么,什么时候会调用Watcher呢,这个具体讲解不是我今天的重点,我简单描述一下,Watcher分为渲染Watcher(render)、用户Watcher($watch)、异步Watcher(computed)这三种,所以我们可以看到为什么一个值的改变会触发这些的改变,都是因为它们的dep收集了这些Watcher的实例

我们分析了对象,基本上已经能解决很大一部分双向绑定的问题吗,但是我们回到这次问题的根源是Tree这棵树,它的数据结构是数组,我们貌似忽略了刚才在Vue中,数组是如何做到响应式收集的,在讲述这个问题之前,我先提一个问题,看代码

<template>
<div
  :key="item.id"
  v-for="item in list">
  {{item.title}}
</div></template>

上面这段代码中,item这个对象是否会收集当前的Render Watcher,如果你答对了,并且可以很流畅的说出原理,那么恭喜你,你已经对这块了解的很透彻了,可以洗洗睡了,如果没有答对,或者对这块抱有疑问,那么我们继续往下看

有过前端经验的童鞋都知道,Object.defineProperty的劫持相对于数组是无法拦截的,所以Vue内部使用了更改原型链的方式,对数组的常用方法进行了拦截,例如push、splice、shift等,那么数组的成员如何被监听起来,它们的依赖关系怎么建立起来的,还记得上面提到的监听属性会在Observer类中添加一个__ob__的属性,这个属性指向自己,也就是一个Observer实例,这块该他们登场了。

当一个元素是数组时,会继续递归遍历它们的子属性进行监听,监听完成后,我们会注意到在defineReactive中的get方法最后收集watcher时候会调用dependArray方法,这个方法实现如下

function dependArray (value: Array<any>) {  for (let e, i = 0, l = value.length; i < l; i++) {    e = value[i]    e && e.__ob__ && e.__ob__.dep.depend()    if (Array.isArray(e)) {      dependArray(e)    }  }}

看到每一个数组元素的__ob__去收集了当前的Watcher,它们自身是不会收集当前Watcher的,所以回到上面那个问题,我们知道item对象是不会收集当前Watcher的,那么新问题来了,__ob__啥时候用,为什么要收集,我们来看一个场景,如果一个监听元素要新增属性,如何引起页面重新刷新,有经验的童鞋肯定会高呼$set可以,那我们看一下$set究竟是什么

function set (target: Array<any> | Object, key: any, val: any): any {
  if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
  }
  const ob = (target: any).__ob__
  if (target._isVue || (ob && ob.vmCount)) {
    return val
  }
  if (!ob) {
    target[key] = val
    return val
  }
  defineReactive(ob.value, key, val)
  ob.dep.notify()
  return val
}

我们可以看到__ob__登场了,之前我们知道__ob__收集的就是元素渲染当前的Watcher,这时候可以顺利的引用到,并且触发notify刷新,至此整个过程大概完成了,我们回到最后一个终极问题,为什么iview的树第一次点击会卡,奥妙就在刚才将的原理中

来看一下iview的树的监听做了那些处理

 watch: {
    data: {
        deep: true,
        handler () {
            this.stateTree = this.data;
            this.flatState = this.compileFlatState();
            this.rebuildTree();
        }
    }
},   

根组件监听了data数据的改变,然后做了一次深度优先遍历,如果是本次数据的10000条,就会遍历10000次,我们在第一次点击checked时候,会触发$set,进而触发根组件的watcher,而在随后的点击中,checked属性已经监听到了自己的Watcher,所以不会再出发根组件的Watcher。

总结

Vue源码在去年已经看过一次了,很多知识点有点生疏了,借此重温一下,如有问题,请指正。