Read OSS

Manager UI:模块化状态管理与 Addon 集成

高级

前置知识

  • 第 1 篇:架构概览
  • 第 3 篇:Channel 与事件
  • React 组件模式(Context、class 组件)
  • 状态管理模式基础

Manager UI:模块化状态管理与 Addon 集成

Manager 是你日常使用的 Storybook 应用界面——侧边栏、工具栏、addon 面板、设置页面,一切尽在其中。它是一个完整的 React 应用,负责管理复杂的状态:故事树、布局配置、键盘快捷键、活跃的 globals,以及每个已注册 addon 的状态。其架构的独特之处在于模块化状态系统——14 个独立模块各自提供一份状态切片和 API,最终通过 TypeScript 交叉类型组合成一个统一的接口。

第 4 篇中我们深入了解了 Preview 的渲染流水线,现在来看看 PostMessage 边界的另一侧。

Manager 启动流程

启动序列从 runtime.tsx 开始,它作为"全局化运行时"入口被编译(详见第 1 篇):

code/core/src/manager/runtime.tsx#L32-L89

sequenceDiagram
    participant HTML as index.html
    participant RT as runtime.tsx
    participant RP as ReactProvider
    participant Channel as Channel
    participant UI as renderStorybookUI

    HTML->>RT: Script loaded
    RT->>RT: Register toolbar addon
    RT->>RP: new ReactProvider()
    RP->>Channel: createBrowserChannel({ page: 'manager' })
    RP->>Channel: addons.setChannel(channel)
    RP->>Channel: emit(CHANNEL_CREATED)
    RT->>UI: renderStorybookUI(rootEl, provider)

ReactProvider 类(第 32 行)的构造函数做了三件事:

  1. 创建带有 PostMessage + WebSocket 传输层的浏览器 channel
  2. 将 channel 注册到 addons 单例
  3. 发送 CHANNEL_CREATED 事件,通知系统已就绪

此外,它还设置了 CHANNEL_WS_DISCONNECT 处理器——当与开发服务器的 WebSocket 连接断开时(服务器崩溃或超时时常见)显示通知。

实际的渲染调用被包裹在 setTimeout(() => renderStorybookUI(...), 0) 中(第 86 行)。这样可以确保 HTML 中的全局脚本标签(用于设置 CHANNEL_OPTIONSCONFIG_TYPE 等)在 React 尝试使用它们之前已经执行完毕。

组件树结构

Manager 的 React 组件树有着清晰的层次结构:

code/core/src/manager/index.tsx#L32-L99

flowchart TD
    Root["Root"] --> Helmet["HelmetProvider"]
    Helmet --> Location["LocationProvider"]
    Location --> Main["Main"]
    Main --> LocationConsumer["Location (consumer)"]
    LocationConsumer --> MP["ManagerProvider"]
    MP --> Theme["ThemeProvider"]
    Theme --> LP["LayoutProvider"]
    LP --> App["App"]
    App --> Layout["Layout"]
    Layout --> Sidebar
    Layout --> Preview["Preview iframe"]
    Layout --> Panel["Addon Panel"]

每一层包装组件都承担特定职责:

  • HelmetProvider:管理 <head> 中的元素(标题、meta 标签)
  • LocationProvider:URL 路由与历史记录管理
  • ManagerProvider:状态管理核心(14 个模块,统一状态)
  • ThemeProvider:基于 Emotion 的主题系统,由 storybook/theming 提供
  • LayoutProvider:布局测量与尺寸调整状态

renderStorybookUI 函数(第 92 行)会校验传入的 provider 是否继承自基础 Provider 类,然后创建 React root 并挂载整棵组件树。这个校验能有效防止第三方代码传入错误 provider 对象时引发的常见错误。

ManagerProvider 与模块系统

Manager 状态管理的核心是 ManagerProvider,这是一个 React class 组件,负责初始化 14 个模块并组合它们的状态与 API:

code/core/src/manager-api/root.tsx#L82-L110

State 和 API 的类型通过 TypeScript 交叉类型组合而成:

export type State = layout.SubState &
  stories.SubState &
  refs.SubState &
  notifications.SubState &
  version.SubState &
  url.SubState &
  shortcuts.SubState &
  settings.SubState &
  globals.SubState &
  whatsnew.SubState &
  RouterData &
  API_OptionsData &
  Other;

export type API = addons.SubAPI &
  channel.SubAPI &
  provider.SubAPI &
  stories.SubAPI &
  refs.SubAPI &
  globals.SubAPI &
  layout.SubAPI &
  notifications.SubAPI &
  shortcuts.SubAPI &
  settings.SubAPI &
  version.SubAPI &
  url.SubAPI &
  whatsnew.SubAPI &
  openInEditor.SubAPI &
  Other;
classDiagram
    class ManagerProvider {
        +api: API
        +modules: ModuleFn[]
        +state: State
        +initModules()
        +render()
    }
    class ModuleFn {
        <<interface>>
        +init(args): ModuleResult
    }
    class ModuleResult {
        +state: SubState
        +api: SubAPI
        +init(): void
    }

    ManagerProvider --> "14" ModuleFn : initializes
    ManagerProvider --> "1" State : composes
    ManagerProvider --> "1" API : composes

在构造函数中(第 130–197 行),每个模块都会接收一组共享依赖进行初始化:

code/core/src/manager-api/root.tsx#L130-L197

this.modules = [
  provider, channel, addons, layout,
  notifications, settings, shortcuts, stories,
  refs, globals, url, version, whatsnew, openInEditor,
].map((m) =>
  m.init({ ...routeData, ...optionsData, ...apiData, state: this.state, fullAPI: this.api })
);

