# 6-2 Composition API 的核心

在前一個小節的最後,我們透過一個簡單的範例來示範 Composition API 如何將不同的邏輯拆分,並在需要的時候組合起來。 那麼在這個小節裡面,我們就繼續來詳細介紹 Composition API 所提供的各種功能。

Composition API 與 Options API 最大的差別,就是在元件的實體物件內已經不會再有 datacompitedmethods 與生命週期 Hooks... 等等屬性 (雖說兩者並存時,也許可以正常運作但是千、萬、禁、止)。

也就是說,原本我們透過 「this」 來存取的所有屬性,到了 Composition API 的語法後通通沒有了。

既然沒有 this 可以使用,但元件功能依然要有,程式還是要寫,取而代之的就是新的 setup() 函式:

<!-- count.vue -->
<template>
  <button @click="increment">
    Count is: {{ count }}, double is: {{ double }}
  </button>
</template>

<script>
  import { ref, computed, onMounted } from 'vue';

  export default {
    // 元件的入口與啟動點從這裡開始
    setup(prop, context) {
      const count = ref(0);
      const double = computed(() => count.value * 2);

      const increment = () => {
        count.value++;
      }

      // lifecycle hooks
      onMounted(() => {
        console.log('This component is mounted now!');
      });

      // 模板會用到的東西要在這裡 return 出去
      return {
        count,
        double,
        increment
      }
    },
  }
</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

上面範例是一個透過 setup 建立的簡單 Vue 元件檔 ,實際在網頁會渲染出一個 <button> 的按鈕。 對 Composition API 不熟悉的讀者看不懂是正常的,那麼以下就來介紹它的結構。

小提醒

這裡我假設讀者對 Vue Options API 已經有一定程度的理解了, 若讀者對元件屬性有不熟悉的地方,請務必先詳讀本書第一章【Vue.js 基礎入門】與第二章【Vue.js 元件系統】的內容。

# setup - 啟動元件的位置

不同於過去在 Options API 使用的語法結構,Vue Composition API 啟動元件的位置就在這個 setup() 函式裡面。 在 setup() 這個函式通常會包含生命週期鉤子 (Lifecycle Hooks) 函式,以及元件的相關狀態等, 但元件內的程式邏輯與狀態「不一定」要在這裡面定義,可以透過 import 的方式從外部檔案引進。

需要注意的是,在 setup() 函式的最後,必須要把給模板解析的內容 (包含狀態與事件處理方法等) 透過 return 回傳出去。

