前端面試之Vue中元件通訊的方式

謝小飛發表於2020-03-28

  vue是資料驅動檢視更新的框架, 所以對於vue來說元件間的資料通訊非常重要;我們常用的方式莫過於通過props傳值給子元件,但是vue還有其他很多不常用的通訊方式,瞭解他們,也許在以後在寫程式碼的時候能給你帶來更多的思路和選擇。

prop/$emit

  父元件通過prop的方式向子元件傳遞資料,而通過$emit子元件可以向父元件通訊。

//Parent.vue
<template>
  <div>
    當前選中:{{ current }}
    <Child :list="list" @change="changeCurrent"></Child>
  </div>
</template>
<script>
import Child from "./child";
export default {
  data() {
    return {
      current: 0,
      list: ["紅樓夢", "水滸傳", "三國演義", "西遊記"]
    };
  },
  components: { Child },
  methods: {
    changeCurrent(num) {
      this.current = num;
    }
  }
};
</script>
複製程式碼

  我們可以通過prop向子元件傳遞資料;用一個形象的比喻來說,父子元件之間的資料傳遞相當於自上而下的下水管子,管子中的水就像資料,水只能從上往下流,不能逆流。這也正是Vue的設計理念之單向資料流。而prop正是管道與管道之間的一個銜介面,這樣水(資料)才能往下流。

//Child.vue
<template>
  <div>
    <template v-for="(item, index) in list">
      <div @click="clickItem(index)" :key="index">{{ item }}</div>
    </template>
  </div>
</template>
<script>
export default {
  props: {
    list: {
      type: Array,
      default: () => {
        return [];
      }
    }
  },
  methods: {
    clickItem(index) {
      this.$emit("change", index);
    }
  }
};
</script>
複製程式碼

prop-emit.gif

  在子元件中我們通過props物件定義了接收父元件值的型別和預設值,然後通過$emit()觸發父元件中的自定義事件。prop/$emit傳遞資料的方式在日常開發中用的非常多,一般涉及到元件開發都是基於通過這種方式;通過父元件中註冊子元件,並在子元件標籤上繫結對自定義事件的監聽。他的優點是傳值取值方便簡潔明瞭,但是這種方式的缺點是:

  1. 由於資料是單向傳遞,如果子元件需要改變父元件的props值每次需要給子元件繫結對應的監聽事件。
  2. 如果父元件需要給孫元件傳值,需要子元件進行轉發,較為不便。

.sync修飾符

  有些情況下,我們希望在子元件能夠“直接修改”父元件的prop值,但是雙向繫結會帶來維護上的問題;vue提供了一種解決方案,通過語法糖.sync修飾符。

  .sync修飾符在 vue1.x 的時候曾作為雙向繫結功能存在,即子元件可以修改父元件中的值。但是它違反了單向資料流的設計理念,所以在 vue2.0 的時候被幹掉了。但是在 vue2.3.0+ 以上版本又重新引入了。但是這次它只是作為一個編譯時的語法糖存在。它會被擴充套件為一個自動更新父元件屬性的v-on監聽器。說白了就是讓我們手動進行更新父元件中的值了,從而使資料改動來源更加的明顯。

//Parent.vue
<template>
  <div>
    <Child :msg.sync="msg" :num.sync="num"></Child>
  </div>
</template>
<script>
import Child from "./child";
export default {
  name: "way2",
  components: {
    Child
  },
  data() {
    return {
      msg: "hello every guys",
      num: 0
    };
  }
};
</script>

複製程式碼

  我們在Child元件傳值時給每個值新增一個.sync修飾,在編譯時會被擴充套件為如下程式碼:

<Child :msg="msg" @update.msg="val => msg = val" :num.sync="num" @update.num="val => num = val"></Child>
複製程式碼

  因此子元件中只需要顯示的觸發update的更新事件:

//Child.vue
<template>
  <div>
    <div @click="clickRevert">點選更新字串:{{ msg }}</div>
    <div>當前值:{{ num }}</div>
    <div @click="clickOpt('add')" class="opt">+</div>
    <div @click="clickOpt('sub')" class="opt">-</div>
  </div>
