【Vue技巧】利用Proxy自动添加响应式属性

相关原理

初始化Vue实例时,Vue将递归遍历data对象,经过Object.defineProperty将其中已有的属性转化为响应式的属性(getter/setter)。响应式属性的变化才可以被Vue观察到。
这就是为何,Vue文档建议咱们在初始化Vue实例以前,提早初始化data中全部可能用到的属性。若是想要在Vue实例建立之后添加响应式属性,须要使用Vue.set(object, key, value),而不能直接经过赋值来添加新属性(这样添加的新属性不具备响应性)。html

运行时才能肯定数据属性的键,这称为 动态属性。相对地,若是在 编程时就能肯定属性的键,这称为 静态属性。

Vue.set的限制

注意,Vue.set的第一个参数不能是Vue实例或者Vue实例的数据对象,能够是数据对象内嵌套的对象,或者props中的对象。也就是说,不能动态添加根级响应式属性。vue

Vue文档: Vue does not allow dynamically adding new root-level reactive properties to an already created instance. However, it’s possible to add reactive properties to a nested object using the Vue.set(object, key, value) method.
let vm = new Vue({
    data: {
        nestedObj: {}
    }
});    // 建立Vue实例
Vue.set(vm, 'a', 2);    // not works,不能为Vue实例添加根级响应式属性
Vue.set(vm.$data, 'b', 2);    // not works,不能为Vue数据对象添加根级响应式属性
Vue.set(vm.nestedObj, 'c', 2);    // works,vm.nestedObj是数据对象内的一个嵌套对象
Vue.set(vm.$data.nestedObj, 'd', 2);    // works,vm.$data.nestedObj是数据对象内的一个嵌套对象

Vue.set会作适当的检查并报错:set源码react

Vue.set例子

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
</head>

<body>
  <div id="app">
    <test-dynamic></test-dynamic>
  </div>
</body>

<script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.js"></script>
<script>
  const testDynamicComponent = {
    template: `
      <div>
        <button @click="onClick">test</button>
        <p v-if="show">{{ nestedObj.dynamic }}</p>
      </div>
    `,
    data() {
      return ({
        show: false,
        nestedObj: {}
      })
    },
    methods: {
      onClick() {
        // Vue.set(this, 'dynamic', 'wait 2 seconds...');  // this will not works!
        // Vue.set(this.$data, 'dynamic', 'wait 2 seconds...');  // this will not works!
        Vue.set(this.$data.nestedObj, 'dynamic', 'wait 2 seconds...'); // this works
        // Vue.set(this.nestedObj, 'dynamic', 'wait 2 seconds...'); // this also works
        this.show = true;
        setTimeout(() => {
          this.nestedObj.dynamic = 'createReactiveProxy works!';
        }, 2000);
      }
    }
  };
  var app = new Vue({
    el: '#app',
    components: {
      'test-dynamic': testDynamicComponent
    }
  })
</script>

</html>

问题背景

实际使用场景中,有时碰到这种状况:在建立Vue实例的时候,你还不肯定会用到哪些属性(须要与用户进行交互以后才知道),或者有大量的属性都有可能被用到(而你不想为数据对象初始化那么多的属性)。这时候,提早初始化全部数据对象的属性就不太现实了。git

解决方案

一个原始的解决方案:与用户交互的过程当中,每当发现须要用到新的属性,就经过Vue.set添加响应式属性。github

牢记上面讲到的 Vue.set的限制。动态添加的属性只能放在data内嵌套的对象中,或者props中的对象。实战中能够在data数据对象中专门用一个属性来存放动态属性,好比 data: { staticProp1: '', staticProp2: '', dynamicProps: {} }

在这个方法的基础上,能够扩展出一个一劳永逸的方案:使用ES6 Proxy,为data建立一个代理,拦截对data的赋值操做,若是发现此次赋值是属性添加,则使用Vue.set来动态添加响应式属性。npm

再进一步,咱们还能够:编程

  1. 递归为已存在的子属性建立代理。
  2. 动态添加属性时,若是赋值的属性值是对象,那么也为这个对象建立代理。

实现以下:api

import Vue from "vue";
// 已经拥有createReactiveProxy的对象拥有如下特殊属性,方便咱们检测、获取reactiveProxy
const REACTIVE_PROXY = Symbol("reactiveProxy拥有的特殊标记,方便识别");

/**
 * @description 拦截赋值操做,
 * 若是发现此次赋值是属性添加,则使用Vue.set(object, key, value)来添加响应式属性。
 */
