本文主要从以下两条线索出发剖析 storybook 的源码,阅读本文你将知道 storybook 的基本使用方式和功能,以及功能实现背后的源码解读。
- 从 init 命令谈 storybook 多平台适配
- 从 npm run storybook 命令谈 stories.js 文件如何被解析
storybook 仓库地址:github.com/storybookjs...
storybook 官网地址:storybook.js.org/
介绍
目前前端开发基本都采用组件化的开发模式,每一段 UI 代码都能成为一个组件,组件化开发模式的强大之处在于,程序员不再需要仅仅为了查看它们的渲染方式而启动整个应用程序,可以通过传入 props、模拟数据或伪造事件来单独渲染特定的组件变体。
目前许多主流组件库基本在开发阶段都使用了 storybook,它被安装进项目中,并提供了一个独立的开发环境,帮助开发人员构建、测试和文档化可复用的UI组件,通过编写组件相应的 stories 便可以在 storybook 提供的开发环境中方便地检查编写组件的状态,并且每一个组件都将被渲染在一个独立的 iframe 中,确保组件在独立环境下的正常运行。
Storybook 的优势包括:
- 开发更耐用的组件。隔离组件和页面并以 stories 的形式跟踪它们的用例。可以验证难以触及的 UI 边缘情况。同时提供插件来模拟组件开发所需的一切------context、API 请求、设备功能等。
- 更轻松的 UI 测试。Storybook 提供内置的自动 A11y 、交互和视觉测试。
- 自动生成组件文档供团队使用。
- 优秀的 CI 集成能力。结合 CI 构建自动化测试等。
- 多平台支持,Storybook 支持多种前端框架和库,如React、Vue、Angular等。无论你使用哪种技术栈,都可以使用 Storybook 来开发和展示组件。
准备
了解了 Storybook 的基本介绍和功能,我们可以快速地新建一个包含 storybook 的项目来直观体验一下我们的组件开发最佳搭档。
首先,新建一个空的 react 项目
shell
npx create-react-app story-with-react-demo
在项目中安装 storybook
shell
npx storybook@latest init
命令运行成功后,项目文件会发生变化,并且 package.json 中会被自动写入相关的依赖和脚本命令。此时我们已经获得了一个拥有 storybook 基础模版的项目啦!👏
从 init 命令谈 storybook 多平台适配
在真正开始探索 storybook 的配置之前,我想先深入 npx storybook@latest init
,看看这个命令具体都做了什么事情。主要流程图如下
npx 可以在命令行直接执行本地已安装的依赖包命令,如果没有该命令对应的包,会自动地将该包下载到一个临时目录,使用以后再删除。所以这个命令最终执行的是以下代码。
js
// code/lib/cli/src/generate.ts
command('init')
.description('Initialize Storybook into your project.')
.option('-f --force', 'Force add Storybook')
.option('-s --skip-install', 'Skip installing deps')
.option('--package-manager <npm|pnpm|yarn1|yarn2>', 'Force package manager for installing deps')
.option('-N --use-npm', 'Use npm to install deps (deprecated)')
.option('--use-pnp', 'Enable pnp mode for Yarn 2+')
.option('-p --parser <babel | babylon | flow | ts | tsx>', 'jscodeshift parser')
.option('-t --type <type>', 'Add Storybook for a specific project type')
.option('-y --yes', 'Answer yes to all prompts')
.option('-b --builder <webpack5 | vite>', 'Builder library')
.option('-l --linkable', 'Prepare installation for link (contributor helper)')
.action((options: CommandOptions) => {
initiate(options, pkg).catch(() => process.exit(1));
});
通过调用链我们可以找到另一个重要函数 doInitiate
,该函数的主要作用是检查当前项目所使用的框架,根据框架类型更新 package.json 文件并安装相关依赖并打印一些必要提示信息。
js
// code/lib/cli/src/initiate.ts
async function doInitiate(
options: CommandOptions,
pkg: PackageJson
): Promise<
| {
shouldRunDev: true;
projectType: ProjectType;
packageManager: JsPackageManager;
storybookCommand: string;
}
| { shouldRunDev: false }
> {
let { packageManager: pkgMgr } = options;
if (options.useNpm) {
useNpmWarning();
pkgMgr = 'npm';
}
// 判断当前使用的包管理工具, 主要通过结合当前有无响应的 lock file 和 xxx --version 命令判断,此处结果为 npm
const packageManager = JsPackageManagerFactory.getPackageManager({ force: pkgMgr });
const welcomeMessage = 'storybook init - the simplest way to add a Storybook to your project.';
logger.log(chalk.inverse(`\n ${welcomeMessage} \n`));
// Update notify code.
const { default: updateNotifier } = await import('simple-update-notifier');
await updateNotifier({
pkg: pkg as any,
updateCheckInterval: 1000 * 60 * 60, // every hour (we could increase this later on.)
});
let projectType: ProjectType;
const projectTypeProvided = options.type;
// 未指定 type 由程序自动检查
const infoText = projectTypeProvided
? `Installing Storybook for user specified project type: ${projectTypeProvided}`
: 'Detecting project type' ;
const done = commandLog(infoText);
if (projectTypeProvided) {
if (installableProjectTypes.includes(projectTypeProvided)) {
projectType = projectTypeProvided.toUpperCase() as ProjectType;
} else {
done(`The provided project type was not recognized by Storybook: ${projectTypeProvided}`);
logger.log(`\nThe project types currently supported by Storybook are:\n`);
installableProjectTypes.sort().forEach((framework) => paddedLog(`- ${framework}`));
logger.log();
throw new HandledError(`Unknown project type supplied: ${projectTypeProvided}`);
}
} else {
try {
// 通过项目 package.json 的 dependency 和 peerdenendency 判断当前项目的框架,此处检测结果为 REACT_SCRIPTS
projectType = await detect (packageManager, options);
} catch (err) {
done(err.message);
throw new HandledError(err);
}
}
done();
// ....
// 根据 framework 更新当前 package.json 的依赖和命令, 并 添加 .stroybook 文件夹下的配置文件
const installResult = await installStorybook (projectType as ProjectType , packageManager, options);
if (!options.skipInstall) {
// 安装依赖
await packageManager. installDependencies ();
}
//.....
const storybookCommand =
projectType === ProjectType.ANGULAR
? `ng run ${installResult.projectName}:storybook`
// 获运行 storybook 的命令 npm run storybook
: packageManager. getRunStorybookCommand ();
logger.log(
boxen(
dedent`
Storybook was successfully installed in your project! 🎉
To run Storybook manually, run ${chalk.yellow(
chalk.bold(storybookCommand)
)}. CTRL+C to stop.
Wanna know more about Storybook? Check out ${chalk.cyan('https://storybook.js.org/')}
Having trouble or want to chat? Join us at ${chalk.cyan('https://discord.gg/storybook/')}
`,
{ borderStyle: 'round', padding: 1, borderColor: '#F1618C' }
)
);
return {
shouldRunDev: process.env.CI !== 'true' && process.env.IN_STORYBOOK_SANDBOX !== 'true',
projectType,
packageManager,
storybookCommand,
};
}
Init 命令日志如下示
📌 Storybook 如何检测不同的 framework
👉 首先将 Storybook 安装到现有项目中。然后,它会尝试检测您正在使用的框架并自动配置 Storybook 以与其配合使用。这意味着添加必要的库作为依赖项并调整配置。最后,启动 Storybook 的操作将在加载任何现有插件以匹配您的应用程序环境之前自动加载 framework 配置。
从 npm run storybook 命令谈 stories.js 文件如何被解析
实际在执行完上面的 init 命令后,storybook 会自动为我们再执行 npm run storybook
命令,运行成功后我们便可看到类似下图右侧的 storybook 页面了。
npm run xxx
接下来我们探究一下 npm run stroybook
的命令是怎么一步步生效的。
从上文的打印信息中我们可以知道,通过 npm run storybook
的命令可以手动地启动 storybook 环境。
执行 npm run 时,会自动新建一个shell,将 node_modules/.bin 目录添加到 PATH 变量中。这就意味着,当前目录的node_modules/.bin
子目录里面的所有脚本,都可以直接用脚本名调用,而不必加上路径。比如
js
"scripts": {"storybook": "storybook dev"}
// 等价于
"scripts": {"storybook": "node_modules/.bin/storybook dev"}
那么在执行该命令时,实际执行了哪些文件呢?我们可以回溯一下👇
js
// node_modules/.bin/storybook
#!/usr/bin/env node
require ('@storybook/cli/bin/index' );
js
// node_modules/@storybook/cli/bin/index.js
#!/usr/bin/env node
const majorNodeVersion = parseInt(process.version.toString().replace('v', '').split('.')[0], 10);
if (majorNodeVersion < 16) {
// 判断当前 node 版本是否符合预期
console.error('To run storybook you need to have node 16 or higher');
process.exit(1);
}
require ('../dist/generate.js');
ts
// code/lib/cli/src/generate.ts
command ('dev')
.option('-p, --port <number>', 'Port to run Storybook', (str) => parseInt(str, 10))
// ......
.action(async (options) => {
logger.setLevel(program.loglevel);
consoleLogger.log(chalk.bold(`${pkg.name} v${pkg.version}`) + chalk.reset('\n'));
// The key is the field created in `options` variable for
// each command line argument. Value is the env variable.
getEnvConfig(options, {
port: 'SBCONFIG_PORT',
host: 'SBCONFIG_HOSTNAME',
staticDir: 'SBCONFIG_STATIC_DIR',
configDir: 'SBCONFIG_CONFIG_DIR',
ci: 'CI',
});
if (parseInt(`${options.port}`, 10)) {
// eslint-disable-next-line no-param-reassign
options.port = parseInt(`${options.port}`, 10);
}
await dev({ ...options, packageJson: pkg }).catch(() => process.exit(1));
});
综上可知,最后执行的代码为 generate.ts 文件中的 dev
函数。其中主要执行的函数为 buildDevStandalone
ts
export async function buildDevStandalone(
options: CLIOptions & LoadOptions & BuilderOptions
): Promise<{ port: number; address: string; networkAddress: string }> {
const { packageJson, versionUpdates } = options;
invariant(
packageJson.version !== undefined,
`Expected package.json#version to be defined in the "${packageJson.name}" package}`
);
// updateInfo are cached, so this is typically pretty fast
const [port, versionCheck] = await Promise.all([
getServerPort(options.port),
versionUpdates
? updateCheck(packageJson.version)
: Promise.resolve({ success: false, cached: false, data: {}, time: Date.now() }),
]);
// 判断当前端口是否可用
if (!options.ci && !options.smokeTest && options.port != null && port !== options.port) {
const { shouldChangePort } = await prompts({
type: 'confirm',
initial: true,
name: 'shouldChangePort',
message: `Port ${options.port} is not available. Would you like to run Storybook on port ${port} instead?`,
});
if (!shouldChangePort) process.exit(1);
}
/* eslint-disable no-param-reassign */
options.port = port;
options.versionCheck = versionCheck;
options.configType = 'DEVELOPMENT';
options.configDir = resolve(options.configDir);
options.outputDir = options.smokeTest
? resolvePathInStorybookCache('public')
: resolve(options.outputDir || resolvePathInStorybookCache('public'));
options.serverChannelUrl = getServerChannelUrl(port, options);
/* eslint-enable no-param-reassign */
// 加载 .storybook/main.ts 配置
const config = await loadMainConfig (options);
const { framework } = config;
const corePresets = [];
const frameworkName = typeof framework === 'string' ? framework : framework?.name;
validateFrameworkName(frameworkName);
corePresets.push(join(frameworkName, 'preset'));
await warnOnIncompatibleAddons(config);
// Load first pass: We need to determine the builder
// We need to do this because builders might introduce 'overridePresets' which we need to take into account
// We hope to remove this in SB8
let presets = await loadAllPresets({
corePresets,
overridePresets: [
require.resolve('@storybook/core-server/dist/presets/common-override-preset'),
],
...options,
});
const { renderer, builder, disableTelemetry } = await presets.apply<CoreConfig>('core', {});
invariant(builder, 'No builder configured in core.builder');
if (!options.disableTelemetry && !disableTelemetry) {
if (versionCheck.success && !versionCheck.cached) {
telemetry('version-update');
}
}
const builderName = typeof builder === 'string' ? builder : builder.name;
// 此处主要用于在下文中加载对应的 corePresets
const [previewBuilder, managerBuilder] = await Promise.all([
getPreviewBuilder (builderName, options. configDir ), // 默认使用加载 @storybook/builder-webpack
getManagerBuilder (), // 默认加载 @storybook/builder-manager,uses `esbuild` to bundle the manager-side of addons,
]);
const resolvedRenderer = renderer && resolveAddonName(options.configDir, renderer, options);
// Load second pass: all presets are applied in order
presets = await loadAllPresets({
corePresets: [
require.resolve('@storybook/core-server/dist/presets/common-preset'),
...(managerBuilder.corePresets || []),
...(previewBuilder.corePresets || []),
...(resolvedRenderer ? [resolvedRenderer] : []),
...corePresets,
require.resolve('@storybook/core-server/dist/presets/babel-cache-preset'),
],
overridePresets: [
...(previewBuilder.overridePresets || []),
require.resolve('@storybook/core-server/dist/presets/common-override-preset'),
],
...options,
});
const features = await presets.apply<StorybookConfig['features']>('features');
global.FEATURES = features;
const fullOptions: Options = {
...options,
presets,
features,
};
const { address, networkAddress, managerResult, previewResult } = await storybookDevServer (
fullOptions
);
const previewTotalTime = previewResult?.totalTime;
const managerTotalTime = managerResult?.totalTime;
const previewStats = previewResult?.stats;
const managerStats = managerResult?.stats;
// ......
}
return { port, address, networkAddress };
}
我们深入看看 storybookDevServer 函数的代码
📌 Builders
Storybook 的核心是由 Webpack 和 Vite 等构建器提供支持。这些构建器启动一个开发环境,将您的代码(Javascript、CSS 和 MDX)编译为可执行包,并实时更新浏览器。
从上图中我们知道 storybook 的开发环境可以分为 Preview Iframe 部分和 Manager App 部分,前者默认由 webpack5 提供构建支持,后者主要由 ESbuild 提供构建支持。
ts
// code/lib/core/server/src/dev-server.ts
export async function storybookDevServer(options: Options) {
const app = express();
const [server, features, core] = await Promise.all([
getServer(app, options),
options.presets.apply<StorybookConfig['features']>('features'),
options.presets.apply<CoreConfig>('core'),
]);
const serverChannel = await options.presets.apply(
'experimental_serverChannel',
getServerChannel(server)
);
let indexError: Error | undefined;
// try get index generator, if failed, send telemetry without storyCount, then rethrow the error
// 尝试将符合条件的匹配文件转化成会渲染在页面左侧的导航树
const initializedStoryIndexGenerator : Promise < StoryIndexGenerator | undefined > =
getStoryIndexGenerator (features ?? {}, options, serverChannel). catch ( ( err ) => {
indexError = err;
return undefined ;
});
app.use(compression({ level: 1 }));
if (typeof options.extendServer === 'function') {
options.extendServer(server);
}
app.use(getAccessControlMiddleware(core?.crossOriginIsolated ?? false));
app.use(getCachingMiddleware());
getMiddleware(options.configDir)(router);
app.use(router);
const { port, host, initialPath } = options;
invariant(port, 'expected options to have a port');
const proto = options.https ? 'https' : 'http';
const { address, networkAddress } = getServerAddresses(port, host, proto, initialPath);
const listening = new Promise<void>((resolve, reject) => {
// @ts-expect-error (Following line doesn't match TypeScript signature at all 🤔)
server.listen({ port, host }, (error: Error) => (error ? reject(error) : resolve()));
});
invariant(core?.builder, 'no builder configured!');
const builderName = typeof core?.builder === 'string' ? core.builder : core?.builder?.name;
const [previewBuilder, managerBuilder] = await Promise.all([
getPreviewBuilder(builderName, options.configDir),
getManagerBuilder(),
useStatics(router, options),
]);
if (options.debugWebpack) {
logConfig('Preview webpack config', await previewBuilder.getConfig(options));
}
// 启动 managerBuilder,主要是利用 express 将以 /sb-addons 和 /sb-manager 开头的请求路径映射到对应目录下的静态文件,
// 并将对应的 cssFile 以及 jsFiles 提取,交由模版 html 渲染,用作构建 Manager App 界面
const managerResult = await managerBuilder. start ({
startTime: process.hrtime(),
options,
router,
server,
channel: serverChannel,
});
let previewStarted: Promise<any> = Promise.resolve();
if (!options.ignorePreview) {
// 启动 previewBuilder
// 获取一个 webpack 实例并根据 option 创建一个 webpack 编译器
// 创建一个进度插件(ProgressPlugin),用于监听构建进度,并通过 channel.emit() 发送构建进度的消息
// 利用 express 将以 /sb-preview 等开头的请求路径映射到对应目录下的静态文件
previewStarted = previewBuilder
. start ({
startTime: process.hrtime(),
options,
router,
server,
channel: serverChannel,
})
.catch(async (e: any) => {
await managerBuilder?.bail().catch();
// For some reason, even when Webpack fails e.g. wrong main.js config,
// the preview may continue to print to stdout, which can affect output
// when we catch this error and process those errors (e.g. telemetry)
// gets overwritten by preview progress output. Therefore, we should bail the preview too.
await previewBuilder?.bail().catch();
// re-throw the error
throw e;
});
}
// this is a preview route, the builder has to be started before we can serve it
// this handler keeps request to that route pending until the builder is ready to serve it, preventing a 404
router.get('/iframe.html', (req, res, next) => {
// We need to catch here or node will treat any errors thrown by `previewStarted` as
// unhandled and exit (even though they are very much handled below)
previewStarted.catch(() => {}).then(() => next());
});
await Promise.all([initializedStoryIndexGenerator, listening]).then(async ([indexGenerator]) => {
if (indexGenerator && !options.ci && !options.smokeTest && options.open) {
openInBrowser(host ? networkAddress : address);
}
});
if (indexError) {
await managerBuilder?.bail().catch();
await previewBuilder?.bail().catch();
throw indexError;
}
const previewResult = await previewStarted;
// Now the preview has successfully started, we can count this as a 'dev' event.
doTelemetry(core, initializedStoryIndexGenerator, options);
return { previewResult, managerResult, address, networkAddress };
}
getStoryIndexGenerator 与 .stories.js 文件处理
该函数是将符合条件的匹配文件转化成会渲染在页面左侧的导航树的重要一步,在该步骤中我们可以知道 storybook 是用什么方式处理 .stories.js 文件内容的。
CSF 格式规范
在真正深入函数之前,我想先聊一下 stoies 是什么以及它的书写规范。
📌 What's a Story
A story captures the rendered state of a UI component. Developers write multiple stories per component that describe all the "interesting" states a component can support.
story 捕获 UI 组件的渲染状态。开发人员为每个组件编写多个 story,描述组件可以支持的所有"interesting"状态。
实际上我觉得可以简单把 story 理解为组件在不同给定参数下的状态,我们通过书写 story 来测试我们的组件在不同情况下是否符合我们的预期。
Story 通常写在以 .stories.js 或 .stories.ts 结尾的文件中,并以 Component Story Format (CSF) 格式编写,这是一种基于 ES6 模块的标准,用于编写组件示例,易于编写并可在测试工具中复用,比如可以在 Jest 和 Testing Library 中复用 stories 来验证交互,在 Chromatic 中复用进行视觉的测试,在 Cypress 中复用进行 E2E 测试。该格式的关键部分是有一个描述组件的 default export 和多个用于描述组件样例的 named exports。
一个基础的按照 CSF 规范书写的 story 🌰 如下(重点查看注释部分)
js
// src/storyies/Buttion.stories.js
import { Button } from './Button';
import { userEvent, within } from '@storybook/testing-library';
// default export
// 默认导出的元数据控制 Storybook 如何列出您的 story 并提供插件使用的信息。
export default {
// 控制 story 及其可能动态生成的文档在左侧导航栏中的位置。
title: 'Example/Button',
component: Button,
// parameters 是一组有关故事的静态命名元数据,通常用于控制 Storybook 功能和插件的行为,可以简单理解为控制组件之外的东西。
parameters: {
// 比如可以控制组件在画布中的位置
layout: 'centered',
// 或者控制画布的背景颜色。
backgrounds: {
values: [
{ name: 'red', value: '#f00' },
{ name: 'green', value: '#0f0' },
{ name: 'blue', value: '#00f' },
],
},
},
// 用于定制包裹 story 的 extra markup 或 context mocking
decorators: [
(Story) => (
<div style={{ margin: '3em' }}>
{/* 👇 Decorators in Storybook also accept a function. Replace <Story/> with Story() to enable it */}
<Story />
</div>
),
],
// 设置 autodocs 后将为该组件自动生成相应的文档。文档内容包括标题、描述、可交互的 API 表格、对于过长文档可选的右侧 anchor 和该组件的所有 story 展示
tags: ['autodocs'],
// 更多 argTypes 内容查阅 https://storybook.js.org/docs/react/api/arg-types#argtypes
argTypes: {
backgroundColor: { control: 'color' },
},
};
// named exports 建议以大写开头
export const Primary = {
// 重命名该 story
name: 'So simple!',
// 直接传入 args 参数使例子更简洁
args: {
primary: true,
label: 'Button',
},
parameters: {},
decorators: [],
// play 函数是故story 渲染后执行的小代码片段。使您能够与组件进行交互并测试原本需要用户干预的场景。
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button');
await userEvent.click(button);
},
// loaders 是为 story 及其 decorators 加载数据的异步函数。
loaders: [async () => ({
todo: await (await fetch('https://jsonplaceholder.typicode.com/todos/1')).json(),
}),
],
};
export const Secondary = {
args: {
label: 'Button',
},
};
export const Large = {
args: {
size: 'large',
label: 'Button',
},
};
export const Small = {
args: {
size: 'small',
label: 'Button',
},
};
export const Warning = {
args: {
primary: true,
label: 'Delete now',
backgroundColor: 'red',
}
};
该 stories 对应的组件代码如下
jsx
// src/stories/Button.jsx
import React from 'react';
import PropTypes from 'prop-types';
import './button.css';
// autoDocs description
/**
* Primary UI component for user interaction
*/
export const Button = ({ primary, backgroundColor, size, label, ...props }) => {
const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary';
return (
<button
type="button"
className={['storybook-button', `storybook-button--${size}`, mode].join(' ')}
style={backgroundColor && { backgroundColor }}
{...props}
>
{label}
</button>
);
};
// 此处编写的注释将成为 control 面板中 api 的 description
Button.propTypes = {
/**
* Is this the principal call to action on the page?
*/
primary: PropTypes.bool,
/**
* What background color to use
*/
backgroundColor: PropTypes.string,
/**
* How large should the button be?
*/
size: PropTypes.oneOf(['small', 'medium', 'large']),
/**
* Button contents
*/
label: PropTypes.string.isRequired,
/**
* Optional click handler
*/
onClick: PropTypes.func,
};
Button.defaultProps = {
backgroundColor: null,
primary: false,
size: 'medium',
onClick: undefined,
};
Button 组件被放入了 EXAMPLE 文件夹下,并且不同类型的文件有不同的图标做标识,👉 组件、文档、story。Docs 是一个为当前组件自动生成的文档。
结合上述例子我们知道了一个符合规范 csf 规范的 stories.js 文件包括一个 default export 和多个 named exports,其中 default export 导出的 metadata 用于控制当前 组件的 stories 在左侧展示导航栏中的位置并提供组件层面的插件控制信息,named export 则用于定制单个 story 级别的控制信息,包括 name、args、parameters、decorators、play function 和 loader 等。
CSF 识别
大致了解了 CSF 格式后,我们回到 getStoryIndexGenerator
函数。
ts
// code/lib/core-server/src/utils
export async function getStoryIndexGenerator(
features: {
buildStoriesJson?: boolean;
storyStoreV7?: boolean;
argTypeTargetsV7?: boolean;
warnOnLegacyHierarchySeparator?: boolean;
},
options: Options,
serverChannel: ServerChannel
): Promise<StoryIndexGenerator | undefined> {
if (!features?.buildStoriesJson && !features?.storyStoreV7) {
return undefined;
}
const workingDir = process.cwd();
const directories = {
configDir: options.configDir,
workingDir,
};
//获取 stories 配置,此处为 ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"]
const stories = options. presets . apply ( 'stories' );
const deprecatedStoryIndexers = options.presets.apply('storyIndexers', []);
const indexers = options.presets.apply('experimental_indexers', []);
const docsOptions = options.presets.apply<DocsOptions>('docs', {});
const normalizedStories = normalizeStories(await stories, directories);
/**
* 通过 main.js 中的 stories 定义,StoryIndexGenerator 为每个匹配(一个或多个)stories "specifiers"的文件提取 story 和 doc entries,输出是一组 entries。
**/
const generator = new StoryIndexGenerator (normalizedStories, {
...directories,
storyIndexers: await deprecatedStoryIndexers,
indexers: await indexers,
docs: await docsOptions,
workingDir,
storiesV2Compatibility: !features?.storyStoreV7,
storyStoreV7: features.storyStoreV7 ?? false,
});
const initializedStoryIndexGenerator = generator. initialize (). then ( () => generator);
useStoriesJson({
router,
initializedStoryIndexGenerator,
normalizedStories,
serverChannel,
workingDir,
});
return initializedStoryIndexGenerator;
}
gernerator.initialize
ts
// code/lib/core-server/src/utils/StoryIndexGenerator.ts
async initialize() {
// 查找每个说明符的所有匹配路径
const specifiersAndCaches = await Promise.all(
this.specifiers.map(async (specifier) => {
// .....
})
);
// ...
// 为每个文件提取故事
await this . ensureExtracted ();
}
ensureExtracted function 的主要作用是将每个匹配的文件根据 story 或 mdx 的类型进行区分并做不同的提取策略。
ts
// code/lib/core-server/src/utils/StoryIndexGenerator.ts
async ensureExtracted(): Promise<(IndexEntry | ErrorEntry)[]> {
// 先处理所有故事文件,然后在第二遍中,处理文档文件。 原因是文档文件可能使用"<Meta of={XStories} />"语法,这要求首先处理包含元的故事文件。
await this.updateExtracted(async (specifier, absolutePath) =>
// extractStories 会为 story 设置标题,自动生成或在读取config中的配置
this.isDocsMdx(absolutePath) ? false : this . extractStories(specifier, absolutePath)
);
await this.updateExtracted(async (specifier, absolutePath) =>
// extractDocs 会为 docs 设置标题,自动生成或在读取config中的配置
this.extractDocs(specifier, absolutePath)
);
return this.specifiers.flatMap((specifier) => {
const cache = this.specifierToCache.get(specifier);
if (!cache)
throw new Error(
`specifier ${specifier} does not have a matching cache entry in specifierToCache`
);
return Object.values(cache).flatMap((entry): (IndexEntry | ErrorEntry)[] => {
if (!entry) return [];
if (entry.type === 'docs') return [entry];
if (entry.type === 'error') return [entry];
return entry.entries;
});
});
}
extractStories 的关键代码如下,其主要功能是将匹配的文件路径处理成以下结构的 entries 供 manage 侧消费(比如用作渲染左侧导航)
json
"entries": {
"example-button--primary": {
"type": "story",
"id": "example-button--primary",
"name": "So simple!",
"title": "Example/Button",
"importPath": "./src/stories/Button.stories.js",
"tags": [
"autodocs",
"play-fn",
"story"
]
},
// ....
}
ts
// code/lib/core-server/src/utils/StoryIndexGenerator.ts
async extractStories(
specifier: NormalizedStoriesSpecifier,
absolutePath: Path
): Promise<StoriesCacheEntry | DocsCacheEntry> {
const relativePath = path.relative(this.options.workingDir, absolutePath);
const importPath = slash(normalizeStoryPath(relativePath));
const defaultMakeTitle = (userTitle?: string) => {
const title = userOrAutoTitleFromSpecifier(importPath, specifier, userTitle);
invariant(
title,
"makeTitle created an undefined title. This happens when the fileName doesn't match any specifier from main.js"
);
return title;
};
const indexer = (this.options.indexers as StoryIndexer[])
.concat(this.options.storyIndexers)
.find((ind) => ind.test.exec(absolutePath));
invariant(indexer, `No matching indexer found for ${absolutePath}`);
if (indexer.indexer) {
return this.extractStoriesFromDeprecatedIndexer({
indexer: indexer.indexer,
indexerOptions: { makeTitle: defaultMakeTitle },
absolutePath,
importPath,
});
}
// 实际执行 async (fileName, options) => (await readCsf(fileName, options)).parse().indexInputs
// indexInputs 结构如下
// [{
// type: 'story',
// importPath: this._fileName,
// exportName,
// name: story.name,
// title: this.meta?.title,
// metaId: this.meta?.id,
// tags,
// __id: story.id,
// },....]
const indexInputs = await indexer. index (absolutePath, { makeTitle : defaultMakeTitle });
const entries: ((StoryIndexEntryWithMetaId | DocsCacheEntry) & { tags: Tag[] })[] =
indexInputs.map((input) => {
const name = input.name ?? storyNameFromExport(input.exportName);
const title = input.title ?? defaultMakeTitle();
// eslint-disable-next-line no-underscore-dangle
const id = input.__id ?? toId(input.metaId ?? title, storyNameFromExport(input.exportName));
const tags = (input.tags || []).concat('story');
return {
type:'story',
id,
metaId : input. metaId ,
name,
title,
importPath,
tags,
};
});
const { autodocs } = this.options.docs;
// We need a docs entry attached to the CSF file if either:
// a) autodocs is globally enabled
// b) we have autodocs enabled for this file
// c) it is a stories.mdx transpiled to CSF
const hasAutodocsTag = entries.some((entry) => entry.tags.includes(AUTODOCS_TAG));
const isStoriesMdx = entries.some((entry) => entry.tags.includes(STORIES_MDX_TAG));
const createDocEntry =
autodocs === true || (autodocs === 'tag' && hasAutodocsTag) || isStoriesMdx;
// 符合条件则为组件自动生成文档并加在 entries 数组的第一位
if (createDocEntry) {
const name = this.options.docs.defaultName ?? 'Docs';
const { metaId } = indexInputs[0];
const { title } = entries[0];
const tags = indexInputs[0].tags || [];
const id = toId(metaId ?? title, name);
entries.unshift({
id,
title,
name,
importPath,
type: 'docs',
tags: [...tags, 'docs', ...(!hasAutodocsTag && !isStoriesMdx ? [AUTODOCS_TAG] : [])],
storiesImports: [],
});
}
const entriesWithoutDocsOnlyStories = entries.filter(
(entry) => !(entry.type === 'story' && entry.tags.includes('stories-mdx-docsOnly'))
);
return {
entries: entriesWithoutDocsOnlyStories,
dependents: [],
type: 'stories',
};
}
接下来我们看看代码层面,是如何处理将 csf 文件处理成 indexInputs 的。
ts
export const csfIndexer: Indexer = {
test: /.(stories|story).(m?js|ts)x?$/,
index: async (fileName, options) => ( await readCsf (fileName, options)). parse (). indexInputs,
};
// eslint-disable-next-line @typescript-eslint/naming-convention
export const experimental_indexers: StorybookConfig['experimental_indexers'] = (existingIndexers) =>
[csfIndexer].concat(existingIndexers || []);
ts
// code/lib.csf-tools/src/CsfFile.ts
export const loadCsf = (code: string, options: CsfOptions) => {
// 利用 @babel/parser 和 reacast 获取 flow 或者 typescript 的 ast (抽象语法树)。
const ast = babelParse(code);
return new CsfFile(ast, options);
};
此处我们着重解析 csf.parse() 函数,该函数的主要作用是通过遍历 csf 文件的 ast,对 ExportDefaultDeclaration、ExportNamedDeclaration、ExpressionStatement、 CallExpression、ImportDeclaration 等类型的节点进行处理得到相关的 metaData,返回相关的 name、title、tag 等信息供 extractStories 函数使用。大家可以将 button.stories.js 的代码贴到 AST exporler,结合源码进行解析。
ts
// code/lib/csf-toolssrc/CsfFile.ts
parse() {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
traverse.default(this._ast, {
ExportDefaultDeclaration: {
enter({ node, parent }) {
let metaNode: t.ObjectExpression | undefined;
const isVariableReference = t.isIdentifier(node.declaration) && t.isProgram(parent);
let decl;
if (isVariableReference) {
// const meta = { ... };
// export default meta;
const variableName = (node.declaration as t.Identifier).name;
const isVariableDeclarator = (declaration: t.VariableDeclarator) =>
t.isIdentifier(declaration.id) && declaration.id.name === variableName;
self._metaStatement = self._ast.program.body.find(
(topLevelNode) =>
t.isVariableDeclaration(topLevelNode) &&
topLevelNode.declarations.find(isVariableDeclarator)
);
decl = ((self?._metaStatement as t.VariableDeclaration)?.declarations || []).find(
isVariableDeclarator
)?.init;
} else {
self._metaStatement = node;
decl = node.declaration;
}
if (t.isObjectExpression(decl)) {
// 样例命中该策略
// export default { ... };
metaNode = decl;
} else if (
// export default { ... } as Meta<...>
(t.isTSAsExpression(decl) || t.isTSSatisfiesExpression(decl)) &&
t.isObjectExpression(decl.expression)
) {
metaNode = decl.expression;
}
if (!self._meta && metaNode && t.isProgram(parent)) {
// 样例命中该策略
self._metaNode = metaNode;
// 解析 export default 中的信息并赋值给 this._meta
self._parseMeta(metaNode, parent);
}
if (self._metaStatement && !self._metaNode) {
throw new NoMetaError(
'default export must be an object',
self._metaStatement,
self._fileName
);
}
},
},
ExportNamedDeclaration: {
enter({ node, parent }) {
let declarations;
if (t.isVariableDeclaration(node.declaration)) {
declarations = node.declaration.declarations.filter((d) => t.isVariableDeclarator(d));
} else if (t.isFunctionDeclaration(node.declaration)) {
declarations = [node.declaration];
}
if (declarations) {
// export const X = ...;
declarations.forEach((decl: t.VariableDeclarator | t.FunctionDeclaration) => {
if (t.isIdentifier(decl.id)) {
const { name: exportName } = decl.id;
if (exportName === '__namedExportsOrder' && t.isVariableDeclarator(decl)) {
self._namedExportsOrder = parseExportsOrder(decl.init as t.Expression);
return;
}
self._storyExports[exportName] = decl;
self._storyStatements[exportName] = node;
let name = storyNameFromExport(exportName);
if (self._storyAnnotations[exportName]) {
logger.warn(
`Unexpected annotations for "${exportName}" before story declaration`
);
} else {
self._storyAnnotations[exportName] = {};
}
let storyNode;
if (t.isVariableDeclarator(decl)) {
storyNode =
t.isTSAsExpression(decl.init) || t.isTSSatisfiesExpression(decl.init)
? decl.init.expression
: decl.init;
} else {
storyNode = decl;
}
const parameters: { [key: string]: any } = {};
if (t.isObjectExpression(storyNode)) {
parameters.__isArgsStory = true; // assume default render is an args story
// CSF3 object export
(storyNode.properties as t.ObjectProperty[]).forEach((p) => {
if (t.isIdentifier(p.key)) {
if (p.key.name === 'render') {
parameters.__isArgsStory = isArgsStory(
p.value as t.Expression,
parent,
self
);
} else if (p.key.name === 'name' && t.isStringLiteral(p.value)) {
name = p.value.value;
} else if (p.key.name === 'storyName' && t.isStringLiteral(p.value)) {
logger.warn(
`Unexpected usage of "storyName" in "${exportName}". Please use "name" instead.`
);
} else if (p.key.name === 'parameters' && t.isObjectExpression(p.value)) {
const idProperty = p.value.properties.find(
(property) =>
t.isObjectProperty(property) &&
t.isIdentifier(property.key) &&
property.key.name === '__id'
) as t.ObjectProperty | undefined;
if (idProperty) {
parameters.__id = (idProperty.value as t.StringLiteral).value;
}
}
self._storyAnnotations[exportName][p.key.name] = p.value;
}
});
} else {
parameters.__isArgsStory = isArgsStory(storyNode as t.Node, parent, self);
}
self._stories[exportName] = {
id: 'FIXME',
name,
parameters,
};
}
});
} else if (node.specifiers.length > 0) {
// export { X as Y }
node.specifiers.forEach((specifier) => {
if (t.isExportSpecifier(specifier) && t.isIdentifier(specifier.exported)) {
const { name: exportName } = specifier.exported;
if (exportName === 'default') {
let metaNode: t.ObjectExpression | undefined;
const decl = t.isProgram(parent)
? findVarInitialization(specifier.local.name, parent)
: specifier.local;
if (t.isObjectExpression(decl)) {
// export default { ... };
metaNode = decl;
} else if (
// export default { ... } as Meta<...>
t.isTSAsExpression(decl) &&
t.isObjectExpression(decl.expression)
) {
metaNode = decl.expression;
}
if (!self._meta && metaNode && t.isProgram(parent)) {
self._parseMeta(metaNode, parent);
}
} else {
self._storyAnnotations[exportName] = {};
self._stories[exportName] = { id: 'FIXME', name: exportName, parameters: {} };
}
}
});
}
},
},
ExpressionStatement: {
enter({ node, parent }) {
const { expression } = node;
// B.storyName = 'some string';
if (
t.isProgram(parent) &&
t.isAssignmentExpression(expression) &&
t.isMemberExpression(expression.left) &&
t.isIdentifier(expression.left.object) &&
t.isIdentifier(expression.left.property)
) {
const exportName = expression.left.object.name;
const annotationKey = expression.left.property.name;
const annotationValue = expression.right;
// v1-style annotation
// A.story = { parameters: ..., decorators: ... }
if (self._storyAnnotations[exportName]) {
if (annotationKey === 'story' && t.isObjectExpression(annotationValue)) {
(annotationValue.properties as t.ObjectProperty[]).forEach((prop) => {
if (t.isIdentifier(prop.key)) {
self._storyAnnotations[exportName][prop.key.name] = prop.value;
}
});
} else {
self._storyAnnotations[exportName][annotationKey] = annotationValue;
}
}
if (annotationKey === 'storyName' && t.isStringLiteral(annotationValue)) {
const storyName = annotationValue.value;
const story = self._stories[exportName];
if (!story) return;
story.name = storyName;
}
}
},
},
CallExpression: {
enter({ node }) {
const { callee } = node;
if (t.isIdentifier(callee) && callee.name === 'storiesOf') {
throw new Error(dedent`
Unexpected `storiesOf` usage: ${formatLocation(node, self._fileName)}.
In SB7, we use the next-generation `storyStoreV7` by default, which does not support `storiesOf`.
More info, with details about how to opt-out here: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#storystorev7-enabled-by-default
`);
}
},
},
ImportDeclaration: {
enter({ node }) {
const { source } = node;
if (t.isStringLiteral(source)) {
self.imports.push(source.value);
} else {
throw new Error('CSF: unexpected import source');
}
},
},
});
if (!self._meta) {
throw new NoMetaError('missing default export', self._ast, self._fileName);
}
if (!self._meta.title && !self._meta.component) {
throw new Error(dedent`
CSF: missing title/component ${formatLocation(self._ast, self._fileName)}
More info: https://storybook.js.org/docs/react/writing-stories/introduction#default-export
`);
}
// default export can come at any point in the file, so we do this post processing last
const entries = Object.entries(self._stories);
self._meta.title = this._makeTitle(self._meta?.title as string);
if (self._metaAnnotations.play) {
// 存在 play funciton 时会往 tag 里增加 play-fn 的 tag
self._meta.tags = [...(self._meta.tags || []), 'play-fn'];
}
// 处理每个 name exports stories
self._stories = entries.reduce((acc, [key, story]) => {
if (!isExportStory(key, self._meta as StaticMeta)) {
return acc;
}
const id =
story.parameters?.__id ??
toId((self._meta?.id || self._meta?.title) as string, storyNameFromExport(key));
const parameters: Record<string, any> = { ...story.parameters, __id: id };
const { includeStories } = self._meta || {};
if (
key === '__page' &&
(entries.length === 1 || (Array.isArray(includeStories) && includeStories.length === 1))
) {
parameters.docsOnly = true;
}
acc[key] = { ...story, id, parameters };
const { tags, play } = self._storyAnnotations[key];
if (tags) {
const node = t.isIdentifier(tags)
? findVarInitialization(tags.name, this._ast.program)
: tags;
acc[key].tags = parseTags(node);
}
if (play) {
acc[key].tags = [...(acc[key].tags || []), 'play-fn'];
}
return acc;
}, {} as Record<string, StaticStory>);
Object.keys(self._storyExports).forEach((key) => {
if (!isExportStory(key, self._meta as StaticMeta)) {
delete self._storyExports[key];
delete self._storyAnnotations[key];
}
});
// 对 stories 进行重新排序
if (self._namedExportsOrder) {
const unsortedExports = Object.keys(self._storyExports);
self._storyExports = sortExports(self._storyExports, self._namedExportsOrder);
self._stories = sortExports(self._stories, self._namedExportsOrder);
const sortedExports = Object.keys(self._storyExports);
if (unsortedExports.length !== sortedExports.length) {
throw new Error(
`Missing exports after sort: ${unsortedExports.filter(
(key) => !sortedExports.includes(key)
)}`
);
}
}
return self as CsfFile & IndexedCSFFile;
}
实际上识别拆解 .stories.js 的过程就是使用 Babel 解析 CSF 文件,根据 Babel AST 提取其元数据和 story 的过程。最后我们得到供导航树使用的 entries 格式如下
json
"entries": {
"configure-your-project--docs": {
"id": "configure-your-project--docs",
"title": "Configure your project",
"name": "Docs",
"importPath": "./src/stories/Configure.mdx",
"storiesImports": [],
"type": "docs",
"tags": [
"unattached-mdx",
"docs"
]
},
"example-button--docs": {
"id": "example-button--docs",
"title": "Example/Button",
"name": "Docs",
"importPath": "./src/stories/Button.stories.js",
"type": "docs",
"tags": [
"autodocs",
"play-fn",
"docs"
],
"storiesImports": []
},
"example-button--primary": {
"type": "story",
"id": "example-button--primary",
"name": "So simple!",
"title": "Example/Button",
"importPath": "./src/stories/Button.stories.js",
"tags": [
"autodocs",
"play-fn",
"story"
]
},
"example-button--secondary": {
"type": "story",
"id": "example-button--secondary",
"name": "Secondary",
"title": "Example/Button",
"importPath": "./src/stories/Button.stories.js",
"tags": [
"autodocs",
"story"
]
},
"example-button--large": {
"type": "story",
"id": "example-button--large",
"name": "Large",
"title": "Example/Button",
"importPath": "./src/stories/Button.stories.js",
"tags": [
"autodocs",
"story"
]
},
"example-button--small": {
"type": "story",
"id": "example-button--small",
"name": "Small",
"title": "Example/Button",
"importPath": "./src/stories/Button.stories.js",
"tags": [
"autodocs",
"story"
]
},
"example-button--warning": {
"type": "story",
"id": "example-button--warning",
"name": "Warning",
"title": "Example/Button",
"importPath": "./src/stories/Button.stories.js",
"tags": [
"autodocs",
"story"
]
},
"example-header--docs": {
"id": "example-header--docs",
"title": "Example/Header",
"name": "Docs",
"importPath": "./src/stories/Header.stories.js",
"type": "docs",
"tags": [
"autodocs",
"docs"
],
"storiesImports": []
},
"example-header--logged-in": {
"type": "story",
"id": "example-header--logged-in",
"name": "Logged In",
"title": "Example/Header",
"importPath": "./src/stories/Header.stories.js",
"tags": [
"autodocs",
"story"
]
},
"example-header--logged-out": {
"type": "story",
"id": "example-header--logged-out",
"name": "Logged Out",
"title": "Example/Header",
"importPath": "./src/stories/Header.stories.js",
"tags": [
"autodocs",
"story"
]
},
"example-page--logged-out": {
"type": "story",
"id": "example-page--logged-out",
"name": "Logged Out",
"title": "Example/Page",
"importPath": "./src/stories/Page.stories.js",
"tags": [
"story"
]
},
"example-page--logged-in": {
"type": "story",
"id": "example-page--logged-in",
"name": "Logged In",
"title": "Example/Page",
"importPath": "./src/stories/Page.stories.js",
"tags": [
"play-fn",
"story"
]
}
}
总结
-
npx storybook@latest init
命令通过检查当前项目的 package.json 内容识别用户当前环境的开发框架和包管理器,为用户安装匹配当前框架的 storybook 依赖及模版。 -
Storybook 的开发环境可以分为 Preview Iframe 部分和 Manager App 部分,前者默认由 webpack5 提供构建支持,后者主要由 ESbuild 提供构建支持
-
CSF 格式的关键部分是一个描述组件的 default export 和多个用于描述组件样例的 named exports 。
-
Storybook 通过将匹配路径的下的文件内容通过 Babel 解析并根据 Babel AST 提取其元数据和 story 的方式得到一组供导航树使用的 entries。
下一次笔者将深入Preview Iframe 和 Manager App 通信交互的部分,探索点击左侧导航树切换 storybook 之后右侧 iframe 展示内容变更的过程,敬请期待~
如有错漏之处,欢迎大家批评指正。