【2019 前端進階之路】Vue 元件間通訊方式完整版

江三瘋發表於2019-03-25

前言

Vue.js 在現今使用有多廣泛不用多說,而 Vue 的一大特點就是元件化。本期要講的,便是 Vue 元件間通訊方式的總結,這也幾乎是近年 Vue 面試中的必考題。注:文中示例都基於 Vue 腳手架講解,會用到一些 Element UI 示例。

【前端進階之路】會作為一個新系列連載,後續會更多優質前端內容,感興趣的同學不妨關注一下。 文章最後有 交流群公眾號,可以一起學習交流,感謝?。

  • 下期預告:深入 Vue 響應式原理,手寫一個 mvvm

元件

元件是可以複用的 Vue 例項。 — Vue 官方文件

在進入主題之前,還是決定先簡單聊聊元件。在 Vue 中,根據註冊方式的不同,可以分為:

  • 區域性元件 (區域性註冊)
  • 全域性元件 (全域性註冊)

顧名思義,全域性註冊的元件,可以用在 Vue 例項的任意模板中。但是帶來的隱患是,在 webpack 模組化構建時,即便你沒有在專案中使用這個元件,依然會打包到最終的專案程式碼中。而區域性元件,則需要在使用到的例項中註冊該元件。

// 全域性註冊
// install.js
import Icon from './Icon.vue';
const install = {
    install:function(Vue){
        Vue.component('VIcon', Icon);
    }
};
export default install;
// main.js
import install from './install.js'; // 引入全域性外掛
Vue.use(install); // 註冊

// 區域性註冊
import VIcon from './Icon.vue';
export default{
    components: {
        VIcon
    }
}

// 使用
<v-icon> </v-icon>
複製程式碼

根據應用場景的不同,又可以分為:

  • 頁面元件:我們使用 Vue 時,每個路由代表的頁面,都可以稱之為元件。
  • 基礎元件:就像上面栗子中的 Icon 元件,就是一個典型的基礎元件。基本上不摻雜業務邏輯,在專案中可能被大量使用,易於移植。類似的基礎元件還有 Button、Input 等,常見於各類 UI 元件庫。
  • 業務元件:業務元件和專案具體的業務邏輯有大量耦合,一般抽離於當前專案。

以上就是元件的簡單介紹,那我們到底為什麼要推崇元件化?元件化有什麼好處?複用?我個人認為元件化最大的好處,便是解耦,易於專案管理。所以在大型專案管理中,元件化是非常有必要的。當然,這並不是今天學習的重點,以後有機會再聊。

正因為在 Vue 中處處都是元件,而我們也偏向於元件化、模組化。那我們在一堆元件中,便需要解決一個問題 — 元件間通訊。下面,我們就進入今天的主題,Vue 的元件間通訊。

元件間通訊

元件間通訊是我們在 Vue 專案中不可避免的問題,深刻了解了 Vue 元件間通訊的幾種方式,才能讓我們在處理各種互動問題時遊刃有餘。

Props

Vue 中,最基本的通訊方式就是 Props,它是父子元件通訊中父元件傳值給子元件的一種方式。它允許以陣列形式接收,但是更推薦你開啟型別檢查的形式。更詳細的型別檢查前往 vue 文件

// communication.vue
<communication-sub v-bind="dataProps"></communication-sub>
// v-bind="dataProps" 等同於 :title="title",適用於多個引數一起傳遞
···
data() {
    return {
        dataProps: {
            title: '我是父元件的值',
        }
    }
}
// communication-sub.vue
<div class="communication-sub">
    {{title}}
</div>
···
props: ['title']
// 更推薦開啟型別檢查
props: {
    title: {
        type: String,
        required: true,
        default: '' // 允許指定預設值,引用型別需要函式返回
    }
}
···
複製程式碼

我們都知道,Props 是單向資料流,這是 Vue 為了避免子元件意外改變父元件的狀態,從而導致資料流向難以理解而做出的限制。所以 Vue 推薦需要改動的時候,通過改變父元件的值從而觸發 Props 的響應。或者,我們可以在接收非引用型別的值時,使用子元件自身的 data 做一次接收。

props: ['title'],
data: function () {
  return {
    text: this.title
  };
}
複製程式碼

