CHAPTER 01

Capítulo 1: Cognición macro: la filosofía de diseño de ingeniería del repositorio core

Upstream: vuejs/core · Commit @4ab865a8 · Progreso: Capítulo 1 de 14

Antes de comenzar a rastrear cualquier línea de la implementación de reactividad o del DOM virtual, primero debemos entender la matriz de ingeniería de la que depende la existencia de este código. Al abrir el repositorio Vue core, lo primero que salta a la vista no es la lógica central del framework, sinopackage.jsonypnpm-workspace.yamleste tipo de archivos de configuración de ingeniería: no contienen ninguna funcionalidad en tiempo de ejecución, pero determinan si todo el framework puede compilarse, probarse y publicarse correctamente. Este capítulo responde precisamente a esta pregunta previa: qué es exactamente el repositorio core. No es@vue/runtime-coreese paquete npm, sino la matriz de ingeniería que albergaruntime-core、reactivity、compiler-sfcy más de una decena de paquetes publicados públicamente, además desfc-playground、template-explorery otros paquetes experimentales privados. Comprender la forma de organización de esta matriz es el requisito previo para todos los capítulos posteriores (compilación, tipos, publicación, presupuesto de tamaño). Este capítulo se desarrollará en torno a tres líneas principales: la estructura de doble directorio del workspace, las restricciones unificadas de TypeScript y Rollup a nivel raíz, y la filosofía de desacoplamiento entre el «repositorio de código fuente» y los «artefactos de publicación».

I. Estructura de doble directorio: el aislamiento físico entre packages y packages-private

Modelo intuitivo

Imagina el repositorio core como un edificio de I+D.packages/es la línea de productos oficial, lo que se produce debe llevar la marca y venderse en el mercado;packages-private/es el laboratorio interno, las muestras que contiene solo se usan para depuración y demostración, y nunca se envían al exterior. Ambos comparten el mismo suministro de agua y electricidad (dependencias, herramientas de compilación), pero el sistema de control de acceso (flujo de publicación) los trata de forma diferenciada.

Sin esta capa de aislamiento físico, un paquete playground de depuración interna podría publicarse fácilmente por error en npm; esto no es una suposición, sino un accidente clásico de los monorepos.

Estructura de datos y diseño de memoria

El límite del workspace está definido porpnpm-workspace.yaml. Solo tiene tres líneas de declaración efectivas:

📎 pnpm-workspace.yaml:1-3

yaml
packages:
  - 'packages/*'
  - 'packages-private/*'

Estos dos globs le indican a pnpm:packages/ypackages-private/cada subdirectorio bajo es un paquete independiente. pnpm creará enlaces simbólicos para ellos, de modo que cuando@vue/runtime-corehaga referencia a@vue/reactivityapunte directamente al directorio de código fuente local, en lugar de descargarlo desde el registry.

Inmediatamente después, la seccióncatalog:es el mecanismo dedirectorio de versiones de dependenciasde pnpm:

📎 pnpm-workspace.yaml:5-13

yaml
catalog:
  '@babel/parser': ^7.29.8
  '@babel/types': ^7.29.8
  'entities': '^7.0.1'
  'estree-walker': ^2.0.2
  'magic-string': ^0.30.21
  'source-map-js': ^1.2.1
  'vite': ^8.3.0
  '@vitejs/plugin-vue': ^6.0.9

En elpackage.jsonraíz corresponde lo escrito como"@babel/parser": "catalog:" 📎 package.json:65-65。catalog:es un marcador de posición, y pnpm lo reemplaza durante la instalación por la versión declarada en la sección catalog. El beneficio de hacer esto es:@babel/parserla versión depnpm-workspace.yamlse mantiene en un único lugar, y todos los paquetes que la referencian se alinean automáticamente, evitando la deriva de versiones del tipo «el paquete A usa 7.28, el paquete B usa 7.29».

Walkthrough guiado por escenarios: qué ocurre después de unpnpm installSupongamos que ejecutas

en la raíz del repositorio. Sustituyendo este escenario, rastrea paso a paso:pnpm installPrimer paso: control de acceso preinstall.

pnpm antes de instalar activará el scriptdelpackage.jsonraíz:preinstallCopiar

📎 package.json:45-45

json
"preinstall": "npx only-allow pnpm"
comprobará si el gestor de paquetes actual es pnpm; si no lo es, dará error y saldrá directamente. La existencia de este script implica que: instalar el repositorio core con npm o yarn fallará. ¿Por qué es obligatorio bloquear pnpm? Porque el repositorio core depende de los enlaces simbólicos del workspace y del mecanismo catalog de pnpm; los workspaces de npm no admiten la sintaxis

only-allow pnpm, y el modo PnP de yarn cambia las rutas de resolución de módulos, provocando que el comportamiento decatalog:en los scripts de compilación sea inconsistente.createRequireSegundo paso: resolver el workspace.

pnpm lee, escaneapnpm-workspace.yamlypackages/*, y crea un registro de paquete para cada directorio que contengapackages-private/*.package.jsonTercer paso: aplicar la sustitución de catalog.

En elraíz, todos lospackage.jsoncatalog:El marcador de posición se reemplaza con la versión real del segmento catalog y luego se instala de forma unificada.

Cuarto paso: hook postinstall.Se activa después de completar la instalación:

📎 package.json:46-46

json
"postinstall": "simple-git-hooks"

simple-git-hooksLeer la raízpackage.jsonen elsimple-git-hookscampo, escribir el hook de Git en.git/hooks/:

📎 package.json:48-51

json
"simple-git-hooks": {
  "pre-commit": "pnpm lint-staged && pnpm check",
  "commit-msg": "node scripts/verify-commit.js"
}

pre-commitEl hook ejecuta lint-staged y verificación de tipos antes de cada commit,commit-msgEl hook valida el formato del mensaje de commit (Vue usa conventional commits). Notapreinstallypostinstallsimetría: el primero actúa como guardián (solo permite pnpm), el segundo despliega defensas (instala hooks de Git).

Reflexiones de diseño y errores comunes

〔Inferencia de diseño y compensaciones arquitectónicas〕

¿Por qué usar dos globs en lugar de uno?packages*/?Listar explícitamente dos directorios hace que la semántica de "público" y "privado" sea visible a nivel de configuración. Cualquier nuevo desarrollador que leapnpm-workspace.yamlsabe de inmediato que el repositorio tiene dos tipos de paquetes. Si se escribierapackages*/, esta semántica quedaría oculta.

allowBuildsy seguridad de la cadena de suministro.Presta atención a esta configuración:

📎 pnpm-workspace.yaml:15-21

yaml
allowBuilds:
  '@parcel/watcher': true
  '@swc/core': true
  'esbuild': true
  'puppeteer': true
  'simple-git-hooks': true
  'unrs-resolver': true

pnpm prohíbe por defecto que los paquetes de dependencias ejecuten scripts de instalación (postinstall), porque es un punto de entrada común para ataques a la cadena de suministro.allowBuildses una lista blanca: solo los paquetes listados pueden ejecutar scripts de compilación.@swc/core、esbuildnecesita descargar binarios nativos específicos de la plataforma,puppeteernecesita descargar Chromium,simple-git-hooksnecesita escribir hooks de Git — todos estos son comportamientos legítimos en tiempo de compilación, por lo que se permiten explícitamente.

minimumReleaseAge: 1440El significado profundo de .Esta línea de configuración exige que las versiones de dependencias recién publicadas deben tener "al menos 24 horas" (1440 minutos) para poder instalarse:

📎 pnpm-workspace.yaml:33-33

yaml
minimumReleaseAge: 1440
〔Inferencia de diseño y compensaciones arquitectónicas〕

Este es un mecanismo de período de enfriamiento para defenderse del envenenamiento de la cadena de suministro de npm. Después de que un atacante secuestra un paquete y publica una versión maliciosa, generalmente se descubre y se retira en cuestión de horas. Establecer un período de enfriamiento de 24 horas permite que el repositorio core evite esta ventana. YminimumReleaseAgeExcludepermite excepciones para parches de seguridad específicos:

📎 pnpm-workspace.yaml:36-38

yaml
minimumReleaseAgeExclude:
  # Renovate security update: vitest@4.1.11
  - vitest@4.1.11

El comentario aclara explícitamente que esta es una actualización de seguridad activada por Renovate, que debe aplicarse de inmediato, por lo que se exime del período de enfriamiento.

---

Dos, tsconfig raíz: restricciones unificadas de los límites de tipos de todos los subpaquetes

Modelo intuitivo

Si cada subpaquete mantiene su propio tsconfig, aparecerán grietas como "el paquete A usastrict: false, el paquete B usastrict: true". El tsconfig raíz es laconstitución: establece las reglas de tipos que todos los subpaquetes deben cumplir conjuntamente; los subpaquetes solo pueden agregar sobre esta base, no pueden violarla.

Estructura de datos y diseño de memoria

Raíztsconfig.jsondecompilerOptionses la base de todo el sistema de tipos del repositorio. Seleccionemos algunos campos clave:

📎 tsconfig.json:5-29

json
"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"]
}

Interpretación uno por uno:

  • target: es2016: la sintaxis de salida se degrada a ES2016. Esto se corresponde contargetde esbuild en la configuración de Rollup (isServerRenderer || isCJSBuild ? 'es2019' : 'es2016' 📎 rollup.config.js:337-337)。
  • moduleResolution: bundler: adopta la resolución de módulos estilo empaquetador, permite omitir extensiones, admite el campoexports.
  • strict: true: activa todas las verificaciones estrictas, incluyendostrictNullChecks、noImplicitAnyetc.
  • noUnusedLocals: true: las variables locales no utilizadas generan error directamente. Esta regla tiene significado práctico junto con Tree-shaking — las variables no utilizadas suelen ser señales de código muerto.
  • isolatedModules: true: requiere que cada archivo pueda transpilarse de forma independiente. Este es el requisito previo para herramientas como esbuild/swc que "transpilan archivo por archivo, sin análisis de tipos entre archivos".
  • isolatedDeclarations: true: requiere que todas las exportaciones tengan anotaciones de tipo explícitas. Esta regla sirve directamente a la canalización de generación de.d.ts— solo las anotaciones explícitas permiten quetscgenere rápidamente archivos de declaración sin realizar inferencia de tipos completa.
  • composite: true: activa los metadatos de compilación incremental necesarios para las referencias de proyecto (project references).

pathsEl campo es elespejo de la capa de tipos:@vue/*del workspace, mapeado a./packages/*/src, permitiendo que TypeScript resuelva directamente al código fuente en tiempo de compilación, en lugar de a los enlaces simbólicos ennode_modules. Esto complementa los enlaces simbólicos en tiempo de ejecución de pnpm — en tiempo de ejecución se depende de pnpm, en tiempo de compilación se depende de paths.

Walkthrough guiado por escenarios: una verificación de tipos depnpm check

checkEl script estsc --incremental --noEmit 📎 package.json:15-15. Sustituyendo este escenario:

Primer paso: leer el alcance de include.Elincludede tsconfig determina qué archivos participan en la verificación:

📎 tsconfig.json:31-39

json
"include": [
  "packages/global.d.ts",
  "packages/*/src",
  "packages/*/__tests__",
  "packages/vue/jsx-runtime",
  "packages/runtime-dom/types/jsx.d.ts",
  "scripts/*",
  "rollup.*.js"
]

Notascripts/*yrollup.*.jstambién están dentro del alcance de verificación. Esto significa que los propios scripts de compilación también están sujetos a restricciones de tipos —rollup.config.jsen la parte superior de// @ts-check 📎 rollup.config.js:1-1junto con las anotaciones de tipo JSDoc, permiten que este archivo puramente JS también pueda ser verificado portsc.

Segundo paso: aplicar la exclusión de exclude.

📎 tsconfig.json:40-40

json
"exclude": ["packages-private/sfc-playground/src/vue-dev-proxy*"]
〔Inferencia de diseño y compensaciones arquitectónicas〕

sfc-playgroundEnvue-dev-proxyel archivo

está excluido. ¿Por qué? Este tipo de archivos suelen ser código proxy generado dinámicamente en tiempo de ejecución, cuya forma de tipos es inestable, y incluirlos en la verificación generaría ruido. --incrementalTercer paso: verificación incremental.tscpermite que.tsbuildinfoalmacene en caché los resultados de la verificación anterior en--noEmit, verificando solo los archivos modificados.

indica solo verificar sin emitir — la verificación de tipos y la generación de artefactos son dos canalizaciones independientes.

isolatedDeclarationsReflexiones de diseño y errores comunesEl costo y beneficio de .export function foo(): numberDespués de activar esta regla, cualquier exportación debe tener anotado explícitamente el tipo de retorno, por ejemploexport function foo() { return 1 }en lugar de.d.ts. Esto aumenta el costo de escritura, pero a cambio se obtiene una mejora sustancial en la velocidad de generación detsc—build-dtspuede producir archivos de declaración sin necesidad de inferencia entre archivos. Esto se corresponde contsc -p tsconfig.build.json --noChecken el script--noCheckla bandera

types: dado que los tipos ya están anotados explícitamente, al generar archivos de declaración incluso se puede omitir la verificación.

📎 tsconfig.json:21-21

json
"types": ["vitest/globals", "puppeteer", "node"]

Copiardescribe、it、expectEstos tres paquetes de tipos se inyectan globalmente, lo que significa que los archivos de prueba pueden usar directamentepuppeteersin necesidad de import, y las pruebas e2e pueden usar directamente los tipos de

---

III. Configuración de Rollup: de buildOptions a una fábrica unificada de artefactos multiformato

Modelo intuitivo

La configuración de Rollup es eltaller de ensamblaje finaldel repositorio core. No le importa qué hace específicamente un paquete, solo le importa «qué formatos debe producir este paquete, dónde está el archivo de entrada de cada formato y qué dependencias deben externalizarse». El campopackage.jsonen elbuildOptionsde cada subpaquete es la orden de envío pegada al paquete, y el taller de ensamblaje final trabaja siguiendo esa orden.

Estructura de datos y diseño de memoria

En la entrada del archivo de configuración se establece el modelo de «construcción por paquete»:

📎 rollup.config.js:32-44

js
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)

Diseño clave:TARGETLa variable de entorno especifica qué paquete construir. La configuración determina mediantefs.readdirSync('packages-private')si el paquete pertenece al directorio público o privado, decidiendo asípkgBase. Esta es unadetección de directorio en tiempo de ejecución—no es necesario mantener una lista de «qué paquetes son privados», la estructura de directorios en sí misma es la verdad.

buildOptionsEs un campo personalizado en el subpaquetepackage.json,packageOptions.filenamedetermina el prefijo del nombre del archivo de artefacto,packageOptions.formatsdetermina el formato de construcción predeterminado.

La asignación de formato a artefacto está definida poroutputConfigs:

📎 rollup.config.js:58-88

js
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' },
}

Siete formatos, que cubren tres escenarios de consumo:esm-bundlerpara consumo de empaquetadores como Vite/webpack,esm-browserpara consumo de ESM nativo del navegador,globalpara consumo de la etiqueta<script>. Los que llevan el sufijo-runtimeson construcciones «solo runtime», abiertas únicamente para el paquete principalvue.

Walkthrough guiado por escenarios: flujo de decisión completo de unapnpm build vue

Ejecutando el escenarionode scripts/build.js vue.TARGET=vue, rastreando las decisiones dentro decreateConfig:

Primer paso: determinar la lista de formatos.

📎 rollup.config.js:91-92

js
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]))

Prioridad: línea de comandosFORMATS> subpaquetebuildOptions.formats> predeterminado['esm-bundler', 'cjs']。PROD_ONLYSi la variable de entorno es verdadera, se omiten las construcciones no de producción, conservando solo la configuración de.prod.jsañadida posteriormente.

Segundo paso: calcular los indicadores de construcción. createConfigInternamente se derivan un conjunto de indicadores booleanos a partir de la cadena de formato:

📎 rollup.config.js:131-142

js
const isProductionBuild = process.env.__DEV__ === 'false' || /\.prod\.js$/.test(output.file)
const isBundlerESMBuild = /esm-bundler/.test(format)
const isBrowserESMBuild = /esm-browser/.test(format)
const isServerRenderer = name === 'server-renderer'
const isCJSBuild = format === 'cjs'
const isGlobalBuild = /global/.test(format)
const isCompatPackage = pkg.name === '@vue/compat'
const isCompatBuild = !!packageOptions.compat
const isBrowserBuild =
  (isGlobalBuild || isBrowserESMBuild || isBundlerESMBuild) &&
  !packageOptions.enableNonBrowserBranches

Estos indicadores son laúnica fuente de verdadpara todas las decisiones posteriores: selección de archivo de entrada, reemplazo de define, determinación de external, ensamblaje de plugins, todo depende de ellos.

Tercer paso: seleccionar el archivo de entrada.

📎 rollup.config.js:159-168

js
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`
}

La entrada predeterminada essrc/index.ts, las construcciones solo runtime usansrc/runtime.ts. El paquete compat (@vue/compat, es decir, la construcción compatible con Vue 2) necesita proporcionar tanto exportaciones default como named, lo que haría que Rollup reporte errores para objetivos no ESM, por lo que para la construcción ESM se usa una entradaesm-index.ts / esm-runtime.tsseparada.

Cuarto paso: generar la tabla de reemplazo de define. resolveDefineSe reemplazan constantes en tiempo de compilación como__DEV__、__BROWSER__en el código fuente por literales:

📎 rollup.config.js:170-201

js
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`,
}

Aquí hay una estratificación ingeniosa:los feature flags no se codifican de forma rígida en la construcción esm-bundler, sino que se conservan como identificadores como__VUE_OPTIONS_API__, dejándolos al empaquetador del usuario final para que los reemplace. Así el usuario puede desactivar el soporte de Options API mediantedefine: { __VUE_OPTIONS_API__: false }, permitiendo que el código relacionado sea eliminado por Tree-shaking. En cambio, en las construcciones global/esm-browser, estos flags se codifican de forma rígida comotrue/false, porque los artefactos consumidos directamente por el navegador no tienen intervención de un empaquetador.

Quinto paso: permitir la sobrescritura por variables de entorno.

📎 rollup.config.js:208-216

js
// 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
  }
})

Cualquier clave de define puede sobrescribirse mediante una variable de entorno del mismo nombre. El ejemplo dado en los comentarios es__RUNTIME_COMPILE__=true pnpm build runtime-core—utilizado para depurar una rama de compilación específica.

Sexto paso: ensamblar la cadena de plugins.

📎 rollup.config.js:324-342

js
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,
],

El orden de los plugins es importante:jsonprimero se procesan las importaciones JSON,aliasse mapea@vue/*a la ruta del código fuente,enumPluginse hace inline de enums,replacese hace reemplazo de cadenas,esbuildse hace transpilación de TS. Nótese que elesbuilddetsconfigapunta al tsconfig raíz—todos los subpaquetes comparten la misma configuración de tipos, esto es precisamente la manifestación en tiempo de construcción de la «constitución» discutida en la segunda sección.

Séptimo paso: adición de la construcción de producción.SiNODE_ENV=production:

📎 rollup.config.js:97-114

js
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))
    }
  })
}

Al formato CJS se le añade una versión.prod.js(reemplazando con__DEV__=false), y a los formatos global y esm-browser se les añade una versión comprimida (usando swc para minificar).packageOptions.prod === falseLos paquetes de

pueden salir de este mecanismo.

mermaid
flowchart TD
    start["node scripts/build.js vue"] --> check_target{"process.env.TARGET 存在?"}
    check_target -->|否| throw_err["throw Error: TARGET must be specified"]
    check_target -->|是| detect_dir{"TARGET 在 packages-private 中?"}
    detect_dir -->|是| base_priv["pkgBase = packages-private"]
    detect_dir -->|否| base_pub["pkgBase = packages"]
    base_priv --> read_pkg["require(package.json) 读取 buildOptions"]
    base_pub --> read_pkg
    read_pkg --> resolve_formats{"FORMATS 环境变量?"}
    resolve_formats -->|有| use_inline["使用命令行格式"]
    resolve_formats -->|无| check_buildopts{"buildOptions.formats?"}
    check_buildopts -->|有| use_pkg["使用包声明格式"]
    check_buildopts -->|无| use_default["使用默认 esm-bundler,cjs"]
    use_inline --> create_cfg["createConfig(format, output)"]
    use_pkg --> create_cfg
    use_default --> create_cfg
    create_cfg --> check_output{"output 配置存在?"}
    check_output -->|否| exit_err["console.log invalid format; process.exit(1)"]
    check_output -->|是| pick_entry{"格式含 runtime?"}
    pick_entry -->|是| entry_rt["entryFile = src/runtime.ts"]
    pick_entry -->|否| entry_idx["entryFile = src/index.ts"]
    entry_rt --> build_flags["计算 isBundlerESMBuild/isCJSBuild 等标志"]
    entry_idx --> build_flags
    build_flags --> prod_check{"NODE_ENV == production?"}
    prod_check -->|是| add_prod["追加 .prod.js 与 minified 配置"]
    prod_check -->|否| done["导出 packageConfigs"]
    add_prod --> done

Copiar

externalReflexiones de diseño y trampas resolveExternalLa estrategia de tres ramas de

📎 rollup.config.js:257-283

js
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,
    ]
  }
}

CopiartreeShakenDepsLas construcciones de navegador (global/esm-browser) incorporan todas las dependencias, listando solodependenciescomo external para suprimir advertencias—estas dependencias no se referencian realmente en la rama de navegador y serán eliminadas por Tree-shaking. Las construcciones Node/esm-bundler externalizan todos lospeerDependenciesy

onwarn, dejando que el consumidor gestione las versiones de dependencias por sí mismo.

📎 rollup.config.js:344-348

js
onwarn: (msg, warn) => {
  if (msg.code !== 'CIRCULAR_DEPENDENCY') {
    warn(msg)
  }
},

Copiarruntime-coreLas advertencias de dependencias circulares se silencian. Entrereactivityy

treeshake.moduleSideEffects: falsede Vue existe una referencia circular legítima (el sistema de reactividad necesita referenciar el tipo de instancia de componente), estos ciclos son seguros en tiempo de ejecución, por lo que se filtran.

📎 rollup.config.js:355-355

js
treeshake: {
  moduleSideEffects: false,
},

.CopiarEsto le dice a Rollup: todos los módulos no tienen efectos secundarios, se pueden eliminar con confianza las importaciones no referenciadas. Esta es una

suposición agresivapure_getters—si algún módulo ejecuta código con efectos secundarios en el nivel superior (como registrar variables globales), podría eliminarse erróneamente. El código fuente de Vue garantiza por convención que todos los módulos son puros, por lo que se puede activar esta optimización.

📎 rollup.config.js:373-388

js
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: truede swc-minify.obj.fooCopiartrack()) en lugar de completarse mediante efectos secundarios implícitos del getter, por lo que es seguro.map: nullindica que no se genera sourcemap tras la compresión—los artefactos de producción no necesitan mapas de depuración.

---

Reflexión de diseño: por qué el repositorio de código fuente y los artefactos de publicación deben desacoplarse

Volvamos a la proposición central de este capítulo. El diseño de ingeniería del repositorio core tiene una línea principal que lo atraviesa de principio a fin:La responsabilidad del repositorio de código fuente es «producir», la responsabilidad de los artefactos de publicación es «consumir», y ambos se desacoplan mediante la tubería de construcción。

Esto se refleja concretamente en tres niveles:

Primero, el código fuente no se publica directamente. package.jsondeprivate: true 📎 package.json:2-2indica que el paquete raíz nunca se publica. En cada subpaquete, elpackage.jsondentro demain/module/exportscampo apunta adist/bajo los artefactos, no asrc/. Cuando el usuario instalavue, lo que obtiene es el.jsy.d.tsconstruidos, mientras que el código fuente permanece en el repositorio.

Segundo, el formato de los artefactos lo determina el escenario de consumo.Los siete formatos no son una enumeración arbitraria, sino que corresponden a siete rutas de consumo reales: los usuarios de Vite obtienenesm-bundler, los usuarios de CDN obtienenglobal, los usuarios de Node SSR obtienencjs. La lógica de selección de formato se concentra enrollup.config.jsun solo lugar, y los subpaquetes solo necesitan declarar enbuildOptions.formatscuáles necesitan.

Tercero, separación entre tipos e implementación. build-dtsEl scripttsc -p tsconfig.build.json --noCheck && rollup -c rollup.dts.config.js 📎 package.json:9-9indica que la generación de.d.tses una tubería independiente.isolatedDeclarations: truepermite que la generación de archivos de declaración omita la verificación de tipos (--noCheck), porque los tipos ya están anotados explícitamente.

[Inferencia de diseño y compensaciones arquitectónicas]

La motivación profunda de este desacoplamiento es:La forma de organizar el código fuente sirve al desarrollador, la forma de organizar los artefactos sirve al consumidor, y la solución óptima de ambos es diferente. El código fuente necesita una estructura de directorios clara, información de tipos completa y sourcemaps depurables; los artefactos necesitan el mínimo tamaño, el formato de módulo correcto y una superficie de API estable. Forzar la unificación de ambos (por ejemplo, publicar directamente el código fuente TS) perjudicaría simultáneamente la experiencia de ambos extremos.

---

Resumen del capítulo

Este capítulo estableció una comprensión macro del repositorio core desde tres dimensiones:

1. Estructura de doble directorio:packages/ypackages-private/el aislamiento físico de , junto con los enlaces simbólicos del workspace de pnpm y el catálogo de versiones, logra un límite claro entre «paquetes públicos» y «paquetes privados».preinstallLa puerta de control deallowBuilds, la lista blanca deminimumReleaseAgey el período de enfriamiento de

2. constituyen conjuntamente la línea de defensa de seguridad de la cadena de suministro.tsconfig a nivel raízpaths: como la constitución de tipos de todos los subpaquetes, mediante el mapeo deisolatedDeclarationsimplementa la resolución de workspace en tiempo de compilación, y mediantecompositey

3. soporta la construcción incremental y la generación rápida de archivos de declaración.Fábrica unificada de RollupTARGET: toma la variable de entornobuildOptionscomo punto de entrada, lee la metainformación del subpaquete mediante

, y mediante un conjunto de indicadores booleanos impulsa la selección de entradas, el reemplazo de define, la determinación de external y el ensamblaje de plugins, produciendo finalmente artefactos en siete formatos.La filosofía central esel desacoplamiento entre el repositorio de código fuente y los artefactos de publicación

---

: el repositorio se encarga de producir, los artefactos se encargan de consumir, y la tubería de construcción es el único puente entre ambos.

Transición al final del capítuloscripts/build.jsEste capítulo respondió a «qué es el repositorio core». Pero la estructura estática del repositorio es solo el escenario; el verdadero drama ocurre durante la ejecución de una solicitud de construcción:

cómo analizar los argumentos de línea de comandos, cómo invocar la API de Rollup, cómo manejar fallos de construcción y concurrencia. El próximo capítulo rastreará el viaje de extremo a extremo de una solicitud de construcción desde la entrada hasta los artefactos, transformando la comprensión estática establecida en este capítulo en una vista de ejecución dinámica.

Reflexión y autoevaluación de este capítulopnpm-workspace.yamlQ1: Si enminimumReleaseAge: 1440se cambia0aminimumReleaseAgeExclude, ¿qué riesgo se introduciría en escenarios de actualización de dependencias? ¿Por qué la existencia de

es necesaria?:

minimumReleaseAge: 1440 📎 pnpm-workspace.yaml:33-33Análisis de referencia0exige que una versión de dependencia recién publicada deba tener al menos 24 horas para poder instalarse. Si se cambiara a

, cualquier versión recién publicada podría incorporarse de inmediato.@babel/parserEscenario de riesgo: un atacante secuestra alguna dependencia transitiva (por ejemplo, alguna versión patch de

minimumReleaseAgeExclude 📎 pnpm-workspace.yaml:36-38) y publica una versión con un script postinstall malicioso. Durante el período de enfriamiento de 24 horas, la comunidad normalmente descubre el problema y retira esa versión; si el período de enfriamiento fuera 0, el CI del repositorio core podría actualizar automáticamente y ejecutar el script malicioso dentro de la ventana de ataque.vitest@4.1.11existe porque el mecanismo de enfriamiento entra en conflicto con la urgencia de los parches de seguridad. El

Q2: rollup.config.jsen el comentario es una actualización de seguridad detectada por Renovate—este tipo de actualizaciones necesita surtir efecto de inmediato, y esperar 24 horas alargaría la ventana de exposición. Por lo tanto, se necesita una lista explícita de exenciones que permita a las actualizaciones de seguridad eludir el período de enfriamiento. Esto refleja el principio de diseño de seguridad de «conservador por defecto, excepciones explícitas».resolveDefineEn__FEATURE_OPTIONS_API__, el tratamiento deisBundlerESMBuild ? '__VUE_OPTIONS_API__' : 'true'para'true'es

. Si por error se cambiara a devolver:

📎 rollup.config.js:192-194

js
__FEATURE_OPTIONS_API__: isBundlerESMBuild
  ? `__VUE_OPTIONS_API__`
  : `true`,

Análisis de referencia__FEATURE_OPTIONS_API__Copiar__VUE_OPTIONS_API__En la construcción esm-bundler,define: { __VUE_OPTIONS_API__: false }se conserva como el identificadordata、methods、computed, para que el empaquetador del usuario final lo reemplace. El usuario puede establecer

en su propia configuración de construcción, permitiendo que Tree-shaking elimine todo el código relacionado con Options API (la lógica de manejo de opciones como'true'), reduciendo significativamente el tamaño del artefacto.defineSi se cambiara a devolver

para todos los formatos, entonces el código de Options API en el artefacto esm-bundler quedaría codificado de forma rígida, la configuración dedel usuario dejaría de funcionar y no sería posible hacer Tree-shake. Para un proyecto que solo usa Composition API, esto aumentaría en vano varios KB el tamaño del artefacto.La idea clave de este diseño es:

Q3: rollup.config.jsderesolveExternal, la compilación para navegador solo devuelvetreeShakenDepscomo external, mientras que la compilación para Node devuelve todos losdependencies. Supongamos que algún día alguien añade una nueva dependencia de tiempo de ejecuciónruntime-coreafoo-lib, pero olvida actualizar la lógica deresolveExternal. ¿Qué sucederá en la compilación para navegador?

Análisis de referencia:

📎 rollup.config.js:257-283

La compilación para navegador (isGlobalBuild || isBrowserESMBuild) al!packageOptions.enableNonBrowserBranchessolo devuelvetreeShakenDeps(source-map-js、@babel/parser、estree-walker、entities/decode). Esto significa quefoo-libno está en la lista de external,

Hasta aquí, hemos visto desde un nivel macro la filosofía de diseño integral del repositorio core como matriz de ingeniería: la estructura de workspace de doble directorio delimita la frontera entre paquetes públicos y paquetes experimentales privados, la configuración raíz de TypeScript y Rollup proporciona restricciones unificadas, y el desacoplamiento entre el repositorio de código fuente y los artefactos de publicación hace posible la salida en múltiples formatos. Estos conocimientos allanan el camino para profundizar en las cadenas de ingeniería concretas. En el próximo capítulo, pasaremos la mirada de la estructura estática al flujo dinámico, tomandonode scripts/build.js vuecomo punto de partida, para trazar el viaje de extremo a extremo de una solicitud de compilación completa desde el análisis de argumentos de línea de comandos, la localización del paquete objetivo, la generación de la configuración de Rollup hasta la escritura de artefactos en disco, viendo cómo build.js analiza los flags formats/devOnly/release mediante parseArgs, cómo hace require dinámico del package.json del paquete objetivo y lee buildOptions, y finalmente impulsa a rollup.config.js a producir artefactos en múltiples formatos como esm-bundler, cjs, global, etc.

Convierte cualquier código en un libro comprensible

¿Disfrutaste este capítulo? Convierte tu código privado en un libro

Arquitectura local-first con Tauri 2 + Rust. 100% offline y seguro, sin subir código. Lectura en panel dual con anclajes de commit inmutables.

⚡ Tauri 2 · Rust Core · 100% Privado y Offline · Probado en +1M líneas

CHAPTER 02

Capítulo 2: Ciclo de vida del tronco principal: el viaje de extremo a extremo de una solicitud de compilación

Upstream: vuejs/core · Commit @4ab865a8 · Progreso: Capítulo 2 de 14

En el capítulo anterior aclaramos la posición del repositorio core como matriz de ingeniería, y cómo pnpm workspace y la configuración raíz restringen de forma unificada todos los subpaquetes. Ahora, profundizamos en el núcleo del sistema de compilación, trazando cómo un solo comando impulsa todo el proceso de compilación.node scripts/build.js vueparece simple, pero es la única entrada para todos los artefactos —esm-bundler, cjs, global—. Entender cómo traduce la intención del usuario en tareas de compilación ejecutables es un paso clave para dominar el mecanismo de compilación de Vue.

Generación de la configuración de Rollup: de variables de entorno a artefactos multiformato

build.jsmedianteexectras iniciar Rollup, el control pasa arollup.config.js. Este archivo es el «cerebro» del sistema de compilación: lee variables de entorno y genera dinámicamente un array de objetos de configuración de Rollup.

Validación de variables de entorno y localización de paquetes

📎 rollup.config.js:27-29

SiTARGETno está establecido, lanza un error directamente. Esto es programación defensiva: la configuración de Rollup puede invocarse directamente (comorollup -c), y en ese caso no haybuild.jsinyectando variables de entorno, por lo que debe fallar rápidamente.

📎 rollup.config.js:32-44

Aquí se repite la lógica de juicio de paquetes privados debuild.js, porquerollup.config.jses un proceso independiente y no puede compartir el estado en memoria debuild.js.resolveresuelve la ruta relativa a una ruta absoluta dentro del directorio del paquete,pkges el contenido delpackage.jsondel paquete objetivo,packageOptionses el campobuildOptionsdentro de él,namees el prefijo del nombre de archivo del artefacto (se prefierebuildOptions.filename, si no, se usa el nombre del directorio).

Tabla de mapeo de formatos:outputConfigs

📎 rollup.config.js:58-88

Esta tabla define el mapeo de 7 formatos a configuraciones de salida. Observaciones clave:

  • esm-bundler、esm-browser、esm-bundler-runtime、esm-browser-runtimeson todosformat: 'es', la diferencia está solo en el nombre del archivo.
  • cjsesformat: 'cjs'。
  • globalyglobal-runtimeesformat: 'iife'(expresión de función invocada inmediatamente), adecuada para inclusión directa mediante la etiqueta<script>.
  • runtimeLos formatos con sufijovuesolo tienen sentido para el paquete principal

: no incluyen el compilador y son de menor tamaño.

📎 rollup.config.js:91-92

Selección de formato: tres niveles de prioridadFORMATSLa selección de formato sigue tres niveles de prioridad: línea de comandosbuildOptions.formatsvariable de entorno > del paquete['esm-bundler', 'cjs']。PROD_ONLY> por defecto

La variable de entorno controla si se omite la configuración base: si solo se compila la versión de producción, el array de configuración base queda vacío y posteriormente solo se añade la configuración de producción.

📎 rollup.config.js:97-114

Lógica de adición de la configuración de producciónNODE_ENV === 'production'Cuando

  • , para cada formato:packageOptions.prod === falseSi
  • , se omite (ese paquete no necesita versión de producción).cjsSi escreateProductionConfig, se añade.prod.js: genera el archivo
  • ./^(global|esm-browser)(-runtime)?/Si coincide concreateMinifiedConfig, se añade
: genera la versión minificada.

〔Inferencia de diseño y compensaciones arquitectónicas〕cjs¿Por quécreateProductionConfigusaglobal/esm-browsermientras quecreateMinifiedConfigusa

createConfig? Porque CJS es para Node, y el entorno de Node no necesita minificación (el usuario se encargará de ello), pero sí necesita distinguir las ramas dev/prod; en cambio, los artefactos que se incluyen directamente en el navegador deben minificarse para reducir el tamaño. Esta diferencia se refleja en la implementación de las dos funciones de fábrica.

createConfig: el núcleo de la generación de configuración

📎 rollup.config.js:125-142

es la función más grande; recibe el formato y la configuración de salida, y devuelve el objeto de configuración completo de Rollup.

  • isProductionBuildAl inicio hay una serie de cálculos de flags booleanos:__DEV__: se determina mediante la variable de entorno.prod.jso si el nombre del archivo contiene
  • isBundlerESMBuild、isBrowserESMBuild、isCJSBuild、isGlobalBuild.
  • isServerRenderer: se determina mediante coincidencia regex del nombre del formato.server-renderer。
  • isCompatPackage、isCompatBuild: si el nombre del paquete es
  • isBrowserBuild: relacionado con la compilación compatible con Vue 2.

: compilación global o compilación ESM para navegador, y sin habilitar la rama no-navegador.resolveDefine、resolveReplace、resolveExternalEstos flags se usan repetidamente en el posterior

📎 rollup.config.js:144-157

y son la base central para diferenciar la configuración.exportsConfiguración básica de salida: encabezado de copyright banner, modoauto(los paquetes compat usannamed, el resto usanesModule), interoperabilidadexternalLiveBindings: falsehabilitada en la compilación CJS, sourcemap controlado por variables de entorno,reexportProtoFromExternal: falseyoutput.nameson ajustes de compatibilidad de Rollup 4. La compilación global establece adicionalmentewindow, es decir, el nombre de la variable montada en

.

📎 rollup.config.js:159-168

Selección del archivo de entradasrc/index.tsLa entrada por defecto esruntime, pero los formatos con sufijosrc/runtime.ts。La compilación ESM del paquete compat necesita exportar tanto default como named, por lo que se usa una entradaesm-index.ts / esm-runtime.tsseparada.

Definiciones de macros:resolveDefine

📎 rollup.config.js:170-218

resolveDefineDevuelve una tabla de reemplazo que sustituye en el código fuente__COMMIT__、__VERSION__、__BROWSER__y otras macros por literales. Estas macros se usan en el código fuente para compilación condicional — por ejemploif (__DEV__) { ... }en compilaciones de producción se reemplaza porif (false) { ... }, y luego es eliminado por Tree-shaking.

Diseño clave:__FEATURE_OPTIONS_API__、__FEATURE_PROD_DEVTOOLS__Los interruptores de características comoesm-bundlerse conservan en la compilación__VUE_OPTIONS_API__como identificadores del tipotrue, permitiendo que el usuario final los sobrescriba mediante la configuración del empaquetador; mientras que en otras compilaciones se codifican directamente comofalse。

📎 rollup.config.js:203-206

oesm-bundlerLas compilaciones no__DEV__codifican directamente

📎 rollup.config.js:210-216

, porque sus ramas dev/prod ya están determinadas en tiempo de compilación.__RUNTIME_COMPILE__=true pnpm build runtime-coreEl último paso permite que las variables de entorno sobrescriban cualquier definición de macro, soportando sobrescrituras en línea como

Plugin de reemplazo:resolveReplace

📎 rollup.config.js:222-255

resolveReplaceManeja fuera deresolveDefinelos reemplazos que esbuild no puede procesar:

  • FusionaenumDefines(definiciones de inline de enum provenientes deinlineEnums).
  • En compilaciones de producción para navegador, añade la anotación/*@__PURE__*/a las funciones de creación de errores para ayudar al Tree-shaking.
  • esm-bundlerEn la compilación__DEV__,!!(process.env.NODE_ENV !== 'production')se reemplaza por
  • , dejando que el empaquetador decida.process.envEn compilaciones ESM para navegador,

se reemplaza por un objeto vacío para evitar errores en el navegador.resolveExternal

📎 rollup.config.js:257-283

Dependencias externas:treeShakenDepsEste es el núcleo de la pregunta de reflexión al final del capítulo anterior. La compilación para navegador solo devuelvedependenciescomo external — estas dependencias, aunque se importan, no se ejecutan realmente en la rama de navegador; se listan aquí solo para suprimir las advertencias de Rollup. Las compilaciones Node/ESM-bundler externalizan todospeerDependenciesypath、url、stream, así como módulos integrados de Node como

.

📎 rollup.config.js:319-352

Objeto de configuración final

  • inputEl objeto de configuración devuelto contiene:
  • external: ruta absoluta del archivo de entrada.
  • plugins: lista de dependencias externas.
  • output: array de plugins, en orden json → alias → enumPlugin → replace → esbuild → nodePlugins.
  • onwarn: configuración de salida.CIRCULAR_DEPENDENCY: filtra las advertencias
  • treeshake.moduleSideEffects: false(existen dependencias circulares en el código fuente de Vue, pero son inofensivas en tiempo de ejecución).

: indica a Rollup que todos los módulos no tienen efectos secundarios, Tree-shaking agresivo.

mermaid
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

execEscritura en disco de artefactos y verificación de tamaño

build.jsGestión de procesos deexecInicia el subproceso de Rollup mediante

📎 scripts/utils.js:64-114

exec:spawnencapsula

  • stdio, devolviendo una Promise. Diseño clave:['ignore', 'pipe', 'pipe']por defecto es
  • shell: process.platform === 'win32'—stdin ignorado, stdout/stderr capturados por pipe.
  • —en Windows se necesita shell para analizar correctamente el comando.stderrChunksRecopila la salida mediantestdoutChunksy el arrayexit, concatenando en el evento
  • .
Si el código de salida es 0, resuelve; de lo contrario, rechaza con el contenido de stderr.

〔Inferencia de diseño y compensaciones arquitectónicas〕build.jsNota:execal llamar a{ stdio: 'inherit' }se pasa

, lo que sobrescribe la configuración de pipe predeterminada, haciendo que la salida de Rollup se transmita directamente a la terminal. Este es el comportamiento correcto de una herramienta de compilación — el usuario necesita ver el progreso de la compilación en tiempo real.checkAllSizes

📎 scripts/build.js:206-215

Verificación de tamaño:devOnlyLa verificación de tamaño tiene dos condiciones de omisión:globales verdadero, o se especificó un formato pero no contiene

📎 scripts/build.js:222-228

checkSize. Porque la verificación de tamaño solo aplica a los artefactos de compilación global — esos son los archivos que el usuario final importa directamente, y el tamaño es lo más sensible.${target}.global.prod.jsVerifica dos archivos:${target}.runtime.global.prod.jsyglobal-runtime(el último solo se verifica cuando no se especifica formato o se especifica

📎 scripts/build.js:235-264

checkFileSize).gzipSyncLee el archivo, calcula el tamaño comprimido conbrotliCompressSyncyprettyBytes, y formatea la salida conwriteSize. Sitemp/size/${fileName}.jsones verdadero, escribe el resultado en

— esta es la fuente de datos para la verificación de presupuesto de tamaño en CI.

📎 scripts/build.js:94-108

Compilación de declaraciones de tipobuildTypesSipnpm run build-dtses verdadero, llama a--environment TARGETS:..., y pasa la lista de objetivos mediante

. Esto asegura que solo se generen declaraciones de tipo para los paquetes realmente compilados.

Reflexiones de diseño y trampas en producción--environment¿Por qué usaren lugar de pasar parámetros directamente?--environmentElprocess.envde Rollup es la única forma de pasar parámetros que puede leerse en el archivo de configuración mediante--config. Pasar directamente el parámetroprocess.argvrequiere analizar--environment, mientras que

fuzzyMatchTargetproporciona un análisis estructurado de pares clave-valor. target.match(partialTarget)La trampa de las expresiones regulares enpartialTarget.runtime-core,-Enruntime.core,.es entrada del usuario. Si el usuario ingresa

como literal en la expresión regular, no hay problema; pero si ingresa runParallelcoincidirá con cualquier carácter, pudiendo coincidir con objetivos inesperados. Este es el riesgo inherente de la coincidencia difusa, pero los nombres de paquetes de Vue no contienen caracteres especiales de regex, por lo que en la práctica no se activa.cpus().lengthCompetencia de recursos en compilaciones concurrentes.--max-old-space-sizeusa

scanEnumscomo límite de concurrencia, pero cada proceso de Rollup también inicia workers. En contenedores CI con pocos núcleos, esto puede causar desbordamiento de memoria. En producción, si se encuentra OOM, se puede mitigar mediante removeCacheo reduciendo el número de concurrencias.finallyCiclo de vida de la caché descanEnums.removeCachese llama enfinally, pero siscanEnumsmismo lanza un error,tryno se asigna, y la llamada en

resolveExternalfallará. En realidad, la función devuelta porya está determinada antes deruntime-core, por lo que este riesgo no existe — pero este es un detalle de secuencia temporal que hay que confirmar al leer.resolveExternalRiesgo de omisión en

.

La pregunta de reflexión del capítulo anterior ya señaló: si se añade una nueva dependencia anode scripts/build.js vuepero se olvida actualizar

1. parseArgs, la compilación para navegador incluirá esa dependencia en el bundle (porque no está en la lista de external), causando un aumento de tamaño. Este es el costo inherente de la estrategia de «external por lista blanca».commitResumen del capítulo

2. run()Un viaje completo descanEnums:fuzzyMatchTargetanaliza la línea de comandos,allTargets)。

3. buildAllse obtiene de forma síncrona.runParallelllama abuild。

4. buildpara generar la caché de enum, analiza los objetivos (package.jsonodistmediante--environmentprograma concurrentementeexecIniciar Rollup.

5. rollup.config.jsLeer variables de entorno, mediantecreateConfiggenerar el arreglo de configuración,resolveDefine/resolveReplace/resolveExternalprocesar respectivamente macros, reemplazos y dependencias externas.

6. Rollup ejecuta la compilación, los artefactos se escriben en disco endist/。

7. checkAllSizesCalcular el tamaño gzip/brotli, opcionalmente escribir entemp/size/。

8. Si--withTypes, llamar abuild-dtsgenerar declaraciones de tipos.

Reflexiones y autoevaluación de este capítulo

Q1: Enbuild.jsdebuildfunción,if (!formats && fs.existsSync(...))esta condición determina si se eliminadistdirectorio. Si se quita!formatsesta condición (es decir, eliminardistindependientemente de si se especifica el formato), enpnpm build-all-cjsen un script como este, ¿qué sucedería?

Análisis de referencia:

📎 scripts/build.js:172-175

pnpm build-all-cjscorresponde anode scripts/build.js vue runtime compiler reactivity shared -af cjs(ver📎 package.json:40). Especifica-f cjs, por lo queformatses'cjs',!formatses falso, la lógica actual no eliminarádist。

Si se quita!formats, cada compilación eliminarádist. Perobuild-all-cjssolo compilacjsformato, tras la eliminacióndistsolo quedacjsartefacto, los previamente compiladosesm-bundler、globaly otros formatos se pierden por completo. Más grave aún,build-runtime-esm、build-browser-esmy otros scripts se ejecutarán secuencialmente (ver📎 package.json:39debuild-sfc-playgroundscript), cada script eliminará los artefactos del script anterior, provocando que al finaldistsolo contenga el formato del último script. Esto rompería la compilación de SFC Playground — que necesita que coexistan artefactos de múltiples formatos.

Q2: runParallelEnif (maxConcurrency <= source.length)¿cuál es la función de esta condición? Si se quita, al compilar un solo paquete (targets.length === 1), ¿qué sucedería?

Análisis de referencia:

📎 scripts/build.js:131-151

Esta condición controla si se habilita la limitación de concurrencia. CuandomaxConcurrency > source.length, no se necesita limitación — todas las tareas pueden iniciarse simultáneamente. Si se quita esta condición, incluso con una sola tarea, se crearáexecutingarreglo y se ejecutaráawait Promise.race(executing)。

Para una sola tarea,executingsolo hay una Promisee,Promise.raceque esperará a que se complete. Esto no causará errores, pero introducirá cadenas de Promise innecesarias y sobrecarga de programación de microtareas. Más importante aún,executing.splice(executing.indexOf(e), 1)sigue funcionando correctamente en escenarios de una sola tarea, así que funcionalmente no hay diferencia, solo una pequeña pérdida de rendimiento.

El riesgo real está en: simaxConcurrencyes 0 (teóricamente imposible, porquecpus().lengthes al menos 1),executing.length >= 0siempre es verdadero,Promise.race([])se colgará indefinidamente. Perocpus().lengthgarantiza que este límite no se active.

Q3: resolveExternalEntreeShakenDeps, la compilación de navegador devuelve

como external, pero estas dependencias no se ejecutarán realmente en la rama de navegador. Si se eliminan de la lista external (es decir, dejar que Rollup intente empaquetarlas), ¿qué sucedería?:

📎 rollup.config.js:257-283

treeShakenDepsAnálisis de referenciasource-map-js、@babel/parser、estree-walker、entities/decodeincluyecompiler-sfc. Estas son dependencias de paquetes como__BROWSER__, excluidas por compilación condicional mediante

macro en la compilación de navegador.treeshake.moduleSideEffects: false(📎 rollup.config.js:355-355Si se eliminan de external, Rollup intentará resolver y empaquetar estas dependencias. Dado queif (!__BROWSER__)), y las declaraciones de importación de estas dependencias están en__BROWSER__rama, el define de esbuild reemplazarátruecon

, marcando la rama como código muerto. El Tree-shaking de Rollup eliminará estas importaciones, y el artefacto final no incluirá el código de estas dependencias.onwarnPero el problema es: Rollup necesita resolver los módulos antes del Tree-shaking. Si estas dependencias no están instaladas (por ejemplo, en un entorno CI reducido), Rollup reportará un error de "no se puede resolver el módulo". Listarlas como external es una medida defensiva — incluso si las dependencias no existen, Rollup no intentará resolverlas, solo emitirá advertencias (y

filtrará las advertencias de dependencias no circulares).scripts/dev.jsHasta aquí, hemos recorrido completamente el viaje de compilación desde el análisis de comandos hasta la invocación de Rollup, revelando mecanismos centrales como la programación concurrente y el filtrado de paquetes privados. Sin embargo, la compilación de producción es solo la mitad de la historia. En el próximo capítulo, nos dirigiremos a la cadena en modo desarrollo, para ver

Convierte cualquier código en un libro comprensible

¿Disfrutaste este capítulo? Convierte tu código privado en un libro

Arquitectura local-first con Tauri 2 + Rust. 100% offline y seguro, sin subir código. Lectura en panel dual con anclajes de commit inmutables.

⚡ Tauri 2 · Rust Core · 100% Privado y Offline · Probado en +1M líneas

CHAPTER 03

Capítulo siguiente: Capítulo 3 →

Upstream: vuejs/core · Commit @4ab865a8 · Progreso: Capítulo 3 de 14

Estado de verificación: FACT anclaje real de números de líneascripts/dev.jsEn el capítulo anterior rastreamos la cadena completa de la compilación de producción desde el análisis de parámetros hasta la escritura en disco de artefactos multi-formato; esa cadena persigue la integridad y normalización de los artefactos. Pero la demanda central del modo desarrollo es solo una: modificar una línea de código y ver el efecto inmediatamente en el navegador. La cadena de compilación de producción de "analizar parámetros → generar configuración → empaquetado completo → escritura en disco" tarda decenas de segundos, incapaz de satisfacer esta demanda. El repositorio de Vue core mantiene por ello una cadena independiente en modo desarrollo:scripts/pre-dev-sfc.jsusa el modo watch de esbuild para compilación incremental,

precompila el compilador de SFC antes de la compilación principal. Este capítulo desglosa el mecanismo de colaboración de ambos.

3.1 dev.js: el compilador incremental que cambia velocidad por esbuild

Modelo intuitivo📎 scripts/dev.js:3-5

La compilación de producción es como "la imprenta formal maquetando e imprimiendo" — prioriza la calidad, no importa ser más lento; la compilación de desarrollo es como "un boceto a lápiz en papel borrador" — no busca belleza, solo que se plasme al instante. Vue elige esbuild en lugar de Rollup para dibujar este boceto, la razón está escrita en el comentario al inicio del archivo: los artefactos de Rollup son más pequeños y su Tree-shaking mejor, pero esbuild es mucho más rápido.

Sin este script, los desarrolladores tendrían que ejecutar una compilación de producción completa cada vez que hagan un cambio, el ciclo de retroalimentación degeneraría de milisegundos a minutos, y la experiencia de hot update desaparecería por completo.

Análisis de parámetros y derivación de formatoparseArgsLa entrada del script usa elformatintegrado de Node para analizar tres opciones:global)、prod(por defectofalse)、inline(por defectofalse)。📎 scripts/dev.js:18-40los parámetros posicionales se recopilan comotargets, si está vacío por defecto es['vue']。📎 scripts/dev.js:42-53

〔Inferencia de diseño y compensaciones arquitectónicas〕

Aquí hay un detalle fácil de pasar por alto:rawFormatyformatson dos asignaciones.parseArgseldefault: 'global'ya garantiza querawFormattiene valor, pero el script aún escribeconst format = rawFormat || 'global'como respaldo.📎 scripts/dev.js:42Esta es una escritura defensiva, para evitar queparseArgscambios de comportamiento o al pasar explícitamente una cadena vacía, elformat.startsWithdownstream lance un error.

formatLa asignación al formato de salida de esbuild tiene tres ramas: comenzando conglobalse asigna aiife, igual acjsse asigna acjs, el resto siempreesm。📎 scripts/dev.js:42-53El sufijo del nombre del archivo de salida se maneja por separado según el sufijo-runtime:global-runtimese convierte enruntime.global, el resto permanece igual.📎 scripts/dev.js:42-53

Localización del paquete objetivo y ruta de salida

El script primero leepackages-privatela lista de directorios, para determinar si el paquete objetivo pertenece a paquetes públicos o privados.📎 scripts/dev.js:56Para cada target, decide si la ruta base del paquete espackagesopackages-private, luegorequiresupackage.jsonobtieneversionybuildOptions。📎 scripts/dev.js:58-63

El nombre del archivo de salida tiene un caso especial:vue-compatel objetivo se renombra avue, para evitar que el artefacto se llamevue-compat.global.js。📎 scripts/dev.js:64-69La ruta final tiene la formapackages/vue/dist/vue.global.js,prodcuando es verdadero se inserta el segmentoprod..

Resolución de external: evitar empaquetar dependencias en el artefacto

externalEl array determina qué módulos no se empaquetan. La lógica se divide en dos capas:

Primera capa, cuandoinlineno está habilitado y el formato escjso contieneesm-bundler, se añaden todas las claves dedependencies、peerDependenciesa external, y se codifican de forma fijapath、url、streamtres módulos integrados de Node.📎 scripts/dev.js:76-88Los comentarios explican claramente que estos tres están preparados para@vue/compiler-sfcyserver-renderer.

Segunda capa, para el objetivocompiler-sfc, se resuelven adicionalmente@vue/consolidatelosdevDependencies, se marcan como external junto confs、vm、cryptoetc.📎 scripts/dev.js:90-112En el código también se codifican de forma fija rutas de motores de plantillas comoreact-dom/server、teacup/lib/express、arc-templates/dist/es5、then-pug、then-jade— estos son motores de plantillas soportados por consolidate, son dependencias opcionales, no se pueden forzar a instalar.

〔Inferencia de diseño y compensaciones arquitectónicas〕

Esta lógica es altamente redundante conrollup.config.js, los comentarios del código fuente también lo admiten (TODO this logic is largely duplicated from rollup.config.js). La razón por la que no se extrajo una función común es porque las estrategias external de dev y prod tienen diferencias sutiles (dev externaliza más agresivamente para acelerar la construcción), forzar la unificación en cambio aumenta el acoplamiento.

Plugins e inyección de define

El array de plugins por defecto solo tiene unlog-rebuild, en el hookonEndimprime la ruta relativa del artefacto de construcción.📎 scripts/dev.js:115-124Esta es la única señal de retroalimentación para que el desarrollador perciba que "los cambios han surtido efecto".

〔Inferencia de diseño y compensaciones arquitectónicas〕

El segundo plugin es condicional: cuando el formato no escjsy elbuildOptions.enableNonBrowserBranchesdel paquete es verdadero, se montapolyfillNode()。📎 scripts/dev.js:126-128paquetes comocompiler-sfcen la construcción de navegador aún toman la rama de Node, necesitan polyfill de módulos integrados de Node para funcionar en el entorno del navegador.

defineEl bloque es la parte con mayor densidad de información de este capítulo.📎 scripts/dev.js:141-159Reemplaza todas las macros__XXX__en el código fuente con literales:

  • __COMMIT__fijado a"dev",__VERSION__toma la versión del paquete;
  • __DEV__determinado por el flagprod,__TEST__siempre esfalse;
  • __BROWSER__La derivación de es la más sutil:format !== 'cjs' && !pkg.buildOptions?.enableNonBrowserBranches。📎 scripts/dev.js:146-148Es decir, solo "no cjs y el paquete no soporta la rama no-navegador" se marca como entorno de navegador;
  • __SSR__esformat !== 'global', es decir, la construcción global no habilita la rama SSR;
  • __COMPAT__determinado por si el target esvue-compat;
  • tres feature flags (__FEATURE_SUSPENSE__、__FEATURE_OPTIONS_API__、__FEATURE_PROD_DEVTOOLS__、__FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) en modo dev se escriben de forma fija todos.

Estas macros corresponden uno a uno con el bloquevitest.config.tsendefine.📎 vitest.config.ts:6-21El entorno de prueba establece__TEST__comotrue、__DEV__establecetrue, la diferencia con la construcción dev es precisamente el punto de distinción entre los dos estados de ejecución "prueba vs desarrollo".

Inicio del modo watch

El último paso esesbuild.context(...).then(ctx => ctx.watch())。📎 scripts/dev.js:130-161 contextcrear el contexto de construcción pero no ejecutarlo inmediatamente,watch()es cuando realmente se inicia la escucha de archivos. Después, esbuild mantiene internamente el grafo de dependencias, cualquier cambio en un archivo dependiente desencadena una reconstrucción incremental, al completar la reconstrucción el callbackonEndimprime el log.

mermaid
flowchart TD
    start["parseArgs 解析 format/prod/inline"] --> targets{"positionals 为空?"}
    targets -->|是| def["targets = ['vue']"]
    targets -->|否| use["targets = positionals"]
    def --> loop["遍历每个 target"]
    use --> loop
    loop --> priv{"target 在 packages-private?"}
    priv -->|是| pbase["pkgBase = packages-private"]
    priv -->|否| pub["pkgBase = packages"]
    pbase --> req["require package.json"]
    pub --> req
    req --> ext{"inline 开启?"}
    ext -->|是| noext["external = []"]
    ext -->|否| fmt{"format 是 cjs 或 esm-bundler?"}
    fmt -->|是| deps["加入 dependencies/peerDependencies + path/url/stream"]
    fmt -->|否| sfc{"target == compiler-sfc?"}
    deps --> sfc
    sfc -->|是| cons["加入 consolidate devDeps + fs/vm/crypto"]
    sfc -->|否| noext
    cons --> ctx["esbuild.context 创建上下文"]
    noext --> ctx
    ctx --> watch["ctx.watch() 启动监听"]
    watch --> onend["onEnd 打印 built: 相对路径"]

3.2 pre-dev-sfc.js: el centinela de precompilación que rompe dependencias circulares

Modelo intuitivo

Imagina un dilema del "huevo y la gallina":compiler-sfcel código fuente de importacompiler-core, ycompiler-coreen modo desarrollo necesitacompiler-sfcpara procesar archivos.vue. Si ambos dependen de la compilación en tiempo real de esbuild watch, quien compile primero se bloquea.pre-dev-sfc.jsEl rol de es "primero incubar el huevo, luego criar la gallina" — antes de que se inicie la construcción principal, asegurar que los artefactos CJS de estos paquetes ya existan.

Lista de verificación y lógica de cortocircuito

El script mantiene una lista fija:compiler-sfc、compiler-core、compiler-dom、compiler-ssr、shared。📎 scripts/pre-dev-sfc.js:4-10Para cada paquete, verifica sipackages/${pkg}/dist/${pkg}.cjs.jsexiste.📎 scripts/pre-dev-sfc.js:4-23

Si falta al menos uno,allFilesPresentse establece enfalsey inmediatamentebreak, sin verificar los paquetes restantes.📎 scripts/pre-dev-sfc.js:20-21Finalmente siallFilesPresentes falso,process.exit(1)sale con código distinto de cero.📎 scripts/pre-dev-sfc.js:25-27

Semántica del código de salida

Este script en sí no ejecuta ninguna compilación, solo hace "aserción de existencia".exit(1)Es una señal para el llamador superior (generalmente la cadena&&del npm script o el script de CI): los artefactos están incompletos, se necesita ejecutar primero una construcción completa. Si todos existen sale normalmente (código de salida 0), la construcción principal continúa.

mermaid
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"]

3.3 aliases.js y vitest.config.ts: la otra mitad del enlace en modo desarrollo

scripts/dev.jsResuelve "cómo generar rápidamente los artefactos", pero en desarrollo hay otra ruta: ejecutar pruebas.scripts/aliases.jsProporciona alias de rutas compartidos para vitest y rollup.📎 scripts/aliases.js:7-7

Lógica de generación de alias

resolveEntryForPkgMapea nombres de paquetes apackages/${p}/src/index.ts。📎 scripts/aliases.js:7-7Los entries base codifican de forma fija cuatro mapeos especiales:vue、vue/compiler-sfc、vue/server-renderer、@vue/compat。📎 scripts/aliases.js:16-21

Luego recorre todos los subdirectorios bajopackagesel directorio, omitiendovuesí mismo, omitiendononSrcPackages(sfc-playground、template-explorer、dts-test), omitiendo claves ya existentes, y debe ser un directorio, solo entonces se añade al mapeo@vue/${dir}.📎 scripts/aliases.js:23-35

〔Inferencia de diseño y compensaciones arquitectónicas〕

Esta estrategia de "elementos especiales codificados de forma fija + elementos genéricos escaneados dinámicamente" es para que los paquetes nuevos no necesiten modificar manualmente el archivo de alias — siempre que el nombre del directorio cumpla con la norma, vitest puede resolverlo automáticamente.nonSrcPackagesLa lista de exclusión se debe a que estos tres paquetes no tienensrc/index.tsentrada, y forzar el mapeo provocaría un fallo en la resolución.

El define de vitest y el consumo de alias

vitest.config.tsimportar directamenteentriescomoresolve.alias。📎 vitest.config.ts:3📎 vitest.config.ts:22-24sudefinebloque forma un contraste con la inyección de macros de dev.js: el entorno de pruebas__DEV__: true、__TEST__: true、__BROWSER__: false、__CJS__: true。📎 vitest.config.ts:6-21

Las pruebas se dividen en cinco proyectos:unit、unit-gc、unit-jsdom、e2e、e2e-browser。📎 vitest.config.ts:51-118entre los cualesunit-gcusapool: 'forks'y pasa--expose-gc, dedicado a ejecutar pruebas SSR que requieren activar manualmente el GC.📎 vitest.config.ts:65-76 e2e-browseren cambio habilita la instancia de chromium de playwright para ejecutar las pruebas relacionadas con Transition.📎 vitest.config.ts:99-117

mermaid
sequenceDiagram
    participant Dev as 开发者
    participant NPM as npm script
    participant Pre as pre-dev-sfc.js
    participant DevJS as dev.js
    participant ESB as esbuild context
    participant FS as 文件系统

    Dev->>NPM: 启动开发
    NPM->>Pre: 检查 SFC 产物
    Pre->>FS: existsSync(dist/*.cjs.js)
    alt 产物缺失
        FS-->>Pre: false
        Pre-->>NPM: exit(1)
        NPM-->>Dev: 提示先跑完整构建
    else 产物齐全
        FS-->>Pre: true
        Pre-->>NPM: exit(0)
        NPM->>DevJS: 启动 dev.js
        DevJS->>ESB: context(...).watch()
        ESB->>FS: 监听源码变化
        Dev->>FS: 修改 src/index.ts
        FS-->>ESB: 文件变更事件
        ESB->>ESB: 增量重建
        ESB-->>Dev: onEnd 打印 built: 路径
    end

Reflexión de diseño

¿Por qué dev usa esbuild y prod usa Rollup?Esto no es una elección tecnológica arbitraria, sino que las restricciones de ambos escenarios son diferentes. En desarrollo no importa el tamaño del artefacto, pero la latencia de retroalimentación es extremadamente sensible; en producción ocurre lo contrario. esbuild está escrito en Go y tiene un alto grado de paralelización, su arranque en frío y su construcción incremental son un orden de magnitud más rápidos, pero su capacidad de Tree-shaking y de división de código es inferior a la de Rollup.📎 scripts/dev.js:3-5Usar dos conjuntos de herramientas para servir a dos escenarios distintos es un compromiso pragmático de ingeniería.

〔Inferencia de diseño y compensaciones arquitectónicas〕

¿Por qué pre-dev-sfc solo verifica y no compila?Si él mismo desencadenara la compilación, volvería a introducir la dependencia circular: necesita compilarcompiler-sfc, y el proceso de compilación en sí mismo puede depender decompiler-sfclos artefactos de. Por lo tanto, solo puede hacer una «aserción», exponiendo el hecho de «falta de artefactos» a la capa superior, y que esta decida si ejecutar la compilación completa o salir con error. Este es un «patrón centinela»: no resuelve el problema, solo lo reporta.

¿Es la duplicación de la lista external deuda técnica?La lógica external de dev.js y rollup.config.js está duplicada, y los comentarios del código fuente también lo admiten.📎 scripts/dev.js:73Pero los conjuntos external de ambos no son completamente idénticos: dev, por velocidad, externaliza de forma más agresiva. Extraer a la fuerza una función común requeriría introducir interruptores de diferencia parametrizados, lo que haría que ambas lógicas fueran más difíciles de leer. Esta es una compensación típica de «la duplicación es mejor que una abstracción errónea».

Resumen del capítulo

Este capítulo desglosa las tres piezas del rompecabezas de la cadena en modo desarrollo de Vue core:

1. scripts/dev.js: usar elcontext().watch()de esbuild para implementar construcción incremental, medianteparseArgsresolver formato y banderas, dinámicamenterequireel paquete objetivopackage.jsonlocalizar la ruta de salida, inyectar__DEV__、__BROWSER__y otras macros para controlar la compilación condicional, y usarlog-rebuildel plugin para imprimir retroalimentación después de cada reconstrucción.

2. scripts/pre-dev-sfc.js: antes de la construcción principal, verificar si existen los artefactos CJS de los cinco paquetes principales; si faltan, cortocircuitar con código de salida 1 para evitar un bloqueo de construcción causado por dependencias circulares.

3. scripts/aliases.js + vitest.config.ts: proporcionar alias de rutas compartidos para la cadena de pruebas, con elementos especiales codificados y elementos genéricos escaneados dinámicamente, junto con una configuración multiproyecto que cubre cinco escenarios de prueba: unitarias, GC, jsdom, e2e y e2e en navegador.

Reflexión y autoevaluación de este capítulo

P1: Si se eliminascripts/pre-dev-sfc.jsdebreak(es decir, verificar todos los paquetes antes de decidir salir), ¿en qué escenarios empeoraría la experiencia del desarrollador? ¿Por qué el autor del código fuente eligió «cortocircuitar al encontrar la primera ausencia»?

Análisis de referencia:

📎 scripts/pre-dev-sfc.js:4-23

breakse encuentra enif (!fs.existsSync(...))dentro de la rama, y en cuanto detecta que falta el artefacto de algún paquete, sale inmediatamente del bucle.

Si se eliminabreak, el script seguiría verificando los paquetes restantes, y finalmenteallFilesPresentseguiría siendofalse, el código de salida seguiría siendo 1,funcionalmente equivalente. Pero la diferencia está en:

1. Rendimiento: las cincoexistsSyncllamadas en sí mismas son rápidas, pero si la lista se expande a decenas de paquetes, el cortocircuito ahorraría una gran cantidad de llamadas al sistema stat innecesarias.

2. Semántica: el cortocircuito expresa «basta con que falte uno para que el conjunto esté incompleto»: es una aserción booleana, no hace falta saber cuántos faltan exactamente. Seguir verificando no produce información adicional.

3. Experiencia del desarrollador: en realidad lo que empeora es el «mensaje de error». El script actual no imprime qué paquete falta, el desarrollador solo ve el código de salida 1. Si se eliminabreaky se añaden logs, en cambio se podría decir al desarrollador «faltan compiler-core y shared», pero eso requiere código adicional. El autor eligió la implementación más simple, dejando el diagnóstico de «cuál falta» al error del script de construcción de la capa superior.

Por lo tantobreakla motivación central es «semántica de aserción + rendimiento», no la optimización de la experiencia.

Q2: scripts/dev.jsen__BROWSER__la deducción deformat !== 'cjs' && !pkg.buildOptions?.enableNonBrowserBranchesesbuildOptions.enableNonBrowserBranches. Supongamos que eltruede algún paquete es-f global, y que el desarrollador usa__BROWSER__para construir, en ese momentofalseestrue. ¿Qué consecuencias tendría esto? ¿Qué pasaría si por error se cambiara a

Análisis de referencia:

📎 scripts/dev.js:146-148

Cuandoformat = 'global'yenableNonBrowserBranches = true:

  • format !== 'cjs'estrue
  • !pkg.buildOptions?.enableNonBrowserBranchesesfalse
  • En conjunto__BROWSER__ = false

Esto significa que todas las ramasif (__BROWSER__)del código fuente son reemplazadas por el define de esbuild conif (false), el código exclusivo del navegador se elimina mediante Tree-shaking y las ramas que no son de navegador (lógica exclusiva de Node) se conservan.

Consecuencia: el artefacto de construcción global debería ejecutarse en el navegador, pero incluye ramas exclusivas de Node. Si estas ramas hacen referencia afs、pathy otros módulos integrados de Node, al cargar en el navegador se reportará «módulo no definido». Esta es precisamente la razón por la que los paquetes conenableNonBrowserBranchesverdadero (comocompiler-sfc) normalmente no se usan para la construcción global, o necesitanpolyfillNode()un plugin de respaldo.📎 scripts/dev.js:126-128

Si por error se cambiara atrue:__BROWSER__ = true, se conservarían las ramas del navegador y se eliminarían las de Node. Paracompiler-sfcpaquetes como este que deben ejecutar la compilación SFC en el entorno Node, esto provocaría que la funcionalidad central (leer archivos, llamar a la API de Node) fuera eliminada por Tree-shaking, y el artefacto reportaría «función no definida» al ejecutarse en Node.

Q3: scripts/aliases.jsen, al escanear dinámicamentepackagesel directorio se omitenonSrcPackages(sfc-playground、template-explorer、dts-test). Si algún paquete nuevo se añade apackagesel directorio pero nosrc/index.ts, y no se ha añadido anonSrcPackages, ¿qué sucede? ¿En qué etapa fallará vitest durante la ejecución?

Análisis de referencia:

📎 scripts/aliases.js:23-35

La lógica de escaneo dinámico es: para cada directorio, sidir !== 'vue', no está ennonSrcPackages, la key no existe, y es un directorio, se añade aentries['@vue/${dir}'] = resolveEntryForPkg(dir)。

resolveEntryForPkgdevuelve la ruta depackages/${p}/src/index.ts.📎 scripts/aliases.js:7-7Nótese queno comprueba si el archivo existe, solo concatena la ruta.

Consecuencia: el alias se registrará, pero apuntará a un archivo inexistente. Cuando vitest resuelve un import, si algún archivo de test importa este paquete, el plugin resolve de Vite intentará cargar esa ruta y reportará «no se puede resolver el módulo» o «el archivo no existe».

Etapa del error: no ocurre durante la ejecución dealiases.js(que solo hace concatenación de cadenas), sino tras el arranque de vitest, la primera vez que se resuelve ese import. Si ningún test importa este paquete, no habrá error — el alias simplemente queda en el objetoentries.

Forma de evitarlo: añadir este tipo de paquetes sinsrc/index.tsanonSrcPackages, o asegurarse de que el nuevo paquete tenga una entrada estándar. Esta es también la razón por la quenonSrcPackagesrequiere mantenimiento manual — es la lista de excepciones de «convención sobre configuración».

Los límites de la colaboración entre los tres son muy claros:pre-dev-sfcgestiona «si los artefactos están listos»,dev.jsgestiona «cómo actualizar rápidamente los artefactos»,aliasesgestiona «cómo los tests resuelven el código fuente». La cadena en modo desarrollo resuelve el problema de la velocidad, pero en la fase de compilación hay otro tipo de optimización más sutil — aquellas transformaciones que se completan antes de que el código sea ejecutado por el navegador. El siguiente capítulo entra en la magia de la fase de compilación, para ver cómo el inline de enums y el mecanismo de verificación de Tree-shaking reemplazan los TypeScript enum por literales durante la compilación, y garantizan que la promesa de importación bajo demanda no se rompa.

Convierte cualquier código en un libro comprensible

¿Disfrutaste este capítulo? Convierte tu código privado en un libro

Arquitectura local-first con Tauri 2 + Rust. 100% offline y seguro, sin subir código. Lectura en panel dual con anclajes de commit inmutables.

⚡ Tauri 2 · Rust Core · 100% Privado y Offline · Probado en +1M líneas

CHAPTER 04

Capítulo 4: Magia en tiempo de compilación: inline de enums y mecanismo de verificación de Tree-shaking

Upstream: vuejs/core · Commit @4ab865a8 · Progreso: Capítulo 4 de 14

En el capítulo anterior vimos cómo la cadena en modo desarrollo intercambia velocidad de «cambiar una línea y que surta efecto inmediatamente» mediante file watching y compilación incremental. Pero más allá de la velocidad, Vue tiene otra restricción más sutil: el tamaño de los artefactos publicados debe ser controlable. Uno de los enemigos de esta restricción es el enum de TypeScript — en tiempo de ejecución es un objeto real que rompe el Tree-shaking. Este capítulo entra en la fase de compilación para ver cómo scripts/inline-enums.js «disuelve» los enums en literales antes de que el código sea ejecutado por el navegador; y luego cómo scripts/verify-treeshaking.js, tras la compilación, verifica inversamente mediante cadenas del artefacto que la promesa de «importación bajo demanda» no se ha roto silenciosamente.

4.1 Inline de enums: disolver objetos en tiempo de ejecución en literales

Modelo intuitivo

Imagina que escribes una receta en la que aparece repetidamente «una pizca de sal». Si cada vez que cocinas tienes que ir al apéndice a consultar «una pizca = 3 gramos», es lento y ocupa espacio. Lo que hace el inline de enums es, antes de imprimir, reemplazar en todo el libro «una pizca de sal» por «3 gramos de sal», y luego arrancar esa página del apéndice. Para el lector (tiempo de ejecución), el resultado es exactamente el mismo, pero el libro es más delgado.

Si no existiera, ¿qué catástrofe enfrentaría el sistema? Unenumnormal de TypeScript, tras compilar, genera un objeto literal real, y con mapeo bidireccional (Enum[Enum.A] === 'A'). Este objeto esuna declaración a nivel de módulo con efectos secundarios, Rollup no puede probar que no se usa, así que solo puede conservarlo — aunque solo importes uno de sus miembros, todo el objeto enum junto con el mapeo inverso se incluirá en el artefacto.📎 scripts/inline-enums.js:3-9El comentario deconst enumlo dice claramente: solían usar

, pero por el issue #1228 cambiaron a enum normal, así que usan este script para «recuperar manualmente el beneficio de coste cero de const enum».

Estructura de datos y diseño en memoria📎 scripts/inline-enums.js:33-36

  • EnumMember:{ name, value }El núcleo del script son tres definiciones de tipos; entenderlas es entender todo el flujo de datos.
  • EnumDeclaration:{ id, range: [start, end], members }。range, el nombre de un miembro individual del enum y el literal evaluado.esel offset en bytes del código fuenteexport enum X { ... }, que apunta a la posición inicial y final de toda la declaración de
  • EnumData:{ declarations, defines }。declarationsen el archivo — este es el ancla para el reemplazo preciso posterior con MagicString.definesindexado por ruta de archivo, registra los rangos de reemplazo de todas las declaraciones de enum en ese archivo; es un mapeo plano, cuya clave es ` 形式的字符串,值是 ${nombreEnum}.${nombreMiembro}

el literal tras JSON.stringify`.definesAquí hay un diseño clave:la clave de。📎 scripts/inline-enums.js:98-103no incluye la ruta del archivoErrorCodesEl comentario explica la razón —@vue/compiler-corepuede existir simultáneamente en@vue/runtime-coreyErrorCodes.__EXTEND_POINT__, por lo que se permite que enums con el mismo nombre existan en distintos archivos; pero el mismofullKey in definesno puede repetirse en dos enums con el mismo nombre, de lo contrarioname conflicthace match y lanza directamente

. Esta es una restricción de «único globalmente por nombre de miembro», no de «único globalmente por nombre de enum».temp/enum.json。📎 scripts/inline-enums.js:33-36La caché se guarda enscanEnums()¿Por qué es necesario persistir en disco? Porquese llama solo una vez en la entrada de compilación, y Rollup iniciará。📎 scripts/inline-enums.js:39-41procesos independientesinlineEnums()para cada paquete y cada formato. El comentario señala: los datos deben compartirse entre procesos concurrentes de Rollup, así que deben serializarse a disco, y cada proceso los lee de vuelta mediante su

.

Paso a paso: de grep al reemplazo por literalesexport enumPrimer paso: grep de todos los archivos que contienen📎 scripts/inline-enums.js:51-61.spawnSync('git', ['grep', 'export enum'])usapath:line:content, la salida tiene la forma:, luego se corta porSetel primer segmento (ruta del archivo), y se deduplica congit grepen lugar de recorrer el sistema de archivos: naturalmente solo escanea los archivos rastreados por Git, excluyendo automáticamentenode_modulesy los artefactos de compilación.

Segundo paso: Babel analiza y recopila información de enumeraciones.📎 scripts/inline-enums.js:64-70Para cada archivo usa@babel/parsercontypescriptplugin,sourceType: 'module'lo analiza en un AST, y luego solo recorre los nodos de nivel superior deast.program.body.📎 scripts/inline-enums.js:74-79Solo reconoce nodosExportNamedDeclarationque seandeclaration.type === 'TSEnumDeclaration'y cuyo— es decir, las enum no exportadas no serán procesadas。

Para cada declaración de enumeración, el script evalúa miembro por miembro. La evaluación de miembros se divide en tres rutas:

1. Inicialización literal:StringLiteraloNumericLiteraltoma directamenteinit.value。📎 scripts/inline-enums.js:114-119

2. Expresión binaria: como1 << 2. RecursivamenteresolveValueprocesa los operandos izquierdo y derecho; los operandos pueden ser literales o tambiénMemberExpression(es decir, referencias a miembros de enumeración previamente definidos).📎 scripts/inline-enums.js:121-151La clave está en la ramaMemberExpression: usacontent.slice(node.start, node.end)desdeel texto fuente originalpara extraer la cadena de expresión (comoErrorCodes.FOO), luego consultadefines. Si no lo encuentra, lanzaunhandled enum initialization expression。📎 scripts/inline-enums.js:132-141Esto explica por quédefinesdebe ser un mapeo plano global — al referenciar entre enumeraciones, el referenciado puede provenir de otro archivo, pero la clave solo reconoce枚举名.成员名。

3. Expresión unaria: como-1, se concatena en la cadena-1y luego se evalúa conevaluate.📎 scripts/inline-enums.js:152-163

La evaluación en sí usanew Function('return ' + exp)()。📎 scripts/inline-enums.js:39-41Esto es uneval controlado: la entrada proviene de fragmentos de AST ya analizados del código fuente, no de entrada arbitraria del usuario, por lo que el límite de seguridad es controlable.

Tercer paso: procesar miembros sin inicializador (semántica de autoincremento).📎 scripts/inline-enums.js:171-183Si un miembro no tieneinitializer: el primer miembro por defecto es0; si los miembros posterioreslastInitializedson números entonces++; si son cadenas entonces lanzawrong enum initialization sequence— porque los miembros de enumeración de cadena no permiten autoincremento implícito. Esta es precisamente la semántica de las enumeraciones de TypeScript.

Cuarto paso: escribir caché y devolver función de limpieza.📎 scripts/inline-enums.js:200-213 scanEnums()Devuelve un closure; al invocarlo sermSyncelimina el archivo de caché.build.jsSe usa dentro detry/finally.📎 scripts/build.js:81-112Esto garantiza que incluso si ocurre un error a mitad de la compilación, la caché se limpie y no contamine la siguiente compilación.

Quinto paso: reemplazo en la fase transform de Rollup. inlineEnums()Lee de vuelta la caché y construye un plugin de Rollup.📎 scripts/inline-enums.js:219-234Entransform(code, id), siidcoincide conenumData.declarations, usa MagicString para reemplazar[start, end]ese segmento de declaración con un objeto literal.📎 scripts/inline-enums.js:242-274

La forma tras el reemplazo esexport const X = { ... }. Nótese queno simplemente elimina la enumeración, sino que la reescribe como objeto literal, y además genera mapeo inverso adicional para miembros numéricos:JSON.stringify(value.toString()) + ': ' + JSON.stringify(name)。📎 scripts/inline-enums.js:257-270El comentario cita la regla de reverse-mappings de la documentación oficial de TypeScript: los miembros de enumeración de cadena no generan mapeo inverso, los numéricos sí. Esto garantiza que el comportamiento en tiempo de ejecución tras el reemplazo sea completamente idéntico al enum original.

Y lo que realmente elimina la sobrecarga en tiempo de ejecución es quedefinesse entrega a@rollup/plugin-replace。📎 rollup.config.js:222-223Todas lasX.Memberreferencias ason reemplazadas directamente por literales en el plugin de reemplazo, de modo que ese objeto literal reescrito, si nadie lo usa, puede ser eliminado por Tree-shaking.El siguiente diagrama de flujo describe la ruta de decisión completa desde grep hasta el reemplazo:

Copiar

mermaid
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 替换引用"]

¿Por qué usar MagicString en lugar de regenerar todo el archivo?

Porquesolo reemplaza el segmento de la declaración de enumeración, el resto de los bytes del código fuente permanecen intactos,s.update(start, end, ...)y además puede generar sourcemaps precisos.s.generateMap()Si se usara Babel para reimprimir todo el AST, se perdería el formato original, los comentarios, y la calidad del sourcemap disminuiría.📎 scripts/inline-enums.js:277-281¿Por qué

rangeen lugar denode.start/node.endLo que se afirma esdeclaration.start?📎 scripts/inline-enums.js:189-193(es decir, el nodonode.start), el rango de reemplazo cubreExportNamedDeclarationtodo el segmento, incluyendoexport enum X {...}la palabra claveexport. El texto de reemplazo comienza conexport const, continuando exactamente.

Puntos problemáticos:definesLa restricción de unicidad global deSi dos archivos diferentes tienen cada uno unErrorCodes, y ambos definen__EXTEND_POINT__, la compilación fallará directamente.📎 scripts/inline-enums.js:101-103Esto no es un bug, sino un diseño deliberado — porquedefineses una tabla de reemplazo global, incapaz de distinguir el origen del archivo. En producción, al agregar nuevos miembros de enumeración, si el nombre entra en conflicto con un miembro de enumeración existente, explotará aquí.

Punto problemático:new FunctionEl momento de evaluación deLa evaluación de expresiones binarias ocurre en la fasescanEnums, en ese momentodefinespuede que aún no tenga el miembro referenciado (si el orden de referencia está invertido).📎 scripts/inline-enums.js:136-140lanzaráunhandled enum initialization expression. Esto requiere que las referencias a miembros de enumeración sigan el orden del código fuente de «definir primero, referenciar después».

4.2 Verificación de Tree-shaking: demostrar la promesa a la inversa usando las cadenas del artefacto

Modelo intuitivo

La inclusión de enumeraciones es una «optimización previa», pero ¿realmente surte efecto la optimización? Si algún helper se conserva accidentalmente por una escritura inadecuada, el tamaño se inflará silenciosamente sin que el desarrollador lo note.verify-treeshaking.jsEs ese «inspector de calidad posterior»: construye el artefacto y luego, como en una autopsia, revisa en el artefactosi aparece lo que no debería aparecer. Sin él, la promesa de importación bajo demanda de Vue podría romperse silenciosamente tras alguna refactorización, hasta que los usuarios se quejen de que el paquete creció.

Estructuras de datos e ítems de verificación

Este script no tiene estructuras de datos complejas; el núcleo es unerrorsarray y tresincludesverificaciones📎 scripts/verify-treeshaking.js:6-6Primero construyeglobal-runtimeformato, luego lee los artefactos dev y prod por separado.

Los tres ítems de verificación corresponden a tres tipos de «fallo de Tree-shaking»:

1. El artefacto dev contiene__spreadValues。📎 scripts/verify-treeshaking.js:13-19Este es el helper que esbuild genera para{ ...obj }la sintaxis de propagación de objetos. Si aparece, indica que el código en tiempo de ejecución usa propagación de objetos, cuando la convención de Vue es usarextendhelper para evitar código adicional.

2. El artefacto prod contieneVue warn。📎 scripts/verify-treeshaking.js:26-31Indica que haywarn()llamadas que no están envueltas por la condición__DEV__, provocando que el código de advertencia se filtre al paquete de producción.

3. El artefacto prod contiene la lista de configuración de DOM tags。📎 scripts/verify-treeshaking.js:33-42comohtml,body,base、svg,animate,animateMotion、annotation,annotation-xml,maction. Estos sonisHTMLTag()Los datos internos de helpers como este deberían existir solo en el compilador y ser eliminados por el runtime. Si aparecen en el artefacto de runtime, indica que la ruta de runtime está usando indebidamente un helper exclusivo del compilador.

Paso a paso: flujo de verificación

📎 scripts/verify-treeshaking.js:5-5Primeroexec('pnpm', ['build', 'vue', '-f', 'global-runtime']), construir solovuedel paqueteglobal-runtimeen formato — este es el artefacto de runtime minimizado, el más adecuado para exponer fugas. Tras completar la construcción, leer sincrónicamente ambos archivos, revisar uno por unoincludes, y al encontrar coincidencias hacer push aerrorsde un mensaje con explicación. Finalmente, sierrors.lengthes distinto de cero, lanzar un error agregado.📎 scripts/verify-treeshaking.js:44-48

mermaid
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 聚合错误"]

Reflexiones de diseño y trampas

〔Inferencia de diseño y compensaciones arquitectónicas〕

¿Por qué usar cadenasincludesen lugar de análisis AST?Porque esto es una "verificación centinela", no un "análisis preciso". No busca completitud, solo establecer alertas de bajo costo para tres tipos de regresiones que han ocurrido realmente en la historia. La coincidencia de cadenas tiene cero dependencias, cero sobrecarga de análisis, y es igualmente efectiva en artefactos comprimidos — el análisis AST se vuelve más difícil después de minify.

〔Inferencia de diseño y compensaciones arquitectónicas〕

¿Por qué verificar sologlobal-runtime?este formato incorpora todas las dependencias en línea (externalestá vacío), es el artefacto más sensible al tamaño y más propenso a inclusiones erróneas. Si está limpio, otros formatos normalmente también lo están. Además, se construye rápido, adecuado para ejecutarse frecuentemente en CI.

〔Inferencia de diseño y compensaciones arquitectónicas〕

Trampa: los elementos de verificación son una "lista negra", que se vuelve obsoleta con la evolución del código.Si algún díaisHTMLTagla estructura de datos cambia,html,body,baseesta cadena deja de aparecer, y la verificación queda vacía. Esto requiere que los mantenedores actualicen sincrónicamente las cadenas centinela aquí al modificar los helpers relacionados. Este es el costo inherente de la verificación por lista negra.

4.3 Colaboración con Rollup: orden de plugins e inyección de define

La inclusión de enums no opera aisladamente, está incrustada en el pipeline de plugins de Rollup. Entender su posición en el pipeline es entender por quédefinesse delega areplaceen lugar deesbuild。

📎 rollup.config.js:47-50llamar en el nivel superior del módulo de configuracióninlineEnums(), desestructurando[enumPlugin, enumDefines]. Nota que esto se ejecutaal inicio de cada proceso de Rollup, leyendo la caché escrita porscanEnums.

El orden del array de plugins es:json → alias → enumPlugin → ...resolveReplace() → esbuild。📎 rollup.config.js:324-339 enumPluginva antes dereplace, lo que significa que la reescritura de declaraciones de enum ocurre primero, luegoreplaceusadefinespara reemplazar referencias. Yesbuildva al final, encargado de la transpilación TS.

¿Por quédefinesusareplacey noesbuildel comentario dedefine?📎 rollup.config.js:220-221da la respuesta: el define de esbuild "es algo estricto, solo permite literales JSON o identificadores". Y nombres de miembros de enum comoErrorCodes.__EXTEND_POINT__son expresiones de miembro con punto, que el define de esbuild no puede manejar directamente como claves. Por eso se debe usar@rollup/plugin-replace, que soporta reemplazo de claves de cadena arbitrarias.📎 rollup.config.js:250-251y se configurópreventAssignment: true, para evitar reemplazar también el lado izquierdo de asignaciones.

resolveReplace()enconst replacements = { ...enumDefines }es el primer paso.📎 rollup.config.js:222-223Después se superponen las anotaciones de producción/*@__PURE__*/,__DEV__y otros reemplazos. Este orden garantiza que el reemplazo de literales de enum siempre tenga efecto.

Reflexiones de diseño

La esencia de la inclusión de enums es "intercambiar complejidad en tiempo de construcción por tamaño en runtime".Replica completamente la semántica del sistema de tipos de TypeScript (evaluación de enum, autoincremento, mapeo inverso) en tiempo de construcción —scanEnumsla lógica de evaluación en📎 scripts/inline-enums.js:110-183es casi un subconjunto de la evaluación de enums del compilador TS.unhandledEsto conlleva costo de mantenimiento: si TS añade nueva sintaxis de enum (como expresiones constantes más complejas), aquí debe actualizarse, o se lanzará error

. Pero el beneficio es claro: cero objetos enum en runtime, Tree-shaking completo.

〔Inferencia de diseño y compensaciones arquitectónicas〕El script de verificación y el script de inclusión son un par de "promesa y cumplimiento".

El script de inclusión promete "los enums no ocupan tamaño en runtime", el script de verificación comprueba "otro código tampoco ocupa tamaño a escondidas". Ambos protegen conjuntamente el presupuesto de tamaño de Vue. Este diseño pareado de "optimización + verificación" es un patrón típico de ingeniería en grandes bibliotecas frontend: toda optimización necesita una verificación automatizada para prevenir regresiones. scanEnumsLa caché entre procesos es imprescindible para construcciones concurrentes.inlineEnumsEl patrón de ejecución única,📎 scripts/inline-enums.js:39-41múltiples lecturas,

resuelve el problema de "un escaneo, N procesos consumidores". Sin caché, cada proceso de Rollup tendría que hacer grep + parseo de nuevo, desperdiciando gran cantidad de IO y CPU.

Resumen del capítulo

Reflexiones y autoevaluación del capítuloscanEnumsP1: Si se eliminasaveValueenif (fullKey in defines)de

la verificación de conflictos, ¿en qué escenarios causaría errores en el artefacto de construcción?:

definesAnálisis de referencia枚举名.成员名es un mapeo plano global, con clave📎 scripts/inline-enums.js:98-103, sin rutas de archivo.@vue/compiler-coreTras eliminar la verificación de conflictos, si dos archivos distintos tienen cada uno un enum con el mismo nombre y definen un miembro con el mismo nombre (como@vue/runtime-coreyErrorCodes.__EXTEND_POINT__ambos tienen

), el último en escribir sobrescribe al primero.defines['ErrorCodes.__EXTEND_POINT__']Consecuencias:plugin-replacesolo queda un valor, yal reemplazar no puede distinguir el archivo de origen, reemplazarátodosErrorCodes.__EXTEND_POINT__los📎 rollup.config.js:222-223en los archivos por el mismo valor.

Entonces el valor del miembro de enum de uno de los paquetes es alterado silenciosamente, causando comportamiento erróneo en runtime y extremadamente difícil de diagnosticar — porque el código fuente parece completamente correcto.📎 scripts/inline-enums.js:98-100Esto es precisamente la razón por la que el comentario enfatiza "permitir enums con el mismo nombre entre archivos, pero no miembros con el mismo nombre".

La verificación de conflictos es el guardián que previene la contaminación de la tabla de reemplazo global.rollup.config.jsP2: Si se intercambia el orden deenumPluginy...resolveReplace()en el array de plugins de

, ¿qué ocurriría?:

Análisis de referenciaenumPluginEl orden actual esreplaceprimero,📎 rollup.config.js:331-332después.transformEl hook

de Rollup se ejecuta en el orden del array de plugins.replaceSi se intercambia,export enum X { ... }se ejecutaría primero, cuando las declaraciones de enum aún están en su forma originalreplace.definesusaX.Memberpara reemplazar referencias aenumPlugin— pero en ese momento las referencias aún existen, el reemplazo puede funcionar. El problema surge cuandos.update(start, end, ...)se ejecuta después: usa📎 scripts/inline-enums.js:250-273para reescribir el segmento de declaración.replacePerocodeya ha modificadoenumPlugin, ycodeobtienereplacecuya desplazamiento de bytes ya no corresponde conscanEnumsregistrado enrange(basado en el código fuente original)ya no corresponde。

Consecuencia: MagicString cortará en el desplazamiento incorrecto y la sintaxis del artefacto quedará corrupta. Esto revela un contrato implícito del pipeline de plugins:las transformaciones basadas en desplazamientos del código fuente deben ejecutarse primero, para que las transformaciones posteriores puedan continuar de forma segura sobre su salida.

Q3: verify-treeshaking.jssolo verifica tres centinelas de cadena. Si alguna refactorización cambia losisHTMLTagdatos internos de'html,body,base'de['html','body','base']a una forma de arreglo

, ¿qué pasaría con el script de verificación? ¿Qué defecto de diseño expone esto?:

Análisis de referenciaprodBuild.includes('html,body,base')El script de verificación usa📎 scripts/verify-treeshaking.js:33-37para comprobar.includesSi los datos cambian a un arreglo, la cadena conectada por comas ya no aparecerá en el artefacto minificado,falsedevuelve, la comprobaciónpasa silenciosamenteisHTMLTag—incluso si

realmente se filtró al artefacto de tiempo de ejecución.Esto expone el defecto inherente de la verificación de cadenas tipo lista negra:las cadenas centinela están acopladas a la implementación del código fuente; si la implementación cambia, la verificación deja de ser válida

. No puede detectar «filtraciones desconocidas», solo puede detectar «filtraciones conocidas y cuya forma de cadena no ha cambiado».

〔Inferencia de diseño y compensaciones arquitectónicas〕isHTMLTagDirección de mejora: se podría cambiar a verificar identificadores más estables (como el nombre de función

), o prohibir a nivel de código fuente mediante reglas de lint el import en tiempo de ejecución de helpers del compilador, en lugar de depender de cadenas del artefacto. Pero bajo las restricciones de costo actuales, los centinelas de cadena son un compromiso «suficiente y barato»..d.tsLa inclusión de enums resolvió «cómo eliminar la sobrecarga en tiempo de ejecución durante la compilación», y el script de verificación resolvió «cómo confirmar que la optimización no se ha roto». Pero los artefactos de compilación, además de JS, incluyen otro tipo de producto que también requiere procesamiento en el pipeline: los archivos de declaración de tipos. El siguiente capítulo entrará en el pipeline de artefactos de tipos, para ver cómo Vue genera un paquete de tipos de nivel de publicación a partir del código fuentedts-testy cómo

usa pruebas de contrato de tipos para proteger la forma de tipos de la API pública.

Convierte cualquier código en un libro comprensible

¿Disfrutaste este capítulo? Convierte tu código privado en un libro

Arquitectura local-first con Tauri 2 + Rust. 100% offline y seguro, sin subir código. Lectura en panel dual con anclajes de commit inmutables.

⚡ Tauri 2 · Rust Core · 100% Privado y Offline · Probado en +1M líneas

CHAPTER 05

Capítulo siguiente: Capítulo 5 →

Upstream: vuejs/core · Commit @4ab865a8 · Progreso: Capítulo 5 de 14

Estado de verificación: líneas FACT ancladas de forma realinline-enums.jsEn el capítulo anterior desglosamosverify-treeshaking.jsyimport { ref } from 'vue': uno se encarga de reemplazar las referencias a enum por literales, permitiendo que el objeto enum sea eliminado por Tree-shaking, y el otro se encarga de confirmar después de la compilación, mediante centinelas de cadena, que tres tipos conocidos de fugas no regresen. Ambos protegen conjuntamente la promesa de tamaño en tiempo de ejecución de Vue. Pero los artefactos de compilación no son solo JS. Cuando el usuariotsc, las sugerencias de tipo que muestra el editor,.d.tsla verificación de tipos del código del usuario, todo depende de otro tipo de artefacto:srclos archivos de declaración. Si el artefacto JS está mal, hay errores en tiempo de ejecución; si el artefacto de tipos está mal, el usuario obtiene errores en tiempo de compilación, o peor aún: los tipos derivan silenciosamente, el código del usuario compila, pero la forma de los tipos no coincide con el comportamiento real en tiempo de ejecución. Este capítulo rastrea cómo Vue agrega los tipos del código fuente dispersos en losdts-built-testde cada subpaquete en un paquete de tipos de nivel de publicación, y usa

para hacer pruebas de humo de tipos sobre artefactos de compilación reales.

5.1 Pipeline de tipos en dos fases: tsc produce, rollup agrega

Modelo intuitivo.tsImagina una línea de impresión: en la primera fase, cada subpaquete compone su propio manuscrito (.d.tscódigo fuente) en una prueba de página única (.d.ts); en la segunda fase, se encuadernan decenas de pruebas en un libro según el orden del directorio (nivel de publicación

), y se unifican encabezados y pies de página (declaraciones de exportación).Sin este pipeline, Vue tendría que mantener manualmente un archivo de tipos de publicación, y cada cambio en el código fuente requeriría una edición manual sincronizada: un caldo de cultivo para la deriva de tipos. El enfoque de Vue es:。

los artefactos de tipos se generan completamente a partir del código fuente, nunca se escriben a mano

tsconfig.build.jsonPrimera fase: tsconfig.build.json delimita el alcance de produccióntsconfig.jsones la configuración de la primera fase de este pipeline. Hereda la raíz

📎 tsconfig.build.json:3-9

y solo cubre opciones relacionadas con la compilación.

  • declaration: trueDesglose opción por opción de las clave:.d.ts。
  • emitDeclarationOnly: true:: hace que tsc genere para cada archivo fuente el correspondientesolo emite tipos, no JS
  • stripInternal: true. Rollup se encarga del JS; tsc aquí es puramente un extractor de tipos.@internal: toda declaración marcada con.d.tsse elimina deexport. Esta es la primera compuerta con la que Vue controla la superficie de la API pública: los detalles internos de implementación, incluso si son@internal, mientras estén marcados con
  • composite: falseno se filtrarán a los tipos publicados..tsbuildinfo: desactiva el modo de compilación incremental de las referencias de proyecto (project references). Vue aquí no necesita incrementalidad entre paquetes; desactivarlo evita el estado adicional que introduce

include. La lista

📎 tsconfig.build.json:10-23

delimita con precisión qué directorios participan en la producción:Nota: aquísolo se listan 12 directoriospackages/。packages-private/、packages/dts-test/、packages/sfc-playground/, no todo, etc. no están incluidos. Esto significa: los tipos de paquetes privados y paquetes de pruebaEntrar en los artefactos de publicación. Esto es un aislamiento físico: no por convención, sino por configuración.

〔Inferencia de diseño y compensaciones arquitectónicas〕

¿Por qué usar una lista blanca en lugar de una lista negra? Porque añadir subpaquetes en un monorepo es la norma. Si se usara unaexcludelista negra, al añadir un paquete privado y olvidar incluirlo en exclude, sus tipos se colarían silenciosamente en los artefactos de publicación. La lista blanca es lo contrario: los paquetes nuevos no participan en la compilación por defecto, deben añadirse explícitamente, lo que cumple con el principio de «valores predeterminados seguros».

Tras ejecutartsc -p tsconfig.build.json --noChecklos artefactos quedan entemp/packages/<pkg>/src/*.d.ts. Nota--noCheck: omite la verificación de tipos, solo hace emit. La verificación de tipos la realiza untsc --noEmitseparado, la fase de compilación no la repite, ahorrando tiempo.

Segunda fase: agregación con rollup.dts.config.js

La segunda fase está impulsada porrollup.dts.config.js. Su punto de entrada primero realiza una validación previa:

📎 rollup.dts.config.js:15-22

Sitemp/packagesno existe, significa que la primera fase no se ejecutó, el script directamenteprocess.exit(1)y sugiere ejecutar primerotsc. Este es elcontrato de ordendel pipeline: la fase de rollup depende fuertemente de los artefactos de la fase de tsc, ambos son indispensables.

Luego lee todos los directorios de subpaquetes y admite la variable de entornoTARGETSpara construir un subconjunto:

📎 rollup.dts.config.js:15-22

TARGETSEl mecanismo permite reconstruir solo los tipos de algunos paquetes, lo que reduce significativamente el ciclo de retroalimentación durante el desarrollo y la depuración.

El núcleo estargetPackages.map(...)generar una configuración de Rollup para cada paquete:

📎 rollup.dts.config.js:23-42

Interpretación campo por campo:

  • input: ./temp/packages/${pkg}/src/index.d.ts: la entrada es el archivo de tipos producido en la primera fase, no el código fuente.ts。
  • output.file: packages/${pkg}/dist/${pkg}.d.ts: los artefactos van al directoriodistde cada paquete, con el nombre de archivo igual al nombre del paquete (comovue.d.ts)。
  • format: 'es': los archivos de tipos usan uniformemente el formato ES module.
  • plugins: [dts(), patchTypes(pkg), ...(pkg === 'vue' ? [copyMts()] : [])]: tres plugins, los dos primeros se aplican a todos los paquetes,copyMtssolo se aplica al paquetevue.

onwarnEl hook

📎 rollup.dts.config.js:23-42

merece mención aparte:UNRESOLVED_IMPORTDurante el dts rollup, todas las importaciones con rutas no relativas se externalizan por defecto. Esto provoca que Rollup emita una advertencia. Pero esto escomportamiento esperadoimport { X } from 'some-pkg': lasreturnen los archivos de tipos deben permanecer como referencias externas, no deben incluirse en el empaquetado. Por eso el script para «importaciones no resueltas con rutas no relativas» directamentewarn。

suprime la advertencia, y solo deja pasar las importaciones no resueltas con rutas relativas al

predeterminado.!warning.exporter?.startsWith('.')〔Inferencia de diseño y compensaciones arquitectónicas〕.Aquí hay una sutileza:

determina si el exporter comienza con

mermaid
flowchart TD
    src["packages/*/src/*.ts<br/>源码类型"] --> tsc{"tsc -p tsconfig.build.json<br/>--noCheck"}
    tsc -->|"include 白名单命中"| temp["temp/packages/*/src/*.d.ts<br/>单包校样"]
    tsc -->|"不在 include 列表"| skip["不产出<br/>私有包/测试包被隔离"]
    temp --> check{"temp/packages 存在?"}
    check -->|"否"| exit["process.exit(1)<br/>提示先跑 tsc"]
    check -->|"是"| rollup["rollup-plugin-dts<br/>聚合为单文件"]
    rollup --> patch["patchTypes(pkg)<br/>内联导出 + 追加 types/"]
    patch --> vue{"pkg === 'vue'?"}
    vue -->|"是"| mts["copyMts()<br/>写 vue.d.mts"]
    vue -->|"否"| done["packages/pkg/dist/pkg.d.ts"]
    mts --> done

Panorama del pipelinetscCopiarrollupEste diagrama ancla el flujo de control de dos fases:checkla lista blanca depatchTypesdetermina quién puede entrar al pipeline,copyMtselvuede

determina si puede continuar,

es un paso obligatorio,

rollup-plugin-dtses la rama exclusiva del paquete.d.ts.export { A, B, C, ... }5.2 patchTypes: reescribir los artefactos agregados a una forma de nivel de publicacióndefineComponentModelo intuitivo

patchTypesDespués de fusionar docenas deen un solo archivo, la forma producida es «primero declarar un montón de tipos, finalmente exportar todo con un enorme». Esto no es amigable para la lectura humana, y para algunas cadenas de herramientas (como la llamada

de VitePress) también provoca el error «el tipo inferido no puede nombrarse sin una referencia».

patchTypeses esterenderChunkproceso de post-procesamiento de conformación

📎 rollup.dts.config.js:87-88

  • isExported: cambiar «exportación centralizada» por «exportación en línea in situ», y luego añadir mejoras de tipos específicas del paquete.Estructura de datos: dos Set y tres pasadasdevuelve un plugin de Rollup, la lógica central está en el hookexport { ... }. Mantiene dos conjuntos:
  • shouldRemoveExport: registra todos los nombres de tipos queya estaban exportadosoriginalmente (provenientes de la declaración

).

Step-by-Step Walkthrough

: registra todos los nombres de tipos que

📎 rollup.dts.config.js:90-100

necesitan eliminarse del gran bloque de exportaciónExportNamedDeclaration(porque ya se exportaron en línea).El flujo de procesamiento se divide en tres pasadas (pass 0 / pass 1 / pass 2), este es el típico patrón de «primero recopilar, luego reescribir, finalmente limpiar».Pass 0: recopilar todos los nombres de tipos ya exportados.export ... from '...'Recorre los nodos de nivel superior del AST, todoisExported。

que seaexporty

📎 rollup.dts.config.js:102-125

sin sourceVariableDeclaration、TSTypeAliasDeclaration、TSInterfaceDeclaration、TSDeclareFunction、TSEnumDeclaration、ClassDeclaration(es decir, que no sea una reexportación deprocessDeclaration。

processDeclaration), añade el local name de su specifier a

📎 rollup.dts.config.js:70-85

Pass 1: añadir in situ el prefijo

a los nodos de declaración.idRecorre los nodos de nivel superior, para las seis categorías de declaración

llama a la lógica de_:Tres pasos:1. Sin

devuelve directamente (como declaraciones anónimas).shouldRemoveExport2. Si el nombre comienza conisExportedse omite: esta es laprependLeftconvenciónexport : los tipos con prefijo de guion bajo son tipos auxiliares internos, no se exportan.

3. Añade el nombre aVariableDeclaration; si ese nombre está en

📎 rollup.dts.config.js:104-115

(es decir, ya estaba exportado), en la posición inicial de la declaracióndeclare constuna cadenadeclare const a, b.processDeclarationNota: la ramadeclarations[0]tiene una aserción adicional:Si undeclara múltiples declarators (como

), lanza un error directamente. Porque

📎 rollup.dts.config.js:127-171

solo procesaExportNamedDeclaration, múltiples declarators provocarían un procesamiento omitido. Aquí se elige

  • fallo rápidoshouldRemoveExporten lugar de error silencioso, es una manifestación de programación defensiva.exported === localPass 2: eliminar del gran bloque de exportación los tipos ya exportados en línea.export { Foo as Bar }Recorre
  • , para cada specifier:
  • Si su local name está enExportNamedDeclaration, y

(excluyendo el caso de renombrado de

📎 rollup.dts.config.js:172-183

code = s.toString()), entonces elimina ese specifier.packages/${pkg}/typesAl eliminar, usa MagicString para borrar con precisión: si hay más specifiers después, borra hasta el start del siguiente specifier; si es el último, borra hasta el end del anterior o su propio start.

〔Inferencia de diseño y compensaciones arquitectónicas〕

Estetypes/directorio esun punto de entrada de mejoras de tipos mantenido manualmente, utilizado para colocar aquellos tipos que no pueden generarse automáticamente desde el código fuente (como mejoras globales de JSX, declaraciones de tipos de macros). Se fusiona en el mismo archivo con los tipos generados automáticamente, pero con un origen claramente separado: los generados automáticamente arriba, las mejoras manuales abajo.

¿Por qué es obligatorio exportar en línea?

El comentario da la razón directa:

📎 rollup.dts.config.js:45-51

El texto original dice: cambiar todos los tipos a exportación en línea y eliminarlos del bloque de exportación grande, de lo contrario en la llamada de VitePressdefineComponentse reportará «the inferred type cannot be named without a reference».

〔Inferencia de diseño y compensaciones arquitectónicas〕

La esencia de este error es: cuando TypeScript genera tipos, si un tipo solo puede nombrarse mediante «referenciar la exportación de otro módulo», y esa referencia no es visible en el lado del consumidor, se reporta un error. El bloque de exportación centralizado separa el nombre del tipo de su ubicación de declaración, agravando este problema. La exportación en línea hace que cada tipo sea visible en su lugar de declaración, eliminando esta capa indirecta.

copyMts: proporciona tipos para el doble modo Node ESM/CJS

copyMtsEl plugin solo tiene efecto para el paquetevue:

📎 rollup.dts.config.js:196-204

En el hookwriteBundle, escribe el contenido devue.d.tstal cual envue.d.mts。

El comentario explica la razón:

📎 rollup.dts.config.js:188-192

Según la especificación de exports de TypeScript 4.7package.json, para proporcionar correctamente tipos tanto para Node ESM como para CJS,debe haber dos archivos de declaración independientes. Por eso, durante la compilación se copiavue.d.tscomovue.d.mts。

〔Inferencia de diseño y compensaciones arquitectónicas〕

¿Por qué copiar en lugar de regenerar? Porque la forma de los tipos de ESM y CJS es completamente idéntica, la diferencia solo está en la extensión del archivo y el mapeo depackage.jsonenexports. Copiar es la solución más económica, evitando ejecutar rollup una vez más.

5.3 dts-built-test: prueba de humo de tipos sobre el artefacto real

Modelo intuitivo

Las dos secciones anteriores garantizan que los artefactos de tipos puedan generarse y que su forma sea correcta. Pero «poder generarse» no equivale a «generarse correctamente». Si alguna pasada depatchTypestiene un bug y elimina por error alguna exportación, el artefacto aún puede generarse, pero el usuario alimportdescubrirá que faltan tipos.

dts-built-testesuna prueba de humo de tipos que se ejecuta sobre el artefacto de compilación real: no prueba los tipos del código fuente, sino que consume el paqueteimportya publicadovue, verificando que las formas de tipos clave no hayan sufrido regresiones.

Estructura de datos: una aserción de tipos minimizada

El núcleo de todo el paquete de prueba es un solo archivo:

📎 packages-private/dts-built-test/src/index.ts:3-6

Lectura línea por línea:

  • L1: importavuedesdedefineComponent. Nótese que aquí se importa elnombre del paquete, no una ruta relativa: consume el artefacto realpackages/vue/dist/vue.d.ts.
  • L3-6: define un componente_CustomPropsNotErased, con props vacías y setup vacío.
  • L8: comentario// #8376, apuntando a un issue concreto.
  • L9-12: exportaCustomPropsNotErased, con tipo_CustomPropsNotErasedy{ foo: string }como tipo de intersección.

Lo que verifica esta prueba es:defineComponentque el tipo de retorno de{ foo: string }, tras la intersección confoo, no borra la propiedad。

〔Inferencia de diseño y compensaciones arquitectónicas〕

Contexto inferido del issue #8376:defineComponentel tipo de retorno de

posiblemente pasa por algún tipo condicional o tipo mapeado, lo que provoca que las propiedades adicionales en el tipo de intersección sean «borradas». Esta prueba fija este comportamiento con una reproducción mínima; si hay regresión, fallará en la fase de verificación de tipos.

📎 packages-private/dts-built-test/package.json:1-11

Configuración del paquete: dependencia de workspace apuntando al artefacto real

  • private: trueCampos clave:
  • types: dist/index.d.ts: no se publica en npm.
  • dependencies: el punto de entrada de tipos apunta al artefacto de compilación.workspace:*Tres dependencias de@vue/shared、@vue/reactivity、vue。
en

〔Inferencia de diseño y compensaciones arquitectónicas〕@vue/shared¿Por qué depender de@vue/reactivityyvue? Porque los tipos detypespueden referenciar los tipos de estos dos paquetes. En modo workspace, pnpm enlaza simbólicamente estas dependencias a los paquetes locales, y el campodistde los paquetes locales apunta a los artefactos bajo su respectivo. Así toda la cadena de pruebas consumeartefactos de compilación

, no código fuente.

dts-built-testCómo se ejecuta la pruebasrc/index.tsen sí no tiene script de prueba; sutsces el caso de prueba. La forma de ejecutarlo es: en CI ejecutartscpara hacer verificación de tipos sobre el paquete. Si la forma de los tipos sufre regresión,

reporta error y CI falla.

〔Inferencia de diseño y compensaciones arquitectónicas〕Lo ingenioso de este diseño es que codifica el «contrato de tipos» comocódigo compilabletsc. No necesita una librería de aserciones adicional, no necesita runtime;

en sí mismo es el ejecutor de pruebas. Si los tipos son correctos, compila; si los tipos son incorrectos, falla la compilación.

División de trabajo con dts-testdts-built-testNótese que eldts-testde este capítulo y el

  • dts-built-testdel siguiente capítulo son dos cosas distintas:(este capítulo): consumeartefactos de compilación
  • dts-test, verifica la forma de tipos a nivel de publicación.(siguiente capítulo): consumetipos del código fuente
, verifica el contrato de la superficie de API.

〔Inferencia de diseño y compensaciones arquitectónicas〕patchTypes¿Por qué se necesitan dos capas? Porque los tipos del código fuente y los tipos del artefacto pueden ser inconsistentes.stripInternalLa reescritura de AST detypes/, la eliminación dedts-built-test, la adición del directorio

, todo puede introducir bugs a nivel de artefacto bajo la premisa de que los tipos del código fuente son correctos.

mermaid
sequenceDiagram
    participant CI as CI 脚本
    participant TSC as tsc (tsconfig.build.json)
    participant Rollup as rollup.dts.config.js
    participant Patch as patchTypes(pkg)
    participant Dist as packages/vue/dist
    participant BuiltTest as dts-built-test

    CI->>TSC: tsc -p tsconfig.build.json --noCheck
    TSC->>TSC: include 白名单过滤
    TSC-->>Rollup: temp/packages/*/src/*.d.ts
    Rollup->>Rollup: existsSync('temp/packages') 校验
    Rollup->>Rollup: rollup-plugin-dts 聚合
    Rollup->>Patch: renderChunk(code, chunk)
    Patch->>Patch: pass0 收集 isExported
    Patch->>Patch: pass1 prependLeft('export ')
    Patch->>Patch: pass2 移除大导出块 specifier
    Patch->>Patch: 追加 packages/vue/types/*
    Patch-->>Rollup: 改写后 code
    Rollup->>Dist: 写 vue.d.ts
    Rollup->>Dist: copyMts 写 vue.d.mts
    CI->>BuiltTest: tsc 类型检查
    BuiltTest->>Dist: import { defineComponent } from 'vue'
    Dist-->>BuiltTest: 类型形状
    BuiltTest-->>CI: 编译通过 / 报错

Secuencia completa del pipeline de tipospatchTypesCopiardts-built-testEste diagrama de secuencia ancla la colaboración entre módulos: CI impulsa las dos fases de tsc y Rollup,

las tres pasadas de

son el procesamiento central,

patchTypesconsume el artefacto al final para la verificación.code.replace(...)Reflexiones de diseño, recuperación de errores y trampas en producción

1. ¿Por qué usar MagicString en lugar de reemplazo de cadenas?Se usa MagicString en todo el proceso para reescritura precisa, en lugar destart/end. Hay dos razones:

2. Posición precisaMagicString puede generar mapas, permitiendo que los archivos de tipos reescritos sigan siendo rastreables hasta el código fuente. Aunque el uso de sourcemaps en archivos de tipos es limitado, mantener la consistencia es una buena práctica.

Fallo rápido vs tolerancia silenciosa

patchTypesse usa en múltiples lugaresassert:

📎 rollup.dts.config.js:74-74

📎 rollup.dts.config.js:107-108

📎 rollup.dts.config.js:147-148

Estas aserciones lanzan errores inmediatamente al encontrar formas de AST no esperadas. En contraste cononwarndonde se traga silenciosamenteUNRESOLVED_IMPORTlaEl ruido esperado se traga, las formas inesperadas fallan rápido. Esta es la postura correcta para un script de construcción: es preferible que la construcción falle a producir archivos de tipos con formas incorrectas.

Problemas en producción:_Convención de prefijo

processDeclarationOmitir_tipos que comienzan con:

📎 rollup.dts.config.js:76-78

Esto significa que cualquier tipo exportado en el código fuente que comience con_no será exportado en línea. Si un tipo debería ser público pero se omite porque su nombre comienza con_, los usuarios encontrarán errores de «el tipo no existe».

〔Inferencia de diseño y compensaciones arquitectónicas〕

El enfoque para investigar este tipo de problemas: primero verificar si el tipo aún está en el gran bloque de exportación en el artefactovue.d.ts, luego verificar si el nombre del tipo en el código fuente comienza con_. Esto es un acoplamiento implícito entre la convención de nombres y el comportamiento de la herramienta, propenso a errores.

Problemas en producción: aserción de múltiples declaradores

📎 rollup.dts.config.js:106-115

Si en algún.d.tsaparecedeclare const a, b, la construcción lanza un error directamente. Esto es raro en tipos escritos a mano, pero se activará si algún archivo de tipos generado por una herramienta usa esta forma. El mensaje de error imprimirá el fragmento de código problemático para facilitar la localización.

Resumen del capítulo

Este capítulo rastreó la canalización completa de los artefactos de tipos de Vue:

1. Primera etapa (tsc):tsconfig.build.jsonusaincludela lista blanca para delimitar con precisión el alcance de salida,emitDeclarationOnlysolo emite tipos,stripInternalelimina declaraciones internas. Los artefactos se ubican entemp/packages/。

2. Segunda etapa (rollup):rollup.dts.config.jsusarollup-plugin-dtspara agregar los tipos de cada paquete,patchTypesmediante tres pasadas de recorrido del AST reescribe las exportaciones centralizadas en exportaciones en línea, y añadetypes/las mejoras manuales del directorio.copyMtsparavueel paquete genera adicionalmente.d.mts。

3. Fase de verificación (dts-built-test): realiza pruebas de humo de tipos sobre los artefactos de construcción reales, usando código compilable para fijar las formas de tipos clave y prevenir la deriva de tipos.

Reflexiones y autoevaluación del capítulo

Q1: Si se cambiatsconfig.build.jsondeincludela lista blanca a["packages"](es decir, incluir todo el directorio packages), ¿qué sucedería? ¿En qué escenarios causaría contaminación de tipos publicados?

Análisis de referencia:

includeAl cambiar de 12 directorios precisos a["packages"], todos los subpaquetes (incluyendo todos lospackages-privatefuera depackages/*) participarán en la salida de tsc.📎 tsconfig.build.json:10-23

Cadena de consecuencias:

1. temp/packages/bajo.d.ts。

2. rollup.dts.config.jsaparecerán muchosreaddirSync('temp/packages')de paquetes adicionales📎 rollup.dts.config.js:15-22

3. targetPackagesdepackages/<pkg>/dist/<pkg>.d.ts。📎 rollup.dts.config.js:15-22

leerá estos paquetes adicionales.distpor defecto es igual a todos los paquetes, entonces se generará para cada paquetepackage.jsonEscenario de contaminación: si un paquete no debería publicarse (como un paquete de herramientas internas), su artefacto de tipos aparecerá bajoprivate: true. Si el

de ese paquete no tiene

Q2: patchTypes, el script de publicación podría publicarlo junto con todo a npm, causando filtración de tipos internos.processDeclarationEsto es precisamente el valor del diseño de lista blanca: los paquetes nuevos por defecto no participan, deben añadirse explícitamente, cumpliendo con valores predeterminados seguros._En el pass 1 dereturn,_para tipos que comienzan con_InternalTypedirectamente

. Si el tipo de una API pública casualmente comienza con:

processDeclaration(como_exportado accidentalmente), ¿qué fenómeno verían los usuarios? ¿Cómo investigarlo?shouldRemoveExportAnálisis de referenciaexport 。📎 rollup.dts.config.js:76-78

al encontrar

que comienza conexport。

retorna directamente, sin añadirlo ashouldRemoveExport, ni prepend

Consecuencias:1. Ese tipo no obtendráen línea

2. Tampoco será eliminado del gran bloque de exportación (porque no está enexport { _InternalType }).stripInternal3. Por lo tantotscaún está en el gran bloque de exportación

, teóricamente aún puede importarse.

Pero el problema es: elvue.d.tsen el gran bloque de exportaciónexportreferencia la ubicación de la declaración. Si esa declaración por alguna razón (como

) es eliminada, el bloque de exportación referenciará un nombre inexistente, causando_error.

Enfoque de investigación:

1. Verificar en el artefacto_si ese tipo no tiene

Q3: dts-built-testen la declaración, y además es referenciado en el gran bloque de exportación.src/index.ts2. Verificar si el nombre del tipo en el código fuente comienza contypeof _CustomPropsNotErased & { foo: string }.foo3. Si se confirma que es un problema de nombres, renombrar eliminando el prefijo de guion bajo.Omit<typeof _CustomPropsNotErased, never> & { foo: string }Esto expone el acoplamiento implícito entre la convención de nombres y el comportamiento de la herramienta:

el prefijo originalmente significa «interno», pero la herramienta lo interpreta como «no exportar», los dos significados no son completamente consistentes.:

Omit<T, never>deusa el tipo de intersecciónpara verificar

  • que no sea borrado. Si se cambia el tipo de intersección aT & { foo: string }, ¿la prueba aún podría capturar la regresión de #8376? ¿Por qué?fooAnálisis de referenciadefineComponentcrea un nuevo tipo mapeado, quefoorecalcula
  • Omittodas las propiedades de T. Si el bug de #8376 es «propiedades adicionales en el tipo de intersección son borradas», entonces:OmitEscritura originalT: intersección directa,{ foo: string }es parte del tipo de intersección, siOmitla lógica de procesamiento del tipo de retorno de

📎 packages-private/dts-built-test/src/index.ts:9-12

borra las propiedades adicionales en la intersección,se perderá.Escritura conOmit、Pick:

primero hace mapeo sobre

, luego intersecta con

.dts-built-testEl proceso de mapeo dedts-test, veamos cómo Vue utiliza pruebas de contrato de tipos para proteger la superficie de su API pública.

Los tres forman un ciclo cerrado de «generación → conformación → verificación», garantizando que los tipos del código fuente y los tipos publicados sean estrictamente consistentes. Sin embargo, que el paquete de tipos en sí sea correcto no equivale a que la forma de los tipos de la API pública esté bloqueada. En el próximo capítulo profundizaremos enpackages-private/dts-test, para ver cómo más de 20 archivos.test-d.tsutilizanexpectTypey otras herramientas para convertir «los tipos como contrato de API» en pruebas automatizadas regresivas.

Convierte cualquier código en un libro comprensible

¿Disfrutaste este capítulo? Convierte tu código privado en un libro

Arquitectura local-first con Tauri 2 + Rust. 100% offline y seguro, sin subir código. Lectura en panel dual con anclajes de commit inmutables.

⚡ Tauri 2 · Rust Core · 100% Privado y Offline · Probado en +1M líneas

CHAPTER 06

Capítulo 6: Pruebas de contrato de tipos: cómo dts-test protege la superficie de la API

Upstream: vuejs/core · Commit @4ab865a8 · Progreso: Capítulo 6 de 14

En el capítulo anterior rastreamos la cadena de generación de declaraciones de tipos y vimos cómo Vue garantiza, mediante configuración de compilación y pruebas de humo, que «los tipos del código fuente» y «los tipos publicados» sean estrictamente consistentes. Pero el contrato de tipos no se limita a «si la forma es correcta»; lo más crucial es «si la superficie de la API cumple con lo esperado»: qué tipos deben exportarse, cuáles no, y si las restricciones genéricas son precisas. Este capítulo entra enpackages-private/dts-test, para ver cómo Vue utiliza más de 20 archivos.test-d.tspara llevar «los tipos como contrato de API» a pruebas automatizadas regresivas.

Modelo cognitivo de las pruebas de contrato de tipos: convertir el «manual» en un «contrato ejecutable»

dts-testLos archivos del directorio tienen una característica contraintuitiva: casino producen ningún comportamiento en tiempo de ejecución. Al abrirdefineComponent.test-d.tsx, verás una gran cantidad de llamadasdefineComponent({...}), pero nunca se ejecutan realmente durante la ejecución de las pruebas; estos archivos solo son sometidos portsc/vue-tsca verificación de tipos,noEmit: truegarantizando que no se produzca ningún JS.

📎 packages-private/dts-test/tsconfig.test.json:1-11

Esta configuración es el «entorno de ejecución» de todo el sistema de contratos:noEmitdesactiva la emisión de artefactos,jsx: preservedeja que la sintaxis TSX sea analizada por el sistema de tipos,strictactiva todas las comprobaciones estrictas,moduleResolution: bundlercoincide con la semántica moderna de empaquetado,libe introduce simultáneamenteesnextydom。Sin este conjunto de configuración,.test-d.tsxel JSX dentro sería tratado como JSX en tiempo de ejecución, y las aserciones de tipos perderían sentido。

〔Inferencia de diseño y compensaciones arquitectónicas〕

Separar las pruebas de tipos en un subpaquetepackages-privateindependiente en lugar de meterlas enpackages/vuede__tests__tiene tres motivaciones: primero, las dependencias de las pruebas de tipos son los tipos de nivel de publicaciónvuede, no los módulos internos del código fuente; el aislamiento físico fuerza a pasar por la entrada pública; segundo,(vue/jsx、vuela verificación de tipos de las pruebas de tipos consume mucho más tiempo que las pruebas unitarias en tiempo de ejecución, y un directorio independiente facilita la programación separada en CI; tercero,.d.tslos archivos no serán ejecutados erróneamente por el recolector en tiempo de ejecución de Vitest.tscAnalogía cotidiana: una prueba unitaria normal es como «encender la máquina y ver si echa humo», mientras que una prueba de contrato de tipos es como «revisar cláusula por cláusula antes de firmar un contrato»: no se realiza la transacción real, solo se confirma que «la cantidad a pagar por la parte A» está escrita en «yuanes» y no en «dólares». Si las cláusulas del contrato están mal, no importa cuán bien funcione la máquina..test-d.tsxproporciona todas las herramientas para esta «verificación de contrato»:

Solo hay cuatro herramientas clave:

utils.d.tsafirma que el tipo de

📎 packages-private/dts-test/utils.d.ts:7-21

es exactamenteexpectType<T>(value: T)afirma quevaluees asignable aT;expectAssignable<T, T2 extends T>determina siT2es un tipo unión;T;IsUnion<T>determina siTesIsAny<T>. Nótese elTde L5any: registra el espacio de nombres global JSX, permitiendo queimport 'vue/jsx'dentro de TSX sea reconocido por el sistema de tipos como<MyComponent />La implementación deJSX.Element。

📎 packages-private/dts-test/utils.d.ts:7-21

IsUnionmerece un examen detallado:T extends any ? (U extends T ? false : true) : neverutiliza tipos condicionales distributivos; siTes un tipo unión, cada miembro se evalúa de forma independiente, y finalmenteextends falsedetermina si todas las ramas devuelvenfalse. Esto esuna prueba de existencia a nivel de tipos: se usa para bloquear contratos como «props.jjjdebe ser un tipo unión y no fusionarse en una única firma».

Walkthrough guiado por escenarios:defineComponentcadena completa de inferencia de tipos de props en

defineComponent.test-d.tsxtiene 2260 líneas y es el núcleo del sistema de contratos. Nos situamos en un escenario concreto:el usuario escribedefineComponent({ props: {...}, setup(props) {...} }), y el sistema de tipos de Vue necesita inferir a partir de la declaración en tiempo de ejecución depropsel tipo preciso del parámetrosetupdentro deprops. Esta cadena es la parte más compleja del sistema de tipos de Vue.Primer paso: construir el «tipo esperado» como base del contrato

El archivo de prueba primero define la interfaz

, fijando explícitamente por escrito el tipo que debería inferirse para cada forma de declaración de propsExpectedPropsEsta interfaz es la versión escrita de las «cláusulas del contrato». Nótese algunos tipos sutiles::

📎 packages-private/dts-test/defineComponent.test-d.tsx:21-53

(props opcionales cona?: number | undefined(tiene default, por lo que no es opcional),undefined)、aa: numberdeclarado explícitamente),aaa: number | null(PropType<number | null>pero el tipo contieneaaaa: number | undefined(required: true as const). Estas diferencias no están escritas al azar; cada una corresponde a una rama específica en la declaración deundefined.propsSegundo paso: «alimentar» con diversas formas de declaración a

Este objetodefineComponent

📎 packages-private/dts-test/defineComponent.test-d.tsx:57-158

es unapropsmatriz exhaustiva de formas de declaración, que cubre todas las maneras de escribir props en Vue:—— abreviatura de constructor, inferido como

  • a: Number—— tiene default, inferido como no opcionalnumber | undefined
  • aa: { type: Number as PropType<number | undefined>, default: 1 }evita quenumber
  • aaaa: { type: Number, required: true as const } —— as constse amplíe atrue, preservando el tipo literalbooleanhace que la propiedad no sea void
  • b: { type: String, required: true as true } —— required: true—— sin
  • bb: { default: 'hello' }, infiere el tipo solo a partir del defaulttype—— conversión de tipo explícita
  • cc: Array as PropType<string[]>—— sintaxis de array, inferido como
  • l: [Date]—— array de múltiples tipos, inferido comoDate | undefined
  • ll: [Date, Number]—— igual que el anteriorDate | number | undefined
  • lll: [String, Number]〔Inferencia de diseño y compensaciones arquitectónicas〕
La coexistencia de

required: true as const(L70) yrequired: true as true(L75) es un rastro de evolución histórica: al principio se usabaas true, luego se descubrió queas constes más general (puede bloquear simultáneamente otros literales dentro del objeto), pero la forma antigua se conserva para verificar compatibilidad hacia atrás. Este es el valor típico de las pruebas de contrato:bloquea simultáneamente «la nueva forma es usable» y «la forma antigua no regresa»。

Tercer paso: afirmar en las tres ubicacionessetup / render / this

Este es el diseño más ingenioso de las pruebas de contrato:el mismo tipo de props debe inferirse correctamente en tres ubicaciones de consumo diferentes。

📎 packages-private/dts-test/defineComponent.test-d.tsx:160-217

setup(props)se haceexpectType<ExpectedProps['x']>(props.x)para cada prop. Nótese el tratamiento especial de L168-170:

📎 packages-private/dts-test/defineComponent.test-d.tsx:168-170

// @ts-expect-error should included 'undefined'junto conexpectType<number>(props.aaaa)——escribir deliberadamente una aserción que genere un error, usando@ts-expect-errorpara tragarse el error. Esto verifica queprops.aaaael tipo deno es number(de lo contrario esta línea no daría error,@ts-expect-errorsino que fallaría porque «no hay error que tragar»). Esta es la técnica de «aserción inversa» en las pruebas de tipos.

📎 packages-private/dts-test/defineComponent.test-d.tsx:204-205

// @ts-expect-error props should be readonlyjunto conprops.a = 1——verifica que los props son de solo lectura ensetup. Si alguna refactorización hace accidentalmente que los props sean mutables, esta línea ya no dará error,@ts-expect-errory fallará.

render()Enthis.$propsse afirma a través de dos rutas:this.xy

📎 packages-private/dts-test/defineComponent.test-d.tsx:221-279

L252-276 verifica que «los props declarados también deben exponerse enthis», L278-279 verifica quethis.a = 1da error (los props enthistambién son de solo lectura). L281-287 verifica el desempaquetado del valor de retorno de setup:this.cesnumber(ref(1)desempaquetado),this.d.e.valueesstring(el ref anidado conserva.value)、this.f.gesGT(reactiveel tipo branded en

Cuarto paso: verificación de tipos en el lado del consumidor TSX

El último eslabón del contrato de tipos es «cómo el usuario utiliza este componente». En TSX, la verificación de props de<MyComponent />es una ruta de tipos independiente:

📎 packages-private/dts-test/defineComponent.test-d.tsx:296-322

Aquí se verifica que<MyComponent>acepta todos los props declarados, así comoclass/style/key/ref/ref_forestos atributos integrados. Luego viene laverificación inversa:

📎 packages-private/dts-test/defineComponent.test-d.tsx:337-345

// @ts-expect-error missing required propsverifica que falta un prop obligatorio y da error;wrong prop typesverifica que un tipo no coincidente da error; L342 verifica queggg="baz"da error (gggsolo acepta'foo' | 'bar')。

Toda la cadena puede resumirse con un diagrama de flujo de datos:

mermaid
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 阻断合并"]

La clave de este diagrama es:la misma declaración depropsdebe satisfacer simultáneamente las expectativas de tipo de tres posiciones de consumo. Cualquier desviación en la inferencia en alguno de ellos hará quetscdé error.

Fronteras y puertas traseras:__typeProps、__typeEmitsy contratos de tipos condicionales

defineComponentLa inferencia de tipos detiene una limitación fundamental:las declaraciones de props en tiempo de ejecución no pueden expresar «tipos condicionales»color='white'. Por ejemplo, la restricción «cuandoappearance,'outline'debe ser__typeProps» no puede escribirse con la sintaxis de objeto en tiempo de ejecución. Para esto, Vue proporciona

__typePropsy otras «puertas traseras de tipos».

📎 packages-private/dts-test/defineComponent.test-d.tsx:1803-1836

ConditionalProps: la cápsula de escape de tipos para props condicionalescolores un tipo unión: o bienappearanceycolor: 'white'son ambos opcionales, o bienappearance: 'outline'y

  • L1823-1824:<Comp color="white" />. Las pruebas verifican:color: 'white'da error——proporcionar
  • L1825-1826:<Comp color="white" appearance="normal" />por sí solo no satisface ninguna ramaappearanceda error——'outline'
  • L1827:<Comp color="white" appearance="outline" />debe ser
pasa

__typeProps〔Inferencia de diseño y compensaciones arquitectónicas〕

__typeEmitsLa motivación de diseño de

__typeEmitses «permitir que el sistema de tipos exprese restricciones que no pueden expresarse en tiempo de ejecución». No participa en el análisis de props en tiempo de ejecución, es una cobertura puramente a nivel de tipos. El costo es que el usuario debe mantener manualmente la coherencia entre los tipos y la declaración en tiempo de ejecución——por eso se llama «backdoor» y no API oficial.: equivalencia de las dos sintaxis de emits:

📎 packages-private/dts-test/defineComponent.test-d.tsx:1838-1885

admite dos sintaxis, y las pruebas{ change: [id: number], update: [value: string] }bloquean ambas simultáneamentethis.$props.onChange?.(123)Sintaxis de objetoonChange?.('123')usa tuplas con nombre para expresar parámetros. Las pruebas verifican que

📎 packages-private/dts-test/defineComponent.test-d.tsx:1887-1934

pasa y{ (e: 'change', id: number): void; (e: 'update', value: string): void }da error.Sintaxis de firma de llamadausa sobrecargas para expresarla.Los cuerpos de prueba de ambas sintaxis son casi idénticos línea por línea——esto es intencional: el contrato exige que ambas formas produzcan

un comportamiento de tipos completamente equivalente

.defineEmits〔Inferencia de diseño y compensaciones arquitectónicas〕

__typeRefs¿Por qué mantener dos sintaxis? La sintaxis de objeto se acerca más a la forma de escritura de__typeEl, y la sintaxis de firma de llamada se acerca más a los tipos de eventos tradicionales de TS. Vue necesita admitir ambas y garantizar un comportamiento consistente. La estructura de «espejo línea por línea» de las pruebas es la prueba de equivalencia más fuerte.

📎 packages-private/dts-test/defineComponent.test-d.tsx:1936-1952

__typeRefsyParent: referencias entre componentes y tipos de nodos anfitriones__typeRefs: { child: ComponentInstance<typeof Child> }permite que el componente padre conozca con precisión el tipo del ref del componente hijo.refs.child.$refs.foodeclaranumber。

📎 packages-private/dts-test/defineComponent.test-d.tsx:1963-1977

__typeEl, por lo quepuede inferirse comoElementes más sutil. El comentario de prueba en L1963-1977 señala la intención de diseño:TypeEllos nodos anfitriones de renderizadores personalizados (TUI, canvas, native) no son DOMElement, por lo queCustomElementno puede restringirse a$el. Las pruebas usan la interfaz

para verificar que

puede aceptar cualquier tipo de anfitrión.TypeEl〔Inferencia de diseño y compensaciones arquitectónicas〕Element,@vue/runtime-testEsta es la garantía a nivel de tipos de que Vue 3 admite renderizadores personalizados. Si$else restringiera rígidamente a

, los usuarios de renderizadores no DOM como

function syntax w/ runtime propsno podrían inferir correctamente el tipo de. Lo que las pruebas de contrato protegen aquí es la «independencia del renderizador».。

📎 packages-private/dts-test/defineComponent.test-d.tsx:1501-1545

Restricción mutuamente excluyente entre componentes genéricos y props en tiempo de ejecucióngenerics aren't supported with object runtime propsLa sección<Comp3<string>>fija una regla importante:

los componentes genéricos no pueden coexistir con props de objeto en tiempo de ejecución

El comentarioExtractPropTypesen L1501 es una declaración de contrato. L1525-1535 verifica que setup genérico + props de objeto da error; L1538-1539 verifica que

da error. En cambio, los props de tipo array sí permiten genéricos (L1464-1499).

@ts-expect-error〔Inferencia de diseño y compensaciones arquitectónicas〕

@ts-expect-errorLa causa raíz de esta restricción es el orden de inferencia de tipos: los props de objeto necesitan quedetermine primero el tipo, mientras que los genéricos solo pueden determinarse en el momento de la instanciación, y ambos entran en conflicto. Los props de tipo array no participan en la extracción de tipos, por lo que no hay conflicto. Las pruebas de contrato solidifican esta «limitación del sistema de tipos» como aserciones regresionables.@ts-expect-errorReflexiones de diseño, recuperación de errores y trampas en producciónLa espada de doble filo de

📎 packages-private/dts-test/defineComponent.test-d.tsx:1354-1362

es la herramienta central de las pruebas de contrato de tipos, pero tiene una trampa fatal:// @ts-expect-error missing propcuando el código debajo de ella ya no da error,<Comp msg={123} />ella misma da error. Esto parece una protección, pero en realidad exige que el autor de la prueba controle con precisión «dónde ocurre el error».Observa este fragmento:expectType<JSX.Element>(...)se coloca en@ts-expect-errorla líneaexpectTypeanterior a

, pero toda la expresión está envuelta en

. Si la posición de@ts-expect-errorse desplaza una línea, o si el error ocurre realmente en la llamada aen lugar de en el JSX, la prueba fallará.@ts-expect-error〔Inferencia de diseño y compensaciones arquitectónicas〕Punto de trampa en producción: cuando una actualización de la versión de TypeScript provoca un ajuste fino en la posición del error, una gran cantidad de

IsAnyyIsUnion: Prueba de existencia a nivel de tipos

📎 packages-private/dts-test/defineComponent.test-d.tsx:1991-1993

expectType<IsAny<typeof props.foo>>(false)verificaprops.foono esany. Esto escontrato inverso: no solo requiere que el tipo sea correcto, sino que también requiere que el tipo «no pueda degenerar enany」。anyes un agujero negro del sistema de tipos, cualquieranyhará que las aserciones posteriores pierdan sentido.

📎 packages-private/dts-test/defineComponent.test-d.tsx:195-196

expectType<IsUnion<typeof props.jjj>>(true)verificajjjes un tipo unión.jjjdeclarado como((arg1: string) => string) | ((arg1: string, arg2: string) => string), si el sistema de tipos lo fusiona en una única firma,IsUniondevolveráfalse, la prueba falla.

〔Inferencia de diseño y compensaciones arquitectónicas〕

Estas dos herramientas protegen la «precisión del tipo» y no la «corrección del tipo». Un tipo que degenera enanyo una unión que se fusiona, en la mayoría de escenarios de uso «parece funcionar», pero pierde las sugerencias del IDE y la verificación en tiempo de compilación. Las pruebas de contrato deben fijar esta precisión.

Contrato implícito del orden de declaración

📎 packages-private/dts-test/defineComponent.test-d.tsx:1784-1801

Este comentario es extremadamente 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。DefineComponenttiene 13 parámetros genéricos, el orden esContrato público——vue-tscel tipo de componente generado depende de este orden. La prueba usadeclare const MyButton: DefineComponent<...>para escribir explícitamente los 13 parámetros, fijando el orden.

〔Inferencia de diseño y compensaciones arquitectónicas〕

Este es el contrato más fácil de pasar por alto: el orden de los parámetros genéricos no es un «detalle de implementación», sino la «ABI del código generado». Cualquier PR que ajuste el orden hará que elvue-tscgenerado.d.tssea incompatible con el tipo en tiempo de ejecución. Las pruebas de contrato actúan aquí como «guardián de compatibilidad de ABI».

Contrato entre archivos:componentInstance.test-d.tsxcomplemento de

componentInstance.test-d.tsxsolo tiene 154 líneas, pero cubre todas las formas de entrada deComponentInstancetipos de utilidad:

📎 packages-private/dts-test/componentInstance.test-d.tsx:10-40

ComponentInstance<typeof CompSetup>extrae el tipo de instancia del resultado dedefineComponent;ComponentInstance<typeof CompFunctional>extrae de componentes funcionales;ComponentInstance<typeof CompFunction>extrae de funciones puras. Los tres deben inferir la clase baseComponentPublicInstance.

📎 packages-private/dts-test/componentInstance.test-d.tsx:71-116

Más extremo es el «objeto puro sindefineComponentenvoltorio»:CompObjectSetup、CompObjectData、CompObjectNoPropslas tres formas deben poder ser extraídas correctamente porComponentInstance. L113-114 es especialmente contraintuitivo:CompObjectNoPropsno tiene declaraciónprops, perocompObjectNoProps.testaún infiere comostring | undefined——esto es el respaldo que proporciona la clase baseComponentPublicInstance.

📎 packages-private/dts-test/componentInstance.test-d.tsx:143-147

La prueba#12751de L141 fija un límite:__typeEmitsel evento'update:visible'declarado debe exponerse en la instancia comocomp['onUpdate:visible'](clave de cadena con dos puntos), y el tipo de$propses{ 'onUpdate:visible'?: (value?: boolean) => any }. L152-153 verifica quecomp['$props']['$props']reporta error——previniendo la autorreferencia recursiva de tipos.

Resumen del capítulo

dts-testel directorio usa más de 20 archivos.test-d.tspara llevar «el tipo como contrato de API» a pruebas automatizadas de regresión. El mecanismo central tiene tres capas:

1. Capa de herramientas:expectType、expectAssignable、IsUnion、IsAnyproporciona primitivas de aserción de tipos,@ts-expect-errorproporciona capacidad de aserción inversa.

2. Capa de contrato:ExpectedPropsla interfaz fija explícitamente «qué tipo debería inferirse»,propsla matriz de declaraciones agota todas las formas de escritura, tres posiciones de consumo (setup/render/TSX) verificadas de forma cruzada.

3. Capa de puerta trasera:__typeProps、__typeEmits、__typeRefs、__typeElproporciona una vía de escape para restricciones de tipos que no pueden expresarse en tiempo de ejecución, a la vez que fija la equivalencia de las dos sintaxis de emits.

Reflexión y autoevaluación del capítulo

Q1: Si se eliminadefineComponent.test-d.tsxde L168-170@ts-expect-error, dejando soloexpectType<number>(props.aaaa), ¿qué sucedería? ¿Por qué esta prueba «fallaría silenciosamente»?

Análisis de referencia:

props.aaaadeclarado como{ type: Number as PropType<number | undefined>, required: true as const }, su tipo inferido esnumber | undefined(porquePropType<number | undefined>incluye explícitamenteundefined)。

expectType<number>(props.aaaa)requiere queprops.aaaasea exactamentenumber. Dado que el tipo real esnumber | undefined, esta líneapor sí misma reportaría error。@ts-expect-errorla función de

es «esperar que aquí se reporte error, y tragárselo».@ts-expect-errorSi se elimina, esta línea reportaría error directamente, la prueba falla——parece «más estricto». Pero el problema es:props.aaaasi alguna refactorización hace quenumberrealmente se convierta en@ts-expect-error(corrección de bug o cambio de comportamiento), esta línea ya no reporta error, y al eliminarla prueba pasaría

——en ese momento la prueba no puede distinguir entre «tipo correcto» y «tipo incorrecto pero que casualmente no reporta error».@ts-expect-errorConservarla forma de escritura esbloqueo bidireccionalnumber | undefined: tanto requiere que «el tipo actual sea@ts-expect-error» (tragando el error deexpectType<number>mediantenumber), como requiere que «el tipo no pueda sernumber,@ts-expect-error» (si se convierte enfallará porque no hay error que tragar). Esta es la técnica central de las pruebas de contrato de tipos——。

📎 packages-private/dts-test/defineComponent.test-d.tsx:168-170

Q2: __typePropsusar «error esperado» para fijar «el tipo debe contener cierto componente»ConditionalPropsla prueba de puerta trasera (L1803-1836) verifica las restricciones de tipos unión condicionales. Si se cambia{ color?: 'normal' | 'primary' | 'secondary' | 'white'; appearance?: 'normal' | 'outline' | 'text' }de tipo unión a__typeProps(es decir, aplanar todas las opciones), ¿cómo fallaría la prueba? ¿Qué restricción de diseño de

ilustra esto?:

Análisis de referenciacolorEl tipo aplanado permite cualquier combinación deappearanceycolor: 'white' + appearance: 'normal', incluyendo. Pero la prueba L1825-1826 requiere explícitamente que esta combinación:

code
// @ts-expect-error
;<Comp color="white" appearance="normal" />

Copiar@ts-expect-errorSi el tipo se aplana, esta línea ya no reporta error,<Comp color="white" />falla porque «no hay error que tragar». Al mismo tiempo,@ts-expect-errorde L1823-1824 también pasaría de «reportar error» a «pasar», haciendo que

falle igualmente.__typePropsEsto indica que la restricción de diseño dees:。__typePropsdebe conservar la semántica de «exclusión mutua de ramas» del tipo uniónPropsno es simplemente «cobertura de tipos», sino «expresar con el sistema de tipos restricciones condicionales que los props en tiempo de ejecución no pueden expresar». Si en la implementación se aplica aPrettifyalguna transformación de mapeo comoOmito

, podría romper la discriminabilidad de las ramas de la unión, invalidando la restricción.

〔Inferencia de diseño y compensaciones arquitectónicas〕__typePropsEsta es también la razón por la que los casos de prueba deCommonProps & ConditionalPropsusan la intersección más simple de

Q3: DefineComponent, en lugar de tipos mapeados más «elegantes»——cualquier transformación de tipos adicional podría ocultar bugs.VNodeProps & AllowedComponentProps & ComponentCustomPropsel orden de los 13 parámetros genéricos deReadonly<ExtractPropTypes<{}>>está fijado explícitamente por L1784-1801. Si alguna refactorización intercambia el 9.º parámetro (

) con el 10.º parámetro (:

DefineComponent), ¿qué elementos posteriores se verían afectados? ¿Por qué las pruebas de contrato deben fijar este orden?vue-tscAnálisis de referencia<script setup>el orden de los parámetros genéricos dedefineProps / defineEmits,vue-tsces la «ABI» al generar el tipo de componente. Cuando el usuario escribeCreateComponentPublicInstance<...>en, se genera un tiposimilar a L1999-2116, donde la

posición

1. vue-tscde los parámetros genéricos determina el significado de cada parámetro de tipo..d.tsSi se intercambian los parámetros 9.º y 10.º:DefineComponentelVNodeProps & AllowedComponentProps & ComponentCustomPropsgenerado rellenará los parámetros según el orden antiguo, peroReadonly<ExtractPropTypes<{}>>los interpretará según el orden nuevo——Los tipos de props del componente de usuario están todos desalineados。

2. L1786-1800 dedeclare const MyButton: DefineComponent<...>reportará un error directamente — porque{}yVNodeProps & ...no son compatibles.

3. L1999-2116 deErrorMessagetipo (simulavue-tscresultado generado) también reportará un error.

El valor de que las pruebas de contrato fijen el orden radica en:eleva el «orden de los parámetros genéricos» de «detalle de implementación» a «contrato público». Cualquier PR que ajuste el orden hará que L1786-1800 falle inmediatamente, bloqueando cambios incompatibles antes de su publicación.

📎 packages-private/dts-test/defineComponent.test-d.tsx:1784-1801

〔Inferencia de diseño y compensaciones arquitectónicas〕

Este es el valor más subestimado de las pruebas de contrato de tipos: no protegen «si los tipos son correctos», sino «la estabilidad de la interfaz del sistema de tipos». El orden de los parámetros genéricos,@ts-expect-errorla posición deIsAnyel valor de retorno de

, todos forman parte del «ABI de tipos».

Las pruebas de contrato de tipos resuelven «si la superficie de la API cumple con lo esperado». Pero los tipos son solo la mitad de la ingeniería de Vue — la otra mitad es «cómo el usuario verifica en tiempo real el comportamiento de estas APIs en el navegador». El siguiente capítulo entrará en SFC Playground, para ver cómo Vue empaqueta el compilador, el runtime y el sistema de tipos en un entorno de depuración en tiempo real dentro del navegador, permitiendo al usuario ver el producto compilado y el resultado en ejecución en el instante en que modifica el código.IsAny/IsUnionLas pruebas de contrato no solo protegen «si los tipos son correctos», sino también «si los tipos son precisos» (DefineComponent), «si el orden de los parámetros genéricos es estable» (__typeEl13 parámetros), «independencia del renderizador» (Elementno se restringe avue-tsc). Una vez que estas restricciones se rompen, las sugerencias del IDE del lado del usuario,packages-private/sfc-playgroundlos tipos generados derivarán. Y la estabilidad del contrato de tipos, en última instancia, debe servir a la experiencia de depuración diaria del desarrollador — el siguiente capítulo entraremos en

Convierte cualquier código en un libro comprensible

¿Disfrutaste este capítulo? Convierte tu código privado en un libro

Arquitectura local-first con Tauri 2 + Rust. 100% offline y seguro, sin subir código. Lectura en panel dual con anclajes de commit inmutables.

⚡ Tauri 2 · Rust Core · 100% Privado y Offline · Probado en +1M líneas

CHAPTER 07

Capítulo siguiente: Capítulo 7 →

Upstream: vuejs/core · Commit @4ab865a8 · Progreso: Capítulo 7 de 14

Estado de verificación: líneas FACT ancladas realmente.test-d.tsEn el capítulo anterior usamos más de 20packages-private/sfc-playgroundarchivos para clavar «los tipos como contrato de API» en CI. Pero el contrato de tipos solo responde «cómo se ve la superficie de la API», no puede responder «cómo se ve exactamente este SFC al compilarse» ni «si el resultado del renderizado es consistente en modo SSR». Para responder las dos últimas preguntas, el equipo de Vue necesita un sandbox que pueda ejecutar la canalización de compilación completa en el navegador — este espackages/. Tiene una diferencia esencial con los paquetes públicos bajopackage.json:"private": trueen"version": "0.0.0" 📎 packages-private/sfc-playground/package.json:2-4yvue, lo que significa que nunca se publica en npm, es solo una herramienta de depuración oficial. Entre sus dependenciasworkspace:* 📎 packages-private/sfc-playground/package.json:19apunta a

, es decir, al producto de compilación del código fuente local, no a la versión estable en npm — esto hace que el Playground sea naturalmente una «demostración viva del commit actual». Este capítulo se centra en tres preguntas: cómo se inicializa la entrada, cómo el Header impulsa el cambio de estado, y cómo se inyectan las constantes en tiempo de compilación.

I. El minimalismo de la entrada: el contrato de inicialización de main.ts y ReplStore

main.tsModelo intuitivowindowsolo tiene 9 líneas, como un «script de autocomprobación al arrancar»: antes de montar la aplicación Vue, primero se coloca en

una configuración global, diciéndole a Vue DevTools «qué app seleccionar por defecto». Sin este paso, DevTools al abrirse se enfrentaría a múltiples instancias de app (el propio Playground + el código ejecutándose en el REPL del usuario) y no podría enfocarse automáticamente, la experiencia de depuración degeneraría en cambio manual.

main.tsEstructura de datos y efectos secundarios globalescreateAppEl núcleo dewindowno es

📎 packages-private/sfc-playground/src/main.ts:4-7

ts
// @ts-expect-error Custom window property
window.VUE_DEVTOOLS_CONFIG = {
  defaultSelectedAppId: 'repl',
}

:

Copiar

1. @ts-expect-errorAquí hay dos detalles de ingeniería que merecen atención:@ts-ignore:window〔Inferencia de diseño y compensaciones arquitectónicas〕Window & typeof globalThisen lugar deVUE_DEVTOOLS_CONFIGel tipo estándar de@ts-expect-errorno tiene el campo@types/*. Usar@ts-expect-errorsignifica «sé que aquí dará error, y exijo que dé error» — si en el futuro algúnañade este campo,。

dará error inversamente por «no producir error», recordando así al autor eliminar ese comentario. Esto está en la misma línea que la idea de las pruebas de contrato de tipos del capítulo anterior:

2. defaultSelectedAppId: 'repl'usar el sistema de tipos para proteger la intención, no para ocultar problemas〔Inferencia de diseño y compensaciones arquitectónicas〕'repl'la convención de cadena de@vue/repl: este@vue/repldebe coincidir exactamente con el id usado al crear la app dentro de

. Es un contrato literal entre paquetes, sin ninguna protección de restricción de tipos — una vez que

cambie el id, la selección por defecto de DevTools del Playground fallará silenciosamente.

Step-by-Step: de HTML al montajeindex.htmlEl flujo de ejecución es extremadamente corto, pero cada paso tiene restricciones implícitas:<div id="app">1. El navegador cargamount('#app'), que contiene

(no proporcionado en este material, peromain.tsse puede inferir a la inversa).import App from './App.vue' 📎 packages-private/sfc-playground/src/main.ts:22. Resolución del grafo de módulos:@vitejs/plugin-vueen la parte superior de

dispara

3. la compilación SFC de:window.VUE_DEVTOOLS_CONFIG〔Inferencia de diseño y compensaciones arquitectónicas〕createApp(App).mount('#app') 📎 packages-private/sfc-playground/src/main.ts:9Orden clavecreateAppdebe escribirse antes de

4. mount('#app'). Porque el hook de DevTools se registra dentro deApp.vue, escribir la configuración después del mount no podrá afectar la primera selección.ReplStoredisparaApp.vueel setup de

mermaid
flowchart TD
    load["浏览器加载 index.html"] --> parse["解析 main.ts 模块图"]
    parse --> sfc["@vitejs/plugin-vue 编译 App.vue"]
    sfc --> setcfg["写入 window.VUE_DEVTOOLS_CONFIG"]
    setcfg --> check{"VUE_DEVTOOLS_CONFIG 已设置?"}
    check -->|是| mount["createApp(App).mount('#app')"]
    check -->|否| devtools["DevTools 无法默认选中 repl"]
    mount --> appsetup["App.vue setup 创建 ReplStore"]
    appsetup --> ready["Playground 就绪"]
    devtools --> mount

(en

main.ts, no incluido en este material).CopiarApp.vueReflexiones de diseño y trampasReplStore. El punto de entrada solo asume dos responsabilidades: «inyección de efectos secundarios globales + montaje». Ninguna lógica de negocio debería aparecer aquí. Esta es la decisión de compromiso de Playground como «herramienta de depuración» en lugar de «producto»: no necesita compatibilidad con SSR, no necesita múltiples puntos de entrada, no necesita carga diferida.

〔Inferencia de diseño y compensaciones arquitectónicas〕

Puntos problemáticos en producción:window.VUE_DEVTOOLS_CONFIGessingleton global. Si Playground se incrusta en otra página que también usa DevTools (como en un escenario de iframe), el último en escribir sobrescribirá al primero. Dado que Playground normalmente se despliega de forma independiente, este riesgo se acepta.

---

II. Header.vue: estado derivado con computed y flujo de datos unidireccional con emit

Modelo intuitivo

Header.vuees el «panel de control» de Playground: selección de versión, conmutación PROD/DEV, interruptor SSR, cambio de tema, compartir, descargar. Por sí mismono posee ningún estado de negocio, todo el estado proviene deprops.storey props booleanos, todos los cambios se reportan medianteemital componente padre. Sin esta restricción de «componente tonto + propagación de eventos», Header se convertiría en un foco de dispersión de estado, y los efectos secundarios del cambio de versión y del cambio de SSR no podrían gestionarse de forma centralizada.

Análisis de estructuras de datos y campos

La definición de props de Header es la clave para entender sus responsabilidades:

📎 packages-private/sfc-playground/src/Header.vue:13-19

ts
const props = defineProps<{
  store: ReplStore
  prod: boolean
  ssr: boolean
  autoSave: boolean
  theme: 'dark' | 'light'
}>()

Los cinco props se dividen en dos categorías:

  • store: ReplStore: la única referencia al contenedor de estado, proveniente de@vue/repl. Header lo usa para leerstore.loading、store.vueVersion、store.typescriptVersion, y escribe directamente enstore.vueVersion。
  • cuatro props booleanos/literales:prod、ssr、autoSave、theme. Sonestado controlado, Header solo lee, no escribe; los cambios debenemit。

la lista de emit correspondiente📎 packages-private/sfc-playground/src/Header.vue:20-28:

ts
const emit = defineEmits([
  'toggle-theme',
  'toggle-ssr',
  'toggle-prod',
  'toggle-autosave',
  'reload-page',
])

Nótese quetoggle-themeaunque es generado internamente portoggleDark(), peroemites usado directamente en la plantillatoggle-ssr/toggle-prod/toggle-autosavecomo$emit. Esta mezcla es un estilo común en Vue 3📎 packages-private/sfc-playground/src/Header.vue:102-118:<script setup>cuando se necesitan efectos secundarios se usa emit como función, para reenvío puro se usa la plantillaPaso a paso: visualización y cambio de versión$emit。

Contextualizando: el usuario abre Playground, Header necesita mostrar la versión actual de Vue.

Paso 1: computed deriva el texto a mostrar

Copiar

📎 packages-private/sfc-playground/src/Header.vue:30-37

ts
const vueVersion = computed(() => {
  if (store.loading) {
    return 'loading...'
  }
  return store.vueVersion || `@${__COMMIT__}`
})

estado →loading; el usuario seleccionó explícitamente una versión →'loading...'; de lo contrario →store.vueVersion(hash corto del commit actual).@${__COMMIT__}es una constante inyectada en tiempo de compilación, se detalla en la siguiente sección.__COMMIT__Paso 2: enlace bidireccional de VersionSelect

Copiar

📎 packages-private/sfc-playground/src/Header.vue:88-88

html
<VersionSelect
  :model-value="vueVersion"
  @update:model-value="setVueVersion"
  pkg="vue"
  label="Vue Version"
>

no se usó, sino que se descompone explícitamente env-model. La razón es que:model-value + @update:model-valuees computed (solo lectura), no puede enlazarse bidireccionalmente de forma directa; debe escribirse mediantevueVersionesta función settersetVueVersionCopiarstore.vueVersion:

📎 packages-private/sfc-playground/src/Header.vue:39-41

ts
async function setVueVersion(v: string) {
  store.vueVersion = v
}

function resetVueVersion() {
  store.vueVersion = null
}
se declara como

setVueVersionpero internamente no tieneasync—¿es legado histórico o intencional? Se especula que es para alinearse con la semántica de carga asíncrona deawait(cambiar de versión dispara carga remota), manteniendo la consistencia de la interfaz.VersionSelectPaso 3: comparación con la versión TypeScript

Copiar

📎 packages-private/sfc-playground/src/Header.vue:76-80

html
<VersionSelect
  v-model="store.typescriptVersion"
  pkg="typescript"
  label="TypeScript Version"
/>

, porquev-modeles una propiedad normal escribible, no necesita envoltura computed.store.typescriptVersionEl mismo componente usa dos formas de enlace en la misma plantilla, lo cual es una manifestación直观 de «controlado vs no controlado».Cambio de tema: combinación de efectos secundarios y emit

Copiar

📎 packages-private/sfc-playground/src/Header.vue:58-66

ts
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'))
}

Nótese que no modifica directamente—porque los props son de solo lectura, el componente padre solo actualizaráprops.themetras recibirtoggle-theme, lo que a su vez impulsa el textothemeen la plantilla:title〔Inferencia de diseño y compensaciones arquitectónicas〕📎 packages-private/sfc-playground/src/Header.vue:123。

Aquí hay un diseño sutil:

la manipulación de clases del DOM y el estado reactivo de Vue son dos rutas independientesmodifica directamente el DOM, mientras que el prop。document.documentElement.classList.toggle('dark')se actualiza a través de Vue. Si ambos no están sincronizados (por ejemplo, el componente padre rechaza la actualización), la UI presentará una inconsistencia de «clase ya cambiada pero texto del title sin cambiar». En la práctica el componente padre siempre acepta el emit, así que el problema no se manifiesta.themeLógica oculta: la rama metaKey de copyLink

Copiar

📎 packages-private/sfc-playground/src/Header.vue:47-56

ts
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.')
}

puerta trasera para desarrolladores: al mantener presionado Cmd sobrey hacer clic en el botón de compartir, se redirige aplay.vuejs.org(servidor dev local), llevando consigo el hash de la URL actual. El hash codifica el estado completo del REPL (código fuente, versión, opciones), por lo que la depuración local puede reproducir problemas en línea. El comentariolocalhost:5173marca explícitamente que esta es una funcionalidad oculta intencional.// hidden logic for going to local debug from play.vuejs.org 📎 packages-private/sfc-playground/src/Header.vue:47-56〔Inferencia de diseño y compensaciones arquitectónicas〕

se llama antes de la redirección, poniendo

resetVueVersion()enstore.vueVersion, asegurando que la depuración local use el commit actual en lugar de la versión seleccionada en línea.nullCopiar

mermaid
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)"]

〔Inferencia de diseño y compensaciones arquitectónicas〕

Trampa 1:

permisos y contexto de seguridad denavigator.clipboardno tiene try/catch。copyLink. En entornos sin HTTPS o cuando el usuario rechaza el permiso del portapapeles,📎 packages-private/sfc-playground/src/Header.vue:47-56será rechazado, provocando un Promise rejection no capturado. Playground se despliega sobre HTTPS, el riesgo se acepta, pero esta es una típica «trampa de entorno de producción».writeText〔Inferencia de diseño y compensaciones arquitectónicas〕

Trampa 2:

clave de localStorage hardcodeada entoggleDarkes un literal de cadena, sin extracción a constante. Si en el futuro se quiere cambiar la clave, habrá que hacer una búsqueda global.。'vue-sfc-playground-prefer-dark'Trampa 3:

comparación entrecurrentCommityvueVersion. En la plantilla。模板里 :class="{ active: vueVersion === \@${currentCommit}\ }" 📎 packages-private/sfc-playground/src/Header.vue:88-88Comparar mediante concatenación de cadenas. Si__COMMIT__la inyección falla (se convierte enundefined), aquí se convertirá en'@undefined', nunca coincidirá. La fiabilidad de la inyección de constantes en tiempo de compilación determina directamente la corrección de la UI—este es precisamente el tema de la siguiente sección.

---

Tres, inyección de constantes en tiempo de compilación: la doble responsabilidad de __COMMIT__ y copyVuePlugin

Modelo intuitivo

vite.config.tses el «taller de ensamblaje» del Playground: ejecuta en tiempo de compilacióngit rev-parsepara obtener el hash del commit, y mediantedefinelo convierte en la constante global__COMMIT__; al mismo tiempo, mediante un plugin personalizado copia los artefactos ESM del navegador bajopackages/vue/dist/al directorio de artefactos del Playground. Sin este paso, el Playground no podría cargar en el navegador «el runtime de Vue del commit actual»—solo podría depender de la versión estable de npm, perdiendo el significado de «demostración en vivo».

Estructuras de datos y constantes en tiempo de compilación

📎 packages-private/sfc-playground/vite.config.ts:7-9

ts
const commit = spawnSync('git', ['rev-parse', '--short=7', 'HEAD'])
  .stdout.toString()
  .trim()

spawnSyncejecuta síncronamente el comando git,--short=7toma el hash corto de 7 dígitos. La ejecución síncrona es intencional:el archivo de configuración necesita el valor decommitdurante la fase de carga del módulo, lo asíncrono alteraría el orden de resolución de la configuración de Vite.

📎 packages-private/sfc-playground/vite.config.ts:23-26

ts
define: {
  __COMMIT__: JSON.stringify(commit),
  __VUE_PROD_DEVTOOLS__: JSON.stringify(true),
},

definees el mecanismo dereemplazo de textode Vite: todo__COMMIT__en el código fuente será reemplazado por el resultado deJSON.stringify(commit)(es decir, un literal de cadena entre comillas).JSON.stringifyes necesario—si se escribiera directamentecommit, tras el reemplazo se convertiría en el identificador desnudoabc1234, siendo tratado como nombre de variable en lugar de cadena.

〔Inferencia de diseño y compensaciones arquitectónicas〕

__VUE_PROD_DEVTOOLS__: truees otra constante clave: permite que lacompilación de producciónde Vue también conserve el soporte de DevTools. Por defecto, la compilación de producción elimina el hook de DevTools para reducir el tamaño, pero el Playground necesita depurar el código del usuario, por lo que se fuerza su activación.

Paso a paso: el traslado de artefactos de copyVuePlugin

📎 packages-private/sfc-playground/vite.config.ts:32-63

ts
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álisis punto por punto de los aspectos clave:

1. generateBundleHook: se ejecuta después de que Rollup genere el bundle y antes de escribirlo en disco. En este momento se puedeemitFileinsertar archivos adicionales en los artefactos.

2. import.meta.dirname: versión ESM de__dirnameproporcionada por Node 20.11+. La ruta../../packagesasciende desdepackages-private/sfc-playground/hasta la raíz del repositorio, y luego entra enpackages/。

3. Verificación de existencia + error explícito: sivue.esm-browser.jsno existe, lanza un error con instrucciones de reparaciónRun "nr build vue -f esm-browser" first.. Esto es un ejemplo ejemplar deexperiencia de desarrollador—el mensaje de error te dice directamente cómo arreglarlo.

4. Cinco artefactos:vueversión completa/versión runtime × dev/prod, másserver-renderer. Estos cinco archivos son precisamente el conjunto de candidatos para el import dinámico del Playground en el navegador, correspondientes al cambio de versión y al interruptor SSR en el Header.

〔Inferencia de diseño y compensaciones arquitectónicas〕

¿Por qué estos cinco?La versión completa (con compilador) se usa para escenarios de «compilación en runtime»; la versión runtime para escenarios de «precompilación»; dev/prod corresponden al interruptor PROD/DEV del Header; server-renderer corresponde al interruptor SSR. Estos cinco archivos constituyen la «matriz de runtime de Vue» del Playground.

Flujo de datos completo del cambio de versión

Viendo en conjunto elsetVueVersiondel Header y los artefactos de copyVuePlugin:

mermaid
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["实时预览"]

Nótese el valor especial@${__COMMIT__}: corresponde a los artefactos locales copiados por copyVuePlugin, no a un CDN. Por eso el Playground debe copiar los artefactos de compilación de Vue para el navegador—la opción «This Commit» necesita archivos locales。

Reflexiones de diseño y trampas

〔Inferencia de diseño y compensaciones arquitectónicas〕

Trampa 1:spawnSyncmanejo de fallos de. Si el directorio actual no es un repositorio git (por ejemplo, extraído de un tarball),spawnSyncdevolverá un código de salida distinto de cero,stdoutestará vacío,commitse convertirá en cadena vacía. En ese momento__COMMIT__es reemplazado por"", en el Header@${currentCommit}se convierte en'@'. No hay manejo explícito de errores.

〔Inferencia de diseño y compensaciones arquitectónicas〕

Trampa 2:optimizeDeps.exclude: ['@vue/repl'] 📎 packages-private/sfc-playground/vite.config.ts:27-29. Vite por defecto preempaqueta las dependencias para acelerar el arranque en frío, pero@vue/replestá excluido. La razón es que@vue/replusa internamente import dinámico y workers, y el preempaquetado rompería estos mecanismos. Este es un problema común en el ecosistema Vite de «conflicto entre preempaquetado y carga dinámica».

〔Inferencia de diseño y compensaciones arquitectónicas〕

Trampa 3:script.fsconfiguración 📎 packages-private/sfc-playground/vite.config.ts:13-19。@vitejs/plugin-vuela opciónscript.fspermite que el bloque<script>de SFC lea archivos mediantefs. Aquí se pasanfs.existsSyncyfs.readFileSync, para soportar el análisis de sentenciasimporten SFC (por ejemplo,import x from './foo'necesita verificar si el archivo existe).Esta es la clave para que el Playground pueda simular la resolución completa de módulos en el navegador—inyecta la capacidad fs de Node en la fase de resolución del compilador.

---

Reflexión de diseño: compensaciones arquitectónicas del Playground

Viendo las tres subsecciones en conjunto, la arquitectura del Playground sigue un principio claro:separar el «estado» de los «efectos secundarios», separar el «tiempo de compilación» del «tiempo de ejecución»。

  • main.tssolo hace inyección de efectos secundarios globales, sin tocar el estado de negocio.
  • Header.vuees un componente puramente presentacional, el estado fluye hacia dentro mediante props y hacia fuera mediante emit.
  • vite.config.tssolidifica la información de tiempo de compilación «commit actual» como una constante, de solo lectura en tiempo de ejecución.
〔Inferencia de diseño y compensaciones arquitectónicas〕

Esta separación aporta un beneficio directo:el Playground puede incrustarse en cualquier aplicación Vue(por ejemplo, ejemplos incrustados en sitios de documentación), siempre que se proporcionenstorey cuatro props booleanos.

El costo esEstado disperso:storeEn@vue/repl, el estado booleano está en el componente padre, la clase DOM está endocument.documentElement, y hay otra copia en localStorage. Cuatro ubicaciones de estado necesitan sincronización manual, y cualquier desincronización causará inconsistencia en la UI.

〔Inferencia de diseño y compensaciones arquitectónicas〕

Otra compensación esrenunciar a la compatibilidad con SSR。main.tsacceder directamente awindow,Header.vuedetoggleDarkacceder directamente adocument. Playground es una aplicación puramente CSR, no necesita considerar renderizado del lado del servidor.

---

Resumen del capítulo

Este capítulo analizópackages-private/sfc-playgroundlos tres archivos principales:

1. main.ts: entrada de 9 líneas, el núcleo es el orden de inyección dewindow.VUE_DEVTOOLS_CONFIG— debe ser antes demount.

2. Header.vue: mediantecomputedderivarvueVersion, medianteemitreportar todos los cambios de estado.copyLinkla ramametaKeyes una puerta trasera oculta de depuración local.

3. vite.config.ts:spawnSyncobtener el hash del commit,defineinyectar__COMMIT__,copyVuePlugincopiar los cinco artefactos de navegador de Vue al directorio de artefactos de Playground.

El hilo conductor que atraviesa los tres esla frontera entre constantes de tiempo de compilación y estado de tiempo de ejecución:__COMMIT__es un hecho de tiempo de compilación de solo lectura,store.vueVersiones una elección de tiempo de ejecución mutable, elvueVersioncomputed del Header unifica ambos en una cadena de visualización.

Reflexión y autoevaluación del capítulo

Q1: Si se mueve la asignación demain.tsenwindow.VUE_DEVTOOLS_CONFIGa después decreateApp(App).mount('#app'), ¿qué sucedería? ¿Por qué?

Análisis de referencia:window.VUE_DEVTOOLS_CONFIGes la configuración que Vue DevTools lee al registrar el hook dentro decreateAppregistrará inmediatamente📎 packages-private/sfc-playground/src/main.ts:4-9。createApp, en este momento DevTools leerá__VUE_DEVTOOLS_GLOBAL_HOOK__para decidir qué app seleccionar por defecto. Si la asignación ocurre después dedefaultSelectedAppId, DevTools ya habrá completado la primera selección de app, la configuración no surtirá efecto, y el usuario necesitará cambiar manualmente a la appmounten DevTools. Lo más sutil es que: dado quereplinternamente también crea una app, una asignación tardía puede causar que DevTools seleccione por defecto el propio Playground en lugar del REPL del usuario, requiriendo cambio manual al depurar código del usuario. Esto refleja la importancia del «orden de inyección de efectos secundarios globales» en herramientas de depuración.@vue/replel

Q2: Header.vuedetoggleDark()opera simultáneamente sobre la clase DOM, localStorage y emit, pero no modifica directamenteprops.theme. Si el componente padre, tras recibir el eventotoggle-theme, rechaza actualizar el proptheme, ¿qué inconsistencia en la UI aparecería? ¿Cómo localizarlo desde el nivel del código fuente?

Análisis de referencia:toggleDark()en📎 packages-private/sfc-playground/src/Header.vue:58-66llama directamente adocument.documentElement.classList.toggle('dark'), esto cambiará inmediatamente la clasedarken el DOM, activando el cambio de variable CSS (ver la regla📎 packages-private/sfc-playground/src/Header.vue:186-186de.dark nav). Pero el texto:titleen la plantilla📎 packages-private/sfc-playground/src/Header.vue:123depende deprops.theme, si el componente padre no actualiza, el title permanecerá en el valor antiguo. Método de localización: verificar en DevTools del navegador si la clase de<html>y el atributo title del botón se contradicen. La causa raíz es que «efecto secundario del DOM» y «estado reactivo de Vue» siguen dos rutas independientes, sin una única fuente de datos.

Q3: copyVuePluginengenerateBundlerealizar una verificaciónfs.existsSyncpara cada archivo, lanzando un error con instrucciones de reparación cuando falta. Si se elimina esta verificación y se ejecuta directamentefs.readFileSync, ¿qué sucedería en un entorno CI (sin haber construido vue previamente)? ¿Cómo induciría a error el mensaje de error a los desarrolladores?

Análisis de referencia: tras eliminar la verificación,fs.readFileSynclanzaráENOENT: no such file or directory, open '.../packages/vue/dist/vue.esm-browser.js' 📎 packages-private/sfc-playground/vite.config.ts:32-63. Este error solo indica al desarrollador «el archivo no existe», pero no le dice «necesitas ejecutarnr build vue -f esm-browserprimero». En un entorno CI, el desarrollador podría malinterpretar como error de configuración de rutas, problema de permisos o submódulo git no inicializado, desperdiciando mucho tiempo en la investigación. Elthrow new Error(\${basename} not built. Run "nr build vue -f esm-browser" first.\)del código original vincula el «síntoma» con la «acción de reparación», siendo un detalle clave del diseño de experiencia del desarrollador. Esto también explica por qué el script de construcción de Playground debe tener un orden de dependencia claro con el script de construcción del núcleo de Vue.

---

El siguiente capítulo entrará enpackages-private/template-explorer, para ver cómo Vue visualiza los productos intermedios del compilador (AST, resultados de transformación, generación de código), permitiendo a los desarrolladores observar paso a paso cada transformación desde la plantilla hasta la función de renderizado. A diferencia de la «caja negra de extremo a extremo» de Playground, Template Explorer es una «sonda de caja blanca».

Hasta aquí, hemos visto claramente cómo SFC Playground traslada el pipeline de compilación al navegador: la inicialización de la entrada, el cambio de estado del Header y la inyección de constantes de tiempo de compilación constituyen conjuntamente un sandbox depurable en tiempo real. Pero la perspectiva de Playground siempre es «la compilación y ejecución del SFC completo», no responde directamente a «qué transformación hace exactamente el compilador sobre una expresión de plantilla». El siguiente capítulo entrará en Template Explorer, para ver cómo despliega línea por línea los resultados de compilación de@vue/compiler-domy@vue/compiler-ssr, usando SourceMapConsumer para establecer el mapeo entre código fuente y artefactos, convirtiendo así el comportamiento interno del compilador en una sonda observable y rastreable.

Convierte cualquier código en un libro comprensible

¿Disfrutaste este capítulo? Convierte tu código privado en un libro

Arquitectura local-first con Tauri 2 + Rust. 100% offline y seguro, sin subir código. Lectura en panel dual con anclajes de commit inmutables.

⚡ Tauri 2 · Rust Core · 100% Privado y Offline · Probado en +1M líneas

CHAPTER 08

Capítulo 8: Template Explorer: Sonda de visualización del comportamiento del compilador

Upstream: vuejs/core · Commit @4ab865a8 · Progreso: Capítulo 8 de 14

En el capítulo anterior vimos cómo SFC Playground encapsula toda la cadena de «entrada SFC → compilación en el navegador → vista previa en tiempo real» como una caja negra: el desarrollador ve el resultado final renderizado, pero no lo que el compilador hace en el medio. Cuando en la plantilla se escribe una directiva personalizada, o cuando se activa hoistStatic y de repente la salida incluye un montón de variables _hoisted_1, Playground no puede responder «por qué el compilador genera esto así». La propuesta de Template Explorer es exactamente la opuesta: expone por completo la salida de compilación de @vue/compiler-dom y @vue/compiler-ssr, el AST, las marcas de error y el mapeo de posiciones desde el código fuente hasta la salida. Su núcleo no es «ejecutar», sino «observar». Este capítulo se desarrolla en torno a tres archivos: index.ts se encarga de la invocación de compilación y del mapeo bidireccional de SourceMap, options.ts gestiona con reactive decenas de CompilerOptions e impulsa la UI, y theme.ts personaliza el tema del editor Monaco.

I. Invocación de compilación y mapeo bidireccional de SourceMap: index.ts

Modelo intuitivo

Template Explorer esindex.tscomo una «máquina de traducción bidireccional»: a la izquierda se introduce la plantilla, a la derecha se emite la función de renderizado. Pero tiene una capacidad más que una máquina de traducción: cuando colocas el cursor en una línea de la izquierda, la derecha resalta la salida correspondiente; a la inversa, si colocas el cursor en la derecha, la izquierda resalta la plantilla correspondiente. Sin el mapeo de SourceMap, esta herramienta degeneraría en dos cuadros de texto uno al lado del otro, y el desarrollador solo podría comparar a simple vista, sin poder establecer la cadena causal de «línea N de la plantilla → línea N de la salida».

Estructuras de datos y diseño de memoria

index.tsNo hay Structs complejos, pero sí varias variables de estado clave a nivel de módulo que determinan el comportamiento de toda la herramienta:

lastSuccessfulCodeylastSuccessfulMapson la caché del resultado de compilación📎 packages-private/template-explorer/src/index.ts:74-75. La primera es una cadena, la segunda esSourceMapConsumer | undefined. Nótese quelastSuccessfulMapinicialmente esundefined, y solo se asigna cuando la compilación tiene éxito ymapexiste📎 packages-private/template-explorer/src/index.ts:99-100. Este estadoundefinedes la condición de guarda para toda la lógica posterior de mapeo de cursor: si la compilación falla, la funcionalidad de mapeo se desactiva silenciosamente de forma automática, en lugar de lanzar una excepción.

PersistedStateLa interfaz define la forma del estado que se persiste en localStorage y en el hash de la URL📎 packages-private/template-explorer/src/index.ts:26-30:src(código fuente de la plantilla),ssr(si está en modo SSR),options(opciones del compilador). Aquí hay una decisión de diseño clave:optionsel tipo de es elCompilerOptionscompleto, pero al persistir en la práctica solo se guardan «las entradas diferentes de los valores por defecto»; esta lógica de recorte se realiza enreCompile.

sharedEditorOptionsson las opciones de construcción compartidas por ambos editores📎 packages-private/template-explorer/src/index.ts:26-30:fontSize: 14、scrollBeyondLastLine: false、renderWhitespace: 'selection'、minimap.enabled: false. Se desactiva el minimap porque la plantilla y la salida normalmente solo tienen unas pocas decenas de líneas, y el minimap ocupa espacio horizontal innecesariamente.

Step-by-Step Walkthrough

Escenario: el usuario abre la página, introduce<div>{{ msg }}</div>y luego mueve el cursor.

Primer paso: inicialización y restauración de estado. window.inites el punto de entrada global📎 packages-private/template-explorer/src/index.ts:41. Primero registra y activa el tema personalizado📎 packages-private/template-explorer/src/index.ts:44-45, y luego intenta restaurar el estado desde el hash de la URL o desde localStorage📎 packages-private/template-explorer/src/index.ts:49-56. Nótese el orden de decodificación aquí: primeroatoby luegoescape, despuésdecodeURIComponent. Si falla el análisis del hash, se hace fallback alocalStorage.getItem('state'), y luego fallback a{}. Si falla todo el JSON.parse, se vacía localStorage y se imprime una advertencia📎 packages-private/template-explorer/src/index.ts:57-64。

Tras restaurar el estado, hay un detalle que se pasa por alto fácilmente:delete persistedState.options?.nodeTransforms 📎 packages-private/template-explorer/src/index.ts:69. El comentario explica el motivo: las funciones no se pueden serializar, por lo que al persistirnodeTransformsse pierde, y al restaurar, si queda un objeto vacío residual, provocará un comportamiento anómalo del compilador. Esta es la trampa clásica de «persistir campos no serializables».

Segundo paso: núcleo de compilacióncompileCode。Este es el corazón de toda la herramienta📎 packages-private/template-explorer/src/index.ts:76-106. Primeroconsole.clear(), luego, segúnssrMode.value, eligessrCompileocompile 📎 packages-private/template-explorer/src/index.ts:80. NótesecompileFnlos parámetros de la llamada: expandecompilerOptions, fuerzafilename: 'ExampleTemplate.vue'、sourceMap: truee inyectaonErrorel callback para recopilar errores📎 packages-private/template-explorer/src/index.ts:82-89。

Aquí hay una decisión de diseño:filenameestá codificado como'ExampleTemplate.vue'. Este valor debe coincidir exactamente en la llamada posterior ageneratedPositionFor, de lo contrario la consulta de SourceMap devolverá un resultado vacío. Este es un contrato implícito: las dos cadenas deben ser iguales, pero ningún sistema de tipos lo garantiza.📎 packages-private/template-explorer/src/index.ts:189Una vez completada la compilación, los errores se convierten al formato de marker de Monaco y se establecen en el editor

convierte📎 packages-private/template-explorer/src/index.ts:91-95。formatErrordeCompilerErrorallocde Monaco. NótesestartLineNumber/startColumn/endLineNumber/endColumn 📎 packages-private/template-explorer/src/index.ts:108-119: solo se marcan los errores con información de posición; los errores sinerrors.filter(e => e.loc)(como errores de configuración global) solo se muestran en la consola.locTercer paso: establecimiento del SourceMap.

Tras el éxito de la compilación,, e inmediatamente después se llama alastSuccessfulMap = new SourceMapConsumer(map!) 📎 packages-private/template-explorer/src/index.ts:99es una API clave decomputeColumnSpans() 📎 packages-private/template-explorer/src/index.ts:100。computeColumnSpans: precalcula el intervalo de columnas de cada segmento de mapeo, de modo que el camposource-map-jsdevuelto porgeneratedPositionForesté disponible. Sin este paso, el mapeo inverso solo puede localizar la columna inicial y no puede resaltar todo el rango del token.lastColumnCuarto paso: mapeo bidireccional del cursor.

Cuando el usuario, en eleditor de código fuente, mueve el cursor, se dispara. Tras un debounce de 100 ms, el callback llama aeditor.onDidChangeCursorPosition 📎 packages-private/template-explorer/src/index.ts:184. NóteselastSuccessfulMap.generatedPositionFor({ source: 'ExampleTemplate.vue', line, column: column - 1 }) 📎 packages-private/template-explorer/src/index.ts:188-192: los números de columna de Monaco empiezan en 1, mientras que los de SourceMap empiezan en 0. Elcolumn - 1devuelto, si tieneposyline, crea un decorador en el editor de salida para resaltar el rango correspondientecolumn, y se desplaza a esa posición📎 packages-private/template-explorer/src/index.ts:194-206El mapeo inverso está en📎 packages-private/template-explorer/src/index.ts:207-210。

.output.onDidChangeCursorPositionLlama a📎 packages-private/template-explorer/src/index.ts:223, pero con una guarda adicional: ignoraoriginalPositionFor 📎 packages-private/template-explorer/src/index.ts:227-230pos.line === 1 && pos.column === 0de "mock location"📎 packages-private/template-explorer/src/index.ts:231-237. Este guard es muy crítico: cierto código generado por el compilador (comoimportsentencias o funciones helper) no tiene una posición de plantilla correspondiente, y SourceMap devolverá{ line: 1, column: 0 }como marcador de posición. Si no se ignora, colocar el cursor en estas líneas resaltará erróneamente la primera línea de la plantilla.

Quinto paso: persistencia del estado. reCompileno solo activa la compilación, sino que también se encarga de escribir el estado actual en localStorage y el hash de la URL📎 packages-private/template-explorer/src/index.ts:121-146. Al persistir hay una lógica de recorte: recorrercompilerOptions, guardar solo los elementos que "no son objetos y no son iguales al valor predeterminado"📎 packages-private/template-explorer/src/index.ts:125-133. Esto explica por québindingMetadataopciones de tipo objeto como esta no se persisten: es demasiado complejo y el valor predeterminado ya es suficiente para la demostración.

mermaid
flowchart TD
    init["window.init()"] --> restore{"hash 或 localStorage 有状态?"}
    restore -->|是| parse["JSON.parse 成功?"]
    restore -->|否| useDefault["使用默认模板"]
    parse -->|成功| delNodeTrans["delete nodeTransforms"]
    parse -->|失败| clearLS["localStorage.clear() + 警告"]
    delNodeTrans --> createEditor["monaco.editor.create(source)"]
    clearLS --> createEditor
    useDefault --> createEditor
    createEditor --> initOpt["initOptions()"]
    initOpt --> watch["watchEffect(reCompile)"]
    watch --> compileCode["compileCode(source)"]
    compileCode --> chooseFn{"ssrMode.value?"}
    chooseFn -->|true| ssr["ssrCompile(source, opts)"]
    chooseFn -->|false| dom["compile(source, opts)"]
    ssr --> hasMap{"map 存在?"}
    dom --> hasMap
    hasMap -->|是| newSMC["new SourceMapConsumer(map)"]
    hasMap -->|否| skipMap["lastSuccessfulMap 保持 undefined"]
    newSMC --> computeSpan["computeColumnSpans()"]
    computeSpan --> setOutput["output.setValue(code)"]
    skipMap --> setOutput
    compileCode -->|抛异常| catchErr["lastSuccessfulCode = ERROR 注释"]
    catchErr --> setOutput

Reflexiones de diseño y problemas en producción

Por qué usarsource-map-jsen lugar desource-map? source-mapes la biblioteca original de Mozilla, tiene gran tamaño y depende de WASM (versión nueva).source-map-jses una implementación pura en JS, de tamaño pequeño, adecuada para entornos de navegador. Template Explorer, como herramienta puramente frontend, elegirsource-map-jses razonable📎 packages-private/template-explorer/package.json:15。

Elección del retardo de debounce.El debounce del editor de código fuente es de 300 ms por defecto📎 packages-private/template-explorer/src/index.ts:271, mientras que el debounce del movimiento del cursor es de 100 ms📎 packages-private/template-explorer/src/index.ts:215. Esta diferencia es intencional: la compilación es una operación pesada, 300 ms evita activaciones frecuentes; el movimiento del cursor es una operación ligera, 100 ms garantiza sensación de respuesta. Pero 100 ms aún puede provocar parpadeo del resaltado al mover el cursor rápidamente; esto es un compromiso aceptable.

window.initMontaje global de. Atención:window.initywindow.monacoambos están montados en el global📎 packages-private/template-explorer/src/index.ts:19-23. Esto se debe a que el editor Monaco se carga de forma asíncrona mediante el CDNloader.js, y una vez completada la carga se llama awindow.init. Este patrón de "callback global" es el uso estándar de Monaco en entornos no modulares, pero choca con las formas de construcción ESM modernas.

---

Dos, panel de opciones impulsado por reactive: options.ts

Modelo intuitivo

options.tses como un "panel de consola": arriba hay una docena de interruptores y botones de opción, cada uno corresponde a un comportamiento del compilador. Al accionar cualquier interruptor, el producto de compilación de la derecha cambia de inmediato. Sin este módulo, los desarrolladores solo podrían modificar los parámetros de llamada decompileen el código fuente y recompilar, sin poder comparar en tiempo real los efectos de distintas opciones.

Estructura de datos y diseño de memoria

options.tsEl núcleo de

ssrModeson tres exportaciones:ref(false) 📎 packages-private/template-explorer/src/options.ts:5es uncompilerOptions. Es independiente decompile vs ssrCompile, porque el modo SSR cambia la propia función de compilación (

defaultOptions), no las opciones de compilación.CompilerOptionses un objeto completo de📎 packages-private/template-explorer/src/options.ts:5-27. Define los valores predeterminados de todas las opciones, incluidosmode: 'module'、prefixIdentifiers: false、hoistStatic: false、cacheHandlers: false、scopeId: null、inline: false、ssrCssVars: '{ color }'、compatConfig: { MODE: 3 }、whitespace: 'condense', así como unbindingMetadata 📎 packages-private/template-explorer/src/options.ts:18-26。

compilerOptionsque contiene 7 tipos de bindingreactive(Object.assign({}, defaultOptions)) 📎 packages-private/template-explorer/src/options.ts:29-31esObject.assign({}, ...). Atención: aquí se usareactive(defaultOptions)para hacer una copia superficial; si se hiciera directamentecompilerOptions, modificardefaultOptionscontaminaríareCompile, lo que invalidaría la lógica de "comparación con el valor predeterminado" en

Step-by-Step Walkthrough

Escenario: el usuario hace clic en la casilla de verificación "hoistStatic".

Primer paso: renderizado de la UI. AppElsetupdel componente📎 packages-private/template-explorer/src/options.ts:33-35devuelve una función de renderizadossrMode.value、compilerOptions.mode、compilerOptions.prefixIdentifiers. Esta función de renderizado lee estados reactivos como📎 packages-private/template-explorer/src/options.ts:36-39, por lo que cuando estos estados cambian, toda la UI se vuelve a renderizar.

Segundo paso: binding checked de la casilla de verificación. hoistStaticLa propiedadcheckedde la casilla de verificación escompilerOptions.hoistStatic && !isSSR 📎 packages-private/template-explorer/src/options.ts:150. Aquí hay una lógica: en modo SSR,hoistStaticse fuerza a mostrarse como no marcado, porque la compilación SSR no admite elevación estática. Al mismo tiempo,disabled: isSSR 📎 packages-private/template-explorer/src/options.ts:151garantiza que el usuario no pueda alternarlo en modo SSR.

Tercer paso: manejo de onChange.Cuando el usuario hace clic en la casilla de verificación,onChangeactiva📎 packages-private/template-explorer/src/options.ts:152-156, asignando directamentee.target.checkedacompilerOptions.hoistStatic. Dado quecompilerOptionsesreactivedewatchEffect(reCompile) 📎 packages-private/template-explorer/src/index.ts:266, esta asignación activa el seguimiento de dependencias y, a su vez, activa

, recompilando finalmente.Cuarto paso: interacción entre opciones.cacheHandlersAtención:checkedElusePrefix && compilerOptions.cacheHandlers && !isSSR 📎 packages-private/template-explorer/src/options.ts:166,disabledde!usePrefix || isSSR 📎 packages-private/template-explorer/src/options.ts:167escacheHandlersesprefixIdentifiers. Esto significa quemode === 'module'depende deprefixIdentifiersofunction. Esta relación de interacción se manifiesta en la UI como: cuandocacheHandlersno está activado y el modo es

scopeId, la casilla de verificacióndisabled: !isModule 📎 packages-private/template-explorer/src/options.ts:182,checked: isModule && compilerOptions.scopeId 📎 packages-private/template-explorer/src/options.ts:183está deshabilitada.isModuleLa interacción denull 📎 packages-private/template-explorer/src/options.ts:184-189。

es más compleja: initOptions. Solo en modo module se puede establecer scopeId, y en onChange, sicreateApp(App).mount(document.getElementById('header')!) 📎 packages-private/template-explorer/src/options.ts:232-234es false, se fuerza avueQuinto paso: montaje.createAppLlamar a@vue/runtime-dom. Atención: aquí se usaoptions.tsdel paquetevue, y no

mermaid
flowchart LR
    subgraph reactive_state["reactive 状态层"]
        ssrMode["ssrMode: Ref<boolean>"]
        compilerOptions["compilerOptions: reactive(CompilerOptions)"]
    end
    subgraph ui_layer["UI 渲染层 (options.ts)"]
        modeRadio["mode 单选"]
        wsRadio["whitespace 单选"]
        ssrCheck["SSR 复选框"]
        prefixCheck["prefixIdentifiers 复选框"]
        hoistCheck["hoistStatic 复选框"]
        cacheCheck["cacheHandlers 复选框"]
        scopeCheck["scopeId 复选框"]
        inlineCheck["inline 复选框"]
        compatCheck["compatConfig 复选框"]
    end
    subgraph compile_layer["编译层 (index.ts)"]
        watchEffect["watchEffect(reCompile)"]
        compileCode["compileCode()"]
    end
    ssrMode -->|"checked/disabled"| ssrCheck
    ssrMode -->|"isSSR 守卫"| hoistCheck
    ssrMode -->|"isSSR 守卫"| cacheCheck
    compilerOptions -->|"mode"| modeRadio
    compilerOptions -->|"whitespace"| wsRadio
    compilerOptions -->|"prefixIdentifiers"| prefixCheck
    compilerOptions -->|"hoistStatic"| hoistCheck
    compilerOptions -->|"cacheHandlers"| cacheCheck
    compilerOptions -->|"scopeId"| scopeCheck
    compilerOptions -->|"inline"| inlineCheck
    compilerOptions -->|"compatConfig.MODE"| compatCheck
    modeRadio -->|"onChange 赋值"| compilerOptions
    wsRadio -->|"onChange 赋值"| compilerOptions
    ssrCheck -->|"onChange 赋值"| ssrMode
    prefixCheck -->|"onChange 赋值"| compilerOptions
    hoistCheck -->|"onChange 赋值"| compilerOptions
    cacheCheck -->|"onChange 赋值"| compilerOptions
    scopeCheck -->|"onChange 赋值"| compilerOptions
    inlineCheck -->|"onChange 赋值"| compilerOptions
    compatCheck -->|"onChange 赋值"| compilerOptions
    compilerOptions -->|"依赖追踪"| watchEffect
    ssrMode -->|"依赖追踪"| watchEffect
    watchEffect --> compileCode

es código de capa de aplicación y puede depender directamente del paquete completo

CopiarreactiveReflexiones de diseño y problemas en producciónref? compilerOptionsPor qué usarreactiveen lugar decompilerOptions.hoistStatic = truees un objeto que contiene una docena de campos; usarcompilerOptions.value.hoistStatic = truepermitereactivedirectamente, sin necesidad decompilerOptions.xxx. Esto es más conciso en el código de UI. Pero el costo de

bindingMetadataes que la desestructuración pierde reactividad; en el código fuente no hay ninguna desestructuración, todo se accede mediante, que es el uso correcto.📎 packages-private/template-explorer/src/options.ts:18-26Diseño de valores predeterminados deSETUP_CONST、SETUP_REF、SETUP_LET、SETUP_MAYBE_REF、PROPS. El valor predeterminado deprefixIdentifiersincluye 7 bindings$setup, cubriendoprefixIdentifierscinco tipos. Esto es para que los desarrolladores, al abrir

compatConfig, puedan ver de inmediato el impacto de distintos tipos de binding en la forma de acceso a compilerOptions.compatConfig!.MODE = 2 📎 packages-private/template-explorer/src/options.ts:216-220en el producto. Sin este valor predeterminado,reactiveel efecto dereactivesería muy monótono.compatConfigReactividad anidada deCompatConfig | undefined. Una asignación anidada como!es reactiva bajocompatConfig, porque

ssrModeaplica proxy recursivamente a objetos anidados. Pero atención: el tipo decompilerOptionses ssrMode, por lo que se usó la aserciónref,compilerOptions. Si no hubierareactiveen el valor predeterminado, aquí habría un fallo en tiempo de ejecución.ssrSeparación de responsabilidades entrecompilerOptionsyssr.CompilerOptionses

---

es

. Por qué no poner

theme.tsEs como «cambiarle la piel» al editor: define el color y el estilo de fuente de cada token de sintaxis. Sin este módulo, Monaco usaría el temavs-darkpredeterminado; aunque funcional, las etiquetas HTML, expresiones y directivas en las plantillas Vue carecerían de distinción visual, dificultando que el desarrollador localice rápidamente las partes clave.

Estructura de datos y diseño de memoria

theme.tsExporta un objeto que cumple con la interfaz de MonacoIStandaloneThemeData.📎 packages-private/template-explorer/src/theme.ts:1-244Tiene tres campos de nivel superior:

base: 'vs-dark'Especifica el tema base📎 packages-private/template-explorer/src/theme.ts:2,inherit: trueIndica las reglas que heredan del tema base📎 packages-private/template-explorer/src/theme.ts:3. Esto significa que solo es necesario definir las diferencias; los tokens no definidos harán fallback avs-dark。

rulesEs un array donde cada elemento contienetoken(el nombre del token en Monaco) yforeground/background/fontStyle 📎 packages-private/template-explorer/src/theme.ts:4-235. Este array tiene más de 50 entradas, cubriendo tipos de token como number, comment, keyword, string, variable, entity.name.tag, etc.

colorsDefine los colores de la interfaz del 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

Escenario: registrar el tema al cargar la página.

Primer paso: definir el tema. monaco.editor.defineTheme('my-theme', theme) 📎 packages-private/template-explorer/src/index.ts:44. Esta llamada registra el objeto exportado detheme.tsen el registro de temas de Monaco, con el nombre de clave'my-theme'。

Segundo paso: activar el tema. monaco.editor.setTheme('my-theme') 📎 packages-private/template-explorer/src/index.ts:45. Esta línea de código debe llamarse después dedefineTheme; de lo contrario, lanzará el error «tema no definido».

Tercer paso: coincidencia de tokens.Cuando Monaco renderiza el código de la plantilla, tokeniza el código usando el servicio de lenguaje HTML y luego busca por nombre de token las reglas enrules. Por ejemplo,<div>endivse marcará comoentity.name.tag, coincidirá conforeground: 'cc6666' 📎 packages-private/template-explorer/src/theme.ts:41-44y se mostrará en rojo.

Reflexiones de diseño y errores en producción

Por qué usarinherit: true?Si no se hereda, habría que definir los colores de todos los tokens, incluidos aquellos que no aparecen en la plantilla (comomarkup.heading、meta.diff). La herencia permite que el archivo de tema solo se enfoque en los tokens que realmente aparecen en la plantilla y en el producto JS.

Coincidencia jerárquica de nombres de token.La coincidencia de tokens de Monaco es por prefijo:entity.name.tagcoincidirá conentity.name.tag.html、entity.name.tag.css, etc. En el código fuente se definen tantoentity.name.tag 📎 packages-private/template-explorer/src/theme.ts:41-44comoentity.name.tag.css 📎 packages-private/template-explorer/src/theme.ts:169-172; este último sobrescribe al primero en el escenario específico de CSS.

colorsyrulesdivisión de responsabilidades. rulescontrola el color del texto del código,colorscontrola el color de la interfaz del editor (fondo, cursor, línea seleccionada). Ambos son independientes, pero necesitan coordinación visual. En el código fuente,editor.background: '#1D1F21'ybase: 'vs-dark'tienen fondos predeterminados similares, para mantener la consistencia visual.

---

Reflexión de diseño: compensaciones de ingeniería de la sonda visual

La diferencia central entre Template Explorer y SFC Playground radica en la «granularidad de observación». Playground observa «si todo el SFC compilado puede ejecutarse»; Template Explorer observa «en qué se compila una expresión de plantilla individual». Esta diferencia determina la elección técnica de ambas herramientas:

La introducción de SourceMapConsumer es inevitable.Sin él, el desarrollador solo podría comparar a ojo el código fuente y el producto, sin poder establecer una correspondencia precisa de «línea X → línea Y». Pero la API de SourceMapConsumer es asíncrona (las versiones nuevas devuelven una Promise); en el código fuente se usa la versión síncronasource-map-js, para simplificar la lógica de llamada.

reactiveLa gestión de opciones es la elección natural del ecosistema Vue.Si se usaran eventos DOM nativos para gestionar manualmente la sincronización de estado de una docena de opciones, la cantidad de código se duplicaría.reactiveEl seguimiento de dependencias dewatchEffect(reCompile)automatiza la cadena «cambio de opción → recompilación», y una sola línea de código completa la suscripción.

El modo de carga global de Monaco es una carga histórica. window.monacoywindow.initEl montaje global de

---

Resumen del capítulo

Template Explorer es una «sonda de caja blanca»: no ejecuta el producto compilado, solo muestra el proceso de compilación.index.tsMediantecompileCodellama a@vue/compiler-domo@vue/compiler-ssr, usaSourceMapConsumerpara establecer un mapeo bidireccional entre el código fuente y el producto, y mediante la API de decoradores de Monaco implementa el resaltado vinculado al cursor.options.tsUsareactivepara gestionarCompilerOptions, mediantewatchEffectimpulsa la recompilación; las relaciones de vinculación entre opciones (como SSR deshabilitandohoistStatic) se codifican explícitamente en la capa de UI.theme.tsPersonaliza el tema de Monaco para que los tokens de sintaxis de la plantilla y el producto tengan una distinción visual clara.

El valor central de esta herramienta radica en «usar la herramienta para inferir el comportamiento del compilador»: cuando no estés seguro de quéhoistStaticle hace a una plantilla, abre Template Explorer, cambia opciones y observa los cambios en el producto. Esto es más intuitivo que leer el código fuente del compilador y más confiable que adivinar.

Reflexiones y autoevaluación de este capítulo

Q1: Si se elimina el guard de mock location (index.ts) deoriginalPositionForenpos.line === 1 && pos.column === 0, ¿en qué escenario provocaría un resaltado incorrecto? ¿Por qué el compilador genera un mapeo como{ line: 1, column: 0 }?

Análisis de referencia: el guard se encuentra en📎 packages-private/template-explorer/src/index.ts:231-237. El compilador, al generar el producto, inserta código que no tiene una posición correspondiente en la plantilla, como declaraciones de importación de helpersimport { createElementVNode as _createElementVNode } from 'vue', o firmas de función comoexport function render(_ctx, _cache) { ... }. Este código no tiene posición original en el SourceMap,source-map-jsdevolverá{ line: 1, column: 0 }como marcador de posición. Si se elimina el guard, cuando el usuario coloque el cursor sobre estas líneas,originalPositionFordevolverá{ line: 1, column: 0 }, el código considerará que esta es una posición válida y, por lo tanto, creará un decorador de resaltado en la primera fila y primera columna del editor de código fuente. El resultado es: el usuario hace clic en la líneaimportdel artefacto, la primera línea del editor de código fuente se resalta erróneamente, lo que genera confusión. La esencia de esta guarda es "distinguir entre mapeo real y mapeo de marcador de posición", y{ line: 1, column: 0 }essource-map-jsel valor centinela de "sin mapeo" acordado.

Q2: reCompileen las opciones de persistencia, la condicióntypeof val !== 'object' && val !== defaultOptions[key]omitirá todas las opciones de tipo objeto. SibindingMetadataes modificado por el usuario (por ejemplo, a través de la consola), esta modificación se perderá después de actualizar la página. ¿Es un bug o un diseño intencional? Si se quiere admitirbindingMetadataen la persistencia, ¿qué problemas hay que resolver?

Análisis de referencia: la condición se encuentra en📎 packages-private/template-explorer/src/index.ts:129. Es un diseño intencional, por tres razones: primera,bindingMetadatael valor de esBindingTypesun enum, tras la serialización es un número y, al deserializar, no se puede distinguir entre "el usuario lo estableció explícitamente en 0" y "el valor predeterminado"; segunda,compatConfiges un objeto anidado,val !== defaultOptions[key]compara referencias, siempre es true, lo que provocaría que todas las opciones de objeto se persistieran; tercera,nodeTransformscontiene funciones, no se puede serializar, y en el código fuente ya se manejadelete persistedState.options?.nodeTransformsmediante📎 packages-private/template-explorer/src/index.ts:69. Si se quiere admitirbindingMetadata, es necesario implementar una comparación profunda (en lugar de comparación por referencia) y también manejar la serialización/deserialización de valores enum. El problema más fundamental es:bindingMetadatano tiene entrada de edición en la UI, el usuario solo puede modificarlo a través de la consola, y ese tipo de modificación en sí misma no debería persistirse.

Q3: options.tsencompilerOptionsse crea conreactive(Object.assign({}, defaultOptions)). Si se cambiaObject.assign({}, defaultOptions)porreactive(defaultOptions)directamente, después de que el usuario cambie la opción y actualice la página, ¿qué ocurrirá? ¿Por qué?

Análisis de referencia:Object.assign({}, defaultOptions)es una copia superficial, ubicada en📎 packages-private/template-explorer/src/options.ts:29-31. Si se cambia areactive(defaultOptions),compilerOptionsydefaultOptionsapuntarán al mismo objeto. Cuando el usuario cambiahoistStatica true,compilerOptions.hoistStaticse vuelve true y, al mismo tiempo,defaultOptions.hoistStatictambién se vuelve true. Luego, la lógica de persistenciareCompileen📎 packages-private/template-explorer/src/index.ts:129compararával !== defaultOptions[key]; en ese momento,valydefaultOptions[key]son ambos true, la condición es false y esa opción no se guardará en localStorage. Después de actualizar la página,defaultOptionsse reinicializa ahoistStatic: false, y la modificación del usuario se pierde. Más grave aún: una vez quedefaultOptionsqueda contaminado, toda la lógica posterior de "comparar con el valor predeterminado" deja de funcionar, lo que provoca que la funcionalidad de persistencia se rompa por completo. La sutileza de este bug radica en que: dentro de una sola sesión todo funciona normal, y solo después de actualizar se puede descubrir.

---

El siguiente capítulo entrará enscripts/release.js, para ver cómo Vue orquesta con una máquina de estados interactiva todo el flujo de actualización de número de versión, build, test, commit de Git, creación de tag y npm publish. A diferencia de la "observación" de Template Explorer, release.js es "ejecución": necesita mantener estado entre múltiples pasos, manejar rollback ante fallos y equilibrar la confirmación interactiva con la automatización.

A través de Template Explorer, hemos aprendido cómo convertir el estado interno del compilador —AST, artefactos de compilación, SourceMap— en sondas visuales interactivas, transformando "por qué el compilador genera esto" de conjetura en observación. Este control y orquestación precisos del estado interno también se reflejan en el flujo de publicación de Vue: el siguiente capítulo profundizará en scripts/release.js, para ver cómo una máquina de estados de más de 500 líneas usa parseArgs para analizar más de diez flags, confirma interactivamente el número de versión mediante enquirer y dispara en orden build, test, commit de Git, creación de tag y npm publish, revelando el flujo de estados completo y la estrategia de rollback ante fallos detrás de una publicación formal.

Convierte cualquier código en un libro comprensible

¿Disfrutaste este capítulo? Convierte tu código privado en un libro

Arquitectura local-first con Tauri 2 + Rust. 100% offline y seguro, sin subir código. Lectura en panel dual con anclajes de commit inmutables.

⚡ Tauri 2 · Rust Core · 100% Privado y Offline · Probado en +1M líneas

CHAPTER 09

Capítulo 9: Automatización de publicación: la máquina de estados y la orquestación interactiva de release.js

Upstream: vuejs/core · Commit @4ab865a8 · Progreso: Capítulo 9 de 14

En el capítulo anterior, con ayuda de template-explorer inferimos el comportamiento del compilador y dominamos la metodología de observar mecanismos internos con herramientas. Ahora, pasamos la mirada del tiempo de compilación al tiempo de publicación: este es el momento más peligroso de todo proyecto de código abierto, ya que toca simultáneamente cuatro sistemas externos irreversibles: número de versión, artefactos de compilación, historial de Git y npm registry. Un npm publish erróneo no se puede retirar, y un push de tag erróneo contamina la resolución de dependencias de todos los usuarios downstream. Vue core usa un scripts/release.js de 537 líneas para domar este peligro: no es ni un script puramente automatizado ni una checklist puramente manual, sino una máquina de estados interactiva: se detiene a preguntar a la persona en los nodos clave, ejecuta de forma totalmente automática en los nodos predecibles y revierte el número de versión al punto inicial si falla cualquier paso. Este capítulo desglosará los tres mecanismos centrales de este orquestador: análisis de parámetros e inicialización de estado, decisión interactiva de versión y puerta de CI, y orden de publicación y rollback ante fallos.

Análisis de parámetros e inicialización del estado global

Modelo intuitivo

Imaginarelease.jscomo el panel de control de una lavadora antigua: el mando (parseArgs) decide qué modo usar, las luces indicadoras (variables globales) registran en qué etapa se está actualmente, y el botón "cancelar" (manejo de errores) debe poder devolver la máquina al estado previo al llenado de agua. Sin esta lógica de inicialización, el script perdería el control sobre la pregunta "qué versión quiere publicar realmente el usuario": o publicaría la versión equivocada, o se quedaría bloqueado en CI esperando una entrada de teclado que nunca llegará.

Diseño de memoria de flags y estado global

〔Inferencia de diseño y compensaciones arquitectónicas〕

Lo primero que hace el script tras iniciarse es analizar los argumentos de línea de comandos en un objeto estructurado. Aquí se usa el módulo integrado de NodeparseArgs, en lugar deyargsocommander— esto es para eliminar dependencias de terceros, porque el script de publicación en sí debe poder ejecutarse en cualquier entorno, incluso sinode_modulesestá instalado a medias.

📎 scripts/release.js:27-62define 10 opciones, que se pueden dividir en cuatro categorías:

  • Categoría de semántica de versión:preid(identificador de prelanzamiento, comoalpha/beta/rc)、tag(npm dist-tag)
  • Categoría de omisión:skipBuild、skipTests、skipGit、skipPrompts— estos cuatro interruptores booleanos constituyen los botones de ajuste del «grado de automatización»
  • Categoría de modo de ejecución:dry(simulación),publish(si publicar directamente en local),publishOnly(solo publicar sin actualizar la versión)
  • Categoría de destino:registry(dirección de registry personalizada)

Nótese quepublishel valor predeterminado de esfalse 📎 scripts/release.js:51-54, mientras que los demás elementos booleanos no tienen valor predeterminado (es decir,undefined). Esta asimetría es deliberada:publishla semántica de es «si ejecutar npm publish en local», por defecto no publica, y delega la acción de publicación a GitHub Actions; mientras queskipXxxpor defectoundefinedsignifica «no especificado», y la lógica posterior distinguirá entre «el usuario pasó explícitamente--skipTests» y «el usuario no lo pasó».

Una vez completado el análisis, el script aplana los parámetros en un conjunto de variables a nivel de módulo📎 scripts/release.js:64-66:

js
const preId = args.preid || semver.prerelease(currentVersion)?.[0]
const isDryRun = args.dry
let skipTests = args.skipTests
const skipBuild = args.skipBuild
const skipPrompts = args.skipPrompts
const skipGit = args.skipGit

Aquí hay dos diseños que merecen atención. Primero,preIdla prioridad de valor de es «especificado explícitamente en la línea de comandos > inferido a partir del número de versión actual»📎 scripts/release.js:64-66. Si la versión actual depackage.jsones3.5.0-beta.1, entoncessemver.prereleasedevolverá['beta', 1], tomando[0]se obtiene'beta'. Esto significa que al publicar versiones consecutivas en la rama beta, no es necesario escribir--preid betacada vez. Segundo,skipTestsse declara conletmientras que los demás usanconst 📎 scripts/release.js:64-66, porque enrunTestsIfNeededserá reescrito dinámicamente por el resultado de CI — este es un bit de estado de «decisión diferida».

Inmediatamente después está la lógica de descubrimiento de paquetes📎 scripts/release.js:68-83: lee el directoriopackages/, filtra elementos que no son directorios, elementos sinpackage.json, y paquetesprivate: true. Nótese que aquí se leepackages/en lugar depackages-private/— este último es un paquete de depuración interno, que nunca se publica.

Algoritmo de ordenación del orden de publicación

📎 scripts/release.js:85-85define una función que parece simple pero es crucial:

js
const sortPackagesForPublishing = (packageNames) => [
  ...packageNames.filter(p => p !== 'vue'),
  ...packageNames.filter(p => p === 'vue'),
]

Coloca el paquete de entradavueal final. El comentario📎 scripts/release.js:85-85explica la razón: si se publica primerovue, los usuarios podrían instalar la nueva versión de@vue/runtime-coreantes de que paquetes internos comovueestén en línea, y npm dará error al no encontrar dependencias internas coincidentes. Esta es la solución de compromiso de la «atomicidad de publicación» en el ecosistema npm — npm no tiene transacciones entre paquetes, solo puede aproximarse a la atomicidad mediante el orden.

Construcción dinámica del conjunto de candidatos de incremento de versión

📎 scripts/release.js:111-116construye las opciones del menú interactivo:

js
const versionIncrements = [
  'patch', 'minor', 'major',
  ...(preId ? ['prepatch', 'preminor', 'premajor', 'prerelease'] : []),
]

Esta es una expansión condicional: solo cuandopreIdexiste (es decir, actualmente en el canal de prelanzamiento, o el usuario especificó explícitamente--preid), se añaden al menú los tipos de incremento relacionados con prelanzamiento. Si actualmente es una versión estable3.5.43y no se especificópreid, el menú solo tienepatch/minor/majortres opciones — evitando que el usuario convierta por error una versión estable en una versión de prelanzamiento a medias como3.5.44-0.

incLa función📎 scripts/release.js:120-120encapsulasemver.inc, pasandopreIdcomo tercer parámetro. Aquí hay una defensa de tipos:typeof preId === 'string' ? preId : undefined— porquepreIdpuede serstring | undefined, ysemver.incesperastring | undefined, esta expresión ternaria es para satisfacer el estrechamiento de tipos de TS.

Primitivas de ejecución: el sistema de doble vía de run y dryRun

📎 scripts/release.js:122-123es uno de los diseños más ingeniosos de todo el capítulo:

js
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 : run

runestablece el stdio del subproceso eninherit, permitiendo que la salida de compilación/pruebas se transmita directamente a la terminal — esto es crucial para compilaciones de larga duración, el usuario puede ver el progreso en tiempo real.dryRunsolo imprime el comando sin ejecutarlo.runIfNotDryes una «selección de estrategia»: al cargar el módulo se vincula el puntero de función adryRunorun, y todos los puntos de llamada posteriores ya no necesitan juzgarisDryRun。

〔Inferencia de diseño y compensaciones arquitectónicas〕

Este patrón de «decidir la estrategia en la inicialización» es menos propenso a errores que «juzgar en cada punto de llamada»: si algún punto de llamada olvida juzgarisDryRun, en modo dry run se ejecutarán realmente los efectos secundarios. Mientras querunIfNotDryconcentra el juicio en un solo lugar, eliminando la posibilidad de este tipo de omisiones.

mermaid
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"]

---

Decisión interactiva de versión y control de acceso de CI

Modelo intuitivo

Esta etapa es como el control de seguridad de un aeropuerto: primero verifica tu tarjeta de embarque (si el commit local está sincronizado con el remoto), luego confirma a dónde vas (número de versión), y finalmente comprueba si ya pasaste el control de seguridad (si CI pasó). Si alguna parte no pasa, todo el proceso se detiene. Sin este control de acceso, un commit local no subido podría ser etiquetado y publicado, provocando que el código fuente correspondiente a la versión en npm no exista en GitHub — este es el accidente de publicación más difícil de diagnosticar.

Verificación de sincronización y selección de versión

mainLo primero que hace la funciónisInSyncWithRemote() 📎 scripts/release.js:141-141es📎 scripts/release.js:337-363. La lógica de esta funcióngit rev-parse HEADes: obtener el nombre de la rama actual, solicitar a la API de GitHub el SHA del último commit de esa rama, y compararlo con el📎 scripts/release.js:348-355local. Si no coinciden, aparece un cuadro de confirmación con advertencia rojafalse, dejando que el usuario decida si continuar. Si la solicitud a la API falla (problema de red, sin token), devuelve directamente📎 scripts/release.js:365-367。

y termina

〔Inferencia de diseño y compensaciones arquitectónicas〕

La filosofía de diseño aquí es «fallar es abortar»: ante una anomalía de red, es preferible no permitir la publicación que arriesgarse a continuar con un estado desconocido. Porque la publicación es irreversible, y el costo de volver a ejecutar el script es muy bajo.node scripts/release.js 3.6.0),targetVersionLa determinación del número de versión tiene dos rutas. Si el usuario pasó un parámetro posicional en la línea de comandos (como📎 scripts/release.js:141-141se toma directamente ese valor📎 scripts/release.js:152-176. De lo contrario, se entra al menú interactivocustom: primero se deja que el usuario elija el tipo de incremento, y si elige

se muestra otro cuadro de entrada para que el usuario escriba manualmente el número de versión.📎 scripts/release.js:174Nótese la línea

js
targetVersion = release.match(/\((.*)\)/)?.[1] ?? ''

Copiarpatch (3.5.44)El formato del elemento del menú escustom, esta expresión regular extrae el número de versión real de los paréntesis. Si el usuario eligió📎 scripts/release.js:164-172。

, se sigue otra rama📎 scripts/release.js:178-182Posteriormente hay una lógica de «segundo análisis»targetVersion: sipatch/minorEste tipo de palabra clave incremental (el usuario podría pasar directamentenode release.js minor), se llama aincpara convertirla en un número de versión concreto. Finalmente se usasemver.validpara validar📎 scripts/release.js:184-186, y si el número de versión es inválido se lanza un error directamente.

Puerta de CI: la lógica de tres estados de runTestsIfNeeded

Este es el flujo de control más complejo de todo el capítulo.📎 scripts/release.js:281-317ElrunTestsIfNeededde

es en realidad una máquina de decisión de tres estados:--skipTests。skipTestsEstado uno: el usuario pasó explícitamentetruese inicializa como📎 scripts/release.js:314-316。

, se omite directamente todo el cuerpo de la función y se imprime "Tests skipped."Estado dos: no se omitió, y CI ya pasógetCIResult() 📎 scripts/release.js:319-335. El script llama aci, que solicita la API de GitHub Actions y verifica si existe un workflow run llamadoconclusion === 'success'y con📎 scripts/release.js:319-335. Si pasa, pregunta al usuario «CI ya pasó, ¿omitir las pruebas locales?»📎 scripts/release.js:288-295. Si el usuario activó--skipPrompts, se omiten automáticamente las pruebas locales📎 scripts/release.js:296-298。

Estado tres: no se omitió, y CI no pasó. Si se activó--skipPrompts, se lanza un error directamente📎 scripts/release.js:299-304:

js
throw new Error(
  'CI for the latest commit has not passed yet. ' +
    'Only run the release workflow after the CI has passed.',
)

Si no se activó--skipPrompts, entoncesskipTestsse mantiene comoundefined, y se cae en la rama final de pruebas locales📎 scripts/release.js:307-313, ejecutandopnpm run test --run。

Aquí hay un detalle sutil📎 scripts/release.js:285:

js
skipTests ||= isCIPassed

||=es una asignación lógica OR: solo cuandoskipTestses un valor falsy (undefinedofalse) se le asignaisCIPassed. Esto significa que si el usuario pasó explícitamente--skipTests(true), esta línea no lo cambia; si el usuario no lo pasó (undefined), entonces se establece con el resultado de CI. Pero inmediatamente después📎 scripts/release.js:287-298se reasigna cuando CI pasa; por lo tanto||=el efecto real de esta línea es solo «si CI no pasó, establecerskipTestscomofalse», para que la posterior ramaif (!skipTests)ejecute las pruebas locales.

〔Inferencia de diseño y compensaciones arquitectónicas〕

Esta lógica da muchas vueltas, pero en esencia quiere expresar: «CI pasó → se pueden omitir las pruebas locales (pero preguntando al usuario); CI no pasó → se deben ejecutar las pruebas locales (a menos que el usuario pida explícitamente omitirlas)». Usar||=más una sobrescritura posterior es compacto, pero poco legible; es el típico code smell de «bit de estado modificado en varios lugares».

mermaid
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)

Escritura de números de versión: el recorrido de updateVersions

📎 scripts/release.js:377-384ElupdateVersionsdepackage.jsonhace dos cosas: actualizar elupdatePackage。updatePackage 📎 scripts/release.js:391-398raíz, y luego recorrer todos los subpaquetes llamando anamepara leer el JSON, reescribirversionyJSON.stringify(pkg, null, 2) + '\n', y escribir de vuelta con\n— atención al

getNewPackageNamefinal, esto es para mantener el archivo terminado en salto de línea y evitar que git diff muestre "No newline at end of file".keepThePackageName 📎 scripts/release.js:105El parámetro

---

por defecto es

, es decir, no cambia el nombre del paquete. La existencia de este parámetro es para soportar el escenario de «renombrar el paquete al publicar en un registry personalizado»; aunque los puntos de llamada actuales pasan el valor por defecto, la interfaz deja espacio para extensibilidad.

Orden de publicación, idempotencia y reversión ante fallosupdateVersionsModelo intuitivo

Esta etapa es como fichas de dominó:

se derriba la primera ficha (cambiar el número de versión), y luego caen sucesivamente el changelog, el lockfile, el commit, el tag y el publish. Si alguna ficha se atasca a mitad de camino, debe existir un mecanismo para levantar las fichas ya caídas; de lo contrario, el repositorio quedaría en un estado a medias de «número de versión cambiado pero sin publicar».

publishPackage 📎 scripts/release.js:439-489Publicación idempotente: isPackagePublished y respaldo ante errores📎 scripts/release.js:442-451〔Inferencia de diseño y compensaciones arquitectónicas〕--tages el núcleo de la publicación. Primero determina el dist-tagalpha/beta/rc: prioriza el parámetroversion.includes('alpha'); de lo contrario, lo infiere a partir de la palabra clavesemver.prereleaseen el número de versión. Nótese que aquí se usa3.5.0-alpha.1,includesen lugar de

— porque el número de versión puede tener la forma📎 scripts/release.js:453-458:

js
if (!isDryRun && (await isPackagePublished(packageName, version))) {
  console.log(pico.yellow(`Skipping already published: ${pkgVersion}`))
  alreadyPublishedPackages.push(pkgVersion)
  return
}

isPackagePublished 📎 scripts/release.js:491-513Antes de publicar hay una comprobación de idempotencianpm view <pkg>@<version> versionCopiartrueejecutafalse, si tiene éxito devuelve

, si reporta un error tipo E404 devuelvenpm view. El sentido de esta comprobación es que el flujo de publicación puede reejecutarse por una interrupción de red, y al reejecutar los paquetes ya publicados no deben publicarse de nuevo (npm rechazará versiones duplicadas).isPackagePublishedPero la comprobación misma también puede fallar; por ejemplo,📎 scripts/release.js:507-510lanza un error que no es E404 por un timeout de red. En ese caso

propaga el error hacia arribapnpm publish, lo que provoca que toda la publicación se aborte. Esta es otra manifestación de «preferir abortar antes que arriesgarse».publishPackageIncluso si la comprobación pasa,📎 scripts/release.js:480-488:

js
} catch (e) {
  if (e.message?.match(/previously published/)) {
    console.log(pico.red(`Skipping already published: ${pkgVersion}`))
    alreadyPublishedPackages.push(pkgVersion)
  } else {
    throw e
  }
}

hace un segundo respaldo en el bloque catchpreviously publishedCopiar

Solo si coincide con

📎 scripts/release.js:412-432se traga el error; cualquier otro error se relanza. Esto es «tolerancia precisa a fallos»: solo se degradan los errores conocidos y seguros de ignorar.pnpm publishEnsamblado dinámico de las banderas de publicación

js
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-checkssegún el entorno de ejecución:pnpm publishCopiar

--provenancese habilita en tres casos: dry run, omitir git, o estar en CI. La razón es que📎 scripts/release.js:425-427por defecto verifica si el árbol de trabajo está limpio, si la rama actual es la rama de publicación, etc., y en CI estas comprobaciones dan falsos positivos.!args.registrysolo se habilita en CI y cuando no se especificó un registry personalizado

. provenance es una característica de seguridad de la cadena de suministro de npm; firma y adjunta al paquete la información de origen del artefacto de compilación (qué commit, qué workflow). Pero los registries personalizados (como un registry privado interno) normalmente no soportan provenance, por eso se añadió la condición

.mainReversión ante fallos: la bandera versionUpdated📎 scripts/release.js:528-537:

js
fnToRun().catch(err => {
  if (versionUpdated) {
    updateVersions(currentVersion)
  }
  console.error(err)
  process.exit(1)
})

versionUpdatedCopiarfalse 📎 scripts/release.js:24-27es un booleano a nivel de módulo, inicialmenteupdateVersions, y se establece inmediatamente entrue 📎 scripts/release.js:208tras el éxito de la llamada atrue. Si cualquier paso posterior (generación de changelog, actualización de lockfile, git commit, publish) lanza un error, el bloque catch revisa esta bandera y, si escurrentVersion。

, revierte el número de versión a

Esta reversión es "de mejor esfuerzo": solo reviertepackage.jsonel número de versión en , no revierte el archivo changelog, no revierte el lockfile, no revierte el git commit ya ejecutado. Si el error ocurre después del git commit, el repositorio quedará en un estado intermedio de "número de versión revertido pero commit ya existente". Esta es una decisión de diseño—una reversión completa requeriríagit reset, y eso destruiría otros cambios que el usuario podría haber hecho. Por eso el script elige revertir solo el número de versión más crítico, dejando que el usuario maneje manualmente el resto.

NotapublishOnlyruta📎 scripts/release.js:519-526no estableceversionUpdated, porque su semántica es "solo publicar, no cambiar versión"—incluso si falla, no necesita reversión. Pero cuandotargetVersionexiste, llama aupdateVersions 📎 scripts/release.js:519-526, y si falla en ese momento, el número de versión no será revertido. Este es un problema potencial de borde, ver las preguntas de reflexión al final del capítulo.

mermaid
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)"]

Orden de publicación y manejo especial del paquete vue

publishPackages 📎 scripts/release.js:412-432itera sobresortPackagesForPublishing(packages)el resultado, llamando uno por uno apublishPackage. Dado que la ordenación colocavueal final📎 scripts/release.js:85-85, toda la secuencia de publicación garantiza que los paquetes internos se publiquen primero.

publishPackageinternamente usacwd: getPkgRoot(pkgName) 📎 scripts/release.js:475para cambiar el directorio de trabajo al directorio del subpaquete, de modo quepnpm publishpublica el subpaquete en lugar del paquete raíz. El comentario📎 scripts/release.js:462-463advierte especialmente "no cambiar a npm publish"—porquepnpm publishpuede manejar correctamenteworkspace:*el protocolo de dependencias, convirtiéndolo al número de versión real, mientras quenpm publishmantendríaworkspace:*tal cual, causando fallo en la instalación.

---

Reflexión de diseño

¿Por qué usarparseArgsen lugar deyargs?? El script de publicación es la "última línea de defensa", debe ser ejecutable en cualquier entorno. Si una biblioteca CLI de terceros falla al cargarse por un árbol de dependencias corrupto, todo el flujo de publicación se paraliza. ElparseArgsintegrado de Node, aunque de funcionalidad rudimentaria (no soporta subcomandos, no soporta help automático), tiene cero dependencias y cero riesgo.

¿Por quépublishse establece por defecto enfalse?? Porque la publicación oficial de Vue pasa por GitHub Actions (ver📎 scripts/release.js:256-263el mensaje de aviso), el script local solo se encarga de cambiar el número de versión, generar changelog, crear tag, hacer push. El verdaderonpm publishse ejecuta en CI, aprovechando la firma de provenance y el entorno controlado de CI.--publishEl flag es una vía de escape para que los mantenedores publiquen localmente en situaciones de emergencia.

¿Por qué la reversión solo revierte el número de versión?Porque una reversión completa requiere entender "qué cambios hizo el script y qué cambios hizo el usuario", y eso no se puede distinguir a nivel de git. El script elige revertir solo lo que está más seguro de haber cambiado—package.jsonel número de versión—y deja el resto al juicio del usuario.

---

Resumen del capítulo

scripts/release.jsimplementa con 537 líneas de código una "máquina de estados interactiva", cuyo diseño central se puede resumir en tres puntos:

1. Parámetros como estrategia: 10 flags se analizan al cargar el módulo y se aplanan en variables globales,runIfNotDryvincula la estrategia durante la inicialización, evitando omisiones de juicio en los puntos de llamada.

2. Puertas de control al frente: verificación de sincronización, validación de versión, puertas de CI se completan antes de cualquier efecto secundario, asegurando "todo o nada".

3. Tolerancia a fallos precisa:isPackagePublishedpreverificación +previously publishedrespaldo de errores constituyen doble protección idempotente;versionUpdatedlos flags implementan reversión minimizada.

Este mecanismo forma un contraste interesante con el Template Explorer del capítulo anterior: Template Explorer es "observar"—visualizar el estado interno del compilador; release.js es "ejecutar"—explicitar cada paso del estado del flujo de publicación. Ambos reflejan la misma filosofía de ingeniería:convertir estado implícito en estado explícito, convertir efectos secundarios incontrolables en pasos controlables。

Reflexión y autoevaluación del capítulo

Q1: Si se cambia📎 scripts/release.js:285deskipTests ||= isCIPassedaskipTests = isCIPassed, ¿qué sucede cuando el usuario pasa explícitamente--skipTestsy CI no ha pasado? ¿Por qué?

Análisis de referencia: En la lógica original, cuando el usuario pasa--skipTests,skipTestsinicialmente estrue 📎 scripts/release.js:64-66,||=no lo cambia, por lo tantorunTestsIfNeededen📎 scripts/release.js:282el juicio deif (!skipTests)es falso, salta directamente a📎 scripts/release.js:314-316imprimir "Tests skipped.". Si se cambia askipTests = isCIPassed, entoncesskipTestsse fuerza afalse(CI no pasado), luego📎 scripts/release.js:287elif (isCIPassed)de es falso, cae en📎 scripts/release.js:299elelse if (skipPrompts)de —si no se activa--skipPrompts, entoncesskipTestspermanecefalse, finalmente en📎 scripts/release.js:307-313ejecuta pruebas locales. Esto viola la intención del usuario de "omitir pruebas explícitamente", y en entorno CI (--skipPrompts) además lanzaría directamente error📎 scripts/release.js:300-303, causando la interrupción de la publicación.||=La existencia de es precisamente para respetar la elección explícita del usuario.

Q2: publishOnlyruta📎 scripts/release.js:519-526cuandotargetVersionexiste llama aupdateVersions, pero no estableceversionUpdated. Si en ese momentobuildPackagesopublishPackageslanza error, ¿qué sucede? ¿Es razonable este diseño?

Análisis de referencia:publishOnlyllama aupdateVersions(targetVersion) 📎 scripts/release.js:519-526modificando todospackage.jsonlos números de versión, pero no estableceversionUpdated = true. Cuando posteriormentebuildPackages 📎 scripts/release.js:519-526opublishPackages 📎 scripts/release.js:519-526lanza error,fnToRun().catch 📎 scripts/release.js:528-537verificaversionUpdatedque esfalse, no revierte el número de versión. El resultado es que el repositorio queda en estado "versión cambiada pero publicación fallida". Este diseño es razonable bajo la semántica original depublishOnly(solo publicar, no cambiar versión)—porquetargetVersionnormalmente no se pasa,updateVersionsno se ejecuta. Pero cuando el usuario pasatargetVersion, esta ruta tiene una vulnerabilidad de reversión. La forma de corregir es agregar📎 scripts/release.js:519-526después deversionUpdated = true, o hacer quepublishOnlyreutilicemainla lógica de reversión de .

Q3: isPackagePublished 📎 scripts/release.js:491-513usanpm viewpara verificar si el paquete ya fue publicado. Si un timeout de red causa quenpm viewlance un error que no es E404, ¿qué sucede? ¿Es seguro este comportamiento en escenarios de re-ejecución de CI?

Análisis de referencia:isPackagePublisheden el bloque catch📎 scripts/release.js:507-510llama aisPackageNotFoundErrorpara determinar el tipo de error. Esa función📎 scripts/release.js:515-515solo coincide con/E404|No match found|No matching version|notarget/i. El mensaje de error de timeout de red no contiene estas palabras clave, por lo tantoisPackageNotFoundErrordevuelvefalse,isPackagePublishedrelanza el error📎 scripts/release.js:507-510. Este error se propaga hacia arriba hastapublishPackage 📎 scripts/release.js:453, lo que provoca la interrupción de toda la publicación. En escenarios de reejecución de CI, esto causaría que «aunque el paquete ya está publicado, se interrumpa por una fluctuación de red» — pero esta es una dirección de fallo segura: es mejor abortar que juzgar erróneamente como «no publicado» y volver a publicar. La republicación activaría el errorpreviously publishedde npm, siendo cubierto por📎 scripts/release.js:491-492, pero desperdiciaría un viaje de ida y vuelta de red. Por lo tanto, «error de red = abortar» es una elección conservadora pero correcta.

---

El siguiente capítulo entrará en.github/workflows/, para ver cómo GitHub Actions toma el relevo de la construcción y publicación posteriores después de que release.js empuje el tag, así como la implementación completa de las puertas de CI.

Hasta aquí, hemos visto claramente cómo release.js minimiza el riesgo de publicación irreversible mediante una máquina de estados y orquestación interactiva. Pero el script de publicación en sí es solo el ejecutor; quien realmente decide cuándo activar y bajo qué condiciones permitir el paso es el guardián de automatización de nivel superior. El siguiente capítulo analizará el sistema CI/CD bajo el directorio .github/workflows: cómo ci.yml ejecuta la triple puerta de lint/typecheck/test en la fase de PR, cómo release.yml activa la publicación al empujar un tag, cómo size-report.yml y size-data.yml rastrean regresiones de tamaño de paquete, y cómo autofix.yml corrige automáticamente problemas de formato. Entenderás cómo Vue utiliza GitHub Actions para solidificar las normas de ingeniería en un pipeline que no se puede eludir.

Convierte cualquier código en un libro comprensible

¿Disfrutaste este capítulo? Convierte tu código privado en un libro

Arquitectura local-first con Tauri 2 + Rust. 100% offline y seguro, sin subir código. Lectura en panel dual con anclajes de commit inmutables.

⚡ Tauri 2 · Rust Core · 100% Privado y Offline · Probado en +1M líneas

CHAPTER 10

Capítulo 10: Flujos de trabajo CI/CD: el guardián automatizado desde PR hasta Release

Upstream: vuejs/core · Commit @4ab865a8 · Progreso: Capítulo 10 de 14

En el capítulo anterior vimosscripts/release.jscómo utiliza una máquina de estados interactiva para encadenar cada paso de una publicación. Pero ese script tiene una premisa: debe ser invocado activamente por alguien o algún sistema. En el repositorio de Vue core, este invocador activo no es la terminal local del mantenedor, sino GitHub Actions. release.js es el ejecutor, workflows es el decisor — decide qué evento activa qué tarea, bajo qué condiciones permite el paso y bajo qué condiciones lo bloquea. Este capítulo se centra en.github/workflows/los cuatro archivos bajo el directorio:ci.yml(puerta de PR y prepublicación continua),release.yml(publicación formal activada por tag),size-report.yml(informe de regresión de tamaño),autofix.yml(corrección automática de formato). Entenderlos no consiste en memorizar la sintaxis YAML, sino en ver claramente cómo el equipo de Vue traduce las normas de ingeniería en restricciones de pipeline que no se pueden eludir.

I. ci.yml: triple puerta y prepublicación continua

Modelo intuitivo

Imaginaci.ymlcomo el control de seguridad de un aeropuerto. Cada PR debe pasar por esta puerta: lint revisa si tu equipaje tiene artículos prohibidos, typecheck confirma que tu identificación es real y válida, test verifica que no llevas materiales peligrosos. Pero no hay una sola puerta de seguridad — Vue también ha añadido aquí un canal de «prepublicación continua», que publica directamente los artefactos de construcción de cada PR en pkg-pr-new, permitiendo a los contribuyentes validar sus cambios en un escenario real de instalación desde npm.

Sin esta puerta, cualquier fusión podría introducir errores de formato, vulnerabilidades de tipos o regresiones de comportamiento en la rama main, y la rama main es el origen de todas las releases posteriores.

Condiciones de activación y control de concurrencia

ci.ymlLa configuración de activación de

📎 .github/workflows/ci.yml:2-11

yaml
on:
  push:
    branches:
      - '**'
    tags:
      - '!**'
  pull_request:
    branches:
      - main
      - minor

CopiarpushAquí hay dos diseños clave. Primero, el evento'**'escucha todas las ramas (tags: ['!**']), pero excluye explícitamente todos los push de tags medianterelease.yml. ¿Por qué excluir los tags? Porque el push de tags es manejado por separado porci.yml; sipull_requesttambién respondiera a los tags, se activarían duplicadamente el flujo de publicación y el flujo de CI, desperdiciando recursos del runner e incluso generando condiciones de carrera. Segundo,mainsolo escucha las dos ramasminorymain— esta es la estrategia de doble rama de Vue:minoralberga la versión estable,

📎 .github/workflows/ci.yml:22-22

yaml
concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

CopiargroupEl control de concurrencia es el trazo más ingenioso aquí.github.event.pull_request.number || github.refLa expresión decancel-in-progressutilizatruecomo fallback: los eventos de PR usan el número de PR como clave de agrupación, los eventos de push usan el ref (nombre de rama) como clave de agrupación. Esto significa que múltiples push del mismo PR caerán en el mismo grupo de concurrencia. Y

solo es

durante eventos de PR — cuando empujas tres commits consecutivos, los CI de los dos primeros se cancelan automáticamente, conservando solo el más reciente.

〔Inferencia de diseño y compensaciones arquitectónicas〕

📎 .github/workflows/ci.yml:22-22

yaml
jobs:
  test:
    if: ${{ ! startsWith(github.event.head_commit.message, 'release:') && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository) }}
    uses: ./.github/workflows/test.yml

La entrada de la triple puerta: la condición del job testifCopiar&&Esta condición

contiene dos ramas de conjunción lógica (! startsWith(github.event.head_commit.message, 'release:')), cada una merece ser desarrollada.release:Al principio, se omiten las pruebas. Este es exactamente el formato del mensaje de commit que release.js envía en el capítulo anterior: release.js ya ha ejecutado las pruebas completas localmente, por lo que CI no necesita verificarlas de nuevo. Esta es una optimización de «confiar en el origen».

〔Inferencia de diseño y compensaciones arquitectónicas〕

La segunda condición(github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository): el evento push siempre ejecuta las pruebas; el evento PR requiere que el PR provenga de un fork (head.repo.full_name != github.repository). ¿Por qué solo se ejecutan las pruebas para PR de fork? Porque los PR de ramas del mismo repositorio generalmente son creados por miembros del equipo principal, y el push de sus ramas ya ha activado el CI del evento push. En cambio, los PR de fork no activan el evento push (el push de un fork no notifica al repositorio upstream), por lo que deben ejecutarse adicionalmente en el evento PR.

Notauses: ./.github/workflows/test.yml——esta es una llamada a un reusable workflow.test.ymles un archivo de workflow independiente, compartido porci.ymlyrelease.yml. Esta reutilización evita definir repetidamente los pasos de lint/typecheck/test en múltiples workflows.

Prepublicación continua: el rol de pkg-pr-new

📎 .github/workflows/ci.yml:25-51

yaml
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,yarn

continuous-releaseEl job solo se ejecuta en el repositorio principalvuejs/core(if: github.repository == 'vuejs/core'), no se ejecuta en forks. Hace tres cosas: construir (pnpm build --withTypes, con declaraciones de tipos), luego usarpkg-pr-newpara publicar todos los paquetes bajo./packages/*en un registro npm temporal.

〔Inferencia de diseño y compensaciones arquitectónicas〕

El valor de este mecanismo radica en que los contribuyentes pueden, directamente en su propio proyecto,npm installlos artefactos de compilación de este PR, verificando si los cambios realmente resuelven el problema. Esto es más convincente que «ver que CI está en verde», porque valida un escenario real de consumo del paquete.

Nota: todas las actions están fijadas a un commit SHA (comoactions/checkout@3d3c42e5...), en lugar de usar@v4una etiqueta flotante como esa. Este es un requisito estricto de seguridad de la cadena de suministro: evitar que código malicioso fluya automáticamente tras un compromiso del repositorio de la action.

Diagrama de flujo de control de ci.yml

mermaid
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: orquestación de publicación tras el push de un tag

Modelo intuitivo

Sici.ymles el control de seguridad,release.ymles la plataforma de lanzamiento. Cuando release.js completa localmente la actualización de versión, el commit, la creación del tag y el push, el evento de push del tag enciende el motor derelease.yml. Primero ejecuta una ronda completa de pruebas (confirmación adicional), luego ejecutaReleaseen el entorno protegidopnpm release --publishOnly, y finalmente crea el GitHub Release.

Sin él, el tag que release.js envía sería solo una referencia de Git, no habría nueva versión en npm ni página de Release en GitHub.

Condición de activación: solo reconoce tags

📎 .github/workflows/release.yml:3-6

yaml
on:
  push:
    tags:
      - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10

Solo escucha pushes de tags con formatov*. Esto es complementario conci.ymldetags: ['!**']——ambos son estrictamente mutuamente excluyentes y no se activan simultáneamente.

Condiciones de guarda del job de publicación

📎 .github/workflows/release.yml:8-21

yaml
jobs:
  test:
    uses: ./.github/workflows/test.yml

  release:
    if: github.repository == 'vuejs/core'
    needs: [test]
    runs-on: ubuntu-latest
    permissions:
      contents: write
      id-token: write
    environment: Release

Aquí hay tres capas de guarda, ninguna de las cuales puede omitirse.

Primera capaif: github.repository == 'vuejs/core': evita activaciones accidentales de publicación en forks. Si alguien hace fork del repositorio y envía unv1.0.0tag, esta condición impedirá que se ejecute el flujo de publicación.

Segunda capaneeds: [test]: el job release depende del job test. El job test llama atest.yml, y si las pruebas fallan, el job release no se iniciará en absoluto. Esta es la restricción estricta de «debe pasar las pruebas antes de publicar».

〔Inferencia de diseño y compensaciones arquitectónicas〕

Tercera capaenvironment: Release: este es un GitHub Environment, que puede configurar reglas de protección de despliegue (como requerir aprobación de personal específico). Esto significa que incluso si el push del tag activa el workflow, el paso de publicación puede requerir aprobación manual para ejecutarse: esta es la última línea de defensa para operaciones irreversibles.

En cuanto a permisos,contents: writese usa para crear GitHub Release,id-token: writese usa para la autenticación de provenance de npm (token OIDC). Nota: aquí no haypackages: write, porque Vue publica en npm, no en GitHub Packages.

Cadena completa del paso de publicación

📎 .github/workflows/release.yml:37-46

yaml
- 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
〔Inferencia de diseño y compensaciones arquitectónicas〕

Los tres pasos tienen sus matices.--frozen-lockfileasegura que el entorno de CI instale estrictamente según el lockfile, evitando que la deriva de versiones de dependencias haga que los artefactos de compilación sean inconsistentes con los locales.npm i -g npm@latestes para obtener la CLI de npm más reciente, porque la autenticación de provenance y OIDC depende de versiones más nuevas de npm; las versiones antiguas pueden no soportar estas características.

pnpm release --publishOnlyes el punto de entrada de release.js del capítulo anterior.--publishOnlyEl flag le indica a release.js: omitir la selección interactiva de versión, omitir el commit de Git y la creación del tag (porque el tag ya existe), y solo ejecutar la compilación y npm publish.

Crear GitHub Release

📎 .github/workflows/release.yml:48-57

yaml
- 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.
〔Inferencia de diseño y compensaciones arquitectónicas〕

Aquí se usarelease-tag action。tag_name: ${{ github.ref }}mantenido por el propio autor de Vue, Evan You, que utiliza directamente la ref del evento desencadenante (es decir,refs/tags/v3.x.x). El cuerpo del Release no incluye los cambios específicos, sino que apunta a CHANGELOG.md — porque el changelog de Vue es generado automáticamente por conventional-changelog, y mantener manualmente el cuerpo del Release crearía inconsistencias con el changelog.

Diagrama de secuencia de release.yml

mermaid
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"

---

Tres, size-report.yml y autofix.yml: seguimiento de tamaño y autocorrección de formato

size-report.yml: informe de regresión de tamaño entre workflows

size-report.ymlLa forma de activación es muy particular — no se activa directamente por push o PR, sino por el evento de finalización de otro workflow.

📎 .github/workflows/size-report.yml:3-7

yaml
on:
  workflow_run:
    workflows: ['size data']
    types:
      - completed

workflow_runEl evento de escucha se llamasize datael workflow completado. Este es un diseño de dos fases:size-data.yml(Este capítulo no proporciona el código fuente) se encarga de construir y medir el tamaño en el PR, subiendo el resultado como artifact;size-report.ymldespués de quesize datase complete, descarga el artifact, genera el informe y comenta en el PR.

📎 .github/workflows/size-report.yml:20-23

yaml
if: >
  github.repository == 'vuejs/core' &&
  github.event.workflow_run.event == 'pull_request' &&
  github.event.workflow_run.conclusion == 'success'

Triple guardia: repositorio principal, evento PR, workflow upstream exitoso. Sisize datafalla, el job de informe no se ejecuta — porque no hay datos que reportar.

El flujo de datos es el siguiente:

📎 .github/workflows/size-report.yml:41-46

yaml
- name: Download Size Data
  uses: dawidd6/action-download-artifact@d63b86af1b34672e53c440b1b83979861906bad7 # v24
  with:
    name: size-data
    run_id: ${{ github.event.workflow_run.id }}
    path: temp/size

Descarga desde el workflow run upstream elsize-dataartifact atemp/size. Luego lee en paralelo el número de PR y la rama base:

📎 .github/workflows/size-report.yml:48-59

yaml
- 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.txt

paralleles azúcar sintáctico de GitHub Actions que permite ejecutar dos pasos sin dependencias simultáneamente.number.txtybase.txtsonsize-data.ymlarchivos de metadatos escritos durante la medición.

A continuación, descarga los datos históricos de tamaño de la rama base para comparar:

📎 .github/workflows/size-report.yml:61-69

yaml
- name: Download Previous Size Data
  uses: dawidd6/action-download-artifact@d63b86af1b34672e53c440b1b83979861906bad7 # v24
  with:
    branch: ${{ steps.pr-base.outputs.content }}
    workflow: size-data.yml
    event: push
    name: size-data
    path: temp/size-prev
    if_no_artifact_found: warn

Notaif_no_artifact_found: warn— si la rama base aún no tiene datos históricos (por ejemplo, una rama nueva), no fallará, solo advertirá. Esto garantiza que el informe aún se genere en la primera ejecución, solo que sin línea base de comparación.

Finalmente, genera el informe y comenta:

📎 .github/workflows/size-report.yml:71-89

yaml
- 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.jsleetemp/sizeytemp/size-prevlos datos bajo, generando un informe en Markdown.maintain-one-comment-backupLa action usabody-include: '<!-- VUE_CORE_SIZE -->'como marcador, asegurando que solo se mantenga un comentario de informe de tamaño en el mismo PR (actualizar en lugar de añadir). Nota la anotación en L81 que indica que el repositorio original de la action fue bloqueado por GitHub, por lo que se usó un repositorio de respaldo con commit fijado.

autofix.yml: corrección automática de problemas de formato

autofix.ymlresuelve un problema muy práctico: el código enviado por el contribuyente no cumple con las normas de prettier/eslint, CI falla, y el contribuyente necesita ejecutar manualmentepnpm lint --fixy volver a enviar. Este workflow automatiza este paso.

📎 .github/workflows/autofix.yml:3-8

yaml
on:
  pull_request:

concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

Se activa en todos los PR, el control de concurrencia es similar aci.yml— un nuevo push en el mismo PR cancela la ejecución anterior de autofix.

📎 .github/workflows/autofix.yml:35-41

yaml
- name: Run eslint
  run: pnpm run lint --fix

- name: Run prettier
  run: pnpm run format

- uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8

Primero ejecuta el--fixde eslint, luego el formateo de prettier, y finalmenteautofix-ci/actionhace commit directo de los archivos modificados a la rama del PR. Nota quepnpm run formaten sí mismo es un comando de formateo (no necesita el--fixflag, porque el script format internamente esprettier --write)。

[Inferencia de diseño y compensaciones arquitectónicas]

La clave de este mecanismo es queautofix-ci/actionhace commit de las correcciones como el autor del PR, no como bot. Así el contribuyente no necesita operaciones adicionales, y la corrección de formato aparece automáticamente en su PR. Pero esto también significa que si la rama del contribuyente tiene reglas de protección (no permite push de bots), autofix fallará — este es un caso límite que el contribuyente debe manejar manualmente.

Diagrama de flujo de datos de size-report

mermaid
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

---

Reflexión de diseño: solidificar las normas en el pipeline

Revisando estos cuatro workflows, se pueden ver varios principios de diseño que atraviesan todo.

Primero, minimización de permisos. ci.ymlyautofix.ymlambos declaranpermissions: contents: read, solorelease.ymlnecesitacontents: writeyid-token: write。size-report.ymlnecesitapull-requests: writeyissues: writepara comentar. Cada workflow solo obtiene los permisos que realmente necesita.

Segundo, seguridad de la cadena de suministro.Todas las actions de terceros están fijadas a commit SHA, no a tags flotantes.size-report.ymlLa anotación en L81 indica directamente que tras el bloqueo del repositorio original de la action se cambió a un repositorio de respaldo con commit fijado — esto es defensa práctica contra ataques a la cadena de suministro.

Tercero, separación de responsabilidades y reutilización. test.ymles compartido porci.ymlyrelease.yml, evitando duplicación de lógica de pruebas.size-data.ymlysize-report.ymlestán separados, permitiendo que medición y reporte evolucionen independientemente.

Cuarto, elección de la dirección del fallo. size-report.ymlElif_no_artifact_found: warnde elige «advertir en lugar de fallar», porque la falta de datos históricos no debería bloquear el PR. Mientras que elrelease.ymldeneeds: [test]elige «fallo de prueba bloquea el release», porque el release es una operación irreversible.

Quinto, diferenciación del control de concurrencia.El evento PR cancela ejecuciones antiguas (cancel-in-progress: true), el evento push no cancela (cancel-in-progress: false). Esta diferencia refleja la semántica de ambos eventos: los commits antiguos del PR ya no tienen sentido, cada commit del push puede ser el estado final.

---

Resumen del capítulo

Este capítulo analizó los cuatro workflows principales del repositorio Vue core:

  • ci.yml: puerta de PR + pre-release continuo. Medianteifcondiciones que distinguen push/PR y fork/mismo repositorio, usandoconcurrencypara cancelar ejecuciones obsoletas de PR, usandopkg-pr-newpara publicar paquetes pre-release instalables.
  • release.yml: Publicación oficial activada por tag. Tres capas de protección (verificación del repositorio, needs test, aprobación del environment) garantizan que solo los tags que hayan pasado las pruebas y hayan sido aprobados puedan publicarse en npm.
  • size-report.yml: Informe de regresión de tamaño entre workflows. Medianteworkflow_runeventos se escucha el flujo ascendentesize datacompletado, se descarga el artifact y se comparan los datos con la rama base, retroalimentando al PR en forma de comentario.
  • autofix.yml: Corrección automática de formato. Se ejecutan eslint --fix y prettier en el PR, y medianteautofix-ci/actionse envían las correcciones directamente de vuelta a la rama del PR.

Estos cuatro workflows juntos constituyen una «pipeline que no se puede eludir»: el estilo del código se corrige automáticamente con autofix, los tipos y las pruebas se verifican obligatoriamente con ci.yml, la regresión de tamaño se rastrea con size-report, y la publicación se ejecuta con release.yml bajo múltiples capas de protección.

Reflexiones y autoevaluación de este capítulo

Q1: Si se cambia el valor deci.ymlencancel-in-progresspara que sea siempretrue(es decir, se elimina la condición degithub.event_name == 'pull_request'), ¿en qué escenarios causaría problemas?

Análisis de referencia:cancel-in-progressQue sea siempretruesignifica que, al hacer push a la rama main, un nuevo push cancelará la CI antigua que esté en ejecución. Considérese este escenario: en la rama main se fusionan dos PR consecutivamente, la CI del primer PR está en ejecución (incluyendo lint/typecheck/test completos), y la fusión del segundo PR activa una nueva ejecución de CI. Sicancel-in-progressestrue, la CI del primer PR será cancelada; pero el código del primer PR ya está en main, y su resultado de CI es crucial para juzgar el estado de salud de la rama main. Cancelarla significa que en la rama main hay un fragmento de código que nunca fue verificado por completo. Y la condición📎 .github/workflows/ci.yml:22-22github.event_name == 'pull_request'precisamente evita este problema: solo los eventos de PR cancelan ejecuciones antiguas, los eventos de push nunca cancelan.

Q2: release.ymlEnrelease, ¿qué escenarios defienden respectivamenteif: github.repository == 'vuejs/core'yenvironment: Releasedel job? ¿Qué pasaría si se elimina uno de ellos?

Análisis de referencia:if: github.repository == 'vuejs/core' 📎 .github/workflows/release.yml:14defiende el escenario de fork. Si alguien hace fork de vuejs/core y hace push de un tagv3.99.0, sin esta condición, el workflow se ejecutaría en el repositorio forkpnpm release --publishOnly. Aunque el repositorio fork no tiene token de npm y no puede publicar realmente, desperdiciaría recursos del runner y podría generar notificaciones de fallo engañosas.environment: Release 📎 .github/workflows/release.yml:21defiende el riesgo de «publicación automática tras el push del tag»: permite configurar una aprobación manual, asegurando que incluso si se hace push del tag, la publicación requiera la confirmación del mantenedor. Si se elimina la condiciónif, el fork desperdiciaría recursos; si se eliminaenvironment, cualquiera con permiso para hacer push de tags podría activar la publicación, sin el paso final de confirmación humana. Ambos son defensas de niveles distintos y no pueden sustituirse entre sí.

Q3: size-report.ymlEnif_no_artifact_found: warn, la elección derelease.ymly enneeds: [test], la elección de

, ¿qué filosofía de diseño de dirección de fallo reflejan respectivamente? ¿Qué pasaría si se intercambiaran ambas estrategias?:if_no_artifact_found: warn 📎 .github/workflows/size-report.yml:69Análisis de referenciafailelige «advertir en lugar de fallar cuando faltan datos históricos», porque el informe de tamaño es información auxiliar, no una condición de bloqueo. Si se cambiara aneeds: [test] 📎 .github/workflows/release.yml:15, entonces las ramas nuevas o los PR en su primera ejecución fallarían por no encontrar datos base, lo cual es claramente irrazonable.

---

elige «bloquear la publicación si fallan las pruebas», porque la publicación es una operación irreversible y debe garantizar la calidad del código. Si se intercambiaran —size-report fallando cuando faltan datos, release publicando aunque fallen las pruebas—, lo primero causaría una gran cantidad de falsos positivos que bloquearían PR normales, y lo segundo haría que código no probado llegara a npm. Esto refleja el principio de diseño de dirección de fallo de «flexible con la información auxiliar, estricto con las operaciones irreversibles».scripts/size-report.jsEl próximo capítulo profundizará en el núcleo del mecanismo de presupuesto de tamaño:usage-sizecómo se analizan los datos de tamaño, cómo se calcula el incremento, cómo se formatea la salida, y la filosofía de medición de

—por qué Vue elige medir el «tamaño de uso real» en lugar del «tamaño completo del paquete».scripts/size-report.jsDesde la puerta de acceso del PR hasta la publicación por tag, los cuatro archivos de workflow juntos constituyen una cadena de guardianes automatizados que no se puede eludir. Pero la pipeline puede bloquear fusiones solo si dispone de criterios de juicio cuantificables. El próximo capítulo se centrará en la gobernanza ingenieril de Vue sobre el tamaño del paquete como métrica central:scripts/usage-size.jscómo calcular el tamaño gzip de cada artefacto y compararlo con la línea base,

Convierte cualquier código en un libro comprensible

¿Disfrutaste este capítulo? Convierte tu código privado en un libro

Arquitectura local-first con Tauri 2 + Rust. 100% offline y seguro, sin subir código. Lectura en panel dual con anclajes de commit inmutables.

⚡ Tauri 2 · Rust Core · 100% Privado y Offline · Probado en +1M líneas

CHAPTER 11

Capítulo siguiente: Capítulo 11 →

Upstream: vuejs/core · Commit @4ab865a8 · Progreso: Capítulo 11 de 14

En el capítulo anterior vimos cómo Vue utiliza GitHub Actions para convertir lint, verificación de tipos, pruebas y seguimiento de tamaño en un pipeline imposible de eludir, donde size-report.yml y size-data.yml se encargan de dejar datos de tamaño tras cada cambio. Pero el pipeline solo ejecuta; lo que realmente responde «cuánto ha crecido y dónde» son los dos scripts que desglosaremos en este capítulo. La contradicción central del presupuesto de tamaño radica en que: el tamaño del paquete es una métrica que solo se puede percibir, pero es difícil de atribuir con precisión. Cuando los usuarios se quejan de que «Vue es demasiado grande», los mantenedores necesitan responder tres preguntas: ¿cuánto ha crecido? ¿dónde ha crecido? ¿este cambio lo ha hecho más grande? scripts/size-report.js se encarga de la comparación, scripts/usage-size.js se encarga de la atribución, y juntos constituyen la filosofía de medición del presupuesto de tamaño.

11.1 size-report: convertir las diferencias de tamaño en una tabla Markdown legible

Modelo intuitivo

Imagina que eres un inspector de calidad en una empresa de logística. Cada paquete (artefacto de compilación) debe pesarse antes de salir del almacén, y tu trabajo no es pesar en sí, sino colocar «el peso de hoy» y «el peso de ayer» en una tabla, marcando en negrita+2.3 kBqué paquetes han aumentado de peso. Sin esta tabla comparativa, los mantenedores solo verían un montón de números aislados, incapaces de determinar si un PR ha introducido una regresión de tamaño.

size-report.jses precisamente ese inspector de calidad. No produce datos de tamaño (eso es tarea deusage-size.jsy los scripts de compilación), solo consume los archivos JSON de dos directorios y genera un informe Markdown.

Estructura de datos y convención de directorios

La convención central del script está oculta en dos constantes. El directorio de datos actual estemp/size, y el directorio de línea base histórica estemp/size-prev。

📎 scripts/size-report.js:23-24

La denominación de estos dos directorios no es arbitraria:temp/sizees generado porsize-data.ymlel flujo de trabajo en cada ejecución y subido como artifact📎 .github/workflows/size-data.yml:53-57, mientras quetemp/size-preves obtenido porsize-report.ymltras descargar el artifact de línea base y descomprimirlo. El nombre del directorio en sí mismo es el contrato del flujo de datos.

El script define tres alias de tipo que describen con precisión la estructura de los archivos JSON:

📎 scripts/size-report.js:8-21

SizeResulttiene tres campos numéricos:size(sin comprimir),gzip、brotli。BundleResultsobre esta base añade el campofilepara mostrar el nombre del archivo.UsageResultes unRecord, donde la clave es el nombre del preset y el valor esSizeResult & { name: string }—observa que aquí hay un campo adicionalname, porque las claves del objeto JSON se pierden después deObject.values, por lo que el nombre debe almacenarse redundantemente en el valor.

Step-by-Step Walkthrough

El flujo principal es extremadamente simple, solo dos pasos más una salida:

📎 scripts/size-report.js:23-38

run()primero llama arenderFiles()para renderizar la tabla de archivos de artefactos, luego llama arenderUsages()para renderizar la tabla de escenarios de uso, y finalmente escribe la cadena acumulada en la variable a nivel de módulooutputde una sola vez en stdout📎 scripts/size-report.js:25. Este patrón de «acumular cadenas y luego emitirlas de una vez» evita la sobrecarga de múltiples concatenaciones deprocess.stdout.writey también hace que el orden de salida sea completamente controlable.

Primer paso: recopilar la lista de archivos y calcular la unión.

📎 scripts/size-report.js:44-49

filterFilesfiltra dos tipos de archivos: los que comienzan con_(como_usages.json) y los que terminan con.txt(comonumber.txt、base.txt). Estos dos tipos de archivos son metadatos, no datos de tamaño. Luego toma la unión de los nombres de archivo del directorio actual y del directorio históricofileList—usandoSetpara eliminar duplicados. ¿Por qué tomar la unión? Porque un archivo puede existir solo en el directorio histórico (este build eliminó ese artefacto), o puede existir solo en el directorio actual (este build añadió un nuevo artefacto). Ambas situaciones deben reflejarse en el informe.

Segundo paso: comparación archivo por archivo.

📎 scripts/size-report.js:43-75

Para cada archivo en la unión, intenta importar el JSON desde ambos directorios.importJSONLa implementación de

📎 scripts/size-report.js:112-115

es «devuelve undefined si el archivo no existe»:import()Aquí se usawith: { type: 'json' }dinámico junto con la aserción de importaciónfs.readFileSync + JSON.parse, en lugar deimport(). El primero es manejado por el cargador de módulos de Node, el segundo requiere manejo manual de errores de codificación y análisis. El costo de elegirrenderFileses que devuelve una Promise, por lo que todo

es async.if (!curr)La rama clave está en~~fileName~~: si el directorio actual no tiene este archivo, significa que el artefacto ha sido eliminado, y se marca📎 scripts/size-report.js:60-61con la sintaxis de tachado de MarkdowngetDiff. De lo contrario, se renderiza una línea normal, concatenando el resultado de

después de cada valor numérico.

📎 scripts/size-report.js:124-130

getDiffTercer paso: calcular la diferencia.prev === undefinedtiene tres puntos de retorno anticipado:diff === 0devuelve cadena vacía cuando (no hay línea base, no se puede comparar);prettyBytes(diff)devuelve cadena vacía cuando (sin cambios, no mostrar ruido); de lo contrario devuelve la diferencia con signo en negrita. Observa que-1.2 kBmaneja correctamente los números negativos, produciendo formas comosign, mientras que la variable+。

solo añade

📎 scripts/size-report.js:80-103

renderUsagescuando es positivo.renderFilesCuarto paso: renderizar la tabla de usage._usages.jsonLa diferencia estructural entreObject.values(curr)yprev?.[usage.name]merece atención: importa directamentename, porque los datos de usage existen fijamente en este único archivo..filter(usage => !!usage)convierte el Record en array y luego busca los datos históricos por nombre mediantemap—esta es precisamente la razón por la que el campo

se almacena redundantemente.markdown-tableEsta línea es en realidad redundante, porque📎 scripts/size-report.js:72-74。

mermaid
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)"]

Finalmente usa la biblioteca

para renderizar el array bidimensional como una tabla Markdown

Copiarimport()Reflexiones de diseño y trampasreadFileSync?〔Inferencia de diseño y compensaciones arquitectónicas〕import()¿Por qué usar

filterFilesen lugar defile[0] !== '_'dinámicoLa aserción de importación para JSON es la práctica estándar en Node 20+, que maneja naturalmente la carga de JSON en entornos ESM. El costo es que no se puede usar en contextos síncronos, y cada importación es cacheada por el módulo —pero en este script de una sola ejecución, el caché no es un problema.readdirLa comprobaciónfile[0]deundefined,undefined !== '_'.

Manejo de la eliminación de artefactos.Cuando se elimina un artefacto, el informe lo marca con tachado en lugar de eliminarlo directamente. Esto es un diseño intencional: los mantenedores necesitan ver «este archivo desapareció», en lugar de que desaparezca silenciosamente de la tabla. Si se filtrara directamente, los lectores pensarían erróneamente que ese artefacto nunca existió.

11.2 usage-size: simular el escenario de importación de un usuario real

Modelo intuitivo

size-reportTe dice «cuán grande es el paquete completo», pero eso no responde a la pregunta que realmente le importa al usuario: «si solo usocreateApp, ¿cuánto código necesito descargar realmente?». El tamaño del paquete completo incluye una gran cantidad de código que probablemente nunca usarás (comodefineCustomElement、Transition、KeepAlive)。usage-size.jsEl rol de es interpretar a un «usuario típico»: escribir un archivo de entrada virtual que solo importe una API específica, empaquetarlo con Rollup y ver cuán grande es el artefacto final.

Esto es como si un restaurante no te dijera «el peso total de todos los ingredientes en la cocina es de 50 kilogramos», sino «si pides un pollo Kung Pao, los ingredientes que realmente se usan son 300 gramos».

Estructura de datos: array de Preset

La estructura de datos central del script es el arraypresets, cada elemento describe un escenario de uso:

📎 scripts/usage-size.js:27-55

PresetEl tipo tiene tres campos:name(nombre para mostrar),imports(lista de APIs importadas desde Vue), opcionalreplace(reemplazos adicionales en tiempo de compilación). Los cinco presets cubren escenarios de uso desde el mínimo hasta el máximo:

  • createApp (CAPI only): solo importacreateApp, y reemplaza__VUE_OPTIONS_API__por'false', simulando un usuario de API de composición pura📎 scripts/usage-size.js:35-40
  • createApp: solo importacreateApp, conservando Options API📎 scripts/usage-size.js:35-40
  • createSSRApp: escenario SSR📎 scripts/usage-size.js:35-40
  • defineCustomElement: escenario de Web Components📎 scripts/usage-size.js:35-40
  • overall: importa seis APIs principales, simulando un usuario «full-featured»📎 scripts/usage-size.js:44-54

El archivo de entrada se fija como el artefacto esm-bundler de runtime-only:

📎 scripts/usage-size.js:24-28

Se eligevue.runtime.esm-bundler.jsen lugar de la versión completavue.esm-bundler.js, porque la versión de runtime no incluye el compilador de plantillas y se acerca más a la situación real de los usuarios de herramientas de construcción modernas: usan SFC para precompilar plantillas y no necesitan el compilador en runtime.

Step-by-Step Walkthrough

Primer paso: generar en paralelo los bundles de todos los presets.

📎 scripts/usage-size.js:62-69

main()Se crea para cada preset una Promise degenerateBundle, ejecutándolas en paralelo conPromise.all. Aquí el paralelismo es seguro, porque cada llamada agenerateBundlees independienterollup(), sin compartir estado entre sí.

Segundo paso: construir la entrada virtual.

📎 scripts/usage-size.js:94-96

Esta es la parte más ingeniosa de todo el script. No escribe archivos temporales en disco, sino que construye un ID de módulo virtualvirtual:entry, cuyo contenido es una sentencia re-export:export { createApp } from '/absolute/path/to/vue.runtime.esm-bundler.js'. Nótese queentryes una ruta absoluta, porque Rollup necesita poder resolverla.

Tercer paso: configurar la cadena de plugins de Rollup.

📎 scripts/usage-size.js:98-121

El orden del array de plugins es crucial:

1. Personalizadousage-size-plugin:resolveIdinterceptavirtual:entryy devuelve sí mismo,loaddevuelve el contenido virtual📎 scripts/usage-size.js:101-110. Este es el patrón estándar de módulos virtuales de Rollup.

2. nodeResolve(): resuelvevue.runtime.esm-bundler.jslos imports internos de📎 scripts/usage-size.js:111。

3. replace: inyecta constantes en tiempo de compilación📎 scripts/usage-size.js:112-119。

replaceLa configuración del plugin revela el mecanismo central del artefacto esm-bundler: conserva__VUE_OPTIONS_API__、__VUE_PROD_DEVTOOLS__y otros indicadores de runtime, que son reemplazados por la herramienta de construcción del usuario. Aquí el script hace el reemplazo por el usuario:

  • process.env.NODE_ENV → "production": toma la rama de producción
  • __VUE_PROD_DEVTOOLS__ → 'false': desactiva el soporte de devtools
  • __VUE_PROD_HYDRATION_MISMATCH_DETAILS__ → 'false': desactiva los errores detallados de hydration
  • __VUE_OPTIONS_API__ → 'true': conserva Options API por defecto

Luego expande...preset.replace, permitiendo que el preset sobrescriba los valores por defecto.createApp (CAPI only)El preset precisamente usa este mecanismo para cambiar__VUE_OPTIONS_API__a'false' 📎 scripts/usage-size.js:35-40。

preventAssignment: trueEvitar reemplazarobj.process.env.NODE_ENV = xeste tipo de sentencias de asignación📎 scripts/usage-size.js:117。

Cuarto paso: generar, comprimir, medir.

📎 scripts/usage-size.js:123-134

result.generate({})Se produce el código, se tomaoutput[0].code. Luego se comprime con SWC:

📎 scripts/usage-size.js:125-130

module: trueindica que la entrada es ESM,toplevel: truepermite comprimir nombres de variables de ámbito superior. Tras la compresión se calculan tres métricas:minified.length(longitud en bytes),gzipSync(minified).length、brotliCompressSync(minified).length。

Nótese que aquí se usa la API síncrona denode:zlib, no la versión asíncrona. En un script de una sola ejecución, la API síncrona es más concisa, y la compresión en sí es una operación intensiva en CPU, por lo que la asincronía no aporta beneficios de paralelismo.

Quinto paso: salida y persistencia.

📎 scripts/usage-size.js:62-86

Los resultados se imprimen primero en la consola en formato legible para humanos, coloreando conpico.📎 scripts/usage-size.js:62-86. Luego se escriben entemp/size/_usages.json, usandoObject.fromEntriespara convertir el array de vuelta a Record, con clave el nombre del preset📎 scripts/usage-size.js:81-85。

--writeEl indicador controla si se escriben adicionalmente los bundles sin comprimir de cada preset en disco📎 scripts/usage-size.js:136-138, para depuración.

mermaid
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"]

Reflexiones de diseño y trampas encontradas

〔Inferencia de diseño y compensaciones arquitectónicas〕

¿Por qué usar módulos virtuales en lugar de archivos temporales?Los archivos temporales requieren manejar rutas, limpieza y conflictos de escritura concurrente. Los módulos virtuales mantienen el contenido de entrada en memoria, y el hookresolveId/loadde Rollup soporta naturalmente este patrón. El costo es que se debe coincidir exactamente el ID; cualquier error tipográfico hará que Rollup reporte «no se puede resolver la entrada».

replaceLa trampa depreventAssignmentenSi no se establecepreventAssignment: true,replace, el plugin también reemplazará sentencias de asignación comoprocess.env.NODE_ENV = 'x', produciendo"production" = 'x'un error de sintaxis. En el código fuente de Vue sí existen asignaciones aprocess.env.NODE_ENV(en herramientas de prueba), por lo que esta opción es necesaria.

__VUE_OPTIONS_API__Elección del valor por defecto deEl script establece el valor por defecto como'true' 📎 scripts/usage-size.js:116, en lugar de'false'. Esta es una elección conservadora: si el usuario no configura nada, Vue conservará el soporte de Options API.createApp (CAPI only)El preset lo sobrescribe explícitamente a'false', mostrando el beneficio en tamaño al desactivarlo. Esta comparación en sí misma es documentación para el usuario: decirle «cuánto se ahorra al desactivar Options API».

Semántica de fallo dePromise.allen paralelo.Si el empaquetado de cualquier preset falla,Promise.allse rechazará inmediatamente, los demás empaquetados en curso no se cancelarán (Rollup no proporciona un mecanismo de cancelación). En CI, esto significa que un fallo desperdicia el cómputo de los otros presets, pero el script en sí termina con un código de salida distinto de cero, y CI puede capturarlo correctamente.

11.3 De los datos a la puerta de control: cómo CI consume estos informes

Panorama del flujo de datos

Para entender estos dos scripts, hay que devolverlos al pipeline de CI.size-data.ymlse ejecuta al hacer push a main/minor o en un PRpnpm run size 📎 .github/workflows/size-data.yml:45, producetemp/sizedirectorio, luego se sube como artifact📎 .github/workflows/size-data.yml:53-57。

Para los PR, además escribe dos archivos de metadatos:

📎 .github/workflows/size-data.yml:47-51

number.txtalmacena el número de PR,base.txtalmacena el nombre de la rama destino. Estos dos archivos son precisamentesize-report.jsenfilterFileslos que hay que filtrar.txtarchivos📎 scripts/size-report.js:44-45. Su existencia es para que elsize-report.ymldownstream sepa «con qué línea base comparar».

Obtención y comparación de la línea base

size-report.yml(ya detallado en el capítulo anterior) el flujo de trabajo es: descargar elsize-dataartifact del PR actual, descargar el artifact de línea base de la rama destino, descomprimir la línea base entemp/size-prev, luego ejecutarsize-report.jspara generar el informe Markdown y comentarlo en el PR.

Aquí hay una restricción de diseño clave:size-report.jsen sí no se encarga de obtener la línea base, asume quetemp/size-prevya existe. Si no existe,existsSync(prevDir)devuelve false,preves un array vacío📎 scripts/size-report.js:48, todos los diff son cadenas vacías. Esto es degradación elegante: sin línea base el informe aún se genera, solo que no muestra diferencias.

Lógica de decisión de la puerta de control de tamaño

〔Inferencia de diseño y compensaciones arquitectónicas〕

Es necesario aclarar un malentendido común:size-report.jsen sí no realiza la decisión de la puerta de control. Solo genera el informe, no devuelve código de salida, no establece umbrales. La verdadera puerta de control ocurre a nivel delsize-report.ymlworkflow — puede contener un paso que analice los valores de diff del informe y haga fallar el job si superan el umbral.

Este diseño de «separación entre medición y decisión» tiene razones profundas: el script de medición debe mantenerse puro, solo encargado de producir hechos; la lógica de decisión debe estar a nivel del workflow, porque los umbrales pueden variar según versión, rama o fase de publicación. Codificar los umbrales ensize-report.jslo haría difícil de reutilizar.

Reflexiones de diseño

¿Por qué el presupuesto de tamaño necesita dos conjuntos de mediciones?El tamaño completo del paquete y el tamaño de usage responden a preguntas diferentes. El tamaño completo del paquete es el «límite superior» — te dice cuánto tendría que descargar el usuario en el peor caso. El tamaño de usage es el «valor típico» — te dice cuánto descarga realmente la mayoría de los usuarios. Solo combinando ambos se obtiene un retrato completo del tamaño. Si solo existiera el tamaño completo del paquete, los mantenedores tenderían a optimizar en exceso APIs poco usadas; si solo existiera el tamaño de usage, podrían pasarse por alto explosiones de tamaño en ciertos escenarios límite.

El significado de la doble métrica gzip y brotli.Los CDN modernos soportan brotli de forma generalizada, pero no en todos los escenarios está habilitado. Reportar ambos permite a los mantenedores evaluar «cómo es el tamaño en entornos que solo soportan gzip». brotli suele ser 15-20% más pequeño que gzip, y esa diferencia en sí misma es información valiosa.

El contrato de estabilidad del formato de datos. size-report.jsyusage-size.jsse desacoplan mediante archivos JSON.usage-size.jsescribe_usages.json,size-report.jslo lee. Los nombres de campo de este contrato (name、size、gzip、brotli) son implícitos, sin validación de schema. Siusage-size.jscambia un nombre de campo y olvida sincronizarsize-report.js, el informe mostrará datos erróneos silenciosamente. Este es el punto frágil del diseño actual.

Resumen del capítulo

Reflexiones y autoevaluación del capítulo

Q1: size-report.jsdefilterFilesfiltra los archivos que comienzan con_. Siusage-size.jsrenombra el archivo de salida de_usages.jsonausages.json, ¿qué sucedería?

Análisis de referencia:filterFilesLa condición de filtrado defile[0] !== '_' && !file.endsWith('.txt') 📎 scripts/size-report.js:44-45esusages.json. Si el archivo se renombra a_, ya no comienza confilterFiles, será retenido porfileListy entrará en la unión derenderFiles. LuegoimportJSONintentará tratarlo como archivo bundle:Record<string, UsageResult>puede importarlo con éxito (es JSON válido), pero su estructura esBundleResulten lugar decurr?.file, por lo queundefined,fileNameescurr.sizees una cadena vacía,undefined,prettyBytes(undefined)también esfilterFileslanzará error o dará salida anómala. Esto provocará el fallo en la generación del informe. La raíz del problema es que

Q2: usage-size.jsusa el prefijo del nombre de archivo como criterio para distinguir «metadatos vs datos», en lugar de usar estructura de directorios o un manifiesto explícito. Un enfoque más robusto sería poner los datos de usage en un subdirectorio, o mantener una lista explícita de archivos de metadatos.Promise.all(tasks)enreplaceejecuta en paralelo el empaquetado de todos los presets. Si la configuración de__VUE_OPTIONS_API__de algún preset omite'true', ¿qué sucedería? ¿Por qué el valor por defecto se establece en'false'?

en lugar de:replaceAnálisis de referencia__VUE_OPTIONS_API__: 'true'En la configuración del plugin...preset.replace,📎 scripts/usage-size.js:116-118es el valor por defecto, luego se expande'true'permitiendo sobrescribir'true'. Si algún preset omite la configuración, usará el valor por defecto__VUE_OPTIONS_API__, es decir, conserva el soporte de Options API, y el tamaño será mayor. Establecer el valor por defecto en'false'es una elección conservadora: refleja «el comportamiento real cuando el usuario no configura». En el artefacto esm-bundler de Vue,createApp (CAPI only)el comportamiento por defecto es conservar Options API (a menos que el usuario lo desactive explícitamente). Si el valor por defecto se estableciera en'false' 📎 scripts/usage-size.js:35-40, todos los presets sin configuración explícita mostrarían un tamaño menor, engañando al usuario haciéndole creer que «sin configurar se ahorra tamaño».

Q3: size-report.jsEl preset se establece explícitamente enimportJSON, precisamente para mostrar «el beneficio tras desactivarlo explícitamente», contrastando con el valor por defecto.import()defs.readFileSyncusatemp/size-prevdinámico en lugar de

. Si algún archivo JSON en el directorioestá corrupto (JSON inválido), ¿en qué se diferencian ambos comportamientos de implementación?import()Análisis de referenciaSyntaxError: elimportJSONdinámico lanzaexistsSyncal parsear JSON inválido, y este error no puede ser capturado por la comprobación interna deexistsSyncSolo verifica si el archivo existe, no valida la legalidad del contenido📎 scripts/size-report.js:112-115. El error se propaga hacia arriba hastarenderFiles, lo que provoca que falle toda la generación del informe. Si se usafs.readFileSync + JSON.parse, también lanzará un error, pero se puede envolver dentro deimportJSONcon try-catch, devolviendoundefinedpara lograr una degradación elegante. La implementación actual opta por dejar que el error se propague, con la suposición implícita de que «el JSON en el artifact siempre es válido» — esta suposición normalmente se cumple en entornos de CI, porque los archivos son generados porusage-size.jsy los scripts de compilación. Pero al depurar localmente, si se modifica manualmente el archivo JSON y se corrompe, el informe fallará directamente en lugar de omitir ese archivo. Esta es una decisión de diseño de «confiar en la fuente de datos».

---

El mecanismo de presupuesto de tamaño resuelve los problemas de «qué medir» y «cómo comparar», pero depende de una premisa: que el artefacto de compilación en sí sea reproducible. El siguiente capítulo entrará en el sandbox de depuración mínimo:vite-debugcómo iniciar un entorno de desarrollo Vue interactivo con la mínima configuración, y cómo se vincula con los artefactos de compilación locales, formando un ciclo cerrado desde la modificación del código fuente hasta la verificación en tiempo de ejecución.

Hasta aquí, el ciclo de medición del presupuesto de tamaño ya está claro: size-report.js responde con la comparación de directorios a «cuánto ha crecido», usage-size.js simula escenarios reales de importación con módulos virtuales para responder a «dónde ha crecido», y la determinación del umbral se deja a la capa de flujo de trabajo. Este mecanismo convierte la regresión de tamaño de una queja vaga en datos trazables. Pero los datos solo te dicen que el problema existe; para localizarlo y corregirlo realmente, se necesita un entorno mínimo que pueda reproducir el problema rápidamente. El siguiente capítulo entrará en packages-private/vite-debug para ver cómo Vue construye un sandbox de depuración minimalista con Vite + SFC, convirtiendo «hacer una reproducción mínima sobre el código fuente real» en una práctica diaria operable.

Convierte cualquier código en un libro comprensible

¿Disfrutaste este capítulo? Convierte tu código privado en un libro

Arquitectura local-first con Tauri 2 + Rust. 100% offline y seguro, sin subir código. Lectura en panel dual con anclajes de commit inmutables.

⚡ Tauri 2 · Rust Core · 100% Privado y Offline · Probado en +1M líneas

CHAPTER 12

Capítulo 12: Sandbox de depuración mínimo: vite-debug y el ciclo cerrado de desarrollo local

Upstream: vuejs/core · Commit @4ab865a8 · Progreso: Capítulo 12 de 14

En el capítulo anterior completamos el ciclo de medición del presupuesto de tamaño: size-report.js responde a «cuánto ha crecido», usage-size.js responde a «dónde ha crecido», y la capa de flujo de trabajo se encarga de la determinación del umbral. Pero este mecanismo tiene una premisa implícita — que el artefacto de compilación en sí sea reproducible. Cuando descubres que el tamaño de algún paquete se ha inflado anormalmente, o que algún comportamiento en tiempo de ejecución no coincide con lo esperado, necesitas un entorno mínimo que pueda cargar rápidamente el código fuente local y ver el efecto inmediatamente después de modificarlo. packages-private/vite-debug es ese entorno. Solo tiene cuatro archivos, con menos de 40 líneas de código en total, pero constituye el punto de entrada de la práctica diaria de «hacer una reproducción mínima sobre el código fuente real» en el repositorio de Vue core. Este capítulo desglosará archivo por archivo la lógica de construcción de este sandbox, y explicará por qué se colocó en packages-private en lugar del directorio packages.

I. El esqueleto del sandbox:main.tsyApp.vuela cadena de montaje mínima

Modelo intuitivo

Si comparamos todo el runtime de Vue con un motor, entoncesvite-debuges un «banco de pruebas desnudo» — sin carcasa, sin panel de instrumentos, solo el cableado mínimo para que el motor arranque. Su valor no radica en la completitud funcional, sino eneliminar todas las variables de interferencia: cuando sospechas que un bug está en el sistema de reactividad o dentro del renderizador, no querrás que la complejidad del propio entorno de depuración se convierta en una fuente de ruido.

Estructura de datos y disposición de archivos

Primero veamosmain.tstodo el contenido de:

📎 packages-private/vite-debug/main.ts:4-4

ts
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

app.mount('#app')

Estas seis líneas de código son el paradigma estándar de inicio de una aplicación Vue, pero cada línea tiene un significado de ingeniería preciso en el contexto de depuración:

  • L1en elimport { createApp } from 'vue'de'vue', a qué se resuelve finalmente este identificador de módulo depende completamente de las declaraciones de dependencia devite.config.tsypackage.json. Este es el eslabón más crítico de todo el sandbox — veremos más adelante cómo se apunta al código fuente local.
  • L2elimport App from './App.vue'de@vitejs/plugin-vueactiva la cadena de compilación SFC deApp.vue: Vite registra este plugin al iniciar el dev server, y cuando el navegador solicita<script>、<template>、<style>, el plugin lo descompone en
  • L4tres módulos virtuales que se compilan por separado.createApp(App)elapp._context、app._instancede
  • L6crea la instancia de la aplicación; en este momento Vue inicializa internamenteapp.mount('#app')y otros campos principales, pero aún no se ha activado ningún renderizado.appel

deindex.htmles el verdadero interruptor de arranque: busca en el DOM el elemento contenedor con idindex.html, crea la instancia del componente raíz y activa el primer renderizado.<div id="app"></div>Nótese que aquí no hay referencia a<script type="module" src="/main.ts"></script>— la convención de Vite es que elapp.mount('#app')en el directorio raíz del proyecto sirva como HTML de entrada, que contiene

y

. Aunque este archivo no está en los keyFiles de este capítulo, es la premisa para queApp.vuefuncione correctamente.

📎 packages-private/vite-debug/App.vue:4-8

vue
<script setup>
import { ref } from 'vue'

const count = ref(0)
</script>

<template>
  <button @click="count++">{{ count }}</button>
</template>

<style>
button {
  color: red;
}
</style>

Ahora veamos, que es el «vehículo experimental» de este sandbox:

Copiar

@vitejs/plugin-vueSituémonos en un escenario concreto:App.vueCuando el usuario hace clic en el botón en el navegador, ¿qué sucede?

  • <script setup>Primer paso: fase de compilación SFC (al iniciar el dev server)setup()compilaref(0)en tres partes:RefImplel bloque.valuese compila en la función0。
  • <template>del componente,{{ count }}la llamada devuelve un objeto_toDisplayString(count.value),@click="count++"cuyoonClick: $event => (count.value++)。
  • <style>inicialmente es<style>el bloque

se compila en la función de renderizado,app.mountse convierte en

createApp(App)se convierte enmount('#app'), se crea el componente raízComponentInternalInstance, se ejecutasetup()para obtenercountel RefImpl de, y luego se llama a la función de renderizado para generar el árbol VNode. En la función de renderizado, leercount.valueactivatrackla recolección de dependencias: el efecto de renderizado actualmente activo (ReactiveEffect) se registra encountdedep.

Tercer paso: evento de clic (durante la interacción del usuario)

El navegador activa el eventoclick, y el manejador de eventos de Vue ejecutacount.value++. Esta es una operación setter que activatrigger: recorre los efectos recolectados encount.depy programa una nueva renderización. Como es una actualización síncrona y no está en la cola por lotes, el efecto de renderizado se ejecuta inmediatamente, se vuelve a llamar a la función de renderizado, se genera un nuevo VNode, se hace diff con el VNode antiguo, se detecta que el contenido de texto cambió de0a1, y se actualiza eltextContent。

del DOM real. Toda la cadena se puede representar con el siguiente diagrama de flujo de datos:

mermaid
flowchart LR
    subgraph compile["编译期 (Vite Dev Server)"]
        sfc["App.vue"] -->|"@vitejs/plugin-vue"| script["setup() 函数"]
        sfc -->|"@vitejs/plugin-vue"| render["渲染函数"]
        sfc -->|"@vitejs/plugin-vue"| style["CSS 模块"]
    end
    subgraph runtime["运行时 (浏览器)"]
        script -->|"ref(0)"| refimpl["RefImpl { value: 0 }"]
        render -->|"读取 count.value"| track["track() 收集依赖"]
        click["用户点击"] -->|"count.value++"| trigger["trigger() 触发更新"]
        trigger -->|"调度渲染副作用"| rerender["重新执行渲染函数"]
        rerender -->|"diff + patch"| dom["更新真实 DOM"]
    end
    track -.->|"dep 记录 ReactiveEffect"| trigger

La clave de este diagrama es:Los únicos dos puntos de acoplamiento entre los artefactos de tiempo de compilación y el comportamiento en tiempo de ejecución son——ref(0)el objeto RefImpl devuelto, y la lectura/escritura decount.valueen la función de renderizado. Esto significa que si quieres depurar una rama del sistema de reactividad (por ejemplo,triggerla lógica de programación en), solo necesitas construir el patrón de lectura/escritura correspondiente en esteApp.vue.

Reflexión de diseño: por qué esrefy noreactive?

〔Inferencia de diseño y compensaciones arquitectónicas〕

Elegirref(0)en lugar dereactive({ count: 0 })como ejemplo predeterminado implica una consideración de prioridad de depuración:refla ruta de acceso a.valuedeRefImples más corta; al expandir el objeto_value、dep、__v_isRefen el depurador se pueden ver directamente campos internos comoreactive, mientras que expandir el objeto Proxy devuelto por

---

en la consola activa el getter, lo que puede interferir con la observación del estado original. Para escenarios de "reproducción mínima", reducir una capa de indirección Proxy significa menos variables.vite.config.tsDos, resolución de alias:package.jsony'vue'cómo apuntan

al código fuente local

vite.config.tsModelo intuitivoimport { createApp } from 'vue'solo tiene seis líneas, pero es el "centro de enrutamiento" de todo el sandbox: determina si el'vue'enApp.vuefinalmente carga la versión publicada en npm o el código fuente en desarrollo en el repositorio. Si la configuración de alias no es correcta, el código que modificas en

puede que ni siquiera active la copia del código fuente de Vue que estás depurando, y la depuración se convierte en "dispararle al objetivo equivocado".

Estructura de datos y cadena de resoluciónvite.config.ts:

📎 packages-private/vite-debug/vite.config.ts:4-6

ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
})

CopiarAquíresolve.aliasno tiene una configuración explícita de. Entonces, ¿cómo se resuelve'vue'al código fuente local? La respuesta está enpackage.json:

📎 packages-private/vite-debug/package.json:1-15

json
{
  "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:*"
  }
}

La clave está enL13:"vue": "workspace:*". Esta es la declaración del protocolo pnpm workspace, que indica quevite-debugdepende del paquete local llamadovueen el monorepo, no de la versión en el registro de npm. pnpm creará un enlace simbólico ennode_modules/vue, apuntando apackages/vue(el directorio del paquete principal de Vue).

Pero esto no es suficiente:packages/vueel campopackage.jsonenmain/module/exportsdenormalmente apunta aartefactos de compilacióndist/vue.runtime.esm-bundler.js(comosrc/), no al código fuente bajopackages/runtime-core/src/renderer.ts. Si modificasdist, pero no reconstruyes, Vite seguirá cargando el archivo

antiguo.

〔Inferencia de diseño y compensaciones arquitectónicas〕packages/vue/package.jsonPor eso el"development"del repositorio Vue core normalmente configuraresolve.conditionsexportaciones condicionales o un mapeo similar de entrada de código fuente; en modo dev, eldevelopmentde Vite priorizará la coincidencia de la condiciónsrc/index.ts, cargando asídisten lugar devite-debug. Este mecanismo permite que

, sin configurar alias explícitamente, vea los efectos inmediatamente mediante HMR después de modificar el código fuente.import 'vue'Walkthrough guiado por escenarios: un proceso de resolución de

Sustituyendo el escenario:Cuando el servidor de desarrollo de Vite recibe la solicitud del navegador paramain.ts, al encontrarimport { createApp } from 'vue', ¿cómo es la cadena de resolución?

mermaid
flowchart TD
    req["浏览器请求 /main.ts"] --> parse["Vite 解析 import 'vue'"]
    parse --> resolve{"resolve 条件匹配"}
    resolve -->|"development 条件命中"| src_entry["packages/vue/src/index.ts"]
    resolve -->|"仅 production 条件"| dist_entry["packages/vue/dist/vue.runtime.esm-bundler.js"]
    src_entry -->|"源码模块图"| hmr["HMR 监听 src/ 变更"]
    dist_entry -->|"预构建产物"| no_hmr["无源码级 HMR"]
    hmr -->|"修改 renderer.ts"| reload["浏览器热更新"]
    no_hmr -->|"修改 renderer.ts"| stale["仍加载旧产物"]
    reload --> verify["验证行为变更"]
    stale --> rebuild["需手动重新构建"]
    rebuild --> verify

Este diagrama de flujo revela una rama clave:Si la condicióndevelopmentno está configurada correctamente, después de modificar el código fuente el navegador no se actualizará en caliente, y caerás en la confusión de "cambié el código pero el comportamiento no cambió". El método de diagnóstico es revisar en el panel Network de DevTools del navegador la ruta de carga real del módulovue; si ves la rutadist/, significa que el mapeo de entrada de código fuente no está funcionando.

Reflexión de diseño: por qué no escribir alias explícitamente envite.config.ts?

〔Inferencia de diseño y compensaciones arquitectónicas〕

Una pregunta natural es: por qué no escribir directamentevite.config.tsenresolve: { alias: { vue: '../../packages/vue/src/index.ts' } }? Aunque esto es intuitivo, tiene dos problemas:

1. Rompe las importaciones de subrutas: la API pública de Vue incluye subrutas comovue/server-renderer、vue/compiler-sfc. Si solo se alias'vue'en sí mismo, las importaciones de subrutas seguirán usandodist, lo que hará que algunos módulos provengan del código fuente y otros del artefacto, con comportamiento inconsistente.

2. Omite el mecanismo de exportaciones condicionales: el campopackage.jsonenexportsde Vue ya define un mapeo completo de exportaciones condicionales (development/production/browser/node, etc.); el alias sobrescribirá este mecanismo, haciendo que el comportamiento de resolución del entorno de depuración se desvíe del entorno real del usuario.

Por lo tanto,vite-debugelige la combinación de "confiar en el protocolo workspace + exportaciones condicionales", haciendo que la cadena de resolución se acerque lo más posible al escenario de uso real. Esto también explica por quépackage.jsonen"vue": "workspace:*"es necesario: es la premisa para activar el enlace simbólico de pnpm y, por lo tanto, permitir que Vite encuentrenode_modules/vuea través depackages/vue.

Errores en producción:catalog:protocolo y deriva de versiones

Observa quepackage.jsonenL11-L12usa el protocolo"catalog:":

json
"@vitejs/plugin-vue": "catalog:",
"vite": "catalog:",

Esta es una característica de catálogo de pnpm, que indica que el número de versión se gestiona de forma unificada mediante el campopnpm-workspace.yamlencatalog. Su función esevitar la deriva de versiones cuando varios paquetes del monorepo referencian la misma dependencia。

〔Inferencia de diseño y compensaciones arquitectónicas〕

En escenarios de depuración, esto trae una trampa oculta: si envite-debugEncontrar un posible bug de Vite o plugin-vue, querer actualizar temporalmente la versión para verificarlo, modificar directamentepackage.jsonencatalog:no es efectivo — necesitas modificarpnpm-workspace.yamlla definición del catálogo en, esto afectará a todos los paquetes que usan ese catálogo. La forma correcta es cambiar temporalmente a un número de versión explícito (como"vite": "5.0.0"), y después de verificar, volver a cambiar acatalog:。

---

Tres、packages-privatediseño de aislamiento: por qué el sandbox de depuración no se publica externamente

Modelo intuitivo

packages-privateEl directorio es como el "laboratorio interno" de la empresa — las muestras dentro no se venden externamente, solo se usan para pruebas y demostraciones. Está físicamente aislado depackagesdirectorio, evitando que el código de depuración se publique accidentalmente en npm.

Tres capas de garantía del mecanismo de aislamiento

Primera capa: aislamiento de directorio

packages-private/vite-debugno está bajopackages/, mientras quepnpm-workspace.yamlgeneralmente declararápackages/*ypackages-private/*ambos como miembros del workspace, pero el script de publicación (comoscripts/release.js) solo recorrerá los paquetes bajopackages/.

Segunda capa:private: true

📎 packages-private/vite-debug/package.json:3

json
"private": true,

Esta línea es una restricción obligatoria de npm/pnpm: los paquetes marcados comoprivatenunca podrán serpublicados pornpm publish, incluso si se ejecuta manualmente será rechazado. Esta es la última línea de defensa contra publicaciones accidentales.Tercera capa: sin

campoversionNota

no tienepackage.jsoncampo. La especificación de npm requiere que los paquetes publicables tenganversion, los paquetes que carecen de este campo reportarán un error alversion. Esto es "doble seguro" — incluso sinpm publishse elimina accidentalmente, la falta deprivateseguirá impidiendo la publicación.versionReflexión de diseño: división de trabajo entre el sandbox de depuración y Playground

El repositorio de Vue core ya tiene un

completamente funcional (discutido en el capítulo 7), ¿por qué todavía se necesitaSFC Playground〔Inferencia de diseño y compensaciones arquitectónicas〕vite-debug?

Las posiciones de ambos son completamente diferentes:

Dimensión

Entorno de ejecuciónSFC Playgroundvite-debug
Dentro del navegador (la compilación también en el navegador)Node.js + navegadorCarga de código fuente
A través de CDN o artefactos precompiladosCarga directamente el código fuente localCapacidad de depuración
Limitada por el sandbox del navegadorPuede usar el depurador de Node.js, puntos de interrupciónModificación del código fuente
No soportadoSoporta HMREscenarios de aplicación
Verificar la salida de compilación, compartir reproduccionesDepurar el comportamiento interno en tiempo de ejecuciónEl valor central de

vite-debugradica enque se ejecuta en un entorno real de Node.js, puedes usarnode --inspectpara adjuntar el depurador, poner puntos de interrupción enpackages/reactivity/src/effect.ts, observarReactiveEffectel proceso de creación y programación. Esto es algo que Playground no puede proporcionar.

Problemas en producción: límites de HMR y pérdida de estado

〔Inferencia de diseño y compensaciones arquitectónicas〕

Al usarvite-debugpara depurar, una confusión común es: después de modificarApp.vueencountel valor inicial de<script setup>, el contador en el navegador no se restablece. Esto se debe a que el HMR de Vite trata los bloques decomopreservar el estado del componente, solo reemplazar la función de renderizadoApp.vue. Si necesitas restablecer completamente el estado, necesitas actualizar manualmente la página, o agregarimport.meta.hot?.invalidate()en

para forzar una actualización completa de la página.packages/runtime-core/src/Otra trampa es: cuando modificas el código fuente bajovite-debug, la cadena de propagación de HMR puede no activarse automáticamente — porque el límite de HMR deApp.vueestá definido a nivel depackages/, y los cambios en el código fuente bajohmr updatenecesitan propagarse a través del gráfico de módulos de Vite. Si descubres que el navegador no responde después de modificar el código fuente, verifica si la salida de la terminal de Vite tiene

---

registros; si no los tiene, puede que necesites reiniciar el dev server.

packages-private/vite-debugResumen del capítulo

1. main.tsCon cuatro archivos y menos de 40 líneas de código, se construyó un ciclo completo de depuración:createApp(App).mount('#app')Proporciona la cadena de montaje mínima:

2. App.vue, excluyendo toda lógica de inicialización no necesaria.refComo vehículo de experimentación:

3. vite.config.ts + package.json+ interpolación de plantillas + manejo de eventos, cubriendo la ruta principal del sistema reactivo.workspace:*A través de'vue'protocolo y exportaciones condicionales, resuelve

4. packages-private + private: trueal código fuente local, logrando "modificar el código fuente y que surta efecto".version+ sin

aislamiento de tres capas, asegurando que el código de depuración no se publique accidentalmente.La filosofía de ingeniería de este sandbox es:La complejidad del entorno de depuración en sí misma debe tender a cero, dejando toda la complejidad al código fuente que se está depurandopackages/reactivity. Cuando encuentras un bug difícil de reproducir envite-debug,

proporciona una mesa de experimentación que puedes modificar libremente y verificar inmediatamente.

Reflexión y autoevaluación del capítulopackage.jsonQ1: Si cambias"vue": "workspace:*"en"vue": "^3.4.0"avite-debug, después de modificarpackages/reactivity/src/ref.tsen

, ¿qué cambios ocurrirán en el comportamiento del navegador? ¿Por qué?Análisis de referencia"^3.4.0": Después de cambiar apackages/vue 📎 packages-private/vite-debug/package.json:13, pnpm descargará la versión publicada de Vue 3.4.x desde el registro de npm, en lugar de enlazar alimport { createApp } from 'vue'local. En este momentonode_modules/.pnpm/vue@3.4.x/node_modules/vue/dist/vue.runtime.esm-bundler.jsresuelve apackages/reactivity/src/ref.ts, es decir, el artefacto precompilado. Modificarrefno activará ningún HMR, porque el gráfico de módulos de Vite simplemente no incluye este archivo. Lo que se ejecuta en el navegador sigue siendo la implementación deworkspace:*de la versión de npm. Este experimento verifica inversamente que

Q2: App.vuees una condición necesaria para la depuración a nivel de código fuente.<style>El bloque descopedenvite-debugno tiene agregado

, si en este sandbox se montan dos instancias de componente simultáneamente, ¿qué sucederá con los estilos? ¿Qué relación tiene esto con el objetivo de depuración de?scopedAnálisis de referenciabutton { color: red }: Sin📎 packages-private/vite-debug/App.vue:4-8,<button>es un estilo globalvite-debug, actuará sobre todos losscopedelementos de la página. Si se montan dos instancias de componente, los botones de ambas instancias se volverán rojos. La relación con el objetivo de depuración radica en:data-v-xxxLa posición descopedes "reproducción mínima", no "verificación de aislamiento de estilos". Omitirscopedreduce las variables de inyección de atributos@vitejs/plugin-vueen tiempo de compilación, haciendo que la estructura del DOM en el depurador sea más limpia. Si necesitas depurar la lógica de compilación de estilos de

, deberías agregar explícitamentepackages/runtime-core/src/renderer.tsy observar el código de inyección de atributos generado porpatch.console.logQ3: Supongamos que agregas una línea

en la función:

de, pero la consola del navegador no muestra salida. Enumera al menos tres posibles causas y explica cómo investigarlas una por una.。'vue'Análisis de referenciadistCausa uno:src. Diagnóstico: en el panel Network de DevTools, revisa lavueruta de carga del módulo; si comienza condist/, significa que la exportación condicional no coincidió con ladevelopmentcondición📎 packages-private/vite-debug/package.json:13。

Causa dos:HMR no se propagó. El grafo de módulos de Vite no propagó los cambios depackages/runtime-core/src/renderer.tsavite-debug. Diagnóstico: revisa si en la terminal de Vite aparece el loghmr update; si no aparece, reinicia el dev server.

Causa tres:patchla función no fue llamada. Si la página actual no dispara ninguna actualización del DOM (por ejemplo, no se hizo clic en ningún botón),patchpuede ejecutarse solo una vez en el primer montaje, y ese primer montaje ocurrió antes de que agregarasconsole.log. Diagnóstico: recarga la página o agrega enApp.vueuna acción que dispare una actualización.

Causa cuatro (complementaria):caché de build. La caché de preconstrucción de dependencias de Vite (node_modules/.vite) podría seguir usando la versión antigua. Diagnóstico: eliminanode_modules/.vitey reinicia.

---

El presupuesto de tamaño te dice «el problema existe»,vite-debugte permite «reproducir el problema con tus propias manos». Pero cuando intentas extender este modo sandbox a todo el monorepo, te encuentras con una serie de condiciones límite: diferencias de resolución del protocolo workspace en entornos CI,catalog:el dilema de actualización con versiones bloqueadas,packages-privatey las restricciones de dirección de dependencias entrepackagesy

... El siguiente capítulo entrará en el análisis de compromisos arquitectónicos y la guía para evitar trampas, y sistematizará las condiciones límite que la ingeniería de monorepos expone en proyectos reales.

Convierte cualquier código en un libro comprensible

¿Disfrutaste este capítulo? Convierte tu código privado en un libro

Arquitectura local-first con Tauri 2 + Rust. 100% offline y seguro, sin subir código. Lectura en panel dual con anclajes de commit inmutables.

⚡ Tauri 2 · Rust Core · 100% Privado y Offline · Probado en +1M líneas

CHAPTER 13

Capítulo 13: Compromisos arquitectónicos y guía para evitar trampas: condiciones límite de la ingeniería de monorepos

Upstream: vuejs/core · Commit @4ab865a8 · Progreso: Capítulo 13 de 14

En el capítulo anterior tomamospackages-private/vite-debugcomo punto de entrada y dominamos el paradigma de depuración para hacer reproducciones mínimas sobre código fuente real. Cuando este tipo de paquetes de depuración interna se multiplica, surge un problema práctico: conviven en el mismo workspace con los paquetes formales publicados externamente, ¿cómo garantizar que el flujo de publicación no los dañe por error? Este capítulo profundizará en las condiciones límite de la ingeniería de monorepos, partiendo del contrato de doble directorio entrepackagesypackages-private, analizará el diseño defensivo detrás de los compromisos arquitectónicos y ofrecerá una guía práctica para evitar trampas.

13.2 Regla temporal inquebrantable: la inserción en línea de enums debe ejecutarse antes que Rollup

Modelo intuitivo

La inserción en línea de enums es como «cambiar las etiquetas de las piezas por números antes de embalarlas». Si el operario de embalaje (Rollup) ya empezó a empaquetar y luego cambias las etiquetas, las piezas y las etiquetas dentro de la caja ya no coincidirán.build.jsusascanEnums() / removeCache()este par de funciones para encerrar estrictamente la inserción en línea antes de Rollup.

Estructura de datos y ciclo de vida

inline-enums.jsexportascanEnums()devuelve un cierreremoveCacheque escanea las definiciones de enum en el código fuente y genera archivos temporales para que Rollup los consuma📎 scripts/build.js:30-34。build.jsderun()usatry/finallypara garantizar la limpieza de caché📎 scripts/build.js:81-112:

js
const removeCache = scanEnums()
try {
  // ... buildAll / checkAllSizes / build-dts
} finally {
  removeCache()
}

rollup.config.jsllama en el nivel superior del módulo ainlineEnums()para obtener[enumPlugin, enumDefines] 📎 rollup.config.js:47-50, dondeenumPluginse inserta en el arreglo plugins📎 rollup.config.js:331-331,enumDefinesy se incorpora a la tabla de reemplazos del plugin replace📎 rollup.config.js:222-223。

Paso a paso: ciclo de vida completo de un enum en una compilación

1. build.jsderun()primero llama ascanEnums(), escanea las definiciones de enum de todos los paquetes y las escribe en la caché temporal, devuelveremoveCache 📎 scripts/build.js:87-87。

2. buildAlle inicia varios procesos de Rollup en paralelo📎 scripts/build.js:119-121。

3. Cada proceso de Rollup ejecuta durante la fase de carga de configuracióninlineEnums(), lee la caché generada en el paso anterior y obtieneenumPluginyenumDefines 📎 rollup.config.js:47-50。

4. enumPluginen la fase transform reemplaza las referencias a enum en el código fuente por literales;enumDefinescomo complemento de replace, maneja el reemplazo de constantes entre módulos📎 rollup.config.js:222-223。

5. Al finalizar la compilación,finallyel bloque llama aremoveCache()para limpiar los archivos temporales📎 scripts/build.js:119-121。

mermaid
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 块"]

Reflexiones de diseño y trampas

[Inferencia de diseño y compromisos arquitectónicos]

¿Por qué no usar un plugin de Rollup para escanear y usar en el momento durante la fase transform? Porque la inserción en línea de enums necesitauna vista global entre paquetes:runtime-core: el enum referenciado puede estar definido enshared, y un único proceso de Rollup solo ve el árbol de código fuente de su propio paquete, por lo que no puede completar el reemplazo entre paquetes.scanEnums()Establecer una caché global antes de la compilación es precisamente para resolver este problema de visibilidad.

Puntos de trampa en producción:removeCache()se coloca enfinally, lo que significa que se limpiará incluso si la compilación lanza un error a mitad de camino. Pero si interrumpes manualmente el proceso mientras depuras (Ctrl+C),finallypodría no ejecutarse, y los archivos de caché residuales harán que la siguiente compilación lea enums obsoletos. Método de diagnóstico: revisa si hay archivos de caché de enum residuales en el directoriotemp/, elimínalos manualmente y vuelve a intentarlo.

---

13.3 Orquestador de publicación:release.jsmatriz de flags skip de

Modelo intuitivo

release.jses como el director general de una boda,skipBuild / skipTests / skipGit / skipPromptsy los cuatro interruptores son los botones de «saltar ensayo», «saltar juramento», «saltar fotos» y «saltar confirmación». La existencia de cada botón corresponde a un escenario real: el entorno CI necesitaskipPrompts, la depuración local necesitaskipGit, el hotfix de emergencia necesitaskipTests。

Estructura de datos y valores predeterminados de los flags

Los cuatro flags skip se declaran enparseArgs📎 scripts/release.js:39-50y luego se desestructuran en variables locales📎 scripts/release.js:64-66:

js
let skipTests = args.skipTests
const skipBuild = args.skipBuild
const skipPrompts = args.skipPrompts
const skipGit = args.skipGit

Nota:skipTestsusaletdeclaración, porque está enrunTestsIfNeeded()será reescrito dinámicamente📎 scripts/release.js:281-317。

Paso a paso: el flujo completo de decisiones de un release

main()el orden de ejecución de📎 scripts/release.js:143-279:

1. Verificación de sincronización remota:isInSyncWithRemote()Compara el HEAD local con el SHA de la rama remota; si no coinciden, muestra un cuadro de confirmación📎 scripts/release.js:337-363。

2. Selección de versión: cuando no hay argumentos posicionales, se muestraversionIncrementsmenú de selección📎 scripts/release.js:152-176。

3. Decisión de pruebas:runTestsIfNeeded()es donde la lógica de skip es más densa📎 scripts/release.js:281-317。

4. Actualización de versión:updateVersions()recorre todos los paquetes y reescribepackage.json 📎 scripts/release.js:377-398。

5. Generación de Changelog: llama apnpm run changelog 📎 scripts/release.js:211-212。

6. Commit de Git:skipGitsi es verdadero, se omite todo el bloque📎 scripts/release.js:231-240。

7. Publicación: solo se ejecuta cuandoargs.publishes verdaderobuildPackages() + publishPackages() 📎 scripts/release.js:243-246。

runTestsIfNeeded()La lógica de ramas de

mermaid
flowchart TD
  entry["runTestsIfNeeded()"] --> skipFlag{"skipTests?"}
  skipFlag -->|是| done["Tests skipped"]
  skipFlag -->|否| ci["getCIResult()"]
  ci --> ciPass{"CI passed?"}
  ciPass -->|是| promptMode{"skipPrompts?"}
  promptMode -->|是| setSkip["skipTests = true"]
  promptMode -->|否| ask["prompt: Skip local tests?"]
  ask --> setSkip2["skipTests = promptSkipTests"]
  ciPass -->|否| noPrompt{"skipPrompts?"}
  noPrompt -->|是| throwErr["throw Error<br/>CI not passed"]
  noPrompt -->|否| runLocal["run('pnpm', ['run','test','--run'])"]
  setSkip --> done
  setSkip2 --> done
  runLocal --> done

Reflexiones de diseño y trampas

〔Inferencias de diseño y compensaciones arquitectónicas〕

skipTestsusaleten lugar deconstde GitHub Actions ya ejecutó las pruebas completas, y volver a ejecutarlas localmente es puro desperdicio.release.ymlya ejecutó las pruebas completas, y volver a ejecutarlas localmente es puro desperdicio.

El contrato oculto del orden de publicación:sortPackagesForPublishingcolocavueal final📎 scripts/release.js:85-85, y el comentario indica explícitamente que «el usuario no puede instalar el nuevo paquete de entrada antes de que los paquetes internos estén disponibles». Si modificas este orden, el usuarionpm install vue@nextpodría obtener una versión cuyas dependencias aún no se han publicado, provocandoERR_MODULE_NOT_FOUND。

Protección de idempotencia:publishPackagellama antes de publicar aisPackagePublishedpara verificar el registry📎 scripts/release.js:453-458, y si la publicación falla, captura el errorpreviously publishedy degrada a omitir📎 scripts/release.js:480-488. Esto permite que el script de release se reintente de forma segura: tras una interrupción de red, volver a ejecutarlo no fallará por completo debido a «el paquete ya existe».

Reversión en caso de fallo:fnToRun().catch()cuandoversionUpdatedes verdadero, llama aupdateVersions(currentVersion)para revertir el número de versión📎 scripts/release.js:528-537. Pero atención: esto solo reviertepackage.jsonel campo de versión enno revierte los commits que ya segit commithan hecho. Si la publicación falla cuandoskipGites falso, necesitas hacerlo manualmentegit reset。

---

Reflexión de diseño: el patrón común de las tres compensaciones

Si revisamos las tres compensaciones centrales de este capítulo, comparten la misma filosofía de diseño:convertir «verificaciones en tiempo de ejecución fáciles de olvidar» en «restricciones estructurales imposibles de eludir»。

  • packages-privateAislamiento físico: no depende de que el autor del script recuerde verificar el campoprivate, sino que hace que el alcance del escaneo lo excluya de forma natural.
  • Inline de enum por adelantado: no depende de que el plugin de Rollup «casualmente» pueda ver el enum entre paquetes durante el transform, sino que establece una caché global antes de la compilación.
  • release.js: no depende de que el publicador recuerde «si CI ya pasó, no hace falta ejecutar pruebas locales», sino que hace que el script consulte automáticamente el estado de CI y reescribaskipTests。
〔Inferencias de diseño y compensaciones arquitectónicas〕

El costo de este patrón esel aumento de la complejidad del script:build.jshay que mantener la listaprivatePackages,rollup.config.jshay que duplicar la lógica de detección de directorios,release.jshay que manejar la combinación cruzada de cuatro flags de skip. Pero para un repositorio como Vue que publica varias veces por semana, el beneficio de fiabilidad que aportan las restricciones estructurales supera con creces el costo de complejidad.

---

Resumen del capítulo

Este capítulo, partiendo del código fuente, desglosa tres condiciones límite clave del sistema de ingeniería de Vue core:

1. packages-privateypackagesel aislamiento físico degarantizado conjuntamente por el glob del workspace,build.jsla detección de directorios,release.jsy el filtrado de📎 pnpm-workspace.yaml:1-3📎 scripts/build.js:153-170📎 scripts/release.js:68-83。

2. la restricción temporal del inline de enumgarantizada obligatoriamente porscanEnums() / removeCache()la estructuratry/finally, y la configuración de Rollup consume la caché en el nivel superior del módulo📎 scripts/build.js:81-112📎 rollup.config.js:47-50。

3. release.jsla matriz de flags de skip desirve a tres escenarios: publicación por CI, depuración local y hotfix urgente,skipTestsy el orden de publicación son los dos contratos ocultos más fáciles de pasar por alto📎 scripts/release.js:281-317📎 scripts/release.js:85-85。

Reflexión y autoevaluación de este capítulo

Q1: Si enbuild.jsse elimina la comprobaciónbuild(target)dentro de la funciónprivatePackages.includes(target)y se usa uniformementepackagescomopkgBase, ¿en qué escenarios habría problemas?

Análisis de referencia:build.js:160-164es la única entrada por la que un paquete privado puede compilarse. Si se elimina,nr build vite-debugbuscarápackages/vite-debugbajopackage.json, pero ese directorio no existe,fs.readFileSynclanzará directamenteENOENT. El problema más oculto es: si en el futuro alguien crea un directorio con el mismo nombre bajopackages/, la compilación usará silenciosamente la configuración del directorio equivocado, y tanto la ruta del artefacto comobuildOptionsquedarán completamente desalineadas. Además,rollup.config.js:37-42tiene una lógica de detección de directorios independiente, y ambos lugares deben modificarse en sincronía; de lo contrario, aparecerá el estado inconsistente de «build.jsencontró el paquete pero Rollup no puede encontrarlo».

Q2: release.jsEnrunTestsIfNeeded()deskipTests ||= isCIPassedla línea de códigorelease.js:285) cuandoskipPromptses verdadero y CI no ha pasado, ¿qué rama tomará? Si se eliminaelse if (skipPrompts)de la ramathrow, ¿qué consecuencias habría?

Análisis de referencia: cuandoskipPromptses verdadero y CI no ha pasado,skipTests ||= isCIPassedenisCIPassedesfalse,skipTestsmantiene el valor original (normalmentefalse). Luego entra en la ramaelse if (skipPrompts)y lanzaError(release.js:299-304). Si se elimina estethrow, el código continuará ejecutándose hasta la ramaif (!skipTests)y ejecutarápnpm run test --runen un entorno sin interacción. En CI, esto puede hacer que las pruebas fallen por diferencias de entorno o, peor aún, que las pruebas pasen pero CI en realidad no haya pasado (por ejemplo, si CI ejecuta un subconjunto diferente de pruebas), publicando una versión sin validación completa.

Q3: rollup.config.js:55ElinlineEnums()se llama en el nivel superior del módulo, mientras quebuild.js:87elscanEnums()se llama dentro de la funciónrun(). Si se intercambia el momento de ejecución de ambos (es decir, hacer queinlineEnums()se llame en el hookbuildStartde Rollup), ¿qué se rompería?

Análisis de referencia:scanEnums()debe completarse antes de que se inicien todos los procesos de Rollup, porque necesita escaneartodos los paquetesel código fuente de todos los paquetes para establecer la caché global de enum.inlineEnums()se llama en el nivel superior del módulorollup.config.js, cuando Rollup aún no ha comenzado ninguna compilación y la caché ya está lista. Si se cambiara para llamarse enbuildStart, cada proceso de Rollup escanearía de forma independiente, perobuildAllse ejecuta de forma concurrente (build.js:119-121), y múltiples procesos escaneando simultáneamente el mismo lote de archivos producirían una condición de carrera: el proceso A podría leer un archivo de caché que el proceso B aún no ha terminado de escribir, provocando un reemplazo de enum incompleto. Más grave aún,scanEnums()elremoveCachedepende del estado de los descriptores de archivo en el momento del escaneo, y en escenarios concurrentes el momento de limpieza no puede coordinarse.

Contrato de doble directorio, determinación de pertenencia de scripts de compilación, filtrado secundario de scripts de publicación: estos mecanismos delimitan conjuntamente la frontera de seguridad de la ingenierización del monorepo. Pero la frontera no es inmutable: a medida que las herramientas de compilación migran de Rollup a Rolldown y las pruebas de tipos y las pruebas en tiempo de ejecución convergen, las estrategias de equilibrio actuales también enfrentarán nuevos desafíos. En el próximo capítulo, basándonos en la trayectoria de cambios de 3.0 a 3.4, proyectaremos la dirección de evolución del sistema de ingenierización de próxima generación.

Convierte cualquier código en un libro comprensible

¿Disfrutaste este capítulo? Convierte tu código privado en un libro

Arquitectura local-first con Tauri 2 + Rust. 100% offline y seguro, sin subir código. Lectura en panel dual con anclajes de commit inmutables.

⚡ Tauri 2 · Rust Core · 100% Privado y Offline · Probado en +1M líneas

CHAPTER 14

Capítulo 14: Evolución futura: de 3.x al sistema de ingenierización de próxima generación

Upstream: vuejs/core · Commit @4ab865a8 · Progreso: Capítulo 14 de 14

En el capítulo anterior revisamos la «frontera de seguridad» del sistema de ingenierización de Vue core: contrato de doble directorio, determinación de pertenencia de scripts de compilación, filtrado secundario de scripts de publicación. Estos mecanismos no fueron un diseño único, sino que se pulieron repetidamente durante las iteraciones de 3.0 a 3.4. Este capítulo adopta otra perspectiva: ya no observamos «cómo se ve ahora», sino «cómo llegó a verse así», y a partir de ello inferimos hacia dónde irá el sistema de ingenierización de próxima generación. El material fuente de este capítulo son changelogs/CHANGELOG-3.3.md, changelogs/CHANGELOG-3.4.md y el package.json en la raíz del repositorio. Los registros de cambios parecen solo una lista de «qué bugs se corrigieron», pero son el informe médico más auténtico del sistema de ingenierización: cada commit con prefijo build:, cada cambio con prefijo types:, cada reversión de versión de dependencia, exponen los puntos de tensión de la arquitectura actual. Lo que debemos hacer es leer la dirección de evolución a partir de estos puntos de tensión. Tratar los registros de cambios como una «ventana de observación del sistema de ingenierización» en lugar de una «lista de funcionalidades» es la metodología central de este capítulo. Los cambios funcionales nos dicen qué puede hacer Vue, mientras que los cambios relacionados con compilación, tipos y CI nos dicen «dónde le duele» al sistema de ingenierización de Vue.

I. Puntos de tensión de la cadena de herramientas de compilación: la energía de migración de Rollup a Rolldown

Modelo intuitivo

Imagina la cadena de herramientas de compilación como una línea de ensamblaje: Rollup es la mesa de ensamblaje principal, esbuild se encarga del corte rápido (transpilación de TS), terser se encarga del empaquetado y compresión final. A medida que el producto (el runtime de Vue) se vuelve más complejo y los procesos en la mesa de ensamblaje aumentan, la mesa de ensamblaje principal se convierte en el cuello de botella. El posicionamiento de Rolldown es reescribir la mesa de ensamblaje principal en Rust: no reemplaza a esbuild, sino a Rollup mismo.

Sin esta presión evolutiva, el «desastre» que enfrenta el sistema no es un colapso, sinoel tiempo de compilación que se expande linealmente con el número de paquetes: por cada subpaquete añadido, hay que iniciar un proceso Rollup más, escanear una vez más la caché de enum, ejecutar una ronda más de generación de dts.

Estructuras de datos y disposición de dependencias

Primero veamos una instantánea estática de la cadena de herramientas actual.package.jsonEldevDependenciesde es una «lista de mesas de ensamblaje» precisa:

📎 package.json:103-106

code
    "rollup": "^4.63.3",
    "rollup-plugin-dts": "^6.5.1",
    "rollup-plugin-esbuild": "^6.2.1",
    "rollup-plugin-polyfill-node": "^0.13.0",

Aquí se pueden leer tres hechos clave. Primero, la versión principal de Rollup es^4.63.3, en la fase madura de Rollup 4.x. Segundo,rollup-plugin-esbuildse encarga de la transpilación de TS, lo que significa que Rollup en sí no analiza TS, solo procesa el JS que esbuild emite. Tercero,rollup-plugin-dtsse encarga de forma independiente del empaquetado de.d.ts, que es precisamente la base material de la independencia dedts-built-testdiscutida en el capítulo anterior.

Ahora veamos la orquestación de entrada de los scripts de compilación:

📎 package.json:8-9

code
    "build": "node scripts/build.js",
    "build-dts": "tsc -p tsconfig.build.json --noCheck && rollup -c rollup.dts.config.js",

build-dtses «de dos etapas»: primerotsc --noCheckgenera los archivos de declaración originales (--noCheckomite la verificación de tipos, solo hace emit), luegorollup -c rollup.dts.config.jsempaqueta los.d.tsdispersos en un solo archivo. Este diseño en sí depende de las capacidades de Rollup:rollup-plugin-dtsnecesita el grafo de módulos de Rollup para rastrear dependencias de tipos.

Impulsado por escenarios: qué expuso un commit debuild:

Las entradas con prefijobuild:en los registros de cambios son evidencia directa de los puntos de tensión de la cadena de herramientas de compilación. Elijamos tres para examinar.

La primera, la alineación de configuración de minify en 3.4.32:

📎 changelogs/CHANGELOG-3.4.md:84

code
* **build:** use consistent minify options from previous terser config ([789675f](https://github.com/vuejs/core/commit/789675f65d2b72cf979ba6a29bd323f716154a4b))

La motivación de este commit es «tras migrar de terser a esbuild minify, las opciones de compresión son inconsistentes». Revela un estado intermedio en la migración: Vue solía usar terser para la compresión, luego cambió a esbuild (eldevDependenciesenesbuild: ^0.28.2lo confirma), pero las opciones de compresión no se alinearon completamente, causando desviaciones en el tamaño o comportamiento del artefacto. Este es precisamente el costo típico de «cambiar piezas de la mesa de ensamblaje».

La segunda, la reversión de versión de entities en 3.4.38:

📎 changelogs/CHANGELOG-3.4.md:6

code
* **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)

entitieses una biblioteca de decodificación de entidades HTML, de la que dependecompiler-dom. Se revirtió a 4.5 porque la nueva versión presentaba problemas en el análisis en tiempo de ejecución. Este commit demuestra:la actualización de dependencias de la cadena de herramientas de compilación no es aislada; el salto de versión de una dependencia indirecta puede penetrar hasta el comportamiento en tiempo de ejecución。

La tercera, la contaminación de la compilación cjs de server-renderer en 3.4.29:

📎 changelogs/CHANGELOG-3.4.md:155

code
* **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 es el tipo más típico de bug de compilación: en formato CJS,server-rendererincluyó accidentalmenteruntime-coreen su propio artefacto. La causa suele ser que la determinación deexternalde Rollup falla en formato CJS: ESM puede identificar estáticamente dependencias externas mediante sentenciasimport, mientras que el CJS derequireMás dinámico, propenso a pasar por alto fallos. Este commit apunta directamente a la fragilidad de la lógica en la configuración de Rollup.externalfragilidad de la lógica.

Representación Mermaid del potencial de migración

La siguiente imagen describe el flujo de control del pipeline de construcción actual y señala los nodos que la migración a Rolldown tocará:

mermaid
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["构建完成"]
〔Inferencia de diseño y compensaciones arquitectónicas〕

El valor de la migración a Rolldown radica en que reemplaza el modelo de concurrencia de "un proceso por paquete" por un modelo de "paralelismo dentro de un solo proceso",scanEnums()el escaneo global de y el reemplazo deinlineEnums()pueden coordinarse dentro del mismo runtime de Rust, y el problema de "condición de carrera en el escaneo concurrente" discutido en el capítulo anterior desaparecerá de raíz. Pero la resistencia a la migración también está aquí——rollup-plugin-esbuild、rollup-plugin-dtsestos ecosistemas de plugins necesitan que Rolldown proporcione una capa de compatibilidad, y la lógica de determinación deexternalnecesita ser reescrita.

Reflexiones de diseño y trampas encontradas

¿Por qué la migración no se logrará de la noche a la mañana?Observa el campopackage.jsondeengines:

📎 package.json:61-63

code
  "engines": {
    "node": ">=20.0.0"
  },

Node 20 es el límite inferior obligatorio. Rolldown, como módulo nativo de Rust, requiere los enlaces N-API correspondientes y distribución de binarios precompilados. Una vez introducido,pnpm installel tiempo de ejecución, la compatibilidad de binarios multiplataforma (Windows/macOS/Linux) y la estrategia de caché de CI deben rediseñarse. Esto no es tan simple como "cambiar una dependencia", sinouna recalibración completa de toda la cadena de instalación-construcción-caché。

Puntos problemáticos en producción:build-dtsEltsc --noCheckde es un arma de doble filo. Omitir la verificación de tipos acelera la emisión, pero significa que los errores de tipo no se detectarán en la fase de generación de.d.ts——los errores de tipo solo pueden ser cubiertos porpnpm check(tsc --incremental --noEmit) ytest-dts. Si después de la migración a Rolldown se quieren fusionar estos dos pasos, hay que asegurarse de que la verificación de tipos no ralentice la construcción, de lo contrario se traiciona el propósito original de--noCheck.

---

II. Tendencia de fusión entre pruebas de tipos y pruebas en tiempo de ejecución

Modelo intuitivo

Imagina las pruebas de tipos y las pruebas en tiempo de ejecución como dos controles de calidad independientes: uno verifica si "el manual (.d.ts) está bien escrito", y el otro verifica si "la máquina (el runtime) gira correctamente". Cada control tiene su propio puesto de trabajo, sus propias herramientas y sus propios informes. La tendencia de fusión significa:¿se puede hacer que el mismo caso de prueba verifique simultáneamente el manual y la máquina?

Sin la fusión, el desastre que enfrenta el sistema esderiva entre tipos y comportamiento en tiempo de ejecución:.d.tsdice queref()devuelveRef<T>, pero la forma del objeto que realmente devuelve el runtime ha cambiado; la prueba de tipos pasa, la prueba en tiempo de ejecución también pasa, pero la combinación de ambas es incorrecta.

Estructura de datos: disposición de la orquestación de scripts de prueba

package.jsonEn elscriptsde , las entradas relacionadas con pruebas se dividen claramente en dos grupos:

📎 package.json:19-24

code
    "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",

La estructura clave aquí es eltest-dtsderun-s build-dts test-dts-only——esserial: primero se construye.d.ts, luego se ejecutan las pruebas de tipos. Y dentro detest-dts-onlyhay a su vezdos procesostscindependientes: uno ejecutadts-built-test(verifica los artefactos de construcción), y otro ejecutadts-test(verifica los tipos del código fuente).

Nota quetest-unitusavitest --project unit*,test-e2eusavitest --project e2e --project e2e-browser. Esto indica que el mecanismo--projectde Vitest ya ha dividido las pruebas en diferentes proyectos según "unidad/end-to-end/navegador".La base física para la fusión ya existe: el mecanismo de proyectos de Vitest permite ejecutar diferentes tipos de pruebas en el mismo runner.

Impulsado por escenarios: la ruta completa de un commit detypes:En el changelog, la densidad de entradas con el prefijo

es extremadamente alta, lo cual es un reflejo directo de la complejidad del sistema de tipos. Rastreemos una típica corrección de tipos.types:Reversión de tipos de ref en 3.4.37:

Copiar

📎 changelogs/CHANGELOG-3.4.md:23-24

code
* 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))

Copiar

📎 changelogs/CHANGELOG-3.4.md:55

code
* **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

code
* **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)

las pruebas de tipos pueden verificar que "la firma de tipo cumple lo esperado", pero no pueden verificar "si esta firma de tipo es realmente útil en código real".En las pruebas de tipos puede pasar completamente, pero en uso real hará que la inferencia de tipos de。allow getter and setter types to be unrelatedsea demasiado laxa, rompiendo la seguridad de tipos del código downstream.refRepresentación Mermaid de la fusión de pruebas de tipos

La siguiente imagen describe la estructura actual de separación entre pruebas de tipos y pruebas en tiempo de ejecución, así como la forma objetivo tras la fusión:

Copiar

mermaid
flowchart LR
    subgraph current["当前:分离的两条链路"]
        src["packages/*/src/*.ts"] --> tsc_build["tsc -p tsconfig.build.json --noCheck"]
        tsc_build --> raw_dts["散落的 .d.ts"]
        raw_dts --> rollup_dts["rollup -c rollup.dts.config.js"]
        rollup_dts --> built_dts["打包后的 .d.ts"]
        built_dts --> dts_built_test["dts-built-test/tsconfig.json"]
        src --> dts_test["dts-test/tsconfig.test.json"]
        src --> vitest_unit["vitest --project unit*"]
        dts_built_test --> report_a["类型报告"]
        dts_test --> report_a
        vitest_unit --> report_b["运行时报告"]
    end
    subgraph future["融合目标:单一 runner"]
        src2["源码"] --> vitest_all["vitest --project unit --project dts"]
        vitest_all --> unified["统一报告 + 类型断言"]
    end
    current -.演进.-> future
La ruta técnica de la fusión probablemente sea: encapsular las llamadas a

ydts-built-testdedts-testcomo un proyecto personalizado de Vitest, permitiendo que las aserciones de tipos se integren en forma detscdentro de los archivos de prueba. Así, una sola llamada aexpectTypeOfpuede ejecutar simultáneamente aserciones en tiempo de ejecución y aserciones de tipos, con informes unificados. Pero la resistencia está en que:vitestla verificación de tipos detsces "global", mientras que las pruebas de Vitest son "por archivo", y las estrategias de incrementalidad de ambas son incompatibles.

Reflexiones de diseño y trampas encontradas

¿Por quédts-built-testdebe ser independiente dedts-test?Ya se discutió en el capítulo anterior; aquí se complementa desde la perspectiva evolutiva:dts-built-testverifica losartefactos de construcción(rollup-plugin-dtsempaquetados.d.ts),dts-testverifica lostipos del código fuente. Si al fusionar se combinan ambos, se perderá el punto de verificación clave de "si los artefactos de construcción son consistentes con los tipos del código fuente". Este commit de 3.4.38 confirma precisamente la importancia de los tipos de los artefactos de construcción:

📎 changelogs/CHANGELOG-3.4.md:9

code
* **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))

"Proporcionar un stub de fallback cuando falta la lib DOM"——esta es una corrección de compatibilidad de tipos a nivel de artefactos de construcción, que solo puede descubrirse en escenarios comodts-built-testdonde se "consume el.d.tsempaquetado".

Puntos problemáticos en producción: el ciclo de "integración-reversión" de las pruebas de tipos indica que los cambios en las firmas de tipos requieren validación deproyectos downstream reales, no solo aserciones de tipos. Las pruebas de tipos de Vue se ejecutan enpackages-private/dts-test, se utilizan casos de prueba internos del repositorio, que no cubren todos los usos downstream. Si la tendencia de fusión solo se centra en «fusionar dos runners», sin resolver «cómo introducir retroalimentación real de downstream», es solo una fusión formal.

---

III. Direcciones de optimización de granularidad fina para la caché de CI

Modelo intuitivo

Imagina la caché de CI como el «área de preparación de materiales» de un almacén: cada compilación necesita tomar materias primas (dependencias, artefactos de compilación, caché de tipos) del área de preparación. Si el área de preparación solo tiene una caja grande, y para tomar cualquier cosa hay que rebuscar en toda la caja, entonces por muy alta que sea la tasa de aciertos de caché, no será rápida. La optimización de granularidad fina significa:Dividir la caja grande en compartimentos pequeños clasificados por uso。

Sin caché de granularidad fina, el desastre que enfrenta el sistema esAmplificación en cascada de la invalidación de caché: modificar una línea de código fuente provoca que toda la caché denode_modulesse invalide, CI reinstala todas las dependencias y el tiempo de compilación pasa de 2 minutos a 10 minutos.

Estructura de datos: clasificación de elementos cacheables

A partir depackage.jsonse pueden identificar varias categorías de «materiales» cacheables:

Primera categoría, productos de instalación de dependencias.packageManagerEl campo fija la versión de pnpm:

📎 package.json:4

code
  "packageManager": "pnpm@12.4.2",

Elnode_modulesde pnpm es una estructura de enlaces simbólicos; lo que se cachea es el content-addressable store de pnpm, no unnode_modulesplano. Esto significa que la clave de caché debe basarse en el hash depnpm-lock.yaml, no enpackage.json。

Segunda categoría, artefactos de compilación.cleanEl script revela la ubicación física de los artefactos:

📎 package.json:10

code
    "clean": "rimraf --glob packages/*/dist temp .eslintcache",

packages/*/dist、temp、.eslintcache——estas tres categorías de artefactos pueden cachearse de forma independiente.distes la salida de compilación,tempson archivos temporales (comobench.json),.eslintcachees la caché de lint.

Tercera categoría, caché de verificación de tipos.checkEl script utiliza--incremental:

📎 package.json:15

code
    "check": "tsc --incremental --noEmit",

--incrementalgenera el archivo.tsbuildinfo, que es la caché incremental de la verificación de tipos. Si en CI se cachea este archivo,tscla segunda ejecución de

será mucho más rápida.

Impulsado por escenarios: flujo de ejecución de CI de un PRpackages/reactivity/src/ref.tsSituémonos en un escenario típico: el desarrollador modifica

y envía un PR. ¿Qué pasos debe ejecutar CI y cuáles pueden acertar en la caché?scriptsA partir desimple-git-hooksse puede inferir la secuencia de ejecución de CI (elpre-commitde

📎 package.json:48-51

code
  "simple-git-hooks": {
    "pre-commit": "pnpm lint-staged && pnpm check",
    "commit-msg": "node scripts/verify-commit.js"
  },

Copiarpre-commitEllint-stagedlocal ejecutacheckylint、check、test-unit、test-dts、size. En CI se ejecutarán

  • lint, etc. La estrategia de caché de cada paso es diferente:.eslintcache: cachear
  • check, clave basada en el hash de los archivos fuente..tsbuildinfo: cacheartsconfig, clave basada en
  • test-unity el hash del código fuente.
  • test-dts: Vitest tiene su propia caché, pero normalmente en CI no se cachean los resultados de las pruebas, solo las dependencias.build-dts: depende de los artefactos depackages/*/dist, clave de caché basada en el hash de
  • size: depende de los artefactos de compilación, clave de caché igual que arriba.

Representación Mermaid de la optimización de caché de CI

mermaid
flowchart TD
    pr["PR 提交"] --> checkout["checkout 代码"]
    checkout --> cache_deps{"pnpm store 缓存命中?"}
    cache_deps -->|是| install_fast["pnpm install --offline"]
    cache_deps -->|否| install_slow["pnpm install 全量下载"]
    install_fast --> lint_step["pnpm lint"]
    install_slow --> lint_step
    lint_step --> cache_eslint{".eslintcache 命中?"}
    cache_eslint -->|是| lint_inc["增量 lint"]
    cache_eslint -->|否| lint_full["全量 lint"]
    lint_inc --> check_step["pnpm check"]
    lint_full --> check_step
    check_step --> cache_tsbuild{".tsbuildinfo 命中?"}
    cache_tsbuild -->|是| check_inc["增量类型检查"]
    cache_tsbuild -->|否| check_full["全量类型检查"]
    check_inc --> test_unit["pnpm test-unit"]
    check_full --> test_unit
    test_unit --> build_dts["pnpm build-dts"]
    build_dts --> cache_dist{"packages/*/dist 命中?"}
    cache_dist -->|是| dts_cached["复用 dts 产物"]
    cache_dist -->|否| dts_rebuild["重新生成 dts"]
    dts_cached --> test_dts["pnpm test-dts-only"]
    dts_rebuild --> test_dts
    test_dts --> size_check["pnpm size"]
    size_check --> done["CI 通过"]
〔Inferencia de diseño y compensaciones arquitectónicas〕

La contradicción central de la caché de granularidad fina esla granularidad de la clave de caché: si la clave es demasiado gruesa (por ejemplo, basada solo en el commit hash), la tasa de aciertos es baja; si la clave es demasiado fina (por ejemplo, basada en el hash de cada archivo), el costo de calcular la clave anula el beneficio de la caché. La estrategia razonable para un monorepo como Vue es «fragmentar por paquete»: cadapackages/*subpaquete cachea de forma independientedist,reactivity; los cambios encompiler-coreno invalidan la caché dedistde

Reflexiones de diseño y errores comunes

¿Por qué el scriptsizedebe dividirse en múltiples subcomandos?Observa estas tres líneas:

📎 package.json:11-14

code
    "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",

sizeutilizarun-s "size-*"para ejecutar en serie todos los subcomandos con el prefijosize-. Este patrón de «agregación por prefijo» permite que cada dimensión de tamaño (global, esm-runtime, esm) se cachee y falle de forma independiente. Si se fusionaran en un solo comando grande, cualquier dimensión que exceda el límite haría fallar todo elsize, sin poder localizar qué dimensión tiene el problema.

Puntos problemáticos en producción: el error más fácil de cometer en la caché de CI esla contaminación de caché——cachear artefactos incorrectos, lo que provoca que compilaciones posteriores se basen en datos sucios.cleanEl script

📎 package.json:10

code
    "clean": "rimraf --glob packages/*/dist temp .eslintcache",

Copiarpackages/*/distObserva que lo que limpia espackages-private/*/dist, nopackages-private. Esto significa que los artefactos depackages-privateno están dentro del alcance de limpieza habitual——si CI cachea los artefactos decleanypackages-privateno los limpia, puede aparecer el problema de «tener cacheados artefactos de una versión antigua del playground». Al diseñar la caché de granularidad fina, hay que tratar

---

por separado.

Reflexión de diseño: el sistema de ingeniería como ciclo de vida del productoAl enlazar los hilos de las tres secciones, se puede ver una línea principal clara:。

El sistema de ingeniería de Vue está pasando de «funcionar» a «ser fácil de usar», de «orquestación manual» a «configuración declarativa»

La migración de la cadena de herramientas de compilación (Rollup → Rolldown) es una evolución «impulsada por el rendimiento»: cuando el número de paquetes crece hasta cierto punto, el costo de la concurrencia a nivel de proceso supera el beneficio y hay que cambiar a un modelo de concurrencia más ligero.

La fusión de las pruebas de tipos es una evolución «impulsada por la consistencia»: cuando la frecuencia de cambios en las firmas de tipos supera la frecuencia de cambios en el comportamiento en tiempo de ejecución, dos conjuntos separados de pruebas se convierten en una carga y hay que hacer que compartan los mismos casos de uso.

La granularización fina de la caché de CI es una evolución «impulsada por el costo»: cuando los minutos de CI se convierten en el cuello de botella, el desperdicio de una caché de granularidad gruesa se vuelve inaceptable y hay que fragmentar por uso.

〔Inferencia de diseño y compensaciones arquitectónicas〕La restricción común de estas tres líneas de evolución esla compatibilidad hacia atrásBREAKING CHANGES. La estrategia de publicación de Vue (visible en la sección

---

del changelog) permite hacer «type-only breaking change» en versiones minor, pero no permite breaking changes en tiempo de ejecución. Esto significa que la evolución del sistema de ingeniería debe garantizar que: sin importar cómo cambie la cadena de herramientas interna, la API pública y el comportamiento en tiempo de ejecución de los artefactos no pueden cambiar. Esta es la frontera dura de todas las decisiones de evolución.

Resumen del capítulopackage.jsonEste capítulo, partiendo del changelog y de

1. , ha ordenado las tres líneas de evolución del sistema de ingeniería de Vue core:: La combinación actual de Rollup 4.x + esbuild + rollup-plugin-dts tiene sus puntos de tensión reflejados enbuild:commits con el prefijo (alineación de configuración de minify, reversión de versión de entities, omisión de detección de CJS external). El potencial de la migración a Rolldown proviene del reemplazo de «concurrencia multiproceso» por «paralelismo monoproceso», mientras que la resistencia proviene del ecosistema de plugins y la distribución de binarios multiplataforma.

2. Fusión de pruebas de tipos:test-dtsderun-s build-dts test-dts-onlyestructura serial, así comodts-built-testydts-testel dobletscproceso, son evidencia física de la forma de separación actual. La ruta técnica de fusión es aprovechar el mecanismo de--projectde Vitest, la resistencia es que la verificación completa detsces incompatible con la estrategia incremental de pruebas por archivo de Vitest.

3. Granularización de caché de CI:packageManagerfijar pnpm,cleanlimpiar tres tipos de artefactos,checkusar--incremental、sizeagregar por prefijo — todos estos son criterios de clasificación para elementos cacheables. La contradicción central es la granularidad de las claves de caché, la estrategia razonable es «fragmentar por paquete».

El cambio cognitivo más importante es:el sistema de ingeniería en sí mismo es un producto, tiene sus propios usuarios (contribuidores), sus propias métricas de rendimiento (tiempo de compilación, minutos de CI), sus propias restricciones de compatibilidad (API de artefactos sin cambios). Necesita iteración continua, no un diseño único.

Reflexiones y autoevaluación de este capítulo

Q1: package.json:9debuild-dtsusótsc -p tsconfig.build.json --noCheck. Si se elimina--noCheck, ¿qué reacciones en cadena traería tras la migración a Rolldown?

Análisis de referencia:--noCheckLa función de es omitir la verificación de tipos y solo hacer emit. Tras eliminarlo,tschará una verificación completa de tipos antes de generar.d.ts. Bajo la arquitectura actual de Rollup, esto solo hace quebuild-dtssea más lento; pero tras la migración a Rolldown, el problema se amplifica: el punto de venta central de Rolldown es «compilación paralela monoproceso», si la fase debuild-dtsintroduce una verificación completa detsc, se convierte en el cuello de botella serial de toda la cadena de producción — la compilación de todos los paquetes debe esperar a que esta verificación termine. Más grave aún, la verificación de tipos detsces monohilo, no puede aprovechar la capacidad paralela de Rolldown. La práctica correcta es mantener--noCheck, delegar la verificación de tipos apnpm check(package.json:15) ytest-dts(package.json:22) independientes, desacoplando compilación y verificación.

Q2: El changelog 3.4.37 revirtió consecutivamente dos correcciones detypes/ref(CHANGELOG-3.4.md:23-24), y estas dos correcciones se acababan de integrar en 3.4.35 (CHANGELOG-3.4.md:30,55). Si las pruebas de tipos y las pruebas de tiempo de ejecución ya se hubieran fusionado, ¿se podría evitar este ciclo de «integración-reversión»? ¿Por qué?

Análisis de referencia: No se puede evitar por completo, pero sí acortar el ciclo. Las pruebas de tipos fusionadas aún solo pueden verificar que «la firma de tipos cumple con las aserciones», mientras que el problema de correcciones comoallow getter and setter types to be unrelatedradica en que «la firma de tipos es demasiado permisiva, rompe la seguridad de tipos del código downstream» — esto es un problema deuso downstream, no dela firma en sí. Donde la fusión puede acortar el ciclo es en que: si las aserciones de tipos y las aserciones de tiempo de ejecución se escriben en el mismo archivo de prueba, los desarrolladores pueden detectar más rápido la inconsistencia de «la firma de tipos cambió pero el comportamiento en tiempo de ejecución no». Pero para evitar realmente las reversiones, se necesita introducir verificación de tipos de proyectos downstream reales (por ejemplo, extenderpackages-private/dts-testa un conjunto de pruebas que «simule el uso downstream»), lo cual excede el ámbito de la simple «fusión de runners».

Q3: package.json:10decleanscript limpiapackages/*/dist, pero no limpiapackages-private/*/dist. Si CI adopta una estrategia de caché de granularidad fina «fragmentada por paquete», ¿qué trampa de producción traería esta asimetría?

Análisis de referencia: La trampa radica en «cachear artefactos antiguos depackages-private».packages-privatecontienesfc-playground、template-explorery otras herramientas de depuración, sus artefactos de compilación (comopackages-private/sfc-playground/dist) si son cacheados por CI, ycleanno los limpia, ocurrirá: el código fuente se actualizó, pero CI reutiliza artefactos antiguos del playground, causando que los resultados de verificación debuild-sfc-playground(package.json:39) se distorsionen. Más sutil aún,dev-sfc-prepare(package.json:34) verificará si los artefactos depackages-privateexisten, si se cachearon artefactos antiguos, omitirá la recompilación, haciendo que los desarrolladores crean que el entorno es nuevo. Al diseñar caché de granularidad fina, se debe definir una clave de caché separada parapackages-private, o simplemente no cachear sus artefactos — porque es una herramienta de depuración, el costo de reconstrucción es bajo, el beneficio de caché es pequeño.

A través de la ventana de observación del changelog, identificamos los puntos de tensión del sistema de ingeniería actual, y a partir de ello inferimos las posibles direcciones de evolución de la próxima generación del sistema. Estas direcciones no son castillos en el aire, sino que crecen a partir de verdaderos tropiezos y compensaciones en producción. Hasta aquí, el análisis del sistema de ingeniería de Vue en este libro llega a una pausa, pero la exploración de la ingeniería nunca termina — el próximo capítulo será el último, alejando la perspectiva de Vue mismo para explorar cómo estas experiencias se pueden transferir a escenarios de ingeniería más amplios.

Convierte cualquier código en un libro comprensible

¿Disfrutaste este capítulo? Convierte tu código privado en un libro

Arquitectura local-first con Tauri 2 + Rust. 100% offline y seguro, sin subir código. Lectura en panel dual con anclajes de commit inmutables.

⚡ Tauri 2 · Rust Core · 100% Privado y Offline · Probado en +1M líneas

Para comprender cualquier proyecto complejo, todo lo que necesitas es un buen libro

Compilado automáticamente por AiReadCode escaneando el repositorio oficial con anclajes inmutables de commit.

Dar una estrella en GitHub ★ Ver más libros →