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'
}
},
// shared, may be used in any env
{
files: ['packages/shared/**'],
rules: {
'no-restricted-globals': 'off'
}
},
// Packages targeting DOM
{
files: ['packages/{vue,runtime-dom}/**'],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -179,10 +179,10 @@ const publicPropertiesMap: PublicPropertiesMap = extend(Object.create(null), {
$parent: i => i.parent && i.parent.proxy,
$root: i => i.root && i.root.proxy,
$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),
$nextTick: () => nextTick,
$watch: __FEATURE_OPTIONS__ ? i => instanceWatch.bind(i) : NOOP
$watch: i => (__FEATURE_OPTIONS_API__ ? instanceWatch.bind(i) : NOOP)
} as PublicPropertiesMap)
const enum AccessTypes {

View File

@ -9,7 +9,7 @@ export interface AppRecord {
types: Record<string, string | Symbol>
}
enum DevtoolsHooks {
const enum DevtoolsHooks {
APP_INIT = 'app:init',
APP_UNMOUNT = 'app:unmount',
COMPONENT_UPDATED = 'component:updated',
@ -31,38 +31,40 @@ export function setDevtoolsHook(hook: DevtoolsHook) {
devtools = hook
}
export function initApp(app: App, version: string) {
export function devtoolsInitApp(app: App, version: string) {
// TODO queue if devtools is undefined
if (!devtools) return
devtools.emit(DevtoolsHooks.APP_INIT, app, version, {
Fragment: Fragment,
Text: Text,
Comment: Comment,
Static: Static
Fragment,
Text,
Comment,
Static
})
}
export function appUnmounted(app: App) {
export function devtoolsUnmountApp(app: App) {
if (!devtools) return
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
)
export const componentRemoved = createDevtoolsHook(
export const devtoolsComponentRemoved = /*#__PURE__*/ createDevtoolsHook(
DevtoolsHooks.COMPONENT_REMOVED
)
function createDevtoolsHook(hook: DevtoolsHooks) {
return (component: ComponentInternalInstance) => {
if (!devtools || !component.appContext.__app) return
if (!devtools) return
devtools.emit(
hook,
component.appContext.__app,
component.appContext.app,
component.uid,
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 { startMeasure, endMeasure } from './profiling'
import { ComponentPublicInstance } from './componentProxy'
import { componentRemoved, componentUpdated } from './devtools'
import { devtoolsComponentRemoved, devtoolsComponentUpdated } from './devtools'
import { initFeatureFlags } from './featureFlags'
export interface Renderer<HostElement = RendererElement> {
render: RootRenderFunction<HostElement>
@ -383,6 +384,11 @@ function baseCreateRenderer(
options: RendererOptions,
createHydrationFns?: typeof createHydrationFunctions
): any {
// compile-time feature flags check
if (__ESM_BUNDLER__ && !__TEST__) {
initFeatureFlags()
}
const {
insert: hostInsert,
remove: hostRemove,
@ -1393,9 +1399,13 @@ function baseCreateRenderer(
invokeVNodeHook(vnodeHook!, parent, next!, vnode)
}, parentSuspense)
}
if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
devtoolsComponentUpdated(instance)
}
if (__DEV__) {
popWarningContext()
componentUpdated(instance)
}
}
}, __DEV__ ? createDevEffectOptions(instance) : prodEffectOptions)
@ -2046,7 +2056,9 @@ function baseCreateRenderer(
}
}
__DEV__ && componentRemoved(instance)
if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
devtoolsComponentRemoved(instance)
}
}
const unmountChildren: UnmountChildrenFn = (

View File

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

View File

@ -146,3 +146,20 @@ export const toNumber = (val: any): any => {
const n = parseFloat(val)
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() {
const target: any = __BROWSER__ ? window : global
const target = getGlobalThis()
target.__VUE__ = version
target.__VUE__ = true
setDevtoolsHook(target.__VUE_DEVTOOLS_GLOBAL_HOOK__)
if (__BROWSER__) {
// @ts-ignore `console.info` cannot be null error
console[console.info ? 'info' : 'log'](
console.info(
`You are running a development build of Vue.\n` +
`Make sure to use the production build (*.prod.js) when deploying for production.`
)

View File

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