Viteでstorybookみたいにコンポーネントを一覧できるUIBookを作る
はじめに
最近自分用にmu-uiというUIライブラリを公開しました
そこでコンポーネントをstorybookのように簡単に試せるものが欲しかったこと、一方storybookは難点も多かったのでViteでもっと雑にできないかなと思って作ったものです。
ちなみにできたものは以下です。(以下、UIBookと呼ぶことにします)
(vitebookというツールも存在するようですが、私の環境ではうまく動かなかったので諦めました)
アーキテクチャ
すごく雑にいうと、dynamic importで.story.tsxファイルを集めてくる→公開されている関数をstoryとみなしてタイトルと共に埋め込むみたいなことをやっています。
さらに(あとで説明するように)storyのコード表示を行うために、.story.tsxをパースして該当の関数宣言はソースコードと共にプラグインからglobal変数経由でVite側に渡しています。
作り方
glob importを使ってstoryファイルを読み込む
Viteにはglob importなる機能があり、これによってファイルを動的に読み込めます。
このglob importは内部的にはただのdynamic importです。また、Viteのdynamic importはrollupの制約がそれなりにあるため使える記法などが割と限られているので注意が必要です。
import.meta.glob("../src/*.story.tsx");
と書くと Record<string, () => JSX.Element>
が手に入るのでそれをそのまま表示に利用するだけでできます。
Vite pluginでHMRを効かせる
上記の方法でimportは可能ですが、これだと ./uibook/
でしかuibookのHMRが有効になりません。コンポーネントファイルやstoryファイルでもHMRしてほしいので、ファイルのupdateを監視してdevServerを再起動します。
幸いにもこのようなVite pluginは簡単に書けます。
const uiBook = (): Plugin => {
return {
name: "uibook",
handleHotUpdate: (ctx) => {
ctx.server.ws.send({
type: "full-reload",
});
return [];
},
};
};
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react({
jsxImportSource: "@emotion/react",
}),
uiBook(),
],
});
handleHotUpdateで通知を受け取って、serverにfull-reloadを伝えます。もうちょっと賢く(不要なファイル更新を無視)したければctx.fileを見てもいいですがまあこれでもいいでしょう。
あとはViteで読み込んだstoryコンポーネントを埋め込む画面などを作ってやればdevServerで爆速のstorybookみたいなものになります。
コードの埋め込み
ついでに、それぞれのstoryに対応するソースコードの表示もやってみます。
これにはソースコードのファイルの中身(のコンパイル前のもの)が文字列として必要なので、Viteからどうこうするのは無理です。そのためViteプラグイン側でファイルを全て取り出してパースし、ソースの該当部分をconfigの変数として渡してViteに埋め込んでもらいます。
.tsxファイルをパースするのにはeslintの@typescript-eslint/parserを使いました。(eslintなら基本的にどの環境でも入れる上、パーサー自体はちゃんと書かれているので品質にも安心感もあります。parserは普通に使いやすく、さらにASTにソースファイルの位置情報がちゃんと入っているので該当箇所をソースから切り抜くのが簡単にできました)
パース後、 export const Story = () => {...}
みたいな宣言をASTから探し出し、ソースの位置情報(range)を使ってソースコードの文字列から該当箇所を切り出して関数名と共にdefineでエクスポートします。
const uiBook = (): Plugin => {
return {
config: () => {
const modules = {};
fs.readdirSync(srcDir).forEach((filePath) => {
if (filePath.endsWith(".story.tsx")) {
const name = filePath.split("/").pop().replace(".story.tsx", "");
const file = fs.readFileSync(path.join(srcDir, filePath)).toString();
const parseResult = parse(file, {
ecmaFeatures: {
jsx: true,
},
sourceType: "module",
range: true,
});
const decls = {};
parseResult.body.forEach((node) => {
if (
node.type === "ExportNamedDeclaration" &&
node.declaration.type === "VariableDeclaration" &&
node.declaration.declarations[0].id.type === "Identifier"
) {
decls[node.declaration.declarations[0].id.name] = file.slice(
...node.range
);
}
});
modules[name] = decls;
}
});
return {
define: {
STORY_CODE: modules,
},
};
},
};
};
コードは割と適当ですがこれでとりあえず動いているので良いことにしました。
Viteのconfig.defineで宣言したものは、devServerではグローバル変数として利用可能で、ビルド時には実行結果が直接埋め込まれます。なのでこの結果をそのまま受け取って、 STORY_CODE[moduleName][storyName]
のようにアクセスすれば良いです。
Syntax Highlight
ついでなのでsyntax highlightも対応しました。tsxがいい感じにハイライトできて軽く、Vite上で利用可能なライブラリが少なくかなり苦労しましたが、最終的にはPrism.jsのラッパーである refractor がいい感じにやってくれたのでそれを使いました。
(Prism.jsはbabel-pluginなどが必要なこともありVite上で使うのは難しそうでした。手でimportを読み込むのも、typescriptのcomponentをなぜかうまく読み込んでくれなかったためうまく行きませんでした)
終わりに
筋力だけの実装という感じなのでスマートさはまるでないです。特に、一方ではdynamic importでインポートしつつ他方ではViteでビルド時にソースコードをパースした結果をグローバル変数として渡すというよくわからないことをしていてよくわからないですね。
あと、uibookはViteでアプリとしてビルドしつつ、mu-ui自体はUIライブラリとして公開するためにlibモードでビルドするconfigを書くなどもいろいろ調べて書きました。基本的には、viteでlibモードのビルドをしつつtscで型定義ファイルをemitしてそれをパッケージに入れて配るなどしています。
それなりにVite関連で知見も得られたので満足しました。storybookみたいなやつ作るのも楽しそうだなと思いました。
Discussion