在 Storybook 中通过 globals 为 Story 定义并锁定视口(viewport):组件级与单 Story 粒度的响应式渲染配置指南
Viewport 功能通过调整 Story 渲染 iframe 的宽高,让你在不离开 Storybook 的情况下开发与验证响应式 UI。本文以仓库中的代码片段 docs/_snippets/addon-viewport-define-globals.md 为核心主体,系统讲解如何用 globals.viewport 在组件(meta)层级为全部 Story 预设视口、在单个 Story 层级进行覆盖,并结合 Storybook 源码 剖析其"一旦指定即锁定"的底层机制。读完本文,你将掌握 React、Angular、Vue、Svelte、Web Components 等各框架/CSF 方言下统一而精确的视口定义写法。
该图片展示的即是 Storybook 工具栏中的 viewport 下拉菜单,它是 Viewport 模块在 manager 界面中提供的可视化入口(完整功能介绍见 docs/essentials/viewport.mdx)。
一、先理解 globals.viewport:value 与 isRotated
Viewport 模块向 Storybook 贡献了一个位于 viewport 命名空间下的全局变量(global),结构定义在 code/core/src/viewport/types.ts 中:
export interface ViewportStyles {
height: string;
width: string;
}
export type Viewport = {
name: string;
styles: ViewportStyles;
type?: ViewportType;
};
export type GlobalState = {
// 一旦设置,该 viewport 即被应用,且无法通过工具栏再更改;
// 值必须是某个可用 viewport 的 key,或形如 '{width}-{height}' 的自定义尺寸(如 '320-480'),
// 也可以带单位(如 '100vw' / '100pct')。
value: string | undefined;
// 为 true 时,应用的 viewport 会旋转 90°(例如从竖屏旋转为横屏)。
isRotated?: boolean;
};
模块的初始全局值在 code/core/src/viewport/preview.ts 中声明:
export const initialGlobals: Record<string, GlobalState> = {
[PARAM_KEY]: { value: undefined, isRotated: false },
};
即未做任何设置时,viewport.value 为 undefined,Story 以默认的 "Responsive"(100% × 100%)渲染;一旦你在 CSF 文件中通过 globals 指定了视口,Storybook 就会据此改变 iframe 尺寸。
value 字段可接受的值
value 字段需要与当前可用的 viewport 列表中的 key 对应。可用列表来自 parameters.viewport.options;当不配置 options 时,默认使用 MINIMAL_VIEWPORTS 最小集合(定义于 code/core/src/viewport/defaults.ts),共 4 个:
| Key | 名称(name) | 实际样式(按 styles 的 width × height,px) |
|---|---|---|
mobile1 |
Small mobile | 320 × 568 |
mobile2 |
Large mobile | 414 × 896 |
tablet |
Tablet | 834 × 1112 |
desktop |
Desktop | 1280 × 1024 |
本文示例中使用的 'tablet' 与 'mobile1' 都是该默认集合的 key,因此开箱即可运行、无需任何额外配置。如果你需要更全的设备清单,可引入同一文件中导出的 INITIAL_VIEWPORTS(内含从 iPhone 5 到 iPhone 14 Pro Max、Galaxy、Nexus、Pixel、iPad 系列等数十个设备预设),具体配置方式见 addon-viewport-options-in-preview.md。
除了预设 viewport 的 key,value 还支持自定义尺寸格式 '{width}-{height}'(例如 '320-480'、'100pct-200px'),其解析由 code/core/src/viewport/resolveViewport.ts 中的 URL_VALUE_PATTERN 完成。也就是说,即使不想在 options 里注册新设备,也能直接把某个 Story 锁在任意像素/百分比尺寸上。
isRotated 的旋转语义
当 isRotated: true 时,渲染时的宽高会互换(相当于把 portrait 旋转成 landscape)。这在 resolveViewport.ts 中被显式实现:
width: isRotated ? styles.height : styles.width,
height: isRotated ? styles.width : styles.height,
值得一提的是,globals.viewport 也允许直接用字符串简写。类型声明与解析逻辑中的 normalizeGlobal 都兼容两种形态,例如下面两种写法等价:
// 完整对象形态
globals: { viewport: { value: 'mobile1', isRotated: false } }
// 字符串简写形态(isRotated 缺省为 false)
globals: { viewport: 'mobile1' }
二、组件级统一设置 + 单 Story 覆盖:通用 CSF 3 写法
在 CSF 文件中,globals 既可以放在 meta(组件)层,也可以放在单个导出 Story 上。meta 层的 globals 会作用于该组件下的所有 Story,而 Story 层自己的 globals 会覆盖 meta 层的设置。通用 TypeScript CSF 3 写法如下:
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc.
import type { Meta, StoryObj } from '@storybook/your-framework';
import { Button } from './Button';
const meta = {
component: Button,
globals: {
// 👇 为该组件下所有 Story 设置 viewport
viewport: { value: 'tablet', isRotated: false },
},
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const OnPhone: Story = {
globals: {
// 👇 仅覆盖当前这个 Story 的 viewport
viewport: { value: 'mobile1', isRotated: false },
},
};
对应的通用 JavaScript CSF 3 写法完全一致,只是不需要类型注解:
import { Button } from './Button';
export default {
component: Button,
globals: {
// 👇 Set viewport for all component stories
viewport: { value: 'tablet', isRotated: false },
},
};
export const OnPhone = {
globals: {
// 👇 Override viewport for this story
viewport: { value: 'mobile1', isRotated: false },
},
};
这样,Button 组件的其余 Story 默认都在 834 × 1112 的平板(tablet)视口下渲染,而 OnPhone 这一个 Story 会以 320 × 568 的小屏手机(mobile1)视口呈现,从而精准复现移动端断点下的真实渲染。
三、逐框架/CSF 方言落地示例
代码片段 addon-viewport-define-globals.md 为每种渲染器都准备了对应的写法。下面按框架组织,覆盖 CSF 3、CSF Next 🧪 与 Svelte CSF 三种形态。
3.1 React(CSF 3 与 CSF Next 🧪)
React 的 CSF 3 写法可沿用上文"通用 CSF 3"代码(将 your-framework 替换为 react-vite 等)。CSF Next 🧪 的写法通过 .storybook/preview 中导出的 preview 实例进行链式声明:
// Button.stories.tsx
import preview from '../.storybook/preview';
import { Button } from './Button';
const meta = preview.meta({
component: Button,
globals: {
// 👇 Set viewport for all component stories
viewport: { value: 'tablet', isRotated: false },
},
});
export const OnPhone = meta.story({
globals: {
// 👇 Override viewport for this story
viewport: { value: 'mobile1', isRotated: false },
},
});
// Button.stories.jsx
import preview from '../.storybook/preview';
import { Button } from './Button';
const meta = preview.meta({
component: Button,
globals: {
viewport: { value: 'tablet', isRotated: false },
},
});
export const OnPhone = meta.story({
globals: {
viewport: { value: 'mobile1', isRotated: false },
},
});
3.2 Angular(CSF 3 与 CSF Next 🧪)
// Button.stories.ts(CSF 3)
import type { Meta, StoryObj } from '@storybook/angular';
import { Button } from './button.component';
const meta: Meta<Button> = {
component: Button,
globals: {
// 👇 Set viewport for all component stories
viewport: { value: 'tablet', isRotated: false },
},
};
export default meta;
type Story = StoryObj<Button>;
export const OnPhone: Story = {
globals: {
// 👇 Override viewport for this story
viewport: { value: 'mobile1', isRotated: false },
},
};
// Button.stories.ts(CSF Next 🧪)
import preview from '../.storybook/preview';
import { Button } from './button.component';
const meta = preview.meta({
component: Button,
globals: {
// 👇 Set viewport for all component stories
viewport: { value: 'tablet', isRotated: false },
},
});
export const OnPhone = meta.story({
globals: {
// 👇 Override viewport for this story
viewport: { value: 'mobile1', isRotated: false },
},
});
3.3 Vue 3(CSF Next 🧪)
// Button.stories.ts
import preview from '../.storybook/preview';
import Button from './Button.vue';
const meta = preview.meta({
component: Button,
globals: {
// 👇 Set viewport for all component stories
viewport: { value: 'tablet', isRotated: false },
},
});
export const OnPhone = meta.story({
globals: {
// 👇 Override viewport for this story
viewport: { value: 'mobile1', isRotated: false },
},
});
// Button.stories.js
import preview from '../.storybook/preview';
import Button from './Button.vue';
const meta = preview.meta({
component: Button,
globals: {
viewport: { value: 'tablet', isRotated: false },
},
});
export const OnPhone = meta.story({
globals: {
viewport: { value: 'mobile1', isRotated: false },
},
});
3.4 Svelte(CSF 3 与 Svelte CSF)
Svelte 组件的 Story 既可以写在传统 Button.stories.js 中,也可以写在 .stories.svelte 单文件组件中:
// Button.stories.ts(CSF 3;Replace your-framework with svelte-vite or sveltekit)
import type { Meta, StoryObj } from '@storybook/your-framework';
import Button from './Button.svelte';
const meta = {
component: Button,
globals: {
// 👇 Set viewport for all component stories
viewport: { value: 'tablet', isRotated: false },
},
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const OnPhone: Story = {
globals: {
// 👇 Override viewport for this story
viewport: { value: 'mobile1', isRotated: false },
},
};
// Button.stories.js(Svelte CSF 3)
import Button from './Button.svelte';
export default {
component: Button,
globals: {
// 👇 Set viewport for all component stories
viewport: { value: 'tablet', isRotated: false },
},
};
export const OnPhone = {
globals: {
// 👇 Override viewport for this story
viewport: { value: 'mobile1', isRotated: false },
},
};
<!-- Button.stories.svelte(Svelte CSF,通过 defineMeta 声明组件级 globals) -->
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import Button from './Button.svelte';
const { Story } = defineMeta({
component: Button,
globals: {
// 👇 Set viewport for all component stories
viewport: { value: "tablet", isRotated: false },
},
});
</script>
<Story
name="OnPhone"
globals={{
// 👇 Override viewport for this story
viewport: { value: "mobile1", isRotated: false },
}}
/>
Svelte CSF 在 <script module> 中用 defineMeta 的 globals 声明组件级视口,再用 <Story globals={...}> 对单个 Story 做覆盖,JS/TS 两种语言下的写法一致。
3.5 Web Components(CSF 3 与 CSF Next 🧪)
Web Components 渲染器里,component 字段填写的是自定义元素名(如 'demo-button'):
// Button.stories.js(CSF 3)
export default {
component: 'demo-button',
globals: {
// 👇 Set viewport for all component stories
viewport: { value: 'tablet', isRotated: false },
},
};
export const OnPhone = {
globals: {
// 👇 Override viewport for this story
viewport: { value: 'mobile1', isRotated: false },
},
};
// Button.stories.ts(CSF 3)
import type { Meta, StoryObj } from '@storybook/web-components-vite';
const meta: Meta = {
component: 'demo-button',
globals: {
// 👇 Set viewport for all component stories
viewport: { value: 'tablet', isRotated: false },
},
};
export default meta;
type Story = StoryObj;
export const OnPhone: Story = {
globals: {
// 👇 Override viewport for this story
viewport: { value: 'mobile1', isRotated: false },
},
};
// Button.stories.js(CSF Next 🧪)
import preview from '../.storybook/preview';
const meta = preview.meta({
component: 'demo-button',
globals: {
// 👇 Set viewport for all component stories
viewport: { value: 'tablet', isRotated: false },
},
});
export const OnPhone = meta.story({
globals: {
// 👇 Override viewport for this story
viewport: { value: 'mobile1', isRotated: false },
},
});
// Button.stories.ts(CSF Next 🧪)
import preview from '../.storybook/preview';
const meta = preview.meta({
component: 'demo-button',
globals: {
viewport: { value: 'tablet', isRotated: false },
},
});
export const OnPhone = meta.story({
globals: {
viewport: { value: 'mobile1', isRotated: false },
},
});
3.6 各框架写法速查
| 框架/渲染器 | 支持的文件形态 | globals 声明位置 |
|---|---|---|
| React(react-vite / nextjs 等) | CSF 3:*.stories.tsx;CSF Next:preview.meta() / meta.story() |
meta 层 + Story 层 |
| Angular | CSF 3:Meta<Button>;CSF Next:preview.meta() |
meta 层 + Story 层 |
| Vue 3 | CSF Next:preview.meta() / meta.story() |
meta 层 + Story 层 |
| Svelte | CSF 3:*.stories.js;Svelte CSF:*.stories.svelte 的 defineMeta / <Story> |
defineMeta + <Story globals> |
| Web Components | CSF 3:*.stories.js/ts;CSF Next:preview.meta() |
meta 层 + Story 层 |
四、底层原理:为什么一旦指定就无法用工具栏更改
viewport.mdx 中有一条关键提示:当你在 Story(或其组件)上通过 globals 指定了 viewport,该视口会被应用且工具栏中无法再更改,以此保证某个 Story 始终在特定视口下渲染。从源码可以确认这一"锁定(locked)"行为的实现位置。
在 resolveViewport.ts 中,解析逻辑会先区分三层 globals 的来源:
const storyHasViewport = PARAM_KEY in storyGlobals;
// Story 层的 viewport globals 会覆盖用户全局设置(userGlobals)
const primaryGlobal = storyHasViewport ? storyGlobal : userGlobal;
const secondaryGlobal = storyHasViewport ? userGlobal : storyGlobal;
// 锁定条件:功能被禁用、Story 声明了 viewport、或没有任何可用视口
const isLocked = disable || PARAM_KEY in storyGlobals || !keys.length;
也就是说:只要 viewport 出现在某个 Story 的 globals 中(无论写在 CSF 的 meta 还是具体 Story 上),Storybook 就会把该 Story 的视口解析为"锁定"状态。此时工具栏下拉菜单对该 Story 不可用,这与文档中"一旦指定即锁定"的表述完全吻合——这正是用它来固化某个 Story 的渲染尺寸(例如作为截图/视觉回归基线)时最想得到的行为。
在 manager 侧,useViewport.ts 通过 useGlobals() 读取并更新全局值:
const { options = MINIMAL_VIEWPORTS, disable = false } = parameter || {};
const { name, type, width, height, value, isLocked, isRotated, ... } =
resolveViewport({
globals,
storyGlobals,
userGlobals,
options,
...
});
几个值得注意的实现细节:
- 非 story 视图下的行为:
resolveViewport对viewMode !== 'story'的情况会直接返回Responsive(100% × 100%)并标记isLocked: true。因此用globals定义视口只作用于 story 渲染视图,docs/文档模式不受影响。 - 无效值回退:若
value指向不存在的 key(例如误填'phone'),而 Story 本身没有声明 viewport,useViewport会重置为默认值;若 Story 声明了 viewport,则不会被意外清除。 - 旋转快捷指令:工具栏旋转按钮与键盘快捷键(下一篇 API 会展开)本质都是对该全局值的更新,例如
rotate: () => update({ value, isRotated: !isRotated }),这解释了为什么isRotated能在保持value不变的前提下独立切换。 - 旧版参数的迁移:
useViewport.ts中还内置了对已废弃的viewport.defaultViewport参数的检测——该参数在 Storybook 10 中已被移除,会提示改用globals: { viewport: ... }或运行npx storybook automigrate自动迁移。这也说明"用 globals 定义视口"是当前版本推荐的标准姿势。
五、仓库内置的可运行验证示例
Storybook 仓库自身在 code/core/template/stories/viewport/globals.stories.ts 中内置了一套覆盖各种形态的验证 Story,可以作为本文写法的活教材。它演示了本文所述机制的多种变体:
// code/core/template/stories/viewport/globals.stories.ts
export const Selected = {
globals: {
viewport: { value: first, isRotated: false }, // 完整对象形态(first = 'mobile1')
},
};
export const Orientation = {
globals: {
viewport: { value: first, isRotated: true }, // 竖屏旋转为横屏
},
};
export const Invalid = {
globals: {
viewport: { value: 'phone', isRotated: false }, // 无效 key,用于验证回退逻辑
},
};
export const Shorthand = {
globals: {
viewport: first, // 字符串简写形态
},
};
export const NoRatioDefined = {
globals: {
viewport: { value: first }, // 省略 isRotated,缺省为 false
},
};
这个模板文件中的 Unset(不设置)、Selected(对象形态)、Shorthand(字符串简写)、Orientation(旋转)、Invalid(无效值)、NoRatioDefined(省略 isRotated)与 Disabled(通过 parameters.viewport.disable 禁用)等用例,恰好从测试角度验证了本文第三节描述的各类边界情况。
六、实用建议
- 优先组件级而非全局:如果某个组件的 Story 都适合在平板上查看,就把
viewportglobals 放在meta层,仅对特例(如移动端断点)在单个 Story 上覆盖,避免在每个 Story 里重复声明。 - "锁定"是特性而非限制:需要 Story 以固定尺寸稳定渲染(例如配合视觉回归快照、Chromatic 快照)时,用 globals 锁死视口可以避免测试结果随默认浏览器窗口大小波动。
isRotated只在需要横屏布局时才声明:省略该字段与显式写false等价(参见上文的normalizeGlobal缺省逻辑)。- 自定义尺寸无需注册新设备:
value支持'320-480'、'100pct-200px'这类{width}-{height}格式,适合那些临时想精确到像素的检查,不需要为此改动parameters.viewport.options。 - 相关配套文档:若要进一步定制可用设备集合(
options)、添加自定义设备或在 preview 中指定初始视口,可继续阅读 addon-viewport-options-in-preview.md、addon-viewport-add-viewport-in-preview.md 与 addon-viewport-configuration-in-meta.md。
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
