# 6-2 Composition API 的核心
在前一個小節的最後,我們透過一個簡單的範例來示範 Composition API 如何將不同的邏輯拆分,並在需要的時候組合起來。 那麼在這個小節裡面,我們就繼續來詳細介紹 Composition API 所提供的各種功能。
Composition API 與 Options API 最大的差別,就是在元件的實體物件內已經不會再有 data
、 compited
、 methods
與生命週期 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>
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
};
}
2
3
4
5
6
7
8
9
10
11
12
13
# props
與 context
另外,當 setup
函式在元件啟動被調用時,會帶入兩個參數: props
與 context
。
props
相信大家已經很熟悉了,沒錯,就是我們曾在第二章介紹過的 Props
屬性。
由於 setup
函式已經不再使用 this
來存取元件內的各種屬性,
所以我們可以透過 setup
函式提供的 props
物件來取得定義在 props
的內容:
export default {
props: {
defaultNum: {
type: Number,
default: 0
}
},
// 注意要加上 props
setup(props, context) {
// 透過 prop 物件取得對應的屬性
console.log(props.defaultNum);
},
}
2
3
4
5
6
7
8
9
10
11
12
13
14
而另一個 context
物件則是提供了 attrs
、slots
以及 emit
三種屬性,分別對應到元件的實體物件上。
如果我們希望在 setup
函式內取得屬性、slots
或者觸發事件的 emit
時,就可以透過 context.attrs
、 context.slots
及 context.emit
來使用它們。
# ref
與 reactive
在 Composition API 裡面,我們可以使用 ref()
這個函式來將數值進行包裝,並且回傳一個響應式的「物件」。
這個物件裡頭,會提供一個 value
的屬性,我們可以透過這個 value
來改變這個狀態內的數值:
const count = ref(0);
console.log(count.value); // 0
count.value++;
console.log(count.value); // 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>
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>
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-for
與 ref
複數動態節點
另外,當我們的 DOM 節點是透過 v-for
指令動態產生時,又需要使用 ref
綁定模板,那麼就可以透過 v-bind:ref
的方式綁定到某個陣列:
<div v-for="(item, i) in list" :ref="el => { divs[i] = el }">
{{ item }}
</div>
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,
};
},
};
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>
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 所代理過的物件,這樣才能做到響應式的更新。
而 reactive
與 ref
最大的不同是, reactive
內的參數只能是「物件」,而 ref
的參數可以是物件也可以是原始型別的數值,但無論包裝的內容為何,在透過 JavaScript 操作的時候都必須透過 .value
來存取內容,而由 reactive()
包裝後的物件,在存取內部屬性的時候,不用在後面加上 .value
。
# toRefs
與 reactive
但是在使用 reactive()
的時候,有個地方讀者們需要特別注意,假設我們有個 counter.js
:
// counter.js
import { reactive } from "vue";
export default () => {
const count = reactive({
amount: 0,
increase: () => count.amount++
});
return count;
};
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>
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>
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>
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>
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>
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
函式之外,也可以傳入物件來分別指定 get
與 set
函式:
const count = ref(0);
const plusOne = computed({
get: () => count.value + 1,
set: (val) => {
count.value = val - 1
},
});
2
3
4
5
6
7
8
這裏的 get
與 set
的功能,與我們在 1-3 小節曾介紹過的 get
與 set
函式作用一模一樣,分別用來處理這個 computed
屬性的讀與寫,這裡就不再贅述。
# readonly
我們可以傳入一個物件 (無論是否為響應式物件) 到 readonly()
函式內,此時 readonly()
會回傳一個被代理過的「唯讀」物件:
setup () {
const original = reactive({ count: 0 });
const copy = readonly(original);
const plus = () => original.count++;
return {
original,
copy,
plus,
};
},
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
};
}
};
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}`); }
)
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}`);
}
);
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}`);
});
2
3
4
5
6
7
如果觀察的是一個物件內的屬性是否被更新,則可以在 watch()
加入第三個參數: { deep: true }
:
const data = reactive({
obj: {
count: 0
}
});
// 觀察物件內的屬性更新
watch(data.obj, (val, oldVal) => { /* 略 */ }, { deep: true });
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,
};
},
}
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,
};
},
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
那麼當 plus
執行時,是不會觸發 watchEffect
的,因為 count2.value
並未被更新。
也因為這個特性,所以如果我們需要針對 count
與 count2
兩者更新時進行不同的行為,
建議拆分多組 watchEffect()
會是比較好的作法,也不會互相干擾:
watchEffect(() => {
console.log("watchEffect", count.value);
});
watchEffect(() => {
console.log("watchEffect", count2.value);
});
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();
2
3
4
5
6
7
8
9
10
# 相依性注入 (Dependency Injection)
這裏與我們曾在本書 2-2 小節介紹過的 provide
與 inject
一樣,在 Composition API 內也提供了類似的功能,這裏透過一個簡單的範例來做解說。
// store.js
export default {
todoList: Symbol()
};
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,
};
},
};
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,
};
},
};
2
3
4
5
6
7
8
9
10
11
12
13
14
像這樣,我們在 Composition API 裡,也可以使用 provide()
將指定的資料輸出,
當另一個元件需要取得時,再透過 inject()
取出,這樣這兩個元件的 todoList
就可以達到狀態同步的效果了。
要特別注意的是, provide
與 inject
兩者都只能在現存元件中使用。
換句話說,若元件被銷毀時,provide
與 inject
的連結就會失效。
# 生命週期 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!');
});
},
}
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
,
而 beforeCreate
與 created
則是被 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-setup
與 ref-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>
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>
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>
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
}
}
}
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... 過去的工具也許在未來會換個形式被納入變成標準也說不定。