一个下拉框引发的血案

344 阅读5分钟

DDD 之 Value Object 前端实践

初学乍练

在一个前端页面中,我们需要给用户提供一个可修改某状态的有限变化时,很有多人这么干,看起来蛮清爽的。

下面使用了一个下拉菜单,让用户修改状态,你也可以使用checkbox或其他展现形式的组件,在这里我们不讨论交互层面的东西,只讨论编程。

<select
  v-model="formModel.monitorStatus"
  placeholder="应用类型"
>
  <option :key="1" :value="1">新增</option>
  <option :key="2" :value="2">开启</option>
  <option :key="3" :value="3">停用</option>
</select>

现在有个地方需要展示状态,怎么办?

<div>
  <span v-if="formModel.monitorStatus === 1">新增</span>
  <span v-else-if="formModel.monitorStatus === 2">开启</span>
  <span v-else-if="formModel.monitorStatus === 3">停用</span>
  <span v-else></span>
</div>

如果想给用户提供一个默认值,怎么办?设个值呗,看起来相当简单!

formModel.monitorStatus = 1;

如果有多个页面需要这个功能,怎么办?当然是复制了!一个字就是爽!

<!-- Page1 -->
......

<!-- Page2 -->
......

<!-- Page3 -->
......

每个页面的默认值可能不一样怎么办?设不同的默认值喽,傻Diao!

// Page1
formModel.monitorStatus = 1;

// Page2
formModel.monitorStatus = 2;

// Page3
formModel.monitorStatus = 3;

后来值对应的含义变了怎么办?啥?靠,xxxxx!

略有小成

首先一个状态只有可选的几个值,并且每个值有对应的描述,那么我们可以把它定义为如下的枚举类型。

export const MONITOR_STATUS_ENUM = [[1, "新建"], [2, "开启"], [3, "停用"]];

然后在视图中使用这个枚举类型,当菜单项需要变动的时候我们修改枚举类型即可。

<select
  v-model="formModel.monitorStatus"
  placeholder="应用类型"
>
  <option
    v-for="item in MONITOR_STATUS_ENUM"
    :key="item[0]"
    :value="item[0]"
  >{{ item[1] }}</option>
</select>

现在有个地方需要展示状态,怎么办?加个字典查阅就好。

export const MONITOR_STATUS_ENUM = [[0, "新建"], [1, "开启"], [2, "关闭"]];
export const MONITOR_DESC_MAP = new Map(MONITOR_STATUS_ENUM);

可能你会想到这种方式,但是在我们这个例子中,它会不会有什么问题,卖个关子,欢迎大家讨论。

const MONITOR_DESC_MAP = Object.fromEntries(MONITOR_STATUS_ENUM);

顺带注册个过滤器方便用。

<div>{{ formModel.monitorStatus | toStatusDesc }}</div>

<!-- 语义写法,实际不能这么干,你懂的 -->
filter toStatusDesc(status) {
  const result = MONITOR_DESC_MAP.get(status);
  return result ? result : "无";
}

如果想给用户设置一个默认值,怎么办?

// 这样写代码的可读性很差,如果我不打开枚举类型的定义文件,我根本无法得知1的含义是什么
formModel.monitorStatus = 1;

配套一个语义字典,这样可以大幅提升可读性。

export const MONITOR_STATUS_ENUM = [[1, "新建"], [2, "开启"], [3, "停用"]];
export const MONITOR_DESC_MAP = new Map(MONITOR_STATUS_ENUM);
export const MONITOR_STATUS_DICT = {
  "create": 1,
  "start": 2,
  "stop": 3
};

// 这样可读性有了
formModel.monitorStatus = MONITOR_STATUS_DICT.create;

多个页面不同默认状态怎么办?这, 无需多言了吧。

// Page1
formModel.monitorStatus = MONITOR_STATUS_MAP.create;

// Page2
formModel.monitorStatus = MONITOR_STATUS_MAP.start;

// Page3
formModel.monitorStatus = MONITOR_STATUS_MAP.stop;

后来值的含义变了怎么办?改枚举类型和含义映射就好了。

export const MONITOR_STATUS_ENUM = [[0, "新建"], [1, "开启"], [2, "关闭"]];
export const MONITOR_STATUS_DICT = Object.fromEntries(MONITOR_STATUS_ENUM);
export const MONITOR_STATUS_MAP = {
  "create": 0,
  "start": 1,
  "stop": 2
};

驾轻就熟

感觉确实还可以,但是每次设置默认值,或者脱离视图通过js修改状态,都需要依赖Map映射,有点繁琐,能不能改进一下?擦,果然给了粮食就会要肉吃...

