通用类型的 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 类型(您可能需要开启暗黑模式才能看到这里的图片)
这是一个如何工作的示例:
<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);
参考: