手摸手教你封裝幾個 Vue3 中很有用的組合式 API

來自:掘金,作者:橙紅年代

鏈接:https://juejin.cn/post/6888925879243079687

本文將介紹如何使用 Vue3 來封裝一些比較有用的組合 API,主要包括背景、實現思路以及一些思考。

就我自己的感覺而言,HookComposition API概念是很類似的,事實上在 React 大部分可用的 Hook 都可以使用 Vue3 再實現一遍。

爲了拼寫方便,下文內容均使用 Hook 代替Composition API。相關代碼均放在 github[1] 上面。

useRequest

背景

使用 hook 來封裝一組數據的操作是很容易的,例如下面的useBook

import {ref, onMounted} from 'vue'

function fetchBookList() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve([1, 2, 3])
        }, 1000)
    })
}

export function useBook() {
    const list = ref([])
    const loading = ref(false)
    const getList = async () => {
        loading.value = true
        const data = await fetchBookList({page: 1})
        loading.value = false
        list.value = data
    }

    onMounted(() => {
        getList()
    })

    return {
        list,
        loading,
        getList
    }
}

其中封裝了獲取資源、處理加載狀態等邏輯,看起來貌似能滿足我們的需求了

缺點在於對應另外一個資源而言,我們貌似還需要寫類似的模板代碼,因此可以將這一堆代碼進行抽象,封裝成useApi方法

實現

function useApi(api) {
    const loading = ref(false)
    const result = ref(null)
    const error = ref(null)

    const fetchResource = (params) => {
        loading.value = true
        return api(params).then(data => {
            // 按照約定,api返回的結果直接複製給result
            result.value = data
        }).catch(e => {
            error.value = e
        }).finally(() => {
            loading.value = false
        })
    }
    return {
        loading,
        error,
        result,
        fetchResource
    }
}

然後修改上面的useBook方法

function useBook2() {
    const {loading, error, result, fetchResource,} = useApi(fetchBookList)

    onMounted(() => {
        fetchResource({page: 1})
    })

    return {
        loading,
        error,
        list: result
    }
}

注意這是一個非常通用的方法,假設現在需求封裝其他的請求,處理起來也是非常方便的,不需要再一遍遍地處理 loading 和 error 等標誌量

function fetchUserList() {
    return new Promise((resolve) => {
        setTimeout(() => {
            const payload = {
                code: 200,
                data: [11, 22, 33],
                msg: 'success'
            }
            resolve(payload)
        }, 1000)
    })
}

function useUser() {
    const {loading, error, result, fetchResource,} = useApi((params) => {
        // 封裝請求返回值
        return fetchUserList(params).then(res => {
            console.log(res)
            if (res.code === 200) {
                return res.data
            }
            return []
        })
    })
    // ...
}

思考

處理網絡請求是前端工作中十分常見的問題,處理上面列舉到的加載、錯誤處理等,還可以包含去抖、節流、輪詢等各種情況,還有離開頁面時取消未完成的請求等,都是可以在useRequest中進一步封裝的

useEventBus

EventBus 在多個組件之間進行事件通知的場景下還是比較有用的,通過監聽事件和觸發事件,可以在訂閱者和發佈者之間解耦,實現一個常規的 eventBus 也比較簡單

class EventBus {
    constructor() {
        this.eventMap = new Map()
    }

    on(key, cb) {
        let handlers = this.eventMap.get(key)
        if (!handlers) {
            handlers = []
        }
        handlers.push(cb)
        this.eventMap.set(key, handlers)
    }

    off(key, cb) {
        const handlers = this.eventMap.get(key)
        if (!handlers) return
        if (cb) {
            const idx = handlers.indexOf(cb)
            idx > -1 && handlers.splice(idx, 1)
            this.eventMap.set(key, handlers)
        } else {
            this.eventMap.delete(key)
        }
    }

    once(key, cb) {
        const handlers = [(payload) => {
            cb(payload)
            this.off(key)
        }]
        this.eventMap.set(key, handlers)
    }

    emit(key, payload) {
        const handlers = this.eventMap.get(key)
        if (!Array.isArray(handlers)) return
        handlers.forEach(handler => {
            handler(payload)
        })
    }
}

我們在組件初始化時監聽事件,在交互時觸發事件,這些是很容易理解的;但很容易被遺忘的是,我們還需要在組件卸載時取消事件註冊,釋放相關的資源。

因此可以封裝一個useEventBus接口,統一處理這些邏輯

實現

既然要在組件卸載時取消註冊的相關事件,簡單的實現思路是:只要在註冊時 (ononce) 收集相關的事件和處理函數,然後在onUnmounted的時候取消 (off) 收集到的這些事件即可

因此我們可以劫持事件註冊的方法,同時額外創建一個eventMap用於收集使用當前接口註冊的事件

// 事件總線,全局單例
const bus = new EventBus()

