diff --git a/packages/runtime-core/__tests__/apiOptions.spec.ts b/packages/runtime-core/__tests__/apiOptions.spec.ts index 9da4e7b03d0..dc6e6ea4154 100644 --- a/packages/runtime-core/__tests__/apiOptions.spec.ts +++ b/packages/runtime-core/__tests__/apiOptions.spec.ts @@ -1394,4 +1394,204 @@ describe('api: options', () => { ).toHaveBeenWarned() }) }) + + describe('options merge strategies', () => { + test('this.$options.data', () => { + const mixin = { + data() { + return { foo: 1, bar: 2 } + } + } + createApp({ + mixins: [mixin], + data() { + return { + foo: 3, + baz: 4 + } + }, + created() { + expect(this.$options.data).toBeInstanceOf(Function) + expect(this.$options.data()).toEqual({ + foo: 3, + bar: 2, + baz: 4 + }) + }, + render: () => null + }).mount(nodeOps.createElement('div')) + }) + + test('this.$options.watch', () => { + const mixin = { + watch: { + a() {}, + b() {} + } + } + createApp({ + mixins: [mixin], + data() { + return { + a: 1, + b: 2 + } + }, + watch: { + a() {} + }, + created() { + expect(this.$options.watch.a).toBeInstanceOf(Array) + expect(this.$options.watch.a.length).toBe(2) + expect(this.$options.watch.b).toBeInstanceOf(Function) + }, + render: () => null + }).mount(nodeOps.createElement('div')) + }) + + test('this.$options.inject', () => { + const mixin = { + inject: ['a'] + } + createApp({ + mixins: [mixin], + inject: ['b'], + created() { + expect(this.$options.inject.a).toEqual({ from: 'a' }) + expect(this.$options.inject.b).toEqual({ from: 'b' }) + }, + render: () => null + }).mount(nodeOps.createElement('div')) + }) + + test('this.$options.provide', () => { + const mixin = { + provide: { + a: 1 + } + } + createApp({ + mixins: [mixin], + provide() { + return { + b: 2 + } + }, + created() { + expect(this.$options.provide).toBeInstanceOf(Function) + expect(this.$options.provide()).toEqual({ a: 1, b: 2 }) + }, + render: () => null + }).mount(nodeOps.createElement('div')) + }) + + test('this.$options[lifecycle-name]', () => { + const mixin = { + mounted() {} + } + createApp({ + mixins: [mixin], + mounted() {}, + created() { + expect(this.$options.mounted).toBeInstanceOf(Array) + expect(this.$options.mounted.length).toBe(2) + }, + render: () => null + }).mount(nodeOps.createElement('div')) + }) + + test('this.$options[asset-name]', () => { + const mixin = { + components: { + a: {} + }, + directives: { + d1: {} + } + } + createApp({ + mixins: [mixin], + components: { + b: {} + }, + directives: { + d2: {} + }, + created() { + expect('a' in this.$options.components).toBe(true) + expect('b' in this.$options.components).toBe(true) + expect('d1' in this.$options.directives).toBe(true) + expect('d2' in this.$options.directives).toBe(true) + }, + render: () => null + }).mount(nodeOps.createElement('div')) + }) + + test('this.$options.methods', () => { + const mixin = { + methods: { + fn1() {} + } + } + createApp({ + mixins: [mixin], + methods: { + fn2() {} + }, + created() { + expect(this.$options.methods.fn1).toBeInstanceOf(Function) + expect(this.$options.methods.fn2).toBeInstanceOf(Function) + }, + render: () => null + }).mount(nodeOps.createElement('div')) + }) + + test('this.$options.computed', () => { + const mixin = { + computed: { + c1() {} + } + } + createApp({ + mixins: [mixin], + computed: { + c2() {} + }, + created() { + expect(this.$options.computed.c1).toBeInstanceOf(Function) + expect(this.$options.computed.c2).toBeInstanceOf(Function) + }, + render: () => null + }).mount(nodeOps.createElement('div')) + }) + + // #2791 + test('modify $options in the beforeCreate hook', async () => { + const count = ref(0) + const mixin = { + data() { + return { foo: 1 } + }, + beforeCreate(this: any) { + if (!this.$options.computed) { + this.$options.computed = {} + } + this.$options.computed.value = () => count.value + } + } + const root = nodeOps.createElement('div') + createApp({ + mixins: [mixin], + render(this: any) { + return this.value + } + }).mount(root) + + expect(serializeInner(root)).toBe('0') + + count.value++ + await nextTick() + expect(serializeInner(root)).toBe('1') + }) + }) }) diff --git a/packages/runtime-core/src/apiCreateApp.ts b/packages/runtime-core/src/apiCreateApp.ts index 92cce58435b..6ad05d42072 100644 --- a/packages/runtime-core/src/apiCreateApp.ts +++ b/packages/runtime-core/src/apiCreateApp.ts @@ -45,7 +45,8 @@ export type OptionMergeFunction = ( to: unknown, from: unknown, instance: any, - key: string + key: string, + asMixin: boolean ) => any export interface AppConfig { diff --git a/packages/runtime-core/src/componentOptions.ts b/packages/runtime-core/src/componentOptions.ts index 7b4081afecc..398c28b399f 100644 --- a/packages/runtime-core/src/componentOptions.ts +++ b/packages/runtime-core/src/componentOptions.ts @@ -109,9 +109,10 @@ export interface ComponentOptionsBase< Extends extends ComponentOptionsMixin, E extends EmitsOptions, EE extends string = string, - Defaults = {} + Defaults = {}, + isMergedOptions = false > - extends LegacyOptions, + extends LegacyOptions, ComponentInternalOptions, ComponentCustomOptions { setup?: ( @@ -356,20 +357,23 @@ type ComponentWatchOptionItem = WatchOptionItem | WatchOptionItem[] type ComponentWatchOptions = Record -type ComponentInjectOptions = - | string[] - | Record< - string | symbol, - string | symbol | { from?: string | symbol; default?: unknown } - > - +type ObjectInjectOptions = Record< + string | symbol, + string | symbol | { from?: string | symbol; default?: unknown } +> +type ComponentInjectOptions = string[] | ObjectInjectOptions +type GetLifecycleHookType< + allowArray = false, + hook = (() => void) +> = allowArray extends true ? hook | hook[] : hook interface LegacyOptions< Props, D, C extends ComputedOptions, M extends MethodOptions, Mixin extends ComponentOptionsMixin, - Extends extends ComponentOptionsMixin + Extends extends ComponentOptionsMixin, + isMergedOptions = false > { // allow any custom options [key: string]: any @@ -401,31 +405,33 @@ interface LegacyOptions< computed?: C methods?: M watch?: ComponentWatchOptions - provide?: Data | Function - inject?: ComponentInjectOptions + provide?: isMergedOptions extends true ? Function : Data | Function + inject?: isMergedOptions extends true + ? ObjectInjectOptions + : ComponentInjectOptions // composition mixins?: Mixin[] extends?: Extends // lifecycle - beforeCreate?(): void - created?(): void - beforeMount?(): void - mounted?(): void - beforeUpdate?(): void - updated?(): void - activated?(): void - deactivated?(): void + beforeCreate?: GetLifecycleHookType + created?: GetLifecycleHookType + beforeMount?: GetLifecycleHookType + mounted?: GetLifecycleHookType + beforeUpdate?: GetLifecycleHookType + updated?: GetLifecycleHookType + activated?: GetLifecycleHookType + deactivated?: GetLifecycleHookType /** @deprecated use `beforeUnmount` instead */ - beforeDestroy?(): void - beforeUnmount?(): void + beforeDestroy?: GetLifecycleHookType + beforeUnmount?: GetLifecycleHookType /** @deprecated use `unmounted` instead */ - destroyed?(): void - unmounted?(): void - renderTracked?: DebuggerHook - renderTriggered?: DebuggerHook - errorCaptured?: ErrorCapturedHook + destroyed?: GetLifecycleHookType + unmounted?: GetLifecycleHookType + renderTracked?: GetLifecycleHookType + renderTriggered?: GetLifecycleHookType + errorCaptured?: GetLifecycleHookType // runtime compile only delimiters?: [string, string] @@ -484,16 +490,22 @@ export let shouldCacheAccess = true export function applyOptions( instance: ComponentInternalInstance, - options: ComponentOptions, - deferredData: DataFn[] = [], - deferredWatch: ComponentWatchOptions[] = [], - deferredProvide: (Data | Function)[] = [], - asMixin: boolean = false + options: ComponentOptions ) { + const publicThis = instance.proxy! + const ctx = instance.ctx + + // we need to call the beforeCreate hook first, + // because users may potentially modify the $options in the beforeCreate hook + const { beforeCreate } = publicThis.$options + if (beforeCreate) { + shouldCacheAccess = false + callSyncHook(beforeCreate, instance, LifecycleHooks.BEFORE_CREATE) + shouldCacheAccess = true + } + + // get the options after the beforeCreate hooks is called const { - // composition - mixins, - extends: extendsOptions, // state data: dataOptions, computed: computedOptions, @@ -505,6 +517,7 @@ export function applyOptions( components, directives, // lifecycle + created, beforeMount, mounted, beforeUpdate, @@ -521,53 +534,12 @@ export function applyOptions( errorCaptured, // public API expose - } = options + } = publicThis.$options - const publicThis = instance.proxy! - const ctx = instance.ctx - const globalMixins = instance.appContext.mixins - - if (asMixin && render && instance.render === NOOP) { + if (render && instance.render === NOOP) { instance.render = render as InternalRenderFunction } - // applyOptions is called non-as-mixin once per instance - if (!asMixin) { - shouldCacheAccess = false - callSyncHook( - 'beforeCreate', - LifecycleHooks.BEFORE_CREATE, - options, - instance, - globalMixins - ) - shouldCacheAccess = true - // global mixins are applied first - applyMixins( - instance, - globalMixins, - deferredData, - deferredWatch, - deferredProvide - ) - } - - // extending a base component... - if (extendsOptions) { - applyOptions( - instance, - extendsOptions, - deferredData, - deferredWatch, - deferredProvide, - true - ) - } - // local mixins - if (mixins) { - applyMixins(instance, mixins, deferredData, deferredWatch, deferredProvide) - } - const checkDuplicateProperties = __DEV__ ? createDuplicateChecker() : null if (__DEV__) { @@ -583,9 +555,9 @@ export function applyOptions( // - props (already done outside of this function) // - inject // - methods - // - data (deferred since it relies on `this` access) + // - data // - computed - // - watch (deferred since it relies on `this` access) + // - watch if (injectOptions) { if (isArray(injectOptions)) { @@ -643,31 +615,24 @@ export function applyOptions( } } - if (!asMixin) { - if (deferredData.length) { - deferredData.forEach(dataFn => resolveData(instance, dataFn, publicThis)) - } - if (dataOptions) { - // @ts-ignore dataOptions is not fully type safe - resolveData(instance, dataOptions, publicThis) - } - if (__DEV__) { - const rawData = toRaw(instance.data) - for (const key in rawData) { - checkDuplicateProperties!(OptionTypes.DATA, key) - // expose data on ctx during dev - if (key[0] !== '$' && key[0] !== '_') { - Object.defineProperty(ctx, key, { - configurable: true, - enumerable: true, - get: () => rawData[key], - set: NOOP - }) - } + if (dataOptions) { + // @ts-ignore dataOptions is not fully type safe + resolveData(instance, dataOptions, publicThis) + } + if (__DEV__) { + const rawData = toRaw(instance.data) + for (const key in rawData) { + checkDuplicateProperties!(OptionTypes.DATA, key) + // expose data on ctx during dev + if (key[0] !== '$' && key[0] !== '_') { + Object.defineProperty(ctx, key, { + configurable: true, + enumerable: true, + get: () => rawData[key], + set: NOOP + }) } } - } else if (dataOptions) { - deferredData.push(dataOptions as DataFn) } if (computedOptions) { @@ -707,174 +672,82 @@ export function applyOptions( } } - if (watchOptions) { - deferredWatch.push(watchOptions) - } - if (!asMixin && deferredWatch.length) { - deferredWatch.forEach(watchOptions => { - for (const key in watchOptions) { - createWatcher(watchOptions[key], ctx, publicThis, key) - } - }) + for (const key in watchOptions) { + createWatcher(watchOptions[key], ctx, publicThis, key) } if (provideOptions) { - deferredProvide.push(provideOptions) - } - if (!asMixin && deferredProvide.length) { - deferredProvide.forEach(provideOptions => { - const provides = isFunction(provideOptions) - ? provideOptions.call(publicThis) - : provideOptions - Reflect.ownKeys(provides).forEach(key => { - provide(key, provides[key]) - }) + const provides = isFunction(provideOptions) + ? provideOptions.call(publicThis) + : provideOptions + Reflect.ownKeys(provides).forEach(key => { + provide(key, provides[key]) }) } - // asset options. // To reduce memory usage, only components with mixins or extends will have // resolved asset registry attached to instance. - if (asMixin) { - if (components) { - extend( - instance.components || - (instance.components = extend( - {}, - (instance.type as ComponentOptions).components - ) as Record), - components - ) - } - if (directives) { - extend( - instance.directives || - (instance.directives = extend( - {}, - (instance.type as ComponentOptions).directives - )), - directives - ) - } + if (components && (components as any).asMixin) { + instance.components = components as Record | null + } + if (directives && (directives as any).asMixin) { + instance.directives = directives as Record | null } // lifecycle options - if (!asMixin) { - callSyncHook( - 'created', - LifecycleHooks.CREATED, - options, - instance, - globalMixins - ) + if (created) { + callSyncHook(created, instance, LifecycleHooks.CREATED) } if (beforeMount) { - onBeforeMount(beforeMount.bind(publicThis)) + registerHooks(beforeMount, onBeforeMount, instance.proxy!) } if (mounted) { - onMounted(mounted.bind(publicThis)) + registerHooks(mounted, onMounted, instance.proxy!) } if (beforeUpdate) { - onBeforeUpdate(beforeUpdate.bind(publicThis)) + registerHooks(beforeUpdate, onBeforeUpdate, instance.proxy!) } if (updated) { - onUpdated(updated.bind(publicThis)) + registerHooks(updated, onUpdated, instance.proxy!) } if (activated) { - onActivated(activated.bind(publicThis)) + registerHooks(activated, onActivated, instance.proxy!) } if (deactivated) { - onDeactivated(deactivated.bind(publicThis)) + registerHooks(deactivated, onDeactivated, instance.proxy!) } if (errorCaptured) { - onErrorCaptured(errorCaptured.bind(publicThis)) + registerHooks(errorCaptured, onErrorCaptured, instance.proxy!) } if (renderTracked) { - onRenderTracked(renderTracked.bind(publicThis)) + registerHooks(renderTracked, onRenderTracked, instance.proxy!) } if (renderTriggered) { - onRenderTriggered(renderTriggered.bind(publicThis)) + registerHooks(renderTriggered, onRenderTriggered, instance.proxy!) } if (__DEV__ && beforeDestroy) { warn(`\`beforeDestroy\` has been renamed to \`beforeUnmount\`.`) } if (beforeUnmount) { - onBeforeUnmount(beforeUnmount.bind(publicThis)) + registerHooks(beforeUnmount, onBeforeUnmount, instance.proxy!) } if (__DEV__ && destroyed) { warn(`\`destroyed\` has been renamed to \`unmounted\`.`) } if (unmounted) { - onUnmounted(unmounted.bind(publicThis)) + registerHooks(unmounted, onUnmounted, instance.proxy!) } if (isArray(expose)) { - if (!asMixin) { - if (expose.length) { - const exposed = instance.exposed || (instance.exposed = proxyRefs({})) - expose.forEach(key => { - exposed[key] = toRef(publicThis, key as any) - }) - } else if (!instance.exposed) { - instance.exposed = EMPTY_OBJ - } - } else if (__DEV__) { - warn(`The \`expose\` option is ignored when used in mixins.`) - } - } -} - -function callSyncHook( - name: 'beforeCreate' | 'created', - type: LifecycleHooks, - options: ComponentOptions, - instance: ComponentInternalInstance, - globalMixins: ComponentOptions[] -) { - for (let i = 0; i < globalMixins.length; i++) { - callHookWithMixinAndExtends(name, type, globalMixins[i], instance) - } - callHookWithMixinAndExtends(name, type, options, instance) -} - -function callHookWithMixinAndExtends( - name: 'beforeCreate' | 'created', - type: LifecycleHooks, - options: ComponentOptions, - instance: ComponentInternalInstance -) { - const { extends: base, mixins } = options - const selfHook = options[name] - if (base) { - callHookWithMixinAndExtends(name, type, base, instance) - } - if (mixins) { - for (let i = 0; i < mixins.length; i++) { - callHookWithMixinAndExtends(name, type, mixins[i], instance) + if (expose.length) { + const exposed = instance.exposed || (instance.exposed = proxyRefs({})) + expose.forEach(key => { + exposed[key] = toRef(publicThis, key as any) + }) + } else if (!instance.exposed) { + instance.exposed = EMPTY_OBJ } } - if (selfHook) { - callWithAsyncErrorHandling(selfHook.bind(instance.proxy!), instance, type) - } -} - -function applyMixins( - instance: ComponentInternalInstance, - mixins: ComponentOptions[], - deferredData: DataFn[], - deferredWatch: ComponentWatchOptions[], - deferredProvide: (Data | Function)[] -) { - for (let i = 0; i < mixins.length; i++) { - applyOptions( - instance, - mixins[i], - deferredData, - deferredWatch, - deferredProvide, - true - ) - } } function resolveData( @@ -954,11 +827,16 @@ export function resolveMergedOptions( if (!globalMixins.length && !mixins && !extendsOptions) return raw const options = {} globalMixins.forEach(m => mergeOptions(options, m, instance)) - mergeOptions(options, raw, instance) + mergeOptions(options, raw, instance, false) return (raw.__merged = options) } -function mergeOptions(to: any, from: any, instance: ComponentInternalInstance) { +function mergeOptions( + to: any, + from: any, + instance: ComponentInternalInstance, + asMixin = true +) { const strats = instance.appContext.config.optionMergeStrategies const { mixins, extends: extendsOptions } = from @@ -966,11 +844,184 @@ function mergeOptions(to: any, from: any, instance: ComponentInternalInstance) { mixins && mixins.forEach((m: ComponentOptionsMixin) => mergeOptions(to, m, instance)) + if (__DEV__ && asMixin && 'expose' in from) { + delete from.expose + warn(`The \`expose\` option is ignored when used in mixins.`) + } + for (const key in from) { if (strats && hasOwn(strats, key)) { - to[key] = strats[key](to[key], from[key], instance.proxy, key) + to[key] = strats[key](to[key], from[key], instance.proxy, key, asMixin) + } else if (key in defaultMergeStrategies) { + to[key] = defaultMergeStrategies[key]( + to[key], + from[key], + instance.proxy!, + key, + asMixin + ) } else { to[key] = from[key] } } } + +const defaultMergeStrategies: Record = { + data( + toValue: DataFn | undefined, + fromValue: DataFn | undefined, + publicThis: ComponentPublicInstance + ) { + if (!fromValue) return toValue + if (!toValue) return fromValue + + return function mergeDataFn() { + return extend( + Object.create(null), + toValue.call(publicThis, publicThis), + fromValue.call(publicThis, publicThis) + ) + } + }, + watch( + toValue: ComponentWatchOptions | undefined, + fromValue: ComponentWatchOptions | undefined + ) { + if (!fromValue) return toValue + if (!toValue) return fromValue + + const ret: ComponentWatchOptions = Object.create(null) + extend(ret, toValue) + for (const key in fromValue) { + let existing = ret[key] + const from = fromValue[key] + if (existing && !isArray(existing)) { + existing = [existing] + } + ret[key] = existing ? (existing as WatchOptionItem[]).concat(from) : from + } + + return ret + }, + // the `inject` option is merged into object format + inject( + toValue: ComponentInjectOptions | undefined, + fromValue: ComponentInjectOptions | undefined + ): ObjectInjectOptions | undefined { + toValue = normalizeInject(toValue) + fromValue = normalizeInject(fromValue) + if (!fromValue) return toValue + if (!toValue) return fromValue + return extend(Object.create(null), toValue, fromValue) + }, + // the `provide` option is merged into a factory function + provide( + toValue: Data | Function | undefined, + fromValue: Data | Function | undefined, + publicThis: ComponentPublicInstance + ) { + return function mergeProvideFn() { + return extend( + Object.create(null), + isFunction(toValue) ? toValue.call(publicThis) : toValue, + isFunction(fromValue) ? fromValue.call(publicThis) : fromValue + ) + } + }, + render( + toValue: InternalRenderFunction | undefined, + fromValue: InternalRenderFunction | undefined + ) { + return fromValue || toValue + } +} + +// lifecycle hooks are merged into function or an array of functions +;([ + 'beforeCreate', + 'created', + 'beforeMount', + 'mounted', + 'beforeUpdate', + 'updated', + 'activated', + 'deactivated', + 'beforeDestroy', + 'beforeUnmount', + 'destroyed', + 'unmounted', + 'renderTracked', + 'renderTriggered', + 'errorCaptured' +] as const).forEach(hookName => { + defaultMergeStrategies[hookName] = function mergeHook( + toValue?: Function | Function[], + fromValue?: Function + ) { + if (!fromValue) return toValue + toValue = toValue ? (isArray(toValue) ? toValue : [toValue]) : undefined + return toValue ? toValue.concat(fromValue) : fromValue + } +}) +;(['components', 'directives'] as const).forEach(assetName => { + defaultMergeStrategies[assetName] = function mergeAssets( + toValue: Record | null, + fromValue: Record | null, + publicThis: ComponentPublicInstance, + key: string, + asMixin: boolean + ) { + if (!fromValue) return toValue + const ret = toValue ? extend(Object.create(toValue), fromValue) : fromValue + + if (!toValue) { + Reflect.setPrototypeOf(ret, { asMixin }) + } + + return ret + } +}) +;(['methods', 'computed'] as const).forEach(optionName => { + defaultMergeStrategies[optionName] = function( + toValue?: Record, + fromValue?: Record + ) { + if (!fromValue) return toValue + if (!toValue) return fromValue + return extend(Object.create(null), toValue, fromValue) + } +}) + +function normalizeInject( + injectOption?: ComponentInjectOptions +): ObjectInjectOptions | undefined { + if (!injectOption) return + const normalized = Object.create(null) + if (isArray(injectOption)) { + for (let i = 0; i < injectOption.length; i++) { + normalized[injectOption[i]] = { from: injectOption[i] } + } + return normalized + } + return injectOption +} + +function callSyncHook( + hooks: Function | Function[], + instance: ComponentInternalInstance, + type: LifecycleHooks +) { + hooks = isArray(hooks) ? hooks : [hooks] + hooks.forEach(hook => { + callWithAsyncErrorHandling(hook.bind(instance.proxy!), instance, type) + }) +} + +function registerHooks( + hooks: Function | Function[], + hookRegister: Function, + publicThis: ComponentPublicInstance +) { + hooks = isArray(hooks) ? hooks : [hooks] + hooks.forEach(hook => hookRegister(hook.bind(publicThis))) +} diff --git a/packages/runtime-core/src/componentPublicInstance.ts b/packages/runtime-core/src/componentPublicInstance.ts index fd0351f64d7..c977bb69ade 100644 --- a/packages/runtime-core/src/componentPublicInstance.ts +++ b/packages/runtime-core/src/componentPublicInstance.ts @@ -157,7 +157,7 @@ export type CreateComponentPublicInstance< PublicProps, PublicDefaults, MakeDefaultsOptional, - ComponentOptionsBase + ComponentOptionsBase > // public properties exposed on the proxy, which is used as the render context @@ -172,7 +172,19 @@ export type ComponentPublicInstance< PublicProps = P, Defaults = {}, MakeDefaultsOptional extends boolean = false, - Options = ComponentOptionsBase + Options = ComponentOptionsBase< + any, + any, + any, + any, + any, + any, + any, + any, + any, + {}, + true + > > = { $: ComponentInternalInstance $data: D diff --git a/test-dts/component.test-d.ts b/test-dts/component.test-d.ts index 06368e37778..88848546c96 100644 --- a/test-dts/component.test-d.ts +++ b/test-dts/component.test-d.ts @@ -428,3 +428,25 @@ describe('class', () => { expectType(props.foo) }) + +describe('$options', () => { + defineComponent({ + created() { + expectType(this.$options.data) + // the `provide` option is merged into a factory function + expectType(this.$options.provide) + // the `inject` option is merged into object format + expectType< + | Record< + string | symbol, + string | symbol | { from?: string | symbol; default?: unknown } + > + | undefined + >(this.$options.inject) + // lifecycle hooks are merged into function or an array of functions + expectType<(() => void) | (() => void)[] | undefined>( + this.$options.beforeMount + ) + } + }) +})