如何从头到尾做一个UI组件库

首先我们我们的这个ui组件库是开发的vue版本,如果需要变通其他版本的话,就把vue相关的编译器移除,其他相关的编译器加上就可以了(例如react),打包构建方式是通用的。

组件库的组织发版和项目是不太一样的,这里提下思路。
首先我们要确定我们做的库要满足的需求:

  1. 支持全部加载
  2. 支持按需加载
  3. ts的补充类型支持
  4. 同时支持cjs和esm版本

知道了我们要支持的需求之后,要确定一下我们最后包的目录结构是什么样的,如下:
如何从头到尾做一个UI组件库_第1张图片
这简单描述下为何是这样的结构,首先index.esm是 我们整全量的包,里面包含了所有的ui组件,还有一个index.cjs版本,在打包工具不支持esm时会使用cjs版本,两个版本可以更好的支持不同的打包工具。

lib下放的是我们单个组件,用来结合 babel-plugin-import 来做按需加载。
这里先简单做一个概括,后续实现的时候会做详细的解释。

好了,了解了 我们最后的项目结构,就要开始ui库的搭建了,后续所有的操作配置,都是为了在保证程序健壮性通用性的基础上来打出来我们最后要发布的这个包的结构。


设计及流程

代码的组织方式Monorepo

Monorepo 是管理项目代码的一个方式,指在一个项目仓库(repo) 中管理多个模块/包(package),不同于常见的每个模块建一个repo。
例如:

├── packages
|   ├── pkg1
|   |   ├── package.json
|   ├── pkg2
|   |   ├── package.json
├── package.json

这样的结构,可以看到这些项目的第一级目录的内容以脚手架为主,主要内容都在 packages 目录中、分多个package进行管理。

一些知名的库例如vue3.0和react都是采用这种方式来管理项目的。
如何从头到尾做一个UI组件库_第2张图片
如何从头到尾做一个UI组件库_第3张图片
后续我们会根据这些packages里的小包,来生成按需加载的文件。

包的管理工具采用yarn,因为我们要用到它的workspaces依赖管理

如果不用workspaces时,因为各个package理论上都是独立的,所以每个package都维护着自己的dependencies,而很大的可能性,package之间有不少相同的依赖,而这就可能使install时出现重复安装,使本来就很大的 node_modules 继续膨胀(这就是「依赖爆炸」...)。

为了解决这个问题在这里我们要使用yarn的workspaces特性,这也就是依赖管理我们为什么使用yarn的原因。
而使用yarn作为包管理器的同学,可以在 package.json 中以 workspaces 字段声明packages,yarn就会以monorepo的方式管理packages。
使用方式详情可以查看它的官方文档
文档

我们在package.json开启了yarn 的workspaces工作区之后,当前这个目录被称为了工作区根目录,工作区并不是要发布的,然后这会我们在下载依赖的时候,不同组件包里的相同版本的依赖会下载到工作区的node_modules里,如果当前包依赖的版本和其他不一样就会下载到当前包的node_modules里。

yarn的话突出的是对依赖的管理,包括packages 的相互依赖、packages 对第三方的依赖,yarn 会以semver 约定来分析dependencies 的版本,安装依赖时更快、占用体积更小。

lerna

这里简单提一下lerna,因为目前主流的monorepo解决方案是Lerna 和 yarn 的 workspaces 特性,它主要用来管理工作流,但是它个人感觉如果你需要一次性发布packages里的所有包时,用它会比较方便,我们这里没有过多的用到它。

Storybook开发阶段的调试

组件效果的调试和使用介绍我们通过Storybook来进行管理,这是一个可视化的组件展示平台,它可以让我们在隔离的开发环境 交互地开发和测试组件,最后也可以生成使用说明的静态界面,它支持很多框架例如:vue.react,ng,React Native等。

jest单元测试

单元测试的话我们使用Facebook的jest

plop创建相同模版

我们包的结构是这样的,例如avatar:

├── packages
|   ├── avatar
|   |   ├── __test__  //单元测试文件
|   |   ├── src //组件文件
|   |   ├── stories //storyBook 开发阶段预览的展示,扫描文件
|   |   ├── index.ts //包入口
|   |   ├── LICENSE
|   |   ├── README.MD
|   |   ├── package.json
├── package.json

