Vue原理解析(七):全面深入理解响应式原理(下)-数组进阶篇

上一篇:全面深入理解响应式原理(上)-对象基础篇

  sayHi(friend)
  
  function sayHi(friend) {
    if(friend.status === '不太理解响应式且还没有看过上一篇') {
      console.log(`
        建议看下上一篇,因为算是响应式的基础了,
        不然可能这篇看起来会费点劲。
      `)
    } else if(friend.status === '之前看过上一篇了') {
      console.log(`
        也可以瞟一眼,为了和这一篇相契合,部分地方做了增删改。
      `)
    } else if(friend.status === '我是大牛,就来看看你理解的怎么样') {
      console.log(`
        大佬!里边请~
      `)
    }
  }

我们首先来看下改变数组的两种方式:

export default {
  data() {
    list: [1, 2, 3]
  },
  methods: {
    changeArr1() {  // 方式一:重新赋值
      this.list = [4, 5, 6]
    },
    changeArr2() {  // 方式二:方法改变
      this.list.push(7)
    }
  }
}

对于这两种改变数据的方式,vue内部的实现并不相同。

方式一:重新赋值

  • 实现原理和对象是一样的,再vm._render()时有用到list,就将依赖收集起来,重新赋值后走对象派发更新的那一套。

方式二:方法改变

  • 走对象的那一套就不行了,因为并不是重新赋值,虽然改变了数组自身但并不会触发set,原有的响应式系统根本感知不到,所以我们接下来就分析,vue是如何解决使用数组方法改变自身触发视图的。

Dep收集依赖的位置

上一篇它的声音并不大,现在我们来重新认识它。Dep类的主要作用就是管理依赖,在响应式系统中会有两个地方要实例化它,当然它们都会进行依赖的收集,首先是之前具体包装的时候:

function defineReactive(obj, key, val) {
  const dep = new Dep()  // 自动依赖管理器
  ...
  Object.defineProperty(obj, key, {
    get() {...},
    set() {...}
  })
}

这里它会对每个读取到的key都进行依赖收集,无论是对象/数组/原始类型,如果是通过重新赋值触发set就会使用这里收集到的依赖进行更新,笔者这里就把它命名为自动依赖管理器,方便和之后的区分。

还有一个地方也会对它进行实例化就是Observer类中:

class Observer {
  constructor(value) {
    this.dep = new Dep() //  手动依赖管理器
    ...
  }
}

这个依赖管理器并不能通过set触发,而且是只会收集对象/数组的依赖。也就是说对象的依赖会被收集两次,一次在自动依赖管理器内,一次在这里,为什么要收集两次,本章之后说明。而最重要的是数组使用方法改变自身去触发更新的依赖就是再这收集的,这个前提还是很有必要交代下的。

数组的响应式原理

数组响应式数据的创建

数组示例:
export default {
  data() {
    return {
      list: [{
        name: 'cc',
        sex: 'man'
      }, {
        name: 'ww',
        sex: 'woman'
      }]
    }
  }
}

流程开始还是执行observe方法,接下来我们更加详细分析响应式系统:

