Storybook:簡介、實例、填坑

1 什么是 Storybook

Storybook is an open source tool for developing UI components and pages in isolation. It simplifies building, documenting, and testing UIs.

Storybook 是一個開源工具,它能有組織和高效地構(gòu)建 UI 組件,文檔編制和測試,包括 React、Vue 和 Angular 。

特點:

  • 分開展示各個組件不同屬性下的狀態(tài);

  • 能追蹤組件的行為并且具有屬性調(diào)試的功能;

  • 可以為組件自動生成文檔和屬性列表;

2 安裝

根據(jù)官網(wǎng),本人使用的 react 項目,所以,直接控制臺運行如下命令,集成 Storybook:本人安裝當前最新版本為 "@storybook/react": "^6.2.9",

# Add Storybook:
npx sb init

安裝成功后,直接在控制臺運行如下命令,就可以看到啟動頁面:

# Starts Storybook in development mode
npm run storybook
成功啟動運行在 6006 端口

3 說明

  • 根目錄生成的 .storybook為 storybook 默認配置目錄;

  • src/stories 目錄為 storybook 頁面組件目錄;

  • 本人項目是 ts,安裝完成 storybook 后, storybook 頁面組件默認就是 tsx,無需再額外配置;

4 decorators

decorators 的作用主要是統(tǒng)一修飾組件展示區(qū)域的樣式,例如:設置組件展示都居中,或者是 margin、padding 的距離等等。

在對應的組件配置如下:例如(xxx.stories.tsx,組件展示區(qū)域都距離 1em 邊距)

export default {
  title: 'components/Button',
  component: Button,
  decorators: [(storyFn) => <div style={{ margin: '1em' }}>{storyFn()}</div>],
};

詳細配置,參考相關(guān)的官網(wǎng)說明文檔。

5 parameters

parameters 通常是用于控制 Storybook 功能和插件的行為。詳細配置,參考相關(guān)的官網(wǎng)說明文檔。

簡單給個 Story parameters 例子:

export default {
  title: 'components/Button',
  component: Button,
  decorators: [(storyFn) => <div style={{ margin: '1em' }}>{storyFn()}</div>],
  parameters: {
    docs: {
      source: {
        code: 'Some custom string here',
        state: true,
      }
    }
  }
};

6 注釋

storybook 解析的組件,只要注釋符合 JSDoc 標準,通過 docs 插件,目前安裝的版本,應該已經(jīng)集成了,組件就會被自動解析。

7 實例

說明:這只是個例子,樣式文件本人只是測試相關(guān)的 less 引用是否有問題,官網(wǎng) demo 給的示例,組件樣式是使用 css,使用 less 或者 scss 需要額外的配置,上面有說明。

  • src/components/Button/Button.tsx
/*
 * Author: lin.zehong
 * Date: 2021-04-30 10:38:00
 * Desc: Button 組件
 */
import React from 'react';
import classnames from 'classnames';
import './Button.less';

export type ButtonType = 'default' | 'primary' | 'danger';

export type ButtonSize = 'lg' | 'sm';

interface IButtonProps {
  /**
   * 按鈕類型
   */
  btnType?: ButtonType;
  /**
   * 按鈕大小
   */
  size?: ButtonSize;
  /**
   * 按鈕自定義 className
   */
  className?: string;
  /**
   * 超鏈接按鈕
   */
  link?: string;
  /**
   * 按鈕是否不可以操作
   */
  disabled?: boolean;
  /**
   * 按鈕內(nèi)容
   */
  children?: React.ReactNode;
  /**
   * Optional click handler
   */
  onClick?: () => void;
}

// & 聯(lián)合屬性,并關(guān)系; | 或者關(guān)系
type NativeButtonProps = IButtonProps & React.ButtonHTMLAttributes<HTMLElement>;
type AnchorButtonProps = IButtonProps & React.AnchorHTMLAttributes<HTMLElement>;

// Partial,把屬性都設置為可選
export type ButtonProps = Partial<NativeButtonProps & AnchorButtonProps>;

