由於 VueJS 採用元件系統 (Component System) 來組織我們的應用程式,元件之間的資料傳遞,一直都是個不容忽視的問題,尤其在過去我們看過太多資料傳遞不當處理的方式,專案隨著時間不斷擴張,變得難以維護,最終導致不得不砍掉重練的悲劇。

讓我們先從一個最簡單的範例說起吧!

  <div id="app">
    <button @click="count++">You clicked me {{ count }} times.</button>
  </div>
  var app = new Vue({
    el: '#app',
    data: {
      count: 0
    }
  });

上面是一個點擊計數器的範例,當我們點擊畫面按鈕的時候,按鈕裡面的數字也會隨著增加。

Demo link

接著,如果我們需要增加另一個計數器呢?

聰明的你也許會想到,那我們就複製一份 button 吧!

  <div id="app">
    <button @click="count++">You clicked me {{ count }} times.</button>
    <button @click="count++">You clicked me {{ count }} times.</button>
  </div>

此時畫面雖然變成兩個按鈕,但問題來了,當我按下其中一個按鈕的時候,兩個按鈕內的 count 都增加了! 這是因為這兩個按鈕共用同一份 count 的狀態。

Demo link

此時,你可能會說,那我就改成 count1count2 ... 這樣新增下去。

沒錯,這樣雖然可以避開錯誤,讓每一個按鈕擁有各自的狀態,但我們的程式也失去了彈性,有幾個按鈕就必須事先新增幾個 count 的屬性。

這個時候,就需要利用 VueJS 的元件系統來將計數器切分成各自的元件。

在 VueJS 每個元件實體都有自己的資料作用範圍 (通常稱之為 scope),每個元件的資料都是獨立的。 換言之,我們無法直接從某個元件去存取其他元件的資料。

這樣講可能不太好理解,讓我們改寫一下剛剛的計數器範例:

  <div id="app">
    <button-counter></button-counter>
    <button-counter></button-counter>
  </div>
Vue.component('button-counter', {
  data: function () {
    return {
      count: 0
    }
  },
  template: '<button @click="count++">You clicked me {{ count }} times.</button>'
})

var app = new Vue({
  el: '#app'
});

像這樣,我們將計數器包裝成獨立的元件 <button-counter>,雖然同樣是計數器元件,但各自的 count 資料屬性都是獨立的,並不會因為按了 A 按鈕,B 按鈕就跟著加一。

如果要新增多組計數器,我們也只需要在 View 中繼續增加 <button-counter> 的數量就可以了,不必擔心彼此的資料是否衝突。

Demo link

完美!

工商服務插播

這是我最近在五倍紅寶石開設的 VueJS 入門課程,時間在九月底。 如果你對 VueJS 有興趣,卻總是不得其門而入,歡迎點此 https://5xruby.tw/talks/vue-js 報名參加,聽說早鳥票有打折,而且折扣還不少。


父與子: 「props in, events out」

當然事情不會如你我所想得這麼簡單,此時問題又來了!

假設今天有個需求,想要知道所有按鈕被按下的次數,也就是說,就是要計算所有 <button-counter>count,並且加總。 在元件資料各自為政的情況下,我們該如何處理?

當然我們不能直接在某個元件去直接存取另一個元件的資料,在 VueJS 裡面處理元件資料的時候,有個很重要的觀念:

「props in, events out」

資料透過 props 傳入,而更新透過 events 觸發。

關於 props 的部份我們稍後再提。


現在我們要做 count 的加總, 那麼首先,我們就在父層 #app 上加上一個資料屬性: sum 來儲存各個元件的總量,並且在 HTML 把它顯示出來:

  <div id="app">
    <h1>Total: {{sum}}</h1>
    <button-counter></button-counter>
    <button-counter></button-counter>
  </div>
Vue.component('button-counter', {
  data: function () {
    return {
      count: 0
    }
  },
  template: '<button @click="count++">You clicked me {{ count }} times.</button>'
})

var app = new Vue({
  el: '#app',
  data: {
    sum: 0
  }
});

Demo link

像這樣,雖然畫面顯示了「Total: 0」的字樣,但是當我們按下按鈕的時候,並未有任何反應。

你沒看錯,因為我們只是新增了 sum,程式還沒寫。


還記得剛剛曾說過的「props in, events out」嗎? 雖然我們不能在 <button-counter> 裡面控制父層的 sum,但我們可以透過「事件」來讓父層去觸發資料更新。

