Closed16

[キャッチアップ] react-docgen

shingo.sasakishingo.sasaki

本スクラップについて

大好きな Storybook でも使用されている、React コンポーネントのメタデータを収集するツールである react-doc-gen

これの理解を深めれば、Storybook をさらに活用できたり、あるいはツール単体で利用して開発の効率化が出来るのではと思い、適当に深掘って見ることに。

https://github.com/reactjs/react-docgen

shingo.sasakishingo.sasaki

公式ドキュメントをチェックする

最新版である v6 からはドキュメントサイトが新設されている。が、中身はまだスカスカで不十分な感じ。必要そうなところから作成されていると考えれば、作成されてる箇所だけで有益な情報が得られるとも言える。

https://react-docgen.dev/

shingo.sasakishingo.sasaki

公式ドキュメント > Home

https://react-docgen.dev/

  • react-docgen は React コンポーネントからその情報を抽出し、マシンリーダブルな形式で出力するライブラリ
  • Babel を用いてパースされたAST を使用する
  • 出力形式は JSON または JavaScript オブジェクトとなる
  • クラスコンポーネントまたは関数コンポーネントの両方に対応
shingo.sasakishingo.sasaki

公式ドキュメント > Playground

百聞は一見にしかずで、Reactコンポーネントのコードを貼るとそのメタデータを出力してくれるプレイグラウンドが用意されている。

https://react-docgen.dev/playground

input

interface Props {
  /** The name to greet */
  name: string
}

/**
 * Hello world component
 */
const MyComponent = ({name = 'world'}: Props) => {
  return <div />;
}

output

[
  {
    "description": "Hello world component",
    "displayName": "MyComponent",
    "methods": [],
    "props": {
      "name": {
        "required": false,
        "tsType": {
          "name": "string"
        },
        "description": "The name to greet",
        "defaultValue": {
          "value": "'world'",
          "computed": false
        }
      }
    }
  }
]

少なくとも以下がわかる

  • JSDoc によるドキュメンテーション
  • コンポーネント名 (≒変数名)
  • Props 定義
    • JSDoc によるドキュメンテーション
    • 型情報
    • required/optional
    • デフォルト値

JSDoc 解釈したりデフォ値まで見れるのは優秀。

shingo.sasakishingo.sasaki

もうちょっと小難しいコンポーネントで試してみる。
例えば Component Driven User Interface から拝借したこのコンポーネントを入力する。
https://github.com/ComponentDriven/componentdriven.org/blob/master/src/components/Navbar.tsx

output

[
  {
    "description": "",
    "displayName": "NavGroup",
    "methods": [],
    "props": {
      "links": {
        "required": true,
        "tsType": {
          "name": "Array",
          "elements": [
            {
              "name": "signature",
              "type": "object",
              "raw": "{ title: string; href: string }",
              "signature": {
                "properties": [
                  {
                    "key": "title",
                    "value": {
                      "name": "string",
                      "required": true
                    }
                  },
                  {
                    "key": "href",
                    "value": {
                      "name": "string",
                      "required": true
                    }
                  }
                ]
              }
            }
          ],
          "raw": "link[]"
        },
        "description": ""
      }
    }
  },
  {
    "description": "",
    "displayName": "Navbar",
    "methods": [],
    "props": {
      "links": {
        "required": true,
        "tsType": {
          "name": "Array",
          "elements": [
            {
              "name": "signature",
              "type": "object",
              "raw": "{ title: string; href: string }",
              "signature": {
                "properties": [
                  {
                    "key": "title",
                    "value": {
                      "name": "string",
                      "required": true
                    }
                  },
                  {
                    "key": "href",
                    "value": {
                      "name": "string",
                      "required": true
                    }
                  }
                ]
              }
            }
          ],
          "raw": "link[]"
        },
        "description": ""
      },
      "githubLink": {
        "required": true,
        "tsType": {
          "name": "signature",
          "type": "object",
          "raw": "{ namespace: string; repo: string }",
          "signature": {
            "properties": [
              {
                "key": "namespace",
                "value": {
                  "name": "string",
                  "required": true
                }
              },
              {
                "key": "repo",
                "value": {
                  "name": "string",
                  "required": true
                }
              }
            ]
          }
        },
        "description": ""
      }
    }
  }
]

追加で確認できたこと

  • ファイル内に複数コンポーネントがある場合にそれぞれが抽出される
  • 配列型の場合はその中の要素についても取り出せている
  • 型変数への代入などがあっても、最終的な型情報が取り出される
shingo.sasakishingo.sasaki

型の抜き出しについてはどこまで出来るのか気になる。
以下ぐらい対応できれば最低限使えそう。

