使用storybook开发自己的React组件库

ohanahibi
有时候并不希望使用第三方组件库,以及大部分时候需要自己定制一套组件实现,以供公司或项目内部使用。开发自己的组件有如重复造轮子,会花费相当多的精力制造。如果是为了一种长远公司发展,这份努力是值得的,如果不想自己耗费精力,也有许多开源的组件实现,比如material-ui。一般都是拿来改造一下,可以避免很多开发或兼容性上的坑。

Architecture

编写这篇文章时,笔者用的都是最新的包以及依赖,坑比较多,比如webpack4,改掉以及删除很多功能;又如babel7弃用了旧的写法,全部带上了scoped packages,即在presets前面带上模块标志:

1
2
3
4
module.exports = {
presets: ["@babel/env"], // "@babel/preset-env"
plugins: ["@babel/transform-arrow-functions"], // same as "@babel/plugin-transform-arrow-functions"
};

主要关键技术:

  • webpack
  • typescript
  • react
  • state management(redux or mobx)
  • react-router-4
  • component hot reload
  • svg icon
  • jest
  • UI lib
  • less
  • eslint

主要库以及版本:

  • webpack 4.x.x
  • typescript 2.9.x
  • react 16.7.x
  • react-router-dom 4.2.x
  • react-hot-loader 4.6.x
  • node 10.13.x

注意:react-router v4为第四代react-router,react-dom为v2或v3的旧版本。新版本的react-router v4可以说是重写了路由,react-router v4 被一分为三:react-router-dom(for web)、react-router-native(for native)、react-router(core)。仅在浏览器中使用的话,一般引入react-router-dom即可。如果从旧版本迁移,可以参考这里

目录结构:

1
2
3
4
5
6
7
8
9
10
11
--.storybook
--public 模板目录
--src
--components 自定义组件
--docs 代码入口<App/>
--stories 文档说明
eslintrc.yml
tsconfig.json
.babelrc
package.json 依赖包
webpack.config.js

Babel

Babel用于将浏览器不能识别的ES以及TS转换为JS,首先添加依赖:

1
yarn add --dev @babel/core @babel/cli @babel/preset-env @babel/preset-react @babel/preset-typescript @babel/plugin-proposal-object-rest-spread @babel/plugin-transform-runtime

然后在根目录添加.babelrc,

1
2
3
4
5
6
7
8
9
10
11
{
"presets": [
"@babel/preset-env",
"@babel/preset-react",
"@babel/preset-typescript"
],
"plugins": [
"@babel/plugin-proposal-object-rest-spread",
"@babel/plugin-transform-runtime"
]
}

Unit Test

添加单元测试,单测默认识别目录__tests__,例如

1
2
3
4
5
6
7
--src
--components
--Link
--__tests__
Link.react.test.js
Link.react.js
index.js

首先,使用TypeScript编写一个组件,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import React from 'react';
import PropTypes from 'prop-types';

const STATUS = {
HOVERED: 'hovered',
NORMAL: 'normal',
};

export class MLink extends React.Component {
constructor(props) {
super(props);

this._onMouseEnter = this._onMouseEnter.bind(this);
this._onMouseLeave = this._onMouseLeave.bind(this);

this.state = {
class: STATUS.NORMAL,
};
}

_onMouseEnter() {
this.setState({ class: STATUS.HOVERED });
}

_onMouseLeave() {
this.setState({ class: STATUS.NORMAL });
}

render() {
return (
<a
className={this.state.class}
href={this.props.page || '#'}
onMouseEnter={this._onMouseEnter}
onMouseLeave={this._onMouseLeave}
>
{this.props.children}
</a>
);
}
}

MLink.propTypes = {
children: PropTypes.node.isRequired,
page: PropTypes.node,
};

对应其__tests__目录添加单元测试,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// MLink.react.test.js
import React from 'react';
import { MLink } from '../Link.react';
import renderer from 'react-test-renderer';

test('MLink changes the class when hovered', () => {
const component = renderer.create(
<MLink page="http://www.facebook.com">Facebook</MLink>,
);
let tree = component.toJSON();
expect(tree).toMatchSnapshot();

// manually trigger the callback
tree.props.onMouseEnter();
// re-rendering
tree = component.toJSON();
expect(tree).toMatchSnapshot();

// manually trigger the callback
tree.props.onMouseLeave();
// re-rendering
tree = component.toJSON();
expect(tree).toMatchSnapshot();
});

把单元测试所需的配置补充上,添加单元测试所需要的依赖

1
yarn add --dev jest @types/jest babel-jest ts-jest

另外还需要一个Enzyme,它是React的测试工具,还需要enzyme-to-json 转换,