為什麼是非引用型別呢,因為在 JavaScript 中,引用型別的賦值,實際是記憶體地址的傳遞。所以上面栗子中的簡單賦值,顯然會指向同一個記憶體地址,所以如果是陣列或是物件,你可能需要一次深拷貝。

let obj = JSON.parse(JSON.stringify(obj));
複製程式碼

上面這個操作有一些缺陷,不能序列化函式、undefined、迴圈引用等,詳見傳送門,但是也能應付一些日常情況了。

事實上,在 Props 是引用型別時,單獨修改物件、陣列的某個屬性或下標,Vue 並不會丟擲錯誤。當然,前提是你要非常清楚自己在做什麼,並寫好註釋,防止你的小夥伴們疑惑。

有的同學可能知道,在元件上繫結的屬性,如果沒有在元件內部用 Props 宣告,會預設繫結到元件的根元素上去。還是之前的栗子:

<communication-sub v-bind="dataProps" class="one" type="div"></communication-sub>
複製程式碼

結果如下:

【2019 前端進階之路】Vue 元件間通訊方式完整版

這是 Vue 預設處理的,而且,除了 class 和 style 採用合併策略,其它特性(如上慄 type)會替換掉原來根元素上的屬性值。當然,我們也可以顯示的在元件內部關閉掉這個特性:

...
inheritAttrs: false,
props: ['title']
複製程式碼

利用 inheritAttrs,我們還可以方便的把元件繫結的其它特性,轉移到我們指定的元素上。這就需要用到下一個我們要講的 $attrs 了。

attrs、listeners

我們在使用元件庫的時候經常會這麼寫:

<el-input v-model="input" placeholder="請輸入內容"></el-input>
複製程式碼

實際渲染後:

【2019 前端進階之路】Vue 元件間通訊方式完整版

可以看到我們指定的的 placeholder 是渲染在 input 上的,但是 input 並不是根元素。難道都用 Props 宣告後,再賦值給 input?這種情況就可以用到 $attrs 了,改造一下我們之前那個栗子。

// communication.vue
<template>
    <div class="communication">
        <communication-sub v-bind="dataProps" class="input" type="text" placeholder="請輸入內容">
        </communication-sub>
    </div>
</template>
<script>
import communicationSub from './communication-sub.vue';
export default{
    name: 'communication',
    data() {
        return {
            dataProps: {
                title: '我是 communication 的值',
            }
        }
    },
    components: {
        communicationSub
    }
}
</script>

// communication-sub.vue
···
<div class="communication-sub">
    <input v-bind="$attrs" v-model="title"></input>
</div>
···
 export default {
    inheritAttrs: false
}
複製程式碼

【2019 前端進階之路】Vue 元件間通訊方式完整版

可以看到,type 已經轉移到了子元素 input 標籤上,但是 class 沒有。這是因為 inheritAttrs: false 選項不會影響 style 和 class 的繫結。可以看出 $attrs 則是將沒有被元件內部 Props 宣告的傳值(也叫非 Props 特性)收集起來的一個物件,再通過 v-bind 將其繫結在指定元素上。這也是 Element 等元件庫採用的策略。

這裡需要注意一點,通過 $attrs 指定給元素的屬性,不會與該元素原有屬性發生合併或替換,而是以原有屬性為準。舉個例子,假如我將上述 input 的 type 預設設定為 password。

<input v-bind="$attrs" v-model="title" type="password"></input>
複製程式碼

則不會採用 $attrs 中的 type: 'text',將以 password 為準,所以如果需要預設值的屬性,建議不要用這種方式。

$listeners$attrs 類似,可以看做是一個包含了元件上所有事件監聽器(包括自定義事件、不包括.native修飾的事件)的物件。它也支援上述的寫法,適用於將事件安放於元件內指定元素上。

// communication.vue
<communication-sub v-bind="dataProps"
class="input"
type="text"
placeholder="請輸入內容"
@focus="onFocus" >
</communication-sub>
···
methods: {
    onFocus() {
        console.log('onFocus');
    }
}
// communication-sub.vue
<input v-bind="$attrs" v-model="title" v-on="$listeners"></input>
複製程式碼

給之前的栗子繫結一個聚焦事件,在子元件中通過 $listeners 繫結給 input,則會在 input 聚焦時觸發。

