[IT 鐵人賽] Component 的溝通方式 :props Day 14

這裡拿 :props 來回鍋複習一下,也順帶的,我們會提及一些關於事件溝通的方式。或者說反過來,講事件溝通,然後順帶提一下 :props 也可以。元件溝通其實一直都是各種框架裡面比較麻煩的事情,昨天提到的 Vuex 其實算是偷吃步,元件之間在沒有狀態管理機制的情況下,多數還是仰賴事件傳播為主。

只是,當元件趨於複雜,事件傳播就相對惱人。


props

之前在提及元件的時候有提到這個屬性,如果忘記的人可以再回去看一下:

Component 基本入門 Day 2

當我們需要將資料傳遞給元件的時候,我們可以利用 props 來達成這件事情,當然請記得,他是屬於單向綁定的,如果需要雙向資料溝通,請加上 .sync 修飾子來達成。這個屬性在 Vue Router 也有一個類似的設定,不過跟元件溝通比較沒有關係,算是路由與元件之間的事情。

再者,使用 props 來溝通,對於比較複雜的元件結構來說,就會變得比較難以維護:

import BComponent from '@/components/BComponent.vue'

export default {
  name: 'AComponent',
  components: {
    BComponent
  }
}
import CComponent from '@/components/CComponent.vue'

export default {
  name: 'BComponent',
  props: {
    id: {
      type: Number,
      required: true
    }
  },
  components: {
    CComponent
  }
}
export default {
  name: 'CComponent',
  props: {
    id: {
      type: Number,
      required: true
    }
  }
}

我相信應該不會有人這麼做(吧)?對於 props 的定義上,他主要的目的是為了傳播一些,對於該元件相對有利用價值,爾或是必須要傳入的數值。就如同你撰寫了一個函式,這個函式可以接受某些參數,或者是必須要傳入某些參數,大概是一樣的道理。所以,利用 props 來向眾多子元件傳遞訊息,就變得過於麻煩且相對不好維護。

但這個方式,在多數第三方套件、插件或是客製化元件中,應用上還是相當頻繁。其實最主要的核心概念是:

  • 多數用於 一次性 的設定值,沒有一定要雙向綁定的需求。
  • 這些元件都已經是末端元件,換句話說,不太會有其子元件存在。
  • 會搭配 Slot 來做到元件變化,而不是再寫一個子元件。
  • 當你必須要使用其他子元件時,請改用事件傳遞所需要的資料。
  • props 所承接的資料,絕大多數不會過於複雜。

上述最後一點其實見仁見智,因為我自己寫過分頁元件,然後我把分頁資料整包餵給了他(這是不好的設計,小朋友不要學)。

<section>
  <ul>
    <li v-for="(page, index) in pager" :key="index">
      <a :href="'?page=' + pager.page">{{ pager.page }}</a>
    </li>
  </ul>
</section>
export default {
  name: 'Pagination',
  props: {
    pager: {
      type: Object,
      required: true
    }
  }
}

雖然看起來好像很簡單,但是其實這樣的 props 設計相當不良。私心建議將你的物件分開來寫,雖然在 <template> 裡面可能會變得較為複雜,但對於後續維護上會比較友善。

export default {
  name: 'Pagination',
  props: {
    totlaItems: {
      type: Number,
      required: true,
      default: 0
    },
    limit: {
      type: Number,
      required: true,
      default: 10
    },
    first: {
      type: Number,
      required: true,
      default: 1
    },
    current: {
      type: Number,
      required: true,
      default: 1
    }
  },
  computed: {
    totalPages () {
      if (this.totlaItems <= 0) {
        return 0
      }
      return Math.ceil(this.totalItems / this.limit)
    },
    last () {
      return this.totalPages
    }
  }
}
<section>
  <ul>
    <li v-for="page in totalPages" :key="page">
      <a :href="'?page=' + page">{{ page }}</a>
    </li>
  </ul>
</section>

