使用react搭建組件庫:react+typescript+storybook

1. 安裝組件庫

執行安裝命令javascript

$ npx create-react-app echo-rui  --typescript
複製代碼

2. 組件庫配置eslint

  • 配置ESlint 新建.eslintrc.json文件
{
  "extends": "react-app"
}
複製代碼

新建 .vscode/settings.json文件css

{
  "eslint.validate": [
    "javascript",
    "javascriptreact",
    { "language": "typescript", "autoFix": true },
    { "language": "typescriptreact", "autoFix": true }
  ]
}
複製代碼

3. 引入依賴

在組件中使用 classnamegithub.com/jedWatson/c… 執行安裝命令java

$ npm install classnames -D
$ npm install @types/classnames -D
$ npm install node-sass -D
複製代碼

使用方法示例: 若是對象的key值是變化的,能夠採用下面的中括號的形式:[btn-${btnType}]node

// btn, btn-lg, btn-primary
const classes = classNames('btn', className, {
   [`btn-${btnType}`]: btnType,
   [`btn-${size}`]: size,
   'disabled': (btnType === 'link') && disabled
})
複製代碼

4. 編寫組件

  • 新建 src/components/Button/button.tsx
import React,{FC,ButtonHTMLAttributes,AnchorHTMLAttributes} 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;
  /** 當btnType爲link時,必填 */
  href?: string;
}

// 並集
type NativeButtonProps = BaseButtonProps &
  ButtonHTMLAttributes<HTMLElement>;
type AnchorButtonProps = BaseButtonProps &
  AnchorHTMLAttributes<HTMLElement>;

// Partial:typescript全局函數,將屬性所有變成可選的
export type ButtonProps = Partial<NativeButtonProps & AnchorButtonProps>;

// 使用react-docgen-typescript-loader的bug,只能使用FC,不能React.FC
export const Button: FC<ButtonProps> = (props) => {
  const {
    disabled,
    size,
    btnType,
    children,
    href,
    className,
    ...resetProps
  } = props;

  const classes = classnames("echo-btn", className, {
    [`echo-btn-${btnType}`]: btnType,
    [`echo-btn-${size}`]: size,
    "echo-button-disabled": btnType === "link" && disabled,
  });
  if (btnType === "link" && href) {
    return (
      <a href={href} className={classes} {...resetProps}>
        {children}
      </a>
    );
  } else {
    return (
      <button className={classes} disabled={disabled} {...resetProps}>
        {children}
      </button>
    );
  }
};

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

export default Button;



複製代碼
  • 新建 src/components/Button/index.tsx
import Button from "./button";

export default Button;
複製代碼
  • 新建 src/components/Button/_style.scss
@import "../../styles/variables/button";
@import "../../styles/mixin/button";
.echo-btn {
  position: relative;
  display: inline-block;
  font-weight: $btn-font-weight;
  line-height: $btn-line-height;
  color: $body-color;
  white-space: nowrap;
  text-align: center;
  vertical-align: middle;
  background-image: none;
  border: $btn-border-width solid transparent;
  @include button-size(
    $btn-padding-y,
    $btn-padding-x,
    $btn-font-size,
    $btn-border-radius
  );
  box-shadow: $btn-box-shadow;
  cursor: pointer;
  transition: $btn-transition;
  &.echo-button-disabled
  &[disabled] {
    cursor: not-allowed;
    opacity: $btn-disabled-opacity;
    box-shadow: none;
    > * {
      pointer-events: none;
    }
  }
}

.echo-btn-lg {
  @include button-size(
    $btn-padding-y-lg,
    $btn-padding-x-lg,
    $btn-font-size-lg,
    $btn-border-radius-lg
  );
}

.echo-btn-sm {
  @include button-size(
    $btn-padding-y-sm,
    $btn-padding-x-sm,
    $btn-font-size-sm,
    $btn-border-radius-sm
  );
}

.echo-btn-primary {
  @include button-style($primary, $primary, $white);
}

.echo-btn-danger {
  @include button-style($danger, $danger, $white);
}

.echo-btn-default {
  @include button-style(
    $white,
    $gray-400,
    $body-color,
    $white,
    $primary,
    $primary
  );
}

