跳到正文

工作收集/面向对象

GitHub

用实体继承与注册表统一预算卡片

面向对象继承多态工厂注册表React

场景

租金、押金和采购预算单共用同一种卡片布局,都要展示预算单号、申请人和状态,但各自的业务字段和可用操作不同。把这些差异收进对应实体,卡片就可以按统一结构渲染。

基类提供公共字段和操作,子类通过 super 复用后追加自己的内容。每个操作包含 key、label 和 execute:例如租金实体既定义「上传合同」按钮,也提供点击后调用的函数。

渲染时,工厂根据 budget.type 创建实体,UI 读取字段和操作。此时 execute 只是一个函数,不会执行;点击按钮后才调用它,触发对应业务函数。

代码

这里只保留预算类型到实体类的注册表。操作直接写在 getActions() 返回的对象里,卡片统一绑定 onClick={() => action.execute()}。

实体直接导入业务函数。配套的 budget-actions.js 为独立阅读提供了输出调用参数的示例函数,不会创建真实弹窗;接入项目时替换为实际弹窗实现。按钮设置 type="button",避免卡片放进表单时意外提交。

基类提供公共字段与操作

import {
  openDetailModal,
  openEditModal,
  openRefundModal,
  openUploadModal
} from './budget-actions.js'
 
// 1. 基础实体:公共字段和操作
class BaseBudgetEntity {
  constructor(data) {
    this.data = data
  }
 
  getFields() {
    return [
      {
        key: 'id',
        label: '预算单号',
        value: this.data.id
      },
      {
        key: 'applicant',
        label: '申请人',
        value: this.data.applicant
      },
      {
        key: 'status',
        label: '状态',
        value: this.data.status
      }
    ]
  }
 
  getActions() {
    return [
      {
        key: 'detail',
        label: '查看详情',
        execute: () => openDetailModal(this.data)
      }
    ]
  }
}

子类追加字段,并直接提供操作函数

// 2. 业务实体:只维护各自的差异
class RentBudgetEntity extends BaseBudgetEntity {
  getFields() {
    return [
      ...super.getFields(),
      {
        key: 'rentAmount',
        label: '租金',
        value: this.data.rentAmount
      },
      {
        key: 'rentPeriod',
        label: '租期',
        value: this.data.rentPeriod
      }
    ]
  }
 
  getActions() {
    return [
      ...super.getActions(),
      {
        key: 'edit',
        label: '编辑',
        execute: () => openEditModal(this.data)
      },
      {
        key: 'uploadContract',
        label: '上传合同',
        execute: () => openUploadModal(this.data)
      }
    ]
  }
}
 
class DepositBudgetEntity extends BaseBudgetEntity {
  getFields() {
    return [
      ...super.getFields(),
      {
        key: 'depositAmount',
        label: '押金',
        value: this.data.depositAmount
      },
      {
        key: 'refundDate',
        label: '退还日期',
        value: this.data.refundDate
      }
    ]
  }
 
  getActions() {
    return [
      ...super.getActions(),
      {
        key: 'refund',
        label: '退款',
        execute: () => openRefundModal(this.data)
      }
    ]
  }
}
 
class PurchaseBudgetEntity extends BaseBudgetEntity {
  getFields() {
    return [
      ...super.getFields(),
      {
        key: 'amount',
        label: '采购金额',
        value: this.data.amount
      },
      {
        key: 'supplier',
        label: '供应商',
        value: this.data.supplierName
      }
    ]
  }
 
  getActions() {
    return [
      ...super.getActions(),
      {
        key: 'edit',
        label: '编辑',
        execute: () => openEditModal(this.data)
      }
    ]
  }
}

按预算类型创建实体

// 3. 注册表:预算类型 → 实体类
const budgetEntityRegistry = new Map([
  ['rent', RentBudgetEntity],
  ['deposit', DepositBudgetEntity],
  ['purchase', PurchaseBudgetEntity]
])
 
