Vue3-hook函数编写指南

3,203 阅读7分钟

写在前面

本文的知识主要来源为本人观看vue conf上antfu演讲后做的总结和自己的经验。

组合式api介绍

如果你已经了解vue3组合式api,并已经清楚知道它比vue2强大在何处,那么可以跳过这一节。

本节图片来源juejin.cn/post/706075…

先介绍一下vue3的组合式api,以一段切换暗色模式的组件代码为例子;

vue2的写法:

<script>
export default{
	data(){
		return{
			dark: false
		}
	},
	computed:{
		light(){
			return !this.dark
		}
	},
	methods:{
		toggleDark(){
			this.dark = !this.dark
		}
	}
}
</script>

接下来是vue3组合式API的写法:

<script>
import { ref, computed } from 'vue'

export default {
	setup() {
		const dark = ref(false)
		const light = computed(() => !dark.value)
		const toggleDark = ()=>{
			dark.value = !dark.value
		}
		return {
			dark,
			light,
			toggleDark
		}
	}
}
</script>

如果想修改这个切换暗色模式的逻辑,你会怎么做呢?

在vue2中,你需要先后在data、computed、methods中寻找这段逻辑有关的代码,然后再一个个修改,修改时要在代码上下跳转:

而在vue3中,你只需要找到这块逻辑集中的地方,统一改动就可以了。

这就引出了vue3的第一个优点:逻辑关注点分离

vue2组织代码的形式是按照API类型来组织的,它把一个组件分成了很多不同的API块,如data、computed、methods、生命周期函数等等:

img

而vue3的代码,你可以自由按照功能逻辑来组织:

img

当然,当一个组件代码量过大的时候,vue3仍会有可读性的问题,这时候就需要拆分代码了。

在vue2中,如果我们想要拆分出一段涉及到响应式变量(视图)逻辑,通常在定义一个组件,然后通过mixins来组合它们。

但是mixins的有很多缺陷,最大的缺点就是丢失上下文,且拥有潜在的命名冲突,很多时候根本享受不到拆分带来的便利。

那么,vue3应该怎么做呢? 还是以这段逻辑为基础,我们先写出抽离后,我们想要的效果:

App.vue:

<template>
	<button @click="toggleDark"></button>
</template>

<script>
import { ref, computed } from 'vue'
import { useDark } from './composible'

export default{
	setup(){
		const { dark, light, toggleDark } = useDark()
		return {
			dark,light,toggleDark
		}
	}
}
</script>

这样,如果我们需要改动暗色模式的逻辑,只用去修改useDark函数就可以了。

如果只接触过vue2的同学可能不会理解,为什么这种涉及到模板变量的逻辑也可以被单独抽到一个函数里?

这就引出了vue3第二个强大之处:vue3的响应式系统可以独立在组件外使用

composible.ts:

import { ref, computed } from 'vue'
export function useDark(){
		const dark = ref(false)
		const light = computed(() => !dark.value)
		const toggleDark = ()=>{
			dark.value = !dark.value
		}
		return{
			dark, light, toggleDark
		}
}

这样把逻辑拆成一个函数,而函数中可以单独使用vue响应式系统提供的api,甚至可以单独使用生命周期钩子,这种函数我们一般称为vue3的hook函数。多个hook函数可以灵活组合,每个hook函数里也可以使用其他hook。

最终,一个vue3页面的架构应该如下图所示:

img

至于什么时候拆分,如何拆分,这就看具体的场景和个人习惯了,大家可以参考一下vueuse项目:vueuse.org/,或者github上的…

总结:

声明式api组合式api
不利于复用极易复用(原生JS函数)
潜在命名冲突可灵活组合(生命周期钩子可多次使用)
上下文丢失更好的上下文支持
有限的类型支持完善的Typescript支持
按API类型组织按功能逻辑组织
只能用于vue组件中可在vue组件外独立使用

hook函数编写模式与技巧

ref和reactive怎么选择?

这里有个偷懒的选择方法,但也经过了很多库的实践:能使用ref的情况下 就使用ref

原因如下:

ref可以显式调用.value,触发类型检查

例如 let foo = ref(1)如果不小心给foo赋值了一个普通变量foo = 2,TS编译器会报错,然后加上.value,你就能区分这是个响应式的变量,而reactive可能会和普通变量混淆。

ref比起reactive局限更少