function observe(value) {
  if (!isObject(value) { //不是数组或对象,再见
    return
  }
  
  let ob
  if(hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {  // 避免重复包装
    ob = value.__ob__
  } else {
    ob = new Observer(value)
  }
  return ob
}

只要是响应式的数据都会有一个__ob__的属性,它是在Observer类中挂载的,如果已经有__ob__属性就直接赋值给ob,不会再次去创建Observer实例,避免重复包装。首次肯定没__ob__属性了,所以再重新看下Observer类的定义:

class Observer {
  constructor(value) {
    this.value = value
    this.dep = new Dep()  // 手动依赖管理器
    
    def(value, '__ob__', this)  // 挂载__ob__属性,三个参数
    ...
  }
}

现在看Observer类会丰富很多,首先定义一个手动依赖管理器,然后挂载一个不可枚举的__ob__属性到传入的参数下,表示它的一个响应式的数据,而且__ob__的值就是当前Observer类的实例,它拥有实例上的所有属性和方法,这很重要,我们接下来看下def是如何完成属性挂载的:

function def (obj, key, val, enumerable) {
  Object.defineProperty(obj, key, {
    value: val,
    enumerable: !!enumerable,
    writable: true,
    configurable: true
  })
}

其实就是一个简单的封装,第四个参数不传,enumerable项就是不可枚举的了。接着看Observer类的定义:

class Observer {
  constructor(value) {
    ...
    if (Array.isArray(value)) {  // 数组
      ...
    } else {  // 对象
      this.walk(value)  // {list: [{...}, {...}]}
    }
  }
  
  walk (obj) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }
}

首次传入还是对象的格式,所以会执行walk遍历的将对象每个属性包装为响应式的,再来看下defineReactive方法:

function defineReactive(obj, key, val) { 

  const dep = new Dep()  // 自动依赖管理器
  
  val = obj[key]  // val为数组 [{...}, {...}]
  
  let childOb = observe(val)  // 返回Observer类实例
  
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {  // 依赖收集
      if (Dep.target) {
        dep.depend()  // 自动依赖管理器收集依赖
        if (childOb) {  // 只有对象或数组才有返回值
          childOb.dep.depend()  // 手动依赖管理器收集依赖
          if (Array.isArray(val)) { 如果是数组
            dependArray(val) // 将数组每一项包装为响应式
          }
        }
      }
      return value
    },
    set(newVal) {
      ...
    }
  }
}

首先递归执行observe(val)会有一个返回值了,如果是对象或数组的话,childOb就是Observer类的实例。所以在get内的childOb.dep.depend()执行的就是Observer类里定义的dep进行依赖收集,收集的render watcher跟自动依赖管理器是一样的。接下来如果是数组就执行dependArray方法:

function dependArray (value) {
  for (let e, i = 0, i < value.length; i++) {
    e = value[i]
    e && e.__ob__ && e.__ob__.dep.depend()  // 是响应式数据
    if (Array.isArray(e)) {  // 如果是嵌套数组
      dependArray(e)  // 递归调用自己
    }
  }
}

这个方法的作用就是递归的为每一项收集依赖,这里每一项都必须要有__ob__属性,然后执行Observer类里的dep手动依赖收集器进行依赖收集。我们现在知道数组的依赖放哪了,现在关心的是在哪里去更新这个收集到的依赖。

数组方法更新依赖

回到defineReactive方法,看看let childOb = observe(val)这句代码:

function defineReactive(obj, key, val) { 
  ...
  
  val = obj[key]  // val为数组 [{...}, {...}]
  let childOb = observe(val)  // 看这句
  
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {...},
    set(newVal) {...}
  }
}

通过求值,val现在就是具体的数组,传入到observe内以数组的形式执行,我们又回到Observer类中:

class Observer {
  constructor(value) {
    ...
    if (Array.isArray(value)) {  // 数组
      
      const augment = hasProto // 第一句
        ? protoAugment 
        : copyAugment
      
      augment(value, arrayMethods, arrayKeys)  // 第二句
      
      this.observeArray(value)  // 第三句
      
    } else {  // 对象
      ...
    }
  }
  
  observeArray(items) {
    for (let i = 0, i < items.length; i++) {
      observe(items[i])
    }
  }
}

数组方法改变自身触发视图原理:首先覆盖数组的__proto__隐式原型,借用数组原生的方法,定义vue内部自定义的数组异变方法拦截原生方法,再调用异变方法改变自身之后手动触发依赖。

有了这只指向月亮的手,我们现在就一起去往心中的月亮。首先分析第一句:

const augment = hasProto ? protoAugment : copyAugment

--------------------------------------------------------

const hasProto = '__proto__' in {}

function protoAugment (target, src) {  // src为拦截器
  target.__proto__ = src
}

function copyAugment (target, src, keys) {  // src为拦截器
  for (let i = 0; i < keys.length; i++) {
    const key = keys[i]
    def(target, key, src[key])
  }
}

__proto__这个属性并不是所有浏览器都有的,笔者之前也一直以为这是一个通用属性,原来IE11才开始有这个属性,通过'__protp__' in {}也可以快速判断当前浏览浏览器是否IE10以上?确实用过,好用!

