Storybook 模块别名配置指南:用 viteFinal / webpackFinal 将依赖替换为 Mock 模块
在 Storybook 中渲染或测试组件时,常常需要隔离其模块依赖(网络请求、session、第三方工具库等)。除了官方推荐、开箱即用的 Automocking(基于 sb.mock)之外,Storybook 还提供了一种名为 Builder Aliases(构建器别名) 的替代方案:直接在 .storybook/main.* 中修改构建器配置,让 Vite 或 Webpack 在解析模块时把「原模块路径」指向「mock 文件」。本文将以 docs/_snippets/module-aliases-config.md 中的完整配置为骨架,讲清楚这一方案的使用场景、配置细节、Vite/Webpack 的差异以及背后的实现原理。读完后,你可以为 Vite 与 Webpack 两类构建器、CSF 3 与实验性 CSF Next 两种配置风格,分别写出可运行的模块别名 mock 配置。
别名机制在整个 Mock 体系中的定位
在 mocking-modules.mdx 中,Storybook 把模块 mock 划分为三种手段,按推荐程度排序:
- Automocking:通过
storybook/test中的sb.mock()注册模块,适用于 Vite 和 Webpack 构建器,配置成本最低; - Subpath Imports:借助 Node 的
#子路径导入与package.json的imports字段实现按条件切换模块; - Builder Aliases:当项目无法使用前两种方案时,直接修改构建器的模块解析别名(alias),在打包 Storybook 时把模块替换成 mock 文件。
本文的主角就是第三种。它与前两者的关键区别在于:mock 替换发生在模块解析(resolve)阶段,即构建器在「找文件」这一环节就把路径改写为 mock 文件,而不是在运行时拦截。因此它不依赖 sb.mock 或 imports 条件导出,属于最底层、最通用也最「外科手术式」的手段。
这种写法需要你预先准备 mock 文件(例如与源文件同目录的 session.mock.ts),mock 文件通常用 storybook/test 的 fn 工具包裹原函数,以便在 story 中控制行为并断言调用。参见 storybook-test-mock-file-example.md:
import { fn } from 'storybook/test';
import * as actual from './session';
export * from './session';
export const getUserFromSession = fn(actual.getUserFromSession).mockName('getUserFromSession');
工作原理:别名是如何在 Storybook 的构建管线里生效的
之所以能在 .storybook/main.* 中改别名,是因为 Storybook 的构建器在产出最终配置前会暴露一个可编程的 hook:
- 对于 Vite,Storybook 在 code/builders/builder-vite/src/build.ts 中通过
presets.apply('viteFinal', config, options)把开发者提供的viteFinal函数与内部生成的 Vite 配置合并,viteFinal的返回值最终送入viteBuild; - 对于 Webpack,code/builders/builder-webpack5/src/presets/custom-webpack-preset.ts 定义了
webpackFinal(config, options),并在 第 89 行 调用presets.apply('webpackFinal', defaultConfig, options)应用这一 hook。
也就是说,viteFinal / webpackFinal 都是异步的最终配置回调:Storybook 先装配好一份完整可用的构建配置(其中 config.resolve.alias 已包含默认别名),再调用你的回调做增量改写。你在回调里对 config.resolve.alias 展开合并,即用「对象展开」保留原有别名(...config.resolve?.alias),再叠加自己的路径映射。
正是由于别名最终进入真实构建器配置,Vite 侧甚至会把对象形式的 alias 归一化为数组形式 [{ find, replacement }](见 code/builders/builder-vite/src/change-detection-adapter/headless.ts 中的相关注释),最终作用于所有模块解析。所以:只要组件里出现与该 alias key 完全匹配的 import 语句,构建器就会把目标换成你指定的 mock 文件。
Vite 构建器的别名配置
下面是 snippet 中 Vite(CSF 3)的完整配置。它同时演示了两类 mock 目标:外部 npm 包(lodash)与项目内部模块(@/api、@/app/actions、@/lib/session、@/lib/db):
export default {
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs-vite, vue3-vite, etc.
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: import.meta.resolve('./lodash.mock'),
// 👇 Internal modules
'@/api': import.meta.resolve('./api.mock.ts'),
'@/app/actions': import.meta.resolve('./app/actions.mock.ts'),
'@/lib/session': import.meta.resolve('./lib/session.mock.ts'),
'@/lib/db': import.meta.resolve('./lib/db.mock.ts'),
};
}
return config;
},
};
对应的 TypeScript 版本使用 @storybook/your-framework 导出的 StorybookConfig 类型做约束,其余逻辑一致:
// 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: import.meta.resolve('./lodash.mock'),
// 👇 Internal modules
'@/api': import.meta.resolve('./api.mock.ts'),
'@/app/actions': import.meta.resolve('./app/actions.mock.ts'),
'@/lib/session': import.meta.resolve('./lib/session.mock.ts'),
'@/lib/db': import.meta.resolve('./lib/db.mock.ts'),
};
}
return config;
},
};
export default config;
几个需要重点理解的细节:
if (config.resolve)守卫:config是 Vite 的InlineConfig,某些边缘场景下resolve字段可能缺失。做展开前先判空,避免在undefined上解构抛错,是官方推荐写法的稳健性所在。import.meta.resolve(...)的作用:别名替换值必须是绝对路径/文件 URL。import.meta.resolve('./lodash.mock')是 Node.js 原生 ESM 方法,能够把相对于当前main.js(即.storybook/目录)的路径解析成绝对文件 URL。mock 文件就放在.storybook/目录下(如.storybook/lodash.mock、.storybook/api.mock.ts),与配置同目录便于定位。若你的运行环境不支持该方法,也可以改用path.resolve(__dirname, '...')等绝对路径方案。- Vite 的 alias 是前缀匹配:
@/lib/session会命中所有以该字符串开头的导入。正因为如此,Vite 版本的 key 不需要(也不建议)额外加尾缀;但 Webpack 情况不同,见下文。
Webpack 构建器的别名配置与 $ 精确匹配
Webpack 的配置入口换成了 webpackFinal,结构完全对称:
export default {
// Replace your-framework with the framework you are using (e.g., nextjs, react-webpack5)
framework: '@storybook/your-framework',
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
webpackFinal: async (config) => {
if (config.resolve) {
config.resolve.alias = {
...config.resolve.alias,
// 👇 External module
lodash: import.meta.resolve('./lodash.mock'),
// 👇 Internal modules
'@/api$': import.meta.resolve('./api.mock.ts'),
'@/app/actions$': import.meta.resolve('./app/actions.mock.ts'),
'@/lib/session$': import.meta.resolve('./lib/session.mock.ts'),
'@/lib/db$': import.meta.resolve('./lib/db.mock.ts'),
};
}
return config;
},
};
// Replace your-framework with the framework you are using (e.g., nextjs, react-webpack5)
import type { StorybookConfig } from '@storybook/your-framework';
const config: StorybookConfig = {
framework: '@storybook/your-framework',
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
webpackFinal: async (config) => {
if (config.resolve) {
config.resolve.alias = {
...config.resolve.alias,
// 👇 External module
lodash: import.meta.resolve('./lodash.mock'),
// 👇 Internal modules
'@/api$': import.meta.resolve('./api.mock.ts'),
'@/app/actions$': import.meta.resolve('./app/actions.mock.ts'),
'@/lib/session$': import.meta.resolve('./lib/session.mock.ts'),
'@/lib/db$': import.meta.resolve('./lib/db.mock.ts'),
};
}
return config;
},
};
export default config;
Vite 与 Webpack 的最大差异在于别名 key 的写法:
- Webpack 的 alias 默认是前缀匹配,
@/api会同时吞掉@/api与@/api/utils等所有子路径导入; - 若只想让
@/api这一个确切导入被替换(这正是 mock 场景的诉求),必须在 key 末尾加$(如@/api$),表示精确匹配; - Vite 的别名语义并不依赖
$尾缀,因此两套配置的 key 必须分开维护:Vite 写@/api,Webpack 写@/api$。
外部模块 lodash 在两种构建器下都不带 $,这是因为它的目标通常是「整个包」整体替换(例如用 ./lodash.mock 这个单文件 mock 顶替整个 lodash),前缀匹配反而符合预期。而项目内部模块常常需要保留子路径导入,比如 @/lib/db/users 应该继续按原逻辑解析,所以用 $ 锁死到 mock 那个入口本身。
实验性 CSF Next 配置风格:defineMain
如果项目启用了 Storybook 实验性的 CSF Next 配置(以 🧪 标识),.storybook/main.* 的写法从「默认导出对象」变为「调用 defineMain()」,并需要从对应框架包的 /node 子路径导入该函数。例如 Vite(React)下的 CSF Next 写法:
// Replace your-framework with the framework you are using (e.g., react-vite, nextjs-vite)
import { defineMain } from '@storybook/your-framework/node';
export default defineMain({
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: import.meta.resolve('./lodash.mock'),
// 👇 Internal modules
'@/api': import.meta.resolve('./api.mock.ts'),
'@/app/actions': import.meta.resolve('./app/actions.mock.ts'),
'@/lib/session': import.meta.resolve('./lib/session.mock.ts'),
'@/lib/db': import.meta.resolve('./lib/db.mock.ts'),
};
}
return config;
},
});
defineMain 会对配置对象做类型推导与统一校验,使 framework、stories 与 viteFinal / webpackFinal 的字段类型更严格。框架包与构建器的对应关系需要注意:
- Vue 3(Vite):从
@storybook/vue3-vite/node导入defineMain,framework: '@storybook/vue3-vite'; - Web Components(Vite):从
@storybook/web-components-vite/node导入,framework: '@storybook/web-components-vite'; - Angular(Webpack):从
@storybook/angular/node导入,framework: '@storybook/angular',别名 key 同样需要$精确匹配写法; - 其他框架(React、Next.js 等)统一使用
@storybook/<框架>/node的形式。
这些入口文件就由仓库中各框架包的 package.json 的 exports 映射导出,例如 code/frameworks/vue3-vite、code/frameworks/angular、code/frameworks/web-components-vite 等目录各自提供 /node 的 Node 侧入口。从源码结构看,defineMain 是 CSF Next 下对普通对象配置的类型安全包装,本质上仍是产出同样结构的 main 配置,只是享受了编译期校验。
在 stories 中消费被别名替换的模块
配置完成之后,组件与 story 里的 import 语句不需要任何改动,这也是别名方案最省心的地方:构建器在解析时已经把导入静默替换。若你需要在 story 内显式拿到 mock 对象以便设置返回值、做断言,则用与项目代码一致的别名导入即可,例如:
import { getUserFromSession } from '@/lib/session';
由于 mock 文件内部通过 fn(来自 storybook/test)包装了原函数,你得到的是完整的 Vitest mock 函数,可配合 mockReturnValue(value)、mockResolvedValue(value)、mockImplementation(fn) 等方法控制行为,或用 mockName() 保证压缩后名称可读。mock 文件只要被别名命中,其导出的 mock 函数就会在渲染阶段进入组件;而在 beforeEach、play function 等 hook 中动态调整返回值即可实现对不同场景的覆盖。
常见注意事项
把别名方案应用到真实项目时,有几个从上述配置结构与构建器语义中可以推导出的要点:
- mock 文件遵循「相对导入 + 全量再导出」约定:mock 文件内应使用相对路径导入原模块(绝不能再用别名导入自身),并通过
export *透传原模块其余导出,只对需要 mock 的具名导出用fn(actual.x)覆盖; - 区分「整个外部包」与「内部模块」两种映射:外部包走宽匹配,内部模块在 Webpack 下务必加
$;若某些工具库走深层导入(如lodash-es/add),需要在别名里配置到对应深度的 key,或对包提供完整 mock 文件; - 保持别名 key 与项目自身的解析配置一致:示例中的
@前缀别名通常是项目已在tsconfig.json、Vite/Webpack 原生配置里定义好的;若项目还没有@别名,可参考 storybook-main-ts-module-resolution-atsign-import.md 先在 Storybook 配置里把@指向src目录; - CSF 3 与 CSF Next 两套风格要选一套贯彻到底:普通导出对象用于 CSF 3,
defineMain用于实验性 CSF Next,混用会导致配置含义不一致; - 优先考虑 Automocking:对于使用 Vite/Webpack 且未被其他测试工具占用
__mocks__约定的项目,官方文档建议优先使用sb.mock自动 mock;只有项目无法使用自动 mock(例如 Webpack 下需要 mock 带 CommonJS 入口的第三方包、或与其他工具的 mock 配置冲突)时,才退回到本文的别名方案。所有 mock 手段的取舍与边界条件,都汇总在 mocking-modules.mdx 中,可作为对照参考。
借助 viteFinal / webpackFinal 这两个构建器 hook,你可以在不侵入组件源码的前提下,把任何模块依赖在 Storybook 预览环境下无缝替换为可控的 mock 文件——这正是隔离式开发与组件级测试所依赖的基础设施。
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 StartedRust0627
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