如何实现一个前端框架6——完善对象类型响应式的支持

177 阅读5分钟

在日常开发中,经常遇到这种场合:

先定义一个空对象(或数组),然后调后端接口,拿到数据之后赋值给这个对象(或数组),然后拿着这些数据去页面上展示:

new ViewComponent({
  el: '#app',
  data: function () {
    return {
      good: {}
    }
  },
  methods: {
    getGoodData () {
      let _this = this
      setTimeout(function () {
        _this.good = {
          id: '8faw8cs4fw9760zt7tnesini4qup5hid',
          name: 'iPhoneX',
          cpuNum: 1,
          memory: 1073741824,
          brand: 'iPhone',
          category: 'phone',
          color: 'black'
        }
        _this.good.region = '北京'
      }, 1000)
    },
    changeName () {
      this.good.name = 'new iPhonex'
    }
  },
  render: function () {
    // 生成新的DOM
    let children = [
      this.createElement('div', { class: 'item' }, this.good.name),
      this.createElement('div', { class: 'item' }, this.good.brand),
      this.createElement('div', { class: 'item' }, this.good.cpuNum),
      this.createElement('div', { class: 'item' }, this.good.memory),
      this.createElement('div', { class: 'item' }, this.good.category),
      this.createElement('div', { class: 'item' }, this.good.color),
      this.createElement('div', { class: 'item' }, this.good.region),
    ]
    return this.createElement('div', { class: 'good-detail' }, 
      [
        this.createElement('button', {
          on: {
            click: this.changeName
          }
        }, '重命名'),
        this.createElement('div', { class: 'detail' }, children)
      ]
    )
  },
  mounted: function () {
    this.getGoodData()
  }
})

此外,还会修改这个对象上的某些属性,例如我们可能有重命名的功能,在回调中会重新赋值good.name,例如上面代码中的changeName

有时,我们也会在这个对象上自己扩展一些属性,比如上面代码中methods -> getGoodData里面的

_this.good.region = '北京'

不过把上面的代码跑一遍之后发现,我们发现,最开始时页面上什么也没有,setTimeout过了1秒钟之后,good上本来就有的属性渲染出来了,但在getGoodData中扩展的region没有展示出来,点击按钮触发changeName修改good.name也没改成功

因为,我们现在还没有支持到这一步,所以本节我们进一步完善代码,支持对象扩展属性和修改属性时,触发组件重新渲染

我们再回想组件初始化以及后来触发渲染再次更新的流程: 初始化:遍历data、props等里面的数据,通过defineReactive,将其改为响应式的数据,然后再通过renderAndCollectDependencies收集依赖

注意:开始时,我们的data中只有一个值为空对象的good,所以初始化的时候只有good有对应的updateFns,但由于good是空对象,good里面没有任何key value,所以再递归进到里面遍历属性添加响应式时,什么都遍历不出来

接下来执行render,准备收集依赖,同样也是只有通过this.good这种方式访问good时才会收集到依赖,通过this.good.name访问name时,是收集不到name对应的依赖的

为了验证good是响应式的,我们可以在render中把good创建出来放到页面上看一下:

    return this.createElement('div', { class: 'good-detail' }, 
      [
        this.createElement('div', { class: 'detail' }, children),
        this.good
      ]
    )

效果如下:

最下面的[object Object]就是good在页面上的渲染

render执行完后,接下来组件就执行到mounted钩子里了,mounted里面调用getGoodData拿到数据之后重新给good赋值

由于good是响应式的,所以给good赋值,就可以触发good对应的依赖,即组件渲染函数update的执行,组件在重新渲染时,在render中可以得到重新赋过值的新的good对象,自然也就能将它的值放到DOM中展示出来

但是,这些值只是赋给了good而已,good里面的属性并不是响应式的,所以我们点击按钮触发changeName,虽然修改了name:this.good.name = 'new iPhonex',但good.name并没有被defineReactive处理过,所以它只是good的普通属性,并不是响应式属性,自然也没有收集过什么依赖,所以也没有对应的updateFns

既然问题出在了这里,自然地,我们在属性set的时候做一下判断,如果发现重新赋的值是对象类型(或数组类型)的,那就要深度遍历对象里所有的属性(深度遍历数组下的各项)将其重新定义为响应式的 注:数组的情况我们下一节会讨论

