Formily JSON Schema 渲染流程及案例浅析

作者:漆杰

前言

Formily 是基于 JSON Schema 的强大表单解决方案,它提供了灵活的表单渲染和数据绑定能力。本文将探讨 Formily JSON Schema 渲染机制和实现细节,帮助我们更好地理解和应用该技术。

介绍

Formily 是一个基于 React 的高性能表单解决方案,它提供了 JSON Schema、JSX Schema、纯 JSX 三种开发模式,来描述表单结构和验证规则。

JSON Schema 是一个社区推动的 JSON 文件协议,用于规范 JSON 文件内容。它提供了丰富的属性和约束,可以完整地描述一个表单的各个字段和相关规则。它与平台无关,可以描述任意复杂的数据结构,相比 JSX,JSON Schema 的描述格式更加紧凑,可读性更好。JSON Schema 在 JSON 的格式上,加入了一些标准化属性,用于描述结构化数据。

基本概念

  • 解析 JSON Schema:Formily 解析传入的 JSON Schema 对象,识别其中的字段类型、属性、校验规则等信息。它会根据字段类型选择相应的表单字段组件,并将属性和校验规则应用到相应的组件上。

    js 复制代码
    const rule = (value) => {
      if (!value?.length) {
        return '请至少新增一个';
      }
      return true;
    };
    
    const formSchema = {
      type: 'array',
      title: '明细信息',
      'x-decorator': 'FormItem',
      'x-component': 'ArrayTable',
      'x-validator': rule,
      // ...
    }
  • 构建表单树:Formily 根据解析后的 JSON Schema 数据构建表单树,表单树是一个组件树结构,每个节点对应一个表单字段或表单组件。

    js 复制代码
    import { createForm } from '@formily/core'
    import { createSchemaField } from '@formily/react-schema-renderer'
    
    const form = createForm()
    const SchemaField = createSchemaField({
      components: {
        // 定义各个表单组件的渲染方式
        FormItem,
        Input,
        Password,
        VerifyCode,
        // ...
      },
    })
    
    const formTree = (
      <Form form={form}>
          <SchemaField schema={formSchema} />
      </Form>;
    )
  • 表单数据绑定:Formily 通过双向绑定机制将表单数据与表单组件进行关联。当表单组件的值发生变化时,表单数据会自动更新;反之,当表单数据发生变化时,表单组件的值也会更新。这种双向绑定机制使得表单数据与界面保持同步,提供了便捷的数据操作能力。

  • 表单校验和验证:Formily 根据 JSON Schema 中定义的验证规则对表单数据进行校验和验证。它自动根据字段类型和属性进行验证,并提供实时的验证反馈。

  • 表单提交和数据处理:一旦用户完成表单填写,您可以使用 Formily 提供的 API 获取表单数据,并进行后续的处理,例如提交到服务器或进行其他业务逻辑操作。Formily 提供了丰富的功能和扩展性,支持自定义表单行为和数据处理逻辑。

渲染流程

在每次进行开发的时候,我们都会使用 createSchemaField 来声明一个 SchemaField 来装载我们写好的 Schema ,那么它是如何实现的呢? 部分实现如下:

js 复制代码
export function createSchemaField(options) {
    if (options === void 0) { options = {}; }
    function SchemaField(props) {
        var schema = Schema.isSchemaInstance(props.schema)
            ? props.schema
            : new Schema(__assign({ type: 'object' }, props.schema));
        var renderMarkup = function () {
            env.nonameId = 0;
            if (props.schema)
                return null;
            return render(React.createElement(SchemaMarkupContext.Provider, { value: schema }, props.children));
        };
        var renderChildren = function () {
            return React.createElement(RecursionField, __assign({}, props, { schema: schema }));
        };
        return (React.createElement(SchemaOptionsContext.Provider, { value: options },
            React.createElement(SchemaComponentsContext.Provider, { value: lazyMerge(options.components, props.components) },
                React.createElement(ExpressionScope, { value: lazyMerge(options.scope, props.scope) },
                    renderMarkup(),
                    renderChildren()))));
    }
    SchemaField.displayName = 'SchemaField';
    /// ...
    return SchemaField;
}

我们可以看到,代码相对比较简单,套了几层"壳子"之后,代码最终走向了 renderChildren 方法中的 RecursionField

那么 RecursionField 究竟是个什么东西?我们接着往下看:

js 复制代码
export var RecursionField = function (props) {
    var basePath = useBasePath(props);
    var fieldSchema = useMemo(function () { return new Schema(props.schema); }, [props.schema]);
    var fieldProps = useFieldProps(fieldSchema);
    var renderProperties = function (field) {
        if (props.onlyRenderSelf)
            return;
        var properties = Schema.getOrderProperties(fieldSchema);
        if (!properties.length)
            return;
        return (React.createElement(Fragment, null, properties.map(function (_a, index) {
            var item = _a.schema, name = _a.key;
            var base = (field === null || field === void 0 ? void 0 : field.address) || basePath;
            var schema = item;
            if (isFn(props.mapProperties)) {
                var mapped = props.mapProperties(item, name);
                if (mapped) {
                    schema = mapped;
                }
            }
            if (isFn(props.filterProperties)) {
                if (props.filterProperties(schema, name) === false) {
                    return null;
                }
            }
            return (React.createElement(RecursionField, { schema: schema, key: "".concat(index, "-").concat(name), name: name, basePath: base }));
        })));
    };
    var render = function () {
        if (!isValid(props.name))
            return renderProperties();
        if (fieldSchema.type === 'object') {
            if (props.onlyRenderProperties)
                return renderProperties();
            return (React.createElement(ObjectField, __assign({}, fieldProps, { name: props.name, basePath: basePath }), renderProperties));
        }
        else if (fieldSchema.type === 'array') {
            return (React.createElement(ArrayField, __assign({}, fieldProps, { name: props.name, basePath: basePath })));
        }
        else if (fieldSchema.type === 'void') {
            if (props.onlyRenderProperties)
                return renderProperties();
            return (React.createElement(VoidField, __assign({}, fieldProps, { name: props.name, basePath: basePath }), renderProperties));
        }
        return React.createElement(Field, __assign({}, fieldProps, { name: props.name, basePath: basePath }));
    };
    if (!fieldSchema)
        return React.createElement(Fragment, null);
    return (React.createElement(SchemaContext.Provider, { value: fieldSchema }, render()));
};

以上代码看似复杂,其实我们只需要关注两点:

  1. 在第26行,RecursionField 对自身进行了递归调用。其实,这里也就说明了 Formily 是如何获取 properties,然后根据 properties 的属性来进行递归渲染的了

  2. render 方法中,针对不同的 type 使用了不同的 Field 来进行渲染。

看似这么多个不同的 Field,其实他们都有个相同的特点,就是里面都渲染了一层 ReactiveField。就比如 ObjectField

js 复制代码
export var ObjectField = function (props) {
    var form = useForm();
    var parent = useField();
    var field = useAttach(form.createObjectField(__assign({ basePath: parent === null || parent === void 0 ? void 0 : parent.address }, props)));
    return (React.createElement(FieldContext.Provider, { value: field },
        React.createElement(ReactiveField, { field: field }, props.children)));
};
ObjectField.displayName = 'ObjectField';

那么,为什么要着重提到 ReactiveField,它有什么魔力呢?

其实ReactiveField 才是整个渲染流程里最最重要的核心,照惯例,先上源码。

具体实现如下:

js 复制代码
var ReactiveInternal = function (props) {
    var _a;
    var components = useContext(SchemaComponentsContext);
    if (!props.field) {
        return React.createElement(Fragment, null, renderChildren(props.children));
    }
    var field = props.field;
    var content = mergeChildren(renderChildren(props.children, field, field.form), (_a = field.content) !== null && _a !== void 0 ? _a : field.componentProps.children);
    if (field.display !== 'visible')
        return null;
    var getComponent = function (target) {
        var _a;
        return isValidComponent(target)
            ? target
            : (_a = FormPath.getIn(components, target)) !== null && _a !== void 0 ? _a : target;
    };
    var renderDecorator = function (children) {
        if (!field.decoratorType) {
            return React.createElement(Fragment, null, children);
        }
        return React.createElement(getComponent(field.decoratorType), toJS(field.decoratorProps), children);
    };
    var renderComponent = function () {
        var _a, _b, _c;
        if (!field.componentType)
            return content;
        // props 内容
        var value = !isVoidField(field) ? field.value : undefined;
        var onChange = !isVoidField(field)
            ? function () {
                var _a, _b;
                var args = [];
                for (var _i = 0; _i < arguments.length; _i++) {
                    args[_i] = arguments[_i];
                }
                field.onInput.apply(field, __spreadArray([], __read(args), false));
                (_b = (_a = field.componentProps) === null || _a === void 0 ? void 0 : _a.onChange) === null || _b === void 0 ? void 0 : _b.call.apply(_b, __spreadArray([_a], __read(args), false));
            }
            : (_a = field.componentProps) === null || _a === void 0 ? void 0 : _a.onChange;
        var onFocus = !isVoidField(field)
            ? function () {
                var _a, _b;
                var args = [];
                for (var _i = 0; _i < arguments.length; _i++) {
                    args[_i] = arguments[_i];
                }
                field.onFocus.apply(field, __spreadArray([], __read(args), false));
                (_b = (_a = field.componentProps) === null || _a === void 0 ? void 0 : _a.onFocus) === null || _b === void 0 ? void 0 : _b.call.apply(_b, __spreadArray([_a], __read(args), false));
            }
            : (_b = field.componentProps) === null || _b === void 0 ? void 0 : _b.onFocus;
        var onBlur = !isVoidField(field)
            ? function () {
                var _a, _b;
                var args = [];
                for (var _i = 0; _i < arguments.length; _i++) {
                    args[_i] = arguments[_i];
                }
                field.onBlur.apply(field, __spreadArray([], __read(args), false));
                (_b = (_a = field.componentProps) === null || _a === void 0 ? void 0 : _a.onBlur) === null || _b === void 0 ? void 0 : _b.call.apply(_b, __spreadArray([_a], __read(args), false));
            }
            : (_c = field.componentProps) === null || _c === void 0 ? void 0 : _c.onBlur;
        var disabled = !isVoidField(field)
            ? field.pattern === 'disabled' || field.pattern === 'readPretty'
            : undefined;
        var readOnly = !isVoidField(field)
            ? field.pattern === 'readOnly'
            : undefined;
        return React.createElement(getComponent(field.componentType), __assign(__assign({ disabled: disabled, readOnly: readOnly }, toJS(field.componentProps)), { value: value, onChange: onChange, onFocus: onFocus, onBlur: onBlur }), content);
    };
    return renderDecorator(renderComponent());
};
ReactiveInternal.displayName = 'ReactiveField';
export var ReactiveField = observer(ReactiveInternal, {
    forwardRef: true,
});

在组件的17行和23行的方法里,分别去获取了x-decoratorx-component 所对应的组件来进行渲染,两者稍有不同的是,x-componentprops 更为丰富。还有一个需要注意的细节点,在代码的第八行,获取到了所有子项,在25行的判断中,如果当前节点的组件类型为指定时,那么我们就认为他不是一个UI节点,那么就会直接返回它的子项进行后续的解析并渲染。最后,在代码的第70行,将 Component 放入了 Decorator 中,并将 reactive-react 提供的 observer 将该组件变成了响应式组件,这也就是解释了为什么 formily 能做到组件级别的精准刷新,因为他的响应式监听是最小粒度的。

实践

我们可以用以下代码简单构建一个表单:

js 复制代码
{
	name: {
		title: '姓名',
		type: 'string',
		'x-decorator': 'FormItem',
		'x-component': 'Input',
		'x-component-props': {
			placeholder: '请输入姓名',
		},
	},
	age: {
		title: '年龄',
		type: 'number',
		'x-decorator': 'FormItem',
		'x-component': 'Input',
		'x-component-props': {
			placeholder: '请输入年龄',
		},
	},
	gender: {
		title: '性别',
		type: 'number',
		'x-decorator': 'FormItem',
		'x-component': 'Radio.Group',
		enum: [{
				label: '男',
				value: 1,
			},
			{
				label: '女',
				value: 0,
			},
		],
	},
}

效果如下:

产品看了一眼:"太丑了!不要每个都占满一行。"于是我们又有了以下代码:

js 复制代码
{
    layout: {
        type: 'void',
        'x-component': 'Layout.Inline',
        'x-decorator': 'FormGrid',
        properties: {
            name: {
                title: '姓名',
                type: 'string',
                'x-decorator': 'FormItem',
                'x-component': 'Input',
                'x-component-props': {
                    placeholder: '请输入姓名',
                },
            },
            age: {
                title: '年龄',
                type: 'number',
                'x-decorator': 'FormItem',
                'x-component': 'Input',
                'x-component-props': {
                    placeholder: '请输入年龄',
                },
            },
            gender: {
                title: '性别',
                type: 'number',
                'x-decorator': 'FormItem',
                'x-component': 'Radio.Group',
                enum: [{
                            label: '男',
                            value: 1,
                        },
                        {
                            label: '女',
                            value: 0,
                        },
                ],
            },
        },
    },
}

