react 组件库搭建记录

4,056 阅读4分钟

一、 初始化项目

  • 初始化项目
git init
npm init -y
  • 创建 .gitignore
node_modules
coverage
dist
es
lib

package-lock.json

二、 基于 storybook 搭建开发测试环境

2.1 项目快速搭建

npx -p @storybook/cli sb init --type react
  • 当前目录介绍

stories: storybook 主目录相当于一般项目下的 src 目录 index.stories.js: storybook 的入口文件 .storybook: storybook 配置目录

├── .gitignore
├── package.json
├── package-lock.json
├── stories
│   ├── index.stories.js
└── .storybook
    ├── addons.js
    └── config.js

2.2 目录架构调整

stories/pages 目录,用于存放 storybook 所有相关页面, stories/config.js 作为页面配置在入口文件 index.stories.js 中进行引用并根据该配置渲染出所有相关页面,同时假设我们 storybook 有一级目录 基本 且目录下有 介绍 页面, 那么对应的添加 stories/pages/base/Introduce 目录,目录下 api.md 用于编写对应组件 api 文档(当然介绍页面并没有 api 文档),index.css 作为当前页面的样式文件,index.jsx 则作为当前页面的入口文件, subpage 则用于存放页面的子页面。

├── .gitignore
├── package.json
├── package-lock.json
├── stories
│   ├── config.js
│   ├── index.stories.js
│   └── pages
│       └── base
│           └── Introduce
│               ├── api.md
│               ├── index.css
│               ├── index.jsx
│               └── subpage
└── .storybook
    ├── addons.js
    └── config.js

2.3 编写测试代码

  • 编写 stories/pages/base/Introduce/index.jsx
import React from 'react';
import './index.css';
export default () => {
  return (
    <div>
      react 组件介绍
    </div>
  );
};

  • 编写 stories/config.js
import Introduce from './pages/base/Introduce';
export default [
  {
    title: '介绍',
    module: '基本',
    component: Introduce
  }
];
  • 编写 stories/index.stories.js
import React from 'react';
import { storiesOf } from '@storybook/react';
import config from './config';

config.forEach( v => (storiesOf(v.module, module).add(v.title, v.component)));
  • 添加 npm 脚本
{
  "scripts": {
+  "start": "npm run storybook",
    "storybook": "start-storybook -p 8080",
    "build-storybook": "build-storybook"
  },
}
  • 执行脚本 npm start

演示

2.4 自定义 webpack 配置

storybook 虽然有自己的 webpack 配置, 但是显然无法满足一些复杂的情况, 在 storybook 中可通过创建 .storybook/webpack.config.js 来自定义 webpack 配置。

  • 下载相关依赖
# 1. webpack 安装
npm install webpack webpack-cli -D

# 2. babel-loader 相关依赖包安装
npm install babel-loader @babel/core -D

#  3. babel 相关预设依赖包安装
npm install @babel/preset-env @babel/preset-react -D

#  4. babel 相关插件依赖包安装
npm install @babel/plugin-transform-runtime -D
npm install @babel/plugin-proposal-decorators -D
npm install @babel/plugin-transform-async-to-generator -D

# 5. eslint-loader 相关依赖
npm install eslint eslint-loader -D

# 6. eslint 相关插件安装
npm install babel-eslint eslint-plugin-babel eslint-plugin-react -D

# 7. 样式文件加载配置所需依赖
npm install style-loader css-loader sass-loader node-sass postcss-loader -D

# 8. postcss-loader 相关插件依赖包安装
npm install autoprefixer -D

# 9. 图片字体加载所需依赖
npm install url-loader file-loader -D

# 10. 文本文件加载所需依赖
npm install raw-loader -D
  • 创建 .storybook/webpack.config.js 文件
const path = require('path');
const webpack = require('webpack');

// 路径别名
const alias = {};