function defineReactive(data, key, val) {
  let updateFns = []
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
    get: function () {
      if (curExecUpdate && !updateFns.find(fn => fn === curExecUpdate)) {
        updateFns.push(curExecUpdate)
      }
      return val
    },
    set: function (newVal) {
      val = newVal
      // 如果发现重新赋的值是对象类型,就要深度遍历对象里所有的属性
      if (isObject(newVal) || isArray(newVal)) {
        setDataReactive(newVal)
      }
      for (let i = 0; i < updateFns.length; i++) {
        updateFns[i]()
      }
    }
  })
}

注意,在set钩子中,我们新增了将newVal中所有属性变成响应式的操作setDataReactive(newVal)

这么搞完,刷新之后再看效果,似乎还是不行

我们的目的是期望点击重命名,触发:

    changeName () {
      this.good.name = 'new iPhonex'
    }

进而触发name的set钩子,然后去找name对应的updateFns,然后遍历更新

但是这也是出问题的地方,我们可以想一想,updateFns里面有我们想要的组件渲染函数吗?

答案是没有

因为我们往updateFns里收集渲染函数只是在组件调用init初始化的时候做的,之后即使再访问name触发它的get钩子,curExecUpdate也是null,啥也收集不进来

所以,我们收集依赖的时机有问题

其实在Vue中,每次执行render的时候都会收集依赖的渲染函数(其实就是watcher),每个渲染函数都标有id,内部会判断,发现重复的watcher,就不加进去了,对应的代码:

  Watcher.prototype.addDep = function addDep (dep) {
    var id = dep.id;
    if (!this.newDepIds.has(id)) {
      this.newDepIds.add(id);
      this.newDeps.push(dep);
      // 如果在添加dep的时候,发现要添加的watcher之前和dep有关联关系,这个判断就是true
      if (!this.depIds.has(id)) {
        dep.addSub(this);
      }
    }
  };

我们也跟着Vue的思路来吧:

ViewComponent.prototype.renderAndCollectDependencies = function () {
  this.update = this.update.bind(this)
  this.update()
  let fn = this.$options.mounted
  if (fn && isFunction(fn)) {
    fn.call(this)
  }
}
ViewComponent.prototype.update = function () {
  // 清除旧的DOM
  let oldRenderedElement = this.renderedElement || this.el

  this.renderedElement = this._render()
  if (oldRenderedElement) {
    let parent = oldRenderedElement.parentNode
    let sibling = oldRenderedElement.nextElementSibling
    parent.removeChild(oldRenderedElement)
    if (!this.renderedElement) return
    if (sibling) {
      parent.insertBefore(this.renderedElement, sibling)
    } else {
      parent.appendChild(this.renderedElement)
    }
  }
}
ViewComponent.prototype._render = function () {
  pushCurExecUpdateToStack(this.update)
  let renderedElement = this.render()
  popCurExecUpdateFromStack()
  return renderedElement
}

上面的代码主要改了3个地方:

  1. renderAndCollectDependencies中去掉了pushCurExecUpdateToStack和popCurExecUpdateFromStack
  2. ViewComponent.prototype.update中render改成了_render
  3. 新增了_render方法,里面调用开发人员自己定义的render,并用pushCurExecUpdateToStack和popCurExecUpdateFromStack包围起来,这样就做到了每次渲染时,都收集依赖,我们也有判断依赖是否重复的逻辑,所以也不会重复收集依赖

改完代码后,我们刷新页面再点重命名按钮,就得到我们预期的效果了(我查了一下,掘金似乎没法插入视频,遗憾),效果只能靠大家脑补了,或者最后查看一下我给出的源码也可以

题外话:在调试代码的过程中,发现this.good.name = 'new iPhonex'执行的时候,依次执行了good的get钩子,good.name的set钩子

不过也可以看到,除了name被改变了,region也神奇的出来了,这个region出现的时机明显不对,我们是在getGoodData里拿到数据之后,给对象赋值region属性的,应该赋完值后立马出来才对,而不是点重命名触发changeName时再出,这也是我们接下来要解决的问题

Vue中解决这个问题是提供了一个$set的方法,如果需要给data中初始化之后的数据中的对象添加新的属性,就需要调用这个方法

不过,需要注意,在执行$set添加属性的时候,也要触发组件渲染才可以,但现在有一个矛盾点,我们添加的是新属性,新属性原来根本都没有在对象上,更不可能收集过依赖,所以要去哪触发依赖的执行呢?

上面这段话不太好理解,我们以此处的this.good.region为例来具体说明一下

good上每个属性都有对应的updateFns(里面其实就是App组件的update),如果改变这个属性,就会触发它的set钩子,然后set钩子遍历updateFns里面的函数挨个执行,进而实现DOM更新
但如果要添加一个good上原来没有的属性,例如region,在添加region的时候我们也想遍历某个updateFns,然后更新DOM
不过,以我们目前的实现情况,属性和updateFns是一一对应的,good原来连region这个属性都没有,更不可能有它对应的updateFns

