组件库——如何实现一个跨框架的组件库文档?

·  阅读 2468
组件库——如何实现一个跨框架的组件库文档?

要搞一个跨框架的组件库文档怎么下手?最近笔者在建设公司内部的组件库,其中就需要为vue2、vue3、react技术栈的中台系统提供基础组件以接入。于是,一个跨框架组件库文档的需求就来了...

补充在线演示地址:组件库最终版本在线演示地址(已剥离公司、项目相关信息,与文中截图、代码演示可能有所差异,望理解)。详细了解可看笔者其余两篇文章~

源码了解

背景介绍

需求背景概述:

  • 内部需要做一个云产品以统一各种中后台系统(类似阿里云)。有一定的样式、布局规范,还有一些基于ui框架扩展的统一的基础组件。so,笔者需要做的就是做一个组件库,分别实现element-uielement-plusant-design组件的二次封装,并提供组件库文档给到使用方接入。

本文主旨:

  • 围绕 组件库文档 进行展开。组件库文档并不是自研,笔者采用的是 vitepress 来快速搭建静态组件文档站点,所以本文的一些技术攻坚也只针对于 vitepress 场景

  • 实现在 vitepress 中使用 vue2 + element-ui 组件、 react+antd组件。react 还是没做哈哈哈,不过实现思路跟 vue2 是一样的。

  • 实现文档内各组件样式隔离。因为要把各框架的组件都放在 vitepress 的单页应用中(粗略看了下目前不支持multiple page:issues),这样依赖不可避免的就会出现 css 冲突,跟微前端的场景有点类似。

    image.png

想了解整个组件库架构的,可以看笔者的上一篇文章:快上车!从零开始搭建一个属于自己的组件库!

一、组件库开发思路

虽然跟本文主旨没太大关系,但是笔者觉得还是得提一下,这样可以更好的开展后续内容。

组件库开发流程图: 组件开发.png 简单说明:

  1. 组件开发。组件库组件库,组件开发当然是核心!所以第一步就是开发组件。
  2. 组件demo。怎么理解demo?组件可能有很多不同的使用场景,不同的配置,demo就是罗列各种使用场景、配置,以此来演示如何使用组件。(好比button组件,传不同的type有不同的表现形式)
  3. 组件文档。文档就是最后一步了,把我们的组件demo放上去,把demo源码、各种配置、事件等进行罗列说明~

简单看看其中一个组件库的项目结构: image.png

  1. components目录:存放所有的组件。这里的组件指的是纯净的,基础的组件,也就是最后提供给开发者使用的。
  2. demo目录:导入components的组件,并模拟用户去使用组件。这里主要是对组件进行开发调试了解开发环境搭建),且最终提供给组件库文档使用的。

所以整个组件库的开发,会涉及组件开发、组件demo开发、组件文档编写三大步骤!

二、vitepress 使用 vue2 组件

有用过 vitepress 的都知道,其是支持在 markdown 里直接写 vue3 代码的。这样我们可以在写文档之时直接写组件demo代码,非常的方便。但是,其只支持vue3。如果用 vitepress 作为组件库文档工具且需要呈现其他框架的组件,就需要适配一层了。(有句话说得好啊:没什么是加中间层解决不了的,一层不行就两层🤣)

1. 实现方案

开工之前,先大概了解下 markdown 中可直接用 vue3 的原理。文档地址 image.png 根据介绍,md文件最终编译成vue3的组件形式。从大体上看,vitepress 其实就是一个 vue3 + vitessr应用,我们可以简单理解成 md文件 即是 .vue 文件。我们可以在里面写html、写vue3组件、写script、写style,灵活度非常高。

再看看尤大对其中一个关于vitepress使用vue2组件的issues的回答: image.png

这样一来,思路就很清晰了,方案也就更加确定了。我们可以包一层vue3,但更直接一点的是直接在vue3中挂载vue2的组件。这样也能达到一样的效果,而笔者采用的是后者。

思路步骤大致分为:

  1. vue2组件产物。打包编写好的 vue2组件demo,最后得到的产物就是其对应的组件对象
  2. 组件实例化。通过我们最熟悉的vue2实例化:new Vue(组件对象),得到一个组件实例
  3. 挂载dom。在 md 文件中提供一个 dom节点 进行挂载。

2、3串起来其实就是我们vue2项目中,在main.js里面挂载应用根节点那样~

new Vue(App).$mount('#app')
// 或者
new Vue({
  el: '#app',
  render: (h) => h(App),
})
复制代码

2. 可行性分析

这里大致讲下整个实现思路的可行性,以便清晰的理解这个方案的实现。