/**
 * 我的 Button 組件
*/
const Button: React.FC<ButtonProps> = (props) => {
  const { btnType, size, className, link, disabled, children, ...restProps } = props;

  const classes = classnames('btn', className, {
    [`btn-${btnType}`]: btnType,
    [`btn-${size}`]: size,
    [`btn-link`]: link,
    disabled: disabled && link,
  });

  if (link) {
    return (
      <a href={link} className={classes} {...restProps}>
        {children}
      </a>
    );
  }

  return (
    <button className={classes} disabled={disabled} {...restProps}>
      {children}
    </button>
  );
};

Button.defaultProps = {
  disabled: false,
  btnType: 'default',
  children: '按鈕'
};

export default Button;

  • src/components/Button/Button.less
@import '../../mixin.less';
@import '../../vartest.less';

.btn{
  .button-size(@btn-padding-y, @btn-padding-x, @btn-font-size, @btn-border-radius);
  position: relative;;
  display: inline-block;
  cursor: pointer;
  text-align: center;
  vertical-align: middle;
  white-space: nowrap;
  outline: none;
  font-weight: @btn-font-weight;
  font-family: @btn-font-family;
  line-height: @btn-line-height;
  border: @btn-border-width solid @border-color;
  background-image: none;
  background: transparent;
  box-shadow: @btn-box-shadow;
  transition: @btn-transition;
  &.disabled,
  &[disabled] {
    pointer-events: none;
    box-shadow: none;
    opacity: @btn-disabled-opacity;
    cursor: not-allowed;
  }
}

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

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

.btn-default {
  .button-style(@body-color, transparent, @border-color,  @primary,  transparent,  @primary);
}

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

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

.btn-link{
  border: none;
  box-shadow: none;
  color: @btn-link-color;
  text-decoration: @link-decoration;
  padding: 0;

  &:hover,
  &.hover,
  &:focus,
  &.focus{
    color: @btn-link-hover-color;
    border: none;
  }
  &.disabled{
    color: @btn-link-disabled-color;
    &:hover{
      text-decoration: none;
    }
  }
}

  • mixin.less
// 按鈕
.button-size(@padding-y, @padding-x, @font-size, @border-raduis) {
  padding: @padding-y @padding-x;
  font-size: @font-size;
  border-radius: @border-raduis;
}
.button-style(
  @color,
  @background,
  @border,
  @hover-color: lighten(@color, 10%),
  @hover-background: lighten(@background, 10%),
  @hover-border: lighten(@border, 10%),
) {
  color: @color;
  background: @background;
  border: @border-width solid @border;
  &:hover,
  &.hover {
    color: @hover-color;
    background: @hover-background;
    border: @border-width solid @hover-border;
  }
  // &:focus,
  // &.focus{
  //   color: @hover-color;
  //   background: @hover-background;
  //   border: @border-width solid @hover-border;
  // }
  &:active,
  &.active {
    color: @color;
    background: @background;
    border: @border-width solid @border;
  }
}
// 按鈕 end