module.exports = {
  mode: 'production',
  module: {
    rules: [
      { // js 模块打包
        test: /\.(mjs|js|jsx)$/,
        exclude: [ path.resolve(__dirname, 'node_modules') ],
        use: ['babel-loader', 'eslint-loader']
      }, { // 样式文件打包
        test: /\.(css|scss)$/,
        use: [
          'style-loader', {
            loader: 'css-loader',
            options: {
              sourceMap: false,
            }
          }, {
            loader: 'postcss-loader',
            options: { javascriptEnabled: true, sourceMap: false },
          }, {
            loader: 'sass-loader'
          }
        ],
      }, { // 文字图片打包
        test: /\.(png|jpg|gif|woff|svg|eot|ttf)$/,
        use: [{
          loader: 'url-loader',
          options: {
            limit: 10 * 1000,
          }
        }]
      }, { // 文本文件加载(后期可能需要引入 markdown 文件)
        test: /\.(txt|md)$/,
        use: 'raw-loader',
      },
    ]
  },

  plugins: [
    new webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /zh-cn|en-gb/),
  ],

  // 解析模块
  resolve: {
    alias,
    // 自动解析确定的扩展
    extensions: ['.mjs', '.js', '.jsx'],
  },
}
  • 项目下新增 babel 配置文件 .babelrc
{
  "plugins": [
    // 为api提供沙箱的垫片方案,不会污染全局的 api
    ["@babel/plugin-transform-runtime"],
    // 修饰器
    ["@babel/plugin-proposal-decorators", { "legacy": true }],
    // asyn await 支持
    ["@babel/plugin-transform-async-to-generator"]
  ],
  "presets": ["@babel/preset-react", "@babel/preset-env"]
}
  • 项目下新增 postcss 配置文件 postcss.config.js
module.exports = {
  plugins: [
    require("autoprefixer")({
      browsers: [
        "last 2 versions",
        "Android >= 4.4",
        "Firefox ESR",
        "not ie < 9",
        "ff >= 30",
        "chrome >= 34",
        "safari >= 6",
        "opera >= 12.1",
        "ios >= 6"
      ]
    })
  ]
};
  • 项目下新增 eslint 配置文件 .eslintrc.js .eslintignore
