前言
两三周前就准备写这篇文章了,但一直比较忙,直到本周内才开始断断续续写。刚好本周末有空,就来补上这篇文章。本文主要分析 varlet use
的两个hook useChildren 和 useParent 的源码以及在 varlet ui
的具体实践。
通过阅读本文,你将收获
- vue3 provide/inject 的使用
- 深层级组件数据收集和传递
- 发布订阅模式实践
- 防御式编程实践
源码分析
在开始阅读之前,先通过 复选框组组件 和 复选框组件 来了解下这两个hook的使用场景
<script setup>
import { ref } from 'vue'
const value = ref([])
</script>
<template>
<var-checkbox-group v-model="value">
<var-checkbox :checked-value="0">吃饭</var-checkbox>
<var-checkbox :checked-value="1">睡觉</var-checkbox>
</var-checkbox-group>
</template>
复选框组组件 会提供一个默认插槽,就可以把 复选框组件 当为子组件传进来
provide 和 inject 无视组件树的层级,很适合这类组件由上而下传递数据。但是有个小小的弊端,没办法向上传递数据,这个时候发布订阅模式就很合适了。
useChildren
这个hook会用在父组件上,比如 复选框组组件
import { removeItem, isArray } from '@varlet/shared'
import {
getCurrentInstance,
computed,
provide,
reactive,
isVNode,
type VNode,
type ComponentInternalInstance,
type ComputedRef,
} from 'vue'
function flatVNodes(subTree: any) {
const vNodes: VNode[] = []
const flat = (subTree: any) => {
if (subTree?.component) {
flat(subTree?.component.subTree)
return
}
if (isArray(subTree?.children)) {
subTree.children.forEach((child: any) => {
if (isVNode(child)) {
vNodes.push(child)
flat(child)
}
})
}
}
flat(subTree)
return vNodes
}
export interface UseChildrenBaseProvider<C> {
childInstances: ComponentInternalInstance[]
collect(instance: ComponentInternalInstance, childProvider: C): void
clear(instance: ComponentInternalInstance, childProvider: C): void
}
export function useChildren<P, C>(key: symbol | string) {
// 获取当前(父)组件的实例
const parentInstance: ComponentInternalInstance = getCurrentInstance() as ComponentInternalInstance
const childInstances: ComponentInternalInstance[] = reactive([])
const childProviders: C[] = []
const length: ComputedRef<number> = computed(() => childInstances.length)
const sortInstances = () => {
const vNodes: any[] = flatVNodes(parentInstance.subTree)
childInstances.sort((a, b) => vNodes.indexOf(a.vnode) - vNodes.indexOf(b.vnode))
}
const collect = (childInstance: ComponentInternalInstance, childProvider: C) => {
// 收集子实例
childInstances.push(childInstance)
// 收集子组件提供的属性或者函数
childProviders.push(childProvider)
sortInstances()
}
const clear = (childInstance: ComponentInternalInstance, childProvider: C) => {
// 从列表中移除子实例
removeItem(childInstances, childInstance)
// 从列表中移除子组件提供的属性或函数
removeItem(childProviders, childProvider)
}
// 注入有关函数或属性
const bindChildren = (parentProvider: P) => {
provide<P & UseChildrenBaseProvider<C>>(key, {
childInstances,
collect,
clear,
...parentProvider,
})
}
return {
length,
childProviders,
bindChildren,
}
}
这里有两个关键函数 collect
和 clear
,一个用来收集子组件提供的实例和数据,一个用来清除,类似于发布订阅模式的订阅和取消订阅,至于发布,下面实践部分基本都是在执行发布。
useParent
这个hook会用在子组件上,比如 复选框组件
import {
getCurrentInstance,
inject,
onMounted,
onBeforeUnmount,
nextTick,
computed,
type ComponentInternalInstance,
type ComputedRef,
} from 'vue'
import { type UseChildrenBaseProvider } from './useChildren.js'
export function keyInProvides(key: symbol | string) {
const instance = getCurrentInstance() as any
return key in instance.provides
}
export function useParent<P, C>(key: symbol | string) {
if (!keyInProvides(key)) {
return {
index: null,
parentProvider: null,
bindParent: null,
}
}
// 接收父组件提供的属性或方法
const provider = inject<P & UseChildrenBaseProvider<C>>(key) as P & UseChildrenBaseProvider<C>
// 解构
const { childInstances, collect, clear, ...parentProvider } = provider
const childInstance: ComponentInternalInstance = getCurrentInstance() as ComponentInternalInstance
// 获取当前子组件在子组件列表中的顺序
const index: ComputedRef<number> = computed(() => childInstances.indexOf(childInstance))
const bindParent = (childProvider: C) => {
// 挂载结束收集子组件实例和提供的数据
onMounted(() => {
nextTick().then(() => {
collect(childInstance, childProvider)
})
})
// 卸载之前清除子组件实例和提供的数据
onBeforeUnmount(() => {
nextTick().then(() => {
clear(childInstance, childProvider)
})
})
}
return {
index,
parentProvider,
bindParent,
}
}
这里要在 onMounted
回调里收集是因为vue中父组件是在子组件挂载结束之后才真正挂载结束。varlet
相比发布订阅有点特殊,多了一个绑定的过程,带来的收益是组件挂载自动收集,组件卸载自动清除。
前置设计
后面的内容会涉及到一些组件设计考虑的知识,在这里提前讲解下
checked-value 和 unchecked-value 是什么
这两个字段分别代表复选框 选中状态的值 和 未选中的值,默认值为 true
和 false
熟悉 element plus
的掘友可能已经想到了 true-value
和 false-value
考虑到如果数据并不是简单的布尔类型,而需要传递特定的字符串或者数值,在后台处理服务器交互和数据存储时,会更加灵活和友好。
为什么有些属性用computed包了一层
比如说 checkbox
组件的 是否选中状态属性 checked
一方面的考虑是 checkbox
的选中状态只由该组件控制,不允许其他组件控制,比如 checkboxGroup
组件。只允许其他组件读取选中的状态,不能修改,确保数据流向是稳定可控的。
这里有个编程概念,防御性编程。当然这个不是说要让代码变得很难维护,而是让代码更加健壮,易于阅读和维护。
实践
我们拿 复选框组件
举例,站在库开发者角度去考虑这个组件有什么样的应用场景以及该怎么把上面说到的内容具体的应用起来。
checkboxGroup
先来想想 checkboxGroup
组件可能会有什么样的应用场景呢?
- 获取所有选中的复选框
- 限制复选框的最大选择数量
- 全部选中或者全部反选所有的复选框
- 调整复选框的布局(水平或者垂直)
- ...
checkbox
而 checkbox
有什么状态需要暴露给 checkboxGroup
呢?
- 是否选中
- 选中后的值
- 同步值
- ...
实践
现在来解答上面的点
获取所有选中的复选框
怎么获取所有选中的复选框呢?前面说过,useChildren
这个 hook 会收集 checkbox
组件提供的数据,所以第一步要执行 useChildren
,来获取所有的 checkbox
组件提供的数据
checkbox
组件提供的数据为
const checked = computed(() => value.value === props.checkedValue)
const checkedValue = computed(() => props.checkedValue)
const checkboxProvider: CheckboxProvider = {
checkedValue, // 选中的值
checked, // 是否选中了当前checkbox
sync,
validate,
resetValidation,
reset,
resetWithAnimation,
}
既然拿到了所有的复选框提供的数据,获取所有选中的复选框就只需要过滤出来 checked.value
为 true
的值就行了
const checkedBoxes = checkboxes.filter(({ checked }) => checked.value)
限制复选框的最大选择数量
实际需求开发中,有时候会需要限制复选框的选中数量,比如问卷调查最多只能选择3个爱好,学生选课最多只能选择3门选修课等。varlet
给 checkboxGroup
组件提供了一个 max
属性,用来限制复选框的最大选择的数量。
max
是 checkboxGroup
的属性,而上一节说过,checkbox
组件是否选中是它本身在控制,那么该怎么处理这个问题呢?
varlet
的 做法是通过 vue3 provide 将数据注入到子组件中
第二张图解构的 checkboxGroup
就是 checkboxGroup组件
提供的数据,具体是如下字段
const max = computed(() => props.max)
const checkedCount = computed(() => props.modelValue.length) // modelValue为数组
const checkboxGroupProvider: CheckboxGroupProvider = {
max, // 最大选择数量
checkedCount, // 当前已经选中的数量
onChecked,
onUnchecked,
validate,
resetValidation,
reset,
errorMessage: checkboxGroupErrorMessage,
}
了解了这些就很容易实现限制最大选择数量这个需求了,只需要在点击的时候判断是否达到阈值。如果当前复选框没有选中并且超出了最大值,就不触发修改的逻辑了。
全部选中或者全部反选所有的复选框
varlet
提供了两个方法用于全选或者全部反选复选框
这两个实例方法的实现思路是,checkboxGroup组件
触发函数执行,修改 modelValue
,通过 checkbox 组件
修改选中自身的状态,代码如下:
// expose
function checkAll() {
const checkedValues: any[] = checkboxes.map(({ checkedValue }) => checkedValue.value)
const changedModelValue: any[] = uniq(checkedValues)
resetWithAnimation()
call(props['onUpdate:modelValue'], changedModelValue) // 修改v-model绑定的值
return changedModelValue
}
// expose
function inverseAll() {
const checkedValues: any[] = checkboxes
.filter(({ checked }) => !checked.value)
.map(({ checkedValue }) => checkedValue.value)
const changedModelValue: any[] = uniq(checkedValues)
resetWithAnimation()
call(props['onUpdate:modelValue'], changedModelValue) // 修改v-model绑定的值
return changedModelValue
}
这里会触发 modelValue
修改
watch(() => props.modelValue, syncCheckboxes, { deep: true })
function syncCheckboxes() {
checkboxes.forEach(({ sync }) => sync(props.modelValue))
}
监听到修改之后,触发同步修改值的函数 sync
const value = useVModel(props, 'modelValue')
function sync(values: Array<any>) {
const { checkedValue, uncheckedValue } = props
value.value = values.includes(checkedValue) ? checkedValue : uncheckedValue
}
这里的 value.value
就是 checkbox组件
的绑定的值
checkbox组件状态修改同步checkboxGroup组件
checkbox组件
的状态更改也要同步到 checkboxGroup组件
function change(changedValue: any) {
const { checkedValue, onChange } = props
value.value = changedValue
isIndeterminate.value = false
call(onChange, value.value)
validateWithTrigger('onChange')
// 同步数据到checkboxGroup组件
changedValue === checkedValue ? checkboxGroup?.onChecked(checkedValue) : checkboxGroup?.onUnchecked(checkedValue)
}
如果不是当前这个状态,就去调用 checkboxGroup组件
的 onChecked
或者 onUnchecked
方法,修改 modelValue
function change(changedModelValue: any) {
// 更新 modelValue
call(props['onUpdate:modelValue'], changedModelValue)
call(props.onChange, changedModelValue)
validateWithTrigger('onChange')
}
function onChecked(changedValue: any) {
const { modelValue } = props
// 如果这个值不在modelValue中,就更新到modelValue中
if (!modelValue.includes(changedValue)) {
change([...modelValue, changedValue])
}
}
总结
以上就是文章的所有内容了,如有问题,欢迎指正。