From 6213b73cd2be40aa56583b5cdf687ca741bf41be Mon Sep 17 00:00:00 2001 From: Evan You Date: Fri, 3 Feb 2023 09:54:15 +0800 Subject: [PATCH] build: custom const enum processing --- .eslintrc.js | 10 +- package.json | 2 + packages/compiler-ssr/src/errors.ts | 13 +- packages/reactivity/src/effect.ts | 12 +- packages/reactivity/src/index.ts | 7 +- packages/runtime-core/src/apiCreateApp.ts | 4 +- packages/runtime-core/src/apiWatch.ts | 7 +- packages/runtime-core/src/componentProps.ts | 2 +- pnpm-lock.yaml | 6 +- rollup.config.mjs | 7 +- scripts/const-enum.mjs | 192 ++++++++++++++++++++ 11 files changed, 243 insertions(+), 19 deletions(-) create mode 100644 scripts/const-enum.mjs diff --git a/.eslintrc.js b/.eslintrc.js index c0282ebd817..fe5e1493e0d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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' ] }, diff --git a/package.json b/package.json index 161f0322fe1..7aef6ced254 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/compiler-ssr/src/errors.ts b/packages/compiler-ssr/src/errors.ts index 755379fb675..67622c1beb9 100644 --- a/packages/compiler-ssr/src/errors.ts +++ b/packages/compiler-ssr/src/errors.ts @@ -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.`, diff --git a/packages/reactivity/src/effect.ts b/packages/reactivity/src/effect.ts index 5eb84bc48d7..130fc863f4f 100644 --- a/packages/reactivity/src/effect.ts +++ b/packages/reactivity/src/effect.ts @@ -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! + ) + ) } } } diff --git a/packages/reactivity/src/index.ts b/packages/reactivity/src/index.ts index cbba32d3146..60707febef4 100644 --- a/packages/reactivity/src/index.ts +++ b/packages/reactivity/src/index.ts @@ -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' diff --git a/packages/runtime-core/src/apiCreateApp.ts b/packages/runtime-core/src/apiCreateApp.ts index c02597bed58..05c7ce31539 100644 --- a/packages/runtime-core/src/apiCreateApp.ts +++ b/packages/runtime-core/src/apiCreateApp.ts @@ -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( ): CreateAppFunction { return function createApp(rootComponent, rootProps = null) { if (!isFunction(rootComponent)) { - rootComponent = { ...rootComponent } + rootComponent = extend({}, rootComponent) } if (rootProps != null && !isObject(rootProps)) { diff --git a/packages/runtime-core/src/apiWatch.ts b/packages/runtime-core/src/apiWatch.ts index ac5778cd435..631299fdc57 100644 --- a/packages/runtime-core/src/apiWatch.ts +++ b/packages/runtime-core/src/apiWatch.ts @@ -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' } ) } diff --git a/packages/runtime-core/src/componentProps.ts b/packages/runtime-core/src/componentProps.ts index fa756fa32f2..db8bf73a9a1 100644 --- a/packages/runtime-core/src/componentProps.ts +++ b/packages/runtime-core/src/componentProps.ts @@ -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) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b65ba02501e..78a878323f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/rollup.config.mjs b/rollup.config.mjs index 9cf55744ed4..84b44478d21 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -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 index 00000000000..5942b795c03 --- /dev/null +++ b/scripts/const-enum.mjs @@ -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]>} + */ +export async function constEnum() { + /** + * @type {{ ranges: Record, defines: Record }} + */ + 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] +}