是否有__proto__属性处理方法也不相同,如果有的的话,直接在protoAugment方法内使用拦截器覆盖;如果没有__proto__属性,那就在当前调用数组下挂载拦截器里的异变数组方法。

实现原理都是根据原型链的特性,再数组使用原生方法之前加一个拦截器,拦截器内定义的都是可以改变数组自身的异变方法,如果拦截器内没有就向一层去找。

接下来分析第二句,也是整个数组方法实现的核心:

augment(value, arrayMethods, arrayKeys)

----------------------------------------------------------------------------

const arrayProto = Array.prototype  // 数组原型,有所有数组原生方法
const arrayMethods = Object.create(arrayProto)  // 创建空对象拦截器

const methodsToPatch = [  // 七个数组使用会改变自身的方法
  'push','pop','shift','unshift','splice','sort','reverse'
]

methodsToPatch.forEach(function (method) {  // 往拦截器下挂载异变方法

  const original = arrayProto[method]  // 过滤出七个数组原生原始方法
  
  def(arrayMethods, method, function mutator (...args) {  // 不定参数
  
    const result = original.apply(this, args)  // 借用原生方法,this就是调用的数组
    
    const ob = this.__ob__  // 之前Observer类下挂载的__ob__
    
    let inserted  // 临时保存数组新增的值
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    if (inserted) {
      ob.observeArray(inserted)  // 执行Observer类中的observeArray方法
    }
    ob.dep.notify()  // 触发手动依赖收集器内的依赖
    
    return result  // 返回数组执行结果
  })
})

const arrayKeys = Object.getOwnPropertyNames(arrayMethods) 
// 获取拦截器内挂载好的七个方法key的数组集合,用于没有__proto__的情况

首先获取数组的所有原生方法,从中过滤出七个调用可以改变自身的方法,然后创建拦截器在它下面挂载七个经过异变的方法,这个异变方法的使用效果和原生方法是一致的,因为就是使用apply借用的,将执行后的结果保存给result,比如:

const arr = [1, 2, 3]
const result = arr.push(4)

这个时候arr就变成了[1,2,3,4]result保存的就是新数组的长度,既然模仿就模仿的像一点。

接下来的赋值const ob = this.__ob__,之前定义的__ob__不仅仅是标记位,保存的也是Observer类的实例。

有三个操作数组的方法是会添加新值的,使用inserted变量保存新添的值。如果是使用splice方法,就将前面两个表示位置的参数截取掉。然后使用observeArray方法将新添加的参数包装为响应式的。

最后通知手动依赖管理器内收集到的依赖派发更新,返回数组执行后的结果。

最后执行第三句:

this.observeArray(value)

将数组内的是数组或对象的每一项都包装成响应式的。所以当数组再使用方法时,首先会去arrayMethods拦截器内查找是否是异变方法,不是的话才去调用数组原生方法:

export default {
  data() {
    return {
      list: [1, 2, 3]
    }
  },
  methods: {
    changeArr1() {
      this.list.push(4)  // 调用拦截器里的异变方法
    },
    changeArr2() {
      this.list = this.list.concat(5) 
      // 调用原生方法,因为拦截器里没有,必须重新赋值因为不会改变自身
    }
  }
}

至此数组响应式系统相关的也讲解完毕,整个响应式系统也分析完了。我们来总结下吧,数组和对象它们收集依赖都是在get方法里,但是依赖存放位置并不同,对象是在defineReactive方法的dep内,数组是Observer类中的dep里;依赖的触发对象可以直接在set方法中派发更新,而数组是在自己定义的异变数组方法最后手动触发的。

同样数组响应式也是不是完美的,它也有缺点:

export default {
  data() {
    return {
      list: [1, 2, 3]
    }
  },
  methods: {
    changeListItem() {  // 改变数组某一项
      this.list[1] = 5
    },
    changeListLength() {  // 改变数组长度
      this.list.length = 0
    }
  }
}

以上两种方式都改变了数组,但响应式是无法监听到的,因为不会触发set也没用使用数组方法去改变。不过大家还记得我们之前介绍的手动依赖管理器么?我们只要手动去通知它更新依赖就可以触发视图变更~

