封装一个属于自己的UI组件库都会踩哪些坑

1,738 阅读2分钟

在日常的开发中我们早已厌倦了反复无常的码代码,总有一颗躁动的心,自己整一个来玩玩,虽然市面上已有的组件库已经很完善了,但还是想体验一把用自己组件库的成就感,顺带也提升提升自己!

涉及技术: Typescript Create-React-App sass Jest Storybook Classnames Husky Fontawesome

Create-React-App 搭建工程

普通模板:
npx create-react-app my-app
cd my-app
npm start
typescript模板:
npx create-react-app my-app --template typescript

规划目录结构

├ .storybook
  ├ main.js             storybook配置,包括自定义的一些配置
  ├ preview.js          页面展示相关配置
  ├ manager.js          插件设置配置 (页面很多东西都是可以自行配置)
  ├ yourTheme.js        例子:更改左上角logo
├ .vscode               vscode配置
├ public                公共文件
├ src                   源码
  ├ components          组件
   ├ Button
   ...
  ├ stories             storybook文件
   ├ button.stories.tsx 示例:button组件引入stories.tsx后就可以预览组件
   ...
  ├ styles              样式文件
   ├ _mixin.scss        全局mixin
   ├ _reboot.scss       全局样式, 类型reset.css
   ├ _varialbes.scss    全局样式变量定义
   ├ inde.scss          所有样式文件引入一并管理
  ├ index.tsx           入口
├ .gitignore            git
├ package.json          package
├ package-lock.json     package包版本号固定
├ yarn.lock     
├ tsconfig.json         ts配置文件
├ tsconfig.build.json   组件编译
├ README.md             说明文档

SCSS

  • 确定好全局样式,后续用到直接以变量的形式使用,便于管理
_variables.scss
基本色
$white:    #fff    !default;
$gray-100: #f8f9fa !default;
$gray-200: #e9ecef !default;
$gray-300: #dee2e6 !default;
$gray-400: #ced4da !default;
$gray-500: #adb5bd !default;
$gray-600: #6c757d !default;
$gray-700: #495057 !default;
$gray-800: #343a40 !default;
$gray-900: #212529 !default;
$black:    #000    !default;
主题色
$blue:    #0d6efd !default;
$indigo:  #6610f2 !default;
$purple:  #6f42c1 !default;
$pink:    #d63384 !default;
$red:     #dc3545 !default;
$orange:  #fd7e14 !default;
$yellow:  #fadb14 !default;
$green:   #52c41a !default;
$teal:    #20c997 !default;
$cyan:    #17a2b8 !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;
...
定义
@mixin button-size($padding-y, $padding-x, $font-size, $border-radius) {
  padding: $padding-y $padding-x;
  font-size: $font-size;
  border-radius: $border-radius;
}
使用
@include button-size($btn-padding-y, $btn-padding-x, $btn-font-size, $border-radius);
  • 样式集成管理 注意文件名定义 _xxxx.scss 引用的时候 xxxx , 可以省略 _ .scss
// config
@import "variables";

//layout
@import "reboot";

//mixin
@import "mixin";

// animation
@import "animation";

// button样式
@import "../components/Button/style";

// icon
@import "../components/Icon/style";

// menu样式
@import "../components/Menu/style";

// input样式
@import "../components/Input/style";

//upload
@import "../components/Upload/style";

//progress
@import "../components/Progress/style";

当然还有其他高级的功能,可自行学习

组件编写

文件布局方式

Button
  ├ _style.scss      组件样式
  ├ button.tsx       组件源码
  ├ button.test.tsx  组件单元测试
  ├ index.tsx        组件导出
  
button.tsx 示例
/*
 * @Descripttion: 
 * @version: 
 * @Author: zoucw (326359613@qq.com)
 * @Date: 2021-02-14 13:54:04
 * @LastEditors: Please set LastEditors
 * @LastEditTime: 2021-02-17 16:42:36
 */

import React from 'react';
import classNames from 'classnames';

export type ButtonSize = 'lg' | 'sm'
export type ButtonType = 'primary' | 'default' | 'danger' | 'link'

interface BaseButtonProps {
  className?: string;
  /**设置 Button 的禁用 */
  disabled?: boolean;
  /**设置 Button 的尺寸 */
  size?: ButtonSize;
  /**设置 Button 的类型 */
  btnType?: ButtonType;
  children: React.ReactNode;
  href?: string;
}

type NativeButtonProps = BaseButtonProps & React.ButtonHTMLAttributes<HTMLElement>
type AnchorButtonProps = BaseButtonProps & React.AnchorHTMLAttributes<HTMLElement>

// Partial把所有属性变成可选属性
export type ButtonProps = Partial<NativeButtonProps & AnchorButtonProps>

