jimengio/table-form-example

一套基于 React Hooks 的表格/表单方案

Opened this issue · 0 comments

当前项目 https://github.com/jimengio/table-form-example 是一个 Demo,
展示了基于 Jimeng 的 React 组件快速搭建表格表单业务的方案.
由于路由和 API 的代码, 参考其他项目的实现, 通过代码生成省掉了不少代码,
剩下来主要就是 UI 部分了, 常用的表格/表单也就是这个示例提供的代码.

完整 Demo 可以看 http://fe.jimu.io/table-form-example/#/
对应代码在 https://github.com/jimengio/table-form-example/tree/master/src/pages .
基于 Mock 数据, 比较简单, 后面是拆开来的各部分的解释.

插件化

在解释之前, 先说这套方案遇到的一个坎, React 还有 Hooks 之间发生的事情.
在 React 之前, 我们写一个 Modal 包含一个业务, 比较容易当成插件的方式插入.
比如说有个 formPlugin, 表格需要该业务的时候就拿过来加上.

这个 formPlugin 有一些配置,

  • 标题是什么
  • 表单项是那些, form fields, 还有包含哪些逻辑,
  • 确认按钮的布局

然后 formPlugin 有事件, 比如提交的时候,

  • onSubmit = (form) => {} 提交的时候数据什么样子,
  • onCancel, 如果取消了是否要跟上什么操作,

然后 formPlugin 暴露方法, 用于控制显示/隐藏, 以及数据,

  • .show()
  • .close()
  • .submit() 触发提交逻辑
  • .setFormData(data)
  • .setErrors(errors) 清空错误, 或者用来展示服务端返回的报错,

但是基于 React 组件的话, 问题就来了, React 组件不会暴露方法去操作组件内部状态,
比如说一个 <input/> 暴露一个 setValue 方法给你, 太不 React 了!

<input value={text} onChange={event => { setText(event.target.value); }}>

起码, 先要有一个 state, 用来存储 text 的数据,

let [text, setText] = useState("")

<input value={text} onChange={event => { setText(event.target.value); }}>

基于组件的话, 你要操作状态就需要 setText 才行, 可组件不好暴露给你.
不过, React Hooks 的话, 倒是开了一扇窗:

let useInput = () => {
  let [text, setText] = useState("")

  let ui = <input value={text} onChange={event => { setText(event.target.value); }}>

  return {ui, setText}
}

Hooks 方式提供 Form

那么, 对应到上边的 Form 的需求, 可以有一个 API, 把 Form 的 ui 和操作暴露出来,

export let useEditTask = (props: { afterChange: () => void }) => {
  // Model

  // 记录打开关闭状态
  let stateAtom = useAtom({
    editing: false,
    new: false,
  });
  
  // let formFields = ...
  // 见下文

  // View
  let ui = (
    // Modal 定义见下文
  );

  // Controller

  let onCreate = () => {
    formFields.resetForm({} as ITask);
    stateAtom.swapWith((state) => {
      state.editing = true;
      state.new = true;
    });
  };

  let onEdit = (data: ITask) => {
    formFields.resetForm(data);
    stateAtom.swapWith((state) => {
      state.editing = true;
      state.new = false;
    });
  };

  return { ui, create: onCreate, edit: onEdit };
};

那么我们基于这个 Hooks API, 创建一个插件,

let editPlugin = useEditTask({})

其中 ui 部分直接挂载到 DOM 当中用于渲染,
然后 editPlugin.create() 就可以用来执行 .show().resetForm() 的功能了,
以及 editPlugin.edit(form) 可以发起对数据的修改.
整个就回到了前面设想的 Plugin 方式的 Form 开发了, 假装有可以操作的内部状态.

Form 的配置

上面的代码省略了 Form 的具体配置, 具体代码是这样的,
items 提供 JSON(包含函数)描述的 Form 的结构, 提供配置,

// Plugins

