前言
之前在學習 React Hooks 的過程中,看到一篇外網文章,通過 Hooks 來請求資料,並將這段邏輯抽象成一個新的 Hooks 給其他元件複用,我也在我的部落格裡翻譯了一下:《在 React Hooks 中如何請求資料?》,感興趣可以看看。雖然是去年的文章,在閱讀之後一下子就掌握了 Hooks 的使用方式,而且資料請求是在業務程式碼中很常用的邏輯。
Vue 3 已經發布一段時間了,其組合 API 多少有點 React Hooks 的影子在裡面,今天我也打算通過這種方式來學習下組合 API。
專案初始化
為了快速啟動一個 Vue 3 專案,我們直接使用當下最熱門的工具 Vite 來初始化專案。整個過程一氣呵成,行雲流水。
npm init vite-app vue3-app
複製程式碼
# 開啟生成的專案資料夾
cd vue3-app
# 安裝依賴
npm install
# 啟動專案
npm run dev
複製程式碼
我們開啟 App.vue
將生成的程式碼先刪掉。
組合 API 的入口
接下來我們將通過 Hacker News API 來獲取一些熱門文章,Hacker News API返回的資料結構如下:
{
"hits": [
{
"objectID": "24518295",
"title": "Vue.js 3",
"url": "http://github.com/vuejs/vue-next/releases/tag/v3.0.0",
},
{...},
{...},
]
}
複製程式碼
我們通過 ui > li
將新聞列表展示到介面上,新聞資料從 hits
遍歷中獲取。
<template>
<ul>
<li
v-for="item of hits"
:key="item.objectID"
>
<a :href="item.url">{{item.title}}</a>
</li>
</ul>
</template>
<script>
import { reactive } from 'vue'
export default {
setup() {
const state = reactive({
hits: []
})
return state
}
}
</script>
複製程式碼
在講解資料請求前,我看先看看 setup()
方法,組合 API 需要通過 setup()
方法來啟動,setup()
返回的資料可以在模板內使用,可以簡單理解為 Vue 2 裡面 data()
方法返回的資料,不同的是,返回的資料需要先經過 reactive()
方法進行包裹,將資料變成響應式。
組合 API 中請求資料
在 Vue 2 中,我們請求資料時,通常需要將發起請求的程式碼放到某個生命週期中(created
或 mounted
)。在 setup()
方法內,我們可以使用 Vue 3 提供的生命週期鉤子將請求放到特定生命週期內,關於生命週期鉤子方法與之前生命週期的對比如下:
可以看到,基本上就是在之前的方法名前加上了一個 on
,且並沒有提供 onCreated
的鉤子,因為在 setup()
內執行就相當於在 created
階段執行。下面我們在 mounted
階段來請求資料:
import { reactive, onMounted } from 'vue'
export default {
setup() {
const state = reactive({
hits: []
})
onMounted(async () => {
const data = await fetch(
'http://hn.algolia.com/api/v1/search?query=vue'
).then(rsp => rsp.json())
state.hits = data.hits
})
return state
}
}
複製程式碼
最後效果如下:
監聽資料變動
Hacker News 的查詢介面有一個 query 引數,前面的案例中,我們將這個引數固定了,現在我們通過響應式的資料來定義這個變數。
<template>
<input type="text" v-model="query" />
<ul>
<li
v-for="item of hits"
:key="item.objectID"
>
<a :href="item.url">{{item.title}}</a>
</li>
</ul>
</template>
<script>
import { reactive, onMounted } from 'vue'
export default {
setup() {
const state = reactive({
query: 'vue',
hits: []
})
onMounted((async () => {
const data = await fetch(
`http://hn.algolia.com/api/v1/search?query=${state.query}`
).then(rsp => rsp.json())
state.hits = data.hits
})
return state
}
}
</script>
複製程式碼
現在我們在輸入框修改,就能觸發 state.query
同步更新,但是並不會觸發 fetch 重新呼叫,所以我們需要通過 watchEffect()
來監聽響應資料的變化。
import { reactive, onMounted, watchEffect } from 'vue'
export default {
setup() {
const state = reactive({
query: 'vue',
hits: []
})
const fetchData = async (query) => {
const data = await fetch(
`http://hn.algolia.com/api/v1/search?query=${query}`
).then(rsp => rsp.json())
state.hits = data.hits
}
onMounted(() => {
fetchData(state.query)
watchEffect(() => {
fetchData(state.query)
})
})
return state
}
}
複製程式碼
由於 watchEffect()
首次呼叫的時候,其回撥就會執行一次,造成初始化時會請求兩次介面,所以我們需要把 onMounted
中的 fetchData
刪掉。
onMounted(() => {
- fetchData(state.query)
watchEffect(() => {
fetchData(state.query)
})
})
複製程式碼
watchEffect()
會監聽傳入函式內所有的響應式資料,一旦其中的某個資料發生變化,函式就會重新執行。如果要取消監聽,可以呼叫 watchEffect()
的返回值,它的返回值為一個函式。下面舉個例子:
const stop = watchEffect(() => {
if (state.query === 'vue3') {
// 當 query 為 vue3 時,停止監聽
stop()
}
fetchData(state.query)
})
複製程式碼
當我們在輸入框輸入 "vue3"
後,就不會再發起請求了。
返回事件方法
現在有個問題就是 input 內的值每次修改都會觸發一次請求,我們可以增加一個按鈕,點選按鈕後再觸發 state.query
的更新。
<template>
<input type="text" v-model="input" />
<button @click="setQuery">搜尋</button>
<ul>
<li
v-for="item of hits"
:key="item.objectID"
>
<a :href="item.url">{{item.title}}</a>
</li>
</ul>
</template>
<script>
import { reactive, onMounted, watchEffect } from 'vue'
export default {
setup() {
const state = reactive({
input: 'vue',
query: 'vue',
hits: []
})
const fetchData = async (query) => {
const data = await fetch(
`http://hn.algolia.com/api/v1/search?query=${query}`
).then(rsp => rsp.json())
state.hits = data.hits
}
onMounted(() => {
watchEffect(() => {
fetchData(state.query)
})
})
const setQuery = () => {
state.query = state.input
}
return { setQuery, state }
}
}
</script>
複製程式碼
可以注意到 button 繫結的 click 事件的方法,也是通過 setup()
方法返回的,我們可以將 setup()
方法返回值理解為 Vue2 中 data()
方法和 methods
物件的合併。
原先的返回值 state 變成了現在返回值的一個屬性,所以我們在模板層取資料的時候,需要進行一些修改,在前面加上 state.
。
<template>
<input type="text" v-model="state.input" />
<button @click="setQuery">搜尋</button>
<ul>
<li
v-for="item of state.hits"
:key="item.objectID"
>
<a :href="item.url">{{item.title}}</a>
</li>
</ul>
</template>
複製程式碼
返回資料修改
作為強迫症患者,在模板層通過 state.xxx
的方式獲取資料實在是難受,那我們是不是可以通過物件解構的方式將 state
的資料返回呢?
<template>
<input type="text" v-model="input" />
<button class="search-btn" @click="setQuery">搜尋</button>
<ul class="results">
<li
v-for="item of hits"
:key="item.objectID"
>
<a :href="item.url">{{item.title}}</a>
</li>
</ul>
</template>
<script>
import { reactive, onMounted, watchEffect } from 'vue'
export default {
setup(props, ctx) {
const state = reactive({
input: 'vue',
query: 'vue',
hits: []
})
// 省略部分程式碼...
return {
...state,
setQuery,
}
}
}
</script>
複製程式碼
答案是『不可以』。修改程式碼後,可以看到頁面雖然發起了請求,但是頁面並沒有展示資料。
state
在解構後,資料就變成了靜態資料,不能再被跟蹤,返回值類似於:
export default {
setup(props, ctx) {
// 省略部分程式碼...
return {
input: 'vue',
query: 'vue',
hits: [],
setQuery,
}
}
}
複製程式碼
為了跟蹤基礎型別的資料(即非物件資料),Vue3 也提出瞭解決方案:ref()
。
import { ref } from 'vue'
const count = ref(0)
console.log(count.value) // 0
count.value++
console.log(count.value) // 1
複製程式碼
上面為 Vue 3 的官方案例,ref()
方法返回的是一個物件,無論是修改還是獲取,都需要取返回物件的 value
屬性。
我們將 state
從響應物件改為一個普通物件,然後所有屬性都使用 ref
包裹,這樣修改後,後續的解構才做才能生效。這樣的弊端就是,state
的每個屬性在修改時,都必須取其 value
屬性。但是在模板中不需要追加 .value
,Vue 3 內部有對其進行處理。
import { ref, onMounted, watchEffect } from 'vue'
export default {
setup() {
const state = {
input: ref('vue'),
query: ref('vue'),
hits: ref([])
}
const fetchData = async (query) => {
const data = await fetch(
`http://hn.algolia.com/api/v1/search?query=${query}`
).then(rsp => rsp.json())
state.hits.value = data.hits
}
onMounted(() => {
watchEffect(() => {
fetchData(state.query.value)
})
})
const setQuery = () => {
state.query.value = state.input.value
}
return {
...state,
setQuery,
}
}
}
複製程式碼
有沒有辦法保持 state
為響應物件,同時又支援其物件解構的呢?當然是有的,Vue 3 也提供瞭解決方案:toRefs()
。toRefs()
方法可以將一個響應物件變為普通物件,並且給每個屬性加上 ref()
。
import { toRefs, reactive, onMounted, watchEffect } from 'vue'
export default {
setup() {
const state = reactive({
input: 'vue',
query: 'vue',
hits: []
})
const fetchData = async (query) => {
const data = await fetch(
`http://hn.algolia.com/api/v1/search?query=${query}`
).then(rsp => rsp.json())
state.hits = data.hits
}
onMounted(() => {
watchEffect(() => {
fetchData(state.query)
})
})
const setQuery = () => {
state.query = state.input
}
return {
...toRefs(state),
setQuery,
}
}
}
複製程式碼
Loading 與 Error 狀態
通常,我們發起請求的時候,需要為請求新增 Loading 和 Error 狀態,我們只需要在 state
中新增兩個變數來控制這兩種狀態即可。
export default {
setup() {
const state = reactive({
input: 'vue',
query: 'vue',
hits: [],
error: false,
loading: false,
})
const fetchData = async (query) => {
state.error = false
state.loading = true
try {
const data = await fetch(
`http://hn.algolia.com/api/v1/search?query=${query}`
).then(rsp => rsp.json())
state.hits = data.hits
} catch {
state.error = true
}
state.loading = false
}
onMounted(() => {
watchEffect(() => {
fetchData(state.query)
})
})
const setQuery = () => {
state.query = state.input
}
return {
...toRefs(state),
setQuery,
}
}
}
複製程式碼
同時在模板使用這兩個變數:
<template>
<input type="text" v-model="input" />
<button @click="setQuery">搜尋</button>
<div v-if="loading">Loading ...</div>
<div v-else-if="error">Something went wrong ...</div>
<ul v-else>
<li
v-for="item of hits"
:key="item.objectID"
>
<a :href="item.url">{{item.title}}</a>
</li>
</ul>
</template>
複製程式碼
展示 Loading、Error 狀態:
將資料請求邏輯抽象
用過 umi 的同學肯定知道 umi 提供了一個叫做 useRequest 的 Hooks,用於請求資料非常的方便,那麼我們通過 Vue 的組合 API 也可以抽象出一個類似於 useRequest 的公共方法。
接下來我們新建一個檔案 useRequest.js
:
import {
toRefs,
reactive,
} from 'vue'
export default (options) => {
const { url } = options
const state = reactive({
data: {},
error: false,
loading: false,
})
const run = async () => {
state.error = false
state.loading = true
try {
const result = await fetch(url).then(res => res.json())
state.data = result
} catch(e) {
state.error = true
}
state.loading = false
}
return {
run,
...toRefs(state)
}
}
複製程式碼
然後在 App.vue
中引入:
<template>
<input type="text" v-model="query" />
<button @click="search">搜尋</button>
<div v-if="loading">Loading ...</div>
<div v-else-if="error">Something went wrong ...</div>
<ul v-else>
<li
v-for="item of data.hits"
:key="item.objectID"
>
<a :href="item.url">{{item.title}}</a>
</li>
</ul>
</template>
<script>
import { ref, onMounted } from 'vue'
import useRequest from './useRequest'
export default {
setup() {
const query = ref('vue')
const { data, loading, error, run } = useRequest({
url: 'http://hn.algolia.com/api/v1/search'
})
onMounted(() => {
run()
})
return {
data,
query,
error,
loading,
search: run,
}
}
}
</script>
複製程式碼
當前的 useRequest
還有兩個缺陷:
- 傳入的 url 是固定的,query 修改後,不能及時的反應到 url 上;
- 不能自動請求,需要手動呼叫一下 run 方法;
import {
isRef,
toRefs,
reactive,
onMounted,
} from 'vue'
export default (options) => {
const { url, manual = false, params = {} } = options
const state = reactive({
data: {},
error: false,
loading: false,
})
const run = async () => {
// 拼接查詢引數
let query = ''
Object.keys(params).forEach(key => {
const val = params[key]
// 如果去 ref 物件,需要取 .value 屬性
const value = isRef(val) ? val.value : val
query += `${key}=${value}&`
})
state.error = false
state.loading = true
try {
const result = await fetch(`${url}?${query}`)
.then(res => res.json())
state.data = result
} catch(e) {
state.error = true
}
state.loading = false
}
onMounted(() => {
// 第一次是否需要手動呼叫
!manual && run()
})
return {
run,
...toRefs(state)
}
}
複製程式碼
經過修改後,我們的邏輯就變得異常簡單了。
import useRequest from './useRequest'
export default {
setup() {
const query = ref('vue')
const { data, loading, error, run } = useRequest(
{
url: 'http://hn.algolia.com/api/v1/search',
params: {
query
}
}
)
return {
data,
query,
error,
loading,
search: run,
}
}
}
複製程式碼
當然,這個 useRequest
還有很多可以完善的地方,例如:不支援 http 方法修改、不支援節流防抖、不支援超時時間等等。最後,希望大家看完文章後能有所收穫。