StorybookのコンフィグをTypeScriptで書きたいので調べた
これらの罠は、Storybook が、ts-node がいれば ts-node/register
を require して main.ts
のトランスパイルに使う暗黙的な挙動が原因。
ただ、わかってしまえば、ts-node の設定方法 を使えばいいだけなので解決できる。
{
"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"
}
}
}
Storybook のコンフィグは .storybook/main.js
が基本。でも実は main.ts
でもOK。
また、main.ts
をトランスパイルするときのオプションは、プロジェクトルートのものが使われてしまう。
./
├── .storybook/
│ ├── main.js
│ └── tsconfig.json <- こちらではなく 🙅♂️
├── package.json
└── tsconfig.json <- こちらがどうしても有効になる 😞
tsconfig.json
の include
オプションを適切に設定したつもりでもうまくいかない。
ここからは、ソースコードを辿って前述の結論に辿り着く道筋のメモ。結論だけで十分な場合は読まなくていいです。
詳しく知りたい人、ts-node 以外でトランスパイルしたい人は読む。
Storybook for React、つまり @storybook/react
を使っている前提。
Storybook を起動したりビルドしたりするコマンド (start-storybook
, build-storybook
) はこの @storybook/react
が持っているので、そこから調べる。
ソースコードは github.dev で追ってください。
"name": "@storybook/react",
...
"bin": {
"build-storybook": "./bin/build.js",
"start-storybook": "./bin/index.js",
"storybook-server": "./bin/index.js"
},
#!/usr/bin/env node
require('../dist/cjs/server');
dist
はバージョン管理外だが、src
配下の同名の TS ファイルが相当するだろう。
import { buildDev } from '@storybook/core/server';
import options from './options';
buildDev(options);
これが start-storybook
コマンドで実行されるコードのようだ。
main.ts
をトランスパイルする機構が options
にある可能性もあるが、そちらを辿る(省略)と React 向けプリセットをいろいろ詰め込んでいるだけなので、buildDev
が main.ts
を読み込んでいるようだ。
@storybook/core/server
の buildDev
を探せばよいとわかった。
@storybook/core
の buildDev
を探す。github.dev ならプロジェクト内検索もできてしまうけど、一応パッケージを順に追います。
"name": "@storybook/core",
export * from '@storybook/core-server';
@storybook/core-server
をバイパスしているだけだった。
"name": "@storybook/core-server",
...
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
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
があやしい。
export async function buildDev(loadOptions: LoadOptions) {
見つけた。
中身の処理を見ると、configDir
の設定を受け取っている buildDevStandalone
があやしい。
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
は同ファイルに存在している。
export async function buildDevStandalone(options: CLIOptions & LoadOptions & BuilderOptions) {
この関数内には処理がいくつかあるが、options.configDir
を受け取っている次の2関数があやしい。
const previewBuilder = await getPreviewBuilder(options.configDir);
const managerBuilder = await getManagerBuilder(options.configDir);
インポート元は次のファイル。
import { getPreviewBuilder } from './utils/get-preview-builder';
import { getManagerBuilder } from './utils/get-manager-builder';
getPreviewBuilder
と getManagerBuilder
のどちらを見ても、冒頭に main.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 };
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.js
や main.ts
など、拡張子候補のうち存在する main.*
を見つける処理をしている。
見つかったものを serverRequire
で読み込んでいる。serverRequire
の戻り値でコンフィグオブジェクトの core
プロパティを読めているので。
import { getInterpretedFile, serverRequire, Options } from '@storybook/core-common';
@storybook/core-common
パッケージを見ればいいらしい。
@storybook/core-common
パッケージ。
"name": "@storybook/core-common",
...
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
やはり dist
は存在しないので、同名の 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
が見つかった。
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)
のはず。
当該処理は同ファイルにあった。
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)
しているだけであった。
素の Node.js であれば、TS ファイルを require しただけだと当然パースエラーになる。require にフックして on-the-fly のトランスパイルを仕込む必要がある。
その仕込みは、今回は registerCompiler(moduleDescriptor)
が該当する。
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 が依存にいれば)実行されるとわかった。
これで 冒頭の結論 に至った。
これで、main.ts
が「なんとなく」使えてしまうが、思いどおりいかないこともある理由がわかった。
ts-node が不在のプロジェクトでは、おそらくほぼ依存が避けられなさそうな @babel/register
にぶつかって、その設定 (.babelrc
) が見つからないためうまく動かないのだろう。
プロジェクトルートの tsconfig.json
しか使われないのは、オプションなしで ts-node を呼び出しているからそうなっているだけ。プロジェクトルートの tsconfig.json
に ts-node 専用のオプションを書くか、環境変数で設定する(たとえば TS_NODE_TRANSPILE_ONLY
)必要がある。
感想。
「ts-node がいれば使う」というのは余計なお世話な気がする。そういうことをするなら、明示的にセットアップしないと TS コンフィグは使えないようにしてほしい。そうでなくても、ts-node を Storybook の依存にいれておいてもらうほうがまだマシな気がした。
個人的には esbuild-register や tsm を使わせてほしい。
おしまい。