《Vuejs设计与实现》第 5 章(非原始值响应式方案) 中

45 阅读10分钟

《Vuejs设计与实现》第 5 章(非原始值响应式方案) 中

目录

[TOC]

5.4 合理触发响应

为了合理触发响应,我们需要处理一些问题。

首先,当值没有变化时,我们不应该触发响应:

const obj = { foo: 1 }
const p = new Proxy(obj, { /* ... */ })
 
effect(() => {
  console.log(p.foo)
})
 
// 设置 p.foo 的值,但值没有变化
p.foo = 1

上述代码,p.foo 的初始值为 1,当为 p.foo 设置新的值时,如果值没有发生变化,则不需要触发响应。
为了满足需求,在调用 trigger 函数触发响应之前,我们需要检查值是否发生了变化

const p = new Proxy(obj, {
  set(target, key, newVal, receiver) {
    // 先获取旧值
    const oldVal = target[key]
 
    const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
    const res = Reflect.set(target, key, newVal, receiver)
    // 比较新值与旧值,只要当不全等的时候才触发响应
    if (oldVal !== newVal) {
      trigger(target, key, type)
    }
    return res
  }
})

在set 函数内,先获取旧值 oldVal,比较新旧值,只有不全等时才触发响应。
但是,全等比较对 NaN 的处理有缺陷,因为 NaN === NaN 返回 false,为了解决这个问题,需要加一个条件:

const p = new Proxy(obj, {
  set(target, key, newVal, receiver) {
    // 先获取旧值
    const oldVal = target[key]
 
    const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
    const res = Reflect.set(target, key, newVal, receiver)
    // 比较新值与旧值,只有当它们不全等,并且不都是 NaN 的时候才触发响应
    if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
      trigger(target, key, type)
    }
 
    return res
  }
})

现在,我们已经解决了对 NaN 的处理问题。当新旧值不全等且不都是 NaN 时,才触发响应。

我们还需要处理从原型上继承属性的情况。首先,我们封装一个 reactive 函数,接受一个对象作为参数,返回创建的响应式数据:

function reactive(obj) {
  return new Proxy(obj, {
    // 省略拦截函数
  })
}

接下来,创建一个例子:

const obj = {}
const child = reactive(obj)
const proto = { bar: 1 }
const parent = reactive(proto)
// 使用 parent 作为 child 的原型
Object.setPrototypeOf(child, parent)
 
effect(() => {
  console.log(child.bar) // 1
})
// 修改 child.bar 的值
child.bar = 2 // 会导致副作用函数重新执行两次

在这个例子中,我们创建了两个响应式对象 child 和 parent,并将 parent 设置为 child 的原型。
在副作用函数中访问 child.bar 时,值是从原型上继承的。当我们执行 child.bar = 2 时,副作用函数会执行两次,导致不必要的更新。
我们分析下整个过程,访问 child.bar 时,触发 child 代理对象的 get 拦截函数。在拦截函数中,引擎使用 Reflect.get(target, key, receiver) 得到结果。如果对象自身不存在该属性,会获取对象的原型,并调用原型的 [[Get]] 方法得到最终结果。
在这个例子中,由于 child 自身没有 bar 属性,所以最终得到的实际上是 parent.bar 的值。但 parent 本身也是响应式数据,因此在副作用函数中访问 parent.bar 的值时,会建立响应联系。所以,child.bar 和 parent.bar 都与副作用函数建立了响应联系。
当设置 child.bar 的值时,我们需要弄清楚为什么副作用函数会连续执行两次。在设置过程中,会先触发 child 代理对象的 set 拦截函数。由于 obj 上不存在 bar 属性,会取得 obj 的原型 parent,并执行 parent 代理对象的 set 拦截函数。这导致副作用函数被触发两次。
为了解决这个问题,我们可以在 set 拦截函数内区分这两次更新。当我们设置 child.bar 的值时,receiver 始终是 child,而 target 则会变化:

// child 的 set 拦截函数
set(target, key, value, receiver) {
  // target 是原始对象 obj
  // receiver 是代理对象 child
}
 
// parent 的 set 拦截函数
set(target, key, value, receiver) {
  // target 是原始对象 proto
  // receiver 仍然是代理对象 child
}

我们只需要判断 receiver 是否是 target 的代理对象即可。只有当 receiver 是 target 的代理对象时才触发更新,从而屏蔽原型引起的更新。
这就需要我们为 get 拦截函数添加一个能力,使代理对象可以通过 raw 属性访问原始数据

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      // 代理对象可以通过 raw 属性访问原始数据
      if (key === 'raw') {
        return target
      }
 
      track(target, key)
      return Reflect.get(target, key, receiver)
    }
    // 省略其他拦截函数
  })
}

