使用vue3+vite开发一个仿element ui框架

语言: CN / TW / HK

theme: channing-cyan highlight: an-old-hope


看完这篇文章,你会有以下新的认识: 1. 如何使用vue3+vite封装插件并发布到npm 2. 如何构建一个ui框架文档网站 3. 插件开发中的技巧

前言

在平日的开发中,我们经常使用不同的ui框架,不知道大家有没有想法自己开发一个自己的ui框架,或许很多人感觉,没有必要重复造轮子,但是现在前端工程师的要求越来越高,需要的技术栈也越来越多,学习一下这个开发流程和一些解决方案还是很有必要的。而且我觉得,最重要的是,在平时的项目开发中,会有许多ui框架无法覆盖的组件,这是和这个业务比较绑定的,独属于这个业务需求的组件,当这个业务比较大的时候,这个组件就需要有更高的灵活性和易用性,有时候使用现有的ui框架进行二次封装也具有一定的成本,甚至高过从头开发,所以在这种情况下,我们就可以把常用的组件,封装成ui插件,配合上完整的组件文档,无论是方便以后项目迭代的时候查看,还是分享给其他人,都是极好的。
下面,仿照element plus官网的样子,来仿一个ui框架,以此讲述开发流程和用到的技术与方案。成品展示:

image.png

image.png

仓库地址: https://gitee.com/biluo_x/biluo-ui
npm地址:biluo-ui - npm (npmjs.com)

技术栈

  1. vue3 前端主流框架之一,这里我们使用3.2版本
  2. vite 代替vue-cli的新脚手架
  3. typescript js的超集,提供类型系统
  4. vite-plugin-md vite的md插件,提供把md文件当做vue导入的能力,最厉害的是,也可以在md文件中使用vue组件
  5. tailwindcss 为了快速得到效果,使用原子类提供样式
  6. prismjs 在代码展示的时候,提供代码高亮

    如果没有使用过tailwindcss 可以看看这篇文章:受够了重复繁琐的css?来试试原子类吧 - 掘金 (juejin.cn)

需求分析

我们是仿照element plus来写的所以,我们可以观察一下element 的展示情况。

image.png 抛开那些其他的功能,主要部分分为三个,左边根据组件分类的导航栏,中间的展示文档,以及右边的文档目录。
先看左侧导航
一个组件对应了一个目录,而我们需要把同种的目录分组,比如基础组件放一项,表单组件放一项等。
再看主体文档
1. 主体文档应该使用markdown编写,一个组件对应一个md文件,所以我们需要有在vue中导入md的功能。 2. 组件有不同的功能,需要提供一个演示框,这个演示框里面会放不同的组件功能展示,以及固定的查看代码,粘贴代码,前往仓库的固定功能。我可以发现这个演示框应该是一个vue组件,所以需要有在md文件中导入vue组件的功能 最后看右侧的目录
1. 目录需要自动提取md文件中的标题 2. 目录需要跟着文档滚动而滚动 3. 点击目录可以跳转到对应的标题

目录介绍

image.png 项目使用vite初始化,选择vue3+ts模板,然后包管理器使用的是yarn。具体初始化就不献丑了。 除此之外,我这里加入了eslint+prettier为代码格式化,[email protected]/test-utils来提供测试支持(写了两三个组件测试就懒得写了...),这些没有也不影响开发,这里提一嘴。 目录规划如下:
1. src 和平时的页面开发一致,这里存放展示在外的文档页面,打包成文档网站使用 2. packages 这里存放我们ui组件相关的代码。主要结构如下:

image.png

在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 ```js 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`js 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组件的具体开发:

```js

这个代码看起来不少,实际上很简单,最多的就是,prop和根据prop对类名进行处理。button的所有样式都是使用css来控制的。js只在原生属性上面稍微处理了一下。这个代码其实写的不好,在类名处理哪里写了一堆的三元表达式,后来发现element源码里面写弄了一个hook专门搞这个,我也去整了一个,代码很简单,大概就是根据bool改变类名之类的:ts 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 } } } } 有了这个后,后来的类名处理就写了这样ts

``` 开发方面都很简单,就不过多赘述了.

input 组件

