Capítulo 1: Cognição macro: a filosofia de design de engenharia do repositório core
Antes de começar a rastrear qualquer linha da implementação de reatividade ou do DOM virtual, precisamos primeiro entender o corpo de engenharia no qual esse código vive. Ao abrir o repositório Vue core, a primeira coisa que chama atenção não é a lógica central do framework, maspackage.jsonepnpm-workspace.yamlarquivos de configuração de engenharia como esses — eles não contêm nenhuma funcionalidade de tempo de execução, mas determinam se todo o framework pode ser corretamente construído, testado e publicado. Este capítulo responde exatamente a essa questão preliminar: o que é, afinal, o repositório core. Ele não é@vue/runtime-coreaquele pacote npm, mas sim o corpo de engenharia que abrigaruntime-core、reactivity、compiler-sfce mais de uma dezena de pacotes publicados publicamente, além de pacotes experimentais privados comosfc-playground、template-explorer. Entender a forma de organização desse corpo é o pré-requisito para todos os capítulos seguintes (build, tipos, publicação, orçamento de tamanho). Este capítulo se desenrola em três linhas principais: a estrutura de diretórios dupla do workspace, a restrição unificada de TypeScript e Rollup no nível raiz, e a filosofia de desacoplamento entre "repositório de código-fonte" e "artefatos de publicação".
I. Estrutura de diretórios dupla: o isolamento físico entre packages e packages-private
Modelo intuitivo
Imagine o repositório core como um prédio de P&D.packages/é a linha de produtos oficial, e o que é produzido ali deve receber uma marca e ser vendido no mercado;packages-private/é o laboratório interno, e as amostras dentro dele servem apenas para depuração e demonstração, nunca para envio externo. Ambos compartilham o mesmo conjunto de água e eletricidade (dependências, ferramentas de build), mas o sistema de controle de acesso (fluxo de publicação) os trata de forma diferente.
Sem essa camada de isolamento físico, um pacote playground usado para depuração interna poderia facilmente ser publicado por engano no npm — isso não é uma hipótese, mas um acidente clássico de monorepo.
Estrutura de dados e layout de memória
A fronteira do workspace é definida porpnpm-workspace.yaml. Ele tem apenas três linhas de declaração efetiva:
📎 pnpm-workspace.yaml:1-3
packages:
- 'packages/*'
- 'packages-private/*'Esses dois globs dizem ao pnpm:packages/epackages-private/cada subdiretório sob eles é um pacote independente. O pnpm criará links simbólicos para eles, fazendo com que@vue/runtime-coreao referenciar@vue/reactivityaponte diretamente para o diretório de código-fonte local, em vez de baixar do registry.
Logo em seguida, a seçãocatalog:é o mecanismo dediretório de versões de dependênciasdo 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.9Nopackage.jsonraiz, o correspondente escrito é"@babel/parser": "catalog:" 📎 package.json:65-65。catalog:é um placeholder, e o pnpm o substitui durante a instalação pela versão declarada na seção catalog. O ganho disso é:@babel/parsera versão depnpm-workspace.yamlé mantida em apenas um lugar,
, e todos os pacotes que a referenciam se alinham automaticamente, eliminando a deriva de versão do tipo "pacote A usa 7.28, pacote B usa 7.29".pnpm installWalkthrough orientado por cenário: o que acontece após um
Suponha que você executepnpm installna raiz do repositório. Colocando-se nesse cenário, rastreie passo a passo:
Primeiro passo: portão do preinstall.o pnpm, antes da instalação, dispara opackage.jsondopreinstallraiz:
📎 package.json:45-45
"preinstall": "npx only-allow pnpm"only-allow pnpmverifica se o gerenciador de pacotes atual é o pnpm; se não for, ele reporta erro e sai imediatamente. A existência dessa linha de script significa que: instalar o repositório core com npm ou yarn falhará. Por que é obrigatório travar no pnpm? Porque o repositório core depende dos links simbólicos de workspace e do mecanismo de catalog do pnpm, os workspaces do npm não suportam a sintaxecatalog:, e o modo PnP do yarn altera os caminhos de resolução de módulos, causando comportamento inconsistente decreateRequirenos scripts de build.
Segundo passo: resolver o workspace.o pnpm lêpnpm-workspace.yaml, escaneiapackages/*epackages-private/*, e cria um registro de pacote para cada diretório que contémpackage.json.
Terceiro passo: aplicar a substituição do catalog.nopackage.jsonraiz, todos oscatalog:Os espaços reservados são substituídos pelas versões reais do segmento catalog e, em seguida, a instalação é unificada.
Quarto passo: hook postinstall.Após a conclusão da instalação, é acionado:
📎 package.json:46-46
"postinstall": "simple-git-hooks"simple-git-hooksLê a raizpackage.jsonno camposimple-git-hooks, gravando os hooks do Git em.git/hooks/:
📎 package.json:48-51
"simple-git-hooks": {
"pre-commit": "pnpm lint-staged && pnpm check",
"commit-msg": "node scripts/verify-commit.js"
}pre-commitO hook executa lint-staged e verificação de tipos antes de cada commit,commit-msgO hook valida o formato da mensagem de commit (Vue usa conventional commits). Observe a simetria entrepreinstallepostinstall: o primeiro faz o controle de acesso (permitindo apenas pnpm), o segundo estabelece a defesa (instalando hooks do Git).
Reflexões de design e armadilhas
Por que usar dois globs em vez de umpackages*/?Listar explicitamente dois diretórios torna a semântica de "público" e "privado" visível no nível de configuração. Qualquer novo desenvolvedor que leiapnpm-workspace.yamlsaberá imediatamente que o repositório tem duas categorias de pacotes. Se fosse escrito comopackages*/, essa semântica ficaria oculta.
allowBuildse segurança da cadeia de suprimentos.Observe esta configuração:
📎 pnpm-workspace.yaml:15-21
allowBuilds:
'@parcel/watcher': true
'@swc/core': true
'esbuild': true
'puppeteer': true
'simple-git-hooks': true
'unrs-resolver': trueO pnpm proíbe por padrão que pacotes de dependência executem scripts de instalação (postinstall), pois esta é uma entrada comum para ataques à cadeia de suprimentos.allowBuildsé uma lista de permissões: apenas os pacotes listados podem executar scripts de build.@swc/core、esbuildprecisa baixar binários nativos específicos da plataforma,puppeteerprecisa baixar o Chromium,simple-git-hooksprecisa escrever hooks do Git — todos esses são comportamentos legítimos em tempo de build, portanto são explicitamente permitidos.
minimumReleaseAge: 1440O significado profundo de .Esta linha de configuração exige que versões recém-publicadas de dependências tenham "pelo menos 24 horas" (1440 minutos) antes de poderem ser instaladas:
📎 pnpm-workspace.yaml:33-33
minimumReleaseAge: 1440Este é um mecanismo de período de resfriamento para se defender contra envenenamento da cadeia de suprimentos do npm. Depois que um atacante sequestra um pacote e publica uma versão maliciosa, geralmente ela é descoberta e removida em poucas horas. Definir um período de resfriamento de 24 horas permite que o repositório core evite essa janela. JáminimumReleaseAgeExcludepermite abrir exceções para patches de segurança específicos:
📎 pnpm-workspace.yaml:36-38
minimumReleaseAgeExclude:
# Renovate security update: vitest@4.1.11
- vitest@4.1.11O comentário deixa claro que esta é uma atualização de segurança acionada pelo Renovate, que precisa entrar em vigor imediatamente, portanto isenta do período de resfriamento.
---
II. tsconfig raiz: restringir uniformemente as fronteiras de tipo de todos os subpacotes
Modelo intuitivo
Se cada subpacote mantivesse seu próprio tsconfig, surgiriam fissuras como "o pacote A usastrict: false, o pacote B usastrict: true". O tsconfig raiz é aconstituição: ele define as regras de tipo que todos os subpacotes devem seguir em conjunto; os subpacotes só podem adicionar sobre essa base, não podem violá-la.
Estrutura de dados e layout de memória
A raiztsconfig.jsondocompilerOptionsé a base de todo o sistema de tipos do repositório. Destacamos alguns campos-chave:
📎 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"]
}Interpretação item a item:
target: es2016: rebaixa a sintaxe de saída para ES2016. Isso ecoa otargetdo esbuild na configuração do Rollup (isServerRenderer || isCJSBuild ? 'es2019' : 'es2016'📎rollup.config.js:337-337)。moduleResolution: bundler: adota resolução de módulos no estilo bundler, permitindo omitir extensões e suportar o campoexports.strict: true: ativa todas as verificações estritas, incluindostrictNullChecks、noImplicitAnyetc.noUnusedLocals: true: variáveis locais não utilizadas geram erro diretamente. Esta regra tem significado prático em conjunto com Tree-shaking — variáveis não utilizadas costumam ser um sinal de código morto.isolatedModules: true: exige que cada arquivo possa ser transpilado independentemente. Este é o pré-requisito para ferramentas como esbuild/swc que "transpilam arquivo por arquivo, sem análise de tipos entre arquivos".isolatedDeclarations: true: exige que todas as exportações tenham tipo explicitamente anotado. Esta regra serve diretamente ao pipeline de geração de.d.ts— apenas com anotação explícita otscpode gerar arquivos de declaração rapidamente sem fazer inferência completa de tipos.composite: true: ativa os metadados de build incremental necessários para project references.
pathsO campo é oespelho na camada de tipos:@vue/*do workspace, mapeando para./packages/*/src, permitindo que o TypeScript resolva diretamente para o código-fonte em tempo de compilação, em vez de para o link simbólico emnode_modules. Isso complementa os links simbólicos em tempo de execução do pnpm — em tempo de execução depende-se do pnpm, em tempo de compilação depende-se dos paths.
Walkthrough orientado por cenário: uma verificação de tipos depnpm check
checkO script étsc --incremental --noEmit 📎 package.json:15-15. Colocando neste cenário:
Primeiro passo: ler o escopo de include.Oincludedo tsconfig determina quais arquivos participam da verificação:
📎 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"
]Observe quescripts/*erollup.*.jstambém estão no escopo de verificação. Isso significa que os próprios scripts de build também estão sujeitos a restrições de tipo —rollup.config.jso// @ts-check 📎 rollup.config.js:1-1no topo, combinado com anotações de tipo JSDoc, permite que este arquivo puramente JS também seja verificado pelotsc.
Segundo passo: aplicar a exclusão do exclude.
📎 tsconfig.json:40-40
"exclude": ["packages-private/sfc-playground/src/vue-dev-proxy*"]sfc-playgroundO arquivovue-dev-proxyem é excluído. Por quê? Arquivos desse tipo geralmente são código proxy gerado dinamicamente em tempo de execução, cuja forma de tipo é instável; incluí-los na verificação geraria ruído.
Terceiro passo: verificação incremental. --incrementalfaz com quetscarmazene em cache o resultado da verificação anterior em.tsbuildinfo, reexaminando apenas os arquivos alterados.--noEmitindica verificar sem emitir — verificação de tipos e geração de artefatos são dois pipelines independentes.
Reflexões de design e armadilhas
isolatedDeclarationsCusto e benefício de .Após ativar esta regra, qualquer exportação deve ter o tipo de retorno explicitamente anotado, por exemploexport function foo(): numberem vez deexport function foo() { return 1 }. Isso aumenta o custo de escrita, mas em troca traz um grande aumento na velocidade de geração de.d.ts—tscé possível produzir arquivos de declaração sem inferência entre arquivos. Isso ecoa obuild-dtsno scripttsc -p tsconfig.build.json --noCheckde--noCheck: como os tipos já estão explicitamente anotados, ao gerar arquivos de declaração pode-se até pular a verificação.
typesInjeção global do campo .
📎 tsconfig.json:21-21
"types": ["vitest/globals", "puppeteer", "node"]Esses três pacotes de tipos são injetados globalmente, o que significa que arquivos de teste podem usar diretamentedescribe、it、expectsem import, e testes e2e podem usar diretamente os tipos depuppeteer. Este é um trade-off entre conveniência e poluição — quanto mais tipos globais, maior o risco de conflitos de nomes, mas melhor a experiência de escrita do código de teste.
---
三、Configuração do Rollup: da buildOptions à fábrica unificada de artefatos multi-formato
Modelo intuitivo
A configuração do Rollup é aoficina de montagem finaldo repositório core. Ela não se importa com o que cada pacote faz especificamente, apenas com "quais formatos este pacote deve produzir, onde está o arquivo de entrada de cada formato, e quais dependências devem ser externalizadas". O campopackage.jsonembuildOptionsde cada subpacote é a nota de envio colada na encomenda, e a oficina de montagem final trabalha seguindo a nota.
Estrutura de dados e layout de memória
Logo na entrada do arquivo de configuração, estabelece-se o modelo de "construção por pacote":
📎 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)Decisões de design principais:TARGETA variável de ambiente especifica qual pacote construir. A configuração usafs.readdirSync('packages-private')para determinar se o pacote pertence ao diretório público ou privado, decidindo assimpkgBase. Esta é umasondagem de diretório em tempo de execução——não é necessário manter uma lista de "quais pacotes são privados", a própria estrutura de diretórios é a verdade.
buildOptionsé um campo personalizado nopackage.jsondo subpacote,packageOptions.filenamedetermina o prefixo do nome do arquivo de artefato,packageOptions.formatsdetermina o formato de construção padrão.
O mapeamento de formato para artefato é definido poroutputConfigs:
📎 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' },
}Sete formatos, cobrindo três cenários de consumo:esm-bundlerpara consumo por empacotadores como Vite/webpack,esm-browserpara consumo de ESM nativo do navegador,globalpara consumo pela tag<script>. Os com sufixo-runtimesão construções "somente runtime", abertas apenas para o pacotevueprincipal.
Walkthrough orientado a cenários: o fluxo completo de decisão de umapnpm build vueexecução
Assumindo a execução do cenárionode scripts/build.js vue.TARGET=vue, rastreando as decisões dentro decreateConfig:
Primeiro passo: determinar a lista de formatos.
📎 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]))Prioridade: linha de comandoFORMATS> subpacotebuildOptions.formats> padrão['esm-bundler', 'cjs']。PROD_ONLYSe a variável de ambiente for verdadeira, pula construções não-produção, mantendo apenas as configurações.prod.jsadicionadas posteriormente.
Segundo passo: calcular as flags de construção. createConfigInternamente, deriva-se um conjunto de flags booleanas a partir da string de formato:
📎 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.enableNonBrowserBranchesEssas flags são afonte única de verdadepara todas as decisões subsequentes: seleção de arquivo de entrada, substituição de define, determinação de external, montagem de plugins, tudo depende delas.
Terceiro passo: selecionar o arquivo de entrada.
📎 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`
}A entrada padrão ésrc/index.ts, construções somente runtime usamsrc/runtime.ts. O pacote compat (@vue/compat, ou seja, construção compatível com Vue 2) precisa fornecer exportações default e named simultaneamente, o que faria o Rollup reportar erro para alvos não-ESM, portanto usa-se uma entradaesm-index.ts / esm-runtime.tsseparada para construções ESM.
Quarto passo: gerar a tabela de substituição de define. resolveDefineSubstitui constantes de tempo de compilação como__DEV__、__BROWSER__no código-fonte por literais:
📎 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`,
}Há uma estratificação engenhosa aqui:as feature flags não são hardcoded nas construções esm-bundler, mas mantidas como identificadores como__VUE_OPTIONS_API__, deixadas para o empacotador do usuário final substituir. Assim o usuário pode desativar o suporte a Options API viadefine: { __VUE_OPTIONS_API__: false }, permitindo Tree-shake do código relacionado. Já nas construções global/esm-browser, essas flags são hardcoded comotrue/false, pois os artefatos consumidos diretamente pelo navegador não têm empacotador envolvido.
Quinto passo: permitir sobrescrita por variáveis de ambiente.
📎 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
}
})Qualquer chave define pode ser sobrescrita por uma variável de ambiente de mesmo nome. O exemplo dado no comentário é__RUNTIME_COMPILE__=true pnpm build runtime-core——usado para depurar um branch de compilação específico.
Sexto passo: montar a cadeia de plugins.
📎 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,
],A ordem dos plugins importa:jsonprimeiro processa importações JSON,aliasmapeia@vue/*para caminhos do código-fonte,enumPluginfaz inline de enums,replacefaz substituição de strings,esbuildfaz transpilação TS. Note que oesbuilddetsconfigaponta para o tsconfig raiz——todos os subpacotes compartilham a mesma configuração de tipos, o que é exatamente a manifestação em tempo de construção da "constituição" discutida na seção dois.
Sétimo passo: adição de construção de produção.SeNODE_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))
}
})
}O formato CJS adiciona uma versão.prod.js(substituindo por__DEV__=false), os formatos global e esm-browser adicionam uma versão minificada (minify com swc).packageOptions.prod === falsePacotes
podem optar por sair desse mecanismo.
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 --> doneCopiar
externalReflexões de design e armadilhas resolveExternalA estratégia de três ramos de
📎 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,
]
}
}CopiartreeShakenDepsConstruções de navegador (global/esm-browser) fazem inline de todas as dependências, listando apenasdependenciescomo external para suprimir avisos——essas dependências não são realmente referenciadas no branch de navegador, sendo removidas por Tree-shaking. Construções Node/esm-bundler externalizam todos ospeerDependenciese
onwarn, deixando o consumidor gerenciar as versões das dependências.
📎 rollup.config.js:344-348
onwarn: (msg, warn) => {
if (msg.code !== 'CIRCULAR_DEPENDENCY') {
warn(msg)
}
},Copiarruntime-coreAvisos de dependência circular são silenciados. Existe uma referência circular legítima entrereactivitye
treeshake.moduleSideEffects: falseno Vue (o sistema reativo precisa referenciar o tipo de instância do componente), esses ciclos são seguros em tempo de execução, portanto são filtrados.
📎 rollup.config.js:355-355
treeshake: {
moduleSideEffects: false,
},CopiarIsso diz ao Rollup: todos os módulos não têm efeitos colaterais, importações não referenciadas podem ser removidas com segurança. Esta é umasuposição agressiva
——se algum módulo executar código com efeitos colaterais no nível superior (como registrar variáveis globais), ele pode ser removido erroneamente. O código-fonte do Vue garante por convenção que todos os módulos são puros, portanto essa otimização pode ser ativada.pure_gettersA armadilha de
📎 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: trueCopiarobj.foodiz ao minificador que "acessos a propriedades não têm efeitos colaterais", podendo remover com segurança chamadas de getter não utilizadas. Isso é perigoso para o código reativo do Vue——track()) em vez de efeitos colaterais implícitos de getter, portanto é seguro.map: nullindica que nenhum sourcemap é gerado após a compressão — artefatos de produção não precisam de mapeamento de depuração.
---
Reflexão de design: por que o repositório de código-fonte e os artefatos de publicação devem ser desacoplados
Voltando à proposição central deste capítulo. O design de engenharia do repositório core tem uma linha condutora que permeia todo o processo:A responsabilidade do repositório de código-fonte é "produzir", a responsabilidade dos artefatos de publicação é "consumir", e ambos são desacoplados através do pipeline de build。
Isso se manifesta concretamente em três níveis:
Primeiro, o código-fonte não é publicado diretamente. package.jsonOprivate: true 📎 package.json:2-2indica que o pacote raiz nunca é publicado. Opackage.jsonde cada subpacotemain/module/exportscampo aponta paradist/os artefatos sob, e nãosrc/. Quando o usuário instalavue, ele recebe o.jse o.d.tsconstruídos, enquanto o código-fonte permanece no repositório.
Segundo, o formato dos artefatos é determinado pelo cenário de consumo.Os sete formatos não são uma listagem arbitrária, mas correspondem a sete caminhos reais de consumo: usuários do Vite recebemesm-bundler, usuários de CDN recebemglobal, usuários de Node SSR recebemcjs. A lógica de seleção de formato está centralizada emrollup.config.jsum único lugar, e os subpacotes só precisam declarar embuildOptions.formatsquais são necessários.
Terceiro, tipos e implementação são separados. build-dtsO scripttsc -p tsconfig.build.json --noCheck && rollup -c rollup.dts.config.js 📎 package.json:9-9indica que.d.tsa geração é um pipeline independente.isolatedDeclarations: truepermite que a geração de arquivos de declaração pule a verificação de tipos (--noCheck), porque os tipos já estão explicitamente anotados.
A motivação profunda desse desacoplamento é:A forma de organização do código-fonte serve ao desenvolvedor, a forma de organização dos artefatos serve ao consumidor, e as soluções ótimas de ambos são diferentes. O código-fonte precisa de uma estrutura de diretórios clara, informações completas de tipos, sourcemaps depuráveis; os artefatos precisam de volume mínimo, formato de módulo correto, superfície de API estável. Forçar a unificação de ambos (por exemplo, publicar diretamente o código-fonte TS) prejudicaria a experiência de ambos os lados.
---
Resumo do capítulo
Este capítulo estabeleceu uma compreensão macro do repositório core a partir de três dimensões:
1. Estrutura de diretórios dupla:packages/epackages-private/o isolamento físico, combinado com os symlinks do pnpm workspace e o catálogo de versões, realiza uma fronteira clara entre "pacotes públicos" e "pacotes privados".preinstallO gate deallowBuilds, a whitelist deminimumReleaseAge, e o período de resfriamento de
2. juntos formam a linha de defesa de segurança da cadeia de suprimentos.tsconfig de nível raizpaths: como a constituição de tipos de todos os subpacotes, através do mapeamento deisolatedDeclarationsrealiza a resolução de workspace em tempo de compilação, através decompositee
3. suporta build incremental e geração rápida de arquivos de declaração.Fábrica unificada RollupTARGET: tendo a variável de ambientebuildOptionscomo ponto de entrada, lê metainformações dos subpacotes através de
, e através de um conjunto de flags booleanas direciona a seleção de entrada, substituição de define, determinação de external e montagem de plugins, produzindo finalmente artefatos em sete formatos.A filosofia central éo desacoplamento entre repositório de código-fonte e artefatos de publicação
---
: o repositório é responsável pela produção, os artefatos são responsáveis pelo consumo, e o pipeline de build é a única ponte entre ambos.
Transição para o próximo capítuloscripts/build.jsEste capítulo respondeu "o que é o repositório core". Mas a estrutura estática do repositório é apenas o palco; o verdadeiro drama acontece durante a execução de uma requisição de build:
como analisar argumentos de linha de comando, como chamar a API do Rollup, como lidar com falhas de build e concorrência. O próximo capítulo rastreará a jornada ponta a ponta de uma requisição de build desde a entrada até o artefato, transformando a compreensão estática estabelecida neste capítulo em uma visão dinâmica de execução.
Reflexões e autoavaliação deste capítulopnpm-workspace.yamlQ1: Se emminimumReleaseAge: 1440o0fosse alterado paraminimumReleaseAgeExclude, que riscos seriam introduzidos no cenário de atualização de dependências? Por que a existência de
é necessária?:
minimumReleaseAge: 1440 📎 pnpm-workspace.yaml:33-33Análise de referência0exige que versões recém-publicadas de dependências só possam ser instaladas após 24 horas. Se fosse alterado para
, qualquer versão recém-publicada poderia ser imediatamente puxada.@babel/parserCenário de risco: um atacante compromete alguma dependência transitiva (por exemplo, alguma versão patch de
minimumReleaseAgeExclude 📎 pnpm-workspace.yaml:36-38), publicando uma versão com script postinstall malicioso. Durante o período de resfriamento de 24 horas, a comunidade geralmente descobre o problema e remove a versão; se o período de resfriamento fosse 0, o CI do repositório core poderia atualizar automaticamente e executar o script malicioso dentro da janela de ataque.vitest@4.1.11A existência de
Q2: rollup.config.jsse deve ao fato de que o mecanismo de período de resfriamento entra em conflito com a urgência de patches de segurança. OresolveDefineno comentário é uma atualização de segurança detectada pelo Renovate — esse tipo de atualização precisa entrar em vigor imediatamente, e esperar 24 horas na verdade prolonga a janela de exposição. Portanto, é necessária uma lista explícita de isenções para que atualizações de segurança contornem o período de resfriamento. Isso reflete o princípio de design de segurança "padrão conservador, exceções explícitas".__FEATURE_OPTIONS_API__EmisBundlerESMBuild ? '__VUE_OPTIONS_API__' : 'true', o tratamento de'true'para
é:
📎 rollup.config.js:192-194
__FEATURE_OPTIONS_API__: isBundlerESMBuild
? `__VUE_OPTIONS_API__`
: `true`,para todos os formatos, que impacto isso teria no usuário final?__FEATURE_OPTIONS_API__Análise de referência__VUE_OPTIONS_API__Copiardefine: { __VUE_OPTIONS_API__: false }No build esm-bundler,data、methods、computedé mantido como o identificador
, deixado para o bundler do usuário final substituir. O usuário pode definir'true'em sua própria configuração de build, permitindo que o Tree-shaking remova todo o código relacionado à Options API (a lógica de tratamento de opções comodefine), reduzindo significativamente o volume do artefato.
Se fosse alterado para retornarpara todos os formatos, o código da Options API no artefato esm-bundler seria mantido de forma hardcoded, a configuraçãodo usuário deixaria de funcionar, e não seria possível fazer Tree-shake. Para um projeto que usa apenas Composition API, isso adicionaria desnecessariamente vários KB ao volume do artefato.
Q3: rollup.config.jsderesolveExternal, a construção do navegador retorna apenastreeShakenDepscomo external, enquanto a construção Node retorna todos osdependencies. Suponha que um dia alguém adicione uma nova dependência de runtimeruntime-core, mas esqueça de atualizarfoo-liba lógica deresolveExternal. O que acontecerá na construção do navegador?
Análise de referência:
📎 rollup.config.js:257-283
A construção do navegador (isGlobalBuild || isBrowserESMBuild) em!packageOptions.enableNonBrowserBranchesretorna apenastreeShakenDeps(source-map-js、@babel/parser、estree-walker、entities/decode). Isso significa quefoo-libnão está na lista de external,
Até aqui, já vimos em nível macro a filosofia de design geral do repositório core como matriz de engenharia: a estrutura de workspace com dois diretórios delimita a fronteira entre pacotes públicos e pacotes experimentais privados, as configurações TypeScript e Rollup no nível raiz fornecem restrições unificadas, e o desacoplamento entre o repositório de código-fonte e os artefatos de publicação torna possível a saída em múltiplos formatos. Essas percepções abrem caminho para o aprofundamento posterior nos elos concretos de engenharia. No próximo capítulo, desviaremos o olhar da estrutura estática para o fluxo dinâmico, tomandonode scripts/build.js vuecomo ponto de partida, rastreando a jornada ponta a ponta de uma requisição completa de build, desde a análise de argumentos de linha de comando, localização do pacote-alvo, geração da configuração Rollup até a gravação dos artefatos em disco, para ver como build.js analisa flags como formats/devOnly/release via parseArgs, como faz require dinâmico do package.json do pacote-alvo e lê buildOptions, e finalmente impulsiona rollup.config.js a produzir artefatos em múltiplos formatos como esm-bundler, cjs e global.
Capítulo 2: Ciclo de vida do tronco principal: a jornada ponta a ponta de uma requisição de build
No capítulo anterior, esclarecemos a posição do repositório core como matriz de engenharia e como o pnpm workspace e as configurações no nível raiz restringem uniformemente todos os subpacotes. Agora, vamos nos aprofundar no núcleo do sistema de build e rastrear como um comando impulsiona todo o fluxo de build.node scripts/build.js vueparece simples, mas é a única entrada para todos os artefatos — esm-bundler, cjs, global. Entender como ele traduz a intenção do usuário em tarefas de build executáveis é um passo fundamental para dominar o mecanismo de build do Vue.
Geração da configuração Rollup: de variáveis de ambiente a artefatos em múltiplos formatos
build.jsviaexecinicia o Rollup, o controle passa pararollup.config.js. Este arquivo é o "cérebro" do sistema de build — ele lê variáveis de ambiente e gera dinamicamente um array de objetos de configuração Rollup.
Validação de variáveis de ambiente e localização de pacotes
📎 rollup.config.js:27-29
SeTARGETnão estiver definido, lança erro diretamente. Isso é programação defensiva: a configuração Rollup pode ser chamada diretamente (comorollup -c), e nesse momento não hábuild.jsinjetando variáveis de ambiente, então é preciso falhar rapidamente.
📎 rollup.config.js:32-44
Aqui se repete a lógica de determinação de pacote privado embuild.js— porquerollup.config.jsé um processo independente e não pode compartilhar o estado em memória debuild.js.resolveA função resolve caminhos relativos para caminhos absolutos dentro do diretório do pacote,pkgé o conteúdo depackage.jsondo pacote-alvo,packageOptionsé o campobuildOptionsdentro dele,nameé o prefixo do nome do arquivo de artefato (priorizabuildOptions.filename, caso contrário usa o nome do diretório).
Tabela de mapeamento de formatos:outputConfigs
📎 rollup.config.js:58-88
Esta tabela define o mapeamento de 7 formatos para configurações de saída. Observações-chave:
esm-bundler、esm-browser、esm-bundler-runtime、esm-browser-runtimesão todosformat: 'es', a diferença está apenas no nome do arquivo.cjséformat: 'cjs'。globaleglobal-runtimeéformat: 'iife'(expressão de função imediatamente invocada), adequado para introdução direta via tag<script>.runtimeFormatos com sufixo só fazem sentido para o pacote principalvue— eles não incluem o compilador e têm tamanho menor.
Seleção de formato: três níveis de prioridade
📎 rollup.config.js:91-92
A seleção de formato segue três níveis de prioridade: linha de comandoFORMATSvariável de ambiente > do pacotebuildOptions.formats> padrão['esm-bundler', 'cjs']。PROD_ONLYA variável de ambiente controla se a configuração base é ignorada — se apenas a versão de produção for construída, o array de configuração base fica vazio e, em seguida, apenas a configuração de produção é adicionada.
Lógica de adição da configuração de produção
📎 rollup.config.js:97-114
QuandoNODE_ENV === 'production', para cada formato:
- Se
packageOptions.prod === false, pula (o pacote não precisa de versão de produção). - Se for
cjs, adicionacreateProductionConfig— gera o arquivo.prod.js. - Se corresponder a
/^(global|esm-browser)(-runtime)?/, adicionacreateMinifiedConfig— gera a versão minificada.
Por quecjsusacreateProductionConfigenquantoglobal/esm-browserusacreateMinifiedConfig? Porque CJS é para Node, e o ambiente Node não precisa de minificação (o usuário cuidará disso), mas precisa distinguir os ramos dev/prod; já os artefatos introduzidos diretamente no navegador precisam ser minificados para reduzir tamanho. Essa diferença se reflete na implementação das duas funções de fábrica.
createConfig: o núcleo da geração de configuração
createConfigé a maior função; ela recebe formato e configuração de saída e retorna o objeto completo de configuração Rollup.
📎 rollup.config.js:125-142
No início há uma série de cálculos de flags booleanas:
isProductionBuild: determinado via__DEV__variável de ambiente ou se o nome do arquivo contém.prod.js.isBundlerESMBuild、isBrowserESMBuild、isCJSBuild、isGlobalBuild: correspondência por regex no nome do formato.isServerRenderer: se o nome do pacote éserver-renderer。isCompatPackage、isCompatBuild: relacionado à construção compatível com Vue 2.isBrowserBuild: construção global ou construção ESM para navegador, e sem habilitar o ramo não-navegador.
Essas flags são usadas repetidamente noresolveDefine、resolveReplace、resolveExternalsubsequente e são a base central para a diferenciação das configurações.
📎 rollup.config.js:144-157
Configurações básicas de saída: cabeçalho de copyright no banner, modoexports(pacotes compat usamauto, os demais usamnamed), construção CJS habilita interoperabilidadeesModule, sourcemap controlado por variável de ambiente,externalLiveBindings: falseereexportProtoFromExternal: falsesão configurações de compatibilidade do Rollup 4. A construção global define adicionalmenteoutput.name, ou seja, o nome da variável montada emwindow.
Seleção do arquivo de entrada
📎 rollup.config.js:159-168
A entrada padrão ésrc/index.ts, mas formatos com sufixoruntimeusamsrc/runtime.ts。A build ESM do pacote compat precisa exportar tanto default quanto named, então usa uma entradaesm-index.ts / esm-runtime.tsseparada.
Definições de macro:resolveDefine
📎 rollup.config.js:170-218
resolveDefineRetorna uma tabela de substituição, substituindo no código-fonte__COMMIT__、__VERSION__、__BROWSER__e outras macros por literais. Essas macros são usadas no código-fonte para compilação condicional — por exemploif (__DEV__) { ... }em builds de produção é substituído porif (false) { ... }, e então removido pelo Tree-shaking.
Design principal:__FEATURE_OPTIONS_API__、__FEATURE_PROD_DEVTOOLS__e outros feature flags são mantidos em buildsesm-bundlercomo identificadores__VUE_OPTIONS_API__, permitindo que usuários finais os sobrescrevam via configuração do bundler; enquanto em outros builds são codificados diretamente comotrueoufalse。
📎 rollup.config.js:203-206
builds nãoesm-bundlercodificam diretamente__DEV__, porque seus ramos dev/prod já são determinados em tempo de build.
📎 rollup.config.js:210-216
A última etapa permite que variáveis de ambiente sobrescrevam qualquer definição de macro, suportando__RUNTIME_COMPILE__=true pnpm build runtime-coresobrescritas inline como essa.
Plugin de substituição:resolveReplace
📎 rollup.config.js:222-255
resolveReplaceProcessa fora doresolveDefinesubstituições que o esbuild não consegue processar:
- Mescla
enumDefines(definições de inline de enum provenientes deinlineEnums). - Em builds de produção para navegador, adiciona anotação
/*@__PURE__*/às funções de criação de erro, auxiliando o Tree-shaking. esm-bundlerEm builds__DEV__, substitui!!(process.env.NODE_ENV !== 'production')por- , deixando o bundler decidir.
process.envEm builds ESM para navegador, substitui
por um objeto vazio, evitando erros no navegador.resolveExternal
📎 rollup.config.js:257-283
Dependências externas:treeShakenDepsEste é o núcleo da questão de reflexão no final do capítulo anterior. O build para navegador retorna apenasdependenciescomo external — essas dependências, embora importadas, não serão realmente executadas no ramo do navegador; são listadas aqui apenas para suprimir avisos do Rollup. Os builds Node/ESM-bundler externalizam todospeerDependenciesepath、url、stream, bem como módulos internos do Node como
.
📎 rollup.config.js:319-352
Objeto de configuração final
inputO objeto de configuração retornado contém:external: caminho absoluto do arquivo de entrada.plugins: lista de dependências externas.output: array de plugins, na ordem json → alias → enumPlugin → replace → esbuild → nodePlugins.onwarn: configuração de saída.CIRCULAR_DEPENDENCY: filtra avisostreeshake.moduleSideEffects: false(existem dependências circulares no código-fonte do Vue, mas são inofensivas em tempo de execução).
: informa ao Rollup que todos os módulos não têm efeitos colaterais, Tree-shaking agressivo.
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 产物落盘"]Copiar
execGravação de artefatos em disco e verificação de tamanho
build.jsGerenciamento de processos deexecInicia o subprocesso do Rollup através de
📎 scripts/utils.js:64-114
exec:spawnencapsula
stdio, retornando uma Promise. Design principal:['ignore', 'pipe', 'pipe']o padrão éshell: process.platform === 'win32'— stdin ignorado, stdout/stderr capturados por pipe.- — no Windows é necessário shell para analisar corretamente o comando.
stderrChunksColeta a saída através dos arraysstdoutChunkseexit, concatenando no evento - .
〔Inferência de design e trade-offs arquiteturais〕build.jsNote queexecao chamar{ stdio: 'inherit' }passa
, o que sobrescreve a configuração padrão de pipe, fazendo a saída do Rollup ser transmitida diretamente ao terminal. Este é o comportamento correto de uma ferramenta de build — o usuário precisa ver o progresso do build em tempo real.checkAllSizes
📎 scripts/build.js:206-215
Verificação de tamanho:devOnlyA verificação de tamanho tem duas condições de skip:globalé verdadeiro, ou um formato foi especificado mas não contém
📎 scripts/build.js:222-228
checkSize. Porque a verificação de tamanho é apenas para artefatos de build global — esses são os arquivos que o usuário final importa diretamente, e o tamanho é mais sensível.${target}.global.prod.jsVerifica dois arquivos:${target}.runtime.global.prod.jseglobal-runtime(o último só é verificado quando nenhum formato é especificado ou
📎 scripts/build.js:235-264
checkFileSizeé especificado).gzipSyncLê o arquivo, calcula o tamanho comprimido combrotliCompressSynceprettyBytes, formata a saída comwriteSize. Setemp/size/${fileName}.jsonfor verdadeiro, grava o resultado em
— esta é a fonte de dados para a verificação de orçamento de tamanho no CI.
📎 scripts/build.js:94-108
Construção de declarações de tipobuildTypesSepnpm run build-dtsfor verdadeiro, chama--environment TARGETS:..., passando a lista de alvos através de
. Isso garante que declarações de tipo sejam geradas apenas para os pacotes realmente construídos.
Reflexões de design e armadilhas em produção--environmentPor que usarem vez de passar parâmetros diretamente?--environmentOprocess.envdo Rollup é a única forma de passar parâmetros que pode ser lida no arquivo de configuração através de--config. Passar diretamente o parâmetroprocess.argvrequer analisar--environment, enquanto
fuzzyMatchTargetfornece análise estruturada de pares chave-valor. target.match(partialTarget)A armadilha de regex empartialTarget.runtime-core,-Emruntime.core,., o
é entrada do usuário. Se o usuário inserir runParallel, é literal na regex, sem problema; mas se inserircpus().length, corresponderá a qualquer caractere, podendo corresponder a alvos inesperados. Este é o risco inerente da correspondência difusa, mas os nomes de pacotes do Vue não contêm caracteres especiais de regex, então na prática não é acionado.--max-old-space-sizeCompetição de recursos em builds concorrentes.
scanEnumsUsa removeCachecomo limite de concorrência, mas cada processo Rollup em si também inicia workers. Em contêineres de CI com poucos núcleos, isso pode causar estouro de memória. Em produção, se ocorrer OOM, pode ser mitigado através definallyou reduzindo a concorrência.scanEnumsCiclo de vida do cache deremoveCache.finallyÉ chamado emscanEnums, mas setryem si lançar erro,
resolveExternalnão será atribuído, e a chamada emfalhará. Na prática, a função retornada porruntime-corejá está determinada antes deresolveExternal, então esse risco não existe — mas este é um detalhe de temporização que precisa ser confirmado durante a leitura.
Risco de omissão em
.node scripts/build.js vueA questão de reflexão do capítulo anterior já apontou: se adicionar uma nova dependência a
1. parseArgsmas esquecer de atualizarcommit, o build para navegador incluirá essa dependência no bundle (porque não está na lista external), causando aumento de tamanho. Este é o custo inerente da estratégia de "whitelist external".
2. run()Resumo do capítuloscanEnumsA jornada completa de umfuzzyMatchTarget:allTargets)。
3. buildAllanalisa a linha de comando,runParallelobtido sincronamente.build。
4. buildChamapackage.jsonpara gerar o cache de enum, analisa os alvos (distou--environmentatravés deexecIniciar o Rollup.
5. rollup.config.jsLer as variáveis de ambiente, através decreateConfigGerar o array de configuração,resolveDefine/resolveReplace/resolveExternalProcessar separadamente macros, substituições e dependências externas.
6. O Rollup executa a build, os artefatos são gravados em disco emdist/。
7. checkAllSizesCalcular o tamanho gzip/brotli, opcionalmente escrever emtemp/size/。
8. Se--withTypes, chamarbuild-dtsGerar as declarações de tipo.
Reflexões e autoavaliação deste capítulo
Q1: Embuild.jsdabuildfunçãoif (!formats && fs.existsSync(...))esta condição determina se deve excluirdistdiretório. Se remover!formatsesta condição (ou seja, excluir independentemente do formato especificadodist), empnpm build-all-cjsum script como este, o que aconteceria?
Análise de referência:
📎 scripts/build.js:172-175
pnpm build-all-cjsCorresponde anode scripts/build.js vue runtime compiler reactivity shared -af cjs(ver📎 package.json:40). Ele especifica-f cjs, portantoformatsé'cjs',!formatsé falso, a lógica atual não excluirádist。
Se remover!formats, cada build irá excluirdist. Masbuild-all-cjsapenas constróicjsformato, após a exclusãodistrestará apenascjsartefatos, os anteriormente construídosesm-bundler、globale outros formatos serão todos perdidos. Mais grave ainda,build-runtime-esm、build-browser-esme outros scripts serão executados em sequência (ver📎 package.json:39dobuild-sfc-playgroundscript), cada script irá excluir os artefatos do script anterior, resultando emdistcontendo apenas o formato do último script. Isso quebraria a build do SFC Playground — ele precisa que múltiplos formatos de artefatos existam simultaneamente.
Q2: runParallelEmif (maxConcurrency <= source.length)qual é a função desta condição? Se removê-la, ao construir um único pacote (targets.length === 1) o que aconteceria?
Análise de referência:
📎 scripts/build.js:131-151
Esta condição controla se o limitador de concorrência é habilitado. QuandomaxConcurrency > source.length, não é necessário limitar — todas as tarefas podem iniciar simultaneamente. Se remover esta condição, mesmo com apenas uma tarefa, será criadoexecutingarray e executadoawait Promise.race(executing)。
Para uma única tarefa,executinghá apenas uma Promisee,Promise.raceque aguardará sua conclusão. Isso não causaria erro, mas introduziria cadeias de Promise e overhead de agendamento de microtarefas desnecessários. Mais importante,executing.splice(executing.indexOf(e), 1)ainda funciona corretamente no cenário de tarefa única, então funcionalmente não há diferença, apenas uma pequena perda de desempenho.
O risco real está em: semaxConcurrencyfor 0 (teoricamente impossível, poiscpus().lengthé no mínimo 1),executing.length >= 0seria sempre verdadeiro,Promise.race([])ficaria suspenso para sempre. Mascpus().lengthgarante que este limite não será acionado.
Q3: resolveExternalEmtreeShakenDeps, a build do navegador retorna
como external, mas essas dependências não serão realmente executadas no branch do navegador. Se removê-las da lista external (ou seja, deixar o Rollup tentar empacotá-las), o que aconteceria?:
📎 rollup.config.js:257-283
treeShakenDepsAnálise de referênciasource-map-js、@babel/parser、estree-walker、entities/decodecontémcompiler-sfc. Estas são__BROWSER__dependências de pacotes como
, excluídas por compilação condicional através da macrotreeshake.moduleSideEffects: false(📎 rollup.config.js:355-355na build do navegador.if (!__BROWSER__)Se removidas do external, o Rollup tentaria resolver e empacotar essas dependências. Como__BROWSER__), e as instruções de importação dessas dependências estão localizadas emtruebranch, o define do esbuild substituiria
poronwarn, fazendo o branch ser marcado como código morto. O Tree-shaking do Rollup removeria essas importações, e o artefato final não conteria o código dessas dependências.
Mas o problema é: o Rollup precisa resolver os módulos antes do Tree-shaking. Se essas dependências não estiverem instaladas (por exemplo, em um ambiente CI enxuto), o Rollup reportaria um erro de "não foi possível resolver o módulo". Listá-las como external é uma medida defensiva — mesmo que as dependências não existam, o Rollup não tentará resolvê-las, apenas emitirá um aviso (escripts/dev.jsfiltraria avisos de dependências não circulares).
Voltar ao topo ↑
Progresso do livro: Capítulo 3 / 14scripts/dev.jsStatus de verificação: Linhas FACT com ancoragem realscripts/pre-dev-sfc.jsNo capítulo anterior, rastreamos a cadeia completa da build de produção, desde a análise de parâmetros até a gravação de artefatos em múltiplos formatos, uma cadeia que busca a completude e padronização dos artefatos. Já a demanda central do fluxo de desenvolvimento é apenas uma: alterar uma linha de código e ver o efeito imediatamente no navegador. A cadeia da build de produção — "analisar parâmetros → gerar configuração → empacotar tudo → gravar em disco" — leva dezenas de segundos, incapaz de atender a essa demanda. O repositório do Vue core mantém, para isso, uma cadeia independente de desenvolvimento:
usar o modo watch do esbuild para build incremental,
pré-compilar o compilador de SFC antes da build principal. Este capítulo disseca o mecanismo de colaboração entre os dois.
3.1 dev.js: o construtor incremental que troca velocidade pelo esbuild📎 scripts/dev.js:3-5
Modelo intuitivo
A build de produção é como "a gráfica fazendo a composição formal para impressão" — qualidade em primeiro lugar, ser mais lento não importa; a build de desenvolvimento é como "um esboço a lápis no rascunho" — sem buscar refinamento, apenas que apareça assim que a caneta tocar o papel. O Vue escolhe o esbuild em vez do Rollup para fazer esse esboço, e o motivo está escrito no comentário no início do arquivo: os artefatos do Rollup são menores e o Tree-shaking é melhor, mas o esbuild é muito mais rápido.
Sem este script, o desenvolvedor teria que rodar uma build de produção completa a cada alteração, e o ciclo de feedback degradaria de milissegundos para minutos, destruindo completamente a experiência de hot update.parseArgsAnálise de parâmetros e derivação de formatoformatA entrada do script usa oglobal)、prodnativo do Node para analisar três opções:false)、inline(padrãofalse)。📎 scripts/dev.js:18-40parâmetros posicionais são coletados comotargets, se vazio então o padrão é['vue']。📎 scripts/dev.js:42-53
Há um detalhe fácil de ignorar aqui:rawFormateformatsão duas atribuições.parseArgsdedefault: 'global'já garante querawFormattem valor, mas o script ainda escreveuconst format = rawFormat || 'global'como fallback.📎 scripts/dev.js:42Isso é uma escrita defensiva, para evitar queparseArgsmudanças de comportamento ou passagem explícita de string vazia façam oformat.startsWithdownstream lançar erro.
formatO mapeamento para o formato de saída do esbuild tem três ramos: começando comglobalmapeia paraiife, igual acjsmapeia paracjs, todo o restoesm。📎 scripts/dev.js:42-53O sufixo do nome do arquivo de saída é tratado separadamente pelo sufixo-runtime:global-runtimese tornaruntime.global, o restante permanece como está.📎 scripts/dev.js:42-53
Localização do pacote alvo e caminho de saída
O script primeiro lêpackages-privatea lista de diretórios, para determinar se o pacote alvo pertence a pacote público ou privado.📎 scripts/dev.js:56Para cada target, decide se o caminho base do pacote épackagesoupackages-private, depoisrequireseupackage.jsonpara obterversionebuildOptions。📎 scripts/dev.js:58-63
O nome do arquivo de saída tem um caso especial:vue-compato target será renomeado paravue, para evitar que o artefato se chamevue-compat.global.js。📎 scripts/dev.js:64-69O caminho final tem a formapackages/vue/dist/vue.global.js,prodquando verdadeiro, insereprod.o segmento.
Resolução de external: evitar empacotar dependências no artefato
externalO array determina quais módulos não serão empacotados. A lógica é dividida em duas camadas:
Primeira camada, quandoinlinenão está ativado e o formato écjsou contémesm-bundler, adiciona todas as chaves dedependencies、peerDependenciesao external, e codifica fixamentepath、url、streamtrês módulos internos do Node.📎 scripts/dev.js:76-88O comentário explica claramente que esses três são para@vue/compiler-sfceserver-renderer.
Segunda camada, para o targetcompiler-sfc, resolve adicionalmente@vue/consolidateodevDependenciesdefs、vm、crypto, colocando-os junto com📎 scripts/dev.js:90-112etc. como external.react-dom/server、teacup/lib/express、arc-templates/dist/es5、then-pug、then-jadeO código também codifica fixamente caminhos de template engines como
〔Inferência de design e trade-offs arquiteturais〕rollup.config.jsEste trecho de lógica é altamente duplicado comTODO this logic is largely duplicated from rollup.config.js, os comentários do código-fonte também admitem isso (
). A razão de não extrair uma função comum é que as estratégias de external de dev e prod têm diferenças sutis (dev é mais agressivo em externalizar para acelerar o build), forçar a unificação aumentaria o acoplamento.
Plugins e injeção de definelog-rebuildO array de plugins tem por padrão apenas umonEnd, no hook📎 scripts/dev.js:115-124imprime o caminho relativo do artefato de build.
〔Inferência de design e trade-offs arquiteturais〕cjsO segundo plugin é condicional: quando o formato não ébuildOptions.enableNonBrowserBranchese opolyfillNode()。📎 scripts/dev.js:126-128do pacote é verdadeiro, montacompiler-sfcPacotes como este (ex:
define) ainda seguem o ramo Node em builds de navegador, precisam de polyfill de módulos internos do Node para rodar no ambiente de navegador.📎 scripts/dev.js:141-159O bloco é a parte de maior densidade de informação deste capítulo.__XXX__Ele substitui todas as macros
__COMMIT__no código-fonte por literais:"dev",__VERSION__fixado como__DEV__pega a versão do pacote;proddeterminado pela flag__TEST__,false;__BROWSER__sempre éformat !== 'cjs' && !pkg.buildOptions?.enableNonBrowserBranches。📎scripts/dev.js:146-148A derivação de é a mais sutil:__SSR__Ou seja, apenas "não cjs e o pacote não suporta ramo não-navegador" é marcado como ambiente de navegador;format !== 'global'é__COMPAT__, ou seja, builds global não ativam o ramo SSR;vue-compatdeterminado por se o target é- ;
__FEATURE_SUSPENSE__、__FEATURE_OPTIONS_API__、__FEATURE_PROD_DEVTOOLS__、__FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__três feature flags (
) são todos fixados no modo dev.vitest.config.tsEssas macros correspondem um a um com o blocodefineem📎 vitest.config.ts:6-21.__TEST__O ambiente de teste definetrue、__DEV__comotruecomo
, a diferença com o build dev é exatamente o ponto de distinção entre os dois estados de execução "teste vs desenvolvimento".
Inicialização do modo watchesbuild.context(...).then(ctx => ctx.watch())。📎 scripts/dev.js:130-161 contextO último passo éwatch()criar o contexto de build mas não executar imediatamente,onEndsó então realmente inicia o monitoramento de arquivos. Depois disso o esbuild mantém internamente o grafo de dependências, qualquer mudança em arquivo dependido dispara rebuild incremental, o callback de conclusão do rebuild
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: 相对路径"]Copiar
3.2 pre-dev-sfc.js: sentinela de pré-compilação para quebrar dependência circular
Modelo intuitivocompiler-sfcImagine um dilema "ovo e galinha":compiler-coreo código-fonte de importacompiler-core, ecompiler-sfcem modo de desenvolvimento precisa de.vuepara processar arquivospre-dev-sfc.js. Se ambos dependem de compilação em tempo real via esbuild watch, quem compilar primeiro trava.
O papel de é "chocar o ovo primeiro, depois criar a galinha" — antes do build principal iniciar, garantir que os artefatos CJS desses pacotes já existam.
Checklist e lógica de curto-circuitocompiler-sfc、compiler-core、compiler-dom、compiler-ssr、shared。📎 scripts/pre-dev-sfc.js:4-10O script mantém uma lista fixa:packages/${pkg}/dist/${pkg}.cjs.jsPara cada pacote, verifica se📎 scripts/pre-dev-sfc.js:4-23
existe.allFilesPresentSe qualquer um estiver faltando,falsedefine comobreake imediatamente📎 scripts/pre-dev-sfc.js:20-21, não verifica os pacotes restantes.allFilesPresentFinalmente, seprocess.exit(1)for falso,📎 scripts/pre-dev-sfc.js:25-27
sai com código diferente de zero.
Semântica do código de saídaexit(1)Este script em si não executa nenhuma compilação, ele apenas faz "asserção de existência".&&É o sinal para o chamador superior (geralmente a cadeia
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"]Copiar
scripts/dev.js3.3 aliases.js e vitest.config.ts: a outra metade do fluxo em desenvolvimentoscripts/aliases.jsresolve "como gerar artefatos rapidamente", mas em desenvolvimento há outro caminho: rodar testes.📎 scripts/aliases.js:7-7
fornece aliases de caminho compartilhados para vitest e rollup.
resolveEntryForPkgLógica de geração de aliasespackages/${p}/src/index.ts。📎 scripts/aliases.js:7-7mapeia nomes de pacotes paravue、vue/compiler-sfc、vue/server-renderer、@vue/compat。📎 scripts/aliases.js:16-21
entries base codifica fixamente quatro mapeamentos especiais:packagesEm seguida percorre todos os subdiretórios sobvue, pulanonSrcPackages(sfc-playground、template-explorer、dts-testem si, pula@vue/${dir}), pula keys já existentes, e deve ser diretório, só então adiciona ao mapeamento📎 scripts/aliases.js:23-35
〔Inferência de design e trade-offs arquiteturais〕nonSrcPackagesA lista de exclusão deve-se ao facto de estes três pacotes não teremsrc/index.tsentrada, forçar o mapeamento causaria falha na análise.
O define do vitest e o consumo de aliases
vitest.config.tsimportar diretamenteentriescomoresolve.alias。📎 vitest.config.ts:3📎 vitest.config.ts:22-24o seudefinebloco contrasta com a injeção de macros do dev.js: ambiente de teste__DEV__: true、__TEST__: true、__BROWSER__: false、__CJS__: true。📎 vitest.config.ts:6-21
Os testes são divididos em cinco projetos:unit、unit-gc、unit-jsdom、e2e、e2e-browser。📎 vitest.config.ts:51-118entre os quaisunit-gcusapool: 'forks'e passa--expose-gc, dedicado a executar testes SSR que requerem acionamento manual do GC.📎 vitest.config.ts:65-76 e2e-browserPor sua vez, ativa a instância chromium do playwright, executando testes relacionados com 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: 路径
endReflexão de design
Porque é que o dev usa esbuild e o prod usa Rollup?Isto não é uma escolha técnica arbitrária, mas sim porque as restrições dos dois cenários são diferentes. Em desenvolvimento, o tamanho do artefacto não é sensível, mas a latência de feedback é extremamente sensível; em produção, o inverso. O esbuild é escrito em Go, com alto grau de paralelização, arranque a frio e construção incremental uma ordem de magnitude mais rápidos, mas a sua capacidade de Tree-shaking e divisão de código é inferior à do Rollup.📎 scripts/dev.js:3-5Usar dois conjuntos de ferramentas para servir dois cenários é um compromisso pragmático de engenharia.
Porque é que o pre-dev-sfc apenas verifica e não compila?Se ele próprio acionasse a compilação, traria de volta a dependência circular — ele precisa de compilarcompiler-sfc, e o processo de compilação em si pode depender doscompiler-sfcartefactos de . Portanto, só pode fazer uma "asserção", expondo o facto de "artefacto em falta" à camada superior, que decide se executa a construção completa ou termina com erro. Isto é um "padrão sentinela": não resolve o problema, apenas o reporta.
A duplicação da lista external é dívida técnica?A lógica external do dev.js e do rollup.config.js está duplicada, e os comentários no código-fonte também o admitem.📎 scripts/dev.js:73Mas os conjuntos external de ambos não são completamente idênticos — o dev, por questões de velocidade, externaliza de forma mais agressiva. Extrair forçadamente uma função comum exigiria introduzir interruptores de diferença parametrizados, tornando ambas as lógicas mais difíceis de ler. Este é um exemplo típico do compromisso "duplicação é melhor que abstração errada".
Resumo do capítulo
Este capítulo desmontou as três peças do puzzle da cadeia de desenvolvimento do Vue core:
1. scripts/dev.js: usar ocontext().watch()do esbuild para implementar construção incremental, através doparseArgsanalisar formato e flags, dinamicamenterequireo pacote alvopackage.jsonlocalizar o caminho de saída, injetar__DEV__、__BROWSER__e outras macros para controlar compilação condicional, e usar olog-rebuildplugin para imprimir feedback após cada reconstrução.
2. scripts/pre-dev-sfc.js: antes da construção principal, verificar se os artefactos CJS dos cinco pacotes principais existem; se faltarem, terminar com código de saída 1, evitando deadlock de construção causado por dependências circulares.
3. scripts/aliases.js + vitest.config.ts: fornecer aliases de caminho partilhados para a cadeia de testes, itens especiais codificados manualmente mais itens genéricos com varrimento dinâmico, em conjunto com configuração multi-projeto cobrindo cinco cenários de teste: unitário, GC, jsdom, e2e e e2e de browser.
Reflexão e autoavaliação do capítulo
Q1: Se removermos oscripts/pre-dev-sfc.jsdobreak(ou seja, verificar todos os pacotes antes de decidir sair), em que cenários isso degradaria a experiência do programador? Porque é que o autor do código-fonte escolheu "curto-circuito ao encontrar a primeira falha"?
Análise de referência:
📎 scripts/pre-dev-sfc.js:4-23
breakestá localizado noif (!fs.existsSync(...))ramo, e assim que se deteta a falta de um artefacto de pacote, sai imediatamente do ciclo.
Se removermos obreak, o script continuaria a verificar os restantes pacotes, e no final oallFilesPresentcontinuaria a serfalse, o código de saída continuaria a ser 1,funcionalmente equivalente. Mas a diferença está em:
1. Desempenho: as cinco chamadasexistsSyncsão rápidas em si, mas se a lista se expandir para dezenas de pacotes, o curto-circuito poupa uma grande quantidade de chamadas de sistema stat desnecessárias.
2. Semântica: o curto-circuito expressa "basta faltar um para o todo estar incompleto" — é uma asserção booleana, não é necessário saber quantos faltam especificamente. Continuar a verificar não produz informação adicional.
3. Experiência do programador: na verdade, o que piora é a "mensagem de erro". O script atual não imprime qual pacote falta, o programador só vê o código de saída 1. Se removermos obreake adicionarmos logs, poderíamos informar o programador "falta compiler-core e shared" — mas isso exigiria código adicional. O autor escolheu a implementação mais simples, deixando o diagnóstico de "qual falta" para a mensagem de erro do script de construção da camada superior.
Portanto, a motivação central dobreaké "semântica de asserção + desempenho", e não otimização de experiência.
Q2: scripts/dev.jsEm__BROWSER__a derivação deformat !== 'cjs' && !pkg.buildOptions?.enableNonBrowserBranchesébuildOptions.enableNonBrowserBranches. Suponha que otruede um pacote é-f global, e o programador usa__BROWSER__para construir, neste casofalseétrue. Que consequências isso causaria? E se fosse alterado erroneamente para
?:
📎 scripts/dev.js:146-148
Análise de referênciaformat = 'global'QuandoenableNonBrowserBranches = truee
format !== 'cjs':true!pkg.buildOptions?.enableNonBrowserBrancheséfalse- é
__BROWSER__ = false
o todoif (__BROWSER__)Isto significa que todos os ramosif (false)no código-fonte são substituídos pelo define do esbuild por
, o código exclusivo do browser é removido pelo Tree-shaking, e os ramos não-browser (lógica exclusiva do Node) são preservados.Consequênciafs、path: o artefacto de construção global deveria correr no browser, mas contém ramos exclusivos do Node. Se esses ramos referenciaremenableNonBrowserBranchese outros módulos internos do Node, ao carregar no browser dará erro de "módulo não definido". É precisamente por isso que pacotes comcompiler-sfcverdadeiro (comopolyfillNode()) normalmente não são usados para construção global, ou precisam do📎 scripts/dev.js:126-128
plugin como salvaguarda.true:__BROWSER__ = trueSe fosse alterado erroneamente paracompiler-sfc, o ramo do browser seria preservado e o ramo do Node removido. Para
Q3: scripts/aliases.js, um pacote que tem de correr compilação SFC no ambiente Node, isso faria com que funcionalidades centrais (leitura de ficheiros, chamadas à API do Node) fossem removidas pelo Tree-shaking, e o artefacto ao correr no Node daria erro de "função não definida".packagesEmnonSrcPackages(sfc-playground、template-explorer、dts-test, ao varrer dinamicamente o diretóriopackages, foi ignoradosrc/index.ts, e não foi adicionado anonSrcPackages, o que acontece? Em qual etapa o vitest lançará erro durante a execução?
Análise de referência:
📎 scripts/aliases.js:23-35
A lógica de varredura dinâmica é: para cada diretório, sedir !== 'vue', não está emnonSrcPackages, a key não existe, e é um diretório, então adiciona aentries['@vue/${dir}'] = resolveEntryForPkg(dir)。
resolveEntryForPkgretorna o caminho depackages/${p}/src/index.ts.📎 scripts/aliases.js:7-7Observe que elenão verifica se o arquivo existe, apenas concatena o caminho.
Consequência: o alias será registrado, mas apontará para um arquivo inexistente. Quando o vitest resolve o import, se algum arquivo de teste importar esse pacote, o plugin resolve do Vite tentará carregar esse caminho e reportará "não foi possível resolver o módulo" ou "arquivo não existe".
Etapa do erro: não é durante a execução dealiases.js(ele apenas faz concatenação de strings), mas após o vitest iniciar, na primeira vez que esse import for resolvido. Se nenhum teste importar esse pacote, não haverá erro — o alias apenas ficará parado no objetoentries.
Forma de evitar: adicione esse tipo de pacote semsrc/index.tsanonSrcPackages, ou garanta que o novo pacote tenha uma entrada padrão. É também por isso quenonSrcPackagesprecisa ser mantido manualmente — é a lista de exceções do "convenção sobre configuração".
A fronteira da colaboração entre os três é bem clara:pre-dev-sfcgerencia "se o artefato está pronto",dev.jsgerencia "como atualizar o artefato rapidamente",aliasesgerencia "como os testes resolvem o código-fonte". A cadeia em tempo de desenvolvimento resolve o problema de velocidade, mas na fase de build há outro tipo de otimização mais oculta — aquelas transformações concluídas antes de o código ser executado pelo navegador. O próximo capítulo entrará na magia do tempo de compilação, para ver como o inline de enums e o mecanismo de verificação de Tree-shaking substituem TypeScript enum por literais durante o build e garantem que a promessa de importação sob demanda não seja quebrada.
Capítulo 4: Magia do tempo de compilação: inline de enums e mecanismo de verificação de Tree-shaking
No capítulo anterior vimos como a cadeia em tempo de desenvolvimento troca observação de arquivos e build incremental pela velocidade de "alterar uma linha e entrar em vigor imediatamente". Mas além da velocidade, Vue tem outra restrição mais oculta: o tamanho do artefato publicado deve ser controlável. Um dos inimigos dessa restrição é o enum do TypeScript — ele é um objeto real em tempo de execução e quebra o Tree-shaking. Este capítulo entra no tempo de compilação para ver como scripts/inline-enums.js "dissolve" enums em literais antes de o código ser executado pelo navegador; e depois ver como scripts/verify-treeshaking.js, após o build, usa strings do artefato para verificar reversamente que a promessa de "importação sob demanda" não foi silenciosamente quebrada.
4.1 Inline de enums: dissolvendo objetos de tempo de execução em literais
Modelo intuitivo
Imagine que você escreveu uma receita na qual "um pouco de sal" aparece repetidamente. Se toda vez que cozinhasse fosse preciso ir ao apêndice consultar "um pouco = 3 gramas", seria lento e ocuparia espaço. O que o inline de enums faz é, antes da impressão, substituir diretamente todo "um pouco de sal" do livro por "3 gramas de sal" e depois rasgar aquela página do apêndice. Para o leitor (tempo de execução), o resultado é exatamente o mesmo, mas o livro fica mais fino.
Sem isso, que desastre o sistema enfrentaria? Umenumcomum do TypeScript, após compilação, gera um objeto literal real e com mapeamento bidirecional (Enum[Enum.A] === 'A'). Esse objeto éuma declaração em nível de módulo com efeitos colaterais, e o Rollup não consegue provar que ele não é usado, então só pode mantê-lo — mesmo que você importe apenas um de seus membros, todo o objeto enum junto com o mapeamento reverso será incluído no artefato.📎 scripts/inline-enums.js:3-9O comentário deconst enumdiz claramente: eles já usaram
, mas por causa da issue #1228 mudaram para enum comum, então usam este script para "recuperar manualmente o benefício de custo zero do const enum".
Estrutura de dados e layout de memória📎 scripts/inline-enums.js:33-36
EnumMember:{ name, value }O núcleo do script são três definições de tipo; entendê-las é entender todo o fluxo de dados.EnumDeclaration:{ id, range: [start, end], members }。range, o nome de um único membro de enum e o literal após avaliação.éo deslocamento em bytes do código-fonteexport enum X { ... }, apontando para a posição inicial e final de toda a declaraçãoEnumData:{ declarations, defines }。declarationsno arquivo — esta é a âncora para a substituição precisa posterior com MagicString.definesindexado por caminho de arquivo, registrando os intervalos de substituição de todas as declarações de enum nesse arquivo;é um mapeamento plano, cuja chave é o literal após `形式的字符串,值是${nomeDoEnum}.${nomeDoMembro}
JSON.stringify`.definesHá um design-chave aqui:a chave de。📎 scripts/inline-enums.js:98-103não contém caminho de arquivoErrorCodesO comentário explica o motivo —@vue/compiler-corepode existir simultaneamente em@vue/runtime-coreeErrorCodes.__EXTEND_POINT__, então enums com o mesmo nome podem existir em arquivos diferentes; mas o mesmofullKey in definesnão pode se repetir em dois enums com o mesmo nome, caso contrárioname conflicté acionado e lança
diretamente. Esta é uma restrição de "unicidade global por nome de membro", não de "unicidade global por nome de enum".temp/enum.json。📎 scripts/inline-enums.js:33-36O cache fica emscanEnums()Por que precisa ser gravado em disco? Porqueé chamado apenas uma vez na entrada do build, e o Rollup iniciará。📎 scripts/inline-enums.js:39-41processos independentesinlineEnums()para cada pacote e cada formato. O comentário aponta: os dados precisam ser compartilhados entre processos concorrentes do Rollup, então devem ser serializados em disco e lidos de volta pelo
de cada processo.
Step-by-Step: de grep à substituição por literaisexport enumPrimeiro passo: grep de todos os arquivos contendo📎 scripts/inline-enums.js:51-61.spawnSync('git', ['grep', 'export enum'])usapath:line:content, com saída no formato:, depois corta o primeiro segmento porSet(caminho do arquivo), e usagit grepem vez de percorrer o sistema de arquivos — ele naturalmente varre apenas os arquivos rastreados pelo Git, excluindo automaticamentenode_modulese artefatos de build.
Segundo passo: o Babel analisa e coleta informações de enum.📎 scripts/inline-enums.js:64-70Para cada arquivo usa@babel/parsercomtypescriptplugin,sourceType: 'module'analisa em AST, e então percorre apenasast.program.bodyos nós de nível superior.📎 scripts/inline-enums.js:74-79Reconhece apenasExportNamedDeclaratione cujodeclaration.type === 'TSEnumDeclaration'nó — ou seja,enums não exportados não serão processados。
Para cada declaração de enum, o script avalia membro por membro. A avaliação de membros segue três caminhos:
1. Inicialização literal:StringLiteralouNumericLiteralobtém diretamenteinit.value。📎 scripts/inline-enums.js:114-119
2. Expressão binária: como1 << 2. RecursivamenteresolveValueprocessa os operandos esquerdo e direito, os operandos podem ser literais ouMemberExpression(ou seja, referência a um membro de enum já definido anteriormente).📎 scripts/inline-enums.js:121-151O ponto-chave está noMemberExpressionbranch: ele usacontent.slice(node.start, node.end)a partir dotexto do código-fonte originalpara extrair a string da expressão (comoErrorCodes.FOO), depois consultadefines. Se não encontrar, lançaunhandled enum initialization expression。📎 scripts/inline-enums.js:132-141Isso explica por quedefinesdeve ser um mapeamento global plano — ao referenciar entre enums, o referenciado pode vir de outro arquivo, mas a chave reconhece apenas枚举名.成员名。
3. Expressão unária: como-1, monta a string-1e usaevaluatepara avaliar.📎 scripts/inline-enums.js:152-163
A avaliação em si usanew Function('return ' + exp)()。📎 scripts/inline-enums.js:39-41Este é umeval controlado: a entrada vem de fragmentos de AST já analisados no código-fonte, não de entrada arbitrária do usuário, então o limite de segurança é controlável.
Terceiro passo: processar membros sem inicializador (semântica de auto-incremento).📎 scripts/inline-enums.js:171-183Se o membro não teminitializer: o primeiro membro por padrão0; membros subsequentes, selastInitializedfor numérico então++; se for string, lançawrong enum initialization sequence— porque membros de enum string não permitem auto-incremento implícito. Esta é exatamente a semântica do enum do TypeScript.
Quarto passo: gravar cache e retornar função de limpeza.📎 scripts/inline-enums.js:200-213 scanEnums()Retorna um closure, cuja chamadarmSyncexclui o arquivo de cache.build.jsUsa-o emtry/finally.📎 scripts/build.js:81-112Isso garante que mesmo se um erro for lançado no meio do build, o cache será limpo, não contaminando o próximo build.
Quinto passo: substituição na fase de transform do Rollup. inlineEnums()Lê de volta o cache, constrói um plugin Rollup.📎 scripts/inline-enums.js:219-234Emtransform(code, id), seidcorresponder aenumData.declarations, usa MagicString para substituir[start, end]este trecho de declaração por um object literal.📎 scripts/inline-enums.js:242-274
A forma após a substituição éexport const X = { ... }. Note que elenão simplesmente remove o enum, mas reescreve como object literal, e gera mapeamento reverso adicional para membros numéricos:JSON.stringify(value.toString()) + ': ' + JSON.stringify(name)。📎 scripts/inline-enums.js:257-270O comentário cita a regra de reverse-mappings da documentação oficial do TypeScript: membros de enum string não geram mapeamento reverso, membros numéricos geram. Isso garante que o comportamento em tempo de execução após a substituição seja completamente idêntico ao enum original.
E o que realmente elimina a sobrecarga em tempo de execução édefinesser entregue a@rollup/plugin-replace。📎 rollup.config.js:222-223Todas asX.Memberreferências asãono plugin de substituição diretamente trocadas por literais, então aquele object literal reescrito, se ninguém o usar, pode ser eliminado pelo Tree-shaking.
O fluxograma abaixo descreve o caminho completo de decisão do grep até a substituição:
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 替换引用"]Reflexões de design e armadilhas
Por que usar MagicString em vez de regenerar o arquivo inteiro?Porques.update(start, end, ...)substitui apenas o trecho da declaração do enum, os demais bytes do código-fonte permanecem intactos,s.generateMap()e ainda gera sourcemap preciso.📎 scripts/inline-enums.js:277-281Se usasse Babel para reimprimir todo o AST, perderia a formatação original, comentários, e a qualidade do sourcemap diminuiria.
rangePor quenode.start/node.endem vez dedeclaration.start?📎 scripts/inline-enums.js:189-193afirmanode.start(ou seja,ExportNamedDeclarationnó), o escopo de substituição cobreexport enum X {...}todo o trecho, incluindoexporta palavra-chave. O texto de substituição começa comexport const, conectando-se perfeitamente.
Armadilhas:definesA restrição de unicidade global deSe dois arquivos diferentes tiverem cada um umErrorCodes, e ambos definirem__EXTEND_POINT__, o build falhará diretamente.📎 scripts/inline-enums.js:101-103Isso não é um bug, mas um design intencional — porquedefinesé uma tabela de substituição global, incapaz de distinguir a origem do arquivo. Em ambiente de produção, ao adicionar novos membros de enum, se o nome conflitar com um membro de enum existente, explodirá aqui.
Armadilha:new FunctionO momento de avaliação deA avaliação de expressão binária ocorre nascanEnumsfase, neste momentodefinespode ainda não ter o membro referenciado (se a ordem de referência estiver invertida).📎 scripts/inline-enums.js:136-140lançaráunhandled enum initialization expression. Isso exige que a referência a membros de enum siga a ordem do código-fonte de "definir antes de referenciar".
4.2 Verificação de Tree-shaking: usar strings do artefato para provar a promessa inversamente
Modelo intuitivo
O inline de enum é uma "otimização prévia", mas a otimização realmente funciona? Se algum helper for mantido acidentalmente por escrita inadequada, o tamanho inflará silenciosamente, e o desenvolvedor nem perceberá.verify-treeshaking.jsÉ o "inspetor de qualidade posterior": ele constrói o artefato, e então como uma autópsia verifica no artefatose coisas que não deveriam aparecer aparecem. Sem ele, a promessa de importação sob demanda do Vue pode silenciosamente quebrar após alguma refatoração, até que usuários reclamem que o pacote cresceu.
Estrutura de dados e itens de verificação
Este script não tem estrutura de dados complexa, o núcleo é umerrorsarray e trêsincludesverificações.📎 scripts/verify-treeshaking.js:6-6Ele primeiro constróiglobal-runtimeformato, depois lê separadamente os artefatos dev e prod.
Os três itens de verificação correspondem a três tipos de "falha de Tree-shaking":
1. artefato dev contém__spreadValues。📎 scripts/verify-treeshaking.js:13-19Este é o helper gerado pelo esbuild para{ ...obj }sintaxe de spread de objeto. Se ele aparecer, significa que o código em tempo de execução usou spread de objeto, enquanto a convenção do Vue deveria usarextendhelper para evitar código extra.
2. artefato prod contémVue warn。📎 scripts/verify-treeshaking.js:26-31significa que háwarn()chamada não envolvida por__DEV__condição, causando vazamento de código de aviso no pacote de produção.
3. artefato prod contém lista de configuração de DOM tag。📎 scripts/verify-treeshaking.js:33-42comohtml,body,base、svg,animate,animateMotion、annotation,annotation-xml,maction. Estes sãoisHTMLTag()Os dados internos de helpers como este deveriam existir apenas no compilador e ser eliminados pelo runtime. Se aparecerem no artefato de runtime, isso indica que o caminho de runtime usou erroneamente um helper exclusivo do compilador.
Passo a passo: fluxo de verificação
📎 scripts/verify-treeshaking.js:5-5Primeiroexec('pnpm', ['build', 'vue', '-f', 'global-runtime']), construir apenasvueo pacoteglobal-runtimeno formato — este é o artefato de runtime mais minimizado, ideal para expor vazamentos. Após a construção, ler os dois arquivos de forma síncrona, verificarincludesum por um, e ao encontrar correspondência, fazer push de uma mensagem com explicação emerrors. Por fim, seerrors.lengthfor diferente de zero, lançar um erro agregado.📎 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 聚合错误"]Reflexões de design e armadilhas
Por que usar stringincludesem vez de análise AST?Porque isto é uma "verificação sentinela", não uma "análise precisa". Não busca completude, apenas configura alertas de baixo custo para três tipos de regressão que realmente ocorreram historicamente. Correspondência de strings tem zero dependências, zero custo de parsing, e é igualmente eficaz em artefatos minificados — análise AST, após minify, torna-se ainda mais difícil de fazer.
Por que verificar apenasglobal-runtime?este formato inlining todas as dependências (externalvazio), é o artefato mais sensível a tamanho e mais suscetível a ser introduzido erroneamente. Se ele está limpo, outros formatos geralmente também estão. Além disso, sua construção é rápida, adequada para rodar frequentemente em CI.
Armadilha: os itens de verificação são uma "lista negra", que se torna ineficaz com a evolução do código.Se algum diaisHTMLTaga estrutura de dados mudar,html,body,baseesta string não aparecerá mais, e a verificação se tornará inútil. Isso exige que os mantenedores atualizem sincronamente as strings sentinela aqui ao modificar helpers relacionados. Este é o custo inerente da verificação por lista negra.
4.3 Colaboração com Rollup: ordem de plugins e injeção de define
O inlining de enums não opera isoladamente; ele está embutido no pipeline de plugins do Rollup. Entender sua posição no pipeline é essencial para compreender por quedefinesdeve ser entregue areplaceem vez deesbuild。
📎 rollup.config.js:47-50chamar no nível superior do módulo de configuraçãoinlineEnums(), desestruturando[enumPlugin, enumDefines]. Note que isto é executadoa cada inicialização de processo do Rollup, lendo o cache escrito porscanEnums.
A ordem do array de plugins é:json → alias → enumPlugin → ...resolveReplace() → esbuild。📎 rollup.config.js:324-339 enumPluginvem antes dereplace, significando que a reescrita das declarações de enum ocorre primeiro, e entãoreplaceusadefinespara substituir referências. Eesbuildvem por último, responsável pela transpilação TS.
Por quedefinesusareplacee nãoesbuild? O comentário emdefine?📎 rollup.config.js:220-221dá a resposta: o define do esbuild "é um pouco estrito, permitindo apenas JSON literal ou identificadores". E nomes de membros de enum comoErrorCodes.__EXTEND_POINT__são expressões de membro com ponto, e o define do esbuild não consegue lidar diretamente com tais chaves. Portanto, é obrigatório usar@rollup/plugin-replace, que suporta substituição de chaves de string arbitrárias.📎 rollup.config.js:250-251E configuroupreventAssignment: true, evitando substituir também o lado esquerdo de instruções de atribuição.
resolveReplace()Emconst replacements = { ...enumDefines }é o primeiro passo.📎 rollup.config.js:222-223Somente depois é que se sobrepõem as anotações de produção/*@__PURE__*/,__DEV__e outras substituições. Esta ordem garante que a substituição de literais de enum sempre tenha efeito.
Reflexões de design
A essência do inlining de enums é "trocar complexidade em tempo de build por tamanho em tempo de runtime".Ele reproduz completamente em tempo de build a semântica do sistema de tipos do TypeScript (avaliação de enum, auto-incremento, mapeamento reverso) —scanEnumsa lógica de avaliação em📎 scripts/inline-enums.js:110-183é quase um subconjunto da avaliação de enum do compilador TS. Isso traz custo de manutenção: se o TS adicionar nova sintaxe de enum (como expressões constantes mais complexas), aqui deve-se acompanhar, caso contrário lança errounhandled. Mas o benefício é claro: zero objetos de enum em runtime, permitindo Tree-shaking completo.
O script de verificação e o script de inlining são um par de "promessa e cumprimento".O script de inlining promete "enums não ocupam tamanho em runtime", o script de verificação checa "outros códigos também não ocupam tamanho secretamente". Ambos juntos protegem o orçamento de tamanho do Vue. Este design pareado de "otimização + verificação" é um padrão típico de engenharia em grandes bibliotecas frontend: qualquer otimização precisa de uma verificação automatizada para prevenir regressões.
Cache entre processos é essencial para builds concorrentes. scanEnumsO padrão de execução única,inlineEnumsmúltiplas leituras,📎 scripts/inline-enums.js:39-41resolve o problema de "uma varredura, N processos consumindo". Sem cache, cada processo Rollup teria que refazer grep + parsing, desperdiçando muito IO e CPU.
Resumo do capítulo
Reflexões e autoavaliação do capítulo
Q1: Se removerscanEnumsemsaveValuea verificação de conflito deif (fullKey in defines), em quais cenários isso causaria erros no artefato de build?
Análise de referência:
definesé um mapeamento global plano, com chave枚举名.成员名, sem incluir caminho de arquivo.📎 scripts/inline-enums.js:98-103Após remover a verificação de conflito, se dois arquivos diferentes tiverem enums com o mesmo nome e definirem membros com o mesmo nome (como@vue/compiler-coree@vue/runtime-coreambos tendoErrorCodes.__EXTEND_POINT__), o último a escrever sobrescreverá o primeiro.
Consequências:defines['ErrorCodes.__EXTEND_POINT__']restará apenas um valor, eplugin-replaceao substituir não conseguirá distinguir a origem do arquivo, substituindotodososErrorCodes.__EXTEND_POINT__em todos os arquivos pelo mesmo valor.📎 rollup.config.js:222-223Assim, o valor do membro de enum de um dos pacotes é silenciosamente adulterado, causando comportamento incorreto em runtime e extremamente difícil de depurar — porque o código-fonte parece completamente correto.
É exatamente por isso que o comentário enfatiza "permitir enums com mesmo nome entre arquivos, mas não permitir membros com mesmo nome".📎 scripts/inline-enums.js:98-100A verificação de conflito é o guardião que impede a contaminação da tabela global de substituição.
Q2: Se inverter a ordem derollup.config.jseenumPluginno array de plugins em...resolveReplace(), o que aconteceria?
Análise de referência:
A ordem atual éenumPluginprimeiro,replacedepois.📎 rollup.config.js:331-332O hooktransformdo Rollup executa na ordem do array de plugins.
Se invertido,replacerodaria primeiro, quando as declarações de enum ainda estão na forma originalexport enum X { ... }.replaceusadefinespara substituir referências deX.Member— mas as referências ainda estão lá, a substituição funcionaria. O problema surge quandoenumPluginroda em seguida: ele usas.update(start, end, ...)para reescrever o segmento de declaração.📎 scripts/inline-enums.js:250-273Masreplacejá modificoucode, e oenumPluginobtido porcodeé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 规则禁止运行时 import 编译器 helper,而非依赖产物字符串。但在当前成本约束下,字符串哨兵是「够用且廉价」的折中。
枚举内联解决了「构建期如何消除运行时开销」,验证脚本解决了「如何确认优化没被破坏」。但构建产物除了 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 如何保证源码类型与发布类型严格一致。