天天看點

技術實踐幹貨 | 前端插件化的探索

作者:閃念基因

1

背景

現在訂閱資料分析平台的客戶越來越多,行業也越來越寬泛,單純的标準化産品已經無法滿足客戶多樣化的業務場景和需求。

資料分析平台又有多種部署環境:私有化、分析雲、SAAS,不同的客戶又使用不同的版本,即使我們能夠快速開發進行疊代,為了滿足客戶需求我們可能需要将對應的 feature pick 至不同的版本,否則客戶就隻能更新到最新版才能使用對應的功能。

是以我們需要提供一些插件化方案,插件的所實作的邏輯是可自定義、可實時更新的,不依賴于主項目的發版。資料分析平台中的自定義圖表功能即是符合這樣的需求的一個插件化方案。

2

圖表插件化方案

下述是自定義圖表編輯頁面的截圖,如圖所示使用者需要編寫 HTML+CSS+JavaScript 代碼以生成對應的圖表,圖表會通過 iframe 進行渲染。如果想要複用這些代碼來建立圖表,則可以将代碼打包為一個 json 檔案,以插件的形式安裝至資料分析平台,使用者直接基于安裝的插件選擇視圖資料建立圖表,十分簡便快捷。

技術實踐幹貨 | 前端插件化的探索

同時我們定義了一套通信機制,依托于這套通信機制,可以讓父頁面與iframe 進行資料傳遞,如上圖中右側區域的表格資料即來自于父頁面傳入的視圖資料。

這種實作方式雖然自由度很高,但是也要求編輯者有一定的前端知識基礎,大大提升了使用成本;又由于iframe 的隔離限制,我們很難為自定義圖表提供一些開放能力,比如資料格式化等;此外iframe 的加載會重建上下文,不僅慢且耗費浏覽器資源。考慮到這些限制,我們又推出了自定義圖表Lite。

3

圖表插件化方案更新

自定義圖表Lite 基于 ECharts 實作,目的是為了讓使用者能更快更簡單地建立圖表,相較于前者僅需要編寫 JavaScript 代碼實作 ECharts 繪圖所需要的 option 即可,對于一些簡單的圖表完全可以基于官方示例加以修改就能實作,大大降低了圖表開發者的心智負擔。

下面的截圖展示了自定義圖表 Lite 的編輯界面,左側 option 參考ECharts官方示例的基礎折線圖[1]實作。

技術實踐幹貨 | 前端插件化的探索

繪制自定義圖表Lite 也不再使用 iframe,而是直接使用内置的 BaseChart,脫離了 iframe 的限制,資料互動變得十分簡單,且可以使用很多内置的能力,如前面提到的在 iframe 的場景下難以支援的資料格式。

當然我們的場景遠不止圖表能力擴充這一種場景,上述圖表插件化的方案也隻能為圖表這一項功能服務。假設我們想要實作更多自定義的業務場景,比如想要支援使用者自定義資訊回報,資料采集等場景,又該如何設計插件化方案呢?

4

插件化方案如何技術選型

我們需要考慮如下方面來進行插件化方案的技術選型:

  • 環境隔離:通過插件引入的自定義代碼必須和首頁面進行隔離,防止造成樣式、變量等污染
  • 技術成熟度:該項技術需要已經十分成熟,對于各個浏覽器的支援不能太差,社群活躍度較高
  • 适應性:對于跨平台、跨架構有十分好的适應性,這樣可以一套代碼多端使用
  • 通信方式:太複雜的通信方式會增加實作的複雜度
  • DOM 結構共享:會對在視口居中顯示彈窗的場景有所助益
  • 支援動态加載和更新

當我們看到「隔離」時首先想到的是 iframe 的方案,但是iframe 也有很多劣勢,具體可以參考微前端架構qiankun 技術選型時未選擇 iframe 的這篇文章 Why Not Iframe[2]。

通過阿裡巴巴的D2前端技術論壇和前端早早聊了解到很多公司已經在生産環境使用 Web Components 技術,不少網站也使用了 Web Components,如 youtube[3]、github[4],衆多落地場景也使得我們開始關注這項技術。

