diff --git a/packages/runtime-core/__tests__/componentEmits.spec.ts b/packages/runtime-core/__tests__/componentEmits.spec.ts index b47a16cb5..973099545 100644 --- a/packages/runtime-core/__tests__/componentEmits.spec.ts +++ b/packages/runtime-core/__tests__/componentEmits.spec.ts @@ -178,40 +178,13 @@ describe('component: emit', () => { expect(fn).toHaveBeenCalledTimes(1) }) - describe('isEmitListener', () => { - test('array option', () => { - const def1 = { emits: ['click'] } - expect(isEmitListener(def1, 'onClick')).toBe(true) - expect(isEmitListener(def1, 'onclick')).toBe(false) - expect(isEmitListener(def1, 'onBlick')).toBe(false) - }) - - test('object option', () => { - const def2 = { emits: { click: null } } - expect(isEmitListener(def2, 'onClick')).toBe(true) - expect(isEmitListener(def2, 'onclick')).toBe(false) - expect(isEmitListener(def2, 'onBlick')).toBe(false) - }) - - test('with mixins and extends', () => { - const mixin1 = { emits: ['foo'] } - const mixin2 = { emits: ['bar'] } - const extend = { emits: ['baz'] } - const def3 = { - mixins: [mixin1, mixin2], - extends: extend - } - expect(isEmitListener(def3, 'onFoo')).toBe(true) - expect(isEmitListener(def3, 'onBar')).toBe(true) - expect(isEmitListener(def3, 'onBaz')).toBe(true) - expect(isEmitListener(def3, 'onclick')).toBe(false) - expect(isEmitListener(def3, 'onBlick')).toBe(false) - }) - - test('.once listeners', () => { - const def2 = { emits: { click: null } } - expect(isEmitListener(def2, 'onClickOnce')).toBe(true) - expect(isEmitListener(def2, 'onclickOnce')).toBe(false) - }) + test('isEmitListener', () => { + const options = { click: null } + expect(isEmitListener(options, 'onClick')).toBe(true) + expect(isEmitListener(options, 'onclick')).toBe(false) + expect(isEmitListener(options, 'onBlick')).toBe(false) + // .once listeners + expect(isEmitListener(options, 'onClickOnce')).toBe(true) + expect(isEmitListener(options, 'onclickOnce')).toBe(false) }) }) diff --git a/packages/runtime-core/__tests__/componentProps.spec.ts b/packages/runtime-core/__tests__/componentProps.spec.ts index dee736c46..d0ddc5c1f 100644 --- a/packages/runtime-core/__tests__/componentProps.spec.ts +++ b/packages/runtime-core/__tests__/componentProps.spec.ts @@ -7,7 +7,8 @@ import { FunctionalComponent, defineComponent, ref, - serializeInner + serializeInner, + createApp } from '@vue/runtime-test' import { render as domRender, nextTick } from 'vue' @@ -309,4 +310,44 @@ describe('component props', () => { expect(setupProps).toMatchObject(props) expect(renderProxy.$props).toMatchObject(props) }) + + test('merging props from global mixins', () => { + let setupProps: any + let renderProxy: any + + const M1 = { + props: ['m1'] + } + const M2 = { + props: { m2: null } + } + const Comp = { + props: ['self'], + setup(props: any) { + setupProps = props + }, + render(this: any) { + renderProxy = this + return h('div', [this.self, this.m1, this.m2]) + } + } + + const props = { + self: 'from self, ', + m1: 'from mixin 1, ', + m2: 'from mixin 2' + } + const app = createApp(Comp, props) + app.mixin(M1) + app.mixin(M2) + + const root = nodeOps.createElement('div') + app.mount(root) + + expect(serializeInner(root)).toMatch( + `from self, from mixin 1, from mixin 2` + ) + expect(setupProps).toMatchObject(props) + expect(renderProxy.$props).toMatchObject(props) + }) }) diff --git a/packages/runtime-core/src/apiCreateApp.ts b/packages/runtime-core/src/apiCreateApp.ts index 39140c315..2397cdf33 100644 --- a/packages/runtime-core/src/apiCreateApp.ts +++ b/packages/runtime-core/src/apiCreateApp.ts @@ -33,6 +33,7 @@ export interface App { provide(key: InjectionKey | string, value: T): this // internal, but we need to expose these for the server-renderer and devtools + _uid: number _component: ConcreteComponent _props: Data | null _container: HostElement | null @@ -108,6 +109,8 @@ export type CreateAppFunction = ( rootProps?: Data | null ) => App +let uid = 0 + export function createAppAPI( render: RootRenderFunction, hydrate?: RootHydrateFunction @@ -124,6 +127,7 @@ export function createAppAPI( let isMounted = false const app: App = (context.app = { + _uid: uid++, _component: rootComponent as ConcreteComponent, _props: rootProps, _container: null, diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index 0bc928911..1182447e8 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -18,7 +18,8 @@ import { import { ComponentPropsOptions, NormalizedPropsOptions, - initProps + initProps, + normalizePropsOptions } from './componentProps' import { Slots, initSlots, InternalSlots } from './componentSlots' import { warn } from './warning' @@ -30,7 +31,8 @@ import { EmitsOptions, ObjectEmitsOptions, EmitFn, - emit + emit, + normalizeEmitsOptions } from './componentEmits' import { EMPTY_OBJ, @@ -72,11 +74,11 @@ export interface ComponentInternalOptions { /** * @internal */ - __props?: NormalizedPropsOptions | [] + __props?: Record /** * @internal */ - __emits?: ObjectEmitsOptions + __emits?: Record /** * @internal */ @@ -231,6 +233,16 @@ export interface ComponentInternalInstance { * @internal */ directives: Record | null + /** + * reoslved props options + * @internal + */ + propsOptions: NormalizedPropsOptions + /** + * resolved emits options + * @internal + */ + emitsOptions: ObjectEmitsOptions | null // the rest are only for stateful components --------------------------------- @@ -254,14 +266,17 @@ export interface ComponentInternalInstance { */ ctx: Data - // internal state + // state data: Data props: Data attrs: Data slots: InternalSlots refs: Data emit: EmitFn - // used for keeping track of .once event handlers on components + /** + * used for keeping track of .once event handlers on components + * @internal + */ emitted: Record | null /** @@ -387,6 +402,14 @@ export function createComponentInstance( components: null, directives: null, + // resolved props and emits options + propsOptions: normalizePropsOptions(type, appContext), + emitsOptions: normalizeEmitsOptions(type, appContext), + + // emit + emit: null as any, // to be set immediately + emitted: null, + // state ctx: EMPTY_OBJ, data: EMPTY_OBJ, @@ -419,9 +442,7 @@ export function createComponentInstance( a: null, rtg: null, rtc: null, - ec: null, - emit: null as any, // to be set immediately - emitted: null + ec: null } if (__DEV__) { instance.ctx = createRenderContext(instance) diff --git a/packages/runtime-core/src/componentEmits.ts b/packages/runtime-core/src/componentEmits.ts index 33ca7f694..6418f8c7d 100644 --- a/packages/runtime-core/src/componentEmits.ts +++ b/packages/runtime-core/src/componentEmits.ts @@ -8,12 +8,16 @@ import { isFunction, extend } from '@vue/shared' -import { ComponentInternalInstance, ConcreteComponent } from './component' +import { + ComponentInternalInstance, + ComponentOptions, + ConcreteComponent +} from './component' import { callWithAsyncErrorHandling, ErrorCodes } from './errorHandling' import { warn } from './warning' -import { normalizePropsOptions } from './componentProps' import { UnionToIntersection } from './helpers/typeUtils' import { devtoolsComponentEmit } from './devtools' +import { AppContext } from './apiCreateApp' export type ObjectEmitsOptions = Record< string, @@ -44,10 +48,12 @@ export function emit( const props = instance.vnode.props || EMPTY_OBJ if (__DEV__) { - const options = normalizeEmitsOptions(instance.type) - if (options) { - if (!(event in options)) { - const propsOptions = normalizePropsOptions(instance.type)[0] + const { + emitsOptions, + propsOptions: [propsOptions] + } = instance + if (emitsOptions) { + if (!(event in emitsOptions)) { if (!propsOptions || !(`on` + capitalize(event) in propsOptions)) { warn( `Component emitted event "${event}" but it is neither declared in ` + @@ -55,7 +61,7 @@ export function emit( ) } } else { - const validator = options[event] + const validator = emitsOptions[event] if (isFunction(validator)) { const isValid = validator(...args) if (!isValid) { @@ -98,11 +104,16 @@ export function emit( } } -function normalizeEmitsOptions( - comp: ConcreteComponent -): ObjectEmitsOptions | undefined { - if (hasOwn(comp, '__emits')) { - return comp.__emits +export function normalizeEmitsOptions( + comp: ConcreteComponent, + appContext: AppContext, + asMixin = false +): ObjectEmitsOptions | null { + const appId = appContext.app ? appContext.app._uid : -1 + const cache = comp.__emits || (comp.__emits = {}) + const cached = cache[appId] + if (cached !== undefined) { + return cached } const raw = comp.emits @@ -111,18 +122,23 @@ function normalizeEmitsOptions( // apply mixin/extends props let hasExtends = false if (__FEATURE_OPTIONS_API__ && !isFunction(comp)) { - if (comp.extends) { + const extendEmits = (raw: ComponentOptions) => { hasExtends = true - extend(normalized, normalizeEmitsOptions(comp.extends)) + extend(normalized, normalizeEmitsOptions(raw, appContext, true)) + } + if (!asMixin && appContext.mixins.length) { + appContext.mixins.forEach(extendEmits) + } + if (comp.extends) { + extendEmits(comp.extends) } if (comp.mixins) { - hasExtends = true - comp.mixins.forEach(m => extend(normalized, normalizeEmitsOptions(m))) + comp.mixins.forEach(extendEmits) } } if (!raw && !hasExtends) { - return (comp.__emits = undefined) + return (cache[appId] = null) } if (isArray(raw)) { @@ -130,20 +146,22 @@ function normalizeEmitsOptions( } else { extend(normalized, raw) } - return (comp.__emits = normalized) + return (cache[appId] = normalized) } // Check if an incoming prop key is a declared emit event listener. // e.g. With `emits: { click: null }`, props named `onClick` and `onclick` are // both considered matched listeners. -export function isEmitListener(comp: ConcreteComponent, key: string): boolean { - let emits: ObjectEmitsOptions | undefined - if (!isOn(key) || !(emits = normalizeEmitsOptions(comp))) { +export function isEmitListener( + options: ObjectEmitsOptions | null, + key: string +): boolean { + if (!options || !isOn(key)) { return false } key = key.replace(/Once$/, '') return ( - hasOwn(emits, key[2].toLowerCase() + key.slice(3)) || - hasOwn(emits, key.slice(2)) + hasOwn(options, key[2].toLowerCase() + key.slice(3)) || + hasOwn(options, key.slice(2)) ) } diff --git a/packages/runtime-core/src/componentOptions.ts b/packages/runtime-core/src/componentOptions.ts index d2a4c0594..29a09bf1b 100644 --- a/packages/runtime-core/src/componentOptions.ts +++ b/packages/runtime-core/src/componentOptions.ts @@ -42,11 +42,7 @@ import { WritableComputedOptions, toRaw } from '@vue/reactivity' -import { - ComponentObjectPropsOptions, - ExtractPropTypes, - normalizePropsOptions -} from './componentProps' +import { ComponentObjectPropsOptions, ExtractPropTypes } from './componentProps' import { EmitsOptions } from './componentEmits' import { Directive } from './directives' import { @@ -431,7 +427,7 @@ export function applyOptions( const checkDuplicateProperties = __DEV__ ? createDuplicateChecker() : null if (__DEV__) { - const propsOptions = normalizePropsOptions(options)[0] + const [propsOptions] = instance.propsOptions if (propsOptions) { for (const key in propsOptions) { checkDuplicateProperties!(OptionTypes.PROPS, key) diff --git a/packages/runtime-core/src/componentProps.ts b/packages/runtime-core/src/componentProps.ts index 28d7de2a5..a8bccd9a5 100644 --- a/packages/runtime-core/src/componentProps.ts +++ b/packages/runtime-core/src/componentProps.ts @@ -31,6 +31,7 @@ import { } from './component' import { isEmitListener } from './componentEmits' import { InternalObjectKey } from './vnode' +import { AppContext } from './apiCreateApp' export type ComponentPropsOptions

= | ComponentObjectPropsOptions

@@ -107,7 +108,8 @@ type NormalizedProp = // normalized value is a tuple of the actual normalized options // and an array of prop keys that need value casting (booleans and defaults) -export type NormalizedPropsOptions = [Record, string[]] +export type NormalizedProps = Record +export type NormalizedPropsOptions = [NormalizedProps, string[]] | [] export function initProps( instance: ComponentInternalInstance, @@ -121,7 +123,7 @@ export function initProps( setFullProps(instance, rawProps, props, attrs) // validation if (__DEV__) { - validateProps(props, instance.type) + validateProps(props, instance) } if (isStateful) { @@ -151,7 +153,7 @@ export function updateProps( vnode: { patchFlag } } = instance const rawCurrentProps = toRaw(props) - const [options] = normalizePropsOptions(instance.type) + const [options] = instance.propsOptions if ( // always force full diff if hmr is enabled @@ -236,7 +238,7 @@ export function updateProps( trigger(instance, TriggerOpTypes.SET, '$attrs') if (__DEV__ && rawProps) { - validateProps(props, instance.type) + validateProps(props, instance) } } @@ -246,7 +248,7 @@ function setFullProps( props: Data, attrs: Data ) { - const [options, needCastKeys] = normalizePropsOptions(instance.type) + const [options, needCastKeys] = instance.propsOptions if (rawProps) { for (const key in rawProps) { const value = rawProps[key] @@ -259,7 +261,7 @@ function setFullProps( let camelKey if (options && hasOwn(options, (camelKey = camelize(key)))) { props[camelKey] = value - } else if (!isEmitListener(instance.type, key)) { + } else if (!isEmitListener(instance.emitsOptions, key)) { // Any non-declared (either as a prop or an emitted event) props are put // into a separate `attrs` object for spreading. Make sure to preserve // original key casing @@ -283,7 +285,7 @@ function setFullProps( } function resolvePropValue( - options: NormalizedPropsOptions[0], + options: NormalizedProps, props: Data, key: string, value: unknown @@ -315,10 +317,15 @@ function resolvePropValue( } export function normalizePropsOptions( - comp: ConcreteComponent -): NormalizedPropsOptions | [] { - if (comp.__props) { - return comp.__props + comp: ConcreteComponent, + appContext: AppContext, + asMixin = false +): NormalizedPropsOptions { + const appId = appContext.app ? appContext.app._uid : -1 + const cache = comp.__props || (comp.__props = {}) + const cached = cache[appId] + if (cached) { + return cached } const raw = comp.props @@ -329,22 +336,24 @@ export function normalizePropsOptions( let hasExtends = false if (__FEATURE_OPTIONS_API__ && !isFunction(comp)) { const extendProps = (raw: ComponentOptions) => { - const [props, keys] = normalizePropsOptions(raw) + hasExtends = true + const [props, keys] = normalizePropsOptions(raw, appContext, true) extend(normalized, props) if (keys) needCastKeys.push(...keys) } + if (!asMixin && appContext.mixins.length) { + appContext.mixins.forEach(extendProps) + } if (comp.extends) { - hasExtends = true extendProps(comp.extends) } if (comp.mixins) { - hasExtends = true comp.mixins.forEach(extendProps) } } if (!raw && !hasExtends) { - return (comp.__props = EMPTY_ARR) + return (cache[appId] = EMPTY_ARR) } if (isArray(raw)) { @@ -381,9 +390,8 @@ export function normalizePropsOptions( } } } - const normalizedEntry: NormalizedPropsOptions = [normalized, needCastKeys] - comp.__props = normalizedEntry - return normalizedEntry + + return (cache[appId] = [normalized, needCastKeys]) } // use function string name to check type constructors @@ -416,9 +424,9 @@ function getTypeIndex( /** * dev only */ -function validateProps(props: Data, comp: ConcreteComponent) { +function validateProps(props: Data, instance: ComponentInternalInstance) { const rawValues = toRaw(props) - const options = normalizePropsOptions(comp)[0] + const options = instance.propsOptions[0] for (const key in options) { let opt = options[key] if (opt == null) continue diff --git a/packages/runtime-core/src/componentPublicInstance.ts b/packages/runtime-core/src/componentPublicInstance.ts index 288145271..35125ac81 100644 --- a/packages/runtime-core/src/componentPublicInstance.ts +++ b/packages/runtime-core/src/componentPublicInstance.ts @@ -29,7 +29,6 @@ import { resolveMergedOptions, isInBeforeCreate } from './componentOptions' -import { normalizePropsOptions } from './componentProps' import { EmitsOptions, EmitFn } from './componentEmits' import { Slots } from './componentSlots' import { @@ -250,7 +249,7 @@ export const PublicInstanceProxyHandlers: ProxyHandler = { } else if ( // only cache other properties when instance has declared (thus stable) // props - (normalizedProps = normalizePropsOptions(type)[0]) && + (normalizedProps = instance.propsOptions[0]) && hasOwn(normalizedProps, key) ) { accessCache![key] = AccessTypes.PROPS @@ -354,7 +353,7 @@ export const PublicInstanceProxyHandlers: ProxyHandler = { has( { - _: { data, setupState, accessCache, ctx, type, appContext } + _: { data, setupState, accessCache, ctx, appContext, propsOptions } }: ComponentRenderContext, key: string ) { @@ -363,8 +362,7 @@ export const PublicInstanceProxyHandlers: ProxyHandler = { accessCache![key] !== undefined || (data !== EMPTY_OBJ && hasOwn(data, key)) || (setupState !== EMPTY_OBJ && hasOwn(setupState, key)) || - ((normalizedProps = normalizePropsOptions(type)[0]) && - hasOwn(normalizedProps, key)) || + ((normalizedProps = propsOptions[0]) && hasOwn(normalizedProps, key)) || hasOwn(ctx, key) || hasOwn(publicPropertiesMap, key) || hasOwn(appContext.config.globalProperties, key) @@ -450,8 +448,10 @@ export function createRenderContext(instance: ComponentInternalInstance) { export function exposePropsOnRenderContext( instance: ComponentInternalInstance ) { - const { ctx, type } = instance - const propsOptions = normalizePropsOptions(type)[0] + const { + ctx, + propsOptions: [propsOptions] + } = instance if (propsOptions) { Object.keys(propsOptions).forEach(key => { Object.defineProperty(ctx, key, {