参加直播:周四,美国东部时间上午 11 点,Storybook 9 发布及问答
文档
Storybook Docs

适用于 Next.js 的 Storybook

适用于 Next.js 的 Storybook 是一个框架,它使得为 Next.js 应用隔离开发和测试 UI 组件变得容易。它包括

  • 🔀 路由
  • 🖼 图像优化
  • ⤵️ 绝对路径导入
  • 🎨 样式设置
  • 🎛 Webpack 与 Babel 配置
  • 💫 还有更多!

要求

  • Next.js ≥ 14.1

入门

在没有 Storybook 的项目中

在 Next.js 项目的根目录运行此命令后,按照提示操作

npm create storybook@latest

更多关于 Storybook 入门的信息。

在已有 Storybook 的项目中

此框架设计用于 Storybook 7+。如果您尚未升级到 v7,请运行此命令进行升级

npx storybook@next upgrade

自动迁移

运行上述 upgrade 命令时,您应该会收到提示,询问您是否要迁移到 @storybook/nextjs,这应该会为您处理一切。如果自动迁移不适用于您的项目,请参考下面的手动迁移步骤。

手动迁移

首先,安装框架

npm install --save-dev @storybook/nextjs

然后,更新您的 .storybook/main.js|ts 文件,更改框架属性

.storybook/main.ts
import type { StorybookConfig } from '@storybook/nextjs';
 
const config: StorybookConfig = {
  // ...
  // framework: '@storybook/react-webpack5', 👈 Remove this
  framework: '@storybook/nextjs', // 👈 Add this
};
 
export default config;

最后,如果您之前使用 Storybook 插件与 Next.js 集成,使用此框架时这些插件不再需要,可以移除

.storybook/main.ts
import type { StorybookConfig } from '@storybook/nextjs';
 
const config: StorybookConfig = {
  // ...
  addons: [
    // ...
    // 👇 These can both be removed
    // 'storybook-addon-next',
    // 'storybook-addon-next-router',
  ],
};
 
export default config;

使用 Vite

Storybook 推荐使用 @storybook/nextjs-vite 框架,它基于 Vite,无需 Webpack 和 Babel。它支持此处记录的所有功能。

npm install --save-dev @storybook/nextjs-vite

然后,更新您的 .storybook/main.js|ts 文件,更改框架属性

.storybook/main.ts
import type { StorybookConfig } from '@storybook/nextjs-vite';
 
const config: StorybookConfig = {
  // ...
  // framework: '@storybook/react-webpack5', 👈 Remove this
  framework: '@storybook/nextjs-vite', // 👈 Add this
};
 
export default config;

如果您的 Storybook 配置在 webpackFinal 中包含自定义 Webpack 操作,您可能需要在 viteFinal 中创建等效配置。

更多信息,请参阅 Vite 构建器文档

最后,如果您之前使用 Storybook 插件与 Next.js 集成,使用此框架时这些插件不再需要,可以移除

.storybook/main.ts
import type { StorybookConfig } from '@storybook/nextjs-vite';
 
const config: StorybookConfig = {
  // ...
  addons: [
    // ...
    // 👇 These can both be removed
    // 'storybook-addon-next',
    // 'storybook-addon-next-router',
  ],
};
 
export default config;

运行设置向导

如果一切顺利,您应该会看到一个设置向导,它将帮助您开始使用 Storybook,向您介绍主要概念和功能,包括 UI 的组织方式、如何编写第一个 Story,以及如何利用控件测试组件对各种输入的响应。

Storybook onboarding

如果您跳过了向导,只要示例 Stories 仍然可用,您随时可以通过在 Storybook 实例的 URL 中添加查询参数 ?path=/onboarding 再次运行它。

Next.js 的 Image 组件

此框架允许您无需配置即可使用 Next.js 的 next/image

本地图片

支持本地图片

index.jsx
import Image from 'next/image';
import profilePic from '../public/me.png';
 
function Home() {
  return (
    <>
      <h1>My Homepage</h1>
      <Image
        src={profilePic}
        alt="Picture of the author"
        // width={500} automatically provided
        // height={500} automatically provided
        // blurDataURL="../public/me.png" set to equal the image itself (for this framework)
        // placeholder="blur" // Optional blur-up while loading
      />
      <p>Welcome to my homepage!</p>
    </>
  );
}

远程图片

也支持远程图片

index.jsx
import Image from 'next/image';
 
export default function Home() {
  return (
    <>
      <h1>My Homepage</h1>
      <Image src="/me.png" alt="Picture of the author" width={500} height={500} />
      <p>Welcome to my homepage!</p>
    </>
  );
}

Next.js 字体优化

next/font 在 Storybook 中部分支持。支持 next/font/googlenext/font/local 包。

next/font/google

您无需执行任何操作。next/font/google 开箱即用。

next/font/local

对于本地字体,您必须定义 src 属性。路径是相对于调用字体加载函数所在的目录。

