CHAPTER 01

Глава 1: Макроуровневое понимание: философия инженерного проектирования репозитория core

Проект: vuejs/core · Прогресс книги: глава 1 / 14 · Статус верификации: FACT — реальная привязка к номерам строк

Прежде чем начать отслеживать любую строку реализации реактивности или виртуального DOM, нам сначала нужно понять инженерную основу, на которой существует этот код. Открыв репозиторий Vue core, первое, что бросается в глаза, — вовсе не основная логика фреймворка, аpackage.jsonиpnpm-workspace.yaml— такие конфигурационные файлы проекта, которые не содержат никакой функциональности времени выполнения, но определяют, может ли весь фреймворк быть корректно собран, протестирован и опубликован. Именно на этот предварительный вопрос и отвечает данная глава: что такое репозиторий core. Это не@vue/runtime-coreтот npm-пакет, а инженерная основа, несущаяruntime-core、reactivity、compiler-sfcи ещё более десяти публично публикуемых пакетов, плюсsfc-playground、template-explorerи другие приватные экспериментальные пакеты. Понимание способа организации этой основы является предпосылкой для всех последующих глав (сборка, типы, релиз, бюджет размера). Данная глава будет разворачиваться по трём основным линиям: двойная структура каталогов workspace, унифицированные ограничения корневого TypeScript и Rollup, а также философия развязки «репозитория исходного кода» и «публикуемых артефактов».

I. Двойная структура каталогов: физическая изоляция packages и packages-private

Интуитивная модель

Представьте репозиторий core как здание для исследований и разработки.packages/— это официальная продуктовая линейка, произведённые продукты должны быть промаркированы и проданы на рынке;packages-private/— это внутренняя лаборатория, образцы в которой используются только для отладки и демонстрации и никогда не отправляются наружу. Оба используют одну и ту же систему водоснабжения и электричества (зависимости, инструменты сборки), но система контроля доступа (процесс релиза) относится к ним по-разному.

Без этого слоя физической изоляции внутренний отладочный playground-пакет легко может быть ошибочно опубликован в npm — это не гипотеза, а классический инцидент monorepo.

Структуры данных и компоновка в памяти

Границы workspace определяютсяpnpm-workspace.yaml. В нём всего три строки действующих объявлений:

📎 pnpm-workspace.yaml:1-3

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

Эти два glob-шаблона сообщают pnpm:packages/иpackages-private/каждый подкаталог является независимым пакетом. pnpm создаст для них символические ссылки, чтобы@vue/runtime-coreпри ссылке на@vue/reactivityуказывал напрямую на локальный каталог исходного кода, а не скачивал из registry.

Следующий за этим разделcatalog:— это механизм pnpmкаталога версий зависимостей:

📎 pnpm-workspace.yaml:5-13

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

В корневомpackage.jsonсоответствующая запись — это"@babel/parser": "catalog:" 📎 package.json:65-65。catalog:— это плейсхолдер, который pnpm при установке заменяет на версию, объявленную в разделе catalog. Выгода от этого:@babel/parserверсияpnpm-workspace.yamlподдерживается только в одном месте —

, все ссылающиеся на неё пакеты автоматически выравниваются, что исключает дрейф версий вида «пакет A использует 7.28, пакет B использует 7.29».pnpm installСценарный Walkthrough: что происходит после

pnpm installПредположим, вы выполняете

в корневом каталоге репозитория. Погрузившись в этот сценарий, проследим шаг за шагом:Шаг первый: шлюз preinstall.package.jsonpnpm перед установкой запускаетpreinstallскрипт корневого

📎 package.json:45-45

json
"preinstall": "npx only-allow pnpm"
Копировать

only-allow pnpm〔Проектные предположения и архитектурные компромиссы〕catalog:проверяет, является ли текущий менеджер пакетов pnpm, и если нет — сразу завершается с ошибкой. Наличие этой строки скрипта означает: установка репозитория core через npm или yarn завершится неудачей. Почему необходимо жёстко зафиксировать pnpm? Потому что репозиторий core зависит от символических ссылок workspace и механизма catalog pnpm, workspaces npm не поддерживаютcreateRequireсинтаксис, а режим PnP yarn изменяет пути разрешения модулей, что приводит к несогласованному поведению

в скриптах сборки.Шаг второй: разрешение workspace.pnpm-workspace.yamlpnpm читаетpackages/*, сканируетpackages-private/*иpackage.json, создавая запись пакета для каждого каталога, содержащего

.Шаг третий: применение замены catalog.package.jsonВ корневомcatalog:Заполнители заменяются фактическими версиями из раздела catalog, после чего выполняется единая установка.

Шаг четвёртый: хук postinstall.После завершения установки срабатывает:

📎 package.json:46-46

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

simple-git-hooksСчитывает корневойpackage.jsonизsimple-git-hooksполя, записывает Git-хуки в.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-commitХук перед каждым коммитом запускает lint-staged и проверку типов,commit-msgХук проверяет формат сообщения коммита (Vue использует conventional commits). Обратите внимание на симметриюpreinstallиpostinstall: первый стоит на страже (разрешает только pnpm), второй возводит оборону (устанавливает Git-хуки).

Размышления о дизайне и подводные камни

〔Проектные допущения и архитектурные компромиссы〕

Почему используются два glob-шаблона вместо одногоpackages*/?Явное перечисление двух директорий делает семантику «публичного» и «приватного» видимой на уровне конфигурации. Любой новый разработчик, прочитавpnpm-workspace.yamlсразу поймёт, что в репозитории есть два типа пакетов. Если бы было написаноpackages*/, эта семантика была бы скрыта.

allowBuildsи безопасность цепочки поставок.Обратите внимание на этот фрагмент конфигурации:

📎 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 по умолчанию запрещает пакетам зависимостей выполнять установочные скрипты (postinstall), поскольку это распространённый вектор атак на цепочку поставок.allowBuilds— это белый список: только перечисленным пакетам разрешено запускать скрипты сборки.@swc/core、esbuildтребуется загрузить платформенно-зависимые нативные бинарные файлы,puppeteerтребуется загрузить Chromium,simple-git-hooksтребуется записать Git-хуки — всё это легитимные действия на этапе сборки, поэтому они явно разрешены.

minimumReleaseAge: 1440глубинный смысл.Эта строка конфигурации требует, чтобы новые версии зависимостей были «старше 24 часов» (1440 минут) для возможности установки:

📎 pnpm-workspace.yaml:33-33

yaml
minimumReleaseAge: 1440
〔Проектные допущения и архитектурные компромиссы〕

Это механизм периода охлаждения для защиты от отравления цепочки поставок npm. После того как злоумышленник захватывает пакет и публикует вредоносную версию, обычно в течение нескольких часов её обнаруживают и отзывают. Установка 24-часового периода охлаждения позволяет репозиторию core избежать этого окна. АminimumReleaseAgeExcludeпозволяет делать исключения для определённых патчей безопасности:

📎 pnpm-workspace.yaml:36-38

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

Комментарий явно указывает, что это обновление безопасности, инициированное Renovate, которое должно вступить в силу немедленно, поэтому период охлаждения не применяется.

---

Два. Корневой tsconfig: единые ограничения типовых границ для всех подпакетов

Интуитивная модель

Если каждый подпакет поддерживает собственный tsconfig, возникнут трещины вроде «пакет A используетstrict: false, пакет B используетstrict: true». Корневой tsconfig — этоконституция: он устанавливает общие правила типизации для всех подпакетов, подпакеты могут только дополнять его, но не нарушать.

Структуры данных и размещение в памяти

Корневойtsconfig.jsonизcompilerOptions— это фундамент всей системы типов репозитория. Выделим несколько ключевых полей:

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

Построчная расшифровка:

  • target: es2016: выходной синтаксис понижается до ES2016. Это перекликается сtargetesbuild в конфигурации Rollup (isServerRenderer || isCJSBuild ? 'es2019' : 'es2016' 📎 rollup.config.js:337-337)。
  • moduleResolution: bundler: используется стиль разрешения модулей, характерный для сборщиков, допускается опускание расширений, поддерживается полеexports.
  • strict: true: включаются все строгие проверки, включаяstrictNullChecks、noImplicitAnyи другие.
  • noUnusedLocals: true: неиспользуемые локальные переменные вызывают ошибку. Это правило имеет практический смысл в сочетании с Tree-shaking — неиспользуемые переменные часто являются сигналом мёртвого кода.
  • isolatedModules: true: требуется, чтобы каждый файл мог транслироваться независимо. Это предпосылка для таких инструментов, как esbuild/swc, которые «транслируют пофайлово и не выполняют межфайловый анализ типов».
  • isolatedDeclarations: true: требуется, чтобы все экспорты имели явную аннотацию типа. Это правило напрямую обслуживает конвейер генерации.d.ts— только явная аннотация позволяетtscбыстро генерировать файлы деклараций без полного вывода типов.
  • composite: true: включаются метаданные инкрементальной сборки, необходимые для ссылок на проекты (project references).

pathsПоле — этозеркало уровня типов:@vue/*рабочей области, отображаемое на./packages/*/src, позволяя TypeScript во время компиляции напрямую разрешать исходный код, а не симлинки вnode_modules. Это дополняет симлинки pnpm во время выполнения — во время выполнения полагаемся на pnpm, во время компиляции на paths.

Сценарный Walkthrough: одна проверка типовpnpm check

checkСкрипт — этоtsc --incremental --noEmit 📎 package.json:15-15. Подставим этот сценарий:

Шаг первый: чтение диапазона include.tsconfigincludeопределяет, какие файлы участвуют в проверке:

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

Обратите внимание, чтоscripts/*иrollup.*.jsтакже входят в область проверки. Это означает, что сами скрипты сборки также подчиняются типовым ограничениям —rollup.config.jsв верхней части// @ts-check 📎 rollup.config.js:1-1в сочетании с аннотациями типов JSDoc позволяет этому чисто JS-файлу также проверятьсяtsc.

Шаг второй: применение exclude.

📎 tsconfig.json:40-40

json
"exclude": ["packages-private/sfc-playground/src/vue-dev-proxy*"]
〔Проектные допущения и архитектурные компромиссы〕

sfc-playgroundВvue-dev-proxyфайлы

исключены. Почему? Такие файлы обычно являются динамически генерируемым во время выполнения прокси-кодом, форма их типов нестабильна, и включение их в проверку создаёт шум. --incrementalШаг третий: инкрементальная проверка.tscПозволяет.tsbuildinfoкэшировать результаты предыдущей проверки в--noEmit, повторно проверяя только изменённые файлы.

Означает только проверку без вывода — проверка типов и генерация артефактов — это два независимых конвейера.

isolatedDeclarationsРазмышления о дизайне и подводные камниЦена и выгодаexport function foo(): number. После включения этого правила любой экспорт должен иметь явную аннотацию возвращаемого типа, напримерexport function foo() { return 1 }вместо.d.ts. Это увеличивает затраты на написание, но взамен даёт значительное ускорение генерацииtsc—build-dtsможет создавать файлы деклараций без межфайлового вывода. Это перекликается с флагомtsc -p tsconfig.build.json --noCheckв скрипте--noCheck: поскольку типы уже явно аннотированы, при генерации файлов деклараций можно даже пропустить проверку.

typesГлобальная инъекция поля

📎 tsconfig.json:21-21

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

Копироватьdescribe、it、expectЭти три пакета типов внедряются глобально, что означает, что тестовые файлы могут напрямую использоватьpuppeteerбез импорта, а e2e-тесты могут напрямую использовать типы

---

III. Конфигурация Rollup: от buildOptions до унифицированной фабрики мультиформатных артефактов

Интуитивная модель

Конфигурация Rollup — это в репозитории coreсборочный цех. Ему не важно, чем занимается конкретный пакет, важно лишь «какие форматы должен производить этот пакет, где находятся входные файлы для каждого формата, какие зависимости должны быть внешними». Полеpackage.jsonвbuildOptionsкаждого подпакета — это накладная, приклеенная к посылке, и сборочный цех работает по этой накладной.

Структуры данных и размещение в памяти

Уже на входе в конфигурационный файл устанавливается модель «сборка по пакетам»:

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

Ключевые решения:TARGETПеременная окружения указывает, какой пакет собирать. Конфигурация черезfs.readdirSync('packages-private')определяет, принадлежит ли пакет к публичной или приватной директории, и тем самым решаетpkgBase. Этообнаружение директории во время выполнения— не нужно поддерживать список «какие пакеты приватные», сама структура директорий является истиной.

buildOptions— это пользовательское поле вpackage.jsonподпакета,packageOptions.filenameопределяет префикс имени файла артефакта,packageOptions.formatsопределяет формат сборки по умолчанию.

Отображение форматов в артефакты определяетсяoutputConfigs:

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

Семь форматов охватывают три сценария потребления:esm-bundlerдля потребителей вроде Vite/webpack,esm-browserдля нативного ESM в браузере,globalдля тега<script>. С суффиксом-runtime— это сборка «только runtime», доступная только для основного пакетаvue.

Сценарно-ориентированный Walkthrough: полный поток решений приpnpm build vue

Подставим выполнениеnode scripts/build.js vue.TARGET=vue, проследим решения внутриcreateConfig:

Шаг первый: определение списка форматов.

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

Приоритет: командная строкаFORMATS> подпакетbuildOptions.formats> значение по умолчанию['esm-bundler', 'cjs']。PROD_ONLYЕсли переменная окружения истинна, то непроизводственные сборки пропускаются, остаются только добавляемые позже конфигурации.prod.js.

Шаг второй: вычисление флагов сборки. createConfigВнутри по строке формата выводится набор булевых флагов:

📎 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

Эти флаги —единый источник истиныдля всех последующих решений: выбор входного файла, замена define, определение external, сборка плагинов — всё зависит от них.

Шаг третий: выбор входного файла.

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

Входной файл по умолчанию —src/index.ts, для сборки «только runtime» используетсяsrc/runtime.ts. Пакет compat (@vue/compat, то есть сборка, совместимая с Vue 2) должен предоставлять одновременно default и named экспорты, что заставляет Rollup выдавать ошибку для не-ESM целей, поэтому для сборки ESM отдельно используется входesm-index.ts / esm-runtime.ts.

Шаг четвёртый: генерация таблицы замен define. resolveDefineЗаменяет в исходном коде такие константы времени компиляции, как__DEV__、__BROWSER__, на литералы:

📎 rollup.config.js:170-201

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

Здесь есть тонкое разделение на слои:feature flags в сборке esm-bundler не хардкодятся, а сохраняются как идентификаторы вроде__VUE_OPTIONS_API__, и замену выполняет сборщик конечного пользователя. Так пользователь может черезdefine: { __VUE_OPTIONS_API__: false }отключить поддержку Options API и тем самым Tree-shake соответствующий код. А в сборках global/esm-browser эти флаги хардкодятся вtrue/false, потому что артефакты, потребляемые браузером напрямую, не проходят через сборщик.

Шаг пятый: разрешение переопределения через переменные окружения.

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

Любой ключ define можно переопределить одноимённой переменной окружения. В комментарии приведён пример__RUNTIME_COMPILE__=true pnpm build runtime-core— для отладки конкретной ветки компиляции.

Шаг шестой: сборка цепочки плагинов.

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

Порядок плагинов имеет значение:jsonсначала обрабатывает импорты JSON,aliasсопоставляет@vue/*с путями исходников,enumPluginвыполняет инлайнинг перечислений,replaceвыполняет замену строк,esbuildвыполняет транспиляцию TS. Обратите внимание, чтоesbuildвtsconfigуказывает на корневой tsconfig —все подпакеты используют одну и ту же конфигурацию типов, и это как раз проявление «конституции», обсуждавшейся во втором разделе, на этапе сборки.

Шаг седьмой: добавление производственной сборки.ЕслиNODE_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))
    }
  })
}

Для формата CJS добавляется версия.prod.js(с заменой__DEV__=false), для форматов global и esm-browser добавляется минифицированная версия (минификация через swc).packageOptions.prod === falseПакеты

могут отказаться от этого механизма.

mermaid
```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
```

Копировать

externalРазмышления о дизайне и подводные камни resolveExternalСтратегия из трёх ветвей.

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

КопироватьtreeShakenDepsБраузерные сборки (global/esm-browser) инлайнят все зависимости и указывают как external толькоdependencies, чтобы подавить предупреждения — эти зависимости в браузерной ветке фактически не используются и будут удалены Tree-shaking. Сборки Node/esm-bundler делают внешними всеpeerDependenciesи

onwarn, позволяя потребителю самому управлять версиями зависимостей.

📎 rollup.config.js:344-348

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

Копироватьruntime-coreПредупреждения о циклических зависимостях заглушаются. Междуreactivityи

treeshake.moduleSideEffects: falseв Vue существуют легальные циклические ссылки (системе реактивности нужно ссылаться на тип экземпляра компонента), эти циклы безопасны во время выполнения, поэтому они фильтруются.

📎 rollup.config.js:355-355

js
treeshake: {
  moduleSideEffects: false,
},

КопироватьЭто говорит Rollup: все модули не имеют побочных эффектов, неиспользуемые импорты можно смело удалять. Этоагрессивное допущение

— если какой-то модуль на верхнем уровне выполняет код с побочными эффектами (например, регистрирует глобальную переменную), он может быть ошибочно удалён. Исходный код Vue по соглашению гарантирует, что все модули чистые, поэтому эту оптимизацию можно включать.pure_gettersЛовушка

📎 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: trueКопироватьobj.fooговорит минификатору, что «обращение к свойству не имеет побочных эффектов», и неиспользуемые вызовы геттеров можно безопасно удалять. Для реактивного кода Vue это опасно —track()) а не через неявный побочный эффект getter, поэтому это безопасно.map: nullозначает, что sourcemap не генерируется после сжатия — производственным артефактам не нужны отладочные карты.

---

Проектное размышление: почему репозиторий исходного кода и публикуемые артефакты должны быть развязаны

Вернёмся к ключевому тезису этой главы. В инженерном проектировании репозитория core есть одна сквозная линия:Задача репозитория исходного кода — «производство», задача публикуемых артефактов — «потребление», и эти две стороны развязываются через конвейер сборки。

Конкретно это проявляется на трёх уровнях:

Во-первых, исходный код не публикуется напрямую. package.jsonвprivate: true 📎 package.json:2-2указывает, что корневой пакет никогда не публикуется. В каждом подпакетеpackage.jsonполеmain/module/exportsуказывает на артефакты вdist/, а не наsrc/. Когда пользователь устанавливаетvue, он получает собранные.jsи.d.ts, а исходный код остаётся в репозитории.

Во-вторых, формат артефактов определяется сценарием потребления.Семь форматов перечислены не произвольно, а соответствуют семи реальным путям потребления: пользователи Vite получаютesm-bundler, пользователи CDN получаютglobal, пользователи Node SSR получаютcjs. Логика выбора формата сосредоточена вrollup.config.jsв одном месте, подпакетам достаточно объявить вbuildOptions.formats, какие из них нужны.

В-третьих, типы отделены от реализации. build-dtsСкриптtsc -p tsconfig.build.json --noCheck && rollup -c rollup.dts.config.js 📎 package.json:9-9показывает, что.d.tsгенерируется отдельным конвейером.isolatedDeclarations: trueпозволяет генерации файлов деклараций пропускать проверку типов (--noCheck), поскольку типы уже явно аннотированы.

〔Проектные выводы и архитектурные компромиссы〕

Глубинная мотивация такой развязки такова:Способ организации исходного кода служит разработчикам, способ организации артефактов служит потребителям, и оптимальные решения для этих двух сторон различаются. Исходному коду нужна чёткая структура каталогов, полная информация о типах, отлаживаемые sourcemap; артефактам нужен минимальный размер, правильный формат модулей, стабильная поверхность API. Насильственное объединение обоих (например, прямая публикация исходников TS) навредит опыту обеих сторон одновременно.

---

Резюме главы

Эта глава сформировала макроскопическое понимание репозитория core по трём измерениям:

1. Двухкаталожная структура:packages/иpackages-private/физически изолированы, а в сочетании с симлинками pnpm workspace и каталогом версий catalog достигается чёткая граница между «публичными пакетами» и «приватными пакетами».preinstallшлюз,allowBuildsбелый список,minimumReleaseAgeпериод охлаждения совместно образуют линию защиты цепочки поставок.

2. Корневой tsconfig: как типовая конституция для всех подпакетов, черезpathsмаппинг реализует разрешение workspace на этапе компиляции, черезisolatedDeclarationsиcompositeподдерживает инкрементальную сборку и быструю генерацию файлов деклараций.

3. Унифицированная фабрика Rollup: сTARGETпеременной окружения в качестве входа, черезbuildOptionsчитает метаинформацию подпакетов, через набор булевых флагов управляет выбором входных точек, заменой define, определением external и сборкой плагинов, в итоге производя артефакты семи форматов.

Ключевая философия —развязка репозитория исходного кода и публикуемых артефактов: репозиторий отвечает за производство, артефакты отвечают за потребление, конвейер сборки — единственный мост между ними.

---

Переход к следующей главе

Эта глава ответила на вопрос «что такое репозиторий core». Но статическая структура репозитория — лишь сцена, настоящая драма разворачивается в процессе выполнения одного запроса на сборку:scripts/build.jsкак разбираются аргументы командной строки, как вызывается Rollup API, как обрабатываются сбои сборки и параллелизм. Следующая глава проследит сквозной путь одного запроса на сборку от входа до артефакта, превратив статическое понимание, построенное в этой главе, в динамическое представление выполнения.

Вопросы для размышления и самопроверки к этой главе

Q1: Если вpnpm-workspace.yamlизменитьminimumReleaseAge: 1440на0, какие риски это создаст в сценарии обновления зависимостей? ПочемуminimumReleaseAgeExcludeнеобходимо?

Справочный разбор:

minimumReleaseAge: 1440 📎 pnpm-workspace.yaml:33-33требует, чтобы вновь опубликованная версия зависимости была доступна для установки только спустя 24 часа. Если изменить на0, то любая только что опубликованная версия может быть немедленно подтянута.

Сценарий риска: злоумышленник захватывает какую-либо транзитивную зависимость (например,@babel/parserкакой-то patch-версии) и публикует версию с вредоносным postinstall-скриптом. В течение 24-часового периода охлаждения сообщество обычно обнаруживает проблему и отзывает эту версию; если период охлаждения равен 0, CI репозитория core может автоматически обновиться и выполнить вредоносный скрипт в окне атаки.

minimumReleaseAgeExclude 📎 pnpm-workspace.yaml:36-38существует потому, что механизм периода охлаждения конфликтует со срочностью security-патчей. В комментарииvitest@4.1.11— это обновление безопасности, обнаруженное Renovate; такие обновления должны вступать в силу немедленно, а ожидание 24 часов лишь продлевает окно экспозиции. Поэтому нужен явный список исключений, позволяющий обновлениям безопасности обходить период охлаждения. Это отражает принцип безопасного проектирования «по умолчанию консервативно, исключения явны».

Q2: rollup.config.jsВresolveDefineобработка__FEATURE_OPTIONS_API__— этоisBundlerESMBuild ? '__VUE_OPTIONS_API__' : 'true'. Если ошибочно изменить так, чтобы для всех форматов возвращалось'true', какое влияние это окажет на конечных пользователей?

Справочный разбор:

📎 rollup.config.js:192-194

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

В сборке esm-bundler__FEATURE_OPTIONS_API__сохраняется как идентификатор__VUE_OPTIONS_API__и передаётся бандлеру конечного пользователя для замены. Пользователь может в своей конфигурации сборки задатьdefine: { __VUE_OPTIONS_API__: false }, чтобы Tree-shaking удалил весь код, связанный с Options API (обработку таких опций, какdata、methods、computed), значительно уменьшив размер артефакта.

Если изменить так, чтобы для всех форматов возвращалось'true', то код Options API в артефакте esm-bundler будет жёстко зафиксирован, конфигурацияdefineпользователя перестанет работать, и Tree-shake станет невозможен. Для проекта, использующего только Composition API, это напрасно увеличит размер артефакта на несколько КБ.

Ключевое озарение этого дизайна:окончательная форма артефакта esm-bundler определяется бандлером пользователя, поэтому feature flag должен разрешаться только на этапе сборки у пользователя. А артефакты global/esm-browser выполняются непосредственно в браузере, без участия бандлера, поэтому должны быть жёстко закодированы.

Q3: rollup.config.jsизresolveExternal, браузерная сборка возвращает толькоtreeShakenDepsв качестве external, а Node-сборка возвращает всеdependencies。Предположим, однажды кто-то добавил вruntime-coreновую runtime-зависимостьfoo-lib, но забыл обновитьresolveExternalлогику. Что произойдёт в браузерной сборке?

Справочный разбор:

📎 rollup.config.js:257-283

Браузерная сборка (isGlobalBuild || isBrowserESMBuild) при!packageOptions.enableNonBrowserBranchesвозвращает толькоtreeShakenDeps(source-map-js、@babel/parser、estree-walker、entities/decode). Это означает, чтоfoo-libотсутствует в списке external,

На этом мы уже с макроуровня увидели общую философию проектирования репозитория core как инженерной основы: структура workspace с двумя каталогами задаёт границу между публичными и приватными экспериментальными пакетами, корневые конфигурации TypeScript и Rollup обеспечивают единые ограничения, а разделение исходного репозитория и публикуемых артефактов делает возможным вывод в нескольких форматах. Эти знания проложили путь для дальнейшего углубления в конкретные инженерные цепочки. В следующей главе мы переведём взгляд со статической структуры на динамический процесс и, начиная сnode scripts/build.js vue, проследим полный путь одного запроса на сборку — от разбора аргументов командной строки, определения целевого пакета и генерации конфигурации Rollup до записи артефактов на диск, — чтобы увидеть, как build.js через parseArgs разбирает флаги formats/devOnly/release, как динамически require-ит package.json целевого пакета и читает buildOptions и в итоге управляет rollup.config.js, порождая артефакты в нескольких форматах, таких как esm-bundler, cjs, global.

CHAPTER 02

Глава 2: Жизненный цикл сборки: сквозная цепочка вызовов производственной сборки

Проект: vuejs/core · Прогресс по книге: глава 2 / 14 · Статус проверки: строки FACT реально привязаны

В предыдущей главе мы прояснили позиционирование репозитория core как инженерной основы и то, как pnpm workspace и корневые конфигурации единообразно ограничивают все подпакеты. Теперь мы углубимся в ядро системы сборки и проследим, как одна команда приводит в движение весь процесс сборки.node scripts/build.js vueКазалось бы, простой, но он является единственной точкой входа для всех артефактов — esm-bundler, cjs, global. Понимание того, как он переводит намерение пользователя в исполняемые задачи сборки, — ключевой шаг к освоению механизма сборки Vue.

Генерация конфигурации Rollup: от переменных окружения к артефактам в нескольких форматах

build.jsчерезexecПосле запуска Rollup управление передаётся вrollup.config.js。Этот файл — «мозг» системы сборки: он читает переменные окружения и динамически генерирует массив объектов конфигурации Rollup.

Проверка переменных окружения и определение пакета

📎 rollup.config.js:27-29

ЕслиTARGETне установлена, сразу выбрасывается ошибка. Это защитное программирование: конфигурация Rollup может быть вызвана напрямую (например,rollup -c), и в этом случаеbuild.jsвнедряет переменные окружения, необходимо быстро завершить работу с ошибкой.

📎 rollup.config.js:32-44

Здесь повторяетсяbuild.jsлогика определения приватных пакетов в — посколькуrollup.config.jsявляется независимым процессом и не может совместно использоватьbuild.jsсостояние памяти.resolveФункция преобразует относительный путь в абсолютный путь внутри каталога пакета,pkg— это целевого пакетаpackage.jsonсодержимое,packageOptionsявляется одним изbuildOptionsполей,name— это префикс имени файла артефакта (предпочтительно использоватьbuildOptions.filename, иначе использовать имя каталога).

Таблица сопоставления форматов:outputConfigs

📎 rollup.config.js:58-88

Эта таблица определяет сопоставление 7 форматов с конфигурациями вывода. Ключевые наблюдения:

  • esm-bundler、esm-browser、esm-bundler-runtime、esm-browser-runtimeоба являютсяformat: 'es', разница только в имени файла.
  • cjsэтоformat: 'cjs'。
  • globalиglobal-runtimeэтоformat: 'iife'(немедленно вызываемое функциональное выражение), подходит для<script>прямой импорт через тег.
  • runtimeФормат с суффиксом имеет смысл только для основногоvueпакета — они не содержат компилятор и имеют меньший размер.

Выбор формата: три уровня приоритета

📎 rollup.config.js:91-92

Выбор формата следует трём уровням приоритета: командная строкаFORMATSПеременные окружения > пакетныеbuildOptions.formats> по умолчанию['esm-bundler', 'cjs']。PROD_ONLYПеременная окружения управляет тем, пропускать ли базовую конфигурацию — если собирать только production-версию, массив базовых конфигураций пуст, и далее добавляются только production-конфигурации.

Логика добавления production-конфигурации

📎 rollup.config.js:97-114

КогдаNODE_ENV === 'production', для каждого формата:

  • ЕслиpackageOptions.prod === false, пропустить (этому пакету не нужна production-версия).
  • Если этоcjs, добавитьcreateProductionConfig—— генерация.prod.jsфайла.
  • Если соответствует/^(global|esm-browser)(-runtime)?/, добавитьcreateMinifiedConfig—— генерация сжатой версии.
〔Проектные предположения и архитектурные компромиссы〕

Почемуcjsс помощьюcreateProductionConfigаglobal/esm-browserс помощьюcreateMinifiedConfig?Потому что CJS предназначен для Node, а среда Node не требует сжатия (пользователь сам с этим справится), но требует различать ветки dev/prod; а артефакты, напрямую подключаемые в браузере, обязательно должны быть сжаты для уменьшения размера. Это различие отражается в реализации двух фабричных функций.

createConfig:ядро генерации конфигурации

createConfigявляется самой большой функцией, она принимает формат и выходную конфигурацию, возвращает полный объект конфигурации Rollup.

📎 rollup.config.js:125-142

В начале вычисляется ряд булевых флагов:

  • isProductionBuild: через__DEV__переменную окружения или содержит ли имя файла.prod.jsопределение.
  • isBundlerESMBuild、isBrowserESMBuild、isCJSBuild、isGlobalBuild: сопоставление по регулярному выражению имени формата.
  • isServerRenderer: является ли имя пакетаserver-renderer。
  • isCompatPackage、isCompatBuild: связано со сборкой для совместимости с Vue 2.
  • isBrowserBuild: глобальная сборка или браузерная ESM-сборка, при этом не включена небраузерная ветка.

Эти флаги в дальнейшемresolveDefine、resolveReplace、resolveExternalмногократно используется в , что является ключевым основанием для дифференциации конфигурации.

📎 rollup.config.js:144-157

Базовые настройки конфигурации вывода: banner с копирайтом、exportsрежим (для compat-пакета используетсяauto, для остальных —named), в CJS-сборке включаетсяesModuleвзаимная совместимость, sourcemap управляется переменными окружения,externalLiveBindings: falseиreexportProtoFromExternal: false— это настройки совместимости Rollup 4. Глобальная сборка дополнительно задаётoutput.name,то есть имя переменной, под которым она монтируется вwindow.

Выбор входного файла

📎 rollup.config.js:159-168

По умолчанию вход —src/index.ts, ноruntimeформат суффикса используетsrc/runtime.ts. ESM-сборка пакета compat должна одновременно экспортировать default и named, поэтому используется отдельнаяesm-index.ts / esm-runtime.tsточка входа.

Определения макросов:resolveDefine

📎 rollup.config.js:170-218

resolveDefineВозвращает таблицу замен, которая в исходном коде__COMMIT__、__VERSION__、__BROWSER__и другие макросы заменяются на литералы. Эти макросы используются в исходном коде для условной компиляции — например,if (__DEV__) { ... }в production-сборке заменяется наif (false) { ... }, а затем удаляется через Tree-shaking.

Ключевые решения:__FEATURE_OPTIONS_API__、__FEATURE_PROD_DEVTOOLS__и другие флаги функций вesm-bundlerсборке сохраняются как__VUE_OPTIONS_API__такие идентификаторы, чтобы конечные пользователи могли переопределить их через конфигурацию бандлера; в других сборках они жёстко заданы какtrueилиfalse。

📎 rollup.config.js:203-206

неesm-bundlerсборка жёстко задаёт__DEV__, поскольку их ветки dev/prod определяются на этапе сборки.

📎 rollup.config.js:210-216

Последний шаг позволяет переменным окружения переопределять любое определение макроса, поддерживая__RUNTIME_COMPILE__=true pnpm build runtime-coreтакую встроенную замену.

Плагин замены:resolveReplace

📎 rollup.config.js:222-255

resolveReplaceОбрабатывает внеresolveDefineзамены, которые esbuild не может обработать:

  • ОбъединяетenumDefines(встроенные определения перечислений изinlineEnums).
  • В production-сборке для браузера добавляет к функциям создания ошибок/*@__PURE__*/аннотацию, помогающую Tree-shaking.
  • esm-bundlerВ сборке__DEV__заменяется на!!(process.env.NODE_ENV !== 'production'), позволяя бандлеру решить.
  • В ESM-сборке для браузераprocess.envзаменяется на пустой объект, чтобы избежать ошибок в браузере.

Внешние зависимости:resolveExternal

📎 rollup.config.js:257-283

Это ядро вопроса для размышления в конце предыдущей главы. Сборка для браузера возвращает толькоtreeShakenDepsкак external — эти зависимости хотя и импортируются, но в ветке для браузера фактически не выполняются; они перечислены здесь только для подавления предупреждений Rollup. Сборки Node/ESM-bundler выносят всеdependenciesиpeerDependencies, а такжеpath、url、streamи другие встроенные модули Node.

Итоговый объект конфигурации

📎 rollup.config.js:319-352

Возвращаемый объект конфигурации содержит:

  • input: абсолютный путь к входному файлу.
  • external: список внешних зависимостей.
  • plugins: массив плагинов в порядке json → alias → enumPlugin → replace → esbuild → nodePlugins.
  • output: конфигурация вывода.
  • onwarn: отфильтровываетCIRCULAR_DEPENDENCYпредупреждения (в исходном коде Vue есть циклические зависимости, но они безвредны во время выполнения).
  • treeshake.moduleSideEffects: false: сообщает Rollup, что все модули не имеют побочных эффектов, агрессивный Tree-shaking.

На следующей диаграмме показан поток данных от переменных окружения до итоговой конфигурации:

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 записываются на диск"]

Запись артефактов на диск и проверка размера

execУправление процессами

build.jsчерезexecзапускает дочерний процесс Rollup:

📎 scripts/utils.js:64-114

execинкапсулируетspawn, возвращая Promise. Ключевые решения:

  • stdioпо умолчанию['ignore', 'pipe', 'pipe']— stdin игнорируется, stdout/stderr захватываются через каналы.
  • shell: process.platform === 'win32'— в Windows требуется shell для корректного разбора команды.
  • черезstderrChunksиstdoutChunksмассивы собирают вывод, объединяя его вexitсобытии.
  • При коде выхода 0 — resolve, иначе reject с содержимым stderr.
〔Проектные выводы и архитектурные компромиссы〕

Обратите внимание:build.jsпри вызовеexecпередаётся{ stdio: 'inherit' }, что переопределяет конфигурацию каналов по умолчанию, позволяя выводу Rollup напрямую транслироваться в терминал. Это правильное поведение инструмента сборки — пользователю нужно видеть прогресс сборки в реальном времени.

Проверка размера:checkAllSizes

📎 scripts/build.js:206-215

Проверка размера имеет два условия пропуска:devOnlyистинно, или указан формат, но не содержитglobal. Поскольку проверка размера применяется только к артефактам глобальной сборки — это файлы, которые конечные пользователи подключают напрямую, и их размер наиболее критичен.

📎 scripts/build.js:222-228

checkSizeПроверяет два файла:${target}.global.prod.jsи${target}.runtime.global.prod.js(последний проверяется только если формат не указан или указанglobal-runtime).

📎 scripts/build.js:235-264

checkFileSizeчитает файл, используетgzipSyncиbrotliCompressSyncдля вычисления размера после сжатия, используетprettyBytesдля форматированного вывода. ЕслиwriteSizeистинно, записывает результат вtemp/size/${fileName}.json— это источник данных для проверки бюджета размера в CI.

Сборка объявлений типов

📎 scripts/build.js:94-108

ЕслиbuildTypesистинно, вызываетсяpnpm run build-dts, и через--environment TARGETS:...передаётся список целей. Это гарантирует генерацию объявлений типов только для фактически собираемых пакетов.

Проектные соображения и подводные камни в production

Почему используется--environmentвместо прямой передачи аргументов?Rollup--environment— единственный способ передачи аргументов, который можно прочитать в файле конфигурации черезprocess.env. Прямая передача--configаргументов требует разбораprocess.argv, а--environmentпредоставляет структурированный разбор пар ключ-значение.

fuzzyMatchTargetЛовушка регулярных выражений в target.match(partialTarget)ВpartialTarget— пользовательский ввод. Если пользователь вводитruntime-core,-, в регулярном выражении это литерал, проблем нет; но если вводитсяruntime.core,., оно будет соответствовать любым символам и может совпасть с неожиданными целями. Это неотъемлемый риск нечёткого сопоставления, но имена пакетов Vue не содержат специальных символов регулярных выражений, так что на практике это не срабатывает.

Конкуренция за ресурсы при параллельной сборке. runParallelиспользуетcpus().lengthкак ограничение параллелизма, но каждый процесс Rollup сам запускает worker'ы. В контейнерах CI с малым числом ядер это может привести к переполнению памяти. В production при возникновении OOM можно смягчить проблему через--max-old-space-sizeили уменьшение числа параллельных задач.

scanEnumsЖизненный цикл кэша removeCacheвызывается вfinally, но еслиscanEnumsсам выбрасывает ошибку,removeCacheне будет присвоен, и вызов вfinallyзавершится неудачей. ФактическиscanEnumsвозвращаемая функция определяется доtry, так что этого риска не существует — но это деталь порядка выполнения, которую нужно подтвердить при чтении.

resolveExternalРиск пропуска вВопрос для размышления из предыдущей главы уже указал: если добавить новую зависимость вruntime-coreно забыть обновитьresolveExternal, сборка для браузера включит эту зависимость в бандл (поскольку её нет в списке external), что приведёт к раздуванию размера. Это неотъемлемая цена стратегии «белого списка external».

Резюме главы

Полный путь одногоnode scripts/build.js vue:

1. parseArgsразбирает командную строку,commitсинхронно получает.

2. run()вызываетscanEnumsдля генерации кэша перечислений, разбирает цели (fuzzyMatchTargetилиallTargets)。

3. buildAllчерезrunParallelпараллельно планируетbuild。

4. buildнаходит каталог пакета, читаетpackage.json, фильтрует приватные пакеты, очищаетdist, собирает--environmentаргументы, вызываетexecЗапуск Rollup.

