文件
Storybook 文件

撰寫擴充功能

Storybook 擴充功能是擴展 Storybook 功能和自訂開發體驗的強大方法。它們可用於新增功能、自訂 UI 或與第三方工具整合。

我們將建立什麼?

本參考指南旨在協助您透過建立一個基於熱門 外框擴充功能 的簡單擴充功能,來開發 Storybook 擴充功能運作方式的心智模型。在本指南中,您將瞭解擴充功能的結構、Storybook 的 API、如何在本機測試您的擴充功能以及如何發佈它。

擴充功能剖析

擴充功能主要有兩大類,每一類都有其作用

  • 基於 UI:這些擴充功能負責自訂介面、啟用常用工作的快速鍵,或在 UI 中顯示其他資訊。
  • 預設這些是預先設定的設定或配置,可讓開發人員快速設定和自訂其具有特定功能、特性或技術的環境。

基於 UI 的擴充功能

本指南中建立的擴充功能是基於 UI 的擴充功能,特別是 工具列擴充功能,可讓使用者透過快速鍵或按一下按鈕,在 story 中的每個元素周圍繪製外框。UI 擴充功能可以建立其他類型的 UI 元素,每個元素都有其功能:面板索引標籤,為使用者提供各種與 UI 互動的方式。

src/Tool.tsx
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>
  );
});

設定

若要建立您的第一個擴充功能,您將使用 擴充功能套件,這是一個現成的範本,具有所有必要的建置區塊、相依性和配置,可協助您開始建置擴充功能。在擴充功能套件儲存庫中,按一下 使用此範本 按鈕,以根據擴充功能套件的程式碼建立新的儲存庫。

複製您剛剛建立的儲存庫,並安裝其相依性。安裝程序完成後,系統會提示您回答問題以設定您的擴充功能。回答它們,當您準備好開始建置您的擴充功能時,請執行下列命令以在開發模式下啟動 Storybook,並在監看模式下開發您的擴充功能

npm run start

擴充功能套件預設使用 Typescript。如果您想要改用 JavaScript,可以執行 eject-ts 命令,將專案轉換為 JavaScript。

了解建置系統

在 Storybook 生態系統中建置的擴充功能依賴 tsup,這是一個由 esbuild 支援的快速零設定綁定器,可將您的擴充功能程式碼轉譯為可在瀏覽器中執行的現代 JavaScript。開箱即用,擴充功能套件隨附預先設定的 tsup 設定檔,您可以使用該設定檔來自訂擴充功能的建置程序。

當建置指令碼執行時,它會尋找設定檔並根據提供的設定預先綁定擴充功能的程式碼。擴充功能可以透過各種方式與 Storybook 互動。它們可以定義預設來修改配置、將行為新增至管理員 UI 或將行為新增至預覽 iframe。這些不同的使用案例需要不同的套件輸出,因為它們以不同的執行階段和環境為目標。預設會在 Node 環境中執行。Storybook 的管理員和預覽環境會在全域範圍內提供某些套件,因此擴充功能不需要綁定它們,或將它們包含在其 package.json 檔案中作為相依性。

tsup 設定預設會處理這些複雜性,但您可以根據其需求來自訂它。如需有關所用綁定技術的詳細說明,請參閱 擴充功能套件的 README,並查看預設 tsup 設定 此處

註冊附加元件

預設情況下,基於 UI 的附加元件程式碼位於以下其中一個檔案中,具體取決於建立的附加元件類型:src/Tool.tsxsrc/Panel.tsxsrc/Tab.tsx。由於我們要建立工具列附加元件,我們可以安全地移除 PanelTab 檔案,並將剩餘的檔案更新為以下內容

src/Tool.tsx
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 套件的 useGlobalsuseStorybookApi Hook 用於存取 Storybook 的 API,讓使用者可以與附加元件互動,例如啟用或停用它。