// 動畫
.animation-zoom(
  @direction: 'top',
  @scaleStart: scaleY(0),
  @scaleEnd: scaleY(1),
  @ransform-origin: center top,
) {
  .zoom-in-@{direction}-enter {
    opacity: 0;
    transform: @scaleStart;
  }
  .zoom-in-@{direction}-enter-active {
    opacity: 1;
    transform: @scaleEnd;
    transition: opacity 500ms cubic-bezier(0.23, 1, 0.32, 1),
      transform 500ms cubic-bezier(0.23, 1, 0.32, 1);
    transform-origin: @ransform-origin;
  }
  .zoom-in-@{direction}-exit {
    opacity: 1;
    transform: @scaleEnd;
  }
  .zoom-in-@{direction}-exit-active {
    opacity: 0;
    transform: @scaleStart;
    transition: opacity 500ms cubic-bezier(0.23, 1, 0.32, 1) 100ms,
      transform 500ms cubic-bezier(0.23, 1, 0.32, 1) 100ms;
    transform-origin: @ransform-origin;
  }
}
// 動畫 end

  • vartest.less
  // 自定義顏色
  @white: #fff;
  @gray-100: #f8f9fa;
  @gray-200: #e9ecef;
  @gray-300: #dee2e6;
  @gray-400: #ced4da;
  @gray-500: #adb5bd;
  @gray-600: #6c757d;
  @gray-700: #495057;
  @gray-800: #343a40;
  @gray-900: #212529;
  @black: #000;

  @blue: #0d6efd;
  @indigo: #6610f2;
  @purple: #6f42c1;
  @pink: #d63384;
  @red: #dc3545;
  @orange: #fd7e14;
  @yellow: #fadb14;
  @green: #52c41a;
  @teal: #20c997;
  @cyan: #17a2b8;

  @primary: @blue;
  @secondary: @gray-600;
  @success: @green;
  @info: @cyan;
  @warning: @yellow;
  @danger: @red;
  @light: @gray-100;
  @dark: @gray-800;

  // @theme-colors: @primary; @secondary; @success; @info; @warning; @danger; @light; @dark;

  // 字體
  @font-family-sans-serif:
  '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"';
@font-family-monospace:
  'SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
@font-family-base: @font-family-sans-serif;


  // 字體大小
  @font-size-base: 1rem; // Assumes the browse;
  @font-size-lg: @font-size-base * 1.25;
  @font-size-sm: @font-size-base * .875;
  @font-size-root: null;

  // // 字重
  @font-weight-lighter: lighter;
  @font-weight-light: 300;
  @font-weight-normal: 400;
  @font-weight-bold: 700;
  @font-weight-bolder: bolder;
  @font-weight-base: @font-weight-normal;

  // // 行高
  @line-height-base: 1.5;
  @line-height-lg: 2;
  @line-height-sm: 1.25;

  // // 標題大小
  @h1-font-size: @font-size-base * 2.5;
  @h2-font-size: @font-size-base * 2;
  @h3-font-size: @font-size-base * 1.75;
  @h4-font-size: @font-size-base * 1.5;
  @h5-font-size: @font-size-base * 1.25;
  @h6-font-size: @font-size-base;

  // // 鏈接
  @link-color: @primary;
  @link-decoration: none;
  @link-hover-color: lighten(@link-color; 15%);
  @link-hover-decoration: underline;

  // body
  @body-bg: @white;
  @body-color: @gray-900;
  @body-text-align: null;

  // Spacing
  @spacer: 1rem;

  // Paragraphs

  @paragraph-margin-bottom: 1rem;

  // 字體其他部分 heading list hr 等等
  @headings-margin-bottom: @spacer / 2;
  @headings-font-family: null;
  @headings-font-style: null;
  @headings-font-weight: 500;
  @headings-line-height: 1.2;
  @headings-color: null;

  @display1-size: 6rem;
  @display2-size: 5.5rem;
  @display3-size: 4.5rem;

  @display4-size: 3.5rem;
  @display1-weight: 300;
  @display2-weight: 300;
  @display3-weight: 300;
  @display4-weight: 300;
  @display-line-height: @headings-line-height;

  @lead-font-size: @font-size-base * 1.25;
  @lead-font-weight: 300;

  @small-font-size: .875em;

  @sub-sup-font-size: .75em;

  @text-muted: @gray-600;

  @initialism-font-size: @small-font-size;

  @blockquote-small-color: @gray-600;
  @blockquote-small-font-size: @small-font-size;
  @blockquote-font-size: @font-size-base * 1.25;

  @hr-color: inherit;
  @hr-height: 1px;
  @hr-opacity: .25;

  @legend-margin-bottom: .5rem;
  @legend-font-size: 1.5rem;
  @legend-font-weight: null;

  @mark-padding: .2em;

  @dt-font-weight: @font-weight-bold;

  @nested-kbd-font-weight: @font-weight-bold;

  @list-inline-padding: .5rem;

  @mark-bg: #fcf8e3;

  @hr-margin-y: @spacer;

  // Code

  @code-font-size: @small-font-size;
  @code-color: @pink;
  @pre-color: null;

  // options 可配置選項
  @enable-pointer-cursor-for-buttons: true;

  // 邊框 和 border radius

  @border-width: 1px;
  @border-color: @gray-300;

  @border-radius: .25rem;
  @border-radius-lg: .3rem;
  @border-radius-sm: .2rem;

  // 不同類型的 box shadow
  @box-shadow-sm: 0 .125rem .25rem rgba(@black; .075);
  @box-shadow: 0 .5rem 1rem rgba(@black; .15);
  @box-shadow-lg: 0 1rem 3rem rgba(@black; .175);
  @box-shadow-inset: inset 0 1px 2px rgba(@black; .075);

  // 按鈕
  // 按鈕基本屬性
  @btn-font-weight: 400;
  @btn-padding-y: .375rem;
  @btn-padding-x: .75rem;
  @btn-font-family: @font-family-base;
  @btn-font-size: @font-size-base;
  @btn-line-height: @line-height-base;

  //不同大小按鈕的 padding 和 font size
  @btn-padding-y-sm: .25rem;
  @btn-padding-x-sm: .5rem;
  @btn-font-size-sm: @font-size-sm;

  @btn-padding-y-lg: .5rem;
  @btn-padding-x-lg: 1rem;
  @btn-font-size-lg: @font-size-lg;

  // 按鈕邊框
  @btn-border-width: @border-width;

  // 按鈕其他
  @btn-box-shadow: inset 0 1px 0 rgba(@white; .15) 0 1px 1px rgba(@black; .075);
  @btn-disabled-opacity: .65;

  // 鏈接按鈕
  @btn-link-color: @link-color;
  @btn-link-hover-color: @link-hover-color;
  @btn-link-disabled-color: @gray-600;

  // 按鈕 radius
  @btn-border-radius: @border-radius;
  @btn-border-radius-lg: @border-radius-lg;
  @btn-border-radius-sm: @border-radius-sm;

  @btn-transition:
    color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out;


  • src/components/Button/Button.stories.tsx