5. rollup.config.jsЧтение переменных окружения черезcreateConfigГенерация массива конфигураций,resolveDefine/resolveReplace/resolveExternalОтдельная обработка макросов, замен и внешних зависимостей.

6. Rollup выполняет сборку, артефакты записываются на диск вdist/。

7. checkAllSizesВычисление размера gzip/brotli, опциональная запись вtemp/size/。

8. Если--withTypes, вызовbuild-dtsГенерация объявлений типов.

Вопросы для размышления и самопроверки в этой главе

Q1: Вbuild.jsфункцииbuildизif (!formats && fs.existsSync(...))это условие определяет, удалять лиdistкаталог. Если убрать!formatsэто условие (то есть удалятьdistнезависимо от указанного формата), что произойдёт вpnpm build-all-cjsтаком скрипте?

Справочный разбор:

📎 scripts/build.js:172-175

pnpm build-all-cjsсоответствуетnode scripts/build.js vue runtime compiler reactivity shared -af cjs(см.📎 package.json:40). Оно указывает-f cjs, поэтомуformatsравно'cjs',!formatsложно, текущая логика не удалитdist。

Если убрать!formats, каждая сборка будет удалятьdist. Ноbuild-all-cjsсобирает толькоcjsформат, после удаления вdistостанутся толькоcjsартефакты, ранее собранныеesm-bundler、globalи другие форматы будут полностью потеряны. Что ещё серьёзнее,build-runtime-esm、build-browser-esmи другие скрипты выполняются последовательно (см.📎 package.json:39скриптbuild-sfc-playground), каждый скрипт удаляет артефакты предыдущего, в результате в итоговомdistостанется только формат последнего скрипта. Это нарушит сборку SFC Playground — ей требуется одновременное наличие артефактов нескольких форматов.

Q2: runParallelВif (maxConcurrency <= source.length)какова роль условияtargets.length === 1? Если убрать его, что произойдёт при сборке одного пакета (

)?:

📎 scripts/build.js:131-151

Справочный разборmaxConcurrency > source.lengthЭто условие управляет включением ограничения параллелизма. Когдаexecuting, ограничение не нужно — все задачи могут запускаться одновременно. Если убрать это условие, даже при одной задаче будет созданawait Promise.race(executing)。

массив и выполненоexecutingДля одной задачиe,Promise.raceсодержит только один Promiseexecuting.splice(executing.indexOf(e), 1), который будет ждать его завершения. Это не вызовет ошибки, но добавит ненужную цепочку Promise и накладные расходы на планирование микрозадач. Что важнее,

в сценарии с одной задачей всё равно работает корректно, так что функционально разницы нет, только небольшое снижение производительности.maxConcurrencyНастоящий риск в том, что еслиcpus().lengthравно 0 (теоретически невозможно, так какexecuting.length >= 0минимум 1),Promise.race([])всегда истинно,cpus().lengthбудет вечно висеть. Но

Q3: resolveExternalгарантирует, что эта граница не сработает.treeShakenDepsВ

браузерная сборка возвращает:

📎 rollup.config.js:257-283

treeShakenDepsкак external, но эти зависимости в браузерной ветке фактически не выполняются. Что произойдёт, если убрать их из списка external (то есть позволить Rollup попытаться упаковать их)?source-map-js、@babel/parser、estree-walker、entities/decodeСправочный разборcompiler-sfcсодержит__BROWSER__. Это зависимости таких пакетов, как

, в браузерной сборке они исключаются условной компиляцией черезtreeshake.moduleSideEffects: false(📎 rollup.config.js:355-355макрос.if (!__BROWSER__)Если убрать из external, Rollup попытается разрешить и упаковать эти зависимости. Поскольку__BROWSER__), и операторы импорта этих зависимостей находятся вtrueветке, define от esbuild заменит

наonwarn, что пометит ветку как мёртвый код. Tree-shaking в Rollup удалит эти импорты, и в итоговом артефакте не будет кода этих зависимостей.

Но проблема в том, что Rollup перед Tree-shaking должен сначала разрешить модули. Если эти зависимости не установлены (например, в минималистичном CI-окружении), Rollup выдаст ошибку «не удалось разрешить модуль». Указание их как external — это защитная мера: даже если зависимости отсутствуют, Rollup не будет пытаться их разрешить, а лишь выдаст предупреждение (аscripts/dev.jsотфильтрует предупреждения о нециклических зависимостях).

CHAPTER 03

Глава 3: Скрипты разработки и протокол прекомпиляции SFC

Следующая глава: Глава 3 → · Глава 3: Цепочка в режиме разработки: механизм взаимодействия dev-скрипта и предварительной компиляции SFC · Проект: vuejs/core

Прогресс книги: Глава 3 / 14scripts/dev.jsСтатус проверки: FACT — номера строк реально привязаныscripts/pre-dev-sfc.jsВ предыдущей главе мы проследили полный путь производственной сборки от разбора аргументов до записи артефактов в нескольких форматах; тот путь нацелен на полноту и соответствие стандартам артефактов. А основное требование режима разработки только одно: изменить строку кода — и сразу увидеть эффект в браузере. Цепочка производственной сборки «разбор аргументов → генерация конфигурации → полная упаковка → запись на диск» занимает десятки секунд и совершенно не удовлетворяет этому требованию. Репозиторий Vue core поддерживает для этого отдельную цепочку в режиме разработки:

использует режим watch esbuild для инкрементальной сборки,

предварительно компилирует компилятор SFC перед основной сборкой. В этой главе разбирается механизм взаимодействия этих двух компонентов.

3.1 dev.js: инкрементальный сборщик, меняющий скорость на esbuild📎 scripts/dev.js:3-5

Интуитивная модель

Производственная сборка похожа на «официальный набор и печать в типографии» — качество в приоритете, можно и помедленнее; сборка для разработки похожа на «карандашный набросок на черновике» — не нужна красота, нужно мгновенное появление. Vue выбирает esbuild, а не Rollup, для этого наброска, и причина написана в комментарии в начале файла: артефакты Rollup меньше, Tree-shaking лучше, но esbuild намного быстрее.

Без этого скрипта разработчику при каждом изменении пришлось бы запускать полную производственную сборку, цикл обратной связи деградировал бы с миллисекунд до минут, а опыт горячей замены был бы полностью утрачен.parseArgsРазбор аргументов и вывод форматовformatТочка входа скрипта использует встроенный в Nodeglobal)、prodдля разбора трёх опций:false)、inline(по умолчаниюfalse)。📎 scripts/dev.js:18-40позиционные аргументы собираются вtargets, если пусто, то по умолчанию['vue']。📎 scripts/dev.js:42-53

〔Проектные предположения и архитектурные компромиссы〕

Здесь есть легко упускаемая деталь:rawFormatиformat— это два присваивания.parseArgsвdefault: 'global'уже гарантирует, чтоrawFormatимеет значение, но скрипт всё равно пишетconst format = rawFormat || 'global'как запасной вариант.📎 scripts/dev.js:42Это защитное написание, позволяющее избежатьparseArgsошибок в downstreamformat.startsWithпри изменении поведения или явной передаче пустой строки.

formatСопоставление с форматом вывода esbuild — это трёхветвевое ветвление: начинается сglobalсопоставляется сiife, равноcjsсопоставляется сcjs, всё остальное —esm。📎 scripts/dev.js:42-53Суффикс имени файла артефакта обрабатывается отдельно суффиксом-runtime:global-runtimeпревращается вruntime.global, остальные остаются без изменений.📎 scripts/dev.js:42-53

Определение целевого пакета и путь вывода

Скрипт сначала читает список каталоговpackages-private, чтобы определить, принадлежит ли целевой пакет к публичным или приватным пакетам.📎 scripts/dev.js:56Для каждого target определяется, является ли базовый путь пакетаpackagesилиpackages-private, затемrequireегоpackage.jsonдля полученияversionиbuildOptions。📎 scripts/dev.js:58-63

Есть особый случай с именем выходного файла:vue-compatцель будет переименована вvue, чтобы избежать артефакта с именемvue-compat.global.js。📎 scripts/dev.js:64-69Итоговый путь имеет видpackages/vue/dist/vue.global.js,prodпри значении true вставляется сегментprod..

Разрешение external: избегание упаковки зависимостей в артефакт

externalМассив определяет, какие модули не упаковываются. Логика разделена на два уровня:

Первый уровень: когдаinlineне включён и форматcjsили содержитesm-bundler, все ключиdependencies、peerDependenciesдобавляются в external, и жёстко кодируютсяpath、url、streamтри встроенных модуля Node.📎 scripts/dev.js:76-88Комментарий явно указывает, что эти три предназначены для@vue/compiler-sfcиserver-renderer.

Второй уровень: для целиcompiler-sfcдополнительно разрешается@vue/consolidateизdevDependencies, они, а такжеfs、vm、cryptoи другие, помечаются как external.📎 scripts/dev.js:90-112В коде также жёстко закодированы пути к шаблонизаторам, таким какreact-dom/server、teacup/lib/express、arc-templates/dist/es5、then-pug、then-jade— это шаблонизаторы, поддерживаемые consolidate, они являются опциональными зависимостями и не могут быть принудительно установлены.

〔Проектные предположения и архитектурные компромиссы〕

Эта логика в значительной степени дублируетrollup.config.js, что признаётся и в комментариях исходного кода (TODO this logic is largely duplicated from rollup.config.js). Причина, по которой не была выделена общая функция, заключается в тонких различиях в стратегии external между dev и prod (dev более агрессивно выносит в external для ускорения сборки), и принудительное объединение, наоборот, увеличило бы связанность.

Плагины и внедрение define

Массив плагинов по умолчанию содержит только одинlog-rebuild, который в хукеonEndвыводит относительный путь артефакта сборки.📎 scripts/dev.js:115-124Это единственный сигнал обратной связи, позволяющий разработчику понять, что «изменения вступили в силу».

〔Проектные предположения и архитектурные компромиссы〕

Второй плагин является условным: когда формат неcjsиbuildOptions.enableNonBrowserBranchesпакета истинно, подключаетсяpolyfillNode()。📎 scripts/dev.js:126-128Такие пакеты (например,compiler-sfc) в браузерной сборке всё ещё используют ветку Node и требуют полифилов встроенных модулей Node для работы в браузерной среде.

defineБлок является наиболее информационно насыщенной частью этой главы.📎 scripts/dev.js:141-159Он заменяет все макросы__XXX__в исходном коде на литералы:

  • __COMMIT__фиксируется как"dev",__VERSION__берётся из версии пакета;
  • __DEV__определяется флагомprod,__TEST__всегдаfalse;
  • __BROWSER__Выводformat !== 'cjs' && !pkg.buildOptions?.enableNonBrowserBranches。📎 scripts/dev.js:146-148наиболее тонок: то есть только «не cjs и пакет не поддерживает небраузерную ветку» помечается как браузерная среда;
  • __SSR__равноformat !== 'global', то есть глобальная сборка не включает ветку SSR;
  • __COMPAT__определяется тем, является ли targetvue-compat;
  • три feature flag (__FEATURE_SUSPENSE__、__FEATURE_OPTIONS_API__、__FEATURE_PROD_DEVTOOLS__、__FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) в режиме dev все жёстко заданы.

Эти макросы соответствуют блокуvitest.config.tsвdefineодин к одному.📎 vitest.config.ts:6-21Тестовая среда устанавливает__TEST__вtrue、__DEV__устанавливаетtrue, и разница с dev-сборкой как раз и является точкой разграничения двух режимов работы: «тестирование vs разработка».

Запуск режима watch

Последний шаг —esbuild.context(...).then(ctx => ctx.watch())。📎 scripts/dev.js:130-161 contextсоздаёт контекст сборки, но не выполняет его немедленно,watch()только тогда действительно запускается отслеживание файлов. После этого esbuild внутренне поддерживает граф зависимостей, любое изменение отслеживаемого файла вызывает инкрементальную пересборку, и по завершении пересборки вызываетсяonEndдля вывода лога.

mermaid
flowchart TD
    start["parseArgs разбирает format/prod/inline"] --> targets{"positionals пуст?"}
    targets -->|да| def["targets = ['vue']"]
    targets -->|нет| use["targets = positionals"]
    def --> loop["перебор каждого target"]
    use --> loop
    loop --> priv{"target в packages-private?"}
    priv -->|да| pbase["pkgBase = packages-private"]
    priv -->|нет| pub["pkgBase = packages"]
    pbase --> req["require package.json"]
    pub --> req
    req --> ext{"inline включён?"}
    ext -->|да| noext["external = []"]
    ext -->|нет| fmt{"format — cjs или esm-bundler?"}
    fmt -->|да| deps["добавить dependencies/peerDependencies + path/url/stream"]
    fmt -->|нет| sfc{"target == compiler-sfc?"}
    deps --> sfc
    sfc -->|да| cons["добавить consolidate devDeps + fs/vm/crypto"]
    sfc -->|нет| noext
    cons --> ctx["esbuild.context создаёт контекст"]
    noext --> ctx
    ctx --> watch["ctx.watch() запускает наблюдение"]
    watch --> onend["onEnd выводит built: относительный путь"]

3.2 pre-dev-sfc.js: предкомпиляционный страж, разрубающий циклические зависимости

Интуитивная модель

Представьте дилемму «курица и яйцо»:compiler-sfcВ исходном кодеcompiler-coreимпортируетcompiler-core, аcompiler-sfcв режиме разработки нуждается в.vueдля обработки файловpre-dev-sfc.js. Если оба полагаются на компиляцию в реальном времени через esbuild watch, кто скомпилируется первым, тот и застрянет.

Роль

— «сначала высидеть яйцо, потом вырастить курицу» — перед запуском основной сборки убедиться, что CJS-артефакты этих пакетов уже существуют.compiler-sfc、compiler-core、compiler-dom、compiler-ssr、shared。📎 scripts/pre-dev-sfc.js:4-10Контрольный список и логика короткого замыканияpackages/${pkg}/dist/${pkg}.cjs.jsСкрипт поддерживает фиксированный список:📎 scripts/pre-dev-sfc.js:4-23

Для каждого пакета проверяется наличиеallFilesPresent.falseЕсли хотя бы один отсутствует,breakустанавливается в📎 scripts/pre-dev-sfc.js:20-21и немедленноallFilesPresent, остальные пакеты не проверяются.process.exit(1)В конце, если📎 scripts/pre-dev-sfc.js:25-27

ложно,

завершается с ненулевым кодом.exit(1)Семантика кода выхода&&Этот скрипт сам по себе не выполняет никакой компиляции, он только делает «утверждение о существовании».

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

в npm script или CI-скрипта): артефакты неполны, нужно сначала запустить полную сборку. Если всё существует, происходит нормальный выход (код выхода 0), и основная сборка продолжается.

scripts/dev.jsКопироватьscripts/aliases.js3.3 aliases.js и vitest.config.ts: другая половина цепочки разработки📎 scripts/aliases.js:7-7

Решает вопрос «как быстро генерируются артефакты», но во время разработки есть ещё один путь: запуск тестов.

resolveEntryForPkgПредоставляет общие псевдонимы путей для vitest и rollup.packages/${p}/src/index.ts。📎 scripts/aliases.js:7-7Логика генерации псевдонимовvue、vue/compiler-sfc、vue/server-renderer、@vue/compat。📎 scripts/aliases.js:16-21

Сопоставляет имена пакетов сpackagesБазовые entries жёстко кодируют четыре специальных сопоставления:vueЗатем перебираются все подкаталоги в каталогеnonSrcPackages(sfc-playground、template-explorer、dts-test, пропускается сам@vue/${dir}, пропускается📎 scripts/aliases.js:23-35

), пропускаются уже существующие ключи, и только если это каталог, добавляется сопоставление

.nonSrcPackagesСписок исключений потому, что эти три пакета не имеютsrc/index.tsточки входа, принудительное сопоставление приведёт к ошибке разрешения.

define и потребление псевдонимов в vitest

vitest.config.tsпрямой импортentriesв качествеresolve.alias。📎 vitest.config.ts:3📎 vitest.config.ts:22-24егоdefineблоки и инъекция макросов dev.js образуют контраст: тестовая среда__DEV__: true、__TEST__: true、__BROWSER__: false、__CJS__: true。📎 vitest.config.ts:6-21

тесты разделены на пять проектов:unit、unit-gc、unit-jsdom、e2e、e2e-browser。📎 vitest.config.ts:51-118среди нихunit-gcиспользуетpool: 'forks'и передаёт--expose-gc, специально для запуска SSR-тестов, требующих ручного запуска GC.📎 vitest.config.ts:65-76 e2e-browserвключает экземпляр chromium playwright для запуска тестов, связанных с 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

Размышления о дизайне

Почему dev использует esbuild, а prod — Rollup?Это не случайность технического выбора, а разные ограничения двух сценариев. В режиме разработки размер артефактов не критичен, но задержка обратной связи крайне важна; в production — наоборот. esbuild написан на Go, высоко параллелизирован, холодный старт и инкрементальная сборка быстрее на порядок, но его возможности Tree-shaking и разделения кода слабее, чем у Rollup.📎 scripts/dev.js:3-5Использование двух наборов инструментов для двух сценариев — это прагматичный инженерный компромисс.

〔Проектные выводы и архитектурные компромиссы〕

Почему pre-dev-sfc только проверяет, но не компилирует?Если бы он сам запускал компиляцию, то снова вернул бы циклическую зависимость — ему нужно компилироватьcompiler-sfc, а сам процесс компиляции может зависеть отcompiler-sfcартефактов. Поэтому он может только выполнять «утверждение», выставляя факт «отсутствия артефакта» на верхний уровень, который решает, запускать полную сборку или завершиться с ошибкой. Это «режим часового»: не решает проблему, а только сообщает о ней.

Является ли дублирование списка external техническим долгом?Логика external в dev.js и rollup.config.js дублируется, и комментарии в исходном коде это признают.📎 scripts/dev.js:73Но наборы external у них не полностью совпадают — dev для скорости более агрессивно выносит в external. Принудительное выделение общей функции потребовало бы введения параметризованного переключателя различий, что сделало бы обе части логики ещё менее читаемыми. Это типичный компромисс «дублирование лучше ошибочной абстракции».

Резюме главы

В этой главе разобраны три части мозаики цепочки режима разработки Vue core:

1. scripts/dev.js: используетcontext().watch()esbuild для реализации инкрементальной сборки, черезparseArgsразрешает форматы и флаги, динамическиrequireцелевой пакетpackage.jsonопределяет путь вывода, внедряет__DEV__、__BROWSER__и другие макросы для управления условной компиляцией, и используетlog-rebuildплагин для вывода обратной связи после каждой пересборки.

2. scripts/pre-dev-sfc.js: перед основной сборкой проверяет наличие CJS-артефактов пяти ключевых пакетов, при отсутствии завершается с кодом 1, чтобы избежать взаимоблокировки сборки из-за циклических зависимостей.

3. scripts/aliases.js + vitest.config.ts: предоставляет общие псевдонимы путей для тестовой цепочки, жёстко кодирует специальные элементы и динамически сканирует общие, в сочетании с многопроектной конфигурацией охватывает пять тестовых сценариев: unit, GC, jsdom, e2e, браузерный e2e.

Вопросы для размышления и самопроверки к главе

Q1: Если убратьscripts/pre-dev-sfc.jsвbreak(то есть проверять все пакеты, а потом решать о выходе), в каких сценариях это ухудшит опыт разработчика? Почему автор исходного кода выбрал «короткое замыкание при обнаружении первого отсутствующего»?

Справочный разбор:

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

breakнаходится внутри веткиif (!fs.existsSync(...)), при обнаружении отсутствия артефакта какого-либо пакета немедленно выходит из цикла.

Если убратьbreak, скрипт продолжит проверять остальные пакеты, в итогеallFilesPresentвсё равно будетfalse, код выхода всё равно 1,функционально эквивалентно. Но разница в следующем:

1. Производительность: пять вызововexistsSyncсами по себе быстры, но если список расширится до десятков пакетов, короткое замыкание сэкономит массу лишних системных вызовов stat.

2. Семантика: короткое замыкание выражает «если отсутствует хотя бы один, целое неполно» — это булево утверждение, не нужно знать, сколько именно отсутствует. Продолжение проверки не даёт дополнительной информации.

3. Опыт разработчика: на самом деле ухудшается «сообщение об ошибке». Текущий скрипт не печатает, какой пакет отсутствует, разработчик видит только код выхода 1. Если убратьbreakи добавить логирование, можно было бы сообщить разработчику «отсутствуют compiler-core и shared» — но это требует дополнительного кода. Автор выбрал минимальную реализацию, оставив диагностику «чего не хватает» на ошибки верхнего скрипта сборки.

Поэтомуbreakключевая мотивация — «семантика утверждения + производительность», а не оптимизация опыта.

Q2: scripts/dev.jsВ__BROWSER__выводformat !== 'cjs' && !pkg.buildOptions?.enableNonBrowserBranches. Предположим, у некоторого пакетаbuildOptions.enableNonBrowserBranchesравноtrue, и разработчик собирает с помощью-f global, тогда__BROWSER__равноfalse. Каковы последствия? Что будет, если ошибочно изменить наtrue?

Справочный разбор:

📎 scripts/dev.js:146-148

Когдаformat = 'global'иenableNonBrowserBranches = true:

  • format !== 'cjs'равноtrue
  • !pkg.buildOptions?.enableNonBrowserBranchesравноfalse
  • В целом__BROWSER__ = false

Это означает, что все веткиif (__BROWSER__)в исходном коде заменяются define esbuild наif (false), браузерный код удаляется Tree-shaking, небраузерные ветки (логика только для Node) сохраняются.

Последствия: артефакт global-сборки должен работать в браузере, но содержит ветки только для Node. Если эти ветки ссылаются на встроенные модули Node, такие какfs、path, при загрузке в браузере возникнет ошибка «модуль не определён». Именно поэтому пакеты, у которыхenableNonBrowserBranchesистинно (например,compiler-sfc), обычно не используются для global-сборки или требуютpolyfillNode()плагина для подстраховки.📎 scripts/dev.js:126-128

Если ошибочно изменить наtrue:__BROWSER__ = true, браузерные ветки сохраняются, Node-ветки удаляются. Дляcompiler-sfcтакого пакета, который обязан запускать компиляцию SFC в среде Node, это приведёт к удалению Tree-shaking основной функциональности (чтение файлов, вызов Node API), и артефакт при запуске в Node выдаст ошибку «функция не определена».

Q3: scripts/aliases.jsВpackagesпри динамическом сканировании каталогаnonSrcPackages(sfc-playground、template-explorer、dts-testпропускаетсяpackages). Если новый пакет добавлен в каталогsrc/index.ts, и не был добавлен вnonSrcPackages, что произойдёт? На каком этапе vitest во время выполнения выдаст ошибку?

Справочный разбор:

📎 scripts/aliases.js:23-35

Логика динамического сканирования такова: для каждого каталога, еслиdir !== 'vue', отсутствует вnonSrcPackages, ключ ещё не существует и это каталог, то добавляется вentries['@vue/${dir}'] = resolveEntryForPkg(dir)。

resolveEntryForPkgвозвращает путьpackages/${p}/src/index.ts.📎 scripts/aliases.js:7-7Обратите внимание, что онне проверяет существование файла, а просто конкатенирует путь.

Последствие: псевдоним будет зарегистрирован, но указывает на несуществующий файл. Когда vitest разрешает import, если какой-либо тестовый файл импортирует этот пакет, плагин resolve в Vite попытается загрузить этот путь и сообщит «не удалось разрешить модуль» или «файл не существует».

Этап ошибки: не во время выполненияaliases.js(он выполняет только конкатенацию строк), а после запуска vitest, при первом разрешении этого import. Если ни один тест не импортирует этот пакет, ошибки не будет — псевдоним просто лежит в объектеentries.

Способ обхода: добавить такие пакеты безsrc/index.tsвnonSrcPackages, или убедиться, что у нового пакета есть стандартная точка входа. Именно поэтомуnonSrcPackagesнужно поддерживать вручную — это список исключений из принципа «соглашение важнее конфигурации».

Границы взаимодействия этих трёх компонентов очень чёткие:pre-dev-sfcуправляет тем, «готов ли артефакт»,dev.jsуправляет тем, «как быстро обновляется артефакт»,aliasesуправляет тем, «как тесты разрешают исходный код». Цепочка в режиме разработки решает проблему скорости, но на этапе сборки есть ещё один, более скрытый класс оптимизаций — те преобразования, которые выполняются до того, как код будет исполнен браузером. В следующей главе мы перейдём к магии этапа компиляции и посмотрим, как механизмы встраивания enum и проверки Tree-shaking заменяют TypeScript enum на литералы во время сборки и гарантируют, что обещание о подключении по требованию не будет нарушено.

CHAPTER 04

Глава 4: Магия этапа компиляции: встраивание enum и механизм проверки Tree-shaking

Проект: vuejs/core · Прогресс по книге: Глава 4 / 14 · Статус проверки: FACT — реальная привязка к номерам строк

В предыдущей главе мы увидели, как цепочка в режиме разработки за счёт отслеживания файлов и инкрементальной сборки обеспечивает скорость «изменил строку — сразу вступило в силу». Но помимо скорости у Vue есть ещё одно, более скрытое ограничение: размер публикуемого артефакта должен быть контролируемым. Один из врагов этого ограничения — enum в TypeScript: во время выполнения это реально существующий объект, который ломает Tree-shaking. Эта глава переносит нас на этап компиляции: посмотрим, как scripts/inline-enums.js до исполнения кода браузером «растворяет» перечисления в литералы; а затем посмотрим, как scripts/verify-treeshaking.js после сборки с помощью строк артефакта обратно проверяет, что обещание «подключения по требованию» не было незаметно нарушено.

4.1 Встраивание enum: растворение объекта времени выполнения в литералы

Интуитивная модель

Представьте, что вы написали кулинарную книгу, в которой постоянно встречается «немного соли». Если каждый раз при готовке нужно листать приложение, чтобы узнать, что «немного = 3 грамма», это и медленно, и занимает место. Встраивание enum делает следующее: перед печатью заменяет во всей книге «немного соли» прямо на «3 грамма соли», а затем вырывает страницу приложения. Для читателя (времени выполнения) результат полностью тот же, но книга стала тоньше.

Если бы этого не было, с какой катастрофой столкнулась бы система? Обычныйenumв TypeScript после компиляции создаёт реальный объектный литерал и обладает двусторонним отображением (Enum[Enum.A] === 'A'). Этот объект —объявление уровня модуля с побочными эффектами, Rollup не может доказать, что оно не используется, и потому вынужден его сохранить — даже если вы импортировали только один его член, весь объект перечисления вместе с обратным отображением будет вставлен в артефакт.📎 scripts/inline-enums.js:3-9В комментарии прямо сказано: они использовалиconst enum, но из-за issue #1228 перешли на обычный enum и потому с помощью этого скрипта «вручную возвращают нулевые затраты const enum».

Структуры данных и размещение в памяти

Ядро скрипта — три определения типов; поняв их, вы поймёте весь поток данных.📎 scripts/inline-enums.js:33-36

  • EnumMember:{ name, value }, имя отдельного члена перечисления и вычисленный литерал.
  • EnumDeclaration:{ id, range: [start, end], members }。range— этосмещение в байтах исходного кода, указывающее на начало и конец всего объявленияexport enum X { ... }в файле — это якорь для последующей точной замены через MagicString.
  • EnumData:{ declarations, defines }。declarationsиндексируется по пути файла и записывает диапазоны замены всех объявлений перечислений в этом файле;defines— это плоское отображение, ключ — ` ${имя перечисления}.${имя члена} 形式的字符串,值是 JSON.stringify` после преобразования в литерал.

Здесь есть ключевой дизайн:definesключне содержит путь к файлу。📎 scripts/inline-enums.js:98-103В комментарии объясняется причина —ErrorCodesможет одновременно существовать в@vue/compiler-coreи@vue/runtime-core, поэтому одноимённые перечисления могут существовать в разных файлах; но один и тот жеErrorCodes.__EXTEND_POINT__не допускается повторять в двух одноимённых перечислениях, иначеfullKey in definesсработает и сразу выброситname conflict. Это ограничение «глобальной уникальности по имени члена», а не «глобальной уникальности по имени перечисления».

Кэш сохраняется вtemp/enum.json。📎 scripts/inline-enums.js:33-36Почему нужна запись на диск? Потому чтоscanEnums()в точке входа сборки вызывается только один раз, а Rollup запускает для каждого пакета и каждого форматанезависимые процессы。📎 scripts/inline-enums.js:39-41В комментарии указано: данные должны совместно использоваться параллельными процессами Rollup, поэтому их необходимо сериализовать на диск, чтобы каждый процесс через свойinlineEnums()прочитал их обратно.

Пошагово: от grep до замены на литералы

Шаг первый: grep всех файлов, содержащихexport enum.📎 scripts/inline-enums.js:51-61используетspawnSync('git', ['grep', 'export enum']), вывод имеет видpath:line:content, затем по:отрезается первая часть (путь к файлу), с помощьюSetвыполняется дедупликация. Обратите внимание, что здесь используетсяgit grepвместо обхода файловой системы — он естественным образом сканирует только файлы, отслеживаемые Git, автоматически исключаяnode_modulesи артефакты сборки.

Шаг второй: Babel выполняет разбор и собирает информацию о перечислениях.📎 scripts/inline-enums.js:64-70Для каждого файла используется@babel/parserсtypescriptплагином,sourceType: 'module'разбирается в AST, затем обходятся толькоast.program.bodyузлы верхнего уровня.📎 scripts/inline-enums.js:74-79Распознаются толькоExportNamedDeclarationи егоdeclaration.type === 'TSEnumDeclaration'узлы — то есть,неэкспортируемые enum не обрабатываются。

Для каждого объявления перечисления скрипт вычисляет значения членов по одному. Вычисление членов делится на три пути:

1. Литеральная инициализация:StringLiteralилиNumericLiteralнапрямую берётсяinit.value。📎 scripts/inline-enums.js:114-119

2. Бинарное выражение: например,1 << 2. РекурсивноresolveValueобрабатываются левый и правый операнды; операнд может быть литералом илиMemberExpression(то есть ссылкой на ранее определённый член перечисления).📎 scripts/inline-enums.js:121-151Ключевой момент —MemberExpressionветвь: она используетcontent.slice(node.start, node.end)изисходного текста кодавырезает строку выражения (например,ErrorCodes.FOO), затем ищет вdefines. Если не найдено, выбрасываетсяunhandled enum initialization expression。📎 scripts/inline-enums.js:132-141Это объясняет, почемуdefinesдолжно быть глобальным плоским отображением — при кросс-перечисленческих ссылках ссылаемый элемент может быть из другого файла, но ключ распознаётся только по枚举名.成员名。

3. Унарное выражение: например,-1, собирается в-1строку и вычисляется с помощьюevaluate.📎 scripts/inline-enums.js:152-163

Само вычисление используетnew Function('return ' + exp)()。📎 scripts/inline-enums.js:39-41Этоконтролируемый eval: входные данные берутся из уже разобранного фрагмента AST в исходном коде, а не из произвольного пользовательского ввода, поэтому границы безопасности контролируемы.

Шаг третий: обработка членов без инициализатора (семантика автоинкремента).📎 scripts/inline-enums.js:171-183Если у члена нетinitializer: первый член по умолчанию0; для последующих членов, еслиlastInitializedявляется числом, то++; если строкой, выбрасываетсяwrong enum initialization sequence— потому что строковым членам перечисления не разрешён неявный автоинкремент. Это в точности семантика перечислений TypeScript.

Шаг четвёртый: запись кэша и возврат функции очистки.📎 scripts/inline-enums.js:200-213 scanEnums()Возвращается замыкание, при вызове которогоrmSyncудаляется файл кэша.build.jsИспользуется вtry/finally.📎 scripts/build.js:81-112Это гарантирует, что даже при ошибке в середине сборки кэш будет очищен и не загрязнит следующую сборку.

Шаг пятый: замена на этапе transform в Rollup. inlineEnums()Кэш читается обратно, создаётся плагин Rollup.📎 scripts/inline-enums.js:219-234Вtransform(code, id), еслиidпопадает вenumData.declarations, с помощью MagicString[start, end]этот фрагмент объявления заменяется на объектный литерал.📎 scripts/inline-enums.js:242-274

Заменённая форма —export const X = { ... }. Обратите внимание, что онне просто удаляет перечисление, а переписывает его в объектный литерал и дополнительно генерирует обратное отображение для числовых членов:JSON.stringify(value.toString()) + ': ' + JSON.stringify(name)。📎 scripts/inline-enums.js:257-270Комментарий ссылается на правило reverse-mappings из официальной документации TypeScript: для строковых членов перечисления обратное отображение не генерируется, для числовых — генерируется. Это гарантирует, что после замены поведение во время выполнения полностью совпадает с исходным enum.

А реальное устранение накладных расходов во время выполнения происходит, когдаdefinesпередаётся в@rollup/plugin-replace。📎 rollup.config.js:222-223Все ссылки наX.Memberссылкив плагине замены напрямую заменяются на литералы, поэтому тот переписанный объектный литерал, если никем не используется, может быть выброшен Tree-shaking'ом.

Приведённая ниже блок-схема описывает полный путь принятия решений от grep до замены:

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

Проектные соображения и подводные камни

Почему используется MagicString, а не повторная генерация всего файла?Потому чтоs.update(start, end, ...)заменяет только фрагмент объявления перечисления, остальные байты исходного кода полностью не трогаются,s.generateMap()при этом всё ещё можно сгенерировать точный sourcemap.📎 scripts/inline-enums.js:277-281Если использовать Babel для повторной печати всего AST, будет потеряно исходное форматирование, комментарии, а качество sourcemap снизится.

rangeПочемуnode.start/node.end, а неdeclaration.start?📎 scripts/inline-enums.js:189-193Утверждаетсяnode.start(то естьExportNamedDeclarationузел), диапазон замены охватываетexport enum X {...}весь фрагмент, включаяexportключевое слово. Текст замены начинается сexport const, точно продолжая.

Подводные камни:definesОграничение глобальной уникальностиЕсли в двух разных файлах есть по одномуErrorCodes, и оба определяют__EXTEND_POINT__, сборка сразу завершится ошибкой.📎 scripts/inline-enums.js:101-103Это не баг, а намеренное решение — потому чтоdefinesявляется глобальной таблицей замены и не может различать источник по файлам. При добавлении новых членов перечисления в продакшене, если имя конфликтует с уже существующим членом перечисления, это проявится здесь.

Подводный камень:new FunctionМомент вычисленияВычисление бинарного выражения происходит на этапеscanEnums, в это время вdefinesможет ещё не быть ссылаемого члена (если порядок ссылок обратный).📎 scripts/inline-enums.js:136-140Выбрасываетсяunhandled enum initialization expression. Это требует, чтобы ссылки на члены перечисления следовали порядку исходного кода «сначала определение, потом ссылка».

4.2 Проверка Tree-shaking: обратное доказательство обещания с помощью строк артефактов

Интуитивная модель

Встраивание перечислений — это «предварительная оптимизация», но действительно ли оптимизация срабатывает? Если какой-то helper из-за неудачного написания был случайно сохранён, размер тихо раздуется, а разработчик ничего не заметит.verify-treeshaking.jsЭто и есть тот «контролёр после факта»: он собирает артефакт, а затем, как при вскрытии, проверяет в артефакте,не появилось ли то, чего не должно быть. Без него обещание Vue о по требованию импорте могло бы после какого-то рефакторинга беззвучно нарушиться, пока пользователи не пожалуются на увеличение пакета.

Структуры данных и проверяемые пункты

В этом скрипте нет сложных структур данных, ядро — этоerrorsмассив и триincludesпроверки.📎 scripts/verify-treeshaking.js:6-6Сначала собираетсяglobal-runtimeформат, затем по отдельности читаются dev- и prod-артефакты.

Три проверки соответствуют трём типам «провала Tree-shaking»:

1. dev-артефакт содержит__spreadValues。📎 scripts/verify-treeshaking.js:13-19Это helper, генерируемый esbuild для{ ...obj }синтаксиса spread объекта. Если он присутствует, значит в коде времени выполнения использован spread объекта, тогда как по соглашению Vue следует использоватьextendhelper, чтобы избежать лишнего кода.

2. prod-артефакт содержитVue warn。📎 scripts/verify-treeshaking.js:26-31означает, что естьwarn()вызов, не обёрнутый в__DEV__условие, из-за чего код предупреждения просочился в продакшен-пакет.

3. prod-артефакт содержит список конфигураций DOM tag。📎 scripts/verify-treeshaking.js:33-42например,html,body,base、svg,animate,animateMotion、annotation,annotation-xml,maction. ЭтоisHTMLTag()Данные внутри helper'ов вроде этого должны существовать только в компиляторе и удаляться во время выполнения. Если они появляются в артефактах времени выполнения, это означает, что путь времени выполнения ошибочно использует helper, предназначенный только для компилятора.

Пошагово: процесс проверки

📎 scripts/verify-treeshaking.js:5-5Сначалаexec('pnpm', ['build', 'vue', '-f', 'global-runtime']), собираем толькоvueпакетаglobal-runtimeформат — это минимальный артефакт времени выполнения, лучше всего подходящий для выявления утечек. После завершения сборки синхронно читаем оба файла, по одномуincludesпроверяем, при совпадении добавляем вerrorsсообщение с объяснением. В конце, еслиerrors.lengthне равно нулю, выбрасываем агрегированную ошибку.📎 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 компилятора просочился в runtime"]
    c3 -->|нет| done{"errors пуст?"}
    e3 --> done
    done -->|да| pass["проверка пройдена"]
    done -->|нет| fail["throw агрегированная ошибка"]

Размышления о дизайне и подводные камни

〔Предположения о дизайне и архитектурные компромиссы〕

Почему используется строковыйincludesвместо AST-анализа?Потому что это «проверка-страж», а не «точный анализ». Она не стремится к полноте, а лишь устанавливает низкозатратные сигналы тревоги для трёх типов регрессий, реально случавшихся в истории. Сопоставление строк не имеет зависимостей, нулевых затрат на парсинг и одинаково эффективно для минифицированных артефактов — AST-анализ после minify, наоборот, сложнее выполнить.

〔Предположения о дизайне и архитектурные компромиссы〕

Почему проверяется толькоglobal-runtime?этот формат инлайнит все зависимости (externalпусто), это наиболее чувствительный к размеру и наиболее подверженный ошибочному включению артефакт. Если он чист, другие форматы обычно тоже чисты. При этом он быстро собирается, что удобно для частого запуска в CI.

〔Предположения о дизайне и архитектурные компромиссы〕

Подводный камень: проверяемые элементы — это «чёрный список», который устаревает по мере эволюции кода.Если однаждыisHTMLTagструктура данных изменится,html,body,baseэта строка больше не появится, и проверка станет фиктивной. Это требует от сопровождающих синхронно обновлять строки-стражи при изменении соответствующих helper'ов. Это неизбежная цена проверки на основе чёрного списка.