let items: IMesonFieldItem<ITask>[] = [
  {
    type: "input",
    name: "title",
    label: "Title",
    required: true,
  },
  {
    type: "textarea",
    name: "content",
    label: "Content",
  },
  {
    type: "number",
    name: "priority",
    label: "Priority",
    validator: (x: number) => {
      if (x != null) {
        if (x < 1) {
          return "Littler than 1!";
        } else if (x > 5) {
          return "Greater than 5!";
        }
      }
    },
  },
];

再用另一个 Hooks API 初始化一个 Form 出来, 得到 formFields,

let taskCreation = genSeedApiTree.tasks.dynamicPOST();
let taskUpdate = genSeedApiTree.tasks._.dynamicPUT();


let formFields = useMesonFields({
  items: items,
  initialValue: {} as ITask,
  onSubmit: async (formData) => {
    if (stateAtom.current.new) {
      await taskCreation.request({}, formData);
    } else {
      await taskUpdate.request({ id: formData.id }, formData);
    }
    stateAtom.swapWith((state) => {
      state.editing = false;
    });
    props.afterChange?.();
  },
});

// UI
<>
  {formFields.ui}
  <FooterButtons items={buttons} />
</>

类似地, Form 本身也是一个 Hooks API 来做的, 模拟了内部状态.
React 虽然说了很多 stateless, 但是对 From 的场景来说, 内部状态是必要的,
Form 每一项会进行错误的校验, 这个从业务角度显然是 Form 组件内部维护比较好,
再者, Form 当中的 form data 某种程度来说就是可变的, 不然你怎么编辑呢?
同时我们也知道通过 props 传给 Form 的那份 data 是不能随便改的, Table 还要用呢,
所以在 Form 的内部实际上是要拷贝一个 form data, 在内部做更改,
这种操作内部状态的行为, 就通过 Hooks API 开的窗来完成了.

表格

表格部分目前做得比较简单, 没有带状态在里边, 直接用组件配一下,

import { RoughDivTable, IRoughTableColumn, ActionLinks, IActionLinkItem } from "@jimengio/rough-table";


let columns: IRoughTableColumn<ITask>[] = [
  { title: "Title", dataIndex: "title", },
  { title: "Priority", dataIndex: "priority", },
  { title: "Content", dataIndex: "content", clampText: true, },
  {
    title: "Operations",
    dataIndex: "id",
    render: (id: string, record) => {
      // actions 见下文
      return <ActionLinks actions={actions} />;
    },
  },
];

<RoughDivTable data={tasksResource.result} isLoading={tasksResource.isLoading} columns={columns} />

然后表格还有操作栏, 就是一些按钮, 也提供了一些配置项用于统一,

import { RoughDivTable, IRoughTableColumn, ActionLinks, IActionLinkItem } from "@jimengio/rough-table";

let actions: IActionLinkItem[] = [
  {
    text: "Edit",
    onClick: () => {
      editPlugin.edit(record);
    },
  },
  {
    text: "Remove",
    onClick: async () => {
      if (await confirmRemove.forConfirmation()) {
        await taskDeletion.request({ id: id });
        tasksResource.loadData();
      }
    },
  },
];

return <ActionLinks actions={actions} />;

可以看到中间涉及到触发编辑数据的地方, 直接用了 editPlugin.edit(record) 进行调用了.
以前的时候触发修改也是一个方法调用就好的对吧.

删除确认

然后是删除数据的位置, 因为删除有个弹框确认的过程, 这边增加了一个抽象.
这个场景是 Modal, Modal 就有着自己的打开关闭的状态,
可以脑补一下, 平时的 React Modal 你就需要 let [visible, setVisible] = useState(false) 了,
我这边改成一个 Hooks API, 把状态藏到里边去, 然后 .forConfirmation() 内部触发弹出,

let confirmRemove = useConfirmPop({
  text: "Are you sure to remove this?",
});

if (await confirmRemove.forConfirmation()) {
  await taskDeletion.request({ id: id });
  tasksResource.loadData();
}
      
{confirmRemove.ui}

这个代码太短, 比较难看出来关闭也在内部做了. 这个实现上有点投机取巧了,
.forConfirmation() 返回的是 Promise, 用户点击确认, 返回 Promise<true>.
这个写法看上去大概很不 React 了, 更像以前的 jQuery Plugin.