然后,在 set 拦截函数中判断 receiver 是不是 target 的代理对象:

function reactive(obj) {
  return new Proxy(obj, {
    set(target, key, newVal, receiver) {
      const oldVal = target[key]
      const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
      const res = Reflect.set(target, key, newVal, receiver)
 
      // target === receiver.raw 说明 receiver 就是 target 的代理对象
      if (target === receiver.raw) {
        if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
          trigger(target, key, type)
        }
      }
 
      return res
    }
    // 省略其他拦截函数
  })
}

通过这种方式,我们只在 receiver 是 target 的代理对象时触发更新,从而避免了由原型引起的不必要的更新操作。

5.5 浅响应与深响应

事实上,我们目前实现的 reactive 是浅响应的。看以下代码:

const obj = reactive({ foo: { bar: 1 } })
 
effect(() => {
  console.log(obj.foo.bar)
})
// 修改 obj.foo.bar 的值,并不能触发响应
obj.foo.bar = 2

首先,创建了 obj 代理对象,该对象的 foo 属性值是另一个对象,即 { bar: 1 }。
然后,在副作用函数内访问 obj.foo.bar 的值。但我们发现,后续对 obj.foo.bar 的修改无法触发副作用函数的重新执行。
为什么呢?让我们看一下现有的实现:

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      if (key === 'raw') {
        return target
      }
 
      track(target, key)
      // 当读取属性值时,直接返回结果
      return Reflect.get(target, key, receiver)
    }
    // 省略其他拦截函数
  })
}

上述代码显示,当我们读取 obj.foo.bar 时,首先要读取 obj.foo 的值。
这里我们直接使用 Reflect.get 函数返回 obj.foo 的结果。
由于通过 Reflect.get 得到的 obj.foo 结果是一个普通对象,即 { bar: 1 },它不是响应式对象,因此在副作用函数中访问 obj.foo.bar 时,无法建立响应联系。
为解决此问题,我们需要对 Reflect.get 返回结果进行一层包装:

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      if (key === 'raw') {
        return target
      }
 
      track(target, key)
      // 得到原始值结果
      const res = Reflect.get(target, key, receiver)
      if (typeof res === 'object' && res !== null) {
        // 调用 reactive 将结果包装成响应式数据并返回
        return reactive(res)
      }
      // 返回 res
      return res
    }
    // 省略其他拦截函数
  })
}

如上述代码所示,当读取属性值时,我们首先检测该值是否是对象。如果是对象,就递归地调用 reactive 函数将其包装成响应式数据并返回。
这样,当使用 obj.foo 读取 foo 属性值时,得到的结果就是一个响应式数据。因此,再通过 obj.foo.bar 读取 bar 属性值时,就会自然地建立响应联系。这样,当修改 obj.foo.bar 的值时,就能触发副作用函数重新执行。

然而,并非所有情况下我们都希望深响应。这就产生了 shallowReactive,即浅响应。浅响应的是只有对象的第一层属性是响应的,例如:

const obj = shallowReactive({ foo: { bar: 1 } })
 
effect(() => {
  console.log(obj.foo.bar)
})
// obj.foo 是响应的,可以触发副作用函数重新执行
obj.foo = { bar: 2 }
// obj.foo.bar 不是响应的,不能触发副作用函数重新执行
obj.foo.bar = 3

在这个例子中,我们使用 shallowReactive 函数创建了一个浅响应的代理对象 obj。
可以发现,只有对象的第一层属性是响应的,第二层及更深层次的属性则不是响应的。
实现此功能并不难,如下面的代码所示:

// 封装 createReactive 函数,接收一个参数 isShallow,代表是否为浅响应,默认为 false,即非浅响应
function createReactive(obj, isShallow = false) {
  return new Proxy(obj, {
    // 拦截读取操作
    get(target, key, receiver) {
      if (key === 'raw') {
        return target
      }
 
      const res = Reflect.get(target, key, receiver)
 
      track(target, key)
 
      // 如果是浅响应,则直接返回原始值
      if (isShallow) {
        return res
      }
 
      if (typeof res === 'object' && res !== null) {
        return reactive(res)
      }
 
      return res
    }
    // 省略其他拦截函数
  })
}
 
// 使用 createReactive 函数轻松实现 reactive 和 shallowReactive 函数
function reactive(obj) {
  return createReactive(obj)
}
function shallowReactive(obj) {
  return createReactive(obj, true)
}

在上述代码中,我们将对象创建的工作封装到一个新的函数 createReactive 中。
该函数除了接收原始对象 obj 之外,还接收参数 isShallow,它是一个布尔值,代表是否创建浅响应对象。
有了 createReactive 函数后,我们就可以使用它轻松地实现 reactive 和 shallowReactive 函数。

