首页
/ Storybook ESLint 规则 no-stories-of 深度解读:告别 storiesOf,全面拥抱 CSF

Storybook ESLint 规则 no-stories-of 深度解读:告别 storiesOf,全面拥抱 CSF

2026-09-06 18:15:42作者:董灵辛Dennis

no-stories-ofeslint-plugin-storybook(本仓库 code/lib/eslint-plugin)中的一条核心规则,它的职责非常单一而坚决:只要代码里从 Storybook 渲染器导入 storiesOf,就直接报错。这条规则存在的根本原因,是 Storybook 自 5.2 版本起以 Component Story Format(CSF)作为编写 story 的推荐方式,而 storiesOf 这套旧式 API 已被官方移除。读完本文,你将完整掌握该规则的判定逻辑、启用方式(csf-strict / flat/csf-strict)、正确的 CSF 写法,以及使用官方 codemod 一键把存量 storiesOf 代码迁移到 CSF 的实战流程。

一、背景:为什么 storiesOf 必须被淘汰

在 Storybook 的早期版本中,storiesOf 是组织与声明 story 的标准写法。它的特征是通过链式调用 .add() 动态地往某个层级中追加 story,其 API 形态大致如下:

import { storiesOf } from '@storybook/react';

import Button from '../components/Button';

storiesOf('Button', module).add('primary', () => <Button primary />);

从 Storybook 5.2 开始,官方引入了 Component Story Format(CSF),把"一个 story"重新定义为模块顶层的一个具名导出。相比 storiesOf 那种"在模块执行时动态注册"的命令式 API,CSF 是完全声明式、静态可分析的:story 文件本身就是一个普通的 ES Module,story 就是导出值,元信息通过 default 导出声明。

正如该规则的官方说明 no-stories-of.md 所述:从引入 CSF 开始,storiesOf 就处于被弃用状态,如今该 API 已从 Storybook 中移除。因此任何新代码都不应再使用它,存量代码也应尽快迁移。

二、规则速览:属于哪套配置、报什么错

这条规则由 Yann Braga 维护,实现在 src/rules/no-stories-of.ts。规则元信息(meta)中明确标注了以下关键属性:

属性 说明
rule name no-stories-of 规则全名,在配置中写作 storybook/no-stories-of
type problem 归类为"代码确实有问题",而非风格(suggestion)类
severity error 规则自身的默认严重级别为 error
categories csf-strict 通过 CategoryId.CSF_STRICT 常量挂载
messages.doNotUseStoriesOf storiesOf is deprecated and should not be used 触犯规则时输出的报错文案
options schema [] 该规则不接受任何可配置参数

也就是说,这条规则是"一刀切"且无选项的——它不接受任何开关或白名单,一旦命中就报错。规则文档首部的自动生成区块还标注了它的配置归属:

Included in these configurations: csf-strictflat/csf-strict

这意味着只要你启用了 eslint-plugin-storybookcsf-strict 配置(legacy 模式)或 flat/csf-strict(ESLint flat config 模式),该规则就会自动以 error 级别生效,无需手动逐条声明。

三、违规与合规示例:规则到底盯住什么

3.1 违规代码(incorrect)

规则文档给出的典型违规示例就是从渲染器导入 storiesOf 并使用链式 .add()

import { storiesOf } from '@storybook/react';

import Button from '../components/Button';

storiesOf('Button', module).add('primary', () => <Button primary />);

3.2 合规代码(correct)之一:CSF 函数式 story

把上面的例子改写为 CSF:用 default 导出声明元信息(component),再把每个 story 变成具名导出(如 Primary):

import Button from "../components/Button";

export default {
  component: Button
};

export const Primary = () => <Button primary />;

3.3 合规代码(correct)之二:CSF 对象式 story(带 args)

另一种等价但更推荐的 CSF 写法是把 story 声明为对象,通过 args 描述传入组件的属性。这样 story 变得完全可序列化,便于 controls、测试和复用:

