Channel 与事件:贯穿 Storybook 的通信协议
前置知识
- ›第 1 篇:架构概览
- ›了解事件发射器(Event Emitter)模式
- ›具备 PostMessage 与 WebSocket API 的基础知识
Channel 与事件:贯穿 Storybook 的通信协议
在第 1 篇中,我们了解到 Storybook 运行在三个相互隔离的环境中:Node.js 服务端、Manager UI 以及 Preview iframe。第 2 篇则介绍了 preset 系统如何在服务端组合配置。但浏览器加载完成后,这些环境之间究竟如何相互通信?当你在侧边栏点击一个 story 时,Preview iframe 又是如何知道该渲染哪个内容的?
答案就在于 Storybook 的 channel 系统——一个支持可插拔传输层的事件发射器,充当整个系统的通信骨干。它定义了超过 100 种独立的事件类型,足以处理从 story 选择、args 变更到遥测错误上报的一切场景。
Channel 类与传输层抽象
Channel 类的核心是一个兼容浏览器的事件发射器,它将消息的实际投递委托给一个或多个传输对象:
code/core/src/channels/main.ts#L22-L147
classDiagram
class Channel {
-sender: string
-events: EventsKeyValue
-data: Record~string, any~
-transports: ChannelTransport[]
+emit(eventName, ...args)
+on(eventName, listener)
+once(eventName, listener)
+off(eventName, listener)
+last(eventName): any
+hasTransport: boolean
}
class ChannelTransport {
<<interface>>
+send(event, options)
+setHandler(handler)
}
class PostMessageTransport {
+send(event, options)
+setHandler(handler)
}
class WebsocketTransport {
+send(event, options)
+setHandler(handler)
}
Channel --> "*" ChannelTransport
ChannelTransport <|.. PostMessageTransport
ChannelTransport <|.. WebsocketTransport
每个 Channel 实例会生成一个随机的 sender ID(第 25 行)。调用 emit() 时,它会将事件类型、参数和 sender ID 打包成一个 ChannelEvent,然后同时分发给所有传输层并在本地处理。正是这种"双重分发"机制——既通过传输层,也通知本地监听器——使得 Manager 发出的事件既能到达 Preview(经由传输层),也能被 Manager 自身的本地监听器捕获。
last() 方法(第 84 行)是一个细节但实用的功能:它会缓存每种事件类型最近一次的参数。这样,后续加入的监听器无需等待下一次事件触发,就能直接获取最新的状态快照。
PostMessage 与 WebSocket 传输层
createBrowserChannel() 工厂函数会根据当前运行环境,自动组装合适的传输层:
code/core/src/channels/index.ts#L32-L51
flowchart TB
CBC["createBrowserChannel({ page })"] --> PM["PostMessageTransport"]
CBC --> DevCheck{"CONFIG_TYPE === 'DEVELOPMENT'?"}
DevCheck -->|Yes| WS["WebsocketTransport"]
DevCheck -->|No| NoWS["(no WebSocket)"]
PM --> Channel["Channel instance"]
WS --> Channel
subgraph "Communication Paths"
direction LR
ManagerFrame["Manager<br>(parent frame)"] <-->|PostMessage| PreviewFrame["Preview<br>(iframe)"]
ManagerFrame <-->|WebSocket| Server["Node Server"]
PreviewFrame <-->|WebSocket| Server
end
PostMessage 传输层始终存在,负责处理 iframe 与父级页面之间的通信。Manager 和 Preview 分别以 page: 'manager' 或 page: 'preview' 为参数创建各自的 channel。
WebSocket 传输层仅在开发模式下启用(第 35 行),连接到服务端的 WebSocket 端点 /storybook-server-channel?token=<wsToken>。该 token 由服务端生成并内嵌在 HTML 页面中,用于防止未经授权的连接。
在生产环境的静态构建中,由于没有服务端,只有 PostMessage 可用。因此,所有依赖服务端的功能(文件搜索、创建 story 等)均为开发模式专属。
提示: 如果你正在开发需要服务端功能的 addon,请务必在依赖服务端事件之前先检查 WebSocket 传输层是否可用。在静态部署环境中,这些事件永远不会到达。
事件词汇表
core-events/index.ts 文件定义了完整的事件词汇:
code/core/src/core-events/index.ts#L1-L103
这些事件可以归纳为以下几大类:
flowchart TD
Events["Core Events (~50 named)"] --> Lifecycle["Story Lifecycle"]
Events --> State["State Mutation"]
Events --> UI["UI Coordination"]
Events --> ServerFeatures["Server Features"]
Lifecycle --> L1["STORY_SPECIFIED"]
Lifecycle --> L2["STORY_PREPARED"]
Lifecycle --> L3["STORY_RENDERED"]
Lifecycle --> L4["STORY_FINISHED"]
Lifecycle --> L5["STORY_RENDER_PHASE_CHANGED"]
State --> S1["UPDATE_STORY_ARGS / STORY_ARGS_UPDATED"]
State --> S2["UPDATE_GLOBALS / GLOBALS_UPDATED"]
State --> S3["SET_INDEX"]
UI --> U1["SET_CURRENT_STORY / CURRENT_STORY_WAS_SET"]
UI --> U2["SELECT_STORY"]
UI --> U3["PREVIEW_KEYDOWN"]
ServerFeatures --> SF1["FILE_COMPONENT_SEARCH_*"]
ServerFeatures --> SF2["CREATE_NEW_STORYFILE_*"]
ServerFeatures --> SF3["SAVE_STORY_*"]
ServerFeatures --> SF4["OPEN_IN_EDITOR_*"]
命名规律清晰可见:发起操作的事件使用祈使式命名(SET_CURRENT_STORY、UPDATE_STORY_ARGS),而报告操作已完成的事件则使用过去时(CURRENT_STORY_WAS_SET、STORY_ARGS_UPDATED)。服务端功能采用请求/响应配对模式(FILE_COMPONENT_SEARCH_REQUEST / FILE_COMPONENT_SEARCH_RESPONSE)。
事件流追踪:选择一个 Story
接下来,让我们追踪用户在侧边栏点击"Button/Primary"时,系统内部究竟发生了什么:
sequenceDiagram
participant User
participant Sidebar as Manager: Sidebar
participant API as Manager: stories module
participant Channel as Channel (PostMessage)
participant PWS as Preview: PreviewWithSelection
participant SR as Preview: StoryRender
User->>Sidebar: Click "Button/Primary"
Sidebar->>API: api.selectStory('button--primary')
API->>API: Navigate URL, update state
API->>Channel: emit(SET_CURRENT_STORY, { storyId, viewMode })
Channel->>PWS: onSetCurrentStory({ storyId, viewMode })
PWS->>PWS: Update selection, create StoryRender
PWS->>SR: render.prepare()
SR->>SR: Load story from StoryStore
SR->>Channel: emit(STORY_PREPARED, { id, parameters, ... })
Channel->>API: Handle STORY_PREPARED
API->>API: Update story data in state
SR->>SR: renderToElement(canvasElement)
SR->>Channel: emit(STORY_RENDERED, storyId)
Channel->>API: Handle STORY_RENDERED
这里有一个关键点:Manager 从不直接告诉 Preview 如何渲染。它只是发出一个声明式的 SET_CURRENT_STORY 事件,携带 story ID,Preview 则独立完成加载、准备和渲染的全过程。状态更新再通过 channel 回流给 Manager,让 Manager 能及时更新 UI(例如显示加载状态、在 addon 面板中展示参数等)。
这种声明式模式让 Manager 和 Preview 可以独立演进——Preview 不需要知道侧边栏的存在,Manager 也无需关心渲染的内部细节。
服务端 Channel
涉及文件系统访问的功能,服务端与浏览器之间通过 WebSocket 传输层进行通信。
Common preset 负责初始化所有服务端 channel 处理程序:
code/core/src/core-server/presets/common-preset.ts#L271-L288
export const experimental_serverChannel = async (channel, options) => {
initializeChecklist();
initializeWhatsNew(channel, options, coreOptions);
initializeSaveStory(channel, options, coreOptions);
initFileSearchChannel(channel, options, coreOptions);
initCreateNewStoryChannel(channel, options, coreOptions);
initGhostStoriesChannel(channel, options, coreOptions);
initOpenInEditorChannel(channel, options, coreOptions);
initTelemetryChannel(channel, options);
return channel;
};
每个 init* 函数负责注册请求事件的监听器,并发出对应的响应事件。以 initFileSearchChannel 为例,它监听 FILE_COMPONENT_SEARCH_REQUEST 事件,使用项目的 glob 规则搜索文件系统,再将结果通过 FILE_COMPONENT_SEARCH_RESPONSE 事件发送回去。
服务端 channel 还处理 STORY_INDEX_INVALIDATED 事件——当磁盘上的文件发生变化时,服务端会使其 story 索引缓存失效,并广播该事件,通知 Manager 和 Preview 重新拉取数据。
Universal Store:跨环境状态同步
在 channel 系统之上,还构建了一个更新的抽象层——UniversalStore,专门用于在多个环境之间同步状态:
code/core/src/shared/universal-store/index.ts#L82-L120
UniversalStore 采用 leader/follower 模式:
sequenceDiagram
participant Leader as Leader (Server)
participant Channel as Channel
participant Follower1 as Follower (Manager)
participant Follower2 as Follower (Preview)
Leader->>Channel: LEADER_CREATED
Follower1->>Channel: FOLLOWER_CREATED
Follower1->>Channel: EXISTING_STATE_REQUEST
Leader->>Channel: EXISTING_STATE_RESPONSE { state }
Follower1->>Follower1: Set state, resolve syncing
Follower2->>Channel: FOLLOWER_CREATED
Follower2->>Channel: EXISTING_STATE_REQUEST
Leader->>Channel: EXISTING_STATE_RESPONSE { state }
Follower2->>Follower2: Set state, resolve syncing
Note over Leader,Follower2: Normal operation
Follower1->>Channel: SET_STATE { newState }
Leader->>Leader: Update local state
Leader->>Channel: Forward SET_STATE to other followers
Follower2->>Follower2: Update local state
Leader(通常运行在服务端)持有权威状态。Manager 和 Preview 中的 follower 在创建时会自动同步——它们发送 EXISTING_STATE_REQUEST,并等待最多 1 秒的响应。如果没有 leader 应答,follower 将进入错误状态。
任何 follower 发起的状态变更都会经由 leader 中转,再由 leader 转发给其他所有 follower。这种中心辐射式(hub-and-spoke)的拓扑结构无需 follower 之间直接通信,即可保证状态一致性。
UniversalStore.__prepare() 静态方法在 channel 创建时被调用(channels/index.ts 第 45–48 行),将 store 绑定到 channel 并设置环境类型。这一过程是自动完成的——在 channel 就绪之前创建的 store 实例会进入队列,待准备完成后统一初始化。
提示: Universal Store 适合管理体量较小、更新频繁的状态(例如功能开关、工具栏设置)。对于 story 索引这类大型数据集,基于事件的
SET_INDEX模式仍然更为合适。
下一篇
理解了 channel 系统之后,我们已经掌握了完整的通信层:preset 在构建时组合配置,channel 在运行时传递事件。下一篇文章将深入 Preview iframe,完整追踪渲染流水线——从 Vite 生成的虚拟模块,经过 CSF 处理、配置组合、StoryRender 生命周期,直至框架渲染器的集成。