前言
本文目的是带领大家从0开始开发一个组件库,看了网上开发组件库的文章,要么组件库是基于React
的,对于Vue
党十分不友好,要不就是文章写的不具体,看完还是不知从何开始。基于我司组件库开发经验,决定自己总结一篇通俗易上手的文章。
我们即将要实现的组件库vfox-ui是基于 Ant Design Vue组件库的一个二次封装,只写了一个vf-button
组件的示例,并没有全部组件的代码。
vfox-ui
演示代码github地址:github.com/Iffyyy/vfox…- 组件库文档地址:iffyyy.github.io/vfox-ui/
- 组件库单测报告地址:iffyyy.github.io/vfox-ui/uni…
相关工具
构建一个组件库,无疑工作量很大,一般公司很少会从0开始,都是基于现有的ui框架,进行二次封装。但无论怎样,一个成熟的组件库应该满足以下要求:
- 开发调试,开发组件的时候在本地调试代码
- 支持按需引入组件,组件库必备条件
- 单元测试,保证代码的可信度
- 组件库文档,说明组件的用法和组件示例
- 自动化的文档部署
环境搭建
创建项目
我们使用 vue create vfox-ui
来创建项目,vfox-ui是我们的项目名。
注意:
- 本项目仍然选择的
vue2.x
版本 - 因为要进行单测,安装的时候单测工具请选择
jest
目录结构修改
- docs:存放组件库文档
- packages:存放组件代码,一个组件单独一个文件夹
实现一个button组件
为什么使用 JSX
JSX
是JavaScript
的语法扩展,允许我们在JavaScript
里面写XML
,具备了javaScript
的灵活性,同时又兼具HTML
的语义化和直观性。
- Ant Design Vue的底层是基于
JSX
实现的,而我们的组件库是基于Ant Design Vue
的二次开发。 JSX
的写法更灵活,对于我们开发复杂组件提供了很大的帮助。
不熟悉 JSX
用法的可以移步 vuejs/jsx 学习。
编写组件代码
当然完全使用JSX
来写,有一定学习成本,而vue
本身也支持JSX
的写法,所以可以在vue
组件内写JSX
的方式来实现:
// packages/vf-button/vf-button.vue
<script>
import { Button } from 'ant-design-vue'
import 'ant-design-vue/lib/button/style'
export default {
name: 'VfButton',
components: {
AButton: Button
},
render() {
const listeners = {
...this.$listeners,
click: this.btnClick
}
const props = {
...this.$attrs,
...this.$props
}
return (
<a-button
class="vf-button"
on={listeners}
props={props}
scopedSlots={this.$scopedSlots}
>
{this.$slots.default}
</a-button>
)
},
data() {
return {}
},
methods: {
btnClick(event) {
this.$emit('click', event)
}
},
props: {
disabled: {
type: Boolean,
default: false
},
ghost: {
type: Boolean,
default: false
},
htmlType: {
type: String,
default: 'button'
},
icon: {
type: String
},
size: {
type: String,
default: 'default'
},
loading: {
type: [Boolean, Object],
default: false
},
shape: {
type: String
},
type: {
type: String,
default: 'default'
},
block: {
type: Boolean,
default: false
}
},
}
</script>
复制代码
因为是二次封装,主要做了两件事:
- 修改组件样式风格
- 在组件原本功能上进行了扩展
同理,我们来实现vf-button-group组件:
// packages/vf-button/vf-button-group.vue
<script>
import { Button } from 'ant-design-vue'
import 'ant-design-vue/lib/button/style'
export default {
name: 'VfButtonGroup',
components: {
AButtonGroup: Button.Group
},
props: {},
render() {
const listeners = {
...this.$listeners
}
const props = { ...this.$attrs, ...this.$props }
return (
<a-button-group
class="vf-button-group"
on={listeners}
props={props}
scopedSlots={this.$scopedSlots}
>
{this.$slots.default}
</a-button-group>
)
},
}
</script>
复制代码
安装组件并导出
首先导出单个组件
// packages/vf-button/index.js
import VfButton from './vf-button.vue';
import VfButtonGroup from './vf-button-group.vue';
VfButton.install = function (Vue) {
Vue.component(VfButton.name, VfButton);
};
VfButtonGroup.install = function (Vue) {
Vue.component(VfButtonGroup.name, VfButtonGroup);
};
export {
VfButton,
VfButtonGroup
}
复制代码
在packages的入口文件中导入所有的组件(这里只有示例的vf-botton组件),安装并导出所有组件:
// packages/index.js
import { VfButton, VfButtonGroup } from './vf-button/index';
const components = [
VfButton,
VfButtonGroup
]
const install = function (Vue) {
components.map(component => Vue.use(component));
shell_components.map(el => {
Vue.component(el.name, el.component);
});
}
if (typeof window !== 'undefined' && window.Vue) {
install(window.Vue)
}
export {
VfButton,
VfButtonGroup
}
export default {
install
}
复制代码
调试组件
全局安装组件
// src/main.js
import VfUI from '../packages/index.js';
Vue.use(VfUI);
复制代码
在页面中使用
// src/views/test.vue
<template>
<div>
<VfButton>展开</VfButton>
</div>
</template>
复制代码
这样就能在本地调试组件了。
单元测试
单元测试是针对程序的最小单元来进行正确性检验的测试工作。
为什么要进行单元测试
- 提高代码的可靠性,单测覆盖越全,说明你的代码越可靠。
- 保证重构质量,有效的单测能降低重构的风险。
我们一般的项目没有做单元测试,因为业务变动比较大,再加上开发周期比较紧张,做单元测试十分不现实。但是像组件库这种自然会选择做单元测试,来提高程序的正确率。
jest配置
单测的工具很多,这里我们用的 jest + vue/test-utils。
jest
相关配置如下:
// jest.config.js
module.exports = {
preset: "@vue/cli-plugin-unit-jest",
moduleFileExtensions: ["js", "jsx", "json", "vue"], // 告诉jest要处理哪些文件
transform: {
"^.+\\.vue$": "vue-jest", // 用 vue-jest 处理.vue文件
".+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$":
"jest-transform-stub",
"^.+\\.jsx?$": "babel-jest",
},
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1", // 支持源代码中相同的 `@` -> `src` 别名
"vfox-ui(.*)$": "<rootDir>/packages/$1"
},
transformIgnorePatterns: ["<rootDir>/node_modules/(?!ant-design-vue/)"],
snapshotSerializers: ["jest-serializer-vue"],
testMatch: [
"**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)",
],
testURL: "http://localhost/",
verbose: true,
collectCoverage: true, // 生成测试覆盖率报告
coverageDirectory: './docs/unit-test-coverage', // 目标输出目录
collectCoverageFrom: [ // 定义需要收集测试覆盖率信息的文件
"packages/**/*.{js,ts,vue}",
"!src/App.vue", // 入口文件排除
"!src/main.js", // 设置文件排除
"!src/router/index.js",
"!**/node_modules/**",
],
}
复制代码
上面的配置已经在关键配置的地方给出了注释,更多配置信息请移步 Configuring Jest。
button 组件单测
由于文章篇幅有限,这里不具体介绍单元测试怎么写,官网的介绍已经很详细了。这里只贴一下示例代码:
// tests/unit/vf-button.spec.js
import { mount, createLocalVue } from "@vue/test-utils";
import { VfButton, VfButtonGroup } from "../../packages";
import UI from "../../packages/index";
const localVue = createLocalVue();
localVue.use(UI);
/////////////////////////////////VfButton
const factory = (props = {}, data = {}) => {
return mount(VfButton, {
propsData: {
...props,
},
data() {
return {
...data,
};
},
});
};
describe("VfButton", () => {
it("测试click触发", async () => {
const wrapper = factory();
// 获取对应按钮
const button = wrapper.find("button");
// 点击按钮
button.trigger("click");
await wrapper.vm.$nextTick();
// 断言$emit('click')函数被触发
expect(wrapper.emitted().click).toBeTruthy();
});
});
/////////////////////////////////VfButtonGroup
const factory1 = (props = {}, data = {}) => {
return mount(
{
render() {
return (
<VfButtonGroup attrs={{ ...props }}>
<VfButton type="cloud"> Button1 </VfButton>
<VfButton type="cloud"> Button2 </VfButton>
</VfButtonGroup>
);
},
},
{
propsData: {
...props,
},
data() {
return {
...data,
};
},
}
);
};
describe("VfButtonGroup", () => {
// 测试内容:snapshot->概括的测试DOM结构
it("测试DOM结构", () => {
const wrapper = factory1();
expect(wrapper.html()).toMatchSnapshot();
});
it("测试size属性", () => {
const wrapper1 = factory1({
size: "small",
});
// 断言ant-btn-sm类名存在
expect(wrapper1.classes()).toContain("ant-btn-group-sm");
const wrapper2 = factory1({
size: "large",
});
// 断言ant-btn-lg类名存在
expect(wrapper2.classes()).toContain("ant-btn-group-lg");
});
});
复制代码
生成单测覆盖率报告
单元测试覆盖率是我们单测的度量指标,从报告能看出我们单元测试的代码占比,从而分析出哪些地方单测覆盖不全。 生成测试覆盖率报告的配置,我们已经在前面配置过了:
// jest.config.js
module.exports = {
...
collectCoverage: true, // 生成测试覆盖率报告
coverageDirectory: './docs/unit-test-coverage', // 目标输出目录
collectCoverageFrom: [ // 定义需要收集测试覆盖率信息的文件
"packages/**/*.{js,ts,vue}",
"!src/App.vue", // 入口文件排除
"!src/main.js", // 设置文件排除
"!src/router/index.js",
"!**/node_modules/**",
],
}
复制代码
在package.json文件中配置命令:
"scripts": {
...
"test": "vue-cli-service test:unit",
}
复制代码
再次运行,可以看到测试报告
同时,我们也可以看到在vfox-ui\docs\unit-test-coverage
路径下生成了报告结果,打开\unit-test-coverage\lcov-report\index.html
文件可以看到以下页面:
发布到npm
打包配置
新建lib.js
文件,内容如下:
// vfox-ui\config\common\lib.js
require('shelljs/global');
const inquirer = require('inquirer');
const chalk = require('chalk');
const config = require('../../package.json');
let run = function () {
// 更新
exec(`git pull`);
console.log(chalk.blueBright(`编译组件总包`));
// 编译组件总包
exec(`vue-cli-service build --target lib --formats=umd-min --name index --no-clean --dest lib packages/index.js`);
console.log(chalk.green(`编译已完成`));
compPublish();
};
let compPublish = function () {
inquirer.prompt([{
name: 'conform',
message: `是否发布工程到仓库?`,
type: 'list',
default: 1,
choices: [{
name: '是',
value: 1
}, {
name: '否',
value: 0
}]
}]).then(function (answers) {
if (answers.conform) {
console.log(`npm unpublish ${config.name}@${config.version} --force`);
exec(`npm unpublish ${config.name}@${config.version} --force`);
console.log(`npm publish`);
exec('npm publish');
console.log(chalk.green(`已发布`));
}
});
};
run();
复制代码
package.json配置
这里说下关键的配置:
// package.json
{
"name": "vfox-ui",
"version": "1.0.0",
"main": "lib/index.umd.min.js",
"private": false,
"scripts": {
...
"lib": "node config/common/lib.js", // 打包命令
}
}
复制代码
name
:包名。如果你要把组件库发布在npm
上,在取名字的时候可以去npm上搜一下,注意不要和现有的包重名version
:版本号main
:组件库的主入口地址(在使用组件时引入的地址)private
:声明组件库的私有性,想要公开组件库设置为false
发布
发布组件只需要下面这两步:
npm login
:登录npm
账户,没有账户的可以先去npm官网注册npm run lib
:打包组件,并且也会执行npm publish
发布组件到npm
上
发布成功的话,我们就可以在npm
官网搜到我们的组件库了。
在项目中使用
安装组件库
npm i vfox-ui -S
复制代码
使用组件库:
<template>
<div class="home">
<vf-button>11111111</vf-button>
</div>
</template>
<script>
import { VfButton } from 'vfox-ui'
export default {
name: 'Home',
components: {
VfButton,
},
}
</script>
复制代码
组件库文档
到目前为止,我们已经完整走完一套组件开发流程了,现在要做的是完善组件库文档。选择的工具是 vuepress,因为搭建比较简单,可以自动把.md
文件转化成.html
文件,我们可以更加专注于文档内容的编写。
vuepress官网的教程比较详细,在下面步骤中如果遇到不太理解的地方,可以去官网看教程文档。
安装相关依赖
npm i vuepress -D
npm i markdown-it markdown-it-container -D
复制代码
在package.json
里新添加两行:
"scripts": {
...
"doc": "vuepress dev docs",
"doc:build": "node config/common/buildDoc.js",
}
复制代码
buildDoc.js中做了两件事:
- 打包组件库文档
- 把
unit-test-coverage
文件的内容拷贝进了组件打包后的目录.vuepress/dist
,这样我们在组件库文档页也能显示单测报告。
配置页面内容
在/docs
目录下新建.vuepress
和page
s文件夹,配置首页内容:
/docs/README.md
home: true
actionText: 组件库文档 →
actionLink: /pages/guide/
features:
- title: antdv
details: 基于Ant Design of Vue。
- title: 定制化
details: 为企业定制化。
- title: 专业化
details: 组件库专业化。
复制代码
配置导航和侧边导航等:
// /docs/.vuepress/config.js
const themeSetting = require("../../config/antdv/theme");
module.exports = (options, context) => ({
title: "vfox-ui组件库",
description: "基于antdv封装的UI组件库",
base: '/vfox-ui/',
themeConfig: {
nav: [
{ text: "首页", link: "/" },
{
text: "单测覆盖率",
link: "https://iffyyy.github.io/vfox-ui/unit-test-coverage/lcov-report/index.html",
},
],
sidebar: [
{
title: "组件库说明",
path: "/pages/guide/",
},
{
title: "组件",
collapsable: false,
children: [
{
title: "Button 按钮",
path: "/pages/components/vf-button/vf-button",
},
],
},
],
sidebarDepth: 0,
lastUpdated: "Last Updated", // string | boolean
},
less: {
lessOptions: {
modifyVars: themeSetting,
javascriptEnabled: true,
math: "always", // 此处指定为兼容 less-loader 3.x 的默认选项
},
},
chainWebpack(config) {
config.resolve.alias.set("vfox-ui", process.cwd() + "/packages");
config.resolve.alias.set("core-js/library/fn", "core-js/features");
config.module
.rule("md")
.test(/\.md$/)
.use("vue-loader")
.loader(require.resolve("./loader/replaceFile"))
.options({
replaceFiles: true, // 默认true, 是否将文件填充进md
wrapper: false, // 默认true,默认输出Vue Component ,false 时输出html片段
})
.end();
},
});
复制代码
然后启动:npm run doc
,就能看到组件文档首页的样子了
搭建组件展示页面架构
为了方便,我们采用.md
文件来编写组件文档,vuepress
允许我们在md
文件里面编写vue
代码。
首先我们抽离三个vue组件:
- demo-title.vue
- demo-code.vue
- demo-block.vue
然后配置config.js:
// .vuepress/config.js
chainWebpack(config) {
config.module
.rule("md")
.test(/\.md$/)
.use("vue-loader")
.loader(require.resolve("./loader/replaceFile"))
.options({
replaceFiles: true, // 默认true, 是否将文件填充进md
wrapper: false, // 默认true,默认输出Vue Component ,false 时输出html片段
})
.end();
},
复制代码
其中replaceFile.js的作用是整合组件下的vue
文件内容,并填充进md
文件中。
编写vf-button组件文档
具体代码看github源码吧,这里就不贴了
部署到github pages
- 安装
gh-pages
npm install gh-pages -D
复制代码
- 在
package.json
文件上添加脚本命令
"scripts": {
"deploy": "gh-pages -d docs/.vuepress/dist",
"deploy:build": "npm run doc:build && gh-pages -d docs/.vuepress/dist"
}
复制代码
-
- 打包并推送到
gh-pages
分支
- 打包并推送到
npm run deploy:build
复制代码
最后我们的组件库文档地址是 https://<yourname>.github.io/<repo>
线上效果:iffyyy.github.io/vfox-ui/
结语
本文只作为一个基础组件库入门文章,但基本上囊括了搭建一个组件库的所有过程。实际上组件库开发还有很多工程化思想,有很多值得深入探讨的地方。