</template>
<script>
export default {
  props: {
    msg: {
      type: String,
      default: ""
    },
    num: {
      type: Number,
      default: 0
    }
  },
  methods: {
    clickRevert() {
      let { msg } = this;
      this.$emit("update:msg",msg.split("").reverse().join(""));
    },
    clickOpt(type = "") {
      let { num } = this;
      if (type == "add") {
        num++;
      } else {
        num--;
      }
      this.$emit("update:num", num);
    }
  }
};
</script>
複製程式碼

sync.gif

  這種“雙向繫結”的操作是不是看著似曾相識?是的,v-model本質上也是一種語法糖,只不過它觸發的不是update方法而是input方法;而且v-model沒有.sync來的更加靈活,v-model只能繫結一個值。

  總結:.sync修飾符優化了父子元件通訊的傳值方式,不需要在父元件再寫多餘的函式來修改賦值。

attrs和listeners

  當需要用到從A到C的跨級通訊時,我們會發現prop傳值非常麻煩,會有很多冗餘繁瑣的轉發操作;如果C中的狀態改變還需要傳遞給A,使用事件還需要一級一級的向上傳遞,程式碼可讀性就更差了。

$attr-$listen

  因此vue2.4+版本提供了新的方案:$attrs和$listeners,我們先來看一下官網對$attrs的描述:

包含了父作用域中不作為 prop 被識別 (且獲取) 的特性繫結 (class 和 style 除外)。當一個元件沒有宣告任何 prop 時,這裡會包含所有父作用域的繫結 (class 和 style 除外),並且可以通過 v-bind="$attrs" 傳入內部元件——在建立高階別的元件時非常有用。

emo.jpg

  這一大段話第一次讀非常的繞口,而且晦澀難懂,不過沒關係,我們直接上程式碼:

//Parent.vue
<template>
  <div>
    <Child
      :notUse="'not-use'"
      :childMsg="childMsg"
      :grandChildMsg="grandChildMsg"
      @onChildMsg="onChildMsg"
      @onGrandChildMsg="onGrandChildMsg"
    ></Child>
  </div>
</template>
<script>
import Child from "./child";
export default {
  data() {
    return {
      childMsg: "hello child",
      grandChildMsg: "hello grand child"
    };
  },
  components: { Child },
  methods: {
    onChildMsg(msg) {
      this.childMsg = msg;
    },
    onGrandChildMsg(msg) {
      this.grandChildMsg = msg;
    }
  }
};
</script>
複製程式碼

  我們首先定義了兩個msg,一個給子元件展示,另一個給孫元件展示,首先將這兩個資料傳遞到子元件中,同時將兩個改變msg的函式傳入。

//child.vue
<template>
  <div class="box">
    <div @click="clickMsg">{{ childMsg }}</div>
    <div>$attrs: {{ $attrs }}</div>
    <GrandChild v-bind="$attrs" v-on="$listeners"></GrandChild>
  </div>
</template>
<script>
import GrandChild from "./grand-child";
export default {
  props: {
    childMsg: {
      type: String
    }
  },
  methods: {
    clickMsg() {
      let { childMsg } = this;
      this.$emit(
          "onChildMsg",
          childMsg.split("").reverse().join("")
      );
    }
  },
  components: { GrandChild }
};
</script>
複製程式碼

  在子元件中我們通過props獲取子元件所需要的引數,即childMsg;剩餘的引數就被歸到了$attrs物件中,我們可以在頁面中展示出來,然後把它繼續往孫元件中傳;同時把所有的監聽函式歸到$listeners,也繼續往下傳。

//grand-child.vue
<template>
  <div class="box1" @click="clickMsg">grand-child:{{ grandChildMsg }}</div>
</template>
<script>
export default {
  props: {
    grandChildMsg: {
      type: String
    }
  },
  methods: {
    clickMsg() {
      let { grandChildMsg } = this;
      this.$emit(
        "onGrandChildMsg",
        grandChildMsg.split("").reverse().join("")
      );
    }
  }
};
</script>
複製程式碼

  在孫元件中我們繼續取出所需要的資料進行展示或者操作,執行結果如下:

