關於Svelte開發WebComponent的一個踩坑過程
theme: nico highlight: darcula
事情的起因是我的一個朋友跑來問我如何找到一些Web Component寫的元件,因為他的一個專案需要用到一個簡單的tree元件。
雖然我不清楚現在什麼樣的專案會專門需要用到web component來進行開發,不過本著看著不算難,所以要試試的原則,我跟他說,可以試試Svelte。
作為一個~~非知名~~Svelte佈道者,我個人在最近的很長一段時間裡都在嘗試並使用了Svelte,雖然大部分都是用到了個人專案中,比如我自己個人主頁,還有一個簡單的開源專案的前端部分。
個人主頁: https://mowtwo.com/https://github.com/mowtwo/mow-page-public
FFServer: https://github.com/DimCyan/ffserver/https://github.com/mowtwo/ffserver_frontend
在之前我就看到一些用Svelte開發WebComponent的文章,所以在我提出這個建議的時候,我以為事情會變得順其自然。
不過對方很快給出了一些問題,首先他不是一個專業的前端,Svelte雖然簡單,但是目前他還不會。而且其實這次需求下,僅僅只是需要一個tree元件,能夠讓後端開箱即用。
那要解決問題,就只能考慮另一個辦法,那就是現成的元件庫。Svelte的冷門程度確實還是超乎了我的想象,雖然常說在國外,Svelte還是有一定的使用者的,但是實際上找尋下來,Svelte並沒有類似於Vue跟React那種特別通用的元件庫。找到的幾個大都也是基於Material Design的,元件數量實在很少不說,而且都沒有tree元件。
雖然最終我找到了一個基於TailwindCSS,MD設計風格,並且有tree元件。但是這並沒有解決我的問題,因為我發現,這玩意樣式壓根不生效,折騰了半小時我放棄了,可能是我的使用姿勢不對吧。
但是作為一個喜歡折騰的人,我不能讓我好不容易推薦出去的Svelte成為一個笑話。我打算自己寫,對,參考上面找到的那個框架的文件裡演示的tree元件,寫一個,因為功能看起來並不複雜。
效果展示
踩坑的開始
編寫tree的過程確實不復雜,我也模仿上面的那個框架,選擇引入TailwindCSS來快速解決樣式問題。
編寫出來的程式碼很簡單,實現了一個tree-item元件,然後又封裝了一層tree-view來負責實現遞迴封裝,這個元件最終只需要接收一個tree的props,就可以完成一個tree的生成。
下面是tree-item元件的程式碼,不過是複製的最終完成版本的程式碼還原的
```html
```
在網頁上,用普通編譯模式預覽後,我就開始準備編譯到WebComponent。
開啟WebComponent編譯模式
在Svelte中,打包WebComponent無非做兩件事,一個就是在打包工具的Svelte外掛裡開啟customElement的編譯選項,一個就是給每一個元件都新增一個自定義標籤名。
由於我用的是vite建立的專案,所以就是在vite的Svelte外掛裡設定
ts
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [svelte({
compilerOptions: {
customElement: true,
},
})],
})
然後就是給各個元件新增自定義標籤名,這個是通過Svelte提供的內建元素實現的
html
<svelte:options tag="tree-item" />
注意:每一個用到的svelte檔案都得加,一開始我以為只需要給匯出的那個新增,但是其實是每一個元素都得加
而且還要注意另外一點,svelte元件被註冊成WebComponent後,在元件之間互相引用也必須採用WebComponent的方式進入引入,比如我在用tree-view元件包裝tree-item的時候,原來的寫法是
```html
但是註冊後,則改成下面的寫法
```html
編譯並沒有遇到太大的問題,不過預覽的方式需要進行修改,Svelte預設編譯模式下,通過引入App元件然後new App後掛載target到頁面中的一個元素下。
而現在則不再需要這些步驟,只需要把需要用到的元件引入,然後在HTML裡直接編寫WebComponent元素使用就可以。
因此需要繼續修改
typescript
import "TreeItem.svelte";
import "TreeView.svelte";
```html
```
執行dev,在瀏覽器中沒有報錯,並且用devtool進行檢視,可以發現shadow dom成功渲染。
看起來還算順利,但是當我們真正開始使用後,新的問題接踵而來。
傳遞引數問題
我們在上面使用WebComponent時,還沒有傳遞任何的props,但是實際上我們的tree-view元件是需要接受一個叫做tree的物件陣列來渲染內容的,因此我們嘗試傳遞
```html
```
看起來沒啥問題,但是實際上我們會得到一個報錯。
其實報錯的內容完全不需要在意,因為看了也沒啥用,問題很明顯,那就是通過HTML的attribute直接傳遞的props,會被當成字串直接傳遞。
而且元件內,實際上我們是回去遍歷tree陣列,然後還要做判斷,類似這樣
html
{#each tree as item}
{#if !item.children}
<tree-leaf
selected={item.selected}>{item.text}</tree-leaf
>
{:else}
<tree-item
text={item.text}>
<svelte:self
tree={item.children}
/>
</tree-item>
{/if}
{/each}
很明顯,字串是沒辦法實現這個情況的。
在解決問題之前,我們要分析一下這裡涉及到兩種情況。在我們使用普通的HTML標籤的時候,設定標籤的屬性一般有兩種方式,一種就是在HTML裡之前給標籤新增屬性,還有一種就是通過js去直接設定屬性或者使用setAttribute。其實setAttribute的情況就跟直接在HTML新增屬性是類似的行為,因為setAttribute傳遞的引數值必須是字串。
那這裡就要單獨分析一下直接設定的情況,這裡先說一下結論,那就是直接通過js屬性的方式設定的值是可以保留型別的。
這個是我在原生網頁與Vue內測試後得出的結果,下面給出Vue的案例
```html
```
不過為了能夠讓WebComponent使用起來更像普通的HTML標籤,我們也得相容一下這種情況,因此我們建立一個新的renderTree來作為最終渲染的陣列,而tree改成可以接受陣列跟字串的形式,在元件更新時做一個自動轉換,這裡其實也可以寫成computed的形式,不過我為了方便寫判斷,所以寫成了程式碼塊。
ts
let renderTree: Tree[] = [];
$: {
if (Array.isArray(tree)) {
renderTree = tree;
} else if (typeof tree === "string") {
renderTree = JSON.parse(tree);
} else {
console.warn("tree必須是合法的JSON字串或物件陣列");
}
}
樣式不生效
解決了各種渲染問題,最終也終於看到頁面中出現了內容,但是一個很基礎,但是被我忘記的東西出現了。
我之前用了TailwindCSS,樣式全部無效了。這裡其實簡單想想就知道了,WebComponent為了防止樣式汙染,所以對於整個WebComponent進行了密封,內部的樣式無法影響到內部,外部的樣式也無法影響到內部,而TailwindCSS的樣式是會被編譯進單獨的CSS檔案的。
因此只能將樣式重新在模板檔案的style裡寫了一次,不過好歹很有效,樣式出現了,雖然跟TailwindCSS寫的相比缺少了一些相容性。
修改後終於可以看到簡單的效果,基本符合最初的設計
事件不生效
本來以為事情已經完美解決,但是很快另一個問題就一下又讓我浪費了兩個小時。
原本以為Svelte打出來的WebComponent基本上符合標準的HTML標籤的用法,因為上面的props傳遞的方式迷惑了我,因此我很自信的認為繫結事件應該也是一樣的。
我在測試裡寫下了
ts
const tree = document.querySelector('tree-view')
tree.addEventListener('leafClick',function(e) {
console.log("leafClick",e.detail)
})
tree.addEventListener('itemToggle',function(e) {
console.log("itemToggle",e.detail)
})
結果很快被打臉,壓根不生效,一直到這裡,我原本以為半小時就能搞定的小問題,其實已經浪費了我一下午將近五個小時的時間。將近崩潰的我只能開始求助於Google(不是沒百度,而是百度壓根找不到)。
這裡其實也是分為兩部分解決,並且是在同一個GitHub issue裡看到解決方案:
首先就是關於事件的繫結的問題,那就是不能直接使用原生的事件監聽系統,Svelte在編譯元件的時候會在元件物件看掛載一個自定義的監聽器$on
。
將監聽修改
ts
const tree = document.querySelector('tree-view')
tree.$on('leafClick',function(e) {
console.log("leafClick",e.detail)
})
tree.$on('itemToggle',function(e) {
console.log("itemToggle",e.detail)
})
這樣修改後,事件成功觸發,但是我很快發現了另外一個問題,就是隻有leafClick被觸發,而自定義的摺疊/展開的觸發事件itemToggle無法觸發。
這裡就不說排查過程,實際解法就是,Svelte的createEventDispatcher建立的dispatch函式是無法將事件傳遞出WebComponent,原因暫時未知,沒有去研究。而其中一個事件會觸發的原因是leafClick是直接對映的原生的click事件,這裡其實挺奇怪的。
根據issue裡解法,就是在觸發普通dispatch的時候還需要去呼叫原生DOM的dispatchEvent,這裡要注意在template是沒辦法直接拿到元件頂層的DOM物件,不過Svelte提供了get_current_component
來獲取,所以解決並不複雜,將有使用到dispatch的地方進行簡單的改造
ts
const thisComponent = get_current_component();
const svelteDispatch = createEventDispatcher<TreeItemEvents>();
const dispatch: typeof svelteDispatch = (type, detail) => {
thisComponent?.dispatchEvent?.(
new CustomEvent(type, {
detail,
})
);
return svelteDispatch(type, detail);
};
至此問題全部解決,終於結束了。
總結
其實這個開發過程總體都是因為不熟悉各種WebComponent相關的特性造成的問題,所以踩坑在所難免。
不過最終雖然解決了問題,不過個人感覺專案已經基本上算是完蛋了。不過最終還是在朋友@alexzhang1030 (github.com)的幫助下幫忙配置了發包,目前也將開發的包暫時釋出到了npm上。