關於Svelte開發WebComponent的一個踩坑過程

語言: CN / TW / HK

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  

​  

​  

  {#if leaf}      
       
     
  {:else}      
{         expand = !expand;         dispatch("toggleExpand", expand);       }}     >        
         
                     
       
       
{text}
     
    {#if expand}        
                 
    {/if}   {/if}  

​ ```

在網頁上,用普通編譯模式預覽後,我就開始準備編譯到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  

{         dispatch("itemToggle", {           target: item,           expand: e.detail,         });       }}     >         {           handleChildrenLeafClick(e);         }}         on:itemToggle={(e) => {           dispatch("itemToggle", e.detail);         }}       />       ```

但是註冊後,則改成下面的寫法

```html    

{         dispatch("itemToggle", {           target: item,           expand: e.detail,         });       }}     >         {           handleChildrenLeafClick(e);         }}         on:itemToggle={(e) => {           dispatch("itemToggle", e.detail);         }}       />       ```

編譯並沒有遇到太大的問題,不過預覽的方式需要進行修改,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寫的相比缺少了一些相容性。

修改後終於可以看到簡單的效果,基本符合最初的設計

image-20220513134751653

事件不生效

本來以為事情已經完美解決,但是很快另一個問題就一下又讓我浪費了兩個小時。

原本以為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裡看到解決方案:

Events are not emitted from components compiled to a custom element · Issue #3119 · sveltejs/svelte (github.com)

首先就是關於事件的繫結的問題,那就是不能直接使用原生的事件監聽系統,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上。

相關連結