4.3 Взаимодействие с Rollup: порядок плагинов и внедрение define

Инлайнинг перечислений не работает изолированно, он встроен в конвейер плагинов Rollup. Понимание его места в конвейере позволяет понять, почемуdefinesдолжно передаваться вreplace, а неesbuild。

📎 rollup.config.js:47-50вызывается на верхнем уровне модуля конфигурацииinlineEnums(), деструктурируя[enumPlugin, enumDefines]. Обратите внимание, что это выполняетсяпри запуске каждого процесса Rollup, читаяscanEnumsзаписанный кэш.

Порядок массива плагинов таков:json → alias → enumPlugin → ...resolveReplace() → esbuild。📎 rollup.config.js:324-339 enumPluginстоит передreplace, это означает, что перезапись объявлений перечислений происходит первой, затемreplaceиспользуетdefinesдля замены ссылок. Аesbuildстоит последним, отвечая за транспиляцию TS.

Почемуdefinesидёт черезreplace, а не черезesbuild— комментарийdefine?📎 rollup.config.js:220-221даёт ответ: define в esbuild «немного строг, допускает только литеральный JSON или идентификаторы». А имена членов перечислений вродеErrorCodes.__EXTEND_POINT__— это выражение-член с точкой, и define в esbuild не может напрямую обработать такой ключ. Поэтому необходимо использовать@rollup/plugin-replace, который поддерживает замену произвольных строковых ключей.📎 rollup.config.js:250-251и установленpreventAssignment: true, чтобы не заменять также левую часть оператора присваивания.

resolveReplace()Вconst replacements = { ...enumDefines }— это первый шаг.📎 rollup.config.js:222-223Только после этого накладываются производственные/*@__PURE__*/аннотации,__DEV__и другие замены. Этот порядок гарантирует, что замена литералов перечислений всегда срабатывает.

Размышления о дизайне

Суть инлайнинга перечислений — «обмен сложности времени сборки на объём времени выполнения».Он полностью воспроизводит семантику системы типов TypeScript (вычисление перечислений, автоинкремент, обратное отображение) на этапе сборки —scanEnumsлогика вычисления в📎 scripts/inline-enums.js:110-183почти является подмножеством вычисления перечислений компилятором TS. Это влечёт затраты на сопровождение: если TS добавит новый синтаксис перечислений (например, более сложные константные выражения), здесь нужно будет догонять, иначе будет выброшена ошибкаunhandled. Но выгода очевидна: нулевой объект перечисления во время выполнения, Tree-shaking становится полным.

〔Предположения о дизайне и архитектурные компромиссы〕

Скрипт проверки и скрипт инлайнинга — это пара «обещание и его исполнение».Скрипт инлайнинга обещает, что «перечисления не занимают объём времени выполнения», скрипт проверки проверяет, что «другой код тоже не занимает объём тайком». Оба вместе охраняют бюджет размера Vue. Такой парный дизайн «оптимизация + проверка» — типичный паттерн инженерии крупных фронтенд-библиотек: любая оптимизация требует автоматической проверки для предотвращения регрессий.

Межпроцессный кэш — необходимость для параллельных сборок. scanEnumsРежим однократного выполнения,inlineEnumsмногократного чтения📎 scripts/inline-enums.js:39-41решает проблему «одно сканирование, N процессов-потребителей». Без кэша каждый процесс Rollup должен заново выполнять grep + парсинг, тратя впустую много IO и CPU.

Резюме главы

Вопросы для размышления и самопроверки к этой главе

Q1: Если удалитьscanEnumsвsaveValueизif (fullKey in defines)проверку конфликтов, в каких сценариях это приведёт к ошибкам в артефактах сборки?

Разбор ответа:

defines— это глобальное плоское отображение, ключом является枚举名.成员名, без пути к файлу.📎 scripts/inline-enums.js:98-103После удаления проверки конфликтов, если два разных файла имеют одноимённые перечисления и определяют одноимённые члены (например,@vue/compiler-coreи@vue/runtime-coreоба имеютErrorCodes.__EXTEND_POINT__), последний записавший перезапишет первого записавшего.

Последствия:defines['ErrorCodes.__EXTEND_POINT__']останется только одно значение, аplugin-replaceпри замене не сможет различить источник файла и заменитвсефайлыErrorCodes.__EXTEND_POINT__на одно и то же значение.📎 rollup.config.js:222-223В результате значение члена перечисления одного из пакетов будет молча искажено, поведение во время выполнения будет ошибочным и крайне трудно диагностируемым — потому что исходный код выглядит абсолютно корректно.

Именно поэтому комментарий подчёркивает: «одноимённые перечисления в разных файлах допускаются, но одноимённые члены — нет».📎 scripts/inline-enums.js:98-100Проверка конфликтов — это страж, предотвращающий загрязнение глобальной таблицы замен.

Q2: Если поменять местамиrollup.config.jsв массиве плагиновenumPluginи...resolveReplace(), что произойдёт?

Разбор ответа:

Текущий порядок:enumPluginвпереди,replaceпозади.📎 rollup.config.js:331-332Хукtransformв Rollup выполняется в порядке массива плагинов.

Если поменять местами,replaceзапустится первым, и в этот момент объявления перечислений всё ещё в исходнойexport enum X { ... }форме.replaceиспользуетdefinesдля замены ссылок наX.Member— но в этот момент ссылки ещё на месте, замена сработает. Проблема возникает, когдаenumPluginзатем запускается: он используетs.update(start, end, ...)для перезаписи секции объявлений.📎 scripts/inline-enums.js:250-273Ноreplaceуже изменилcode, аenumPluginполучаетcode— этоreplaceвывод, байтовое смещение которого уже не соответствуетscanEnumsзаписанномуrange(на основе исходного кода)больше не соответствует。

Последствие: MagicString будет разрезать по неверному смещению, синтаксис артефакта будет нарушен. Это выявляет неявный контракт конвейера плагинов:преобразования, основанные на смещениях в исходном коде, должны выполняться первыми, чтобы последующие преобразования могли безопасно продолжать работу над их выводом.

Q3: verify-treeshaking.jsпроверяются только три строковых маркера. Если при каком-либо рефакторингеisHTMLTagвнутренние данные из'html,body,base'будут изменены на форму массива['html','body','base'], что произойдёт со скриптом проверки? Какой недостаток проектирования это выявляет?

справочный разбор:

Проверка скрипта используется дляprodBuild.includes('html,body,base')проверки.📎 scripts/verify-treeshaking.js:33-37Если данные преобразовать в массив, в сжатом артефакте больше не будет строки, соединённой запятыми,includesвозвращаетfalse, проверяетпроходит молча— даже еслиisHTMLTagдействительно произошла утечка в артефакт времени выполнения.

Это выявляет врождённый недостаток проверки строк по чёрному списку:строки-маркеры связаны с реализацией исходного кода; при изменении реализации проверка становится недействительной. Она не способна обнаружить «неизвестные утечки», а может обнаружить только «известные утечки, строковая форма которых не изменилась».

〔Проектные предположения и архитектурные компромиссы〕

Направление улучшения: можно перейти к проверке более стабильных идентификаторов (например, имён функцийisHTMLTag), либо на уровне исходного кода запретить с помощью правил lint импорт компиляторных helper'ов во время выполнения, вместо того чтобы полагаться на строки в артефакте. Однако при текущих ограничениях по затратам строковые маркеры — это «достаточный и дешёвый» компромисс.

Встраивание перечислений решило вопрос «как устранить накладные расходы времени выполнения на этапе сборки», а скрипт проверки решил вопрос «как убедиться, что оптимизация не нарушена». Но помимо JS, у артефактов сборки есть ещё одна категория продуктов, также требующих конвейерной обработки, — файлы объявлений типов. В следующей главе мы перейдём к конвейеру артефактов типов и посмотрим, как Vue из исходного кода.d.tsГенерация пакетов типов уровня релиза, а такжеdts-testКак с помощью тестирования типовых контрактов защитить форму типов публичного API.

В этой главе разбираются два ключевых скрипта этапа компиляции. inline-enums.js использует git grep для поиска перечислений, Babel для разбора AST, new Function для вычисления членов, MagicString для точного перезаписывания объявлений и в конечном итоге через глобальную таблицу подстановок defines превращает ссылки на перечисления в литералы, позволяя объектам перечислений подвергаться Tree-shaking. verify-treeshaking.js же после сборки проверяет артефакты с помощью строковых маркеров, гарантируя, что три известных типа утечек Tree-shaking не вернутся. Один отвечает за «оптимизацию», другой — за «проверку того, что оптимизация не сломана», вместе они охраняют обещание Vue по размеру бандла. Далее мы перейдём от этапа компиляции к цепочке генерации типовых артефактов и посмотрим, как Vue обеспечивает строгое соответствие типов исходного кода и опубликованных типов.

CHAPTER 05

Глава 5: Конвейер типов: от исходного кода .d.ts до пакетов типов для релиза

Проект: vuejs/core · Прогресс книги: Глава 5 / 14 · Статус верификации: FACT-номера строк реально привязаны

В предыдущей главе мы разобралиinline-enums.jsиverify-treeshaking.js: один отвечает за замену ссылок на enum литералами, позволяя объектам перечислений подвергаться Tree-shaking, другой — за проверку после сборки с помощью строковых маркеров того, что три известных типа утечек не вернулись. Вместе они охраняют обещание Vue по размеру рантайма. Но артефакты сборки — это не только JS. Когда пользовательimport { ref } from 'vue', всплывающие подсказки типов в редакторе,tscпроверка типов пользовательского кода — всё это зависит от другого типа артефактов —.d.tsфайлов деклараций. Если JS-артефакт содержит ошибку, она проявляется в рантайме; если типовой артефакт содержит ошибку, она проявляется на этапе компиляции на стороне пользователя, или, что ещё хуже: типы незаметно дрейфуют, пользовательский код компилируется успешно, но форма типов не соответствует реальному поведению в рантайме. В этой главе мы проследим, как Vue собирает разбросанные по подпакетамsrcТипы исходного кода изdts-built-testПроведение дымового тестирования типов на реальных артефактах сборки.

5.1 Двухэтапный конвейер типов: вывод tsc, агрегация rollup

Интуитивная модель

Представьте полиграфический конвейер: на первом этапе каждый подпакет верстает свою рукопись (.tsисходный код) в одностраничные корректуры (.d.ts); на втором этапе десятки корректур сшиваются в книгу в порядке каталога (публикационный уровень.d.ts), с унифицированными колонтитулами (объявления экспорта).

Без этого конвейера Vue пришлось бы вручную поддерживать файл публикационных типов, и при каждом изменении исходного кода синхронно править его вручную — это питательная среда для дрейфа типов. Подход Vue таков:Артефакты типов полностью генерируются из исходного кода, никогда не пишутся вручную。

Первый этап: tsconfig.build.json определяет область вывода

tsconfig.build.jsonЭто конфигурация первого этапа данного конвейера. Она наследует корневойtsconfig.json, переопределяя только опции, связанные со сборкой.

📎 tsconfig.build.json:3-9

Разбор ключевых опций по порядку:

  • declaration: true: заставляет tsc генерировать для каждого исходного файла соответствующий.d.ts。
  • emitDeclarationOnly: true:Выводить только типы, без JS. JS обрабатывается Rollup, а tsc здесь — исключительно извлекатель типов.
  • stripInternal: true: все объявления, помеченные@internal, безусловно исключаются из.d.ts. Это первый шлюз Vue для контроля поверхности публичного API — детали внутренней реализации, даже если ониexport, при наличии@internalне попадут в публикуемые типы.
  • composite: false: отключает режим инкрементальной сборки ссылок на проекты (project references). Vue здесь не нуждается в межпакетной инкрементальности; отключение позволяет избежать дополнительного состояния, вносимого.tsbuildinfo.

includeСписок точно определяет, какие каталоги участвуют в выводе:

📎 tsconfig.build.json:10-23

Обратите внимание, что здесьперечислено только 12 каталогов, а не весьpackages/。packages-private/、packages/dts-test/、packages/sfc-playground/и прочие не входят в него. Это означает: типы приватных пакетов и тестовых пакетовникогда непопадает в артефакты публикации. Это физическая изоляция — не по соглашению, а по конфигурации.

〔Проектные допущения и архитектурные компромиссы〕

Почему используется белый список, а не чёрный? Потому что добавление новых подпакетов в monorepo — обычное дело. Если использоватьexcludeчёрный список, при добавлении нового приватного пакета и забывании внести его в exclude его типы тихо попадут в артефакты публикации. Белый список работает наоборот: новые пакеты по умолчанию не участвуют в сборке и должны быть явно добавлены, что соответствует принципу «безопасных значений по умолчанию».