5

什麼是 Web Components

Web Components 是一套可以讓我們建立可重用的自定義元素的技術。它于 2011 年被 Alex Russell 在 Fronteers Conference[5] 提出,2012 年 W3C 開始正式發起草案[6],2014年正式納入标準[7],後逐漸被浏覽器所支援,其中谷歌 2015 年開始的 Polymer Project 項目,通過 polyfill 來臨時支援浏覽器相容,起了很大的推進作用。如今使用的 Web Components 為它的第二個版本v1(上一個版本v0)。

Web Components 由 custom elements、shadow dom、html templates 三項核心技術組成。相關技術細節則不在此處贅述,感興趣則可以進一步檢視 MDN 上的介紹[8]。我們先來看看如何基于 Web Components 實作一個自定義元素。

class MyElement extends HTMLElement {
    constructor() {
        super()
        // 建立一個 shadow Root
        const shadowRoot = this.attachShadow({ mode: 'open' })
        const container = document.createElement('div');
        container.setAttribute('id', 'container');
        container.innerText = "hello, my custom element"

        shadowRoot.appendChild(container)
   }
}

customElements.define('my-element', MyElement)           

上述 js 檔案中實作了一個自定義元素 my-element,使用 customElements 的 define 方法即可以定義自定義元素對應的實作,我們可以在 html 檔案中引入對應的 js 檔案,并使用該自定義元素,在浏覽器中打開該 html 檔案即可以看到内容成功渲染。

<html>
   <head>
       <script src="./my-element.js"></script>
   </head>
  <body>
      <my-element></my-element>
  </body>
</html>           

Shadow Dom 還有一個比較特殊的 css 僞類選擇器 :host,通過這個選擇器可以選中 Shadow Root,當我們想要根據不同環境給自定義元素定義樣式時,可以使用 :host-context() 僞類選擇器。如下css 代碼即實作了「當該自定義元素在 h1 标簽中時,設定其背景色為紅色」的功能。

:host-context(h1) {
    background-color: red;
}           

Web Components 的功能遠不止于此,其他更多使用可以參考官方示例[9]。在了解 Web Components 的使用方式後,該技術方案是否可以滿足現有的業務場景需求,如支援在頁面上自定義一個回報入口,則還需要進一步驗證。

6

基于 Web Components 的插件化方案驗證

由于資料分析平台是基于 React 開發的,為了在相同的環境中進行測試,我們使用 create-react-app 快速建立一個 React 項目。

  • 在 public 目錄中添加 my-element.js 檔案,在該檔案中我們實作了 my-element 這個自定義元素,該元素主要是繪制了一個 icon, 點選 icon 可以打開一個彈窗,在彈窗中會展示傳入的參數 x 和 y;
  • 在 index.html 中通過 script 标簽引入該檔案,同時在 App.js 中的特定容器中渲染 my-element 标簽,并通過 atrribute 的方式傳參。

我們看一下實作的效果:

技術實踐幹貨 | 前端插件化的探索

對應的 my-element.js 的實作如下:

class MyElement extends HTMLElement {
    constructor () {
        super();
        this.init();
         
        this.open = false

        this.triggerOpen = this.triggerOpen.bind(this)
        this.triggerClose = this.triggerClose.bind(this)
    }
    
