antd中可编辑表格中巧用useWatch实现联动高性能效果,以及定制延伸学习

前言

前端平时开发中并没有那么多的高大上的项目做,实际上很多看起来高大上的内容实际做起来也就那么回事,并没有那么复杂,而对于普通偏向于ui业务开发的前端开发者来说,接触到的最常见相对复杂的场景可能就是复杂表单场景了,尤其是带着联动的

这期,我们就介绍antd中,可编辑表格中巧用 useWatch 实现效率更高的联动效果

ps :可能有人会为为什么不直接用更加复杂的 ProComponents 呢,实际上很多企业为了减少包的大小,只引入了一个老版本的 antd,连antd5都没有机会使用,更何况 ProTable 了,此时只能使用默认的 form + table 来实现可编辑表格的效果了

ps2:要想实现联动的话,直接更新更新 dataSource 该有状态联动更新不也实现了么,很多人都是这么做的,但真的就是万能的么,如果产品要求你这页要展示100条数据,每条数据有20个可编辑表单内容呢,会发现每次更改联动有明显卡顿甚至可能2s以上,这是我们想要的么,就算减少到20条,面对公司业务使用的一些十年前的电脑,还是有明显卡顿(不少大公司内部使用云桌面集群,有时候就是这么卡,别不信哈),何况100条

ps3:就算不更新dataSource的话,也可以直接使用默认的联动函数,也可以呀,可以是可以,可惜的是,rule它不生效了,联动变化的rule不生效,只能将rule放到被监听者那里,这体验就不好了,因此使用一些手段直接更新联动者既能保持联动,还能保留rule,体验非常好

因此放弃更新DataSource的方式联动,而是巧用 useWatch 的方式(后面还会追加没有useWatch的方案)实现高性能联动

巧用useWatch实现联动

使用 useWatch 联动巧妙的地方,是其和useState一样,当触发更新的时候,监听的渲染函数会重新执行,因此使用此原理,监听我们的联动组件,以实现我们的组件联动效果,其本质就是一个订阅发布功能,因此效率会比全量更新要高的多(尤其是数据量比较大的情况)

因此我们只需要监听被依赖表单即可,我们封装一个统一的监听组件,当被监听内容更新的时候,我们对应的组件也联动更新也就可以了

下面我们实现一个效果,age发生改变的同时,联动岗位 position,年龄大于30岁则不可以编辑岗位,不大于则可以编辑岗位,且必填(我们省略了更多的联动效果,例如30岁之后要置空岗位,这里不作处理了,自己联想怎么处理哈,也不复杂)

js 复制代码
import { Form } from "antd";
import React, { useEffect, useMemo } from "react";

const WatchComponent = (props) => {
  const { form, watchName, watchNames, render, children, ...rest } = props;
  const val = Form.useWatch(watchName, form); //监听一个
  const fn = render || children
  return rest?.length ? fn(val, ...rest) : fn(val);
};

export default WatchComponent

外部table和columns我们这样写,这样就实现了一个联动

js 复制代码
import { useEffect, useState } from "react";
import "./App.css";
import { Button, Form, Input, Table } from "antd";
import WatchComponent from "./WatchComponent";