export default {
  data() {
    return {
      list: [1, 2, 3],
      info: { name: 'cc' }
    }
  },
  methods: {
    changeListItem() {  // 改变数组某一项
      this.list[1] = 5
      this.list.__ob__.dep.notify()  // 手动通知
    },
    changeListLength() {  // 改变数组长度
      this.list.length = 0
      this.list.__ob__.dep.notify()  // 手动通知
    },
    changeInfo() {
      this.info.sex = 'man'
      this.info.__ob__.dep.notify()  // 对象也可以
    }
  }
}

常规的对象增加属性是不会被感知到的,也可以使用手动通知的形式触发依赖,知道这个原理还是很cool的~

官方填坑

上面的奇技淫巧并不被推荐使用,我们还是介绍下官方推荐的弥补响应式不足的两个API$set$delete,其实它们只是处理一些情况,都不满足的最后还是调了一下手动依赖管理器来实现,只是进行了简单的二次封装。

this.$set || Vue.set

function set(target, key, val) {
  if(Array.isArray(target)) {  // 数组
    target.length = Math.max(target.length, key)  // 最大值为长度
    target.splice(key, 1, val)  // 移除一位,异变方法派发更新
    return val
  }
  
  if(key in target && !(key in Object.prototype)) {  // key属于target
    target[key] = val  // 赋值操作触发set
    return val
  }
  
  if(!target.__ob__) {  // 普通对象赋值操作
    target[key] = val
    return val
  }
  
  defineReactive(target.__ob__.value, key, val)  // 将新值包装为响应式
  
  target.__ob__.dep.notify()  // 手动触发通知
  
  return val
}

首先判断target是否是数组,是数组的话第二个参数就是长度了,设置数组的长度,然后使用splice这个异变方法插入val
然后是判断key是否属于target,属于的话就是赋值操作了,这个会触发set去派发更新。接下来如果target并不是响应式数据,那就是普通对象,那就设置一个对应key吧。最后以上情况都不满足,说明是在响应式数据上新增了一个属性,把新增的属性转为响应式数据,然后通知手动依赖管理器派发更新。

this.$delete || Vue.delete

function del (target, key) {
  if (Array.isArray(target)) {  // 数组
    target.splice(key, 1)  // 移除指定下表
    return
  }
  
  if (!hasOwn(target, key)) {  // key不属于target,再见
    return
  }
  
  delete target[key]  // 删除对象指定key
  
  if (!target.__ob__) {  // 普通对象,再见
    return
  }
  target.__ob__.dep.notify()  // 手动派发更新
}

this.$delete就更加简单了,首先如果是数组就使用异变方法splice移除指定下标值。如果target是对象但key不属于它,再见。然后删除制定key的值,如果target不是响应式对象,删除的就是普通对象一个值,删了就删了。否则通知手动依赖管理器派发更新视图。

最后按照惯例我们还是以一道vue可能会被问到的面试题作为本章的结束~

面试官微笑而又不失礼貌的问道:

  • 请简单描述下vue响应式系统?

怼回去:

  • 简单来说就是使用Object.defineProperty这个API为数据设置getset。当读取到某个属性时,触发get将读取它的组件对应的render watcher收集起来;当重置赋值时,触发set通知组件重新渲染页面。如果数据的类型是数组的话,还做了单独的处理,对可以改变数组自身的方法进行重写,因为这些方法不是通过重新赋值改变的数组,不会触发set,所以要单独处理。响应系统也有自身的不足,所以官方给出了$set$delete来弥补。

上一篇: Vue原理解析(八):一起搞明白令人头疼的diff算法

顺手点个赞或关注呗,找起来也方便~

分享一个笔者自己写的组件库,哪天可能会用的上了 ~ ↓

你可能会用的上的一个vue功能组件库,持续完善中...

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,236评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,867评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,715评论 0 340
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,899评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,895评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,733评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,085评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,722评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,025评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,696评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,816评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,447评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,057评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,009评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,254评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,204评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,561评论 2 343

推荐阅读更多精彩内容