Storybook `previewHead` 配置指南:在 `.storybook/main.js|ts` 中动态注入预览页 `<head>` 内容
导读
previewHead 是 Storybook 主配置(main.js|ts)中用于编程式调整 preview 预览页 <head> 的核心配置项,类型为 (head: string) => string。它适用于需要根据运行环境(如 NODE_ENV、自定义环境变量)或运行时状态条件注入第三方脚本、外链字体与全局样式,以及编写 Addon 时向预览页统一注入脚本的场景。读完本篇,你将掌握 previewHead 的完整签名、JS/TS 与各框架写法、与静态文件 preview-head.html 的取舍,以及它在源码中从主配置到 iframe 构建产物的完整执行链路。
为什么需要 previewHead
Storybook 的界面由两个互相隔离的 HTML 文档构成:
- Manager(管理界面):包含左侧导航栏、工具栏、面板等 UI 外壳;
- Preview(预览页):真正渲染组件/story 的 iframe 页面。
previewHead 操作的是** Preview iframe 的 <head>**,即渲染组件的那一层文档头部。因此适合它的内容,是对“被渲染的组件自身运行环境”产生影响的脚本与样式(例如组件主题所需的全局 CSS 变量、字体、外部 SDK),而不是对 Storybook 管理界面产生作用的脚本。
从类型定义看,公共配置接口中该字段被定义为:
/**
* Programmatically modify the preview head/body HTML. The previewHead and previewBody functions
* accept a string, which is the existing head/body, and return a modified string.
*/
previewHead?: PresetValue<StorybookConfigRaw['previewHead']>;
其中 PresetValue<T> = T | ((config: T, options: Options) => T | Promise<T>)(见 core-common.ts)。也就是说 previewHead 既可以是一个直接返回的字符串,也可以是一个接收“当前已有 head 内容”并返回新内容的函数——官方推荐并广泛使用的正是函数形式,因为它允许你保留 Storybook 默认注入的头部内容。
基础用法:函数形式注入自定义内容
previewHead 的签名非常直白:它接收一个字符串参数 head,该参数是 Storybook 处理好的现有 head 内容(包含基础模板与 .storybook/preview-head.html 静态文件内容),你负责在其基础上拼接新内容并整体返回。
以下示例演示了根据环境变量条件注入统计脚本的经典写法,同时覆盖 main.js(Common 渲染器,CSF 3)、main.ts(含 CSF Next)以及 React / Vue 3 / Angular / Web Components 等框架的 defineMain 写法。
经典 CSF 3 写法(.storybook/main.js)
export default {
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc.
framework: '@storybook/your-framework',
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
previewHead: (head) => `
${head}
${
process.env.ANALYTICS_ID ? '<script src="https://cdn.example.com/analytics.js"></script>' : ''
}
`,
};
类型安全的 .storybook/main.ts
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, 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)'],
previewHead: (head) => `
${head}
${
process.env.ANALYTICS_ID ? '<script src="https://cdn.example.com/analytics.js"></script>' : ''
}
`,
};
export default config;
CSF Next 写法(.storybook/main.ts + defineMain)
在框架的 node 端入口(如 @storybook/react-vite/node)提供了 defineMain 工厂(见 react-vite 入口、vue3-vite 入口、angular 入口、web-components-vite 入口)时,推荐使用:
// Replace your-framework with the framework you are using (e.g., react-vite, nextjs, nextjs-vite)
import { defineMain } from '@storybook/react-vite/node';
export default defineMain({
framework: '@storybook/react-vite',
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
previewHead: (head) => `
${head}
${
process.env.ANALYTICS_ID ? '<script src="https://cdn.example.com/analytics.js"></script>' : ''
}
`,
});
Vue 3、Angular、Web Components 等框架只需把 defineMain 的导入路径与 framework 字段换成对应框架包即可,例如 Vue 3 使用 @storybook/vue3-vite/node + framework: '@storybook/vue3-vite',Angular 使用 @storybook/angular/node + framework: '@storybook/angular',Web Components 使用 @storybook/web-components-vite/node + framework: '@storybook/web-components-vite'。
CSF Next 写法(.storybook/main.js)
// Replace your-framework with the framework you are using (e.g., react-vite, nextjs, nextjs-vite)
import { defineMain } from '@storybook/react-vite/node';
export default defineMain({
framework: '@storybook/react-vite',
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
previewHead: (head) => `
${head}
${
process.env.ANALYTICS_ID ? '<script src="https://cdn.example.com/analytics.js"></script>' : ''
}
`,
});
三处值得注意的要点:
- 必须拼接
${head}:传入的head参数是 Storybook 已经生成的头部基线。若忘记在模板字符串中输出${head},会直接丢掉基础头部内容,可能导致预览页运行异常。上例把所有新内容都放在${head}之后追加,安全且顺序可控。 - 环境变量按需注入:示例通过
process.env.ANALYTICS_ID判断是否注入统计脚本。这展示了previewHead相对静态preview-head.html的核心优势——同一份配置在不同环境(开发/CI/本地)下可以产出不同的 head 内容。 - 字符串拼接自由度高:返回值是普通字符串,因此既可以做简单的字符串拼接,也可以使用数组
join、条件分支乃至调用其他工具函数,只要是最终产出一个合法的 HTML 片段字符串即可。
何时使用 previewHead,何时使用 preview-head.html
如果不需要编程式地调整,Storybook 提供了更简单的静态文件方式:在配置目录 .storybook 下创建 preview-head.html,其内容会被原样追加到预览页 head(详见 story-rendering 配置文档)。
两者的取舍可归纳为:
| 场景 | 推荐方式 | 理由 |
|---|---|---|
无条件注入固定的脚本、<link>、字体或全局样式 |
.storybook/preview-head.html |
声明式、简单直接、无需 JS |
需要依据 process.env.*、构建模式或自定义逻辑动态决定注入内容 |
`main.js | ts的previewHead` 函数 |
| Addon / Preset 作者希望向所有用户预览页统一注入脚本或样式 | previewHead(在 Preset 中导出) |
通过 preset 机制与用户配置无缝合并 |
从源码看,previewHead 函数最终产出的字符串是在静态文件之上继续叠加的。在 template.ts 中,getPreviewHeadTemplate() 会读取 Storybook 自带的 base-preview-head.html 作为基底,若 .storybook/preview-head.html 存在则将其追加到基底之后,最后再做 %KEY% 占位符插值。换言之,即使你同时使用了 preview-head.html 与 previewHead 函数,函数中收到的 head 参数也已经包含前者,两种方式可以共存而不会互相覆盖。
这一行为也直接反映在单测 template.test.ts 中:当 preview-head.html 不存在时返回纯基础模板内容;当存在时返回 BASE_HTML_CONTENTS + HEAD_HTML_CONTENTS(基础内容在前、静态文件内容追加在后)。
执行链路:从 main.js 到 iframe 的 <head>
理解了用法后,了解它背后的 preset 合并与模板注入机制,有助于在排查“head 内容没有生效 / 被覆盖”问题时快速定位。
第一步:preset 的 reduce 式合并
Storybook 会把主配置与所有 Addon/Preset 一起加载,并以 preset 为单位做链式合并。presets.ts 中的 applyPresets 对所有 preset 依次执行 reduce:当某个 preset 导出的同名配置项是函数时,就以前一次的输出作为入参继续调用,形如:
configA(head) => configB(head) => ... => 最终 head 字符串
因此你的 main.js 中 previewHead 返回的结果会成为链路的最终输出(前提是你的主配置在加载链末尾),而传入函数的第一参数正是上游 preset(含 Addon 注入)已经处理好的 head。
第二步:核心 preset 生成默认 head
common-preset.ts 中定义了 previewHead 默认 preset,它先通过 presets.apply('env') 拿到环境变量插值表,再调用前文提到的 getPreviewHeadTemplate(configDir, interpolations) 组装出基础 + preview-head.html 的内容。这也是为什么你在自定义函数里能拿到一份“已含默认内容”的 head 字符串。
第三步:builder 将 head 注入 iframe 模板
以 Vite builder 为例,transform-iframe-html.ts 在 transformIframeHtml() 中通过 presets.apply('previewHead') 与 presets.apply('previewBody') 取得最终片段,并将其替换进 iframe HTML 模板的 <!-- [HEAD HTML SNIPPET HERE] --> 与 <!-- [BODY HTML SNIPPET HERE] --> 占位符。Webpack5 builder 也有对应的 iframe HTML 处理(见 iframe-webpack.config.ts)。由此可以确认:previewHead 的输出在 dev server 与静态构建时都会被消费,无论你通过 storybook dev 还是 storybook build 启动都会生效。
模板插值:%VAR% 占位符
template.ts 中的 interpolate() 会用 %KEY% 占位符与传入的数据做全局替换(template.ts)。这解释了官方模板文件中常见的 %BASE_URL% 一类变量从何而来:它们来源于 env preset(见 common-preset.ts 的 loadEnvs)。这意味着你在 preview-head.html 静态文件里也可以使用 %YOUR_ENV_VAR% 这类占位符,由 Storybook 在读取时统一插值。
与相关配置项的对比
previewBody:注入 <body> 起始位置
与 previewHead 对称的 previewBody 接收 (body: string) => string,用于向预览页 <body> 开头注入内容(例如全屏背景容器、加载遮罩、Provider 外包层等),详见 previewBody 配置文档。它同样支持 .storybook/preview-body.html 静态文件方式,且从 template.test.ts 看,body 的拼接顺序与 head 相反——静态文件内容在前、基础模板在后。两者共享同一套模板插值与 preset 合并机制。
managerHead:管理界面(而非预览 iframe)
容易混淆的是 managerHead。它编程式调整的是 Storybook Manager(外壳 UI)的 <head>,与渲染组件的 iframe 完全隔离,仅处理 manager-head.html 静态文件(见 common-preset.ts 的 managerHead preset)。注入自定义 UI 主题、Manager 侧字体时应使用它,详见 managerHead 配置文档。
面向 Addon / Preset 作者的补充说明
previewHead 最常见的生产场景其实是 Addon 作者向用户的预览页统一注入脚本与样式(参考 writing-presets 文档 中的 UI 配置一节)。在编写自定义 Addon 的 preset 时,你可以这样组合:
export const previewHead = (head: string) => `
${head}
<script src="your-addon-runtime.js"></script>
`;
得益于第一步介绍过的 reduce 合并链,多个 Addon 的 previewHead 会依次叠加、互不覆盖;只要每个 preset 都保留 ${head},用户的最终 head 就能同时包含所有参与方的贡献。这也是把预览页内容“插桩”做得幂等、可组合的推荐方式。
调试与验证
- 开发模式下打开 Storybook 页面,用浏览器开发者工具检查 预览 iframe 内部 的
<head>(注意不要误看 Manager 外壳的<head>);确认自定义<script>、<link>是否出现在</head>闭合标签之前、且位于默认内容之后。 - 若注入的脚本没有生效,优先检查
main.js中framework、stories之外是否真的导出了previewHead,以及是否误用了module.exports(主配置文件必须是 ESM,需使用export default,详见 主配置总览)。 - 若内容“莫名被覆盖”,检查是否有其他 Addon/Preset 也导出了
previewHead且丢弃了入参head——reduce 链中任一环丢弃参数都会造成后续内容整体丢失。 - 环境变量相关逻辑建议通过
process.env.XXX直接读取,并留意在调试运行与 CI 构建时的取值差异。
小结
previewHead 是把“环境感知的 HTML 注入能力”交还给开发者与 Addon 作者的官方入口:它以 (head: string) => string 的函数签名挂载在主配置 main.js|ts 上,经由 preset reduce 链完成多来源合并,最终由 builder 注入预览 iframe 的 <head>。与静态 preview-head.html 各司其职、与 previewBody/managerHead 分工明确,掌握它即可系统性地管理 Storybook 预览环境的脚本、样式与集成代码。
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