UI 和 业务!逻辑分离之法

前言

前端开发中经常遇到UI逻辑和业务逻辑混在一起,当需求发生变更往往有可能改动巨大,就像一个感冒,要进行开颅手术。收到这个问题的困扰,就有了这个话题。

问题分析

  1. 为什么要把UI逻辑和业务逻辑写在一起?

    在当今天前端两大框架(React 自称库)的影响下,在组件中可以获取数据对数据进行二次加工,逻辑判断,接口都可以在一个组件完成,这样写的时候确实很爽,而且他们提供了很多对数据很友好的方法,比如(Vue: watch computed; React: useMemo, useEffect),帮我们实现了类似观察者模式或者发布订阅模式。

  2. 这样写你认为怎么样?

    毋庸置疑React和Vue都是前端非常好的产品,但是不能说有了他们就舍弃了我们最原始的东西。在开发中我认为程序员需要做的并不是业务单一性和功能单一性,更重要的是复用性和原子性,UI逻辑和业务逻辑应该有且必要去分离,UI逻辑和业务逻辑交织在一起。对于代码而言,耦合性太高;对于业务来说,维护困难太大,当改变了UI逻辑就需要从交织的逻辑中抽丝剥茧,业务逻辑也是如此。

  3. 如何分离?

    回归原始,从JavaScript开始的时候开始,JavaScript是一门面向对象的编程语言。那我们就从对象入手,每一个业务,都是一个业务对象。把业务所具备的功能抽离到业务对象中,UI组件只负责接收所需要的参数和UI逻辑。

  4. 那现在都是响应式的数据,我们原生对象怎么实现和组件的响应式呢?

    这个问题其实在一开始就解决了,在Vue 2 和 3 以及React对响应式的实现方式中去寻找答案,他们怎么实现我们也可以怎么实现, 类似于Object.defineProperty 或者 Proxy,当然也可以用class来实现。

代码实现

这里以React为背景来设计这样一个工具,采用class来实现。

需求

  1. 实现一个可以适配React响应式的数据工具。

需求分析(倒推法)

  1. React的响应式需要useState来实现。
  2. 纯数据的工具就能包含UI逻辑,可以使用自定义hook来做中间层,使用观察者模式来更新自定义hook的内容。
  3. 实现一个满足观察者模式的对象,可以使用class来实现。
  4. 要通用性则需要继承。

设计开发(正推法)

  1. 实现一个类对数据的劫持
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];
  }
}
  1. 实现劫持也就是监听方法

需要一个监听队列,注册监听和取消注册监听 分为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);
  }
}
  1. 这里已经基本做完,尝试让他变成响应式数据,这里就需要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>
  );
};
相关推荐
前端工作日常8 分钟前
H5 实时摄像头 + 麦克风:完整可运行 Demo 与深度拆解
前端·javascript
韩沛晓19 分钟前
uniapp跨域怎么解决
前端·javascript·uni-app
前端工作日常19 分钟前
以 Vue 项目为例串联eslint整个流程
前端·eslint
程序员鱼皮21 分钟前
太香了!我连夜给项目加上了这套 Java 监控系统
java·前端·程序员
该用户已不存在1 小时前
这几款Rust工具,开发体验直线上升
前端·后端·rust
前端雾辰1 小时前
Uniapp APP 端实现 TCP Socket 通信(ZPL 打印实战)
前端
无羡仙1 小时前
虚拟列表:怎么显示大量数据不卡
前端·react.js
云水边1 小时前
前端网络性能优化
前端
用户51681661458411 小时前
[微前端 qiankun] 加载报错:Target container with #child-container not existed while devi
前端
东北南西1 小时前
设计模式-工厂模式
前端·设计模式