# 6-3 從 Options API 到 Composition API

在介紹了 Vue Composition API 幾個常見的 API 使用方式之後, 接著我們來看看如何將一個傳統的 Options API 元件遷移到 Composition API 的語法。

這是一個使用傳統 Options API 所寫的簡單 To-Do List 元件範例:

<template>
  <div>
    <!-- 輸入框 -->
    <div class="input-group">
      <input v-model="todo" @keyup.enter="add" type="text">
      <button @click="add">Add</button>
    </div>

    <!-- 列表 -->
    <div class="list-group">
      <div v-for="item in items" :key="item">
        <span>{{ item }}</span>
        
        <!-- 刪除按鈕 -->
        <button @click="remove(item)">
          <span>&times;</span>
        </button>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      todo: "",
      items: ["Vue", "is", "Awesome"]
    };
  },
  methods: {
    add() {
      if (this.todo) {
        this.items.push(this.todo);
        this.todo = "";
      }
    },
    remove(item) {
      this.items = this.items.filter(v => v !== item);
    }
  },
  mounted() {
    console.log(`todoList is mounted!`);
  }
};
</script>
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
44
45
46

通常將元件改寫成 Composition API 語法的時候,模板 <template> 的內容幾乎不會動到它,以這個範例來說也是。 所以我們把重點放在 <script> 區塊。

首先我們在元件內新增一個 setup() 函式,這是 Composition API 元件的啟動點。 然後把 data 回傳的兩個屬性 todoitems 改寫,這裏我們選用 ref() 來包裝:

import { ref } from 'vue';

