要搞一个跨框架的组件库文档怎么下手?最近笔者在建设公司内部的组件库,其中就需要为vue2、vue3、react技术栈的中台系统提供基础组件以接入。于是,一个跨框架组件库文档的需求就来了...
补充在线演示地址:组件库最终版本在线演示地址(已剥离公司、项目相关信息,与文中截图、代码演示可能有所差异,望理解)。详细了解可看笔者其余两篇文章~
源码了解
背景介绍
需求背景概述:
- 内部需要做一个云产品以统一各种中后台系统(类似阿里云)。有一定的样式、布局规范,还有一些基于ui框架扩展的统一的基础组件。so,笔者需要做的就是做一个组件库,分别实现
element-ui
、element-plus
、ant-design
组件的二次封装,并提供组件库文档给到使用方接入。
本文主旨:
-
围绕 组件库文档 进行展开。组件库文档并不是自研,笔者采用的是
vitepress
来快速搭建静态组件文档站点,所以本文的一些技术攻坚也只针对于vitepress
场景~ -
实现在
vitepress
中使用vue2 + element-ui
组件、react+antd
组件。react
还是没做哈哈哈,不过实现思路跟vue2
是一样的。 -
实现文档内各组件样式隔离。因为要把各框架的组件都放在
vitepress
的单页应用中(粗略看了下目前不支持multiple page:issues),这样依赖不可避免的就会出现css
冲突,跟微前端的场景有点类似。
想了解整个组件库架构的,可以看笔者的上一篇文章:快上车!从零开始搭建一个属于自己的组件库!
一、组件库开发思路
虽然跟本文主旨没太大关系,但是笔者觉得还是得提一下,这样可以更好的开展后续内容。
组件库开发流程图:
简单说明:
- 组件开发。组件库组件库,组件开发当然是核心!所以第一步就是开发组件。
- 组件demo。怎么理解demo?组件可能有很多不同的使用场景,不同的配置,demo就是罗列各种使用场景、配置,以此来演示如何使用组件。(好比
button
组件,传不同的type有不同的表现形式) - 组件文档。文档就是最后一步了,把我们的
组件demo
放上去,把demo源码、各种配置、事件等进行罗列说明~
简单看看其中一个组件库的项目结构:
- components目录:存放所有的组件。这里的组件指的是纯净的,基础的组件,也就是最后提供给开发者使用的。
- demo目录:导入components的组件,并模拟用户去使用组件。这里主要是对组件进行开发调试(了解开发环境搭建),且最终提供给组件库文档使用的。
所以整个组件库的开发,会涉及组件开发、组件demo开发、组件文档编写三大步骤!
二、vitepress
使用 vue2
组件
有用过 vitepress
的都知道,其是支持在 markdown
里直接写 vue3
代码的。这样我们可以在写文档之时直接写组件demo代码,非常的方便。但是,其只支持vue3
。如果用 vitepress
作为组件库文档工具且需要呈现其他框架的组件,就需要适配一层了。(有句话说得好啊:没什么是加中间层解决不了的,一层不行就两层🤣)
1. 实现方案
开工之前,先大概了解下 markdown
中可直接用 vue3
的原理。文档地址
根据介绍,md文件最终编译成vue3的组件形式。从大体上看,
vitepress
其实就是一个 vue3
+ vite
的ssr应用,我们可以简单理解成 md文件
即是 .vue
文件。我们可以在里面写html、写vue3组件、写script、写style,灵活度非常高。
再看看尤大对其中一个关于vitepress使用vue2组件的issues的回答:
这样一来,思路就很清晰了,方案也就更加确定了。我们可以包一层vue3,但更直接一点的是直接在vue3中挂载vue2的组件。这样也能达到一样的效果,而笔者采用的是后者。
思路步骤大致分为:
- vue2组件产物。打包编写好的
vue2组件demo
,最后得到的产物就是其对应的组件对象。 - 组件实例化。通过我们最熟悉的vue2实例化:
new Vue(组件对象)
,得到一个组件实例。 - 挂载dom。在
md
文件中提供一个dom节点
进行挂载。
2、3串起来其实就是我们vue2项目中,在main.js
里面挂载应用根节点那样~
new Vue(App).$mount('#app')
// 或者
new Vue({
el: '#app',
render: (h) => h(App),
})
2. 可行性分析
这里大致讲下整个实现思路的可行性,以便清晰的理解这个方案的实现。
进入讲解前,笔者先抛个问题,大家可以先想想:
- vue同学看这里:从
.vue文件
到 页面渲染 的流程(经历了什么)? - react同学看这里:从
jsx
到 页面渲染 的流程(经历了什么)?
笔者作为一位 react
小菜鸡,就不在这里班门弄斧了~这里仅对 vue
的问题进行分析讲解!react大牛快来指点指点笔者react~(虽然主讲vue,从大体来看,两个框架从..到渲染流程多多少少有点相似,所以react也可以沿用vue2的思路的)
整个vue应用的生命周期(笔者之前一篇文章的图):
-
明确组件对象产物。就是
.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') }
-
从
new Vue
到 渲染:也就是
new Vue(组件对象).$mount('#app')
的流程init
。各种初始化、响应式数据、computed等的初始化$mount
。开启挂载的全流程render
。一定要明确,所有组件都有render函数,执行render的结果就是返回VNodepatch
。根据VNode,调用浏览器的各种api(如createElement
等)创建真实的dom,挂载到我们提供的挂载节点上(这里省略diff的过程)
组件化流程(笔者之前一篇文章的图):
这里大家可以不用特别关注,感兴趣的可以顺便了解下。如图,跟第一张图的区别就是多了个 “循环” 的过程。因为我们的应用都不会只有一个组件,应用最终的成型就是一颗组件树。Vue应用就是不断递归(图1)组件化的流程,整个应用就实现了对dom树的形成和挂载了。
讲到这里,大家很容易能想到,把 Vue2组件
挂载到 vitepress
的 Vue3应用
里,其实只要对上述流程进行一定的剥离就可以实现了。换句话说,Vue3应用什么都不用管,只需要提供一个节点,而我们把已经实例化的Vue2组件实例进行挂载即可。
基于此,react
也是一个同样的实现思路,参照 react应用
的挂载实现,感觉跟 vue
都是异曲同工的~
ReactDOM.render(
<App />,
document.getElementById('root')
)
最后,确定下实现方案:
- 包装adapt层。在内部进行差异抹平,实现接收一个dom参数,把组件挂载到dom上的能力。
- 统一只提供挂载节点。vue3应用只需要在
onMounted
时获取挂载节点,传到适配层即可。
如何包装我们的 组件demo
完成 adapt层,我们接着往下看。
3. 实现adapt层完成组件挂载
根据上述分析,我们已经很明确 adapt 这层要做什么了:
- 内部对组件进行实例化,得到组件实例:
componentInstance
- 得到挂载节点(外部传入),挂载组件
项目架构划分:因为 adapt 这层跟组件库的组件是无关的,也不属于文档内的内容。它的作用就是:抹平不同框架组件的差异,并完成 dom
挂载。所以笔者决定把 adapt 这层独立一个项目,放到外层,跟docs项目同级(基于monorepo的结构,想详细了解的可以看上篇文章!)。
结构可能和上一篇有一点区别,因为1.0
版本组件库文档还在开发,不过核心架构、逻辑还是那样。(细心的小伙伴可能发现组件库项目名从 voice-ui
变成vico-design
了哈哈哈)
接下来,就以 element-ui
为基础,扩展了自定义列的 el-table
组件为例,进行实现讲解。跟着大伙一起实现在 vitepress
文档站点中,挂载 vue2
组件。(直讲大概的实现,粒度不会特别细)
-
demo组件如下,为了标识,加了个标题:vue2组件。
组件代码:
<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>
-
在 adapt层
import demo组件
, 并用适配器包装,向外export
包装后的结果。Vue2ComponentInstanceWrapper
对组件对象包装后返回一个xxxMount
函数 -
实现包装函数
- 导入
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) } }
- 导入
-
适配层打包。然后在文档中导入适配层的 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>
- 这里展开一下,为什么适配层要打包?因为我们最终需要在 文档(vue3) 直接使用适配层的组件实例,而文档项目里不会有 vue2相关的内容。也就是说,文档项目不具备编译 vue2 组件能力的,而我们所写的demo,都是
-
vitepress
中成功使用vue2组件。效果如下:- 功能也是可以正常使用的:
到这里,vitepress
导入 vue2组件
的实现算是完成了,组件也能正常运作。最核心的步骤其实代码就几行,比较关键的就是要在适配行打包,这个打包要把 vue2
、element-ui
也打进去,因为这样产物就可以直接在浏览器环境运行,也就能很顺理成章的挂载的vue3的文档项目里。
这时候,就剩下最后一个问题,就是样式隔离了。大家看到的 table样式变形 ,其实是受 vitepress
的一些全局样式所影响的。不管是 element-plus
还是 element-ui
的 table
都有这样的问题。那我们接着往下走~
三、样式隔离
笔者上一篇文章中谈到说用 webcomponent
解决样式冲突问题,本是想着用 shadow dom
的沙箱机制,中间也做了很多尝试和突破,但是效果其实不尽如人意,笔者在这里踩了很多的坑......并且在写本文的时候,笔者已经决定放弃使用 shadow dom
来做沙箱了,接着往下!笔者将一一道来~
1. 为什么需要样式隔离?
- 跨框架、多框架共存。
- 首先就是
element-ui
和element-plus
的冲突问题。这2个框架的命名,都是以 el-xxx 这种格式的,绝对无法避免的会有样式冲突,选择器的名称都一样了~ ant-design
相对比前2个,即使有着命名空间的隔离优势,但是其自有的一些全局样式,也会对其他框架的样式进行干扰。
- 首先就是
vitepress
自带的一些样式影响。特别是table
的,正好笔者第一个封装的组件就是table,一上来就踩坑~
2. 使用 shadow dom 隔离
这里笔者真的踩了太多坑了,先直接给个自己的结论:
- 需要ui框架也支持
webcomponent
。 - 样式隔离有场景限制。
首先,需要对应的 ui框架 支持 webcomponet 是什么意思?笔者现在同时把包装了element-ui
、element-plus
的 vc-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-ui
: -
vue3 + element-plus
:
看上去好像一切正常,而且近乎完美是吧?此时,我们去点击一下 vue2 的自定义列的图标进行自定义列的操作。这时候问题就出现了,看下图:
从报错信息来看,应该就是 popper
组件计算位置的时候报的错。而这个问题,就是是 ui框架 没有对 shadow dom
做支持而导致的。笔者也在 element-ui
的 issues 中找到类似的问题,而且还有一个未被合并的pr。
还有挺多的,笔者就不一一罗列了,感兴趣的朋友可以自行去搜来看看~
既然 element-ui
不行,那 element-plus
呢?
很辛运,在 element-plus
中并不会出现 element-ui
中的报错问题,popper
也能正常的运行,点击后能在正确的位置出现下拉列表。如图所示,vc-table
组件确实是在 shadow dom
中。
这样看来,element-plus
应该是可以采用 shadow dom
进行隔离的。那个时候笔者也萌生了一种想法,就是把 element-plus
的组件放进沙箱隔离,那外部自然也就不会其他样式影响到 element-ui
的组件,只需要把 vitepress
的样式进行覆盖就能解决样式冲突的问题。
但是问题总不是那么容易解决的,这时候笔者发现了另外一个问题,也就是上述提到的:样式隔离有场景限制。笔者也不卖关子了,那就是 append-to-body
的问题。如下:
虽然,table组件
是放进 shadow dom
中了,但是其 popper
的 dom
是在 body
。回想一下用过的 el-dialog
等组件,好像弹窗类组件都是插入到 body
中的。这样的另一个问题就是,虽然 shadow dom
中的组件进行了样式隔离,但是不可避免的弹窗中的内容还是会存在样式冲突这个问题。这也是场景限制的问题所在。
没错,我们的确有能力去实现弹窗、下拉框等各种弹窗类组件插入到 body
还是 parent dom
的问题,但是这样的开发效率,开发成本可能就不受控制了。本来组件库的核心就是要开发组件,做基础,当组件开发完了,我们还要为了组件能在文档中正常运行、显示下苦工,可能不是一个好的选择。
就我个人而言,开发者开发完组件后,编写文档应该是一个轻量化的工作,而不应该为了文档而各种适配,这样反而有点本末倒置的感觉了。而且如果其他的开发者不知道这个问题,发现文档中有样式错乱,也许会很苦恼,当得知要为了兼容文档展示,要兼容这些弹窗类组件插入节点的问题可能会直接骂人了...
于是,想到这里,笔者就决定放弃使用 shadow dom
进行样式隔离的方案。目前想的方向是用 iframe
做一个天然隔离,从根源上就能避免很多奇怪的问题,如此一来,整个组件库的架构、开发流程可能还得再改一版😂
由于最近业务比较多,而且组件库基本成型能用,优先级自然就下来了。笔者也只能下班、周末的时间投入,所以新的解决方案其实还没开始做,而且进度也不会特别快。不过做完了一定会再写一篇文章来介绍实现方案的~
写在最后
还是那句,人不能一口吃成一个胖子。项目在0-1阶段还是有很多问题需要慢慢去解决,不断优化、迭代。这里,笔者会把整个组件库的实现做成系列,记录搭建这个组件库的心路历程。
系列文章: