TypeScript + Sass + Storybook + Rollup 开发 react 组件库

2,142 阅读3分钟

这篇文章是我对开发 react-notion-block 库的一个记录总结。这边文章里不会涉及到具体代码,而是关注构建 React 组件库的基本流程。

概述

react-notion-block 组件库的开发流程,包含了如下特性:

  • 基于 React
  • 基于 Sass
  • 基于 TypeScript (支持 TypeScript types)
  • 基于 Storybook 在沙盒环境中展示和使用组件
  • 对组件进行完整的测试 (使用 Jest/React 测试库)
  • 最终作为单个 NPM 包发布

创建组件库

首先,初始化 npm 项目 npm init 。

接着,安装 react 库,npm i -D react react-dom @types/react @types/react-dom 。

最后,将 react 和 react-dom 配置为 Peer Dependencies。(保证安装开发的组件库时, react 和 react-dom 会作为 项目的 dependencies 安装)。

...
"peerDependencies": {
"react": ">=17.0.2",
"react-dom": ">=17.0.2"
}
...

配置 TypeScript

运行 npm i -D typescript @types/node ,配置 tsconfig.json 文件,一些具体配置的意义可以参考 这里

{
    "include": [
        "src/**/*"
    ],
    "compilerOptions": {
        "declaration": true,
        "declarationDir": "build",
        "target": "ES2019",
        "strict": true,
        "lib": [
            "DOM",
            "DOM.Iterable",
            "ES2019"
        ],
        "sourceMap": true,
        "jsx": "react-jsx",
        "allowSyntheticDefaultImports": true,
        "moduleResolution": "node"
    },
    "exclude": [
        "node_modules",
        "build",
        "src/**/*.stories.tsx",
        "src/**/*.test.tsx"
    ]
}

其中,declaration 和 declarationDir 指明了在构建文件夹中生成组件类型文件

配置 Rollup

安装依赖

npm i -D rollup \
         rollup-plugin-peer-deps-external \
         rollup-plugin-terser \
         @rollup/plugin-node-resolve \
         rollup-plugin-typescript2 \
         @rollup/plugin-commonjs \
         rollup-plugin-postcss \
         node-sass
  • rollup

  • rollup-plugin-peer-deps-external - 打包时排除 peer dependencies,减小组件库的体积

  • rollup-plugin-terser - 缩小生成的 es 包

  • @rollup/plugin-node-resolve - 打包第三方依赖模块

  • rollup-plugin-typescript2 - 将 TypeScript 文件转换为 JavaScript。设置 "useTsconfigDeclarationDir": true 输出指定目录的 .d.ts 文件

  • @rollup/plugin-commonjs - 转换为 CommonJS (CJS) 格式

  • rollup-plugin-postcss - 将 Sass 转换为 CSS,配合 node-sass 一起使用

  • node-sass

配置 rollup.config.js 文件

import peerDepsExternal from "rollup-plugin-peer-deps-external";
import resolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import typescript from "rollup-plugin-typescript2";
import postcss from "rollup-plugin-postcss";
import { terser } from "rollup-plugin-terser";

const packageJson = require("./package.json");

export default {
    input: "src/index.ts",
    output: [
        {
            file: packageJson.main,
            format: "cjs",
            sourcemap: true
        },
        {
            file: packageJson.module,
            format: "esm",
            sourcemap: true
        }
    ],
    plugins: [
        peerDepsExternal(),
        resolve(),
        commonjs(),
        typescript({ useTsconfigDeclarationDir: true }),
        postcss(),
        terser()
    ]
};

注意这里指定了两个 output

  • CommonJS - CJS

  • ES Modules - ESM

并在 package.json 中

  • main 字段指向 CommonJS 入口点

  • module 字段指向 ES Modules 入口点

  • 以及 Rollup 运行命令

...
"main": "build/index.js",
"module": "build/index.es.js",
"files": ["build"],
...
"scripts": {
    ...
    "build": "rollup -c", 
  }
...

配置 Storybook

运行 npx storybook init

npm install -D @storybook/preset-scss sass
npm install -D html-webpack-plugin
npm uninstall node-sass
npm install -D \
	css-loader@5 \
	sass-loader@10 \
	style-loader@2
npm install -D node-sass

配置 .storybook/main.js