export function createReactiveProxy(obj) {
  if (typeof obj !== "object" || obj === null) {
    throw new Error(
      "createReactiveProxy的参数不是object: " + JSON.stringify(obj)
    );
  }
  if (obj[REACTIVE_PROXY]) {
    // 若是传入的对象已经拥有reactiveProxy,或者它就是reactiveProxy,则直接返回已有reactiveProxy
    return obj[REACTIVE_PROXY];
  }
  // console.log("creating reactiveProxy", obj);
  const proxy = new Proxy(obj, {
    set(target, property, value, receiver) {
      // 若是receiver === target,代表proxy处于被赋值对象的原型链上
      // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/set
      // 仅仅拦截直接对proxy的赋值操做(reactiveProxy.newProperty=newValue)
      if (!target.hasOwnProperty(property) && receiver === proxy) {
        if (typeof value === "object" && value !== null) {
          // 若是要赋的值也是对象,则也要拦截这个对象的赋值操做
          value = createReactiveProxy(value);
        }
        // console.log("Vue.set ", target, property);
        Vue.set(target, property, value);
        return true;
      } else {
        // console.log("Reflect.set ", target, property);
        return Reflect.set(...arguments);
      }
    }
  });
  // 方便之后检测、找到对象的reactiveProxy
  Object.defineProperty(obj, REACTIVE_PROXY, { value: proxy });
  Object.defineProperty(proxy, REACTIVE_PROXY, { value: proxy });
  // 检测这个对象已有的属性,若是是对象,则也要被拦截
  Object.keys(obj).forEach(key => {
    if (typeof obj[key] === "object" && obj[key] !== null) {
      obj[key] = createReactiveProxy(obj[key]);
    }
  });
  return proxy;
}

createReactiveProxy例子

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
</head>

<body>
  <div id="app">
    <test-dynamic></test-dynamic>
  </div>
</body>

<script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.js"></script>
<script>
  // 已经拥有createReactiveProxy的对象拥有如下特殊属性,方便咱们检测、获取reactiveProxy
  const REACTIVE_PROXY = Symbol("reactiveProxy拥有的特殊标记,方便识别");

  /**
  * @description 拦截赋值操做,
  * 若是发现此次赋值是属性添加,则使用Vue.set(object, key, value)来添加响应式属性。
  */
  function createReactiveProxy(obj) {
    if (typeof obj !== "object" || obj === null) {
      throw new Error(
        "createReactiveProxy的参数不是object: " + JSON.stringify(obj)
      );
    }
    if (obj[REACTIVE_PROXY]) {
      // 若是传入的对象已经拥有reactiveProxy,或者它就是reactiveProxy,则直接返回已有reactiveProxy
      return obj[REACTIVE_PROXY];
    }
    console.log("creating reactiveProxy", obj);
    const proxy = new Proxy(obj, {
      set(target, property, value, receiver) {
        // 若是receiver === target,代表proxy处于被赋值对象的原型链上
        // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/set
        // 仅仅拦截直接对proxy的赋值操做(reactiveProxy.newProperty=newValue)
        if (!target.hasOwnProperty(property) && receiver === proxy) {
          if (typeof value === "object" && value !== null) {
            // 若是要赋的值也是对象,则也要拦截这个对象的赋值操做
            value = createReactiveProxy(value);
          }
          console.log("Vue.set ", target, property, value);
          Vue.set(target, property, value);
          return true;
        } else {
          console.log("Reflect.set ", target, property, value);
          return Reflect.set(...arguments);
        }
      }
    });
    // 方便之后检测、找到对象的reactiveProxy
    Object.defineProperty(obj, REACTIVE_PROXY, { value: proxy });
    Object.defineProperty(proxy, REACTIVE_PROXY, { value: proxy });
    // 检测这个对象已有的属性,若是是对象,则也要被拦截
    Object.keys(obj).forEach(key => {
      if (typeof obj[key] === "object" && obj[key] !== null) {
        obj[key] = createReactiveProxy(obj[key]);
      }
    });
    return proxy;
  }
</script>
<script>
  const testDynamicComponent = {
    template: `
      <div>
        <button @click="onClick">test</button>
        <p v-if="show">{{ dynamicProps.dynamic }}</p>
      </div>
    `,
    data() {
      return createReactiveProxy({
        show: false,
        dynamicProps: {}
      });
    },
    methods: {
      onClick() {
        this.dynamicProps.dynamic = 'wait 2 seconds...';
        this.show = true;
        setTimeout(() => {
          this.dynamicProps.dynamic = 'createReactiveProxy works!';
        }, 2000);
      }
    }
  };
  var app = new Vue({
    el: '#app',
    components: {
      'test-dynamic': testDynamicComponent
    }
  })
</script>

</html>

关于v-model的补充

  1. Vue.set添加属性时,是经过defineProperty来添加getter和setter,并不会触发set handler,而是触发defineProperty handler
  2. 若是v-model绑定的属性不存在对象上,那么v-model会在第一次@input事件发生时,经过Vue.set添加绑定属性,让绑定的属性拥有响应性。如上一条所说,这个过程不会触发proxy的set handler。
  3. 在后续的@input事件,v-model才会经过data.prop=$event来更新绑定,这时会触发proxy的set handler。

也就是说,v-model不单单是data.prop=$event这样的语法糖,它会自动添加尚不存在、但当即须要的属性(利用Vue.set)。app

参考资料

  1. 深刻响应式原理 - Vue文档
  2. Vue.set文档
  3. Proxy
相关文章
相关标签/搜索