CHAPTER 01

Chapitre 1 : Vision macroscopique : la philosophie de conception d'ingénierie du dépôt core

Upstream: vuejs/core · Commit @4ab865a8 · Progression: Chapitre 1 sur 14

Avant de commencer à tracer la moindre ligne d'implémentation de la réactivité ou du DOM virtuel, nous devons d'abord comprendre la matrice d'ingénierie dans laquelle ce code évolue. En ouvrant le dépôt Vue core, ce qui saute aux yeux en premier n'est pas la logique centrale du framework, mais des fichiers de configuration d'ingénierie tels quepackage.jsonetpnpm-workspace.yaml— ils ne contiennent aucune fonctionnalité d'exécution, mais déterminent si l'ensemble du framework peut être correctement construit, testé et publié. Ce chapitre répond précisément à cette question préalable : qu'est-ce que le dépôt core exactement. Il n'est pas@vue/runtime-corece paquet npm, mais la matrice d'ingénierie qui hébergeruntime-core、reactivity、compiler-sfcplus de dix paquets publiés publiquement, ainsi que des paquets expérimentaux privés tels quesfc-playground、template-explorerComprendre l'organisation de cette matrice est le prérequis de tous les chapitres suivants (build, types, publication, budget de taille). Ce chapitre se déploie selon trois axes principaux : la structure à double répertoire du workspace, la contrainte unifiée de TypeScript et Rollup au niveau racine, et la philosophie de découplage entre « dépôt de code source » et « artefacts de publication ».

I. Structure à double répertoire : l'isolation physique entre packages et packages-private

Modèle intuitif

Imaginez le dépôt core comme un immeuble de R&D.packages/est la ligne de produits officielle, dont les productions doivent être marquées et vendues sur le marché ;packages-private/est le laboratoire interne, dont les échantillons ne servent qu'au débogage et à la démonstration, et ne sont jamais expédiés à l'extérieur. Les deux partagent le même réseau d'eau et d'électricité (dépendances, outils de build), mais le système de contrôle d'accès (processus de publication) les traite différemment.

Sans cette couche d'isolation physique, un paquet playground à usage de débogage interne pourrait facilement être publié par erreur sur npm — ce n'est pas une hypothèse, mais un accident classique des monorepos.

Structures de données et disposition mémoire

La frontière du workspace est définie parpnpm-workspace.yamlIl ne contient que trois lignes de déclaration effective :

📎 pnpm-workspace.yaml:1-3

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

Ces deux globs indiquent à pnpm :packages/etpackages-private/chaque sous-répertoire sous@vue/runtime-coreest un paquet indépendant. pnpm crée des liens symboliques pour eux, afin que@vue/reactivityréférence

pointe directement vers le répertoire source local, plutôt que de télécharger depuis le registry.catalog:Juste après, la sectionest le mécanisme derépertoire de versions de dépendances

📎 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

Copierpackage.jsonDans le"@babel/parser": "catalog:" 📎 package.json:65-65。catalog:racine, la valeur correspondante est@babel/parserest un placeholder, que pnpm remplace lors de l'installation par la version déclarée dans la section catalog. Le bénéfice de cette approche est que :pnpm-workspace.yamlla version de

n'est maintenue qu'à un seul endroit danspnpm installtous les paquets qui la référencent sont automatiquement alignés, ce qui élimine la dérive de version du type « le paquet A utilise 7.28, le paquet B utilise 7.29 ».

Walkthrough guidé par scénario : ce qui se passe après unpnpm installSupposons que vous exécutiez

à la racine du dépôt. Plaçons-nous dans ce scénario et traçons étape par étape :Première étape : le contrôle d'accès preinstall.package.jsonpnpm déclenche avant l'installation lepreinstallscript

📎 package.json:45-45

json
"preinstall": "npx only-allow pnpm"
Copier

only-allow pnpm〔Inférence de conception et compromis architecturaux〕catalog:vérifie si le gestionnaire de paquets actuel est pnpm, et sinon, renvoie directement une erreur et quitte. L'existence de ce script signifie que : installer le dépôt core avec npm ou yarn échouera. Pourquoi faut-il verrouiller pnpm ? Parce que le dépôt core dépend des liens symboliques de workspace et du mécanisme catalog de pnpm, que les workspaces de npm ne prennent pas en chargecreateRequirela syntaxe

et que le mode PnP de yarn modifie les chemins de résolution des modules, entraînant une incohérence de comportement dedans les scripts de build.pnpm-workspace.yamlDeuxième étape : résolution du workspace.packages/*pnpm litpackages-private/*scannepackage.jsonet

et crée un enregistrement de paquet pour chaque répertoire contenantTroisième étape : application du remplacement catalog.package.jsonDans lecatalog:Le placeholder est remplacé par la version réelle du segment catalog, puis installé uniformément.

Quatrième étape : hook postinstall.Déclenché après l'installation :

📎 package.json:46-46

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

simple-git-hooksLire la racinepackage.jsondans le champsimple-git-hooks, écrire les hooks Git dans.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-commitLe hook exécute lint-staged et la vérification de types avant chaque commit,commit-msgLe hook valide le format du message de commit (Vue utilise les conventional commits). Attention àpreinstalletpostinstallla symétrie : le premier garde l'entrée (seul pnpm autorisé), le second déploie la défense (installation des hooks Git).

Réflexions de conception et pièges

〔Inférences de conception et compromis architecturaux〕

Pourquoi utiliser deux globs au lieu d'un seulpackages*/?Lister explicitement deux répertoires rend la sémantique « public » et « privé » visible au niveau de la configuration. Tout nouveau développeur lisantpnpm-workspace.yamlsait immédiatement que le dépôt contient deux catégories de packages. Si l'on écrivaitpackages*/, cette sémantique serait masquée.

allowBuildset sécurité de la chaîne d'approvisionnement.Notez cette configuration :

📎 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 interdit par défaut aux packages de dépendances d'exécuter des scripts d'installation (postinstall), car c'est un vecteur courant d'attaques de la chaîne d'approvisionnement.allowBuildsest une liste blanche : seuls les packages listés sont autorisés à exécuter des scripts de build.@swc/core、esbuildnécessite le téléchargement de binaires natifs spécifiques à la plateforme,puppeteernécessite le téléchargement de Chromium,simple-git-hooksnécessite l'écriture de hooks Git — ce sont tous des comportements légitimes à la construction, donc explicitement autorisés.

minimumReleaseAge: 1440la signification profonde.Cette ligne de configuration exige que les versions de dépendances nouvellement publiées aient « au moins 24 heures » (1440 minutes) avant de pouvoir être installées :

📎 pnpm-workspace.yaml:33-33

yaml
minimumReleaseAge: 1440
〔Inférences de conception et compromis architecturaux〕

C'est un mécanisme de période de refroidissement pour se défendre contre l'empoisonnement de la chaîne d'approvisionnement npm. Après qu'un attaquant détourne un package et publie une version malveillante, elle est généralement découverte et retirée en quelques heures. Définir une période de refroidissement de 24 heures permet au dépôt core d'éviter cette fenêtre. EtminimumReleaseAgeExcludepermet de faire exception pour des correctifs de sécurité spécifiques :

📎 pnpm-workspace.yaml:36-38

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

Le commentaire précise explicitement qu'il s'agit d'une mise à jour de sécurité déclenchée par Renovate, nécessitant une prise d'effet immédiate, donc exemptée de la période de refroidissement.

---

II. tsconfig racine : contraindre uniformément les frontières de types de tous les sous-packages

Modèle intuitif

Si chaque sous-package maintenait son propre tsconfig, il y aurait des fissures du type « le package A utilisestrict: false, le package B utilisestrict: true». Le tsconfig racine estune constitution: il définit les règles de types que tous les sous-packages doivent respecter conjointement ; les sous-packages ne peuvent qu'ajouter par-dessus, sans les enfreindre.

Structures de données et disposition mémoire

La racinetsconfig.jsonducompilerOptionsest la fondation de tout le système de types du dépôt. Extrayons quelques champs clés :

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

Interprétation ligne par ligne :

  • target: es2016: la syntaxe de sortie est rétrogradée à ES2016. Cela fait écho autargetd'esbuild dans la configuration Rollup (isServerRenderer || isCJSBuild ? 'es2019' : 'es2016' 📎 rollup.config.js:337-337)。
  • moduleResolution: bundler: adopte la résolution de modules de style bundler, permettant d'omettre les extensions et supportant le champexports.
  • strict: true: active toutes les vérifications strictes, incluantstrictNullChecks、noImplicitAnyetc.
  • noUnusedLocals: true: les variables locales inutilisées provoquent directement une erreur. Cette règle a un sens pratique avec le Tree-shaking — les variables inutilisées sont souvent le signal de code mort.
  • isolatedModules: true: exige que chaque fichier soit transpilable indépendamment. C'est le prérequis des outils comme esbuild/swc qui « transpilent fichier par fichier sans analyse de types inter-fichiers ».
  • isolatedDeclarations: true: exige que tous les exports aient une annotation de type explicite. Cette règle sert directement le pipeline de génération de.d.ts— seule une annotation explicite permet àtscde générer rapidement les fichiers de déclaration sans inférence de types complète.
  • composite: true: active les métadonnées de build incrémental nécessaires aux project references.

pathsLe champ est lemiroir de la couche de types:@vue/*du workspace, mappé vers./packages/*/src, permettant à TypeScript de résoudre directement vers le code source à la compilation, plutôt que vers les liens symboliques dansnode_modules. Cela complète les liens symboliques d'exécution de pnpm — à l'exécution on s'appuie sur pnpm, à la compilation sur paths.

Walkthrough guidé par scénario : une vérification de types depnpm check

checkLe script esttsc --incremental --noEmit 📎 package.json:15-15. Plaçons-nous dans ce scénario :

Première étape : lire la portée include.Leincludedu tsconfig détermine quels fichiers participent à la vérification :

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

Notez quescripts/*etrollup.*.jssont aussi dans la portée de vérification. Cela signifie que les scripts de build eux-mêmes sont soumis aux contraintes de types —rollup.config.jsen tête de// @ts-check 📎 rollup.config.js:1-1avec les annotations de types JSDoc, permet à ce fichier purement JS d'être aussi vérifié partsc.

Deuxième étape : appliquer l'exclusion exclude.

📎 tsconfig.json:40-40

json
"exclude": ["packages-private/sfc-playground/src/vue-dev-proxy*"]
〔Inférences de conception et compromis architecturaux〕

sfc-playgroundLes fichiersvue-dev-proxydans

sont exclus. Pourquoi ? Ces fichiers sont généralement du code proxy généré dynamiquement à l'exécution, dont la forme des types est instable, et les inclure dans la vérification produirait du bruit. --incrementalTroisième étape : vérification incrémentale.tscpermet à.tsbuildinfode mettre en cache les résultats de la dernière vérification dans--noEmit, ne revérifiant que les fichiers modifiés.

signifie vérifier sans produire — la vérification de types et la génération d'artefacts sont deux pipelines indépendants.

isolatedDeclarationsRéflexions de conception et piègesLe coût et le bénéfice deexport function foo(): number. Après activation de cette règle, tout export doit avoir un type de retour explicitement annoté, par exempleexport function foo() { return 1 }au lieu de.d.ts. Cela augmente le coût d'écriture, mais en échange, la vitesse de génération detscest considérablement améliorée —build-dtspeut produire les fichiers de déclaration sans inférence inter-fichiers. Cela fait écho au flagtsc -p tsconfig.build.json --noCheckdans le script--noCheck: puisque les types sont déjà explicitement annotés, on peut même sauter la vérification lors de la génération des fichiers de déclaration.

typesInjection globale du champ

📎 tsconfig.json:21-21

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

Ces trois packages de types sont injectés globalement, ce qui signifie que les fichiers de test peuvent utiliser directementdescribe、it、expectsans import, et les tests e2e peuvent utiliser directement les types depuppeteer. C'est un compromis entre commodité et pollution — plus il y a de types globaux, plus le risque de conflit de noms est élevé, mais meilleure est l'expérience d'écriture du code de test.

---

III. Configuration Rollup : de buildOptions à l'usine unifiée des artefacts multi-formats

Modèle intuitif

La configuration Rollup est l'atelier d'assemblagedu dépôt core. Il ne se soucie pas de ce que fait un package spécifique, mais seulement de « quels formats ce package doit produire, où se trouve le fichier d'entrée pour chaque format, et quelles dépendances doivent être externalisées ». Le champpackage.jsondans lebuildOptionsde chaque sous-package est un bon de livraison collé sur le colis, et l'atelier d'assemblage travaille selon ce bon.

Structures de données et disposition mémoire

Dès l'entrée du fichier de configuration, le modèle « construction par package » est établi :

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

Conceptions clés :TARGETLa variable d'environnement spécifie quel package construire. La configuration utilisefs.readdirSync('packages-private')pour déterminer si le package appartient à un répertoire public ou privé, décidant ainsi depkgBase. C'est unedétection de répertoire à l'exécution— pas besoin de maintenir une liste de « quels packages sont privés », la structure des répertoires est elle-même la vérité.

buildOptionsest un champ personnalisé dans lepackage.jsondu sous-package,packageOptions.filenamedétermine le préfixe du nom de fichier de l'artefact,packageOptions.formatsdétermine le format de construction par défaut.

La correspondance format-vers-artefact est définie paroutputConfigs:

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

Sept formats, couvrant trois scénarios de consommation :esm-bundlerpour les bundlers comme Vite/webpack,esm-browserpour l'ESM natif du navigateur,globalpour la balise<script>. Ceux avec le suffixe-runtimesont des constructions « runtime uniquement », ouvertes uniquement au package principalvue.

Parcours guidé par scénario : le flux de décision complet d'unpnpm build vue

Imaginons l'exécution du scénarionode scripts/build.js vue.TARGET=vue, traçons les décisions internes decreateConfig:

Première étape : déterminer la liste des formats.

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

Priorité : ligne de commandeFORMATS> sous-packagebuildOptions.formats> par défaut['esm-bundler', 'cjs']。PROD_ONLYSi la variable d'environnement est vraie, les constructions non-production sont ignorées, ne conservant que la configuration.prod.jsajoutée ultérieurement.

Deuxième étape : calculer les indicateurs de construction. createConfigdéduit en interne un ensemble d'indicateurs booléens à partir de la chaîne de format :

📎 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

Ces indicateurs sont lasource unique de véritépour toutes les décisions ultérieures : sélection du fichier d'entrée, remplacement define, détermination external, assemblage des plugins, tout en dépend.

Troisième étape : sélectionner le fichier d'entrée.

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

L'entrée par défaut estsrc/index.ts, les constructions runtime uniquement utilisentsrc/runtime.ts. Le package compat (@vue/compat, c'est-à-dire la construction compatible Vue 2) doit fournir à la fois les exports default et named, ce qui ferait échouer Rollup pour les cibles non-ESM, donc une entréeesm-index.ts / esm-runtime.tsdistincte est utilisée pour la construction ESM.

Quatrième étape : générer la table de remplacement define. resolveDefineremplace les constantes de compilation comme__DEV__、__BROWSER__dans le code source par des littéraux :

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

Il y a ici une stratification ingénieuse :les feature flags ne sont pas codés en dur dans la construction esm-bundler, mais conservés comme des identifiants tels que__VUE_OPTIONS_API__, laissés au bundler de l'utilisateur final pour le remplacement. Ainsi l'utilisateur peut désactiver le support de l'Options API viadefine: { __VUE_OPTIONS_API__: false }, permettant le Tree-shaking du code associé. En revanche, dans les constructions global/esm-browser, ces flags sont codés en dur àtrue/false, car les artefacts consommés directement par le navigateur n'ont pas d'intervention de bundler.

Cinquième étape : permettre la surcharge par variables d'environnement.

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

Toute clé define peut être surchargée par une variable d'environnement du même nom. L'exemple donné en commentaire est__RUNTIME_COMPILE__=true pnpm build runtime-core— utilisé pour déboguer une branche de compilation spécifique.

Sixième étape : assembler la chaîne 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,
],

L'ordre des plugins est important :jsontraite d'abord les imports JSON,aliasmappe@vue/*vers le chemin source,enumPluginfait l'inlining d'énumérations,replacefait le remplacement de chaînes,esbuildfait la transpilation TS. Notez que leesbuilddetsconfigpointe vers le tsconfig racine —tous les sous-packages partagent la même configuration de types, ce qui est précisément la manifestation à la construction de la « constitution » discutée dans la section II.

Septième étape : ajout des constructions de production.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))
    }
  })
}

Le format CJS ajoute une version.prod.js(avec remplacement par__DEV__=false), les formats global et esm-browser ajoutent une version minifiée (minify avec swc).packageOptions.prod === falseLes packages

peuvent se retirer de ce mécanisme.

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

Copier

externalRéflexions de conception et pièges resolveExternalLa stratégie à trois branches 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,
    ]
  }
}

retourne différentes listes d'externalisation selon le type de construction :treeShakenDepsCopierdependenciesLes constructions navigateur (global/esm-browser) intègrent toutes les dépendances, ne listantpeerDependenciescomme external que pour supprimer les avertissements — ces dépendances ne sont pas réellement référencées dans la branche navigateur et seront supprimées par Tree-shaking. Les constructions Node/esm-bundler externalisent tous les

onwarnet

📎 rollup.config.js:344-348

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

Filtrage des dépendances circulaires.runtime-coreCopierreactivityLes avertissements de dépendances circulaires sont silencieux. Il existe des références circulaires légitimes entre

treeshake.moduleSideEffects: falseet

📎 rollup.config.js:355-355

js
treeshake: {
  moduleSideEffects: false,
},

L'hypothèse agressive de.Copier

Cela indique à Rollup : tous les modules sont sans effets de bord, les imports non référencés peuvent être supprimés en toute sécurité. C'est unepure_gettershypothèse agressive

📎 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: trueLe piègeobj.foode swc-minify.track()) plutôt que par un effet de bord implicite du getter, ce qui est donc sûr.map: nullindique qu'aucun sourcemap n'est généré après minification — les artefacts de production n'ont pas besoin de mappings de débogage.

---

Réflexion de conception : pourquoi le dépôt source et les artefacts publiés doivent être découplés

Revenons à la proposition centrale de ce chapitre. La conception d'ingénierie du dépôt core suit un fil conducteur :La responsabilité du dépôt source est la « production », celle des artefacts publiés est la « consommation », les deux étant découplés par le pipeline de build。

Cela se manifeste concrètement à trois niveaux :

Premièrement, le code source n'est pas publié directement. package.jsonLeprivate: true 📎 package.json:2-2indique que le package racine n'est jamais publié. Dans chaque sous-package, lepackage.jsoncontient lemain/module/exportschamp qui pointe versdist/les artefacts sous, et non verssrc/. Lorsqu'un utilisateur installevue, il obtient les.jset.d.tsaprès build, le code source restant dans le dépôt.

Deuxièmement, le format des artefacts est déterminé par le scénario de consommation.Les sept formats ne sont pas listés au hasard, mais correspondent à sept chemins de consommation réels : les utilisateurs de Vite prennentesm-bundler, les utilisateurs de CDN prennentglobal, les utilisateurs de Node SSR prennentcjs. La logique de sélection des formats est centralisée enrollup.config.jsun seul endroit, les sous-packages n'ayant qu'à déclarer dansbuildOptions.formatsceux dont ils ont besoin.

Troisièmement, séparation des types et de l'implémentation. build-dtsLe scripttsc -p tsconfig.build.json --noCheck && rollup -c rollup.dts.config.js 📎 package.json:9-9indique que.d.tsla génération est un pipeline indépendant.isolatedDeclarations: truepermet à la génération des fichiers de déclaration de sauter la vérification de types (--noCheck), car les types sont déjà explicitement annotés.

〔Inférences de conception et compromis architecturaux〕

La motivation profonde de ce découplage est :L'organisation du code source sert les développeurs, l'organisation des artefacts sert les consommateurs, et leurs optima diffèrent. Le code source a besoin d'une structure de répertoires claire, d'informations de types complètes, de sourcemaps débogables ; les artefacts ont besoin d'une taille minimale, de formats de modules corrects, d'une surface d'API stable. Forcer l'unification des deux (par exemple publier directement le code source TS) nuirait simultanément à l'expérience des deux côtés.

---

Résumé de ce chapitre

Ce chapitre a établi une compréhension macroscopique du dépôt core selon trois dimensions :

1. Structure à double répertoire:packages/etpackages-private/l'isolation physique de, combinée aux liens symboliques de pnpm workspace et au catalogue de versions catalog, réalise une frontière claire entre « packages publics » et « packages privés ».preinstallLe verrouillage,allowBuildsla liste blanche,minimumReleaseAgeet la période de refroidissement constituent ensemble la ligne de défense de la sécurité de la chaîne d'approvisionnement.

2. tsconfig racine: en tant que constitution des types pour tous les sous-packages, il réalise la résolution workspace à la compilation viapathsle mapping, et soutient la construction incrémentale et la génération rapide de fichiers de déclaration viaisolatedDeclarationsetcomposite.

3. Fabrique unifiée Rollup: avecTARGETla variable d'environnement comme point d'entrée, il lit les métadonnées des sous-packages viabuildOptions, pilote la sélection d'entrée, le remplacement define, la détermination external et l'assemblage des plugins via un ensemble de drapeaux booléens, produisant finalement des artefacts en sept formats.

La philosophie centrale estle découplage entre le dépôt source et les artefacts publiés: le dépôt est responsable de la production, les artefacts de la consommation, le pipeline de build étant l'unique pont entre les deux.

---

Transition de fin de chapitre

Ce chapitre a répondu à « qu'est-ce que le dépôt core ». Mais la structure statique du dépôt n'est que la scène ; le véritable drame se joue lors de l'exécution d'une requête de build :scripts/build.jscomment parser les arguments de ligne de commande, comment appeler l'API Rollup, comment gérer les échecs de build et la concurrence. Le prochain chapitre suivra le voyage de bout en bout d'une requête de build, de l'entrée aux artefacts, transformant la compréhension statique établie dans ce chapitre en une vue d'exécution dynamique.

Réflexions et auto-évaluation de ce chapitre

Q1 : si l'on remplace danspnpm-workspace.yamlleminimumReleaseAge: 1440par0, quels risques cela introduirait-il dans un scénario de mise à jour de dépendances ? PourquoiminimumReleaseAgeExcludel'existence de est-elle nécessaire ?

Analyse de référence:

minimumReleaseAge: 1440 📎 pnpm-workspace.yaml:33-33exige qu'une version de dépendance nouvellement publiée ait au moins 24 heures avant de pouvoir être installée. Si l'on remplace par0, toute version fraîchement publiée peut être immédiatement tirée.

Scénario de risque : un attaquant détourne une dépendance transitive (par exemple@babel/parserune certaine version patch de), publiant une version contenant un script postinstall malveillant. Pendant la période de refroidissement de 24 heures, la communauté détecte généralement le problème et retire la version ; si la période de refroidissement est de 0, la CI du dépôt core pourrait automatiquement mettre à jour et exécuter le script malveillant pendant la fenêtre d'attaque.

minimumReleaseAgeExclude 📎 pnpm-workspace.yaml:36-38L'existence de est due au fait que le mécanisme de refroidissement entre en conflit avec l'urgence des correctifs de sécurité. Levitest@4.1.11dans les commentaires est une mise à jour de sécurité détectée par Renovate — ce type de mise à jour doit prendre effet immédiatement, attendre 24 heures ne ferait que prolonger la fenêtre d'exposition. Il faut donc une liste d'exemptions explicite permettant aux mises à jour de sécurité de contourner la période de refroidissement. Cela illustre le principe de conception de sécurité « conservateur par défaut, exception explicite ».

Q2: rollup.config.jsDansresolveDefine, le traitement de__FEATURE_OPTIONS_API__parisBundlerESMBuild ? '__VUE_OPTIONS_API__' : 'true'est'true'. Si l'on modifiait par erreur pour retourner

pour tous les formats, quel impact cela aurait-il sur les utilisateurs finaux ?:

📎 rollup.config.js:192-194

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

Copie__FEATURE_OPTIONS_API__Dans le build esm-bundler,__VUE_OPTIONS_API__est conservé comme identifiantdefine: { __VUE_OPTIONS_API__: false }, laissé au bundler de l'utilisateur final pour remplacement. L'utilisateur peut définirdata、methods、computeddans sa propre configuration de build, permettant au Tree-shaking de supprimer tout le code lié à l'Options API (la logique de traitement des options

, etc.), réduisant significativement la taille de l'artefact.'true'Si l'on modifiait pour retournerdefinepour tous les formats, le code de l'Options API serait codé en dur dans l'artefact esm-bundler, la configuration

de l'utilisateur deviendrait inopérante, empêchant le Tree-shaking. Pour un projet n'utilisant que la Composition API, cela ajouterait inutilement plusieurs Ko à la taille de l'artefact.L'idée clé de cette conception est :la forme finale de l'artefact esm-bundler est déterminée par le bundler de l'utilisateur, donc les feature flags doivent être résolus tardivement, au moment du build de l'utilisateur

Q3: rollup.config.jsderesolveExternal, la construction navigateur ne retourne quetreeShakenDepscomme external, tandis que la construction Node retourne tous lesdependencies. Supposons qu'un jour quelqu'un ajoute une nouvelle dépendance d'exécutionruntime-coreàfoo-lib, mais oublie de mettre à jourresolveExternalla logique de

. Que se passe-t-il dans la construction navigateur ?:

📎 rollup.config.js:257-283

Analyse de référenceisGlobalBuild || isBrowserESMBuildLa construction navigateur (!packageOptions.enableNonBrowserBranches) lors detreeShakenDeps(source-map-js、@babel/parser、estree-walker、entities/decodene retourne quefoo-lib). Cela signifie que

n'est pas dans la liste external,node scripts/build.js vueJusqu'ici, nous avons clairement vu, au niveau macro, la philosophie de conception globale du dépôt core en tant que matrice d'ingénierie : la structure workspace à double répertoire délimite la frontière entre les packages publics et les packages expérimentaux privés, les configurations TypeScript et Rollup au niveau racine fournissent des contraintes unifiées, et le découplage entre le dépôt source et les artefacts de publication rend possible la sortie multi-format. Ces connaissances ouvrent la voie à l'exploration approfondie des chaînes d'ingénierie concrètes. Dans le chapitre suivant, nous passerons de la structure statique au flux dynamique, en prenant

Transformez n'importe quel code en un livre compréhensible

Vous avez aimé ce chapitre ? Créez un livre pour votre projet privé

Architecture local-first en Tauri 2 + Rust. Sécurité 100% hors ligne, zéro code téléversé. Lecture double panneau avec ancres de commits immuables.

⚡ Tauri 2 · Rust Core · 100% Hors ligne & Privé · Testé sur 1M+ lignes

CHAPTER 02

Chapitre suivant : Chapitre 2 →

Upstream: vuejs/core · Commit @4ab865a8 · Progression: Chapitre 2 sur 14

Statut de vérification : lignes FACT réellement ancréesnode scripts/build.js vueDans le chapitre précédent, nous avons clarifié la position du dépôt core en tant que matrice d'ingénierie, ainsi que la manière dont le workspace pnpm et la configuration racine contraignent uniformément tous les sous-packages. Maintenant, nous plongeons au cœur du système de construction pour suivre comment une commande pilote l'ensemble du processus de construction.

semble simple, mais constitue l'unique point d'entrée de tous les artefacts — esm-bundler, cjs, global. Comprendre comment il traduit l'intention de l'utilisateur en tâches de construction exécutables est une étape clé pour maîtriser le mécanisme de construction de Vue.

build.jsGénération de la configuration Rollup : des variables d'environnement aux artefacts multi-formatexecviarollup.config.jsdémarre Rollup, puis le contrôle passe à

. Ce fichier est le « cerveau » du système de construction — il lit les variables d'environnement et génère dynamiquement un tableau d'objets de configuration Rollup.

📎 rollup.config.js:27-29

Validation des variables d'environnement et localisation des packagesTARGETSirollup -cn'est pas défini, une erreur est levée directement. C'est de la programmation défensive : la configuration Rollup peut être appelée directement (commebuild.js), auquel cas aucune

📎 rollup.config.js:32-44

n'injecte de variables d'environnement, et il faut échouer rapidement.build.jsIci, la logique de détermination des packages privés derollup.config.jsest répétée — carbuild.jsest un processus indépendant et ne peut pas partager l'état mémoire deresolve.pkgLa fonction résout un chemin relatif en chemin absolu dans le répertoire du package,package.jsonest le contenupackageOptionsdu package cible,buildOptionsest le champnamequ'il contient,buildOptions.filenameest le préfixe du nom de fichier de l'artefact (on utilise en priorité

, sinon le nom du répertoire).outputConfigs

📎 rollup.config.js:58-88

Table de correspondance des formats :

  • esm-bundler、esm-browser、esm-bundler-runtime、esm-browser-runtimeCette table définit la correspondance entre 7 formats et les configurations de sortie. Observations clés :format: 'es'sont tous
  • cjs, la différence ne réside que dans le nom de fichier.format: 'cjs'。
  • globalestglobal-runtimeetformat: 'iife'est<script>(expression de fonction immédiatement invoquée), adapté à une introduction directe via la balise
  • runtime.vueLes formats avec ce suffixe n'ont de sens que pour le package principal

— ils n'incluent pas le compilateur et sont plus légers.

📎 rollup.config.js:91-92

Sélection du format : trois niveaux de prioritéFORMATSLa sélection du format suit trois niveaux de priorité : ligne de commandebuildOptions.formatsvariable d'environnement > package['esm-bundler', 'cjs']。PROD_ONLY> par défaut

La variable d'environnement contrôle s'il faut ignorer la configuration de base — si seule la version de production est construite, le tableau de configuration de base est vide, et seule la configuration de production est ensuite ajoutée.

📎 rollup.config.js:97-114

Logique d'ajout de la configuration de productionNODE_ENV === 'production'Lorsque

  • , pour chaque format :packageOptions.prod === falseSi
  • , ignorer (ce package n'a pas besoin de version de production).cjsSi c'estcreateProductionConfig, ajouter.prod.js— génère le fichier
  • ./^(global|esm-browser)(-runtime)?/Si cela correspond àcreateMinifiedConfig, ajouter
— génère la version compressée.

〔Inférence de conception et compromis architecturaux〕cjsPourquoicreateProductionConfigutiliseglobal/esm-browsertandis quecreateMinifiedConfigutilise

createConfig? Parce que CJS est destiné à Node, et l'environnement Node n'a pas besoin de compression (l'utilisateur s'en charge), mais doit distinguer les branches dev/prod ; tandis que les artefacts directement introduits dans le navigateur doivent être compressés pour réduire la taille. Cette différence se reflète dans l'implémentation des deux fonctions de fabrique.

createConfig: le cœur de la génération de configuration

📎 rollup.config.js:125-142

est la plus grande fonction ; elle reçoit le format et la configuration de sortie, et retourne l'objet de configuration Rollup complet.

  • isProductionBuildAu début se trouve une série de calculs de drapeaux booléens :__DEV__: via.prod.jsla variable d'environnement ou si le nom de fichier contient
  • isBundlerESMBuild、isBrowserESMBuild、isCJSBuild、isGlobalBuild.
  • isServerRenderer: correspondance par expression régulière sur le nom de format.server-renderer。
  • isCompatPackage、isCompatBuild: si le nom du package est
  • isBrowserBuild: lié à la construction compatible Vue 2.

: construction globale ou construction ESM navigateur, et branche non-navigateur non activée.resolveDefine、resolveReplace、resolveExternalCes drapeaux sont utilisés de manière répétée dans le

📎 rollup.config.js:144-157

ultérieur et constituent la base essentielle de la différenciation des configurations.exportsParamètres de base de la configuration de sortie : en-tête de copyright banner,automode (les packages compat utilisentnamed, les autres utilisentesModule), activation de l'interopérabilitéexternalLiveBindings: falsepour la construction CJS, sourcemap contrôlé par variable d'environnement,reexportProtoFromExternal: falseetoutput.namesont des paramètres de compatibilité de Rollup 4. La construction globale définit en pluswindow, c'est-à-dire le nom de variable monté sur

.

📎 rollup.config.js:159-168

Sélection du fichier d'entréesrc/index.tsL'entrée par défaut estruntime, mais les formats avec le suffixesrc/runtime.ts. La construction ESM du paquet compat doit exporter à la fois default et named, donc on utilise une entréeesm-index.ts / esm-runtime.tsséparée.

Définition de macros :resolveDefine

📎 rollup.config.js:170-218

resolveDefineRetourne une table de remplacement qui remplace dans le code source les__COMMIT__、__VERSION__、__BROWSER__macros telles que par des littéraux. Ces macros sont utilisées dans le code source pour la compilation conditionnelle — par exempleif (__DEV__) { ... }sera remplacé en production parif (false) { ... }, puis supprimé par Tree-shaking.

Conception clé :__FEATURE_OPTIONS_API__、__FEATURE_PROD_DEVTOOLS__Les commutateurs de fonctionnalités tels que sont conservés dans la constructionesm-bundlersous forme d'identifiants__VUE_OPTIONS_API__tels que, permettant aux utilisateurs finaux de les surcharger via la configuration du bundler ; dans les autres constructions, ils sont directement codés en dur commetrueoufalse。

📎 rollup.config.js:203-206

nonesm-bundlerLa construction code en dur__DEV__, car leurs branches dev/prod sont déterminées au moment de la construction.

📎 rollup.config.js:210-216

La dernière étape permet aux variables d'environnement de surcharger toute définition de macro, prenant en charge__RUNTIME_COMPILE__=true pnpm build runtime-coredes surcharges en ligne telles que.

Plugin de remplacement :resolveReplace

📎 rollup.config.js:222-255

resolveReplaceEn dehors deresolveDefine, gère les remplacements qu'esbuild ne peut pas traiter :

  • FusionneenumDefines(définitions d'inlining d'énumérations provenant deinlineEnums).
  • Dans la construction navigateur de production, ajoute une annotation/*@__PURE__*/aux fonctions de création d'erreurs pour aider le Tree-shaking.
  • esm-bundlerDans la construction,__DEV__est remplacé par!!(process.env.NODE_ENV !== 'production'), laissant le bundler décider.
  • Dans la construction ESM navigateur, remplaceprocess.envpar un objet vide pour éviter les erreurs du navigateur.

Dépendances externes :resolveExternal

📎 rollup.config.js:257-283

C'est le cœur de la question de réflexion à la fin du chapitre précédent. La construction navigateur ne retourne quetreeShakenDepscomme external — ces dépendances, bien qu'importées, ne seront pas réellement exécutées dans la branche navigateur ; elles sont listées ici uniquement pour supprimer les avertissements de Rollup. Les constructions Node/ESM-bundler externalisent tous lesdependenciesetpeerDependencies, ainsi quepath、url、streamles modules intégrés Node tels que.

Objet de configuration final

📎 rollup.config.js:319-352

L'objet de configuration retourné contient :

  • input: chemin absolu du fichier d'entrée.
  • external: liste des dépendances externes.
  • plugins: tableau de plugins, dans l'ordre json → alias → enumPlugin → replace → esbuild → nodePlugins.
  • output: configuration de sortie.
  • onwarn: filtre les avertissementsCIRCULAR_DEPENDENCY(il existe des dépendances circulaires dans le code source de Vue, mais elles sont inoffensives à l'exécution).
  • treeshake.moduleSideEffects: false: indique à Rollup que tous les modules n'ont pas d'effets de bord, Tree-shaking agressif.

La figure ci-dessous montre le flux de données des variables d'environnement à la configuration finale :

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 产物落盘"]

Écriture des artefacts sur disque et vérification de taille

execGestion des processus de

build.jsLance le sous-processus Rollup viaexec:

📎 scripts/utils.js:64-114

execencapsulespawn, retourne une Promise. Conception clé :

  • stdioPar défaut,['ignore', 'pipe', 'pipe']— stdin ignoré, stdout/stderr capturés par pipe.
  • shell: process.platform === 'win32'— sous Windows, un shell est nécessaire pour analyser correctement la commande.
  • Collecte la sortie via les tableauxstderrChunksetstdoutChunks, concaténée dans l'événementexit.
  • resolve si le code de sortie est 0, sinon reject avec le contenu de stderr.
〔Inférence de conception et compromis architecturaux〕

Notez quebuild.jsappelleexecen passant{ stdio: 'inherit' }, ce qui écrase la configuration de pipe par défaut, faisant passer la sortie de Rollup directement au terminal. C'est le comportement correct d'un outil de construction — l'utilisateur doit voir la progression de la construction en temps réel.

Vérification de taille :checkAllSizes

📎 scripts/build.js:206-215

La vérification de taille a deux conditions de saut :devOnlyest vrai, ou un format est spécifié mais ne contient pasglobal. Car la vérification de taille ne concerne que les artefacts de construction globale — ce sont les fichiers directement importés par l'utilisateur final, les plus sensibles à la taille.

📎 scripts/build.js:222-228

checkSizeVérifie deux fichiers :${target}.global.prod.jset${target}.runtime.global.prod.js(le second n'est vérifié que si aucun format n'est spécifié ou siglobal-runtimeest spécifié).

📎 scripts/build.js:235-264

checkFileSizeLit le fichier, calcule la taille compressée avecgzipSyncetbrotliCompressSync, formate la sortie avecprettyBytes. SiwriteSizeest vrai, écrit le résultat danstemp/size/${fileName}.json— c'est la source de données pour la vérification du budget de taille en CI.

Construction des déclarations de type

📎 scripts/build.js:94-108

SibuildTypesest vrai, appellepnpm run build-dts, et passe la liste des cibles via--environment TARGETS:.... Cela garantit que les déclarations de type ne sont générées que pour les paquets réellement construits.

Réflexions de conception et pièges en production

Pourquoi utiliser--environmentplutôt que de passer directement les arguments ?Le--environmentde Rollup est le seul moyen de passer des arguments lisible viaprocess.envdans le fichier de configuration. Passer directement les arguments--confignécessite d'analyserprocess.argv, tandis que--environmentfournit une analyse structurée clé-valeur.

fuzzyMatchTargetLe piège des expressions régulières. target.match(partialTarget)DanspartialTargetest une entrée utilisateur. Si l'utilisateur saisitruntime-core,-qui est un littéral dans l'expression régulière, pas de problème ; mais si l'entréeruntime.core,.correspond à n'importe quel caractère, elle peut correspondre à une cible inattendue. C'est le risque inhérent de la correspondance floue, mais les noms de paquets Vue ne contiennent pas de caractères spéciaux d'expression régulière, donc en pratique cela ne se déclenche pas.

Compétition de ressources en construction concurrente. runParallelUtilisecpus().lengthcomme limite de concurrence, mais chaque processus Rollup démarre lui-même des workers. Dans les conteneurs CI à faible nombre de cœurs, cela peut provoquer un dépassement de mémoire. En production, en cas d'OOM, on peut atténuer via--max-old-space-sizeou en réduisant le nombre de concurrences.

scanEnumsCycle de vie du cache. removeCacheEst appelé dansfinally, mais siscanEnumslui-même lève une erreur,removeCachene sera pas assigné, et l'appel dansfinallyéchouera. En réalité, la fonction retournée parscanEnumsest déjà déterminée avanttry, donc ce risque n'existe pas — mais c'est un détail temporel à confirmer lors de la lecture.

resolveExternalRisque d'omission.La question de réflexion du chapitre précédent l'a déjà souligné : si l'on ajoute une nouvelle dépendance àruntime-coremais oublie de mettre à jourresolveExternal, la construction navigateur inclura cette dépendance (car elle n'est pas dans la liste external), entraînant une augmentation de taille. C'est le coût inhérent de la stratégie « external par liste blanche ».

Résumé du chapitre

Le voyage complet d'unnode scripts/build.js vue:

1. parseArgsanalyse la ligne de commande,commitobtention synchrone.

2. run()appellescanEnumspour générer le cache d'énumérations, analyse la cible (fuzzyMatchTargetouallTargets)。

3. buildAllviarunParallelplanifie en concurrencebuild。

4. buildlocalise le répertoire du paquet, litpackage.json, filtre les paquets privés, nettoiedist, assemble les arguments--environment, appelleexecDémarrer Rollup.

5. rollup.config.jsLire les variables d'environnement, viacreateConfigGénérer le tableau de configuration,resolveDefine/resolveReplace/resolveExternalTraiter séparément les macros, les remplacements et les dépendances externes.

6. Rollup exécute la construction, les artefacts sont écrits sur disque dansdist/。

7. checkAllSizesCalculer la taille gzip/brotli, écrire optionnellement danstemp/size/。

8. Si--withTypes, appelerbuild-dtsGénérer les déclarations de types.

Réflexions et auto-évaluation de ce chapitre

Q1 : Dansbuild.jsla fonctionbuilddeif (!formats && fs.existsSync(...))Cette condition détermine si l'on supprimedistle répertoire. Si l'on retire!formatscette condition (c'est-à-dire supprimerdistquel que soit le format spécifié),pnpm build-all-cjsdans un script comme

Analyse de référence:

📎 scripts/build.js:172-175

pnpm build-all-cjscorrespond ànode scripts/build.js vue runtime compiler reactivity shared -af cjs(voir📎 package.json:40). Il spécifie-f cjs, doncformatsvaut'cjs',!formatsest faux, la logique actuelle ne supprime pasdist。

Si l'on retire!formats, chaque construction supprimeradist. Maisbuild-all-cjsne construit quecjsle format, après suppressiondistil ne reste quecjsles artefacts, lesesm-bundler、globalet autres formats précédemment construits sont tous perdus. Plus grave encore,build-runtime-esm、build-browser-esmdes scripts comme📎 package.json:39s'exécutent séquentiellement (voirbuild-sfc-playgroundle scriptdistde

Q2: runParallel), chaque script supprimant les artefacts du script précédent, ce qui fait qu'à la finif (maxConcurrency <= source.length)ne contient que le format du dernier script. Cela casserait la construction du SFC Playground — qui nécessite la présence simultanée d'artefacts de plusieurs formats.targets.length === 1Quel est le rôle de la condition

dans:

📎 scripts/build.js:131-151

? Si on la retire, que se passe-t-il lors de la construction d'un seul paquet (maxConcurrency > source.length) ?executingAnalyse de référenceawait Promise.race(executing)。

Cette condition contrôle l'activation ou non de la limitation de concurrence. Lorsqueexecuting, aucune limitation n'est nécessaire — toutes les tâches peuvent démarrer simultanément. Si l'on retire cette condition, même avec une seule tâche, on créerae,Promise.racele tableau et exécuteraexecuting.splice(executing.indexOf(e), 1)Pour une seule tâche,

il n'y a qu'une seule Promise dansmaxConcurrencyqui attendra son achèvement. Cela ne provoquera pas d'erreur, mais introduira une chaîne de Promises et une surcharge de planification de micro-tâches inutiles. Plus important encore,cpus().lengthfonctionne toujours correctement dans un scénario mono-tâche, donc aucune différence fonctionnelle, juste une légère perte de performance.executing.length >= 0Le vrai risque est : siPromise.race([])vaut 0 (théoriquement impossible, carcpus().lengthvaut au moins 1),

Q3: resolveExternalest toujours vrai,treeShakenDepsrestera suspendu indéfiniment. Mais

garantit que cette limite ne sera pas déclenchée.:

📎 rollup.config.js:257-283

treeShakenDepsDanssource-map-js、@babel/parser、estree-walker、entities/decode, la construction navigateur retournecompiler-sfccomme external, mais ces dépendances ne seront pas réellement exécutées dans la branche navigateur. Que se passe-t-il si on les retire de la liste external (c'est-à-dire si on laisse Rollup tenter de les empaqueter) ?__BROWSER__Analyse de référence

contienttreeshake.moduleSideEffects: false(📎 rollup.config.js:355-355. Ce sont les dépendances de paquets commeif (!__BROWSER__), exclues par compilation conditionnelle via la macro__BROWSER__dans la construction navigateur.trueSi on les retire des external, Rollup tentera de résoudre et d'empaqueter ces dépendances. Comme

), et que les instructions d'import de ces dépendances se trouvent dans la brancheonwarn, le define d'esbuild remplacera

parscripts/dev.js, ce qui marquera la branche comme code mort. Le Tree-shaking de Rollup supprimera ces imports, et le produit final ne contiendra pas le code de ces dépendances.

Transformez n'importe quel code en un livre compréhensible

Vous avez aimé ce chapitre ? Créez un livre pour votre projet privé

Architecture local-first en Tauri 2 + Rust. Sécurité 100% hors ligne, zéro code téléversé. Lecture double panneau avec ancres de commits immuables.

⚡ Tauri 2 · Rust Core · 100% Hors ligne & Privé · Testé sur 1M+ lignes

CHAPTER 03

collabore avec la précompilation SFC pour réaliser une boucle de retour de développement en millisecondes.

Upstream: vuejs/core · Commit @4ab865a8 · Progression: Chapitre 3 sur 14

Chapitre 3 : Chaîne de développement : mécanisme de collaboration entre le script dev et la précompilation SFCscripts/dev.jsProjet : vuejs/corescripts/pre-dev-sfc.jsProgression du livre : Chapitre 3 / 14

État de vérification : ancrage réel des numéros de ligne FACT

Dans le chapitre précédent, nous avons suivi la chaîne complète de la construction de production, de l'analyse des paramètres à l'écriture des artefacts multi-formats sur disque ; cette chaîne vise la complétude et la conformité des artefacts. Or le besoin fondamental du mode développement est unique : modifier une ligne de code et voir immédiatement l'effet dans le navigateur. La chaîne de construction de production « analyser les paramètres → générer la configuration → empaqueter entièrement → écrire sur disque » prend des dizaines de secondes, ce qui ne peut absolument pas satisfaire ce besoin. Le dépôt Vue core maintient pour cela une chaîne de développement indépendante :

utiliser le mode watch d'esbuild pour la construction incrémentale,📎 scripts/dev.js:3-5

précompiler le compilateur SFC avant la construction principale. Ce chapitre décompose le mécanisme de collaboration entre ces deux éléments.

3.1 dev.js : un constructeur incrémental qui mise sur esbuild pour la vitesse

Modèle intuitifparseArgsLa construction de production ressemble à « la composition et l'impression officielles d'une imprimerie » — la qualité prime, la lenteur n'est pas grave ; la construction de développement ressemble à « un croquis au crayon sur un brouillon » — pas besoin d'être beau, juste immédiat. Vue choisit esbuild plutôt que Rollup pour ce croquis, la raison étant écrite dans le commentaire en tête de fichier : les artefacts de Rollup sont plus petits, le Tree-shaking meilleur, mais esbuild est bien plus rapide.formatSans ce script, les développeurs devraient exécuter une construction de production complète à chaque modification, la boucle de retour passant de l'échelle de la milliseconde à celle de la minute, et l'expérience de hot reload disparaîtrait totalement.global)、prodAnalyse des paramètres et déduction des formatsfalse)、inline(par défautfalse)。📎 scripts/dev.js:18-40les paramètres positionnels sont collectés commetargets, s'ils sont vides, la valeur par défaut est['vue']。📎 scripts/dev.js:42-53

〔Inférence de conception et compromis architecturaux〕

Il y a ici un détail facile à négliger :rawFormatetformatsont deux affectations distinctes.parseArgsledefault: 'global'derawFormatgarantit déjà queconst format = rawFormat || 'global'a une valeur, mais le script écrit tout de même📎 scripts/dev.js:42comme filet de sécurité.parseArgsC'est une écriture défensive, pour éviter queformat.startsWithne change de comportement ou qu'une chaîne vide explicitement passée ne fasse lever une erreur en aval à

formatLa correspondance vers le format de sortie esbuild se fait en trois branches : ce qui commence parglobalest mappé versiife, ce qui est égal àcjsest mappé verscjs, tout le reste estesm。📎 scripts/dev.js:42-53Le suffixe du nom de fichier produit est quant à lui géré séparément par le suffixe-runtime:global-runtimedevientruntime.global, le reste reste inchangé.📎 scripts/dev.js:42-53

Localisation du package cible et chemin de sortie

Le script lit d'abord la liste du répertoirepackages-privatepour déterminer si le package cible est un package public ou privé.📎 scripts/dev.js:56Pour chaque target, il détermine si le chemin de base du package estpackagesoupackages-private, puisrequiresonpackage.jsonpour obtenirversionetbuildOptions。📎 scripts/dev.js:58-63

Le nom du fichier de sortie a un cas particulier :vue-compatla ciblevueest renommée envue-compat.global.js。📎 scripts/dev.js:64-69, pour éviter que le produit ne s'appellepackages/vue/dist/vue.global.js,prodLe chemin final a la formeprod.insère le segment

lorsque la condition est vraie.

externalRésolution des external : éviter d'inclure les dépendances dans le produit

Le tableauinlinedétermine quels modules ne sont pas bundlés. La logique se divise en deux niveaux :cjsPremier niveau, lorsqueesm-bundlern'est pas activé et que le format estdependencies、peerDependenciesou contientpath、url、stream, toutes les clés de📎 scripts/dev.js:76-88sont ajoutées aux external, et les trois modules intégrés Node@vue/compiler-sfcsont codés en dur.server-rendererUn commentaire précise explicitement que ces trois sont destinés à

etcompiler-sfcDeuxième niveau, pour la cible@vue/consolidate, on résout en plus lesdevDependenciesdefs、vm、crypto, et on les externalise avec📎 scripts/dev.js:90-112etc.react-dom/server、teacup/lib/express、arc-templates/dist/es5、then-pug、then-jadeLe code code également en dur des chemins de moteurs de template comme

— ce sont des moteurs de template supportés par consolidate, des dépendances optionnelles, qui ne peuvent pas être installées de force.

〔Inférence de conception et compromis architecturaux〕rollup.config.jsCette logique est hautement redondante avecTODO this logic is largely duplicated from rollup.config.js, et les commentaires du code source l'admettent (

). La raison pour laquelle aucune fonction commune n'a été extraite est qu'il existe de subtiles différences dans la stratégie external entre dev et prod (dev externalise de manière plus agressive pour accélérer la construction), et une unification forcée augmenterait au contraire le couplage.

Plugins et injection de definelog-rebuildLe tableau de plugins ne contient par défaut qu'un seulonEnd, qui affiche dans le hook📎 scripts/dev.js:115-124le chemin relatif du produit de construction.

C'est le seul signal de retour permettant au développeur de percevoir que « la modification a pris effet ».

〔Inférence de conception et compromis architecturaux〕cjsLe deuxième plugin est conditionnel : lorsque le format n'est pasbuildOptions.enableNonBrowserBrancheset que lepolyfillNode()。📎 scripts/dev.js:126-128du package est vrai, on montecompiler-sfcDes packages comme

define(par exemple📎 scripts/dev.js:141-159) empruntent toujours la branche Node dans une construction navigateur, et nécessitent un polyfill des modules intégrés Node pour fonctionner dans un environnement navigateur.__XXX__Le bloc

  • __COMMIT__est la partie la plus dense en informations de ce chapitre."dev",__VERSION__Il remplace toutes les macros
  • __DEV__du code source par des littéraux :prodest fixé à__TEST__prend la version du package ;false;
  • __BROWSER__est déterminé par le flagformat !== 'cjs' && !pkg.buildOptions?.enableNonBrowserBranches。📎 scripts/dev.js:146-148,
  • __SSR__est toujoursformat !== 'global'La dérivation de
  • __COMPAT__est la plus subtile :vue-compatAutrement dit, seul « non-cjs et package ne supportant pas la branche non-navigateur » est marqué comme environnement navigateur ;
  • vaut__FEATURE_SUSPENSE__、__FEATURE_OPTIONS_API__、__FEATURE_PROD_DEVTOOLS__、__FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__, c'est-à-dire que la construction global n'active pas la branche SSR ;

est déterminé par le fait que le target estvitest.config.tsou non ;defineles trois feature flags (📎 vitest.config.ts:6-21) sont tous codés en dur en mode dev.__TEST__Ces macros correspondent une à une au bloctrue、__DEV__danstrueL'environnement de test définit

à

etesbuild.context(...).then(ctx => ctx.watch())。📎 scripts/dev.js:130-161 contextàwatch(), et la différence avec la construction dev est précisément le point de distinction entre les deux états d'exécution « test vs développement ».onEndDémarrage du mode watch

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: 相对路径"]

crée le contexte de construction mais ne l'exécute pas immédiatement,

ne lance réellement la surveillance de fichiers qu'ensuite. Par la suite, esbuild maintient en interne le graphe de dépendances, tout changement d'un fichier dépendu déclenche une reconstruction incrémentale, et le callback de fin de reconstruction

affiche le journal.compiler-sfcCopiercompiler-core3.2 pre-dev-sfc.js : la sentinelle de précompilation qui brise les dépendances circulairescompiler-coreModèle intuitifcompiler-sfcImaginez un dilemme de « l'œuf et la poule » :.vuele code source depre-dev-sfc.jsimporte

, et

en mode développement a besoin decompiler-sfc、compiler-core、compiler-dom、compiler-ssr、shared。📎 scripts/pre-dev-sfc.js:4-10pour traiter les fichierspackages/${pkg}/dist/${pkg}.cjs.js. Si les deux reposent sur la compilation en temps réel d'esbuild watch, celui qui compile en premier se bloque.📎 scripts/pre-dev-sfc.js:4-23

Le rôle deallFilesPresentest de « faire éclore l'œuf d'abord, puis élever la poule » — avant le démarrage de la construction principale, s'assurer que les produits CJS de ces packages existent déjà.falseListe de vérification et logique de court-circuitbreakLe script maintient une liste fixe :📎 scripts/pre-dev-sfc.js:20-21Pour chaque package, il vérifie siallFilesPresentexiste.process.exit(1)Si un seul est manquant,📎 scripts/pre-dev-sfc.js:25-27

est mis à

etexit(1)immédiatement, sans vérifier les packages restants.&&Enfin, si

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

se termine avec un code non nul.

scripts/dev.jsSémantique du code de sortiescripts/aliases.jsCe script n'effectue lui-même aucune compilation, il ne fait qu'une « assertion d'existence ».📎 scripts/aliases.js:7-7

est un signal destiné à l'appelant supérieur (généralement la chaîne

resolveEntryForPkgd'un npm script ou un script CI) : les produits sont incomplets, il faut d'abord lancer une construction complète. Si tout existe, il se termine normalement (code de sortie 0), et la construction principale continue.packages/${p}/src/index.ts。📎 scripts/aliases.js:7-7Copiervue、vue/compiler-sfc、vue/server-renderer、@vue/compat。📎 scripts/aliases.js:16-21

3.3 aliases.js et vitest.config.ts : l'autre moitié de la chaîne en mode développementpackagesrésout la question de « comment générer rapidement les produits », mais en développement il existe un autre chemin : lancer les tests.vuefournit des alias de chemins partagés pour vitest et rollup.nonSrcPackages(sfc-playground、template-explorer、dts-testLogique de génération des alias@vue/${dir}mappe les noms de packages vers📎 scripts/aliases.js:23-35

Les entries de base codent en dur quatre mappings spéciaux :

Ensuite, il parcourt tous les sous-répertoires du répertoirenonSrcPackagesLa liste d'exclusion s'explique par le fait que ces trois paquets n'ont pas desrc/index.tspoint d'entrée, et un mappage forcé entraînerait un échec de résolution.

Le define et la consommation d'alias de vitest

vitest.config.tsimport directentriesen tant queresolve.alias。📎 vitest.config.ts:3📎 vitest.config.ts:22-24sondefinebloc contraste avec l'injection de macros de dev.js : environnement de test__DEV__: true、__TEST__: true、__BROWSER__: false、__CJS__: true。📎 vitest.config.ts:6-21

Les tests sont divisés en cinq projets :unit、unit-gc、unit-jsdom、e2e、e2e-browser。📎 vitest.config.ts:51-118parmi lesquelsunit-gcutilisepool: 'forks'et passe--expose-gc, dédié aux tests SSR nécessitant un déclenchement manuel du GC.📎 vitest.config.ts:65-76 e2e-browseractive quant à lui une instance chromium de playwright pour exécuter les tests liés à 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

Réflexion de conception

Pourquoi utiliser esbuild en dev et Rollup en prod ?Ce n'est pas un choix technologique arbitraire, mais les contraintes diffèrent selon les deux scénarios. En développement, la taille du produit importe peu, mais la latence de retour est critique ; en production, c'est l'inverse. esbuild, écrit en Go et hautement parallélisé, offre un démarrage à froid et une construction incrémentale un ordre de grandeur plus rapides, mais ses capacités de Tree-shaking et de découpage de code sont inférieures à celles de Rollup.📎 scripts/dev.js:3-5Utiliser deux ensembles d'outils pour servir deux scénarios distincts est un compromis pragmatique d'ingénierie.

〔Inférence de conception et arbitrage architectural〕

Pourquoi pre-dev-sfc ne fait-il que vérifier sans compiler ?S'il déclenchait lui-même la compilation, il réintroduirait la dépendance circulaire — il doit compilercompiler-sfc, et le processus de compilation lui-même peut dépendre descompiler-sfcproduits de. Il ne peut donc faire qu'une « assertion », exposant le fait « produit manquant » à la couche supérieure, qui décide d'exécuter une construction complète ou de quitter avec une erreur. C'est un « mode sentinelle » : ne pas résoudre le problème, seulement le signaler.

La duplication de la liste external est-elle une dette technique ?La logique external de dev.js et rollup.config.js est dupliquée, et les commentaires du code source l'admettent.📎 scripts/dev.js:73Mais les ensembles external des deux ne sont pas totalement identiques — dev externalise de manière plus agressive pour la vitesse. Extraire de force une fonction commune nécessiterait d'introduire un commutateur de différence paramétré, rendant les deux logiques plus difficiles à lire. C'est un arbitrage typique de « la duplication vaut mieux qu'une mauvaise abstraction ».

Résumé de ce chapitre

Ce chapitre décompose les trois pièces du puzzle de la chaîne de développement de Vue core :

1. scripts/dev.js: utiliser lecontext().watch()d'esbuild pour réaliser une construction incrémentale, viaparseArgsanalyser le format et les indicateurs, dynamiquementrequirele paquet ciblepackage.jsonlocaliser le chemin de sortie, injecter__DEV__、__BROWSER__et autres macros pour contrôler la compilation conditionnelle, et utiliserlog-rebuildun plugin pour afficher un retour après chaque reconstruction.

2. scripts/pre-dev-sfc.js: vérifier avant la construction principale si les produits CJS des cinq paquets principaux existent, et en cas d'absence, court-circuiter avec le code de sortie 1, évitant un blocage de construction dû à une dépendance circulaire.

3. scripts/aliases.js + vitest.config.ts: fournir des alias de chemin partagés pour la chaîne de test, avec des éléments spéciaux codés en dur et une analyse dynamique des éléments génériques, accompagné d'une configuration multi-projets couvrant cinq scénarios de test : unitaire, GC, jsdom, e2e et e2e navigateur.

Réflexions et auto-évaluation de ce chapitre

Q1 : Si l'on retirescripts/pre-dev-sfc.jsdansbreak(c'est-à-dire vérifier tous les paquets avant de décider de quitter), dans quel scénario l'expérience développeur se dégraderait-elle ? Pourquoi l'auteur du code source a-t-il choisi de « court-circuiter dès le premier manque détecté » ?

Analyse de référence:

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

breakse situe dansif (!fs.existsSync(...))la branche, et dès qu'un produit de paquet manquant est détecté, la boucle est immédiatement interrompue.

Si l'on retirebreak, le script continuerait de vérifier les paquets restants, et finalementallFilesPresentresteraitfalse, le code de sortie resterait 1,fonctionnellement équivalent. Mais la différence réside dans :

1. Performance: les cinqexistsSyncappels sont eux-mêmes rapides, mais si la liste s'étend à des dizaines de paquets, le court-circuit économise un grand nombre d'appels système stat inutiles.

2. Sémantique: le court-circuit exprime « s'il en manque un seul, l'ensemble est incomplet » — c'est une assertion booléenne, il n'est pas nécessaire de savoir combien manquent précisément. Continuer la vérification ne produit aucune information supplémentaire.

3. Expérience développeur: en réalité, ce qui se dégrade, c'est le « message d'erreur ». Le script actuel n'indique pas quel paquet manque, le développeur ne voit que le code de sortie 1. Si l'on retiraitbreaket ajoutait des logs, on pourrait au contraire indiquer au développeur « il manque compiler-core et shared » — mais cela nécessite du code supplémentaire. L'auteur a choisi l'implémentation la plus simple, laissant le diagnostic de « lequel manque » au script de construction supérieur.

Doncbreakle motif central est « sémantique d'assertion + performance », et non l'optimisation de l'expérience.

Q2: scripts/dev.jsdans__BROWSER__la déduction deformat !== 'cjs' && !pkg.buildOptions?.enableNonBrowserBranchesestbuildOptions.enableNonBrowserBranches. Supposons qu'un paquet aittruepour-f global, et que le développeur construise avec__BROWSER__, alorsfalseesttrue. Quelle en serait la conséquence ? Que se passerait-il si l'on modifiait par erreur en

Analyse de référence:

📎 scripts/dev.js:146-148

Lorsqueformat = 'global'etenableNonBrowserBranches = true:

  • format !== 'cjs'esttrue
  • !pkg.buildOptions?.enableNonBrowserBranchesestfalse
  • global__BROWSER__ = false

Cela signifie que toutes les branchesif (__BROWSER__)du code source sont remplacées par le define d'esbuild enif (false), le code spécifique au navigateur est supprimé par Tree-shaking, et les branches non-navigateur (logique spécifique à Node) sont conservées.

Conséquence: le produit de construction global est censé s'exécuter dans le navigateur, mais il contient des branches spécifiques à Node. Si ces branches référencentfs、pathet autres modules intégrés de Node, le navigateur signalera « module non défini » au chargement. C'est précisément pourquoienableNonBrowserBranchesles paquets pour lesquels c'est vrai (commecompiler-sfc) ne sont généralement pas utilisés pour la construction global, ou nécessitentpolyfillNode()un plugin de secours.📎 scripts/dev.js:126-128

Si l'on modifie par erreur entrue:__BROWSER__ = true, la branche navigateur est conservée et la branche Node supprimée. Pourcompiler-sfcce type de paquet qui doit exécuter la compilation SFC dans l'environnement Node, cela entraînerait la suppression par Tree-shaking des fonctionnalités essentielles (lecture de fichiers, appels aux API Node), et le produit signalerait « fonction non définie » à l'exécution dans Node.

Q3: scripts/aliases.jsdans, lors de l'analyse dynamique dupackagesrépertoire, a sauténonSrcPackages(sfc-playground、template-explorer、dts-test). Si un nouveau paquet est ajouté aupackagesrépertoire mais sanssrc/index.ts, et n'a pas été ajouté ànonSrcPackages, que se passe-t-il ? À quelle étape vitest signalera-t-il une erreur lors de l'exécution ?

Analyse de référence:

📎 scripts/aliases.js:23-35

La logique de balayage dynamique est la suivante : pour chaque répertoire, sidir !== 'vue', n'est pas dansnonSrcPackages, la clé n'existe pas, et c'est un répertoire, alors on l'ajoute àentries['@vue/${dir}'] = resolveEntryForPkg(dir)。

resolveEntryForPkgrenvoie le chemin depackages/${p}/src/index.ts.📎 scripts/aliases.js:7-7Notez qu'ilne vérifie pas si le fichier existe, il ne fait que concaténer le chemin.

Conséquence: l'alias sera enregistré, mais pointera vers un fichier inexistant. Lorsque vitest résout un import, si un fichier de test importe ce package, le plugin resolve de Vite tentera de charger ce chemin et signalera « impossible de résoudre le module » ou « fichier inexistant ».

Étape de l'erreur: ce n'est pas au moment de l'exécution dealiases.js(il ne fait que de la concaténation de chaînes), mais après le démarrage de vitest, lors de la première résolution de cet import. Si aucun test n'importe ce package, aucune erreur ne sera signalée — l'alias reste simplement dans l'objetentries.

Moyen de contournement: ajoutez ce type de package sanssrc/index.tsànonSrcPackages, ou assurez-vous que le nouveau package possède un point d'entrée standard. C'est aussi pourquoinonSrcPackagesdoit être maintenu manuellement — c'est la liste des exceptions à la règle « convention plutôt que configuration ».

Les frontières de la collaboration entre les trois sont très claires :pre-dev-sfcgère « si les artefacts sont prêts »,dev.jsgère « comment mettre à jour rapidement les artefacts »,aliasesgère « comment les tests résolvent le code source ». La chaîne en mode développement résout le problème de vitesse, mais il existe une autre catégorie d'optimisations plus discrètes au moment de la construction — celles qui sont effectuées avant que le code ne soit exécuté par le navigateur. Le chapitre suivant abordera la magie de la compilation, pour voir comment l'inlining des enums et le mécanisme de vérification du Tree-shaking remplacent les TypeScript enum par des littéraux au moment de la construction, et garantissent que la promesse d'importation à la demande n'est pas brisée.

Transformez n'importe quel code en un livre compréhensible

Vous avez aimé ce chapitre ? Créez un livre pour votre projet privé

Architecture local-first en Tauri 2 + Rust. Sécurité 100% hors ligne, zéro code téléversé. Lecture double panneau avec ancres de commits immuables.

⚡ Tauri 2 · Rust Core · 100% Hors ligne & Privé · Testé sur 1M+ lignes

CHAPTER 04

Chapitre 4 : Magie de la compilation : inlining des enums et mécanisme de vérification du Tree-shaking

Upstream: vuejs/core · Commit @4ab865a8 · Progression: Chapitre 4 sur 14

Dans le chapitre précédent, nous avons vu comment la chaîne en mode développement échange la surveillance de fichiers et la construction incrémentale contre la vitesse de « modifier une ligne et prendre effet immédiatement ». Mais au-delà de la vitesse, Vue a une autre contrainte plus discrète : la taille des artefacts publiés doit être contrôlable. L'un des ennemis de cette contrainte est l'enum de TypeScript — il s'agit d'un objet réellement existant à l'exécution, qui brise le Tree-shaking. Ce chapitre entre dans la phase de compilation, pour voir comment scripts/inline-enums.js « dissout » les enums en littéraux avant que le code ne soit exécuté par le navigateur ; puis comment scripts/verify-treeshaking.js, après la construction, utilise les chaînes des artefacts pour vérifier en sens inverse que la promesse d'« importation à la demande » n'a pas été silencieusement brisée.

4.1 Inlining des enums : dissoudre les objets d'exécution en littéraux

Modèle intuitif

Imaginez que vous écrivez une recette dans laquelle « un peu de sel » apparaît à plusieurs reprises. Si à chaque fois que vous cuisinez, vous devez feuilleter l'annexe pour vérifier que « un peu = 3 grammes », c'est à la fois lent et encombrant. Ce que fait l'inlining des enums, c'est remplacer directement dans tout le livre « un peu de sel » par « 3 grammes de sel » avant l'impression, puis arracher la page de l'annexe. Pour le lecteur (l'exécution), le résultat est exactement le même, mais le livre est plus fin.

Sans cela, quel désastre le système affronterait-il ? Unenumordinaire de TypeScript génère après compilation un véritable objet littéral, avec un mapping bidirectionnel (Enum[Enum.A] === 'A'). Cet objet estune déclaration au niveau du module avec effets de bord, Rollup ne peut pas prouver qu'il n'est pas utilisé, il doit donc le conserver — même si vous n'importez qu'un seul de ses membres, l'objet enum entier ainsi que le mapping inverse seront inclus dans l'artefact.📎 scripts/inline-enums.js:3-9Le commentaire deconst enumest très explicite : ils utilisaient auparavant

, mais en raison de l'issue #1228, ils sont passés à un enum ordinaire, et utilisent donc ce script pour « récupérer manuellement le bénéfice zéro coût du const enum ».

Structures de données et disposition mémoire📎 scripts/inline-enums.js:33-36

  • EnumMember:{ name, value }Le cœur du script réside dans trois définitions de types ; les comprendre, c'est comprendre tout le flux de données.
  • EnumDeclaration:{ id, range: [start, end], members }。range, le nom d'un membre d'enum individuel et le littéral après évaluation.estl'offset en octets dans le code sourceexport enum X { ... }, pointant vers la position de début et de fin de toute la déclaration
  • EnumData:{ declarations, defines }。declarationsdans le fichier — c'est l'ancre pour le remplacement précis ultérieur par MagicString.definesest indexé par chemin de fichier, enregistrant les plages de remplacement de toutes les déclarations d'enum dans ce fichier ; est un mapping plat, dont la clé est le littéral après ` 形式的字符串,值是 ${nomEnum}.${nomMembre}

JSON.stringify`.definesIl y a ici une conception clé :la clé de。📎 scripts/inline-enums.js:98-103ne contient pas le chemin de fichierErrorCodes. Le commentaire explique la raison —@vue/compiler-corepeut exister simultanément dans@vue/runtime-coreetErrorCodes.__EXTEND_POINT__, donc les enums de même nom peuvent exister dans différents fichiers ; mais le mêmefullKey in definesn'est pas autorisé à se répéter dans deux enums de même nom, sinonname conflictest atteint et

est directement levé. C'est une contrainte d'« unicité globale par nom de membre », et non d'« unicité globale par nom d'enum ».temp/enum.json。📎 scripts/inline-enums.js:33-36Le cache est stocké dansscanEnums()Pourquoi faut-il l'écrire sur disque ? Parce quen'est appelé qu'une seule fois à l'entrée de la construction, tandis que Rollup démarre。📎 scripts/inline-enums.js:39-41des processus indépendantsinlineEnums()pour chaque package et chaque format. Le commentaire précise : les données doivent être partagées entre les processus Rollup concurrents, elles doivent donc être sérialisées sur disque et relues par le

de chaque processus.

Step-by-Step : du grep au remplacement par littérauxexport enumPremière étape : grep tous les fichiers contenant📎 scripts/inline-enums.js:51-61.spawnSync('git', ['grep', 'export enum'])utilisepath:line:content, la sortie ressemble à:, puis on découpe le premier segment (chemin de fichier) selonSet, et on déduplique avecgit grepau lieu de parcourir le système de fichiers — il ne scanne naturellement que les fichiers suivis par Git, excluant automatiquementnode_moduleset les artefacts de build.

Deuxième étape : Babel analyse et collecte les informations d'énumération.📎 scripts/inline-enums.js:64-70Pour chaque fichier, on utilise@babel/parseravectypescriptle plugin,sourceType: 'module'pour analyser en AST, puis on ne parcourt queast.program.bodyles nœuds de premier niveau.📎 scripts/inline-enums.js:74-79On ne reconnaît queExportNamedDeclarationet sesdeclaration.type === 'TSEnumDeclaration'nœuds — c'est-à-dire queles enum non exportés ne seront pas traités。

Pour chaque déclaration d'énumération, le script évalue chaque membre. L'évaluation des membres suit trois chemins :

1. Initialisation littérale:StringLiteralouNumericLiteralon prend directementinit.value。📎 scripts/inline-enums.js:114-119

2. Expression binaire: comme1 << 2. RécursivementresolveValueon traite les opérandes gauche et droit, les opérandes pouvant être des littéraux ou desMemberExpression(c'est-à-dire une référence à un membre d'énumération déjà défini).📎 scripts/inline-enums.js:121-151Le point clé est la brancheMemberExpression: elle utilisecontent.slice(node.start, node.end)pour extrairedu texte source originalla chaîne d'expression (commeErrorCodes.FOO), puis consultedefines. Si introuvable, on lanceunhandled enum initialization expression。📎 scripts/inline-enums.js:132-141Cela explique pourquoidefinesdoit être un mapping plat global — lors d'une référence inter-énumérations, le référencé peut provenir d'un autre fichier, mais la clé ne reconnaît que枚举名.成员名。

3. Expression unaire: comme-1, on assemble la chaîne-1puis on utiliseevaluatepour évaluer.📎 scripts/inline-enums.js:152-163

L'évaluation elle-même utilisenew Function('return ' + exp)()。📎 scripts/inline-enums.js:39-41C'est uneval contrôlé: l'entrée provient de fragments d'AST déjà analysés dans le code source, pas d'une entrée utilisateur arbitraire, donc la frontière de sécurité est contrôlable.

Troisième étape : traitement des membres sans initialiseur (sémantique d'auto-incrémentation).📎 scripts/inline-enums.js:171-183Si un membre n'a pasinitializer: le premier membre vaut par défaut0; si les membres suivants ontlastInitializedqui est un nombre alors++; si c'est une chaîne alors on lancewrong enum initialization sequence— car les membres d'énumération de type chaîne n'autorisent pas l'auto-incrémentation implicite. C'est exactement la sémantique des enums TypeScript.

Quatrième étape : écrire le cache et retourner la fonction de nettoyage.📎 scripts/inline-enums.js:200-213 scanEnums()Retourne une closure, dont l'appelrmSyncsupprime le fichier de cache.build.jsOn l'utilise danstry/finally.📎 scripts/build.js:81-112Cela garantit que même si une erreur survient en cours de build, le cache sera nettoyé et ne polluera pas le build suivant.

Cinquième étape : remplacement lors de la phase transform de Rollup. inlineEnums()On relit le cache et on construit un plugin Rollup.📎 scripts/inline-enums.js:219-234Danstransform(code, id), siidcorrespond àenumData.declarations, on utilise MagicString pour remplacer[start, end]cette déclaration par un littéral d'objet.📎 scripts/inline-enums.js:242-274

La forme après remplacement estexport const X = { ... }. Notez qu'ilne s'agit pas simplement de supprimer l'énumération, mais de la réécrire en littéral d'objet, et de générer en plus un mapping inverse pour les membres numériques :JSON.stringify(value.toString()) + ': ' + JSON.stringify(name)。📎 scripts/inline-enums.js:257-270Le commentaire cite la règle reverse-mappings de la documentation officielle TypeScript : les membres d'énumération de type chaîne ne génèrent pas de mapping inverse, les membres numériques oui. Cela garantit que le comportement à l'exécution après remplacement est totalement identique à l'enum original.

Et ce qui élimine réellement le coût à l'exécution, c'est quedefinesest confié à@rollup/plugin-replace。📎 rollup.config.js:222-223toutes lesX.Memberréférences àsontdirectement remplacées par des littéraux dans le plugin de remplacement, de sorte que le littéral d'objet réécrit, s'il n'est utilisé par personne, peut être éliminé par le Tree-shaking.

Le diagramme de flux ci-dessous décrit le chemin de décision complet, du grep au remplacement :

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

Réflexions de conception et pièges

Pourquoi utiliser MagicString plutôt que régénérer tout le fichier ?Parce ques.update(start, end, ...)ne remplace que le segment de la déclaration d'énumération, le reste des octets du code source reste totalement inchangé,s.generateMap()et on peut encore générer une sourcemap précise.📎 scripts/inline-enums.js:277-281Si l'on utilisait Babel pour réimprimer tout l'AST, on perdrait le formatage original, les commentaires, et la qualité de la sourcemap se dégraderait.

rangePourquoinode.start/node.endplutôt quedeclaration.start?📎 scripts/inline-enums.js:189-193ce qui est asserté estnode.start(c'est-à-direExportNamedDeclarationle nœud), la plage de remplacement couvreexport enum X {...}tout le segment, y comprisexportle mot-clé. Le texte de remplacement commence parexport const, s'enchaînant parfaitement.

Pièges :definesLa contrainte d'unicité globale deSi deux fichiers différents contiennent chacun unErrorCodes, et que tous deux définissent__EXTEND_POINT__, le build échouera directement.📎 scripts/inline-enums.js:101-103Ce n'est pas un bug, mais une conception délibérée — cardefinesest une table de remplacement globale, incapable de distinguer la provenance des fichiers. En production, lors de l'ajout d'un nouveau membre d'énumération, si son nom entre en conflit avec un membre d'énumération existant, cela explosera ici.

Piège :new FunctionLe moment d'évaluation deL'évaluation des expressions binaires se produit à la phasescanEnums, à ce momentdefinespeut ne pas encore contenir le membre référencé (si l'ordre de référence est inversé).📎 scripts/inline-enums.js:136-140lanceraunhandled enum initialization expression. Cela exige que la référence aux membres d'énumération respecte l'ordre du code source « définir avant de référencer ».

4.2 Vérification du Tree-shaking : prouver la promesse à rebours via les chaînes du produit

Modèle intuitif

L'inlining des énumérations est une « optimisation a priori », mais l'optimisation prend-elle vraiment effet ? Si un helper est accidentellement conservé à cause d'une mauvaise écriture, la taille gonflera silencieusement, sans que le développeur ne s'en aperçoive.verify-treeshaking.jsC'est le « contrôleur qualité a posteriori » : il construit le produit, puis inspecte comme lors d'une autopsie sice qui ne devrait pas apparaître apparaîtdans le produit. Sans lui, la promesse d'import à la demande de Vue pourrait silencieusement se briser après une refactorisation, jusqu'à ce que les utilisateurs se plaignent de la taille du bundle.

Structures de données et éléments de vérification

Ce script n'a pas de structure de données complexe, le cœur est unerrorstableau et troisincludesvérifications.📎 scripts/verify-treeshaking.js:6-6Il construit d'abordglobal-runtimele format, puis lit respectivement les produits dev et prod.

Les trois vérifications correspondent à trois types d'« échecs de Tree-shaking » :

1. Le produit dev contient__spreadValues。📎 scripts/verify-treeshaking.js:13-19C'est le helper généré par esbuild pour{ ...obj }la syntaxe de spread d'objet. S'il apparaît, cela signifie que le code à l'exécution utilise le spread d'objet, alors que la convention Vue devrait utiliserextendle helper pour éviter du code supplémentaire.

2. Le produit prod contientVue warn。📎 scripts/verify-treeshaking.js:26-31Cela indique qu'il y awarn()des appels qui ne sont pas__DEV__enveloppés par la condition, entraînant une fuite du code d'avertissement dans le bundle de production.

3. Le produit prod contient la liste de configuration des tags DOM。📎 scripts/verify-treeshaking.js:33-42commehtml,body,base、svg,animate,animateMotion、annotation,annotation-xml,maction. Ce sontisHTMLTag()Les données internes à des helpers comme celui-ci ne devraient exister que dans le compilateur et être éliminées par le runtime. Si elles apparaissent dans les artefacts d'exécution, cela signifie que le chemin d'exécution utilise à tort un helper réservé au compilateur.

Étape par étape : processus de validation

📎 scripts/verify-treeshaking.js:5-5D'abordexec('pnpm', ['build', 'vue', '-f', 'global-runtime']), construire uniquementvuele paquetglobal-runtimeau format — c'est l'artefact d'exécution minimal, le plus susceptible d'exposer les fuites. Une fois la construction terminée, lire les deux fichiers en parallèle, vérifier un par unincludes, et en cas de correspondance, pousser danserrorsun message accompagné d'une explication. Enfin, sierrors.lengthest non nul, lever une erreur agrégée.📎 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 聚合错误"]

Réflexions de conception et pièges rencontrés

〔Inférence de conception et compromis architecturaux〕

Pourquoi utiliser la chaîneincludesplutôt qu'une analyse AST ?Parce qu'il s'agit d'une « vérification sentinelle » et non d'une « analyse précise ». Elle ne vise pas l'exhaustivité, mais se limite à mettre en place des alertes à faible coût pour trois types de régressions réellement survenues dans l'historique. La correspondance de chaînes n'a aucune dépendance, aucun coût d'analyse, et reste efficace sur les artefacts minifiés — l'analyse AST est au contraire plus difficile après minification.

〔Inférence de conception et compromis architecturaux〕

Pourquoi ne valider queglobal-runtime?ce format inline toutes les dépendances (externalest vide), c'est l'artefact le plus sensible au volume et le plus susceptible d'être introduit par erreur. S'il est propre, les autres formats le sont généralement aussi. De plus, sa construction est rapide, ce qui le rend adapté à une exécution fréquente en CI.

〔Inférence de conception et compromis architecturaux〕

Piège : les éléments vérifiés constituent une « liste noire », qui devient obsolète à mesure que le code évolue.Si un jourisHTMLTagla structure de données change,html,body,basecette chaîne n'apparaîtra plus et la vérification sera vide de sens. Cela exige que les mainteneurs mettent à jour ces chaînes sentinelles en même temps qu'ils modifient les helpers concernés. C'est le coût inhérent à une validation par liste noire.

4.3 Collaboration avec Rollup : ordre des plugins et injection de define

L'inlining des enums ne fonctionne pas isolément ; il s'insère dans le pipeline de plugins de Rollup. Comprendre sa position dans le pipeline permet de comprendre pourquoidefinesdoit être confié àreplaceplutôt qu'àesbuild。

📎 rollup.config.js:47-50appeler au niveau supérieur du module de configurationinlineEnums(), pour déstructurer[enumPlugin, enumDefines]. Notez que cela s'exécuteau démarrage de chaque processus Rollup, et lit le cache écrit parscanEnums.

L'ordre du tableau de plugins est :json → alias → enumPlugin → ...resolveReplace() → esbuild。📎 rollup.config.js:324-339 enumPluginest placé avantreplace, ce qui signifie que la réécriture des déclarations d'enum a lieu en premier, puisreplaceutilisedefinespour remplacer les références. Etesbuildest placé en dernier, chargé de la transpilation TS.

Pourquoidefinespasse parreplaceet non paresbuild? Le commentaire dedefine?📎 rollup.config.js:220-221donne la réponse : le define d'esbuild « est un peu strict, n'autorisant que des littéraux JSON ou des identifiants ». Or les noms de membres d'enum commeErrorCodes.__EXTEND_POINT__sont des expressions de membre avec point, et le define d'esbuild ne peut pas traiter directement ce type de clé. Il faut donc utiliser@rollup/plugin-replace, qui prend en charge le remplacement de clés de chaîne arbitraires.📎 rollup.config.js:250-251et définitpreventAssignment: true, pour éviter de remplacer aussi le côté gauche des instructions d'affectation.

resolveReplace()Dansconst replacements = { ...enumDefines }est la première étape.📎 rollup.config.js:222-223Ensuite seulement viennent les remplacements de production/*@__PURE__*/d'annotations,__DEV__, etc. Cet ordre garantit que le remplacement des littéraux d'enum prend toujours effet.

Réflexions de conception

L'essence de l'inlining des enums est d'« échanger de la complexité au moment de la construction contre du volume à l'exécution ».Il reproduit intégralement la sémantique du système de types de TypeScript (évaluation d'enum, auto-incrémentation, mapping inverse) au moment de la construction —scanEnumsla logique d'évaluation dans est presque un sous-ensemble de l'évaluation d'enum du compilateur TS.📎 scripts/inline-enums.js:110-183Cela entraîne un coût de maintenance : si TS ajoute une nouvelle syntaxe d'enum (comme des expressions constantes plus complexes), il faut suivre ici, sinon une erreurunhandledest levée. Mais le bénéfice est clair : zéro objet enum à l'exécution, et un Tree-shaking complet.

〔Inférence de conception et compromis architecturaux〕

Le script de validation et le script d'inlining forment un couple « promesse et réalisation ».Le script d'inlining promet que « les enums n'occupent pas de volume à l'exécution », le script de validation vérifie que « les autres codes n'en occupent pas non plus en cachette ». Les deux protègent ensemble le budget de taille de Vue. Cette conception appariée « optimisation + validation » est un modèle typique d'ingénierie des grandes bibliothèques front-end : toute optimisation nécessite une vérification automatisée pour prévenir les régressions.

Le cache inter-processus est indispensable aux constructions concurrentes. scanEnumsLe modèle d'une exécution unique et deinlineEnumslectures multiples📎 scripts/inline-enums.js:39-41résout le problème « un seul scan, N processus consommateurs ». Sans cache, chaque processus Rollup devrait refaire un grep + parsing, gaspillant massivement IO et CPU.

Résumé de ce chapitre

Réflexions et auto-évaluation de ce chapitre

Q1 : Si l'on supprime dansscanEnumsla vérification de conflit desaveValuedansif (fullKey in defines), dans quel scénario cela entraînerait-il des erreurs dans les artefacts de construction ?

Analyse de référence:

definesest un mapping plat global, dont les clés sont枚举名.成员名, sans chemin de fichier.📎 scripts/inline-enums.js:98-103Après suppression de la vérification de conflit, si deux fichiers différents ont chacun un enum de même nom définissant un membre de même nom (par exemple@vue/compiler-coreet@vue/runtime-coreont tous deuxErrorCodes.__EXTEND_POINT__), le dernier écrit écrasera le premier.

Conséquences :defines['ErrorCodes.__EXTEND_POINT__']il ne reste qu'une seule valeur, etplugin-replacelors du remplacement ne peut pas distinguer la provenance du fichier, et remplaceratouslesErrorCodes.__EXTEND_POINT__de tous les fichiers par la même valeur.📎 rollup.config.js:222-223Ainsi, la valeur d'un membre d'enum d'un des paquets est silencieusement altérée, entraînant un comportement erroné à l'exécution et extrêmement difficile à diagnostiquer — car le code source semble parfaitement correct.

C'est précisément pourquoi le commentaire souligne « autoriser les enums de même nom entre fichiers, mais pas les membres de même nom ».📎 scripts/inline-enums.js:98-100La vérification de conflit est le gardien qui empêche la pollution de la table de remplacement globale.

Q2 : Si l'on inverse l'ordre derollup.config.jset deenumPlugindans le tableau de plugins de...resolveReplace(), que se passerait-il ?

Analyse de référence:

L'ordre actuel estenumPluginen premier,replaceen second.📎 rollup.config.js:331-332Le hooktransformde Rollup s'exécute dans l'ordre du tableau de plugins.

Si l'on inverse,replaces'exécuterait en premier, alors que les déclarations d'enum sont encore sous leur forme originaleexport enum X { ... }.replaceutilisedefinespour remplacer les référencesX.Member— mais à ce moment les références existent encore, le remplacement peut prendre effet. Le problème survient ensuite lorsqueenumPlugins'exécute : il utilises.update(start, end, ...)pour réécrire la section de déclaration.📎 scripts/inline-enums.js:250-273Maisreplacea déjà modifiécode, etenumPluginobtientcodeestreplacela sortie de , dont le décalage d'octets a été enregistré avecscanEnumsenregistré parrange(basé sur le code source original)ne correspond plus。

Conséquence : MagicString découpera au mauvais décalage, et le produit aura une syntaxe corrompue. Cela révèle un contrat implicite du pipeline de plugins :les transformations basées sur les décalages du code source doivent être exécutées en premier, afin que les transformations suivantes puissent continuer en toute sécurité sur leur sortie.

Q3: verify-treeshaking.jsne vérifie que trois sentinelles de chaînes. Si une refactorisation fait passerisHTMLTagles données internes de'html,body,base'à une forme de tableau['html','body','base'], que fera le script de vérification ? Quel défaut de conception cela expose-t-il ?

Analyse de référence:

Le script de vérification utiliseprodBuild.includes('html,body,base')pour vérifier.📎 scripts/verify-treeshaking.js:33-37Si les données deviennent un tableau, la chaîne concaténée par des virgules n'apparaîtra plus dans le produit minifié,includesrenvoiefalse, la vérificationpasse silencieusement— même siisHTMLTaga réellement fui dans le produit d'exécution.

Cela expose le défaut inhérent à la validation par chaînes en liste noire :les chaînes sentinelles sont couplées à l'implémentation du code source ; dès que l'implémentation change, la validation devient invalide. Elle ne peut pas détecter les « fuites inconnues », seulement les « fuites connues dont la forme de chaîne n'a pas changé ».

〔Inférence de conception et compromis architecturaux〕

Piste d'amélioration : on pourrait plutôt vérifier des identifiants plus stables (comme le nom de fonctionisHTMLTag), ou interdire au niveau du code source, via une règle de lint, l'import à l'exécution d'helpers du compilateur, plutôt que de dépendre des chaînes du produit. Mais sous la contrainte de coût actuelle, les sentinelles de chaînes sont un compromis « suffisant et peu coûteux ».

L'inlining des enums résout « comment éliminer le coût d'exécution à la compilation », et le script de vérification résout « comment confirmer que l'optimisation n'a pas été cassée ». Mais les produits de build ne se limitent pas au JS ; il existe une autre catégorie de produits qui nécessitent également un traitement par pipeline — les fichiers de déclaration de types. Le chapitre suivant entrera dans le pipeline des produits de types, pour voir comment Vue génère, à partir du code source.d.ts, un paquet de types de niveau publication, et commentdts-testutilise des tests de contrat de types pour protéger la forme typée de l'API publique.

Ce chapitre a décomposé deux scripts clés de la phase de compilation. inline-enums.js utilise git grep pour localiser les enums, Babel pour analyser l'AST, new Function pour évaluer les membres, MagicString pour réécrire précisément les déclarations, et finalement, via la table de remplacement globale defines, transforme les références d'enum en littéraux, permettant à l'objet enum d'être éliminé par Tree-shaking. verify-treeshaking.js, quant à lui, vérifie le produit après le build à l'aide de sentinelles de chaînes, afin de garantir que trois types connus de fuites de Tree-shaking ne régressent pas. L'un s'occupe de « l'optimisation », l'autre de « vérifier que l'optimisation n'a pas été cassée » ; ensemble, ils protègent la promesse de taille de Vue. Ensuite, nous passerons de la phase de compilation à la chaîne de génération des produits de types, pour voir comment Vue garantit une stricte cohérence entre les types du code source et les types publiés.

Transformez n'importe quel code en un livre compréhensible

Vous avez aimé ce chapitre ? Créez un livre pour votre projet privé

Architecture local-first en Tauri 2 + Rust. Sécurité 100% hors ligne, zéro code téléversé. Lecture double panneau avec ancres de commits immuables.

⚡ Tauri 2 · Rust Core · 100% Hors ligne & Privé · Testé sur 1M+ lignes

CHAPTER 05

Chapitre 5 : Pipeline des produits de types : des .d.ts sources au paquet de types de niveau publication

Upstream: vuejs/core · Commit @4ab865a8 · Progression: Chapitre 5 sur 14

Dans le chapitre précédent, nous avons décomposéinline-enums.jsetverify-treeshaking.js: l'un remplace les références d'enum par des littéraux pour que l'objet enum puisse être éliminé, l'autre confirme après le build, à l'aide de sentinelles de chaînes, que trois fuites connues ne régressent pas. Ensemble, ils protègent la promesse de taille d'exécution de Vue. Mais les produits de build ne se limitent pas au JS. Lorsque l'utilisateurimport { ref } from 'vue', les indications de type affichées par l'éditeur,tscla vérification de type du code utilisateur, tout dépend d'une autre catégorie de produits —.d.tsles fichiers de déclaration. Si le produit JS est erroné, une erreur survient à l'exécution ; si le produit de types est erroné, une erreur survient côté utilisateur à la compilation, ou pire : une dérive silencieuse des types, le code utilisateur compile, mais la forme typée ne correspond pas au comportement réel à l'exécution. Ce chapitre retrace comment Vue agrège les types sources dispersés dans les différents sous-paquetssrcen un paquet de types de niveau publication, et utilisedts-built-testpour effectuer des tests fumigènes de types sur les produits de build réels.

5.1 Pipeline de types en deux phases : tsc produit, rollup agrège

Modèle intuitif

Imaginez une chaîne d'impression : dans la première phase, chaque sous-paquet met en page son propre manuscrit (.tscode source) en une épreuve d'une page (.d.ts) ; dans la deuxième phase, on relie des dizaines d'épreuves dans l'ordre du catalogue pour en faire un livre (.d.tsde niveau publication), avec des en-têtes et pieds de page uniformes (déclarations d'export).

Sans cette chaîne, Vue devrait maintenir manuellement un fichier de types publié, et toute modification du code source exigerait une modification manuelle synchronisée — un terreau pour la dérive des types. L'approche de Vue est la suivante :les produits de types sont entièrement générés à partir du code source, jamais écrits à la main。

Première phase : tsconfig.build.json délimite la portée de production

tsconfig.build.jsonest la configuration de la première phase de cette chaîne. Elle hérite de la racinetsconfig.json, et ne couvre que les options liées au build.

📎 tsconfig.build.json:3-9

Décomposition des options clés une par une :

  • declaration: true: demander à tsc de générer pour chaque fichier source le.d.ts。
  • emitDeclarationOnly: true:correspondant, uniquement des types, pas de JS. Le JS est pris en charge par Rollup ; ici, tsc est purement un extracteur de types.
  • stripInternal: true: toute déclaration marquée@internalest retirée de.d.ts. C'est la première barrière par laquelle Vue contrôle la surface de l'API publique — même si un détail d'implémentation interne estexport, tant qu'il porte@internal, il ne fuira pas dans les types publiés.
  • composite: false: désactiver le mode de build incrémental des références de projet (project references). Vue n'a pas besoin ici d'incrémentalité inter-paquets ; le désactiver évite l'état supplémentaire apporté par.tsbuildinfo.

includeLa liste délimite précisément quels répertoires participent à la production :

📎 tsconfig.build.json:10-23

Notez icine liste que 12 répertoires, et non l'ensemblepackages/。packages-private/、packages/dts-test/、packages/sfc-playground/etc. n'en font pas partie. Cela signifie : les types des paquets privés et des paquets de testne seront jamaisentrez dans les artefacts de publication. Il s'agit d'une isolation physique — non pas par convention, mais par configuration.

〔Inférences de conception et arbitrages architecturaux〕

Pourquoi utiliser une liste blanche plutôt qu'une liste noire ? Parce que l'ajout de sous-paquets dans un monorepo est monnaie courante. Si l'on utilisait uneexcludeliste noire, lors de l'ajout d'un paquet privé, si l'on oublie de l'ajouter à exclude, ses types se glisseraient silencieusement dans les artefacts de publication. La liste blanche, à l'inverse : les nouveaux paquets ne participent pas à la construction par défaut et doivent être ajoutés explicitement, ce qui respecte le principe de « valeurs par défaut sûres ».

Après exécution detsc -p tsconfig.build.json --noCheck, les artefacts se trouvent danstemp/packages/<pkg>/src/*.d.ts. Notez--noCheck: on saute la vérification de types, on fait uniquement l'emit. La vérification de types est assurée séparément partsc --noEmit, on ne la répète pas lors de la construction, ce qui permet de gagner du temps.

Deuxième phase : agrégation par rollup.dts.config.js

La deuxième phase est pilotée parrollup.dts.config.js. Son point d'entrée effectue d'abord une validation préalable :

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

Sitemp/packagesn'existe pas, cela signifie que la première phase n'a pas été exécutée, le script fait directementprocess.exit(1)et indique d'exécuter d'abordtsc. C'est lecontrat d'ordredu pipeline : la phase rollup dépend fortement des artefacts de la phase tsc, les deux sont indispensables.

Ensuite, il lit tous les répertoires de sous-paquets et prend en charge la variable d'environnementTARGETSpour une construction en sous-ensemble :

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

TARGETSCe mécanisme permet de ne reconstruire que les types de certains paquets, ce qui raccourcit considérablement la boucle de retour lors du développement et du débogage.

Le cœur esttargetPackages.map(...)qui génère une configuration Rollup pour chaque paquet :

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

Décryptage champ par champ :

  • input: ./temp/packages/${pkg}/src/index.d.ts: l'entrée est le fichier de types produit par la première phase, et non le code source.ts。
  • output.file: packages/${pkg}/dist/${pkg}.d.ts: les artefacts vont dans le répertoiredistpropre à chaque paquet, le nom de fichier correspondant au nom du paquet (par exemplevue.d.ts)。
  • format: 'es': les fichiers de types utilisent uniformément le format ES module.
  • plugins: [dts(), patchTypes(pkg), ...(pkg === 'vue' ? [copyMts()] : [])]: trois plugins, les deux premiers s'appliquent à tous les paquets,copyMtsne s'applique qu'au paquetvue.

onwarnLe hook

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

mérite une mention spéciale :UNRESOLVED_IMPORTLors du dts rollup, tous les imports à chemin non relatif sont externalisés par défaut. Cela provoque l'avertissementde Rollup. Mais c'est uncomportement attenduimport { X } from 'some-pkg'— lesreturndans les fichiers de types doivent être conservés comme références externes et ne doivent pas être intégrés. Le script supprime donc directement l'avertissementwarn。

pour les « imports non résolus à chemin non relatif », et ne laisse passer vers le

par défaut que les imports non résolus à chemin relatif.!warning.exporter?.startsWith('.')〔Inférences de conception et arbitrages architecturaux〕.Il y a ici une subtilité :

vérifie si l'exporter commence par

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

Vue d'ensemble du pipelinetscCopierrollupCe schéma ancre le flux de contrôle en deux phases :checkla liste blanche depatchTypesdétermine qui peut entrer dans le pipeline,copyMtslevuede

détermine si l'on peut continuer,

est une étape obligatoire,

rollup-plugin-dtsest la branche exclusive au paquet.d.ts.export { A, B, C, ... }5.2 patchTypes : réécrire les artefacts agrégés en une forme de niveau publicationdefineComponentModèle intuitif

patchTypesAprès avoir fusionné des dizaines deen un seul fichier, la forme produite est « on déclare d'abord une pile de types, puis on exporte le tout via un énorme». Ce n'est pas agréable à lire pour un humain, et pour certaines chaînes d'outils (comme l'appel

de VitePress), cela déclenche l'erreur « le type inféré ne peut pas être nommé sans référence ».

patchTypesest précisément cetterenderChunkétape de post-traitement et de mise en forme

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

  • isExported: transformer l'« export centralisé » en « export inline sur place », puis ajouter les augmentations de types propres au paquet.Structure de données : deux Set et trois passesretourne un plugin Rollup, dont la logique centrale se trouve dans le hookexport { ... }. Il maintient deux ensembles :
  • shouldRemoveExport: enregistre tous les noms de typesdéjà exportés à l'origine(provenant des déclarations

).

Step-by-Step Walkthrough

: enregistre tous les noms de types

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

à retirer du grand bloc d'exportExportNamedDeclaration(car déjà exportés inline).Le traitement se fait en trois passes (pass 0 / pass 1 / pass 2), c'est le schéma typique « collecter d'abord, réécrire ensuite, nettoyer enfin ».Pass 0 : collecter tous les noms de types déjà exportés.export ... from '...'Parcourir les nœuds de premier niveau de l'AST, pour toutisExported。

quiexportn'a pas de source

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

(c'est-à-dire qui n'est pas une ré-exportationVariableDeclaration、TSTypeAliasDeclaration、TSInterfaceDeclaration、TSDeclareFunction、TSEnumDeclaration、ClassDeclaration), ajouter le local name de son specifier àprocessDeclaration。

processDeclarationPass 1 : ajouter sur place le préfixe

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

aux nœuds de déclaration.

Parcourir les nœuds de premier niveau, pour les six catégories de déclarationsidappeler la logique de

:_Trois étapes :1. Sansretourner directement (comme une déclaration anonyme).

2. Si le nom commence parshouldRemoveExport, passer — c'est uneisExportedconventionprependLeft: les types préfixés par un underscore sont des types auxiliaires internes, non exportés.export 3. Ajouter le nom à

; si ce nom est dansVariableDeclaration(c'est-à-dire déjà exporté à l'origine), insérer

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

une chaînedeclare constà la position de début de la déclaration.declare const a, bNotez que la brancheprocessDeclarationa une assertion supplémentaire :declarations[0]Si undéclare plusieurs declarators (comme), lever directement une erreur. Car

ne traite que

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

, plusieurs declarators entraîneraient un traitement manqué. Ici on choisitExportNamedDeclarationl'échec rapide

  • plutôt qu'une erreur silencieuse, ce qui est une manifestation de programmation défensive.shouldRemoveExportPass 2 : retirer du grand bloc d'export les types déjà inlinés.exported === localParcourirexport { Foo as Bar }, pour chaque specifier :
  • Si son local name est dans
  • , etExportNamedDeclaration(en excluant le cas de renommage

), alors retirer ce specifier.

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

code = s.toString()Lors du retrait, utiliser MagicString pour supprimer précisément : s'il y a encore un specifier après, supprimer jusqu'au start du specifier suivant ; si c'est le dernier, supprimer jusqu'au end du précédent ou à son propre start.packages/${pkg}/typesSi tous les specifiers de tout le bloc d'export sont retirés, supprimer tout le nœud

〔Inférence de conception et arbitrages architecturaux〕

Cetypes/répertoire estl'entrée d'amélioration de types maintenue manuellement, destinée à accueillir les types qui ne peuvent pas être générés automatiquement à partir du code source (comme les améliorations globales JSX, les déclarations de types de macros). Il est fusionné dans le même fichier que les types générés automatiquement, mais les sources sont clairement séparées — les types générés automatiquement en haut, les améliorations manuelles en bas.

Pourquoi l'export inline est-il obligatoire ?

Le commentaire en donne la raison directe :

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

Le texte original dit : convertir tous les types en export inline et les retirer du grand bloc d'export, sinon dans l'appeldefineComponentde VitePress, l'erreur « the inferred type cannot be named without a reference » sera signalée.

〔Inférence de conception et arbitrages architecturaux〕

L'essence de cette erreur est la suivante : lorsque TypeScript génère des types, si un type ne peut être nommé que par « référence à l'export d'un autre module », et que cette référence n'est pas visible côté consommateur, une erreur est signalée. Le bloc d'export centralisé sépare le nom du type de son emplacement de déclaration, ce qui aggrave ce problème. L'export inline rend chaque type visible à son emplacement de déclaration, éliminant cette couche d'indirection.

copyMts : fournir des types pour le double mode Node ESM/CJS

copyMtsLe plugin ne prend effet que pour le paquetvue:

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

Dans le hookwriteBundle, il écrit le contenu devue.d.tstel quel dansvue.d.mts。

Le commentaire explique la raison :

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

Selon la spécificationpackage.jsonexports de TypeScript 4.7, pour fournir correctement des types à la fois pour Node ESM et CJS,il faut deux fichiers de déclaration indépendants. Donc lors du build, on copievue.d.tsenvue.d.mts。

〔Inférence de conception et arbitrages architecturaux〕

Pourquoi copier plutôt que régénérer ? Parce que la forme des types ESM et CJS est totalement identique, la différence ne réside que dans l'extension de fichier et le mappingpackage.jsondeexports. La copie est la solution la moins coûteuse, évitant de relancer rollup une seconde fois.

5.3 dts-built-test : effectuer un test de fumée des types sur les artefacts réels

Modèle intuitif

Les deux sections précédentes garantissent que les artefacts de types peuvent être générés et que leur forme est correcte. Mais « pouvoir être généré » ne signifie pas « être généré correctement ». SipatchTypesa un bug dans l'une de ses passes de parcours et supprime par erreur un export, l'artefact peut toujours être généré, mais l'utilisateurimportdécouvrira que le type est manquant.

dts-built-testC'estun test de fumée des types exécuté sur les artefacts de build réels: il ne teste pas les types du code source, maisimportle paquetvuedéjà publié, pour vérifier qu'aucune régression n'est survenue dans la forme des types clés.

Structure de données : une assertion de type minimale

Le cœur de tout le paquet de test ne contient qu'un seul fichier :

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

Lecture ligne par ligne :

  • L1 : importervuedepuisdefineComponent. Noter qu'ici on importe lenom du paquet, pas un chemin relatif — il consomme l'artefact réelpackages/vue/dist/vue.d.ts.
  • L3-6 : définir un composant_CustomPropsNotErased, avec des props vides et un setup vide.
  • L8 : commentaire// #8376, pointant vers un issue spécifique.
  • L9-12 : exporterCustomPropsNotErased, de type_CustomPropsNotErasedcroisé avec{ foo: string }.

Ce que ce test vérifie :defineComponentle type de retour de{ foo: string }après croisement avecfoo, la propriété。

n'est pas effacée

〔Inférence de conception et arbitrages architecturaux〕defineComponentContexte supposé de l'issue #8376 :

le type de retour de

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

pourrait passer par un type conditionnel ou un type mappé, entraînant l'« effacement » des propriétés supplémentaires dans le type croisé. Ce test verrouille ce comportement avec une reproduction minimale ; toute régression sera signalée lors de la vérification des types.

  • private: trueConfiguration du paquet : les dépendances workspace pointent vers les artefacts réels
  • types: dist/index.d.tsChamps clés :
  • dependencies: ne pas publier sur npm.workspace:*: l'entrée de types pointe vers l'artefact de build.@vue/shared、@vue/reactivity、vue。
Trois dépendances

dans@vue/shared〔Inférence de conception et arbitrages architecturaux〕@vue/reactivityPourquoi dépendre devueettypes? Parce que les types dedistpeuvent référencer les types de ces deux paquets. En mode workspace, pnpm crée des liens symboliques vers les paquets locaux, et le champdes paquets locaux pointe vers les artefacts sous leurrespectif. Ainsi, toute la chaîne de test consomme des

artefacts de build

dts-built-test, et non le code source.src/index.tsComment le test s'exécutetsclui-même n'a pas de script de test, sontscest le cas de test. La méthode d'exécution est : dans la CI, exécuter

pour effectuer une vérification de types sur ce paquet. Si la forme des types régresse,

signale une erreur et la CI échoue.〔Inférence de conception et arbitrages architecturaux〕L'ingéniosité de cette conception réside dans le fait qu'elle encode le « contrat de types » entsccode compilable

. Pas besoin de bibliothèque d'assertions supplémentaire, pas besoin de runtime,

est lui-même le lanceur de tests. Si les types sont corrects, la compilation passe ; s'ils sont erronés, la compilation échoue.dts-built-testRépartition des rôles avec dts-testdts-testNoter que le

  • dts-built-testde ce chapitre et ledu chapitre suivant sont deux choses différentes :(ce chapitre) : consomme les
  • dts-testartefacts de build, vérifie la forme des types au niveau de la publication.(chapitre suivant) : consomme les
types du code source

, vérifie le contrat de surface de l'API.patchTypes〔Inférence de conception et arbitrages architecturaux〕stripInternalPourquoi deux niveaux ? Parce que les types du code source et les types des artefacts peuvent être incohérents.types/La réécriture AST dedts-built-test, l'élimination de

, l'ajout du répertoire

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: 编译通过 / 报错

garde spécifiquement ce dernier kilomètre.patchTypesChronologie complète du pipeline de typesdts-built-testCopie

Ce diagramme de séquence ancre la collaboration inter-modules : la CI pilote les deux phases tsc et Rollup,

les trois passes de parcours de

patchTypesconstituent le traitement central,code.replace(...)consomme les artefacts en fin de chaîne pour la vérification.

1. Réflexions de conception, récupération d'erreurs et pièges en productionPourquoi utiliser MagicString plutôt que le remplacement de chaînes ?start/endutilise MagicString tout au long pour des réécritures précises, plutôt que

2. . Deux raisons :: MagicString peut générer des mappings, permettant aux fichiers de types réécrits de rester traçables jusqu'au code source. Bien que l'utilité des sourcemaps pour les fichiers de types soit limitée, maintenir la cohérence est une bonne pratique.

Échec rapide vs tolérance silencieuse

patchTypesutilisés à plusieurs endroitsassert:

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

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

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

Ces assertions lancent immédiatement une erreur en cas de forme AST inattendue. Comparez aveconwarnoùUNRESOLVED_IMPORTest silencieusement avalé——Le bruit attendu est avalé, les formes inattendues échouent rapidement. C'est la bonne posture pour un script de build : mieux vaut que le build échoue que de produire des fichiers de types mal formés.

Pièges en production :_Convention de préfixe

processDeclarationIgnorer les types commençant par_:

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

Cela signifie que tout type exporté dans le code source commençant par_ne sera pas exporté en ligne. Si un type devrait être public mais est ignoré parce que son nom commence par_, les utilisateurs rencontreront une erreur « le type n'existe pas ».

〔Inférences de conception et compromis architecturaux〕

Pour diagnostiquer ce genre de problème : vérifiez d'abord si le type est encore dans le grand bloc d'export dans l'artefactvue.d.ts, puis vérifiez si le nom du type dans le code source commence par_. C'est un couplage implicite entre convention de nommage et comportement de l'outil, facile à piéger.

Piège en production : assertion multi-declarator

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

Si un.d.tscontientdeclare const a, b, le build lance directement une erreur. C'est rare dans les types écrits à la main, mais si un fichier de types généré par un outil utilise cette forme, cela se déclenchera. Le message d'erreur affiche l'extrait de code problématique pour faciliter la localisation.

Résumé de ce chapitre

Ce chapitre a retracé le pipeline complet des artefacts de types Vue :

1. Première phase (tsc):tsconfig.build.jsonutiliseincludeune liste blanche pour délimiter précisément la portée de sortie,emitDeclarationOnlyne produit que les types,stripInternalexclut les déclarations internes. Les artefacts se trouvent danstemp/packages/。

2. Deuxième phase (rollup):rollup.dts.config.jsutiliserollup-plugin-dtspour agréger les types de chaque package,patchTypesvia trois passes de traversée AST, réécrit les exports centralisés en exports en ligne, et ajoutetypes/les enrichissements manuels du répertoire.copyMtspour le packagevuegénère en plus.d.mts。

3. Phase de validation (dts-built-test): effectue des tests de fumée de types sur les artefacts de build réels, verrouille les formes de types clés avec du code compilable, pour prévenir la dérive des types.

Réflexions et auto-évaluation de ce chapitre

Q1 : Si on change la liste blanchetsconfig.build.jsondeincludeen["packages"](c'est-à-dire incluant tout le répertoire packages), que se passe-t-il ? Dans quels scénarios cela entraînerait une pollution des types publiés ?

Analyse de référence:

includeEn passant de 12 répertoires précis à["packages"], tous les sous-packages (y compris tous lespackages-privateen dehors depackages/*) participeront à la sortie tsc.📎 tsconfig.build.json:10-23

Chaîne de conséquences :

1. temp/packages/contiendra les.d.ts。

2. rollup.dts.config.jsde nombreux packages en plusreaddirSync('temp/packages')lira ces packages supplémentaires.📎 rollup.dts.config.js:15-22

3. targetPackagesest par défaut égal à tous les packages, donc générera pour chaque packagepackages/<pkg>/dist/<pkg>.d.ts。📎 rollup.dts.config.js:15-22

Scénario de pollution : si un package ne devrait pas être publié (comme un package d'outils internes), ses artefacts de types apparaîtront dansdist. Si lepackage.jsonde ce package n'a pasprivate: true, le script de publication pourrait le publier sur npm, entraînant une fuite de types internes.

C'est précisément la valeur de la conception par liste blanche : les nouveaux packages ne participent pas par défaut, ils doivent être ajoutés explicitement, conformément aux valeurs par défaut sécurisées.

Q2: patchTypesDans la passe 1 deprocessDeclaration, pour les types commençant par_retourne directementreturn. Si le type d'une API publique commence恰好 par_(comme_InternalTypeexporté accidentellement), que verront les utilisateurs ? Comment diagnostiquer ?

Analyse de référence:

processDeclarationEn rencontrant_commençant par, retourne directement, sans ajouter àshouldRemoveExport, ni prependexport 。📎 rollup.dts.config.js:76-78

Conséquences :

1. Ce type n'obtiendra pas d'export。

en ligneshouldRemoveExport2. Il ne sera pas non plus retiré du grand bloc d'export (car pas dans

).3. Donc ilreste dans le grand bloc d'export

, théoriquement encore importable.export { _InternalType }Mais le problème est : lestripInternaldans le grand bloc d'export fait référence à la position de déclaration. Si cette déclaration est exclue pour une raison quelconque (commetsc), le bloc d'export référencera un nom inexistant, provoquant une erreur

.

Piste de diagnostic :vue.d.ts1. Vérifier dans l'artefactexportsi ce type n'a pas de

à sa déclaration, et est référencé dans le grand bloc d'export._2. Vérifier si le nom du type dans le code source commence par

.

3. Si c'est confirmé comme un problème de nommage, il suffit de renommer en supprimant le préfixe underscore._Cela expose le couplage implicite entre convention de nommage et comportement de l'outil :

Q3: dts-built-testLe préfixesrc/index.tssignifie à l'origine « interne », mais l'outil le traite comme « non exporté », les deux sémantiques n'étant pas parfaitement alignées.typeof _CustomPropsNotErased & { foo: string }LefoodeOmit<typeof _CustomPropsNotErased, never> & { foo: string }utilise le type d'intersection

pour vérifier que:

Omit<T, never>n'est pas effacé. Si on change le type d'intersection en, le test peut-il encore capturer la régression #8376 ? Pourquoi ?Analyse de référence

  • crée un nouveau type mappé, quiT & { foo: string }recalculefootoutes les propriétés de T. Si le bug #8376 est « les propriétés supplémentaires dans le type d'intersection sont effacées », alors :defineComponentÉcriture originalefoo: intersection directe,
  • Omitfait partie du type d'intersection, si la logique de traitement du type de retour deOmitefface les propriétés supplémentaires de l'intersection,Tsera perdu.{ foo: string }ÉcritureOmit:

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

mappe d'abord, puis croise avec. Le processus de mapping deOmit、Pickpeut modifier la structure du type, rendant les conditions de déclenchement du bug non valides——même si le bug existe, le test peut passer.

Donc la

minimalité

du cas de test est cruciale : il doit reproduire précisément le chemin de déclenchement du bug. Toute transformation de type supplémentaire (commedts-built-test) peut masquer le bug. C'est pourquoi le test utilise le type d'intersection le plus simple, plutôt qu'une écriture plus « élégante ».dts-test, découvrez comment Vue protège la surface de son API publique grâce aux tests de contrat de types.

Ces trois éléments forment une boucle fermée « génération → mise en forme → vérification », garantissant une correspondance stricte entre les types source et les types publiés. Cependant, le fait que le paquet de types soit lui-même correct ne signifie pas que la forme typée de l'API publique soit verrouillée. Dans le chapitre suivant, nous approfondironspackages-private/dts-test, pour voir comment plus de 20.test-d.tsfichiers utilisentexpectTypeet d'autres outils pour transformer « le type comme contrat d'API » en tests automatisés reproductibles.

Transformez n'importe quel code en un livre compréhensible

Vous avez aimé ce chapitre ? Créez un livre pour votre projet privé

Architecture local-first en Tauri 2 + Rust. Sécurité 100% hors ligne, zéro code téléversé. Lecture double panneau avec ancres de commits immuables.

⚡ Tauri 2 · Rust Core · 100% Hors ligne & Privé · Testé sur 1M+ lignes

CHAPTER 06

Chapitre 6 : Tests de contrat de types : comment dts-test protège la surface de l'API

Upstream: vuejs/core · Commit @4ab865a8 · Progression: Chapitre 6 sur 14

Dans le chapitre précédent, nous avons suivi la chaîne de génération des déclarations de types, et vu comment Vue garantit, via la configuration de build et des tests de fumée, une correspondance stricte entre « types source » et « types publiés ». Mais le contrat de types ne se limite pas à « la forme est-elle correcte » ; plus crucial encore est « la surface de l'API est-elle conforme aux attentes » — quels types doivent être exportés, lesquels ne doivent pas l'être, et si les contraintes génériques sont précises. Ce chapitre entre danspackages-private/dts-test, pour voir comment Vue utilise plus de 20.test-d.tsfichiers pour transformer « le type comme contrat d'API » en tests automatisés reproductibles.

Modèle cognitif des tests de contrat de types : transformer le « manuel » en « contrat exécutable »

dts-testLes fichiers du répertoireont une caractéristique contre-intuitive : ilsne produisent presque aucun comportement à l'exécutiondefineComponent.test-d.tsx. En ouvrantdefineComponent({...}), vous verrez de nombreux appelstsc/vue-tsc, mais ils ne sont jamais réellement exécutés au moment de l'exécution des tests — ces fichiers sont uniquement soumis ànoEmit: truepour la vérification de types,

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

garantissant qu'aucun JS n'est produit.noEmitCette configuration constitue l'« environnement d'exécution » de tout le système de contrat :jsx: preservedésactive la sortie d'artefacts,strictlaisse la syntaxe TSX être analysée par le système de types,moduleResolution: bundleractive toutes les vérifications strictes,libcorrespond aux sémantiques de bundling modernes,esnextet introduit simultanémentdom。et.test-d.tsxSans cette configuration,。

le JSX dans

serait traité comme du JSX d'exécution, et les assertions de types perdraient leur senspackages-private〔Inférence de conception et arbitrages architecturaux〕packages/vueIsoler les tests de types dans un sous-paquet__tests__plutôt que de les intégrer dansvuede, pour trois raisons : premièrement, les dépendances des tests de types sont les(vue/jsx、vuede niveau publication de.d.ts, et non les modules internes du code source ; l'isolation physique force le passage par les points d'entrée publics ; deuxièmement,tscla vérification des tests de types prend bien plus de temps que les tests unitaires d'exécution, et un répertoire indépendant facilite une planification CI séparée ; troisièmement,.test-d.tsxles fichiers ne seront pas exécutés par erreur par le collecteur d'exécution de Vitest.

Analogie du quotidien : un test unitaire ordinaire ressemble à « mettre la machine sous tension et voir si elle fume », tandis qu'un test de contrat de types ressemble à « vérifier clause par clause avant de signer un contrat » — sans transaction réelle, on confirme simplement que « la somme due par la partie A » est libellée en « yuan » et non en « dollars ». Si les clauses du contrat sont erronées, la machine a beau tourner parfaitement, cela ne sert à rien.

utils.d.tsfournit tous les outils de cette « vérification de contrat » :

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

Il n'y a que quatre outils clés :expectType<T>(value: T)affirme quevaluea exactement le typeT;expectAssignable<T, T2 extends T>affirme queT2est assignable àT;IsUnion<T>détermine siTest un type union ;IsAny<T>détermine siTestany. Notez leimport 'vue/jsx'en L5 — il enregistre l'espace de noms JSX global, permettant au<MyComponent />dans TSX d'être reconnu par le système de types commeJSX.Element。

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

IsUnion. L'implémentation deT extends any ? (U extends T ? false : true) : nevermérite un examen attentif :Tutilise les types conditionnels distributifs ; siextends falseest un type union, chaque membre est évalué indépendamment, et finalementfalsedétermine si toutes les branches retournent. C'estune preuve d'existence au niveau des typesprops.jjj— utilisée pour verrouiller des contrats du type «

doit être un type union et non fusionné en une signature unique ».defineComponentParcours guidé par scénario :

defineComponent.test-d.tsxchaîne complète d'inférence des types de props decompte 2260 lignes et constitue le cœur du système de contrat. Plaçons-nous dans un scénario concret :defineComponent({ props: {...}, setup(props) {...} })l'utilisateur écritprops, le système de types de Vue doit déduire à partir desetupla déclaration d'exécutionpropsle type précis du paramètredans

. Cette chaîne est la partie la plus complexe du système de types de Vue.

Première étape : construire le « type attendu » comme référence contractuelleExpectedPropsLe fichier de test définit d'abord l'interface, en écrivant explicitement et de manière figée:

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

le type que chaque mode de déclaration de props devrait inférera?: number | undefined. Cette interface est la version écrite des « clauses du contrat ». Notez quelques types subtils :undefined)、aa: number(props optionnelles avecaaa: number | null(PropType<number | null>(a une default donc non optionnelle),aaaa: number | undefined(required: true as constdéclaré explicitement),undefinedmais le type contientprops). Ces différences ne sont pas écrites au hasard ; chacune correspond à une branche spécifique dans la déclaration

.defineComponent

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

Deuxième étape : « nourrir »propsavec divers modes de déclarationCet objetest

  • a: Numberune matrice exhaustive des modes de déclarationnumber | undefined
  • aa: { type: Number as PropType<number | undefined>, default: 1 }, couvrant toutes les écritures de props de Vue :number
  • aaaa: { type: Number, required: true as const } —— as const— raccourci de constructeur, inféré commetrue— a une default, inféré comme non optionnelbooleanempêche
  • b: { type: String, required: true as true } —— required: trued'être élargi en
  • bb: { default: 'hello' }, préserve le type littéraltyperend la propriété non void
  • cc: Array as PropType<string[]>— sans
  • l: [Date], type inféré uniquement à partir de la defaultDate | undefined
  • ll: [Date, Number]— conversion de type expliciteDate | number | undefined
  • lll: [String, Number]— syntaxe tableau, inférée comme
— tableau multi-types, inféré comme

required: true as const— idemrequired: true as true〔Inférence de conception et arbitrages architecturaux〕as true(L70) etas const(L75) coexistent, trace d'une évolution historique : au début on utilisait, puis on a découvert que。

était plus général (pouvant verrouiller simultanément d'autres littéraux dans l'objet), mais l'ancienne écriture est conservée pour vérifier la rétrocompatibilité. C'est la valeur typique des tests de contrat —setup / render / thisils verrouillent simultanément « la nouvelle écriture est utilisable » et « l'ancienne écriture ne régresse pas »

Troisième étape : affirmer aux trois emplacementsC'est la conception la plus ingénieuse des tests de contrat :。

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

setup(props)un même type de props doit être correctement inféré dans trois positions de consommation différentesexpectType<ExpectedProps['x']>(props.x)effectue un

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

// @ts-expect-error should included 'undefined'AvecexpectType<number>(props.aaaa)——Écrire délibérément une assertion qui génère une erreur, en utilisant@ts-expect-errorpour avaler l'erreur. Cela vérifie queprops.aaaale type den'est pas number(sinon cette ligne ne générerait pas d'erreur,@ts-expect-erroréchouerait plutôt à cause de « aucune erreur à avaler »). C'est la technique de « l'assertion inversée » pour les tests de type.

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

// @ts-expect-error props should be readonlyAvecprops.a = 1— vérifie que les props sont en lecture seule danssetup. Si une refactorisation rend accidentellement les props mutables, cette ligne ne génère plus d'erreur,@ts-expect-erroréchouera.

render()Dansthis.$propsetthis.xon asserte via deux chemins :

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

L252-276 vérifie que « les props déclarées doivent aussi être exposées surthis», L278-279 vérifie quethis.a = 1génère une erreur (les props surthissont aussi en lecture seule). L281-287 vérifie le déballage de la valeur de retour de setup :this.cestnumber(ref(1)déballé),this.d.e.valueeststring(les refs imbriqués conservent.value)、this.f.gestGT(reactivele type branded dans

n'est pas déballé). Étape quatre : validation des types côté consommateur TSX

Le dernier maillon du contrat de type est « comment l'utilisateur utilise ce composant ». Dans TSX, la validation des props de<MyComponent />est un chemin de type indépendant :

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

Ici on vérifie que<MyComponent>accepte toutes les props déclarées, ainsi queclass/style/key/ref/ref_forces attributs intégrés. Ensuite vientla validation inversée:

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

// @ts-expect-error missing required propsvérifie qu'une prop obligatoire manquante génère une erreur ;wrong prop typesvérifie qu'une incompatibilité de type génère une erreur ; L342 vérifie queggg="baz"génère une erreur (gggn'accepte que'foo' | 'bar')。

Toute la chaîne peut être résumée par un diagramme de flux de données :

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

Le point clé de ce diagramme est :la mêmepropsdéclaration doit satisfaire simultanément les attentes de type de trois positions de consommation. Toute divergence d'inférence fera échouertsc.

Limites et portes dérobées :__typeProps、__typeEmitset contrats de types conditionnels

defineComponentL'inférence de type dea une limitation fondamentale :les déclarations de props à l'exécution ne peuvent pas exprimer de « types conditionnels »color='white'. Par exemple « quandappearancedoit être'outline'» ce type de contrainte ne peut pas s'écrire avec la syntaxe d'objet à l'exécution. Vue fournit pour cela__typePropset autres « portes dérobées de type ».

__typeProps: la capsule de secours pour les props conditionnelles

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

ConditionalPropsest un type union : soitcoloretappearancesont tous deux optionnels, soitcolor: 'white'etappearance: 'outline'. Le test vérifie :

  • L1823-1824:<Comp color="white" />génère une erreur — fournircolor: 'white'seul ne satisfait aucune branche
  • L1825-1826:<Comp color="white" appearance="normal" />génère une erreur —appearancedoit être'outline'
  • L1827:<Comp color="white" appearance="outline" />passe
〔Inférences de conception et compromis architecturaux〕

__typePropsLa motivation de conception de

__typeEmits: équivalence des deux syntaxes d'emits

__typeEmitssupporte deux syntaxes, le testverrouille les deux simultanément:

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

Syntaxe objet{ change: [id: number], update: [value: string] }exprime les paramètres avec des tuples nommés. Le test vérifie quethis.$props.onChange?.(123)passe,onChange?.('123')génère une erreur.

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

Syntaxe de signature d'appel{ (e: 'change', id: number): void; (e: 'update', value: string): void }exprime via des surcharges.Les corps de test des deux syntaxes sont presque identiques ligne par ligne— c'est délibéré : le contrat exige que les deux écritures produisentun comportement de type complètement équivalent.

〔Inférences de conception et compromis architecturaux〕

Pourquoi conserver deux syntaxes ? La syntaxe objet est plus proche de l'écriture dedefineEmits, la syntaxe de signature d'appel est plus proche des types d'événements TS traditionnels. Vue doit supporter les deux et garantir un comportement identique. La structure de « miroir ligne par ligne » des tests est la preuve d'équivalence la plus forte.

__typeRefset__typeEl: références inter-composants et types de nœuds hôtes

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

__typeRefspermet au composant parent de connaître précisément le type du ref du composant enfant.Parentdéclare__typeRefs: { child: ComponentInstance<typeof Child> }, ainsirefs.child.$refs.foopeut être inféré commenumber。

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

__typeElest plus subtil. Le commentaire de test L1963-1977 précise l'intention de conception :Les nœuds hôtes des moteurs de rendu personnalisés (TUI, canvas, native) ne sont pas desElementDOM, doncTypeElne peut pas être contraint àElement. Le test utilise l'interfaceCustomElementpour vérifier que$elpeut accepter n'importe quel type hôte.

〔Inférences de conception et compromis architecturaux〕

C'est la garantie au niveau des types que Vue 3 supporte les moteurs de rendu personnalisés. SiTypeElétait contraint en dur àElement,@vue/runtime-test, les utilisateurs de moteurs de rendu non-DOM ne pourraient pas inférer correctement le type de$el. Le test de contrat protège ici « l'indépendance du moteur de rendu ».

Contrainte mutuellement exclusive entre composants génériques et props à l'exécution

function syntax w/ runtime propsLa sectionverrouille une règle importante :。

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

les composants génériques ne peuvent pas coexister avec des props objet à l'exécutiongenerics aren't supported with object runtime propsLe commentaire L1501<Comp3<string>>est une déclaration de contrat. L1525-1535 vérifie que setup générique + props objet génère une erreur ; L1538-1539 vérifie que

génère une erreur. Les props tableau autorisent en revanche les génériques (L1464-1499).

〔Inférences de conception et compromis architecturaux〕ExtractPropTypesLa cause racine de cette contrainte est l'ordre d'inférence de type : les props objet nécessitent que

détermine d'abord le type, tandis que les génériques ne peuvent être déterminés qu'à l'instanciation, les deux entrent en conflit. Les props tableau ne participent pas à l'extraction de type, donc pas de conflit. Le test de contrat fige cette « limitation du système de types » en assertions régressables.

@ts-expect-errorRéflexions de conception, récupération d'erreurs et pièges en production

@ts-expect-errorLa double arme deest l'outil central des tests de contrat de type, mais il a un piège fatal :@ts-expect-errorquand le code en dessous ne génère plus d'erreur,lui-même génère une erreur

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

. Cela semble être une protection, mais exige en réalité que l'auteur du test contrôle précisément « l'emplacement où l'erreur se produit ».// @ts-expect-error missing propRegardez ceci :<Comp msg={123} />est placé sur la ligneau-dessus de, mais toute l'expression est enveloppée dansexpectType<JSX.Element>(...). Si la position de@ts-expect-errorse décale d'une ligne, ou si l'erreur se produit en réalité sur l'appelexpectTypeplutôt que sur le JSX, le test échouera.

〔Inférences de conception et compromis architecturaux〕

Piège en production : quand une mise à jour de version de TypeScript décale légèrement les positions d'erreur, un grand nombre de@ts-expect-errorpeuvent échouer collectivement. La stratégie de Vue est decoller@ts-expect-errorau plus près du code asserté, et de verrouiller la version de TypeScript dans la CI. Toute mise à jour de TS nécessite de revalider tous les tests de type.

IsAnyetIsUnion: « preuve d'existence » au niveau du type

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

expectType<IsAny<typeof props.foo>>(false)vérifie queprops.foon'est pasany. C'est uncontrat inversé: il exige non seulement que le type soit correct, mais aussi qu'il ne puisse pas dégénérer enany」。anyest un trou noir du système de types, toutanyrendra les assertions suivantes dénuées de sens.

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

expectType<IsUnion<typeof props.jjj>>(true)vérifie quejjjest un type union.jjjdéclaré comme((arg1: string) => string) | ((arg1: string, arg2: string) => string), si le système de types le fusionne en une signature unique,IsUnionrenverrafalse, le test échoue.

〔Inférences de conception et arbitrages architecturaux〕

Ces deux outils protègent la « précision du type » plutôt que la « correction du type ». Un type dégénéré enanyou une union fusionnée « semble fonctionnel » dans la plupart des cas d'usage, mais perd les indications de l'IDE et les vérifications à la compilation. Les tests de contrat doivent verrouiller cette précision.

Contrat implicite de l'ordre de déclaration

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

Ce commentaire est extrêmement crucial :code generated by tsc / vue-tsc, make sure this continues to work so we don't accidentally change the args order of DefineComponent。DefineComponentpossède 13 paramètres génériques, dans l'ordreContrat public——vue-tscle type de composant généré dépend de cet ordre. Le test utilisedeclare const MyButton: DefineComponent<...>pour écrire explicitement les 13 paramètres, verrouillant l'ordre.

〔Inférences de conception et arbitrages architecturaux〕

C'est le contrat le plus facilement négligé : l'ordre des paramètres génériques n'est pas un « détail d'implémentation », mais l'« ABI du code généré ». Toute PR modifiant l'ordre rendra levue-tscgénéré par.d.tsincompatible avec le type à l'exécution. Le test de contrat joue ici le rôle de « gardien de la compatibilité ABI ».

Contrat inter-fichiers :componentInstance.test-d.tsxcomplément de

componentInstance.test-d.tsxne fait que 154 lignes, mais couvre toutes les formes d'entrée deComponentInstancedu type utilitaire :

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

ComponentInstance<typeof CompSetup>extrait le type d'instance depuis le résultat dedefineComponent;ComponentInstance<typeof CompFunctional>extrait depuis un composant fonctionnel ;ComponentInstance<typeof CompFunction>extrait depuis une fonction nue. Les trois doivent dériver la classe de baseComponentPublicInstance.

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

Plus extrême encore, « l'objet nu sansdefineComponentenveloppant » :CompObjectSetup、CompObjectData、CompObjectNoPropsles trois formes doivent pouvoir être correctement extraites parComponentInstance. L113-114 est particulièrement contre-intuitif :CompObjectNoPropsn'a pas de déclarationprops, maiscompObjectNoProps.testest quand même déduit commestring | undefined— c'est le repli fourni par la classe de baseComponentPublicInstance.

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

Le test#12751de L141 verrouille une limite :__typeEmitsl'événement'update:visible'déclaré doit être exposé sur l'instance commecomp['onUpdate:visible'](clé de chaîne avec deux-points), et le type$propsest{ 'onUpdate:visible'?: (value?: boolean) => any }. L152-153 vérifie quecomp['$props']['$props']renvoie une erreur — empêchant l'auto-référence récursive du type.

Résumé de ce chapitre

dts-testle répertoire utilise plus de 20 fichiers.test-d.tspour transformer « le type est le contrat d'API » en tests automatisés régressifs. Le mécanisme central comporte trois couches :

1. Couche outils:expectType、expectAssignable、IsUnion、IsAnyfournit les primitives d'assertion de type,@ts-expect-errorfournit la capacité d'assertion inversée.

2. Couche contrat:ExpectedPropsl'interface fige explicitement « quel type doit être déduit »,propsla matrice de déclaration énumère toutes les écritures possibles, les trois emplacements de consommation (setup/render/TSX) se recoupent.

3. Couche porte dérobée:__typeProps、__typeEmits、__typeRefs、__typeElfournit une trappe de secours pour les contraintes de type inexprimables à l'exécution, tout en verrouillant l'équivalence des deux syntaxes emits.

Réflexions et auto-évaluation de ce chapitre

Q1 : si l'on supprime ledefineComponent.test-d.tsxde L168-170 dans@ts-expect-error, en ne gardant queexpectType<number>(props.aaaa), que se passe-t-il ? Pourquoi ce test « échoue-t-il silencieusement » ?

Analyse de référence:

props.aaaadéclaré comme{ type: Number as PropType<number | undefined>, required: true as const }, son type déduit estnumber | undefined(carPropType<number | undefined>inclut explicitementundefined)。

expectType<number>(props.aaaa)exige queprops.aaaasoit exactementnumber. Comme le type réel estnumber | undefined, cette ligneelle-même renverra une erreur。@ts-expect-errorle rôle de

est « prévoir une erreur ici et l'absorber ».@ts-expect-errorSi l'on supprime, cette ligne renverra directement une erreur, le test échoue — cela semble « plus strict ». Mais le problème est :props.aaaasi une refactorisation fait quenumberdevient réellement@ts-expect-error(correction de bug ou changement de comportement), cette ligne ne renverra plus d'erreur, et après suppression dele test passera

— à ce moment le test ne peut plus distinguer « type correct » et « type erroné mais qui ne renvoie justement pas d'erreur ».@ts-expect-errorConserver l'écritureest unverrouillage bidirectionnelnumber | undefined: il exige à la fois que « le type actuel soit@ts-expect-error» (en absorbant l'erreurexpectType<number>vianumber), et que « le type ne puisse pas êtrenumber,@ts-expect-error» (s'il devient, le test échouera faute d'erreur à absorber). C'est la technique centrale des tests de contrat de type —。

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

Q2: __typePropsutiliser « l'erreur attendue » pour verrouiller « le type doit contenir un certain composant »ConditionalPropsle test de porte dérobée (L1803-1836) vérifie la contrainte du type union conditionnel. Si l'on change{ color?: 'normal' | 'primary' | 'secondary' | 'white'; appearance?: 'normal' | 'outline' | 'text' }d'un type union en__typeProps(c'est-à-dire en aplatissant toutes les options), comment le test échouerait-il ? Qu'est-ce que cela révèle comme contrainte de conception de

?:

Analyse de référencecolorLe type aplati permet n'importe quelle combinaison deappearanceetcolor: 'white' + appearance: 'normal', y compris. Mais le test L1825-1826 exige explicitement que cette combinaison:

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

Copie@ts-expect-errorSi le type est aplati, cette ligne ne renverra plus d'erreur,<Comp color="white" />échouera car « aucune erreur à absorber ». De même, le@ts-expect-errorde L1823-1824 passera de « erreur » à « succès », faisant également échouer

.__typePropsCela montre que la contrainte de conception deest :。__typePropsil doit préserver la sémantique d'« exclusion mutuelle des branches » du type unionPropsce n'est pas une simple « couverture de type », mais « exprimer via le système de types des contraintes conditionnelles que les props à l'exécution ne peuvent pas exprimer ». Si lors de l'implémentation on applique àPrettifydes transformations de mapping commeOmitou

, cela peut briser la discriminabilité des branches de l'union, rendant la contrainte inopérante.

〔Inférences de conception et arbitrages architecturaux〕__typePropsC'est aussi pourquoi les cas de test deCommonProps & ConditionalPropsutilisent l'intersection la plus brute

Q3: DefineComponent, plutôt que des types mappés plus « élégants » — toute transformation de type supplémentaire risque de masquer des bugs.VNodeProps & AllowedComponentProps & ComponentCustomPropsL'ordre des 13 paramètres génériques deReadonly<ExtractPropTypes<{}>>est explicitement verrouillé par L1784-1801. Si une refactorisation échange le 9e paramètre (

) et le 10e paramètre (:

DefineComponent), quels éléments en aval seraient affectés ? Pourquoi le test de contrat doit-il verrouiller cet ordre ?vue-tscAnalyse de référence<script setup>L'ordre des paramètres génériques dedefineProps / defineEmits,vue-tscest l'« ABI » lors de la génération du type de composant. Quand l'utilisateur écritCreateComponentPublicInstance<...>dans, cela génère un typesimilaire à L1999-2116, où la

position

1. vue-tscdes paramètres génériques détermine la signification de chaque paramètre de type..d.tsSi l'on échange les 9e et 10e paramètres :DefineComponentleVNodeProps & AllowedComponentProps & ComponentCustomPropsgénéré parReadonly<ExtractPropTypes<{}>>remplira les paramètres selon l'ancien ordre, maisLes types des props des composants utilisateur sont tous décalés。

2. L1786-1800 dedeclare const MyButton: DefineComponent<...>générera directement une erreur — car{}etVNodeProps & ...sont incompatibles.

3. L1999-2116 deErrorMessagetype (simulantvue-tscrésultat de génération) générera également une erreur.

La valeur du verrouillage de l'ordre par les tests de contrat réside dans le fait que :il élève « l'ordre des paramètres génériques » du statut de « détail d'implémentation » à celui de « contrat public ». Toute PR modifiant l'ordre fera immédiatement échouer L1786-1800, empêchant les changements incompatibles d'entrer en release.

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

〔Inférence de conception et compromis architecturaux〕

C'est la valeur la plus sous-estimée des tests de contrat de types : ce qu'ils protègent n'est pas « le type est-il correct », mais « la stabilité de l'interface du système de types ». L'ordre des paramètres génériques,@ts-expect-errorla position deIsAnyla valeur de retour de

Les tests de contrat de types résolvent la question « la surface de l'API est-elle conforme aux attentes ». Mais les types ne représentent que la moitié de l'ingénierie Vue — l'autre moitié est « comment l'utilisateur peut vérifier en temps réel dans le navigateur le comportement de ces API ». Le chapitre suivant abordera le SFC Playground, pour voir comment Vue empaquette le compilateur, le runtime et le système de types dans un environnement de débogage en temps réel au sein du navigateur, permettant à l'utilisateur de voir instantanément les produits de compilation et les résultats d'exécution dès qu'il modifie le code.

Les tests de contrat ne protègent pas seulement « le type est-il correct », mais aussi « le type est-il précis » (IsAny/IsUnion), « l'ordre des paramètres génériques est-il stable » (DefineComponent13 paramètres), « l'indépendance vis-à-vis du renderer » (__typeElnon contraint àElement). Une fois ces contraintes brisées, les indications IDE côté utilisateur,vue-tscles types générés dériveront. Et la stabilité du contrat de types doit finalement servir l'expérience de débogage quotidienne du développeur — le chapitre suivant nous emmènera danspackages-private/sfc-playground, pour voir comment un Playground purement front-end réalise la boucle fermée de compilation SFC et de prévisualisation en temps réel dans le navigateur.

Transformez n'importe quel code en un livre compréhensible

Vous avez aimé ce chapitre ? Créez un livre pour votre projet privé

Architecture local-first en Tauri 2 + Rust. Sécurité 100% hors ligne, zéro code téléversé. Lecture double panneau avec ancres de commits immuables.

⚡ Tauri 2 · Rust Core · 100% Hors ligne & Privé · Testé sur 1M+ lignes

CHAPTER 07

Chapitre 7 : SFC Playground : sous-système de compilation et de débogage en temps réel dans le navigateur

Upstream: vuejs/core · Commit @4ab865a8 · Progression: Chapitre 7 sur 14

Dans le chapitre précédent, nous avons utilisé plus de 20.test-d.tsfichiers pour clouer « le type est le contrat d'API » dans la CI. Mais le contrat de types ne répond qu'à « à quoi ressemble la surface de l'API », il ne peut pas répondre à « à quoi ressemble exactement ce SFC une fois compilé » ni « les résultats de rendu sont-ils cohérents en mode SSR ». Pour répondre à ces deux dernières questions, l'équipe Vue avait besoin d'un bac à sable capable d'exécuter le pipeline de compilation complet dans le navigateur — c'estpackages-private/sfc-playground. Il diffère fondamentalement despackages/packages publics souspackage.json:"private": trueet"version": "0.0.0" 📎 packages-private/sfc-playground/package.json:2-4, ce qui signifie qu'il n'est jamais publié sur npm, c'est juste un outil de débogage officiel. Dans ses dépendances,vuepointe versworkspace:* 📎 packages-private/sfc-playground/package.json:19, c'est-à-dire le produit de build des sources locales, et non la version stable sur npm — ce qui fait naturellement du Playground une « démonstration vivante du commit actuel ». Ce chapitre se concentre sur trois questions : comment l'entrée s'initialise, comment le Header pilote les changements d'état, comment les constantes de build sont injectées.

I. Le minimalisme de l'entrée : contrat d'initialisation de main.ts et ReplStore

Modèle intuitif

main.tsne contient que 9 lignes, comme un « script d'auto-test au démarrage » : avant le montage de l'application Vue, on injecte d'abord danswindowune configuration globale, pour dire à Vue DevTools « quelle app sélectionner par défaut ». Sans cette étape, DevTools ferait face à plusieurs instances d'app à l'ouverture (le Playground lui-même + le code exécuté dans le REPL utilisateur) et ne pourrait pas se focaliser automatiquement, l'expérience de débogage dégénérerait en bascule manuelle.

Structures de données et effets de bord globaux

main.tsLe cœur decreateAppn'est paswindow, mais l'écriture polluante sur

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

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

Voici deux détails d'ingénierie notables :

〔Inférence de conception et compromis architecturaux〕

1. @ts-expect-errorplutôt que@ts-ignore:windowle type standard deWindow & typeof globalThisne possède pasVUE_DEVTOOLS_CONFIGchamp. Utiliser@ts-expect-errorsignifie « je sais que cela va générer une erreur, et j'exige qu'elle soit générée » — si à l'avenir un@types/*ajoute ce champ,@ts-expect-errorgénérera une erreur inverse pour « absence d'erreur produite », rappelant ainsi à l'auteur de retirer ce commentaire. Cela s'inscrit dans la continuité de la démarche des tests de contrat de types du chapitre précédent :utiliser le système de types pour protéger l'intention, plutôt que masquer le problème。

〔Inférence de conception et compromis architecturaux〕

2. defaultSelectedAppId: 'repl'La convention de chaîne de: ce'repl'doit être parfaitement identique à l'id utilisé lors de la création de l'app à l'intérieur de@vue/repl. C'est un contrat littéral inter-packages, sans aucune protection de contrainte de type — si@vue/replchange l'id, la sélection par défaut du DevTools du Playground échouera silencieusement.

Step-by-Step : du HTML au montage

Le flux d'exécution est très court, mais chaque étape a des contraintes implicites :

1. Le navigateur chargeindex.html, qui contient<div id="app">(non fourni dans ce matériel, maismount('#app')permet de le déduire).

2. Résolution du graphe de modules :main.tsen haut deimport App from './App.vue' 📎 packages-private/sfc-playground/src/main.ts:2déclenche@vitejs/plugin-vuela compilation SFC de

〔Inférence de conception et compromis architecturaux〕

3. Ordre critique:window.VUE_DEVTOOLS_CONFIGdoit être écrit avantcreateApp(App).mount('#app') 📎 packages-private/sfc-playground/src/main.ts:9. Car le hook de DevTools est enregistré à l'intérieur decreateApp, une écriture de configuration après le mount ne pourra pas influencer la première sélection.

4. mount('#app')déclencheApp.vuele setup deReplStore, créant ainsiApp.vue(dans

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

Copier

main.tsRéflexions de conception et piègesLe minimalisme deApp.vueest délibéré :ReplStore. Le point d'entrée ne assume que deux choses : « injection d'effets de bord globaux + montage ». Aucune logique métier ne doit apparaître ici. C'est un compromis assumé du Playground en tant qu'« outil de débogage » plutôt que « produit » — il n'a pas besoin de compatibilité SSR, ni de points d'entrée multiples, ni de chargement paresseux.

〔Inférences de conception et arbitrages architecturaux〕

Pièges rencontrés en production :window.VUE_DEVTOOLS_CONFIGestSingleton global. Si le Playground est intégré dans une autre page utilisant également DevTools (par exemple dans un scénario iframe), le dernier écrivain écrasera le premier. Comme le Playground est généralement déployé de manière indépendante, ce risque est accepté.

---

II. Header.vue : état dérivé par computed et flux de données unidirectionnel via emit

Modèle intuitif

Header.vueest le « panneau de contrôle » du Playground — sélection de version, bascule PROD/DEV, interrupteur SSR, bascule de thème, partage, téléchargement. Il nedétient aucun état métier, tous les états proviennent deprops.storeet de props booléens, toutes les modifications sont remontées au composant parent viaemit. Sans cette contrainte de « composant muet + remontée d'événements », le Header deviendrait une zone sinistrée où les états seraient dispersés, et les effets de bord du changement de version et de la bascule SSR ne pourraient plus être gérés de manière centralisée.

Analyse de la structure de données et des champs

La définition des props du Header est la clé pour comprendre ses responsabilités :

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

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

Les cinq props se répartissent en deux catégories :

  • store: ReplStore: la référence unique au conteneur d'état, provenant de@vue/repl. Le Header lit via celui-cistore.loading、store.vueVersion、store.typescriptVersion, et écrit directement dansstore.vueVersion。
  • quatre props booléens/littéraux:prod、ssr、autoSave、theme. Ce sont desétats contrôlés, le Header est en lecture seule, les modifications doivent passer paremit。

la liste d'emit correspondante📎 packages-private/sfc-playground/src/Header.vue:20-28:

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

Noter quetoggle-themebien que défini en interne partoggleDark(), maisemitest directementtoggle-ssr/toggle-prod/toggle-autosavedans le template$emit. Ce mélange est un style courant en Vue 3📎 packages-private/sfc-playground/src/Header.vue:102-118:<script setup>utiliser emit sous forme de fonction lorsqu'un effet de bord est nécessaire, utiliser le templatepour un simple transfert$emit。

Étape par étape : affichage et changement de version

Mise en situation : l'utilisateur ouvre le Playground, le Header doit afficher la version actuelle de Vue.

Étape 1 : dérivation du texte affiché via computed

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

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

Il y a ici trois niveaux de priorité :loadingétat →'loading...'; l'utilisateur a explicitement choisi une version →store.vueVersion; sinon →@${__COMMIT__}(hash court du commit actuel).__COMMIT__est une constante injectée au moment du build, détaillée dans la section suivante.

Étape 2 : liaison bidirectionnelle de VersionSelect

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

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

Noter icin'utilise pasv-model, mais est explicitement décomposé en:model-value + @update:model-value. La raison est quevueVersionest un computed (en lecture seule), il ne peut pas être lié bidirectionnellement directement ; il faut passer parsetVueVersioncette fonction setter pour écrire dansstore.vueVersion:

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

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

function resetVueVersion() {
  store.vueVersion = null
}
〔Inférences de conception et arbitrages architecturaux〕

setVueVersionest déclaré commeasyncmais sansawaiten interne — est-ce un héritage historique ou intentionnel ? On suppose que c'est pour s'aligner sur la sémantique de chargement asynchrone deVersionSelect(le changement de version déclenche un chargement distant), afin de maintenir la cohérence de l'interface.

Étape 3 : comparaison avec la version TypeScript

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

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

La version TypeScript utilisev-model, carstore.typescriptVersionest une propriété ordinaire modifiable, pas besoin d'un wrapper computed.Le même composant utilise deux modes de liaison dans le même template, ce qui illustre de manière直观 la distinction « contrôlé vs non contrôlé ».

Bascule de thème : combinaison d'effets de bord et d'emit

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

Cette fonction fait trois choses : manipuler la classe DOM, persister dans localStorage, émettre un emit pour notifier le composant parent.Noter qu'elle ne modifie pas directementprops.theme— car les props sont en lecture seule, le composant parent ne mettra à jourtoggle-themequ'après avoir reçutheme, ce qui pilote ensuite dans le template:titlele texte📎 packages-private/sfc-playground/src/Header.vue:123。

〔Inférences de conception et arbitrages architecturaux〕

Il y a ici une conception subtile :la manipulation de classe DOM et l'état réactif Vue sont deux chemins indépendants。document.documentElement.classList.toggle('dark')modifie directement le DOM, tandis quethemela prop est mise à jour via Vue. Si les deux ne sont pas synchronisés (par exemple si le composant parent refuse la mise à jour), l'UI présentera une incohérence : « la classe a changé mais le texte du title n'a pas changé ». En pratique, le composant parent accepte toujours l'emit, donc le problème ne se manifeste pas.

Logique cachée : la branche metaKey de copyLink

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

C'est uneporte dérobée pour développeurs: en maintenant Cmd enfoncé surplay.vuejs.orget en cliquant sur le bouton de partage, on est redirigé verslocalhost:5173(serveur de dev local), en emportant le hash de l'URL actuelle. Le hash encode l'état complet du REPL (code source, version, options), ce qui permet de reproduire les problèmes de production en débogage local. Le commentaire// hidden logic for going to local debug from play.vuejs.org 📎 packages-private/sfc-playground/src/Header.vue:47-56indique clairement qu'il s'agit d'une fonctionnalité volontairement cachée.

〔Inférences de conception et arbitrages architecturaux〕

resetVueVersion()est appelé avant la redirection, mettantstore.vueVersionànull, garantissant que le débogage local utilise le commit actuel plutôt que la version sélectionnée en ligne.

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

Réflexions de conception et pièges

〔Inférences de conception et arbitrages architecturaux〕

Piège 1 :navigator.clipboardpermissions et contexte de sécurité de。copyLinkn'a pas de try/catch📎 packages-private/sfc-playground/src/Header.vue:47-56. En l'absence de HTTPS ou si l'utilisateur refuse la permission du presse-papiers,writeTextsera rejeté, entraînant un rejet de Promise non capturé. Le Playground étant déployé en HTTPS, le risque est accepté, mais c'est un « piège de production » typique.

〔Inférences de conception et arbitrages architecturaux〕

Piège 2 :toggleDarkclé localStorage codée en dur de。'vue-sfc-playground-prefer-dark'est un littéral de chaîne, sans extraction en constante. Si la clé doit être modifiée à l'avenir, une recherche globale sera nécessaire.

Piège 3 :currentCommitcomparaison entrevueVersionet. Dans le template:class="{ active: vueVersion === \@${currentCommit}\ }" 📎 packages-private/sfc-playground/src/Header.vue:88-88Comparaison par concaténation de chaînes. Si__COMMIT__l'injection échoue (devientundefined), ici cela devient'@undefined', ne correspondant jamais. La fiabilité de l'injection de constantes à la compilation détermine directement la justesse de l'UI — c'est précisément le sujet de la section suivante.

---

III. Injection de constantes à la compilation : la double responsabilité de __COMMIT__ et copyVuePlugin

Modèle intuitif

vite.config.tsest l'« atelier d'assemblage » du Playground : il exécute à la compilationgit rev-parsepour obtenir le hash de commit, le transforme viadefineen constante globale__COMMIT__; simultanément, via un plugin personnalisé, il copie les artefacts navigateur ESM depackages/vue/dist/vers le répertoire de sortie du Playground. Sans cette étape, le Playground ne pourrait pas charger « le runtime Vue du commit actuel » dans le navigateur — il ne pourrait dépendre que de la version stable sur npm, perdant ainsi la signification de « démonstration vivante ».

Structures de données et constantes à la compilation

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

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

spawnSyncexécute synchroniquement la commande git,--short=7prend le hash court de 7 caractères. L'exécution synchrone est intentionnelle :le fichier de configuration a besoin de la valeur decommitdès la phase de chargement du module, l'asynchrone perturberait l'ordre de résolution de la configuration Vite.

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

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

defineest le mécanisme deremplacement de textede Vite : dans le code source, tous les__COMMIT__sont remplacés par le résultat deJSON.stringify(commit)(c'est-à-dire une chaîne littérale entre guillemets).JSON.stringifyest nécessaire — si l'on écrivait directementcommit, après remplacement cela deviendrait l'identifiant nuabc1234, traité comme nom de variable et non comme chaîne.

〔Inférence de conception et compromis architecturaux〕

__VUE_PROD_DEVTOOLS__: trueest une autre constante clé : elle permet à lacompilation de productionde Vue de conserver le support DevTools. Par défaut, la compilation de production supprime le hook DevTools pour réduire la taille, mais le Playground doit déboguer le code utilisateur, donc il est forcé activé.

Étape par étape : le transport d'artefacts 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`)
    },
  }
}

Analyse point par point des éléments clés :

1. generateBundlehook: exécuté après que Rollup a généré le bundle, avant l'écriture sur disque. À ce moment, on peutemitFileajouter des fichiers supplémentaires dans les artefacts.

2. import.meta.dirname: version ESM de__dirnamefournie par Node 20.11+. Le chemin../../packagesremonte depackages-private/sfc-playground/jusqu'à la racine du dépôt, puis entre danspackages/。

3. Vérification d'existence + erreur explicite: sivue.esm-browser.jsn'existe pas, lever une erreur avec instruction de correctionRun "nr build vue -f esm-browser" first.. C'est un modèle d'expérience développeur— le message d'erreur indique directement comment corriger.

4. Cinq artefacts:vueversion complète/runtime × dev/prod, plusserver-renderer. Ces cinq fichiers constituent précisément l'ensemble candidat pour l'import dynamique du Playground dans le navigateur, correspondant au changement de version et à l'interrupteur SSR dans le Header.

〔Inférence de conception et compromis architecturaux〕

Pourquoi ces cinq ?La version complète (avec compilateur) sert au scénario de « compilation à l'exécution » ; la version runtime au scénario de « précompilation » ; dev/prod correspond au basculement PROD/DEV du Header ; server-renderer correspond à l'interrupteur SSR. Ces cinq fichiers constituent la « matrice runtime Vue » du Playground.

Flux de données complet du changement de version

Relions lesetVueVersiondu Header aux artefacts 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["实时预览"]

Noter la valeur spéciale@${__COMMIT__}: elle correspond aux artefacts locaux copiés par copyVuePlugin, et non au CDN. C'est pourquoi le Playground doit copier les artefacts de build navigateur de Vue —l'option « This Commit » nécessite des fichiers locaux。

Réflexions de conception et pièges

〔Inférence de conception et compromis architecturaux〕

Piège 1 :spawnSyncgestion de l'échec. Si le répertoire courant n'est pas un dépôt git (par exemple extrait d'un tarball),spawnSyncrenvoie un code de sortie non nul,stdoutest vide,commitdevient une chaîne vide. À ce moment,__COMMIT__est remplacé par"", dans le Header@${currentCommit}devient'@'. Aucune gestion d'erreur explicite.

〔Inférence de conception et compromis architecturaux〕

Piège 2 :optimizeDeps.exclude: ['@vue/repl'] 📎 packages-private/sfc-playground/vite.config.ts:27-29. Vite pré-bundle les dépendances par défaut pour accélérer le démarrage à froid, mais@vue/replest exclu. La raison est que@vue/replutilise en interne des imports dynamiques et des workers, et le pré-bundling casserait ces mécanismes. C'est un problème courant dans l'écosystème Vite : « conflit entre pré-bundling et chargement dynamique ».

〔Inférence de conception et compromis architecturaux〕

Piège 3 :script.fsconfiguration 📎 packages-private/sfc-playground/vite.config.ts:13-19。@vitejs/plugin-vuel'optionscript.fspermet au bloc<script>des SFC de lire des fichiers viafs. Ici, on passefs.existsSyncetfs.readFileSync, afin de supporter l'analyse des instructionsimportdans les SFC (par exempleimport x from './foo'doit vérifier si un fichier existe).C'est la clé permettant au Playground de simuler une résolution de modules complète dans le navigateur— il injecte la capacité fs de Node dans la phase de résolution du compilateur.

---

Réflexions de conception : compromis architecturaux du Playground

En reliant les trois sous-sections, l'architecture du Playground suit un principe clair :séparer « état » et « effets de bord », séparer « compilation » et « exécution »。

  • main.tsne fait qu'injecter des effets de bord globaux, sans toucher à l'état métier.
  • Header.vueest un composant purement présentationnel, l'état entre par props et sort par emit.
  • vite.config.tsfige l'information de compilation « commit actuel » en constante, en lecture seule à l'exécution.
〔Inférence de conception et compromis architecturaux〕

Cette séparation apporte un avantage direct :le Playground peut être intégré dans n'importe quelle application Vue(par exemple un exemple intégré dans un site de documentation), à condition de fournirstoreet quatre props booléens.

Le coût estÉtat dispersé:storeDans@vue/repl, l'état booléen est dans le composant parent, la classe DOM est surdocument.documentElement, et il y en a une autre copie dans localStorage. Quatre emplacements d'état doivent être synchronisés manuellement, et toute désynchronisation entraîne une incohérence de l'UI.

〔Inférences de conception et arbitrages architecturaux〕

Un autre compromis estabandonner la compatibilité SSR。main.tsaccéder directement àwindow,Header.vuedetoggleDarkaccéder directement àdocument. Le Playground est une application purement CSR, il n'est pas nécessaire de considérer le rendu côté serveur.

---

Résumé de ce chapitre

Ce chapitre a analysépackages-private/sfc-playgroundles trois fichiers principaux :

1. main.ts: point d'entrée de 9 lignes, le cœur étant l'ordre d'injection dewindow.VUE_DEVTOOLS_CONFIG— doit être avantmount.

2. Header.vue: dérivecomputedviavueVersion, et signale tous les changements d'état viaemit.copyLinkla branchemetaKeyde

3. vite.config.ts:spawnSyncest une porte dérobée de débogage local cachée.definerécupère le hash de commit,__COMMIT__,copyVuePlugininjecte

pour transporter les cinq artefacts de navigateur Vue vers le répertoire d'artefacts du Playground.Le fil conducteur traversant les trois est:__COMMIT__la frontière entre les constantes de build et l'état d'exécutionstore.vueVersionest un fait de build en lecture seule,vueVersionest un choix d'exécution mutable, le

computed du Header unifie les deux en une seule chaîne d'affichage.

Réflexions et auto-évaluation de ce chapitremain.tsQ1 : Si l'on déplace l'assignation dewindow.VUE_DEVTOOLS_CONFIGdanscreateApp(App).mount('#app')après

, que se passe-t-il ? Pourquoi ?:window.VUE_DEVTOOLS_CONFIGAnalyse de référencecreateAppest la configuration lue par Vue DevTools lors de l'enregistrement du hook à l'intérieur de📎 packages-private/sfc-playground/src/main.ts:4-9。createAppenregistre immédiatement__VUE_DEVTOOLS_GLOBAL_HOOK__, à ce moment DevTools litdefaultSelectedAppIdpour décider quelle app est sélectionnée par défaut. Si l'assignation est postérieure àmount, DevTools a déjà terminé la première sélection d'app, la configuration ne prendra pas effet, et l'utilisateur devra basculer manuellement vers l'apprepldans DevTools. Plus subtil encore : comme@vue/replcrée aussi une app en interne, une assignation tardive peut amener DevTools à sélectionner par défaut le Playground lui-même plutôt que le REPL de l'utilisateur, nécessitant un basculement manuel lors du débogage du code utilisateur. Cela illustre l'importance de « l'ordre d'injection des effets de bord globaux » dans les outils de débogage.

Q2: Header.vueletoggleDark()deprops.thememanipule simultanément la classe DOM, localStorage et emit, mais ne modifie pas directementtoggle-theme. Si le composant parent, après avoir reçu l'événementtheme, refuse de mettre à jour la prop

, quelle incohérence d'UI apparaîtrait ? Comment la localiser au niveau du code source ?:toggleDark()Analyse de référence📎 packages-private/sfc-playground/src/Header.vue:58-66dansdocument.documentElement.classList.toggle('dark')appelle directementdark, ce qui change immédiatement la classe📎 packages-private/sfc-playground/src/Header.vue:186-186sur le DOM, déclenchant le basculement des variables CSS (voir.dark navla règle:titlede📎 packages-private/sfc-playground/src/Header.vue:123). Mais le texteprops.themedans le template dépend de<html>, si le composant parent ne met pas à jour, le title restera à l'ancienne valeur. Méthode de localisation : inspecter dans les DevTools du navigateur si la classe de

Q3: copyVuePluginet l'attribut title du bouton sont contradictoires. La cause racine est que « l'effet de bord DOM » et « l'état réactif Vue » empruntent deux chemins indépendants, sans source de données unique.generateBundledansfs.existsSynceffectue une vérificationfs.readFileSyncsur chaque fichier, lançant une erreur avec instruction de correction en cas d'absence. Si l'on supprime cette vérification et appelle directement

, que se passe-t-il dans un environnement CI (sans avoir construit vue au préalable) ? Comment le message d'erreur induirait-il les développeurs en erreur ?Analyse de référencefs.readFileSync: après suppression de la vérification,ENOENT: no such file or directory, open '.../packages/vue/dist/vue.esm-browser.js' 📎 packages-private/sfc-playground/vite.config.ts:32-63lanceranr build vue -f esm-browser. Cette erreur indique seulement au développeur que « le fichier n'existe pas », mais ne lui dit pas qu'« il faut d'abord exécuterthrow new Error(\${basename} not built. Run "nr build vue -f esm-browser" first.\)». Dans un environnement CI, le développeur pourrait croire à tort à une erreur de configuration de chemin, un problème de permissions ou un sous-module git non initialisé, perdant beaucoup de temps en investigation. Le

---

du code original lie « le symptôme » et « l'action de correction », un détail clé de la conception de l'expérience développeur. Cela explique aussi pourquoi le script de build du Playground doit avoir un ordre de dépendance explicite avec le script de build du cœur de Vue.packages-private/template-explorerLe chapitre suivant abordera

, pour voir comment Vue visualise les produits intermédiaires du compilateur (AST, résultats de transformation, génération de code), permettant aux développeurs d'observer pas à pas chaque transformation du template vers la fonction de rendu. Contrairement au « black box de bout en bout » du Playground, Template Explorer est une « sonde white box ».@vue/compiler-domJusqu'ici, nous avons vu clairement comment le SFC Playground transporte le pipeline de compilation dans le navigateur : initialisation de l'entrée, bascule d'état du Header et injection de constantes de build constituent ensemble un bac à sable débogable en temps réel. Mais la perspective du Playground reste toujours « la compilation et l'exécution d'un SFC entier », il ne répond pas directement à « ce que le compilateur fait exactement comme transformation sur une expression de template donnée ». Le chapitre suivant entrera dans Template Explorer, pour voir comment il déploie ligne par ligne les résultats de compilation de@vue/compiler-ssret

Transformez n'importe quel code en un livre compréhensible

Vous avez aimé ce chapitre ? Créez un livre pour votre projet privé

Architecture local-first en Tauri 2 + Rust. Sécurité 100% hors ligne, zéro code téléversé. Lecture double panneau avec ancres de commits immuables.

⚡ Tauri 2 · Rust Core · 100% Hors ligne & Privé · Testé sur 1M+ lignes

CHAPTER 08

Chapitre suivant : Chapitre 8 →

Upstream: vuejs/core · Commit @4ab865a8 · Progression: Chapitre 8 sur 14

Dans le chapitre précédent, nous avons vu comment SFC Playground encapsule toute la chaîne « saisie SFC → compilation dans le navigateur → aperçu en temps réel » dans une boîte noire : le développeur voit le résultat de rendu final, mais ne voit pas ce que le compilateur fait entre-temps. Lorsqu'une directive personnalisée est écrite dans le template, ou que hoistStatic est activé et que le produit de sortie fait soudain apparaître une multitude de variables _hoisted_1, le Playground ne peut pas répondre à la question « pourquoi le compilateur génère-t-il cela ». Le positionnement de Template Explorer est exactement l'inverse : il expose entièrement les produits de compilation de @vue/compiler-dom et @vue/compiler-ssr, l'AST, les marqueurs d'erreur, ainsi que la correspondance de position entre le code source et le produit de sortie. Son cœur n'est pas « exécuter », mais « observer ». Ce chapitre s'articule autour de trois fichiers : index.ts est responsable de l'appel de compilation et de la correspondance bidirectionnelle SourceMap, options.ts gère des dizaines de CompilerOptions avec reactive et pilote l'UI, theme.ts personnalise le thème de l'éditeur Monaco.

I. Appel de compilation et correspondance bidirectionnelle SourceMap : index.ts

Modèle intuitif

Leindex.tsde Template Explorer ressemble à une « machine à traduire bidirectionnelle » : à gauche on saisit le template, à droite on obtient la fonction de rendu. Mais elle possède une capacité de plus qu'une machine à traduire — lorsque vous placez le curseur sur une ligne à gauche, la droite met en surbrillance le produit correspondant ; inversement, si vous placez le curseur à droite, la gauche met en surbrillance le template correspondant. Sans le mappage SourceMap, cet outil dégénérerait en deux zones de texte côte à côte, et le développeur devrait comparer à l'œil nu, sans pouvoir établir la chaîne causale « ligne N du template → ligne N du produit ».

Structures de données et disposition mémoire

index.tsIl n'y a pas de Struct complexe dans , mais il existe plusieurs variables d'état clés au niveau du module, qui déterminent le comportement de tout l'outil :

lastSuccessfulCodeetlastSuccessfulMapsont le cache du résultat de compilation📎 packages-private/template-explorer/src/index.ts:74-75. Le premier est une chaîne, le second estSourceMapConsumer | undefined. Notez quelastSuccessfulMapest initialementundefined, et n'est assigné que lorsque la compilation réussit et quemapexiste📎 packages-private/template-explorer/src/index.ts:99-100. Cet étatundefinedest la condition de garde de toute la logique ultérieure de mappage du curseur — si la compilation échoue, la fonctionnalité de mappage devient automatiquement silencieusement inactive, au lieu de lever une exception.

PersistedStateL'interface définit la forme de l'état persisté dans localStorage et le hash d'URL📎 packages-private/template-explorer/src/index.ts:26-30:src(code source du template),ssr(mode SSR ou non),options(options du compilateur). Il y a ici une conception clé :optionsa pour type leCompilerOptionscomplet, mais lors de la persistance réelle, seuls les « éléments différents des valeurs par défaut » sont enregistrés ; cette logique de filtrage est effectuée dansreCompile.

sharedEditorOptionssont les options de construction partagées par les deux éditeurs📎 packages-private/template-explorer/src/index.ts:26-30:fontSize: 14、scrollBeyondLastLine: false、renderWhitespace: 'selection'、minimap.enabled: false. La minimap est désactivée car le template et le produit ne font généralement que quelques dizaines de lignes, et la minimap occupe au contraire l'espace horizontal.

Step-by-Step Walkthrough

Scénario : l'utilisateur ouvre la page, saisit<div>{{ msg }}</div>, puis déplace le curseur.

Première étape : initialisation et restauration de l'état. window.initest le point d'entrée global📎 packages-private/template-explorer/src/index.ts:41. Il enregistre et active d'abord le thème personnalisé📎 packages-private/template-explorer/src/index.ts:44-45, puis tente de restaurer l'état depuis le hash d'URL ou localStorage📎 packages-private/template-explorer/src/index.ts:49-56. Notez l'ordre de décodage ici : d'abordatobpuisescape, ensuitedecodeURIComponent. Si l'analyse du hash échoue, on revient àlocalStorage.getItem('state'), puis à{}. Si l'ensemble du JSON.parse échoue, localStorage est vidé et un avertissement est affiché📎 packages-private/template-explorer/src/index.ts:57-64。

Après la restauration de l'état, il y a un détail facile à négliger :delete persistedState.options?.nodeTransforms 📎 packages-private/template-explorer/src/index.ts:69. Le commentaire explique la raison — les fonctions ne peuvent pas être sérialisées, donc lors de la persistancenodeTransformsest perdu, et lors de la restauration, s'il reste un objet vide, le comportement du compilateur devient anormal. C'est le piège classique de la « persistance de champs non sérialisables ».

Deuxième étape : cœur de la compilationcompileCode。C'est le cœur de tout l'outil📎 packages-private/template-explorer/src/index.ts:76-106. Il commence parconsole.clear(), puis selonssrMode.valuechoisitssrCompileoucompile 📎 packages-private/template-explorer/src/index.ts:80. Notez les paramètres d'appel decompileFn: on étendcompilerOptions, on forcefilename: 'ExampleTemplate.vue'、sourceMap: true, et on injecte le callbackonErrorpour collecter les erreurs📎 packages-private/template-explorer/src/index.ts:82-89。

Il y a ici une décision de conception :filenameest codé en dur à'ExampleTemplate.vue'. Cette valeur doit correspondre exactement dans l'appel ultérieur àgeneratedPositionFor, sinon la requête SourceMap renverra un résultat vide. C'est un contrat implicite — les deux chaînes doivent être identiques, mais aucun système de types ne le garantit.📎 packages-private/template-explorer/src/index.ts:189Une fois la compilation terminée, les erreurs sont converties au format marker de Monaco et définies sur l'éditeur

convertit le📎 packages-private/template-explorer/src/index.ts:91-95。formatErrordeCompilerErrorenlocde Monaco. NotezstartLineNumber/startColumn/endLineNumber/endColumn 📎 packages-private/template-explorer/src/index.ts:108-119— seules les erreurs avec information de position sont marquées ; les erreurs sanserrors.filter(e => e.loc)(comme les erreurs de configuration globale) ne sont affichées que dans la console.locTroisième étape : établissement de la SourceMap.

Après une compilation réussie,, puis on appelle immédiatementlastSuccessfulMap = new SourceMapConsumer(map!) 📎 packages-private/template-explorer/src/index.ts:99est une API clé decomputeColumnSpans() 📎 packages-private/template-explorer/src/index.ts:100。computeColumnSpans: elle précalcule l'étendue de colonne de chaque segment de mappage, ce qui rend disponible le champsource-map-jsrenvoyé pargeneratedPositionFor. Sans cette étape, le mappage inverse ne peut localiser que la colonne de début, et ne peut pas mettre en surbrillance toute l'étendue du token.lastColumnQuatrième étape : mappage bidirectionnel du curseur.

Lorsque l'utilisateur, dansl'éditeur de code source, déplace le curseur, cela déclenche. Après un debounce de 100 ms, le callback appelleeditor.onDidChangeCursorPosition 📎 packages-private/template-explorer/src/index.ts:184. NotezlastSuccessfulMap.generatedPositionFor({ source: 'ExampleTemplate.vue', line, column: column - 1 }) 📎 packages-private/template-explorer/src/index.ts:188-192: les numéros de colonne de Monaco commencent à 1, tandis que ceux de la SourceMap commencent à 0. Lecolumn - 1renvoyé, s'il possèdeposetline, crée un décorateur sur l'éditeur de sortie pour mettre en surbrillance la plage correspondantecolumn, et défile jusqu'à cette position📎 packages-private/template-explorer/src/index.ts:194-206Le mappage inverse se fait dans📎 packages-private/template-explorer/src/index.ts:207-210。

deoutput.onDidChangeCursorPosition. Il appelle📎 packages-private/template-explorer/src/index.ts:223, mais avec une garde supplémentaire : ignoreroriginalPositionFor 📎 packages-private/template-explorer/src/index.ts:227-230,但多了一个守卫:忽略 pos.line === 1 && pos.column === 0de « mock location »📎 packages-private/template-explorer/src/index.ts:231-237. Ce garde est crucial — certains codes générés par le compilateur (comme lesimportinstructions ou fonctions helper) n'ont pas de position de template correspondante, et SourceMap renvoie{ line: 1, column: 0 }comme placeholder. Si on ne l'ignore pas, placer le curseur sur ces lignes mettra erronément en surbrillance la première ligne du template.

Cinquième étape : persistance de l'état. reCompiledéclenche non seulement la compilation, mais est aussi responsable d'écrire l'état actuel dans localStorage et le hash d'URL📎 packages-private/template-explorer/src/index.ts:121-146. Lors de la persistance, il y a une logique de filtrage : parcourircompilerOptions, et ne sauvegarder que les éléments « non-objet et différents de la valeur par défaut »📎 packages-private/template-explorer/src/index.ts:125-133. Cela explique pourquoibindingMetadatace type d'option de type objet n'est pas persisté — c'est trop complexe, et la valeur par défaut suffit déjà pour la démonstration.

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

Réflexions de conception et pièges en production

Pourquoi utilisersource-map-jsplutôt quesource-map? source-mapest la bibliothèque originale de Mozilla, volumineuse et dépendante de WASM (nouvelle version).source-map-jsest une implémentation pure JS, de petite taille, adaptée à l'environnement navigateur. Template Explorer, en tant qu'outil purement front-end, choisirsource-map-jsest raisonnable📎 packages-private/template-explorer/package.json:15。

le choix du délai de debounce.Le debounce de l'éditeur de code source est par défaut de 300ms📎 packages-private/template-explorer/src/index.ts:271, tandis que le debounce du déplacement du curseur est de 100ms📎 packages-private/template-explorer/src/index.ts:215. Cette différence est intentionnelle : la compilation est une opération lourde, 300ms évite les déclenchements fréquents ; le déplacement du curseur est une opération légère, 100ms garantit la réactivité. Mais 100ms peut encore provoquer un clignotement de la surbrillance lors de déplacements rapides du curseur — c'est un compromis acceptable.

window.initLe montage global de. Noter quewindow.initetwindow.monacosont tous deux montés sur le global📎 packages-private/template-explorer/src/index.ts:19-23. C'est parce que l'éditeur Monaco est chargé de manière asynchrone via le CDNloader.js, et une fois le chargement terminé, il appellewindow.init. Ce modèle de « callback global » est l'usage standard de Monaco dans un environnement non modulaire, mais il est en décalage avec les méthodes de build ESM modernes.

---

II. Panneau d'options piloté par reactive : options.ts

Modèle intuitif

options.tsressemble à un « panneau de contrôle » : il y a une dizaine d'interrupteurs et de boutons radio en haut, chacun correspondant à un comportement du compilateur. Actionner n'importe quel interrupteur fait immédiatement changer le résultat de compilation à droite. Sans ce module, les développeurs ne pourraient que modifier les paramètres d'appel decompiledans le code source puis recompiler, sans pouvoir comparer en temps réel les effets des différentes options.

Structure de données et disposition mémoire

options.tsLe cœur de

ssrModeestref(false) 📎 packages-private/template-explorer/src/options.ts:5. Il est indépendant decompilerOptions, car le mode SSR commute la fonction de compilation elle-même (compile vs ssrCompile), et non les options de compilation.

defaultOptionsest un objet completCompilerOptions📎 packages-private/template-explorer/src/options.ts:5-27. Il définit les valeurs par défaut de toutes les options, y comprismode: 'module'、prefixIdentifiers: false、hoistStatic: false、cacheHandlers: false、scopeId: null、inline: false、ssrCssVars: '{ color }'、compatConfig: { MODE: 3 }、whitespace: 'condense', ainsi qu'unbindingMetadata 📎 packages-private/template-explorer/src/options.ts:18-26。

compilerOptionscontenant 7 types de bindingsreactive(Object.assign({}, defaultOptions)) 📎 packages-private/template-explorer/src/options.ts:29-31estObject.assign({}, ...). Noter ici l'utilisation dereactive(defaultOptions)pour une copie superficielle — si on faisait directementcompilerOptions, modifierdefaultOptionspollueraitreCompile, rendant inopérante la logique de « comparaison avec la valeur par défaut » dans

Step-by-Step Walkthrough

Scénario : l'utilisateur clique sur la case à cocher « hoistStatic ».

Première étape : rendu de l'UI. AppLesetupdu composant📎 packages-private/template-explorer/src/options.ts:33-35retourne une fonction de rendussrMode.value、compilerOptions.mode、compilerOptions.prefixIdentifiers. Cette fonction de rendu lit📎 packages-private/template-explorer/src/options.ts:36-39et d'autres états réactifs

, donc lorsque ces états changent, toute l'UI se re-rend. hoistStaticDeuxième étape : liaison checked de la case à cocher.checkedLa propriétécompilerOptions.hoistStatic && !isSSR 📎 packages-private/template-explorer/src/options.ts:150de la case à cocher esthoistStatic. Il y a une logique ici : en mode SSR,disabled: isSSR 📎 packages-private/template-explorer/src/options.ts:151est forcé à s'afficher comme non coché, car la compilation SSR ne supporte pas le hoisting statique. En même temps,

garantit que l'utilisateur ne peut pas le basculer en mode SSR.Troisième étape : traitement onChange.onChangeLorsque l'utilisateur clique sur la case à cocher,📎 packages-private/template-explorer/src/options.ts:152-156déclenchee.target.checked, assignant directementcompilerOptions.hoistStaticàcompilerOptions. CommereactiveestwatchEffect(reCompile) 📎 packages-private/template-explorer/src/index.ts:266, cette assignation déclenche le suivi des dépendances, puis déclenche

, et finalement recompile.Quatrième étape : interaction entre options.cacheHandlersNoter quecheckedleusePrefix && compilerOptions.cacheHandlers && !isSSR 📎 packages-private/template-explorer/src/options.ts:166,disabledde!usePrefix || isSSR 📎 packages-private/template-explorer/src/options.ts:167estcacheHandlersestprefixIdentifiers. Cela signifie quemode === 'module'dépend deprefixIdentifiersoufunction. Cette relation d'interaction se manifeste dans l'UI par : lorsquecacheHandlersn'est pas activé et que le mode est

scopeId, la case à cocherdisabled: !isModule 📎 packages-private/template-explorer/src/options.ts:182,checked: isModule && compilerOptions.scopeId 📎 packages-private/template-explorer/src/options.ts:183est désactivée.isModuleL'interaction denull 📎 packages-private/template-explorer/src/options.ts:184-189。

est plus complexe : initOptions. scopeId ne peut être défini qu'en mode module, et lors du onChange, sicreateApp(App).mount(document.getElementById('header')!) 📎 packages-private/template-explorer/src/options.ts:232-234est false, il sera forcé àvueCinquième étape : montage.createAppappelle@vue/runtime-dom. Noter ici l'utilisation deoptions.tsdu packagevue, et non

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

est du code applicatif, et peut dépendre directement du package complet

CopierreactiveRéflexions de conception et pièges en productionref? compilerOptionsPourquoi utiliserreactiveplutôt quecompilerOptions.hoistStatic = trueest un objet contenant une dizaine de champs, utilisercompilerOptions.value.hoistStatic = truepermet dereactivedirectement, sans avoir besoin decompilerOptions.xxx. C'est plus concis dans le code UI. Mais le coût de

bindingMetadataest que la déstructuration fait perdre la réactivité — il n'y a aucune déstructuration dans le code source, tout est accédé via, c'est l'usage correct.📎 packages-private/template-explorer/src/options.ts:18-26La conception des valeurs par défaut deSETUP_CONST、SETUP_REF、SETUP_LET、SETUP_MAYBE_REF、PROPSLes valeurs par défaut deprefixIdentifierscontiennent 7 bindings$setup, couvrant les cinq typesprefixIdentifiers. C'est pour que les développeurs puissent, après avoir ouvert

compatConfig, voir immédiatement l'impact des différents types de bindings sur la façon d'accéder à compilerOptions.compatConfig!.MODE = 2 📎 packages-private/template-explorer/src/options.ts:216-220dans le résultat. Sans cette valeur par défaut,reactivel'effet dereactiveserait très monotone.compatConfigLa réactivité imbriquée deCompatConfig | undefinedCe type d'assignation imbriquée est réactive sous!, carcompatConfigproxy récursivement les objets imbriqués. Mais noter que le type de

ssrModeestcompilerOptions, donc on utilise l'assertion ssrMode. Si la valeur par défaut ne contenait pasref,compilerOptions, cela planterait à l'exécution ici.reactiveSéparation des responsabilités entressretcompilerOptionsssrestCompilerOptionsest

---

. Pourquoi ne pas mettre

dans

theme.tsC'est comme « changer de peau » pour l'éditeur : il définit la couleur et le style de police de chaque token syntaxique. Sans ce module, Monaco utiliserait le thèmevs-darkpar défaut ; bien que fonctionnel, les balises HTML, les expressions et les directives dans les templates Vue manqueraient de distinction visuelle, rendant difficile pour les développeurs de localiser rapidement les parties clés.

Structure de données et disposition mémoire

theme.tsExporte un objet conforme à l'interfaceIStandaloneThemeDataMonaco📎 packages-private/template-explorer/src/theme.ts:1-244. Il possède trois champs de premier niveau :

base: 'vs-dark'Spécifie le thème de base📎 packages-private/template-explorer/src/theme.ts:2,inherit: trueReprésente les règles héritant du thème de base📎 packages-private/template-explorer/src/theme.ts:3. Cela signifie qu'il suffit de définir les différences ; les tokens non définis reviendront àvs-dark。

rulesest un tableau dont chaque élément contienttoken(le nom du token Monaco) etforeground/background/fontStyle 📎 packages-private/template-explorer/src/theme.ts:4-235. Ce tableau compte plus de 50 entrées, couvrant des types de tokens tels que number, comment, keyword, string, variable, entity.name.tag, etc.

colorsDéfinit les couleurs de l'interface de l'éditeur📎 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

Scénario : enregistrer le thème au chargement de la page.

Première étape : définir le thème. monaco.editor.defineTheme('my-theme', theme) 📎 packages-private/template-explorer/src/index.ts:44. Cet appel enregistretheme.tsl'objet exporté dans le registre de thèmes de Monaco, avec pour nom de clé'my-theme'。

Deuxième étape : activer le thème. monaco.editor.setTheme('my-theme') 📎 packages-private/template-explorer/src/index.ts:45. Cette ligne de code doit être appelée aprèsdefineTheme, sinon une erreur « thème non défini » sera levée.

Troisième étape : correspondance des tokens.Lorsque Monaco rend le code du template, il tokenise le code avec le service de langage HTML, puis recherche les règles dansrulespar nom de token. Par exemple<div>dansdivsera marqué commeentity.name.tag, correspondant àforeground: 'cc6666' 📎 packages-private/template-explorer/src/theme.ts:41-44, affiché en rouge.

Réflexions de conception et pièges en production

Pourquoi utiliserinherit: true?? Sans héritage, il faudrait définir les couleurs de tous les tokens, y compris ceux qui n'apparaissent pas dans les templates (commemarkup.heading、meta.diff). L'héritage permet au fichier de thème de se concentrer uniquement sur les tokens réellement présents dans les templates et les sorties JS.

Correspondance hiérarchique des noms de tokens.La correspondance des tokens de Monaco est basée sur les préfixes :entity.name.tagcorrespondra àentity.name.tag.html、entity.name.tag.css, etc. Le code source définit à la foisentity.name.tag 📎 packages-private/template-explorer/src/theme.ts:41-44etentity.name.tag.css 📎 packages-private/template-explorer/src/theme.ts:169-172, ce dernier écrasant le premier dans le contexte CSS spécifique.

colorsRépartition entreruleset rulescontrôle la couleur du texte du code,colorscontrôle la couleur de l'interface de l'éditeur (arrière-plan, curseur, ligne sélectionnée). Les deux sont indépendants mais doivent être visuellement coordonnés. Dans le code source,editor.background: '#1D1F21'est proche de l'arrière-plan par défaut debase: 'vs-dark', afin de maintenir la cohérence visuelle.

---

Réflexions de conception : compromis d'ingénierie d'une sonde de visualisation

La différence fondamentale entre Template Explorer et SFC Playground réside dans la « granularité d'observation ». Playground observe « si l'ensemble du SFC compilé peut s'exécuter », tandis que Template Explorer observe « ce en quoi une expression de template individuelle est compilée ». Cette différence détermine les choix techniques des deux outils :

L'introduction de SourceMapConsumer est inévitable.Sans lui, les développeurs ne pourraient que comparer visuellement le code source et la sortie, sans pouvoir établir une correspondance précise « ligne X → ligne Y ». Mais l'API de SourceMapConsumer est asynchrone (les nouvelles versions renvoient une Promise) ; le code source utilise la version synchronesource-map-js, afin de simplifier la logique d'appel.

reactiveLa gestion des options est un choix naturel dans l'écosystème Vue.Si l'on gérait manuellement la synchronisation d'état d'une dizaine d'options avec des événements DOM natifs, la quantité de code doublerait.reactiveLe suivi de dépendances dewatchEffect(reCompile)automatise la chaîne « changement d'option → recompilation », une seule ligne de code suffit pour l'abonnement.

Le mode de chargement global de Monaco est un héritage historique. window.monacoLes méthodes de montage global dewindow.initet

---

découlent de la conception du chargeur AMD de Monaco. Dans les builds ESM modernes, cela semble anachronique, mais la taille de Monaco (environ 5 Mo) rend le chargement à la demande toujours nécessaire.

Résumé du chapitreindex.tsTemplate Explorer est une « sonde en boîte blanche » : il n'exécute pas la sortie compilée, il montre uniquement le processus de compilation.compileCodeVia@vue/compiler-domil appelle@vue/compiler-ssrouSourceMapConsumer, utiliseoptions.tspour établir une correspondance bidirectionnelle entre le code source et la sortie, et implémente la surbrillance synchronisée du curseur via l'API de décorateurs de Monaco.reactiveUtiliseCompilerOptionspour gérerwatchEffect, pilote la recompilation viahoistStatic, et les relations entre options (comme la désactivation detheme.tsen SSR) sont explicitement codées dans la couche UI.

Personnalise le thème Monaco pour que les tokens syntaxiques des templates et de la sortie aient une distinction visuelle claire.hoistStaticLa valeur fondamentale de cet outil réside dans « utiliser l'outil pour déduire le comportement du compilateur » : lorsque vous n'êtes pas sûr de ce que

fait à un template donné, ouvrez Template Explorer, changez les options, observez les changements de sortie. C'est plus intuitif que de lire le code source du compilateur, et plus fiable que de deviner.

Réflexions et auto-évaluation de ce chapitreindex.tsQ1 : Si l'on supprime le garde mock location (originalPositionFor) depos.line === 1 && pos.column === 0dans{ line: 1, column: 0 }, dans quel scénario cela provoquerait-il une surbrillance erronée ? Pourquoi le compilateur génère-t-il des mappings comme

?Analyse de référence📎 packages-private/template-explorer/src/index.ts:231-237: le garde se situe dansimport { createElementVNode as _createElementVNode } from 'vue'. Le compilateur insère du code sans position correspondante dans le template lors de la génération de la sortie, par exemple des instructions d'import de helpers commeexport function render(_ctx, _cache) { ... }, ou des signatures de fonctions commesource-map-js. Ces codes n'ont pas de position d'origine dans la SourceMap,{ line: 1, column: 0 }renverraoriginalPositionForcomme valeur de remplacement. Si l'on supprime le garde, lorsque l'utilisateur place le curseur sur ces lignes,{ line: 1, column: 0 }, le code considérera qu'il s'agit d'une position valide et créera un décorateur de surbrillance à la première ligne, première colonne de l'éditeur de code source. Le résultat est : lorsque l'utilisateur clique sur la ligneimportde l'artefact, la première ligne de l'éditeur de code source est mise en surbrillance à tort, ce qui induit en erreur. L'essence de ce garde-fou est de « distinguer les mappings réels des mappings fictifs », et{ line: 1, column: 0 }estsource-map-jsla valeur sentinelle « aucun mapping » conventionnée.

Q2: reCompilelors de la persistance des options danstypeof val !== 'object' && val !== defaultOptions[key], la conditionbindingMetadataignore toutes les options de type objet. SibindingMetadataest modifié par l'utilisateur (par exemple via la console), cette modification sera perdue après actualisation de la page. Est-ce un bug ou une conception intentionnelle ? Si l'on souhaite prendre en charge

dans la persistance, quels problèmes faut-il résoudre ?Analyse de référence📎 packages-private/template-explorer/src/index.ts:129: la condition se trouve dansbindingMetadata. C'est une conception intentionnelle, pour trois raisons : premièrement,BindingTypesa une valeur de type énumérationcompatConfig, qui après sérialisation devient un nombre, et lors de la désérialisation, il est impossible de distinguer « l'utilisateur a explicitement défini la valeur à 0 » de « la valeur par défaut » ; deuxièmement,val !== defaultOptions[key]est un objet imbriqué,nodeTransformscompare des références, ce qui est toujours vrai, et entraînerait la persistance de toutes les options d'objet ; troisièmement,delete persistedState.options?.nodeTransformscontient des fonctions, qui ne peuvent pas être sérialisées, et le code source gère déjà📎 packages-private/template-explorer/src/index.ts:69viabindingMetadata. Si l'on souhaite prendre en chargebindingMetadata, il faudrait implémenter une comparaison profonde (plutôt qu'une comparaison de références), et gérer la sérialisation/désérialisation des valeurs d'énumération. Le problème plus fondamental est que :

Q3: options.tsn'a pas de point d'entrée d'édition dans l'interface utilisateur, l'utilisateur ne peut le modifier que via la console, et ce type de modification ne devrait de toute façon pas être persisté.compilerOptionsdansreactive(Object.assign({}, defaultOptions))est créé avecObject.assign({}, defaultOptions). Si l'on remplacereactive(defaultOptions)par une simple

, que se passe-t-il après que l'utilisateur a basculé l'option et actualisé la page ? Pourquoi ?:Object.assign({}, defaultOptions)Analyse de référence📎 packages-private/template-explorer/src/options.ts:29-31est une copie superficielle, située dansreactive(defaultOptions),compilerOptions. Si l'on remplace pardefaultOptionsethoistStaticpointeraient vers le même objet. Lorsque l'utilisateur basculecompilerOptions.hoistStaticà true,defaultOptions.hoistStaticdevient true, etreCompiledevient également true. Ensuite, la logique de persistance📎 packages-private/template-explorer/src/index.ts:129dansval !== defaultOptions[key]compareval, à ce momentdefaultOptions[key]etdefaultOptionssont tous deux true, la condition est fausse, et cette option ne sera pas sauvegardée dans localStorage. Après actualisation de la page,hoistStatic: falseest réinitialisé àdefaultOptions, la modification de l'utilisateur est perdue. Plus grave encore, une fois

---

pollué, toute la logique ultérieure de « comparaison avec la valeur par défaut » devient invalide, entraînant un effondrement complet de la fonctionnalité de persistance. Le caractère insidieux de ce bug réside dans le fait que : tout fonctionne normalement au sein d'une même session, et le problème ne se manifeste qu'après actualisation.scripts/release.jsLe chapitre suivant abordera

, pour voir comment Vue orchestre avec une machine à états interactive l'ensemble du processus de mise à jour du numéro de version, build, tests, commit Git, tag et npm publish. Contrairement à l'« observation » de Template Explorer, release.js est de l'« exécution » — il doit maintenir un état entre plusieurs étapes, gérer les échecs et les rollbacks, et trouver un équilibre entre confirmation interactive et automatisation.

Transformez n'importe quel code en un livre compréhensible

Vous avez aimé ce chapitre ? Créez un livre pour votre projet privé

Architecture local-first en Tauri 2 + Rust. Sécurité 100% hors ligne, zéro code téléversé. Lecture double panneau avec ancres de commits immuables.

⚡ Tauri 2 · Rust Core · 100% Hors ligne & Privé · Testé sur 1M+ lignes

CHAPTER 09

Chapitre suivant : Chapitre 9 →

Upstream: vuejs/core · Commit @4ab865a8 · Progression: Chapitre 9 sur 14

Statut de vérification : ancrage réel des numéros de ligne FACT

Dans le chapitre précédent, nous avons utilisé template-explorer pour déduire le comportement du compilateur et maîtrisé la méthodologie d'observation des mécanismes internes par les outils. Maintenant, nous détournons notre regard de la compilation vers la publication — c'est le moment le plus dangereux pour tout projet open source : il touche simultanément quatre systèmes externes irréversibles que sont le numéro de version, les artefacts de build, l'historique Git et le registre npm. Un npm publish erroné ne peut pas être annulé, un push de tag erroné polluera la résolution de dépendances de tous les utilisateurs en aval. Vue core utilise un scripts/release.js de 537 lignes pour dompter ce danger — ce n'est ni un script purement automatisé, ni une liste purement manuelle, mais une machine à états interactive : s'arrêter pour demander à l'humain aux points critiques, exécuter entièrement automatiquement aux points prévisibles, et restaurer le numéro de version à son état initial en cas d'échec à n'importe quelle étape. Ce chapitre décomposera les trois mécanismes centraux de cet orchestrateur : l'analyse des paramètres et l'initialisation de l'état, la décision interactive de version et le contrôle CI, ainsi que l'ordre de publication et le rollback en cas d'échec.

Analyse des paramètres et initialisation de l'état global

Modèle intuitifrelease.jsImaginezparseArgscomme le panneau de commande d'une vieille machine à laver : le bouton rotatif (

) détermine le mode à utiliser, les voyants lumineux (variables globales) enregistrent l'étape en cours, et le bouton « annuler » (gestion des erreurs) doit pouvoir ramener la machine à l'état précédant le remplissage d'eau. Sans cette logique d'initialisation, le script perdrait le contrôle sur la question « quelle version l'utilisateur veut-il réellement publier » — soit il publierait la mauvaise version, soit il resterait bloqué en CI à attendre une saisie clavier qui n'arrivera jamais.

〔Inférence de conception et arbitrages architecturaux〕

La première chose que fait le script après son lancement est d'analyser les arguments de ligne de commande en un objet structuré. Ici, on utilise le module intégré de NodeparseArgs, plutôt queyargsoucommander— afin d'éliminer les dépendances tierces, car le script de publication lui-même doit pouvoir s'exécuter dans n'importe quel environnement, même sinode_modulesn'est installé qu'à moitié.

📎 scripts/release.js:27-62définit 10 options, répartissables en quatre catégories :

  • Catégorie sémantique de version:preid(identifiant de prépublication, tel quealpha/beta/rc)、tag(npm dist-tag)
  • Catégorie de saut:skipBuild、skipTests、skipGit、skipPrompts— ces quatre commutateurs booléens constituent les boutons de réglage du « degré d'automatisation »
  • Catégorie de mode d'exécution:dry(exécution à blanc),publish(s'il faut publier directement en local),publishOnly(publier sans mettre à jour la version)
  • Catégorie de cible:registry(adresse de registre personnalisée)

Noter quepublishla valeur par défaut defalse 📎 scripts/release.js:51-54, tandis que les autres booléens n'ont pas de valeur par défaut (c'est-à-direundefined). Cette asymétrie est intentionnelle :publishla sémantique de est « faut-il exécuter npm publish en local », par défaut ne pas publier, en confiant l'action de publication à GitHub Actions ; tandis queskipXxxpar défautundefinedsignifie « non spécifié », et la logique ultérieure distinguera « l'utilisateur a explicitement passé--skipTests» de « l'utilisateur ne l'a pas passé ».

Une fois l'analyse terminée, le script aplatit les paramètres sur un ensemble de variables au niveau du module📎 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

Deux conceptions méritent ici d'être examinées. Premièrement,preIdla priorité de valeur de est « spécification explicite en ligne de commande > inférence à partir de la version actuelle »📎 scripts/release.js:64-66. Si la version actuelle depackage.jsonest3.5.0-beta.1, alorssemver.prereleaserenverra['beta', 1], et prendre[0]donne'beta'. Cela signifie que lors de publications successives sur la branche beta, il n'est pas nécessaire de taper--preid betaà chaque fois. Deuxièmement,skipTestsest déclaré aveclettandis que les autres utilisentconst 📎 scripts/release.js:64-66, car il sera réécrit dynamiquement par le résultat de la CI dansrunTestsIfNeeded— c'est un bit d'état de « décision différée ».

Vient ensuite la logique de découverte de paquets📎 scripts/release.js:68-83: lire le répertoirepackages/, filtrer les entrées non répertoires, celles sanspackage.json, ainsi que les paquetsprivate: true. Noter qu'ici on litpackages/et nonpackages-private/— ce dernier est un paquet de débogage interne, jamais publié.

L'algorithme de tri de l'ordre de publication

📎 scripts/release.js:85-85définit une fonction apparemment simple mais cruciale :

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

Il place le paquet d'entréevueen dernier. Le commentaire📎 scripts/release.js:85-85en explique la raison : si l'on publievueen premier, les utilisateurs pourraient installer la nouvelle version de@vue/runtime-corealors que des paquets internes commevuene sont pas encore en ligne, et npm signalerait une erreur faute de dépendance interne correspondante. C'est le compromis de « l'atomicité de publication » dans l'écosystème npm — npm n'a pas de transaction inter-paquets, et l'on ne peut qu'approcher l'atomicité par l'ordre.

Construction dynamique de l'ensemble des candidats d'incrément de version

📎 scripts/release.js:111-116construit les candidats du menu interactif :

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

Il s'agit d'une expansion conditionnelle : ce n'est que lorsquepreIdexiste (c'est-à-dire que l'on est actuellement dans le canal de prépublication, ou que l'utilisateur a explicitement spécifié--preid) que les types d'incrément liés à la prépublication sont ajoutés au menu. Si l'on est actuellement en version stable3.5.43et qu'aucunpreidn'est spécifié, le menu ne contient quepatch/minor/majortrois éléments — évitant qu'une mauvaise manipulation de l'utilisateur ne transforme la version stable en une version de prépublication bancale comme3.5.44-0.

incLa fonction📎 scripts/release.js:120-120encapsulesemver.inc, en passantpreIdcomme troisième paramètre. Il y a ici une défense de typage :typeof preId === 'string' ? preId : undefined— carpreIdpourrait êtrestring | undefined, alors quesemver.incattendstring | undefined, cette expression ternaire sert à satisfaire le rétrécissement de type de TS.

Primitives d'exécution : le système à double voie run et dryRun

📎 scripts/release.js:122-123est l'une des conceptions les plus ingénieuses de tout le chapitre :

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

rundéfinit le stdio du sous-processus surinherit, laissant la sortie de build/test se transmettre directement au terminal — ce qui est essentiel pour les builds de longue durée, l'utilisateur pouvant voir la progression en temps réel.dryRunse contente d'afficher la commande sans l'exécuter.runIfNotDryest un « choix de stratégie » : au chargement du module, le pointeur de fonction est lié àdryRunourun, et tous les points d'appel ultérieurs n'ont plus besoin de vérifierisDryRun。

〔Inférence de conception et arbitrages architecturaux〕

Ce modèle consistant à « décider de la stratégie à l'initialisation » est moins sujet aux erreurs que « vérifier à chaque point d'appel » : si un point d'appel oublie de vérifierisDryRun, en mode dry run, l'effet de bord serait réellement exécuté. OrrunIfNotDrycentralise la vérification en un seul endroit, éliminant ce type d'omission.

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

---

Décision interactive de version et verrou de CI

Modèle intuitif

Cette phase ressemble au contrôle de sécurité d'un aéroport : on vérifie d'abord votre carte d'embarquement (le commit local est-il synchronisé avec le distant), puis on confirme où vous allez (le numéro de version), et enfin on vérifie que vous avez passé le contrôle (la CI est-elle passée). Si une seule étape échoue, tout le processus s'arrête. Sans ce verrou, un commit local non poussé pourrait être étiqueté et publié, faisant que le code source correspondant à la version sur npm n'existe pas du tout sur GitHub — c'est l'accident de publication le plus difficile à diagnostiquer.

Vérification de synchronisation et sélection de version

mainLa première chose que fait la fonctionisInSyncWithRemote() 📎 scripts/release.js:141-141est📎 scripts/release.js:337-363. La logique de cette fonctiongit rev-parse HEADest : prendre le nom de la branche actuelle, demander à l'API GitHub le SHA du dernier commit de cette branche, et le comparer avec le📎 scripts/release.js:348-355local. En cas de divergence, une boîte de confirmation avec avertissement rougefalses'affiche, laissant l'utilisateur décider s'il faut continuer. Si la requête API échoue (problème réseau, absence de token), elle renvoie directement📎 scripts/release.js:365-367。

et interrompt

〔Inférence de conception et arbitrages architecturaux〕

La philosophie de conception ici est « l'échec entraîne l'arrêt » : en cas d'anomalie réseau, mieux vaut empêcher la publication que de risquer de continuer dans un état inconnu. Car la publication est irréversible, tandis que le coût de réexécuter le script est très faible.node scripts/release.js 3.6.0),targetVersionLa détermination du numéro de version suit deux chemins. Si l'utilisateur a passé un paramètre positionnel en ligne de commande (tel que📎 scripts/release.js:141-141prend directement cette valeur📎 scripts/release.js:152-176. Sinon, on entre dans le menu interactifcustom: on laisse d'abord l'utilisateur choisir le type d'incrément, et s'il choisit

, une boîte de saisie supplémentaire s'affiche pour qu'il remplisse manuellement le numéro de version.📎 scripts/release.js:174Noter

js
targetVersion = release.match(/\((.*)\)/)?.[1] ?? ''

Copierpatch (3.5.44)Le format de l'élément de menu estcustom, cette regex extrait le numéro de version réel des parenthèses. Si l'utilisateur choisit📎 scripts/release.js:164-172。

, c'est une autre branche qui est suivie📎 scripts/release.js:178-182Il y a ensuite une logique de « seconde analyse »targetVersion: sipatch/minorCe type de mot-clé incrémental (l'utilisateur peut passer directementnode release.js minor), on appelleincpour le convertir en numéro de version concret. Enfin, on utilisesemver.validpour valider📎 scripts/release.js:184-186, un numéro de version invalide lève directement une erreur.

Porte CI : la logique à trois états de runTestsIfNeeded

C'est le flux de contrôle le plus complexe de tout le chapitre.📎 scripts/release.js:281-317LerunTestsIfNeededde

est en réalité une machine de décision à trois états :--skipTests。skipTestsÉtat un : l'utilisateur a explicitement passétrueinitialisé à📎 scripts/release.js:314-316。

, on saute directement tout le corps de la fonction, on affiche "Tests skipped."État deux : non sauté, et la CI est déjà passéegetCIResult() 📎 scripts/release.js:319-335. Le script appelleci, qui interroge l'API GitHub Actions pour vérifier s'il existe un workflow run nomméconclusion === 'success'et📎 scripts/release.js:319-335. Si c'est validé, on demande à l'utilisateur « La CI est passée, voulez-vous sauter les tests locaux ? »📎 scripts/release.js:288-295. Si l'utilisateur a activé--skipPrompts, on saute automatiquement les tests locaux📎 scripts/release.js:296-298。

État trois : non sauté, et la CI n'est pas passée. Si--skipPromptsest activé, on lève directement une erreur📎 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--skipPromptsn'est pas activé, alorsskipTestsreste àundefined, on tombe dans la dernière branche de tests locaux📎 scripts/release.js:307-313, on exécutepnpm run test --run。

Il y a ici un détail subtil📎 scripts/release.js:285:

js
skipTests ||= isCIPassed

||=est une affectation par OU logique : on n'assigneskipTestsque lorsqueundefinedest falsy (falseouisCIPassed). Cela signifie que si l'utilisateur a explicitement passé--skipTests(true), cette ligne ne le modifie pas ; si l'utilisateur n'a rien passé (undefined), on le définit sur le résultat de la CI. Mais juste après,📎 scripts/release.js:287-298réassigne à nouveau lorsque la CI passe — donc||=l'effet réel de cette ligne est seulement « si la CI n'est pas passée, définirskipTestssurfalse», permettant ainsi à la brancheif (!skipTests)suivante d'exécuter les tests locaux.

〔Inférence de conception et compromis d'architecture〕

Cette logique fait un détour, mais l'essence est d'exprimer : « CI passée → on peut sauter les tests locaux (mais on demande à l'utilisateur) ; CI non passée → on doit exécuter les tests locaux (sauf si l'utilisateur demande explicitement de sauter) ». L'écriture avec||=plus une surcharge ultérieure est compacte, mais peu lisible, c'est un code smell typique de « bit d'état modifié à plusieurs endroits ».

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)

Écriture des numéros de version : le parcours de updateVersions

📎 scripts/release.js:377-384LeupdateVersionsdepackage.jsonfait deux choses : mettre à jour leupdatePackage。updatePackage 📎 scripts/release.js:391-398racine, puis parcourir tous les sous-packages en appelantnamepour lire le JSON, réécrireversionetJSON.stringify(pkg, null, 2) + '\n', et réécrire avec\n— notez le

getNewPackageNameà la fin, c'est pour conserver un fichier terminé par un saut de ligne, évitant que git diff n'affiche "No newline at end of file".keepThePackageName 📎 scripts/release.js:105Le paramètre

---

est par défaut

, c'est-à-dire sans changer le nom du package. Ce paramètre existe pour supporter le scénario « renommer le package lors de la publication vers un registry personnalisé » — bien que les points d'appel actuels passent tous la valeur par défaut, l'interface réserve l'extensibilité.

Ordre de publication, idempotence et rollback en cas d'échecupdateVersionsModèle intuitif

Cette phase ressemble à des dominos :

on pousse la première pièce (changement de numéro de version), puis les changelog, lockfile, commit, tag, publish tombent successivement. Si une pièce se bloque en cours de route, il faut un mécanisme pour relever les pièces déjà tombées — sinon le dépôt resterait dans un état bancal « version modifiée mais non publiée ».

publishPackage 📎 scripts/release.js:439-489Publication idempotente : isPackagePublished et repli sur erreur📎 scripts/release.js:442-451〔Inférence de conception et compromis d'architecture〕--tagest le cœur de la publication. Il détermine d'abord le dist-tagalpha/beta/rc: on privilégie le paramètreversion.includes('alpha'), sinon on l'infère à partir du mot-clésemver.prereleasedans le numéro de version. Notez qu'on utilise ici3.5.0-alpha.1,includesplutôt que

— car le numéro de version peut être de la forme📎 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-513Avant la publication, il y a une vérification d'idempotencenpm view <pkg>@<version> versionCopiertrueexécutefalse, retourne

en cas de succès, retournenpm viewen cas d'erreur de type E404. La raison d'être de cette vérification : le processus de publication peut être relancé suite à une interruption réseau, et un package déjà publié ne doit pas être republié (npm refusera une version en double).isPackagePublishedMais la vérification elle-même peut aussi échouer — par exemple si📎 scripts/release.js:507-510lève une erreur non-E404 à cause d'un timeout réseau. Dans ce cas,

propage l'erreur vers le hautpnpm publish, ce qui interrompt toute la publication. C'est une nouvelle manifestation de « plutôt interrompre que prendre un risque ».publishPackageMême si la vérification passe,📎 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
  }
}

fait un second repli dans le bloc catchpreviously publishedCopier

On n'avale l'erreur que si elle correspond à

📎 scripts/release.js:412-432, toutes les autres erreurs sont relancées. C'est de la « tolérance précise » : on ne dégrade que pour des erreurs connues et sûres à ignorer.pnpm publishAssemblage dynamique des flags de publication

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-checksselon l'environnement d'exécution :pnpm publishCopier

--provenanceest activé dans trois cas : dry run, skip git, ou en CI. La raison est que📎 scripts/release.js:425-427vérifie par défaut si le workspace est propre, si la branche courante est la branche de publication, etc., et en CI ces vérifications produisent des faux positifs.!args.registryn'est activé qu'en CI et lorsqu'aucun registry personnalisé n'est spécifié

. provenance est une fonctionnalité de sécurité de la chaîne d'approvisionnement de npm, qui signe et attache au package les informations d'origine de l'artefact de build (quel commit, quel workflow). Mais un registry personnalisé (comme un registry privé interne) ne supporte généralement pas provenance, d'où la condition

ajoutée.mainRollback en cas d'échec : le flag versionUpdated📎 scripts/release.js:528-537:

js
fnToRun().catch(err => {
  if (versionUpdated) {
    updateVersions(currentVersion)
  }
  console.error(err)
  process.exit(1)
})

versionUpdatedCopierfalse 📎 scripts/release.js:24-27est un booléen au niveau du module, initialisé àupdateVersions, et mis àtrue 📎 scripts/release.js:208immédiatement après le succès de l'appel àtrue. Si une étape ultérieure (génération de changelog, mise à jour du lockfile, git commit, publish) lève une erreur, le bloc catch vérifie ce flag, et s'il est àcurrentVersion。

, il restaure le numéro de version à

Ce rollback est « au mieux » : il ne restaure quepackage.jsonle numéro de version dans, sans restaurer le fichier changelog, le lockfile, ni le commit git déjà effectué. Si l'erreur se produit après le git commit, le dépôt se retrouve dans un état intermédiaire « numéro de version restauré mais commit existant ». C'est un compromis de conception — un rollback complet nécessiteraitgit reset, ce qui détruirait d'autres modifications que l'utilisateur aurait pu faire. Le script choisit donc de ne restaurer que le numéro de version critique, laissant l'utilisateur gérer le reste manuellement.

AttentionpublishOnlyle chemin📎 scripts/release.js:519-526ne définit pasversionUpdated, car sa sémantique est « publier uniquement, ne pas modifier la version » — même en cas d'échec, aucun rollback n'est nécessaire. Mais il appelletargetVersionlorsqueupdateVersions 📎 scripts/release.js:519-526existe, et en cas d'échec à ce moment, le numéro de version ne sera pas restauré. C'est un problème de bord potentiel, voir la question de réflexion en fin de chapitre.

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

Ordre de publication et traitement spécial du paquet vue

publishPackages 📎 scripts/release.js:412-432parcourtsortPackagesForPublishing(packages)le résultat et appellepublishPackageun par un. Comme le tri placevueen dernier📎 scripts/release.js:85-85, toute la séquence de publication garantit que les paquets internes sont mis en ligne en premier.

publishPackageutilise en internecwd: getPkgRoot(pkgName) 📎 scripts/release.js:475pour changer le répertoire de travail vers le répertoire du sous-paquet, afin quepnpm publishpublie le sous-paquet et non le paquet racine. Le commentaire📎 scripts/release.js:462-463rappelle spécifiquement « ne pas remplacer par npm publish » — carpnpm publishgère correctement le protocole de dépendanceworkspace:*, en le convertissant en numéro de version réel, tandis quenpm publishconserveraitworkspace:*tel quel, provoquant un échec d'installation.

---

Réflexion de conception

Pourquoi utiliserparseArgsplutôt queyargs?Le script de publication est la « dernière ligne de défense », il doit être exécutable dans n'importe quel environnement. Si une bibliothèque CLI tierce échoue à se charger à cause d'un arbre de dépendances corrompu, tout le processus de publication est paralysé. LeparseArgsintégré à Node, bien que rudimentaire (pas de sous-commandes, pas d'aide automatique), est sans dépendances et sans risque.

Pourquoi définirpublishpar défaut àfalse?Parce que la publication officielle de Vue passe par GitHub Actions (voir📎 scripts/release.js:256-263le message d'aide), le script local ne fait que modifier le numéro de version, générer le changelog, créer le tag et pousser. Le véritablenpm publishs'exécute dans la CI, ce qui permet de bénéficier de la signature de provenance et de l'environnement contrôlé de la CI.--publishLe flag

est une porte de sortie pour les mainteneurs en cas d'urgence pour publier localement.Pourquoi le rollback ne restaure-t-il que le numéro de version ?package.jsonParce qu'un rollback complet nécessiterait de comprendre « quelles modifications ont été faites par le script et lesquelles par l'utilisateur », ce qui est impossible à distinguer au niveau de git. Le script choisit de ne restaurer que ce dont il est le plus certain d'avoir modifié —

---

le numéro de version — et laisse le reste à l'appréciation de l'utilisateur.

scripts/release.jsRésumé de ce chapitre

1. implémente une « machine à états interactive » en 537 lignes de code, dont la conception centrale se résume en trois points :Les paramètres sont la stratégierunIfNotDry: 10 flags sont analysés au chargement du module et aplatis en variables globales,

2. lie la stratégie lors de l'initialisation, évitant les oublis de vérification aux points d'appel.Barrières en amont

3. : les vérifications de synchronisation, de version et les barrières CI sont toutes effectuées avant tout effet de bord, garantissant « tout ou rien ».:isPackagePublishedTolérance aux erreurs précisepreviously publishedpré-vérification +versionUpdateden secours d'erreur constituent une double protection idempotente ;

le flagréalise un rollback minimal.。

Ce mécanisme forme un contraste intéressant avec le Template Explorer du chapitre précédent : le Template Explorer « observe » — il visualise l'état interne du compilateur ; release.js « exécute » — il explicite chaque étape de l'état du processus de publication. Les deux incarnent la même philosophie d'ingénierie :

Transformer l'état implicite en état explicite, transformer les effets de bord incontrôlables en étapes contrôlables📎 scripts/release.js:285Réflexions et auto-évaluation de ce chapitreskipTests ||= isCIPassedQ1 : Si l'on changeskipTests = isCIPassedle--skipTestsde

en, que se passe-t-il lorsque l'utilisateur passe explicitement--skipTestset que la CI échoue ? Pourquoi ?skipTestsAnalyse de référencetrue 📎 scripts/release.js:64-66,||=: Dans la logique originale, lorsque l'utilisateur passerunTestsIfNeeded,📎 scripts/release.js:282est initialement àif (!skipTests)et ne le modifie pas, donc📎 scripts/release.js:314-316dans leskipTests = isCIPasseddeskipTestsest évalué à faux, sautant directement àfalsequi affiche « Tests skipped. ». Si on change en📎 scripts/release.js:287, alorsif (isCIPassed)est forcé à📎 scripts/release.js:299(CI échouée), ensuite leelse if (skipPrompts)de--skipPromptsest faux, tombant dans leskipTestsdefalse— si📎 scripts/release.js:307-313n'est pas activé, alors--skipPromptsreste à📎 scripts/release.js:300-303, et finalement||=exécute les tests locaux. Cela va à l'encontre de l'intention de l'utilisateur de « sauter explicitement les tests », et dans un environnement CI (

Q2: publishOnly), cela lèvera directement une erreur📎 scripts/release.js:519-526, provoquant l'arrêt de la publication.targetVersionL'existence deupdateVersionsest précisément là pour respecter le choix explicite de l'utilisateur.versionUpdatedLe cheminbuildPackagesappellepublishPackageslorsque

existe, mais ne définit pas:publishOnly. Si à ce momentupdateVersions(targetVersion) 📎 scripts/release.js:519-526oupackage.jsonlève une erreur, que se passe-t-il ? Cette conception est-elle raisonnable ?versionUpdated = trueAnalyse de référencebuildPackages 📎 scripts/release.js:519-526appellepublishPackages 📎 scripts/release.js:519-526modifie le numéro de version de tous lesfnToRun().catch 📎 scripts/release.js:528-537, mais ne définit pasversionUpdated. Lorsque ensuitefalseoupublishOnlylève une erreur,targetVersionvérifieupdateVersionsqui est àtargetVersion, et ne restaure pas le numéro de version. Le résultat est que le dépôt reste dans l'état « version modifiée mais publication échouée ». Cette conception est raisonnable dans la sémantique originale de📎 scripts/release.js:519-526(publier uniquement, ne pas modifier la version) — carversionUpdated = truen'est généralement pas passé,publishOnlyne s'exécute pas. Mais lorsque l'utilisateur passemain, ce chemin présente une faille de rollback. La correction consiste à ajouter

Q3: isPackagePublished 📎 scripts/release.js:491-513aprèsnpm view, ou à faire en sorte quenpm viewréutilise la logique de rollback de

.:isPackagePublishedutilise📎 scripts/release.js:507-510pour vérifier si le paquet est déjà publié. Si un timeout réseau fait queisPackageNotFoundErrorlève une erreur non-E404, que se passe-t-il ? Ce comportement est-il sûr dans un scénario de réexécution CI ?📎 scripts/release.js:515-515Analyse de référence/E404|No match found|No matching version|notarget/idans le bloc catchisPackageNotFoundErrorappellefalse,isPackagePublishedpour déterminer le type d'erreur. Cette fonction📎 scripts/release.js:507-510ne correspond qu'àpublishPackage 📎 scripts/release.js:453, ce qui entraîne l'annulation de toute la publication. Dans le scénario de réexécution de la CI, cela conduit à « le paquet est déjà publié, mais le processus s'arrête à cause d'une instabilité réseau » — mais c'est une direction d'échec sûre : l'arrêt est préférable à une erreur de jugement « non publié » entraînant une publication en double. Une publication en double déclenche l'erreurpreviously publishedd'npm, rattrapée par📎 scripts/release.js:491-492, mais gaspille un aller-retour réseau. Donc « erreur réseau = arrêt » est un choix conservateur mais correct.

---

Le chapitre suivant abordera.github/workflows/, pour voir comment, après que release.js a poussé le tag, GitHub Actions prend en charge la construction et la publication ultérieures, ainsi que l'implémentation complète des barrières CI.

Jusqu'ici, nous avons vu clairement comment release.js utilise une machine à états et une orchestration interactive pour minimiser le risque irréversible d'une publication. Mais le script de publication n'est qu'un exécutant ; ce qui décide réellement quand déclencher et sous quelles conditions laisser passer, c'est le gardien automatisé de niveau supérieur. Le chapitre suivant analysera le système CI/CD dans le répertoire .github/workflows : comment ci.yml exécute la triple barrière lint/typecheck/test au stade de la PR, comment release.yml déclenche la publication lors du push d'un tag, comment size-report.yml et size-data.yml suivent les régressions de taille de paquet, comment autofix.yml corrige automatiquement les problèmes de format. Vous comprendrez comment Vue utilise GitHub Actions pour figer les normes d'ingénierie en un pipeline incontournable.

Transformez n'importe quel code en un livre compréhensible

Vous avez aimé ce chapitre ? Créez un livre pour votre projet privé

Architecture local-first en Tauri 2 + Rust. Sécurité 100% hors ligne, zéro code téléversé. Lecture double panneau avec ancres de commits immuables.

⚡ Tauri 2 · Rust Core · 100% Hors ligne & Privé · Testé sur 1M+ lignes

CHAPTER 10

Chapitre 10 : Workflows CI/CD : le gardien automatisé de la PR à la Release

Upstream: vuejs/core · Commit @4ab865a8 · Progression: Chapitre 10 sur 14

Dans le chapitre précédent, nous avons vuscripts/release.jscomment utiliser une machine à états interactive pour enchaîner chaque étape d'une publication. Mais ce script a un prérequis : il doit être appelé activement par une personne ou un système. Dans le dépôt Vue core, cet appelant actif n'est pas le terminal local d'un mainteneur, mais GitHub Actions. release.js est l'exécutant, les workflows sont le décideur — ils déterminent quel événement déclenche quelle tâche, sous quelles conditions laisser passer, sous quelles conditions bloquer. Ce chapitre se concentre sur.github/workflows/les quatre fichiers du répertoire :ci.yml(barrière de PR et prépublication continue),release.yml(publication officielle déclenchée par tag),size-report.yml(rapport de régression de taille),autofix.yml(correction automatique du format). Les comprendre ne consiste pas à mémoriser la syntaxe YAML, mais à voir clairement comment l'équipe Vue traduit les normes d'ingénierie en contraintes de pipeline incontournables.

I. ci.yml : triple barrière et prépublication continue

Modèle intuitif

Imaginezci.ymlcomme un point de contrôle aéroportuaire. Chaque PR doit passer cette barrière : lint vérifie que vos bagages ne contiennent pas d'objets interdits, typecheck confirme que vos documents sont authentiques et valides, test vérifie que vous ne transportez pas de matières dangereuses. Mais il n'y a pas qu'un seul point de contrôle — Vue y a aussi accroché un canal de « prépublication continue », publiant directement les artefacts de build de chaque PR vers pkg-pr-new, permettant aux contributeurs de valider leurs modifications dans un scénario réel d'installation npm.

Sans cette barrière, toute fusion pourrait introduire des erreurs de format, des failles de type ou des régressions de comportement dans la branche main, or la branche main est la source de toutes les releases ultérieures.

Conditions de déclenchement et contrôle de concurrence

ci.ymlLa configuration de déclenchement de

📎 .github/workflows/ci.yml:2-11

yaml
on:
  push:
    branches:
      - '**'
    tags:
      - '!**'
  pull_request:
    branches:
      - main
      - minor

CopierpushIl y a ici deux conceptions clés. Premièrement,'**'l'événement écoute toutes les branches (tags: ['!**']), mais utiliserelease.ymlpour exclure explicitement tous les push de tags. Pourquoi exclure les tags ? Parce que le push de tags est traité séparément parci.yml; sipull_requestrépondait aussi aux tags, le processus de publication et le processus CI se déclencheraient en double, gaspillant les ressources des runners et pouvant même créer des conditions de course. Deuxièmement,mainn'écoute queminoretmaindeux branches — c'est la stratégie à double branche de Vue :minorporte la version stable,

📎 .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' }}

CopiergroupLe contrôle de concurrence est ici l'élément le plus ingénieux.github.event.pull_request.number || github.refL'expression decancel-in-progressutilisetruecomme fallback : les événements PR utilisent le numéro de PR comme clé de regroupement, les événements push utilisent le ref (nom de branche) comme clé de regroupement. Cela signifie que plusieurs push d'une même PR tombent dans le même groupe de concurrence. Et

n'est

que pour les événements PR — lorsque vous poussez trois commits consécutifs, les CI des deux premiers sont automatiquement annulées, seule la plus récente est conservée.

〔Inférence de conception et compromis architecturaux〕

📎 .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

L'entrée des trois barrières : la condition du job testifCopier&&Cette

condition contient deux branches de ET logique (! startsWith(github.event.head_commit.message, 'release:')), chacune méritant d'être développée.release:Au début, on saute les tests. C'est exactement le format du message de commit poussé par release.js dans le chapitre précédent — release.js a déjà exécuté l'ensemble des tests en local, la CI n'a pas besoin de revérifier. C'est une optimisation de « confiance en amont ».

〔Inférence de conception et arbitrages architecturaux〕

La deuxième condition(github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository): l'événement push exécute toujours les tests ; l'événement PR exige que la PR provienne d'un fork (head.repo.full_name != github.repository). Pourquoi seules les PR de fork sont exécutées ? Parce que les PR de branches du même dépôt sont généralement créées par les membres de l'équipe principale, et le push de leur branche a déjà déclenché la CI de l'événement push. En revanche, les PR de fork ne déclenchent pas l'événement push (le push d'un fork ne notifie pas le dépôt amont), il faut donc les exécuter en complément dans l'événement PR.

Attentionuses: ./.github/workflows/test.yml— c'est un appel à un reusable workflow.test.ymlest un fichier workflow indépendant, partagé parci.ymletrelease.yml. Cette réutilisation évite de redéfinir les étapes lint/typecheck/test dans plusieurs workflows.

Prépublication continue : le rôle 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-releaseLe jobvuejs/corene s'exécute que sur le dépôt principalif: github.repository == 'vuejs/core'), pas sur les forks. Il fait trois choses : build (pnpm build --withTypes, avec déclarations de types), puis utilisepkg-pr-newpour publier tous les packages sous./packages/*vers un registre npm temporaire.

〔Inférence de conception et arbitrages architecturaux〕

La valeur de ce mécanisme réside dans le fait que les contributeurs peuvent directementnpm installles artefacts de build de cette PR dans leur propre projet, pour vérifier si les modifications résolvent réellement le problème. C'est plus convaincant que « voir la CI au vert », car cela valide un scénario réel de consommation du package.

Notez que toutes les actions sont verrouillées sur un commit SHA (commeactions/checkout@3d3c42e5...), plutôt que d'utiliser@v4un tag flottant comme celui-ci. C'est une exigence stricte de sécurité de la chaîne d'approvisionnement — empêcher l'injection automatique de code malveillant après la compromission d'un dépôt d'action.

Graphe de flux de contrôle 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 : orchestration de publication après push de tag

Modèle intuitif

Sici.ymlest le point de contrôle de sécurité,release.ymlest la rampe de lancement. Lorsque release.js a terminé localement la mise à jour du numéro de version, le commit, le tag et le push, l'événement de push de tag allume le moteur derelease.yml. Il exécute d'abord une passe complète de tests (confirmation supplémentaire), puis exécuteReleasedans l'environnement protégépnpm release --publishOnly, et enfin crée la GitHub Release.

Sans lui, le tag poussé par release.js ne serait qu'une référence Git, aucune nouvelle version sur npm, aucune page Release sur GitHub.

Condition de déclenchement : uniquement les tags

📎 .github/workflows/release.yml:3-6

yaml
on:
  push:
    tags:
      - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10

N'écoute que les push de tags au formatv*. Cela complèteci.ymldetags: ['!**']— les deux sont strictement mutuellement exclusifs et ne se déclenchent jamais simultanément.

Conditions de garde du job de publication

📎 .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

Il y a ici trois niveaux de garde, aucun ne peut être omis.

Premier niveauif: github.repository == 'vuejs/core': empêche un déclenchement accidentel de publication sur un fork. Si quelqu'un forke le dépôt et pousse un tagv1.0.0, cette condition empêchera le processus de publication de s'exécuter.

Deuxième niveauneeds: [test]: le job release dépend du job test. Le job test appelletest.yml, si les tests échouent, le job release ne démarrera pas du tout. C'est une contrainte stricte de « tests obligatoires avant publication ».

〔Inférence de conception et arbitrages architecturaux〕

Troisième niveauenvironment: Release: c'est un GitHub Environment, qui peut configurer des règles de protection de déploiement (comme nécessiter l'approbation de personnes spécifiques). Cela signifie que même si le push de tag déclenche le workflow, l'étape de publication peut nécessiter une approbation manuelle pour s'exécuter — c'est la dernière ligne de défense contre les opérations irréversibles.

Côté permissions,contents: writesert à créer la GitHub Release,id-token: writesert à l'authentification provenance de npm (token OIDC). Notez qu'il n'y a pas depackages: writeici, car Vue publie sur npm et non sur GitHub Packages.

Chaîne complète de l'étape de publication

📎 .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
〔Inférence de conception et arbitrages architecturaux〕

Les trois étapes ont chacune leur subtilité.--frozen-lockfilegarantit que l'environnement CI installe strictement selon le lockfile, évitant que la dérive des versions de dépendances ne rende les artefacts de build incohérents avec le local.npm i -g npm@latestsert à obtenir la dernière npm CLI — car la provenance et l'authentification OIDC dépendent de versions relativement récentes de npm, les anciennes versions pouvant ne pas supporter ces fonctionnalités.

pnpm release --publishOnlyest le point d'entrée de release.js du chapitre précédent.--publishOnlyLe flag

indique à release.js : sauter la sélection interactive du numéro de version, sauter le commit Git et le tag (car le tag existe déjà), et exécuter uniquement le build et npm publish.

📎 .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.
Copier

〔Inférence de conception et arbitrages architecturaux〕release-tag action。tag_name: ${{ github.ref }}Ici on utiliserefs/tags/v3.x.x). Le corps de la Release ne contient pas les détails des changements, mais pointe vers CHANGELOG.md — car le changelog de Vue est généré automatiquement par conventional-changelog, et maintenir manuellement le corps de la Release créerait des incohérences avec le changelog.

Diagramme de séquence 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"

---

III. size-report.yml et autofix.yml : suivi de taille et auto-réparation de format

size-report.yml : rapport de régression de taille inter-workflow

size-report.ymlLe mode de déclenchement de est très particulier — il n'est pas déclenché directement par un push ou une PR, mais par l'événement d'achèvement d'un autre workflow.

📎 .github/workflows/size-report.yml:3-7

yaml
on:
  workflow_run:
    workflows: ['size data']
    types:
      - completed

workflow_runL'événement écoute l'achèvement du workflow nommésize data. C'est une conception en deux phases :size-data.yml(le code source n'est pas fourni dans ce chapitre) est responsable de construire et mesurer la taille sur la PR, puis de téléverser le résultat comme artifact ;size-report.ymlaprès l'achèvement desize data, télécharge l'artifact, génère le rapport et le commente sur la 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 garde : dépôt principal, événement PR, succès du workflow amont. Sisize dataéchoue, le job de rapport ne s'exécute pas — car il n'y a aucune donnée à rapporter.

Le flux de données est le suivant :

📎 .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

Télécharge l'artifactsize-datadepuis le workflow run amont verstemp/size. Puis lit en parallèle le numéro de PR et la branche de 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

parallelest un sucre syntaxique de GitHub Actions qui permet à deux étapes sans dépendance de s'exécuter simultanément.number.txtetbase.txtsont des fichiers de métadonnées écrits parsize-data.ymllors de la mesure.

Ensuite, télécharge les données historiques de taille de la branche de base pour comparaison :

📎 .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

Noterif_no_artifact_found: warn— si la branche de base n'a pas encore de données historiques (par exemple une nouvelle branche), cela n'échouera pas, mais émettra seulement un avertissement. Cela garantit que le rapport peut toujours être généré lors de la première exécution, simplement sans base de comparaison.

Enfin, génère le rapport et commente :

📎 .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.jslit les données soustemp/sizeettemp/size-prev, et génère un rapport Markdown.maintain-one-comment-backupL'action utilisebody-include: '<!-- VUE_CORE_SIZE -->'comme marqueur, garantissant qu'un seul commentaire de rapport de taille est conservé sur la même PR (mise à jour plutôt qu'ajout). Noter le commentaire à la L81 indiquant que le dépôt d'action original a été bloqué par GitHub, donc un dépôt de secours a été utilisé avec un commit verrouillé.

autofix.yml : réparation automatique des problèmes de format

autofix.ymlrésout un problème très concret : le code soumis par le contributeur ne respecte pas les normes prettier/eslint, la CI échoue, et le contributeur doit exécuter manuellementpnpm lint --fixpuis soumettre à nouveau. Ce workflow automatise cette étape.

📎 .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' }}

Déclenche toutes les PR, le contrôle de concurrence est similaire àci.yml— un nouveau push sur la même PR annule l'ancienne exécution 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

Exécute d'abord le--fixd'eslint, puis le formatage prettier, et enfinautofix-ci/actionsoumet directement les fichiers modifiés vers la branche de la PR. Noter quepnpm run formatest lui-même une commande de formatage (pas besoin du flag--fix, car le script format est en interneprettier --write)。

〔Inférence de conception et compromis architecturaux〕

La clé de ce mécanisme est queautofix-ci/actionsoumet les corrections en tant qu'auteur de la PR, et non en tant que bot. Ainsi, les contributeurs n'ont pas besoin d'opérations supplémentaires, et les corrections de format apparaissent automatiquement dans leur PR. Mais cela signifie aussi que si la branche du contributeur a des règles de protection (interdisant les push de bot), autofix échouera — c'est un cas limite que le contributeur doit gérer manuellement.

Diagramme de flux de données 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

---

Réflexion de conception : cristalliser les normes dans le pipeline

En examinant ces quatre workflows, on peut voir plusieurs principes de conception qui traversent l'ensemble.

Premièrement, minimisation des permissions. ci.ymletautofix.ymldéclarent touspermissions: contents: read, seulrelease.ymla besoin decontents: writeetid-token: write。size-report.ymla besoin depull-requests: writeetissues: writepour publier des commentaires. Chaque workflow ne prend que les permissions dont il a réellement besoin.

Deuxièmement, sécurité de la chaîne d'approvisionnement.Toutes les actions tierces sont verrouillées à un commit SHA, plutôt qu'à un tag flottant.size-report.ymlLe commentaire à la L81 indique même directement que le dépôt d'action original a été bloqué, puis qu'on est passé à un dépôt de secours avec un commit verrouillé — c'est une défense pratique contre les attaques de la chaîne d'approvisionnement.

Troisièmement, séparation des responsabilités et réutilisation. test.ymlest partagé parci.ymletrelease.yml, évitant la duplication de la logique de test.size-data.ymletsize-report.ymlsont séparés, permettant à la mesure et au rapport d'évoluer indépendamment.

Quatrièmement, le choix de la direction d'échec. size-report.ymlLeif_no_artifact_found: warnde choisit « avertir plutôt qu'échouer », car l'absence de données historiques ne devrait pas bloquer la PR. Tandis que lerelease.ymldeneeds: [test]choisit « bloquer la publication en cas d'échec de test », car la publication est une opération irréversible.

Cinquièmement, différenciation du contrôle de concurrence.L'événement PR annule les anciennes exécutions (cancel-in-progress: true), l'événement push ne les annule pas (cancel-in-progress: false). Cette différence reflète la sémantique des deux événements : les anciens commits d'une PR n'ont plus de sens, chaque commit d'un push peut être l'état final.

---

Résumé de ce chapitre

Ce chapitre a analysé les quatre workflows principaux du dépôt Vue core :

  • ci.yml: porte de PR + pré-publication continue. Via la conditionifpour distinguer push/PR et fork/même dépôt, utiliserconcurrencypour annuler les exécutions de PR obsolètes, utiliserpkg-pr-newpour publier des paquets de pré-publication installables.
  • release.yml: Publication officielle déclenchée par tag. Trois niveaux de garde (vérification du dépôt, needs test, approbation de l'environnement) garantissent que seuls les tags ayant passé les tests et été approuvés peuvent être publiés sur npm.
  • size-report.yml: Rapport de régression de taille inter-workflow. Viaworkflow_runévénement d'écoute en amontsize dataterminé, télécharge l'artifact et compare les données de la branche base, puis renvoie le retour sous forme de commentaire sur la PR.
  • autofix.yml: Correction automatique du format. Exécute eslint --fix et prettier sur la PR, viaautofix-ci/actioncommit directement les corrections dans la branche de la PR.

Ces quatre workflows constituent ensemble un « pipeline incontournable » : les normes de code sont corrigées automatiquement par autofix, les types et les tests sont vérifiés de manière obligatoire par ci.yml, la régression de taille est suivie par size-report, et la publication est exécutée par release.yml sous de multiples gardes.

Réflexions et auto-évaluation de ce chapitre

Q1 : Si l'on remplace dansci.ymlla valeur decancel-in-progresspar constammenttrue(c'est-à-dire en supprimant la conditiongithub.event_name == 'pull_request'), dans quels scénarios cela poserait-il problème ?

Analyse de référence:cancel-in-progressConstammenttruesignifie que lors d'un push vers la branche main, un nouveau push annulera l'ancienne CI en cours d'exécution. Considérons ce scénario : deux PR sont fusionnées consécutivement sur la branche main, la CI de la première PR est en cours d'exécution (incluant lint/typecheck/test complets), la fusion de la deuxième PR déclenche une nouvelle exécution de la CI. Sicancel-in-progressesttrue, la CI de la première PR sera annulée — mais le code de la première PR est déjà sur main, et son résultat de CI est crucial pour juger de la santé de la branche main. L'annuler signifie qu'un segment de code sur la branche main n'a jamais été complètement validé. Or la condition📎 .github/workflows/ci.yml:22-22degithub.event_name == 'pull_request'vise précisément à éviter ce problème : seuls les événements PR annulent les anciennes exécutions, les événements push n'annulent jamais.

Q2: release.ymlDansrelease, que protègent respectivementif: github.repository == 'vuejs/core'etenvironment: Releasedu job

? Que se passerait-il si l'on en supprimait un ?:if: github.repository == 'vuejs/core' 📎 .github/workflows/release.yml:14Analyse de référencev3.99.0protège le scénario fork. Si quelqu'un fork vuejs/core et pousse un tagpnpm release --publishOnly, sans cette condition, le workflow exécuteraitenvironment: Release 📎 .github/workflows/release.yml:21dans le dépôt fork. Bien que le dépôt fork n'ait pas de token npm et ne puisse pas réellement publier, cela gaspillerait des ressources de runner et pourrait produire des notifications d'échec trompeuses.ifprotège contre le risque de « publication automatique après push d'un tag » — il permet de configurer une approbation manuelle, garantissant que même si le tag est poussé, la publication nécessite la confirmation d'un mainteneur. Si l'on supprime la conditionenvironment, le fork gaspillerait des ressources ; si l'on supprime

Q3: size-report.yml, toute personne ayant la permission de pousser un tag pourrait déclencher une publication, sans étape de confirmation humaine finale. Les deux sont des défenses de niveaux différents et ne peuvent se substituer l'une à l'autre.if_no_artifact_found: warnDansrelease.yml, le choix deneeds: [test]et dans

, le choix de:if_no_artifact_found: warn 📎 .github/workflows/size-report.yml:69, quelle philosophie de conception de direction d'échec reflètent-ils respectivement ? Que se passerait-il si l'on intervertissait ces deux stratégies ?failAnalyse de référenceneeds: [test] 📎 .github/workflows/release.yml:15choisit « avertir plutôt qu'échouer en cas d'absence de données historiques », car le rapport de taille est une information auxiliaire, pas une condition bloquante. Si l'on remplaçait par

---

, alors une nouvelle branche ou une PR s'exécutant pour la première fois échouerait faute de données base, ce qui est manifestement déraisonnable.scripts/size-report.jschoisit « bloquer la publication en cas d'échec des tests », car la publication est une opération irréversible et la qualité du code doit être garantie. Si l'on intervertissait — size-report échouant en l'absence de données, release publiant malgré l'échec des tests — le premier provoquerait de nombreux faux positifs bloquant des PR normales, le second ferait entrer du code non testé dans npm. Cela illustre le principe de conception de direction d'échec « tolérant pour les informations auxiliaires, strict pour les opérations irréversibles ».usage-sizeLe chapitre suivant approfondira le cœur du mécanisme de budget de taille :

commentscripts/size-report.jsanalyse les données de taille, comment il calcule l'incrément, comment il formate la sortie, ainsi que la philosophie de mesure descripts/usage-size.js— pourquoi Vue choisit de mesurer la « taille réellement utilisée » plutôt que la « taille complète du paquet ».

Transformez n'importe quel code en un livre compréhensible

Vous avez aimé ce chapitre ? Créez un livre pour votre projet privé

Architecture local-first en Tauri 2 + Rust. Sécurité 100% hors ligne, zéro code téléversé. Lecture double panneau avec ancres de commits immuables.

⚡ Tauri 2 · Rust Core · 100% Hors ligne & Privé · Testé sur 1M+ lignes

CHAPTER 11

comment

Upstream: vuejs/core · Commit @4ab865a8 · Progression: Chapitre 11 sur 14

Dans le chapitre précédent, nous avons vu que Vue utilise GitHub Actions pour transformer le lint, la vérification de types, les tests et le suivi de taille en un pipeline incontournable, où size-report.yml et size-data.yml sont chargés de conserver les données de taille après chaque modification. Mais le pipeline ne fait qu'exécuter ; ce qui répond réellement à « de combien ça a grossi, et où ? », ce sont les deux scripts que ce chapitre va décomposer. La contradiction centrale du budget de taille réside dans le fait que la taille du bundle est un indicateur que l'on peut percevoir mais difficile à attribuer avec précision. Quand les utilisateurs se plaignent que « Vue est trop gros », les mainteneurs doivent répondre à trois questions — de combien ça a grossi ? où ? cette modification l'a-t-elle aggravé ? scripts/size-report.js est chargé de la comparaison, scripts/usage-size.js de l'attribution, et ensemble ils constituent la philosophie de mesure du budget de taille.

11.1 size-report : transformer les différences de taille en tableaux Markdown lisibles

Modèle intuitif

Imaginez que vous êtes un inspecteur qualité dans une entreprise de logistique. Chaque colis (artefact de build) doit être pesé avant de quitter l'entrepôt, et votre travail n'est pas la pesée elle-même, mais de placer « le poids d'aujourd'hui » et « le poids d'hier » côte à côte dans un tableau, en mettant en gras+2.3 kBpour signaler quels colis ont pris du poids. Sans ce tableau comparatif, les mainteneurs ne verraient qu'une série de chiffres isolés et ne pourraient pas déterminer si une PR a introduit une régression de taille.

size-report.jsest précisément cet inspecteur qualité. Il ne produit pas de données de taille (c'est le rôle deusage-size.jset des scripts de build), il consomme uniquement les fichiers JSON de deux répertoires et génère un rapport Markdown.

Structure des données et convention de répertoires

La convention centrale du script est cachée dans deux constantes. Le répertoire de données actuel esttemp/size, le répertoire de référence historique esttemp/size-prev。

📎 scripts/size-report.js:23-24

La dénomination de ces deux répertoires n'est pas arbitraire :temp/sizeest généré par le workflowsize-data.ymlà chaque exécution et téléversé comme artifact📎 .github/workflows/size-data.yml:53-57, tandis quetemp/size-prevest obtenu parsize-report.ymlaprès avoir récupéré et décompressé l'artifact de référence. Le nom du répertoire est lui-même le contrat du flux de données.

Le script définit trois alias de types qui décrivent précisément la structure des fichiers JSON :

📎 scripts/size-report.js:8-21

SizeResultpossède trois champs numériques :size(non compressé),gzip、brotli。BundleResulty ajoute le champfilepour afficher le nom du fichier.UsageResultest quant à lui unRecord, dont la clé est le nom du preset et la valeur unSizeResult & { name: string }— notez qu'il y a ici un champnamesupplémentaire, car les clés d'un objet JSON sont perdues aprèsObject.values, il faut donc stocker le nom de manière redondante dans la valeur.

Step-by-Step Walkthrough

Le flux principal est minimaliste : seulement deux étapes plus une sortie :

📎 scripts/size-report.js:23-38

run()on appelle d'abordrenderFiles()pour rendre le tableau des fichiers d'artefacts, puisrenderUsages()pour rendre le tableau des scénarios d'utilisation, et enfin on écrit en une seule fois dans stdout la chaîne accumulée dans la variable au niveau du moduleoutput. Ce modèle « accumuler la chaîne puis la sortir en une fois » évite les surcoûts de concaténation multiples de📎 scripts/size-report.js:25et rend l'ordre de sortie entièrement contrôlable.process.stdout.writePremière étape : collecter la liste des fichiers et en faire l'union.

On filtre deux types de fichiers : ceux commençant par

📎 scripts/size-report.js:44-49

filterFiles(comme_) et ceux se terminant par_usages.json(comme.txt). Ces deux types de fichiers sont des métadonnées, pas des données de taille. On prend ensuite l'unionnumber.txt、base.txtdes noms de fichiers du répertoire actuel et du répertoire historique — en utilisantfileListpour dédupliquer. Pourquoi prendre l'union ? Parce qu'un fichier peut n'exister que dans le répertoire historique (cet artefact a été supprimé lors de ce build), ou n'exister que dans le répertoire actuel (un nouvel artefact a été ajouté lors de ce build). Les deux cas doivent apparaître dans le rapport.SetDeuxième étape : comparaison fichier par fichier.

Pour chaque fichier de l'union, on tente d'importer le JSON depuis les deux répertoires respectivement.

📎 scripts/size-report.js:43-75

L'implémentation deimportJSONest « retourne undefined si le fichier n'existe pas » :

📎 scripts/size-report.js:112-115

On utilise ici unimport()dynamique avec une assertion d'importwith: { type: 'json' }, plutôt quefs.readFileSync + JSON.parse. Le premier est géré par le chargeur de modules de Node, le second nécessite de gérer manuellement l'encodage et les erreurs de parsing. Le coût de choisirimport()est qu'il retourne une Promise, donc toutrenderFilesest async.

La branche clé est dansif (!curr): si le fichier n'existe pas dans le répertoire actuel, cela signifie que l'artefact a été supprimé, on le marque avec la syntaxe barrée de Markdown~~fileName~~sur📎 scripts/size-report.js:60-61. Sinon on rend une ligne normale, en concaténant le résultat degetDiffaprès chaque valeur numérique.

Troisième étape : calculer les différences.

📎 scripts/size-report.js:124-130

getDiffpossède trois points de retour anticipé :prev === undefinedretourne une chaîne vide quand (pas de référence, impossible de comparer) ;diff === 0retourne une chaîne vide quand (pas de changement, on n'affiche pas de bruit) ; sinon retourne la différence signée en gras. Notez queprettyBytes(diff)gère correctement les nombres négatifs et produira une forme comme-1.2 kB, tandis que la variablesignn'ajoute le+。

que pour les positifs.

📎 scripts/size-report.js:80-103

renderUsagesQuatrième étape : rendre le tableau usage.renderFilesLa différence structurelle entre_usages.jsonetObject.values(curr)mérite attention : il importe directementprev?.[usage.name], car les données usage existent toujours dans ce seul fichier.nameconvertit le Record en tableau, puis recherche les données historiques par nom via.filter(usage => !!usage)— c'est précisément la raison du stockage redondant du champmap. Cette ligne

est en fait redondante, carmarkdown-tableretourne toujours un élément du tableau et ne produit jamais de valeur falsy.📎 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)"]

pour rendre le tableau à deux dimensions en tableau Markdown

Copier

Réflexions de conception et piègesimport()〔Inférence de conception et arbitrages architecturaux〕readFileSync?Pourquoi utiliserimport()plutôt que

filterFilesL'assertion d'import dynamiquefile[0] !== '_'pour JSON est la pratique standard de Node 20+, elle gère naturellement le chargement de JSON en environnement ESM. Le coût est qu'elle ne peut pas être utilisée dans un contexte synchrone, et que chaque import est mis en cache par le module — mais dans ce script à usage unique, le cache n'est pas un problème.Le jugementreaddirdefile[0]. Ce jugement suppose que le nom de fichier n'est pas vide. Siundefined,undefined !== '_'retourne une chaîne vide (théoriquement impossible),

Traitement des artefacts supprimés.Lorsqu'un artefact est supprimé, le rapport le marque d'un trait de suppression plutôt que de le retirer directement. C'est un choix de conception délibéré : les mainteneurs doivent voir que « ce fichier a disparu », et non le laisser s'évanouir silencieusement du tableau. S'il était simplement filtré, le lecteur pourrait croire à tort que cet artefact n'a jamais existé.

11.2 usage-size : simuler le scénario d'importation d'un utilisateur réel

Modèle intuitif

size-reportVous indique « quelle est la taille du paquet complet », mais cela ne répond pas à la question qui intéresse vraiment l'utilisateur : « Si je n'utilise quecreateApp, combien de code dois-je réellement télécharger ? » Le volume du paquet complet contient une grande quantité de code que vous n'utiliserez peut-être jamais (commedefineCustomElement、Transition、KeepAlive)。usage-size.jsLe rôle de est de jouer un « utilisateur typique » : écrire un fichier d'entrée virtuel qui n'importe qu'une API spécifique, le bundler avec Rollup, et observer la taille du produit final.

C'est comme un restaurant qui ne vous dit pas « le poids total de tous les ingrédients dans la cuisine est de 50 kg », mais qui vous dit « pour une portion de poulet Kung Pao, les ingrédients réellement utilisés pèsent 300 grammes ».

Structure de données : tableau de Presets

La structure de données centrale du script estpresetstableau, chaque élément décrivant un scénario d'utilisation :

📎 scripts/usage-size.js:27-55

PresetLe type possède trois champs :name(nom affiché),imports(liste des API importées depuis Vue), et optionnellementreplace(substitutions supplémentaires à la compilation). Cinq presets couvrent les scénarios d'utilisation du plus petit au plus grand :

  • createApp (CAPI only): importer uniquementcreateApp, et remplacer__VUE_OPTIONS_API__par'false', simulant un utilisateur de l'API Composition pure📎 scripts/usage-size.js:35-40
  • createApp: importer uniquementcreateApp, conserver l'Options API📎 scripts/usage-size.js:35-40
  • createSSRApp: scénario SSR📎 scripts/usage-size.js:35-40
  • defineCustomElement: scénario Web Components📎 scripts/usage-size.js:35-40
  • overall: importer six API principales, simulant un utilisateur « tout-en-un »📎 scripts/usage-size.js:44-54

Le fichier d'entrée est fixé au produit esm-bundler en runtime-only :

📎 scripts/usage-size.js:24-28

Choisirvue.runtime.esm-bundler.jsplutôt que la version complètevue.esm-bundler.js, car la version runtime ne contient pas le compilateur de templates, ce qui est plus proche de la situation réelle des utilisateurs d'outils de build modernes — ils utilisent des templates précompilés par SFC et n'ont pas besoin du compilateur runtime.

Step-by-Step Walkthrough

Première étape : générer en parallèle les bundles de tous les presets.

📎 scripts/usage-size.js:62-69

main()Pour chaque preset, créergenerateBundlede Promise, exécutées en parallèle avecPromise.all. La parallélisation est sûre ici, car chaquegenerateBundleappelle unrollup()indépendant, sans état partagé.

Deuxième étape : construire l'entrée virtuelle.

📎 scripts/usage-size.js:94-96

C'est la partie la plus ingénieuse de tout le script. Il n'écrit pas de fichier temporaire sur le disque, mais construit un ID de module virtuelvirtual:entry, dont le contenu est une instruction re-export :export { createApp } from '/absolute/path/to/vue.runtime.esm-bundler.js'. Notez queentryest un chemin absolu, car Rollup doit pouvoir le résoudre.

Troisième étape : configurer la chaîne de plugins Rollup.

📎 scripts/usage-size.js:98-121

L'ordre du tableau de plugins est crucial :

1. Personnaliséusage-size-plugin:resolveIdinterceptevirtual:entryretourne lui-même,loadretourne le contenu virtuel📎 scripts/usage-size.js:101-110. C'est le modèle standard des modules virtuels de Rollup.

2. nodeResolve(): résoudrevue.runtime.esm-bundler.jsles imports internes📎 scripts/usage-size.js:111。

3. replace: injecter les constantes de compilation📎 scripts/usage-size.js:112-119。

replaceLa configuration du plugin révèle le mécanisme central du produit esm-bundler : il conserve__VUE_OPTIONS_API__、__VUE_PROD_DEVTOOLS__et autres indicateurs runtime, remplacés par l'outil de build de l'utilisateur. Ici, le script effectue le remplacement à la place de l'utilisateur :

  • process.env.NODE_ENV → "production": prendre la branche de production
  • __VUE_PROD_DEVTOOLS__ → 'false': désactiver le support devtools
  • __VUE_PROD_HYDRATION_MISMATCH_DETAILS__ → 'false': désactiver les messages d'erreur détaillés d'hydratation
  • __VUE_OPTIONS_API__ → 'true': conserver l'Options API par défaut

Puis étendre...preset.replace, permettant au preset de remplacer les valeurs par défaut.createApp (CAPI only)Le preset utilise précisément ce mécanisme pour remplacer__VUE_OPTIONS_API__par'false' 📎 scripts/usage-size.js:35-40。

preventAssignment: trueEmpêcher le remplacementobj.process.env.NODE_ENV = xde ce type d'instruction d'affectation📎 scripts/usage-size.js:117。

Quatrième étape : générer, compresser, mesurer.

📎 scripts/usage-size.js:123-134

result.generate({})produit le code, prendreoutput[0].code. Puis compresser avec SWC :

📎 scripts/usage-size.js:125-130

module: trueindique que l'entrée est ESM,toplevel: truepermet de compresser les noms de variables de portée supérieure. Après compression, calculer respectivement trois métriques :minified.length(longueur en octets),gzipSync(minified).length、brotliCompressSync(minified).length。

Notez qu'ici on utilisenode:zlibl'API synchrone, et non la version asynchrone. Dans un script à usage unique, l'API synchrone est plus concise, et la compression elle-même étant une opération intensive en CPU, l'asynchrone n'apporterait pas de gain de parallélisme.

Cinquième étape : sortie et persistance.

📎 scripts/usage-size.js:62-86

Les résultats sont d'abord imprimés sur la console dans un format lisible par l'homme, avecpicocoloration📎 scripts/usage-size.js:62-86. Puis écrits danstemp/size/_usages.json, avecObject.fromEntriespour reconvertir le tableau en Record, la clé étant le nom du preset📎 scripts/usage-size.js:81-85。

--writeL'indicateur contrôle si l'on écrit en plus le bundle non compressé de chaque preset sur le disque📎 scripts/usage-size.js:136-138, pour le débogage.

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

Réflexions de conception et pièges rencontrés

〔Inférences de conception et compromis architecturaux〕

Pourquoi utiliser des modules virtuels plutôt que des fichiers temporaires ?Les fichiers temporaires nécessitent de gérer les chemins, le nettoyage, les conflits d'écriture concurrente. Les modules virtuels gardent le contenu de l'entrée en mémoire, et leresolveId/loadhook de Rollup supporte naturellement ce modèle. Le coût est qu'il faut faire correspondre l'ID avec précision, toute faute de frappe provoquant une erreur Rollup « impossible de résoudre l'entrée ».

replaceLepreventAssignmentpiège deSi l'on ne définit paspreventAssignment: true,replace, le plugin effectuera aussi le remplacement surprocess.env.NODE_ENV = 'x'ce type d'instruction d'affectation, produisant"production" = 'x'une erreur de syntaxe. Le code source de Vue contient effectivement des affectations àprocess.env.NODE_ENV(dans les outils de test), donc cette option est nécessaire.

__VUE_OPTIONS_API__Choix de la valeur par défaut deLe script définit la valeur par défaut à'true' 📎 scripts/usage-size.js:116, plutôt que'false'. C'est un choix conservateur : si l'utilisateur ne configure pas, Vue conservera le support de l'Options API.createApp (CAPI only)Le preset remplace explicitement par'false', montrant le gain de taille après désactivation. Cette comparaison est en elle-même une documentation pour l'utilisateur : lui dire « combien on économise en désactivant l'Options API ».

ParallèlePromise.allSémantique d'échec deSi le bundling de n'importe quel preset échoue,Promise.allrejette immédiatement, les autres empaquetages en cours ne seront pas annulés (Rollup ne fournit pas de mécanisme d'annulation). Dans CI, cela signifie qu'un échec gaspille le calcul des autres presets, mais le script lui-même se termine avec un code de sortie non nul, que CI peut correctement capturer.

11.3 Des données au contrôle qualité : comment CI consomme ces rapports

Vue d'ensemble du flux de données

Pour comprendre ces deux scripts, il faut les replacer dans le pipeline CI.size-data.ymls'exécute lors d'un push vers main/minor ou d'une PRpnpm run size 📎 .github/workflows/size-data.yml:45, produittemp/sizele répertoire, puis le téléverse comme artifact📎 .github/workflows/size-data.yml:53-57。

Pour les PR, il écrit en plus deux fichiers de métadonnées :

📎 .github/workflows/size-data.yml:47-51

number.txtstocke le numéro de PR,base.txtstocke le nom de la branche cible. Ces deux fichiers sont exactementsize-report.jsdansfilterFilesà filtrer.txtles fichiers📎 scripts/size-report.js:44-45. Ils existent pour que lesize-report.ymlen aval sache « avec quelle base comparer ».

Obtention et comparaison de la base

size-report.yml(détaillé dans le chapitre précédent) le workflow est : télécharger lesize-dataartifact de la PR actuelle, télécharger l'artifact de base de la branche cible, décompresser la base danstemp/size-prev, puis exécutersize-report.jspour générer un rapport Markdown et commenter sur la PR.

Il y a ici une contrainte de conception clé :size-report.jslui-même n'est pas responsable de l'obtention de la base, il suppose quetemp/size-prevexiste déjà. S'il n'existe pas,existsSync(prevDir)retourne false,prevest un tableau vide📎 scripts/size-report.js:48, tous les diffs sont des chaînes vides. C'est une dégradation gracieuse : sans base, le rapport est quand même généré, il n'affiche simplement pas les différences.

Logique de décision du contrôle de taille

〔Inférence de conception et compromis architecturaux〕

Il faut clarifier un malentendu courant :size-report.jslui-même ne fait pas de jugement de contrôle qualité. Il génère seulement un rapport, ne retourne pas de code de sortie, ne définit pas de seuil. Le véritable contrôle qualité se produit au niveau dusize-report.ymlworkflow — il peut contenir une étape qui analyse les valeurs de diff dans le rapport et fait échouer le job si le seuil est dépassé.

Cette conception de « séparation mesure/jugement » a des raisons profondes : le script de mesure doit rester pur, ne produire que des faits ; la logique de jugement doit être au niveau du workflow, car les seuils peuvent varier selon la version, la branche, la phase de release. Coder en dur les seuils danssize-report.jsle rendrait difficile à réutiliser.

Réflexions de conception

Pourquoi le budget de taille nécessite-t-il deux ensembles de mesures ?La taille complète du bundle et la taille usage répondent à des questions différentes. La taille complète est la « limite supérieure » — elle vous dit combien l'utilisateur doit télécharger dans le pire des cas. La taille usage est la « valeur typique » — elle vous dit combien la plupart des utilisateurs téléchargent réellement. Les deux combinées donnent un portrait complet de la taille. Si on n'avait que la taille complète, les mainteneurs auraient tendance à sur-optimiser les API peu utilisées ; si on n'avait que la taille usage, on pourrait ignorer l'explosion de taille de certains cas limites.

Signification des doubles métriques gzip et brotli.Les CDN modernes supportent généralement brotli, mais pas dans tous les scénarios. Rapporter les deux permet aux mainteneurs d'évaluer « quelle est la taille dans un environnement qui ne supporte que gzip ». brotli est généralement 15-20% plus petit que gzip, cet écart est en soi une information précieuse.

Contrat de stabilité du format de données. size-report.jsetusage-size.jssont découplés via des fichiers JSON.usage-size.jsécrit_usages.json,size-report.jsle lit. Les noms de champs de ce contrat (name、size、gzip、brotli) sont implicites, sans validation de schéma. Siusage-size.jschange un nom de champ et oublie de synchronisersize-report.js, le rapport affichera silencieusement des données erronées. C'est le point fragile de la conception actuelle.

Résumé de ce chapitre

Réflexions et auto-évaluation de ce chapitre

Q1: size-report.jslefilterFilesfiltre les fichiers commençant par_. Siusage-size.jsrenomme le fichier de sortie de_usages.jsonenusages.json, que se passe-t-il ?

Analyse de référence:filterFilesla condition de filtrage estfile[0] !== '_' && !file.endsWith('.txt') 📎 scripts/size-report.js:44-45. Si le fichier est renommé enusages.json, il ne commence plus par_, serafilterFilesconservé, entre dansfileListl'union. PuisrenderFilestentera de le traiter comme un fichier bundle :importJSONpeut l'importer avec succès (c'est du JSON valide), mais sa structure estRecord<string, UsageResult>et nonBundleResult, donccurr?.fileestundefined,fileNameune chaîne vide,curr.sizeaussiundefined,prettyBytes(undefined)lancera une erreur ou produira une sortie anormale. Cela entraînera l'échec de la génération du rapport. La racine du problème est quefilterFilesutilise le préfixe du nom de fichier comme critère de distinction « métadonnées vs données », plutôt que la structure de répertoires ou un manifeste explicite. Une approche plus robuste serait de placer les données usage dans un sous-répertoire, ou de maintenir une liste explicite de fichiers de métadonnées.

Q2: usage-size.jsdansPromise.all(tasks)exécute en parallèle l'empaquetage de tous les presets. Si lareplaceconfiguration d'un preset omet__VUE_OPTIONS_API__, que se passe-t-il ? Pourquoi la valeur par défaut est-elle'true'et non'false'?

Analyse de référence:replaceDans la configuration du plugin,__VUE_OPTIONS_API__: 'true'est la valeur par défaut, puis l'expansion de...preset.replacepermet de remplacer📎 scripts/usage-size.js:116-118. Si un preset omet la configuration, il utilisera la valeur par défaut'true', c'est-à-dire conserve le support de l'Options API, la taille sera plus grande. La valeur par défaut'true'est un choix conservateur : elle reflète « le comportement réel quand l'utilisateur ne configure pas ». Dans les artefacts esm-bundler de Vue,__VUE_OPTIONS_API__le comportement par défaut est de conserver l'Options API (sauf si l'utilisateur le désactive explicitement). Si la valeur par défaut était'false', tous les presets non explicitement configurés afficheraient une taille plus petite, induisant l'utilisateur en erreur en lui faisant croire que « ne pas configurer permet d'économiser de la taille ».createApp (CAPI only)le preset est explicitement défini sur'false' 📎 scripts/usage-size.js:35-40, précisément pour montrer « le gain après désactivation explicite », en contraste avec la valeur par défaut.

Q3: size-report.jsleimportJSONutilise unimport()dynamique plutôt quefs.readFileSync. Sitemp/size-prevun fichier JSON dans le répertoire est corrompu (JSON invalide), quelle est la différence de comportement entre les deux implémentations ?

Analyse de référence: leimport()dynamique lanceraSyntaxErrorlors de l'analyse d'un JSON invalide, et cette erreur ne peut pas être capturée parimportJSONla vérificationexistsSyncinterne —existsSyncVérifie uniquement si le fichier existe, sans contrôler la validité du contenu📎 scripts/size-report.js:112-115. L'erreur se propagera vers le haut jusqu'àrenderFiles, entraînant l'échec de la génération du rapport entier. Si on utilisefs.readFileSync + JSON.parse, une erreur sera également levée, mais on peut l'encapsuler dans un try-catch à l'intérieur deimportJSON, et retournerundefinedpour réaliser une dégradation gracieuse. L'implémentation actuelle choisit de laisser l'erreur se propager, avec l'hypothèse implicite que « le JSON dans l'artifact est nécessairement valide » — cette hypothèse est généralement vérifiée dans un environnement CI, car les fichiers sont générés parusage-size.jset les scripts de build. Mais en débogage local, si le fichier JSON est modifié manuellement et corrompu, le rapport plantera directement au lieu d'ignorer ce fichier. C'est un choix de conception « faire confiance à la source de données ».

---

Le mécanisme de budget de taille résout les questions « quoi mesurer » et « comment comparer », mais il repose sur un prérequis : les artefacts de build eux-mêmes sont reproductibles. Le chapitre suivant abordera le bac à sable de débogage minimal :vite-debugcomment démarrer un environnement de développement Vue interactif avec un minimum de configuration, et comment il s'articule avec les artefacts de build locaux pour former une boucle fermée allant de la modification du code source à la validation à l'exécution.

À ce stade, la boucle de mesure du budget de taille est claire : size-report.js répond à « de combien c'est plus gros » par comparaison de répertoires, usage-size.js répond à « où c'est plus gros » en simulant des scénarios d'import réels via des modules virtuels, et la décision de seuil est laissée à la couche workflow. Ce mécanisme transforme la régression de taille d'une plainte vague en données traçables. Mais les données ne peuvent que vous dire que le problème existe ; pour vraiment localiser et corriger, il faut un environnement minimal capable de reproduire rapidement le problème. Le chapitre suivant abordera packages-private/vite-debug, pour voir comment Vue construit un bac à sable de débogage minimal avec Vite + SFC, transformant « faire une reproduction minimale sur le code source réel » en une pratique quotidienne opérationnelle.

Transformez n'importe quel code en un livre compréhensible

Vous avez aimé ce chapitre ? Créez un livre pour votre projet privé

Architecture local-first en Tauri 2 + Rust. Sécurité 100% hors ligne, zéro code téléversé. Lecture double panneau avec ancres de commits immuables.

⚡ Tauri 2 · Rust Core · 100% Hors ligne & Privé · Testé sur 1M+ lignes

CHAPTER 12

Chapitre 12 : Bac à sable de débogage minimal : vite-debug et boucle de développement local

Upstream: vuejs/core · Commit @4ab865a8 · Progression: Chapitre 12 sur 14

Dans le chapitre précédent, nous avons complété la boucle de mesure du budget de taille : size-report.js répond à « de combien c'est plus gros », usage-size.js répond à « où c'est plus gros », et la couche workflow est responsable de la décision de seuil. Mais ce mécanisme a un prérequis implicite — les artefacts de build eux-mêmes sont reproductibles. Lorsque vous découvrez qu'un package a une taille anormalement gonflée, ou qu'un comportement à l'exécution ne correspond pas aux attentes, vous avez besoin d'un environnement minimal capable de charger rapidement le code source local et de voir immédiatement l'effet après modification. packages-private/vite-debug est cet environnement. Il ne contient que quatre fichiers, totalisant moins de 40 lignes de code, mais il constitue le point d'entrée pratique quotidien pour « faire une reproduction minimale sur le code source réel » dans le dépôt Vue core. Ce chapitre décomposera fichier par fichier la logique de construction de ce bac à sable, et expliquera pourquoi il est placé dans packages-private plutôt que dans le répertoire packages.

I. Le squelette du bac à sable :main.tsetApp.vuela chaîne de montage minimale

Modèle intuitif

Si l'on compare tout le runtime Vue à un moteur, alorsvite-debugest un « banc d'essai à nu » — sans carrosserie, sans tableau de bord, avec juste le câblage minimal pour faire tourner le moteur. Sa valeur ne réside pas dans la complétude fonctionnelle, mais dansl'élimination de toutes les variables parasites: lorsque vous soupçonnez qu'un bug se trouve dans le système de réactivité ou à l'intérieur du renderer, vous ne voulez pas que la complexité de l'environnement de débogage lui-même devienne une source de bruit.

Structures de données et disposition des fichiers

Regardons d'abordmain.tstout le contenu :

📎 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')

Ces six lignes de code sont le paradigme standard de démarrage d'une application Vue, mais chaque ligne a une signification d'ingénierie précise dans un contexte de débogage :

  • L1dans leimport { createApp } from 'vue'de'vue', ce que l'identifiant de modulevite.config.tsrésout finalement dépend entièrement des déclarations de dépendances depackage.jsonet
  • L2. C'est le maillon le plus critique de tout le bac à sable — nous verrons plus tard comment il est redirigé vers le code source local.import App from './App.vue'le@vitejs/plugin-vuedeApp.vuedéclenche le pipeline de compilation SFC de<script>、<template>、<style>: Vite enregistre ce plugin au démarrage du dev server, et lorsque le navigateur demande
  • L4, le plugin le décompose encreateApp(App)trois modules virtuels compilés séparément.app._context、app._instancele
  • L6deapp.mount('#app')crée l'instance d'application ; à ce moment Vue initialise en interneappet d'autres champs principaux, mais ne déclenche encore aucun rendu.

leindex.htmldeindex.htmlest le véritable interrupteur de démarrage : il recherche dans le DOM l'élément conteneur avec l'id<div id="app"></div>, crée l'instance du composant racine, et déclenche le premier rendu.<script type="module" src="/main.ts"></script>Notez qu'il n'y a pas ici de référence àapp.mount('#app')— la convention de Vite est que le

à la racine du projet sert de HTML d'entrée, contenant

etApp.vue. Bien que ce fichier ne figure pas dans les keyFiles de ce chapitre, il est le prérequis au succès de

📎 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>

Walkthrough guidé par scénario : la chaîne complète d'un clicRegardons maintenant

, c'est le « support d'expérimentation » de ce bac à sable :

@vitejs/plugin-vueCopierApp.vuePlaçons-nous dans un scénario concret :

  • <script setup>Que se passe-t-il lorsque l'utilisateur clique sur le bouton dans le navigateur ?setup()Première étape : phase de compilation SFC (au démarrage du dev server)ref(0)compileRefImplen trois parties :.valuele bloc0。
  • <template>est compilé en fonction{{ count }}du composant,_toDisplayString(count.value),@click="count++"l'appel retourne un objetonClick: $event => (count.value++)。
  • <style>dont le<style>est initialement

le blocapp.mountest compilé en fonction de rendu,

createApp(App)est converti enmount('#app'), le composant racine est crééComponentInternalInstance, exécutesetup()pour obtenircountle RefImpl de, puis appelle la fonction de rendu pour générer l'arbre VNode. Dans la fonction de rendu, la lecture decount.valuedéclenchetrackla collecte de dépendances — l'effet de rendu actif actuel (ReactiveEffect) est enregistré danscountledepde.

Troisième étape : événement de clic (lors de l'interaction utilisateur)

Le navigateur déclencheclickl'événement, le gestionnaire d'événements de Vue exécutecount.value++. Il s'agit d'une opération setter, qui déclenchetrigger: parcourtcount.deples effets collectés dans, planifie un nouveau rendu. Comme il s'agit d'une mise à jour synchrone et qu'elle ne se trouve pas dans la file de traitement par lots, l'effet de rendu est exécuté immédiatement, la fonction de rendu est rappelée, un nouveau VNode est généré, un diff est effectué avec l'ancien VNode, et il est découvert que le contenu textuel passe de0à1, mettant à jour letextContent。

du DOM réel. L'ensemble du chaînage peut être représenté par le diagramme de flux de données suivant :

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

Le point clé de ce diagramme est le suivant :Il n'y a que deux points de couplage entre les artefacts de compilation et le comportement d'exécution——ref(0)l'objet RefImpl retourné par, ainsi que la lecture et l'écriture decount.valuedans la fonction de rendu. Cela signifie que si vous souhaitez déboguer une branche spécifique du système de réactivité (par exempletriggerla logique de planification dans), il vous suffit de construire le modèle de lecture-écriture correspondant dans ceApp.vue.

Réflexion de conception : pourquoirefplutôt quereactive?

〔Inférence de conception et compromis architecturaux〕

Choisirref(0)plutôt quereactive({ count: 0 })comme exemple par défaut implique une considération de priorité au débogage :refle.valuechemin d'accès de est plus court, lors de l'expansion de l'objetRefImpldans le débogueur, on peut voir directement_value、dep、__v_isRefles champs internes tels que, tandis que l'expansion de l'objet Proxy retourné parreactivedans la console déclenche le getter, ce qui peut interférer avec l'observation de l'état d'origine. Pour le scénario de « reproduction minimale », réduire une couche d'indirection Proxy signifie moins de variables.

---

Deuxièmement, résolution d'alias :vite.config.tsetpackage.jsoncomment faire pointer'vue'vers le code source local

Modèle intuitif

vite.config.tsne comporte que six lignes, mais c'est le « centre de routage » de tout le bac à sable — il détermine si leimport { createApp } from 'vue'dans'vue'charge finalement la version publiée sur npm ou le code source en cours de développement dans le dépôt. Sans une configuration d'alias correcte, le code que vous modifiez dansApp.vuepourrait ne pas du tout déclencher la source Vue que vous déboguez, et le débogage devient « tirer sur la mauvaise cible ».

Structure de données et chaîne de résolution

Regardons d'abordvite.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()],
})

Icin'a pas de configurationresolve.aliasexplicite. Alors comment'vue'est-il résolu vers le code source local ? La réponse se trouve danspackage.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 clé estL13:"vue": "workspace:*". Il s'agit de la déclaration du protocole pnpm workspace, indiquant quevite-debugdépend du package local nommévuedans le monorepo, et non de la version sur le registre npm. pnpm créera un lien symbolique dansnode_modules/vue, pointant verspackages/vue(le répertoire du package principal de Vue).

Mais cela ne suffit pas —packages/vuelepackage.jsondansmain/module/exportsle champpointe généralement versles artefacts de builddist/vue.runtime.esm-bundler.js(commesrc/), et non vers le code source souspackages/runtime-core/src/renderer.ts. Si vous modifiezdist, mais sans reconstruire, Vite chargera toujours l'ancien fichier

.

〔Inférence de conception et compromis architecturaux〕packages/vue/package.jsonC'est pourquoi le"development"de Vue core configure généralementresolve.conditionsdes exports conditionnels ou un mappage d'entrée source similaire — en mode dev, ledevelopmentde Vite correspond en priorité à la conditionsrc/index.ts, chargeant ainsidistplutôt quevite-debug. Ce mécanisme permet à

de voir immédiatement les effets via HMR après modification du code source, sans configuration explicite d'alias.import 'vue'Parcours guidé par scénario : un processus de résolution de

Mise en situation :Lorsque le serveur de développement Vite reçoit une requête du navigateur pourmain.ts, et rencontreimport { createApp } from 'vue', quelle est la chaîne de résolution ?

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

Ce diagramme de flux révèle une branche critique :Si la conditiondevelopmentn'est pas correctement configurée, le navigateur ne fera pas de hot update après modification du code source, et vous vous retrouverez dans la confusion « j'ai modifié le code mais le comportement n'a pas changé ». La méthode de diagnostic consiste à vérifier le chemin de chargement réel du modulevuedans le panneau Network des DevTools du navigateur — si vous voyez le chemindist/, cela signifie que le mappage d'entrée source n'a pas pris effet.

Réflexion de conception : pourquoi ne pas écrire explicitement l'alias dansvite.config.ts?

〔Inférence de conception et compromis architecturaux〕

Une question naturelle est : pourquoi ne pas écrire directementvite.config.tsdansresolve: { alias: { vue: '../../packages/vue/src/index.ts' } }? Bien que cela soit intuitif, cela pose deux problèmes :

1. Cela casse les imports de sous-chemins: l'API publique de Vue inclutvue/server-renderer、vue/compiler-sfcdes sous-chemins tels que. Si seul'vue'lui-même est aliasé, les imports de sous-chemins passeront toujours pardist, entraînant une incohérence de comportement où certains modules proviennent du code source et d'autres des artefacts.

2. Cela contourne le mécanisme d'exports conditionnels: lepackage.jsonde Vueexportsle champdevelopment/production/browser/nodedéfinit déjà un mappage complet d'exports conditionnels (

etc.), et l'alias écraserait ce mécanisme, créant un écart de comportement de résolution entre l'environnement de débogage et l'environnement utilisateur réel.vite-debugPar conséquent,package.jsonchoisit la combinaison « faire confiance au protocole workspace + exports conditionnels », rendant la chaîne de résolution aussi proche que possible du scénario d'utilisation réel. Cela explique aussi pourquoi"vue": "workspace:*"lenode_modules/vuedanspackages/vueest nécessaire — c'est la condition préalable pour déclencher le lien symbolique pnpm, permettant ensuite à Vite de trouver

viacatalog:.

Pièges en production :package.jsonprotocole et dérive de versionL11-L12Attention"catalog:"dans

json
"@vitejs/plugin-vue": "catalog:",
"vite": "catalog:",

:pnpm-workspace.yamlCopiercatalogIl s'agit de la fonctionnalité catalog de pnpm, indiquant que le numéro de version est géré de manière unifiée par le champdans。

. Son rôle est d'éviter la dérive de version lorsque plusieurs packages du monorepo référencent la même dépendance

〔Inférence de conception et compromis architecturaux〕vite-debugSi vous rencontrez un bug suspecté de Vite ou de plugin-vue, et que vous souhaitez vérifier en mettant à jour temporairement la version, modifier directementpackage.jsondanscatalog:est inefficace — vous devez modifierpnpm-workspace.yamlla définition du catalog dans"vite": "5.0.0"), puis revenir àcatalog:。

---

après vérification. Troisièmement,packages-privatela conception d'isolation de

Modèle intuitif

packages-privateLe répertoire est comme le « laboratoire interne » de l'entreprise — les échantillons qu'il contient ne sont pas vendus à l'extérieur, ils servent uniquement aux tests et aux démonstrations. Il est physiquement isolé du répertoirepackagesafin d'éviter que le code de débogage ne soit publié par erreur sur npm.

Triple garantie du mécanisme d'isolation

Première couche : isolation par répertoire

packages-private/vite-debugn'est pas souspackages/, tandis quepnpm-workspace.yamldéclare généralementpackages/*etpackages-private/*comme membres du workspace, mais le script de publication (commescripts/release.js) ne parcourt que les packages souspackages/.

Deuxième couche :private: true

📎 packages-private/vite-debug/package.json:3

json
"private": true,

Cette ligne est une contrainte stricte de npm/pnpm : un package marqué commeprivatene pourrajamais être publié parnpm publish,même une exécution manuelle sera refusée. C'est la dernière ligne de défense contre les publications accidentelles.

Troisième couche : absence du champversion

Notez quepackage.jsonne contient pas le champversion. La spécification npm exige qu'un package publiable possèdeversion; un package dépourvu de ce champ provoquera une erreur lors denpm publish. C'est une « double assurance » — même siprivateest supprimé par erreur, l'absence deversionempêchera toujours la publication.

Réflexion de conception : la répartition des rôles entre le bac à sable de débogage et le Playground

Le dépôt Vue core contient déjà unSFC Playgroundcomplet (discuté au chapitre 7), pourquoi avoir besoin devite-debug?

〔Inférence de conception et compromis architecturaux〕

Leurs positionnements sont radicalement différents :

DimensionSFC Playgroundvite-debug
Environnement d'exécutionDans le navigateur (la compilation aussi dans le navigateur)Node.js + navigateur
Chargement du code sourceVia CDN ou artefacts précompilésChargement direct du code source local
Capacité de débogageLimitée par le bac à sable du navigateurUtilisation du débogueur Node.js, points d'arrêt
Modification du code sourceNon pris en chargePrise en charge du HMR
Cas d'usageVérifier la sortie de compilation, partager une reproductionDéboguer le comportement interne à l'exécution

vite-debugLa valeur principale deréside dans le fait qu'il s'exécute dans un véritable environnement Node.js, vous pouvez utilisernode --inspectpour attacher un débogueur, poser un point d'arrêt danspackages/reactivity/src/effect.ts, et observer le processus de création et d'ordonnancement deReactiveEffect. C'est ce que le Playground ne peut pas offrir.

Pièges en production : limites du HMR et perte d'état

〔Inférence de conception et compromis architecturaux〕

Lors de l'utilisation devite-debugpour le débogage, une confusion fréquente est la suivante : après avoir modifié la valeur initiale deApp.vuedanscount, le compteur dans le navigateur ne se réinitialise pas. Cela est dû au fait que le HMR de Vite traite les blocs<script setup>enpréservant l'état du composant et ne remplaçant que la fonction de rendu. Si vous avez besoin de réinitialiser complètement l'état, vous devez actualiser manuellement la page, ou ajouterApp.vuedansimport.meta.hot?.invalidate()pour forcer un rechargement complet de la page.

Un autre piège : lorsque vous modifiez le code source souspackages/runtime-core/src/, la chaîne de propagation du HMR peut ne pas se déclencher automatiquement — car la frontière HMR devite-debugest définie au niveau deApp.vue, et les modifications du code source souspackages/doivent se propager via le graphe de modules de Vite. Si vous constatez qu'après modification du code source le navigateur ne réagit pas, vérifiez si la sortie du terminal Vite contient un journalhmr update; sinon, il peut être nécessaire de redémarrer le serveur de développement.

---

Résumé de ce chapitre

packages-private/vite-debugAvec quatre fichiers et moins de 40 lignes de code, un cycle de débogage complet est construit :

1. main.tsFournit une chaîne de montage minimale :createApp(App).mount('#app'), en excluant toute logique d'initialisation non nécessaire.

2. App.vueComme support d'expérimentation :ref+ interpolation de template + gestion d'événements, couvrant le chemin principal du système de réactivité.

3. vite.config.ts + package.jsonVia le protocoleworkspace:*et les exports conditionnels,'vue'est résolu vers le code source local, réalisant « modifier le code source, effet immédiat ».

4. packages-private + private: true+ sansversionisolation à trois niveaux, garantissant que le code de débogage ne sera pas publié par erreur.

La philosophie d'ingénierie de ce bac à sable est la suivante :la complexité de l'environnement de débogage lui-même doit tendre vers zéro, en laissant toute la complexité au code source débogué. Lorsque vous rencontrez danspackages/reactivityun bug difficile à reproduire,vite-debugoffre une plateforme d'expérimentation que vous pouvez modifier à volonté et vérifier immédiatement.

Réflexions et auto-évaluation de ce chapitre

Q1 : Si vous remplacezpackage.jsondans"vue": "workspace:*"par"vue": "^3.4.0", après avoir modifiévite-debugdanspackages/reactivity/src/ref.ts, quel sera le changement de comportement dans le navigateur ? Pourquoi ?

Analyse de référence: après avoir remplacé par"^3.4.0", pnpm téléchargera depuis le registre npm la version publiée de Vue 3.4.x, au lieu de créer un lien vers lepackages/vue 📎 packages-private/vite-debug/package.json:13local. À ce moment-là,import { createApp } from 'vue'est résolu versnode_modules/.pnpm/vue@3.4.x/node_modules/vue/dist/vue.runtime.esm-bundler.js, c'est-à-dire l'artefact précompilé. Modifierpackages/reactivity/src/ref.tsne déclenchera aucun HMR, car le graphe de modules de Vite n'inclut pas du tout ce fichier. Ce qui s'exécute dans le navigateur reste l'implémentation derefde la version npm. Cette expérience valide a contrario queworkspace:*est une condition nécessaire au débogage au niveau du code source.

Q2: App.vueLe bloc<style>n'a pas ajoutéscoped; si deux instances de composant sont montées simultanément dans ce bac à sable, que se passera-t-il au niveau des styles ? Quel est le rapport avec l'objectif de débogage devite-debug?

Analyse de référence: sansscoped,button { color: red }est un style global📎 packages-private/vite-debug/App.vue:4-8, qui s'appliquera à tous les éléments<button>de la page. Si deux instances de composant sont montées, les boutons des deux instances deviendront rouges. Le rapport avec l'objectif de débogage réside dans le fait que :vite-debuga pour positionnement la « reproduction minimale », et non la « vérification de l'isolation des styles ». Omettrescopedréduit les variables d'injection de l'attributdata-v-xxxau moment de la compilation, rendant la structure DOM dans le débogueur plus propre. Si vous avez besoin de déboguer la logique de compilation des stylesscoped, vous devriez ajouter explicitementscopedet observer le code d'injection d'attribut généré par@vitejs/plugin-vue.

Q3 : Supposons que vous ayez ajouté une lignepackages/runtime-core/src/renderer.tsdans la fonctionpatchdeconsole.log, mais que la console du navigateur n'affiche rien. Listez au moins trois causes possibles et expliquez comment les vérifier une par une.

Analyse de référence:

Cause un :le point d'entrée du code source n'est pas effectif。'vue'est résolu vers l'artefactdistplutôt quesrc. Diagnostic : dans le panneau Network des DevTools, vérifiez levuechemin de chargement du module ; s'il commence pardist/, cela signifie que l'export conditionnel n'a pas été atteintdevelopmentcondition📎 packages-private/vite-debug/package.json:13。

Cause 2 :HMR non propagé. Le graphe de modules de Vite n'a pas propagé les modifications depackages/runtime-core/src/renderer.tsversvite-debug. Diagnostic : vérifiez si le terminal Vite affiche des logshmr update; sinon, redémarrez le dev server.

Cause 3 :patchfonction non appelée. Si la page actuelle ne déclenche aucune mise à jour du DOM (par exemple, aucun clic sur un bouton),patchpeut ne s'exécuter qu'une seule fois lors du premier montage, et ce premier montage a eu lieu avant que vous n'ajoutiezconsole.log. Diagnostic : rafraîchissez la page, ou ajoutez dansApp.vueune action déclenchant une mise à jour.

Cause 4 (complément) :cache de build. Le cache de pré-bundling des dépendances de Vite (node_modules/.vite) peut encore utiliser l'ancienne version. Diagnostic : supprimeznode_modules/.vitepuis redémarrez.

---

Le budget de taille vous dit « le problème existe »,vite-debugvous permet de « reproduire le problème de vos propres mains ». Mais lorsque vous tentez de généraliser ce mode sandbox à l'ensemble du monorepo, vous rencontrez une série de conditions limites : les différences de résolution du protocole workspace en environnement CI,catalog:le dilemme de mise à niveau du verrouillage de version,packages-privateetpackagesla contrainte de direction des dépendances entre ... Le chapitre suivant abordera les compromis architecturaux et le guide anti-pièges, en analysant systématiquement les conditions limites exposées par l'ingénierie monorepo dans des projets réels.

Jusqu'ici, nous avons accompli la boucle d'ingénierie allant de la mesure de taille à la reproduction minimale : vite-debug, avec quatre fichiers minimalistes, transforme la « validation rapide sur le code source réel » en une pratique quotidienne utilisable. Mais lorsque vous commencerez réellement à reproduire ce système, vous découvrirez davantage de compromis cachés — pourquoi packages-private doit-il être physiquement isolé de packages ? Pourquoi l'inlining des enums doit-il être terminé avant Rollup ? Le chapitre suivant synthétisera les points de décision clés et les retours d'expérience de production exposés dans les douze chapitres précédents, pour vous fournir une liste complète anti-pièges et des bases de décision.

Transformez n'importe quel code en un livre compréhensible

Vous avez aimé ce chapitre ? Créez un livre pour votre projet privé

Architecture local-first en Tauri 2 + Rust. Sécurité 100% hors ligne, zéro code téléversé. Lecture double panneau avec ancres de commits immuables.

⚡ Tauri 2 · Rust Core · 100% Hors ligne & Privé · Testé sur 1M+ lignes

CHAPTER 13

Chapitre 13 : Compromis architecturaux et guide anti-pièges : les conditions limites de l'ingénierie monorepo

Upstream: vuejs/core · Commit @4ab865a8 · Progression: Chapitre 13 sur 14

Dans le chapitre précédent, en prenantpackages-private/vite-debugcomme point d'entrée, nous avons maîtrisé le paradigme de débogage pour la reproduction minimale sur le code source réel. Lorsque ces packages de débogage internes se multiplient, un problème concret émerge : ils cohabitent dans le même workspace que les packages officiels publiés, comment garantir que le processus de publication ne les affecte pas par erreur ? Ce chapitre approfondira les conditions limites de l'ingénierie monorepo, en partant du contrat à double répertoire entrepackagesetpackages-private, pour analyser la conception défensive derrière les compromis architecturaux et fournir un guide anti-pièges applicable.

13.2 Règle temporelle absolue : l'inlining des enums doit précéder l'exécution de Rollup

Modèle intuitif

L'inlining des enums, c'est comme « remplacer les étiquettes sur les pièces par des numéros avant l'emballage ». Si l'ouvrier emballeur (Rollup) a déjà commencé à empaqueter, et que vous modifiez ensuite les étiquettes, les pièces dans la boîte et les étiquettes ne correspondront plus.build.jsutilisescanEnums() / removeCache()cette paire de fonctions pour encadrer strictement l'inlining avant Rollup.

Structures de données et cycle de vie

inline-enums.jsexportescanEnums()retourne une fermetureremoveCache, qui scanne les définitions d'enum dans le code source et génère des fichiers temporaires destinés à la consommation par Rollup📎 scripts/build.js:30-34。build.jsderun()utilisetry/finallypour garantir le nettoyage du cache📎 scripts/build.js:81-112:

js
const removeCache = scanEnums()
try {
  // ... buildAll / checkAllSizes / build-dts
} finally {
  removeCache()
}

rollup.config.jsappelle au niveau supérieur du moduleinlineEnums()pour obtenir[enumPlugin, enumDefines] 📎 rollup.config.js:47-50, oùenumPluginest inséré dans le tableau plugins📎 rollup.config.js:331-331,enumDefineset intégré dans la table de remplacement du plugin replace📎 rollup.config.js:222-223。

Step-by-Step : le cycle de vie complet d'un enum lors d'un build

1. build.jsderun()appelle d'abordscanEnums(), scanne les définitions d'enum de tous les packages et écrit dans le cache temporaire, retourneremoveCache 📎 scripts/build.js:87-87。

2. buildAllet lance en parallèle plusieurs processus Rollup📎 scripts/build.js:119-121。

3. Chaque processus Rollup exécuteinlineEnums()lors de la phase de chargement de configuration, lit le cache généré à l'étape précédente, obtientenumPluginetenumDefines 📎 rollup.config.js:47-50。

4. enumPluginremplace les références d'enum dans le code source par des littéraux lors de la phase transform ;enumDefinescomplète replace en gérant le remplacement de constantes inter-modules📎 rollup.config.js:222-223。

5. À la fin du build, le blocfinallyappelleremoveCache()pour nettoyer les fichiers temporaires📎 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 块"]

Réflexions de conception et pièges

〔Inférences de conception et compromis architecturaux〕

Pourquoi ne pas utiliser un plugin Rollup pour scanner et utiliser à la volée lors de la phase transform ? Parce que l'inlining des enums nécessiteune vue globale inter-packages:runtime-core. L'enum référencé peut être défini dansshared; un seul processus Rollup ne voit que l'arbre source de son propre package et ne peut pas effectuer le remplacement inter-packages.scanEnums()L'établissement d'un cache global avant le build vise précisément à résoudre ce problème de visibilité.

Pièges en production :removeCache()placé dansfinallysignifie qu'il sera nettoyé même en cas d'erreur en cours de build. Mais si vous interrompez manuellement le processus lors du débogage (Ctrl+C),finallypeut ne pas s'exécuter, et les fichiers de cache résiduels feront que le prochain build lira des enums obsolètes. Méthode de diagnostic : vérifiez si des fichiers de cache d'enum résiduels existent dans le répertoiretemp/, supprimez-les manuellement puis réessayez.

---

13.3 Orchestrateur de publication :release.jsla matrice de flags skip de

Modèle intuitif

release.jsressemble au directeur général d'un mariage ;skipBuild / skipTests / skipGit / skipPromptsles quatre interrupteurs sont les boutons « sauter la répétition », « sauter le serment », « sauter les photos », « sauter la confirmation ». Chaque bouton correspond à un scénario réel : l'environnement CI nécessiteskipPrompts, le débogage local nécessiteskipGit, le hotfix d'urgence nécessiteskipTests。

Structure de données et valeurs par défaut des flags

Les quatre flags skip sont déclarés dansparseArgs📎 scripts/release.js:39-50, puis déstructurés 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

Notez queskipTestsutiliseletdéclaration, car elle est dansrunTestsIfNeeded()dynamiquement réécrite📎 scripts/release.js:281-317。

Step-by-Step : le flux de décision complet d'une release

main()l'ordre d'exécution📎 scripts/release.js:143-279:

1. Vérification de synchronisation distante:isInSyncWithRemote()Comparaison du HEAD local avec le SHA de la branche distante, affichage d'une boîte de confirmation en cas de divergence📎 scripts/release.js:337-363。

2. Sélection de version: en l'absence d'argument positionnel, affichage duversionIncrementsmenu de sélection📎 scripts/release.js:152-176。

3. Décision de test:runTestsIfNeeded()est l'endroit où la logique de skip est la plus dense📎 scripts/release.js:281-317。

4. Mise à jour de version:updateVersions()parcourt tous les packages pour réécrirepackage.json 📎 scripts/release.js:377-398。

5. Génération du Changelog: appel depnpm run changelog 📎 scripts/release.js:211-212。

6. Commit Git:skipGitsi vrai, tout le bloc est ignoré📎 scripts/release.js:231-240。

7. Publication: exécuté uniquement siargs.publishest vraibuildPackages() + publishPackages() 📎 scripts/release.js:243-246。

runTestsIfNeeded()La logique de branche mérite d'être détaillée séparément :

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

Réflexions de conception et pièges rencontrés

〔Inférences de conception et compromis architecturaux〕

skipTestsutiliseletplutôt queconstLa conception vise à supporter le chemin d'optimisation « si la CI est passée, ignorer automatiquement les tests locaux ». Cela économise beaucoup de temps dans les scénarios de publication CI — lerelease.ymlde GitHub Actions a déjà exécuté la suite complète de tests, les relancer localement est un pur gaspillage.

Le contrat caché de l'ordre de publication:sortPackagesForPublishingplacevueen dernier📎 scripts/release.js:85-85, avec un commentaire explicite indiquant que « l'utilisateur ne peut pas installer le nouveau package d'entrée avant que les packages internes soient disponibles ». Si vous modifiez cet ordre, l'utilisateurnpm install vue@nextpourrait récupérer une version dont les dépendances ne sont pas encore publiées, entraînantERR_MODULE_NOT_FOUND。

Protection d'idempotence:publishPackageappelleisPackagePublishedavant la publication pour vérifier le registry📎 scripts/release.js:453-458, capture l'erreurpreviously publisheden cas d'échec de publication et dégrade en ignorant📎 scripts/release.js:480-488. Cela permet au script de release d'être relancé en toute sécurité — après une interruption réseau, une réexécution n'échouera pas globalement à cause de « le package existe déjà ».

Rollback en cas d'échec:fnToRun().catch()appelleversionUpdatedlorsqueupdateVersions(currentVersion)est vrai pour restaurer le numéro de version📎 scripts/release.js:528-537. Mais attention : cela ne restaure quepackage.jsonle champ de version dans, ne restaure pas les commits déjàgit commit. Si vous publiez en échec alors queskipGitest faux, vous devez manuellementgit reset。

---

Réflexion de conception : le schéma commun des trois compromis

En revisitant les trois compromis centraux de ce chapitre, ils partagent la même philosophie de conception :transformer « une vérification à l'exécution facile à oublier » en « une contrainte structurelle impossible à contourner »。

  • packages-privateIsolation physique : ne pas dépendre de l'auteur du script qui se souvient de vérifier le champprivate, mais faire en sorte que la portée du scan l'exclue naturellement.
  • Inlining d'enum en amont : ne pas dépendre du plugin Rollup qui « tombe par hasard » sur un enum inter-packages lors du transform, mais établir un cache global avant le build.
  • release.jsLa matrice de skip deskipTests。
: ne pas dépendre du publieur qui se souvient que « si la CI est passée, pas besoin de lancer les tests locaux », mais faire en sorte que le script interroge automatiquement l'état de la CI et réécrive

〔Inférences de conception et compromis architecturaux〕Le coût de ce schéma est:build.jsune complexité accrue du scriptprivatePackagesil faut maintenir la listerollup.config.js,release.jsdoit dupliquer la logique de détection de répertoire,

---

doit gérer les combinaisons croisées de quatre indicateurs de skip. Mais pour un dépôt comme Vue qui publie plusieurs fois par semaine, le gain de fiabilité apporté par les contraintes structurelles dépasse largement le coût en complexité.

Résumé du chapitre

1. packages-privateCe chapitre, en partant du code source, décompose trois conditions limites clés du système d'ingénierie de Vue core :packagesL'isolation physique entreetbuild.jsest garantie conjointement par le workspace glob,release.jsla détection de répertoire,📎 pnpm-workspace.yaml:1-3📎 scripts/build.js:153-170📎 scripts/release.js:68-83。

2. et le filtrageLa contrainte temporelle de l'inlining d'enumscanEnums() / removeCache()est garantie de manière forcée par la structuretry/finallyde📎 scripts/build.js:81-112📎 rollup.config.js:47-50。

3. release.js, la configuration Rollup consommant le cache au niveau supérieur du moduleLa matrice d'indicateurs de skip deskipTestssert trois scénarios : publication CI, débogage local, correctif urgent,📎 scripts/release.js:281-317📎 scripts/release.js:85-85。

la réécriture dynamique et le tri de l'ordre de publication sont les deux contrats cachés les plus facilement négligés

Réflexions et auto-évaluation de ce chapitrebuild.jsQ1 : Si l'on supprime la vérificationbuild(target)dans la fonctionprivatePackages.includes(target)depackages, et qu'on utilise uniformémentpkgBasecomme

, dans quels scénarios cela poserait-il problème ?:build.js:160-164Analyse de référencenr build vite-debugLa détection de répertoire depackages/vite-debugest le seul point d'entrée permettant aux packages privés d'être construits. Après suppression,package.jsonchercherafs.readFileSyncsousENOENT, or ce répertoire n'existe pas,packages/lance directementbuildOptions. Le problème plus insidieux : si à l'avenir quelqu'un crée un répertoire du même nom sousrollup.config.js:37-42, le build utilisera silencieusement la configuration du mauvais répertoire, et les chemins de sortie ainsi quebuild.jsseront tous décalés. De plus,

Q2: release.jspossède une logique de détection de répertoire indépendante, les deux endroits doivent être modifiés en synchronisation, sinon on obtient un état incohérent où «runTestsIfNeeded()a trouvé le package mais Rollup ne le trouve pas ».skipTests ||= isCIPassedDans lerelease.js:285deskipPrompts, la ligne de code (else if (skipPrompts)) lorsquethrowest vrai et que la CI n'est pas passée, quelle branche sera empruntée ? Si l'on supprime le

de la branche, quelles seraient les conséquences ?skipPromptsAnalyse de référenceskipTests ||= isCIPassed: lorsqueisCIPassedest vrai et que la CI n'est pas passée,false,skipTestsdansfalsereste àelse if (skipPrompts)sa valeur originale (généralementError(release.js:299-304). Ensuite, on entre dans la branchethrow, qui lanceif (!skipTests)). Si l'on supprime cepnpm run test --run, le code continuera jusqu'à la branche

Q3: rollup.config.js:55, exécutantinlineEnums()dans un environnement non interactif. En CI, cela peut provoquer l'échec des tests à cause de différences d'environnement, ou pire — les tests passent mais la CI n'est en réalité pas passée (par exemple, la CI exécute un sous-ensemble de tests différent), publiant une version non entièrement validée.build.js:87LescanEnums()derun()est appelé au niveau supérieur du module, tandis que leinlineEnums()debuildStartest appelé à l'intérieur de la fonction

. Si l'on échange le moment d'exécution de ces deux (c'est-à-dire faire appeler:scanEnums()dans le hookde Rollup), que casserait-on ?Analyse de référenceinlineEnums()doit être terminé avant le démarrage de tous les processus Rollup, car il doit scannerrollup.config.jstous les packagesbuildStartpour établir le cache global d'enum.buildAllest appelé au niveau supérieur du modulebuild.js:119-121, à ce moment Rollup n'a pas encore commencé de build, le cache est déjà prêt. Si on le déplaçait dansscanEnums(), chaque processus Rollup scannerait indépendamment — maisremoveCaches'exécute en concurrence (

Le contrat à double répertoire, la détermination de l'appartenance des scripts de build, le filtrage secondaire des scripts de publication — ces mécanismes délimitent ensemble les frontières de sécurité de l'ingénierie monorepo. Mais les frontières ne sont pas immuables : à mesure que les outils de build migrent de Rollup vers Rolldown et que les tests de types et les tests d'exécution convergent, les stratégies de compromis actuelles seront confrontées à de nouveaux défis. Dans le prochain chapitre, nous examinerons les orientations d'évolution de la prochaine génération de systèmes d'ingénierie, en nous appuyant sur la trajectoire des changements de 3.0 à 3.4.

Transformez n'importe quel code en un livre compréhensible

Vous avez aimé ce chapitre ? Créez un livre pour votre projet privé

Architecture local-first en Tauri 2 + Rust. Sécurité 100% hors ligne, zéro code téléversé. Lecture double panneau avec ancres de commits immuables.

⚡ Tauri 2 · Rust Core · 100% Hors ligne & Privé · Testé sur 1M+ lignes

CHAPTER 14

Chapitre 14 : Évolution future : de la 3.x à la prochaine génération de systèmes d'ingénierie

Upstream: vuejs/core · Commit @4ab865a8 · Progression: Chapitre 14 sur 14

Dans le chapitre précédent, nous avons examiné les « frontières de sécurité » du système d'ingénierie de Vue core — le contrat à double répertoire, la détermination de l'appartenance des scripts de build, le filtrage secondaire des scripts de publication. Ces mécanismes n'ont pas été conçus en une seule fois, mais ont été affinés de manière itérative entre 3.0 et 3.4. Ce chapitre adopte un angle différent : il ne s'agit plus de regarder « à quoi cela ressemble maintenant », mais « comment cela en est arrivé là », et d'en déduire où ira la prochaine génération de systèmes d'ingénierie. Les sources de ce chapitre sont changelogs/CHANGELOG-3.3.md, changelogs/CHANGELOG-3.4.md et le package.json à la racine du dépôt. Les journaux de modification ressemblent à un simple relevé de « quels bugs ont été corrigés », mais ils constituent le rapport de santé le plus authentique du système d'ingénierie : chaque commit avec le préfixe build:, chaque modification avec le préfixe types:, chaque régression de version de dépendance expose les points de tension de l'architecture actuelle. Notre tâche est de lire la direction de l'évolution à partir de ces points de tension. Considérer les journaux de modification comme une « fenêtre d'observation du système d'ingénierie » plutôt qu'une « liste de fonctionnalités » est la méthodologie centrale de ce chapitre. Les changements fonctionnels nous disent ce que Vue peut faire, tandis que les changements liés au build, aux types et à la CI nous disent « où le système d'ingénierie de Vue a mal ».

I. Les points de tension de la chaîne d'outils de build : le potentiel de migration de Rollup vers Rolldown

Modèle intuitif

Imaginez la chaîne d'outils de build comme une ligne d'assemblage : Rollup est le poste d'assemblage principal, esbuild se charge du découpage rapide (transpilation TS), terser se charge de l'empaquetage et de la compression finaux. À mesure que le produit (le runtime Vue) devient plus complexe et que les opérations sur le poste d'assemblage se multiplient, le poste d'assemblage principal devient lui-même un goulot d'étranglement. Le positionnement de Rolldown est celui d'un poste d'assemblage principal réécrit en Rust — il ne remplace pas esbuild, mais Rollup lui-même.

Sans cette pression d'évolution, la « catastrophe » à laquelle le système serait confronté n'est pas un crash, maisune inflation linéaire du temps de build en fonction du nombre de packages: chaque sous-package ajouté nécessite de lancer un processus Rollup supplémentaire, de rescanner le cache enum, et d'exécuter un cycle supplémentaire de génération dts.

Structures de données et disposition des dépendances

Examinons d'abord un instantané statique de la chaîne d'outils actuelle.package.jsonLedevDependenciesde

📎 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",

Copier^4.63.3On peut en lire trois faits clés. Premièrement, la version majeure de Rollup estrollup-plugin-esbuild, en phase de maturité de Rollup 4.x. Deuxièmement,rollup-plugin-dtsprend en charge la transpilation TS, ce qui signifie que Rollup lui-même ne parse pas le TS et ne traite que le JS produit par esbuild. Troisièmement,.d.tsest responsable de manière indépendante de l'empaquetagedts-built-test, ce qui constitue précisément la base matérielle de l'indépendance

discutée dans le chapitre précédent.

📎 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-dtsCopiertsc --noCheckest « en deux étapes » : d'abord--noCheckgénère les fichiers de déclaration bruts (rollup -c rollup.dts.config.jsignore la vérification de types et se contente d'émettre), puis.d.tsempaquette lesrollup-plugin-dtsdispersés en un fichier unique. Cette conception dépend elle-même des capacités de Rollup —

nécessite le graphe de modules de Rollup pour tracer les dépendances de types.build:Piloté par scénario : ce qu'un commit

a exposébuild:Les entrées avec le préfixe

dans les journaux de modification sont des preuves directes des points de tension de la chaîne d'outils de build. Prenons-en trois.

📎 changelogs/CHANGELOG-3.4.md:84

code
* **build:** use consistent minify options from previous terser config ([789675f](https://github.com/vuejs/core/commit/789675f65d2b72cf979ba6a29bd323f716154a4b))

CopierdevDependenciesLa motivation de ce commit est « après la migration de terser vers esbuild minify, les options de compression sont incohérentes ». Cela révèle un état intermédiaire en cours de migration : Vue utilisait terser pour la compression, puis est passé à esbuild (esbuild: ^0.28.2dans

le confirme), mais les options de compression n'ont pas été entièrement alignées, entraînant des écarts de taille ou de comportement des artefacts. C'est précisément le coût typique du « remplacement de pièces du poste d'assemblage ».

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

entitiesCopiercompiler-domest une bibliothèque de décodage d'entités HTML, dépendance de. La régression vers 4.5 est due à des problèmes de résolution à l'exécution dans la nouvelle version. Ce commit montre que :。

la mise à niveau des dépendances de la chaîne d'outils de build n'est pas isolée ; le saut de version d'une dépendance indirecte peut se répercuter sur le comportement à l'exécution

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

Copierserver-rendererC'est le type de bug de build le plus typique : en format CJS,runtime-corea accidentellement intégréexternaldans son propre artefact. La cause est généralement la défaillance de la déterminationimportde Rollup en format CJS — l'ESM peut identifier statiquement les dépendances externes grâce aux instructionsrequireLa dynamique est plus forte, ce qui rend les omissions de détection plus faciles. Ce commit pointe directement vers la fragilité de la logiqueexternaldans la configuration de Rollup.

Représentation Mermaid du potentiel de migration

Le diagramme ci-dessous décrit le flux de contrôle du pipeline de build actuel et met en évidence les nœuds qui seront touchés par la migration vers Rolldown :

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["构建完成"]
〔Inférences de conception et arbitrages architecturaux〕

La valeur de la migration vers Rolldown réside dans le fait qu'elle remplace le modèle de concurrence « un processus par paquet » par un modèle de « parallélisme au sein d'un seul processus »,scanEnums()l'analyse globale deinlineEnums()et le remplacement derollup-plugin-esbuild、rollup-plugin-dtspeuvent être coordonnés au sein du même runtime Rust, et le problème de « race condition lors de l'analyse concurrente » discuté au chapitre précédent disparaîtra à la racine. Mais la résistance à la migration se situe précisément ici —externalces écosystèmes de plugins nécessitent que Rolldown fournisse une couche de compatibilité, et la logique de décision de

doit être réécrite.

Réflexions de conception et pièges rencontrésPourquoi la migration ne se fera-t-elle pas en une seule étape ?package.jsonRegardez le champenginesde

📎 package.json:61-63

code
  "engines": {
    "node": ">=20.0.0"
  },

Node 20 est la limite inférieure stricte. Rolldown, en tant que module natif Rust, nécessite les bindings N-API correspondants et une distribution de binaires précompilés. Une fois introduit,pnpm installle temps d'exécution de, la compatibilité binaire multiplateforme (Windows/macOS/Linux) et la stratégie de cache CI doivent tous être repensés. Il ne s'agit pas simplement de « changer une dépendance », mais d'unrecalibrage complet de toute la chaîne installation-build-cache。

Pièges en production:build-dtsLetsc --noCheckde est une arme à double tranchant. Sauter la vérification de types accélère l'emit, mais cela signifie que.d.tsl'étape de génération ne détectera pas les erreurs de type — celles-ci ne pourront être rattrapées que parpnpm check(tsc --incremental --noEmit) ettest-dts. Si après la migration vers Rolldown on souhaite fusionner ces deux étapes, il faut s'assurer que la vérification de types ne ralentit pas le build, sinon cela va à l'encontre de l'objectif initial de--noCheck.

---

II. La tendance à la fusion des tests de types et des tests d'exécution

Modèle intuitif

Imaginez les tests de types et les tests d'exécution comme deux points de contrôle qualité indépendants : l'un vérifie si « le manuel (.d.ts) est correctement rédigé », l'autre vérifie si « la machine (le runtime) tourne correctement ». Les deux points de contrôle ont chacun leur propre poste de travail, leurs propres outils, leurs propres rapports. La tendance à la fusion signifie :peut-on faire en sorte qu'un même cas de test valide simultanément le manuel et la machine ?

Sans fusion, le désastre auquel le système est confronté estla dérive entre les types et le comportement à l'exécution:.d.ts: le type dit queref()renvoieRef<T>, mais la forme de l'objet réellement renvoyé à l'exécution a changé ; le test de types passe, le test d'exécution passe aussi, mais leur combinaison est erronée.

Structure de données : disposition d'orchestration des scripts de test

package.jsonDans lescriptsde, les entrées liées aux tests se répartissent clairement en deux groupes :

📎 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 structure clé ici est letest-dtsderun-s build-dts test-dts-only— il estséquentiel: d'abord construire.d.ts, puis exécuter les tests de types. Et à l'intérieur detest-dts-only, il y a à nouveaudeux processustscindépendants: un qui exécutedts-built-test(vérifie les artefacts de build), un qui exécutedts-test(vérifie les types du code source).

Notez quetest-unitutilisevitest --project unit*,test-e2eutilisevitest --project e2e --project e2e-browser. Cela montre que le mécanisme--projectde Vitest a déjà réparti les tests en différents projets selon « unitaire/e2e/navigateur ».La base physique de la fusion existe déjà: le mécanisme de projet de Vitest permet d'exécuter différents types de tests dans le même runner.

Piloté par scénario : le chemin complet d'un committypes:Dans le changelog, la densité des entrées préfixées par

est extrêmement élevée, ce qui reflète directement la complexité du système de types. Nous traçons un correctif de type typique.types:Retour en arrière du type ref dans la 3.4.37 :

Copier

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

Copier

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

les tests de types peuvent vérifier que « la signature de type correspond aux attentes », mais ils ne peuvent pas vérifier « si cette signature de type est agréable à utiliser dans du code réel ». Dans les tests de types,。allow getter and setter types to be unrelatedpeut parfaitement passer, mais à l'usage réel, cela rendra l'inférence de types dereftrop permissive, brisant la sécurité de types du code en aval.

Représentation Mermaid de la fusion des tests de types

Le diagramme ci-dessous décrit la structure actuelle de séparation entre tests de types et tests d'exécution, ainsi que la forme cible après fusion :

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
〔Inférences de conception et arbitrages architecturaux〕

Le chemin technique de la fusion est très probablement : encapsuler les appelsdts-built-testetdts-testdetscen un projet personnalisé Vitest, et faire en sorte que les assertions de types soient intégrées sous forme deexpectTypeOfdans les fichiers de test. Ainsi, un seul appelvitestpermettrait d'exécuter simultanément les assertions d'exécution et les assertions de types, avec un rapport unifié. Mais la résistance réside dans le fait que :tscla vérification de types de est « globale », tandis que les tests de Vitest sont « par fichier », et les deux stratégies incrémentales sont incompatibles.

Réflexions de conception et pièges rencontrés

Pourquoidts-built-testdoit-il être indépendant dedts-test?? Cela a déjà été discuté au chapitre précédent, complétons ici du point de vue de l'évolution :dts-built-testvérifieles artefacts de build(rollup-plugin-dtsle.d.ts),dts-testaprès bundling ; vérifieles types du code source. Si l'on fusionne les deux, on perd le point de contrôle clé « les artefacts de build sont-ils cohérents avec les types du code source ». Le commit de la 3.4.38 confirme précisément l'importance des types des artefacts de build :

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

« Fournir un stub de fallback lorsque la lib DOM est absente » — c'est un correctif de compatibilité de types au niveau des artefacts de build, qui ne peut être découvert que dans le scénariodts-built-testde « consommation du.d.tsaprès bundling ».

Pièges en production: le cycle « fusion-retour en arrière » des tests de types montre que les modifications de signatures de types nécessitent une validation parde vrais projets en aval, et pas seulement par des assertions de types. Les tests de types de Vue s'exécutent danspackages-private/dts-test, on utilise des cas de test internes au dépôt, ce qui ne couvre pas tous les usages en aval. Si la tendance à la fusion se limite à « fusionner deux runners » sans résoudre « comment introduire un retour réel des usages en aval », il ne s'agit que d'une fusion formelle.

---

III. Axes d'optimisation fine du cache CI

Modèle intuitif

Imaginez le cache CI comme la « zone de préparation » d'un entrepôt : chaque build doit y puiser des matières premières (dépendances, artefacts de build, cache de types). Si la zone de préparation n'est qu'une grande caisse, il faut fouiller toute la caisse pour prendre le moindre élément, et même avec un taux de succès de cache élevé, cela ne peut pas être rapide. L'optimisation fine signifie :diviser la grande caisse en petites cases classées par usage。

Sans cache fin, le désastre auquel le système fait face estl'amplification en cascade de l'invalidation du cache: modifier une ligne de code source invalide tout le cachenode_modules, la CI réinstalle toutes les dépendances, et le temps de build passe de 2 minutes à 10 minutes.

Structure de données : classification des éléments cachables

À partir depackage.json, on peut identifier plusieurs catégories de « matériaux » cachables :

Première catégorie, les produits d'installation des dépendances.packageManagerLe champ

📎 package.json:4

code
  "packageManager": "pnpm@12.4.2",

Lenode_modulesde pnpm est une structure de liens symboliques ; ce qui est mis en cache est le content-addressable store de pnpm, et non unnode_modulesplat. Cela signifie que la clé de cache doit être basée sur le hachage depnpm-lock.yaml, et non surpackage.json。

Deuxième catégorie, les artefacts de build.cleanLe script

📎 package.json:10

code
    "clean": "rimraf --glob packages/*/dist temp .eslintcache",

packages/*/dist、temp、.eslintcache— ces trois types d'artefacts peuvent être mis en cache indépendamment.distest la sortie de build,tempest un fichier temporaire (commebench.json),.eslintcacheest le cache de lint.

Troisième catégorie, le cache de vérification de types.checkLe script--incremental:

📎 package.json:15

code
    "check": "tsc --incremental --noEmit",

--incrementalgénère le fichier.tsbuildinfo, qui est le cache incrémental de la vérification de types. Si ce fichier est mis en cache dans la CI,tscla seconde exécution de

sera bien plus rapide.

Scénario guidé : flux d'exécution CI d'une PRpackages/reactivity/src/ref.tsPlaçons-nous dans un scénario typique : un développeur modifie

, soumet une PR. Quelles étapes la CI doit-elle exécuter, et lesquelles peuvent bénéficier du cache ?scriptsÀ partir desimple-git-hooks, on peut déduire la séquence d'exécution de la CI (pre-commitle

📎 package.json:48-51

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

Lepre-commitlocal exécutelint-stagedetcheck. En CI, on exécuteralint、check、test-unit、test-dts、sizeetc. La stratégie de cache diffère à chaque étape :

  • lint: mettre en cache.eslintcache, clé basée sur le hachage des fichiers source.
  • check: mettre en cache.tsbuildinfo, clé basée surtsconfiget le hachage du code source.
  • test-unit: Vitest a son propre cache, mais en général la CI ne met pas en cache les résultats de test, seulement les dépendances.
  • test-dts: dépend des artefacts debuild-dts, clé de cache basée sur le hachage depackages/*/dist.
  • size: dépend des artefacts de build, clé de cache identique à ci-dessus.

Représentation Mermaid de l'optimisation du cache 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 通过"]
〔Inférence de conception et compromis architecturaux〕

La contradiction centrale du cache fin estla granularité de la clé de cache: une clé trop grossière (par exemple basée uniquement sur le commit hash) donne un faible taux de succès ; une clé trop fine (par exemple basée sur le hachage de chaque fichier) fait que le coût de calcul de la clé annule le gain du cache. La stratégie raisonnable pour un monorepo comme Vue est le « sharding par package » : chaquepackages/*sous-package dispose d'un cache indépendantdist,reactivity; une modification decompiler-coren'invalide pas le cachedistde

Réflexions de conception et pièges

Pourquoi le scriptsizeest-il divisé en plusieurs sous-commandes ?Regardez ces trois lignes :

📎 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",

sizeutiliserun-s "size-*"pour exécuter en série toutes les sous-commandes préfixées parsize-. Ce modèle d'« agrégation par préfixe » permet à chaque dimension de taille (global, esm-runtime, esm) d'être mise en cache et d'échouer indépendamment. Si l'on fusionnait en une seule grande commande, tout dépassement de seuil dans une dimension ferait échouer l'ensemble desize, sans pouvoir localiser la dimension problématique.

Pièges en production: le piège le plus courant du cache CI estla pollution du cache— mettre en cache de mauvais artefacts, ce qui fait que les builds suivants reposent sur des données corrompues.cleanLe script

📎 package.json:10

code
    "clean": "rimraf --glob packages/*/dist temp .eslintcache",

Notez qu'il nettoiepackages/*/dist, et nonpackages-private/*/dist. Cela signifie que les artefacts depackages-privatene font pas partie du nettoyage habituel — si la CI met en cache les artefacts depackages-privateet quecleanne les nettoie pas, on peut se retrouver avec « un ancien artefact de playground mis en cache ». Lors de la conception d'un cache fin, il faut traiterpackages-privateséparément.

---

Réflexion de conception : le système d'ingénierie comme cycle de vie du produit

En reliant les fils des trois sections, on voit une ligne directrice claire :Le système d'ingénierie de Vue passe de « fonctionnel » à « agréable à utiliser », de « l'orchestration manuelle » à « la configuration déclarative »。

La migration de la chaîne d'outils de build (Rollup → Rolldown) est une évolution « pilotée par la performance » : lorsque le nombre de packages croît au-delà d'un certain seuil, le coût de la concurrence au niveau processus dépasse le gain, et il faut passer à un modèle de concurrence plus léger.

La fusion des tests de types est une évolution « pilotée par la cohérence » : lorsque la fréquence de modification des signatures de types dépasse celle du comportement à l'exécution, deux suites de tests séparées deviennent un fardeau, et il faut les faire partager les mêmes cas de test.

La finesse du cache CI est une évolution « pilotée par le coût » : lorsque les minutes de CI deviennent un goulot d'étranglement, le gaspillage d'un cache grossier devient inacceptable, et il faut sharder par usage.

〔Inférence de conception et compromis architecturaux〕

La contrainte commune à ces trois lignes d'évolution estla rétrocompatibilité. La stratégie de publication de Vue (visible dans la sectionBREAKING CHANGESdu changelog) autorise des « type-only breaking changes » en version mineure, mais pas de breaking change à l'exécution. Cela signifie que l'évolution du système d'ingénierie doit garantir : quelle que soit la rotation interne de la chaîne d'outils, l'API publique et le comportement à l'exécution des artefacts ne doivent pas changer. C'est la frontière dure de toutes les décisions d'évolution.

---

Résumé du chapitre

Ce chapitre, à partir du changelog et depackage.json, a passé en revue les trois lignes d'évolution du système d'ingénierie de Vue core :

1. Chaîne d'outils de build: La combinaison actuelle de Rollup 4.x + esbuild + rollup-plugin-dts, dont les points de tension se manifestent dansbuild:les commits préfixés par (alignement de la configuration minify, rétrogradation de version des entities, omission de détection des externals CJS). Le potentiel de migration vers Rolldown vient du remplacement de la « concurrence multi-processus » par le « parallélisme mono-processus », la résistance vient de l'écosystème de plugins et de la distribution de binaires multiplateformes.

2. Fusion des tests de types:test-dtsderun-s build-dts test-dts-onlyla structure sérielle, ainsi quedts-built-testetdts-testle doubletscprocessus, constituent les preuves physiques de la forme séparée actuelle. Le chemin technique de fusion passe par le mécanisme--projectde Vitest, la résistance étant quetscla vérification complète est incompatible avec la stratégie incrémentale de test par fichier de Vitest.

3. Granularisation du cache CI:packageManagerverrouillage de pnpm,cleannettoyage de trois catégories d'artefacts,checkutilisation de--incremental、sizeutilisation de l'agrégation par préfixe — ce sont autant de critères de classification des éléments cachables. La contradiction centrale réside dans la granularité des clés de cache, la stratégie raisonnable étant le « sharding par paquet ».

Le changement de perception le plus important est le suivant :le système d'ingénierie lui-même est un produit, il a ses propres utilisateurs (contributeurs), ses propres indicateurs de performance (temps de build, minutes CI), ses propres contraintes de compatibilité (API des artefacts inchangée). Il nécessite une itération continue, et non une conception ponctuelle.

Réflexions et auto-évaluation de ce chapitre

Q1: package.json:9debuild-dtsutilisetsc -p tsconfig.build.json --noCheck. Si l'on supprime--noCheck, quelles réactions en chaîne cela entraînera-t-il après la migration vers Rolldown ?

Analyse de référence:--noCheckLe rôle detscest de sauter la vérification de types et de ne faire que l'emit. Si on le supprime,.d.tseffectuera une vérification complète des types avant de générerbuild-dts. Dans l'architecture Rollup actuelle, cela ne fait que ralentirbuild-dts; mais après la migration vers Rolldown, le problème s'amplifiera : le principal argument de vente de Rolldown est la « construction parallèle mono-processus », si l'étapetscintroduit une vérification complètetsc, elle devient le goulot d'étranglement sériel de toute la chaîne — toutes les constructions de paquets doivent attendre la fin de cette vérification. Plus grave encore, la vérification de types de--noCheckest mono-thread et ne peut pas exploiter la capacité parallèle de Rolldown. La bonne approche est de conserverpnpm check(package.json:15, et de confier la vérification de types à destest-dts(package.json:22indépendants, découplant ainsi construction et vérification.

Q2 : Le changelog 3.4.37 a rétrogradé consécutivement deux correctionstypes/ref(CHANGELOG-3.4.md:23-24), alors que ces deux corrections venaient d'être fusionnées dans 3.4.35 (CHANGELOG-3.4.md:30,55). Si les tests de types et les tests d'exécution étaient déjà fusionnés, ce cycle « fusion-rétrogradation » pourrait-il être évité ? Pourquoi ?

Analyse de référence: Impossible à éviter complètement, mais le cycle peut être raccourci. Les tests de types fusionnés ne peuvent toujours vérifier que « la signature de type correspond à l'assertion », alors que le problème de corrections commeallow getter and setter types to be unrelatedréside dans le fait que « la signature de type est trop permissive, brisant la sécurité de types du code en aval » — c'est un problèmed'usage en aval, et non un problèmede la signature elle-même. Là où la fusion peut raccourcir le cycle, c'est que : si les assertions de types et les assertions d'exécution sont écrites dans le même fichier de test, le développeur peut plus rapidement détecter l'incohérence « la signature de type a changé mais le comportement d'exécution n'a pas changé ». Mais pour vraiment éviter les rétrogradations, il faut introduire la vérification de types de vrais projets en aval (par exemple étendrepackages-private/dts-testen un ensemble de tests « simulant l'usage en aval »), ce qui dépasse le cadre du simple « runner fusionné ».

Q3: package.json:10Le scriptcleandepackages/*/distnettoiepackages-private/*/dist, mais ne nettoie pas

. Si la CI adopte une stratégie de cache fine « sharding par paquet », quelle trappe de production cette asymétrie entraînera-t-elle ?Analyse de référencepackages-private: La trappe réside dans le « cache d'anciens artefacts depackages-private». Lessfc-playground、template-explorercontiennent des outils de débogage commepackages-private/sfc-playground/dist, et si leurs artefacts de construction (tels queclean) sont mis en cache par la CI, alors quebuild-sfc-playground(package.json:39ne les nettoie pas, il se produira : le code source est mis à jour, mais la CI réutilise d'anciens artefacts de playground, faussant les résultats de vérification dedev-sfc-prepare(package.json:34. Plus insidieux encore,packages-privatevérifie si les artefacts depackages-privateexistent ; si d'anciens artefacts sont mis en cache, il sautera la reconstruction, laissant le développeur croire que l'environnement est neuf. Lors de la conception d'un cache fin, il faut soit définir une clé de cache distincte pour

, soit simplement ne pas cacher ses artefacts — car c'est un outil de débogage, dont le coût de reconstruction est faible et le gain de cache minime.

Transformez n'importe quel code en un livre compréhensible

Vous avez aimé ce chapitre ? Créez un livre pour votre projet privé

Architecture local-first en Tauri 2 + Rust. Sécurité 100% hors ligne, zéro code téléversé. Lecture double panneau avec ancres de commits immuables.

⚡ Tauri 2 · Rust Core · 100% Hors ligne & Privé · Testé sur 1M+ lignes

Pour comprendre un projet complexe, tout ce dont vous avez besoin est un bon livre

Compilé automatiquement par AiReadCode en scannant le dépôt officiel avec ancrage permanent des commits.

Étoiler sur GitHub ★ Parcourir d'autres livres →