    init () {
        const shadowRoot = this.attachShadow({mode: 'open'});

        const style = document.createElement('style');
        style.textContent = `
        #container { height: 100% }

        .icon-wrapper {
            display: flex;
            align-items: center;
            justify-content: center;
            height: 40px;
            width: 40px;
            border-radius: 100%;
            overflow: hidden;
            background-color: #fff;
            box-shadow: 0 2px 4px rgb(206, 224, 245);
            cursor: pointer;
        }

        .icon-wrapper:hover {
            box-shadow: 0 4px 6px rgba(57, 85, 163, 0.8);
        }

        .icon-wrapper svg {
            width: 20px;
            height: 20px;
        }

        .modal-wrapper {
            position: fixed;
            top: 0;
            left: 0;
            right: 0;
            bottom: 0;
            background-color: rgba(0, 0, 0, 0.3);

            visibility: hidden;
            transform: scale(0);
            transition: opacity 0.25s 0s, transform 0.25s;
        }
        .modal-wrapper.show {
            visibility: visible;
            transform: scale(1.0);
        }
        .modal-content {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            width: 300px;
            background-color: white;
            border-radius: 2px;
            padding: 12px;
            max-height: 300px;
        }
        `

        const container = document.createElement('div');
        container.setAttribute('id', 'container');

        const iconWrapper = document.createElement('div')
        iconWrapper.setAttribute('class', 'icon-wrapper')
        iconWrapper.innerHTML= `
            <svg t="1667901570010" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="21577" width="200" height="200">
                <path d="M511.908 955.75c-8.807 0-17.43-3.302-24.22-10.091L385.307 843.276c-13.394-13.394-13.394-34.861 0-48.255s34.861-13.395 48.256 0l78.346 78.346 78.347-78.346c6.422-6.422 15.045-10.092 24.22-10.092h238.893c18.898 0 34.127-15.229 34.127-34.128V204.76c0-18.715-15.229-34.128-34.127-34.128H170.816c-18.715 0-34.128 15.413-34.128 34.128V750.8c0 18.9 15.413 34.128 34.128 34.128h102.383c18.898 0 34.127 15.229 34.127 34.128s-15.229 34.127-34.127 34.127H170.816c-56.513 0-102.383-45.87-102.383-102.383V204.76c0-56.513 45.87-102.383 102.383-102.383h682.552c56.512 0 102.383 45.87 102.383 102.383V750.8c0 56.513-45.87 102.383-102.383 102.383H628.419l-92.291 92.475c-6.605 6.605-15.413 10.092-24.22 10.092z" p-id="21578"></path><path d="M324.206 511.908c-28.256 0-51.19-22.935-51.19-51.191s22.934-51.192 51.19-51.192 51.192 22.936 51.192 51.192-22.935 51.191-51.192 51.191z m204.766 0c-28.256 0-51.191-22.935-51.191-51.191s22.935-51.192 51.191-51.192 51.191 22.936 51.191 51.192-22.935 51.191-51.19 51.191z m204.949 0c-28.256 0-51.191-22.935-51.191-51.191s22.935-51.192 51.191-51.192c28.256 0 51.192 22.936 51.192 51.192s-23.12 51.191-51.192 51.191z" p-id="21579"></path>
            </svg>
        `

        const modalWrapper = document.createElement('div')
        modalWrapper.setAttribute('class', 'modal-wrapper')
        const content = document.createElement('div')
        content.setAttribute('class', 'modal-content')
        modalWrapper.appendChild(content)

        container.appendChild(iconWrapper)
        container.appendChild(modalWrapper)

        shadowRoot.appendChild(style);
        shadowRoot.appendChild(container);
    }

    connectedCallback() {
        // 添加事件監聽
        const iconWrapper = this.shadowRoot.querySelector('#container .icon-wrapper')
        iconWrapper.addEventListener('click', this.triggerOpen)

        const maskWrapper = this.shadowRoot.querySelector('#container .modal-wrapper')
        maskWrapper.addEventListener('click', this.triggerClose)
    }

    disconnectedCallback () {
        // 解除安裝事件監聽
        const wrapper = this.shadowRoot.querySelector('#container .icon-wrapper')
        wrapper && wrapper.removeEventListener('click', this.triggerOpen)

        const maskWrapper = this.shadowRoot.querySelector('#container .modal-wrapper')
        maskWrapper && maskWrapper.removeEventListener('click', this.triggerClose)
    }

    triggerOpen () {
        const modalWrapper = this.shadowRoot.querySelector('#container .modal-wrapper')
        if(modalWrapper) {
            const maskContent = modalWrapper.querySelector('.modal-content')
            maskContent.innerHTML = `
                <p>x: ${this.getAttribute('x')}</p>
                <p>y: ${this.getAttribute('y')}</p>
            `
            modalWrapper.classList.add('show')
        }
    }

