如何开发一款前端UI组件库??
写在最前,很久之前用vue3+ts+vite开发了一款前端UI组件库,工作很忙没有太多精力去维护扩展,今天补个教程,最主要的是其中开发思路。
在线预览:unicorn-zbf.gitee.io/zealousui
看完本章你将会学会:
- 如何封装通用组件
- 如何实现代码预览功能
- 如何发布一个npm包
- 如何部署Gitee or Github站点(静态网页)
一、创建demo
//npm、yarn or pnpm
yarn create vite my-baseui --template vue
//创建好后启动项目
yarn
yarn dev
二、梳理文件结构
//在根目录下创建packages文件夹,目录结构如下:
`packages/components`: 存放组件。
`packages/styles`: 存放全局样式和组件样式。
`packages/index.ts`:注册和导出组件
`packages/hooks`:拖拽的函数
`packages/iconfont`:字体
`packages/utils`:事件总线bus和其他公共方法
三、封装按钮组件
//在packages/components下创建button文件夹
`packages/components/button/doc/demo{n}`: 使用组件。
`packages/components/button/doc/doc.md`: md预览文件。
`packages/components/button/index.ts`: 用来把写好的组件暴露出去。
`packages/components/button/index.vue`: 封装的通用button组件。
1.准备style文件
// `styles/common/base.scss`组件库的主要颜色
$primary: #57a3f3;
$success: #19be6b;
$info: #909399;
$warning: #f90;
$error: #ed4014;
// `styles/components/button.scss`组件的样式
@import '../common/base.scss';
.z-button {
display: inline-block;
padding: 12px 20px;
line-height: 1;
border-radius: 8px;
border: 1px solid #d9d9d9;
box-sizing: border-box;
background: #fff;
color: #333;
white-space: nowrap;
outline: none;
font-size: 14px;
cursor: pointer;
}
.z-button--default {
border: 1px solid #d9d9d9;
background: #fff;
color: #333;
&:hover {
opacity: 0.8;
border-color: $primary;
color: $primary;
}
}
.z-button--primary {
color: #fff;
background-color: $primary;
border-color: $primary;
&:hover {
opacity: 0.8;
color: #fff;
}
}
.z-button--success {
color: #fff;
background-color: $success;
border-color: $success;
&:hover {
opacity: 0.8;
background-color: $success;
color: #fff;
}
}
.z-button--error {
color: #fff;
background-color: $error;
border-color: $error;
&:hover {
color: #fff;
border-color: $error;
opacity: 0.8;
}
}
.z-button--info {
color: #fff;
background-color: $info;
border-color: $info;
&:hover {
color: #fff;
border-color: $info;
opacity: 0.8;
}
}
.z-button--warning {
color: #fff;
background-color: $warning;
border-color: $warning;
&:hover {
color: #fff;
border-color: $warning;
opacity: 0.8;
}
}
.z-button--text {
border-color: transparent;
color: #409eff;
background: transparent;
padding-left: 0;
padding-right: 0;
&:hover {
border-color: transparent;
opacity: 0.8;
}
}
// 禁止
.is-disabled {
color: #fff;
cursor: not-allowed;
background-color: #fff;
border-color: #ebeef5;
}
.z-button--default.is-disabled {
color: #c0c4cc;
cursor: not-allowed;
background-color: #fff;
border-color: #ebeef5;
}
.z-button--primary.is-disabled {
color: #fff;
background-color: #a0cfff;
border-color: #a0cfff;
}
.z-button--success.is-disabled {
color: #fff;
background-color: #b3e19d;
border-color: #b3e19d;
}
.z-button--error.is-disabled {
color: #fff;
background-color: #fab6b6;
border-color: #fab6b6;
}
.z-button--info.is-disabled {
color: #fff;
background-color: #c8c9cc;
border-color: #c8c9cc;
}
.z-button--warning.is-disabled {
color: #fff;
background-color: #f3d19e;
border-color: #f3d19e;
}
.z-button--text.is-disabled {
cursor: no-drop;
color: #555;
border: 1px solid #fff;
background: none;
opacity: .5;
}
// 圆角
.is-round {
border-radius: 20px;
}
// 圆
.is-circle {
border-radius: 50%;
padding: 12px;
}
.noText {
margin-left: 0 !important;
margin-right: 0 !important;
}
.icon-loading {
display: inline-block;
margin-right: 4px;
animation: rotating 2s linear infinite;
-webkit-animation: rotating 2s linear infinite;
}
@keyframes rotating {
0% {
transform: rotate(0deg);
}
50% {
transform: rotate(180deg);
}
100% {
transform: rotate(360deg);
}
}
.z-button--medium {
padding: 10px 20px;
font-size: 14px;
}
.z-button--small {
padding: 9px 15px;
font-size: 12px;
}
.z-button--mini {
padding: 7px 15px;
font-size: 12px;
}
2.编写button组件
<template>
<button class="z-button" :class="zClass" :round="round" @click="handleClick">
<i class="iconfont icon-loading" v-if="loading"></i>
<i :class="isIconClass" v-if="leftIcon && !loading"></i>
<span ref="slotRef" :style="slotStyle" :class="isHaveSlot ? 'noText' : ''">
<slot></slot>
</span>
<i :class="isIconClass" v-if="rightIcon"></i>
</button>
</template>
<script lang="ts">
export default {
name: 'zButton'
};
</script>
<script lang="ts" setup>
import { ref, reactive, computed, watch, nextTick, onMounted } from "vue";
const props = defineProps({
type: {
type: String,
default: 'default',
validator(value:any) {
return ['default', 'primary', 'success', 'info', 'warning', 'error', 'text'].indexOf(value) > -1;
}
},
round: {
type: Boolean,
default: false
},
circle: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
},
size: {
type: String,
default: "default"
},
loading: {
type: Boolean,
default: false
},
leftIcon: {
type: String,
default: ""
},
rightIcon: {
type: String,
default: ""
},
});
const emits = defineEmits(['click'])
const slotRef:any= ref(null)
const isHaveSlot:any = ref(null)
const zClass = computed(() => {
return {
[`z-button--${props.type}`]: props.type,
'is-round': props.round,
'is-circle': props.circle,
'is-disabled': props.loading ? true : props.disabled,
[`z-button--${props.size}`]: props.size
}
});
const isIconClass = computed(() => {
return [
'iconfont',
props.leftIcon || props.rightIcon
]
})
const slotStyle = computed(() => {
return {
'margin-left': props.leftIcon ? '4px' : '0',
'margin-right': props.rightIcon ? '4px' : '0'
}
})
const handleClick = (e:any) => {
emits('click')
}
onMounted(() => {
if (!slotRef?.value.innerText) {
isHaveSlot.value = true
}
})
</script>
<style scoped lang="scss">
@import '../../styles/components/button.scss';
</style>
3.button组件中 index.js文件,用来把写的组件暴露出去
import zButton from './index.vue'
zButton.install = (app:any) => {
app.component(zButton.name, zButton)
}
export default zButton;
四、packages文件夹下的index.ts文件,用来管理所有的组件
import zButton from "./components/button/index";
const components = [
zButton,
]
// 定义 install 方法,接收 Vue 作为参数。如果使用 use 注册插件,那么所有的组件都会被注册
const install: any = (Vue: any) => {
// 判断是否安装
if (install.installed) return
// 遍历注册全局组件
components.map(component => Vue.component(component.name, component))
Vue.config.globalProperties.$message = zMessage
Vue.config.globalProperties.$loading = zLoading.service
Vue.directive('loading', zLoading.directive);
}
if (typeof window !== 'undefined' && (window as any).Vue) {
install((window as any).Vue);
}
export default {
install,
zButton,
};
//提供按需引入 import {zButton} from 'ZUI'
// export default ZUI;
五、组件引入
1.在src 的main.ts
中引入
import { createApp } from 'vue'
import App from './App.vue';
import ZUI from '../packages/index';
import router from './router';
// 引入全局样式
import './styles/index.scss';
import '~/iconfont/iconfont.css';
//代码高亮插件
import hljs from "highlight.js";
import "highlight.js/styles/color-brewer.css";
const app = createApp(App);
app.directive("highlight", function (el) {
const blocks = el.querySelectorAll("pre code");
blocks.forEach((block: any) => {
hljs.highlightBlock(block);
})
})
app.use(ZUI);
app.use(router)
app.mount('#app');
六、解析md文件
组件的文档一般是用 Markdown 来写。这里我们使用一个插件 vite-plugin-md vite pulgin 将 md 文件转换成 vue 组件渲染的主要流程是:
- 配置 vue router 路由,指向 .md 文件
- 编写 vite 插件将 .md 文件解析成 vue 文件字符串
- 最后由 vite 的插件@vitejs/plugin-vue 将 vue 文件字符串编译成函数组件返回给前端
- 配置路由
import routerPages from './routerPage/pages'
const routes = [
{
path: '/',
name: '主页',
component: () => import('@/views/index.vue')
},
{
path: '/home',
name: '组件页面',
component: () => import('@/views/home.vue'),
children: [
{
path: '/button',
name: 'Button 按钮',
component: () => import('/packages/components/button/doc/doc.md')
}
}
]
const router = createRouter({
history: createWebHashHistory(),
routes:routes,
});
export default router
- 配置vite.config.js文件,需要引入
vite-plugin-md
插件来解析 Markdown 文件并把它变成 Vue 文件。
import { defineConfig } from 'vite'
import Vue from '@vitejs/plugin-vue'
import Markdown from 'vite-plugin-md';
import { resolve } from "path";
// https://vitejs.dev/config/
export default defineConfig({
base:'./',
plugins: [
Vue({
include: [/\.vue$/, /\.md$/],
}),
Markdown()
],
resolve:{
alias:{
"@": resolve(__dirname, "src"),
'~': resolve(__dirname, "packages")
},
extensions: [".mjs", ".js", ".ts", ".jsx", ".tsx", ".json", ".vue"],
}
})
七.编写home页面,增加侧边栏和主区域
<template>
<div class="main-container">
<aside @mousemove="mousemove" @mouseleave="mouseleave" :class="{ 'sidebar-scroll': isEnter }"
v-show="!windowWidth">
<div class="sidebar" id="sidebarScroll">
<ul v-for="(item, ii) in menuList" :key="ii">
<p class="title">{{ item.name }}</p>
<li class="sidebarLi" v-for="(ele, index) in item.list" :key="index" :class="{ 'active': mIndex == ii + '-' + index }"
@click="goPath(ele, ii, index)">
{{ ele.name }}
</li>
</ul>
</div>
</aside>
<main class="app-main" ref="mainScroll">
<router-view></router-view>
</main>
<div class="content-slidebar" v-show="!windowWidth">
<div class="content-section">
<p class="content-title">内容导航</p>
<ul v-for="(item, ii) in state.contentList" :key="ii">
<p @click="mainScrollHandler(ii)" class="nav-title" :class="{ selected: item.active }">
<span class="desc"> {{ item.title }}</span>
</p>
<!-- <li v-for="(ele, index) in item.list" :key="ele" :class="{ 'active': mIndex == ii + '-' + index }"
@click="goPath(ele, ii, index)">
{{ ele.name }}
</li> -->
</ul>
</div>
</div>
</div>
</template>
<script setup lang=ts>
import { computed, ref, reactive, onMounted, nextTick, watch, onBeforeMount } from 'vue';
import { useRouter } from 'vue-router';
import Header from '../components/header.vue';
import { menuList } from '../router/routerPage/index';
import emitter from "../../packages/utils/bus";
const router = useRouter()
const mIndex: any = ref(sessionStorage.getItem("mIndex") || '0');
const isEnter = ref(false)
const state:any = reactive({
contentList: [],
topList: []
})
const mainScroll:any = ref(null)
const sidebarScroll = ref(null)
state.contentList = menuList[0]['list'][0]?.content;
watch(() => router.currentRoute.value,(newVal) => {
mIndex.value = sessionStorage.getItem("mIndex")
nextTick(() => {
//左侧菜单滚动
document.querySelector('.sidebarLi.active')?.scrollIntoView({
behavior: 'auto',
block: 'center',
inline: 'nearest'
})
confirmContentSlider(0); //初始化 内容导航栏
calcH2TopList(); // 回到顶部
})
},
{ immediate: true }
);
const goPath = (ele:any, ii:any, index:any) => {
mIndex.value = ii + '-' + index
console.log(mIndex.value)
state.contentList = menuList[ii]['list'][index]?.content;
router.push({
path: ele.path
})
sessionStorage.setItem("mIndex", mIndex.value);
}
const confirmContentSlider = (index:any) => {
let arr = state.contentList;
arr && arr.forEach((item:any, indexPath:any) => {
item.active = false;
if (indexPath == index) {
item.active = true;
}
})
state.contentList = arr;
}
const mousemove = () => {
isEnter.value = true
}
const mouseleave = () => {
isEnter.value = false
}
const calcH2TopList = () => {
let h2List = document.querySelectorAll('h2');
let arr:any = [];
h2List.forEach(item => {
arr.push(item.offsetTop);
})
state.topList = arr;
}
let isScrollStatus = false;
const mainScrollHandler = (index:any) => {
confirmContentSlider(index);
isScrollStatus = true;
mainScroll.value?.scrollTo({
top: state.topList[index] - 70,
left: 0,
behavior: 'smooth'
})
setTimeout(() => {
isScrollStatus = false;
}, 500);
}
const handleScroll = () => {
if (isScrollStatus) return;
//获取dom滚动距离
const scrollTop = mainScroll.value.scrollTop;
// console.log('滚动的距离:' + scrollTop);
for (let i = 0; i < state.topList.length; i++) {
if (scrollTop > state.topList[i] - 80 && scrollTop <= state.topList[i + 1] - 80) {
confirmContentSlider(i);
break;
}
}
}
let flag = true;
let that = this;
const thorrle = (fn:any, interval:any) => {
let last:any = 0;
return function () {
if (!flag) return false;
let context = that;
let args = arguments;
let now:any = new Date();
if ((now - last) > interval) {
last = now;
//劫持当前所在的方法返回fn的方法的内容
fn.apply(context, args);
}
}
}
//监听设备
let windowWidth = ref(false);
const resizeWidth = () => {
if(window.innerWidth<800){
windowWidth.value = true
} else {
windowWidth.value = false
}
}
window.addEventListener('resize',()=>{
resizeWidth()
})
onMounted(() => {
resizeWidth();
nextTick(() => {
calcH2TopList();
mainScroll.value.addEventListener("scroll", thorrle(handleScroll, 200));
})
emitter.on('previewChange', (res) => {
console.log('erer',res)
setTimeout(() => {
calcH2TopList();
}, 500);
})
})
onBeforeMount(() => {
emitter.off('previewChange');
nextTick(() => {
mainScroll.value.removeEventListener('scroll', () => { }); // 离开当前组件别忘记移除事件监听
})
});
</script>
<style lang="scss" scoped>
.main-container {
display: flex;
height: calc(100vh - 64px - 2vh);
margin-top: 8px;
overflow: hidden;
aside {
height: 100%;
border-right: 1px solid #eee;
overflow-y: auto;
box-shadow: 0 2px 8px #f0f1f2;
.sidebar {
width: 250px;
height: auto;
margin-top: 10px;
box-sizing: border-box;
.title {
font-weight: 700;
line-height: 40px;
margin-left: 40px;
color: #333;
border-bottom: 1px solid #f0f0f0;
}
ul {
li {
height: 50px;
line-height: 50px;
padding-left: 40px;
font-size: 13px;
color: #606266;
cursor: pointer;
}
.active {
color: #409eff;
background-color: #ECF5FF;
}
}
}
}
.sidebar-scroll {
&::-webkit-scrollbar {
display: block;
width: 6px;
height: 1px;
}
&::-webkit-scrollbar-thumb {
border-radius: 8px;
background: #ddd;
}
&::-webkit-scrollbar-track {
border-radius: 8px;
background: #fff;
}
}
.content-slidebar {
width: 300px;
height: 100%;
.content-section {
margin-top: 50px;
.content-title {
font-weight: 550;
font-size: 17px;
padding-bottom: 20px;
}
.nav-title {
cursor: pointer;
font-size: 15px;
padding: 10px 0;
position: relative;
.desc {
margin-left: 10px;
}
&:after {
content: '';
width: 0;
height: 0;
border-right: 4px solid #409eff;
position: absolute;
top: 50%;
left: 0;
transform: translateY(-50%);
transition: .3s;
}
}
.selected {
color: #409eff;
&:after {
height: 40%;
}
}
}
}
.app-main {
flex: 1;
padding: 10px 50px;
overflow-y: auto;
&::-webkit-scrollbar {
display: none;
width: 10px;
height: 1px;
}
}
}
</style>
再把app.vue改一下:
<template>
<Header />
<router-view></router-view>
</template>
<script lang="ts" setup>
import Header from '@/components/header.vue';
</script>
<style>
html,body{
margin: 0;
padding: 0;
}
#app {
overflow: hidden;
}
</style>
packages/components/button/doc/demo1
下使用组件。
<template>
<z-space>
<z-button>Default</z-button>
<z-button type="primary">Primary</z-button>
<z-button type="success">Success</z-button>
<z-button type="error">Error</z-button>
<z-button type="info">Info</z-button>
<z-button type="warning">Warning</z-button>
<z-button type="text">Text</z-button>
</z-space>
</template>
完成后,浏览器查看就有效果了。
八、代码预览功能
接下来我们来实现代码预览功能,虽然说代码预览也很简单,可以直接在 Markdown 中贴代码,但是代码又写一遍就显得很繁琐了,有没有方法可以直接把demo.vue中的代码展示出来呢?
答案是可以的。
在 Vite 的开发文档里有记载到,它支持在资源的末尾加上一个后缀来控制所引入资源的类型。比如可以通过 import xx from 'xx?raw' 以字符串形式引入 xx 文件。基于这个能力,我们可以编写一个 组件来获取所需要展示的文件源码。
1、新建一个 Preview.vue
文件
<template>
<div class="pre-code-box">
<transition name="slide-fade">
<pre class="language-html" v-if="showCode" v-highlight>
<code class="language-html">{{ sourceCode }}</code>
</pre>
</transition>
<div class="showCode" @click="showOrhideCode">
<i :class="iconClass"></i>
<span>{{ showCode ? "隐藏代码" : "显示代码" }}</span>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, watch, nextTick, onMounted } from "vue";
import emitter from "../../packages/utils/bus";
const props = defineProps({
compName: {
type: String,
default: "",
required: true
},
demoName: {
type: String,
default: "",
required: true
}
})
const showCode = ref(false);
const sourceCode = ref("");
const isDev = import.meta.env.MODE === 'development';
const iconClass = computed(() => {
return [
'iconfont',
showCode.value ? 'icon-arrow-up-filling' : 'icon-arrow-down-filling'
]
})
const showOrhideCode = () => {
emitter.emit('previewChange', 'ok');
showCode.value = !showCode.value;
}
const getSourceCode = async () => {
if (isDev) {
let msg = await import( /* @vite-ignore */`../../packages/components/${props.compName}/doc/${props.demoName}.vue?raw`)
sourceCode.value = msg.default
} else {
sourceCode.value = await fetch(`./components/${props.compName}/doc/${props.demoName}.vue`).then(res => res.text());
}
}
onMounted(() => {
getSourceCode()
})
</script>
<style lang="scss" scoped>
.slide-fade-enter-active {
transition: all 0.1s ease-out;
}
.slide-fade-leave-active {
transition: all 0.1s cubic-bezier(1, 0.5, 0.8, 1);
}
.slide-fade-enter-from,
.slide-fade-leave-to {
transform: translateY(-10px);
opacity: 0.5;
}
.pre-code-box {
width: 100%;
height: auto;
overflow: hidden;
border-top: 0;
position: relative;
transition: all 0.15s ease-out;
&:hover {
box-shadow: 0px 16px 15px -16px rgb(0 0 0 / 10%);
}
pre {
margin-top: -15px;
margin-bottom: 0;
}
.showCode {
width: 100%;
line-height: 40px;
font-size: 14px;
text-align: center;
background: #f9f9f9;
box-shadow: 0px 16px 15px -16px rgb(0 0 0 / 10%);
color: #666;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
&:hover {
background: #f9f9f9;
color: #0e80eb;
}
span {
margin-left: 10px;
}
}
}
</style>
这里需要加 @vite-ignore 的注释是因为 Vite 基于 Rollup,在 Rollup 当中动态 import 是被要求传入确定的路径,不能是这种动态拼接的路径。此处加上该注释则会忽略 Rollup 的要求而直接支持该写法。
但是这样的写法,在开发模式下可行,build打包到线上后报错,拿不到资源,我们可以通过判断环境变量,在 build 模式下通过 fetch 请求文件的源码来绕过,我们后面再讲。
2.在md文件中引入preview
<script setup>
import demo1 from './demo1.vue'
import demo2 from './demo2.vue'
import demo3 from './demo3.vue'
import demo4 from './demo4.vue'
import demo5 from './demo5.vue'
import preview from '@/components/preview.vue'
</script>
## 按钮Button
按钮用来触发一些操作。
### 演示
<div class="container">
<div class="container-gird">
<div class="source">
<p class="z-text-fs18">基础</p>
<p class="z-text">按钮的 type 分别为
<code class="z-text--code">default</code>、
<code class="z-text--code">primary</code>、
<code class="z-text--code">success</code>、
<code class="z-text--code">error</code>、
<code class="z-text--code">info</code>、
<code class="z-text--code">warning</code>、
<code class="z-text--code">text</code>。
</p>
<demo1/>
<preview compName="button" demoName="demo1"></preview>
</div>
<div class="source">
<p class="z-text-fs18">不同的尺寸</p>
<p class="z-text">额外的尺寸:
<code class="z-text--code">medium</code>、
<code class="z-text--code">small</code>、
<code class="z-text--code">mini</code>、
通过设置size属性来配置它们。
</p>
<demo2/>
<preview compName="button" demoName="demo2"></preview>
</div>
<div class="source">
<p class="z-text-fs18">禁用状态</p>
<p class="z-text">按钮可以被禁用。
</p>
<demo5/>
<preview compName="button" demoName="demo5"></preview>
</div>
</div>
<div class="container-gird">
<div class="source">
<p class="z-text-fs18">图标按钮</p>
<p class="z-text">在按钮上使用图标,通过设置
<code class="z-text--code">leftIcon</code>、
<code class="z-text--code">rightIcon</code>
属性来配置图标的位置。
</p>
<demo3/>
<preview compName="button" demoName="demo3"></preview>
</div>
<div class="source">
<p class="z-text-fs18">加载ing...</p>
<p class="z-text">要设置为 loading 状态,只要设置 <code class="z-text--code">loading</code> 属性为true即可。
</p>
<demo4/>
<preview compName="button" demoName="demo4"></preview>
</div>
</div>
</div>
<br/>
## API
| 参数 | 类型 | 说明 | 可选值 | 默认值 |
|---------- |-------- |---------- |------------- |-------- |
| type | string | 类型 | primary / success / warning / error / info / text | — |
| size | string | 尺寸 | medium / small / mini | — |
| round | boolean | 是否圆角按钮 | — | false |
| circle | boolean | 是否圆形按钮 | — | false |
| loading | boolean | 是否加载中状态 | — | false |
| disabled | boolean | 是否禁用状态 | — | false |
| leftIcon | string | 图标按钮,并且icon展示在左侧 | 参考图标库 | — |
| rightIcon | string | 图标按钮,并且icon展示在右侧 | 参考图标库 | — |
<br/>
main.ts已经注册了v-highlight自定义指令,查看页面效果:
九.gitee部署静态站点
到这里,我们的组件库基本结构算是完成了,接下来,我们上传到gitee部署静态站点。 执行npm run build 时,默认会打包成dist文件,打包后的文件夹里并没有components文件,我们得把packages下的components文件加入进去
开启Gitee Pages服务(需要上传身份证实名审核),选择分支和部署的目录,点击更新按钮等待片刻
十、发布npm
打包完之后执行npm login 命令,输入用户名和密码,输入密码时是看不到的,之后提示输入email,成功后你的邮箱会收到一个one-time password,填入这个一次性密码,登录之后,执行npm publish进行发布(每次进行发布的时候记得改下版本号),发布成功后,到npm上,查看头像---->packages,就可以看到发布的包了。
转载自:https://juejin.cn/post/7242677017035636795