如果以下组件定义本地字体如下

src/components/MyComponent.js
import localFont from 'next/font/local';
 
const localRubikStorm = localFont({ src: './fonts/RubikStorm-Regular.ttf' });

staticDir 映射

如果您使用的是 @storybook/nextjs-vite 而不是 @storybook/nextjs,则可以安全地跳过本节。基于 Vite 的框架会自动处理映射。

您必须通过 staticDirs 配置告诉 Storybook fonts 目录在哪里。from 值是相对于 .storybook 目录的。to 值是相对于 Storybook 执行上下文的。它很可能是您项目的根目录。

.storybook/main.ts
// Replace your-framework with nextjs or nextjs-vite
import type { StorybookConfig } from '@storybook/your-framework';
 
const config: StorybookConfig = {
  // ...
  staticDirs: [
    {
      from: '../src/components/fonts',
      to: 'src/components/fonts',
    },
  ],
};
 
export default config;

不支持的 next/font 功能

以下功能(尚)不受支持。这些功能的支持可能计划在未来实现

测试期间模拟字体

有时在 Storybook 构建步骤中从 Google 获取字体可能会失败。强烈建议模拟这些请求,因为这些失败也可能导致您的 CI/CD 流程失败。Next.js 支持模拟字体,通过一个 JavaScript 模块,该模块位于环境变量 NEXT_FONT_GOOGLE_MOCKED_RESPONSES 引用的位置。

例如,使用 GitHub Actions

.github/workflows/ci.yml
- uses: chromaui/action@latest
  env:
    #👇 the location of mocked fonts to use
    NEXT_FONT_GOOGLE_MOCKED_RESPONSES: ${{ github.workspace }}/mocked-google-fonts.js
  with:
    projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
    token: ${{ secrets.GITHUB_TOKEN }}

您模拟的字体将如下所示

mocked-google-fonts.js
//👇 Mocked responses of google fonts with the URL as the key
module.exports = {
  'https://fonts.googleapis.com/css?family=Inter:wght@400;500;600;800&display=block': `
    /* cyrillic-ext */
    @font-face {
      font-family: 'Inter';
      font-style: normal;
      font-weight: 400;
      font-display: block;
      src: url(https://fonts.gstatic.com/s/inter/v12/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyfAZJhiJ-Ek-_EeAmM.woff2) format('woff2');
      unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
    }
    /* more font declarations go here */
    /* latin */
    @font-face {
      font-family: 'Inter';
      font-style: normal;
      font-weight: 400;
      font-display: block;
      src: url(https://fonts.gstatic.com/s/inter/v12/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyfAZ9hiJ-Ek-_EeA.woff2) format('woff2');
      unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
    }`,
};

Next.js 路由

Next.js 的路由会自动为您提供存根,以便在与路由交互时,如果安装了 Storybook Actions 插件,其所有交互都会自动记录到 Actions 面板中。

您只能在 pages 目录中使用 next/router。在 app 目录中,必须使用 next/navigation

覆盖默认值

可以通过在 Story parameters 上添加 nextjs.router 属性来执行单 Story 覆盖。框架会将您在此处设置的任何内容浅层合并到路由器中。

RouterBasedComponent.stories.ts
// Replace your-framework with nextjs or nextjs-vite
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import RouterBasedComponent from './RouterBasedComponent';
 
const meta = {
  component: RouterBasedComponent,
} satisfies Meta<typeof RouterBasedComponent>;
 
export default meta;
type Story = StoryObj<typeof meta>;
 
// If you have the actions addon,
// you can interact with the links and see the route change events there
export const Example: Story = {
  parameters: {
    nextjs: {
      router: {
        pathname: '/profile/[id]',
        asPath: '/profile/1',
        query: {
          id: '1',
        },
      },
    },
  },
};

这些覆盖也可以应用于组件的所有 Stories项目中的所有 Stories。遵循标准的参数继承规则。

默认路由

存根路由器的默认值如下(有关全局变量如何工作的更多详细信息,请参阅全局变量)。

// Default router
const defaultRouter = {
  // The locale should be configured globally: https://storybook.org.cn/docs/essentials/toolbars-and-globals#globals
  locale: globals?.locale,
  asPath: '/',
  basePath: '/',
  isFallback: false,
  isLocaleDomain: false,
  isReady: true,
  isPreview: false,
  route: '/',
  pathname: '/',
  query: {},
};

此外,router 对象包含所有原始方法(例如 push()replace() 等)作为模拟函数,可以使用常规模拟 API 进行操作和断言。

要覆盖这些默认值,您可以使用parametersbeforeEach

.storybook/preview.js|ts
// Replace your-framework with nextjs or nextjs-vite
import type { Preview } from '@storybook/your-framework';
 
// 👇 Must include the `.mock` portion of filename to have mocks typed correctly
import { getRouter } from "@storybook/your-framework/router.mock";
 
