天天看點

Vue 元件間的通信方式

前言

在Vue元件庫的開發過程中,元件之間的通信一直是一個重要的課題。雖然官方的Vuex狀态管理方案可以很好的解決元件之間的通信問題,但是元件庫内部對Vuex的使用往往比較繁重。本文列舉了幾種實用的不使用Vuex的元件間通信方法,供大家參考。

元件之間通信的場景

在進入我們今天的主題之前,我們先來總結下 Vue 元件之間通信的幾種場景,一般可以分為如下幾種場景:

  1. 父子元件之間的通信
  2. 兄弟元件之間的通信
  3. 隔代元件之間的通信

父子元件之間的通信

父子元件之間的通信應該是 Vue 元件通信中最簡單也最常見的一種了,概括為兩個部分:父元件通過 prop 向子元件傳遞資料,子元件通過自定義事件向父元件傳遞資料。

父元件通過 prop 向子元件傳遞資料

Vue 元件的資料流向都遵循單向資料流的原則,所有的 prop 都使得其父子 prop 之間形成了一個單向下行綁定:父級 prop 的更新會向下流動到子元件中,但是反過來則不行。這樣會防止從子元件意外變更父級元件的狀态,進而導緻你的應用的資料流向難以了解。

額外的,每次父級元件發生變更時,子元件中所有的 prop 都将會重新整理為最新的值。這意味着你不應該在一個子元件内部改變 prop。如果你這樣做了,Vue 會在浏覽器的控制台中發出警告。

父元件 ComponentA:

<template>
  <div>
    <component-b title="welcome"></component-b>
  </div>
</template>
<script>
import ComponentB from './ComponentB'

export default {
  name: 'ComponentA',
  components: {
    ComponentB
  }
}
</script>
複制代碼      

子元件 ComponentB:

<template>
  <div>
    <div>{{title}}</div>
  </div>
</template>
<script>
export default {
  name: 'ComponentB',
  props: {
    title: {
      type: String,
    }
  }
} 
</script>
複制代碼      

子元件通過自定義事件向父元件傳遞資料

在子元件中可以通過 ​

​$emit​

​ 向父元件發生一個事件,在父元件中通過 ​

​v-on​

​/​

​@​

​ 進行監聽。

子元件 ComponentA:

<template>
  <div>
    <component-b :title="title" @title-change="titleChange"></component-b>
  </div>
</template>
<script>
import ComponentB from './ComponentB'

export default {
  name: 'ComponentA',
  components: {
    ComponentB
  },
  data: {
    title: 'Click me'
  },
  methods: {
    titleChange(newTitle) {
      this.title = newTitle
    } 
  }
}
</script>
複制代碼      

子元件 ComponentB:

<template>
  <div>
    <div @click="handleClick">{{title}}</div>
  </div>
</template>
<script>
export default {
  name: 'ComponentB',
  props: {
    title: {
      type: String,
    }
  },
  methods: {
    handleClick() {
      this.$emit('title-change', 'New title !')
    }  
  }
} 
</script>
複制代碼      

這個例子非常簡單,在子元件 ComponentB 裡面通過 ​

​$emit​

​ 派發一個事件 ​

​title-change​

​,在父元件 ComponentA 通過 ​

​@title-change​

​ 綁定的 ​

​titleChange​

​ 事件進行監聽,ComponentB 向 ComponentA 傳遞的資料在 ​

​titleChange​

​ 函數的傳參中可以擷取到。

兄弟元件之間的通信

狀态提升

寫過 React 的同學應該對元件的 ​

​狀态提升​

​ 概念并不陌生,React 裡面将元件按照職責的不同劃分為兩類:​

​展示型元件(Presentational Component)​

​ 和 ​

​容器型元件(Container Component)​

​。

展示型元件不關心元件使用的資料是如何擷取的,以及元件資料應該如何修改,它隻需要知道有了這些資料後,元件 UI 是什麼樣子的即可。外部元件通過 props 傳遞給展示型元件所需的資料和修改這些資料的回調函數,展示型元件隻是它們的使用者。

容器型元件的職責是擷取資料以及這些資料的處理邏輯,并把資料和邏輯通過 props 提供給子元件使用。

是以,參考 React 元件中的 ​

​狀态提升​

​ 的概念,我們在兩個兄弟元件之上提供一個父元件,相當于容器元件,負責處理資料,兄弟元件通過 props 接收參數以及回調函數,相當于展示元件,來解決兄弟元件之間的通信問題。

ComponentA (兄弟元件 A):

<template>
  <div>
    <div>{{title}}</div>
    <div @click="changeTitle">click me</div>
  </div>
