From d901b6bea885aa31bcaf04ccbcd64188f4bad97a Mon Sep 17 00:00:00 2001 From: Evan You Date: Sat, 2 May 2020 16:16:51 -0400 Subject: [PATCH] refactor(reactivity): use more efficient reactive checks WeakSets and WeakMaps shows degrading performance as the amount of observed objects increases. Using hidden keys result in better performance especially when repeatedly creating large amounts of reactive proxies. This also makes it possible to more efficiently declare non-reactive objects in userland. --- packages/reactivity/src/baseHandlers.ts | 10 ++- packages/reactivity/src/collectionHandlers.ts | 27 ++++-- packages/reactivity/src/computed.ts | 2 +- packages/reactivity/src/index.ts | 3 +- packages/reactivity/src/reactive.ts | 87 +++++++++++-------- packages/reactivity/src/ref.ts | 23 ++--- packages/runtime-core/__tests__/misc.spec.ts | 18 ++++ packages/runtime-core/__tests__/vnode.spec.ts | 9 +- packages/runtime-core/src/component.ts | 5 +- packages/runtime-core/src/componentProxy.ts | 15 ++-- packages/runtime-core/src/h.ts | 2 +- packages/runtime-core/src/vnode.ts | 17 +++- packages/shared/src/index.ts | 5 +- 13 files changed, 145 insertions(+), 78 deletions(-) create mode 100644 packages/runtime-core/__tests__/misc.spec.ts diff --git a/packages/reactivity/src/baseHandlers.ts b/packages/reactivity/src/baseHandlers.ts index 5fc10b02..531d19c7 100644 --- a/packages/reactivity/src/baseHandlers.ts +++ b/packages/reactivity/src/baseHandlers.ts @@ -1,4 +1,4 @@ -import { reactive, readonly, toRaw } from './reactive' +import { reactive, readonly, toRaw, ReactiveFlags } from './reactive' import { TrackOpTypes, TriggerOpTypes } from './operations' import { track, trigger, ITERATE_KEY } from './effect' import { isObject, hasOwn, isSymbol, hasChanged, isArray } from '@vue/shared' @@ -35,6 +35,14 @@ const arrayInstrumentations: Record = {} function createGetter(isReadonly = false, shallow = false) { return function get(target: object, key: string | symbol, receiver: object) { + if (key === ReactiveFlags.isReactive) { + return !isReadonly + } else if (key === ReactiveFlags.isReadonly) { + return isReadonly + } else if (key === ReactiveFlags.raw) { + return target + } + const targetIsArray = isArray(target) if (targetIsArray && hasOwn(arrayInstrumentations, key)) { return Reflect.get(arrayInstrumentations, key, receiver) diff --git a/packages/reactivity/src/collectionHandlers.ts b/packages/reactivity/src/collectionHandlers.ts index 6ab7e6fd..b03cbd58 100644 --- a/packages/reactivity/src/collectionHandlers.ts +++ b/packages/reactivity/src/collectionHandlers.ts @@ -1,4 +1,4 @@ -import { toRaw, reactive, readonly } from './reactive' +import { toRaw, reactive, readonly, ReactiveFlags } from './reactive' import { track, trigger, ITERATE_KEY, MAP_KEY_ITERATE_KEY } from './effect' import { TrackOpTypes, TriggerOpTypes } from './operations' import { @@ -242,29 +242,40 @@ iteratorMethods.forEach(method => { ) }) -function createInstrumentationGetter( - instrumentations: Record -) { +function createInstrumentationGetter(isReadonly: boolean) { + const instrumentations = isReadonly + ? readonlyInstrumentations + : mutableInstrumentations + return ( target: CollectionTypes, key: string | symbol, receiver: CollectionTypes - ) => - Reflect.get( + ) => { + if (key === ReactiveFlags.isReactive) { + return !isReadonly + } else if (key === ReactiveFlags.isReadonly) { + return isReadonly + } else if (key === ReactiveFlags.raw) { + return target + } + + return Reflect.get( hasOwn(instrumentations, key) && key in target ? instrumentations : target, key, receiver ) + } } export const mutableCollectionHandlers: ProxyHandler = { - get: createInstrumentationGetter(mutableInstrumentations) + get: createInstrumentationGetter(false) } export const readonlyCollectionHandlers: ProxyHandler = { - get: createInstrumentationGetter(readonlyInstrumentations) + get: createInstrumentationGetter(true) } function checkIdentityKeys( diff --git a/packages/reactivity/src/computed.ts b/packages/reactivity/src/computed.ts index 154247a6..d6f89fe2 100644 --- a/packages/reactivity/src/computed.ts +++ b/packages/reactivity/src/computed.ts @@ -57,7 +57,7 @@ export function computed( } }) computed = { - _isRef: true, + __v_isRef: true, // expose effect so computed can be stopped effect: runner, get value() { diff --git a/packages/reactivity/src/index.ts b/packages/reactivity/src/index.ts index 1c9cf821..bbab3118 100644 --- a/packages/reactivity/src/index.ts +++ b/packages/reactivity/src/index.ts @@ -21,7 +21,8 @@ export { shallowReactive, shallowReadonly, markRaw, - toRaw + toRaw, + ReactiveFlags } from './reactive' export { computed, diff --git a/packages/reactivity/src/reactive.ts b/packages/reactivity/src/reactive.ts index 7d7e2558..6d48b6c0 100644 --- a/packages/reactivity/src/reactive.ts +++ b/packages/reactivity/src/reactive.ts @@ -1,4 +1,4 @@ -import { isObject, toRawType } from '@vue/shared' +import { isObject, toRawType, def } from '@vue/shared' import { mutableHandlers, readonlyHandlers, @@ -13,25 +13,38 @@ import { UnwrapRef, Ref } from './ref' import { makeMap } from '@vue/shared' // WeakMaps that store {raw <-> observed} pairs. -const rawToReactive = new WeakMap() -const reactiveToRaw = new WeakMap() -const rawToReadonly = new WeakMap() -const readonlyToRaw = new WeakMap() +// const rawToReactive = new WeakMap() +// const reactiveToRaw = new WeakMap() +// const rawToReadonly = new WeakMap() +// const readonlyToRaw = new WeakMap() -// WeakSets for values that are marked readonly or non-reactive during -// observable creation. -const rawValues = new WeakSet() +export const enum ReactiveFlags { + skip = '__v_skip', + isReactive = '__v_isReactive', + isReadonly = '__v_isReadonly', + raw = '__v_raw', + reactive = '__v_reactive', + readonly = '__v_readonly' +} + +interface Target { + __v_skip?: boolean + __v_isReactive?: boolean + __v_isReadonly?: boolean + __v_raw?: any + __v_reactive?: any + __v_readonly?: any +} const collectionTypes = new Set([Set, Map, WeakMap, WeakSet]) const isObservableType = /*#__PURE__*/ makeMap( 'Object,Array,Map,Set,WeakMap,WeakSet' ) -const canObserve = (value: any): boolean => { +const canObserve = (value: Target): boolean => { return ( - !value._isVNode && + !value.__v_skip && isObservableType(toRawType(value)) && - !rawValues.has(value) && !Object.isFrozen(value) ) } @@ -42,13 +55,12 @@ type UnwrapNestedRefs = T extends Ref ? T : UnwrapRef export function reactive(target: T): UnwrapNestedRefs export function reactive(target: object) { // if trying to observe a readonly proxy, return the readonly version. - if (readonlyToRaw.has(target)) { + if (target && (target as Target).__v_isReadonly) { return target } return createReactiveObject( target, - rawToReactive, - reactiveToRaw, + false, mutableHandlers, mutableCollectionHandlers ) @@ -60,8 +72,7 @@ export function reactive(target: object) { export function shallowReactive(target: T): T { return createReactiveObject( target, - rawToReactive, - reactiveToRaw, + false, shallowReactiveHandlers, mutableCollectionHandlers ) @@ -72,8 +83,7 @@ export function readonly( ): Readonly> { return createReactiveObject( target, - rawToReadonly, - readonlyToRaw, + true, readonlyHandlers, readonlyCollectionHandlers ) @@ -88,17 +98,15 @@ export function shallowReadonly( ): Readonly<{ [K in keyof T]: UnwrapNestedRefs }> { return createReactiveObject( target, - rawToReadonly, - readonlyToRaw, + true, shallowReadonlyHandlers, readonlyCollectionHandlers ) } function createReactiveObject( - target: unknown, - toProxy: WeakMap, - toRaw: WeakMap, + target: Target, + isReadonly: boolean, baseHandlers: ProxyHandler, collectionHandlers: ProxyHandler ) { @@ -108,15 +116,16 @@ function createReactiveObject( } return target } + // target is already a Proxy, return it. + // excpetion: calling readonly() on a reactive object + if (target.__v_raw && !(isReadonly && target.__v_isReactive)) { + return target + } // target already has corresponding Proxy - let observed = toProxy.get(target) + let observed = isReadonly ? target.__v_readonly : target.__v_reactive if (observed !== void 0) { return observed } - // target is already a Proxy - if (toRaw.has(target)) { - return target - } // only a whitelist of value types can be observed. if (!canObserve(target)) { return target @@ -125,30 +134,34 @@ function createReactiveObject( ? collectionHandlers : baseHandlers observed = new Proxy(target, handlers) - toProxy.set(target, observed) - toRaw.set(observed, target) + def( + target, + isReadonly ? ReactiveFlags.readonly : ReactiveFlags.reactive, + observed + ) return observed } export function isReactive(value: unknown): boolean { - value = readonlyToRaw.get(value) || value - return reactiveToRaw.has(value) + if (isReadonly(value)) { + return isReactive((value as Target).__v_raw) + } + return !!(value && (value as Target).__v_isReactive) } export function isReadonly(value: unknown): boolean { - return readonlyToRaw.has(value) + return !!(value && (value as Target).__v_isReadonly) } export function isProxy(value: unknown): boolean { - return readonlyToRaw.has(value) || reactiveToRaw.has(value) + return isReactive(value) || isReadonly(value) } export function toRaw(observed: T): T { - observed = readonlyToRaw.get(observed) || observed - return reactiveToRaw.get(observed) || observed + return (observed && toRaw((observed as Target).__v_raw)) || observed } export function markRaw(value: T): T { - rawValues.add(value) + def(value, ReactiveFlags.skip, true) return value } diff --git a/packages/reactivity/src/ref.ts b/packages/reactivity/src/ref.ts index 07a5fc85..629a2dad 100644 --- a/packages/reactivity/src/ref.ts +++ b/packages/reactivity/src/ref.ts @@ -5,18 +5,11 @@ import { reactive, isProxy, toRaw } from './reactive' import { ComputedRef } from './computed' import { CollectionTypes } from './collectionHandlers' -const isRefSymbol = Symbol() - export interface Ref { - // This field is necessary to allow TS to differentiate a Ref from a plain - // object that happens to have a "value" field. - // However, checking a symbol on an arbitrary object is much slower than - // checking a plain property, so we use a _isRef plain property for isRef() - // check in the actual implementation. - // The reason for not just declaring _isRef in the interface is because we - // don't want this internal field to leak into userland autocompletion - - // a private symbol, on the other hand, achieves just that. - [isRefSymbol]: true + /** + * @internal + */ + __v_isRef: true value: T } @@ -27,7 +20,7 @@ const convert = (val: T): T => export function isRef(r: Ref | unknown): r is Ref export function isRef(r: any): r is Ref { - return r ? r._isRef === true : false + return r ? r.__v_isRef === true : false } export function ref( @@ -51,7 +44,7 @@ function createRef(rawValue: unknown, shallow = false) { } let value = shallow ? rawValue : convert(rawValue) const r = { - _isRef: true, + __v_isRef: true, get value() { track(r, TrackOpTypes.GET, 'value') return value @@ -99,7 +92,7 @@ export function customRef(factory: CustomRefFactory): Ref { () => trigger(r, TriggerOpTypes.SET, 'value') ) const r = { - _isRef: true, + __v_isRef: true, get value() { return get() }, @@ -126,7 +119,7 @@ export function toRef( key: K ): Ref { return { - _isRef: true, + __v_isRef: true, get value(): any { return object[key] }, diff --git a/packages/runtime-core/__tests__/misc.spec.ts b/packages/runtime-core/__tests__/misc.spec.ts new file mode 100644 index 00000000..4cb93eb4 --- /dev/null +++ b/packages/runtime-core/__tests__/misc.spec.ts @@ -0,0 +1,18 @@ +import { render, h, nodeOps, reactive, isReactive } from '@vue/runtime-test' + +describe('misc', () => { + test('component public instance should not be observable', () => { + let instance: any + const Comp = { + render() {}, + mounted() { + instance = this + } + } + render(h(Comp), nodeOps.createElement('div')) + expect(instance).toBeDefined() + const r = reactive(instance) + expect(r).toBe(instance) + expect(isReactive(r)).toBe(false) + }) +}) diff --git a/packages/runtime-core/__tests__/vnode.spec.ts b/packages/runtime-core/__tests__/vnode.spec.ts index 2d8dd804..e8bde9bc 100644 --- a/packages/runtime-core/__tests__/vnode.spec.ts +++ b/packages/runtime-core/__tests__/vnode.spec.ts @@ -12,7 +12,7 @@ import { } from '../src/vnode' import { Data } from '../src/component' import { ShapeFlags, PatchFlags } from '@vue/shared' -import { h } from '../src' +import { h, reactive, isReactive } from '../src' import { createApp, nodeOps, serializeInner } from '@vue/runtime-test' describe('vnode', () => { @@ -425,5 +425,12 @@ describe('vnode', () => { createApp(App).mount(root) expect(serializeInner(root)).toBe('

Root Component

') }) + + test('should not be observable', () => { + const a = createVNode('div') + const b = reactive(a) + expect(b).toBe(a) + expect(isReactive(b)).toBe(false) + }) }) }) diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index 1c9b17ba..a26837bc 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -4,8 +4,7 @@ import { ReactiveEffect, pauseTracking, resetTracking, - shallowReadonly, - markRaw + shallowReadonly } from '@vue/reactivity' import { ComponentPublicInstance, @@ -464,7 +463,7 @@ function setupStatefulComponent( instance.accessCache = {} // 1. create public instance / render proxy // also mark it raw so it's never observed - instance.proxy = markRaw(new Proxy(instance.ctx, PublicInstanceProxyHandlers)) + instance.proxy = new Proxy(instance.ctx, PublicInstanceProxyHandlers) if (__DEV__) { exposePropsOnRenderContext(instance) } diff --git a/packages/runtime-core/src/componentProxy.ts b/packages/runtime-core/src/componentProxy.ts index 71a056e8..ff895a61 100644 --- a/packages/runtime-core/src/componentProxy.ts +++ b/packages/runtime-core/src/componentProxy.ts @@ -6,7 +6,8 @@ import { ReactiveEffect, UnwrapRef, toRaw, - shallowReadonly + shallowReadonly, + ReactiveFlags } from '@vue/reactivity' import { ExtractComputedReturns, @@ -128,6 +129,11 @@ export const PublicInstanceProxyHandlers: ProxyHandler = { appContext } = instance + // let @vue/reatvitiy know it should never observe Vue public instances. + if (key === ReactiveFlags.skip) { + return true + } + // data / props / ctx // This getter gets called for every property access on the render context // during render and is a major hotspot. The most expensive part of this @@ -197,10 +203,9 @@ export const PublicInstanceProxyHandlers: ProxyHandler = { } else if ( __DEV__ && currentRenderingInstance && - // #1091 avoid isRef/isVNode checks on component instance leading to - // infinite warning loop - key !== '_isRef' && - key !== '_isVNode' + // #1091 avoid internal isRef/isVNode checks on component instance leading + // to infinite warning loop + key.indexOf('__v') !== 0 ) { if (data !== EMPTY_OBJ && key[0] === '$' && hasOwn(data, key)) { warn( diff --git a/packages/runtime-core/src/h.ts b/packages/runtime-core/src/h.ts index aebd8506..4e644c3e 100644 --- a/packages/runtime-core/src/h.ts +++ b/packages/runtime-core/src/h.ts @@ -46,7 +46,7 @@ h(Component, null, {}) type RawProps = VNodeProps & { // used to differ from a single VNode object as children - _isVNode?: never + __v_isVNode?: never // used to differ from Array children [Symbol.iterator]?: never } diff --git a/packages/runtime-core/src/vnode.ts b/packages/runtime-core/src/vnode.ts index 508f0136..e4107da7 100644 --- a/packages/runtime-core/src/vnode.ts +++ b/packages/runtime-core/src/vnode.ts @@ -103,7 +103,14 @@ export type VNodeNormalizedChildren = | null export interface VNode { - _isVNode: true + /** + * @internal + */ + __v_isVNode: true + /** + * @internal + */ + __v_skip: true type: VNodeTypes props: VNodeProps | null key: string | number | null @@ -221,7 +228,7 @@ export function createBlock( } export function isVNode(value: any): value is VNode { - return value ? value._isVNode === true : false + return value ? value.__v_isVNode === true : false } export function isSameVNodeType(n1: VNode, n2: VNode): boolean { @@ -344,7 +351,8 @@ function _createVNode( } const vnode: VNode = { - _isVNode: true, + __v_isVNode: true, + __v_skip: true, type, props, key: props && normalizeKey(props), @@ -403,7 +411,8 @@ export function cloneVNode( // This is intentionally NOT using spread or extend to avoid the runtime // key enumeration cost. return { - _isVNode: true, + __v_isVNode: true, + __v_skip: true, type: vnode.type, props, key: props && normalizeKey(props), diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8274c10d..c679dfe6 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -128,5 +128,8 @@ export const invokeArrayFns = (fns: Function[], arg?: any) => { } export const def = (obj: object, key: string | symbol, value: any) => { - Object.defineProperty(obj, key, { value }) + Object.defineProperty(obj, key, { + configurable: true, + value + }) }