reactive可以自动解包,但是有一些坏处:

  • 在类型上和一般对象没有区别

  • 使用ES6解构会导致响应式丢失(toRef)

  • 需要使用箭头函数包装才可以进行watch

  • const foo = { prop: 0 }
    const bar = reactve({
      prop: 0
    })
    
    foo.prop = 1
    bar.prop = 2
    
    //这种代码看上去,这两个变量没有区别,需要去检查初始化的代码才能知道
    

有很多同学刚开始用vue3,可能不太喜欢.value的使用,但其实ref在很多情况下会被vue自动解包:

  • watch监听时

  • 模板中(同时在模板中赋值也不用.value)

  • //使用reactive包裹嵌套的ref也会自动解包
    const foo = ref('bar')
    const data = reactive({
        foo,
        id
    })
    data.foo = bar //ok
    

unref - Ref的反操作

原理:如果传入ref,则返回其值,否则就原样返回。

实现:

import { isRef } from 'vue'

function unref<T>( r: Ref<T> | T ): T {
    return isRef(r) ? r.value : r
}

该函数已于正式版中被vue3官方收编,直接使用:

import { unref } from 'vue'

有了这个函数,我们就可以进行一些比较无脑的写法:

import { ref, unref } from 'vue'
const foo = ref('foo')
unref(foo) // foo
const bar = 'bar'
unref(bar) // bar

这在hooks多重嵌套时将会很有帮助。

模式:接受ref作为函数参数

先来一个纯函数:

function add(a: number, b: number) {
	return a + b
}

这个函数的结果不会依赖a和b之后变化。

接下来是接受ref作为参数的函数:

import { Ref, computed } from 'vue'

function add( a: Ref<number>, b: Ref<number> ){
	return computed( ()=> a.value + b.value )
}

这样,函数的结果也是一个ref,它也会永远依赖与a和b的值( computed )。

更加灵活的写法,同时接受ref和字面量:

import { Ref, computed, unref } from 'vue'

function add( a: Ref<number> | number, b: Ref<number> | number ){
	return computed( ()=>unref(a) + unref(b) )
}

使用起来很无脑:

const a = ref(1)
const c = add( a,5 )

console.log( c.value ) //6

a.value = 2

console.log( c.value ) //7

MaybeRef类型工具

如果不太了解TS的类型工具编写,可以先学习后再进行实践。

MaybeRef类型工具实现:

type MaybeRef<T> = Ref<T> | T

很简单但是很实用,在vueuse库中就大量使用了这个类型工具。

假如不使用Mayberef:

export function useTimeAgo(
	time: Date | number | string | Ref<Date | number | string>
){
	return computed( ()=> someFormating( unref(time) ) )
}

可以看到参数类型非常繁琐,可以使用MaybeRef进行简化:

export function useTimeAgo( MaybeRef<Date | number | string> ){
	return computed( ()=> someFormating( unref(time) ) )
}

编写更加灵活的hook

尽量让函数可以灵活的使用,以vueuse中的useTitle函数举例,该函数控制页面的title标签。

使用:

// 通常用法
const title = useTitle()
title.value = 'Hello World'

// 更加灵活的用法
const name = ref('Hello')

const title = computed( ()=> `${name.value} World` )
useTitle(title) //此时title和页面的title建立了连结

name.value = 'Hi' //页面标题变成 Hi World

实现

import { ref, watch } from 'vue'
import { MaybeRef } from '@vueuse/core'

function useTitle( newTitle: MaybeRef<string | null | undefined>){
	const title = ref(newTitle ?? document.title) //核心
	watch(title, (t)=> {
		if(t){
			document.title = t
		}
	}, { immediate: true })
}

可以看到,这个hook使用watch api让自己变得更加灵活,我们在编写hook时也应该时刻考虑如何让它更灵活。

重复使用已有的ref

新手可能会编写这种代码:

const foo = ref(1)

// ...

const bar = isRef(foo) ? foo : ref(foo)
const bar = ref(foo)

在不确定类型时会进行这种判断,但其实并不需要。

因为如果一个ref被传递给ref构造函数,它将原样返回

const foo = ref(1)
const bar = ref(foo)

foo === bar // true

模式:由ref组成的对象

在hook中返回由ref组成的对象,会让hook的使用更加灵活:

function useMouse() {
    // ...
    
    return {
        x: ref(0)
        y: ref(0)
    }
}

//可以直接使用
const { x } = useMouse()
x.value // 0

//也可以自动解包(不需要.value)
const mouse = reactve(useMouse())