const Button: React.FC<ButtonProps> = (props) => {
  const { 
    btnType,
    className,
    disabled,
    size,
    children,
    href,
    ...restProps
  } = props;

  // btn btn-lg btn-primary btn-default
  const classes = classNames('btn', className, {
    [`btn-${btnType}`]: btnType,
    [`btn-${size}`]: size,
    'disabled': (btnType === 'link') && disabled
  })
  if (btnType === 'link' && href) {
    return (
      <a
        className={classes}
        href={href}
        {...restProps}
      >
        {children}
      </a>
    )
  } else {
    return (
      <button
        className={classes}
        disabled={disabled}
        {...restProps}
      >
        {children}
      </button>
    )
  }
}

Button.defaultProps = {
  disabled: false,
  btnType: 'default'
}

export default Button

组件预览调试

过往看别人项目预览组件都是在项目里搭建页面来展示组件,这里给大家推荐一个UI组件预览神器,只需手动配置就可以展示组件测试组件而且可以展示组件源码,它就是Storybook

这里不做过多介绍,安装完跑起来很容易,其他一些自定义的展示功能要仔细研究研究

安装

npx sb init

npm run storybook

在你的项目根目录下执行npx sb init后,storybook会自动在你的项目根目录新增.storybook文件夹,在你的component文件夹的平级处新增stories文件夹,可以按照已有的实例模仿研究一下

新增组件预览 stories -> xxx.stories.tsx -> 引入自己的组件

配置完成大概样式就长这么样,很多样式可以自定义哦

发布前的编译

  • 首先是要编译tsx文件为js
"scripts": {
    ...
    "build-ts": "tsc -p tsconfig.build.json",
}

tsconfig.build.json
{
  "compilerOptions": {
    "outDir": "dist",
    "module": "esnext",
    "target": "es5",
    "declaration": true,
    "jsx": "react",
    "moduleResolution":"Node",
    "allowSyntheticDefaultImports": true,
  },
  "include": [
    "src"
  ],
  "exclude": [
    "src/**/*.test.tsx",
    "src/**/*.stories.tsx",
    "src/setupTests.ts",
    "src/reportWebVitals.ts"
  ]
}
  • 编译scss
"scripts": {
    "build-css": "node-sass ./src/styles/index.scss ./dist/index.css",
}

编译完成之后会构建出如下文件

注意: 我们在开发组件的过程,迭代了功能怎么去模拟引入生产的组件来测试? 1.完善package.json配置

{
  "main": "dist/index.js",
  "module": "dist/index.js",
  "types": "dist/index.d.ts",
}

2.组件项目的根目录下执行 npm link

3.切换到测试项目的根目录,执行npm link name (注: 这个name是你组件项目package.json里的name)

4.打开测试项目的node_modules,看是否有名称为 name 的依赖包且后面带有一个icon,说明link成功

5.在你的测试项目引入组件一样使用(npm安装的包怎么用就怎么用)

当你测试到某些组件是又会报一个错误:❌ 大概意思就是引入的组件内部importreact与测试项目的react产生了冲突关系 咋办,继续link UI组件项目react到当前的测试项目

示例
npm link ../cute-spring/node_modules/react

此时你是不是会想引用npm包是也这样吗? No,请继续往下看

Npm 发布

