likes
comments
collection
share

菜鸟学Vue源码之实现数组的函数劫持

作者站长头像
站长
· 阅读数 11

“我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第2篇文章,点击查看活动详情

接上文defineProperty响应式数据原理实现之后,会发现虽然已经实现了对属性的劫持,但当我们传入的data中包含数组,就会出现一些问题,如下:

const vm = new Vue({
  data(){
    return {
      name:'i东东',
      age:18,
      say:{
        hobby:'学习'
      },
      went:['北京','上海']
    }
  }
})
console.log(vm.went);

控制台输出如下:

菜鸟学Vue源码之实现数组的函数劫持 我们写的defineProperty他会把数组里面的每个属性都增加了get()、set(),那这样会出现什么问题呢?

虽然说我们更改数组的第0个,第n个都会触发视图的更新,但是假如说我们的这个数组有一千个,一万个值的话呢?这样就会导致在循环的时候就会走多次,将所有的都去遍历一次,但是我们的数组再去更改的时候基本不会用arr[88] = xx ,很少会用索引去更改数组。这样内部做劫持就会很浪费性能,一万个就需要代理一万次。

一般修改数组都是通过方法去进行修改,比如说:push() shift()...所以我们就可以考虑如果他是一个数组的话就不去做循环。如果data是一个数组的话,我们就可以监测用户有没有使用push()、shift()等方法,如果调用了这些方法的话,我们就重写数组的方法。

在此之前,如果数组中有引用数据类型的数据也同样需要进行劫持[a,b,c,{d:1}]

// observe/index.js
class Observer {
  constructor(data) {
    // Objece.defineProperty()只能劫持已经存在的属性,后增加的或者删除的是不知道的(Vue2里面会为此单独写一些api  $set $delete)
    // 类的好处就是方便扩展
    if (Array.isArray(data)) {
      //我们可以重写数组的7个变异方法,是可以修改数组本身的
      this.observeArray(data) // 如果数组中放的是对象,可以监控到对象的变化
    } else {
      this.walk(data)
    }
  }
  walk(data) { // 循环对象 堆属性一次劫持
    // 重新定义属性相当于把属性重写  proxy性能会提高
    Object.keys(data).forEach(key => defineReactive(data, key, data[key]))
  }
  observeArray(data) { // 观测数组
    data.forEach(item => observe(item))
  }
}

代码如上,我们想要对数组中的对象也进行劫持,这样的话就可以增加this.observeArray(data)把当前的data传递进去,通过遍历调用observe()将每一项都变成响应式的这样当我们取值时数组中的对象也是会被监控到的。

// index.html
console.log(vm.went[2].a);

输出如下:

菜鸟学Vue源码之实现数组的函数劫持 那如果我们现在调用push的话会发现,现在我们的代码只能触发到读取值

// index.html
vm.went.push('1')

输出如下:

菜鸟学Vue源码之实现数组的函数劫持 这样我们就需要去重写数组上的push等这些方法了

// observe/index.js

class Observer {
  constructor(data) {
    // Objece.defineProperty()只能劫持已经存在的属性,后增加的或者删除的是不知道的(Vue2里面会为此单独写一些api  $set $delete)
    // 类的好处就是方便扩展
    if (Array.isArray(data)) {
      //我们可以重写数组的7个变异方法,是可以修改数组本身的
      data.__proto__ = {
        push(){
          console.log('重写的push');
        }
      }
      this.observeArray(data) // 如果数组中放的是对象,可以监控到对象的变化
    } else {
      this.walk(data)
    }
  }
.......
}

重写一个对象__proto__,给对象增加上push方法,当调用push时就会走重写的push,写了之后再次运行index.html会发现控制台报错

菜鸟学Vue源码之实现数组的函数劫持

那当我们注释掉 this.observeArray(data)在执行,就可以正常调用了push,原因是因为我们后面去forEach的时候他不是一个数组了。但是这样写肯定是不合理的,这样做会把原始的push给覆盖掉了。 控制台输出如下:

菜鸟学Vue源码之实现数组的函数劫持

这样的话我们就需要保留数组原有的方法、原有的特性,并且可以重写部分方法,那么我们就单独在observe目录下新建一个文件array.js

在这里我希望可以重写数组的部分方法,最后把重写以后的对象返回去,那我需要先拿到数组原来的方法。

// observe/array.js
// 在这里我希望重写数组中的部分方法
let oldArrayProto = Array.prototype // 获取数组的原型
// newArrayProto.__proto__ = oldArrayProto
export let newArrayProto = Object.create(oldArrayProto)
let methods = [ // 找到所有的变异方法(能修改原数组的方法)
    'push',
    'pop',
    'shift',
    'unshift',
    'reverse',
    'splice'
] // concat slice 都不会改变原数组

methods.forEach(method => {
    // Array.push(1,2,3)
    newArrayProto[method] = function (...args) { // 重写数组的方法
    // 谁调的push this就是谁
        const result = oldArrayProto[method].call(this, ...args) // 内部调用原来的方法 这种方式叫函数劫持  模式叫切片变成
        return result
    }
})

