如何实现一个前端框架7-Dependency的封装和数组的响应式支持

221 阅读11分钟

前几节,我们的代码中好几个地方都定义了updateFns数组,还有objectUpdateFns数组,我们会在组件data属性关联的get钩子触发时,把组件的update方法放进去,然后在set的时候遍历这个数组执行里面的update方法,这其实就是个很常见的pub-sub(发布-订阅)模型,所以我们很自然地想到,可以将其抽象成为一个类,便于管理,代码可读性也更加好

Vue中将updateFns抽象成了Dep这个类,我猜测全称应该是Dependency(依赖),不过我想了很久,也没有想出一个比较合理的解释,具体没想明白的是谁依赖谁?

是组件的render中用到了某些属性,那这个组件的初始化或更新就对这些用到的属性有依赖?还是说这些属性的变化会触发组件的重新渲染,属性做了和渲染函数的关联,这就叫依赖?应该这么理解吗?此处欢迎交流评论

不过我们的Dependency类要做的事情却是很明确的,它需要有一个Array类型的属性subscribers,里面存放订阅函数,在这里就是存放组件的update方法,另外还需要往数组中push(订阅)的原型方法subscribe,和一个遍历subscribers数组中各个update方法执行的publish(发布)原型方法

function Dependency () {
  this.subscribers = []
}
Dependency.prototype.subscribe = function (fn) {
  if (!this.subscribers.find(sub => sub === fn)) {
    this.subscribers.push(fn)
  }
}
Dependency.prototype.publish = function () {
  for (let i = 0; i < this.subscribers.length; i++) {
    this.subscribers[i]()
  }
}

接下来我们就只需要把之前定义updateFns的地方改为实例化Dependency对象,把遍历执行updateFns的地方,改为为Dependency的publish方法,具体代码如下:

function setDataReactive (data) {
  // 对象本身对应的Dependency,在$set中可以通过这个引用拿到对象的subscribers
  let dep = new Dependency()
  Object.defineProperty(data, '_dep', {
    value: dep,
    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 dep
}

let curExecUpdate = null
function defineReactive(data, key, val) {
  // 属性对应的Dependency
  let dep = new Dependency()
  // 对象本身对应的Dependency
  let childDep
  if (isObject(val)) {
    childDep = setDataReactive(val)
  }
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
    get: function () {
      // 之前这里有判断update方法是否重复,现在这个逻辑已经移到Dependency的subscribe方法里面
      if (curExecUpdate) {
        dep.subscribe(curExecUpdate)
      }
      if (childDep && curExecUpdate) {
        childDep.subscribe(curExecUpdate)
      }
      return val
    },
    set: function (newVal) {
      val = newVal
      if (isObject(newVal) || isArray(newVal)) {
        childDep = setDataReactive(newVal)
      }
      // 设置值的时候执行发布
      dep.publish()
    }
  })
}

$set的改动如下:

ViewComponent.prototype.$set = function (target, key, val) {
  if (key in target) {
    target[key] = val
    return
  }
  // 从目标对象上取到Dependency对象
  let dep = target._dep
  if (!dep) {
    target[key] = val
    return
  }
  defineReactive(target, key, val)
  // 执行Dependency对象的发布方法
  dep.publish()
}

在defineReactive中的defineProperty的get钩子中,还可以进一步优化:

    get: function () {
      if (curExecUpdate) {
        dep.subscribe(curExecUpdate)
        if (childDep) {
          childDep.subscribe(curExecUpdate)
        }
      }
      return val
    },

原来的代码中由于判断update方法是否存在于订阅列表中,所以写的比较冗余,这里可以将childDep的订阅放到if (curExecUpdate)的里面

完整代码

Dependency的封装到此就告一段落了,接下来我们来实现响应式最后一部分内容,就是对数组的支持

在我们平时日常开发中,遇到最常见的情况就是对象数组,通常后端给我们一个比较复杂的对象数组,前端拿到它之后进行字段的整合和扩展,然后渲染到页面对应的位置中,整个过程下来是比较复杂的,接下来我们就由简单到复杂一点点分析

在分析比较复杂的对象数组的情况之前,我们还是需要先从基本类型数组开始说起,例如

let arr = [1, 2, 3]
let testArrayComponent = {
  data: function () {
    return {
      labels: arr
    }
  }
}

我们要将其变为响应式,其实就是在mounted钩子或methods中,对this.labels执行push、pop、splice、unshift、shift等操作时,要通知testArrayComponent组件执行update方法

对于对象类型的k、v结构,ES5提供了Object.defineProperty这个方法,通过get、set钩子来拦截属性的获取和设置,从而收集依赖,但数组里的值,像这个案例中的arr里面的1、2、3,是没有get、set这种对应的方法的

不过我们也可以发现,对数组本身做操作时要求触发所在的组件的update,这个场合和我们上一节遇到的给对象添加新属性时,通过_dep来更新组件非常相似,因为给对象添加新的属性也没有用通过defineProperty的get中收集到的dep依赖

于是我们可以和给对象本身添加一个dep依赖的思路一样,给每个数组也创建一个对应的Dependency对象,与之映射