为了解决这个问题,根据Vue的解决思路,还需要设计另外一种updateFns,这种updateFns不是和属性一一对应,而是和对象一一对应,什么意思呢?
假设组件定义中data写成了下面这样:

let myComp = {
  data: function () {
    return {
      basicTypeAttr: '基本类型',
      referenceTypeAttr: {
        key1: '对象类型value1',
        key2: '对象类型value2'
      }
    }
  }
}

在我们现在的代码中,对于这个myComp组件,在initData执行完后,会有4个updateFns,分别和basicTypeAttr、referenceTypeAttr、key1、key2 这4个属性一一对应

所谓和对象一一对应,其实就是对于referenceTypeAttr这类值是引用类型的data属性,专门创建一个updateFns和它对应,这个updateFns将会作为referenceTypeAttr对象上的一个属性存到对象上:

      referenceTypeAttr: {
        key1: '对象类型value1',
        key2: '对象类型value2',
        _updateFns: updateFns
      }

当然,这个属性只是为了我们方便之后从对象中拿到updateFns来更新组件,不应该提供给开发人员使用,所以它的enumerable应该设置为false

除此之外,还额外增加了一个名为set的方法,以此来拦截到这种给对象加新的keyvalue的情况,在set的方法,以此来拦截到这种给对象加新的key value的情况,在set里面拿到要添加属性的对象,再通过该对象拿到_updateFns,然后再执行,从而达到更新DOM的效果

这个原理我之前看的时候感觉,恩,挺合理

不过前几天当我在某一次断点调试时,尝试把一个组件上所有的dep都console出来,看一下它都和哪些data中的key对应,和哪些对象对应:

上图中,后面有数字的console都是dep相关的,这个数字就是它的id

我发现其实这个对象对应的updateFns和这个对象所属的上一层对象的key(此处就是referenceTypeAttr)对应的updateFns应该是起同一作用的,都应该是修改这个属性时需要触发的update,例如id为8的dep就是对象对应的dep,id为7的dep和id为8的dep都是作用在同一对象上的同一个key的,这个key为testObject

如果从节省空间的角度来讲,假如有一种方法能把id为7和id为8的dep合在一起,其实是最好的,因为这两个dep分别对应直接重新给testObject赋值和增删改testObject上的某些属性,总之这些操作都是改testObject,所以都应该触发testObject对应的依赖进行更新

不过作者并没有这么做,可能是考虑到语言层面的语法支持度?或者是其他一些不好处理的地方? 比如说,当改了对象里面很深层次的某个属性时,逐层通知到父级各个对象,这个不好实现? 这个疑问纯属我个人猜测,如果有误,欢迎大神指出,也欢迎大家一起讨论

废话了这么多,我们还是按照Vue的思路来,看下怎么实现

首先需要改造的是setDataReactive这个函数,由于我们之前只考虑了对每个属性添加对应的updateFns,所以这个函数的功能是深度遍历每个属性,遍历到每个属性时,每调用一次defineReactive,就在这次调用的作用域内专门为这个属性创建一个属于自己的updateFns,其实这就是闭包的一个经典应用

那么沿着这种思路,如果我们要为data中的每个对象也建一个对应的updateFns,那么也必然需要遍历到一个对象,就调用一次某个函数,在这个函数的作用域内专门为这个对象建一个属于它的updateFns,其实还是闭包的使用

接下来我们把setDataReactive做一下改造,使其作为上面我们提到的“某个函数”

要怎么改造呢?可以从setDataReactive和defineReactive的调用关系中寻找答案

在之前只支持每个属性对应有一个updateFns的情况下,setDataReactive深度遍历对象的每个属性,每遍历到一个属性,就调用defineReactive 那么将setDataReactive遍历的维度降低,遍历到对象时便不再往里面深度遍历,然后直接将遍历到的value传给setDataReactive,setDataReactive内部判断到value是对象时,就再调用defineReactive

这样一来的好处就是,defineReactive的每次调用其实就是data里面遇到各级对象时的调用,也就抓到了这个时机,可以给每个对象建立一个自己的updateFns

function setDataReactive (data) {
  let updateFns = []
  Object.defineProperty(data, '_updateFns', {
    value: updateFns,
    enumerable: false,
    writable: true,
    configurable: true
  })
  let keys = Object.keys(data)
  for (let i = 0; i < keys.length; i++) {
    let v = data[keys[i]]
    defineReactive(data, keys[i], v)
  }
  return updateFns
}

