Storybook 的previewHead与previewBody配置指南:以编程方式调整预览 HTML
【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook
本篇指南讲解 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、bodyHTML 元素,与使用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 增强能力。
【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考