前段時間,Vue 官方釋出了 Composition API RFC 的文檔,我也在收到消息的第一時間上手嚐鮮。
雖然 Vue 3.0 尚未發佈,但是其處於 RFC 階段的 Composition API 已經可以通過插件 @vue/composition-api 進行體驗了。接下來的內容我將以構建一個 TODO LIST 應用來體驗 Composition API 的用法。
本文示例的代碼:https://github.com/jrainlau/v...
一、Vue 2.x 方式構建應用。
這個 TODO LIST 應用非常簡單,僅有一個輸入框、一個狀態切換器、以及 TODO 列表構成:
大家也可以在這裏體驗。
藉助 vue-cli
初始化項目以後,我們的項目結構如下(僅討論 /src
目錄):
.
├── App.vue
├── components
│ ├── Inputer.vue
│ ├── Status.vue
│ └── TodoList.vue
└── main.js
從 /components
裏文件的命名不難發現,三個組件對應了 TODO LIST 應用的輸入框、狀態切換器,以及 TODO 列表。這三個組件的代碼都非常簡單就不展開討論了,此處只討論核心的 App.vue
的邏輯。
App.vue
<template>
<div class="main">
<Inputer @submit="submit" />
<Status @change="onStatusChanged" />
<TodoList
:list="onShowList"
@toggle="toggleStatus"
@delete="onItemDelete"
/>
</div>
</template>
<script>
import Inputer from './components/Inputer'
import TodoList from './components/TodoList'
import Status from './components/Status'
export default {
components: {
Status,
Inputer,
TodoList
},
data () {
return {
todoList: [],
showingStatus: 'all'
}
},
computed: {
onShowList () {
if (this.showingStatus === 'all') {
return this.todoList
} else if (this.showingStatus === 'completed') {
return this.todoList.filter(({ completed }) => completed)
} else if (this.showingStatus === 'uncompleted') {
return this.todoList.filter(({ completed }) => !completed)
}
}
},
methods: {
submit (content) {
this.todoList.push({
completed: false,
content,
id: parseInt(Math.random(0, 1) * 100000)
})
},
onStatusChanged (status) {
this.showingStatus = status
},
toggleStatus ({ isChecked, id }) {
this.todoList.forEach(item => {
if (item.id === id) {
item.completed = isChecked
}
})
},
onItemDelete (id) {
let index = 0
this.todoList.forEach((item, i) => {
if (item.id === id) {
index = i
}
})
this.todoList.splice(index, 1)
}
}
}
</script>
在上述的代碼邏輯中,我們使用 todoList
數組存放列表數據,用 onShowList
根據狀態條件 showingStatus
的不同而展示不同的列表。在 methods
對象中定義了添加項目、切換項目狀態、刪除項目的方法。總體來說還是非常直觀簡單的。
按照 Vue 的官方說法,2.x 的寫法屬於 Options-API 風格,是基於配置的方式聲明邏輯的。而接下來我們將使用 Composition-API 風格重構上面的邏輯。
二、使用 Composition-API 風格重構邏輯
下載了 @vue/composition-api
插件以後,按照文檔在 main.js
引用便開啓了 Composition API 的能力。
main.js
import Vue from 'vue'
import App from './App.vue'
import VueCompositionApi from '@vue/composition-api'
Vue.config.productionTip = false
Vue.use(VueCompositionApi)
new Vue({
render: h => h(App),
}).$mount('#app')
回到 App.vue
,從 @vue/composition-api
插件引入 { reactive, computed, toRefs }
三個函數:
import { reactive, computed, toRefs } from '@vue/composition-api'
僅保留 components: { ... }
選項,刪除其他的,然後寫入 setup()
函數:
export default {
components: { ... },
setup () {}
}
接下來,我們將會在 setup()
函數裏面重寫之前的邏輯。
首先定義數據。
爲了讓數據具備“響應式”的能力,我們需要使用 reactive()
或者 ref()
函數來對其進行包裝,關於這兩個函數的差異,會在後續的章節裏面闡述,現在我們先使用 reactive()
來進行。
在 setup()
函數裏,我們定義一個響應式的 data
對象,類似於 2.x 風格下的 data()
配置項。
setup () {
const data = reactive({
todoList: [],
showingStatus: 'all',
onShowList: computed(() => {
if (data.showingStatus === 'all') {
return data.todoList
} else if (data.showingStatus === 'completed') {
return data.todoList.filter(({ completed }) => completed)
} else if (data.showingStatus === 'uncompleted') {
return data.todoList.filter(({ completed }) => !completed)
}
})
})
}
其中計算屬性 onShowList
經過了 computed()
函數的包裝,使得它可以根據其依賴的數據的變化而變化。
接下來定義方法。
在 setup()
函數裏面,對之前的幾個操作選項的方法稍加修改即可直接使用:
function submit (content) {
data.todoList.push({
completed: false,
content,
id: parseInt(Math.random(0, 1) * 100000)
})
}
function onStatusChanged (status) {
data.showingStatus = status
}
function toggleStatus ({ isChecked, id }) {
data.todoList.forEach(item => {
if (item.id === id) {
item.completed = isChecked
}
})
}
function onItemDelete (id) {
let index = 0
data.todoList.forEach((item, i) => {
if (item.id === id) {
index = i
}
})
data.todoList.splice(index, 1)
}
與在 methods: {}
對象中定義的形式所不同的地方是,在 setup()
裏的方法不能通過 this
來訪問實例上的數據,而是通過直接讀取 data
來訪問。
最後,把剛剛定義好的數據和方法都返回出去即可:
return {
...toRefs(data),
submit,
onStatusChanged,
toggleStatus,
onItemDelete,
}
這裏使用了 toRefs()
給 data
對象包裝了一下,是爲了讓它的數據保持“響應式”的,這裏面的原委會在後續章節展開。
重構完成後,發現其運行的結果和之前的完全一致,證明 Composition API 是可以正確運行的。接下來我們來聊聊 reactive()
和 ref()
的問題。
三、響應式數據
我們知道 Vue 的其中一個賣點,就是其強大的響應式系統。無論是哪個版本,這個核心功能都貫穿始終。而說到響應式系統,往往離不開響應式數據,這也是被大家所津津樂道的話題。
回顧一下,在2.x版本中 Vue 使用了 Object.defineProperty()
方法改寫了一個對象,在它的 getter 和 setter 裏面埋入了響應式系統相關的邏輯,使得一個對象被修改時能夠觸發對應的邏輯。在即將到來的 3.0 版本中,Vue 將會使用 Proxy
來完成這裏的功能。爲了體驗所謂的“響應式對象”,我們可以直接通過 Vue 提供的一個 API Vue.observable()
來實現:
const state = Vue.observable({ count: 0 })
const Demo = {
render(h) {
return h('button', {
on: { click: () => { state.count++ }}
}, `count is: ${state.count}`)
}
}
上述代碼引用自官方文檔
從代碼可以看出,通過 Vue.observable()
封裝的 state
,已經具備了響應式的特性,當按鈕被點擊的時候,它裏面的 count
值會改變,改變的同時會引起視圖層的更新。
回到 Composition API,它的 reactive()
和 ref()
函數也是爲了實現類似的功能,而 @vue/composition-api
插件的核心也是來自 Vue.observable()
:
function observe<T>(obj: T): T {
const Vue = getCurrentVue();
let observed: T;
if (Vue.observable) {
observed = Vue.observable(obj);
} else {
const vm = createComponentInstance(Vue, {
data: {
$$state: obj,
},
});
observed = vm._data.$$state;
}
return observed;
}
節選自插件源碼
在理解了 reactive()
和 ref()
的目的之後,我們就可以去分析它們的區別了。
首先我們來看兩段代碼:
// style 1: separate variables
let x = 0
let y = 0
function updatePosition(e) {
x = e.pageX
y = e.pageY
}
// --- compared to ---
// style 2: single object
const pos = {
x: 0,
y: 0
}
function updatePosition(e) {
pos.x = e.pageX
pos.y = e.pageY
}
假設 x
和 y
都是需要具備“響應式”能力的數據,那麼 ref()
就相當於第一種風格,單獨地爲某個數據提供響應式能力;而 reactive()
則相當於第二種風格,給一整個對象賦予響應式能力。
但是在具體的用法上,通過 reactive()
包裝的對象會有一個坑。如果想要保持對象內容的響應式能力,在 return 的時候必須把整個 reactive()
對象返回出去,同時在引用的時候也必須對整個對象進行引用而無法解構,否則這個對象內容的響應式能力將會丟失。這麼說起來有點繞,可以看看官網的例子加深理解:
// composition function
function useMousePosition() {
const pos = reactive({
x: 0,
y: 0
})
// ...
return pos
}
// consuming component
export default {
setup() {
// reactivity lost!
const { x, y } = useMousePosition()
return {
x,
y
}
// reactivity lost!
return {
...useMousePosition()
}
// this is the only way to retain reactivity.
// you must return `pos` as-is and reference x and y as `pos.x` and `pos.y`
// in the template.
return {
pos: useMousePosition()
}
}
}
舉一個不太恰當的例子。“對象的特性”是賦予給整個“對象”的,它裏面的內容如果也想要擁有這部分特性,只能和這個對象捆綁在一塊,而不能單獨拎出來。
但是在具體的業務中,如果無法使用解構取出 reactive()
對象的值,每次都需要通過 .
操作符訪問它裏面的屬性會是非常麻煩的,所以官方提供了 toRefs()
函數來爲我們填好這個坑。只要使用 toRefs()
把 reactive()
對象包裝一下,就能夠通過解構單獨使用它裏面的內容了,而此時的內容也依然維持着響應式的特性。
至於何時使用 reactive()
和 ref()
,都是按照具體的業務邏輯來選擇。對於我個人來說,會更傾向於使用 reactive()
搭配 toRefs()
來使用,因爲經過 ref()
封裝的數據必須通過 .value
才能訪問到裏面的值,寫法上要注意的地方相對更多一些。
四、Composition API 的優勢及擴展
Vue 其中一個被人詬病得很嚴重的問題就是邏輯複用。隨着項目越發的複雜,可以抽象出來被複用的邏輯也越發的多。但是 Vue 在 2.x 階段只能通過 mixins 來解決(當然也可以非常繞地實現 HOC,這裏不再展開)。mixins 只是簡單地把代碼邏輯進行合併,如果需要對邏輯進行追蹤將會是一個非常痛苦的過程,因爲繁雜的業務邏輯裏面往往很難一眼看出哪些數據或方法是來自 mixins 的,哪些又是來自當前組件的。
另外一點則是對 TypsScript 的支持。爲了更好地進行類型推斷,雖然 2.x 也有使用 Class 風格的 ts 實現方案,但其冗長繁雜和依賴不穩定的 decorator 的寫法,並非一個好的解決方案。受到 React Hooks 的啓發,Vue Composition API 以函數組合的方式完成邏輯,天生就適合搭配 TypeScript 使用。
至於 Options API 和 Composition API 孰優孰劣的問題,在本文所展示的例子中其實是比較難區分的,原因是這個例子的邏輯實在是太過簡單。但是如果深入思考的話不難發現,如果項目足夠複雜,Composition API 能夠很好地把邏輯抽離出來,每個組件的 setup()
函數所返回的值都能夠方便地被追蹤(比如在 VSCode 裏按着 cmd 點擊變量名即可跳轉到其定義的地方)。這樣的能力在維護大型項目或者多人協作項目的時候會非常有用,通用的邏輯也可以更細粒度地共享出去。
關於 Composition API 的設計理念和優勢可以參考官網的 Motivation 章節。
如果腦洞再開大一點,Composition API 可能還有更酷的玩法。
- 對於一些第三方組件庫(如 element-ui),除了可以提供包含了樣式、結構和邏輯的組件之外,還可以把部分邏輯以 Composition API 的方式提供出來,其可定製化和玩法將會更加豐富。
-
reactive()
方法可以把一個對象變得響應式,搭配watch()
方法可以很方便地處理 side effects:import { reactive, watch } from 'vue' const state = reactive({ count: 0 }) watch(() => { document.body.innerHTML = `count is ${state.count}` })
上述例子中,當響應式的
state.count
被修改以後,會觸發watch()
函數裏面的回調。基於此,也許我們可以利用這個特性去處理其他平臺的視圖更新問題。微信小程序開發框架 mpvue 就是通過魔改 Vue 的源碼來實現小程序視圖的數據綁定及更新的,如果擁有了 Composition API,也許我們就可以通過reactive()
和watch()
等方法來實現類似的功能,此時 Vue 將會是位於數據和視圖中間的一層,數據的綁定放在reactive()
,而視圖的更新則統一放在watch()
當中進行。
五、小結
本文通過一個 TODO LIST 應用,按照官網的指導完成一次對 Composition API 的嚐鮮式探索,學習了新的 API 的用法並討論了當中的一些設計理念,分析了當中的一些問題,最後腦洞大開對立面的用法進行了探索。由於相關資料較少且 Composition API 仍在 RFC 階段,所以文章當中可能會有難以避免的謬誤,如果有任何的意見和看法都歡迎和我交流。