阅读 3597
React组件库完整搭建流程(持续更新)

React组件库完整搭建流程(持续更新)

前言

年前的时候就想着仿造ant design写一个自己的组件库来串联一下自己的工程化知识,以及增强一下自己的代码封装水平,毕竟切图可不能提高我们的代码水平,这篇文章主要是记录一下我的整个搭建过程包括,组件的设计,项目工作流的优化,自动化部署我都会提到,完整代码在仓库里面,具体细节以仓库代码为准,欢迎star~~

线上地址

chicken design 戳我预览

github 求✨✨!!

技术栈

  • 开发语言和框架 TypeScript + React + Sass
  • 构建工具 Gulp
  • 静态站点 Docz
  • 持续化部署 简单的采用git中的hooks里面的post-recieve
  • 测试工具 Jest
  • 代码约束 husky提交发布前验证 eslint提升代码规范性

进度

  • 代码规范&提交格式&全局样式
  • 静态站点部署,githook自动化设置
  • Jest代码测试
  • npm发布 chicken-design@1.1.2
  • 组件进度 Alert Button Menu Icon

参考文档

可能是最详细的React组件库搭建总结

完整流程 (以Menu组件为例子)

准备工作

初始化组件仓库

  • npm init -y 初始化一个代码仓库存放自己的组件库代码

环境安装

  • 安装TypeScriptnpm install typescript
//tsconfig.json

{
 "compilerOptions": {
   "baseUrl": "./",
   "paths": {
     "chicken-design": ["components/index.ts"],
     "chicken-design/esm/*": ["components/*"],
     "chicken-design/lib/*": ["components/*"]
   },
   "allowJs": true,
   "esModuleInterop": true,
   "strict": true,
   "forceConsistentCasingInFileNames": true,
   "resolveJsonModule": true,
   "isolatedModules": true,
   "noEmit": true,
   "outDir": "lib", // 打包输出位置
   "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 // 跳过类库检查
 },
 "target": "es5",
 "lib": [
   "dom",
   "dom.iterable",
   "esnext"
 ],
 "include": ["components", "global.d.ts"],
 "exclude": ["types", "node_modules", "lib", "esm"]
}

//tsconfig.build,json

{
 "extends": "./tsconfig.json",
 // "compilerOptions": { "emitDeclarationOnly": true },
 "exclude": ["**/__tests__/**", "**/demo/**", "node_modules", "lib", "esm"]
}

复制代码
  • 安装React npm install react react-dom @types/react @types/react-dom

代码规范

  • 安装依赖 "prettier": "^1.19.1" ,"@umijs/fabric": "^1.2.12"
  1. .eslintrc.js
module.exports = {
  extends: [require.resolve('@umijs/fabric/dist/eslint')],
  rules: {
    'react/require-default-props': 0,
  },
};

复制代码
  1. .prettierrc.js
const fabric = require('@umijs/fabric');

module.exports = {
  ...fabric.prettier,
};

复制代码
  1. .stylelintrc.js
module.exports = {
  extends: [require.resolve('@umijs/fabric/dist/stylelint')],
};
复制代码
  • 安装依赖 "husky": "^3.1.0" "lint-staged": "^9.5.0" "@commitlint/cli": "^8.2.0" @commitlint/config-conventional "commitizen": "^4.0.3" "cz-conventional-changelog": "^3.0.2"
//工作流程说明
1. git add .将所有改动的文件提交到暂存区
2. git commit -m ""此操作会被husky拦截,之后调用lint-staged对文件进行检查。
3. lint-staged会先进行git stash操作,之后会将与规则相匹配的暂存区的文件进行检查,只有已经提交到暂存区的文件才会被检查。
4. 等到lint-staged执行完成后,只要有一个文件没有通过检查,husky会阻止本次git commit,然后手动修改对应的有问题的文件,重新执行git add和git commit操作,会重复2,3步骤进行检查。

复制代码
  1. package.json

 "lint-staged": {
    "components/**/*.ts?(x)": [
      "prettier --write",
      "eslint --fix",
      "jest --bail --findRelatedTests",
      "git add"
    ],
    "components/**/*.scss": [
      "stylelint --syntax scss --fix",
      "git add"
    ]
  },
  "husky": {
    "hooks": {
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS",
      "pre-commit": "lint-staged"
    }
  },

