# 6-1 Composition API 簡介

作為 Vue.js 3.0 開始納入的重大特性,Composition API 在這之中所代表的地位絕對是舉足輕重, 這也是為什麼我選擇將此特性獨立出單一章節的原因。 那麼在本書的最後一個章節,我們就來看看這個 Vue Composition API 到底為 Vue.js 帶來了什麼變革。

# Composition API 是什麼

從 2018 年開始,Vue 3.0 的新計畫就已經在核心團隊開始醞釀了, 起初 Vue.js 團隊所採用的是類似 React 語法的 Class API - Vue Class Component (https://github.com/vuejs/vue-class-component (opens new window)),但在新版本考慮到與原有 API 的相容性、在 TypeScript 與非 TypeScript 開發者間的平衡 (看看 Angular 2+,不用 TS 無法開發)、瀏覽器對原生 ES Class 的支援度、透過 Class API 開發所需要的 Class fields、Decorators 等等提案始終還不穩定 (stage < 4) 等因素, 因此最終決定放棄 Class API 的路線。

在決定放棄 Class API 的路線之後,取而代之的是 Function-based API (也就是現在的 Composition API)。 Composition API 從 2019 年以 Function-based APIs 之名發表就引發社群熱議不斷, 直到 2020 年才正式定名為 Composition API,其由來自於「組合代替繼承」之意。

說得再白話一些,Composition API 就是為了要解決過去在 Vue.js 2.x 時,元件與元件之間的程式碼與邏輯結構,難以抽取出來重複使用的問題。而 Composition API 所提供的解決方式,就是以函式作為邏輯的載體,將該放在一起的東西放在一起

什麼意思? 別急,相信看完本章,讀者們就能夠理解了。

# Vue 2.x - Options API 出了什麼問題

標題有些誤導人了,其實 Vue 2.x 很好,就像我們在前面幾個章節所介紹的那樣,我們可以把程式組織成一個個的「元件」, 這個元件同時包含了「模板」 (<template>) 、「行為與邏輯」 (<script>) ,以及「樣式」 (<style>) 三大部分。

<script> 裡面,我們又可以將不同的屬性做切分,例如與模板響應式的狀態我們可以放在 data 裡面,相依的狀態放在 computed, 事件與方法可以放在 methods,以及生命週期鉤子函式,還有其他各式屬性等等。

Vue 2.x options - 1

像這樣,我們將元件內程式碼分門別類,看起來很棒。


但是隨著時間經過,過去開發的專案一定會面臨功能的新增、需求變更等等問題,這個時候,我們就會在原有的結構這樣加入新的功能:

Vue 2.x options - 2

於是,當元件的功能越來越多,明明應該是同一個操作邏輯,程式碼卻要散佈在元件的各種地方,時間一長不但不利於維護, 往後當我們想要重複使用某些功能的時候,卻也難以拆開了。

# 功能與邏輯的重複使用 (reusable)

而 Vue 2.x 當時對於功能與邏輯的重複使用, Vue.js 提供了幾種解決方案:

小提醒

本書範例使用的都是 Vue 3.x 的語法,可能與 Vue 2.x 的 API 會有些許不同,無法直接套用。

# 自定義指令 (Vue 3.0 仍支援,可安心使用)

如同我們在第一章曾介紹過的,指令就是 Vue.js 提供給模板使用的特殊屬性。 除了 Vue.js 內建的各種指令外,它也允許開發者自由建立新的指令。

舉例來說,過去我們會在 <img> 標籤上面加上 @error="..." 來處理當圖片載入錯誤時,用預設圖替代以免網頁破圖跑版的問題, 但是如果這個需求在多個元件上都需要用到,豈不是要在每個元件上都寫上這個方法?

這時,我們就可以自行建立一個指令來處理這個問題,建立一個客製化的指令很簡單,我們可以透過 app.directive()

假設就叫它 img-fallback 好了。

// 建立新元件
const app = Vue.createApp({ });

// 在 app 註冊自定義指令
app.directive('img-fallback', {
  // mounted hook
  mounted (el) { 
    // 傳入的 el 指的是元素本身
    // 當 el 觸發 error 事件的時候,把 src 路徑換成預設圖
    el.addEventListener('error', () => {
      el.src = 'https://dummyimage.com/150x150/000/ffffff.png&text=no+image'
    });
  }
});

app.mount('#app');
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

那麼實際應用的時候,我們就可以在所有想要設定預設圖的 <img> 標籤加上 v-img-fallback 的自定指令:

<div id="app">
  <!-- 這張圖不存在 -->
  <img 
    v-img-fallback 
    src="https://www.google.com/noimage.png" alt="這張圖不存在">
</div>
1
2
3
4
5
6

像這樣,當瀏覽器試圖取德這個不存在的圖檔 https://www.google.com/noimage.png 時,就會觸發 error 事件, 這個時候就會觸發我們在指令定義好的 error handler 把圖檔路徑至換成預設的路徑了。

自訂指令除了上面所示範的 mounted hook 函式之外,這裏把全部 hook 列出來給讀者參考:

  • created: 在綁定元素的屬性或事件監聽器前觸發。 (時間點在 v-on 以前)
  • beforeMount: 指令剛被綁定到指定元素,且掛載到父元件之前觸發。
  • mounted: 指令綁定的元素,掛載到父元件之後觸發。
  • beforeUpdate: 該元件的 VNode 更新之前觸發。
  • updated: 該元件的 VNode 以及它的以元件被更新之後觸發。
  • beforeUnmount: 元素卸載前觸發。
  • unmounted: 元素卸載後觸發。

而對應的 callback 函式除了範例裡的 el 參數可以取到元素本身之外,同時還有 bindingvnodeprevNnode 可以使用。

比較值得一提的是,binding 提供了這幾種屬性:

  • instance: 使用此指令的元件實體。
  • value: 傳遞給指令的綁定值。
  • oldValue: 綁定值的前一個值,只能在 beforeUpdateupdated 的 hook 使用。
  • arg: 傳給此指令的參數。
  • modifiers: 修飾子,提供開發者自行定義。
  • dir: 一個物件,在指令被註冊的時候作為參數傳遞。

vnodeprevNnode 則是使用此指令的元件的虛擬節點 (即 virtual DOM) 與其前一個虛擬節點。

除了前面所介紹的預設圖之外,像是在元件內紀錄 Google Analytics 之類的事件追蹤,也很適合用自訂指令來處理:

// 列出所有需要被追蹤的事件名稱
const availableEventsList = [
  'click',
  'dblclick',
  'input',
  'keydown',
  'keypress',
  'keyup',
  'mousedown',
  'mouseenter',
  'mouseleave',
  'mousemove',
  'mouseout',
  'mouseover',
  'scroll'
];

// 在 app 註冊 event-track 指令
app.directive('event-track', {
  mounted(el, binding) {
    const { value, modifiers } = binding;

    // 當指定在 availableEventsList 裡的事件被觸發的時候
    // 呼叫 event_track() 進行紀錄
    for (const event in modifiers) {
      if (availableEventsList.includes(event)) {        
        el.addEventListener(event, event_track(value.category, event, value.label));
        break;
      }
    }
  },
  // 卸載前解除事件綁定
  beforeUnmount(el, binding) {
    const { value, modifiers } = binding;

    for (const event in modifiers) {
      if (availableEventsList.includes(event)) {
        el.removeEventListener(event, event_track(value.category, event, value.label));
        break;
      }
    }
  }
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

實際在模板元素上就可以使用 v-event-track 這個指令:

<!-- 將追蹤事件名放在修飾子上, category 與 label 則是透過 binding.value 取得 -->
<button v-event-track.click="{ category: '...', label: '...' }"> click </button>
1
2

這樣一來,當這個按鈕被點擊的時候,就會呼叫 event_track 來記錄相關事件了。

# mixins (Vue 3.0 仍支援,但已不建議使用)

除了自定義指令之外,Vue.js 也提供了 Mixins 的語法來提供不同元件內的屬性重複使用,先自訂一個 Mixin 物件:

// 自訂一個 Mixin 物件
const myMixin = {
  methods: {
    hello() {
      console.log('hello from mixin!')
    }
  },
  created() {
    this.hello()
  },
};
1
2
3
4
5
6
7
8
9
10
11

需要使用的時候,在元件加上 mixins 屬性:

const app = Vue.createApp({});

// 子元件 A
app.component('custom-component-a', {
  mixins: [myMixin]
});

// 子元件 B
app.component('custom-component-b', {
  mixins: [myMixin]
});

app.mount('#app');
1
2
3
4
5
6
7
8
9
10
11
12
13

像這樣,我們 custom-component-acustom-component-b 同時透過 mixinsmyMixin 物件引入。

引入 myMixin 物件後的 custom-component-acustom-component-b 元件, 就會分別同時擁有 hello() 以及 created hook 的功能了,意義等同於下面段程式碼:

// 子元件 A
app.component('custom-component-a', {
  methods: {
    hello() {
      console.log('hello from mixin!')
    }
  },
  created() {
    this.hello()
  },
});

// 子元件 B
app.component('custom-component-b', {
  methods: {
    hello() {
      console.log('hello from mixin!')
    }
  },
  created() {
    this.hello()
  },
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

如果有多個 mixins 則可利用 mixins: [myMixinA, myMixinB], 逗號隔開的方式加入。 像這樣,我們將相同的屬性拆出來到 mixins 物件後,再將它分別引進到需要繼承的元件內,是不是很方便呢!

但是 mixins 一時爽,一直 mixins 的結果?

前面我們說過,專案的程式碼通常 就像工程師的體重一樣 會隨著時間與需求增加、修改而不斷地成長。 當我們的程式碼經過不斷地迭代,累積了代代祖傳的各種 mixins 時,就是開發者崩潰的開始。

我用個簡單的例子示範:

// myMixinA
const myMixinA = {
  data () {
    return {
      msg: 'Hello'
    }
  },
  mounted() {
    alert(`${this.msg}, ${this.name}!!`);
  }
};

// myMixinB
const myMixinB = {
  data () {
    return {
      msg: '你好'
    }
  },
};

// myMixinC
const myMixinC = {
  data () {
    return {
      name: '訪客'
    }
  },
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

這個時候問題來了,請問各位讀者,以下的兩個元件在執行的時候,分別會發生什麼事?

// 子元件 A
app.component('custom-component-a', {
  template: `<h1> {{msg}}, {{name}}!! }} </h1>`,
  mixins: [myMixinA],
  data () {
    return {
      name: 'Kuro'
    }
  }
});

// 子元件 B
app.component('custom-component-b', {
  template: `<h1> {{name}}, {{msg}}!! }} </h1>`,
  mixins: [myMixinB, myMixinC],
});

// 子元件 C
app.component('custom-component-c', {
  template: `<h1> {{name}}, {{msg}}!! }} </h1>`,
  mixins: [myMixinA, myMixinB],
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

相信各位讀者也很難在第一時間回答這個問題對吧。

像這樣,在元件之間充滿各種不同 mixins 的排列組合,發生了什麼問題?

可能前一位開發者看到了 custom-component-b 能夠正常運作,就仿造前面的寫法寫了一個 custom-component-c, 怎知道 myMixinAmyMixinB 裡面根本沒有定義 name 這個 data 屬性,於是在模板 template 要印出 name 時就會出現問題。

邏輯與結構「過度」封裝的結果,就是開發的時候,我們根本不知道這個元件內哪些狀態、哪些方法是可以被調用的,甚至是有哪些會衝突,我們應該要避開的,完全無從得知。

而本章所要介紹的 Vue 3.0 Composition API 就是為了解決這個問題所發展出來的。

# Composition API (Vue 3.0 新增)

自 Vue 3.0 引進了 Composition API 這個特性後,「表面上看起來」與前面所介紹的 mixins 感覺很像:

Composition API

都是將跨元件共用的屬性 (如 datacomputedmethods... 等) 包裝起來,然後需要用的元件再它們引入進去。

Composition API

但是與 mixins 有個很大的不同之處是,被引入 (import) 到元件內的屬性,必須要以「物件」或「函式」的形式來將它們引進到元件中。 這裏我們以一個簡單的 todo-list 來示範。

這裏我們建立一個名為 toDo 的函式,將狀態 (原本在 data 回傳的東西) 透過 Vue.ref() 進行封裝。 另外新增 addremove 用來處理增加與移除 items 陣列元素的方法,最後再將狀態、方法透過 return 回傳出去:

// todo function
const toDo = () => {
  const todo = Vue.ref('');
  const items = Vue.ref(['Vue', 'is', 'Awesome']);

  // 將輸入框新增到列表中
  const add = () => {
    if (todo.value) {
      items.value.push(todo.value);
      todo.value = '';
    }
  };

  // 將選中的項目移除
  const remove = item => {
    items.value = items.value.filter(v => v !== item);
  };

  // 這裏只需要回傳 <template> 會用到的部分即可
  return {
    todo,
    items,
    add,
    remove
  };
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

這個時候,我們只需要在子元件 todo-list 裡面使用 setup 來啟動這個元件:

const app = createApp({
  setup() {
    // 這裏沒有 this!

    const {
      todo,
      items,
      add,
      remove
    } = todoList();
    
    // 將模板會用到的部分 return 出去
    return {
      todo,
      items,
      add,
      remove
    };
  }
}).mount('#app');
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

而網頁模板的部分則是與過去的撰寫風格一樣:

<h3>Todo App</h3>  

<div class="todo-list">
  <div>
    <input v-model="todo" @keyup.enter="add" type="text">
    <button @click="add">Add</button>
  </div>
  
  <ul class="list-group">
    <li v-for="item in items" :key="item">
      <span>{{ item }}</span>

      <button @click="remove(item)">
        <span>&times;</span>
      </button>
    </li>
  </ul>

</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
試一試

日後若想加入新功能,假設新增一個 counter 的計數器按鈕, 所以我們增加一個 counter 函式,並回傳計數數字與 add 方法:

const counter = () => {
  const count = ref(0);
  const add = () => {
    count.value++;
  };

  return {
    count,
    add
  }
};
1
2
3
4
5
6
7
8
9
10
11

於是主元件程式便可以這樣改寫:

const app = createApp({
  setup() {
    // 這裏沒有 this!
    const {
      todo,
      items,
      add,
      remove
    } = todoList();
    
    // 因為 add 與上面 todoList 的 add 名稱重複了,所以換個名字 counterAdd
    const {
      count,
      add: counterAdd,
    } = counter();

    
    // 將模板會用到的部分 return 出去
    return {
      todo,
      items,
      add,
      remove,
      count,
      counterAdd
    };
  }
}).mount('#app');
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

像這樣我們就可以將 todo-listcounter 的邏輯分別拆分出來,利用新的 Composition API 來進行開發, 未來引入到元件使用的時候也不再會有過去混淆不清的情況了。

試一試

但這裡必須強調,Composition API 絕對不是為了廢棄原本 Options API 所發展出的語法,它只是 Vue.js 官方團隊提供給開發者們的另一種組織程式碼的風格。

針對 Composition API 的基本介紹就到這,在接下來的小節裡面,我們將繼續介紹 Composition API 各種常用到的 API 以及使用方法。

Last Updated: 1/14/2021, 6:53:31 PM