从0开发一个Vue组件库(附单元测试和文档)

前言

本文目的是带领大家从0开始开发一个组件库,看了网上开发组件库的文章,要么组件库是基于React的,对于Vue党十分不友好,要不就是文章写的不具体,看完还是不知从何开始。基于我司组件库开发经验,决定自己总结一篇通俗易上手的文章。

我们即将要实现的组件库vfox-ui是基于 Ant Design Vue组件库的一个二次封装,只写了一个vf-button组件的示例,并没有全部组件的代码。

相关工具

构建一个组件库,无疑工作量很大,一般公司很少会从0开始,都是基于现有的ui框架,进行二次封装。但无论怎样,一个成熟的组件库应该满足以下要求:

  • 开发调试,开发组件的时候在本地调试代码
  • 支持按需引入组件,组件库必备条件
  • 单元测试,保证代码的可信度
  • 组件库文档,说明组件的用法和组件示例
  • 自动化的文档部署

环境搭建

创建项目

我们使用 vue create vfox-ui 来创建项目,vfox-ui是我们的项目名。

注意:

  • 本项目仍然选择的 vue2.x 版本
  • 因为要进行单测,安装的时候单测工具请选择 jest

目录结构修改

  • docs:存放组件库文档
  • packages:存放组件代码,一个组件单独一个文件夹

image.png

实现一个button组件

为什么使用 JSX

JSXJavaScript的语法扩展,允许我们在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",
  }
复制代码

再次运行,可以看到测试报告

image.png

同时,我们也可以看到在vfox-ui\docs\unit-test-coverage路径下生成了报告结果,打开\unit-test-coverage\lcov-report\index.html文件可以看到以下页面:

image.png

发布到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目录下新建.vuepresspages文件夹,配置首页内容:

/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,就能看到组件文档首页的样子了

image.png

搭建组件展示页面架构

为了方便,我们采用.md文件来编写组件文档,vuepress允许我们在md文件里面编写vue代码。

首先我们抽离三个vue组件:

  • demo-title.vue
  • demo-code.vue
  • demo-block.vue

image.png

然后配置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源码吧,这里就不贴了

image.png

部署到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"
}
复制代码
    1. 打包并推送到 gh-pages 分支
npm run deploy:build
复制代码

最后我们的组件库文档地址是 https://<yourname>.github.io/<repo>

线上效果:iffyyy.github.io/vfox-ui/

结语

本文只作为一个基础组件库入门文章,但基本上囊括了搭建一个组件库的所有过程。实际上组件库开发还有很多工程化思想,有很多值得深入探讨的地方。

猜你喜欢

转载自juejin.im/post/7042568558596849701