// .eslintrc.js 配置文件
module.exports = {
  parserOptions: {
    ecmaVersion: 8,
    sourceType: "module",
    ecmaFeatures: {
      jsx: true
    }
  },
  parser: "babel-eslint",
  plugins: ["babel", "react"],
  extends: "eslint:recommended",
  env: {
    es6: true,
    browser: true,
    commonjs: true
  },
  globals: {
    process: true,
    describe: true,
    it: true,
    __dirname: true,
    expect: true,
    jest: true,
    beforeAll: true,
    afterEach: true
  },
  rules: {
    "object-shorthand": "error",
    "generator-star-spacing": ["error", "after"],
    camelcase: ["error", { properties: "never" }],
    eqeqeq: ["error", "smart"],
    "linebreak-style": ["error", "unix"],
    "new-cap": "error",
    "no-array-constructor": "error",
    "no-lonely-if": "error",
    "no-loop-func": "error",
    "no-param-reassign": "error",
    "no-sequences": "error",
    "no-shadow-restricted-names": "error",
    "no-unneeded-ternary": "error",
    "no-unused-expressions": "error",
    "no-unused-vars": "off",
    "no-use-before-define": ["error", "nofunc"],
    "no-var": "error",
    "prefer-arrow-callback": "error",
    "prefer-spread": "error",
    "prefer-template": "error",
    "wrap-iife": ["error", "inside"],
    yoda: ["error", "never"],
    "react/jsx-uses-react": "error",
    "react/jsx-uses-vars": "error",
    "react/jsx-no-undef": ["error", { allowGlobals: true }],
    "react/jsx-no-bind": ["error", { allowArrowFunctions: true }],
    "react/jsx-key": "error",
    "react/no-unknown-property": "error",
    "react/no-string-refs": "error",
    "react/no-direct-mutation-state": "error",
    "no-console": "off"
  }
};
# .eslintignore 配置文件
tests
node_modules
*.bundle.js
*.js.map
.history
dist
.vscode
**/*.snap

三、 测试组件的创建和发布

3.1 components 目录设计

项目根目录下创建 components 目录用于存放组件库的所有组件, components 目录的结构则参考 antd 进行设计, 假设有组件 input-number 那么 components 可以是下面这样:

  • index.js 作为组件库的入口文件
  • assets/iconfont 存放字体图标文件
  • assets/style 用于存放通用样式文件
  • input-number 用于存放 input-number 组件的所有源码
  • input-number/index.jsx 作为组件的入口文件
  • input-number/style 用于存放组件的所有样式文件
  • input-number/style/index.js 组件样式的入口文件(该组件引入组件所有需要的样式文件,之后配合 babel-plugin-import 实现按需加载功能 )
  • __tests__ 用于存放组件的单元测试
├── components
│   ├── assets
│   │   ├── iconfont
│   │   └── style
│   ├── index.js
│   └── input-number
│       ├── index.jsx
│       ├── style
│       │   ├── index.js
│       │   └── index.scss
│       └── __tests__
....

3.2 测试组件 input-number 编写

  • 编写 components/input-number/index.jsx
import React from 'react';

export default () => {
  return (
    <div className="qyrc-input-num">
      测试组件: input-number
    </div>
  );
};

  • 编写 components/input-number/style/index.scss
.qyrc-input-num {
  color: #999;
}
  • 编写 components/input-number/style/index.js
// 如果组件需要额外样式文件则需要一同引入
import './index.scss';
  • 编写 components/index.js 导出组件
export { default as InputNumber } from './input-number';
  • 在 storybook 中对组件进行测试

在 stories/pages/base/Introduceindex.jsx 引用 InputNumber 组件,并运行项目对组件进行测试

import React from 'react';
import './index.css';
import './index.scss';
// 引入 InputNumber 组件和样式
import { InputNumber } from '../../../../components';
import '../../../../components/input-number/style/index';

export default () => {
  return (
    <div>
      react 组件介绍
      <InputNumber />
    </div>
  );
};

3.3 配置脚本对组件进行编译打包

对于 npm 包我们在发布时需要对所需要发布的文件进行简单的编译和打包, 对于我们的组件库我们常常需要将组件库编译为 ES 模块和 CommonJS 以及 UMD 模块。

ES 模块和 CommonJS 模块的编译方式大同小异, 都是通过 babel 针对组件库中 js 模块进行编译对于其他文件则只需进行简单拷贝,当然针对样式文件还需要额外编译一份 css 文件, 唯一区别的是打包后的 js 模块不同一个是 ES 模块一个是 CommonJS 模块,同时编译后的 ES 模块和 CommonJS 模块的目录结构需要满足 babel-plugin-import 原理,从而实现按需加载功能。

UMD 模块则是通过 webpack 针对 component 中入口文件进行完整的打包编译

3.3.1 通过 babel 对组件库中 js 模块进行编译

  • 安装所需依赖
npm install cross-env @babel/cli -D
  • 编写 npm 脚本: 针对组件库中的 js 模块编译为 ES 模块和 CommonJS 模块
{
  "scripts": {
+   "build:lib": "cross-env OUTPUT_MODULE=commonjs babel components -d lib --ignore **/__tests__",
+   "build:es": "babel components -d es --ignore **/__tests__"
  }
}

3.3.2 通过 gulp 对组件库中进行处理

  • 安装所需依赖
npm install gulp -D
npm install gulp-sass -D
npm install gulp-concat -D
npm install gulp-autoprefixer -D
npm install gulp-cssnano -D
npm install gulp-filesize -D
npm install gulp-sourcemaps -D
npm install gulp-rename -D
npm install gulp-replace -D
  • 创建 scripts/gulpfile.js
/**
 * @name gulpfile.js
 * @description 打包项目css依赖
 * @description 参考 cuke-ui
 */

const fs = require('fs');
const path = require('path');
const gulp = require('gulp');
const concat = require('gulp-concat');
const sass = require('gulp-sass');
const autoprefixer = require('gulp-autoprefixer');
const cssnano = require('gulp-cssnano');
const size = require('gulp-filesize');
const sourcemaps = require('gulp-sourcemaps');
const rename = require('gulp-rename');
const replace = require('gulp-replace');
const { name } = require('../package.json');

const browserList = [
  'last 2 versions',
  'Android >= 4.0',
  'Firefox ESR',
  'not ie < 9'
];

const DIR = {
  // 输入目录
  scss: path.resolve(__dirname, '../components/**/*.scss'),
  buildSrc: path.resolve(__dirname, '../components/**/style/*.scss'),
  style: path.resolve(__dirname, '../components/**/style/index.js'),
  
  // 输入目录
  lib: path.resolve(__dirname, '../lib'),
  es: path.resolve(__dirname, '../es'),
  dist: path.resolve(__dirname, '../dist')
};