如果是 Antd confirm 的话, 当然也可以更短.. 而且没有 .ui 那个尾巴.
可是那样实现的话, 就会涉及 Context 之类的操作了, 数据流也就复杂了.

Modal 部分

Modal 也做了一些抽象. 不过前面已经把 visible 状态放在 Hooks 里控制了, 就不需要再抽了.
状态还是遵循 Single Source of Truth 的方式进行设置的,

let stateAtom = useAtom({
  editing: false,
  new: false,
});

<MesonModal
  visible={stateAtom.current.editing}
  title={stateAtom.current.new ? "Create task" : "Edit task"}
  onClose={() => {
    stateAtom.swapWith((state) => {
      state.editing = false;
    });
  }}
  renderContent={() => {
    let buttons: IFooterButtonOptions[] = [
      {
        text: "Cancel",
        onClick: () => {
          stateAtom.swapWith((state) => {
            state.editing = false;
          });
        },
      },
      {
        text: " Submit",
        filled: true,
        disabled: taskUpdate.isLoading || taskCreation.isLoading,
        onClick: () => {
          formFields.checkAndSubmit();
        },
      },
    ];

    return (
      <>
        {formFields.ui}
        <FooterButtons items={buttons} />
      </>
    );
  }}
/>

回顾

这套方案的重点是什么?

不用 Hooks 的话, Table, Modal, 还有 Form 也是, 用 class 组件很容易单独写出来的,
但是从编辑 data A 到编辑 data B 之间进行切换的时候呢?
比如说有个 <FormModal initialData={dataA} />, 刚刚在编辑 dataA.
这时候关闭了又想修改 dataB, FormModal 的内部状态怎么说?

整个 Form 进行重置吗, 比如用 key? 这个办法也是可行.
那么就需要在外部设置一个 editingData 状态, 记录正在编辑哪个数据.
关闭 Modal 的时候可能要清空状态, 或者下次打开之前重置掉. 也要避免影响到 Modal 动画.
当然也不是这一个, Modal 是否打开也需要记一个 visible 状态, 从而进行控制. 这就两个状态了.
然后 Modal 内部 Cancel 按钮是可以操作 visible 的, 加一个方法暴露.
这个在性能来说, 销毁重建有那么点多余, 不过这小问题.

不对 Form 进行重置吗? 那当然会有问题, 组件不重置, 状态总是要重置的.
Form 内部就要做一些处理的, 监听对应的时机, 每次打开, 要重置内部的数据,
因为每次编辑开始, 总不能把上次的草稿, 还有校验错误, 那些东西展示出来.
可以脑补一下这个 useEffect(f, [props.key, props.visible]) 怎么实现.
为了从 props 把东西传进去, 前面说的 editingDatavisible 也少不了.

从业务的分离来说, 第一个"重置"的方案, 父组件还要管着子组件的状态, 总觉得有些多余,
而且子组件作为复用组件的时候, 老是要定义状态绑状态, 多次写的话总觉得有点烦.

至少在 useEditTask 这个方案里边, 承认 Form 就是带状态的, 拆分起来清爽不少.

其他

这套写法, 算是实践当中摸出来的, 算不上是社区当中主流的玩法,
至少把 UI 定义在 Hooks 里, 然后又传出来, 弄得人总觉得很奇怪.
想想 React 当中 Virtual DOM 本来有点 UI is data 的味道, 好像又很自然.
至少在目前的实践当中, 这是一套灵活性和可靠性还算不错的方案

完整 Demo 可以看 http://fe.jimu.io/table-form-example/#/
对应代码在 https://github.com/jimengio/table-form-example/tree/master/src/pages .

另外 demo 当中包含 filterForm 部分没有弄完整, 涉及到 query params 的问题, 不展开了.
在代码的具体实现当中, 为了应对闭包问题, 用到了一些 useRef, 脱离了闭包限制,
这种不知道会不会对 React 内部的优化造成影响, 等有机会再深入了解下.