.echo-btn-link {
  font-weight: $font-weight-normal;
  color: $btn-link-color;
  text-decoration: $link-decoration;
  box-shadow: none;
  &:hover {
    color: $btn-link-hover-color;
    text-decoration: $link-hover-decoration;
  }
  &:focus {
    text-decoration: $link-hover-decoration;
    box-shadow: none;
  }
  &:disabled,
  &.echo-button-disabled {
    color: $btn-link-disabled-color;
    pointer-events: none;
  }
}
複製代碼
  • 新建 styles/variables/button.scss文件
@import "./common";
// 按鈕基本屬性
$btn-font-weight: 400;
$btn-padding-y: 0.375rem !default;
$btn-padding-x: 0.75rem !default;
$btn-font-family: $font-family-base !default;
$btn-font-size: $font-size-base !default;
$btn-line-height: $line-height-base !default;
...
...
複製代碼
  • 新建 styles/mixin/button.scss`文件
@mixin button-size($padding-y, $padding-x, $font-size, $border-raduis) {
  padding: $padding-y $padding-x;
  font-size: $font-size;
  border-radius: $border-raduis;
}

@mixin button-style(
  $background,
  $border,
  $color,
  // lghten,sass內置函數,比$background顏色要淺上7.5%
    $hover-background: lighten($background, 7.5%),
  $hover-border: lighten($border, 10%),
  $hover-color: $color
) 
...
...
複製代碼
  • src/styles/index.scss文件中引入組件樣式
// index文件主要是引入全部組件的樣式。
// 不須要寫_,這是sass的一種寫法,告訴sass這些樣式不打包到css中,只能作導入,也是一種模塊化

// 按鈕樣式
@import "../components/Button/style";

複製代碼
  • 新建 src/styles/index.scss文件
// index文件主要是引入全部組件的樣式。
// 不須要寫_,這是sass的一種寫法,告訴sass這些樣式不打包到css中,只能作導入,也是一種模塊化

// 按鈕樣式
@import "../components/Button/style";
...

複製代碼

5. 刪除多餘文件+引用組件

  • 刪除src/App.css + src/logo.svg + src/index.css + src/App.test.js + serviceWorker.ts文件
  • 修改App.tsx文件
import React from "react";
import "./styles/index.scss";
import Button, { ButtonType, ButtonSize } from "./components/Button/button";

function App() {
  return (
    <div className="App">
      <Button>hello</Button>
      <Button disabled>hello</Button>
      <Button btnType="primary" size="sm">
        hello
      </Button>
      <Button
        btnType="danger"
        size="lg"
        onClick={() => {
          alert(111);
        }}
      >
        hello
      </Button>
     <Button
        btnType="link"
        href="http://www.baidu.com"
        target="_blank"
      >
        hello
      </Button>
      <Button disabled btnType="link" href="http://www.baidu.com">
        hello
      </Button>
    </div>
  );
}
export default App;
複製代碼
  • 修改src/index.tsx文件
export { default as Button } from "./components/Button";
複製代碼

6. 運行項目

執行命令react

$ npm start
複製代碼

訪問項目 能夠看到button組件成功了! webpack

image.png

##7.單元測試 新建src/Button/button.test.tsx文件git

import React from "react";
import { render, fireEvent } from "@testing-library/react";
import Button, { ButtonProps } from "./button";

describe("Button 組件", () => {
  it('默認Button', () => {
    const testProps: ButtonProps = {
      onClick: jest.fn(),
    }
    const wrapper = render(<Button {...testProps}>hello</Button>);
    const element = wrapper.getByText('hello')as HTMLButtonElement;
    // 元素是否被渲染在文檔中
    expect(element).toBeInTheDocument();
    // 判斷標籤名
    expect(element.tagName).toEqual("BUTTON");
     // 判斷是否有類名
     expect(element).toHaveClass("echo-btn-default");
     expect(element).not.toHaveClass("echo-disabled");
     //   觸發點擊事件
    fireEvent.click(element);
    expect(testProps.onClick).toHaveBeenCalled();
    expect(element.disabled).toBeFalsy();
  })
  it("測試傳入不一樣屬性的狀況", () => {
    const testProps: ButtonProps = {
      btnType: "primary",
      size: "lg",
      className: "test-name",
    };
    const wrapper = render(<Button {...testProps}>hello</Button>);
    const element = wrapper.getByText("hello") as HTMLButtonElement;
    expect(element).toBeInTheDocument();
    expect(element).toHaveClass("echo-btn-primary");
    expect(element).toHaveClass("echo-btn-lg");
    expect(element).toHaveClass("test-name");
  });

  it("測試當btnType爲link和href存在的狀況", () => {
    const testProps: ButtonProps = {
      btnType: "link",
      href: "http://www.baidu.com",
    };
    const wrapper = render(<Button {...testProps}>Link</Button>);
    const element = wrapper.getByText("Link") as HTMLAnchorElement;
    expect(element).toBeInTheDocument();
    expect(element.tagName).toEqual("A");
    expect(element).toHaveClass("echo-btn-link");
  });

  it("測試禁用的狀況", () => {
    const testProps: ButtonProps = {
      onClick: jest.fn(),
      disabled: true,
    };
    const wrapper = render(<Button {...testProps}>Disabled</Button>);
    const element = wrapper.getByText("Disabled") as HTMLButtonElement;
    expect(element).toBeInTheDocument();
    expect(element.disabled).toBeTruthy();
    fireEvent.click(element);
    expect(testProps.onClick).not.toHaveBeenCalled();
  });
});
複製代碼

執行命令github

$ npm test
複製代碼

能夠看到單元測試成功經過! web

image.png

##8.組件庫實現按需加載typescript

  • 安裝依賴
$npm install node-cmd -D
複製代碼
  • 新建 buildScss.js文件
const cmd = require("node-cmd");
const path = require("path");
const fs = require("fs");
const entryDir = path.resolve(__dirname, "./src/components");
const outputDir = path.resolve(__dirname, "./dist/components");
function getScssEntry() {
  let entryMap = {};
  fs.readdirSync(entryDir).forEach(function (pathName) {
    const entryName = path.resolve(entryDir, pathName);
    const outputName = path.resolve(outputDir, pathName);
    let entryFileName = path.resolve(entryName, "_style.scss");
    let outputFileName = path.resolve(outputName, "style/index.css");

    entryMap[pathName] = {};
    entryMap[pathName].entry = entryFileName;
    entryMap[pathName].output = outputFileName;
  });

  return entryMap;
}
const entry = getScssEntry();
let buildArr = [];
for (const key in entry) {
  const promise = new Promise((resolve, reject) => {
    cmd.get(`npx node-sass ${entry[key].entry} ${entry[key].output}`, function (
      err,
      data,
      stderr
    ) {
      if (err) {
        reject(err);
        return;
      }
      console.log("the current working dir is : ", data);
      fs.writeFileSync(
        path.join(__dirname, `./dist/components/${key}/style/css.js`),
        "import './index.css'"
      );
      resolve();
    });
  });
  buildArr.push(promise);
}

Promise.all(buildArr)
  .then(() => {
    console.log("build success");
  })
  .catch((e) => {
    console.log(e);
  });


複製代碼
  • 新建 _babel.config.js文件 libraryName:須要加載的庫,我這裏的是echo-rui libraryDirectory:須要加載的組件庫所在的目錄,當前的組件是存放在dist/components下的 style:加載的css類型,當前項目只有css,沒有less,因此這裏寫css便可,如需配置less,請看註釋
module.exports = {
  presets: ["react-app"],
  plugins: [
    [
      "import",
      {
        libraryName: "echo-rui", 
        camel2DashComponentName: false, // 是否須要駝峯轉短線
        camel2UnderlineComponentName: false, // 是否須要駝峯轉下劃線
        libraryDirectory: "dist/components",
        style: "css",
      },
    ],
    // ["import", {
    //   "libraryName": "antd",
    //   "libraryDirectory": "es",
    //   "style": "css" // `style: true` 會加載 less 文件
    // }]
  ],
};

複製代碼

⚠️⚠️⚠️重要說明:在開發的時候,每一個組件目錄下面必須有一個_style.scss樣式文件,即便他是個空文件也必須有,不然會在按需引入的時候報錯找不到css文件⚠️⚠️

##9.storybook文檔生成

  1. 初始化storyBook
$ npx -p @storybook/cli sb init
複製代碼
  1. 添加依賴和插件
$ npm install @storybook/addon-info --save-dev
複製代碼
  1. 添加npm腳本
"scripts": {
    ...
    "storybook": "start-storybook -p 9009 -s public",
    "build-storybook": "build-storybook -s public"
  },
複製代碼
  1. 配置storybook,支持typescript
$ npm install react-docgen-typescript-loader -D
複製代碼
  1. 添加storybook配置文件
  • 新建.storybook/webpack.config.js文件 shouldExtractLiteralValuesFromEnum:storybook爬取組件屬性的時候會自動把type類型的屬性自動展開。 propFilter:過濾掉不須要爬取的屬性的來源。
module.exports = ({ config }) => {
  config.module.rules.push({
    test: /\.tsx?$/,
    use: [
      {
        loader: require.resolve("babel-loader"),
        options: {
          presets: [require.resolve("babel-preset-react-app")]
        }
      }, 
      {
        loader: require.resolve("react-docgen-typescript-loader"),
        options: {
          shouldExtractLiteralValuesFromEnum: true,
          propFilter: (prop) => {
            if (prop.parent) {
              return !prop.parent.fileName.includes('node_modules')
            }
            return true            
          }
        }
      }
    ]
  });

  config.resolve.extensions.push(".ts", ".tsx");

  return config;
};
複製代碼
  • 新建.storybook/style.scss文件
// 這個文件主要是對storybook文檔樣式的配置
複製代碼
  • 新建.storybook/config.tsx文件 配置插件以及須要加載的文件
import { configure,addDecorator,addParameters } from '@storybook/react';
import { withInfo } from '@storybook/addon-info'


import '../src/styles/index.scss'
import './style.scss'
import React from 'react'

const wrapperStyle: React.CSSProperties = {
  padding: '20px 40px'
}

const storyWrapper = (stroyFn: any) => (
  <div style={wrapperStyle}>
    <h3>組件演示</h3>
    {stroyFn()}
  </div>
)
addDecorator(storyWrapper)
addDecorator(withInfo)
addParameters({info: { inline: true, header: false}})


const loaderFn = () => {
    const allExports = [];
    const req = require.context('../src/components', true, /\.stories\.tsx$/);
    req.keys().forEach(fname => allExports.push(req(fname)));
    return allExports;
  };

configure(loaderFn, module);

複製代碼
  1. 在每一個組件目錄下面新建一個.stories.tsx結尾的文件
  • button組件下新建button.stories.tsx文件
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';

import Button from './button'

const defaultButton = () => (
    <div>
        <Button onClick={action('default button')}>default button</Button>
    </div>
)

const buttonWithSize = () => (
    <div>
        <Button size='lg' btnType='primary' onClick={action('lg button')}>lg button</Button>
        <Button className='ml-20' size='sm' btnType='danger' onClick={action('sm button')}>sm button</Button>
    </div>
)


const buttonWithType = () => (
    <div>
        <Button onClick={action('danger button')} btnType='danger'>danger button</Button>
        <Button onClick={action('primary button')} className='ml-20' btnType='primary'>primary button</Button>
        <Button onClick={action('link')} className='ml-20' btnType='link' href='https://www.baidu.com/'>link</Button>
    </div>
)

const buttonWithDisabled = () => (
    <div>
        <Button onClick={action('disabled button')} btnType='danger' disabled={true}>disabled button</Button>
        <Button onClick={action('unDisabled button')} className='ml-20' btnType='primary'>unDisabled button</Button>
    </div>
)

// storiesOf('Button組件', module)
// .addDecorator(withInfo)
// .addParameters({
//     info:{
//         text:`
//         這是默認組件
//         ~~~js
//         const a = 12
//         ~~~
//         `,
//         inline:true
//     }
// })
//   .add('默認 Button', defaultButton)
//   .add('不一樣尺寸 Button', buttonWithSize,{info:{inline:false}})
//   .add('不一樣類型 Button', buttonWithType)