</template>
<script>
export default {
  name: 'ComponentA',
  props: {
    title: {
      type: String
    },
    changeTitle: Function
  }
}
</script>
複制代碼      

ComponentB (兄弟元件 B):

<template>
  <div>
    <div>{{title}}</div>
    <div @click="changeTitle">click me</div>
  </div>
</template>
<script>
export default {
  name: 'ComponentB',
  props: {
    title: {
      type: String
    },
    changeTitle: Function
  }
}
</script>
複制代碼      

ComponentC (容器元件 C):

<template>
  <div>
    <component-a :title="titleA" :change-title="titleAChange"></component-a>
    <component-b :title="titleB" :change-title="titleBChange"></component-b>
  </div>
</template>
<script>
import ComponentA from './ComponentA'
import ComponentB from './ComponentB'

export default {
  name: 'ComponentC',
  components: {
    ComponentA,
    ComponentB
  },
  data: {
    titleA: 'this is title A',
    titleB: 'this is title B'
  },
  methods: {
    titleAChange() {
      this.titleA = 'change title A'
    },
    titleBChange() {
      this.titleB = 'change title B'
    }
  }
}
</script>
複制代碼      

可以看到,上述這種 "狀态提升" 的方式是比較繁瑣的,特别是兄弟元件的通信還要借助于父元件,元件複雜之後處理起來是相當麻煩的。

隔代元件之間的通信

隔代元件之間的通信可以通過如下幾種方式實作:

  • ​$attrs​

    ​/​

    ​$listeners​

  • ​rovide​

    ​/​

    ​inject​

  • 基于​

    ​$parent​

    ​/​

    ​$children​

    ​ 實作的​

    ​dispatch​

    ​ 和​

    ​broadcast​

attrs/attrs/attrs/listeners

Vue 2.4.0 版本新增了 ​

​$attrs​

​ 和 ​

​$listeners​

​ 兩個方法。先看下官方對 ​

​$attrs​

​ 的介紹:

包含了父作用域中不作為 prop 被識别 (且擷取) 的 attribute 綁定 (​

​class​

​ 和 ​

​style​

​ 除外)。當一個元件沒有聲明任何 prop 時,這裡會包含所有父作用域的綁定 (​

​class​

​ 和 ​

​style​

​ 除外),并且可以通過 ​

​v-bind="$attrs"​

​ 傳入内部元件 —— 在建立進階别的元件時非常有用。

看個例子:

元件 A (ComponentA):

<template>
  <component-a name="Lin" age="24" sex="male"></component-a>
</template>
<script>
import ComponentB from '@/components/ComponentB.vue'

export default {
  name: 'App',
  components: {
    ComponentA
  }
}
</script>
複制代碼      

元件 B (ComponetB):

<template>
  <div>
    I am component B
    <component-c v-bind="$attrs"></component-c>
  </div>
</template>
<script>
import ComponentC from '@/components/ComponentC.vue'

export default {
  name: 'ComponentB',
  inheritAttrs: false,
  components: {
    ComponentC
  }
}
</script>
複制代碼      

元件 C (ComponetC):

<template>
  <div>
    I am component C
  </div>
</template>
<script>

export default {
  name: 'ComponentC',
  props: {
    name: {
      type: String
    }
  },
  mounted: function() {
    console.log('$attrs', this.$attrs)
  }
}
</script>
複制代碼      

這裡有三個元件,祖先元件 (ComponentA)、父元件 (ComponentB) 和子元件 (ComponentC)。這三個元件構成了一個典型的子孫元件之間的關系。

ComponetA 給 ComponetB 傳遞了三個屬性 name、age 和 sex,ComponentB 通過 ​

​v-bind="$attrs"​

​ 将這三個屬性再透傳給 ComponentC, 最後在 ComponentC 中列印 ​

​$attrs​

​ 的值為:

{age: '24', sex: 'male'}
複制代碼      

為什麼我們一開始傳遞了三個屬性,最後隻列印了兩個屬性 age 和 sex 呢?因為在 ComponentC 的 props 中聲明了 name 屬性,​

​$attrs​

​ 會自動排除掉在 props 中聲明的屬性,并将其他屬性以對象的形式輸出。

說白了就是一句話,​

​$attrs​

​ 可以擷取父元件中綁定的非 Props 屬性。

一般在使用的時候會同時和 ​

​inheritAttrs​

​ 屬性配合使用。

如果你不希望元件的根元素繼承 attribute,你可以在元件的選項中設定 ​​

​inheritAttrs: false​

​。

在 ComponentB 添加了 ​

​inheritAttrs=false​

