编写插件
Storybook 插件是一种强大的方式,可以扩展 Storybook 的功能并自定义开发体验。它们可以用于添加新功能、自定义 UI 或与第三方工具集成。
我们将构建什么?
本参考指南旨在帮助您建立 Storybook 插件工作原理的心智模型,方法是基于流行的 Outline 插件 构建一个简单的插件。在本指南中,您将了解插件的结构、Storybook 的 API、如何在本地测试插件以及如何发布插件。
插件结构
插件主要分为两大类,每类都有其作用
- **基于 UI 的插件**:这些插件负责自定义界面,为常见任务提供快捷方式,或在 UI 中显示其他信息。
- **预设**:这些是预先配置的设置或配置,使开发人员能够快速设置和自定义其环境,并使用一组特定的功能、功能或技术。
基于 UI 的插件
本指南中构建的插件是一个基于 UI 的插件,具体来说是一个 工具栏 插件,它允许用户通过快捷键或点击按钮在故事中的每个元素周围绘制轮廓。UI 插件可以创建其他类型的 UI 元素,每个元素都有其功能:面板 和 选项卡,为用户提供各种与 UI 交互的方式。
import React, { memo, useCallback, useEffect } from 'react';
import { useGlobals, useStorybookApi } from '@storybook/manager-api';
import { IconButton } from '@storybook/components';
import { LightningIcon } from '@storybook/icons';
import { ADDON_ID, PARAM_KEY, TOOL_ID } from './constants';
export const Tool = memo(function MyAddonSelector() {
const [globals, updateGlobals] = useGlobals();
const api = useStorybookApi();
const isActive = [true, 'true'].includes(globals[PARAM_KEY]);
const toggleMyTool = useCallback(() => {
updateGlobals({
[PARAM_KEY]: !isActive,
});
}, [isActive]);
useEffect(() => {
api.setAddonShortcut(ADDON_ID, {
label: 'Toggle Measure [O]',
defaultShortcut: ['O'],
actionName: 'outline',
showInMenu: false,
action: toggleMyTool,
});
}, [toggleMyTool, api]);
return (
<IconButton key={TOOL_ID} active={isActive} title="Enable my addon" onClick={toggleMyTool}>
<LightningIcon />
</IconButton>
);
});
设置
要创建您的第一个插件,您将使用 Addon Kit,这是一个现成的模板,包含所有必需的构建块、依赖项和配置,可帮助您开始构建插件。在 Addon Kit 存储库中,单击“**使用此模板**”按钮,根据 Addon Kit 的代码创建一个新的存储库。
克隆您刚刚创建的存储库并安装其依赖项。安装过程完成后,系统将提示您回答一些问题以配置插件。回答这些问题,当您准备好开始构建插件时,运行以下命令以在开发模式下启动 Storybook 并以监视模式开发插件
npm run start
Addon Kit 默认使用 Typescript。如果您想改为使用 JavaScript,可以运行 eject-ts
命令将项目转换为 JavaScript。
了解构建系统
在 Storybook 生态系统中构建的插件依赖于 tsup,这是一个由 esbuild 提供支持的快速、零配置捆绑器,用于将插件代码转换为可以在浏览器中运行的现代 JavaScript。开箱即用,Addon Kit 带有一个预先配置的 tsup
配置文件,您可以使用它来自定义插件的构建过程。
构建脚本运行时,它将查找配置文件并根据提供的配置预捆绑插件代码。插件可以通过多种方式与 Storybook 交互。它们可以定义预设以修改配置,向管理器 UI 添加行为,或向预览 iframe 添加行为。这些不同的用例需要不同的捆绑输出,因为它们针对不同的运行时和环境。预设在 Node 环境中执行。Storybook 的管理器和预览环境在全局范围内提供某些包,因此插件不需要捆绑它们或将其作为依赖项包含在其 package.json
文件中。
默认情况下,tsup
配置会处理这些复杂性,但您可以根据需要对其进行自定义。有关所用捆绑技术的详细说明,请参阅addon-kit 的自述文件,并查看默认的 tsup
配置此处。
注册插件
默认情况下,基于 UI 的插件的代码位于以下文件之一,具体取决于构建的插件类型:src/Tool.tsx
、src/Panel.tsx
或 src/Tab.tsx
。由于我们正在构建一个工具栏插件,因此我们可以安全地删除 Panel
和 Tab
文件,并将其余文件更新为以下内容
import React, { memo, useCallback, useEffect } from 'react';
import { useGlobals, useStorybookApi } from '@storybook/manager-api';
import { IconButton } from '@storybook/components';
import { LightningIcon } from '@storybook/icons';
import { ADDON_ID, PARAM_KEY, TOOL_ID } from './constants';
export const Tool = memo(function MyAddonSelector() {
const [globals, updateGlobals] = useGlobals();
const api = useStorybookApi();
const isActive = [true, 'true'].includes(globals[PARAM_KEY]);
const toggleMyTool = useCallback(() => {
updateGlobals({
[PARAM_KEY]: !isActive,
});
}, [isActive]);
useEffect(() => {
api.setAddonShortcut(ADDON_ID, {
label: 'Toggle Addon [8]',
defaultShortcut: ['8'],
actionName: 'myaddon',
showInMenu: false,
action: toggleMyTool,
});
}, [toggleMyTool, api]);
return (
<IconButton key={TOOL_ID} active={isActive} title="Enable my addon" onClick={toggleMyTool}>
<LightningIcon />
</IconButton>
);
});
按顺序浏览代码块
// src/Tool.tsx
import { useGlobals, useStorybookApi } from '@storybook/manager-api';
import { IconButton } from '@storybook/components';
import { LightningIcon } from '@storybook/icons';
来自 manager-api
包的 useGlobals
和 useStorybookApi
钩子用于访问 Storybook 的 API,允许用户与插件进行交互,例如启用或禁用它。
来自 @storybook/components
包的 IconButton
或 Button
组件可用于在工具栏中渲染按钮。@storybook/icons
包提供了一套大量大小和样式合适的图标供选择。
// src/Tool.tsx
export const Tool = memo(function MyAddonSelector() {
const [globals, updateGlobals] = useGlobals();
const api = useStorybookApi();
const isActive = [true, 'true'].includes(globals[PARAM_KEY]);
const toggleMyTool = useCallback(() => {
updateGlobals({
[PARAM_KEY]: !isActive,
});
}, [isActive]);
useEffect(() => {
api.setAddonShortcut(ADDON_ID, {
label: 'Toggle Addon [8]',
defaultShortcut: ['8'],
actionName: 'myaddon',
showInMenu: false,
action: toggleMyTool,
});
}, [toggleMyTool, api]);
return (
<IconButton key={TOOL_ID} active={isActive} title="Enable my addon" onClick={toggleMyTool}>
<LightningIcon />
</IconButton>
);
});
Tool
组件是插件的入口点。它在工具栏中渲染 UI 元素,注册键盘快捷键,并处理启用和禁用插件的逻辑。
转到管理器,在这里我们使用唯一的名称和标识符在 Storybook 中注册插件。由于我们已删除 Panel
和 Tab
文件,因此我们需要调整文件以仅引用我们正在构建的插件。
import { addons, types } from '@storybook/manager-api';
import { ADDON_ID, TOOL_ID } from './constants';
import { Tool } from './Tool';
// Register the addon
addons.register(ADDON_ID, () => {
// Register the tool
addons.add(TOOL_ID, {
type: types.TOOL,
title: 'My addon',
match: ({ tabId, viewMode }) => !tabId && viewMode === 'story',
render: Tool,
});
});
有条件地渲染插件
请注意 match
属性。它允许您控制视图模式(故事或文档)和选项卡(故事画布或自定义选项卡),工具栏插件在其中可见。例如
({ tabId }) => tabId === 'my-addon/tab'
在查看 ID 为my-addon/tab
的选项卡时将显示您的插件。({ viewMode }) => viewMode === 'story'
在画布中查看故事时将显示您的插件。({ viewMode }) => viewMode === 'docs'
在查看组件的文档时将显示您的插件。({ tabId, viewMode }) => !tabId && viewMode === 'story'
在画布中查看故事且不在自定义选项卡中时(即tabId === undefined
时)将显示您的插件。
运行 start
脚本以构建并启动 Storybook,并验证插件是否已正确注册并在 UI 中显示。
设置插件样式
在 Storybook 中,应用插件样式被认为是一种副作用。因此,我们需要对插件进行一些更改,以使其在处于活动状态时可以使用样式,并在禁用时删除样式。我们将依靠 Storybook 的两个功能来处理此问题:装饰器 和 全局变量。为了处理 CSS 逻辑,我们必须包含一些辅助函数来注入和删除 DOM 中的样式表。首先创建包含以下内容的辅助文件
import { global } from '@storybook/global';
export const clearStyles = (selector: string | string[]) => {
const selectors = Array.isArray(selector) ? selector : [selector];
selectors.forEach(clearStyle);
};
const clearStyle = (input: string | string[]) => {
const selector = typeof input === 'string' ? input : input.join('');
const element = global.document.getElementById(selector);
if (element && element.parentElement) {
element.parentElement.removeChild(element);
}
};
export const addOutlineStyles = (selector: string, css: string) => {
const existingStyle = global.document.getElementById(selector);
if (existingStyle) {
if (existingStyle.innerHTML !== css) {
existingStyle.innerHTML = css;
}
} else {
const style = global.document.createElement('style');
style.setAttribute('id', selector);
style.innerHTML = css;
global.document.head.appendChild(style);
}
};
接下来,创建包含我们要注入的样式的文件,内容如下
import { dedent } from 'ts-dedent';
export default function outlineCSS(selector: string) {
return dedent/* css */ `
${selector} body {
outline: 1px solid #2980b9 !important;
}
${selector} article {
outline: 1px solid #3498db !important;
}
${selector} nav {
outline: 1px solid #0088c3 !important;
}
${selector} aside {
outline: 1px solid #33a0ce !important;
}
${selector} section {
outline: 1px solid #66b8da !important;
}
${selector} header {
outline: 1px solid #99cfe7 !important;
}
${selector} footer {
outline: 1px solid #cce7f3 !important;
}
${selector} h1 {
outline: 1px solid #162544 !important;
}
${selector} h2 {
outline: 1px solid #314e6e !important;
}
${selector} h3 {
outline: 1px solid #3e5e85 !important;
}
${selector} h4 {
outline: 1px solid #449baf !important;
}
${selector} h5 {
outline: 1px solid #c7d1cb !important;
}
${selector} h6 {
outline: 1px solid #4371d0 !important;
}
${selector} main {
outline: 1px solid #2f4f90 !important;
}
${selector} address {
outline: 1px solid #1a2c51 !important;
}
${selector} div {
outline: 1px solid #036cdb !important;
}
${selector} p {
outline: 1px solid #ac050b !important;
}
${selector} hr {
outline: 1px solid #ff063f !important;
}
${selector} pre {
outline: 1px solid #850440 !important;
}
${selector} blockquote {
outline: 1px solid #f1b8e7 !important;
}
${selector} ol {
outline: 1px solid #ff050c !important;
}
${selector} ul {
outline: 1px solid #d90416 !important;
}
${selector} li {
outline: 1px solid #d90416 !important;
}
${selector} dl {
outline: 1px solid #fd3427 !important;
}
${selector} dt {
outline: 1px solid #ff0043 !important;
}
${selector} dd {
outline: 1px solid #e80174 !important;
}
${selector} figure {
outline: 1px solid #ff00bb !important;
}
${selector} figcaption {
outline: 1px solid #bf0032 !important;
}
${selector} table {
outline: 1px solid #00cc99 !important;
}
${selector} caption {
outline: 1px solid #37ffc4 !important;
}
${selector} thead {
outline: 1px solid #98daca !important;
}
${selector} tbody {
outline: 1px solid #64a7a0 !important;
}
${selector} tfoot {
outline: 1px solid #22746b !important;
}
${selector} tr {
outline: 1px solid #86c0b2 !important;
}
${selector} th {
outline: 1px solid #a1e7d6 !important;
}
${selector} td {
outline: 1px solid #3f5a54 !important;
}
${selector} col {
outline: 1px solid #6c9a8f !important;
}
${selector} colgroup {
outline: 1px solid #6c9a9d !important;
}
${selector} button {
outline: 1px solid #da8301 !important;
}
${selector} datalist {
outline: 1px solid #c06000 !important;
}
${selector} fieldset {
outline: 1px solid #d95100 !important;
}
${selector} form {
outline: 1px solid #d23600 !important;
}
${selector} input {
outline: 1px solid #fca600 !important;
}
${selector} keygen {
outline: 1px solid #b31e00 !important;
}
${selector} label {
outline: 1px solid #ee8900 !important;
}
${selector} legend {
outline: 1px solid #de6d00 !important;
}
${selector} meter {
outline: 1px solid #e8630c !important;
}
${selector} optgroup {
outline: 1px solid #b33600 !important;
}
${selector} option {
outline: 1px solid #ff8a00 !important;
}
${selector} output {
outline: 1px solid #ff9619 !important;
}
${selector} progress {
outline: 1px solid #e57c00 !important;
}
${selector} select {
outline: 1px solid #e26e0f !important;
}
${selector} textarea {
outline: 1px solid #cc5400 !important;
}
${selector} details {
outline: 1px solid #33848f !important;
}
${selector} summary {
outline: 1px solid #60a1a6 !important;
}
${selector} command {
outline: 1px solid #438da1 !important;
}
${selector} menu {
outline: 1px solid #449da6 !important;
}
${selector} del {
outline: 1px solid #bf0000 !important;
}
${selector} ins {
outline: 1px solid #400000 !important;
}
${selector} img {
outline: 1px solid #22746b !important;
}
${selector} iframe {
outline: 1px solid #64a7a0 !important;
}
${selector} embed {
outline: 1px solid #98daca !important;
}
${selector} object {
outline: 1px solid #00cc99 !important;
}
${selector} param {
outline: 1px solid #37ffc4 !important;
}
${selector} video {
outline: 1px solid #6ee866 !important;
}
${selector} audio {
outline: 1px solid #027353 !important;
}
${selector} source {
outline: 1px solid #012426 !important;
}
${selector} canvas {
outline: 1px solid #a2f570 !important;
}
${selector} track {
outline: 1px solid #59a600 !important;
}
${selector} map {
outline: 1px solid #7be500 !important;
}
${selector} area {
outline: 1px solid #305900 !important;
}
${selector} a {
outline: 1px solid #ff62ab !important;
}
${selector} em {
outline: 1px solid #800b41 !important;
}
${selector} strong {
outline: 1px solid #ff1583 !important;
}
${selector} i {
outline: 1px solid #803156 !important;
}
${selector} b {
outline: 1px solid #cc1169 !important;
}
${selector} u {
outline: 1px solid #ff0430 !important;
}
${selector} s {
outline: 1px solid #f805e3 !important;
}
${selector} small {
outline: 1px solid #d107b2 !important;
}
${selector} abbr {
outline: 1px solid #4a0263 !important;
}
${selector} q {
outline: 1px solid #240018 !important;
}
${selector} cite {
outline: 1px solid #64003c !important;
}
${selector} dfn {
outline: 1px solid #b4005a !important;
}
${selector} sub {
outline: 1px solid #dba0c8 !important;
}
${selector} sup {
outline: 1px solid #cc0256 !important;
}
${selector} time {
outline: 1px solid #d6606d !important;
}
${selector} code {
outline: 1px solid #e04251 !important;
}
${selector} kbd {
outline: 1px solid #5e001f !important;
}
${selector} samp {
outline: 1px solid #9c0033 !important;
}
${selector} var {
outline: 1px solid #d90047 !important;
}
${selector} mark {
outline: 1px solid #ff0053 !important;
}
${selector} bdi {
outline: 1px solid #bf3668 !important;
}
${selector} bdo {
outline: 1px solid #6f1400 !important;
}
${selector} ruby {
outline: 1px solid #ff7b93 !important;
}
${selector} rt {
outline: 1px solid #ff2f54 !important;
}
${selector} rp {
outline: 1px solid #803e49 !important;
}
${selector} span {
outline: 1px solid #cc2643 !important;
}
${selector} br {
outline: 1px solid #db687d !important;
}
${selector} wbr {
outline: 1px solid #db175b !important;
}`;
}
由于插件可以在故事和文档模式下都处于活动状态,因此 Storybook 预览 iframe
的 DOM 节点在这两种模式下是不同的。事实上,Storybook 在文档模式下在一个页面上呈现多个故事预览。因此,我们需要为将注入样式的 DOM 节点选择正确的选择器,并确保 CSS 的范围仅限于该特定选择器。src/withGlobals.ts
文件中提供了一个示例机制,我们将使用它将样式和辅助函数连接到插件逻辑。将文件更新为以下内容
import type { Renderer, PartialStoryFn as StoryFunction, StoryContext } from '@storybook/types';
import { useEffect, useMemo, useGlobals } from '@storybook/preview-api';
import { PARAM_KEY } from './constants';
import { clearStyles, addOutlineStyles } from './helpers';
import outlineCSS from './outlineCSS';
export const withGlobals = (StoryFn: StoryFunction<Renderer>, context: StoryContext<Renderer>) => {
const [globals] = useGlobals();
const isActive = [true, 'true'].includes(globals[PARAM_KEY]);
// Is the addon being used in the docs panel
const isInDocs = context.viewMode === 'docs';
const outlineStyles = useMemo(() => {
const selector = isInDocs ? `#anchor--${context.id} .docs-story` : '.sb-show-main';
return outlineCSS(selector);
}, [context.id]);
useEffect(() => {
const selectorId = isInDocs ? `my-addon-docs-${context.id}` : `my-addon`;
if (!isActive) {
clearStyles(selectorId);
return;
}
addOutlineStyles(selectorId, outlineStyles);
return () => {
clearStyles(selectorId);
};
}, [isActive, outlineStyles, context.id]);
return StoryFn();
};
打包和发布
Storybook 插件,类似于 JavaScript 生态系统中的大多数包,都作为 NPM 包分发。但是,它们需要满足特定的标准才能发布到 NPM 并被集成目录抓取
- 具有包含转译代码的
dist
文件夹。 - 一个声明以下内容的
package.json
文件:- 模块相关信息
- 集成目录元数据
模块元数据
第一类元数据与插件本身相关。这包括模块的条目、发布插件时要包含的文件以及与 Storybook 集成插件所需的配置,使其能够被其使用者使用。
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"node": "./dist/index.js",
"require": "./dist/index.js",
"import": "./dist/index.mjs"
},
"./manager": "./dist/manager.mjs",
"./preview": "./dist/preview.mjs",
"./package.json": "./package.json"
},
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"files": ["dist/**/*", "README.md", "*.js", "*.d.ts"],
"devDependencies": {
"@storybook/blocks": "^7.0.0",
"@storybook/components": "^7.0.0",
"@storybook/core-events": "^7.0.0",
"@storybook/manager-api": "^7.0.0",
"@storybook/preview-api": "^7.0.0",
"@storybook/theming": "^7.0.0",
"@storybook/types": "^7.0.0"
},
"bundler": {
"exportEntries": ["src/index.ts"],
"managerEntries": ["src/manager.ts"],
"previewEntries": ["src/preview.ts"]
}
}
集成目录元数据
第二类元数据与集成目录相关。大部分信息已由 Addon Kit 预先配置。但是,诸如显示名称、图标和框架之类的项目必须通过 storybook
属性进行配置,才能在目录中显示。
{
"name": "my-storybook-addon",
"version": "1.0.0",
"description": "My first storybook addon",
"author": "Your Name",
"storybook": {
"displayName": "My Storybook Addon",
"unsupportedFrameworks": ["react-native"],
"icon": "https://yoursite.com/link-to-your-icon.png"
},
"keywords": ["storybook-addons", "appearance", "style", "css", "layout", "debug"]
}
storybook
配置元素包含其他属性,有助于自定义插件的可搜索性和索引。有关更多信息,请参阅集成目录文档。
需要注意的一点是 keywords
属性,因为它映射到目录的标签系统。添加 storybook-addons
可确保在搜索插件时,插件可在目录中被发现。其余关键字有助于插件的可搜索性和分类。
发布到 NPM
当您准备好将插件发布到 NPM 时,Addon Kit 预先配置了用于发布管理的 Auto 包。它会自动生成变更日志并将包上传到 NPM 和 GitHub。因此,您需要配置对两者的访问权限。
- 使用 npm adduser 进行身份验证。
- 生成一个具有
read
和publish
权限的 访问令牌。 - 创建一个具有
repo
和workflow
范围权限的 个人访问令牌。 - 在项目根目录下创建一个
.env
文件并添加以下内容:
GH_TOKEN=value_you_just_got_from_github
NPM_TOKEN=value_you_just_got_from_npm
接下来,运行以下命令在 GitHub 上创建标签。您将使用这些标签对包的更改进行分类。
npx auto create-labels
最后,运行以下命令为您的插件创建发布版本。这将构建和打包插件代码,更新版本号,将发布版本推送到 GitHub 和 npm,并生成变更日志。
npm run release
CI 自动化
默认情况下,Addon Kit 预先配置了一个 GitHub Actions 工作流,使您可以自动化发布管理流程。这确保了包始终与最新更改保持一致,并且变更日志也相应更新。但是,您需要进行其他配置才能使用您的 NPM 和 GitHub 令牌成功发布包。在您的存储库中,点击 **设置** 选项卡,然后点击 **Secrets 和变量** 下拉菜单,再点击 **Actions** 项。您应该会看到以下屏幕。
然后,点击 **新建存储库密钥**,将其命名为 NPM_TOKEN
,并粘贴您之前生成的令牌。每当您将拉取请求合并到默认分支时,工作流将运行并发布新版本,自动递增版本号并更新变更日志。
了解更多关于 Storybook 插件生态系统的信息