但是在这里我们并不能直接通过Array.prototype.push = function(){}这样做会把数组原来的方法也更改掉。这个时候我们就需要将数据给拷贝一份出来newArrayProto。紧接着需要找到那些方法会影响原数组methods

然后就可以去重写这些方法了,在newArrayProto上增加这些方法,里面还需要去调用原来的push(),实际上调用新的默认会调用旧的方法。再调用时需要把传的参数传递进去,紧接着需要在传递给原生的方法,也就是oldArrayProto

注意:在这个时候this就不对了,这样相当于直接去调用了push执行了,所以需要把当前的this改回去,然后再把参数返回去

现在就创造了一个新的原型,新的原型就可以导出去进行赋值了,无论调用七个方法的任意一个就都可以被监控到了,这样就实现了一个数组的劫持。

// observe/array.js
import { newArrayProto } from './array'
class Observer {
  constructor(data) {
    // Objece.defineProperty()只能劫持已经存在的属性,后增加的或者删除的是不知道的(Vue2里面会为此单独写一些api  $set $delete)
    // 类的好处就是方便扩展
    if (Array.isArray(data)) {
      //我们可以重写数组的7个变异方法,是可以修改数组本身的
      data.__proto__ = newArrayProto
      this.observeArray(data) // 如果数组中放的是对象,可以监控到对象的变化
    } else {
      this.walk(data)
    }
  }
......
}

现在往data中追加一个对象看一下功能是否实现了

index.html
const vm = new Vue({
  data(){
    return {
      name:'i东东',
      age:18,
      say:{
        hobby:'学习'
      },
      went:['北京','上海',{a:1}]
    }
  }
})
vm.went.unshift({x:'追加的内容'})
console.log(vm);

控制台输出如下:

菜鸟学Vue源码之实现数组的函数劫持 从控制台的输出可以看见新追加的值并没有被劫持,原因是因为我们只是拦截了这些方法,并没有对新增的属性进行处理。

// observe/array.js
......
methods.forEach(method => {
    // Array.push(1,2,3)
    newArrayProto[method] = function (...args) { // 重写数组的方法
        const result = oldArrayProto[method].call(this, ...args) // 内部调用原来的方法 这种方式叫函数劫持  模式叫切片变成
        // 对新增的数据再次进行劫持\
        let inserted
        let ob = this.__ob__ // 这个指的就是this
        switch (method) {
            case 'push':
            case 'unshift':
                inserted = args
            case 'splice':
                inserted = args.slice(2)
            default:
                break
        }
        console.log(inserted); //新增的内容
        if (inserted) {
            // 对新增的的内容再次进行观测
            ob.observeArray(inserted)
        }
        return result
    }
})

在if判断中唯一能够拿到的值就是this,this就是arr,谁调用push谁就是this,返回看observe/index.js会发现arr和data是一样的,那在代码中是data调用的push,那我们只需要把this给放到data上就可以了,给data加一个自定义属性

// observe/index.js
class Observer {
  constructor(data) {
    data.__ob__ =this  // 增加这一句 // 给数据加了一个标识,如果数据上有__ob__ 就说明这个属性被观测过
    }
}
......
export function observe(data) {
  // 对这个对象进行劫持
  if (typeof data !== 'object' || data == null) {
    return // 只对对象进行劫持
  }
if(data.__ob__ instanceof Observer){ // 说明这个对象被代理过
  return data.__ob__
  
}
  // 如果一个对象被劫持过,那么就不需要再次被劫持了(要判断一个对象是否被劫持过,可以增添一个实例来判断是否被劫持过)
  // 所以在内部创造了一个类去观测数据,如果数据被观测过那他的实例就是这个类
  return new Observer(data); // 对这个数据进行观测
}

如果数据上有data.__ob__ =this就说明这个属性被观测过,name我们就可以增加一条判断,如果data上有一条属性为__ob__他是Observer的实例,说明这个对象已经被代理过了,那我们就可以直接讲他的实例进行返回

但是这样的话会出现一个BUG,如果是对象被监测过的话也会出现一个__ob__属性,在下面会进行循环遍历__ob__属性,__ob__是个对象,对象上面又有__ob__,这样就成了死循环了。

菜鸟学Vue源码之实现数组的函数劫持

菜鸟学Vue源码之实现数组的函数劫持 菜鸟学Vue源码之实现数组的函数劫持 那这样就不可以那样写了,不可以让他循环的时候依然可以遍历到data.__ob__这个属性,这样只需要将它变成不可枚举就可以了。

// observe/index.js
    Object.defineProperty(data,'__ob__',{
      value:this, // 值为this
      enumerable:false // 不可枚举 将__ob__变成隐藏的 循环的时候无法获取
    })
    // data.__ob__ = this 将这替换成上面的

最后再次执行index.html,当控制台输出如下内容就说明对数组的劫持完成了。

菜鸟学Vue源码之实现数组的函数劫持