React 编写私有 UI 组件库(上)之组件库发布完整流程

7,427 阅读12分钟

前言

只要在写前端代码就避免不了使用流行的 UI 组件库,例如 ElementUI 、 Ant Design  。但是我们只停留在使用层面上的话,未免显得肤浅。因此作者一直都想编写一个属于自己的 UI 组件库。

其实搭建一个属于自己的组件库涉及的知识面还是非常广泛的:

  • 语言层面: TypeScipt 、 React 、 Sass ;
  • 测试层面: Jest 、React Testing Library  ;
  • 前端工程:组件库基础结构、打包组件库、发布组件库、发布组件库文档。

语言层面和测试层面需要具备的基础知识由于篇幅限制在本文并不会讲解,希望你已经具备了一定的基础。

搭建一个组件库的难点除了编写通用组件外,那就是工程方面。因此本文会按照以下顺序,循序渐进的讲解如何搭建并发布一个企业级组件库。

  1. 组件库基础框架搭建;
  2. 编写一个 Button 组件;
  3. 组件库文档与调试;
  4. 打包发布组件库;
  5. 线上文档自动发布。

组件库基础框架

目录结构

├──README.md // 文档说明
├──node_modules
├──package.json
├──tsconfig.json // ts 配置文件
├──.gitignore
└──src
    ├──components // 组件库
    ├──styles // 公用样式库
    └──index.js // 组件库入口文件

通过组件库目录结构你会发现,以往熟悉的 App.tsx 不见了,的确我们这是一个组件库,并不是一个应用,因此不需要它。

查看本小结完整代码

样式解决方案

采用 css 预处理语言 sass ,关于 sass 的使用,可以查阅它的官方文档,这里不做过多解释。

主要目录结构及作用解析:

└──styles
	├──_variables.scss // 各种变量以及可配置设置
	├──_mixins.scss // 全局 mixins
	├──_reboot.scss // 重置样式
		├──_functions.scss // 全局 functions
		└──index.scss // import styles 中所有样式
└──components
	└──Button
		└──style.scss // 组件独立样式

组件库样式变量分类

一个样式库的变量大致分为以下几类:

  • 基础色彩系统
  • 字体系统
  • 表单
  • 按钮
  • 边框和阴影
  • ...

定义变量文件: _variables.scss  定义变量以及常量的好处是,全组件库统一,初写时会觉得麻烦,一旦当项目到达一定体量时,就可以感知到它的好处了。

# 基础色彩系统
$blue:    #0d6efd !default;
$indigo:  #6610f2 !default;
...

# 字体系统
$font-family-sans-serif:      -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" !default;
// 等宽字体
$font-family-monospace:       SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !default;
// 主要字体
$font-family-base:            $font-family-sans-serif !default;

// 字体大小
$font-size-base:              1rem !default; // Assumes the browser default, typically `16px`
$font-size-lg:                $font-size-base * 1.25 !default;
$font-size-sm:                $font-size-base * .875 !default;
$font-size-root:              null !default;

// 字重
$font-weight-lighter:         lighter !default;
$font-weight-light:           300 !default;
...

// 行高
$line-height-base:            1.5 !default;
...

// 标题大小
$h1-font-size:                $font-size-base * 2.5 !default;
...

!default 是什么?

Sass 提供了 !default 标志。 仅在未定义变量或其值为空时,才为变量分配值。 否则将使用现有值。

样式重置

使用 normalize.css 解决方案,它的 github 地址是 github.com/necolas/nor…

它有什么作用?

  • 与许多 CSS 重置不同,保留有用的默认值;
  • 标准化各种元素的样式;
  • 更正错误和常见的浏览器不一致问题;
  • 通过细微的修改来提高可用性;
  • 使用详细注释说明代码的作用。

样式重置: _reboot.scss 

