第 1 章:マクロ認知:core リポジトリのエンジニアリング設計哲学
リアクティブや仮想 DOM の実装を一行でも追跡し始める前に、まずこれらのコードが依存するエンジニアリングの母体を理解する必要がある。Vue core リポジトリを開くと、最初に目に入るのはフレームワークのコアロジックではなく、package.jsonとpnpm-workspace.yamlといったエンジニアリング設定ファイルである——これらはランタイム機能を一切含まないが、フレームワーク全体が正しくビルド・テスト・リリースできるかを決定する。本章が答えるのはまさにこの前置問題である:core リポジトリとは何か。それは@vue/runtime-coreという npm パッケージではなく、runtime-core、reactivity、compiler-sfcなど十余りの公開リリースパッケージ、さらにsfc-playground、template-explorerなどのプライベート実験パッケージを担うエンジニアリングの母体である。この母体の組織方法を理解することが、以降の全章(ビルド、型、リリース、サイズ予算)の前提となる。本章は三つの主線に沿って展開する:workspace の二重ディレクトリ構造、ルートレベルの TypeScript と Rollup の統一制約、そして「ソースリポジトリ」と「リリース産物」の分離哲学である。
一、二重ディレクトリ構造:packages と packages-private の物理的隔離
直感モデル
core リポジトリを一棟の研究開発ビルに例えよう。packages/は正式な製品ラインであり、生産されたものは商標を貼って市場に売り出す;packages-private/は内部試験室であり、中のサンプルはデバッグとデモにのみ使用され、決して外部に出荷されない。両者は同じ水道・電気(依存関係、ビルドツール)を共有するが、入退室管理システム(リリースフロー)はそれらを区別して扱う。
この物理的隔離がなければ、内部デバッグ用の playground パッケージが誤って npm に公開されることは容易に起こりうる——これは仮定ではなく、monorepo の古典的事故である。
データ構造とメモリレイアウト
workspace の境界はpnpm-workspace.yamlによって定義される。有効な宣言はわずか三行である:
📎 pnpm-workspace.yaml:1-3
packages:
- 'packages/*'
- 'packages-private/*'この二つの glob が pnpm に伝えるのは:packages/とpackages-private/配下の各サブディレクトリが独立したパッケージであるということ。pnpm はそれらのためにシンボリックリンクを確立し、@vue/runtime-coreが@vue/reactivityを参照する際に registry からダウンロードするのではなく、直接ローカルソースディレクトリを指すようにする。
続くcatalog:セクションは pnpm の依存バージョンカタログメカニズムである:
📎 pnpm-workspace.yaml:5-13
catalog:
'@babel/parser': ^7.29.8
'@babel/types': ^7.29.8
'entities': '^7.0.1'
'estree-walker': ^2.0.2
'magic-string': ^0.30.21
'source-map-js': ^1.2.1
'vite': ^8.3.0
'@vitejs/plugin-vue': ^6.0.9ルートpackage.jsonに対応して書かれているのは"@babel/parser": "catalog:" 📎 package.json:65-65。catalog:はプレースホルダであり、pnpm がインストール時に catalog セクションで宣言されたバージョンに置き換える。これによる利点は:@babel/parserのバージョンがpnpm-workspace.yamlの一箇所でのみ管理され、それを参照する全パッケージが自動的に整合し、「A パッケージは 7.28、B パッケージは 7.29」というバージョンドリフトを根絶する。
シナリオ駆動 Walkthrough:一度のpnpm installの後に何が起こるか
リポジトリのルートディレクトリでpnpm installを実行すると仮定しよう。このシナリオに代入し、段階的に追跡する:
第一步:preinstall ゲート。pnpm はインストール前にルートpackage.jsonのpreinstallスクリプトをトリガーする:
📎 package.json:45-45
"preinstall": "npx only-allow pnpm"only-allow pnpmは現在のパッケージマネージャが pnpm かどうかをチェックし、そうでなければ直接エラーで終了する。このスクリプトの存在は、npm や yarn で core リポジトリをインストールすると失敗することを意味する。なぜ pnpm に固定しなければならないのか? core リポジトリは pnpm の workspace シンボリックリンクと catalog メカニズムに依存しており、npm の workspaces はcatalog:構文をサポートせず、yarn の PnP モードはモジュール解決パスを変えてしまい、ビルドスクリプト中のcreateRequireの動作が不一致になるからである。
第二步:workspace の解析。pnpm はpnpm-workspace.yamlを読み取り、packages/*とpackages-private/*をスキャンし、package.jsonを含む各ディレクトリにパッケージレコードを確立する。
第三步:catalog 置換の適用。ルートpackage.json中のすべてのcatalog:プレースホルダーは catalog セクションの実際のバージョンに置き換えられ、その後一括インストールされます。
第四ステップ:postinstall フック。インストール完了後にトリガーされます:
📎 package.json:46-46
"postinstall": "simple-git-hooks"simple-git-hooksルートpackage.json内のsimple-git-hooksフィールドを読み取り、Git フックを.git/hooks/:
📎 package.json:48-51
"simple-git-hooks": {
"pre-commit": "pnpm lint-staged && pnpm check",
"commit-msg": "node scripts/verify-commit.js"
}pre-commitフックは毎回のコミット前に lint-staged と型チェックを実行し、commit-msgフックはコミットメッセージのフォーマットを検証します(Vue は conventional commits を使用)。注意preinstallとpostinstallの対称性:前者はゲートキーパー(pnpm のみ許可)、後者は防御配置(Git フックのインストール)。
設計上の考察と落とし穴
なぜ2つの glob を使い、1つのpackages*/?にしないのか。2つのディレクトリを明示的に列挙することで、「公開」と「プライベート」のセマンティクスが設定レベルで可視化されます。新しく参加した開発者がpnpm-workspace.yamlを読めば、リポジトリに2種類のパッケージがあることが一目でわかります。もしpackages*/と書いた場合、このセマンティクスは隠蔽されてしまいます。
allowBuildsとサプライチェーンセキュリティ。この設定に注目してください:
📎 pnpm-workspace.yaml:15-21
allowBuilds:
'@parcel/watcher': true
'@swc/core': true
'esbuild': true
'puppeteer': true
'simple-git-hooks': true
'unrs-resolver': truepnpm はデフォルトで依存パッケージのインストールスクリプト(postinstall)の実行を禁止しています。これはサプライチェーン攻撃の一般的な侵入経路であるためです。allowBuildsはホワイトリストです:リストされたパッケージのみがビルドスクリプトを実行できます。@swc/core、esbuildはプラットフォーム固有のネイティブバイナリをダウンロードする必要があり、puppeteerは Chromium をダウンロードする必要があり、simple-git-hooksは Git フックを書き込む必要があります——これらはすべて正当なビルド時の動作であるため、明示的に許可されています。
minimumReleaseAge: 1440の深い意味。この設定行は、新しく公開された依存バージョンが「24時間(1440分)経過」するまでインストールを許可しないことを要求しています:
📎 pnpm-workspace.yaml:33-33
minimumReleaseAge: 1440これは npm サプライチェーン攻撃に対するクールダウン期間メカニズムです。攻撃者がパッケージを乗っ取り悪意のあるバージョンを公開した後、通常は数時間以内に発見され撤下されます。24時間のクールダウン期間を設けることで、core リポジトリはこのウィンドウを回避できます。一方、minimumReleaseAgeExcludeは特定のセキュリティパッチに対して例外を許可します:
📎 pnpm-workspace.yaml:36-38
minimumReleaseAgeExclude:
# Renovate security update: vitest@4.1.11
- vitest@4.1.11コメントはこれが Renovate によってトリガーされたセキュリティ更新であり、即座に有効化する必要があるため、クールダウン期間が免除されることを明確に説明しています。
---
二、ルートレベルの tsconfig:すべてのサブパッケージの型境界を統一的に制約する
直感的モデル
各サブパッケージがそれぞれ tsconfig を管理すると、「A パッケージはstrict: false、B パッケージはstrict: true」という亀裂が生じます。ルートレベルの tsconfig は憲法です:すべてのサブパッケージが共通して遵守する型ルールを規定し、サブパッケージはその上に追加することのみ可能で、違反はできません。
データ構造とメモリレイアウト
ルートtsconfig.jsonのcompilerOptionsはリポジトリ全体の型システムの基盤です。いくつかの重要なフィールドを抜粋します:
📎 tsconfig.json:5-29
"target": "es2016",
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"noUnusedLocals": true,
"isolatedModules": true,
"isolatedDeclarations": true,
"composite": true,
"paths": {
"@vue/compat": ["./packages/vue-compat/src"],
"@vue/*": ["./packages/*/src"],
"vue": ["./packages/vue/src"]
}項目ごとに解説:
target: es2016:出力構文を ES2016 にダウングレード。これは Rollup 設定における esbuild のtargetと呼応しています(isServerRenderer || isCJSBuild ? 'es2019' : 'es2016'📎rollup.config.js:337-337)。moduleResolution: bundler:バンドラー形式のモジュール解決を採用し、拡張子の省略を許可し、exportsフィールドをサポート。strict: true:すべての厳格チェックを有効化。strictNullChecks、noImplicitAnyなどを含む。noUnusedLocals: true:未使用のローカル変数を直接エラーにします。このルールは Tree-shaking と組み合わせて実用的な意味があります——未使用の変数はしばしばデッドコードのシグナルです。isolatedModules: true:各ファイルが独立してトランスパイル可能であることを要求。これは esbuild/swc のような「ファイル単位のトランスパイル、クロスファイル型分析なし」ツールの前提条件です。isolatedDeclarations: true:すべてのエクスポートに明示的な型注釈を要求。このルールは.d.ts生成パイプラインに直接貢献します——明示的な注釈があってこそ、tscが完全な型推論を行わずに宣言ファイルを高速生成できます。composite: true:プロジェクト参照(project references)に必要なインクリメンタルビルドメタデータを有効化。
pathsフィールドは workspace の型層ミラー:@vue/*を./packages/*/srcにマッピングし、TypeScript がコンパイル時にnode_modules内のシンボリックリンクではなくソースコードを直接解決できるようにします。これは pnpm のランタイムシンボリックリンクと補完関係にあります——ランタイムは pnpm、コンパイル時は paths。
シナリオ駆動ウォークスルー:一度のpnpm checkの型チェック
checkスクリプトはtsc --incremental --noEmit 📎 package.json:15-15です。このシナリオを代入すると:
第一ステップ:include 範囲の読み取り。tsconfig のincludeがどのファイルがチェックに参加するかを決定します:
📎 tsconfig.json:31-39
"include": [
"packages/global.d.ts",
"packages/*/src",
"packages/*/__tests__",
"packages/vue/jsx-runtime",
"packages/runtime-dom/types/jsx.d.ts",
"scripts/*",
"rollup.*.js"
]とscripts/*もチェック範囲内であることに注意。rollup.*.jsこれはビルドスクリプト自体も型制約を受けることを意味します——rollup.config.js先頭の// @ts-check 📎 rollup.config.js:1-1と JSDoc 型注釈の組み合わせにより、この純粋な JS ファイルもtscでチェックできます。
第二ステップ:exclude の適用。
📎 tsconfig.json:40-40
"exclude": ["packages-private/sfc-playground/src/vue-dev-proxy*"]sfc-playgroundのvue-dev-proxyファイルは除外されます。なぜか? このようなファイルは通常ランタイムで動的に生成されるプロキシコードであり、その型形状は不安定で、チェックに含めるとノイズが発生します。
第三ステップ:インクリメンタルチェック。 --incrementalによりtscは前回のチェック結果を.tsbuildinfoにキャッシュし、変更されたファイルのみを再チェックします。--noEmitはチェックのみで出力なしを意味します——型チェックと成果物生成は2つの独立したパイプラインです。
設計上の考察と落とし穴
isolatedDeclarationsのコストとベネフィット。このルールを有効にすると、すべてのエクスポートに明示的な戻り型注釈が必要になります。例えばexport function foo(): numberではなくexport function foo() { return 1 }のように。これは記述コストを増やしますが、その代わりに.d.ts生成速度の大幅な向上を得られます——tscはクロスファイル推論なしで宣言ファイルを生成できます。これはbuild-dtsスクリプトtsc -p tsconfig.build.json --noCheckの--noCheckフラグと呼応しています:型が明示的に注釈されているため、宣言ファイル生成時にチェックをスキップすることも可能です。
typesフィールドのグローバル注入。
📎 tsconfig.json:21-21
"types": ["vitest/globals", "puppeteer", "node"]これら3つの型パッケージがグローバルに注入されることで、テストファイルはdescribe、it、expectを import なしで直接使用でき、e2e テストはpuppeteerの型を直接使用できます。これは利便性と汚染性のトレードオフです——グローバル型が増えるほど名前衝突のリスクが高まりますが、テストコードの記述体験は向上します。
---
三、Rollup 設定:buildOptions からマルチフォーマット成果物への統一ファクトリ
直感モデル
Rollup 設定は core リポジトリの総組立工場です。特定のパッケージが何をするかには関心がなく、「このパッケージがどのフォーマットを出力するか、各フォーマットのエントリファイルはどこか、どの依存を外部化するか」だけを関心を持ちます。各サブパッケージのpackage.jsonにおけるbuildOptionsフィールドは荷物に貼られた出荷伝票であり、総組立工場は伝票に従って作業します。
データ構造とメモリレイアウト
設定ファイルの入口で「パッケージ単位のビルド」モデルが確立されます:
📎 rollup.config.js:32-44
if (!process.env.TARGET) {
throw new Error('TARGET package must be specified via --environment flag.')
}
...
const privatePackages = fs.readdirSync('packages-private')
const pkgBase = privatePackages.includes(process.env.TARGET)
? `packages-private`
: `packages`
const packagesDir = path.resolve(__dirname, pkgBase)
const packageDir = path.resolve(packagesDir, process.env.TARGET)
...
const pkg = require(resolve(`package.json`))
const packageOptions = pkg.buildOptions || {}
const name = packageOptions.filename || path.basename(packageDir)重要な設計:TARGET環境変数でどのパッケージをビルドするかを指定します。設定はfs.readdirSync('packages-private')によってそのパッケージが公開ディレクトリかプライベートディレクトリかに属するかを判断し、pkgBaseを決定します。これは実行時ディレクトリ探索です——「どのパッケージがプライベートか」のリストを維持する必要はなく、ディレクトリ構造自体が真実です。
buildOptionsはサブパッケージpackage.jsonのカスタムフィールドであり、packageOptions.filenameは成果物のファイル名プレフィックスを決定し、packageOptions.formatsはデフォルトのビルドフォーマットを決定します。
フォーマットから成果物へのマッピングはoutputConfigsで定義されます:
📎 rollup.config.js:58-88
const outputConfigs = {
'esm-bundler': { file: resolve(`dist/${name}.esm-bundler.js`), format: 'es' },
'esm-browser': { file: resolve(`dist/${name}.esm-browser.js`), format: 'es' },
cjs: { file: resolve(`dist/${name}.cjs.js`), format: 'cjs' },
global: { file: resolve(`dist/${name}.global.js`), format: 'iife' },
'esm-bundler-runtime': { file: resolve(`dist/${name}.runtime.esm-bundler.js`), format: 'es' },
'esm-browser-runtime': { file: resolve(`dist/${name}.runtime.esm-browser.js`), format: 'es' },
'global-runtime': { file: resolve(`dist/${name}.runtime.global.js`), format: 'iife' },
}7つのフォーマットが3種類の消費シナリオをカバーします:esm-bundlerは Vite/webpack などのバンドラが消費するため、esm-browserはブラウザネイティブ ESM が消費するため、globalは<script>タグが消費するため。-runtimeサフィックスが付くものは「ランタイムのみ」ビルドで、メインのvueパッケージにのみ開放されます。
シナリオ駆動ウォークスルー:一度のpnpm build vueの完全な意思決定フロー
実行node scripts/build.js vueのシナリオを代入します。TARGET=vue、createConfig内部の意思決定を追跡します:
ステップ1:フォーマットリストを決定。
📎 rollup.config.js:91-92
const defaultFormats = ['esm-bundler', 'cjs']
const inlineFormats = process.env.FORMATS && process.env.FORMATS.split(',')
const packageFormats = inlineFormats || packageOptions.formats || defaultFormats
const packageConfigs = process.env.PROD_ONLY
? []
: packageFormats.map(format => createConfig(format, outputConfigs[format]))優先度:コマンドラインFORMATS> サブパッケージbuildOptions.formats> デフォルト['esm-bundler', 'cjs']。PROD_ONLY環境変数が真の場合、非本番ビルドをスキップし、後で追加される.prod.js設定のみを保持します。
ステップ2:ビルドフラグを計算。 createConfig内部でフォーマット文字列から一連のブールフラグを導出します:
📎 rollup.config.js:131-142
const isProductionBuild = process.env.__DEV__ === 'false' || /\.prod\.js$/.test(output.file)
const isBundlerESMBuild = /esm-bundler/.test(format)
const isBrowserESMBuild = /esm-browser/.test(format)
const isServerRenderer = name === 'server-renderer'
const isCJSBuild = format === 'cjs'
const isGlobalBuild = /global/.test(format)
const isCompatPackage = pkg.name === '@vue/compat'
const isCompatBuild = !!packageOptions.compat
const isBrowserBuild =
(isGlobalBuild || isBrowserESMBuild || isBundlerESMBuild) &&
!packageOptions.enableNonBrowserBranchesこれらのフラグは後続のすべての意思決定の単一の真実源です:エントリファイルの選択、define 置換、external 判定、プラグインの組み立て、すべてがこれらに依存します。
ステップ3:エントリファイルを選択。
📎 rollup.config.js:159-168
let entryFile = /runtime$/.test(format) ? `src/runtime.ts` : `src/index.ts`
if (isCompatPackage && (isBrowserESMBuild || isBundlerESMBuild)) {
entryFile = /runtime$/.test(format)
? `src/esm-runtime.ts`
: `src/esm-index.ts`
}デフォルトのエントリはsrc/index.ts、ランタイムのみのビルドはsrc/runtime.tsを使用。compat パッケージ(@vue/compat、つまり Vue 2 互換ビルド)は default と named の両方のエクスポートを提供する必要があり、これにより Rollup が非 ESM ターゲットでエラーを報告するため、ESM ビルドには別途esm-index.ts / esm-runtime.tsエントリを使用します。
ステップ4:define 置換テーブルを生成。 resolveDefineソースコード内の__DEV__、__BROWSER__などのコンパイル時定数をリテラルに置換します:
📎 rollup.config.js:170-201
const replacements = {
__COMMIT__: `"${process.env.COMMIT}"`,
__VERSION__: `"${masterVersion}"`,
__TEST__: `false`,
__BROWSER__: String(isBrowserBuild),
__GLOBAL__: String(isGlobalBuild),
__ESM_BUNDLER__: String(isBundlerESMBuild),
__ESM_BROWSER__: String(isBrowserESMBuild),
__CJS__: String(isCJSBuild),
__SSR__: String(!isGlobalBuild),
__COMPAT__: String(isCompatBuild),
__FEATURE_SUSPENSE__: `true`,
__FEATURE_OPTIONS_API__: isBundlerESMBuild ? `__VUE_OPTIONS_API__` : `true`,
__FEATURE_PROD_DEVTOOLS__: isBundlerESMBuild ? `__VUE_PROD_DEVTOOLS__` : `false`,
__FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__: isBundlerESMBuild ? `__VUE_PROD_HYDRATION_MISMATCH_DETAILS__` : `false`,
}ここに巧妙な階層化があります:feature flags は esm-bundler ビルドではハードコードされず、__VUE_OPTIONS_API__のような識別子として保持され、最終ユーザーのバンドラが置換します。これによりユーザーはdefine: { __VUE_OPTIONS_API__: false }で Options API サポートを無効化し、関連コードを Tree-shake できます。一方、global/esm-browser ビルドでは、これらの flag はtrue/falseにハードコードされます。ブラウザが直接消費する成果物にはバンドラが介在しないためです。
ステップ5:環境変数による上書きを許可。
📎 rollup.config.js:208-216
// allow inline overrides like
//__RUNTIME_COMPILE__=true pnpm build runtime-core
Object.keys(replacements).forEach(key => {
if (key in process.env) {
const value = process.env[key]
assert(typeof value === 'string')
replacements[key] = value
}
})任意の define キーは同名の環境変数で上書きできます。コメントに示された例は__RUNTIME_COMPILE__=true pnpm build runtime-core——特定のコンパイル分岐をデバッグするためです。
ステップ6:プラグインチェーンを組み立て。
📎 rollup.config.js:324-342
plugins: [
json({ namedExports: false }),
alias({ entries }),
enumPlugin,
...resolveReplace(),
esbuild({
tsconfig: path.resolve(__dirname, 'tsconfig.json'),
sourceMap: output.sourcemap,
minify: false,
target: isServerRenderer || isCJSBuild ? 'es2019' : 'es2016',
define: resolveDefine(),
}),
...resolveNodePlugins(),
...plugins,
],プラグインの順序には意味があります:json最初に JSON インポートを処理し、aliasは@vue/*をソースパスにマッピングし、enumPluginは列挙型のインライン化を行い、replaceは文字列置換を行い、esbuildは TS トランスパイルを行います。esbuildのtsconfigがルート tsconfig を指すことに注意——すべてのサブパッケージが同一の型設定を共有する、これはまさに第2節で議論した「憲法」のビルド期における具現化です。
ステップ7:本番ビルドの追加。もしNODE_ENV=production:
📎 rollup.config.js:97-114
if (process.env.NODE_ENV === 'production') {
packageFormats.forEach(format => {
if (packageOptions.prod === false) {
return
}
if (format === 'cjs') {
packageConfigs.push(createProductionConfig(format))
}
if (/^(global|esm-browser)(-runtime)?/.test(format)) {
packageConfigs.push(createMinifiedConfig(format))
}
})
}CJS フォーマットには.prod.jsバージョンを追加(__DEV__=falseで置換)、global と esm-browser フォーマットには圧縮バージョンを追加(swc で minify)。packageOptions.prod === falseのパッケージはこのメカニズムから退出できます。
意思決定フロー全体は以下の制御フロー図で要約できます:
flowchart TD
start["node scripts/build.js vue"] --> check_target{"process.env.TARGET 存在?"}
check_target -->|否| throw_err["throw Error: TARGET must be specified"]
check_target -->|是| detect_dir{"TARGET 在 packages-private 中?"}
detect_dir -->|是| base_priv["pkgBase = packages-private"]
detect_dir -->|否| base_pub["pkgBase = packages"]
base_priv --> read_pkg["require(package.json) 读取 buildOptions"]
base_pub --> read_pkg
read_pkg --> resolve_formats{"FORMATS 环境变量?"}
resolve_formats -->|有| use_inline["使用命令行格式"]
resolve_formats -->|无| check_buildopts{"buildOptions.formats?"}
check_buildopts -->|有| use_pkg["使用包声明格式"]
check_buildopts -->|无| use_default["使用默认 esm-bundler,cjs"]
use_inline --> create_cfg["createConfig(format, output)"]
use_pkg --> create_cfg
use_default --> create_cfg
create_cfg --> check_output{"output 配置存在?"}
check_output -->|否| exit_err["console.log invalid format; process.exit(1)"]
check_output -->|是| pick_entry{"格式含 runtime?"}
pick_entry -->|是| entry_rt["entryFile = src/runtime.ts"]
pick_entry -->|否| entry_idx["entryFile = src/index.ts"]
entry_rt --> build_flags["计算 isBundlerESMBuild/isCJSBuild 等标志"]
entry_idx --> build_flags
build_flags --> prod_check{"NODE_ENV == production?"}
prod_check -->|是| add_prod["追加 .prod.js 与 minified 配置"]
prod_check -->|否| done["导出 packageConfigs"]
add_prod --> done設計上の考察と落とし穴
externalの三分岐戦略。 resolveExternalビルドタイプに応じて異なる外部化リストを返します:
📎 rollup.config.js:257-283
function resolveExternal() {
const treeShakenDeps = ['source-map-js', '@babel/parser', 'estree-walker', 'entities/decode']
if (isGlobalBuild || isBrowserESMBuild || isCompatPackage) {
if (!packageOptions.enableNonBrowserBranches) {
return treeShakenDeps
}
} else {
return [
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.peerDependencies || {}),
...['path', 'url', 'stream'],
...treeShakenDeps,
]
}
}ブラウザビルド(global/esm-browser)はすべての依存をインライン化し、treeShakenDepsのみを external として列挙して警告を抑制します——これらの依存はブラウザ分岐では実際に参照されず、Tree-shaking で除去されます。Node/esm-bundler ビルドではすべてのdependenciesとpeerDependenciesを外部化し、消費側が依存バージョンを自分で管理できるようにします。
onwarn循環依存のフィルタリング。
📎 rollup.config.js:344-348
onwarn: (msg, warn) => {
if (msg.code !== 'CIRCULAR_DEPENDENCY') {
warn(msg)
}
},循環依存の警告は黙って無視されます。Vue のruntime-coreとreactivityの間には正当な循環参照が存在し(リアクティブシステムがコンポーネントインスタンス型を参照する必要がある)、これらの循環は実行時に安全であるため、フィルタリングされます。
treeshake.moduleSideEffects: falseの積極的な仮定。
📎 rollup.config.js:355-355
treeshake: {
moduleSideEffects: false,
},これは Rollup に伝えます:すべてのモジュールには副作用がなく、参照されていないインポートを安心して削除できると。これは積極的な仮定です——もしあるモジュールがトップレベルで副作用コード(グローバル変数の登録など)を実行する場合、誤って削除される可能性があります。Vue のソースコードはすべてのモジュールが純粋であることを規約で保証しているため、この最適化を有効にできます。
swc-minify のpure_gettersの罠。
📎 rollup.config.js:373-388
async renderChunk(contents, _, { format }) {
const { code } = await minifySwc(contents, {
module: format === 'es',
format: { comments: false },
compress: { ecma: 2016, pure_getters: true },
safari10: true,
mangle: true,
})
return { code: banner + code, map: null }
}pure_getters: trueは圧縮器に「プロパティアクセスには副作用がない」と伝え、未使用の getter 呼び出しを安全に削除できます。これは Vue のリアクティブコードにとって危険です——obj.fooは getter をトリガーして依存を収集する可能性があります。しかしここでは global/esm-browser の本番ビルドにのみ使用され、かつ Vue ソースコードでは依存収集は明示的な関数呼び出し(track())であり、暗黙的なgetterの副作用ではなく完了するため、安全である。map: nullは圧縮後にsourcemapを生成しないことを示す——本番成果物にはデバッグマッピングは不要である。
---
設計思考:なぜソースリポジトリとリリース成果物は分離されなければならないのか
本章の核心命題に戻る。coreリポジトリのエンジニアリング設計には一貫した主線がある:ソースリポジトリの責務は「生産」、リリース成果物の責務は「消費」であり、両者はビルドパイプラインを通じて分離される。
具体的には三つの層面に現れる:
第一に、ソースは直接公開されない。 package.jsonのprivate: true 📎 package.json:2-2はルートパッケージが永久に公開されないことを示す。各サブパッケージのpackage.jsonにおけるmain/module/exportsフィールドはdist/配下の成果物を指し、src/ではない。ユーザーがvueをインストールする際に取得するのはビルド後の.jsと.d.tsであり、ソースはリポジトリに残る。
第二に、成果物のフォーマットは消費シーンによって決定される。七種のフォーマットは恣意的な列挙ではなく、七種の実際の消費経路に対応する:Viteユーザーはesm-bundlerを取得し、CDNユーザーはglobalを取得し、Node SSRユーザーはcjsを取得する。フォーマットの選択ロジックはrollup.config.jsの一箇所に集中し、サブパッケージはbuildOptions.formatsでどれが必要かを宣言するだけでよい。
第三に、型と実装の分離。 build-dtsスクリプトtsc -p tsconfig.build.json --noCheck && rollup -c rollup.dts.config.js 📎 package.json:9-9は.d.ts生成が独立したパイプラインであることを示す。isolatedDeclarations: trueにより宣言ファイル生成は型チェックをスキップできる(--noCheck)。型は既に明示的に注釈されているためである。
この分離の深層的な動機は:ソースの組織方法は開発者に奉仕し、成果物の組織方法は消費者に奉仕し、両者の最適解は異なる。ソースには明確なディレクトリ構造、完全な型情報、デバッグ可能なsourcemapが必要であり;成果物には最小の体積、正しいモジュールフォーマット、安定したAPI表面が必要である。両者を強制的に統一する(例えばTSソースを直接公開する)と、両端の体験を同時に損なう。
---
本章小结
本章は三つの次元からcoreリポジトリのマクロ的認知を確立した:
1. 二重ディレクトリ構造:packages/とpackages-private/の物理的分離、pnpm workspaceのシンボリックリンクとcatalogバージョンカタログの組み合わせにより、「公開パッケージ」と「プライベートパッケージ」の明確な境界を実現した。preinstallゲート、allowBuildsホワイトリスト、minimumReleaseAgeクールダウン期間が共同でサプライチェーン安全防線を構成する。
2. ルートレベルtsconfig:すべてのサブパッケージの型憲法として、pathsマッピングを通じてコンパイル期のworkspace解決を実現し、isolatedDeclarationsとcompositeを通じて増分ビルドと高速な宣言ファイル生成を支える。
3. Rollup統一ファクトリ:TARGET環境変数を入口とし、buildOptionsを通じてサブパッケージのメタ情報を読み取り、一組のブールフラグでエントリ選択、define置換、external判定、プラグイン装配を駆動し、最終的に七種フォーマットの成果物を産出する。
核心哲学はソースリポジトリとリリース成果物の分離:リポジトリは生産を担い、成果物は消費を担い、ビルドパイプラインは両者間の唯一の橋梁である。
---
章末過渡
本章は「coreリポジトリとは何か」に答えた。しかしリポジトリの静的構造は舞台に過ぎず、真のドラマは一回のビルドリクエストの実行過程で起こる:scripts/build.jsがどのようにコマンドライン引数を解析し、どのようにRollup APIを呼び出し、どのようにビルド失敗と並行性を処理するか。次章では一回のビルドリクエストの入力から成果物までのエンドツーエンドの旅を追跡し、本章で確立した静的認知を動的な実行ビューに変換する。
本章の思考と自測
Q1: もしpnpm-workspace.yamlのminimumReleaseAge: 1440を0に変更した場合、依存関係アップグレードのシナリオでどのようなリスクが生じるか?なぜminimumReleaseAgeExcludeの存在が必要なのか?
参考解析:
minimumReleaseAge: 1440 📎 pnpm-workspace.yaml:33-33は新たに公開された依存バージョンが24時間を満たさなければインストールを許可しないことを要求する。もし0に変更すると、公開されたばかりの任意のバージョンが即座に取り込まれる。
リスクシナリオ:攻撃者が何らかの推移的依存(例えば@babel/parserのあるpatchバージョン)を乗っ取り、悪意のあるpostinstallスクリプトを含むバージョンを公開する。24時間のクールダウン期間内に、コミュニティは通常問題を発見しそのバージョンを撤回する;もしクールダウン期間が0なら、coreリポジトリのCIは攻撃ウィンドウ内で自動アップグレードし悪意のあるスクリプトを実行する可能性がある。
minimumReleaseAgeExclude 📎 pnpm-workspace.yaml:36-38の存在はクールダウン期間メカニズムがセキュリティパッチの緊急性と衝突するためである。コメント中のvitest@4.1.11はRenovateが検出したセキュリティ更新である——このような更新は即座に有効化する必要があり、24時間待つことはかえって露出ウィンドウを延長する。したがって明示的な免除リストが必要であり、セキュリティ更新がクールダウン期間を迂回できるようにする。これは「デフォルトは保守的、例外は明示的」という安全設計原則を体現している。
Q2: rollup.config.jsにおけるresolveDefineの__FEATURE_OPTIONS_API__に対する処理はisBundlerESMBuild ? '__VUE_OPTIONS_API__' : 'true'である。もし誤ってすべてのフォーマットに対して'true'を返すように変更した場合、最終ユーザーにどのような影響を与えるか?
参考解析:
📎 rollup.config.js:192-194
__FEATURE_OPTIONS_API__: isBundlerESMBuild
? `__VUE_OPTIONS_API__`
: `true`,esm-bundlerビルドにおいて、__FEATURE_OPTIONS_API__は識別子__VUE_OPTIONS_API__として保持され、最終ユーザーのバンドラーに置換を委ねる。ユーザーは自身のビルド設定でdefine: { __VUE_OPTIONS_API__: false }を設定でき、それによりTree-shakingがすべてのOptions API関連コード(data、methods、computedなどのオプションの処理ロジック)を除去し、成果物体積を著しく削減できる。
もしすべてのフォーマットに対して'true'を返すように変更すると、esm-bundler成果物においてOptions APIコードがハードコードされ保持され、ユーザーのdefine設定が無効となり、Tree-shakeできなくなる。Composition APIのみを使用するプロジェクトにとって、これは無駄に数KBの成果物体積を増加させる。
この設計の鍵となる洞察は:esm-bundler成果物の最終形態はユーザーのバンドラーによって決定されるため、feature flagはユーザーのビルド期まで遅延して解析されなければならない。一方、global/esm-browser成果物は直接ブラウザで実行され、バンドラーが介在しないため、ハードコードしなければならない。
Q3: rollup.config.jsのresolveExternalでは、ブラウザビルドはtreeShakenDepsのみを external として返し、Node ビルドはすべてのdependenciesを返します。ある日、誰かがruntime-coreに新しいランタイム依存foo-libを追加したが、resolveExternalのロジックの更新を忘れたとします。ブラウザビルドでは何が起こるでしょうか?
参考解析:
📎 rollup.config.js:257-283
ブラウザビルド(isGlobalBuild || isBrowserESMBuild)は!packageOptions.enableNonBrowserBranches時にtreeShakenDeps(source-map-js、@babel/parser、estree-walker、entities/decodeのみを返します)。これはfoo-libが external リストに含まれないことを意味し、
ここまでで、私たちは core リポジトリがエンジニアリングの母体として持つ全体的な設計哲学をマクロな視点から見てきました。二重ディレクトリの workspace 構造が公開パッケージとプライベート実験パッケージの境界を画定し、ルートレベルの TypeScript と Rollup 設定が統一的な制約を提供し、ソースリポジトリとリリース成果物の分離がマルチフォーマット出力を可能にしています。これらの認識が、以降の具体的なエンジニアリングチェーンへの深掘りの道を開きました。次の章では、視点を静的構造から動的フローへ移し、node scripts/build.js vueを起点として、完全なビルドリクエストがコマンドライン引数の解析、ターゲットパッケージの特定、Rollup 設定の生成から成果物のディスク書き込みまでのエンドツーエンドの旅を追跡し、build.js が parseArgs を通じて formats/devOnly/release などのフラグをどのように解析し、ターゲットパッケージの package.json を動的に require して buildOptions を読み取り、最終的に rollup.config.js を駆動して esm-bundler、cjs、global などのマルチフォーマット成果物を生成するかを見ていきます。
この章を読み終えましたか?ご自身のプライベートリポジトリを技術書へ
Tauri 2 + Rust によるローカルファースト設計。100% オフラインの安全性、コードのクラウド送信は一切ありません。不変コミットアンカーで精読。
⚡ Tauri 2 · Rust コア · 100% 完全オフライン · 100万行超のコードベース検証済
第 2 章:メインライフサイクル:1回のビルドリクエストのエンドツーエンドの旅
前章では、core リポジトリがエンジニアリングの母体として持つ位置づけ、および pnpm workspace とルートレベル設定がすべてのサブパッケージをどのように統一的に制約するかを明らかにしました。ここでは、ビルドシステムの核心に深く入り込み、1つのコマンドがどのようにビルドプロセス全体を駆動するかを追跡します。node scripts/build.js vue一見単純に見えますが、すべての成果物——esm-bundler、cjs、global——への唯一の入口です。これがユーザーの意図を実行可能なビルドタスクにどのように変換するかを理解することは、Vue のビルドメカニズムを習得するための鍵となる一歩です。
Rollup 設定生成:環境変数からマルチフォーマット成果物へ
build.jsがexecを通じて Rollup を起動した後、制御はrollup.config.jsに移ります。このファイルはビルドシステムの「脳」です——環境変数を読み取り、Rollup 設定オブジェクトの配列を動的に生成します。
環境変数の検証とパッケージの特定
📎 rollup.config.js:27-29
もしTARGETが設定されていなければ、直接エラーをスローします。これは防御的プログラミングです:Rollup 設定は直接呼び出される可能性があり(例えばrollup -c)、その場合build.jsが環境変数を注入しないため、迅速に失敗する必要があります。
📎 rollup.config.js:32-44
ここではbuild.jsのプライベートパッケージ判定ロジックが繰り返されています——なぜならrollup.config.jsは独立したプロセスであり、build.jsのメモリ状態を共有できないからです。resolve関数は相対パスをパッケージディレクトリ下の絶対パスに解決し、pkgはターゲットパッケージのpackage.jsonの内容、packageOptionsはその中のbuildOptionsフィールド、nameは成果物ファイル名のプレフィックスです(優先的にbuildOptions.filenameを使用し、そうでなければディレクトリ名を使用)。
フォーマットマッピングテーブル:outputConfigs
📎 rollup.config.js:58-88
このテーブルは7種類のフォーマットから出力設定へのマッピングを定義しています。重要な観察点:
esm-bundler、esm-browser、esm-bundler-runtime、esm-browser-runtimeはすべてformat: 'es'であり、違いはファイル名のみです。cjsはformat: 'cjs'。globalとglobal-runtimeはformat: 'iife'(即時実行関数式)であり、<script>タグで直接導入するのに適しています。runtimeサフィックスのフォーマットはメインのvueパッケージに対してのみ意味があります——それらはコンパイラを含まず、サイズがより小さくなります。
フォーマット選択:3層の優先度
📎 rollup.config.js:91-92
フォーマット選択は3層の優先度に従います:コマンドラインFORMATS環境変数 > パッケージのbuildOptions.formats> デフォルト['esm-bundler', 'cjs']。PROD_ONLY環境変数は基本設定をスキップするかどうかを制御します——プロダクションバージョンのみをビルドする場合、基本設定配列は空になり、その後はプロダクション設定のみがプッシュされます。
プロダクション設定の追加ロジック
📎 rollup.config.js:97-114
がNODE_ENV === 'production'のとき、各フォーマットに対して:
- もし
packageOptions.prod === falseなら、スキップ(そのパッケージはプロダクションバージョンを必要としない)。 - もし
cjsなら、createProductionConfigを追加——生成.prod.jsファイル。 - もし
/^(global|esm-browser)(-runtime)?/にマッチすれば、createMinifiedConfigを追加——圧縮版を生成。
なぜcjsはcreateProductionConfigを使い、global/esm-browserはcreateMinifiedConfigを使うのか?なぜなら CJS は Node 用であり、Node 環境では圧縮は不要(ユーザー自身が処理する)だが、dev/prod ブランチを区別する必要があるからです;一方、ブラウザで直接導入される成果物はサイズを減らすために圧縮必須です。この違いは2つのファクトリ関数の実装に現れています。
createConfig:設定生成の核心
createConfigは最大の関数であり、フォーマットと出力設定を受け取り、完全な Rollup 設定オブジェクトを返します。
📎 rollup.config.js:125-142
冒頭は一連のブールフラグの計算です:
isProductionBuild:__DEV__環境変数またはファイル名に.prod.jsが含まれるかで判定。isBundlerESMBuild、isBrowserESMBuild、isCJSBuild、isGlobalBuild:フォーマット名の正規表現マッチで判定。isServerRenderer:パッケージ名がserver-renderer。isCompatPackage、isCompatBuildかどうか:Vue 2 互換ビルド関連。isBrowserBuild:グローバルビルドまたはブラウザ ESM ビルドで、非ブラウザブランチが有効になっていない。
これらのフラグは後続のresolveDefine、resolveReplace、resolveExternalで繰り返し使用され、設定差別化の核心的な根拠となります。
📎 rollup.config.js:144-157
出力設定の基本設定:banner 著作権ヘッダー、exportsモード(compat パッケージはautoを使用、それ以外はnamedを使用)、CJS ビルドでesModule相互運用を有効化、sourcemap は環境変数で制御、externalLiveBindings: falseとreexportProtoFromExternal: falseは Rollup 4 の互換性設定。グローバルビルドでは追加でoutput.nameを設定、つまりwindowにマウントされる変数名。
エントリファイルの選択
📎 rollup.config.js:159-168
デフォルトのエントリはsrc/index.tsですが、runtimeサフィックスのフォーマットはsrc/runtime.ts。compat パッケージの ESM ビルドは default と named の両方をエクスポートする必要があるため、個別のesm-index.ts / esm-runtime.tsエントリを使用します。
マクロ定義:resolveDefine
📎 rollup.config.js:170-218
resolveDefine置換テーブルを返し、ソースコード内の__COMMIT__、__VERSION__、__BROWSER__などのマクロをリテラルに置換します。これらのマクロはソースコード内で条件付きコンパイルに使用されます——例えばif (__DEV__) { ... }は本番ビルドでif (false) { ... }に置換され、さらに Tree-shaking によって除去されます。
重要な設計:__FEATURE_OPTIONS_API__、__FEATURE_PROD_DEVTOOLS__などの機能フラグはesm-bundlerビルドでは__VUE_OPTIONS_API__のような識別子として保持され、エンドユーザーがバンドラー設定で上書きできるようにします。一方、他のビルドでは直接trueまたはfalse。
📎 rollup.config.js:203-206
にハードコードされます。非esm-bundlerビルドでは__DEV__をハードコードします。これらの dev/prod 分岐はビルド時に確定しているためです。
📎 rollup.config.js:210-216
最後のステップでは環境変数が任意のマクロ定義を上書きできるようにし、__RUNTIME_COMPILE__=true pnpm build runtime-coreのようなインライン上書きをサポートします。
置換プラグイン:resolveReplace
📎 rollup.config.js:222-255
resolveReplaceの外側で esbuild が処理できない置換を処理します:resolveDefineマージ
- (
enumDefinesからの列挙型インライン定義)。inlineEnums本番ブラウザビルドでは、エラー作成関数に - アノテーションを付加し、Tree-shaking を支援します。
/*@__PURE__*/ビルドでは、 esm-bundlerを__DEV__に置換し、バンドラーに判断を委ねます。!!(process.env.NODE_ENV !== 'production')ブラウザ ESM ビルドでは、- を空オブジェクトに置換し、ブラウザエラーを回避します。
process.env外部依存:
これは前章の終わりの考察問題の核心です。ブラウザビルドはresolveExternal
📎 rollup.config.js:257-283
のみを external として返します——これらの依存は import されていますが、ブラウザ分岐では実際には実行されず、ここに列挙されているのは Rollup の警告を抑制するためだけです。Node/ESM-bundler ビルドでは、すべてのtreeShakenDepsとdependencies、およびpeerDependenciesなどの Node 組み込みモジュールを externalize します。path、url、stream最終設定オブジェクト
返される設定オブジェクトには以下が含まれます:
📎 rollup.config.js:319-352
:エントリファイルの絶対パス。
input:外部依存リスト。external:プラグイン配列、順序は json → alias → enumPlugin → replace → esbuild → nodePlugins。plugins:出力設定。output:onwarn警告をフィルタリング(Vue ソースコードに循環依存が存在するが、実行時には無害)。CIRCULAR_DEPENDENCY:すべてのモジュールに副作用がないことを Rollup に伝え、積極的な Tree-shaking を実行。treeshake.moduleSideEffects: false以下の図は環境変数から最終設定へのデータフローを示しています:
コピー
flowchart LR
env["process.env<br/>TARGET, FORMATS, NODE_ENV"] --> pkg_load["require(package.json)"]
pkg_load --> pkg_opts["packageOptions<br/>= pkg.buildOptions"]
env --> fmt_sel["packageFormats<br/>= FORMATS || buildOptions.formats || default"]
fmt_sel --> cfg_map["outputConfigs[format]"]
pkg_opts --> create_cfg["createConfig(format, output)"]
cfg_map --> create_cfg
create_cfg --> define["resolveDefine()<br/>__DEV__, __BROWSER__ ..."]
create_cfg --> replace["resolveReplace()<br/>enumDefines, __DEV__"]
create_cfg --> external["resolveExternal()<br/>treeShakenDeps / deps"]
create_cfg --> node_plugins["resolveNodePlugins()<br/>commonJS, nodeResolve"]
define --> rollup_cfg["RollupOptions<br/>{ input, external, plugins, output }"]
replace --> rollup_cfg
external --> rollup_cfg
node_plugins --> rollup_cfg
rollup_cfg --> rollup_run["Rollup 执行构建"]
rollup_run --> dist["dist/*.js 产物落盘"]のプロセス管理
execは
build.jsを通じて Rollup サブプロセスを起動します:execは
📎 scripts/utils.js:64-114
execをラップし、Promise を返します。重要な設計:spawnのデフォルトは
stdio——stdin は無視、stdout/stderr はパイプでキャプチャ。['ignore', 'pipe', 'pipe']——Windows ではコマンドを正しく解析するために shell が必要。shell: process.platform === 'win32'と- 配列を通じて出力を収集し、
stderrChunksイベントで結合します。stdoutChunks終了コードが 0 の場合は resolve、それ以外は reject し stderr の内容を付加します。exit〔設計推論とアーキテクチャのトレードオフ〕 - 注意:
を呼び出す際にbuild.jsを渡しており、これがデフォルトのパイプ設定を上書きし、Rollup の出力を直接ターミナルに透過させます。これはビルドツールの正しい動作です——ユーザーはビルドの進行状況をリアルタイムで確認する必要があります。execサイズチェック:{ stdio: 'inherit' }サイズチェックには 2 つのスキップ条件があります:
が真、またはフォーマットが指定されているがcheckAllSizes
📎 scripts/build.js:206-215
を含まない場合。サイズチェックはグローバルビルド成果物のみを対象としているためです——それはエンドユーザーが直接読み込むファイルであり、サイズが最も敏感です。devOnlyは 2 つのファイルをチェックします:globalと
📎 scripts/build.js:222-228
checkSize(後者はフォーマットが未指定または${target}.global.prod.jsが指定された場合のみチェック)。${target}.runtime.global.prod.jsはファイルを読み取り、global-runtimeと
📎 scripts/build.js:235-264
checkFileSizeで圧縮後サイズを計算し、gzipSyncでフォーマットして出力します。brotliCompressSyncが真の場合、結果をprettyBytesに書き込みます——これは CI におけるサイズ予算チェックのデータソースです。writeSize型宣言ビルドtemp/size/${fileName}.jsonが真の場合、
を呼び出し、
📎 scripts/build.js:94-108
を通じてターゲットリストを渡します。これにより、実際にビルドされるパッケージのみの型宣言が生成されます。buildTypes設計上の考察と本番環境の落とし穴pnpm run build-dtsなぜ--environment TARGETS:...を直接渡すのではなく
を使うのか?
Rollup の--environmentは設定ファイル内でを通じて読み取れる唯一の引数渡し方法です。直接--environment引数を渡すにはprocess.envを解析する必要がありますが、--configは構造化されたキーと値のペアの解析を提供します。process.argvの正規表現の罠。--environmentにおいて
fuzzyMatchTargetはユーザー入力です。ユーザーが target.match(partialTarget)を入力した場合、正規表現ではリテラルなので問題ありませんが、partialTargetを入力すると任意の文字にマッチし、予期しないターゲットにマッチする可能性があります。これはあいまいマッチングの固有のリスクですが、Vue のパッケージ名には正規表現の特殊文字が含まれていないため、実際には発生しません。runtime-core,-並行ビルドのリソース競合。runtime.core,.は
を並行上限として使用しますが、各 Rollup プロセス自体も worker を起動します。CI の低コア数のコンテナでは、メモリオーバーフローを引き起こす可能性があります。本番環境で OOM が発生した場合、 runParallelまたは並行数を減らすことで緩和できます。cpus().lengthのキャッシュライフサイクル。--max-old-space-sizeは
scanEnums内で呼び出されますが、 removeCache自体がエラーをスローした場合、finallyは代入されず、scanEnums内の呼び出しが失敗します。実際にはremoveCacheが返す関数はfinallyの前に確定しているため、このリスクは存在しません——ただしこれは読む際に確認が必要なタイミングの詳細です。scanEnumsの漏れリスク。try前章の考察問題ですでに指摘されています:
resolveExternalに新しい依存を追加したがの更新を忘れた場合、ブラウザビルドはその依存をバンドルに含めてしまい(external リストにないため)、サイズが膨張します。これは「ホワイトリスト external」戦略の固有のコストです。runtime-core本章のまとめresolveExternal一度の
の完全な旅:
がコマンドラインを解析し、node scripts/build.js vueが同期的に取得。
1. parseArgsがcommitを呼び出して列挙型キャッシュを生成し、ターゲットを解析(
2. run()またはscanEnumsがfuzzyMatchTargetを通じてallTargets)。
3. buildAllを並行スケジュールしrunParallelパッケージディレクトリを特定、build。
4. buildを読み取り、プライベートパッケージをフィルタリング、package.jsonをクリーンアップ、dist引数を組み立て、呼び出し--environment 参数、调用 execRollup を起動する。
5. rollup.config.js環境変数を読み取り、createConfigを通じて設定配列を生成し、resolveDefine/resolveReplace/resolveExternalマクロ、置換、外部依存をそれぞれ処理する。
6. Rollup がビルドを実行し、成果物がディスクに書き込まれるdist/。
7. checkAllSizesgzip/brotli のサイズを計算し、オプションで書き込むtemp/size/。
8. もし--withTypesなら、build-dtsを呼び出して型宣言を生成する。
本章の考察とセルフチェック
Q1:build.jsのbuild関数において、if (!formats && fs.existsSync(...))この条件がdistディレクトリを削除するかどうかを決定する。もし!formatsこの条件を外した場合(つまりフォーマット指定に関わらずdistを削除する)、pnpm build-all-cjsのようなスクリプトで何が起こるか?
参考解説:
📎 scripts/build.js:172-175
pnpm build-all-cjsはnode scripts/build.js vue runtime compiler reactivity shared -af cjsに対応する(📎 package.json:40参照)。これは-f cjsを指定するので、formatsは'cjs',!formatsは偽となり、現在のロジックではdist。
を削除しない。もし!formatsを外すと、毎回のビルドでdistが削除される。しかしbuild-all-cjsはcjsフォーマットのみをビルドするため、削除後はdistにはcjsの成果物だけが残り、以前ビルドしたesm-bundler、globalなどのフォーマットはすべて失われる。さらに深刻なのは、build-runtime-esm、build-browser-esmなどのスクリプトが順に実行され(📎 package.json:39のbuild-sfc-playgroundスクリプト参照)、各スクリプトが前のスクリプトの成果物を削除するため、最終的にdistには最後のスクリプトのフォーマットしか残らない。これは SFC Playground のビルドを破壊する——複数フォーマットの成果物が同時に存在する必要があるため。
Q2: runParallelにおけるif (maxConcurrency <= source.length)この条件の役割は何か?もし外した場合、単一パッケージ(targets.length === 1)のビルドで何が起こるか?
参考解説:
📎 scripts/build.js:131-151
この条件は並行制限を有効にするかどうかを制御する。maxConcurrency > source.lengthのとき、制限は不要——すべてのタスクを同時に起動できる。もしこの条件を外すと、タスクが1つだけでもexecuting配列を作成しawait Promise.race(executing)。
を実行する。単一タスクの場合、executing内の Promise は1つだけe,Promise.raceがその完了を待つ。これはエラーにはならないが、不要な Promise チェーンとマイクロタスクスケジューリングのオーバーヘッドを生む。さらに重要なのは、executing.splice(executing.indexOf(e), 1)は単一タスクのシナリオでも正しく動作するため、機能的には差異がなく、性能上のわずかな損失のみである。
真のリスクは:もしmaxConcurrencyが 0 の場合(理論上不可能、cpus().lengthは少なくとも 1 のため)、executing.length >= 0は永遠に真となり、Promise.race([])は永遠にハングする。しかしcpus().lengthがこの境界が発生しないことを保証している。
Q3: resolveExternalにおいて、ブラウザビルドはtreeShakenDepsを external として返すが、これらの依存はブラウザ分岐では実際には実行されない。もしそれらを external リストから外すと(つまり Rollup にバンドルさせようとすると)、何が起こるか?
参考解説:
📎 rollup.config.js:257-283
treeShakenDepsはsource-map-js、@babel/parser、estree-walker、entities/decodeを含む。これらはcompiler-sfcなどのパッケージの依存であり、ブラウザビルドでは__BROWSER__マクロによって条件付きコンパイルで除外される。
もし external から外すと、Rollup はこれらの依存を解決してバンドルしようとする。treeshake.moduleSideEffects: false(📎 rollup.config.js:355-355であり、かつこれらの依存のインポート文がif (!__BROWSER__)分岐内にあるため、esbuild の define が__BROWSER__をtrueに置換し、分岐がデッドコードとしてマークされる。Rollup の Tree-shaking がこれらのインポートを除去し、最終成果物にこれらの依存のコードは含まれない。
しかし問題は:Rollup は Tree-shaking の前にモジュールを解決する必要がある。もしこれらの依存がインストールされていない場合(例えば簡素な CI 環境で)、Rollup は「モジュールを解決できない」エラーを出す。それらを external に列挙することは防御策である——依存が存在しなくても、Rollup は解決を試みず、警告を出すだけである(そしてonwarnは非循環依存の警告をフィルタリングする)。
ここまでで、コマンド解析から Rollup 呼び出しまでのビルドの旅を完全に辿り、並行スケジューリング、プライベートパッケージのフィルタリングなどの核心メカニズムを明らかにした。しかし、プロダクションビルドは物語の半分に過ぎない。次の章では、開発態のリンクに転じ、scripts/dev.jsが SFC プリコンパイルとどのように協調し、ミリ秒級の開発フィードバックループを実現するかを見る。
この章を読み終えましたか?ご自身のプライベートリポジトリを技術書へ
Tauri 2 + Rust によるローカルファースト設計。100% オフラインの安全性、コードのクラウド送信は一切ありません。不変コミットアンカーで精読。
⚡ Tauri 2 · Rust コア · 100% 完全オフライン · 100万行超のコードベース検証済
第 3 章:開発態リンク:dev スクリプトと SFC プリコンパイルの協調メカニズム
前の章では、プロダクションビルドがパラメータ解析からマルチフォーマット成果物のディスク書き込みまでの完全なリンクを追跡した。そのリンクが追求するのは成果物の完全性と規範性である。一方、開発態の核心的な要求はただ一つ:1行コードを変更したら、ブラウザで即座に効果が見えること。プロダクションビルドの「パラメータ解析 → 設定生成 → 全量バンドル → ディスク書き込み」というリンクは、数十秒かかることもざらで、この要求を全く満たせない。Vue core リポジトリはこのために独立した開発態リンクを維持している:scripts/dev.jsは esbuild の watch モードでインクリメンタルビルドを行い、scripts/pre-dev-sfc.jsはメインビルドの前に SFC コンパイラを事前コンパイルする。本章ではこの両者の協調メカニズムを分解する。
3.1 dev.js:esbuild で速度を得るインクリメンタルビルダー
直感モデル
プロダクションビルドは「印刷工場の正式な組版・印刷」のようなもの——品質優先で、遅くても構わない;開発ビルドは「下書き用紙への鉛筆スケッチ」のようなもの——美しさは求めず、書けば即座に現れることだけを求める。Vue がこのスケッチを描くのに Rollup ではなく esbuild を選んだ理由は、ファイル冒頭のコメントに書かれている:Rollup の成果物はより小さく、Tree-shaking も優れているが、esbuild の方がずっと速い。📎 scripts/dev.js:3-5
もしこのスクリプトがなければ、開発者は変更のたびに完全なプロダクションビルドを実行しなければならず、フィードバックループはミリ秒級から分級に退化し、ホットリロード体験は跡形もなくなる。
パラメータ解析とフォーマット導出
スクリプトのエントリは Node 組み込みのparseArgsで3つのオプションを解析する:format(デフォルトglobal)、prod(デフォルトfalse)、inline(デフォルトfalse)。📎 scripts/dev.js:18-40位置引数はtargetsとして収集され、空の場合はデフォルトで['vue']。📎 scripts/dev.js:42-53
ここに見落としがちな細かい点がある:rawFormatとformatは2回の代入である。parseArgsのdefault: 'global'はすでにrawFormatに値があることを保証しているが、スクリプトは依然としてconst format = rawFormat || 'global'をフォールバックとして記述している。📎 scripts/dev.js:42これは防御的な記述であり、parseArgsの動作変更や明示的に空文字列が渡された場合に下流のformat.startsWithがエラーを投げるのを避けるためである。
formatesbuildの出力フォーマットへのマッピングは3分岐である:globalで始まる場合はiifeにマッピングされ、cjsと等しい場合はcjsにマッピングされ、それ以外はすべてesm。📎 scripts/dev.js:42-53成果物のファイル名サフィックスは-runtimeサフィックスで個別に処理される:global-runtimeはruntime.globalに変換され、それ以外はそのまま維持される。📎 scripts/dev.js:42-53
ターゲットパッケージの特定と出力パス
スクリプトはまずpackages-privateディレクトリ一覧を読み取り、ターゲットパッケージが公開パッケージかプライベートパッケージかを判断する。📎 scripts/dev.js:56各targetについて、パッケージのベースパスをpackagesにするかpackages-privateにするかを決定し、次にrequireそのpackage.jsonを取得してversionとbuildOptions。📎 scripts/dev.js:58-63
出力ファイル名には特殊なケースがある:vue-compatターゲットはvueにリネームされ、成果物がvue-compat.global.js。📎 scripts/dev.js:64-69と呼ばれるのを避ける。最終パスはpackages/vue/dist/vue.global.js,prodが真の場合にprod.セグメントを挿入する。
external解析:依存関係を成果物にバンドルしない
external配列はどのモジュールをバンドルしないかを決定する。ロジックは2層に分かれる:
第1層では、inlineが有効でなく、フォーマットがcjsまたはesm-bundlerを含む場合、dependencies、peerDependenciesのキーをすべてexternalに追加し、ハードコードでpath、url、streamの3つのNode組み込みモジュールを指定する。📎 scripts/dev.js:76-88コメントには、これら3つが@vue/compiler-sfcとserver-rendererのために用意されていることが明記されている。
第2層では、compiler-sfcターゲットに対して、追加で@vue/consolidateのdevDependenciesを解析し、それらおよびfs、vm、cryptoなどをまとめてexternalにする。📎 scripts/dev.js:90-112コードにはさらにreact-dom/server、teacup/lib/express、arc-templates/dist/es5、then-pug、then-jadeなどのテンプレートエンジンパスがハードコードされている——これらはconsolidateがサポートするテンプレートエンジンであり、オプション依存であるため、強制インストールはできない。
このロジックはrollup.config.jsと高度に重複しており、ソースコードのコメントもこの点を認めている(TODO this logic is largely duplicated from rollup.config.js)。共通関数として抽出しなかった理由は、devとprodのexternal戦略に微妙な差異があるためである(devはより積極的にexternal化してビルドを高速化する)。無理に統一するとかえって結合度が増す。
プラグインとdefine注入
プラグイン配列のデフォルトはlog-rebuildが1つだけで、onEndフック内でビルド成果物の相対パスを出力する。📎 scripts/dev.js:115-124これは開発者が「変更が反映された」ことを感知する唯一のフィードバック信号である。
2つ目のプラグインは条件付きである:フォーマットがcjsでなく、パッケージのbuildOptions.enableNonBrowserBranchesが真の場合、polyfillNode()。📎 scripts/dev.js:126-128をマウントする。compiler-sfcのようなパッケージ(例:
define)はブラウザビルドでもNode分岐を通るため、ブラウザ環境で動作させるにはNode組み込みモジュールのpolyfillが必要である。📎 scripts/dev.js:141-159ブロックは本章で情報密度が最も高い部分である。__XXX__ソースコード内のすべての
__COMMIT__マクロをリテラルに置換する:"dev",__VERSION__は__DEV__に固定され、prodはパッケージバージョンを取得し、__TEST__はfalse;__BROWSER__フラグで決定され、format !== 'cjs' && !pkg.buildOptions?.enableNonBrowserBranches。📎scripts/dev.js:146-148は常に__SSR__の導出が最も微妙である:format !== 'global'つまり、「cjsでなく、かつパッケージが非ブラウザ分岐をサポートしない」場合のみブラウザ環境としてマークされる;__COMPAT__はvue-compat、すなわちglobalビルドではSSR分岐が有効にならない;- はtargetが
__FEATURE_SUSPENSE__、__FEATURE_OPTIONS_API__、__FEATURE_PROD_DEVTOOLS__、__FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__かどうかで決定される;
3つのfeature flag(vitest.config.ts)はdevモードですべてハードコードされる。defineこれらのマクロは📎 vitest.config.ts:6-21の__TEST__ブロックと一対一で対応する。true、__DEV__テスト環境ではtrueを
に設定し、
をesbuild.context(...).then(ctx => ctx.watch())。📎 scripts/dev.js:130-161 contextに設定する。devビルドとの差異こそが「テスト vs 開発」という2つの実行状態の区別点である。watch()watchモードの起動onEnd最後のステップは
flowchart TD
start["parseArgs 解析 format/prod/inline"] --> targets{"positionals 为空?"}
targets -->|是| def["targets = ['vue']"]
targets -->|否| use["targets = positionals"]
def --> loop["遍历每个 target"]
use --> loop
loop --> priv{"target 在 packages-private?"}
priv -->|是| pbase["pkgBase = packages-private"]
priv -->|否| pub["pkgBase = packages"]
pbase --> req["require package.json"]
pub --> req
req --> ext{"inline 开启?"}
ext -->|是| noext["external = []"]
ext -->|否| fmt{"format 是 cjs 或 esm-bundler?"}
fmt -->|是| deps["加入 dependencies/peerDependencies + path/url/stream"]
fmt -->|否| sfc{"target == compiler-sfc?"}
deps --> sfc
sfc -->|是| cons["加入 consolidate devDeps + fs/vm/crypto"]
sfc -->|否| noext
cons --> ctx["esbuild.context 创建上下文"]
noext --> ctx
ctx --> watch["ctx.watch() 启动监听"]
watch --> onend["onEnd 打印 built: 相对路径"]で初めてファイル監視を実際に開始する。以降esbuild内部が依存グラフを維持し、依存ファイルの変更があればインクリメンタルリビルドがトリガーされ、リビルド完了コールバック
がログを出力する。
コピーcompiler-sfc3.2 pre-dev-sfc.js:循環依存を打破するプリコンパイルセンチネルcompiler-core直感的モデルcompiler-core「鶏が先か卵が先か」のジレンマを想像してほしい:compiler-sfcのソースコードは.vueをimportしており、一方pre-dev-sfc.jsは開発時に
を必要として
ファイルを処理する。両方がesbuild watchでリアルタイムコンパイルされる場合、先にコンパイルする方がデッドロックする。compiler-sfc、compiler-core、compiler-dom、compiler-ssr、shared。📎 scripts/pre-dev-sfc.js:4-10の役割は「先に卵を孵し、それから鶏を育てる」こと——メインビルド開始前に、これらのパッケージのCJS成果物がすでに存在することを保証する。packages/${pkg}/dist/${pkg}.cjs.jsチェックリストとショートサーキットロジック📎 scripts/pre-dev-sfc.js:4-23
スクリプトは固定リストを維持する:allFilesPresent各パッケージについて、falseが存在するかチェックする。break1つでも欠けていれば、📎 scripts/pre-dev-sfc.js:20-21をallFilesPresentに設定し、直ちにprocess.exit(1)して残りのパッケージをチェックしない。📎 scripts/pre-dev-sfc.js:25-27
最後に
が偽の場合、exit(1)は非ゼロコードで終了する。&&終了コードのセマンティクス
flowchart TD
start["遍历 packagesToCheck 清单"] --> check{"dist/pkg.cjs.js 存在?"}
check -->|是| next{"还有下一个包?"}
next -->|是| check
next -->|否| ok["allFilesPresent 保持 true"]
check -->|否| fail["allFilesPresent = false 并 break"]
ok --> exit0["正常退出 退出码 0"]
fail --> exit1["process.exit(1) 退出码 1"]は上位の呼び出し元(通常はnpm scriptの
scripts/dev.jsチェーンやCIスクリプト)へのシグナルである:成果物が不完全であり、先に完全なビルドを実行する必要がある。すべて存在すれば正常終了(終了コード0)し、メインビルドが続行される。scripts/aliases.jsコピー📎 scripts/aliases.js:7-7
3.3 aliases.jsとvitest.config.ts:開発態リンクのもう半分
resolveEntryForPkgは「成果物をどう素早く生成するか」を解決するが、開発時にはもう1つのパスがある:テストを実行することである。packages/${p}/src/index.ts。📎 scripts/aliases.js:7-7はvitestとrollupに共有のパスエイリアスを提供する。vue、vue/compiler-sfc、vue/server-renderer、@vue/compat。📎 scripts/aliases.js:16-21
エイリアス生成ロジックpackagesはパッケージ名をvueにマッピングする。ベースentriesには4つの特殊マッピングがハードコードされている:nonSrcPackages(sfc-playground、template-explorer、dts-testその後@vue/${dir}ディレクトリ下のすべてのサブディレクトリを走査し、📎 scripts/aliases.js:23-35
をスキップし)、既存のkeyをスキップし、かつディレクトリでなければならないもののみnonSrcPackages除外リストは、これら3つのパッケージにsrc/index.tsエントリーポイントがなく、強制的にマッピングすると解析が失敗するためです。
vitest の define とエイリアスの消費
vitest.config.ts直接 importentriesとしてresolve.alias。📎 vitest.config.ts:3📎 vitest.config.ts:22-24そのdefineブロックと dev.js のマクロ注入が対照を形成:テスト環境__DEV__: true、__TEST__: true、__BROWSER__: false、__CJS__: true。📎 vitest.config.ts:6-21
テストは5つの project に分割されています:unit、unit-gc、unit-jsdom、e2e、e2e-browser。📎 vitest.config.ts:51-118そのうちunit-gcを使用しpool: 'forks'かつ渡す--expose-gc、手動で GC をトリガーする必要がある SSR テストを専門に実行します。📎 vitest.config.ts:65-76 e2e-browserは playwright の chromium インスタンスを有効にし、Transition 関連のテストを実行します。📎 vitest.config.ts:99-117
sequenceDiagram
participant Dev as 开发者
participant NPM as npm script
participant Pre as pre-dev-sfc.js
participant DevJS as dev.js
participant ESB as esbuild context
participant FS as 文件系统
Dev->>NPM: 启动开发
NPM->>Pre: 检查 SFC 产物
Pre->>FS: existsSync(dist/*.cjs.js)
alt 产物缺失
FS-->>Pre: false
Pre-->>NPM: exit(1)
NPM-->>Dev: 提示先跑完整构建
else 产物齐全
FS-->>Pre: true
Pre-->>NPM: exit(0)
NPM->>DevJS: 启动 dev.js
DevJS->>ESB: context(...).watch()
ESB->>FS: 监听源码变化
Dev->>FS: 修改 src/index.ts
FS-->>ESB: 文件变更事件
ESB->>ESB: 增量重建
ESB-->>Dev: onEnd 打印 built: 路径
end設計上の考察
なぜ dev は esbuild を使い、prod は Rollup を使うのか?これは技術選定の気まぐれではなく、2つのシナリオの制約が異なるためです。開発時は成果物のサイズに敏感ではなく、フィードバック遅延に極度に敏感です。本番時はその逆です。esbuild は Go で書かれ、並列化の度合いが高く、コールドスタートとインクリメンタルビルドが一桁速いですが、Tree-shaking とコード分割の能力は Rollup より劣ります。📎 scripts/dev.js:3-52つのツールをそれぞれのシナリオに使い分けるのは、エンジニアリング上の実用的なトレードオフです。
pre-dev-sfc はなぜチェックのみでコンパイルしないのか?もしそれ自体がコンパイルをトリガーすると、循環依存を再び引き込んでしまいます——それはcompiler-sfcをコンパイルする必要があり、コンパイルプロセス自体がcompiler-sfcの成果物に依存する可能性があります。したがって、それは「アサーション」のみを行い、「成果物の欠如」という事実を上位層に公開し、上位層が完全なビルドを実行するかエラーで終了するかを決定します。これは「センチネルパターン」です:問題を解決せず、問題を報告するだけです。
external リストの重複は技術的負債か?dev.js と rollup.config.js の external ロジックは重複しており、ソースコードのコメントもそれを認めています。📎 scripts/dev.js:73しかし両者の external 集合は完全には一致していません——dev は速度のために、より積極的に external 化します。無理に共通関数を抽出すると、パラメータ化された差異スイッチを導入する必要があり、かえって両方のロジックが読みにくくなります。これは「重複は誤った抽象化に優る」の典型的なトレードオフです。
本章のまとめ
本章では Vue core の開発時チェーンの3つのピースを分解しました:
1. scripts/dev.js:esbuild のcontext().watch()でインクリメンタルビルドを実現し、parseArgsでフォーマットとフラグを解析し、動的にrequireターゲットパッケージpackage.jsonで出力パスを特定し、__DEV__、__BROWSER__などのマクロを注入して条件付きコンパイルを制御し、log-rebuildプラグインで毎回の再ビルド後にフィードバックを出力します。
2. scripts/pre-dev-sfc.js:メインビルド前に5つのコアパッケージの CJS 成果物が存在するかをチェックし、欠如していれば終了コード1でショートサーキットし、循環依存によるビルドデッドロックを回避します。
3. scripts/aliases.js + vitest.config.ts:テストチェーンに共有パスエイリアスを提供し、特殊項目をハードコードしつつ汎用項目を動的スキャンし、マルチ project 設定でユニット、GC、jsdom、e2e、ブラウザ e2e の5つのテストシナリオをカバーします。
本章の考察とセルフチェック
Q1: もしscripts/pre-dev-sfc.jsのbreakを削除した場合(つまり全パッケージをチェックしてから終了を決定する)、どのようなシナリオで開発者体験が悪化するか?なぜソースコードの作者は「最初の欠如を発見したらショートサーキット」を選んだのか?
参考解析:
📎 scripts/pre-dev-sfc.js:4-23
breakはif (!fs.existsSync(...))分岐内にあり、あるパッケージの成果物が欠如しているのを発見すると即座にループを抜けます。
もしbreakを削除すると、スクリプトは残りのパッケージのチェックを続け、最終的にallFilesPresentは依然としてfalseとなり、終了コードも依然として1で、機能的には等価です。しかし差異は以下にあります:
1. パフォーマンス:5つのexistsSync呼び出し自体は高速ですが、リストが数十のパッケージに拡張されると、ショートサーキットは大量の無駄な stat システムコールを節約できます。
2. セマンティクス:ショートサーキットが表現するのは「1つでも欠如していれば、全体が不完全である」——これはブールアサーションであり、具体的にいくつ欠如しているかを知る必要はありません。チェックを続けても追加情報は生まれません。
3. 開発者体験:実際に悪化するのは「エラーメッセージ」です。現在のスクリプトはどのパッケージが欠如しているかを出力せず、開発者は終了コード1だけを見ます。もしbreakを削除してログを追加すれば、かえって開発者に「compiler-core と shared が欠如している」と伝えられます——しかしこれには追加コードが必要です。作者は最も簡素な実装を選び、「どれが欠如しているか」の診断を上位ビルドスクリプトのエラーに委ねています。
したがってbreakの核心的な動機は「アサーションのセマンティクス + パフォーマンス」であり、体験の最適化ではありません。
Q2: scripts/dev.jsにおける__BROWSER__の導出はformat !== 'cjs' && !pkg.buildOptions?.enableNonBrowserBranchesです。あるパッケージのbuildOptions.enableNonBrowserBranchesがtrueであり、開発者が-f globalでビルドすると仮定すると、このとき__BROWSER__はfalseとなります。これはどのような結果を引き起こすか?もし誤ってtrueに変更するとどうなるか?
参考解析:
📎 scripts/dev.js:146-148
かつformat = 'global'のとき:enableNonBrowserBranches = trueは
format !== 'cjs'はtrue!pkg.buildOptions?.enableNonBrowserBranches全体false- これは、ソースコード内のすべての
__BROWSER__ = false
分岐が esbuild の define によってif (__BROWSER__)に置換され、ブラウザ専用コードが Tree-shaking で除去され、非ブラウザ分岐(Node 専用ロジック)が保持されることを意味します。if (false)結果
:global ビルド成果物は本来ブラウザで動作すべきですが、Node 専用分岐を含んでいます。もしこれらの分岐がなどの Node 組み込みモジュールを参照していると、ブラウザでの読み込み時に「モジュールが未定義」とエラーになります。これこそがfs、pathが真であるパッケージ(enableNonBrowserBranchesなど)が通常 global ビルドに使用されない理由、あるいはcompiler-sfcプラグインでのフォールバックが必要な理由です。polyfillNode()もし誤って📎 scripts/dev.js:126-128
に変更すると、ブラウザ分岐が保持され、Node 分岐が除去されます。true:__BROWSER__ = trueのように Node 環境で SFC コンパイルを実行する必要があるパッケージでは、コア機能(ファイル読み取り、Node API 呼び出し)が Tree-shaking で除去され、成果物が Node で実行時に「関数が未定義」とエラーになります。compiler-sfcにおいて、動的スキャンで
Q3: scripts/aliases.jsディレクトリをスキャンする際にpackagesをスキップしました)。もし新しいパッケージがnonSrcPackages(sfc-playground、template-explorer、dts-testディレクトリに追加されたがpackagesがなければsrc/index.ts、かつ に追加されていないnonSrcPackages、何が起こるか?vitest の実行時、どの段階でエラーが発生するか?
参考解析:
📎 scripts/aliases.js:23-35
動的スキャンロジックは次の通り:各ディレクトリについて、もしdir !== 'vue'、 に存在せずnonSrcPackages、key が存在せず、かつディレクトリであれば、entries['@vue/${dir}'] = resolveEntryForPkg(dir)。
resolveEntryForPkgが返すのはpackages/${p}/src/index.tsのパスである。📎 scripts/aliases.js:7-7注意すべき点として、それファイルの存在をチェックしない、単にパスを結合するだけである。
結果:エイリアスは登録されるが、存在しないファイルを指す。vitest が import を解決する際、あるテストファイルがこのパッケージを import すると、Vite の resolve プラグインがそのパスを読み込もうとし、「モジュールを解決できない」または「ファイルが存在しない」というエラーを報告する。
エラー発生段階: の実行時ではなく(それは文字列結合のみを行う)、vitest 起動後、初めてその import を解決する時である。どのテストもこのパッケージを import しなければ、エラーは発生しない——エイリアスはただaliases.jsオブジェクトの中に横たわっているだけである。entries回避方法
:このようなを持たないパッケージをsrc/index.tsに追加するか、新しいパッケージに標準的なエントリポイントがあることを保証する。これがnonSrcPackagesを手動で維持する必要がある理由でもある——それは「設定より規約」の例外リストである。nonSrcPackages三者協調の境界は非常に明確である:
は「成果物が準備できているか」を管理し、pre-dev-sfcは「成果物をいかに迅速に更新するか」を管理し、dev.jsは「テストがいかにソースコードを解決するか」を管理する。開発時リンクは速度問題を解決したが、ビルド期にはさらに別の、より隠れた最適化がある——コードがブラウザで実行される前に完了する変換である。次の章ではコンパイル期の魔法に入り、enum のインライン化と Tree-shaking 検証メカニズムが、ビルド期にどのように TypeScript enum をリテラルに置き換え、オンデマンド import の約束が破られないことを保証するかを見る。aliases← 前の章:第 2 章
この章を読み終えましたか?ご自身のプライベートリポジトリを技術書へ
Tauri 2 + Rust によるローカルファースト設計。100% オフラインの安全性、コードのクラウド送信は一切ありません。不変コミットアンカーで精読。
⚡ Tauri 2 · Rust コア · 100% 完全オフライン · 100万行超のコードベース検証済
所属プロジェクト:vuejs/core
4.1 enum インライン化:実行時オブジェクトをリテラルに溶解する
直感モデル
あなたがレシピを書いたと想像してほしい。そこには「塩少々」が繰り返し現れる。毎回料理するたびに付録を開いて「少々 = 3 グラム」を調べるのは、遅くて場所も取る。enum インライン化が行うのは、印刷前に全書の「塩少々」を直接「塩 3 グラム」に置き換え、その後付録のページを破り捨てることである。読者(実行時)にとって結果は全く同じだが、本は薄くなる。
もしそれがなければ、システムはどのような災難に直面するか?TypeScript の通常の
はコンパイル後に実在するオブジェクトリテラルを生成し、双方向マッピング(enum)を持つ。このオブジェクトはEnum[Enum.A] === 'A'副作用のあるモジュールレベル宣言であり、Rollup はそれが未使用であることを証明できないため、保持せざるを得ない——たとえそのうちの一つのメンバーだけを import しても、enum オブジェクト全体と逆マッピングが成果物に詰め込まれる。のコメントは率直に述べている:彼らはかつて📎 scripts/inline-enums.js:3-9を使ったが、issue #1228 のために通常の enum に切り替え、そこでこのスクリプトを使って「const enum のゼロコストの利点を手動で取り戻した」。const enumデータ構造とメモリレイアウト
スクリプトの核心は三つの型定義であり、それらを理解すればデータフロー全体を理解できる。
、単一の enum メンバーの名前と評価後のリテラル。📎 scripts/inline-enums.js:33-36
EnumMember:{ name, value }はEnumDeclaration:{ id, range: [start, end], members }。rangeソースコードのバイトオフセットであり、の宣言全体のファイル内での開始位置と終了位置を指す——これは後続の MagicString による正確な置換のアンカーである。export enum X { ... }はファイルパスでインデックスされ、そのファイル内のすべての enum 宣言の置換範囲を記録する;EnumData:{ declarations, defines }。declarationsはフラットマッピングであり、キーは `defines${enum名}.${メンバー名}JSON.stringify` 後のリテラルである。形式的字符串,值是ここに重要な設計がある:
のキーdefinesはファイルパスを含まないコメントが理由を説明している——。📎 scripts/inline-enums.js:98-103はErrorCodesと@vue/compiler-coreに同時に存在できるため、同名の enum がファイルを跨いで存在することを許可する;しかし同じ@vue/runtime-coreは二つの同名 enum 内で重複を許さない。そうでなければErrorCodes.__EXTEND_POINT__がヒットし、直接fullKey in definesをスローする。これは「メンバー名によるグローバル一意」の制約であり、「enum 名によるグローバル一意」ではない。name conflictキャッシュは
に置かれる。なぜディスクに書き込む必要があるか?なぜならtemp/enum.json。📎 scripts/inline-enums.js:33-36はビルドエントリで一度だけ呼ばれ、Rollup は各パッケージ、各フォーマットごとにscanEnums()独立したプロセスを起動するからである。コメントが指摘する:データは並行する Rollup プロセス間で共有される必要があるため、ディスクにシリアライズし、各プロセスの。📎 scripts/inline-enums.js:39-41が読み戻さなければならない。inlineEnums()Step-by-Step:grep からリテラル置換まで
第一步:
を含むすべてのファイルを grep する。export enumは📎 scripts/inline-enums.js:51-61を使い、出力はspawnSync('git', ['grep', 'export enum'])の形で、次にpath:line:contentで最初の部分(ファイルパス)を切り出し、:で重複を除去する。ここで使っているのはSet 去重。注意这里用的是 git grepファイルシステムを走査するのではなく——Gitに追跡されているファイルだけを自然にスキャンし、自動的に除外するnode_modulesとビルド成果物。
第二步:Babelが解析し、列挙情報を収集する。📎 scripts/inline-enums.js:64-70各ファイルに対して@babel/parserをtypescriptプラグイン、sourceType: 'module'でASTに解析し、その後ast.program.bodyのトップレベルノードのみを走査する。📎 scripts/inline-enums.js:74-79ExportNamedDeclarationかつそのdeclaration.type === 'TSEnumDeclaration'のノードのみを認識する——つまり、エクスポートされていないenumは処理されない。
各列挙宣言について、スクリプトはメンバーを1つずつ評価する。メンバー評価は3つのパスに分かれる:
1. リテラル初期化:StringLiteralまたはNumericLiteral直接init.value。📎 scripts/inline-enums.js:114-119
2. 二項式:例えば1 << 2。再帰的にresolveValue左右のオペランドを処理し、オペランドはリテラルでもよく、MemberExpression(つまり以前に定義された列挙メンバーへの参照)でもよい。📎 scripts/inline-enums.js:121-151鍵となるのはMemberExpression分岐である:それはcontent.slice(node.start, node.end)を用いて元のソーステキストから式文字列(例えばErrorCodes.FOO)を切り出し、次にdefinesを調べる。見つからなければunhandled enum initialization expression。📎 scripts/inline-enums.js:132-141をスローする。これがdefinesがグローバルなフラットマッピングでなければならない理由を説明する——列挙をまたぐ参照では、参照される側が別のファイル由来かもしれないが、キーは枚举名.成员名。
3. 単項式:例えば-1、-1文字列に組み立ててからevaluateで評価する。📎 scripts/inline-enums.js:152-163
評価自体はnew Function('return ' + exp)()。📎 scripts/inline-enums.js:39-41を用いる。これは制御されたevalである:入力はソース内ですでに解析されたAST断片から来ており、任意のユーザー入力ではないため、安全境界は制御可能である。
第三步:初期化子のないメンバーを処理する(自動インクリメントのセマンティクス)。📎 scripts/inline-enums.js:171-183メンバーにinitializerがない場合:最初のメンバーはデフォルトで0;後続のメンバーはlastInitializedが数値なら++;文字列ならwrong enum initialization sequenceをスローする——文字列列挙メンバーは暗黙の自動インクリメントを許可しないためである。これはまさにTypeScriptのenumのセマンティクスである。
第四步:キャッシュを書き込み、クリーンアップ関数を返す。📎 scripts/inline-enums.js:200-213 scanEnums()クロージャを返し、呼び出すとrmSyncキャッシュファイルを削除する。build.jsでそれを使用する。try/finally📎 scripts/build.js:81-112これにより、ビルドの途中でエラーがスローされてもキャッシュがクリーンアップされ、次のビルドを汚染しないことが保証される。
第五步:Rollup transform段階での置換。 inlineEnums()キャッシュを読み戻し、Rollupプラグインを構築する。📎 scripts/inline-enums.js:219-234において、transform(code, id)がidにヒットした場合、enumData.declarationsMagicStringを使って[start, end]この宣言部分をオブジェクトリテラルに置換する。📎 scripts/inline-enums.js:242-274
置換後の形態はexport const X = { ... }である。注意すべきは、それは単にenumを削除するのではなく、オブジェクトリテラルに書き換え、さらに数値メンバーに対して追加で逆マッピングを生成する:JSON.stringify(value.toString()) + ': ' + JSON.stringify(name)。📎 scripts/inline-enums.js:257-270コメントはTypeScript公式ドキュメントのreverse-mappingsルールを引用している:文字列列挙メンバーは逆マッピングを生成せず、数値メンバーは生成する。これにより、置換後の実行時動作が元のenumと完全に一致することが保証される。
そして実行時オーバーヘッドを真に排除するのは、definesが@rollup/plugin-replace。📎 rollup.config.js:222-223に渡されることである。X.Memberへのすべての参照は置換プラグイン内で直接リテラルに置き換えられるため、書き換えられたオブジェクトリテラルが誰にも使われなければ、Tree-shakingで振り落とすことができる。
以下のフローチャートは、grepから置換までの完全な意思決定パスを描いている:
flowchart TD
grep["spawnSync git grep 'export enum'"] --> files["去重得到文件列表"]
files --> parse["@babel/parser 解析 AST"]
parse --> check{"顶层节点是<br/>ExportNamedDeclaration<br/>且 declaration 为 TSEnumDeclaration?"}
check -->|否| skip["跳过该节点"]
check -->|是| dup{"enumIds 已含该 id?"}
dup -->|是| err1["throw 不支持声明合并"]
dup -->|否| member["遍历 members 求值"]
member --> init{"有 initializer?"}
init -->|有| eval["字面量/二元/一元求值"]
init -->|无| auto["lastInitialized 自增或默认 0"]
eval --> conflict{"fullKey 已在 defines?"}
auto --> conflict
conflict -->|是| err2["throw name conflict"]
conflict -->|否| save["saveValue 写入 members 与 defines"]
save --> cache["writeFileSync temp/enum.json"]
cache --> transform["Rollup transform: MagicString 重写声明"]
transform --> replace["plugin-replace 用 defines 替换引用"]設計上の考察と落とし穴
なぜファイル全体を再生成せずMagicStringを使うのか?なぜならs.update(start, end, ...)は列挙宣言のその部分だけを置換し、残りのソースバイトは完全に変更しないため、s.generateMap()正確なsourcemapも生成できる。📎 scripts/inline-enums.js:277-281BabelでAST全体を再出力すると、元のフォーマットやコメントが失われ、sourcemapの品質も低下する。
rangeなぜnode.start/node.endではなくdeclaration.start?📎 scripts/inline-enums.js:189-193なのかnode.startがアサートするのはExportNamedDeclaration(つまりexport enum X {...}ノード)であり、置換範囲はexport全体をカバーし、export constキーワードも含む。置換テキストは
で始まり、ちょうど接続する。defines落とし穴:のグローバル一意性制約。ErrorCodesもし2つの異なるファイルにそれぞれ__EXTEND_POINT__があり、どちらも📎 scripts/inline-enums.js:101-103を定義している場合、ビルドは直接失敗する。definesこれはバグではなく、意図的な設計である——なぜなら
はグローバル置換テーブルであり、ファイルの出所を区別できないためである。本番環境で新しい列挙メンバーを追加する際、名前が既存の列挙メンバーと衝突すると、ここで爆発する。new Function落とし穴:の評価タイミング。scanEnums二項式の評価はdefines段階で発生し、この時点で📎 scripts/inline-enums.js:136-140内にまだ参照されたメンバーがない可能性がある(参照順序が逆の場合)。unhandled enum initialization expressionは
をスローする。これは、列挙メンバーの参照が「先に定義、後に参照」というソース順序に従わなければならないことを要求する。
4.2 Tree-shaking検証:成果物文字列で逆に約束を証明する
直感的モデルverify-treeshaking.js列挙のインライン化は「事前最適化」だが、最適化が本当に効いているか? あるhelperが書き方の不備で誤って保持されると、サイズが静かに膨張し、開発者はまったく気づかない。がその「事後品質検査員」である:それは成果物をビルドし、検死のように成果物内に出現すべきでないものが出現していないか
を検査する。それがなければ、Vueのオンデマンド導入の約束はあるリファクタリング後に音もなく破られ、ユーザーがパッケージが大きくなったと不満を言うまで発見されないかもしれない。
データ構造と検査項目errorsこのスクリプトに複雑なデータ構造はなく、核心はincludes配列と3回の📎 scripts/verify-treeshaking.js:6-6チェックである。global-runtimeそれはまず
フォーマットをビルドし、その後devとprodの成果物をそれぞれ読み取る。
1. 3つの検査項目は3種類の「Tree-shaking失敗」に対応する:__spreadValues。📎 scripts/verify-treeshaking.js:13-19dev成果物に{ ...obj }が含まれるextendこれはesbuildが
2. オブジェクトスプレッド構文のために生成するhelperである。これが出現する場合、実行時コードでオブジェクトスプレッドが使われており、Vueの規約ではVue warn。📎 scripts/verify-treeshaking.js:26-31helperに変更して余分なコードを避けるべきであることを示す。warn()prod成果物に__DEV__が含まれる
3. これは。📎 scripts/verify-treeshaking.js:33-42呼び出しがhtml,body,base、svg,animate,animateMotion、annotation,annotation-xml,maction条件で包まれておらず、警告コードが本番バンドルに漏れていることを示す。isHTMLTag()などの helper 内部のデータは、本来コンパイラにのみ存在し、ランタイムによって除去されるべきものです。もしランタイム成果物に現れるなら、ランタイムパスがコンパイラ専用の helper を誤って使用していることを示します。
Step-by-Step:検証フロー
📎 scripts/verify-treeshaking.js:5-5まずexec('pnpm', ['build', 'vue', '-f', 'global-runtime'])のみをビルドし、vueパッケージのglobal-runtimeフォーマット——これは最小化されたランタイム成果物であり、リークを露呈するのに最適です。ビルド完了後、2つのファイルを同期的に読み取り、1つずつincludesチェックし、ヒットしたらerrorsに説明付きのメッセージを push します。最後にerrors.lengthが非ゼロであれば、集約エラーをスローします。📎 scripts/verify-treeshaking.js:44-48
flowchart TD
build["exec pnpm build vue -f global-runtime"] --> readDev["读取 vue.runtime.global.js"]
readDev --> c1{"dev 含 __spreadValues?"}
c1 -->|是| e1["push: 应改用 extend helper"]
c1 -->|否| readProd["读取 vue.runtime.global.prod.js"]
e1 --> readProd
readProd --> c2{"prod 含 'Vue warn'?"}
c2 -->|是| e2["push: warn 未被 __DEV__ 包裹"]
c2 -->|否| c3{"prod 含 DOM tag 配置?"}
e2 --> c3
c3 -->|是| e3["push: 编译器 helper 泄漏到运行时"]
c3 -->|否| done{"errors 为空?"}
e3 --> done
done -->|是| pass["验证通过"]
done -->|否| fail["throw 聚合错误"]設計上の考察と落とし穴
なぜ文字列includesを使い、AST 分析を使わないのか?これは「センチネルチェック」であり「精密分析」ではないからです。完全性を追求せず、歴史的に実際に発生した3種類のリグレッションに対して低コストのアラートを設定するだけです。文字列マッチングはゼロ依存、ゼロ解析オーバーヘッドで、圧縮後の成果物にも同様に有効です——AST 分析は minify 後にはむしろ難しくなります。
なぜglobal-runtime?のみを検証するのか。このフォーマットはすべての依存をインライン化し(externalが空)、サイズに最も敏感で、誤って導入されやすい成果物です。これがクリーンであれば、他のフォーマットも通常クリーンです。同時にビルドが速く、CI に頻繁に組み込むのに適しています。
落とし穴:チェック項目は「ブラックリスト」であり、コードの進化に伴い無効化します。もしある日isHTMLTagのデータ構造が変更され、html,body,baseこの文字列が出現しなくなれば、チェックは形骸化します。これはメンテナが関連する helper を変更する際に、ここのセンチネル文字列を同期して更新することを要求します。これはブラックリスト式検証の固有のコストです。
4.3 Rollup との協調:プラグイン順序と define 注入
列挙型のインライン化は孤立して動作するのではなく、Rollup のプラグインパイプラインに組み込まれています。パイプライン内での位置を理解して初めて、なぜdefinesをreplaceに委ねるのか、esbuild。
📎 rollup.config.js:47-50ではなくinlineEnums()設定モジュールのトップレベルで[enumPlugin, enumDefines]を呼び出し、を分割代入するのかが理解できます。これは各 Rollup プロセスの起動時scanEnumsに実行され、
が書き込んだキャッシュを読み取ることに注意してください。json → alias → enumPlugin → ...resolveReplace() → esbuild。📎 rollup.config.js:324-339 enumPluginプラグイン配列の順序は:replaceはreplaceより前に配置され、列挙型宣言の書き換えが先に発生し、その後definesがesbuildを使用して参照を置換します。そして
は最後に配置され、TS トランスパイルを担当します。definesなぜreplaceがesbuildを経由せずdefine?📎 rollup.config.js:220-221を経由するのかErrorCodes.__EXTEND_POINT__のコメントが答えを与えます:esbuild の define は「やや厳格で、リテラル JSON または識別子のみを許可する」。一方、列挙型メンバー名のような@rollup/plugin-replaceはドット付きのメンバー式であり、esbuild の define はこのようなキーを直接処理できません。したがって📎 rollup.config.js:250-251を使用する必要があり、これは任意の文字列キーの置換をサポートします。preventAssignment: trueかつ
resolveReplace()を設定し、代入文の左辺も置換されるのを避けます。const replacements = { ...enumDefines }内の📎 rollup.config.js:222-223が最初のステップです。/*@__PURE__*/その後で初めて本番環境の__DEV__アノテーション、
などの置換が重ねられます。この順序により、列挙型リテラルの置換が常に有効になります。
設計上の考察列挙型インライン化の本質は「ビルド期の複雑さでランタイムサイズを交換する」ことです。scanEnumsこれは TypeScript の型システムセマンティクス(列挙型評価、自動インクリメント、逆マッピング)をビルド期に完全に再現します——📎 scripts/inline-enums.js:110-183内の評価ロジックはほぼ TS コンパイラの列挙型評価のサブセットです。unhandledこれはメンテナンスコストをもたらします:TS が新しい列挙型構文(より複雑な定数式など)を追加した場合、ここも追随しなければならず、そうでなければ
〔設計推論とアーキテクチャトレードオフ〕検証スクリプトとインラインスクリプトは「約束と履行」のペアです。
インラインスクリプトは「列挙型がランタイムサイズを占めない」ことを約束し、検証スクリプトは「他のコードも密かにサイズを占めていない」ことをチェックします。両者が共同で Vue のサイズ予算を守ります。この「最適化 + 検証」のペア設計は、大規模フロントエンドライブラリのエンジニアリングにおける典型的なパターンです:あらゆる最適化にはリグレッションを防ぐ自動チェックが必要です。 scanEnumsプロセス間キャッシュは並行ビルドの必需品です。inlineEnums単回実行、📎 scripts/inline-enums.js:39-41複数回読み取りのパターンは、
「1回のスキャン、N プロセスの消費」問題を解決します。キャッシュがなければ、各 Rollup プロセスが再 grep + 解析を行い、大量の IO と CPU を浪費します。
本章のまとめ
本章の考察とセルフチェックscanEnumsQ1: もしsaveValue内のif (fullKey in defines)の
衝突チェックを削除した場合、どのようなシナリオでビルド成果物にエラーが発生しますか?:
defines参考解析枚举名.成员名はグローバルフラットマッピングであり、キーは📎 scripts/inline-enums.js:98-103で、ファイルパスを含みません。@vue/compiler-core衝突チェックを削除した後、2つの異なるファイルがそれぞれ同名の列挙型を持ち、同名のメンバーを定義している場合(例えば@vue/runtime-coreとErrorCodes.__EXTEND_POINT__の両方に
がある)、後から書き込んだ者が先に書き込んだ者を上書きします。defines['ErrorCodes.__EXTEND_POINT__']結果:plugin-replaceには1つの値しか残らず、は置換時にファイルソースを区別できず、すべてのErrorCodes.__EXTEND_POINT__ファイル内の📎 rollup.config.js:222-223を同じ値に置換します。
その結果、一方のパッケージの列挙型メンバー値が静かに改ざんされ、ランタイム動作が誤りとなり、しかも極めて特定困難です——ソースコードは完全に正しく見えるからです。📎 scripts/inline-enums.js:98-100これこそがコメントが強調する「同名列挙型のファイル間跨ぎは許可するが、同名メンバーは許可しない」理由です。
衝突チェックはグローバル置換テーブルが汚染されるのを防ぐ門番です。rollup.config.jsQ2: もしenumPlugin内のプラグイン配列で...resolveReplace()と
の順序を入れ替えた場合、何が起こりますか?:
参考解析enumPlugin現在の順序はreplaceが前、📎 rollup.config.js:331-332が後です。transformRollup の
フックはプラグイン配列の順序で実行されます。replace入れ替えると、export enum X { ... }が先に実行され、この時点で列挙型宣言はまだ元のreplace形態です。definesがX.Memberを使用してenumPlugin参照を置換します——しかしこの時点で参照はまだ存在し、置換は有効です。問題はs.update(start, end, ...)がその後実行される時に発生します:それは📎 scripts/inline-enums.js:250-273を使用して宣言セクションを書き換えます。replaceしかしcodeはすでにenumPluginを変更しており、code 是 replaceの出力は、そのバイトオフセットがすでにscanEnumsに記録されたrange(元のソースコードに基づく)と対応しなくなっている。
結果:MagicString は誤ったオフセットで切断し、生成物の構文が乱れる。これはプラグインパイプラインの暗黙の契約を明らかにする:ソースコードのオフセットに基づく変換は最初に実行されなければならない、その後の変換がその出力上で安全に続行できるように。
Q3: verify-treeshaking.jsは3つの文字列センチネルのみをチェックする。もしあるリファクタリングでisHTMLTagの内部データを'html,body,base'から配列形式['html','body','base']に変更したら、検証スクリプトはどうなるか?これはどのような設計上の欠陥を露呈するか?
参考解析:
検証スクリプトはprodBuild.includes('html,body,base')でチェックする。📎 scripts/verify-treeshaking.js:33-37データが配列に変更されると、圧縮生成物にカンマで連結された文字列が現れなくなり、includesはfalseを返し、チェックは静かに通過する——たとえisHTMLTagが本当にランタイム生成物に漏れていたとしても。
これはブラックリスト方式の文字列検証の固有の欠陥を露呈する:センチネル文字列はソースコードの実装と結合しており、実装が変われば検証は無効になる。それは「未知の漏洩」を検出できず、「既知の、かつ文字列形態が変わっていない漏洩」しか検出できない。
改善方向:より安定した識別子(例えば関数名isHTMLTag)をチェックするように変更するか、ソースコードレベルで lint ルールを用いてランタイムでのコンパイラ helper の import を禁止し、生成物の文字列に依存しないようにできる。しかし現在のコスト制約の下では、文字列センチネルは「十分かつ安価」な妥協案である。
列挙型のインライン化は「ビルド時にランタイムオーバーヘッドをどう排除するか」を解決し、検証スクリプトは「最適化が破壊されていないことをどう確認するか」を解決した。しかしビルド生成物には JS 以外にも、同様にパイプライン加工が必要な生成物の種類がある——型宣言ファイルである。次の章では型生成物パイプラインに入り、Vue がソースコード.d.tsからリリース級の型パッケージをどう生成するか、そしてdts-testが型契約テストで公開 API の型形状をどう守るかを見る。
本章ではコンパイル期の2つの重要なスクリプトを分解した。inline-enums.js は git grep で列挙型を特定し、Babel で AST を解析し、new Function でメンバーを評価し、MagicString で宣言を正確に書き換え、最終的に defines グローバル置換テーブルを通じて列挙型参照をリテラルに変え、列挙型オブジェクトを Tree-shaking で揺り落とせるようにする。verify-treeshaking.js はビルド後に文字列センチネルで生成物をチェックし、3種類の既知の Tree-shaking 漏洩が回帰しないことを保証する。両者は一方が「最適化」を担当し、もう一方が「最適化が破壊されていないことの検証」を担当し、共に Vue のサイズの約束を守っている。次に、コンパイル期から型生成物の生成チェーンへと移り、Vue がソースコードの型とリリース型の厳密な一致をどう保証するかを見る。
この章を読み終えましたか?ご自身のプライベートリポジトリを技術書へ
Tauri 2 + Rust によるローカルファースト設計。100% オフラインの安全性、コードのクラウド送信は一切ありません。不変コミットアンカーで精読。
⚡ Tauri 2 · Rust コア · 100% 完全オフライン · 100万行超のコードベース検証済
第 5 章:型生成物パイプライン:ソースコード .d.ts からリリース級型パッケージへ
前の章ではinline-enums.jsとverify-treeshaking.jsを分解した:一方は enum 参照をリテラルに置換し、列挙型オブジェクトを揺り落とせるようにし、もう一方はビルド後に文字列センチネルで3種類の既知の漏洩が回帰していないことを確認する。両者は共に Vue のランタイムサイズの約束を守っている。しかしビルド生成物は JS だけではない。ユーザーがimport { ref } from 'vue'するとき、エディタが表示する型ヒント、tscによるユーザーコードの型チェックは、すべて別の種類の生成物——.d.ts宣言ファイルに依存している。JS 生成物が間違っていれば実行時にエラーが出る;型生成物が間違っていれば、ユーザー側のコンパイル期にエラーが出るか、さらに悪い場合は:型が静かにドリフトし、ユーザーコードはコンパイルを通るが、型形状が実際のランタイム動作と一致しない。本章では Vue が各サブパッケージsrcに散在するソースコード型をリリース級の型パッケージに集約し、dts-built-testで実際のビルド生成物に対して型スモークテストを行う方法を追跡する。
5.1 二段階型パイプライン:tsc が出料し、rollup が集約
直感モデル
印刷パイプラインを想像しよう:第一段階では、各サブパッケージがそれぞれ自分の原稿(.tsソースコード)を単ページ校正刷り(.d.ts)に組版する;第二段階では、数十枚の校正刷りを目次順に一冊の本(リリース級.d.ts)に製本し、ヘッダーとフッター(エクスポート宣言)を統一する。
もしこのパイプラインがなければ、Vue はリリース型ファイルを手動で維持しなければならず、ソースコードが変われば手で同期して修正する必要がある——これは型ドリフトの温床である。Vue のやり方は:型生成物は完全にソースコードから生成され、決して手書きしない。
第一段階:tsconfig.build.json が出料範囲を画定
tsconfig.build.jsonはこのパイプラインの第一段階の設定である。それはルートtsconfig.jsonを継承し、ビルド関連のオプションのみをカバーする。
📎 tsconfig.build.json:3-9
主要オプションを一つずつ分解:
declaration: true:tsc に各ソースファイルに対応する.d.ts。emitDeclarationOnly: true:を生成させる。型のみを出力し、JS は出力しない。JS は Rollup が担当し、tsc はここでは純粋に型抽出器である。stripInternal: true:@internalと注記された宣言はすべて.d.tsから除外される。これは Vue が公開 API 表面を制御する第一の関門である——内部実装の詳細はexportされていても、@internalが付けられていればリリース型に漏洩しない。composite: false:プロジェクト参照(project references)の増分ビルドモードを無効にする。Vue ではここでクロスパッケージ増分は不要であり、無効にすることで.tsbuildinfoがもたらす追加状態を避けられる。
includeリストはどのディレクトリが出料に参加するかを正確に画定する:
📎 tsconfig.build.json:10-23
ここでは12個のディレクトリのみを列挙していることに注意。全体のpackages/。packages-private/、packages/dts-test/、packages/sfc-playground/などは含まれていない。これは意味する:プライベートパッケージとテストパッケージの型は決してリリース成果物に入る。これは物理的な分離である——約束事ではなく、設定によって実現される。
なぜブラックリストではなくホワイトリストを使うのか?monorepoでは新しいサブパッケージの追加が常態だからである。もしexcludeブラックリストを使うと、新しいプライベートパッケージを追加した際にexcludeへの追加を忘れると、その型が密かにリリース成果物に混入してしまう。ホワイトリストは逆である。新しいパッケージはデフォルトでビルドに参加せず、明示的に追加しなければならない。「安全なデフォルト値」の原則に合致する。
実行tsc -p tsconfig.build.json --noCheck後、成果物はtemp/packages/<pkg>/src/*.d.tsに出力される。注意--noCheck:型チェックをスキップし、emitのみを行う。型チェックは別のtsc --noEmitが担当し、ビルド段階では重複チェックを行わず、時間を節約する。
第二段階:rollup.dts.config.js による集約
第二段階はrollup.dts.config.jsによって駆動される。そのエントリはまず事前検証を行う:
📎 rollup.dts.config.js:15-22
もしtemp/packagesが存在しなければ、第一段階が実行されていないことを意味し、スクリプトは直接process.exit(1)して先にtscを実行するよう促す。これはパイプラインの順序契約である:rollup段階はtsc段階の成果物に強く依存し、どちらも欠かせない。
次にすべてのサブパッケージディレクトリを読み取り、TARGETS環境変数によるサブセットビルドをサポートする:
📎 rollup.dts.config.js:15-22
TARGETSこのメカニズムにより、一部のパッケージの型のみを再ビルドでき、開発デバッグ時にフィードバックループを大幅に短縮できる。
核心はtargetPackages.map(...)が各パッケージにRollup設定を生成することである:
📎 rollup.dts.config.js:23-42
フィールドごとに解説する:
input: ./temp/packages/${pkg}/src/index.d.ts:エントリは第一段階で出力された型ファイルであり、ソースコード.ts。output.file: packages/${pkg}/dist/${pkg}.d.tsではない:成果物は各パッケージ自身のdistディレクトリに出力され、ファイル名はパッケージ名と一致する(例vue.d.ts)。format: 'es':型ファイルは統一してES module形式を使用する。plugins: [dts(), patchTypes(pkg), ...(pkg === 'vue' ? [copyMts()] : [])]:3つのプラグインで、最初の2つはすべてのパッケージに適用され、copyMtsはvueパッケージにのみ適用される。
onwarnフックは特筆に値する:
📎 rollup.dts.config.js:23-42
dts rollupの過程で、すべての非相対パスのimportはデフォルトで外部化(externalized)される。これによりRollupがUNRESOLVED_IMPORT警告を出す。しかしこれは期待される動作である——型ファイル内のimport { X } from 'some-pkg'は本来外部参照として保持されるべきで、バンドルに含めるべきではない。そのためスクリプトは「非相対パスの未解決インポート」に対して直接return警告を握りつぶし、相対パスの未解決インポートのみをデフォルトのwarn。
ここに微妙な点がある:!warning.exporter?.startsWith('.')はexporterが.で始まるかどうかを判定する。相対パスのインポートが未解決の場合、第一段階の成果物に欠落があることを意味し、真の問題であり、必ず警告を出す必要がある。この区別により警告ノイズを最小限に抑えつつ、真のエラーを見逃さない。
パイプライン全景
flowchart TD
src["packages/*/src/*.ts<br/>源码类型"] --> tsc{"tsc -p tsconfig.build.json<br/>--noCheck"}
tsc -->|"include 白名单命中"| temp["temp/packages/*/src/*.d.ts<br/>单包校样"]
tsc -->|"不在 include 列表"| skip["不产出<br/>私有包/测试包被隔离"]
temp --> check{"temp/packages 存在?"}
check -->|"否"| exit["process.exit(1)<br/>提示先跑 tsc"]
check -->|"是"| rollup["rollup-plugin-dts<br/>聚合为单文件"]
rollup --> patch["patchTypes(pkg)<br/>内联导出 + 追加 types/"]
patch --> vue{"pkg === 'vue'?"}
vue -->|"是"| mts["copyMts()<br/>写 vue.d.mts"]
vue -->|"否"| done["packages/pkg/dist/pkg.d.ts"]
mts --> doneこの図は二段階の制御フローを固定する:tscのホワイトリストが誰がパイプラインに入れるかを決定し、rollupのcheckが続行可能かを決定し、patchTypesは必須の工程であり、copyMtsはvueパッケージ専用の分岐である。
5.2 patchTypes:集約成果物をリリース級の形状に書き換える
直感的モデル
rollup-plugin-dtsが数十個の.d.tsを1つのファイルにマージした後、出力される形状は「まず大量の型を宣言し、最後に巨大なexport { A, B, C, ... }で統一的にエクスポートする」というものになる。これは人間が読むには優しくなく、一部のツールチェーン(例えばVitePressのdefineComponent呼び出し)では「推論された型を参照なしで命名できない」というエラーを引き起こす。
patchTypesはこの後処理整形工程である:「集中エクスポート」を「インラインエクスポート」に変更し、さらにパッケージ専用の型拡張を追加する。
データ構造:2つのSetと3回の走査
patchTypesはRollupプラグインを返し、核心ロジックはrenderChunkフック内にある。2つの集合を維持する:
📎 rollup.dts.config.js:87-88
isExported:すべての元々エクスポートされていた型名を記録する(export { ... }宣言から)。shouldRemoveExport:すべての大きなエクスポートブロックから削除する必要がある型名を記録する(すでにインラインエクスポートされているため)。
処理フローは3つのパス(pass 0 / pass 1 / pass 2)に分かれる。これは典型的な「まず収集、次に書き換え、最後にクリーンアップ」パターンである。
Step-by-Step Walkthrough
Pass 0:すべてのエクスポート済み型名を収集する。
📎 rollup.dts.config.js:90-100
ASTのトップレベルノードを走査し、ExportNamedDeclarationかつsourceを持たない(つまりexport ... from '...'の再エクスポートではない)ものについて、そのspecifierのlocal nameをisExported。
に追加する。exportPass 1:宣言ノードにその場で
📎 rollup.dts.config.js:102-125
プレフィックスを追加する。VariableDeclaration、TSTypeAliasDeclaration、TSInterfaceDeclaration、TSDeclareFunction、TSEnumDeclaration、ClassDeclarationトップレベルノードを走査し、processDeclaration。
processDeclarationの6種類の宣言に対して
📎 rollup.dts.config.js:70-85
のロジックを呼び出す:
3ステップ:id1.
がなければ直接返す(匿名宣言など)。_2. 名前がで始まる場合はスキップ——これは約束事
である:アンダースコアプレフィックスの型は内部補助型であり、エクスポートしない。shouldRemoveExport3. 名前をisExportedに追加する;その名前がprependLeftにある場合(つまり元々エクスポートされていた場合)、宣言の開始位置にexport 文字列を
する。VariableDeclaration注意
📎 rollup.dts.config.js:104-115
分岐には追加のアサーションがある:declare constもし1つのdeclare const a, bが複数のdeclaratorを宣言している場合(例processDeclaration)、直接エラーを投げる。なぜならdeclarations[0]はのみを処理し、複数のdeclaratorは処理漏れを引き起こすからである。ここでは高速失敗
を選択し、静かなエラーにはしない。これは防御的プログラミングの表れである。
📎 rollup.dts.config.js:127-171
Pass 2:大きなエクスポートブロックからインライン化された型を削除する。ExportNamedDeclarationを走査し、各specifierについて:
- そのlocal nameが
shouldRemoveExportにあり、かつexported === local(export { Foo as Bar }のリネームケースを除外)の場合、そのspecifierを削除する。 - 削除時はMagicStringで正確に削除する:後ろにまだspecifierがあれば、次のspecifierのstartまで削除;最後のものであれば、前のもののendまたは自身のstartまで削除する。
- もしエクスポートブロック全体のすべてのspecifierが削除された場合、
ExportNamedDeclarationノード全体を削除する。
仕上げ:パッケージ専用の型を追加する。
📎 rollup.dts.config.js:172-183
code = s.toString()は書き換え後のコードを取得した後、packages/${pkg}/typesディレクトリが存在するか確認する。存在する場合、ディレクトリ下のすべてのファイル内容を読み取り、改行で連結してコードの末尾に追加する。
このtypes/ディレクトリは手動で維持される型拡張のエントリポイントであり、ソースコードから自動生成できない型(JSX グローバル拡張、マクロ型宣言など)を配置するためのものです。自動生成された型と同じファイル内でマージされますが、ソースは明確に分離されています——自動生成が上、手動拡張が下です。
なぜインラインエクスポートが必須なのか?
コメントに直接的な理由が示されています:
📎 rollup.dts.config.js:45-51
原文によると:すべての型をインラインエクスポートに変更し、大きなエクスポートブロックから削除しないと、VitePress のdefineComponent呼び出しで「the inferred type cannot be named without a reference」が報告されます。
このエラーの本質は:TypeScript が型を生成する際、ある型が「別のモジュールのエクスポートを参照する」ことでのみ命名可能であり、その参照が消費側で可視でない場合にエラーが発生します。集中エクスポートブロックは型名と宣言位置を分離し、この問題を悪化させます。インラインエクスポートは各型を宣言位置で可視にし、この間接層を排除します。
copyMts:Node ESM/CJS デュアルモードに型を提供
copyMtsプラグインはvueパッケージにのみ適用されます:
📎 rollup.dts.config.js:196-204
それはwriteBundleフック内で、vue.d.tsの内容をそのままvue.d.mts。
に書き込みます。コメントに理由が説明されています:
📎 rollup.dts.config.js:188-192
TypeScript 4.7 のpackage.jsonexports 仕様によると、Node ESM と CJS の両方に正しく型を提供するには、2 つの独立した宣言ファイルが必要です。そのためビルド時にvue.d.tsをvue.d.mts。
〔設計推論とアーキテクチャのトレードオフ〕package.jsonなぜ再生成ではなくコピーなのか?ESM と CJS の型形状は完全に同一であり、違いはファイル拡張子とexportsの
マッピングのみです。コピーは最も低コストな手段であり、rollup を再度実行することを避けます。
5.3 dts-built-test:実際の成果物で型スモークテストを実施
直感的モデルpatchTypes前の 2 節で型成果物が生成でき、形状が正しいことを保証しました。しかし「生成できる」は「正しく生成されている」と等しくありません。もしimportのいずれかの走査にバグがあり、あるエクスポートを誤って削除した場合、成果物は依然として生成できますが、ユーザーが
dts-built-testする際に型の欠落に気づきます。はまさに実際のビルド成果物で実行される型スモークテストimportです:ソースコードの型をテストするのではなく、vue公開済みの
パッケージを消費し、重要な型形状にリグレッションがないことを検証します。
データ構造:最小化された型アサーション
📎 packages-private/dts-built-test/src/index.ts:3-6
テストパッケージ全体の核心は 1 つのファイルのみです:
- 行ごとの解説:
vueL1:defineComponentからをインポートします。ここでインポートされるのはパッケージ名packages/vue/dist/vue.d.tsであり、相対パスではありません——それは - という実際の成果物を消費します。
_CustomPropsNotErasedL3-6:コンポーネント - を定義し、空の props と空の setup を持ちます。
// #8376L8:コメント - で、特定の issue を指します。
CustomPropsNotErasedL9-12:_CustomPropsNotErasedをエクスポートし、型は{ foo: string }と
の交差型です。defineComponentこのテストが検証するのは:{ foo: string }の戻り値型がfooと交差した後、。
〔設計推論とアーキテクチャのトレードオフ〕defineComponentissue #8376 の背景推測:
の戻り値型が何らかの条件型やマップ型処理を経て、交差型内の追加プロパティが「消去」される可能性があります。このテストは最小再現でこの動作を固定し、リグレッションが発生すると型チェック段階でエラーが報告されます。
📎 packages-private/dts-built-test/package.json:1-11
パッケージ設定:workspace 依存が実際の成果物を指す
private: true重要なフィールド:types: dist/index.d.ts:npm に公開しない。dependencies:型エントリがビルド成果物を指す。workspace:*内の 3 つの@vue/shared、@vue/reactivity、vue。
〔設計推論とアーキテクチャのトレードオフ〕@vue/sharedなぜ@vue/reactivityとvueに依存するのか?typesの型がこれら 2 つのパッケージの型を参照する可能性があるためです。workspace モードでは、pnpm はこれらの依存をローカルパッケージにシンボリックリンクし、ローカルパッケージのdistフィールドはそれぞれの下の成果物を指します。これによりテストチェーン全体がビルド成果物
を消費し、ソースコードではありません。
dts-built-testテストの実行方法src/index.ts自体にはテストスクリプトがなく、そのtscがテストケースです。実行方法は:CI でtscを実行し、このパッケージに対して型チェックを行います。型形状がリグレッションすると、
〔設計推論とアーキテクチャのトレードオフ〕この設計の巧妙さは:「型契約」をコンパイル可能なコードtscとしてエンコードすることにあります。追加のアサーションライブラリは不要で、ランタイムも不要です。
自体がテストランナーです。型が正しければコンパイルが通り、型が間違っていればコンパイルが失敗します。
dts-test との役割分担dts-built-test本章のdts-testと次章の
dts-built-testは別物であることに注意してください:(本章):ビルド成果物dts-testを消費し、公開レベルの型形状を検証します。(次章):ソースコードの型
〔設計推論とアーキテクチャのトレードオフ〕patchTypesなぜ 2 層が必要なのか?ソースコードの型と成果物の型が一致しない可能性があるためです。stripInternalの AST 書き換え、types/の除去、dts-built-testディレクトリの追加は、ソースコードの型が正しい前提で成果物レベルのバグを導入する可能性があります。
はこの最後の 1 マイルを専門に守ります。
sequenceDiagram
participant CI as CI 脚本
participant TSC as tsc (tsconfig.build.json)
participant Rollup as rollup.dts.config.js
participant Patch as patchTypes(pkg)
participant Dist as packages/vue/dist
participant BuiltTest as dts-built-test
CI->>TSC: tsc -p tsconfig.build.json --noCheck
TSC->>TSC: include 白名单过滤
TSC-->>Rollup: temp/packages/*/src/*.d.ts
Rollup->>Rollup: existsSync('temp/packages') 校验
Rollup->>Rollup: rollup-plugin-dts 聚合
Rollup->>Patch: renderChunk(code, chunk)
Patch->>Patch: pass0 收集 isExported
Patch->>Patch: pass1 prependLeft('export ')
Patch->>Patch: pass2 移除大导出块 specifier
Patch->>Patch: 追加 packages/vue/types/*
Patch-->>Rollup: 改写后 code
Rollup->>Dist: 写 vue.d.ts
Rollup->>Dist: copyMts 写 vue.d.mts
CI->>BuiltTest: tsc 类型检查
BuiltTest->>Dist: import { defineComponent } from 'vue'
Dist-->>BuiltTest: 类型形状
BuiltTest-->>CI: 编译通过 / 报错コピーpatchTypesこのシーケンス図はクロスモジュール協調を固定しています:CI が tsc と Rollup の 2 段階を駆動し、dts-built-testの 3 回の走査が核心的な加工であり、
が最後に成果物を消費して検証します。
設計思考、エラー回復、本番での落とし穴
patchTypesなぜ文字列置換ではなく MagicString を使うのか?code.replace(...)全体を通して
1. ではなく MagicString で正確な書き換えを行います。理由は 2 つ:位置が正確start/end:AST ノードは
2. オフセットを持ち、MagicString はオフセットに従って操作するため、同名の識別子を誤って傷つけません。:MagicString はマッピングを生成でき、書き換え後の型ファイルからもソースコードへ遡及できる。型ファイルの sourcemap の用途は限定的だが、一貫性を保つことは良い実践である。
早期失敗 vs 静かな寛容
patchTypesを複数箇所で使用assert:
📎 rollup.dts.config.js:74-74
📎 rollup.dts.config.js:107-108
📎 rollup.dts.config.js:147-148
これらのアサーションは予期しない AST 形状に遭遇すると即座にエラーを投げる。対比としてonwarn内でのUNRESOLVED_IMPORTの静かな飲み込み——予期内のノイズは飲み込み、予期外の形状は早期失敗。これがビルドスクリプトの正しい姿勢である:ビルドを失敗させる方が、形状の誤った型ファイルを出力するよりもましである。
本番での落とし穴:_プレフィックス規約
processDeclarationをスキップ_で始まる型:
📎 rollup.dts.config.js:76-78
これはソースコード内で_で始まるエクスポート型は、インラインエクスポートされないことを意味する。ある型が本来公開されるべきなのに、命名が_で始まるためにスキップされると、ユーザー側で「型が存在しない」というエラーに遭遇する。
このような問題を調査する思路:まず成果物vue.d.ts内でその型がまだ大きなエクスポートブロックにあるか確認し、次にソースコード内でその型名が_で始まるか確認する。これは命名規約とツール動作の暗黙的な結合であり、落とし穴に陥りやすい。
本番での落とし穴:複数 declarator のアサーション
📎 rollup.dts.config.js:106-115
ある.d.ts内にdeclare const a, bが出現すると、ビルドは直接エラーを投げる。これは手書きの型では稀だが、あるツールが生成した型ファイルがこの形式を使っているとトリガーされる。エラーメッセージには問題のあるコードスニペットが出力され、特定が容易になる。
本章のまとめ
本章では Vue 型成果物の完全なパイプラインを追跡した:
1. 第一段階(tsc):tsconfig.build.jsonでincludeホワイトリストを正確に用いて出力範囲を画定し、emitDeclarationOnly型のみを出力し、stripInternal内部宣言を除去する。成果物はtemp/packages/。
2. 第二段階(rollup):rollup.dts.config.jsでrollup-plugin-dts各パッケージの型を集約し、patchTypes三回の AST 走査を通じて集中エクスポートをインラインエクスポートに書き換え、さらにtypes/ディレクトリの手動拡張を追加する。copyMtsをvueパッケージに追加生成.d.mts。
3. 検証段階(dts-built-test):実際のビルド成果物上で型スモークテストを行い、コンパイル可能なコードで重要な型形状を固定し、型ドリフトを防ぐ。
本章の考察とセルフチェック
Q1: もしtsconfig.build.jsonのincludeホワイトリストを["packages"](つまり packages ディレクトリ全体を含む)に変更したら、何が起こるか?どのようなシナリオで公開型の汚染が発生するか?
参考解析:
includeを 12 の正確なディレクトリから["packages"]に変更すると、すべてのサブパッケージ(packages-private以外のすべてのpackages/*)が tsc 出力に参加する。📎 tsconfig.build.json:10-23
結果の連鎖:
1. temp/packages/下に多くのパッケージの.d.ts。
2. rollup.dts.config.jsのreaddirSync('temp/packages')がこれらの余分なパッケージを読み取る。📎 rollup.dts.config.js:15-22
3. targetPackagesはデフォルトで全パッケージに等しいため、各パッケージに対してpackages/<pkg>/dist/<pkg>.d.ts。📎 rollup.dts.config.js:15-22
汚染シナリオ:もしあるパッケージが本来公開されるべきでない場合(例:内部ツールパッケージ)、その型成果物がdist下に出現する。もしそのパッケージのpackage.jsonにprivate: trueがなければ、公開スクリプトがそれを npm に一緒に公開し、内部型が漏洩する可能性がある。
これこそがホワイトリスト設計の価値である:新しいパッケージはデフォルトで参加せず、明示的に追加する必要があり、安全なデフォルト値に適合する。
Q2: patchTypesの pass 1 では、processDeclarationが_で始まる型を直接return。もしある公開 API の型が偶然_で始まる場合(例:_InternalTypeが誤ってエクスポートされた場合)、ユーザー側ではどのような現象が見られるか?どのように調査するか?
参考解析:
processDeclarationが_で始まるものに遭遇すると直接返し、shouldRemoveExportに追加もせず、prepend もexport 。📎 rollup.dts.config.js:76-78
結果:
1. その型はインラインexport。
2. それは大きなエクスポートブロックからも削除されない(shouldRemoveExportにないため)。
3. したがってそれはまだ大きなエクスポートブロック内にあり、理論上はまだインポート可能である。
しかし問題は:大きなエクスポートブロック内のexport { _InternalType }が宣言位置を参照していることである。もしその宣言が何らかの理由(例:stripInternal)で除去されると、エクスポートブロックは存在しない名前を参照し、tscエラーを引き起こす。
調査の思路:
1. 成果物vue.d.ts内でその型が宣言位置にexportがなく、かつ大きなエクスポートブロック内で参照されているか確認する。
2. ソースコード内でその型名が_で始まるか確認する。
3. 命名問題と確認できれば、アンダースコアプレフィックスを除去するようリネームする。
これは命名規約とツール動作の暗黙的な結合を露呈する:_プレフィックスの本来の意図は「内部」だが、ツールはそれを「非エクスポート」と解釈し、両者のセマンティクスは完全には一致しない。
Q3: dts-built-testのsrc/index.tsが交差型typeof _CustomPropsNotErased & { foo: string }でfooが消去されないことを検証する。もし交差型をOmit<typeof _CustomPropsNotErased, never> & { foo: string }に変更したら、テストはまだ #8376 のリグレッションを捕捉できるか?なぜか?
参考解析:
Omit<T, never>は新しいマップ型を作成し、それは再計算T のすべてのプロパティを。もし #8376 のバグが「交差型内の追加プロパティが消去される」であれば:
- 元の書き方
T & { foo: string }:直接交差、fooは交差型の一部であり、もしdefineComponentの戻り値型処理ロジックが交差内の追加プロパティを消去すると、fooは失われる。 Omit書き方:OmitはまずTをマップし、次に{ foo: string }と交差する。Omitのマッピング過程が型構造を変更し、バグのトリガー条件が成立しなくなる可能性がある——たとえバグが存在しても、テストは通過するかもしれない。
📎 packages-private/dts-built-test/src/index.ts:9-12
したがってテストケースの最小性が極めて重要である:それはバグのトリガーパスを正確に再現しなければならない。いかなる余分な型変換(例:Omit、Pick)もバグを隠す可能性がある。これがテストで最も素朴な交差型を使い、より「エレガント」な書き方を使わない理由である。
改善方向:複数の書き方を同時に保持し、異なる型変換パスをカバーし、リグレッション捕捉率を高めることができる。ただしメンテナンスコストが増加し、トレードオフが必要である。
型パイプラインは「ソースコードから公開レベルの型をどのように生成するか」を解決し、dts-built-testは「成果物の型形状をどのように検証するか」を解決した。しかし型契約は「形状が正しいかどうか」にとどまらず、「API 表面が期待に合致するか」——どの型をエクスポートすべきか、すべきでないか、ジェネリック制約が正確か——も含む。次章ではdts-test、Vueが型契約テストで公開API表面をどのように守っているかを見ていきます。
三者は「生成 → 整形 → 検証」の閉ループを構成し、ソースコードの型と公開型が厳密に一致することを保証します。しかし、型パッケージ自体が正しいことは、公開APIの型形状がロックされていることと同じではありません。次の章では深く掘り下げますpackages-private/dts-test、20余りの.test-d.tsファイルがどのようにexpectTypeなどのツールを使って、「型即API契約」を回帰可能な自動テストに変えているかを見ていきます。
この章を読み終えましたか?ご自身のプライベートリポジトリを技術書へ
Tauri 2 + Rust によるローカルファースト設計。100% オフラインの安全性、コードのクラウド送信は一切ありません。不変コミットアンカーで精読。
⚡ Tauri 2 · Rust コア · 100% 完全オフライン · 100万行超のコードベース検証済
第 6 章:型契約テスト:dts-testがAPI表面をどのように守るか
前の章では型宣言の生成チェーンを追跡し、Vueがビルド設定とスモークテストを通じて「ソースコードの型」と「公開型」の厳密な一致をどのように保証しているかを見ました。しかし型契約は「形状が正しいかどうか」にとどまらず、より重要なのは「API表面が期待通りかどうか」です——どの型をエクスポートすべきか、すべきでないか、ジェネリック制約が正確かどうか。本章ではpackages-private/dts-testに入り、Vueが20余りの.test-d.tsファイルで「型即API契約」を回帰可能な自動テストとしてどのように実現しているかを見ていきます。
型契約テストの認知モデル:「説明書」を「実行可能な契約」に変える
dts-testディレクトリ内のファイルには直感に反する特徴があります:それらはほとんどいかなるランタイム動作も生成しません。開いてみるとdefineComponent.test-d.tsx、大量のdefineComponent({...})呼び出しが見られますが、テスト実行時に実際に実行されることはありません——これらのファイルはtsc/vue-tscによる型チェックのみを受け、noEmit: trueいかなるJSも出力しないことを保証します。
📎 packages-private/dts-test/tsconfig.test.json:1-11
この設定は契約体系全体の「実行環境」です:noEmit成果物出力をオフにし、jsx: preserveTSX構文を型システムの解析に残し、strictすべての厳格チェックをオンにし、moduleResolution: bundler現代のバンドルセマンティクスにマッチし、lib同時にesnextとdom。を導入します。この設定がなければ、.test-d.tsx内のJSXはランタイムJSXとして処理され、型アサーションは意味を失います。
型テストを独立したpackages-privateサブパッケージにし、packages/vueの__tests__に詰め込まない動機は三つあります:第一に、型テストの依存はvueの公開レベル型(vue/jsx、vueの.d.tsであり、ソースコード内部モジュールではないため、物理的分離により公開エントリを強制できます;第二に、tscの型テストチェックはランタイム単体テストよりはるかに時間がかかるため、独立ディレクトリでCIを個別にスケジュールしやすくなります;第三に、.test-d.tsxファイルがVitestのランタイムコレクタに誤って実行されません。
生活のアナロジー:通常の単体テストは「機械に通電して煙が出ないか見る」ようなものですが、型契約テストは「契約署名前に条項を一つずつ確認する」ようなものです——実際に取引せず、「甲方が支払うべき金額」が「米ドル」ではなく「人民元」と書かれていることを確認するだけです。契約条項が間違っていれば、機械がどんなに順調に動いても無意味です。
utils.d.tsこの「契約確認」のすべてのツールを提供します:
📎 packages-private/dts-test/utils.d.ts:7-21
重要なツールは四つだけです:expectType<T>(value: T)valueの型が正確にT;expectAssignable<T, T2 extends T>であることをアサートT2T;IsUnion<T>がTに代入可能であることをアサートIsAny<T>Tがユニオン型かどうかを判定;anyimport 'vue/jsx'が<MyComponent />かどうかを判定。L5のJSX.Element。
📎 packages-private/dts-test/utils.d.ts:7-21
IsUnionに注意——グローバルJSX名前空間を登録し、TSX内のT extends any ? (U extends T ? false : true) : neverが型システムにTとして認識されるようにします。extends falseの実装は詳しく見る価値があります:false分散条件型を利用し、がユニオン型であれば、各メンバーが独立に評価され、最終的にすべての分岐がprops.jjjを返すかどうかを判定します。これは
型レベルでの存在証明defineComponentです——「
defineComponent.test-d.tsxが単一シグネチャにマージされずユニオン型でなければならない」といった契約をロックするために使われます。シナリオ駆動ウォークスルー:defineComponent({ props: {...}, setup(props) {...} })のprops型推論全チェーンpropssetupは2260行あり、契約体系の中核です。具体的なシナリオを代入してみましょう:propsユーザーがと書き、Vueの型システムは
ランタイム宣言から
内のExpectedPropsパラメータの正確な型を推論する必要があります。このチェーンはVue型システムで最も複雑な部分です。:
📎 packages-private/dts-test/defineComponent.test-d.tsx:21-53
第一步:「期待型」を契約基準として構築a?: number | undefinedテストファイルはまずundefined)、aa: numberインターフェースを定義し、各props宣言方式が推論すべき型をaaa: number | null(PropType<number | null>明示的にハードコードしますaaaa: number | undefined(required: true as constこのインターフェースは「契約条項」の書面版です。いくつかの微妙な型に注意:undefined(オプショナルpropsにprops(defaultがあるので非オプショナル)、
明示的宣言)、defineComponent
📎 packages-private/dts-test/defineComponent.test-d.tsx:57-158
だが型にpropsを含む)。これらの差異は随意に書かれたものではなく、それぞれが宣言内の特定の分岐に対応します。第二步:様々な宣言方式で
a: Numberに「食わせる」number | undefinedaa: { type: Number as PropType<number | undefined>, default: 1 }このnumberaaaa: { type: Number, required: true as const }——as constオブジェクトはtrue宣言方式の網羅的マトリクスbooleanであり、Vue propsのすべての書き方をカバーします:b: { type: String, required: true as true }——required: true—— コンストラクタ省略記法、bb: { default: 'hello' }として推論type—— defaultがあり、非オプショナルcc: Array as PropType<string[]>として推論l: [Date]Date | undefinedll: [Date, Number]がDate | number | undefinedlll: [String, Number]に拡張されるのを防ぎ、リテラル型を保持
required: true as constでプロパティを非voidにrequired: true as true——as trueなし、as constのみで型を推論—— 明示的型変換。
—— 配列構文、setup / render / thisとして推論
—— 複数型配列、として推論。
📎 packages-private/dts-test/defineComponent.test-d.tsx:160-217
setup(props)—— 同上expectType<ExpectedProps['x']>(props.x)〔設計推論とアーキテクチャのトレードオフ〕
📎 packages-private/dts-test/defineComponent.test-d.tsx:168-170
// @ts-expect-error should included 'undefined'と組み合わせてexpectType<number>(props.aaaa)——意図的にエラーを出すアサーションを書き、@ts-expect-errorでエラーを飲み込む。これによりprops.aaaaの型がではないことが検証される number(そうでなければこの行はエラーにならず、@ts-expect-errorむしろ「飲み込むエラーがない」ために失敗する)。これは型テストの「逆アサーション」テクニックである。
📎 packages-private/dts-test/defineComponent.test-d.tsx:204-205
// @ts-expect-error props should be readonlyと組み合わせてprops.a = 1——propsがsetup内で読み取り専用であることを検証する。もしリファクタリングで誤ってpropsが可変になると、この行はエラーにならなくなり、@ts-expect-errorが失敗する。
render()内ではthis.$propsとthis.xの2つのパスでアサーションする:
📎 packages-private/dts-test/defineComponent.test-d.tsx:221-279
L252-276は「宣言されたpropsもthisに公開される必要がある」ことを検証し、L278-279はthis.a = 1がエラーになること(this上のpropsも読み取り専用)を検証する。L281-287はsetup戻り値のアンラップを検証する:this.cはnumber(ref(1)がアンラップされ)、this.d.e.valueはstring(ネストされたrefは.value)、this.f.gを保持し、GT(reactiveは
内のbranded型がアンラップされない)。
第四步:TSX消費側の型チェック<MyComponent />型契約の最後の環は「ユーザーがこのコンポーネントをどう使うか」である。TSX内の
📎 packages-private/dts-test/defineComponent.test-d.tsx:296-322
のpropsチェックは独立した型パスである:<MyComponent>ここではclass/style/key/ref/ref_forが宣言されたすべてのpropsを受け入れること、およびこれらの組み込み属性を検証する。次に:
📎 packages-private/dts-test/defineComponent.test-d.tsx:337-345
// @ts-expect-error missing required props逆検証wrong prop types必須propsの欠落がエラーになることを検証し;ggg="baz"型の不一致がエラーになることを検証し;L342はgggがエラーになることを検証する('foo' | 'bar')。
は
flowchart LR
A["props 声明对象<br/>L57-158"] --> B["defineComponent<br/>泛型推导"]
B --> C["ExtractPropTypes<br/>运行时声明 → 类型"]
C --> D["setup(props)<br/>L162-217"]
C --> E["render() this.$props<br/>L221-279"]
C --> F["TSX 消费端<br/>L296-345"]
D --> G["expectType 断言<br/>契约锁定"]
E --> G
F --> G
G --> H{"全部通过?"}
H -->|是| I["类型契约成立"]
H -->|否| J["tsc 报错<br/>CI 阻断合并"]チェーン全体は1つのデータフロー図で要約できる:コピーpropsこの図の鍵は:同じtsc宣言が、3つの消費位置の型期待を同時に満たさなければならない
。どこか一箇所でも推論のずれがあれば__typeProps、__typeEmitsがエラーになる。
defineComponent境界とバックドア:と条件型契約の型推論には根本的な制限がある:color='white'実行時のprops宣言では「条件型」を表現できないappearance。例えば「'outline'のとき__typePropsは
__typePropsでなければならない」という制約は、実行時のオブジェクト構文では書けない。Vueはこのために
📎 packages-private/dts-test/defineComponent.test-d.tsx:1803-1836
ConditionalPropsなどの「型バックドア」を提供している。color:条件付きpropsの型エスケープハッチappearanceはユニオン型である:要么color: 'white'とappearance: 'outline'が両方オプショナルか、
- L1823-1824:
<Comp color="white" />かつcolor: 'white'。テストで検証: - L1825-1826:
<Comp color="white" appearance="normal" />がエラー——単独でappearanceを与えてもどちらのブランチも満たさない'outline' - L1827:
<Comp color="white" appearance="outline" />がエラー——
__typePropsでなければならない
__typeEmitsが通過
__typeEmits〔設計推論とアーキテクチャトレードオフ〕の設計動機は「型システムに実行時では表現できない制約を表現させる」ことである。実行時のprops解決には関与せず、純粋に型レベルのカバレッジである。代償はユーザーが型と実行時宣言の一貫性を手動で維持する必要があること——これが「backdoor」と呼ばれ正式APIではない理由である。:
📎 packages-private/dts-test/defineComponent.test-d.tsx:1838-1885
:2つのemits構文の等価性{ change: [id: number], update: [value: string] }は2つの構文をサポートし、テストthis.$props.onChange?.(123)は両方を同時にロックするonChange?.('123')オブジェクト構文
📎 packages-private/dts-test/defineComponent.test-d.tsx:1887-1934
は名前付きタプルで引数を表現する。テストで{ (e: 'change', id: number): void; (e: 'update', value: string): void }が通過、がエラーになることを検証。呼び出しシグネチャ構文はオーバーロードで表現する。2つの構文のテスト本体はほぼ行単位で同一
完全に等価defineEmitsな型動作を生むことを要求する。
__typeRefs〔設計推論とアーキテクチャトレードオフ〕__typeElなぜ2つの構文を保持するのか?オブジェクト構文は
📎 packages-private/dts-test/defineComponent.test-d.tsx:1936-1952
__typeRefsの書き方に近く、呼び出しシグネチャ構文は従来のTSイベント型に近い。Vueは両方をサポートし、動作の一貫性を保証する必要がある。テストの「行単位ミラー」構造が最強の等価性証明である。Parentと__typeRefs: { child: ComponentInstance<typeof Child> }:コンポーネント間参照とホストノード型refs.child.$refs.fooにより親コンポーネントが子コンポーネントrefの型を正確に知ることができる。number。
📎 packages-private/dts-test/defineComponent.test-d.tsx:1963-1977
__typeElがを宣言し、それによりElementがTypeElと推論できるElementはさらに微妙である。L1963-1977のテストコメントが設計意図を明示している:CustomElementカスタムレンダラー(TUI、canvas、native)のホストノードはDOM$elではないため、
に制約できない。テストはTypeElインターフェースでElement,@vue/runtime-testが任意のホスト型を受け入れられることを検証する。$el〔設計推論とアーキテクチャトレードオフ〕
これはVue 3がカスタムレンダラーをサポートするための型レベル保証である。もし
function syntax w/ runtime propsがにハード制約されると、非DOMレンダラーのユーザーは。
📎 packages-private/dts-test/defineComponent.test-d.tsx:1501-1545
型を正しく推論できなくなる。契約テストがここで守るのは「レンダラー非依存性」である。generics aren't supported with object runtime propsジェネリックコンポーネントと実行時propsの相互排他制約<Comp3<string>>のセクションは重要なルールをロックする:
L1501のコメントExtractPropTypesは契約宣言である。L1525-1535はジェネリックsetup + オブジェクトpropsがエラーになることを検証;L1538-1539は
がエラーになることを検証。一方、配列propsはジェネリックを許可する(L1464-1499)。
@ts-expect-error〔設計推論とアーキテクチャトレードオフ〕
@ts-expect-errorこの制約の根本原因は型推論の順序である:オブジェクトpropsはが先に型を確定する必要があり、ジェネリックはインスタンス化時にしか確定できないため、両者が衝突する。配列propsは型抽出に関与しないため衝突しない。契約テストはこの「型システムの制限」を回帰可能なアサーションとして固定化する。@ts-expect-error設計思考、エラー回復、本番の落とし穴の諸刃の剣
📎 packages-private/dts-test/defineComponent.test-d.tsx:1354-1362
は型契約テストの核心ツールだが、致命的な罠がある:// @ts-expect-error missing propその下のコードがエラーにならなくなると、<Comp msg={123} />自体がエラーになる。これは保護に見えて、実際にはテスト作者が「エラーが発生する位置」を正確に制御することを要求する。このコードを見てほしい:expectType<JSX.Element>(...)は@ts-expect-errorのexpectTypeの前の行に置かれている
で包まれている。もし@ts-expect-errorの位置が1行ずれるか、エラーが実際に呼び出しで発生しJSX上でない場合、テストは失敗する。@ts-expect-error〔設計推論とアーキテクチャトレードオフ〕本番の落とし穴:TypeScriptのバージョンアップでエラー位置が微妙に変わると、大量の
IsAnyとIsUnion:型レベルでの「存在証明」
📎 packages-private/dts-test/defineComponent.test-d.tsx:1991-1993
expectType<IsAny<typeof props.foo>>(false)検証props.fooではないany。これは逆契約:型が正しいことだけでなく、型が「any」。anyに退化してはならない」ことも要求する。anyは型システムのブラックホールであり、あらゆる
📎 packages-private/dts-test/defineComponent.test-d.tsx:195-196
expectType<IsUnion<typeof props.jjj>>(true)検証jjjがユニオン型であることを検証する。jjjとして宣言され、((arg1: string) => string) | ((arg1: string, arg2: string) => string)、型システムがそれを単一のシグネチャに統合すると、IsUnionはfalseを返し、テストは失敗する。
これら二つのツールが守るのは「型の正確性」であり、「型の正当性」ではない。単一のanyに退化した型、あるいはユニオンが統合された型は、ほとんどの使用シナリオで「使えるように見える」が、IDEのヒントとコンパイル時のチェックを失う。契約テストはこの正確性を固定しなければならない。
宣言順序の暗黙的契約
📎 packages-private/dts-test/defineComponent.test-d.tsx:1784-1801
このコメントは極めて重要である:code generated by tsc / vue-tsc, make sure this continues to work so we don't accidentally change the args order of DefineComponent。DefineComponentには13個のジェネリックパラメータがあり、順序は公開契約——vue-tsc生成されるコンポーネント型はこの順序に依存する。テストではdeclare const MyButton: DefineComponent<...>で13個のパラメータをすべて明示的に記述し、順序を固定する。
これは最も見落とされやすい契約である:ジェネリックパラメータの順序は「実装の詳細」ではなく、「生成コードのABI」である。順序を変更するPRは、vue-tscが生成する.d.tsをランタイム型と互換性がなくする。契約テストはここで「ABI互換性ガード」の役割を果たす。
ファイル間契約:componentInstance.test-d.tsxの補足
componentInstance.test-d.tsxはわずか154行だが、ComponentInstanceツール型のすべての入力形態をカバーしている:
📎 packages-private/dts-test/componentInstance.test-d.tsx:10-40
ComponentInstance<typeof CompSetup>からdefineComponent結果のインスタンス型を抽出;ComponentInstance<typeof CompFunctional>関数コンポーネントから抽出;ComponentInstance<typeof CompFunction>生の関数から抽出。三者すべてがComponentPublicInstance基底クラスを導出しなければならない。
📎 packages-private/dts-test/componentInstance.test-d.tsx:71-116
さらに極端なのは「defineComponentでラップされていない生のオブジェクト」である:CompObjectSetup、CompObjectData、CompObjectNoProps三つの形態すべてがComponentInstanceによって正しく抽出されなければならない。L113-114は特に直感に反する:CompObjectNoPropsにprops宣言がないが、compObjectNoProps.testは依然としてstring | undefinedと導出される——これはComponentPublicInstance基底クラスが提供するフォールバックである。
📎 packages-private/dts-test/componentInstance.test-d.tsx:143-147
L141の#12751テストは一つの境界を固定している:__typeEmitsで宣言された'update:visible'イベントは、インスタンス上でcomp['onUpdate:visible'](コロン付きの文字列キー)として公開され、かつ$propsの型は{ 'onUpdate:visible'?: (value?: boolean) => any }である。L152-153はcomp['$props']['$props']がエラーを報告することを検証する——型の再帰的自己参照を防ぐためである。
本章のまとめ
dts-testディレクトリは20余りの.test-d.tsファイルを用いて、「型即API契約」を回帰可能な自動テストとして実現している。核心的なメカニズムは三層ある:
1. ツール層:expectType、expectAssignable、IsUnion、IsAnyは型アサーションのプリミティブを提供し、@ts-expect-errorは逆アサーション能力を提供する。
2. 契約層:ExpectedPropsインターフェースは「どのような型が導出されるべきか」を明示的に固定し、props宣言マトリクスはすべての記述法を網羅し、三つの消費位置(setup/render/TSX)でクロス検証する。
3. バックドア層:__typeProps、__typeEmits、__typeRefs、__typeElはランタイムで表現できない型制約に脱出ハッチを提供し、同時に二つのemits構文の等価性を固定する。
本章の考察とセルフチェック
Q1:defineComponent.test-d.tsxL168-170の@ts-expect-errorを削除し、expectType<number>(props.aaaa)のみを残すと何が起こるか?なぜこのテストは「静かに無効化」されるのか?
参考解析:
props.aaaaは{ type: Number as PropType<number | undefined>, required: true as const }として宣言され、その導出型はnumber | undefinedである(なぜならPropType<number | undefined>は明示的にundefined)。
expectType<number>(props.aaaa)を含み、props.aaaaが正確にnumberであることを要求する)。実際の型はnumber | undefinedであるため、この行自体がエラーを報告する。@ts-expect-errorの役割は「ここでエラーが報告されることを期待し、それを飲み込む」ことである。
もし@ts-expect-errorを削除すると、この行は直接エラーを報告し、テストは失敗する——一見「より厳格」に見える。しかし問題は:もしあるリファクタリングでprops.aaaaが本当にnumberになった場合(バグ修正または動作変更)、この行はもはやエラーを報告せず、@ts-expect-errorを削除した後のテストは通過する——この時点でテストは「型が正しい」と「型が間違っているがたまたまエラーを報告しない」を区別できない。
を保持する@ts-expect-error記法は双方向ロックである:「現在の型はnumber | undefinedである」(@ts-expect-errorでexpectType<number>のエラーを飲み込むことで)ことを要求し、かつ「型はnumberであってはならない」(もしnumber,@ts-expect-errorになると飲み込むべきエラーがなくなり失敗する)ことも要求する。これは型契約テストの核心テクニックである——「エラーを期待する」ことで「型が特定の成分を含まなければならない」ことを固定する。
📎 packages-private/dts-test/defineComponent.test-d.tsx:168-170
Q2: __typePropsバックドアテスト(L1803-1836)は条件付きユニオン型の制約を検証している。もしConditionalPropsをユニオン型から{ color?: 'normal' | 'primary' | 'secondary' | 'white'; appearance?: 'normal' | 'outline' | 'text' }に変更した場合(つまりすべてのオプションを平坦化した場合)、テストはどのように失敗するか?これは__typePropsのどのような設計制約を示しているか?
参考解析:
平坦化後の型は任意のcolorとappearanceの組み合わせを許可する。これにはcolor: 'white' + appearance: 'normal'も含まれる。しかしテストL1825-1826はこの組み合わせがエラーを報告する:
// @ts-expect-error
;<Comp color="white" appearance="normal" />ことを明確に要求している。もし型が平坦化されると、この行はもはやエラーを報告せず、@ts-expect-errorは「飲み込むべきエラーがない」ために失敗する。同時にL1823-1824の<Comp color="white" />も「エラー報告」から「通過」に変わり、同様に@ts-expect-errorを失敗させる。
これは__typePropsの設計制約を示している:それはユニオン型の「分岐相互排他」セマンティクスを保持しなければならない。__typeProps単純な「型の上書き」ではなく、「型システムを用いてランタイムpropsでは表現できない条件制約を表現する」ことである。もし実装時にPropsに対してPrettifyやOmitなどのマッピング変換を行った場合、ユニオンの分岐の判別性が損なわれ、制約が無効になる可能性がある。
これが__typePropsのテストケースが最も素朴なCommonProps & ConditionalProps交差を使用し、より「エレガントな」マッピング型を使用しない理由でもある——いかなる追加の型変換もバグを隠す可能性がある。
Q3: DefineComponentの13個のジェネリックパラメータの順序はL1784-1801で明示的に固定されている。もしあるリファクタリングで9番目のパラメータ(VNodeProps & AllowedComponentProps & ComponentCustomProps)と10番目のパラメータ(Readonly<ExtractPropTypes<{}>>)を交換した場合、どの下流が影響を受けるか?なぜ契約テストはこの順序を固定しなければならないのか?
参考解析:
DefineComponentのジェネリックパラメータの順序はvue-tscがコンポーネント型を生成する際の「ABI」である。ユーザーが<script setup>でdefineProps / defineEmits,vue-tscと書くと、L1999-2116のようなCreateComponentPublicInstance<...>型が生成され、そこではジェネリックパラメータの位置が各型パラメータの意味を決定する。
もし9番目と10番目のパラメータを交換すると:
1. vue-tscが生成する.d.tsは古い順序でパラメータを埋めるが、DefineComponentは新しい順序で解釈する——VNodeProps & AllowedComponentProps & ComponentCustomPropsはprops型として扱われ、Readonly<ExtractPropTypes<{}>>はVNode属性として扱われる。結果としてユーザーコンポーネントの props 型がすべてずれている。
2. L1786-1800 のdeclare const MyButton: DefineComponent<...>は直接エラーになる——なぜなら{}とVNodeProps & ...が互換性がないからだ。
3. L1999-2116 のErrorMessage型(シミュレーションvue-tsc生成結果)もエラーになる。
契約テストが順序を固定する価値は:それが「ジェネリックパラメータの順序」を「実装の詳細」から「公開契約」へと引き上げる点にある。順序を変更する PR は L1786-1800 を即座に失敗させ、互換性のない変更がリリースに入るのを防ぐ。
📎 packages-private/dts-test/defineComponent.test-d.tsx:1784-1801
これは型契約テストで最も過小評価されがちな価値である:それが守るのは「型が正しいかどうか」ではなく、「型システムのインターフェース安定性」である。ジェネリックパラメータの順序、@ts-expect-errorの位置、IsAnyの戻り値は、いずれも「型 ABI」の構成要素である。
型契約テストは「API 表面が期待通りかどうか」を解決する。しかし型は Vue エンジニアリングの半分に過ぎない——もう半分は「ユーザーがブラウザ内でこれらの API の動作をリアルタイムに検証する方法」である。次の章では SFC Playground に入り、Vue がコンパイラ、ランタイム、型システムをブラウザ内のリアルタイムデバッグ環境にどのようにパッケージングし、ユーザーがコードを変更した瞬間にコンパイル成果物と実行結果を確認できるようにしているかを見る。
契約テストが守るのは「型が正しいかどうか」だけでなく、「型がどれだけ正確か」(IsAny/IsUnion)、「ジェネリックパラメータの順序が安定しているか」(DefineComponent13 パラメータ)、「レンダラー非依存性」(__typeElをElementに制約しない)も含まれる。これらの制約が一度破られると、ユーザー側の IDE ヒント、vue-tscが生成する型がすべてドリフトする。そして型契約の安定性は、最終的に開発者の日常的なデバッグ体験に奉仕するものである——次の章ではpackages-private/sfc-playgroundに入り、純粋なフロントエンド Playground がブラウザ内で SFC コンパイルとリアルタイムプレビューの閉ループをどのように完成させるかを見る。
この章を読み終えましたか?ご自身のプライベートリポジトリを技術書へ
Tauri 2 + Rust によるローカルファースト設計。100% オフラインの安全性、コードのクラウド送信は一切ありません。不変コミットアンカーで精読。
⚡ Tauri 2 · Rust コア · 100% 完全オフライン · 100万行超のコードベース検証済
第 7 章:SFC Playground:ブラウザ内のリアルタイムコンパイルとデバッグサブシステム
前の章では 20 余りの.test-d.tsファイルで「型即 API 契約」を CI に釘付けにした。しかし型契約は「API 表面がどのような形か」にしか答えられず、「この SFC がコンパイルされると実際にどのような形になるか」「SSR モードでレンダリング結果が一致するか」には答えられない。後者の二つの問いに答えるために、Vue チームはブラウザ内で完全なコンパイルパイプラインを実行できるサンドボックスを必要とした——それがpackages-private/sfc-playgroundである。それはpackages/下の公開パッケージとは本質的に異なる:package.jsonでは"private": trueかつ"version": "0.0.0" 📎 packages-private/sfc-playground/package.json:2-4、つまり npm に永遠に公開されず、公式のデバッグツールに過ぎない。その依存関係ではvueがworkspace:* 📎 packages-private/sfc-playground/package.json:19を指し、つまり npm 上の安定版ではなくローカルソースのビルド成果物である——これにより Playground は自然に「現在の commit の生きたデモ」となる。本章は三つの問題に焦点を当てる:エントリがどのように初期化されるか、Header がどのように状態切り替えを駆動するか、ビルド時定数がどのように注入されるか。
一、エントリのミニマリズム:main.ts と ReplStore の初期化契約
直感モデル
main.tsはわずか 9 行で、「起動時セルフチェックスクリプト」のようなものだ:Vue アプリがマウントされる前に、まずwindowにグローバル設定を入れ、Vue DevTools に「デフォルトでどの app を選択するか」を伝える。このステップがなければ、DevTools を開いたときに複数の app インスタンス(Playground 自身 + ユーザー REPL で実行されるコード)に直面し、自動フォーカスできず、デバッグ体験が手動切り替えに退化する。
データ構造とグローバル副作用
main.tsの核心はcreateAppではなく、windowへの汚染的な書き込みである:
📎 packages-private/sfc-playground/src/main.ts:4-7
// @ts-expect-error Custom window property
window.VUE_DEVTOOLS_CONFIG = {
defaultSelectedAppId: 'repl',
}ここに注目すべきエンジニアリングの詳細が二つある:
1. @ts-expect-errorであり@ts-ignore:windowではない 標準型Window & typeof globalThisにはVUE_DEVTOOLS_CONFIGフィールドが存在しない。@ts-expect-errorを使うことは「ここでエラーが出ることを知っており、かつエラーが出ることを要求する」を意味する——将来ある@types/*がこのフィールドを補った場合、@ts-expect-errorは「エラーが発生しなかった」ことで逆にエラーを出し、作者にそのコメントを削除するよう促す。これは前の章の型契約テストの考え方と一脈通じる:型システムで意図を守り、問題を覆い隠すのではなく。
2. defaultSelectedAppId: 'repl'の文字列規約:この'repl'は@vue/repl内部で app を作成する際に使用される id と完全に一致しなければならない。これはクロスパッケージのリテラル契約であり、いかなる型制約にも保護されていない——@vue/replが id を変更したら、Playground の DevTools デフォルト選択は静かに無効化される。
Step-by-Step:HTML からマウントまで
実行フローは極めて短いが、各ステップに暗黙の制約がある:
1. ブラウザがindex.htmlをロードし、そこには<div id="app">が含まれる(本資料では提供されていないが、mount('#app')から逆推できる)。
2. モジュールグラフの解析:main.ts冒頭のimport App from './App.vue' 📎 packages-private/sfc-playground/src/main.ts:2が@vitejs/plugin-vueの SFC コンパイルをトリガーする。
3. 重要な順序:window.VUE_DEVTOOLS_CONFIGはcreateApp(App).mount('#app') 📎 packages-private/sfc-playground/src/main.ts:9より前に書き込まれなければならない。DevTools の hook はcreateApp内部で登録されるため、mount より遅く設定を書き込むと初回選択に影響を与えられない。
4. mount('#app')がApp.vueの setup をトリガーし、さらにReplStoreを作成する(App.vue内、本資料には含まれない)。
flowchart TD
load["浏览器加载 index.html"] --> parse["解析 main.ts 模块图"]
parse --> sfc["@vitejs/plugin-vue 编译 App.vue"]
sfc --> setcfg["写入 window.VUE_DEVTOOLS_CONFIG"]
setcfg --> check{"VUE_DEVTOOLS_CONFIG 已设置?"}
check -->|是| mount["createApp(App).mount('#app')"]
check -->|否| devtools["DevTools 无法默认选中 repl"]
mount --> appsetup["App.vue setup 创建 ReplStore"]
appsetup --> ready["Playground 就绪"]
devtools --> mount設計思考と落とし穴
main.tsのミニマリズムは意図的である:複雑さをすべてApp.vueとReplStore。エントリポイントは「グローバル副作用の注入 + マウント」の2つだけを担い、ビジネスロジックはここに現れるべきではない。これはPlaygroundが「製品」ではなく「デバッグツール」であることによるトレードオフである——SSR互換性も、マルチエントリも、遅延読み込みも必要としない。
本番環境での落とし穴:window.VUE_DEVTOOLS_CONFIGはグローバルシングルトン。もしPlaygroundがDevToolsを使用する別のページ(iframeシナリオなど)に埋め込まれた場合、後から書き込んだ者が前者を上書きする。Playgroundは通常独立してデプロイされるため、このリスクは許容されている。
---
二、Header.vue:computed派生状態とemit単方向データフロー
直感的モデル
Header.vueはPlaygroundの「コントロールパネル」である——バージョン選択、PROD/DEV切り替え、SSRスイッチ、テーマ切り替え、共有、ダウンロード。それ自体はいかなるビジネス状態も保持しない。すべての状態はprops.storeとブールpropsから来ており、すべての変更はemitを通じて親コンポーネントに報告される。この「ダムコンポーネント + イベントバブリング」の制約がなければ、Headerは状態が散在する重災区となり、バージョン切り替えとSSR切り替えの副作用を集中的に管理できなくなる。
データ構造とフィールド分析
Headerのprops定義は、その責務を理解する鍵である:
📎 packages-private/sfc-playground/src/Header.vue:13-19
const props = defineProps<{
store: ReplStore
prod: boolean
ssr: boolean
autoSave: boolean
theme: 'dark' | 'light'
}>()5つのpropsは2つのカテゴリに分かれる:
store: ReplStore:唯一の状態コンテナ参照であり、@vue/replから来る。Headerはそれを介してstore.loading、store.vueVersion、store.typescriptVersionを読み取り、直接store.vueVersion。- 4つのブール/リテラルprops:
prod、ssr、autoSave、theme。これらは制御された状態であり、Headerは読み取りのみで書き込みは行わず、変更は必ずemit。
対応するemitリスト📎 packages-private/sfc-playground/src/Header.vue:20-28:
const emit = defineEmits([
'toggle-theme',
'toggle-ssr',
'toggle-prod',
'toggle-autosave',
'reload-page',
])注意:toggle-themeはtoggleDark()内部でemitされるが、toggle-ssr/toggle-prod/toggle-autosaveはテンプレート内で直接$emitされる📎 packages-private/sfc-playground/src/Header.vue:102-118。この混用はVue 3<script setup>の一般的なスタイルである:副作用が必要な場合は関数emitを使用し、純粋な転送の場合はテンプレート$emit。
Step-by-Step:バージョン表示と切り替え
シナリオ:ユーザーがPlaygroundを開き、Headerは現在のVueバージョンを表示する必要がある。
ステップ1:computed派生表示テキスト
📎 packages-private/sfc-playground/src/Header.vue:30-37
const vueVersion = computed(() => {
if (store.loading) {
return 'loading...'
}
return store.vueVersion || `@${__COMMIT__}`
})ここには3層の優先順位がある:loading状態 →'loading...';ユーザーが明示的にバージョンを選択 →store.vueVersion;それ以外 →@${__COMMIT__}(現在のcommitショートハッシュ)。__COMMIT__はビルド時に注入される定数であり、次のセクションで詳述する。
ステップ2:VersionSelectの双方向バインディング
📎 packages-private/sfc-playground/src/Header.vue:88-88
<VersionSelect
:model-value="vueVersion"
@update:model-value="setVueVersion"
pkg="vue"
label="Vue Version"
>ここで注意すべきはを使用せずv-model、明示的に:model-value + @update:model-valueに分割している点である。理由はvueVersionがcomputed(読み取り専用)であり、直接双方向バインディングできないため、setVueVersionというsetter関数を通じてstore.vueVersion:
📎 packages-private/sfc-playground/src/Header.vue:39-41
async function setVueVersion(v: string) {
store.vueVersion = v
}
function resetVueVersion() {
store.vueVersion = null
}setVueVersionはasyncとして宣言されているが内部にawaitがない——これは歴史的遺産か意図的なものか? 推測ではVersionSelectの非同期読み込みセマンティクス(バージョン切り替えがリモート読み込みをトリガーする)と整合させ、インターフェースの一貫性を保つためである。
ステップ3:TypeScriptバージョンの比較
📎 packages-private/sfc-playground/src/Header.vue:76-80
<VersionSelect
v-model="store.typescriptVersion"
pkg="typescript"
label="TypeScript Version"
/>TypeScriptバージョンではv-modelを使用している。なぜならstore.typescriptVersionは書き込み可能な通常のプロパティであり、computedでラップする必要がないからである。同じコンポーネントが同じテンプレート内で2つのバインディング方式を使用することは、まさに「制御 vs 非制御」の直感的な体現である。
テーマ切り替え:副作用とemitの組み合わせ
📎 packages-private/sfc-playground/src/Header.vue:58-66
function toggleDark() {
const cls = document.documentElement.classList
cls.toggle('dark')
localStorage.setItem(
'vue-sfc-playground-prefer-dark',
String(cls.contains('dark')),
)
emit('toggle-theme', cls.contains('dark'))
}この関数は3つのことを行う:DOM classの操作、localStorageへの永続化、親コンポーネントへのemit通知。注意:直接props.themeを変更していない——propsは読み取り専用であるため、親コンポーネントがtoggle-themeを受け取ってからthemeを更新し、それによってテンプレート内の:titleテキスト📎 packages-private/sfc-playground/src/Header.vue:123。
ここに微妙な設計がある:DOM class操作とVueリアクティブ状態は2つの独立したパスである。document.documentElement.classList.toggle('dark')は直接DOMを変更し、themepropはVueを通じて更新される。もし両者が同期しなければ(例えば親コンポーネントが更新を拒否した場合)、UIに「classは切り替わったがtitleテキストが変わっていない」という不整合が生じる。実際には親コンポーネントは常にemitを受け入れるため、問題は顕在化しない。
隠しロジック:copyLinkのmetaKey分岐
📎 packages-private/sfc-playground/src/Header.vue:47-56
async function copyLink(e: MouseEvent) {
if (e.metaKey) {
resetVueVersion()
// hidden logic for going to local debug from play.vuejs.org
window.location.href = 'http://localhost:5173/' + window.location.hash
return
}
await navigator.clipboard.writeText(location.href)
alert('Sharable URL has been copied to clipboard.')
}これは開発者バックドアである:play.vuejs.org上でCmdを押しながら共有ボタンをクリックすると、localhost:5173(ローカルdev server)にジャンプし、現在のURL hashを持っていく。hashには完全なREPL状態(ソースコード、バージョン、オプション)がエンコードされているため、ローカルデバッグでオンラインの問題を再現できる。コメント// hidden logic for going to local debug from play.vuejs.org 📎 packages-private/sfc-playground/src/Header.vue:47-56はこれが意図的に隠された機能であることを明示している。
resetVueVersion()はジャンプ前に呼び出され、store.vueVersionをnullに設定し、ローカルデバッグがオンラインで選択されたバージョンではなく現在のcommitを使用することを保証する。
flowchart TD
click["用户点击 Share 按钮"] --> meta{"e.metaKey 按下?"}
meta -->|是| reset["resetVueVersion() 置 null"]
reset --> jump["跳转 localhost:5173 + hash"]
jump --> local["本地 dev server 复现"]
meta -->|否| copy["navigator.clipboard.writeText(location.href)"]
copy --> check{"写入成功?"}
check -->|是| alert["alert 提示已复制"]
check -->|否| fail["静默失败 (无 catch)"]設計思考と落とし穴
落とし穴1:navigator.clipboardの権限とセキュリティコンテキスト。copyLinkにはtry/catchがない📎 packages-private/sfc-playground/src/Header.vue:47-56。非HTTPSまたはユーザーがクリップボード権限を拒否した場合、writeTextはrejectし、未キャッチのPromise rejectionが発生する。PlaygroundはHTTPS上にデプロイされているためリスクは許容されているが、これは典型的な「本番環境の罠」である。
落とし穴2:toggleDarkのlocalStorage keyハードコーディング。'vue-sfc-playground-prefer-dark'は文字列リテラルであり、定数抽出がされていない。将来keyを変更する場合、グローバル検索が必要になる。
落とし穴3:currentCommitとvueVersionの比較。テンプレート内で:class="{ active: vueVersion === \@${currentCommit}\ }" 📎 packages-private/sfc-playground/src/Header.vue:88-88文字列連結で比較する。もし__COMMIT__注入が失敗した場合(undefinedになる)、ここは'@undefined'になり、永遠に一致しない。ビルド時定数注入の信頼性がUIの正確性を直接左右する——これが次の節のテーマである。
---
三、ビルド時定数注入:__COMMIT__ と copyVuePlugin の二重の責務
直感的モデル
vite.config.tsは Playground の「組み立て工場」である:ビルド時にgit rev-parseを実行して commit ハッシュを取得し、defineを通じてそれをグローバル定数__COMMIT__に変換する。同時にカスタムプラグインを通じてpackages/vue/dist/配下の ESM ブラウザビルド成果物を Playground の出力ディレクトリにコピーする。このステップがなければ、Playground はブラウザで「現在の commit の Vue ランタイム」を読み込めず、npm 上の安定版に依存するしかなくなり、「ライブデモ」の意味を失う。
データ構造とビルド時定数
📎 packages-private/sfc-playground/vite.config.ts:7-9
const commit = spawnSync('git', ['rev-parse', '--short=7', 'HEAD'])
.stdout.toString()
.trim()spawnSyncは同期的に git コマンドを実行し、--short=7で7桁の短縮ハッシュを取得する。同期実行は意図的である:設定ファイルはモジュール読み込み時にcommitの値が必要であり、非同期だと Vite の設定解析タイミングが乱れる。
📎 packages-private/sfc-playground/vite.config.ts:23-26
define: {
__COMMIT__: JSON.stringify(commit),
__VUE_PROD_DEVTOOLS__: JSON.stringify(true),
},defineは Vite のテキスト置換機構である:ソース内のすべての__COMMIT__がJSON.stringify(commit)の結果(つまり引用符付きの文字列リテラル)に置換される。JSON.stringifyは必須である——もし直接commitと書くと、置換後に裸の識別子abc1234になり、文字列ではなく変数名として扱われる。
__VUE_PROD_DEVTOOLS__: trueはもう一つの重要な定数である:Vue の本番ビルドでも DevTools サポートを保持させる。デフォルトでは本番ビルドはサイズ削減のため DevTools フックを除去するが、Playground はユーザーコードをデバッグする必要があるため強制的に有効化する。
Step-by-Step:copyVuePlugin の成果物搬送
📎 packages-private/sfc-playground/vite.config.ts:32-63
function copyVuePlugin(): Plugin {
return {
name: 'copy-vue',
generateBundle() {
const copyFile = (file: string) => {
const filePath = path.resolve(
import.meta.dirname,
'../../packages',
file,
)
const basename = path.basename(file)
if (!fs.existsSync(filePath)) {
throw new Error(
`${basename} not built. ` +
`Run "nr build vue -f esm-browser" first.`,
)
}
this.emitFile({
type: 'asset',
fileName: basename,
source: fs.readFileSync(filePath, 'utf-8'),
})
}
copyFile(`vue/dist/vue.esm-browser.js`)
copyFile(`vue/dist/vue.esm-browser.prod.js`)
copyFile(`vue/dist/vue.runtime.esm-browser.js`)
copyFile(`vue/dist/vue.runtime.esm-browser.prod.js`)
copyFile(`server-renderer/dist/server-renderer.esm-browser.js`)
},
}
}重要なポイントを順に解析する:
1. generateBundleフック:Rollup がバンドルを生成した後、ディスクに書き込む前に実行される。この時点でemitFileを使って成果物に追加ファイルを入れることができる。
2. import.meta.dirname:Node 20.11+ が提供する ESM 版__dirname。パス../../packagesはpackages-private/sfc-playground/からリポジトリルートへ遡り、さらにpackages/。
3. へ進む。存在チェック+明確なエラー:もしvue.esm-browser.jsが存在しなければ、修正手順を含むエラーRun "nr build vue -f esm-browser" first.を投げる。これは開発者体験の模範である——エラーメッセージが直接修正方法を教えてくれる。
4. 五つの成果物:vueの完全版/ランタイム版 × dev/prod、さらにserver-renderer。これら五つのファイルこそが Playground がブラウザで動的に import する候補集合であり、Header のバージョン切り替えと SSR スイッチに対応する。
なぜこの五つなのか?完全版(コンパイラ含む)は「ランタイムコンパイル」シーン用;ランタイム版は「事前コンパイル」シーン用;dev/prod は Header の PROD/DEV 切り替えに対応;server-renderer は SSR スイッチに対応。これら五つのファイルが Playground の「Vue ランタイムマトリクス」を構成する。
バージョン切り替えの完全なデータフロー
Header のsetVueVersionと copyVuePlugin の成果物を結びつけて見ると:
flowchart LR
user["用户选择版本"] --> setver["setVueVersion(v)"]
setver --> store["store.vueVersion = v"]
store --> repl["@vue/repl 内部"]
repl --> fetch{"版本来源?"}
fetch -->|"@commit"| local["加载本地 vue.esm-browser.js"]
fetch -->|"3.4.0"| cdn["从 CDN 加载"]
local --> compile["浏览器内编译 SFC"]
cdn --> compile
compile --> preview["实时预览"]この特殊値@${__COMMIT__}に注意:これは CDN ではなく copyVuePlugin がコピーしたローカル成果物に対応する。これが Playground が Vue のブラウザビルド成果物をコピーしなければならない理由である——「This Commit」オプションにはローカルファイルが必要。
設計思考と落とし穴
落とし穴 1:spawnSyncの失敗処理。もし現在のディレクトリが git リポジトリでなければ(例えば tarball から解凍した場合)、spawnSyncは非ゼロの終了コードを返し、stdoutは空になり、commitは空文字列になる。この時__COMMIT__は""に置換され、Header の@${currentCommit}は'@'になる。明示的なエラー処理はない。
落とし穴 2:optimizeDeps.exclude: ['@vue/repl'] 📎 packages-private/sfc-playground/vite.config.ts:27-29。Vite はデフォルトでコールドスタートを加速するため依存関係を事前バンドルするが、@vue/replは除外される。理由は@vue/repl内部で動的 import と worker を使用しており、事前バンドルがこれらの機構を破壊するからである。これは Vite エコシステムでよくある「事前バンドルと動的読み込みの衝突」問題である。
落とし穴 3:script.fs設定 📎 packages-private/sfc-playground/vite.config.ts:13-19。@vitejs/plugin-vueのscript.fsオプションは SFC の<script>ブロックがfsを通じてファイルを読み取ることを許可する。ここでfs.existsSyncとfs.readFileSyncを渡すのは、SFC 内のimport文の解析をサポートするためである(例えばimport x from './foo'はファイルの存在チェックが必要)。これは Playground がブラウザで完全なモジュール解決をシミュレートできる鍵である——Node の fs 機能をコンパイラの解決段階に注入している。
---
設計思考:Playground のアーキテクチャトレードオフ
三つの小節を繋げて見ると、Playground のアーキテクチャは明確な原則に従っている:「状態」と「副作用」を分離し、「ビルド期」と「実行期」を分離する。
main.tsはグローバルな副作用注入のみを行い、ビジネス状態には触れない。Header.vueは純粋な表示コンポーネントであり、状態は props で流入し、emit で流出する。vite.config.tsは「現在の commit」というビルド期情報を定数として固定化し、実行期は読み取り専用とする。
この分離は直接的な利点をもたらす:Playground は任意の Vue アプリケーションに埋め込むことができる(例えばドキュメントサイトの埋め込み例)。必要なのはstoreと四つのブール props を提供するだけである。
代償は状態の分散:storeにおいて@vue/replでは、ブール状態は親コンポーネントにあり、DOM class はdocument.documentElement上にあり、localStorage にもう一份ある。四箇所の状態を手動で同期する必要があり、どこか一箇所でも同期が外れると UI の不整合が発生する。
もう一つのトレードオフはSSR 互換性を放棄する。main.ts直接アクセスwindow,Header.vueのtoggleDark直接アクセスdocument。Playground は純粋な CSR アプリケーションであり、サーバーサイドレンダリングを考慮する必要がない。
---
本章のまとめ
本章ではpackages-private/sfc-playgroundの三つの核心ファイルを分析した:
1. main.ts:9 行のエントリ、核心はwindow.VUE_DEVTOOLS_CONFIGの注入順序——必ずmountの前でなければならない。
2. Header.vue:computedを通じてvueVersionを派生し、emitを通じてすべての状態変更を報告する。copyLinkのmetaKey分岐は隠されたローカルデバッグの裏口である。
3. vite.config.ts:spawnSynccommit ハッシュを取得し、defineを注入して__COMMIT__,copyVuePlugin五つの Vue ブラウザビルド成果物を Playground の成果物ディレクトリに運搬する。
三者を貫く主線はビルド時定数と実行時状態の境界:__COMMIT__は読み取り専用のビルド時事実であり、store.vueVersionは可変の実行時選択であり、Header のvueVersioncomputed が両者を一つの表示文字列に統合する。
本章の考察とセルフチェック
Q1: もしmain.tsにおけるwindow.VUE_DEVTOOLS_CONFIGの代入をcreateApp(App).mount('#app')の後に移動したら、何が起こるか?なぜか?
参考解説:window.VUE_DEVTOOLS_CONFIGは Vue DevTools がcreateApp内部で hook を登録する際に読み取る設定📎 packages-private/sfc-playground/src/main.ts:4-9。createAppは即座に__VUE_DEVTOOLS_GLOBAL_HOOK__を登録し、この時点で DevTools はdefaultSelectedAppIdを読み取ってデフォルトでどの app を選択するかを決定する。もし代入がmountより遅れると、DevTools は既に初回の app 選択を完了しており、設定は効果を発揮せず、ユーザーは手動で DevTools 内でreplapp に切り替える必要がある。さらに隠蔽的なのは:@vue/repl内部でも app を作成するため、遅い代入は DevTools がデフォルトで Playground 自身を選択し、ユーザーの REPL ではなくなる可能性がある。ユーザーコードをデバッグする際に手動で切り替える必要がある。これは「グローバル副作用の注入順序」がデバッグツールにおいて重要であることを示している。
Q2: Header.vueのtoggleDark()は同時に DOM class、localStorage、emit を操作するが、props.themeを直接変更しない。もし親コンポーネントがtoggle-themeイベントを受け取った後にthemeprop の更新を拒否したら、どのような UI の不整合が発生するか?ソースコードレベルでどのように特定するか?
参考解説:toggleDark()は📎 packages-private/sfc-playground/src/Header.vue:58-66で直接document.documentElement.classList.toggle('dark')を呼び出し、これにより即座に DOM 上のdarkclass が変更され、CSS 変数の切り替えがトリガーされる(📎 packages-private/sfc-playground/src/Header.vue:186-186の.dark navルールを参照)。しかしテンプレート内の:title文案📎 packages-private/sfc-playground/src/Header.vue:123はprops.themeに依存しており、もし親コンポーネントが更新しなければ、title は古い値のまま留まる。特定方法:ブラウザの DevTools で<html>の class とボタンの title 属性が矛盾していないか確認する。根本原因は「DOM 副作用」と「Vue リアクティブ状態」が二つの独立した経路を辿り、単一のデータソースが存在しないことである。
Q3: copyVuePluginはgenerateBundle内で各ファイルに対してfs.existsSyncチェックを行い、欠落時に修復指示付きのエラーをスローする。もしこのチェックを除去して直接fs.readFileSyncした場合、CI 環境(vue を先にビルドしていない)で何が起こるか?エラーメッセージはどのように開発者を誤導するか?
参考解説:チェックを除去すると、fs.readFileSyncがENOENT: no such file or directory, open '.../packages/vue/dist/vue.esm-browser.js' 📎 packages-private/sfc-playground/vite.config.ts:32-63をスローする。このエラーは開発者に「ファイルが存在しない」とだけ伝え、「nr build vue -f esm-browserを先に実行する必要がある」ことは伝えない。CI 環境では、開発者はパス設定の誤り、権限問題、git サブモジュールの未初期化と誤解し、多大な時間を浪費する可能性がある。元のコードのthrow new Error(\${basename} not built. Run "nr build vue -f esm-browser" first.\)は「症状」と「修復アクション」を結びつけており、開発者体験設計の重要な細部である。これはまた、Playground のビルドスクリプトが Vue コアのビルドスクリプトと明確な依存順序を持たなければならない理由も説明している。
---
次章ではpackages-private/template-explorerに入り、Vue がコンパイラの中間成果物(AST、変換結果、コード生成)をどのように可視化し、開発者がテンプレートからレンダリング関数への各変換ステップを段階的に観察できるようにするかを見る。Playground の「エンドツーエンドのブラックボックス」とは異なり、Template Explorer は「ホワイトボックスのプローブ」である。
ここまでで、SFC Playground がどのようにコンパイルパイプラインをブラウザに持ち込むかを明らかにした:エントリの初期化、Header の状態切り替え、ビルド時定数の注入が共にリアルタイムでデバッグ可能なサンドボックスを構成している。しかし Playground の視点は常に「SFC 全体のコンパイルと実行」であり、「コンパイラが特定のテンプレート式に対して実際に何の変換を行ったか」には直接答えない。次章では Template Explorer に入り、@vue/compiler-domと@vue/compiler-ssrのコンパイル結果を行ごとに展開し、SourceMapConsumer でソースコードと成果物のマッピングを確立することで、コンパイラの内部動作を観察可能で逆推論可能なプローブに変える方法を見る。
この章を読み終えましたか?ご自身のプライベートリポジトリを技術書へ
Tauri 2 + Rust によるローカルファースト設計。100% オフラインの安全性、コードのクラウド送信は一切ありません。不変コミットアンカーで精読。
⚡ Tauri 2 · Rust コア · 100% 完全オフライン · 100万行超のコードベース検証済
第 8 章:Template Explorer:コンパイラ動作の可視化プローブ
前章では、SFC Playgroundが「SFC入力 → ブラウザ内コンパイル → リアルタイムプレビュー」という一連の流れをどのようにブラックボックス化しているかを見ました。開発者は最終的なレンダリング結果を見ることはできますが、コンパイラが中間で何を行っているかは見えません。テンプレートにカスタムディレクティブを書いたり、hoistStaticを有効にした後に生成物に突然_hoisted_1変数が大量に現れたりすると、Playgroundは「なぜコンパイラがこのように生成したのか」を答えることができません。Template Explorerの位置づけは正に逆です。@vue/compiler-domと@vue/compiler-ssrのコンパイル生成物、AST、エラーマーカー、そしてソースコードから生成物への位置マッピングをすべて展開します。その核心は「実行」ではなく「観察」です。本章では3つのファイルを中心に展開します。index.tsはコンパイル呼び出しとSourceMapの双方向マッピングを担当し、options.tsはreactiveを用いて数十のCompilerOptionsを管理しUIを駆動し、theme.tsはMonacoエディタのテーマをカスタマイズします。
一、コンパイル呼び出しとSourceMap双方向マッピング:index.ts
直感的モデル
Template Explorerのindex.tsは「双方向翻訳機」のようなものです。左側でテンプレートを入力し、右側でレンダリング関数を出力します。しかし翻訳機よりも一つの能力が優れています——左側の特定の行にカーソルを置くと、右側で対応する生成物がハイライトされます。逆に右側にカーソルを置くと、左側で対応するテンプレートがハイライトされます。SourceMapマッピングがなければ、このツールは単に並んだ2つのテキストボックスに退化し、開発者は肉眼で比較するしかなく、「テンプレートの何行目 → 生成物の何行目」という因果連鎖を構築できません。
データ構造とメモリレイアウト
index.tsには複雑なStructはありませんが、ツール全体の動作を決定するいくつかの重要なモジュールレベルの状態変数があります:
lastSuccessfulCodeとlastSuccessfulMapはコンパイル結果のキャッシュ📎 packages-private/template-explorer/src/index.ts:74-75です。前者は文字列で、後者はSourceMapConsumer | undefinedです。注意:lastSuccessfulMapは初期値がundefinedで、コンパイルが成功しmapが存在する場合にのみ📎 packages-private/template-explorer/src/index.ts:99-100が代入されます。このundefined状態は、後続のすべてのカーソルマッピングロジックのガード条件です——コンパイルが失敗した場合、マッピング機能は自動的に静かに無効化され、例外をスローしません。
PersistedStateインターフェースはlocalStorageとURLハッシュに永続化される状態の形状を定義します📎 packages-private/template-explorer/src/index.ts:26-30:src(テンプレートソースコード)、ssr(SSRモードかどうか)、options(コンパイラオプション)。ここに重要な設計があります:optionsの型は完全なCompilerOptionsですが、実際に永続化する際は「デフォルト値と異なる項目」のみを保存します。このトリミングロジックはreCompileで行われます。
sharedEditorOptionsは2つのエディタで共有される構築オプション📎 packages-private/template-explorer/src/index.ts:26-30:fontSize: 14、scrollBeyondLastLine: false、renderWhitespace: 'selection'、minimap.enabled: falseです。minimapを無効にするのは、テンプレートと生成物は通常数十行しかなく、minimapがむしろ横方向のスペースを占有するためです。
Step-by-Step Walkthrough
シナリオ:ユーザーがページを開き、<div>{{ msg }}</div>を入力し、カーソルを移動します。
ステップ1:初期化と状態復元。 window.initはグローバルエントリ📎 packages-private/template-explorer/src/index.ts:41です。まずカスタムテーマを登録してアクティブ化し📎 packages-private/template-explorer/src/index.ts:44-45、次にURLハッシュまたはlocalStorageから状態を復元しようとします📎 packages-private/template-explorer/src/index.ts:49-56。ここでのデコード順序に注意:まずatob次にescape、そしてdecodeURIComponent。ハッシュの解析が失敗した場合、localStorage.getItem('state')にフォールバックし、さらに{}にフォールバックします。JSON.parse全体が失敗した場合、localStorageをクリアし警告を出力します📎 packages-private/template-explorer/src/index.ts:57-64。
状態復元後、見落とされがちな詳細があります:delete persistedState.options?.nodeTransforms 📎 packages-private/template-explorer/src/index.ts:69。コメントが理由を説明しています——関数はシリアライズできないため、永続化時にnodeTransformsが失われ、復元時に空のオブジェクトが残っているとコンパイラの動作が異常になります。これは「シリアライズ不可能なフィールドの永続化」という古典的な罠です。
ステップ2:コンパイルコアcompileCode。これはツール全体の心臓部📎 packages-private/template-explorer/src/index.ts:76-106です。まずconsole.clear()、次にssrMode.valueに基づいてssrCompileまたはcompile 📎 packages-private/template-explorer/src/index.ts:80を選択します。注意:compileFnの呼び出しパラメータは、展開compilerOptions、強制filename: 'ExampleTemplate.vue'、sourceMap: true、そしてonErrorコールバックを注入してエラーを収集します📎 packages-private/template-explorer/src/index.ts:82-89。
ここに設計上の決定があります:filenameは'ExampleTemplate.vue'にハードコードされています。この値は後続のgeneratedPositionFor呼び出しで正確に一致する必要があります📎 packages-private/template-explorer/src/index.ts:189。そうでなければSourceMapクエリは空の結果を返します。これは暗黙の契約です——2箇所の文字列が一致しなければなりませんが、型システムによる保証はありません。
コンパイル完了後、エラーはMonacoのマーカー形式に変換されエディタに設定されます📎 packages-private/template-explorer/src/index.ts:91-95。formatErrorはCompilerErrorのlocをMonacoのstartLineNumber/startColumn/endLineNumber/endColumn 📎 packages-private/template-explorer/src/index.ts:108-119に変換します。注意:errors.filter(e => e.loc)——位置情報を持つエラーのみがマークされ、locを持たないエラー(グローバル設定エラーなど)はコンソールにのみ出力されます。
ステップ3:SourceMapの構築。コンパイル成功後、lastSuccessfulMap = new SourceMapConsumer(map!) 📎 packages-private/template-explorer/src/index.ts:99、続けてcomputeColumnSpans() 📎 packages-private/template-explorer/src/index.ts:100。computeColumnSpansを呼び出します。source-map-jsはgeneratedPositionForの重要なAPIです:各マッピングセグメントの列スパンを事前計算し、lastColumnが返す
フィールドを利用可能にします。このステップがなければ、逆方向マッピングは開始列しか特定できず、トークン範囲全体をハイライトできません。ステップ4:双方向カーソルマッピング。ユーザーがソースエディタeditor.onDidChangeCursorPosition 📎 packages-private/template-explorer/src/index.ts:184でカーソルを移動すると、lastSuccessfulMap.generatedPositionFor({ source: 'ExampleTemplate.vue', line, column: column - 1 }) 📎 packages-private/template-explorer/src/index.ts:188-192がトリガーされます。コールバックは100msのdebounce後、column - 1を呼び出します。注意:pos——Monacoの列番号は1から始まり、SourceMapの列番号は0から始まります。返されたlineにcolumnと📎 packages-private/template-explorer/src/index.ts:194-206があれば、出力エディタ上にデコレータを作成して対応範囲をハイライトし📎 packages-private/template-explorer/src/index.ts:207-210。
、その位置にスクロールしますoutput.onDidChangeCursorPosition逆方向マッピングは📎 packages-private/template-explorer/src/index.ts:223で行われますoriginalPositionFor 📎 packages-private/template-explorer/src/index.ts:227-230。それはpos.line === 1 && pos.column === 0の「mock location」📎 packages-private/template-explorer/src/index.ts:231-237。このガードは非常に重要です——コンパイラが生成する一部のコード(例えばimport文やヘルパー関数)には対応するテンプレート位置がなく、SourceMap は{ line: 1, column: 0 }をプレースホルダとして返します。これを無視しないと、これらの行にカーソルを置いたときにテンプレートの最初の行が誤ってハイライトされます。
第五步:状態の永続化。 reCompileはコンパイルをトリガーするだけでなく、現在の状態を localStorage と URL ハッシュに書き込む役割も担います📎 packages-private/template-explorer/src/index.ts:121-146。永続化時にはトリミングロジックがあります:compilerOptionsを走査し、「オブジェクトでなく、かつデフォルト値と等しくない」項目のみを保存します📎 packages-private/template-explorer/src/index.ts:125-133。これにより、bindingMetadataのようなオブジェクト型のオプションが永続化されない理由が説明できます——複雑すぎるうえ、デフォルト値でデモには十分だからです。
flowchart TD
init["window.init()"] --> restore{"hash 或 localStorage 有状态?"}
restore -->|是| parse["JSON.parse 成功?"]
restore -->|否| useDefault["使用默认模板"]
parse -->|成功| delNodeTrans["delete nodeTransforms"]
parse -->|失败| clearLS["localStorage.clear() + 警告"]
delNodeTrans --> createEditor["monaco.editor.create(source)"]
clearLS --> createEditor
useDefault --> createEditor
createEditor --> initOpt["initOptions()"]
initOpt --> watch["watchEffect(reCompile)"]
watch --> compileCode["compileCode(source)"]
compileCode --> chooseFn{"ssrMode.value?"}
chooseFn -->|true| ssr["ssrCompile(source, opts)"]
chooseFn -->|false| dom["compile(source, opts)"]
ssr --> hasMap{"map 存在?"}
dom --> hasMap
hasMap -->|是| newSMC["new SourceMapConsumer(map)"]
hasMap -->|否| skipMap["lastSuccessfulMap 保持 undefined"]
newSMC --> computeSpan["computeColumnSpans()"]
computeSpan --> setOutput["output.setValue(code)"]
skipMap --> setOutput
compileCode -->|抛异常| catchErr["lastSuccessfulCode = ERROR 注释"]
catchErr --> setOutput設計上の考察と本番での落とし穴
なぜsource-map-jsではなくsource-map? source-mapを使うのかsource-map-jsは Mozilla のオリジナルライブラリで、サイズが大きく、WASM(新しいバージョン)に依存しています。source-map-jsは純粋な JS 実装で、サイズが小さく、ブラウザ環境に適しています。Template Explorer は純粋なフロントエンドツールとして、📎 packages-private/template-explorer/package.json:15。
を選択するのは合理的ですdebounce の遅延の選択。📎 packages-private/template-explorer/src/index.ts:271ソースコードエディタの debounce はデフォルトで 300ms📎 packages-private/template-explorer/src/index.ts:215ですが、カーソル移動の debounce は 100ms です
window.init。この差異は意図的なものです:コンパイルは重い操作で、300ms は頻繁なトリガーを避けるため。カーソル移動は軽い操作で、100ms は応答感を保証するため。ただし 100ms でもカーソルを素早く動かすとハイライトのちらつきが発生する可能性があります——これは許容できるトレードオフです。のグローバルマウント。window.init注意:window.monacoと📎 packages-private/template-explorer/src/index.ts:19-23はどちらもグローバルloader.jsにマウントされます。これは Monaco エディタが CDN のwindow.initを通じて非同期で読み込まれ、読み込み完了後に
---
を呼び出すためです。この「グローバルコールバック」パターンは非モジュール環境における Monaco の標準的な使い方ですが、現代の ESM ビルド方式とは相容れません。
二、reactive 駆動のオプションパネル:options.ts
options.ts直感的モデルcompileは「コンソールパネル」のようなものです:上に十数のスイッチとラジオボタンがあり、それぞれがコンパイラの動作に対応しています。どれかのスイッチを切り替えると、右側のコンパイル結果が即座に変わります。このモジュールがなければ、開発者はソースコード内の
呼び出しパラメータを変更して再コンパイルするしかなく、異なるオプションの効果をリアルタイムで比較できません。
options.tsデータ構造とメモリレイアウト
ssrModeの核心は 3 つのエクスポートです:ref(false) 📎 packages-private/template-explorer/src/options.ts:5はcompilerOptionsです。これはcompile vs ssrCompileから独立しています。なぜなら SSR モードが切り替えるのはコンパイル関数自体(
defaultOptions)であり、コンパイルオプションではないからです。CompilerOptionsは完全な📎 packages-private/template-explorer/src/options.ts:5-27オブジェクトmode: 'module'、prefixIdentifiers: false、hoistStatic: false、cacheHandlers: false、scopeId: null、inline: false、ssrCssVars: '{ color }'、compatConfig: { MODE: 3 }、whitespace: 'condense'です。これはすべてのオプションのデフォルト値を定義しており、bindingMetadata 📎 packages-private/template-explorer/src/options.ts:18-26。
compilerOptionsや、7 つのバインディングタイプを含むreactive(Object.assign({}, defaultOptions)) 📎 packages-private/template-explorer/src/options.ts:29-31が含まれますObject.assign({}, ...)はreactive(defaultOptions)です。ここでcompilerOptionsを使って浅いコピーを行っていることに注意——defaultOptionsを直接行うと、reCompileを変更したときに
Step-by-Step Walkthrough
が汚染され、
内の「デフォルト値との比較」ロジックが無効になります。 Appシナリオ:ユーザーが「hoistStatic」チェックボックスをクリック。setup第一步:UI レンダリング。📎 packages-private/template-explorer/src/options.ts:33-35コンポーネントのssrMode.value、compilerOptions.mode、compilerOptions.prefixIdentifiersはレンダリング関数📎 packages-private/template-explorer/src/options.ts:36-39を返します。このレンダリング関数は
などのリアクティブ状態 hoistStaticを読み取るため、これらの状態が変化すると UI 全体が再レンダリングされます。checked第二步:チェックボックスの checked バインディング。compilerOptions.hoistStatic && !isSSR 📎 packages-private/template-explorer/src/options.ts:150チェックボックスのhoistStatic属性はdisabled: isSSR 📎 packages-private/template-explorer/src/options.ts:151です。ここにロジックがあります:SSR モードでは
が強制的に未チェックで表示されます。SSR コンパイルは静的巻き上げをサポートしていないためです。同時ににより、ユーザーは SSR モードでこれを切り替えられません。onChange第三步:onChange 処理。📎 packages-private/template-explorer/src/options.ts:152-156ユーザーがチェックボックスをクリックすると、e.target.checkedがcompilerOptions.hoistStaticをトリガーし、直接compilerOptionsをreactiveに代入します。watchEffect(reCompile) 📎 packages-private/template-explorer/src/index.ts:266は
であるため、この代入は依存関係の追跡をトリガーし、さらにをトリガーし、最終的に再コンパイルされます。cacheHandlers第四步:オプション間の連動。checked注意:usePrefix && compilerOptions.cacheHandlers && !isSSR 📎 packages-private/template-explorer/src/options.ts:166,disabledの!usePrefix || isSSR 📎 packages-private/template-explorer/src/options.ts:167はcacheHandlersはprefixIdentifiersです。これはmode === 'module'がprefixIdentifiersまたはfunctionに依存することを意味します。この連動関係は UI 上では次のように現れます:cacheHandlersが有効でなく、モードが
scopeIdのとき、disabled: !isModule 📎 packages-private/template-explorer/src/options.ts:182,checked: isModule && compilerOptions.scopeId 📎 packages-private/template-explorer/src/options.ts:183チェックボックスは無効になります。isModuleの連動はより複雑です:null 📎 packages-private/template-explorer/src/options.ts:184-189。
。module モードでのみ scopeId を設定でき、onChange 時に initOptionsが false の場合、強制的にcreateApp(App).mount(document.getElementById('header')!) 📎 packages-private/template-explorer/src/options.ts:232-234に設定されますvue第五步:マウント。createAppは@vue/runtime-domを呼び出します。ここでoptions.tsパッケージのvueではなく
flowchart LR
subgraph reactive_state["reactive 状态层"]
ssrMode["ssrMode: Ref<boolean>"]
compilerOptions["compilerOptions: reactive(CompilerOptions)"]
end
subgraph ui_layer["UI 渲染层 (options.ts)"]
modeRadio["mode 单选"]
wsRadio["whitespace 单选"]
ssrCheck["SSR 复选框"]
prefixCheck["prefixIdentifiers 复选框"]
hoistCheck["hoistStatic 复选框"]
cacheCheck["cacheHandlers 复选框"]
scopeCheck["scopeId 复选框"]
inlineCheck["inline 复选框"]
compatCheck["compatConfig 复选框"]
end
subgraph compile_layer["编译层 (index.ts)"]
watchEffect["watchEffect(reCompile)"]
compileCode["compileCode()"]
end
ssrMode -->|"checked/disabled"| ssrCheck
ssrMode -->|"isSSR 守卫"| hoistCheck
ssrMode -->|"isSSR 守卫"| cacheCheck
compilerOptions -->|"mode"| modeRadio
compilerOptions -->|"whitespace"| wsRadio
compilerOptions -->|"prefixIdentifiers"| prefixCheck
compilerOptions -->|"hoistStatic"| hoistCheck
compilerOptions -->|"cacheHandlers"| cacheCheck
compilerOptions -->|"scopeId"| scopeCheck
compilerOptions -->|"inline"| inlineCheck
compilerOptions -->|"compatConfig.MODE"| compatCheck
modeRadio -->|"onChange 赋值"| compilerOptions
wsRadio -->|"onChange 赋值"| compilerOptions
ssrCheck -->|"onChange 赋值"| ssrMode
prefixCheck -->|"onChange 赋值"| compilerOptions
hoistCheck -->|"onChange 赋值"| compilerOptions
cacheCheck -->|"onChange 赋值"| compilerOptions
scopeCheck -->|"onChange 赋值"| compilerOptions
inlineCheck -->|"onChange 赋值"| compilerOptions
compatCheck -->|"onChange 赋值"| compilerOptions
compilerOptions -->|"依赖追踪"| watchEffect
ssrMode -->|"依赖追踪"| watchEffect
watchEffect --> compileCodeはアプリケーション層のコードであり、完全な
パッケージに直接依存できるためです。reactiveコピーref? compilerOptions設計上の考察と本番での落とし穴reactiveなぜcompilerOptions.hoistStatic = trueではなくcompilerOptions.value.hoistStatic = trueを使うのかreactiveは十数のフィールドを含むオブジェクトで、compilerOptions.xxxを使えば
bindingMetadataを直接行え、が不要です。これは UI コードではより簡潔です。ただし📎 packages-private/template-explorer/src/options.ts:18-26の代償は、分割代入がリアクティビティを失うことです——ソースコードには分割代入が一切なく、すべてSETUP_CONST、SETUP_REF、SETUP_LET、SETUP_MAYBE_REF、PROPSを通じてアクセスしており、これは正しい使い方です。prefixIdentifiersのデフォルト値の設計。$setupのデフォルト値には 7 つのバインディングprefixIdentifiersが含まれ、
compatConfigの 5 つのタイプをカバーしています。これは開発者が compilerOptions.compatConfig!.MODE = 2 📎 packages-private/template-explorer/src/options.ts:216-220を開いたときに、異なるバインディングタイプが出力のreactiveアクセス方法に与える影響をすぐに確認できるようにするためです。このデフォルト値がなければ、reactiveの効果は非常に単調になります。compatConfigのネストされたリアクティビティ。CompatConfig | undefinedこのようなネストされた代入は!ではリアクティブです。なぜならcompatConfigはネストされたオブジェクトを再帰的にプロキシするからです。ただし
ssrModeの型はcompilerOptionsであるため、 ssrModeアサーションを使用しています。デフォルト値にref,compilerOptionsがなければ、ここで実行時にクラッシュします。reactiveとssrの責務の分離。compilerOptionsはssrはCompilerOptionsです。なぜ
---
を
に入れないのか?それは
theme.tsエディタに「別のスキン」を着せるようなもの:各構文トークンの色とフォントスタイルを定義する。このモジュールがなければ、Monaco はデフォルトのvs-darkテーマを使用する。動作はするが、Vue テンプレート内の HTML タグ、式、ディレクティブの視覚的な区別がなくなり、開発者が重要な部分を素早く特定するのが難しくなる。
データ構造とメモリレイアウト
theme.tsMonacoIStandaloneThemeDataインターフェースに準拠したオブジェクトをエクスポートする📎 packages-private/template-explorer/src/theme.ts:1-244。トップレベルには3つのフィールドがある:
base: 'vs-dark'ベーステーマを指定する📎 packages-private/template-explorer/src/theme.ts:2,inherit: trueベーステーマを継承するルールを表す📎 packages-private/template-explorer/src/theme.ts:3。つまり差分部分だけを定義すればよく、未定義のトークンはvs-dark。
rulesにフォールバックする。配列であり、各要素はtoken(Monaco のトークン名)とforeground/background/fontStyle 📎 packages-private/template-explorer/src/theme.ts:4-235を含む。この配列には50以上のエントリがあり、number、comment、keyword、string、variable、entity.name.tag などのトークンタイプをカバーしている。
colorsエディタ UI の色を定義する📎 packages-private/template-explorer/src/theme.ts:236-243:editor.foreground、editor.background、editor.selectionBackground、editor.lineHighlightBackground、editorCursor.foreground、editorWhitespace.foreground。
Step-by-Step Walkthrough
シナリオ:ページ読み込み時にテーマを登録する。
ステップ1:テーマを定義する。 monaco.editor.defineTheme('my-theme', theme) 📎 packages-private/template-explorer/src/index.ts:44。この呼び出しはtheme.tsのエクスポートオブジェクトを Monaco のテーマレジストリに登録し、キー名は'my-theme'。
ステップ2:テーマをアクティブ化する。 monaco.editor.setTheme('my-theme') 📎 packages-private/template-explorer/src/index.ts:45。この行はdefineThemeの後に呼び出す必要があり、そうでなければ「テーマが未定義」エラーがスローされる。
ステップ3:トークンマッチング。Monaco がテンプレートコードをレンダリングする際、HTML 言語サービスでコードをトークン化し、トークン名に基づいてrules内のルールを検索する。例えば<div>内のdivはentity.name.tagとしてマークされ、foreground: 'cc6666' 📎 packages-private/template-explorer/src/theme.ts:41-44にマッチし、赤色で表示される。
設計上の考察と本番での落とし穴
なぜinherit: true?を使用するのか。継承しなければ、テンプレートに現れないもの(markup.heading、meta.diffなど)を含むすべてのトークンの色を定義する必要がある。継承により、テーマファイルはテンプレートと JS 出力に実際に現れるトークンだけに注目すればよくなる。
トークン名の階層マッチング。Monaco のトークンマッチングはプレフィックスマッチングである:entity.name.tagはentity.name.tag.html、entity.name.tag.cssなどにマッチする。ソースコードではentity.name.tag 📎 packages-private/template-explorer/src/theme.ts:41-44とentity.name.tag.css 📎 packages-private/template-explorer/src/theme.ts:169-172の両方が定義されており、後者が前者の CSS 固有のシナリオを上書きする。
colorsとrulesの役割分担。 rulesはコードテキストの色を制御し、colorsはエディタ UI(背景、カーソル、選択行)の色を制御する。両者は独立しているが、視覚的な調和が必要である。ソースコード内のeditor.background: '#1D1F21'とbase: 'vs-dark'のデフォルト背景は近く、これは視覚的一貫性を保つためである。
---
設計上の考察:可視化プローブのエンジニアリング上のトレードオフ
Template Explorer と SFC Playground の核心的な違いは「観察の粒度」にある。Playground が観察するのは「SFC 全体がコンパイル後に動作するか」であり、Template Explorer が観察するのは「個々のテンプレート式が何にコンパイルされるか」である。この違いが2つのツールの技術選定を決定づけている:
SourceMapConsumer の導入は必然である。これがなければ、開発者はソースコードと出力を目視で比較するしかなく、正確な「何行目 → 何行目」のマッピングを構築できない。しかし SourceMapConsumer の API は非同期であり(新しいバージョンは Promise を返す)、ソースコードでは同期バージョンsource-map-jsを使用しており、これは呼び出しロジックを簡素化するためである。
reactive管理オプションは Vue エコシステムの自然な選択である。ネイティブ DOM イベントで十数のオプションの状態同期を手動管理すると、コード量が倍になる。reactiveの依存追跡により「オプション変更 → 再コンパイル」というチェーンが自動化され、watchEffect(reCompile)1行のコードで購読が完了する。
Monaco のグローバルロードモードは歴史的な負債である。 window.monacoとwindow.initのグローバルマウント方式は Monaco の AMD ローダー設計に由来する。現代の ESM ビルドではこれはそぐわないが、Monaco のサイズ(約5MB)のためオンデマンドロードは依然として必要である。
---
本章のまとめ
Template Explorer は「ホワイトボックスプローブ」である:コンパイル出力を実行せず、コンパイル過程のみを表示する。index.tscompileCodeを呼び出して@vue/compiler-domまたは@vue/compiler-ssrを実行し、SourceMapConsumerでソースコードと出力の双方向マッピングを構築し、Monaco のデコレータ API でカーソル連動ハイライトを実現する。options.tsreactiveでCompilerOptionsを管理し、watchEffectで再コンパイルを駆動し、オプション間の連動関係(例:SSR がhoistStaticを無効化)は UI 層で明示的にコーディングされている。theme.tsMonaco テーマをカスタマイズし、テンプレートと出力の構文トークンに明確な視覚的区別を持たせる。
このツールの核心的価値は「ツールでコンパイラの動作を逆算する」ことにある:hoistStaticが特定のテンプレートに対して何を行ったか不明なとき、Template Explorer を開き、オプションを切り替え、出力の変化を観察する。これはコンパイラのソースコードを読むより直感的で、推測より信頼できる。
本章の考察とセルフチェック
Q1:index.ts内のoriginalPositionForの mock location ガード(pos.line === 1 && pos.column === 0)を削除した場合、どのようなシナリオで誤ったハイライトが発生するか?なぜコンパイラは{ line: 1, column: 0 }のようなマッピングを生成するのか?
参考解析:ガードは📎 packages-private/template-explorer/src/index.ts:231-237に位置する。コンパイラは出力生成時にテンプレートに対応する位置を持たないコードを挿入する。例えばimport { createElementVNode as _createElementVNode } from 'vue'のようなヘルパーインポート文、あるいはexport function render(_ctx, _cache) { ... }のような関数シグネチャである。これらのコードは SourceMap に元の位置がなく、source-map-jsは{ line: 1, column: 0 }をプレースホルダとして返す。ガードを削除すると、ユーザーがこれらの行にカーソルを置いたとき、originalPositionForが返す{ line: 1, column: 0 }、コードはこれを有効な位置と見なし、ソースコードエディタの1行1列目にハイライトデコレータを作成します。結果として、ユーザーが成果物のimport行をクリックすると、ソースコードエディタの1行目が誤ってハイライトされ、誤解を招きます。このガードの本質は「実際のマッピングとプレースホルダーマッピングを区別する」ことであり、{ line: 1, column: 0 }はsource-map-jsで約定された「マッピングなし」のセンチネル値です。
Q2: reCompileで永続化オプションを使用する場合、条件typeof val !== 'object' && val !== defaultOptions[key]はすべてのオブジェクト型のオプションをスキップします。もしbindingMetadataがユーザーによって変更された場合(例えばコンソール経由で)、ページをリロードするとこの変更は失われます。これはバグでしょうか、それとも意図的な設計でしょうか?永続化でbindingMetadataをサポートする場合、どのような問題を解決する必要があるでしょうか?
参考解析:条件は📎 packages-private/template-explorer/src/index.ts:129にあります。これは意図的な設計であり、理由は3つあります。第一に、bindingMetadataの値はBindingTypes列挙型であり、シリアライズ後は数値となり、デシリアライズ時に「ユーザーが明示的に0に設定した」のか「デフォルト値」なのかを区別できません。第二に、compatConfigはネストされたオブジェクトであり、val !== defaultOptions[key]は参照を比較するため、常にtrueとなり、すべてのオブジェクトオプションが永続化されてしまいます。第三に、nodeTransformsは関数を含み、シリアライズできないため、ソースコードでは既にdelete persistedState.options?.nodeTransformsを通じて📎 packages-private/template-explorer/src/index.ts:69を処理しています。bindingMetadataをサポートする場合、深い比較(参照比較ではなく)を実装し、列挙値のシリアライズ/デシリアライズを処理する必要があります。より根本的な問題は、bindingMetadataにはUI上に編集入口がなく、ユーザーはコンソール経由でしか変更できず、そのような変更自体が永続化されるべきではないということです。
Q3: options.tsでcompilerOptionsはreactive(Object.assign({}, defaultOptions))を使って作成されます。もしObject.assign({}, defaultOptions)を直接reactive(defaultOptions)に変更した場合、ユーザーがオプションを切り替えた後にページをリロードすると何が起こるでしょうか?なぜでしょうか?
参考解析:Object.assign({}, defaultOptions)は浅いコピーであり、📎 packages-private/template-explorer/src/options.ts:29-31にあります。もしreactive(defaultOptions),compilerOptionsとdefaultOptionsに変更すると、同じオブジェクトを指すようになります。ユーザーがhoistStaticをtrueに切り替えると、compilerOptions.hoistStaticがtrueになり、同時にdefaultOptions.hoistStaticもtrueになります。そしてreCompileの永続化ロジック📎 packages-private/template-explorer/src/index.ts:129がval !== defaultOptions[key]を比較し、この時valとdefaultOptions[key]は両方ともtrueであるため、条件はfalseとなり、このオプションはlocalStorageに保存されません。ページをリロードすると、defaultOptionsはhoistStatic: falseに再初期化され、ユーザーの変更は失われます。さらに深刻なのは、defaultOptionsが汚染されると、以降のすべての「デフォルト値との比較」ロジックが無効になり、永続化機能が完全に崩壊することです。このバグの隠蔽性は、単一セッション内ではすべて正常に動作し、リロード後に初めて発見できる点にあります。
---
次の章ではscripts/release.jsに入り、Vueがインタラクティブなステートマシンでバージョン番号の更新、ビルド、テスト、Gitコミット、タグ付け、npm publishの全プロセスをどのように編成しているかを見ていきます。Template Explorerの「観察」とは異なり、release.jsは「実行」です——複数のステップ間で状態を維持し、失敗時のロールバックを処理し、インタラクティブな確認と自動化のバランスを取る必要があります。
Template Explorerを通じて、私たちはコンパイラの内部状態——AST、コンパイル成果物、SourceMap——をインタラクティブな可視化プローブに変換し、「コンパイラがなぜこのように生成するのか」を推測から観察へと変える方法を習得しました。この内部状態の精密な制御と編成は、Vueのリリースプロセスにも同様に現れています。次の章ではscripts/release.jsを深く掘り下げ、500行余りのステートマシンがparseArgsで十数のフラグを解析し、enquirerでバージョン番号をインタラクティブに確認し、順番にビルド、テスト、Gitコミット、タグ付け、npm publishをトリガーする様子を明らかにし、正式リリースの背後にある完全な状態遷移と失敗時のロールバック戦略を明らかにします。
この章を読み終えましたか?ご自身のプライベートリポジトリを技術書へ
Tauri 2 + Rust によるローカルファースト設計。100% オフラインの安全性、コードのクラウド送信は一切ありません。不変コミットアンカーで精読。
⚡ Tauri 2 · Rust コア · 100% 完全オフライン · 100万行超のコードベース検証済
第 9 章:リリース自動化:release.jsのステートマシンとインタラクティブ編成
前の章ではtemplate-explorerを活用してコンパイラの動作を逆推し、ツールで内部メカニズムを観察する方法論を習得しました。今度は、視点をコンパイル時からリリース時へと移します——これはすべてのオープンソースプロジェクトにとって最も危険な瞬間です。バージョン番号、ビルド成果物、Git履歴、npm registryという4つの不可逆な外部システムに同時に触れるからです。一度の誤ったnpm publishは取り消せず、一度の誤ったタグプッシュはすべての下流ユーザーの依存解決を汚染します。Vue coreは537行のscripts/release.jsでこの危険を飼いならしています——それは純粋な自動化スクリプトでも、純粋な手動チェックリストでもなく、インタラクティブなステートマシンです:重要なポイントでは立ち止まって人に尋ね、予測可能なポイントでは完全自動で実行し、どのステップで失敗してもバージョン番号を開始点にロールバックします。本章ではこのオーケストレーターの3つの中核メカニズムを分解します:引数解析と状態初期化、インタラクティブなバージョン決定とCIゲート、そしてリリース順序と失敗時のロールバックです。
引数解析とグローバル状態の初期化
直感モデル
release.jsを古い式の洗濯機のコントロールパネルとして想像してください:ノブ(parseArgs)はどのモードを使うかを決め、インジケーターランプ(グローバル変数)は現在どの段階にあるかを記録し、「キャンセル」ボタン(エラー処理)はマシンを給水前の状態に戻せなければなりません。この初期化ロジックがなければ、スクリプトは「ユーザーが実際にどのバージョンをリリースしたいのか」という問題で制御を失います——間違ったバージョン番号をリリースするか、CIで永遠に来ないキーボード入力を待ってスタックするかのどちらかです。
フラグとグローバル状態のメモリレイアウト
スクリプト起動後の最初の処理は、コマンドライン引数を構造化オブジェクトに解析することです。ここではNode組み込みのparseArgsを使用しており、yargsやcommanderではありません——これはサードパーティ依存を排除するためです。なぜなら、リリーススクリプト自体はどのような環境でも動作する必要があり、node_modulesが半分しかインストールされていない場合でも同様です。
📎 scripts/release.js:27-62は10個のオプションを定義しており、4つのカテゴリに分類できます:
- バージョンセマンティクス類:
preid(プレリリース識別子、例:alpha/beta/rc)、tag(npm dist-tag) - スキップ類:
skipBuild、skipTests、skipGit、skipPrompts——これら4つのブールスイッチが「自動化の度合い」を調整するノブを構成します - 実行モード類:
dry(ドライラン)、publish(ローカルで直接公開するかどうか)、publishOnly(公開のみでバージョン更新なし) - ターゲット類:
registry(カスタムregistryアドレス)
注意すべきはpublishのデフォルト値がfalse 📎 scripts/release.js:51-54であり、他のブール項目にはデフォルト値がない(つまりundefined)ことです。この非対称性は意図的です:publishのセマンティクスは「ローカルでnpm publishを実行するかどうか」であり、デフォルトでは公開せず、公開アクションをGitHub Actionsに委ねます。一方、skipXxxのデフォルトundefinedは「未指定」を意味し、後続のロジックで「ユーザーが明示的に--skipTestsを渡した」場合と「ユーザーが渡さなかった」場合を区別します。
解析完了後、スクリプトはパラメータをモジュールレベルの変数群に展開します📎 scripts/release.js:64-66:
const preId = args.preid || semver.prerelease(currentVersion)?.[0]
const isDryRun = args.dry
let skipTests = args.skipTests
const skipBuild = args.skipBuild
const skipPrompts = args.skipPrompts
const skipGit = args.skipGitここには興味深い設計が2点あります。第一に、preIdの値の優先順位は「コマンドラインでの明示的指定 > 現在のバージョン番号からの推論」📎 scripts/release.js:64-66です。もし現在のpackage.jsonのバージョンが3.5.0-beta.1であれば、semver.prereleaseは['beta', 1]を返し、[0]を取ると'beta'になります。これは、betaブランチで連続してリリースする際に、毎回--preid betaを入力する必要がないことを意味します。第二に、skipTestsはletで宣言されていますが、他はconst 📎 scripts/release.js:64-66を使用しています。なぜなら、それはrunTestsIfNeeded内でCI結果によって動的に書き換えられるからです——これは「遅延決定」の状態ビットです。
続いてパッケージ発見ロジック📎 scripts/release.js:68-83です:packages/ディレクトリを読み取り、非ディレクトリ項目、package.jsonを持たない項目、およびprivate: trueのパッケージをフィルタリングします。ここで読み取るのはpackages/であり、packages-private/ではないことに注意してください——後者は内部デバッグパッケージであり、決して公開されません。
公開順序のソートアルゴリズム
📎 scripts/release.js:85-85は一見単純でありながら極めて重要な関数を定義しています:
const sortPackagesForPublishing = (packageNames) => [
...packageNames.filter(p => p !== 'vue'),
...packageNames.filter(p => p === 'vue'),
]これはvueというエントリパッケージを最後に配置します。コメント📎 scripts/release.js:85-85がその理由を説明しています:もし先にvueを公開すると、ユーザーは@vue/runtime-coreなどの内部パッケージがまだ公開されていない段階で新しいvueをインストールできてしまい、npmは一致する内部依存を見つけられずエラーを報告します。これは「公開の原子性」をnpmエコシステム下で妥協した解決策です——npmにはクロスパッケージトランザクションがなく、順序によって原子性に近づけるしかありません。
バージョン増分候補セットの動的構築
📎 scripts/release.js:111-116はインタラクティブメニューの候補項目を構築します:
const versionIncrements = [
'patch', 'minor', 'major',
...(preId ? ['prepatch', 'preminor', 'premajor', 'prerelease'] : []),
]これは条件付き展開です:preIdが存在する場合(つまり現在プレリリースチャネルにあるか、ユーザーが明示的に--preidを指定した場合)にのみ、プレリリース関連の増分タイプをメニューに追加します。もし現在が安定版3.5.43でpreidが指定されていない場合、メニューにはpatch/minor/majorの3項目のみです——ユーザーが誤操作で安定版を3.5.44-0のような中途半端なプレリリースバージョンにすることを避けます。
inc関数📎 scripts/release.js:120-120はsemver.incをラップし、preIdを第3引数として渡します。ここに型防御があります:typeof preId === 'string' ? preId : undefined——なぜならpreIdはstring | undefinedの可能性があり、semver.incはstring | undefinedを期待するため、この三項式はTSの型絞り込みを満たすためです。
実行プリミティブ:runとdryRunの二重トラック制
📎 scripts/release.js:122-123は本章で最も精巧な設計の一つです:
const run = async (bin, args, opts = {}) =>
exec(bin, args, { stdio: 'inherit', ...opts })
const dryRun = async (bin, args, opts = {}) =>
console.log(pico.blue(`[dryrun] ${bin} ${args.join(' ')}`), opts)
const runIfNotDry = isDryRun ? dryRun : runrunは子プロセスのstdioをinheritに設定し、ビルド/テストの出力を直接ターミナルに透過させます——これは長時間実行されるビルドにとって極めて重要で、ユーザーはリアルタイムの進捗を確認できます。dryRunはコマンドを印刷するだけで実行しません。runIfNotDryは「戦略選択」です:モジュールロード時に既に関数ポインタをdryRunまたはrunにバインドし、後続のすべての呼び出し箇所でisDryRun。
この「初期化時に戦略を決定する」パターンは「各呼び出し箇所で判断する」よりもエラーが発生しにくいです:もしある呼び出し箇所でisDryRunの判断を忘れると、ドライランモードで実際に副作用が実行されてしまいます。しかしrunIfNotDryは判断を一箇所に集中させ、このような漏れの可能性を排除します。
flowchart TD
start["node scripts/release.js"] --> parse["parseArgs 解析 10 个选项"]
parse --> preid{"args.preid 存在?"}
preid -->|是| use_arg["preId = args.preid"]
preid -->|否| infer["preId = semver.prerelease(currentVersion)[0]"]
use_arg --> scan["扫描 packages/ 目录"]
infer --> scan
scan --> filter{"是目录 且 有 package.json 且 非 private?"}
filter -->|否| skip_pkg["排除该包"]
filter -->|是| keep_pkg["加入 packages 列表"]
skip_pkg --> build_menu
keep_pkg --> build_menu
build_menu{"preId 存在?"} -->|是| full["versionIncrements = patch/minor/major + 4 个 pre*"]
build_menu -->|否| stable["versionIncrements = patch/minor/major"]
full --> dispatch{"args.publishOnly?"}
stable --> dispatch
dispatch -->|是| publish_only["fnToRun = publishOnly"]
dispatch -->|否| main_fn["fnToRun = main"]---
インタラクティブなバージョン決定とCIゲート
直感的モデル
この段階は空港の保安検査のようなものです:まず搭乗券を確認し(ローカルコミットがリモートと同期しているか)、次にどこへ行くかを確認し(バージョン番号)、最後に保安検査を通過したかチェックします(CIが通過したか)。いずれかの段階で失敗すると、プロセス全体が中止されます。このゲートがなければ、プッシュされていないローカルコミットにタグが付けられて公開され、npm上のバージョンに対応するソースコードがGitHub上に存在しない可能性があります——これは最もトラブルシューティングが困難なリリース事故です。
同期チェックとバージョン選択
main関数の最初の処理はisInSyncWithRemote() 📎 scripts/release.js:141-141です。この関数📎 scripts/release.js:337-363のロジックは:現在のブランチ名を取得し、GitHub APIにリクエストしてそのブランチの最新コミットSHAを取得し、ローカルのgit rev-parse HEADと比較します。もし一致しなければ、赤い警告の確認ダイアログ📎 scripts/release.js:348-355を表示し、ユーザーが続行するかどうかを決定します。もしAPIリクエストが失敗した場合(ネットワーク問題、トークンなし)、直接falseを返し📎 scripts/release.js:365-367。
〔設計推論とアーキテクチャのトレードオフ〕
ここでの設計哲学は「失敗即中止」です:ネットワーク異常時には、状態が不明なまま続行するリスクを冒すよりも、公開しない方を選びます。なぜなら公開は不可逆であり、スクリプトを再実行するコストは非常に低いからです。node scripts/release.js 3.6.0),targetVersionバージョン番号の決定には2つのパスがあります。もしユーザーがコマンドラインで位置引数を渡した場合(例:📎 scripts/release.js:141-141は直接その値を取ります📎 scripts/release.js:152-176。そうでなければインタラクティブメニューcustomに入ります:まずユーザーに増分タイプを選択させ、もし
を選んだ場合はさらに入力ボックスを表示してユーザーにバージョン番号を手入力させます。📎 scripts/release.js:174注意
targetVersion = release.match(/\((.*)\)/)?.[1] ?? ''コピーpatch (3.5.44)メニュー項目の形式はcustomであり、この正規表現は括弧内から実際のバージョン番号を抽出します。もしユーザーが📎 scripts/release.js:164-172。
を選んだ場合、別の分岐📎 scripts/release.js:178-182を通りますtargetVersionその後「二次解析」ロジックpatch/minorこの種の増分キーワード(ユーザーが直接渡す可能性があるnode release.js minor)は、incを呼び出して具体的なバージョン番号に変換します。最後にsemver.validで📎 scripts/release.js:184-186を検証し、不正なバージョン番号は直接エラーをスローします。
CIゲート:runTestsIfNeededの三態ロジック
これは章全体で最も複雑な制御フローです。📎 scripts/release.js:281-317のrunTestsIfNeededは実際には三態決定マシンです:
状態1:ユーザーが明示的に--skipTests。skipTestsを渡した場合、初期値はtrueで、関数本体全体をスキップし、「Tests skipped.」と出力します。📎 scripts/release.js:314-316。
状態2:スキップされておらず、かつCIが通過済み。スクリプトはgetCIResult() 📎 scripts/release.js:319-335を呼び出し、GitHub Actions APIにリクエストして、ciという名前でconclusion === 'success'のworkflow runが存在するか確認します📎 scripts/release.js:319-335。通過していれば、ユーザーに「CI已通过,是否跳过本地测试?」と尋ねます📎 scripts/release.js:288-295。ユーザーが--skipPromptsを有効にしていれば、ローカルテストを自動スキップします📎 scripts/release.js:296-298。
状態3:スキップされておらず、かつCIが未通過。--skipPromptsが有効なら、直接エラーをスローします📎 scripts/release.js:299-304:
throw new Error(
'CI for the latest commit has not passed yet. ' +
'Only run the release workflow after the CI has passed.',
)有効でなければ、--skipPromptsはskipTestsのまま保持され、最後のローカルテスト分岐に落ちますundefined、📎 scripts/release.js:307-313を実行しますpnpm run test --run。
ここに微妙な詳細があります📎 scripts/release.js:285:
skipTests ||= isCIPassed||=は論理OR代入です:skipTestsが偽値(undefinedまたはfalse)の場合のみisCIPassedを代入します。つまり、ユーザーが明示的に--skipTests(trueを渡した場合、この行はそれを変更しません;ユーザーが渡さなかった場合(undefined)、CI結果を設定します。しかし直後に📎 scripts/release.js:287-298がCI通過時に再代入します——したがって||=この行の実際の効果は「CIが未通過の場合、skipTestsをfalseに設定する」ことであり、それにより後続のif (!skipTests)分岐でローカルテストが実行されます。
このロジックは回りくどいですが、本質的には「CI通過 → ローカルテストをスキップ可能(ただしユーザーに確認);CI未通過 → ローカルテストを必ず実行(ユーザーが明示的にスキップを要求しない限り)」を表現したいのです。||=に後続の上書きを加える書き方はコンパクトですが可読性が低く、典型的な「状態ビットが複数箇所で変更される」コードスメルです。
sequenceDiagram
participant Dev as 开发者
participant Main as main()
participant Git as git CLI
participant GH as GitHub API
participant Pnpm as pnpm
Dev->>Main: node scripts/release.js
Main->>Git: getBranch() / getSha()
Git-->>Main: branch, sha
Main->>GH: fetch commits/{branch}
GH-->>Main: remote sha
alt sha 不一致
Main->>Dev: prompt 确认继续?
Dev-->>Main: yes/no
end
Main->>Dev: prompt 选择版本增量
Dev-->>Main: "patch (3.5.44)"
Main->>Main: semver.valid 校验
Main->>GH: getCIResult() 查询 workflow_runs
GH-->>Main: workflow_runs[]
alt CI 通过
Main->>Dev: prompt 跳过本地测试?
Dev-->>Main: yes
else CI 未通过
Main->>Pnpm: run test --run
Pnpm-->>Main: exit code
end
Main->>Main: updateVersions(targetVersion)バージョン番号の書き込み:updateVersionsの走査
📎 scripts/release.js:377-384のupdateVersionsは2つのことを行います:ルートpackage.jsonを更新し、すべてのサブパッケージを走査してupdatePackage。updatePackage 📎 scripts/release.js:391-398を呼び出してJSONを読み取り、nameとversionを書き換え、JSON.stringify(pkg, null, 2) + '\n'で書き戻します——末尾の\nに注意してください。これはファイルが改行で終わることを保証し、git diffで「No newline at end of file」が表示されるのを避けるためです。
getNewPackageNameパラメータのデフォルトはkeepThePackageName 📎 scripts/release.js:105、つまりパッケージ名を変更しません。このパラメータの存在は「カスタムregistryに公開する際にパッケージをリネームする」シナリオをサポートするためです——現在の呼び出し箇所はすべてデフォルト値を渡していますが、インターフェースは拡張性を確保しています。
---
公開順序、冪等性、失敗時のロールバック
直感的モデル
この段階はドミノ倒しのようなものです:updateVersions最初の牌(バージョン番号の変更)を倒すと、後続のchangelog、lockfile、commit、tag、publishが順に倒れます。途中で牌が詰まった場合、既に倒れた牌を起こす仕組みが必要です——そうでなければリポジトリは「バージョン番号は変更されたが公開されていない」中途半端な状態に留まります。
冪等公開:isPackagePublishedとエラーフォールバック
publishPackage 📎 scripts/release.js:439-489は公開の核心です。まずdist-tag📎 scripts/release.js:442-451を決定します:--tagパラメータを優先し、そうでなければバージョン番号内のalpha/beta/rcキーワードから推論します。ここではversion.includes('alpha')ではなくsemver.prereleaseを使用していることに注意——バージョン番号が3.5.0-alpha.1,includesの形式である可能性があり、十分シンプルで誤判定しないためです。
公開前に冪等性チェックがあります📎 scripts/release.js:453-458:
if (!isDryRun && (await isPackagePublished(packageName, version))) {
console.log(pico.yellow(`Skipping already published: ${pkgVersion}`))
alreadyPublishedPackages.push(pkgVersion)
return
}isPackagePublished 📎 scripts/release.js:491-513はnpm view <pkg>@<version> versionを実行し、成功すればtrueを返し、E404系エラーならfalseを返します。このチェックの意義は:公開フローはネットワーク中断で再実行される可能性があり、再実行時に既公開のパッケージを再度公開すべきではない(npmは重複バージョンを拒否する)ことです。
しかしチェック自体も失敗する可能性があります——例えばnpm viewがネットワークタイムアウトでE404以外のエラーをスローする場合です。このときisPackagePublishedはエラーを上位にスローし📎 scripts/release.js:507-510、公開全体が中止されます。これは「危険を冒すより中止する」のもう一つの現れです。
チェックが通過しても、pnpm publish自体は競合(別のCIが同じバージョンを公開した)で失敗する可能性があります。そのためpublishPackageはcatchブロックで二次フォールバックを行います📎 scripts/release.js:480-488:
} catch (e) {
if (e.message?.match(/previously published/)) {
console.log(pico.red(`Skipping already published: ${pkgVersion}`))
alreadyPublishedPackages.push(pkgVersion)
} else {
throw e
}
}にマッチした場合のみpreviously publishedエラーを飲み込み、その他のエラーはすべて再スローします。これは「精密なフォールトトレランス」です:既知の、安全に無視できるエラーのみを降格処理します。
公開フラグの動的組み立て
📎 scripts/release.js:412-432は実行環境に応じてpnpm publishの追加フラグを組み立てます:
const additionalPublishFlags = []
if (isDryRun) additionalPublishFlags.push('--dry-run')
if (isDryRun || skipGit || process.env.CI)
additionalPublishFlags.push('--no-git-checks')
if (process.env.CI && !args.registry)
additionalPublishFlags.push('--provenance')--no-git-checksは3つの場合に有効化されます:dry run、gitスキップ、またはCI内。理由はpnpm publishがデフォルトでワークスペースがクリーンか、現在のブランチがリリースブランチかなどをチェックし、CI内ではこれらのチェックが誤報を出すためです。
--provenanceはCIかつカスタムregistryが指定されていない場合のみ有効化されます📎 scripts/release.js:425-427。provenanceはnpmのサプライチェーンセキュリティ機能で、ビルド成果物の出所情報(どのcommit、どのworkflow)を署名してパッケージに添付します。しかしカスタムregistry(内部プライベートregistryなど)は通常provenanceをサポートしないため、!args.registryの条件が追加されています。
失敗時のロールバック:versionUpdatedフラグ
の末尾に戻りますmainコピー📎 scripts/release.js:528-537:
fnToRun().catch(err => {
if (versionUpdated) {
updateVersions(currentVersion)
}
console.error(err)
process.exit(1)
})versionUpdated、false 📎 scripts/release.js:24-27の呼び出し成功直後にupdateVersionsに設定されます。後続のいずれかのステップ(changelog生成、lockfile更新、git commit、publish)がエラーをスローした場合、catchブロックがこのフラグをチェックし、true 📎 scripts/release.js:208であればバージョン番号をtrueにロールバックしますcurrentVersion。
このロールバックは「ベストエフォート」である:それはpackage.json内のバージョン番号のみをロールバックし、changelog ファイル、lockfile、すでに実行された git commit はロールバックしない。エラーが git commit の後に発生した場合、リポジトリには「バージョン番号はロールバックされたが commit は存在する」という中間状態が残る。これは設計上のトレードオフである——完全なロールバックにはgit resetが必要だが、それはユーザーがすでに行った他の変更を破壊する可能性がある。そのためスクリプトは最も重要なバージョン番号のみをロールバックし、残りはユーザーが手動で処理することを選択している。
注意publishOnlyパス📎 scripts/release.js:519-526はversionUpdatedを設定しない。そのセマンティクスは「公開のみ、バージョン変更なし」であるため——失敗してもロールバックは不要である。しかしtargetVersionが存在する場合にupdateVersions 📎 scripts/release.js:519-526を呼び出すため、この時失敗するとバージョン番号はロールバックされない。これは潜在的な境界問題であり、章末の思考問題を参照。
flowchart TD
upd["updateVersions(targetVersion)"] --> flag["versionUpdated = true"]
flag --> changelog["pnpm run changelog"]
changelog --> lock["pnpm install --prefer-offline"]
lock --> gitdiff{"git diff 有输出?"}
gitdiff -->|是| commit["git add -A && git commit"]
gitdiff -->|否| nochange["No changes to commit"]
commit --> pub{"args.publish?"}
nochange --> pub
pub -->|是| build["buildPackages()"]
pub -->|否| push
build --> publish["publishPackages()"]
publish --> push["git tag && git push"]
push --> done["完成"]
changelog -.->|抛错| rollback["catch: updateVersions(currentVersion)"]
lock -.->|抛错| rollback
commit -.->|抛错| rollback
publish -.->|抛错| rollback
rollback --> exit["process.exit(1)"]公開順序と vue パッケージの特別な処理
publishPackages 📎 scripts/release.js:412-432はsortPackagesForPublishing(packages)の結果を走査し、順番にpublishPackageを呼び出す。ソートによりvueが最後に配置されるため📎 scripts/release.js:85-85、公開シーケンス全体で内部パッケージが先に公開されることが保証される。
publishPackageは内部的にcwd: getPkgRoot(pkgName) 📎 scripts/release.js:475を使って作業ディレクトリをサブパッケージディレクトリに切り替えるため、pnpm publishはルートパッケージではなくサブパッケージを公開する。コメント📎 scripts/release.js:462-463は特に「npm publish に変更しないでください」と注意している——なぜならpnpm publishはworkspace:*依存プロトコルを正しく処理し、実際のバージョン番号に変換できるが、npm publishはworkspace:*をそのまま保持してインストール失敗を引き起こすからである。
---
設計上の考察
なぜparseArgsではなくyargs?を使うのか 公開スクリプトは「最後の防衛線」であり、どのような環境でも実行可能でなければならない。サードパーティの CLI ライブラリが依存ツリーの破損により読み込みに失敗すると、公開プロセス全体が麻痺する。Node 組み込みのparseArgsは機能が貧弱(サブコマンド非対応、自動 help 非対応)だが、ゼロ依存、ゼロリスクである。
なぜpublishをデフォルトでfalse?に設定するのか Vue の正式リリースは GitHub Actions 経由で行われるため(📎 scripts/release.js:256-263のヒントメッセージを参照)、ローカルスクリプトはバージョン番号の変更、changelog の生成、タグ付け、プッシュのみを担当する。実際のnpm publishは CI で実行され、CI の provenance 署名と制御された環境を活用できる。--publishフラグはメンテナーが緊急時にローカルで公開するための脱出ハッチである。
なぜロールバックはバージョン番号のみをロールバックするのか?完全なロールバックには「どの変更がスクリプトによるものか、どの変更がユーザーによるものか」を理解する必要があるが、これは git レベルでは区別できない。スクリプトは自分が変更したと最も確信できるもの——package.jsonのバージョン番号——のみをロールバックし、残りはユーザーの判断に委ねることを選択している。
---
本章のまとめ
scripts/release.jsは 537 行のコードで「対話型ステートマシン」を実装しており、その核心設計は三点に集約できる:
1. パラメータ即ポリシー:10 個のフラグがモジュール読み込み時に解析されグローバル変数に展開され、runIfNotDryが初期化時にポリシーをバインドし、呼び出し箇所での判断漏れを防ぐ。
2. ゲート前置:同期チェック、バージョン検証、CI ゲートはすべて副作用が発生する前に完了し、「すべてやるか、まったくやらないか」を保証する。
3. 精密なフォールトトレランス:isPackagePublished事前チェック +previously publishedエラーフォールバックが二重の冪等性保護を構成し、versionUpdatedフラグが最小限のロールバックを実現する。
このメカニズムは前章の Template Explorer と興味深い対照をなしている:Template Explorer は「観察」——コンパイラの内部状態を可視化する;release.js は「実行」——公開プロセスの各ステップの状態を明示化する。両者とも同じエンジニアリング哲学を体現している:暗黙的な状態を明示的な状態に変え、制御不能な副作用を制御可能なステップに変える。
本章の考察とセルフチェック
Q1:📎 scripts/release.js:285のskipTests ||= isCIPassedをskipTests = isCIPassedに変更した場合、ユーザーが明示的に--skipTestsを渡し、かつ CI が通過しなかった場合に何が起こるか?なぜか?
参考解析:元のロジックでは、ユーザーが--skipTestsを渡すとskipTestsは初期値true 📎 scripts/release.js:64-66,||=のままで変更されないため、runTestsIfNeededは📎 scripts/release.js:282のif (!skipTests)で偽と判定され、直接📎 scripts/release.js:314-316にジャンプして "Tests skipped." を出力する。もしskipTests = isCIPassedに変更すると、skipTestsは強制的にfalseに設定され(CI 未通過)、その後📎 scripts/release.js:287のif (isCIPassed)が偽となり、📎 scripts/release.js:299のelse if (skipPrompts)に到達する——もし--skipPromptsが有効でなければ、skipTestsはfalseのままとなり、最終的に📎 scripts/release.js:307-313でローカルテストが実行される。これはユーザーの「明示的にテストをスキップする」意図に反し、CI 環境(--skipPrompts)では直接エラー📎 scripts/release.js:300-303をスローし、公開が中止される。||=の存在はまさにユーザーの明示的な選択を尊重するためである。
Q2: publishOnlyパス📎 scripts/release.js:519-526はtargetVersionが存在する場合にupdateVersionsを呼び出すが、versionUpdatedを設定しない。この時buildPackagesまたはpublishPackagesがエラーをスローすると何が起こるか?この設計は妥当か?
参考解析:publishOnlyはupdateVersions(targetVersion) 📎 scripts/release.js:519-526を呼び出してすべてのpackage.jsonのバージョン番号を変更したが、versionUpdated = trueを設定していない。後続のbuildPackages 📎 scripts/release.js:519-526またはpublishPackages 📎 scripts/release.js:519-526がエラーをスローすると、fnToRun().catch 📎 scripts/release.js:528-537はversionUpdatedがfalseであることを確認し、バージョン番号をロールバックしない。結果としてリポジトリは「バージョン番号は変更されたが公開が失敗した」状態に留まる。この設計はpublishOnlyの元のセマンティクス(公開のみ、バージョン変更なし)の下では合理的である——なぜならtargetVersionは通常渡されず、updateVersionsは実行されないからである。しかしユーザーがtargetVersionを渡した場合、このパスにはロールバックの脆弱性が存在する。修正方法は📎 scripts/release.js:519-526の後にversionUpdated = trueを追加するか、publishOnlyにmainのロールバックロジックを再利用させることである。
Q3: isPackagePublished 📎 scripts/release.js:491-513はnpm viewを使ってパッケージが公開済みかどうかをチェックする。ネットワークタイムアウトによりnpm viewが E404 以外のエラーをスローした場合、何が起こるか?この動作は CI 再実行シナリオで安全か?
参考解析:isPackagePublishedは catch ブロック内で📎 scripts/release.js:507-510を呼び出してisPackageNotFoundErrorエラータイプを判定する。この関数📎 scripts/release.js:515-515は/E404|No match found|No matching version|notarget/iのみにマッチする。ネットワークタイムアウトエラーの message にはこれらのキーワードが含まれないため、isPackageNotFoundErrorはfalse,isPackagePublishedを返し📎 scripts/release.js:507-510エラーを再スローする。このエラーは上方に伝播しpublishPackage 📎 scripts/release.js:453その結果、リリース全体が中止される。CI 再実行のシナリオでは、これは「パッケージは公開済みなのに、ネットワークの揺らぎで中止される」という事態を招く——しかしこれは安全な失敗方向である。中止は「未公開」と誤判定して重複公開するより良い。重複公開は npm のpreviously publishedエラーを引き起こし、📎 scripts/release.js:491-492によってフォールバックされるが、ネットワーク往復を1回無駄にする。したがって「ネットワークエラー即中止」は保守的だが正しい選択である。
---
次の章では.github/workflows/に入り、release.js が tag をプッシュした後、GitHub Actions がどのように後続のビルドと公開を引き継ぐか、そして CI ゲートの完全な実装を見ていく。
ここまでで、release.js が状態機械と対話的なオーケストレーションによって、不可逆なリリースリスクを最小化する方法が明らかになった。しかしリリーススクリプト自体は単なる実行者にすぎず、いつトリガーし、どのような条件で通過させるかを実際に決定するのは、より上位の自動化された門番である。次の章では .github/workflows ディレクトリ配下の CI/CD 体系を分析する:ci.yml が PR 段階で lint/typecheck/test の三重ゲートをどのように実行するか、release.yml が tag プッシュ時にどのようにリリースをトリガーするか、size-report.yml と size-data.yml がどのようにバンドルサイズのリグレッションを追跡するか、autofix.yml がどのようにフォーマット問題を自動修正するか。Vue が GitHub Actions を用いて、エンジニアリング規範を回避不可能なパイプラインとしてどのように固化しているかを理解できるだろう。
この章を読み終えましたか?ご自身のプライベートリポジトリを技術書へ
Tauri 2 + Rust によるローカルファースト設計。100% オフラインの安全性、コードのクラウド送信は一切ありません。不変コミットアンカーで精読。
⚡ Tauri 2 · Rust コア · 100% 完全オフライン · 100万行超のコードベース検証済
第 10 章:CI/CD ワークフロー:PR から Release までの自動化された門番
前の章ではscripts/release.jsが対話的な状態機械を用いて、1回のリリースの各ステップをどのようにつなげるかを見た。しかしそのスクリプトには前提がある:誰か、あるいは何らかのシステムによって能動的に呼び出されなければならない。Vue core リポジトリにおいて、この能動的な呼び出し元はメンテナのローカル端末ではなく、GitHub Actions である。release.js は実行者であり、workflows は意思決定者である——どのイベントがどのタスクをトリガーするか、どの条件で通過させ、どの条件でブロックするかを決定する。本章は.github/workflows/ディレクトリ配下の4つのファイルに焦点を当てる:ci.yml(PR ゲートと継続的プレリリース)、release.yml(tag トリガーによる正式リリース)、size-report.yml(バンドルサイズのリグレッションレポート)、autofix.yml(フォーマットの自動修正)。それらの核心を理解するとは、YAML 構文を覚えることではなく、Vue チームがエンジニアリング規範を回避不可能なパイプライン制約へとどのように翻訳しているかを見極めることである。
一、ci.yml:三重ゲートと継続的プレリリース
直感的モデル
ci.ymlを空港の保安検査場と想像してほしい。すべての PR はこのゲートを通らなければならない:lint は荷物に禁止物品がないか検査し、typecheck は身分証が本物で有効かを確認し、test は危険物を持ち込んでいないかを検証する。しかし保安検査場は1つだけではない——Vue はここに「継続的プレリリース」チャネルも設けており、各 PR のビルド成果物を直接 pkg-pr-new に公開し、コントリビューターが実際の npm インストールシナリオで自分の変更を検証できるようにしている。
もしこのゲートがなければ、あらゆるマージがフォーマットエラー、型の穴、あるいは動作のリグレッションを main ブランチに持ち込みうる。そして main ブランチは、その後のすべての release の源流である。
トリガー条件と並行制御
ci.ymlのトリガー設定は、1行ずつ分解する価値がある。
📎 .github/workflows/ci.yml:2-11
on:
push:
branches:
- '**'
tags:
- '!**'
pull_request:
branches:
- main
- minorここには2つの重要な設計がある。第一に、pushイベントはすべてのブランチ('**')を監視するが、tags: ['!**']によってすべての tag プッシュを明示的に除外している。なぜ tag を除外するのか? tag プッシュはrelease.ymlが単独で処理するため、もしci.ymlも tag に反応すると、リリースフローと CI フローが重複してトリガーされ、runner リソースを浪費し、さらには競合状態を生むからである。第二に、pull_requestはmainとminorの2つのブランチのみを監視する——これは Vue のデュアルブランチ戦略である:mainは安定版を担い、minorはプレリリース版を担う。
📎 .github/workflows/ci.yml:22-22
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}並行制御はここで最も巧妙な一手である。groupの式はgithub.event.pull_request.number || github.refを fallback として用いる:PR イベントは PR 番号をグループキーとし、push イベントは ref(ブランチ名)をグループキーとする。これは同じ PR の複数回のプッシュが同じ並行グループに落ちることを意味する。そしてcancel-in-progressは PR イベントのときのみtrueとなる——3回連続でコミットをプッシュすると、最初の2回の CI は自動的にキャンセルされ、最新の1回だけが保持される。
この設計の動機は明確である:PR 段階では開発者が頻繁にプッシュし、古いコミットの CI 結果はすでに無意味であり、それらをキャンセルすることで大量の runner 時間を節約できる。しかし main ブランチへの push はキャンセルできない——main 上の各 push はリリース前の最後の検証でありうるため、キャンセルすると検証の欠落を招くからである。
三重ゲートの入口:test job の条件判定
📎 .github/workflows/ci.yml:22-22
jobs:
test:
if: ${{ ! startsWith(github.event.head_commit.message, 'release:') && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository) }}
uses: ./.github/workflows/test.ymlこのif条件は2つの論理積(&&)の分岐を含み、それぞれを展開する価値がある。
最初の条件! startsWith(github.event.head_commit.message, 'release:'):コミットメッセージがrelease:冒頭で、テストをスキップします。これはまさに前章の release.js がプッシュしたコミットメッセージの形式です——release.js はローカルで既に完全なテストを実行済みであり、CI は重複検証する必要がありません。これは「上流を信頼する」最適化です。
2つ目の条件(github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository):push イベントは常にテストを実行し;PR イベントは PR が fork からの場合を要求します(head.repo.full_name != github.repository)。なぜ fork の PR のみ実行するのか? 同一リポジトリのブランチの PR は通常コアチームメンバーが作成し、彼らのブランチプッシュは既に push イベントの CI をトリガーしているからです。一方、fork の PR は push イベントをトリガーしません(fork の push は上流リポジトリに通知されない)。そのため、PR イベントで補完実行する必要があります。
注意uses: ./.github/workflows/test.yml——これは reusable workflow の呼び出しです。test.ymlは独立した workflow ファイルであり、ci.ymlとrelease.ymlで共有されています。この再利用により、複数の workflow で lint/typecheck/test のステップを重複定義することを避けています。
継続的プレリリース:pkg-pr-new の役割
📎 .github/workflows/ci.yml:25-51
continuous-release:
if: github.repository == 'vuejs/core'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# ... 安装 pnpm、Node.js、依赖 ...
- name: Build
run: pnpm build --withTypes
- name: Release
run: pnpx pkg-pr-new publish --compact --pnpm './packages/*' --packageManager=pnpm,npm,yarncontinuous-releasejob はvuejs/coreメインリポジトリでのみ実行され(if: github.repository == 'vuejs/core')、fork では実行されません。それは3つのことを行います:ビルド(pnpm build --withTypes、型宣言付き)、次にpkg-pr-newを使って./packages/*以下のすべてのパッケージを一時的な npm registry に公開します。
このメカニズムの価値は:コントリビューターが自分のプロジェクトで直接npm installこの PR のビルド成果物を利用し、変更が本当に問題を解決したかを検証できることです。これは「CI がグリーンになったのを見る」よりも説得力があります。なぜなら、実際のパッケージ消費シナリオを検証しているからです。
すべての action が commit SHA をロックしていることに注意してください(例actions/checkout@3d3c42e5...)。これは@v4のような浮動タグを使用するのではなく。これはサプライチェーンセキュリティの厳格な要件です——action リポジトリが侵害された後に悪意のあるコードが自動的に流入するのを防ぎます。
ci.yml 制御フロー図
flowchart TD
trigger{"事件类型?"}
trigger -->|"push 到任意分支"| push_check{"提交信息以 release: 开头?"}
trigger -->|"PR 到 main/minor"| pr_check{"PR 来自 fork?"}
push_check -->|"是"| skip_test["跳过 test job"]
push_check -->|"否"| run_test["调用 test.yml"]
pr_check -->|"是"| run_test
pr_check -->|"否"| skip_test
run_test --> test_result{"test.yml 通过?"}
test_result -->|"否"| block["PR 被阻断"]
test_result -->|"是"| cont_release{"仓库是 vuejs/core?"}
cont_release -->|"是"| build["pnpm build --withTypes"]
cont_release -->|"否"| end_node["结束"]
build --> publish["pkg-pr-new publish"]
publish --> end_node---
二、release.yml:tag プッシュ後のリリースオーケストレーション
直感的モデル
もしci.ymlがセキュリティチェックポイントなら、release.ymlは発射台です。release.js がローカルでバージョン番号の更新、コミット、タグ付け、プッシュを完了した後、tag プッシュイベントがrelease.ymlのエンジンに点火します。それはまず完全なテストを実行し(再確認)、次に保護されたRelease環境でpnpm release --publishOnlyを実行し、最後に GitHub Release を作成します。
もしこれがなければ、release.js がプッシュした tag は単なる Git 参照に過ぎず、npm に新しいバージョンはなく、GitHub に Release ページもありません。
トリガー条件:tag のみを認識
📎 .github/workflows/release.yml:3-6
on:
push:
tags:
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10v*形式の tag プッシュのみを監視します。これはci.ymlのtags: ['!**']と補完関係にあります——両者は厳密に相互排他的で、同時にトリガーされることはありません。
リリース job のガード条件
📎 .github/workflows/release.yml:8-21
jobs:
test:
uses: ./.github/workflows/test.yml
release:
if: github.repository == 'vuejs/core'
needs: [test]
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
environment: Releaseここには3層のガードがあり、各層は省略できません。
第1層if: github.repository == 'vuejs/core':fork での誤ったリリーストリガーを防ぎます。もし誰かがリポジトリを fork してv1.0.0tag をプッシュした場合、この条件がリリースプロセスの実行を阻止します。
第2層needs: [test]:release job は test job に依存します。test job はtest.ymlを呼び出し、テストが失敗した場合、release job はまったく起動しません。これは「リリース前にテストを通過しなければならない」というハード制約です。
第3層environment: Release:これは GitHub Environment であり、デプロイ保護ルール(特定の人員の承認が必要など)を設定できます。これは、tag プッシュが workflow をトリガーしても、リリースステップが実行されるには手動承認が必要な場合があることを意味します——これは不可逆操作に対する最後の防衛線です。
権限に関して、contents: writeは GitHub Release の作成に使用され、id-token: writeは npm の provenance 認証(OIDC token)に使用されます。ここにはpackages: writeがないことに注意してください。なぜなら Vue は GitHub Packages ではなく npm に公開するからです。
リリースステップの完全なチェーン
📎 .github/workflows/release.yml:37-46
- name: Install deps
run: pnpm install --frozen-lockfile
- name: Update npm
run: npm i -g npm@latest
- name: Build and publish
id: publish
run: |
pnpm release --publishOnly3つのステップにはそれぞれ工夫があります。--frozen-lockfileは CI 環境が lockfile に厳密に従ってインストールし、依存バージョンのドリフトによってビルド成果物がローカルと不一致になることを防ぎます。npm i -g npm@latestは最新の npm CLI を取得するためです——provenance と OIDC 認証は比較的新しいバージョンの npm に依存しており、古いバージョンではこれらの機能がサポートされない可能性があるからです。
pnpm release --publishOnlyは前章の release.js のエントリポイントです。--publishOnlyフラグは release.js に伝えます:対話的なバージョン番号選択をスキップし、Git コミットとタグ付けをスキップし(tag は既に存在するため)、ビルドと npm publish のみを実行します。
GitHub Release の作成
📎 .github/workflows/release.yml:48-57
- name: Create GitHub release
id: release_tag
uses: yyx990803/release-tag@8cccf7c5aa332d71d222df46677f70f77a8d2dc0 # v1.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
body: |
For stable releases, please refer to [CHANGELOG.md](...) for details.
For pre-releases, please refer to [CHANGELOG.md](...) of the `minor` branch.ここでは Vue 作者の尤雨溪自身がメンテナンスするrelease-tag action。tag_name: ${{ github.ref }}を使用し、トリガーイベントの ref を直接使用します(つまりrefs/tags/v3.x.x)。Release body には具体的な変更内容を書かず、CHANGELOG.md を指し示す——Vue の changelog は conventional-changelog によって自動生成されるため、Release body を手動で管理すると changelog と不一致が生じるからである。
release.yml シーケンス図
sequenceDiagram
participant Dev as "开发者本地"
participant GH as "GitHub"
participant Test as "test.yml"
participant Rel as "release job"
participant NPM as "npm registry"
Dev->>GH: "git push origin v3.x.x"
GH->>Test: "触发 test.yml"
Test-->>GH: "测试通过"
GH->>Rel: "needs: [test] 满足"
Rel->>Rel: "environment: Release 审批"
Rel->>Rel: "pnpm install --frozen-lockfile"
Rel->>Rel: "pnpm release --publishOnly"
Rel->>NPM: "npm publish (OIDC provenance)"
NPM-->>Rel: "发布成功"
Rel->>GH: "release-tag 创建 Release"---
三、size-report.yml と autofix.yml:サイズ追跡とフォーマット自己修復
size-report.yml:ワークフロー横断のサイズ回帰レポート
size-report.ymlのトリガー方法は非常に特殊である——push や PR によって直接トリガーされるのではなく、別の workflow の完了イベントによってトリガーされる。
📎 .github/workflows/size-report.yml:3-7
on:
workflow_run:
workflows: ['size data']
types:
- completedworkflow_runイベントリスナー名はsize dataの workflow 完了。これは二段階設計である:size-data.yml(本章ではソースコード未提供)が PR 上でビルドとサイズ測定を行い、結果を artifact としてアップロードする;size-report.ymlがsize data完了後に artifact をダウンロードし、レポートを生成して PR にコメントする。
📎 .github/workflows/size-report.yml:20-23
if: >
github.repository == 'vuejs/core' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'三重ガード:メインリポジトリ、PR イベント、上流 workflow の成功。もしsize dataが失敗した場合、レポート job は実行されない——報告すべきデータがないからである。
データフローは以下の通り:
📎 .github/workflows/size-report.yml:41-46
- name: Download Size Data
uses: dawidd6/action-download-artifact@d63b86af1b34672e53c440b1b83979861906bad7 # v24
with:
name: size-data
run_id: ${{ github.event.workflow_run.id }}
path: temp/size上流 workflow run からsize-dataartifact をtemp/sizeにダウンロードする。その後、PR 番号と base ブランチを並行して読み取る:
📎 .github/workflows/size-report.yml:48-59
- parallel:
- name: Read PR Number
id: pr-number
uses: juliangruber/read-file-action@271ff311a4947af354c6abcd696a306553b9ec18 # v1.1.8
with:
path: temp/size/number.txt
- name: Read base branch
id: pr-base
uses: juliangruber/read-file-action@271ff311a4947af354c6abcd696a306553b9ec18 # v1.1.8
with:
path: temp/size/base.txtparallelは GitHub Actions のシンタックスシュガーで、依存関係のない二つのステップを同時に実行させる。number.txtとbase.txtはsize-data.ymlが測定時に書き込むメタデータファイルである。
次に base ブランチの履歴サイズデータを比較用にダウンロードする:
📎 .github/workflows/size-report.yml:61-69
- name: Download Previous Size Data
uses: dawidd6/action-download-artifact@d63b86af1b34672e53c440b1b83979861906bad7 # v24
with:
branch: ${{ steps.pr-base.outputs.content }}
workflow: size-data.yml
event: push
name: size-data
path: temp/size-prev
if_no_artifact_found: warn注意if_no_artifact_found: warn——base ブランチにまだ履歴データがない場合(例えば新規ブランチ)、失敗せず警告のみとなる。これにより初回実行時でもレポートは生成され、比較ベースラインがないだけであることが保証される。
最後にレポートを生成してコメントする:
📎 .github/workflows/size-report.yml:71-89
- name: Prepare report
run: node scripts/size-report.js > size-report.md
- name: Read Size Report
id: size-report
uses: juliangruber/read-file-action@271ff311a4947af354c6abcd696a306553b9ec18 # v1.1.8
with:
path: ./size-report.md
- name: Create Comment
uses: actions-cool/maintain-one-comment-backup@fbbc22ad1809c1bcf46f19b58397b6254773588c # backup for v3.0.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
number: ${{ steps.pr-number.outputs.content }}
body: |
${{ steps.size-report.outputs.content }}
<!-- VUE_CORE_SIZE -->
body-include: '<!-- VUE_CORE_SIZE -->'scripts/size-report.jsがtemp/sizeとtemp/size-prev配下のデータを読み取り、Markdown レポートを生成する。maintain-one-comment-backupaction はbody-include: '<!-- VUE_CORE_SIZE -->'をマーカーとして使用し、同一 PR 上にサイズレポートコメントが一つだけ保持されるようにする(追加ではなく更新)。L81 のコメントは元の action リポジトリが GitHub によってブロックされたため、バックアップリポジトリを使用し commit を固定したことを説明している。
autofix.yml:フォーマット問題の自動修復
autofix.ymlは非常に実用的な問題を解決する:コントリビューターが提出したコードのフォーマットが prettier/eslint 規約に準拠しておらず、CI がエラーを出し、コントリビューターが手動でpnpm lint --fixを実行して再提出する必要がある。この workflow はそのステップを自動化する。
📎 .github/workflows/autofix.yml:3-8
on:
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}すべての PR をトリガーし、並行制御はci.ymlと類似——同一 PR への新しいプッシュは古い autofix 実行をキャンセルする。
📎 .github/workflows/autofix.yml:35-41
- name: Run eslint
run: pnpm run lint --fix
- name: Run prettier
run: pnpm run format
- uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8まず eslint の--fixを実行し、次に prettier でフォーマットし、最後にautofix-ci/actionが修正されたファイルを直接 PR ブランチにコミットし戻す。注意pnpm run format自体がフォーマットコマンドである(--fixフラグは不要、なぜなら format スクリプト内部がprettier --write)。
このメカニズムの鍵はautofix-ci/actionが PR 作成者の身分で修正をコミットし、bot の身分ではないことである。これによりコントリビューターは追加操作が不要で、フォーマット修正が自動的に彼らの PR に現れる。しかしこれは、コントリビューターのブランチに保護ルールがある場合(bot のプッシュを許可しない)、autofix が失敗することを意味する——これはコントリビューターが手動で対処する必要があるエッジケースである。
size-report データフロー図
flowchart LR
subgraph "size-data.yml (上游)"
build_pr["构建 PR 分支"] --> measure["测量体积"]
measure --> artifact_pr["artifact: size-data\n(number.txt, base.txt, 体积数据)"]
end
subgraph "size-report.yml (下游)"
artifact_pr -->|"workflow_run 触发"| download["下载 size-data"]
download --> read_meta["读取 number.txt / base.txt"]
read_meta --> download_prev["下载 base 分支历史数据\n(if_no_artifact_found: warn)"]
download_prev --> gen_report["node scripts/size-report.js"]
gen_report --> comment["评论到 PR\n(标记: VUE_CORE_SIZE)"]
end---
設計思考:規範をパイプラインとして固化する
これら四つの workflow を振り返ると、一貫した設計原則がいくつか見えてくる。
第一に、権限の最小化。 ci.ymlとautofix.ymlはともにpermissions: contents: readを宣言し、release.ymlのみがcontents: writeとid-token: write。size-report.ymlが必要でpull-requests: writeとissues: writeがコメントを投稿する。各 workflow は本当に必要な権限だけを取得する。
第二に、サプライチェーンセキュリティ。すべてのサードパーティ action は commit SHA に固定され、浮動タグではない。size-report.ymlL81 のコメントはさらに直接的に、元の action リポジトリがブロックされた後にバックアップリポジトリに切り替え commit を固定したことを説明している——これはサプライチェーン攻撃に対する実戦的防御である。
第三に、責務の分離と再利用。 test.ymlはci.ymlとrelease.ymlに共有され、テストロジックの重複を避ける。size-data.ymlとsize-report.ymlを分離し、測定と報告がそれぞれ独立して進化できるようにする。
第四に、失敗方向の選択。 size-report.ymlのif_no_artifact_found: warnは「失敗ではなく警告」を選択する。なぜなら履歴データの欠如は PR をブロックすべきではないからである。一方release.ymlのneeds: [test]は「テスト失敗即リリースブロック」を選択する。なぜならリリースは不可逆操作だからである。
第五に、並行制御の差別化。PR イベントは古い実行をキャンセルし(cancel-in-progress: true)、push イベントはキャンセルしない(cancel-in-progress: false)。この差異は二つのイベントのセマンティクスを反映している:PR の古いコミットはもはや無意味であり、push の各コミットは最終状態である可能性がある。
---
本章のまとめ
本章では Vue core リポジトリの四つのコア workflow を分析した:
ci.yml:PR ゲート + 継続的プレリリース。if条件で push/PR と fork/同一リポジトリを区別し、concurrencyで古い PR 実行をキャンセルし、pkg-pr-newでインストール可能なプレリリースパッケージを公開する。release.yml:tag によってトリガーされる正式リリース。三層のガード(リポジトリチェック、needs test、environment 承認)により、テストに合格し承認された tag のみが npm に公開される。size-report.yml:ワークフローを跨いだサイズ回帰レポート。workflow_runイベントを通じて上流のsize data完了を監視し、artifact をダウンロードして base ブランチのデータと比較し、コメント形式で PR にフィードバックする。autofix.yml:フォーマットの自動修正。PR 上で eslint --fix と prettier を実行し、autofix-ci/actionを通じて修正を直接 PR ブランチにコミットし戻す。
これら4つのワークフローは共に「迂回不可能なパイプライン」を構成している:コード規約は autofix により自動修正され、型とテストは ci.yml により強制チェックされ、サイズ回帰は size-report により追跡され、リリースは release.yml により多重ガードの下で実行される。
本章の考察とセルフチェック
Q1: もしci.ymlにおけるcancel-in-progressの値を常にtrueに変更した場合(すなわちgithub.event_name == 'pull_request'の条件を削除した場合)、どのようなシナリオで問題が発生するか?
参考解説:cancel-in-progressが常にtrueであることは、main ブランチへの push 時に、新しい push が実行中の古い CI をキャンセルすることを意味する。次のシナリオを考えよう:main ブランチ上で2つの PR が連続してマージされ、最初の PR の CI が実行中(完全な lint/typecheck/test を含む)で、2番目の PR のマージが新しい CI 実行をトリガーした。もしcancel-in-progressがtrueであれば、最初の PR の CI はキャンセルされる——しかし最初の PR のコードはすでに main 上にあり、その CI 結果は main ブランチの健全性を判断するために極めて重要である。それをキャンセルすることは、main ブランチ上のコードの一部が完全に検証されたことがないことを意味する。一方、📎 .github/workflows/ci.yml:22-22の条件github.event_name == 'pull_request'はまさにこの問題を回避するためのものである:PR イベントのみが古い実行をキャンセルし、push イベントは決してキャンセルしない。
Q2: release.ymlにおけるreleasejob のif: github.repository == 'vuejs/core'とenvironment: Releaseはそれぞれどのようなシナリオを防御しているか?どちらかを削除するとどうなるか?
参考解説:if: github.repository == 'vuejs/core' 📎 .github/workflows/release.yml:14が防御しているのは fork シナリオである。もし誰かが vuejs/core を fork してv3.99.0tag を push した場合、この条件がなければ、ワークフローは fork リポジトリ内でpnpm release --publishOnlyを実行する。fork リポジトリには npm token がなく実際には公開できないが、runner リソースを浪費し、誤解を招く失敗通知を生成する可能性がある。environment: Release 📎 .github/workflows/release.yml:21が防御しているのは「tag プッシュ後の自動リリース」のリスクである——これにより人的承認を設定でき、tag がプッシュされてもリリースにはメンテナーの確認が必要となる。もしif条件を削除すると、fork がリソースを浪費する;もしenvironmentを削除すると、tag プッシュ権限を持つ誰もがリリースをトリガーでき、最後の人的確認ステップがなくなる。両者は異なるレベルの防御であり、互いに代替できない。
Q3: size-report.ymlにおけるif_no_artifact_found: warnの選択とrelease.ymlにおけるneeds: [test]の選択は、それぞれどのような失敗方向の設計哲学を体现しているか?もしこれら2つの戦略を交換すると何が起こるか?
参考解説:if_no_artifact_found: warn 📎 .github/workflows/size-report.yml:69は「履歴データが欠如している場合に失敗ではなく警告する」を選択している。なぜならサイズレポートは補助情報であり、ブロック条件ではないからである。もしfailに変更すると、新しいブランチや初回実行の PR は base データが見つからず失敗するが、これは明らかに不合理である。needs: [test] 📎 .github/workflows/release.yml:15は「テスト失敗即リリースブロック」を選択している。なぜならリリースは不可逆操作であり、コード品質を確保しなければならないからである。もし交換すると——size-report がデータ欠如時に失敗し、release がテスト失敗時でもリリースする——前者は大量の誤検知で正常な PR をブロックし、後者は未テストのコードが npm に入り込む。これは「補助情報は寛容に、不可逆操作は厳格に」という失敗方向の設計原則を体现している。
---
次章ではサイズ予算メカニズムの核心に深く踏み込む:scripts/size-report.jsがどのようにサイズデータを解析し、増分を計算し、出力をフォーマットするか、そしてusage-sizeの測定哲学——なぜ Vue が「完全パッケージサイズ」ではなく「実際使用サイズ」を測定することを選んだのか。
PR ゲートから tag リリースまで、4つのワークフローファイルが共に迂回不可能な自動化ゲートチェーンを構成している。しかしパイプラインがマージをブロックできるのは、定量化可能な判断根拠を掌握している前提があってのことである。次章では Vue のパッケージサイズという核心指標に対するエンジニアリング的ガバナンスに焦点を当てる:scripts/size-report.jsが各成果物の gzip 後サイズをどのように計算しベースラインと比較するか、scripts/usage-size.jsが実際のユーザー導入シナリオをどのようにシミュレートして実際のオーバーヘッドを見積もるか、そして CI がサイズ超過時にどのようにマージをブロックするか。
この章を読み終えましたか?ご自身のプライベートリポジトリを技術書へ
Tauri 2 + Rust によるローカルファースト設計。100% オフラインの安全性、コードのクラウド送信は一切ありません。不変コミットアンカーで精読。
⚡ Tauri 2 · Rust コア · 100% 完全オフライン · 100万行超のコードベース検証済
第 11 章:サイズ予算メカニズム:size-report と usage-size の測定哲学
前章で見たように、Vue は GitHub Actions を使って lint、型チェック、テスト、サイズ追跡を回避不可能なパイプラインとして固定化しています。その中で size-report.yml と size-data.yml が、変更のたびにサイズデータを残す役割を担っています。しかしパイプラインは実行するだけで、「どれだけ大きくなったか、どこが大きくなったか」に実際に答えるのは、本章で解き明かす2つのスクリプトです。サイズ予算の核心的な矛盾は、パッケージサイズが感知できても正確な帰属が難しい指標であるという点にあります。ユーザーが「Vue は大きすぎる」と不満を述べるとき、メンテナーは3つの問いに答える必要があります——どれだけ大きくなったか?どこが大きくなったか?今回の変更でさらに大きくなったか?scripts/size-report.js が比較を担当し、scripts/usage-size.js が帰属を担当し、両者が共にサイズ予算の測定哲学を構成しています。
11.1 size-report:サイズ差分を読みやすい Markdown テーブルに変える
直感的モデル
あなたが物流会社の品質検査員だと想像してください。各荷物(ビルド成果物)は出庫前に重量を測られますが、あなたの仕事は計量そのものではなく、「今日の重量」と「昨日の重量」を1つの表に並べ、太字の+2.3 kBでどの荷物が重くなったかを示すことです。この比較表がなければ、メンテナーは孤立した数字の山しか見えず、ある PR がサイズ回帰を導入したかどうかを判断できません。
size-report.jsがその品質検査員です。これはサイズデータを生成しません(それはusage-size.jsとビルドスクリプトの役割です)。2つのディレクトリにある JSON ファイルを消費し、Markdown レポートを生成するだけです。
データ構造とディレクトリ規約
スクリプトの核心的な規約は2つの定数に隠されています。現在のデータディレクトリはtemp/size、履歴ベースラインディレクトリはtemp/size-prev。
📎 scripts/size-report.js:23-24
です。これら2つのディレクトリの命名は恣意的ではありません:temp/sizeはsize-data.ymlワークフローが実行のたびに生成し、artifact としてアップロードします📎 .github/workflows/size-data.yml:53-57。一方、temp/size-prevはsize-report.ymlがベースライン artifact を取得して解凍したものです。ディレクトリ名そのものがデータフローの契約です。
スクリプトは3つの型エイリアスを定義しており、それらは JSON ファイルの構造を正確に描写しています:
📎 scripts/size-report.js:8-21
SizeResultには3つの数値フィールドがあります:size(非圧縮)、gzip、brotli。BundleResultはその上にfileフィールドを加えてファイル名を表示します。UsageResultはRecordであり、キーは preset 名、値はSizeResult & { name: string }です——ここでnameフィールドが1つ増えていることに注意してください。JSON オブジェクトのキーはObject.valuesの後に失われるため、名前を値の中に冗長に保存する必要があります。
Step-by-Step Walkthrough
メインフローは極めて簡潔で、2ステップと1回の出力だけです:
📎 scripts/size-report.js:23-38
run()まずrenderFiles()を呼び出して成果物ファイルのテーブルをレンダリングし、次にrenderUsages()を呼び出して使用シナリオのテーブルをレンダリングし、最後にモジュールレベルの変数outputに蓄積された文字列を一度に stdout へ書き出します📎 scripts/size-report.js:25。この「文字列を蓄積してから一括出力」というパターンは、複数回のprocess.stdout.writeの連結オーバーヘッドを避け、出力順序も完全に制御可能にします。
ステップ1:ファイルリストを収集して和集合を求める。
📎 scripts/size-report.js:44-49
filterFilesは2種類のファイルを除外します:_で始まるもの(例:_usages.json)と.txtで終わるもの(例:number.txt、base.txt)。これら2種類のファイルはメタデータであり、サイズデータではありません。次に現在のディレクトリと履歴ディレクトリのファイル名の和集合fileListを取ります——つまりSetで重複を排除します。なぜ和集合を取るのか?ファイルが履歴ディレクトリにのみ存在する場合(今回のビルドでその成果物が削除された)もあれば、現在のディレクトリにのみ存在する場合(今回のビルドで成果物が新規追加された)もあるからです。どちらの場合もレポートに反映する必要があります。
ステップ2:ファイルごとに比較する。
📎 scripts/size-report.js:43-75
和集合の各ファイルについて、それぞれ2つのディレクトリから JSON のインポートを試みます。importJSONの実装は「ファイルが存在しなければ undefined を返す」です:
📎 scripts/size-report.js:112-115
ここでは動的import()とwith: { type: 'json' }インポートアサーションを組み合わせて使っており、fs.readFileSync + JSON.parseではありません。前者は Node のモジュールローダーが処理し、後者はエンコーディングとパースエラーを手動で処理する必要があります。import()を選ぶ代償は Promise を返すことなので、renderFiles全体が async になっています。
重要な分岐はif (!curr)にあります:現在のディレクトリにこのファイルがなければ、その成果物は削除されたことを意味し、Markdown の取り消し線構文~~fileName~~で📎 scripts/size-report.js:60-61をマークします。そうでなければ通常通り1行をレンダリングし、各数値の後ろにgetDiffの結果を連結します。
ステップ3:差分を計算する。
📎 scripts/size-report.js:124-130
getDiffには3つの早期リターンポイントがあります:prev === undefinedのとき空文字列を返す(ベースラインがなく比較不能);diff === 0のとき空文字列を返す(変化なし、ノイズを表示しない);そうでなければ太字の符号付き差分を返します。注意すべきはprettyBytes(diff)が負数も正しく処理し、-1.2 kBのような形式を出力することです。一方sign変数は正数のときだけ+。
を補います。
📎 scripts/size-report.js:80-103
renderUsagesステップ4:usage テーブルをレンダリングする。renderFilesと_usages.jsonの構造の違いは注目に値します:これは直接Object.values(curr)をインポートします。usage データは常にこの1つのファイルに存在するからです。prev?.[usage.name]は Record を配列に変換した後、nameを通じて名前で履歴データを検索します——これこそが.filter(usage => !!usage)フィールドを冗長に保存する理由です。mapこの行は実際には冗長です。なぜなら
は常に配列要素を返し、falsy 値を生成しないからです。markdown-table最後に📎 scripts/size-report.js:72-74。
flowchart TD
start["run()"] --> rf["renderFiles()"]
rf --> read_curr["readdir(temp/size)"]
rf --> read_prev{"existsSync(temp/size-prev)?"}
read_prev -->|是| read_prev_dir["readdir(temp/size-prev)"]
read_prev -->|否| empty_prev["prev = []"]
read_curr --> union["fileList = Set(curr ∪ prev)"]
read_prev_dir --> union
empty_prev --> union
union --> loop{"遍历 fileList"}
loop -->|每个 file| import_c["importJSON(currPath)"]
loop -->|每个 file| import_p["importJSON(prevPath)"]
import_c --> check_curr{"curr 存在?"}
check_curr -->|否| deleted["push(~~fileName~~)"]
check_curr -->|是| render_row["push(fileName, size+diff, gzip+diff, brotli+diff)"]
deleted --> loop
render_row --> loop
loop -->|遍历结束| ru["renderUsages()"]
ru --> import_u["importJSON(_usages.json)"]
import_u --> table["markdownTable 渲染"]
table --> out["process.stdout.write(output)"]コピー
〔設計推論とアーキテクチャのトレードオフ〕import()なぜreadFileSync?ではなくimport()を使うのか 動的
filterFilesによる JSON のインポートアサーションは Node 20+ の標準的な手法であり、ESM 環境での JSON 読み込みを自然に処理します。代償は同期コンテキストで使用できないことと、インポートのたびにモジュールキャッシュされることですが、この使い捨てスクリプトではキャッシュは問題になりません。file[0] !== '_'の判断。readdirこの判断はファイル名が非空であることを前提としています。もしfile[0]が空文字列を返した場合(理論上あり得ません)、undefined,undefined !== '_'は
削除された成果物の処理。ある成果物が削除された場合、レポートでは取り消し線でマークされ、直接削除されることはない。これは意図的な設計である:メンテナは「このファイルが消えた」ことを確認する必要があり、テーブルから静かに消えてはならない。もし直接フィルタリングしてしまうと、読者はその成果物が存在しなかったと誤解するだろう。
11.2 usage-size:実際のユーザーの導入シナリオをシミュレートする
直感的モデル
size-report「完全なパッケージがどれくらい大きいか」を教えてくれるが、これはユーザーが本当に気にしている問題には答えられない:「自分はcreateAppしか使わないのに、実際にどれくらいのコードをダウンロードする必要があるのか?」完全なパッケージのサイズには、おそらく永遠に使わない大量のコードが含まれている(例えばdefineCustomElement、Transition、KeepAlive)。usage-size.jsの役割は「典型的なユーザー」を演じることである:特定の API だけを import する仮想エントリファイルを書き、Rollup でバンドルし、最終的な成果物がどれくらいの大きさになるかを見る。
これはレストランが「厨房にある全ての食材の総重量は50キロです」と伝えるのではなく、「宮保鶏丁を一つ注文すると、実際に使われる食材は300グラムです」と伝えるようなものである。
データ構造:Preset 配列
スクリプトの核心的なデータ構造はpresets配列であり、各要素が一つの使用シナリオを記述する:
📎 scripts/usage-size.js:27-55
Preset型には三つのフィールドがある:name(表示名)、imports(Vue からインポートする API リスト)、オプションのreplace(追加のコンパイル時置換)。五つの preset が最小から最大までの使用シナリオをカバーする:
createApp (CAPI only):createAppのみをインポートし、__VUE_OPTIONS_API__を'false'に置換し、純粋な Composition API ユーザーをシミュレートする📎scripts/usage-size.js:35-40createApp:createAppのみをインポートし、Options API を保持する📎scripts/usage-size.js:35-40createSSRApp:SSR シナリオ📎scripts/usage-size.js:35-40defineCustomElement:Web Components シナリオ📎scripts/usage-size.js:35-40overall:六つのコア API をインポートし、「フル機能」ユーザーをシミュレートする📎scripts/usage-size.js:44-54
エントリファイルは runtime-only の esm-bundler 成果物に固定されている:
📎 scripts/usage-size.js:24-28
vue.runtime.esm-bundler.jsではなく完全版vue.esm-bundler.jsを選択するのは、ランタイム版にはテンプレートコンパイラが含まれておらず、現代のビルドツールユーザーの実際の状況に近いためである——彼らは SFC でテンプレートを事前コンパイルしており、ランタイムコンパイラは不要である。
Step-by-Step Walkthrough
ステップ1:全ての preset のバンドルを並列生成する。
📎 scripts/usage-size.js:62-69
main()各 preset に対してgenerateBundleの Promise を作成し、Promise.allで並列実行する。ここでの並列化は安全である。なぜなら各generateBundle呼び出しは独立したrollup()を持ち、状態を共有しないからである。
ステップ2:仮想エントリを構築する。
📎 scripts/usage-size.js:94-96
これはスクリプト全体で最も精巧な部分である。一時ファイルをディスクに書き込むのではなく、仮想モジュール IDvirtual:entryを構築し、その内容は re-export 文である:export { createApp } from '/absolute/path/to/vue.runtime.esm-bundler.js'。なおentryは絶対パスである。Rollup がそれを解決できる必要があるためである。
ステップ3:Rollup プラグインチェーンを設定する。
📎 scripts/usage-size.js:98-121
プラグイン配列の順序は極めて重要である:
1. カスタムusage-size-plugin:resolveIdがvirtual:entryをインターセプトし自身を返し、loadが仮想コンテンツ📎 scripts/usage-size.js:101-110を返す。これは Rollup 仮想モジュールの標準パターンである。
2. nodeResolve():vue.runtime.esm-bundler.js内部の import📎 scripts/usage-size.js:111。
3. replaceを解決する:📎 scripts/usage-size.js:112-119。
replace:コンパイル時定数を注入する__VUE_OPTIONS_API__、__VUE_PROD_DEVTOOLS__プラグインの設定は esm-bundler 成果物の核心的な仕組みを明らかにする:それは
process.env.NODE_ENV→"production"などのランタイムフラグを保持し、使用者のビルドツールによって置換される。ここではスクリプトがユーザーの代わりに置換を行う:__VUE_PROD_DEVTOOLS__→'false':プロダクションブランチを使用__VUE_PROD_HYDRATION_MISMATCH_DETAILS__→'false':devtools サポートを無効化__VUE_OPTIONS_API__→'true':ハイドレーションの詳細エラーを無効化
:デフォルトで Options API を保持...preset.replaceその後createApp (CAPI only)を展開し、preset がデフォルト値を上書きできるようにする。__VUE_OPTIONS_API__preset はまさにこの仕組みを使って'false' 📎 scripts/usage-size.js:35-40。
preventAssignment: trueをobj.process.env.NODE_ENV = xに変更する📎 scripts/usage-size.js:117。
この種の代入文の置換を防ぐ
📎 scripts/usage-size.js:123-134
result.generate({})ステップ4:生成、圧縮、計測。output[0].codeがコードを生成し、
📎 scripts/usage-size.js:125-130
module: trueを取得する。その後 SWC で圧縮する:toplevel: trueは入力が ESM であることを示し、minified.lengthはトップレベルスコープの変数名の圧縮を許可する。圧縮後に三つの指標をそれぞれ計算する:gzipSync(minified).length、brotliCompressSync(minified).length。
(バイト長)、node:zlibここでは
の同期 API を使用しており、非同期版ではないことに注意。一回限りのスクリプトでは同期 API の方が簡潔であり、圧縮自体は CPU 集約的な操作であるため、非同期にしても並列の利益は得られない。
📎 scripts/usage-size.js:62-86
ステップ5:出力と永続化。pico結果はまず人間が読める形式でコンソールに出力され、📎 scripts/usage-size.js:62-86でtemp/size/_usages.jsonに着色される。その後Object.fromEntriesに書き込まれ、📎 scripts/usage-size.js:81-85。
--writeで配列を Record に戻し、キーは preset 名📎 scripts/usage-size.js:136-138フラグが各 preset の非圧縮バンドルを追加でディスクに書き出すかどうかを制御する
flowchart LR
subgraph preset_loop["presets 并行遍历"]
p1["Preset: createApp"]
p2["Preset: overall"]
end
p1 --> virtual["virtual:entry\n'export { createApp } from ...'"]
p2 --> virtual
virtual --> rollup["rollup({ input: virtual:entry })"]
rollup --> resolve["nodeResolve()\n解析 vue.runtime.esm-bundler.js"]
resolve --> replace["replace()\n__VUE_OPTIONS_API__ 等"]
replace --> gen["result.generate()\noutput[0].code"]
gen --> minify["swc.minify(module, toplevel)"]
minify --> metrics["size / gzipSync / brotliCompressSync"]
metrics --> json["_usages.json"]コピー
〔設計上の推論とアーキテクチャのトレードオフ〕なぜ一時ファイルではなく仮想モジュールを使うのか?resolveId/load一時ファイルはパスの処理、クリーンアップ、並行書き込みの競合に対処する必要がある。仮想モジュールはエントリの内容をメモリ内に保持し、Rollup の
replaceフックがこのパターンを自然にサポートする。代償は ID を正確に一致させる必要があることで、少しでもスペルミスがあると Rollup が「エントリを解決できない」と報告する。preventAssignmentのの落とし穴。preventAssignment: true,replaceもしprocess.env.NODE_ENV = 'x'を設定しないと、プラグインは"production" = 'x'のような代入文も置換してしまい、process.env.NODE_ENVの構文エラーが発生する。Vue のソースコードには確かに
__VUE_OPTIONS_API__への代入が存在する(テストユーティリティ内)ため、このオプションは必須である。のデフォルト値の選択。'true' 📎 scripts/usage-size.js:116スクリプトはデフォルト値を'false'ではなくcreateApp (CAPI only)に設定している。これは保守的な選択である:ユーザーが設定しなければ、Vue は Options API サポートを保持する。'false'preset は明示的に
に上書きし、無効化後のサイズ削減効果を示す。この対比自体がユーザーへのドキュメントである:「Options API をオフにするとどれだけ節約できるか」をユーザーに伝える。Promise.all並列の失敗セマンティクス。Promise.allは即座に reject され、他の進行中のパッケージングはキャンセルされない(Rollup はキャンセル機構を提供していない)。CI においてこれは、一度の失敗が他の preset の計算を無駄にすることを意味するが、スクリプト自体は非ゼロの終了コードで終了するため、CI は正しく検出できる。
11.3 データからゲートへ:CI がこれらのレポートをどう消費するか
データフロー全景
これら二つのスクリプトを理解するには、それらを CI パイプラインに戻して考える必要がある。size-data.ymlmain/minor への push または PR 時に実行されpnpm run size 📎 .github/workflows/size-data.yml:45を生成しtemp/sizeディレクトリを生成し、その後 artifact としてアップロードする📎 .github/workflows/size-data.yml:53-57。
PR の場合、さらに二つのメタデータファイルを書き込む:
📎 .github/workflows/size-data.yml:47-51
number.txtには PR 番号が保存され、base.txtにはターゲットブランチ名が保存される。これら二つのファイルこそがsize-report.jsにおいてfilterFilesがフィルタリングで除外する.txtファイルである📎 scripts/size-report.js:44-45。それらが存在するのは、下流のsize-report.ymlが「どのベースラインと比較すべきか」を知るためである。
ベースラインの取得と比較
size-report.yml(前章で詳述)のワークフローは:現在の PR のsize-dataartifact をダウンロードし、ターゲットブランチのベースライン artifact をダウンロードし、ベースラインをtemp/size-prevに解凍し、その後size-report.jsを実行して Markdown レポートを生成し PR にコメントする。
ここに重要な設計制約がある:size-report.js自体はベースラインの取得を担当せず、temp/size-prevが既に存在することを前提とする。存在しない場合、existsSync(prevDir)は false を返し、prevは空配列📎 scripts/size-report.js:48となり、すべての diff は空文字列となる。これは優雅なデグラデーションである:ベースラインがない場合でもレポートは生成されるが、差異は表示されない。
サイズゲートの判定ロジック
よくある誤解を明確にする必要がある:size-report.js自体はゲート判定を行わない。レポートを生成するだけで、終了コードを返さず、閾値も設定しない。実際のゲートはsize-report.ymlワークフローレベルで発生する——レポート内の diff 値を解析し、閾値を超えた場合に job を失敗させるステップを含む可能性がある。
この「測定と判定の分離」という設計には深い理由がある:測定スクリプトは純粋に保たれ、事実を生成するだけであるべき;判定ロジックはワークフローレベルにあるべきである。なぜなら閾値はバージョン、ブランチ、リリース段階によって変化しうるからである。閾値をsize-report.jsにハードコードすると、再利用が困難になる。
設計思考
なぜサイズ予算には二つの測定が必要なのか?完全パッケージサイズと usage サイズは異なる問いに答える。完全パッケージサイズは「上限」である——最悪の場合にユーザーがどれだけダウンロードする必要があるかを示す。usage サイズは「典型値」である——大多数のユーザーが実際にどれだけダウンロードするかを示す。両者を組み合わせて初めて完全なサイズ像が得られる。完全パッケージサイズだけでは、メンテナはマイナーな API を過度に最適化する傾向がある;usage サイズだけでは、一部のエッジケースでのサイズ爆発を見落とす可能性がある。
gzip と brotli の二重指標の意義。現代の CDN は一般に brotli をサポートするが、すべてのシナリオで有効とは限らない。両方を報告することで、メンテナは「gzip のみをサポートする環境でのサイズはどうか」を評価できる。brotli は通常 gzip より 15-20% 小さいが、この差自体が価値ある情報である。
データフォーマットの安定性契約。 size-report.jsとusage-size.jsは JSON ファイルを介して疎結合される。usage-size.jsが書き_usages.json,size-report.jsが読む。この契約のフィールド名(name、size、gzip、brotli)は暗黙的であり、schema 検証はない。もしusage-size.jsがフィールド名を変更しsize-report.jsの同期を忘れた場合、レポートは静かに誤ったデータを表示する。これが現在の設計の脆弱点である。
本章のまとめ
本章の考察とセルフチェック
Q1: size-report.jsのfilterFilesは_で始まるファイルをフィルタリングで除外する。もしusage-size.jsが出力ファイルを_usages.jsonからusages.jsonに改名した場合、何が起こるか?
参考解析:filterFilesのフィルタ条件はfile[0] !== '_' && !file.endsWith('.txt') 📎 scripts/size-report.js:44-45である。もしファイルがusages.jsonに改名されると、もはや_で始まらないため、filterFilesに保持され、fileListの和集合に入る。そしてrenderFilesはそれを bundle ファイルとして処理しようとする:importJSONは正常にインポートできる(合法な JSON である)が、その構造はRecord<string, UsageResult>ではなくBundleResultであるため、curr?.fileはundefined,fileNameとなり空文字列となり、curr.sizeもundefined,prettyBytes(undefined)となりエラーを投げるか異常を出力する。これによりレポート生成が失敗する。この問題の根源はfilterFilesがファイル名のプレフィックスを「メタデータ vs データ」の区別基準としており、ディレクトリ構造や明示的なマニフェストを使っていないことである。より堅牢な方法は、usage データをサブディレクトリに置くか、明示的なメタデータファイルリストを維持することである。
Q2: usage-size.jsにおいてPromise.all(tasks)はすべての preset のパッケージングを並列実行する。もしある preset のreplace設定が__VUE_OPTIONS_API__を欠いている場合、何が起こるか?なぜデフォルト値が'true'ではなく'false'?
に設定されているのか?:replace参考解析__VUE_OPTIONS_API__: 'true'プラグインの設定において、...preset.replaceがデフォルト値であり、その後展開される📎 scripts/usage-size.js:116-118が'true'の上書きを許可する。もしある preset が設定を欠いている場合、デフォルト値'true'を使用し、すなわち Options API サポートを保持し、サイズが大きくなる。デフォルト値を__VUE_OPTIONS_API__に設定するのは保守的な選択である:それは「ユーザーが設定しない場合の実際の挙動」を反映する。Vue の esm-bundler 成果物において、'false'のデフォルト挙動は Options API を保持することである(ユーザーが明示的に無効化しない限り)。もしデフォルト値をcreateApp (CAPI only)に設定すると、明示的に設定していないすべての preset が小さめのサイズを表示し、「設定しなければサイズを節約できる」とユーザーを誤解させる。'false' 📎 scripts/usage-size.js:35-40preset が明示的に
Q3: size-report.jsに設定されているのは、まさに「明示的に無効化した後の利益」を示し、デフォルト値と対比させるためである。importJSONのimport()はfs.readFileSyncではなく動的temp/size-prevを使用する。もし
ディレクトリ内の JSON ファイルが破損している(不正な JSON)場合、二つの実装の挙動はどう異なるか?参考解析import():動的SyntaxErrorは不正な JSON を解析する際にimportJSONを投げ、このエラーはexistsSync内部のexistsSyncファイルの存在のみをチェックし、内容の正当性はチェックしない📎 scripts/size-report.js:112-115。エラーは上位に伝播しrenderFilesに到達し、レポート生成全体が失敗する。もしfs.readFileSync + JSON.parseを使用した場合も同様にエラーがスローされるが、importJSONの内部で try-catch で囲み、undefinedを返すことで優雅なデグラデーションを実現できる。現在の実装はエラーを伝播させる選択をしており、暗黙の前提は「artifact 内の JSON は必ず正当である」というものである——この前提は CI 環境では通常成立する。なぜならファイルはusage-size.jsとビルドスクリプトによって生成されるからである。しかしローカルデバッグ時、手動で JSON ファイルを変更して破損させた場合、レポートはそのファイルをスキップするのではなく直接クラッシュする。これは「データソースを信頼する」という設計上の選択である。
---
サイズ予算メカニズムは「何を測定するか」と「どのように比較するか」という問題を解決したが、それはビルド成果物自体が再現可能であるという前提に依存している。次の章では最小デバッグサンドボックスに入る:vite-debug最小限の設定でインタラクティブな Vue 開発環境を起動する方法、そしてそれがローカルビルド成果物とどのように連動し、ソースコードの変更からランタイム検証までの閉ループを形成するか。
ここまでで、サイズ予算の測定閉ループは明確になった:size-report.js はディレクトリ比較で「どれだけ大きくなったか」を答え、usage-size.js は仮想モジュールで実際のインポートシナリオをシミュレートして「どこが大きいか」を答え、ゲート判定はワークフロー層に委ねられる。このメカニズムにより、サイズ回帰は曖昧な不満から追跡可能なデータへと変わった。しかしデータは問題の存在を教えてくれるだけで、実際に特定して修正するには、問題を素早く再現できる最小環境が必要である。次の章では packages-private/vite-debug に入り、Vue が Vite + SFC でどのように極簡デバッグサンドボックスを構築し、「実際のソースコード上で最小再現を行う」ことを実行可能な日常実践に変えているかを見る。
この章を読み終えましたか?ご自身のプライベートリポジトリを技術書へ
Tauri 2 + Rust によるローカルファースト設計。100% オフラインの安全性、コードのクラウド送信は一切ありません。不変コミットアンカーで精読。
⚡ Tauri 2 · Rust コア · 100% 完全オフライン · 100万行超のコードベース検証済
第 12 章:最小デバッグサンドボックス:vite-debug とローカル開発閉ループ
前の章ではサイズ予算の測定閉ループを完成させた:size-report.js は「どれだけ大きくなったか」を答え、usage-size.js は「どこが大きいか」を答え、ワークフロー層がゲート判定を担当する。しかしこのメカニズムには暗黙の前提がある——ビルド成果物自体が再現可能であることだ。あるパッケージのサイズが異常に膨張していることに気づいたり、あるランタイム動作が期待と異なる場合、ローカルソースコードを素早く読み込み、変更後すぐに効果を確認できる最小環境が必要になる。packages-private/vite-debug がその環境である。わずか4つのファイル、合計40行未満のコードでありながら、Vue core リポジトリにおける「実際のソースコード上で最小再現を行う」日常実践の入口を構成している。本章ではこのサンドボックスの構築ロジックをファイルごとに分解し、なぜこれが packages ではなく packages-private ディレクトリに置かれているのかを説明する。
一、サンドボックスの骨格:main.tsとApp.vueの最小マウントチェーン
直感モデル
Vue ランタイム全体をエンジンに例えるなら、vite-debugは「ベアメタルテストベンチ」である——外殻もダッシュボードもなく、エンジンを動かすための最小限の配線だけがある。その価値は機能の完全性ではなく、すべての干扰変数を排除することにある:あるバグがリアクティブシステムやレンダラー内部にあると疑っているとき、デバッグ環境自体の複雑さがノイズ源になることは望まない。
データ構造とファイルレイアウト
まずmain.tsの全内容を見る:
📎 packages-private/vite-debug/main.ts:4-4
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.mount('#app')この6行のコードは Vue アプリケーション起動の標準パラダイムだが、各行はデバッグシナリオにおいて正確な工学的意味を持つ:
- L1の
import { createApp } from 'vue'において、'vue'というモジュール識別子が最終的に何に解決されるかは、完全にvite.config.tsとpackage.jsonの依存宣言によって決まる。これがサンドボックス全体で最も重要な一环である——これがどのようにローカルソースコードを指すようになるかは後で見る。 - L2の
import App from './App.vue'が@vitejs/plugin-vueの SFC コンパイルパイプラインをトリガーする:Vite は dev server 起動時にこのプラグインを登録し、ブラウザがApp.vueをリクエストすると、プラグインはそれを<script>、<template>、<style>の3つの仮想モジュールに分解して個別にコンパイルする。 - L4の
createApp(App)がアプリインスタンスを作成し、このとき Vue 内部でapp._context、app._instanceなどのコアフィールドが初期化されるが、まだレンダリングはトリガーされない。 - L6の
app.mount('#app')が真の起動スイッチである:DOM 内で id がappのコンテナ要素を探し、ルートコンポーネントインスタンスを作成し、初回レンダリングをトリガーする。
ここにindex.htmlの参照がないことに注意——Vite の規約ではプロジェクトルートディレクトリ下のindex.htmlがエントリ HTML であり、そこに<div id="app"></div>と<script type="module" src="/main.ts"></script>が含まれる。このファイルは本章の keyFiles にはないが、app.mount('#app')が成功する前提である。
シナリオ駆動の Walkthrough:1回のクリックの完全なチェーン
次にApp.vueを見る。これはこのサンドボックスの「実験キャリア」である:
📎 packages-private/vite-debug/App.vue:4-8
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<button @click="count++">{{ count }}</button>
</template>
<style>
button {
color: red;
}
</style>具体的なシナリオを代入する:ユーザーがブラウザでボタンをクリックしたとき、何が起こるか?
第一步:SFC コンパイル期(dev server 起動時)
@vitejs/plugin-vueがApp.vueを3つの部分にコンパイルする:
<script setup>ブロックはコンポーネントのsetup()関数にコンパイルされ、ref(0)呼び出しはRefImplオブジェクトを返し、その.valueは初期状態で0。<template>ブロックはレンダー関数にコンパイルされ、{{ count }}は_toDisplayString(count.value),@click="count++"に変換され、onClick: $event => (count.value++)。<style>は<style>に変換される
ブロックは CSS モジュールにコンパイルされ、app.mountタグを通じて DOM に注入される。
createApp(App)第二步:初回レンダリング(mount('#app')のとき、ルートコンポーネントのComponentInternalInstanceを作成し、setup()を実行してcountの RefImpl を取得し、その後レンダー関数を呼び出して VNode ツリーを生成します。レンダー関数内でcount.valueを読み取るとtrackが依存関係の収集をトリガーします——現在アクティブなレンダー副作用(ReactiveEffect)がcountのdepに記録されます。
第三步:クリックイベント(ユーザーインタラクション時)
ブラウザがclickイベントをトリガーし、Vue のイベントハンドラがcount.value++を実行します。これは setter 操作であり、triggerをトリガーします:count.depで収集された副作用を走査し、再レンダリングをスケジュールします。同期更新でありバッチキュー内にないため、レンダー副作用は即座に実行され、レンダー関数が再度呼び出されて新しい VNode が生成され、古い VNode と diff され、テキスト内容が0から1に変化したことが検出され、実際の DOM のtextContent。
が更新されます。チェーン全体は以下のデータフロー図で表せます:
flowchart LR
subgraph compile["编译期 (Vite Dev Server)"]
sfc["App.vue"] -->|"@vitejs/plugin-vue"| script["setup() 函数"]
sfc -->|"@vitejs/plugin-vue"| render["渲染函数"]
sfc -->|"@vitejs/plugin-vue"| style["CSS 模块"]
end
subgraph runtime["运行时 (浏览器)"]
script -->|"ref(0)"| refimpl["RefImpl { value: 0 }"]
render -->|"读取 count.value"| track["track() 收集依赖"]
click["用户点击"] -->|"count.value++"| trigger["trigger() 触发更新"]
trigger -->|"调度渲染副作用"| rerender["重新执行渲染函数"]
rerender -->|"diff + patch"| dom["更新真实 DOM"]
end
track -.->|"dep 记录 ReactiveEffect"| triggerこの図の鍵は:コンパイル時产物とランタイム動作の間の結合点は2つだけ——ref(0)が返す RefImpl オブジェクト、およびレンダー関数内でのcount.valueの読み書き。つまり、レスポンシブシステムの特定の分岐(例えばtrigger内のスケジューリングロジック)をデバッグしたい場合、App.vue内で対応する読み書きパターンを構築するだけで済みます。
設計上の考察:なぜrefではなくreactive?
を選択ref(0)ではなくreactive({ count: 0 })をデフォルト例として使用することは、デバッグ優先の考慮を暗に示しています:refの.valueアクセスパスがより短く、デバッガでRefImplオブジェクトを展開すると_value、dep、__v_isRefなどの内部フィールドを直接確認できますが、reactiveが返す Proxy オブジェクトをコンソールで展開すると getter がトリガーされ、元の状態の観察が妨げられる可能性があります。「最小再現」シナリオでは、Proxy 間接層を1つ減らすことは変数が少ないことを意味します。
---
二、エイリアス解決:vite.config.tsとpackage.jsonがどのように'vue'をローカルソースコードに指し示すか
直感モデル
vite.config.tsはわずか6行ですが、サンドボックス全体の「ルーティングハブ」です——import { createApp } from 'vue'内の'vue'が最終的に npm 上のリリース版をロードするのか、リポジトリで開発中のソースコードをロードするのかを決定します。正しいエイリアス設定がなければ、App.vueで変更したコードがデバッグ中の Vue ソースコードをトリガーしない可能性があり、デバッグは「間違った的を撃つ」ことになります。
データ構造と解決チェーン
まずvite.config.ts:
📎 packages-private/vite-debug/vite.config.ts:4-6
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
})ここで明示的なresolve.alias設定がありません。では'vue'はどのようにローカルソースコードに解決されるのか?答えはpackage.jsonにあります:
📎 packages-private/vite-debug/package.json:1-15
{
"name": "vite-debug",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"serve": "vite preview"
},
"devDependencies": {
"@vitejs/plugin-vue": "catalog:",
"vite": "catalog:",
"vue": "workspace:*"
}
}鍵はL13:"vue": "workspace:*"です。これは pnpm workspace プロトコルの宣言であり、vite-debugが monorepo 内のvueという名前のローカルパッケージに依存していることを示し、npm registry 上のバージョンではありません。pnpm はnode_modules/vueにシンボリックリンクを作成し、packages/vue(Vue のメインパッケージディレクトリ)を指します。
しかしこれだけでは不十分——packages/vueのpackage.json内のmain/module/exportsフィールドは通常ビルド产物(例えばdist/vue.runtime.esm-bundler.js)を指し、src/下のソースコードではありません。もしpackages/runtime-core/src/renderer.tsを変更しても再ビルドしなければ、Vite は依然として古いdistファイルをロードします。
これが、Vue core リポジトリのpackages/vue/package.jsonに通常"development"条件付きエクスポートや同様のソースエントリマッピングが設定されている理由です——dev モードでは、Vite のresolve.conditionsがdevelopment条件を優先的にマッチし、src/index.tsではなくdistをロードします。このメカニズムにより、vite-debugは明示的な alias 設定なしで、ソースコード変更後に HMR を通じて即座に効果を確認できます。
シナリオ駆動のウォークスルー:一度のimport 'vue'の解決プロセス
シナリオを想定:Vite dev server がブラウザからのmain.tsへのリクエストを受け取り、import { createApp } from 'vue'に遭遇したとき、解決チェーンはどうなるか?
flowchart TD
req["浏览器请求 /main.ts"] --> parse["Vite 解析 import 'vue'"]
parse --> resolve{"resolve 条件匹配"}
resolve -->|"development 条件命中"| src_entry["packages/vue/src/index.ts"]
resolve -->|"仅 production 条件"| dist_entry["packages/vue/dist/vue.runtime.esm-bundler.js"]
src_entry -->|"源码模块图"| hmr["HMR 监听 src/ 变更"]
dist_entry -->|"预构建产物"| no_hmr["无源码级 HMR"]
hmr -->|"修改 renderer.ts"| reload["浏览器热更新"]
no_hmr -->|"修改 renderer.ts"| stale["仍加载旧产物"]
reload --> verify["验证行为变更"]
stale --> rebuild["需手动重新构建"]
rebuild --> verifyこのフロー図は重要な分岐を明らかにしています:もしdevelopment条件が正しく設定されていなければ、ソースコード変更後にブラウザはホットアップデートされず、「コードを変更したが動作が変わらない」という困惑に陥ります。調査方法はブラウザ DevTools の Network パネルでvueモジュールの実際のロードパスを確認することです——もしdist/パスが見えたら、ソースエントリマッピングが有効でないことを示します。
設計上の考察:なぜvite.config.tsに明示的に alias を書かないのか?
自然な疑問は:なぜvite.config.tsに直接resolve: { alias: { vue: '../../packages/vue/src/index.ts' } }を書かないのか?これは直感的ですが、2つの問題があります:
1. サブパスインポートを破壊する:Vue の公開 API にはvue/server-renderer、vue/compiler-sfcなどのサブパスが含まれます。もし'vue'自体のみを alias すると、サブパスインポートは依然としてdistを通り、一部のモジュールがソースコードから、一部が产物から来ることになり、動作が不一致になります。
2. 条件付きエクスポートメカニズムをバイパスする:Vue のpackage.json内のexportsフィールドは既に完全な条件付きエクスポートマッピング(development/production/browser/nodeなど)を定義しており、alias はこのメカニズムを上書きし、デバッグ環境と実際のユーザー環境の解決動作に偏差を生じさせます。
したがって、vite-debugは「workspace プロトコル + 条件付きエクスポートを信頼する」組み合わせを選択し、解決チェーンを可能な限り実際の使用シナリオに近づけています。これもpackage.json内の"vue": "workspace:*"が必須である理由を説明しています——それは pnpm シンボリックリンクをトリガーし、Vite がnode_modules/vueを通じてpackages/vueを見つけられるようにする前提条件です。
本番の落とし穴:catalog:プロトコルとバージョンドリフト
注意package.json内のL11-L12が"catalog:"プロトコルを使用しています:
"@vitejs/plugin-vue": "catalog:",
"vite": "catalog:",これは pnpm の catalog 機能であり、バージョン番号がpnpm-workspace.yaml内のcatalogフィールドで統一的に管理されることを示します。その役割はmonorepo 内の複数のパッケージが同じ依存を参照する際のバージョンドリフトを回避すること。
デバッグシナリオでは、これが隠れた罠をもたらします:もしvite-debugVite または plugin-vue の疑わしいバグに遭遇し、一時的にバージョンをアップグレードして検証したい場合、直接package.json内のcatalog:を変更しても無効です——あなたはpnpm-workspace.yaml内の catalog 定義を変更する必要があり、これはその catalog を使用するすべてのパッケージに影響します。正しい方法は、一時的に明示的なバージョン番号(例:"vite": "5.0.0")に変更し、検証が完了したらcatalog:。
---
に戻すことです。三、packages-privateの分離設計:なぜデバッグサンドボックスは外部に公開されないのか
直感的モデル
packages-privateディレクトリは会社の「内部試験室」のようなものです——中のサンプルは外部に販売されず、テストとデモのみに使用されます。それはpackagesディレクトリと物理的に分離されており、デバッグコードが誤って npm に公開されるのを防ぎます。
分離メカニズムの三層保障
第一層:ディレクトリ分離
packages-private/vite-debugはpackages/の下になく、一方pnpm-workspace.yamlは通常packages/*とpackages-private/*の両方を workspace メンバーとして宣言しますが、公開スクリプト(例:scripts/release.js)はpackages/下のパッケージのみを走査します。
第二層:private: true
📎 packages-private/vite-debug/package.json:3
"private": true,この行は npm/pnpm の厳格な制約です:privateとマークされたパッケージは決してnpm publishで公開できません、手動で実行しても拒否されます。これは誤公開を防ぐ最後の防衛線です。
第三層:versionフィールドなし
注意package.jsonにはversionフィールドがありません。npm の仕様では、公開可能なパッケージにはversionが必須であり、このフィールドが欠けているパッケージはnpm publish時にエラーになります。これは「二重保険」です——たとえprivateが誤って削除されても、versionの欠如が依然として公開を阻止します。
設計思考:デバッグサンドボックスと Playground の役割分担
Vue core リポジトリにはすでに完全な機能を持つSFC Playgroundがあります(第7章で議論済み)。なぜまだvite-debug?
両者の位置づけはまったく異なります:
| 次元 | SFC Playground | vite-debug |
|---|---|---|
| 実行環境 | ブラウザ内(コンパイルもブラウザ内) | Node.js + ブラウザ |
| ソースコードの読み込み | CDN または事前ビルド成果物経由 | ローカルソースコードを直接読み込み |
| デバッグ能力 | ブラウザサンドボックスに制限される | Node.js デバッガ、ブレークポイントが使用可能 |
| ソースコードの変更 | 非対応 | HMR 対応 |
| 適用シーン | コンパイル出力の検証、再現の共有 | ランタイム内部動作のデバッグ |
vite-debugの核心的価値は実際の Node.js 環境で動作することにあり、node --inspectでデバッガをアタッチし、packages/reactivity/src/effect.tsにブレークポイントを設定して、ReactiveEffectの作成とスケジューリング過程を観察できます。これは Playground では提供できません。
本番での落とし穴:HMR の境界と状態の喪失
を使用してデバッグする際、よくある困惑は:vite-debug内のApp.vueの初期値を変更しても、ブラウザ内のカウントがリセットされないことです。これは Vite の HMR がcountブロックに対して<script setup>コンポーネント状態を保持し、レンダリング関数のみを置換するためです。状態を完全にリセットする必要がある場合は、手動でページをリフレッシュするか、にApp.vueを追加して強制的にページ全体をリフレッシュする必要があります。import.meta.hot?.invalidate()もう一つの落とし穴は:
下のソースコードを変更したとき、HMR の伝播チェーンが自動的にトリガーされない可能性があることです——なぜならpackages/runtime-core/src/の HMR 境界はvite-debugレベルで定義されており、App.vue下のソースコード変更は Vite のモジュールグラフを通じて伝播する必要があるからです。ソースコード変更後にブラウザが反応しない場合は、Vite のターミナル出力にpackages/ログがあるか確認してください。なければ、dev server の再起動が必要かもしれません。hmr update本章のまとめ
---
は4つのファイル、40行未満のコードで、完全なデバッグループを構築しました:
packages-private/vite-debugは最小限のマウントチェーンを提供:
1. main.ts、すべての不要な初期化ロジックを排除。createApp(App).mount('#app')は実験のキャリアとして:
2. App.vue+ テンプレート補間 + イベント処理で、リアクティブシステムの主要パスをカバー。refは
3. vite.config.ts + package.jsonプロトコルと条件付きエクスポートを通じて、workspace:*をローカルソースコードに解決し、「ソースコード変更が即座に反映される」を実現。'vue'+
4. packages-private + private: trueなしのversion三層分離で、デバッグコードが誤って公開されないことを保証。
このサンドボックスのエンジニアリング哲学は:デバッグ環境自体の複雑さはゼロに近づけるべきであり、すべての複雑さはデバッグ対象のソースコードに委ねる。あなたがpackages/reactivityで再現困難なバグに遭遇したとき、vite-debugは自由に変更でき、即座に検証できる実験台を提供します。
本章の思考とセルフチェック
Q1: もしpackage.json内の"vue": "workspace:*"を"vue": "^3.4.0"に変更した場合、vite-debugでpackages/reactivity/src/ref.tsを変更した後、ブラウザ内の動作はどう変わりますか?なぜですか?
参考解析:"^3.4.0"に変更すると、pnpm は npm registry から Vue 3.4.x のリリース版をダウンロードし、ローカルのpackages/vue 📎 packages-private/vite-debug/package.json:13にリンクしません。このときimport { createApp } from 'vue'が解決するのはnode_modules/.pnpm/vue@3.4.x/node_modules/vue/dist/vue.runtime.esm-bundler.js、つまり事前ビルド成果物です。packages/reactivity/src/ref.tsを変更しても HMR はトリガーされません。Vite のモジュールグラフにこのファイルが含まれていないためです。ブラウザで動作しているのは依然として npm 版のref実装です。この実験は逆説的にworkspace:*がソースコードレベルデバッグの必要条件であることを証明しています。
Q2: App.vue内の<style>ブロックにscopedが付いていない場合、このサンドボックスで同時に2つのコンポーネントインスタンスをマウントすると、スタイルはどうなりますか?これはvite-debugのデバッグ目標とどう関係しますか?
参考解析:scopedがない場合、button { color: red }はグローバルスタイル📎 packages-private/vite-debug/App.vue:4-8となり、ページ内のすべての<button>要素に作用します。2つのコンポーネントインスタンスをマウントすると、両方のインスタンスのボタンが赤くなります。デバッグ目標との関係は:vite-debugの位置づけは「最小再現」であり、「スタイル分離の検証」ではありません。scopedを省略することで、コンパイル時にdata-v-xxx属性を注入する変数が減り、デバッガ内の DOM 構造がよりクリーンになります。scopedスタイルのコンパイルロジックをデバッグする必要がある場合は、明示的にscopedを追加し、@vitejs/plugin-vueが生成する属性注入コードを観察すべきです。
Q3:packages/runtime-core/src/renderer.tsのpatch関数にconsole.logを1行追加したが、ブラウザコンソールに出力がないとします。少なくとも3つの可能な原因を挙げ、それぞれの調査方法を説明してください。
参考解析:
原因一:ソースコードエントリが有効でない。'vue'がdist成果物に解決されており、src。トラブルシューティング:DevTools の Network パネルで確認するvueモジュールの読み込みパスがdist/で始まる場合、条件付きエクスポートがヒットしていないdevelopment条件📎 packages-private/vite-debug/package.json:13。
原因2:HMR が伝播していない。Vite のモジュールグラフがpackages/runtime-core/src/renderer.tsの変更をvite-debugに伝播していない。トラブルシューティング:Vite のターミナルにhmr updateログがあるか確認する。なければ dev server を再起動する。
原因3:patch関数が呼び出されていない。現在のページで DOM 更新が何もトリガーされていない場合(例えばボタンをクリックしていない)、patchは初回マウント時に一度だけ実行される可能性があり、その初回マウントはconsole.logを追加する前に発生している。トラブルシューティング:ページをリロードするか、App.vueに更新をトリガーする操作を追加する。
原因4(補足):ビルドキャッシュ。Vite の依存関係プリビルドキャッシュ(node_modules/.vite)が古いバージョンを使用している可能性がある。トラブルシューティング:node_modules/.viteを削除して再起動する。
---
サイズ予算は「問題が存在する」ことを教えてくれ、vite-debugは「問題を自分の手で再現する」ことを可能にする。しかし、このサンドボックスモードを monorepo 全体に広げようとすると、一連の境界条件に遭遇する:CI 環境における workspace プロトコルの解決差異、catalog:のバージョンロックによるアップグレードの困難さ、packages-privateとpackagesの間の依存方向の制約……次章ではアーキテクチャのトレードオフと落とし穴回避ガイドに入り、monorepo エンジニアリングが実際のプロジェクトで露呈する境界条件を体系的に整理する。
ここまでで、サイズ計測から最小再現までのエンジニアリング閉ループを完成させた:vite-debug は極めてシンプルな4つのファイルで、「実際のソースコード上で素早く検証する」ことを日常的に使える実践へと変えた。しかし、この仕組みを実際に再現し始めると、さらに多くの隠れたトレードオフが見えてくる——なぜ packages-private は packages と物理的に分離しなければならないのか?なぜ enum のインライン化は Rollup より前に完了しなければならないのか?次章では、第12章までで露呈した重要な意思決定ポイントと本番での落とし穴記録をまとめ、完全な落とし穴回避チェックリストと意思決定の根拠を提供する。
この章を読み終えましたか?ご自身のプライベートリポジトリを技術書へ
Tauri 2 + Rust によるローカルファースト設計。100% オフラインの安全性、コードのクラウド送信は一切ありません。不変コミットアンカーで精読。
⚡ Tauri 2 · Rust コア · 100% 完全オフライン · 100万行超のコードベース検証済
第 13 章:アーキテクチャのトレードオフと落とし穴回避ガイド:monorepo エンジニアリングの境界条件
前章ではpackages-private/vite-debugを切り口として、実際のソースコード上で最小再現を行うデバッグパラダイムを習得した。このような内部デバッグパッケージが増えてくると、現実的な問題が浮上する:それらが对外公開される正式パッケージと同じ workspace に共存する場合、リリースプロセスが誤って影響を与えないようにするにはどうすればよいか?本章では monorepo エンジニアリングの境界条件を深く掘り下げ、packagesとpackages-privateの二重ディレクトリ契約から出発し、アーキテクチャのトレードオフの背後にある防御的設計を分析し、実行可能な落とし穴回避ガイドを提供する。
13.2 タイミングの鉄則:enum のインライン化は Rollup の実行より先でなければならない
直感モデル
enum のインライン化は「箱詰め前に部品のラベルを数字に貼り替える」ようなものだ。もし箱詰め作業員(Rollup)がすでに梱包を始めていたら、後からラベルを変更しても、箱の中の部品とラベルが一致しなくなる。build.jsはscanEnums() / removeCache()という関数ペアでインライン化を厳密に Rollup の前に挟み込む。
データ構造とライフサイクル
inline-enums.jsがエクスポートするscanEnums()はremoveCacheクロージャを返し、ソースコード内の enum 定義をスキャンして、Rollup が消費するための一時ファイルを生成する📎 scripts/build.js:30-34。build.jsのrun()はtry/finallyでキャッシュクリアを保証する📎 scripts/build.js:81-112:
const removeCache = scanEnums()
try {
// ... buildAll / checkAllSizes / build-dts
} finally {
removeCache()
}rollup.config.jsモジュールのトップレベルでinlineEnums()を呼び出して[enumPlugin, enumDefines] 📎 rollup.config.js:47-50を取得する。ここでenumPluginは plugins 配列に挿入され📎 rollup.config.js:331-331,enumDefinesは replace プラグインの置換テーブルに組み込まれる📎 rollup.config.js:222-223。
Step-by-Step:1回のビルドにおける enum の完全なライフサイクル
1. build.jsのrun()はまずscanEnums()を呼び出し、すべてのパッケージの enum 定義をスキャンして一時キャッシュに書き込み、removeCache 📎 scripts/build.js:87-87。
2. buildAllを返し、複数の Rollup プロセスを並行起動する📎 scripts/build.js:119-121。
3. 各 Rollup プロセスは設定読み込み段階でinlineEnums()を実行し、前のステップで生成されたキャッシュを読み取り、enumPluginとenumDefines 📎 rollup.config.js:47-50。
4. enumPluginを得るenumDefinesは transform 段階でソースコード内の enum 参照をリテラルに置換する;📎 rollup.config.js:222-223。
は replace の補完として、モジュールをまたぐ定数置換を処理するfinally5. ビルド終了時、removeCache()ブロックが📎 scripts/build.js:119-121。
flowchart LR
src["源码 enum 定义"] --> scan["scanEnums()<br/>scripts/inline-enums.js"]
scan --> cache["临时缓存文件"]
cache --> inline["inlineEnums()<br/>rollup.config.js"]
inline --> plugin["enumPlugin<br/>transform 阶段替换"]
inline --> defines["enumDefines<br/>replace 替换表"]
plugin --> bundle["Rollup 产物<br/>字面量已内联"]
defines --> bundle
bundle --> cleanup["removeCache()<br/>finally 块"]コピー
〔設計推論とアーキテクチャのトレードオフ〕なぜ Rollup プラグインで transform 段階においてその場でスキャンして使わないのか?それは enum のインライン化には:runtime-coreパッケージをまたぐグローバルビューsharedが必要だからであるscanEnums()で参照される enum は
で定義されている可能性があり、単一の Rollup プロセスは自分のパッケージのソースツリーしか見えず、パッケージをまたぐ置換を完了できない。removeCache()ビルド前にグローバルキャッシュを構築するのは、まさにこの可視性の問題を解決するためである。finally本番の落とし穴ポイント:finallyをtemp/に置くことは、ビルド途中でエラーが発生してもクリーンアップされることを意味する。しかし、デバッグ中に手動でプロセスを中断(Ctrl+C)すると、
---
が実行されない可能性があり、残留したキャッシュファイルが次回のビルドで期限切れの enum を読み込む原因となる。トラブルシューティング方法:release.jsディレクトリに残留した enum キャッシュファイルがないか確認し、手動で削除して再試行する。
13.3 リリースオーケストレーター:
release.jsの skip フラグビットマトリクスskipBuild / skipTests / skipGit / skipPrompts直感モデルskipPromptsは結婚式の総合演出家のようなもので、skipGitの4つのスイッチは「リハーサルをスキップ」「宣誓をスキップ」「写真撮影をスキップ」「確認をスキップ」のボタンである。各ボタンの存在はそれぞれ実際のシナリオに対応する:CI 環境ではskipTests。
が必要で、ローカルデバッグでは
が必要で、緊急ホットフィックスではparseArgsが必要である📎 scripts/release.js:39-50フラグビットのデータ構造とデフォルト値📎 scripts/release.js:64-66:
let skipTests = args.skipTests
const skipBuild = args.skipBuild
const skipPrompts = args.skipPrompts
const skipGit = args.skipGitで宣言されskipTests、その後ローカル変数に分割代入されるlet宣言、なぜならそれはrunTestsIfNeeded()で動的に書き換えられるから📎 scripts/release.js:281-317。
Step-by-Step:1回のreleaseの完全な意思決定フロー
main()の実行順序📎 scripts/release.js:143-279:
1. リモート同期チェック:isInSyncWithRemote()ローカルHEADとリモートブランチのSHAを比較し、不一致時に確認ダイアログを表示📎 scripts/release.js:337-363。
2. バージョン選択:位置引数がない場合にversionIncrements選択メニューを表示📎 scripts/release.js:152-176。
3. テスト判断:runTestsIfNeeded()はskipロジックが最も密集している箇所📎 scripts/release.js:281-317。
4. バージョン更新:updateVersions()すべてのパッケージを走査して書き換えpackage.json 📎 scripts/release.js:377-398。
5. Changelog生成:呼び出しpnpm run changelog 📎 scripts/release.js:211-212。
6. Gitコミット:skipGitが真の場合、セクション全体をスキップ📎 scripts/release.js:231-240。
7. 公開:以下の場合のみargs.publishが真の場合に実行buildPackages() + publishPackages() 📎 scripts/release.js:243-246。
runTestsIfNeeded()の分岐ロジックは個別に展開する価値がある:
flowchart TD
entry["runTestsIfNeeded()"] --> skipFlag{"skipTests?"}
skipFlag -->|是| done["Tests skipped"]
skipFlag -->|否| ci["getCIResult()"]
ci --> ciPass{"CI passed?"}
ciPass -->|是| promptMode{"skipPrompts?"}
promptMode -->|是| setSkip["skipTests = true"]
promptMode -->|否| ask["prompt: Skip local tests?"]
ask --> setSkip2["skipTests = promptSkipTests"]
ciPass -->|否| noPrompt{"skipPrompts?"}
noPrompt -->|是| throwErr["throw Error<br/>CI not passed"]
noPrompt -->|否| runLocal["run('pnpm', ['run','test','--run'])"]
setSkip --> done
setSkip2 --> done
runLocal --> done設計上の考察と落とし穴
skipTestsを使用letではなくconstの設計は、「CIが通過済みならローカルテストを自動スキップ」という最適化パスをサポートするため。これはCI公開シナリオで大量の時間を節約する——GitHub Actionsのrelease.ymlはすでに完全なテストを実行済みで、ローカルで再度実行するのは純粋な無駄である。
公開順序の隠れた契約:sortPackagesForPublishingはvueを最後に配置📎 scripts/release.js:85-85、コメントで明確に「ユーザーは内部パッケージが利用可能になる前に新しいエントリパッケージをインストールできない」と説明している。この順序を変更すると、ユーザーがnpm install vue@next時に依存関係がまだ公開されていないバージョンを取得し、ERR_MODULE_NOT_FOUND。
冪等性保護:publishPackageは公開前にisPackagePublishedを呼び出してregistryを📎 scripts/release.js:453-458チェックし、公開失敗時にpreviously publishedエラーをキャッチして📎 scripts/release.js:480-488をスキップに降格する。これによりreleaseスクリプトは安全にリトライできる——ネットワーク中断後に再実行しても「パッケージが既に存在する」ことで全体が失敗しない。
失敗時のロールバック:fnToRun().catch()はversionUpdatedが真の場合にupdateVersions(currentVersion)を呼び出してバージョン番号をロールバック📎 scripts/release.js:528-537。ただし注意:これはpackage.json内のバージョンフィールドのみをロールバックし、すでにgit commitされたコミットはロールバックしない。もしskipGitが偽の状態で公開が失敗した場合、手動でgit reset。
---
設計思考:3つのトレードオフに共通するパターン
本章の3つの核心的トレードオフを振り返ると、それらは同じ設計哲学を共有している:「忘れがちな実行時チェック」を「回避不可能な構造的制約」に変換する。
packages-private物理的隔離:スクリプト作者がprivateフィールドのチェックを覚えていることに依存せず、スキャン範囲から自然に除外される。- 列挙型インライン化の前置:Rollupプラグインがtransform時に「たまたま」クロスパッケージenumを見られることに依存せず、ビルド前にグローバルキャッシュを構築する。
release.jsのskipマトリクス:公開者が「CI通過済みならローカルテスト不要」を覚えていることに依存せず、スクリプトが自動的にCIステータスを照会してskipTests。
このパターンの代償はスクリプトの複雑度上昇:build.jsはprivatePackagesリストを維持する必要があり、rollup.config.jsはディレクトリ探索ロジックを重複させ、release.jsは4つのskipフラグの交差組み合わせを処理する必要がある。しかしVueのような週に複数回リリースするリポジトリでは、構造的制約による信頼性の利益は複雑度のコストをはるかに上回る。
---
本章のまとめ
本章はソースコードから出発し、Vue coreエンジニアリング体系の3つの重要な境界条件を分解した:
1. packages-privateとpackagesの物理的隔離はworkspace glob、build.jsディレクトリ探索、release.jsフィルタの3箇所が共同で保証📎 pnpm-workspace.yaml:1-3📎 scripts/build.js:153-170📎 scripts/release.js:68-83。
2. 列挙型インライン化のタイミング制約はscanEnums() / removeCache()のtry/finally構造によって強制保証され、Rollup設定はモジュールトップレベルでキャッシュを消費📎 scripts/build.js:81-112📎 rollup.config.js:47-50。
3. release.jsのskipフラグビットマトリクスはCI公開、ローカルデバッグ、緊急ホットフィックスの3シナリオにサービスし、skipTestsの動的書き換えと公開順序のソートは最も見落とされやすい2つの隠れた契約📎 scripts/release.js:281-317📎 scripts/release.js:85-85。
本章の考察とセルフチェック
Q1: もしbuild.jsのbuild(target)関数内のprivatePackages.includes(target)判断を削除し、統一的にpackagesをpkgBaseとして使用した場合、どのようなシナリオで問題が発生するか?
参考解析:build.js:160-164のディレクトリ探索はプライベートパッケージがビルドされる唯一の入口である。削除すると、nr build vite-debugはpackages/vite-debug下でpackage.jsonを検索するが、そのディレクトリは存在せず、fs.readFileSync直接ENOENTをスローする。さらに隠れた問題は:将来誰かがpackages/下に同名ディレクトリを作成した場合、ビルドは静かに誤ったディレクトリの設定を使用し、成果物パスとbuildOptionsがすべてずれる。加えて、rollup.config.js:37-42は独立したディレクトリ探索ロジックを持ち、2箇所を同期して修正する必要があり、そうでなければ「build.jsはパッケージを見つけたがRollupは見つけられない」という不整合状態が発生する。
Q2: release.jsのrunTestsIfNeeded()において、skipTests ||= isCIPassedこの行のコード(release.js:285)はskipPromptsが真かつCI未通過の場合、どの分岐をたどるか?もしelse if (skipPrompts)分岐のthrowを削除すると、どのような結果になるか?
参考解析:skipPromptsが真かつCI未通過の場合、skipTests ||= isCIPassed内のisCIPassedはfalse,skipTestsのまま元の値(通常はfalse)を保持する。その後else if (skipPrompts)分岐に入り、Error(release.js:299-304)をスローする。もしこのthrowを削除すると、コードはif (!skipTests)分岐まで実行を続け、非対話環境でpnpm run test --runを実行する。これはCIで環境差異によりテストが失敗するか、さらに悪い場合は——テストは通過するがCIが実際には通過していない(例えばCIが異なるテストサブセットを実行している)状態で、完全に検証されていないバージョンを公開してしまう。
Q3: rollup.config.js:55のinlineEnums()はモジュールトップレベルで呼び出され、一方build.js:87のscanEnums()はrun()関数内で呼び出される。もしこの2つの実行タイミングを交換した場合(つまりinlineEnums()をRollupのbuildStartフック内で呼び出すようにした場合)、何が壊れるか?
参考解析:scanEnums()はすべてのRollupプロセスが起動する前に完了する必要がある。なぜならすべてのパッケージのソースコードをスキャンしてグローバルenumキャッシュを構築する必要があるから。inlineEnums()はrollup.config.jsモジュールトップレベルで呼び出され、この時点でRollupはまだビルドを開始しておらず、キャッシュはすでに準備完了している。もしbuildStart内で呼び出すように変更すると、各Rollupプロセスが独立してスキャンする——しかしbuildAllは並行実行される(build.js:119-121)ため、複数プロセスが同時に同じファイル群をスキャンすると競合が発生する:プロセスAがプロセスBのまだ書き込み完了していないキャッシュファイルを読む可能性があり、enum置換が不完全になる。さらに深刻なのは、scanEnums()が返すremoveCacheクロージャがスキャン時のファイルハンドル状態に依存し、並行シナリオではクリーンアップタイミングを調整できないことである。
二重ディレクトリ契約、ビルドスクリプトの帰属判定、リリーススクリプトの二次フィルタリング——これらの仕組みが共にmonorepoエンジニアリングの安全境界を画定している。しかし境界は不変ではない。ビルドツールがRollupからRolldownへ移行し、型テストとランタイムテストが融合へ向かうにつれ、既存のトレードオフ戦略も新たな課題に直面する。次章では、3.0から3.4の変更軌跡に基づき、次世代エンジニアリング体系の進化方向を展望する。
この章を読み終えましたか?ご自身のプライベートリポジトリを技術書へ
Tauri 2 + Rust によるローカルファースト設計。100% オフラインの安全性、コードのクラウド送信は一切ありません。不変コミットアンカーで精読。
⚡ Tauri 2 · Rust コア · 100% 完全オフライン · 100万行超のコードベース検証済
第 14 章:未来の進化:3.xから次世代エンジニアリング体系へ
前章では、Vue coreエンジニアリング体系の「安全境界」——二重ディレクトリ契約、ビルドスクリプトの帰属判定、リリーススクリプトの二次フィルタリング——を整理した。これらの仕組みは一度に設計されたものではなく、3.0から3.4の反復の中で繰り返し磨き上げられてきたものである。本章では視点を変える。「今どのような姿か」ではなく「どのようにして今の姿になったか」を見て、それに基づいて次世代エンジニアリング体系がどこへ向かうかを推測する。本章のソース資料はchangelogs/CHANGELOG-3.3.md、changelogs/CHANGELOG-3.4.md、そしてリポジトリルートのpackage.jsonである。変更ログは一見「どんなバグを直したか」の記録にすぎないが、エンジニアリング体系の最もリアルな健康診断書である。build:プレフィックスのコミット、types:プレフィックスの変更、依存バージョンのロールバック——その一つひとつが現在のアーキテクチャの応力点を露呈している。我々がすべきことは、これらの応力点から進化の方向を読み取ることである。変更ログを「機能リスト」ではなく「エンジニアリング体系の観測窓」として扱うことが、本章の核心的な方法論である。機能変更はVueが何をできるかを教えてくれるが、ビルド・型・CI関連の変更はVueのエンジニアリング体系が「どこで痛んでいるか」を教えてくれる。
一、ビルドツールチェーンの応力点:RollupからRolldownへの移行ポテンシャル
直感モデル
ビルドツールチェーンを組み立てラインに例えよう。Rollupは主組立台、esbuildは高速切断(TSトランスパイル)を担当し、terserは最終的なバンドル圧縮を担当する。製品(Vueランタイム)が複雑になるにつれ、組立台上の工程が増え、主組立台自体がボトルネックとなる。Rolldownの位置づけは、Rustで書き直された主組立台である——それが置き換えるのはesbuildではなく、Rollupそのものである。
この進化の圧力がなければ、システムが直面する「災難」はクラッシュではなく、ビルド時間がパッケージ数に比例して線形に膨張することである:サブパッケージが一つ増えるごとに、Rollupプロセスを一つ余分に起動し、enumキャッシュを一回余分にスキャンし、dts生成を一回余分に実行することになる。
データ構造と依存レイアウト
まず現在のツールチェーンの静的スナップショットを見る。package.jsonのdevDependenciesは正確な「組立台リスト」である:
📎 package.json:103-106
"rollup": "^4.63.3",
"rollup-plugin-dts": "^6.5.1",
"rollup-plugin-esbuild": "^6.2.1",
"rollup-plugin-polyfill-node": "^0.13.0",ここから三つの重要な事実が読み取れる。第一に、Rollupのメジャーバージョンは^4.63.3であり、Rollup 4.xの成熟期にある。第二に、rollup-plugin-esbuildがTSトランスパイルを担うということは、Rollup自体はTSを解析せず、esbuildが吐き出したJSのみを処理することを意味する。第三に、rollup-plugin-dtsが独立して.d.tsのバンドルを担当しており、これこそ前章で論じたdts-built-testの独立性の物質的基盤である。
次にビルドスクリプトのエントリ編成を見る:
📎 package.json:8-9
"build": "node scripts/build.js",
"build-dts": "tsc -p tsconfig.build.json --noCheck && rollup -c rollup.dts.config.js",build-dtsは「二段階式」である:まずtsc --noCheckで生の宣言ファイルを生成し(--noCheckは型チェックをスキップし、emitのみ行う)、次にrollup -c rollup.dts.config.jsで散在する.d.tsを単一ファイルにバンドルする。この設計自体がRollupの能力への依存である——rollup-plugin-dtsは型依存を追跡するためにRollupのモジュールグラフを必要とする。
シナリオ駆動:あるbuild:コミットが露呈したもの
変更ログのbuild:プレフィックスの項目は、ビルドツールチェーンの応力点の直接的な証拠である。三つを選んで見てみよう。
第一に、3.4.32のminify設定の整合:
📎 changelogs/CHANGELOG-3.4.md:84
* **build:** use consistent minify options from previous terser config ([789675f](https://github.com/vuejs/core/commit/789675f65d2b72cf979ba6a29bd323f716154a4b))このコミットの動機は「terserからesbuild minifyへの移行後、圧縮オプションが不一致になった」ことである。これは移行中の過渡状態を明らかにしている:Vueはかつてterserで圧縮していたが、後にesbuildに変更した(devDependenciesのesbuild: ^0.28.2がこれを裏付けている)が、圧縮オプションが完全には整合しておらず、産物のサイズや挙動に偏差が生じた。これはまさに「組立台の部品を交換する」際の典型的な代償である。
第二に、3.4.38のentitiesバージョンのロールバック:
📎 changelogs/CHANGELOG-3.4.md:6
* **build:** revert entities to 4.5 to avoid runtime resolution errors ([f349af7](https://github.com/vuejs/core/commit/f349af7b65b9f8605d8b7bafcc06c25ab1f2daf0)), closes [#11603](https://github.com/vuejs/core/issues/11603)entitiesはHTMLエンティティデコードライブラリであり、compiler-domが依存している。4.5へのロールバックは、新バージョンがランタイム解析で問題を起こしたためである。このコミットが示すのは:ビルドツールチェーンの依存アップグレードは孤立しておらず、一つの間接依存のバージョン変動がランタイム挙動にまで貫通する。
第三に、3.4.29のserver-renderer cjsビルド汚染:
📎 changelogs/CHANGELOG-3.4.md:155
* **build:** fix accidental inclusion of runtime-core in server-renderer cjs build ([11cc12b](https://github.com/vuejs/core/commit/11cc12b915edfe0e4d3175e57464f73bc2c1cb04)), closes [#11137](https://github.com/vuejs/core/issues/11137)これは最も典型的なビルドバグの一種である:CJS形式で、server-rendererが誤ってruntime-coreを自身の産物にバンドルしてしまった。原因は通常、Rollupのexternal判定がCJS形式で機能しないことである——ESMはimport文による外部依存の静的識別ができるが、CJSのrequire動的性がより強く、見落としが発生しやすい。このコミットはRollup設定におけるexternalロジックの脆弱性を直接指摘している。
移行ポテンシャルのMermaid描写
以下の図は現在のビルドパイプラインの制御フローを描写し、Rolldown移行が影響を与えるノードを示している:
flowchart TD
start["node scripts/build.js"] --> scan["scanEnums() 全局扫描"]
scan --> cache_ok{"enum 缓存就绪?"}
cache_ok -->|否| err_enum["抛出错误 / 中断构建"]
cache_ok -->|是| build_all["buildAll() 并发启动"]
build_all --> rollup_proc["每个包一个 Rollup 进程"]
rollup_proc --> inline["inlineEnums() 顶层调用"]
inline --> esbuild_plugin["rollup-plugin-esbuild 转译 TS"]
esbuild_plugin --> external_check{"external 判定"}
external_check -->|ESM 格式| ext_ok["静态 import 识别成功"]
external_check -->|CJS 格式| ext_risk["require 动态性导致漏判"]
ext_risk --> pollution["runtime-core 被打进 server-renderer"]
ext_ok --> output["产物输出"]
pollution --> output
output --> dts["build-dts 两段式生成"]
dts --> tsc_emit["tsc --noCheck 生成原始 d.ts"]
tsc_emit --> rollup_dts["rollup-plugin-dts 打包"]
rollup_dts --> done["构建完成"]Rolldownの移行価値は、「パッケージごとに1プロセス」の並行モデルを「単一プロセス内並行」モデルに置き換えることにある。scanEnums()のグローバルスキャンとinlineEnums()の置換を同一のRustランタイム内で調整できるため、前章で議論した「並行スキャン競合」問題が根本から消える。しかし移行の抵抗もここにある——rollup-plugin-esbuild、rollup-plugin-dtsこれらのプラグインエコシステムにはRolldownが互換レイヤーを提供する必要があり、external判定ロジックは書き直す必要がある。
設計上の考察と落とし穴
なぜ移行は一足飛びに進まないのか?を見るとpackage.jsonのenginesフィールド:
📎 package.json:61-63
"engines": {
"node": ">=20.0.0"
},Node 20がハード下限である。RolldownはRustネイティブモジュールとして、対応するN-APIバインディングとプリコンパイル済みバイナリ配布が必要である。一度導入すると、pnpm installの所要時間、クロスプラットフォーム(Windows/macOS/Linux)のバイナリ互換性、CIキャッシュ戦略をすべて再設計する必要がある。これは「依存関係を変える」ほど単純な話ではなく、インストール-ビルド-キャッシュチェーン全体の再調整。
本番環境の落とし穴:build-dtsのtsc --noCheckは諸刃の剣である。型チェックをスキップするとemitが速くなるが、.d.ts生成段階で型エラーが発見されない——型エラーはpnpm check(tsc --incremental --noEmit)とtest-dtsでしか補えない。Rolldown移行後にこの2ステップを統合したいなら、型チェックがビルドを遅くしないことを保証しなければならない。そうでなければ--noCheckの趣旨に反する。
---
二、型テストとランタイムテストの融合トレンド
直感モデル
型テストとランタイムテストを2つの独立した品質検査ゲートとして想像しよう:1つは「説明書(.d.ts)が正しく書かれているか」を検査し、もう1つは「機械(ランタイム)が正しく回っているか」を検査する。2つのゲートはそれぞれ独立した作業台、独立したツール、独立したレポートを持つ。融合トレンドの意味は:同じテストケースで説明書と機械の両方を同時に検証できないか?
融合がなければ、システムが直面する災難は型とランタイム動作のドリフト:.d.tsはref()がRef<T>を返すと言うが、ランタイムが実際に返すオブジェクトの形状が変わり、型テストは通り、ランタイムテストも通るが、両者を組み合わせると間違っている。
データ構造:テストスクリプトの編成レイアウト
package.jsonのscriptsでは、テスト関連のエントリが明確に2つのグループに分かれている:
📎 package.json:19-24
"test": "vitest",
"test-unit": "vitest --project unit*",
"test-e2e": "node scripts/build.js vue -f global -d && vitest --project e2e --project e2e-browser",
"test-dts": "run-s build-dts test-dts-only",
"test-dts-only": "tsc -p packages-private/dts-built-test/tsconfig.json && tsc -p ./packages-private/dts-test/tsconfig.test.json",
"test-coverage": "vitest run --project unit* --coverage",ここでの鍵となる構造はtest-dtsのrun-s build-dts test-dts-only——それは直列である:まず.d.tsをビルドし、次に型テストを実行する。そしてtest-dts-onlyの内部はさらに2つの独立したtscプロセスである:1つはdts-built-test(ビルド成果物を検証)を実行し、1つはdts-test(ソースコードの型を検証)を実行する。
注意すべきはtest-unitがvitest --project unit*,test-e2eを使い、vitest --project e2e --project e2e-browserを使っていることである。これはVitestの--projectメカニズムがすでにテストを「ユニット/E2E/ブラウザ」に異なるprojectとして分類していることを示している。融合の物理的基盤はすでに存在する:Vitestのprojectメカニズムは同一runner内で異なるタイプのテストを実行することを可能にする。
シナリオ駆動:あるtypes:コミットの完全なパス
変更ログではtypes:プレフィックスのエントリ密度が極めて高い。これは型システムの複雑さの直接的な現れである。典型的な型修正を追跡しよう。
3.4.37のref型リバート:
📎 changelogs/CHANGELOG-3.4.md:23-24
* Revert "fix(types/ref): allow getter and setter types to be unrelated ([#11442](https://github.com/vuejs/core/issues/11442))" ([b1abac0](https://github.com/vuejs/core/commit/b1abac06cdb198bd72f8e614b1f68b92e1c78339))
* Revert "fix(types/ref): correct type inference for nested refs ([#11536](https://github.com/vuejs/core/issues/11536))" ([3a56315](https://github.com/vuejs/core/commit/3a56315f94bc0e11cfbb288b65482ea8fc3a39b4))2つの連続するRevertが、2つの型修正をリバートした。3.4.35でこの2つの修正がちょうどマージされたことに注意:
📎 changelogs/CHANGELOG-3.4.md:55
* **types/ref:** allow getter and setter types to be unrelated ([#11442](https://github.com/vuejs/core/issues/11442)) ([e0b2975](https://github.com/vuejs/core/commit/e0b2975ef65ae6a0be0aa0a0df43fb887c665251))📎 changelogs/CHANGELOG-3.4.md:30
* **types/ref:** correct type inference for nested refs ([#11536](https://github.com/vuejs/core/issues/11536)) ([536f623](https://github.com/vuejs/core/commit/536f62332c455ba82ef2979ba634b831f91928ba)), closes [#11532](https://github.com/vuejs/core/issues/11532) [#11537](https://github.com/vuejs/core/issues/11537)3.4.35でのマージから3.4.37でのリバートまで、間にパッチバージョンが1つしかない。この「マージ-リバート」の高速サイクルは、型テストの根本的なジレンマを露呈している:型テストは「型シグネチャが期待通りか」を検証できるが、「この型シグネチャが実際のコードで使いやすいか」は検証できない。allow getter and setter types to be unrelatedは型テストでは完全に通るかもしれないが、実際に使用するとrefの型推論が過度に緩くなり、下流コードの型安全性を損なう。
型テスト融合のMermaid描写
以下の図は現在の型テストとランタイムテストの分離構造、および融合後の目標形態を描写している:
flowchart LR
subgraph current["当前:分离的两条链路"]
src["packages/*/src/*.ts"] --> tsc_build["tsc -p tsconfig.build.json --noCheck"]
tsc_build --> raw_dts["散落的 .d.ts"]
raw_dts --> rollup_dts["rollup -c rollup.dts.config.js"]
rollup_dts --> built_dts["打包后的 .d.ts"]
built_dts --> dts_built_test["dts-built-test/tsconfig.json"]
src --> dts_test["dts-test/tsconfig.test.json"]
src --> vitest_unit["vitest --project unit*"]
dts_built_test --> report_a["类型报告"]
dts_test --> report_a
vitest_unit --> report_b["运行时报告"]
end
subgraph future["融合目标:单一 runner"]
src2["源码"] --> vitest_all["vitest --project unit --project dts"]
vitest_all --> unified["统一报告 + 类型断言"]
end
current -.演进.-> future融合の技術的パスはおそらく:dts-built-testとdts-testのtsc呼び出しをVitestのカスタムprojectとしてカプセル化し、型アサーションをexpectTypeOfの形式でテストファイルにインライン化することである。これにより1回のvitest呼び出しでランタイムアサーションと型アサーションを同時に実行でき、レポートが統一される。しかし抵抗は:tscの型チェックは「全量」であり、Vitestのテストは「ファイル単位」であるため、両者のインクリメンタル戦略は互換性がない。
設計上の考察と落とし穴
なぜdts-built-testはdts-test?から独立していなければならないのか?前章で既に議論したが、ここでは進化の視点から補足する:dts-built-testが検証するのはビルド成果物(rollup-plugin-dtsバンドル後の.d.ts),dts-testが検証するのはソースコードの型である。融合時に両者を統合すると、「ビルド成果物がソースコードの型と一致しているか」という重要なチェックポイントが失われる。3.4.38のこのコミットはまさにビルド成果物の型の重要性を裏付けている:
📎 changelogs/CHANGELOG-3.4.md:9
* **types:** add fallback stub for DOM types when DOM lib is absent ([#11598](https://github.com/vuejs/core/issues/11598)) ([4db0085](https://github.com/vuejs/core/commit/4db0085de316e1b773f474597915f9071d6ae6c6))「DOM libが欠落している場合にfallback stubを提供する」——これはビルド成果物レベルの型互換性修正であり、dts-built-testのような「バンドル後の.d.tsを消費する」シナリオでのみ発見できる。
本番環境の落とし穴:型テストの「マージ-リバート」サイクルは、型シグネチャの変更には実際の下流プロジェクトによる検証が必要であり、型アサーションだけでは不十分であることを示している。Vueの型テストはpackages-private/dts-testでは、リポジトリ内部のテストケースを使用しており、すべての下流の使用法をカバーできていない。融合トレンドが「二つの runner を統合する」ことだけに注目し、「真の下流フィードバックをどのように導入するか」を解決しなければ、形式的な融合に過ぎない。
---
三、CI キャッシュの細粒度最適化の方向性
直感モデル
CI キャッシュをリポジトリの「材料置き場」と想像しよう。各ビルドは材料置き場から原料(依存関係、ビルド成果物、型キャッシュ)を取り出す。もし材料置き場に大きな箱が一つしかなく、何かを取るたびに箱全体を探し回らなければならないなら、キャッシュヒット率がどれほど高くても速くはならない。細粒度最適化とは:大きな箱を用途別に分類した小さな仕切りに分割すること。
細粒度キャッシュがなければ、システムが直面する災難はキャッシュ無効化のカスケード増幅:ソースコードを一行変更すると、全体のnode_modulesキャッシュが無効化され、CI がすべての依存関係を再インストールし、ビルド時間が2分から10分に変わる。
データ構造:キャッシュ可能なものの分類
package.jsonから、キャッシュ可能な「物料」をいくつか識別できる:
第一類、依存関係インストール成果物。packageManagerフィールドは pnpm バージョンを固定している:
📎 package.json:4
"packageManager": "pnpm@12.4.2",pnpm のnode_modulesはシンボリックリンク構造であり、キャッシュするのは pnpm の content-addressable store であり、フラットなnode_modulesではない。これは、キャッシュキーがpnpm-lock.yamlのハッシュに基づくべきであり、package.json。
ではないことを意味する。第二類、ビルド成果物。cleanスクリプトは成果物の物理的な位置を明らかにしている:
📎 package.json:10
"clean": "rimraf --glob packages/*/dist temp .eslintcache",packages/*/dist、temp、.eslintcache——これら三種類の成果物は独立してキャッシュできる。distはビルド出力、tempは一時ファイル(例えばbench.json),.eslintcacheは lint キャッシュ。
第三類、型チェックキャッシュ。checkスクリプトは--incremental:
📎 package.json:15
"check": "tsc --incremental --noEmit",--incrementalを使用して.tsbuildinfoファイルを生成する。これは型チェックのインクリメンタルキャッシュである。CI でこのファイルをキャッシュすれば、tscの二回目の実行はずっと速くなる。
シナリオ駆動:一回の PR の CI 実行フロー
典型的なシナリオを代入する:開発者がpackages/reactivity/src/ref.tsを変更し、PR を提出した。CI はどのステップを実行する必要があり、どれがキャッシュヒットするか?
scriptsから CI の実行シーケンスを推論できる(simple-git-hooksのpre-commitはローカルフックであり、CI はより完全なシーケンスを実行する):
📎 package.json:48-51
"simple-git-hooks": {
"pre-commit": "pnpm lint-staged && pnpm check",
"commit-msg": "node scripts/verify-commit.js"
},ローカルpre-commitはlint-stagedとcheckを実行する。CI 上ではlint、check、test-unit、test-dts、sizeなどが実行される。各ステップのキャッシュ戦略は異なる:
lint:キャッシュ.eslintcache、キーはソースファイルのハッシュに基づく。check:キャッシュ.tsbuildinfo、キーはtsconfigとソースハッシュに基づく。test-unit:Vitest には独自のキャッシュがあるが、通常 CI ではテスト結果をキャッシュせず、依存関係のみをキャッシュする。test-dts:依存build-dtsの成果物、キャッシュキーはpackages/*/distのハッシュに基づく。size:ビルド成果物に依存、キャッシュキーは同上。
CI キャッシュ最適化の Mermaid 描写
flowchart TD
pr["PR 提交"] --> checkout["checkout 代码"]
checkout --> cache_deps{"pnpm store 缓存命中?"}
cache_deps -->|是| install_fast["pnpm install --offline"]
cache_deps -->|否| install_slow["pnpm install 全量下载"]
install_fast --> lint_step["pnpm lint"]
install_slow --> lint_step
lint_step --> cache_eslint{".eslintcache 命中?"}
cache_eslint -->|是| lint_inc["增量 lint"]
cache_eslint -->|否| lint_full["全量 lint"]
lint_inc --> check_step["pnpm check"]
lint_full --> check_step
check_step --> cache_tsbuild{".tsbuildinfo 命中?"}
cache_tsbuild -->|是| check_inc["增量类型检查"]
cache_tsbuild -->|否| check_full["全量类型检查"]
check_inc --> test_unit["pnpm test-unit"]
check_full --> test_unit
test_unit --> build_dts["pnpm build-dts"]
build_dts --> cache_dist{"packages/*/dist 命中?"}
cache_dist -->|是| dts_cached["复用 dts 产物"]
cache_dist -->|否| dts_rebuild["重新生成 dts"]
dts_cached --> test_dts["pnpm test-dts-only"]
dts_rebuild --> test_dts
test_dts --> size_check["pnpm size"]
size_check --> done["CI 通过"]細粒度キャッシュの核心的な矛盾はキャッシュキーの粒度である:キーが粗すぎる(例えば commit hash のみに基づく)とヒット率が低く、キーが細かすぎる(例えば各ファイルのハッシュに基づく)とキー計算のオーバーヘッドがキャッシュの利益を相殺する。Vue のような monorepo の合理的な戦略は「パッケージ単位のシャーディング」である:各packages/*サブパッケージが独立してキャッシュされdist,reactivityの変更がcompiler-coreのdistキャッシュを無効化しない。
設計思考と落とし穴
なぜsizeスクリプトを複数のサブコマンドに分割するのか?この三つを見てみよう:
📎 package.json:11-14
"size": "run-s \"size-*\" && node scripts/usage-size.js",
"size-global": "node scripts/build.js vue runtime-dom -f global -p --size",
"size-esm-runtime": "node scripts/build.js vue -f esm-bundler-runtime",
"size-esm": "node scripts/build.js runtime-dom runtime-core reactivity shared -f esm-bundler",sizerun-s "size-*"を使用してすべてのsize-プレフィックスのサブコマンドを直列実行する。この「プレフィックス集約」パターンにより、各サイズ次元(global、esm-runtime、esm)が独立してキャッシュされ、独立して失敗できる。もし一つの大きなコマンドに統合すると、いずれかの次元が基準を超えるとsize全体が失敗し、どの次元の問題か特定できなくなる。
本番の落とし穴:CI キャッシュで最も陥りやすい罠はキャッシュ汚染——誤った成果物をキャッシュし、後続のビルドがダーティデータに基づいてしまう。cleanスクリプトの存在はまさにこの状況に対処するためである:
📎 package.json:10
"clean": "rimraf --glob packages/*/dist temp .eslintcache",注意すべきは、これがクリーンアップするのはpackages/*/distであり、packages-private/*/distではない。これはpackages-privateの成果物が通常のクリーンアップ範囲に含まれないことを意味する——もし CI がpackages-privateの成果物をキャッシュし、cleanがそれをクリーンアップしないと、「古いバージョンの playground 成果物をキャッシュした」問題が発生しうる。細粒度キャッシュ設計時にはpackages-privateを個別に処理する必要がある。
---
設計思考:製品としてのエンジニアリング体系のライフサイクル
三節の手がかりを繋げると、明確な主線が見える:Vue のエンジニアリング体系は「使える」から「使いやすい」へ、「手動編成」から「宣言的設定」へと進んでいる。
ビルドツールチェーンの移行(Rollup → Rolldown)は「性能駆動」の進化である:パッケージ数が一定量に増えると、プロセスレベルの並行処理のオーバーヘッドが利益を超え、より軽量な並行モデルに交換する必要がある。
型テストの融合は「一貫性駆動」の進化である:型シグネチャの変更頻度がランタイム動作の変更頻度を超えると、分離した二套のテストが負担となり、同じテストケースを共有させる必要がある。
CI キャッシュの細粒度化は「コスト駆動」の進化である:CI 分数がボトルネックになると、粗粒度キャッシュの浪費は受け入れられなくなり、用途別にシャーディングする必要がある。
これら三つの進化線の共通制約は後方互換性である。Vue のリリース戦略(変更ログのBREAKING CHANGES段落から見える)は、minor バージョンで「type-only breaking change」を許可するが、ランタイム breaking change は許可しない。これはエンジニアリング体系の進化が保証しなければならないことを意味する:内部ツールチェーンがどう変わろうと、成果物の公開 API とランタイム動作は変わってはならない。これがすべての進化決定のハード境界である。
---
本章のまとめ
本章は変更ログとpackage.jsonから出発し、Vue core エンジニアリング体系の三つの進化線を整理した:
1. ビルドツールチェーン:Rollup 4.x + esbuild + rollup-plugin-dts の現在の組み合わせにおいて、そのストレスポイントはbuild:プレフィックスのコミットに現れている(minify 設定の整合、entities バージョンのロールバック、CJS external の判定漏れ)。Rolldown 移行のポテンシャルは「シングルプロセス並列」による「マルチプロセス並行」の置き換えにあり、抵抗はプラグインエコシステムとクロスプラットフォームバイナリ配布にある。
2. 型テストの融合:test-dtsのrun-s build-dts test-dts-only直列構造、およびdts-built-testとdts-testのデュアルtscプロセスは、現在の分離形態の物理的証拠である。融合の技術的パスは Vitest の--projectメカニズムを活用することであり、抵抗はtsc全量チェックと Vitest のファイル単位テストのインクリメンタル戦略が互換性がないことにある。
3. CI キャッシュの細粒度化:packageManagerpnpm のロック、clean3種類の成果物のクリーンアップ、checkの使用、--incremental、sizeプレフィックスによる集約——これらはすべてキャッシュ可能物の分類根拠である。核心的な矛盾はキャッシュキーの粒度であり、合理的な戦略は「パッケージ単位のシャーディング」である。
最も重要な認識の転換は:エンジニアリング体系それ自体が一つのプロダクトであり、それには独自のユーザー(コントリビューター)、独自のパフォーマンス指標(ビルド時間、CI 分数)、独自の互換性制約(成果物 API の不変)がある。それは継続的なイテレーションが必要であり、一度きりの設計ではない。
本章の考察とセルフチェック
Q1: package.json:9のbuild-dtsはtsc -p tsconfig.build.json --noCheckを使用している。もし--noCheckを除去した場合、Rolldown 移行後にどのような連鎖反応が起きるか?
参考解説:--noCheckの役割は型チェックをスキップし、emit のみを行うことである。それを除去すると、tscは.d.tsを生成する前に全量型チェックを実行する。現在の Rollup アーキテクチャでは、これはbuild-dtsを遅くするだけである;しかし Rolldown 移行後は、問題が増幅される:Rolldown の核心的なセールスポイントは「シングルプロセス並列ビルド」であり、もしbuild-dts段階で全量tscチェックを導入すると、それがパイプライン全体の直列ボトルネックになる——すべてのパッケージのビルドがこのチェックの完了を待たなければならない。さらに深刻なのは、tscの型チェックはシングルスレッドであり、Rolldown の並列能力を活用できないことである。正しい方法は--noCheckを維持し、型チェックを独立したpnpm check(package.json:15)とtest-dts(package.json:22)に委ね、ビルドとチェックを分離することである。
Q2: チェンジログ 3.4.37 で連続して2つのtypes/ref修正(CHANGELOG-3.4.md:23-24)がロールバックされたが、これらの修正は 3.4.35 でちょうどマージされたばかりである(CHANGELOG-3.4.md:30,55)。もし型テストとランタイムテストがすでに融合されていれば、この「マージ-ロールバック」サイクルは回避できるか?なぜか?
参考解説:完全には回避できないが、サイクルを短縮できる。融合後の型テストは依然として「型シグネチャがアサーションに適合する」ことしか検証できない。一方、allow getter and setter types to be unrelatedのような修正の問題は「型シグネチャが緩すぎて、下流コードの型安全性を破壊する」ことにあり——これは下流の用法の問題であり、シグネチャ自体の問題ではない。融合がサイクルを短縮できる点は:型アサーションとランタイムアサーションが同じテストファイルに書かれていれば、開発者は「型シグネチャが変わったがランタイム動作が変わっていない」という不整合をより早く発見できる。しかしロールバックを真に回避するには、実際の下流プロジェクトの型チェックを導入する必要がある(例えばpackages-private/dts-testを「下流の用法をシミュレートする」テストセットに拡張するなど)。これは単なる「runner の融合」の範疇を超えている。
Q3: package.json:10のcleanスクリプトはpackages/*/distをクリーンアップするが、packages-private/*/distはクリーンアップしない。もし CI が「パッケージ単位のシャーディング」という細粒度キャッシュ戦略を採用した場合、この非対称性はどのような本番トラップをもたらすか?
参考解説:トラップは「packages-privateの古い成果物をキャッシュしてしまう」ことにある。packages-privateにはsfc-playground、template-explorerなどのデバッグツールが含まれており、それらのビルド成果物(例えばpackages-private/sfc-playground/dist)が CI にキャッシュされ、かつcleanがそれらをクリーンアップしない場合:ソースコードは更新されたが、CI が古い playground 成果物を再利用し、build-sfc-playground(package.json:39)の検証結果が歪む。さらに隠蔽的なのは、dev-sfc-prepare(package.json:34)がpackages-privateの成果物の存在をチェックする場合、古い成果物がキャッシュされていると再ビルドをスキップし、開発者に環境が新しいと誤認させる。細粒度キャッシュ設計時には、packages-privateのために個別にキャッシュキーを定義するか、あるいはその成果物をキャッシュしないことだ——デバッグツールであるため、再ビルドコストは低く、キャッシュの利益は小さい。
チェンジログの観測ウィンドウを通じて、我々は現在のエンジニアリング体系のストレスポイントを特定し、それに基づいて次世代体系の可能な進化方向を推論した。これらの方向は机上の空論ではなく、実際の本番の落とし穴とトレードオフから生まれたものである。ここに至り、本書の Vue エンジニアリング体系の分析は一区切りとなるが、エンジニアリングの探求に終わりはない——次章は最終章として、視点を Vue 自体から引き離し、これらの経験をより広範なエンジニアリングシーンにどう移転できるかを探る。
この章を読み終えましたか?ご自身のプライベートリポジトリを技術書へ
Tauri 2 + Rust によるローカルファースト設計。100% オフラインの安全性、コードのクラウド送信は一切ありません。不変コミットアンカーで精読。
⚡ Tauri 2 · Rust コア · 100% 完全オフライン · 100万行超のコードベース検証済
複雑なプロジェクトを理解するために必要なのは、一冊の優れた本です
本書は AiReadCode により公式リポジトリをスキャンして自動編纂され、不変コミットと FACT アンカーで裏付けられています。