1
yarn add --dev jest react-test-renderer enzyme enzyme-adapter-react-16 enzyme-to-json

更新一下package.json,加上对应的脚本

1
2
3
4
5
6
7
8
9
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"jest": {
"setupFiles": ["./test/jestsetup.js"],
"snapshotSerializers": ["enzyme-to-json/serializer"]
}

创建一个test/jestsetup.js文件,自定义测试环境

1
2
3
4
5
6
7
8
import Enzyme, { shallow, render, mount } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
// React 16 Enzyme adapter
Enzyme.configure({ adapter: new Adapter() });
// Make Enzyme functions available in all test files without importing
global.shallow = shallow;
global.render = render;
global.mount = mount;

对于CSS模块,添加以下配置到package.json

1
2
3
4
5
"jest": {
"moduleNameMapper": {
"^.+\\.(css|scss)$": "identity-obj-proxy"
}
}

运行命令进行测试

1
jest --env=jsdom --coverage --no-cache --detectOpenHandles

jest可以测试的特性有:

  • component render
  • props
  • events
  • event handlers

更多例子可以参考这里

ESLint

首先加入依赖:

1
yarn add --dev eslint eslint-config-airbnb-base eslint-loader eslint-plugin-import eslint-plugin-react typescript-eslint-parser

ESLint用于代码检查用,配置也比较无脑,当你用像WebStorm这些开发工具时,工具会自动检测到ESLint,自动为你格式化和检查代码。下面是我自己的一份配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
env:
browser: true
commonjs: true
es6: true
jest: true
ecmaFeatures:
modules: true
spread: true
restParams: true
extends:
- 'eslint:recommended'
- 'plugin:react/recommended'
parser: 'babel-eslint'
parserOptions:
ecmaFeatures:
jsx: true
ecmaVersion: 2018
sourceType: module
plugins:
- react
rules:
indent:
- error
- 2
linebreak-style:
- error
- windows
quotes:
- error
- single
semi:
- error
comma-dangle: off
no-unused-vars: warn
no-console: error
no-unexpected-multiline: warn
import/prefer-default-export: off
settings:
react:
pragma: React
version: detect

像VSCode,可以在首选项 -> 设置 中找到eslint.validate,加入typescript与typescriptreact,分别用于监听ts与tsx文件,如下

1
2
3
4
5
6
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
]

更多配置可以自己定制。

Storybook

storybook的主要作用是为自己实现的组件编写文档,可以在这里查看更多细节。

安装storybook:

1
yarn add --dev @storybook/react

加入package.json的script

1
2
3
4
5
{
"scripts": {
"storybook": "start-storybook -p 9001 -c .storybook"
}
}

默认storybook会在.storybook文件夹作为配置,配置文件为config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
// .storybook/config.js中修改路径
import { configure } from '@storybook/react';
// src/stories/button.stories.js
import React from 'react';

const req = require.context('../src/stories', true, /\.stories\.js$/);


function loadStories() {
req.keys().forEach((filename) => req(filename));
}

configure(loadStories, module);

上面配置指定了创建stories的位置,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// .storybook/config.js中修改路径
import { configure } from '@storybook/react';

function loadStories() {
require('../src/stories/button.stories.js');
}

configure(loadStories, module);

// src/stories/button.stories.js
import React from 'react';
import { storiesOf } from '@storybook/react';
import {Button} from '../components/Button';

storiesOf('Button', module)
.add('基本用法',() => (
<Button>按钮</Button>
))

// src/components/Button.js
import React from 'react'

export class Button extends React.Component{
constructor (props) {
super(props)
}

render () {
return (
<button style={{backgroundColor: '#fff', border: '1px solid #ccc'}}>{this.props.children}</button>
)
}
}

启动stroybook服务

1
yarn storybook

storybook

默认会自动打开浏览器

storybook

出现以上页面说明配置已经成功了。

Live Editing

默认地,webpack的development mode在每次更新代码时,会自动刷新页面。但刷新不同于Live Edit,我不希望编写一个CSS还要重新refresh一下,页面直接变化就最好了!有很多工具可以实现这种方式,这里仅介绍react-hot-loader:

1
yarn add --dev react-hot-loader

在script里面加入一个--hot选项即可。

1
2
3
"scripts": {
"start": "./node_modules/.bin/webpack-dev-server --hot --mode development --progress --colors --config ./webpack.config.js",
},

出现以下效果,说明Live Editing功能实现了。

Summary

由于使用的东西比较多,而且开发的方式也比较灵活,具体技术细节可以具体深入理解。

完整代码,可以参考我的github