feat: provide ability to overwrite feature flags in esm-bundler builds

e.g. by replacing `__VUE_OPTIONS_API__` to `false` using webpack's
`DefinePlugin`, the final bundle will drop all code supporting the
options API.

This does not break existing usage, but requires the user to explicitly
configure the feature flags via bundlers to properly tree-shake the
disabled branches. As a result, users will see a console warning if
the flags have not been properly configured.
This commit is contained in:
Evan You 2020-07-20 21:51:30 -04:00
parent dabdc5e115
commit 54727f9874
15 changed files with 123 additions and 45 deletions

View File

@ -32,6 +32,13 @@ module.exports = {
'no-restricted-syntax': 'off' 'no-restricted-syntax': 'off'
} }
}, },
// shared, may be used in any env
{
files: ['packages/shared/**'],
rules: {
'no-restricted-globals': 'off'
}
},
// Packages targeting DOM // Packages targeting DOM
{ {
files: ['packages/{vue,runtime-dom}/**'], files: ['packages/{vue,runtime-dom}/**'],

View File

@ -9,7 +9,7 @@ module.exports = {
__ESM_BUNDLER__: true, __ESM_BUNDLER__: true,
__ESM_BROWSER__: false, __ESM_BROWSER__: false,
__NODE_JS__: true, __NODE_JS__: true,
__FEATURE_OPTIONS__: true, __FEATURE_OPTIONS_API__: true,
__FEATURE_SUSPENSE__: true __FEATURE_SUSPENSE__: true
}, },
coverageDirectory: 'coverage', coverageDirectory: 'coverage',

View File

@ -10,5 +10,6 @@ declare var __COMMIT__: string
declare var __VERSION__: string declare var __VERSION__: string
// Feature flags // Feature flags
declare var __FEATURE_OPTIONS__: boolean declare var __FEATURE_OPTIONS_API__: boolean
declare var __FEATURE_PROD_DEVTOOLS__: boolean
declare var __FEATURE_SUSPENSE__: boolean declare var __FEATURE_SUSPENSE__: boolean

View File

@ -13,7 +13,7 @@ import { isFunction, NO, isObject } from '@vue/shared'
import { warn } from './warning' import { warn } from './warning'
import { createVNode, cloneVNode, VNode } from './vnode' import { createVNode, cloneVNode, VNode } from './vnode'
import { RootHydrateFunction } from './hydration' import { RootHydrateFunction } from './hydration'
import { initApp, appUnmounted } from './devtools' import { devtoolsInitApp, devtoolsUnmountApp } from './devtools'
import { version } from '.' import { version } from '.'
export interface App<HostElement = any> { export interface App<HostElement = any> {
@ -32,7 +32,7 @@ export interface App<HostElement = any> {
unmount(rootContainer: HostElement | string): void unmount(rootContainer: HostElement | string): void
provide<T>(key: InjectionKey<T> | string, value: T): this provide<T>(key: InjectionKey<T> | string, value: T): this
// internal. We need to expose these for the server-renderer and devtools // internal, but we need to expose these for the server-renderer and devtools
_component: Component _component: Component
_props: Data | null _props: Data | null
_container: HostElement | null _container: HostElement | null
@ -50,7 +50,6 @@ export interface AppConfig {
// @private // @private
readonly isNativeTag?: (tag: string) => boolean readonly isNativeTag?: (tag: string) => boolean
devtools: boolean
performance: boolean performance: boolean
optionMergeStrategies: Record<string, OptionMergeFunction> optionMergeStrategies: Record<string, OptionMergeFunction>
globalProperties: Record<string, any> globalProperties: Record<string, any>
@ -68,15 +67,13 @@ export interface AppConfig {
} }
export interface AppContext { export interface AppContext {
app: App // for devtools
config: AppConfig config: AppConfig
mixins: ComponentOptions[] mixins: ComponentOptions[]
components: Record<string, PublicAPIComponent> components: Record<string, PublicAPIComponent>
directives: Record<string, Directive> directives: Record<string, Directive>
provides: Record<string | symbol, any> provides: Record<string | symbol, any>
reload?: () => void // HMR only reload?: () => void // HMR only
// internal for devtools
__app?: App
} }
type PluginInstallFunction = (app: App, ...options: any[]) => any type PluginInstallFunction = (app: App, ...options: any[]) => any
@ -89,9 +86,9 @@ export type Plugin =
export function createAppContext(): AppContext { export function createAppContext(): AppContext {
return { return {
app: null as any,
config: { config: {
isNativeTag: NO, isNativeTag: NO,
devtools: true,
performance: false, performance: false,
globalProperties: {}, globalProperties: {},
optionMergeStrategies: {}, optionMergeStrategies: {},
@ -126,7 +123,7 @@ export function createAppAPI<HostElement>(
let isMounted = false let isMounted = false
const app: App = { const app: App = (context.app = {
_component: rootComponent as Component, _component: rootComponent as Component,
_props: rootProps, _props: rootProps,
_container: null, _container: null,
@ -165,7 +162,7 @@ export function createAppAPI<HostElement>(
}, },
mixin(mixin: ComponentOptions) { mixin(mixin: ComponentOptions) {
if (__FEATURE_OPTIONS__) { if (__FEATURE_OPTIONS_API__) {
if (!context.mixins.includes(mixin)) { if (!context.mixins.includes(mixin)) {
context.mixins.push(mixin) context.mixins.push(mixin)
} else if (__DEV__) { } else if (__DEV__) {
@ -230,8 +227,12 @@ export function createAppAPI<HostElement>(
} }
isMounted = true isMounted = true
app._container = rootContainer app._container = rootContainer
// for devtools and telemetry
;(rootContainer as any).__vue_app__ = app
__DEV__ && initApp(app, version) if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
devtoolsInitApp(app, version)
}
return vnode.component!.proxy return vnode.component!.proxy
} else if (__DEV__) { } else if (__DEV__) {
@ -247,8 +248,7 @@ export function createAppAPI<HostElement>(
unmount() { unmount() {
if (isMounted) { if (isMounted) {
render(null, app._container) render(null, app._container)
devtoolsUnmountApp(app)
__DEV__ && appUnmounted(app)
} else if (__DEV__) { } else if (__DEV__) {
warn(`Cannot unmount an app that is not mounted.`) warn(`Cannot unmount an app that is not mounted.`)
} }
@ -267,9 +267,7 @@ export function createAppAPI<HostElement>(
return app return app
} }
} })
context.__app = app
return app return app
} }

View File

@ -49,7 +49,7 @@ import {
markAttrsAccessed markAttrsAccessed
} from './componentRenderUtils' } from './componentRenderUtils'
import { startMeasure, endMeasure } from './profiling' import { startMeasure, endMeasure } from './profiling'
import { componentAdded } from './devtools' import { devtoolsComponentAdded } from './devtools'
export type Data = Record<string, unknown> export type Data = Record<string, unknown>
@ -423,7 +423,9 @@ export function createComponentInstance(
instance.root = parent ? parent.root : instance instance.root = parent ? parent.root : instance
instance.emit = emit.bind(null, instance) instance.emit = emit.bind(null, instance)
__DEV__ && componentAdded(instance) if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
devtoolsComponentAdded(instance)
}
return instance return instance
} }
@ -647,7 +649,7 @@ function finishComponentSetup(
} }
// support for 2.x options // support for 2.x options
if (__FEATURE_OPTIONS__) { if (__FEATURE_OPTIONS_API__) {
currentInstance = instance currentInstance = instance
applyOptions(instance, Component) applyOptions(instance, Component)
currentInstance = null currentInstance = null

View File

@ -105,7 +105,7 @@ function normalizeEmitsOptions(
// apply mixin/extends props // apply mixin/extends props
let hasExtends = false let hasExtends = false
if (__FEATURE_OPTIONS__ && !isFunction(comp)) { if (__FEATURE_OPTIONS_API__ && !isFunction(comp)) {
if (comp.extends) { if (comp.extends) {
hasExtends = true hasExtends = true
extend(normalized, normalizeEmitsOptions(comp.extends)) extend(normalized, normalizeEmitsOptions(comp.extends))

View File

@ -322,7 +322,7 @@ export function normalizePropsOptions(
// apply mixin/extends props // apply mixin/extends props
let hasExtends = false let hasExtends = false
if (__FEATURE_OPTIONS__ && !isFunction(comp)) { if (__FEATURE_OPTIONS_API__ && !isFunction(comp)) {
const extendProps = (raw: ComponentOptions) => { const extendProps = (raw: ComponentOptions) => {
const [props, keys] = normalizePropsOptions(raw) const [props, keys] = normalizePropsOptions(raw)
extend(normalized, props) extend(normalized, props)

View File

@ -179,10 +179,10 @@ const publicPropertiesMap: PublicPropertiesMap = extend(Object.create(null), {
$parent: i => i.parent && i.parent.proxy, $parent: i => i.parent && i.parent.proxy,
$root: i => i.root && i.root.proxy, $root: i => i.root && i.root.proxy,
$emit: i => i.emit, $emit: i => i.emit,
$options: i => (__FEATURE_OPTIONS__ ? resolveMergedOptions(i) : i.type), $options: i => (__FEATURE_OPTIONS_API__ ? resolveMergedOptions(i) : i.type),
$forceUpdate: i => () => queueJob(i.update), $forceUpdate: i => () => queueJob(i.update),
$nextTick: () => nextTick, $nextTick: () => nextTick,
$watch: __FEATURE_OPTIONS__ ? i => instanceWatch.bind(i) : NOOP $watch: i => (__FEATURE_OPTIONS_API__ ? instanceWatch.bind(i) : NOOP)
} as PublicPropertiesMap) } as PublicPropertiesMap)
const enum AccessTypes { const enum AccessTypes {

View File

@ -9,7 +9,7 @@ export interface AppRecord {
types: Record<string, string | Symbol> types: Record<string, string | Symbol>
} }
enum DevtoolsHooks { const enum DevtoolsHooks {
APP_INIT = 'app:init', APP_INIT = 'app:init',
APP_UNMOUNT = 'app:unmount', APP_UNMOUNT = 'app:unmount',
COMPONENT_UPDATED = 'component:updated', COMPONENT_UPDATED = 'component:updated',
@ -31,38 +31,40 @@ export function setDevtoolsHook(hook: DevtoolsHook) {
devtools = hook devtools = hook
} }
export function initApp(app: App, version: string) { export function devtoolsInitApp(app: App, version: string) {
// TODO queue if devtools is undefined // TODO queue if devtools is undefined
if (!devtools) return if (!devtools) return
devtools.emit(DevtoolsHooks.APP_INIT, app, version, { devtools.emit(DevtoolsHooks.APP_INIT, app, version, {
Fragment: Fragment, Fragment,
Text: Text, Text,
Comment: Comment, Comment,
Static: Static Static
}) })
} }
export function appUnmounted(app: App) { export function devtoolsUnmountApp(app: App) {
if (!devtools) return if (!devtools) return
devtools.emit(DevtoolsHooks.APP_UNMOUNT, app) devtools.emit(DevtoolsHooks.APP_UNMOUNT, app)
} }
export const componentAdded = createDevtoolsHook(DevtoolsHooks.COMPONENT_ADDED) export const devtoolsComponentAdded = /*#__PURE__*/ createDevtoolsHook(
DevtoolsHooks.COMPONENT_ADDED
)
export const componentUpdated = createDevtoolsHook( export const devtoolsComponentUpdated = /*#__PURE__*/ createDevtoolsHook(
DevtoolsHooks.COMPONENT_UPDATED DevtoolsHooks.COMPONENT_UPDATED
) )
export const componentRemoved = createDevtoolsHook( export const devtoolsComponentRemoved = /*#__PURE__*/ createDevtoolsHook(
DevtoolsHooks.COMPONENT_REMOVED DevtoolsHooks.COMPONENT_REMOVED
) )
function createDevtoolsHook(hook: DevtoolsHooks) { function createDevtoolsHook(hook: DevtoolsHooks) {
return (component: ComponentInternalInstance) => { return (component: ComponentInternalInstance) => {
if (!devtools || !component.appContext.__app) return if (!devtools) return
devtools.emit( devtools.emit(
hook, hook,
component.appContext.__app, component.appContext.app,
component.uid, component.uid,
component.parent ? component.parent.uid : undefined component.parent ? component.parent.uid : undefined
) )

View File

@ -0,0 +1,33 @@
import { getGlobalThis } from '@vue/shared'
/**
* This is only called in esm-bundler builds.
* It is called when a renderer is created, in `baseCreateRenderer` so that
* importing runtime-core is side-effects free.
*
* istanbul-ignore-next
*/
export function initFeatureFlags() {
let needWarn = false
if (typeof __FEATURE_OPTIONS_API__ !== 'boolean') {
needWarn = true
getGlobalThis().__VUE_OPTIONS_API__ = true
}
if (typeof __FEATURE_PROD_DEVTOOLS__ !== 'boolean') {
needWarn = true
getGlobalThis().__VUE_PROD_DEVTOOLS__ = false
}
if (__DEV__ && needWarn) {
console.warn(
`You are running the esm-bundler build of Vue. It is recommended to ` +
`configure your bundler to explicitly replace the following global ` +
`variables with boolean literals so that it can remove unnecessary code:\n\n` +
`- __VUE_OPTIONS_API__ (support for Options API, default: true)\n` +
`- __VUE_PROD_DEVTOOLS__ (enable devtools inspection in production, default: false)`
// TODO link to docs
)
}
}

View File

@ -64,7 +64,8 @@ import { createHydrationFunctions, RootHydrateFunction } from './hydration'
import { invokeDirectiveHook } from './directives' import { invokeDirectiveHook } from './directives'
import { startMeasure, endMeasure } from './profiling' import { startMeasure, endMeasure } from './profiling'
import { ComponentPublicInstance } from './componentProxy' import { ComponentPublicInstance } from './componentProxy'
import { componentRemoved, componentUpdated } from './devtools' import { devtoolsComponentRemoved, devtoolsComponentUpdated } from './devtools'
import { initFeatureFlags } from './featureFlags'
export interface Renderer<HostElement = RendererElement> { export interface Renderer<HostElement = RendererElement> {
render: RootRenderFunction<HostElement> render: RootRenderFunction<HostElement>
@ -383,6 +384,11 @@ function baseCreateRenderer(
options: RendererOptions, options: RendererOptions,
createHydrationFns?: typeof createHydrationFunctions createHydrationFns?: typeof createHydrationFunctions
): any { ): any {
// compile-time feature flags check
if (__ESM_BUNDLER__ && !__TEST__) {
initFeatureFlags()
}
const { const {
insert: hostInsert, insert: hostInsert,
remove: hostRemove, remove: hostRemove,
@ -1393,9 +1399,13 @@ function baseCreateRenderer(
invokeVNodeHook(vnodeHook!, parent, next!, vnode) invokeVNodeHook(vnodeHook!, parent, next!, vnode)
}, parentSuspense) }, parentSuspense)
} }
if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
devtoolsComponentUpdated(instance)
}
if (__DEV__) { if (__DEV__) {
popWarningContext() popWarningContext()
componentUpdated(instance)
} }
} }
}, __DEV__ ? createDevEffectOptions(instance) : prodEffectOptions) }, __DEV__ ? createDevEffectOptions(instance) : prodEffectOptions)
@ -2046,7 +2056,9 @@ function baseCreateRenderer(
} }
} }
__DEV__ && componentRemoved(instance) if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
devtoolsComponentRemoved(instance)
}
} }
const unmountChildren: UnmountChildrenFn = ( const unmountChildren: UnmountChildrenFn = (

View File

@ -69,6 +69,7 @@ export const createApp = ((...args) => {
container.innerHTML = '' container.innerHTML = ''
const proxy = mount(container) const proxy = mount(container)
container.removeAttribute('v-cloak') container.removeAttribute('v-cloak')
container.setAttribute('data-vue-app', '')
return proxy return proxy
} }

View File

@ -146,3 +146,20 @@ export const toNumber = (val: any): any => {
const n = parseFloat(val) const n = parseFloat(val)
return isNaN(n) ? val : n return isNaN(n) ? val : n
} }
let _globalThis: any
export const getGlobalThis = (): any => {
return (
_globalThis ||
(_globalThis =
typeof globalThis !== 'undefined'
? globalThis
: typeof self !== 'undefined'
? self
: typeof window !== 'undefined'
? window
: typeof global !== 'undefined'
? global
: {})
)
}

View File

@ -1,14 +1,14 @@
import { version, setDevtoolsHook } from '@vue/runtime-dom' import { setDevtoolsHook } from '@vue/runtime-dom'
import { getGlobalThis } from '@vue/shared'
export function initDev() { export function initDev() {
const target: any = __BROWSER__ ? window : global const target = getGlobalThis()
target.__VUE__ = version target.__VUE__ = true
setDevtoolsHook(target.__VUE_DEVTOOLS_GLOBAL_HOOK__) setDevtoolsHook(target.__VUE_DEVTOOLS_GLOBAL_HOOK__)
if (__BROWSER__) { if (__BROWSER__) {
// @ts-ignore `console.info` cannot be null error console.info(
console[console.info ? 'info' : 'log'](
`You are running a development build of Vue.\n` + `You are running a development build of Vue.\n` +
`Make sure to use the production build (*.prod.js) when deploying for production.` `Make sure to use the production build (*.prod.js) when deploying for production.`
) )

View File

@ -212,8 +212,13 @@ function createReplacePlugin(
__ESM_BROWSER__: isBrowserESMBuild, __ESM_BROWSER__: isBrowserESMBuild,
// is targeting Node (SSR)? // is targeting Node (SSR)?
__NODE_JS__: isNodeBuild, __NODE_JS__: isNodeBuild,
__FEATURE_OPTIONS__: true,
// feature flags
__FEATURE_SUSPENSE__: true, __FEATURE_SUSPENSE__: true,
__FEATURE_OPTIONS_API__: isBundlerESMBuild ? `__VUE_OPTIONS_API__` : true,
__FEATURE_PROD_DEVTOOLS__: isBundlerESMBuild
? `__VUE_PROD_DEVTOOLS__`
: false,
...(isProduction && isBrowserBuild ...(isProduction && isBrowserBuild
? { ? {
'context.onError(': `/*#__PURE__*/ context.onError(`, 'context.onError(': `/*#__PURE__*/ context.onError(`,