import React from 'react';
import { Story } from '@storybook/react';
import Button, { ButtonProps } from './Button';
import { action } from '@storybook/addon-actions'

//?? This default export determines where your story goes in the story list
export default {
  title: 'components/Button',
  component: Button,
  decorators: [(storyFn) => <div style={{ margin: '1em' }}>{storyFn()}</div>],
  // parameters: {docs: { previewSource: 'open' } }
  parameters: {
    docs: {
      source: {
        // code: 'Some custom string here',
        state: true,
      }
    }
  }
};

//?? We create a “template” of how args map to rendering
const Template: Story<ButtonProps> = (args) => <Button onClick={action('12222')} {...args} />;

// Template.parameters = {
//   docs: { previewSource: 'open' },
// }

export const FirstStory = Template.bind({});
FirstStory.args = {
  /*?? The args you need here will depend on your component */
  btnType: 'primary',
};

// export const DisabledButton = Template.bind({});
// DisabledButton.storyName = 'So simple!1';
// DisabledButton.args = {
//   /*?? The args you need here will depend on your component */
//   disabled: true,
// };

成功

8 填坑

8.1 less 不支持,需要配置

先不要急著安裝,往下看,不然,啟動會有相關(guān)的報錯?。?!

yarn add style-loader css-loader less-loader

由于上面安裝的是最新的 less-loader 版本,本人裝完過后是 8.1.1的版本,啟動項目后,出現(xiàn)了各種錯誤,例如:

Cannot find module 'less'

Module build failed less-loader this.getOptions is not a function

但是,本人確定 less-loader 是安裝成功,最后,發(fā)現(xiàn)問題是由于 less-loader版本過高,所以,安裝了較低的版本后 yarn add less-loader@7.0.0,啟動成功

  • .storybook / mian.js 配置
const path = require('path');