而且这种方法第一次写起来麻烦就麻烦把,后面用起来还这么麻烦,保不准哪天我就越过Map映射直接赋个值,嘿嘿~_~。靠,给自己挖坑还好意思说...

好吧,再送一个锦囊给你 -> 值对象

// 老东西,无需多言
const MONITOR_STATUS_ENUM = [[1, "新建"], [2, "开启"], [3, "停用"]];
const MONITOR_DESC_MAP = new Map(MONITOR_STATUS_ENUM);
const MONITOR_STATUS_DICT = {
  "create": 1,
  "start": 2,
  "stop": 3
};

// 所谓的值对象类
export default class MonitorStatus {

  // 通过一个静态属性暴露
  static enum = MONITOR_STATUS_ENUM;

  // 私有化,不让随便改
  #value = null;

  // 向外暴露,增加了规则校验
  set value(status) {
    if (!this.isMeaningful(status)) {
      throw new Error(`
        不存在状态: ${status},
        可选的状态有:${Object.values(MONITOR_STATUS_DICT)}
      `);
    }

    this.#value = status;
  }

  get value() {
    return this.#value;
  }

  constructor(status) {
    this.value = status;
  }

  // 校验status有效性
  isMeaningful(status) {
    return !!MONITOR_DESC_MAP.get(status);
  }

  // 得到状态描述
  toDesc() {
    return MONITOR_DESC_MAP.get(this.status);
  }

  // 语义化修改状态
  updateByTag(tag) {
    const status = MONITOR_STATUS_DICT[tag];

    if (status == null) {
      throw new Error(`
        不存在状态: ${tag},
        可选的状态有:${Object.keys(MONITOR_STATUS_DICT)}
      `);
    }

    this.#value = status;
  }
}

现在使用时,只导入这个类就好了,感觉比以前更内聚了。

import MonitorStatus from "xxx";

// 拿枚举值
const MONITOR_STATUS_ENUM = MonitorStatus.enum;

// 创建值对象
formModel.monitorStatus = new MonitorStatus(db.status);

提供选项试图时,视图绑定的值对应value属性。

<select
  v-model="formModel.monitorStatus.value"
  placeholder="应用类型"
>
  <option
    v-for="item in MONITOR_STATUS_ENUM"
    :key="item[0]"
    :value="item[0]"
  >{{ item[1] }}</option>
</select>

某个地方需要展示状态,调个方法即可。

<div>{{ formModel.monitorStatus.toDesc() }}</div>

设置默认值,同样调个方法。

formModel.monitorStatus.updateByTag("start");

需要用脚本修改值,根据情况赋值或者调方法

// value赋值:从某种渠道拿到的状态值赋值,无需担心值越界
formModel.monitorStatus.value = 2;

// 调方法:明确更新成某种状态,更语义化
formModel.monitorStatus.updateByTag("stop");

后来值的含义变了,同样修改枚举类型和含义映射就好了。

const MONITOR_STATUS_ENUM = [[0, "新建"], [1, "开启"], [2, "关闭"]];
const MONITOR_DESC_MAP = new Map(MONITOR_STATUS_ENUM);
const MONITOR_STATUS_DICT = {
  "create": 0,
  "start": 1,
  "stop": 2
};

export default class MonitorStatus {
  ......
}

我靠,似乎有点复杂,又似乎有点叼,要不要用呢? 随你喽~~~

要是这个数据我要提交给后端怎么办?传个对象过去不得把它弄崩了=_=! 弄崩不至于,砍你倒是有可能,这个问题还是要解决一下。

首先,如果你用了这个值对象,保不准将来会有多个种,为了区分它们,我们定义个父类,能用接口的话定义个接口最好,这样可以强制约定几个关键方法的命名,比如 isMeaningful,toDesc,updateByTag

class BaseValueObject {

  // 如果子类没有重写,那么JSON.stringify序列化值对象时就会忽略它
  get value() {
    return undefined;
  }
}

// 子类继承就好
class MonitorStatus extends BaseValueObject {}
class XXX extends BaseValueObject {}

现在大多数情况下,我们都是通过JSON格式进行数据传输的,这里我们使用JSON.stringify和它的自定义能力就好了,为了方便,你可以把这个转换逻辑封装到请求前置拦截器里面。

const fromModel = { a: 1, b: new MonitorStatus(1) };
JSON.stringify(fromModel, (key, val) => {
  // 值对象取其value属性
  if (val instanceof BaseValueObject) return val.value;
  return val;
});

这个值对象没有完全按照标准原则设计,比如不可变性,更重要的是鉴借其思想。