复制代码
  1. .commitlintrc.js

module.exports = { extends: ['@commitlint/config-conventional'] };

  1. eg

样式设置

开发采用预处理语言Sass,在安装完Sass的相关依赖以后在根目录下创建styles文件夹目录结构如下

styles
├─_animation.scss //存放自定义动画
├─_mixin.scss //存放重复使用的样式
├─_reboot.scss //样式重置 采用了normalize.css解决方案
├─_variables.scss //全局样式变量
└index.scss 
复制代码

这部分的代码大家可以按照自己的喜好定制合适的代码,因为配置实在有点多我就不贴上来了,代码全在仓库里面啦

传送门!

开发组件

做完前面这些工作,我们的组件库在环境的配置以及代码规范约束的层面上就已经做好了,那么现在让我们试着开发一个菜单组件来开始我们的开发流程吧

组件结构

components
├─index.ts
├─Menu
|  ├─index.mdx
|  ├─index.tsx
|  ├─menu.tsx
|  ├─menuItem.tsx
|  ├─subMenu.tsx
|  ├─styles
|  |   ├─index.scss
|  |   └index.ts
|  ├─demo
|  |  ├─menu-demo-1.tsx
|  |  └menu-demo-2.tsx

复制代码

组件结构分析

菜单组件大体功能有普通的子菜单,以及可以收缩的下拉菜单那么很自然的我们就需要写三个组件

Menu,MenuItem,subMeun

这里给出Menu的主体代码给大家参考一下,主要的点在于

  • 使用useContext给子组件共享数据
  • 使用React.children.map对给子组件的props进行操作
  • 动态变更类名改变样式
//Menu.tsx
const MenuContext = createContext<IMenuContext>({ index: '0' })
export const Menu: FC<MenuProps> = props => {
  const { className, mode, style, children, defaultIndex, onSelect, defaultOpenSubMenus } = props
  const [currentActive, setActive] = useState(defaultIndex)
  const classes = classNames('chicken-design-menu', className, {
    'menu-vertical': mode === 'vertical',
    'menu-horizontal': mode !== 'vertical',
  })
  const handleClick = (index: string) => {
    setActive(index)
    if (onSelect) {
      onSelect(index)
    }
  }
  const passedContext: IMenuContext = {
    index: currentActive || '0',
    onSelect: handleClick,
    mode,
    defaultOpenSubMenus,
  }
  const renderChildren = () => React.Children.map(children, (child, index) => {
      const childElement = child as React.FunctionComponentElement<MenuItemProps>
      const { displayName } = childElement.type
      if (displayName === 'MenuItem' || displayName === 'SubMenu') {
        return React.cloneElement(childElement, {
          index: index.toString(),
        })
      }
    })
  return (
    <ul className={classes} style={style} data-testid="test-menu">
      <MenuContext.Provider value={passedContext}>
        {renderChildren()}
      </MenuContext.Provider>
    </ul>
  )
}
复制代码
//MenuItem.tsx
const MenuItem: React.FC<MenuItemProps> = props => {
  const { index, disabled, className, style, children } = props
  const context = useContext(MenuContext)
  const classes = classNames('menu-item', className, {
    'is-disabled': disabled,
    'is-active': context.index === index,
  })
  const handleClick = () => {
    if (context.onSelect && !disabled && (typeof index === 'string')) {
      context.onSelect(index)
    }
  }
  return (
    <li className={classes} style={style} onClick={handleClick}>
      {children}
    </li>
  )
}
复制代码
//subMeun.tsx
const SubMenu: React.FC<SubMenuProps> = ({ index, title, children, className }) => {
  const context = useContext(MenuContext)
  const openedSubMenus = context.defaultOpenSubMenus as Array<string>
  const isOpend = (index && context.mode === 'vertical') ? openedSubMenus.includes(index) : false
  const [menuOpen, setOpen] = useState(isOpend)
  const classes = classNames('menu-item submenu-item', className, {
    'is-active': context.index === index,
    'is-opened': menuOpen,
    'is-vertical': context.mode === 'vertical',
  })
  const handleClick = (e: React.MouseEvent) => {
    e.preventDefault()
    setOpen(!menuOpen)
  }
  let timer: any
  const handleMouse = (e: React.MouseEvent, toggle: boolean) => {
    clearTimeout(timer)
    e.preventDefault()
    timer = setTimeout(() => {
      setOpen(toggle)
    }, 300)
  }
  const clickEvents = context.mode === 'vertical' ? {
    onClick: handleClick,
  } : {}
  const hoverEvents = context.mode !== 'vertical' ? {
    onMouseEnter: (e: React.MouseEvent) => { handleMouse(e, true) },
    onMouseLeave: (e: React.MouseEvent) => { handleMouse(e, false) },
  } : {}
  const renderChildren = () => {
    const subMenuClasses = classNames('chicken-design-submenu', {
      'menu-opened': menuOpen,
    })
    const childrenComponent = React.Children.map(children, (child, i) => {
      const childElement = child as FunctionComponentElement<MenuItemProps>
      if (childElement.type.displayName === 'MenuItem') {
        return React.cloneElement(childElement, {
          index: `${index}-${i}`,
        })
      }
        console.error('Warning: SubMenu has a child which is not a MenuItem component')
    })
    return (
      <Transition
        in={menuOpen}
        timeout={300}
        animation="zoom-in-bottom"
      >
        <ul className={subMenuClasses}>
          {childrenComponent}
        </ul>
      </Transition>
    )
  }
  return (
    <li key={index} className={classes} {...hoverEvents}>
      <div className="submenu-title" {...clickEvents}>
        {title}
        <Icon icon="angle-down" className="arrow-icon"/>
      </div>
      {renderChildren()}
    </li>
  )
}
复制代码