const preview: Preview = {
  parameters: {
    nextjs: {
      // 👇 Override the default router properties
      router: {
        basePath: '/app/',
      },
    },
  },
  async beforeEach() {
    // 👇 Manipulate the default router method mocks
    getRouter().push.mockImplementation(() => {
      /* ... */
    });
  },
};

Next.js 导航

请注意,next/navigation 只能在 app 目录中的组件/页面中使用。

nextjs.appDirectory 设置为 true

如果您的 Story 导入使用了 next/navigation 的组件,则需要为该组件的 Stories 设置参数 nextjs.appDirectorytrue

NavigationBasedComponent.stories.ts
// Replace your-framework with nextjs or nextjs-vite
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import NavigationBasedComponent from './NavigationBasedComponent';
 
const meta = {
  component: NavigationBasedComponent,
  parameters: {
    nextjs: {
      appDirectory: true, // 👈 Set this
    },
  },
} satisfies Meta<typeof NavigationBasedComponent>;
export default meta;

如果您的 Next.js 项目对所有页面都使用 app 目录(换句话说,它没有 pages 目录),您可以在 .storybook/preview.js|ts 文件中将参数 nextjs.appDirectory 设置为 true,以应用于所有 Stories。

.storybook/preview.ts
// Replace your-framework with nextjs or nextjs-vite
import type { Preview } from '@storybook/your-framework';
 
const preview: Preview = {
  // ...
  parameters: {
    // ...
    nextjs: {
      appDirectory: true,
    },
  },
};
 
export default preview;

覆盖默认值

可以通过在 Story parameters 上添加 nextjs.navigation 属性来执行单 Story 覆盖。框架会将您在此处设置的任何内容浅层合并到路由器中。

NavigationBasedComponent.stories.ts
// Replace your-framework with nextjs or nextjs-vite
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import NavigationBasedComponent from './NavigationBasedComponent';
 
const meta = {
  component: NavigationBasedComponent,
  parameters: {
    nextjs: {
      appDirectory: true,
    },
  },
} satisfies Meta<typeof NavigationBasedComponent>;
export default meta;
 
type Story = StoryObj<typeof meta>;
 
// If you have the actions addon,
// you can interact with the links and see the route change events there
export const Example: Story = {
  parameters: {
    nextjs: {
      navigation: {
        pathname: '/profile',
        query: {
          user: '1',
        },
      },
    },
  },
};

这些覆盖也可以应用于组件的所有 Stories项目中的所有 Stories。遵循标准的参数继承规则。

useSelectedLayoutSegmentuseSelectedLayoutSegmentsuseParams Hooks

Storybook 支持 useSelectedLayoutSegmentuseSelectedLayoutSegmentsuseParams Hooks。您必须设置 nextjs.navigation.segments 参数来返回您想要使用的 segments 或 params。

NavigationBasedComponent.stories.ts
// Replace your-framework with nextjs or nextjs-vite
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import NavigationBasedComponent from './NavigationBasedComponent';
 
const meta = {
  component: NavigationBasedComponent,
  parameters: {
    nextjs: {
      appDirectory: true,
      navigation: {
        segments: ['dashboard', 'analytics'],
      },
    },
  },
} satisfies Meta<typeof NavigationBasedComponent>;
export default meta;

使用上述配置,在 Stories 中渲染的组件将从 Hooks 接收以下值

NavigationBasedComponent.js
import { useSelectedLayoutSegment, useSelectedLayoutSegments, useParams } from 'next/navigation';
 
export default function NavigationBasedComponent() {
  const segment = useSelectedLayoutSegment(); // dashboard
  const segments = useSelectedLayoutSegments(); // ["dashboard", "analytics"]
  const params = useParams(); // {}
  // ...
}

要使用 useParams,您必须使用一个 segments 数组,其中每个元素都是一个包含两个字符串的数组。第一个字符串是 param 键,第二个字符串是 param 值。

NavigationBasedComponent.stories.ts
// Replace your-framework with nextjs or nextjs-vite
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import NavigationBasedComponent from './NavigationBasedComponent';
 
const meta = {
  component: NavigationBasedComponent,
  parameters: {
    nextjs: {
      appDirectory: true,
      navigation: {
        segments: [
          ['slug', 'hello'],
          ['framework', 'nextjs'],
        ],
      },
    },
  },
} satisfies Meta<typeof NavigationBasedComponent>;
export default meta;

使用上述配置,在 Stories 中渲染的组件将从 Hooks 接收以下值

ParamsBasedComponent.js
import { useSelectedLayoutSegment, useSelectedLayoutSegments, useParams } from 'next/navigation';
 
export default function ParamsBasedComponent() {
  const segment = useSelectedLayoutSegment(); // hello
  const segments = useSelectedLayoutSegments(); // ["hello", "nextjs"]
  const params = useParams(); // { slug: "hello", framework: "nextjs" }
  ...
}

