数据响应

Proxy

Proxy是 ES6 中新增的功能,它可以用来定义对象中的操作。

let p = new Proxy(target,handler)

target 代表需要添加代理的对象,handler 用来自定义对象中的操作,比如可以用来自定义 set 或者 get 函数。

接下来我们通过 Proxy来实现一个数据响应式。

let onWatch = (object, setBind, getLogger) => {
  let handler = {
    // 目标对象 target
    // 被获取的属性名 property
    // receiver Proxy或者继承Proxy的对象
    get (target, property, receiver) {
      getLogger(target, property)

      // 方法允许你从一个对象中取值
      // target 需要取值的目标对象
      // property 需要获取的值的键值
      // receiver 如果遇到 getter,此值将提供给目标调用
      return Reflect.get(target, property, receiver)
    },

    set (target, property, value, receiver) {
      setBind(value, property)

      // 在一个对象上设置一个属性
      // target 设置属性的目标对象
      // property 设置的属性的名称
      //  value 设置的值
      return Reflect.set(target, property, value)
    }
  }


  return new Proxy(obj, handler)
}


let obj = { a: 1 };

let p = onWatch(
  obj,
  (v, property) => {
    console.log(`监听到属性${property}改变为${v}`)
  },
  (target, property) => {
    console.log(`'${property}' = ${target[property]}`)
  }
)

p.a = 2 // 监听到属性a改变
p.a // 'a' = 2

上述代码中,我们通过自定义 setget 函数的方式,在原来的逻辑中插入了我们的函数逻辑,实现了对对象任何属性进行读写时发出通知。

当然这是简单版的响应式实现,如果需要实现一个 Vue 中的响应式,需要我们在 get 中收集依赖,在 set 派发更新,之所以 Vue3.0 要使用 Proxy 替换原本的 API 原因在于 Proxy 无需一层层递归为每个属性添加代理,一次即可完成以上操作,性能上更好,并且原本的实现有一些数据更新不能监听到,但是 Proxy 可以完美监听到任何方式的数据改变,唯一缺陷可能就是浏览器的兼容性不好了。

Object.defineProperty

Object.defineProperty 函数,通过该函数为对象的每个属性设置一对 getter/setter 从而得知属性被读取和被设置。

如下:

const data = {
  a: 1
}

Object.defineProperty(data, 'a', {
   set () {
    console.log('设置了属性 a')
  },
  get () {
    console.log('读取了属性 a')
  }
})

这样我们就实现了对属性 a 的设置和获取操作的拦截,有了它我们就可以大胆地思考一些事情,比如: 能不能在获取属性 a 的时候收集依赖,然后在设置属性 a 的时候触发之前收集的依赖呢? 嗯,这是一个好思路,不过既然要收集依赖,我们起码需要一个”筐“,然后将所有收集到的依赖通通放到这个”筐”里,当属性被设置的时候将“筐”里所有的依赖都拿出来执行就可以了,落实到代码如下:

// dep 数组就是我们所谓的“筐”
const dep = []
Object.defineProperty(data,'a',{
  set() {
        // 当属性被设置的时候,将“筐”里的依赖都执行一次
        dep.forEach(fn => fn())
  },
  get(){
      // 当属性被获取的时候,把依赖放到“筐”里
      dep.push(fn)
  }
})

如上代码所示,我们定义了常量 dep ,它是一个数组,这个数组就是我们所说的“筐”,当获取属性 a 的值时将触发 get 函数,在 get 函数中,我们将收集到的依赖放入“筐”内,当设置属性 a 的值时将触发 set 函数,在 set 函数内我们将“筐”里的依赖全部拿出来执行。

但是新的问题出现了,上面的代码中我们假设 fn 函数就是我们需要收集的依赖(观察者),但 fn 从何而来呢? 也就是说如何在获取属性 a 的值时收集依赖呢? 这个时候我们就需要在 $watch 函数中做文章了,我们知道 $watch 函数接收两个参数,第一个参数是一个字符串,即数据字段名,比如 ‘a’,第二个参数是依赖该字段的函数:

$watch('a', () => {
  console.log('设置了 a')
})

重点在于 $watch 函数是知道当前正在观测的是哪一个字段的,所以一个思路是我们在 $watch 函数中读取该字段的值,从而触发字段的 get 函数,同时将依赖收集,如下代码:


const data = {
  a: 1
}

const dep = []

Object.defineProperty(data, 'a', {
  set () {
    dep.forEach(fn => fn())
  },
  get () {
    // 此时 Target 变量中保存的就是依赖函数
    dep.push(Target)
  }
})

// Target 是全局变量
let Target = null;

function $watch (exp, fn) {
  // 将 Target 的值设置为 fn
  Target = fn

  // 读取字段值,触发get函数
  data[exp]
}