每个UI组件的结构基本都是一样的,所以在这里我们选用plop来统一生成模版,plop主要用于创建项目中特定文件类型的小工具,类似于Yeoman中的sub generator,一般不会独立使用。一般会把Plop集成到项目中,用来自动化的创建同类型的项目文件。

Rollup进行打包

最后是构建操作,这里我们打包不使用webpack,而是用Rollup,。
webpack的话更适合项目工程使用,因为项目里很多静态资源需要处理,再或者构建的项目需要引入很多CommonJS模块的依赖,这样虽然它也有摇树的功能tree-shaking(额外配置),但是因为要处理转换其他文件所以它打出来的包还是会有一些冗余代码。
而rollup 也是支持tree-shaking的,而且它主要是针对js打包使用,它打包结果比webpack更小,开发类库用它会更合适。

下面讲下构建过程:

首先我贴一个我最后完整版本的依赖,如下:

{
  "name": "c-dhn-act",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "gitlabGroup": "component",
  "devDependencies": {
    "@babel/cli": "^7.13.16",
    "@babel/core": "^7.11.4",
    "@babel/plugin-transform-runtime": "^7.13.15",
    "@babel/preset-env": "^7.11.5",
    "@babel/preset-typescript": "^7.13.0",
    "@rollup/plugin-json": "^4.1.0",
    "@rollup/plugin-node-resolve": "^8.4.0",
    "@storybook/addon-actions": "6.2.9",
    "@storybook/addon-essentials": "6.2.9",
    "@storybook/addon-links": "6.2.9",
    "@storybook/vue3": "6.2.9",
    "@types/jest": "^26.0.22",
    "@types/lodash": "^4.14.168",
    "@vue/compiler-sfc": "^3.1.4",
    "@vue/component-compiler-utils": "^3.2.0",
    "@vue/shared": "^3.1.4",
    "@vue/test-utils": "^2.0.0-rc.6",
    "babel-core": "^7.0.0-bridge.0",
    "babel-jest": "^26.6.3",
    "babel-loader": "^8.2.2",
    "babel-plugin-lodash": "^3.3.4",
    "cp-cli": "^2.0.0",
    "cross-env": "^7.0.3",
    "css-loader": "^5.2.6",
    "http-server": "^0.12.3",
    "inquirer": "^8.0.0",
    "jest": "^26.6.3",
    "jest-css-modules": "^2.1.0",
    "json-format": "^1.0.1",
    "lerna": "^4.0.0",
    "plop": "^2.7.4",
    "rimraf": "^3.0.2",
    "rollup": "^2.45.2",
    "rollup-plugin-alias": "^2.2.0",
    "rollup-plugin-terser": "^7.0.2",
    "rollup-plugin-typescript2": "^0.30.0",
    "rollup-plugin-vue": "^6.0.0",
    "sass": "^1.35.1",
    "sass-loader": "10.1.1",
    "storybook-readme": "^5.0.9",
    "style-loader": "^2.0.0",
    "typescript": "^4.2.4",
    "vue": "3.1.4",
    "vue-jest": "5.0.0-alpha.5",
    "vue-loader": "^16.2.0"
  },
  "peerDependencies": {
    "vue": "^3.1.x"
  },
  "scripts": {
    "test": "jest --passWithNoTests",
    "storybookPre": "http-server build",
    "storybook": "start-storybook -p 6006",
    "build-storybook": "build-storybook --quiet --docs -o ui",
    "lerna": "lerna publish",
    "buildTiny:prod": "cross-env NODE_ENV=production rollup -c buildProject/rollup.tiny.js",
    "buildTiny:dev": "cross-env NODE_ENV=development rollup -c buildProject/rollup.tiny.js",
    "clean": "lerna clean",
    "plop": "plop",
    "clean:lib": "rimraf dist/lib",
    "build:theme": "rimraf packages/theme-chalk/lib && gulp build --gulpfile packages/theme-chalk/gulpfile.js && cp-cli packages/theme-chalk/lib dist/lib/theme-chalk && rimraf packages/theme-chalk/lib",
    "build:utils": "cross-env BABEL_ENV=utils babel packages/utils --extensions .ts --out-dir dist/lib/utils",
    "buildAll:prod": "cross-env NODE_ENV=production rollup -c buildProject/rollup.all.js",
    "buildAll:dev": "cross-env NODE_ENV=development rollup -c buildProject/rollup.all.js",
    "build:type": "node buildProject/gen-type.js",
    "build:v": "node buildProject/gen-v.js",
    "build:dev": "yarn build:v && yarn clean:lib  && yarn buildTiny:dev && yarn buildAll:dev && yarn build:utils && yarn build:type && yarn build:theme",
    "build:prod": "yarn build:v && yarn clean:lib  && yarn buildTiny:prod && yarn buildAll:prod && yarn build:utils  && yarn build:type && yarn build:theme"
  },
  "dependencies": {
    "comutils": "1.1.9",
    "dhn-swiper": "^1.0.0",
    "lodash": "^4.17.21",
    "vue-luck-draw": "^3.4.7"
  },
  "private": true,
  "workspaces": [
    "./packages/*"
  ]
}