export default {
  setup () {
    const todo = ref("");
    const items = ref(["Vue", "is", "Awesome"]);

    return {
      todo,
      items
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

別忘了最後要 return 出去模板才吃得到。

接著,將兩個 addremovemethods 抽取出來並且改寫:

const add = () => {
  if (todo.value) {
    items.value.push(todo.value);
    todo.value = "";
  }
};

const remove = (item) => {
  items.value = items.value.filter(v => v !== item);
};
1
2
3
4
5
6
7
8
9
10

這兩個函式不一定要寫在 setup 裡面,即使最後我們寫成:

import { ref } from 'vue';

const todo = ref("");
const items = ref(["Vue", "is", "Awesome"]);

const add = () => { ... };
const remove = (item) => { ... };

export default {
  setup () {

    return {
      todo,
      items,
      add,
      remove
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

根據 JavaScript 的範圍鍊 (Scope-Chain) 規則來說,只要最後在 setup 把它們 return 出去,模板內照樣讀取得到。

甚至就算我們把 todoList 相關的邏輯通通搬到另一個 todo.js 檔案:

// todo.js
import { ref } from 'vue';

export const todo = ref("");
export const items = ref(["Vue", "is", "Awesome"]);

export const add = () => { ... };
export const remove = (item) => { ... };
1
2
3
4
5
6
7
8

到元件內再 import 進來也是沒問題,而且 setup 會變得更乾淨。

// app.vue
import { todo, items, add, remove } from "./todo";

export default {
  setup () {

    return {
      todo,
      items,
      add,
      remove
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

最後要再次提醒各位的地方是, 原本的 mounted 鉤子函式,到了 Composition API 之後,改名為 onMounted(), 並且一定要寫在 setup() 函式內,而且由於在模板內用不到,所以不需要 return 出去。

import { onMounted } from 'vue';
import { todo, items, add, remove } from "./todo";

export default {
  setup () {
    
    onMounted(() => {
      console.log(`todoList is mounted!`);
    })

    return {
      todo,
      items,
      add,
      remove
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

這樣就完成了一個基本元件的改造。

# Composition API 與 Vuex 的對比

相信看到這裡,讀者應該已經發現 Options API 與 Composition API 除了語法與程式碼的組織方式是最大差別之外, 還有另一個我認為很值得與讀者們討論的地方,就是既然 Composition API 已經可以透過 exportimport 將跨元件共用的狀態抽取出來, 那麼前面第五章所介紹的 Vuex 是否還有存在的必要性?

回答這個問題前,我想先試著將範例中的 Vuex 從專案中拆出來,再用 Composition API 改寫,直接讓讀者感受兩者的不同,再回頭討論這個話題。

那麼,在本書的最後,我們就直接示範第五章曾經示範過的「口罩地圖」範例進行改寫!

小提醒

這裏不會重新說明口罩地圖的細節,只著重在需要改寫的部分,建議讀者先閱讀過本書 5-3 小節的範例說明。

# 實戰! 用 Composition API 改寫口罩地圖!

# 專案下載

首先我們到 https://github.com/kurotanshi/mask-map-demo (opens new window) 將 5-3 小節完整的程式碼下載下來, 然後透過 yarnnpm install 將該安裝的相依套件裝起來。

打開專案目錄,我們在 src/ 目錄下新增一個 composition 的目錄並新增另一個 store.js ,用來取代原本 Vuex 的 store.js

# 建立新的 store.js

在這個 composition/store.js 裡面,我們將原本在 Vuex store 裡的 stategetters 以及 actions 內容分別建立起來:

// src/composition/store.js
import { reactive, computed } from 'vue';

// 對應原本的 state
const state = reactive({
  currCity: '臺北市',
  currDistrict: '北投區',
  infoBoxSid: null,
  keywords: '',
  showModal: false,
  location: [],
  stores: [],
  // 以 computed 對應原本的 getters
  cityList: computed(() => state.location.map((d) => d.name)),
  districtList: computed(() => state.location.find((d) => d.name === state.currCity)?.districts || []),
  currDistrictInfo: computed(() => state.districtList.find((d) => d.name === state.currDistrict) || {}),
  filteredStores: computed(() => {
    return state.keywords
      ? state.stores.filter((d) => d.name.includes(state.keywords)).slice(0, 30)
      : state.stores.filter((d) => d.county === state.currCity && d.town === state.currDistrict);
  })
});

// 對應原本的 actions
const fetchLocations = async () => {
  // 取得行政區資料
  const json = await fetch('https://raw.githubusercontent.com/kurotanshi/mask-map/master/raw/area-location.json')
    .then((res) => res.json());

  state.location = json;
};

const fetchPharmacies = async () => {
  // 取得藥局資料
  const json = await fetch('https://kiang.github.io/pharmacies/json/points.json')
    .then((res) => res.json());

  const data = json.features.map((d) => ({
    ...d.properties,
    latitude: d.geometry.coordinates[0],
    longitude: d.geometry.coordinates[1],
  }));

  state.stores = data;
};

// 將 state 與 actions 匯出
export default {
  state,
  fetchLocations,
  fetchPharmacies,
};
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
44
45
46
47
48
49
50
51
52

程式碼結構改寫完成後,最後再透過 export default 來輸出。 這樣一來,需要載入這個 store 的元件就可以透過 import 將它引入即可。

小提醒

這裏我們簡化範例,直接將原本的 state 包裝成 reactive 物件輸出。

若希望保持過去在 Vuex 的嚴謹模式,可以在 export default 輸出時加上 readonly 來鎖定狀態, 並分別為它們建立 setter 函式 (對應原本的 mutations) 來更新狀態,以確保 state 的更新來源。

// 保護 state 的更新,採用 set function 來改寫狀態內容
const setCurrCity = (val) => { state.currCity = val };
const setCurrDistrict = (val) => { state.currDistrict = val };

export default {
  // 將輸出的 state 透過 readonly 鎖定
  state: readonly(state), 
  setCurrCity,
  setCurrDistrict,
  ...
};
1
2
3
4
5
6
7
8
9
10
11

將共用的部分抽取出來到 src/composition/store.js 之後,接著我們分別修改 asideMenu.vuelightbox.vuemaskMap.vue 以及 App.vue 這些元件。

# 修改元件 - App.vue

首先修改的是 App.vue,我們將前面建立好的 store.js 引入到專案根元件,採用相依性注入 (Dependency Injection) 的模式,將資料分享至專案內的所有元件中:

 















 






import { provide } from 'vue';
import mapStore from '@/composition/store';

import asideMenu from './components/asideMenu.vue';
import lightBox from './components/lightbox.vue';
import maskMap from './components/maskMap.vue';

export default {
  name: 'App',
  components: {
    asideMenu,
    lightBox,
    maskMap
  },
  setup () {
    // mapStore - 作為所有元件的提供者
    provide('mapStore', mapStore);

    mapStore.fetchLocations();
    mapStore.fetchPharmacies();
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

provide-inject模式傳遞共用邏輯

像這樣,我們使用 provide()App.vue 作為資料的提供者,這樣等等在子元件內就可以使用 inject 來提取共用資料。 另外,由於 setup() 本身即可作為替代原本的 created 鉤子,所以我們直接在此呼叫遠端 API 取得資料即可。

另外,原本在 <aside-menu> 所使用的 openPopup() 方法,因為我們等等會將它抽取出來,所以這裏直接移除,連同模板上的 ref 屬性一併移除:

<template>
  <div id="app">
    <aside-menu />
    <mask-map />
    <light-box />
  </div>
</template>
1
2
3
4
5
6
7

# 修改元件 - asideMenu.vue

接著我們修改左側欄的列表元件,首先是將 vuex 與 computedwatchmethods 等屬性通通移除,改為 setup() 函式來建立元件內容。 並且透過 inject('mapStore') 取得我們前面在 App.vue 透過 provide 所注入的 mapStore 物件:







 
 
 













 
 
 
 









import { toRefs, inject, watch } from "vue";

export default {
  name: "asideMenu",
  setup() {
    
    // 透過 inject('mapStore') 取得 @/composition/store 的資料
    const mapStore = inject('mapStore');
    const { state } = mapStore;

    // 原本的 methods,注意這裡已經沒有 「this」
    const keywordHighlight = val => {
      return val.replace(new RegExp(state.keywords, 'g'), 
          `<span class="highlight">${state.keywords}</span>`)
    };

    const openInfoBox = sid => {
      state.showModal = true;
      state.infoBoxSid = sid;
    }

    // 注意! 物件資料需要透過函式回傳
    watch(() => (state.districtList), v => {
      const [arr] = v;
      state.currDistrict = arr.name;
    });

    return {
      ...toRefs(state),
      openInfoBox,
      keywordHighlight
    };
  },
};
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

這裡比較需要注意的是,原本的 methods 改寫成 Composition API 的語法後,不再會有 this, 而且 watch 由於是觀察物件內的資料更新,所以需要透過函式來包裝並回傳觀察的屬性。

# 修改元件 - maskMap.vue

接下來的 maskMap.vue 與改寫 asideMenu.vue 的步驟相差不大,這個元件內程式碼同樣包含了 datacomputedwatchmethodsmounted 這幾個部分,打開之後當然是逐一將它們進行拆解,將 datamethods 抽取出來,然後新增 setup(),並將其中必要的部分 (watchonMounted) 添加進去:

import { ref, watch, onMounted } from 'vue';
import store from './store';
import L from 'leaflet';

// 略
const ICON = { ... };
const map = ref(null);
const markers = [];

// methods,內容略
const addMarker = (item) => { ... };
const clearMarkers = () => { ... };
const triggerPopup = markerId => { ... }

export default {
  name: 'maskMap',
  setup () {
    // onMounted hook
    onMounted(() => {
      map.value = L.map('mask-map', {
        center: [25.03, 121.55],
        zoom: 14,
      });

      L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
        attribution: '<a target="_blank" href="https://www.openstreetmap.org/">© OpenStreetMap 貢獻者</a>',
        maxZoom: 18,
      }).addTo(map.value);
    });

    // 原本的 watch
    watch(() => store.state.currDistrictInfo, dist => {
      map.value.panTo(new L.LatLng(dist.latitude, dist.longitude));
    });

    watch(() => store.state.filteredStores, stores => {
      clearMarkers();
      stores.forEach((element) => addMarker(element));
    });
  }
}
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

到目前為止,雖然已經可以正常運作,但由於我們已經知道 triggerPopup() 方法未來在 asideMenu.vue 也會用到, 所以我們決定在 composition/ 目錄下新增一個 map.js,並且把所有控制地圖邏輯的程式碼搬進去:

// src/composition/map.js
import { ref, watch, onMounted } from 'vue';
import L from 'leaflet';
import store from './store';

// 略
const ICON = { ... };
const map = ref(null);
const markers = [];

// 原 methods,內容略
const addMarker = (item) => { ... };
const clearMarkers = () => { ... };
const triggerPopup = markerId => { ... }

const mapInit = (element) => {

  onMounted(() => {
    map.value = L.map(element, {
      center: [25.03, 121.55],
      zoom: 14,
    });

    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
      attribution: '<a target="_blank" href="https://www.openstreetmap.org/">© OpenStreetMap 貢獻者</a>',
      maxZoom: 18,
    }).addTo(map.value);

    watch(() => store.state.currDistrictInfo, (dist) => {
      map.value.panTo(new L.LatLng(dist.latitude, dist.longitude));
    });

    watch(() => store.state.filteredStores, (stores) => {
      clearMarkers();
      stores.forEach((element) => addMarker(element));
    });

  });
};

// 外部會使用到的只有 triggerPopup 與 mapInit
export default {
  triggerPopup,
  mapInit
};
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
44
45

然後將原本在 setup() 啟動地圖的功能抽取出來,包裝成 mapInit 函式之後,並且加個 export 將它輸出。

接著回到 App.vue ,同樣透過 import 引入 composition/map.js ,並使用 provide 輸出 map

import { provide } from 'vue';
import map from '@/composition/map';
import mapStore from '@/composition/store';

// 中略...

export default {
  setup () {
    // 前後略...
    provide('mapStore', mapStore);
    provide('map', map);
  }
1
2
3
4
5
6
7
8
9
10
11
12

這樣一來,回到原本的 maskMap.vue,我們的 <script> 就只剩下呼叫 mapInit() 函式啟動地圖了。

<!-- maskMap.vue -->
<template>
  <div class="mask-map" id="mask-map"></div>
</template>

<script>
import L from 'leaflet';
import { inject } from "vue";

export default {
  name: 'maskMap',
  setup () {
    // 同樣透過 inject 引入共用程式碼
    const map = inject('map');
    map.mapInit('mask-map');
  }
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

這樣就完成了 maskMap.vue 的改寫,將地圖相關的邏輯全都封裝到 composition/map.js 檔案,元件的結構就單純多了。

再來回到 asideMenu.vue,同樣加上 inject('map') 引入地圖相關程式邏輯,並且將 triggerPopup 方法透過 return 回傳到模板上:







 

 






 






import { toRefs, inject, watch } from "vue";

export default {
  name: "asideMenu",
  setup() {
    const mapStore = inject('mapStore');
    const map = inject('map');

    const { triggerPopup } = map;
    const { state } = mapStore;

    // 中間略...

    return {
      ...toRefs(state),
      triggerPopup,
      openInfoBox,
      keywordHighlight
    };
  },
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

然後修改模板部分:

 <li v-for="s in filteredStores" :key="s.id" @click="$emit('triggerMarkerPopup', s.id)">
1

改寫為

 <li v-for="s in filteredStores" :key="s.id"  @click="triggerPopup(s.id)">
1

現在有了 Composition API 之後,就不用像過去在元件間將事件繞來繞去,取代原本需要繞一大圈,在元件之間傳遞事件才能完成的任務了。

# 修改元件 - lightbox.vue

最後則是 lightbox.vue 的部分,修改的步驟與前面差別不大,同樣將 computedmethods 從元件抽取出來,並加入 setup() 改寫, 共用的 mapStore 則同樣使用 inject('mapStore') 抽取出來:

import { inject, computed, toRefs } from "vue";

export default {
  name: 'Lightbox',
  setup () {
    const mapStore = inject('mapStore');
    const { state } = mapStore;

    // currStore 與 servicePeriods 因未與其他元件共用,故無需抽取
    const currStore = computed(() => state.stores.filter((d) => d.id === state.infoBoxSid)[0]);
    const servicePeriods = computed(() => {
      let servicePeriods = currStore.value?.['service_periods'] || '';
      servicePeriods = servicePeriods.replace(/N/g, 'O').replace(/Y/g, 'X');
      return servicePeriods
        ? [servicePeriods.slice(0, 7).split(''),
            servicePeriods.slice(7, 14).split(''),
            servicePeriods.slice(14, 21).split('')]
        : servicePeriods;
    });

    const close = () => { state.showModal = false };

    return {
      ...toRefs(state),
      currStore,
      servicePeriods,
      close
    }
  }
};
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

最後同樣將模板需要的狀態 return 出去就完成囉。

完成的程式碼讀者可至 https://github.com/kurotanshi/mask-map-demo/tree/composition-demo (opens new window)取得。

# Composition API 是否可以取代 Vuex?

那麼回到前面的主題,從前面改寫的幾個範例我們可以得知,由於 Composition API 已經可以將跨元件共用的狀態作爲模組抽取出來, 透過 import 引入至所需的元件,再使用 provideinject 將共用的狀態、程式邏輯導入到共用的子元件中。

使用 Composition API 之後,我們無需再去煩惱什麼時候該 dispatch,什麼時候該去 commits,而且除了狀態本身之外, 甚至連操作的函式也能夠一起被導出,來達到跨元件調用方法的效果,這是過去在 Vuex 所沒辦法做到的事。

那麼,我們是否還有繼續使用 Vuex 的理由? 我的建議是看情況使用,這不是幹話。

雖然 Composition API 可以做到很多 Vuex 可以做到的事,但是 Vuex 的優勢在於:

  1. Vuex 它可以同時相容於 Options API 與 Composition API 兩者。
  2. Vuex 與 Vue Devtools 的高度整合,我們可以透過除錯工具來觀察,狀態是在什麼時候被更新,而 Composition API 裡面我們只能夠自己寫 watchwatchEffect 來追蹤狀態的變化。 而且若是在多頁應用的情況下,Vuex 還有 vuex-persistedstate 可以協助將狀態存入 localStorage,改用 Composition API 在現階段就只能自己來處理。
  3. Vuex 的發展時間比起 Composition API 要來得長,發展出來的相關套件自然少不了,像是 vuex-i18n、vuex-persistedstate 等等,如果本來已經在用了,就沒必要硬要把它們捨棄。
  4. 對 Vuex 而言,配合 Vue 3.0 所推出的 v4 只是過渡期版本,在下一個版本 Vuex v5 對 Composition API 的整合更好,而且更重要的是對於Vue.js 在伺服器端渲染 (SSR) 來說,使用 Vuex 會有更大的優勢。

所以說,如果是從 Vue 3.0 開始建立起的新專案,而專案開發成員團隊也都採用 Composition API 進行開發,那當然不一定需要 Vuex。 但若是本來就已經在用 Vuex 的朋友,也無需為了 Composition API 而特意捨棄 Vuex,畢竟兩者並不衝突,並不是說用了這個就不能用那個。

備註

關於官方團隊對於 Vuex v5 的想法,可以參考 Kia King (Vuex 的貢獻者) 在 Vuejs Amsterdam 2020 大會的演講錄影:「Kia King // The State of VueX at Vuejs Global」 ( https://www.youtube.com/watch?v=ajGglyQQD0k )

Last Updated: 1/11/2021, 5:59:46 PM