result

  當我們在元件上賦予一個非prop宣告時,比如child元件上的notuse和grandchildmsg屬性我們沒有用到,編譯之後的程式碼會把這個屬性當成原始屬性對待,新增到html原生標籤上,所以我們檢視程式碼是這樣的:

inheritAttrs.png

  這樣會很難看,我們可以在元件上加上inheritAttrs屬性將它去掉:

export default {
    mounted(){},
    inheritAttrs: false,
}
複製程式碼

  總結:$attrs和$listeners很好的解決了跨一級元件傳值的問題。

provide和inject

  雖然$attrs和$listeners可以很方便的從父元件傳值到孫元件,但是如果跨了三四級,並且想要的資料已經被上級元件取出來,這時$attrs就不能解決了。

  provide/inject是vue2.2+版本新增的屬性,簡單來說就是父元件中通過provide來提供變數, 然後再子元件中通過inject來注入變數。這裡inject注入的變數不像$attrs,只能向下一層;inject不論子元件巢狀有多深,都能獲取到。

//Parent.vue
<template>
  <div>
    <Child></Child>
  </div>
</template>
<script>
import Child from "./child";
export default {
  components: { Child },
  data() {
    return {
      childmsg: "hello child",
      grandmsg: "hello grand child"
    };
  },
  provide() {
    return {
      childmsg: this.childmsg,
      grandmsg: this.grandmsg
    };
  },
  mounted() {
    setTimeout(() => {
      this.childmsg = "hello new child";
      this.grandmsg = "hello new grand child";
    }, 2000);
  },
};
</script>
複製程式碼

  我們在父元件通過provide注入了兩個變數,並且在兩秒之後修改變數的值,然後就在子元件和孫元件取出來。

//child.vue
<template>
  <div class="box">
    <div>child-msg:{{ childmsg }}</div>
    <div>grand-msg:{{ grandmsg }}</div>
    <GrandChild></GrandChild>
  </div>
</template>
<script>
import GrandChild from "./grand-child";
export default {
  inject: ["childmsg", "grandmsg"],
  components: { GrandChild },
};
</script>
//grand-child.vue
<template>
  <div class="box">
    <div>child-msg:{{ childmsg }}</div>
    <div>grand-msg:{{ grandmsg }}</div>
  </div>
</template>
<script>
export default {
  name: "GrandChild",
  inject: ["childmsg", "grandmsg"],
};
</script>
複製程式碼

provide-inject.png

  可以看到子元件和孫元件都能取出值,並且渲染出來。需要注意的是,一旦子元件注入了某個資料,在data中就不能再宣告這個資料了。

  同時,過了兩秒後我們發現childmsg和grandmsg的值並沒有按照預期的改變,也就是說子元件並沒有響應修改後的值,官網的介紹是這麼說的:

提示:provideinject 繫結並不是可響應的。這是刻意為之的。然而,如果你傳入了一個可監聽的物件,那麼其物件的屬性還是可響應的。

  vue並沒有把provide和inject設計成響應式的,這是vue故意的,但是如果傳入了一個可監聽的物件,那麼就可以響應了:

export default {
  data() {
    return {
      respondData: {
        name: "hello respond"
      }
    };
  },
  provide() {
    return {
      respondData: this.respondData
    };
  },
  mounted() {
    setTimeout(() => {
      this.respondData.name = this.respondData.name
        .split("")
        .reverse()
        .join("");
    }, 2000);
  },
}
複製程式碼

  那麼為什麼上面的props和$attrs都是響應式的,連破壞“單向資料流”的.sync修飾符都是響應式的,但到了provide/inject就不是響應式的了呢?在網上找了半天的資料也沒有找到確切的答案,本文就此結束。

no-expect.jpg

  就這麼結束了嗎?當然沒有!在一(zi)個(ji)哥(xue)們(xi)的幫(yuan)助(ma)下,我總算找到了答案。首先我們試想一下,如果有多個子元件同時依賴於一個父元件提供的資料,那麼一旦父元件修改了該值,那麼所有元件都會受到影響,這是我們不希望看到的;這一方面增加了耦合度,另一方面使得資料變化不可控制。接著看一下vue是怎麼來實現provide/inject的。

