daiwei 2 месяцев назад
Родитель
Сommit
007378408c

+ 1 - 0
package.json

@@ -34,6 +34,7 @@
     "prebench-compare": "node scripts/build.js -pf esm-browser reactivity",
     "bench": "vitest bench --project=unit --outputJson=temp/bench.json",
     "bench-compare": "vitest bench --project=unit --compare=temp/bench.json",
+    "bench-builds": "node scripts/benchmark-builds.js -- --quiet --warmup 5 --runs 10",
     "release": "node scripts/release.js",
     "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s",
     "dev-esm": "node scripts/dev.js -if esm-bundler-runtime",

+ 1 - 1
rollup.config.js

@@ -13,7 +13,7 @@ import { nodeResolve } from '@rollup/plugin-node-resolve'
 import esbuild from 'rollup-plugin-esbuild'
 import alias from '@rollup/plugin-alias'
 import { entries } from './scripts/aliases.js'
-import { inlineEnums } from './scripts/inline-enums.js'
+import { inlineEnums } from './scripts/inline-enums-rollup.js'
 import { minify as minifySwc } from '@swc/core'
 import { trimVaporExportsPlugin } from './scripts/trim-vapor-exports.js'
 

+ 177 - 0
scripts/benchmark-builds.js

@@ -0,0 +1,177 @@
+#!/usr/bin/env node
+import { spawnSync } from 'node:child_process'
+
+const args = process.argv.slice(2)
+const runsArgIndex = args.findIndex(arg => arg === '--runs' || arg === '-n')
+const warmupArgIndex = args.findIndex(arg => arg === '--warmup' || arg === '-w')
+const runs =
+  runsArgIndex >= 0
+    ? Number(args[runsArgIndex + 1])
+    : Number(process.env.RUNS || 10)
+const warmup =
+  warmupArgIndex >= 0
+    ? Number(args[warmupArgIndex + 1])
+    : Number(process.env.WARMUP || 5)
+const quiet = args.includes('--quiet')
+
+if (!Number.isFinite(runs) || runs <= 0) {
+  console.error('Invalid runs value. Use --runs <number> or set RUNS.')
+  process.exit(1)
+}
+
+if (!Number.isFinite(warmup) || warmup < 0) {
+  console.error('Invalid warmup value. Use --warmup <number> or set WARMUP.')
+  process.exit(1)
+}
+
+const cases = [
+  {
+    title: 'build vs build-rollup',
+    a: {
+      label: 'build',
+      cmd: 'node',
+      args: ['scripts/build.js'],
+    },
+    b: {
+      label: 'build-rollup',
+      cmd: 'node',
+      args: ['scripts/build-with-rollup.js'],
+    },
+  },
+  {
+    title: 'build-dts vs build-dts-tsc',
+    a: { label: 'build-dts', cmd: 'node', args: ['scripts/build-types.js'] },
+    b: {
+      label: 'build-dts-tsc',
+      cmd: 'node',
+      args: ['scripts/build-dts-tsc.js'],
+    },
+  },
+]
+
+function extractBuiltInMs(output) {
+  const cleaned = output.replace(/\u001b\[[0-9;]*m/g, '').replace(/\s+/g, ' ')
+  const matches = [...cleaned.matchAll(/built in\s+([\d.]+)ms/gi)]
+  if (!matches.length) return null
+  return matches.reduce((sum, match) => sum + Number(match[1]), 0)
+}
+
+function runOnce(command, commandArgs, label, index) {
+  const result = spawnSync(command, commandArgs, {
+    encoding: 'utf8',
+    stdio: 'pipe',
+    shell: false,
+  })
+
+  if (!quiet) {
+    if (result.stdout) process.stdout.write(result.stdout)
+    if (result.stderr) process.stderr.write(result.stderr)
+  }
+
+  if (result.error) throw result.error
+  if (result.status !== 0) {
+    throw new Error(
+      `${label} failed with exit code ${result.status} on run ${index + 1}`,
+    )
+  }
+
+  const output = `${result.stdout ?? ''}${result.stderr ?? ''}`
+  const builtInMs = extractBuiltInMs(output)
+  if (!Number.isFinite(builtInMs)) {
+    const lines = output.trim().split(/\r?\n/)
+    const tail = lines.slice(-20).join('\n')
+    throw new Error(
+      `${label} did not emit a "built in <ms>" timing line on run ${
+        index + 1
+      }.\nLast output:\n${tail}`,
+    )
+  }
+
+  return builtInMs
+}
+
+function summarize(times) {
+  const sorted = [...times].sort((a, b) => a - b)
+  const sum = times.reduce((acc, t) => acc + t, 0)
+  const avg = sum / times.length
+  const min = sorted[0]
+  const max = sorted[sorted.length - 1]
+  const mid = Math.floor(sorted.length / 2)
+  const median =
+    sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
+  const variance =
+    times.reduce((acc, t) => acc + (t - avg) ** 2, 0) / times.length
+  const stddev = Math.sqrt(variance)
+  return { avg, min, max, median, stddev }
+}
+
+function formatMs(ms) {
+  return `${(ms / 1000).toFixed(2)}s`
+}
+
+function buildTable(rows) {
+  const widths = rows[0].map((_, col) =>
+    Math.max(...rows.map(row => String(row[col]).length)),
+  )
+  const formatRow = row =>
+    `| ${row.map((cell, i) => String(cell).padEnd(widths[i], ' ')).join(' | ')} |`
+  const separator = `| ${widths.map(w => '-'.repeat(w)).join(' | ')} |`
+  return [formatRow(rows[0]), separator, ...rows.slice(1).map(formatRow)].join(
+    '\n',
+  )
+}
+
+function renderStatsTable(labelA, labelB, statsA, statsB) {
+  const diff = statsB.avg - statsA.avg
+  const ratio = statsB.avg / statsA.avg
+  const sign = diff >= 0 ? '+' : '-'
+  const table = buildTable([
+    ['metric', labelA, labelB],
+    ['avg', formatMs(statsA.avg), formatMs(statsB.avg)],
+    ['median', formatMs(statsA.median), formatMs(statsB.median)],
+    ['min', formatMs(statsA.min), formatMs(statsB.min)],
+    ['max', formatMs(statsA.max), formatMs(statsB.max)],
+    ['stddev', formatMs(statsA.stddev), formatMs(statsB.stddev)],
+    ['avg diff', '-', `${sign}${formatMs(Math.abs(diff))}`],
+    ['avg ratio', '-', `${ratio.toFixed(2)}x`],
+  ])
+  console.log(table)
+}
+
+for (const testCase of cases) {
+  console.log(`\n== ${testCase.title} ==`)
+  const timesA = []
+  const timesB = []
+
+  if (warmup > 0) {
+    console.log(`\nWarmup x${warmup} - ${testCase.a.label}`)
+    for (let i = 0; i < warmup; i += 1) {
+      runOnce(testCase.a.cmd, testCase.a.args, testCase.a.label, i)
+    }
+    console.log(`\nWarmup x${warmup} - ${testCase.b.label}`)
+    for (let i = 0; i < warmup; i += 1) {
+      runOnce(testCase.b.cmd, testCase.b.args, testCase.b.label, i)
+    }
+  }
+
+  for (let i = 0; i < runs; i += 1) {
+    console.log(`\nRun ${i + 1}/${runs} - ${testCase.a.label}`)
+    const timeA = runOnce(testCase.a.cmd, testCase.a.args, testCase.a.label, i)
+    timesA.push(timeA)
+    console.log(`time: ${formatMs(timeA)}`)
+  }
+
+  for (let i = 0; i < runs; i += 1) {
+    console.log(`\nRun ${i + 1}/${runs} - ${testCase.b.label}`)
+    const timeB = runOnce(testCase.b.cmd, testCase.b.args, testCase.b.label, i)
+    timesB.push(timeB)
+    console.log(`time: ${formatMs(timeB)}`)
+  }
+
+  const statsA = summarize(timesA)
+  const statsB = summarize(timesB)
+  console.log('')
+  renderStatsTable(testCase.a.label, testCase.b.label, statsA, statsB)
+}
+
+console.log('\nDone.')

+ 18 - 0
scripts/build-dts-tsc.js

@@ -0,0 +1,18 @@
+#!/usr/bin/env node
+import { spawnSync } from 'node:child_process'
+import { performance } from 'node:perf_hooks'
+
+const start = performance.now()
+
+const steps = [
+  { cmd: 'tsc', args: ['-p', 'tsconfig.build.json', '--noCheck'] },
+  { cmd: 'rollup', args: ['-c', 'rollup.dts.config.js'] },
+]
+
+for (const step of steps) {
+  const result = spawnSync(step.cmd, step.args, { stdio: 'inherit' })
+  if (result.error) throw result.error
+  if (result.status !== 0) process.exit(result.status ?? 1)
+}
+
+console.log(`\ndts-tsc built in ${(performance.now() - start).toFixed(2)}ms.`)

+ 1 - 3
scripts/build-types.js

@@ -57,9 +57,7 @@ await Promise.all(
   ),
 )
 
-console.log(
-  `bundled dts generated in ${(performance.now() - start).toFixed(2)}ms.`,
-)
+console.log(`dts built in ${(performance.now() - start).toFixed(2)}ms.`)
 
 function write(file, content) {
   const dir = path.dirname(file)

+ 4 - 1
scripts/build-with-rollup.js

@@ -25,8 +25,9 @@ import path from 'node:path'
 import { brotliCompressSync, gzipSync } from 'node:zlib'
 import pico from 'picocolors'
 import { cpus } from 'node:os'
+import { performance } from 'node:perf_hooks'
 import { targets as allTargets, exec, fuzzyMatchTarget } from './utils.js'
-import { scanEnums } from './inline-enums.js'
+import { scanEnums } from './inline-enums-rollup.js'
 import prettyBytes from 'pretty-bytes'
 import { spawnSync } from 'node:child_process'
 
@@ -120,7 +121,9 @@ async function run() {
  * @returns {Promise<void>} - A promise representing the build process.
  */
 async function buildAll(targets) {
+  const start = performance.now()
   await runParallel(cpus().length, targets, build)
+  console.log(`built in ${(performance.now() - start).toFixed(2)}ms.`)
 }
 
 /**

+ 308 - 0
scripts/inline-enums-rollup.js

@@ -0,0 +1,308 @@
+// @ts-check
+
+/**
+ * We used const enums before, but it caused some issues: #1228, so we
+ * switched to regular enums. But we still want to keep the zero-cost benefit
+ * of const enums, and minimize the impact on bundle size as much as possible.
+ *
+ * Here we pre-process all the enums in the project and turn them into
+ * global replacements, and rewrite the original declarations as object literals.
+ *
+ * This file is expected to be executed with project root as cwd.
+ */
+
+import * as assert from 'node:assert'
+import {
+  existsSync,
+  mkdirSync,
+  readFileSync,
+  rmSync,
+  writeFileSync,
+} from 'node:fs'
+import * as path from 'node:path'
+import { parseSync } from 'oxc-parser'
+import { spawnSync } from 'node:child_process'
+import MagicString from 'magic-string'
+
+/**
+ * @typedef {{ readonly name: string, readonly value: string | number }} EnumMember
+ * @typedef {{ readonly id: string, readonly range: readonly [start: number, end: number], readonly members: ReadonlyArray<EnumMember>}} EnumDeclaration
+ * @typedef {{ readonly declarations: { readonly [file: string] : ReadonlyArray<EnumDeclaration>}, readonly defines: { readonly [ id_key: `${string}.${string}`]: string } }} EnumData
+ */
+
+const ENUM_CACHE_PATH = 'temp/enum.json'
+
+/**
+ * @param {string} exp
+ * @returns {string | number}
+ */
+function evaluate(exp) {
+  return new Function(`return ${exp}`)()
+}
+
+/**
+ * @param {import('oxc-parser').Expression | import('oxc-parser').PrivateIdentifier} exp
+ * @returns { exp is import('oxc-parser').StringLiteral | import('oxc-parser').NumericLiteral }
+ */
+function isStringOrNumberLiteral(exp) {
+  return (
+    exp.type === 'Literal' &&
+    (typeof exp.value === 'string' || typeof exp.value === 'number')
+  )
+}
+
+// this is called in the build script entry once
+// so the data can be shared across concurrent Rolldown processes
+export function scanEnums() {
+  /** @type {{ [file: string]: EnumDeclaration[] }} */
+  const declarations = Object.create(null)
+  /** @type {{ [id_key: `${string}.${string}`]: string; }} */
+  const defines = Object.create(null)
+
+  // 1. grep for files with exported enum
+  const { stdout } = spawnSync('git', ['grep', `enum `])
+  const files = [
+    ...new Set(
+      stdout
+        .toString()
+        .trim()
+        .split('\n')
+        .map(line => line.split(':')[0]),
+    ),
+  ]
+
+  // 2. parse matched files to collect enum info
+  for (const relativeFile of files) {
+    const file = path.resolve(process.cwd(), relativeFile)
+    const content = readFileSync(file, 'utf-8')
+    const res = parseSync(file, content, {
+      sourceType: 'module',
+    })
+
+    /** @type {Set<string>} */
+    const enumIds = new Set()
+    for (const node of res.program.body) {
+      let decl
+      if (node.type === 'TSEnumDeclaration') {
+        decl = node
+      }
+      if (
+        node.type === 'ExportNamedDeclaration' &&
+        node.declaration &&
+        node.declaration.type === 'TSEnumDeclaration'
+      ) {
+        decl = node.declaration
+      }
+
+      if (decl) {
+        const id = decl.id.name
+        if (enumIds.has(id)) {
+          throw new Error(
+            `not support declaration merging for enum ${id} in ${file}`,
+          )
+        }
+        enumIds.add(id)
+        /** @type {string | number | undefined} */
+        let lastInitialized
+        /** @type {Array<EnumMember>} */
+        const members = []
+
+        for (let i = 0; i < decl.body.members.length; i++) {
+          const e = decl.body.members[i]
+          const key =
+            e.id.type === 'Identifier'
+              ? e.id.name
+              : e.id.type === 'Literal'
+                ? e.id.value
+                : ''
+          if (key === '') continue
+
+          const fullKey = /** @type {const} */ (`${id}.${key}`)
+          const saveValue = (/** @type {string | number} */ value) => {
+            // We need allow same name enum in different file.
+            // For example: enum ErrorCodes exist in both @vue/compiler-core and @vue/runtime-core
+            // But not allow `ErrorCodes.__EXTEND_POINT__` appear in two same name enum
+            if (fullKey in defines) {
+              throw new Error(`name conflict for enum ${id} in ${file}`)
+            }
+            members.push({
+              name: key,
+              value,
+            })
+            defines[fullKey] = JSON.stringify(value)
+          }
+          const init = e.initializer
+          if (init) {
+            /** @type {string | number} */
+            let value
+            if (isStringOrNumberLiteral(init)) {
+              value = init.value
+            }
+            // e.g. 1 << 2
+            else if (init.type === 'BinaryExpression') {
+              const resolveValue = (
+                /** @type {import('oxc-parser').Expression | import('oxc-parser').PrivateIdentifier} */ node,
+              ) => {
+                assert.ok(typeof node.start === 'number')
+                assert.ok(typeof node.end === 'number')
+                if (isStringOrNumberLiteral(node)) {
+                  return node.value
+                } else if (
+                  node.type === 'MemberExpression' ||
+                  // @ts-expect-error oxc only type
+                  node.type === 'StaticMemberExpression'
+                ) {
+                  const exp = /** @type {`${string}.${string}`} */ (
+                    content.slice(node.start, node.end)
+                  )
+                  if (!(exp in defines)) {
+                    throw new Error(
+                      `unhandled enum initialization expression ${exp} in ${file}`,
+                    )
+                  }
+                  return defines[exp]
+                } else {
+                  throw new Error(
+                    `unhandled BinaryExpression operand type ${node.type} in ${file}`,
+                  )
+                }
+              }
+              const exp = `${resolveValue(init.left)}${
+                init.operator
+              }${resolveValue(init.right)}`
+              value = evaluate(exp)
+            } else if (init.type === 'UnaryExpression') {
+              if (isStringOrNumberLiteral(init.argument)) {
+                const exp = `${init.operator}${init.argument.value}`
+                value = evaluate(exp)
+              } else {
+                throw new Error(
+                  `unhandled UnaryExpression argument type ${init.argument.type} in ${file}`,
+                )
+              }
+            } else {
+              throw new Error(
+                `unhandled initializer type ${init.type} for ${fullKey} in ${file}`,
+              )
+            }
+            lastInitialized = value
+            saveValue(lastInitialized)
+          } else {
+            if (lastInitialized === undefined) {
+              // first initialized
+              lastInitialized = 0
+              saveValue(lastInitialized)
+            } else if (typeof lastInitialized === 'number') {
+              lastInitialized++
+              saveValue(lastInitialized)
+            } else {
+              // should not happen
+              throw new Error(`wrong enum initialization sequence in ${file}`)
+            }
+          }
+        }
+
+        if (!(file in declarations)) {
+          declarations[file] = []
+        }
+        assert.ok(typeof node.start === 'number')
+        assert.ok(typeof node.end === 'number')
+        declarations[file].push({
+          id,
+          range: [node.start, node.end],
+          members,
+        })
+      }
+    }
+  }
+
+  // 3. save cache
+  try {
+    mkdirSync('temp')
+  } catch {}
+
+  /** @type {EnumData} */
+  const enumData = {
+    declarations,
+    defines,
+  }
+
+  writeFileSync(ENUM_CACHE_PATH, JSON.stringify(enumData))
+
+  return () => {
+    rmSync(ENUM_CACHE_PATH, { force: true })
+  }
+}
+
+/**
+ * @returns {[import('rolldown').Plugin, Record<string, string>]}
+ */
+export function inlineEnums() {
+  if (!existsSync(ENUM_CACHE_PATH)) {
+    throw new Error('enum cache needs to be initialized before creating plugin')
+  }
+  /**
+   * @type {EnumData}
+   */
+  const enumData = JSON.parse(readFileSync(ENUM_CACHE_PATH, 'utf-8'))
+
+  // 3. during transform:
+  //    3.1 files w/ enum declaration: rewrite declaration as object literal
+  //    3.2 files using enum: inject into rolldown define
+  /**
+   * @type {import('rolldown').Plugin}
+   */
+  const plugin = {
+    name: 'inline-enum',
+    transform(code, id) {
+      /**
+       * @type {MagicString | undefined}
+       */
+      let s
+
+      if (id in enumData.declarations) {
+        s = s || new MagicString(code)
+        for (const declaration of enumData.declarations[id]) {
+          const {
+            range: [start, end],
+            id,
+            members,
+          } = declaration
+          s.update(
+            start,
+            end,
+            `export const ${id} = {${members
+              .flatMap(({ name, value }) => {
+                const forwardMapping =
+                  JSON.stringify(name) + ': ' + JSON.stringify(value)
+                const reverseMapping =
+                  JSON.stringify(value.toString()) + ': ' + JSON.stringify(name)
+
+                // see https://www.typescriptlang.org/docs/handbook/enums.html#reverse-mappings
+                return typeof value === 'string'
+                  ? [
+                      forwardMapping,
+                      // string enum members do not get a reverse mapping generated at all
+                    ]
+                  : [
+                      forwardMapping,
+                      // other enum members should support enum reverse mapping
+                      reverseMapping,
+                    ]
+              })
+              .join(',\n')}}`,
+          )
+        }
+      }
+
+      if (s) {
+        return {
+          code: s.toString(),
+          map: s.generateMap(),
+        }
+      }
+    },
+  }
+
+  return [plugin, enumData.defines]
+}

+ 15 - 3
scripts/inline-enums.js

@@ -22,6 +22,7 @@ import {
 import * as path from 'node:path'
 import { parseSync } from 'oxc-parser'
 import { spawnSync } from 'node:child_process'
+import MagicString from 'magic-string'
 
 /**
  * @typedef {{ readonly name: string, readonly value: string | number }} EnumMember
@@ -245,6 +246,8 @@ export function inlineEnums() {
    */
   const enumData = JSON.parse(readFileSync(ENUM_CACHE_PATH, 'utf-8'))
 
+  const useNativeMagicString = true
+
   // 3. during transform:
   //    3.1 files w/ enum declaration: rewrite declaration as object literal
   //    3.2 files using enum: inject into rolldown define
@@ -253,13 +256,15 @@ export function inlineEnums() {
    */
   const plugin = {
     name: 'inline-enum',
+    // @ts-ignore
     transform(code, id, meta) {
       /**
        * @type {import('rolldown').BindingMagicString | undefined}
        */
       let s
       if (id in enumData.declarations) {
-        s = s || meta.magicString
+        // @ts-ignore
+        s = s || useNativeMagicString ? meta.magicString : new MagicString(code)
         for (const declaration of enumData.declarations[id]) {
           const {
             range: [start, end],
@@ -295,8 +300,15 @@ export function inlineEnums() {
       }
 
       if (s) {
-        return {
-          code: s,
+        if (useNativeMagicString) {
+          return {
+            code: s,
+          }
+        } else {
+          return {
+            code: s.toString(),
+            map: s.generateMap(),
+          }
         }
       }
     },