• 首页 首页 icon
  • 工具库 工具库 icon
    • IP查询 IP查询 icon
  • 内容库 内容库 icon
    • 快讯库 快讯库 icon
    • 精品库 精品库 icon
    • 问答库 问答库 icon
  • 更多 更多 icon
    • 服务条款 服务条款 icon

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

武飞扬头像
碧落晓
帮助1

看完这篇文章,你会有以下新的认识:

  1. 如何使用vue3 vite封装插件并发布到npm
  2. 如何构建一个ui框架文档网站
  3. 插件开发中的技巧

前言

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

下面,仿照element plus官网的样子,来仿一个ui框架,以此讲述开发流程和用到的技术与方案。成品展示:

学新通

学新通

仓库地址: 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 在代码展示的时候,提供代码高亮

需求分析

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

学新通
抛开那些其他的功能,主要部分分为三个,左边根据组件分类的导航栏,中间的展示文档,以及右边的文档目录。

先看左侧导航

一个组件对应了一个目录,而我们需要把同种的目录分组,比如基础组件放一项,表单组件放一项等。

再看主体文档

  1. 主体文档应该使用markdown编写,一个组件对应一个md文件,所以我们需要有在vue中导入md的功能。
  2. 组件有不同的功能,需要提供一个演示框,这个演示框里面会放不同的组件功能展示,以及固定的查看代码,粘贴代码,前往仓库的固定功能。我可以发现这个演示框应该是一个vue组件,所以需要有在md文件中导入vue组件的功能
    最后看右侧的目录
  3. 目录需要自动提取md文件中的标题
  4. 目录需要跟着文档滚动而滚动
  5. 点击目录可以跳转到对应的标题

目录介绍

学新通
项目使用vite初始化,选择vue3 ts模板,然后包管理器使用的是yarn。具体初始化就不献丑了。
除此之外,我这里加入了eslint prettier为代码格式化,jest @vue/test-utils来提供测试支持(写了两三个组件测试就懒得写了…),这些没有也不影响开发,这里提一嘴。
目录规划如下:

  1. src 和平时的页面开发一致,这里存放展示在外的文档页面,打包成文档网站使用
  2. 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中可以用两种组件.

  1. 全局组件 直接当html标签使用,可以直接解析
  2. 局部组件,在md文件中导入使用,使用方式如下:

学新通
以上,我们就完成了md引入vue组件的操作,接下来我们来开发代码展示组件。
学新通
一共三个区域。

  1. 展示区:通过slot,展示外部组件。
  2. 控件去:前往仓库,一键复制,代码展示,三个控件
  3. 代码区:获取展示区传入的外部组件的代码,加上代码高亮展示
    这个组件本身很简单,因为使用频繁,所以我们直接注册为全局组件,这样就可以直接在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 进行发布
  • 注意
  1. 每次发布,都需要更新版本号,否则无法成功上传
  2. 上传到npm上时,要将package.json中的private属性值改为false

最后

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

这篇好文章是转载于:学新通技术网

  • 版权申明: 本站部分内容来自互联网,仅供学习及演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,请提供相关证据及您的身份证明,我们将在收到邮件后48小时内删除。
  • 本站站名: 学新通技术网
  • 本文地址: /boutique/detail/tanhggcice
系列文章
更多 icon
同类精品
更多 icon
继续加载