首页
/ Storybook `previewBody` 配置指南:在 main 配置中编程式改写预览页面 Body

Storybook `previewBody` 配置指南:在 main 配置中编程式改写预览页面 Body

2026-09-07 10:05:46作者:袁立春Spencer

previewBody 是 Storybook 的 main.js|ts 配置项之一,用于以编程方式调整预览(preview)iframe 的 <body> 内容,例如根据运行环境条件性地注入脚本或样式。本文以官方配置文档为主体,结合本仓库中该选项的类型定义、preset 实现与模板加载源码,完整讲解其 API 语义、静态文件备选方案、底层原理与可验证依据,帮助你在项目或自定义 addon 中安全、精准地改写 Storybook 预览页面的 body。

一、previewBody 是什么,何时需要它

在 Storybook 中,所有 story 都渲染在名为 Canvas 的预览 iframe 内。这个 iframe 本身的 HTML 骨架由 Storybook 生成,其 <head><body> 允许你按需扩展,从而配合自定义字体、注入统计脚本、添加自定义 DOM 挂载点等。

previewBody 的作用就是程序化地改写这个预览 <body>,它接收一个代表当前 body HTML 的字符串,并返回修改后的字符串:

Type: (body: string) => string

官方文档特别提示,它最常被 addon 作者使用(见 Writing presets 中 UI configuration 小节);如果普通项目只需要固定注入一段静态 HTML,无需程序化改写,则应该优先使用静态文件 preview-body.html(下文第三节详述)。两种方式的差异可以概括为:

方式 载体 典型场景
静态注入 .storybook/preview-body.html 注入固定的 <div> 挂载点、全局基准样式
程序化注入 main 配置中的 previewBody(body) 依赖环境变量、条件注入、addon 动态改写

二、在 main.js / main.ts 中启用 previewBody

配置入口在 .storybook/main.js.storybook/main.ts。仓库中的官方示例片段位于 docs/_snippets/main-config-preview-body.md,下面是其最常见的两种写法。

CSF 3: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)'],
  previewBody: (body) => `
    ${body}
    ${
      process.env.ANALYTICS_ID ? '<script src="https://cdn.example.com/analytics.js"></script>' : ''
    }
  `,
};

CSF 3: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)'],
  previewBody: (body) => `
    ${body}
    ${
      process.env.ANALYTICS_ID ? '<script src="https://cdn.example.com/analytics.js"></script>' : ''
    }
  `,
};

export default config;

要点说明:

  • stories 数组决定了 Storybook 扫描 story 与文档文件的范围;实际使用时应替换为你项目的路径。
  • 模板字符串开头的 ${body} 必须保留,它是 Storybook 原有的 body HTML,丢弃它会破坏预览页面的正常渲染。
  • 示例利用 process.env.ANALYTICS_ID 判断当前环境,只有设置了该环境变量时才向 body 末尾追加 <script> 统计脚本。同理,你也可以条件性注入 <style> 标签,例如仅在本地开发时引入调试样式。

CSF Next:基于 defineMain 的类型安全写法

对于 React、Vue、Angular、Web Components 等 renderer,仓库还提供了基于 defineMain 的类型安全写法,从对应框架包的 node 入口导入。以下为官方示例中的完整形态:

// Replace your-framework with the framework you are using (e.g., react-vite, nextjs, 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)'],
  previewBody: (body) => `
    ${body}
    ${
      process.env.ANALYTICS_ID ? '<script src="https://cdn.example.com/analytics.js"></script>' : ''
    }
  `,
});

其余 renderer 只需替换导入来源与 framework 字段:

import { defineMain } from '@storybook/vue3-vite/node';

export default defineMain({
  framework: '@storybook/vue3-vite',
  stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
  previewBody: (body) => `
    ${body}
    ${
      process.env.ANALYTICS_ID ? '<script src="https://cdn.example.com/analytics.js"></script>' : ''
    }
  `,
});
import { defineMain } from '@storybook/angular/node';

export default defineMain({
  framework: '@storybook/angular',
  stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
  previewBody: (body) => `
    ${body}
    ${
      process.env.ANALYTICS_ID ? '<script src="https://cdn.example.com/analytics.js"></script>' : ''
    }
  `,
});
import { defineMain } from '@storybook/web-components-vite/node';

export default defineMain({
  framework: '@storybook/web-components-vite',
  stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
  previewBody: (body) => `
    ${body}
    ${
      process.env.ANALYTICS_ID ? '<script src="https://cdn.example.com/analytics.js"></script>' : ''
    }
  `,
});

同理,JavaScript 项目也可以直接从 @storybook/your-framework/node 导入 defineMain,得到与 TS 相同的 previewBody 配置能力。

三、不加代码也能注入:静态 preview-body.html 备选方案

官方文档在 API 参考页 明确建议:如果你不需要程序化改写预览 body,直接在 .storybook/preview-body.html 中写静态 HTML 即可。这一做法的详细说明见 Story rendering 文档中 Adding to <body> 一节

例如,需要为组件提供一个额外的 DOM 挂载点时,在 .storybook 目录下创建 preview-body.html(示例见 storybook-preview-body-example.md):

