用 Web Component 製作客製化表格元件

# 前言

目前團隊在規劃建立一個元件庫,希望未來能在各個專案上快速套用自己所寫的元件。由於各項專案使用的框架可能有所不同,所以元件庫必須支援不同框架的使用情境。例如說 VueFormGenerator 當中的 custom field 是可以自由客製化表單中的元件,我們就能引用這個元件庫的元件,將其使用於 Vue 的表單之中。

大部分元件在實作上沒有太大的困難,像是按鈕這個元件就很單純,開幾個欄位提供使用者改改顏色或改改樣式基本上就沒太大問題了。
不過有幾項元件就不是那麼好實作了,例如本篇文章想要探討的 table 這個元件,它不光是改樣式這麼簡單而已,像是每個欄位需要如何抓取資料,各自的行為是什麼?還有表格所衍生出來的各項功能如何去實作?以及如何讓使用者自由客製化...等等。這些都需要提前設計好,才不會在使用的時候感到不方便。

以往在 HTML 使用到 table 這個標籤時,每個頁面的表格都會是分開來做的,A 頁面的表格直接寫在 A 頁面的檔案底下,B 頁面的表格也直接寫在 B 頁面的檔案底下,所以每個頁面的表格需要幾個 column 都可以分別定義好的,有幾個功能也可以分別放上去。

但是現在要達成的目標變成是,做出一個 table 元件,不管是 A 頁面還是 B 頁面,都可以直接使用這個元件,這也是第一次研究如何可以做出讓不同頁面一起使用的 table 元件。

由於這邊的 table 元件是透過 Web Component 來實作的,所以上半部的篇幅會先提到關於 Web Component 最簡單的概念(已經了解的大大們可以跳過這部分),下半部才會是分享 table 元件的實作。

# 目標

第一,要能讓使用者在使用當下,透過設定的方式來帶入它需要幾個 column,且分別需要顯示什麼資料。

第二,要能讓使用者自由定義要綁上去的功能有哪些

# 初探 Web Component

考量到希望引入元件是獨立的,不會與專案當中其他的程式碼相互干擾而發生悲劇,所以參考了利用 web component 的方法來實作元件。它可以讓我們自定義元件,包含 HTML 結構、CSS 樣式、JavaScript,並且取一個自己喜歡的標籤名稱(例如:<wc-table></wc-table),插入到頁面上就能得到一個封裝好的組件。

由於 Web Component 是利用 Browser 原生支援的 Custom Elements 來渲染共用元件的,所以能共用在任何前端框架上,非常符合我們對於元件庫的需求。它的概念其實跟 React 的 class component 有幾分類似,都能讓我們自定義一個元件的架構跟樣式,並使用在其他地方,只是它還有一些需要另外去熟悉的特性,例如 shadow DOM 的概念。

shadow DOM 允許我們創建一些完全獨立於其他元素的 sub-DOM trees,什麼意思呢?有點像我們組裝模型一樣,有一個可以組裝用的接口,讓我們裝上別的手臂或武器之類的,而且裝上去的部位跟模型本身不會相互影響。

可以參考下圖:我們可以利用 shadow-host 這個節點,裝上一個 shadow-tree