​ 屬性後,ComponentB 的 dom 結構中可以看到是不會繼承父元件傳遞過來的屬性:

Vue 元件間的通信方式

如果不加上 ​

​inheritAttrs=false​

​ 屬性,就會自動繼承父元件傳遞過來的屬性:

Vue 元件間的通信方式

再看下 ​

​$listeners​

​ 的定義:

包含了父作用域中的 (不含 ​

​.native​

​ 修飾器的) ​

​v-on​

​ 事件監聽器。它可以通過 ​

​v-notallow="$listeners"​

​ 傳入内部元件 —— 在建立更高層次的元件時非常有用。

​$listeners​

​ 也能把父元件中對子元件的事件監聽全部拿到,這樣我們就能用一個 ​

​v-on​

​ 把這些來自于父元件的事件監聽傳遞到下一級元件。

繼續改造 ComponentB 元件:

<template>
  <div>
    I am component B
    <component-c v-bind="$attrs" v-on="$listeners"></component-c>
  </div>
</template>
<script>
import ComponentC from '@/components/ComponentC.vue'

export default {
  name: 'ComponentB',
  inheritAttrs: false,
  components: {
    ComponentC
  }
}
</script>
複制代碼      

這裡利用 ​

​$attrs​

​ 和 ​

​$listeners​

​ 方法,可以将祖先元件 (ComponentA) 中的屬性和事件透傳給孫元件 (ComponentC),這樣就可以實作隔代元件之間的通信。

provide/inject

​provide/inject​

​ 是 Vue 2.2.0 版本後新增的方法。

這對選項需要一起使用,以允許一個祖先元件向其所有子孫後代注入一個依賴,不論元件層次有多深,并在其上下遊關系成立的時間裡始終生效。如果你熟悉 React,這與 React 的上下文特性很相似。

先看下簡單的用法:

父級元件:

export default {
  provide: {
    name: 'Lin'
  }
}
複制代碼      

子元件:

export default {
  inject: ['name'],
  mounted () {
    console.log(this.name);  // Lin
  }
}
複制代碼      

上面的例子可以看到,父元件通過 ​

​privide​

​ 傳回的對象裡面的值,在子元件中通過 ​

​inject​

​ 注入之後可以直接通路到。

但是需要注意的是,​

​provide​

​ 和 ​

​inject​

​ 綁定并不是可響應的,按照官方的說法,這是刻意為之的。

也就是說父元件 provide 裡面的 name 屬性值變化了,子元件中 this.name 擷取到的值不變。

如果想讓 provide 和 inject 變成可響應的,有以下兩種方式:

  • provide 祖先元件的執行個體,然後在子孫元件中注入依賴,這樣就可以在子孫元件中直接修改祖先元件的執行個體的屬性,不過這種方法有個缺點就是這個執行個體上挂載很多沒有必要的東西比如 props,methods
  • 使用 Vue 2.6 提供的 Vue.observable 方法優化響應式 provide

看一下第一種場景:

祖先元件元件 (ComponentA):

export default {
  name: 'ComponentA',
  provide() {
    return {
      app: this
    }
  },
  data() {
    return {
       appInfo: {
         title: ''
       }
    }
  },
  methods: {
    fetchAppInfo() {
      this.appInfo = { title: 'Welcome to Vue world'}
    }
  }
}
複制代碼      

我們把整個 ComponentA.vue 的執行個體 ​

​this​

​ 對外提供,命名為 ​

​app​

​。接下來,任何元件隻要通過 ​

​inject​

​ 注入 app 的話,都可以直接通過 ​

​this.app.xxx​

​ 來通路 ComponentA.vue 的 ​

​data​

​、​

​computed​

​、​

​methods​

​ 等内容。

子元件 (ComponentB):

<template>
  <div>
    {{ title }}
    <button @click="fetchInfo">擷取App資訊</button>
  </div>
</template>
<script>
export default {
  name: 'ComponentB',
  inject: ['app'],
  computed: {
    title() {
      return this.app.appInfo.title
    }
  },
  methods: {
    fetchInfo() {
      this.app.fetchAppInfo()
    } 
  }
}
</script>
複制代碼      

這樣,任何子元件,隻要通過 ​

​inject​

​ 注入 ​

​app​

​ 後,就可以直接通路祖先元件中的資料了,同時也可以調用祖先元件提供的方法修改祖先元件的資料并反應到子元件上。

當點選子元件 (ComponentB) 的擷取 App 資訊按鈕,會調用 ​

​this.app.fetchAppInfo​