进入讲解前,笔者先抛个问题,大家可以先想想:

  1. vue同学看这里:从 .vue文件页面渲染 的流程(经历了什么)?
  2. react同学看这里:从 jsx页面渲染 的流程(经历了什么)?

笔者作为一位 react 小菜鸡,就不在这里班门弄斧了~这里仅对 vue 的问题进行分析讲解!react大牛快来指点指点笔者react~(虽然主讲vue,从大体来看,两个框架从..到渲染流程多多少少有点相似,所以react也可以沿用vue2的思路的)

整个vue应用的生命周期(笔者之前一篇文章的图): new vue.png

  1. 明确组件对象产物。就是 .vue 编译的产物。比如我们写的 .vue 文件如下

    <template>
      <div>hello world</div>
    </template>
    <script>
    export default {
      name: '组件名',
      data () { return {} },
      created () {}
      ...
    }
    </script>
    复制代码

    编译后的产物大概是(也就是笔者说的组件对象):

    export defualt{
      name: '组件名',
      data () { return {} },
      created () {},
      render: () => h('div', 'hello world')
    }
    复制代码
  2. new Vue 到 渲染:

    也就是 new Vue(组件对象).$mount('#app') 的流程

    • init。各种初始化、响应式数据、computed等的初始化
    • $mount。开启挂载的全流程
    • render。一定要明确,所有组件都有render函数,执行render的结果就是返回VNode
    • patch。根据VNode,调用浏览器的各种api(如createElement等)创建真实的dom,挂载到我们提供的挂载节点上(这里省略diff的过程)

组件化流程(笔者之前一篇文章的图): 组件化简化.png 这里大家可以不用特别关注,感兴趣的可以顺便了解下。如图,跟第一张图的区别就是多了个 “循环” 的过程。因为我们的应用都不会只有一个组件,应用最终的成型就是一颗组件树。Vue应用就是不断递归(图1)组件化的流程,整个应用就实现了对dom树的形成和挂载了。

讲到这里,大家很容易能想到,把 Vue2组件 挂载到 vitepressVue3应用 里,其实只要对上述流程进行一定的剥离就可以实现了。换句话说,Vue3应用什么都不用管,只需要提供一个节点,而我们把已经实例化的Vue2组件实例进行挂载即可。

基于此,react 也是一个同样的实现思路,参照 react应用 的挂载实现,感觉跟 vue 都是异曲同工的~

ReactDOM.render(
  <App />,
  document.getElementById('root')
)
复制代码

最后,确定下实现方案:

  • 包装adapt层。在内部进行差异抹平,实现接收一个dom参数,把组件挂载到dom上的能力。
  • 统一只提供挂载节点。vue3应用只需要在 onMounted 时获取挂载节点,传到适配层即可。

跨框架组件挂载.png

如何包装我们的 组件demo 完成 adapt层,我们接着往下看。

3. 实现adapt层完成组件挂载

根据上述分析,我们已经很明确 adapt 这层要做什么了:

  • 内部对组件进行实例化,得到组件实例:componentInstance
  • 得到挂载节点(外部传入),挂载组件

项目架构划分:因为 adapt 这层跟组件库的组件是无关的,也不属于文档内的内容。它的作用就是:抹平不同框架组件的差异,并完成 dom 挂载。所以笔者决定把 adapt 这层独立一个项目,放到外层,跟docs项目同级(基于monorepo的结构,想详细了解的可以看上篇文章!)。

image.png

结构可能和上一篇有一点区别,因为1.0版本组件库文档还在开发,不过核心架构、逻辑还是那样。(细心的小伙伴可能发现组件库项目名从 voice-ui变成vico-design了哈哈哈)