在之前代码的基础上,我们在外层套了一个 x-component 为 Layout.Inline 的节点,效果如下:

这样确实更顺眼一点了。

可是,过了一会儿,后端又来了:我想要把数据的结构从

js 复制代码
{
    name: '张三',
    age: 18,
    gender: 0
}

改成

js 复制代码
{
    {
        nickname: '张三',
        age: 18
    },
    gender: 0
}

行吧...,咱也不敢问为什么要这样改。于是我们继续:

js 复制代码
{
    layout: {
        type: 'void',
        'x-component': 'Layout.Inline',
        'x-decorator': 'FormGrid',
        properties: {
        obj: {
            type: 'object',
            properties: {
                name: {
                    title: '姓名',
                    type: 'string',
                    'x-decorator': 'FormItem',
                    'x-component': 'Input',
                    'x-component-props': {
                        placeholder: '请输入姓名',
                    },
                },
                age: {
                    title: '年龄',
                    type: 'number',
                    'x-decorator': 'FormItem',
                    'x-component': 'Input',
                    'x-component-props': {
                        placeholder: '请输入年龄',
                    },
                },
            },
        },
        gender: {
            title: '性别',
            type: 'number',
            'x-decorator': 'FormItem',
            'x-component': 'Radio.Group',
            enum: [{
                    label: '男',
                    value: 1,
                },
                {
                    label: '女',
                    value: 0,
                },
            ],
        },
        },
    },
}

这次我们在 name 和 age 的外层套了一个名为 obj 的节点,并将 type 设置为 object,并将 name 节点的名称改成了 nickname。验证后,我们发现,表单的数据的结构终于符合预期了。

没多久,产品又双叒叕跑过来了:"我希望性别选择为女性时,不用输入年龄,毕竟女孩的年龄需要保密。"

我:"???"

于是,我们继续作以下改动:

js 复制代码
{
    layout: {
        type: 'void',
        'x-component': 'Layout.Inline',
        'x-decorator': 'FormGrid',
        properties: {
            obj: {
                type: 'object',
                properties: {
                    name: {
                        title: '姓名',
                        type: 'string',
                        'x-decorator': 'FormItem',
                        'x-component': 'Input',
                        'x-component-props': {
                            placeholder: '请输入姓名',
                        },
                    },
                    age: {
                        title: '年龄',
                        type: 'number',
                        'x-decorator': 'FormItem',
                        'x-component': 'Input',
                        'x-component-props': {
                            placeholder: '请输入年龄',
                        },
                        'x-reactions': {
                            dependencies: ['gender'],
                            fulfill: {
                                schema: {
                                    'x-disabled': '{{  $deps[0] === 0 }}',
                                },
                            },
                        },
                    },
                },
            },
            gender: {
                title: '性别',
                type: 'number',
                'x-decorator': 'FormItem',
                'x-component': 'Radio.Group',
                enum: [{
                        label: '男',
                        value: 1,
                    },
                    {
                        label: '女',
                        value: 0,
                    },
                ],
            },
        },
    },
}

相较于以前的代码,我们在 age 节点下加入了 x-reactions 属性,使其能够被 gender 的值影响,来动态变换 disable 的值。

终于,在磕磕绊绊中,我们完成了此次需求。

其实,以上例子虽然简单,但也从侧面反应出,Formily 基于 JSON Schema 的强大表单解决能力。无论是应对表单数据结构的调整,数据字段名的更改,还是组件样式更改和组件联动,都能轻而易举地通过简单配置来化解。

不过在日常项目中,我们所需要经历的场景,大多都复杂得多。

复杂场景

需求概述

如上图,业务对象编码是一个下拉框 Select ,它的值改变时,调用接口,将接口返回的数据,应用到其他三个表单数据中。

业务对象名称同理,在它的值改变时,也会调用接口,与业务对象编码行为一致,它会反过来影响业务对象编码的值。

需求分析

既然是表单联动,我们首先会想到 x-reactions 属性,我们可以用它来达到组件联动的目的。于是我们心里有了以下的大致结构:

