開發後臺管理系統,在業務上接觸的最多就是表單(輸入)和表格(輸出)了。對於使用 Vue 框架進行開發的同學來說,組件庫 Element 是肯定會接觸的,而其中的 el-table 和 el-form 更是管理系統中的常客。
然而,一旦項目的表格或表單多起來,每個不同的配置,以及多一個字段少一個字段,都要在 template 中重新寫一大段組件代碼,顯得非常麻煩。或許你會考慮將這些代碼封裝起來,可是又會發現,封裝的表格、表單大多數隻在一處地方使用,還不如不封裝呢。到底要如何封裝,可以讓每處使用 el-table 或 el-form, 都可以復用相同的組件,減少代碼量的同時又具備高度的可定制性?
本文章將會按照從無到有的步驟,按照封裝組件常用的思路來封裝 el-table,並且實現封裝完成的組件支持 el-table 的全配置。在封裝的過程中,你將會看到:
- 如何抽取組件。
- 巧用屬性透傳。
- v-html、component 組件、h 函數、動態組件的應用。
- 具名插槽、作用域插槽。
- v-bind 的妙用。
- 實現插槽透傳的方法。
一般的組件封裝思路
以下是 el-table 在項目中常用的寫法:el-table 接受一個數組 data 作為數據,在 el-table 元素中插入多個 el-table-column 組件,用於定義列的名稱(label),數據來源(prop),以及其它列的定制配置(width 等)。在實際項目中,往往不止幾行 column,甚至三四十行都有可能(不過一般超過十行,最好考慮把次要的信息放在詳情中展示,而不是全部列在表格上,除非是業務需要在表格上瀏覽所有數據),而且每個 column 都可能會有不同的配置,比如排序、fix、不同寬度、插入增刪改按鈕等,這就使得一個表格的代碼會變得又長又復雜,如果還要寫其它業務功能,會大大地降低模板代碼的可讀性。
<template>
<el-table :data="tableData" style="width: 100%">
<el-table-column prop="a" label="aName" width="180" />
<el-table-column prop="b" label="bName" width="180" />
<el-table-column prop="c" label="cName" width="180" />
<el-table-column prop="d" label="dName" width="180" />
<el-table-column prop="e" label="eName" width="180" />
<el-table-column prop="f" label="fName" width="180" />
<el-table-column prop="g" label="gName" width="180" />
<el-table-column prop="h" label="hName" width="180" />
<el-table-column prop="i" label="iName" width="180" />
</el-table>
</template>
<script lang="ts" setup>
const tableData = new Array(9).fill({
a: "2016-05-03",
b: "Tom",
c: "No. 189, Grove St, Los Angeles",
d: "No. 189, Grove St, Los Angeles",
e: "2016-05-03",
f: "Tom",
g: "No. 189, Grove St, Los Angeles",
h: "2016-05-03",
i: "Tom",
});
</script>
當然,這種情況,一般都會將它抽取為組件:
// CustomTable.vue
<template>
<el-table :data="tableData" style="width: 100%">
<el-table-column prop="a" label="aName" width="180" />
<el-table-column prop="b" label="bName" width="180" />
<el-table-column prop="c" label="cName" width="180" />
<el-table-column prop="d" label="dName" width="180" />
<el-table-column prop="e" label="eName" width="180" />
<el-table-column prop="f" label="fName" width="180" />
<el-table-column prop="g" label="gName" width="180" />
<el-table-column prop="h" label="hName" width="180" />
<el-table-column prop="i" label="iName" width="180" />
</el-table>
</template>
<script lang="ts" setup>
defineProps<{
tableData: Array<any>;
}>();
</script>
然後在頁面中使用:
// App.vue
<template>
<CustomTable :tableData="tableData"></CustomTable>
</template>
<script lang="ts" setup>
import CustomTable from "./CustomTable.vue";
const tableData = new Array(9).fill({
a: "2016-05-03",
b: "Tom",
c: "No. 189, Grove St, Los Angeles",
d: "No. 189, Grove St, Los Angeles",
e: "2016-05-03",
f: "Tom",
g: "No. 189, Grove St, Los Angeles",
h: "2016-05-03",
i: "Tom",
});
</script>
一般封裝的過程到這裡就結束了。可見,這種封裝既將表格從頁面中抽取出來方便單獨維護,又提高了頁面代碼的可讀性。然而,這種封裝方式並沒有解決開篇提到的書寫重復代碼的問題,而且還比沒有封裝多了一些操作,其實仍然是一種“體力活”。
封裝表格數據Api
為了後面演示方便,先將表格數據的 api 封裝起來。
// src/api/index.ts
export function getData() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
data: new Array(9).fill({
a: "2016-05-03",
b: "Tom",
c: "No. 189, Grove St, Los Angeles",
d: "No. 189, Grove St, Los Angeles",
e: "2016-05-03",
f: "Tom",
g: "No. 189, Grove St, Los Angeles",
h: "2016-05-03",
i: "Tom",
}),
});
}, 200);
});
}
同時,組件的位置也改為
src/components/custom-table/index.vue。
// App.vue
<template>
<CustomTable :tableData="tableData"></CustomTable>
</template>
<script lang="ts" setup>
import CustomTable from "./components/custom-table/index.vue";
import { getData } from "./api/index";
import { onMounted, ref } from "vue";
const tableData = ref<any>([]);
onMounted(() => {
getData().then((res: any) => {
tableData.value = res.data;
});
});
</script>
v-for 復用 el-table-column
先回到最初的代碼,來解決 el-table-column 復用的問題。首先暫時不考慮 el-table-column 定制化屬性的需求,先把下面的代碼量減少,如何實現?很簡單,使用 v-for:
// src\components\custom-table\index.vue
<template>
<el-table :data="tableData" style="width: 100%">
<el-table-column
v-for="column in tableHeaders"
:key="column"
:prop="column"
:label="column 'Name'"
width="180"
></el-table-column>
</el-table>
</template>
<script lang="ts" setup>
import { computed } from "vue";
const prop = defineProps<{
tableData: Array<any>;
}>();
const tableHeaders = computed(() => Object.keys(prop.tableData[0] || {}));
</script>
這裡,我們使用數據的 key 作為列名,數據有多少個字段,就可以顯示多少列,當數據的列數發生改變時,還不需要修改任何代碼。
自定義列名和列的屬性
自定義列名
然而,數據的 key 作為列名的情況很少(至少在我們這裡,一般是使用中文作為列名的),這就需要我們使用可定制的列名,並且,如果我們不想展示某些字段,上面的寫法也是做不到的(它會顯示數據的所有字段)。
這時候,我們隻需要一個映射(mapper)就可以解決這些問題。該對象的每一個屬性對應每一列的 prop、key,值對應列的列名 label。
// App.vue
// 定義新的Header結構,key為column的prop/key,value為column的label
const tableHeaderMapper = {
a: "列a",
b: "列b",
c: "列c",
d: "列d",
e: "列e",
f: "列f",
g: "列g",
h: "列h",
i: "列i",
};
遍歷並綁定:
// src\components\custom-table\index.vue
<template>
<el-table :data="tableData" style="width: 100%">
<el-table-column
v-for="(value, key) in tableHeaders"
:key="key"
:prop="key"
:label="value"
></el-table-column>
</el-table>
</template>
<script lang="ts" setup>
export type Mapper<T> = {
[P in keyof T as string]?: string;
};
defineProps<{
tableData: Array<any>;
tableHeaders: Mapper<any>;
}>();
</script>
使用:
<CustomTable :tableData="tableData" :table-headers="tableHeaderMapper"></CustomTable>
v-bind 綁定所有屬性
在 Vue 中,可以使用 v-bind 直接綁定對象,簡化很多代碼:
<el-table-column
:key="key"
:prop="key"
:label="value"
></el-table-column>
等同:
<el-table-column
v-bind="{key:key,prop:key,label:value}"
></el-table-column>
所以,我們其實可以這樣定義 tableHeaders!
const tableHeaderMapper = {
a: {
label: "列a",
key: "a",
prop: "a"
},
...
};
改寫組件代碼:
// src\components\custom-table\index.vue
// v-bind 直接綁定一個對象
<template>
<el-table :data="tableData" style="width: 100%">
<el-table-column
v-for="column in tableHeaders"
v-bind="column"
></el-table-column>
</el-table>
</template>
<script lang="ts" setup>
export type Mapper<T> = {
[P in keyof T as string]?: object; // 從string類型改為object類型
};
defineProps<{
tableData: Array<any>;
tableHeaders: Mapper<any>;
}>();
</script>
如果覺得重復寫 key、prop 太冗餘,又或者想兼容之前自定義列名的寫法,可以在自定義組件中對 tableHeaders 進行處理,生成 newTableHeaders。
// src\components\custom-table\index.vue
<template>
<el-table :data="tableData" style="width: 100%">
<el-table-column
v-for="column in newTableHeader"
v-bind="column"
></el-table-column>
</el-table>
</template>
<script lang="ts" setup>
import { ref, onMounted, onBeforeUpdate } from "vue";
export type Mapper<T> = {
[P in keyof T as string]?: string | object;
};
const prop = defineProps<{
tableData: Array<any>;
tableHeaders: Mapper<any>;
}>();
const newTableHeader = ref<any>({});
const genNewTableHeader = () => {
newTableHeader.value = { ...prop.tableHeaders };
const rawAttr = prop.tableHeaders;
for (let key in rawAttr) {
let column = rawAttr[key];
if (typeof column === "string") {
Reflect.set(newTableHeader.value, key, {
key: key,
prop: key,
label: column,
});
}
// 其實此時一定是對象了,此處判斷是用於ts類型收窄
if (typeof column === "object") {
// 設置默認的key
if (!Reflect.has(column, "key")) {
Reflect.set(column, "key", key);
}
if (!Reflect.has(column, "label")) {
Reflect.set(column, "label", key);
}
// 設置默認的prop,如果該列是多選項,則不需要prop
if (
!Reflect.has(column, "prop") &&
!(
Reflect.has(column, "type") &&
Reflect.get(column, "type") == "selection"
)
) {
Reflect.set(column, "prop", key);
}
}
}
console.log(newTableHeader.value);
};
onMounted(genNewTableHeader);
onBeforeUpdate(genNewTableHeader);
</script>
使用:
// App.vue
const tableHeaderMapper = {
a: {
label: "列a",
width: "200",
},
b: "列b",
c: "列c",
d: "列d",
e: "列e",
f: "列f",
g: "列g",
h: "列h",
i: "列i",
};
到這裡,對 el-tabl 的封裝已經相對完善了,我們不需要書寫復雜的 el-table-column,隻需傳入 tableData 和 tableHeaders,就可以自由定制我們的表格列了。
表格透傳
我們解決了表格列屬性問題,現在,如果我們還想要表格屬性和表格列屬性一樣可以自由傳入,如何實現?
屬性、樣式、事件透傳
Vue 天然支持屬性、樣式、事件透傳,我們直接在 CustomeTable 上書寫我們想要的 el-table 屬性或事件即可!
// App.vue
<template>
<CustomTable
:tableData="tableData"
:table-headers="tableHeaderMapper"
v-loading="loading"
></CustomTable>
</template>
<script lang="ts" setup>
...
const loading = ref(false);
onMounted(() => {
loading.value = true;
getData().then((res: any) => {
tableData.value = res.data;
loading.value = false;
});
});
</script>
插槽透傳
仔細觀察 el-table 屬性,發現它還支持三個插槽:默認插槽、append、empty。我們的 CustomTable 也要支持!
先回顧以下我們的 CustomTable。在 custom-table/index. vue 中,我們並沒有書寫 slot 標簽,所以在 App. vue 中往 CustomTable 插入標簽是不會被渲染的,所以我們需要給 CustomTable 添加 slot 插槽,要添加什麼樣的插槽呢?和 el-table 提供同名的插槽,並在 custom-table/index. vue 中的 el-table 中插入。即,CustomTable 提供插槽,App. vue 寫入插槽,CustomTable 讀取到插槽,並把插槽的內容寫入 el-table 中。插槽的內容是這樣傳遞的:App. vue -> CustomTable -> el-table。
在 CustomTable 中開始寫插槽前,會發現,我們已經使用了 el-table 的插槽,將我們 v-for 生成的 column 插入到 el-table 的默認插槽中了。這個時候,我們需要改變我們的寫法:將 column 的生成也拆分為組件!然後傳入給 CustomTable,而 CustomTable 的職責則變為:隻負責從 App. vue 傳遞插槽給 el-table。符合單一職責的封裝原則!
// 新的CustomTable,隻負責傳遞插槽
<template>
<el-table :data="tableData" style="width: 100%">
<slot></slot>
</el-table>
</template>
<script lang="ts" setup>
defineProps<{
tableData: Array<any>;
}>();
</script>
新建
src/components/custom-column/index. vue,修改少許代碼。
// 抽取為CustomColumn
<template>
<el-table-column
v-for="column in newTableHeader"
v-bind="column"
></el-table-column>
</template>
<script lang="ts" setup>
import { ref, onMounted, onBeforeUpdate } from "vue";
export type Mapper<T> = {
[P in keyof T as string]?: string | object;
};
const prop = defineProps<{
tableHeaders: Mapper<any>;
}>();
const newTableHeader = ref<any>({});
const genNewTableHeader = () => {
newTableHeader.value = { ...prop.tableHeaders };
const rawAttr = prop.tableHeaders;
for (let key in rawAttr) {
let column = rawAttr[key];
if (typeof column === "string") {
Reflect.set(newTableHeader.value, key, {
key: key,
prop: key,
label: column,
});
}
// 其實此時一定是對象了,此處判斷是用於ts類型收窄
if (typeof column === "object") {
// 設置默認的key
if (!Reflect.has(column, "key")) {
Reflect.set(column, "key", key);
}
if (!Reflect.has(column, "label")) {
Reflect.set(column, "label", key);
}
// 設置默認的prop,如果該列是多選項,則不需要prop
if (
!Reflect.has(column, "prop") &&
!(
Reflect.has(column, "type") &&
Reflect.get(column, "type") == "selection"
)
) {
Reflect.set(column, "prop", key);
}
}
}
console.log(newTableHeader.value);
};
onMounted(genNewTableHeader);
onBeforeUpdate(genNewTableHeader);
</script>
使用:
// App.vue
<template>
<CustomTable :tableData="tableData" v-loading="loading">
<CustomColumn :table-headers="tableHeaderMapper"></CustomColumn>
</CustomTable>
</template>
<script lang="ts" setup>
import CustomColumn from "./components/custom-column/index.vue";
...
上面我們隻是支持傳遞默認插槽,現在我們讓 CustomTable 將所有插槽原封不動傳給 el-table,實現“插槽透傳“!
// src\components\custom-table\index.vue
<template>
<el-table :data="tableData" style="width: 100%">
<template v-for="slot in Object.keys($slots)" #[slot]>
<slot :name="slot"></slot>
</template>
</el-table>
</template>
<script setup lang="ts">
defineProps<{
tableData: Array<any>;
}>();
</script>
Vue 中並沒有實現插槽透傳的方法,我們做的其實是插槽傳遞,而非透傳,但對於組件的使用者來說,這種傳遞方法看起來和透傳無異!
到了這一步,我們的 CustomTable 的基礎功能就算封裝完成啦!支持 el-tabled 的所有屬性和插槽。如有需要,可以在 CustomTable 的基礎上進行修改,設計出我們自己的專屬 Table!
在列中插入子元素
表格列中重要的一個內容,就是插入各種按鈕(增刪改)、圖片、進度條以及其它元素,來實現表格的可操作性、數據可視化。在 el-table-column 中,我們想插入元素,直接使用插槽即可。然而,這裡並不能完全照搬表格的插槽傳遞的方法,因為你很快就會發現,CustomColumn 隻有一個,而 el-table-column 卻有很多個,我怎麼知道插入 CustomColumn 的插槽是屬於哪個 el-table-column 的!
非插槽寫法 v-html
如何在不同的 column 插入元素?很快我們就會想起我們是如何在不同 column 中定義不同的列名,對,tableHeaders!我們可以給 tableHeaders 定義一個屬性,隻要寫到這個屬性中,就插入到對應的列的位置上!我們暫且稱這個屬性為 inner。
const tableHeaderMapper = {
a: {
label: "列a",
width: "200",
inner: xxx
},
接下來就是如何讀取 inner 並插入了,我們很容易就想起 v-html:
// CustomColumn
<template>
<el-table-column v-for="column in newTableHeader" v-bind="column">
<div v-if="column.inner" v-html="column.inner"></div>
</el-table-column>
</template>
inner 的值為字符串:
const tableHeaderMapper = {
a: {
label: "列a",
inner: "<h1>hello</h1>",
},
在字符串中寫 html,顯然不是很方便,而一說到字符串中寫 html,我們自然而然就會想到 jsx!那麼這裡我們能否接受 jsx 呢?顯然不能,但是可以接受一個返回組件的函數嗎?可以一試!
const tableHeaderMapper = {
a: {
label: "列a",
inner: h('h1','function component!'),
},
直接運行,可以看到對應列顯示:
![](https://news.xinpengboligang.com/upload/keji/33ced9a720271b4405a5beaea783179a.jpeg)
其實就是輸出一個組件對象。如何應用這個對象?不妨試試 component。
// CustomColumn
<template>
<el-table-column v-for="column in newTableHeader" v-bind="column">
<div v-if="typeof column.inner == 'string'" v-html="column.inner"></div>
<component v-else :is="column.inner"></component>
</el-table-column>
</template>
瀏覽頁面:
![](https://news.xinpengboligang.com/upload/keji/186a447cd0062583e92cde145ad05a00.jpeg)
好極了。
既然支持 h 渲染函數,能否支持導入的組件?
const tableHeaderMapper = {
a: {
label: "列a",
width: "200",
inner: CustomButton,
component 也是支持的:
![](https://news.xinpengboligang.com/upload/keji/2d6ca0a56f65450a2bfac33a55da1f8b.jpeg)
插槽寫法
如何在 CustomColumn 的插槽中區分不同的插槽?其實也很簡單,規定一個格式,使用不同的後綴即可。比如,a 列的插槽為 default-a,b 列的插槽為 default-b。我們可以使用 useSlots 讀取傳入 CustomColumn 中的所有插槽,並使用正則進行匹配。
const slots = useSlots();
const slotKeys = Object.keys(slots);
for(let key of keys){
const res = key.match(/^(\S )-(\S )/);
console.log(res);
}
假設傳入:
<CustomColumn :table-header="tableHeaderMapper">
<template #default-a> I am slot of a </template>
</CustomColumn>
輸出:
const slots = useSlots() // { 'default-a': V_node }
const slotKeys = Object.keys(slots); // ['default-a']
for(let key of keys){
const res = key.match(/^(\S )-(\S )/);
console.log(res); // ['default-a','default','a']
}
根據 res 的值,我們可以很快確定插槽是屬於哪一列的、哪一種類型的插槽。
插槽能夠區分和讀取了,接下來如何插入?
試想一下我們之前是如何插入列的 inner 屬性的,很快就有思路了。
// CustomColumn
<template>
<el-table-column v-for="column in newTableHeader" v-bind="column">
<template v-for="(value, key) in column.slot" #[key]>
<slot :name="value">
<div v-if="column.inner && String(key) == 'default'">
<div
v-if="typeof column.inner == 'string'"
v-html="column.inner"
></div>
<component v-else :is="column.inner"></component>
</div>
</slot>
</template>
<!-- <div v-if="typeof column.inner == 'string'" v-html="column.inner"></div> -->
<!-- <component v-else :is="column.inner"></component> -->
</el-table-column>
</template>
<script lang="ts" setup>
import { ref, onMounted, onBeforeUpdate, useSlots } from "vue";
...
const slots = useSlots();
const newTableHeader = ref<any>({});
const genNewTableHeader = () => {
newTableHeader.value = { ...prop.tableHeaders };
const rawAttr = prop.tableHeaders;
for (let key in rawAttr) {
...
if (typeof column === "object") {
// 設置默認的key
...
// 設置默認的prop,如果該列是多選項,則不需要prop
...
// 處理插槽
const slotKeys = Object.keys(slots);
for (let key of slotKeys) {
const res = key.match(/^(\S )-(\S )/);
// 查找不到則res為null
if (res && res[2] == Reflect.get(column, "key")) {
if (!Reflect.has(column, "slot")) {
Reflect.set(column, "slot", {});
}
Reflect.set(Reflect.get(column, "slot"), res[1], res[0]);
}
}
}
}
console.log(newTableHeader.value);
};
...
</script>
這裡我們預先處理 slots,和 inner 屬性類似,在渲染 column 時,如果有 inner 就渲染 inner。我們在 column 對象(column 對象位於 newTableHeaders 上)上綁定了一個 slot 屬性,在渲染 column 時,如果有 slot 就渲染 slot。
兼容 inner 和 slot:因為 inner 也是渲染在 default 上,所以上面的代碼需要將 inner 註釋掉,以免沖突。這裡我選擇在 slot 存在的情況下,忽略 inner(你也可以選擇其它處理方式,比如二者存在時,都渲染出來,或者存在 inner,就忽略slot)。
// CustomColumn
<template>
<el-table-column v-for="column in newTableHeader" v-bind="column">
<template v-for="(value, key) in column.slot" #[key]>
<slot :name="value">
<div v-if="column.inner && String(key) == 'default'">
<div
v-if="typeof column.inner == 'string'"
v-html="column.inner"
></div>
<component v-else :is="column.inner"></component>
</div>
</slot>
</template>
<template v-if="!column.slot" #default>
<div v-if="column.inner">
<div v-if="typeof column.inner == 'string'" v-html="column.inner"></div>
<component v-else :is="column.inner"></component>
</div>
</template>
</el-table-column>
</template>
插槽作用域
在點擊按鈕時(修改列、刪除列),我們需要得到點擊對應列的信息,該信息是通過插槽作用域實現的。
// CustomColumn
<template>
<el-table-column v-for="column in newTableHeader" v-bind="column">
<template v-for="(value, key) in column.slot" #[key]="scope">
<slot :name="value" v-bind="scope">
<div v-if="column.inner && String(key) == 'default'">
<div
v-if="typeof column.inner == 'string'"
v-html="column.inner"
></div>
<component v-else :is="column.inner"></component>
</div>
</slot>
</template>
<template v-if="!column.slot" #default>
<div v-if="column.inner">
<div v-if="typeof column.inner == 'string'" v-html="column.inner"></div>
<component v-else :is="column.inner"></component>
</div>
</template>
</el-table-column>
</template>
這裡使用 template 的 #插槽 =“xxx”讀取了 el-table-column 提供的列信息並保存 xxx 為 scope,然後又使用 v-bind 將 scope 通過 slot 的屬性傳遞給 CustomColumn 的使用者。
// App.vue 使用scope可以讀取對應的列的信息
<template>
<CustomTable :tableData="tableData" v-loading="loading">
<template #empty>暫無數據</template>
<CustomColumn :table-headers="tableHeaderMapper">
// 使用scope.$index讀取該列在表格中的索引
<template #default-a="scope"> I am slot of a {{ scope.$index }}</template>
</CustomColumn>
</CustomTable>
</template>
關於 scope 對象的取值可以查閱 Element 的文檔(Table 表格 | Element Plus)
![](https://news.xinpengboligang.com/upload/keji/f4d078faa423fb703188c23c8b465cc3.jpeg)
總結
本文章通過通俗易懂、循序漸進的方法,介紹了如何對 el-table 進行基礎性的二次封裝,讓我們使用表格的代碼量減到最少的同時,又具備極高的可定制性和可維護性。同時,又在封裝的過程中掌握了 v-for、v-if、v-else、v-html、v-slot 等內置指令的用法、屬性透傳和插槽的概念,如果閱讀後有所收獲,不妨給文章點贊,鼓勵一下作者!
作者:天氣好
鏈接:
https://juejin.cn/post/7301569815827103755