Read OSS

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_STORYUPDATE_STORY_ARGS),而报告操作已完成的事件则使用过去时(CURRENT_STORY_WAS_SETSTORY_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 生命周期,直至框架渲染器的集成。