storiesOf('Button 按鈕', module)
    .addParameters({
        info: {
            text: `
        ## 引用方法
        ~~~js
        import {Button} from ecoh-rui
        ~~~
        `
        }
    })
    .add('默認 Button', defaultButton)
    .add('不一樣尺寸 Button', buttonWithSize)
    .add('不一樣類型 Button', buttonWithType)
    .add('禁用的 Button',buttonWithDisabled)

複製代碼

7.執行命令

$ npm run storybook
複製代碼

在終端能夠看到

image.png
瀏覽器打開 http://localhost:9009/,能夠看到組件庫文檔生成了。
image.png

10. typescript編譯配置

新建tsconfig.build.json文件

{
  "compilerOptions": {
    // 輸出路徑
    "outDir": "dist",
    // 打包模塊規範
    "module": "esnext",
    // 構建目標
    "target": "es5",
    // 生成定義文件d.ts
    "declaration": true,
    "jsx": "react",
    // 模塊引入策略
    "moduleResolution": "Node",
    // 容許import React from 'react'這樣導包
    "allowSyntheticDefaultImports": true
  },
  // 須要編譯的目錄
  "include": ["src"],
  // 不須要編譯的
  "exclude": ["src/**/*.test.tsx", "src/**/*.stories.tsx", "src/setupTests.ts"]
}

