前言
前端开发中经常遇到UI逻辑和业务逻辑混在一起,当需求发生变更往往有可能改动巨大,就像一个感冒,要进行开颅手术。收到这个问题的困扰,就有了这个话题。
问题分析
-
为什么要把UI逻辑和业务逻辑写在一起?
在当今天前端两大框架(React 自称库)的影响下,在组件中可以获取数据对数据进行二次加工,逻辑判断,接口都可以在一个组件完成,这样写的时候确实很爽,而且他们提供了很多对数据很友好的方法,比如(Vue: watch computed; React: useMemo, useEffect),帮我们实现了类似观察者模式或者发布订阅模式。
-
这样写你认为怎么样?
毋庸置疑React和Vue都是前端非常好的产品,但是不能说有了他们就舍弃了我们最原始的东西。在开发中我认为程序员需要做的并不是业务单一性和功能单一性,更重要的是复用性和原子性,UI逻辑和业务逻辑应该有且必要去分离,UI逻辑和业务逻辑交织在一起。对于代码而言,耦合性太高;对于业务来说,维护困难太大,当改变了UI逻辑就需要从交织的逻辑中抽丝剥茧,业务逻辑也是如此。
-
如何分离?
回归原始,从JavaScript开始的时候开始,JavaScript是一门面向对象的编程语言。那我们就从对象入手,每一个业务,都是一个业务对象。把业务所具备的功能抽离到业务对象中,UI组件只负责接收所需要的参数和UI逻辑。
-
那现在都是响应式的数据,我们原生对象怎么实现和组件的响应式呢?
这个问题其实在一开始就解决了,在Vue 2 和 3 以及React对响应式的实现方式中去寻找答案,他们怎么实现我们也可以怎么实现, 类似于Object.defineProperty 或者 Proxy,当然也可以用class来实现。
代码实现
这里以React为背景来设计这样一个工具,采用class来实现。
需求
- 实现一个可以适配React响应式的数据工具。
需求分析(倒推法)
- React的响应式需要useState来实现。
- 纯数据的工具就能包含UI逻辑,可以使用自定义hook来做中间层,使用观察者模式来更新自定义hook的内容。
- 实现一个满足观察者模式的对象,可以使用class来实现。
- 要通用性则需要继承。
设计开发(正推法)
- 实现一个类对数据的劫持
typescript
import { cloneDeep, isEqual } from "lodash-es";
export class DataControl<T extends Record<string | number | symbol, any>> {
/**需要劫持的对象,只读属性,避免被三方修改 */
private readonly data: T;
constructor(data: T) {
this.data = Object.freeze(cloneDeep(data));
}
/**修改data 某个属性的方法 */
setDataByKey<K extends keyof T>(key: K, value: T[K]): Readonly<T> {
// 1. 判断是否变化
if (isEqual(this.data[key], value)) {
// 未发生变化直接返回
return this.data;
}
// 深拷贝去除data在引用地址的访问
const newData = Object.freeze(cloneDeep({ ...this.data, [key]: value }));
//TODO 拦截更新
// 更新数据
Object.assign(this, { data: newData });
return this.data;
}
/**修改data方法,劫持set方法 */
setData(data: T) {
const newData = data;
// 1. 判断是否变化
if (isEqual(this.data, newData)) {
// 未发生变化直接返回
return this.data;
}
// 获取所有的key值
const symbolKeys = Object.getOwnPropertySymbols(newData);
const normalKeys = Object.getOwnPropertyNames(newData);
const keys = [...symbolKeys, ...normalKeys];
for (let index = 0; index < keys.length; index++) {
const key = keys[index];
const value = newData[key];
this.setDataByKey(key, value);
}
return this.data;
}
/** 获取值 */
getData(): Readonly<T> {
return this.data;
}
/** 获取 key 的值 */
getDataByKey<K extends keyof T>(key: K): Readonly<T[K]> {
return this.data[key];
}
}
- 实现劫持也就是监听方法
需要一个监听队列,注册监听和取消注册监听 分为key监听和全部监听
typescript
import { cloneDeep, isEqual } from "lodash-es";
export class DataControl<T extends Record<string | number | symbol, any>> {
/**需要劫持的对象,只读属性,避免被三方修改 */
private readonly data: T;
/** key变化订阅列表 */
private keyListenerList: Map<
keyof T,
Set<(value: any, preValue: any) => void>
>;
/** 变化订阅列表 */
private allListener: Set<(data: T, preData: T) => void>;
constructor(data: T) {
this.data = Object.freeze(cloneDeep(data));
this.keyListenerList = new Map();
this.allListener = new Set();
}
/**修改data 某个属性的方法 */
setDataByKey<K extends keyof T>(key: K, value: T[K]): Readonly<T> {
// 1. 判断是否变化
if (isEqual(this.data[key], value)) {
// 未发生变化直接返回
return this.data;
}
// 深拷贝去除data在引用地址的访问
const newData = Object.freeze(cloneDeep({ ...this.data, [key]: value }));
/**
* 1. 通知key订阅者
* 2. 通知变化订阅者
*/
if (this.keyListenerList.has(key)) {
this.keyListenerList.get(key)!.forEach((listener) => {
listener(newData[key], this.data[key]);
});
}
this.allListener.forEach((listener) => listener(newData, this.data));
// 更新数据
Object.assign(this, { data: newData });
return this.data;
}
/**修改data方法,劫持set方法 */
setData(data: T) {
const newData = data;
// 1. 判断是否变化
if (isEqual(this.data, newData)) {
// 未发生变化直接返回
return this.data;
}
// 获取所有的key值
const symbolKeys = Object.getOwnPropertySymbols(newData);
const normalKeys = Object.getOwnPropertyNames(newData);
const keys = [...symbolKeys, ...normalKeys];
for (let index = 0; index < keys.length; index++) {
const key = keys[index];
const value = newData[key];
this.setDataByKey(key, value);
}
return this.data;
}
/** 获取值 */
getData(): Readonly<T> {
return this.data;
}
/** 获取 key 的值 */
getDataByKey<K extends keyof T>(key: K): Readonly<T[K]> {
return this.data[key];
}
/** 新增key变化订阅 */
addKeyListener<K extends keyof T>(
key: K,
listener: (value: T[K], pre: T[K]) => void,
) {
if (!this.keyListenerList.has(key)) {
this.keyListenerList.set(key, new Set());
}
this.keyListenerList.get(key)!.add(listener);
}
/** 移除key变化订阅 */
removeKeyListener<K extends keyof T>(
key: K,
listener: (value: T[K], pre: T[K]) => void,
) {
if (this.keyListenerList.has(key)) {
this.keyListenerList.get(key)!.delete(listener);
}
}
/** 新增变化订阅列表 */
addAllListener(listener: (value: T, pre: T) => void) {
this.allListener.add(listener);
}
/** 移除变化订阅列表 */
removeAllListener(listener: (value: T, pre: T) => void) {
this.allListener.delete(listener);
}
}
- 这里已经基本做完,尝试让他变成响应式数据,这里就需要React的自定义hook(Vue同理可得)
typescript
export class DataControl<T extends Record<string | number | symbol, any>>{...}
// 类型工具
type InferStoreControlType<T> = T extends DataControl<infer U> ? U : never;
// key hook
export const generateKeyHook = <T extends DataControl<any>>(control: T) => {
/** 返回一个函数,也就是我们的自定义hook */
return <K extends keyof InferStoreControlType<T>>(
key: K,
): [
InferStoreControlType<T>[K],
React.Dispatch<React.SetStateAction<InferStoreControlType<T>[K]>>,
] => {
// 设初始值
const [value, setValue] = useState<InferStoreControlType<T>[K]>(
control.getDataByKey(key),
);
const changeValue: React.Dispatch<
React.SetStateAction<InferStoreControlType<T>[K]>
> = useCallback(
(value) => {
// 这里不能直接设置 setValue,直接设置 DataControl 里面的值,通过订阅更新回来
if (typeof value === "function") {
const currentValue = control.getDataByKey(key);
const newValue = (
value as (
prevState: InferStoreControlType<T>[K],
) => InferStoreControlType<T>[K]
)(currentValue);
control.setDataByKey(key, newValue);
} else {
control.setDataByKey(key, value);
}
},
[key],
);
useEffect(() => {
/** 订阅变化 */
control.addKeyListener(key, setValue);
return () => {
/** 取消订阅 */
control.removeKeyListener(key, setValue);
};
}, [key]);
return [value, changeValue];
};
};
// all hook
export const generateAllHook = <T extends DataControl<any>>(control: T) => {
/** 返回一个函数,也就是我们的自定义hook */
return (): [
InferStoreControlType<T>,
React.Dispatch<React.SetStateAction<InferStoreControlType<T>>>,
] => {
// 设初始值
const [value, setValue] = useState<InferStoreControlType<T>>(
control.getData() as InferStoreControlType<T>,
);
const changeValue: React.Dispatch<
React.SetStateAction<InferStoreControlType<T>>
> = useCallback((value) => {
// 这里不能直接设置 setValue,直接设置 DataControl 里面的值,通过订阅更新回来
if (typeof value === "function") {
const currentValue = control.getData() as InferStoreControlType<T>;
const newValue = (
value as (
prevState: InferStoreControlType<T>,
) => InferStoreControlType<T>
)(currentValue);
control.setData(newValue);
} else {
control.setData(value);
}
}, []);
useEffect(() => {
/** 订阅变化 */
control.addAllListener(setValue);
return () => {
/** 取消订阅 */
control.removeAllListener(setValue);
};
}, []);
return [value, changeValue];
};
};
这里我都写一个文件了便于写文章
还有一些细节比如计算属性(写不动),多层对象(用Proxy会好实现一点)
可以当编程题挑战一下
实战
单例模式可以当作是全局属性,就不需要Redux或者Rcoil了
typescript
import {
DataControl,
generateAllHook,
generateKeyHook,
} from "@/control/storeGenerate/dataControl";
type TStudent = {
classInfo: { classId: number; className: string }[];
baseInfo: { name: string; id: number; classId: number }[];
sourceInfo: { id: number; chinese: number; english: number; math: number }[];
totalSource: number;
};
const initInfo: TStudent = {
classInfo: [],
baseInfo: [],
sourceInfo: [],
totalSource: 0,
};
class StudentControl extends DataControl<TStudent> {
/**
* 单例模式可以作为全局的store,所有组件可用
* 非单例模式可复用
*/
static instance: StudentControl;
constructor() {
if (StudentControl.instance) return StudentControl.instance;
super(initInfo);
StudentControl.instance = this;
this.addKeyListener("sourceInfo", (data) => {
let totalSource = 0;
data.forEach((item) => {
const { chinese, english, math } = item;
totalSource += chinese + english + math;
});
this.setDataByKey("totalSource", totalSource);
});
}
init() {
getClassInfo().then((res) => {
this.setDataByKey("classInfo", res);
});
getBaseInfo().then((res) => {
this.setDataByKey("baseInfo", res);
});
getSourceInfo().then((res) => {
this.setDataByKey("sourceInfo", res);
});
}
deleteStudentSource(id: number) {
const currentData = this.getDataByKey("sourceInfo").filter(
(item) => id !== item.id,
);
this.setDataByKey("sourceInfo", currentData);
}
}
export const studentControl = new StudentControl();
export const useStudent = generateAllHook(studentControl);
export const useStudentKey = generateKeyHook(studentControl);
使用
typescript
import { FC } from "react";
import { studentControl, useStudent, useStudentKey } from "./control";
interface ComponentProps {}
/**
*
*/
export const Component: FC<ComponentProps> = (props) => {
const [total] = useStudentKey("totalSource");
const [{ sourceInfo: sources }] = useStudent();
return (
<div>
<div>
<span>total:</span> <span>{total}</span>
</div>
<div>
{sources.map((item) => {
const { id, chinese, english, math } = item;
return (
<div key={item.id}>
<span>chinese: {chinese}</span>
<span>english: {english}</span>
<span>math: {math}</span>
<button
type="button"
onClick={() => {
studentControl.deleteStudentSource(id);
}}
>
delete
</button>
</div>
);
})}
</div>
</div>
);
};