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.
Gostou deste capítulo? Crie um livro para seu repositório privado
Arquitetura local-first em Tauri 2 + Rust. 100% offline e seguro, zero upload de código. Leitura em painel duplo com âncoras imutáveis de commit.
⚡ Tauri 2 · Rust Core · 100% Offline e Privado · Testado em 1M+ linhas
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).
Gostou deste capítulo? Crie um livro para seu repositório privado
Arquitetura local-first em Tauri 2 + Rust. 100% offline e seguro, zero upload de código. Leitura em painel duplo com âncoras imutáveis de commit.
⚡ Tauri 2 · Rust Core · 100% Offline e Privado · Testado em 1M+ linhas
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.
Gostou deste capítulo? Crie um livro para seu repositório privado
Arquitetura local-first em Tauri 2 + Rust. 100% offline e seguro, zero upload de código. Leitura em painel duplo com âncoras imutáveis de commit.
⚡ Tauri 2 · Rust Core · 100% Offline e Privado · Testado em 1M+ linhas
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 如何保证源码类型与发布类型严格一致。
Gostou deste capítulo? Crie um livro para seu repositório privado
Arquitetura local-first em Tauri 2 + Rust. 100% offline e seguro, zero upload de código. Leitura em painel duplo com âncoras imutáveis de commit.
⚡ Tauri 2 · Rust Core · 100% Offline e Privado · Testado em 1M+ linhas
Capítulo 5: Pipeline de Teste de Tipos: Guardiões de Código-Fonte e Contratos de Tipos
Gostou deste capítulo? Crie um livro para seu repositório privado
Arquitetura local-first em Tauri 2 + Rust. 100% offline e seguro, zero upload de código. Leitura em painel duplo com âncoras imutáveis de commit.
⚡ Tauri 2 · Rust Core · 100% Offline e Privado · Testado em 1M+ linhas
Capítulo 6: Testes de contrato de tipo: como o dts-test protege a superfície da API
No capítulo anterior, rastreamos a cadeia de geração das declarações de tipo e vimos como o Vue garante, por meio de configuração de build e testes de fumaça, que "tipos do código-fonte" e "tipos publicados" sejam estritamente consistentes. Mas o contrato de tipo não se limita a "a forma está correta"; mais crucial ainda é "se a superfície da API atende ao esperado" — quais tipos devem ser exportados, quais não devem, e se as restrições genéricas são precisas. Este capítulo entra empackages-private/dts-test, para ver como o Vue usa mais de 20.test-d.tsarquivos para transformar "tipo como contrato de API" em testes automatizados regressáveis.
Modelo cognitivo dos testes de contrato de tipo: transformar o "manual" em um "contrato executável"
dts-testOs arquivos no diretóriotêm uma característica contraintuitiva: elesquase não produzem nenhum comportamento em tempo de execuçãodefineComponent.test-d.tsx. Ao abrirdefineComponent({...}), você verá muitas chamadastsc/vue-tsc, mas elas nunca são realmente executadas durante a execução dos testes — esses arquivos são apenas submetidos anoEmit: truepara verificação de tipos,
📎 packages-private/dts-test/tsconfig.test.json:1-11
garantindo que nenhum JS seja produzido.noEmitEsta configuração é o "ambiente de execução" de todo o sistema de contrato:jsx: preservedesativa a emissão de artefatos,strictpreserva a sintaxe TSX para o sistema de tipos analisar,moduleResolution: bundlerativa todas as verificações estritas,libcorresponde à semântica moderna de empacotamento,esnexte ao mesmo tempo introduzdom。e.test-d.tsxSem esse conjunto de configuração,。
seria tratado como JSX de tempo de execução, e as asserções de tipo perderiam sentidopackages-private〔Inferência de design e trade-offs arquiteturais〕packages/vueSeparar os testes de tipo em um subpacote__tests__em vez de colocá-los dentro devuedetem três motivações: primeiro, as dependências dos testes de tipo são os(vue/jsx、vuetipos em nível de publicação de.d.ts), e não módulos internos do código-fonte; o isolamento físico força o uso da entrada pública; segundo,tsca verificação de tipos dos testes de tipo leva muito mais tempo do que os testes unitários em tempo de execução, e um diretório independente facilita o agendamento separado no CI; terceiro,.test-d.tsxos arquivos
não são executados por engano pelo coletor de tempo de execução do Vitest.
utils.d.tsAnalogia cotidiana: testes unitários comuns são como "ligar a máquina e rodá-la para ver se solta fumaça", enquanto testes de contrato de tipo são como "conferir cláusula por cláusula antes de assinar o contrato" — sem transação real, apenas confirmando que "valor a pagar pela parte A" está escrito como "renminbi" e não "dólar". Se as cláusulas do contrato estiverem erradas, não adianta a máquina rodar bem.
📎 packages-private/dts-test/utils.d.ts:7-21
fornece todas as ferramentas para essa "conferência de contrato":expectType<T>(value: T)Há apenas quatro ferramentas principais:valueafirma queT;expectAssignable<T, T2 extends T>é exatamente do tipoT2afirma queT;IsUnion<T>é atribuível aTdetermina seIsAny<T>é um tipo união;Tdetermina seanyéimport 'vue/jsx'. Observe o<MyComponent />na L5 — ele registra o namespace global JSX, permitindo queJSX.Element。
📎 packages-private/dts-test/utils.d.ts:7-21
IsUnionem TSX seja reconhecido pelo sistema de tipos comoT extends any ? (U extends T ? false : true) : neverA implementação deTmerece uma análise detalhada:extends falseutiliza tipos condicionais distributivos; sefalsefor um tipo união, cada membro será avaliado independentemente, e no finaldetermina se todos os ramos retornam. Esta é umaprops.jjjprova de existência no nível de tipo
— usada para travar contratos como "defineComponentdeve ser um tipo união e não ser mesclado em uma única assinatura".
defineComponent.test-d.tsxWalkthrough orientado por cenário:cadeia completa de inferência de tipos de props emdefineComponent({ props: {...}, setup(props) {...} })tem 2260 linhas e é o núcleo do sistema de contrato. Vamos nos colocar em um cenário concreto:propso usuário escrevesetup, e o sistema de tipos do Vue precisa inferir, a partir da declaração em tempo de execução deprops, o tipo preciso do parâmetroem
. Essa cadeia é a parte mais complexa do sistema de tipos do Vue.
Primeiro passo: construir o "tipo esperado" como referência do contratoExpectedPropsO arquivo de teste primeiro define a interface, fixando explicitamente o tipo que cada forma de declaração de props deveria inferir:
📎 packages-private/dts-test/defineComponent.test-d.tsx:21-53
Essa interface é a versão escrita das "cláusulas do contrato". Observe alguns tipos sutis:a?: number | undefined(props opcionais comundefined)、aa: number(tem default, portanto não opcional),aaa: number | null(PropType<number | null>declarado explicitamente),aaaa: number | undefined(required: true as constmas o tipo contémundefined). Essas diferenças não são escritas aleatoriamente; cada uma corresponde a um ramo específico na declaração deprops.
Segundo passo: "alimentar"defineComponent
📎 packages-private/dts-test/defineComponent.test-d.tsx:57-158
com várias formas de declaraçãopropsEste trechoo objeto éuma matriz exaustiva de formas de declaração
a: Number, cobrindo todas as maneiras de escrever props no Vue:number | undefinedaa: { type: Number as PropType<number | undefined>, default: 1 }—— forma abreviada de construtor, inferida comonumberaaaa: { type: Number, required: true as const }——as const—— tem default, inferido como não opcionaltrueevita quebooleanseja ampliado parab: { type: String, required: true as true }——required: true, preservando o tipo literalbb: { default: 'hello' }torna a propriedade não voidtype—— semcc: Array as PropType<string[]>, inferindo o tipo apenas pelo defaultl: [Date]—— conversão explícita de tipoDate | undefinedll: [Date, Number]—— sintaxe de array, inferida comoDate | number | undefinedlll: [String, Number]—— array de múltiplos tipos, inferido como
required: true as const〔Inferência de design e trade-offs arquiteturais〕required: true as true(L70) eas true(L75) coexistem como vestígios de evolução histórica: no início usava-seas const, depois se descobriu queera mais geral (capaz de travar simultaneamente outros literais no objeto), mas a forma antiga foi mantida para verificar compatibilidade retroativa. Este é o valor típico dos testes de contrato —。
eles travam ao mesmo tempo "a nova forma é utilizável" e "a forma antiga não regride"setup / render / thisTerceiro passo: afirmar em três posições
Este é o design mais engenhoso dos testes de contrato:o mesmo tipo de props deve ser inferido corretamente em três posições de consumo diferentes。
📎 packages-private/dts-test/defineComponent.test-d.tsx:160-217
setup(props)fazexpectType<ExpectedProps['x']>(props.x)para cada prop. Observe o tratamento especial em L168-170:
📎 packages-private/dts-test/defineComponent.test-d.tsx:168-170
// @ts-expect-error should included 'undefined'Combinado comexpectType<number>(props.aaaa)——Escrever deliberadamente uma asserção que gera erro, usando@ts-expect-errorpara engolir o erro. Isso verifica queprops.aaaao tipo denão é number(caso contrário, esta linha não geraria erro,@ts-expect-errore sim falharia por "não haver erro para engolir"). Esta é a técnica de "asserção reversa" para testes de tipo.
📎 packages-private/dts-test/defineComponent.test-d.tsx:204-205
// @ts-expect-error props should be readonlyCombinado comprops.a = 1— verifica que as props são somente leitura emsetup. Se alguma refatoração acidentalmente tornar as props mutáveis, esta linha deixa de gerar erro e@ts-expect-errorfalhará.
render()Já emthis.$propsethis.xdois caminhos de asserção:
📎 packages-private/dts-test/defineComponent.test-d.tsx:221-279
L252-276 verifica que "as props declaradas também devem ser expostas emthis", L278-279 verifica quethis.a = 1gera erro (thisas props em também são somente leitura). L281-287 verifica o desempacotamento do valor de retorno do setup:this.cénumber(ref(1)desempacotado),this.d.e.valueéstring(ref aninhado preserva.value)、this.f.géGT(reactiveo tipo branded em não é desempacotado).
Quarta etapa: validação de tipo no lado do consumidor TSX
O último elo do contrato de tipo é "como o usuário usa este componente". No TSX,<MyComponent />a validação de props de é um caminho de tipo independente:
📎 packages-private/dts-test/defineComponent.test-d.tsx:296-322
Aqui verifica-se que<MyComponent>aceita todas as props declaradas, bem comoclass/style/key/ref/ref_foressas propriedades internas. Em seguida vemvalidação reversa:
📎 packages-private/dts-test/defineComponent.test-d.tsx:337-345
// @ts-expect-error missing required propsverifica que props obrigatórias ausentes geram erro;wrong prop typesverifica que incompatibilidade de tipo gera erro; L342 verifica queggg="baz"gera erro (gggaceita apenas'foo' | 'bar')。
Toda a cadeia pode ser resumida em um diagrama de fluxo de dados:
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 阻断合并"]O ponto-chave deste diagrama é:a mesmapropsdeclaração de deve satisfazer simultaneamente as expectativas de tipo de três posições de consumo. Qualquer desvio de inferência em qualquer ponto farátscgerar erro.
Fronteiras e backdoors:__typeProps、__typeEmitse contratos de tipo condicional
defineComponentA inferência de tipo de tem uma limitação fundamental:declarações de props em runtime não conseguem expressar "tipos condicionais". Por exemplo, "quandocolor='white',appearancedeve ser'outline'" — esse tipo de restrição não pode ser escrita com a sintaxe de objeto em runtime. Vue fornece para isso__typePropse outros "backdoors de tipo".
__typeProps: cápsula de escape de tipo para props condicionais
📎 packages-private/dts-test/defineComponent.test-d.tsx:1803-1836
ConditionalPropsé um tipo união: oucoloreappearancesão ambos opcionais, oucolor: 'white'eappearance: 'outline'. O teste verifica:
- L1823-1824:
<Comp color="white" />gera erro — fornecercolor: 'white'sozinho não satisfaz nenhum dos ramos - L1825-1826:
<Comp color="white" appearance="normal" />gera erro —appearancedeve ser'outline' - L1827:
<Comp color="white" appearance="outline" />passa
__typePropsA motivação de design de é "permitir que o sistema de tipos expresse restrições que o runtime não consegue expressar". Ele não participa da resolução de props em runtime, é puramente uma sobreposição em nível de tipo. O custo é que o usuário precisa manter manualmente a consistência entre tipos e declarações de runtime — por isso é chamado de "backdoor" e não de API oficial.
__typeEmits: equivalência entre duas sintaxes de emits
__typeEmitssuporta duas sintaxes, e o testetrava ambas simultaneamente:
📎 packages-private/dts-test/defineComponent.test-d.tsx:1838-1885
Sintaxe de objeto{ change: [id: number], update: [value: string] }usa tuplas nomeadas para expressar parâmetros. O teste verifica quethis.$props.onChange?.(123)passa,onChange?.('123')gera erro.
📎 packages-private/dts-test/defineComponent.test-d.tsx:1887-1934
Sintaxe de assinatura de chamada{ (e: 'change', id: number): void; (e: 'update', value: string): void }usa overloads para expressar.Os corpos de teste das duas sintaxes são quase idênticos linha a linha— isso é intencional: o contrato exige que ambas as formas produzamcomportamento de tipo completamente equivalente.
Por que manter duas sintaxes? A sintaxe de objeto é mais próxima da forma de escrita dedefineEmits, enquanto a sintaxe de assinatura de chamada é mais próxima dos tipos de evento tradicionais do TS. Vue precisa suportar ambas e garantir comportamento consistente. A estrutura de "espelhamento linha a linha" dos testes é a prova mais forte de equivalência.
__typeRefse__typeEl: referências entre componentes e tipos de nó hospedeiro
📎 packages-private/dts-test/defineComponent.test-d.tsx:1936-1952
__typeRefspermite que o componente pai saiba com precisão o tipo do ref do componente filho.Parentdeclara__typeRefs: { child: ComponentInstance<typeof Child> }, entãorefs.child.$refs.foopode ser inferido comonumber。
📎 packages-private/dts-test/defineComponent.test-d.tsx:1963-1977
__typeElé mais sutil. O comentário de teste em L1963-1977 aponta a intenção de design:nós hospedeiros de renderizadores personalizados (TUI, canvas, native) não são DOMElement, entãoTypeElnão pode ser restringido aElement. O teste usa a interfaceCustomElementpara verificar que$elpode aceitar qualquer tipo de hospedeiro.
Esta é a garantia em nível de tipo do Vue 3 para suportar renderizadores personalizados. SeTypeElfosse rigidamente restringido aElement,@vue/runtime-test, usuários de renderizadores não-DOM como esse não conseguiriam inferir corretamente o tipo de$el. O teste de contrato aqui protege a "independência de renderizador".
Restrição mutuamente exclusiva entre componentes genéricos e props de runtime
function syntax w/ runtime propsA seção trava uma regra importante:componentes genéricos não podem coexistir com props de runtime em objeto。
📎 packages-private/dts-test/defineComponent.test-d.tsx:1501-1545
O comentário em L1501generics aren't supported with object runtime propsé uma declaração de contrato. L1525-1535 verifica que setup genérico + props de objeto gera erro; L1538-1539 verifica que<Comp3<string>>gera erro. Já props em array permitem genéricos (L1464-1499).
A causa raiz desta restrição é a ordem de inferência de tipo: props de objeto exigem queExtractPropTypesdetermine o tipo primeiro, enquanto genéricos só podem ser determinados na instanciação, e os dois entram em conflito. Props em array não participam da extração de tipo, então não há conflito. O teste de contrato solidifica essa "limitação do sistema de tipos" como asserções regressíveis.
Reflexões de design, recuperação de erros e armadilhas em produção
@ts-expect-errorA faca de dois gumes de
@ts-expect-erroré a ferramenta central dos testes de contrato de tipo, mas tem uma armadilha fatal:quando o código abaixo dele deixa de gerar erro,@ts-expect-errorele próprio gera erro. Isso parece proteção, mas na verdade exige que o autor do teste controle com precisão "onde o erro ocorre".
📎 packages-private/dts-test/defineComponent.test-d.tsx:1354-1362
Veja este trecho:// @ts-expect-error missing propé colocado em<Comp msg={123} />nalinha acima, mas toda a expressão está envolvida emexpectType<JSX.Element>(...). Se a posição de@ts-expect-errordeslocar uma linha, ou se o erro ocorrer na verdade na chamada deexpectTypeem vez do JSX, o teste falhará.
Armadilha em produção: quando uma atualização de versão do TypeScript causa ajustes sutis na posição do erro, muitos@ts-expect-errorpodem falhar em massa. A estratégia do Vue écolocar@ts-expect-errorcolado ao código assertado, e travar a versão do TypeScript no CI. Qualquer atualização do TS exige revalidação de todos os testes de tipo.
IsAnyeIsUnion:Prova de existência no nível de tipo
📎 packages-private/dts-test/defineComponent.test-d.tsx:1991-1993
expectType<IsAny<typeof props.foo>>(false)validarprops.foonão éany. Isto écontrato reverso: não apenas exige que o tipo esteja correto, mas também exige que o tipo "não possa degenerar paraany」。anyé um buraco negro do sistema de tipos, qualqueranyfará com que asserções subsequentes percam o significado.
📎 packages-private/dts-test/defineComponent.test-d.tsx:195-196
expectType<IsUnion<typeof props.jjj>>(true)validarjjjé um tipo união.jjjdeclarado como((arg1: string) => string) | ((arg1: string, arg2: string) => string), se o sistema de tipos o mesclar em uma única assinatura,IsUnionretornaráfalse, o teste falha.
Essas duas ferramentas protegem a "precisão do tipo" e não a "correção do tipo". Um tipo que degenera paraanyou uma união que é mesclada, na maioria dos cenários de uso "parece funcionar", mas perde as dicas da IDE e a verificação em tempo de compilação. Os testes de contrato devem travar essa precisão.
Contrato implícito da ordem de declaração
📎 packages-private/dts-test/defineComponent.test-d.tsx:1784-1801
Este comentário é extremamente crítico:code generated by tsc / vue-tsc, make sure this continues to work so we don't accidentally change the args order of DefineComponent。DefineComponenttem 13 parâmetros genéricos, a ordem éContrato público——vue-tsco tipo de componente gerado depende desta ordem. O teste usadeclare const MyButton: DefineComponent<...>para escrever explicitamente todos os 13 parâmetros, travando a ordem.
Este é o contrato mais facilmente negligenciado: a ordem dos parâmetros genéricos não é um "detalhe de implementação", mas sim a "ABI do código gerado". Qualquer PR que ajuste a ordem fará com quevue-tscgere.d.tsincompatível com o tipo em tempo de execução. O teste de contrato desempenha aqui o papel de "guardião de compatibilidade de ABI".
Contrato entre arquivos:componentInstance.test-d.tsxcomplemento de
componentInstance.test-d.tsxtem apenas 154 linhas, mas cobreComponentInstancetodas as formas de entrada do tipo utilitário:
📎 packages-private/dts-test/componentInstance.test-d.tsx:10-40
ComponentInstance<typeof CompSetup>extrair o tipo de instância do resultado dedefineComponent;ComponentInstance<typeof CompFunctional>extrair de componente funcional;ComponentInstance<typeof CompFunction>extrair de função pura. Os três devem derivar a classe baseComponentPublicInstance.
📎 packages-private/dts-test/componentInstance.test-d.tsx:71-116
Mais extremo é o "objeto puro semdefineComponentenvoltório":CompObjectSetup、CompObjectData、CompObjectNoPropsas três formas devem poder ser corretamente extraídas porComponentInstance. L113-114 é especialmente contra-intuitivo:CompObjectNoPropsnão tem declaraçãoprops, mascompObjectNoProps.testainda deriva parastring | undefined——isto é o fallback fornecido pela classe baseComponentPublicInstance.
📎 packages-private/dts-test/componentInstance.test-d.tsx:143-147
O teste#12751de L141 trava uma fronteira:__typeEmitso evento'update:visible'declarado deve ser exposto na instância comocomp['onUpdate:visible'](chave de string com dois pontos), e o tipo$propsé{ 'onUpdate:visible'?: (value?: boolean) => any }. L152-153 validacomp['$props']['$props']erro——previne autorreferência recursiva de tipo.
Resumo do capítulo
dts-testo diretório usa mais de 20 arquivos.test-d.tspara transformar "tipo como contrato de API" em testes automatizados regressivos. O mecanismo central tem três camadas:
1. Camada de ferramentas:expectType、expectAssignable、IsUnion、IsAnyfornece primitivas de asserção de tipo,@ts-expect-errorfornece capacidade de asserção reversa.
2. Camada de contrato:ExpectedPropsa interface fixa explicitamente "qual tipo deve ser derivado",propsa matriz de declaração esgota todas as formas de escrita, três posições de consumo (setup/render/TSX) validação cruzada.
3. Camada de backdoor:__typeProps、__typeEmits、__typeRefs、__typeElfornece uma escotilha de escape para restrições de tipo que não podem ser expressas em tempo de execução, enquanto trava a equivalência das duas sintaxes de emits.
Reflexões e autoavaliação do capítulo
Q1: Se removermosdefineComponent.test-d.tsxL168-170@ts-expect-error, mantendo apenasexpectType<number>(props.aaaa), o que acontecerá? Por que este teste "falha silenciosamente"?
Análise de referência:
props.aaaadeclarado como{ type: Number as PropType<number | undefined>, required: true as const }, seu tipo derivado énumber | undefined(porquePropType<number | undefined>inclui explicitamenteundefined)。
expectType<number>(props.aaaa)exigeprops.aaaaexatamentenumber. Como o tipo real énumber | undefined, esta linhapor si só reportará erro。@ts-expect-errora função é "esperar que aqui reporte erro, engoli-lo".
Se removermos@ts-expect-error, esta linha reportará erro diretamente, o teste falha——parece "mais rigoroso". Mas o problema é:Se alguma refatoração fizerprops.aaaarealmente se tornarnumber(correção de bug ou mudança de comportamento), esta linha não reportará mais erro, e após remover@ts-expect-erroro teste passará——neste momento o teste não consegue distinguir entre "tipo correto" e "tipo errado mas que por acaso não reporta erro".
Manter@ts-expect-errora forma de escrita étravamento bidirecional: tanto exige "o tipo atual énumber | undefined" (através de@ts-expect-errorengolirexpectType<number>o erro), quanto exige "o tipo não pode sernumber" (se se tornarnumber,@ts-expect-errorfalhará por não haver erro para engolir). Esta é a técnica central dos testes de contrato de tipo——usar "erro esperado" para travar "o tipo deve conter certo componente"。
📎 packages-private/dts-test/defineComponent.test-d.tsx:168-170
Q2: __typePropso teste de backdoor (L1803-1836) valida a restrição de tipo união condicional. Se mudarmosConditionalPropsde tipo união para{ color?: 'normal' | 'primary' | 'secondary' | 'white'; appearance?: 'normal' | 'outline' | 'text' }(ou seja, achatar todas as opções), como o teste falhará? O que isso ilustra sobre__typePropsqual restrição de design?
Análise de referência:
O tipo achatado permite qualquer combinação decoloreappearance, incluindocolor: 'white' + appearance: 'normal'. Mas o teste L1825-1826 exige explicitamente que esta combinaçãoreporte erro:
// @ts-expect-error
;<Comp color="white" appearance="normal" />Se o tipo for achatado, esta linha não reportará mais erro,@ts-expect-errorfalhará por "não haver erro para engolir". Ao mesmo tempo, L1823-1824<Comp color="white" />também mudará de "reportar erro" para "passar", também fazendo@ts-expect-errorfalhar.
Isso ilustra que__typePropsa restrição de design é:Ele deve preservar a semântica de "exclusão mútua de ramos" do tipo união。__typePropsnão é simplesmente "sobreposição de tipos", mas sim "usar o sistema de tipos para expressar restrições condicionais que props em tempo de execução não conseguem expressar". Se na implementaçãoPropsfizerPrettifyouOmitalgum mapeamento de transformação, pode quebrar a discriminabilidade dos ramos da união, fazendo com que a restrição falhe.
É também por isso que__typePropsos casos de teste usam a interseçãoCommonProps & ConditionalPropsmais simples, em vez de tipos mapeados mais "elegantes"——qualquer transformação de tipo adicional pode mascarar bugs.
Q3: DefineComponenta ordem dos 13 parâmetros genéricos de é explicitamente travada por L1784-1801. Se alguma refatoração trocar o 9º parâmetro (VNodeProps & AllowedComponentProps & ComponentCustomProps) com o 10º parâmetro (Readonly<ExtractPropTypes<{}>>), quais downstreams serão afetados? Por que o teste de contrato deve travar esta ordem?
Análise de referência:
DefineComponenta ordem dos parâmetros genéricos de é a "ABI" ao gerar o tipo de componente. Quando o usuário escreve emvue-tsc<script setup>gerará um tipodefineProps / defineEmits,vue-tscsimilar a L1999-2116, onde aCreateComponentPublicInstance<...>posiçãodos parâmetros genéricosdetermina o significado de cada parâmetro de tipo.
Se trocarmos o 9º e 10º parâmetros:
1. vue-tsco.d.tsgerado preencherá os parâmetros na ordem antiga, masDefineComponentinterpretará na nova ordem——VNodeProps & AllowedComponentProps & ComponentCustomPropsserá tratado como tipo props,Readonly<ExtractPropTypes<{}>>será tratado como atributo VNode. O resultado éOs tipos de props dos componentes do usuário estão todos desalinhados。
2. L1786-1800 dedeclare const MyButton: DefineComponent<...>irá gerar erro diretamente — porque{}eVNodeProps & ...são incompatíveis.
3. L1999-2116 deErrorMessagetipo (simulandovue-tscresultado gerado) também irá gerar erro.
O valor de os testes de contrato fixarem a ordem está em:ele eleva a «ordem dos parâmetros genéricos» de «detalhe de implementação» para «contrato público». Qualquer PR que ajuste a ordem fará L1786-1800 falhar imediatamente, impedindo que mudanças incompatíveis entrem na release.
📎 packages-private/dts-test/defineComponent.test-d.tsx:1784-1801
Este é o valor mais subestimado dos testes de contrato de tipo: o que eles protegem não é «se o tipo está correto», mas sim «a estabilidade da interface do sistema de tipos». A ordem dos parâmetros genéricos,@ts-expect-errora posição de,IsAnyo valor de retorno de, todos fazem parte do «ABI de tipos».
Os testes de contrato de tipo resolvem «se a superfície da API corresponde ao esperado». Mas tipos são apenas metade da engenharia Vue — a outra metade é «como o usuário valida em tempo real o comportamento dessas APIs no navegador». O próximo capítulo entrará no SFC Playground, para ver como Vue empacota compilador, runtime e sistema de tipos em um ambiente de depuração em tempo real dentro do navegador, permitindo que o usuário veja o artefato de compilação e o resultado de execução no instante em que altera o código.
Os testes de contrato protegem não apenas «se o tipo está correto», mas também «se o tipo é preciso» (IsAny/IsUnion), «se a ordem dos parâmetros genéricos é estável» (DefineComponent13 parâmetros), «independência de renderizador» (__typeElnão restrito aElement). Uma vez que essas restrições sejam quebradas, as dicas de IDE do lado do usuário,vue-tscos tipos gerados irão sofrer drift. E a estabilidade do contrato de tipo, em última análise, deve servir à experiência diária de depuração do desenvolvedor — no próximo capítulo entraremos nopackages-private/sfc-playground, para ver como um Playground puramente frontend completa o ciclo fechado de compilação SFC e pré-visualização em tempo real dentro do navegador.
Gostou deste capítulo? Crie um livro para seu repositório privado
Arquitetura local-first em Tauri 2 + Rust. 100% offline e seguro, zero upload de código. Leitura em painel duplo com âncoras imutáveis de commit.
⚡ Tauri 2 · Rust Core · 100% Offline e Privado · Testado em 1M+ linhas
Capítulo 7: SFC Playground: subsistema de compilação e depuração em tempo real no navegador
No capítulo anterior usamos mais de 20.test-d.tsarquivos para fixar «tipo como contrato de API» no CI. Mas contratos de tipo só respondem «como é a superfície da API», eles não conseguem responder «como este trecho de SFC é compilado» nem «se o resultado de renderização é consistente no modo SSR». Para responder às duas últimas perguntas, a equipe Vue precisa de um sandbox capaz de executar todo o pipeline de compilação no navegador — este é opackages-private/sfc-playground. Ele tem diferenças essenciais em relação aos pacotes públicos sobpackages/:package.jsonem"private": truee"version": "0.0.0" 📎 packages-private/sfc-playground/package.json:2-4, significa que ele nunca será publicado no npm, sendo apenas uma ferramenta oficial de depuração. Em suas dependências,vueaponta paraworkspace:* 📎 packages-private/sfc-playground/package.json:19, ou seja, o artefato de build do código-fonte local, e não a versão estável no npm — isso faz do Playground naturalmente uma «demonstração viva do commit atual». Este capítulo foca em três questões: como a entrada é inicializada, como o Header impulsiona a troca de estado, e como constantes de tempo de build são injetadas.
I. Minimalismo da entrada: o contrato de inicialização de main.ts e ReplStore
Modelo intuitivo
main.tstem apenas 9 linhas, como um «script de autoteste na inicialização»: antes de a aplicação Vue ser montada, primeiro insere emwindowuma configuração global, dizendo ao Vue DevTools «qual app selecionar por padrão». Sem esse passo, o DevTools ao abrir enfrentaria múltiplas instâncias de app (o próprio Playground + o código executado no REPL do usuário) e não conseguiria focar automaticamente, degradando a experiência de depuração para troca manual.
Estrutura de dados e efeito colateral global
main.tsO núcleo de não écreateApp, mas a escrita poluidora emwindow:
📎 packages-private/sfc-playground/src/main.ts:4-7
// @ts-expect-error Custom window property
window.VUE_DEVTOOLS_CONFIG = {
defaultSelectedAppId: 'repl',
}Aqui há dois detalhes de engenharia dignos de nota:
1. @ts-expect-errorem vez de@ts-ignore:windowo tipo padrão deWindow & typeof globalThisnão possui o campoVUE_DEVTOOLS_CONFIG. Usar@ts-expect-errorsignifica «eu sei que aqui vai dar erro, e exijo que dê erro» — se no futuro algum@types/*adicionar esse campo,@ts-expect-errorirá gerar erro inverso por «não produzir erro», lembrando o autor a remover esse comentário. Isso está em linha com a abordagem dos testes de contrato de tipo do capítulo anterior:usar o sistema de tipos para proteger a intenção, não para mascarar problemas。
2. defaultSelectedAppId: 'repl'a convenção de string de: este'repl'deve ser completamente idêntico ao id usado internamente por@vue/replao criar o app. É um contrato literal entre pacotes, sem nenhuma proteção de restrição de tipo — uma vez que@vue/replaltere o id, a seleção padrão do DevTools do Playground falhará silenciosamente.
Step-by-Step: do HTML à montagem
O fluxo de execução é extremamente curto, mas cada passo tem restrições implícitas:
1. O navegador carregaindex.html, que contém<div id="app">(não fornecido neste material, masmount('#app')pode-se inferir).
2. Resolução do grafo de módulos:main.tsno topo deimport App from './App.vue' 📎 packages-private/sfc-playground/src/main.ts:2dispara@vitejs/plugin-vuea compilação SFC de.
3. Ordem crítica:window.VUE_DEVTOOLS_CONFIGdeve ser escrito antes decreateApp(App).mount('#app') 📎 packages-private/sfc-playground/src/main.ts:9. Porque o hook do DevTools é registrado dentro decreateApp, escrever a configuração após o mount não afetará a seleção inicial.
4. mount('#app')disparaApp.vueo setup de, criando entãoReplStore(emApp.vue, não incluído neste material).
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 --> mountReflexões de design e armadilhas
main.tsO minimalismo de é intencional:empurrar toda a complexidade paraApp.vueeReplStoreA entrada assume apenas duas responsabilidades: "injeção de efeitos colaterais globais + montagem". Nenhuma lógica de negócio deve aparecer aqui. Esta é uma escolha de design do Playground como "ferramenta de depuração" e não como "produto" — ele não precisa de compatibilidade com SSR, não precisa de múltiplas entradas, não precisa de lazy loading.
Armadilhas em produção:window.VUE_DEVTOOLS_CONFIGéSingleton global. Se o Playground for incorporado em outra página que também usa DevTools (como em cenário de iframe), o último a escrever sobrescreve o anterior. Como o Playground geralmente é implantado de forma independente, esse risco é aceito.
---
II. Header.vue: estado derivado por computed e fluxo de dados unidirecional via emit
Modelo intuitivo
Header.vueé o "painel de controle" do Playground — seleção de versão, alternância PROD/DEV, chave SSR, alternância de tema, compartilhamento, download. Ele próprionão mantém nenhum estado de negócio, todo estado vem deprops.storee props booleanas, todas as alterações são reportadas ao componente pai viaemit. Sem essa restrição de "componente burro + propagação de eventos", o Header se tornaria um ponto crítico de estado disperso, e os efeitos colaterais da troca de versão e da alternância de SSR não poderiam ser gerenciados de forma centralizada.
Análise da estrutura de dados e campos
A definição de props do Header é a chave para entender suas responsabilidades:
📎 packages-private/sfc-playground/src/Header.vue:13-19
const props = defineProps<{
store: ReplStore
prod: boolean
ssr: boolean
autoSave: boolean
theme: 'dark' | 'light'
}>()As cinco props se dividem em duas categorias:
store: ReplStore: referência ao único contêiner de estado, vindo de@vue/repl. O Header lê através delestore.loading、store.vueVersion、store.typescriptVersion, e escreve diretamente emstore.vueVersion。- quatro props booleanas/literais:
prod、ssr、autoSave、theme. Elas sãoestado controlado, o Header apenas lê, não escreve; alterações devememit。
a lista de emits correspondente📎 packages-private/sfc-playground/src/Header.vue:20-28:
const emit = defineEmits([
'toggle-theme',
'toggle-ssr',
'toggle-prod',
'toggle-autosave',
'reload-page',
])Atençãotoggle-themeembora sejatoggleDark()internamenteemit, mastoggle-ssr/toggle-prod/toggle-autosaveé usado diretamente no template$emito📎 packages-private/sfc-playground/src/Header.vue:102-118. Essa mistura é um estilo comum do Vue 3<script setup>:quando é necessário efeito colateral, usa-se emit como função; para repasse puro, usa-se o template$emit。
Passo a passo: exibição e troca de versão
Cenário: o usuário abre o Playground, o Header precisa exibir a versão atual do Vue.
Passo 1: computed deriva o texto de exibição
📎 packages-private/sfc-playground/src/Header.vue:30-37
const vueVersion = computed(() => {
if (store.loading) {
return 'loading...'
}
return store.vueVersion || `@${__COMMIT__}`
})Aqui há três níveis de prioridade:loadingestado →'loading...'; usuário selecionou explicitamente uma versão →store.vueVersion; caso contrário →@${__COMMIT__}(hash curto do commit atual).__COMMIT__é uma constante injetada em tempo de build, detalhada na próxima seção.
Passo 2: vinculação bidirecional do VersionSelect
📎 packages-private/sfc-playground/src/Header.vue:88-88
<VersionSelect
:model-value="vueVersion"
@update:model-value="setVueVersion"
pkg="vue"
label="Vue Version"
>Note que aquinão foi usadov-model, mas explicitamente separado em:model-value + @update:model-value. A razão é quevueVersioné computed (somente leitura), não pode ser vinculado bidirecionalmente de forma direta; é necessário usarsetVueVersionessa função setter para escreverstore.vueVersion:
📎 packages-private/sfc-playground/src/Header.vue:39-41
async function setVueVersion(v: string) {
store.vueVersion = v
}
function resetVueVersion() {
store.vueVersion = null
}setVueVersiondeclarado comoasyncmas internamente semawait— isso é legado histórico ou intencional? Presume-se que seja para alinhar com a semântica de carregamento assíncrono deVersionSelect(trocar de versão dispara carregamento remoto), mantendo a consistência da interface.
Passo 3: comparação com a versão TypeScript
📎 packages-private/sfc-playground/src/Header.vue:76-80
<VersionSelect
v-model="store.typescriptVersion"
pkg="typescript"
label="TypeScript Version"
/>A versão TypeScript usouv-model, porquestore.typescriptVersioné uma propriedade comum gravável, não precisa de encapsulamento com computed.O mesmo componente usa dois modos de vinculação no mesmo template, o que é a expressão visual de "controlado vs não controlado".
Alternância de tema: combinação de efeitos colaterais e emit
📎 packages-private/sfc-playground/src/Header.vue:58-66
function toggleDark() {
const cls = document.documentElement.classList
cls.toggle('dark')
localStorage.setItem(
'vue-sfc-playground-prefer-dark',
String(cls.contains('dark')),
)
emit('toggle-theme', cls.contains('dark'))
}Esta função faz três coisas: manipula a classe do DOM, persiste no localStorage, emite notificação ao componente pai.Note que ela não altera diretamenteprops.theme— porque props são somente leitura, o componente pai só atualizatoggle-themeapós recebertheme, o que por sua vez impulsiona o texto de:titleno template📎 packages-private/sfc-playground/src/Header.vue:123。
Há um design sutil aqui:A manipulação de classe do DOM e o estado reativo do Vue são dois caminhos independentes。document.documentElement.classList.toggle('dark')altera diretamente o DOM, enquanto a propthemeé atualizada via Vue. Se os dois não estiverem sincronizados (por exemplo, o componente pai recusa a atualização), a UI apresentará inconsistência como "classe já alternada mas texto do title inalterado". Na prática, o componente pai sempre aceita o emit, então o problema não se manifesta.
Lógica oculta: ramo metaKey do copyLink
📎 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.')
}Esta é umaporta dos fundos para desenvolvedores: emplay.vuejs.org, segurar Cmd e clicar no botão de compartilhar redireciona paralocalhost:5173(servidor de dev local), levando junto o hash da URL atual. O hash codifica o estado completo do REPL (código-fonte, versão, opções), portanto a depuração local consegue reproduzir problemas de produção. O comentário// hidden logic for going to local debug from play.vuejs.org 📎 packages-private/sfc-playground/src/Header.vue:47-56marca explicitamente que esta é uma funcionalidade oculta intencional.
resetVueVersion()é chamado antes do redirecionamento, definindostore.vueVersioncomonull, garantindo que a depuração local use o commit atual em vez da versão selecionada em produção.
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)"]Reflexões de design e armadilhas
Armadilha 1:navigator.clipboardpermissões e contexto de segurança de。copyLinknão tem try/catch📎 packages-private/sfc-playground/src/Header.vue:47-56. Em contexto não-HTTPS ou quando o usuário nega permissão de área de transferência,writeTextserá rejeitado, causando Promise rejection não capturada. O Playground é implantado em HTTPS, o risco é aceito, mas esta é uma típica "armadilha de ambiente de produção".
Armadilha 2:toggleDarkchave de localStorage de。'vue-sfc-playground-prefer-dark'é literal de string, sem extração de constante. Se no futuro for preciso alterar a chave, será necessário buscar globalmente.
Armadilha 3:currentCommitcomparação entrevueVersione. No template:class="{ active: vueVersion === \@${currentCommit}\ }" 📎 packages-private/sfc-playground/src/Header.vue:88-88Usar concatenação de strings para comparar. Se__COMMIT__a injeção falhar (tornar-seundefined), aqui tornar-se-á'@undefined', nunca correspondendo. A confiabilidade da injeção de constantes em tempo de build determina diretamente a correção da UI — este é precisamente o tema da próxima secção.
---
III. Injeção de constantes em tempo de build: as responsabilidades duplas de __COMMIT__ e copyVuePlugin
Modelo intuitivo
vite.config.tsé a «oficina de montagem» do Playground: executa em tempo de buildgit rev-parsepara obter o hash do commit, através dedefinetransforma-o na constante global__COMMIT__; simultaneamente, através de um plugin personalizado, copia os artefactos ESM de browser empackages/vue/dist/para o diretório de artefactos do Playground. Sem este passo, o Playground não conseguiria carregar no browser «o runtime Vue do commit atual» — dependeria apenas da versão estável do npm, perdendo o sentido de «demonstração ao vivo».
Estruturas de dados e constantes em tempo de build
📎 packages-private/sfc-playground/vite.config.ts:7-9
const commit = spawnSync('git', ['rev-parse', '--short=7', 'HEAD'])
.stdout.toString()
.trim()spawnSyncexecuta sincronamente o comando git,--short=7obtém o hash curto de 7 caracteres. A execução síncrona é intencional:o ficheiro de configuração precisa do valor decommitdurante o carregamento do módulo, e a assincronia perturbaria a ordem de resolução da configuração do Vite.
📎 packages-private/sfc-playground/vite.config.ts:23-26
define: {
__COMMIT__: JSON.stringify(commit),
__VUE_PROD_DEVTOOLS__: JSON.stringify(true),
},defineé o mecanismo desubstituição de textodo Vite: todas as ocorrências de__COMMIT__no código-fonte são substituídas pelo resultado deJSON.stringify(commit)(ou seja, um literal de string entre aspas).JSON.stringifyé necessário — se escrevêssemos diretamentecommit, após a substituição tornar-se-ia o identificador nuabc1234, tratado como nome de variável e não como string.
__VUE_PROD_DEVTOOLS__: trueé outra constante crucial: permite que abuild de produçãodo Vue também preserve o suporte a DevTools. Por defeito, a build de produção remove o hook de DevTools para reduzir tamanho, mas o Playground precisa de depurar código do utilizador, pelo que é forçado a ativar.
Passo a passo: o transporte de artefactos do 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`)
},
}
}Análise ponto a ponto dos aspetos-chave:
1. generateBundlehook: executa após o Rollup gerar o bundle e antes de escrever no disco. Neste momento pode-seemitFileinserir ficheiros adicionais nos artefactos.
2. import.meta.dirname: versão ESM de__dirnamefornecida pelo Node 20.11+. O caminho../../packagessobe depackages-private/sfc-playground/até à raiz do repositório, depois entra empackages/。
3. verificação de existência + erro explícito: sevue.esm-browser.jsnão existir, lança um erro com instruções de correçãoRun "nr build vue -f esm-browser" first.. Este é umexemplo exemplar de experiência de programador— a mensagem de erro diz diretamente como corrigir.
4. Cinco artefactos:vueversão completa/runtime × dev/prod, maisserver-renderer. Estes cinco ficheiros são precisamente o conjunto de candidatos a import dinâmico do Playground no browser, correspondendo à alternância de versão e ao interruptor SSR no Header.
Porque estes cinco?A versão completa (com compilador) serve o cenário de «compilação em runtime»; a versão runtime serve o cenário de «pré-compilação»; dev/prod correspondem à alternância PROD/DEV no Header; server-renderer corresponde ao interruptor SSR. Estes cinco ficheiros constituem a «matriz de runtime Vue» do Playground.
O fluxo de dados completo da alternância de versão
Ligando osetVueVersiondo Header aos artefactos do 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["实时预览"]Atenção ao valor especial@${__COMMIT__}: corresponde aos artefactos locais copiados pelo copyVuePlugin, não ao CDN. É por isso que o Playground tem de copiar os artefactos de build de browser do Vue —a opção «This Commit» precisa de ficheiros locais。
Reflexões de design e armadilhas
Armadilha 1:spawnSynctratamento de falha. Se o diretório atual não for um repositório git (por exemplo, extraído de um tarball),spawnSyncdevolve um código de saída não nulo,stdoutfica vazio,committorna-se string vazia. Neste caso__COMMIT__é substituído por"", e no Header@${currentCommit}torna-se'@'. Não há tratamento de erro explícito.
Armadilha 2:optimizeDeps.exclude: ['@vue/repl'] 📎 packages-private/sfc-playground/vite.config.ts:27-29. O Vite, por defeito, pré-empacota dependências para acelerar o arranque a frio, mas@vue/replé excluído. A razão é que@vue/replusa internamente import dinâmico e workers, e o pré-empacotamento quebraria esses mecanismos. Este é um problema comum no ecossistema Vite de «conflito entre pré-empacotamento e carregamento dinâmico».
Armadilha 3:script.fsconfiguração 📎 packages-private/sfc-playground/vite.config.ts:13-19。@vitejs/plugin-vuea opçãoscript.fspermite que o bloco<script>do SFC leia ficheiros através defs. Aqui passam-sefs.existsSyncefs.readFileSync, para suportar a análise de instruçõesimportno SFC (por exemplo,import x from './foo'precisa de verificar se o ficheiro existe).Esta é a chave para o Playground conseguir simular a resolução completa de módulos no browser— injeta a capacidade fs do Node na fase de resolução do compilador.
---
Reflexão de design: os compromissos arquiteturais do Playground
Ligando as três subsecções, a arquitetura do Playground segue um princípio claro:separar «estado» de «efeitos secundários», separar «tempo de build» de «tempo de execução»。
main.tsapenas injeta efeitos secundários globais, sem tocar no estado de negócio.Header.vueé um componente puramente de apresentação, com estado a entrar via props e a sair via emit.vite.config.tsfixa a informação de tempo de build «commit atual» como constante, apenas de leitura em tempo de execução.
Esta separação traz um benefício direto:o Playground pode ser embutido em qualquer aplicação Vue(por exemplo, exemplos incorporados em sites de documentação), bastando fornecerstoree quatro props booleanas.
O custo éEstado disperso:storeEm@vue/repl, o estado booleano está no componente pai, a classe DOM está emdocument.documentElement, e ainda há uma cópia no localStorage. Quatro locais de estado precisam ser sincronizados manualmente, e qualquer dessincronização causará inconsistência na UI.
Outro trade-off éabrir mão da compatibilidade com SSR。main.tsacessar diretamentewindow,Header.vuedotoggleDarkacessar diretamentedocument. O Playground é uma aplicação puramente CSR, não precisa considerar renderização no servidor.
---
Resumo do capítulo
Este capítulo analisoupackages-private/sfc-playgroundos três arquivos centrais:
1. main.ts: entrada de 9 linhas, o núcleo é a ordem de injeção dewindow.VUE_DEVTOOLS_CONFIG— deve ser antes demount.
2. Header.vue: através decomputedderivavueVersion, através deemitreporta todas as mudanças de estado.copyLinkometaKeybranch é um backdoor oculto de depuração local.
3. vite.config.ts:spawnSyncobtém o hash do commit,defineinjeta__COMMIT__,copyVuePluginpara mover os cinco artefatos de build do Vue para o navegador para o diretório de artefatos do Playground.
O fio condutor que atravessa os três éa fronteira entre constantes de tempo de build e estado de tempo de execução:__COMMIT__é um fato somente-leitura de tempo de build,store.vueVersioné uma escolha mutável de tempo de execução, ovueVersioncomputed do Header unifica ambos em uma única string de exibição.
Reflexões e autoavaliação do capítulo
Q1: Se movermos a atribuição demain.tsemwindow.VUE_DEVTOOLS_CONFIGpara depois decreateApp(App).mount('#app'), o que acontecerá? Por quê?
Análise de referência:window.VUE_DEVTOOLS_CONFIGé a configuração lida pelo Vue DevTools ao registrar o hook dentro decreateAppregistrará imediatamente📎 packages-private/sfc-playground/src/main.ts:4-9。createApp, nesse momento o DevTools lerá__VUE_DEVTOOLS_GLOBAL_HOOK__para decidir qual app selecionar por padrão. Se a atribuição ocorrer depois dedefaultSelectedAppId, o DevTools já terá concluído a primeira seleção de app, a configuração não terá efeito, e o usuário precisará alternar manualmente no DevTools para omountapp. Mais sutil ainda: comorepltambém cria um app internamente, uma atribuição tardia pode fazer o DevTools selecionar por padrão o próprio Playground em vez do REPL do usuário, sendo necessário alternar manualmente ao depurar o código do usuário. Isso demonstra a importância da "ordem de injeção de efeitos colaterais globais" em ferramentas de depuração.@vue/replo
Q2: Header.vueopera simultaneamente a classe DOM, o localStorage e o emit, mas não modifica diretamentetoggleDark(). Se o componente pai, ao receber o eventoprops.theme, recusar atualizar otoggle-themeprop, que inconsistência de UI aparecerá? Como localizar no nível do código-fonte?themeAnálise de referência
em:toggleDark()chama diretamente📎 packages-private/sfc-playground/src/Header.vue:58-66, o que altera imediatamente adocument.documentElement.classList.toggle('dark')class no DOM, disparando a troca de variáveis CSS (verdarka regra📎 packages-private/sfc-playground/src/Header.vue:186-186). Mas o texto.dark navno template:titledepende de📎 packages-private/sfc-playground/src/Header.vue:123, se o componente pai não atualizar, o title permanecerá no valor antigo. Método de localização: inspecionar no DevTools do navegador se a class deprops.themee o atributo title do botão se contradizem. A causa raiz é que "efeitos colaterais no DOM" e "estado reativo do Vue" seguem dois caminhos independentes, sem uma única fonte de dados.<html>em
Q3: copyVuePluginfaz uma verificação degenerateBundlepara cada arquivo, lançando um erro com instruções de correção quando ausente. Se removermos essa verificação e fizermosfs.existsSyncdiretamente, o que acontecerá em um ambiente de CI (sem construir o vue previamente)? Como a mensagem de erro enganaria o desenvolvedor?fs.readFileSyncAnálise de referência
: após remover a verificação,lançaráfs.readFileSync. Esse erro apenas informa ao desenvolvedor que "o arquivo não existe", mas não informa que "é necessário executarENOENT: no such file or directory, open '.../packages/vue/dist/vue.esm-browser.js' 📎 packages-private/sfc-playground/vite.config.ts:32-63primeiro". Em um ambiente de CI, o desenvolvedor pode erroneamente pensar que é um erro de configuração de caminho, problema de permissão ou submódulo git não inicializado, desperdiçando muito tempo investigando. Onr build vue -f esm-browserdo código original vincula o "sintoma" à "ação de correção", sendo um detalhe crucial de design de experiência do desenvolvedor. Isso também explica por que o script de build do Playground deve ter uma ordem de dependência clara em relação ao script de build do núcleo do Vue.throw new Error(\${basename} not built. Run "nr build vue -f esm-browser" first.\)O próximo capítulo entrará em
---
, para ver como o Vue visualiza os produtos intermediários do compilador (AST, resultados de transformação, geração de código), permitindo que o desenvolvedor observe passo a passo cada transformação do template até a função de renderização. Diferente do "black box ponta a ponta" do Playground, o Template Explorer é uma "sonda white box".packages-private/template-explorerAté aqui, vimos como o SFC Playground traz o pipeline de compilação para o navegador: inicialização da entrada, troca de estado do Header e injeção de constantes de tempo de build juntos formam um sandbox depurável em tempo real. Mas a perspectiva do Playground é sempre "a compilação e execução do SFC inteiro", ele não responde diretamente "o que o compilador realmente fez com uma determinada expressão de template". O próximo capítulo entrará no Template Explorer, para ver como ele expõe linha a linha os resultados de compilação de
e@vue/compiler-dom, usando SourceMapConsumer para estabelecer o mapeamento entre código-fonte e artefato, transformando o comportamento interno do compilador em uma sonda observável e reversível.@vue/compiler-ssr← Capítulo anterior: Capítulo 6
Gostou deste capítulo? Crie um livro para seu repositório privado
Arquitetura local-first em Tauri 2 + Rust. 100% offline e seguro, zero upload de código. Leitura em painel duplo com âncoras imutáveis de commit.
⚡ Tauri 2 · Rust Core · 100% Offline e Privado · Testado em 1M+ linhas
Projeto pertencente: vuejs/core
No capítulo anterior, vimos como o SFC Playground encapsula toda a cadeia de "entrada SFC → compilação no navegador → pré-visualização em tempo real" numa caixa negra: o programador vê o resultado final da renderização, mas não vê o que o compilador faz pelo meio. Quando se escreve uma diretiva personalizada no template, ou quando se ativa hoistStatic e o resultado passa a incluir uma série de variáveis _hoisted_1, o Playground não consegue responder a "porque é que o compilador gera isto?". O Template Explorer tem precisamente a posição oposta: expõe por completo o resultado da compilação do @vue/compiler-dom e do @vue/compiler-ssr, a AST, as marcações de erro e o mapeamento de posições do código-fonte para o resultado. O seu núcleo não é "executar", mas "observar". Este capítulo organiza-se em torno de três ficheiros: index.ts trata da chamada de compilação e do mapeamento bidirecional de SourceMap, options.ts usa reactive para gerir dezenas de CompilerOptions e impulsionar a UI, theme.ts personaliza o tema do editor Monaco.
Um, chamada de compilação e mapeamento bidirecional de SourceMap: index.ts
Modelo intuitivo
O Template Explorerindex.tsé como uma "máquina de tradução bidirecional": à esquerda entra o template, à direita sai a função de renderização. Mas tem uma capacidade extra em relação a uma máquina de tradução — quando colocas o cursor numa linha à esquerda, a direita realça o resultado correspondente; inversamente, se colocares o cursor à direita, a esquerda realça o template correspondente. Sem o mapeamento de SourceMap, esta ferramenta degeneraria em duas caixas de texto lado a lado, e o programador só poderia comparar a olho, sem conseguir estabelecer a cadeia causal "linha X do template → linha Y do resultado".
Estruturas de dados e disposição em memória
index.tsnão tem Structs complexas, mas tem algumas variáveis de estado críticas ao nível do módulo, que determinam o comportamento de toda a ferramenta:
lastSuccessfulCodeelastSuccessfulMapsão a cache do resultado da compilação📎 packages-private/template-explorer/src/index.ts:74-75. A primeira é uma string, a segunda éSourceMapConsumer | undefined. Nota quelastSuccessfulMapcomeça comoundefined, e só é atribuído quando a compilação é bem-sucedida emapexiste📎 packages-private/template-explorer/src/index.ts:99-100. Esteundefinedestado é a condição de guarda para toda a lógica de mapeamento do cursor a seguir — se a compilação falhar, a funcionalidade de mapeamento desativa-se silenciosamente, em vez de lançar uma exceção.
PersistedStateA interface define a forma do estado persistido em localStorage e no hash do URL📎 packages-private/template-explorer/src/index.ts:26-30:src(código-fonte do template),ssr(se está em modo SSR),options(opções do compilador). Aqui há uma decisão de design importante:optionso tipo de é oCompilerOptionscompleto, mas na persistência real só se guardam "os itens diferentes dos valores predefinidos"; esta lógica de recorte é feita emreCompile.
sharedEditorOptionssão as opções de construção partilhadas pelos dois editores📎 packages-private/template-explorer/src/index.ts:26-30:fontSize: 14、scrollBeyondLastLine: false、renderWhitespace: 'selection'、minimap.enabled: false. O minimap está desligado porque o template e o resultado costumam ter apenas algumas dezenas de linhas, e o minimap acaba por ocupar espaço horizontal.
Step-by-Step Walkthrough
Cenário: o utilizador abre a página, introduz<div>{{ msg }}</div>, e depois move o cursor.
Primeiro passo: inicialização e restauro de estado. window.inité o ponto de entrada global📎 packages-private/template-explorer/src/index.ts:41. Primeiro regista e ativa o tema personalizado📎 packages-private/template-explorer/src/index.ts:44-45, depois tenta restaurar o estado a partir do hash do URL ou do localStorage📎 packages-private/template-explorer/src/index.ts:49-56. Nota a ordem de descodificação: primeiroatobe depoisescape, e em seguidadecodeURIComponent. Se a análise do hash falhar, faz fallback paralocalStorage.getItem('state'), e depois fallback para{}. Se todo o JSON.parse falhar, limpa o localStorage e imprime um aviso📎 packages-private/template-explorer/src/index.ts:57-64。
Depois de restaurar o estado, há um detalhe fácil de ignorar:delete persistedState.options?.nodeTransforms 📎 packages-private/template-explorer/src/index.ts:69. O comentário explica a razão — as funções não podem ser serializadas, por isso na persistêncianodeTransformsperde-se, e ao restaurar, se ficar um objeto vazio residual, isso provoca comportamento anómalo no compilador. Esta é a armadilha clássica de "persistir campos não serializáveis".
Segundo passo: núcleo da compilaçãocompileCode。Este é o coração de toda a ferramenta📎 packages-private/template-explorer/src/index.ts:76-106. Primeiroconsole.clear(), depois, conformessrMode.value, escolhessrCompileoucompile 📎 packages-private/template-explorer/src/index.ts:80. Nota os parâmetros da chamada acompileFn: expandecompilerOptions, forçafilename: 'ExampleTemplate.vue'、sourceMap: true, e injetaonErrorcallback para recolher erros📎 packages-private/template-explorer/src/index.ts:82-89。
Aqui há uma decisão de design:filenameestá fixado em'ExampleTemplate.vue'. Este valor, nas chamadas subsequentes ageneratedPositionFor, tem de corresponder exatamente a📎 packages-private/template-explorer/src/index.ts:189, caso contrário a consulta ao SourceMap devolve um resultado vazio. Este é um contrato implícito — as duas strings têm de ser iguais, mas nenhum sistema de tipos o garante.
Depois de concluída a compilação, os erros são convertidos para o formato de marker do Monaco e definidos no editor📎 packages-private/template-explorer/src/index.ts:91-95。formatErrorconverteCompilerErrordelocpara ostartLineNumber/startColumn/endLineNumber/endColumn 📎 packages-private/template-explorer/src/index.ts:108-119do Monaco. Notaerrors.filter(e => e.loc)— só os erros com informação de posição são marcados; os erros semloc(como erros de configuração global) só são impressos na consola.
Terceiro passo: criação do SourceMap.Depois de a compilação ser bem-sucedida,lastSuccessfulMap = new SourceMapConsumer(map!) 📎 packages-private/template-explorer/src/index.ts:99, e logo a seguir chamacomputeColumnSpans() 📎 packages-private/template-explorer/src/index.ts:100。computeColumnSpansé uma API fundamental desource-map-js: pré-calcula a amplitude de colunas de cada segmento de mapeamento, de modo a quegeneratedPositionFordevolva olastColumncampo disponível. Sem este passo, o mapeamento inverso só consegue localizar a coluna inicial, sem conseguir realçar todo o intervalo do token.
Quarto passo: mapeamento bidirecional do cursor.Quando o utilizador, noeditor de código-fonte, move o cursor, disparaeditor.onDidChangeCursorPosition 📎 packages-private/template-explorer/src/index.ts:184. O callback, após 100ms de debounce, chamalastSuccessfulMap.generatedPositionFor({ source: 'ExampleTemplate.vue', line, column: column - 1 }) 📎 packages-private/template-explorer/src/index.ts:188-192. Notacolumn - 1: os números de coluna do Monaco começam em 1, enquanto os do SourceMap começam em 0. Oposdevolvido, se tiverlineecolumn, cria um decorador no editor de saída para realçar o intervalo correspondente📎 packages-private/template-explorer/src/index.ts:194-206, e faz scroll até essa posição📎 packages-private/template-explorer/src/index.ts:207-210。
O mapeamento inverso está emoutput.onDidChangeCursorPositionem📎 packages-private/template-explorer/src/index.ts:223. ChamaoriginalPositionFor 📎 packages-private/template-explorer/src/index.ts:227-230, mas com uma guarda adicional: ignorapos.line === 1 && pos.column === 0de "mock location"📎 packages-private/template-explorer/src/index.ts:231-237. Este guard é crucial — certos códigos gerados pelo compilador (comoimportinstruções ou funções helper) não têm posição de template correspondente, e o SourceMap retorna{ line: 1, column: 0 }como placeholder. Se não for ignorado, colocar o cursor nessas linhas irá destacar erroneamente a primeira linha do template.
Quinto passo: persistência de estado. reCompilenão apenas dispara a compilação, mas também é responsável por gravar o estado atual no localStorage e no URL hash📎 packages-private/template-explorer/src/index.ts:121-146. Na persistência há uma lógica de filtragem: percorrecompilerOptions, salvando apenas itens que "não são objetos e não são iguais ao valor padrão"📎 packages-private/template-explorer/src/index.ts:125-133. Isso explica por quebindingMetadataopções desse tipo de objeto não são persistidas — é muito complexo, e o valor padrão já é suficiente para demonstração.
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 --> setOutputReflexões de design e armadilhas em produção
Por que usarsource-map-jsem vez desource-map? source-mapé a biblioteca original da Mozilla, tem tamanho grande e depende de WASM (versões novas).source-map-jsé uma implementação pura em JS, de tamanho pequeno, adequada para ambiente de navegador. O Template Explorer, sendo uma ferramenta puramente frontend, escolhersource-map-jsé razoável📎 packages-private/template-explorer/package.json:15。
Escolha do delay do debounce.O debounce do editor de código-fonte tem padrão de 300ms📎 packages-private/template-explorer/src/index.ts:271, enquanto o debounce do movimento do cursor é de 100ms📎 packages-private/template-explorer/src/index.ts:215. Essa diferença é intencional: compilar é uma operação pesada, 300ms evita disparos frequentes; mover o cursor é uma operação leve, 100ms garante sensação de resposta. Mas 100ms ainda pode causar piscadas no destaque ao mover o cursor rapidamente — é um trade-off aceitável.
window.initMontagem global de. Observe quewindow.initewindow.monacoestão ambos montados no global📎 packages-private/template-explorer/src/index.ts:19-23. Isso porque o editor Monaco é carregado assincronamente via CDNloader.js, e após o carregamento chamawindow.init. Esse padrão de "callback global" é o uso padrão do Monaco em ambientes não modularizados, mas é incompatível com formas modernas de build ESM.
---
Dois, painel de opções orientado por reactive: options.ts
Modelo intuitivo
options.tsfunciona como um "painel de console": há mais de uma dúzia de switches e radio buttons, cada um correspondendo a um comportamento do compilador. Ao alternar qualquer switch, o artefato de compilação à direita muda imediatamente. Sem esse módulo, o desenvolvedor só poderia alterar os parâmetros da chamada decompileno código-fonte e recompilar, sem conseguir comparar em tempo real os efeitos de diferentes opções.
Estrutura de dados e layout de memória
options.tsO núcleo de
ssrModesão três exportações:ref(false) 📎 packages-private/template-explorer/src/options.ts:5é umcompilerOptions. Ele é independente decompile vs ssrCompile, porque o modo SSR alterna a própria função de compilação (
defaultOptions), e não as opções de compilação.CompilerOptionsé um objeto completo de📎 packages-private/template-explorer/src/options.ts:5-27. Ele define os valores padrão de todas as opções, incluindomode: 'module'、prefixIdentifiers: false、hoistStatic: false、cacheHandlers: false、scopeId: null、inline: false、ssrCssVars: '{ color }'、compatConfig: { MODE: 3 }、whitespace: 'condense', e umbindingMetadata 📎 packages-private/template-explorer/src/options.ts:18-26。
compilerOptionscontendo 7 tipos de bindingreactive(Object.assign({}, defaultOptions)) 📎 packages-private/template-explorer/src/options.ts:29-31éObject.assign({}, ...). Observe que aqui foi usadoreactive(defaultOptions)para cópia superficial — se fosse diretamentecompilerOptions, modificardefaultOptionscontaminariareCompile, fazendo com que a lógica de "comparação com o valor padrão" em
Step-by-Step Walkthrough
falhasse.
Cenário: o usuário clica na checkbox "hoistStatic". AppPrimeiro passo: renderização da UI.setupO📎 packages-private/template-explorer/src/options.ts:33-35do componentessrMode.value、compilerOptions.mode、compilerOptions.prefixIdentifiersretorna uma função de renderização📎 packages-private/template-explorer/src/options.ts:36-39. Essa função de renderização lê
e outros estados reativos hoistStatic, portanto quando esses estados mudam, toda a UI é re-renderizada.checkedSegundo passo: binding checked da checkbox.compilerOptions.hoistStatic && !isSSR 📎 packages-private/template-explorer/src/options.ts:150A propriedadehoistStaticda checkboxdisabled: isSSR 📎 packages-private/template-explorer/src/options.ts:151é
. Há uma lógica aqui: no modo SSR,é forçado a aparecer como não marcado, porque a compilação SSR não suporta hoisting estático. Ao mesmo tempo,onChangegarante que o usuário não possa alterná-lo no modo SSR.📎 packages-private/template-explorer/src/options.ts:152-156Terceiro passo: tratamento do onChange.e.target.checkedQuando o usuário clica na checkbox,compilerOptions.hoistStaticdisparacompilerOptions, atribuindo diretamentereactiveawatchEffect(reCompile) 📎 packages-private/template-explorer/src/index.ts:266. Como
édecacheHandlers, essa atribuição dispara o rastreamento de dependências, que por sua vez disparachecked, e finalmente recompila.usePrefix && compilerOptions.cacheHandlers && !isSSR 📎 packages-private/template-explorer/src/options.ts:166,disabledQuarto passo: interligação entre opções.!usePrefix || isSSR 📎 packages-private/template-explorer/src/options.ts:167Observe quecacheHandlersoprefixIdentifiersdemode === 'module'éprefixIdentifierséfunction. Isso significa quecacheHandlersdepende de
scopeIdoudisabled: !isModule 📎 packages-private/template-explorer/src/options.ts:182,checked: isModule && compilerOptions.scopeId 📎 packages-private/template-explorer/src/options.ts:183. Essa relação de interligação se manifesta na UI como: quandoisModulenão está ativado e o modo énull 📎 packages-private/template-explorer/src/options.ts:184-189。
, a checkbox initOptionsfica desabilitada.createApp(App).mount(document.getElementById('header')!) 📎 packages-private/template-explorer/src/options.ts:232-234A interligação devueé mais complexa:createApp. Só no modo module é possível definir scopeId, e no onChange, se@vue/runtime-domfor false, será forçado paraoptions.tsQuinto passo: montagem.vuechama
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 --> compileCodedo pacote
, e nãoreactive— porqueref? compilerOptionsé código de camada de aplicação, podendo depender diretamente do pacote completoreactive.compilerOptions.hoistStatic = trueCopiarcompilerOptions.value.hoistStatic = trueReflexões de design e armadilhas em produçãoreactivePor que usarcompilerOptions.xxxem vez de
bindingMetadataé um objeto contendo mais de uma dúzia de campos; usarpermite diretamente📎 packages-private/template-explorer/src/options.ts:18-26, sem precisar deSETUP_CONST、SETUP_REF、SETUP_LET、SETUP_MAYBE_REF、PROPS. Isso é mais conciso no código de UI. Mas o custo deprefixIdentifiersé que a desestruturação perde reatividade — no código-fonte não há nenhuma desestruturação, tudo é acessado via$setup, o que é o uso correto.prefixIdentifiersDesign dos valores padrão de
compatConfig. Os valores padrão de compilerOptions.compatConfig!.MODE = 2 📎 packages-private/template-explorer/src/options.ts:216-220incluem 7 bindingsreactive, cobrindoreactivecinco tipos. Isso é para que o desenvolvedor, ao abrircompatConfig, possa ver imediatamente o impacto de diferentes tipos de binding na forma de acesso aCompatConfig | undefinedno artefato. Sem esse valor padrão,!o efeito decompatConfigseria muito monótono.
ssrModeReatividade aninhada decompilerOptions. Atribuições aninhadas como ssrModesão reativas sobref,compilerOptions, porquereactivefaz proxy recursivo de objetos aninhados. Mas observe que o tipo dessrécompilerOptions, então foi usada uma asserçãossr. Se não houvesseCompilerOptionsnos valores padrão, aqui ocorreria um crash em tempo de execução.
---
Separação de responsabilidades entre
e
theme.tsÉ como "trocar a pele" do editor: define a cor e o estilo de fonte de cada token de sintaxe. Sem este módulo, o Monaco usaria o temavs-darkpadrão, que embora funcional, faria com que tags HTML, expressões e diretivas em templates Vue carecessem de distinção visual, dificultando a localização rápida de partes-chave pelo desenvolvedor.
Estrutura de dados e layout de memória
theme.tsExporta um objeto compatível com a interface do MonacoIStandaloneThemeData.📎 packages-private/template-explorer/src/theme.ts:1-244Ele possui três campos de nível superior:
base: 'vs-dark'Especifica o tema base📎 packages-private/template-explorer/src/theme.ts:2,inherit: trueRepresenta regras que herdam do tema base📎 packages-private/template-explorer/src/theme.ts:3. Isso significa que só é necessário definir as diferenças; tokens não definidos farão fallback paravs-dark。
rulesÉ um array, cada elemento contémtoken(nome do token no Monaco) eforeground/background/fontStyle 📎 packages-private/template-explorer/src/theme.ts:4-235. Este array tem mais de 50 entradas, cobrindo tipos de token como number, comment, keyword, string, variable, entity.name.tag, etc.
colorsDefine as cores da UI do editor📎 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
Cenário: registrar o tema no carregamento da página.
Primeiro passo: definir o tema. monaco.editor.defineTheme('my-theme', theme) 📎 packages-private/template-explorer/src/index.ts:44. Esta chamada registratheme.tso objeto exportado no registro de temas do Monaco, com a chave'my-theme'。
Segundo passo: ativar o tema. monaco.editor.setTheme('my-theme') 📎 packages-private/template-explorer/src/index.ts:45. Esta linha deve ser chamada apósdefineTheme, caso contrário lançará o erro "tema não definido".
Terceiro passo: correspondência de tokens.Quando o Monaco renderiza o código do template, ele tokeniza o código usando o serviço de linguagem HTML e então busca pelo nome do token as regras emrules. Por exemplo,<div>emdivserá marcado comoentity.name.tag, correspondendo aforeground: 'cc6666' 📎 packages-private/template-explorer/src/theme.ts:41-44, exibido em vermelho.
Reflexões de design e armadilhas em produção
Por que usarinherit: true?Se não herdar, seria necessário definir as cores de todos os tokens, incluindo aqueles que não aparecem no template (comomarkup.heading、meta.diff). A herança permite que o arquivo de tema foque apenas nos tokens que realmente aparecem no template e no produto JS.
Correspondência hierárquica de nomes de token.A correspondência de tokens do Monaco é por prefixo:entity.name.tagcorresponderá aentity.name.tag.html、entity.name.tag.cssetc. O código-fonte define tantoentity.name.tag 📎 packages-private/template-explorer/src/theme.ts:41-44quantoentity.name.tag.css 📎 packages-private/template-explorer/src/theme.ts:169-172, o último sobrescrevendo o cenário CSS específico do primeiro.
colorsDivisão de responsabilidades entrerulese rulescontrola a cor do texto do código,colorscontrola as cores da UI do editor (fundo, cursor, linha selecionada). Ambos são independentes, mas precisam de coordenação visual. No código-fonte,editor.background: '#1D1F21'ebase: 'vs-dark'têm fundos padrão próximos, para manter consistência visual.
---
Reflexão de design: trade-offs de engenharia de uma sonda visual
A diferença central entre o Template Explorer e o SFC Playground está na "granularidade de observação". O Playground observa "se o SFC completo compilado pode ser executado", enquanto o Template Explorer observa "no que uma única expressão de template é compilada". Essa diferença determina as escolhas técnicas das duas ferramentas:
A introdução do SourceMapConsumer é inevitável.Sem ele, o desenvolvedor só poderia comparar código-fonte e produto a olho nu, sem estabelecer um mapeamento preciso de "linha X → linha Y". Mas a API do SourceMapConsumer é assíncrona (versões novas retornam Promise); o código-fonte usa a versão síncronasource-map-js, para simplificar a lógica de chamada.
reactiveGerenciar opções é a escolha natural no ecossistema Vue.Se fosse usado gerenciamento manual de sincronização de estado de uma dúzia de opções com eventos DOM nativos, a quantidade de código dobraria.reactiveO rastreamento de dependências dewatchEffect(reCompile)torna automática a cadeia "mudança de opção → recompilação", com uma linha de código realizando a subscrição.
O modo de carregamento global do Monaco é um fardo histórico. window.monacoO modo de montagem global dewindow.inite
---
vem do design do carregador AMD do Monaco. Em builds ESM modernos, isso parece deslocado, mas o tamanho do Monaco (cerca de 5MB) torna o carregamento sob demanda ainda necessário.
Resumo do capítuloindex.tsO Template Explorer é uma "sonda de caixa branca": ele não executa o produto compilado, apenas mostra o processo de compilação.compileCodeAtravés de@vue/compiler-domchamando@vue/compiler-ssrouSourceMapConsumer, usaoptions.tspara estabelecer mapeamento bidirecional entre código-fonte e produto, e implementa destaque sincronizado do cursor via API de decoradores do Monaco.reactiveUsaCompilerOptionspara gerenciarwatchEffect, aciona recompilação viahoistStatic, e as relações de dependência entre opções (como SSR desabilitandotheme.ts) são codificadas explicitamente na camada de UI.
Personaliza o tema do Monaco, dando aos tokens de sintaxe do template e do produto uma distinção visual clara.hoistStaticO valor central desta ferramenta está em "usar a ferramenta para inferir o comportamento do compilador": quando você não tem certeza do que
fez com um determinado template, abra o Template Explorer, alterne as opções e observe as mudanças no produto. Isso é mais intuitivo do que ler o código-fonte do compilador e mais confiável do que adivinhar.
Reflexões e autoavaliação do capítuloindex.tsQ1: Se for removida a guarda de mock location (originalPositionFor) depos.line === 1 && pos.column === 0em{ line: 1, column: 0 }, em quais cenários isso causaria destaque incorreto? Por que o compilador gera mapeamentos como
?Análise de referência📎 packages-private/template-explorer/src/index.ts:231-237: A guarda está emimport { createElementVNode as _createElementVNode } from 'vue'. O compilador, ao gerar o produto, insere código sem posição correspondente no template, como instruções de importação de helpers comoexport function render(_ctx, _cache) { ... }, ou assinaturas de função comosource-map-js. Esses códigos não têm posição original no SourceMap,{ line: 1, column: 0 }retornaráoriginalPositionForcomo placeholder. Se a guarda for removida, quando o usuário posicionar o cursor nessas linhas,{ line: 1, column: 0 }, o código considerará esta uma posição válida e criará um decorador de destaque na primeira linha e primeira coluna do editor de código-fonte. O resultado é: o usuário clica no artefatoimportlinha, a primeira linha do editor de código-fonte é destacada incorretamente, causando confusão. A essência desta guarda é "distinguir mapeamento real de mapeamento de espaço reservado", e{ line: 1, column: 0 }ésource-map-jso valor sentinela de "sem mapeamento" convencionado.
Q2: reCompileopções de persistência, a condiçãotypeof val !== 'object' && val !== defaultOptions[key]ignorará todas as opções do tipo objeto. SebindingMetadatafor modificado pelo usuário (por exemplo, através do console), esta modificação será perdida após atualizar a página. Isto é um bug ou design intencional? Se quisermos suportarbindingMetadatana persistência, quais problemas precisam ser resolvidos?
Análise de referência: a condição está localizada em📎 packages-private/template-explorer/src/index.ts:129. Isto é design intencional, por três razões: primeiro,bindingMetadatao valor éBindingTypesenum, após serialização é um número, e na desserialização não é possível distinguir entre "usuário definiu explicitamente como 0" e "valor padrão"; segundo,compatConfigé um objeto aninhado,val !== defaultOptions[key]compara referências, sempre será true, fazendo com que todas as opções de objeto sejam persistidas; terceiro,nodeTransformscontém funções, não pode ser serializado, e no código-fonte já foi tratado através dedelete persistedState.options?.nodeTransformspara lidar com📎 packages-private/template-explorer/src/index.ts:69. Se quisermos suportarbindingMetadata, é necessário implementar comparação profunda (em vez de comparação por referência), e também lidar com a serialização/desserialização de valores enum. O problema mais fundamental é:bindingMetadatanão tem entrada de edição na UI, o usuário só pode modificar através do console, e este tipo de modificação por si só não deveria ser persistida.
Q3: options.tsemcompilerOptionsé criado comreactive(Object.assign({}, defaultOptions)). SeObject.assign({}, defaultOptions)for alterado para diretamentereactive(defaultOptions), o que acontecerá após o usuário alternar a opção e atualizar a página? Por quê?
Análise de referência:Object.assign({}, defaultOptions)é uma cópia superficial, localizada em📎 packages-private/template-explorer/src/options.ts:29-31. Se for alterado parareactive(defaultOptions),compilerOptionsedefaultOptionsapontarão para o mesmo objeto. Quando o usuário alternarhoistStaticpara true,compilerOptions.hoistStaticse torna true, e ao mesmo tempodefaultOptions.hoistStatictambém se torna true. Então a lógica de persistência emreCompile📎 packages-private/template-explorer/src/index.ts:129irá compararval !== defaultOptions[key], neste momentovaledefaultOptions[key]são ambos true, a condição é false, e esta opção não será salva no localStorage. Após atualizar a página,defaultOptionsé reinicializado comohoistStatic: false, a modificação do usuário é perdida. Mais grave ainda, apósdefaultOptionsser poluído, toda a lógica subsequente de "comparação com valor padrão" falhará, causando o colapso completo da funcionalidade de persistência. A sutileza deste bug está em: tudo funciona normalmente dentro de uma única sessão, só é possível descobrir após atualizar.
---
O próximo capítulo entrará emscripts/release.js, para ver como o Vue orquestra todo o fluxo de atualização de número de versão, build, testes, commit Git, criação de tag e npm publish com uma máquina de estados interativa. Diferente da "observação" do Template Explorer, o release.js é "execução" — ele precisa manter estado entre múltiplos passos, lidar com rollback em caso de falha, e equilibrar entre confirmação interativa e automação.
Através do Template Explorer, dominamos como transformar o estado interno do compilador — AST, artefatos de compilação, SourceMap — em sondas visualizáveis interativas, transformando "por que o compilador gera assim" de suposição em observação. Este controle preciso e orquestração do estado interno também se reflete no processo de release do Vue: o próximo capítulo mergulhará em scripts/release.js, para ver como uma máquina de estados de mais de 500 linhas usa parseArgs para analisar mais de dez flags, confirma interativamente o número de versão através do enquirer, e dispara sequencialmente build, testes, commit Git, criação de tag e npm publish, revelando o fluxo completo de estados e a estratégia de rollback em caso de falha por trás de um lançamento oficial.
Gostou deste capítulo? Crie um livro para seu repositório privado
Arquitetura local-first em Tauri 2 + Rust. 100% offline e seguro, zero upload de código. Leitura em painel duplo com âncoras imutáveis de commit.
⚡ Tauri 2 · Rust Core · 100% Offline e Privado · Testado em 1M+ linhas
Capítulo 9: Automação de Release: Máquina de Estados e Orquestração Interativa do release.js
No capítulo anterior, através do template-explorer, inferimos o comportamento do compilador e dominamos a metodologia de usar ferramentas para observar mecanismos internos. Agora, voltamos nosso olhar do tempo de compilação para o tempo de release — este é o momento mais perigoso de qualquer projeto open source: ele toca simultaneamente quatro sistemas externos irreversíveis: número de versão, artefatos de build, histórico Git e npm registry. Um npm publish errado não pode ser desfeito, um push de tag errado poluirá a resolução de dependências de todos os usuários downstream. O Vue core usa um scripts/release.js de 537 linhas para domar este perigo — ele não é nem um script puramente automatizado, nem uma lista de verificação puramente manual, mas sim uma máquina de estados interativa: para em pontos críticos para perguntar ao humano, executa totalmente automático em pontos previsíveis, e faz rollback do número de versão ao ponto inicial em caso de falha em qualquer passo. Este capítulo desmontará os três mecanismos centrais deste orquestrador: análise de parâmetros e inicialização de estado, decisão interativa de versão e gate de CI, e ordem de release e rollback em caso de falha.
Análise de parâmetros e inicialização de estado global
Modelo intuitivo
Imaginerelease.jscomo o painel de controle de uma máquina de lavar antiga: o botão (parseArgs) decide qual modo usar, as luzes indicadoras (variáveis globais) registram em qual estágio está atualmente, e o botão "cancelar" (tratamento de erros) deve ser capaz de restaurar a máquina ao estado antes de começar a encher de água. Sem esta lógica de inicialização, o script perderia o controle na questão "qual versão o usuário realmente quer lançar" — ou lançaria a versão errada, ou travaria no CI esperando uma entrada de teclado que nunca chegará.
Layout de memória de flags e estado global
A primeira coisa que o script faz após iniciar é analisar os argumentos da linha de comando em um objeto estruturado. Aqui é usado oparseArgsintegrado do Node, em vez deyargsoucommander— isso é para eliminar dependências de terceiros, porque o próprio script de publicação precisa rodar em qualquer ambiente, mesmo quenode_modulesesteja pela metade.
📎 scripts/release.js:27-62define 10 opções, que podem ser divididas em quatro categorias:
- Categoria de semântica de versão:
preid(identificador de pré-lançamento, comoalpha/beta/rc)、tag(npm dist-tag) - Categoria de pulo:
skipBuild、skipTests、skipGit、skipPrompts— esses quatro interruptores booleanos constituem os botões de ajuste do "grau de automação" - Categoria de modo de execução:
dry(simulação),publish(se publica diretamente localmente),publishOnly(apenas publica sem atualizar a versão) - Categoria de alvo:
registry(endereço de registry personalizado)
Observe que o valor padrão depublishéfalse 📎 scripts/release.js:51-54, enquanto os outros itens booleanos não têm valor padrão (ou seja,undefined). Essa assimetria é intencional:publishA semântica de é "se deve executar npm publish localmente", por padrão não publica, deixando a ação de publicação para o GitHub Actions; enquantoskipXxxpor padrãoundefinedsignifica "não especificado", e a lógica subsequente distinguirá "o usuário passou explicitamente--skipTests" de "o usuário não passou".
Após a análise, o script achata os parâmetros em um conjunto de variáveis em nível de módulo📎 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.skipGitHá dois designs interessantes aqui. Primeiro, a prioridade de valor depreIdé "especificação explícita na linha de comando > inferência a partir da versão atual"📎 scripts/release.js:64-66. Se a versão atual depackage.jsonfor3.5.0-beta.1, entãosemver.prereleaseretornará['beta', 1], e ao obter[0]resulta em'beta'. Isso significa que, ao publicar versões consecutivas no branch beta, não é necessário digitar--preid betatoda vez. Segundo,skipTestsé declarado comletenquanto os outros usamconst 📎 scripts/release.js:64-66, porque ele será dinamicamente reescrito pelo resultado do CI emrunTestsIfNeeded— este é um estado de "decisão adiada".
Em seguida vem a lógica de descoberta de pacotes📎 scripts/release.js:68-83: lê o diretóriopackages/, filtra itens que não são diretórios, itens sempackage.json, e pacotesprivate: true. Observe que aqui é lidopackages/em vez depackages-private/— este último é um pacote de depuração interno, nunca publicado.
Algoritmo de ordenação da sequência de publicação
📎 scripts/release.js:85-85define uma função aparentemente simples, mas crucial:
const sortPackagesForPublishing = (packageNames) => [
...packageNames.filter(p => p !== 'vue'),
...packageNames.filter(p => p === 'vue'),
]Ela coloca o pacote de entradavuepor último. O comentário📎 scripts/release.js:85-85explica o motivo: sevuefor publicado primeiro, os usuários poderão instalar a nova versão de@vue/runtime-coreantes que pacotes internos comovueestejam online, e o npm reportará erro por não encontrar a dependência interna correspondente. Esta é a solução de compromisso da "atomicidade de publicação" no ecossistema npm — o npm não tem transações entre pacotes, então só resta aproximar a atomicidade pela ordem.
Construção dinâmica do conjunto de candidatos de incremento de versão
📎 scripts/release.js:111-116constrói os itens candidatos do menu interativo:
const versionIncrements = [
'patch', 'minor', 'major',
...(preId ? ['prepatch', 'preminor', 'premajor', 'prerelease'] : []),
]Esta é uma expansão condicional: somente quandopreIdexiste (ou seja, atualmente está no canal de pré-lançamento, ou o usuário especificou explicitamente--preid), os tipos de incremento relacionados a pré-lançamento são adicionados ao menu. Se atualmente for uma versão estável3.5.43epreidnão for especificado, o menu terá apenas os três itenspatch/minor/major— evitando que o usuário, por operação equivocada, transforme a versão estável em uma versão de pré-lançamento meia-boca como3.5.44-0.
incA função📎 scripts/release.js:120-120encapsulasemver.inc, passandopreIdcomo terceiro parâmetro. Há uma defesa de tipo aqui:typeof preId === 'string' ? preId : undefined— porquepreIdpode serstring | undefined, esemver.incesperastring | undefined, esta expressão ternária serve para satisfazer o narrowing de tipo do TS.
Primitivas de execução: o sistema de trilhos duplos de run e dryRun
📎 scripts/release.js:122-123é um dos designs mais engenhosos de todo o capítulo:
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 : runrundefine o stdio do subprocesso comoinherit, permitindo que a saída de build/teste seja transmitida diretamente ao terminal — isso é crucial para builds de longa duração, pois o usuário pode ver o progresso em tempo real.dryRunapenas imprime o comando sem executá-lo.runIfNotDryé uma "seleção de estratégia": no carregamento do módulo, o ponteiro de função é vinculado adryRunourun, e todos os pontos de chamada subsequentes não precisam mais julgarisDryRun。
Esse padrão de "decidir a estratégia na inicialização" é menos propenso a erros do que "julgar em cada ponto de chamada": se algum ponto de chamada esquecer de julgarisDryRun, no modo dry run ele realmente executará efeitos colaterais. JárunIfNotDryconcentra o julgamento em um único lugar, eliminando a possibilidade desse tipo de omissão.
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"]---
Decisão interativa de versão e portão de CI
Modelo intuitivo
Esta etapa é como a segurança do aeroporto: primeiro verifica seu cartão de embarque (se o commit local está sincronizado com o remoto), depois confirma para onde você vai (número da versão) e, por fim, verifica se você já passou pela segurança (se o CI passou). Se qualquer etapa falhar, todo o processo é interrompido. Sem esse portão, um commit local não enviado poderia ser marcado com tag e publicado, fazendo com que o código-fonte correspondente à versão no npm simplesmente não exista no GitHub — este é o acidente de publicação mais difícil de diagnosticar.
Verificação de sincronização e seleção de versão
mainA primeira coisa que a funçãoisInSyncWithRemote() 📎 scripts/release.js:141-141faz é📎 scripts/release.js:337-363. A lógica desta funçãogit rev-parse HEADé: obter o nome do branch atual, solicitar à API do GitHub o SHA do commit mais recente desse branch, e comparar com o📎 scripts/release.js:348-355local. Se forem diferentes, exibe uma caixa de confirmação com aviso vermelhofalse, permitindo que o usuário decida se continua. Se a requisição à API falhar (problema de rede, sem token), retorna diretamente📎 scripts/release.js:365-367。
〔Inferência de design e trade-offs de arquitetura〕
A filosofia de design aqui é "falha significa interrupção": em caso de anomalia de rede, é melhor não permitir a publicação do que arriscar continuar com estado desconhecido. Porque a publicação é irreversível, e o custo de executar o script novamente é baixo.node scripts/release.js 3.6.0),targetVersionA determinação do número da versão segue dois caminhos. Se o usuário passou um parâmetro posicional na linha de comando (como📎 scripts/release.js:141-141, usa diretamente esse valor📎 scripts/release.js:152-176. Caso contrário, entra no menu interativocustom: primeiro deixa o usuário escolher o tipo de incremento; se escolher
, então exibe outra caixa de entrada para o usuário digitar manualmente o número da versão.📎 scripts/release.js:174Observe a linha
targetVersion = release.match(/\((.*)\)/)?.[1] ?? ''Copiarpatch (3.5.44)O formato do item de menu écustom, e esta linha de regex extrai o número de versão real dos parênteses. Se o usuário escolher📎 scripts/release.js:164-172。
, segue outro branch📎 scripts/release.js:178-182Em seguida há uma lógica de "segunda análise"targetVersion: sepatch/minorEsse tipo de palavra-chave incremental (o usuário pode passar diretamentenode release.js minor), então chamaincpara convertê-la em um número de versão específico. Por fim, usasemver.validpara validar📎 scripts/release.js:184-186, e números de versão inválidos geram erro imediatamente.
Portão de CI: a lógica de três estados de runTestsIfNeeded
Este é o fluxo de controle mais complexo de todo o capítulo.📎 scripts/release.js:281-317OrunTestsIfNeededna verdade é uma máquina de decisão de três estados:
Estado um: o usuário passou explicitamente--skipTests。skipTestsinicializado comotrue, pula todo o corpo da função e imprime "Tests skipped."📎 scripts/release.js:314-316。
Estado dois: não foi pulado, e o CI já passou. O script chamagetCIResult() 📎 scripts/release.js:319-335, que solicita à API do GitHub Actions e verifica se existe um workflow run chamadocieconclusion === 'success'📎 scripts/release.js:319-335. Se passar, pergunta ao usuário "CI já passou, deseja pular os testes locais?"📎 scripts/release.js:288-295. Se o usuário ativou--skipPrompts, pula automaticamente os testes locais📎 scripts/release.js:296-298。
Estado três: não foi pulado, e o CI não passou. Se--skipPromptsestiver ativado, lança erro diretamente📎 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.',
)Se--skipPromptsnão estiver ativado, entãoskipTestspermaneceundefined, cai no branch final de testes locais📎 scripts/release.js:307-313, executapnpm run test --run。
Há um detalhe sutil aqui📎 scripts/release.js:285:
skipTests ||= isCIPassed||=é atribuição lógica OU: só atribuiskipTestsquandoundefinedé um valor falso (falseouisCIPassed). Isso significa que, se o usuário passou explicitamente--skipTests(true), esta linha não o altera; se o usuário não passou (undefined), define-o como o resultado do CI. Mas logo em seguida📎 scripts/release.js:287-298reatribui quando o CI passa — então||=o efeito real desta linha é apenas "se o CI não passou, defineskipTestscomofalse", fazendo com que o branch subsequenteif (!skipTests)execute os testes locais.
Essa lógica dá uma volta, mas essencialmente quer expressar: "CI passou → pode pular os testes locais (mas pergunte ao usuário); CI não passou → deve executar os testes locais (a menos que o usuário peça explicitamente para pular)". Usar||=mais sobrescrita posterior, embora compacto, tem baixa legibilidade e é um cheiro de código típico de "bits de estado modificados em vários lugares".
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)Gravação do número de versão: a travessia de updateVersions
📎 scripts/release.js:377-384OupdateVersionsfaz duas coisas: atualiza opackage.jsonraiz, depois percorre todos os subpacotes chamandoupdatePackage。updatePackage 📎 scripts/release.js:391-398para ler o JSON, reescrevernameeversion, e usarJSON.stringify(pkg, null, 2) + '\n'para gravar de volta — observe o\nno final, isso serve para manter o arquivo terminando com nova linha, evitando que o git diff mostre "No newline at end of file".
getNewPackageNameO parâmetrokeepThePackageName 📎 scripts/release.js:105tem valor padrão
---
, ou seja, não altera o nome do pacote. A existência desse parâmetro é para suportar o cenário de "renomear pacote ao publicar em um registry personalizado" — embora os pontos de chamada atuais passem o valor padrão, a interface reserva extensibilidade.
Ordem de publicação, idempotência e rollback em caso de falha
Modelo intuitivoupdateVersionsEsta fase é como dominós:
derruba a primeira peça (alterar número de versão), e as peças seguintes — changelog, lockfile, commit, tag, publish — caem em sequência. Se alguma peça travar no meio, deve haver um mecanismo para levantar as peças já derrubadas — caso contrário, o repositório ficará no estado inacabado de "número de versão alterado mas não publicado".
publishPackage 📎 scripts/release.js:439-489〔Inferência de design e trade-offs de arquitetura〕📎 scripts/release.js:442-451é o núcleo da publicação. Primeiro determina o dist-tag--tag: prioriza o parâmetroalpha/beta/rc, caso contrário infere pela palavra-chaveversion.includes('alpha')no número de versão. Observe que aqui usasemver.prereleaseem vez de3.5.0-alpha.1,includes— porque o número de versão pode ter a forma
, suficientemente simples e sem risco de julgamento incorreto.📎 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-513Copiarnpm view <pkg>@<version> versionexecutatrue, se bem-sucedido retornafalse, se reportar erro do tipo E404 retorna
. O significado dessa verificação é: o fluxo de publicação pode ser reexecutado devido a interrupção de rede, e pacotes já publicados não devem ser publicados novamente (o npm rejeita versões duplicadas).npm viewMas a própria verificação também pode falhar — por exemplo,isPackagePublishedlança um erro não E404 devido a timeout de rede. Nesse caso📎 scripts/release.js:507-510propaga o erro para cima
, fazendo toda a publicação abortar. Esta é mais uma manifestação de "prefiro abortar a arriscar".pnpm publishMesmo que a verificação passe,publishPackageainda pode falhar devido a corrida (outro CI acabou de publicar a mesma versão). Por isso📎 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
}
}Copiarpreviously publishedSó engole o erro se corresponder a
, todos os outros erros são relançados. Isso é "tolerância a falhas precisa": só faz degradação para erros conhecidos e seguramente ignoráveis.
📎 scripts/release.js:412-432Montagem dinâmica das flags de publicaçãopnpm publishmonta as flags adicionais de
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-checksCopiarpnpm publishé ativado em três casos: dry run, pular git, ou em CI. O motivo é que
--provenancepor padrão verifica se o workspace está limpo, se o branch atual é o branch de publicação etc., e em CI essas verificações geram falsos positivos.📎 scripts/release.js:425-427só é ativado em CI e quando nenhum registry personalizado é especificado!args.registry. provenance é um recurso de segurança da cadeia de suprimentos do npm, que assina as informações de origem do artefato de build (qual commit, qual workflow) e as anexa ao pacote. Mas registries personalizados (como registries privados internos) geralmente não suportam provenance, então foi adicionada a condição
.
Rollback em caso de falha: a flag versionUpdatedmainVoltando ao final de📎 scripts/release.js:528-537:
fnToRun().catch(err => {
if (versionUpdated) {
updateVersions(currentVersion)
}
console.error(err)
process.exit(1)
})versionUpdatedé um booleano em nível de módulo, inicializado comofalse 📎 scripts/release.js:24-27, e definido comoupdateVersionsimediatamente após a chamada bem-sucedida detrue 📎 scripts/release.js:208. Se qualquer etapa subsequente (geração de changelog, atualização de lockfile, git commit, publish) lançar erro, o bloco catch verifica essa flag e, se fortrue, reverte o número de versão paracurrentVersion。
Este rollback é "melhor esforço": ele apenas revertepackage.jsono número de versão em , não reverte o arquivo changelog, não reverte o lockfile, não reverte o git commit já executado. Se o erro ocorrer após o git commit, o repositório ficará em um estado intermediário de "número de versão revertido mas commit já existente". Esta é uma escolha de design — um rollback completo exigiriagit reset, e isso destruiria outras alterações que o usuário possa ter feito. Portanto, o script opta por reverter apenas o número de versão mais crítico, deixando o restante para o usuário lidar manualmente.
AtençãopublishOnlycaminho📎 scripts/release.js:519-526não defineversionUpdated, porque sua semântica é "apenas publicar, não alterar versão" — mesmo em caso de falha, não é necessário rollback. Mas ele chamatargetVersionquando existeupdateVersions 📎 scripts/release.js:519-526, e nesse caso, se falhar, o número de versão não será revertido. Este é um problema de borda potencial, veja a questão de reflexão no final do capítulo.
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)"]Ordem de publicação e tratamento especial do pacote vue
publishPackages 📎 scripts/release.js:412-432percorresortPackagesForPublishing(packages)o resultado e chamapublishPackageum por um. Como a ordenação colocavuepor último📎 scripts/release.js:85-85, toda a sequência de publicação garante que os pacotes internos sejam publicados primeiro.
publishPackageinternamente usacwd: getPkgRoot(pkgName) 📎 scripts/release.js:475para mudar o diretório de trabalho para o diretório do subpacote, assimpnpm publishpublica o subpacote e não o pacote raiz. O comentário📎 scripts/release.js:462-463alerta especialmente "não mude para npm publish" — porquepnpm publishconsegue lidar corretamente comworkspace:*o protocolo de dependência, convertendo-o para o número de versão real, enquantonpm publishmanteriaworkspace:*como está, causando falha na instalação.
---
Reflexão de design
Por que usarparseArgsem vez deyargs?O script de publicação é a "última linha de defesa", ele deve ser executável em qualquer ambiente. Se uma biblioteca CLI de terceiros falhar ao carregar devido a uma árvore de dependências corrompida, todo o fluxo de publicação fica paralisado. OparseArgsnativo do Node, embora simples (não suporta subcomandos, não suporta help automático), tem zero dependências e zero risco.
Por que definirpublishcomo padrãofalse?Porque a publicação oficial do Vue passa pelo GitHub Actions (veja📎 scripts/release.js:256-263a mensagem de aviso), o script local é responsável apenas por alterar o número de versão, gerar changelog, criar tag e fazer push. Onpm publishreal é executado no CI, aproveitando a assinatura de provenance e o ambiente controlado do CI.--publishA flag é uma rota de escape para mantenedores publicarem localmente em situações de emergência.
Por que o rollback reverte apenas o número de versão?Porque um rollback completo exigiria entender "quais alterações foram feitas pelo script e quais foram feitas pelo usuário", e isso não é distinguível no nível do git. O script opta por reverter apenas o que ele tem mais certeza de ter alterado —package.jsono número de versão — e deixa o resto para o usuário julgar.
---
Resumo do capítulo
scripts/release.jsimplementa uma "máquina de estados interativa" com 537 linhas de código, cujo design central pode ser resumido em três pontos:
1. Parâmetros são estratégia: 10 flags são analisadas no carregamento do módulo e achatadas em variáveis globais,runIfNotDryvincula a estratégia na inicialização, evitando que pontos de chamada omitam verificações.
2. Portões antecipados: verificações de sincronização, validação de versão e portões de CI são concluídos antes de qualquer efeito colateral, garantindo "tudo ou nada".
3. Tolerância a falhas precisa:isPackagePublishedpré-verificação +previously publishedfallback de erro constituem proteção de idempotência dupla;versionUpdatedflags implementam rollback minimizado.
Este mecanismo forma um contraste interessante com o Template Explorer do capítulo anterior: Template Explorer é "observar" — visualizar o estado interno do compilador; release.js é "executar" — tornar explícito cada passo do estado do fluxo de publicação. Ambos refletem a mesma filosofia de engenharia:transformar estado implícito em estado explícito, transformar efeitos colaterais incontroláveis em passos controláveis。
Reflexão e autoavaliação do capítulo
Q1: Se mudarmos📎 scripts/release.js:285oskipTests ||= isCIPassedde paraskipTests = isCIPassed, o que acontece quando o usuário passa explicitamente--skipTestse o CI não passou? Por quê?
Análise de referência: Na lógica original, quando o usuário passa--skipTests,skipTestsinicialmente étrue 📎 scripts/release.js:64-66,||=e não o altera, portantorunTestsIfNeededem📎 scripts/release.js:282oif (!skipTests)é avaliado como falso, pulando diretamente para📎 scripts/release.js:314-316imprimir "Tests skipped.". Se mudarmos paraskipTests = isCIPassed, entãoskipTestsé forçado parafalse(CI não passou), em seguida📎 scripts/release.js:287oif (isCIPassed)é falso, caindo em📎 scripts/release.js:299oelse if (skipPrompts)— se--skipPromptsnão estiver ativado, entãoskipTestspermanecefalse, e finalmente em📎 scripts/release.js:307-313executa os testes locais. Isso contraria a intenção do usuário de "pular testes explicitamente", e em ambiente de CI (--skipPrompts) ainda lançaria diretamente o erro📎 scripts/release.js:300-303, causando a interrupção da publicação.||=A existência de é justamente para respeitar a escolha explícita do usuário.
Q2: publishOnlycaminho📎 scripts/release.js:519-526chamatargetVersionquando existeupdateVersions, mas não defineversionUpdated. Se nesse momentobuildPackagesoupublishPackageslançar erro, o que acontece? Esse design é razoável?
Análise de referência:publishOnlychamaupdateVersions(targetVersion) 📎 scripts/release.js:519-526e modifica todospackage.jsonos números de versão, mas não defineversionUpdated = true. Quando posteriormentebuildPackages 📎 scripts/release.js:519-526oupublishPackages 📎 scripts/release.js:519-526lança erro,fnToRun().catch 📎 scripts/release.js:528-537verificaversionUpdatedcomofalse, não reverte o número de versão. O resultado é que o repositório fica no estado de "versão alterada mas publicação falhou". Esse design é razoável sob a semântica original depublishOnly(apenas publicar, não alterar versão) — porquetargetVersionnormalmente não é passado,updateVersionsnão é executado. Mas quando o usuário passatargetVersion, esse caminho tem uma brecha de rollback. A correção é adicionar📎 scripts/release.js:519-526apósversionUpdated = true, ou fazerpublishOnlyreutilizarmaina lógica de rollback de .
Q3: isPackagePublished 📎 scripts/release.js:491-513usanpm viewpara verificar se o pacote já foi publicado. Se um timeout de rede fizernpm viewlançar um erro que não seja E404, o que acontece? Esse comportamento é seguro em cenários de reexecução de CI?
Análise de referência:isPackagePublishedno bloco catch📎 scripts/release.js:507-510chamaisPackageNotFoundErrorpara determinar o tipo de erro. Essa função📎 scripts/release.js:515-515corresponde apenas a/E404|No match found|No matching version|notarget/i. A mensagem de erro de timeout de rede não contém essas palavras-chave, portantoisPackageNotFoundErrorretornafalse,isPackagePublishede relança o erro📎 scripts/release.js:507-510. Esse erro se propaga para cima atépublishPackage 📎 scripts/release.js:453, o que faz com que todo o lançamento seja abortado. Em cenários de reexecução de CI, isso leva a "o pacote já foi publicado, mas o processo é abortado devido a instabilidade de rede" — mas esta é uma direção de falha segura: abortar é melhor do que julgar erroneamente como "não publicado" e publicar novamente. A republicação acionará o erropreviously publisheddo npm, sendo contida pelo📎 scripts/release.js:491-492, mas desperdiçará uma ida e volta de rede. Portanto, "erro de rede significa abortar" é uma escolha conservadora, porém correta.
---
O próximo capítulo entrará em.github/workflows/, para ver como, após o release.js fazer push da tag, o GitHub Actions assume a construção e publicação subsequentes, bem como a implementação completa do gate de CI.
Até aqui, vimos claramente como o release.js usa máquina de estados e orquestração interativa para minimizar o risco irreversível de publicação. Mas o script de publicação em si é apenas o executor; quem realmente decide quando acionar e sob quais condições liberar é o guardião de automação de nível superior. O próximo capítulo analisará o sistema CI/CD no diretório .github/workflows: como o ci.yml executa o triplo gate de lint/typecheck/test na fase de PR, como o release.yml aciona a publicação no push de tag, como o size-report.yml e o size-data.yml rastreiam regressões de tamanho de pacote, e como o autofix.yml corrige automaticamente problemas de formatação. Você entenderá como o Vue usa o GitHub Actions para solidificar normas de engenharia em pipelines incontornáveis.
Gostou deste capítulo? Crie um livro para seu repositório privado
Arquitetura local-first em Tauri 2 + Rust. 100% offline e seguro, zero upload de código. Leitura em painel duplo com âncoras imutáveis de commit.
⚡ Tauri 2 · Rust Core · 100% Offline e Privado · Testado em 1M+ linhas
Capítulo 10: Fluxos de trabalho CI/CD: o guardião automatizado do PR ao Release
No capítulo anterior vimos comoscripts/release.jsusa uma máquina de estados interativa para encadear cada passo de um lançamento. Mas aquele script tem um pré-requisito: ele precisa ser invocado ativamente por alguém ou algum sistema. No repositório Vue core, esse invocador ativo não é o terminal local do mantenedor, mas o GitHub Actions. O release.js é o executor, os workflows são os decisores — eles decidem qual evento aciona qual tarefa, sob quais condições liberar e sob quais condições bloquear. Este capítulo foca nos quatro arquivos dentro do diretório.github/workflows/:ci.yml(gate de PR e pré-lançamento contínuo),release.yml(lançamento oficial acionado por tag),size-report.yml(relatório de regressão de tamanho),autofix.yml(correção automática de formatação). Entendê-los não é memorizar a sintaxe YAML, mas ver claramente como a equipe Vue traduz normas de engenharia em restrições de pipeline incontornáveis.
I. ci.yml: triplo gate e pré-lançamento contínuo
Modelo intuitivo
Imagine oci.ymlcomo o ponto de segurança do aeroporto. Cada PR precisa passar por esse portão: o lint verifica se sua bagagem tem itens proibidos, o typecheck confirma que seu documento é autêntico e válido, o test verifica que você não está carregando materiais perigosos. Mas não há apenas um ponto de segurança — o Vue também pendurou aqui um canal de "pré-lançamento contínuo", publicando diretamente os artefatos de build de cada PR no pkg-pr-new, permitindo que contribuidores validem suas mudanças em cenários reais de instalação via npm.
Sem esse portão, qualquer merge poderia trazer erros de formatação, brechas de tipo ou regressões de comportamento para a branch main, e a branch main é a origem de todos os releases subsequentes.
Condições de acionamento e controle de concorrência
ci.ymlA configuração de acionamento do
📎 .github/workflows/ci.yml:2-11
on:
push:
branches:
- '**'
tags:
- '!**'
pull_request:
branches:
- main
- minorCopiarpushHá dois designs-chave aqui. Primeiro, o evento'**'escuta todas as branches (tags: ['!**']), mas usarelease.ymlpara excluir explicitamente todos os pushes de tag. Por que excluir tags? Porque o push de tag é tratado separadamente peloci.yml; se opull_requesttambém respondesse a tags, isso causaria acionamento duplicado do fluxo de publicação e do fluxo de CI, desperdiçando recursos de runner e até gerando condições de corrida. Segundo, omainescuta apenas as duas branchesminoremain— esta é a estratégia de branch dupla do Vue:minorcarrega a versão estável,
📎 .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' }}CopiargroupO controle de concorrência é o toque mais refinado aqui. A expressão dogithub.event.pull_request.number || github.refusacancel-in-progresscomo fallback: eventos de PR usam o número do PR como chave de agrupamento, eventos de push usam o ref (nome da branch) como chave de agrupamento. Isso significa que múltiplos pushes do mesmo PR cairão no mesmo grupo de concorrência. E otruesó é
〔Inferência de design e trade-offs arquiteturais〕
A motivação deste design é clara: na fase de PR, os desenvolvedores fazem push com frequência, e os resultados de CI de commits antigos já não têm significado; cancelá-los economiza muito tempo de runner. Mas push para a branch main não pode ser cancelado — porque cada push na main pode ser a última validação antes do lançamento, e cancelar causaria uma lacuna de validação.
📎 .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.ymlCopiarifEsta condição&&contém dois ramos de conjunção lógica (
), cada um merecendo ser detalhado.! startsWith(github.event.head_commit.message, 'release:')A primeira condiçãorelease:No início, pula os testes. Este é exatamente o formato da mensagem de commit enviada pelo release.js no capítulo anterior — o release.js já executou os testes completos localmente, então o CI não precisa validar novamente. Esta é uma otimização de "confiar na origem".
A segunda condição(github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository): eventos push sempre executam testes; eventos PR exigem que o PR venha de um fork (head.repo.full_name != github.repository). Por que apenas PRs de fork executam? Porque PRs de branches do mesmo repositório geralmente são criados por membros da equipe principal, e o push de seus branches já acionou o CI do evento push. Já PRs de fork não acionam o evento push (o push do fork não notifica o repositório upstream), então é necessário executá-los no evento PR.
Atençãouses: ./.github/workflows/test.yml——esta é uma chamada de reusable workflow.test.ymlÉ um arquivo de workflow independente, compartilhado porci.ymlerelease.yml. Essa reutilização evita definir repetidamente os passos de lint/typecheck/test em múltiplos workflows.
Pré-lançamento contínuo: o papel do 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-releaseO job só é executado novuejs/corerepositório principal (if: github.repository == 'vuejs/core'), não é executado em forks. Ele faz três coisas: build (pnpm build --withTypes, com declarações de tipo), depois usapkg-pr-newpara publicar todos os pacotes em./packages/*para um registry npm temporário.
O valor deste mecanismo é que os contribuidores podem diretamentenpm installo artefato de build deste PR em seus próprios projetos, verificando se a mudança realmente resolve o problema. Isso é mais convincente do que "ver o CI verde", porque valida um cenário real de consumo do pacote.
Observe que todas as actions estão fixadas em commit SHA (comoactions/checkout@3d3c42e5...), em vez de usar@v4tags flutuantes como essa. Este é um requisito rígido de segurança da cadeia de suprimentos — evitar que código malicioso flua automaticamente após o repositório da action ser comprometido.
Grafo de fluxo de controle do 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---
II. release.yml: orquestração de publicação após push de tag
Modelo intuitivo
Seci.ymlé o posto de segurança,release.ymlé a plataforma de lançamento. Quando o release.js conclui localmente a atualização de versão, commit, criação de tag e push, o evento de push de tag acende o motor dorelease.yml. Ele primeiro executa os testes completos (confirmando novamente), depois executa no ambiente protegidoReleaseopnpm release --publishOnly, e finalmente cria o GitHub Release.
Sem ele, a tag enviada pelo release.js seria apenas uma referência Git, não haveria nova versão no npm, nem página de Release no GitHub.
Condição de disparo: apenas tags
📎 .github/workflows/release.yml:3-6
on:
push:
tags:
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10Escuta apenas push de tags no formatov*. Isso forma complementaridade comci.ymlotags: ['!**']do
— ambos são estritamente mutuamente exclusivos, não disparam ao mesmo tempo.
📎 .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: ReleaseCopiar
Aqui há três camadas de guarda, nenhuma delas pode ser omitida.if: github.repository == 'vuejs/core'Primeira camadav1.0.0: evitar disparo acidental de publicação em forks. Se alguém fizer fork do repositório e enviar uma tag
, esta condição impedirá a execução do fluxo de publicação.needs: [test]Segunda camadatest.yml: o job release depende do job test. O job test chama
〔Inferência de design e trade-offs arquiteturais〕environment: ReleaseTerceira camada
: este é um GitHub Environment, que pode configurar regras de proteção de deploy (como exigir aprovação de pessoas específicas). Isso significa que mesmo que o push de tag dispare o workflow, o passo de publicação pode exigir aprovação manual para executar — esta é a última linha de defesa para operações irreversíveis.contents: writeEm termos de permissões,id-token: writeé usado para criar o GitHub Release,packages: writeé usado para autenticação de provenance do npm (token OIDC). Observe que não há
aqui, porque o Vue publica no npm e não no GitHub Packages.
📎 .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〔Inferência de design e trade-offs arquiteturais〕--frozen-lockfileOs três passos têm suas particularidades.npm i -g npm@latestgarante que o ambiente de CI instale estritamente conforme o lockfile, evitando que a deriva de versões de dependências torne o artefato de build inconsistente com o local.
pnpm release --publishOnlyé para obter o npm CLI mais recente — porque provenance e autenticação OIDC dependem de versões mais novas do npm, versões antigas podem não suportar esses recursos.--publishOnlyé a entrada do release.js do capítulo anterior.
A flag
📎 .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.Copiarrelease-tag action。tag_name: ${{ github.ref }}〔Inferência de design e trade-offs arquiteturais〕refs/tags/v3.x.x). O corpo do Release não contém as mudanças específicas, mas aponta para o CHANGELOG.md — porque o changelog do Vue é gerado automaticamente pelo conventional-changelog, e manter manualmente o corpo do Release criaria inconsistências com o changelog.
Diagrama de sequência do 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"---
III. size-report.yml e autofix.yml: rastreamento de tamanho e autocorreção de formatação
size-report.yml: relatório de regressão de tamanho entre workflows
size-report.ymlO modo de acionamento é bastante peculiar — não é acionado diretamente por push ou PR, mas pelo evento de conclusão de outro workflow.
📎 .github/workflows/size-report.yml:3-7
on:
workflow_run:
workflows: ['size data']
types:
- completedworkflow_runO evento de escuta é chamadosize dataO workflow é concluído. Este é um design de duas fases:size-data.yml(o código-fonte não é fornecido neste capítulo) é responsável por construir e medir o tamanho no PR, enviando o resultado como artifact;size-report.ymlApóssize dataser concluído, baixa o artifact, gera o relatório e comenta no 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'Três guardas: repositório principal, evento de PR, workflow upstream bem-sucedido. Sesize datafalhar, o job de relatório não será executado — pois não há dados para reportar.
O fluxo de dados é o seguinte:
📎 .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/sizeBaixa do workflow run upstream osize-dataartifact paratemp/size. Em seguida, lê em paralelo o número do PR e a branch 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É um açúcar sintático do GitHub Actions que permite que dois passos sem dependências sejam executados simultaneamente.number.txtebase.txtsãosize-data.ymlarquivos de metadados gravados durante a medição.
Em seguida, baixa os dados históricos de tamanho da branch base para comparação:
📎 .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: warnAtençãoif_no_artifact_found: warn— se a branch base ainda não tiver dados históricos (como uma nova branch), não falhará, apenas emitirá um aviso. Isso garante que o relatório ainda possa ser gerado na primeira execução, apenas sem linha de base de comparação.
Por fim, gera o relatório e comenta:
📎 .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.jsLêtemp/sizeetemp/size-prevos dados em, gerando um relatório em Markdown.maintain-one-comment-backupA action usabody-include: '<!-- VUE_CORE_SIZE -->'como marcador, garantindo que apenas um comentário de relatório de tamanho seja mantido no mesmo PR (atualização em vez de acréscimo). Observe o comentário na L81 explicando que o repositório original da action foi bloqueado pelo GitHub, então usaram um repositório de backup com commit fixado.
autofix.yml: correção automática de problemas de formatação
autofix.ymlResolve um problema bastante prático: o código submetido pelo contribuidor não está em conformidade com as regras do prettier/eslint, o CI falha, e o contribuidor precisa executar manualmentepnpm lint --fixe submeter novamente. Este workflow automatiza essa etapa.
📎 .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' }}Aciona todos os PRs, com controle de concorrência similar aoci.yml— novos pushes no mesmo PR cancelam execuções antigas do 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@7a166d7532b277f34e16238930461bf77f9d7ed8Primeiro executa o--fixdo eslint, depois a formatação do prettier, e por fimautofix-ci/actionfaz commit direto dos arquivos modificados de volta para a branch do PR. Note quepnpm run formatjá é um comando de formatação (não precisa da--fixflag, porque o script format internamente já éprettier --write)。
A chave deste mecanismo é queautofix-ci/actionfará o commit da correção como o autor do PR, não como bot. Assim, o contribuidor não precisa de nenhuma ação extra, e a correção de formatação aparece automaticamente em seu PR. Mas isso também significa que se a branch do contribuidor tiver regras de proteção (que não permitem push de bots), o autofix falhará — este é um caso limite que o contribuidor precisa resolver manualmente.
Diagrama de fluxo de dados do 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---
Reflexão de design: solidificando normas na pipeline
Revisando estes quatro workflows, é possível ver vários princípios de design que permeiam tudo.
Primeiro, minimização de permissões. ci.ymleautofix.ymlambos declarampermissions: contents: read, apenasrelease.ymlprecisa decontents: writeeid-token: write。size-report.ymlprecisa depull-requests: writeeissues: writepara postar comentários. Cada workflow obtém apenas as permissões que realmente necessita.
Segundo, segurança da cadeia de suprimentos.Todas as actions de terceiros são fixadas a commit SHA, em vez de tags flutuantes.size-report.ymlO comentário na L81 explica diretamente que, após o repositório original da action ser bloqueado, mudaram para um repositório de backup e fixaram o commit — esta é uma defesa prática contra ataques à cadeia de suprimentos.
Terceiro, separação de responsabilidades e reutilização. test.ymlÉ compartilhado porci.ymlerelease.yml, evitando duplicação da lógica de teste.size-data.ymlesize-report.ymlsão separados, permitindo que medição e relatório evoluam independentemente.
Quarto, escolha da direção de falha. size-report.ymlOif_no_artifact_found: warnescolhe "avisar em vez de falhar", porque a falta de dados históricos não deve bloquear o PR. Já orelease.ymldoneeds: [test]escolhe "falha no teste bloqueia o release", porque o release é uma operação irreversível.
Quinto, diferenciação no controle de concorrência.Eventos de PR cancelam execuções antigas (cancel-in-progress: true), eventos de push não cancelam (cancel-in-progress: false). Essa diferença reflete a semântica dos dois eventos: commits antigos de um PR já não têm significado, enquanto cada commit de um push pode ser o estado final.
---
Resumo do capítulo
Este capítulo analisou os quatro workflows principais do repositório Vue core:
ci.yml: portão de PR + pré-release contínuo. Através daifcondição que distingue push/PR e fork/mesmo repositório, usaconcurrencypara cancelar execuções obsoletas de PR, epkg-pr-newpara publicar pacotes de pré-release instaláveis.release.yml:Lançamento oficial acionado por tag. Três camadas de proteção (verificação do repositório, needs test, aprovação do environment) garantem que apenas tags que passaram nos testes e foram aprovadas possam ser publicadas no npm.size-report.yml:Relatório de regressão de tamanho entre workflows. Através doworkflow_runevento que escuta o upstreamsize dataconcluído, baixa o artifact e compara com os dados do branch base, enviando feedback ao PR na forma de comentário.autofix.yml:Correção automática de formatação. Executa eslint --fix e prettier no PR, através doautofix-ci/actionfaz commit das correções diretamente de volta ao branch do PR.
Esses quatro workflows juntos formam uma "pipeline impossível de contornar": a padronização de código é corrigida automaticamente pelo autofix, tipos e testes são verificados obrigatoriamente pelo ci.yml, a regressão de tamanho é rastreada pelo size-report, e o lançamento é executado pelo release.yml sob múltiplas camadas de proteção.
Reflexões e autoavaliação deste capítulo
Q1: Se alterarmos o valor deci.ymlemcancel-in-progresspara sempretrue(ou seja, remover a condição degithub.event_name == 'pull_request'), em quais cenários isso causaria problemas?
Análise de referência:cancel-in-progresssempretruesignifica que, ao fazer push para o branch main, um novo push cancelará a CI antiga em execução. Considere este cenário: dois PRs são mesclados consecutivamente no branch main, a CI do primeiro PR está em execução (incluindo lint/typecheck/test completos), e a mesclagem do segundo PR dispara uma nova execução de CI. Secancel-in-progressfortrue, a CI do primeiro PR será cancelada — mas o código do primeiro PR já está no main, e seu resultado de CI é crucial para avaliar a saúde do branch main. Cancelá-la significa que há um trecho de código no branch main que nunca foi completamente validado. E a condição📎 .github/workflows/ci.yml:22-22degithub.event_name == 'pull_request'existe justamente para evitar esse problema: apenas eventos de PR cancelam execuções antigas, eventos de push nunca cancelam.
Q2: release.ymlemreleasedo jobif: github.repository == 'vuejs/core'eenvironment: Releasedefendem quais cenários respectivamente? O que aconteceria se removêssemos um deles?
Análise de referência:if: github.repository == 'vuejs/core' 📎 .github/workflows/release.yml:14defende o cenário de fork. Se alguém fizer fork de vuejs/core e enviar umav3.99.0tag, sem essa condição, o workflow seria executado no repositório forkadopnpm release --publishOnly. Embora o repositório forkado não tenha npm token e não possa realmente publicar, isso desperdiçaria recursos do runner e poderia gerar notificações de falha enganosas.environment: Release 📎 .github/workflows/release.yml:21defende o risco de "publicação automática após push de tag" — permite configurar aprovação manual, garantindo que mesmo que a tag seja enviada, a publicação exija confirmação do mantenedor. Se removermos a condiçãoif, o fork desperdiçaria recursos; se removermosenvironment, qualquer pessoa com permissão de push de tag poderia acionar a publicação, sem a etapa final de confirmação humana. Ambos são defesas de níveis diferentes e não podem se substituir mutuamente.
Q3: size-report.ymlemif_no_artifact_found: warna escolha derelease.ymle emneeds: [test]a escolha de
, que filosofias de design de direção de falha elas refletem respectivamente? O que aconteceria se trocássemos essas duas estratégias?:if_no_artifact_found: warn 📎 .github/workflows/size-report.yml:69Análise de referênciafailescolhe "avisar em vez de falhar quando faltam dados históricos", porque o relatório de tamanho é informação auxiliar, não uma condição de bloqueio. Se mudássemos paraneeds: [test] 📎 .github/workflows/release.yml:15, então branches novos ou PRs em primeira execução falhariam por não encontrar dados base, o que é claramente irracional.
---
escolhe "bloquear a publicação se os testes falharem", porque a publicação é uma operação irreversível e deve garantir a qualidade do código. Se trocássemos — size-report falhando quando faltam dados, release publicando mesmo com testes falhando — o primeiro causaria muitos falsos positivos bloqueando PRs normais, e o segundo faria código não testado entrar no npm. Isso reflete o princípio de design de direção de falha: "informação auxiliar tolerante, operações irreversíveis rigorosas".scripts/size-report.jsO próximo capítulo aprofundará o núcleo do mecanismo de orçamento de tamanho:usage-sizecomo analisar dados de tamanho, como calcular incrementos, como formatar a saída, e a filosofia de medição do
— por que o Vue escolhe medir "tamanho de uso real" em vez de "tamanho completo do pacote".scripts/size-report.jsDo gate de PR ao lançamento por tag, os quatro arquivos de workflow juntos formam uma cadeia automatizada de guarda intransponível. Mas a pipeline só pode bloquear merges se tiver critérios quantificáveis de julgamento. O próximo capítulo focará na governança de engenharia do Vue sobre o tamanho do pacote como métrica central:scripts/usage-size.jscomo calcular o tamanho gzip de cada artefato e comparar com a linha de base,
Gostou deste capítulo? Crie um livro para seu repositório privado
Arquitetura local-first em Tauri 2 + Rust. 100% offline e seguro, zero upload de código. Leitura em painel duplo com âncoras imutáveis de commit.
⚡ Tauri 2 · Rust Core · 100% Offline e Privado · Testado em 1M+ linhas
Próximo capítulo: Capítulo 11 →
No capítulo anterior, vimos que o Vue usa GitHub Actions para solidificar lint, verificação de tipos, testes e rastreamento de tamanho em um pipeline impossível de contornar, onde size-report.yml e size-data.yml são responsáveis por deixar dados de tamanho após cada alteração. Mas o pipeline apenas executa; quem realmente responde "quanto aumentou, onde aumentou" são os dois scripts que este capítulo vai dissecar. A contradição central do orçamento de tamanho está em: o tamanho do pacote é uma métrica que só pode ser percebida, mas difícil de atribuir com precisão. Quando usuários reclamam que "o Vue está muito grande", os mantenedores precisam responder três perguntas — quanto aumentou? Onde aumentou? Esta alteração o tornou maior? scripts/size-report.js é responsável pela comparação, scripts/usage-size.js pela atribuição, e juntos formam a filosofia de medição do orçamento de tamanho.
11.1 size-report: transformando diferenças de tamanho em tabelas Markdown legíveis
Modelo intuitivo
Imagine que você é um inspetor de qualidade de uma empresa de logística. Cada pacote (artefato de build) precisa ser pesado antes de sair do armazém, e seu trabalho não é a pesagem em si, mas colocar o "peso de hoje" e o "peso de ontem" lado a lado em uma tabela, usando negrito para+2.3 kBmarcar quais pacotes ficaram mais pesados. Sem essa tabela comparativa, os mantenedores só veriam um monte de números isolados, incapazes de julgar se um PR introduziu uma regressão de tamanho.
size-report.jsé exatamente esse inspetor. Ele não produz dados de tamanho (isso é responsabilidade dousage-size.jse dos scripts de build), ele apenas consome os arquivos JSON dos dois diretórios e gera um relatório Markdown.
Estrutura de dados e convenção de diretórios
A convenção central do script está escondida em duas constantes. O diretório de dados atual étemp/size, o diretório de linha de base histórica étemp/size-prev。
📎 scripts/size-report.js:23-24
A nomenclatura desses dois diretórios não é arbitrária:temp/sizeé gerado pelosize-data.ymlworkflow a cada execução e enviado como artifact📎 .github/workflows/size-data.yml:53-57, enquantotemp/size-prevé obtido pelosize-report.ymlapós baixar o artifact de linha de base e descompactá-lo. O nome do diretório em si é o contrato do fluxo de dados.
O script define três aliases de tipo, que descrevem precisamente a estrutura dos arquivos JSON:
📎 scripts/size-report.js:8-21
SizeResulttem três campos numéricos:size(não comprimido),gzip、brotli。BundleResultadiciona sobre isso o campofilepara exibir o nome do arquivo.UsageResulté umRecord, cuja chave é o nome do preset e o valor éSizeResult & { name: string }— note que aqui há um camponameadicional, porque as chaves de objetos JSON são perdidas apósObject.values, sendo necessário armazenar o nome redundantemente no valor.
Step-by-Step Walkthrough
O fluxo principal é minimalista, com apenas dois passos e uma saída:
📎 scripts/size-report.js:23-38
run()primeiro chamarenderFiles()para renderizar a tabela de arquivos de artefato, depois chamarenderUsages()para renderizar a tabela de cenários de uso, e finalmente escreve a string acumulada na variável de nível de módulooutputde uma vez para stdout📎 scripts/size-report.js:25. Esse padrão de "acumular string e outputar de uma vez" evita múltiplasprocess.stdout.writeconcatenações custosas e torna a ordem de saída totalmente controlável.
Primeiro passo: coletar a lista de arquivos e calcular a união.
📎 scripts/size-report.js:44-49
filterFilesfiltra dois tipos de arquivos: os que começam com_(como_usages.json) e os que terminam com.txt(comonumber.txt、base.txt). Esses dois tipos são metadados, não dados de tamanho. Em seguida, obtém a uniãofileListdos nomes de arquivos do diretório atual e do histórico — usandoSetpara deduplicar. Por que calcular a união? Porque um arquivo pode existir apenas no diretório histórico (o artefato foi removido neste build), ou apenas no diretório atual (novo artefato adicionado neste build). Ambos os casos precisam ser refletidos no relatório.
Segundo passo: comparar arquivo por arquivo.
📎 scripts/size-report.js:43-75
Para cada arquivo na união, tenta importar o JSON de ambos os diretórios.importJSONA implementação de
📎 scripts/size-report.js:112-115
é "retorna undefined se o arquivo não existir":import()Aqui usa-sewith: { type: 'json' }dinâmico com asserção de importaçãofs.readFileSync + JSON.parse, em vez deimport(). O primeiro é tratado pelo carregador de módulos do Node, o segundo requer tratamento manual de erros de codificação e parsing. O custo de escolherrenderFilesé que ele retorna uma Promise, então todo o
é async.if (!curr)O branch crítico está em~~fileName~~: se o arquivo não existe no diretório atual, significa que o artefato foi removido, marca-se📎 scripts/size-report.js:60-61com a sintaxe de riscado do MarkdowngetDiff. Caso contrário, renderiza uma linha normal, concatenando
após cada valor numérico.
📎 scripts/size-report.js:124-130
getDiffTerceiro passo: calcular a diferença.prev === undefinedtem três pontos de retorno antecipado:diff === 0retorna string vazia quandoprettyBytes(diff)(sem linha de base, impossível comparar);-1.2 kBretorna string vazia quandosign(sem mudança, não exibe ruído); caso contrário, retorna a diferença com sinal em negrito. Note que+。
lida corretamente com números negativos, produzindo
📎 scripts/size-report.js:80-103
renderUsagesnesse formato, enquanto a variávelrenderFilessó adiciona_usages.jsonquando positivo.Object.values(curr)Quarto passo: renderizar a tabela usage.prev?.[usage.name]A diferença estrutural entrenamee.filter(usage => !!usage)merece atenção: ele importa diretamentemap, porque os dados de usage existem fixamente nesse único arquivo.
converte o Record em array e, através demarkdown-table, busca os dados históricos pelo nome — exatamente por isso o campo📎 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)"]Esta linha é na verdade redundante, porque
Finalmente, usa a bibliotecaimport()para renderizar o array bidimensional como tabela MarkdownreadFileSync?Copiarimport()Reflexões de design e armadilhas
filterFiles〔Inferência de design e trade-offs arquiteturais〕file[0] !== '_'Por que usarem vez dereaddirdinâmicofile[0]A asserção de importação para JSON é a prática padrão no Node 20+, que naturalmente lida com o carregamento de JSON em ambiente ESM. O custo é não poder ser usado em contexto síncrono, e cada importação é armazenada em cache pelo módulo — mas neste script de execução única, o cache não é problema.undefined,undefined !== '_'A verificação
Tratamento de artefatos removidos.Quando um artefato é removido, o relatório o marca com tachado em vez de removê-lo diretamente. Isso é um design intencional: os mantenedores precisam ver "este arquivo desapareceu", em vez de deixá-lo desaparecer silenciosamente da tabela. Se fosse filtrado diretamente, os leitores pensariam erroneamente que o artefato nunca existiu.
11.2 usage-size: simulando o cenário de importação de um usuário real
Modelo intuitivo
size-reportDiz quanto o "pacote completo" pesa, mas isso não responde à pergunta que o usuário realmente se importa: "Eu só usocreateApp, quanto código preciso baixar de fato?" O tamanho do pacote completo inclui muito código que você talvez nunca use (comodefineCustomElement、Transition、KeepAlive)。usage-size.jsO papel é atuar como um "usuário típico": escrever um arquivo de entrada virtual que importa apenas APIs específicas, empacotar com Rollup e ver o tamanho do artefato final.
É como um restaurante não te dizer "o peso total de todos os ingredientes na cozinha é 50 kg", mas sim "pedindo um frango Kung Pao, os ingredientes realmente usados são 300 gramas".
Estrutura de dados: array de Presets
A estrutura de dados central do script é opresetsarray, cada elemento descreve um cenário de uso:
📎 scripts/usage-size.js:27-55
PresetO tipo tem três campos:name(nome de exibição),imports(lista de APIs importadas do Vue), opcionalreplace(substituições adicionais em tempo de compilação). Cinco presets cobrem cenários de uso do menor ao maior:
createApp (CAPI only): importa apenascreateApp, e substitui__VUE_OPTIONS_API__por'false', simulando um usuário puro de Composition API📎scripts/usage-size.js:35-40createApp: importa apenascreateApp, mantém Options API📎scripts/usage-size.js:35-40createSSRApp: cenário SSR📎scripts/usage-size.js:35-40defineCustomElement: cenário Web Components📎scripts/usage-size.js:35-40overall: importa seis APIs principais, simulando um usuário "full-featured"📎scripts/usage-size.js:44-54
O arquivo de entrada é fixado como o artefato esm-bundler runtime-only:
📎 scripts/usage-size.js:24-28
Escolhervue.runtime.esm-bundler.jsem vez da versão completavue.esm-bundler.js, porque a versão runtime não inclui o compilador de templates, sendo mais próxima da situação real de usuários de ferramentas de build modernas — eles usam SFC para pré-compilar templates e não precisam do compilador em runtime.
Step-by-Step Walkthrough
Primeiro passo: gerar em paralelo os bundles de todos os presets.
📎 scripts/usage-size.js:62-69
main()Para cada preset, criargenerateBundlePromise, executar em paralelo comPromise.all. O paralelismo aqui é seguro, porque cadagenerateBundlechama independentementerollup(), sem compartilhar estado.
Segundo passo: construir a entrada virtual.
📎 scripts/usage-size.js:94-96
Esta é a parte mais engenhosa de todo o script. Ele não escreve arquivos temporários no disco, mas constrói um ID de módulo virtualvirtual:entry, cujo conteúdo é uma instrução re-export:export { createApp } from '/absolute/path/to/vue.runtime.esm-bundler.js'. Note queentryé um caminho absoluto, porque o Rollup precisa conseguir resolvê-lo.
Terceiro passo: configurar a cadeia de plugins do Rollup.
📎 scripts/usage-size.js:98-121
A ordem do array de plugins é crucial:
1. Personalizadousage-size-plugin:resolveIdinterceptavirtual:entryretorna a si mesmo,loadretorna o conteúdo virtual📎 scripts/usage-size.js:101-110. Este é o padrão padrão para módulos virtuais no Rollup.
2. nodeResolve(): resolvevue.runtime.esm-bundler.jsimports internos📎 scripts/usage-size.js:111。
3. replace: injeta constantes em tempo de compilação📎 scripts/usage-size.js:112-119。
replaceA configuração do plugin revela o mecanismo central do artefato esm-bundler: ele preserva__VUE_OPTIONS_API__、__VUE_PROD_DEVTOOLS__e outros flags de runtime, que são substituídos pela ferramenta de build do usuário. Aqui o script faz a substituição pelo usuário:
process.env.NODE_ENV→"production": segue o branch de produção__VUE_PROD_DEVTOOLS__→'false': desativa suporte a devtools__VUE_PROD_HYDRATION_MISMATCH_DETAILS__→'false': desativa mensagens detalhadas de erro de hydration__VUE_OPTIONS_API__→'true': mantém Options API por padrão
Então expande...preset.replace, permitindo que o preset sobrescreva os valores padrão.createApp (CAPI only)O preset usa exatamente esse mecanismo para mudar__VUE_OPTIONS_API__para'false' 📎 scripts/usage-size.js:35-40。
preventAssignment: trueEvitar substituirobj.process.env.NODE_ENV = xesse tipo de instrução de atribuição📎 scripts/usage-size.js:117。
Quarto passo: gerar, minificar, medir.
📎 scripts/usage-size.js:123-134
result.generate({})produz o código, obtémoutput[0].code. Depois minifica com SWC:
📎 scripts/usage-size.js:125-130
module: trueindica que a entrada é ESM,toplevel: truepermite minificar nomes de variáveis no escopo de nível superior. Após a minificação, calcula três métricas separadamente:minified.length(comprimento em bytes),gzipSync(minified).length、brotliCompressSync(minified).length。
Note que aqui é usada anode:zlibAPI síncrona, em vez da versão assíncrona. Em um script de execução única, a API síncrona é mais concisa, e a minificação em si é uma operação intensiva de CPU, então assincronia não traria ganho de paralelismo.
Quinto passo: saída e persistência.
📎 scripts/usage-size.js:62-86
Os resultados são primeiro impressos no console em formato legível por humanos, compicocolorindo📎 scripts/usage-size.js:62-86. Depois escreve emtemp/size/_usages.json, usandoObject.fromEntriespara converter o array de volta em Record, com chave sendo o nome do preset📎 scripts/usage-size.js:81-85。
--writeO flag controla se deve adicionalmente escrever o bundle não minificado de cada preset no disco📎 scripts/usage-size.js:136-138, para depuração.
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"]Reflexões de design e armadilhas
Por que usar módulo virtual em vez de arquivo temporário?Arquivos temporários exigem lidar com caminhos, limpeza, conflitos de escrita concorrente. O módulo virtual mantém o conteúdo da entrada na memória, e oresolveId/loadhook do Rollup suporta naturalmente esse padrão. O custo é que é preciso corresponder exatamente ao ID, qualquer erro de digitação fará o Rollup reportar "não foi possível resolver a entrada".
replaceOpreventAssignmentarmadilha doSe não definirpreventAssignment: true,replaceo plugin também fará substituição emprocess.env.NODE_ENV = 'x'instruções de atribuição como essa, produzindo"production" = 'x'erro de sintaxe. No código-fonte do Vue realmente existe atribuição aprocess.env.NODE_ENV(em ferramentas de teste), então esta opção é necessária.
__VUE_OPTIONS_API__Escolha do valor padrão deO script define o valor padrão como'true' 📎 scripts/usage-size.js:116, em vez de'false'. Esta é uma escolha conservadora: se o usuário não configurar, o Vue manterá suporte a Options API.createApp (CAPI only)O preset sobrescreve explicitamente para'false', mostrando o ganho de tamanho após desativar. Essa comparação em si é documentação para o usuário: dizer ao usuário "quanto se economiza desativando Options API".
ParaleloPromise.allSemântica de falha doSe o empacotamento de qualquer preset falhar,Promise.allserá rejeitado imediatamente, os outros empacotamentos em andamento não serão cancelados (o Rollup não fornece mecanismo de cancelamento). Em CI, isso significa que uma falha desperdiça o cálculo dos outros presets, mas o script em si termina com código de saída diferente de zero, e o CI consegue capturar corretamente.
11.3 Dos dados ao gate: como o CI consome esses relatórios
Panorama do fluxo de dados
Para entender esses dois scripts, é preciso colocá-los de volta no pipeline de CI.size-data.ymlExecuta ao fazer push para main/minor ou em PRpnpm run size 📎 .github/workflows/size-data.yml:45, produztemp/sizediretório, e então faz upload como artifact📎 .github/workflows/size-data.yml:53-57。
Para PRs, ele também grava dois arquivos de metadados adicionais:
📎 .github/workflows/size-data.yml:47-51
number.txtarmazena o número do PR,base.txtarmazena o nome do branch de destino. Esses dois arquivos são exatamentesize-report.jsemfilterFilesos que devem ser filtrados.txtarquivos📎 scripts/size-report.js:44-45. Eles existem para que osize-report.ymldownstream saiba "com qual baseline comparar".
Obtenção e comparação do baseline
size-report.yml(detalhado no capítulo anterior) o workflow é: baixar osize-dataartifact do PR atual, baixar o artifact de baseline do branch de destino, descompactar o baseline emtemp/size-prev, e então executarsize-report.jspara gerar o relatório Markdown e comentar no PR.
Aqui há uma restrição de design fundamental:size-report.jsele próprio não é responsável por obter o baseline, ele assume quetemp/size-prevjá existe. Se não existir,existsSync(prevDir)retorna false,prevarray vazio📎 scripts/size-report.js:48, todos os diffs são strings vazias. Isso é degradação graciosa: sem baseline o relatório ainda é gerado, apenas não mostra diferenças.
Lógica de decisão do gate de tamanho
É preciso esclarecer um mal-entendido comum:size-report.jsele próprio não faz a decisão do gate. Ele apenas gera o relatório, não retorna código de saída, não define limiares. O gate real acontece nosize-report.ymlnível do workflow — ele pode conter um passo que analisa os valores de diff no relatório e faz o job falhar se ultrapassar o limiar.
Esse design de "separação entre medição e decisão" tem razões profundas: o script de medição deve permanecer puro, responsável apenas por produzir fatos; a lógica de decisão deve estar no nível do workflow, porque os limiares podem variar conforme versão, branch e estágio de release. Codificar os limiares diretamente emsize-report.jstornaria difícil reutilizá-lo.
Reflexões de design
Por que o orçamento de tamanho precisa de dois conjuntos de medições?O tamanho do pacote completo e o tamanho de usage respondem a perguntas diferentes. O tamanho do pacote completo é o "limite superior" — ele informa quanto o usuário precisa baixar no pior caso. O tamanho de usage é o "valor típico" — ele informa quanto a maioria dos usuários realmente baixa. Só combinando os dois é possível obter um retrato completo do tamanho. Se houvesse apenas o tamanho do pacote completo, os mantenedores tenderiam a otimizar excessivamente APIs pouco usadas; se houvesse apenas o tamanho de usage, poderiam ignorar explosões de tamanho em certos cenários de borda.
O significado das métricas duplas de gzip e brotli.CDNs modernas geralmente suportam brotli, mas nem todos os cenários o habilitam. Reportar ambos permite que os mantenedores avaliem "como fica o tamanho em ambientes que só suportam gzip". O brotli costuma ser 15-20% menor que o gzip, e essa diferença em si já é informação valiosa.
O contrato de estabilidade do formato de dados. size-report.jseusage-size.jssão desacoplados via arquivos JSON.usage-size.jsescreve_usages.json,size-report.jslê. Os nomes de campos desse contrato (name、size、gzip、brotli) são implícitos, sem validação de schema. Seusage-size.jsmudar um nome de campo e esquecer de sincronizarsize-report.js, o relatório exibirá dados incorretos silenciosamente. Esse é o ponto frágil do design atual.
Resumo do capítulo
Reflexões e autoavaliação do capítulo
Q1: size-report.jsOfilterFilesfiltra arquivos que começam com_. Seusage-size.jsrenomear o arquivo de saída de_usages.jsonparausages.json, o que acontecerá?
Análise de referência:filterFilesA condição de filtro defile[0] !== '_' && !file.endsWith('.txt') 📎 scripts/size-report.js:44-45éusages.json. Se o arquivo for renomeado para_, ele não começa mais comfilterFiles, será mantido porfileListe entrará na união derenderFiles. EntãoimportJSONtentará tratá-lo como arquivo de bundle:Record<string, UsageResult>consegue importá-lo com sucesso (é JSON válido), mas sua estrutura éBundleResultem vez decurr?.file, entãoundefined,fileNameécurr.sizestring vazia,undefined,prettyBytes(undefined)também éfilterFileslançará erro ou produzirá saída anômala. Isso fará o relatório falhar. A raiz do problema é que
Q2: usage-size.jsusa o prefixo do nome do arquivo como critério para distinguir "metadados vs dados", em vez de usar estrutura de diretórios ou uma lista explícita. Uma abordagem mais robusta seria colocar os dados de usage em um subdiretório, ou manter uma lista explícita de arquivos de metadados.Promise.all(tasks)Emreplaceexecuta em paralelo o empacotamento de todos os presets. Se a configuração de__VUE_OPTIONS_API__de algum preset omitir'true', o que acontecerá? Por que o valor padrão é definido como'false'?
em vez de:replaceAnálise de referência__VUE_OPTIONS_API__: 'true'Na configuração do plugin...preset.replace,📎 scripts/usage-size.js:116-118é o valor padrão, e então o spread de'true'permite sobrescrever'true'. Se algum preset omitir a configuração, ele usará o valor padrão__VUE_OPTIONS_API__, ou seja, manterá o suporte à Options API, e o tamanho ficará maior. Definir o padrão como'false'é uma escolha conservadora: reflete "o comportamento real quando o usuário não configura". No artefato esm-bundler do Vue, o comportamento padrão decreateApp (CAPI only)é manter a Options API (a menos que o usuário a desative explicitamente). Se o padrão fosse'false' 📎 scripts/usage-size.js:35-40, todos os presets sem configuração explícita mostrariam tamanhos menores, enganando o usuário a pensar que "não configurar economiza tamanho".
Q3: size-report.jsO preset define explicitamenteimportJSONjustamente para mostrar "o ganho após desativação explícita", contrastando com o valor padrão.import()Ofs.readFileSyncdetemp/size-prevusa
dinâmico em vez de. Se algum arquivo JSON no diretórioimport()estiver corrompido (JSON inválido), qual é a diferença de comportamento entre as duas implementações?SyntaxErrorAnálise de referênciaimportJSON: oexistsSyncdinâmico lançaexistsSyncApenas verifica se o arquivo existe, não verifica a validade do conteúdo📎 scripts/size-report.js:112-115. O erro será propagado para cima atérenderFiles, causando a falha na geração de todo o relatório. Se usarfs.readFileSync + JSON.parse, também lançará erro, mas pode ser envolvido com try-catch dentro deimportJSON, retornandoundefinedpara implementar degradação graciosa. A implementação atual opta por deixar o erro se propagar, com a suposição implícita de que "o JSON no artifact é sempre válido" — essa suposição geralmente é válida em ambientes de CI, pois os arquivos são gerados porusage-size.jse scripts de build. Mas ao depurar localmente, se o arquivo JSON for modificado manualmente e corrompido, o relatório irá travar diretamente em vez de pular o arquivo. Esta é uma escolha de design de "confiar na fonte de dados".
---
O mecanismo de orçamento de tamanho resolve as questões de "o que medir" e "como comparar", mas depende de uma premissa: o artefato de build em si é reproduzível. O próximo capítulo entrará no sandbox mínimo de depuração:vite-debugcomo iniciar um ambiente de desenvolvimento Vue interativo com o mínimo de configuração, e como ele se integra com os artefatos de build locais, formando um ciclo fechado da modificação do código-fonte até a verificação em tempo de execução.
Até aqui, o ciclo de medição do orçamento de tamanho está claro: size-report.js usa comparação de diretórios para responder "quanto aumentou", usage-size.js usa módulos virtuais para simular cenários reais de importação e responder "onde aumentou", enquanto a decisão de gate é deixada para a camada de workflow. Esse mecanismo transforma a regressão de tamanho de reclamações vagas em dados rastreáveis. Mas os dados só podem dizer que o problema existe; para realmente localizar e corrigir, ainda é necessário um ambiente mínimo que possa reproduzir o problema rapidamente. O próximo capítulo entrará em packages-private/vite-debug, para ver como o Vue usa Vite + SFC para construir um sandbox de depuração minimalista, transformando "fazer uma reprodução mínima no código-fonte real" em uma prática diária operacional.
Gostou deste capítulo? Crie um livro para seu repositório privado
Arquitetura local-first em Tauri 2 + Rust. 100% offline e seguro, zero upload de código. Leitura em painel duplo com âncoras imutáveis de commit.
⚡ Tauri 2 · Rust Core · 100% Offline e Privado · Testado em 1M+ linhas
Capítulo 12: Sandbox mínimo de depuração: vite-debug e o ciclo fechado de desenvolvimento local
No capítulo anterior, concluímos o ciclo de medição do orçamento de tamanho: size-report.js responde "quanto aumentou", usage-size.js responde "onde aumentou", e a camada de workflow é responsável pela decisão de gate. Mas esse mecanismo tem uma premissa implícita — o artefato de build em si é reproduzível. Quando você descobre que o tamanho de algum pacote aumentou anormalmente, ou que algum comportamento em tempo de execução não corresponde ao esperado, você precisa de um ambiente mínimo que possa carregar rapidamente o código-fonte local e ver o efeito imediatamente após a modificação. packages-private/vite-debug é esse ambiente. Ele tem apenas quatro arquivos, totalizando menos de 40 linhas de código, mas constitui a entrada da prática diária de "fazer uma reprodução mínima no código-fonte real" no repositório Vue core. Este capítulo irá desmontar arquivo por arquivo a lógica de construção deste sandbox, e explicar por que ele foi colocado em packages-private em vez do diretório packages.
I. O esqueleto do sandbox:main.tseApp.vuecadeia mínima de montagem
Modelo intuitivo
Se compararmos todo o runtime do Vue a um motor, entãovite-debugé uma "bancada de testes bare-metal" — sem carcaça, sem painel de instrumentos, apenas a fiação mínima para fazer o motor funcionar. Seu valor não está na completude funcional, mas emeliminar todas as variáveis de interferência: quando você suspeita que um bug está no sistema de reatividade ou dentro do renderer, você não quer que a complexidade do próprio ambiente de depuração se torne uma fonte de ruído.
Estrutura de dados e layout de arquivos
Vejamos primeiromain.tstodo o conteúdo de:
📎 packages-private/vite-debug/main.ts:4-4
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.mount('#app')Estas seis linhas de código são o paradigma padrão de inicialização de uma aplicação Vue, mas cada linha tem um significado de engenharia preciso em cenários de depuração:
- L1Em
import { createApp } from 'vue'de'vue', para onde o identificador de módulovite.config.tsé finalmente resolvido depende inteiramente das declarações de dependência depackage.jsone - L2. Este é o elo mais crítico de todo o sandbox — veremos mais adiante como ele é apontado para o código-fonte local.
import App from './App.vue'O@vitejs/plugin-vuedeApp.vueaciona o pipeline de compilação SFC de<script>、<template>、<style>: o Vite registra este plugin na inicialização do dev server, e quando o navegador solicita - L4, o plugin o decompõe em
createApp(App)três módulos virtuais compilados separadamente.app._context、app._instanceO - L6de
app.mount('#app')cria a instância da aplicação; neste momento, o Vue inicializa internamenteappe outros campos principais, mas ainda não aciona nenhuma renderização.
Oindex.htmldeindex.htmlé o verdadeiro interruptor de inicialização: ele procura no DOM o elemento container com id<div id="app"></div>, cria a instância do componente raiz e aciona a primeira renderização.<script type="module" src="/main.ts"></script>Observe que não há referência aapp.mount('#app')aqui — a convenção do Vite é que o
no diretório raiz do projeto serve como HTML de entrada, contendo
eApp.vue. Embora este arquivo não esteja nos keyFiles deste capítulo, ele é o pré-requisito para que
📎 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>Walkthrough orientado a cenários: a cadeia completa de um cliqueAgora vejamos
, que é o "veículo de experimento" deste sandbox:
@vitejs/plugin-vueCopiarApp.vueColocando em um cenário concreto:
<script setup>O que acontece quando o usuário clica no botão no navegador?setup()Primeiro passo: fase de compilação SFC (na inicialização do dev server)ref(0)compilaRefImplem três partes:.valueO bloco0。<template>é compilado na função{{ count }}do componente,_toDisplayString(count.value),@click="count++"a chamada retorna um objetoonClick: $event => (count.value++)。<style>cujo<style>é inicialmente
O blocoapp.mounté compilado na função de renderização,
createApp(App)é convertido emmount('#app'), o componente raiz é criadoComponentInternalInstance, executa-sesetup()para obtercounto RefImpl de , e então a função de renderização é chamada para gerar a árvore VNode. Na função de renderização, a leitura decount.valueacionatracka coleta de dependências — o efeito de renderização atualmente ativo (ReactiveEffect) é registrado emcountnodepde .
Terceiro passo: evento de clique (durante interação do usuário)
O navegador dispara o eventoclick, e o manipulador de eventos do Vue executacount.value++. Esta é uma operação setter, que acionatrigger: percorre os efeitos colaterais coletados emcount.dep, agendando a re-renderização. Como é uma atualização síncrona e não está na fila de lotes, o efeito de renderização é executado imediatamente, chamando novamente a função de renderização, gerando um novo VNode, fazendo diff com o VNode antigo, descobrindo que o conteúdo de texto mudou de0para1, e atualizando otextContent。
do DOM real. Todo o encadeamento pode ser representado pelo seguinte diagrama de fluxo de dados:
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"| triggerO ponto-chave deste diagrama é:Existem apenas dois pontos de acoplamento entre os artefatos de tempo de compilação e o comportamento de tempo de execução——ref(0)O objeto RefImpl retornado por , e a leitura/escrita decount.valuena função de renderização. Isso significa que, se você quiser depurar um determinado ramo do sistema reativo (por exemplo,triggera lógica de agendamento em ), basta construir o padrão de leitura/escrita correspondente nesteApp.vue.
Reflexão de design: por querefem vez dereactive?
Escolherref(0)em vez dereactive({ count: 0 })como exemplo padrão implica uma consideração de prioridade de depuração:refO caminho de acesso de.valueé mais curto; ao expandir o objetoRefImplno depurador, é possível ver diretamente campos internos como_value、dep、__v_isRef, enquanto expandir o objeto Proxy retornado porreactiveno console aciona o getter, o que pode interferir na observação do estado original. Para cenários de "reprodução mínima", reduzir uma camada de indireção Proxy significa menos variáveis.
---
Dois, resolução de alias:vite.config.tsepackage.jsoncomo apontar'vue'para o código-fonte local
Modelo intuitivo
vite.config.tstem apenas seis linhas, mas é o "centro de roteamento" de todo o sandbox — determina se oimport { createApp } from 'vue'em'vue'será, no final, carregado da versão publicada no npm ou do código-fonte em desenvolvimento no repositório. Se não houver configuração de alias correta, o código que você modifica emApp.vuepode nem sequer acionar a versão do código-fonte do Vue que você está depurando, e a depuração se torna "atirar no alvo errado".
Estrutura de dados e cadeia de resolução
Primeiro vejavite.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()],
})Aquinão há configuração explícita deresolve.alias. Então comoé resolvido para o código-fonte local? A resposta está em'vue':package.jsonCopiar
📎 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:*"
}
}. Esta é a declaração do protocolo pnpm workspace, indicando queL13:"vue": "workspace:*"depende do pacote local chamadovite-debugno monorepo, e não da versão no npm registry. O pnpm criará um link simbólico emvue, apontando paranode_modules/vue(o diretório do pacote principal do Vue).packages/vueMas isso ainda não é suficiente —
o campopackages/vueempackage.jsondemain/module/exportsgeralmente aponta paraartefatos de build(comodist/vue.runtime.esm-bundler.js), e não para o código-fonte emsrc/. Se você modificarpackages/runtime-core/src/renderer.ts, mas não reconstruir, o Vite ainda carregará o arquivodistantigo.
É por isso que nopackages/vue/package.jsondo repositório Vue core geralmente se configura"development"exportações condicionais ou mapeamentos semelhantes de entrada de código-fonte — em modo dev, oresolve.conditionsdo Vite dará prioridade à condiçãodevelopment, carregando assimsrc/index.tsem vez dedist. Esse mecanismo permite quevite-debug, sem configurar alias explicitamente, veja o efeito imediatamente via HMR após modificar o código-fonte.
Walkthrough orientado por cenário: um processo de resolução deimport 'vue'.
Contextualizando:Quando o Vite dev server recebe a requisição do navegador paramain.tse encontraimport { createApp } from 'vue', como é a cadeia de resolução?
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 --> verifyEste fluxograma revela um ramo crítico:Se a condiçãodevelopmentnão estiver configurada corretamente, após modificar o código-fonte o navegador não fará hot update, e você ficará preso na confusão de "mudei o código, mas o comportamento não mudou". O método de investigação é verificar no painel Network do DevTools do navegador o caminho real de carregamento do módulovue— se você vir o caminhodist/, isso indica que o mapeamento de entrada do código-fonte não entrou em vigor.
Reflexão de design: por que não escrever alias explicitamente emvite.config.ts?
Uma dúvida natural é: por que não escrever diretamentevite.config.tsemresolve: { alias: { vue: '../../packages/vue/src/index.ts' } }? Embora isso seja intuitivo, há dois problemas:
1. Quebra importações de subcaminho: A API pública do Vue inclui subcaminhos comovue/server-renderer、vue/compiler-sfc. Se apenas'vue'em si receber alias, as importações de subcaminho ainda passarão pordist, fazendo com que parte dos módulos venha do código-fonte e parte do artefato, resultando em comportamento inconsistente.
2. Ignora o mecanismo de exportações condicionais: Nopackage.jsondo Vue, o campoexportsjá define o mapeamento completo de exportações condicionais (development/production/browser/nodeetc.); o alias sobrescreverá esse mecanismo, fazendo com que o comportamento de resolução do ambiente de depuração divirja do ambiente real do usuário.
Portanto,vite-debugescolhe a combinação "confiar no protocolo workspace + exportações condicionais", tornando a cadeia de resolução o mais próxima possível do cenário de uso real. Isso também explica por quepackage.jsonem"vue": "workspace:*"é necessário — é o pré-requisito para acionar o link simbólico do pnpm e, assim, permitir que o Vite encontrenode_modules/vueatravés depackages/vue.
Armadilhas em produção:catalog:protocolo e deriva de versão
Observe quepackage.jsonemL11-L12usa o protocolo"catalog:":
"@vitejs/plugin-vue": "catalog:",
"vite": "catalog:",Este é o recurso catalog do pnpm, indicando que o número de versão é gerenciado uniformemente pelo campopnpm-workspace.yamlemcatalog. Sua função éevitar deriva de versão quando vários pacotes no monorepo referenciam a mesma dependência。
Em cenários de depuração, isso traz uma armadilha oculta: se você emvite-debugEncontrou um possível bug do Vite ou do plugin-vue e quer atualizar temporariamente a versão para verificar, modificando diretamentepackage.jsonocatalog:em é ineficaz — você precisa modificarpnpm-workspace.yamla definição de catalog em, o que afetará todos os pacotes que usam esse catalog. A abordagem correta é alterar temporariamente para um número de versão explícito (como"vite": "5.0.0"), e após a verificação, reverter paracatalog:。
---
III.packages-privateO design de isolamento de: por que o sandbox de depuração não é publicado externamente
Modelo intuitivo
packages-privateO diretório é como o "laboratório interno" da empresa — as amostras dentro não são vendidas externamente, servem apenas para testes e demonstrações. Ele está fisicamente isolado dopackagesdiretório, evitando que o código de depuração seja publicado acidentalmente no npm.
Três camadas de garantia do mecanismo de isolamento
Primeira camada: isolamento de diretório
packages-private/vite-debugNão está sobpackages/, enquantopnpm-workspace.yamlgeralmente declarapackages/*epackages-private/*ambos como membros do workspace, mas scripts de publicação (comoscripts/release.js) percorrem apenas os pacotes sobpackages/.
Segunda camada:private: true
📎 packages-private/vite-debug/package.json:3
"private": true,Esta linha é uma restrição obrigatória do npm/pnpm: pacotes marcados comoprivatenunca podem serpublicadosnpm publishpelo, mesmo que executados manualmente serão rejeitados. Esta é a última linha de defesa contra publicação acidental.
Terceira camada: sem o campoversion
Observe quepackage.jsonnão possui o campoversion. A especificação do npm exige que pacotes publicáveis tenhamversion, e pacotes sem esse campo gerarão erro aonpm publish. Este é o "seguro duplo" — mesmo queprivateseja removido acidentalmente, a falta deversionainda impedirá a publicação.
Reflexão de design: a divisão de trabalho entre o sandbox de depuração e o Playground
O repositório do Vue core já possui umSFC Playgroundcompleto (discutido no Capítulo 7), por que ainda é necessáriovite-debug?
As posições dos dois são completamente diferentes:
| Dimensão | SFC Playground | vite-debug |
|---|---|---|
| Ambiente de execução | No navegador (compilação também no navegador) | Node.js + navegador |
| Carregamento do código-fonte | Via CDN ou artefatos pré-compilados | Carrega diretamente o código-fonte local |
| Capacidade de depuração | Limitada pelo sandbox do navegador | Pode usar depurador do Node.js, breakpoints |
| Modificação do código-fonte | Não suportado | Suporta HMR |
| Cenários de uso | Validar saída de compilação, compartilhar reproduções | Depurar comportamento interno em tempo de execução |
vite-debugO valor central de está emEle roda em um ambiente Node.js real, você pode usarnode --inspectpara anexar o depurador, definir breakpoints empackages/reactivity/src/effect.ts, observarReactiveEffecto processo de criação e agendamento. Isso é algo que o Playground não pode oferecer.
Armadilhas em produção: limites do HMR e perda de estado
Ao usarvite-debugpara depurar, uma confusão comum é: após modificarApp.vueo valor inicial decountem, o contador no navegador não é redefinido. Isso ocorre porque o HMR do Vite trata blocos<script setup>comopreservando o estado do componente e substituindo apenas a função de renderização. Se você precisar redefinir completamente o estado, é necessário atualizar a página manualmente, ou adicionarApp.vueemimport.meta.hot?.invalidate()para forçar a atualização completa da página.
Outra armadilha é: quando você modifica o código-fonte sobpackages/runtime-core/src/, a cadeia de propagação do HMR pode não ser acionada automaticamente — porquevite-debugo limite do HMR é definido no nível deApp.vue, epackages/as alterações no código-fonte sob precisam se propagar através do grafo de módulos do Vite. Se descobrir que o navegador não reage após modificar o código-fonte, verifique se a saída do terminal do Vite temhmr updatelogs; se não tiver, pode ser necessário reiniciar o dev server.
---
Resumo do capítulo
packages-private/vite-debugCom quatro arquivos e menos de 40 linhas de código, construiu um ciclo completo de depuração:
1. main.tsFornece o caminho mínimo de montagem:createApp(App).mount('#app'), excluindo toda lógica de inicialização não essencial.
2. App.vueComo veículo de experimentação:ref+ interpolação de template + tratamento de eventos, cobrindo o caminho principal do sistema reativo.
3. vite.config.ts + package.jsonAtravés doworkspace:*protocolo e exportações condicionais, resolve'vue'para o código-fonte local, alcançando "modificar o código-fonte e entrar em vigor imediatamente".
4. packages-private + private: true+ semversionisolamento em três camadas, garantindo que o código de depuração não seja publicado acidentalmente.
A filosofia de engenharia deste sandbox é:A complexidade do próprio ambiente de depuração deve tender a zero, deixando toda a complexidade para o código-fonte sendo depurado. Quando você encontra um bug difícil de reproduzir empackages/reactivity,vite-debugfornece uma bancada de experimentos que pode ser modificada livremente e verificada imediatamente.
Reflexão e autoavaliação do capítulo
Q1: Se alterarpackage.jsono"vue": "workspace:*"em para"vue": "^3.4.0", após modificarvite-debugempackages/reactivity/src/ref.ts, o que acontecerá com o comportamento no navegador? Por quê?
Análise de referência: Após alterar para"^3.4.0", o pnpm baixará a versão publicada do Vue 3.4.x do npm registry, em vez de linkar para opackages/vue 📎 packages-private/vite-debug/package.json:13local. Neste momentoimport { createApp } from 'vue'resolve paranode_modules/.pnpm/vue@3.4.x/node_modules/vue/dist/vue.runtime.esm-bundler.js, ou seja, o artefato pré-compilado. Modificarpackages/reactivity/src/ref.tsnão acionará nenhum HMR, porque o grafo de módulos do Vite simplesmente não inclui esse arquivo. O que roda no navegador ainda é a implementação derefda versão npm. Este experimento valida inversamente queworkspace:*é uma condição necessária para depuração em nível de código-fonte.
Q2: App.vueO bloco<style>em não adicionouscoped, se montar duas instâncias de componente simultaneamente neste sandbox, o que acontecerá com os estilos? Qual é a relação disso com o objetivo de depuração devite-debug?
Análise de referência: Semscoped, obutton { color: red }é um estilo global📎 packages-private/vite-debug/App.vue:4-8, atuará sobre todos os elementos<button>na página. Se montar duas instâncias de componente, os botões de ambas as instâncias ficarão vermelhos. A relação com o objetivo de depuração está em:vite-debuga posição de é "reprodução mínima", não "validação de isolamento de estilo". Omitirscopedreduz as variáveis de injeção de atributosdata-v-xxxem tempo de compilação, tornando a estrutura DOM no depurador mais limpa. Se você precisar depurar a lógica de compilação descopedestilos, deve adicionar explicitamentescopede observar@vitejs/plugin-vueo código de injeção de atributos gerado.
Q3: Suponha que você adicionou uma linhapackages/runtime-core/src/renderer.tsna funçãopatchdeconsole.log, mas o console do navegador não exibe nada. Liste pelo menos três possíveis causas e explique como investigar cada uma.
Análise de referência:
Causa um:A entrada do código-fonte não entrou em vigor。'vue'resolveu para o artefatodistem vez desrc. Diagnóstico: no painel Network do DevTools, verifique ovuecaminho de carregamento do módulo; se começar comdist/, significa que a exportação condicional não correspondeu àdevelopmentcondição📎 packages-private/vite-debug/package.json:13。
Causa dois:HMR não propagado. O grafo de módulos do Vite não propagou as alterações depackages/runtime-core/src/renderer.tsparavite-debug. Diagnóstico: verifique se o terminal do Vite tem logs dehmr update; se não tiver, reinicie o dev server.
Causa três:patchfunção não chamada. Se a página atual não dispara nenhuma atualização do DOM (por exemplo, nenhum clique em botão),patchpode ser executado apenas uma vez na primeira montagem, e a primeira montagem ocorreu antes de você adicionarconsole.log. Diagnóstico: atualize a página ou adicione uma ação que dispare atualização emApp.vue.
Causa quatro (complementar):cache de build. O cache de pré-build de dependências do Vite (node_modules/.vite) pode ainda usar a versão antiga. Diagnóstico: excluanode_modules/.vitee reinicie.
---
O orçamento de tamanho informa que "o problema existe",vite-debugpermite que você "reproduza o problema com as próprias mãos". Mas quando você tenta generalizar esse modo sandbox para todo o monorepo, encontra uma série de condições de contorno: diferenças de resolução do protocolo workspace em ambientes de CI,catalog:o dilema de atualização com versões fixadas,packages-privateepackagesrestrições de direção de dependência entre ... O próximo capítulo entrará em trade-offs de arquitetura e guia para evitar armadilhas, organizando sistematicamente as condições de contorno expostas pela engenharia de monorepo em projetos reais.
Até aqui, concluímos o ciclo de engenharia da medição de tamanho à reprodução mínima: o vite-debug, com apenas quatro arquivos minimalistas, transformou "validar rapidamente no código-fonte real" em uma prática de uso diário. Mas quando você realmente começa a replicar esse sistema, descobre mais trade-offs ocultos — por que packages-private deve ser fisicamente isolado de packages? Por que a inline de enums deve ser concluída antes do Rollup? O próximo capítulo reunirá os pontos de decisão críticos e registros de armadilhas em produção expostos nos doze capítulos anteriores, oferecendo uma lista completa de prevenção de armadilhas e base para decisões.
Gostou deste capítulo? Crie um livro para seu repositório privado
Arquitetura local-first em Tauri 2 + Rust. 100% offline e seguro, zero upload de código. Leitura em painel duplo com âncoras imutáveis de commit.
⚡ Tauri 2 · Rust Core · 100% Offline e Privado · Testado em 1M+ linhas
Capítulo 13: Trade-offs de arquitetura e guia para evitar armadilhas: condições de contorno da engenharia de monorepo
No capítulo anterior, usamospackages-private/vite-debugcomo ponto de entrada e dominamos o paradigma de depuração para reprodução mínima no código-fonte real. Quando esse tipo de pacote de depuração interna se multiplica, surge um problema prático: eles coexistem no mesmo workspace com os pacotes oficiais publicados externamente; como garantir que o fluxo de publicação não os afete por engano? Este capítulo aprofundará as condições de contorno da engenharia de monorepo, partindo do contrato de diretório duplo entrepackagesepackages-private, analisará o design defensivo por trás dos trade-offs de arquitetura e fornecerá um guia prático para evitar armadilhas.
13.2 Regra temporal: a inline de enums deve ser executada antes do Rollup
Modelo intuitivo
A inline de enums é como "trocar as etiquetas das peças por números antes de embalar". Se o empacotador (Rollup) já começou a embalar e você for alterar as etiquetas depois, as peças dentro da caixa não corresponderão mais às etiquetas.build.jsusascanEnums() / removeCache()este par de funções para prender estritamente a inline antes do Rollup.
Estrutura de dados e ciclo de vida
inline-enums.jsexportascanEnums()retorna umremoveCacheclosure, que varre as definições de enum no código-fonte e gera arquivos temporários para o Rollup consumir📎 scripts/build.js:30-34。build.jsderun()usatry/finallypara garantir a limpeza do cache📎 scripts/build.js:81-112:
const removeCache = scanEnums()
try {
// ... buildAll / checkAllSizes / build-dts
} finally {
removeCache()
}rollup.config.jschama no nível superior do móduloinlineEnums()para obter[enumPlugin, enumDefines] 📎 rollup.config.js:47-50, ondeenumPlugininsere no array plugins📎 rollup.config.js:331-331,enumDefinese incorpora à tabela de substituição do plugin replace📎 rollup.config.js:222-223。
Passo a passo: o ciclo de vida completo de um enum em uma build
1. build.jsderun()primeiro chamascanEnums(), varre as definições de enum de todos os pacotes e grava no cache temporário, retornandoremoveCache 📎 scripts/build.js:87-87。
2. buildAlle inicia múltiplos processos Rollup em paralelo📎 scripts/build.js:119-121。
3. Cada processo Rollup executa na fase de carregamento de configuraçãoinlineEnums(), lê o cache gerado na etapa anterior e obtémenumPlugineenumDefines 📎 rollup.config.js:47-50。
4. enumPluginna fase de transform, substitui as referências de enum no código-fonte por literais;enumDefinescomo complemento do replace, trata substituições de constantes entre módulos📎 rollup.config.js:222-223。
5. Ao final da build,finallyo bloco chamaremoveCache()para limpar arquivos temporários📎 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 块"]Reflexões de design e armadilhas
Por que não usar um plugin Rollup para varrer e usar na hora, na fase de transform? Porque a inline de enums precisa devisão global entre pacotes:runtime-coreo enum referenciado pode estar definido emshared, e um único processo Rollup só vê a árvore de código-fonte do próprio pacote, incapaz de concluir a substituição entre pacotes.scanEnums()estabelecer um cache global antes da build é justamente para resolver esse problema de visibilidade.
Pontos de armadilha em produção:removeCache()colocado emfinallysignifica que a limpeza ocorrerá mesmo se a build lançar erro no meio. Mas se você interromper manualmente o processo durante a depuração (Ctrl+C),finallypode não ser executado, e arquivos de cache residuais farão a próxima build ler enums expirados. Método de diagnóstico: verifique se há arquivos de cache de enum residuais no diretóriotemp/, exclua manualmente e tente novamente.
---
13.3 Orquestrador de publicação:release.jsmatriz de flags skip de
Modelo intuitivo
release.jsé como o diretor-geral de um casamento,skipBuild / skipTests / skipGit / skipPromptsos quatro interruptores são os botões de "pular ensaio", "pular votos", "pular fotos" e "pular confirmação". A existência de cada botão corresponde a um cenário real: ambientes de CI precisam deskipPrompts, depuração local precisa deskipGit, hotfix emergencial precisa deskipTests。
Estrutura de dados e valores padrão das flags
As quatro flags skip são declaradas emparseArgsem📎 scripts/release.js:39-50, e depois desestruturadas em variáveis locais📎 scripts/release.js:64-66:
let skipTests = args.skipTests
const skipBuild = args.skipBuild
const skipPrompts = args.skipPrompts
const skipGit = args.skipGitObserve queskipTestsusaletdeclaração, porque ela emrunTestsIfNeeded()será reescrita dinamicamente📎 scripts/release.js:281-317。
Step-by-Step: o fluxo completo de decisão de um release
main()a ordem de execução📎 scripts/release.js:143-279:
1. Verificação de sincronização remota:isInSyncWithRemote()compara o HEAD local com o SHA do branch remoto e, quando不一致, exibe uma caixa de confirmação📎 scripts/release.js:337-363。
2. Seleção de versão: quando não há argumento posicional, abreversionIncrementsmenu de seleção📎 scripts/release.js:152-176。
3. Decisão de teste:runTestsIfNeeded()é onde a lógica de skip é mais densa📎 scripts/release.js:281-317。
4. Atualização de versão:updateVersions()percorre todos os pacotes e reescrevepackage.json 📎 scripts/release.js:377-398。
5. Geração de Changelog: chamapnpm run changelog 📎 scripts/release.js:211-212。
6. Commit no Git:skipGitquando verdadeiro, todo o trecho é ignorado📎 scripts/release.js:231-240。
7. Publicação: executa somente quandoargs.publishfor verdadeirobuildPackages() + publishPackages() 📎 scripts/release.js:243-246。
runTestsIfNeeded()a lógica de branch merece ser detalhada separadamente:
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 --> doneReflexões de design e armadilhas
skipTestsusaletem vez deconsto design de em vez de existe para suportar o caminho de otimização “se o CI já passou, pular automaticamente os testes locais”. Isso economiza muito tempo em cenários de release via CI — orelease.ymldo GitHub Actions já executou os testes completos, então rodar tudo de novo localmente é puro desperdício.
O contrato oculto da ordem de publicação:sortPackagesForPublishingcolocavueno final📎 scripts/release.js:85-85, e o comentário deixa explícito que “o usuário não pode instalar o novo pacote de entrada antes que os pacotes internos estejam disponíveis”. Se você alterar essa ordenação, o usuárionpm install vue@nextpode obter uma versão cujas dependências ainda não foram publicadas, causandoERR_MODULE_NOT_FOUND。
Proteção de idempotência:publishPackagechama antes da publicaçãoisPackagePublishedverifica o registry📎 scripts/release.js:453-458, captura em caso de falha de publicaçãopreviously publishederro e faz downgrade para pular📎 scripts/release.js:480-488. Isso permite que o script de release seja repetido com segurança — após uma interrupção de rede, reexecutar não falhará por completo por causa de “pacote já existente”.
Rollback em caso de falha:fnToRun().catch()quandoversionUpdatedfor verdadeiro, chamaupdateVersions(currentVersion)faz rollback do número de versão📎 scripts/release.js:528-537. Mas atenção: isso só faz rollback dopackage.jsoncampo de versão emnão faz rollback do commit jágit commitenviado. Se você publicar com falha quandoskipGitfor falso, será necessário fazer manualmentegit reset。
---
Reflexão de design: o padrão comum dos três trade-offs
Revisando os três trade-offs centrais deste capítulo, eles compartilham a mesma filosofia de design:transformar “verificações em tempo de execução fáceis de esquecer” em “restrições estruturais impossíveis de contornar”。
packages-privateisolamento físico: não depende de o autor do script lembrar de verificarprivatecampo, mas faz com que o escopo de varredura o exclua naturalmente.- Inline de enum antecipado: não depende de o plugin do Rollup “por acaso” conseguir ver enums entre pacotes durante o transform, mas estabelece um cache global antes do build.
release.jsmatriz de skip de : não depende de o publicador lembrar que “se o CI passou, não precisa rodar testes locais”, mas faz o script consultar automaticamente o status do CI e reescreverskipTests。
O custo desse padrão éaumento da complexidade do script:build.jsprecisa manterprivatePackageslista,rollup.config.jsprecisa repetir a lógica de detecção de diretório,release.jsprecisa lidar com a combinação cruzada de quatro flags de skip. Mas para um repositório como o Vue, que publica várias vezes por semana, o ganho de confiabilidade trazido por restrições estruturais supera em muito o custo de complexidade.
---
Resumo do capítulo
Este capítulo, partindo do código-fonte, decompôs três condições de contorno críticas do sistema de engenharia do Vue core:
1. packages-privateepackagesisolamento físicogarantido em conjunto pelo workspace glob,build.jsdetecção de diretório,release.jsfiltro em três pontos📎 pnpm-workspace.yaml:1-3📎 scripts/build.js:153-170📎 scripts/release.js:68-83。
2. Restrição temporal do inline de enumgarantida obrigatoriamente pelascanEnums() / removeCache()detry/finallyestrutura, com a configuração do Rollup consumindo o cache no nível superior do módulo📎 scripts/build.js:81-112📎 rollup.config.js:47-50。
3. release.jsmatriz de flags de skip deatende a três cenários: release via CI, depuração local e hotfix emergencial,skipTestsa reescrita dinâmica e a ordenação da sequência de publicação são dois contratos ocultos mais facilmente ignorados📎 scripts/release.js:281-317📎 scripts/release.js:85-85。
Reflexões e autoavaliação deste capítulo
Q1: Se removermosbuild.jsembuild(target)a funçãoprivatePackages.includes(target)verificaçãopackagese usarmos uniformementepkgBasecomo
, em quais cenários isso causaria problemas?:build.js:160-164Análise de referêncianr build vite-debuga detecção de diretório é a única entrada pela qual pacotes privados podem ser construídos. Depois de removê-la,packages/vite-debugprocurará empackage.json,fs.readFileSyncmas esse diretório não existe,ENOENTlança diretamentepackages/. Um problema mais oculto é: se no futuro alguém criar um diretório com o mesmo nome embuildOptions, o build usará silenciosamente a configuração do diretório errado, e os caminhos de artefato erollup.config.js:37-42ficarão todos desalinhados. Além disso,build.jstem lógica independente de detecção de diretório, e os dois pontos precisam ser modificados em sincronia, caso contrário surgirá o estado inconsistente de “
Q2: release.jsencontrou o pacote, mas o Rollup não encontrou”.runTestsIfNeeded()emskipTests ||= isCIPassedesta linha de código (release.js:285) quandoskipPromptsfor verdadeiro e o CI não tiver passado, qual branch será seguido? Se removermoselse if (skipPrompts)do branchthrow, quais seriam as consequências?
Análise de referência: quandoskipPromptsfor verdadeiro e o CI não tiver passado,skipTests ||= isCIPassedemisCIPassedéfalse,skipTestsmantém o valor original (normalmentefalse). Em seguida entra noelse if (skipPrompts)branch, lançandoError(release.js:299-304). Se removermos estethrow, o código continuará atéif (!skipTests)branch, executando em ambiente não interativopnpm run test --run. No CI, isso pode fazer os testes falharem por diferenças de ambiente ou, pior — os testes passarem, mas o CI na prática não ter passado (por exemplo, o CI executa um subconjunto diferente de testes), publicando uma versão sem validação completa.
Q3: rollup.config.js:55deinlineEnums()é chamado no nível superior do módulo, enquantobuild.js:87descanEnums()é chamado dentro da funçãorun(). Se invertermos o momento de execução dos dois (ou seja, fazerinlineEnums()ser chamado nobuildStarthook do Rollup), o que seria quebrado?
Análise de referência:scanEnums()deve ser concluído antes que todos os processos do Rollup iniciem, porque precisa escaneartodos os pacoteso código-fonte para estabelecer o cache global de enum.inlineEnums()é chamado no nível superior do módulorollup.config.js, quando o Rollup ainda não iniciou nenhum build, e o cache já está pronto. Se fosse alterado para ser chamado embuildStart, cada processo do Rollup escanearia independentemente — masbuildAllé executado concorrentemente (build.js:119-121), e vários processos escaneando simultaneamente o mesmo conjunto de arquivos gerariam uma corrida: o processo A pode ler um arquivo de cache que o processo B ainda não terminou de escrever, resultando em substituição incompleta de enum. Mais grave ainda,scanEnums()o retorno deremoveCacheclosure depende do estado do file handle no momento da varredura, e em cenários concorrentes o momento de limpeza não pode ser coordenado.
Contrato de diretório duplo, determinação de atribuição de scripts de build, filtragem secundária de scripts de publicação — esses mecanismos juntos delimitam a fronteira de segurança da engenharia de monorepo. Mas a fronteira não é imutável: à medida que as ferramentas de build migram de Rollup para Rolldown e os testes de tipo e testes de runtime convergem, as estratégias de trade-off existentes também enfrentarão novos desafios. No próximo capítulo, com base na trajetória de mudanças de 3.0 a 3.4, vislumbraremos a direção de evolução da próxima geração do sistema de engenharia.
Gostou deste capítulo? Crie um livro para seu repositório privado
Arquitetura local-first em Tauri 2 + Rust. 100% offline e seguro, zero upload de código. Leitura em painel duplo com âncoras imutáveis de commit.
⚡ Tauri 2 · Rust Core · 100% Offline e Privado · Testado em 1M+ linhas
Capítulo 14: Evolução Futura: Do 3.x à Próxima Geração do Sistema de Engenharia
No capítulo anterior, examinamos as "fronteiras de segurança" do sistema de engenharia do Vue core — contrato de diretório duplo, determinação de atribuição de scripts de build, filtragem secundária de scripts de publicação. Esses mecanismos não foram projetados de uma só vez, mas repetidamente refinados ao longo das iterações de 3.0 a 3.4. Este capítulo adota uma perspectiva diferente: não olhamos mais "como é agora", mas sim "como chegou a ser assim", e a partir disso inferimos para onde a próxima geração do sistema de engenharia irá. O material de código-fonte deste capítulo são changelogs/CHANGELOG-3.3.md, changelogs/CHANGELOG-3.4.md e o package.json na raiz do repositório. Os changelogs parecem apenas um registro de "quais bugs foram corrigidos", mas são o relatório de check-up mais autêntico do sistema de engenharia: cada commit com prefixo build:, cada alteração com prefixo types:, cada reversão de versão de dependência, tudo expõe os pontos de estresse da arquitetura atual. O que precisamos fazer é ler a direção da evolução a partir desses pontos de estresse. Tratar o changelog como uma "janela de observação do sistema de engenharia" em vez de uma "lista de funcionalidades" é a metodologia central deste capítulo. As mudanças de funcionalidade nos dizem o que o Vue pode fazer, enquanto as mudanças relacionadas a build, tipos e CI nos dizem "onde dói" no sistema de engenharia do Vue.
I. Pontos de estresse da cadeia de ferramentas de build: o potencial de migração de Rollup para Rolldown
Modelo intuitivo
Imagine a cadeia de ferramentas de build como uma linha de montagem: Rollup é a bancada principal de montagem, esbuild é responsável pelo corte rápido (transpilação de TS), terser é responsável pela compactação final do empacotamento. À medida que o produto (o runtime do Vue) se torna cada vez mais complexo e as etapas na bancada de montagem aumentam, a própria bancada principal se torna o gargalo. O posicionamento do Rolldown é ser a bancada principal de montagem reescrita em Rust — ele não substitui o esbuild, mas o próprio Rollup.
Se não houvesse essa pressão evolutiva, o "desastre" que o sistema enfrentaria não seria um colapso, mas simo tempo de build inflando linearmente com o número de pacotes: a cada subpacote adicionado, seria necessário iniciar mais um processo Rollup, escanear mais uma vez o cache de enum, executar mais uma rodada de geração de dts.
Estrutura de dados e layout de dependências
Primeiro, vejamos um instantâneo estático da cadeia de ferramentas atual.package.jsonOdevDependenciesé uma "lista precisa da bancada de montagem":
📎 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",Aqui podemos extrair três fatos-chave. Primeiro, a versão principal do Rollup é^4.63.3, estando no período de maturidade do Rollup 4.x. Segundo,rollup-plugin-esbuildassume a transpilação de TS, o que significa que o próprio Rollup não analisa TS, apenas processa o JS emitido pelo esbuild. Terceiro,rollup-plugin-dtsé responsável independentemente pelo empacotamento de.d.ts, o que é exatamente a base material da independência dedts-built-testdiscutida no capítulo anterior.
Agora vejamos a orquestração de entrada dos scripts de build:
📎 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é "em duas etapas": primeirotsc --noCheckgera os arquivos de declaração brutos (--noCheckpula a verificação de tipos, apenas faz emit), depoisrollup -c rollup.dts.config.jsempacota os.d.tsdispersos em um único arquivo. Esse design em si depende das capacidades do Rollup —rollup-plugin-dtsprecisa do grafo de módulos do Rollup para rastrear dependências de tipos.
Orientado por cenários: o que um commit debuild:expôs
As entradas com prefixobuild:no changelog são evidências diretas dos pontos de estresse da cadeia de ferramentas de build. Vamos escolher três para analisar.
A primeira, o alinhamento de configuração de minify na 3.4.32:
📎 changelogs/CHANGELOG-3.4.md:84
* **build:** use consistent minify options from previous terser config ([789675f](https://github.com/vuejs/core/commit/789675f65d2b72cf979ba6a29bd323f716154a4b))A motivação deste commit é "após migrar de terser para esbuild minify, as opções de compactação ficaram inconsistentes". Isso revela um estado intermediário em migração: o Vue costumava usar terser para compactação, depois mudou para esbuild (devDependenciesemesbuild: ^0.28.2confirma isso), mas as opções de compactação não foram totalmente alinhadas, causando desvios no tamanho ou comportamento do artefato. Esse é exatamente o custo típico de "trocar peças da bancada de montagem".
A segunda, a reversão da versão de entities na 3.4.38:
📎 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é uma biblioteca de decodificação de entidades HTML, dependida porcompiler-dom. A reversão para 4.5 ocorreu porque a nova versão apresentou problemas na análise em runtime. Este commit mostra:a atualização de dependências da cadeia de ferramentas de build não é isolada; a mudança de versão de uma dependência indireta pode permear até o comportamento em runtime。
A terceira, a poluição do build cjs do server-renderer na 3.4.29:
📎 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)Este é o tipo mais típico de bug de build: no formato CJS,server-rendereracidentalmente incluiuruntime-coreem seu próprio artefato. A causa geralmente é a determinação deexternaldo Rollup falhando no formato CJS — ESM consegue identificar estaticamente dependências externas através deimportdeclarações, enquanto o CJSrequireA dinamicidade é maior, o que facilita a omissão de erros. Este commit aponta diretamente para a fragilidade da lógicaexternalna configuração do Rollup.
Representação Mermaid da energia de migração
A figura abaixo descreve o fluxo de controle do pipeline de build atual e destaca os nós que serão afetados pela migração para o 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["构建完成"]O valor da migração para o Rolldown está em: substituir o modelo de concorrência de "um processo por pacote" por um modelo de "paralelismo em processo único",scanEnums()A varredura global de e a substituição deinlineEnums()podem ser coordenadas dentro do mesmo runtime Rust, e o problema de "condição de corrida em varredura concorrente" discutido no capítulo anterior desaparecerá pela raiz. Mas a resistência à migração também está aqui —rollup-plugin-esbuild、rollup-plugin-dtsEsses ecossistemas de plugins precisam que o Rolldown forneça uma camada de compatibilidade, e a lógica de decisão deexternalprecisa ser reescrita.
Reflexões de design e armadilhas
Por que a migração não acontecerá da noite para o dia?Veja o campopackage.jsondeengines:
📎 package.json:61-63
"engines": {
"node": ">=20.0.0"
},Node 20 é o limite mínimo obrigatório. O Rolldown, como módulo nativo Rust, precisa das bindings N-API correspondentes e distribuição de binários pré-compilados. Uma vez introduzido,pnpm installo tempo de execução, a compatibilidade de binários multiplataforma (Windows/macOS/Linux) e a estratégia de cache de CI precisam ser redesenhados. Isso não é simplesmente "trocar uma dependência", mas simuma recalibração de toda a cadeia de instalação-build-cache。
Armadilhas em produção:build-dtsOtsc --noCheckde é uma faca de dois gumes. Pular a verificação de tipos acelera o emit, mas significa que.d.tsa fase de geração não detectará erros de tipo — erros de tipo só podem ser contidos porpnpm check(tsc --incremental --noEmit) etest-dts. Se após a migração para o Rolldown quisermos fundir essas duas etapas, devemos garantir que a verificação de tipos não torne o build mais lento, caso contrário, contrariaremos o propósito original de--noCheck.
---
II. Tendência de fusão entre testes de tipo e testes de runtime
Modelo intuitivo
Imagine testes de tipo e testes de runtime como dois postos de controle de qualidade independentes: um verifica se o "manual (.d.ts) está escrito corretamente", o outro verifica se "a máquina (runtime) está girando corretamente". Cada posto tem sua própria estação de trabalho, ferramentas e relatórios independentes. A tendência de fusão significa:Podemos fazer com que o mesmo caso de teste valide tanto o manual quanto a máquina?
Sem a fusão, o desastre que o sistema enfrenta éDesvio entre tipos e comportamento em runtime:.d.tsdiz queref()retornaRef<T>, mas a forma do objeto realmente retornado em runtime mudou; o teste de tipo passa, o teste de runtime também passa, mas a combinação dos dois está errada.
Estrutura de dados: layout de orquestração do script de teste
package.jsonEmscriptsde , as entradas relacionadas a testes são claramente divididas em dois grupos:
📎 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",A estrutura-chave aqui étest-dtsderun-s build-dts test-dts-only— ela éserial: primeiro constrói.d.ts, depois executa os testes de tipo. E dentro detest-dts-onlyhádois processostscindependentes: um executadts-built-test(valida os artefatos de build), outro executadts-test(valida os tipos do código-fonte).
Note quetest-unitusavitest --project unit*,test-e2eusavitest --project e2e --project e2e-browser. Isso mostra que o mecanismo de--projectdo Vitest já dividiu os testes em diferentes projects por "unidade/e2e/navegador".A base física para a fusão já existe: o mecanismo de project do Vitest permite executar diferentes tipos de teste no mesmo runner.
Orientado a cenários: o caminho completo de um committypes:de
No changelog, a densidade de entradas com prefixotypes:é extremamente alta, o que é um reflexo direto da complexidade do sistema de tipos. Vamos rastrear uma correção de tipo típica.
Reversão do tipo ref na 3.4.37:
📎 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))Dois Reverts consecutivos, revertendo duas correções de tipo. Note que na 3.4.35 essas duas correções acabaram de ser mescladas:
📎 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)Da mesclagem na 3.4.35 até a reversão na 3.4.37, houve apenas uma versão de patch de intervalo. Esse ciclo rápido de "mesclar-reverter" expõe um dilema fundamental dos testes de tipo:Testes de tipo conseguem validar que "a assinatura de tipo está conforme o esperado", mas não conseguem validar "se essa assinatura de tipo é útil em código real"。allow getter and setter types to be unrelatedpode passar completamente nos testes de tipo, mas no uso real tornará a inferência de tipo derefexcessivamente frouxa, quebrando a segurança de tipos do código downstream.
Representação Mermaid da fusão de testes de tipo
A figura abaixo descreve a estrutura atual de separação entre testes de tipo e testes de runtime, e a forma-alvo após a fusão:
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 -.演进.-> futureO caminho técnico para a fusão provavelmente é: encapsular as chamadas dedts-built-testedts-testdetsccomo um project personalizado do Vitest, permitindo que as asserções de tipo sejam embutidas nos arquivos de teste na forma deexpectTypeOf. Assim, uma única chamada devitestpode executar simultaneamente asserções de runtime e asserções de tipo, com relatório unificado. Mas a resistência está em:tsca verificação de tipos de é "total", enquanto os testes do Vitest são "por arquivo", e as estratégias de incrementalidade dos dois são incompatíveis.
Reflexões de design e armadilhas
Por quedts-built-testdeve ser independente dedts-test?Já discutimos isso no capítulo anterior; aqui complementamos sob a perspectiva da evolução:dts-built-testO quevalida são os(rollup-plugin-dtsartefatos de build.d.ts),dts-testapós o empacotamentoO quevalida são os
📎 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)). Se na fusão os dois forem combinados, perderemos o ponto de verificação crucial de "se os artefatos de build são consistentes com os tipos do código-fonte". Este commit da 3.4.38 confirma exatamente a importância dos tipos dos artefatos de build:dts-built-testCopiar.d.ts"Fornecer fallback stub quando a lib DOM estiver ausente" — esta é uma correção de compatibilidade de tipos no nível do artefato de build, que só pode ser descoberta no cenário de
"consumir oempacotado".Armadilhas em produção: o ciclo de "mesclar-reverter" dos testes de tipo mostra que mudanças em assinaturas de tipo precisam de validação porpackages-private/dts-testNo repositório, são usados casos de teste internos, que não cobrem todos os usos downstream. Se a tendência de fusão se concentrar apenas em "fundir dois runners", sem resolver "como introduzir feedback real de downstream", será apenas uma fusão formal.
---
III. Direções de otimização refinada do cache de CI
Modelo intuitivo
Imagine o cache de CI como a "área de preparação de materiais" de um armazém: cada build precisa retirar matérias-primas (dependências, artefatos de build, cache de tipos) dessa área. Se a área de preparação tiver apenas uma caixa grande, e para pegar qualquer item seja necessário revirar a caixa inteira, então mesmo com alta taxa de acerto do cache, não será rápido. Otimização refinada significa:Dividir a caixa grande em compartimentos menores classificados por finalidade。
Sem cache refinado, o desastre que o sistema enfrenta éAmplificação em cascata da invalidação de cache: alterar uma linha do código-fonte faz com que todo onode_modulescache seja invalidado, o CI reinstala todas as dependências, e o tempo de build passa de 2 minutos para 10 minutos.
Estrutura de dados: classificação dos itens cacheáveis
A partir dopackage.jsoné possível identificar várias categorias de "materiais" cacheáveis:
Primeira categoria, produtos de instalação de dependências.packageManagerO campo fixa a versão do pnpm:
📎 package.json:4
"packageManager": "pnpm@12.4.2",Onode_modulesdo pnpm é uma estrutura de links simbólicos; o que se cacheia é o content-addressable store do pnpm, e não onode_modulesplano. Isso significa que a chave de cache deve ser baseada no hash dopnpm-lock.yaml, e não nopackage.json。
Segunda categoria, artefatos de build.cleanO script revela a localização física dos artefatos:
📎 package.json:10
"clean": "rimraf --glob packages/*/dist temp .eslintcache",packages/*/dist、temp、.eslintcache— essas três categorias de artefatos podem ser cacheadas independentemente.disté a saída de build,tempsão arquivos temporários (comobench.json),.eslintcacheé o cache de lint.
Terceira categoria, cache de verificação de tipos.checkO script usa--incremental:
📎 package.json:15
"check": "tsc --incremental --noEmit",--incrementalgera o arquivo.tsbuildinfo, que é o cache incremental da verificação de tipos. Se esse arquivo for cacheado no CI,tsca segunda execução do
será muito mais rápida.
Orientado a cenários: fluxo de execução do CI em um PRpackages/reactivity/src/ref.tsConsidere um cenário típico: o desenvolvedor modificou
e submeteu um PR. Quais etapas o CI precisa executar, e quais podem acertar o cache?scriptsA partir dosimple-git-hooksé possível inferir a sequência de execução do CI (opre-commitdo
📎 package.json:48-51
"simple-git-hooks": {
"pre-commit": "pnpm lint-staged && pnpm check",
"commit-msg": "node scripts/verify-commit.js"
},Copiarpre-commitLocalmente olint-stagedexecutacheckelint、check、test-unit、test-dts、size. No CI, serão executados
lintetc. A estratégia de cache de cada etapa é diferente:.eslintcache: cacheiacheck, chave baseada no hash dos arquivos-fonte..tsbuildinfo: cacheiatsconfig, chave baseada emtest-unite no hash do código-fonte.test-dts: o Vitest tem seu próprio cache, mas normalmente no CI não se cacheiam resultados de teste, apenas dependências.build-dts: depende dos artefatos depackages/*/dist, chave de cache baseada no hash desize: depende dos artefatos de build, chave de cache igual à anterior.
Representação Mermaid da otimização do cache de CI
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 通过"]A contradição central do cache refinado éa granularidade da chave de cache: chave muito grossa (por exemplo, baseada apenas no commit hash) tem baixa taxa de acerto; chave muito fina (por exemplo, baseada no hash de cada arquivo) faz com que o custo de calcular a chave anule o ganho do cache. A estratégia razoável para monorepos como o Vue é "fragmentar por pacote": cadapackages/*subpacote tem cache independente; alterações emdist,reactivitynão invalidam ocompiler-corecache dedist.
Reflexões de design e armadilhas
Por que o scriptsizedeve ser dividido em vários subcomandos?Veja estas três linhas:
📎 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",sizeusarun-s "size-*"para executar serialmente todos os subcomandos com prefixosize-. Esse padrão de "agregação por prefixo" permite que cada dimensão de tamanho (global, esm-runtime, esm) seja cacheada e falhe independentemente. Se fossem fundidas em um único comando grande, qualquer dimensão que ultrapassasse o limite faria todo osizefalhar, sem ser possível localizar qual dimensão causou o problema.
Armadilhas em produção: a armadilha mais comum no cache de CI éa poluição do cache—cachear artefatos errados, fazendo com que builds subsequentes se baseiem em dados sujos.cleanO script
📎 package.json:10
"clean": "rimraf --glob packages/*/dist temp .eslintcache",Copiarpackages/*/distObserve que ele limpapackages-private/*/dist, e nãopackages-private. Isso significa que os artefatos depackages-privatenão estão no escopo de limpeza convencional—se o CI cachear os artefatos decleane opackages-privatenão os limpar, pode surgir o problema de "cachear artefatos antigos do playground". No design de cache refinado, é necessário tratar
---
separadamente.
Reflexão de design: o sistema de engenharia como ciclo de vida do produtoConectando as pistas das três seções, é possível ver uma linha principal clara:。
O sistema de engenharia do Vue está passando de "funcional" para "fácil de usar", de "orquestração manual" para "configuração declarativa"
A migração da cadeia de ferramentas de build (Rollup → Rolldown) é uma evolução "orientada a desempenho": quando o número de pacotes cresce a certo ponto, o custo da concorrência em nível de processo supera o ganho, sendo necessário trocar por um modelo de concorrência mais leve.
A fusão dos testes de tipo é uma evolução "orientada à consistência": quando a frequência de mudanças nas assinaturas de tipo supera a frequência de mudanças no comportamento em tempo de execução, os dois conjuntos separados de testes se tornam um fardo, sendo necessário que compartilhem os mesmos casos de uso.
〔Inferência de design e trade-offs arquiteturais〕A restrição comum a essas três linhas de evolução éa compatibilidade retroativaBREAKING CHANGES. A estratégia de release do Vue (visível no parágrafo
---
do changelog) permite "type-only breaking change" em versões minor, mas não permite breaking change em tempo de execução. Isso significa que a evolução do sistema de engenharia deve garantir: independentemente de como a cadeia de ferramentas interna mude, a API pública e o comportamento em tempo de execução dos artefatos não podem mudar. Essa é a fronteira rígida de todas as decisões de evolução.
Resumo do capítulopackage.jsonEste capítulo, partindo do changelog e do
1. , organizou as três linhas de evolução do sistema de engenharia do Vue core::A combinação atual de Rollup 4.x + esbuild + rollup-plugin-dts tem seus pontos de estresse evidenciados embuild:commits com o prefixo (alinhamento de configuração de minify, reversão de versão de entities, omissão na detecção de external em CJS). O potencial da migração para Rolldown vem da substituição de "concorrência multiprocesso" por "paralelismo monoprocesso", enquanto a resistência vem do ecossistema de plugins e da distribuição de binários multiplataforma.
2. Fusão de testes de tipo:test-dtsdorun-s build-dts test-dts-onlyestrutura serial, bem comodts-built-testedts-testo duplotscprocesso, são evidências físicas da forma de separação atual. O caminho técnico para a fusão é utilizar o mecanismo de--projectdo Vitest, e a resistência é que a verificação completa detscé incompatível com a estratégia incremental de testes por arquivo do Vitest.
3. Granularização do cache de CI:packageManagerfixa o pnpm,cleanlimpa três tipos de artefatos,checkusa--incremental、sizeagrega por prefixo — todos esses são critérios de classificação para itens cacheáveis. A contradição central é a granularidade da chave de cache, e a estratégia razoável é "fragmentação por pacote".
A mudança de percepção mais importante é:o próprio sistema de engenharia é um produto, com seus próprios usuários (contribuidores), suas próprias métricas de desempenho (tempo de build, minutos de CI), suas próprias restrições de compatibilidade (API de artefatos inalterada). Ele precisa de iteração contínua, não de um design único.
Reflexões e autoavaliação deste capítulo
Q1: package.json:9dobuild-dtsusoutsc -p tsconfig.build.json --noCheck. Se removermos--noCheck, quais reações em cadeia isso traria após a migração para Rolldown?
Análise de referência:--noCheckserve para pular a verificação de tipos e apenas fazer emit. Após removê-lo,tscfará verificação completa de tipos antes de gerar.d.ts. Na arquitetura atual do Rollup, isso apenas tornabuild-dtsmais lento; mas após a migração para Rolldown, o problema se amplifica: o principal atrativo do Rolldown é "build paralelo em processo único", e se a etapa debuild-dtsintroduzir uma verificação completa detsc, ela se torna um gargalo serial de todo o pipeline — o build de todos os pacotes precisa esperar essa verificação terminar. Pior ainda,tsca verificação de tipos é single-threaded e não consegue aproveitar a capacidade paralela do Rolldown. A abordagem correta é manter--noCheck, delegar a verificação de tipos apnpm check(package.json:15) etest-dts(package.json:22) independentes, desacoplando build e verificação.
Q2: O changelog 3.4.37 reverteu consecutivamente duas correções detypes/ref(CHANGELOG-3.4.md:23-24), e essas duas correções tinham acabado de ser integradas na 3.4.35 (CHANGELOG-3.4.md:30,55). Se os testes de tipo e os testes de runtime já estivessem fundidos, esse ciclo de "integração-reversão" poderia ser evitado? Por quê?
Análise de referência:Não pode ser completamente evitado, mas pode encurtar o ciclo. Os testes de tipo após a fusão ainda só conseguem validar "a assinatura de tipo atende à asserção", enquanto o problema de correções comoallow getter and setter types to be unrelatedestá em "a assinatura de tipo é excessivamente permissiva, quebrando a segurança de tipos do código downstream" — isso é um problema deuso downstream, não um problema da própriaassinatura. Onde a fusão pode encurtar o ciclo é: se asserções de tipo e asserções de runtime estiverem escritas no mesmo arquivo de teste, o desenvolvedor pode descobrir mais rapidamente a inconsistência de "a assinatura de tipo mudou, mas o comportamento em runtime não mudou". Mas para realmente evitar reversões, é preciso introduzir verificação de tipos de projetos downstream reais (por exemplo, expandirpackages-private/dts-testpara um conjunto de testes que "simula uso downstream"), o que ultrapassa o escopo de simplesmente "fundir runners".
Q3: package.json:10docleanscript limpapackages/*/dist, mas não limpapackages-private/*/dist. Se o CI adotar uma estratégia de cache de granularidade fina "fragmentada por pacote", que armadilha de produção essa assimetria traria?
Análise de referência:A armadilha está em "cachear artefatos antigos depackages-private".packages-privatecontémsfc-playground、template-explorere outras ferramentas de depuração; se seus artefatos de build (comopackages-private/sfc-playground/dist) forem cacheados pelo CI, ecleannão os limpar, ocorrerá: o código-fonte foi atualizado, mas o CI reutiliza artefatos antigos do playground, distorcendo o resultado de validação debuild-sfc-playground(package.json:39). De forma mais sutil,dev-sfc-prepare(package.json:34) verificará se os artefatos depackages-privateexistem; se artefatos antigos estiverem cacheados, ele pulará a reconstrução, fazendo o desenvolvedor pensar que o ambiente é novo. Ao projetar cache de granularidade fina, é preciso definir uma chave de cache separada parapackages-private, ou simplesmente não cachear seus artefatos — porque é uma ferramenta de depuração, com baixo custo de reconstrução e baixo benefício de cache.
Através da janela de observação do changelog, identificamos os pontos de estresse do sistema de engenharia atual e, com base nisso, inferimos as possíveis direções de evolução da próxima geração do sistema. Essas direções não são castelos no ar, mas cresceram a partir de armadilhas e trade-offs reais de produção. Neste ponto, a análise do sistema de engenharia do Vue ao longo de todo o livro chega a uma pausa, mas a exploração da engenharia nunca termina — o próximo capítulo será o capítulo final, afastando a perspectiva do próprio Vue para discutir como essas experiências podem migrar para cenários de engenharia mais amplos.
Gostou deste capítulo? Crie um livro para seu repositório privado
Arquitetura local-first em Tauri 2 + Rust. 100% offline e seguro, zero upload de código. Leitura em painel duplo com âncoras imutáveis de commit.
⚡ Tauri 2 · Rust Core · 100% Offline e Privado · Testado em 1M+ linhas
Para entender qualquer projeto complexo, tudo o que você precisa é de um bom livro
Compilado automaticamente pelo AiReadCode através da verificação do repositório oficial com âncoras imutáveis de commit.