diff --git a/packages/antd/src/fields/table.tsx b/packages/antd/src/fields/table.tsx index 01be7668509..19a0c896190 100644 --- a/packages/antd/src/fields/table.tsx +++ b/packages/antd/src/fields/table.tsx @@ -5,7 +5,7 @@ import { SchemaField, Schema } from '@uform/react-schema-renderer' -import { toArr, isFn, isArr } from '@uform/shared' +import { toArr, isFn, isArr, FormPath } from '@uform/shared' import { ArrayList } from '@uform/react-shared-components' import { CircleButton, TextButton } from '../components/Button' import { Table, Form, Icon } from 'antd' @@ -54,7 +54,7 @@ const FormTableField = styled( render: (value: any, record: any, index: number) => { return ( - + ) } diff --git a/packages/react-schema-renderer/README.zh-cn.md b/packages/react-schema-renderer/README.zh-cn.md index 4858564264b..a15eda567b5 100644 --- a/packages/react-schema-renderer/README.zh-cn.md +++ b/packages/react-schema-renderer/README.zh-cn.md @@ -19,23 +19,499 @@ npm install --save @uform/react-schema-renderer #### 快速开始 +如果您是直接基于@uform/react-schema-renderer做开发的,那么您必须在开发前将自定义组件注册到渲染器里去,否则,我们的JSON-Schema协议是不能渲染表单的。所以: + +```jsx +import { SchemaForm, registerFormField,connect } from '@uform/react-schema-renderer' + +registerFormField('string',connect()(({value,onChange})=>{ + return +})) + +export default ()=>{ + return ( + + ) +} +``` + +大工告成,这个就是最简单的用法,核心就是注册组件,然后使用协议渲染。**需要注意一点是,我们在注册组件的时候使用了connect函数,这个connect函数的功能就是,让任意一个组件,只要支持value/onChange API的,都可以快速注册到SchemaForm里面去,同时,connect函数也屏蔽了Field API,所以使用了connect函数的组件,是不能做更加强大的扩展的,详细的connect API后面会有介绍**。同时,还有一个要注意的就是,如果我们要接入一套组件库的,业内大多数组件库其实都是有自己的Form和FormItem组件的,他们核心是用于控制样式,FormItem控制表单局部样式,Form控制全局表单样式,所以在生产环境下,其实我们还需要注册Form和FormItem组件,这样才能做到样式与原有解决方案的一致性,具体如何注册,我们会在后面有详细介绍。 + #### JSON Schema驱动表单渲染 +说到JSON Schema,上面一个例子其实已经涉及了,当然,它并不够复杂,我们看一个较为复杂的例子: + +```tsx +import { SchemaForm } from '@uform/react-schema-renderer' + +registerFormField('string',connect()(({value,onChange})=>{ + return +})) + +registerFormField('array',()=>{ + return //... +}) + +export default ()=>{ + return ( + + ) +} +``` + +上面的代码是一段伪代码,因为我们并没有注册array类型的自定义组件,这里先暂时不讲如何注册array类型的自定义组件,我们核心是分析JSON Schema是如何驱动表单渲染的。在这份JSON Schema中,我们主要使用了properties和items属性用来描述复杂数据结构,这就是JSON Schema最核心的特性,注意:**在SchemaForm中,内置了object的properties的递归渲染,但是并没有内置array的items递归渲染**,主要原因是,array的递归渲染会涉及很多样式需求,并不方便内置,所以最好还是留给开发者自己实现,所以,后面我们会详细介绍如何实现自增列表的递归渲染需求。 + #### JSchema驱动表单渲染 -#### 快速接入第三方组件库 +JSchema就是在jsx中以一种更优雅的写法来描述JSON Schema,我们可以针对以上例子用JSchema实现一版: + +```tsx +import { SchemaForm,Field } from '@uform/react-schema-renderer' + +export default ()=>{ + return ( + + + + + + + + ) +} +``` + +可以看到,使用JSchema在代码中描述JSON Schema比起JSON而言变得更加优雅了,大大的提高了代码可维护性。 + +#### 非单例注册组件 + +在前面的例子中,我们使用了registerFormField API来注册了自定义组件,这种方式是单例注册的方式,它的主要优点就是方便,但是也会存在一些问题,就是单例容易受污染,特别是在SPA页面中,如果不同页面的开发者是不一样的,因为共享同一个内存环境,那么A开发者可能会注册B开发者同名的自定义组件,这样就很容易导致线上故障,所以,我们更加推荐用户使用非单例注册方式: + +```jsx +import React,{ useMemo } from 'react' +import { SchemaForm, registerFormField,connect } from '@uform/react-schema-renderer' + +const StringField = connect()(({value,onChange})=>{ + return +}) -#### 使用自定义组件建立自己的表单组件生态 +const useFields = ()=>useMemo(()=>{ + string:StringField +}) -#### 使用VirtualBox组件建立自己的表单布局生态 +export default ()=>{ + return ( + + ) +} +``` + +在上面的例子中,我们主要是在SchemaForm的props维度来传递自定义组件,这样就能保证是实例级注册了,这样的形式对SPA非常友好,同时,**需要注意的是我们抽象了一个useFields的React Hook,它主要用于解决组件多次渲染的时候不会影响React Virtual DOM重新计算,从而避免表单组件重复渲染**。 ### 高级教程 --- -#### 如何实现自己的递归渲染组件? +#### 如何接入第三方组件库? + +因为@uform/react-schema-renderer是一个基础库,默认不会集成任何组件库的,所以我们在实际业务开发中,如果要基于它来定制,那么就必须得面对接入第三方组件库的问题。如何接入第三方组件库,我们分为以下几步: + +- 接入Form/FormItem组件 +- 接入组件库表单组件 +- 实现表单布局组件 +- 实现自增列表组件 + +下面就让我们一步步的来接入第三方组件库吧!这里我们主要以antd组件库为例子。 + +#### 如何接入Form/FormItem组件? + +接入方式目前提供了全局注册机制与单例注册机制,全局注册主要使用registerFormComponent和registerFormItemComponent两个API来注册,单例注册则是直接在SchemaForm属性上传formComponent和formItemComponent。如果是SPA场景,推荐使用单例注册的方式,下面看看例子: + +```tsx +import { + SchemaForm, + registerFormComponent, + registerFormItemComponent +} from '@uform/react-schema-renderer' +import { Form } from 'antd' + +export const CompatFormComponent = ({children,...props})=>{ + return
{children}
//很简单的使用Form组件,props是SchemaForm组件的props,这里会直接透传 +}) + +export const CompatFormItemComponent = ({children,...props})=>{ + const messages = [].concat(props.errors || [], props.warnings || []) + let status = '' + if (props.loading) { + status = 'validating' + } + if (props.invalid) { + status = 'error' + } + if (props.warnings && props.warnings.length) { + status = 'warning' + } + return ( + + {children} + + ) +} + +/*** +全局注册方式 +registerFormComponent(CompatFormComponent) +registerFormItemComponent(CompatFormItemComponent) +***/ + +//单例注册方式 +export default ()=>{ + return ( + +} + +``` + +我们可以看到,扩展表单整体或局部的样式,仅仅只需要通过扩展Form/FormItem组件就可以轻松解决了,这里需要注意的是,FormItem组件接收到的props有点复杂,不用担心,后面会列出详细props API,现在我们只需要知道大概是如何注册的就行了。 + +#### 如何接入表单组件? + +因为组件库的所有组件都是原子型组件,同时大部分都兼容了value/onChange规范,所以我们可以借助connect函数快速接入组件库的组件,通常,我们接入组件库组件,大概要做3件事情: + +- 处理状态映射,将uform内部的loading/error状态映射到该组件属性上,当然,**前提是要求组件必须支持loading或error这类的样式** +- 处理详情态样式,将uform内部的editable状态,映射到一个PreviewText组件上去,用于更友好更干净的展示数据 +- 处理组件枚举态,我们想一下,**JSON Schema,每一个节点都应该支持enum属性的**,如果配了enum属性,我们最好都以Select形式来展现,所以我们需要处理一下组件枚举态 + +咱们以InputNumber为例演示一下: + +```tsx +import { connect, registerFormField } from '@uform/react-schema-renderer' +import { InputNumber } from 'antd' + +const mapTextComponent = ( + Target: React.JSXElementConstructor, + props: any = {}, + fieldProps: any = {} +): React.JSXElementConstructor => { + const { editable } = fieldProps + if (editable !== undefined) { + if (editable === false) { + return PreviewText + } + } + if (Array.isArray(props.dataSource)) { + return Select + } + return Target +} + +const mapStyledProps = ( + props: IConnectProps, + fieldProps: MergedFieldComponentProps +) => { + const { loading, errors } = fieldProps + if (loading) { + props.state = props.state || 'loading' + } else if (errors && errors.length) { + props.state = 'error' + } +} + +const acceptEnum = (component: React.JSXElementConstructor) => { + return ({ dataSource, ...others }) => { + if (dataSource) { + return React.createElement(Select, { dataSource, ...others }) + } else { + return React.createElement(component, others) + } + } +} + +registerFormField( + 'number', + connect({ + getProps: mapStyledProps,//处理状态映射 + getComponent: mapTextComponent//处理详情态 + })(acceptEnum(InputNumber))//处理枚举态 +) + +``` + +在这个例子中,我们深度使用了connect函数,其实connect就是一个HOC,在渲染阶段,它可以在组件渲染过程中加入一些中间处理逻辑,帮助动态分发。当然,connect还有很多API,后面会详细介绍。 + +#### 如何处理表单布局? + +JSON Schema描述表单数据结构,其实是天然支持的,但是表单最终还是落在UI层面的,可惜在UI层面上我们有很多组件其实并不能作为JSON Schema的一个具体数据节点,它仅仅只是一个UI节点。所以,想要在JSON Schema中描述复杂布局,怎么做? + +现在uform的做法是,抽象了一个叫**虚拟节点**的概念,用户在代码层面上指定某个JSON Schema x-component为虚拟节点之后,后面不管是在渲染,还是在数据处理,还是最终数据提交,只要解析到这个节点是虚拟节点,都不会将它当做一个正常的数据节点。所以,有了这个虚拟节点的概念,我们就可以在JSON Schema中描述各种复杂布局,下面让我们试着写一个布局组件: + +```tsx +import { SchemaForm,registerVirtualBox } from '@uform/react-schema-renderer' +import { Card } from 'antd' + +registerVirtualBox('card',({children,...props})=>{ + return {children} +}) + +export default ()=>{ + return ( + + ) +} +``` + +从这段伪代码中我们可以看到card就是一个正常的Object Schema节点,只是需要指定一个x-component为card,这样就能和registerVirtualBox注册的card匹配上,就达到了虚拟节点的效果,所以,不管你把JSON Schema中的属性名改为什么,都不会影响最终的提交的数据结构。**这里需要注意的是x-component-props是直接透传到registerVirtualBox的回调函数参数上的。** 这是JSON Schema形式的使用,我们还有JSchema的使用方式: + +```tsx +import { SchemaForm,createVirtualBox } from '@uform/react-schema-renderer' +import { Card } from 'antd' + +const Card = createVirtualBox('card',({children,...props})=>{ + return {children} +}) + +export default ()=>{ + return ( + + + + + + + + + + ) +} +``` + +从这个例子中我们可以看到,借助createVirtualBox API可以快速创建一个布局组件,同时在JSchema中直接使用。**其实createVirtualBox的内部实现很简单,还是使用了registerVitualBox和Field**: + +```tsx +export function createVirtualBox( + key: string, + component?: React.JSXElementConstructor> +) { + registerVirtualBox( + key, + component + ? ({ props,schema, children }) => { + return React.createElement(component, { + ...schema.getExtendsComponentProps(), + children + }) + } + : () => + ) + const VirtualBox: React.FC = ({ + children, + name, + ...props + }) => { + return ( + + {children} + + ) + } + return VirtualBox +} +``` + +前面介绍的注册布局组件的方式,其实都是单例注册,如果我们需要实例形式的注册,还是与前面说的方式类似 + +```tsx + +const Card = ({children,...props})=>{ + return {children} +} + +export default ()=>{ + return ( + + ) +} +``` + + + +#### 如何实现递归渲染组件? + +什么叫递归渲染组件?其实就是**实现了JSON Schema中properties和items的组件**,像`type:"string"` 这种节点就属于原子节点,不属于递归渲染组件。其实像前面说的布局组件,其实它也是属于递归渲染组件,只是它固定了渲染模式,所以可以很简单的注册。所以,我们大部分想要实现递归渲染的场景,可能实际业务场景中,更多的是在`type:"array"`这种场景才会去实现递归渲染,下面我们会详细介绍自增列表组件的实现方式。 + +#### 如何实现自增列表组件? + +自增列表它主要有几个特点: + +- 有独立样式 +- 支持递归渲染子组件 +- 支持数组项的新增,删除,上移,下移 +- 不能使用connect函数包装,因为必须调用Field API + +为了帮助大家更好的理解如何实现自增列表组件,我们就不实现具体样式了,更多的是教大家如何实现递归渲染和,数组项的操作。下面我们看伪代码: + +```tsx +import React, { Fragment } from 'react' +import { + registerFormField, + SchemaField, + FormPath +} from '@uform/react-schema-renderer' + +//不用connect包装 +registerFormField('array',({value,path,mutators})=>{ + + const emptyUI = + + const listUI = value.map((item,index)=>{ + return ( +
+ + + + +
+ ) + }) + + return ( + value.length == 0 ? emptyUI : listUI + ) +}) +``` + +看到了没,要实现一个带递归渲染的自增列表组件,超级简单,反而如果要实现相关的样式就会有点麻烦,总之核心就是使用了SchemaField这个组件和mutators API,具体API会在后面详细介绍。 #### 如何实现超复杂自定义组件? +这个问题,在老版UForm中基本无解,恰好也是因为我们这边的业务复杂度高到一定程度之后,我们自己被这个问题给受限制了,所以必须得想办法解决这个问题,下面我们可以定义一下,什么才是超复杂自定义组件: + +- 组件内部存在大量表单组件,同时内部也存在大量联动关系 +- 组件内部存在私有的服务端动态渲染方案 + +- 组件内部有复杂布局结构 + +就这3点,基本上满足超复杂自定义组件的特征了,对于这种场景,为什么我们通过正常的封装自定义组件的形式不能解决问题呢?其实主要是受限于校验,没法整体校验,所以,我们需要一个能聚合大量字段处理逻辑的能力,那么,我们该如何解决呢? + ### 协议 --- diff --git a/packages/react-schema-renderer/src/components/SchemaMarkup.tsx b/packages/react-schema-renderer/src/components/SchemaMarkup.tsx index a57712d4c19..c61e25819de 100644 --- a/packages/react-schema-renderer/src/components/SchemaMarkup.tsx +++ b/packages/react-schema-renderer/src/components/SchemaMarkup.tsx @@ -71,17 +71,14 @@ SchemaMarkupForm.displayName = 'SchemaMarkupForm' export function createVirtualBox( key: string, - component?: React.JSXElementConstructor> + component?: React.JSXElementConstructor ) { registerVirtualBox( key, component - ? ({ props, children }) => { - return React.createElement(component, { - ...props['x-props'], - ...props['x-component-props'], - children - }) + ? ({ schema, children }) => { + const props = schema.getExtendsComponentProps() + return React.createElement(component, props, children) } : () => ) diff --git a/packages/react/src/components/FormSpy.tsx b/packages/react/src/components/FormSpy.tsx index a88b98dd370..b8fa7d7ff26 100644 --- a/packages/react/src/components/FormSpy.tsx +++ b/packages/react/src/components/FormSpy.tsx @@ -1,66 +1,11 @@ -import { - useContext, - useMemo, - useRef, - useEffect, - useCallback, - useState, - useReducer -} from 'react' -import { FormHeartSubscriber, LifeCycleTypes } from '@uform/core' -import { isFn, isStr, FormPath, isArr } from '@uform/shared' +import React from 'react' +import { isFn } from '@uform/shared' import { IFormSpyProps } from '../types' -import FormContext, { BroadcastContext } from '../context' +import { useFormSpy } from '../hooks/useFormSpy' export const FormSpy: React.FunctionComponent = props => { - const broadcast = useContext(BroadcastContext) - const form = useContext(FormContext) - const initializedRef = useRef(false) - const subscriberId = useRef() - const [type, setType] = useState(LifeCycleTypes.ON_FORM_INIT) - const [state, dispatch] = useReducer( - (state, action) => props.reducer(state, action, form), - {} - ) - const subscriber = useCallback(({ type, payload }) => { - if (initializedRef.current) return - setTimeout(() => { - if (isStr(props.selector) && FormPath.parse(props.selector).match(type)) { - setType(type) - dispatch({ - type, - payload - }) - } else if (isArr(props.selector) && props.selector.indexOf(type) > -1) { - setType(type) - dispatch({ - type, - payload - }) - } - }) - }, []) - useMemo(() => { - initializedRef.current = true - if (form) { - subscriberId.current = form.subscribe(subscriber) - } else if (broadcast) { - subscriberId.current = broadcast.subscribe(subscriber) - } - initializedRef.current = false - }, []) - useEffect(() => { - return () => { - if (form) { - form.unsubscribe(subscriberId.current) - } else if (broadcast) { - broadcast.unsubscribe(subscriberId.current) - } - } - }, []) if (isFn(props.children)) { - const formApi = form ? form : broadcast && broadcast.getContext() - return props.children({ form: formApi, type, state }) + return props.children(useFormSpy(props)) } else { return props.children } diff --git a/packages/react/src/hooks/useForm.ts b/packages/react/src/hooks/useForm.ts index 64d4fd78188..84798f88d9f 100644 --- a/packages/react/src/hooks/useForm.ts +++ b/packages/react/src/hooks/useForm.ts @@ -77,7 +77,7 @@ export const useForm = < }) const lifecycles = [ new FormLifeCycle( - ({ type, payload }: { type: string; payload: IModel }) => { + ({ type, payload }) => { dispatch.lazy(type, () => { return isStateModel(payload) ? payload.getState() : payload }) diff --git a/packages/react/src/hooks/useFormEffects.ts b/packages/react/src/hooks/useFormEffects.ts new file mode 100644 index 00000000000..aeee34fb8f6 --- /dev/null +++ b/packages/react/src/hooks/useFormEffects.ts @@ -0,0 +1,25 @@ +import { useContext, useEffect } from 'react' +import { isStateModel, LifeCycleTypes } from '@uform/core' +import FormContext from '../context' +import { useEva } from 'react-eva' +import { IFormEffect } from '../types' +import { createFormEffects } from '../shared' + + +export function useFormEffects(effects: IFormEffect) { + const form = useContext(FormContext) + const { dispatch } = useEva({ + effects: createFormEffects(effects, form) + }) + useEffect(() => { + const subscribeId = form.subscribe(({ type, payload }) => { + dispatch.lazy(type, () => { + return isStateModel(payload) ? payload.getState() : payload + }) + }) + dispatch(LifeCycleTypes.ON_FORM_INIT, form.getFormState()) + return () => { + form.unsubscribe(subscribeId) + } + }, []) +} diff --git a/packages/react/src/hooks/useFormSpy.ts b/packages/react/src/hooks/useFormSpy.ts new file mode 100644 index 00000000000..1c2000f194f --- /dev/null +++ b/packages/react/src/hooks/useFormSpy.ts @@ -0,0 +1,67 @@ +import { + useContext, + useMemo, + useRef, + useEffect, + useCallback, + useState, + useReducer +} from 'react' +import { FormHeartSubscriber, LifeCycleTypes } from '@uform/core' +import { isStr, FormPath, isArr } from '@uform/shared' +import { IFormSpyProps } from '../types' +import FormContext, { BroadcastContext } from '../context' + +export const useFormSpy = (props: IFormSpyProps) => { + const broadcast = useContext(BroadcastContext) + const form = useContext(FormContext) + const initializedRef = useRef(false) + const subscriberId = useRef() + const [type, setType] = useState(LifeCycleTypes.ON_FORM_INIT) + const [state, dispatch] = useReducer( + (state, action) => props.reducer(state, action, form), + {} + ) + const subscriber = useCallback(({ type, payload }) => { + if (initializedRef.current) return + setTimeout(() => { + if (isStr(props.selector) && FormPath.parse(props.selector).match(type)) { + setType(type) + dispatch({ + type, + payload + }) + } else if (isArr(props.selector) && props.selector.indexOf(type) > -1) { + setType(type) + dispatch({ + type, + payload + }) + } + }) + }, []) + useMemo(() => { + initializedRef.current = true + if (form) { + subscriberId.current = form.subscribe(subscriber) + } else if (broadcast) { + subscriberId.current = broadcast.subscribe(subscriber) + } + initializedRef.current = false + }, []) + useEffect(() => { + return () => { + if (form) { + form.unsubscribe(subscriberId.current) + } else if (broadcast) { + broadcast.unsubscribe(subscriberId.current) + } + } + }, []) + const formApi = form ? form : broadcast && broadcast.getContext() + return { + form: formApi, + type, + state + } +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 7427cdcb193..df70b023a5d 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -14,6 +14,7 @@ export * from './components/FormConsumer' export * from './hooks/useForm' export * from './hooks/useField' export * from './hooks/useVirtualField' +export * from './hooks/useFormEffects' export * from './types' export { diff --git a/packages/react/src/shared.ts b/packages/react/src/shared.ts index b2c65bbf607..0bde589128a 100644 --- a/packages/react/src/shared.ts +++ b/packages/react/src/shared.ts @@ -166,7 +166,7 @@ export const env = { export const [raf, caf] = getScheduler() -export const createFormEffects = ( +export const createFormEffects = ( effects: IFormEffect | null, actions: Actions ) => { diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 6a92e9fe1fe..95f243d7ca9 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -14,7 +14,7 @@ import { } from '@uform/core' import { FormPathPattern } from '@uform/shared' import { Observable } from 'rxjs/internal/Observable' -export interface IFormEffect { +export interface IFormEffect { ( selector: IFormExtendsEffectSelector, actions: Actions @@ -30,14 +30,14 @@ export interface IFormEffectSelector { export type IFormExtendsEffectSelector< Payload = any, - Actions = {} + Actions = any > = IFormEffectSelector & Actions export interface IFormProps< Value = {}, DefaultValue = {}, EffectPayload = any, - EffectActions = {} + EffectActions = any > { value?: Value defaultValue?: DefaultValue diff --git a/packages/validator/src/types.ts b/packages/validator/src/types.ts index e7f2fcf4a8b..7d91d809f2c 100644 --- a/packages/validator/src/types.ts +++ b/packages/validator/src/types.ts @@ -53,7 +53,7 @@ export interface ValidateDescription { export type ValidateRules = ValidateDescription[] export type ValidateArrayRules = Array< -InternalFormats | CustomValidator | ValidateDescription + InternalFormats | CustomValidator | ValidateDescription > export type ValidatePatternRules =