在上一节我们实现每个JavaScript普通对象和一个Dependency对象进行映射时,是在setDataReactive方法中

既然这里的思路是相通的,所以也可以在这个方法当中进行实现:

function defineReactive(data, key, val) {
  let dep = new Dependency()
  let childDep
  // 给对象、数组本身添加Dependency的情况
  if (isObject(val) || isArray(val)) {
    childDep = setDataReactive(val)
  }

这样一来,我们就一定要在setDataReactive中对入参val进行类型判断,对于对象类型的,按照之前的方式处理,但对于数组类型的,就要添加新的逻辑

无论是对象类型,还是数组类型,他们和Dependency依赖的关系都是一样的:

function setDataReactive (data) {
  if (!isObject(data) || !isArray(data)) return
  let dep = new Dependency()
  Object.defineProperty(data, '_dep', {
    value: dep,
    enumerable: false,
    writable: true,
    configurable: true
  })
  if (isObject(data)) {
    let keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      let v = data[keys[i]]
      defineReactive(data, keys[i], v)
    }
  } else if (isArray(data)) {
    // 入参是数组的情况,按照数组的方式处理
    setArrayReactive(data)
  }
  return dep
}

此处setArrayReactive的实参data其实就是一个数组

在setArrayReactive方法里面,我们必须要做下面的工作:重写入参data数组上的增删改的方法,目的是在数组做增删改的时候,从data上取到_dep,然后执行其publish方法完成组件的更新,就像是下面这样:

function setArrayReactive (arr) {
  let methodsToPatch = [
    'push',
    'pop',
    'shift',
    'unshift',
    'splice',
    'sort',
    'reverse'
  ]
  methodsToPatch.forEach(function (key) {
    Object.defineProperty(arr, key, {
      value: function () {
        let result = arr[key].apply(arr, arguments)
        let dep = arr._dep
        dep.publish()
        return result
      },
      enumerable: false,
      writable: true,
      configurable: true
    })
  })
}

注意,这里没有直接覆盖数组的方法,而是和之前一样用defineProperty和enumerable: false的方式来定义方法,其实也是为了避免遍历到这些新覆盖的方法

这里面遍历methodsToPatch时的回调key就是需要修正的方法的名字,我们将一个新的function赋盖了原来对应的方法,在这个新的方法中,首先通过:

let result = arr[key].apply(arr, arguments)

来执行方法本身

接下来取出arr上的_dep,进行发布,最后再把原始方法执行过后的result返回回去

这个逻辑上看起来是没问题

但是要注意,此处的Object.defineProperty是在给arr添加重写的方法,我们都知道对于普通的数组,他们的push、pop、unshift等等这些方法都在数组的原型上,而不是每个数组各存一份,也就是说访问数组方法时会先在当前数组实例上找,当前实例一定没有,所以会去数组原型上的方法找,毕竟所有数组的这些方法都是一样的逻辑,一定只留一份

不过,如果按照上面的代码定义的话,就破坏了这种原型链,这个代码会给每个传进来的arr都定义一份各自的方法,这显然是极大的空间浪费

为了节省空间,同时也为了能让经过响应式处理的数组具有收集依赖的能力,我们有必要基于数组的原型再扩展一个新的原型出来,这个原型会在数组原始方法的基础之上增加触发依赖的功能,让数组在经过响应式处理时,将它的原型(也就是__proto__属性)指向这个扩展出来的新的原型,或者将它原来的方法覆盖为新的原型上的方法,代码如下:

let methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]
let arrayProto = Array.prototype
let reactiveArrayMethods = Object.create(arrayProto)

methodsToPatch.forEach(function (key) {
  Object.defineProperty(reactiveArrayMethods, key, {
    value: function () {
      let result = this[key].apply(this, arguments)
      let dep = this._dep
      dep.publish()
      return result
    },
    enumerable: false,
    writable: true,
    configurable: true
  })
})

可以看到,我们定义了一个名为reactiveArrayMethods的对象,顾名思义,这个对象就是我们上面提到的增加了能够发布依赖的数组方法的原型对象(感觉比较啰嗦,但还是为了严谨),在下面的代码中,我们通过遍历能使数组值改变的方法(用methodsToPatch存了起来),将这些处理过的方法覆盖原有的方法

除此之外,我们还发现一个细节,这行代码:

      let result = this[key].apply(this, arguments)

这里写this的地方,我们在之前写的是arr

之前我们是在将具体的数据变为响应式的过程中,也就是在setArrayReactive方法中,顺便覆盖了方法,所以那个时候,也就是在setArrayReactive里面,是可以拿到具体的arr对象的

但现在我们把这段代码提到了方法外面,让它作为一个公共的对象,这就不能拿到具体的数据了,而我们在通过this.xxx访问xxx这个属性时同时会执行xxx的value钩子(如果有的话),而value钩子在执行过程中,其上下文对象this就是xxx本身,所以这里可以用this来替代

接下来我们把reactiveArrayMethods这个原型再给要添加响应式功能的数组的__proto__赋值,或者把要添加响应式功能的数组的方法覆盖为reactiveArrayMethods中对应的方法即可