js 复制代码
{
    businessObjectCode: {
        type: 'string',
        title: '业务对象编码',
        'x-decorator': 'FormItem',
        'x-component': 'Select',
        'x-component-props': {
            placeholder: '请选择业务对象编码',
        },
        required: true,
        'x-reactions': {
            dependencies: ['xxx'],
            fulfill: {
                schema: {
                    'xxx': '{{ $deps[0] }}',
                },
            },
        },
    },
    businessObjectName: {
        type: 'string',
        title: '业务对象名称',
        'x-decorator': 'FormItem',
        'x-component': 'Select',
        'x-component-props': {
                placeholder: '请选择业务对象名称',
        },
        required: true,
        'x-reactions': {
            dependencies: ['xxx'],
            fulfill: {
                schema: {
                    'xxx': '{{ $deps[0] }}',
                },
            },
        },
    },
    ownerName: {
        type: 'string',
        title: '货主',
        'x-decorator': 'FormItem',
        'x-component': 'Input',
        required: true,
        'x-disabled': true,
        'x-reactions': {
            dependencies: ['xxx'],
            fulfill: {
                schema: {
                    'xxx': '{{ $deps[0] }}',
                },
            },
        },
    },
    businessObjectType: {
        type: 'string',
        title: '业务对象类型',
        'x-decorator': 'FormItem',
        'x-component': 'Input',
        required: true,
        'x-disabled': true,
        'x-reactions': {
            dependencies: ['xxx'],
            fulfill: {
                schema: {
                    'xxx': '{{ $deps[0] }}',
                },
            },
        },
    },
}

我们为每个节点都添加了 x-reactions 属性,使其能够被影响。但是我们的需求并不是简单的通过组件的值去影响其他组件,而是组件的值请求接口后再去影响其他组件。如下图:

以业务对象编码为例

graph TD Start --> 业务对象编码下拉选择 --> 通过选择到的业务对象编码请求接口内容 --> 将返回的接口数据进行解析 --> 将解析出的数据放如表单中 --> Stop

那么,由此不难想到,要有一个地方放接口请求的逻辑,它能够随着下拉框的数据改变而进行数据请求。所以,简单的 Select 已经不能满足我们的场景了,我们需要自定义一个组件,把这些逻辑都封装到其中。伪代码大致如下:

js 复制代码
const BusinessObjectSelect: React.FunctionComponent<IBusinessObjectSelectProps> = ({
  params,
  onChange,
}) => {
  const handleChange = () => {
    // 处理接口入参
    const requestParams = transformParams(params);
    
    const { response } = service.fetchBusinessObjectData(requestParams);
    
    // 处理接口数据
    const data = transformResponse(response);
    // 将处理完的数据放入onChange
    onChange(data);
  };

  return (
    <Select
      {...props}
      labelInValue
      value={value}
      onSearch={handleSearch}
      onChange={handleChange}
      options={data}
      defaultActiveFirstOption={false}
      showArrow={false}
      filterOption={false}
    />
  );
};

将组件注册完之后(不懂如何注册详询官方文档),再将 Schema 结构中的组件进行替换,并分别对其依赖属性进行修改。

js 复制代码
businessObjectCode: {
    type: 'string',
    title: '业务对象编码',
    'x-decorator': 'FormItem',
    'x-component': 'Select',
    'x-component-props': {
        placeholder: '请选择业务对象编码',
    },
    required: true,
        'x-reactions': {
            dependencies: ['businessObjectName'],
            fulfill: {
                schema: {
                    'x-component-props.params': '{{ $deps[0] }}',
                },
            },
        },
    },
    businessObjectName: {
        type: 'string',
        title: '业务对象名称',
        'x-decorator': 'FormItem',
        'x-component': 'Select',
        'x-component-props': {
            placeholder: '请选择业务对象名称',
        },
        required: true,
        'x-reactions': {
            dependencies: ['businessObjectCode'],
            fulfill: {
                schema: {
                    'x-component-props.params': '{{ $deps[0] }}',
                },
            },
        },
    },