上述只是簡單的例子,如果當你的頁碼超過 100 頁的時候,你可以再優化上述的呈現方式。不然,你會拿到 100 個 <li> 好像也是哪裡怪怪的。


事件溝通

在 Vue 的元件當中,對於事件的處理上有四種方式:

  1. $on 用於綁定一個或多個事件(多個事件接受陣列方式傳入)。
  2. $emit 用於觸發一個事件。
  3. $once 用於觸發一個事件,但僅觸發一次。
  4. $off 用於解除綁定一個或多個事件(多個事件接受陣列方式傳入)。

上個段落我們提到了,關於子元件之間的溝通,我們可以透過事件傳播的方式來做。不過,基於生命週期的關係,所以你必須 特別留意 事件在 綁定觸發 以及 解除 的時機點。

對於 $off 解除事件的操作,他與 JavaScript 原生的 removeEventListener 很類似,操作的區別在於:

// 不傳入任何參數,會解除全部的綁定事件。
this.$off()

// 傳入事件名稱,會解除所有跟這個事件名稱相關的綁定事件。
this.$off('hitEvent')

// 傳入事件名稱與回呼函式,會解除該事件名稱與此回呼函數的特定綁定事件。
this.$off('hitEvent', this.hitEventCallback)

通常,我們在決定一個事件是否需要被綁定,有一個大方向的分野:

  • 我一開始就要監聽某個事件。
  • 我需要達成某些 條件 才需要監聽某個事件。

如果依照大方向來說,我們舉個例子:

export default {
  name: 'EventComponent',
  created () {
    // 我一開始就要監聽一個事件,所以我在元件建立時就開始監聽。
    this.$on('EventComponentCreated', this.eventComponentCreated)
  },
  mounted () {
    // 我在元件被加入瀏覽器的 DOM 結構樹的時候,才要監聽事件。
    this.$on('EventComponentMounted', this.eventComponentMounted)
  },
  methods: {
    eventComponentCreated () {
      // 在這裡做一些事件的操作。
    },
    eventComponentMounted () {
      // 在這裡做一些事件的操作。
    }
  },
  beforeDestroy () {
    // 元件被銷毀之前,把監聽事件解除。
    this.$off('EventComponentCreated', this.eventComponentCreated)
    this.$off('EventComponentMounted', this.eventComponentMounted)
  }
}

請留意 事件解除 的地方,如果你的元件是可以被重複使用的,那麼你必須要在元件被銷毀之前,先將事件解除,否則,當這個元件再次被使用時,你的事件綁定會 重複 ,這樣會造成同一個事件呼叫,會做出 2 次以上的事件操作。

另外,還記得我們提過的 <keep-alive> 嗎?

薛丁格的生命週期 Day 12

當你的元件被包含在 <keep-alive> 當中,他就沒有所謂的 beforeDestroy() 這件事情,而且,他的 mounted 會不斷的被呼叫,所以,當你有使用 <keep-alive> 的時候,請注意你的事件綁定與解除。


上面的操作示意圖,你覺得我的 $emit 是不是有寫錯?是的,上述四種關於事件操作的方法,在元件當中是僅屬於該 元件內部 可以使用的。所以,當你需要呼叫你的子元件,或甚至呼叫你的父元件,那麼你有以下幾種操作方式:

  • 使用 $refs 來指定子元件呼叫其事件。
  • 使用 $children 來指定子元件呼叫其事件。
  • 使用 $parent 來呼叫父元件所綁定的事件。
  • 使用 $root 來呼叫根元件所綁定的事件。

所以,當你需要跨越不同元件來呼叫事件時,你就得搞清楚先後關係,這樣其實操作起來不甚方便,以上述的例子來說:

export default {
  name: 'App',
  components: {
    HelloKitty
  },
  mounted () {
    console.log('App mounted, emit event after 3 seconds.')
    setTimeout(() => {
      this.$refs.HelloKitty.$emit('changeAge', 30)
      this.$children[0].$emit('changeAge', 30)
    }, 3000)
  }
}

