首页
/ Storybook Codemods:基于 JSCodeshift 的 Story 文件批量迁移工具

Storybook Codemods:基于 JSCodeshift 的 Story 文件批量迁移工具

2026-09-06 17:50:09作者:羿妍玫Ivan

本文以 Storybook 仓库中的 @storybook/codemod 包(code/lib/codemod/README.md)为核心,讲解这套基于 JSCodeshift 编写的 codemod 脚本集合是如何工作的:既讲清楚了 README 中记录的 CLI 集成方式与手工运行方式,也深入到 code/lib/codemod/src 源码,解析 sb migrate 命令的完整执行链路、四个 transform 的实现细节与参数含义,帮助你在执行 Storybook 大版本迁移(层级分隔符、CSF 2 → 3、废弃类型清理)时能够准确选择并安全运行对应的 codemod。

Codemods 是什么

Storybook Codemods 是一组用 JSCodeshift(一个基于 AST 的 JavaScript 代码变换引擎)编写的自动化迁移脚本,用于帮助开发者批量处理 Storybook 大版本升级中的 breaking changes 与 deprecations。仓库中它对应 @storybook/codemod 包,位于 code/lib/codemod,核心结构为:

  • src/index.ts:导出 listCodemods / runCodemod 两个入口函数,供 CLI 调用;
  • src/transforms/:每个 transform 是一个独立的 JSCodeshift 变换脚本;
  • src/lib/utils.ts:辅助工具(命名清理、jscodeshift parser 到 prettier parser 的映射);
  • package.json:通过 exports 字段暴露各 transform,如 ./transforms/csf-2-to-3./transforms/upgrade-hierarchy-separators

其中 jscodeshift 是执行迁移的引擎,@storybook/codemod 则提供具体的变换逻辑,两者分工明确。

CLI 集成:通过 sb migrate 运行(推荐方式)

README 推荐的运行方式是通过 Storybook CLI 的 migrate 命令,其实现位于 code/lib/cli-storybook/src/migrate.ts

查看可用的 codemod 列表

npx sb migrate --list

从源码看,--list 会调用 src/index.ts 中的 listCodemods():它通过 readdirSync 读取已安装的 @storybook/codemod/dist/transforms 目录,过滤出所有 .js 文件并去掉后缀作为 codemod 名称输出。这意味着运行时实际可用的 codemod 列表取决于你安装的包版本中 dist/transforms 目录的内容,这也是为什么升级 Storybook 版本后建议重新执行一次 --list 确认。

运行一个 codemod

npx sb migrate <name-of-codemod> --glob="**/*.stories.js"

migrate 命令支持的选项(见 migrate.ts 中的 CLIOptions 定义):

选项 含义
<migration> 位置参数,codemod 名称,必须与 --list 输出一致,否则报错 Unknown codemod ... Run --list for options
--glob 匹配要迁移的文件的 glob 模式,如 **/*.stories.js
--list 仅列出所有可用 codemod,不执行迁移
--dry-run 试运行,只统计匹配文件、不实际写入(源码中对应 dryRun,见下文)
--rename 迁移完成后批量重命名文件,格式为 from:to,例如 ".js:.ts"
--parser 指定 jscodeshift 解析器,取值为 babel | babylon | flow | ts | tsx

如果既没有指定 migration 名称也没有 --list,命令会抛出 Migrate: please specify a migration name or --list

手工运行 codemod(不依赖 CLI)

README 同时给出了不通过 CLI、直接驱动 jscodeshift 的手工方式,适合需要在 CI 或脚本中精细控制的场景:

yarn add jscodeshift @storybook/codemod --dev
  • @storybook/codemod 是 codemod 脚本集合;
  • jscodeshift 是执行 codemod 的引擎。

随后在安装了两者的目录下运行(以 upgrade-hierarchy-separators 为例):

./node_modules/.bin/jscodeshift -t ./node_modules/@storybook/codemod/dist/transforms/upgrade-hierarchy-separators.js . --ignore-pattern "node_modules|dist"

命令结构解析:

<jscodeShiftCommand> -t <transformFileLocation> <pathToSource> --ignore-pattern "<globPatternToIgnore>"
  • -t 指定 transform 脚本位置(注意必须是 dist/transforms/ 下编译后的 .js);
  • 第四个参数是源码路径(示例中为当前目录 .);
  • --ignore-pattern 用 glob 模式排除不需要扫描的目录。

迁移完成后,README 建议可以把这两个包从 package.json 中移除,因为它们只是迁移期工具。

sb migrate 的底层执行链路

runCodemod 的实现(src/index.ts)解释了 CLI 方式相比手工方式多做了什么:

  1. 校验名称:先调用 listCodemods(),若传入的 codemod 不在列表中直接抛错;

  2. 解析 rename 参数:若提供了 --rename,必须满足 from:to 两段式格式,否则报 Codemod rename: expected format "from:to"

  3. 解析文件列表:用 tinyglobby--glob 求值,且自动追加 !**/node_modules!**/dist 两条排除规则;若匹配到 0 个文件,打印 No matching files for glob 后直接返回;

  4. 推断 parser:若未显式传 --parser,会从 glob 的文件扩展名推断——src/lib/utils.tsjscodeshiftToPrettierParser 维护了映射表(babylon→babelflow→flowts/tsx→typescript,默认 babel),仅当推断结果不是 babel 时才会向 jscodeshift 传 --parser

  5. spawn 执行 jscodeshift:通过 cross-spawn 同步启动:

    node <jscodeshift>/bin/jscodeshift --no-babel --extensions=<文件实际扩展名列表> --fail-on-error -t <TRANSFORM_DIR>/<codemod>.js [--parser <x>] <匹配到的文件列表>
    

    几个值得注意的细节:--no-babel 是为了避免 codeshift 用 babel 解析自身依赖从而变慢并刷出 [BABEL] 警告;--extensions 传的是实际匹配文件的扩展名并集(逗号分隔),保证混合 js/ts 文件也能正确解析;--fail-on-error 使单个文件报错时整体退出码非零;

  6. 错误与重命名处理:若 jscodeshift 退出码为 1,则打印 Skipped renaming because of errors 并中止(不会在代码出错的情况下重命名文件);成功后若配置了 --rename,会并发地对每个文件执行 from→to 的重命名并逐条打印 Rename: ... 日志。

