前言
最近做了很多云控制台的前端界面,这类toB产品中,前端的特点之一是业务表单逻辑复杂,比如复杂的跨字段联动、字段自动填充、复杂校验等等。后来随着复杂表单愈做愈多,形成了自己的一套方法论,按照这个逻辑,完成的表单很容易同时复用于创建、详情、编辑,其中主要参考学习了SOLID原则。本文更多的是基于个人经验的实践感想,欢迎有其他心得的朋友们交流学习。
为降低理解门槛,本文主要使用React + HTML原生组件为示例进行表述。无论平时常用的组件库是TDesign、Ant Design还是Material,封装业务表单时,思维逻辑都是与上方一致的,不过是API使用、状态管理方案可能不同罢了。
以下完整例子可在codesandbox查看,由于仅是demo,所以ts写得比较随意。
什么是表单?
这个问题看似简单,但非常重要,他决定了我们设计的表单组件应该承载有哪些功能。我认为表单的功能是:包含数据的输入、呈现、交互、校验、触发、输出。注意,这里面不包含请求API获取数据及请求API提交数据的功能,至于原因,后文给出解释。
如何设计?
接下来按照【输入、触发、输出】、【呈现、交互】、【校验】组合的顺序设计实现组件。
【输入、触发、输出】
首先通过initValues作为表单的输入,submitRef引用表单的触发函数,触发函数调用时,onSubmit回调函数会调用,从而获取表单输出值,开发者可以通过onSubmit函数获取的表单值联调接口。
import { useRef } from "react";
import ExampleForm from "./ExampleForm";
import { createApi } from "./mock.ts";
export default function CreatePage() {
const submitRef = useRef(null);
return (
<>
<ExampleForm
initValues={{
studentName: "bar",
studentAge: 8
}}
onSubmit={async function (formValues) {
const resp = await creqteApi(formValues);
}}
submitRef={submitRef}
readonly={false}
/>
<button
onClick={() => {
submitRef?.current();
}}
>
创建
</button>
</>
);
}
这里读者可能会有些疑惑,为什么要把输入值、触发提交、输出值以组件接口的形式暴露出来呢?为什么不把他们封装在表单组件里呢?上文已经是创建场景中的例子,接下来以编辑为例,来说明表单的健壮性。
import { useRef } from "react";
import ExampleForm from "./ExampleForm";
import { editApi, useStudentData } from "./mock";
export default function EditPage() {
const submitRef = useRef(null);
const { loading, data } = useStudentData();
return loading ? (
<p>loading...</p>
) : (
<>
<ExampleForm
initValues={{
studentName: data.studentName,
studentAge: data.studentAge
}}
onSubmit={async function (formValues) {
const resp = await editApi(formValues);
}}
submitRef={submitRef}
readonly={false}
/>
<button
onClick={() => {
submitRef?.current();
}}
>
更新
</button>
</>
);
}
可以见到,编辑场景下,使用此表单改变了输入值来源、onSubmit中的联调接口、触发UI("创建"——>"更新"),并不需要对表单内部的逻辑做任何修改。如果是详情场景下,只需要把readonly改为false,或者添加一个state控制当前为编辑模式还是只读模式。
如此设计,表单就完全和外部数据源及API请求解藕,仅需要专注于内部的数据处理即可。假设业务迭代,接口变更,但表单内部逻辑是完全一致的,开发者维护时仅需要修改输入数据(如上例中的初始数据及useStudentData)和API请求代码即可,不需要对业务表单组件做任何修改,降低表单内部逻辑出错的可能性。
【呈现、交互】
表单需要将数据呈现并且与用户交互,有时业务可能还有只读的场景,故上文我们同时设置了readonly的组件接口,如此将业务表单组件完善一下。为了理解门槛较低,这里使用了原生HTML组件与state进行表单状态管理,实际业务开发中,可根据自己的UI库及表单状态管理方案进行调整。
import React, { FC, useState } from "react";
interface FormValues {}
interface ExampleFormProps {
initValues: FormValues;
readonly: boolean;
onSubmit: (formValues: FormValues) => void;
submitRef: React.MutableRefObject<null>;
}
const ExampleForm: FC<ExampleFormProps> = function ({
initValues,
readonly,
onSubmit,
submitRef
}) {
const [studentName, setStudentName] = useState(initValues.studentName);
const [studentAge, setStudentAge] = useState(initValues.studentAge);
submitRef.current = function () {
onSubmit({
studentName: studentName,
studentAge: studentAge
});
};
return (
<>
<input
name="studentName"
value={studentName}
disabled={readonly}
onChange={(e) => {
setStudentName(e.target.value);
}}
/>
<input
name="age"
type="number"
value={studentAge}
disabled={readonly}
onChange={(e) => {
setStudentAge(e.target.value);
}}
/>
</>
);
};
export default ExampleForm;
如上代码处理了呈现、交互的问题,如果有更复杂的交互流程,如联动、自动填充、流程编排等等,都可以内置在业务表单组件中,从输入数据、输出数据的视角去编写代码。
【校验】
校验不通过时,最好拦截在onSubmit之前,故可以在加入如下代码。
submitRef.current = function () {
if (studentName === "") {
alert("姓名不能为空");
return;
}
onSubmit({
studentName: studentName,
studentAge: studentAge
});
};
例子中仅仅是最简单的校验,实际场景中可能是在输入框onChange时进行校验,onBlur时进行校验等等,具体看所选的校验方案了,此处重点是如果校验不通过,onSubmit函数也不应该触发,如此外部调用时就不需要处理校验逻辑了,只需要专注于数据的输入和输出。
设计模式总结
本表单设计参考学习了SOLID原则,主要运用了其中的S、I、D:
- S(单一职责,即SRP):表单的职责只有处理数据,没有获取、提交数据。提高其不同业务页面下(创建、编辑、详情)的复用性。
- I(接口隔离原则,即ISP):表单的初始数据不依赖于接口,在创建场景下,可以js代码生成;在详情或编辑场景下,可通过接口获得
- D(依赖倒置原则,即DIP):DIP原则指出**“要依赖于抽象,不要依赖于具体”**。如果表单依赖于具体的API,如创建API、编辑API等等,那么表单组件就与此API耦合了,内部需要更多的逻辑去区分这两种场景的API。以上例子通过onSubmit组件接口,将表单值输出,原本是表单依赖于接口,现在是接口依赖于表单的输出。如此解耦,表单组件可在不同的业务场景下完成复用,同时不需要修改其内部逻辑。
学习SOLID原则时阅读了此篇文章:如何在React中应用SOLID原则?,非常值得推荐。
扩展情况
有读者可能会说,按照以上的组件接口还是满足不了其业务场景,比如只读状态下studentName不允许编辑等等,根因可以归纳为不同业务场景下存在不同的编辑逻辑。个人遇到这种情况,如果不是太复杂的话,均通过添加组件入参来解决,比如添加scene = "edit" | "create"。
总结
此设计思路满足了个人开发时的大多数场景,一般联调好创建,编辑、详情页面也能很快完成,随着业务迭代,也很容易添加或删改逻辑,个人的灵感来源更多的是函数式编程,一个表单组件接受输入(initValues)、输出(onSubmit获取),渲染出数据的呈现及交互,API请求可以获得输入,输出可以发送给API请求。
当然也存在无法覆盖的情况,毕竟没有万能的设计思路。以上主要为个人经验总结,欢迎有更多心得的朋友交流讨论。