前言

我个人非常喜欢谷歌的 MD 设计,Vue 组件库这块 vuetify 是该设计的杠把子

尤其是升级到对应 Vue3v3 版本之后,其内部实现让人眼前一亮

大概是因为 Vue2 为数不多的逻辑复用方式 mixin 很容易让人眼前一黑,一对比下来就显得新版的 vuetify 很牛逼

属性透传

回到重点,在设计组件库时,嵌套组件的属性透传、默认值复写等一直让人头疼

vuetify 提供了一种较为优雅的解决方法,每个组件都是由二次封装defineComponent 方法声明的,例如 v-btn 的源码:

1
2
3
4
5
6
7
8
9
10
11
import { defineComponent, ... } from '@/util'
// 使用了 @util 下二次封装的 defineComponent 方法
export const VBtn = defineComponent({
name: 'VBtn',
directives: { ... },
props: { ... },
emits: { ... },
setup(props, { attrs, slots }) {
...
},
});

该方法构建了一个数据流,该数据流从组件树顶部传到各个叶子节点

只要上层节点提供了需要复写的参数,下层子组件只要在该数据流中(通过 defineComponent 声明),就必定会被复写参数,子组件无需主动去获取

数据不仅能够传递到嵌套多层的组件中,还能让其只在局部生效,非常灵活

实现

这玩意相当于一个中间件,接管了部分参数的 inject, provide 行为,复写了传参中的 setup 函数,总体实现可以总结成以下步骤:

  1. 从约定的 default 命名空间中 inject 参数哈希表

  2. 使用 setup 的外部传参 props 构建一个新的响应式 _props

  3. 根据当前组件名在哈希表中获得该组件需要覆盖的参数,合并后传给原本的 setup 函数

具体实现要复杂一些

处理组件声明中的 setup 和 props

  • 首先将原本的 setup 记为 _setup

    1
    options._setup = options._setup ?? options.setup;
  • 后续需要通过组件名来注入默认属性,所以检查下有无组件名

    1
    2
    3
    4
    5
    6
    if (!options.name) {
    consoleWarn(
    'The component is missing an explicit name, unable to generate default prop value'
    );
    return options;
    }
  • 格式化 props

    1
    2
    3
    4
    5
    6
    options.props = options.props ?? {};
    // propsFactory 是一个工具方法,便于定义选项式 props
    // 这里相当于格式化了以下,不用在意
    options.props = propsFactory(options.props, toKebabCase(options.name))();
    // 手动加入 _as 定义
    options.props._as = String;

    其中还添加了 _as 属性的声明

    当一个 v-btn 组件需要使用 v-tab 的默认属性时,可以在该组件上标记 _as="VTab"

复写 setup

1
2
3
options.setup = function setup (props: Record<string, any>, ctx) {
...
}

最核心的实现,简单说就是如下几步:

  1. props 变为响应式

  2. 记住子组件默认值

  3. 在响应式的 _props 上更新属性值

  4. 用响应式的 _props 执行原本的 _setup

  5. 将子组件的默认值传递(provide)到子组件,在子组件默认值发生变化时也会更新传递下去的值

  6. 返回执行原本 _setup 的返回值

详细说明:

  • 初始化一些变量

    1
    2
    3
    4
    5
    6
    7
    8
    // 当前组件实例
    const vm = getCurrentInstance()!;
    // 注入父组件提供的默认值,内部实现是 inject
    const defaults = useDefaults();
    // 用于存放子组件需要覆盖的默认值
    const _subcomponentDefaults = shallowRef();
    // 用于存放变为响应式的初始参数
    const _props = shallowReactive({ ...toRaw(props) });
  • 记录子组件需要覆盖的默认值,为当前组件覆盖默认值

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    watchEffect(() => {
    // 全局的需要覆盖的默认值
    const globalDefaults = defaults.value.global;
    // 根据组件名拿到当前组件需要覆盖的默认值
    const componentDefaults = defaults.value[props._as ?? options.name!];
    // 如果需要覆盖
    if (componentDefaults) {
    // 记录子组件需要覆盖的默认值
    const subComponents = Object.entries(componentDefaults).filter(([key]) =>
    key.startsWith(key[0].toUpperCase())
    );
    if (subComponents.length)
    _subcomponentDefaults.value = Object.fromEntries(subComponents);
    }

    // 遍历所有 prop
    for (const prop of Object.keys(props)) {
    // 记录新值
    let newVal = props[prop];
    /* 如果外部没有传入该参数,则优先使用需要覆盖的值
    * PS: 外部传入了的参数会在 vnode 的 props 中出现
    */
    if (!propIsDefined(vm.vnode, prop)) {
    newVal =
    componentDefaults?.[prop] ?? globalDefaults?.[prop] ?? props[prop];
    }
    // 更新响应式的属性
    if (_props[prop] !== newVal) {
    _props[prop] = newVal;
    }
    }
    });

    上面实现在 watchEffect 中,里面需要覆盖的默认值变动后会自动执行以更新值

  • 执行原有的 _setup,并记录返回值 setupBindings

    1
    const setupBindings = options._setup(_props, ctx);
  • 当子组件需要覆盖的数据变化时,需要更新提供给子组件的覆盖数据

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    let scope: EffectScope;
    watch(
    _subcomponentDefaults,
    (val, oldVal) => {
    // 如果不再需要覆盖了(为空),停止更新数据的行为
    if (!val && scope) scope.stop();
    // 如果是第一次声明需要覆盖的值
    else if (val && !oldVal) {
    // 上层更新时更新 provide 以将新的数据传到子节点
    scope = effectScope();
    scope.run(() => {
    provideDefaults(
    mergeDeep(injectSelf(DefaultsSymbol)?.value ?? {}, val)
    );
    });
    }
    },
    { immediate: true }
    );
  • 最后返回 _setup 的返回值

    1
    return setupBindings;