来聊聊vue3组合式api组件封装的进阶玩法

439 阅读4分钟

前言

之前写了篇文章 还在烦恼满足不了坑爹产品的奇葩需求?来感受下Vue3组合式api的魅力! ,介绍了一种组件封装的新思路,接下来我们就着实际业务场景来思考一下怎么改良这个思路。

一定要先去看上面的文章,否则会对本篇代码感到陌生

源码地址

业务场景

老板闲的没事儿想把公司项目里的弹框捣鼓捣鼓,便来问你能不能在不同的场景展示不同的弹框效果。尽管是很是不解:这老登没事儿加什么工作量?如果按照以前的弹框封装方式,代码肯定会十分冗余,有没有什么好的偷懒方式呢?

具体实现

我们之前实现了一个组件最基本的功能,接下来我们针对该组件再明确一下要实现的需求:

  • 组件要可以抽离出来,并且参数可以灵活传递
  • 抽离组件不能起冲突,里面的方法要可以在页面上使用

来看我们的 useDemo 函数,里面有一个 listRef 变量,这是每个组件的实例对象。

那么我们能不能在这个实例对象上做做文章,在 useDemo 文件里声明一个公共对象 xxxData ,以 组件 uidkeyvisiblevalue,然后创建一个 useDemoInner 函数,使用一个公共 Ref 实例作为该函数回调的传参。

先用 vuegetCurrentInstance() 获取 BasicDemo.vue 的实例,然后改造 getPropssetProps ,注意新增的 visibleRef 变量和 visible-change 事件 ,具体代码如下:

// BasicDemoPlus.vue
<script setup lang="ts">
  import { computed, getCurrentInstance, nextTick, ref, unref, watch } from 'vue';
  import { DemoActionType, DemoPropsType } from './types';
  import { demoProps } from './props';
  import { deepMerge } from '/@/utils';

  const props = defineProps(demoProps);

  const emit = defineEmits(['register', 'visible-change']);

  const propsRef = ref<Partial<DemoPropsType>>();
  const visibleRef = ref<boolean>(false);

  const instance = getCurrentInstance();

  const getMergeProps = computed(() => {
    return { ...props, ...unref(propsRef) };
  });

  const getProps = computed(() => {
    const opt = {
      ...unref(getMergeProps),
      visible: unref(visibleRef),
    };
    return opt as DemoPropsType;
  });

  const setProps = (props: Partial<DemoPropsType>) => {
    // deepMerge函数可以把两份props合并
    propsRef.value = deepMerge(unref(propsRef) || {}, props);
    if (Reflect.has(props, 'visible')) {
      visibleRef.value = !!props.visible;
    }
  };

  const demoAction: DemoActionType = {
    setProps,
    emitVisible: undefined,
  };

  instance && emit('register', demoAction, instance.uid);

  watch(
    () => visibleRef.value,
    (v) => {
      nextTick(() => {
        emit('visible-change', v);
        instance && demoAction.emitVisible?.(v, instance.uid);
      });
    },
  );
</script>

<template>
  <div>{{ getProps }}</div>
</template>

接着转到 useDemoPlus.ts 文件,声明 visibleData 公共数组变量和 dataTransFerRef 组件对应实例对象数据。

// useDemoPlus.ts
import { computed, onUnmounted, reactive, ref, toRaw, unref, watch } from 'vue';
import { getDynamicProps } from '/@/utils/props';
import { DemoActionType, DemoPropsType, ReturnMethods, UseDemoReturnType } from '../types';
import { isEqual } from 'lodash-es';

const dataTransFerRef = reactive({});
const visibleData = reactive<{ [key: number]: boolean }>({});

export function useDemoPlus(props: DemoPropsType): UseDemoReturnType {
  // 组件实例
  const listRef = ref<Nullable<DemoActionType>>(null);
  // 组件uid
  const uid = ref<string>('');

  // 确保获取到组件实例
  function getDemo() {
    const list = unref(listRef);
    if (!list) {
      console.log('demo示例尚未获取,请确保在执行操作时已呈现demo!');
    }
    return list as DemoActionType;
  }

  // 注册组件
  function register(instance: DemoActionType, uuid: string) {
    // 确保性能
    onUnmounted(() => {
      listRef.value = null;
    });

    // 组件实例赋值
    listRef.value = instance;

    uid.value = uuid;

    // 声明赋值函数
    instance.emitVisible = (visible: boolean, uid: number) => {
      visibleData[uid] = visible;
    };

    watch(
      () => props,
      () => {
        // getDynamicProps函数可以把ref数据转为unref数据
        props && instance.setProps(getDynamicProps(props));
      },
      {
        immediate: true,
        deep: true,
      },
    );
  }

  // 组件暴露出去的方法
  const method: ReturnMethods = {
    setProps: async (props: Partial<DemoPropsType>) => {
      const demo = await getDemo();
      demo.setProps(props);
    },
    getVisible: computed((): boolean => {
      return visibleData[~~unref(uid)];
    }),
    openDialog: <T = any>(visible = true, data?: T, openOnSet = true): void => {
      getDemo()?.setProps({
        visible: visible,
        ...data,
      });
      if (!data) return;
      if (openOnSet) {
        // 重复赋值会为了避免原有数据的影响
        dataTransFerRef[unref(uid)] = null;
        dataTransFerRef[unref(uid)] = toRaw(data);
      }
      const equal = isEqual(toRaw(dataTransFerRef[unref(uid)]), toRaw(data));

      if (!equal) {
        dataTransFerRef[unref(uid)] = toRaw(data);
      }
    },
    closeDialog: () => {
      getDemo()?.setProps({ visible: false });
    },
  };

  return [register, method];
}

声明 useDemoPlusInner 函数作为子页面调用的函数,注意该函数必须在 setup 函数中使用。

这个函数的关键点在于 watchEffect ,代码如下:

// useDemoPlus.ts
import {
  computed,
  getCurrentInstance,
  nextTick,
  onUnmounted,
  reactive,
  ref,
  toRaw,
  unref,
  watch,
  watchEffect,
} from 'vue';
import { getDynamicProps } from '/@/utils/props';
import {
  DemoActionType,
  DemoPropsType,
  ReturnMethods,
  UseDemoReturnType,
  UseDemoInnerReturnType,
  ReturnInnerMethods,
} from '../types';
import { isEqual, isFunction } from 'lodash-es';

const dataTransFerRef = reactive({});
const visibleData = reactive<{ [key: number]: boolean }>({});

...
...

export function useDemoPlusInner(callbackFn?: Fn): UseDemoInnerReturnType {
  const dialogInstanceRef = ref<Nullable<DemoActionType>>(null);
  const currentInstance = getCurrentInstance();
  const uidRef = ref<string>('');

  if (!getCurrentInstance()) {
    throw new Error('useDemoPlusInner()只能在setup或component内部使用!');
  }

  const getInstance = () => {
    const instance = unref(dialogInstanceRef);
    if (!instance) {
      console.log('useDemoPlusInner实例未创建!');
      return;
    }
    return instance;
  };

  const register = (instance: DemoActionType, uuid: string) => {
    uidRef.value = uuid;
    dialogInstanceRef.value = instance;
    // 与useDialog建立emit联系
    currentInstance?.emit('register', instance, uuid);
  };

  watchEffect(() => {
    const data = dataTransFerRef[unref(uidRef)];
    if (!data) return;
    if (!callbackFn || !isFunction(callbackFn)) return;
    nextTick(() => {
      callbackFn(data);
    });
  });

  const methods: ReturnInnerMethods = {
    getVisible: computed((): boolean => {
      return visibleData[~~unref(uidRef)];
    }),
    closeDialog: () => {
      getInstance()?.setProps({ visible: false });
    },
    setProps: (props: Partial<DemoPropsType>) => {
      getInstance()?.setProps(props);
    },
  };

  return [register, methods];
}

在页面上使用

// Demo.vue  主模块
<script setup lang="ts">
  import { useDemoPlus } from '/@/components/hp-demo-plus';
  import DemoPlus from './DemoPlus.vue';
  // import { BasicDemo, useDemo } from '/@/components/hp-demo';

  // const [register, { setProps }] = useDemo({
  //   field1: '示例数据',
  // });
  const [DemoPlusRegister, { openDialog }] = useDemoPlus();
</script>

<template>
  <div>
    <!-- <span @click="setProps({ field1: '修改后数据1' })">点击修改参数1</span>
    <span @click="setProps({ field1: '修改后数据2' })">点击修改参数2</span> -->
    <span @click="openDialog(true, { field1: '父页面传递过去的数据' })">点击修改plus3</span>
    <!-- <BasicDemo @register="register" /> -->
    <DemoPlus @register="DemoPlusRegister" />
  </div>
</template>

// DemoPlus.vue  子模块
<script setup lang="ts">
  import { BasicDemoPlus, useDemoPlusInner } from '../components/hp-demo-plus';
  const [register] = useDemoPlusInner((data) => {
    console.log(data);
  });
</script>

<template>
  <div>
    <BasicDemoPlus @register="register" />
  </div>
</template>

来看下效果

动画.gif

这种思路适合在交互类组件中使用,比如弹框等等。

结语

本篇文章没有实现实际的需求,只是提供一种组件封装的思路,这些是我自己经过思考和学习开源代码总结的,俗话说一个个摘瓜,不如抓住瓜藤,掌握一种思路就可以应对各种业务场景。