要觸發事件,自然要先註冊事件。

第一步,在 view 上面加上 @add-sum="sum++" 來註冊我們的自訂事件「add-sum」,在 add-sum 這個事件被觸發的時候,會對父層的做 sum++

  <div id="app">
    <h1>Total: {{sum}}</h1>
    <button-counter @add-sum="sum++"></button-counter>
    <button-counter @add-sum="sum++"></button-counter>
  </div>

那麼,在 button-counter 的部分,則是在原本的 click 事件中,加入 $emit('add-sum') 來表示當使用者點擊按鈕的同時,也要跟著發送 add-sum 事件:

Vue.component('button-counter', {
  data: function () {
    return {
      count: 0
    }
  },
  template: '<button @click="count++; $emit(\'add-sum\')">You clicked me {{ count }} times.</button>',
});

Demo link

於是現在我們在點擊按鈕的時候,也能同時透過觸發「事件」去通知上層元件去更新對應資料,而不是直接存取父層的 data 屬性,這就是前面說的「Events out」。


那麼 Props in 呢?

前面說過每個元件都有各自獨立的 Scope,但有時候我們元件內的資料是需要從外部引進來的,這時我們就必須要透過 「Props」這個屬性來宣告我們要從外部引進的資料。

一樣是前面的計數器範例。

假設 PM 今天又新增了一個需求:「欸欸,我的計數器可不可以不要從零開始跳,我想指定從某個數字開始...」

當然不可以啊

............ 雖然我知道你很想這樣回,但現實總是殘酷,實話永遠傷人。

回到程式。 最簡單的用法就是在 component 裡面新增 props 這個屬性:

Vue.component('button-counter', {
  props: {
    ['initialCounter']
  },
  data: function () {
    return {
      count: this.initialCounter
    }
  },
  template: '<button @click="count++; $emit(\'add-sum\')">You clicked me {{ count }} times.</button>',
});

像這樣,我們就新增了一個叫 initialCounter 的 props,而原本的 count 也改為由 props 帶入的 this.initialCounter 來表示。

props 宣告完成了,那麼,該怎麼把資料帶到 button-counter 呢?

很簡單,這裡我們來改寫一下 HTML 的部分:

  <div id="app">
    <h1>Total: {{sum}}</h1>
    <button-counter :initial-counter="10" @add-sum="sum++"></button-counter>
    <button-counter :initial-counter="20" @add-sum="sum++"></button-counter>
  </div>

這裏需要注意的是,由於 HTML 不分大小寫的特性,我們定義的 initialCounter props,必須要轉成 kebab-case (單字與單字用 - 符號連結) 的寫法,才能正確傳入喔。

像這樣,現在我們就可以針對不同計數器去個別定義它們的初始數值了!

Demo link

就這樣,一天又平安的過去了,感謝飛天小女警的努力!


老司機送貨囉: Event bus

前面提到元件間的溝通,如果是單純的父子層級,透過 props 與 events 來處理顯然沒有什麼問題。 但是我們的專案不會總是只有父子兩層這樣單純的關係,若是同層間的元件或是跨層級元件的互相溝通呢?

那麼就得找來老司機 Event bus 來幫忙了!

那怎麼使用 Event bus 呢? 很簡單,我們新增一個變數,並指定一個新的 Vue 物件實體給它:

// event bus
var bus = new Vue();

沒開玩笑,就這樣。 不相信? 好,讓我們再回到前面的計數器範例。

前面做好的計數器看起來很完美,但總覺得好像缺少了什麼東西對吧... 是 reset 啊,我忘了 reset ! 沒錯,如果沒有 reset 按鈕,如果要重新計算的話不就每次都得重整頁面,實在太 low 了。

沒問題,我們這就新增一顆 reset 按鈕:

  <div id="app">
    <h1>Total: {{sum}}</h1>
    <button-counter @add-sum="sum++"></button-counter>
    <button-counter @add-sum="sum++"></button-counter>

    <button>reset</button>
  </div>

但問題來了,我要怎麼在這個 reset 按鈕按下去的同時,針對所有的計數器與頂層的 sum 來歸零呢?

此時輪到老司機出場了!

首先,我們先將 reset button 封裝成一個獨立的元件 <button-reset>

  <div id="app">
    <h1>Total: {{sum}}</h1>
    <button-counter @add-sum="sum++"></button-counter>
    <button-counter @add-sum="sum++"></button-counter>

    <button-reset></button-reset>
  </div>