//src/core/instance/inject.js
//部分核心原始碼
export function initProvide (vm: Component) {
  const provide = vm.$options.provide
  if (provide) {
    vm._provided = typeof provide === 'function'
      ? provide.call(vm)
      : provide
  }
}
export function initInjections (vm: Component) {
  const result = resolveInject(vm.$options.inject, vm)
  if (result) {
    observerState.shouldConvert = false
    Object.keys(result).forEach(key => {
      defineReactive(vm, key, result[key])
    })
    observerState.shouldConvert = true
  }
}
export function resolveInject (inject: any, vm: Component): ?Object {
  if (inject) {
    const result = Object.create(null)
    const keys = hasSymbol
        ? Reflect.ownKeys(inject).filter(key => {
          return Object.getOwnPropertyDescriptor(inject, key).enumerable
        })
        : Object.keys(inject)

    for (let i = 0; i < keys.length; i++) {
      const key = keys[i]
      const provideKey = inject[key].from
      let source = vm
      while (source) {
        if (source._provided && provideKey in source._provided) {
          result[key] = source._provided[provideKey]
          break
        }
        source = source.$parent
      }
      if (!source) {
        if ('default' in inject[key]) {
          const provideDefault = inject[key].default
          result[key] = typeof provideDefault === 'function'
            ? provideDefault.call(vm)
            : provideDefault
        }
      }
    }
    return result
  }
}
複製程式碼

  可以看到初始化provide的時候將父元件的provide掛載到_provided,但它不是一個響應式的物件;然後子元件通過$parent向上查詢所有父元件的_provided獲取第一個有目標屬性的值,然後遍歷繫結到子元件上;因為只是初始化的時候繫結的,而且_provided也不是響應式的,所以造成了provide/inject的這種特性。

  那麼provide/inject這麼危險,又不是響應式的,它能拿來做什麼呢?開啟element-ui的原始碼搜尋provide,我們可以看到非常多的元件使用了provide/inject,我們就拿form、form-item和button舉個例子。

  form和form-item都可以傳入一個屬性size來控制子元件的尺寸,但是子元件的位置是不固定的,可能會巢狀了好幾層el-row或者el-col,如果一層一層的通過props傳size下去會很繁瑣,這是provide/inject就派上用處了。

//form-item.vue
export default {
    provide() {
      return {
        elFormItem: this
      };
    },
}
//button.vue
export default {
    inject: {
      elForm: {
        default: ''
      },
      elFormItem: {
        default: ''
      }
    },
    computed: {
      _elFormItemSize() {
        return (this.elFormItem || {}).elFormItemSize;
      },
      buttonSize() {
        return this.size || this._elFormItemSize || (this.$ELEMENT || {}).size;
      },
    },
}
複製程式碼

  我們通過父元件將elFormItem本身注入到子元件中,子元件通過inject獲取父元件本身然後動態地計算buttonSize。

  總結:provide/inject能夠解決多層元件巢狀傳值的問題,但是是非響應的,即provide與inject之間沒有繫結,注入的值是在子元件初始化過程中決定的。

EventBus

  EventBus我剛開始直接翻譯理解為事件車,但比較官方的翻譯是事件匯流排。它的實質就是建立一個vue例項,通過一個空的vue例項作為橋樑實現vue元件間的通訊。它是實現非父子元件通訊的一種解決方案,所有的元件都可以上下平行地通知其他元件,但也就是太方便所以若使用不慎,就會造成難以維護的“災難”。

//utils/event-bus.js
import Vue from "vue";
export default new Vue();
複製程式碼

  首先創造一個空的vue物件並將其匯出,他是一個不具備DOM的元件,它具有的僅僅只是它例項方法而已,因此它非常的輕便。

//main.js
import bus from "@/utils/event-bus";
Vue.prototype.$bus = bus;
複製程式碼

  將其掛載到全域性,變成全域性的事件匯流排,這樣在元件中就能很方便的呼叫了。

//Parent.vue
<template>
  <div class="box">
    <Child1></Child1>
    <Child2></Child2>
  </div>