由于有一些浏览器不支持直接修改对象的__proto__属性,所以我们选择第二种方式,即覆盖对应方法:

function setArrayReactive (arr) {
  for (var i = 0, l = methodsToPatch.length; i < l; i++) {
    var key = methodsToPatch[i]
    Object.defineProperty(arr, key, {
      value: reactiveArrayMethods[key],
      enumerable: false,
      writable: true,
      configurable: true
    })
  }
}

这样一来,我们就做到了所有响应式数组公用一份方法的效果了

不过还没完,以上我们讨论的都是数组元素为基本类型时的情况,如果数组元素为引用类型,那就又有点复杂了

假如我们有如下组件:

new ViewComponent({
  el: '#app',
  data: function () {
    return {
      selectedLabels: [],
      canSelectLabels: []
    }
  },
  methods: {
    getLabelData () {
      let _this = this
      setTimeout(function () {
        _this.canSelectLabels = [
          { id: 'f0145630-7e90-11eb-9439-0242ac130002', name: '手机' },
          { id: '151fbb59-fbaa-42a9-bf44-41266f601292', name: '电脑' },
          { id: 'b5233098-066b-49ba-baa5-3c9dc7c388fc', name: '家具' },
          { id: '2029def3-a95d-49ca-b06b-198d0582011d', name: '服装' },
          { id: '4c49831c-d591-4114-abed-f4cc00414f44', name: '玩具' }
        ]
      }, 1000)
    }
  },
  mounted: function () {
    this.getLabelData()
  }
})

此处为了代码更加清晰,我把methods里面很多操作selectedLabels、canSelectLabels的方法,以及render方法都删掉了,大家只需要关心我用setTimeout模拟了一个请求的响应,给canSelectLabels赋值为一个对象数组即可

我们从这一步开始考虑,当在data中初始化一个对象数组,或者和上面的代码一样,在前端收到响应时重新给一个data上的空数组赋值一个新的对象数组,这两种情况在实际开发中用的非常多,这些初始化或赋值的操作最终都会走向setDataReactive中,在这个方法里面我们之前已经对数组做了响应式处理:

function setDataReactive (data) {
  if (!isObject(data) || !isArray(data)) return
  let dep = new Dependency()
  Object.defineProperty(data, '_dep', {
    value: dep,
    enumerable: false,
    writable: true,
    configurable: true
  })
  if (isObject(data)) {
    let keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      let v = data[keys[i]]
      defineReactive(data, keys[i], v)
    }
  } else if (isArray(data)) {
    // 入参是数组的情况,按照数组的方式处理
    setArrayReactive(data)
  }
  return dep
}

但是,数组中的各项如果又是数组或对象的时候,我们没有处理这种情况,其实添加这个逻辑也很简单:

function setDataReactive (data) {
  // 对于基础类型的值,不做处理
  if (!isObject(data) || !isArray(data)) return
  let dep = new Dependency()
  Object.defineProperty(data, '_dep', {
    value: dep,
    enumerable: false,
    writable: true,
    configurable: true
  })
  if (isObject(data)) {
    let keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      let v = data[keys[i]]
      defineReactive(data, keys[i], v)
    }
  } else if (isArray(data)) {
    setArrayReactive(data, arrayMethods, methodsToPatch)
    // 给数组中的各项添加响应式处理
    for (let i = 0; i < data.length; i++) {
      setDataReactive(data[i])
    }
  }
  return dep
}

可以看到我们会遍历数组中的各项,继续递归执行setDataReactive,无论传进来的数组的每一项是数组、还是对象、还是基本类型,继续交给setDataReactive执行

对于数组中的项是基础类型的情况,我们需要在setDataReactive开头容个错,上面代码也有体现

除此之外,我们在对数组操作时,总是会增删改里面的数据 对于“删”这个动作,其实无所谓,只需要触发数组对应的组件的update执行,渲染删了元素之后的数组对应的DOM即可 对于“改”这个动作,其实也无所谓,因为无论是怎么改,按照我们目前的实现,也总是能触发相应的更新 但对于“增”这个动作,由于用户增进来的可能是各种类型,所以就需要额外处理了

那都有哪些方法会导致数组中内容增加呢?push、unshift、splice,都有可能,所以对于这些方法,我们要拿到新增的数据,将其变为响应式:

methodsToPatch.forEach(function (method) {
  let original = arrayProto[method]
  Object.defineProperty(reactiveArrayMethods, method, {
    value: function () {
      let args = [], len = arguments.length
      while ( len-- ) args[ len ] = arguments[ len ]

      let result = original.apply(this, args)
      let dep = this._dep
      // 拿到增加的元素
      let inserted
      switch (method) {
        case 'push':
        case 'unshift':
          inserted = args
          break
        case 'splice':
          inserted = args.slice(2)
          break
      }
      // 将增加的元素变为响应式的
      if (inserted) {
        for (let i = 0; i < inserted.length; i++) {
          setDataReactive(inserted[i])
        }
      }
      dep.publish()
      return result
    },
    enumerable: false,
    writable: true,
    configurable: true
  })
})

到此,对数组的处理终于可以结束了,本节我还准备了一个实例,大家也可以参考下: 代码