body {
  margin: 0; // 1
  font-family: $font-family-base;
  font-size: $font-size-base;
  font-weight: $font-weight-base;
  line-height: $line-height-base;
  color: $body-color;
  text-align: $body-text-align;
  background-color: $body-bg; // 2
  -webkit-text-size-adjust: 100%; // 3
  -webkit-tap-highlight-color: rgba($black, 0); // 4
}
...省略

已经使用 _variables 文件定义好的基础变量进行替换。

导入样式

index.scss 

// config
@import "variables";

//layout
@import "reboot";

之前定义的是 _variables  与 _reboot ,为什么导入的时候下划线没有了?

如果你有一个 Scss 或 Sass 文件需要引入, 但是你又不希望它被编译为一个 CSS 文件, 这时,你就可以在文件名前面加一个下划线,就能避免被编译。 这将告诉 Sass 不要把它编译成 CSS 文件。 然后,你就可以像往常一样引入这个文件了,而且还可以省略掉文件名前面的下划线。

src/index.tsx  中引入样式文件

...
import './styles/index.scss';
import Button from "./components/Button";

ReactDOM.render(
  <React.StrictMode>
    <Button />
  </React.StrictMode>,
  document.getElementById('root')
);

查看本小结完整代码

Button 组件

组件需求分析

我们先看看 Ant Design  中 Button 是什么样子的? image.png image.png

Ant Design 中的使用

<Button type="primary">Primary Button</Button>
<Button type="primary" disabled>Primary(disabled)</Button>
...

我们简单总结下:

  1. type 划分: Primary 、 Default 、 Danger 、 Link ;
  2. size 划分: Normal 、 Small 、 Large ;
  3. disabled 状态:禁用、正常。

需求就简单分析到这里,下面我们开始编码了。

组件代码编写

在编写 Button 之前先介绍一个有用的工具 classnames ,它是一个简单易用的 JavaScript 库,通过条件判断将 className 拼接在一起。

安装 classnames 

yarn add classnames @types/classnames

使用 classnames 

classNames('foo', 'bar'); // => 'foo bar'
classNames('foo', { bar: true }); // => 'foo bar'
classNames({ 'foo-bar': true }); // => 'foo-bar'
classNames({ 'foo-bar': false }); // => ''
classNames({ foo: true }, { bar: true }); // => 'foo bar'
classNames({ foo: true, bar: true }); // => 'foo bar'
 
// lots of arguments of various types
classNames('foo', { bar: true, duck: false }, 'baz', { quux: true }); // => 'foo bar baz quux'
 
// other falsy values are just ignored
classNames(null, false, 'bar', undefined, 0, 1, { baz: null }, ''); // => 'bar 1'

工具介绍完我们就正式开始了,由于技术选型使用的是 TypeScript ,因此我们可以根据上面的需求分析,先定义好相关类型:

// 定义按钮大小类型
export type ButtonSize = 'lg' | 'sm';

// 定义按钮type种类
export type ButtonType = 'primary' | 'default' | 'danger' | 'link'

// 定义Button组件基础入参属性
interface BaseButtonProps {
  className: string;
  disabled: boolean;
  size: ButtonSize;
  btnType: ButtonType;
  children: React.ReactNode;
  href: string;
}

// 定义按钮的基础类型与原生按钮的联合类型
type NativeButtonProps = BaseButtonProps & React.ButtonHTMLAttributes<HTMLElement>;

// 定义按钮的基础类型与原生A标签的联合类型
type AnchorButtonProps = BaseButtonProps & React.AnchorHTMLAttributes<HTMLElement>;

// 执行 Partial,相当于所有属性都变为可选,如 {disabled?:boolean,...}.
export type ButtonProps = Partial<NativeButtonProps & AnchorButtonProps>;

类型定义好了,接下来实现 Button 组件的核心功能:

src/components/Button/Button.tsx 