这些覆盖也可以应用于单个 Story项目中的所有 Stories。遵循标准的参数继承规则。

如果未设置,nextjs.navigation.segments 的默认值为 []

默认导航上下文

存根导航上下文的默认值如下

// Default navigation context
const defaultNavigationContext = {
  pathname: '/',
  query: {},
};

此外,router 对象包含所有原始方法(例如 push()replace() 等)作为模拟函数,可以使用常规模拟 API 进行操作和断言。

要覆盖这些默认值,您可以使用parametersbeforeEach

.storybook/preview.js|ts
// Replace your-framework with nextjs or nextjs-vite
import type { Preview } from '@storybook/your-framework';
 
// 👇 Must include the `.mock` portion of filename to have mocks typed correctly
import { getRouter } from '@storybook/your-framework/navigation.mock';
 
const preview: Preview = {
  parameters: {
    nextjs: {
      // 👇 Override the default navigation properties
      navigation: {
        pathname: '/app/',
      },
    },
  },
  async beforeEach() {
    // 👇 Manipulate the default navigation method mocks
    getRouter().push.mockImplementation(() => {
      /* ... */
    });
  },
};

Next.js Head

开箱即用支持 next/head。您可以在 Stories 中像在 Next.js 应用中一样使用它。请记住,Head 的 children 会被放入 Storybook 用于渲染 Stories 的 iframe 的 head 元素中。

Sass/Scss

全局 Sass/Scss 样式表也无需任何额外配置即可支持。只需将它们导入到 .storybook/preview.js|ts 中即可

.storybook/preview.js|ts
import '../styles/globals.scss';

这将自动包含您在 next.config.js 文件中的任何自定义 Sass 配置

next.config.js
import * as path from 'path';
 
export default {
  // Any options here are included in Sass compilation for your stories
  sassOptions: {
    includePaths: [path.join(__dirname, 'styles')],
  },
};

CSS/Sass/Scss 模块

CSS 模块按预期工作。

src/components/Button.jsx
// This import will work in Storybook
import styles from './Button.module.css';
// Sass/Scss is also supported
// import styles from './Button.module.scss'
// import styles from './Button.module.sass'
 
export function Button() {
  return (
    <button type="button" className={styles.error}>
      Destroy
    </button>
  );
}

Styled JSX

Next.js 内置的 CSS-in-JS 解决方案是 styled-jsx,此框架也开箱即用支持它,零配置。

src/components/HelloWorld.jsx
// This will work in Storybook
function HelloWorld() {
  return (
    <div>
      Hello world
      <p>scoped!</p>
      <style jsx>{`
        p {
          color: blue;
        }
        div {
          background: red;
        }
        @media (max-width: 600px) {
          div {
            background: blue;
          }
        }
      `}</style>
      <style global jsx>{`
        body {
          background: black;
        }
      `}</style>
    </div>
  );
}
 
export default HelloWorld;

您也可以使用自己的 Babel 配置。这是一个如何自定义 styled-jsx 的示例。

// .babelrc (or whatever config file you use)
{
  "presets": [
    [
      "next/babel",
      {
        "styled-jsx": {
          "plugins": ["@styled-jsx/plugin-sass"]
        }
      }
    ]
  ]
}

PostCSS

Next.js 允许您自定义 PostCSS 配置。因此,此框架将自动为您处理 PostCSS 配置。

