前言
前端平时开发中并没有那么多的高大上的项目做,实际上很多看起来高大上的内容实际做起来也就那么回事,并没有那么复杂,而对于普通偏向于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} />
进阶
进阶版本实际不一定会使用,毕竟我们都说了,对于特殊条件下才需要使用那么多骚操作:
- 页面数据比较少,就10条,基本都是选择框联动,直接更新dataSouce更有性价比,这也是最为推荐的方案,这也是最常见的方案
- 更新datasource卡顿,如果有useWatch,我们能直接使用 useWatch 当时是极其推荐的,现成的简单粗暴,有多个就手动写多个useWatch 即可(因为useWatch不让循环使用)
- 正常联动使用form的默认联动即可,即使是需要rule也不是所有场景两者都带的,只针对于两者都需要的特殊情况
当然我们也可以使用其中一个骚操作,但有更加优秀的方案,自然是不推荐的,这就是我们的进阶方案了:
- 使用 onFieldsChange 封装,这是每个表单修改的时候都会走回调,且回调所有,并不是一对一关系,而是多对多关系,使用一个回调函数处理,表单一多性能变差,违反我们初衷,因此非常鸡肋,顾舍弃
- 模仿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的效果,我们理解了上面,下面就是站在巨人的肩膀上开发了,简单很多,步骤如下所示
- 对于状态更新回调和状态使用useState
- 对于订阅和取消定于使用副作用函数
- 观察单个,注意副作用函数订阅和取消订阅,只需要回调更新函数换成state即可
- 观察多个,注意副作用函数订阅和取消订阅,只需要将回调函数处理一下,将所有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,我们也不能原地踏步不是,打不过我们就加入不就是了😄