那麼除了用在這種給元件內指定元素繫結特性和事件的情況,還有哪些場景可以用到呢?官方說明:在建立更高層次的元件時非常有用。比如在祖孫元件中傳遞資料,在孫子元件中觸發事件後要在祖輩中做相應更新。我們繼續之前的栗子:在孫輩元件觸發點選事件,然後在祖輩中修改相應的 data。

【2019 前端進階之路】Vue 元件間通訊方式完整版

// communication.vue
<communication-sub v-bind="dataProps"
                @click="onCommunicationClick">
</communication-sub>
···
methods: {
    onCommunicationClick() {
        this.dataProps.title = '我是點選之後的值';
    }
};

// communication-sub.vue
<communication-min-sub v-on="$listeners"></communication-min-sub> // 子元件中將事件透傳到孫輩

// communication-min-sub.vue
<template>
    <div class="communication-min-sub">
        <p>我是 communication-min-sub</p>
        <button v-on="$listeners">click</button>
    </div>
</template>
<script>
export default{
    name: 'communication-min-sub',
    inheritAttrs: false
}
</script>
複製程式碼

這樣就能很方便的在多級元件的子級元件中,快速訪問到父元件的資料和方法。正如在剛才的例子中,button 點選時,是直接呼叫的 communication.vue 中定義的方法。

依賴注入 provide、inject

上面的方法,在大多數多級元件巢狀的場景很有用,但有時我們遇到的並不一定是有父子關係的元件。比如基礎元件中的 Select 下拉選擇器。

<el-select v-model="value" placeholder="請選擇">
    <el-option
      v-for="item in options"
      :key="item.value"
      :label="item.label"
      :value="item.value">
    </el-option>
</el-select>
複製程式碼

相信大家都使用過上慄或者類似於上慄的基礎元件,它們藉助 vue 插槽 實現。所以這個時候,el-select 和 el-option 之間的資料通訊,我們之前的 $attrs$listeners就沒有用武之地了。有同學可能不太理解上面的程式碼為什麼要通訊,我簡單介紹一下 Element 的處理方式:

【2019 前端進階之路】Vue 元件間通訊方式完整版

我們可以簡單的認為(Element 原始碼比這個要稍複雜,為了方便理解,簡化一下,如有需要,可直接前往原始碼閱讀),在 el-select 中有一個 input 元素,el-option 中是一列渲染好的 li。根據需求,我們在選中某個 li 的時候,要通知 input 展示相應的資料。而且我們在實際使用的時候,一般還伴隨 el-form、el-form-item等元件,所以迫切需要一種方式:

可以允許一個祖先元件向其所有子孫後代注入一個依賴,不論元件層次有多深,並在起上下游關係成立的時間裡始終生效。--- Vue 文件

有同學可能會想到,這種多級的可以用 Vuex、EventBus等方式,當然可以。只不過我們現在的前提是基礎元件,一般第三方元件庫是不會增加一些額外的依賴的。事實上 Vue 本身並不推薦直接在業務中使用 provide、inject,一般在元件、外掛庫用到的比較多。

但是在專案比較小、業務邏輯比較簡單的時候,我們完全不必特意引入 Vuex。只要使用得當,provide、inject 確實不失為一種好辦法。說了這麼多,我們來看一下具體用法,我們將之前的栗子,改為用 provide、inject 來實現。

// communication.vue
<communication-sub v-bind="dataProps" >
</communication-sub>
// @click="onCommunicationClick" 移除之前繫結的時間
···
// 在 provide 新增子代需要接收的方法 onCommunicationClick,
// 也可以直接指定為 this,子代便能訪問父代所有的資料和方法。
provide: function () {
    return {
        onCommunicationClick: this.onCommunicationClick
    }
},
methods: {
    onCommunicationClick() {
        this.dataProps.title = '我是點選之後的值';
    }
};

// communication-sub.vue
<communication-min-sub></communication-min-sub>
// 移除之前的 v-on="$listeners",因為在這個元件中不需要用到父元件的方法,所以不用做其它處理

// communication-min-sub.vue
<template>
    <div class="communication-min-sub">
        ···
        <button @click="onCommunicationClick">click</button>
        // 移除 v-on="$listeners",然後繫結 inject 接收到的方法
    </div>