function App() {
  const [dataSource, setDataSource] = useState([]);
  const [form] = Form.useForm();

  console.log("App");

  useEffect(() => {
    const data = [
      {
        key: "1",
        name: "胡彦斌",
        age: 32,
        position: "前端工程师",
      },
      {
        key: "2",
        name: "胡彦祖",
        age: 42,
        position: "后端工程师",
      },
      {
        key: "3",
        name: "吴彦祖",
        age: 42,
        position: "后端工程师",
      },
    ];
    setDataSource(data);
  }, []);

  const columns = [
    {
      title: "姓名",
      dataIndex: "name",
      key: "name",
    },
    {
      title: "年龄",
      dataIndex: "age",
      key: "age",
      render: (text, record) => (
        <Form.Item
          name={[record.key, "age"]}
          rules={[{ required: true, message: "请输入年龄" }]}
        >
          <Input />
        </Form.Item>
      ),
    },
    {
      title: "岗位",
      dataIndex: "position",
      key: "position",
      render: (text, record) => {
        return (
          <Form.Item
            name={[record.key, "position"]}
            rules={[
              {
                //联动当age > 30时,非必填
                validator: (_, value) => {
                  const age = form.getFieldValue([record.key, "age"]);
                  const position = form.getFieldValue([record.key, "position"]);
                  if (age <= 30 && !position) {
                    return Promise.reject("请输入岗位");
                  }
                  return Promise.resolve();
                },
              },
            ]}
          >
            <WatchComponent
              form={form}
              watchName={[record.key, "age"]}
              render={(val, ...rest) => <Input disabled={val > 30} {...rest} />}
            ></WatchComponent>
          </Form.Item>
        );
      },
    },
  ];

  return (
    <div className="App">
      <Form form={form} onFinish={(values) => console.log(values)}>
        <Table dataSource={dataSource} columns={columns} />
        <Button type="primary" htmlType="submit">
          提交
        </Button>
      </Form>
    </div>
  );
}

export default App;

用过usewatch大概也能猜到,其实际上内部有一个useState,将value返回给外部,内部一个订阅事件,订阅的事件发现响应回调直接setState就触发更新了

上面的 WatchComponent 这里只监听了一个组件,如果直接监听多个会有bug,你能根据上面原理优化一下么

当然,即使是 antd4 也不是所有版本都有 Form.useWatch,如果公司引用的库版本更低,你打算怎么做呢,要是需要监听多个你怎么处理呢

ps:如果时间不足直接跟BA商量每页数据少点,直接更新datasource吧,简单粗暴,如果有时间可以封装一个更通用的

没有useWatch的简易骚操作

现在是组件没有 useWatch,且也不想封装那么复杂组件,就一个地方有联动,我们就想快点,那我们该怎么处理这种rule+联动呢

直接利用form更新后被更新后,内部组件会被触发渲染特性解决

js 复制代码
//这里假设我们的 position 最多空串,不会为null,我们直接先将其更新一下,再更新回来,那么就会强制触发子组件渲染,此时更新我们的子组件就行了
const onChange = (val, key) => {
    const currentPosition = form.getFieldValue([key, "position"]);
    form.setFieldValue([key, "position"], null);
    setTimeout(() => {
      form.setFieldValue([key, "position"], currentPosition);
    }, 0);
};

此外,如果我们的子组件如果是默认组件,假如是一个值类型的 disabled 直接传递值,由于组件没有销毁重新,通过模板传递的是初值,后续内部重新渲染的时候,这个disabled已经是传递过来的固定初值,没办法从内部更新;因此,则可以直接创建一个新组件,传递一个函数,里面展开使用,当内部更新的时候,直接调用传递过来的函数就可以动态获取到最新的disable状态,以达到更新了

js 复制代码
//由于table的render只渲染了一次(我们也没有更新datasource),因此这里传递了一个初始值进去
//当表单更新触发渲染的时候,内部触发更新,而input内部早就接收到的是传递过来的初值,也没啥变化,因此没更新
<Input disabled={form.getFieldValue([record.key, "age"]) > 30} />

//这里由于传递过来的是函数,当触发内部重新渲染的时候,内部重新执行函数,通过获取到了最新的的状态,从而更新了表单
const DynamicComponent = (props) => {
  const { disableFn, ...rest } = props;
  return <Input disabled={disableFn()} {...rest} />
};
<DynamicComponent disableFn={() => form.getFieldValue([record.key, "age"]) > 30} />

进阶

进阶版本实际不一定会使用,毕竟我们都说了,对于特殊条件下才需要使用那么多骚操作:

  1. 页面数据比较少,就10条,基本都是选择框联动,直接更新dataSouce更有性价比,这也是最为推荐的方案,这也是最常见的方案
  2. 更新datasource卡顿,如果有useWatch,我们能直接使用 useWatch 当时是极其推荐的,现成的简单粗暴,有多个就手动写多个useWatch 即可(因为useWatch不让循环使用)
  3. 正常联动使用form的默认联动即可,即使是需要rule也不是所有场景两者都带的,只针对于两者都需要的特殊情况

