【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
相關文章
相關標籤/搜索