1.你是否已经注册npm账号(如果没有,点击注册

2.继续完善package.json配置项,因为npm展示组件包的信息都是来自package.json里配置的内容

{
    ...
    "description": "this is my component",
    // 配置入口文件
    "main": "dist/index.js",
    "module": "dist/index.js",
    "types": "dist/index.d.ts",
    
    "author": "xxx",
    "private": false,
    "license": "MIT",
    "keywords": [
      "Component",
      "UI",
      "React"
    ],
    "homepage": "https://github.com/Spring-Listening/cute",
    "repository": {
      "type": "git",
      "url": "https://github.com/Spring-Listening/cute"
    },
    // 这一项配置是关键,因为`npm publish`发布时会上传两部分文件,一部分是用户定义在files里,一部分是默认上传的文件,比如readme.md package.json 
    "files": [
      "dist"
    ],
    ...
}

3.解决npm包与使用组件项目react冲突的问题

首先把package.json里的依赖 devDependencies Dependencies 关系处理好,开发过程用到的依赖包放devDependencies, 组件库正常使用就需要依赖的包放Dependencies

"peerDependencies": {
  "react": ">=16.8.0",
  "react-dom": ">=16.8.0"
},

tips: 意思是安装此UI组件之前,你的开发项目必须安装符合如上要求的依赖包,进而组件库内的组件可以使用开发项目的依赖,进而组件库可以不用去安装重复的依赖,也不会有上面那种冲突的现象

4.完善一些工程化相关的东西,比如eslint 组件单元测试 husky

{
  "scripts": {
    // eslint
    "lint": "eslint --ext js,ts,tsx src --max-warnings 5",
    // 单元测试
    "test:nowatch": "cross-env CI=true react-scripts test",
    // 编译ts scss文件
    "build": "npm run clean && npm run build-ts && npm run build-css",
    // 执行npm publish时会执行这条命令,然后将构建好文件上传npm服务器
    "prepare": "npm run build",
  },
  "husky": {
    "hooks": {
      "pre-commit": "npm run test:nowatch && npm run lint"
    }
  },
}

执行 npm publish

人生的路总是不会那么平坦,你可能会遇见各种403错误导致上传失败

当你遇到困难时不要灰心,按照这个自检清单逐一排查:

1.检查是否登录了npm  (npm whoami)

2.你是否有多个账号记混了,退出重新登录

3.你注册号时所填的邮箱是否被验证了,如果没有刷新页面点击顶部通知栏重发验证邮件

4.检查你的包名是否已被占用

5.检查你的版本号是否是用过的版本号

6.检查你的npm源是否是npm官方源(npm config list)

Jest 单元测试

在日常的工作中一般业务可能都不会去搞这个单元测试,一来是业务频繁变化,测试用例没法复用,导致测试用例使用率不高,造成效率的低下。二来写测试用户也会对效率打一定的折扣,不符合当今资本家的嘴脸。但是对于这种组件开发,业务逻辑稳定,不太容易完全推翻重做,比较适合来做单元测试。

测试的依赖包主要分三部分:Jest、jest-dom、@testing-library/react (或者是其他框架的包) jest主要是这个测试的核心包,提供了一个基础功能的api,因为在日常的开发中,涉及到dom方面的东西,jest-dom这个包主要提供了跟dom相关的api,@testing-library这个包主要是提供了当前市面上用的比较多的框架配套的测试api

以上是jest提供的一些基础api

toBe() 用于检验基本数据类型的值是否相等
toEqual() 用于检验引用数据类型的值,由于js本身object数据类型的本身特性,引用数据类型对比只是指针的对比,但是需要对比对象的每个值,所以这时候用到的是toEqual()
Truthiness 布尔值判断的匹配器

toBeNull 只匹配 null
toBeUndefined 只匹配 undefined
toBeDefined 与 toBeUndefined 相反
toBeTruthy 匹配任何 if 语句为真
toBeFalsy 匹配任何 if 语句为假

数字匹配器 用于判断数字值之间的对比

toBeGreaterThan 大于匹配器
toBeGreaterThanOrEqual 大于等于匹配器
toBeLessThan 小于匹配器
toBeLessThanOrEqual 小于等于匹配器
tobe 和 toequal 都是等价功能相同的对于数字

toMatch 字符串匹配器 和字符串的match相同
toContain 数组匹配器 用于判断数组中是否包含某些值
toThrow 报错匹配器 用于测试特定的抛出错误,可以判断报错语句的文字(支持正则匹配),也可以判断报错类型。

异步测试示例

// 提前了解async和await的使用方法和场景

test('the data is peanut butter', async () => {
  // 切记添加断言测试
  expect.assertions(1);
  const data = await fetchData();
  expect(data).toBe('peanut butter');
});

@testing-library/react

import {render, fireEvent} from '@testing-library/react'

test('should render the correct default button', () => {
  const warpper = render(<Button {...defaultProps}>Nice</Button>)
  const element = warpper.getByText('Nice') as HTMLButtonElement
  expect(element).toBeInTheDocument()
  expect(element.tagName).toEqual('BUTTON')
  expect(element).toHaveClass('btn btn-default')
  expect(element.disabled).toBeFalsy()
  // 模拟点击事件
  fireEvent.click(element)
  expect(defaultProps.onClick).toHaveBeenCalled()
})

更多有关react的测试示例@testing-library/react

jest-dom

这些dom相关的api语义还是很明显的,看了api名就知道是什么功能,具体用法可以看这个文档 github.com/testing-lib…

CI CD 持续集成,持续部署

组件开发完后,我们的组件文档怎么发到网上去,而且是一键发布,不用手动去发,繁琐!下面我们来优化一下流程

  • 打开travis www.travis-ci.com/
  • 使用GitHub授权登陆,此时就可以选中本次的组件库到Travis
  • 在项目添加一个文件,没有这个没法自动触发这套流程
.travis.yml
// 语言
language: node_js
// node版本
node_js:
  - "stable"
cache:
  directories:
  - node_modules
// 环境
env:
  - CI=true
script:
// 构建命令
  - npm run build-storybook
// 下面这一段的意思是把构建好的包发到 `GitHub` 的 `pages`
deploy:
  provider: pages
  skip_cleanup: true
  // 配置token 关键
  github_token: $github_token
  // 构建出来的包的文件夹名称
  local_dir: storybook-static
  on:
    branch: master

配置GitHub-token

GitHub -> 点击头像下拉箭头 -> settings -> developer settings -> personal access tokens -> generate new token 在生成token之前你需要勾选这个token有哪些权限,勾选后, 点击按钮创建,如下图

创建token成功,一定要复制下来

Travis配置刚生成的token

首页右上角 -> more options -> settings 一切配置已完毕,提交代码,就会触发cicd流程 成功触发 怎么访问到我们发上去的包

spring-listening.github.io/包名/?path=/

完结

纸上得来终觉浅,绝知此事要躬行

无论你研究别人的文档有多清楚,实践起来总会遇到各种问题,所以动起来

有不足的地方希望各位大佬补充,共同进步,大家好才是真的好!

组件源码