제 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 섹션의 실제 버전으로 대체된 후 일괄 설치됩니다.
4단계: 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복사commit-msg훅은 매 커밋 전에 lint-staged와 타입 검사를 실행하고,preinstall훅은 커밋 메시지 형식을 검증합니다 (Vue는 conventional commits 사용).postinstall와
의 대칭성에 주목하세요: 전자는 문지기(오직 pnpm만 허용), 후자는 방어 설치(Git 훅 설치).
〔설계 추론과 아키텍처 트레이드오프〕packages*/?왜 두 개의 glob을 사용하고 하나의pnpm-workspace.yaml를 사용하지 않는가packages*/두 디렉토리를 명시적으로 나열하는 것은 '공개'와 '비공개'의 의미를 설정 레벨에서 바로 볼 수 있게 하기 위함입니다. 새로 합류한 개발자가
allowBuilds를 읽으면 첫눈에 저장소에 두 종류의 패키지가 있다는 것을 알 수 있습니다. 만약로 작성했다면 이 의미는 숨겨졌을 것입니다.
📎 pnpm-workspace.yaml:15-21
allowBuilds:
'@parcel/watcher': true
'@swc/core': true
'esbuild': true
'puppeteer': true
'simple-git-hooks': true
'unrs-resolver': true이 설정 부분을 주목하세요:allowBuilds복사@swc/core、esbuildpnpm은 기본적으로 의존성 패키지의 설치 스크립트(postinstall) 실행을 금지합니다. 이는 공급망 공격의 흔한 진입점이기 때문입니다.puppeteer는 화이트리스트입니다: 나열된 패키지만 빌드 스크립트를 실행할 수 있습니다.simple-git-hooks는 플랫폼 관련 네이티브 바이너리를 다운로드해야 하고,
minimumReleaseAge: 1440는 Chromium을 다운로드해야 하며,는 Git 훅을 작성해야 합니다 — 이들은 모두 합법적인 빌드 시점 동작이므로 명시적으로 허용됩니다.
📎 pnpm-workspace.yaml:33-33
minimumReleaseAge: 1440복사minimumReleaseAgeExclude〔설계 추론과 아키텍처 트레이드오프〕
📎 pnpm-workspace.yaml:36-38
minimumReleaseAgeExclude:
# Renovate security update: vitest@4.1.11
- vitest@4.1.11는 특정 보안 패치에 대해 예외를 허용합니다:
---
복사
주석은 이것이 Renovate가 트리거한 보안 업데이트로 즉시 적용되어야 하므로 쿨다운 기간이 면제된다고 명확히 설명합니다.
2. 루트 레벨 tsconfig: 모든 하위 패키지의 타입 경계 통일 제약strict: false직관적 모델strict: true만약 각 하위 패키지가 각자 tsconfig를 유지한다면 'A 패키지는, B 패키지는'의 균열이 발생합니다. 루트 레벨 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복사target하나씩 해석:isServerRenderer || isCJSBuild ? 'es2019' : 'es2016'📎rollup.config.js:337-337)。moduleResolution: bundler: 출력 구문을 ES2016으로 다운그레이드. 이는 Rollup 설정에서 esbuild의exports와 호응합니다 (strict: true: 번들러 스타일 모듈 해석을 채택하여 확장자 생략,strictNullChecks、noImplicitAny필드 지원.noUnusedLocals: true: 모든 엄격 검사 활성화,isolatedModules: true등 포함.isolatedDeclarations: true: 사용되지 않는 지역 변수는 즉시 오류. 이 규칙은 Tree-shaking과 함께 실질적 의미가 있습니다 — 사용되지 않는 변수는 종종 데드 코드의 신호입니다..d.ts: 각 파일이 독립적으로 트랜스파일 가능해야 함을 요구. 이는 esbuild/swc 같은 '파일별 트랜스파일, 크로스 파일 타입 분석 없음' 도구의 전제 조건입니다.tsc: 모든 내보내기에 타입을 명시적으로 표기해야 함을 요구. 이 규칙은composite: true생성 파이프라인에 직접 기여합니다 — 명시적 표기만이
paths가 전체 타입 추론 없이 빠르게 선언 파일을 생성할 수 있게 합니다.: 프로젝트 참조(project references)에 필요한 증분 빌드 메타데이터 활성화.:@vue/*필드는 workspace의./packages/*/src타입 레이어 미러node_modules로 매핑되어 TypeScript가 컴파일 시점에
의 심볼릭 링크가 아닌 소스 코드를 직접 해석하게 합니다. 이는 pnpm의 런타임 심볼릭 링크와 상호 보완적입니다 — 런타임은 pnpm, 컴파일 시점은 paths.pnpm check시나리오 기반 워크스루: 한 번의
check타입 검사tsc --incremental --noEmit 📎 package.json:15-15스크립트는
입니다. 이 시나리오를 대입해보면:1단계: include 범위 읽기.includetsconfig의
📎 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상단의tsc와 JSDoc 타입 주석이 결합되어 이 순수 JS 파일도
검사를 받을 수 있게 합니다.
📎 tsconfig.json:40-40
"exclude": ["packages-private/sfc-playground/src/vue-dev-proxy*"]sfc-playground〔설계 추론과 아키텍처 트레이드오프〕vue-dev-proxy의
파일이 제외됩니다. 왜일까요? 이런 파일은 보통 런타임에 동적으로 생성되는 프록시 코드로, 타입 형태가 불안정하여 검사에 포함하면 노이즈가 발생합니다. --incremental3단계: 증분 검사.tsc는.tsbuildinfo가 이전 검사 결과를--noEmit에 캐시하고 변경된 파일만 재검사하도록 합니다.
는 검사만 하고 출력하지 않음을 나타냅니다 — 타입 검사와 산출물 생성은 두 개의 독립적인 파이프라인입니다.
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"]복사describe、it、expect이 세 가지 타입 패키지가 전역으로 주입되어, 테스트 파일은puppeteer를 import 없이 직접 사용할 수 있고, e2e 테스트는
---
三、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' },
}일곱 가지 포맷으로 세 가지 소비 시나리오를 커버한다:esm-bundler은 Vite/webpack 등 번들러가 소비하고,esm-browser은 브라우저 네이티브 ESM이 소비하며,global은<script>태그가 소비한다.-runtime접미사가 붙은 것은 「런타임 전용」 빌드로, 메인vue패키지에만 개방된다.
시나리오 기반 Walkthrough: 한 번의pnpm build vue완전한 의사결정 흐름
실행node scripts/build.js vue시나리오에 대입한다.TARGET=vue, 내부 의사결정을 추적한다:createConfig
첫 번째 단계: 포맷 목록 결정.
📎 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구성만 유지한다.
두 번째 단계: 빌드 플래그 계산. 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 판정, 플러그인 조립이 전부 이들에 의존한다.
세 번째 단계: 진입 파일 선택.
📎 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진입을 사용한다.
네 번째 단계: 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으로 하드코딩되는데, 브라우저가 직접 소비하는 산출물에는 번들러가 개입하지 않기 때문이다.
다섯 번째 단계: 환경 변수 오버라이드 허용.
📎 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——특정 컴파일 분기를 디버깅하는 데 사용된다.
여섯 번째 단계: 플러그인 체인 조립.
📎 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를 가리킨다는 점에 주목하라——모든 하위 패키지가 동일한 타입 구성을 공유한다. 이것이 바로 두 번째 절에서 논의한 「헌법」이 빌드 시점에 구현된 것이다.
일곱 번째 단계: 프로덕션 빌드 추가.만약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 등 다중 포맷 산출물을 생성하는지 살펴봅니다.
제 2 장: 메인 라이프사이클: 하나의 빌드 요청의 엔드투엔드 여정
이전 장에서 우리는 core 저장소가 엔지니어링 모체로서 가지는 위치와 pnpm workspace 및 루트 레벨 설정이 모든 하위 패키지를 어떻게 통일적으로 제약하는지 명확히 했습니다. 이제 빌드 시스템의 핵심으로 깊이 들어가 하나의 명령이 전체 빌드 프로세스를 어떻게 구동하는지 추적합니다.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를 사용할까요? 왜냐하면 CJS는 Node용이므로 Node 환경은 압축이 필요하지 않지만(사용자가 직접 처리), dev/prod 분기를 구분해야 하기 때문입니다; 반면 브라우저에서 직접 도입하는 산출물은 크기를 줄이기 위해 반드시 압축해야 합니다. 이 차이는 두 팩토리 함수의 구현에 반영됩니다.createMinifiedConfig: 설정 생성의 핵심
createConfig는 가장 큰 함수로, 포맷과 출력 설정을 받아 완전한 Rollup 설정 객체를 반환합니다.
createConfig시작 부분은 일련의 불리언 플래그 계산입니다:
📎 rollup.config.js:125-142
:
isProductionBuild환경 변수 또는 파일명에__DEV__포함 여부로 판단..prod.js: 포맷명 정규식 매칭으로 판단.isBundlerESMBuild、isBrowserESMBuild、isCJSBuild、isGlobalBuild: 패키지명이isServerRenderer인지 여부: Vue 2 호환 빌드 관련.server-renderer。isCompatPackage、isCompatBuild: 전역 빌드 또는 브라우저 ESM 빌드이며, 비브라우저 분기가 활성화되지 않음.isBrowserBuild이러한 플래그는 이후
에서 반복적으로 사용되며, 설정 차별화의 핵심 근거입니다.resolveDefine、resolveReplace、resolveExternal출력 설정의 기본 사항: banner 저작권 헤더,
📎 rollup.config.js:144-157
모드(compat 패키지는exports사용, 나머지는auto사용), CJS 빌드에named상호운용 활성화, sourcemap은 환경 변수로 제어,esModule와externalLiveBindings: false는 Rollup 4의 호환성 설정입니다. 전역 빌드는 추가로reexportProtoFromExternal: false를 설정합니다, 즉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, 그렇지 않으면 stderr 내용과 함께 reject.exit〔설계 추론 및 아키텍처 트레이드오프〕 - 주의:
를 호출할 때build.js를 전달하는데, 이는 기본 파이프 설정을 오버라이드하여 Rollup의 출력이 터미널로 직접 전달되게 한다. 이는 빌드 도구의 올바른 동작이다—사용자는 빌드 진행 상황을 실시간으로 볼 필요가 있다.exec크기 검사:{ stdio: 'inherit' }크기 검사에는 두 가지 건너뛰기 조건이 있다:
가 참이거나, 형식이 지정되었지만checkAllSizes
📎 scripts/build.js:206-215
를 포함하지 않는 경우. 크기 검사는 전역 빌드 산출물에만 적용되기 때문이다—그것은 최종 사용자가 직접 가져오는 파일로 크기에 가장 민감하다.devOnly는 두 파일을 검사한다: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:...왜
를 직접 전달하지 않고
를 사용하는가?--environmentRollup의는 설정 파일에서--environment를 통해 읽을 수 있는 유일한 인자 전달 방식이다.process.env인자를 직접 전달하려면--config를 파싱해야 하지만,process.argv는 구조화된 키-값 쌍 파싱을 제공한다.--environment의 정규식 함정.
fuzzyMatchTarget에서 target.match(partialTarget)는 사용자 입력이다. 사용자가partialTarget를 입력하면 정규식에서 리터럴이므로 문제없지만,runtime-core,-를 입력하면 임의의 문자와 매칭되어 예상치 못한 대상을 매칭할 수 있다. 이는 퍼지 매칭의 고유한 위험이지만, Vue의 패키지 이름에는 정규식 특수 문자가 없어 실제로는 발생하지 않는다.runtime.core,.동시 빌드의 자원 경쟁.
는 runParallel를 동시성 상한으로 사용하지만, 각 Rollup 프로세스 자체도 워커를 시작한다. CI의 저코어 컨테이너에서는 메모리 오버플로가 발생할 수 있다. 프로덕션에서 OOM이 발생하면cpus().length또는 동시성 수를 줄여 완화할 수 있다.--max-old-space-size의 캐시 수명 주기.
scanEnums는 removeCache에서 호출되지만,finally자체가 오류를 던지면scanEnums가 할당되지 않아removeCache의 호출이 실패한다. 실제로finally가 반환하는 함수는scanEnums이전에 이미 확정되므로 이 위험은 존재하지 않는다—하지만 이는 읽을 때 확인해야 할 타이밍 세부 사항이다.try의 누락 위험.
resolveExternal이전 장의 사고 문제에서 이미 지적했다:에 새 의존성을 추가하면서runtime-core를 업데이트하는 것을 잊으면, 브라우저 빌드가 해당 의존성을 번들에 포함시키게 되어(external 목록에 없으므로) 크기가 팽창한다. 이는 '화이트리스트 external' 전략의 고유한 대가이다.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. checkAllSizes에 기록됩니다. gzip/brotli 크기를 계산하고, 선택적으로temp/size/。
에 씁니다.--withTypes8. 만약build-dts이면,
을 호출하여 타입 선언을 생성합니다.
이 장의 생각과 자가 점검build.jsQ1: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등의 스크립트가 순차적으로 실행되며(
Q2: runParallel의if (maxConcurrency <= source.length)스크립트 참조), 각 스크립트가 이전 스크립트의 산출물을 삭제하여 최종적으로targets.length === 1에는 마지막 스크립트의 형식만 남게 됩니다. 이는 SFC Playground의 빌드를 망가뜨립니다. SFC Playground는 여러 형식의 산출물이 동시에 존재해야 하기 때문입니다.
에서:
📎 scripts/build.js:131-151
이 조건의 역할은 무엇인가요? 만약 이를 제거하면, 단일 패키지(maxConcurrency > source.length)를 빌드할 때 무슨 일이 발생할까요?executing참고 해석await Promise.race(executing)。
이 조건은 동시성 제한을 활성화할지 여부를 제어합니다.executing일 때는 제한이 필요 없습니다. 모든 작업을 동시에 시작할 수 있습니다. 만약 이 조건을 제거하면, 작업이 하나뿐이어도e,Promise.race배열을 생성하고executing.splice(executing.indexOf(e), 1)을 실행합니다.
단일 작업의 경우,maxConcurrency에는 Promise가 하나만 있고cpus().length이 그것의 완료를 기다립니다. 이는 오류를 일으키지는 않지만, 불필요한 Promise 체인과 마이크로태스크 스케줄링 오버헤드를 초래합니다. 더 중요한 것은,executing.length >= 0이 단일 작업 시나리오에서도 여전히 올바르게 작동하므로 기능적으로는 차이가 없고, 성능상 미미한 손실만 있습니다.Promise.race([])진짜 위험은: 만약cpus().length이 0이면(이론적으로 불가능합니다.
Q3: resolveExternal이 최소 1이기 때문입니다),treeShakenDeps이 항상 참이 되고,
이 영원히 대기하게 됩니다. 하지만:
📎 rollup.config.js:257-283
treeShakenDeps이 이 경계가 트리거되지 않도록 보장합니다.source-map-js、@babel/parser、estree-walker、entities/decode에서 브라우저 빌드는compiler-sfc을 external로 반환하지만, 이러한 의존성은 브라우저 분기에서 실제로 실행되지 않습니다. 만약 이들을 external 목록에서 제거하면(즉, Rollup이 이들을 번들링하도록 하면), 무슨 일이 발생할까요?__BROWSER__참고 해석
은treeshake.moduleSideEffects: false(📎 rollup.config.js:355-355을 포함합니다. 이들은if (!__BROWSER__)등의 패키지의 의존성이며, 브라우저 빌드에서는__BROWSER__매크로를 통해 조건부 컴파일로 제외됩니다.true만약 external에서 제거하면, Rollup은 이러한 의존성을 해석하고 번들링하려고 시도합니다.
이고, 이러한 의존성의 import 문이onwarn분기에 위치하므로, esbuild의 define이
을scripts/dev.js으로 치환하여 분기가 데드 코드로 표시됩니다. Rollup의 Tree-shaking이 이러한 import를 제거하여 최종 산출물에는 이러한 의존성의 코드가 포함되지 않습니다.
이 SFC 사전 컴파일과 협력하여 밀리초 수준의 개발 피드백 루프를 구현하는 방법을 살펴보겠습니다.
제 3 장: 개발 모드 링크: dev 스크립트와 SFC 사전 컴파일의 협력 메커니즘scripts/dev.js소속 프로젝트: vuejs/corescripts/pre-dev-sfc.js전체 진행률: 제 3 / 14 장
검증 상태: FACT 행 번호 실제 앵커링
이전 장에서 우리는 프로덕션 빌드가 인자 파싱부터 다중 형식 산출물 기록까지의 전체 링크를 추적했습니다. 그 링크가 추구하는 것은 산출물의 완전성과 규범성입니다. 반면 개발 모드의 핵심 요구는 단 하나입니다. 한 줄의 코드를 수정하면 브라우저에서 즉시 효과를 볼 수 있어야 합니다. 프로덕션 빌드의 "인자 파싱 → 설정 생성 → 전체 번들링 → 기록" 링크는 수십 초가 걸려 이 요구를 전혀 충족할 수 없습니다. Vue core 저장소는 이를 위해 독립적인 개발 모드 링크를 유지합니다:
은 esbuild의 watch 모드로 증분 빌드를 수행하고,📎 scripts/dev.js:3-5
은 메인 빌드 전에 SFC 컴파일러를 미리 컴파일합니다. 이 장에서는 이 둘의 협력 메커니즘을 분석합니다.
3.1 dev.js: esbuild로 속도를 얻는 증분 빌더
직관적 모델parseArgs프로덕션 빌드는 "인쇄소의 정식 조판 및 인쇄"와 같습니다. 품질 우선이고 느려도 괜찮습니다. 개발 빌드는 "초안지 위의 연필 스케치"와 같습니다. 아름다움을 추구하지 않고, 그리자마자 나타나는 것만 추구합니다. Vue가 이 스케치를 그리기 위해 Rollup 대신 esbuild를 선택한 이유는 파일开头의 주석에 적혀 있습니다. Rollup 산출물이 더 작고 Tree-shaking이 더 좋지만, esbuild가 훨씬 빠릅니다.format만약 이 스크립트가 없다면, 개발자는 변경할 때마다 전체 프로덕션 빌드를 실행해야 하며, 피드백 루프가 밀리초 수준에서 분 수준으로 퇴화하여 핫 업데이트 경험이 완전히 사라집니다.global)、prod인자 파싱과 형식 추론false)、inline(기본false)。📎 scripts/dev.js:18-40위치 인자는targets로 수집되며, 비어 있으면 기본값은['vue']。📎 scripts/dev.js:42-53
여기 놓치기 쉬운 세부 사항이 있다:rawFormat과format는 두 번의 할당이다.parseArgs의default: 'global'는 이미rawFormat에 값이 있음을 보장하지만, 스크립트는 여전히const format = rawFormat || 'global'를 폴백으로 작성했다.📎 scripts/dev.js:42이는 방어적 작성 방식으로,parseArgs의 동작 변경이나 명시적으로 빈 문자열이 전달될 때 다운스트림format.startsWith에서 오류가 발생하는 것을 방지한다.
formatesbuild 출력 형식으로의 매핑은 세 갈래 분기이다: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-compattarget은vue로 이름이 변경되어 산출물이vue-compat.global.js。📎 scripts/dev.js:64-69로 불리는 것을 방지한다. 최종 경로는packages/vue/dist/vue.global.js,prod가 참일 때prod.세그먼트를 삽입한다.
external 해석: 의존성을 산출물에 번들링하지 않기
external배열은 어떤 모듈을 번들링하지 않을지 결정한다. 로직은 두 계층으로 나뉜다:
첫 번째 계층,inline가 활성화되지 않았고 형식이cjs이거나esm-bundler를 포함할 때,dependencies、peerDependencies의 키를 모두 external에 추가하고,path、url、stream세 개의 Node 내장 모듈을 하드코딩한다.📎 scripts/dev.js:76-88주석은 이 세 가지가@vue/compiler-sfc와server-renderer를 위해 준비되었음을 명확히 설명한다.
두 번째 계층,compiler-sfctarget에 대해 추가로@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하나만 있으며,onEnd훅에서 빌드 산출물의 상대 경로를 출력한다.📎 scripts/dev.js:115-124이는 개발자가 "변경이 적용되었음"을 인지하는 유일한 피드백 신호이다.
두 번째 플러그인은 조건부이다: 형식이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__즉, "cjs가 아니고 패키지가 비브라우저 분기를 지원하지 않을 때"만 브라우저 환경으로 표시된다;format !== 'global'는__COMPAT__즉, global 빌드는 SSR 분기를 활성화하지 않는다;vue-compat는 target이- 인지 여부로 결정된다;
__FEATURE_SUSPENSE__、__FEATURE_OPTIONS_API__、__FEATURE_PROD_DEVTOOLS__、__FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__세 개의 feature flag(
)는 dev 모드에서 모두 하드코딩된다.vitest.config.ts이 매크로들은define의📎 vitest.config.ts:6-21블록과 일대일로 대응한다.__TEST__테스트 환경에서는true、__DEV__를true로 설정하고
를
로 설정하는데, dev 빌드와의 차이가 바로 "테스트 vs 개발" 두 가지 실행 상태의 구분점이다.esbuild.context(...).then(ctx => ctx.watch())。📎 scripts/dev.js:130-161 contextwatch 모드 시작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: 相对路径"]에서 로그를 출력한다.
복사
3.2 pre-dev-sfc.js: 순환 의존성을 해결하는 사전 컴파일 센티넬compiler-sfc직관적 모델compiler-core"닭이 먼저냐 달걀이 먼저냐" 딜레마를 상상해 보자:compiler-core의 소스에서compiler-sfc를 import하는데,.vue는 개발 상태에서pre-dev-sfc.js를 처리하기 위해
가 필요하다. 둘 다 esbuild watch로 실시간 컴파일한다면, 먼저 컴파일하는 쪽이 교착 상태에 빠진다.
의 역할은 "먼저 달걀을 부화시키고, 그다음 닭을 기른다" — 메인 빌드 시작 전에 이 패키지들의 CJS 산출물이 이미 존재하도록 보장하는 것이다.compiler-sfc、compiler-core、compiler-dom、compiler-ssr、shared。📎 scripts/pre-dev-sfc.js:4-10체크리스트 및 단락 로직packages/${pkg}/dist/${pkg}.cjs.js스크립트는 고정 목록을 유지한다:📎 scripts/pre-dev-sfc.js:4-23
각 패키지에 대해allFilesPresent가 존재하는지 확인한다.false하나라도 누락되면,break를📎 scripts/pre-dev-sfc.js:20-21로 설정하고 즉시allFilesPresent하며, 나머지 패키지는 더 이상 확인하지 않는다.process.exit(1)마지막으로📎 scripts/pre-dev-sfc.js:25-27
가 거짓이면,
는 0이 아닌 코드로 종료한다.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"]체인이나 CI 스크립트)에게 보내는 신호이다: 산출물이 불완전하니 먼저 전체 빌드를 한 번 실행해야 한다. 모두 존재하면 정상 종료(종료 코드 0)하고 메인 빌드가 계속된다.
scripts/dev.js복사scripts/aliases.js3.3 aliases.js와 vitest.config.ts: 개발 상태 링크의 나머지 절반📎 scripts/aliases.js:7-7
는 "산출물을 어떻게 빠르게 생성할까"를 해결하지만, 개발 시에는 또 다른 경로가 있다: 테스트 실행.
resolveEntryForPkg는 vitest와 rollup에 공유 경로 별칭을 제공한다.packages/${p}/src/index.ts。📎 scripts/aliases.js:7-7별칭 생성 로직vue、vue/compiler-sfc、vue/server-renderer、@vue/compat。📎 scripts/aliases.js:16-21
는 패키지 이름을packages기본 entries에는 네 가지 특수 매핑이 하드코딩되어 있다:vue이후nonSrcPackages(sfc-playground、template-explorer、dts-test디렉터리 아래 모든 하위 디렉터리를 순회하며,@vue/${dir}자체를 건너뛰고,📎 scripts/aliases.js:23-35
이미 존재하는 key를 건너뛰고, 디렉터리여야만nonSrcPackages제외 목록은 이 세 패키지에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
테스트는 다섯 개의 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을 쓰는가?이는 기술 선택의 임의성이 아니라 두 시나리오의 제약이 다르기 때문입니다. 개발 상태는 산출물 크기에 민감하지 않고 피드백 지연에 극도로 민감합니다; 생산 상태는 반대입니다. esbuild는 Go로 작성되어 병렬화 수준이 높아 콜드 스타트와 증분 빌드가 한 자릿수 빠르지만, Tree-shaking과 코드 분할 능력은 Rollup보다 약합니다.📎 scripts/dev.js:3-5두 도구를 각각 두 시나리오에 서비스하는 것은 공학적으로 실용적인 절충입니다.
pre-dev-sfc는 왜 검사만 하고 컴파일하지 않는가?만약 그것이 스스로 컴파일을 트리거하면 순환 의존성을 다시 끌어들입니다—그것은compiler-sfc을 컴파일해야 하는데, 컴파일 과정 자체가compiler-sfc의 산출물에 의존할 수 있습니다. 그래서 그것은 '단언'만 할 수 있으며, '산출물 부재'라는 사실을 상위 계층에 노출시키고 상위 계층이 전체 빌드를 실행할지 오류로 종료할지 결정합니다. 이는 '센티넬 모드'입니다: 문제를 해결하지 않고 문제만 보고합니다.
external 목록의 중복은 기술 부채인가?dev.js와 rollup.config.js의 external 로직이 중복되며, 소스 주석도 이를 인정합니다.📎 scripts/dev.js:73그러나两者的 external 집합은 완전히 일치하지 않습니다—dev는 속도를 위해 더 공격적으로 external화합니다. 강제로 공통 함수를 추출하려면 매개변수화된 차이 스위치를 도입해야 하여 오히려 두 곳의 로직이 모두 더 읽기 어려워집니다. 이는 '중복이 잘못된 추상화보다 낫다'의 전형적인 트레이드오프입니다.
이 장 요약
이 장은 Vue core 개발 상태 체인의 세 가지 퍼즐 조각을 분해했습니다:
1. scripts/dev.js: esbuild의context().watch()을 사용하여 증분 빌드를 구현하고,parseArgs을 통해 형식과 플래그 비트를解析하며, 동적으로require대상 패키지package.json출력 경로를 찾고,__DEV__、__BROWSER__등의 매크로를 주입하여 조건부 컴파일을 제어하며,log-rebuild플러그인으로 매번 재빌드 후 피드백을 출력합니다.
2. scripts/pre-dev-sfc.js: 메인 빌드 전에 다섯 개 핵심 패키지의 CJS 산출물 존재 여부를 검사하고, 누락 시 종료 코드 1로 단락하여 순환 의존성으로 인한 빌드 교착을 방지합니다.
3. scripts/aliases.js + vitest.config.ts: 테스트 체인에 공유 경로 별칭을 제공하고, 특수 항목을 하드코딩하고 일반 항목을 동적 스캔하며, 다중 project 구성으로 단위, GC, jsdom, e2e, 브라우저 e2e 다섯 가지 테스트 시나리오를 커버합니다.
이 장 사고와 자체 테스트
Q1: 만약scripts/pre-dev-sfc.js에서break을 제거하면(즉 모든 패키지를 검사한 후 종료 결정), 어떤 시나리오에서 개발자 경험이 나빠지나요? 왜 소스 작성자는 '첫 번째 누락 발견 시 단락'을 선택했나요?
참고解析:
📎 scripts/pre-dev-sfc.js:4-23
break은if (!fs.existsSync(...))분기 내에 위치하며, 패키지 산출물 누락 발견 시 즉시 루프를 빠져나갑니다.
만약break을 제거하면, 스크립트는 나머지 패키지를 계속 검사하고 최종적으로allFilesPresent은 여전히false이며 종료 코드는 여전히 1,기능적으로 동등합니다. 그러나 차이는:
1. 성능: 다섯 번의existsSync호출 자체는 빠르지만, 목록이 수십 개 패키지로 확장되면 단락은 많은 무의미한 stat 시스템 호출을 절약합니다.
2. 의미론: 단락은 '하나라도 누락되면 전체가 불완전하다'를 표현합니다—이는 불리언 단언이며 구체적으로 몇 개가 누락되었는지 알 필요가 없습니다. 계속 검사해도 추가 정보가 생성되지 않습니다.
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
이는 소스의 모든if (__BROWSER__)분기가 esbuild의 define에 의해if (false)로 대체되고, 브라우저 전용 코드는 Tree-shaking으로 제거되며, 비브라우저 분기(Node 전용 로직)는 유지됨을 의미합니다.
결과: global 빌드 산출물은 원래 브라우저에서 실행되어야 하지만 Node 전용 분기를 포함합니다. 만약 이 분기들이fs、path등 Node 내장 모듈을 참조하면 브라우저 로드 시 '모듈 미정의' 오류가 발생합니다. 이것이enableNonBrowserBranches이 참인 패키지(예:compiler-sfc)가 일반적으로 global 빌드에 사용되지 않거나polyfillNode()플러그인 폴백이 필요한 이유입니다.📎 scripts/dev.js:126-128
만약 실수로true:__BROWSER__ = true로 변경하면, 브라우저 분기가 유지되고 Node 분기가 제거됩니다.compiler-sfc과 같이 반드시 Node 환경에서 SFC 컴파일을 실행해야 하는 패키지의 경우, 핵심 기능(파일 읽기, Node API 호출)이 Tree-shaking으로 제거되어 산출물이 Node에서 실행 시 '함수 미정의' 오류가 발생합니다.
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 플러그인이 해당 경로를 로드하려고 시도하며, 「모듈을 해석할 수 없음」 또는 「파일이 존재하지 않음」 오류를 발생시킨다.
오류 발생 단계:aliases.js실행 시(문자열拼接만 수행)가 아니라, vitest 시작 후 처음으로 해당 import를 해석할 때 발생한다. 만약 어떤 테스트도 이 패키지를 import하지 않으면, 오류는 발생하지 않는다——별칭은 그저entries객체 안에躺해 있을 뿐이다.
회피 방법: 이런src/index.ts가 없는 패키지를nonSrcPackages에 추가하거나, 새 패키지에 표준 진입점이 있는지 확인한다. 이것이nonSrcPackages를 수동으로 유지해야 하는 이유이다——그것은 「관례가 설정보다 우선」의 예외 목록이다.
세 가지 협업의 경계는 매우 명확하다:pre-dev-sfc는 「산출물이 준비되었는지」를 관리하고,dev.js는 「산출물을 어떻게 빠르게 갱신할지」를 관리하며,aliases는 「테스트가 소스를 어떻게 해석할지」를 관리한다. 개발 모드 링크는 속도 문제를 해결했지만, 빌드 시기에는 또 다른 더 은밀한 최적화가 있다——코드가 브라우저에서 실행되기 전에 완료되는 변환들이다. 다음 장에서는 컴파일 시기 마법으로 들어가, 열거형 인라인과 Tree-shaking 검증 메커니즘이 빌드 시기에 TypeScript enum을 리터럴로 대체하고, 필요 시 가져오기 약속이 깨지지 않도록 보장하는 방법을 살펴본다.
제 4 장: 컴파일 시기 마법: 열거형 인라인과 Tree-shaking 검증 메커니즘
이전 장에서 우리는 개발 모드 링크가 파일 감시와 증분 빌드로 「한 줄 수정 즉시 적용」의 속도를 얻는 방법을 보았다. 하지만 속도 외에도, Vue에는 더 은밀한 제약이 하나 있다: 배포 산출물의 크기가 제어 가능해야 한다는 것이다. 이 제약의 적 중 하나는 TypeScript의 enum이다——그것은 런타임에 실제로 존재하는 객체이며, Tree-shaking을破坏한다. 이 장에서는 컴파일 시기로 들어가, scripts/inline-enums.js가 코드가 브라우저에서 실행되기 전에 열거형을 리터럴로 「용해」하는 방법을 살펴본다; 그런 다음 scripts/verify-treeshaking.js가 빌드 후에 산출물 문자열로 「필요 시 가져오기」 약속이 조용히 깨지지 않았는지 역검증하는 방법을 살펴본다.
4.1 열거형 인라인: 런타임 객체를 리터럴로 용해
직관적 모델
요리책을 하나 썼는데, 그 안에 「약간의 소금」이 반복적으로 등장한다고 상상해보자. 매번 요리할 때마다 부록을 펼쳐 「약간 = 3그램」을 확인해야 한다면, 느릴 뿐만 아니라 자리도 차지한다. 열거형 인라인이 하는 일은, 인쇄 전에全书의 「약간의 소금」을 직접 「3그램 소금」으로 대체하고, 부록 페이지를 찢어버리는 것이다. 독자(런타임)에게 결과는 완전히 동일하지만, 책은 더 얇아진다.
만약 그것이 없다면, 시스템은 어떤 재앙에 직면할까? TypeScript의 일반enum은 컴파일 후 실제 객체 리터럴을 생성하며, 양방향 매핑(Enum[Enum.A] === 'A')을 가진다. 이 객체는부작용이 있는 모듈 수준 선언이며, Rollup은 그것이 사용되지 않았음을 증명할 수 없어, 보존할 수밖에 없다——비록 당신이 그 중 한 멤버만 import했더라도, 전체 열거형 객체와 역방향 매핑이 산출물에 포함된다.📎 scripts/inline-enums.js:3-9의 주석은 매우 직설적으로 말한다: 그들은 한때const enum를 사용했지만, issue #1228 때문에 일반 enum으로 변경했고, 그래서 이 스크립트로 「const enum의 제로 비용 이점을 수동으로 되찾았다」.
데이터 구조와 메모리 레이아웃
스크립트의 핵심은 세 가지 타입 정의이며, 그것들을 이해하면 전체 데이터 흐름을 이해한 것이다.📎 scripts/inline-enums.js:33-36
EnumMember:{ name, value }, 단일 열거형 멤버의 이름과 평가된 리터럴.EnumDeclaration:{ id, range: [start, end], members }。range은소스 바이트 오프셋으로,export enum X { ... }전체 선언의 파일 내 시작과 끝 위치를 가리킨다——이것이 이후 MagicString 정밀 대체의 앵커이다.EnumData:{ declarations, defines }。declarations은 파일 경로로 인덱싱되어, 해당 파일의 모든 열거형 선언 대체 범위를 기록한다;defines은 플랫 매핑으로, 키는 `${열거형 이름}.${멤버 이름}形式的字符串,值是JSON.stringify` 후의 리터럴이다.
여기에 핵심 설계가 하나 있다:defines의 키는파일 경로를 포함하지 않는다。📎 scripts/inline-enums.js:98-103주석이 이유를 설명한다——ErrorCodes은 동시에@vue/compiler-core과@vue/runtime-core에 존재할 수 있으므로, 같은 이름의 열거형이 파일 간에 존재하는 것을 허용한다; 하지만 같은ErrorCodes.__EXTEND_POINT__은 두 개의 같은 이름 열거형에서 중복될 수 없다, 그렇지 않으면fullKey in defines에命中하여, 직접name conflict를 던진다. 이것은 「멤버 이름 기준 전역 고유」 제약이며, 「열거형 이름 기준 전역 고유」가 아니다.
캐시는temp/enum.json。📎 scripts/inline-enums.js:33-36에 저장된다. 왜 디스크에 저장해야 할까? 왜냐하면scanEnums()은 빌드 진입점에서 한 번만 호출되지만, Rollup은 각 패키지, 각 형식마다독립적인 프로세스。📎 scripts/inline-enums.js:39-41를 시작한다. 주석이 지적한다: 데이터는 동시 Rollup 프로세스 간에 공유되어야 하므로, 반드시 디스크에 직렬화되어, 각 프로세스의inlineEnums()가 다시 읽는다.
Step-by-Step: grep에서 리터럴 대체까지
첫 번째 단계:export enum를 포함하는 모든 파일을 grep한다.📎 scripts/inline-enums.js:51-61은spawnSync('git', ['grep', 'export enum'])를 사용하여, 출력 형태는path:line:content이며, 그런 다음:로 첫 번째 세그먼트(파일 경로)를 잘라내고,Set로 중복을 제거한다. 여기서 사용된 것은git grep파일 시스템을 순회하는 대신 — Git이 추적하는 파일만 자연스럽게 스캔하고, 자동으로 제외합니다node_modules와 빌드 산출물을.
2단계: Babel이 파싱하고 열거형 정보를 수집합니다.📎 scripts/inline-enums.js:64-70각 파일에 대해@babel/parser를 사용하여typescript플러그인,sourceType: 'module'을 AST로 파싱한 다음, 오직ast.program.body의 최상위 노드만 순회합니다.📎 scripts/inline-enums.js:74-79오직ExportNamedDeclaration이면서 그declaration.type === 'TSEnumDeclaration'인 노드만 인식합니다 — 즉,내보내지 않은 enum은 처리되지 않습니다。
각 열거형 선언에 대해, 스크립트는 멤버를 하나씩 평가합니다. 멤버 평가는 세 가지 경로로 나뉩니다:
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 조각에서 오며, 임의의 사용자 입력이 아니므로 안전 경계가 통제 가능합니다.
3단계: 초기화자가 없는 멤버 처리 (자동 증가 의미론).📎 scripts/inline-enums.js:171-183멤버에initializer이 없으면: 첫 번째 멤버는 기본적으로0; 이후 멤버의lastInitialized이 숫자면++; 문자열이면wrong enum initialization sequence을 던집니다 — 문자열 열거형 멤버는 암시적 자동 증가를 허용하지 않기 때문입니다. 이것이 바로 TypeScript 열거형의 의미론입니다.
4단계: 캐시 쓰기 및 정리 함수 반환.📎 scripts/inline-enums.js:200-213 scanEnums()클로저를 반환하며, 호출 시rmSync캐시 파일을 삭제합니다.build.js에서 이를 사용합니다.try/finally이는 빌드 중간에 오류가 발생하더라도 캐시가 정리되어 다음 빌드를 오염시키지 않도록 보장합니다.📎 scripts/build.js:81-1125단계: Rollup transform 단계 교체.
캐시를 다시 읽고, Rollup 플러그인을 구성합니다. inlineEnums()에서📎 scripts/inline-enums.js:219-234이transform(code, id)에 매칭되면, MagicString을 사용하여id이 선언 부분을 객체 리터럴로 교체합니다.enumData.declarations교체 후 형태는[start, end]입니다. 주목할 점은📎 scripts/inline-enums.js:242-274
단순히 열거형을 삭제하는 것이 아니라export const X = { ... }객체 리터럴로 재작성하며, 숫자 멤버에 대해서는 추가로 역방향 매핑을 생성한다는 것입니다:주석은 TypeScript 공식 문서의 reverse-mappings 규칙을 인용합니다: 문자열 열거형 멤버는 역방향 매핑을 생성하지 않고, 숫자 멤버는 생성합니다. 이는 교체 후 런타임 동작이 원래 enum과 완전히 일치하도록 보장합니다.실제로 런타임 오버헤드를 제거하는 것은JSON.stringify(value.toString()) + ': ' + JSON.stringify(name)。📎 scripts/inline-enums.js:257-270이
에 전달되어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 替换引用"]왜냐하면
은 열거형 선언 부분만 교체하고 나머지 소스 바이트는 전혀 건드리지 않으며,정확한 sourcemap도 생성할 수 있기 때문입니다.s.update(start, end, ...)Babel로 전체 AST를 다시 출력하면 원본 형식, 주석이 손실되고 sourcemap 품질이 저하됩니다.s.generateMap()왜📎 scripts/inline-enums.js:277-281이고
range이 아닌가?node.start/node.end이 단언하는 것은declaration.start?📎 scripts/inline-enums.js:189-193(즉,node.start노드)이며, 교체 범위는ExportNamedDeclaration전체를 포함하여export enum X {...}키워드까지 포함합니다. 교체 텍스트는export로 시작하여 정확히 이어집니다.export const함정 포인트:
의 전역 고유성 제약.defines서로 다른 두 파일에 각각이 있고 둘 다ErrorCodes을 정의하면, 빌드가 바로 실패합니다.__EXTEND_POINT__이것은 버그가 아니라 의도된 설계입니다 — 왜냐하면📎 scripts/inline-enums.js:101-103은 전역 교체 테이블이라 파일 출처를 구분할 수 없기 때문입니다. 프로덕션 환경에서 새 열거형 멤버를 추가할 때, 이름이 기존 열거형 멤버와 충돌하면 여기서 터집니다.defines함정 포인트:
의 평가 시점.new Function이항 표현식 평가는단계에서 발생하며, 이 시점에scanEnums에 아직 참조된 멤버가 없을 수 있습니다 (참조 순서가 뒤바뀐 경우).defines이📎 scripts/inline-enums.js:136-140을 던집니다. 이는 열거형 멤버 참조가 반드시 「먼저 정의 후 참조」의 소스 순서를 따라야 함을 요구합니다.unhandled enum initialization expression4.2 Tree-shaking 검증: 산출물 문자열로 약속을 역증명
직관적 모델
열거형 인라인은 「사전 최적화」이지만, 최적화가 실제로 효과가 있는가? 만약 어떤 helper가 잘못된 작성 방식 때문에 우연히 유지되면, 크기가 조용히 팽창하는데 개발자는 전혀 눈치채지 못합니다.
이 바로 그 「사후 품질 검사관」입니다: 산출물을 빌드한 다음, 부검하듯이 산출물에verify-treeshaking.js있어서는 안 될 것이 있는지 확인합니다. 이것이 없다면, Vue의 온디맨드 임포트 약속이 어느 리팩토링 후 조용히 깨질 수 있으며, 사용자가 패키지가 커졌다고 불평할 때까지 발견되지 않습니다.데이터 구조와 검사 항목
이 스크립트에는 복잡한 데이터 구조가 없고, 핵심은
배열과 세 번의errors검사입니다.includes먼저📎 scripts/verify-treeshaking.js:6-6형식을 빌드한 다음, dev와 prod 산출물을 각각 읽습니다.global-runtime세 가지 검사 항목은 세 가지 「Tree-shaking 실패」 유형에 대응합니다:
dev 산출물에
1. 포함 — 이것은 esbuild가__spreadValues。📎 scripts/verify-treeshaking.js:13-19객체 전개 구문을 위해 생성한 helper입니다. 이것이 나타나면 런타임 코드에서 객체 전개를 사용했으며, Vue 규약상{ ...obj }helper로 바꿔야 추가 코드를 피할 수 있음을 의미합니다.extendprod 산출물에
2. 포함 — 이는Vue warn。📎 scripts/verify-treeshaking.js:26-31호출이warn()조건으로 감싸지지 않아 경고 코드가 프로덕션 번들로 유출되었음을 의미합니다.__DEV__prod 산출물에 DOM tag 설정 목록
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형식만 빌드한다——이것이 최소화된 런타임 산출물이며, 누출을 드러내기에 가장 적합하다. 빌드 완료 후 두 파일을 동기적으로 읽고, 하나씩includes를 검사하여 적중하면errors에 설명이 포함된 메시지를 push한다. 마지막으로errors.length가 0이 아니면 집계 오류를 throw한다.📎 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 분석을 사용하지 않는가?이것은 "센티넬 검사"이지 "정밀 분석"이 아니기 때문이다. 완전성을 추구하지 않고, 역사적으로 실제 발생했던 세 가지 유형의 회귀에 대해 저비용 경보를 설정한다. 문자열 매칭은 의존성 제로, 파싱 오버헤드 제로이며, 압축된 산출물에도 동일하게 효과적이다——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을 거치지 않는가? 주석이 답을 준다: esbuild의 define은 "다소 엄격하여 리터럴 JSON 또는 식별자만 허용한다". 그리고 열거형 멤버 이름ErrorCodes.__EXTEND_POINT__은 점이 있는 멤버 표현식이므로, esbuild의 define은 이런 키를 직접 처리할 수 없다. 따라서 임의의 문자열 키 교체를 지원하는@rollup/plugin-replace을 사용해야 한다.📎 rollup.config.js:250-251그리고preventAssignment: true을 설정하여 할당문 왼쪽도 교체되는 것을 방지한다.
resolveReplace()에서const replacements = { ...enumDefines }이 첫 번째 단계다.📎 rollup.config.js:222-223이후에야 프로덕션 환경의/*@__PURE__*/주석,__DEV__등의 교체가 중첩된다. 이 순서가 열거형 리터럴 교체가 항상 유효하도록 보장한다.
설계 고찰
열거형 인라인의 본질은 "빌드 시점 복잡성으로 런타임 크기를 교환"하는 것이다.TypeScript의 타입 시스템 의미론(열거형 평가, 자동 증가, 역방향 매핑)을 빌드 시점에 완전히 재현한다——scanEnums의 평가 로직은 거의 TS 컴파일러 열거형 평가의 부분집합이다.📎 scripts/inline-enums.js:110-183이는 유지보수 비용을 초래한다: TS가 새로운 열거형 문법(예: 더 복잡한 상수 표현식)을 추가하면 여기도 따라가야 하며, 그렇지 않으면unhandled오류를 throw한다. 그러나 이득은 명확하다: 런타임에 열거형 객체가 없어 Tree-shaking이 철저해진다.
검증 스크립트와 인라인 스크립트는 "약속과 이행"의 한 쌍이다.인라인 스크립트는 "열거형이 런타임 크기를 차지하지 않는다"고 약속하고, 검증 스크립트는 "다른 코드도 몰래 크기를 차지하지 않았다"고 검사한다. 둘 다 함께 Vue의 크기 예산을 수호한다. 이러한 "최적화 + 검증"의 쌍 설계는 대형 프론트엔드 라이브러리 엔지니어링의 전형적인 패턴이다: 모든 최적화에는 회귀를 방지하기 위한 자동화 검사가 필요하다.
크로스 프로세스 캐시는 동시 빌드의 필수품이다. scanEnums단일 실행,inlineEnums다중 읽기 패턴은📎 scripts/inline-enums.js:39-41"한 번 스캔, N개 프로세스 소비" 문제를 해결한다. 캐시가 없으면 각 Rollup 프로세스가 다시 grep + 파싱해야 하여 막대한 IO와 CPU를 낭비한다.
이 장 요약
이 장 고찰과 자가 테스트
Q1: 만약scanEnums의saveValue에 있는if (fullKey in defines)충돌 검사를 삭제하면, 어떤 시나리오에서 빌드 산출물에 오류가 발생하는가?
참고 해석:
defines은 전역 플랫 매핑이며, 키는枚举名.成员名이고 파일 경로를 포함하지 않는다.📎 scripts/inline-enums.js:98-103충돌 검사를 삭제하면, 두 개의 서로 다른 파일에 각각 동명의 열거형이 있고 동명의 멤버(예:@vue/compiler-core과@vue/runtime-core모두ErrorCodes.__EXTEND_POINT__를 가짐)를 정의한 경우, 나중에 쓰는 쪽이 먼저 쓰는 쪽을 덮어쓴다.
결과:defines['ErrorCodes.__EXTEND_POINT__']값이 하나만 남고,plugin-replace은 교체 시 파일 출처를 구분할 수 없어모든파일의ErrorCodes.__EXTEND_POINT__을 동일한 값으로 교체한다.📎 rollup.config.js:222-223따라서其中一个 패키지의 열거형 멤버 값이 조용히 변조되어, 런타임 동작이 잘못되고 극히 찾기 어렵다——소스 코드는 완전히 올바르게 보이기 때문이다.
이것이 바로 주석이 "동명 열거형의 파일 간 허용은 허용하지만, 동명 멤버는 허용하지 않는다"고 강조하는 이유다.📎 scripts/inline-enums.js:98-100충돌 검사는 전역 교체 테이블이 오염되는 것을 방지하는 문지기다.
Q2: 만약rollup.config.js의 플러그인 배열에서enumPlugin과...resolveReplace()의 순서를 바꾸면 어떻게 되는가?
참고 해석:
현재 순서는enumPlugin이 앞,replace이 뒤다.📎 rollup.config.js:331-332Rollup의transform훅은 플러그인 배열 순서대로 실행된다.
만약 바꾸면,replace이 먼저 실행되고, 이때 열거형 선언은 아직 원래의export enum X { ... }형태다.replacedefines을 사용하여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은 세 개의 문자열 센티널만 검사합니다. 만약 어떤 리팩터링이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의 타입 형태를 지키는지 살펴봅니다.
이 장에서는 컴파일 시점의 두 핵심 스크립트를 해부했습니다. inline-enums.js는 git grep으로 열거형을 찾고, Babel로 AST를 파싱하고, new Function으로 멤버를 평가하고, MagicString으로 선언을 정밀하게 재작성하며, 최종적으로 defines 전역 치환 테이블을 통해 열거형 참조를 리터럴로 바꿔 열거형 객체가 Tree-shaking으로 제거될 수 있게 합니다. verify-treeshaking.js는 빌드 후 문자열 센티널로 산출물을 검사하여 세 가지 알려진 Tree-shaking 누출이 회귀하지 않도록 보장합니다. 둘 중 하나는 '최적화'를, 다른 하나는 '최적화가 깨지지 않았음'을 검증하며, 함께 Vue의 크기 약속을 수호합니다. 다음으로, 우리는 컴파일 시점에서 타입 산출물 생성 경로로 전환하여, Vue가 소스 타입과 배포 타입의 엄격한 일치를 어떻게 보장하는지 살펴봅니다.
제 5 장: 타입 산출물 파이프라인: 소스 .d.ts에서 배포급 타입 패키지까지
이전 장에서 우리는inline-enums.js과verify-treeshaking.js을 해부했습니다: 하나는 enum 참조를 리터럴로 바꿔 열거형 객체가 제거될 수 있게 하는 역할을, 다른 하나는 빌드 후 문자열 센티널로 세 가지 알려진 누출이 회귀하지 않았음을 확인하는 역할을 합니다. 둘은 함께 Vue의 런타임 크기 약속을 수호했습니다. 그러나 빌드 산출물은 JS만이 아닙니다. 사용자가import { ref } from 'vue'할 때, 편집기에서 뜨는 타입 힌트,tsc의 사용자 코드에 대한 타입 검사는 모두 또 다른 산출물 유형——.d.ts선언 파일에 의존합니다. JS 산출물이 틀리면 런타임에 오류가 납니다; 타입 산출물이 틀리면 사용자 측 컴파일 시점에 오류가 나거나, 더 나쁘게는: 타입이 조용히 표류하여 사용자 코드는 컴파일을 통과하지만 타입 형태가 실제 런타임 동작과 맞지 않습니다. 이 장에서는 Vue가 각 하위 패키지src에 흩어진 소스 타입을 어떻게 배포급 타입 패키지로 집계하고,dts-built-test으로 실제 빌드 산출물에 타입 스모크 테스트를 수행하는지 추적합니다.
5.1 2단계 타입 파이프라인: tsc가 산출하고, rollup이 집계
직관적 모델
인쇄 파이프라인을 상상해 보세요: 첫 번째 단계에서 각 하위 패키지가 각자의 원고(.ts소스)를 단일 페이지 교정지(.d.ts)로 조판합니다; 두 번째 단계에서 수십 장의 교정지를 목차 순서대로 한 권의 책(배포급.d.ts)으로 제본하고, 머리말과 꼬리말(export 선언)을 통일합니다.
만약 이 파이프라인이 없다면, 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가 담당하며, 빌드 단계에서는 중복 검사하지 않아 시간을 절약합니다.
2단계: rollup.dts.config.js 집계
2단계는rollup.dts.config.js가 구동합니다. 그 진입점은 먼저 사전 검증을 한 번 수행합니다:
📎 rollup.dts.config.js:15-22
만약temp/packages가 존재하지 않으면, 1단계가 실행되지 않았다는 뜻이므로 스크립트는 바로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: 진입점은 1단계에서 산출된 타입 파일이며, 소스 코드.ts。output.file: packages/${pkg}/dist/${pkg}.d.ts가 아닙니다: 산출물은 각 패키지 자신의dist디렉터리에 떨어지며, 파일명은 패키지명과 일치합니다 (예:vue.d.ts)。format: 'es': 타입 파일은 통일적으로 ES module 형식을 사용합니다.plugins: [dts(), patchTypes(pkg), ...(pkg === 'vue' ? [copyMts()] : [])]: 세 개의 플러그인, 앞의 두 개는 모든 패키지에 적용되고,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가.로 시작하는지를 판단합니다. 상대 경로 임포트가 미해결이면 1단계 산출물에 누락이 있다는 뜻이며, 이는 진짜 문제이므로 반드시 경고해야 합니다. 이 구분은 경고 노이즈를 최소로 줄이면서도 진짜 오류를 놓치지 않습니다.
파이프라인 전경
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이 그림은 2단계 제어 흐름을 고정합니다:tsc의 화이트리스트가 누가 파이프라인에 들어갈 수 있는지를 결정하고,rollup의check가 계속할 수 있는지를 결정하며,patchTypes는 반드시 거쳐야 하는 단계이고,copyMts는vue패키지 전용 분기입니다.
5.2 patchTypes: 집계 산출물을 배포급 형태로 재작성
직관적 모델
rollup-plugin-dts가 수십 개의.d.ts를 하나의 파일로 병합한 후, 산출되는 형태는 「먼저 한 무더기의 타입을 선언하고, 마지막에 하나의 거대한export { A, B, C, ... }로 통일 export」하는 것입니다. 이는 사람이 읽기에 불친절하고, 일부 툴체인(예: VitePress의defineComponent호출)에서는 「추론된 타입을 참조 없이 명명할 수 없음」 오류를 유발하기도 합니다.
patchTypes는 바로 이후처리 정형 공정입니다: 「집중 export」를 「즉석 인라인 export」로 바꾸고, 이어서 패키지 전용 타입 증강을 덧붙입니다.
데이터 구조: 두 개의 Set과 세 번의 순회
patchTypes는 Rollup 플러그인을 반환하며, 핵심 로직은renderChunk훅 안에 있습니다. 그것은 두 개의 집합을 유지합니다:
📎 rollup.dts.config.js:87-88
isExported: 모든원래부터 export되던타입 이름을 기록합니다 (export { ... }선언에서 온 것).shouldRemoveExport: 모든큰 export 블록에서 제거해야 할타입 이름을 기록합니다 (이미 인라인 export되었기 때문).
처리 흐름은 세 번의 패스(pass 0 / pass 1 / pass 2)로 나뉘며, 이는 전형적인 「먼저 수집, 그다음 재작성, 마지막 정리」 패턴입니다.
Step-by-Step Walkthrough
Pass 0: 모든 이미 export된 타입 이름을 수집합니다.
📎 rollup.dts.config.js:90-100
AST 최상위 노드를 순회하며,ExportNamedDeclaration이면서source를 가지지 않는(즉export ... from '...'의 재export가 아닌) 경우, 그 specifier의 local name을isExported。
에 추가합니다.exportPass 1: 선언 노드에 즉석에서
📎 rollup.dts.config.js:102-125
접두사를 추가합니다.VariableDeclaration、TSTypeAliasDeclaration、TSInterfaceDeclaration、TSDeclareFunction、TSEnumDeclaration、ClassDeclaration최상위 노드를 순회하며,processDeclaration。
processDeclaration여섯 종류의 선언에 대해
📎 rollup.dts.config.js:70-85
의 로직을 호출합니다:
세 단계:id1.
가 없으면 바로 반환합니다 (예: 익명 선언)._2. 이름이로 시작하면 건너뜁니다 — 이것은관례
입니다: 밑줄 접두사 타입은 내부 보조 타입이며 export하지 않습니다.shouldRemoveExport3. 이름을isExported에 추가하고; 만약 그 이름이prependLeft에 있으면 (즉 원래부터 export되던 것), 선언 시작 위치에export 문자열을
합니다.VariableDeclaration주의
📎 rollup.dts.config.js:104-115
분기에는 추가 단언이 있습니다:declare const만약 하나의declare const a, b가 여러 declarator를 선언하면 (예:processDeclaration), 바로 오류를 던집니다. 왜냐하면declarations[0]는만 처리하므로, 다중 declarator는 누락 처리를 유발하기 때문입니다. 여기서는빠른 실패
를 선택하고 조용한 오류를 택하지 않으며, 이는 방어적 프로그래밍의 구현입니다.
📎 rollup.dts.config.js:127-171
Pass 2: 큰 export 블록에서 이미 인라인된 타입을 제거합니다.ExportNamedDeclaration를 순회하며, 각 specifier에 대해:
- 만약 그 local name이
shouldRemoveExport에 있고,exported === local(즉export { Foo as Bar }의 이름 변경 상황을 제외)라면, 해당 specifier를 제거합니다. - 제거 시 MagicString으로 정밀 삭제합니다: 뒤에 specifier가 더 있으면 다음 specifier의 start까지 삭제하고; 마지막이면 이전 것의 end 또는 자신의 start까지 삭제합니다.
- 만약 전체 export 블록의 모든 specifier가 제거되면, 전체
ExportNamedDeclaration노드를 삭제합니다.
마무리: 패키지 전용 타입을 덧붙입니다.
📎 rollup.dts.config.js:172-183
code = s.toString()는 재작성된 코드를 받은 후,packages/${pkg}/types디렉터리가 존재하는지 확인합니다. 존재하면 디렉터리 아래 모든 파일 내용을 읽어, 줄바꿈으로 이어 붙인 뒤 코드 끝에 추가합니다.
이types/디렉터리는수동으로 유지 관리하는 타입 강화진입점으로, 소스 코드에서 자동 생성할 수 없는 타입(예: JSX 전역 강화, 매크로 타입 선언)을 넣는 곳입니다. 자동 생성된 타입과 같은 파일에서 병합되지만 출처는 명확히 분리됩니다—자동 생성은 위, 수동 강화는 아래.
왜 반드시 인라인 export여야 하는가?
주석에 직접적인 이유가 제시되어 있습니다:
📎 rollup.dts.config.js:45-51
원문: 모든 타입을 인라인 export로 바꾸고 큰 export 블록에서 제거하라, 그렇지 않으면 VitePress의defineComponent호출에서 「the inferred type cannot be named without a reference」 오류가 발생한다.
이 오류의 본질은: TypeScript가 타입을 생성할 때, 어떤 타입이 「다른 모듈의 export를 참조」해야만 이름을 붙일 수 있고 그 참조가 소비 측에서 보이지 않으면 오류가 발생한다는 것입니다. 중앙 집중식 export 블록은 타입 이름과 선언 위치를 분리시켜 이 문제를 악화시킵니다. 인라인 export는 각 타입이 선언 지점에서 바로 보이게 하여 이 간접 계층을 제거합니다.
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
주석에 이유가 설명되어 있습니다:package.jsonTypeScript 4.7의exports 규범에 따라, Node ESM과 CJS 모두에 올바른 타입을 제공하려면반드시 두 개의 독립적인 선언 파일이 필요합니다vue.d.ts. 그래서 빌드 시vue.d.mts。
로 복사합니다.package.json〔설계 추론 및 아키텍처 트레이드오프〕exports왜 재생성이 아니라 복사인가? ESM과 CJS의 타입 형태가 완전히 동일하고 차이는 파일 확장자와
의
매핑뿐이기 때문입니다. 복사가 가장 저렴한 방안이며 rollup을 다시 돌리는 것을 피합니다.
5.3 dts-built-test: 실제 산출물에 대한 타입 스모크 테스트patchTypes직관적 모델import앞 두 절은 타입 산출물이 생성되고 형태가 올바름을 보장합니다. 하지만 「생성 가능」이 「올바르게 생성됨」과 같지는 않습니다. 만약
dts-built-test의 어떤 순회에 버그가 있어 어떤 export를 잘못 삭제하면 산출물은 여전히 생성되지만 사용자가할 때 타입 누락을 발견하게 됩니다.은import실제 빌드 산출물에서 실행하는 타입 스모크 테스트vue입니다: 소스 타입을 테스트하지 않고
이미 게시된
패키지를 소비하여 핵심 타입 형태에 회귀가 없는지 검증합니다.
📎 packages-private/dts-built-test/src/index.ts:3-6
데이터 구조: 최소화된 타입 단언
- 전체 테스트 패키지의 핵심에는 단 하나의 파일만 있습니다:
vue줄별 해석:defineComponentL1:에서를 import합니다. 여기서 import하는 것은packages/vue/dist/vue.d.ts패키지 이름 - 이며 상대 경로가 아닙니다—그것은
_CustomPropsNotErased이 실제 산출물을 소비합니다. - L3-6: 컴포넌트
// #8376를 정의하며 빈 props와 빈 setup을 가집니다. - L8: 주석
CustomPropsNotErased, 구체적 issue를 가리킵니다._CustomPropsNotErasedL9-12:{ foo: string }를 export하며 타입은
와defineComponent의 교차 타입입니다.{ foo: string }이 테스트가 검증하는 것은:foo의 반환 타입이。
속성이 지워지지 않는지defineComponent〔설계 추론 및 아키텍처 트레이드오프〕
issue #8376의 배경 추측:
📎 packages-private/dts-built-test/package.json:1-11
의 반환 타입이 어떤 조건부 타입이나 매핑 타입 처리를 거쳐 교차 타입의 추가 속성이 「지워질」 수 있습니다. 이 테스트는 최소 재현으로 이 동작을 고정하며 회귀 시 타입 검사 단계에서 오류가 발생합니다.
private: true패키지 구성: workspace 의존성이 실제 산출물을 가리킴types: dist/index.d.ts핵심 필드:dependencies: npm에 게시하지 않음.workspace:*: 타입 진입점이 빌드 산출물을 가리킴.@vue/shared、@vue/reactivity、vue。
의존성:@vue/shared〔설계 추론 및 아키텍처 트레이드오프〕@vue/reactivity왜vue와types에 의존하는가? 왜냐하면dist의 타입이 이 두 패키지의 타입을 참조할 수 있기 때문입니다. workspace 모드에서 pnpm은 이 의존성들을 로컬 패키지에 심볼릭 링크하고 로컬 패키지의필드는 각자의아래 산출물을 가리킵니다. 이렇게 전체 테스트 체인이 소비하는 것은
빌드 산출물
dts-built-test이며 소스 코드가 아닙니다.src/index.ts테스트 실행 방법tsc자체에는 테스트 스크립트가 없고 그것의tsc이 곧 테스트 케이스입니다. 실행 방식은: CI에서
가 오류를 내고 CI가 실패합니다.〔설계 추론 및 아키텍처 트레이드오프〕이 설계의 교묘함은 「타입 계약」을tsc컴파일 가능한 코드
로 인코딩한다는 점입니다. 추가 단언 라이브러리도, 런타임도 필요 없이
자체가 테스트 러너입니다. 타입이 맞으면 컴파일 통과, 틀리면 컴파일 실패.dts-built-testdts-test와의 분업dts-test이 장의
dts-built-test과 다음 장의는 다른 것입니다:(이 장): 소비dts-test빌드 산출물, 게시 수준 타입 형태 검증.(다음 장): 소비
, API 표면 계약 검증.patchTypes〔설계 추론 및 아키텍처 트레이드오프〕stripInternal왜 두 계층이 필요한가? 소스 타입과 산출물 타입이 일치하지 않을 수 있기 때문입니다.types/의 AST 재작성,dts-built-test의 제거,
디렉터리의 추가는 모두 소스 타입이 올바른 전제에서 산출물 수준 버그를 유발할 수 있습니다.
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복사dts-built-test이 시퀀스 다이어그램은 크로스 모듈 협업을 고정합니다: CI가 tsc와 Rollup 두 단계를 구동하고,
의 세 번 순회가 핵심 가공이며,
가 마지막에 산출물을 소비하여 검증합니다.
patchTypes설계 사고, 오류 복구 및 프로덕션 함정code.replace(...)왜 문자열 치환이 아니라 MagicString을 쓰는가?
1. 전 과정에서가 아니라 MagicString으로 정밀 재작성을 합니다. 이유는 두 가지:start/end위치 정밀
2. : AST 노드가 자체적으로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)
: 실제 빌드 산출물에서 타입 스모크 테스트를 수행하고, 컴파일 가능한 코드로 핵심 타입 형태를 고정하여 타입 드리프트를 방지한다.
이 장 사고와 자가 테스트tsconfig.build.jsonQ1: 만약include의["packages"]화이트리스트를
(즉, 전체 packages 디렉토리 포함)로 변경하면 어떻게 될까? 어떤 시나리오에서 배포 타입 오염이 발생할까?:
include참고 해석["packages"]12개의 정확한 디렉토리에서packages-private로 변경하면, 모든 하위 패키지(packages/*외의 모든📎 tsconfig.build.json:10-23
포함)가 tsc 산출에 참여한다.
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아래에 나타난다. 만약 해당 패키지의
에
Q2: patchTypes이 없으면, 배포 스크립트가 그것을 함께 npm에 배포하여 내부 타입이 유출될 수 있다.processDeclaration이것이 바로 화이트리스트 설계의 가치다: 새 패키지는 기본적으로 참여하지 않으며, 명시적으로 추가해야 한다. 이는 안전한 기본값에 부합한다._의 pass 1에서,return은_로 시작하는 타입을 직접_InternalType. 만약 어떤 공개 API의 타입이 우연히
로 시작하면(예::
processDeclaration이 실수로 내보내짐), 사용자 측에서 어떤 현상이 보일까? 어떻게 조사할까?_참고 해석shouldRemoveExport이export 。📎 rollup.dts.config.js:76-78
로 시작하면 직접 반환하며,
에 추가하지도 않고,export。
를 prepend하지도 않는다.shouldRemoveExport결과:
1. 해당 타입은 인라인을 얻지 못한다.2. 또한 큰 내보내기 블록에서 제거되지 않는다(
에 없기 때문).export { _InternalType }3. 따라서 그것은stripInternal여전히 큰 내보내기 블록에 있으며tsc, 이론적으로 여전히 가져올 수 있다.
하지만 문제는: 큰 내보내기 블록의
이 선언 위치를 참조한다는 것이다. 만약 그 선언이 어떤 이유(예:vue.d.ts)로 제거되면, 내보내기 블록은 존재하지 않는 이름을 참조하게 되어export오류가 발생한다.
조사 방법:_1. 산출물
에서 해당 타입이 선언 위치에
이 없으면서 큰 내보내기 블록에서 참조되는지 확인한다._2. 소스 코드에서 해당 타입 이름이
Q3: dts-built-test로 시작하는지 확인한다.src/index.ts3. 명명 문제로 확인되면, 밑줄 접두사를 제거하도록 이름을 바꾸면 된다.typeof _CustomPropsNotErased & { foo: string }이는 명명 규칙과 도구 동작의 암시적 결합을 드러낸다:foo접두사는 원래 "내부"를 의미하지만, 도구는 이를 "내보내지 않음"으로 간주한다. 두 의미가 완전히 일치하지 않는다.Omit<typeof _CustomPropsNotErased, never> & { foo: string }의
이 교차 타입:
Omit<T, never>을 사용하여이 지워지지 않음을 검증한다. 만약 교차 타입을로 변경하면, 테스트가 여전히 #8376의 회귀를 잡을 수 있을까? 왜?
- 참고 해석
T & { foo: string }은 새로운 매핑 타입을 생성하여foo재계산defineComponentT의 모든 속성을 재계산한다. 만약 #8376의 버그가 "교차 타입의 추가 속성이 지워짐"이라면:foo원래写法 Omit: 직접 교차,Omit은 교차 타입의 일부이며, 만약T의 반환 타입 처리 로직이 교차의 추가 속성을 지우면,{ foo: string }이 손실된다.Omit写法:
📎 packages-private/dts-built-test/src/index.ts:9-12
먼저에 매핑을 수행한 후과 교차.Omit、Pick의 매핑 과정이 타입 구조를 변경하여 버그의 트리거 조건이 더 이상 성립하지 않을 수 있다——버그가 존재하더라도 테스트가 통과할 수 있다.
최소성
이 매우 중요하다: 버그의 트리거 경로를 정확히 재현해야 한다. 어떤 추가 타입 변환(예:dts-built-test)도 버그를 가릴 수 있다. 이것이 테스트에서 더 "우아한"写法 대신 가장 소박한 교차 타입을 사용하는 이유다.dts-test, Vue가 타입 계약 테스트로 공개 API 표면을 어떻게 보호하는지 살펴보자.
세 가지가 「생성 → 정형 → 검증」의 폐쇄 루프를 구성하여 소스 타입과 배포 타입이 엄격히 일치하도록 보장한다. 그러나 타입 패키지 자체가 올바르다는 것이 공개 API의 타입 형태가 고정되었다는 것을 의미하지는 않는다. 다음 장에서는 깊이 들어가packages-private/dts-test, 20여 개의.test-d.ts파일이 어떻게expectType등의 도구를 사용하여 「타입이 곧 API 계약」을 회귀 가능한 자동화 테스트로 만드는지 살펴본다.
제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: preserve는 TSX 문법을 타입 시스템 파싱용으로 남겨두며,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>임을 단언한다;T2는T;IsUnion<T>이T에 할당 가능함을 단언한다;IsAny<T>는T이 유니온 타입인지 판단한다;any는import '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이 단일 시그니처로 병합되지 않고 반드시 유니온 타입이어야 한다」와 같은 계약을 고정하는 데 사용된다.시나리오 기반 Walkthrough:defineComponent({ props: {...}, setup(props) {...} })의 props 타입 추론 전체 경로props는 2260줄로, 계약 체계의 핵심이다. 구체적인 시나리오를 대입해 보자:setup사용자가props를 작성하면, Vue의 타입 시스템은런타임 선언에서
안의
파라미터의 정확한 타입을 추론해야 한다ExpectedProps. 이 경로는 Vue 타입 시스템에서 가장 복잡한 부분이다.첫 번째 단계: 「기대 타입」을 계약 기준으로 구성:
📎 packages-private/dts-test/defineComponent.test-d.tsx:21-53
테스트 파일은 먼저a?: number | undefined인터페이스를 정의하여, 각 props 선언 방식이 추론해야 할 타입을undefined)、aa: number명시적으로 하드코딩한다aaa: number | null(PropType<number | null>이 인터페이스는 「계약 조항」의 서면 버전이다. 몇 가지 미묘한 타입에 주목하라:aaaa: number | undefined(required: true as const(선택적 props에undefined(default가 있으므로 비선택적),props는 명시적 선언),
이지만 타입에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이며, Vue props의 모든 작성법을 커버한다:boolean— 생성자 축약,b: { type: String, required: true as true }——required: true로 추론bb: { default: 'hello' }— default가 있으므로 비선택적type로 추론cc: Array as PropType<string[]>는l: [Date]이Date | undefinedll: [Date, Number]로 확장되는 것을 방지하고 리터럴 타입을 보존Date | number | undefinedlll: [String, Number]는 속성을 non-void로 만든다
required: true as const없이 default만으로 타입 추론required: true as true— 명시적 타입 변환as true— 배열 문법,as const로 추론— 다중 타입 배열,。
로 추론setup / render / this— 상동
〔설계 추론과 아키텍처 트레이드오프〕(L70)과。
📎 packages-private/dts-test/defineComponent.test-d.tsx:160-217
setup(props)(L75) 두 작성법이 공존하는 것은 역사적 진화의 흔적이다: 초기에는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두 경로를 통해 단언한다:
📎 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 소비 측의 타입 검증
타입 계약의 마지막 고리는 '사용자가 이 컴포넌트를 어떻게 사용하는가'이다. TSX에서<MyComponent />의 props 검증은 독립적인 타입 경로이다:
📎 packages-private/dts-test/defineComponent.test-d.tsx:296-322
여기서는<MyComponent>가 선언된 모든 props를 받아들이는지, 그리고class/style/key/ref/ref_for같은 내장 속성들을 검증한다. 그 다음은역방향 검증:
📎 packages-private/dts-test/defineComponent.test-d.tsx:337-345
// @ts-expect-error missing required props은 필수 props 누락 시 오류 발생을 검증하고;wrong prop types는 타입 불일치 시 오류 발생을 검증하며; L342는ggg="baz"가 오류를 냄을 검증한다(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 阻断合并"]이 그림의 핵심은:동일한props선언이 세 소비 위치의 타입 기대를 동시에 충족해야 한다. 어느 한 곳에서든 추론 편차가 생기면tsc가 오류를 낸다.
경계와 백도어:__typeProps、__typeEmits와 조건부 타입 계약
defineComponent의 타입 추론에는 근본적인 한계가 있다:런타임 props 선언은 '조건부 타입'을 표현할 수 없다. 예를 들어 '만약color='white'일 때appearance는 반드시'outline'여야 한다' 같은 제약은 런타임 객체 문법으로 작성할 수 없다. Vue는 이를 위해__typeProps같은 '타입 백도어'를 제공한다.
__typeProps: 조건부 props의 타입 탈출구
📎 packages-private/dts-test/defineComponent.test-d.tsx:1803-1836
ConditionalProps는 유니온 타입이다:要么color와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의 설계 동기는 '런타임에 표현할 수 없는 제약을 타입 시스템이 표현하게 하는 것'이다. 이는 런타임 props 파싱에 참여하지 않으며, 순수 타입 레벨의 오버라이드이다. 대가는 사용자가 타입과 런타임 선언의 일관성을 수동으로 유지해야 한다는 점이다——이것이 바로 이것이 공식 API가 아니라 'backdoor'라고 불리는 이유이다.
__typeEmits: 두 가지 emits 문법의 동등성는 두 가지 문법을 지원하며, 테스트:
📎 packages-private/dts-test/defineComponent.test-d.tsx:1838-1885
는 둘 다 동시에 고정한다{ change: [id: number], update: [value: string] }객체 문법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 }호출 시그니처 문법은 오버로드로 표현한다.두 문법의 테스트 본문은 거의 줄 단위로 동일하다——이것은 의도적이다: 계약은 두 작성 방식이완전히 동등한
〔설계 추론과 아키텍처 트레이드오프〕defineEmits왜 두 문법을 유지하는가? 객체 문법은
__typeRefs의 작성 방식에 더 가깝고, 호출 시그니처 문법은 전통적인 TS 이벤트 타입에 더 가깝다. Vue는 둘 다 지원하면서 동작 일관성을 보장해야 한다. 테스트의 '줄 단위 미러' 구조가 가장 강력한 동등성 증명이다.__typeEl와
📎 packages-private/dts-test/defineComponent.test-d.tsx:1936-1952
__typeRefs: 컴포넌트 간 참조와 호스트 노드 타입Parent는 부모 컴포넌트가 자식 컴포넌트 ref의 타입을 정확히 알 수 있게 한다.__typeRefs: { child: ComponentInstance<typeof Child> }는refs.child.$refs.foo를 선언하며, 따라서number。
📎 packages-private/dts-test/defineComponent.test-d.tsx:1963-1977
__typeEl는로 추론될 수 있다. 더 미묘하다. L1963-1977의 테스트 주석은 설계 의도를 명확히 밝힌다:Element커스텀 렌더러(TUI, canvas, native)의 호스트 노드는 DOMTypeEl가 아니므로,Element는CustomElement로 제약될 수 없다. 테스트는$el인터페이스로
〔설계 추론과 아키텍처 트레이드오프〕TypeEl이것은 Vue 3가 커스텀 렌더러를 지원하는 타입 레벨 보장이다. 만약Element,@vue/runtime-test가$el로 하드 제약된다면, 이러한 비 DOM 렌더러 사용자들은
타입을 올바르게 추론할 수 없다. 계약 테스트가 여기서 수호하는 것은 '렌더러 무관성'이다.
function syntax w/ runtime props제네릭 컴포넌트와 런타임 props의 상호 배타 제약섹션은 중요한 규칙 하나를 고정한다:。
📎 packages-private/dts-test/defineComponent.test-d.tsx:1501-1545
제네릭 컴포넌트는 객체 런타임 props와 공존할 수 없다generics aren't supported with object runtime propsL1501의 주석<Comp3<string>>은 계약 선언이다. L1525-1535는 제네릭 setup + 객체 props 오류 발생을 검증하고; L1538-1539는
〔설계 추론과 아키텍처 트레이드오프〕ExtractPropTypes이 제약의 근본 원인은 타입 추론 순서이다: 객체 props는
가 먼저 타입을 확정해야 하는데, 제네릭은 인스턴스화 시점에만 확정할 수 있어 둘이 충돌한다. 배열 props는 타입 추출에 참여하지 않으므로 충돌하지 않는다. 계약 테스트는 이 '타입 시스템 제한'을 회귀 가능한 단언으로 고정한다.
@ts-expect-error설계 사고, 오류 복구, 프로덕션 함정
@ts-expect-error의 양날의 검는 타입 계약 테스트의 핵심 도구이지만, 치명적인 함정이 있다:@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프로덕션 함정 포인트: TypeScript 버전 업그레이드로 오류 위치가 미세 조정되면, 다수의가 집단적으로 무효화될 수 있다. Vue의 대응 전략은@ts-expect-error를단언 대상 코드에 바짝 붙이고
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 컴파일과 실시간 미리보기의 폐쇄 루프를 어떻게 완성하는지 살펴본다.
제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가 어떻게 상태 전환을 구동하는가, 빌드 시점 상수가 어떻게 주입되는가.
1. 진입점의 미니멀리즘: 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. 진입점은 '전역 부수 효과 주입 + 마운트' 두 가지만 담당하며, 어떤 비즈니스 로직도 여기에 나타나서는 안 된다. 이는 Playground가 '제품'이 아닌 '디버깅 도구'로서의 선택——SSR 호환성, 다중 진입점, 지연 로딩이 필요 없다.
프로덕션 함정 포인트:window.VUE_DEVTOOLS_CONFIG는전역 싱글톤. 만약 Playground가 DevTools를 사용하는 다른 페이지(예: iframe 시나리오)에 임베드되면, 나중에 작성된 것이 먼저 것을 덮어쓴다. Playground는 일반적으로 독립 배포되므로 이 위험은 수용된다.
---
2. 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'
}>()다섯 개의 props는 두 가지로 나뉜다:
store: ReplStore: 유일한 상태 컨테이너 참조,@vue/repl에서 옴. Header는 이를 통해store.loading、store.vueVersion、store.typescriptVersion를 읽고, 직접store.vueVersion。- 네 개의 불리언/리터럴 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의<script setup>. 이러한 혼용은 Vue 3의 일반적인 스타일:$emit。
부수 효과가 필요할 때는 함수 emit, 순수 전달 시에는 템플릿
Step-by-Step: 버전 표시와 전환
시나리오: 사용자가 Playground를 열면, Header는 현재 Vue 버전을 표시해야 한다.
📎 packages-private/sfc-playground/src/Header.vue:30-37
const vueVersion = computed(() => {
if (store.loading) {
return 'loading...'
}
return store.vueVersion || `@${__COMMIT__}`
})복사loading여기에는 세 가지 우선순위가 있다:'loading...'상태 →store.vueVersion; 사용자가 명시적으로 버전 선택 →@${__COMMIT__}; 그렇지 않으면 →__COMMIT__(현재 commit 짧은 해시).
는 빌드 시 주입된 상수이며, 다음 섹션에서 자세히 설명한다.
📎 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로 분리했다. 이유는setVueVersion가 computed(읽기 전용)이므로 직접 양방향 바인딩할 수 없으며, 반드시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로 선언되었지만 내부에
가 없다——이것은 역사적 유산인가 의도적인가? 추측컨대
📎 packages-private/sfc-playground/src/Header.vue:76-80
<VersionSelect
v-model="store.typescriptVersion"
pkg="typescript"
label="TypeScript Version"
/>단계 3: TypeScript 버전 비교v-model복사store.typescriptVersionTypeScript 버전은를 사용했는데,는 쓰기 가능한 일반 속성이므로 computed 래핑이 필요 없다.
동일한 컴포넌트가 동일한 템플릿에서 두 가지 바인딩 방식을 사용하는 것
📎 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'))
}테마 전환: 부수 효과와 emit의 조합복사props.theme이 함수는 세 가지를 한다: DOM class 조작, localStorage에 영속화, emit으로 부모 컴포넌트에 알림.toggle-theme주의: 직접theme를 변경하지 않는다——props는 읽기 전용이므로, 부모 컴포넌트가:title를 받은 후에야📎 packages-private/sfc-playground/src/Header.vue:123。
문구〔설계 추론 및 아키텍처 트레이드오프〕。document.documentElement.classList.toggle('dark')여기 미묘한 설계가 있다:themeDOM class 조작과 Vue 반응형 상태는 두 개의 독립 경로
는 직접 DOM을 변경하고,
📎 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.')
}숨겨진 로직: copyLink의 metaKey 분기복사이것은play.vuejs.org개발자 백도어localhost:5173:// hidden logic for going to local debug from play.vuejs.org 📎 packages-private/sfc-playground/src/Header.vue:47-56에서 Cmd를 누른 채 공유 버튼을 클릭하면
resetVueVersion()는 이것이 의도적으로 숨겨진 기능임을 명확히 표시한다.store.vueVersion〔설계 추론 및 아키텍처 트레이드오프〕null는 이동 전에 호출되어
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)"]로 설정하여, 로컬 디버깅이 온라인에서 선택된 버전이 아닌 현재 commit을 사용하도록 보장한다.
설계 사고와 함정navigator.clipboard〔설계 추론 및 아키텍처 트레이드오프〕。copyLink함정 1:📎 packages-private/sfc-playground/src/Header.vue:47-56의 권한과 보안 컨텍스트writeText에는 try/catch가 없다
는 reject되어 처리되지 않은 Promise rejection을 초래한다. Playground는 HTTPS에 배포되므로 위험이 수용되지만, 이는 전형적인 '프로덕션 환경 함정'이다.toggleDark〔설계 추론 및 아키텍처 트레이드오프〕。'vue-sfc-playground-prefer-dark'함정 2:
의 localStorage key 하드코딩currentCommit는 문자열 리터럴이며 상수 추출이 없다. 향후 key를 변경하려면 전역 검색이 필요하다.vueVersion함정 3:와:class="{ active: vueVersion === \@${currentCommit}\ }" 📎 packages-private/sfc-playground/src/Header.vue:88-88문자열 연결로 비교한다. 만약__COMMIT__주입 실패(변환됨undefined), 여기서는'@undefined'로 변해 영원히 일치하지 않는다. 빌드 시점 상수 주입의 신뢰성이 UI 정확성을 직접 결정한다—이것이 바로 다음 절의 주제다.
---
3. 빌드 시점 상수 주입: __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 hook을 제거하지만, 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__}이 특수 값은 copyVuePlugin이 복사한 로컬 산출물에 대응하며 CDN이 아니다. 이것이 Playground가 Vue의 브라우저 빌드 산출물을 반드시 복사해야 하는 이유다—'This Commit' 옵션은 로컬 파일이 필요하다。
설계 사고와 함정
함정 1:spawnSync의 실패 처리. 만약 현재 디렉터리가 git 저장소가 아니면(예: tarball 압축 해제),spawnSync은 0이 아닌 종료 코드를 반환하고,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:spawnSync은 commit 해시를 가져오고,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로 소스와 산출물의 매핑을 구축하여 컴파일러의 내부 동작을 관찰 가능하고 역추적 가능한 프로브로 만드는 방법을 살펴본다.
제8장: Template Explorer: 컴파일러 동작의 시각화 프로브
지난 장에서 우리는 SFC Playground가 'SFC 입력 → 브라우저 내 컴파일 → 실시간 미리보기'라는 전체 체인을 어떻게 블랙박스로 캡슐화하는지 살펴보았다. 개발자는 최종 렌더링 결과만 볼 뿐, 컴파일러가 중간에 무엇을 하는지는 볼 수 없다. 템플릿에 커스텀 디렉티브를 작성하거나 hoistStatic을 켠 후 결과물에 갑자기 _hoisted_1 변수가 잔뜩 생겼을 때, Playground는 '컴파일러가 왜 이렇게 생성했는가'에 답할 수 없다. Template Explorer의 포지셔닝은 정반대다. @vue/compiler-dom과 @vue/compiler-ssr의 컴파일 산출물, AST, 오류 마커, 그리고 소스 코드에서 산출물로의 위치 매핑을 전부 펼쳐 놓는다. 핵심은 '실행'이 아니라 '관찰'이다. 이 장은 세 파일을 중심으로 전개된다. index.ts는 컴파일 호출과 SourceMap 양방향 매핑을 담당하고, options.ts는 reactive로 수십 개의 CompilerOptions를 관리하며 UI를 구동하고, theme.ts는 Monaco 에디터 테마를 커스터마이즈한다.
一、컴파일 호출과 SourceMap 양방향 매핑: index.ts
직관적 모델
Template Explorer의index.ts는 '양방향 번역기'와 같다. 왼쪽에는 템플릿을 입력하고, 오른쪽에는 렌더 함수를 출력한다. 하지만 번역기보다 한 가지 능력이 더 있다. 왼쪽의 특정 줄에 커서를 놓으면 오른쪽에서 대응하는 산출물이 하이라이트되고, 반대로 오른쪽에 커서를 놓으면 왼쪽에서 대응하는 템플릿이 하이라이트된다. SourceMap 매핑이 없다면 이 도구는 나란히 놓인 두 개의 텍스트 상자로 전락하여, 개발자는 육안으로만 비교할 수 있고 '템플릿 몇 번째 줄 → 산출물 몇 번째 줄'이라는 인과 사슬을 구축할 수 없다.
데이터 구조와 메모리 레이아웃
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 hash에 영속화되는 상태 형태를 정의한다.📎 packages-private/template-explorer/src/index.ts:26-30:src(템플릿 소스),ssr(SSR 모드 여부),options(컴파일러 옵션). 여기에는 핵심 설계가 하나 있다.options의 타입은 완전한CompilerOptions이지만, 실제 영속화 시에는 '기본값과 다른 항목'만 저장하며, 이 잘라내기 로직은reCompile에서 수행된다.
sharedEditorOptions는 두 에디터가 공유하는 생성 옵션📎 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>를 입력한 다음 커서를 이동한다.
첫 번째 단계: 초기화와 상태 복원. window.init는 전역 진입점📎 packages-private/template-explorer/src/index.ts:41이다. 먼저 커스텀 테마를 등록하고 활성화한📎 packages-private/template-explorer/src/index.ts:44-45다음, URL hash 또는 localStorage에서 상태 복원을 시도한다📎 packages-private/template-explorer/src/index.ts:49-56. 여기서 디코딩 순서에 주목하자. 먼저atob그다음escape, 그다음decodeURIComponent. hash 파싱이 실패하면localStorage.getItem('state')로 fallback하고, 다시{}로 fallback한다. 전체 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이 손실되고, 복원 시 빈 객체가 남아 있으면 컴파일러 동작이 비정상이 될 수 있다. 이는 '직렬화 불가능한 필드 영속화'의 전형적인 함정이다.
두 번째 단계: 컴파일 핵심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 조회가 빈 결과를 반환한다. 이는 암묵적 계약이다. 두 곳의 문자열이 일치해야 하지만, 이를 보장하는 타입 시스템은 없다.
컴파일이 완료되면 오류가 Monaco의 marker 형식으로 변환되어 에디터에 설정된다📎 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이 없는 오류(예: 전역 설정 오류)는 콘솔에만 출력된다.
세 번째 단계: 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의 핵심 API 중 하나로, 각 매핑 세그먼트의 열 범위를 미리 계산하여generatedPositionFor가 반환하는lastColumn필드를 사용할 수 있게 한다. 이 단계가 없으면 역방향 매핑은 시작 열만 찾을 수 있고 전체 토큰 범위를 하이라이트할 수 없다.
네 번째 단계: 양방향 커서 매핑.사용자가소스 에디터에서 커서를 이동하면editor.onDidChangeCursorPosition 📎 packages-private/template-explorer/src/index.ts:184가 트리거된다. 콜백은 100ms debounce 후lastSuccessfulMap.generatedPositionFor({ source: 'ExampleTemplate.vue', line, column: column - 1 }) 📎 packages-private/template-explorer/src/index.ts:188-192를 호출한다. 주목할 점은column - 1이다. Monaco의 열 번호는 1부터 시작하고 SourceMap의 열 번호는 0부터 시작한다. 반환된pos에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문 또는 helper 함수)에는 대응하는 템플릿 위치가 없어서, SourceMap이{ line: 1, column: 0 }를 자리 표시자로 반환합니다. 이를 무시하지 않으면, 이 줄들에 커서를 놓았을 때 잘못해서 템플릿 첫 번째 줄을 하이라이트하게 됩니다.
다섯 번째 단계: 상태 지속화. reCompile은 컴파일을 트리거할 뿐만 아니라 현재 상태를 localStorage와 URL hash에 기록하는 역할도 합니다📎 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를 쓰지 않는가? 는 Mozilla의 원본 라이브러리로, 용량이 크고 (새 버전에서는) WASM에 의존합니다.source-map-js는 순수 JS 구현으로, 용량이 작아 브라우저 환경에 적합합니다. Template Explorer는 순수 프런트엔드 도구로서source-map-js를 선택한 것은 합리적입니다📎 packages-private/template-explorer/package.json:15。
debounce 지연 선택.소스 편집기의 debounce 기본값은 300ms📎 packages-private/template-explorer/src/index.ts:271이고, 커서 이동의 debounce는 100ms📎 packages-private/template-explorer/src/index.ts:215입니다. 이 차이는 의도적입니다: 컴파일은 무거운 작업이라 300ms로 빈번한 트리거를 피하고, 커서 이동은 가벼운 작업이라 100ms로 반응감을 보장합니다. 하지만 100ms도 커서를 빠르게 움직일 때 하이라이트 깜빡임을 일으킬 수 있습니다——이는 수용 가능한 절충입니다.
window.init의 전역 마운트.주의:window.init와window.monaco는 모두 전역📎 packages-private/template-explorer/src/index.ts:19-23에 마운트됩니다. 이는 Monaco 편집기가 CDN의loader.js을 통해 비동기 로드되고, 로드 완료 후window.init을 호출하기 때문입니다. 이러한 「전역 콜백」 패턴은 비모듈 환경에서 Monaco의 표준 사용법이지만, 현대 ESM 빌드 방식과는 잘 맞지 않습니다.
---
둘째, reactive 기반 옵션 패널: options.ts
직관적 모델
options.ts은 「콘솔 패널」과 같습니다: 위에 십여 개의 스위치와 라디오 버튼이 있고, 각각이 컴파일러의 한 동작에 대응합니다. 어떤 스위치든 움직이면 오른쪽의 컴파일 산출물이 즉시 바뀝니다. 이 모듈이 없다면, 개발자는 소스의compile호출 인자를 수정한 뒤 다시 컴파일해야만 하고, 서로 다른 옵션의 효과를 실시간으로 비교할 수 없습니다.
데이터 구조와 메모리 레이아웃
options.ts의 핵심은 세 개의 export입니다:
ssrMode은ref(false) 📎 packages-private/template-explorer/src/options.ts:5입니다. 이것은compilerOptions과 독립적인데, SSR 모드가 전환하는 것은 컴파일 함수 자체(compile vs ssrCompile)이지 컴파일 옵션이 아니기 때문입니다.
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'와 7개의 바인딩 타입을 포함하는bindingMetadata 📎 packages-private/template-explorer/src/options.ts:18-26。
compilerOptions을 포함합니다.reactive(Object.assign({}, defaultOptions)) 📎 packages-private/template-explorer/src/options.ts:29-31는Object.assign({}, ...)입니다. 여기서reactive(defaultOptions)로 얕은 복사를 했다는 점에 주의하세요——만약 그냥compilerOptions하면,defaultOptions을 수정할 때reCompile이 오염되어
Step-by-Step Walkthrough
안의 「기본값과 비교」 로직이 무효화됩니다.
시나리오: 사용자가 「hoistStatic」 체크박스를 클릭. App첫 번째 단계: UI 렌더링.setup컴포넌트의📎 packages-private/template-explorer/src/options.ts:33-35는 렌더 함수ssrMode.value、compilerOptions.mode、compilerOptions.prefixIdentifiers를 반환합니다. 이 렌더 함수는📎 packages-private/template-explorer/src/options.ts:36-39등의 반응형 상태를 읽으므로, 이 상태들이 변하면 전체 UI가 다시 렌더링됩니다.
두 번째 단계: 체크박스의 checked 바인딩. hoistStatic체크박스의checked속성은compilerOptions.hoistStatic && !isSSR 📎 packages-private/template-explorer/src/options.ts:150입니다. 여기에는 하나의 로직이 있습니다: SSR 모드에서는hoistStatic이 강제로 선택 해제되어 표시되는데, SSR 컴파일이 정적 호이스팅을 지원하지 않기 때문입니다. 동시에disabled: isSSR 📎 packages-private/template-explorer/src/options.ts:151은 사용자가 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에 의존한다는 뜻입니다. 이러한 연동 관계는 UI에서 다음과 같이 나타납니다:function이 켜져 있지 않고 모드가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. module 모드에서만 scopeId를 설정할 수 있고, onChange 시null 📎 packages-private/template-explorer/src/options.ts:184-189。
이 false이면 강제로 initOptions로 설정됩니다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의 다섯 가지 타입을 포괄합니다. 이는 개발자가 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. 세 개의 최상위 필드가 있습니다:
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
시나리오: 페이지 로드 시 테마를 등록합니다.
첫 번째 단계: 테마를 정의합니다. monaco.editor.defineTheme('my-theme', theme) 📎 packages-private/template-explorer/src/index.ts:44. 이 호출은theme.ts의 내보낸 객체를 Monaco의 테마 레지스트리에 등록하며, 키 이름은'my-theme'。
두 번째 단계: 테마를 활성화합니다. monaco.editor.setTheme('my-theme') 📎 packages-private/template-explorer/src/index.ts:45. 이 코드는 반드시defineTheme이후에 호출해야 하며, 그렇지 않으면 "테마가 정의되지 않음" 오류가 발생합니다.
세 번째 단계: 토큰 매칭.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는 "단일 템플릿 표현식이 무엇으로 컴파일되는가"를 관찰합니다. 이 차이가 두 도구의 기술 선택을 결정합니다:
SourceMapConsumer의 도입은 필연적입니다.이것이 없으면 개발자는 소스와 산출물을 육안으로 비교할 수밖에 없어, 정확한 "몇 번째 줄 → 몇 번째 줄" 매핑을 구축할 수 없습니다. 하지만 SourceMapConsumer의 API는 비동기이며(새 버전은 Promise 반환), 소스 코드에서는 동기 버전source-map-js을 사용합니다. 이는 호출 로직을 단순화하기 위함입니다.
reactive관리 옵션은 Vue 생태계의 자연스러운 선택입니다.네이티브 DOM 이벤트로 십여 개 옵션의 상태 동기화를 수동 관리한다면 코드량이 두 배가 됩니다.reactive의 의존성 추적이 "옵션 변경 → 재컴파일"이라는 체인을 자동화하여,watchEffect(reCompile)한 줄의 코드로 구독이 완료됩니다.
Monaco의 전역 로딩 모드는 역사적 부담입니다. window.monaco과window.init의 전역 마운트 방식은 Monaco의 AMD 로더 설계에서 비롯됩니다. 현대 ESM 빌드에서는 이질적으로 보이지만, Monaco의 크기(약 5MB) 때문에 온디맨드 로딩은 여전히 필요합니다.
---
이 장 요약
Template Explorer는 "화이트박스 프로브"입니다: 컴파일 산출물을 실행하지 않고 컴파일 과정만 보여줍니다.index.ts을 통해compileCode또는@vue/compiler-dom을 호출하고,@vue/compiler-ssr로 소스와 산출물의 양방향 매핑을 구축하며, Monaco의 데코레이터 API로 커서 연동 하이라이트를 구현합니다.SourceMapConsumeroptions.ts으로reactive을 관리하고,CompilerOptions을 통해 재컴파일을 구동하며, 옵션 간 연동 관계(예: SSR 비활성화watchEffect)를 UI 레이어에서 명시적으로 코딩합니다.hoistStatictheme.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'같은 helper import 문이나export function render(_ctx, _cache) { ... }같은 함수 시그니처가 그렇습니다. 이 코드들은 SourceMap에 원본 위치가 없어,source-map-js이{ line: 1, column: 0 }을 플레이스홀더로 반환합니다. 가드를 삭제하면 사용자가 이 줄들에 커서를 놓았을 때,originalPositionFor이 반환하는{ line: 1, column: 0 }, 코드는 이를 유효한 위치로 간주하여 소스 코드 편집기의 첫 번째 행 첫 번째 열에 하이라이트 데코레이터를 생성합니다. 결과적으로: 사용자가 산출물의import행을 클릭하면, 소스 코드 편집기의 첫 번째 행이 잘못 하이라이트되어 오해를 유발합니다. 이 가드의 본질은 「실제 매핑과 자리 표시자 매핑을 구분」하는 것이며,{ 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에 위치합니다. 이것은 의도된 설계이며, 그 이유는 세 가지입니다: 첫째,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로 10여 개의 플래그를 파싱하고, enquirer를 통해 버전 번호를 대화형으로 확인하며, 순서대로 빌드, 테스트, Git 커밋, 태그 지정 및 npm publish를 트리거하여, 한 번의 정식 릴리스 뒤에 숨은 완전한 상태 흐름과 실패 롤백 전략을 밝혀냅니다.
제 9 장: 릴리스 자동화: release.js의 상태 머신과 대화형 편성
이전 장에서 우리는 template-explorer를 통해 컴파일러 동작을 역추적하고, 도구로 내부 메커니즘을 관찰하는 방법론을 익혔습니다. 이제 시선을 컴파일 타임에서 릴리스 타임으로 돌립니다 — 이것은 모든 오픈 소스 프로젝트에서 가장 위험한 순간입니다: 버전 번호, 빌드 산출물, Git 히스토리, npm registry라는 네 가지 되돌릴 수 없는 외부 시스템을 동시에 건드리기 때문입니다. 잘못된 npm publish는 취소할 수 없고, 잘못된 태그 푸시는 모든 다운스트림 사용자의 의존성 해석을 오염시킵니다. Vue core는 537행의 scripts/release.js로 이러한 위험을 길들입니다 — 이것은 순수한 자동화 스크립트도, 순수한 수동 체크리스트도 아니며, 대화형 상태 머신입니다: 핵심 지점에서는 멈춰서 사람에게 묻고, 예측 가능한 지점에서는 완전 자동으로 실행하며, 어느 단계에서든 실패하면 버전 번호를 시작점으로 롤백합니다. 이 장에서는 이 편성기의 세 가지 핵심 메커니즘을 분석합니다: 인자 파싱과 상태 초기화, 대화형 버전 결정과 CI 게이트, 그리고 릴리스 순서와 실패 롤백.
인자 파싱과 전역 상태 초기화
직관적 모델
release.js을 구식 세탁기의 제어판이라고 상상해 보세요: 다이얼(parseArgs플래그와 전역 상태의 메모리 레이아웃
标志位与全局状态的内存布局
스크립트가 시작된 후 가장 먼저 하는 일은 명령줄 인자를 구조화된 객체로 파싱하는 것입니다. 여기서는 Node 내장parseArgs을 사용했으며,yargs이나commander가 아닙니다 — 이는 서드파티 의존성을 제거하기 위한 것으로, 배포 스크립트 자체는 어떤 환경에서도 실행될 수 있어야 하기 때문입니다. 심지어node_modules이 절반만 설치된 경우에도 말입니다.
📎 scripts/release.js:27-62은 10개의 옵션을 정의하며, 네 가지 범주로 나눌 수 있습니다:
- 버전 시맨틱 범주:
preid(프리릴리스 식별자, 예:alpha/beta/rc)、tag(npm dist-tag) - 건너뛰기 범주:
skipBuild、skipTests、skipGit、skipPrompts— 이 네 개의 불리언 스위치가 「자동화 정도」를 조절하는 노브를 구성합니다 - 실행 모드 범주:
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여기에는 음미할 만한 두 가지 설계가 있습니다. 첫째,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.5.44-0같은 어중간한 프리릴리스 버전으로 만드는 것을 방지합니다.
inc함수📎 scripts/release.js:120-120은semver.inc을 캡슐화하고,preId을 세 번째 인자로 전달합니다. 여기에는 타입 방어가 있습니다: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 게이트
직관적 모델
이 단계는 공항 보안 검색과 같습니다: 먼저 탑승권을 확인하고(로컬 commit이 원격과 동기화되었는지), 다음으로 어디로 가는지 확인하고(버전 번호), 마지막으로 보안 검색을 통과했는지 확인합니다(CI 통과 여부). 어느 하나라도 통과하지 못하면 전체 프로세스가 중단됩니다. 만약 이 게이트가 없다면, 푸시되지 않은 로컬 commit이 태그가 붙고 배포될 수 있어, npm의 버전에 대응하는 소스 코드가 GitHub에 존재하지 않게 됩니다 — 이것은 가장 해결하기 어려운 배포 사고입니다.
동기화 검사와 버전 선택
main함수가 가장 먼저 하는 일은isInSyncWithRemote() 📎 scripts/release.js:141-141입니다. 이 함수📎 scripts/release.js:337-363의 로직은: 현재 브랜치 이름을 가져오고, GitHub API를 요청해 해당 브랜치의 최신 commit 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는 해당 값을 직접 취합니다📎 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。
로 갑니다. 그 후 「2차 파싱」 로직📎 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은 두 가지를 합니다: 루트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 블록에서 2차 폴백을 수행합니다.📎 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은 세 가지 경우에 활성화됩니다: 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호출이 성공한 직후true 📎 scripts/release.js:208으로 설정됩니다. 이후 어떤 단계(changelog 생성, lockfile 업데이트, git commit, publish)에서 오류가 발생하면, catch 블록이 이 플래그를 확인하여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 생성, tag 생성, 푸시만 담당합니다. 실제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에 의해 처리되지만, 네트워크 왕복 한 번을 낭비하게 된다. 따라서 "네트워크 오류 시 중단"은 보수적이지만 올바른 선택이다.
---
다음 장에서는.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로 엔지니어링 규범을 우회할 수 없는 파이프라인으로 어떻게 고정하는지 이해하게 될 것이다.
제10장: CI/CD 워크플로: PR에서 Release까지의 자동화 게이트키퍼
이전 장에서 우리는scripts/release.js가 대화형 상태 머신으로 한 번의 릴리스의 각 단계를 어떻게 연결하는지 살펴보았다. 하지만 그 스크립트에는 전제가 하나 있다. 누군가 또는 어떤 시스템이 반드시 능동적으로 호출해야 한다는 것이다. Vue core 저장소에서 이 능동적 호출자는 유지관리자의 로컬 터미널이 아니라 GitHub Actions다. release.js는 실행자이고, workflows는 결정자다. 어떤 이벤트가 어떤 작업을 트리거하는지, 어떤 조건에서 통과시키고 어떤 조건에서 차단하는지를 결정한다. 이 장은.github/workflows/디렉터리 아래의 네 파일에 집중한다.ci.yml(PR 게이트와 지속적 사전 배포),release.yml(tag 트리거 정식 배포),size-report.yml(크기 회귀 보고),autofix.yml(포맷 자동 수정). 이들의 핵심을 이해하는 것은 YAML 문법을 외우는 것이 아니라, Vue 팀이 엔지니어링 규범을 우회할 수 없는 파이프라인 제약으로 어떻게 번역하는지를 꿰뚫어 보는 것이다.
1. ci.yml: 삼중 게이트와 지속적 사전 배포
직관적 모델
ci.yml를 공항 보안 검색대라고 상상해 보자. 모든 PR은 이 관문을 통과해야 한다. lint는 수하물에 금지품이 있는지 검사하고, typecheck는 신분증이 진짜 유효한지 확인하며, test는 위험물을 소지하지 않았는지 검증한다. 하지만 보안 검색대는 하나가 아니다. Vue는 여기에 "지속적 사전 배포" 통로도 하나 걸어두어, 각 PR의 빌드 산출물을 pkg-pr-new에 직접 배포함으로써 기여자가 실제 npm 설치 시나리오에서 자신의 변경을 검증할 수 있게 한다.
이 관문이 없다면, 어떤 병합이든 포맷 오류, 타입 허점, 동작 회귀를 main 브랜치로 가져올 수 있으며, main 브랜치는 이후 모든 release의 원천이다.
트리거 조건과 동시성 제어
ci.yml의 트리거 설정은 한 줄씩 분해할 가치가 있다.
📎 .github/workflows/ci.yml:2-11
on:
push:
branches:
- '**'
tags:
- '!**'
pull_request:
branches:
- main
- minor여기에는 두 가지 핵심 설계가 있다. 첫째,push이벤트는 모든 브랜치('**')를 감시하지만,tags: ['!**']로 모든 tag 푸시를 명시적으로 제외한다. 왜 tag를 제외하는가? tag 푸시는release.yml가 단독으로 처리하기 때문이다. 만약ci.yml도 tag에 반응하면 배포 흐름과 CI 흐름이 중복 트리거되어 runner 자원을 낭비하고 심지어 경쟁 상태를 일으킬 수 있다. 둘째,pull_request는main과minor두 브랜치만 감시한다. 이것이 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로 설정된다. 세 번의 커밋을 연속 푸시하면 앞의 두 CI는 자동 취소되고 최신 것만 유지된다.
이 설계의 동기는 명확하다. 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조건은 두 개의 논리곱(&&) 분기를 포함하며, 각각을 풀어볼 가치가 있다.
첫 번째 조건! startsWith(github.event.head_commit.message, 'release:'): 커밋 메시지가release:开头,跳过测试。这正是上一章 release.js 推送的提交信息格式——release.js 在本地已经跑过完整测试,CI 不需要重复验证。这是一个「信任上游」的优化。
두 번째 조건(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는 이미 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에서는 실행되지 않는다. 이는 세 가지를 한다: 빌드(pnpm build --withTypes, 타입 선언 포함), 그런 다음pkg-pr-new를 사용하여./packages/*아래의 모든 패키지를 임시 npm registry에 게시한다.
이 메커니즘의 가치는 기여자가 자신의 프로젝트에서 직접npm install이 PR의 빌드 산출물을 설치하여 변경 사항이 실제로 문제를 해결했는지 검증할 수 있다는 것이다. 이는 "CI가 초록불이다"를 보는 것보다 더 설득력이 있는데, 실제 패키지 소비 시나리오를 검증하기 때문이다.
모든 action이 commit SHA(예:actions/checkout@3d3c42e5...)를 고정하고,@v4와 같은 유동 tag를 사용하지 않는다는 점에 주의하라. 이는 공급망 보안의 강제 요구사항이다——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---
2. release.yml: tag 푸시 후의 릴리스 오케스트레이션
직관적 모델
만약ci.yml이 보안 검색대라면,release.yml은 발사대다. release.js가 로컬에서 버전 번호 업데이트, 커밋, tag 생성 및 푸시를 완료한 후, 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.10오직v*형식의 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여기에는 세 겹의 가드가 있으며, 각 층은 생략할 수 없다.
첫 번째 층if: github.repository == 'vuejs/core': fork에서의 잘못된 릴리스 트리거를 방지한다. 누군가 저장소를 fork하고v1.0.0tag를 푸시하면, 이 조건이 릴리스 프로세스 실행을 차단한다.
두 번째 층needs: [test]: release job은 test job에 의존한다. test job은test.yml을 호출하며, 테스트가 실패하면 release job은 시작조차 되지 않는다. 이는 "릴리스 전 반드시 테스트 통과"라는 강제 제약이다.
세 번째 층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 --publishOnly세 단계 각각에는 고려 사항이 있다.--frozen-lockfile는 CI 환경이 lockfile에 따라 엄격히 설치되도록 보장하여, 의존성 버전 드리프트로 인해 빌드 산출물이 로컬과 불일치하는 것을 방지한다.npm i -g npm@latest는 최신 npm CLI를 얻기 위한 것이다——provenance와 OIDC 인증은 비교적 새로운 버전의 npm에 의존하며, 구버전은 이러한 기능을 지원하지 않을 수 있기 때문이다.
pnpm release --publishOnly는 이전 장 release.js의 진입점이다.--publishOnly플래그는 release.js에게 알린다: 대화형 버전 번호 선택을 건너뛰고, Git 커밋과 tag 생성을 건너뛰며(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에 의해 직접 트리거되는 것이 아니라, 다른 워크플로의 완료 이벤트에 의해 트리거된다.
📎 .github/workflows/size-report.yml:3-7
on:
workflow_run:
workflows: ['size data']
types:
- completedworkflow_run이벤트 리스너 이름은size data인 워크플로가 완료될 때이다. 이것은 2단계 설계이다: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 이벤트, 업스트림 워크플로 성공. 만약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업스트림 워크플로 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을 실행한 후 다시 제출해야 한다. 이 워크플로는 이 단계를 자동화한다.
📎 .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---
설계 사고: 규범을 파이프라인으로 고정화
이 네 가지 워크플로를 되돌아보면, 관통하는 몇 가지 설계 원칙을 볼 수 있다.
첫째, 권한 최소화. ci.yml과autofix.yml은 모두permissions: contents: read을 선언하고, 오직release.yml만contents: write이 필요하며id-token: write。size-report.yml은pull-requests: write과issues: write이 필요하여 코멘트를 단다. 각 워크플로는 실제로 필요한 권한만 가진다.
둘째, 공급망 보안.모든 서드파티 action은 부동 tag가 아닌 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 저장소의 네 가지 핵심 워크플로를 분석했다:
ci.yml: PR 게이트 + 지속적 프리릴리스.if조건으로 push/PR과 fork/동일 저장소를 구분하고,concurrency로 오래된 PR 실행을 취소하며,pkg-pr-new로 설치 가능한 프리릴리스 패키지를 릴리스한다.release.yml: tag로 트리거되는 정식 릴리스. 3중 가드(저장소 검사, 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 브랜치에 직접 커밋한다.
이 네 가지 워크플로가 함께 「우회할 수 없는 파이프라인」을 구성한다: 코드 규범은 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 브랜치에 두 개의 PR이 연속으로 병합되었고, 첫 번째 PR의 CI가 실행 중이며(전체 lint/typecheck/test 포함), 두 번째 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 push 후 자동 배포」의 위험을 방어한다——수동 승인을 구성할 수 있게 하여, tag가 push되더라도 배포에는 메인테이너의 확인이 필요하도록 보장한다. 만약if조건을 제거하면 fork가 리소스를 낭비하고,environment을 제거하면 tag push 권한이 있는 누구나 최종 수동 확인 단계 없이 배포를 트리거할 수 있다. 둘은 서로 다른 계층의 방어이며, 서로를 대체할 수 없다.
Q3: size-report.yml에서if_no_artifact_found: warn의 선택과release.yml에서needs: [test]의 선택은 각각 어떤 실패 방향 설계 철학을 보여주는가? 이 두 전략을 서로 바꾸면 어떻게 되는가?
참고 해설: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 릴리스까지, 네 개의 워크플로 파일이 함께 우회할 수 없는 자동화 가드 체인을 구성한다. 하지만 파이프라인이 병합을 차단할 수 있는 전제는 정량화 가능한 판단 근거를 확보하는 것이다. 다음 장에서는 Vue가 번들 크기라는 핵심 지표를 어떻게 엔지니어링적으로 관리하는지에 초점을 맞춘다:scripts/size-report.js이 각 산출물의 gzip 후 크기를 어떻게 계산하고 베이스라인과 비교하는지,scripts/usage-size.js이 실제 사용자 도입 시나리오를 어떻게 시뮬레이션하여 실제 오버헤드를 추정하는지, 그리고 CI가 크기 초과 시 어떻게 병합을 차단하는지.
제11장: 번들 크기 예산 메커니즘: size-report와 usage-size의 측정 철학
지난 장에서 우리는 Vue가 GitHub Actions를 사용하여 lint, 타입 검사, 테스트, 번들 크기 추적을 우회할 수 없는 파이프라인으로 고정하는 것을 보았다. 그중 size-report.yml과 size-data.yml은 변경이 있을 때마다 크기 데이터를 남기는 역할을 한다. 하지만 파이프라인은 실행만 담당할 뿐, "얼마나 커졌는지, 어디가 커졌는지"를 실제로 답하는 것은 이번 장에서 분석할 두 스크립트이다. 번들 크기 예산의 핵심 모순은 패키지 크기가 감지할 수는 있지만 정확히 원인을 귀속시키기 어려운 지표라는 점이다. 사용자가 "Vue가 너무 크다"고 불평할 때, 유지보수자는 세 가지 질문에 답해야 한다 — 얼마나 커졌는가? 어디가 커졌는가? 이번 변경이 더 크게 만들었는가? scripts/size-report.js는 비교를 담당하고, scripts/usage-size.js는 원인 귀속을 담당하며, 둘은 함께 번들 크기 예산의 측정 철학을 구성한다.
11.1 size-report: 크기 차이를 읽기 쉬운 Markdown 표로 변환
직관적 모델
당신이 물류 회사의 품질 검사원이라고 상상해 보자. 각 소포(빌드 산출물)는 출고 전에 무게를 재야 하며, 당신의 일은 무게를 재는 것 자체가 아니라 "오늘의 무게"와 "어제의 무게"를 한 표에 나란히 놓고, 굵은+2.3 kB로 어떤 소포가 무거워졌는지 표시하는 것이다. 이 비교 표가 없다면 유지보수자는 고립된 숫자 더미만 볼 수 있어 특정 PR이 크기 회귀를 도입했는지 판단할 수 없다.
size-report.js이 바로 그 품질 검사원이다. 이것은 크기 데이터를 생성하지 않으며(그것은usage-size.js와 빌드 스크립트의 일이다), 단지 두 디렉터리의 JSON 파일을 소비하여 Markdown 보고서를 생성한다.
데이터 구조와 디렉터리 규약
스크립트의 핵심 규약은 두 상수에 숨어 있다. 현재 데이터 디렉터리는temp/size이고, 역사적 기준선 디렉터리는temp/size-prev。
📎 scripts/size-report.js:23-24
이다. 이 두 디렉터리의 이름은 임의가 아니다:temp/size은size-data.yml워크플로가 매 실행 시 생성하여 artifact📎 .github/workflows/size-data.yml:53-57로 업로드하고,temp/size-prev은size-report.yml이 기준선 artifact를 가져온 후 압축을 풀어 얻는다. 디렉터리 이름 자체가 데이터 흐름의 계약이다.
스크립트는 세 가지 타입 별칭을 정의하며, 이들은 JSON 파일의 구조를 정확히 묘사한다:
📎 scripts/size-report.js:8-21
SizeResult에는 세 개의 숫자 필드가 있다:size(비압축),gzip、brotli。BundleResult여기에file필드가 추가되어 파일 이름을 표시한다.UsageResult은Record이며, 키는 preset 이름이고 값은SizeResult & { name: string }이다 — 여기에는name필드가 하나 더 있는데, JSON 객체의 키는Object.values이후에 손실되므로 이름을 값 안에 중복 저장해야 하기 때문이다.
Step-by-Step Walkthrough
메인 흐름은 매우 간단하며, 두 단계와 한 번의 출력만 있다:
📎 scripts/size-report.js:23-38
run()먼저renderFiles()를 호출하여 산출물 파일 표를 렌더링하고, 그다음renderUsages()를 호출하여 사용 시나리오 표를 렌더링하며, 마지막으로 모듈 수준 변수output에 누적된 문자열을 한 번에 stdout📎 scripts/size-report.js:25에 쓴다. 이 "문자열을 누적한 후 한 번에 출력"하는 패턴은 여러 번의process.stdout.write연결 오버헤드를 피하고 출력 순서를 완전히 제어할 수 있게 한다.
첫 번째 단계: 파일 목록을 수집하고 합집합을 구한다.
📎 scripts/size-report.js:44-49
filterFiles두 종류의 파일을 필터링한다:_로 시작하는 파일(예:_usages.json)과.txt로 끝나는 파일(예:number.txt、base.txt). 이 두 종류는 메타데이터이지 크기 데이터가 아니다. 그런 다음 현재 디렉터리와 역사 디렉터리 파일 이름의 합집합fileList을 구한다 —Set로 중복을 제거한다. 왜 합집합을 구하는가? 파일이 역사 디렉터리에만 존재할 수도 있고(이번 빌드에서 해당 산출물이 삭제됨), 현재 디렉터리에만 존재할 수도 있기 때문이다(이번 빌드에서 산출물이 새로 추가됨). 두 경우 모두 보고서에 반영해야 한다.
두 번째 단계: 파일별 비교.
📎 scripts/size-report.js:43-75
합집합의 각 파일에 대해 두 디렉터리에서 각각 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를 표시한다. 그렇지 않으면 정상적으로 한 행을 렌더링하며, 각 숫자 뒤에getDiff의 결과를 붙인다.
세 번째 단계: 차이 계산.
📎 scripts/size-report.js:124-130
getDiff에는 세 개의 조기 반환 지점이 있다:prev === undefined일 때 빈 문자열 반환(기준선이 없어 비교 불가);diff === 0일 때 빈 문자열 반환(변화 없음, 노이즈 표시 안 함); 그렇지 않으면 굵은 부호 있는 차이를 반환한다.prettyBytes(diff)은 음수도 올바르게 처리하여-1.2 kB같은 형식을 출력하며,sign변수는 양수일 때만+。
를 붙인다.
📎 scripts/size-report.js:80-103
renderUsages네 번째 단계: usage 표 렌더링.renderFiles와_usages.json의 구조 차이는 주목할 만하다: 이것은Object.values(curr)를 직접 가져오는데, usage 데이터가 이 파일 하나에 고정적으로 존재하기 때문이다.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에서 import하는 API 목록), 선택적replace(추가 컴파일 타임 대체). 다섯 개의 preset이 최소에서 최대 사용 시나리오를 커버합니다:
createApp (CAPI only):createApp만 import하고,__VUE_OPTIONS_API__을'false'로 대체하여 순수 컴포지션 API 사용자📎scripts/usage-size.js:35-40createApp:createApp만 import하고, Options API 유지📎scripts/usage-size.js:35-40createSSRApp: SSR 시나리오📎scripts/usage-size.js:35-40defineCustomElement: Web Components 시나리오📎scripts/usage-size.js:35-40overall: 여섯 개의 핵심 API를 import하여 "풀기능" 사용자 시뮬레이션📎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
첫 번째 단계: 모든 preset의 bundle을 병렬로 생성.
📎 scripts/usage-size.js:62-69
main()각 preset에 대해generateBundle의 Promise를 생성하고,Promise.all로 병렬 실행합니다. 여기서 병렬은 안전합니다. 각generateBundle호출이 독립적인rollup()을 가지며, 상태를 공유하지 않기 때문입니다.
두 번째 단계: 가상 진입점 구성.
📎 scripts/usage-size.js:94-96
이것이 전체 스크립트에서 가장 정교한 부분입니다. 임시 파일을 디스크에 쓰지 않고, 가상 모듈 IDvirtual:entry를 구성하며, 내용은 re-export 문입니다:export { createApp } from '/absolute/path/to/vue.runtime.esm-bundler.js'.entry는 절대 경로입니다. Rollup이 이를 해석할 수 있어야 하기 때문입니다.
세 번째 단계: 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플러그인의 구성은 esm-bundler 산출물의 핵심 메커니즘을 드러냅니다:__VUE_OPTIONS_API__、__VUE_PROD_DEVTOOLS__과 같은 런타임 플래그를 유지하며, 사용자의 빌드 도구가 대체합니다. 여기서 스크립트가 사용자를 대신해 대체합니다:
process.env.NODE_ENV→"production": 프로덕션 분기로__VUE_PROD_DEVTOOLS__→'false': devtools 지원 비활성화__VUE_PROD_HYDRATION_MISMATCH_DETAILS__→'false': hydration 상세 오류 비활성화__VUE_OPTIONS_API__→'true': 기본적으로 Options API 유지
그런 다음...preset.replace을 확장하여 preset이 기본값을 덮어쓸 수 있게 합니다.createApp (CAPI only)preset이 바로 이 메커니즘을 사용하여__VUE_OPTIONS_API__을'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({})이 코드를 생성하고,output[0].code을 가져옵니다. 그런 다음 SWC로 압축:
📎 scripts/usage-size.js:125-130
module: true은 입력이 ESM임을 나타내고,toplevel: true은 최상위 스코프 변수 이름 압축을 허용합니다. 압축 후 세 가지 지표를 각각 계산합니다:minified.length(바이트 길이),gzipSync(minified).length、brotliCompressSync(minified).length。
여기서는node:zlib의 동기 API를 사용하며, 비동기 버전이 아닙니다. 일회성 스크립트에서는 동기 API가 더 간결하고, 압축 자체가 CPU 집약적 작업이므로 비동기가 병렬 이점을 가져오지 않습니다.
다섯 번째 단계: 출력 및 지속화.
📎 scripts/usage-size.js:62-86
결과는 먼저 사람이 읽을 수 있는 형식으로 콘솔에 출력되며,pico로 색상📎 scripts/usage-size.js:62-86을 입힙니다. 그런 다음temp/size/_usages.json에 쓰고,Object.fromEntries로 배열을 다시 Record로 변환하며, 키는 preset 이름📎 scripts/usage-size.js:81-85。
--write플래그는 각 preset의 비압축 bundle을 디스크에 추가로 쓸지 여부를 제어📎 scripts/usage-size.js:136-138, 디버깅용.
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"]설계 고찰 및 함정
왜 임시 파일 대신 가상 모듈을 사용하는가?임시 파일은 경로 처리, 정리, 동시 쓰기 충돌을 다뤄야 합니다. 가상 모듈은 진입 내용을 메모리에 유지하며, Rollup의resolveId/load훅이 자연스럽게 이 패턴을 지원합니다. 대가는 ID를 정확히 일치시켜야 한다는 것이며, 철자 오류가 있으면 Rollup이 "진입점을 해석할 수 없음"을 보고합니다.
replace의preventAssignment함정.만약preventAssignment: true,replace을 설정하지 않으면, 플러그인이process.env.NODE_ENV = 'x'과 같은 할당문도 대체하여"production" = 'x'구문 오류를 발생시킵니다. Vue 소스 코드에는 실제로process.env.NODE_ENV에 대한 할당이 존재하므로(테스트 도구에서), 이 옵션은 필수입니다.
__VUE_OPTIONS_API__의 기본값 선택.스크립트는 기본값을'true' 📎 scripts/usage-size.js:116으로 설정하며,'false'이 아닙니다. 이는 보수적인 선택입니다: 사용자가 구성하지 않으면, Vue는 Options API 지원을 유지합니다.createApp (CAPI only)preset은 명시적으로'false'으로 덮어쓰며, 비활성화 후의 부피 이득을 보여줍니다. 이 비교 자체가 사용자에게 주는 문서입니다: "Options API를 끄면 얼마나 절약되는지"를 알려줍니다.
병렬Promise.all의 실패 의미론.만약 어떤 preset의 번들링이 실패하면,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는 성공적으로 import할 수 있지만(합법적 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로 극도로 간소화된 디버깅 샌드박스를 어떻게 구축하여 "실제 소스 코드에서 최소 재현하기"를 일상적인 실천으로 만드는지 살펴봅니다.
제 12 장: 최소 디버깅 샌드박스: vite-debug와 로컬 개발 폐쇄 루프
이전 장에서 우리는 크기 예산의 측정 폐쇄 루프를 완성했습니다: size-report.js는 "얼마나 커졌는가"를 답하고, usage-size.js는 "어디가 큰가"를 답하며, 워크플로우 계층이 게이트 판정을 담당합니다. 하지만 이 메커니즘에는 암묵적 전제가 있습니다—빌드 산출물 자체가 재현 가능해야 한다는 것입니다. 특정 패키지의 크기가 비정상적으로 팽창했거나 특정 런타임 동작이 예상과 다를 때, 로컬 소스를 빠르게 로드하고 수정 후 즉시 결과를 확인할 수 있는 최소 환경이 필요합니다. packages-private/vite-debug가 바로 그 환경입니다. 파일이 단 네 개, 총 40줄 미만의 코드지만, Vue core 저장소에서 "실제 소스 코드에서 최소 재현하기"의 일상적 실천 진입점을 구성합니다. 이 장에서는 이 샌드박스의 구성 논리를 파일별로 분해하고, 왜 packages가 아닌 packages-private 디렉토리에 배치되었는지 설명합니다.
1. 샌드박스의 골격: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')이 여섯 줄의 코드는 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>세 개의 가상 모듈로 분해하여 각각 컴파일합니다. - 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')가 성공할 수 있는 전제입니다.
시나리오 기반 워크스루: 한 번의 클릭의 전체 체인
이제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를 세 부분으로 컴파일합니다:
<script setup>블록은 컴포넌트의setup()함수로 컴파일되고,ref(0)호출은RefImpl객체를 반환하며, 그.value는 초기에0。<template>블록은 렌더 함수로 컴파일되고,{{ count }}는_toDisplayString(count.value),@click="count++"로 변환되며,onClick: $event => (count.value++)。<style>는<style>블록은 CSS 모듈로 컴파일되어
태그를 통해 DOM에 주입됩니다.app.mount두 번째 단계: 첫 렌더링 (
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이 다이어그램의 핵심은:컴파일 타임 산출물과 런타임 동작 사이의 결합점은 단 두 개뿐이다——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 간접 계층을 하나 줄이는 것이 더 적은 변수를 의미한다.
---
2. 별칭 해석:vite.config.ts과package.json이 어떻게'vue'을 로컬 소스 코드로 가리키는가
직관적 모델
vite.config.ts은 단 여섯 줄이지만, 전체 샌드박스의 "라우팅 허브"이다——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이 npm registry의 버전이 아닌 monorepo의vue라는 로컬 패키지에 의존함을 나타낸다. 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을 통해 즉시 효과를 볼 수 있다.
시나리오 기반 Walkthrough: 한 번의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' } }을 작성하지 않는가? 이렇게 하면 직관적이지만 두 가지 문제가 있다:
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에 배포되는 것을 방지한다.
격리 메커니즘의 3중 보장
첫 번째 층: 디렉터리 격리
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()강제로 전체 페이지를 새로고침해야 한다.
또 다른 함정은:packages/runtime-core/src/아래의 소스를 수정할 때, HMR 전파 경로가 자동으로 트리거되지 않을 수 있다는 점이다——왜냐하면vite-debug의 HMR 경계는App.vue레벨에 정의되어 있고,packages/아래의 소스 변경은 Vite의 모듈 그래프를 통해 전파되어야 하기 때문이다. 소스를 수정한 후 브라우저가 반응하지 않으면 Vite 터미널 출력에hmr update로그가 있는지 확인하라. 없다면 dev server를 재시작해야 할 수도 있다.
---
이 장의 요약
packages-private/vite-debug은 네 개의 파일과 40줄 미만의 코드로 완전한 디버깅 폐루프를 구축했다:
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없음의 3중 격리로 디버그 코드가 실수로 배포되지 않도록 보장한다.
이 샌드박스의 엔지니어링 철학은:디버깅 환경 자체의 복잡도는 0에 가까워야 하며, 모든 복잡도는 디버깅 대상 소스에 남겨두어야 한다. 당신이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을 추가하지 않았다면, 이 샌드박스에서 두 개의 컴포넌트 인스턴스를 동시에 마운트하면 스타일은 어떻게 될까? 이것은vite-debug의 디버깅 목표와 어떤 관계가 있는가?
참고 해석:scoped이 없을 때,button { color: red }은 전역 스타일📎 packages-private/vite-debug/App.vue:4-8이 되어 페이지의 모든<button>요소에 적용된다. 두 개의 컴포넌트 인스턴스를 마운트하면 두 인스턴스의 버튼이 모두 빨간색이 된다. 디버깅 목표와의 관계는:vite-debug의 포지셔닝은 「최소 재현」이지 「스타일 격리 검증」이 아니다.scoped을 생략하면 컴파일 시data-v-xxx속성을 주입하는 변수가 줄어들어 디버거의 DOM 구조가 더 깨끗해진다. 만약scoped스타일의 컴파일 로직을 디버깅해야 한다면 명시적으로scoped을 추가하고@vitejs/plugin-vue이 생성하는 속성 주입 코드를 관찰해야 한다.
Q3: 당신이packages/runtime-core/src/renderer.ts의patch함수에console.log한 줄을 추가했지만 브라우저 콘솔에 출력이 없다고 가정하자. 최소 세 가지 가능한 원인을 나열하고, 각각 어떻게排查할지 설명하라.
참고 해석:
원인 1:소스 엔트리가 적용되지 않음。'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는 극도로 간결한 네 개의 파일로 "실제 소스 코드에서 빠르게 검증하기"를 일상적으로 사용 가능한 실천으로 만들었습니다. 하지만 이 체계를 실제로 복제하기 시작하면 더 많은 숨겨진 트레이드오프를 발견하게 됩니다——왜 packages-private는 반드시 packages와 물리적으로 격리되어야 하는가? 왜 열거형 인라인은 반드시 Rollup 이전에 완료되어야 하는가? 다음 장에서는 앞 열두 장에서 드러난 핵심 결정 지점과 프로덕션 함정 기록을 총정리하여, 완전한 함정 회피 체크리스트와 의사결정 근거를 제공합니다.
제 13 장: 아키텍처 트레이드오프와 함정 회피 가이드: monorepo 엔지니어링의 경계 조건
이전 장에서 우리는packages-private/vite-debug를切口로 삼아 실제 소스 코드에서 최소 재현을 하는 디버깅 패러다임을 익혔습니다. 이러한 내부 디버깅 패키지가 점점 많아지면 현실적인 문제가 떠오릅니다: 이들이 외부에 배포되는 정식 패키지와 같은 workspace에 공존하는데, 어떻게 배포 프로세스가 잘못 건드리지 않도록 보장할 수 있을까? 이번 장에서는 monorepo 엔지니어링의 경계 조건을 깊이 파고들어,packages와packages-private의 이중 디렉터리 계약에서 출발하여 아키텍처 트레이드오프 뒤의 방어적 설계를 분석하고, 실행 가능한 함정 회피 가이드를 제시합니다.
13.2 타이밍 철칙: 열거형 인라인은 반드시 Rollup 실행보다 먼저
직관적 모델
열거형 인라인은 마치 "포장하기 전에 부품의 라벨을 숫자로 바꾸는 것"과 같습니다. 만약 포장 작업자(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. 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을 얻습니다. transform 단계에서 소스 코드의 enum 참조를 리터럴로 교체하며;enumDefines은 replace의 보충으로서 크로스 모듈 상수 교체를 처리합니다📎 rollup.config.js:222-223。
5. 빌드 종료 시,finally블록이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 단계에서 즉석 스캔하여 사용하지 않는가? 열거형 인라인은크로스 패키지 전역 뷰:runtime-core가 필요하기 때문입니다. 참조된 enum은shared에 정의되어 있을 수 있으며, 단일 Rollup 프로세스는 자신의 패키지 소스 트리만 보기 때문에 크로스 패키지 교체를 완료할 수 없습니다.scanEnums()이 빌드 전에 전역 캐시를 구축하는 것은 바로 이 가시성 문제를 해결하기 위한 것입니다.
프로덕션 함정 포인트:removeCache()을finally에 두면 빌드 중간에 오류가 발생해도 정리된다는 의미입니다. 하지만 디버깅 시 수동으로 프로세스를 중단하면(Ctrl+C),finally이 실행되지 않을 수 있으며, 잔여 캐시 파일로 인해 다음 빌드에서 만료된 열거형을 읽게 됩니다. 점검 방법:temp/디렉터리에 잔여 enum 캐시 파일이 있는지 확인하고, 수동으로 삭제한 후 재시도하세요.
---
13.3 배포 오케스트레이터:release.js의 skip 플래그 비트 매트릭스
직관적 모델
release.js은 결혼식 총감독과 같고,skipBuild / skipTests / skipGit / skipPrompts네 개의 스위치는 "리허설 건너뛰기", "서약 건너뛰기", "사진 촬영 건너뛰기", "확인 건너뛰기" 버튼입니다. 각 버튼의 존재는 하나의 실제 시나리오에 대응합니다: CI 환경은skipPrompts이 필요하고, 로컬 디버깅은skipGit이 필요하며, 긴급 핫픽스는skipTests。
이 필요합니다
플래그 비트의 데이터 구조와 기본값parseArgs네 개의 skip 플래그는📎 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: 한 번의 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。
---
설계 사고: 세 가지 트레이드오프의 공통 패턴
이 장의 세 가지 핵심 트레이드오프를 돌아보면,它们은 동일한 설계 철학을 공유한다:"잊기 쉬운 런타임 검사"를 "우회 불가능한 구조적 제약"으로 전환한다。
packages-private물리적 격리: 스크립트 작성자가private필드를 기억해 검사하는 것에 의존하지 않고, 스캔 범위에서 자연스럽게 제외되도록 한다.- 열거형 인라인 사전 배치: Rollup 플러그인이 transform 시 "우연히" 크로스 패키지 enum을 볼 수 있는 것에 의존하지 않고, 빌드 전에 전역 캐시를 구축한다.
release.js의 skip 매트릭스: 발행자가 "CI가 통과했으면 로컬 테스트를 돌릴 필요 없다"는 것을 기억하는 것에 의존하지 않고, 스크립트가 자동으로 CI 상태를 조회하고skipTests。
이 패턴의 대가는스크립트 복잡도 상승:build.js이다.privatePackages목록을 유지해야 하고,rollup.config.js디렉터리 탐지 로직을 중복해야 하며,release.js네 가지 skip 플래그의 교차 조합을 처리해야 한다. 하지만 Vue처럼 매주 여러 번 발행하는 저장소에서는 구조적 제약이 가져오는 신뢰성 이익이 복잡도 비용을 훨씬 능가한다.
---
이 장 요약
이 장은 소스 코드에서 출발하여 Vue core 엔지니어링 체계의 세 가지 핵심 경계 조건을 분석했다:
1. packages-private과packages의 물리적 격리는 workspace glob,build.js디렉터리 탐지,release.js필터 세 곳이 공동으로 보장한다📎 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 발행, 로컬 디버깅, 긴급 핫픽스 세 가지 시나리오를 지원하며,skipTests의 동적 재작성과 발행 순서 정렬이 가장 간과되기 쉬운 두 가지 숨겨진 계약이다📎 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은 독립적인 디렉터리 탐지 로직을 가지고 있어, 두 곳을 반드시 동기화하여 수정해야 한다. 그렇지 않으면 "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()함수 내에서 호출된다. 만약 이两者的 실행 시점을 교환하면(즉inlineEnums()을 Rollup의buildStart훅에서 호출하게 하면), 무엇이 파괴되는가?
참고 해석:scanEnums()은 모든 Rollup 프로세스가 시작되기 전에 완료되어야 한다. 왜냐하면모든 패키지의 소스 코드를 스캔하여 전역 enum 캐시를 구축해야 하기 때문이다.inlineEnums()은rollup.config.js모듈 최상위에서 호출되며, 이때 Rollup은 아직 어떤 빌드도 시작하지 않았고 캐시는 이미 준비되어 있다. 만약buildStart에서 호출하도록 변경하면, 각 Rollup 프로세스가 독립적으로 스캔하게 된다—하지만buildAll은 동시 실행되므로(build.js:119-121), 여러 프로세스가 동시에 같은 파일들을 스캔하면 경쟁 상태가 발생한다: 프로세스 A가 프로세스 B가 아직 쓰기를 완료하지 않은 캐시 파일을 읽을 수 있어, enum 교체가 불완전해진다. 더 심각한 것은,scanEnums()이 반환하는removeCache클로저가 스캔 시의 파일 핸들 상태에 의존하므로, 동시성 시나리오에서 정리 시점을 조율할 수 없다.
이중 디렉터리 계약, 빌드 스크립트의 귀속 판정, 배포 스크립트의 2차 필터링—이러한 메커니즘들은 함께 monorepo 엔지니어링의 안전 경계를 규정한다. 그러나 경계는 불변하지 않는다: 빌드 도구가 Rollup에서 Rolldown으로 마이그레이션되고, 타입 테스트와 런타임 테스트가 융합되면서, 기존의 트레이드오프 전략도 새로운 도전에 직면하게 될 것이다. 다음 장에서는 3.0에서 3.4까지의 변경 궤적을 기반으로 차세대 엔지니어링 체계의 진화 방향을 전망한다.
제14장: 미래 진화: 3.x에서 차세대 엔지니어링 체계로
이전 장에서 우리는 Vue core 엔지니어링 체계의 「안전 경계」—이중 디렉터리 계약, 빌드 스크립트 귀속 판정, 배포 스크립트 2차 필터링—을 정리했다. 이러한 메커니즘들은 일회성 설계가 아니라 3.0에서 3.4까지의 반복 속에서 거듭 다듬어진 것이다. 이번 장에서는 다른 시각으로 전환한다: 「지금 어떤 모습인가」가 아니라 「어떻게 지금의 모습이 되었는가」를 보고, 이를 바탕으로 차세대 엔지니어링 체계가 어디로 향할지 추론한다. 이번 장의 소스 자료는 changelogs/CHANGELOG-3.3.md, changelogs/CHANGELOG-3.4.md 그리고 저장소 루트의 package.json이다. 변경 로그는 단순히 「무엇을 수정했는가」의 기록처럼 보이지만, 그것은 엔지니어링 체계의 가장 진실한 건강 검진 보고서다: build: 접두사의 모든 커밋, types: 접두사의 모든 변경, 의존성 버전의 모든 롤백이 현재 아키텍처의 응력점을 드러낸다. 우리가 해야 할 일은 이러한 응력점에서 진화 방향을 읽어내는 것이다. 변경 로그를 「기능 목록」이 아니라 「엔지니어링 체계의 관측 창」으로 취급하는 것이 이번 장의 핵심 방법론이다. 기능 변경은 Vue가 무엇을 할 수 있는지 알려주고, 빌드·타입·CI 관련 변경은 Vue의 엔지니어링 체계가 「어디가 아픈지」를 알려준다.
1. 빌드 도구 체인의 응력점: 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은 「2단계」 방식이다: 먼저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의 마이그레이션 가치는: 「패키지마다 하나의 프로세스」라는 동시성 모델을 「단일 프로세스 내 병렬」 모델로 바꾸는 것이다,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의.d.ts는 양날의 검이다. 타입 검사를 건너뛰면 emit이 빨라지지만, 이는pnpm check(tsc --incremental --noEmit생성 단계에서 타입 오류를 발견하지 못한다는 뜻이다——타입 오류는 오직test-dts)와--noCheck로만 뒷받침할 수 있다. 만약 Rolldown 마이그레이션 후 이 두 단계를 합치고 싶다면, 타입 검사가 빌드를 느리게 만들지 않도록 보장해야 한다, 그렇지 않으면
---
의 초심에 어긋난다.
二、타입 테스트와 런타임 테스트의 융합 추세
직관 모델.d.ts타입 테스트와 런타임 테스트를 두 개의 독립적인 품질 검사 관문으로 상상해보자: 하나는 「설명서()가 제대로 쓰였는지」를 검사하고, 하나는 「기계(런타임)가 제대로 돌아가는지」를 검사한다. 두 관문은 각각 독립적인 작업대, 독립적인 도구, 독립적인 보고서를 가진다. 융합 추세의 의미는:
동일한 테스트 케이스로 설명서와 기계를 동시에 검증할 수 있는가?융합이 없다면, 시스템이 직면하는 재앙은:.d.ts타입과 런타임 동작의 드리프트ref()이Ref<T>을 반환한다고 말하지만, 런타임에 실제로 반환되는 객체 형태가 바뀌어, 타입 테스트는 통과하고 런타임 테스트도 통과하지만, 둘을 조합하면 틀린 것이다.
데이터 구조: 테스트 스크립트의 편성 레이아웃
package.json의scripts안에서, 테스트 관련 항목은 명확히 두 그룹으로 나뉜다:
📎 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내부는 다시두 개의 독립적인tsc프로세스이다: 하나는dts-built-test를 실행하고(빌드 산출물 검증), 하나는dts-test를 실행한다(소스 타입 검증).
주목하라,test-unit는vitest --project unit*,test-e2e을 사용하고vitest --project e2e --project e2e-browser는--project을 사용한다. 이는 Vitest의메커니즘이 이미 테스트를 「단위/엔드투엔드/브라우저」로 서로 다른 project로 나누었음을 보여준다.융합의 물리적 기반은 이미 존재한다
: Vitest의 project 메커니즘은 동일한 runner 안에서 서로 다른 유형의 테스트를 실행할 수 있게 한다.types:시나리오 기반: 한 번의
커밋의 전체 경로types:변경 로그에서
접두사의 항목 밀도가 매우 높다, 이는 타입 시스템 복잡도의 직접적 반영이다. 우리는 전형적인 타입 수정 하나를 추적한다.
📎 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))복사
📎 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 롤백까지, 중간에 패치 버전 하나만 지났다. 이 「병합-롤백」의 빠른 순환은 타입 테스트의 근본적 딜레마를 드러낸다:。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의expectTypeOf호출을 Vitest의 커스텀 project로 캡슐화하여, 타입 단언을vitest형태로 테스트 파일에 인라인시키는 것이다. 이렇게 하면 한 번의tsc호출로 런타임 단언과 타입 단언을 동시에 실행하고, 보고서를 통일할 수 있다. 하지만 저항은:
의 타입 검사는 「전량」이고, Vitest의 테스트는 「파일별」이어서, 둘의 증분 전략이 호환되지 않는다.
설계 고민과 함정dts-built-test왜dts-test?는dts-built-test로부터 독립적이어야 하는가?이전 장에서 이미 논의했으니, 여기서는 진화 관점에서 보충한다:(rollup-plugin-dts이 검증하는 것은.d.ts),dts-test빌드 산출물이다패키징된
📎 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))소스 타입dts-built-test이다. 만약 융합 시 둘을 합치면, 「빌드 산출물이 소스 타입과 일치하는가」라는 핵심 검사점을 잃게 된다. 3.4.38의 이 커밋이 바로 빌드 산출물 타입의 중요성을 입증한다:.d.ts복사
「DOM lib이 누락되었을 때 fallback stub 제공」——이것은 빌드 산출물 수준의 타입 호환성 수정으로, 오직과 같은 「패키징된을 소비하는」 시나리오에서만 발견될 수 있다.프로덕션 함정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를 사용하여tsc파일을 생성하는데, 이것은 타입 검사의 증분 캐시다. CI에서 이 파일을 캐시하면
의 두 번째 실행이 훨씬 빨라진다.
시나리오 기반: 한 번의 PR CI 실행 흐름packages/reactivity/src/ref.ts전형적인 시나리오를 대입해 보자: 개발자가
를 수정하고 PR을 제출했다. CI는 어떤 단계를 실행해야 하고, 어떤 것이 캐시를 적중시킬 수 있을까?scripts부터simple-git-hooks에서 CI의 실행 시퀀스를 추론할 수 있다(pre-commit의
📎 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과lint、check、test-unit、test-dts、size를 실행한다. CI에서는
lint등을 실행한다. 각 단계의 캐시 전략은 다르다:.eslintcache: 캐시check, 키는 소스 파일 해시 기반..tsbuildinfo: 캐시tsconfig, 키는test-unit과 소스 해시 기반.test-dts: Vitest는 자체 캐시가 있지만, 일반적으로 CI에서는 테스트 결과를 캐시하지 않고 의존성만 캐시한다.build-dts: 의존성packages/*/dist의 산출물, 캐시 키는size의 해시 기반.
: 빌드 산출물에 의존, 캐시 키는 위와 동일.
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 通过"]〔설계 추론과 아키텍처 트레이드오프〕세밀한 캐시의 핵심 모순은캐시 키의 세밀도packages/*: 키가 너무 굵으면(예: commit hash만 기반) 적중률이 낮고; 키가 너무 세밀하면(예: 각 파일의 해시 기반) 키 계산 오버헤드가 캐시 이익을 상쇄한다. Vue 같은 monorepo의 합리적인 전략은 '패키지별 샤딩'이다: 각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",size복사run-s "size-*"는size-를 사용하여 모든size접두사 하위 명령을 직렬로 실행한다. 이러한 '접두사 집계' 패턴은 각 볼륨 차원(global, esm-runtime, esm)이 독립적으로 캐시되고 독립적으로 실패할 수 있게 한다. 만약 하나의 큰 명령으로 합치면, 어느 한 차원이 초과해도 전체
가 실패하여 어느 차원의 문제인지 파악할 수 없다.프로덕션 함정 포인트: CI 캐시에서 가장 빠지기 쉬운 함정은캐시 오염clean——잘못된 산출물을 캐시하여 이후 빌드가 오염된 데이터를 기반으로 하게 된다.
📎 package.json:10
"clean": "rimraf --glob packages/*/dist temp .eslintcache",복사packages/*/dist주목할 점은 이것이packages-private/*/dist을 정리하고,packages-private을 정리하지 않는다는 것이다. 이는packages-private의 산출물이 일반적인 정리 범위에 포함되지 않음을 의미한다——만약 CI가clean의 산출물을 캐시했는데packages-private가 그것을 정리하지 않으면, '구버전 playground 산출물을 캐시한' 문제가 발생할 수 있다. 세밀한 캐시 설계 시 반드시
---
를 별도로 처리해야 한다.
설계 사고: 엔지니어링 체계를 제품의 생명주기로 보기세 섹션의 실마리를 연결하면 명확한 주선이 보인다:。
Vue의 엔지니어링 체계는 '사용 가능'에서 '사용하기 좋음'으로, '수동 오케스트레이션'에서 '선언적 구성'으로 나아가고 있다
빌드 툴체인의 마이그레이션(Rollup → Rolldown)은 '성능 주도' 진화다: 패키지 수가 일정 수준으로 증가하면 프로세스 수준 동시성의 오버헤드가 이익을 초과하므로, 더 가벼운 동시성 모델로 교체해야 한다.
타입 테스트의 융합은 '일관성 주도' 진화다: 타입 시그니처의 변경 빈도가 런타임 동작의 변경 빈도를 초과하면, 분리된 두 테스트 세트가 부담이 되므로 동일한 케이스를 공유해야 한다.
〔설계 추론과 아키텍처 트레이드오프〕이 세 진화선의 공통 제약은하위 호환성BREAKING CHANGES이다. Vue의 릴리스 전략(변경 로그의
---
단락에서 볼 수 있음)은 minor 버전에서 'type-only breaking change'를 허용하지만, 런타임 breaking change는 허용하지 않는다. 이는 엔지니어링 체계의 진화가 반드시 보장해야 함을 의미한다: 내부 툴체인이 어떻게 바뀌든, 산출물의 공개 API와 런타임 동작은 변하지 않아야 한다. 이것이 모든 진화 결정의 단단한 경계다.
이 장 요약package.json이 장은 변경 로그와
1. 에서 출발하여 Vue core 엔지니어링 체계의 세 가지 진화선을 정리했다::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 고정,clean세 가지 산출물 정리,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이 두 개의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 자체에서 멀리 끌어올려 이러한 경험이 더 넓은 엔지니어링 시나리오로 어떻게 이전될 수 있는지 논의한다.
어떤 복잡한 프로젝트든, 사실 좋은 책 한 권이면 읽어낼 수 있다
이 《Vue core 저장소 엔지니어링 해설: 소스 코드에서 배포까지의 전 링크 아키텍처》는 AiReadCode가 공식 오픈소스 저장소를 스캔하여 전자동으로 편찬한 것이다. 수십만 줄의 대형 오픈소스 명작이든, 기업 내부의 복잡한 엔지니어링이든, 당신은 원클릭으로 똑같이 명료한 전용 저작을 생성할 수 있다.