const Button: React.FC<ButtonProps> = (props)=>{
  
  // 通过 ES6 对象的解构赋值取出所有属性,其中restProps就是除显示定义的剩下所有的属性。
  const {
    btnType,
    disabled,
    size,
    children,
    className,
    href,
    ...restProps //ES6 rest 语法
  } = props;
  
	// 利用 classNames 判断按钮的相应 class 值。
  const classes = classNames('btn', className, {
    [`btn-${btnType}`]: btnType, // btnType 参数存在时则动态添加 `btn-${btnType}` 类
    [`btn-${size}`]: size, // size 参数存在时动态添加 `btn-${size}` 类
    'disabled': (btnType === 'link') && disabled // 由于 a 链接原生不带有 disabled 属性,因此需要手动给它添加一个 disabled 类。通过编写类的样式实现disabled效果
  })
  
	// 判断如果是 link 类型,则输出 a 链接,否则输出 button。
  if(btnType === 'link' && href){
    return (
      <a
        {...restProps}
        href={href}
        className={classes}
      >
        {children}
      </a>
    )
  } else {
    return (
      <button
        {...restProps}
        className={classes}
        disabled={disabled}
      >
        {children}
      </button>
    )
  }
}
// 属性默认值
Button.defaultProps = {
  disabled: false,
  btnType: 'default'
}

Button/_styles.scss 

.btn {
  position: relative;
  display: inline-block;
  font-weight: $btn-font-weight;
  line-height: $btn-line-height;
  color: $body-color;
  white-space: nowrap;
  text-align: center;
  vertical-align: middle;
  background-image: none;
  border: $btn-border-width solid transparent;
  @include button-size( $btn-padding-y,  $btn-padding-x,  $btn-font-size,  $border-radius);
  box-shadow: $btn-box-shadow;
  cursor: pointer;
  transition: $btn-transition;
  &.disabled,
  &[disabled] {
    cursor: not-allowed;
    opacity: $btn-disabled-opacity;
    box-shadow: none;
    > * {
      pointer-events: none; // 清除鼠标事件
    }
  }
}

.btn-lg {
  @include button-size($btn-padding-y-lg, $btn-padding-x-lg, $btn-font-size-lg, $btn-border-radius-lg);
}

... 省略

样式中除使用了变量、 mixin 等 sass 高阶语法外其它的与普通 CSS 无异,主要是不能忘记在 index.scss 中引入:

// button
@import "../components/Button/styles";

关于 Button 组件的业务代码写到这里就差不多了,但是我们还有一个非常重要的事情要做,那就是单元测试,由于是通用组件,它非常适合通过单元测试的方式来测试其功能是否达到预期。

组件测试代码

目前比较流行的 React 单元测试解决方案是 JEST 与React Testing Library 。由于单元测试又是一个非常大的课题,本文也不打算详细讲解。

JEST

Jest 是一个 JavaScript 测试框架,旨在确保任意 JavaScript 代码的正确性。 它允许你用可访问的、熟悉的和功能丰富的 API 来写测试,让你快速获得结果。

特点:

  • 零配置: Jest 的目标是在大部分 JavaScript 项目上实现开箱即用,无需配置。
  • 快照:构建能够轻松追踪大 Object 的测试。快照可以独立于测试代码,也可以集成进代码行内。
  • 隔离的:测试程序在自己的进程并行运算以最大限度地提高性能。
  • 优秀的 api :从 it 到 expect , Jest 将整个工具包放在一个地方,好书写,好维护,非常方便。
  • 快速且安全:通过确保你的测试具有独一无二的全局状态, Jest 可以可靠地并行运行测试。 为了让加速测试进程, Jest 会先运行先前失败的测试,并根据测试文件需要多长时间重新组织测试。
  • 代码覆盖率:通过添加 --coverage 标志生成代码覆盖率报告,无需额外设置。 Jest 可以从整个项目收集代码覆盖面信息,包括未经测试的文件。

React Testing Library

非常轻巧的解决方案,不需要所有实现细节。它帮助我们快速找到应用程序中的节点。

Button 组件测试

import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import Button, { ButtonProps } from './Button'

const defaultProps = {
  onClick: jest.fn()
}

describe('test Button component', () => {
  it('should render the correct default button', () => {
    //  wrapper 获取到通过render方法解析的React组件实例信息
    const wrapper = render(<Button {...defaultProps}>Nice</Button>)
    // element 则是通过 text 值获取到的类似“DOM”
    const element = wrapper.getByText('Nice') as HTMLButtonElement
  	// 通过 JEST 框架可以做一系列断言
    expect(element).toBeInTheDocument()
    expect(element.tagName).toEqual('BUTTON')
    expect(element).toHaveClass('btn btn-default')
    expect(element.disabled).toBeFalsy()
  	// 触发元素click事件
    fireEvent.click(element)
  	// 断言模拟事件被触发
    expect(defaultProps.onClick).toHaveBeenCalled()
  })
})

从这个简单的例子可以看出,它其实就是在模拟用户的使用轨迹,“通过做了什么得到一定的结果”。

到此,一个及功能、样式、测试的 Button 组件就全部完成了。

查看本小结完整代码

组件库文档与调试

虽然我们只开发了一个 Button 组件,但是有一个问题困扰着我们,那就是调试困难以及组件写好了,并没有相应的文档输出,如果想要使用 Button 组件就必须得去看源码。显然这样是非常不合理的,我们需要借助工具来帮助我们实现文档功能。

Storybook

Storybook 是一个开源工具,用于为 React,Vue,Angular 等隔离开发 UI 组件。

特点:

  • 提供了一个沙箱,用于隔离地构建 UI 组件;
  • 提供了一些高级插件功能,可以更快地构建 UI ,记录组件库并简化工作流程;
  • Storybook 允许我们轻松地将技术文档纳入我们的设计系统,从而使开发组件更加简化。

安装:

npx -p @storybook/cli sb init
或者
yarn global @storybook/cli && sb init

启动:

npm run storybook

添加一个文件: Button/Button.stories.tsx 

import React from 'react';
import { Story, Meta } from '@storybook/react/types-6-0';
import Button , { ButtonProps } from './Button';
import "../../styles/index.scss";

export default {
  title: 'Button',
  component: Button,
} as Meta;

const Template: Story<ButtonProps> = (args) => <Button {...args} />;

export const Primary = Template.bind({});
Primary.args = {
  btnType: 'danger',
  children: "确定"
};

文档最终效果:

QQ20201210-183845.gif

这里只使用了 storybook 开箱即用的功能,它的功能非常强大,感兴趣的同学可以阅读 Storybook官方文档

查看本小结完整代码

组件库打包发布

打包

回忆下,普通 React App 打包过程,通过 index.tsx 文件为入口, App 为根组件进行打包,最终生成一段 JavaScript 脚本,动态向页面插入 DOM 以及样式,形成一个完整的页面。

组件库并非一个应用程序,显然它不能这样打包,我们先看看 ant design 中的组件是如何被应用程序使用的:

import {Button} from "antd";

这也就意味着我们的组件库的入口文件要作为所有组件的对外提供商。

改造下 index.tsx 

export { default as Button } from "./components/Button";

当然这里我们只写了一个 Button 组件,如果有多个组件就以相同的语法导出即可。

众所周知, webpack 是模块打包器,它会去分析模块中的依赖,打成一个大的 JavaScript 包,然而我们的组件库,能以 ESModule 模块的方式直接提供给使用者。因此我们只需要把 TypeScript 语法打包成普通的 ES5 语法即可。所以打包时借助 ts 编译打包即可。

新建一个 tsconfig.build.json 作为打包文件的配置:

{
  "compilerOptions": {
    "outDir": "dist", // 打包输出位置
    "module": "esnext", // 设置生成代码的模块标准,可以设置为 CommonJS、AMD 和 UMD 等等。
    "target": "es5", // 目标语言的版本
    "declaration": true, // 生成声明文件,记得 inde.d.ts
    "jsx": "react", // 等效 React.createElement调用
    "moduleResolution":"Node", // 模块解析策略,这里提供两种解析策略 node 和 classic,ts 默认使用 node 解析策略。
    "allowSyntheticDefaultImports": true, // 允许对不包含默认导出的模块使用默认导入。这个选项不会影响生成的代码,只会影响类型检查。
    "skipLibCheck": true // 跳过类库检查
  },
  "include": [
    "src" // 编译文件夹
  ],
  "exclude": [ // 排除编译文件
    "src/**/*.test.tsx",
    "src/**/*.stories.tsx",
    "src/setupTests.ts",
    "stories/**/*.svg",
    "stories/**/*.mdx"
  ]
}

package.json 增加 scripts 

// 清除文件命令,需要手动安装rimraf包
"clean": "rimraf ./dist",
// 通过 tsconfig.build.json 配置编译 ts 文件
"build-ts": "tsc -p tsconfig.build.json",
// 编译sass文件
"build-css": "node-sass ./src/styles/index.scss ./dist/index.css",
// 总的编译命令,继发执行  
"build": "npm run clean && npm run build-css && npm run build-ts"

执行 npm run build 命令,看下 dist 目录下生产的文件:

image.png

打包任务大功告成,查看本小结完整代码

发布

打包完成后,我们需要把包发布到线上 npm  包管理上。因此我们需要先登录注册 npm 。

# 查看是否登录
npm whoami

# 查看 npm 配置表
npm config ls

# 注册
npm adduser
Username:shiyou
Email:(xxx@xxx.com)

# 登录
npm login
然后填写注册信息即可

package.json 优化

{
  "name": "lion-design", // 包名,必须要是唯一的
  "version": "1.0.1", // 版本号
  "author": "Lion", // 作者
  "private": false, // 非私有
  "main": "dist/index.js", // 项目的入口文件
  "module": "dist/index.js", // 指向具有 ES2015 模块语法的模块,但仅指向目标环境支持的语法功能。
  "types": "dist/index.d.ts", // 只在 TypeScript 中生效的字段,指向声明文件
  "license": "MIT", // 使用许可证
  "homepage": "https://github.com/shiyou00/lion-design", // 是包的项目主页或者文档首页。
  "repository": { // 代码托管的位置
    "type": "git",
    "url": "https://github.com/shiyou00/lion-design"
  },
  "files": [ // 项目上传至npm服务器的文件,可以是单独的文件、整个文件夹,或者通配符匹配到的文件。
    "dist"
  ],
  "scripts": {
    "clean": "rimraf ./dist",
    "build-ts": "tsc -p tsconfig.build.json",
    "build-css": "node-sass ./src/styles/index.scss ./dist/index.css",
    "build": "npm run clean && npm run build-css && npm run build-ts",
    "prepublishOnly": "npm run build" // 执行 npm publish 之前会默认执行的命令
  },
  // 开发版和发布版需要的依赖
  "dependencies": {
    "@testing-library/jest-dom": "^5.11.4",
    "@testing-library/react": "^11.1.0",
    "@testing-library/user-event": "^12.1.10",
    "classnames": "^2.2.6",
  },
   // 开发环境依赖包
  "devDependencies": {
    "@storybook/addon-actions": "^6.1.10",
    "@storybook/addon-essentials": "^6.1.10",
    "@storybook/addon-links": "^6.1.10",
    "@storybook/node-logger": "^6.1.10",
    "@storybook/preset-create-react-app": "^3.1.5",
    "@storybook/react": "^6.1.10",
    "@types/classnames": "^2.2.11",
    "@types/jest": "^26.0.15",
    "@types/node": "^12.0.0",
    "@types/react": "^16.9.53",
    "@types/react-dom": "^16.9.8",
    "rimraf": "^3.0.2",
    "node-sass": "^4.13.0",
    "react-scripts": "4.0.1",
    "react": "^17.0.1",
    "react-dom": "^17.0.1",
	"typescript": "^4.0.3"
  },
  // 说明还需要依赖的版本,但是用户不进行强行安装,以免造成版本冲突
  "peerDependencies": {
    "react": ">=16.8.0",
    "react-dom": ">=16.8.0"
  },
  "resolutions": {
    "@storybook/react/babel-loader": "8.1.0" // 允许您覆盖特定嵌套依赖项的版本。解决 storybook 与 react-scripts 中 babel 依赖冲突 
  }
}

