在讲解实现原理之前,我们首先需要思考的就是实现思路,一个表格主要分成两部分,第一就是表头,第二就是表体。
因此,我们的最终实现也是按照这两个部分去实现的,我们将采用tsx
语法来实现,因为tsx
语法实现起来更简单一些。
定义表格组件的props
首先创建一个tableProps.ts,然后定义我们的传入table的props属性,分别如下所示:
ts
export const tableProps = {
data: {
type: Array as PropType<Record<string, any>[]>,
default: () => [],
},
align: {
type: String,
default: "center",
},
column: {
type: Array as PropType<ElTableColumnProps[]>,
default: () => [],
},
};
这里涉及到了两个类型的定义,稍后我们会在类型当中讲到。从这段代码当中,我们也可以知道,我们的table的props属性包含了3个,即data,align,column,这也是我们在使用示例当中用到了的,其中data表示表体数据,由于数据是由使用用户决定的,通常来讲就是一个对象数组,因此,这里我们使用了Record
内置类型来定义,默认值是一个空数组,然后align表示全局设置内容的排练方式,有left,right,center三种值,默认值是center,表示居中,column的配置就稍微复杂了一点,我们后续再根据类型定义来详细说明。
事实上,如果我们使用js代码,应该如下所示:
js
export const tableProps = {
data: {
type: Array,
default: () => [],
},
align: {
type: String,
default: "center",
},
column: {
type: Array,
default: () => [],
},
};
笔记: vue props中定义方式,type表示数据类型,类型通常首字母大写(即String,Array,Object,Boolean等数据类型),然后vue内部提供了一个PropType用来定义具体的类型,as关键字表示断言,default即默认值。
表格列的封装
接下来我们来看表格列的封装,新建一个table-column.tsx
,vue提供了一个defineComponent
方法为我们定义一个组件,该方法传入一个对象参数,对象包含了props的定义,以及setup函数等,这个列的封装我们使用了tsx语法来实现。代码如下所示:
tsx
import { defineComponent } from "vue";
// ...
const TableColumn = defineComponent({
props: {
// ...
},
setup(props, { slots }) {
// ...
return () => <></>;
},
});
export default TableColumn
接下来,我们获取我们定义的props,然后传递给props
属性,如下所示:
tsx
import { defineComponent } from "vue";
//
import { MergeTableProps, tableProps } from "./tableProps";
export type ColumnTypeProps = Pick<MergeTableProps,'column' | 'align'>;
//
const columnProps = { column:tableProps.column,align:tableProps.align };
// ...
const TableColumn = defineComponent({
props: {
...(columnProps as ColumnTypeProps),
},
setup(props, { slots }) {
// ...
return () => <></>;
},
});
以上我们从tableProps里引入定义好的props,然后定义一个新的变量columnProps来接受,紧接着传递给defineComponent
的props参数。注意这里涉及到了MergeTableProps类型,我们将在后文介绍。
接下来我们在setup函数中获取props的column列数组,然后对每一列添加渲染列组件,并用作返回值渲染。代码如下所示:
tsx
// ...
const TableColumn = defineComponent({
props: {
...(columnProps as ColumnTypeProps),
},
setup(props, { slots }) {
// 渲染每一列
const renderColumn = (columnDict: Record<string, any>, index: number) => {
// ...
}
const columnsSlots = props?.column?.map(renderColumn);
return () => <>{columnsSlots}</>;
},
});
接下来就是针对一些列属性做规范化或者赋值,然后添加到列组件中,并返回这个列组件。代码如下所示:
tsx
// ...
const TableColumn = defineComponent({
props: {
...(columnProps as ColumnTypeProps),
},
setup(props, { slots }) {
// 渲染每一列
const renderColumn = (columnDict: Record<string, any>, index: number) => {
// ...
);
}
//...
},
});
这里我们使用v-slots
指令来解析属性,其中vSlots是一个对象,我们规范化或者赋值的属性都将存储到该对象当中,接下来我们获取列当中的配置属性render属性,以及slotName, headerSlot, children等属性,并根据这些属性来决定渲染的结果。首先当然是判断render是否是一个函数,如果是,则给vSlots赋值一个default属性,作为渲染的子组件。如果存在slotName属性,并且能够从slots上获取到自定义的slotName属性值,并且还是一个函数,则作为值赋值给default属性 ,如果children的长度大于0,则渲染children,如果存在headerSlot属性,并且slots[headerSlot]
是一个函数,则赋值给header属性。代码如下所示:
tsx
import { MergeTableProps, ScopeType, SlotsType, tableProps } from "./tableProps";
// ...
const TableColumn = defineComponent({
props: {
...(columnProps as ColumnTypeProps),
},
setup(props, { slots }) {
// 渲染每一列
const renderColumn = (columnDict: Record<string, any>, index: number) => {
// 获取配置属性
const { render, slotName, headerSlot, children, ...rest } = columnDict;
// 定义存储结果
const vSlots:SlotsType = {};
// default插槽和header插槽都应该是一个函数
// 判断render属性是否是一个函数
if (typeof render === "function") {
// scope为函数上下文
vSlots.default = (scope) => {
// 如果存在prop属性则传递参数,否则不传
if (rest.prop) {
return render(scope.row[rest.prop], scope);
}
return render(scope);
};
}
// 如果存在slotName属性 ,并且slots[slotName]是一个函数,则渲染,即自定义插槽
if (slotName && typeof slots[slotName] === "function") {
const defaultSlotName = slots[slotName] as ScopeType;
vSlots.default = scope => defaultSlotName(scope);
}
// 如果存在headerSlot属性,并且存在slots[headerSlot],则渲染header,即自定义列插槽
if (headerSlot && slots[headerSlot]) {
const headerSlotName = slots[headerSlot] as ScopeType;
vSlots.header = scope => headerSlotName(scope);
}
// 否则递归渲染children
if (children?.length > 0) {
vSlots.default = () => children.map(renderColumn);
}
// ...
}
const columnsSlots = props?.column?.map(renderColumn);
return () => <>{columnsSlots}</>;
},
});
以上涉及到了2个类型,即SlotsType和ScopeType,它们分别定义如下:
ts
export type SlotsType = {
default?: (scope: Record<string, any>) => any;
header?: (scope: Record<string, any>) => any;
};
export type ScopeType = (scope: any) => {}
这里的ts类型定义是不严格的,因此这2个类型也比较好理解,以上就是列组件的实现,接下来我们来看表格组件的实现。
表格组件的最终实现
实际上封装实现了列组件,那么表格组件就好实现了,与列组件一样,我们也是采用tsx语法来实现,同样也会用到defineComponent函数。代码如下所示:
tsx
import { defineComponent } from "vue";
import TableColumn from "./table-column";
// ...
export default defineComponent({
name: "ElementTable",
props: {
// ...
},
// 注册列组件
components: { TableColumn },
setup(props, { slots, attrs }) {
return () => (
<div class="element-table">
// ...
</div>
);
}
});
接下来,我们同样会从tableProps.ts中获取定义好的tableProps当做参数传递给defineComponent的props属性,然后这里会涉及到element table的一些方法,我们在mounted钩子函数里面去调用,然后在methods属性里面去定义,我们会给el-table绑定一个ref属性,然后通过this上下文去获取,并添加到this对象当中。在el-table中,我们也会使用v-slots指令,这个指令配置了2个参数append与empty属性,如果slots属性中存在对应的属性,则添加进去,最后我们将table-column组件渲染到el-table组件中去。代码如下所示:
tsx
import { defineComponent } from "vue";
import TableColumn from "./table-column";
import { MergeTableProps, MethodNameType, tableProps } from "./tableProps";
export default defineComponent({
name: "ElementTable",
props: {
...(tableProps as MergeTableProps),
},
components: { TableColumn },
mounted() {
// 调用方法注册el-table组件的一些方法
this.injectTablePrimaryMethods();
},
methods: {
// 注册方法
injectTablePrimaryMethods() {
const _self = this as ThisType<unknown>;
const elTableRef = _self['$refs']['elTableRef'];
const tableMethodNameList: MethodNameType[] = [
"clearSelection",
"toggleRowSelection",
"toggleAllSelection",
"toggleRowExpansion",
"setCurrentRow",
"clearSort",
"clearFilter",
"doLayout",
"sort",
];
for (const name of tableMethodNameList) {
if (!(_self[name])) {
_self[name] = elTableRef?.[name];
}
}
}
},
setup(props, { slots, attrs }) {
return () => (
<div class="element-table">
<el-table
data={props.data}
ref="elTableRef"
{...attrs}
v-slots={{
append: () => slots.append && slots.append(),
empty: () => slots.empty && slots.empty(),
}}
>
// 渲染table-column
<table-column column={props.column} align={props.align} v-slots={slots}></table-column>
</el-table>
</div>
);
}
});
这里涉及到了ThisType类型,它是ts的内置类型,以及MethodNameType类型,它的类型定义如下:
ts
import type ElTable from "element-plus/lib/components/table";
// InstanceType 为ts内置类型,表示获取实例类型,使用typeof用于将导入的el-table组件转化成类型
export type ElTableType = InstanceType<typeof ElTable>;
export type MethodNameType = keyof ElTableType;
以上ts类型定义,我们也已经在注释中说明,没有什么可以细讲的,接下来就是我们的最后一步了。
将封装好的table组件用作插件注册到vue中
最后我们会定义一个install方法,然后添加到table中去当做属性,而在install方法内部,我们将使用vue中提供的component方法去注册这个组件,这是vue中实现插件的方式,代码如下所示:
ts
import { App,createApp } from "vue";
import Table from './elementTable';
// install方法接收2个参数,即app应用实例,与配置对象
function install(app: ReturnType<typeof createApp>, options = {}) {
// app.component注册组件
app.component(Table.name, Table);
}
// 定义类型
export type ElementTable = typeof Table & {
install: (app: App, options: Record<string, any>) => App;
};
Table.install = install;
// 导出table
export default Table as ElementTable;
export { Table };
组件实现就到此为止了,接下来我们来看类型定义。
类型的定义
既然要使用tsx
语法,那么我们就避免不了需要定义一些类型,首先创建一个table.d.ts
文件,然后我们使用declare关键字创建一个namespace空间,名字就叫ElementTable,如下所示:
ts
declare namespace ElementTable {
// ... 在这里定义一些类型
}
第一个类型是ConditionalKeys
,该类型接受2个参数,第一个参数代表一个接口,第二个参数代表一个条件类型,如字符串的组合等。这个类型的整体含义就是,要从第一个接口参数当中提取符合第二个条件类型参数的属性,有点类似Pick
类型的作用。代码如下所示:
ts
export type ConditionalKeys<T,U> = NonNullable<{
[K in keyof T]: K extends U ? K : never
}[keyof T]>
这一个类型涉及到了ts的不少知识点,分别总结如下:
NonNullable
代表提取一个非null和undefined的类型,是ts的内置类型。- keyof 关键字用于提取接口的属性。
- extends关键字代表条件判断。
- 读取接口属性值使用
[]
语法。 - in操作符用于读取类型属性。
- never类型表示从不,也是一个类型。
这个类型将用于我们需要从element table当中提取方法属性名的时候用到,后面会说到。
第二个类型则是EventKey
类型,顾名思义,就是表示事件属性,代码如下所示:
ts
export type EventKey = NonNullable<EventKeyVal[keyof EventKeyVal]>;
这个类型实际上就是一个any类型,因为EventKeyVal
本身就是any类型。
第三个类型则是CamelEventKey
,该类型表示驼峰化事件属性,代码如下所示:
ts
export type CamelEventKey<T extends string> = {
[key in T]: key extends `on${infer A}-${infer B}`
? `on${A}${Capitalize<B>}`
: key;
};
用法如下所示:
ts
type A = CamelEventKey<'on-click'> // 'onClick'
这个类型用到了内置类型Capitalize
和infer
关键字,infer
关键字我们可以理解为推断一个类型。接下来我们来看后续的类型定义,都比较好理解,代码如下:
ts
export type eventJsx = CamelEventKey<
CamelEventKey<EmitKeyMethod>[keyof CamelEventKey<EmitKeyMethod>]
>;
export type eventKeyName = eventJsx[keyof eventJsx];
export type eventMethodProps = {
[key in eventKeyName]: (...args: any[]) => any;
};
export type KeyConstructor<Base extends object> = {
[KeyProp in keyof Base]: PropType<Base[KeyProp]>;
};
eventJsx类型就是递归加泛型类型,后续的类型也都是读取属性,然后定义接口类型,比较好理解。接下来我们来看tableProps.ts里面的类型定义,代码如下:
ts
import type ElTable from "element-plus/lib/components/table";
import type { ElTableColumn } from "element-plus/lib/components/table";
import { PropType } from "vue";
export type ElTableType = InstanceType<typeof ElTable>;
export type ElTableProps = ElTableType["$props"];
export type UserElTableColumnProps = {
slotName?: string;
headerSlot?: string;
render?: (...arg: any[]) => any;
children?: ElTableColumnProps[];
};
export type EmitKeyMethod = ElementTable.ConditionalKeys<
ElTableType,
`on${string}`
>;
export type ElTableColumnProps = InstanceType<typeof ElTableColumn>["$props"] &
UserElTableColumnProps;
export type OmitTableProp = Required<
Omit<ElTableProps, "data" | "class" | ElementTable.EventKey>
>;
export type KeyConstructor<Base extends object> = {
[KeyProp in keyof Base]: PropType<Base[KeyProp]>;
};
export type TableValidProps = KeyConstructor<OmitTableProp>;
export type MergeTableProps = typeof tableProps &
ElementTable.eventMethodProps & {
otherProps: PropType<Record<string, any>>;
summaryMethod: PropType<(...param: any[]) => string[]>;
} & TableValidProps;
这里最难的应该就是MergeTableProps类型了,它其实是三个类型的联合类型,其它的都是基础的接口类型,或者读取接口类型。其中Required类型表示将所有属性变作必选类型,是ts的一个内置类型。
总结
我们将table组件的封装分成了2步,第一步封装列组件,然后将列组件注册到表格组件中,组合在一起得到我们最终的table组件,并且我们还添加了install方法,将组件封装成插件的形式。
如果觉得本文不错,望不吝啬点赞收藏,如有错误,敬请指正。
以上代码详情地址源码。