Desmond

Desmond

An introvert who loves web programming, graphic design and guitar
github
bilibili
twitter

Vue 组合 API 的技巧

通用类型的 Vue 组件#

  • 在 Setup 中定义组件
  • 通用 props
  • 使用 SFC 作为基础组件
  • 从 SFC 组件中提取 Prop 类型
  • 通用插槽
  • 通用组件事件

BaseGenericComponent.vue

<template>
  <pre>
    <slot :current-value="value" :old-value="oldValue">
      {{ value }}
    </slot>
  </pre>
</template>
<script setup lang="ts">
import { watch, defineProps, ref, PropType } from 'vue';
const props = defineProps({
  value: {
    type: null as PropType<unknown>,
    required: true,
  },
});
const oldValue = ref<unknown>();
watch(
  () => props.value,
  (_, oldVal) => {
    oldValue.value = oldVal;
  }
);
</script>

types.ts

export type ExtractComponentProps<TComponent> =
  TComponent extends new () => {
    $props: infer P;
  }
    ? P
    : never;

useGenericComponent.ts

import { defineComponent, h, VNode } from 'vue';
import BaseGenericComponent from './BaseGenericComponent.vue';
import { ExtractComponentProps } from './types';
// 我们也省略了 `onChanged` 事件,以便我们可以覆盖它,和 `value` prop 是同样的情况
// 这是因为在 Vue 3 中事件和 props 的处理是相同的,除了事件有 `on` 前缀
// 所以如果你想将事件转换为 prop,你可以遵循这个约定(`changed` => `onChanged`)
type NonGenericProps = Omit<ExtractComponentProps<typeof BaseGenericComponent>, 'value' | 'onChanged'>
interface GenericProps<TValue> extends NonGenericProps {
  value: TValue;
}
interface GenericSlotProps<TValue> {
  currentValue: TValue;
  oldValue: TValue;
}
export function useGenericComponent<TValue = unknown>() {
  // 记得从第二个参数中获取插槽对象
  const wrapper = defineComponent((props: GenericProps<TValue>, { slots }) => {
    // 在 `setup` 中返回函数意味着这是渲染函数
    return () => {
      // 我们传递插槽和事件处理程序
      return h(BaseGenericComponent, props, slots);
    };
  });
  // 将包装器强制转换为自身,以便我们不会丢失现有组件类型信息
  return wrapper as typeof wrapper & {
    // 我们通过添加一个 `$slots` 对象并将插槽函数定义为属性来增强包装器类型
    new (): {
      // 和 `$slots` 一样的技巧,我们覆盖该组件的 emit 信息
      $emit: {
        (e: 'changed', value: TValue): void;
      };
      $slots: {
        // 每个函数对应一个插槽,其参数是可用的插槽 props
        // 这是默认插槽定义,它提供 `GenericSlotProps` 属性作为插槽 props。
        // 它应该返回一个 `VNode` 数组
        default: (arg: GenericSlotProps<TValue>) => VNode[];
      };
    };
  };
}

如何从 SFC 中提取 prop 类型(您可能需要开启暗黑模式才能看到这里的图片)
image

这是一个如何工作的示例:

<script lang="ts" setup>
import { useGenericComponent } from './genericComponent';
const StringComponent = useGenericComponent<string>();
interface User {
  id: number;
  name: string;
}
const ObjectComponent = useGenericComponent<User>();
function onChange(value: User) {
  console.log(value);
}
</script>
<template>
  <!-- 🛑  由于类型错误,这在 Volar 中现在应该会报错 -->
  <StringComponent
    :value="str"
    v-slot="{ currentValue, oldValue }"
    @changed="onChange"
  >
    <div>当前: {{ currentValue }}</div>
    <div>: {{ oldValue }}</div>
  </StringComponent>
  <ObjectComponent
    :value="userObj"
    v-slot="{ currentValue, oldValue }"
    @changed="onChange"
  >
    <div>当前: {{ currentValue }}</div>
    <div>: {{ oldValue }}</div>
  </ObjectComponent>
</template>

实用的通用组件#

这个 SelectInput 组件应该:

  • 接受一个选项列表。
  • 发出所选值并支持 v-model API(接受 modeValue prop 并发出 update:modelValue 事件)。
  • 暴露一个 item 插槽以自定义浮动菜单中每个选项的 UI。
  • 暴露一个 selected 插槽以自定义所选选项的 UI。

https://stackblitz.com/edit/vitejs-vite-wemb2u?embed=1

使用组合 API 减少组件噪音#

  • 我们可以在 setup 函数中动态构建组件并在模板中使用它。
  • 我们可以创建作为通用组件的组件包装器
    这在几个方面有帮助:
  • 预定义一些 Props
  • 以明确的行为暴露更清晰的 API
  • 隐藏一些复杂性

features/modal.ts

import { ref, defineComponent, h } from 'vue';
import ModalDialog from '@/components/ModalDialog.vue';
export function useModalDialog<TData = unknown>(
  onConfirmProp: (data: TData) => void
) {
  // 上下文数据
  const data = ref<TData>();
  function onClose() {
    data.value = undefined;
  }
  function show(value: TData) {
    data.value = value;
  }
  function hide() {
    data.value = undefined;
  }
  const DialogComponent = defineComponent({
    inheritAttrs: false,
    setup(_, { slots, emit }) {
      function onConfirmed() {
        if (data.value !== undefined) {
          onConfirmProp(data);
        }
      }
      return () =>
        h(
          ModalDialog, 
          {
            onClose,
            onConfirmed,
            visible: data.value !== undefined,
          },
          {
            default: () => slots.default?.({ data: data.value }),
          }
        );
    },
  });
  return {
    DialogComponent:
      DialogComponent as typeof DialogComponent & {
        // 我们通过添加一个 `$slots` 对象并将插槽函数定义为属性来增强包装器类型
        new (): {
          $emit: {
            (e: 'confirmed', data: TData): void;
          };
          $slots: {
            default: (arg: { data: TData }) => VNode[];
          };
        };
      },
    show,
    hide
  };
}

示例用法#

<script setup lang="ts">
import { ref } from 'vue';
import { useModalDialog } from '@/features/modal';
interface Item {
  id: number;
  name: string;
}
const items: Item[] = ref([
  //...
]);
const {
  show: onDeleteClick,
  DialogComponent: DeleteItemDialog,
  hide,
} = useModalDialog<Item>((item) => {
  const idx = items.value.findIndex((i) => i.id === item.id);
  items.value.splice(idx, 1);
  console.log('已删除', item);
  hide();
});
</script>
<template>
  <ul>
    <li
      v-for="item in items"
      :key="item.id"
      @click="onDeleteClick(item)"
    >
      {{ item.name }}
    </li>
  </ul>
  <DeleteItemDialog
    v-slot="{ data: itemToDelete }"
  >
    您确定要删除 {{ itemToDelete.name }} 吗?
  </DeleteItemDialog>
</template>

“当我点击一个项目时,打开一个对话框,当操作被确认时让我知道,以便我可以删除该项目”。

您可以创建某种 “共享” 模态对话框,供许多组件访问和使用。也许使用 provide/inject API:

// 在父页面或组件中
const { DialogComponent, ...modalApi } = useModalDialog();
// 使模态 API 可用于子组件
provide('modal', modalApi);
// 在子组件的其他地方:
const modal = inject('modal');
modal.show(data);

参考:

加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。