当然我们也可以使用其中一个骚操作,但有更加优秀的方案,自然是不推荐的,这就是我们的进阶方案了:

  1. 使用 onFieldsChange 封装,这是每个表单修改的时候都会走回调,且回调所有,并不是一对一关系,而是多对多关系,使用一个回调函数处理,表单一多性能变差,违反我们初衷,因此非常鸡肋,顾舍弃
  2. 模仿useWatch做一个新的进阶版本订阅发布方案,但由于接口支持没有useWatch那么纯天然,所以还是优先useWatch

进阶使用useSyncExternalStore解决useWatch监听多参数问题

实现类似useWatch观察者和同时能支持观察多个的观察者,如下所示,实现和使用肯定使用起来比 useWatch复杂,所以还是尽量使用 useWatch

js 复制代码
import React, { useCallback, useRef, useSyncExternalStore } from "react";

//基础观察者
class Watcher {
  constructor() {
    this.state = {};
    this.fnStore = {};
    // 缓存:key字符串 => 缓存的结果数组
    this.stateCache = new Map();
  }

  setState(newState) {
    this.state = { ...this.state, ...newState };
    // 数据更新后清空全部缓存(数据变动快照失效)
    this.stateCache.clear();
  }

  getState(key) {
    return this.state[key];
  }

  //为了保证外面用着方便,这里返回的是数组,毕竟有些key本身就是数组,里面不是作为对象key的也有toString转化不是
  getStates(keys) {
    if (!Array.isArray(keys)) return [];
    // 使用keys拼接字符串作为缓存key
    const cacheKey = keys.toString();//缓存的key不会自动转换成string,这里手动转换

    // 命中缓存直接返回旧数组(保证引用稳定,避免死循环)
    if (this.stateCache.has(cacheKey)) {
      return this.stateCache.get(cacheKey);
    }
    // 生成快照
    const snapshot = keys.map(key => {
      return this.state[key];
    });
    this.stateCache.set(cacheKey, snapshot);
    return snapshot;
  }

  subscribe(key, fn) {
    if (!this.fnStore[key]) this.fnStore[key] = new Set();
    this.fnStore[key].add(fn);
    return () => this.fnStore[key].delete(fn);
  }

  subscribes(keys, fn) {
    keys.forEach((key) => {
      if (!this.fnStore[key]) this.fnStore[key] = new Set();
      this.fnStore[key].add(fn);
    });
    return () => {
      keys.forEach((key) => this.fnStore[key].delete(fn));
    };
  }

  notify(key, value) {
    this.preState = this.state;
    this.state = { ...this.state, [key]: value };
    this.stateCache.clear(); // 更新数据,清除缓存
    this.fnStore[key]?.forEach((fn) => fn(value));
  }
}

//store
export const useWatchStore = () => {
  const ref = useRef();
  if (!ref.current) {
    ref.current = new Watcher();
  }
  return ref.current;
};


//观察单个
export const WatchComponent = React.memo(({ store, watchName, render, ...rest }) => {
  const subscribe = useCallback((fn) => store.subscribe(watchName, fn), [store, watchName]);
  const getSnapshot = useCallback(() => store.getState(watchName), [store, watchName]);
  const state = useSyncExternalStore(
    subscribe,
    getSnapshot,
    getSnapshot,
  );
  return render({state, ...rest});
});

//观察多个
export const WatchComponents = React.memo(({ store, watchNames, render, ...rest }) => {
  const subscribe = useCallback((fn) => store.subscribes(watchNames, fn), [store, watchNames]);
  const getSnapshot = useCallback(() => store.getStates(watchNames), [store, watchNames]);
  const states = useSyncExternalStore(
    subscribe,
    getSnapshot,
    getSnapshot,
  );
  return render({states, ...rest});
});