这里为什么把input组件单独拿出来说一下呢,因为大家也看到了上面button的代码,功能不多,但是代码量特别大,而且繁琐。实际上,vue3的开发方式并不是这样的,上面的开发把全部都合并到一起了,有点像以前vue2的感觉,我们来看一下input组件。用过element的朋友应该知道,input组件在开启清除按钮后,鼠标滑入按钮才会显示,滑出后又会隐藏。这个功能我们要怎么实现呢,其实很简单,用一个bool变量,然后监听鼠标的滑入和滑出事件嘛。在这里我们选择封装成hook的写法,其实就是利用闭包 ts export const useMouseEnterLeave = () => { const mouse_is = ref(false) return { mouse_is, enter: () => (mouse_is.value = true), leave: () => (mouse_is.value = false) } } 然后在vue中引用 ts const { mouse_is, enter, leave } = useMouseEnterLeave() 因为vue3把响应式的功能封装成了ref和reactive这两个函数,不像以前vue2必须写在data函数返回值里面才具备相应监听,这样就让我们开发与封装更加灵活多变。

路由设计

根据上面的对组件导航栏的分析,我们可以发现,这是由多个类型组件的集合组成的大路由。简而言之,就是一个一级标题代表的就是该分类下的所有组件。

image.png 原本我是打算把它设计成数组的,但是考虑到对不同模块的显示隐藏的控制,最终把它设计为了一个对象,各位可以根据自己的实际情况自行处理。 组件路由的类型如下 ts export interface routerType { title: string routerData: RouteRecordRaw[] } 这是具体设计 /src/router/routerConfig/index.ts 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

ts // 基础组件路由 export const baseComponent: RouteRecordRaw[] = [ { path: 'button', meta: { title: 'Button 按钮' }, component: () => import('../../docs/button/README.md') }, { path: 'layout', meta: { title: 'Layout 布局' }, component: () => import('../../docs/layout/README.md') }, { path: 'container', meta: { title: 'Container 布局容器' }, component: () => import('../../docs/container/README.md') }, { path: 'icon', meta: { title: 'Icon 图标' }, component: () => import('../../docs/icon/README.md') } ] 以基础组件路由举例,我们把基础路由相关的文档全部放在这里。可以看到这里引用的组件是一个md文件,具体操作我们等下会讲到。 具体的使用就是在通用路由中配置需要显示的模块的key. /src/components/doc-component-pag.vue ```ts

``` asideKeys里面配置了需要显示的路由模块,可以通过参数的顺序和增伤进一步控制导航的显示。

文档主体

上面我们说到每一个组件路由其实是一个md文件。要想在vue中正常解析md.我们需要下载一个vite插件。 js yarn add [email protected]

为什么使用这个固定版本,因为当时我下载的最新版,有一个bug,就是无法在md文档中导入vue组件,通过它gitHub上提的issues说这个问题已经被解决,但是npm没有更新,现在不晓得更新了没得,但是我们不需要太多功能,这个版本够用了

接下来我们在vite的配置文件里面配置它 ts plugins: [ vue({ include: [/.vue$/, /.md$/] }), vueJsx(), Markdown({ markdownItSetup(md) { // add anchor links to your H[x] tags md.use(require('markdown-it-anchor')) } }) ] 这里用到了markdown-it-anchor这个插件,这个插件的作用是在上面那个插件生成vue组件时候,把h标签的内容作为它的id,这样我们就可以通过id跳转的方式从目录跳转到指定内容了。 如果你使用的是ts,请在环境中提供md支持,将其文件类型定义为vue组件 ts declare module '*.md' { const Component: ComponentOptions export default Component } 接下来我们就可以愉快的使用vue和md双向导入功能了。 vue导入md就不多说了,直接导入作为组件就是,在md中使用vue组件的方法,这里简单说一下,md中可以用两种组件. 1. 全局组件 直接当html标签使用,可以直接解析 2. 局部组件,在md文件中导入使用,使用方式如下:

image.png 以上,我们就完成了md引入vue组件的操作,接下来我们来开发代码展示组件。 image.png 一共三个区域。 1. 展示区:通过slot,展示外部组件。 2. 控件去:前往仓库,一键复制,代码展示,三个控件 3. 代码区:获取展示区传入的外部组件的代码,加上代码高亮展示 这个组件本身很简单,因为使用频繁,所以我们直接注册为全局组件,这样就可以直接在md文件中引入,而展示区的代码,则通过局部引入的方式,导入进行展示。文件结构如下:

image.png 每一个展示区,对应一个vue文件,这样控制粒度更加精细。

代码展示

下面我们来看看代码展示功能是如何实现的,vite可以通过如这种形式import xx from 'xx?raw'把一个文件标记为资源文件,从而获取文件的内容,我们可以通过这种形式,获取展示区的代码。但是这种方式只能在开发环境得到支持,所以生产环境需要换成网络请求的方式,具体代码如下: /src/components/common/show-code.vue ts onMounted(async () => { const isDev = import.meta.env.MODE === 'development' if (isDev) { /* @vite-ignore */ const data: any = await import(/* @vite-ignore */ `../../docs/${props.showPath}.vue?raw`) sourceCode.value = data.default } else { sourceCode.value = await fetch(`/docs/${props.showPath}.vue`).then((res) => res.text()) } await nextTick(() => { Prism.highlightAll() }) }) 判断是否是开发环境,选择静态资源加载或者网络请求。这里也可以看到,在开发环境下,我们需要把docs文件夹复制一份到打包后的根路径。开发到后期经常打包,这样手动cv实在是太恼火了,这里写了一个脚本,在打包后自己复制过去,用到了copy-dir这个包,需要自行下载 js let copydir = require('copy-dir') copydir.sync( process.cwd() + '/src/docs', process.cwd() + '/BiLuoUiDoc/docs', { utimes: true, mode: true, cover: true }, function (err) { if (err) throw err console.log('done') } ) 使用方式只需要在原本的打包命令后加上,就会自动在打包后执行这个代码,node后面是代码所在相对路径。 js && node ./config/copyDocs.js

一键复制

一键复制功能就比较简单了,就是把代码的内容复制给一个input,进入选择状态后控制键盘执行copy指令 /src/components/common/show-code.vue ts // 复制代码 const copyCode = () => { const input = document.createElement('input') document.body.appendChild(input) input.setAttribute('value', sourceCode.value as string) input.setAttribute('readonly', 'readonly') input.select() input.setSelectionRange(0, 9999) // 如果select 没有选择到 if (document.execCommand('copy')) { // console.log('报文已复制到剪切板') BlMessageFn.success!({ message: '成功复制', duration: 2000 }) } document.body.removeChild(input) }

md文件使用方式

当我们把show-code组件全局注册后,就可以在md文件中使用它了 html <show-code showPath="button/baseButton"> <baseButton></baseButton> </show-code> showPath是展示组件的路径,以便在展示代码的时候,获取对应的数据。 具体细节请查看 文档

打包上传npm

编写组件打包配置: /config/prod.com.config.ts ```ts import { defineConfig } from 'vite' // import vue from '@vitejs/plugin-vue' import { resolve } from 'path' import baseConfig from './base.config' // 主要用于alias文件路径别名

