關於元件文件從編寫到生成的那些事

ES2049發表於2021-12-10

前言

說到前端領域的元件,Vue 技術體系下有 Element UI,React 技術體系下有 Ant Design,這些都是當前的前端攻城獅們都免不了要實際使用到的基礎元件庫。而在實際工作中,我們也總免不了要根據自己的工作內容,整理一些適合自己業務風格的一套元件庫,基礎元件部分可以基於上面開源的元件庫以及 less 框架等多主題樣式方案做自己的定製,但更多的是一些基於這些基礎元件整理出適合自己業務產品的一套業務元件庫。

而說到開發元件庫,我們或選擇 Monorepo 單倉庫多包的形式(參考網文 https://segmentfault.com/a/11... 等)或其他 Git 多倉庫單包的形式來維護元件程式碼,最終都免不了要將元件真正落到一個文件中,提供給其他同事去參考使用。

本篇文章就產出元件文件這件事,聊聊我在產出文件過程中的一系列思考過程,解決元件開發這「最後一公里」中的體驗問題。

元件文件的編寫

規範與搭建的調研

元件文件是要有一定的規範的,規範是任何軟體工程階段的第一步。對於元件來說,文件透出的內容都應包含哪些內容,決定著元件開發者和使用者雙方的所有的體驗。確定這些規範並不難,參考開源的元件庫的文件,我們會有一些初步的印象。

因為我們團隊使用的是 React 技術棧,這裡我們參考 Ant Design 元件庫。

比如這個最基本的 Button 元件,官方文件從上至下的內容結構是這樣:

  1. 顯示元件標題,下跟元件的簡單描述。
  2. 列出元件的使用場景。
  3. 不同使用場景下的效果演示、程式碼案例。可外跳 CodeSandbox、CodePen 等線上原始碼編輯器網站編輯實時檢視效果。
  4. 列出元件可配置的屬性、介面方法列表。列表中包含屬性/方法名、欄位型別、預設值、使用描述等。
  5. 常見問題的 FAQ。
  6. 面向設計師的一些 Case 說明連結。

這些文件內容很豐富,作為一個開放的元件庫,幾乎考慮到的從設計到開發視角的方方面面,使用體驗特別好。而在好奇心驅使下,我去檢視了官網原始碼方庫,比如 Button 元件:https://github.com/ant-design...。在原始碼庫下,放置了和元件入口檔案同名的分別以 .zh-CN.md.en-US.md 字尾命名的 Markdown 檔案,而在這些 Markdown 檔案中,便是我們看到的官網文件內容...咦?不對,好像缺少了什麼,案例演示和示例程式碼呢?

難道 AntD 官網文件是另外自己手動開發維護的?這麼大的一個專案肯定不至於,根據其官網類似 docs/react/introduce-cn 這種訪問路徑在原始碼庫中有對應的 Markdown 檔案來看,官網的文件肯定是官方倉庫根據一種規範來生成的。那麼是怎麼生成的呢?第一次做元件文件規範的我被挑起了興趣。

而作為一個前端工程老手,我很熟練地開啟了其 package.json 檔案,通過檢視其中的 scripts 命令,輕易便發現了其下的 site 命令(原始碼倉庫 package.json):

npm run site:theme && cross-env NODE_ICU_DATA=node_modules/full-icu ESBUILD=1 bisheng build --ssr -c ./site/bisheng.config.js

原來如此,網站的構建使用了 bisheng。通過查閱瞭解 bisheng 這個工具庫,發現它確實是一個文件系統的自動生成工具,其下有一個外掛 bisheng-plugin-react,可以將 Markdown 文件中的 JSX 原始碼塊轉換成可以執行演示的示例。而每個元件自身的示例程式碼文件,則在每個元件路徑下的
demo 目錄下維護。

Emmm,bisheng 確實是很好很強大,還能支援多語言,結合一定的文件規範約束下,能夠快速搭建一個文件的主站。但在深入瞭解 bisheng 的過程中,發現其文件相對來說比較缺乏,包裝的東西又那麼多,使用過程中黑盒感嚴重,而我們團隊的元件庫其實要求很簡單,一是能做到方便流通,而是隻在內部流通使用,不會開源。那麼,有沒有更簡單的搭建文件的方式呢?

更多的文件工具庫的調研

在谷歌搜尋中敲入如 React Components Documentation 等關鍵字,我們很快便能搜尋出很多與 React 元件文件相關的工具庫,這裡我看到了如下這些:DoczStoryBookReact Styleguidist 、UMI 構建體系下的 dumi 等等。

這些工具庫都支援解析 Markdown 文件,其中 DoczStoryBook 還支援使用 mdx 格式(Markdown 和 JSX 的混合寫法),且在文件內容格式都能支援到元件屬性列表、示例程式碼演示等功能。

接下來,我們分別簡單看下這些工具庫對於元件文件的支援情況。

Docz

在瞭解過程中,發現 Docz 其實是一個比較老牌的文件系統搭建工具了。它本身便主推 MDX 格式的文件,基本不需要什麼配置便能跑起來。支援本地除錯和構建生成可釋出產物,支援多包倉庫、TypeScript 環境、CSS 處理器、外掛機制等,完全滿足功能需要。

只是 Docz 貌似只支援 React 元件(當然對於我們來說夠用),且看其 NPM 包最近更新已經是兩年之前。另外 MDX 格式的文件雖然理解成本很少但對於使用不多的同事來說還是有一定的接受和熟練上手的成本。暫時備選。

StoryBook

在初次瞭解到 StoryBook 時便被其 66.7K 的 Star 量驚到了(Docz 是 22K),相對 Docz 來說,StoryBook 相關的社群內容非常豐富,它不依賴元件的技術棧體系,現在已經支援 React、Vue、Angular、Web Components 等數十個技術棧。

StoryBook 搭建文件系統的方式不是去自動解析 Markdown 檔案,而是暴露一系列搭建文件的介面,讓開發者自己為元件手動編寫一個個的 stories 檔案,StoryBook 會自動解析這些 stories 檔案來生成文件內容。這種方式會帶來一定的學習和理解介面的成本,但同時也基於這種方式實現了支援跨元件技術棧的效果,並讓社群顯得更為豐富。

官方示例:https://github.com/storybookj...

StoryBook 的強大毋庸置疑,但對於我們團隊的情況來說還是有些殺雞用牛刀了。另外,其需要額外理解介面功能並編寫元件的 stories 檔案在團隊內很難推動起來:大家都很忙,元件開發分佈在團隊幾十號人,情況比較複雜,將文件整理約束到一個人身上又不現實。繼續調研。

React Styleguidist

React Styleguidist 的 Star 量沒有 StoryBook 那麼耀眼(10K+),但包體的下載量也比較大,且近期的提交也是相當活躍。由名字可知,它支援的是 React 元件的環境。它是通過自動解析 Mardown 檔案的形式來生成文件的,實現方式是自動解析文件中 JSX 宣告程式碼塊,按照名稱一一對應的規則查詢到元件原始碼,然後將宣告的程式碼塊通過 Webpack 打包產生出對應的演示示例。

而在繼續試用了 React Styleguidist 的一些基礎案例後,它的一個功能讓我眼前一亮:它會自動解析元件的屬性,並解析出其型別、預設值、註釋描述等內容,然後將解析到的內容自動生成屬性表格放置在演示示例的上方。這就有點 JSDOC 的意思了,對於一個元件開發者來說,TA 確實需要關心元件屬性的透出、註釋以及文件案例的編寫,但編寫完也就夠了,不用去考慮怎麼適應搭建出一個文件系統。

另外, React Styleguidist 解析元件屬性是基於解析 AST 以及配合工具 react-docgen 來實現的,並且還支援配合 react-docgen-typescript 來實現解析 TypeScript 環境下的元件,另外還能很多配置項支援更改文件站點相關的各個部分的展示樣式、內容格式等,配置自定義支援相當靈活。

當然,它也有一些缺點,比如內嵌 Webpack,對於已經將編譯元件庫的構建工具換為 Rollup.js 的情況是一個額外的配置負擔。

總的來說,React Styleguidist 在我看來是一個小而美的工具庫,很適合我們團隊協作參與人多、且大都日常開發工作繁重的情況。暫時備選。

dumi

瞭解到 dumi 是因為我們團隊內已經有部分元件文件站點是基於它來搭建的了。dumi 一樣是通過自動解析 Markdown 文件的方式來實現搭建文件系統,同樣基本零配置,也有很多靈活的配置支援更改文件站點一些部分的顯示內容、(主題)樣式等,整體秉承了 UMI 體系的風格:開箱即用,封裝極好。它能單獨使用,也能結合 UMI 框架一起配置使用。

只是相比於上面已經瞭解到的 React Styleguidist 來說,並未看到有其他明顯的優勢,且貌似沒有看到有自動解析元件屬性部分的功能,對於我來說沒有 React Styleguidist 下得一些亮點。可以參考,不再考慮。

元件文件的生成

在多方對比了多個文件搭建的工具庫後,我最終還是選用了 React Styleguidist。在我看來,自然是其基於 react-docgen 來實現解析元件屬性、型別、註釋描述等的功能吸引到了我,這個功能一方面能在較少的額外時間付出下規範團隊同事開發元件過程中一系列規範,另一方面其 API 介面的接入形式能夠通過統一構建配置而統一產出文件內容格式和樣式,方便各業務接入使用。

決定了技術方案後,便是如何具體實現基於其封裝一個工具,便於各業務倉庫接入了。

我們團隊有自己統一的 CLI 構建工具,再多一個 React Styleguidist 的 CLI 配置會在理解上有一定的熟悉成本,但我可以基於 React Styleguidist 的 Node API 接入形式,將 React Styleguidist 的功能分別融入我們自身 CLI 的 devbuild 命令。

首先,基於 React Styleguidist API 的形式,統一一套配置,將生成 React Styleguidist 示例的程式碼抽象出來:

// 定義一套統一的配置,生成 react-styleguidist 例項
import styleguidist from 'react-styleguidist/lib/scripts/index.esm';
import * as docgen from 'react-docgen';
import * as docgenTS from 'react-docgen-typescript';

import type * as RDocgen from 'react-docgen';

export type DocStyleguideOptions = {
  cwd?: string;
  rootDir: string;
  workDir: string;
  customConfig?: object;
};

const DOC_STYLEGUIDE_DEFAULTS = {
  cwd: process.cwd(),
  rootDir: process.cwd(),
  workDir: process.cwd(),
  customConfig: {},
};

export const createDocStyleguide = (
  env: 'development' | 'production',
  options: DocStyleguideOptions = DOC_STYLEGUIDE_DEFAULTS,
) => {
  // 0. 處理配置項
  const opts = { ...DOC_STYLEGUIDE_DEFAULTS, ...options };
  const {
    cwd: cwdPath = DOC_STYLEGUIDE_DEFAULTS.cwd,
    rootDir,
    workDir,
    customConfig,
  } = opts;

  // 標記:是否正在除錯所有包
  let isDevAllPackages = true;

  // 解析工程根目錄包資訊
  const pkgRootJson = Utils.parsePackageSync(rootDir);

  // 1. 解析指定要除錯的包下的元件
  let componentsPattern: (() => string[]) | string | string[] = [];
  if (path.relative(rootDir, workDir).length <= 0) {
    // 選擇除錯所有包時,則讀取根路徑下 packages 欄位定義的所有包下的元件
    const { packages = [] } = pkgRootJson;
    componentsPattern = packages.map(packagePattern => (
      path.relative(cwdPath, path.join(rootDir, packagePattern, 'src/**/[A-Z]*.{js,jsx,ts,tsx}'))
    ));
  } else {
    // 選擇除錯某個包時,則定位至選擇的具體包下的元件
    componentsPattern = path.join(workDir, 'src/**/[A-Z]*.{js,jsx,ts,tsx}');
    isDevAllPackages = false;
  }

  // 2. 獲取預設的 webpack 配置
  const webpackConfig = getWebpackConfig(env);

  // 3. 生成 styleguidist 配置例項
  const styleguide = styleguidist({
    title: `${pkgRootJson.name}`,
    // 要解析的所有元件
    components: componentsPattern,
    // 屬性解析設定
    propsParser: (filePath, code, resolver, handlers) => {
      if (/\.tsx?/.test(filePath)) {
        // ts 檔案,使用 typescript docgen 解析器
        const pkgRootDir = findPackageRootDir(path.dirname(filePath));
        const tsConfigParser = docgenTS.withCustomConfig(
          path.resolve(pkgRootDir, 'tsconfig.json'),
          {},
        );
        const parseResults = tsConfigParser.parse(filePath);
        const parseResult = parseResults[0];
        return (parseResult as any) as RDocgen.DocumentationObject;
      }
      // 其他使用預設的 react-docgen 解析器
      const parseResults = docgen.parse(code, resolver, handlers);
      if (Array.isArray(parseResults)) {
        return parseResults[0];
      }
      return parseResults;
    },
    // webpack 配置
    webpackConfig: { ...webpackConfig },
    // 初始是否展開程式碼樣例
    // expand: 展開 | collapse: 摺疊 | hide: 不顯示;
    exampleMode: 'expand',
    // 元件 path 展示內容
    getComponentPathLine: (componentPath) => {
      const pkgRootDir = findPackageRootDir(path.dirname(componentPath));
      try {
        const pkgJson = Utils.parsePackageSync(pkgRootDir);
        const name = path.basename(componentPath, path.extname(componentPath));
        return `import ${name} from '${pkgJson.name}';`;
      } catch (error) {
        return componentPath;
      }
    },
    // 非除錯所有包時,不顯示 sidebar
    showSidebar: isDevAllPackages,
    // 日誌配置
    logger: {
      // One of: info, debug, warn
      info: message => Utils.log('info', message),
      warn: message => Utils.log('warning', message),
      debug: message => console.debug(message),
    },
    // 覆蓋自定義配置
    ...customConfig,
  });

  return styleguide;
};

這樣,在 devbuild 命令下可以分別呼叫例項的 server 介面方法和 build 介面方法來實現除錯和構建產出文件靜態資源。

// dev 命令下啟動除錯
// 0. 初始化配置
const HOST = process.env.HOST || customConfig.serverHost || '0.0.0.0';
const PORT = process.env.PORT || customConfig.serverPort || '6060';

// 1. 生成 styleguide 例項
const styleguide = createDocStyleguide(
  'development',
  {
    cwd: cwdPath,
    rootDir: pkgRootPath,
    workDir: workPath,
    customConfig: {
      ...customConfig,
      // dev server host
      serverHost: HOST,
      // dev server port
      serverPort: PORT,
    },
  },
);

// 2. 呼叫 server 介面方法啟動除錯
const { compiler } = styleguide.server((err, config) => {
  if (err) {
    console.error(err);
  } else {
    const url = `http://${config.serverHost}:${config.serverPort}`;
    Utils.log('info', `Listening at ${url}`);
  }
});
compiler.hooks.done.tap('done', (stats: any) => {
  const timeStr = stats.toString({
    all: false,
    timings: true,
  });

  const statStr = stats.toString({
    all: false,
    warnings: true,
    errors: true,
  });

  console.log(timeStr);

  if (stats.hasErrors()) {
    console.log(statStr);
    return;
  }
});
// build 命令下執行構建

// 生成 styleguide 例項
const styleguide = MonorepoDev.createDocStyleguide('production', {
  cwd,
  rootDir,
  workDir,
  customConfig: {
    styleguideDir: path.join(pkgDocsDir, 'dist'),
  },
});
// 構建文件內容
await new Promise<void>((resolve, reject) => {
  styleguide.build(
    (err, config, stats) => {
      if (err) {
        reject(err);
      } else {
        if (stats != null) {
          const statStr = stats.toString({
            all: false,
            warnings: true,
            errors: true,
          });
          console.log(statStr);
          if (stats.hasErrors()) {
            reject(new Error('Docs build failed!'));
            return;
          }
          console.log('\n');
          Utils.log('success', `Docs published to ${path.relative(workDir, config.styleguideDir)}`);
        }
        resolve();
      }
    },
);

最後,在元件多包倉庫的每個包下的 package.json 中,分別配置 devbuild 命令即可。實現了支援無感啟動除錯和構建產出文件資源。

小結

本文主要介紹了我在調研實現元件文件規範和搭建過程中的一個思考過程,誠如文中介紹其他文件系統搭建工具時所說,有很多優秀的開源工具能夠支援實現我們想要的效果,這是前端攻城獅們的幸運,也是不幸:我們可以站在前人的肩膀上,但要在這麼多優秀庫中選擇一個適合自己的,更需要多做一些瞭解和收益點的權衡。一句老話經久不衰:適合自己的才是最好的。

希望這篇文章對看到這裡的你能有所幫助。

作者:ES2049 / 靳志凱
文章可隨意轉載,但請保留此原文連結。
非常歡迎有激情的你加入 ES2049 Studio,簡歷請傳送至 caijun.hcj@alibaba-inc.com

相關文章