</template>
<script>
export default{
    name: 'communication-min-sub',
    inject: ['onCommunicationClick'] // inject 接收父元件的方法
}
</script>
複製程式碼

這種寫法和之前的 $listeners 得到的效果是一樣的,就不再放圖了。大家可以自己嘗試一下,也可以前往原始碼 webrtc-stream

思考:有些同學可能會想到,如果我在根例項,app.vue 中如此設定:

<script>
  export default {
    provide () {
      return {
        app: this // 設定app為this
      }
    },
    data () {
      return {
        userInfo: null,
        otherState: null
      }
    }
  }
</script>
複製程式碼

那這樣把所有的狀態管理都放在 app.data 中,所有的子代中不就可以共享了嗎?是不是就不需要 Vuex 了呢?實際上,Vue 本身就提供了一個方法來訪問根例項 $root,所以即使沒有 provide 也是可以做到的。那為什麼不這麼用呢?還是前面提到的原因,不利於追蹤維護,也失去了所謂狀態管理的意義。不過,如果你的專案足夠小的話,依然可以這麼使用。

ref、parent、children

我們前面一直說的都是子元件如何觸達父元件,那麼父元件能不能訪問到子元件呢?當然是可以的。

  • ref

簡單來說就是獲取元素的 Dom 物件和子元件例項。如果在普通的 DOM 元素上使用,引用指向的就是 DOM 元素;如果用在子元件上,引用就指向元件例項。獲取 Dom 元素就是為了進行一些 Dom 操作,需要注意的一點就是,要在 Dom 載入完成後使用,否則可能獲取不到。比如我要將之前 input 的字型顏色改成紅色:

<input type="text" v-model="dataProps.title" ref="input">
...
mounted() {
    this.$nextTick(_ => { // 確保 Dom 更新完成
        this.$refs['input'].style.color = 'red';
    });
}
// 這裡只是舉一個栗子,實際專案中的需求,最好通過 class 的方式,儘量減少 Dom 操作。
複製程式碼

那什麼情況下需要獲取元件例項呢?比如父元素的某個狀態改變,需要子元件進行 http 請求更新資料。通常情況下,我們會選擇通過 Props 將狀態傳遞給子元件,然後子元件進行 Watch 監測,如果有變更,則進行相應操作。這個時候,我們便可以選擇使用 ref。

<child ref="child"></child>
···
<script>
  export default {
    methods () {
      onStateChange() { // 變更狀態後直接呼叫子元件方法進行更新
          this.$refs['child'].updateData();
      }
    }
  }
</script>
複製程式碼
  • $children$parent

無獨有偶,$children 同樣可以完成上面的任務。$children$parent,顧名思義,一個會找到當前元件的子元件,一個會找到當前元件的父元件。如果有多個子元件,需要依賴元件例項的 name 屬性。改寫一下上面的方法:

<script>
  export default {
    methods () {
      onStateChange() { // 子元件返回的是一個陣列,多個子元件用 $options.name 區分。
          this.$children[0].updateData();
      }
    }
  }
</script>
複製程式碼

$parent$children 用法一樣,不過 $parent 返回的父元件例項,不是陣列,因為父元件肯定只有一個。ref、parent、children 它們幾個的一個缺點就是無法處理跨級元件和兄弟元件,後續我們會介紹 dispatch 和 broadcast 方法,實現跨級通訊。

emit、on、off

$emit,想必大家都非常熟悉,我們通常用作父子元件間通訊,我們也叫它自定義事件。$emit$on都是元件自身的方法,$on 可以監聽 $emit 派發的事件,$off 則用來取消事件監聽。這也是我們下一個要講的通訊方式 EventBus 所依賴的原理。

// 父元件
<template>
    <button-component @clickButton="clickButton"></button-component>
    // 在父元件利用 v-on 監聽
</template>
<script>
export default {
    methods: {
      clickButton () { ··· }
    }
}
</script>

// 子元件
<template>
    <button @click="handleClick"></button>
</template>
<script>
export default {
    methods: {
      handleClick () { // 觸發 $emit
        this.$emit('clickButton');
      }
    },
    mounted() {
        this.$on('clickButton', (...arr) => { // 也可以自己監聽 $emit,雖然沒什麼用···
            console.log(...arr);
        })
    }
}
</script>
複製程式碼

EventBus

