]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
build: custom const enum processing
authorEvan You <yyx990803@gmail.com>
Fri, 3 Feb 2023 01:54:15 +0000 (09:54 +0800)
committerEvan You <yyx990803@gmail.com>
Fri, 3 Feb 2023 01:54:15 +0000 (09:54 +0800)
.eslintrc.js
package.json
packages/compiler-ssr/src/errors.ts
packages/reactivity/src/effect.ts
packages/reactivity/src/index.ts
packages/runtime-core/src/apiCreateApp.ts
packages/runtime-core/src/apiWatch.ts
packages/runtime-core/src/componentProps.ts
pnpm-lock.yaml
rollup.config.mjs
scripts/const-enum.mjs [new file with mode: 0644]

index c0282ebd8173c162d284b36d2607531d831da8d4..fe5e1493e0d0f6b6f703573bc39756b3e1a91fff 100644 (file)
@@ -17,13 +17,15 @@ module.exports = {
     ],
     // most of the codebase are expected to be env agnostic
     'no-restricted-globals': ['error', ...DOMGlobals, ...NodeGlobals],
-    // since we target ES2015 for baseline support, we need to forbid object
-    // rest spread usage in destructure as it compiles into a verbose helper.
-    // TS now compiles assignment spread into Object.assign() calls so that
-    // is allowed.
+
     'no-restricted-syntax': [
       'error',
+      // since we target ES2015 for baseline support, we need to forbid object
+      // rest spread usage in destructure as it compiles into a verbose helper.
       'ObjectPattern > RestElement',
+      // tsc compiles assignment spread into Object.assign() calls, but esbuild
+      // still generates verbose helpers, so spread assignment is also prohiboted
+      'ObjectExpression > SpreadElement',
       'AwaitExpression'
     ]
   },