上面的代码中,首先我们定义了全局变量 Target ,然后在 $watch 中将 Target 的值设置为 fn 也就是依赖,接着读取字段值 data[exp] 从而触发被设置的属性 get 函数,在 get 函数中,由于此时 Target 变量就是我们要收集的依赖,所以讲 Target 添加到 dep 数组。现在我们添加如下测试代码:

$watch('a', () => {
  console.log('第一个依赖')
})
$watch('a', () => {
  console.log('第二个依赖')
})

此时当你尝试设置 data.a = 3 时,在控制台将分别打印字符串 ‘第一个依赖’ 和 ‘第二个依赖’。我们仅仅用十几行代码就实现了这样一个最基本的功能,但其实现在的实现存在很多缺陷,比如目前的代码仅仅能够实现对字段 a 的观测,如果添加一个字段 b 呢?所以最起码我们应该使用一个循环将定义访问器属性的代码包裹起来,如下:

const data = {
  a: 1,
  b: 1
}

for (const key in data) {
  const dep = []
  Object.defineProperty(data, key, {
    set () {
      dep.forEach(fn => fn())
    },
    get () {
      dep.push(Target)
    }
  })
}

这样我们就可以使用 $watch 函数观测任意一个 data 对象下的字段了,但是细心的同学可能早已发现上面代码的坑,即:

console.log(data.a) // undefined

直接在控制台打印 data.a 输出的值为 undefined,这是因为 get 函数没有任何返回值,所以获取任何属性的值都将是 undefined,其实这个问题很好解决,如下

for (let key in data) {
  const dep = []
  let val = data[key] // 缓存字段原有的值
  Object.defineProperty(data, key, {
    set (newVal) {
      // 如果值没有变什么都不做
      if (newVal === val) return
      // 使用新值替换旧值
      val = newVal
      dep.forEach(fn => fn())
    },
    get () {
      dep.push(Target)
      return val  // 将该值返回
    }
  })
}

只需要在使用 Object.defineProperty 函数定义访问器属性之前缓存一下原来的值即 val,然后在 get 函数中将 val 返回即可,除此之外还要记得在 set 函数中使用新值(newVal)重写旧值(val)。

但这样就完美了吗?当然没有,这距离完美可以说还相差十万八千里,比如当数据 data 是嵌套的对象时,我们的程序只能检测到第一层对象的属性,如果数据对象如下:

const data = {
  a: {
    b: 1
  }
}

对于以上对象结构,我们的程序只能把 data.a 字段转换成响应式属性,而 data.a.b 依然不是响应式属性,但是这个问题还是比较容易解决的,只需要递归定义即可:

function walk (data) {
  for (let key in data) {
    const dep = []
    let val = data[key]
    // 如果 val 是对象,递归调用 walk 函数将其转为访问器属性
    const nativeString = Object.prototype.toString.call(val)
    if (nativeString === '[object Object]') {
      walk(val)
    }
    Object.defineProperty(data, key, {
      set (newVal) {
        if (newVal === val) return
        val = newVal
        dep.forEach(fn => fn())
      },
      get () {
        dep.push(Target)
        return val
      }
    })
  }
}

walk(data)

如上代码我们将定义访问器属性的逻辑放到了函数 walk 中,并增加了一段判断逻辑如果某个属性的值仍然是对象,则递归调用 walk` 函数。这样我们就实现了深度定义访问器属性。

但是虽然经过上面的改造 data.a.b 已经是访问器属性了,但是如下代码依然不能正确执行:

$watch('a.b', () => {
  console.log('修改了字段 a.b')
})

来看看目前 $watch 函数的代码:

function $watch (exp, fn) {
  Target = fn
  // 读取字段值,触发 get 函数
  data[exp]
}

读取字段值的时候我们直接使用 data[exp],如果按照 $watch('a.b', fn) 这样调用 $watch 函数,那么 data[exp] 等价于 data['a.b'],这显然是不正确的,正确的读取字段值的方式应该是 data['a']['b']。所以我们需要稍微做一点小小的改造:

const data = {
  a: {
    b: 1
  }
}

function $watch (exp, fn) {
  Target = fn
  let pathArr,
      obj = data
  // 检查 exp 中是否包含 .
  if (/\./.test(exp)) {
    // 将字符串转为数组,例:'a.b' => ['a', 'b']
    pathArr = exp.split('.')
    // 使用循环读取到 data.a.b
    pathArr.forEach(p => {
      obj = obj[p]
    })
    return
  }
  data[exp]
}

我们对 $watch 函数做了一些改造,首先检查要读取的字段是否包含 .,如果包含 . 说明读取嵌套对象的字段,这时候我们使用字符串的 split('.') 函数将字符串转为数组,所以如果访问的路径是 a.b 那么转换后的数组就是 ['a', 'b'],然后使用一个循环从而读取到嵌套对象的属性值,不过需要注意的是读取到嵌套对象的属性值之后应该立即 return,不需要再执行后面的代码。

以上就是一个简单的 Object.defineProperty 的用例。