// 拷贝 scss 文件
gulp.task('copyScss', () => {
  return gulp
    .src(DIR.scss)
    .pipe(gulp.dest(DIR.lib))
    .pipe(gulp.dest(DIR.es));
});

// 对 scss 进行编译后拷贝
gulp.task('copyCss', () => {
  return gulp
    .src(DIR.scss)
    .pipe(sourcemaps.init())
    .pipe(sass())
    .pipe(autoprefixer({ browsers: browserList }))
    .pipe(size())
    .pipe(cssnano())
    .pipe(gulp.dest(DIR.lib))
    .pipe(gulp.dest(DIR.es));
});

// 创建 style/css.js
gulp.task('createCss', () => {
  return gulp
    .src(DIR.style)
    .pipe(replace(/\.scss/, '.css'))
    .pipe(rename({ basename: 'css' }))
    .pipe(gulp.dest(DIR.lib))
    .pipe(gulp.dest(DIR.es));
});

// 编译打包所有组件的样式至 dis 目录
gulp.task('dist', () => {
  return gulp
    .src(DIR.buildSrc)
    .pipe(sourcemaps.init())
    .pipe(sass())
    .pipe(autoprefixer({ browsers: browserList }))
    .pipe(concat(`${name}.css`))
    .pipe(size())
    .pipe(gulp.dest(DIR.dist))
    .pipe(sourcemaps.write())
    .pipe(rename(`${name}.css.map`))
    .pipe(size())
    .pipe(gulp.dest(DIR.dist))
    .pipe(cssnano())
    .pipe(concat(`${name}.min.css`))
    .pipe(size())
    .pipe(gulp.dest(DIR.dist))
    .pipe(sourcemaps.write())
    .pipe(rename(`${name}.min.css.map`))
    .pipe(size())
    .pipe(gulp.dest(DIR.dist));
});

gulp.task('default', gulp.parallel(
  'dist',
  'copyCss',
  'copyScss',
  'createCss',
));
  • 添加 npm 脚本
{
  "scripts": {
+   "build:css": "cd scripts && gulp",
    "build:lib": "cross-env OUTPUT_MODULE=commonjs babel components -d lib --ignore **/__tests__",
    "build:es": "babel components -d es --ignore **/__tests__"
  }
}

3.3.3 通过 webpack 对组件库进行 UMD 模块 打包

  • 相关依赖包安装
npm install uglifyjs-webpack-plugin -D
npm install optimize-css-assets-webpack-plugin -D
npm install mini-css-extract-plugin -D
npm install progress-bar-webpack-plugin -D
  • 创建 scripts/build.umd.js
/**
 * @name UMD 模块 打包
 * @description 参考 cuke-ui
 * @description 输出目录 [dist]
 * CMD Node.js 环境
 * AMD 浏览器环境
 * UMD 两种环境都可以执行
 */

const fs = require("fs");
const path = require("path");
const webpack = require("webpack");
const UglifyJsPlugin = require("uglifyjs-webpack-plugin");
const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin");
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const ProgressBarPlugin = require('progress-bar-webpack-plugin');

const { version, name, description } = require("../package.json");

const LOGO = `
              __                    _
  _______  __/ /_____        __  __(_)
 / ___/ / / / //_/ _ \\______/ / / / /
/ /__/ /_/ / ,< /  __/_____/ /_/ / /  
\\___/\\__,_/_/|_|\\___/     \\__,_/_/

`

