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 5fc10b027..531d19c7d 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 6ab7e6fdd..b03cbd589 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 154247a67..d6f89fe28 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 1c9cf8218..bbab3118a 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 7d7e2558f..6d48b6c0a 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 07a5fc85d..629a2dad9 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 000000000..4cb93eb41 --- /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 2d8dd8041..e8bde9bcc 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 1c9b17bae..a26837bc8 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 71a056e85..ff895a619 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 aebd8506f..4e644c3e0 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 508f0136b..e4107da78 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 8274c10de..c679dfe6d 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 + }) }