Storybook Codemods:基于 JSCodeshift 的 Story 文件批量迁移工具
本文以 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 方式相比手工方式多做了什么:
-
校验名称:先调用
listCodemods(),若传入的 codemod 不在列表中直接抛错; -
解析 rename 参数:若提供了
--rename,必须满足from:to两段式格式,否则报Codemod rename: expected format "from:to"; -
解析文件列表:用
tinyglobby对--glob求值,且自动追加!**/node_modules与!**/dist两条排除规则;若匹配到 0 个文件,打印No matching files for glob后直接返回; -
推断 parser:若未显式传
--parser,会从 glob 的文件扩展名推断——src/lib/utils.ts 中jscodeshiftToPrettierParser维护了映射表(babylon→babel、flow→flow、ts/tsx→typescript,默认babel),仅当推断结果不是babel时才会向 jscodeshift 传--parser; -
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使单个文件报错时整体退出码非零; -
错误与重命名处理:若 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 的“导出对象字面量”形式。其工作流可以从源码中归纳为:
- 解析 CSF:调用
storybook/internal/csf-tools的loadCsf解析整个文件,得到_meta、_storyExports、_storyAnnotations等结构化信息;若解析失败则原样返回,保证不会破坏坏文件; - 识别 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 }对象导出;
- 简单 story(无参箭头函数且无任何注解):直接改写类型标注为
- 清理:删除已被提升进对象的注解语句(如
A.parameters = {...})、删除不再被引用的模板变量(removeUnusedTemplates)、移除废弃的Story类型导入; - 类型升级:内嵌调用
upgradeDeprecatedTypes完成下一节的类型迁移; - 格式化:
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/test 的 expect(...).toHaveBeenCalled() 断言前的代码清理提供了入口。
文档与当前源码的差异说明
需要提醒的是,README 的 Transforms 章节只完整记录了 upgrade-hierarchy-separators 与 csf-hoist-story-annotations 两个 transform,而当前仓库 src/transforms 目录实际包含 csf-2-to-3、find-implicit-spies、upgrade-deprecated-types、upgrade-hierarchy-separators 四个实现。可以推断 README 相对当前版本有所滞后,这也是 src/index.ts 中 listCodemods() 采用“运行时扫描 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_modules与dist已被内置排除规则覆盖。
小结
@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 明确跳过的文件上。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00