vue3-UI组件库开发
UI组件是对一组相关的交互和样式的封装,提供了简单的调用方式和接口,让开发者能很便捷地使用组件提供的功能来实现业务需求。
在开发一个组件库之前,我们通常会想想为什么要去开发UI组件库?随着开发的应用越来越多,在各个应用中会出现很多类似的组件,我们就可以把这些的组件抽离出来,封装成一个组件库。封装成一个组件库有很多好处,例如:节省开发成本、统一设计和交互等。
下面是我对于一个UI组件库开发的一些思考,它不完全是合理的,如有错漏,希望大家指正。
组件库开发需要解决的问题
- 制定规范
- 初始化项目
- 支持多种安装方式
- 全量加载和按需加载
- 提供组件
- 定制主题
- 国际化
- 组件库使用文档
- 单元测试
- 发布
制定规范
1、定制样式变量。规划好哪些使用变量,保持样式一致。可以参考别的组件库自定义主题有哪些内容
- 主题色
- 字体、字体大小、字体颜色
- 背景颜色、阴影颜色、圆角大小
- 禁用状态、hover状态等
- 动画、间距
- 等等
2、组件设计规范
- 统一的交互规则,譬如:默认状态是什么样,hover状态是什么样,disable状态是什么样,空状态、按下、点击、双击、失焦、聚焦、反馈等等
- 定义好组件入参的类型
- 是否支持无障碍访问
3、编码规范
-
用
typescript还是javascript -
eslint
4、图标库
- 提供一套风格统一的图标库
- 使用字体实现还是svg实现
5、其他如命名规范、文档规范、git规范等
初始化项目
- 创建文件夹jUI并进入
npm init- 安装依赖
npm i rollup -g # 全局安装
npm i rollup -D # 项目本地安装
插件安装有问题,可能是node版本问题,找到适合当前node版本的插件即可。如果有其他插件兼容,可以百度。
npm i
rollup-plugin-babel @babel/core @babel/preset-env // 用于转换es6
rollup-plugin-commonjs // 支持CommonJS模块
rollup-plugin-postcss postcss // 处理样式
autoprefixer // css3的一些属性加前缀
cssnano // css压缩
vue //
rollup-plugin-vue @vue/compiler-sfc // 编译vue文件
rollup-plugin-terser // 代码压缩
@rollup/plugin-json // 解析json
node-sass sass // sass样式
rollup-plugin-copy // 拷贝文件
rimraf // 删除文件夹
vitest // 单元测试框架
happy-dom // 模拟浏览器,用于测试
@vue/test-utils // Vue.js 官方的单元测试实用工具库
@vitest/coverage-c8 // 测试用例覆盖率
-D
// .babelrc
{
"presets": [
[
"@babel/preset-env"
]
]
}
// rollup.config.js
import babel from 'rollup-plugin-babel'
import commonjs from 'rollup-plugin-commonjs'
import postcss from 'rollup-plugin-postcss'
import autoprefixer from 'autoprefixer'
import cssnano from 'cssnano' // css压缩
import vue from 'rollup-plugin-vue' // 处理vue文件插件
import { terser } from 'rollup-plugin-terser' // 压缩
import json from '@rollup/plugin-json'
import { name } from './package.json'
import path from 'path'
import copy from 'rollup-plugin-copy'
const file = type => `lib/${name}.${type}.js`
export { name, file }
export default {
input: './src/index.js',
output: {
name,
file: file('esm'),
format: 'es',
globals: {
vue: 'Vue',
'lodash-es': '_'
}
},
plugins: [
vue(),
babel({
exclude: 'node_modules/**'
}),
commonjs(),
postcss({
extract: path.resolve('lib/common.css'),
plugins: [
autoprefixer(),
cssnano()
]
}),
terser(),
json(),
copy({
targets: [
{ src: 'src/components/theme', dest: 'lib' },
{ src: 'src/locale', dest: 'lib' }
]
})
],
external: ['vue', 'jUI']
}
// rollup.esm.config.js
import basicConfig, {file, name} from './rollup.config'
export default {
...basicConfig,
output: {
name,
file: file('esm'),
format: 'es'
}
}
// rollup.umd.config.js
import basicConfig, { name, file } from './rollup.config'
export default {
...basicConfig,
output: {
name,
file: file('umd'),
format: 'umd',
globals: { // 设定全局变量的名称
'vue': 'Vue',
'lodash-es': '_'
},
exports: 'named'
}
}
// rollup.components.config.js
import basicConfig from './rollup.config'
import components from './components'
const config = []
Object.keys(components).forEach(key => {
config.push({
...basicConfig,
input: components[key],
output: {
file: `./lib/${key}/index.js`,
format: 'es',
globals: {
vue: 'Vue',
'lodash-es': '_'
}
}
})
})
export default config
// components.js
export default {
"button": "./src/components/button/index.js",
"icon": "./src/components/icon/index.js",
"message": "./src/components/message/index.js"
}
// package.json
{
"name": "jUI",
"version": "1.0.2",
"description": "",
"main": "./lib/jUI.umd.js",
"module": "./lib/jUI.esm.js",
"scripts": {
"build": "npm run build:esm && npm run build:umd && npm run build:components",
"build:esm": "rollup --config ./rollup.esm.config.js",
"build:umd": "rollup --config ./rollup.umd.config.js",
"build:components": "rollup --config ./rollup.components.config.js",
"clean": "rimraf ./lib",
"test": "vitest",
"coverage": "vitest run --coverage"
},
...
}
// vitest.config.js
import { resolve as _resolve } from 'path'
import { defineConfig } from 'vitest/config'
const resolve = (p) => _resolve(__dirname, p)
export default defineConfig({
test: {
include: ['tests/unit/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
globals: true,
environment: 'happy-dom',
alias: {
'@': resolve('src')
},
coverage: {
provider: 'c8',
reporter: ['text', 'json', 'html'],
},
}
})
jUI //
|-.babelrc //
|-.eslintrc.js //
|-.gitignore // git忽略配置文件
|-.npmignore // npm忽略配置文件
|-components.js // 需要单独打包的组件
|-lib // 打包后代码
| |-button //
| | |-index.js //
| |-common.css //
| |-icon //
| | |-index.js //
| |-jUI.esm.js //
| |-jUI.umd.js //
| |-locale //
| | |-index.js //
| | |-lang //
| | | |-es.js //
| | | |-zh-CN.js //
| |-message //
| | |-index.js //
| |-theme //
| | |-button.scss //
| | |-common //
| | | |-base.scss //
| | | |-var.scss //
| | |-icon.scss //
| | |-index.scss //
| | |-message.scss //
|-package-lock.json //
|-package.json //
|-rollup.components.config.js // 单独组件打包配置文件
|-rollup.config.js // rollup配置
|-rollup.esm.config.js //
|-rollup.umd.config.js //
|-src // 源代码
| |-components // 组件
| | |-button //
| | | |-index.js //
| | | |-src //
| | | | |-Button.js //
| | |-icon //
| | | |-index.js //
| | | |-src //
| | | | |-Icon.js //
| | |-message //
| | | |-index.js //
| | | |-src //
| | | | |-Message.js //
| | |-theme // 主题
| | | |-button.scss //
| | | |-common //
| | | | |-base.scss //
| | | | |-var.scss //
| | | |-icon.scss //
| | | |-index.scss //
| | | |-message.scss //
| |-index.js // 总入口文件
| |-locale // 国际化
| | |-index.js //
| | |-lang //
| | | |-es.js //
| | | |-zh-CN.js //
|-tests // 测试
| |-unit //
| | |-specs //
| | | |-button.spec.js //
| | |-util.js //
|-vitest.config.js // 测试配置文件
提供组件
Button组件
在src/components中新建button文件夹,如下
|-button // 组件文件夹
|-index.js // 组件入口文件
|-src //
|-Button.js // 组件
// Button.js
import { h } from 'vue'
import Icon from '../../icon/index.js'
// import { t } from '../../../locale/index.js'
import { t } from 'jUI/lib/locale/index.js'
export default {
name: 'jButton',
props: ['icon'],
setup (props, { emit }) {
return () => {
return h('button', {
class: 'j_button',
onClick: () => {
emit('buttonClick')
}
}, [
props.icon ? h(Icon, {
type: props.icon
}) : '',
t('jhs.button.text')
])
}
}
}
t函数是为国际化做的,具体在国际化部分讲
// src/components/button/index.js
// 导入组件,组件必须声明 name
import Button from './src/Button.js'
// 为组件提供 install 安装方法,供按需引入
Button.install = function (app) {
app.component(Button.name, Button)
}
// 默认导出组件
export default Button
Icon组件
在src/components中新建icon文件夹,如下
|-icon // 组件文件夹
|-index.js // 组件入口文件
|-src //
|-Icon.js // 组件
// Icon.js
import { h } from 'vue'
const customCache = new Set()
function createFromIconfont (scriptUrl = '//at.alicdn.com/t/font_1452953_5psa3u7r2bs.js') {
if (typeof document !== 'undefined' && typeof window !== 'undefined'
&& typeof document.createElement === 'function'
&& typeof scriptUrl === 'string' && scriptUrl.length
&& !customCache.has(scriptUrl)) {
var script = document.createElement('script')
script.setAttribute('src', scriptUrl)
script.setAttribute('data-namespace', scriptUrl)
customCache.add(scriptUrl)
document.body.appendChild(script)
}
return {
name: 'jIcon',
props: {
type: String,
size: [Number, String]
},
setup (props) {
return () => h('i', {
class: {
j_icon: true
},
style: {
fontSize: props.size ? `${props.size}px` : '12px'
},
}, [h('svg', {
viewBox: '0 0 1024 1024',
fill: 'currentColor',
width: '1em',
height: '1em',
focusable: 'false'
}, [h('use', { 'xlink:href': '#' + props.type })])])
}
}
}
const Icon = createFromIconfont()
Icon.createFromIconfont = createFromIconfont
export default Icon
// src/components/icon/index.js
// 导入组件,组件必须声明 name
import Icon from './src/Icon.js'
// 为组件提供 install 安装方法,供按需引入
Icon.install = function (app) {
app.component(Icon.name, Icon)
}
// 默认导出组件
export default Icon
Message组件
在src/components中新建message文件夹,如下
|-message // 组件文件夹
|-index.js // 组件入口文件
|-src //
|-Message.js // 组件
// Message.js
let seed = 1
class Message {
constructor (opt) {
this.msg = typeof opt === 'string' ? opt : (opt && opt.msg ? opt.msg : '加载中...')
this.time = opt && typeof opt.time !== 'undefined' ? opt.time : 3000
this.id = `message_${seed++}`
this.ele = null
this.init()
}
init () {
this.creatNode()
if (typeof this.time === 'number' && this.time !== 0) {
setTimeout(() => {
this.close()
}, this.time)
}
}
creatNode () {
this.ele = document.createElement('div')
this.ele.id = this.id
this.ele.className = 'message_box'
let p = document.createElement('p')
p.innerHTML = this.msg
this.ele.appendChild(p)
document.body.appendChild(this.ele)
}
close () {
this.ele && document.body.removeChild(this.ele)
}
}
export default function message (opt) {
return new Message(opt)
}
// src/components/message/index.js
// 导入组件,组件必须声明 name
import Message from './src/Message'
// 为组件提供 install 安装方法,供按需引入
Message.install = function (app) {
app.config.globalProperties.$message = Message
}
// 默认导出组件
export default Message
总入口
src/index.js作为整个组件库的入口文件
// src/index.js
import Button from './components/button/index.js'
import Icon from './components/icon/index.js'
import Message from './components/message/index.js'
import locale from './locale/index.js'
// import locale from 'jUI/lib/locale/index.js'
import { version } from '../package.json'
import './components/theme/index.scss'
// 存储组件列表
const components = {
Button,
Icon
}
// 定义 install 方法,接收 app 作为参数。如果使用 use 注册插件,则所有的组件都将被注册
export const install = function (app, opts = {}) {
// locale.use(opts.locale)
// 判断是否安装
if (install.installed) return
// 遍历注册全局组件
Object.keys(components).forEach(key => {
const component = components[key]
app.component(component.name, component)
})
app.config.globalProperties.$message = Message
locale.use(opts.locale)
install.installed = true
}
export {
Button,
Icon,
Message,
locale
}
export default {
version: version,
// 导出的对象必须具有 install,才能被 app.use() 方法安装
install
}
打包
npm run build 打包项目,输出在lib文件夹中
|-lib // 打包后代码
| |-button // Button单独打包后文件
| | |-index.js //
| |-common.css // 总的样式
| |-icon // Icon单独打包后文件
| | |-index.js //
| |-jUI.esm.js // esm文件
| |-jUI.umd.js // umd文件
| |-locale // 国际化
| | |-index.js //
| | |-lang // 语言包
| | | |-es.js //
| | | |-zh-CN.js //
| |-message // Message单独打包后文件
| | |-index.js //
| |-theme // 主题
| | |-button.scss //
| | |-common //
| | | |-base.scss //
| | | |-var.scss //
| | |-icon.scss //
| | |-index.scss //
| | |-message.scss //
支持多种安装方式
-
页面中直接引入。打包后的
jUI.umd.js的文件是支持直接引入到页面中的 -
支持npm安装,配置main、module
// package.json
{
...
"main": "./lib/jUI.umd.js",
"module": "./lib/jUI.esm.js",
...
}
全量加载和按需加载
-
全量加载,
import jUi from 'jUI' -
按需加载
-
import Message from 'jUI/lib/message' -
import { Icon, Button, locale } from 'jUI',通过babel插件实现按需加载【babel-plugin-import】 -
Tree Shaking方式,需要打包成 esmodule 格式,可能因为一些文件的“副作用”导致没法按需加载。可参考【你的Tree-Shaking并没什么卵用】
-
unplugin-vue-components插件,完全不需要自己来引入组件,直接在模板里使用,由插件来扫描引入并注册,但是需要自己来实现解析器。这个插件做的事情只是帮我们引入组件并注册,实际上按需加载的功能还是得靠前面两种方式。
-
配置.babelrc
// .babelrc
{
"presets": [
"@vue/cli-plugin-babel/preset",
[
"@babel/preset-env"
]
],
"plugins": [
[
"import", {
"libraryName": "jUI",
"libraryDirectory": "lib"
}
]
]
}
当前组件库的按需加载只实现了1、2种方式
本地调试
vue create jUI-test 使用@vue/cli重新起一个项目,这个项目也可以作为使用文档
-
在组件库根目录
npm link -
在jUI-test项目的根目录
npm link jUI -
在
jUI-test的package.json里配置依赖
"dependencies": {
"jUI": "1.0.0"
},
- 在
jUI-test项目引入jUI
import { createApp } from 'vue'
import App from './App.vue'
// import jUi from 'jUI'
// import es from 'jUI/lib/locale/lang/es'
import zh from 'jUI/lib/locale/lang/zh-CN'
// import 'jUI/lib/common.css'
import { Icon, Button, locale } from 'jUI'
import './assets/scss/variables.scss'
locale.use(zh)
createApp(App).use(Icon).use(Button).mount('#app')
npm run build -- --report 输出依赖报告
定制主题
这里只介绍覆盖sass默认变量的方式
我们在引入组件库样式的时候,是设计的一次引入全部样式,即使是按需引入组件,如果你想按需引入组件样式,需要做结构调整。
这部分代码在src/components/theme
// src/components/theme/common/var.scss
// 主题色
$orange-color: #FF6800 !default;
// src/components/theme/index.scss
// 引入所有组件样式
@import "./common/base.scss";
@import "./button.scss";
@import "./icon.scss";
@import "./message.scss";
在使用的时候,做样式覆盖即可
// variables.scss
$orange-color: blue;
@import '~jhs-ui/lib/theme/index.scss'
在main.js里引入
import './assets/scss/variables.scss'
国际化
locale // 国际化
| | |-index.js //
| | |-lang //
| | | |-es.js //
| | | |-zh-CN.js //
在src/locale文件夹中
// src/locale/index.js
import defaultLang from './lang/zh-CN'
let lang = defaultLang
export const t = function (path) {
const array = path.split('.')
let current = lang
console.log(current)
for (let i = 0, j = array.length; i < j; i++) {
const property = array[i]
const value = current[property]
if (i === j - 1) return value
if (!value) return ''
current = value
}
return ''
}
export const use = function (l) {
lang = l || lang
}
export default { use, t }
在src/locale/lang创建es.js和zh-CN.js
// es.js
export default {
jhs: {
button: {
text: 'button'
}
}
}
// zh-CN.js
export default {
jhs: {
button: {
text: '按钮'
}
}
}
在组件中引入src/locale/index.js
// src/components/button/src/Button.js
import { h } from 'vue'
import Icon from '../../icon/index.js'
// import { t } from '../../../locale/index.js'
import { t } from 'jUI/lib/locale/index.js'
export default {
name: 'jButton',
props: ['icon'],
setup (props, { emit }) {
return () => {
return h('button', {
class: 'j_button',
onClick: () => {
emit('buttonClick')
}
}, [
props.icon ? h(Icon, {
type: props.icon
}) : '',
t('jhs.button.text')
])
}
}
}
这里的t函数不能这样引入import { t } from '../../../locale/index.js',这样打包的时候会把t函数和默认的语言包打包进当前组件,导致自定义语言的时候失效
公共入口也是一样:
// import locale from './locale/index.js'
import locale from 'jUI/lib/locale/index.js'
export const install = function (app, opts = {}) {
locale.use(opts.locale)
}
需要:
-
import { t } from 'jUI/lib/locale/index.js'这样引入 -
rollup.config.js里排除掉jUI
// rollup.config.js
{
...,
external: ['vue', 'jUI']
}
项目中使用
// main.js
import { createApp } from 'vue'
import App from './App.vue'
// import jUi from 'jUI'
// import es from 'jUI/lib/locale/lang/es'
import zh from 'jUI/lib/locale/lang/zh-CN'
// import 'jUI/lib/common.css'
import { Icon, Button, locale } from 'jUI'
import './assets/scss/variables.scss'
locale.use(zh)
createApp(App).use(Icon).use(Button).mount('#app')
组件库使用文档
可以使用上面创建的jUI-test作为组件库的使用文档
书写文档使用markdown有时候更方便,在vue里使用markdown文档
npm i -D html-loader@1.3.0 markdown-loader
npm i github-markdown-css
vue.config.js里配置
chainWebpack: config => {
config.module
.rule('md')
.test(/\.md/)
.use('html-loader')
.loader('html-loader')
.end()
.use('markdown-loader')
.loader('markdown-loader')
.end()
}
使用:
<div class="markdown-body" v-html="md"></div>
import md from './apiDoc/button.md'
单元测试
使用 vitest 框架
vue-test-utils 文档地址
配置vitest.config.js
// vitest.config.js
import { resolve as _resolve } from 'path'
import { defineConfig } from 'vitest/config'
const resolve = (p) => _resolve(__dirname, p)
export default defineConfig({
test: {
include: ['tests/unit/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
globals: true,
environment: 'happy-dom',
alias: {
'@': resolve('src')
},
coverage: {
provider: 'c8',
reporter: ['text', 'json', 'html'],
},
}
})
测试用例
|-tests // 测试
| |-unit //
| | |-specs //
| | | |-button.spec.js //
| | |-util.js //
|-vitest.config.js // 测试配置文件
// util.js
import { mount } from '@vue/test-utils'
/**
* 创建一个 wrapper
* @param {Object} Comp 组件对象
* @param {Object} propsData props 数据
* @return {Object} wrapper
*/
export const createWrapper = function (Comp, propsData = {}) {
const wrapper = mount(Comp, {
...propsData
})
return wrapper
}
测试button组件
// tests/unit/specs/button.spec.js
import { describe, expect, test } from "vitest"
import { createWrapper } from '../util'
import Button from '@/components/button/'
describe('Button.vue', () => {
let wrapper
test('create', () => {
wrapper = createWrapper(Button)
expect(wrapper.classes()).toContain('j_button')
})
test('props', () => {
wrapper = createWrapper(Button, {
propsData: {
icon: 'icon-close'
}
})
const barByName = wrapper.findComponent({ name: 'jIcon' })
expect(barByName.exists()).toBe(true)
})
test('click', async () => {
wrapper = createWrapper(Button)
const el = wrapper.find('button')
await el.trigger('click')
// 断言事件已经被触发
expect(wrapper.emitted().buttonClick).toBeTruthy()
// 断言事件的数量
expect(wrapper.emitted().buttonClick.length).toBe(1)
await el.trigger('click')
expect(wrapper.emitted().buttonClick.length).toBe(2)
})
})
package.json配置script命令
{
"scripts": {
"test": "vitest",
"coverage": "vitest run --coverage"
}
}
发布
package.json:
- 删除
private: true - 修改
"license": "MIT" - 修改author
- 添加关联的git仓库
{
"name": "jUI",
"version": "1.0.2",
"description": "vue3组件库",
"main": "./lib/jUI.umd.js",
"module": "./lib/jUI.esm.js",
"scripts": {
"build": "npm run build:esm && npm run build:umd && npm run build:components",
"build:esm": "rollup --config ./rollup.esm.config.js",
"build:umd": "rollup --config ./rollup.umd.config.js",
"build:components": "rollup --config ./rollup.components.config.js",
"clean": "rimraf ./lib",
"test": "vitest",
"coverage": "vitest run --coverage"
},
"author": "zrd",
"license": "MIT",
"devDependencies": {
"@babel/core": "^7.18.13",
"@babel/preset-env": "^7.18.10",
"@rollup/plugin-json": "^4.1.0",
"@vitejs/plugin-vue": "^3.1.2",
"@vitest/coverage-c8": "^0.24.3",
"@vue/compiler-sfc": "^3.2.37",
"@vue/test-utils": "^2.0.0-rc.18",
"autoprefixer": "^10.4.8",
"cssnano": "^5.1.13",
"happy-dom": "^7.6.0",
"node-sass": "^7.0.1",
"postcss": "^8.4.16",
"rimraf": "^3.0.2",
"rollup-plugin-babel": "^4.4.0",
"rollup-plugin-commonjs": "^10.1.0",
"rollup-plugin-copy": "^3.4.0",
"rollup-plugin-postcss": "^4.0.2",
"rollup-plugin-terser": "^7.0.2",
"rollup-plugin-vue": "^6.0.0",
"sass": "^1.54.5",
"vitest": "^0.24.3",
"vue": "^3.2.37"
},
"browserslist": [
"defaults",
"not ie < 8",
"last 2 versions",
"> 1%",
"iOS 7",
"last 3 iOS versions"
],
"dependencies": {},
"repository": {
"type": "git",
"url": "xxx"
}
}
配置.npmignore
.DS_Store
node_modules
src
tests
coverage
.eslintrc.js
..babelrc
.gitignore
.npmignore
components.js
vitest.config.js
rollup.*.js
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
去 npm 发布,不要重名了,注意不要把公司代码发到外网,gitlab上可以通过 npm i git+gitlabURL 安装
npm login
npm publish
最后,一个UI组件库从开发到后期维护,也是需要投入很多的人力、精力,需要充分评估好可行性。
参考资料
vitest 框架
vue-test-utils 文档地址