</template>
<script>
import Child1 from "./child1";
import Child2 from "./child2";
export default {
  components: {
    Child1,
    Child2
  }
};
</script>
複製程式碼

  我們先定義了兩個子元件child1和child2,我們希望這兩個元件能夠直接給對方傳送訊息。

//child1.vue
<template>
  <div>
    <div class="send" @click="clickSend">傳送訊息</div>
    <template v-for="(item, index) in msgList">
      <div :key="index">{{ item }}</div>
    </template>
  </div>
</template>
<script>
export default {
  data() {
    return { msgList: [] };
  },
  mounted() {
    this.$bus.$on("getMsg1", res => {
      this.msgList.push(res);
    });
  },
  methods: {
    clickSend() {
      this.$bus.$emit("getMsg2", "hello from1:" + parseInt(Math.random() * 20));
    }
  }
};
</script>
//child2.vue
<template>
  <div>
    <div class="send" @click="clickSend">傳送訊息</div>
    <template v-for="(item, index) in msgList">
      <div :key="index">{{ item }}</div>
    </template>
  </div>
</template>
<script>
export default {
  data() {
    return { msgList: [] };
  },
  mounted() {
    this.$bus.$on("getMsg2", res => {
      this.msgList.push(res);
    });
  },
  methods: {
    clickSend() {
      this.$bus.$emit("getMsg1", "hello from2:" + parseInt(Math.random() * 20));
    }
  }
};
</script>
複製程式碼

  我們初始化時在child1和child2中分別註冊了兩個接收事件,然後點選按鈕時分別觸發這兩個自定義的事件,並傳入資料,最後兩個元件分別能接收到對方傳送的訊息,最後效果如下:

event-bus.gif

  前面也提到過,如果使用不善,EventBus會是一種災難,到底是什麼樣的“災難”了?大家都知道vue是單頁應用,如果你在某一個頁面重新整理了之後,與之相關的EventBus會被移除,這樣就導致業務走不下去。還要就是如果業務有反覆操作的頁面,EventBus在監聽的時候就會觸發很多次,也是一個非常大的隱患。這時候我們就需要好好處理EventBus在專案中的關係。通常會用到,在頁面或元件銷燬時,同時移除EventBus事件監聽。

export default{
    destroyed(){
        $EventBus.$off('event-name')
    }
}
複製程式碼

  總結:EventBus可以用來很方便的實現兄弟元件和跨級元件的通訊,但是使用不當時也會帶來很多問題;所以適合邏輯並不複雜的小頁面,邏輯複雜時還是建議使用vuex。

vuex

  在vue元件開發中,經常會遇到需要將當前元件的狀態傳遞給其他非父子元件元件,或者一個狀態需要共享給多個元件,這時採用上面的方式就會非常麻煩。vue提供了另一個庫vuex來解決資料傳遞的問題;剛開始上手會感覺vuex非常的麻煩,很多概念也容易混淆,不過不用擔心,本文不深入講解vuex。

  vuex實現了單向的資料流,在全域性定義了一個State物件用來儲存資料,當元件要修改State中的資料時,必須通過Mutation進行操作。

//store/count.js
export default {
  namespaced: true,
  state: { num: 1 },
  mutations: {
    ADD_NUM(state) {
      state.num = state.num + 1;
    },
    SUB_NUM(state) {
      state.num = state.num - 1;
    }
  },
  actions: {
    ADD_SYNC({ commit }) {
      setTimeout(() => {
        commit("ADD_NUM");
      }, 1000);
    },
    SUB_SYNC({ commit }) {
      setTimeout(() => {
        commit("SUB_NUM");
      }, 1000);
    }
  }
};
//store/index.js
import count from "./count";
export default new Vuex.Store({
  modules: {
    count
  },
});
複製程式碼

  我們首先在全域性定義了count.js模組用來存放資料和修改資料的方法,然後在全域性引入。

//child.vue
<template>
  <div>
    <div>當前:{{ num }}</div>
    <div class="opt" @click="clickAdd">+</div>
    <div class="opt" @click="clickSub">-</div>
    <div class="opt" @click="clickAddSync">a+</div>
    <div class="opt" @click="clickSubSync">a-</div>
  </div>