index 161f0322fe199e35459e05d61cd9804c3f5670f5..7aef6ced2544a393fbd34e280bb5f098bb4fcf35 100644 (file)
@@ -54,6 +54,7 @@
     "node": ">=16.11.0"
   },
   "devDependencies": {
+    "@babel/parser": "^7.20.15",
     "@babel/types": "^7.20.7",
     "@esbuild-plugins/node-modules-polyfill": "^0.1.4",
     "@microsoft/api-extractor": "~7.20.0",
@@ -83,6 +84,7 @@
     "jsdom": "^21.1.0",
     "lint-staged": "^10.2.10",
     "lodash": "^4.17.15",
+    "magic-string": "^0.27.0",
     "marked": "^4.0.10",
     "minimist": "^1.2.0",
     "npm-run-all": "^4.1.5",
index 755379fb6758da4dce91ca679d40e1def4fa68ea..67622c1beb9a77471e50272518e67b9a850d8df6 100644 (file)
@@ -17,11 +17,22 @@ export function createSSRCompilerError(
 }
 
 export const enum SSRErrorCodes {
-  X_SSR_UNSAFE_ATTR_NAME = DOMErrorCodes.__EXTEND_POINT__,
+  X_SSR_UNSAFE_ATTR_NAME = 62 /* DOMErrorCodes.__EXTEND_POINT__ */,
   X_SSR_NO_TELEPORT_TARGET,
   X_SSR_INVALID_AST_NODE
 }
 
+if (__TEST__) {
+  // esbuild cannot infer const enum increments if first value is from another
+  // file, so we have to manually keep them in sync. this check ensures it
+  // errors out if there are collisions.
+  if (SSRErrorCodes.X_SSR_UNSAFE_ATTR_NAME < DOMErrorCodes.__EXTEND_POINT__) {
+    throw new Error(
+      'SSRErrorCodes need to be updated to match extension point from core DOMErrorCodes.'
+    )
+  }
+}
+
 export const SSRErrorMessages: { [code: number]: string } = {
   [SSRErrorCodes.X_SSR_UNSAFE_ATTR_NAME]: `Unsafe attribute name for SSR.`,
   [SSRErrorCodes.X_SSR_NO_TELEPORT_TARGET]: `Missing the 'to' prop on teleport element.`,
index 5eb84bc48d70977f88edecf75706f2282525a80f..130fc863f4fa865c313ce44d26ff2737483ea5db 100644 (file)
@@ -248,10 +248,14 @@ export function trackEffects(
     dep.add(activeEffect!)
     activeEffect!.deps.push(dep)
     if (__DEV__ && activeEffect!.onTrack) {
-      activeEffect!.onTrack({
-        effect: activeEffect!,
-        ...debuggerEventExtraInfo!
-      })
+      activeEffect!.onTrack(
+        extend(
+          {
+            effect: activeEffect!
+          },
+          debuggerEventExtraInfo!
+        )
+      )
     }
   }
 }
index cbba32d31462d1b9670848716b07b804670ba2dd..60707febef42ad3e4d81ae8cf6a9b3007900b0f7 100644 (file)
@@ -28,7 +28,7 @@ export {
   shallowReadonly,
   markRaw,
   toRaw,
-  ReactiveFlags,
+  ReactiveFlags /* @remove */,
   type Raw,
   type DeepReadonly,
   type ShallowReactive,
@@ -66,4 +66,7 @@ export {
   getCurrentScope,
   onScopeDispose
 } from './effectScope'
-export { TrackOpTypes, TriggerOpTypes } from './operations'
+export {
+  TrackOpTypes /* @remove */,
+  TriggerOpTypes /* @remove */
+} from './operations'
index c02597bed584cb05bdddbd11a82a04701403fd15..05c7ce31539fcf9bfdc8e37fcaf5932e56b3a913 100644 (file)
@@ -22,7 +22,7 @@ import { warn } from './warning'
 import { createVNode, cloneVNode, VNode } from './vnode'
 import { RootHydrateFunction } from './hydration'
 import { devtoolsInitApp, devtoolsUnmountApp } from './devtools'
-import { isFunction, NO, isObject } from '@vue/shared'
+import { isFunction, NO, isObject, extend } from '@vue/shared'
 import { version } from '.'
 import { installAppCompatProperties } from './compat/global'
 import { NormalizedPropsOptions } from './componentProps'
@@ -193,7 +193,7 @@ export function createAppAPI<HostElement>(
 ): CreateAppFunction<HostElement> {
   return function createApp(rootComponent, rootProps = null) {
     if (!isFunction(rootComponent)) {
-      rootComponent = { ...rootComponent }
+      rootComponent = extend({}, rootComponent)
     }
 
     if (rootProps != null && !isObject(rootProps)) {
index ac5778cd435125aea32123166deff3ff098857b9..631299fdc576f23ab49ca7ac645d811c123bce2e 100644 (file)
@@ -22,7 +22,8 @@ import {
   remove,
   isMap,
   isSet,
-  isPlainObject
+  isPlainObject,
+  extend
 } from '@vue/shared'
 import {
   currentInstance,
@@ -94,7 +95,7 @@ export function watchPostEffect(
   return doWatch(
     effect,
     null,
-    __DEV__ ? { ...options, flush: 'post' } : { flush: 'post' }
+    __DEV__ ? extend({}, options as any, { flush: 'post' }) : { flush: 'post' }
   )
 }
 
@@ -105,7 +106,7 @@ export function watchSyncEffect(
   return doWatch(
     effect,
     null,
-    __DEV__ ? { ...options, flush: 'sync' } : { flush: 'sync' }
+    __DEV__ ? extend({}, options as any, { flush: 'sync' }) : { flush: 'sync' }
   )
 }
 
index fa756fa32f29b65346e9c4ef717d0190a27e0e7c..db8bf73a9a1f1d5ce0a9f476f17153d8f8dec0c9 100644 (file)
@@ -522,7 +522,7 @@ export function normalizePropsOptions(
       if (validatePropName(normalizedKey)) {
         const opt = raw[key]
         const prop: NormalizedProp = (normalized[normalizedKey] =
-          isArray(opt) || isFunction(opt) ? { type: opt } : { ...opt })
+          isArray(opt) || isFunction(opt) ? { type: opt } : extend({}, opt))
         if (prop) {
           const booleanIndex = getTypeIndex(Boolean, prop.type)
           const stringIndex = getTypeIndex(String, prop.type)
index b65ba02501e019c0b656f731c36b26c108ae48a9..78a878323f778f4c526008b7d34b56312b331e3b 100644 (file)
@@ -4,6 +4,7 @@ importers:
 
   .:
     specifiers:
+      '@babel/parser': ^7.20.15
       '@babel/types': ^7.20.7
       '@esbuild-plugins/node-modules-polyfill': ^0.1.4
       '@microsoft/api-extractor': ~7.20.0
@@ -33,6 +34,7 @@ importers:
       jsdom: ^21.1.0
       lint-staged: ^10.2.10
       lodash: ^4.17.15
+      magic-string: ^0.27.0
       marked: ^4.0.10
       minimist: ^1.2.0
       npm-run-all: ^4.1.5
@@ -50,11 +52,12 @@ importers:
       terser: ^5.15.1
       todomvc-app-css: ^2.3.0
       tslib: ^2.4.0
-      typescript: ^4.8.0
+      typescript: ^4.9.0
       vite: ^4.0.4
       vitest: ^0.28.2
       vue: workspace:*
     devDependencies:
+      '@babel/parser': 7.20.15
       '@babel/types': 7.20.7
       '@esbuild-plugins/node-modules-polyfill': 0.1.4_esbuild@0.17.5
       '@microsoft/api-extractor': 7.20.1
@@ -84,6 +87,7 @@ importers:
       jsdom: 21.1.0
       lint-staged: 10.5.4
       lodash: 4.17.21
+      magic-string: 0.27.0
       marked: 4.2.12
       minimist: 1.2.7
       npm-run-all: 4.1.5
index 9cf55744ed4e5f7f2961435808916537257a5d42..84b44478d21e9a6471b7865c043a04d9feb78c66 100644 (file)
@@ -12,6 +12,8 @@ import terser from '@rollup/plugin-terser'
 import esbuild from 'rollup-plugin-esbuild'
 import alias from '@rollup/plugin-alias'
 import { entries } from './scripts/aliases.mjs'
+import { constEnum } from './scripts/const-enum.mjs'
+import { writeFileSync } from 'node:fs'
 
 if (!process.env.TARGET) {
   throw new Error('TARGET package must be specified via --environment flag.')
@@ -31,6 +33,8 @@ const pkg = require(resolve(`package.json`))
 const packageOptions = pkg.buildOptions || {}
 const name = packageOptions.filename || path.basename(packageDir)
 
+const [enumPlugin, enumDefines] = await constEnum()
+
 const outputConfigs = {
   'esm-bundler': {
     file: resolve(`dist/${name}.esm-bundler.js`),
@@ -175,7 +179,7 @@ function createConfig(format, output, plugins = []) {
   // esbuild define is a bit strict and only allows literal json or identifiers
   // so we still need replace plugin in some cases
   function resolveReplace() {
-    const replacements = {}
+    const replacements = { ...enumDefines }
 
     if (isProductionBuild && isBrowserBuild) {
       Object.assign(replacements, {
@@ -282,6 +286,7 @@ function createConfig(format, output, plugins = []) {
       alias({
         entries
       }),
+      enumPlugin,
       ...resolveReplace(),
       esbuild({
         tsconfig: path.resolve(__dirname, 'tsconfig.json'),
diff --git a/scripts/const-enum.mjs b/scripts/const-enum.mjs
new file mode 100644 (file)
index 0000000..5942b79
--- /dev/null
@@ -0,0 +1,192 @@
+// @ts-check
+
+/**
+ * We use rollup-plugin-esbuild for faster builds, but esbuild in insolation
+ * mode compiles const enums into runtime enums, bloating bundle size.
+ *
+ * Here we pre-process all the const enums in the project and turn them into
+ * global replacements, and remove the original declarations and re-exports.
+ *
+ * This erases the const enums before the esbuild transform so that we can
+ * leverage esbuild's speed while retaining the DX and bundle size benefits
+ * of const enums.
+ *
+ * This file is expected to be executed with project root as cwd.
+ */
+
+import execa from 'execa'
+import { readFileSync } from 'node:fs'
+import { parse } from '@babel/parser'
+import path from 'node:path'
+import MagicString from 'magic-string'
+
+function evaluate(exp) {
+  return new Function(`return ${exp}`)()
+}
+
+/**
+ * @returns {Promise<[import('rollup').Plugin, Record<string, string>]>}
+ */
+export async function constEnum() {
+  /**
+   * @type {{ ranges: Record<string, [number, number][]>, defines: Record<string, string> }}
+   */
+  const enumData = {
+    ranges: {},
+    defines: {}
+  }
+
+  const knowEnums = new Set()
+
+  // 1. grep for files with exported const enum
+  const { stdout } = await execa('git', ['grep', `export const enum`])
+  const files = [...new Set(stdout.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 ast = parse(content, {
+      plugins: ['typescript'],
+      sourceType: 'module'
+    })
+
+    for (const node of ast.program.body) {
+      if (
+        node.type === 'ExportNamedDeclaration' &&
+        node.declaration &&
+        node.declaration.type === 'TSEnumDeclaration'
+      ) {
+        if (file in enumData.ranges) {
+          // @ts-ignore
+          enumData.ranges[file].push([node.start, node.end])
+        } else {
+          // @ts-ignore
+          enumData.ranges[file] = [[node.start, node.end]]
+        }
+
+        const decl = node.declaration
+        let lastInitialized
+        for (let i = 0; i < decl.members.length; i++) {
+          const e = decl.members[i]
+          const id = decl.id.name
+          knowEnums.add(id)
+          const key = e.id.type === 'Identifier' ? e.id.name : e.id.value
+          const fullKey = `${id}.${key}`
+          const init = e.initializer
+          if (init) {
+            let value
+            if (
+              init.type === 'StringLiteral' ||
+              init.type === 'NumericLiteral'
+            ) {
+              value = init.value
+            }
+
+            // e.g. 1 << 2
+            if (init.type === 'BinaryExpression') {
+              // @ts-ignore assume all operands are literals
+              const exp = `${init.left.value}${init.operator}${init.right.value}`
+              value = evaluate(exp)
+            }
+
+            if (init.type === 'UnaryExpression') {
+              // @ts-ignore assume all operands are literals
+              const exp = `${init.operator}${init.argument.value}`
+              value = evaluate(exp)
+            }
+
+            if (value === undefined) {
+              throw new Error(
+                `unhandled initializer type ${init.type} for ${fullKey} in ${file}`
+              )
+            }
+            enumData.defines[fullKey] = JSON.stringify(value)
+            lastInitialized = value
+          } else {
+            if (lastInitialized === undefined) {
+              // first initialized
+              enumData.defines[fullKey] = `0`
+              lastInitialized = 0
+            } else if (typeof lastInitialized === 'number') {
+              enumData.defines[fullKey] = String(++lastInitialized)
+            } else {
+              // should not happen
+              throw new Error(`wrong enum initialization sequence in ${file}`)
+            }
+          }
+        }
+      }
+    }
+  }
+
+  // construct a regex for matching re-exports of known const enums
+  const reExportsRE = new RegExp(
+    `export {[^}]*?\\b(${[...knowEnums].join('|')})\\b[^]*?}`
+  )
+
+  // 3. during transform:
+  //    3.1 files w/ const enum declaration: remove delcaration
+  //    3.2 files using const enum: inject into esbuild define
+  /**
+   * @type {import('rollup').Plugin}
+   */
+  const plugin = {
+    name: 'remove-const-enum',
+    transform(code, id) {
+      let s
+
+      if (id in enumData.ranges) {
+        s = s || new MagicString(code)
+        for (const [start, end] of enumData.ranges[id]) {
+          s.remove(start, end)
+        }
+      }
+
+      // check for const enum re-exports that must be removed
+      if (reExportsRE.test(code)) {
+        s = s || new MagicString(code)
+        const ast = parse(code, {
+          plugins: ['typescript'],
+          sourceType: 'module'
+        })
+        for (const node of ast.program.body) {
+          if (
+            node.type === 'ExportNamedDeclaration' &&
+            node.exportKind !== 'type' &&
+            node.source
+          ) {
+            for (let i = 0; i < node.specifiers.length; i++) {
+              const spec = node.specifiers[i]
+              if (
+                spec.type === 'ExportSpecifier' &&
+                spec.exportKind !== 'type' &&
+                knowEnums.has(spec.local.name)
+              ) {
+                if (i === 0) {
+                  // first
+                  const next = node.specifiers[i + 1]
+                  // @ts-ignore
+                  s.remove(spec.start, next ? next.start : spec.end)
+                } else {
+                  // locate the end of prev
+                  // @ts-ignore
+                  s.remove(node.specifiers[i - 1].end, spec.end)
+                }
+              }
+            }
+          }
+        }
+      }
+
+      if (s) {
+        return {
+          code: s.toString(),
+          map: s.generateMap()
+        }
+      }
+    }
+  }
+
+  return [plugin, enumData.defines]
+}