From 92945b0b070cee7704d1860b19a0bd40bf7836e9 Mon Sep 17 00:00:00 2001 From: Janry Date: Tue, 24 May 2022 15:24:32 +0800 Subject: [PATCH] feat(react): support dynamic scope (#3143) * feat(react): support dynamic scope * test(shared/reactive): add some tests --- packages/antd/src/form-button-group/index.tsx | 4 +- packages/json-schema/src/transformer.ts | 37 ++++++------ packages/next/src/form-button-group/index.tsx | 4 +- .../react/src/components/ExpressionScope.tsx | 5 +- .../react/src/components/ReactiveField.tsx | 10 ++-- .../react/src/components/RecursionField.tsx | 19 +----- packages/react/src/components/SchemaField.tsx | 31 ++++------ packages/react/src/shared/context.ts | 10 +++- packages/react/src/types.ts | 3 +- .../src/__tests__/annotations.spec.ts | 56 +++++++++++++++++- .../reactive/src/__tests__/define.spec.ts | 20 +++++++ packages/reactive/src/annotations/computed.ts | 30 ++++++++-- packages/shared/src/__tests__/index.spec.ts | 59 ++++++++++++++++++- packages/shared/src/merge.ts | 36 +++++++++++ 14 files changed, 252 insertions(+), 72 deletions(-) diff --git a/packages/antd/src/form-button-group/index.tsx b/packages/antd/src/form-button-group/index.tsx index 3f67e06bdec..b667f0678c9 100644 --- a/packages/antd/src/form-button-group/index.tsx +++ b/packages/antd/src/form-button-group/index.tsx @@ -5,14 +5,14 @@ * 4. 吸底布局 */ import React, { useRef, useLayoutEffect, useState } from 'react' -import StickyBox, { StickyBoxCompProps } from 'react-sticky-box' import { ReactFC } from '@formily/react' import { Space } from 'antd' import { SpaceProps } from 'antd/lib/space' import { BaseItem, IFormItemProps } from '../form-item' import { usePrefixCls } from '../__builtins__' +import StickyBox from 'react-sticky-box' import cls from 'classnames' -interface IStickyProps extends StickyBoxCompProps { +interface IStickyProps extends React.ComponentProps { align?: React.CSSProperties['textAlign'] } diff --git a/packages/json-schema/src/transformer.ts b/packages/json-schema/src/transformer.ts index 292c2ce5cc4..753206add81 100644 --- a/packages/json-schema/src/transformer.ts +++ b/packages/json-schema/src/transformer.ts @@ -7,6 +7,7 @@ import { isFn, isPlainObj, reduce, + lazyMerge, } from '@formily/shared' import { Schema } from './schema' import { @@ -99,10 +100,13 @@ const setSchemaFieldState = ( if (target) { if (request.state) { field.form.setFieldState(target, (state) => - patchCompile(state, request.state, { - ...scope, - $target: state, - }) + patchCompile( + state, + request.state, + lazyMerge(scope, { + $target: state, + }) + ) ) } if (request.schema) { @@ -110,20 +114,21 @@ const setSchemaFieldState = ( patchSchemaCompile( state, request.schema, - { - ...scope, + lazyMerge(scope, { $target: state, - }, + }), demand ) ) } if (isStr(runner) && runner) { field.form.setFieldState(target, (state) => { - shallowCompile(`{{function(){${runner}}}}`, { - ...scope, - $target: state, - })() + shallowCompile( + `{{function(){${runner}}}}`, + lazyMerge(scope, { + $target: state, + }) + )() }) } } else { @@ -153,8 +158,7 @@ const getBaseScope = ( const $self = field const $form = field.form const $values = field.form.values - return { - ...options.scope, + return lazyMerge(options.scope, { $form, $self, $observable, @@ -162,7 +166,7 @@ const getBaseScope = ( $memo, $props, $values, - } + }) } const getBaseReactions = @@ -194,12 +198,11 @@ const getUserReactions = ( const run = () => { const $deps = getDependencies(field, reaction.dependencies) const $dependencies = $deps - const scope = { - ...baseScope, + const scope = lazyMerge(baseScope, { $target: null, $deps, $dependencies, - } + }) const compiledWhen = shallowCompile(when, scope) const condition = when ? compiledWhen : true const request = condition ? fulfill : otherwise diff --git a/packages/next/src/form-button-group/index.tsx b/packages/next/src/form-button-group/index.tsx index b9aeab7f671..c7ee77340cc 100644 --- a/packages/next/src/form-button-group/index.tsx +++ b/packages/next/src/form-button-group/index.tsx @@ -5,13 +5,13 @@ * 4. 吸底布局 */ import React, { useRef, useLayoutEffect, useState } from 'react' -import StickyBox, { StickyBoxCompProps } from 'react-sticky-box' +import StickyBox from 'react-sticky-box' import { ReactFC } from '@formily/react' import { Space, ISpaceProps } from '../space' import { BaseItem, IFormItemProps } from '../form-item' import { usePrefixCls } from '../__builtins__' import cls from 'classnames' -interface IStickyProps extends StickyBoxCompProps { +interface IStickyProps extends React.ComponentProps { align?: React.CSSProperties['textAlign'] } diff --git a/packages/react/src/components/ExpressionScope.tsx b/packages/react/src/components/ExpressionScope.tsx index f0dfd8c420f..cb9dcdf8eed 100644 --- a/packages/react/src/components/ExpressionScope.tsx +++ b/packages/react/src/components/ExpressionScope.tsx @@ -1,11 +1,14 @@ import React, { useContext } from 'react' +import { lazyMerge } from '@formily/shared' import { SchemaExpressionScopeContext } from '../shared' import { IExpressionScopeProps, ReactFC } from '../types' export const ExpressionScope: ReactFC = (props) => { const scope = useContext(SchemaExpressionScopeContext) return ( - + {props.children} ) diff --git a/packages/react/src/components/ReactiveField.tsx b/packages/react/src/components/ReactiveField.tsx index 98c4bb31856..3b5d986293b 100644 --- a/packages/react/src/components/ReactiveField.tsx +++ b/packages/react/src/components/ReactiveField.tsx @@ -1,9 +1,9 @@ import React, { Fragment, useContext } from 'react' import { toJS } from '@formily/reactive' import { observer } from '@formily/reactive-react' -import { isFn } from '@formily/shared' +import { FormPath, isFn } from '@formily/shared' import { isVoidField, GeneralField, Form } from '@formily/core' -import { SchemaOptionsContext } from '../shared' +import { SchemaComponentsContext } from '../shared' import { RenderPropsChildren } from '../types' interface IReactiveFieldProps { field: GeneralField @@ -31,7 +31,7 @@ const renderChildren = ( ) => (isFn(children) ? children(field, form) : children) const ReactiveInternal: React.FC = (props) => { - const options = useContext(SchemaOptionsContext) + const components = useContext(SchemaComponentsContext) if (!props.field) { return {renderChildren(props.children)} } @@ -47,7 +47,7 @@ const ReactiveInternal: React.FC = (props) => { return {children} } const finalComponent = - options?.getComponent(field.decoratorType) ?? field.decoratorType + FormPath.getIn(components, field.decoratorType) ?? field.decoratorType return React.createElement( finalComponent, @@ -84,7 +84,7 @@ const ReactiveInternal: React.FC = (props) => { ? field.pattern === 'readOnly' : undefined const finalComponent = - options?.getComponent(field.componentType) ?? field.componentType + FormPath.getIn(components, field.componentType) ?? field.componentType return React.createElement( finalComponent, { diff --git a/packages/react/src/components/RecursionField.tsx b/packages/react/src/components/RecursionField.tsx index 6cd576b5eff..a94f24c4657 100644 --- a/packages/react/src/components/RecursionField.tsx +++ b/packages/react/src/components/RecursionField.tsx @@ -1,12 +1,8 @@ -import React, { Fragment, useContext, useRef, useMemo } from 'react' +import React, { Fragment, useContext, useMemo } from 'react' import { isFn, isValid } from '@formily/shared' import { GeneralField } from '@formily/core' import { Schema } from '@formily/json-schema' -import { - SchemaContext, - SchemaOptionsContext, - SchemaExpressionScopeContext, -} from '../shared' +import { SchemaContext, SchemaExpressionScopeContext } from '../shared' import { IRecursionFieldProps, ReactFC } from '../types' import { useField } from '../hooks' import { ObjectField } from './ObjectField' @@ -15,18 +11,9 @@ import { Field } from './Field' import { VoidField } from './VoidField' const useFieldProps = (schema: Schema) => { - const options = useContext(SchemaOptionsContext) const scope = useContext(SchemaExpressionScopeContext) - const scopeRef = useRef() - scopeRef.current = scope return schema.toFieldProps({ - ...options, - get scope() { - return { - ...options.scope, - ...scopeRef.current, - } - }, + scope, }) as any } diff --git a/packages/react/src/components/SchemaField.tsx b/packages/react/src/components/SchemaField.tsx index 8c8494c28e5..48334607c14 100644 --- a/packages/react/src/components/SchemaField.tsx +++ b/packages/react/src/components/SchemaField.tsx @@ -6,6 +6,7 @@ import { SchemaMarkupContext, SchemaExpressionScopeContext, SchemaOptionsContext, + SchemaComponentsContext, } from '../shared' import { ReactComponentPath, @@ -16,7 +17,7 @@ import { ISchemaMarkupFieldProps, ISchemaTypeFieldProps, } from '../types' -import { FormPath } from '@formily/shared' +import { lazyMerge } from '@formily/shared' const env = { nonameId: 0, } @@ -53,25 +54,17 @@ export function createSchemaField( } return ( - - + - {renderMarkup()} - {renderChildren()} - + + {renderMarkup()} + {renderChildren()} + + ) } diff --git a/packages/react/src/shared/context.ts b/packages/react/src/shared/context.ts index 95c3e8f0ef4..ba3aebcf980 100644 --- a/packages/react/src/shared/context.ts +++ b/packages/react/src/shared/context.ts @@ -1,7 +1,10 @@ import React, { createContext } from 'react' import { Form, GeneralField } from '@formily/core' import { Schema } from '@formily/json-schema' -import { ISchemaFieldOptionContext } from '../types' +import { + ISchemaFieldReactFactoryOptions, + SchemaReactComponents, +} from '../types' const createContextCleaner = (...contexts: React.Context[]) => { return ({ children }) => { @@ -16,13 +19,16 @@ export const FieldContext = createContext(null) export const SchemaMarkupContext = createContext(null) export const SchemaContext = createContext(null) export const SchemaExpressionScopeContext = createContext(null) +export const SchemaComponentsContext = + createContext(null) export const SchemaOptionsContext = - createContext(null) + createContext(null) export const ContextCleaner = createContextCleaner( FieldContext, SchemaMarkupContext, SchemaContext, SchemaExpressionScopeContext, + SchemaComponentsContext, SchemaOptionsContext ) diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index dd56a690c1b..a4808508526 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -71,8 +71,7 @@ export interface ISchemaFieldReactFactoryOptions< } export interface ISchemaFieldOptionContext { - getComponent: (name: string) => JSXComponent - scope?: any + components: SchemaReactComponents } export interface ISchemaFieldProps< diff --git a/packages/reactive/src/__tests__/annotations.spec.ts b/packages/reactive/src/__tests__/annotations.spec.ts index 954e76a0d80..c137f559ee3 100644 --- a/packages/reactive/src/__tests__/annotations.spec.ts +++ b/packages/reactive/src/__tests__/annotations.spec.ts @@ -1,4 +1,4 @@ -import { observable, action, model } from '../' +import { observable, action, model, define } from '../' import { autorun, reaction } from '../autorun' import { observe } from '../observe' import { isObservable } from '../externals' @@ -292,3 +292,57 @@ test('computed no track get', () => { expect(compu.value).toBe(123) }) }) + +test('computed cache descriptor', () => { + class A { + _value = 0 + constructor() { + define(this, { + _value: observable.ref, + value: observable.computed, + }) + } + + get value() { + return this._value + } + } + const obs1 = new A() + const obs2 = new A() + const handler1 = jest.fn() + const handler2 = jest.fn() + autorun(() => { + handler1(obs1.value) + }) + autorun(() => { + handler2(obs2.value) + }) + expect(handler1).toBeCalledTimes(1) + expect(handler2).toBeCalledTimes(1) + obs1._value = 123 + obs2._value = 123 + expect(handler1).toBeCalledTimes(2) + expect(handler2).toBeCalledTimes(2) +}) + +test('computed normal object', () => { + const obs = define( + { + _value: 0, + get value() { + return this._value + }, + }, + { + _value: observable.ref, + value: observable.computed, + } + ) + const handler = jest.fn() + autorun(() => { + handler(obs.value) + }) + expect(handler).toBeCalledTimes(1) + obs._value = 123 + expect(handler).toBeCalledTimes(2) +}) diff --git a/packages/reactive/src/__tests__/define.spec.ts b/packages/reactive/src/__tests__/define.spec.ts index 334b9022ef0..6ae05a92f86 100644 --- a/packages/reactive/src/__tests__/define.spec.ts +++ b/packages/reactive/src/__tests__/define.spec.ts @@ -138,6 +138,26 @@ describe('makeObservable', () => { expect(handler).toBeCalledTimes(2) expect(target.cc).toEqual(44) }) + test('unexpect target', () => { + const testFn = jest.fn() + const testArr = [] + const obs1 = define(4 as any, { + value: observable.computed, + }) + const obs2 = define('123' as any, { + value: observable.computed, + }) + const obs3 = define(testFn as any, { + value: observable.computed, + }) + const obs4 = define(testArr as any, { + value: observable.computed, + }) + expect(obs1).toBe(4) + expect(obs2).toBe('123') + expect(obs3).toBe(testFn) + expect(obs4).toBe(testArr) + }) }) test('define model', () => { diff --git a/packages/reactive/src/annotations/computed.ts b/packages/reactive/src/annotations/computed.ts index 1ba4d67adcc..d93df95833d 100644 --- a/packages/reactive/src/annotations/computed.ts +++ b/packages/reactive/src/annotations/computed.ts @@ -25,6 +25,26 @@ const getDescriptor = Object.getOwnPropertyDescriptor const getProto = Object.getPrototypeOf +const ClassDescriptorMap = new WeakMap() + +function getPropertyDescriptor(obj: any, key: PropertyKey) { + if (!obj) return + return getDescriptor(obj, key) || getPropertyDescriptor(getProto(obj), key) +} + +function getPropertyDescriptorCache(obj: any, key: PropertyKey) { + const constructor = obj.constructor + if (constructor === Object || constructor === Array) + return getPropertyDescriptor(obj, key) + const cache = ClassDescriptorMap.get(constructor) || {} + const descriptor = cache[key] + if (descriptor) return descriptor + const newDesc = getPropertyDescriptor(obj, key) + ClassDescriptorMap.set(constructor, cache) + cache[key] = newDesc + return newDesc +} + function getGetterAndSetter(target: any, key: PropertyKey, value: any) { if (!target) { if (value) { @@ -36,9 +56,11 @@ function getGetterAndSetter(target: any, key: PropertyKey, value: any) { } return [] } - const descriptor = getDescriptor(target, key) - if (descriptor) return [descriptor.get, descriptor.set] - return getGetterAndSetter(getProto(target), key, value) + const descriptor = getPropertyDescriptorCache(target, key) + if (descriptor) { + return [descriptor.get, descriptor.set] + } + return [] } export const computed: IComputed = createAnnotation( @@ -49,7 +71,7 @@ export const computed: IComputed = createAnnotation( const context = target ? target : store const property = target ? key : 'value' - const [getter, setter] = getGetterAndSetter(context, property, value) + const [getter, setter] = getGetterAndSetter(target, property, value) function compute() { store.value = getter?.call(context) diff --git a/packages/shared/src/__tests__/index.spec.ts b/packages/shared/src/__tests__/index.spec.ts index ef178f3a727..ab9660a8c97 100644 --- a/packages/shared/src/__tests__/index.spec.ts +++ b/packages/shared/src/__tests__/index.spec.ts @@ -18,7 +18,7 @@ import { globalThisPolyfill } from '../global' import { isValid, isEmpty } from '../isEmpty' import { stringLength } from '../string' import { Subscribable } from '../subscribable' -import { merge } from '../merge' +import { lazyMerge, merge } from '../merge' import { instOf } from '../instanceof' import { isFn, isHTMLElement, isNumberLike, isReactElement } from '../checkers' import { defaults } from '../defaults' @@ -777,6 +777,63 @@ describe('merge', () => { test('merge unmatch', () => { expect(merge({ aa: 123 }, [111])).toEqual([111]) }) + + test('lazy merge', () => { + const merge1 = lazyMerge(1, 2) + expect(merge1).toBe(2) + const merge2 = lazyMerge('123', '321') + expect(merge2).toBe('321') + const merge3 = lazyMerge(1, undefined) + expect(merge3).toBe(1) + const merge4 = lazyMerge('123', undefined) + expect(merge4).toBe('123') + const merge5 = lazyMerge(undefined, '123') + expect(merge5).toBe('123') + const merge6 = lazyMerge([1, 2, 3], [3, 4]) + expect(merge6[0]).toBe(3) + expect(merge6[1]).toBe(4) + expect(merge6[2]).toBe(3) + const merge7 = lazyMerge( + { + get x() { + return 'x' + }, + }, + { + get y() { + return 'y' + }, + } + ) + expect(merge7.x).toBe('x') + expect(merge7.y).toBe('y') + const effects = { + a: 1, + b: 2, + } + const merge8 = lazyMerge( + { + get x() { + return effects.a + }, + }, + { + get y() { + return effects.b + }, + } + ) + expect(merge8.x).toBe(1) + expect(merge8.y).toBe(2) + effects.a = 123 + effects.b = 321 + expect(merge8.x).toBe(123) + expect(merge8.y).toBe(321) + expect(Object.keys(merge8)).toEqual(['x', 'y']) + expect('x' in merge8).toBe(true) + expect('y' in merge8).toBe(true) + expect('z' in merge8).toBe(false) + }) }) describe('globalThis', () => { diff --git a/packages/shared/src/merge.ts b/packages/shared/src/merge.ts index 56c1661a9b4..17481620803 100644 --- a/packages/shared/src/merge.ts +++ b/packages/shared/src/merge.ts @@ -151,4 +151,40 @@ function deepmerge(target: any, source: any, options?: Options) { } } +export const lazyMerge = (target: T, source: T): T => { + if (!isValid(source)) return target + if (!isValid(target)) return source + if (typeof target !== 'object') return source + if (typeof source !== 'object') return target + return new Proxy( + {}, + { + get(_, key) { + if (key in source) return source[key] + return target[key] + }, + ownKeys() { + const keys = Object.keys(target) + for (let key in source) { + if (!(key in target)) { + keys.push(key) + } + } + return keys + }, + getOwnPropertyDescriptor() { + return { + enumerable: true, + configurable: true, + writable: false, + } + }, + has(_, key: string) { + if (key in source || key in target) return true + return false + }, + } + ) as any +} + export const merge = deepmerge