input

type Props = {
  riteral: 'foo' | 'bar' | 'baz'
  object: {
    foo: string
    bar: number
  }
  array: Array<number | string>
  function: (foo: string) => number
}

export const MyComponent  = (props: Props) => {
  return <div />
}

output

[
  {
    "description": "",
    "displayName": "MyComponent",
    "methods": [],
    "props": {
      "riteral": {
        "required": true,
        "tsType": {
          "name": "union",
          "raw": "'foo' | 'bar' | 'baz'",
          "elements": [
            {
              "name": "literal",
              "value": "'foo'"
            },
            {
              "name": "literal",
              "value": "'bar'"
            },
            {
              "name": "literal",
              "value": "'baz'"
            }
          ]
        },
        "description": ""
      },
      "object": {
        "required": true,
        "tsType": {
          "name": "signature",
          "type": "object",
          "raw": "{\n  foo: string\n  bar: number\n}",
          "signature": {
            "properties": [
              {
                "key": "foo",
                "value": {
                  "name": "string",
                  "required": true
                }
              },
              {
                "key": "bar",
                "value": {
                  "name": "number",
                  "required": true
                }
              }
            ]
          }
        },
        "description": ""
      },
      "array": {
        "required": true,
        "tsType": {
          "name": "Array",
          "elements": [
            {
              "name": "union",
              "raw": "number | string",
              "elements": [
                {
                  "name": "number"
                },
                {
                  "name": "string"
                }
              ]
            }
          ],
          "raw": "Array<number | string>"
        },
        "description": ""
      },
      "function": {
        "required": true,
        "tsType": {
          "name": "signature",
          "type": "function",
          "raw": "(foo: string) => number",
          "signature": {
            "arguments": [
              {
                "type": {
                  "name": "string"
                },
                "name": "foo"
              }
            ],
            "return": {
              "name": "number"
            }
          }
        },
        "description": ""
      }
    }
  }
]

全然大丈夫そう。情報の欠落もここまでは無い。

shingo.sasakishingo.sasaki

公式ドキュメント > About react-docgen

https://react-docgen.dev/about

  • オリジナルの開発は 2015 年の Facebook によるもの
  • 現在は別の人に引き継がれて開発が継続され、ライセンスも MIT に変わった
  • Babel はじめ、様々な OSS が使用されている

まぁ現在でも Babel ベースなんだな。これを Rust 製のなんらかのパーサーに差し替わったらさらに高速化するとかもあるのかね。

shingo.sasakishingo.sasaki

CLI を使ってみる

react-docgen は CLI とライブラリ両方あるので、まず手元のプロジェクトで気軽に試せるように CLI から使ってみる。

https://react-docgen.dev/docs/getting-started/cli

@react-docgen/cli

ヘルプ確認

$ yarn react-docgen --help
yarn run v1.22.19
Usage: react-docgen-parse [options] <globs...>

Extract meta information from React components.
Either specify a paths to files or a glob pattern that matches multiple files.

Arguments:
  globs                   Can be globs or paths to files

Options:
  -o, --out <file>        Store extracted information in the specified file instead of printing to stdout. If the file exists it will be overwritten.
  -i, --ignore <glob>     Comma separated list of glob patterns which will ignore the paths that match. Can also be used multiple times. (default: ["**/node_modules/**","**/__tests__/**","**/__mocks__/**"])
  --no-default-ignores    Do not ignore the node_modules, __tests__, and __mocks__ directories.
  --pretty                Print the output JSON pretty (default: false)
  --failOnWarning         Fail with exit code 2 on react-docgen component warnings. This includes "no component found" and "multiple components found" warnings. (default: false)
  --resolver <resolvers>  Built-in resolver config (find-all-components, find-all-exported-components, find-all-annotated-components, find-exported-component), package name or path to a module that exports a resolver. Can also be used multiple times.
                          When used, no default handlers will be added. (default: ["find-exported-component"])
  --importer <importer>   Built-in importer name (fsImport, ignoreImporter), package name or path to a module that exports an importer. (default: "fsImporter")
  --handler <handlers>    Comma separated list of handlers to use. Can also be used multiple times. When used, no default handlers will be added. (default:
                          ["childContextTypeHandler","codeTypeHandler","componentDocblockHandler","componentMethodsHandler","componentMethodsJsDocHandler","contextTypeHandler","defaultPropsHandler","displayNameHandler","propDocblockHandler","propTypeCompositionHandler","propTypeHandler"])
  -h, --help              display help for command

必須は glob だけっぽいので、とりあえず手元のプロジェクトの全コンポーネントに実行してみる。