​ 方法,也就是通路祖先元件 (ComponentA) 執行個體上的 fetchAppInfo 方法,fetchAppInfo 會修改 fetchAppInfo 的值。同時子元件 (ComponentB) 中會監聽 this.app.appInfo 的變化,并将變化後的 title 值顯示在元件上。

再看一下第二種場景,通過 ​

​Vue.observable​

​ 方法來實作 ​

​provide​

​ 和 ​

​inject​

​ 綁定并可響應。

基于上面的示例,改造祖先元件 (ComponentA):

import Vue from 'vue'

const state = Vue.observable({ title: '' });

export default {
  name: 'ComponentA',
  provide() {
    return {
      state
    }
  }
}
複制代碼      

使用 ​

​Vue.observable​

​ 定義一個可響應的對象 state,并在 provide 中傳回這個對象。

改造子元件 (ComponentB):

<template>
  <div>
    {{ title }}
    <button @click="fetchInfo">擷取App資訊</button>
  </div>
</template>
<script>
export default {
  name: 'ComponentInject',
  inject: ['state'],
  computed: {
    title() {
      return this.state.title
    }
  },
  methods: {
    fetchInfo() {
      this.state.title = 'Welcome to Vue world22'
    } 
  }
}
</script>
複制代碼      

與之前的例子不同的是,這裡我們直接修改了 this.state.title 的值,因為 state 被定義成了一個可響應的資料,是以 state.title 的值被修改後,視圖上的 title 也會立即響應并更新,從這裡看,其實很像 ​

​Vuex​

​ 的處理方式。

以上兩種方式對比可以發現,第二種借助于 ​

​Vue.observable​

​ 方法實作 ​

​provide​

​ 和 ​

​inject​

​ 的可響應更加簡單高效,推薦大家使用這種方式。

基于 ​

​$parent​

​/​

​$children​

​ 實作的 ​

​dispatch​

​ 和 ​

​broadcast​

先了解下 dispatch 和 broadcast 兩個概念。

  • dispatch: 派發,指的是從一個元件内部向上傳遞一個事件,并在元件内部通過

    $on

     進行監聽
  • broadcast: 廣播,指的是從一個元件内部向下傳遞一個事件,并在元件内部通過

    $on

     進行監聽

在實作 dispatch 和 broadcast 方法之前,先來看一下具體的使用方法。有 ComponentA.vue 和 ComponentB.vue 兩個元件,其中 ComponentB 是 ComponentA 的子元件,中間可能跨多級,在 ComponentA 中向 ComponentB 通信:

元件 ComponentA:

<template>
  <button @click="handleClick">派發事件</button>
</template>
<script>
import Emitter from '../mixins/emitter.js';
export default {
  name: 'ComponentA',
  mixins: [Emitter],
  methods: {
    handleClick () {
      this.dispatch('ComponentB', 'on-message', 'Hello Vue.js')
    }
  }
}
</script>
複制代碼      

元件 ComponentB:

export default {
  name: 'ComponentB',
  created () {
    this.$on('on-message', this.showMessage)
  },
  methods: {
    showMessage (text) {
      console.log(text)
    }
  }
}
複制代碼      

dispatch 的邏輯寫在 ​

​emitter.js​

​ 中,使用的時候通過 ​

​mixins​

​ 混入到元件中,這樣可以很好的将事件通信邏輯群組件進行解耦。

dispatch 的方法有三個傳參,分别是:需要接受事件的元件的名字 (全局唯一,用來精确查找元件)、事件名和事件傳遞的參數。

dispatch 的實作思路非常簡單,通過 ​

​$parent​

​ 擷取目前父元件對象,如果元件的 name 和接受事件的 name 一緻 (dispatch 方法的第一個參數),在父元件上調用 ​

​$emit​

​ 發射一個事件,這樣就會觸發目标元件上 ​

​$on​

​ 定義的回調函數,如果目前元件的 name 和接受事件的 name 不一緻,就遞歸地向上調用此邏輯。

export default {
  methods: {
    dispatch(componentName, eventName, params) {
      let parent = this.$parent || this.$root;
      let name = parent.$options.name;
      while (parent && (!name || name !== componentName)) {
        parent = parent.$parent;
        if (parent) {
          name = parent.$options.name
        }
      }
      if (parent) {
        parent.$emit.apply(parent, [eventName].concat(params));
      }
    }
  }
}
複制代碼      
export default {
  methods: {
    broadcast(componentName, eventName, params) {
      this.$children.forEach(child {
        const name = child.$options.name
        if (name === componentName) {
          child.$emit.apply(child, [eventName].concat(params))
        } else {
          broadcast.apply(child, [componentName, eventName].concat([params]))
        }
      })
    }
  }
}