每个模块的 init 函数都会收到完整的 API 引用(初始为空对象,随着模块的初始化逐步填充),这使得模块之间可以互相调用 API。循环引用问题通过两阶段方式解决:第一阶段,所有模块返回各自的状态切片和 API 方法;第二阶段,initModules() 作为 React effect 被调用,此时各模块才执行依赖其他模块已就绪的初始化逻辑。

提示: 如果你在开发需要访问 Manager 状态的 Storybook addon,可以使用 storybook/manager-api 提供的 useStorybookApi()useStorybookState() hooks。它们会消费 ManagerContext,让你直接获取完整的组合 API 和状态。

核心模块深度解析

Stories 模块

stories 模块是体量最大、最为复杂的一个,它负责管理:

  • 故事树(一个将 ID 映射到条目的 IndexHash
  • 故事的选中与导航
  • 故事过滤(按标签、状态、搜索词)
  • 与 Preview 之间的 args 状态同步

code/core/src/manager-api/modules/stories.ts#L1-L80

它监听多种 channel 事件:SET_INDEX(故事索引变化时)、STORY_PREPARED(故事完整元数据就绪时)、STORY_ARGS_UPDATED(Preview 中 args 发生变化时)。遇到 STORY_INDEX_INVALIDATED 事件时,则会重新从服务器拉取 index.json

Layout 模块

layout 模块控制 Manager 的视觉结构:侧边栏宽度、面板位置(底部或右侧)、面板可见性、全屏模式,以及当前激活的 addon 标签页。其状态会持久化到 localStorage,因此页面刷新后布局偏好仍能保留。

Shortcuts 模块

shortcuts 模块管理键盘绑定(切换侧边栏、切换面板、跳转到上一个/下一个故事等),并处理来自 Manager 框架和 Preview iframe 的 keydown 事件(后者通过 PREVIEW_KEYDOWN 转发过来)。

Addon 注册与插槽

Addon 通过注册 API 与 Manager 集成:

// 典型的 addon manager 入口
import { addons, types } from 'storybook/manager-api';

addons.register('my-addon', (api) => {
  addons.add('my-addon/panel', {
    type: types.PANEL,
    title: 'My Panel',
    render: ({ active }) => active ? <MyPanel /> : null,
  });
});

Manager 构建器(基于 esbuild)会将所有 addon manager 入口文件打包在一起:

code/core/src/builder-manager/index.ts#L36-L120

flowchart TD
    Presets["presets.apply('managerEntries')"] --> Entries["Collect all manager entries"]
    ConfigDir["configDir/manager.ts"] --> Entries
    Entries --> Wrap["wrapManagerEntries()"]
    Wrap --> ESBuild["esbuild bundle (IIFE format)"]
    ESBuild --> Output["sb-addons/*.js"]

    subgraph "Available Slot Types"
        PANEL["types.PANEL — Addon panels"]
        TOOL["types.TOOL — Toolbar tools"]
        TAB["types.TAB — Canvas tabs"]
        PAGE["types.experimental_PAGE — Full pages"]
    end

每个入口都被包裹在 try/catch 中(第 108–111 行),确保某个 addon 出错时不会拖垮整个 Manager。esbuild 配置使用 globalExternals 来保证各 addon 与 Manager 共享同一份 React、storybook/manager-api 等全局依赖,而不是各自打包一份副本。

可用的插槽类型决定了 addon 可以将 UI 注入到哪里:

  • PANEL:显示在预览下方或侧边的 addon 面板区域
  • TOOL:显示在工具栏中
  • TAB:作为 Canvas 旁边的标签页显示
  • experimental_PAGE:完整的页面路由(Settings 页面即使用此类型)

Ref 系统:Storybook 组合

refs 模块实现了 Storybook 组合功能——将多个 Storybook 实例的故事加载到同一套 UI 中:

code/core/src/manager-api/modules/refs.ts#L1-L60

flowchart TB
    Main["Main Storybook"] --> Sidebar
    Sidebar --> LocalStories["Local Stories (IndexHash)"]
    Sidebar --> Ref1["Ref: Design System Storybook"]
    Sidebar --> Ref2["Ref: Shared Components Storybook"]

    Ref1 -->|"fetch /index.json"| Remote1["Remote index.json"]
    Ref2 -->|"fetch /index.json"| Remote2["Remote index.json"]

    Ref1 -->|"iframe src"| Preview1["Remote Preview iframe"]
    Ref2 -->|"iframe src"| Preview2["Remote Preview iframe"]

main.ts 中配置好 refs 后,Manager 会拉取每个远程 Storybook 的 index.json,对故事索引进行转换,再将其与本地故事合并显示在侧边栏中。当用户选中一个远程故事时,Preview iframe 会从远程 URL 加载,而非本地服务器。

refs 的 SubAPI 接口提供了 findRefsetRefupdateRefgetRefscheckRef 等方法。状态中会跟踪每个 ref 的加载状态、故事列表和版本信息。

提示: Storybook 组合对设计系统非常实用。将设计系统的 Storybook 发布为静态站点,然后在消费方应用的 Storybook 配置中引用它。这样用户就能在一个侧边栏中浏览所有故事,而无需重复维护组件代码。

下一步

至此,我们已经详细介绍了三个运行时环境:Server(第 1–2 篇)、Preview(第 4 篇)和 Manager(本篇)。在最后一篇中,我们将目光拉远,聚焦于支撑这一切运转的构建基础设施:Nx 编排的 monorepo 流水线、故事索引生成器、变更检测、沙箱测试,以及结构化错误系统。