$ yarn react-docgen 'client/**/components/**/!(*.stories|*.test).tsx' --pretty -o docgen.json

巨大なJSONファイルが生成された。ファイル名をキーとして、そのファイルから返されるコンポーネントの定義が含まれてる。以外とすんなり動きはした。

shingo.sasakishingo.sasaki

気になったこと1

以下みたいなコンポーネントだと情報が取得できない。

type Props = {
  type: 'bar' | 'baz'
}

export const SampleComponent: FC<Props> = ({ type }) => {
  const components = {
    bar: <div />,
    baz: <span />,
  }
  return components[type]
}

ちゃんと分岐書くと大丈夫になる。

type Props = {
  type: 'bar' | 'baz'
}

export const SampleComponent: FC<Props> = ({ type }) => {
  if (type === 'bar') {
    return <div />
  } else {
    return <span />
  }
}

どっちも型上はちゃんと React コンポーネントとして成立してるんだけどな。

いずれにしても、そういう場合は以下のような警告を出してくれるので、完全に見落とすというのは無さそう。

▶ WARNING: No suitable component definition found. 👀
  in client/src/components/hogehoge.tsx
shingo.sasakishingo.sasaki

気になったこと2

1ファイルが複数コンポーネント返すと警告になる。

export const SampleA = () => <div />

export const SampleB = () => <div />

Playground だと大丈夫だったんだけどな。原則1ファイル1コンポーネントしか export しなければ問題ないだろうけど。

▶ WARNING: Multiple exported component definitions found. 👀
shingo.sasakishingo.sasaki

気になったこと3

以下のように同ファイル内にある型変数を参照している場合

type User = {
  id: string
  name: string
}

type Props = {
  users: User[]
}

export const UserList: FC<Props> = ({ users }) => (
  <li>
    {users.map((user) => (
      <span key={user.id}>{user.name}</span>
    ))}
  </li>
)

Props は適切に展開される

 {
      "description": "",
      "methods": [],
      "displayName": "UserList",
      "props": {
        "users": {
          "required": true,
          "tsType": {
            "name": "Array",
            "elements": [
              {
                "name": "signature",
                "type": "object",
                "raw": "{\n  id: string\n  name: string\n}",
                "signature": {
                  "properties": [
                    {
                      "key": "id",
                      "value": {
                        "name": "string",
                        "required": true
                      }
                    },
                    {
                      "key": "name",
                      "value": {
                        "name": "string",
                        "required": true
                      }
                    }
                  ]
                }
              }
            ],
            "raw": "User[]"
          },
          "description": ""
        }
      }
    }

Lookup型とか使いだしちゃうと

type User = {
  id: string
  name: string
}

type Props = {
  userIds: Array<User['id']>
}

export const UserList: FC<Props> = ({ userIds }) => (
  <li>
    {userIds.map((userId, index) => (
      <ul>{userId}</ul>
    ))}
  </li>
)

展開されない。

    {
      "description": "",
      "methods": [],
      "displayName": "UserList",
      "props": {
        "userIds": {
          "required": true,
          "tsType": {
            "name": "Array",
            "elements": [
              {
                "name": "string",
                "raw": "User['id']"
              }
            ],
            "raw": "Array<User['id']>"
          },
          "description": ""
        }
      }
    }

Lookup 型が悪いのか、他にも限界があるのかはわからない。

shingo.sasakishingo.sasaki

CLI の用途例

とりあえず1ファイル1コンポーネントを export しているという大前提であれば、コンポーネントの一覧はこれだけで取得できそう。

cat docgen.json | jq 'keys'

総数だけならこう

$ cat docgen.json | jq 'keys | length'
shingo.sasakishingo.sasaki

あー、ここまで書いて、Storybook の対応が必要なコンポーネントを洗い出す用途で使えるかもしれんなぁと思った。

shingo.sasakishingo.sasaki

例えばこんなスクリプトを書いて

const { readFile } = require('fs/promises')

;(async () => {
  const docgen = JSON.parse(await readFile('./docgen.json', 'utf-8'))
  const fileNames = Object.keys(docgen)

  fileNames.forEach((fileName) => {
    const doc = docgen[fileName][0]
    const displayName = doc.displayName
    const propsCount = doc.props ? Object.keys(doc.props).length : 0
    console.log(`${propsCount} ${fileName}`)
  })
})()

実行すると、コンポーネントごとの Props 数を一覧できる。 Props が多いコンポーネントのほうが描画パターンが多いから優先的にやるべきとかの判断ができるやも?

このスクラップは2023/12/04にクローズされました