module.exports = {
  "stories": [
    "../src/**/*.stories.mdx",
    "../src/**/*.stories.@(js|jsx|ts|tsx)"
  ],
  "addons": [
    "@storybook/addon-links",
    "@storybook/addon-essentials"
  ],
  webpackFinal: async (config, { configType }) => {
    // `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION'
    // You can change the configuration based on that.
    // 'PRODUCTION' is used when building the static version of storybook.

    // Make whatever fine-grained changes you need
    config.module.rules.push({
      test: /\.less$/,
      loaders: ['style-loader', 'css-loader', 'less-loader'],
      include: path.resolve(__dirname, '../src/')
    });

    // Return the altered config
    return config;
  },
}

擴展:less 模塊化
由于項目都是使用 less 模塊化,所以,這里需要新增模塊化的配置,上面的配置更改為:

const path = require('path');

module.exports = {
  "stories": [
    "../src/**/*.stories.mdx",
    "../src/**/*.stories.@(js|jsx|ts|tsx)"
  ],
  "addons": [
    "@storybook/addon-links",
    "@storybook/addon-essentials"
  ],
  webpackFinal: async (config, { configType }) => {
    // `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION'
    // You can change the configuration based on that.
    // 'PRODUCTION' is used when building the static version of storybook.

    // Make whatever fine-grained changes you need
    config.module.rules.push({
      test: /\.less$/,
      exclude: /node_modules/,
      // loaders: ['style-loader', 'css-loader', 'less-loader'],
      use: [
        {
          loader: 'style-loader'
        },
        {
          loader: 'css-loader',
          options: {
            modules: {
              localIdentName: '[local]_[hash:base64:5]'
            }
          }
        },
        {
          loader: 'less-loader'
        }
      ],
      include: path.resolve(__dirname, '../src/')
    });

    // Return the altered config
    return config;
  },
}

8.2 使用 yarn 安裝

本人使用 cnpm 安裝完依賴后,一直啟動不成功,要么就是項目啟動有問題,要么就是 Storybook 啟動有問題,使用 yarn 安裝完成之后,問題都解決,所以,這里推薦使用 yarn 安裝。

8.3 樣式問題

本人使用的框架是 umi,組件使用的主題色和變量相關(guān)的配置是在 theme.ts 配置文件,項目啟動沒有問題,但是,使用 Storybook 配置相關(guān)的組件,就找不到在 umi 配置文件 theme.ts 的相關(guān)變量,導致樣式相關(guān)錯誤;

所以,變量和 mixin 等相關(guān)的樣式變量,要放在單獨的 less 文件,方便 Storybook 配置對應的組件引入樣式。

8.4 npx sb init 一直無法安裝相關(guān)的 react 依賴

根據(jù) Storybook 官網(wǎng) 說明,使用如下命令 npx 進行安裝

# Add Storybook:
npx sb init

命令安裝下載默認的配置文件 .storybook 和示例 src/stories,如下圖:

接著檢查到為 react 項目,下載 storybook react 相關(guān)依賴,一直有問題,報各種文件已存在,不受 npm 控制等如下錯誤:如下圖

解決方案:

通過手動安裝 storybook react 相關(guān)依賴包,報錯后,不使用 npx sb init storybook cli 進行安裝,storybook react 相關(guān)依賴包為:

cnpm i @storybook/react@6.2.9 -D
cnpm i @storybook/addon-links@6.2.9 -D
cnpm i @storybook/addon-essentials@6.2.9 -D
cnpm i @storybook/addon-actions@6.2.9 -D

最后,在 package.json scripts 中,添加對應的命令 "storybook": "start-storybook -p 6006", "build-storybook": "build-storybook",如下:

  "scripts": {
    "start": "koi dev",
    "build": "koi build",
    "publish": "koi publish",
    "eslint": "eslint --fix --ext .js --ext .jsx --ext .ts --ext .tsx ./src",
    "lint-staged": "lint-staged",
    "test": "umi-test",
    "test:coverage": "umi-test --coverage",
    "storybook": "start-storybook -p 6006",
    "build-storybook": "build-storybook"
  },

添加完成后,控制臺運行命令 yarn run storybook,就可以看到成功的界面了。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容