cover_image

技术实践干货 | 前端插件化的探索

观远数据 观远数据技术团队
2022年11月10日 11:46

本文作者:w.p,观远前端开发工程师,本硕皆就读于东北大学。实践团队开发规范,提升开发质量,挖掘前端知识细节,致力于打造更易用的ABI产品。

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 elementsshadow domhtml 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

参考文档


参考资料 

[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/


图片
图片
图片

👇 点击阅读原文,直接体验Demo

观远数据技术团队 · 目录
上一篇技术干货 | 接口自动化的实践和探索下一篇观远数据 AI 团队的 MLOps 实践
继续滑动看下一个
观远数据技术团队
向上滑动看下一个