这使得零配置的 Tailwind 等酷炫功能成为可能!(参阅Next.js 的示例

绝对路径导入

支持从根目录进行绝对路径导入

index.jsx|tsx
// All good!
import Button from 'components/button';
// Also good!
import styles from 'styles/HomePage.module.css';
 
export default function HomePage() {
  return (
    <>
      <h1 className={styles.title}>Hello World</h1>
      <Button />
    </>
  );
}

.storybook/preview.js|ts 中用于全局样式也 OK!

.storybook/preview.js|ts
import 'styles/globals.scss';
 
// ...

绝对路径导入不能在 Stories/测试中模拟。更多信息请参阅模拟模块部分。

模块别名

也支持模块别名

index.jsx|tsx
// All good!
import Button from '@/components/button';
// Also good!
import styles from '@/styles/HomePage.module.css';
 
export default function HomePage() {
  return (
    <>
      <h1 className={styles.title}>Hello World</h1>
      <Button />
    </>
  );
}

子路径导入

作为模块别名的替代方案,您可以使用子路径导入来导入模块。这遵循 Node 包标准,并在模拟模块时具有优势。

要配置子路径导入,您需要在项目的 package.json 文件中定义 imports 属性。此属性将子路径映射到实际文件路径。以下示例为项目中的所有模块配置了子路径导入

package.json
{
  "imports": {
    "#*": ["./*", "./*.ts", "./*.tsx"]
  }
}

由于子路径导入替换了模块别名,您可以从 TypeScript 配置中移除路径别名。

然后可以像这样使用

index.jsx|tsx
import Button from '#components/button';
import styles from '#styles/HomePage.module.css';
 
export default function HomePage() {
  return (
    <>
      <h1 className={styles.title}>Hello World</h1>
      <Button />
    </>
  );
}

模拟模块

组件通常依赖于导入到组件文件中的模块。这些模块可能来自外部包或您的项目内部。在 Storybook 中渲染或测试这些组件时,您可能希望模拟这些模块以控制和断言其行为。

内置模拟模块

此框架为许多 Next.js 内部模块提供了模拟

  1. @storybook/nextjs/cache.mock
  2. @storybook/nextjs/headers.mock
  3. @storybook/nextjs/navigation.mock
  4. @storybook/nextjs/router.mock

模拟其他模块

在 Storybook 中如何模拟其他模块取决于您如何将模块导入到组件中。

无论哪种方法,第一步都是创建一个模拟文件。这是一个名为 session 的模块的模拟文件示例

lib/session.mock.ts
import { fn } from 'storybook/test';
import * as actual from './session';
 
export * from './session';
export const getUserFromSession = fn(actual.getUserFromSession).mockName('getUserFromSession');

使用子路径导入

如果您正在使用子路径导入,您可以调整配置以应用条件,以便在 Storybook 内部使用模拟模块。以下示例配置了四个内部模块的子路径导入,然后这些模块在 Storybook 中被模拟

package.json
{
  "imports": {
    "#api": {
      // storybook condition applies to Storybook
      "storybook": "./api.mock.ts",
      "default": "./api.ts",
    },
    "#app/actions": {
      "storybook": "./app/actions.mock.ts",
      "default": "./app/actions.ts",
    },
    "#lib/session": {
      "storybook": "./lib/session.mock.ts",
      "default": "./lib/session.ts",
    },
    "#lib/db": {
      // test condition applies to test environments *and* Storybook
      "test": "./lib/db.mock.ts",
      "default": "./lib/db.ts",
    },
    "#*": ["./*", "./*.ts", "./*.tsx"],
  },
}

每个子路径必须以 # 开头,以区别于常规模块路径。#* 条目是一个包罗万象的通配符,将所有子路径映射到根目录。

使用模块别名

如果您正在使用模块别名,您可以向 Storybook 配置添加一个 Webpack 别名,指向模拟文件。

.storybook/main.ts
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs-vite, vue3-vite, etc.
import type { StorybookConfig } from '@storybook/your-framework';
 
const config: StorybookConfig = {
  framework: '@storybook/your-framework',
  stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
  viteFinal: async (config) => {
    if (config.resolve) {
      config.resolve.alias = {
        ...config.resolve?.alias,
        // 👇 External module
        lodash: require.resolve('./lodash.mock'),
        // 👇 Internal modules
        '@/api': path.resolve(__dirname, './api.mock.ts'),
        '@/app/actions': path.resolve(__dirname, './app/actions.mock.ts'),
        '@/lib/session': path.resolve(__dirname, './lib/session.mock.ts'),
        '@/lib/db': path.resolve(__dirname, './lib/db.mock.ts'),
      };
    }
 
    return config;
  },
};
 
export default config;

运行时配置

Next.js 允许运行时配置,您可以导入一个方便的 getConfig 函数,在运行时获取 next.config.js 文件中定义的特定配置。

在此框架下的 Storybook 环境中,您可以期待 Next.js 的运行时配置功能正常工作。

注意,因为 Storybook 不会对您的组件进行服务器渲染,所以您的组件只能看到它们通常在客户端看到的内容(即它们看不到 serverRuntimeConfig,但会看到 publicRuntimeConfig)。

例如,考虑以下 Next.js 配置

next.config.js
module.exports = {
  serverRuntimeConfig: {
    mySecret: 'secret',
    secondSecret: process.env.SECOND_SECRET, // Pass through env variables
  },
  publicRuntimeConfig: {
    staticFolder: '/static',
  },
};

在 Storybook 中调用 getConfig 将返回以下对象

// Runtime config
{
  "serverRuntimeConfig": {},
  "publicRuntimeConfig": {
    "staticFolder": "/static"
  }
}

自定义 Webpack 配置

如果您使用的是 @storybook/nextjs-vite 而不是 @storybook/nextjs,则可以安全地跳过本节。基于 Vite 的 Next.js 框架不支持 Webpack 设置。

Next.js 开箱即用地提供了许多功能,例如 Sass 支持,但有时您会添加自定义 Webpack 配置修改到 Next.js。此框架负责您想要添加的大多数 Webpack 修改。如果 Next.js 开箱即用支持某个功能,则该功能在 Storybook 中也将开箱即用。如果 Next.js 不开箱即用支持某个功能,但易于配置,则此框架也会为 Storybook 提供同样的功能。

任何 Storybook 所需的 Webpack 修改应在 .storybook/main.js|ts 中进行。

注意:并非所有 Webpack 修改都可以在 next.config.js.storybook/main.js|ts 之间复制/粘贴。建议您研究如何正确修改 Storybook 的 Webpack 配置以及Webpack 的工作原理

以下是使用此框架向 Storybook 添加 SVGR 支持的示例。

.storybook/main.ts
import type { StorybookConfig } from '@storybook/nextjs';
 
const config: StorybookConfig = {
  // ...
  webpackFinal: async (config) => {
    config.module = config.module || {};
    config.module.rules = config.module.rules || [];
 
    // This modifies the existing image rule to exclude .svg files
    // since you want to handle those files with @svgr/webpack
    const imageRule = config.module.rules.find((rule) => rule?.['test']?.test('.svg'));
    if (imageRule) {
      imageRule['exclude'] = /\.svg$/;
    }
 
    // Configure .svg files to be loaded with @svgr/webpack
    config.module.rules.push({
      test: /\.svg$/,
      use: ['@svgr/webpack'],
    });
 
    return config;
  },
};
 
export default config;

Typescript

Storybook 处理大多数 Typescript 配置,但此框架为 Next.js 对绝对路径导入和模块路径别名的支持增加了额外的支持。简而言之,它考虑了您的 tsconfig.jsonbaseUrlpaths。因此,像下面这样的 tsconfig.json 文件将开箱即用。

tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/components/*": ["components/*"]
    }
  }
}

React 服务器组件 (RSC)

(⚠️ 实验性功能)

如果您的应用使用React 服务器组件 (RSC),Storybook 可以在浏览器中的 Stories 中渲染它们。

要启用此功能,请在您的 .storybook/main.js|ts 配置中设置 experimentalRSC 功能标志

.storybook/main.ts
// Replace your-framework with nextjs or nextjs-vite
import type { StorybookConfig } from '@storybook/your-framework';
 
const config: StorybookConfig = {
  // ...
  features: {
    experimentalRSC: true,
  },
};
 
export default config;

设置此标志会自动将您的 Story 包装在 Suspense Wrapper 中,该 Wrapper 能够在 NextJS 版本的 React 中渲染异步组件。

如果此 Wrapper 在您的任何现有 Stories 中导致问题,您可以在全局/组件/Story 级别使用 react.rsc parameter 选择性地禁用它

MyServerComponent.stories.ts
// Replace your-framework with nextjs or nextjs-vite
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import MyServerComponent from './MyServerComponent';
 
const meta = {
  component: MyServerComponent,
  parameters: {
    react: { rsc: false },
  },
} satisfies Meta<typeof MyServerComponent>;
export default meta;

请注意,如果您的服务器组件访问服务器端资源(如文件系统或特定于 Node 的库),将服务器组件包装在 Suspense 中是无济于事的。要解决此问题,您需要使用 Webpack 别名或像 storybook-addon-module-mock 这样的插件来模拟数据访问层。

如果您的服务器组件通过网络访问数据,我们建议使用MSW Storybook 插件来模拟网络请求。

未来,我们将在 Storybook 中提供更好的模拟支持,并支持服务器 Actions

针对 Yarn v2 和 v3 用户的注意事项

如果您使用的是 Yarn v2 或 v3,您可能会遇到 Storybook 无法解析 style-loadercss-loader 的问题。例如,您可能会收到以下错误

Module not found: Error: Can't resolve 'css-loader'
Module not found: Error: Can't resolve 'style-loader'

这是因为这些版本的 Yarn 与 Yarn v1.x 的包解析规则不同。如果遇到这种情况,请直接安装该包。

常见问题

获取数据的页面/组件 Stories

Next.js 页面可以在 app 目录中的服务器组件内直接获取数据,这通常包括仅在 Node 环境中运行的模块导入。这目前在 Storybook 中不起作用,因为如果您在 Stories 中从包含这些 Node 模块导入的 Next.js 页面文件导入,您的 Storybook 的 Webpack 将会崩溃,因为这些模块不会在浏览器中运行。为了解决这个问题,您可以将页面文件中的组件提取到单独的文件中,并在您的 Stories 中导入该纯组件。或者,如果由于某些原因不可行,您可以在 Storybook 的 webpackFinal 配置填充这些模块

之前

app/my-page/index.jsx
async function getData() {
  const res = await fetch(...);
  // ...
}
 
// Using this component in your stories will break the Storybook build
export default async function Page() {
  const data = await getData();
 
  return // ...
}

之后

app/my-page/index.jsx
// Use this component in your stories
import MyPage from './components/MyPage';
 
async function getData() {
  const res = await fetch(...);
  // ...
}
 
export default async function Page() {
  const data = await getData();
 
  return <MyPage {...data} />;
}

静态导入的图片无法加载

确保您在正常开发中使用 next/image 时处理图片导入的方式与此一致。

在使用此框架之前,图片导入会导入图片的原始路径(例如 'static/media/stories/assets/logo.svg')。现在图片导入以“Next.js 方式”工作,这意味着您在导入图片时会获得一个对象。例如

// Image import object
{
  "src": "static/media/stories/assets/logo.svg",
  "height": 48,
  "width": 48,
  "blurDataURL": "static/media/stories/assets/logo.svg"
}

因此,如果在 Storybook 中图片未能正确显示,请确保您预期从导入中返回的是一个对象,而不仅仅是资产路径。

更多关于 Next.js 如何处理静态图片导入的详细信息,请参阅本地图片

找不到模块:错误:无法解析 package name

如果您使用的是 Yarn v2 或 v3,您可能会遇到此问题。更多详细信息,请参阅针对 Yarn v2 和 v3 用户的注意事项

如果我使用 Vite 构建器怎么办?

Storybook 为 Next.js 提供了一个基于 Vite 的框架。按照安装说明,将所有 @storybook/nextjs 的实例替换为 @storybook/nextjs-vite

错误:您正在导入 avif 图像,但未安装 sharp。您必须安装 sharp 才能使用 Next.js 中的图像优化功能。

sharp 是 Next.js 图像优化功能的一个依赖项。如果看到此错误,您需要在项目中安装 sharp

npm install sharp
yarn add sharp
pnpm add sharp

您可以参考 Next.js 文档中的 安装 sharp 以使用内置图像优化 获取更多信息。

API

模块

`@storybook/nextjs` 包导出多个模块,使您能够 模拟 Next.js 的内部行为。

`@storybook/nextjs/export-mocks`

类型: { getPackageAliases: ({ useESM?: boolean }) => void }

`getPackageAliases` 是一个用于生成设置 可移植故事 所需别名的辅助函数。

jest.config.ts
import type { Config } from 'jest';
import nextJest from 'next/jest.js';
// 👇 Import the utility function
import { getPackageAliases } from '@storybook/nextjs/export-mocks';
 
const createJestConfig = nextJest({
  // Provide the path to your Next.js app to load next.config.js and .env files in your test environment
  dir: './',
});
 
const config: Config = {
  testEnvironment: 'jsdom',
  // ... rest of Jest config
  moduleNameMapper: {
    ...getPackageAliases(), // 👈 Add the utility as mapped module names
  },
};
 
export default createJestConfig(config);

`@storybook/nextjs/cache.mock`

类型: typeof import('next/cache')

此模块导出 next/cache 模块导出的模拟实现。您可以使用它创建自己的模拟实现,或在故事的 play function 中断言模拟调用。

MyForm.stories.ts
// Replace your-framework with nextjs or nextjs-vite
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import { expect } from 'storybook/test';
 
// 👇 Must include the `.mock` portion of filename to have mocks typed correctly
import { revalidatePath } from '@storybook/your-framework/cache.mock';
 
import MyForm from './my-form';
 
const meta = {
  component: MyForm,
} satisfies Meta<typeof MyForm>;
 
export default meta;
type Story = StoryObj<typeof meta>;
 
export const Submitted: Story = {
  async play({ canvas, userEvent }) {
    const submitButton = canvas.getByRole('button', { name: /submit/i });
    await userEvent.click(saveButton);
    // 👇 Use any mock assertions on the function
    await expect(revalidatePath).toHaveBeenCalledWith('/');
  },
};

`@storybook/nextjs/headers.mock`

类型: cookiesheaders 和 draftMode 来自 Next.js

此模块导出 可写 的 next/headers 模块导出的模拟实现。您可以使用它来设置在故事中读取的 cookie 或 header,并在稍后断言它们已被调用。

Next.js 默认导出的 headers() 是只读的,但此模块公开了允许您写入 header 的方法

  • headers().append(name: string, value: string): 如果 header 已存在,则将值附加到 header。
  • headers().delete(name: string): 删除 header
  • headers().set(name: string, value: string): 将 header 设置为提供的值。

对于 cookie,您可以使用现有 API 写入它们。例如, cookies().set('firstName', 'Jane') 。

因为 headers()、 cookies() 及其子函数都是模拟的,所以您可以在故事中使用任何 模拟工具,例如 headers().getAll.mock.calls 。

MyForm.stories.ts
// Replace your-framework with nextjs or nextjs-vite
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import { expect } from 'storybook/test';
 
// 👇 Must include the `.mock` portion of filename to have mocks typed correctly
import { cookies, headers } from '@storybook/your-framework/headers.mock';
 
import MyForm from './my-form';
 
const meta = {
  component: MyForm,
} satisfies Meta<typeof MyForm>;
 
export default meta;
type Story = StoryObj<typeof meta>;
 
export const LoggedInEurope: Story = {
  async beforeEach() {
    // 👇 Set mock cookies and headers ahead of rendering
    cookies().set('username', 'Sol');
    headers().set('timezone', 'Central European Summer Time');
  },
  async play() {
    // 👇 Assert that your component called the mocks
    await expect(cookies().get).toHaveBeenCalledOnce();
    await expect(cookies().get).toHaveBeenCalledWith('username');
    await expect(headers().get).toHaveBeenCalledOnce();
    await expect(cookies().get).toHaveBeenCalledWith('timezone');
  },
};

`@storybook/nextjs/navigation.mock`

类型: typeof import('next/navigation') & getRouter: () => ReturnType<typeof import('next/navigation')['useRouter']>

此模块导出 next/navigation 模块导出的模拟实现。它还导出一个 getRouter 函数,该函数返回 Next.js 从 useRouter 获取的 router 对象的模拟版本,允许操作和断言其属性。您可以使用它来模拟实现,或在故事的 play function 中断言模拟调用。

MyForm.stories.ts
// Replace your-framework with nextjs or nextjs-vite
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import { expect } from 'storybook/test';
 
// 👇 Must include the `.mock` portion of filename to have mocks typed correctly
import { redirect, getRouter } from '@storybook/your-framework/navigation.mock';
 
import MyForm from './my-form';
 
const meta = {
  component: MyForm,
  parameters: {
    nextjs: {
      // 👇 As in the Next.js application, next/navigation only works using App Router
      appDirectory: true,
    },
  },
} satisfies Meta<typeof MyForm>;
 
export default meta;
type Story = StoryObj<typeof meta>;
 
export const Unauthenticated: Story = {
  async play() {
    // 👇 Assert that your component called redirect()
    await expect(redirect).toHaveBeenCalledWith('/login', 'replace');
  },
};
 
export const GoBack: Story = {
  async play({ canvas, userEvent }) {
    const backBtn = await canvas.findByText('Go back');
 
    await userEvent.click(backBtn);
    // 👇 Assert that your component called back()
    await expect(getRouter().back).toHaveBeenCalled();
  },
};

`@storybook/nextjs/router.mock`

类型: typeof import('next/router') & getRouter: () => ReturnType<typeof import('next/router')['useRouter']>

此模块导出 next/router 模块导出的模拟实现。它还导出一个 getRouter 函数,该函数返回 Next.js 从 useRouter 获取的 router 对象的模拟版本,允许操作和断言其属性。您可以使用它来模拟实现,或在故事的 play function 中断言模拟调用。

MyForm.stories.ts
// Replace your-framework with nextjs or nextjs-vite
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import { expect } from 'storybook/test';
 
// 👇 Must include the `.mock` portion of filename to have mocks typed correctly
import { getRouter } from '@storybook/your-framework/router.mock';
 
import MyForm from './my-form';
 
const meta = {
  component: MyForm,
} satisfies Meta<typeof MyForm>;
 
export default meta;
type Story = StoryObj<typeof meta>;
 
export const GoBack: Story = {
  async play({ canvas, userEvent }) {
    const backBtn = await canvas.findByText('Go back');
 
    await userEvent.click(backBtn);
    // 👇 Assert that your component called back()
    await expect(getRouter().back).toHaveBeenCalled();
  },
};

选项

如果需要,您可以传递一个选项对象进行额外配置

.storybook/main.js
import * as path from 'path';
 
// Replace your-framework with nextjs or nextjs-vite
export default {
  // ...
  framework: {
    name: '@storybook/your-framework',
    options: {
      image: {
        loading: 'eager',
      },
      nextConfigPath: path.resolve(__dirname, '../next.config.js'),
    },
  },
};

可用选项如下

`builder`

类型: Record<string, any>

配置框架 builder 的选项。对于 Next.js,可在 Webpack builder 文档 中找到可用选项。

`image`

类型: object

传递给 next/image 每个实例的 props。更多详情请参阅 next/image 文档

`nextConfigPath`

类型: string

`next.config.js` 文件的绝对路径。如果您的 next.config.js 文件不在项目根目录中,则需要此项。

参数

此框架在 nextjs 命名空间下为 Storybook 贡献了以下 参数

`appDirectory`

类型: boolean

默认值: false

如果您的 story 导入使用了 next/navigation 的组件,则需要将参数 nextjs.appDirectory 设置为 true 。由于这是一个参数,您可以将其应用于 单个 story某个组件的所有 stories 或 Storybook 中的所有 stories 。更多详情请参阅 Next.js Navigation

类型

{
  asPath?: string;
  pathname?: string;
  query?: Record<string, string>;
  segments?: (string | [string, string])[];
}

默认值

{
  segments: [];
}

传递给 next/navigation 上下文的 router 对象。更多详情请参阅 Next.js navigation 文档

`router`

类型

{
  asPath?: string;
  pathname?: string;
  query?: Record<string, string>;
}

传递给 next/router 上下文的 router 对象。更多详情请参阅 Next.js router 文档