上面代码中setDataReactive里面的updateFns就是和遍历到的对象一一对应的,在for循环里面,我们可以看到去掉了之前的判断:

  if (isObject(val)) {
    setDataReactive(val)
  }

一会儿我们会看到,这段被挪到了defineReactive中

还有一个值得注意的地方,就是我们把updateFns返回了回去,返回去干啥呢?我们赶紧看defineReactive的改造,就知道返回去干什么了:

let curExecUpdate = null
function defineReactive(data, key, val) {
  let updateFns = []
  let objectUpdateFns
  // 对判断遍历到的value为对象时情况的处理
  if (isObject(val)) {
    objectUpdateFns = setDataReactive(val)
  }
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
    get: function () {
      if (curExecUpdate && !updateFns.find(fn => fn === curExecUpdate)) {
        updateFns.push(curExecUpdate)
      }
      if (objectUpdateFns && curExecUpdate && !objectUpdateFns.find(fn => fn === curExecUpdate)) {
        objectUpdateFns.push(curExecUpdate)
      }
      return val
    },
    set: function (newVal) {
      val = newVal
      if (isObject(newVal) || isArray(newVal)) {
        objectUpdateFns = setDataReactive(newVal)
      }
      for (let i = 0; i < updateFns.length; i++) {
        updateFns[i]()
      }
    }
  })
}

可以看到在defineProperty的上方,就是我们对判断遍历到的value为对象时情况的处理

还有,我们把setDataReactive的返回值赋给了一个名叫objectUpdateFns的变量,从变量名字中也可以看出来,它其实就是对象本身对应的updateFns,我们用上面举到的myComp的例子来说明一下objectUpdateFns和updateFns之间的关系:

let myComp = {
  data: function () {
    return {
      basicTypeAttr: '基本类型',
      referenceTypeAttr: {		// referenceTypeAttr有自己的"属性对应的updateFns"
        key1: '对象类型value1',
        key2: '对象类型value2',
        _updateFns: []
      }
    }
  }
}

referenceTypeAttr中_updateFns其实就对应我们的objectUpdateFns,set执行的时候就是把this.update放到了这updateFns里面,一会儿我们具体谈到set执行的时候就是把this.update放到了这个_updateFns里面,一会儿我们具体谈到set时就会很清晰

这里我起名叫objectUpdateFns的这个东西,在Vue中其实对应的就是childOb.dep

此外,我还在defineProperty的get钩子中加了这么一段:

      if (objectUpdateFns && curExecUpdate && !objectUpdateFns.find(fn => fn === curExecUpdate)) {
        objectUpdateFns.push(curExecUpdate)
      }

还以myComp的例子来说,当它的render函数通过this.referenceTypeAttr访问这个属性时首先会走这个判断:

if (curExecUpdate && !updateFns.find(fn => fn === curExecUpdate))

将myComp的update放到了updateFns中去 接着便来到了

if (objectUpdateFns && curExecUpdate && !objectUpdateFns.find(fn => fn === curExecUpdate))

这个判断,objectUpdateFns这个值只有在value为对象时才会接收setDataReactive的返回值,否则就是undefined,所以对于referenceTypeAttr这样值为对象的key,这个条件就会是true,从而将依赖添加到objectUpdateFns中去,这将来会在$set中触发

这个地方,最后一点需要提的就是set钩子的改造,我们注意到在set钩子里面,我们同样用objectUpdateFns接收了一下setDataReactive的返回值,用于给一个属性赋值一个新的对象时,这个新的对象又生成了新的objectUpdateFns的情况

先小结一下写这段代码的感受:
当我只是去看Vue源码时,通过

observe -> new Observer -> walk -> defineReactive

一步一步跟进来时,只知道按着作者的思路走,是体会不到其中的设计思想的,而当自己真正实现这么一个功能并发现这个问题时,不得不说在setDataReactive中,定义一个和对象映射的变量objectUpdateFns使其形成闭包,这个设计十分巧妙

到此,关于setDataReactive和defineReactive的修改就告一段落了,在这个过程中我们提了若干次$set,现在可以把它拿出来了,因为是添加一个新方法,所以这是一段纯新增的代码:

ViewComponent.prototype.$set = function (target, key, val) {
  if (key in target) {
    target[key] = val
    return
  }
  let updateFns = target._updateFns
  if (!updateFns) {
    target[key] = val
    return
  }
  defineReactive(target, key, val)
  for (let i = 0; i < updateFns.length; i++) {
    updateFns[i]()
  }
}