После выполненияtsc -p tsconfig.build.json --noCheckартефакты оказываются вtemp/packages/<pkg>/src/*.d.ts. Обратите внимание на--noCheck: пропуск проверки типов, только emit. Проверка типов выполняется отдельнымtsc --noEmit, на этапе сборки она не повторяется, что экономит время.

Второй этап: агрегация rollup.dts.config.js

Второй этап управляетсяrollup.dts.config.js. Его точка входа сначала выполняет предварительную проверку:

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

Еслиtemp/packagesне существует, значит первый этап не выполнялся, скрипт сразуprocess.exit(1)и предлагает сначала запуститьtsc. Этоконтракт порядкаконвейера: этап rollup сильно зависит от артефактов этапа tsc, ни один не может отсутствовать.

Затем читаются все каталоги подпакетов, и поддерживаетсяTARGETSпеременная окружения для сборки подмножества:

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

TARGETSмеханизм позволяет пересобирать типы только нескольких пакетов, что при разработке и отладке значительно сокращает цикл обратной связи.

Ядро —targetPackages.map(...)генерирует конфигурацию Rollup для каждого пакета:

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

Построчный разбор:

  • input: ./temp/packages/${pkg}/src/index.d.ts: точка входа — файлы типов, созданные на первом этапе, а не исходный код.ts。
  • output.file: packages/${pkg}/dist/${pkg}.d.ts: артефакты попадают в собственныйdistкаталог каждого пакета, имя файла совпадает с именем пакета (например,vue.d.ts)。
  • format: 'es': файлы типов единообразно используют формат ES module.
  • plugins: [dts(), patchTypes(pkg), ...(pkg === 'vue' ? [copyMts()] : [])]: три плагина, первые два действуют на все пакеты,copyMtsдействует только наvueпакет.

onwarnХук

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

заслуживает отдельного упоминания:UNRESOLVED_IMPORTВ процессе dts rollup все импорты с неотносительными путями по умолчанию внешние (externalized). Это приводит к тому, что Rollup выдаётпредупреждение. Но этоожидаемое поведениеimport { X } from 'some-pkg'— в файлах типовreturnи должны оставаться внешними ссылками, их не следует включать в сборку. Поэтому скрипт для «неразрешённых импортов с неотносительными путями» простоwarn。

подавляет предупреждение, пропуская к стандартному

только неразрешённые импорты с относительными путями.!warning.exporter?.startsWith('.')〔Проектные допущения и архитектурные компромиссы〕.Здесь есть тонкость:

проверяет, начинается ли exporter с

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

Общая картина конвейераtscКопироватьrollupЭта схема фиксирует поток управления двух этапов:checkбелый списокpatchTypesопределяет, кто может попасть в конвейер,copyMtsопределяет, можно ли продолжить,vue— обязательный этап,

— ветка, специфичная для пакета

5.2 patchTypes: преобразование агрегированных артефактов в форму уровня публикации

rollup-plugin-dtsИнтуитивная модель.d.tsПосле объединения десятковexport { A, B, C, ... }в один файл получается форма «сначала объявляется куча типов, в конце всё экспортируется через один огромныйdefineComponent». Это неудобно для чтения человеком, а для некоторых инструментальных цепочек (например, вызова

patchTypesв VitePress) это вызывает ошибку «inferred type cannot be named without a reference».— это та самаяпостобработка формы

: «централизованный экспорт» заменяется на «встроенный экспорт на месте», затем добавляются специфичные для пакета расширения типов.

patchTypesСтруктуры данных: два Set и три проходаrenderChunkвозвращает плагин Rollup, основная логика находится в хуке

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

  • isExported. Он поддерживает две коллекции:: записывает всеизначально экспортированныеexport { ... }имена типов (из объявлений
  • shouldRemoveExport).: записывает всеимена типов, которые нужно удалить

из большого блока экспорта (поскольку они уже встроены).

Step-by-Step Walkthrough

Обработка делится на три прохода (pass 0 / pass 1 / pass 2) — это типичная модель «сначала собрать, затем переписать, потом очистить».

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

Pass 0: сбор всех имён экспортированных типов.ExportNamedDeclarationОбход узлов верхнего уровня AST: для всехибез sourceexport ... from '...'(то есть неisExported。

реэкспорт), local name спецификатора добавляется вexportPass 1: добавление префикса

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

непосредственно к узлам объявлений.VariableDeclaration、TSTypeAliasDeclaration、TSInterfaceDeclaration、TSDeclareFunction、TSEnumDeclaration、ClassDeclarationОбход узлов верхнего уровня, дляprocessDeclaration。

processDeclarationшести типов объявлений вызывается

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

логика:

Три шага:id1. Без

сразу возврат (например, анонимные объявления)._2. Если имя начинается с— пропуск. Этосоглашение

: типы с префиксом подчёркивания являются внутренними вспомогательными типами и не экспортируются.shouldRemoveExport3. Имя добавляется вisExported; если это имя есть вprependLeft(то есть изначально экспортировалось), в начальную позицию объявленияexport вставляется

строка.VariableDeclarationОбратите внимание, что ветка

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

содержит дополнительную проверку:declare constЕслиdeclare const a, bобъявляет несколько declarator (например,processDeclaration), сразу выбрасывается ошибка. Посколькуdeclarations[0]обрабатывает только, несколько declarator приводят к пропуску обработки. Здесь выбранбыстрый отказ

вместо тихой ошибки, что является проявлением защитного программирования.

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

Pass 2: удаление встроенных типов из большого блока экспорта.ExportNamedDeclarationОбход

  • , для каждого спецификатора:shouldRemoveExportЕсли его local name есть вexported === local, иexport { Foo as Bar }(исключая
  • случай переименования), то спецификатор удаляется.
  • При удалении используется точное удаление MagicString: если далее есть спецификатор, удаляется до start следующего спецификатора; если это последний, удаляется до end предыдущего или собственного start.ExportNamedDeclarationЕсли все спецификаторы блока экспорта удалены, удаляется весь

узел.

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

code = s.toString()Завершение: добавление специфичных для пакета типов.packages/${pkg}/typesПосле получения переписанного кода проверяется, существует ли

〔Проектные допущения и архитектурные компромиссы〕

этоtypes/каталог — этовручную поддерживаемое усиление типовточка входа, предназначенная для типов, которые невозможно автоматически сгенерировать из исходного кода (например, глобальные усиления JSX, объявления типов макросов). Он объединяется с автоматически сгенерированными типами в одном файле, но источники чётко разделены — автоматически сгенерированные сверху, вручную добавленные снизу.

Почему необходим встроенный экспорт?

В комментарии указана прямая причина:

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

В оригинале сказано: изменить все типы на встроенный экспорт и удалить их из большого блока экспорта, иначе в VitePressdefineComponentпри вызове возникнет ошибка «the inferred type cannot be named without a reference».

〔Проектные допущения и архитектурные компромиссы〕

Суть этой ошибки такова: когда TypeScript генерирует типы, если некоторый тип может быть назван только через «ссылку на экспорт другого модуля», а эта ссылка невидима на стороне потребителя, возникает ошибка. Централизованный блок экспорта разделяет имя типа и место объявления, усугубляя эту проблему. Встроенный экспорт делает каждый тип видимым в месте объявления, устраняя этот промежуточный слой.

copyMts: предоставление типов для двойного режима Node ESM/CJS

copyMtsПлагин действует только дляvueпакета:

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

Он вwriteBundleхуке записываетvue.d.tsсодержимое как есть вvue.d.mts。

В комментарии объяснена причина:

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

Согласноpackage.jsonспецификации exports TypeScript 4.7, чтобы корректно предоставлять типы одновременно для Node ESM и CJS,необходимо иметь два независимых файла объявлений. Поэтому при сборкеvue.d.tsкопируется какvue.d.mts。

〔Проектные допущения и архитектурные компромиссы〕

Почему копирование, а не повторная генерация? Потому что формы типов ESM и CJS полностью идентичны, различия только в расширении файла иpackage.jsonвexportsмаппинге

. Копирование — самое дешёвое решение, позволяющее избежать повторного запуска rollup.

5.3 dts-built-test: дымовое тестирование типов на реальных артефактах

Интуитивная модельpatchTypesПредыдущие два раздела гарантировали, что артефакты типов могут быть сгенерированы и имеют правильную форму. Но «может быть сгенерировано» не равно «сгенерировано правильно». Еслиimportв каком-либо проходе есть баг, и какой-то экспорт был ошибочно удалён, артефакт всё равно сгенерируется, но пользователь

dts-built-testобнаружит отсутствие типа.— этодымовой тест типов, запускаемый на реальных артефактах сборкиimport: он тестирует не типы исходного кода, аvueопубликованный

пакет, проверяя отсутствие регрессий в форме ключевых типов.

Структура данных: минимализированное утверждение типа

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

Ядро всего тестового пакета — один файл:

  • Построчный разбор:vueL1: изdefineComponentимпортируется. Обратите внимание, что здесь импортируетсяимя пакетаpackages/vue/dist/vue.d.ts, а не относительный путь — потребляется
  • этот реальный артефакт._CustomPropsNotErasedL3-6: определяется компонент
  • с пустыми props и пустым setup.// #8376L8: комментарий
  • , указывающий на конкретный issue.CustomPropsNotErasedL9-12: экспортируется_CustomPropsNotErased, тип —{ foo: string }пересечение

иdefineComponentЭтот тест проверяет:{ foo: string }тип возвращаемого значенияfooпосле пересечения。

не приводит к стиранию

свойстваdefineComponent〔Проектные допущения и архитектурные компромиссы〕

Предположение о контексте issue #8376:

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

тип возвращаемого значения

  • private: true, возможно, проходит через некоторый условный тип или mapped type, что приводит к «стиранию» дополнительных свойств в пересечении. Этот тест с минимальным воспроизведением фиксирует такое поведение, и при регрессии ошибка возникнет на этапе проверки типов.
  • types: dist/index.d.tsКонфигурация пакета: workspace-зависимости указывают на реальные артефакты
  • dependenciesКлючевые поля:workspace:*: не публикуется в npm.@vue/shared、@vue/reactivity、vue。
: точка входа типов указывает на артефакт сборки.

В@vue/sharedтри@vue/reactivityзависимости:vue〔Проектные допущения и архитектурные компромиссы〕typesПочему зависимостиdistи? Потому что типымогут ссылаться на типы этих двух пакетов. В режиме workspace pnpm создаёт символические ссылки на эти зависимости на локальные пакеты, а поле

локальных пакетов указывает на артефакты в соответствующих

dts-built-test. Таким образом, вся тестовая цепочка потребляетsrc/index.tsартефакты сборкиtsc, а не исходный код.tscКак запускаются тесты

Сам

не имеет тестового скрипта, егои есть тестовый пример. Способ запуска: в CI выполняетсяпроверка типов этого пакета. Если форма типов регрессирует,tscвыдаёт ошибку, CI падает.

〔Проектные допущения и архитектурные компромиссы〕

Изящество этого дизайна в том, что он кодирует «контракт типов» вdts-built-testкомпилируемый кодdts-test. Не нужна дополнительная библиотека утверждений, не нужен рантайм,

  • dts-built-testсам является средством запуска тестов. Типы верны — компиляция проходит, типы неверны — компиляция падает.Разделение обязанностей с dts-testОбратите внимание, что
  • dts-testв этой главе ив следующей главе — это две разные вещи:(эта глава): потребляет
артефакты сборки

, проверяет форму типов уровня публикации.patchTypes(следующая глава): потребляетstripInternalтипы исходного кодаtypes/, проверяет контракт поверхности API.dts-built-test〔Проектные допущения и архитектурные компромиссы〕

Почему нужны два уровня? Потому что типы исходного кода и типы артефактов могут не совпадать.

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 удаление спецификаторов большого блока экспорта
    Patch->>Patch: добавление packages/vue/types/*
    Patch-->>Rollup: перезаписанный code
    Rollup->>Dist: запись vue.d.ts
    Rollup->>Dist: copyMts запись vue.d.mts
    CI->>BuiltTest: проверка типов tsc
    BuiltTest->>Dist: import { defineComponent } from 'vue'
    Dist-->>BuiltTest: форма типа
    BuiltTest-->>CI: компиляция пройдена / ошибка

, удалениеpatchTypes, добавлениеdts-built-testкаталога — всё это может внести баги уровня артефактов при корректных типах исходного кода.

специально охраняет эту последнюю милю.

Полная временная последовательность конвейера типов

patchTypesКопированиеcode.replace(...)Эта диаграмма последовательности фиксирует кросс-модульное взаимодействие: CI управляет двумя этапами — tsc и Rollup,

1. три прохода— это основная обработка,start/endв конце потребляет артефакты для верификации.

2. Размышления о дизайне, восстановление после ошибок и подводные камни в продакшене:MagicString может генерировать маппинги, позволяя изменённым файлам типов по-прежнему отслеживаться до исходного кода. Хотя применение sourcemap для файлов типов ограничено, поддержание согласованности — хорошая практика.

Быстрый отказ vs тихая отказоустойчивость

patchTypesиспользуется во многих местахassert:

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

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

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

Эти утверждения немедленно выбрасывают ошибку при обнаружении неожиданной формы AST. В отличие отonwarnвнутриUNRESOLVED_IMPORTмолчаливое проглатывание —ожидаемый шум проглатывается, неожиданная форма быстро приводит к отказу. Это правильная позиция для сборочного скрипта: лучше пусть сборка упадёт, чем создать файл типов с неправильной формой.

Производственные подводные камни:_соглашение о префиксе

processDeclarationпропускаются типы, начинающиеся с_:

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

Это означает, что любой экспортируемый тип в исходном коде, начинающийся с_, не будет экспортирован через инлайн. Если какой-то тип должен быть публичным, но был пропущен из-за того, что его имя начинается с_, на стороне пользователя возникнет ошибка «тип не существует».

〔Проектные выводы и архитектурные компромиссы〕

Подход к диагностике таких проблем: сначала проверить, остался ли этот тип в большом экспортном блоке в артефактеvue.d.ts, затем проверить, начинается ли имя этого типа в исходном коде с_. Это неявная связь между соглашением об именовании и поведением инструмента, легко наступить на грабли.

Производственные подводные камни: утверждение о множественных declarator

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

Если в каком-либо.d.tsпоявляетсяdeclare const a, b, сборка немедленно выбрасывает ошибку. Это редко встречается в рукописных типах, но сработает, если файл типов, сгенерированный каким-либо инструментом, использует такую форму. В сообщении об ошибке будет напечатан фрагмент проблемного кода для облегчения локализации.

Резюме главы

В этой главе прослежен полный конвейер создания артефактов типов Vue:

1. Первый этап (tsc):tsconfig.build.jsonс помощьюincludeбелый список точно определяет область вывода,emitDeclarationOnlyвыводятся только типы,stripInternalвнутренние объявления удаляются. Артефакты попадают вtemp/packages/。

2. Второй этап (rollup):rollup.dts.config.jsс помощьюrollup-plugin-dtsагрегируются типы пакетов,patchTypesпосредством трёх проходов обхода AST централизованный экспорт переписывается в инлайн-экспорт, и добавляетсяtypes/ручное расширение каталога.copyMtsдляvueпакета дополнительно генерируется.d.mts。

3. Этап верификации (dts-built-test): выполняется дымовое тестирование типов на реальных артефактах сборки, с помощью компилируемого кода фиксируются ключевые формы типов для предотвращения дрейфа типов.

Вопросы для размышления и самопроверки в этой главе

Q1: Если изменитьtsconfig.build.jsonвincludeбелый список на["packages"](то есть включить весь каталог packages), что произойдёт? В каком сценарии это приведёт к загрязнению публикуемых типов?

Справочный анализ:

includeПосле изменения с 12 точных каталогов на["packages"]все подпакеты (включая всеpackages-privateза пределамиpackages/*) будут участвовать в выводе tsc.📎 tsconfig.build.json:10-23

Цепочка последствий:

1. temp/packages/появится множество лишних пакетов.d.ts。

2. rollup.dts.config.jsвreaddirSync('temp/packages')будет читать эти лишние пакеты.📎 rollup.dts.config.js:15-22

3. targetPackagesпо умолчанию равно всем пакетам, поэтому для каждого пакета будет сгенерированpackages/<pkg>/dist/<pkg>.d.ts。📎 rollup.dts.config.js:15-22

Сценарий загрязнения: если какой-то пакет не должен публиковаться (например, внутренний инструментальный пакет), его артефакты типов появятся вdist. Если вpackage.jsonэтого пакета нетprivate: true, скрипт публикации может отправить его вместе в npm, что приведёт к утечке внутренних типов.

Именно в этом ценность дизайна белого списка: новые пакеты по умолчанию не участвуют, необходимо явно добавить, что соответствует безопасным значениям по умолчанию.

Q2: patchTypesВ pass 1processDeclarationдля типов, начинающихся с_, напрямуюreturn. Если тип какого-либо публичного API случайно начинается с_(например,_InternalTypeбыл случайно экспортирован), что увидит пользователь? Как это диагностировать?

Справочный анализ:

processDeclarationпри встрече с_в начале напрямую возвращает, не добавляя вshouldRemoveExport, и не выполняет prependexport 。📎 rollup.dts.config.js:76-78

Последствия:

1. Этот тип не получит инлайнexport。

2. Он также не будет удалён из большого экспортного блока (поскольку его нет вshouldRemoveExport).

3. Поэтому онвсё ещё находится в большом экспортном блоке, теоретически всё ещё может быть импортирован.

Но проблема в том: в большом экспортном блокеexport { _InternalType }ссылается на позицию объявления. Если это объявление по какой-то причине (например,stripInternal) было удалено, экспортный блок будет ссылаться на несуществующее имя, что приведёт кtscошибке.

Подход к диагностике:

1. Проверить, есть ли этот тип в артефактеvue.d.tsни в месте объявленияexport, ни в большом экспортном блоке.

2. Проверить, начинается ли имя этого типа в исходном коде с_.

3. Если подтверждено, что это проблема именования, достаточно переименовать, убрав префикс подчёркивания.

Это выявляет неявную связь между соглашением об именовании и поведением инструмента:_префикс изначально означает «внутренний», но инструмент воспринимает его как «не экспортировать», эти две семантики не полностью совпадают.

Q3: dts-built-testВsrc/index.tsс помощью перекрёстного типаtypeof _CustomPropsNotErased & { foo: string }проверяется, чтоfooне стирается. Если изменить перекрёстный тип наOmit<typeof _CustomPropsNotErased, never> & { foo: string }, сможет ли тест всё ещё поймать регрессию #8376? Почему?

Справочный анализ:

Omit<T, never>создаёт новый mapped type, которыйпересчитываетвсе свойства T. Если баг #8376 заключается в «стирании дополнительных свойств в交叉 типе», то:

  • Исходная записьT & { foo: string }: прямой крест,fooявляется частью перекрёстного типа, еслиdefineComponentлогика обработки возвращаемого типа стирает дополнительные свойства в пересечении,fooбудет потерян.
  • OmitЗапись:Omitсначала выполняется отображение дляT, затем пересечение с{ foo: string }.OmitПроцесс отображения может изменить структуру типа, так что условие срабатывания бага больше не выполняется — даже если баг существует, тест может пройти.

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

Поэтомуминимальностьтестового случая критична: он должен точно воспроизводить путь срабатывания бага. Любое дополнительное преобразование типа (например,Omit、Pick) может замаскировать баг. Именно поэтому в тесте используется самый простой тип пересечения, а не более «элегантная» запись.

〔Проектные выводы и архитектурные компромиссы〕

Направление улучшения: можно одновременно сохранить несколько вариантов записи, охватывающих различные пути преобразования типов, повышая вероятность обнаружения регрессий. Но это увеличит затраты на поддержку, требуется компромисс.

Конвейер типов решает «как генерировать типы уровня публикации из исходного кода»,dts-built-testрешает «как проверить форму артефактов типов». Но контракт типов не ограничивается «правильностью формы», он также включает «соответствие поверхности API ожиданиям» — какие типы должны экспортироваться, какие нет, точны ли ограничения дженериков. В следующей главе мы перейдём кdts-test, посмотрим, как Vue использует тесты типовых контрактов для защиты поверхности публичного API.

Эти три компонента образуют замкнутый цикл «генерация → форматирование → проверка», гарантируя строгое соответствие типов исходного кода и опубликованных типов. Однако корректность самого пакета типов не означает, что типовая форма публичного API зафиксирована. В следующей главе мы углубимся вpackages-private/dts-test, чтобы увидеть, как более 20.test-d.tsфайлов используютexpectTypeи другие инструменты, превращая «типы как контракт API» в автоматизированные регрессионные тесты.

CHAPTER 06

Глава 6: Тестирование типовых контрактов: как dts-test защищает поверхность API

Проект: vuejs/core · Прогресс книги: Глава 6 / 14 · Статус верификации: FACT — реальная привязка к номерам строк

В предыдущей главе мы проследили цепочку генерации объявлений типов и увидели, как Vue через конфигурацию сборки и smoke-тесты обеспечивает строгое соответствие «типов исходного кода» и «опубликованных типов». Но типовой контракт — это не только «правильность формы», важнее — «соответствует ли поверхность API ожиданиям»: какие типы должны экспортироваться, какие нет, точны ли обобщённые ограничения. Эта глава погружается вpackages-private/dts-test, чтобы увидеть, как Vue с помощью более 20.test-d.tsфайлов превращает «типы как контракт API» в автоматизированные регрессионные тесты.

Когнитивная модель тестирования типовых контрактов: превращение «инструкции» в «исполняемый договор»

dts-testФайлы в каталогеобладают контринтуитивной особенностью: онипочти не порождают никакого runtime-поведенияdefineComponent.test-d.tsx. ОткройтеdefineComponent({...}), вы увидите множество вызововtsc/vue-tsc, но они никогда не выполняются во время выполнения тестов — эти файлы только проверяютсяnoEmit: trueна типы,

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

гарантируя, что не производится никакого JS.noEmitЭта конфигурация — «среда выполнения» всей системы контрактов:jsx: preserveотключает вывод артефактов,strictсохраняет синтаксис TSX для разбора системой типов,moduleResolution: bundlerвключает все строгие проверки,libсоответствует современной семантике сборки,esnextодновременно подключаетdom。и.test-d.tsxБез этой конфигурации。

JSX внутри обрабатывался бы как runtime JSX, и утверждения типов теряли бы смысл

〔Проектные выводы и архитектурные компромиссы〕packages-privateВыделение тестов типов в отдельный подпакетpackages/vueвместо того, чтобы поместить их в__tests__внутриvue, мотивировано тремя причинами: во-первых, зависимости тестов типов — этопубликационные типы(vue/jsx、vueиз.d.ts), а не внутренние модули исходного кода; физическая изоляция заставляет использовать публичный вход; во-вторых,tscпроверка типов занимает гораздо больше времени, чем runtime-юнит-тесты, и отдельный каталог упрощает отдельное планирование в CI; в-третьих,.test-d.tsxфайлы не будут ошибочно собраны runtime-коллектором Vitest.

Житейская аналогия: обычные юнит-тесты похожи на «включить машину и посмотреть, не пойдёт ли дым», а тесты типовых контрактов — на «построчную сверку условий перед подписанием договора» — сделка не совершается, лишь подтверждается, что в «сумма к оплате заказчиком» написано «юани», а не «доллары». Если условия договора неверны, машина работает гладко, но это бесполезно.

utils.d.tsпредоставляет весь инструментарий для этой «сверки договора»:

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

Ключевых инструментов всего четыре:expectType<T>(value: T)утверждает, чтоvalueимеет тип ровноT;expectAssignable<T, T2 extends T>утверждает, чтоT2присваиваемT;IsUnion<T>определяет,Tявляется ли объединённым типом;IsAny<T>определяет,Tявляется лиany. Обратите внимание на L5import 'vue/jsx'— он регистрирует глобальное пространство имён JSX, позволяя<MyComponent />в TSX распознаваться системой типов какJSX.Element。

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

IsUnionРеализация заслуживает внимательного рассмотрения:T extends any ? (U extends T ? false : true) : neverиспользует дистрибутивные условные типы: еслиT— объединённый тип, каждый член вычисляется независимо, и в итогеextends falseопределяет, все ли ветви возвращаютfalse. Этодоказательство существования на уровне типов— используется для фиксации контрактов вроде «props.jjjдолжен быть объединённым типом, а не слитым в единую сигнатуру».

Сценарно-ориентированный Walkthrough:defineComponentПолная цепочка вывода типов props в

defineComponent.test-d.tsxсодержит 2260 строк и является ядром системы контрактов. Погрузимся в конкретный сценарий:Пользователь пишетdefineComponent({ props: {...}, setup(props) {...} }), система типов Vue должна изpropsruntime-объявления вывести точный типsetupпараметраpropsвнутри. Эта цепочка — самая сложная часть системы типов Vue.

Шаг первый: построение «ожидаемого типа» как эталона контракта

Тестовый файл сначала определяет интерфейсExpectedProps, жёстко прописывая тип, который должен выводиться для каждого способа объявления propsявно:

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

Этот интерфейс — письменная версия «условий договора». Обратите внимание на несколько тонких типов:a?: number | undefined(опциональные props сundefined)、aa: number(есть default, поэтому не опциональный),aaa: number | null(PropType<number | null>явно объявлен),aaaa: number | undefined(required: true as constно тип содержитundefined). Эти различия не случайны, каждое соответствуетpropsопределённой ветви в объявлении.

Шаг второй: «скормить»defineComponent

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

различными способами объявленияpropsЭтот объект— исчерпывающая матрица способов объявления, охватывающая все варианты записи props в Vue:

  • a: Number— сокращение через конструктор, выводится какnumber | undefined
  • aa: { type: Number as PropType<number | undefined>, default: 1 }— есть default, выводится как не опциональныйnumber
  • aaaa: { type: Number, required: true as const } —— as constпредотвращаетtrueрасширение доboolean, сохраняя литеральный тип
  • b: { type: String, required: true as true } —— required: trueделает свойство не-void
  • bb: { default: 'hello' }— безtype, тип выводится только из default
  • cc: Array as PropType<string[]>— явное приведение типа
  • l: [Date]— синтаксис массива, выводится какDate | undefined
  • ll: [Date, Number]— массив нескольких типов, выводится какDate | number | undefined
  • lll: [String, Number]— то же самое
〔Проектные выводы и архитектурные компромиссы〕

required: true as const(L70) иrequired: true as true(L75) сосуществуют как след исторической эволюции: изначально использовалсяas true, позже выяснилось, чтоas constболее универсален (может одновременно фиксировать другие литералы в объекте), но старый вариант сохранён для проверки обратной совместимости. Это типичная ценность контрактных тестов —он одновременно фиксирует «новый способ работает» и «старый способ не регрессирует»。

Шаг третий: утверждения в трёх местахsetup / render / this

Это самое изящное решение в контрактных тестах:один и тот же тип props должен корректно выводиться в трёх разных местах потребления。

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

setup(props)выполняетexpectType<ExpectedProps['x']>(props.x)для каждого prop. Обратите внимание на особую обработку в L168-170:

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

// @ts-expect-error should included 'undefined'В сочетании сexpectType<number>(props.aaaa)——Намеренно напишите утверждение, которое вызовет ошибку, используя@ts-expect-errorподавить ошибку. Это подтверждает, чтоprops.aaaaтипне является number(иначе эта строка не вызвала бы ошибку,@ts-expect-errorа наоборот, завершилась бы неудачей из-за «отсутствия ошибки для подавления»). Это техника «обратного утверждения» в тестировании типов.

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

// @ts-expect-error props should be readonlyВ сочетании сprops.a = 1— проверка того, что props доступны только для чтения вsetup. Если при каком-либо рефакторинге props случайно станут изменяемыми, эта строка перестанет вызывать ошибку,@ts-expect-errorи тест завершится неудачей.

render()же проверяется черезthis.$propsиthis.xдва пути утверждений:

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

L252-276 проверяет, что «объявленные props также должны быть доступны наthis», L278-279 проверяетthis.a = 1ошибку (thisprops на также доступны только для чтения). L281-287 проверяет разворачивание возвращаемого значения setup:this.cэтоnumber(ref(1)разворачивается),this.d.e.valueэтоstring(вложенный ref сохраняет.value)、this.f.gэтоGT(reactivebranded-типы внутри не разворачиваются).

Шаг четвёртый: проверка типов на стороне потребителя TSX

Последнее звено типового контракта — «как пользователь использует этот компонент». В TSX<MyComponent />проверка props — это независимый путь типов:

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

Здесь проверяется, что<MyComponent>принимает все объявленные props, а такжеclass/style/key/ref/ref_forэти встроенные атрибуты. Затем идётОбратная проверка:

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

// @ts-expect-error missing required propsпроверяет ошибку при отсутствии обязательных props;wrong prop typesпроверяет ошибку при несоответствии типов; L342 проверяетggg="baz"ошибку (gggпринимает только'foo' | 'bar')。

Всю цепочку можно обобщить одной диаграммой потока данных:

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

Ключевой момент этой диаграммы в том, что:одно и то жеpropsобъявление должно одновременно удовлетворять типовым ожиданиям трёх мест потребления. Любое отклонение в выводе типов в любом месте приведёт к ошибкеtsc.

Границы и бэкдоры:__typeProps、__typeEmitsи контракты условных типов

defineComponentВывод типовимеет фундаментальное ограничение:объявления props во время выполнения не могут выражать «условные типы»color='white'. Например, «когдаappearanceдолжно быть'outline'» — такое ограничение невозможно записать в синтаксисе объекта времени выполнения. Vue для этого предоставляет__typePropsи другие «типовые бэкдоры».

__typeProps: аварийный выход для типов условных props

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

ConditionalProps— это объединённый тип: либоcolorиappearanceоба опциональны, либоcolor: 'white'иappearance: 'outline'. Тесты проверяют:

  • L1823-1824:<Comp color="white" />ошибку — указание толькоcolor: 'white'не удовлетворяет ни одной ветви
  • L1825-1826:<Comp color="white" appearance="normal" />ошибку —appearanceдолжно быть'outline'
  • L1827:<Comp color="white" appearance="outline" />проходит
〔Проектные предположения и архитектурные компромиссы〕

__typePropsМотивация дизайна

__typeEmits— «позволить системе типов выражать ограничения, невыразимые во время выполнения». Он не участвует в разборе props во время выполнения, это чисто типовое перекрытие. Цена — пользователю нужно вручную поддерживать согласованность типов и объявлений времени выполнения — именно поэтому он называется «backdoor», а не официальным API.

__typeEmits: эквивалентность двух синтаксисов emitsподдерживает два синтаксиса, тесты:

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

одновременно фиксируют оба{ change: [id: number], update: [value: string] }Синтаксис объектаthis.$props.onChange?.(123)использует именованные кортежи для выражения параметров. Тесты проверяют, чтоonChange?.('123')проходит,

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

ошибку.{ (e: 'change', id: number): void; (e: 'update', value: string): void }Синтаксис сигнатуры вызоваиспользует перегрузки.Тела тестов для обоих синтаксисов почти построчно идентичны— это намеренно: контракт требует, чтобы оба способа записи давалиполностью эквивалентное

типовое поведение.

〔Проектные предположения и архитектурные компромиссы〕defineEmitsПочему сохранены два синтаксиса? Синтаксис объекта ближе к записи

__typeRefs, синтаксис сигнатуры вызова ближе к традиционным типам событий TS. Vue должен поддерживать оба и гарантировать согласованность поведения. Структура тестов «построчное зеркало» — сильнейшее доказательство эквивалентности.__typeElи

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

__typeRefs: межкомпонентные ссылки и типы хост-узловParentпозволяет родительскому компоненту точно знать тип ref дочернего компонента.__typeRefs: { child: ComponentInstance<typeof Child> }объявляетrefs.child.$refs.foo, поэтомуnumber。

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

__typeElможет быть выведен какБолее тонкий момент. Комментарии к тестам L1963-1977 проясняют замысел дизайна:ElementХост-узлы пользовательских рендереров (TUI, canvas, native) — не DOMTypeEl, поэтомуElementне может быть ограниченCustomElement. Тесты используют$elинтерфейс для проверки того, что

может принимать любой тип хоста.

〔Проектные предположения и архитектурные компромиссы〕TypeElЭто типовая гарантия поддержки пользовательских рендереров в Vue 3. Если быElement,@vue/runtime-testбыл жёстко ограничен$el, пользователи не-DOM рендереров не смогли бы корректно выводить типы

. Контрактные тесты здесь защищают «независимость от рендерера».

function syntax w/ runtime propsВзаимоисключающие ограничения обобщённых компонентов и props времени выполненияРаздел。

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

фиксирует важное правило:generics aren't supported with object runtime propsОбобщённые компоненты не могут сосуществовать с объектными props времени выполнения<Comp3<string>>Комментарий L1501

— это объявление контракта. L1525-1535 проверяет ошибку для обобщённого setup + объектных props; L1538-1539 проверяет

ошибку. А массивные props допускают обобщения (L1464-1499).ExtractPropTypes〔Проектные предположения и архитектурные компромиссы〕

Корневая причина этого ограничения — порядок вывода типов: объектные props требуют

@ts-expect-errorсначала определить тип, а обобщения могут быть определены только при инстанцировании, что конфликтует. Массивные props не участвуют в извлечении типов, поэтому конфликта нет. Контрактные тесты закрепляют это «ограничение системы типов» как регрессионное утверждение.

@ts-expect-errorРазмышления о дизайне, восстановление после ошибок и подводные камни в продакшенеДвусторонний меч@ts-expect-error— основной инструмент контрактного тестирования типов, но у него есть фатальная ловушка:Когда код под ним перестаёт вызывать ошибку,

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

сам вызывает ошибку// @ts-expect-error missing prop. Это кажется защитой, но на деле требует от автора теста точного контроля «места возникновения ошибки».<Comp msg={123} />Посмотрите на этот фрагмент:помещён встроку вышеexpectType<JSX.Element>(...), но всё выражение обёрнуто в@ts-expect-error. Если позицияexpectTypeсместится на строку, или ошибка фактически возникнет в вызове

, а не в JSX, тест завершится неудачей.

〔Проектные предположения и архитектурные компромиссы〕@ts-expect-errorПодводный камень в продакшене: когда обновление версии TypeScript приводит к незначительному смещению позиций ошибок, множествоможет массово выйти из строя. Стратегия Vue —@ts-expect-errorрасполагатьвплотную к утверждаемому коду

IsAnyиIsUnion: доказательство существования на уровне типов

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

expectType<IsAny<typeof props.foo>>(false)проверяет, чтоprops.fooне являетсяany. Этообратный контракт: требуется не только правильность типа, но и то, чтобы тип не деградировал доany」。any— это чёрная дыра системы типов, любоеanyлишает смысла последующие утверждения.

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

expectType<IsUnion<typeof props.jjj>>(true)проверяет, чтоjjjявляется объединённым типом.jjjобъявлен как((arg1: string) => string) | ((arg1: string, arg2: string) => string), и если система типов сольёт его в единую сигнатуру,IsUnionвернётfalse, и тест провалится.

〔Проектные выводы и архитектурные компромиссы〕

Эти два инструмента защищают «точность типа», а не «правильность типа». Тип, деградировавший доanyили объединение которого было слито, в большинстве сценариев использования «выглядит рабочим», но теряет подсказки IDE и проверки на этапе компиляции. Контрактные тесты должны фиксировать эту точность.

Неявный контракт порядка объявления

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

Этот комментарий крайне важен:code generated by tsc / vue-tsc, make sure this continues to work so we don't accidentally change the args order of DefineComponent。DefineComponentимеет 13 обобщённых параметров, порядок которых —публичный контракт——vue-tscСгенерированный тип компонента зависит от этого порядка. В тесте с помощьюdeclare const MyButton: DefineComponent<...>явно выписаны все 13 параметров, фиксируя порядок.

〔Проектные выводы и архитектурные компромиссы〕

Это наиболее легко упускаемый контракт: порядок обобщённых параметров — не «деталь реализации», а «ABI генерируемого кода». Любой PR, меняющий порядок, сделает сгенерированныйvue-tsc.d.tsнесовместимым с типом времени выполнения. Контрактный тест здесь выступает «стражем совместимости ABI».

Межфайловый контракт:componentInstance.test-d.tsxдополнение к

componentInstance.test-d.tsxзанимает всего 154 строки, но покрывает все входные формы утилитарного типаComponentInstance:

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

ComponentInstance<typeof CompSetup>извлекает тип экземпляра из результатаdefineComponent;ComponentInstance<typeof CompFunctional>извлекает из функционального компонента;ComponentInstance<typeof CompFunction>извлекает из голой функции. Все три должны выводить базовый классComponentPublicInstance.

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

Ещё более экстремальный случай — «голый объект без обёрткиdefineComponent»:CompObjectSetup、CompObjectData、CompObjectNoPropsВсе три формы должны корректно извлекаться с помощьюComponentInstance. Особенно неинтуитивны строки L113-114:CompObjectNoPropsнет объявленияprops, ноcompObjectNoProps.testвсё равно выводится какstring | undefined— это подстраховка, предоставляемая базовым классомComponentPublicInstance.

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

Тест#12751на L141 фиксирует одну границу:__typeEmitsобъявленное событие'update:visible'на экземпляре должно быть представлено какcomp['onUpdate:visible'](строковый ключ с двоеточием), и тип$propsдолжен быть{ 'onUpdate:visible'?: (value?: boolean) => any }. L152-153 проверяют, чтоcomp['$props']['$props']выдаёт ошибку — предотвращая рекурсивную самоссылку типов.

Итоги главы

dts-testКаталог с помощью более чем 20 файлов.test-d.tsвоплощает принцип «тип — это API-контракт» в автоматизированные регрессионные тесты. Основной механизм состоит из трёх уровней:

1. Уровень инструментов:expectType、expectAssignable、IsUnion、IsAnyпредоставляет примитивы утверждений о типах,@ts-expect-errorпредоставляет возможность обратных утверждений.

2. Уровень контрактов:ExpectedPropsИнтерфейсpropsявно фиксирует, «какой тип должен выводиться», матрица объявленийsetup/renderисчерпывающе перебирает все способы записи, а три места потребления (

3. /TSX) перекрёстно проверяются.:__typeProps、__typeEmits、__typeRefs、__typeElУровень чёрного хода

предоставляет аварийный люк для ограничений типов, невыразимых во время выполнения, одновременно фиксируя эквивалентность двух синтаксисов emits.

Вопросы для размышления и самопроверки в этой главеdefineComponent.test-d.tsxQ1: Если удалить@ts-expect-errorиз L168-170 вexpectType<number>(props.aaaa), оставив только

, что произойдёт? Почему этот тест «тихо перестанет работать»?:

props.aaaaЭталонный разбор{ type: Number as PropType<number | undefined>, required: true as const }объявлен какnumber | undefined, его выводимый тип —PropType<number | undefined>(посколькуundefined)。

expectType<number>(props.aaaa)явно включаетprops.aaaa, требующий, чтобыnumberбыл ровноnumber | undefined). Поскольку фактический тип —, эта строка。@ts-expect-errorсама по себе выдаст ошибку

Роль@ts-expect-error— «ожидать здесь ошибку и проглотить её».Если удалитьprops.aaaa, эта строка сразу выдаст ошибку, и тест провалится — выглядит «строже». Но проблема в том:numberесли какой-то рефакторинг действительно сделает@ts-expect-errorравным(исправление бага или изменение поведения), эта строка перестанет выдавать ошибку, а после удаления

тест пройдёт@ts-expect-error— в этот момент тест не сможет отличить «тип правильный» от «тип неправильный, но случайно не выдаёт ошибку».Сохранение записи с— этоnumber | undefinedдвусторонняя фиксация@ts-expect-error: требуется и «текущий тип — этоexpectType<number>» (через проглатывание ошибкиnumberс помощьюnumber,@ts-expect-error), и «тип не может быть» (если станет。

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

Q2: __typeProps, тест провалится из-за отсутствия ошибки для проглатывания). Это ключевой приём контрактных тестов типов —ConditionalPropsиспользовать «ожидаемую ошибку» для фиксации «тип обязан содержать некоторый компонент»{ color?: 'normal' | 'primary' | 'secondary' | 'white'; appearance?: 'normal' | 'outline' | 'text' }Тест чёрного хода (L1803-1836) проверяет ограничения условного объединённого типа. Если изменить__typePropsс объединённого типа на

(то есть сгладить все варианты), как провалится тест? Какое проектное ограничение:

это демонстрирует?colorЭталонный разборappearanceСглаженный тип допускает любую комбинациюcolor: 'white' + appearance: 'normal'и, включая:

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

выдавала ошибку@ts-expect-errorКопировать<Comp color="white" />Если тип сглажен, эта строка перестанет выдавать ошибку, и@ts-expect-errorпровалится из-за «отсутствия ошибки для проглатывания». При этом

на L1823-1824 также превратится из «ошибки» в «прохождение», что тоже заставит__typePropsпровалиться.Это показывает, что проектное ограничение。__typePropsтаково:Propsон должен сохранять семантику «взаимного исключения ветвей» объединённого типаPrettifyЭто не простое «перекрытие типов», а «выражение через систему типов условных ограничений, невыразимых в runtime props». Если при реализации применить кOmitпреобразование отображения вроде

или

, можно нарушить различимость ветвей объединения и сделать ограничение недействительным.__typeProps〔Проектные выводы и архитектурные компромиссы〕CommonProps & ConditionalPropsИменно поэтому тестовые случаи

Q3: DefineComponentиспользуют самое примитивное пересечениеVNodeProps & AllowedComponentProps & ComponentCustomProps, а не более «элегантные» отображаемые типы — любое дополнительное преобразование типов может замаскировать баг.Readonly<ExtractPropTypes<{}>>Порядок 13 обобщённых параметров

явно зафиксирован в L1784-1801. Если какой-то рефакторинг поменяет местами 9-й параметр (:

DefineComponent) и 10-й параметр (vue-tsc), какие нижележащие компоненты пострадают? Почему контрактный тест обязан фиксировать этот порядок?<script setup>Эталонный разборdefineProps / defineEmits,vue-tscПорядок обобщённых параметровCreateComponentPublicInstance<...>— это «ABI» при генерации типа компонента. Когда пользователь пишет в,генерируется тип

, подобный L1999-2116, где

1. vue-tscпозиция.d.tsобобщённого параметраDefineComponentопределяет смысл каждого параметра типа.VNodeProps & AllowedComponentProps & ComponentCustomPropsЕсли поменять местами 9-й и 10-й параметры:Readonly<ExtractPropTypes<{}>>сгенерированныйТипы props пользовательских компонентов полностью смещены。

2. L1786-1800declare const MyButton: DefineComponent<...>выдаст ошибку напрямую — потому что{}иVNodeProps & ...несовместимы.

3. L1999-2116ErrorMessageтип (имитацияvue-tscрезультата генерации) также выдаст ошибку.

Ценность фиксации порядка в контрактных тестах заключается в том, что:она поднимает «порядок параметров обобщения» с уровня «детали реализации» до уровня «публичный контракт». Любой PR, изменяющий порядок, немедленно провалит L1786-1800, блокируя попадание несовместимых изменений в релиз.

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

〔Проектные выводы и архитектурные компромиссы〕

Это наиболее недооценённая ценность контрактных тестов типов: они защищают не «правильность типов», а «стабильность интерфейса системы типов». Порядок параметров обобщения,@ts-expect-errorпозицияIsAnyвозвращаемое значение

Контрактные тесты типов решают вопрос «соответствует ли поверхность API ожиданиям». Но типы — лишь половина инженерии Vue; другая половина — «как пользователь может в реальном времени проверить поведение этих API в браузере». Следующая глава посвящена SFC Playground: как Vue упаковывает компилятор, рантайм и систему типов в среду отладки в браузере, позволяя пользователю мгновенно видеть результат компиляции и выполнения при изменении кода.

Контрактные тесты защищают не только «правильность типов», но и «точность типов» (IsAny/IsUnion), «стабильность порядка параметров обобщения» (DefineComponent13 параметров), «независимость от рендерера» (__typeElне ограниченElement). Как только эти ограничения нарушаются, подсказки IDE на стороне пользователя,vue-tscсгенерированные типы начинают дрейфовать. А стабильность контрактов типов в конечном счёте должна служить повседневному опыту отладки разработчика — в следующей главе мы заглянем вpackages-private/sfc-playgroundи посмотрим, как чисто фронтендный Playground замыкает цикл компиляции SFC и живого превью прямо в браузере.

CHAPTER 07

Глава 7: SFC Playground: подсистема компиляции и отладки компонентов в реальном времени

Проект: vuejs/core · Прогресс книги: Глава 7 / 14 · Статус верификации: FACT — номера строк реально привязаны

В предыдущей главе мы с помощью более 20.test-d.tsфайлов прибили «типы как API-контракт» намертво в CI. Но контракты типов отвечают лишь на вопрос «как выглядит поверхность API», они не могут ответить на вопросы «как именно выглядит скомпилированный SFC» и «совпадает ли результат рендеринга в режиме SSR». Чтобы ответить на эти два вопроса, команде Vue нужна песочница, способная прогнать полный конвейер компиляции в браузере — этоpackages-private/sfc-playground. Она принципиально отличается от публичных пакетов подpackages/:package.jsonв"private": trueи"version": "0.0.0" 📎 packages-private/sfc-playground/package.json:2-4, что означает, что она никогда не публикуется в npm и является лишь официальным инструментом отладки. В её зависимостяхvueуказывает наworkspace:* 📎 packages-private/sfc-playground/package.json:19, то есть на локальный артефакт сборки исходников, а не на стабильную версию из npm — это делает Playground естественной «живой демонстрацией текущего коммита». Эта глава сосредоточена на трёх вопросах: как инициализируется точка входа, как Header управляет переключением состояния, как внедряются константы времени сборки.

I. Минимализм точки входа: контракт инициализации main.ts и ReplStore

Интуитивная модель

main.tsсодержит всего 9 строк, как «скрипт самопроверки при загрузке»: перед монтированием приложения Vue сначала вwindowпомещается глобальная конфигурация, сообщающая Vue DevTools «какое приложение выбирать по умолчанию». Без этого шага DevTools при открытии столкнётся с несколькими экземплярами приложений (сам Playground + код, выполняемый в пользовательском REPL) и не сможет автоматически сфокусироваться, а опыт отладки деградирует до ручного переключения.

Структуры данных и глобальные побочные эффекты

main.tsсуть не вcreateApp, а в загрязняющей записи вwindow:

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

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

Здесь есть две инженерные детали, заслуживающие внимания:

〔Проектные выводы и архитектурные компромиссы〕

1. @ts-expect-errorа не@ts-ignore:windowстандартный типWindow & typeof globalThisне имеет поляVUE_DEVTOOLS_CONFIG. Использование@ts-expect-errorозначает «я знаю, что здесь будет ошибка, и я требую, чтобы она была» — если в будущем какой-нибудь@types/*добавит это поле,@ts-expect-errorвыдаст обратную ошибку из-за «отсутствия ошибки», тем самым напомнив автору удалить этот комментарий. Это перекликается с подходом контрактных тестов типов из предыдущей главы:защищать намерения системой типов, а не скрывать проблемы。

〔Проектные выводы и архитектурные компромиссы〕

2. defaultSelectedAppId: 'repl'Строковая конвенция: этот'repl'должен полностью совпадать с id, используемым при создании app внутри@vue/repl. Это межпакетный литеральный контракт, не защищённый никакими ограничениями типов — как только@vue/replизменит id, выбор по умолчанию в DevTools Playground тихо перестанет работать.

Пошагово: от HTML до монтирования

Поток выполнения крайне короток, но каждый шаг имеет неявные ограничения:

1. Браузер загружаетindex.html, который содержит<div id="app">(в данном материале не предоставлен, ноmount('#app')можно вывести обратно).

2. Разбор графа модулей:main.tsв началеimport App from './App.vue' 📎 packages-private/sfc-playground/src/main.ts:2запускает@vitejs/plugin-vueкомпиляцию SFC.

〔Проектные выводы и архитектурные компромиссы〕

3. Ключевой порядок:window.VUE_DEVTOOLS_CONFIGдолжен быть записан доcreateApp(App).mount('#app') 📎 packages-private/sfc-playground/src/main.ts:9. Поскольку хук DevTools регистрируется внутриcreateApp, запись конфигурации после mount не повлияет на первоначальный выбор.

4. mount('#app')запускаетApp.vuesetup, тем самым создаваяReplStore(вApp.vue, в данном материале отсутствует).

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

Проектные соображения и подводные камни

main.tsМинимализмнамеренный:App.vueвся сложность опущена вReplStore。Точка входа выполняет только две задачи: «внедрение глобальных побочных эффектов + монтирование». Любая бизнес-логика не должна здесь присутствовать. Это компромисс Playground как «инструмента отладки», а не «продукта» — ему не нужна совместимость с SSR, не нужны множественные точки входа, не нужна ленивая загрузка.

〔Проектные предположения и архитектурные компромиссы〕

Подводные камни в продакшене:window.VUE_DEVTOOLS_CONFIG— даГлобальный синглтон. Если Playground встраивается в другую страницу, которая также использует DevTools (например, в сценарии iframe), последний записавший перезапишет предыдущего. Поскольку Playground обычно развёртывается отдельно, этот риск принят.

---

II. Header.vue: вычисляемое производное состояние и однонаправленный поток данных через emit

Интуитивная модель

Header.vue— это «панель управления» Playground: выбор версии, переключение PROD/DEV, переключатель SSR, переключение темы,分享, скачивание. Сам онне хранит никакого бизнес-состояния, всё состояние приходит изprops.storeи булевых props, все изменения передаются черезemitродительскому компоненту. Без этого ограничения «глупый компонент + всплытие событий» Header превратился бы в зону бедствия с разбросанным состоянием, а побочные эффекты переключения версии и SSR невозможно было бы централизованно управлять.

Разбор структур данных и полей

Определение props в Header — ключ к пониманию его обязанностей:

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

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

Пять props делятся на две категории:

  • store: ReplStore: единственная ссылка на контейнер состояния, приходящая из@vue/repl. Header через неё читаетstore.loading、store.vueVersion、store.typescriptVersion, и напрямую записываетstore.vueVersion。
  • четыре булевых/литеральных props:prod、ssr、autoSave、theme. Они являютсяконтролируемым состоянием, Header только читает, но не пишет; изменения должны идти черезemit。

соответствующий список emit📎 packages-private/sfc-playground/src/Header.vue:20-28:

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

Обратите внимание:toggle-themeхотя и управляется внутриtoggleDark()внутреннимemit, ноtoggle-ssr/toggle-prod/toggle-autosaveв шаблоне напрямую$emitявляется📎 packages-private/sfc-playground/src/Header.vue:102-118. Такое смешение — распространённый стиль Vue 3<script setup>:при необходимости побочных эффектов используется функциональный emit, при чистой передаче — шаблонный$emit。

Пошагово: отображение и переключение версии

Сценарий: пользователь открывает Playground, Header должен показать текущую версию Vue.

Шаг 1: computed для производного текста отображения

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

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

Здесь три уровня приоритета:loadingсостояние →'loading...'; пользователь явно выбрал версию →store.vueVersion; иначе →@${__COMMIT__}(короткий хеш текущего коммита).__COMMIT__— это константа, внедряемая на этапе сборки, подробнее в следующем разделе.

Шаг 2: двусторонняя привязка VersionSelect

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

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

Обратите внимание, здесьне используетсяv-model, а явно разбито на:model-value + @update:model-value. Причина в том, чтоvueVersion— это computed (только для чтения), нельзя напрямую двусторонне связывать; необходимо черезsetVueVersionэту функцию-сеттер записатьstore.vueVersion:

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

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

function resetVueVersion() {
  store.vueVersion = null
}
〔Проектные предположения и архитектурные компромиссы〕

setVueVersionобъявлен какasyncно внутри нетawait— это историческое наследие или намеренное решение? Предположительно, для согласования сVersionSelectсемантикой асинхронной загрузки (переключение версии запускает удалённую загрузку), чтобы сохранить единообразие интерфейса.

Шаг 3: сравнение с версией на TypeScript

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

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

Версия на TypeScript используетv-model, потому чтоstore.typescriptVersion— это записываемое обычное свойство, не требующее обёртки computed.Один и тот же компонент в одном шаблоне использует два способа привязки, что как раз наглядно демонстрирует «контролируемое vs неконтролируемое».

Переключение темы: комбинация побочных эффектов и 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'))
}

Эта функция делает три вещи: манипулирует DOM-классом, сохраняет в localStorage, отправляет emit родительскому компоненту.Обратите внимание, она не изменяет напрямуюprops.theme— потому что props только для чтения, родительский компонент получитtoggle-themeи только тогда обновитtheme, что в свою очередь приведёт к обновлению в шаблоне:titleтекста📎 packages-private/sfc-playground/src/Header.vue:123。

〔Проектные предположения и архитектурные компромиссы〕

Здесь есть тонкий дизайн:Манипуляция DOM-классом и реактивное состояние Vue — это два независимых пути。document.documentElement.classList.toggle('dark')напрямую изменяет DOM, аthemeprop обновляется через Vue. Если они не синхронизированы (например, родительский компонент отказывается обновлять), в UI возникнет несоответствие: «класс уже переключён, но текст title не изменился». На практике родительский компонент всегда принимает emit, поэтому проблема не проявляется.

Скрытая логика: ветка metaKey в 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.')
}

Этозадняя дверь для разработчика: при нажатии Cmd наplay.vuejs.orgи при клике на кнопку 分享 происходит переход наlocalhost:5173(локальный dev server), и текущий URL hash передаётся туда. В hash закодировано полное состояние REPL (исходный код, версия, опции), поэтому локальная отладка может воспроизвести онлайн-проблему. Комментарий// hidden logic for going to local debug from play.vuejs.org 📎 packages-private/sfc-playground/src/Header.vue:47-56явно указывает, что это намеренно скрытая функция.

〔Проектные предположения и архитектурные компромиссы〕

resetVueVersion()вызывается перед переходом, устанавливаяstore.vueVersionвnull, чтобы локальная отладка использовала текущий коммит, а не версию, выбранную онлайн.

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

Размышления о дизайне и подводные камни

〔Проектные предположения и архитектурные компромиссы〕

Подводный камень 1:navigator.clipboardразрешения и контекст безопасности。copyLinkбез try/catch📎 packages-private/sfc-playground/src/Header.vue:47-56. При не-HTTPS или отказе пользователя в разрешении на буфер обменаwriteTextбудет reject, что приведёт к необработанному Promise rejection. Playground развёрнут на HTTPS, риск принят, но это типичная «ловушка продакшена».

〔Проектные предположения и архитектурные компромиссы〕

Подводный камень 2:toggleDarkжёстко закодированный ключ localStorage。'vue-sfc-playground-prefer-dark'— это строковый литерал, без вынесения в константу. Если в будущем понадобится изменить ключ, придётся искать по всему проекту.

Подводный камень 3:currentCommitиvueVersionсравнение. В шаблоне:class="{ active: vueVersion === \@${currentCommit}\ }" 📎 packages-private/sfc-playground/src/Header.vue:88-88Сравнение через конкатенацию строк. Если__COMMIT__инъекция не удалась (превращается вundefined), здесь станет'@undefined', и совпадение никогда не будет достигнуто. Надёжность инъекции констант на этапе сборки напрямую определяет корректность UI — именно этому посвящён следующий раздел.

---

III. Инъекция констант на этапе сборки: двойная ответственность __COMMIT__ и copyVuePlugin

Интуитивная модель

vite.config.ts— это «сборочный цех» Playground: он на этапе сборки выполняетgit rev-parse, получает хеш коммита, черезdefineпревращает его в глобальную константу__COMMIT__; одновременно через пользовательский плагин копирует ESM-браузерные артефакты изpackages/vue/dist/в директорию артефактов Playground. Без этого шага Playground не смог бы загрузить в браузере «Vue-рантайм текущего коммита» — он зависел бы только от стабильной версии из npm, теряя смысл «живой демонстрации».

Структуры данных и константы этапа сборки

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

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

spawnSyncсинхронно выполняет git-команду,--short=7берёт 7-символьный короткий хеш. Синхронное выполнение намеренно:конфигурационный файл на этапе загрузки модуля уже нуждается в значенииcommit, асинхронность нарушила бы порядок разрешения конфигурации Vite.

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

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

define— это механизмтекстовой заменыVite: все вхождения__COMMIT__в исходном коде заменяются наJSON.stringify(commit)(то есть на строковый литерал в кавычках).JSON.stringifyнеобходим — если написать напрямуюcommit, после замены получится голый идентификаторabc1234, который будет воспринят как имя переменной, а не строка.

〔Проектные предположения и архитектурные компромиссы〕

__VUE_PROD_DEVTOOLS__: true— ещё одна ключевая константа: она заставляетproduction-сборкуVue также сохранять поддержку DevTools. По умолчанию production-сборка удаляет хук DevTools для уменьшения размера, но Playground нуждается в отладке пользовательского кода, поэтому принудительно включает его.

Пошагово: перенос артефактов в 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`)
    },
  }
}

Разбор ключевых моментов по порядку:

1. generateBundleхук: выполняется после того, как Rollup сгенерировал bundle, но до записи на диск. В этот момент можноemitFileдобавить дополнительные файлы в артефакты.

2. import.meta.dirname: ESM-версия__dirname, предоставляемая Node 20.11+. Путь../../packagesподнимается отpackages-private/sfc-playground/до корня репозитория, затем входит вpackages/。

3. Проверка существования + явная ошибка: еслиvue.esm-browser.jsне существует, выбрасывается ошибка с инструкцией по исправлениюRun "nr build vue -f esm-browser" first.. Этообразец developer experience— сообщение об ошибке сразу говорит, как исправить.

4. Пять артефактов:vueполная/рантайм-версия × dev/prod, плюсserver-renderer. Эти пять файлов — именно тот набор кандидатов, который Playground динамически импортирует в браузере, соответствуя переключению версий и переключателю SSR в Header.

〔Проектные предположения и архитектурные компромиссы〕

Почему именно эти пять?Полная версия (с компилятором) используется для сценария «компиляции в рантайме»; рантайм-версия — для сценария «предварительной компиляции»; dev/prod соответствуют переключателю PROD/DEV в Header; server-renderer соответствует переключателю SSR. Эти пять файлов образуют «матрицу Vue-рантайма» Playground.

Полный поток данных переключения версий

СвяжемsetVueVersionиз Header с артефактами 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["Предпросмотр в реальном времени"]

Обратите внимание на специальное значение@${__COMMIT__}: оно соответствует локальным артефактам, скопированным copyVuePlugin, а не CDN. Именно поэтому Playground обязан копировать браузерные артефакты сборки Vue —опции «This Commit» нужны локальные файлы。

Проектные размышления и подводные камни

〔Проектные предположения и архитектурные компромиссы〕

Подводный камень 1:spawnSyncобработка сбоя. Если текущая директория не является git-репозиторием (например, распакована из tarball),spawnSyncвернёт ненулевой код выхода,stdoutбудет пустым,commitстанет пустой строкой. Тогда__COMMIT__заменится на"", а в Header@${currentCommit}станет'@'. Явной обработки ошибок нет.

〔Проектные предположения и архитектурные компромиссы〕

Подводный камень 2:optimizeDeps.exclude: ['@vue/repl'] 📎 packages-private/sfc-playground/vite.config.ts:27-29. Vite по умолчанию предварительно упаковывает зависимости для ускорения холодного старта, но@vue/replисключён. Причина в том, что@vue/replвнутри использует динамический import и worker, а предварительная упаковка ломает эти механизмы. Это распространённая в экосистеме Vite проблема «конфликта предварительной упаковки и динамической загрузки».

〔Проектные предположения и архитектурные компромиссы〕

Подводный камень 3:script.fsконфигурация 📎 packages-private/sfc-playground/vite.config.ts:13-19。@vitejs/plugin-vueопцияscript.fsпозволяет блоку<script>SFC читать файлы черезfs. Здесь передаютсяfs.existsSyncиfs.readFileSync, чтобы поддержать разбор оператораimportв SFC (например,import x from './foo'нужно проверить существование файла).Это ключ к тому, что Playground может в браузере эмулировать полное разрешение модулей— он внедряет возможности Node fs в фазу разрешения компилятора.

---

Проектные размышления: архитектурные компромиссы Playground

Связывая три раздела, видим, что архитектура Playground следует чёткому принципу:разделять «состояние» и «побочные эффекты», разделять «этап сборки» и «этап выполнения»。

  • main.tsвыполняет только инъекцию глобальных побочных эффектов, не трогая бизнес-состояние.
  • Header.vue— чисто презентационный компонент, состояние втекает через props и вытекает через emit.
  • vite.config.tsфиксирует информацию этапа сборки «текущий commit» как константу, доступную только для чтения в рантайме.
〔Проектные предположения и архитектурные компромиссы〕

Такое разделение даёт прямое преимущество:Playground можно встроить в любое Vue-приложение(например, встроенный пример в документации), достаточно предоставитьstoreи четыре булевых props.

Цена —Разрозненность состояния:storeВ@vue/replбулево состояние находится в родительском компоненте, DOM-класс — наdocument.documentElement, а в localStorage есть ещё одна копия. Четыре места хранения состояния требуют ручной синхронизации, и любое расхождение приводит к несогласованности UI.

〔Проектные выводы и архитектурные компромиссы〕

Ещё один компромисс —отказ от совместимости с SSR。main.ts— прямой доступ кwindow,Header.vueиtoggleDarkпрямой доступ кdocument. Playground — это чисто CSR-приложение, и серверный рендеринг учитывать не нужно.

---

Итоги главы

В этой главе разобраныpackages-private/sfc-playgroundтри ключевых файла:

1. main.ts: 9-строчная точка входа, суть которой — порядок внедренияwindow.VUE_DEVTOOLS_CONFIG— должен быть доmount.

2. Header.vue: черезcomputedвыводитсяvueVersion, черезemitсообщается обо всех изменениях состояния.copyLinkВеткаmetaKey— это скрытый локальный отладочный бэкдор.

3. vite.config.ts:spawnSyncполучает хеш коммита,defineвнедряет__COMMIT__,copyVuePluginи переносит пять браузерных артефактов Vue в каталог артефактов Playground.

Сквозная линия, объединяющая все три, —граница между константами времени сборки и состоянием времени выполнения:__COMMIT__— это доступный только для чтения факт времени сборки,store.vueVersion— это изменяемый выбор времени выполнения, аvueVersioncomputed в Header объединяет оба в одну отображаемую строку.

Вопросы для размышления и самопроверки

Q1: Если перенести присваиваниеmain.tsвwindow.VUE_DEVTOOLS_CONFIGпослеcreateApp(App).mount('#app'), что произойдёт? Почему?

Разбор ответа:window.VUE_DEVTOOLS_CONFIG— это конфигурация, которую Vue DevTools считывает при регистрации хука внутриcreateAppнемедленно регистрирует📎 packages-private/sfc-playground/src/main.ts:4-9。createApp, и в этот момент DevTools читает__VUE_DEVTOOLS_GLOBAL_HOOK__, чтобы определить, какое приложение выбрать по умолчанию. Если присваивание происходит позжеdefaultSelectedAppId, DevTools уже завершил первичный выбор приложения, и конфигурация не вступит в силу — пользователю придётся вручную переключиться наmountв DevTools. Что ещё менее очевидно: посколькуreplвнутри тоже создаёт приложение, позднее присваивание может привести к тому, что DevTools по умолчанию выберет сам Playground, а не пользовательский REPL, и при отладке пользовательского кода придётся переключаться вручную. Это показывает важность «порядка внедрения глобальных побочных эффектов» в инструментах отладки.@vue/replВ

Q2: Header.vueодновременно воздействует на DOM-класс, localStorage и emit, но не изменяетtoggleDark()напрямую. Если родительский компонент, получив событиеprops.theme, откажется обновлять proptoggle-theme, какая несогласованность UI возникнет? Как локализовать это на уровне исходного кода?themeРазбор ответа

В:toggleDark()напрямую вызывает📎 packages-private/sfc-playground/src/Header.vue:58-66, что немедленно меняетdocument.documentElement.classList.toggle('dark')class в DOM и запускает переключение CSS-переменных (см. правилоdarkв📎 packages-private/sfc-playground/src/Header.vue:186-186). Но текст.dark navв шаблоне:titleзависит от📎 packages-private/sfc-playground/src/Header.vue:123, и если родительский компонент не обновится, title останется старым. Метод локализации: проверить в браузерных DevTools, не противоречат ли class элементаprops.themeи атрибут title кнопки. Корневая причина в том, что «побочный эффект DOM» и «реактивное состояние Vue» идут двумя независимыми путями, без единого источника данных.<html>В

Q3: copyVuePluginдля каждого файла выполняется проверкаgenerateBundle, и при отсутствии выбрасывается ошибка с инструкцией по исправлению. Если убрать эту проверку и сразу вызватьfs.existsSync, что произойдёт в CI-окружении (без предварительной сборки vue)? Как сообщение об ошибке введёт разработчика в заблуждение?fs.readFileSyncРазбор ответа

: после удаления проверкивыброситfs.readFileSync. Эта ошибка сообщает разработчику лишь «файл не существует», но не говорит, что «нужно сначала запуститьENOENT: no such file or directory, open '.../packages/vue/dist/vue.esm-browser.js' 📎 packages-private/sfc-playground/vite.config.ts:32-63». В CI-окружении разработчик может ошибочно решить, что проблема в конфигурации путей, правах доступа или неинициализированных git-подмодулях, и потратить массу времени на диагностику. Исходный кодnr build vue -f esm-browserсвязывает «симптом» с «действием по исправлению» — это ключевая деталь проектирования опыта разработчика. Это также объясняет, почему скрипт сборки Playground должен иметь чёткий порядок зависимостей относительно скрипта сборки ядра Vue.throw new Error(\${basename} not built. Run "nr build vue -f esm-browser" first.\)В следующей главе мы перейдём к

---

и посмотрим, как Vue визуализирует промежуточные продукты компилятора (AST, результаты преобразований, генерацию кода), позволяя разработчику шаг за шагом наблюдать каждое преобразование от шаблона к функции рендеринга. В отличие от «сквозного чёрного ящика» Playground, Template Explorer — это «белый зонд».packages-private/template-explorerИтак, мы увидели, как SFC Playground переносит конвейер компиляции в браузер: инициализация точки входа, переключение состояния Header и внедрение констант времени сборки вместе образуют песочницу, отлаживаемую в реальном времени. Но перспектива Playground всегда — «компиляция и выполнение целого SFC», и она не отвечает напрямую на вопрос «что именно компилятор делает с тем или иным выражением шаблона». В следующей главе мы заглянем в Template Explorer и посмотрим, как он раскладывает построчно результаты компиляции

и@vue/compiler-dom, используя SourceMapConsumer для построения соответствия между исходным кодом и артефактами, превращая внутреннее поведение компилятора в наблюдаемый и обратно выводимый зонд.@vue/compiler-ssr← Предыдущая глава: Глава 6

CHAPTER 08

Глава 8: Runtime-core: виртуальный DOM, diff-алгоритм и диспетчеризация компонентов

Прогресс книги: глава 8 / 14 · Статус проверки: строки FACT реально привязаны · Статус верификации: номера строк FACT реально привязаны

В предыдущей главе мы увидели, как SFC Playground упаковывает всю цепочку «ввод SFC → компиляция в браузере → предпросмотр в реальном времени» в чёрный ящик: разработчик видит конечный результат рендеринга, но не видит, что компилятор делает внутри. Когда в шаблоне написана пользовательская директива, или после включения hoistStatic в выводе внезапно появляется куча переменных _hoisted_1, Playground не может ответить на вопрос «почему компилятор сгенерировал именно так». Позиционирование Template Explorer прямо противоположно: он раскрывает результаты компиляции @vue/compiler-dom и @vue/compiler-ssr, AST, маркеры ошибок и сопоставление позиций от исходного кода к результату. Его суть — не «запуск», а «наблюдение». Эта глава построена вокруг трёх файлов: index.ts отвечает за вызов компиляции и двунаправленное сопоставление SourceMap, options.ts управляет десятками CompilerOptions через reactive и驱动 UI, theme.ts настраивает тему редактора Monaco.

I. Вызов компиляции и двунаправленное сопоставление SourceMap: index.ts

Интуитивная модель

Template Explorerindex.tsпохож на «двунаправленный переводчик»: слева вводится шаблон, справа выводится функция рендеринга. Но у него есть способность, которой нет у переводчика — когда вы ставите курсор на определённую строку слева, справа подсвечивается соответствующий результат; и наоборот, если поставить курсор справа, слева подсвечивается соответствующий шаблон. Без сопоставления SourceMap этот инструмент выродился бы в два текстовых поля рядом, и разработчику пришлось бы сравнивать их глазами, не имея возможности построить причинно-следственную цепочку «строка шаблона → строка результата».

Структуры данных и размещение в памяти

index.tsВнутри нет сложных Struct, но есть несколько ключевых модульных переменных состояния, которые определяют поведение всего инструмента:

lastSuccessfulCodeиlastSuccessfulMap— это кэш результата компиляции📎 packages-private/template-explorer/src/index.ts:74-75. Первый — строка, второй —SourceMapConsumer | undefined. Обратите внимание:lastSuccessfulMapизначальноundefined, и присваивается только при успешной компиляции и наличииmap📎 packages-private/template-explorer/src/index.ts:99-100. Этоundefinedсостояние является защитным условием для всей последующей логики сопоставления курсора — если компиляция не удалась, функция сопоставления автоматически молча отключается, а не выбрасывает исключение.

PersistedStateИнтерфейс определяет форму состояния, сохраняемого в localStorage и URL hash📎 packages-private/template-explorer/src/index.ts:26-30:src(исходный код шаблона),ssr(режим SSR или нет),options(опции компилятора). Здесь есть ключевое проектное решение:optionsимеет тип полногоCompilerOptions, но при фактическом сохранении сохраняются только «элементы, отличающиеся от значений по умолчанию», и эта логика обрезки выполняется вreCompile

sharedEditorOptions— это общие опции конструктора для двух редакторов📎 packages-private/template-explorer/src/index.ts:26-30:fontSize: 14、scrollBeyondLastLine: false、renderWhitespace: 'selection'、minimap.enabled: false. Minimap отключён, потому что шаблон и результат обычно занимают всего несколько десятков строк, и minimap только занимает горизонтальное пространство.

Step-by-Step Walkthrough

Сценарий: пользователь открывает страницу, вводит<div>{{ msg }}</div>, затем перемещает курсор.

Шаг первый: инициализация и восстановление состояния. window.init— это глобальная точка входа📎 packages-private/template-explorer/src/index.ts:41. Сначала она регистрирует и активирует пользовательскую тему📎 packages-private/template-explorer/src/index.ts:44-45, затем пытается восстановить состояние из URL hash или localStorage📎 packages-private/template-explorer/src/index.ts:49-56. Обратите внимание на порядок декодирования: сначалаatob, затемescape, потомdecodeURIComponent. Если разбор hash не удался, происходит fallback кlocalStorage.getItem('state'), затем fallback к{}. Если весь JSON.parse не удался, localStorage очищается и выводится предупреждение📎 packages-private/template-explorer/src/index.ts:57-64。

После восстановления состояния есть легко упускаемая деталь:delete persistedState.options?.nodeTransforms 📎 packages-private/template-explorer/src/index.ts:69. Комментарий объясняет причину — функции не могут быть сериализованы, поэтому при сохраненииnodeTransformsтеряется, и при восстановлении остаточный пустой объект может привести к аномальному поведению компилятора. Это классическая ловушка «сохранения несериализуемых полей».

Шаг второй: ядро компиляцииcompileCode。Это сердце всего инструмента📎 packages-private/template-explorer/src/index.ts:76-106. Сначала онconsole.clear(), затем в зависимости отssrMode.valueвыбираетssrCompileилиcompile 📎 packages-private/template-explorer/src/index.ts:80. Обратите внимание на параметры вызоваcompileFn: разворачиваетсяcompilerOptions, принудительноfilename: 'ExampleTemplate.vue'、sourceMap: true, и внедряетсяonErrorcallback для сбора ошибок📎 packages-private/template-explorer/src/index.ts:82-89。

Здесь есть проектное решение:filenameжёстко задан как'ExampleTemplate.vue'. Это значение в последующих вызовахgeneratedPositionForдолжно точно совпадать📎 packages-private/template-explorer/src/index.ts:189, иначе запрос SourceMap вернёт пустой результат. Это неявный контракт — две строки должны совпадать, но никакая система типов это не гарантирует.

После завершения компиляции ошибки преобразуются в формат marker Monaco и устанавливаются в редактор📎 packages-private/template-explorer/src/index.ts:91-95。formatErrorпреобразуетCompilerErrorизlocв MonacostartLineNumber/startColumn/endLineNumber/endColumn 📎 packages-private/template-explorer/src/index.ts:108-119. Обратите вниманиеerrors.filter(e => e.loc)— только ошибки с информацией о позиции будут помечены, ошибки безloc(например, ошибки глобальной конфигурации) выводятся только в консоль.

Шаг третий: построение SourceMap.После успешной компиляции,lastSuccessfulMap = new SourceMapConsumer(map!) 📎 packages-private/template-explorer/src/index.ts:99, сразу за этим вызываетсяcomputeColumnSpans() 📎 packages-private/template-explorer/src/index.ts:100。computeColumnSpans— этоsource-map-jsключевой APIgeneratedPositionFor: он предвычисляет диапазон столбцов каждого сегмента сопоставления, делая доступным полеlastColumn, возвращаемое

. Без этого шага обратное сопоставление может определить только начальный столбец и не может подсветить весь диапазон токена.Шаг четвёртый: двунаправленное сопоставление курсора.Когда пользователь вредакторе исходного кодаeditor.onDidChangeCursorPosition 📎 packages-private/template-explorer/src/index.ts:184перемещает курсор, срабатываетlastSuccessfulMap.generatedPositionFor({ source: 'ExampleTemplate.vue', line, column: column - 1 }) 📎 packages-private/template-explorer/src/index.ts:188-192. После debounce в 100 мс callback вызываетcolumn - 1. Обратите вниманиеpos: номера столбцов в Monaco начинаются с 1, а в SourceMap — с 0. Возвращённыйline, если содержитcolumnи📎 packages-private/template-explorer/src/index.ts:194-206, создаёт декоратор в выходном редакторе для подсветки соответствующего диапазона📎 packages-private/template-explorer/src/index.ts:207-210。

, и прокручивает к этой позицииoutput.onDidChangeCursorPositionОбратное сопоставление находится в📎 packages-private/template-explorer/src/index.ts:223originalPositionFor 📎 packages-private/template-explorer/src/index.ts:227-230. Оно вызываетpos.line === 1 && pos.column === 0«mock location»📎 packages-private/template-explorer/src/index.ts:231-237. Этот guard критически важен — некоторые фрагменты кода, генерируемые компилятором (например,importоператоры или helper-функции), не имеют соответствующей позиции в шаблоне, и SourceMap возвращает{ line: 1, column: 0 }в качестве заполнителя. Если их не игнорировать, курсор на этих строках будет ошибочно подсвечивать первую строку шаблона.

Шаг пятый: сохранение состояния. reCompileне только запускает компиляцию, но и отвечает за запись текущего состояния в localStorage и URL hash📎 packages-private/template-explorer/src/index.ts:121-146. При сохранении есть логика обрезки: перебираетсяcompilerOptions, сохраняются только элементы, которые «не являются объектами и не равны значению по умолчанию»📎 packages-private/template-explorer/src/index.ts:125-133. Это объясняет, почемуbindingMetadataопции объектного типа не сохраняются — они слишком сложны, а значений по умолчанию достаточно для демонстрации.

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

Размышления о дизайне и подводные камни в продакшене

Почему используетсяsource-map-js, а неsource-map? source-map— это оригинальная библиотека Mozilla, большая по размеру и зависящая от WASM (в новых версиях).source-map-js— это чистая JS-реализация, компактная и подходящая для браузерной среды. Template Explorer как чисто фронтенд-инструмент, выборsource-map-jsобоснован📎 packages-private/template-explorer/package.json:15。

Выбор задержки debounce.В редакторе исходного кода debounce по умолчанию 300 мс📎 packages-private/template-explorer/src/index.ts:271, а debounce перемещения курсора — 100 мс📎 packages-private/template-explorer/src/index.ts:215. Эта разница намеренная: компиляция — тяжёлая операция, 300 мс предотвращают частые срабатывания; перемещение курсора — лёгкая операция, 100 мс обеспечивают отзывчивость. Но 100 мс всё ещё может вызывать мерцание подсветки при быстром перемещении курсора — это приемлемый компромисс.

window.initГлобальное монтирование. Обратите внимание,window.initиwindow.monacoоба привязаны к глобальному📎 packages-private/template-explorer/src/index.ts:19-23. Это связано с тем, что редактор Monaco асинхронно загружается через CDNloader.js, и после завершения загрузки вызываетсяwindow.init. Этот паттерн «глобального колбэка» — стандартный способ использования Monaco в немодульной среде, но он плохо сочетается с современными ESM-сборками.

---

II. Панель опций на основе reactive: options.ts

Интуитивная модель

options.tsпохож на «панель управления»: сверху более десятка переключателей и радиокнопок, каждая соответствует определённому поведению компилятора. Переключение любого из них мгновенно меняет результат компиляции справа. Без этого модуля разработчикам пришлось бы менять параметры вызоваcompileв исходном коде и перекомпилировать, не имея возможности сравнивать эффекты разных опций в реальном времени.

Структура данных и размещение в памяти

options.tsЯдро

ssrMode— этоref(false) 📎 packages-private/template-explorer/src/options.ts:5. Он независим отcompilerOptions, поскольку режим SSR переключает саму функцию компиляции (compile vs ssrCompile), а не опции компиляции.

defaultOptions— это полный объектCompilerOptions.📎 packages-private/template-explorer/src/options.ts:5-27. Он определяет значения по умолчанию для всех опций, включаяmode: 'module'、prefixIdentifiers: false、hoistStatic: false、cacheHandlers: false、scopeId: null、inline: false、ssrCssVars: '{ color }'、compatConfig: { MODE: 3 }、whitespace: 'condense', а такжеbindingMetadata 📎 packages-private/template-explorer/src/options.ts:18-26。

compilerOptions, содержащий 7 типов привязок.reactive(Object.assign({}, defaultOptions)) 📎 packages-private/template-explorer/src/options.ts:29-31— этоObject.assign({}, ...). Обратите внимание, здесь используетсяreactive(defaultOptions)для поверхностного копирования — если напрямуюcompilerOptions, изменениеdefaultOptionsзагрязнитreCompile, что приведёт к неработоспособности логики «сравнения со значением по умолчанию» в

Step-by-Step Walkthrough

Сценарий: пользователь кликает по чекбоксу «hoistStatic».

Шаг первый: рендеринг UI. AppКомпонентsetupвозвращает функцию рендеринга📎 packages-private/template-explorer/src/options.ts:33-35. Эта функция рендеринга читаетssrMode.value、compilerOptions.mode、compilerOptions.prefixIdentifiersи другие реактивные состояния📎 packages-private/template-explorer/src/options.ts:36-39, поэтому при изменении этих состояний весь UI перерисовывается.

Шаг второй: привязка checked чекбокса. hoistStaticСвойствоcheckedчекбокса — этоcompilerOptions.hoistStatic && !isSSR 📎 packages-private/template-explorer/src/options.ts:150. Здесь есть логика: в режиме SSRhoistStaticпринудительно отображается как невыбранный, поскольку SSR-компиляция не поддерживает статический hoisting. В то же времяdisabled: isSSR 📎 packages-private/template-explorer/src/options.ts:151гарантирует, что пользователь не сможет переключить его в режиме SSR.

Шаг третий: обработка onChange.Когда пользователь кликает по чекбоксу,onChangeвызывает📎 packages-private/template-explorer/src/options.ts:152-156, напрямую присваиваяe.target.checkedвcompilerOptions.hoistStatic. ПосколькуcompilerOptionsявляетсяreactive, это присваивание запускает отслеживание зависимостей, что в свою очередь вызываетwatchEffect(reCompile) 📎 packages-private/template-explorer/src/index.ts:266и в итоге перекомпиляцию.

Шаг четвёртый: взаимосвязь опций.Обратите внимание,cacheHandlersдляchecked— этоusePrefix && compilerOptions.cacheHandlers && !isSSR 📎 packages-private/template-explorer/src/options.ts:166,disabled— это!usePrefix || isSSR 📎 packages-private/template-explorer/src/options.ts:167. Это означает, чтоcacheHandlersзависит отprefixIdentifiersилиmode === 'module'. Эта взаимосвязь проявляется в UI так: когдаprefixIdentifiersне включён и режим —function,cacheHandlersчекбокс отключён.

scopeIdВзаимосвязьdisabled: !isModule 📎 packages-private/template-explorer/src/options.ts:182,checked: isModule && compilerOptions.scopeId 📎 packages-private/template-explorer/src/options.ts:183сложнее:isModule. Только в режиме module можно установить scopeId, и при onChange, еслиnull 📎 packages-private/template-explorer/src/options.ts:184-189。

равно false, принудительно устанавливается initOptionsШаг пятый: монтирование.createApp(App).mount(document.getElementById('header')!) 📎 packages-private/template-explorer/src/options.ts:232-234вызываетсяvue. Обратите внимание, здесь используетсяcreateAppиз пакета@vue/runtime-dom, а неoptions.ts— потому чтоvue— это код прикладного уровня, который может напрямую зависеть от полного пакета

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

Размышления о дизайне и подводные камни в продакшене

Почему используетсяreactive, а неref? compilerOptions— это объект с более чем десятком полей, использованиеreactiveпозволяет напрямуюcompilerOptions.hoistStatic = true, без необходимостиcompilerOptions.value.hoistStatic = true. Это лаконичнее в UI-коде. Но ценаreactiveв том, что деструктуризация теряет реактивность — в исходном коде нет никакой деструктуризации, всё доступно черезcompilerOptions.xxx, и это правильное использование.

bindingMetadataДизайн значений по умолчаниюЗначения по умолчанию📎 packages-private/template-explorer/src/options.ts:18-26содержат 7 привязокSETUP_CONST、SETUP_REF、SETUP_LET、SETUP_MAYBE_REF、PROPS, охватывающихprefixIdentifiersпять типов. Это сделано для того, чтобы разработчик, открыв$setup, сразу увидел влияние разных типов привязок на способ доступаprefixIdentifiersв результате. Без этого значения по умолчанию

compatConfigэффект был бы очень однообразным. compilerOptions.compatConfig!.MODE = 2 📎 packages-private/template-explorer/src/options.ts:216-220Вложенная реактивностьreactiveТакое вложенное присваивание вreactiveявляется реактивным, посколькуcompatConfigрекурсивно проксирует вложенные объекты. Но обратите внимание, типCompatConfig | undefined—!, поэтому используется утверждениеcompatConfig. Если бы в значениях по умолчанию не было

ssrMode, здесь произошёл бы краш во время выполнения.compilerOptionsРазделение ответственности ssrModeиref,compilerOptions—reactiveэтоssr— этоcompilerOptions. Почему бы не поместитьssrвCompilerOptions? Потому что

---

не является полем

— он определяет, какую функцию компиляции использовать, а не параметры, передаваемые в функцию компиляции. Это разделение «состояния потока управления» и «состояния конфигурации» — ясный дизайн.

theme.tsЭто как «сменить скин» для редактора: он определяет цвет и стиль шрифта для каждого синтаксического токена. Без этого модуля Monaco будет использовать тему по умолчаниюvs-darkТема хоть и работает, но HTML-теги, выражения и директивы в шаблонах Vue будут лишены визуального различения, и разработчику будет трудно быстро находить ключевые части.

Структура данных и размещение в памяти

theme.tsЭкспортируется объект, соответствующий интерфейсу MonacoIStandaloneThemeDataинтерфейсу📎 packages-private/template-explorer/src/theme.ts:1-244. У него есть три поля верхнего уровня:

base: 'vs-dark'Указывает базовую тему📎 packages-private/template-explorer/src/theme.ts:2,inherit: trueОбозначает правила наследования базовой темы📎 packages-private/template-explorer/src/theme.ts:3. Это означает, что нужно определять только различия, а неопределённые токены будут fallback кvs-dark。

rules— это массив, каждый элемент которого содержитtoken(имя токена Monaco) иforeground/background/fontStyle 📎 packages-private/template-explorer/src/theme.ts:4-235. Этот массив содержит более 50 записей и охватывает такие типы токенов, как number, comment, keyword, string, variable, entity.name.tag и другие.

colorsОпределяет цвета UI редактора📎 packages-private/template-explorer/src/theme.ts:236-243:editor.foreground、editor.background、editor.selectionBackground、editor.lineHighlightBackground、editorCursor.foreground、editorWhitespace.foreground。

Step-by-Step Walkthrough

Сценарий: регистрация темы при загрузке страницы.

Шаг первый: определение темы. monaco.editor.defineTheme('my-theme', theme) 📎 packages-private/template-explorer/src/index.ts:44. Этот вызов регистрируетtheme.tsэкспортируемый объект в реестре тем Monaco под ключом'my-theme'。

Шаг второй: активация темы. monaco.editor.setTheme('my-theme') 📎 packages-private/template-explorer/src/index.ts:45. Эта строка кода должна вызываться послеdefineTheme, иначе будет выброшена ошибка «тема не определена».

Шаг третий: сопоставление токенов.Когда Monaco рендерит код шаблона, он токенизирует код с помощью языковой службы HTML, а затем ищет по имени токена правила вrules. Например,<div>вdivбудет помечен какentity.name.tag, сопоставится сforeground: 'cc6666' 📎 packages-private/template-explorer/src/theme.ts:41-44и отобразится красным.

Проектные соображения и подводные камни в продакшене

Почему используетсяinherit: true?Если не наследовать, придётся определять цвета всех токенов, включая те, которые не встречаются в шаблоне (например,markup.heading、meta.diff). Наследование позволяет файлу темы сосредоточиться только на токенах, реально встречающихся в шаблоне и JS-выходе.

Иерархическое сопоставление имён токенов.Сопоставление токенов в Monaco основано на префиксах:entity.name.tagбудет соответствоватьentity.name.tag.html、entity.name.tag.cssи т. д. В исходном коде одновременно определеныentity.name.tag 📎 packages-private/template-explorer/src/theme.ts:41-44иentity.name.tag.css 📎 packages-private/template-explorer/src/theme.ts:169-172, причём последний перекрывает первый в специфичных для CSS сценариях.

colorsРазделение обязанностей междуrulesи rulesуправляет цветом текста кода,colorsуправляет цветами UI редактора (фон, курсор, выделенная строка). Они независимы, но должны визуально согласовываться. В исходном кодеeditor.background: '#1D1F21'иbase: 'vs-dark'близки к фону по умолчанию, чтобы сохранить визуальную согласованность.

---

Проектное соображение: инженерные компромиссы визуального зонда

Ключевое различие между Template Explorer и SFC Playground — это «гранулярность наблюдения». Playground наблюдает за тем, «может ли скомпилированный целиком SFC запуститься», а Template Explorer наблюдает за тем, «во что компилируется отдельное выражение шаблона». Это различие определяет технический выбор обоих инструментов:

Введение SourceMapConsumer неизбежно.Без него разработчик мог бы лишь визуально сравнивать исходный код и результат и не мог бы построить точное сопоставление «строка N → строка M». Но API SourceMapConsumer асинхронный (новые версии возвращают Promise), а в исходном коде используется синхронная версияsource-map-js, чтобы упростить логику вызова.

reactiveУправление опциями — естественный выбор для экосистемы Vue.Если вручную управлять синхронизацией состояния десятков опций через нативные DOM-события, объём кода удвоится.reactiveОтслеживание зависимостей вwatchEffect(reCompile)позволяет автоматизировать цепочку «изменение опции → повторная компиляция», и одна строка кода выполняет подписку.

Глобальный режим загрузки Monaco — это исторический багаж. window.monacoГлобальный способ монтированияwindow.initи

---

Краткое содержание главы

Template Explorer — это «белый ящик-зонд»: он не запускает результат компиляции, а только показывает процесс компиляции.index.tsЧерезcompileCodeвызывается@vue/compiler-domили@vue/compiler-ssr, с помощьюSourceMapConsumerстроится двустороннее сопоставление исходного кода и результата, а через API декораторов Monaco реализуется синхронная подсветка по курсору.options.tsС помощьюreactiveуправляютсяCompilerOptions, черезwatchEffectзапускается повторная компиляция, а взаимосвязи между опциями (например, отключениеhoistStaticпри SSR) явно закодированы на уровне UI.theme.tsНастраивается тема Monaco, чтобы синтаксические токены шаблона и результата имели чёткое визуальное различие.

Основная ценность этого инструмента в том, чтобы «с помощью инструмента обратно выводить поведение компилятора»: когда вы не уверены, чтоhoistStaticделает с некоторым шаблоном, откройте Template Explorer, переключайте опции и наблюдайте за изменениями результата. Это нагляднее, чем читать исходный код компилятора, и надёжнее, чем догадываться.

Вопросы для размышления и самопроверки в этой главе

Q1: Если удалитьindex.tsвoriginalPositionFormock location guard (pos.line === 1 && pos.column === 0), в каком сценарии это приведёт к ошибочной подсветке? Почему компилятор генерирует такое сопоставление, как{ line: 1, column: 0 }?

Справочное объяснение: guard находится в📎 packages-private/template-explorer/src/index.ts:231-237. При генерации результата компилятор вставляет некоторый код, не имеющий соответствующей позиции в шаблоне, например импорт helper-функций вродеimport { createElementVNode as _createElementVNode } from 'vue'или сигнатуры функций вродеexport function render(_ctx, _cache) { ... }. У этого кода нет исходной позиции в SourceMap,source-map-jsвернёт{ line: 1, column: 0 }в качестве заполнителя. Если удалить guard, то когда пользователь поставит курсор на эти строки,originalPositionForвернёт{ line: 1, column: 0 }, код будет считать это допустимой позицией и создаст декоратор подсветки в первой строке и первом столбце редактора исходного кода. В результате: когда пользователь нажимает на строку артефактаimport, первая строка редактора исходного кода ошибочно подсвечивается, что вводит в заблуждение. Суть этой защиты — «различать реальное сопоставление и заполнитель», а{ line: 1, column: 0 }— этоsource-map-jsсогласованное сигнальное значение «нет сопоставления».

Q2: reCompileпри параметрах персистентности условиеtypeof val !== 'object' && val !== defaultOptions[key]пропускает параметры всех типов объектов. ЕслиbindingMetadataизменён пользователем (например, через консоль), после обновления страницы это изменение будет потеряно. Это баг или намеренное решение? Если нужно поддерживатьbindingMetadataв персистентности, какие проблемы потребуется решить?

Справочный разбор: условие находится в📎 packages-private/template-explorer/src/index.ts:129. Это намеренное решение по трём причинам: во-первых,bindingMetadataзначение — этоBindingTypesперечисление, после сериализации это число, и при десериализации невозможно отличить «пользователь явно установил 0» от «значения по умолчанию»; во-вторых,compatConfig— это вложенный объект,val !== defaultOptions[key]сравнивает ссылки, всегда true, что приведёт к персистентности всех объектных параметров; в-третьих,nodeTransformsсодержит функции, которые невозможно сериализовать, в исходном коде уже черезdelete persistedState.options?.nodeTransformsобрабатывается📎 packages-private/template-explorer/src/index.ts:69. Если нужно поддерживатьbindingMetadata, потребуется реализовать глубокое сравнение (а не сравнение ссылок), а также обработку сериализации/десериализации значений перечислений. Более фундаментальная проблема:bindingMetadataне имеет точки редактирования в UI, пользователь может изменить только через консоль, и такое изменение само по себе не должно сохраняться.

Q3: options.tsвcompilerOptionsсоздаётся с помощьюreactive(Object.assign({}, defaultOptions)). Если изменитьObject.assign({}, defaultOptions)на прямоеreactive(defaultOptions), что произойдёт после того, как пользователь переключит параметр и обновит страницу? Почему?

Справочный разбор:Object.assign({}, defaultOptions)— это поверхностная копия, находится в📎 packages-private/template-explorer/src/options.ts:29-31. Если изменить наreactive(defaultOptions),compilerOptionsиdefaultOptionsбудут указывать на один и тот же объект. Когда пользователь переключитhoistStaticв true,compilerOptions.hoistStaticстанет true, и одновременноdefaultOptions.hoistStaticтоже станет true. Затем логика персистентности вreCompile📎 packages-private/template-explorer/src/index.ts:129сравнитval !== defaultOptions[key], в этот моментvalиdefaultOptions[key]оба true, условие false, и этот параметр не будет сохранён в localStorage. После обновления страницыdefaultOptionsбудет заново инициализирован какhoistStatic: false, изменение пользователя потеряно. Что ещё серьёзнее, после загрязненияdefaultOptionsвся последующая логика «сравнения со значением по умолчанию» перестанет работать, что приведёт к полному краху функциональности персистентности. Коварство этого бага в том, что в рамках одной сессии всё нормально, и только после обновления его можно обнаружить.

---

В следующей главе мы перейдём кscripts/release.js, чтобы увидеть, как Vue с помощью одного интерактивного конечного автомата оркестрирует весь процесс обновления версии, сборки, тестирования, Git-коммита, создания тега и npm publish. В отличие от «наблюдения» в Template Explorer, release.js — это «исполнение»: ему нужно поддерживать состояние между несколькими шагами, обрабатывать откат при сбое и находить баланс между интерактивным подтверждением и автоматизацией.

Через Template Explorer мы освоили, как превратить внутреннее состояние компилятора — AST, артефакты компиляции, SourceMap — в интерактивные визуализационные зонды, тем самым превращая «почему компилятор генерирует именно так» из догадки в наблюдение. Этот точный контроль и оркестрация внутреннего состояния также проявляются в процессе релиза Vue: в следующей главе мы углубимся в scripts/release.js и посмотрим, как конечный автомат более чем на 500 строк с помощью parseArgs разбирает более десяти флагов, через enquirer интерактивно подтверждает номер версии и последовательно запускает сборку, тестирование, Git-коммит, создание тега и npm publish, раскрывая полный поток состояний и стратегию отката при сбое за одним официальным релизом.

CHAPTER 09

Глава 9: Автоматизация релиза: конечный автомат и интерактивная оркестрация в release.js

Проект: vuejs/core · Прогресс книги: Глава 9 / 14 · Статус проверки: FACT номера строк реально привязаны

В предыдущей главе мы с помощью template-explorer восстановили поведение компилятора и освоили методологию наблюдения за внутренними механизмами с помощью инструментов. Теперь переведём взгляд с времени компиляции на время релиза — это самый опасный момент для любого open-source проекта: он одновременно затрагивает четыре необратимые внешние системы: номер версии, артефакты сборки, историю Git и npm registry. Ошибочный npm publish невозможно отозвать, ошибочный push тега загрязнит разрешение зависимостей всех downstream-пользователей. Vue core использует scripts/release.js из 537 строк, чтобы укротить эту опасность — это не чисто автоматизированный скрипт и не чисто ручной чек-лист, а интерактивный конечный автомат: останавливается и спрашивает человека в ключевых точках, полностью автоматически выполняет предсказуемые шаги и при сбое на любом шаге откатывает номер версии к исходному. В этой главе мы разберём три ключевых механизма этого оркестратора: разбор аргументов и инициализацию состояния, интерактивное решение о версии и CI-гейт, а также порядок релиза и откат при сбое.

Разбор аргументов и инициализация глобального состояния

Интуитивная модель

Представьтеrelease.jsкак панель управления старой стиральной машины: ручка (parseArgsРаскладка флагов и глобального состояния в памяти

Разметка памяти для флагов и глобального состояния

〔Проектирование, выводы и архитектурные компромиссы〕

Первое, что делает скрипт после запуска, — разбирает аргументы командной строки в структурированный объект. Здесь используется встроенный в NodeparseArgs, а неyargsилиcommander— это сделано для устранения сторонних зависимостей, поскольку сам скрипт публикации должен запускаться в любом окружении, даже еслиnode_modulesустановлен наполовину.

📎 scripts/release.js:27-62определяет 10 опций, которые можно разделить на четыре категории:

  • Категория семантики версии:preid(идентификатор предварительного выпуска, напримерalpha/beta/rc)、tag(npm dist-tag)
  • Категория пропуска:skipBuild、skipTests、skipGit、skipPrompts— эти четыре булевых переключателя образуют «регуляторы степени автоматизации»
  • Категория режима выполнения:dry(холостой прогон),publish(публиковать ли напрямую локально),publishOnly(только публикация без обновления версии)
  • Категория цели:registry(пользовательский адрес registry)

Обратите внимание:publishзначение по умолчанию —false 📎 scripts/release.js:51-54, а у других булевых параметров нет значения по умолчанию (то естьundefined). Эта асимметрия намеренна:publishсемантика — «выполнять ли npm publish локально», по умолчанию не публиковать, передавая действие публикации GitHub Actions; аskipXxxпо умолчаниюundefinedозначает «не указано», и последующая логика будет различать «пользователь явно передал--skipTests» и «пользователь не передал».

После завершения разбора скрипт раскладывает параметры на набор переменных уровня модуля📎 scripts/release.js:64-66:

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

Здесь есть два интересных момента проектирования. Во-первых,preIdприоритет значения — «явно указано в командной строке > выведено из текущего номера версии»📎 scripts/release.js:64-66. Если текущаяpackage.jsonверсия —3.5.0-beta.1, тоsemver.prereleaseвернёт['beta', 1], взяв[0], получим'beta'. Это означает, что при последовательных выпусках в ветке beta не нужно каждый раз вводить--preid beta. Во-вторых,skipTestsобъявлена черезlet, а остальные черезconst 📎 scripts/release.js:64-66, потому что она вrunTestsIfNeededдинамически перезаписывается результатом CI — это состояние «отложенного решения».

Сразу за этим идёт логика обнаружения пакетов📎 scripts/release.js:68-83: читается каталогpackages/, отфильтровываются не-каталоги, элементы безpackage.json, а также пакетыprivate: true. Обратите внимание, что здесь читаетсяpackages/, а неpackages-private/— последний является внутренним отладочным пакетом и никогда не публикуется.

Алгоритм сортировки порядка публикации

📎 scripts/release.js:85-85определяет функцию, которая кажется простой, но критически важна:

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

Она ставит входной пакетvueв конец. Комментарий📎 scripts/release.js:85-85объясняет причину: если сначала опубликоватьvue, пользователи смогут установить новую версию@vue/runtime-core, пока внутренние пакеты вродеvueещё не выложены, и npm выдаст ошибку из-за отсутствия подходящей внутренней зависимости. Это компромиссное решение «атомарности публикации» в экосистеме npm — в npm нет межпакетных транзакций, и атомарность можно лишь приблизить порядком.

Динамическое построение набора кандидатов приращения версии

📎 scripts/release.js:111-116строит варианты для интерактивного меню:

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

Это условное раскрытие: только когдаpreIdсуществует (то есть текущий канал — предварительный выпуск или пользователь явно указал--preid), в меню добавляются типы приращения, связанные с предварительным выпуском. Если текущая версия стабильная3.5.43иpreidне указан, в меню остаются толькоpatch/minor/majorтри пункта — чтобы пользователь по ошибке не превратил стабильную версию в такой половинчатый предварительный выпуск, как3.5.44-0.

incФункция📎 scripts/release.js:120-120инкапсулируетsemver.inc, передаваяpreIdтретьим аргументом. Здесь есть защита типов:typeof preId === 'string' ? preId : undefined— потому чтоpreIdможет бытьstring | undefined, аsemver.incожидаетstring | undefined, это тернарное выражение нужно для сужения типов TS.

Примитивы выполнения: двухрельсовая система run и dryRun

📎 scripts/release.js:122-123— один из самых изящных приёмов во всей главе:

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

runустанавливает stdio дочернего процесса вinherit, позволяя выводу сборки/тестов напрямую проходить в терминал — это критически важно для длительных сборок, пользователь видит прогресс в реальном времени.dryRunже только печатает команду, не выполняя её.runIfNotDry— это «выбор стратегии»: при загрузке модуля указатель функции привязывается кdryRunилиrun, и всем последующим точкам вызова больше не нужно проверятьisDryRun。

〔Проектирование, выводы и архитектурные компромиссы〕

Такой шаблон «решить стратегию при инициализации» менее подвержен ошибкам, чем «проверять в каждой точке вызова»: если какая-то точка вызова забудет проверитьisDryRun, в режиме dry run побочный эффект действительно выполнится. АrunIfNotDryконцентрирует проверку в одном месте, устраняя возможность таких пропусков.

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)

---

Интерактивное решение по версии и CI-шлюз

Интуитивная модель

Этот этап похож на досмотр в аэропорту: сначала сверяют ваш посадочный талон (синхронизирован ли локальный commit с удалённым), затем подтверждают, куда вы летите (номер версии), и наконец проверяют, прошли ли вы досмотр (прошёл ли CI). Если любой из этапов не пройден, весь процесс останавливается. Без этого шлюза локальный commit, который не был отправлен, может получить tag и быть опубликован, из-за чего исходный код версии на npm вообще не будет существовать на GitHub — это самая трудно диагностируемая авария при публикации.

Проверка синхронизации и выбор версии

mainПервое, что делает функцияisInSyncWithRemote() 📎 scripts/release.js:141-141, — это📎 scripts/release.js:337-363. Логика этой функцииgit rev-parse HEADтакова: берётся имя текущей ветки, запрашивается GitHub API для получения SHA последнего commit этой ветки и сравнивается с локальным📎 scripts/release.js:348-355. Если они не совпадают, появляется диалог подтверждения с красным предупреждениемfalse, позволяя пользователю решить, продолжать ли. Если запрос к API не удался (проблемы с сетью, нет token), то сразу возвращается📎 scripts/release.js:365-367。

и

〔Проектирование, выводы и архитектурные компромиссы〕

Здесь философия проектирования — «сбой означает остановку»: при сетевой аномалии лучше не публиковать, чем рисковать и продолжать в неизвестном состоянии. Потому что публикация необратима, а стоимость повторного запуска скрипта очень низка.node scripts/release.js 3.6.0),targetVersionОпределение номера версии идёт двумя путями. Если пользователь передал позиционный аргумент в командной строке (например📎 scripts/release.js:141-141, берётся это значение📎 scripts/release.js:152-176. Иначе открывается интерактивное менюcustom: сначала пользователь выбирает тип приращения, а если выбран

, появляется ещё одно поле ввода, чтобы пользователь вручную ввёл номер версии.📎 scripts/release.js:174Обратите внимание на строку

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

Формат пункта меню —patch (3.5.44), и это регулярное выражение извлекает фактический номер версии из скобок. Если пользователь выбралcustom, идёт другая ветка📎 scripts/release.js:164-172。

Затем есть логика «повторного разбора»📎 scripts/release.js:178-182: еслиtargetVersionоказываетсяpatch/minorТакие инкрементальные ключевые слова (пользователь может напрямую передатьnode release.js minor), вызываетсяincчтобы преобразовать его в конкретный номер версии. В конце используетсяsemver.validдля проверки📎 scripts/release.js:184-186, недопустимый номер версии сразу вызывает ошибку.

CI-гейт: трёхсостоятельная логика runTestsIfNeeded

Это самый сложный поток управления во всей главе.📎 scripts/release.js:281-317вrunTestsIfNeededфактически представляет собой трёхсостоятельный решающий автомат:

Состояние первое: пользователь явно передал--skipTests。skipTestsизначально установлен вtrue, сразу пропускается всё тело функции, выводится "Tests skipped."📎 scripts/release.js:314-316。

Состояние второе: не пропущено, и CI уже пройден. Скрипт вызываетgetCIResult() 📎 scripts/release.js:319-335, который запрашивает GitHub Actions API, проверяя наличие workflow run с именемciиconclusion === 'success'📎 scripts/release.js:319-335. Если пройден, пользователю задаётся вопрос «CI пройден, пропустить локальные тесты?»📎 scripts/release.js:288-295. Если пользователь включил--skipPrompts, локальные тесты автоматически пропускаются📎 scripts/release.js:296-298。

Состояние третье: не пропущено, и CI не пройден. Если включён--skipPrompts, сразу выбрасывается ошибка📎 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.',
)

Если не включён--skipPrompts, тоskipTestsостаётсяundefined, попадает в последнюю ветку локальных тестов📎 scripts/release.js:307-313, выполняетсяpnpm run test --run。

Здесь есть тонкая деталь📎 scripts/release.js:285:

js
skipTests ||= isCIPassed

||=— это логическое присваивание: только когдаskipTestsявляется ложным значением (undefinedилиfalse) происходит присваиваниеisCIPassed. Это означает, что если пользователь явно передал--skipTests(true), эта строка не изменит его; если пользователь не передал (undefined), то устанавливается результат CI. Но сразу после📎 scripts/release.js:287-298снова перезаписывается при прохождении CI — поэтому||=реальный эффект этой строки только «если CI не пройден, установитьskipTestsвfalse», тем самым заставляя последующую веткуif (!skipTests)выполнить локальные тесты.

〔Проектные предположения и архитектурные компромиссы〕

Эта логика обходит круг, по сути выражая: «CI пройден → можно пропустить локальные тесты (но спросить пользователя); CI не пройден → обязательно запустить локальные тесты (если пользователь явно не требует пропуска)». Использование||=с последующей перезаписью хоть и компактно, но плохо читаемо — типичный «запах кода», когда бит состояния изменяется в нескольких местах.

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: запрос подтверждения продолжения?
        Dev-->>Main: да/нет
    end
    Main->>Dev: запрос выбора инкремента версии
    Dev-->>Main: "patch (3.5.44)"
    Main->>Main: проверка semver.valid
    Main->>GH: getCIResult() запрос workflow_runs
    GH-->>Main: workflow_runs[]
    alt CI прошёл
        Main->>Dev: запрос пропуска локальных тестов?
        Dev-->>Main: да
    else CI не прошёл
        Main->>Pnpm: run test --run
        Pnpm-->>Main: код выхода
    end
    Main->>Main: updateVersions(targetVersion)

Запись номеров версий: обход в updateVersions

📎 scripts/release.js:377-384вupdateVersionsделает две вещи: обновляет корневойpackage.json, затем обходит все подпакеты, вызываяupdatePackage。updatePackage 📎 scripts/release.js:391-398для чтения JSON, измененияnameиversion, записи обратно черезJSON.stringify(pkg, null, 2) + '\n'— обратите внимание на\nв конце, это для сохранения завершающего перевода строки в файле, чтобы git diff не показывал "No newline at end of file".

getNewPackageNameПараметр по умолчаниюkeepThePackageName 📎 scripts/release.js:105, то есть имя пакета не изменяется. Этот параметр существует для поддержки сценария «переименование пакета при публикации в пользовательский registry» — хотя все текущие точки вызова передают значение по умолчанию, интерфейс оставляет возможность расширения.

---

Порядок публикации, идемпотентность и откат при сбое

Интуитивная модель

Этот этап похож на домино:updateVersionsтолкает первую костяшку (изменение номера версии), затем последовательно падают changelog, lockfile, commit, tag, publish. Если какая-то костяшка застревает на полпути, должен быть механизм, поднимающий уже упавшие костяшки — иначе репозиторий останется в половинчатом состоянии «номер версии изменён, но не опубликован».

Идемпотентная публикация: isPackagePublished и обработка ошибок

〔Проектные предположения и архитектурные компромиссы〕

publishPackage 📎 scripts/release.js:439-489— ядро публикации. Сначала определяется dist-tag📎 scripts/release.js:442-451: приоритетно используется--tagпараметр, иначе выводится из ключевого словаalpha/beta/rcв номере версии. Обратите внимание, здесь используетсяversion.includes('alpha')а неsemver.prerelease— потому что номер версии может иметь вид3.5.0-alpha.1,includesдостаточно простой и не даёт ложных срабатываний.

Перед публикацией есть проверка идемпотентности📎 scripts/release.js:453-458:

js
if (!isDryRun && (await isPackagePublished(packageName, version))) {
  console.log(pico.yellow(`Skipping already published: ${pkgVersion}`))
  alreadyPublishedPackages.push(pkgVersion)
  return
}

isPackagePublished 📎 scripts/release.js:491-513выполняетnpm view <pkg>@<version> version, при успехе возвращаетtrue, при ошибке типа E404 возвращаетfalse. Смысл этой проверки в том, что процесс публикации может перезапускаться из-за обрыва сети, и при перезапуске уже опубликованные пакеты не должны публиковаться снова (npm отклонит дублирующую версию).

Но сама проверка тоже может завершиться неудачей — например,npm viewиз-за таймаута сети выбросит ошибку, не являющуюся E404. В этом случаеisPackagePublishedпробрасывает ошибку наверх📎 scripts/release.js:507-510, что приводит к прерыванию всей публикации. Это ещё одно проявление принципа «лучше прервать, чем рисковать».

Даже если проверка пройдена,pnpm publishсам по себе всё ещё может завершиться неудачей из-за гонки (другой CI только что опубликовал ту же версию). ПоэтомуpublishPackageв блоке catch делает вторичную страховку📎 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
  }
}

Только при совпадении сpreviously publishedошибка проглатывается, все остальные ошибки пробрасываются. Это «точная отказоустойчивость»: деградация только для известных, безопасно игнорируемых ошибок.

Динамическая сборка флагов публикации

📎 scripts/release.js:412-432собирает в зависимости от среды выполненияpnpm publishдополнительные флаги:

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-checksвключается в трёх случаях: dry run, пропуск git, или в CI. Причина в том, чтоpnpm publishпо умолчанию проверяет, чист ли рабочий каталог, является ли текущая ветка веткой релиза и т.д., а в CI эти проверки дают ложные срабатывания.

--provenanceвключается только в CI и когда не указан пользовательский registry📎 scripts/release.js:425-427. provenance — это функция безопасности цепочки поставок npm, она подписывает и прикрепляет к пакету информацию об источнике артефакта сборки (какой commit, какой workflow). Но пользовательский registry (например, внутренний приватный registry) обычно не поддерживает provenance, поэтому добавлено условие!args.registry.

Откат при сбое: флаг versionUpdated

Возвращаясь к концуmain📎 scripts/release.js:528-537:

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

versionUpdated— это булева переменная уровня модуля, изначальноfalse 📎 scripts/release.js:24-27, устанавливается вupdateVersionsсразу после успешного вызоваtrue 📎 scripts/release.js:208. Если любой последующий шаг (генерация changelog, обновление lockfile, git commit, publish) выбрасывает ошибку, блок catch проверяет этот флаг, и если онtrue, откатывает номер версии кcurrentVersion。

〔Проектные предположения и архитектурные компромиссы〕

Этот откат является «по мере возможности»: он откатывает толькоpackage.jsonномер версии в , не откатывает файл changelog, не откатывает lockfile, не откатывает уже выполненный git commit. Если ошибка произошла после git commit, в репозитории останется промежуточное состояние «номер версии откачен, но commit уже существует». Это компромисс на уровне проектирования — полный откат требуетgit reset, а это разрушило бы другие изменения, которые пользователь мог уже сделать. Поэтому скрипт выбирает откат только самого критичного — номера версии, оставляя остальное пользователю для ручной обработки.

Обратите внимание:publishOnlyпуть📎 scripts/release.js:519-526не устанавливаетversionUpdated, потому что его семантика — «только публикация, без изменения версии» — даже при сбое откат не нужен. Но он при наличииtargetVersionвызываетupdateVersions 📎 scripts/release.js:519-526, и в этом случае при сбое номер версии не будет откачен. Это потенциальная пограничная проблема, см. вопросы для размышления в конце главы.

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

Порядок публикации и особая обработка пакета vue

publishPackages 📎 scripts/release.js:412-432перебираетsortPackagesForPublishing(packages)результаты и поочерёдно вызываетpublishPackage. Поскольку сортировка ставитvueпоследним📎 scripts/release.js:85-85, вся последовательность публикации гарантирует, что внутренние пакеты выходят первыми.

publishPackageвнутри используетcwd: getPkgRoot(pkgName) 📎 scripts/release.js:475для переключения рабочего каталога в каталог подпакета, так чтоpnpm publishпубликует подпакет, а не корневой пакет. Комментарий📎 scripts/release.js:462-463особо предупреждает «не менять на npm publish» — потому чтоpnpm publishкорректно обрабатываетworkspace:*протокол зависимостей, преобразуя его в фактический номер версии, аnpm publishсохранитworkspace:*как есть, что приведёт к сбою установки.

---

Размышления о проектировании

Почему используетсяparseArgs, а неyargs?Скрипт публикации — это «последняя линия обороны», он должен быть исполним в любой среде. Если сторонняя CLI-библиотека не загрузится из-за повреждённого дерева зависимостей, весь процесс публикации парализуется. Встроенный в NodeparseArgsхоть и функционально прост (не поддерживает подкоманды, не поддерживает автоматический help), но имеет нулевые зависимости и нулевой риск.

Почемуpublishпо умолчанию установлен вfalse?Потому что официальная публикация Vue идёт через GitHub Actions (см. подсказку в📎 scripts/release.js:256-263), а локальный скрипт отвечает только за изменение номера версии, генерацию changelog, создание тега и push. Настоящийnpm publishвыполняется в CI, что позволяет использовать provenance-подпись CI и контролируемую среду.--publishФлаг — это аварийный путь для мейнтейнеров для локальной публикации в экстренных случаях.

Почему откат откатывает только номер версии?Потому что полный откат требует понимания «какие изменения сделал скрипт, а какие — пользователь», а это на уровне git невозможно различить. Скрипт выбирает откат только того, что он точно знает, что изменил, —package.jsonномера версии, — остальное оставляя на усмотрение пользователя.

---

Краткое содержание главы

scripts/release.jsВ 537 строках кода реализован «интерактивный конечный автомат», ключевой дизайн которого можно свести к трём пунктам:

1. Параметры как стратегия: 10 флагов разбираются при загрузке модуля и раскладываются в глобальные переменные,runIfNotDryпри инициализации привязывает стратегию, избегая пропуска проверок в точках вызова.

2. Гейтинг на входе: синхронные проверки, валидация версии, CI-гейты выполняются до любых побочных эффектов, обеспечивая «либо всё, либо ничего».

3. Точная отказоустойчивость:isPackagePublished: предварительная проверка +previously publishedобработка ошибок образуют двойную идемпотентную защиту;versionUpdatedфлаг обеспечивает минимальный откат.

Этот механизм образует интересный контраст с Template Explorer из предыдущей главы: Template Explorer — это «наблюдение» — визуализация внутреннего состояния компилятора; release.js — это «исполнение» — явное представление каждого шага процесса публикации. Оба воплощают одну и ту же инженерную философию:Превратить неявное состояние в явное, а неконтролируемые побочные эффекты — в контролируемые шаги。

Вопросы и самопроверка к этой главе

Q1: Если изменить📎 scripts/release.js:285вskipTests ||= isCIPassedнаskipTests = isCIPassed, что произойдёт, когда пользователь явно передал--skipTestsи CI не прошёл? Почему?

Разбор ответа: В исходной логике, когда пользователь передаёт--skipTests,skipTestsизначально равноtrue 📎 scripts/release.js:64-66,||=и не изменяет его, поэтомуrunTestsIfNeededв📎 scripts/release.js:282приif (!skipTests)условие ложно, и происходит переход сразу к📎 scripts/release.js:314-316с выводом "Tests skipped.". Если изменить наskipTests = isCIPassed, тоskipTestsпринудительно устанавливается вfalse(CI не прошёл), затем📎 scripts/release.js:287вif (isCIPassed)ложно, и происходит переход к📎 scripts/release.js:299вelse if (skipPrompts)— если--skipPromptsне включён, тоskipTestsостаётсяfalse, и в итоге в📎 scripts/release.js:307-313выполняются локальные тесты. Это противоречит намерению пользователя «явно пропустить тесты», а в среде CI (--skipPrompts) тем более сразу выбросит ошибку📎 scripts/release.js:300-303, что приведёт к остановке публикации.||=Существование

Q2: publishOnlyкак раз для того, чтобы уважать явный выбор пользователя.📎 scripts/release.js:519-526ПутьtargetVersionпри наличииupdateVersionsвызываетversionUpdated, но не устанавливаетbuildPackages. Если в этот моментpublishPackagesили

выбросит ошибку, что произойдёт? Разумен ли такой дизайн?:publishOnlyРазбор ответаupdateVersions(targetVersion) 📎 scripts/release.js:519-526вызовpackage.jsonизменяет номера версий всехversionUpdated = true, но не устанавливаетbuildPackages 📎 scripts/release.js:519-526. Когда последующийpublishPackages 📎 scripts/release.js:519-526илиfnToRun().catch 📎 scripts/release.js:528-537выбрасывает ошибку,versionUpdatedпроверяетfalseкакpublishOnlyи не откатывает номер версии. В результате репозиторий остаётся в состоянии «номер версии изменён, но публикация не удалась». Этот дизайн разумен в рамках исходной семантикиtargetVersion(только публикация, без изменения версии) — потому чтоupdateVersionsобычно не передаётся,targetVersionне выполняется. Но когда пользователь передал📎 scripts/release.js:519-526, в этом пути существует уязвимость отката. Способ исправления — добавитьversionUpdated = trueпослеpublishOnly, или заставитьmainпереиспользовать логику отката

Q3: isPackagePublished 📎 scripts/release.js:491-513.npm viewиспользуетnpm viewдля проверки, опубликован ли пакет. Если из-за сетевого таймаута

выбросит ошибку, не являющуюся E404, что произойдёт? Безопасно ли такое поведение в сценарии повторного запуска CI?:isPackagePublishedРазбор ответа📎 scripts/release.js:507-510в блоке catchisPackageNotFoundErrorвызывает📎 scripts/release.js:515-515для определения типа ошибки. Эта функция/E404|No match found|No matching version|notarget/iсопоставляет толькоisPackageNotFoundError. Сообщение об ошибке сетевого таймаута не содержит этих ключевых слов, поэтомуfalse,isPackagePublishedвозвращает📎 scripts/release.js:507-510и перебрасывает ошибкуpublishPackage 📎 scripts/release.js:453, что приводит к остановке всего релиза. В сценарии повторного запуска CI это приводит к ситуации «пакет уже опубликован, но процесс прерван из-за сетевого сбоя» — однако это безопасное направление отказа: остановка лучше, чем ошибочное определение «не опубликован» и повторная публикация. Повторная публикация вызовет ошибку npmpreviously published, которая будет перехвачена📎 scripts/release.js:491-492, но потратит один сетевой round-trip. Поэтому «сетевая ошибка — значит остановка» — консервативный, но правильный выбор.

---

Следующая глава перейдёт к.github/workflows/, чтобы посмотреть, как после отправки tag из release.js GitHub Actions берёт на себя последующую сборку и публикацию, а также полную реализацию CI-гейтов.

Итак, мы увидели, как release.js с помощью конечного автомата и интерактивной оркестрации сводит к минимуму необратимые риски релиза. Но сам скрипт релиза — лишь исполнитель; тот, кто действительно решает, когда запускать и при каких условиях пропускать, — это автоматизированный привратник более высокого уровня. Следующая глава разберёт систему CI/CD в каталоге .github/workflows: как ci.yml выполняет тройной гейт lint/typecheck/test на этапе PR, как release.yml запускает публикацию при отправке tag, как size-report.yml и size-data.yml отслеживают регрессии размера пакета, как autofix.yml автоматически исправляет проблемы форматирования. Вы поймёте, как Vue с помощью GitHub Actions превращает инженерные стандарты в непреодолимый конвейер.

CHAPTER 10

Глава 10: Рабочие процессы CI/CD: автоматизированный привратник от PR до Release

Проект: vuejs/core · Прогресс книги: Глава 10 / 14 · Статус проверки: FACT — номера строк реально привязаны

В предыдущей главе мы увидели,scripts/release.jsкак с помощью интерактивного конечного автомата связать каждый шаг одного релиза. Но у этого скрипта есть предпосылка: он должен быть активно вызван кем-то или какой-то системой. В репозитории Vue core этот активный вызывающий — не локальный терминал мейнтейнера, а GitHub Actions. release.js — исполнитель, workflows —决策者: они решают, какое событие запускает какую задачу, при каких условиях пропустить, при каких условиях заблокировать. Эта глава сосредоточена на.github/workflows/четырёх файлах в каталоге:ci.yml(гейт PR и непрерывная предпубликация),release.yml(официальная публикация по tag),size-report.yml(отчёт о регрессии размера),autofix.yml(автоматическое исправление форматирования). Понимание их сути — не в запоминании синтаксиса YAML, а в том, чтобы увидеть, как команда Vue переводит инженерные стандарты в непреодолимые ограничения конвейера.

I. ci.yml: тройной гейт и непрерывная предпубликация

Интуитивная модель

Представьтеci.ymlкак пункт досмотра в аэропорту. Каждый PR должен пройти этот шлагбаум: lint проверяет, нет ли в вашем багаже запрещённых предметов, typecheck подтверждает подлинность ваших документов, test проверяет, не несёте ли вы опасных веществ. Но пункт досмотра не один — Vue также повесил здесь канал «непрерывной предпубликации», публикуя артефакты сборки каждого PR напрямую в pkg-pr-new, чтобы контрибьюторы могли проверить свои изменения в реальном сценарии установки из npm.

Без этого шлагбаума любое слияние могло бы привнести ошибки форматирования, типовые уязвимости или поведенческие регрессии в ветку main, а main — источник всех последующих release.

Условия запуска и управление параллелизмом

ci.ymlКонфигурация запуска

📎 .github/workflows/ci.yml:2-11

yaml
on:
  push:
    branches:
      - '**'
    tags:
      - '!**'
  pull_request:
    branches:
      - main
      - minor

КопироватьpushЗдесь два ключевых решения. Во-первых,'**'событие слушает все ветки (tags: ['!**']), но с помощьюrelease.ymlявно исключает все отправки tag. Почему исключить tag? Потому что отправка tag обрабатывается отдельноci.yml, и еслиpull_requestтоже будет реагировать на tag, это приведёт к дублирующему запуску процесса релиза и процесса CI, потратит ресурсы runner и даже создаст гонку. Во-вторых,mainслушает толькоminorиmainдве ветки — это стратегия двух веток Vue:minorнесёт стабильную версию,

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

КопироватьgroupУправление параллелизмом — самый изящный ход здесь.github.event.pull_request.number || github.refВыражениеcancel-in-progressиспользуетtrueкак fallback: событие PR использует номер PR как ключ группировки, событие push использует ref (имя ветки) как ключ группировки. Это означает, что несколько отправок одного и того же PR попадут в одну группу параллелизма. А

только для события PR устанавливается в

— когда вы отправляете три коммита подряд, CI первых двух будет автоматически отменён, останется только последний.

〔Проектные выводы и архитектурные компромиссы〕

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

Вход в тройной гейт: условное суждение job testifКопировать&&Это

условие содержит две ветки логического И (! startsWith(github.event.head_commit.message, 'release:')), каждую стоит раскрыть.release:В начале, пропустить тесты. Именно таков формат сообщения коммита, отправляемого release.js из предыдущей главы — release.js уже прогнал полные тесты локально, CI не нужно повторно проверять. Это оптимизация «доверия к источнику».

〔Проектные предположения и архитектурные компромиссы〕

Второе условие(github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository): событие push всегда запускает тесты; событие PR требует, чтобы PR был из форка (head.repo.full_name != github.repository). Почему тесты запускаются только для PR из форка? Потому что PR из веток того же репозитория обычно создаются членами основной команды, и push в их ветки уже вызвал CI по событию push. А PR из форка не вызывает событие push (push в форк не уведомляет upstream-репозиторий), поэтому его необходимо дополнительно запустить в событии PR.

Примечаниеuses: ./.github/workflows/test.yml— это вызов reusable workflow.test.yml— это отдельный файл workflow, совместно используемыйci.ymlиrelease.yml. Такое повторное использование позволяет избежать дублирования определения шагов lint/typecheck/test в нескольких workflow.

Непрерывный предварительный выпуск: роль 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-releasejob выполняется только вvuejs/coreосновном репозитории (if: github.repository == 'vuejs/core'), в форках не запускается. Он делает три вещи: сборка (pnpm build --withTypes, с объявлениями типов), затем с помощьюpkg-pr-newпубликует все пакеты из./packages/*во временный npm registry.

〔Проектные решения и архитектурные компромиссы〕

Ценность этого механизма в том, что контрибьюторы могут в своём проекте напрямуюnpm installиспользовать артефакты сборки этого PR, чтобы проверить, действительно ли изменения решают проблему. Это убедительнее, чем «CI позеленел», потому что проверяется реальный сценарий потребления пакета.

Обратите внимание, что все action привязаны к commit SHA (например,actions/checkout@3d3c42e5...), а не используют@v4такой плавающий тег. Это жёсткое требование безопасности цепочки поставок — предотвращает автоматическое проникновение вредоносного кода после компрометации репозитория action.

Граф потока управления ci.yml

mermaid
flowchart TD
    trigger{"Тип события?"}
    trigger -->|"push в любую ветку"| push_check{"Сообщение коммита начинается с release:?"}
    trigger -->|"PR в main/minor"| pr_check{"PR из форка?"}

    push_check -->|"Да"| skip_test["Пропустить job test"]
    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: оркестрация публикации после пуша тега

Интуитивная модель

Если сказать,ci.yml— это пункт досмотра,release.yml— это стартовая площадка. Когда release.js локально завершает обновление номера версии, коммит, создание тега и пуш, событие пуша тега запускает двигательrelease.yml. Сначала он прогоняет полный набор тестов (повторная проверка), затем в защищённом окруженииReleaseвыполняетpnpm release --publishOnly, и наконец создаёт GitHub Release.

Без него тег, отправленный release.js, остаётся лишь ссылкой Git — новой версии на npm не появится, страницы Release на GitHub не будет.

Условие срабатывания: распознаётся только тег

📎 .github/workflows/release.yml:3-6

yaml
on:
  push:
    tags:
      - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10

Отслеживает толькоv*пуш тегов в формате . Это согласуется сci.ymlизtags: ['!**']образуют взаимодополняющую пару — они строго взаимоисключающие и не срабатывают одновременно.

Условие-ограничитель для задания публикации

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

Здесь три уровня защиты, и ни один из них нельзя опустить.

Первый уровеньif: github.repository == 'vuejs/core': предотвращает ошибочный запуск публикации из форка. Если кто-то сделал форк репозитория и отправилv1.0.0тег, это условие не даст запуститься процессу публикации.

Второй уровеньneeds: [test]: задание release зависит от задания test. Задание test вызываетtest.yml, и если тесты не проходят, задание release вообще не запустится. Это жёсткое требование «перед публикацией обязательно пройти тесты».

〔Проектное предположение и архитектурный компромисс〕

Третий уровеньenvironment: Release: это GitHub Environment, для которого можно настроить правила защиты развёртывания (например, требовать одобрения определённых лиц). Это означает, что даже если отправка тега запустила workflow, шаг публикации может потребовать ручного одобрения для выполнения — это последняя линия защиты для необратимой операции.

Что касается разрешений,contents: writeИспользуется для создания GitHub Release,id-token: writeИспользуется для аутентификации provenance в npm (OIDC token). Обратите внимание, что здесь нетpackages: write, поскольку Vue публикуется в npm, а не в GitHub Packages.

Полная цепочка шагов публикации

📎 .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
〔Проектные предположения и архитектурные компромиссы〕

Каждый из трёх шагов имеет свои особенности.--frozen-lockfileОбеспечивает строгую установку в CI-среде согласно lockfile, что предотвращает несоответствие артефактов сборки локальным из-за дрейфа версий зависимостей.npm i -g npm@latestПредназначен для получения последней версии npm CLI — поскольку provenance и OIDC-аутентификация зависят от более новых версий npm, старые версии могут не поддерживать эти возможности.

pnpm release --publishOnlyЯвляется точкой входа release.js из предыдущей главы.--publishOnlyФлаг сообщает release.js: пропустить интерактивный выбор номера версии, пропустить Git-коммит и создание тега (поскольку тег уже существует), выполнить только сборку и npm publish.

Создание GitHub Release

📎 .github/workflows/release.yml:48-57

yaml
- name: Create GitHub release
  id: release_tag
  uses: yyx990803/release-tag@8cccf7c5aa332d71d222df46677f70f77a8d2dc0 # v1.0.0
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  with:
    tag_name: ${{ github.ref }}
    body: |
      For stable releases, please refer to [CHANGELOG.md](...) for details.
      For pre-releases, please refer to [CHANGELOG.md](...) of the `minor` branch.
〔Проектные предположения и архитектурные компромиссы〕

Здесь используетсяrelease-tag action。tag_name: ${{ github.ref }}Напрямую использовать ref события-триггера (то естьrefs/tags/v3.x.x). В теле Release не указывается конкретное содержимое изменений, вместо этого оно ссылается на CHANGELOG.md — потому что changelog Vue автоматически генерируется через conventional-changelog, и ручное поддержание тела Release привело бы к расхождениям с changelog.

Временная диаграмма 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 и autofix.yml: отслеживание размера и самовосстановление формата

size-report.yml: отчёт о регрессии размера между workflow

size-report.ymlСпособ запуска довольно необычен — он запускается не напрямую по push или PR, а по событию завершения другого workflow.

📎 .github/workflows/size-report.yml:3-7

yaml
on:
  workflow_run:
    workflows: ['size data']
    types:
      - completed

workflow_runСобытие прослушивает завершение workflow с именемsize data. Это двухэтапный дизайн:size-data.yml(в этой главе исходный код не предоставлен) отвечает за сборку и измерение размера в PR, загружая результаты как artifact;size-report.ymlпосле завершенияsize dataскачивает artifact, генерирует отчёт и комментирует его в 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'

Тройная защита: основной репозиторий, событие PR, успех вышестоящего workflow. Еслиsize dataзавершился неудачно, job отчёта не запустится — потому что нет данных для отчёта.

Процесс передачи данных следующий:

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

Скачивает artifactsize-dataиз вышестоящего workflow run вtemp/size. Затем параллельно считывает номер PR и базовую ветку:

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

parallel— это синтаксический сахар GitHub Actions, позволяющий двум независимым шагам выполняться одновременно.number.txtиbase.txt— этоsize-data.ymlфайлы метаданных, записанные при измерении.

Затем скачиваются исторические данные о размере базовой ветки для сравнения:

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

Обратите внимание наif_no_artifact_found: warn— если в базовой ветке ещё нет исторических данных (например, новая ветка), это не приведёт к ошибке, только к предупреждению. Это гарантирует, что при первом запуске отчёт всё равно будет сгенерирован, просто без базовой линии для сравнения.

Наконец, генерируется отчёт и добавляется комментарий:

📎 .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.jsСчитываетtemp/sizeиtemp/size-prevданные под ними, генерирует Markdown-отчёт.maintain-one-comment-backupaction используетbody-include: '<!-- VUE_CORE_SIZE -->'в качестве маркера, чтобы на одном PR оставался только один комментарий с отчётом о размере (обновление, а не добавление). Обратите внимание на комментарий на L81, где указано, что оригинальный репозиторий action был заблокирован GitHub, поэтому используется резервный репозиторий с зафиксированным commit.

autofix.yml: автоматическое исправление проблем форматирования

autofix.ymlРешает очень практичную проблему: код, отправленный контрибьютором, не соответствует стандартам prettier/eslint, CI выдаёт ошибку, и контрибьютору нужно вручную запуститьpnpm lint --fixи снова закоммитить. Этот workflow автоматизирует этот шаг.

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

Запускается для всех PR, управление параллелизмом аналогичноci.yml— новый push в тот же PR отменяет старый запуск 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

Сначала запускается--fixeslint, затем форматирование prettier, и наконецautofix-ci/actionкоммитит изменённые файлы обратно в ветку PR. Обратите внимание, чтоpnpm run formatсам по себе является командой форматирования (не нужен флаг--fix, потому что внутри скрипта format уже естьprettier --write)。

〔Проектные предположения и архитектурные компромиссы〕

Ключевой момент этого механизма в том, чтоautofix-ci/actionкоммитит исправления от имени автора PR, а не от имени бота. Так контрибьюторам не нужно предпринимать дополнительных действий, и исправления формата автоматически появляются в их PR. Но это также означает, что если в ветке контрибьютора есть правила защиты (запрещающие push от бота), autofix завершится неудачно — это граничный случай, который контрибьютору нужно обрабатывать вручную.

Диаграмма потока данных 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

---

Размышления о дизайне: закрепление стандартов в конвейере

Оглядываясь на эти четыре workflow, можно увидеть несколько сквозных принципов дизайна.

Первый — минимизация прав. ci.ymlиautofix.ymlоба объявляютpermissions: contents: read, толькоrelease.ymlнуждается вcontents: writeиid-token: write。size-report.ymlнуждается вpull-requests: writeиissues: writeдля публикации комментариев. Каждый workflow получает только те права, которые ему действительно нужны.

Второй — безопасность цепочки поставок.Все сторонние action зафиксированы на commit SHA, а не на плавающих тегах.size-report.ymlКомментарий на L81 прямо указывает, что после блокировки оригинального репозитория action был выполнен переход на резервный репозиторий с фиксацией commit — это практическая защита от атак на цепочку поставок.

Третий — разделение обязанностей и повторное использование. test.ymlразделяется междуci.ymlиrelease.yml, избегая дублирования логики тестирования.size-data.ymlиsize-report.ymlразделены, позволяя измерению и отчёту развиваться независимо.

Четвёртый — выбор направления при неудаче. size-report.ymlВif_no_artifact_found: warnвыбрано «предупреждение вместо ошибки», потому что отсутствие исторических данных не должно блокировать PR. А вrelease.ymlвыбрано «неудача теста блокирует релиз», потому что релиз — необратимая операция.needs: [test]Пятый — дифференциация управления параллелизмом.

Событие PR отменяет старые запуски (), событие push не отменяет (cancel-in-progress: true). Это различие отражает семантику двух событий: старые коммиты PR уже не имеют значения, каждый коммит push может быть финальным состоянием.cancel-in-progress: falseРезюме главы

---

В этой главе разобраны четыре ключевых workflow репозитория Vue core:

: шлюз PR + непрерывный предрелиз. Через условие

  • ci.ymlразличаются push/PR и fork/тот же репозиторий, с помощьюifотменяются устаревшие запуски PR, с помощьюconcurrencyпубликуется устанавливаемый предрелизный пакет.pkg-pr-newПубликация устанавливаемого предрелизного пакета.
  • release.yml: Официальный релиз запускается по тегу. Трёхуровневая защита (проверка репозитория, needs test, одобрение environment) гарантирует, что только прошедший тесты и одобренный тег может быть опубликован в npm.
  • size-report.yml: Отчёт о регрессии размера между workflow. Черезworkflow_runсобытие прослушивается вышестоящийsize dataзавершается, скачивается artifact и сравниваются данные с веткой base, результат возвращается в PR в виде комментария.
  • autofix.yml: Автоматическое исправление форматирования. Запускает eslint --fix и prettier на PR, черезautofix-ci/actionкоммитит исправления напрямую обратно в ветку PR.

Эти четыре workflow вместе образуют «необходной конвейер»: стиль кода автоматически исправляется через autofix, типы и тесты принудительно проверяются через ci.yml, регрессия размера отслеживается через size-report, а публикация выполняется через release.yml под многоуровневой защитой.

Вопросы для размышления и самопроверки в этой главе

Q1: Если изменитьci.ymlвcancel-in-progressзначение на константуtrue(то есть удалитьgithub.event_name == 'pull_request'условие), в каких сценариях это может вызвать проблемы?

Справочный анализ:cancel-in-progressвсегдаtrueозначает, что при push в ветку main новый push отменит текущий запущенный старый CI. Рассмотрим сценарий: в ветку main последовательно влиты два PR, CI первого PR выполняется (включая полный lint/typecheck/test), слияние второго PR запустило новый запуск CI. Еслиcancel-in-progressравноtrue, CI первого PR будет отменён — но код первого PR уже находится в main, и результат его CI критически важен для оценки состояния здоровья ветки main. Его отмена означает, что часть кода в ветке main никогда не была полностью проверена. А📎 .github/workflows/ci.yml:22-22условиеgithub.event_name == 'pull_request'как раз предназначено для предотвращения этой проблемы: только события PR отменяют старые запуски, события push никогда не отменяют.

Q2: release.ymlвreleasejobif: github.repository == 'vuejs/core'иenvironment: Releaseсоответственно защищают от каких сценариев? Что будет, если убрать одно из них?

Справочный анализ:if: github.repository == 'vuejs/core' 📎 .github/workflows/release.yml:14защищает от сценария fork. Если кто-то форкнул vuejs/core и запушилv3.99.0тег, без этого условия workflow будет запускаться в форк-репозиторииpnpm release --publishOnly. Хотя форк-репозиторий не имеет npm-токена и не может выполнить реальную публикацию, это будет расходовать ресурсы runner'а и может создавать вводящие в заблуждение уведомления о сбоях.environment: Release 📎 .github/workflows/release.yml:21Защищает от риска «автоматической публикации после отправки тега» — он позволяет настроить ручное подтверждение, гарантируя, что даже при отправке тега публикация требует подтверждения мейнтейнера. Если убратьifусловие, форк будет расходовать ресурсы; если убратьenvironment, любой, у кого есть право на отправку тегов, сможет инициировать публикацию без финального этапа ручного подтверждения. Это два разных уровня защиты, которые не могут заменять друг друга.

Q3: size-report.ymlвif_no_artifact_found: warnи выборrelease.ymlвneeds: [test]— какую философию проектирования направления отказа они соответственно отражают? Что произойдёт, если поменять эти две стратегии местами?

Справочный разбор:if_no_artifact_found: warn 📎 .github/workflows/size-report.yml:69Выбор «предупреждать, а не завершаться с ошибкой при отсутствии исторических данных» обусловлен тем, что отчёт о размере является вспомогательной информацией, а не блокирующим условием. Если изменить наfail, то новая ветка или PR при первом запуске потерпят неудачу из-за отсутствия базовых данных, что явно неразумно.needs: [test] 📎 .github/workflows/release.yml:15Выбор «блокировать публикацию при провале тестов» обусловлен тем, что публикация — необратимая операция, необходимо гарантировать качество кода. Если поменять местами — size-report будет завершаться с ошибкой при отсутствии данных, а release будет публиковать даже при провале тестов — первый приведёт к массовым ложным блокировкам нормальных PR, второй приведёт к попаданию непротестированного кода в npm. Это отражает принцип проектирования направления отказа: «вспомогательная информация — мягкая, необратимые операции — строгие».

---

В следующей главе мы углубимся в ядро механизма бюджета размера:scripts/size-report.jsкак разбирать данные о размере, как вычислять приращение, как форматировать вывод, а такжеusage-sizeФилософия измерений — почему Vue выбирает измерение «фактического используемого объёма», а не «полного объёма пакета».

От PR-гейта до публикации тега — четыре файла workflow вместе образуют непреодолимую автоматизированную цепочку контроля. Но конвейер может блокировать слияние только при наличии количественно измеримых критериев оценки. В следующей главе мы сосредоточимся на инженерном управлении ключевой метрикой размера пакета в Vue:scripts/size-report.jsкак вычисляется размер каждого артефакта после gzip и сравнивается с базовым уровнем,scripts/usage-size.jsкак моделируются реальные сценарии использования для оценки фактических затрат, и как CI блокирует слияние при превышении размера.

CHAPTER 11

Глава 11: Механизм бюджета размера: философия метрик size-report и usage-size

Проект: vuejs/core · Прогресс книги: Глава 11 / 14 · Статус верификации: FACT-строки с реальными привязками

В предыдущей главе мы увидели, как Vue с помощью GitHub Actions превращает линтинг, проверку типов, тестирование и отслеживание размера в непреодолимый конвейер, где size-report.yml и size-data.yml отвечают за сохранение данных о размере после каждого изменения. Но конвейер лишь выполняет, а по-настоящему отвечают на вопросы «насколько больше, где больше» — два скрипта, которые мы разберём в этой главе. Основное противоречие бюджета размера заключается в том, что размер пакета — это метрика, которую можно только ощутить, но трудно точно атрибутировать. Когда пользователи жалуются, что «Vue слишком большой», мейнтейнерам нужно ответить на три вопроса — насколько больше? где больше? стало ли от этого изменения ещё больше? scripts/size-report.js отвечает за сравнение, scripts/usage-size.js — за атрибуцию, вместе они образуют философию измерения бюджета размера.

11.1 size-report: превращаем разницу в размере в читаемую Markdown-таблицу

Интуитивная модель

Представьте, что вы контролёр качества в логистической компании. Каждую посылку (артефакт сборки) перед отправкой нужно взвесить, и ваша работа — не само взвешивание, а размещение «сегодняшнего веса» и «вчерашнего веса» рядом в одной таблице, с выделением жирным+2.3 kBтех посылок, которые стали тяжелее. Без этой сравнительной таблицы мейнтейнер видит лишь набор изолированных чисел и не может определить, внёс ли какой-то PR регрессию по размеру.

size-report.js— это и есть тот контролёр. Он не создаёт данные о размере (это делоusage-size.jsи скриптов сборки), он лишь потребляет JSON-файлы из двух директорий и генерирует Markdown-отчёт.

Структура данных и соглашения о директориях

Основные соглашения скрипта скрыты в двух константах. Текущая директория данных —temp/size, директория исторической базовой линии —temp/size-prev。

📎 scripts/size-report.js:23-24

Эти имена директорий не случайны:temp/sizeгенерируетсяsize-data.ymlworkflow при каждом запуске и загружается как артефакт📎 .github/workflows/size-data.yml:53-57, аtemp/size-prevполучаетсяsize-report.ymlпосле извлечения базового артефакта. Само имя директории является контрактом потока данных.

Скрипт определяет три псевдонима типов, которые точно описывают структуру JSON-файлов:

📎 scripts/size-report.js:8-21

SizeResultимеет три числовых поля:size(несжатый),gzip、brotli。BundleResultдобавляет к этомуfileполе для отображения имени файла.UsageResultже являетсяRecord, где ключ — имя preset, значение —SizeResult & { name: string }— обратите внимание на дополнительноеnameполе, потому что ключи JSON-объекта теряются послеObject.values, поэтому имя необходимо избыточно сохранить в значении.

Step-by-Step Walkthrough

Основной поток минимален: всего два шага и один вывод:

📎 scripts/size-report.js:23-38

run()сначала вызываетсяrenderFiles()для отрисовки таблицы файлов артефактов, затемrenderUsages()для отрисовки таблицы сценариев использования, и наконец строка, накопленная в модульной переменнойoutput, записывается в stdout за один раз📎 scripts/size-report.js:25. Такой паттерн «накопить строку и вывести за один раз» избегает многократных затрат на конкатенациюprocess.stdout.writeи делает порядок вывода полностью контролируемым.

Шаг первый: собрать список файлов и найти объединение.

📎 scripts/size-report.js:44-49

filterFilesОтфильтровываются два типа файлов: начинающиеся с_(например,_usages.json) и заканчивающиеся на.txt(например,number.txt、base.txt). Эти два типа файлов — метаданные, а не данные о размере. Затем берётся объединение имён файлов текущей и исторической директорийfileList— с использованиемSetдля дедупликации. Зачем нужно объединение? Потому что файл может существовать только в исторической директории (в этой сборке артефакт был удалён) или только в текущей директории (в этой сборке артефакт был добавлен). Оба случая должны быть отражены в отчёте.

Шаг второй: попарное сравнение по файлам.

📎 scripts/size-report.js:43-75

Для каждого файла из объединения предпринимается попытка импорта JSON из обеих директорий.importJSONреализация

📎 scripts/size-report.js:112-115

— «если файл не существует, вернуть undefined»:import()Здесь используется динамическийwith: { type: 'json' }в сочетании сfs.readFileSync + JSON.parseimport assertion, а неimport(). Первый обрабатывается загрузчиком модулей Node, второй требует ручной обработки ошибок кодировки и парсинга. Цена выбораrenderFiles— возврат Promise, поэтому весь

является async.if (!curr)Ключевое ветвление в~~fileName~~: если в текущей директории нет этого файла, значит артефакт был удалён, и используется синтаксис зачёркивания Markdown📎 scripts/size-report.js:60-61для пометкиgetDiff. Иначе строка отрисовывается нормально, и к каждому числу добавляется результат

.

📎 scripts/size-report.js:124-130

getDiffШаг третий: вычисление разницы.prev === undefinedимеет три точки раннего возврата:diff === 0возвращает пустую строку (нет базовой линии, сравнение невозможно);prettyBytes(diff)возвращает пустую строку (нет изменений, не показываем шум); иначе возвращается жирная разница со знаком. Обратите внимание, что-1.2 kBкорректно обрабатывает отрицательные числа и выводитsign, а переменная+。

добавляет

📎 scripts/size-report.js:80-103

renderUsagesтолько для положительных чисел.renderFilesШаг четвёртый: отрисовка таблицы usage._usages.jsonСтруктурное различие междуObject.values(curr)иprev?.[usage.name]заслуживает внимания: он напрямую импортируетname, потому что данные usage всегда находятся в этом одном файле..filter(usage => !!usage)преобразует Record в массив, затем черезmapищет исторические данные по имени — именно поэтому

поле хранится избыточно.markdown-tableЭта строка фактически избыточна, потому что📎 scripts/size-report.js:72-74。

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

Наконец, с помощью библиотеки

двумерный массив отрисовывается в Markdown-таблицу

Копироватьimport()Размышления о дизайне и подводные камниreadFileSync?〔Дизайнерские выводы и архитектурные компромиссы〕import()Почему используется

filterFiles, а неfile[0] !== '_'динамическийimport assertion для JSON — стандартный подход в Node 20+, он естественно обрабатывает загрузку JSON в среде ESM. Цена — невозможность использования в синхронном контексте, и каждый импорт кэшируется модулем — но в этом одноразовом скрипте кэш не проблема.readdirпроверкаfile[0].undefined,undefined !== '_'Эта проверка предполагает, что имя файла не пустое. Если

Обработка удалённых артефактов.Когда артефакт удалён, в отчёте он помечается зачёркиванием, а не удаляется полностью. Это сделано намеренно: мейнтейнеры должны видеть «этот файл исчез», а не позволять ему молча пропасть из таблицы. Если просто отфильтровать его, читатель ошибочно решит, что артефакт никогда не существовал.

11.2 usage-size: моделирование сценария внедрения реальным пользователем

Интуитивная модель

size-reportСообщает вам «насколько велик полный пакет», но это не отвечает на вопрос, который действительно волнует пользователя: «Я использую толькоcreateApp, сколько кода мне реально придётся скачать?» Объём полного пакета включает множество кода, который вы, возможно, никогда не используете (например,defineCustomElement、Transition、KeepAlive)。usage-size.jsРоль — сыграть «типичного пользователя»: написать виртуальный входной файл, который импортирует только определённый API, собрать его через Rollup и посмотреть, насколько велик итоговый артефакт.

Это как если бы ресторан не говорил вам «общий вес всех продуктов на кухне — 50 кг», а говорил «при заказе одной порции курицы гунбао реально используется 300 граммов продуктов».

Структура данных: массив Preset

Ядро скрипта — массивpresets, каждый элемент описывает один сценарий использования:

📎 scripts/usage-size.js:27-55

PresetТип имеет три поля:name(отображаемое имя),imports(список API, импортируемых из Vue), опциональныйreplace(дополнительные замены на этапе компиляции). Пять preset'ов покрывают сценарии от минимального до максимального:

  • createApp (CAPI only): импортирует толькоcreateApp, и заменяет__VUE_OPTIONS_API__на'false', моделируя пользователя чистого Composition API📎 scripts/usage-size.js:35-40
  • createApp: импортирует толькоcreateApp, сохраняя Options API📎 scripts/usage-size.js:35-40
  • createSSRApp: сценарий SSR📎 scripts/usage-size.js:35-40
  • defineCustomElement: сценарий Web Components📎 scripts/usage-size.js:35-40
  • overall: импортирует шесть ключевых API, моделируя «полнофункционального» пользователя📎 scripts/usage-size.js:44-54

Входной файл фиксирован как runtime-only артефакт esm-bundler:

📎 scripts/usage-size.js:24-28

Выборvue.runtime.esm-bundler.jsвместо полной версииvue.esm-bundler.js, потому что runtime-версия не содержит компилятор шаблонов и ближе к реальной ситуации современных пользователей сборщиков — они используют SFC для предварительной компиляции шаблонов и не нуждаются в runtime-компиляторе.

Step-by-Step Walkthrough

Шаг первый: параллельная генерация bundle для всех preset'ов.

📎 scripts/usage-size.js:62-69

main()Для каждого preset'а создаётся PromisegenerateBundle, выполняемый параллельно черезPromise.all. Здесь параллелизм безопасен, потому что каждыйgenerateBundleвызывает независимыйrollup(), не разделяя состояние.

Шаг второй: построение виртуального входа.

📎 scripts/usage-size.js:94-96

Это самая изящная часть всего скрипта. Он не пишет временный файл на диск, а конструирует виртуальный ID модуляvirtual:entry, содержимое которого — оператор re-export:export { createApp } from '/absolute/path/to/vue.runtime.esm-bundler.js'. Обратите внимание,entry— абсолютный путь, потому что Rollup должен уметь его разрешить.

Шаг третий: настройка цепочки плагинов Rollup.

📎 scripts/usage-size.js:98-121

Порядок массива плагинов критически важен:

1. Пользовательскийusage-size-plugin:resolveIdперехватываетvirtual:entryи возвращает сам себя,loadвозвращает виртуальное содержимое📎 scripts/usage-size.js:101-110. Это стандартный паттерн виртуальных модулей Rollup.

2. nodeResolve(): разрешает import внутриvue.runtime.esm-bundler.js📎 scripts/usage-size.js:111。

3. replace: внедряет константы времени компиляции📎 scripts/usage-size.js:112-119。

replaceКонфигурация плагина раскрывает ключевой механизм артефакта esm-bundler: он сохраняет runtime-флаги вроде__VUE_OPTIONS_API__、__VUE_PROD_DEVTOOLS__, которые заменяются сборщиком пользователя. Здесь скрипт выполняет замену за пользователя:

  • process.env.NODE_ENV → "production": идёт по production-ветке
  • __VUE_PROD_DEVTOOLS__ → 'false': отключает поддержку devtools
  • __VUE_PROD_HYDRATION_MISMATCH_DETAILS__ → 'false': отключает подробные сообщения об ошибках гидратации
  • __VUE_OPTIONS_API__ → 'true': по умолчанию сохраняет Options API

Затем разворачивает...preset.replace, позволяя preset'у переопределить значения по умолчанию.createApp (CAPI only)Preset__VUE_OPTIONS_API__как раз использует этот механизм, чтобы изменить'false' 📎 scripts/usage-size.js:35-40。

preventAssignment: trueнаobj.process.env.NODE_ENV = xПредотвращает замену присваиваний вроде📎 scripts/usage-size.js:117。

📎 scripts/usage-size.js:123-134

result.generate({})Шаг четвёртый: генерация, минификация, измерение.output[0].codeсоздаёт код, берётся

📎 scripts/usage-size.js:125-130

module: true. Затем минифицируется через SWC:toplevel: trueозначает, что вход — ESM,minified.lengthпозволяет минифицировать имена переменных верхнего уровня. После минификации вычисляются три метрики:gzipSync(minified).length、brotliCompressSync(minified).length。

(длина в байтах),node:zlibОбратите внимание, здесь используется синхронный API

, а не асинхронная версия. В одноразовом скрипте синхронный API проще, и сама минификация — операция, нагружающая CPU, асинхронность не даст выигрыша в параллелизме.

📎 scripts/usage-size.js:62-86

Шаг пятый: вывод и сохранение.picoРезультаты сначала выводятся в консоль в человекочитаемом формате, с раскраской через📎 scripts/usage-size.js:62-86. Затем записываются вtemp/size/_usages.json, с помощьюObject.fromEntriesмассив преобразуется обратно в Record, ключ — имя preset'а📎 scripts/usage-size.js:81-85。

--writeФлаг управляет тем, записывать ли дополнительно несжатый bundle каждого preset'а на диск📎 scripts/usage-size.js:136-138, для отладки.

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

Размышления о дизайне и подводные камни

〔Проектные предположения и архитектурные компромиссы〕

Почему виртуальный модуль, а не временный файл?Временные файлы требуют обработки путей, очистки, конфликтов параллельной записи. Виртуальный модуль хранит содержимое входа в памяти, и хукresolveId/loadRollup естественно поддерживает этот паттерн. Цена — необходимость точного совпадения ID, любая опечатка приведёт к ошибке Rollup «не удалось разрешить вход».

replaceЛовушкаpreventAssignmentвЕсли не установитьpreventAssignment: true,replace, плагин выполнит замену и для присваиваний вродеprocess.env.NODE_ENV = 'x', что приведёт к синтаксической ошибке"production" = 'x'. В исходном коде Vue действительно есть присваиванияprocess.env.NODE_ENV(в тестовых утилитах), поэтому эта опция необходима.

__VUE_OPTIONS_API__Выбор значения по умолчанию дляСкрипт устанавливает значение по умолчанию'true' 📎 scripts/usage-size.js:116, а не'false'. Это консервативный выбор: если пользователь не настроит, Vue сохранит поддержку Options API.createApp (CAPI only)Preset'false'явно переопределяет на

, демонстрируя выигрыш в объёме после отключения. Это сравнение само по себе — документация для пользователя: показать, «сколько можно сэкономить, отключив Options API».Promise.allСемантика отказа параллельногоЕсли сборка любого preset'а завершится неудачей,Promise.allнемедленно отклонит, остальные текущие сборки не будут отменены (Rollup не предоставляет механизма отмены). В CI это означает, что одна неудача приведёт к потере вычислений других preset, но сам скрипт завершится с ненулевым кодом выхода, и CI сможет корректно это зафиксировать.

11.3 От данных к контролю: как CI использует эти отчёты

Общая картина потока данных

Чтобы понять эти два скрипта, необходимо вернуть их в контекст CI-конвейера.size-data.ymlзапускается при push в main/minor или при PRpnpm run size 📎 .github/workflows/size-data.yml:45, создаётtemp/sizeкаталог, затем загружается как artifact📎 .github/workflows/size-data.yml:53-57。

Для PR он дополнительно записывает два файла метаданных:

📎 .github/workflows/size-data.yml:47-51

number.txtхранит номер PR,base.txtхранит имя целевой ветки. Эти два файла — именно теsize-report.jsвfilterFiles, которые нужно отфильтровать.txtфайлы📎 scripts/size-report.js:44-45. Они существуют, чтобы downstreamsize-report.ymlзнал, «с какой базовой линией сравнивать».

Получение и сравнение базовой линии

size-report.yml(подробно описано в предыдущей главе) рабочий процесс таков: скачатьsize-dataartifact текущего PR, скачать artifact базовой линии целевой ветки, распаковать базовую линию вtemp/size-prev, затем запуститьsize-report.jsдля генерации Markdown-отчёта и комментария к PR.

Здесь есть ключевое проектное ограничение:size-report.jsсам не отвечает за получение базовой линии, он предполагает, чтоtemp/size-prevуже существует. Если её нет,existsSync(prevDir)возвращает false,prevпустой массив📎 scripts/size-report.js:48, все diff становятся пустыми строками. Это изящная деградация: без базовой линии отчёт всё равно генерируется, просто не показывает различия.

Логика определения контроля объёма

〔Проектные предположения и архитектурные компромиссы〕

Нужно развеять распространённое заблуждение:size-report.jsсам не выполняет контроль. Он только генерирует отчёт, не возвращает код выхода, не устанавливает пороги. Настоящий контроль происходит на уровнеsize-report.ymlworkflow — он может содержать шаг, который парсит значения diff из отчёта и при превышении порога приводит к падению job.

У такого разделения «измерения и определения» есть глубокие причины: скрипт измерения должен оставаться чистым и отвечать только за производство фактов; логика определения должна находиться на уровне workflow, поскольку пороги могут меняться в зависимости от версии, ветки, этапа релиза. Жёсткое кодирование порогов вsize-report.jsсделало бы его трудно переиспользуемым.

Размышления о дизайне

Почему для бюджета объёма нужны две системы измерений?Полный объём пакета и объём usage отвечают на разные вопросы. Полный объём пакета — это «верхняя граница»: он говорит, сколько пользователю придётся скачать в худшем случае. Объём usage — это «типичное значение»: он говорит, сколько скачивает большинство пользователей на практике. Только вместе они дают полную картину объёма. Если бы был только полный объём пакета, мейнтейнеры склонялись бы к чрезмерной оптимизации редких API; если бы был только объём usage, можно было бы упустить взрывной рост объёма в некоторых краевых сценариях.

Смысл двойных метрик gzip и brotli.Современные CDN повсеместно поддерживают brotli, но не во всех сценариях он включён. Одновременный отчёт по обоим позволяет мейнтейнерам оценить, «каков объём в среде, где поддерживается только gzip». brotli обычно на 15-20% меньше gzip, и эта разница сама по себе является ценной информацией.

Контракт стабильности формата данных. size-report.jsиusage-size.jsразвязаны через JSON-файлы.usage-size.jsпишет_usages.json,size-report.jsчитает его. Имена полей этого контракта (name、size、gzip、brotli) неявны, нет валидации схемы. Еслиusage-size.jsизменит имена полей и забудет синхронизироватьsize-report.js, отчёт будет молча показывать неверные данные. Это слабое место текущего дизайна.

Резюме главы

Вопросы для размышления и самопроверки к этой главе

Q1: size-report.jsвfilterFilesотфильтровывает файлы, начинающиеся с_. Еслиusage-size.jsпереименует выходной файл из_usages.jsonвusages.json, что произойдёт?

Справочный разбор:filterFilesусловие фильтрации —file[0] !== '_' && !file.endsWith('.txt') 📎 scripts/size-report.js:44-45. Если файл переименован вusages.json, он больше не начинается с_, будет сохранёнfilterFiles, попадёт вfileListобъединение. ЗатемrenderFilesпопытается обработать его как bundle-файл:importJSONсможет успешно импортировать (это валидный JSON), но его структура —Record<string, UsageResult>а неBundleResult, поэтомуcurr?.fileбудетundefined,fileNameпустой строкой,curr.sizeтакжеundefined,prettyBytes(undefined)выбросит ошибку или выдаст аномалию. Это приведёт к сбою генерации отчёта. Корень проблемы в том, чтоfilterFilesиспользует префикс имени файла как критерий различения «метаданные vs данные», а не структуру каталогов или явный манифест. Более надёжный подход — поместить usage-данные в подкаталог или вести явный список файлов метаданных.

Q2: usage-size.jsвPromise.all(tasks)параллельно выполняет сборку всех preset. Если в конфигурацииreplaceкакого-то preset пропущен__VUE_OPTIONS_API__, что произойдёт? Почему значение по умолчанию установлено в'true'а не'false'?

Справочный разбор:replaceВ конфигурации плагина__VUE_OPTIONS_API__: 'true'является значением по умолчанию, затем раскрытие...preset.replaceпозволяет переопределить📎 scripts/usage-size.js:116-118. Если какой-то preset пропустил конфигурацию, он использует значение по умолчанию'true', то есть сохраняет поддержку Options API, и объём будет больше. Значение по умолчанию'true'— консервативный выбор: оно отражает «фактическое поведение при отсутствии конфигурации пользователя». В esm-bundler-сборке Vue__VUE_OPTIONS_API__по умолчанию сохраняет Options API (если пользователь явно не отключил). Если бы значение по умолчанию было'false', все preset без явной конфигурации показывали бы заниженный объём, вводя пользователей в заблуждение, будто «без конфигурации можно сэкономить объём».createApp (CAPI only)preset явно установлен в'false' 📎 scripts/usage-size.js:35-40, именно чтобы продемонстрировать «выгоду от явного отключения» в контрасте со значением по умолчанию.

Q3: size-report.jsвimportJSONиспользует динамическийimport()вместоfs.readFileSync. Если какой-то JSON-файл в каталогеtemp/size-prevповреждён (невалидный JSON), чем будет отличаться поведение двух реализаций?

Справочный разбор: динамическийimport()при парсинге невалидного JSON выброситSyntaxError, и эта ошибка не может быть перехваченаimportJSONвнутреннейexistsSyncпроверкой —existsSyncПроверяется только существование файла, но не проверяется корректность содержимого📎 scripts/size-report.js:112-115. Ошибка будет распространяться вверх доrenderFiles, что приведёт к сбою генерации всего отчёта. Если использоватьfs.readFileSync + JSON.parse, также будет выброшена ошибка, но можно внутриimportJSONобернуть в try-catch и вернутьundefinedдля реализации изящной деградации. Текущая реализация выбирает распространение ошибки, неявно предполагая, что «JSON в artifact всегда корректен» — это предположение обычно справедливо в среде CI, поскольку файлы генерируютсяusage-size.jsи сборочными скриптами. Однако при локальной отладке, если вручную изменить JSON-файл и повредить его, отчёт просто упадёт вместо пропуска этого файла. Это дизайнерский выбор «доверия к источнику данных».

---

Механизм бюджета размера решает проблемы «что измерять» и «как сравнивать», но он опирается на предпосылку: сами артефакты сборки воспроизводимы. Следующая глава перейдёт к минимальной песочнице отладки:vite-debugкак запустить интерактивную среду разработки Vue с минимальной конфигурацией и как она взаимодействует с локальными артефактами сборки, образуя замкнутый цикл от изменения исходного кода до проверки во время выполнения.

На этом цикл измерения бюджета размера стал ясен: size-report.js с помощью сравнения каталогов отвечает на вопрос «насколько больше», usage-size.js с помощью виртуальных модулей имитирует реальные сценарии импорта и отвечает на вопрос «где больше», а решение о пороговом контроле остаётся на уровне рабочего процесса. Этот механизм превращает регрессию размера из расплывчатых жалоб в прослеживаемые данные. Но данные могут лишь сообщить о наличии проблемы; чтобы действительно локализовать и исправить её, нужна минимальная среда, способная быстро воспроизвести проблему. Следующая глава перейдёт к packages-private/vite-debug, чтобы посмотреть, как Vue с помощью Vite + SFC создаёт минималистичную песочницу отладки, превращая «минимальное воспроизведение на реальном исходном коде» в повседневную практику.

CHAPTER 12

Глава 12: Минимальная песочница отладки: vite-debug и локальный цикл разработки

Проект: vuejs/core · Прогресс книги: Глава 12 / 14 · Статус проверки: FACT — номера строк реально привязаны

В предыдущей главе мы завершили цикл измерения бюджета размера: size-report.js отвечает на вопрос «насколько больше», usage-size.js отвечает на вопрос «где больше», а уровень рабочего процесса отвечает за пороговый контроль. Но у этого механизма есть неявная предпосылка — сами артефакты сборки воспроизводимы. Когда вы обнаруживаете, что размер какого-то пакета аномально разросся, или какое-то поведение во время выполнения не соответствует ожиданиям, вам нужна минимальная среда, способная быстро загрузить локальный исходный код и сразу увидеть эффект после изменения. packages-private/vite-debug — это и есть такая среда. В ней всего четыре файла и менее 40 строк кода, но она образует вход в повседневную практику «минимального воспроизведения на реальном исходном коде» в репозитории Vue core. В этой главе мы разберём логику построения этой песочницы по файлам и объясним, почему она размещена в packages-private, а не в каталоге packages.

I. Каркас песочницы:main.tsиApp.vueминимальная цепочка монтирования

Интуитивная модель

Если представить весь runtime Vue как двигатель, тоvite-debug— это «испытательный стенд на голом железе» — без корпуса, без приборной панели, только минимальные соединения, чтобы двигатель заработал. Его ценность не в полноте функций, а висключении всех мешающих переменных: когда вы подозреваете, что какой-то баг находится в системе реактивности или внутри рендерера, вы не хотите, чтобы сложность самой среды отладки стала источником шума.

Структуры данных и макет файлов

Сначала посмотрим наmain.tsвсё содержимое:

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

Эти шесть строк кода — стандартная парадигма запуска приложения Vue, но каждая строка в сценарии отладки имеет точное инженерное значение:

  • L1Вimport { createApp } from 'vue'из'vue', к какому модулю в конечном итоге разрешится этот идентификатор модуля, полностью определяется объявлениями зависимостейvite.config.tsиpackage.json. Это самое ключевое звено всей песочницы — позже мы увидим, как он указывается на локальный исходный код.
  • L2Вimport App from './App.vue'из@vitejs/plugin-vueзапускает конвейер компиляции SFC вApp.vue: Vite регистрирует этот плагин при запуске dev server, и когда браузер запрашивает<script>、<template>、<style>, плагин разбирает его на
  • L4три виртуальных модуля и компилирует их по отдельности.createApp(App)Вapp._context、app._instanceиз
  • L6создаёт экземпляр приложения; в этот момент Vue внутренне инициализируетapp.mount('#app')и другие ключевые поля, но ещё не запускает никакой рендеринг.appВ

изindex.html— это настоящий переключатель запуска: он находит в DOM элемент-контейнер с idindex.html, создаёт экземпляр корневого компонента и запускает первый рендеринг.<div id="app"></div>Обратите внимание, что здесь нет ссылки на<script type="module" src="/main.ts"></script>— соглашение Vite состоит в том, чтоapp.mount('#app')в корневом каталоге проекта служит входным HTML, который содержит

и

. Хотя этого файла нет в keyFiles этой главы, он является предпосылкой успешной работыApp.vue.

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

Теперь посмотрим на, это «носитель эксперимента» данной песочницы:

Копировать

@vitejs/plugin-vueПодставим конкретный сценарий:App.vueЧто происходит, когда пользователь нажимает кнопку в браузере?

  • <script setup>Шаг первый: этап компиляции SFC (при запуске dev server)setup()компилируетref(0)на три части:RefImplБлок.valueкомпилируется в0。
  • <template>функцию компонента,{{ count }}вызов возвращает_toDisplayString(count.value),@click="count++"объект, чейonClick: $event => (count.value++)。
  • <style>изначально равен<style>Блок

компилируется в функцию рендеринга,app.mountпреобразуется в

createApp(App)преобразуется вmount('#app'), создаётся корневой компонентComponentInternalInstance, выполняетсяsetup(), получаетсяcountRefImpl, затем вызывается функция рендеринга для генерации дерева VNode. В функции рендеринга чтениеcount.valueвызываетtrackсбор зависимостей — текущий активный эффект рендеринга (ReactiveEffect) записывается вcountвdep.

Шаг третий: событие клика (при взаимодействии пользователя)

Браузер вызывает событиеclick, обработчик событий Vue выполняетcount.value++. Это операция setter, которая вызываетtrigger: обход эффектов, собранных вcount.dep, и планирование повторного рендеринга. Поскольку обновление синхронное и не находится в очереди пакетной обработки, эффект рендеринга выполняется немедленно, функция рендеринга вызывается повторно, генерируется новый VNode, выполняется diff со старым VNode, обнаруживается изменение текстового содержимого с0на1, обновляется реальный DOMtextContent。

Весь этот процесс можно представить следующей диаграммой потока данных:

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

Ключевой момент этой диаграммы:Между артефактами этапа компиляции и поведением во время выполнения существует только две точки связи——ref(0)возвращаемый объект RefImpl, а также чтение и записьcount.valueв функции рендеринга. Это означает, что если вы хотите отладить какую-либо ветвь системы реактивности (например,triggerлогику планирования вApp.vue), вам достаточно в этом

сконструировать соответствующий шаблон чтения-записи.refРазмышления о дизайне: почемуreactive?

, а не

〔Предположения о дизайне и архитектурные компромиссы〕ref(0)Выборreactive({ count: 0 })вместоrefв качестве примера по умолчанию подразумевает приоритет отладки:.valueпуть доступа кRefImplкороче, при раскрытии_value、dep、__v_isRefобъекта в отладчике можно напрямую увидетьreactiveи другие внутренние поля, тогда как раскрытие Proxy-объекта, возвращаемого

---

, в консоли вызовет getter, что может помешать наблюдению за исходным состоянием. Для сценария «минимального воспроизведения» уменьшение одного уровня косвенности Proxy означает меньше переменных.vite.config.tsДва: разрешение псевдонимов:package.jsonи'vue'как направить

на локальный исходный код

vite.config.tsИнтуитивная модельimport { createApp } from 'vue'содержит всего шесть строк, но это «маршрутизационный центр» всей песочницы — он определяет,'vue'вApp.vueв конечном итоге загружает опубликованную версию из npm или исходный код, находящийся в разработке в репозитории. Без правильной конфигурации псевдонимов код, который вы изменяете в

, может вообще не затронуть ту версию исходного кода Vue, которую вы отлаживаете, и отладка превращается в «стрельбу по неправильной мишени».

Структура данных и цепочка разрешенияvite.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()],
})

КопироватьЗдесьresolve.aliasнет явной конфигурации. Тогда как'vue'разрешается в локальный исходный код? Ответ находится вpackage.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:*"
  }
}

Ключ вL13:"vue": "workspace:*". Это объявление протокола pnpm workspace, означающее, чтоvite-debugзависит от локального пакета с именемvueв monorepo, а не от версии в npm registry. pnpm создаст символическую ссылку вnode_modules/vue, указывающую наpackages/vue(главный каталог пакета Vue).

Но этого недостаточно —packages/vueвpackage.jsonполеmain/module/exportsобычно указывает наартефакты сборки(например,dist/vue.runtime.esm-bundler.js), а не на исходный код вsrc/. Если вы изменилиpackages/runtime-core/src/renderer.ts, но не пересобрали проект, Vite всё равно загрузит старый файлdist.

〔Предположения о дизайне и архитектурные компромиссы〕

Именно поэтому вpackages/vue/package.jsonрепозитория Vue core обычно настраивается"development"условный экспорт или аналогичное сопоставление входных точек исходного кода — в режиме devresolve.conditionsVite в первую очередь сопоставляет условиеdevelopment, тем самым загружаяsrc/index.tsвместоdist. Этот механизм позволяетvite-debugбез явной настройки alias сразу видеть эффект через HMR после изменения исходного кода.

Сценарный Walkthrough: один процесс разрешенияimport 'vue'

Погружение в сценарий:Когда Vite dev server получает запрос браузера кmain.tsи встречаетimport { createApp } from 'vue', какова цепочка разрешения?

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

Эта блок-схема выявляет ключевое ветвление:Если условиеdevelopmentнастроено неправильно, после изменения исходного кода браузер не выполнит горячее обновление, и вы попадёте в замешательство «код изменён, но поведение не изменилось». Метод диагностики — посмотреть фактический путь загрузки модуляvueна панели Network в браузерных DevTools — если вы видите путьdist/, значит сопоставление входных точек исходного кода не сработало.

Размышления о дизайне: почему бы не написать alias явно вvite.config.ts?

〔Предположения о дизайне и архитектурные компромиссы〕

Естественный вопрос: почему бы просто не написатьvite.config.tsвresolve: { alias: { vue: '../../packages/vue/src/index.ts' } }? Хотя это интуитивно понятно, есть две проблемы:

1. Нарушение импорта подпутей: публичный API Vue включаетvue/server-renderer、vue/compiler-sfcи другие подпути. Если создать alias только для'vue'самого по себе, импорт подпутей всё равно пойдёт черезdist, что приведёт к тому, что часть модулей будет из исходного кода, а часть — из артефактов сборки, и поведение станет несогласованным.

2. Обход механизма условного экспорта: вpackage.jsonVue полеexportsуже определяет полное сопоставление условного экспорта (development/production/browser/nodeи т.д.), alias перекроет этот механизм, что приведёт к расхождению в поведении разрешения между средой отладки и реальной средой пользователя.

Поэтомуvite-debugвыбирает комбинацию «доверие к протоколу workspace + условный экспорт», чтобы цепочка разрешения была как можно ближе к реальному сценарию использования. Это также объясняет, почемуpackage.jsonв"vue": "workspace:*"обязательно — это предпосылка для срабатывания символической ссылки pnpm и, следовательно, для того, чтобы Vite мог черезnode_modules/vueнайтиpackages/vue.

Производственные подводные камни:catalog:протокол и дрейф версий

Обратите внимание, чтоpackage.jsonвL11-L12использует протокол"catalog:":

json
"@vitejs/plugin-vue": "catalog:",
"vite": "catalog:",

Это функция catalog в pnpm, означающая, что номер версии централизованно управляется полемpnpm-workspace.yamlвcatalog. Её назначение —избежать дрейфа версий, когда несколько пакетов в monorepo ссылаются на одну и ту же зависимость。

〔Предположения о дизайне и архитектурные компромиссы〕

В сценарии отладки это создаёт скрытую ловушку: если вы вvite-debugЕсли вы столкнулись с предполагаемым багом Vite или plugin-vue и хотите временно обновить версию для проверки, прямое изменениеpackage.jsonвcatalog:неэффективно — вам нужно изменитьpnpm-workspace.yamlопределение catalog в, что повлияет на все пакеты, использующие этот catalog. Правильный подход — временно указать явный номер версии (например,"vite": "5.0.0"), а после завершения проверки вернуть обратноcatalog:。

---

Три,packages-privateИзолированный дизайн: почему отладочная песочница не публикуется наружу

Интуитивная модель

packages-privateКаталог подобен «внутренней лаборатории» компании — образцы внутри не продаются наружу, а используются только для тестирования и демонстрации. Он физически изолирован отpackagesкаталога, чтобы отладочный код случайно не был опубликован в npm.

Три уровня защиты механизма изоляции

Первый уровень: изоляция каталогов

packages-private/vite-debugНе вpackages/ниже, аpnpm-workspace.yamlобычно объявляютpackages/*иpackages-private/*как члены workspace, но скрипт публикации (например,scripts/release.js) перебирает только пакеты вpackages/.

Второй уровень:private: true

📎 packages-private/vite-debug/package.json:3

json
"private": true,

Эта строка является жёстким ограничением npm/pnpm: пакеты, помеченные какprivateпакетаникогда не могут быть опубликованы черезnpm publishОпубликовать, даже при ручном выполнении будет отказ. Это последняя линия защиты от случайной публикации.

Третий уровень: отсутствиеversionполя

Вниманиеpackage.jsonотсутствуетversionполе. Спецификация npm требует, чтобы публикуемый пакет обязательно имелversion, и пакеты без этого поля приnpm publishвыдают ошибку. Это «двойная страховка» — даже еслиprivateбыл случайно удалён, отсутствиеversionвсё равно заблокирует публикацию.

Проектное размышление: разделение обязанностей между отладочной песочницей и Playground

В репозитории Vue core уже есть полнофункциональныйSFC Playground(обсуждался в главе 7), зачем ещё нуженvite-debug?

〔Проектное предположение и архитектурный компромисс〕

Их назначение совершенно различно:

ИзмерениеSFC Playgroundvite-debug
Среда выполненияВ браузере (компиляция также в браузере)Node.js + браузер
Загрузка исходного кодаЧерез CDN или предварительно собранные артефактыПрямая загрузка локального исходного кода
Возможности отладкиОграничены песочницей браузераДоступны отладчик Node.js, точки останова
Изменение исходного кодаНе поддерживаетсяПоддерживается HMR
Сценарии примененияПроверка результатов компиляции, воспроизведение при совместном использованииОтладка внутреннего поведения во время выполнения

vite-debugОсновная ценность заключается вОн работает в реальной среде Node.js, вы можете использоватьnode --inspectподключить отладчик и установить точку останова вpackages/reactivity/src/effect.ts, чтобы наблюдатьReactiveEffectза процессом создания и планирования. Это то, что Playground не может предоставить.

Производственные подводные камни: границы HMR и потеря состояния

〔Проектные предположения и архитектурные компромиссы〕

При использованииvite-debugдля отладки частое недоумение вызывает следующее: после измененияApp.vueначального значенияcountсчётчик в браузере не сбрасывается. Это происходит потому, что HMR в Vite обрабатывает блок<script setup>следующим образом:сохраняет состояние компонента и заменяет только функцию рендеринга. Если вам нужно полностью сбросить состояние, необходимо вручную обновить страницу или добавить вApp.vueдобавить вimport.meta.hot?.invalidate()принудительное обновление всей страницы.

Ещё одна ловушка: когда вы изменяетеpackages/runtime-core/src/исходный код в директории, цепочка распространения HMR может не сработать автоматически — потому чтоvite-debugграница HMR определена на уровнеApp.vue, аpackages/изменения исходного кода в директории должны распространяться через граф модулей Vite. Если после изменения исходного кода браузер не реагирует, проверьте, есть ли в выводе терминала Vitehmr updateлоги; если их нет, возможно, потребуется перезапустить dev server.

---

Краткое содержание главы

packages-private/vite-debugС помощью четырёх файлов и менее 40 строк кода построен полноценный цикл отладки:

1. main.tsобеспечивает минимальную цепочку монтирования:createApp(App).mount('#app'), исключая всю необязательную логику инициализации.

2. App.vueВ качестве экспериментального носителя:ref+ интерполяция шаблонов + обработка событий, охватывающие основной путь системы реактивности.

3. vite.config.ts + package.jsonЧерезworkspace:*протокол и условный экспорт,'vue'разрешается на локальный исходный код, реализуя принцип «изменил исходник — сразу вступило в силу».

4. packages-private + private: true+ нетversionТрёхуровневая изоляция гарантирует, что отладочный код не будет случайно опубликован.

Инженерная философия этой песочницы такова:сложность самой среды отладки должна стремиться к нулю, а вся сложность должна оставаться в отлаживаемом исходном коде. Когда вы вpackages/reactivityсталкиваетесь с трудно воспроизводимым багом,vite-debugпредоставляет экспериментальную площадку, которую можно свободно изменять и немедленно проверять.

Вопросы для размышления и самопроверки в этой главе

Q1: Если вpackage.jsonизменить"vue": "workspace:*"на"vue": "^3.4.0", то после измененияvite-debugвpackages/reactivity/src/ref.tsчто изменится в поведении в браузере? Почему?

Справочный разбор: изменено на"^3.4.0"после этого pnpm загрузит из npm registry опубликованную версию Vue 3.4.x, а не будет ссылаться на локальнуюpackages/vue 📎 packages-private/vite-debug/package.json:13. В это времяimport { createApp } from 'vue'разрешается вnode_modules/.pnpm/vue@3.4.x/node_modules/vue/dist/vue.runtime.esm-bundler.js, то есть в предварительно собранный артефакт. Изменениеpackages/reactivity/src/ref.tsне вызовет никакого HMR, поскольку граф модулей Vite вообще не содержит этот файл. В браузере по-прежнему выполняется реализацияrefиз npm-версии. Этот эксперимент обратно подтверждает, чтоworkspace:*является необходимым условием для отладки на уровне исходного кода.

Q2: App.vueв<style>блок не добавленscoped, если в этой песочнице одновременно смонтировать два экземпляра компонента, что произойдёт со стилями? Как это связано с целью отладкиvite-debug?

Справочный разбор: безscoped,button { color: red }Это глобальный стиль📎 packages-private/vite-debug/App.vue:4-8, который воздействует на все<button>элементы на странице. Если смонтировать два экземпляра компонента, кнопки в обоих экземплярах станут красными. Связь с целью отладки заключается в следующем:vite-debugпозиционируется как «минимальное воспроизведение», а не «проверка изоляции стилей». Пропускscopedуменьшает количество переменныхdata-v-xxx, внедряемых на этапе компиляции, что делает структуру DOM в отладчике более чистой. Если вам нужно отладить логику компиляции стилейscoped, следует явно добавитьscopedи наблюдать за сгенерированным кодом внедрения атрибутов@vitejs/plugin-vue.

Вопрос 3: Предположим, вы добавили строкуpackages/runtime-core/src/renderer.tsв функциюpatch, но в консоли браузера нет вывода. Перечислите как минимум три возможные причины и объясните, как проверить каждую из них.console.logСправочный разбор

Причина первая::

Причина первая:Точка входа исходного кода не сработала。'vue'Разобрано вdistартефакт, а неsrc. Диагностика: в панели DevTools Network проверьтеvueпуть загрузки модуля, если он начинается сdist/, это означает, что условный экспорт не сработалdevelopmentусловие📎 packages-private/vite-debug/package.json:13。

Причина вторая:HMR не распространяется. Граф модулей Vite не передал измененияpackages/runtime-core/src/renderer.tsвvite-debug. Диагностика: проверьте, есть ли в терминале Vitehmr updateлоги; если нет, перезапустите dev server.

Причина третья:patchфункция не вызывается. Если текущая страница не вызывает никаких обновлений DOM (например, нет нажатия кнопки),patchможет выполняться только один раз при первом монтировании, а первое монтирование произошло до того, как вы добавилиconsole.log. Диагностика: обновите страницу или добавьте вApp.vueдействие, вызывающее обновление.

Причина четвёртая (дополнительно):кэш сборки. Кэш предварительной сборки зависимостей Vite (node_modules/.vite) может всё ещё использовать старую версию. Диагностика: удалитеnode_modules/.viteи перезапустите.

---

Бюджет размера говорит вам «проблема существует»,vite-debugпозволяет вам «воспроизвести проблему своими руками». Но когда вы пытаетесь распространить этот режим песочницы на весь monorepo, вы сталкиваетесь с рядом граничных условий: различия в разрешении протокола workspace в среде CI,catalog:трудности обновления при фиксации версий,packages-privateиpackagesограничения направления зависимостей между... Следующая глава перейдёт к архитектурным компромиссам и руководству по избеганию ошибок, системно разбирая граничные условия, которые инженерия monorepo проявляет в реальных проектах.

На этом мы завершили инженерный цикл от измерения размера до минимального воспроизведения: vite-debug с помощью минималистичных четырёх файлов превратил «быструю проверку на реальном исходном коде» в повседневно применимую практику. Но когда вы действительно начнёте воспроизводить эту систему, вы обнаружите больше скрытых компромиссов — почему packages-private должен быть физически изолирован от packages? Почему встраивание перечислений должно быть завершено до Rollup? Следующая глава обобщит ключевые точки принятия решений и записи о производственных ошибках, выявленные в предыдущих двенадцати главах, предоставив вам полный список по избеганию ошибок и основания для решений.

CHAPTER 13

Глава 13: Архитектурные компромиссы и руководство по избеганию ошибок в monorepo

Проект: vuejs/core · Прогресс книги: Глава 13 / 14 · Статус проверки: FACT номера строк реально привязаны

В предыдущей главе мы, используяpackages-private/vite-debugв качестве отправной точки, освоили парадигму отладки для минимального воспроизведения на реальном исходном коде. Когда таких внутренних отладочных пакетов становится всё больше, всплывает практический вопрос: они сосуществуют в одном workspace с официальными публикуемыми пакетами, как гарантировать, что процесс публикации не заденет их по ошибке? В этой главе мы углубимся в граничные условия инженерии monorepo, начиная с двойного контракта каталоговpackagesиpackages-private, проанализируем защитный дизайн, стоящий за архитектурными компромиссами, и дадим практическое руководство по избеганию ошибок.

13.2 Железный закон последовательности: встраивание перечислений должно выполняться до Rollup

Интуитивная модель

Встраивание перечислений похоже на «замену меток на деталях цифрами перед упаковкой». Если упаковщик (Rollup) уже начал упаковывать, а вы затем меняете метки, детали и метки в коробке не совпадут.build.jsиспользуетscanEnums() / removeCache()эту пару функций, чтобы строго ограничить встраивание перед Rollup.

Структуры данных и жизненный цикл

inline-enums.jsэкспортируемыйscanEnums()возвращает замыканиеremoveCache, которое сканирует определения enum в исходном коде и генерирует временные файлы для потребления Rollup📎 scripts/build.js:30-34。build.jsизrun()используетtry/finallyдля гарантии очистки кэша📎 scripts/build.js:81-112:

js
const removeCache = scanEnums()
try {
  // ... buildAll / checkAllSizes / build-dts
} finally {
  removeCache()
}

rollup.config.jsна верхнем уровне модуля вызываетinlineEnums()получает[enumPlugin, enumDefines] 📎 rollup.config.js:47-50, гдеenumPluginвставляется в массив plugins📎 rollup.config.js:331-331,enumDefinesи включается в таблицу замен плагина replace📎 rollup.config.js:222-223。

Пошагово: полный жизненный цикл перечисления в одной сборке

1. build.jsизrun()сначала вызываетscanEnums(), сканирует определения enum всех пакетов и записывает во временный кэш, возвращаетremoveCache 📎 scripts/build.js:87-87。

2. buildAllпараллельно запускает несколько процессов Rollup📎 scripts/build.js:119-121。

3. Каждый процесс Rollup на этапе загрузки конфигурации выполняетinlineEnums(), читает кэш, сгенерированный на предыдущем шаге, получаетenumPluginиenumDefines 📎 rollup.config.js:47-50。

4. enumPluginна этапе transform заменяет ссылки на enum в исходном коде на литералы;enumDefinesкак дополнение к replace обрабатывает замену констант между модулями📎 rollup.config.js:222-223。

5. По завершении сборкиfinallyблок вызываетremoveCache()очищает временные файлы📎 scripts/build.js:119-121。

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

Размышления о дизайне и подводные камни

〔Проектные предположения и архитектурные компромиссы〕

Почему бы не использовать плагин Rollup для сканирования и использования на месте на этапе transform? Потому что встраивание перечислений требуетглобального представления между пакетами:runtime-core: enum, на который ссылаются, может быть определён вshared, а отдельный процесс Rollup видит только дерево исходного кода своего пакета и не может выполнить замену между пакетами.scanEnums()создание глобального кэша перед сборкой как раз и решает эту проблему видимости.

Производственные подводные камни:removeCache()размещение вfinallyозначает, что очистка произойдёт даже при ошибке в середине сборки. Но если вы вручную прервёте процесс при отладке (Ctrl+C),finallyможет не выполниться, и остаточные файлы кэша приведут к чтению устаревших перечислений при следующей сборке. Метод диагностики: проверьте, нет ли в каталогеtemp/остаточных файлов кэша enum, удалите их вручную и повторите попытку.

---

13.3 Оркестратор публикации:release.jsматрица флагов skip

Интуитивная модель

release.jsпохож на главного режиссёра свадьбы,skipBuild / skipTests / skipGit / skipPromptsчетыре переключателя — это кнопки «пропустить репетицию», «пропустить клятву», «пропустить фото», «пропустить подтверждение». Наличие каждой кнопки соответствует реальному сценарию: в среде CI нуженskipPrompts, при локальной отладке нуженskipGit, при экстренном хотфиксе нуженskipTests。

Структура данных и значения по умолчанию для флагов

четыре флага skip объявлены вparseArgs, затем деструктурированы в локальные переменные📎 scripts/release.js:39-50Копировать📎 scripts/release.js:64-66:

js
let skipTests = args.skipTests
const skipBuild = args.skipBuild
const skipPrompts = args.skipPrompts
const skipGit = args.skipGit

используетskipTestsИспользоватьletобъявление, поскольку оно вrunTestsIfNeeded()будет динамически перезаписано📎 scripts/release.js:281-317。

Пошагово: полный поток принятия решений для одного release

main()порядок выполнения📎 scripts/release.js:143-279:

1. Проверка удалённой синхронизации:isInSyncWithRemote()Сравнение локального HEAD с SHA удалённой ветки, при несовпадении выводится диалог подтверждения📎 scripts/release.js:337-363。

2. Выбор версии: при отсутствии позиционных аргументов появляетсяversionIncrementsменю выбора📎 scripts/release.js:152-176。

3. Решение о тестировании:runTestsIfNeeded()Здесь наиболее плотно сосредоточена логика skip📎 scripts/release.js:281-317。

4. Обновление версии:updateVersions()Обход и перезапись всех пакетовpackage.json 📎 scripts/release.js:377-398。

5. Генерация Changelog: вызовpnpm run changelog 📎 scripts/release.js:211-212。

6. Git-коммит:skipGitПри значении true весь блок пропускается📎 scripts/release.js:231-240。

7. Публикация: только когдаargs.publishвыполняется при значении truebuildPackages() + publishPackages() 📎 scripts/release.js:243-246。

runTestsIfNeeded()Логика ветвления заслуживает отдельного рассмотрения:

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

Размышления о дизайне и подводные камни

〔Проектные решения и архитектурные компромиссы〕

skipTestsИспользованиеletвместоconstобусловлено оптимизацией «автоматически пропускать локальное тестирование, если CI уже пройден». Это экономит значительное время в сценариях CI-релиза — в GitHub Actionsrelease.ymlуже выполнил полный набор тестов, и повторный локальный запуск был бы пустой тратой ресурсов.

Скрытый контракт порядка публикации:sortPackagesForPublishingРазмещениеvueв конце📎 scripts/release.js:85-85с явным комментарием «пользователи не могут установить новый входной пакет до того, как внутренние пакеты станут доступны». Если изменить этот порядок, пользователиnpm install vue@nextмогут получить версию, зависимости которой ещё не опубликованы, что приведёт кERR_MODULE_NOT_FOUND。

Защита идемпотентности:publishPackageПеред публикацией вызываетсяisPackagePublishedдля проверки registry📎 scripts/release.js:453-458, при неудачной публикации перехватывается ошибкаpreviously publishedи происходит переход к пропуску📎 scripts/release.js:480-488. Это позволяет скрипту release безопасно повторяться — повторный запуск после сетевого сбоя не завершится общей ошибкой из-за «пакет уже существует».

Откат при неудаче:fnToRun().catch()вversionUpdatedвызывается, когда истинноupdateVersions(currentVersion)Номер версии отката📎 scripts/release.js:528-537. Но обратите внимание: это откатывает толькоpackage.jsonполе версии вне откатывает ужеgit commitкоммиты. Если вы публикуете приskipGitравном ложно и публикация не удалась, необходимо вручнуюgit reset。

---

Размышления о дизайне: общий паттерн трёх компромиссов

Оглядываясь на три ключевых компромисса в этой главе, они разделяют одну и ту же философию проектирования:превратить «легко забываемые проверки времени выполнения» в «структурные ограничения, которые невозможно обойти»。

  • packages-privateФизическая изоляция: не полагаться на то, что автор скрипта не забудет проверитьprivateполя, а сделать так, чтобы область сканирования естественным образом их исключала.
  • Встраивание перечислений на этапе предварительной обработки: вместо того чтобы полагаться на то, что плагин Rollup при трансформации «случайно» увидит межпакетные перечисления, глобальный кэш создаётся до сборки.
  • release.jsМатрица пропусков: вместо того чтобы полагаться на то, что издатель помнит «раз CI прошёл, локально тесты можно не запускать», скрипт автоматически запрашивает статус CI и перезаписываетskipTests。
〔Проектные выводы и архитектурные компромиссы〕

Цена такого подхода —рост сложности скриптов:build.jsнеобходимость поддерживатьprivatePackagesсписок,rollup.config.jsнеобходимость дублировать логику обнаружения каталогов,release.jsнеобходимость обрабатывать пересекающиеся комбинации четырёх флагов пропуска. Но для такого репозитория, как Vue, выпускающего релизы несколько раз в неделю, выгода в надёжности, которую дают структурные ограничения, значительно перевешивает затраты на сложность.

---

Краткое содержание главы

В этой главе, исходя из исходного кода, были разобраны три ключевых граничных условия инженерной системы Vue core:

1. packages-privateиpackagesфизическое разделениеобеспечивается workspace glob,build.jsобнаружение каталогов,release.jsТри общих гарантии фильтрации📎 pnpm-workspace.yaml:1-3📎 scripts/build.js:153-170📎 scripts/release.js:68-83。

2. Встроенные в перечисление временные ограниченияобеспечиваетсяscanEnums() / removeCache()изtry/finallyСтруктура принудительно гарантирует, что конфигурация Rollup потребляет кэш на верхнем уровне модуля📎 scripts/build.js:81-112📎 rollup.config.js:47-50。

3. release.jsМатрица флагов пропуска дляОбслуживает три сценария: CI-релиз, локальная отладка и экстренное горячее исправление,skipTestsДинамическое переписывание и сортировка порядка публикации — это два наиболее легко упускаемых скрытых контракта📎 scripts/release.js:281-317📎 scripts/release.js:85-85。

Вопросы для размышления и самопроверки в этой главе

Q1: Если убратьbuild.jsвbuild(target)из функцииprivatePackages.includes(target)проверку и унифицированно использоватьpackagesв качествеpkgBase, в каких сценариях возникнут проблемы?

Справочный анализ:build.js:160-164Обнаружение каталога является единственной точкой входа, через которую могут быть собраны приватные пакеты. После удаления,nr build vite-debugбудет искать вpackages/vite-debugкаталогpackage.json, а этот каталог не существует,fs.readFileSyncнапрямую выбрасываетENOENT. Более скрытая проблема: если в будущем кто-то создаст каталог с тем же именем вpackages/, сборка будет молча использовать конфигурацию из неправильного каталога, пути к артефактам иbuildOptionsполностью сместятся. Кроме того,rollup.config.js:37-42имеет независимую логику обнаружения каталогов, обе точки должны изменяться синхронно, иначе возникнет несогласованное состояние «build.jsнашёл пакет, но Rollup не может найти».

Q2: release.jsвrunTestsIfNeeded(),skipTests ||= isCIPassedэта строка кода (release.js:285) вskipPromptsКогда условие истинно и CI не пройден, в какую ветку будет выполнено переход? Если убратьelse if (skipPrompts)веткиthrow, какие будут последствия?

Справочный разбор: КогдаskipPromptsистинно и CI не пройден,skipTests ||= isCIPassedвisCIPassedравноfalse,skipTestsсохраняет исходное значение (обычноfalse). Затем происходит переход в веткуelse if (skipPrompts), выбрасываетсяError(release.js:299-304). Если убрать этотthrow, код продолжит выполнение до веткиif (!skipTests), запуская в неинтерактивной средеpnpm run test --run. В CI это может привести к тому, что тесты упадут из-за различий в окружении, или, что ещё хуже — тесты пройдут, но CI фактически не пройден (например, CI запускает другой набор тестов), и будет выпущена версия без полной проверки.

Q3: rollup.config.js:55изinlineEnums()вызывается на верхнем уровне модуля, аbuild.js:87的scanEnums()вrun()вызывается внутри функции. Что нарушится, если поменять порядок выполнения этих двух (то есть заставитьinlineEnums()вызываться в хуке RollupbuildStart)?

Справочный анализ:scanEnums()должен быть завершён до запуска всех процессов Rollup, поскольку ему необходимо просканироватьвсе пакетыисходный код для построения глобального кэша enum.inlineEnums()вызывается на верхнем уровне модуляrollup.config.js, в этот момент Rollup ещё не начал никаких сборок, и кэш уже готов. Если вместо этого вызвать вbuildStart, каждый процесс Rollup будет сканировать независимо — ноbuildAllвыполняются параллельно (build.js:119-121), одновременное сканирование одной и той же группы файлов несколькими процессами порождает состояние гонки: процесс A может прочитать файл кэша, который процесс B ещё не дописал, что приведёт к неполной замене enum. Что ещё серьёзнее,scanEnums()возвращаемыйremoveCacheзамыкание зависит от состояния файловых дескрипторов на момент сканирования, и в условиях конкурентности момент очистки невозможно согласовать.

Двойной каталог-контракт, определение принадлежности сборочных скриптов, вторичная фильтрация скриптов публикации — эти механизмы совместно очерчивают границы безопасности инженерии monorepo. Но границы не являются неизменными: по мере миграции инструментов сборки с Rollup на Rolldown и сближения типовых и runtime-тестов существующие стратегии компромиссов столкнутся с новыми вызовами. В следующей главе мы, опираясь на траекторию изменений с 3.0 по 3.4, рассмотрим направления эволюции инженерной системы следующего поколения.

CHAPTER 14

Глава 14: Будущая эволюция: от Vue 3.x к инженерной системе следующего поколения

Проект: vuejs/core · Прогресс книги: Глава 14 / 14 · Статус верификации: FACT — реальная привязка к номерам строк

В предыдущей главе мы разобрали «границы безопасности» инженерной системы Vue core — двойной каталог-контракт, определение принадлежности сборочных скриптов, вторичную фильтрацию скриптов публикации. Эти механизмы не были спроектированы за один раз, а многократно оттачивались в итерациях с 3.0 по 3.4. В этой главе мы сменим ракурс: посмотрим не на то, «как это выглядит сейчас», а на то, «как оно стало таким», и на основе этого выведем, куда движется инженерная система следующего поколения. Исходными материалами этой главы являются changelogs/CHANGELOG-3.3.md, changelogs/CHANGELOG-3.4.md и package.json в корне репозитория. Журнал изменений выглядит как простая летопись «что за баг исправили», но на самом деле это самый достоверный отчёт о состоянии инженерной системы: каждый коммит с префиксом build:, каждое изменение с префиксом types:, каждый откат версии зависимости — всё это обнажает точки напряжения текущей архитектуры. Наша задача — прочитать направление эволюции из этих точек напряжения. Рассматривать журнал изменений как «окно наблюдения за инженерной системой», а не как «список функций» — это ключевая методология данной главы. Функциональные изменения говорят нам, что умеет Vue, а изменения, связанные со сборкой, типами и CI, говорят нам, «где болит» инженерная система Vue.

I. Точки напряжения цепочки инструментов сборки: потенциал миграции с Rollup на Rolldown

Интуитивная модель

Представьте цепочку инструментов сборки как сборочный конвейер: Rollup — главный сборочный стол, esbuild отвечает за быструю нарезку (транспиляцию TS), terser — за финальную упаковку и сжатие. Когда продукт (runtime Vue) становится всё сложнее, операций на сборочном столе всё больше, и сам главный сборочный стол становится узким местом. Позиционирование Rolldown — это главный сборочный стол, переписанный на Rust; он призван заменить не esbuild, а сам Rollup.

Без этого давления эволюции система столкнулась бы с «катастрофой» не в виде краха, а в виделинейного роста времени сборки с числом пакетов: с каждым новым подпакетом приходится запускать ещё один процесс Rollup, ещё раз сканировать кэш enum, ещё раз прогонять генерацию dts.

Структуры данных и схема зависимостей

Сначала посмотрим на статический снимок текущей цепочки инструментов.package.jsonВdevDependenciesнаходится

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

Копировать^4.63.3Здесь можно прочитать три ключевых факта. Во-первых, мажорная версия Rollup —rollup-plugin-esbuild, что соответствует зрелому периоду Rollup 4.x. Во-вторых,rollup-plugin-dtsотвечает за транспиляцию TS, а значит, сам Rollup не разбирает TS, а обрабатывает только JS, выдаваемый esbuild. В-третьих,.d.tsнезависимо отвечает за упаковкуdts-built-test, и это как раз материальная основа независимости

, обсуждавшейся в предыдущей главе.

📎 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-dtsКопироватьtsc --noCheckявляется «двухэтапным»: сначала--noCheckгенерирует исходные файлы деклараций (rollup -c rollup.dts.config.jsпропускает проверку типов, выполняя только emit), затем.d.tsупаковывает разрозненныеrollup-plugin-dtsв единый файл. Этот дизайн сам по себе зависит от возможностей Rollup —

требуется модульный граф Rollup для отслеживания зависимостей типов.build:Сценарий: что выявил один коммит

В журнале изменений записи с префиксомbuild:являются прямым доказательством точек напряжения цепочки инструментов сборки. Выберем три из них.

Первая — выравнивание конфигурации minify в 3.4.32:

📎 changelogs/CHANGELOG-3.4.md:84

code
* **build:** use consistent minify options from previous terser config ([789675f](https://github.com/vuejs/core/commit/789675f65d2b72cf979ba6a29bd323f716154a4b))

Мотив этого коммита — «после миграции с terser на esbuild minify параметры сжатия не согласованы». Это выявляет промежуточное состояние миграции: Vue когда-то использовал terser для сжатия, затем перешёл на esbuild (это подтверждаетdevDependenciesвesbuild: ^0.28.2), но параметры сжатия не были полностью выровнены, что привело к отклонениям в размере или поведении артефактов. Это типичная цена «замены деталей сборочного стола».

Вторая — откат версии entities в 3.4.38:

📎 changelogs/CHANGELOG-3.4.md:6

code
* **build:** revert entities to 4.5 to avoid runtime resolution errors ([f349af7](https://github.com/vuejs/core/commit/f349af7b65b9f8605d8b7bafcc06c25ab1f2daf0)), closes [#11603](https://github.com/vuejs/core/issues/11603)

entities— это библиотека декодирования HTML-сущностей, от которой зависитcompiler-dom. Откат до 4.5 произошёл потому, что новая версия вызвала проблемы при runtime-разборе. Этот коммит показывает:обновление зависимостей цепочки инструментов сборки не является изолированным — скачок версии косвенной зависимости проникает в runtime-поведение。

Третья — загрязнение cjs-сборки server-renderer в 3.4.29:

📎 changelogs/CHANGELOG-3.4.md:155

code
* **build:** fix accidental inclusion of runtime-core in server-renderer cjs build ([11cc12b](https://github.com/vuejs/core/commit/11cc12b915edfe0e4d3175e57464f73bc2c1cb04)), closes [#11137](https://github.com/vuejs/core/issues/11137)

Это самый типичный класс багов сборки: в формате CJSserver-rendererнеожиданно включилruntime-coreв свой артефакт. Причина обычно в том, что определениеexternalв Rollup перестаёт работать в формате CJS — ESM может статически распознавать внешние зависимости поimport-инструкциям, а CJSrequireДинамичность выше, легко пропустить. Этот коммит напрямую указывает на уязвимость логикиexternalв конфигурации Rollup.

Mermaid-визуализация движущих сил миграции

Приведённая ниже диаграмма отображает поток управления текущего конвейера сборки и отмечает узлы, которых коснётся миграция на 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["сборка завершена"]
〔Проектные выводы и архитектурные компромиссы〕

Ценность миграции на Rolldown заключается в том, что он заменяет модель параллелизма «один процесс на пакет» на модель «параллелизм внутри одного процесса»,scanEnums()глобальное сканирование и заменаinlineEnums()могут координироваться в рамках одной среды выполнения Rust, и проблема «гонки при параллельном сканировании», обсуждавшаяся в предыдущей главе, исчезнет в корне. Но сопротивление миграции тоже здесь —rollup-plugin-esbuild、rollup-plugin-dtsэтим экосистемам плагинов требуется, чтобы Rolldown предоставил слой совместимости, аexternalлогику определения необходимо переписать.

Проектные размышления и подводные камни

Почему миграция не произойдёт в один шаг?Посмотрите наpackage.jsonполеengines:

📎 package.json:61-63

code
  "engines": {
    "node": ">=20.0.0"
  },

Node 20 — жёсткий минимум. Rolldown как нативный модуль Rust требует соответствующих привязок N-API и распространения предкомпилированных бинарников. Как только он будет внедрён,pnpm installвремя выполнения, кросс-платформенная (Windows/macOS/Linux) совместимость бинарников, стратегия кэширования CI — всё это придётся проектировать заново. Это не просто «замена зависимости», аперекалибровка всей цепочки установка-сборка-кэширование。

Подводные камни в продакшене:build-dtsУtsc --noCheck— это палка о двух концах. Пропуск проверки типов ускоряет emit, но означает, что на этапе генерации.d.tsошибки типов не будут обнаружены — ошибки типов могут быть пойманы только черезpnpm check(tsc --incremental --noEmit) иtest-dtsкак запасной вариант. Если после миграции на Rolldown захочется объединить эти два шага, необходимо убедиться, что проверка типов не замедлит сборку, иначе это противоречит первоначальной цели--noCheck.

---

II. Тенденция слияния типовых тестов и runtime-тестов

Интуитивная модель

Представьте типовые тесты и runtime-тесты как два независимых контрольных пункта качества: один проверяет, правильно ли написана «инструкция (.d.ts)», другой проверяет, правильно ли крутится «машина (runtime)». У каждого пункта — свой рабочий пост, свои инструменты, свой отчёт. Смысл тенденции слияния в следующем:можно ли заставить один и тот же тест-кейс одновременно проверять и инструкцию, и машину?

Если слияния не будет, система столкнётся с катастрофой —расхождение типов и runtime-поведения:.d.tsговорит, чтоref()возвращаетRef<T>, но фактическая форма возвращаемого объекта во время выполнения изменилась: типовой тест проходит, runtime-тест тоже проходит, но их комбинация ошибочна.

Структура данных: компоновка тестовых скриптов

package.jsonВscriptsтестовые записи чётко разделены на две группы:

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

Ключевая структура здесь —test-dtsвrun-s build-dts test-dts-only— онапоследовательная: сначала сборка.d.ts, затем запуск типовых тестов. А внутриtest-dts-onlyнаходятсядва независимыхtscпроцесса: один запускаетdts-built-test(проверка артефактов сборки), другой запускаетdts-test(проверка типов исходного кода).

Обратите внимание:test-unitиспользуетvitest --project unit*,test-e2eиспользуетvitest --project e2e --project e2e-browser. Это показывает, что механизм--projectв Vitest уже разделил тесты на разные project по категориям «unit/e2e/browser».Физическая основа для слияния уже существует: механизм project в Vitest позволяет запускать разные типы тестов в одном runner.

Сценарий: полный путь одного коммитаtypes:В changelog плотность записей с префиксом

чрезвычайно высока — это прямое отражение сложности системы типов. Проследим типичное исправление типов.types:Откат типов ref в 3.4.37:

Копировать

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

Копировать

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

типовой тест может проверить, что «сигнатура типа соответствует ожиданиям», но не может проверить, «удобна ли эта сигнатура типа в реальном коде»В типовом тесте。allow getter and setter types to be unrelatedможет полностью проходить, но при реальном использовании вывод типовrefстанет слишком широким, что подорвёт типобезопасность downstream-кода.

Mermaid-визуализация слияния типовых тестов

Приведённая ниже диаграмма отображает текущую раздельную структуру типовых и runtime-тестов, а также целевую форму после слияния:

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["отчёт по runtime"]
    end
    subgraph future["Цель объединения: единый runner"]
        src2["исходный код"] --> vitest_all["vitest --project unit --project dts"]
        vitest_all --> unified["унифицированный отчёт + утверждения типов"]
    end
    current -.эволюция.-> future
〔Проектные выводы и архитектурные компромиссы〕

Технический путь слияния, скорее всего, таков: обернуть вызовыdts-built-testиdts-testвtscкак пользовательский project в Vitest, чтобы типовые утверждения встраивались в тестовые файлы в формеexpectTypeOf. Тогда один вызовvitestсможет одновременно запускать runtime-утверждения и типовые утверждения с единым отчётом. Но сопротивление в том, что:tscпроверка типов в

— «полная», а тесты в Vitest — «пофайловые», их стратегии инкрементальности несовместимы.

Проектные размышления и подводные камниdts-built-testПочемуdts-test?должен быть независим отdts-built-testЭто уже обсуждалось в предыдущей главе, здесь дополним с точки зрения эволюции:проверяет(rollup-plugin-dtsартефакты сборки.d.ts),dts-testупакованныйпроверяеттипы исходного кода

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

Копироватьdts-built-test«Предоставить fallback stub при отсутствии DOM lib» — это исправление совместимости типов на уровне артефактов сборки, которое может быть обнаружено только в сценарии.d.ts— «потребления упакованного

».Подводные камни в продакшене: цикл «влитие-откат» в типовых тестах показывает, что изменения сигнатур типов требуют проверки нареальных downstream-проектахpackages-private/dts-testиспользуются внутренние тестовые примеры репозитория, которые не охватывают все downstream-сценарии. Если тенденция конвергенции сосредоточена только на «объединении двух runner», но не решает вопрос «как внедрить реальную downstream-обратную связь», это лишь формальная конвергенция.

---

III. Направления тонкой оптимизации CI-кэша

Интуитивная модель

Представьте CI-кэш как «зону подготовки материалов» склада: каждая сборка берёт из неё сырьё (зависимости, артефакты сборки, кэш типов). Если в зоне подготовки только один большой ящик, то для того чтобы достать любую вещь, нужно перерыть весь ящик — и даже при высоком проценте попаданий в кэш это не будет быстро. Тонкая оптимизация означает:разбить большой ящик на маленькие ячейки, классифицированные по назначению。

Без тонкого кэширования система сталкивается с катастрофой:каскадное усиление инвалидации кэша: изменение одной строки исходного кода приводит к инвалидации всегоnode_modulesкэша, CI переустанавливает все зависимости, время сборки вырастает с 2 минут до 10 минут.

Структура данных: классификация кэшируемых объектов

Изpackage.jsonможно выделить несколько категорий кэшируемых «материалов»:

Первая категория — артефакты установки зависимостей.packageManagerПоле

📎 package.json:4

code
  "packageManager": "pnpm@12.4.2",

Копироватьnode_modulespnpm использует структуру символических ссылок; кэшируется content-addressable store pnpm, а не плоскийnode_modules. Это означает, что ключ кэша должен основываться на хэшеpnpm-lock.yaml, а неpackage.json。

Вторая категория — артефакты сборки.cleanСкрипт

📎 package.json:10

code
    "clean": "rimraf --glob packages/*/dist temp .eslintcache",

