Closed13

StorybookのコンフィグをTypeScriptで書きたいので調べた

ピン留めされたアイテム
kazuma1989kazuma1989

これらの罠は、Storybook が、ts-node がいれば ts-node/register を require して main.ts のトランスパイルに使う暗黙的な挙動が原因。

ただ、わかってしまえば、ts-node の設定方法 を使えばいいだけなので解決できる。

tsconfig.json
{
  "compilerOptions": {
    // こちらはフロントアプリの TS コードをトランスパイルするのに最適な設定にしておけばいい。
  },

  // Storybook が main.ts を読み込むときに使う設定。
  // ts-node がプロジェクトの依存にあれば ts-node/register が自動で使われるので、結果的にこの設定が有効になる。
  // cf. https://github.com/TypeStrong/ts-node/tree/v10.4.0#via-tsconfigjson-recommended
  // プロダクションコードと同じ基準にする必要はないし、とくに `"module": "CommonJS"` がネックになるので、別で設定できるのが嬉しい。
  "ts-node": {
    "transpileOnly": true,
    "compilerOptions": {
      "module": "CommonJS"
    }
  }
}
kazuma1989kazuma1989

また、main.ts をトランスパイルするときのオプションは、プロジェクトルートのものが使われてしまう。

./
├── .storybook/
│   ├── main.js
│   └── tsconfig.json  <- こちらではなく 🙅‍♂️
├── package.json
└── tsconfig.json  <- こちらがどうしても有効になる 😞

tsconfig.jsoninclude オプションを適切に設定したつもりでもうまくいかない。

kazuma1989kazuma1989

ここからは、ソースコードを辿って前述の結論に辿り着く道筋のメモ。結論だけで十分な場合は読まなくていいです。

詳しく知りたい人、ts-node 以外でトランスパイルしたい人は読む。

kazuma1989kazuma1989

Storybook for React、つまり @storybook/react を使っている前提。

Storybook を起動したりビルドしたりするコマンド (start-storybook, build-storybook) はこの @storybook/react が持っているので、そこから調べる。

ソースコードは github.dev で追ってください。

app/react/package.json
  "name": "@storybook/react",
...
  "bin": {
    "build-storybook": "./bin/build.js",
    "start-storybook": "./bin/index.js",
    "storybook-server": "./bin/index.js"
  },
app/react/bin/index.js
#!/usr/bin/env node

require('../dist/cjs/server');

dist はバージョン管理外だが、src 配下の同名の TS ファイルが相当するだろう。

app/react/src/server/index.ts
import { buildDev } from '@storybook/core/server';
import options from './options';

buildDev(options);

これが start-storybook コマンドで実行されるコードのようだ。

main.ts をトランスパイルする機構が options にある可能性もあるが、そちらを辿る(省略)と React 向けプリセットをいろいろ詰め込んでいるだけなので、buildDevmain.ts を読み込んでいるようだ。

@storybook/core/serverbuildDev を探せばよいとわかった。

kazuma1989kazuma1989

@storybook/corebuildDev を探す。github.dev ならプロジェクト内検索もできてしまうけど、一応パッケージを順に追います。

lib/core/package.json
  "name": "@storybook/core",
lib/core/src/server.ts
export * from '@storybook/core-server';

@storybook/core-server をバイパスしているだけだった。

lib/core-server/package.json
  "name": "@storybook/core-server",
...
  "main": "dist/cjs/index.js",
  "module": "dist/esm/index.js",
lib/core-server/src/index.ts
import {
  getPreviewHeadTemplate,
  getManagerHeadTemplate,
  getManagerMainTemplate,
  getPreviewBodyTemplate,
  getPreviewMainTemplate,
} from '@storybook/core-common';

export {
  getPreviewHeadTemplate,
  getManagerHeadTemplate,
  getManagerMainTemplate,
  getPreviewBodyTemplate,
  getPreviewMainTemplate,
};

export * from './build-static';
export * from './build-dev';

名前的に ./build-dev があやしい。