可以看到我这里storyBook 用的是6.2.9版本的,这里不用最新版是因为无法最后开启文档模式,不知道现在问题解决了没有。

项目的初始化可以采用storyBook的脚手架,后续我们再往里面添东西。
初始化我们用的是vue3.0版本,这里大家可以按手册去初始化
storybook 官网vue初始化手册

官网也提供了,其他框架项目的初始化。
初始化完成后,我们找到.storyBook文件夹,我们需要修改他下面的内容:
main.js改成这样,如下:

const path = require('path');
module.exports = {
  "stories": [
    "../stories/**/*.stories.mdx",
    "../packages/**/*.stories.mdx",
    "../packages/**/*.stories.@(js|jsx|ts|tsx)",
  ],
  "addons": [
    "@storybook/addon-links",
    "@storybook/addon-essentials"
  ],
  webpackFinal: async (config, { configType }) => {
    // `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION'
    // You can change the configuration based on that.
    // 'PRODUCTION' is used when building the static version of storybook.

    // Make whatever fine-grained changes you need
    config.module.rules.push({
      test: /\.scss$/,
      use: ['style-loader', 'css-loader', {
        loader:'sass-loader',  //这种是指定dart-sass  替代node-sass  不然一些数学函数 用不了  math函数只有dart-sass可以用  
        options:{
          implementation:require("sass")
        }
      }],
      include: path.resolve(__dirname, '../'),
    });

    // Return the altered config
    return config;
  },
}

这里stories 配置项,配置路径里放的是界面要呈现的说明和组件,匹配到的mdx里放的是它的使用指引,mdx是markdowm和jsx的结合。

addons里放的是它的一些插件,addon-essentials是插件集合(集合),包含了一系列的插件 可以保证我们开箱即用,addon-links 用来设置链接的插件。

webpackFinal是针对webpack 的一些扩展,我们这里用dart-sass替代了node-sass,不然一些数学函数 用不了,例如 math函数只有dart-sass可以用。

那我们packages/avatar/stories/avatar.stories.mdx 下语法,你可以参考官网mdx语法

workspaces和private已经在packsge.json里配置了,

  "private": true,
  "workspaces": [
    "./packages/*"
  ]

如果有不了解workspaces的作用的,可以百度下它的作用。

然后就是ts和jest的集成

首先我们先来集成ts,首先下载依赖:
主要的包有俩:

yarn add typescript rollup-plugin-typescript2 -D -W

然后修改tsconfig.json

{
    "compilerOptions": {
      "module": "ESNext",//指定使用的模块标准
      "declaration": true,// 生成声明文件,开启后会自动生成声明文件
      "noImplicitAny": false,// 不允许隐式的any类型
      "strict":true,// 开启所有严格的类型检查
      "removeComments": true,// 删除注释 
      "moduleResolution": "node", //模块解析规则 classic和node的区别   https://segmentfault.com/a/1190000021421461
      //node模式下,非相对路径模块 直接去node_modelus下查找类型定义.ts 和补充声明.d.ts
      //node模式下相对路径查找 逐级向上查找 当在node_modules中没有找到,就会去tsconfig.json同级目录下的typings目录下查找.ts或 .d.ts补充类型声明
      //例如我们这里的.vue模块的  类型补充(.ts 文件不认识.vue模块, 需要我们来定义.vue模块的类型)
      "esModuleInterop": true,//实现CommonJS和ES模块之间的互操作性。抹平两种规范的差异
      "jsx": "preserve",//如果写jsx了,保持jsx 的输出,方便后续babel或者rollup做二次处理
      "noLib": false,
      "target": "es6", //编译之后版本
      "sourceMap": true, //生成
      "lib": [ //包含在编译中的库
        "ESNext", "DOM"
      ],
      "allowSyntheticDefaultImports": true, //用来指定允许从没有默认导出的模块中默认导入
    },
    "exclude": [ //排除
      "node_modules"
    ],

}
   