const config = {
  mode: "production",
  entry: {
    [name]: ["./components/index.js"]
  },

  //umd 模式打包
  output: {
    library: name,
    libraryTarget: "umd",
    umdNamedDefine: true, // 是否将模块名称作为 AMD 输出的命名空间
    path: path.join(process.cwd(), "dist"),
    filename: "[name].min.js"
  },
  //react 和 react-dom 不打包
  externals: {
    react: {
      root: "React",
      commonjs2: "react",
      commonjs: "react",
      amd: "react"
    },
    "react-dom": {
      root: "ReactDOM",
      commonjs2: "react-dom",
      commonjs: "react-dom",
      amd: "react-dom"
    }
  },
  resolve: {
    enforceExtension: false,
    extensions: [".js", ".jsx", ".json", ".less", ".css"]
  },
  module: {
    rules: [
      {
        test: /\.js[x]?$/,
        use: [
          {
            loader: "babel-loader"
          }
        ],
        exclude: "/node_modules/",
        include: [path.resolve("components")]
      },
      {
        test: /\.(le|c)ss$/,
        use: [
          MiniCssExtractPlugin.loader,
          "css-loader",
          { loader: "postcss-loader", options: { sourceMap: false } },
          {
            loader: "sass-loader",
            options: {
              sourceMap: false
            }
          }
        ]
      },
      {
        test: /\.(jpg|jpeg|png|gif|cur|ico)$/,
        use: [
          {
            loader: "file-loader",
            options: {
              name: "images/[name][hash:8].[ext]" //遇到图片  生成一个images文件夹  名字.后缀的图片
            }
          }
        ]
      }
    ]
  },
  optimization: {
    minimizer: [
      new UglifyJsPlugin({
        cache: true,
        parallel: true,
        uglifyOptions: {
          compress: {
            drop_debugger: true,
            drop_console: false
          },
        }
      }),
      new OptimizeCSSAssetsPlugin({
        // 压缩css  与 ExtractTextPlugin 配合使用
        cssProcessor: require("cssnano"),
        cssProcessorOptions: { discardComments: { removeAll: true } }, // 移除所有注释
        canPrint: true // 是否向控制台打印消息
      })
    ],
    noEmitOnErrors: true,
  },
  plugins: [
    new ProgressBarPlugin(),
    new MiniCssExtractPlugin({
      filename: "[name].min.css"
    }),
    // 在打包的文件之前 加上版权说明
    new webpack.BannerPlugin(` \n ${name} v${version} \n ${description} \n ${LOGO}\n`),
    new webpack.DefinePlugin({
      "process.env.NODE_ENV": JSON.stringify("production"),
      __DEBUG__: false,
    }),
    new webpack.LoaderOptionsPlugin({
      minimize: true
    }),
    new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
  ]
};

module.exports = config;

  • 添加 npm 脚本
{
  "scripts": {
+   "build:publish": "npm run build:lib && npm run build:es && npm run build:css && npm run build:umd",
+   "build:umd": "webpack --config ./scripts/build.umd.js",
    "build:css": "cd scripts && gulp",
    "build:lib": "cross-env OUTPUT_MODULE=commonjs babel components -d lib --ignore **/__tests__",
    "build:es": "babel components -d es --ignore **/__tests__"
  }
}

3.4 修改 package.json

  • private 设置项目是否是私有包
  • files 设置在发布包时需要发布的文件和目录
  • main 设置包的入口文件
  • module 设置 npm 包的模块入口
  • peerDependencies 设置 npm 包同等依赖包
{
+ "private": false,
+ "files": [
+   "lib",
+   "es",
+   "dist",
+   "LICENSE"
+ ],
+ "main": "lib/index.js",
+ "module": "es/index.js",
+ "peerDependencies": {
+   "react": ">=16.8.0",
+   "react-dom": ">=16.8.0"
+ },
}

3.4 组件发布

  • 组件库编译
npm run build:publish
  • 组件发布
# 1. 切换官方源头
npm config set registry http://registry.npmjs.org

# 2. 登录 npm
npm login

# 3. 发布包
npm publish --access public

# 4. 如果需要则切换回淘宝源
npm config set registry https://registry.npm.taobao.org/

四、 其他配置

4.1 commit 校验以及版本发布配置

  • 依赖包安装
# husky 包安装
npm install husky --save-dev

# commitlint 所需包安装
npm install @commitlint/config-angular @commitlint/cli --save-dev

# commitizen 包安装
npm install commitizen --save-dev
npm install commitizen -g

# standard-version 包安装
npm install standard-version --save-dev
  • commitlint 和 commitizen 配置
# 生成 commitlint 配置文件
echo "module.exports = {extends: ['@commitlint/config-angular']};" > commitlint.config.js
# commitizen 初始化
commitizen init cz-conventional-changelog --save-dev --save-exact
  • 更新 package.json
{
  "scripts": {
+   "commit": "git-cz",
+   "release": "standard-version"
  },
+ "husky": {
+   "hooks": {
+     "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
+   }
+ }
}

4.2 editorconfig 配置

  • 项目下新增 .editorconfig 配置文件
# http://editorconfig.org
root = true

[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

[*.md]
trim_trailing_whitespace = false

[Makefile]
indent_style = tab

Group 3143