複製代碼

11. package.json相關配置

  • 將依賴包從dependencies搬到devDependencie

爲何要這麼作? 1、是防止發佈組件庫以後別人使用了跟咱們不同的react版本形成衝突 2、是咱們在開發的時候還須要使用到react和react-dom,因此不能刪除,只能搬到devDependencies 3、還有一些跟發佈後的組件庫不相關的依賴都須要搬到devDependencies,例如storybook等

// 嘿嘿,移動以後,發現爲空了,從新install 了一下
// 運行了npm start 和npm run test 和npm run storybook發現一切正常,開心一下!!
"dependencies": {},
複製代碼
  • 添加npm發佈相關配置
"description": "react components library",
"author": "echo",
"private": false,
// 主入口
"main": "dist/index.js",
// 模塊入口
"module": "dist/index.js",
// 類型文件聲明
"types": "dist/index.d.ts",
"license": "MIT",
// 關鍵詞
"keywords": [
    "React",
    "UI",
    "Component",
    "typescript"
  ],
// 首頁你的github地址
  "homepage": "https://github.com/.../echo-rui",
// 倉庫地址
  "repository": {
    "type": "git",
    "url": "https://github.com/.../echo-ruii"
  },
  // 須要上傳的文件,不寫就默認以.gitignore爲準
  "files": [
    "dist"
  ],