接下来,就以 element-ui 为基础,扩展了自定义列的 el-table 组件为例,进行实现讲解。跟着大伙一起实现在 vitepress 文档站点中,挂载 vue2 组件。(直讲大概的实现,粒度不会特别细)

  1. demo组件如下,为了标识,加了个标题:vue2组件。 image.png 组件代码:

    <template>
      <div>
        <h1>vue2组件</h1>
        <vc-table :data="tableData">
          <el-table-column prop="date" label="日期" width="180" />
          <el-table-column prop="name" label="姓名" width="180" />
          <el-table-column prop="address" label="地址" />
        </vc-table>
      </div>
    </template>
    <script>
    import {TableColumn as ElTableColumn} from 'element-ui'
    import { VcTable } from '@voice-ui/element-ui'
    export default {
      name: 'TableCustomColumns',
      components: { ElTableColumn, VcTable },
      data () {
        return {
          tableData: [{
            date: '2016-05-02',
            name: '王小虎',
            address: '上海市普陀区金沙江路 1518 弄'
          }, {
            date: '2016-05-04',
            name: '王小虎',
            address: '上海市普陀区金沙江路 1517 弄'
          }, {
            date: '2016-05-01',
            name: '王小虎',
            address: '上海市普陀区金沙江路 1519 弄'
          }, {
            date: '2016-05-03',
            name: '王小虎',
            address: '上海市普陀区金沙江路 1516 弄111'
          }]
        }
      }
    }
    </script>
    复制代码
  2. 在 adapt层 import demo组件, 并用适配器包装,向外 export 包装后的结果。

    image.png Vue2ComponentInstanceWrapper对组件对象包装后返回一个 xxxMount 函数

  3. 实现包装函数

    • 导入 Vue。(在element-ui/demo中导出)
    • 返回上述的 xxxMount 函数。其内部就执行了 Vue2组件 的「初始化 — 挂载」过程
    import { Vue } from '@voice-ui/element-ui/demo'
    
    export function Vue2ComponentInstanceWrapper (comp) {
      return function customRender (dom) {
        return new Vue({
          render (h) { return h(comp) }
        }).$mount(dom)
      }
    }
    复制代码
  4. 适配层打包。然后在文档中导入适配层的 xxxMount 函数。传入dom执行(注意要在 mounted 阶段)。

    • 这里展开一下,为什么适配层要打包?因为我们最终需要在 文档(vue3) 直接使用适配层的组件实例,而文档项目里不会有 vue2相关的内容。也就是说,文档项目不具备编译 vue2 组件能力的,而我们所写的demo,都是.vue文件,在new Vue时需要进行模板编译。所以,我们在适配层完成这一步,借着 vite-plugin-vue2 等插件对vue2的 SFC 进行编译打包,对外提供一个能直接运行的产物。

    文档的 md 文件代码如下:

    # Table 表格
    
    ## 自定义列表格
    
    自定义列表格用法展示。
    
    <div id="root">挂载节点</div> 
    
    <script setup>
        import { onMounted } from 'vue';
        import { TableCustomColumnsMount } from '@voice-ui/adapt-element-ui';
        import styles from '@voice-ui/adapt-element-ui/dist/style.css';
    
        onMounted(() => {
            TableCustomColumnsMount(document.querySelector('#root'))
        })
    </script>
    复制代码
  5. vitepress 中成功使用vue2组件。效果如下:

    image.png

    • 功能也是可以正常使用的:

    image.png

到这里,vitepress 导入 vue2组件 的实现算是完成了,组件也能正常运作。最核心的步骤其实代码就几行,比较关键的就是要在适配行打包,这个打包要把 vue2element-ui 也打进去,因为这样产物就可以直接在浏览器环境运行,也就能很顺理成章的挂载的vue3的文档项目里。

这时候,就剩下最后一个问题,就是样式隔离了。大家看到的 table样式变形 ,其实是受 vitepress 的一些全局样式所影响的。不管是 element-plus 还是 element-uitable 都有这样的问题。那我们接着往下走~

三、样式隔离

笔者上一篇文章中谈到说用 webcomponent 解决样式冲突问题,本是想着用 shadow dom 的沙箱机制,中间也做了很多尝试和突破,但是效果其实不尽如人意,笔者在这里踩了很多的坑......并且在写本文的时候,笔者已经决定放弃使用 shadow dom 来做沙箱了,接着往下!笔者将一一道来~

1. 为什么需要样式隔离?

  • 跨框架、多框架共存。
    • 首先就是 element-uielement-plus 的冲突问题。这2个框架的命名,都是以 el-xxx 这种格式的,绝对无法避免的会有样式冲突,选择器的名称都一样了~
    • ant-design 相对比前2个,即使有着命名空间的隔离优势,但是其自有的一些全局样式,也会对其他框架的样式进行干扰。
  • vitepress 自带的一些样式影响。特别是 table 的,正好笔者第一个封装的组件就是table,一上来就踩坑~ image.png

2. 使用 shadow dom 隔离

这里笔者真的踩了太多坑了,先直接给个自己的结论:

  1. 需要ui框架也支持 webcomponent
  2. 样式隔离有场景限制

首先,需要对应的 ui框架 支持 webcomponet 是什么意思?笔者现在同时把包装了element-uielement-plusvc-table 放到 shadow dom 中。大概实现代码如下,不同框架内部可能有一点区别。