export default function useEventBus() {
    let instance = {
        eventMap: new Map(),
        // 複用eventBus事件收集相關邏輯
        on: bus.on,
        once: bus.once,
        // 清空eventMap
        clear() {
            this.eventMap.forEach((list, key) => {
                list.forEach(cb => {
                    bus.off(key, cb)
                })
            })
            eventMap.clear()
        }
    }
    let eventMap = new Map()
    // 劫持兩個監聽方法,收集當前組件對應的事件
    const on = (key, cb) => {
        instance.on(key, cb)
        bus.on(key, cb)
    }
    const once = (key, cb) => {
        instance.once(key, cb)
        bus.once(key, cb)
    }

    // 組件卸載時取消相關的事件
    onUnmounted(() => {
        instance.clear()
    })
    return {
        on,
        once,
        off: bus.off.bind(bus),
        emit: bus.emit.bind(bus)
    }
}

這樣,當組價卸載時也會通過instance.clear移除該組件註冊的相關事件,比起手動在每個組件onUnmounted時手動取消要方便很多。

思考

這個思路可以運用在很多需要在組件卸載時執行清理操作的邏輯,比如:

從這個封裝也可以看見組合 API 一個非常明顯的優勢:儘可能地抽象公共邏輯,而無需關注每個組件具體的細節

useModel

參考:

背景

當掌握了 Hook(或者 Composition API)之後,感覺萬物皆可 hook,總是想把數據和操作這堆數據的方法封裝在一起,比如下面的計數器

function useCounter() {
    const count = ref(0)
    const decrement = () => {
        count.value--
    }
    const increment = () => {
        count.value++
    }
    return {
        count,
        decrement,
        increment
    }
}

這個useCounter暴露了獲取當前數值 count、增加數值 decrement 和減少數值 increment 等數據和方法,然後就可以在各個組件中愉快地實現計數器了

在某些場景下我們希望多個組件可以共享同一個計數器,而不是每個組件自己獨立的計數器。

一種情況是使用諸如 vuex 等全局狀態管理工具,然後修改useCounter的實現

import {createStore} from 'vuex'

const store = createStore({
    state: {
        count: 0
    },
    mutations: {
        setCount(state, payload) {
            state.count = payload
        }
    }
})

然後重新實現useCounter

export function useCounter2() {
    const count = computed(() => {
        return store.state.count
    })
    const decrement = () => {
        store.commit('setCount', count.value + 1)
    }
    const increment = () => {
        store.commit('setCount', count.value + 1)
    }
    return {
        count,
        decrement,
        increment
    }
}

很顯然,現在的useCounter2僅僅只是 store 的statemutations的封裝,直接在組件中使用 store 也可以達到相同的效果,封裝就變得意義不大;此外,如果單單只是爲了這個功能就爲項目增加了 vuex 依賴,顯得十分笨重。

基於這些問題,我們可以使用一個useModel來實現複用某個鉤子狀態的需求

實現

整個思路也比較簡單,使用一個 Map 來保存某個 hook 的狀態

const map = new WeakMap()
export default function useModel(hook) {
    if (!map.get(hook)) {
        let ans = hook()
        map.set(hook, ans)
    }
    return map.get(hook)
}

然後包裝一下useCounter

export function useCounter3() {
    return useModel(useCounter)
}

// 在多個組件調用
const {count, decrement, increment} = useCounter3()
// ...
const {count, decrement, increment} = useCounter3()

這樣,在每次調用useCounter3時,都返回的是同一個狀態,也就實現了多個組件之間的 hook 狀態共享。

思考

userModel提供了一種除vuexprovide()/inject()之外共享數據狀態的思路,並且可以很靈活的管理數據與操作數據的方案,而無需將所有 state 放在一起或者模塊下面。

缺點在於,當不使用useModel包裝時,useCounter就是一個普通的 hook,後期維護而言,我們很難判斷某個狀態到底是全局共享的數據還是局部的數據。

因此在使用useModel處理 hook 的共享狀態時,還要要慎重考慮一下到底合不合適。

useReducer

redux 的思想可以簡單概括爲

我們甚至可以將 redux 的使用 hook 化,類似於

function reducer(state, action){
    // 根據action進行處理
    // 返回新的state
}
const initialState = {}
const {state, dispatch} = useReducer(reducer, initialState);

實現

藉助於 Vue 的數據響應系統,我們甚至不需要實現任何發佈和訂閱邏輯

import {ref} from 'vue'

export default function useReducer(reducer, initialState = {}) {
    const state = ref(initialState)
     // 約定action格式爲 {type:string, payload: any}
    const dispatch = (action) => {
        state.value = reducer(state.value, action)
    }
    return {
        state,
        dispatch
    }
}

然後實現一個useRedux負責傳遞reduceraction

import useReducer from './index'

function reducer(state, action) {
    switch (action.type) {
        case "reset":
            return initialState;
        case "increment":
            return {count: state.count + 1};
        case "decrement":
            return {count: state.count - 1};
    }
}

function useStore() {
    return useReducer(reducer, initialState);
}

我們希望是維護一個全局的 store,因此可以使用上面的useModel

export function useRedux() {
    return useModel(useStore);
}

然後就可以在組件中使用了

