深入解析 varlet use 双hook:通过订阅发布模式和依赖注入实现深层级组件数据传递

248 阅读7分钟

前言

两三周前就准备写这篇文章了,但一直比较忙,直到本周内才开始断断续续写。刚好本周末有空,就来补上这篇文章。本文主要分析 varlet use 的两个hook useChildrenuseParent 的源码以及在 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>

复选框组组件 会提供一个默认插槽,就可以把 复选框组件 当为子组件传进来

image.png

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,
  }
}

这里有两个关键函数 collectclear,一个用来收集子组件提供的实例和数据,一个用来清除,类似于发布订阅模式的订阅和取消订阅,至于发布,下面实践部分基本都是在执行发布。

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 是什么

这两个字段分别代表复选框 选中状态的值未选中的值,默认值为 truefalse

image.png

熟悉 element plus 的掘友可能已经想到了 true-valuefalse-value

image.png

考虑到如果数据并不是简单的布尔类型,而需要传递特定的字符串或者数值,在后台处理服务器交互和数据存储时,会更加灵活和友好。

为什么有些属性用computed包了一层

比如说 checkbox 组件的 是否选中状态属性 checked

image.png

一方面的考虑是 checkbox 的选中状态只由该组件控制,不允许其他组件控制,比如 checkboxGroup 组件。只允许其他组件读取选中的状态,不能修改,确保数据流向是稳定可控的。

这里有个编程概念,防御性编程。当然这个不是说要让代码变得很难维护,而是让代码更加健壮,易于阅读和维护。

image.png

实践

我们拿 复选框组件 举例,站在库开发者角度去考虑这个组件有什么样的应用场景以及该怎么把上面说到的内容具体的应用起来。

checkboxGroup

先来想想 checkboxGroup 组件可能会有什么样的应用场景呢?

  • 获取所有选中的复选框
  • 限制复选框的最大选择数量
  • 全部选中或者全部反选所有的复选框
  • 调整复选框的布局(水平或者垂直)
  • ...

checkbox

checkbox 有什么状态需要暴露给 checkboxGroup 呢?

  • 是否选中
  • 选中后的值
  • 同步值
  • ...

实践

现在来解答上面的点

获取所有选中的复选框

怎么获取所有选中的复选框呢?前面说过,useChildren 这个 hook 会收集 checkbox 组件提供的数据,所以第一步要执行 useChildren,来获取所有的 checkbox 组件提供的数据

image.png

image.png

checkbox 组件提供的数据为

const checked = computed(() => value.value === props.checkedValue)
const checkedValue = computed(() => props.checkedValue)

const checkboxProvider: CheckboxProvider = {
  checkedValue, // 选中的值
  checked,  // 是否选中了当前checkbox
  sync,
  validate,
  resetValidation,
  reset,
  resetWithAnimation,
}

image.png

既然拿到了所有的复选框提供的数据,获取所有选中的复选框就只需要过滤出来 checked.valuetrue 的值就行了

const checkedBoxes = checkboxes.filter(({ checked }) => checked.value)

限制复选框的最大选择数量

实际需求开发中,有时候会需要限制复选框的选中数量,比如问卷调查最多只能选择3个爱好,学生选课最多只能选择3门选修课等。varletcheckboxGroup 组件提供了一个 max 属性,用来限制复选框的最大选择的数量。

maxcheckboxGroup 的属性,而上一节说过,checkbox 组件是否选中是它本身在控制,那么该怎么处理这个问题呢?

varlet 的 做法是通过 vue3 provide 将数据注入到子组件中

image.png

image.png

第二张图解构的 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,
}

了解了这些就很容易实现限制最大选择数量这个需求了,只需要在点击的时候判断是否达到阈值。如果当前复选框没有选中并且超出了最大值,就不触发修改的逻辑了。

image.png

全部选中或者全部反选所有的复选框

varlet 提供了两个方法用于全选或者全部反选复选框

image.png

这两个实例方法的实现思路是,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])
  }
}

总结

以上就是文章的所有内容了,如有问题,欢迎指正。