デザインシステムにおけるバレルファイルの自動生成

ogp

前置き

バレルファイル自体の賛否については今回は触れないこととします。

はじめに

こんにちは。enechainで働いている takurinton です。

enechainのデザインシステムではコンポーネントをindex.tsという名前のバレルファイルに集約し、それをエントリポイントとしてビルドして公開しています。つまり、新しくコンポーネントを追加しても、そのコンポーネントを公開するためにはindex.tsにexportを追加する必要があります。

// index.tsの中身
import Button from './Button';
import Input from './Input';

export { Button };
export { Input };

しかし、この運用では新しくコンポーネントを追加した際にそのコンポーネントをindex.tsに追加することを忘れたままリリースしてしまい、再度新しいバージョンを作ってリリースすることがありました。

「SelectOptionInterfaceのexport漏れてるかも」というSlackのメッセージ

このような漏れを防ぐために、バレルファイルを自動生成するツールを作成しました。今回はそのツールについて紹介します。

解決する方法についての議論

結論として最終的にはバレルファイルを自動生成することになったのですが、その手前にどのように公開漏れを防ぐかについての議論をしました。

出た案としては以下がありました。

  1. exportしたいコンポーネントに特定のjsdocを書いて、それがindex.tsに存在していなかったらエラーにする
  2. default exportしてるところがindex.tsで参照されていなかったらエラーにする
  3. バレルファイルを自動生成する

1の案はすべてのexportしたいコンポーネントにjsdocを書くという方法です。
2の案はTypeScript Compiler API等を使い、index.tsから参照されているすべてのファイルを探索して、参照されていないものを弾くというものです。
3の案は、TypeScript Compiler API等を使い、すべてのファイルからindex.tsを生成するというものです。

それぞれのメリットとデメリットは以下のようになりました。

方法 メリット デメリット
1 exportしたいものが明示できる 全てのコンポーネントや定数、関数に記述する必要があり、これはこれで漏れが発生する
2 単に参照されていないものを弾くのでわかりやすい デザインシステム内でのみ使ってるものも弾かれてしまう可能性がある
3 自動生成なので、漏れをそもそも考えなくて良い exportしたくないものも拾ってしまう

1の方法では結局漏れが発生するため、2 or 3が良いということになりました。
その中で、3の方法はそもそも漏れ等を考える必要がないこと、社内用ライブラリであるため余分にexportしている分には許容できることから、最終的に3の方法を採用することになりました。

ツールの概要

作成したツールは以下の挙動をします。

  • exportされているすべての関数、定数、コンポーネントを発見し、ファイルパスとexport名を取得する
  • それを元にindex.tsを生成する
  • しかし、jsdocに @ignoreExport とついてるものは除外する

ツールの作成

作成したツールの処理は以下のようになっています。

  • 全てのファイルを取得
  • exportDeclarationからNodeとファイルパスを取得
    • ただし、jsdocで @ignoreExport がついているものは除外
  • exportされてるものを分類
    • named export
    • default export
  • ファイルパスとNodeからindex.tsを生成
  • prettierを使って生成結果を整形

基本的にはコンポーネントとそのコンポーネントのpropsの型を取得してexportすることを想定していますが、一部定数や関数もexportされていることがあるため、それらも取得する必要があります。
これらの処理を実現するために、TypeScript Compiler APIを使っています。

実際の処理

処理について部分的に紹介します。
コアの機能はvisitという関数に集約されており、そこでNodeを探索しています。
取得したNodeはexportInfosというarrayに追加され、それを元にindex.tsを生成します。

visit関数では以下の処理を行なっています。

  1. export declarationを見つけたら、その親のNodeを取得してarrayに追加
  2. React.memoで囲われている場合は、その親のNodeを取得してarrayに追加
  3. その他の定数や関数、型定義などを取得してarrayに追加

1つずつ見ていきます。

1. export declarationを見つけたら、その親のNodeを取得してarrayに追加

まず、export declarationを見つけたら、その親のNodeを取得してarrayに追加します。
この際、named exportの場合は、その中のすべてのNodeを取得します。

if (ts.isExportDeclaration(node)) {
  if (node.exportClause != null && ts.isNamedExports(node.exportClause)) {
    for (const element of node.exportClause.elements) {
      const symbol = checker.getSymbolAtLocation(element.name)
      if (symbol != null) {
        if (exploredNodeIdentifiers.has(symbol.escapedName.toString())) {
          return
        }
        exploredNodeIdentifiers.add(symbol.escapedName.toString())
        exportInfos.push(getExportInfo(symbol, sourceFile, node))
      }
    }
  }
}

2. React.memoで囲われている場合は、その親のNodeを取得してarrayに追加

次に、React.memoで囲われている場合は、その中の関数を取得します。