// comp 即组件对象
// dom 即组件挂载的dom节点,最后会添加到shadow dom中
// style 样式
export function initWebComponent (comp: Component, dom: HTMLElement, styles: string) {
  // 组件名。统一以  ui框架-组件名的方式命名。采用 kebab-case 的命名规则
  // elu-table-custom-colmuns ===> vue2 + element-ui
  // elp-table-custom-colmuns ===> vue3 + element-plus
  const webComponentName = `elp-${kebabCase(comp.name)}`

  customElements.define(webComponentName, class extends HTMLElement {
    constructor () {
      super()
    }
    
    connectedCallback () {
      // web componet插入dom时触发
      this.initShadowDom()
    }

    initShadowDom () {
      const shadow = this.attachShadow({
        mode: 'open'
      });
      //  这里是加载自身的样式。element-plus、element-ui各自的index.css
      const style = new CSSStyleSheet()
      style.replace(styles)
      shadow.adoptedStyleSheets = [...shadow.adoptedStyleSheets, style]

      shadow.appendChild(dom)
    }
  })
}
复制代码

这时候我们先看看效果:

  • vue2 + element-uiimage.png

  • vue3 + element-plusimage.png

看上去好像一切正常,而且近乎完美是吧?此时,我们去点击一下 vue2 的自定义列的图标进行自定义列的操作。这时候问题就出现了,看下图: image.png

从报错信息来看,应该就是 popper 组件计算位置的时候报的错。而这个问题,就是是 ui框架 没有对 shadow dom 做支持而导致的。笔者也在 element-ui 的 issues 中找到类似的问题,而且还有一个未被合并的pr。 image.png

  1. popper支持shadow dom 的pr
  2. shadow dom导致弹窗问题

还有挺多的,笔者就不一一罗列了,感兴趣的朋友可以自行去搜来看看~

既然 element-ui 不行,那 element-plus 呢?

image.png

很辛运,在 element-plus 中并不会出现 element-ui 中的报错问题,popper 也能正常的运行,点击后能在正确的位置出现下拉列表。如图所示,vc-table 组件确实是在 shadow dom 中。

image.png

这样看来,element-plus 应该是可以采用 shadow dom 进行隔离的。那个时候笔者也萌生了一种想法,就是把 element-plus 的组件放进沙箱隔离,那外部自然也就不会其他样式影响到 element-ui 的组件,只需要把 vitepress 的样式进行覆盖就能解决样式冲突的问题。

但是问题总不是那么容易解决的,这时候笔者发现了另外一个问题,也就是上述提到的:样式隔离有场景限制。笔者也不卖关子了,那就是 append-to-body 的问题。如下:

image.png

虽然,table组件 是放进 shadow dom 中了,但是其 popperdom 是在 body。回想一下用过的 el-dialog 等组件,好像弹窗类组件都是插入到 body 中的。这样的另一个问题就是,虽然 shadow dom 中的组件进行了样式隔离,但是不可避免的弹窗中的内容还是会存在样式冲突这个问题。这也是场景限制的问题所在。

没错,我们的确有能力去实现弹窗、下拉框等各种弹窗类组件插入到 body 还是 parent dom 的问题,但是这样的开发效率,开发成本可能就不受控制了。本来组件库的核心就是要开发组件,做基础,当组件开发完了,我们还要为了组件能在文档中正常运行、显示下苦工,可能不是一个好的选择。

就我个人而言,开发者开发完组件后,编写文档应该是一个轻量化的工作,而不应该为了文档而各种适配,这样反而有点本末倒置的感觉了。而且如果其他的开发者不知道这个问题,发现文档中有样式错乱,也许会很苦恼,当得知要为了兼容文档展示,要兼容这些弹窗类组件插入节点的问题可能会直接骂人了...

于是,想到这里,笔者就决定放弃使用 shadow dom 进行样式隔离的方案。目前想的方向是用 iframe 做一个天然隔离,从根源上就能避免很多奇怪的问题,如此一来,整个组件库的架构、开发流程可能还得再改一版😂

由于最近业务比较多,而且组件库基本成型能用,优先级自然就下来了。笔者也只能下班、周末的时间投入,所以新的解决方案其实还没开始做,而且进度也不会特别快。不过做完了一定会再写一篇文章来介绍实现方案的~

写在最后

还是那句,人不能一口吃成一个胖子。项目在0-1阶段还是有很多问题需要慢慢去解决,不断优化、迭代。这里,笔者会把整个组件库的实现做成系列,记录搭建这个组件库的心路历程。

系列文章:

  1. 快上车!从零开始搭建一个属于自己的组件库!
  2. 组件库建设——实现一个跨框架的「组件库文档」
  3. 完结篇!一步一步实现一个专业的前端组件库~
分类:
前端
收藏成功!
已添加到「」, 点击更改