$emit的痛點依然是不支援跨級和兄弟元件,Vue 官方推薦我們使用一個新的 Vue 例項來做一個全域性的事件通訊(或者叫中央事件匯流排···),也就是我們要講的 EventBus。瞭解過的同學都知道,正常的 bus,我們一般會掛載到 Vue 的 prototype 上,方便全域性呼叫。

// main.js
Vue.prototype.$bus = new Vue();
複製程式碼

依舊改寫之前的栗子:

<!--communication.vue-->
<communication-sub v-bind="dataProps" >
</communication-sub>
···
beforeDestroy() { <!-- 例項銷燬時,需要解除安裝監聽事件 -->
    this.$bus.$off('busClick');
},
created() {  <!-- 監聽子元件觸發的 Bus 事件-->
    this.$bus.$on('busClick', (data) => {
        this.dataProps.title = data;
    });
}

<!--communication-min-sub.vue-->
<template>
    <div class="communication-min-sub">
        <button @click="busClick">click bus</button>
        <!--子元件觸發點選事件-->
    </div>
</template>
<script>
export default{
    methods: {
        busClick() {
            this.$bus.$emit('busClick', 'bus 觸發了');
        }
    }
}
</script>
複製程式碼

這是一個基礎的 EventBus 的實現。現在我們設想一下,類似於 userInfo 這樣的資訊,在很多頁面都需要用到,那我們需要在許多頁面都做 $on 監聽的操作。那能否將這些操作整合到一起呢?我們一起來看:

 // 新建一個 eventBus.js
import Vue from 'vue';
const bus = new Vue({
    data () {
        return {
            userInfo: {}
        }
    },
    created () {
        this.$on('getUserInfo', val => {
            this.userInfo = val;
        })
    }
});
export default bus;
// main.js
import bus from './eventBus';
Vue.prototype.$bus = bus;
// app.vue
methods: {
    getUserInfo() {
        ajax.post(***).then(data => {
            this.$bus.$emit('getUserInfo', data); // 通知 EventBus 更新 userInfo
        })
    }
}
複製程式碼

這樣在其他頁面用到 userInfo 的時候,只需要 this.$bus.userInfo 就可以了。注意剛剛其實沒有用 off 解除安裝掉監聽,因為其實 userInfo 這種全域性資訊,並沒有一個準確的說要銷燬的時機,瀏覽器關閉的時候,也用不著我們處理了。但是,如果只是某個頁面元件用到的,建議還是用最開始的方法,在頁面銷燬的時候解除安裝掉。

不過反過來講,既然用到了 EventBus,說明狀態管理並不複雜,否則還是建議用 Vuex 來做。最後再給大家推薦一篇文章 Vue中eventbus很頭疼?我來幫你,作者處理 EventBus 的思路很巧妙,大家不妨仔細看看。

派發與廣播:dispatch 與 broadcast

此部分參考自 Element 原始碼

在 Vue 1.x 的實現中,有 $dispatch$broadcast 方法,但是在 2.x 被廢棄了。$dispatch 的主要作用是向上級元件派發事件,$broadcast 則是向下級廣播。它們的優點是都支援跨級,再看一下官方廢棄這兩個方法的理由:

因為基於元件樹結構的事件流方式實在是讓人難以理解,並且在元件結構擴充套件的過程中會變得越來越脆弱。並且 $dispatch$broadcast 也沒有解決兄弟元件間的通訊問題。

可以看到,主要原因是在元件結構擴充套件後不易理解,以及沒有解決兄弟元件通訊的問題。但是對於元件庫來說,這依舊是十分有用的,所以它們大多自己實現了這兩個方法。對我們來講,也許在專案中用不到,但學習這種解決問題的思路,是十分必要的。

派發和廣播,依賴於元件的 name(最怕此處有人說:如果不寫 name,這方法不就沒用了?2333···),以此來逐級查詢對應的元件例項。Element 的實現中,給所有的元件都加了一個 componentName 屬性,所以它是根據 componentName 來查詢的。我們在實現的時候還是直接用 name。

我們先來看一下 $dispatch 的簡單用法,再來分析思路。

<!--communication-min-sub.vue-->
<template>
  <button @click="handleDispatch">dispatch</button>