mouse.x // 0 

记住这个模式的编写和使用,很省事。

技巧:将异步操作转换为同步写法

以vueuse中的useFetchhook为例,使用:

// 原生fetch
const data = await fetch('url').then( r=> r.json )

// useFetch
const data = useFetch('url').json
const user = computed( () => data.value ? data.value.user )

大概的实现如下:

function useFetch( url: MaybeRef<string> ){
	const data = shallowRef<T | undefined>()
	const error = shallowRef<Error | undefined>()
	fetch(unref(url))
			.then(c => c.json)
			.then(r => data.value = r)
			.catch(e => error.value = e)
	return {
		data,
		error
	}
}

关于shallowRef,可以参考下vue官方文档哦。

重点:清除副作用

首先,编写hook时记得清除自己创造的副作用:

import { onUnmounted } from 'vue'

export function useEventListener(target: EventTarget, name: string, fn: any){
	target.addEventListener(name, fn)
	onUnmounted(()=>{
			target.removeEventListener(name, fn)  // <--
	})
}

但是有另外一种特殊的副作用也需要清除,

在vue的setup()中,watch、computed等副作用会被收集并且绑定到当前组件实例上,当实例unmounted时,这些副作用会被vue释放。

但我们在编写独立hook时,没有vue帮我们来处理这种脏活了,我们需要自己处理这种情况,

比如,先收集computedwatch

const disposables = []

const counter = ref(0)
const doubled = computed(() => counter.value * 2)

disposables.push(() => stop(doubled.effect))

const stopWatch1 = watchEffect(() => {
  console.log(`counter: ${counter.value}`)
})

disposables.push(stopWatch1)

const stopWatch2 = watch(doubled, () => {
  console.log(doubled.value)
})

disposables.push(stopWatch2)

然后手动释放:

disposables.forEach((f) => f())
disposables = []

我们不想每次编写hook都要干这种脏活。

还好,vue3.2提供了一个专门处理这种情况的api:effectScope

effectScope会收集在它内部的effectcomputedwatchwatchEffect,然后提供了函数来释放。

function useXXX() {
    // ...
    
    const scope = effectScope()

    //副作用写在run的回调函数中
    scope.run(() => {
      const doubled = computed(() => counter.value * 2)

      watch(doubled, () => console.log(doubled.value))

      watchEffect(() => console.log('Count: ', doubled.value))
    })
    
    //释放所有副作用
    scope.stop()
}

记得要用哦。

类型安全的Provice & Inject

如果不知道这个玩意,我们在写ProvideInject时会丢失类型(也就是变成any)。

这个玩意就是injectionKey

//context.ts
import { injectionKey } from 'vue'
export interface UserInfo{
	id: number
	name: string
}
//父组件
export const injectKeyUser: injectionKey<UserInfo> = Symbol()

import { provice } from 'vue'
import { injectKeyUser } from './context'

export default {
	setup(){
			provice( injectKeyUser, {
				id: 1,
				name: 'xxx'
			})
	}
}
import { inject } from 'vue'
import { injectKeyUser } from './context'

export default {
	setup(){
		const user = inject(injectKeyUser)
		if(user){
			console.log( user.name ) //ok
		}
	}
}

这样就可以给他们类型了。

模式:状态共享

也许你已经看了一些关于vue3并不需要状态共享的文章了。

为什么说在vue3中可以不需要vuex这种状态管理工具?

因为组合式API可以独立于组件被使用,所以天然可以被组件共享:

//store.ts
import { reactive } from 'vue'
export const store = reactive({
	state: {
			foo: 'bar'
	}
})
//A.vue
import { store } from 'store'
store.state.foo = 'yeah'
//B.vue
import { store } from 'store'
console.log(store.state.foo) // 'yeah'

vue3官方推荐状态管理库pinia就是类似这种模式。

让vue2也能用上组合式api

插件@vue/composition-api是可以为vue2提供组合式API的插件,可以使用以上所有的技巧!

此外,vue2.7版本也会官方支持以下特性:

  • 将vue/composition-api整合进vue2核心
  • 支持setup script
  • 将vue2代码迁移到typescript
  • vue2将继续支持ie11
  • LTS

如果不想从vue2直接升级到vue3,可以一起期待vue2.7的到来哦。

另外,对于vue组件库作者来说,可以引入vue-demi,这样你的包既可以使用组合式api,又可以同时兼容vue2和vue3!