然而,出现了新的问题:

  1. 在处理另外两个表单数据(ownerName,businessObjectType)的时候,x-reactions 应该依赖谁。因为无论依赖了其中哪一个,在另一个下拉框数据改变的时候,都无法触发联动了。

  2. 在提交表单的时候,表单的数据结构被污染了。本应该是

    js 复制代码
    {
        businessObjectCode:"",
        businessObjectName: "",
        ownerName: "",
        businessObjectType: "",
    }

    现在却变成了

    js 复制代码
    {
        businessObjectCode:{
            // 包含了整个表单数据在内的一大坨数据
        },
        businessObjectName: {
            // 包含了整个表单数据在内的一大坨数据
        },
        ownerName: "",
        businessObjectType: "",
    }
  3. 现在的表单数据因为联动的需要,x-reactions 中的 dependencies 里的数据(businessObjectCode,businessObjectName)也必须为数据对象,在初始化表单时,可能也得将 businessObjectCode 和 businessObjectName 处理成数据对象。

  4. 反直觉。在人的直觉中,我们往往希望所见即所得,而现在的方案包含了太多隐式的内容。

  5. 维护成本。如果后续有需求变更,比如字段调整,表单结构调整,对于维护这段代码的人来说,都是非常难受的。(而我们原本以为改几个字母就好了的呀...)

当然以上问题,都是可以解决的。比如问题1,在value值改变的时候,我们可以手动触发一下组件的onChange,当然,可能会有一些边界 case 需要考虑。比如问题2和3,我们可以中间做一层数据转换。

但是至此,我们花了太多的成本,甚至以上都是笔者简化过的逻辑和代码。试问,我们使用 Formily 的初衷,不是为了提高我们的效率,减轻我们的心智负担吗?而现在似乎背道而驰。

当然,对于熟悉 Formily 的同学,可能也会想到使用 createForm 中 effects 提供的钩子,去监听组件的值改变,然后再去改变整个 form 的数据。可是一旦表单复杂到一定程度,钩子里的逻辑维护成本也是非常高的,而且,如果像笔者一样,在项目中,大部分场景都使用了 Formily 本身的联动能力去解决,仅针对这种特殊场景,用钩子函数去处理的话,显得不伦不类。但话说回来,这也确实能解决问题,而且比较符合直觉。

所以最终,笔者采用了与此类似的方式,在每个 Select 组件的 onChange 中进行接口请求之后,再使用 form.setValues 来改变整个表单的数据。这样,逻辑似乎更简洁了,也不需要去处理自定义组件里的异常 case,仅仅只需要关注 onChange 以及表单的数据应该怎么赋值。表单数据也干净了,不需要花费额外的转换成本。对于此案例,如果是你,你会怎么处理呢?

结论

由此可见,如果仅依靠 Formily 本身的联动能力,在应对某些复杂场景时,是存在一定的局限性的。

另外,在表单交互过程中,JSON Schema 的反复解析可能成为性能瓶颈,特别是当 schema 内容庞大且需要进行复杂的联动和批量更新时,性能问题会更加明显。这是因为 Formily 在内部对状态进行深拷贝,并进行深度遍历的脏检测。虽然这种方式提升了用户体验,但在处理大量数据的情况下,性能问题会显现出来。在这种情况下,考虑屏蔽 Formily 的局部重复渲染,回退到 React 的整树渲染可能是一个解决方案。

然而,任何方案都只能解决部分问题。一个方案可以满足80%的场景,剩下的20%可能需要采用其他方案来处理。因此,在面对问题时,需要根据具体情况选择合适的方案来解决。

最后

📚 小茗文章推荐:

关注公众号「Goodme前端团队」,获取更多干货实践,欢迎交流分享~

相关推荐
zqx_76 小时前
随记 前端框架React的初步认识
前端·react.js·前端框架
TonyH20021 天前
webpack 4 的 30 个步骤构建 react 开发环境
前端·css·react.js·webpack·postcss·打包
掘金泥石流1 天前
React v19 的 React Complier 是如何优化 React 组件的,看 AI 是如何回答的
javascript·人工智能·react.js
lucifer3111 天前
深入解析 React 组件封装 —— 从业务需求到性能优化
前端·react.js
秃头女孩y1 天前
React基础-快速梳理
前端·react.js·前端框架
sophie旭1 天前
我要拿捏 react 系列二: React 架构设计
javascript·react.js·前端框架
BHDDGT2 天前
react-问卷星项目(5)
前端·javascript·react.js
liangshanbo12152 天前
将 Intersection Observer 与自定义 React Hook 结合使用
前端·react.js·前端框架
黄毛火烧雪下2 天前
React返回上一个页面,会重新挂载吗
前端·javascript·react.js