Storybook 的 `previewHead` 与 `previewBody` 配置指南:以编程方式调整预览 HTML
本篇指南讲解 Storybook 中两个由 Preset 驱动的配置入口 previewHead 与 previewBody。它们在 .storybook/main.js 或 main.ts 中通过函数接收 Storybook 渲染 iframe 当前的 <head>、<body> 内容并返回修改后的字符串,可用于按环境条件注入脚本、样式或字体,也能被 addon 作者用于封装 UI 定制能力。读完本文,你将掌握两种配置的签名、适用场景、静态文件(preview-head.html / preview-body.html)与编程方案的取舍,以及底层模板是如何与 HTML 文件合并的。
配置项速览:类型与文档位置
previewHead 与 previewBody 属于 main.js|ts 顶层配置 家族,官方文档分别定义在:
- main-config-preview-head.mdx:类型为
(head: string) => string,用于程序化调整预览区<head>。 - main-config-preview-body.mdx:类型为
(body: string) => string,用于程序化调整预览区<body>。
在类型层面,它们被统一声明在核心类型模块 code/core/src/types/modules/core-common.ts 中:
/**
* 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']>;
previewBody?: PresetValue<StorybookConfigRaw['previewBody']>;
注意这里的 PresetValue 语义:这两个字段本质上是 Preset 入口(preset 机制)的一个组成部分。也就是说,它们并不只是"你项目的某次性配置",而是 Storybook 在加载、合并 presets 时对每个 preset 的 previewHead / previewBody 依次调用后拼接得到最终 HTML 的预设值。正因如此,writing-presets.mdx 明确指出:预设 API 提供对 UI 配置的访问,包括通过 previewHead 与 previewBody 配置的 preview 的 head、body HTML 元素,与使用 preview-head.html、preview-body.html 文件的效果类似。
调用链路:common-preset.ts
在实现层面,Storybook 的核心预设 code/core/src/core-server/presets/common-preset.ts 提供了两个核心 preset 函数:
export const previewHead = async (base: any, { configDir, presets }: Options) => {
const interpolations = await presets.apply<Record<string, string>>('env');
return getPreviewHeadTemplate(configDir, interpolations);
};
export const previewBody = async (base: any, { configDir, presets }: Options) => {
const interpolations = await presets.apply<Record<string, string>>('env');
return getPreviewBodyTemplate(configDir, interpolations);
};
它们做的事是:先通过 presets.apply('env') 收集环境变量插值,再读取模板文件内容(详见下文"模板如何生成"),拿到"已有字符串"后交给你的 previewHead(head => ...) / previewBody(body => ...) 修改。换句话说:你函数收到的字符串参数,是底层模板已经渲染好的 <head>/<body> 内容,你只需在其基础上追加(或改写)并原样返回。
何时用编程式配置、何时用静态 HTML 文件
Storybook 提供了两套等价机制向预览 iframe 注入内容:
| 需求场景 | 静态 HTML 文件方案 | 编程式 preset 方案 |
|---|---|---|
| 注入固定脚本 / 样式,无需条件判断 | 在 .storybook/preview-head.html 或 preview-body.html 中添加内容 |
无需使用 previewHead / previewBody |
| 按环境、按特性开关条件注入 | 无法实现(HTML 文件是纯静态的) | previewHead / previewBody 函数返回拼接结果 |
| 开发插件 / addon 需要修改 UI | 用户难以复用 addon 的逻辑 | addon 在 preset 中导出这两个字段,随 addon 一并生效 |
官方文档在 main-config-preview-head.mdx 给出的 Callout 很明确:如果你不需要程序化调整 preview 的 <head>,可以直接改用 preview-head.html 添加脚本与样式(previewBody 对应 preview-body.html)。反之,需要条件判断或要在 addon 里封装能力时,就使用 previewHead / previewBody 函数。
这个设计在 template.ts 的实现中也得到印证:模板函数会先检查 .storybook/preview-head.html / preview-body.html 是否存在,存在则把静态文件内容与内置基础模板拼接,再交给预设管线。也就是说,HTML 文件方案与函数方案并不会冲突,而是分层处理——文件内容先被合并进基础 HTML,你的函数再拿到合并结果做最后一步加工。
模板如何生成:preview-head.html / preview-body.html 与基础模板合并
previewHead / previewBody 的函数参数到底长什么样?看 code/core/src/common/utils/template.ts 的实现:
export function getPreviewHeadTemplate(configDirPath, interpolations?) {
const base = readFileSync(
join(resolvePackageDir('storybook'), 'assets/server/base-preview-head.html'),
'utf8'
);
const headHtmlPath = resolve(configDirPath, 'preview-head.html');
let result = base;
if (existsSync(headHtmlPath)) {
result += readFileSync(headHtmlPath, 'utf8');
}
return interpolate(result, interpolations);
}
对应地,getPreviewBodyTemplate 的逻辑是将用户 preview-body.html 的内容前置到内置基础模板 base-preview-body.html 之前再合并。两处都会经过 interpolate() 做环境变量插值——即把 %VAR_NAME% 占位符替换为 presets.apply('env') 得到的环境变量值。
配套单元测试 code/core/src/common/utils/tests/template.test.ts 也验证了两个关键行为:
- 当
.storybook/preview-body.html不存在时,返回空内容 / 仅基础模板; - 当文件存在时,返回"用户文件内容 + 基础模板"的合并结果。
值得注意的实现细节:<head> 合并时静态内容被追加到基础模板之后(result += fileContent),而 <body> 合并时静态内容被插入到基础模板之前(fileContent + result)。这意味着你自定义的 <head> 内容会出现在 Storybook 内置 head 内容之后,通常更适合做覆盖;而自定义 body 内容会出现在基础 body 之前。
在 .storybook/main.ts 中使用 previewHead
文档示例的主用法是按环境条件注入样式或脚本。以 CSF 3 与标准配置文件为例,修改 代码示例片段 中 Head (CSF 3) 对应的 TS 写法(previewHead 版本;其他渲染器与格式仅导入路径不同):
// .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 = {
previewHead: (head) => `
${head}
<style>
html, body {
background: #827979;
}
</style>
`,
};
export default config;
核心要点:
- 必须保留
${head}:收到的参数是当前已有<head>内容的完整字符串,忘记拼接会直接破坏 Storybook 预览所需的既有脚本与样式。 - 返回完整的新字符串:你的函数签名是
(head: string) => string,返回什么,Storybook 就使用什么。 - JS(CommonJS/ESM)写法与之完全一致,只是把类型注解去掉、直接导出对象:
// .storybook/main.js
export default {
previewHead: (head) => `
${head}
<style>
html, body {
background: #827979;
}
</style>
`,
};
在 .storybook/main.ts 中使用 previewBody
previewBody 的签名是 (body: string) => string,典型用法是在页面底部按条件追加第三方脚本(例如分析脚本)。文档中的 Body 示例(CSF 3 的 TS 写法)为:
// .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 = {
previewBody: (body) => `
${body}
${
process.env.ANALYTICS_ID ? '<script src="https://cdn.example.com/analytics.js"></script>' : ''
}
`,
};
export default config;
这里展示了与 previewHead 不同的典型用途:
- 把注入逻辑建立在环境变量判断之上(
process.env.ANALYTICS_ID存在时才输出<script>标签); - 因为返回的是普通字符串,你可以做任意字符串处理,如条件拼接、压缩空白或插值;
- 使用该功能时,开发与生产(构建)环境下
process.env的可用变量以 main-config-env 描述的加载机制为准。
各框架 / 格式的代码形态(CSF 3 与 CSF Next)
针对不同渲染器和新的配置写法,官方代码片段 main-config-preview.md 覆盖了多个变体,完整包含:
| 写法形态 | 导入来源 | 配置对象 |
|---|---|---|
CSF 3 .storybook/main.js |
直接导出 { previewHead, previewBody } |
普通对象字面量 |
CSF 3 .storybook/main.ts |
import type { StorybookConfig } from '@storybook/your-framework' |
类型为 StorybookConfig |
CSF Next 🧪(实验性)main.ts |
import { defineMain } from '@storybook/your-framework/node' |
用 defineMain({ ... }) 包裹 |
CSF Next 的实验形态示例(以 Vue 渲染器为例):
// .storybook/main.ts
import { defineMain } from '@storybook/vue3-vite/node';
export default defineMain({
previewHead: (head) => `
${head}
<style>
html, body {
background: #827979;
}
</style>
`,
});
而 React 系列的 CSF Next 写法把类型导入从主包换到 Node 入口(如 @storybook/react-vite/node),Angular 为 @storybook/angular/node,Web Components 为 @storybook/web-components-vite/node。JS 文件同样支持 defineMain,只需去掉类型注解。
需要区分:文档中的
previewHead/previewBody配置与另一组head/body静态文件方案并不冲突,同时配置时按前面"模板如何生成"一节的顺序合并。此外,向**管理界面(manager)**注入内容应使用 main-config-manager-head 对应的机制,不要与这里的预览区配置混用。
典型场景与最佳实践小结
综合官方文档与源码,可以总结以下使用建议:
- addon 作者的首选封装:官方在 main-config-preview-head.mdx 与 main-config-preview-body.mdx 中明确写"Most often used by addon authors"(最常被插件作者使用)。addon 在自身 preset 中导出
previewHead/previewBody,用户安装后即可自动获得注入效果,无需手动编辑 HTML 文件。 - 条件注入优先用函数:从仓库的
common-preset.ts看,函数形式在整个 preset 管线中最后执行,拿到的是合并后的完整内容,因此可以基于process.env或自身逻辑决定保留、替换或追加。 - 永远保留入参前缀:两个函数都是"接收完整字符串 → 返回完整字符串"的纯函数式约定(见 core-common.ts 的注释),漏掉
${head}/${body}会导致 Storybook 预览页面缺失运行时所需的基础 HTML。 - 静态内容优先用文件:若注入的是固定不变的脚本/样式,直接用
preview-head.html/preview-body.html更直观、无需经过函数层;仓库模板函数(template.ts)会自动检测并合并这些文件。 - 头部与 body 的合并顺序不同:自定义 head 内容追加在基础 head 之后、自定义 body 内容前置在基础 body 之前,涉及样式覆盖或首个脚本执行时机时可利用这一顺序差异。
通过 previewHead 与 previewBody,你可以在不修改 Storybook 内部渲染模板的前提下,对组件预览 iframe 的文档头部与正文做精确、可复用、可条件化的定制——无论是为项目引入全局主题样式、按环境加载分析脚本,还是以 addon 形式向用户交付开箱即用的 UI 增强能力。
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 StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00