VueJS 元件 (Component) 之間資料溝通傳遞的方式
由於 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
}
});
上面是一個點擊計數器的範例,當我們點擊畫面按鈕的時候,按鈕裡面的數字也會隨著增加。
接著,如果我們需要增加另一個計數器呢?
聰明的你也許會想到,那我們就複製一份 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
的狀態。
此時,你可能會說,那我就改成 count1
、 count2
... 這樣新增下去。
沒錯,這樣雖然可以避開錯誤,讓每一個按鈕擁有各自的狀態,但我們的程式也失去了彈性,有幾個按鈕就必須事先新增幾個 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>
的數量就可以了,不必擔心彼此的資料是否衝突。
完美!
工商服務插播
這是我最近在五倍紅寶石開設的 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
}
});
像這樣,雖然畫面顯示了「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>',
});
於是現在我們在點擊按鈕的時候,也能同時透過觸發「事件」去通知上層元件去更新對應資料,而不是直接存取父層的 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 (單字與單字用 - 符號連結) 的寫法,才能正確傳入喔。
像這樣,現在我們就可以針對不同計數器去個別定義它們的初始數值了!
就這樣,一天又平安的過去了,感謝飛天小女警的努力!
老司機送貨囉: 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
被呼叫的時候,所屬的 count
與 sum
就歸零。
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
去處理啦!
雖然 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 的 twd
與 jpy
做綁定。 接下來要開始做連動功能,所以我們加上 twd2jpy
與 jpy2twd
兩個 method 來計算。
像這樣,我們分別為兩個輸入框加上 input 事件,並觸發 twd2jpy
或 jpy2twd
來幫我們做計算:
<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);
},
}
});
喔喔,看起來真的是好棒棒啊! 果然 Vue 就是神! 我們又完成了一項艱難的任務!
等等!
從遠方看到 PM 又走來了 (謎之聲:哪來的 PM ) ,並問說「哇這麼厲害,那可不可以也幫我加上美金、人民幣的轉換啊」
程序猿心想,如果要加上人民幣與美金的話,那就還得再加入 twd2usd
、twd2rmb
、jpy2usd
、jpy2rmb
、usd2twd
、usd2jpy
、usd2rmb
......
(程序猿吐血而亡)
看到這裡,相信你已經發現問題出在哪了吧!
在做幣值的計算,其實不管台幣換日幣,或是日幣換台幣,從一開始我們就只需要一種基準值,不管怎麼換,錢都是一樣的。
換言之,程式可以改寫成這樣:
<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
的數值。
往後,就算我們要加上美金也只需要在 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);
}
}
}
});
相信未來若要再加入其他幣值的計算,肯定也不是難事了!
從以上的範例當中,我們可以看到 SSOT (Single Source of Truth) 其實只是一種開發準則、概念性的東西,順著這個邏輯再往下延伸,相信一定會有朋友會想到 Flux 或是 Vuex 的架構。
沒錯,在 Vue 開發大型應用架構確實用 Vuex 來做集中式狀態管理是相當實用的,礙於篇章的關係,網路上的相關介紹與教學文件也不少,就請有興趣的朋友自行搜尋囉。