Follow me on my blog shymean.com

This article will show you how to use Vue3 to encapsulate some of the more useful composite apis, including background, implementation ideas, and some thinking.

In my opinion, Hook and Composition API concepts are very similar, in fact most of the hooks available in React can be implemented again using Vue3.

reference

  • React Hook Libraries to recommend
  • awesome-react-hooks
  • hooks-guide
  • ahooks
  • crooks

For ease of spelling, Hook instead of Composition API is used in the following sections. The code is on Github.

useRequest

background

It is easy to use hooks to encapsulate a set of data, such as the useBook below

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
    }
}
Copy the code

It encapsulates the logic of obtaining resources and handling load state, which seems to meet our needs

The downside is that it looks like we need to write similar template code for another resource, so we can abstract this code and encapsulate it into the useApi method

implementation

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= > {
            // By convention, the result returned by the API is copied directly to result
            result.value = data
        }).catch(e= > {
            error.value = e
        }).finally(() = > {
            loading.value = false})}return {
        loading,
        error,
        result,
        fetchResource
    }
}
Copy the code

Then modify the useBook method above

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

    onMounted(() = > {
        fetchResource({page: 1})})return {
        loading,
        error,
        list: result
    }
}
Copy the code

Note that this is a very generic approach, and it’s easy to handle if you now need to encapsulate other requests without having to deal with loading and error flags over and over again

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) = > {
        // Encapsulate the request return value
        return fetchUserList(params).then(res= > {
            console.log(res)
            if (res.code === 200) {
                return res.data
            }
            return[]})})// ...
}
Copy the code

thinking

Handling network requests is a very common problem in front-end work. Handling the loading and error handling described above, including shaking, throttling, polling, and canceling outstanding requests when leaving the page, can be further encapsulated in useRequest

useEventBus

EventBus can be useful in scenarios where event notification is performed between multiple components. By listening for events and triggering events, it can be decoupled from subscribers and publishers, and it’s easy to implement a regular 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)
        })
    }
}
Copy the code

It is easy to understand that we listen for events during component initialization and fire events during interaction; But it’s easy to forget that we also need to unregister the event when the component is uninstalled, freeing the associated resources.

Therefore, you can encapsulate a useEventBus interface to handle this logic uniformly

implementation

Since want to cancel registration related events, when the component unloading simple implementation approach is: as long as at the time of registration (on and once) collect related events and handlers, and then cancel (off) when onUnmounted these events can be collected

So we can hijack the method of event registration and create an additional eventMap to collect events registered using the current interface

// Event bus, global singleton
const bus = new EventBus()

export default function useEventBus() {
    let instance = {
        eventMap: new Map(),
        // Reuse eventBus events to collect related logic
        on: bus.on,
        once: bus.once,
        / / clear the eventMap
        clear() {
            this.eventMap.forEach((list, key) = > {
                list.forEach(cb= > {
                    bus.off(key, cb)
                })
            })
            eventMap.clear()
        }
    }
    let eventMap = new Map(a)// Hijack two listener methods to collect events corresponding to the current component
    const on = (key, cb) = > {
        instance.on(key, cb)
        bus.on(key, cb)
    }
    const once = (key, cb) = > {
        instance.once(key, cb)
        bus.once(key, cb)
    }

    // Cancel the related event when the component is uninstalled
    onUnmounted(() = > {
        instance.clear()
    })
    return {
        on,
        once,
        off: bus.off.bind(bus),
        emit: bus.emit.bind(bus)
    }
}
Copy the code

This way, instance.clear is also used to remove the events registered by the component when the group price is unmounted, which is much more convenient than manually canceling each component onUnmounted.

thinking

This idea can be applied to a lot of logic that requires cleaning up when a component is unloaded, such as:

  • DOM event registrationaddEventListenerandremoveEventListener
  • The timersetTimeoutandclearTimeout
  • Network requestrequestandabort

This encapsulation also shows a very clear advantage of the composite API: it abstractions the common logic as much as possible without having to focus on the specifics of each component

useModel

Reference:

  • In which the source code

background

Once you’ve mastered the Hook (or Composition API), you feel like anything can be hooked, and you always want to encapsulate the data with the methods that operate on it, such as the counter below

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

This useCounter exposes data and methods to get the current value count, increase decrement and decrease increment, and you can then happily implement counters in various components

In some cases we want multiple components to share the same counter, rather than each component having its own independent counter.

One scenario is to use a global state management tool such as VUex and then modify the implementation of useCounter

import {createStore} from 'vuex'


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

Then re-implement 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
    }
}
Copy the code

Obviously, useCounter2 is only the encapsulation of Store state and mutations, and the same effect can be achieved by directly using Store in the component, so the encapsulation becomes insignificant. Furthermore, adding vuex dependencies to a project just for this functionality would be cumbersome.