<template>
<div>
  <button @click="dispatch({type:'decrement'})">-</button>
  <span>{{ state.count }}</span>
  <button @click="dispatch({type:'increment'})">+</button>
</div>
</template>

<script>
export default {
  name: "useReducer",
  setup() {
    const {state, dispatch} = useStore()
    return {
      state,
      dispatch
    }
  }
}
</script>

看起來跟我們上面useModel的例子並沒有什麼區別,主要是暴露了通用的dispatch方法,在 reducer 處維護狀態變化的邏輯,而不是在每個 useCounter 中自己維護修改數據的邏輯

思考

當然這個 redux 是非常簡陋的,包括中間件、combineReducersconnect等方法均爲實現,但也爲我們展示了一個最基本的 redux 數據流轉過程。

useDebounce 與 useThrottle

背景

前端很多業務場景下都需要處理節流或去抖的場景,節流函數和去抖函數本身沒有減少事件的觸發次數,而是控制事件處理函數的執行來減少實際邏輯處理過程,從而提高瀏覽器性能。

一個去抖的場景是:在搜索框中根據用戶輸入的文本搜索關聯的內容並下拉展示,由於 input 是一個觸發頻率很高的事件,一般需要等到用戶停止輸出文本一段時間後纔開始請求接口查詢數據。

先來實現最原始的業務邏輯

import {ref, watch} from 'vue'

function debounce(cb, delay = 100) {
    let timer
    return function () {
        clearTimeout(timer)
        let args = arguments,
            context = this
        timer = setTimeout(() => {
            cb.apply(context, args)
        }, delay)
    }
}
export function useAssociateSearch() {
    const keyword = ref('')

    const search = () => {
        console.log('search...', keyword.value)
        // mock 請求接口獲取數據
    }

    // watch(keyword, search) // 原始邏輯,每次變化都請求
    watch(keyword, debounce(search, 1000)) // 去抖,停止操作1秒後再請求

    return {
        keyword
    }
}

然後在視圖中引入

<template>
  <div>
    <input type="text" v-model="keyword">
  </div>
</template>

<script>
import {useAssociateSearch} from "../useDebounce";

export default {
  name: "useDebounce",
  setup() {
    const {keyword} = useAssociateSearch()
    return {
      keyword
    }

  }
}
</script>

useApi同理,我們可以將這個 debounce 的邏輯抽象出來,,封裝成一個通用的useDebounce

實現 useDebounce

貌似不需要我們再額外編寫任何代碼,直接將debounce方法重命名爲useDebounce即可,爲了湊字數,我們還是改裝一下,同時增加 cancel 方法

export function useDebounce(cb, delay = 100) {
    const timer = ref(null)

    let handler = function () {
        clearTimeout(timer.value)
        let args = arguments,
            context = this
        timer.value = setTimeout(() => {
            cb.apply(context, args)
        }, delay)
    }

    const cancel = () => {
        clearTimeout(timer)
        timer.value = null
    }

    return {
        handler,
        cancel
    }
}

實現 useThrottle

節流與去抖的封裝方式基本相同,只要知道throttle的實現就可以了。

export function useThrottle(cb, duration = 100) {
    let start = +new Date()
    return function () {
        let args = arguments
        let context = this
        let now = +new Date()
        if (now - start >= duration) {
            cb.apply(context, args)
            start = now
        }
    }
}

思考

從去抖 / 節流的形式可以看出,某些 hook 與我們之前的工具函數並沒有十分明顯的邊界。是將所有代碼統一 hook 化,還是保留原來引入工具函數的風格,這是一個需要思考和實踐的問題

小結

本文主要展示了幾種 Hook 的封裝思路和簡單實現

本文全部代碼均放在 github[3] 上面了,由於只是展示思路,瞭解組合式 API 的靈活用法,因此代碼寫的十分簡陋,如果發現錯誤或有其他想法,歡迎指定並一起討論。

參考

參考資料

[1] https://github.com/tangxiangmin/vue3-hook: https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Ftangxiangmin%2Fvue3-hook

[2] https://github.com/umijs/hox/blob/master/README-cn.md: https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fumijs%2Fhox%2Fblob%2Fmaster%2FREADME-cn.md

[3] https://github.com/tangxiangmin/vue3-hook: https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Ftangxiangmin%2Fvue3-hook

[4] https://juejin.cn/post/6888262631360135175: https://juejin.cn/post/6888262631360135175

[5] https://github.com/rehooks/awesome-react-hooks: https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Frehooks%2Fawesome-react-hooks

[6] https://hooks-guide.netlify.app/: https://link.juejin.cn?target=https%3A%2F%2Fhooks-guide.netlify.app%2F

[7] https://github.com/alibaba/hooks: https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Falibaba%2Fhooks

[8] https://github.com/chrisjpatty/crooks: https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fchrisjpatty%2Fcrooks

Web 開發 分享 Web 後端開發技術,分享 PHP、Ruby、Python 等用於後端網站、後臺系統等後端開發技術;還包含 ThinkPHP,WordPress 等 PHP 網站開發框架、Django,Flask 等 Python 網站開發框架。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/fWawo-Jo-7sw_5KhHsbGBg