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 是一个开源工具,它能有组织和高效地构建 UI 组件,文档编制和测试,包括 React、Vue 和 Angular 。

特点:

  • 分开展示各个组件不同属性下的状态;

  • 能追踪组件的行为并且具有属性调试的功能;

  • 可以为组件自动生成文档和属性列表;

2 安装

根据官网,本人使用的 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 的作用主要是统一修饰组件展示区域的样式,例如:设置组件展示都居中,或者是 margin、padding 的距离等等。

在对应的组件配置如下:例如(xxx.stories.tsx,组件展示区域都距离 1em 边距)

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

详细配置,参考相关的官网说明文档。

5 parameters

parameters 通常是用于控制 Storybook 功能和插件的行为。详细配置,参考相关的官网说明文档。

简单给个 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 插件,目前安装的版本,应该已经集成了,组件就会被自动解析。

7 实例

说明:这只是个例子,样式文件本人只是测试相关的 less 引用是否有问题,官网 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;
  /**
   * 按钮内容
   */
  children?: React.ReactNode;
  /**
   * Optional click handler
   */
  onClick?: () => void;
}

// & 联合属性,并关系; | 或者关系
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 不支持,需要配置

先不要急着安装,往下看,不然,启动会有相关的报错!!!

yarn add style-loader css-loader less-loader

由于上面安装的是最新的 less-loader 版本,本人装完过后是 8.1.1的版本,启动项目后,出现了各种错误,例如:

Cannot find module 'less'

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

但是,本人确定 less-loader 是安装成功,最后,发现问题是由于 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,组件使用的主题色和变量相关的配置是在 theme.ts 配置文件,项目启动没有问题,但是,使用 Storybook 配置相关的组件,就找不到在 umi 配置文件 theme.ts 的相关变量,导致样式相关错误;

所以,变量和 mixin 等相关的样式变量,要放在单独的 less 文件,方便 Storybook 配置对应的组件引入样式。

8.4 npx sb init 一直无法安装相关的 react 依赖

根据 Storybook 官网 说明,使用如下命令 npx 进行安装

# Add Storybook:
npx sb init

命令安装下载默认的配置文件 .storybook 和示例 src/stories,如下图:

接着检查到为 react 项目,下载 storybook react 相关依赖,一直有问题,报各种文件已存在,不受 npm 控制等如下错误:如下图

解决方案:

通过手动安装 storybook react 相关依赖包,报错后,不使用 npx sb init storybook cli 进行安装,storybook react 相关依赖包为:

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,就可以看到成功的界面了。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271

推荐阅读更多精彩内容