整段代码中,最重要的部分就是最后的defineReactive和updateFns的遍历执行,defineReactive就是给目标对象添加名为key、值为value的响应式属性,updateFns则是为了更新DOM,target._updateFns其实就是defineReactive中的objectUpdateFns

当然,$set中还做了其他一些处理,比如说如果target对象中原来就有key属性,那就不需要做响应式处理了,target[key] = val就会触发key的set钩子,进而遍历执行key属性对应的updateFns完成DOM的更新

到此为止,对象类型的值,响应式支持到这一步功能基本差不多了,我们可以再回顾一下文章最开头的那个例子中getGoodData的执行流程

    getGoodData () {
      let _this = this
      setTimeout(function () {
        _this.good = {
          id: '8faw8cs4fw9760zt7tnesini4qup5hid',
          name: 'iPhoneX',
          cpuNum: 1,
          memory: 1073741824,
          brand: 'iPhone',
          category: 'phone',
          color: 'black'
        }
        _this.$set(_this.good, 'region', '北京')
      }, 1000)
    }

我们重点关注setTimeout里面的流程

首先给good属性赋值,触发good的set 由于赋值给good的是一个对象类型,所以需要递归遍历里面的每个属性,对其添加set和get钩子,同时也会给good对象本身添加_updateFns,这个_updateFns是这个新的good对象的updateFns的引用,这个时候,它还是空数组 接下来继续执行good的set钩子里的updateFns钩子函数,注意,这个updateFns对应了good属性,里面放的是之前遍历data时放进去的app组件的update,updateFns执行到render的时候遇到这样的代码时:

this.good.name

就会触发它们的get钩子,收集依赖,这里会先触发good的get钩子:

      if (curExecUpdate && !updateFns.find(fn => fn === curExecUpdate)) {
        updateFns.push(curExecUpdate)
      }
      if (objectUpdateFns && curExecUpdate && !objectUpdateFns.find(fn => fn === curExecUpdate)) {
        objectUpdateFns.push(curExecUpdate)
      }

对于good来说,它的updateFns里已经有了App的update,所以第1个if判断是false

在第二个if判断中,objectUpdateFns其实就是good对象的updateFns了,不过要注意,这个objectUpdateFns已经不是组件初始化时:

  data: function () {
    return {
      good: {}
    }
  },

good对象的那个objectUpdateFns了,而是新赋值的good对象:

        _this.good = {
          id: '8faw8cs4fw9760zt7tnesini4qup5hid',
          name: 'iPhoneX',
          cpuNum: 1,
          memory: 1073741824,
          brand: 'iPhone',
          category: 'phone',
          color: 'black'
        }

对应的objectUpdateFns,这是set钩子中这行代码起的作用:

        objectUpdateFns = setDataReactive(newVal)

即objectUpdateFns会随着给属性赋值不同的对象而跟着改变

this.good的get钩子执行完之后,就会执行this.good.name的钩子,这个过程比较简单就不分析了

之后render函数执行完,继续执行update剩下的部分,删去老的DOM,加上新的DOM,注意此时新的DOM中还没有this.good.region

接下来继续执行:

_this.$set(_this.good, 'region', '北京')

首先通过target._updateFns拿到target对象的updateFns,此处就是拿到_this.good对象的updateFns

再通过defineReactive(target, key, val)将region属性加到good对象上,而且这个region是响应式属性

再遍历调用updateFns中的this.update时,就开始创建DOM,然后顺带收集依赖

其实,对于good以及good的子属性来说,依赖(即this.update)在上一轮执行中已经收集完毕,所以这次update的执行,主要是收集region属性对应的依赖,以备之后修改region时触发更新

至此,App组件的getGoodData中的过程就都分析完了

最后,我们可以发现,虽然目前功能可以实现,但是代码非常冗余,而且有些地方很不好理解,例如updateFns和objectUpdateFns之间的关系就会让人很晕,向updateFns、objectUpdateFns中添加依赖,遍历执行updateFns的代码都是重复的

所以,在Vue中,作者封装了一个对象叫Dep来抽象,我们已经在前面多次提到过依赖这个术语了,其实Vue中的Dep指的就是依赖,下一节我们将把这部分抽离出来,这样让代码更加清晰

除此之外,还有一个问题,在getGoodData这个方法中,我们给good赋值一次,又给good设置了一个新的属性region,这导致了2次连续的DOM更新,但实际上完全没必要,我们经常在拿到请求之后通过$set设置大量的自定义属性,如果每设置一个就更新一次DOM(往往是以组件为粒度更新),非常不好,是性能的极大损耗,所以待响应式部分所有内容结束后,我们会实现异步更新的功能来解决这个问题

完整代码