首页
/ Storybook for Ember:用 @storybook/ember 为 Ember 组件构建隔离开发、文档与测试环境

Storybook for Ember:用 @storybook/ember 为 Ember 组件构建隔离开发、文档与测试环境

2026-09-06 17:06:49作者:瞿蔚英Wynne

Storybook for Ember 是 Storybook 官方为 Ember 应用提供的框架预设(@storybook/ember),它把 UI 组件的开发环境从应用本体中剥离出来,让组件可以在没有路由、数据层和应用级依赖的情况下独立可视化、交互开发。读完本篇,你将掌握 Ember 预设的初始化方式、Ember 版 CSF3 story 的写法(hbs 模板 + args/context)、polyfill 注入机制的源码级原理,以及组件 API 文档(jsondoc)的提取链路。

一、它是什么:一个独立于应用运行的组件工作台

@storybook/ember 的官方定位写在 package.json 中:"Storybook for Ember: Develop, document, and test UI components in isolation"。README 对它的描述是:一个面向 Ember 组件的 UI 开发环境,让你可视化组件的各种状态并交互式地开发它们。

它的核心设计原则是 Storybook runs outside of your app——Storybook 运行在你的应用之外,因此开发组件时不必关心应用特定的依赖与运行要求(比如路由配置、后端接口、认证逻辑)。

当前仓库中该包的版本为 10.6.0-beta.1(见 package.json),peerDependencies 声明了明确的运行环境约束,这实际构成了使用前提:

  • ember-source~3.28.1 || >=4.0.0
  • babel-plugin-ember-modules-api-polyfill^3.5.0
  • babel-plugin-htmlbars-inline-precompile^5.3.1
  • 依赖 React 16.8–19 用于渲染文档 UI

从 sandbox/ember-3-js 与 sandbox/ember-default-js 两个沙盒工程定义也可以看出,仓库的 CI 矩阵同时覆盖了 Ember 3.x 与默认(较新)版本的 Ember 应用,这与上面的 peer 范围吻合。

二、Getting Started:在 Ember 应用中初始化

README 给出的起步命令极其简单,在任意 Ember 应用目录下执行:

cd my-ember-app
npx storybook@latest init

init 命令会自动检测框架并安装 @storybook/ember,同时脚手架出 .storybook 配置与示例 story。示例 story 的模板就存放在本仓库中:template/cli/Button.stories.js。下面以这个模板为蓝本讲解 Ember story 的正确写法。

Ember 版 CSF3 story 的结构

Ember 预设的 story 与 React 版最大的差异在于:render 返回的不是 JSX,而是一个「模板 + 上下文」对象(或直接返回 hbs 编译产物)。模板的核心示例:

import { hbs } from 'ember-cli-htmlbars';
import { action } from 'storybook/actions';
import { fn } from 'storybook/test';

export default {
  title: 'Example/Button',
  render: (args) => ({
    template: hbs`<button {{on "click" this.onClick}}>{{this.label}}</button>`,
    context: args,          // args 作为模板的渲染上下文
  }),
  argTypes: {
    label: { control: 'text' },
  },
  tags: ['autodocs'],       // 自动生成 Autodocs 页面
  args: { onClick: fn() },  // fn() 支持交互测试(Play 函数断言)
};

export const Text = {
  args: { label: 'Button' },
};

export const TextWithAction = {
  render: () => ({
    template: hbs`
    <button {{on "click" this.onClick}}>
      Trigger Action
    </button>
  `,
    context: {
      onClick: () => action('This was clicked')(),
    },
  }),
  name: 'With an action',
};

这个模板里有几个关键实践点:

  1. hbs 标签来自 ember-cli-htmlbars,它在 Babel 编译阶段被内联预编译为模板(见下文源码分析);
  2. render: (args) => ({ template, context: args }) 是 Ember 预设的标准形态:args(由 Controls 面板驱动)被作为模板渲染上下文注入,{{this.label}} 等模板语法即可消费 args;
  3. story 级 render 覆盖TextWithAction 演示了如何为单个 story 重写模板与上下文,例如把点击回调替换成 action(...) 以便在 Actions 面板中观察调用;
  4. 交互测试args: { onClick: fn() } 使用 storybook/testfn 工厂,配合 @storybook/addon-vitest/测试运行时可对交互行为做断言;
  5. story 间跳转:模板中还展示了 linkTo('example-button--docs')(来自 @storybook/addon-links)在点击事件中跳转到其他 story 的用法。

渲染失败时的错误提示