源码中还有一个针对 mdx-to-csf 的特殊分支:该 codemod 失败时会将无法转换的文件改名为 .mdx.broken 并提示用户手工处理后改回。当前仓库的 transforms 目录中未包含该脚本,这是为兼容更高版本包而保留的处理逻辑。

Transforms 详解

README 专门记录了两个 transform 的用途与示例;结合当前源码树(code/lib/codemod/src/transforms),现共有四个 transform。

upgrade-hierarchy-separators(5.3 起:统一层级分隔符为 /

从 5.3 开始,Storybook 改用单一路径分隔符 / 表示 story 层级:旧版中 | 用于 story “roots”(可选),/. 用于表示路径。该 codemod 把旧写法统一更新为新写法:

storiesOf('Foo|Bar/baz');
storiesOf('Foo.Bar.baz');

export default {
  title: 'Foo|Bar/baz.whatever',
};

迁移后变为:

storiesOf('Foo/Bar/baz');
storiesOf('Foo/Bar/baz');

export default {
  title: 'Foo/Bar/baz/whatever',
};
./node_modules/.bin/jscodeshift -t ./node_modules/@storybook/codemod/dist/transforms/upgrade-hierarchy-separators.js . --ignore-pattern "node_modules|dist"

从实现看(upgrade-hierarchy-separators.js),它只做一件事:upgradeSeparator 用正则 /[|.]/g|. 全部替换为 /。AST 层面它处理两类节点:

  • storiesOf(...) 调用:仅当第一个实参是字符串字面量(Literal / StringLiteral)时才改写,动态拼接的 title 会被跳过(测试夹具 __testfixtures__/upgrade-hierarchy-separators/dynamic-storiesof.input.js 正是验证这一保守行为);
  • export default { title: '...' }:仅改写 CSF 对象中名为 title 的字符串属性。

输出通过 root.toSource({ quote: 'single' }) 保持单引号风格。测试与快照见 code/lib/codemod/src/transforms/testfixtures/upgrade-hierarchy-separators

csf-hoist-story-annotations(6.0 起:.story 注解提升)

README 记录的第二个 transform 对应 6.0 的变更:CSF 中 story.story 对象注解被废弃,改为“提升注解”(hoisted annotations),直接挂在导出函数上:

export const Basic = () => <Button />
Basic.story = {
  name: 'foo',
  parameters: { ... },
  decorators: [ ... ],
};

迁移后:

export const Basic = () => <Button />
Basic.storyName = 'foo';
Basic.parameters = { ... };
Basic.decorators = [ ... ];

README 指出新语法更紧凑、更顺手,且与 React 的 displayName/propTypes/defaultProps 注解风格一致。运行命令额外加了 --extensions=js 限定扩展名:

./node_modules/.bin/jscodeshift -t ./node_modules/@storybook/codemod/dist/transforms/csf-hoist-story-annotations.js . --ignore-pattern "node_modules|dist" --extensions=js

csf-2-to-3:从 CSF 2 函数式 story 迁移到 CSF 3 对象式 story

当前源码中功能最重的 transform 是 csf-2-to-3.ts,它把 CSF 2 的“函数导出 + 逐条注解”形式转换为 CSF 3 的“导出对象字面量”形式。其工作流可以从源码中归纳为:

  1. 解析 CSF:调用 storybook/internal/csf-toolsloadCsf 解析整个文件,得到 _meta_storyExports_storyAnnotations 等结构化信息;若解析失败则原样返回,保证不会破坏坏文件;
  2. 识别 story 形态:对每个 story 导出(如 export const A = Template.bind({}) 或箭头函数),区分三种情况——
    • 简单 story(无参箭头函数且无任何注解):直接改写类型标注为 StoryFn
    • 模板绑定:识别 Template.bind({}) 形式,将 render 指向模板变量;
    • 与全局 render 重复的 story:若 meta 声明了 component: Cat 而 story 恰好是 (args) => <Cat {...args} /> 这种纯透传函数,则删除冗余的 render(见 isReactGlobalRenderFn);
    • 其余情况生成 export const A: StoryObj = { render, ...annotations } 对象导出;
  3. 清理:删除已被提升进对象的注解语句(如 A.parameters = {...})、删除不再被引用的模板变量(removeUnusedTemplates)、移除废弃的 Story 类型导入;
  4. 类型升级:内嵌调用 upgradeDeprecatedTypes 完成下一节的类型迁移;
  5. 格式化printCsf 输出后再用 prettier.format(并尝试读取项目自身 prettier 配置)重排,失败时仅告警、不中断。

值得注意的是该 transform 明确不支持命名空间导入:遇到 import * as React from ... 式的 renderer 导入会抛出 This codemod does not support namespace imports 并要求先改写为具名导入。

upgrade-deprecated-types:废弃类型重命名

upgrade-deprecated-types.ts 针对 @storybook/* 包中已废弃的类型标识符,映射关系为:

废弃类型 迁移为
Story StoryFn
ComponentStory StoryFn
ComponentStoryFn StoryFn(去 Component 前缀)
ComponentStoryObj StoryObj
ComponentMeta Meta

它的处理逻辑(upgradeDeprecatedTypes 函数)分两步遍历:第一步扫所有 @storybook 开头的 import,替换废弃的导入名(若新类型名已存在则去重移除,若存在同名的别名导入则抛出带 code frame 的明确错误,提示重命名本地导入后重试);第二步扫 TSTypeReference,替换所有类型引用位置,包括 SB.StoryObj 这种命名空间限定写法(import * as SB)。该函数同时被 csf-2-to-3 复用,保证两条迁移路径的类型处理一致。

find-implicit-spies:play 函数中的隐式 spy 审计

find-implicit-spies.ts 是一个只审计、不改写的 codemod:它遍历每个 story 的 play 函数(兼容 CSF 2 的 Story.play = ... 与 CSF 3 的 const Story = { play: ... } 两种形态),找出形如 on[A-Z]... 的标识符——这类命名通常是 args 中声明的事件回调。如果某个 onXxx 既不在 meta 的 args/argTypes 里、也不在该 story 的 args/argTypes 里,就会打印带 code frame 的警告 Possible implicit spy found,提示该回调可能是一个没有被显式声明的隐式 spy,建议显式声明。这为接入 @storybook/testexpect(...).toHaveBeenCalled() 断言前的代码清理提供了入口。

文档与当前源码的差异说明

需要提醒的是,README 的 Transforms 章节只完整记录了 upgrade-hierarchy-separatorscsf-hoist-story-annotations 两个 transform,而当前仓库 src/transforms 目录实际包含 csf-2-to-3find-implicit-spiesupgrade-deprecated-typesupgrade-hierarchy-separators 四个实现。可以推断 README 相对当前版本有所滞后,这也是 src/index.tslistCodemods() 采用“运行时扫描 dist 目录”而非硬编码列表的原因——npx sb migrate --list 的输出作为可用 codemod 的权威清单,是处理版本差异的最稳妥做法。

实操建议

  • 先 dry-run 再执行:CLI 的 --dry-run 只统计匹配文件数、不写盘,适合先验证 --glob 范围是否正确;
  • 在干净的工作区运行:所有 transform 都是原地改写文件,运行前确保 git 工作区可提交、可回滚;
  • 利用 --fail-on-error 语义:CLI 链路上 jscodeshift 带 --fail-on-error 执行,任一文件失败整体即报错并跳过 rename,因此出现失败日志时不要手动重命名,先修复源文件再重跑;
  • 缩小 glob 范围:相比手工方式对整个 . 目录扫描,sb migrate --glob="**/*.stories.{ts,tsx,js,jsx}" 只处理 story 文件,更快也更安全;node_modulesdist 已被内置排除规则覆盖。

小结

@storybook/codemod 是 Storybook 版本迁移的工程化抓手:sb migrate 命令(code/lib/cli-storybook/src/migrate.ts)封装了 glob 解析、parser 推断、失败中止与安全重命名等细节(code/lib/codemod/src/index.ts),而四个 transform 分别覆盖了层级分隔符统一、CSF 2→3 对象化、废弃类型重命名与隐式 spy 审计四类典型迁移需求。掌握 --list / --dry-run / --rename / --parser 各选项与 transform 的 AST 级行为边界(如动态 title 不处理、命名空间导入会报错),就能在大版本升级中把大量机械性改动交给自动化完成,并将人工精力集中在 codemod 明确跳过的文件上。

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