From 899287ad35d8b74e76a71f39772a92f261dfa4f8 Mon Sep 17 00:00:00 2001 From: Evan You Date: Sun, 5 Apr 2020 18:39:22 -0400 Subject: [PATCH] feat(runtime-core): improve component public instance proxy inspection --- .../__tests__/componentProxy.spec.ts | 14 +- packages/runtime-core/src/component.ts | 34 +++-- packages/runtime-core/src/componentOptions.ts | 46 +++++-- packages/runtime-core/src/componentProxy.ts | 121 +++++++++++++++--- .../runtime-core/src/components/Suspense.ts | 2 +- 5 files changed, 176 insertions(+), 41 deletions(-) diff --git a/packages/runtime-core/__tests__/componentProxy.spec.ts b/packages/runtime-core/__tests__/componentProxy.spec.ts index ee8f2bc75d5..a6e2181abb6 100644 --- a/packages/runtime-core/__tests__/componentProxy.spec.ts +++ b/packages/runtime-core/__tests__/componentProxy.spec.ts @@ -108,8 +108,10 @@ describe('component: proxy', () => { expect(instanceProxy.$attrs).toBe(instance!.attrs) expect(instanceProxy.$slots).toBe(instance!.slots) expect(instanceProxy.$refs).toBe(instance!.refs) - expect(instanceProxy.$parent).toBe(instance!.parent) - expect(instanceProxy.$root).toBe(instance!.root) + expect(instanceProxy.$parent).toBe( + instance!.parent && instance!.parent.proxy + ) + expect(instanceProxy.$root).toBe(instance!.root.proxy) expect(instanceProxy.$emit).toBe(instance!.emit) expect(instanceProxy.$el).toBe(instance!.vnode.el) expect(instanceProxy.$options).toBe(instance!.type) @@ -174,6 +176,14 @@ describe('component: proxy', () => { // set non-existent (goes into sink) instanceProxy.baz = 1 expect('baz' in instanceProxy).toBe(true) + + // dev mode ownKeys check for console inspection + expect(Object.keys(instanceProxy)).toMatchObject([ + 'msg', + 'bar', + 'foo', + 'baz' + ]) }) // #864 diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index 6b7fdb9c366..43e5d547690 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -7,9 +7,13 @@ import { resetTracking } from '@vue/reactivity' import { - PublicInstanceProxyHandlers, ComponentPublicInstance, - runtimeCompiledRenderProxyHandlers + ComponentPublicProxyTarget, + PublicInstanceProxyHandlers, + RuntimeCompiledPublicInstanceProxyHandlers, + createDevProxyTarget, + exposePropsOnDevProxyTarget, + exposeRenderContextOnDevProxyTarget } from './componentProxy' import { ComponentPropsOptions, resolveProps } from './componentProps' import { Slots, resolveSlots } from './componentSlots' @@ -139,6 +143,7 @@ export interface ComponentInternalInstance { attrs: Data slots: Slots proxy: ComponentPublicInstance | null + proxyTarget: ComponentPublicProxyTarget // alternative proxy used only for runtime-compiled render functions using // `with` block withProxy: ComponentPublicInstance | null @@ -195,12 +200,13 @@ export function createComponentInstance( parent, appContext, type: vnode.type as Component, - root: null!, // set later so it can point to itself + root: null!, // to be immediately set next: null, subTree: null!, // will be set synchronously right after creation update: null!, // will be set synchronously right after creation render: null, proxy: null, + proxyTarget: null!, // to be immediately set withProxy: null, propsProxy: null, setupContext: null, @@ -250,6 +256,11 @@ export function createComponentInstance( ec: null, emit: null as any // to be set immediately } + if (__DEV__) { + instance.proxyTarget = createDevProxyTarget(instance) + } else { + instance.proxyTarget = { _: instance } + } instance.root = parent ? parent.root : instance instance.emit = emit.bind(null, instance) return instance @@ -325,7 +336,10 @@ function setupStatefulComponent( // 0. create render proxy property access cache instance.accessCache = {} // 1. create public instance / render proxy - instance.proxy = new Proxy(instance, PublicInstanceProxyHandlers) + instance.proxy = new Proxy(instance.proxyTarget, PublicInstanceProxyHandlers) + if (__DEV__) { + exposePropsOnDevProxyTarget(instance) + } // 2. create props proxy // the propsProxy is a reactive AND readonly proxy to the actual props. // it will be updated in resolveProps() on updates before render @@ -353,7 +367,7 @@ function setupStatefulComponent( if (isSSR) { // return the promise so server-renderer can wait on it return setupResult.then((resolvedResult: unknown) => { - handleSetupResult(instance, resolvedResult, parentSuspense, isSSR) + handleSetupResult(instance, resolvedResult, isSSR) }) } else if (__FEATURE_SUSPENSE__) { // async setup returned Promise. @@ -366,7 +380,7 @@ function setupStatefulComponent( ) } } else { - handleSetupResult(instance, setupResult, parentSuspense, isSSR) + handleSetupResult(instance, setupResult, isSSR) } } else { finishComponentSetup(instance, isSSR) @@ -376,7 +390,6 @@ function setupStatefulComponent( export function handleSetupResult( instance: ComponentInternalInstance, setupResult: unknown, - parentSuspense: SuspenseBoundary | null, isSSR: boolean ) { if (isFunction(setupResult)) { @@ -392,6 +405,9 @@ export function handleSetupResult( // setup returned bindings. // assuming a render function compiled from template is present. instance.renderContext = reactive(setupResult) + if (__DEV__) { + exposeRenderContextOnDevProxyTarget(instance) + } } else if (__DEV__ && setupResult !== undefined) { warn( `setup() should return an object. Received: ${ @@ -460,8 +476,8 @@ function finishComponentSetup( // also only allows a whitelist of globals to fallthrough. if (instance.render._rc) { instance.withProxy = new Proxy( - instance, - runtimeCompiledRenderProxyHandlers + instance.proxyTarget, + RuntimeCompiledPublicInstanceProxyHandlers ) } } diff --git a/packages/runtime-core/src/componentOptions.ts b/packages/runtime-core/src/componentOptions.ts index 842e9a0cebc..ffd086983df 100644 --- a/packages/runtime-core/src/componentOptions.ts +++ b/packages/runtime-core/src/componentOptions.ts @@ -38,9 +38,14 @@ import { import { reactive, ComputedGetter, - WritableComputedOptions + WritableComputedOptions, + ComputedRef } from '@vue/reactivity' -import { ComponentObjectPropsOptions, ExtractPropTypes } from './componentProps' +import { + ComponentObjectPropsOptions, + ExtractPropTypes, + normalizePropsOptions +} from './componentProps' import { EmitsOptions } from './componentEmits' import { Directive } from './directives' import { ComponentPublicInstance } from './componentProxy' @@ -239,6 +244,7 @@ export function applyOptions( options: ComponentOptions, asMixin: boolean = false ) { + const proxyTarget = instance.proxyTarget const ctx = instance.proxy! const { // composition @@ -277,7 +283,7 @@ export function applyOptions( const globalMixins = instance.appContext.mixins // call it only during dev - const checkDuplicateProperties = __DEV__ ? createDuplicateChecker() : null + // applyOptions is called non-as-mixin once per instance if (!asMixin) { callSyncHook('beforeCreate', options, ctx, globalMixins) @@ -293,8 +299,10 @@ export function applyOptions( applyMixins(instance, mixins) } + const checkDuplicateProperties = __DEV__ ? createDuplicateChecker() : null + if (__DEV__ && propsOptions) { - for (const key in propsOptions) { + for (const key in normalizePropsOptions(propsOptions)[0]) { checkDuplicateProperties!(OptionTypes.PROPS, key) } } @@ -314,6 +322,7 @@ export function applyOptions( if (__DEV__) { for (const key in data) { checkDuplicateProperties!(OptionTypes.DATA, key) + if (!(key in proxyTarget)) proxyTarget[key] = data[key] } } instance.data = reactive(data) @@ -326,9 +335,6 @@ export function applyOptions( if (computedOptions) { for (const key in computedOptions) { const opt = (computedOptions as ComputedOptions)[key] - - __DEV__ && checkDuplicateProperties!(OptionTypes.COMPUTED, key) - if (isFunction(opt)) { renderContext[key] = computed(opt.bind(ctx, ctx)) } else { @@ -350,6 +356,15 @@ export function applyOptions( warn(`Computed property "${key}" has no getter.`) } } + if (__DEV__) { + checkDuplicateProperties!(OptionTypes.COMPUTED, key) + if (renderContext[key] && !(key in proxyTarget)) { + Object.defineProperty(proxyTarget, key, { + enumerable: true, + get: () => (renderContext[key] as ComputedRef).value + }) + } + } } } @@ -357,8 +372,13 @@ export function applyOptions( for (const key in methods) { const methodHandler = (methods as MethodOptions)[key] if (isFunction(methodHandler)) { - __DEV__ && checkDuplicateProperties!(OptionTypes.METHODS, key) renderContext[key] = methodHandler.bind(ctx) + if (__DEV__) { + checkDuplicateProperties!(OptionTypes.METHODS, key) + if (!(key in proxyTarget)) { + proxyTarget[key] = renderContext[key] + } + } } else if (__DEV__) { warn( `Method "${key}" has type "${typeof methodHandler}" in the component definition. ` + @@ -387,18 +407,24 @@ export function applyOptions( if (isArray(injectOptions)) { for (let i = 0; i < injectOptions.length; i++) { const key = injectOptions[i] - __DEV__ && checkDuplicateProperties!(OptionTypes.INJECT, key) renderContext[key] = inject(key) + if (__DEV__) { + checkDuplicateProperties!(OptionTypes.INJECT, key) + proxyTarget[key] = renderContext[key] + } } } else { for (const key in injectOptions) { - __DEV__ && checkDuplicateProperties!(OptionTypes.INJECT, key) const opt = injectOptions[key] if (isObject(opt)) { renderContext[key] = inject(opt.from, opt.default) } else { renderContext[key] = inject(opt) } + if (__DEV__) { + checkDuplicateProperties!(OptionTypes.INJECT, key) + proxyTarget[key] = renderContext[key] + } } } } diff --git a/packages/runtime-core/src/componentProxy.ts b/packages/runtime-core/src/componentProxy.ts index ef46ffbfe0c..032e42fc480 100644 --- a/packages/runtime-core/src/componentProxy.ts +++ b/packages/runtime-core/src/componentProxy.ts @@ -2,7 +2,7 @@ import { ComponentInternalInstance, Data } from './component' import { nextTick, queueJob } from './scheduler' import { instanceWatch } from './apiWatch' import { EMPTY_OBJ, hasOwn, isGloballyWhitelisted, NOOP } from '@vue/shared' -import { ReactiveEffect, UnwrapRef } from '@vue/reactivity' +import { ReactiveEffect, UnwrapRef, toRaw } from '@vue/reactivity' import { ExtractComputedReturns, ComponentOptionsBase, @@ -61,8 +61,8 @@ const publicPropertiesMap: Record< $attrs: i => i.attrs, $slots: i => i.slots, $refs: i => i.refs, - $parent: i => i.parent, - $root: i => i.root, + $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), $forceUpdate: i => () => queueJob(i.update), @@ -77,8 +77,13 @@ const enum AccessTypes { OTHER } +export interface ComponentPublicProxyTarget { + [key: string]: any + _: ComponentInternalInstance +} + export const PublicInstanceProxyHandlers: ProxyHandler = { - get(target: ComponentInternalInstance, key: string) { + get({ _: instance }: ComponentPublicProxyTarget, key: string) { const { renderContext, data, @@ -87,7 +92,7 @@ export const PublicInstanceProxyHandlers: ProxyHandler = { type, sink, appContext - } = target + } = instance // data / props / renderContext // This getter gets called for every property access on the render context @@ -133,7 +138,7 @@ export const PublicInstanceProxyHandlers: ProxyHandler = { if (__DEV__ && key === '$attrs') { markAttrsAccessed() } - return publicGetter(target) + return publicGetter(instance) } else if (hasOwn(sink, key)) { return sink[key] } else if ( @@ -154,53 +159,131 @@ export const PublicInstanceProxyHandlers: ProxyHandler = { } }, - has(target: ComponentInternalInstance, key: string) { - const { data, accessCache, renderContext, type, sink } = target + has( + { + _: { data, accessCache, renderContext, type, sink } + }: ComponentPublicProxyTarget, + key: string + ) { return ( accessCache![key] !== undefined || (data !== EMPTY_OBJ && hasOwn(data, key)) || hasOwn(renderContext, key) || - (type.props && hasOwn(type.props, key)) || + (type.props && hasOwn(normalizePropsOptions(type.props)[0], key)) || hasOwn(publicPropertiesMap, key) || hasOwn(sink, key) ) }, - set(target: ComponentInternalInstance, key: string, value: any): boolean { - const { data, renderContext } = target + set( + { _: instance }: ComponentPublicProxyTarget, + key: string, + value: any + ): boolean { + const { data, renderContext } = instance if (data !== EMPTY_OBJ && hasOwn(data, key)) { data[key] = value } else if (hasOwn(renderContext, key)) { renderContext[key] = value - } else if (key[0] === '$' && key.slice(1) in target) { + } else if (key[0] === '$' && key.slice(1) in instance) { __DEV__ && warn( `Attempting to mutate public property "${key}". ` + `Properties starting with $ are reserved and readonly.`, - target + instance ) return false - } else if (key in target.props) { + } else if (key in instance.props) { __DEV__ && - warn(`Attempting to mutate prop "${key}". Props are readonly.`, target) + warn( + `Attempting to mutate prop "${key}". Props are readonly.`, + instance + ) return false } else { - target.sink[key] = value + instance.sink[key] = value + if (__DEV__) { + instance.proxyTarget[key] = value + } } return true } } -export const runtimeCompiledRenderProxyHandlers = { +export const RuntimeCompiledPublicInstanceProxyHandlers = { ...PublicInstanceProxyHandlers, - get(target: ComponentInternalInstance, key: string) { + get(target: ComponentPublicProxyTarget, key: string) { // fast path for unscopables when using `with` block if ((key as any) === Symbol.unscopables) { return } return PublicInstanceProxyHandlers.get!(target, key, target) }, - has(_target: ComponentInternalInstance, key: string) { + has(_: ComponentPublicProxyTarget, key: string) { return key[0] !== '_' && !isGloballyWhitelisted(key) } } + +// In dev mode, the proxy target exposes the same properties as seen on `this` +// for easier console inspection. In prod mode it will be an empty object so +// these properties definitions can be skipped. +export function createDevProxyTarget(instance: ComponentInternalInstance) { + const target: Record = {} + + // expose internal instance for proxy handlers + Object.defineProperty(target, `_`, { + get: () => instance + }) + + // expose public properties + Object.keys(publicPropertiesMap).forEach(key => { + Object.defineProperty(target, key, { + get: () => publicPropertiesMap[key](instance) + }) + }) + + // expose global properties + const { globalProperties } = instance.appContext.config + Object.keys(globalProperties).forEach(key => { + Object.defineProperty(target, key, { + get: () => globalProperties[key] + }) + }) + + return target as ComponentPublicProxyTarget +} + +export function exposePropsOnDevProxyTarget( + instance: ComponentInternalInstance +) { + const { + proxyTarget, + type: { props: propsOptions } + } = instance + if (propsOptions) { + Object.keys(normalizePropsOptions(propsOptions)[0]).forEach(key => { + Object.defineProperty(proxyTarget, key, { + enumerable: true, + get: () => instance.props[key], + // intercepted by the proxy so no need for implementation, + // but needed to prevent set errors + set: NOOP + }) + }) + } +} + +export function exposeRenderContextOnDevProxyTarget( + instance: ComponentInternalInstance +) { + const { proxyTarget, renderContext } = instance + Object.keys(toRaw(renderContext)).forEach(key => { + Object.defineProperty(proxyTarget, key, { + enumerable: true, + get: () => renderContext[key], + // intercepted by the proxy so no need for implementation, + // but needed to prevent set errors + set: NOOP + }) + }) +} diff --git a/packages/runtime-core/src/components/Suspense.ts b/packages/runtime-core/src/components/Suspense.ts index 4e925830ca2..f5934fa0b70 100644 --- a/packages/runtime-core/src/components/Suspense.ts +++ b/packages/runtime-core/src/components/Suspense.ts @@ -419,7 +419,7 @@ function createSuspenseBoundary( if (__DEV__) { pushWarningContext(vnode) } - handleSetupResult(instance, asyncSetupResult, suspense, false) + handleSetupResult(instance, asyncSetupResult, false) if (hydratedEl) { // vnode may have been replaced if an update happened before the // async dep is reoslved.