然後是 JS 的部分,一開始先新增一個 Vue 實體物件來當作 Event Bus:

var bus = new Vue();

我們在原本的 <button-counter> 與頂層實體 #app 分別加上 reset 這個 method,當各自的 reset 被呼叫的時候,所屬的 countsum 就歸零。

Vue.component('button-counter', {
  props: ['initialCounter'],
  data: function () {
    return {
      count: this.initialCounter || 0
    }
  },
  template: '<button @click="count++; $emit(\'add-sum\')">You clicked me {{ count }} times.</button>',
  methods: {
    reset: function(){
      this.count = 0;
    }
  },
  created: function(){
    bus.$on('reset', this.reset);
  }
});

var app = new Vue({
  el: '#app',
  data: {
    sum: 0,
  },
  methods: {
    reset: function(){
      this.sum = 0;
    }
  },
  created: function(){
    bus.$on('reset', this.reset);
  }
});

然後,我們在元件的 created 階段分別為 bus 註冊了 reset 事件: bus.$on('reset', this.reset); ,意思是說,當 bus 觸發了 reset 這個事件的時候,就會去呼叫實體內的 reset method。

當然,事件註冊之後,總要有個地方觸發它,這個任務就交給後來新增的 <button-reset> 吧。

Vue.component('button-reset', {
  template: '<button @click="reset">reset</button>',
  methods: {
    reset: function(){
      bus.$emit('reset');
    }
  }
});

可以看到 <button-reset> 的內部構造其實很簡單,當 reset 按鈕被點擊的時候,就向 bus 去發送 reset 這個事件。

然後接下來的事情就交給老司機 bus 去處理啦!

Demo link

雖然 Event Bus 很方便,但需要注意的小地方也不少,例如小心事件命名的衝突,以及在元件銷毀 (beforeDestroy) 前,要記得自行透過 $off 清除所監聽的所有事件,如:

Vue.component('button-counter', {
  // 略
  created: function(){
    bus.$on('reset', this.reset);
  },
  beforeDestroy: function(){
    bus.$off('reset', this.reset);
  }
});

全域狀態的管理

這部分其實跟資料傳遞比較沒有關係,要講的是狀態管理的策略。 前面提到每個元件都有自己獨立的 scope,但是當某個資料 (或狀態) 需要在多處被引用的時候,光靠 Props / Events 其實是不好維護的。

做法有很多種,但概念都是一樣的。

像是在開發階段,很多人會設定環境變數來區別「開發模式」或是「線上模式」,像這樣:

var hostName = (Vue.config.productionTip) ? 'http://localhost:3000' : 'https://www.your-api.com';

但是否代表我們在每個 component 的 created 階段就必須跑一次這段程式呢? 如果是初始後就不會被改變的值,我們可以這樣做:

Vue.config.productionTip = false;
Vue.prototype.$hostname = (Vue.config.productionTip) ? 'http://localhost:3000' : 'https://www.your-api.com';

透過 Vue.prototype 來定義 $hostname,然後在 component 裡面可以透過 this.$hostname 來取用:

Vue.component('custom-component', {
  created: function(){
    console.log(this.$hostname);
  }
});

或者如果你不希望每個 Vue 元件實體都被建立這份資料,那也可以改用 mixins 的方式,在你想要設定的元件上加入:

// define a mixin object
var myMixin = {
  data: function(){
    Vue.config.productionTip = false;
    return{
      hostname: (Vue.config.productionTip) ? 'http://localhost:3000' : 'https://www.your-api.com'
    }
  }
};

Vue.component('custom-component', {
  mixins: [myMixin],
  created: function(){
    console.log(this.hostname);
  }
});

當然,透過 mixin 所生成的 data 在建立之後,每個元件的 data 就都是獨立的了。 以上面的例子來說,當你改了某個元件的 hostname 並不會影響到另一個元件的 hostname

當然 mixins 也有 global mixin 的寫法,但就要小心這樣的用法會影響的可是所有引入的元件 (包括 third-party component) 需要特別注意。

真相只有一個: SSOT - Single Source of Truth

前面講的是元件引入外部全域資料的做法,最後來講講元件之間資料共享的部分。

為什麼會需要資料共享? 先看個例子。


