- ダウンロードボタン一つのためにパッケージ全体を取り込んでいた問題を解決するまで -
1. はじめに
この第3四半期、社内メッセンジャーサービスLoopinの7.2アップデートを担当しました。その中の一つに、「チャットで受け取ったファイルも自分のファイルボックスに残るようにしたい」という要件がありました。
同じ機能はすでに存在していました。ファイルストレージドメインであるvaultのFileDownloadItemコンポーネントは、ファイル名と容量を表示し、ダウンロードボタンを押すとファイルをダウンロードした後、ユーザーのStashフォルダーに自動登録し、すでに受け取ったファイルであればアイコンを変えて表示するところまで行っていました。ソースは150行ほどの小さなコンポーネントです。チャットで同じものを作り直す理由はなさそうだったので、そのまま利用することにしました。
ところがimportを1行追加しただけでpnpm-lock.yamlに828行が追加され、ビルド成果物にはチャットで使っていないPDFビューアーのワーカーファイル(1MB)が入り込んでいました。この記事は、150行のコンポーネント一つを使うためにパッケージ全体を取り込んでいた問題を、必要な部分だけ切り出して取り込むように変更した過程の記録です。
2. 背景
私たちのフロントエンドはpnpm workspaceとTurborepoをベースにしたモノレポで、パッケージは次の階層になっています。
episodes/* 실제 실행 앱 (엔트리)
└─ collages/* 앱 레이아웃과 도메인 조합 계층
└─ dramas/*-view 화면 컴포넌트 (React/MUI)
└─ dramas/*-state → dramas/*-stub
Loopinとvaultは、それぞれloopin-view、vault-viewという別パッケージとして配布されています。チャットでvaultのコンポーネントを使うには、社内レジストリに公開されている@vizendjs/vault-viewを依存関係に追加すればよいのです。最初に私が書いたコードはこれだけでした。
import { FileDownloadItem } from '@vizendjs/vault-view';
3. 原因を探す — バレルが作ったモジュールグラフ
必要なコンポーネント一つだけをimportしたので、残りはツリーシェイキングで除外されると思っていましたが、そうではありませんでした。
何から見ればよいのかわからなかったので、目に見えるものから確認しました。pnpm-lock.yamlのdiffで新たに追加されたパッケージをざっと確認すると、pdfjs-distやreact-syntax-highlighterのようなチャットとは無関係なものがあり、vault-viewのdistにはpdf.worker.min.mjsという1MBのファイルが入っていました。これらのライブラリを誰がimportしているのか逆にたどってみると、原因はバレルファイル(barrel file)でした。
vault-viewの公開エントリから実際の重いライブラリまでは、次のようにつながっていました。
src/index.ts
export * from './components'
└─ components/index.ts
├─ export * from './common'
│ ├─ CodeBlock.tsx → react-syntax-highlighter
│ ├─ AgGridCheck.tsx → ag-grid
│ └─ FileDownloadItem.tsx ← 내가 필요한 건 이것 하나
├─ export * from './stash'
│ └─ mydisk/modal/LightboxPage.tsx → pdfjs-dist, react-zoom-pan-pinch
│ └─ mydisk/MyDiskViewList.tsx → ag-grid-react
├─ export * from './minipix'
├─ export * from './policy'
├─ export * from './mydisk'
└─ export * from './cabinet'
export * from './utils'
├─ date-utils.ts → dayjs
├─ stash-utils.ts → @vizendjs/vault-stub
└─ file-utils.ts → (의존성 없음)
export *はそのモジュール全体を評価対象にするため、コンポーネント一つだけを取り込んでも、このツリー全体がモジュールグラフに追加されます。たとえば、LightboxPage.tsxは次のように始まります。
import { TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch';
import * as pdfjsLib from 'pdfjs-dist';
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker';
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;
最後の行のように、モジュールのトップレベルでグローバル設定を変更するコードがあると、バンドラーはこのモジュールに副作用があると判断し、簡単には削除できません。さらにvault-viewは、重いライブラリをpeerDependenciesに置き、ビルド時にexternalとして処理していました。externalとは、バンドルには含めずimport文だけを残すという意味です。そのためvault-viewのdist自体は小さく見えますが、そのdistを利用するアプリは、残されたimport文を自分のバンドル内で解決しなければなりません。結局、そのコストを負担するのは利用側のLoopinになる構造でした。
4. 解決策1 — 必要なものだけをまとめたサブパスエントリ
既存のエントリはvaultアプリがそのまま使っていたため、手を加えられませんでした。そこで既存のものはそのまま残し、一部だけが必要な利用者向けに2つ目のエントリを別途作る方針で進めました。
// dramas/vault-view/src/common.ts
// @vizendjs/vault-view/common — 채팅용 경량 엔트리.
// components/common/index.ts 배럴(CodeBlock·AgGridCheck 포함)을 거치지 않고
// 필요한 것만 직접 재export하여 pdfjs-dist / react-syntax-highlighter 가
// 이 청크로 유입되지 않게 한다.
export { FileDownloadItem } from './components/common/FileDownloadItem';
export type { FileDownloadItemProps } from './components/common/FileDownloadItem';
// 배럴(~/utils)이 아닌 파일 직접 경로 → dayjs/vault-stub 유입 차단
export { formatFileSize } from './utils/file-utils';
ここで最も重要だったのは、ファイルパスを最後まで直接指定することでした。最初は'./components/common'までしか書かなかったのですが、チャンクサイズはほとんど変わりませんでした。確認してみると、そのバレルがCodeBlockとAgGridCheckを一緒にexportしていたため、1段階経由しただけでも兄弟モジュールがすべて付いてきていました。
FileDownloadItem自体が何を使っているのかも合わせて確認しました。このコンポーネントのimportは4行だけでした。
// dramas/vault-view/src/components/common/FileDownloadItem.tsx
import { useState } from 'react';
import styled from '@emotion/styled';
import { useClipFileDownload, useStash } from '@vizendjs/vault-state';
import { formatFileSize } from '~/utils/file-utils';
重いライブラリは一つもなく、vaultのダウンロード・Stashフックがあるvault-stateだけに依存していました。これなら切り出しても問題ないという確信が得られたので、作業を進めました。
formatFileSizeも同じ理由で'./utils'ではなく'./utils/file-utils'と記述しました。utils/index.tsのバレルはdate-utils(dayjs)とstash-utils(vault-stub)を一緒にexportしていますが、実際に必要なfile-utils.tsはimport文が一つもない純粋関数ファイルでした。バレルを経由すると無関係なdayjsまで付いてきますが、ファイルを直接指定すれば何も付いてきません。同じ関数を取り込むのに、パスが1段階違うだけで結果が変わるという点が、このとき最も印象に残りました。
5. 解決策2 — ビルドに2つ目のエントリを登録する
ファイルを作るだけでは、@vizendjs/vault-view/commonとしてimportできるわけではありません。ビルド成果物にそのエントリが別ファイルとして出力される必要がありました。
// dramas/vault-view/vite.config.mts
build: {
lib: {
entry: {
index: './src/index.ts',
common: './src/common.ts',
},
name: packageJson.name,
formats: ['es'],
fileName: (format, entryName) => `${entryName}.${format}.js`,
},
rollupOptions: {
external: [
...Object.keys(packageJson.devDependencies),
...Object.keys(packageJson.peerDependencies),
],
},
}
entryを文字列からオブジェクトに変更した瞬間にビルドが失敗し、しばらく行き詰まりました。原因はUMDでした。UMDはすべてのexportを一つのグローバル変数にぶら下げる形式なので、エントリが2つあるとそのグローバル名を決められず、複数エントリをサポートしていませんでした。利用するアプリはすべてESMバンドラーを使っていたため、formatsを['es']に固定し、package.jsonのmainフィールドもES成果物を指すように合わせて整理しました。
6. 解決策3 — 利用者がそのエントリから入れるようにする
ビルド結果にファイルが生成されても、パッケージの外側から@vizendjs/vault-view/commonというパスでアクセスできなければなりません。exportsフィールドにサブパスを追加しました。
{
"main": "./dist/index.es.js",
"module": "./dist/index.es.js",
"exports": {
".": {
"types": "./dist/@types/src/index.d.ts",
"import": "./dist/index.es.js",
"require": "./dist/index.es.js"
},
"./common": {
"types": "./dist/@types/src/common.d.ts",
"import": "./dist/common.es.js"
},
"./styles/all.css": "./dist/vault-view.css"
},
"typesVersions": {
"*": {
"common": ["./dist/@types/src/common.d.ts"]
}
}
}
ここまで進めるとビルドは通るのに、エディターでは相変わらずモジュールが見つからないと表示されました。しばらく調べたところ、exportsのtypes条件はmoduleResolutionがbundler以上の場合にのみ解釈されることがわかりました。私たちのパッケージにはまだレガシーな解決方式を使っているものがあったため、同じ内容をtypesVersionsにももう一度宣言して、ようやくエラーが消えました。2つのフィールドは重複しているように見えますが、それぞれ異なる利用者のためのものでした。
7. 検証 — 本当に余計なものが付いてこないか確認する
作業を終えた後、実際に効果があるのかをdistを直接開いて確認しました。2つのエントリは、それぞれ異なるチャンクを参照していました。
dramas/vault-view/dist/
├─ index.es.js 1.5 KB → index-D-3TlXGH.mjs 참조
├─ index-D-3TlXGH.mjs 3.7 MB
├─ pdf.worker.min.mjs 1.0 MB
├─ common.es.js 120 B → FileDownloadItem-B17vE2-_.mjs 참조
└─ FileDownloadItem-B17vE2-_.mjs 49 KB
common.es.jsの内容は4行だけです。
import { F as a, f } from "./FileDownloadItem-B17vE2-_.mjs";
export {
a as FileDownloadItem,
f as formatFileSize
};
サイズよりも確実な根拠になったのは、各チャンクが外部に要求しているライブラリの一覧でした。前述のとおり、重いライブラリはexternalとして除外されているため、import文だけが残ります。その一覧こそが、利用するアプリが負担しなければならない荷物です。
[배럴 엔트리] index-D-3TlXGH.mjs 가 요구하는 것 (15개)
react, react-dom, @emotion/styled, @emotion/react, @vizendjs/accent,
@tanstack/react-query, axios, notistack, jotai, lodash, dayjs,
ag-grid-react, react-zoom-pan-pinch, pdfjs-dist, react-syntax-highlighter
[경량 엔트리] FileDownloadItem-B17vE2-_.mjs 가 요구하는 것 (5개)
react, @emotion/styled, @tanstack/react-query, axios, @vizendjs/accent
一覧からpdfjs-dist、react-syntax-highlighter、react-zoom-pan-pinch、ag-grid-react、dayjs、lodash、jotaiが消えていました。コンポーネントのソースは150行なのにチャンクが49KBあるのは不思議に見えましたが、一覧に@vizendjs/vault-stateがないのを見て理解しました。externalの対象になるのはdevDependenciesとpeerDependenciesだけですが、vault-stateはdependenciesなので、ダウンロードとStash登録に使われるフックのコードがチャンク内に含まれていたのです。残った5つはチャット画面がすでに使っているものなので、実質的な追加コストはほとんどありませんでした。数字ではなく一覧で確認すると、何がなぜ除外されたのかが明確でした。そのため、それ以降はバンドルを見るとき、サイズよりもまずこの一覧を見るようになりました。
8. 使う側
利用する側では、importパスに/commonを付けるだけです。
import { FileDownloadItem } from '@vizendjs/vault-view/common';
ただし、このimportはチャット画面のコンポーネントではなく、上位階層であるcollageに置きました。loopin-viewから直接importすると、異なるドメインに属する同じ階層のパッケージ同士が依存することになり、モノレポのルールに反するためです。collageで実装を受け取り、Context経由で注入するように変更したおかげで、vault-viewのバージョンが7.2.6から7.2.21まで上がる間、loopin-viewは一度も再配布せずに済みました。
注意すべき点も一つありました。軽量エントリのチャンク内ではaxiosを直接importしていますが、このaxiosは必ずホストアプリと同じインスタンスでなければなりません。私たちは認証トークンと行為者識別子をaxiosのグローバルインスタンスのrequestインターセプターで付与しているため、パッケージが独自のコピーを使うと、そのリクエストだけAuthorizationとActoridなしで送信され、401になります。そのためaxiosはdependenciesではなくpeerDependenciesとして宣言しました。
9. 結果と学び
|
項目 |
バレルエントリ |
軽量エントリ |
|---|---|---|
|
importパス |
@vizendjs/vault-view |
@vizendjs/vault-view/common |
|
参照チャンクサイズ |
約3.7MB |
約49KB |
|
利用アプリが負担する外部ライブラリ |
15個 |
5個 |
|
pdfjs-dist / pdf.worker |
含む |
なし |
機能面でも、「チャットで受け取ったファイルが自分のファイル一覧に残る」という要件が、チャット側に別途実装することなく満たされました。150行のコンポーネントをそのまま再利用し、コストだけを削減した形です。
パッケージの公開APIは、exportの一覧ではなくモジュールグラフのようです。 export * の1行はパッケージを作る側には便利ですが、利用する側には、その下にぶら下がるすべてのものがコストとして跳ね返ってきます。今後、共通パッケージでバレルを作るときは、「これを通じて何が一緒に付いてくるのか」を先に確認しようと思います。
共通パッケージには、入り口が複数あってもよいということを学びました。 最初は既存のエントリを何とか軽くしなければならないと思っていましたが、vaultアプリはそれらの重いコンポーネントがすべて必要な、正当な利用者でした。利用者ごとに必要なサイズが異なるなら、エントリを分けるほうが双方にとってよい選択でした。
まだ整理できていないこともあります。今は軽量エントリに何を含めるかが私の判断にかかっているため、後になって誰かがcommon.tsからうっかりバレルをimportすると、再び重くなります。チャンクの外部import一覧をビルド時に確認し、あらかじめ決めた数を超えたら失敗させるチェックを入れてみたいのですが、どのように構成するのがよいかは、もう少し調べる必要がありそうです。
messi