<div id="custom-root"></div>

当项目使用 remem 等相对单位时,可在同一文件里通过 <style> 调整根字号基准(示例见 storybook-preview-body-font-size.md):

<style>
  html {
    font-size: 15px;
  }
</style>

需要注意,无论静态文件还是 previewBody 函数,Storybook 注入的都是渲染组件所用的 preview iframe,而不是 Storybook 应用自身的 UI(管理端),见 story-rendering.mdx 中的 Callout 说明。

四、类型与运行时约束

在源码层面,previewBody 被声明在两个位置,先看类型定义: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']>;

其底层原始配置项类型为 previewBody?: string(见同文件 core-common.ts)。由于它被 PresetValue<> 包裹,既可以被解析为函数,也可以作为 preset 机制中的普通值传递。因此在实际使用中需要注意:

  1. 该回调在 Node 端(构建/启动 Storybook 时)执行,而不是浏览器运行时;
  2. 函数接收的 body 是已经完成的 body 内容,你返回的新字符串会整体替换它,所以务必基于原 body 做追加或替换,而不是另起炉灶;
  3. 由于执行在 Node 环境,process.env.* 这类读取是有效的——这正是示例中条件注入依赖的机制。

五、底层实现:previewBody preset 与模板如何被加载

previewBody 配置最终会被解析成 core-server 的一个 preset 项。实现位于 code/core/src/core-server/presets/common-preset.ts

export const previewBody = async (base: any, { configDir, presets }: Options) => {
  const interpolations = await presets.apply<Record<string, string>>('env');
  return getPreviewBodyTemplate(configDir, interpolations);
};

这里存在一个关键的间接层:该 preset 读取的是静态模板文件(即 preview-body.html),并把所有 preset 汇总后的 env 插值一并传入。也就是说,main 配置中的 previewBody 函数与静态 preview-body.html 是同一注入通道的两级入口,最终都要经过 getPreviewBodyTemplate 生成 body HTML。

实际拼接逻辑位于 code/core/src/common/utils/template.ts

export function getPreviewBodyTemplate(
  configDirPath: string,
  interpolations?: Record<string, string>
) {
  const base = readFileSync(
    join(resolvePackageDir('storybook'), 'assets/server/base-preview-body.html'),
    'utf8'
  );

  const bodyHtmlPath = resolve(configDirPath, 'preview-body.html');
  let result = base;

  if (existsSync(bodyHtmlPath)) {
    result = readFileSync(bodyHtmlPath, 'utf8') + result;
  }

  return interpolate(result, interpolations);
}

从源码结构可以读出三个实现事实:

  1. 存在一个内置基准模板base-preview-body.html 是打包在 storybook 包中的默认 body 模板;
  2. 静态文件内容被前置:当 .storybook/preview-body.html 存在时,其内容会被读取并放在基准模板之前fileContent + base),而不是像 head 那样追加在基准模板之后(对照同文件 getPreviewHeadTemplate 的实现);
  3. 支持 %VAR% 占位符插值interpolate 会将 %KEY% 形式的占位符替换为 preset 链路中收集到的环境变量值(见 template.ts),这与 common-preset.tsenv preset 返回的 raw 环境对象相衔接。

另外,previewBody 与对称的 previewHead 均由 code/core/src/core-server/index.ts 统一导出(getPreviewHeadTemplategetPreviewBodyTemplate),addon 作者可通过 preset 机制在预设配置中改写它们。

六、行为验证:模板加载的单元测试

仓库为上述模板逻辑提供了直接的单元测试,见 code/core/src/common/utils/tests/template.test.ts。其中关键断言包括:

  • 当目录下不存在 preview-body.html 时,getPreviewBodyTemplate 返回内置基准模板内容(BASE_BODY_HTML_CONTENTS);
  • 当存在自定义 preview-body.html 时,返回内容会包含自定义部分,验证了「读取文件并拼接」的行为。

这组测试可以当作你验证自身配置的最小参考:如果你修改了 .storybook/preview-body.html 却发现 preview 页面没有变化,可优先检查文件是否真的位于 configDir(默认即 .storybook)下,以及项目使用的构建流程是否正确应用了 previewBody 这个 preset。

七、配置指引小结

项目
配置位置 .storybook/main.js / .storybook/main.ts
类型签名 (body: string) => string
导入方式 CSF 3:普通对象导出 StorybookConfig;CSF Next:从 @storybook/<framework>/node 导入 defineMain
静态替代方案 .storybook/preview-body.html(无需程序化改写时)
注入目标 渲染组件的 preview iframe,而非 Storybook 管理 UI
执行环境 Node(构建 / dev server 启动阶段)
底层 preset common-preset.tstemplate.ts

最后再强调两条避坑建议:不要丢掉 ${body} 原内容,否则会覆盖 Storybook 默认的预览骨架;previewBody 与静态 preview-body.html 是同一注入通道的两个入口,若两者同时使用,需注意静态文件内容会被前置拼接到基准模板之前,理解这一顺序有助于排查自定义样式被覆盖或渲染层级异常的问题。

登录后查看全文
热门项目推荐
相关项目推荐