(圖片來源: https://developer.mozilla.org/zh-CN/docs/Web/Web_Components/Using_shadow_DOM)

Shadow DOM 的操作方式跟一般我們常操作的 DOM 是相同的,可以新增屬性、增加 child node...等等,但是我們沒辦法直接透過外部來修改 Shadow DOM 底下的元件。

比如說下面這個 HTML 的架構:

<div class=".wrapper">
<wc-element>
#shadow-root
<!---->
<button class="wc-btn">...</button>
<!---->
</wc-element>
</div>

如果我們想要透過外部的 .wrapper 來調整底下 .wc-btn 的顏色是無法調整的:

.wrapper .wc-btn {
color: red;
}

相反的 Shadow DOM 内部的元素也不會影響到外部。

由於這個特性,使得我們可以封裝一個具有獨立功能的 <wc-table> 元件,並且可以保證不會引用到專案的同時影響到其它 DOM 元素。shadow DOM 和標準的 DOM 一樣,可以設置它的樣式,也可以用 JavaScript 操作它的行為。DOM 和 shadow DOM 創建的獨立組件之間的互不干擾,有利於組件在各個專案的復用。

# 初探 Slot

上面提到了可以透過 Web component 來封裝組件,提供別的專案來使用,不過還有一個問題是,我只是提供一個固定樣式的組件,這樣使用者怎麼客製化?總得要讓使用者可以做一些調整,不然每次要調整都要回去 Web Component 調,這樣也不是很好。

第一個辦法:開一些 attribute 的欄位讓使用者帶入

<wc-button btn-color="primary" hidden="true"> 按鈕 </wc-button>

我們只要在 Web Component 預先定義好去接收這個 attribute,並且決定當使用者帶入的 btn-color 為某些值的時候,要做出什麼樣的反應,就可以讓使用者去挑選自己想要的顏色。

get color() {
return this.getAttribute('btn-color');
}

不過這樣有一個困擾,就是使用者一定只能使用我開好的欄位,且只能局限於我所提供的設定。重點是使用者沒辦法自由的客製化底下的標籤。比如說我的按鈕需要加上 icon

<wc-button>
<i class="pen"></i>
按鈕
</wc-button>

像上面需要加上客製化的標籤 <i class="pen"></i>,這就沒辦法透過 attribute 的欄位來設定了,於是我們可以透過第二種辦法。

第二個辦法: Slot
Slot 可以想像為 Web Component 的 placeholder,當外部想要傳 Value 或者 HTML 的標籤進來時,可以透過 Slot 來達成:

先在 Web Component 底下定義好 slot 跟它的 name

<!-- 我們定義的 Web Component -->
<div>
<slot name="avatar"><slot>
</div>

使用者可以將資料或標籤帶入,使用時透過一個 slot 的 attribute 傳入上面要對應的 name

<!-- 使用者在引入使用元件的時候 -->
<user-data>
<span slot="avatar">Value</span>
</user-data>

瀏覽器會將我們的標籤根據對應到的 name 把它置入於 template 之中

<!-- 瀏覽器最終渲染出來的樣子 -->
<div>
<slot name="avatar">
<span slot="avatar">Value</span>
<slot>
</div>

如此一來,除了可以傳入想要的顏色或 attribute 之外,如果需要客製化標籤,也可以達成了。
下一步要開始來設計我們要製作的元件 table。

# 自訂 table

製作 table 元件以前最重要的就是先把資料給定義清楚,資料確定了以後,後面就只差如何去顯示而已。
因為表格會是有很多組資料組合而成的,所以我們可以想像資料會長的像下面這個樣子

const tableData = [
{
id: 1,
value: "第一筆資料",
},
{
id: 2,
value: "第二筆資料",
},
{
id: 3,
value: "第三筆資料",
},
];

由於表格資料它不含有 HTML 的標籤,所以我們可以想像讓使用者直接利用 attribute 的方式來傳入 Web Component

<wc-table data="{tableData}"></wc-table>

接下來就要來思考如何去讓使用者定義 column 要對應到哪些資料

# 自訂 column

在討論 Web Component 當中的 column 要怎麼實做之前,我們先來決定要怎麼讓使用者來傳入 column,可能有幾種方式:

☞ 第一種方法:由 table 定義好規格,使用者透過 attribute 傳入設定值
只要在我們的 <wc-table> 先定義好 table 的規格,再給使用者自己傳入相關的設定,使用上可能會像下面這個樣子:

<wc-table data="{tableData}" columnCount="{n}" config="{fitData}"></wc-table>

這個方法的好處是,我很好管控規格,使用者只能完全按照規格來走,
缺點也很明顯,使用上很不直觀,也缺少客製化的空間。

☞ 第二種方法:直接讓使用者帶入標籤
我們直接透過傳入 slot 的方式,讓使用者在使用時直接塞入所有標籤,使用上會像下面這個樣子:

<wc-table data="{tableData}">
<thead>
<tr>
...
</tr>
</thead>
<tbody>
<tr>
...
</tr>
<tr>
...
</tr>
</tbody>
</wc-table>

使用者直接在 <wc-table> 底下傳入它想要客製化的所有標籤進來,web component 再把整個 slot 渲染出來。優點是具備非常強大的客製化空間,缺點是完全沒辦法控制使用者傳入什麼進來,也沒辦法去把規格給定義出來。而且這樣子的寫法跟直接使用一般的 <table> 實在差不了多少,缺少了把它模組化的價值。

上述兩種方式其實都可以達成我們想要的目的,但是有沒有能夠擷取各自優點得折衷方法,既能夠有一定的客製化空間,又能夠限制住規格呢?
我們能不能再定義出一個特殊的元件,它不是用來渲染的,而是用來讓使用者帶入資料?然後再去辨別這個元件並且把它的資料擷取出來?既然不想要使用者傳入這麼繁雜的 HTML 標籤,那就定義一種規格的標籤讓使用者使用吧!

☞ 第三種方法:定義新的標籤來使用
我們來自定義出一個新的標籤 <wc-table-column>,它是用來讓使用者設定 column 用的,使用上會像下面這個樣子

<!-- 使用者在引入使用元件的時候 -->
<wc-table data="{tableData}">
<wc-table-column path="{id}" headName="{name}">
<wc-table-column path="{value}" headName="{name}">
</wc-table>

然後我們在 Web Component 底下特別去把 slot 當中的 <wc-table-column> 挑選出來:

// Web Component 擷取元件的時候
function findColumnNodes(slot) {
const nodes = [];
for (const element of slot.assignedNodes({ flatten: true })) {
if (element.matches(element, "wc-table-column")) {
nodes.push(element);
}
}
return nodes;
}

我們將 slot 底下的 <wc-table-column> 整理成一個新的陣列 nodes,這樣一來我們就會有一組 column 的資料,印出來看的話它會是兩個 HTMLElement

到目前為止,Web Component 有 tableData 的資料,以及使用者 column 的設定,我們只要把它們拼接起來,就可以繪製出我們所想要的表格出來了。

// Web Component 繪製 HTML 的時候
const tableHTML = `
<table>
<thead>
${this.nodes.map(
({ headerName }) =>
`
<th>
${headerName}
</th>
`

)}

</thead>
<tbody>
${this.tableData.map(
(item, i) => `
<tr>
${this.nodes.map(
({ path }) =>
`
<td>
${getDataFromPath(item, path)}
</td>
`

)}

</tr>
`

)}

</tbody>
</table>
`
;

最後將這組 HTML render 出來,就完成了 table 的繪製了,第三個方法既達到讓使用者客製化 column 的內容,同時也管控到元件該有的規格。最後剩下的就是 action 的客製化。

# 自訂 action

先提一下 action 是什麼,我這邊指的 action 代表提供給使用者,可以對每個 row 進行哪些操作,比如說:想要複製、刪除 row,或者想要點開來閱讀詳細資料。這些都是對於 row 操作的行為。

由於這些行為在不同的頁面的表格會有所不同,所以一定也是需要客製化的。比如說 A 頁面需要有編輯跟刪除功能,B頁面可能需要有複製、切換狀態、分享...等等更多功能。也就是說,action 該怎麼設定,是需要讓使用者自行選擇的。

想到這邊會發現,其實實作的方式可以利用跟 column 類似的方法,我們可以一樣可以建立一個特別的元件 <wc-table-action>,並且一樣透過 slot 擷取傳進來的 action,並整理成一個陣列。

不過有一個地方需要注意到的是,操作的按鈕也是要讓使用者客製化的,比如說 A 頁面的編輯按鈕用的是「編輯」這個文字,但 B頁面的編輯按鈕要用像鉛筆一樣的 icon 來表示,所以需要在 <wc-table-action> 底下去接使用者傳進來的 slot,才能讓使用者去客製化這個按鈕的樣式:

<!-- 使用者在引入使用元件的時候 -->
<wc-table data="{tableData}">
<wc-table-column path="{id}" headName="{name}">
<wc-table-column path="{value}" headName="{name}">
<wc-table-action action="handleUpdate()">
<i class="pen">編輯</i>
</wc-table-action>
<wc-table-action action="handleDelete()">
<i class="trash-can">刪除</i>
</wc-table-action>
</wc-table>

由於 <wc-table-action> 底下的內容,我們是沒有要讓他渲染的,所以整個 <wc-table-action> 元件要設定 display: none; 來讓它不會顯示,我們要做的是擷取這些內容,把它們組裝起來之後,動態產生在對應的位置。
所以我們先在 table 底下挖好一個位置,最後組合起來的 actions 標籤就會把它放在這裡:

// Web Component 繪製 HTML 的時候
const tableHTML = `
<table>
<thead>...</thead>
<tbody>
${this.tableData.map(
(item, i) => `
<tr>
${
this.actionNodes
? `
<td class="tooltip">
${this.setActions()}
</td>
`

: null
}

</tr>
`

)}

</tbody>
</table>
`
;

這邊的 setActions() 是待會才要定義的 function,我們要利用它來回傳組合好的 HTML 標籤。
我們先在 Web Component 利用相同的方式來擷取 slot 底下的 <wc-table-action>

// Web Component 擷取元件的時候
function findColumnNodes(slot) {
const actionNodes = [];
for (const element of slot.assignedNodes({ flatten: true })) {
if (element.matches(element, "wc-table-action")) {
nodes.push(element);
}
}
return actionNodes;
}

有了這組 actionNodes 的資料以後,我們還需要去取出每個 action node 底下的 slot,這些 slot 就是使用者傳進來的 action 樣式:

function getActions(actionNodes) {
const actions = [];
actionNodes.forEach((item) => {
const slot = item.shadowRoot?.querySelector("slot");
const childNodes = slot.assignedNodes({ flatten: true });
action.push(childNodes);
});
return actions;
}

我們將 action 樣式整理完成之後,就要開始定義稍早講到的 setActions() 這個函式了,它會將每個 action 的樣式組合起來並回傳完整的 HTML 標籤出去。而這邊這個動作除了會繪製 action 的標籤以外,還會呼叫 _handleClickAction 將 action 的 click 事件綁上去。

private setActions() {
const actionsHTML = `
${getActions(actionNodes)
.map(
(item, i) =>
`
<div click-event-index="
${i}">
${item[0] ? item[0].outerHTML : ""}
</div>
`

)
.join("")}

`
;
const actionsElement = this.shadowRoot?.querySelectorAll("tr .action");
actionsElement.forEach((element) => {
element.innerHTML = "";
element.insertAdjacentHTML("beforeend", actionHTML);

const actionItems = element.querySelectorAll("div[click-event-index]");
actionItems.forEach((_, i) => {
actionItems[i].addEventListener("click", this._handleClickAction);
});
});
}

private _handleClickAction = (e) => {
const currentTargetIndex = e.currentTarget.getAttribute('click-event-index')
const rowIndex = e.currentTarget.parentNode.parentNode.getAttribute('aria-rowindex');
const currentData = this.data[Number(rowIndex)];
this.actionNodes[Number(currentTargetIndex)]._click(currentData);
}

如此一來,只要 call 了 setActions() 這個 function 就可以拿到整組的 actions 標籤,而且已經綁定了對應的 click 事件。
所以 table 就透過了 <wc-table-column><wc-table-action> 兩個特殊標籤,來達成了讓使用者自訂表個的目的了。

# 結語

以上的程式碼示範主要要傳達實作的概念,以往在切版時都是按照設計稿,定義標籤,定義樣式。這次因為接觸到了元件庫的建立,才有機會從「複用」的角度來思考怎麼設計出一個可以讓使用者客製化的元件。雖然目前這個表格非常的陽春只有顯示資料跟簡單的操作功能,沒辦法支援很多厲害的操作或設定。不過也讓我理解到原來透過 Web Component 自訂義標籤可以有這些變化性的玩法。

之後會再把整個表格的功能變得更加完善,目前也在討論規格當中。在這邊提供一個不錯的套件 vaadin,它也是能夠用類似的方式建立客製化表格,而且功能相對於完善許多,如果有人有類似的需求,希望 vaadin 可以幫助到你們。

# 參考資料


關於作者

作夢也在寫程式

分享文章