// 4. 工厂:根据类型创建实体,未知类型退回公共展示
function createBudgetEntity(data) {
  const EntityClass =
    budgetEntityRegistry.get(data.type) ?? BaseBudgetEntity
 
  return new EntityClass(data)
}

卡片统一渲染和绑定点击

// 5. UI:统一渲染,点击时调用操作自带的 execute
function BudgetCard({ budget }) {
  const entity = createBudgetEntity(budget)
  const fields = entity.getFields()
  const actions = entity.getActions()
 
  return (
    <div>
      {/* 字段展示 */}
      {fields.map(field => (
        <div key={field.key}>
          <span>{field.label}</span>
          <span>{field.value}</span>
        </div>
      ))}
 
      {/* 行为展示 */}
      {actions.map(action => (
        <button
          type="button"
          key={action.key}
          onClick={() => action.execute()}
        >
          {action.label}
        </button>
      ))}
    </div>
  )
}

记一下

三种类型分别得到什么

所有类型都会先展示预算单号、申请人和状态,并提供「查看详情」。下面是各自追加的部分:

类型追加字段追加操作
rent租金、租期编辑、上传合同
deposit押金、退还日期退款
purchase采购金额、供应商编辑
未注册类型无无

例如租金预算会得到 5 个字段和 3 个操作。创建实体、读取操作或渲染卡片都不会打开弹窗,点击「上传合同」才调用 openUploadModal(this.data)。

为什么用箭头函数

execute: () => openUploadModal(this.data) 把调用延后到点击时,并捕获当前实体的 this。不要写成 execute: openUploadModal(this.data),那会在读取操作列表时立即执行。

super.getActions() 复用基类的「查看详情」,子类再追加自己的操作;super.getFields() 同样复用公共字段。

新增内容时改哪里

变化修改位置
新增预算类型新增实体类,并加入 budgetEntityRegistry
某种预算新增操作在对应实体的 getActions() 中追加 { key, label, execute };全新能力还需实现业务函数
改变某个操作的具体行为对应业务函数,必要时调整实体中的 execute
改变字段或按钮的布局BudgetCard

只要字段和操作保持相同结构,新增类型或操作就不需要去卡片里补分支。如果新增的是特殊控件或全新布局,UI 仍然需要扩展。

这个简化版本的取舍

实体决定展示哪些字段、提供哪些操作以及操作的执行入口;业务函数实现弹窗等交互;UI 遍历字段和按钮,在点击时调用 execute()。

实体因此直接依赖业务函数。对于只服务于预算页面的代码,这样容易定位和修改。如果以后同一个实体要用于不同页面、替换交互实现,再考虑把业务能力从外部传入。

未知类型会退回基类,只显示公共信息和「查看详情」。这是示例的兜底策略,需要严格识别类型时可以改成报错。示例没有实现状态判断、权限校验或金额格式化。

配置表是什么

配置表就是一个普通 JavaScript 对象,按类型保存字段和操作。例如:

const budgetConfig = {
  rent: {
    fields: [
      { key: 'rentAmount', label: '租金' },
      { key: 'rentPeriod', label: '租期' },
    ],
  },
  deposit: {
    fields: [
      { key: 'depositAmount', label: '押金' },
      { key: 'refundDate', label: '退还日期' },
    ],
  },
}

公共函数通过 budgetConfig[budget.type] 取出配置,拼接公共字段,再从 budget 取值即可。配置里也可以放操作函数。

如果差异主要是固定字段和按钮名称,配置表通常更直接;如果各类型逐渐出现独立的计算和判断,实体类可以集中承载这些逻辑。操作带上 execute 并不意味着必须使用类,选择应以是否容易理解和维护为准。

面试时怎么讲

这段设计把公共字段和操作放进基类,各业务子类追加自己的差异,工厂根据预算类型创建对应实体。操作同时带有名称和执行函数,UI 只负责统一渲染并绑定点击,因此新增某种预算的操作可以集中在对应实体中修改。为了保持简单,实体直接调用业务函数,代价是与具体交互实现存在耦合;如果差异只是固定配置,也可以用普通对象实现。