然后集成一下jest

yarn add @types/jest babel-jest jest jest-css-modules vue-jest @vue/test-utils -D -W

建议下载的依赖包版本,以我的项目的lock为准,因为这个是我校验过得稳定版本,升级新版本可能会导致不兼容。

这里-D -W是安装到工作区根目录并且是开发依赖的意思,这里jest是Facebook 给提供的单元测试库官方推荐的,@vue/test-utils 它是Vue.js的官方测试实用程序库,结合jest一起使用 配置最少,处理单文件组件vue-jest,babel-jest 对测试代码做降级处理,jest-css-modules 用来忽略测试的css文件。
然后我们在根目录新建jest.config.js 单元测试的配置文件:

module.exports = {
  "testMatch": ["**/__tests__/**/*.test.[jt]s?(x)"],  //从哪里找测试文件   tests下的
  "moduleFileExtensions": [ //测试模块倒入的后缀
    "js",
    "json",
    // 告诉 Jest 处理 `*.vue` 文件
    "vue",
    "ts"
  ],
  "transform": {
    // 用 `vue-jest` 处理 `*.vue` 文件
    ".*\\.(vue)$": "vue-jest",
    // 用 `babel-jest` 处理 js 降
    ".*\\.(js|ts)$": "babel-jest" 
  },
  "moduleNameMapper" : {
    "\\.(css|less|scss|sss|styl)$" : "/node_modules/jest-css-modules"
  }
}

然后再配置一下babel.config.js 我们测试用到了降级处理 ,后续打生产包时我们会通过babel环境变量utils,来使用对应配置转换packages/utils里一些工具函数。
babel.config.js:

module.exports = {
  // ATTENTION!!
  // Preset ordering is reversed, so `@babel/typescript` will called first
  // Do not put `@babel/typescript` before `@babel/env`, otherwise will cause a compile error
  // See https://github.com/babel/babel/issues/12066
  presets: [
    [
      '@babel/env', //babel转换es6 语法插件集合
    ],
    '@babel/typescript',  //ts
  ],
  plugins: [
    '@babel/transform-runtime', //垫片按需支持Promise,Set,Symbol等
    'lodash', 
    //一个简单的转换为精挑细选的Lodash模块,因此您不必这样做。
  //与结合使用,可以生成更小的樱桃精选版本! https://download.csdn.net/download/weixin_42129005/14985899
  //一般配合lodash-webpack-plugin做lodash按需加载
  ],
  env: {
    utils: { //这个babel环境变量是utils 覆盖上述 的配置 这里暂时不会用 先注释掉
      presets: [
        [
          '@babel/env',
          {
            loose: true,//更快的速度转换
            modules: false,//不转换esm到cjs,支持摇树  这个上面不配置 不然esm规范会导致jest 测试编译不过
          },
        ],
      ],
      // plugins: [
      //   [
      //     'babel-plugin-module-resolver',
      //     {
      //       root: [''],
      //       alias: {
           
      //       },
      //     },
      //   ],
      // ],
    },
  },
}

然后我们在package.json中修改script命令 "test": "jest",
Jest 单元测试具体怎么写 可以根据自己的 需求去查看官方文档。

plop生成组件模板

我们开头的时候说过我们每个包的结构,长得都是一样的,然后每生成一个组件包的话都要手动创建结构的话太麻烦了。

├── packages
|   ├── avatar
|   |   ├── _test_  //单元测试文件夹
|   |   ├─────  xxx.test.ts //测试文件
|   |   ├── src //组件文件文件夹
|   |   ├───── xxx.vue //组件文件
|   |   ├── stories // 故事书调试的js
|   |   ├───── xxx.stories.ts //组件文件
|   |   ├── index.js //包入口
|   |   ├── LICENSE
|   |   ├── README.MD
|   |   ├── package.json
├── package.json