packages/*/dist、temp、.eslintcacheКопироватьdist— эти три категории артефактов можно кэшировать независимо.temp— это выходные данные сборки,bench.json),.eslintcache— временные файлы (например,

— кэш lint.checkТретья категория — кэш проверки типов.--incremental:

📎 package.json:15

code
    "check": "tsc --incremental --noEmit",

--incrementalиспользует.tsbuildinfoКопироватьtscсоздаёт файл

, который является инкрементальным кэшем проверки типов. Если в CI кэшировать этот файл,

повторный запускpackages/reactivity/src/ref.tsбудет намного быстрее.

Сценарий: поток выполнения CI для одного PRscriptsРассмотрим типичный сценарий: разработчик изменилsimple-git-hooksи отправил PR. Какие шаги нужно выполнить в CI, и какие из них могут попасть в кэш?pre-commitИз

📎 package.json:48-51

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

вpre-commit— это локальный хук, CI запускает более полную последовательность):lint-stagedКопироватьcheckЛокальныйlint、check、test-unit、test-dts、sizeзапускает

  • lintи.eslintcache. В CI запускаются
  • checkи т.д. Стратегия кэширования для каждого шага различается:.tsbuildinfo: кэшироватьtsconfig, ключ на основе хэша файлов исходного кода.
  • test-unit: кэшировать
  • test-dts, ключ на основеbuild-dtsи хэша исходного кода.packages/*/dist: у Vitest есть собственный кэш, но обычно в CI кэшируются не результаты тестов, а только зависимости.
  • size: зависит от артефактов