lib/core-server/src/build-dev.ts
export async function buildDev(loadOptions: LoadOptions) {

見つけた。

kazuma1989kazuma1989

中身の処理を見ると、configDir の設定を受け取っている buildDevStandalone があやしい。

lib/core-server/src/build-dev.ts
export async function buildDev(loadOptions: LoadOptions) {
  const cliOptions = await getDevCli(loadOptions.packageJson);

  try {
    await buildDevStandalone({
      ...cliOptions,
      ...loadOptions,
      configDir: loadOptions.configDir || cliOptions.configDir || './.storybook',
      configType: 'DEVELOPMENT',
      ignorePreview: !!cliOptions.previewUrl && !cliOptions.forceBuildPreview,
      docsMode: !!cliOptions.docs,
      cache,
    });

buildDevStandalone は同ファイルに存在している。

lib/core-server/src/build-dev.ts
export async function buildDevStandalone(options: CLIOptions & LoadOptions & BuilderOptions) {

この関数内には処理がいくつかあるが、options.configDir を受け取っている次の2関数があやしい。

lib/core-server/src/build-dev.ts
  const previewBuilder = await getPreviewBuilder(options.configDir);
  const managerBuilder = await getManagerBuilder(options.configDir);

インポート元は次のファイル。

lib/core-server/src/build-dev.ts
import { getPreviewBuilder } from './utils/get-preview-builder';
import { getManagerBuilder } from './utils/get-manager-builder';
kazuma1989kazuma1989

getPreviewBuildergetManagerBuilder のどちらを見ても、冒頭に main.ts を読んでいるであろう同じ処理がある。

lib/core-server/src/utils/get-preview-builder.ts
export async function getPreviewBuilder(configDir: Options['configDir']) {
  const main = path.resolve(configDir, 'main');
  const mainFile = getInterpretedFile(main);
  const { core } = mainFile ? serverRequire(mainFile) : { core: null };
lib/core-server/src/utils/get-manager-builder.ts
export async function getManagerBuilder(configDir: Options['configDir']) {
  const main = path.resolve(configDir, 'main');
  const mainFile = getInterpretedFile(main);
  const { core } = mainFile ? serverRequire(mainFile) : { core: null };

getInterpretedFile を追う過程は省略するが、この関数は main.jsmain.ts など、拡張子候補のうち存在する main.* を見つける処理をしている。

見つかったものを serverRequire で読み込んでいる。serverRequire の戻り値でコンフィグオブジェクトの core プロパティを読めているので。

lib/core-server/src/utils/get-preview-builder.ts
import { getInterpretedFile, serverRequire, Options } from '@storybook/core-common';

@storybook/core-common パッケージを見ればいいらしい。

kazuma1989kazuma1989

@storybook/core-common パッケージ。

lib/core-common/package.json
  "name": "@storybook/core-common",
...
  "main": "dist/cjs/index.js",
  "module": "dist/esm/index.js",

やはり dist は存在しないので、同名の TS コードを追いかける。

lib/core-common/src/index.ts
export * from './presets';

export * from './utils/babel';
export * from './utils/check-webpack-version';
export * from './utils/check-addon-order';
export * from './utils/envs';
export * from './utils/es6Transpiler';
export * from './utils/handlebars';
export * from './utils/interpret-files';
export * from './utils/interpret-require';
export * from './utils/load-custom-babel-config';
export * from './utils/load-custom-presets';
export * from './utils/load-custom-webpack-config';
export * from './utils/load-manager-or-addons-file';
export * from './utils/load-preview-or-config-file';
export * from './utils/log-config';
export * from './utils/merge-webpack-config';
export * from './utils/paths';
export * from './utils/progress-reporting';
export * from './utils/resolve-path-in-sb-cache';
export * from './utils/cache';
export * from './utils/template';
export * from './utils/interpolate';
export * from './utils/validate-configuration-files';
export * from './utils/to-require-context';
export * from './utils/normalize-stories';
export * from './utils/to-importFn';
export * from './utils/readTemplate';
export * from './utils/findDistEsm';

export * from './types';

ここで迷子になったので、プロジェクト内検索を頼った。結局、次のファイルに serverRequire が見つかった。

lib/core-common/src/utils/interpret-require.ts
export function serverRequire(filePath: string | string[]) {
  const candidatePath = serverResolve(filePath);

  if (!candidatePath) {
    return null;
  }

  const candidateExt = path.extname(candidatePath);
  const moduleDescriptor = interpret.extensions[candidateExt];

  // The "moduleDescriptor" either "undefined" or "null". The warning isn't needed in these cases.
  if (moduleDescriptor && registerCompiler(moduleDescriptor) === 0) {
    logger.warn(`=> File ${candidatePath} is detected`);
    logger.warn(`   but impossible to import loader for ${candidateExt}`);

    return null;
  }

  return interopRequireDefault(candidatePath);
}

前半は candidatePath、つまり候補となる main.* を再度?探す処理のようだ。だから main.ts をトランスパイルしつつ読み込むメインの処理は interopRequireDefault(candidatePath) のはず。

当該処理は同ファイルにあった。

lib/core-common/src/utils/interpret-require.ts
function interopRequireDefault(filePath: string) {
  // eslint-disable-next-line import/no-dynamic-require,global-require
  const result = require(filePath);

  const isES6DefaultExported =
    typeof result === 'object' && result !== null && typeof result.default !== 'undefined';

  return isES6DefaultExported ? result.default : result;
}

肝心の読み込みはただ const result = require(filePath) しているだけであった。

kazuma1989kazuma1989

素の Node.js であれば、TS ファイルを require しただけだと当然パースエラーになる。require にフックして on-the-fly のトランスパイルを仕込む必要がある。

その仕込みは、今回は registerCompiler(moduleDescriptor) が該当する。

lib/core-common/src/utils/interpret-require.ts
export function serverRequire(filePath: string | string[]) {
  // ...
  const moduleDescriptor = interpret.extensions[candidateExt];

  // The "moduleDescriptor" either "undefined" or "null". The warning isn't needed in these cases.
  if (moduleDescriptor && registerCompiler(moduleDescriptor) === 0) {

registerCompiler 内では、キャッシュ処理や配列の繰り返し処理などがあるが、本質的には require(moduleDescriptor) をしているだけ。つまり moduleDescriptor として指定されたパッケージが、require フックを実行している。

const moduleDescriptor = interpret.extensions[candidateExt] であるが、interpret のインポート元は interpret パッケージ。

この interpret パッケージは、require にフックすべき設定を拡張子から引く辞書を提供している。たとえば .ts に対しては次のとおり。

  '.ts': [
    'ts-node/register',
    'typescript-node/register',
    'typescript-register',
    'typescript-require',
    'sucrase/register/ts',
    {
      module: '@babel/register',
      register: function(hook) {
        hook({
          extensions: '.ts',
          rootMode: 'upward-optional',
          ignore: [ignoreNonBabelAndNodeModules],
        });
      },
    },
  ],

つまり、main.ts が見つかったら require('ts-node/register') が(ts-node が依存にいれば)実行されるとわかった。

これで 冒頭の結論 に至った。

kazuma1989kazuma1989

これで、main.ts が「なんとなく」使えてしまうが、思いどおりいかないこともある理由がわかった。

ts-node が不在のプロジェクトでは、おそらくほぼ依存が避けられなさそうな @babel/register にぶつかって、その設定 (.babelrc) が見つからないためうまく動かないのだろう。

プロジェクトルートの tsconfig.json しか使われないのは、オプションなしで ts-node を呼び出しているからそうなっているだけ。プロジェクトルートの tsconfig.json に ts-node 専用のオプションを書くか、環境変数で設定する(たとえば TS_NODE_TRANSPILE_ONLY)必要がある。

kazuma1989kazuma1989

感想。

「ts-node がいれば使う」というのは余計なお世話な気がする。そういうことをするなら、明示的にセットアップしないと TS コンフィグは使えないようにしてほしい。そうでなくても、ts-node を Storybook の依存にいれておいてもらうほうがまだマシな気がした。

個人的には esbuild-registertsm を使わせてほしい。

おしまい。

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