</template>
<script>
import Emitter from '../../utils/emitter';
export default {
  mixins: [Emitter], // 混入,方便直接呼叫
  methods: {
    handleDispatch () {
      this.dispatch('communication', 'onMessage', '觸發了dispatch');
    }
  }
}
</script>
複製程式碼
<!--communication.vue-->
<script>
  export default {
    beforeDestroy() { // 銷燬
        this.$off('onMessage');
    },
    mounted () {
        this.$on('onMessage', (data) => { // 監聽
            this.dataProps.title = data;
        })
    }
  }
</script>
複製程式碼

現在明確一下目標,dispatch 方法接收三個引數,元件 name、事件名稱、基礎資料(可不傳)。要做到向上跨級派發事件,需要向上找到指定 name 的元件例項,利用我們前文提到的 $emit方法做派送,所以在指定元件就可以用 $on 來監聽了。所以 dispatch 本質上就是向上查詢到指定元件並觸發其自身的 $emit,以此來做響應,broadcast 則相反。那麼如何做到跨級查詢呢?

function broadcast(componentName, eventName, params) {
  this.$children.forEach(child => { // 遍歷所有的 $children
    var name = child.$options.name; // 拿到例項的name,Element 此處用的 componentName
    if (name === componentName) { // 如果是想要的那個,進行廣播
      child.$emit.apply(child, [eventName].concat(params));
    } else { // 不是則遞迴查詢 直到 $children 為 []
      broadcast.apply(child, [componentName, eventName].concat([params]));
    }
  });
}
export default {
  methods: {
    dispatch(componentName, eventName, params) {
      var parent = this.$parent || this.$root;
      var name = parent.$options.name;
      while (parent && (!name || name !== componentName)) {
      // 存在 parent 且 (不存在 name 或 name 和 指定引數不一樣) 則繼續查詢
        parent = parent.$parent; // 不存在繼續取上級
        if (parent) {
          name = parent.$options.name; // 存在上級 再次賦值並再次迴圈,進行判斷
        }
      }
      if (parent) { // 找到以後 如果有 進行事件派發
        parent.$emit.apply(parent, [eventName].concat(params));
      }
    },
    broadcast(componentName, eventName, params) {
      broadcast.call(this, componentName, eventName, params);
    }
  }
};
複製程式碼

以上是詳細的 emitter.js,可以看見,這和我們之前講到的 $parent$children$emit$on都密切相關。這也是為什麼把它放到後面講的原因。之前說過,派發和廣播並沒有解決兄弟元件通訊的問題,所以這裡大家也可以擴充思考一下,如何支援兄弟元件間通訊。依然是依賴於$parent$children,可以找到任意指定元件。

Vuex

Vuex 是一個專為 Vue.js 應用程式開發的狀態管理模式。它採用集中式儲存管理應用的所有元件的狀態,並以相應的規則保證狀態以一種可預測的方式發生變化。--- 官方文件

Vuex 相信大家都比較熟悉了,我不打算在這裡把 API 再演示一遍。因為我覺得,官方文件 已經非常詳細了。Vuex 的核心是單向資料流,並以相應規則保證所有的狀態管理都可追蹤、可預測。

我們需要知道什麼時候該用 Vuex,如果你的專案比較小,狀態管理比較簡單,完全沒有必要使用 Vuex,你可以考慮我們前文提到的幾種方式。

總結

本期文章內容到這裡就講完了,我們來總結回顧一下:

  • 子元件觸達父元件的方式:Props、$parent$attrs$listeners、provide 和 inject、$dispatch
  • 父元件觸達子元件的方式:$emit$on$children$refbroadcast
  • 全域性通訊:EventBus、Vuex

本來想按照是否支援跨級來分,但是這裡的界定比較模糊:如果逐級傳遞,有些也能做到跨級,但這並不是我們想要的。所以我們只要自己清楚在什麼情況下該怎麼用就好了。

交流群

qq前端交流群:960807765,歡迎各種技術交流,期待你的加入

後記

如果你看到了這裡,且本文對你有一點幫助的話,希望你可以動動小手支援一下作者,感謝?。文中如有不對之處,也歡迎大家指出,共勉。

更多文章:

歡迎關注公眾號 前端發動機,第一時間獲得作者文章推送,還有各類前端優質文章,希望在未來的前端路上,與你一同成長。

【2019 前端進階之路】Vue 元件間通訊方式完整版

相關文章