import Button from "../components/Button";

export default {
  component: Button
};

export const Primary = {
  args: {
    primary: true
  }
};

说明:规则文档中的正确示例保留了 export default = {...} 的字样,那是文档笔误——ES Module 的默认导出语法应为 export default {...}(本仓库规则测试用例 no-stories-of.test.ts 中的合法示例均使用 export default {)。上面两段代码已修正为可真实运行的形式。

3.4 从测试用例看边界的精确判定

src/rules/no-stories-of.test.ts 中,该规则被标注为 valid 的用例包括:

import Button from '../components/Button';
export default {
  title: 'Button',
  component: Button
} as ComponentMeta<typeof Button>;

export const Primary: Story = () => <Button primary />;

也就是说,只要不存在 storiesOf 的具名导入,即使是带 ComponentMeta / Story 类型标注的 TS 版 CSF 也完全合法。而被标记为 invalid 的用例则准确命中错误节点类型为 ImportSpecifier

import { storiesOf } from '@storybook/react';
import Button from '../components/Button';

storiesOf('Button', module)
  .add('primary', () => <Button primary />)

四、规则的实现机制:它在 AST 上做了什么

阅读 src/rules/no-stories-of.ts 的源码可以看到,规则逻辑非常精简:它基于 @typescript-eslint/utilsRuleCreator(封装在 create-storybook-rule.ts 中),只注册了一个 AST 节点监听器 ImportSpecifier

return {
  ImportSpecifier(node) {
    if ('name' in node.imported && node.imported.name === 'storiesOf') {
      context.report({
        node,
        messageId: 'doNotUseStoriesOf',
      });
    }
  },
};

从这段实现可以提炼出三个关键事实:

  1. 报错点精确到 import 语句本身。规则监听的是 ES Module 的具名导入节点(ImportSpecifier),当 imported 的名称恰为 storiesOf 时,直接把错误报告标记在该导入节点上。
  2. 仅针对"具名导入"路径拦截。判断条件是 imported.name === 'storiesOf',因此任何 import { storiesOf } from '@storybook/xxx' 形式(React、Vue3、Angular、Web Components 等渲染器)都会被捕获。
  3. 命中即报错、不做二次分析。规则没有去分析后续是否真的调用了 storiesOf(...).add(...),因为只要引入了该 API 基本可以断定是在编写旧式 stories;报错文案直接复用消息模板 doNotUseStoriesOf

这种"极简拦截 + 明确报错"的设计正是该规则能够稳定运行的原因:它只依赖 import 语句就能完成判定,不涉及复杂的数据流分析。

五、如何启用该规则

5.1 方式一:整包启用 csf-strict 配置(推荐)

先按 eslint-plugin-storybook/README.md 的指引安装插件。插件本身已经打包了所需的 CSF 辅助工具,无需为了加载该 ESLint 插件而额外安装 storybook(例如在 monorepo 的共享 ESLint preset 中这一点很关键):

npm install eslint-plugin-storybook --save-dev
# 或
yarn add eslint-plugin-storybook --dev

随后在 legacy 版 .eslintrc 中直接继承 csf-strict

{
  "extends": ["plugin:storybook/csf-strict"]
}

在 flat config(eslint.config.js)中则引入对应的 flat 配置:

import storybook from 'eslint-plugin-storybook';

export default [
  ...storybook.configs['flat/csf-strict'],
  // ... 其余配置
];

code/lib/eslint-plugin/src/configs/csf-strict.tscode/lib/eslint-plugin/src/configs/flat/csf-strict.ts 中可以看到该配置的具体内容:它基于 csf 配置扩展,并对 story 文件开启了额外的严格规则,其中就包括 'storybook/no-stories-of': 'error'

rules: {
  'react-hooks/rules-of-hooks': 'off',
  'import-x/no-anonymous-default-export': 'off',
  'storybook/no-stories-of': 'error',
  'storybook/no-title-property-in-meta': 'error',
}

同时,两个配置文件都限定了规则的适用文件范围,只针对 story 源码:

files: ['**/*.stories.@(ts|tsx|js|jsx|mjs|cjs)', '**/*.story.@(ts|tsx|js|jsx|mjs|cjs)']

5.2 方式二:在任意配置中单独开启

如果你不想整体切换 csf-strict,也可以只对 story 文件单独开启这一条:

{
  "overrides": [
    {
      "files": ["**/*.stories.@(ts|tsx|js|jsx|mjs|cjs)", "**/*.story.@(ts|tsx|js|jsx|mjs|cjs)"],
      "rules": {
        "storybook/no-stories-of": "error"
      }
    }
  ]
}

5.3 别忘了处理 .storybook 目录

根据插件 README 的提醒,建议在 .eslintignore(或 flat config 的 ignores)中放行 .storybook 目录,让插件能同时检查其中的配置文件(例如 addon 名称拼写):

!.storybook

六、存量代码自动迁移:官方的 storiesof-to-csf codemod

手动逐文件改写存量 storiesOf 代码既繁琐又容易出错。规则文档提供了官方的一键迁移命令——在项目根目录执行即可:

npx storybook@latest migrate storiesof-to-csf --glob="*/**/*.stories.@(tsx|jsx|ts|js)"

对该命令的几个要点说明:

  • npx storybook@latest 会临时拉取最新版 Storybook CLI 并执行其内置的 migrate 子命令;
  • storiesof-to-csf 是迁移名称,代表"把 storiesOf 写法改写成 CSF 写法"这一转换;
  • --glob 用于限定需要扫描/改写哪些文件,默认覆盖 *.stories.tsx/jsx/ts/js 四类典型 story 文件。若你的项目同时存在 *.story.* 后缀,可以按需调整 glob 模式(例如追加 *.story.@(tsx|jsx|ts|js))。

从本仓库源码结构看,Storybook 的 codemod 转换器集中在 code/lib/codemod/src/transforms 目录下,例如 csf-2-to-3upgrade-hierarchy-separators 等。其中 upgrade-hierarchy-separators 的测试夹具(code/lib/codemod/src/transforms/testfixtures/upgrade-hierarchy-separators)就包含 dynamic-storiesof.input.jsstoriesof.input.js 等以 storiesOf 语法作为输入的样例,可见 Storybook 的迁移工具链长期以来都围绕"消化 storiesOf 旧语法"做了大量适配。如果你在迁移后还想进一步把老式 CSF 规范成当前推荐写法,可以继续关注同一目录下的 csf-2-to-3 迁移。

七、迁移后的自查要点

完成迁移与开启规则后,建议对照以下清单自查,确保存量与新增代码都不再触碰 storiesOf

  1. 全局搜索:在项目中搜索 storiesOf(from '@storybook/*' 中是否仍残留 storiesOf 具名导入,确保 import { storiesOf } 不再出现。
  2. lint 验证:运行 eslint "*/**/*.stories.@(tsx|jsx|ts|js)",确认 storybook/no-stories-of 无任何 error。
  3. 结构校验:每个 story 文件都应具备 export default(声明 componenttitle)与至少一个 story 的具名导出;story 既可以是函数(export const Primary = () => <Button primary />),也可以是带 args 的对象(export const Primary = { args: { primary: true } })。

遵守 no-stories-of,本质上是让 story 从"运行时动态注册"走向"静态可分析的模块导出"。这不仅能获得 ESLint、TypeScript 与各类工具链的静态分析支持,也让 story 作为组件的可复用输入(args)得以在测试、文档与可视化调试之间顺畅流动——这正是 Storybook 在 CSF 时代推荐的最佳实践。

登录后查看全文
热门项目推荐
相关项目推荐