// この場合、ExportDeclarationで取得されるのはReact.memoの要素だが、ButtonのNodeを取得したい
export default React.memo(Button)

また、Reactではコンポーネントにジェネリクスを渡してる場合、それをReact.memoでラップすると型情報が失われるという挙動があります。
参考: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/37087

その場合 as 型アサーションをしているのですが、その場合は何も考えずに取得するとAsExpressionとして取得されてしまうため、それを取り除いています。

// この場合、ExportDeclarationで取得されるのはReact.memoの要素だが、ButtonのNodeを取得したい
export default React.memo(Table) as typeof Table

React.memoで囲われている場合は、上記の前提を踏まえて、以下のようにその中の関数を取得します。

// React.memo で囲われている場合は、その中の関数を取得
if (ts.isExportAssignment(node)) {
  let expression = node.expression

  if (ts.isAsExpression(expression)) {
    expression = expression.expression // `as` 型アサーションを取り除く
  }

  if (ts.isCallExpression(expression)) {
    const symbol = checker.getSymbolAtLocation(expression.arguments[0])
    if (symbol != null) {
      if (exploredNodeIdentifiers.has(symbol.escapedName.toString())) {
        return
      }
      exploredNodeIdentifiers.add(symbol.escapedName.toString())
      exportInfos.push(getExportInfo(symbol, sourceFile, node))
    }
  }

  // default export で関数が直接 export されている場合
  const symbol = checker.getSymbolAtLocation(node.expression)
  if (symbol != null) {
    if (exploredNodeIdentifiers.has(symbol.escapedName.toString())) {
      return
    }
    exploredNodeIdentifiers.add(symbol.escapedName.toString())
    exportInfos.push(getExportInfo(symbol, sourceFile, node))
  }
}

3. その他の定数や関数、型定義などを取得してarrayに追加

最後に、コンポーネント以外の部分を取得します。
ここで取得したい内容は以下の通りです。

  • 型定義
  • 関数
  • 定数

それらを取得して、arrayに追加します。

if (
  ts.isFunctionDeclaration(node) ||
  ts.isClassDeclaration(node) ||
  ts.isInterfaceDeclaration(node) ||
  ts.isTypeAliasDeclaration(node) ||
  ts.isVariableStatement(node) ||
  ts.isEnumDeclaration(node)
) {
  if (
    node.modifiers?.some(
      (mod) => mod.kind === ts.SyntaxKind.ExportKeyword,
    ) ??
    false
  ) {
    if (ts.isVariableStatement(node)) {
      for (const declaration of node.declarationList.declarations) {
        const symbol = checker.getSymbolAtLocation(
          declaration.name ?? declaration,
        )
        if (symbol != null) {
          if (exploredNodeIdentifiers.has(symbol.escapedName.toString())) {
            return
          }
          exploredNodeIdentifiers.add(symbol.escapedName.toString())
          exportInfos.push(getExportInfo(symbol, sourceFile, node))
        }
      }
    } else {
      const symbol = checker.getSymbolAtLocation(node.name ?? node)
      if (symbol != null) {
        if (exploredNodeIdentifiers.has(symbol.escapedName.toString())) {
          return
        }
        exploredNodeIdentifiers.add(symbol.escapedName.toString())
        exportInfos.push(getExportInfo(symbol, sourceFile, node))
      }
    }
  }
}

上記の処理を備えた関数にsourceFileを渡すことで、sourceFile内のすべてのNodeを探索し、exportされているものを取得できます。
この結果を整形し、index.tsを生成することで、バレルファイルの生成ができます。

生成漏れを防ぐ

自動生成をするとexport漏れを防ぐことはできます。しかし、その代わり生成漏れが発生する可能性があります。
そのため、生成漏れを防ぐためにGitHub Actionsで生成結果と差分を確認することで生成漏れを防ぎました。

name: Check Generate Root File

on:
  pull_request:
    branches:
      - 'main'
    paths:
      - '.github/workflows/check-generate-root-file.yml'
      - 'packages/components/src/components/**'
  workflow_dispatch:

jobs:
    check-generate-root-file:
      # 省略...

      # root file を生成する
      - name: Generate root file
        run: pnpm run generate-root-file

      # 自動生成した結果が正しいかチェックする
      - name: Compare index.ts with last commit
        run: |
          git diff --exit-code HEAD ./packages/components/src/components/index.ts || (
            echo "root fileに差分があります。pnpm run generate-root-fileを実行して生成してください。"
            exit 1
          )

まとめ

今回の方法を使用して、バレルファイルを自動生成することでexport漏れを防ぐことができました。
これに限らず、ライブラリ開発者がよく遭遇する課題に対してさまざまな方法を模索して組織にとっての最適解を見つけることが重要だと当たり前のことを再認識しました。

enechainでは、一緒に働く仲間を募集しています。詳しくは以下のリンクからご確認ください。