export default defineConfig({ ...baseConfig, // 打包配置 build: { sourcemap: false, //不开启镜像 outDir: 'BiLuoUI', assetsInlineLimit: 8192, // 小于 8kb 的导入或引用资源将内联为 base64 编码 terserOptions: { // 生产环境移除console compress: { drop_console: true, drop_debugger: true } }, lib: { entry: resolve(process.cwd(), './packages/components/index.ts'), // 设置入口文件 name: 'biluo-ui', // 起个名字,安装、引入用 fileName: (format) => biluo-ui.${format}.js // 打包后的文件名 }, rollupOptions: { // 确保外部化处理那些你不想打包进库的依赖 external: ['vue', 'tailwindcss', '@element-plus/icons-vue'], output: { // 在 UMD 构建模式下为这些外部化的依赖提供一个全局变量 globals: { vue: 'Vue', tailwindcss: 'tailwindcss', '@element-plus/icons-vue': '@element-plus/icons-vue' } } } } }) 配置package.jsonjson { "name": "biluo-ui", "auther": "biluo. Email: [email protected]", "private": false, "version": "1.0.6", "description": "这是一个模仿element ui写的ui组件。用以练手和学习。", "keyword": "typescript tailwindcss ui element", "files": ["BiLuoUI"], "main": "./BiLuoUI/biluo-ui.es.js", "module": "./BiLuoUI/biluo-ui.es.js", "repository": "https://gitee.com/biluo_x/biluo-ui", ... } 这里最重要的是这三个字段,files,main,module - files: 设置你要上传的目录,写上我们打包输出的目录 - main: 项目主入口 这里主要是require引用的入口 - module: 同样的主入口,这里是import引入的入口,比如我使用ts import BlUi from 'biluo-ui' `` 默认就是导入:./BiLuoUI/biluo-ui.es.js`。

因为这是一个ui框架,用不上require导入,所以我们都写的一样的入口文件。

打包生成BiLuoUI:

image.png 上传npm: - 登陆 执行npm login命令,系统会提示输入账户和密码。如果没有npm账户,请注册 → npm官网 - 发布 若账户登录成功后,就可以再次执行 npm publish 进行发布 - 注意 1. 每次发布,都需要更新版本号,否则无法成功上传 2. 上传到npm上时,要将package.json中的private属性值改为false

最后

这里大概是梳理了一下开发一个开源组件网站的方案和基本流程,希望对有此想法的朋友提供一定的帮助。文章并没有太过详细的简述ui组件的开发,相信这对大家来说都不是什么问题。如果有什么其他需要的可以自行查看本项目仓库。