5.6 只读和浅只读

有时我们希望某些数据是只读的,即用户尝试修改时会收到警告。
例如,组件接收到的 props 应该是只读的。这时我们可以使用 readonly 函数将数据设为只读:

const obj = readonly({ foo: 1 })
// 尝试修改数据,会得到警告
obj.foo = 2

只读本质上也是对数据对象的代理,我们可以为 createReactive 函数增加第三个参数 isReadonly 来实现:

// 增加第三个参数 isReadonly,代表是否只读,默认为 false,即非只读
function createReactive(obj, isShallow = false, isReadonly = false) {
  return new Proxy(obj, {
    // 拦截设置操作
    set(target, key, newVal, receiver) {
      // 如果是只读的,则打印警告信息并返回
      if (isReadonly) {
        console.warn(`属性 ${key} 是只读的`)
        return true
      }
      const oldVal = target[key]
      const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
      const res = Reflect.set(target, key, newVal, receiver)
      if (target === receiver.raw) {
        if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
          trigger(target, key, type)
        }
      }
 
      return res
    },
    deleteProperty(target, key) {
      // 如果是只读的,则打印警告信息并返回
      if (isReadonly) {
        console.warn(`属性 ${key} 是只读的`)
        return true
      }
      const hadKey = Object.prototype.hasOwnProperty.call(target, key)
      const res = Reflect.deleteProperty(target, key)
 
      if (res && hadKey) {
        trigger(target, key, 'DELETE')
      }
 
      return res
    }
    // 省略其他拦截函数
  })
}

当使用 createReactive 创建代理对象时,可以通过第三个参数指定是否创建一个只读的代理对象
同时,我们还修改了 set 拦截函数和 deleteProperty 拦截函数的实现,因为对于一个对象来说,只读意味着既不可以设置对象的属性值,也不可以删除对象的属性。
当然,如果一个数据是只读的,那就意味着任何方式都无法修改它,所以也就不需要调用 track 函数追踪响应:

const obj = readonly({ foo: 1 });
effect(() => {
  obj.foo; // 可以读取值,但是不需要在副作用函数与数据之间建立响应联系
});

为了实现该功能,我们需要修改 get 拦截函数的实现:

function createReactive(obj, isShallow = false, isReadonly = false) {
  return new Proxy(obj, {
    // 拦截读取操作
    get(target, key, receiver) {
      if (key === 'raw') {
        return target
      }
      // 非只读的时候才需要建立响应联系
      if (!isReadonly) {
        track(target, key)
      }
 
      const res = Reflect.get(target, key, receiver)
 
      if (isShallow) {
        return res
      }
 
      if (typeof res === 'object' && res !== null) {
        return reactive(res)
      }
 
      return res
    }
    // 省略其他拦截函数
  })
}

如上面的代码所示,只有非只读的时候才需要建立响应联系。基于此,我们就可以实现 readonly 函数了:

function readonly(obj) {
  return createReactive(obj, false, true /* 只读 */);
}

然而,上面实现的 readonly 函数更应该叫作 shallowReadonly,因为它没有做到深只读:

const obj = readonly({ foo: { bar: 1 } });
obj.foo.bar = 2; // 仍然可以修改

所以为了实现深只读,我们还应该在 get 拦截函数内递归地调用 readonly 将数据包装成只读的代理对象,并将其作为返回值返回:

function createReactive(obj, isShallow = false, isReadonly = false) {
  return new Proxy(obj, {
    // 拦截读取操作
    get(target, key, receiver) {
      if (key === 'raw') {
        return target
      }
      if (!isReadonly) {
        track(target, key)
      }
 
      const res = Reflect.get(target, key, receiver)
 
      if (isShallow) {
        return res
      }
 
      if (typeof res === 'object' && res !== null) {
        // 如果数据为只读,则调用 readonly 对值进行包装
        return isReadonly ? readonly(res) : reactive(res)
      }
 
      return res
    }
    // 省略其他拦截函数
  })
}

上述代码,我们判断是否只读,如果只读则调用 readonly 函数对值进行包装,并把包装后的只读对象返回。
对于 shallowReadonly,实际上我们只需要修改 createReactive 的第二个参数即可:

function readonly(obj) {
  return createReactive(obj, false, true);
}
 
function shallowReadonly(obj) {
  return createReactive(obj, true, true);
}
 

上述代码,在 shallowReadonly 函数内调用 createReactive 函数创建代理对象时,将第二个参数 isShallow 设置为 true,这样就可以创建一个浅只读的代理对象了。