如果 story 的 storyFn 没有返回 Ember 元素,渲染器会主动报错。从 client/preview/render.tsrenderToCanvas 可以看到错误信息给出的两种合法写法:

  • () => hbs('{{component}}'):直接返回 hbs 编译产物;
  • () => { return { template: hbs\{{component}}`, context }`:返回模板对象。

这可以作为写 story 时的快速自检依据。

三、构建体系:webpack5 + Babel 的双层预设

@storybook/ember 是「框架预设」,它由三部分构成,对应 preset.jsdist/preset.js 的转发以及 src/preset.ts 的源码:

  1. 构建器绑定core 预设属性):强制使用 @storybook/builder-webpack5 作为构建器(见 src/preset.ts#L43-L45)。也就是说 Ember 预设当前走的是 webpack 构建链路而非 Vite;
  2. webpackFinal 定制:在基础配置之上追加一条 Babel 规则(见 src/preset.ts#L12-L41):
    • 匹配 .jsx/.tsx/.js/.ts 等源文件(当 typescript.skipCompiler 为真时只匹配 JS/JSX);
    • 使用 babel-loadercacheDirectory 指向 Storybook 缓存目录(resolvePathInStorybookCache('babel'))以加速二次构建;
    • 通过 options.presets.apply('babel', {}, options) 聚合所有预设(包括框架自带的 Babel 预设)产出的 Babel 配置;
    • include 限定为项目根目录,exclude 排除 node_modules 与 Storybook 注入的虚拟模块;
  3. 框架 Babel 预设addons 属性注册了 server/framework-preset-babel-ember.ts,这是理解 Ember 模板与 polyfill 处理的关键。

框架 Babel 预设:注入 htmlbars 内联预编译与模块 API polyfill

framework-preset-babel-ember.ts 向 Babel 配置追加了两个插件:

  • babel-plugin-htmlbars-inline-precompile:把 hbs 标签内的 HTMLBars 模板字符串在编译期预编译,并配置了对 ember-cli-htmlbarsember-cli-htmlbars-inline-precompilehtmlbars-inline-precompile 三个模块导出的识别映射;
  • babel-plugin-ember-modules-api-polyfill:为 import { get } from '@ember/object' 这类具名导入提供运行时兼容垫片,保证 ES module 写法与 Ember 3.x 全局对象 API 的互操作。

此外它还注册了 previewAnnotations,把编译产物 client/preview/config(dist 下)注入 preview 侧的入口列表,从而让渲染器配置在浏览器端生效。

四、Polyfill 机制详解(README 核心章节的源码印证)

README 中「Working with polyfills」一节是本包最具特色的配置能力:当 Ember 应用中已经使用了某些模板 polyfill(如 ember-named-blocks-polyfill),需要把这些 polyfill 同样接入 Storybook 的模板编译管线。README 给出的配置示例是:

// .storybook/main.js
import namedBlockPolyfill from 'ember-named-blocks-polyfill/lib/named-blocks-polyfill-plugin';

export default {
  framework: {
    name: '@storybook/ember',
    options: {
      polyfills: [namedBlockPolyfill],
    }
  },
  // [...]
};

这段配置的实际生效链路可以从源码得到完整印证。在 framework-preset-babel-ember.ts#L10-L17 中:

function precompileWithPlugins(string: string, options: any) {
  const precompileOptions: any = options;
  if (emberOptions && emberOptions.polyfills) {
    precompileOptions.plugins = { ast: emberOptions.polyfills };
  }
  return precompile(string, precompileOptions); // precompile 来自 ember-source 的模板编译器
}

可以推断出其工作原理:

  1. 框架 options(README 中的 framework.options,含 polyfills 数组)在预设解析阶段被归集为 emberOptionsbabel 预设函数从 options.presetsList 中读取 preset.emberOptions,见 同文件#L19-L29),并随即从 Babel 配置中删除该字段以避免污染 Babel 选项;
  2. precompileWithPlugins 被作为自定义 precompile 函数传给 babel-plugin-htmlbars-inline-precompile,于是每一个 hbs 模板在预编译时都会带上 plugins: { ast: polyfills }——polyfill 以 AST 插件形式作用于模板 AST,这正是 ember-named-blocks-polyfill 这类模板 polyfill 的工作协议;
  3. 最终效果:Storybook 里 story 模板的编译行为与你 Ember 应用自身的构建管线保持一致,named blocks 等语法在两侧行为统一。

注:README 示例中的框架名为 @storybook/ember;而当前源码的类型定义(src/types.ts#L13)将框架标识类型化为 @storybook/ember-webpack5,说明框架命名在版本演进中发生了变化。配置时以你所安装版本的 init 脚手架产出为准。

五、组件 API 文档:基于 jsondoc 的 argTypes 提取

Ember 组件没有 React 那样的 PropTypes 或 TS 内省,Storybook 因此采用了一条基于预生成 JSON 文档的链路。client/preview/config.ts 中注册了两个文档提取器:

export const parameters = {
  renderer: 'ember',
  docs: {
    story: { iframeHeight: '80px' },
    extractArgTypes,
    extractComponentDescription,
  },
};

client/preview/jsondoc.ts 中的 extractArgTypes 实现逻辑是:

  1. 读取全局变量 __EMBER_GENERATED_DOC_JSON__(该变量在 types.ts 中声明,由 Ember 侧的文档生成管线——如 ember-doc-gen 类工具——在构建时注入);
  2. 在 JSON 的 included 列表中按 attributes.name 找到目标组件;
  3. attributes.arguments 归约成 Storybook argTypes 结构,其中 type.required 通过 tags 中是否存在 required 标签判定,table.defaultValuetable.type 分别填充默认值与类型摘要;
  4. extractComponentDescription 则直接返回组件的 attributes.description

这意味着:只要你的 Ember 工程产出了组件 doc JSON,Autodocs 页面即可展示参数表与描述,无需手工维护 argTypes。

六、渲染管线:为每个 story 动态引导一个 Ember 实例

Ember 预设最精妙的部分在 client/preview/render.ts。preview 侧的入口由 framework-preset-babel-ember.ts#L54-L56 通过 previewAnnotations 注入,它做了三件事:

1. 引导 Ember 应用(不自动挂载)

function loadEmberApp() {
  const config = global.require(`${global.STORYBOOK_NAME}/config/environment`);
  return global.require(`${global.STORYBOOK_NAME}/app`).default.create({
    autoboot: false,
    rootElement: rootEl,   // <div id="storybook-root">
    ...config.APP,
  });
}

STORYBOOK_NAMEglobals.tsprocess.env.STORYBOOK_NAME 写入全局,指向你的应用入口模块。autoboot: false 让应用只创建不自动启动路由,rootElement 绑定到 Storybook 的画布容器。

2. 串行化的实例生命周期

render 函数(render.ts#L29-L69)用 lastPromise 串起所有渲染操作:切换 story 时先 destroy() 上一个 app instance,再 buildInstance() + boot() 新实例,从而避免并发引导导致的状态串扰;isRendering 标志保证同一时刻只有一个渲染流程在跑。

3. 以 component:story-mode 临时组件承载 story

每个 story 会被注册为一个名为 component:story-mode 的临时 Ember.Component

instance.register(
  'component:story-mode',
  Ember.Component.extend({
    layout: template || options,   // storyFn 的返回值即模板
    ...context,                      // args 展开为组件属性
  })
);

之后通过 instance.lookup('component:story-mode') 取出组件并 appendTo 到画布元素。这也解释了前文渲染错误提示中两种合法 story 返回值的含义:storyFn 的返回值会被直接当作 layout 使用。

七、版本、依赖与适用前提小结

结合本仓库当前状态(@storybook/ember@10.6.0-beta.1,beta 阶段),使用该预设需注意:

项目 说明 依据
Ember 版本 ember-source ~3.28.1 || >=4.0.0 package.json peerDependencies
构建器 固定使用 @storybook/builder-webpack5(无 Vite 链路) src/preset.ts#L43-L45
必需 Babel 插件 babel-plugin-htmlbars-inline-precompilebabel-plugin-ember-modules-api-polyfill package.json peerDependenciesframework-preset-babel-ember.ts#L33-L46
模板 polyfill 通过 framework.options.polyfills 注入为模板 AST 插件 framework-preset-babel-ember.ts#L10-L17
组件文档 依赖 __EMBER_GENERATED_DOC_JSON__ 全局 doc JSON jsondoc.ts
CI 沙盒 sandbox/ember-3-jssandbox/ember-default-js sandbox/ember-3-js、sandbox/ember-default-js

由于仓库当前处于 10.6.0-beta.1 的 pre-release 状态,生产使用建议以稳定发布版本为准,配置细节(尤其是 framework.name 的取值)以对应版本的脚手架输出为最终依据。

八、关键文件索引

关注点 路径
本框架说明文档 code/frameworks/ember/README.md
包元信息与 peer 依赖 code/frameworks/ember/package.json
预设入口(webpack 定制 + builder 绑定) code/frameworks/ember/src/preset.ts
Babel 预设(htmlbars 预编译 + polyfill 注入) code/frameworks/ember/src/server/framework-preset-babel-ember.ts
渲染管线(Ember 应用引导与实例管理) code/frameworks/ember/src/client/preview/render.ts
preview 参数与文档提取器注册 code/frameworks/ember/src/client/preview/config.ts
jsondoc argTypes 提取 code/frameworks/ember/src/client/preview/jsondoc.ts
初始化脚手架示例 story code/frameworks/ember/template/cli/Button.stories.js

整体来看,@storybook/ember 的设计可以概括为三层:构建层用 webpack5 + 定制 Babel 规则复现 Ember 应用的模板编译管线(含 polyfill 注入);渲染层以「autoboot 关闭的完整 Ember 应用 + 每 story 独立实例」保证组件在真实 Ember 运行时中运行;文档层通过外部 doc JSON 弥补 Ember 缺少组件内省能力的短板。三层叠加,使得 Ember 开发者可以在完全脱离应用上下文的环境中开发、文档化和测试 UI 组件。

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