複製代碼
  • 代碼提交git前檢查。這裏使用husky這個工具
"husky": {
    "hooks": {
    // 用git提交代碼以前
      "pre-commit": "npm run test:nowatch && npm run lint"
    }
  },
"scripts": {
   ...
    "lint": "eslint --ext js,ts,tsx src --fix --max-warnings 5",
    "test:nowatch": "cross-env CI=true react-scripts test",
   ... 
複製代碼
  • 添加打包發佈相關配置
"scripts": {
   ...
    "clean": "rimraf ./dist",
    "build-ts": "tsc -p tsconfig.build.json",
    "build-css": "node-sass ./src/styles/index.scss ./dist/index.css",
    "build": "npm run clean && npm run build-ts && npm run build-css && node ./buildScss.js",
    "prepublishOnly": "npm run lint && npm run build"
  
   ... 
複製代碼

此時執行打包命令,就能夠成功根據配置打包了。

12. 發佈到npm

萬事俱備,只欠發佈。

  • 完善一下 README.md 文檔,這個隨便寫兩句就好
  • 在根目錄下新建一個 .npmignore 文件,內容和 .gitignore 差很少:
複製代碼

最後執行 npm login 登入 npm 帳號,再執行 npm publish 發佈便可,就這麼簡單的兩步就能夠,過一會在 npm 上就能搜到了。固然前提是你有個 npm 帳號,沒有的話去註冊一個吧,很 easy 的,而後還要搜下你的 npm 包名是否有人用,有的話就換一個。

總結:完美!

相關文章
相關標籤/搜索