    triggerClose () {
        const modalWrapper = this.shadowRoot.querySelector('#container .modal-wrapper')
        modalWrapper.classList.remove('show')
    }
}

customElements.define('my-element', MyElement)

           

上述自定義元素的實作是基于原生的js文法,寫起來十分繁瑣,當自定義元素的内部結構複雜度提升時,開發效率也會相應地降低。

社群也有一些方案可以幫助我們快速建構 Web Components,如Google 開源的 Lit[10],Lit 可以讓我們以編寫 React 類元件的方式來編寫 Web Components,大大提升開發體驗。不過需要注意的是 Lit 是基于 ES2019 開發的,為了适應低版本的浏覽器,需要注意在打包時添加對應的插件和polyfill。基于 Lit,也有很多 UI 元件庫開源,如 Wired Elements[11]、Lithops UI[12],感興趣的話也可以去參考這些庫的實作。

7

總結

Web Components 的技術方案已經可以滿足我們目前的業務場景:

  • 通過 Shadow Dom 可以實作樣式隔離,同時又能做到 DOM 結構共享;
  • 資料傳遞方式也很簡單,正文部分的示例中隻介紹了 attribute 傳參這種方式,這種方式隻支援傳遞字元串類型,當需要傳遞複雜資料類型時,我們可以通過 property 的方式來傳參,具體原理可以參考 handling-data-with-web-components[13] 這篇文章;
  • 通過一個引入的 js 檔案來實作自定義元素,可動态化,對該 js 檔案可以設定協商緩存,這樣每次通路頁面時即能擷取最新的内容;

插件化的場景層出不窮,我們也将繼續探索 Web Components 的潛力,為插件化實作更多可能。

8

參考文檔

  • https://developer.mozilla.org/en-US/docs/Web/Web_Components[14]
  • https://qiankun.umijs.org/zh/guide[15]
  • https://www.yuque.com/kuitos/gky7yw/gesexv[16]
  • https://lit.dev/docs/[17]

參考資料

[1] 基礎折線圖: https://echarts.apache.org/examples/zh/editor.html?c=line-simple

[2] Why Not Iframe: https://www.yuque.com/kuitos/gky7yw/gesexv

[3] youtube: https://www.youtube.com/index

[4] github: https://github.com/

[5] Fronteers Conference: https://fronteers.nl/congres/2011/sessions/web-components-and-model-driven-views-alex-russell

[6] 草案: https://www.w3.org/TR/2012/WD-components-intro-20120522/

[7] 标準: https://www.w3.org/TR/components-intro/

[8] MDN 上的介紹: https://developer.mozilla.org/en-US/docs/Web/Web_Components

[9] 官方示例: https://github.com/mdn/web-components-examples

[10] Lit: https://lit.dev/docs/

[11] Wired Elements: https://wiredjs.com/

[12] Lithops UI: https://github.com/cenfun/lithops-ui

[13] handling-data-with-web-components: https://itnext.io/handling-data-with-web-components-9e7e4a452e6e

[14] https://developer.mozilla.org/en-US/docs/Web/Web_Components: https://developer.mozilla.org/en-US/docs/Web/Web_Components

[15] https://qiankun.umijs.org/zh/guide: https://qiankun.umijs.org/zh/guide

[16] https://www.yuque.com/kuitos/gky7yw/gesexv: https://www.yuque.com/kuitos/gky7yw/gesexv

[17] https://lit.dev/docs/: https://lit.dev/docs/

作者:w.p,觀遠前端開發工程師,本碩皆就讀于東北大學。實踐團隊開發規範,提升開發品質,挖掘前端知識細節,緻力于打造更易用的ABI産品。

來源-微信公衆号:觀遠資料技術團隊

出處:https://mp.weixin.qq.com/s/zIeuFnvzeT4pNrXuJ9IZEA