使用vue3+vite开发仿element ui框架
看完这篇文章,你会有以下新的认识:
- 如何使用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 在代码展示的时候,提供代码高亮
需求分析
我们是仿照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
// 基础组件路由
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
<script setup lang="ts">
import DocPageCommon from './common/doc-page-common.vue'
const asideKeys = [
'index',
'baseComponents',
'formComponents',
'dataShowComponents',
'feedBackComponents'
]
</script>
<template>
<doc-page-common :aside-keys="asideKeys" base-link="/doc/component" />
</template>
asideKeys里面配置了需要显示的路由模块,可以通过参数的顺序和增伤进一步控制导航的显示。
文档主体
上面我们说到每一个组件路由其实是一个md文件。要想在vue中正常解析md.我们需要下载一个vite插件。
yarn add vite-plugin-md@0.11.6
为什么使用这个固定版本,因为当时我下载的最新版,有一个bug,就是无法在md文档中导入vue组件,通过它gitHub上提的issues说这个问题已经被解决,但是npm没有更新,现在不晓得更新了没得,但是我们不需要太多功能,这个版本够用了
接下来我们在vite的配置文件里面配置它
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组件
declare module '*.md' {
const Component: ComponentOptions
export default Component
}
接下来我们就可以愉快的使用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
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这个包,需要自行下载
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后面是代码所在相对路径。
&& node ./config/copyDocs.js
一键复制
一键复制功能就比较简单了,就是把代码的内容复制给一个input,进入选择状态后控制键盘执行copy指令/src/components/common/show-code.vue
// 复制代码
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文件中使用它了
<show-code showPath="button/baseButton">
<baseButton></baseButton>
</show-code>
showPath是展示组件的路径,以便在展示代码的时候,获取对应的数据。
具体细节请查看
文档
打包上传npm
编写组件打包配置:/config/prod.com.config.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.json
{
"name": "biluo-ui",
"auther": "biluo. Email: 1633420846@qq.com",
"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引入的入口,比如我使用
import BlUi from 'biluo-ui'
默认就是导入:./BiLuoUI/biluo-ui.es.js
。
因为这是一个ui框架,用不上require导入,所以我们都写的一样的入口文件。
打包生成BiLuoUI:
上传npm:
- 登陆
执行npm login命令,系统会提示输入账户和密码。如果没有npm账户,请注册 → npm官网 - 发布
若账户登录成功后,就可以再次执行 npm publish 进行发布 - 注意
- 每次发布,都需要更新版本号,否则无法成功上传
- 上传到npm上时,要将package.json中的private属性值改为false
最后
这里大概是梳理了一下开发一个开源组件网站的方案和基本流程,希望对有此想法的朋友提供一定的帮助。文章并没有太过详细的简述ui组件的开发,相信这对大家来说都不是什么问题。如果有什么其他需要的可以自行查看本项目仓库。
这篇好文章是转载于:学新通技术网
- 版权申明: 本站部分内容来自互联网,仅供学习及演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,请提供相关证据及您的身份证明,我们将在收到邮件后48小时内删除。
- 本站站名: 学新通技术网
- 本文地址: /boutique/detail/tanhggcice
-
photoshop保存的图片太大微信发不了怎么办
PHP中文网 06-15 -
《学习通》视频自动暂停处理方法
HelloWorld317 07-05 -
Android 11 保存文件到外部存储,并分享文件
Luke 10-12 -
word里面弄一个表格后上面的标题会跑到下面怎么办
PHP中文网 06-20 -
photoshop扩展功能面板显示灰色怎么办
PHP中文网 06-14 -
微信公众号没有声音提示怎么办
PHP中文网 03-31 -
excel下划线不显示怎么办
PHP中文网 06-23 -
excel打印预览压线压字怎么办
PHP中文网 06-22 -
TikTok加速器哪个好免费的TK加速器推荐
TK小达人 10-01 -
怎样阻止微信小程序自动打开
PHP中文网 06-13