module.exports = {
	...
  addons: [
		...
		'@storybook/preset-scss'
	],
};

说明:

  • 当前,storybook 与 css-loader,sass-loader,style-loader有兼容问题,所以限制这三个库的版本

  • 最新的node-sass要求 sass-loader 11 以上,所以重新安装

运行 npm run storybook

配置 Jest & React Testing Library

安装依赖库

npm i -D jest \
  ts-jest \
  @types/jest \
  identity-obj-proxy \
  @testing-library/react@12 \
  @testing-library/jest-dom \
  jest-environment-jsdom

配置 jest.config.js 文件

module.exports = {
    roots: ["./src"],
    setupFilesAfterEnv: ["./jest.setup.ts"],
    moduleFileExtensions: ["ts", "tsx", "js"],
    testPathIgnorePatterns: ["node_modules/"],
    transform: {
        "^.+\.tsx?$": "ts-jest"
    },
    testEnvironment: "jsdom",
    testMatch: ["**/*.test.(ts|tsx)"],
    moduleNameMapper: {
        // Mocks out all these file formats when tests are run
        "\.(jpg|ico|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$":
            "identity-obj-proxy",
        "\.(css|less|scss|sass)$": "identity-obj-proxy"
    }
};

配置 jest.setup.ts 文件

import "@testing-library/jest-dom";

配置 package.json 文件

...
"scripts":
    {
        ....
        "test": "jest",
        "test:watch": "jest --watch",
        ....
    }
...

运行 npm run test:watch

测试代码

最终项目的文件结构,可以参考 这里 如下:

.storybook/
  main.js
  preview.js
.gitignore
jest.config.js
jest.setup.ts
LICENSE
package.json
rollup.config.js
tsconfig.json
# TODO
src/     
  TestComponent/
    TestComponent.tsx
    TestComponent.types.ts
    TestComponent.scss
    TestComponent.stories.tsx
    TestComponent.test.ts
  index.ts

以下是测试的代码,可以运行 npm run storybook 和 npm run test:watch 查看效果

// TestComponent.types.ts
export interface TestComponentProps {
    theme: "primary" | "secondary";
}
// TestComponent.scss
.test-component {
    background-color: white;
    border: 1px solid black;
    padding: 16px;
    width: 360px;
    text-align: center;

    .heading {
        font-size: 64px;
    }

    &.test-component-secondary {
        background-color: black;
        color: white;
    }
}
// TestComponent.tsx
import React from "react";

import { TestComponentProps } from "./TestComponent.types";

import "./TestComponent.scss";

const TestComponent: React.FC<TestComponentProps> = ({ theme }) => (
    <div
        data-testid="test-component"
        className={`test-component test-component-${theme}`}
    >
        <h1 className="heading">I'm the test component</h1>
        <h2>Made with love by Harvey</h2>
    </div>
);

export default TestComponent;
// TestComponent.stories.tsx
import React from "react";
import TestComponent from './TestComponent';

export default {
    title: "TestComponent"
};

export const Primary = () => <TestComponent theme="primary" />;

export const Secondary = () => <TestComponent theme="secondary" />;
// TestComponent.test.ts
import React from "react";
import { render } from "@testing-library/react";

import TestComponent from "./TestComponent";
import { TestComponentProps } from "./TestComponent.types";


describe("Test Component", () => {
    let props: TestComponentProps;

    beforeEach(() => {
        props = {
            theme: "primary"
        };
    });

    const renderComponent = () => render(<TestComponent {...props} />);

    it("should have primary className with default props", () => {
        const { getByTestId } = renderComponent();

        const testComponent = getByTestId("test-component");

        expect(testComponent).toHaveClass("test-component-primary");
    });

    it("should have secondary className with theme set as secondary", () => {
        props.theme = "secondary";
        const { getByTestId } = renderComponent();

        const testComponent = getByTestId("test-component");

        expect(testComponent).toHaveClass("test-component-secondary");
    });
});
// index.ts
import TestComponent from "./TestComponent/TestComponent";

export { TestComponent };

发布

npm run build
npm publish

关于 react-notion-block 库

react-notion-block 库 是用来渲染 Notion 数据的一个库,支持 Notion 官方 API 的数据类型。使用这个库,我搭建了自己的技术博客

参考文章