倘若你沒有設定 $refs 或是你不知道你到底有幾個女兒(醒醒把你沒有女兒),那麼你可能就沒辦法呼叫你所想要觸發的事件。爾或者是,你可能會叫到其他人的女兒,或是其他人的兒子突然出來認你做親爹的這種狀況。

export default {
  name: 'SomebodyChild',
  mounted () {
    this.$parent.$parent.$parent.$parent.$emit('call', 'Dad')
  }
}

這些是多元件下所衍生的問題。所以元件自身的事件,除了自己呼叫以外,上下屬關係最好可以控制在一個階級以內,如果要高於兩個階級以上,個人是不建議這麼做的。一方面維護上相當麻煩,二來你絕對有機會呼叫到不對的事件。


EventBus

為了解決多元件的事件傳遞,除了偷吃步使用 Vuex 來交換資料外,我們還可以透過 EventBus 的方式來溝通。何謂 EventBus?簡單來說就是 有個老司機要開車了,大家快上車(欸不對)。 我們有一個集合事件的地方,這個地方有各式各樣的事件等著被呼叫,我們利用單純的 JavaScript 可以實作出一台車,只要有心,人人都是 老司機

class OldDriver {
  constructor () {
    this.driver = document.createElement('bus')
  }

  $on (event, callback) {
    this.driver.addEventListener(event, callback, false)
  }

  $off (event, callback) {
    this.driver.removeEventListener(event, callback, false)
  }

  $emit (event, payload = {}) {
    this.driver.dispatchEvent(new CustomEvent(event, { detail: payload }))
  }
}

export default new OldDriver()

然後你只要在你的 App.vue 裡面這樣操作:

import EventBus from '@/extends/oldDriver.js'

export default {
  name: 'App',
  components: {
    HelloKitty
  },
  mounted () {
    setTimeout(() => {
      EventBus.$emit('changeAge', 30)
    }, 3000)
  }
}

然後你就會拿到 番號 一個叫做 CustomEvent 的物件,沒錯,因為在原生 JavaScript 當中,使用 dispatchEvent 呼叫時,你必須要傳入 CustomEvent 這個物件,來觸發自定義的事件。所以,你在子元件監聽的當下,會拿到整個 Event 的資料,真正的資料存放位置,是在這個物件的 detail 裡面。

當然,市面上也是有很多關於 EventBus 的套件。然而,其實 Vue 自己也可以當作老司機,我們只要 new 一個起來用就可以了。

import Vue from 'vue'
export default new Vue()

你會發現他連 CustomEvent 都幫你處理好了,你就不用自己處理 detail 的部分,這個老司機是不是好棒棒。


我們這樣看下來,是不是覺得 EventBus 好像非常厲害,但是 因為大家都在同一台車上 ,還是有一些事情需要大家留意,我們以使用 new Vue() 為例:

  • 事件的 命名 一樣的時候,回呼函式不同就等於不一樣。
  • 若移除時不指定回呼函式,則 相同命名 的事件會一起被移除。
  • 若你想知道綁定了什麼事件,可以查看 _events 這個物件。
    • _events 物件中,同名事件會以陣列的方式堆疊。
    • 所以你可以故意修改堆疊順序( 小孩子不要學 )。

小結

事件溝通在現今的 MVVM 生態系中,確實不是一個很容易的課題。不過由於 EventBus 帶來的方便性,也算是大幅度減低我們在操作上的麻煩。但,當你需要管理這些事件的時候,相對的也是挺惱人的。市面上有很多類似的小型專案或是套件可以使用,但說實在的我也不知道怎麼推薦,看起來大同小異,就挑自己喜歡的用吧。

最後,我們下一篇會提及 App 的溝通,應該算是溝通系列一個段落。在我們後續再次提及動態載入時,會再回來聊聊溝通的事情。

ITHome 鐵人賽同步刊登 Component 的溝通方式 :props Day 14

Hina Chen
偏執與強迫症的患者,算不上是無可救藥,只是我已經遇上我的良醫了。
Taipei