setup() {
  // 將程式邏輯定義在 todoList() 內
  // 增加可讀性外,也減少 setup() 變成義大利麵程式碼的機會
  const { todo, items, add, remove } = todoList();

  // 將模板會用到的部分 return 出去
  return {
    todo,
    items,
    add,
    remove
  };
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# propscontext

另外,當 setup 函式在元件啟動被調用時,會帶入兩個參數: propscontext

props 相信大家已經很熟悉了,沒錯,就是我們曾在第二章介紹過的 Props 屬性。 由於 setup 函式已經不再使用 this 來存取元件內的各種屬性, 所以我們可以透過 setup 函式提供的 props 物件來取得定義在 props 的內容:

export default {
  props: {
    defaultNum: {
      type: Number,
      default: 0
    }
  },
  // 注意要加上 props
  setup(props, context) {

    // 透過 prop 物件取得對應的屬性
    console.log(props.defaultNum);
  },
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

而另一個 context 物件則是提供了 attrsslots 以及 emit 三種屬性,分別對應到元件的實體物件上。

如果我們希望在 setup 函式內取得屬性、slots 或者觸發事件的 emit 時,就可以透過 context.attrscontext.slotscontext.emit 來使用它們。

# refreactive

在 Composition API 裡面,我們可以使用 ref() 這個函式來將數值進行包裝,並且回傳一個響應式的「物件」。 這個物件裡頭,會提供一個 value 的屬性,我們可以透過這個 value 來改變這個狀態內的數值:

const count = ref(0);
console.log(count.value); // 0

count.value++;
console.log(count.value); // 1
1
2
3
4
5

ref() 函式除了可以用來包裝原始型別 (Primitive) 的數值外,也可以用來包裝物件或者陣列。

當我們將 ref() 所包裝後的物件,透過 setup() return 出去之後,元件的模板 <template> 就可以觀察到這個數值的變化。 而且在模板裡面存取它,我們無需加上 .value,就如同過去我們在模板裡無需加上 this 一樣。

<template>
  <h1>{{ count }}</h1>
  <button @click="plus">Plus</button>
</template>

<script>
  // 在 SFC 使用 Composition API 提供的方法,必須先透過 import 引入
  // 這樣在打包程式碼時,有助於檔案大小的最佳化
  import { ref } from 'vue';

  export default {
    setup () {      
      const count = ref(0);
      const plus = () => count.value++;

      return {
        // 透過 ref() 包裝的數值可保有響應性
        count,
        plus,
        // 純數值在模板中同樣可以渲染,但不會有響應性追蹤
        nonReactiveCount: 0
      };
    }
  }
</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

# ref 與 DOM

由於 Composition API 的 setup() 已無 this.$ref 可用,所以當我們要存取元件內 DOM 的元素時, 同樣可以利用這裡所介紹的 ref() 來做到:

<template>
  <!-- 模板內同樣要加上 ref 屬性 -->
  <div ref="root"></div>
</template>

<script>
  import { ref, onMounted } from 'vue'

  export default {
    setup() {
      // 這裏的 root 與 <div ref="root"> 配對
      const root = ref(null);

      // 當元件掛載完成後,
      // 就可以透過 root.value 取得實際的 DOM 元素
      onMounted(() => {
        // <div/>
        console.log(root.value);
      })

      // 記得 return 出去
      return {
        root,
      }
    },
  }
</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

像這樣,我們在 setup() 函式裡面使用 const root = ref(null); 再將 root 透過 return 綁定到模板中。 在模板 HTML 所對應的 DOM 節點,則是需要加上 ref="root" 的同名屬性。

# v-forref 複數動態節點

另外,當我們的 DOM 節點是透過 v-for 指令動態產生時,又需要使用 ref 綁定模板,那麼就可以透過 v-bind:ref 的方式綁定到某個陣列:

<div v-for="(item, i) in list" :ref="el => { divs[i] = el }">
  {{ item }}
</div>
1
2
3

像這樣,我們就可以指定 divs[0]divs[1]divs[2] 到對應的 <div> 節點了:

export default {
  setup() {
    const list = reactive([1, 2, 3]);
    const divs = ref([]);

    // 確保在每次更新前重置 divs
    onBeforeUpdate(() => {
      divs.value = [];
    })

    return {
      list,
      divs,
    };
  },
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

不過要小心的是,由於節點是動態產生,divs 有可能因為原本的 list 變動而改變, 所以需在 onBeforeUpdate() 鉤子函式內進行重置的動作,以確保我們的 divs[x] 取得正確的網頁節點。

# reactive

而除了 ref() 之外,另外還有一個可以用來包裝響應式物件的 reactive() 函式也常常被拿來與 ref 做比較,這裏我們也來個例子給讀者參考:

<template>
  <h1>{{ data.count }}</h1>
  <button @click="data.plus">Plus</button>
</template>

<script>
import { reactive } from "vue";

export default {
  setup() {
    
    // reactive 包裝物件
    const data = reactive({
      count: 0,
      plus: () => data.count++
    });

    return { 
      data 
    };
  }
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

reactive() 函式會回傳一個被 ES6 Proxy 所代理過的物件,這樣才能做到響應式的更新。

reactiveref 最大的不同是, reactive 內的參數只能是「物件」,而 ref 的參數可以是物件也可以是原始型別的數值,但無論包裝的內容為何,在透過 JavaScript 操作的時候都必須透過 .value 來存取內容,而由 reactive() 包裝後的物件,在存取內部屬性的時候,不用在後面加上 .value

# toRefsreactive

但是在使用 reactive() 的時候,有個地方讀者們需要特別注意,假設我們有個 counter.js

// counter.js
import { reactive } from "vue";

export default () => {
  const count = reactive({
    amount: 0,
    increase: () => count.amount++
  });

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

若我們在另一個元件檔案中,使用 import 將它引入,並且將這個 count() 回傳的物件 return 到模板裡:

<template>
  <h1>{{ count.amount }}</h1>
  <button @click="count.increase">Click</button>
</template>

<script>
import counter from "./counter";

export default {
  setup() {
    const count = counter();

    return {
      count
    };
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

這時候的執行結果是沒有問題的。

不過,若是我們想要使用 ES6 的展開運算子 ... 來將 count 解構,像這樣:

<template>
  <h1>{{ amount }}</h1>
  <button @click="increase">Click</button>
</template>

<script>
import counter from "./counter";

export default {
  setup() {
    const count = counter();

    return {
      ...count
    };
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

乍看之下好像沒有問題,但這時各位會發現一件事,我們嘗試去點擊 <button> 的時候,數字卻不會增加了。

為什麼呢? 這是由於 ES6 的解構語法,會將 count 物件內的數值從透過 reactive() 包裝後的響應式狀態抽離出來變成普通的數值, 於是當我們嘗試去更新裡面 amount 的時候,Vue.js 就再也無法追蹤數值的更新。

若想要解決這個問題,可以透過 toRefs() 來幫忙:

<template>
  <h1>{{ amount }}</h1>
  <button @click="increase">Click</button>
</template>

<script>
import { toRefs } from "vue";
import counter from "./counter";

export default {
  setup() {
    const count = counter();

    return {
      ...toRefs(count)
    };
  },
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

像這樣,我們需要在 setup() 函式 return 數值的時候,改為 ...toRefs(count), 這樣就可以將被解構的數值再次獲得響應式的能力,同時 Vue.js 也可以繼續追蹤它們的更新了。

# computed

Composition API 裡的 computed 與我們第一章介紹過的 computed 功能完全一樣,只是改成函數式寫法:

<template>
  <h1>{{ count }}</h1>
  <h1>{{ doubleCount }}</h1>
  <h1>{{ quadrupleCount }}</h1>

  <button @click="plus">Plus</button>
</template>

<script>
import { ref, computed } from "vue";

export default {
  setup() {
    const count = ref(0);
    
    // computed 的參數為一個 getter 函式,並回傳一個 ref 物件
    const doubleCount = computed(() => count.value * 2);
    
    // 使用 computed 回傳的內容依然要加上 .value
    const quadrupleCount = computed(() => doubleCount.value * 2);
    const plus = () => count.value++;

    return { 
      count,
      doubleCount,
      quadrupleCount,
      plus
    };
  }
};
</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

computed() 參數為一個 getter 函式,並回傳一個 ref 物件。 換句話說,我們在 JavaScript 區塊內存取 computed 的值的時候,依然要透過 .value 來存取內部數值。

讀者如果喜歡前面介紹的 reactive 語法,我們也可以這樣將 computed 包裝進去 reactive 的物件:

<template>
  <h1>{{ data.count }}</h1>
  <h1>{{ data.doubleCount }}</h1>
  <h1>{{ data.quadrupleCount }}</h1>

  <button @click="data.plus">Plus</button>
</template>

<script>
import { reactive, computed } from "vue";

export default {
  setup() {

    const data = reactive({
      count: 0,
      doubleCount: computed(() => data.count * 2),
      quadrupleCount: computed(() => data.doubleCount * 2),
      plus: () => data.count++
    });

    return {
      data
    };
  }
};
</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

像這樣,使用 reactive 包裝後的 computed 屬性,存取時同樣無需加上 .value

# get 與 set

computed() 除了基本傳入 getter 函式之外,也可以傳入物件來分別指定 getset 函式:

const count = ref(0);

const plusOne = computed({
  get: () => count.value + 1,
  set: (val) => {
    count.value = val - 1
  },
});
1
2
3
4
5
6
7
8

這裏的 getset 的功能,與我們在 1-3 小節曾介紹過的 getset 函式作用一模一樣,分別用來處理這個 computed 屬性的讀與寫,這裡就不再贅述。

# readonly

我們可以傳入一個物件 (無論是否為響應式物件) 到 readonly() 函式內,此時 readonly() 會回傳一個被代理過的「唯讀」物件:

setup () {
  const original = reactive({ count: 0 });
  const copy = readonly(original);
  const plus = () => original.count++;

  return {
    original,
    copy,
    plus,
  };
},
1
2
3
4
5
6
7
8
9
10
11

以上面的範例來說,當我們執行 plus() 後, original.count 會從 0 變成 1。 而透過 readonly() 包裝後回傳的 copy 物件,其內部的 .count 也會變成 1

但是,由 readonly() 所回傳的新物件,我們無法修改它 (包含它底下的屬性),作用類似 Object.freeze(),但又會隨著原始物件的修改而更新。所以如果我們不希望某個被 export 的響應式物件被修改的話,就可以在 export 之前加上 readonly() 包裝。

# methods

在 Composition API 裡面不再提供 methods 屬性,我們可以直接將寫好的函式透過 setup() 回傳出去給模板即可。 若在模板內不需要用到的函式,甚至可以不用 return 出去。

# watch 與 watchEffect

在 Composition API 內的 watch() 與過去在 Options API 的 watch 作用完全一樣。 與前面的 computed 一樣,需要改為函數式的語法:

import { ref, computed, watch } from "vue";

export default {
  setup() {
    const count = ref(0);
    const doubleCount = computed(() => count.value * 2);
    const quadrupleCount = computed(() => doubleCount.value * 2);
    
    const plus = () => count.value++;

    // 觀察單一 ref 物件
    watch(count, (val, oldVal) => {
      console.log(`new count is ${val}, prevCount is ${oldVal}`);
    });

    return { 
      count,
      doubleCount,
      quadrupleCount,
      plus
    };
  }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

另外,如果希望透過 watch 觀察 reactive 物件內的某個屬性的話,則可以這樣:

const data = reactive({
  count: 0,
  doubleCount: computed(() => data.count * 2),
  quadrupleCount: computed(() => data.doubleCount * 2),
  plus: () => data.count++
});

watch(
  () => data.count,
  (val, oldVal) => { console.log(`new count is ${val}, prevCount is ${oldVal}`); }
)
1
2
3
4
5
6
7
8
9
10
11

將傳入 watch() 的第一個參數由原本的 count 改為 () => data.count 函式即可。

同時我們也可以透過陣列的方式,同時 watch 多個屬性,但是要注意的是,這些寫在同一個 watch 對應的 callback 是共用的:

watch(
  [() => data.count, () => data.doubleCount],
  ([newCount, newDoubleCount], [prevCount, prevDoubleCount]) => {
    console.log(`new count is ${newCount}, prevCount is ${prevCount}`);
    console.log(`new doubleCount is ${newDoubleCount}, prevDoubleCount is ${prevDoubleCount}`);
  }
);
1
2
3
4
5
6
7

如果想要分別對不同屬性的更新執行不同對應的動作,則可以分別寫兩個 watch()

watch(count, (val, oldVal) => {
  console.log(`new count is ${val}, prevCount is ${oldVal}`);
});

watch(doubleCount, (val, oldVal) => {
  console.log(`new doubleCount is ${val}, prevDoubleCount is ${oldVal}`);
});
1
2
3
4
5
6
7

如果觀察的是一個物件內的屬性是否被更新,則可以在 watch() 加入第三個參數: { deep: true }

const data = reactive({
  obj: {
    count: 0
  }
});

// 觀察物件內的屬性更新
watch(data.obj, (val, oldVal) => { /* 略 */ }, { deep: true });
1
2
3
4
5
6
7
8

# watchEffect

另外,還有一個作用與 watch 很像的東西 「watchEffect」,也是新手們容易搞混的地方:

export default {
  setup() {
    const count = ref(0);
    const doubleCount = computed(() => count.value * 2);
    const plus = () => count.value++;

    // 當 count.value 更新時調用 callback
    watchEffect(() => {
      console.log("watchEffect", count.value);
    });

    return {
      count,
      doubleCount,
      plus,
    };
  },
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

watch 不同的是, watchEffect() 裡的 callback 函式會在 setup 剛建立的時候就立即執行一次, 而 watchEffect 並不需要像 watch 那樣指定觀察的目標,而是當內部的 callback 函式對應的響應式數值更新後自動執行 (類似 computed),而且 watchEffect 無法取得更新前的數值。

換句話說,如果我們將 watchEffect 內改成:

export default {
  setup() {
    const count = ref(0);
    const count2 = ref(0);
    const plus = () => count.value++;

    watchEffect(() => {
      console.log("watchEffect", count2.value);
    });

    return {
      count,
      count2,
      plus,
    };
  },
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

那麼當 plus 執行時,是不會觸發 watchEffect 的,因為 count2.value 並未被更新。

也因為這個特性,所以如果我們需要針對 countcount2 兩者更新時進行不同的行為, 建議拆分多組 watchEffect() 會是比較好的作法,也不會互相干擾:

watchEffect(() => {
  console.log("watchEffect", count.value);
});

watchEffect(() => {
  console.log("watchEffect", count2.value);
});
1
2
3
4
5
6
7

# 解除 watchEffect 觀察

每一個 watchEffect() 在被調用之後都會回傳一個屬於它的停止函式。 如果想要停止某個 watchEffect() 的觀察時,我們可以直接呼叫該 watchEffect() 所回傳的函式來停止觀察:

const stop1 = watchEffect(() => {
  console.log("watchEffect", count.value);
});

const stop2 = watchEffect(() => {
  console.log("watchEffect", count2.value);
});

// 要停止觀察 count.value 時呼叫, count2 不受影響
stop1();
1
2
3
4
5
6
7
8
9
10

# 相依性注入 (Dependency Injection)

這裏與我們曾在本書 2-2 小節介紹過的 provideinject 一樣,在 Composition API 內也提供了類似的功能,這裏透過一個簡單的範例來做解說。

// store.js
export default {
  todoList: Symbol()
};
1
2
3
4
// 提供者元件
import { ref, provide } from "vue";
import store from "./store";

export default {
  setup() {
    const todoList = ref([]);

    // 將 todoList 透過 provide 指定到 store.todoList
    provide(store.todoList, todoList);

    return {
      todoList,
    };
  },
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 取用者元件
import { ref, inject } from "vue";
import store from "./store";

export default {
  setup() {
    // 透過 inject 取出 store.todoList
    const todoList = inject(store.todoList);

    return {
      todoList,
    };
  },
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14

像這樣,我們在 Composition API 裡,也可以使用 provide() 將指定的資料輸出, 當另一個元件需要取得時,再透過 inject() 取出,這樣這兩個元件的 todoList 就可以達到狀態同步的效果了。

要特別注意的是, provideinject 兩者都只能在現存元件中使用。 換句話說,若元件被銷毀時,provideinject 的連結就會失效。

# 生命週期 Hooks

在 Composition API 使用生命週期 Hooks 基本上與我們在 1-7 小節介紹過的一樣,只是改用了函數式的語法。

// 使用時同樣要先 import 進來
import { onMounted, onUpdated, onUnmounted } from 'vue';

const MyComponent = {
  setup() {

    onMounted(() => {
      console.log('mounted!');
    });

    onUpdated(() => {
      console.log('updated!');
    });

    onUnmounted(() => {
      console.log('unmounted!');
    });
  },
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

需要注意的是,這些生命週期 Hooks 都只能在 setup() 函式裡面註冊,而且無須透過 return 回傳出去。

絕大多數的 Hooks 名稱都只是在原有的名稱前加上 on,例如 beforeMount 變成 onBeforeMount, 而 beforeCreatecreated 則是被 setup() 所取代。 詳細的 Hook 名稱對照可參考本書 1-7 小節生命週期對照表。

# setup 與 ref 語法糖 (實驗階段)

Vue.js 作者在 2020/10/28 針對 Vue 3.0 的 setup()ref 分別提出了一個新的語法糖提案 (https://github.com/vuejs/rfcs/blob/script-setup/active-rfcs/0000-script-setup.md (opens new window)) ,姑且稱它們叫 script-setupref-sugar

# script-setup 語法糖

script-setup 語法糖指的是將原本的 setup 改寫,我們以前面的範例來示範:

<script>
export default {
  setup() {
    const count = ref(0);
    const doubleCount = computed(() => count.value * 2);
    const plus = () => count.value++;

    return { 
      count,
      doubleCount,
      plus
    };
  }
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

採用新的語法糖寫法,可改寫為:

<script setup>
import { ref } from 'vue';

const count = ref(0);
const doubleCount = computed(() => count.value * 2);
const plus = () => { count.value++ };
</script>
1
2
3
4
5
6
7

像這樣,直接在 <script> 標籤上面加上 setup 屬性,並且完全省略掉 return,程式碼超級簡潔。 由於還在實驗性階段,目前僅限定在 SFC 上可以使用,而且隨時可能會因為社群的意見進行修改。

# ref-sugar 語法糖

另一個則是 ref-sugar 語法糖:

<template>
  <button @click="inc">{{ count }}</button>
</template>

<script setup>
// 透過 label statement 語法宣告 ref 
ref: count = 1

function inc() {
  // 不需要 .value 即可直接操作變數
  count++
}

// 或是直接使用 $ 當作前綴來拿 ref
console.log($count.value)
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

這段程式碼實際等同於:

import { ref } from 'vue';

export default {
  setup() {
    const count = ref(1);

    function inc() {
      count.value++
    };

    console.log(count.value);

    return {
      count,
      inc
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

這個就明顯借鑒了 Svelte 的語法,同樣地這個語法糖目前也還沒正式被納進 Vue 3 的 API 中, 仍然是個正在討論中的議題,有人認爲這樣的語法脫離網頁標準太遠,同樣也有人覺得先前的 .value 太囉唆,開發體驗更為重要。

這幾年由工具、框架推動網頁標準的例子並不少見。 也許數年後再回頭來看,這些提案是否正式被納進 Vue.js 的 API,或者再更甚者成為網頁標準的一部分,當然也可能像 Vue Class API 那樣直接被廢棄,沒人能說得準。 會將這兩個還是實驗的特性納進本書裡,主要也是希望能做個紀錄,就好像以前 jQuery 的 Deferred、現在 ES 標準的 Promise、async/await... 過去的工具也許在未來會換個形式被納入變成標準也說不定。

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