Based on these issues, we can use a useModel to implement the need to reuse a hook state

implementation

The whole idea is also relatively simple, using a Map to store the state of a hook

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

Then wrap the useCounter

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

// call in multiple components
const {count, decrement, increment} = useCounter3()
// ...
const {count, decrement, increment} = useCounter3()
Copy the code

This way, the same state is returned each time useCounter3 is called, enabling hook state sharing between multiple components.

thinking

UserModel provides a way to share data state in addition to vuex and provide()/inject(), and can manage data and manipulate data flexibly without having to put all states together or under modules.

The disadvantage is that useCounter is a normal hook when not wrapped with useModel, and it is difficult to determine whether a state is globally shared data or local data for later maintenance.

Therefore, when using useModel to handle the shared state of a hook, it is necessary to carefully consider whether it is appropriate.

useReducer

Redux’s idea can be summarized as follows

  • Store maintains global state data state,
  • Individual components can use the data in state as needed and listen for changes in state
  • reducerReceiving the action and returning the new state, the component can passdispatchThe action was passed and the reducer was triggered
  • When state is updated, the dependent is notified of the updated data

We can even hook the use of Redux like

function reducer(state, action){
    // Process according to action
    // return the new state
}
const initialState = {}
const {state, dispatch} = useReducer(reducer, initialState);
Copy the code

implementation

With Vue’s data response system, we don’t even need to implement any publish and subscribe logic

import {ref} from 'vue'

export default function useReducer(reducer, initialState = {}) {
    const state = ref(initialState)
     // The convention action format is {type:string, payload: any}
    const dispatch = (action) = > {
        state.value = reducer(state.value, action)
    }
    return {
        state,
        dispatch
    }
}
Copy the code

A useRedux is then implemented to pass the Reducer and action

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);
}
Copy the code

We want to maintain a global store, so we can use the useModel above

export function useRedux() {
    return useModel(useStore);
}
Copy the code

It can then be used in components

<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>
Copy the code

It looks no different from the useModel example above. It mainly exposes the general dispatch method and maintains the logic of state changes at the Reducer instead of maintaining the logic of modifying data in each useCounter

thinking

Of course, this ReDUx is very simple, including middleware, combineReducers, connect and other methods are implemented, but it also shows us a most basic redux data flow process.

UseDebounce and useThrottle

background

Many front-end business scenarios need to deal with throttling or chattering scenarios. Throttling and chattering functions themselves do not reduce the number of events triggered, but control the execution of event handlers to reduce the actual logical processing, thus improving browser performance.

A scene for shaking out is: search the associated content according to the text input by the user in the search box and pull down the display. Since the input is an event with a high trigger frequency, it is generally required to wait until the user stops outputting text for a period of time before requesting the interface to query data.

Let’s implement the original business logic first

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 requests that the interface get data
    }

    // watch(keyword, search) // Primitive logic, request every change
    watch(keyword, debounce(search, 1000)) // Stop the operation for 1 second and request again

    return {
        keyword
    }
}
Copy the code

And then we import it in the view

<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>
Copy the code

As with useApi, we can abstract the logic of this debounce and encapsulate it into a generic useDebounce

Implement useDebounce

It looks like we don’t need to write any extra code. We can just rename the debounce method to useDebounce. To make up the word count, we’ll modify it and add the cancel method

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
    }
}
Copy the code

Implement useThrottle

Throttling and de-shaking are packaged in much the same way, as long as you know you are implementing throttle.

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

thinking

As can be seen from the shake/throttling form, some hooks do not have very clear boundaries with our previous utility functions. Whether to hook all the code uniformly, or to retain the original style of introducing utility functions, this is a question that needs to be considered and practiced

summary

This paper mainly shows several Hook packaging ideas and simple implementation

  • useRequestManages the status of network requests in a unified manner, eliminating the need to process loading and error logic repeatedly in each network request
  • useEventBusIt automatically cancels the event that the current component listens to when the component is unloadedonUnmountedCode, this idea can also be used to register and cancel DOM events, timers, network requests, etc
  • useModelThe hook state can be shared by multiple components, and a new hook state can be realized.provide/injectA scheme for sharing data across components outside of functions
  • useReducerUse hook to achieve a simple version ofreduxAnd take advantage ofuseModelImplements a global store
  • useDebouncewithuseThrottle, implemented chattering and throttling, and thought about the hook code style versus the regular util code style, and whether it was necessary to hook everything

All of the code in this article is on Github, because it is just a demonstration of ideas, understand the flexible use of the composite API, so the code is very simple, if you find mistakes or other ideas, welcome to specify and discuss.