看完这篇文章,你会有以下新的认识:
如何使用vue3+vite封装插件并发布到npm
如何构建一个ui框架文档网站
插件开发中的技巧
前言
在平日的开发中,我们经常使用不同的ui框架,不知道大家有没有想法自己开发一个自己的ui框架,或许很多人感觉,没有必要重复造轮子,但是现在前端工程师的要求越来越高,需要的技术栈也越来越多,学习一下这个开发流程和一些解决方案还是很有必要的。而且我觉得,最重要的是,在平时的项目开发中,会有许多ui框架无法覆盖的组件,这是和这个业务比较绑定的,独属于这个业务需求的组件,当这个业务比较大的时候,这个组件就需要有更高的灵活性和易用性,有时候使用现有的ui框架进行二次封装也具有一定的成本,甚至高过从头开发,所以在这种情况下,我们就可以把常用的组件,封装成ui插件,配合上完整的组件文档,无论是方便以后项目迭代的时候查看,还是分享给其他人,都是极好的。 下面,仿照element plus官网的样子,来仿一个ui框架,以此讲述开发流程和用到的技术与方案。成品展示:
仓库地址: https://gitee.com/biluo_x/biluo-ui npm地址:biluo-ui - npm (npmjs.com)
技术栈
vue3 前端主流框架之一,这里我们使用3.2版本
vite 代替vue-cli的新脚手架
typescript js的超集,提供类型系统
vite-plugin-md vite的md插件,提供把md文件当做vue导入的能力,最厉害的是,也可以在md文件中使用vue组件
tailwindcss 为了快速得到效果,使用原子类提供样式
prismjs 在代码展示的时候,提供代码高亮 如果没有使用过tailwindcss 可以看看这篇文章:受够了重复繁琐的css?来试试原子类吧 - 掘金 (juejin.cn)
需求分析
我们是仿照element plus来写的所以,我们可以观察一下element 的展示情况。
抛开那些其他的功能,主要部分分为三个,左边根据组件分类的导航栏,中间的展示文档,以及右边的文档目录。先看左侧导航 一个组件对应了一个目录,而我们需要把同种的目录分组,比如基础组件放一项,表单组件放一项等。再看主体文档
主体文档应该使用markdown编写,一个组件对应一个md文件,所以我们需要有在vue中导入md的功能。
组件有不同的功能,需要提供一个演示框,这个演示框里面会放不同的组件功能展示,以及固定的查看代码,粘贴代码,前往仓库的固定功能。我可以发现这个演示框应该是一个vue组件,所以需要有在md文件中导入vue组件的功能 最后看右侧的目录
目录需要自动提取md文件中的标题
目录需要跟着文档滚动而滚动
点击目录可以跳转到对应的标题
目录介绍
项目使用vite初始化,选择vue3+ts模板,然后包管理器使用的是yarn。具体初始化就不献丑了。 除此之外,我这里加入了eslint+prettier
为代码格式化,jest+@vue/test-utils来提供测试支持(写了两三个组件测试就懒得写了...),这些没有也不影响开发,这里提一嘴。 目录规划如下:
src 和平时的页面开发一致,这里存放展示在外的文档页面,打包成文档网站使用
packages 这里存放我们ui组件相关的代码。主要结构如下:
在components文件夹下编写ui组件,一个文件夹表示一个组件,组件中,src存放组件文件,tests存放测试代码,index.ts 提供默认导出。当然components文件夹下还有一个index.ts提供统一入口,导出所有的组件。
组件开发
这里我们用button组件的开发来展示基础开发流程,用input组件的开发来讲述vue3更好的开发方式。
button组件
button组件的文件夹结构
components├── button│ ├── __tests__│ │ ├── button.test.ts // bl-button.vue 测试│ │ └── buttonGroup.test.ts // bl-button-group.vue 测试│ └── src│ └── bl-button.vue // button 组件 |__bl-button-group.vue // button 组 ├── index.ts // 模块导出文件|── index.ts // 组件库导出文件
在button文件夹下的index.ts中我们将src下的两个组件暴露出去: packages/components/button/index.ts
import BlButton from './src/bl-button.vue'import BlButtonGroup from './src/bl-button-group.vue'import { App } from 'vue'export default { install(app: App) { app.component('BlButton', BlButton) app.component('BlButtonGroup', BlButtonGroup) }}export { BlButtonGroup, BlButton }
这里选择了两种导出,主要是为了能直接全局注册的同时,也支持单独引用。 然后在总的index.ts中全部导出: packages/components/index.ts
import { App } from 'vue'export * from './button'import button from './button'const components = [button]export default { install(app: App) { components.map((item) => item.install(app)) }}
后续如果需要添加新的组件,按这个流程导入即可。下面让我们来看一下button组件的具体开发:
<script setup lang="ts"> import { computed, inject, ref, Ref } from 'vue' import BlIcon from '../../icon/src/bl-icon.vue' // 定义名称 // 定义事件 const $emit = defineEmits(['click']) // 定义props const props = defineProps({ size: { type: String, validator: (value: string) => { return ['default', 'large', 'small'].includes(value) } }, // 按钮类型 type: { type: String, default: 'default', validator: (value: string) => { return ['default', 'primary', 'success', 'info', 'warning', 'danger', 'text'].includes( value ) } }, // 是否为朴素按钮 plain: { type: Boolean, default: false }, // 是否为圆形 round: { type: Boolean, default: false }, // 是否正在加载中 loading: { type: Boolean, default: false }, // 是否为圆形 circle: { type: Boolean, default: false }, // 自定义加载中图标 loadingIcon: { type: String, default: 'Loading' }, // 是否禁用状态 disabled: { type: Boolean, default: false }, iconColor: { type: String, default: 'white' }, // 原生type属性 nativeType: { type: String as () => 'button' | 'reset' | 'submit' | undefined, default: 'button' } }) // 类名计算属性 const classComputed = computed(() => { // const sizeInject = inject<Ref<number | undefined>>('button-group-size', ref(undefined)) const typeInject = inject<Ref<string | undefined>>('button-group-type', ref(undefined)) // const typeClass = props.type ? 'bl-button-' + props.type : 'bl-button-default' const typeClass = props.type === 'default' && typeInject.value ? 'bl-button-' + typeInject.value : 'bl-button-' + props.type const isPlain = props.plain ? 'bl-is-plain' : '' const isRound = props.round ? 'bl-is-round' : '' const isLoading = props.loading ? 'bl-is-disabled is-Loading' : '' const isDisabled = props.disabled || props.loading ? 'bl-is-disabled' : '' const isCircle = props.circle ? 'bl-is-circle' : '' const isSize = props.size ? `bl-is-${props.size}` : '' return [typeClass, isPlain, isRound, isDisabled, isLoading, isCircle, isSize] }) // 禁用点击计算属性 const disabledComputed = computed(() => { const isDisabled = props.disabled || props.loading return { isDisabled } }) // 接受button-group的注入 const groupInjectComputed = computed(() => { const sizeInject = inject<Ref<number | undefined>>('button-group-size', ref(undefined)) const typeInject = inject<Ref<string | undefined>>('button-group-type', ref(undefined)) const classData = [] if (sizeInject.value) { const size = (props.size ? props.size : sizeInject.value) ?? '' classData.push(`bl-is-${size}`) } if (typeInject.value) { const type = props.type === 'default' ? typeInject.value : props.type classData.push(`bl-button-${type}`) } return classData }) // 点击事件 const clickEmit = (event: any) => { const isEmit = props.disabled || props.loading if (!isEmit) $emit('click', event) }</script><template> <button :class="['bl-button', ...groupInjectComputed, ...classComputed]" :type="nativeType" :disabled="disabledComputed.isDisabled" @click="clickEmit($event)" > <span> <bl-icon v-if="loading" :name="loadingIcon" :color="iconColor" class="animate-spin mr-0.5" /> <slot /> </span> </button></template><style> @import '../style/index.css'; /*自身属性*/ .bl-button + .bl-button { margin-left: 12px; } .bl-is-large { height: 40px !important; padding: 12px 19px !important; } .bl-is-small { height: 24px !important; padding: 5px 11px !important; font-size: 12px !important; } .bl-is-large.bl-is-circle { width: 40px !important; padding: 12px !important; } .bl-is-small.bl-is-circle { width: 24px; padding: 5px !important; }</style>
这个代码看起来不少,实际上很简单,最多的就是,prop和根据prop对类名进行处理。button的所有样式都是使用css来控制的。js只在原生属性上面稍微处理了一下。这个代码其实写的不好,在类名处理哪里写了一堆的三元表达式,后来发现element源码里面写弄了一个hook专门搞这个,我也去整了一个,代码很简单,大概就是根据bool改变类名之类的:
type namespaceStyle = 'backgroundColor' | 'color' | 'width' | 'height'export const DEFAULT_NAMESPACE = 'bl'export const STATE_PREFIX = 'is'export const useNamespace = (namespace: string) => { return { b() { return `${DEFAULT_NAMESPACE}-${namespace}` }, is(state: boolean, name: string) { return name && state ? `${STATE_PREFIX}-${name}` : '' }, m(suffix: string) { if (suffix) { return `${DEFAULT_NAMESPACE}-${namespace}-${suffix}` } return '' }, sy(data: string, label: namespaceStyle) { return { [label]: data } as CSSProperties }, is_sy(is: Boolean, one: CSSProperties, two?: CSSProperties) { if (!two) { if (is) return one return {} as CSSProperties } if (is) { return one } else { return two } } }}
有了这个后,后来的类名处理就写了这样
<script setup lang='ts'>const ns = useNamespace('drawer')</script><template><util-modal :visible="modelValue" :class="[ ns.is(direction === 'rtl', 'rtl'), ns.is(direction === 'ltr', 'ltr'), ns.is(direction === 'ttb', 'ttb'), ns.is(direction === 'btt', 'btt') ]" @close /></template>
开发方面都很简单,就不过多赘述了.
input 组件
这里为什么把input组件单独拿出来说一下呢,因为大家也看到了上面button的代码,功能不多,但是代码量特别大,而且繁琐。实际上,vue3的开发方式并不是这样的,上面的开发把全部都合并到一起了,有点像以前vue2的感觉,我们来看一下input组件。用过element的朋友应该知道,input组件在开启清除按钮后,鼠标滑入按钮才会显示,滑出后又会隐藏。这个功能我们要怎么实现呢,其实很简单,用一个bool变量,然后监听鼠标的滑入和滑出事件嘛。在这里我们选择封装成hook的写法,其实就是利用闭包
export const useMouseEnterLeave = () => { const mouse_is = ref(false) return { mouse_is, enter: () => (mouse_is.value = true), leave: () => (mouse_is.value = false) }}
然后在vue中引用
const { mouse_is, enter, leave } = useMouseEnterLeave()
因为vue3把响应式的功能封装成了ref和reactive这两个函数,不像以前vue2必须写在data函数返回值里面才具备相应监听,这样就让我们开发与封装更加灵活多变。
路由设计
根据上面的对组件导航栏的分析,我们可以发现,这是由多个类型组件的集合组成的大路由。简而言之,就是一个一级标题代表的就是该分类下的所有组件。
原本我是打算把它设计成数组的,但是考虑到对不同模块的显示隐藏的控制,最终把它设计为了一个对象,各位可以根据自己的实际情况自行处理。 组件路由的类型如下
export interface routerType { title: string routerData: RouteRecordRaw[]}
这是具体设计 /src/router/routerConfig/index.ts
export const routerDocsComponentConfig = { index: { title: '前言', routerData: beforeComponent }, baseComponents: { title: 'Basic 基础组件', routerData: baseComponent }, dataShowComponents: { title: 'Data 数据展示', routerData: dataShowComponent }, ... }
基础路由就是正常vue-router配置的类型 /src/router/routerConfig/base.component.ts
import BlButton from './src/bl-button.vue'import BlButtonGroup from './src/bl-button-group.vue'import { App } from 'vue'export default { install(app: App) { app.component('BlButton', BlButton) app.component('BlButtonGroup', BlButtonGroup) }}export { BlButtonGroup, BlButton }0
以基础组件路由举例,我们把基础路由相关的文档全部放在这里。可以看到这里引用的组件是一个md文件,具体操作我们等下会讲到。 具体的使用就是在通用路由中配置需要显示的模块的key. /src/components/doc-component-pag.vue
import BlButton from './src/bl-button.vue'import BlButtonGroup from './src/bl-button-group.vue'import { App } from 'vue'export default { install(app: App) { app.component('BlButton', BlButton) app.component('BlButtonGroup', BlButtonGroup) }}export { BlButtonGroup, BlButton }1
asideKeys里面配置了需要显示的路由模块,可以通过参数的顺序和增伤进一步控制导航的显示。
文档主体
上面我们说到每一个组件路由其实是一个md文件。要想在vue中正常解析md.我们需要下载一个vite插件。
import BlButton from './src/bl-button.vue'import BlButtonGroup from './src/bl-button-group.vue'import { App } from 'vue'export default { install(app: App) { app.component('BlButton', BlButton) app.component('BlButtonGroup', BlButtonGroup) }}export { BlButtonGroup, BlButton }2
为什么使用这个固定版本,因为当时我下载的最新版,有一个bug,就是无法在md文档中导入vue组件,通过它gitHub上提的issues说这个问题已经被解决,但是npm没有更新,现在不晓得更新了没得,但是我们不需要太多功能,这个版本够用了
接下来我们在vite的配置文件里面配置它
import BlButton from './src/bl-button.vue'import BlButtonGroup from './src/bl-button-group.vue'import { App } from 'vue'export default { install(app: App) { app.component('BlButton', BlButton) app.component('BlButtonGroup', BlButtonGroup) }}export { BlButtonGroup, BlButton }3
这里用到了markdown-it-anchor这个插件,这个插件的作用是在上面那个插件生成vue组件时候,把h标签的内容作为它的id,这样我们就可以通过id跳转的方式从目录跳转到指定内容了。 如果你使用的是ts,请在环境中提供md支持,将其文件类型定义为vue组件
import BlButton from './src/bl-button.vue'import BlButtonGroup from './src/bl-button-group.vue'import { App } from 'vue'export default { install(app: App) { app.component('BlButton', BlButton) app.component('BlButtonGroup', BlButtonGroup) }}export { BlButtonGroup, BlButton }4
接下来我们就可以愉快的使用vue和md双向导入功能了。 vue导入md就不多说了,直接导入作为组件就是,在md中使用vue组件的方法,这里简单说一下,md中可以用两种组件.
全局组件 直接当html标签使用,可以直接解析
局部组件,在md文件中导入使用,使用方式如下:
以上,我们就完成了md引入vue组件的操作,接下来我们来开发代码展示组件。 一共三个区域。
展示区:通过slot,展示外部组件。
控件去:前往仓库,一键复制,代码展示,三个控件
代码区:获取展示区传入的外部组件的代码,加上代码高亮展示 这个组件本身很简单,因为使用频繁,所以我们直接注册为全局组件,这样就可以直接在md文件中引入,而展示区的代码,则通过局部引入的方式,导入进行展示。文件结构如下:
每一个展示区,对应一个vue文件,这样控制粒度更加精细。
代码展示
下面我们来看看代码展示功能是如何实现的,vite可以通过如这种形式import xx from 'xx?raw'
把一个文件标记为资源文件,从而获取文件的内容,我们可以通过这种形式,获取展示区的代码。但是这种方式只能在开发环境得到支持,所以生产环境需要换成网络请求的方式,具体代码如下: /src/components/common/show-code.vue
import BlButton from './src/bl-button.vue'import BlButtonGroup from './src/bl-button-group.vue'import { App } from 'vue'export default { install(app: App) { app.component('BlButton', BlButton) app.component('BlButtonGroup', BlButtonGroup) }}export { BlButtonGroup, BlButton }5
判断是否是开发环境,选择静态资源加载或者网络请求。这里也可以看到,在开发环境下,我们需要把docs文件夹复制一份到打包后的根路径。开发到后期经常打包,这样手动cv实在是太恼火了,这里写了一个脚本,在打包后自己复制过去,用到了copy-dir这个包,需要自行下载
import BlButton from './src/bl-button.vue'import BlButtonGroup from './src/bl-button-group.vue'import { App } from 'vue'export default { install(app: App) { app.component('BlButton', BlButton) app.component('BlButtonGroup', BlButtonGroup) }}export { BlButtonGroup, BlButton }6
使用方式只需要在原本的打包命令后加上,就会自动在打包后执行这个代码,node后面是代码所在相对路径。
import BlButton from './src/bl-button.vue'import BlButtonGroup from './src/bl-button-group.vue'import { App } from 'vue'export default { install(app: App) { app.component('BlButton', BlButton) app.component('BlButtonGroup', BlButtonGroup) }}export { BlButtonGroup, BlButton }7
一键复制
一键复制功能就比较简单了,就是把代码的内容复制给一个input,进入选择状态后控制键盘执行copy指令 /src/components/common/show-code.vue
import BlButton from './src/bl-button.vue'import BlButtonGroup from './src/bl-button-group.vue'import { App } from 'vue'export default { install(app: App) { app.component('BlButton', BlButton) app.component('BlButtonGroup', BlButtonGroup) }}export { BlButtonGroup, BlButton }8
md文件使用方式
当我们把show-code组件全局注册后,就可以在md文件中使用它了
import BlButton from './src/bl-button.vue'import BlButtonGroup from './src/bl-button-group.vue'import { App } from 'vue'export default { install(app: App) { app.component('BlButton', BlButton) app.component('BlButtonGroup', BlButtonGroup) }}export { BlButtonGroup, BlButton }9
showPath是展示组件的路径,以便在展示代码的时候,获取对应的数据。 具体细节请查看 文档
打包上传npm
编写组件打包配置: /config/prod.com.config.ts
import { App } from 'vue'export * from './button'import button from './button'const components = [button]export default { install(app: App) { components.map((item) => item.install(app)) }}0
配置package.json
import { App } from 'vue'export * from './button'import button from './button'const components = [button]export default { install(app: App) { components.map((item) => item.install(app)) }}1
这里最重要的是这三个字段,files,main,module
files: 设置你要上传的目录,写上我们打包输出的目录
main: 项目主入口 这里主要是require引用的入口
module: 同样的主入口,这里是import引入的入口,比如我使用
import { App } from 'vue'export * from './button'import button from './button'const components = [button]export default { install(app: App) { components.map((item) => item.install(app)) }}2
默认就是导入:./BiLuoUI/biluo-ui.es.js
。
因为这是一个ui框架,用不上require导入,所以我们都写的一样的入口文件。
打包生成BiLuoUI:
上传npm:
登陆 执行npm login命令,系统会提示输入账户和密码。如果没有npm账户,请注册 → npm官网
发布 若账户登录成功后,就可以再次执行 npm publish 进行发布
注意
每次发布,都需要更新版本号,否则无法成功上传
上传到npm上时,要将package.json中的private属性值改为false
最后
这里大概是梳理了一下开发一个开源组件网站的方案和基本流程,希望对有此想法的朋友提供一定的帮助。文章并没有太过详细的简述ui组件的开发,相信这对大家来说都不是什么问题。如果有什么其他需要的可以自行查看本项目仓库。
原文:https://juejin.cn/post/7101567321717604360