preface
Schema => form. Schema is a JSON object. Let’s take a look at ali’s Form-Render library (a react form library).
{
"type": "object"."properties": {
"count": {
// Base attribute
"title": "Code"."type": "string"."min": 6.// rules (add verification information)
"rules": [{"pattern": "^[A-Za-z0-9]+$"."message": "Only letters and numbers are allowed"}].// props (props for antd)
"props": {
"allowClear": true}}}}Copy the code
Although the official website says that this JSON follows the international JSON Schema specification, I think this specification is too troublesome. I define the Schema according to the usage habit of Ant-Design, which is more in line with the usage habit. For example, Ant uses components in this way. Vue’s elementUI seems to be used in a similar way:
<Form here you can define the properties of the Form ><Form.Item name="account"You can define it hereForm.ItemThe properties of >
<InputHere you can define the properties of the form component
</Form.Item>
<Form.Item name="password">
<InputHere you can define the properties of the form component
</Form.Item>
</Form>
Copy the code
So the corresponding schema definition is similar to that used by the component:
{
// Equivalent to the attributes defined on the Form component above
formOptions: {// Retain the field value when the field is deleted
// preserve:true
},
formItems: [ // This is equivalent to all form.item components in the Form component
{
// This attribute is so important that it is required. It is equivalent to the identifier of each component, which can be an array
// An array can be a string or a number, which defines nested objects, nested arrays
name: 'account'.// value: ", where an initial value can be defined or not set
options: { // Equivalent to the form.item component property
// hidden: xx hides the form logic
},
// The layout properties, which are then used to control the layout of the component
// The layout attribute is the UI attribute that sets a row or column of the form, the width and height of the form label, etc
// You can see that we decoupled the UI properties from the logical form properties
// This attribute is not covered in this article
layoutOptions: { // Leave the layout component properties to the and face
// label: xx
},
// Component name, where 'input' will be converted to the Ant Input component
// There will be a map to convert strings to components
Wiget: 'input'.WigetOptions: {}, // Form component properties},],}Copy the code
-
{a: {b: ‘change here’}} {b: ‘change here’}}
-
{a: [undefined, ‘change here’]},
With this name setting, you can satisfy almost all formatting requirements for the form object values.
So we expect the form kernel to use something like this:
/ / define schema
const schema = {
formItems: [{name: 'account'.value: 1.options: {},Wiget: 'input'}}]const Demo = () = > {
const [form] = useForm({ schema });
return <Form form={form} />;
};
ReactDOM.render(
<Demo />.document.getElementById('app'));Copy the code
The above configuration renders an Input component, and the form provides a set of methods just like Ant does. GetFiledsValue, setFieldsValue, and so on make our use of Ant almost seamless.
Some people say, well, you can just use Ant, but you know,
However, some ant properties are functions that cannot be attached to JSON because json. stringify filters out functions. Therefore, many Ant properties that need to be attached to functions are not supported internally, such as the onFinish event, shouldUpdate method, etc
In addition, if our business needs a lot of custom requirements for a product, may involve changing the underlying form library, you need to develop a set of their own, so it is not good to change ant form, it is better to develop a set of their own
Without further ado, start coding!
Our general architecture is as follows (without writing the Form renderer (i.e. visualizing the drag and drop form)) :
Let’s set up the FormStore first. After all, it is the main scheduling component. To save time, we will not use TS, but js first.
// Use some of the tools provided by the Ramda library
import { path, clone, assocPath, merge,
type, equals } from 'ramda'
// This identifier is meant to notify all components of updates
const ALL = Symbol(The '*');
// This identifier is used to identify formStore
const FORM_SIGN = Symbol('formStoreSign');
// Export the identifier of the internal method
const INNER_HOOKS_SIGN = Symbol("innerHooks");
class FormStore {
// The parameters are initialized values
constructor(initialValue) {
// There is a resetValue, which is the method to reset the form, so keep it
this.initialValue = initialValue
// values Stores the values of the form
// The clone function is provided by Ramda
this.values = initialValue ? clone(initialValue) : {}
// Event collector, where subscribed events (functions) are stored
this.listeners = []
}
// Get the form value
getFieldValues = (name) = > {
return clone(this.values)
}
/ / the name here is not necessarily a string, it is possible that an array of strings, or an array subscript (string | string | number [])
// For example: name = ['a', 'b'] specifies the value[a][b] attribute of the form value object
getFieldValue = (name) = > {
if (typeofname ! = ='string'&&!Array.isArray(name)) {
throw new Error(` parameters${name}It needs to be a string or an array ')}// strToArray is defined below, which is the function that converts to an array
// Since the first argument of path must be an array, name may be a string
// path:
// path(['a', 'b'], {a: {b: 2}}) => 2
return path(strToArray(name), this.values)
}
// Method to set a single value of the form
setFieldValue = (name, value) = > {
const newName = strToArray(name)
// assocPath is the function that Ramda uses to set the value of an object
// assocPath:
// assocPath(['a', 'b', 'c'], 42, {a: {b: {c: 0}}})
// => {a: {b: {c: 42}}}
this.values = assocPath(newName, value, this.values)
// Publish events. Our events are identified by a name string
this.notify(name)
}
// A method to set multiple values for the form
setFieldsValue = (value) = > {
// If value is not an object (such as {}), this function does not execute
if(R.type(value) ! = ='Object') return
// The pickPath method resolves an object into a path
// pickPaths({a: 2, c: 3 })
// => [[{path: 'a', value: 2 }], [{ path: 'c', vlaue: 3 }]]
const paths = pickPaths(value)
paths.forEach((item) = > {
this.values = assocPath(item.path, item.value, this.values)
})
this.notify(ALL)
}
// Notifies one or all components to update form values
notify = (name) = > {
for (const listener of this.listeners) listener(name)
}
// A function that returns a function that clears the event subscribed to by the component when it is unloaded
subscribe = (listener) = > {
this.listeners.push(listener)
return () = > {
// Unsubscribe
const index = this.listeners.indexOf(listener)
if (index > -1) this.listeners.splice(index, 1)}}// Expose the formStore's internal methods to the outside, preventing them from accessing the formStore directly
getFormExport = (schema) = > {
return {
signType: FORM_SIGN,
getFieldValue: this.getFieldValue,
setFieldValue: this.setFieldValue,
setFieldsValue: this.setFieldsValue,
isSamePath: this.isSamePath,
getInnerHooks: this.getInnerHooks(schema),
}
}
// Check whether the two paths are equal
// equals([1, 2, 3], [1, 2, 3]); //=> true
isSamePath = (path1, path2) = > {
if(type(path1) ! = ='Array'|| type(path2) ! = ='Array') {
throw new Error(The parameters of the isSamePath function must be an array)}return equals(path1, path2) //=> true
}
// Get an internal method that is only used in internal components
getInnerHooks = schema= > sign= > {
if(sign === INNER_HOOKS_SIGN) {
return {
getFieldValue: this.getFieldValue,
setFieldValue: this.setFieldValue,
setFieldsValue: this.setFieldsValue,
isSamePath: this.isSamePath,
subscribe: this.subscribe,
notify: this.notify,
schema
}
}
console.warn('Externally disallow use of getInnerHooks');
return null; }}// Here are the utility functions
// This function is to convert a string into an array
const strToArray = (name) = > {
if (typeof name === 'string') return [name]
if (Array.isArray(name)) return name
throw new Error(`${name}The argument must be an array or string ')}// This function is used to extract the path of the object.
// pickPaths({a: 2, c: 3 })
// => [[{path: 'a', value: 2 }], [{ path: 'c', vlaue: 3 }]]
// pickPaths({ b:[ { a : 1 } ] )
// => [[ { path: [ "b", 0, "a"], value: 1 }]]
function pickPaths(root, collects = [], resultPaths = []) {
function dfs(root, collects) {
if (type(root) === 'Object') {
return Object.keys(root).map((item) = > {
const newCollect = clone(collects)
newCollect.push(item)
return dfs(root[item], newCollect)
})
}
if (type(root) === 'Array') {
return root.map((item, index) = > {
const newCollect = clone(collects)
newCollect.push(index)
return dfs(item, newCollect)
})
}
return resultPaths.push({ path: collects, value: root })
}
dfs(root, collects)
return resultPaths
}
Copy the code
Some things to note above:
this.notify(name)
In thename
Which can be an array or a string, such as['account', 'CCB']
.['account', 0]
Ok, let’s see what we can do with the FormStore component we just wrote
const formStore = new FormStore({ account: [{name: 'CCB'}}]); formStore.setFieldsValue({account: [{name: 'xiaoming' }, 123]});/ / print formStore. Value
// => { account: [ { name: 123 }, 123 ] }
console.log(formStore.values)
formStore.setFieldValue([ 'account'.1.'age'].10)
// => { account: [ { name: 123 }, age: 10 ] }
console.log(formStore.values)
Copy the code
-
As you can see above, the path resolution module is very important to us, so I will separate it out as a service in the future. We also need to separate these important modules into service classes, or hooks, in our business code.
-
And then I’m going to write it as a function and then I’m going to refactor the function. This is just for those who don’t know functions and don’t know how to use the Ramda library.
Let’s try the formStore registration function again
const formStore = new FormStore({ account: [{ name: "CCB"}}]); formStore.subscribe((name) = >{
if(name === ALL || formStore.isSamePath(name, [ 'account'.0.'name' ])){
console.log('Path match [account, 0, name]')}})// formStore.setFieldsValue({ account: [{ name: "A" }] })
// => Print path match [account, 0, name]
formStore.setFieldValue([ 'account'.0.'name'].'A')
Copy the code
Ok, so this module is supposed to be my test case and I need to use the test library, so I’m not going to use it here, so you’re welcome to check out my upcoming JEST Primer in a couple of days. (Mainly to promote this, keep learning, great 😄)
The subscribe and notify publish events above are a simple publish subscribe model. To put it simply, like the source code of Redux, the subscribed event is to put the subscribed function into an array, and the published event is to take the function out of the array and call it again.
Now what does the Form component look like
import { INNER_HOOKS_SIGN } form './utils';
import { FormContext } from './context';
// Form component mapping
const WigetsMap = {
input: Input
}
function Form(props) {
if(props.form.signType ! == FORM_SIGN)throw new Error('Form type error');
// In this case, the form is the object generated by the useForm
// This object is actually exported by the formStore's exportForm method
// signType indicates the object exported by our formStore.exportForm method
if(form.signType ! == FORM_SIGN)throw new Error('Form type error');
// External form
const{ form, ... restProps } = props;// Get the internal functions exported to fromStore by the getInnerHooks method
const innerForm = form.getInnerHooks(INNER_HOOKS_SIGN);
return (
<form
{. restProps}
onSubmit={(event)= >{ event.preventDefault(); event.stopPropagation(); // the submit method provided by formInstance is called // innerForm.submit(); }} > {/* formInstance as global context pass */}<FormContext.Provider value={innerForm}>{/* useForm */} {innerform.schema? .formItem? .map((item, index) => {return ({/* formItem attribute passed below */})<FormItem key={index} name={item.name} {. item.options} >{/* WigetsMap[item.wiget]?<item.Wiget {. item.WigetOptions} / > : null}
</FormItem>
);
})}
</FormContext.Provider>
</form>
);
}
Copy the code
The main function of the Form component is to pass the innerForm to the form. Item component.
// Get an internal method that is only used in internal components
getInnerHooks = schema= > sign= > {
if(sign === INNER_HOOKS_SIGN) {
return {
getFieldValue: this.getFieldValue,
setFieldValue: this.setFieldValue,
setFieldsValue: this.setFieldsValue,
isSamePath: this.isSamePath,
subscribe: this.subscribe,
notify: this.notify,
schema,
}
}
console.warn('Externally disallow use of getInnerHooks');
return null;
}
Copy the code
As you can see, the exported object must be obtained by passing in the INNER_HOOKS_SIGN identifier. INNER_HOOKS_SIGN is internal to the component and is not available to outside developers using UseForms, so the trace object only serves within the component.
The purpose is to get and set properties, subscribe to, and publish events.
So FormContext is the context, so what does this file look like
import React from 'react'
const warningFunc: any = () = > {
console.warn(
'Please make sure to call the getInternalHooks correctly'
);
};
export const FormContext = React.createContext({
getInnerHooks: () = > {
return {
getFieldValue: warningFunc,
setFieldValue: warningFunc,
setFieldsValue: warningFunc,
isSamePath: warningFunc,
subscribe: warningFunc,
notify: warningFunc, }; }});Copy the code
The default arguments are the getInnerHooks methods we define in the FormStore, which make sure they have the same names as the exported properties of the two functions. This is where typescript becomes important.
You’re welcome to check out my blog for a typescript primer
Next, let’s take a look at how the external useForm is used
const useForm = (props) = > {
// Check whether the schema conforms to the specification. If not, an error is reported
checkSchema(props.schema);
// Save the value of the schema
const schemaRef = useRef(props.schema);
// Save the reference object for the form
const formRef = useRef();
// Initialize the formStore for the first render
if(! formRef.current) { formRef.current =new FormStore(setSchemaToValues(props.schema)).getFormExport(props.schema);
}
// If the schema changes, the formStore is regenerated
if (JSON.stringify(props.schema) ! = =JSON.stringify(schemaRef.current)) {
schemaRef.current = props.schema;
formRef.current = new FormStore(setSchemaToValues(props.schema)).getFormExport(props.schema);
}
return [formRef.current];
};
// Utility functions
function checkSchema(schema) {
ifElse(
isArrayAndNotNilArray,
forEach(checkFormItems),
() = > { throw new Error('formItems property of schema need to an Array') }
)(path(['formItems'], schema));
}
function checkFormItems(item) {
if(! all(equals(true))([isObject(item), isNameType(item.name)])) {
throw new Error('please check whether formItems field of schema meet the specifications'); }}Copy the code
The only thing worth mentioning above is the use of useRef, which can be used as a singleton, as follows:
const a = useRef();
if(! a.current)return 1;
return a.current
Copy the code
The first time I assign a value of 1, if it exists it’s always going to be 1, it’s not going to change
Next, let’s look at the code for the form.item component
import React, { cloneElement, useEffect, useContext, useState } from 'react'
import { FormContext } from './context';
import { ALL } form './utils';
function FormItem(props: any) {
const { name, children } = props;
// This is to get the store Context, which will be explained later
const innerForm = useContext(FormContext);
// If our schema initialization has a value, it will be passed here
const [value, setValue] = useState(name && store ? innerForm.getFieldValue(name) : undefined);
useEffect(() = > {
if(! name || ! innerForm)return;
// If n is ALL, everyone should be updated
// Update the form separately
// Let n be the same as name
return innerForm.subscribe((n) = > {
if (n === ALL || (Array.isArray(n) && innerForm.isSamePath(n, name))) { setValue(store.getFieldValue(name)); }}); }, [name, innerForm]);return cloneElement(children, {
value,
onChange: (e) = >{ innerForm.setFieldValue(name, e.target.value); }}); }Copy the code
Note that the cloneElement wraps the children around its value and onChange methods, such as:
<Form.Item name="account"Here we can define the property > of form.item<InputHere you can define the properties of the form component
</Form.Item>
Copy the code
The Input here will automatically receive the value and onChange properties and methods
- And the onChange method calls the setFieldValue method of the innerForm
- This method will then call the method registered in useEffect for formItem, achieving the goal of updating the component individually without a global refresh
This article is completely interested in their own low code form platform form implementation principle, I checked some information, wrote a demo can run through, but the principle is no problem, there may be bugs inside, welcome to comment area, weekend is still writing articles, see in the hard part, brother point a like, 😀
The following code is refactored using the Ramda library. I ran it by myself and found no problems. The follow-up plan of this paper is as follows:
- Join the typescript
- Added jEST test function functionality
- Added a visual form generation interface
import ReactDOM from 'react-dom';
import React, { useState, useContext, useEffect, useRef, cloneElement } from 'react';
import { path, clone, assocPath, type, equals, pipe, __, all, when, ifElse, F, forEach, reduce } from 'ramda';
import { Input } from 'antd';
// Constant module
const ALL = Symbol(The '*');
const FORM_SIGN = Symbol('formStoreSign');
const INNER_HOOKS_SIGN = Symbol('innerHooks');
// Utility function module
function isString(name) {
return type(name) === 'String';
}
function isArray(name) {
return type(name) === 'Array';
}
function isArrayAndNotNilArray(name) {
if(type(name) ! = ='Array') return false;
return name.length === 0 ? false : true;
}
function isUndefined(name) {
return type(name) === 'Undefined';
}
function isObject(name) {
return type(name) === 'Object';
}
function strToArray(name) {
if (isString(name)) return [name];
if (isArray(name)) return name;
throw new Error(`${name} params need to an Array or String`);
}
function isStrOrArray(name) {
return isString(name) || isArray(name);
}
const returnNameOrTrue = returnName= > name= > {
return returnName ? name : true;
}
function isNameType(name, returnName = false) {
return ifElse(
isStrOrArray,
returnNameOrTrue(returnName),
F,
)(name)
}
function checkSchema(schema) {
ifElse(
isArrayAndNotNilArray,
forEach(checkFormItems),
() = > { throw new Error('formItems property of schema need to an Array') }
)(path(['formItems'], schema));
}
function checkFormItems(item) {
if(! all(equals(true))([isObject(item), isNameType(item.name)])) {
throw new Error('please check whether formItems field of schema meet the specifications'); }}function setFormReduce(acc, item) {
if(! isUndefined(item.value)) { acc = assocPath(strToArray(item.name), item.value, acc) }return acc;
}
function setSchemaToValues(initialSchema) {
return pipe(
path(['formItems']),
reduce(setFormReduce, {})
)(initialSchema)
}
const warningFunc = () = > {
console.warn(
'Please make sure to call the getInternalHooks correctly'
);
};
export const FormContext = React.createContext({
getInnerHooks: () = > {
return {
getFieldsValue: warningFunc,
getFieldValue: warningFunc,
setFieldValue: warningFunc,
setFieldsValue: warningFunc,
isSamePath: warningFunc,
subscribe: warningFunc,
notify: warningFunc }; }});function pickPaths(root, collects = [], resultPaths = []) {
function dfs(root, collects) {
if (isObject(root)) {
return dfsObj(root)
}
if (isArray(root)) {
return dfsArr(root)
}
return resultPaths.push({ path: collects, value: root })
}
function dfsObj(root) {
Object.keys(root).map((item) = > {
const newCollect = clone(collects)
newCollect.push(item)
return dfs(root[item], newCollect)
})
}
function dfsArr(root) {
root.map((item, index) = > {
const newCollect = clone(collects)
newCollect.push(index)
return dfs(item, newCollect)
})
}
dfs(root, collects)
return resultPaths
}
class FormStore {
constructor(initialValue) {
this.initialValue = initialValue
this.values = initialValue ? clone(initialValue) : {}
this.listeners = []
}
getFieldsValue = () = > {
return clone(this.values)
}
getFieldValue = (name) = > {
return ifElse(
isNameType,
pipe(strToArray, path(__, this.values)),
F,
)(name, true)
}
setFieldValue = (name, value) = > {
pipe(
strToArray,
(newName) = > {
this.values = assocPath(newName, value, this.values);
this.notify(name);
},
)(name)
}
setFieldsValue = (value) = > {
return when(
isObject,
pipe(pickPaths, forEach((item) = > {
this.values = assocPath(item.path, item.value, this.values)
}), () = > this.notify(ALL)),
)(value)
}
notify = (name) = > {
for (const listener of this.listeners) listener(name)
}
subscribe = (listener) = > {
this.listeners.push(listener)
return () = > {
const index = this.listeners.indexOf(listener)
if (index > -1) this.listeners.splice(index, 1)
}
}
getFormExport = (schema) = > {
return {
signType: FORM_SIGN,
getFieldValue: this.getFieldValue,
setFieldValue: this.setFieldValue,
setFieldsValue: this.setFieldsValue,
isSamePath: this.isSamePath,
getFieldsValue: this.getFieldsValue,
getInnerHooks: this.getInnerHooks(schema)
}
}
isSamePath = (path1, path2) = > {
if(type(path1) ! = ='Array'|| type(path2) ! = ='Array') {
throw new Error('All arguments to the isSamePath function need an array')}return equals(path1, path2)
}
getInnerHooks = schema= > sign= > {
if (sign === INNER_HOOKS_SIGN) {
return {
getFieldsValue: this.getFieldsValue,
getFieldValue: this.getFieldValue,
setFieldValue: this.setFieldValue,
setFieldsValue: this.setFieldsValue,
isSamePath: this.isSamePath,
subscribe: this.subscribe,
notify: this.notify,
schema
}
}
console.warn('Externally disallow use of getInnerHooks');
return null; }}const useForm = (props) = > {
checkSchema(props.schema);
const schemaRef = useRef(props.schema);
const formRef = useRef();
if(! formRef.current) { formRef.current =new FormStore(setSchemaToValues(props.schema)).getFormExport(props.schema);
}
if (JSON.stringify(props.schema) ! = =JSON.stringify(schemaRef.current)) {
schemaRef.current = props.schema;
formRef.current = new FormStore(setSchemaToValues(props.schema)).getFormExport(props.schema);
}
return [formRef.current];
};
function FormItem(props) {
const { name, children } = props;
// This is to get the store Context, which will be explained later
const innerForm = useContext(FormContext);
// If our new FormStore has
const [value, setValue] = useState(name && innerForm ? innerForm.getFieldValue(name) : undefined);
useEffect(() = > {
if(! name || ! innerForm)return;
return innerForm.subscribe((n) = > {
if (n === ALL || (Array.isArray(n) && innerForm.isSamePath(n, strToArray(name)))) { setValue(innerForm.getFieldValue(name)); }}); }, [name, innerForm, innerForm]);return cloneElement(children, {
value,
onChange: (e) = >{ innerForm.setFieldValue(name, e.target.value); }}); }const WigetsMap = {
input: Input
}
function Form(props) {
if(props.form.signType ! == FORM_SIGN)throw new Error('Form type error');
const{ form, ... restProps } = props;const innerForm = form.getInnerHooks(INNER_HOOKS_SIGN);
return (
<form
{. restProps}
onSubmit={(event)= > {
event.preventDefault();
event.stopPropagation();
}}
>
<FormContext.Provider value={innerForm}>
{innerForm.schema.formItems.map((item, index) => {
return (
<FormItem key={index}
name={item.name}
{. item.options}
>
{WigetsMap[item.Wiget] ? <item.Wiget {. item.WigetOptions} / > : null}
</FormItem>
);
})}
</FormContext.Provider>
</form >
);
}
const schema = {
formItems: [
{
name: 'account',
value: 1,
options: {
},
Wiget: 'input'
}
]
}
const Demo = () => {
const [form] = useForm({ schema });
window.f = form;
return <Form form={form} />;
};
ReactDOM.render(
<Demo />,
document.getElementById('app')
);
Copy the code