來自 @storybook/components 套件的 IconButtonButton 元件可用於在工具列中呈現按鈕。 @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 註冊附加元件。由於我們已移除 PanelTab 檔案,因此我們需要調整檔案,使其僅參考我們正在建立的附加元件。

src/manager.ts
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 屬性。它可讓您控制檢視模式 (story 或 docs) 和索引標籤 (story 畫布或自訂索引標籤),其中工具列附加元件會顯示。例如

  • ({ tabId }) => tabId === 'my-addon/tab' 會在檢視 ID 為 my-addon/tab 的索引標籤時顯示您的附加元件。
  • ({ viewMode }) => viewMode === 'story' 會在畫布中檢視 Story 時顯示您的附加元件。
  • ({ viewMode }) => viewMode === 'docs' 會在檢視元件的文件時顯示您的附加元件。
  • ({ tabId, viewMode }) => !tabId && viewMode === 'story' 會在畫布中檢視 Story 且不在自訂索引標籤中時顯示您的附加元件 (亦即當 tabId === undefined 時)。

執行 start 指令碼以建置並啟動 Storybook,並驗證附加元件是否已正確註冊並顯示在 UI 中。

Addon registered in the toolbar

設定附加元件的樣式

在 Storybook 中,為附加元件套用樣式被視為副作用。因此,我們需要對附加元件進行一些變更,才能讓它在啟用時使用樣式,並在停用時移除樣式。我們將依賴 Storybook 的兩個功能來處理此問題:裝飾器全域變數。若要處理 CSS 邏輯,我們必須加入一些輔助函式,以將樣式表注入和移除 DOM。首先使用以下內容建立輔助檔案

src/helpers.ts
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);
  }
};

接下來,使用以下內容建立包含我們要注入的樣式的檔案

src/OutlineCSS.ts
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;
    }`;
}

由於附加元件可以在 Story 和文件模式中啟用,因此 Storybook 預覽 iframe 的 DOM 節點在這兩種模式中會有所不同。事實上,Storybook 在文件模式中會在一個頁面上呈現多個 Story 預覽。因此,我們需要為將注入樣式的 DOM 節點選擇正確的選取器,並確保 CSS 的範圍限定於該特定選取器。此機制在 src/withGlobals.ts 檔案中以範例形式提供,我們將使用該檔案將樣式和輔助函式連接到附加元件邏輯。將檔案更新為以下內容

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 並由整合目錄檢索

  1. 具有包含已轉譯程式碼的 dist 資料夾。
  2. 宣告的 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"]
  }
}

整合目錄中繼資料

第二類中繼資料與整合目錄相關。此資訊大部分已由附加元件套件預先設定。但是,必須透過 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 後,附加元件套件已預先設定 Auto 套件以進行發佈管理。它會自動產生變更記錄,並將套件上傳到 NPM 和 GitHub。因此,您需要設定對兩者的存取權。

  1. 使用 npm adduser 進行驗證
  2. 產生具有 readpublish 權限的存取權杖
  3. 建立一個具有 repoworkflow 權限範圍的個人存取權杖
  4. 在您的專案根目錄建立一個 .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 自動化

預設情況下,附加元件套件已預先設定好 GitHub Actions 工作流程,讓您可以自動化發佈管理流程。這可確保套件始終與最新的變更保持同步,並相應地更新變更日誌。不過,您需要額外設定才能使用您的 NPM 和 GitHub 權杖成功發佈套件。在您的儲存庫中,點擊 Settings(設定)分頁,然後點擊 Secrets and variables(機密和變數)下拉選單,接著點擊 Actions(動作)項目。您應該會看到以下畫面

GitHub secrets page

然後,點擊 New repository secret(新增儲存庫機密),將其命名為 NPM_TOKEN,並貼上您稍早產生的權杖。每當您將提取請求合併到預設分支時,工作流程將會執行並發佈新版本,自動遞增版本號並更新變更日誌。

深入了解 Storybook 附加元件生態系統