我们的基本结构是这样,然后我们选择plop生成模板,我们前面提到了plop主要用于创建项目中特定文件类型的小工具。 我们把它安装到项目中 yarn add plop -D -W

然后创建它的配置文件plopfile.js

module.exports = plop => {
    plop.setGenerator('组件', {
      description: '自定义组件',
      prompts: [
        {
          type: 'input',
          name: 'name',
          message: '组件名称',
          default: 'MyComponent'
        },
        {
          type: "confirm",
          message: "是否是组合组件",
          name: "combinationComponent",
          default:false
        }
      ],
      actions: [
        {
          type: 'add',
          path: 'packages/{{name}}/src/{{name}}.vue',
          templateFile: 'plop-template/component/src/component.hbs'
        },
        {
          type: 'add',
          path: 'packages/{{name}}/__tests__/{{name}}.test.ts',
          templateFile: 'plop-template/component/__tests__/component.test.hbs'
        },
        {
          type: 'add',
          path: 'packages/{{name}}/stories/{{name}}.stories.ts',
          templateFile: 'plop-template/component/stories/component.stories.hbs'
        },
        {
          type: 'add',
          path: 'packages/{{name}}/index.ts',
          templateFile: 'plop-template/component/index.hbs'
        },
        {
          type: 'add',
          path: 'packages/{{name}}/LICENSE',
          templateFile: 'plop-template/component/LICENSE'
        },
        {
          type: 'add',
          path: 'packages/{{name}}/package.json',
          templateFile: 'plop-template/component/package.hbs'
        },
        {
          type: 'add',
          path: 'packages/{{name}}/README.md',
          templateFile: 'plop-template/component/README.hbs'
        },
        {
          type: 'add',
          path: 'packages/theme-chalk/src/{{name}}.scss',
          templateFile: 'plop-template/component/template.hbs'
        }
      ]
    })
  }

这里通过命令行询问交互 来生成 组件,然后我们来根据我们的配置文件来新建 文件夹和模板。
如何从头到尾做一个UI组件库_第4张图片
模板的结构是这样。
然后 我们来看下对应的模板 长什么样子,如下:
component.test.hbs

import { mount } from '@vue/test-utils'
import Element from '../src/{{name}}.vue'

describe('c-dhn-{{name}}', () => {
    test('{{name}}-text',() => {
        const wrapper = mount(Element)
        expect(wrapper.html()).toContain('div')
    })
})

component.hbs






component.stories.hbs

import CDhn{{properCase name}} from '../'

export default {
  title: 'DHNUI/{{properCase name}}',
  component: CDhn{{properCase name}}
}


export const Index = () => ({
  setup() {
    return {  };
  },
  components: { CDhn{{properCase name}} },
  template: `
    
`, });

index.hbs

import CDhn{{properCase name}} from './src/{{name}}.vue'
import { App } from 'vue'
import type { SFCWithInstall } from '../utils/types'

CDhn{{properCase name}}.install = (app: App): void => {
  app.component(CDhn{{properCase name}}.name, CDhn{{properCase name}})
}

const _CDhn{{properCase name}}: SFCWithInstall = CDhn{{properCase name}}

export default _CDhn{{properCase name}}

然后我们在package.json 中添加一个script命令 "plop": "plop"

执行之后就可以生产对应的文件了,详细的可以吧项目下载下载看一下。

到这里我们测试,开发环境的storyBook和生产文件的plop,已经完事了。
下面就该看如何打出生产环境的包了。

rollup构建打包

首先新建buildProject文件夹,我们的一些命令脚本都会放在这里。
这里打包分为两种,按需加载和全量包,这两种方式有一些配置是一样的,我们这里写一个公共的配置文件rollup.comon.js


import json from '@rollup/plugin-json'
import vue from 'rollup-plugin-vue' //vue相关配置, css抽取到style标签中  编译模版
// import postcss from 'rollup-plugin-postcss'
import { terser } from 'rollup-plugin-terser'  //代码压缩
import { nodeResolve } from '@rollup/plugin-node-resolve'
import alias from 'rollup-plugin-alias';
const { noElPrefixFile } = require('./common')
const pkg = require('../package.json')

const isDev = process.env.NODE_ENV !== 'production'
const deps = Object.keys(pkg.dependencies)
// 公共插件配置
const plugins = [
    vue({
      // Dynamically inject css as a