本地开发

组件的本地开发与演示我采用docz来进行调试与组件演示,当然类似的方案还有很多比如 storybookdumi,以后有机会我也试试~~

docz基于MDX(Markdown + JSX),可以在 Markdown 中引入 React 组件,使得一边编写文档,一边预览调试成为了可能。而且得益于 React 组件生态,我们可以像编写应用一般编写文档.

本地安装docz

npm install docz

添加配置

docz底层采用的Gatsby,所以在配置时还需要添加gatsby的配置文件,详细配置方案大家可以参考官网

// doczrc.js

export default {
  src: 'components',
  dist: 'doc-site', // 打包出来的文件目录名
  title: 'chicken-desgin', // 站点标题
  typescript: true, // 组件源文件是通过typescript开发,需要打开此选项
  scripts: {
    "docz:dev": "docz dev && gulp",
    "docz:build": "docz build",
    "docz:serve": "docz build && docz serve"
  },
};

//gatsby-config.js 这里添加需要使用的plugin
module.exports = {
  plugins: ['gatsby-theme-docz', 'gatsby-plugin-styled-components','gatsby-plugin-sass'],
};

//gatsby-node.js  
const path = require('path');
exports.onCreateWebpackConfig = args => {
  args.actions.setWebpackConfig({
    resolve: {
      modules: [path.resolve(__dirname, '../src'), 'node_modules'],
      alias: {
        'chicken-design/lib': path.resolve(__dirname, '../components/'),
        'chicken-design': path.resolve(__dirname, '../lib/'),
      },
    },
  });
};


复制代码
添加mdx文件
---
name: Menu 菜单
route: /docs/menu
menu: 组件
---

import { ShowTools } from '../../doc-comps';
import BasicDemo from './demo/menu-demo-1';
import BasicDemoCode from '!raw-loader!./demo/menu-demo-1.tsx';
import BasicDemo2 from './demo/menu-demo-2';
import BasicDemoCode2 from '!raw-loader!./demo/menu-demo-2.tsx';


## Menu 菜单

菜单 用于分类处理

### 代码演示

#### 基本用法

<ShowTools code={BasicDemoCode} title="默认">
  <BasicDemo />
</ShowTools>
<ShowTools code={BasicDemoCode2} title="纵向菜单">
  <BasicDemo2 />
</ShowTools>

### API