近年出國的人很多,尤其是日本,所以現在我們想要做個幣值轉換器,來計算台幣與日幣的轉換。 假設 1 日幣 = 0.278 台幣,那麼我們也許可以這樣開始寫:

  <div id="app">
    <p>1 日幣 = 0.278 台幣</p>
    <div>台幣 <input type="text" v-model="twd"></div>
    <div>日幣 <input type="text" v-model="jpy"></div>
  </div>
  var app = new Vue({
    el: '#app',
    data: {
     twd: 0.278,
     jpy: 1,
    }
  });

當然現在還沒有任何功能,我們只是把兩個欄位分別與 data 的 twdjpy 做綁定。 接下來要開始做連動功能,所以我們加上 twd2jpyjpy2twd 兩個 method 來計算。

像這樣,我們分別為兩個輸入框加上 input 事件,並觸發 twd2jpyjpy2twd 來幫我們做計算:

  <div id="app">
    <p>1 日幣 = 0.278 台幣</p>
    <div>台幣 <input type="text" v-model="twd" @input="twd2jpy"></div>
    <div>日幣 <input type="text" v-model="jpy" @input="jpy2twd"></div>
  </div>
var app = new Vue({
  el: '#app',
  data: {
     twd: 0.278,
     jpy: 1,
  },
  methods: {
    twd2jpy: function(){
      this.jpy = Number.parseFloat(Number(this.twd) / 0.278).toFixed(3);
    },
    jpy2twd: function(){
      this.twd = Number.parseFloat(Number(this.jpy) * 0.278).toFixed(3);
    },
  }
});

Demo link

喔喔,看起來真的是好棒棒啊! 果然 Vue 就是神! 我們又完成了一項艱難的任務!

等等!

從遠方看到 PM 又走來了 (謎之聲:哪來的 PM ) ,並問說「哇這麼厲害,那可不可以也幫我加上美金、人民幣的轉換啊」

程序猿心想,如果要加上人民幣與美金的話,那就還得再加入 twd2usdtwd2rmbjpy2usdjpy2rmbusd2twdusd2jpyusd2rmb ......

(程序猿吐血而亡)


看到這裡,相信你已經發現問題出在哪了吧!

在做幣值的計算,其實不管台幣換日幣,或是日幣換台幣,從一開始我們就只需要一種基準值,不管怎麼換,錢都是一樣的。

換言之,程式可以改寫成這樣:

  <div id="app">
    <p>1 日幣 = 0.278 台幣</p>
    <div>台幣 <input type="text" v-model="twd"></div>
    <div>日幣 <input type="text" v-model="jpy"></div>
  </div>
var app = new Vue({
  el: '#app',
  data: {
     twd: 0.278 ,
  },
  computed: {
    jpy:{
      get: function(){
        return Number.parseFloat(Number(this.twd) / 0.278).toFixed(3);
      },
      set: function(val){
        this.twd = Number.parseFloat(Number(val) * 0.278).toFixed(3);
      }
    }
  }
});

此時 data 只留下 twd 作為基準, jpy 則是透過 computed 來依賴 twd 計算,並在 jpy 更新的時候,透過 set 去改寫 twd 的數值。

Demo link

往後,就算我們要加上美金也只需要在 computed 屬性加上 usd

var app = new Vue({
  el: '#app',
  data: {
     twd: 1,
  },
  computed: {
    jpy: {
      get: function(){
        return Number.parseFloat(Number(this.twd) / 0.278).toFixed(3);
      },
      set: function(val){
        this.twd = Number.parseFloat(Number(val) * 0.278).toFixed(3);
      }
    },
    usd: {
      get: function(){
        return Number.parseFloat(Number(this.twd) / 30.645).toFixed(3);
      },
      set: function(val){
        this.twd = Number.parseFloat(Number(val) * 30.645).toFixed(3);
      }
    }
  }
});

Demo link

相信未來若要再加入其他幣值的計算,肯定也不是難事了!


從以上的範例當中,我們可以看到 SSOT (Single Source of Truth) 其實只是一種開發準則、概念性的東西,順著這個邏輯再往下延伸,相信一定會有朋友會想到 Flux 或是 Vuex 的架構。

沒錯,在 Vue 開發大型應用架構確實用 Vuex 來做集中式狀態管理是相當實用的,礙於篇章的關係,網路上的相關介紹與教學文件也不少,就請有興趣的朋友自行搜尋囉。