执行 npm publish 进行发包,点击查看发包成功后 lion-design 线上地址。 发布成功后我们就可以在项目中安装使用自己编写的 UI 组件库了,虽然它目前还只有一个 Button 组件。

使用

# 新建一个项目
create-react-app lion-design-test

# 安装包
npm install lion-design

# index.js 中引入css文件
import "lion-design/dist/index.css";

# App.js 中使用
import { Button } from "lion-design"
<Button btnType="primary">确定</Button>

image.png

学习到这里相应大家已经对编写以及使用一个组件库有一个全局的认识了。正如上面所提及,我们还需要一个线上文档,供使用者查阅。

线上文档自动发布

熟悉 github 的朋友应该知道 GitHub Pages,它可以托管 github 项目页面。

现在我希望做到的是,当我修改了项目中的文档,只要执行了 git push 后,就自动更新线上文档。如何做到自动呢?这里就衍生出一个软件工程中重要的概念 CI/CD 。

CI/CD

  • CI 持续集成,频繁的将代码集成到主干 main 分支,它的目的是让产品可以快速迭代同时又可以保证质量。
  • CD 持续交付、持续部署,频繁的将软件的新版本,交付给质量团队或用户,代码通过评审以后,自动部署到生成环境。

简单了解概念之后,有一个在线的 CI/CD 平台 Travis,它可以帮助我们完成这一系列的自动化任务。

Travis

1、使用 github 授权登录注册。

2、将 .travis.yml 文件添加到项目根目录(请删除注释代码)

language: node_js // 部署时使用 node_js 语言
node_js:
  - "stable" // node.js 版本
cache:
  directories:
  - node_modules // node_modules 设置缓存
env:
  - CI=true // 设置环境变量
script:
  - npm run build-storybook // 执行的脚本命令,这里执行的是构建线上文档
deploy: // 自动部署github pages 的配置
  provider: pages
  skip_cleanup: true
  github_token: $github_token
  local_dir: storybook-static // 需部署的文件夹
  on:
    branch: main // 基于main分支进行部署

首先在 github 上提供个人访问令牌,然后在 travis 网站进行配置:

image.png [注意] github_token 就是 travis  设置的变量名,这个必须要对应。

设置完成后,执行 git push 便会触发这些自动化流程,并最终更新组件库文档。

travis 工作流分析:

  1. 获取 git 相应分支(main)的最新代码。
  2. 使用配置好的 node.js 版本执行 yarn install 命令(如果项目中有 yarn.lock )生成 node_modules
  3. node_modules  进行缓存。
  4. 一切就绪后,执行 npm run build-storybook 命令,生成组件库文档。
  5. deploy 中就是部署的配置,我们的项目是要部署到 github pages 上,部署使用的 token 令牌是 github_token ,部署的内容是 storybook-static 文件夹。

这就是 travis 自动化部署流程了。当然它的能力远不止于此。

组件库线上文档地址

查看本小结完整代码

小结

本文通过一步一步搭建组件库的基础框架以及编写 Button 组件作为简单的组件库骨架并发布到线上 npm 包并且还发布了组件库的线上文档。

通过本文的学习与自我拓展,相信您至少已经会搭建并发布一个组件库了。那么仅仅做到这样肯定是远远不够的,由于篇幅限制,作者将分上下篇进行编写,下篇的核心就是通用组件本身的一些思考与编写了。

如果喜欢本文,请点个赞吧!!!