使用如下,如果WatchComponet使用页面也就是父页面也存在频繁渲染,则可以尝试缓存watchName和Render参数来追求性能

js 复制代码
<WatchComponent
  store={store}
  watchName={[record.key, "age"]}
  render={({ state, value, ...rest}) => (
    <Input {...rest} disabled={state > 30} />
  )}
/>

<WatchComponents
  store={store}
  watchNames={[
    [record.key, "name"],
    [record.key, "age"],
  ]}
  render={({ states, value }) => (
    <Input
      {...rest}
      disabled={states[1] > 30 || states[0]?.startsWith("李")}
    />
  )}
/>

效果如下所示,也是实现了

借鉴useSyncExternalStore原理,监听多参数问题

上面已经使用 useSyncExternalStore 实现了模拟useWatch的效果,我们理解了上面,下面就是站在巨人的肩膀上开发了,简单很多,步骤如下所示

  1. 对于状态更新回调和状态使用useState
  2. 对于订阅和取消定于使用副作用函数
  3. 观察单个,注意副作用函数订阅和取消订阅,只需要回调更新函数换成state即可
  4. 观察多个,注意副作用函数订阅和取消订阅,只需要将回调函数处理一下,将所有key对应的值取出来,一次全部打包更新即可,就是如此简单
js 复制代码
//观察单个,只需要回调函数换成state即可,注意副作用函数订阅和取消订阅
export const WatchComponentEx = React.memo(
  ({ store, watchName, render, ...rest }) => {
    const subscribe = useCallback(
      (fn) => store.subscribe(watchName, fn),
      [store, watchName],
    );
    const [state, setState] = useState();

    useEffect(() => {
      const unsubscribe = store.subscribe(watchName, setState);
      return unsubscribe;
    }, [store, watchName]);

    return render({ state, ...rest });
  },
);

//观察多个
export const WatchComponentsEx = React.memo(
  ({ store, watchNames, render, ...rest }) => {
    const subscribe = useCallback(
      (fn) => store.subscribes(watchNames, fn),
      [store, watchNames],
    );
    const [state, setState] = useState([]);
    useEffect(() => {
      const unsubscribe = store.subscribes(watchNames, (val)=> {
        const newState = watchNames.map((key) => store.getState(key));
        setState(newState);
      });
      return unsubscribe;
    }, [store, watchNames]);
    return render({ states: state, ...rest });
  },
);

最后

实现很简单吧,同时也学习到很多,当然后面的步骤相比较之下还是很麻烦,非必要不要使用,仅供学习参考使用,技术在合理的地方使用,才能发挥它的价值,平时还是以效率为主,没有遇到瓶颈,这个是不需要动的

这期就到这里了,一起进步吧,即便战胜不了ai,我们也不能原地踏步不是,打不过我们就加入不就是了😄

相关推荐
油丶酸萝卜别吃2 小时前
前端转全栈学习路线
前端·学习
浮生望2 小时前
前端路由进阶:History API原理与手写HistoryRouter
前端
陆枫Larry2 小时前
JavaScript 中的竞态是什么,为啥会有竟态?
前端
上海安当技术4 小时前
半天接入:USBKey RESTful API + C 动态库,Web 和 C/S 两套集成路径实战
前端·后端·restful·集成·usbkey
DevUI团队5 小时前
从“即兴创作”到“规格先行”,华为云码道(CodeArts)代码智能体持续深耕企业级规范驱动开发能力
前端·人工智能·后端
weixin_431600446 小时前
NestJS 入门(3):Guard 如何挡住未登录请求?
前端·后端·学习·nest.js
kyriewen6 小时前
我用Claude Code两天干完了团队两周的排期——周报发出去那一刻我就后悔了
前端·javascript·ai编程
IT_陈寒6 小时前
JavaScript类型转换把我坑惨了,这破玩意真该早点搞明白
前端·人工智能·后端
用户938515635077 小时前
Type vs Interface:读完这篇就没有面试官能难倒你了
前端·面试·typescript