, ключ кэша на основе хэша

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 пройден"]
Mermaid-описание оптимизации CI-кэша

Копировать〔Проектные выводы и архитектурные компромиссы〕Основное противоречие тонкого кэширования — этоpackages/*гранулярность ключа кэшаdist,reactivity: слишком грубый ключ (например, только на основе commit hash) даёт низкий процент попаданий; слишком тонкий ключ (например, на основе хэша каждого файла) — затраты на вычисление ключа перекрывают выгоду от кэша. Разумная стратегия для такого monorepo, как Vue, — «шардинг по пакетам»: каждыйcompiler-coreподпакет кэшируется независимо, изменениеdistне инвалидирует

кэш

.sizeПроектные размышления и подводные камниПочему скрипт

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

sizeПосмотрите на эти три:run-s "size-*"Копироватьsize-используетsizeдля последовательного запуска всех подкоманд с префиксом

. Этот паттерн «агрегации по префиксу» позволяет каждому измерению размера (global, esm-runtime, esm) кэшироваться и падать независимо. Если объединить в одну большую команду, превышение лимита по любому измерению приведёт к падению всего, и невозможно будет определить, в каком измерении проблема.Подводные камни в продакшене: самый частый подводный камень CI-кэша — этоcleanзагрязнение кэша

📎 package.json:10

code
    "clean": "rimraf --glob packages/*/dist temp .eslintcache",