#### Menu
| 属性 | 说明     | 类型                                         | 默认值 |
| ---- | -------- | -------------------------------------------- | ------ |
| defaultIndex | 默认active的索引 | number | 0 |
| className | 组件类名 |   string   |   - |
| mode  | 菜单类型横项或者纵向 | oneOf "horizontal" | "vertical" | horizonta | 
| onSelect | 	点击菜单项触发的回掉函数 | function | false |
| defaultOpenSubMenus | 	设置子菜单的默认打开 只在纵向模式下生效 | string[默认打开的索引数组] | - |   


#### MenuItem
| 属性 | 说明     | 类型                                         | 默认值 |
| ---- | -------- | -------------------------------------------- | ------ |
| disabled| 选项是否被禁用 | boolean | false |
| className | 组件类名 |   string   |   - |
| style | css |  |  | 

#### SubMenu
| 属性 | 说明     | 类型                                         | 默认值 |
| ---- | -------- | -------------------------------------------- | ------ |
| title | 名称 | string | - |
| className | 组件类名 |   string   |   - |
| style | css |  |  | 


复制代码
调试

npm run start 启动本地调试

打包

目标 将组件打包到一个新的文件夹,支持scss的同时也要支持css,因为不可能要求使用方安装sass,同时要将ts转成ES5,方便用户使用,在打包样式文件中,我们的index文件默认引入css文件,但是在开发组件的代码里面又没有.css文件,所以我们需要在gulp打包的时候,利用正则将index.scss转换成index.css,具体代码如下

  1. package.json里面增加打包stripts "build:doc": "npm run clean && npm run build:types && gulp build", "build:types": "tsc -p tsconfig.build.json",
  2. 转换scss文件和ts文件使用gulp,安装好相关依赖
//.browserslistrc 
>0.2%
not dead
not op_mini all

//babelrc.js
module.exports = {
 presets: ['@babel/env', '@babel/typescript', '@babel/react'],
 plugins: ['@babel/plugin-transform-runtime', '@babel/proposal-class-properties'],
 env: {
   esm: {
     presets: [
       [
         '@babel/env',
         {
           modules: false,
         },
       ],
     ],
     plugins: [
       [
         '@babel/plugin-transform-runtime',
         {
           useESModules: true,
         },
       ],
     ],
   },
 },
};

//gulpfile.js
const gulp = require('gulp');
const babel = require('gulp-babel');
const scss = require('gulp-sass');
const autoprefixer = require('gulp-autoprefixer');
const cssnano = require('gulp-cssnano');
const through2 = require('through2');

const paths = {
 dest: {
   lib: 'lib',
   esm: 'esm',
   dist: 'dist',
 },
 style: 'components/**/*.scss',
 root: 'styles/*.scss',
 copyroot: 'components/styles',
 scripts: [
   'components/**/*.{ts,tsx}',
   '!components/**/demo/*.{ts,tsx}',
   '!components/**/__tests__/*.{ts,tsx}',
 ],
};