</template>
<script>
export default {
  name: "Child",
  computed: {
    num() {
      return this.$store.state.count.num;
    }
  },
  methods: {
    clickAdd() {
      this.$store.commit("count/ADD_NUM");
    },
    clickSub() {
      this.$store.commit("count/SUB_NUM");
    },
    clickAddSync() {
      this.$store.dispatch("count/ADD_SYNC");
    },
    clickSubSync() {
      this.$store.dispatch("count/SUB_SYNC");
    }
  }
};
</script>
複製程式碼

vuex.gif

  我們就可以在任何元件中來呼叫mutations和actions中的方法運算元據了。vuex在資料傳值和運算元據維護起來比較方便,但是有一定的學習成本。

$refs

  有時候我們需要在vue中直接來操作DOM元素,比如獲取DIV的高度,或者直接呼叫子元件的一些函式;雖然原生的JS也能獲取到,但是vue為我們提供了更方便的一個屬性:$refs。如果在普通的DOM元素上使用,獲取到的就是DOM元素;如果用在子元件上,獲取的就是元件的例項物件。

//child.vue
<template>
  <div>初始化:{{ num }}</div>
</template>
<script>
export default {
  data() {
    return { num: 0 };
  },
  methods: {
    addNum() {
      this.num += 1;
    },
    subNum() {
      this.num -= 1;
    }
  }
};
</script>
複製程式碼

  我們首先建立一個簡單的子元件,有兩個函式用來增減num的值。

<template>
  <div>
    <Child ref="child"></Child>
    <div class="opt" ref="opt_add" @click="clickAddBtn">+</div>
    <div class="opt" ref="opt_sub" @click="clickSubBtn">-</div>
    <div class="opt" ref="opt_show" @click="clickShowBtn">show</div>
  </div>
</template>
<script>
import Child from "./child";
export default {
  components: { Child },
  data() {
    return {};
  },
  methods: {
    clickAddBtn() {
      this.$refs.child.addNum();
    },
    clickSubBtn() {
      this.$refs.child.subNum();
    },
    clickShowBtn() {
      console.log(this.$refs.child);
      console.log(this.$refs.child.num);
    }
  }
};
</script>
複製程式碼

  我們給子元件增加一個ref屬性child,然後通過$refs.child來獲取子元件的例項,通過例項來呼叫子元件中的函式。

refs

  可以看到我們獲取到的是一個VueComponent物件,這個物件包括了子元件的所有資料和函式,可以對子元件進行一些操作。

parent和children

  如果頁面有多個相同的子元件需要操作的話,$refs一個一個操作起來比較繁瑣,vue提供了另外的屬性:$parent和$children來統一選擇。

//child.vue
<template>
  <div>child</div>
</template>
<script>
export default {
  mounted() {
    console.log(this.$parent.show());
    console.log("Child", this.$children, this.$parent);
  }
};
</script>
//Parent.vue
<template>
  <div>
    parent
    <Child></Child>
    <Child></Child>
  </div>
</template>
<script>
import Child from "./child";
export default {
  components: { Child },
  mounted() {
    console.log("Parent", this.$children, this.$parent);
  },
  methods: {
    show() {
      return "to child data";
    }
  }
};
</script>
複製程式碼

  我們在父元件中插入了兩個相同的子元件,在子元件中通過$parent呼叫了父元件的函式,並在父元件通過$children獲取子元件例項的陣列。

children.png

  我們在Parent中列印出$parent屬性看到是最外層#app的例項。

因此我們把常見使用場景分為以下三類:

  • 父子元件通訊: props; $parent/$children; provide/inject; $ref; $attrs/$listeners
  • 兄弟元件通訊: EventBus; Vuex
  • 跨級通訊: EventBus; Vuex; provide/inject; $attrs/$listeners

本文的所有程式碼存放在github

更多前端資料請關注公眾號【前端壹讀】

如果覺得寫得還不錯,請關注我的掘金主頁。更多文章請訪問謝小飛的部落格

相關文章