Скриптpackages/*/distсуществует именно для борьбы с этим:packages-private/*/distКопироватьpackages-privateОбратите внимание, что он очищаетpackages-private, а неclean. Это означает, что артефактыpackages-privateне входят в обычную область очистки — если CI закэшировал артефакты

---

, а

их не очищает, может возникнуть проблема «закэшированы артефакты старой версии playground». При проектировании тонкого кэша необходимо обрабатыватьотдельно.。

Проектное размышление: инженерная система как жизненный цикл продукта

Связывая нити трёх разделов, можно увидеть чёткую основную линию:

Инженерная система Vue движется от «работает» к «удобно работать», от «ручного оркестрирования» к «декларативной конфигурации»

Миграция инструментальной цепочки сборки (Rollup → Rolldown) — это эволюция, «движимая производительностью»: когда количество пакетов вырастает до определённого уровня, накладные расходы на процессный параллелизм превышают выгоду, и необходимо перейти к более лёгкой модели параллелизма.

Конвергенция типовых тестов — это эволюция, «движимая согласованностью»: когда частота изменений сигнатур типов превышает частоту изменений runtime-поведения, раздельные наборы тестов становятся обузой, и необходимо заставить их использовать общие тестовые примеры.Тонкая грануляция CI-кэша — это эволюция, «движимая стоимостью»: когда минуты CI становятся узким местом, расточительность грубого кэша становится неприемлемой, и необходимо шардировать по назначению.〔Проектные выводы и архитектурные компромиссы〕BREAKING CHANGESОбщее ограничение этих трёх линий эволюции —