/**
* @param {string} content
*/
function cssInjection(content) {
 return content
   .replace(/\/style\/?'/g, "/style/css'")
   .replace(/\/style\/?"/g, '/style/css"')
   .replace(/\.less/g, '.css')
   .replace(/\.scss/g, '.css')
}

/**
* 编译脚本文件
* @param {string} babelEnv babel环境变量
* @param {string} destDir 目标目录
*/
function compileScripts(babelEnv, destDir) {
 const { scripts } = paths;
 process.env.BABEL_ENV = babelEnv;
 return gulp
   .src(scripts)
   .pipe(babel()) // 使用gulp-babel处理
   .pipe(
     through2.obj(function z(file, encoding, next) {
       this.push(file.clone());
       // 找到目标
       if (file.path.match(/(\/|\\)styles(\/|\\)index\.js/)) {
         const content = file.contents.toString(encoding);
         file.contents = Buffer.from(cssInjection(content)); // 处理文件内容
         file.path = file.path.replace(/index\.js/, 'index.js'); // 文件重命名
         this.push(file); // 新增该文件
         next();
       } else {
         next();
       }
     }),
   )
   .pipe(gulp.dest(destDir));
}

/**
* 编译cjs
*/
function compileCJS() {
 const { dest } = paths;
 return compileScripts('cjs', dest.lib);
}

/**
* 编译esm
*/
function compileESM() {
 const { dest } = paths;
 return compileScripts('esm', dest.esm);
}

const buildScripts = gulp.series(compileCJS, compileESM);

/**
* 拷贝scss文件
*/

function copyScssRoot() {
 return gulp
   .src(paths.root)
   .pipe(gulp.dest(paths.dest.lib))
   .pipe(gulp.dest(paths.copyroot))
   .pipe(gulp.dest(paths.dest.esm));
}
function copyScss() {
 return gulp
   .src(paths.style)
   .pipe(gulp.dest(paths.dest.lib))
   .pipe(gulp.dest(paths.dest.esm));
}

/**
* 生成css文件
*/
function scss2css() {
 return gulp
   .src(paths.style)
   .pipe(scss()) // 处理scss文件
   .pipe(autoprefixer()) // 根据browserslistrc增加前缀
   .pipe(cssnano({ zindex: false, reduceIdents: false })) // 压缩
   .pipe(gulp.dest(paths.dest.lib))
   .pipe(gulp.dest(paths.dest.esm));
}
gulp.task("watch-copy-scss",function(){
 return gulp
 .src(paths.style)
 .pipe(gulp.dest(paths.dest.lib))
 .pipe(gulp.dest(paths.dest.esm));
})
const watch = gulp.task("watch",function(){
 gulp.watch(paths.style,gulp.parallel(copyScss,scss2css))
})

const build = gulp.parallel(buildScripts,copyScssRoot ,copyScss,scss2css);

exports.build = build;
exports.watch = watch;


复制代码

发布

打包完成之后我们就可以进行发布了

在此之前我们需要在package.json文件里面添加一个发布的白名单,当然你也可以使用npmignore配置文件,这种事黑名单的方式,不太建议,容易遗漏。

  "files": [    "lib","esm"  ]
复制代码

发布流程

  • 注册npm账号
  • npm login
  • npm publish 这里注意,如果使用的是yarn或者taboo等源的话,需要换回npm.js的源,不然会报错

然后就发布成功啦

然后在自己项目中测试一下

ok没毛病!

持续部署

整个流程下来,从开发到发布组件库其实整个流程下来还是比较舒服的,但是现在又有一个新的问题,我们还需要一个展示的官方网站,那么我们通常在制作完一个新的模块或者样式修改的时候,就需要在服务器上面进行更新,那么最方便的情况就是,我在本地输入命令比如git push的时候它会自动更新,目前有很多CI/CD解决方案,比如jekins,travis等,其实github给我们提供了很方便的解决方案!,下面我们就试试这种通过触发github里面的hook的方法

  • 首先你需要一个linux服务器,然后安装nginx服务器,和node.js环境
  • 然后需要建立一个bare仓库,我们会将代码推送到这个bare仓库里面
cd /opt
git init xxxx.git --bare
cd xxxx.git/hook
vim post-receive
复制代码

然后退出vim 命令行chmod +x post-receive 添加文件执行权限

回到本机的项目目录

  1. git remote add prod ssh://@服务器ip/opt/xxxx.git
  2. git remote- v
  3. git add . git commit git push 一条龙

哦对不起,根据husky的提示改一下加一个return

再来一遍

这时候你的静态文件就到你服务器/var/www/html目录下了,这时候你改一下你nginx的预设目录

vim /etc/nginx/sites-available/default

具体配置可以看nginx的官网或者我之前有类似的文章传送门

完成之后浏览器输入地址就可以看到啦

整个流程下来,虽说还有很多不太完美的地方,但是整个开发流程还是比较舒服点,之后可以继续添加,下面留了两个模块,到小菜鸡有收获的时候就会更新的,很快,进度正常的话一个星期应该会更新一次,大家觉得有帮助的话可以点赞关注哈哈~,顺便提醒一下,应为我不想把文章搞太长(太懒了),所以部分代码可能没贴完整,大家以我仓库为准,谢谢~

组件设计

测试

文章分类
前端
文章标签