---

обратная совместимость

. Стратегия релизов Vue (видно из разделаpackage.jsonв changelog) допускает «type-only breaking change» в minor-версиях, но не допускает runtime breaking change. Это означает, что эволюция инженерной системы должна гарантировать: как бы ни менялась внутренняя инструментальная цепочка, публичный API и runtime-поведение артефактов не должны меняться. Это жёсткая граница всех эволюционных решений.

1. Резюме главы:Текущая комбинация Rollup 4.x + esbuild + rollup-plugin-dts, её точки напряжения проявляются вbuild:коммитах с префиксом (выравнивание конфигурации minify, откат версии entities, пропуск определения CJS external). Потенциал миграции на Rolldown исходит из замены «многопроцессной конкурентности» на «однопроцессный параллелизм», сопротивление — из экосистемы плагинов и кросс-платформенного распространения бинарников.

2. Слияние типовых тестов:test-dtsизrun-s build-dts test-dts-onlyпоследовательная структура, а такжеdts-built-testиdts-testдвойнойtscпроцесс — это физическое доказательство текущей раздельной формы. Технический путь слияния — использование механизма--projectVitest, сопротивление —tscполная проверка несовместима с инкрементальной стратегией тестирования по файлам в Vitest.

3. Детализация кэша CI:packageManagerфиксация pnpm,cleanочистка трёх типов артефактов,checkиспользование--incremental、sizeагрегация по префиксу — всё это критерии классификации кэшируемых объектов. Основное противоречие — гранулярность ключа кэша, разумная стратегия — «шардирование по пакетам».

Самое важное изменение в осознании:сама система инженерии — это продукт, у неё есть свои пользователи (контрибьюторы), свои метрики производительности (время сборки, минуты CI), свои ограничения совместимости (неизменность API артефактов). Она требует непрерывной итерации, а не единоразового проектирования.

Размышления и самопроверка в этой главе

Q1: package.json:9изbuild-dtsиспользуетtsc -p tsconfig.build.json --noCheck. Если убрать--noCheck, какие цепные реакции это вызовет после миграции на Rolldown?

Справочный анализ:--noCheckНазначение — пропустить проверку типов и выполнить только emit. После его удаленияtscперед генерацией.d.tsбудет выполнять полную проверку типов. В текущей архитектуре Rollup это лишь замедлитbuild-dts; но после миграции на Rolldown проблема усилится: ключевое преимущество Rolldown — «однопроцессная параллельная сборка», и если на этапеbuild-dtsвводится полная проверкаtsc, она становится последовательным узким местом всего конвейера — сборка всех пакетов должна ждать завершения этой проверки. Что ещё серьёзнее,tscпроверка типов однопоточна и не может использовать параллельные возможности Rolldown. Правильный подход — сохранить--noCheck, передав проверку типов независимымpnpm check(package.json:15) иtest-dts(package.json:22), развязав сборку и проверку.

Q2: Журнал изменений 3.4.37 последовательно откатил дваtypes/refисправления (CHANGELOG-3.4.md:23-24), хотя эти два исправления были только что включены в 3.4.35 (CHANGELOG-3.4.md:30,55). Если бы типовые тесты и runtime-тесты уже были слиты, можно ли было бы избежать этого цикла «включение-откат»? Почему?

Справочный анализ: Полностью избежать нельзя, но можно сократить цикл. Слитые типовые тесты по-прежнему могут проверять лишь «соответствие сигнатуры типа утверждению», а проблема таких исправлений, какallow getter and setter types to be unrelated, заключается в том, что «сигнатура типа слишком широка и нарушает типобезопасность downstream-кода» — это проблемаdownstream-использования, а несамой сигнатуры. Объединение может сократить цикл в том случае, если утверждения типов и runtime-утверждения записаны в одном тестовом файле — разработчик быстрее обнаружит несоответствие «сигнатура типа изменилась, а runtime-поведение — нет». Но чтобы действительно избежать откатов, нужно внедрить проверку типов реальных downstream-проектов (например, расширитьpackages-private/dts-testдо набора тестов «имитация downstream-использования»), что выходит за рамки простого «слияния runner».

Q3: package.json:10изcleanскрипт очищаетpackages/*/dist, но не очищаетpackages-private/*/dist. Если CI применяет стратегию детализированного кэширования «шардирование по пакетам», какую производственную ловушку создаст эта асимметрия?

Справочный анализ: Ловушка в «кэшировании старых артефактовpackages-private».packages-privateсодержитsfc-playground、template-explorerи другие инструменты отладки, их артефакты сборки (например,packages-private/sfc-playground/dist) если кэшируются CI, аcleanих не очищает, возникает ситуация: исходный код обновлён, но CI переиспользует старые артефакты playground, что приводит к искажению результатов проверкиbuild-sfc-playground(package.json:39). Что ещё более скрыто:dev-sfc-prepare(package.json:34) проверяет, существуют ли артефактыpackages-private, и если старые артефакты закэшированы, он пропустит пересборку, заставив разработчика думать, что окружение новое. При проектировании детализированного кэша необходимо определить отдельный ключ кэша дляpackages-private, или вообще не кэшировать его артефакты — поскольку это инструмент отладки, стоимость пересборки низка, а выгода от кэширования мала.

Через окно наблюдения журнала изменений мы выявили точки напряжения текущей системы инженерии и на их основе вывели возможные направления эволюции системы следующего поколения. Эти направления не витают в воздухе, а выросли из реальных производственных ошибок и компромиссов. На этом анализ системы инженерии Vue в книге завершается, но исследование инженерии бесконечно — следующая глава, как заключительная, отдалит перспективу от самого Vue и рассмотрит, как этот опыт переносится на более широкие инженерные сценарии.

Чтобы понять любой сложный проект, на самом деле нужна всего одна хорошая книга

Эта книга «Инженерный разбор репозитория Vue core: архитектура полной цепочки от исходного кода до релиза» автоматически составлена AiReadCode путём сканирования официального открытого репозитория. Будь то крупный открытый проект в сотни тысяч строк или сложная внутренняя корпоративная система, вы можете в один клик сгенерировать такую же чётко структурированную персональную монографию.

Бесплатно скачать клиент AiReadCode Смотреть больше открытых книг →
🇨🇳 Китайский · 🇺🇸 EN · 🇯🇵 Японский · 🇰🇷 한국어 · 🌐 Традиционный китайский · 🇪🇸 ES · 🇩🇪 DE · 🇫🇷 FR · 🇧🇷 PT · 🇷🇺 RU