浅谈“配置化”与 normalize 在复杂嵌套组件开发中的应用

简介

视图层相比脚本,具有不便于调试、无效信息过多(与当前逻辑不相关的属性)等特点,因此,同样的逻辑位于视图可能比位于脚本中的复杂程度更高。

因此,在开发复杂组件,尤其是嵌套组件时,最好遵循一定的规范,且尽量简化视图层需要处理的逻辑,应当在脚本中完成大部分视图层所需内容的处理,若是能直接将数据或内容绑定到视图层最好。

但实际开发中,总有一些场景无法完全通过脚本执行所有的预处理,否则可能使脚本过重。

思路介绍

本文通过配置化的方式,将视图结构抽象为脚本中的配置对象,然后在视图层上遍历该对象,并根据子对象的属性判定应当以何种方式渲染结构和内容。

同时,使用自定义的 normalize 函数,将初始的配置对象归一化为标准的配置对象(初始对象为了方便使用,允许使用特定的简化语法,而完整的对象则通过脚本自动生成)

下文以表格嵌套展开行为例介绍实现思路(表格主题为 “亚马逊 FBA 货件” )

示例

全局常量

const moduleName = ref('shipmentManagement'); // 模块名

// 货件状态;用于显示特定 type 的 el-tag 组件
const shipmentStatusTypes = {
  [0]: 'info',
  [1]: '',
  [2]: 'warning',
  [3]: 'success',
};
// 由于引入了 i18n ,切换语言时, el-table-column 组件的 filters 对象也要同步更新,否则会因语言不同而导致过滤失败
const shipmentStatusFilters = computed(() =>
  Object.keys(shipmentStatusTypes).map((type) => {
    return {
      text: $t(`${moduleName.value}.filters.shipmentStatus.${type}`),
      value: type,
    };
  })
);

格式化相关函数

const setupFieldFormatterConfig = (formatter) => {
  return {
    formatter: (value) => formatter(value),
  };
};

const weightFormatter = (value) => value + ' KG';
const volumeFormatter = (value) => value + ' (cm*cm*cm)';
const currencyFormatter = (value) => $t(`currency.${locale.value}`) + value;

// i18n 格式化;这里的函数是可以抽离到全局的
const setupFieldKeyI18n = (fieldName) =>
  $t(`${moduleName.value}.fieldKeys.${fieldName}`);
const setupFiltersI18n = (fieldName, fieldVal) =>
  $t(`${moduleName.value}.filters.${fieldName}.${fieldVal}`);

初始的配置对象

首先,表格初始仅展示 5 个主要字段以及一列功能区(查看、编辑、删除;查看按钮与展开行的功能是一样的,只是点击时会弹出表单)。完整信息则通过展开行的形式显示,并且,各项数据根据各自的关联性进行分组,并展示在嵌套的卡片中。

因此,需要有两个配置对象:固定列配置对象,以及完整的包含所有字段的配置对象。

// 这些对象用不用 ref() 都可以;若允许用户设置固定列的话最好只用 ref,这样可以响应式更新

// 表格默认只展示这些列
//   这里使用对象可以添加单独作用列表格列的配置参数,区别于展开行的配置
const fixedColumnList = [
  { name: 'createdTime' },
  { name: 'shipmentTitle' },
  { name: 'shipmentId' },
  { name: 'warehouseId' },
];

// 展开行时的字段分组(卡片)配置
const nestedFieldConfigs = [
  {
    headerConfigs: 'shipmentInfo', // 若标题没有其他配置需求的话,允许使用简单的字符串表示
    // 字段同理,可以只使用简答的字符串数组表示
    fieldConfigs: [
      'createdTime',
      'shipmentTitle',
      'shipmentStatus',
      'shipmentComment',
      'shipmentId',
    ],
  },
  {
    headerConfigs: 'warehouseInfo',
    fieldConfigs: {
      warehouseId: 'warehouseId',
      // 这里需要对仓库类型做一次过滤,因为后端返回的数据可能是简单 0 | 1 
      // 同时,由于使用了国际化插件,因此,实际需要显示的文本会放在 i18n 模块下
      warehouseType: setupFieldFormatterConfig((val) =>
        setupFiltersI18n('warehouseType', val)
      ),
    },
  },
  {
    headerConfigs: 'logisticsInfo',
    fieldConfigs: {
      carrierName: 'carrierName',
      logisticsChannelType: setupFieldFormatterConfig((val) =>
        setupFiltersI18n('logisticsChannelType', val)
      ),
      logisticsChannelCode: 'logisticsChannelCode',
      logisticsCode: 'logisticsCode',
    },
  },
  {
    headerConfigs: 'timelinessInfo',
    fieldConfigs: [
      'estimateTransitDateCost',
      'estimateCarrierProcessDateCost',
      'estimateFBAProcessDateCost',
      'pickupDate',
      'deliveryDate',
      'registerDate',
      'completeDate',
    ],
  },
  {
    headerConfigs: 'boxInfo',
    fieldConfigs: {
      boxId: 'boxId',
      boxAmount: 'boxAmount',
      boxSize: setupFieldFormatterConfig(volumeFormatter),
      boxRealWeight: setupFieldFormatterConfig(weightFormatter),
      boxVolumeWeight: setupFieldFormatterConfig(weightFormatter),
      sku: 'sku',
      skuAmount: 'skuAmount',
    },
  },
  {
    headerConfigs: 'costInfo',
    fieldConfigs: {
      totalProducts: 'totalProducts',
      totalBoxes: 'totalBoxes',
      unitCharge: setupFieldFormatterConfig(weightFormatter),
      chargeWeight: setupFieldFormatterConfig(currencyFormatter),
      domesticFreight: setupFieldFormatterConfig(currencyFormatter),
      actualDeliveryFreight: setupFieldFormatterConfig(currencyFormatter),
      additionalCharge: setupFieldFormatterConfig(currencyFormatter),
    },
  },
];

自定义 normarlize 函数

/**
 * @param {原配置对象} config
 * @param {额外的 normalizer} normalizerOptions: Array<{ prop: string, normalizer: (value: string) => string }>
 * @param {unnormalized 是对象类型且未提供 name 属性时,通过该参数传入} nameField: string
 * @return normalizedConfig: { name: string, text: string, [key: string]: any }
 */
const normalizeFieldConfig = (
  unnormalized,
  normalizerOptions = {},
  nameField = ''
) => {
  let normalized = {};
  // 若配置对象为字符串,则使用该字符串作为 name 属性的值
  typeof unnormalized === 'string'
    ? (normalized.name = unnormalized)
    : (normalized = Object.assign({}, unnormalized));

  if (!normalized.name) {
    if (!nameField)
      throw new Error(`Type Error: name attribute can not be empty`);
    normalized.name = nameField;
  }

  Object.entries(normalizerOptions).forEach(([prop, normalizers]) => {
    if (!(normalizers instanceof Array)) normalizers = [normalizers];

    /**
     * 若 normalizerOption 对象中的 prop 属性在原 unnormalized 对象中已存在,则使用该属性的旧值作为参数;
     *   否则使用 normalized.name 的值作为参数
     */
    let oldVal = undefined;
    prop in Object.getOwnPropertyNames(unnormalized)
      ? (oldVal = normalized[prop])
      : (oldVal = normalized.name);

    // 依次调用 normalizer ,后面的函数会以前面函数的返回值作为参数
    normalized[prop] = normalizers.reduce((oldVal, normalizer) => {
      return normalizer(oldVal);
    }, oldVal);
  });
  return normalized;
};

归一化原对象,并输出一个打平后的配置对象

打平的配置对象主要是方便表单组件使用,因为表单字段的输入组件无法通过配置化简单处理,除非将所有输入组件的入口封装为一个统一的组件。因此,这里通过打平后的配置对象匹配对应的文本。

同时,某些场景下,字段中还存在嵌套的字段,而此时需要直接遍历后台返回的数据对象,而不是字段的配置对象。这类场景下,也需要通过打平后的配置对象来获取实际文本等属性。下面的视图层就有这种需求。

// 将原来的嵌套化配置对象格式化,并输出一个新的打平后的配置对象
const normalizeNestedConfigs = (nestedConfigs) => {
  return nestedConfigs.reduce((normalizedFlatConfigs, config) => {
    // 处理嵌套区域的 header 配置
    let headerConfigs = normalizeFieldConfig(config.headerConfigs, {
      text: setupFieldKeyI18n,
    });

    normalizedFlatConfigs[headerConfigs.name] = headerConfigs;
    config.headerConfigs = headerConfigs;

    // 处理嵌套区域的字段配置
    let fieldConfigs = config.fieldConfigs;
    // 将数组类型转为对象
    if (fieldConfigs instanceof Array) {
      fieldConfigs = {};
      Object.entries(config.fieldConfigs).forEach(([i, field]) => {
        fieldConfigs[field] = { name: field };
      });
    }

    Object.entries(fieldConfigs).forEach(([field, configs]) => {
      const normalized = normalizeFieldConfig(
        configs,
        {
          text: setupFieldKeyI18n,
        },
        field
      );
      fieldConfigs[field] = normalized;
      normalizedFlatConfigs[field] = normalized;
    });
    config.fieldConfigs = fieldConfigs;

    return normalizedFlatConfigs;
  }, {});
};

// 打平字段配置对象并转换为 i18n 文本
//   这里仅仅是依赖 i18n.locale ,但不需要判定具体的值,只是为了让 vue 框架能够检测 locale 并自动更新 i18n
//   这样实现较为简单,而且计算也很快
const flatFieldConfigs = computed(
  () => locale.value && normalizeNestedConfigs(nestedFieldConfigs)
);

/*
* 若是想要像原本在视图上直接插入 i18n 那样使用的话,就需要给 normalize 函数加一个 appendOptions 参数,
* 然后在 normalizeNestedConfigs 函数的调用处,添加相应的 { textFormatter: ()=> {} } 参数;这里 formatter 属性已经用于字段值的格式化,为避免冲突只能使用其他命名
* 最后是在视图层上添加对应的判定逻辑 flatFieldConfigs[field].textFormatter ? flatFieldConfigs[field].textFormatter(fieldValue) : fieldValue
* 可以看出,不论视图层还是脚本中,都变得很麻烦,所里这里暂时不加这个参数
*/

视图

视图方面还可以将内嵌的卡片抽象为独立组件,方便复用。

<el-table :data="tableData" border stripe style="width: 100%">
  <el-table-column type="selection" width="38"></el-table-column>
  <!-- 通过展开行显示完整信息 -->
  <el-table-column type="expand">
    <template #default="{ row }">
      <el-scrollbar class="bi-shipment-detail" height="300">
        <div
          class="bi-shipment-detail-section"
          v-for="({ headerConfigs, fieldConfigs }, i) in nestedFieldConfigs"
          :key="i"
        >
          <div class="bi-shipment-detail-section__header">
            {{ headerConfigs.text }}
          </div>
          <div class="bi-shipment-detail-section__content">
            <!-- 箱规有单独的渲染规则 -->
            <template v-if="headerConfigs.name === 'boxInfo'">
              <!-- 
                  注意,这里是遍历每一行的数据,因此 name 和 formatter 是通过 flatFieldConfigs 取出的
                  而下面的其他信息区域内的字段,则是遍历各自的 fieldConfigs 对象取出的
              -->
              <div
                class="bi-shipment-detail-section bi-shipment-detail-section--vertical"
                v-for="(box, j) in row.boxList"
                :key="j"
              >
                <div class="bi-shipment-detail-section__header">
                  {{ flatFieldConfigs.boxId.text + ': ' + box.boxId }}
                </div>

                <div class="bi-shipment-detail-section__content">
                  <template
                    v-for="([field, value], k) in Object.entries(box)"
                    :key="k"
                  >
                    <template v-if="field === 'skuList'">
                      <template v-for="(skuItem, l) in box.skuList" :key="l">
                        <div class="bi-shipment-detail-item">
                          {{ flatFieldConfigs.sku.text + ': ' + skuItem.sku }}
                        </div>
                        <div class="bi-shipment-detail-item">
                          {{
                            flatFieldConfigs.skuAmount.text +
                            ': ' +
                            skuItem.skuAmount
                          }}
                        </div>
                      </template>
                    </template>
                    <div
                      v-else-if="field !== 'boxId'"
                      class="bi-shipment-detail-item"
                    >
                      {{
                        flatFieldConfigs[field]?.formatter
                          ? flatFieldConfigs[field].text +
                            ': ' +
                            flatFieldConfigs[field].formatter(value)
                          : flatFieldConfigs[field].text + ': ' + value
                      }}
                    </div>
                  </template>
                </div>
              </div>
            </template>
            <!-- 其他信息区域 -->
            <template v-else>
              <div
                class="bi-shipment-detail-item"
                v-for="([field, config], j) in Object.entries(fieldConfigs)"
                :key="j"
              >
                <template v-if="field === 'shipmentStatus'">
                  {{ $t(`${moduleName}.fieldKeys.shipmentStatus`) + ': ' }}
                  <el-tag :type="shipmentStatusTypes[row.shipmentStatus]">{{
                    setupFiltersI18n('shipmentStatus', row.shipmentStatus)
                  }}</el-tag>
                </template>
                <template v-else>{{
                  config.text +
                  ': ' +
                  (config?.formatter
                    ? config.formatter(row[field])
                    : row[field])
                }}</template>
              </div>
            </template>
          </div>
        </div></el-scrollbar
      >
    </template>
  </el-table-column>
  <!-- 默认显示列 -->
  <el-table-column
    :prop="name"
    :label="flatFieldConfigs[name].text || name"
    v-for="({ name }, i) in fixedColumnList"
    :key="i"
  />
  <el-table-column
    prop="shipmentStatus"
    :label="flatFieldConfigs.shipmentStatus.text || 'shipmentStatus'"
    :filters="shipmentStatusFilters"
    :filter-method="filterShipmentStatus"
  >
    <template v-slot="{ row }">
      <el-tag :type="shipmentStatusTypes[row.shipmentStatus]">{{
        setupFiltersI18n('shipmentStatus', row.shipmentStatus)
      }}</el-tag>
    </template>
  </el-table-column>
  <!-- 功能区 -->
  <el-table-column
    class="bi-table-action"
    :label="$t('common.action')"
    :width="locale == 'en' ? '210' : '200'"
    fixed="right"
  >
    <!-- 暂时无法解决表格列自适应宽度的问题,plus 的源码有些复杂 -->
    <template v-slot="{ $index }">
      <div class="bi-action-group">
        <!-- v-fitColumn="{ subElClass: 'bi-action-btn', gap: 20, numberInRow: 3 }" -->
        <el-button
          class="bi-action-btn"
          size="small"
          @click="handleView($index)"
          >{{ $t(`form.action.view`) }}</el-button
        >
        <el-button
          class="bi-action-btn"
          size="small"
          type="primary"
          @click="handleEdit($index)"
          >{{ $t(`form.action.edit`) }}</el-button
        >
        <el-button
          class="bi-action-btn"
          size="small"
          type="danger"
          @click="handleDelete($index)"
          >{{ $t(`form.action.delete`) }}</el-button
        >
      </div>
    </template>
  </el-table-column>
</el-table>

i18n 模块

最后是该模块的 i18n 配置文本。表格部分只需要看 shipmentManagement.fieldKeysshipmentManagement.filters 即可。其他文本由于未展示相关的视图和逻辑,因此不在此处列出。

const shipmentManagement = {
  info: {
    moduleName: '货件',
  },
  fieldKeys: {
    // 货件
    shipmentInfo: '货件',
    createdTime: '创建时间',
    shipmentTitle: '货件标题',
    shipmentStatus: '货件状态',
    shipmentComment: '备注',
    shipmentId: '货件编号',
    // 仓库
    warehouseInfo: '仓库',
    warehouseId: '仓库编号',
    warehouseType: '仓库类型',
    // 物流
    logisticsInfo: '物流',
    carrierName: '物流商名称',
    logisticsChannelType: '渠道类型',
    logisticsChannelCode: '渠道编号',
    logisticsCode: '运单号',
    // 时效
    timelinessInfo: '时效',
    estimateTransitDateCost: '头程天数',
    estimateCarrierProcessDateCost: '物流商处理天数',
    estimateFBAProcessDateCost: 'FBA 处理天数',
    // 日期
    pickupDate: '提取日期',
    deliveryDate: '签收日期',
    registerDate: '登记日期',
    completeDate: '完成日期',
    // 箱规
    boxInfo: '箱规',
    boxId: '箱子编号',
    boxAmount: '数量',
    boxSize: '尺寸',
    boxRealWeight: '箱子实重',
    boxVolumeWeight: '体积重',
    sku: 'SKU',
    skuAmount: '产品件数',
    // 计费
    costInfo: '计费',
    totalProducts: '总产品件数',
    totalBoxes: '总箱数',
    unitCharge: '运费单价',
    chargeWeight: '实际计费重',
    domesticFreight: '国内运费',
    actualDeliveryFreight: '实际头程收费',
    additionalCharge: '额外收费',
  },
  filters: {
    shipmentStatus: {
      [0]: '待发货',
      [1]: '在途',
      [2]: '上架中',
      [3]: '已完成',
    },
    warehouseType: {
      [0]: 'FBA',
      [1]: '海外仓',
    },
    logisticsChannelType: {
      [0]: '快递',
      [2]: '空运',
      [1]: '海运',
      [3]: '小包',
    },
  },
};

export default { shipmentManagement };

将 normalizer 抽离到外面作为单独的工具

import { computed } from 'vue';

/**
 * @param {原配置对象} unnormalized
 * @param {额外的 normalizer} normalizerOptions: Array<{ prop: string, normalizer: (value: string) => string }>
 * @param {unnormalized 是对象类型且未提供 name 属性时,通过该参数传入} nameField: string
 * @return normalizedConfig: { name: string, text: string, [key: string]: any }
 */
const normalizeFieldConfig = (
  unnormalized,
  normalizerOptions = {},
  nameField = ''
) => {
  let normalized = {};
  // 若配置对象为字符串,则使用该字符串作为 name 属性的值
  typeof unnormalized === 'string'
    ? (normalized.name = unnormalized)
    : (normalized = Object.assign({}, unnormalized));

  if (!normalized.name) {
    if (!nameField)
      throw new Error(`Type Error: name attribute can not be empty`);
    normalized.name = nameField;
  }

  Object.entries(normalizerOptions).forEach(([prop, normalizers]) => {
    if (!(normalizers instanceof Array)) normalizers = [normalizers];

    /**
     * 若 normalizerOption 对象中的 prop 属性在原 unnormalized 对象中已存在,则使用该属性的旧值作为参数;
     *   否则使用 normalized.name 的值作为参数
     */
    let oldVal = undefined;
    prop in Object.getOwnPropertyNames(unnormalized)
      ? (oldVal = normalized[prop])
      : (oldVal = normalized.name);

    // 依次调用 normalizer ,后面的函数会以前面函数的返回值作为参数
    normalized[prop] = normalizers.reduce((oldVal, normalizer) => {
      return normalizer(oldVal);
    }, oldVal);
  });
  return normalized;
};

// 将原来的嵌套化配置对象格式化,并输出一个新的打平后的配置对象;参数说明与下面的函数相同
const normalizeNestedConfigs = (nestedConfigs, normalizerOptions) => {
  return nestedConfigs.reduce((normalizedFlatConfigs, config) => {
    // 处理嵌套区域的 header 配置
    let headerConfigs = normalizeFieldConfig(
      config.headerConfigs,
      normalizerOptions
    );

    normalizedFlatConfigs[headerConfigs.name] = headerConfigs;
    config.headerConfigs = headerConfigs;

    // 处理嵌套区域的字段配置
    let fieldConfigs = config.fieldConfigs;
    // 将数组类型转为对象
    if (fieldConfigs instanceof Array) {
      fieldConfigs = {};
      config.fieldConfigs.forEach((field) => {
        fieldConfigs[field] = { name: field };
      });
    }

    Object.entries(fieldConfigs).forEach(([field, configs]) => {
      const normalized = normalizeFieldConfig(
        configs,
        normalizerOptions,
        field
      );
      fieldConfigs[field] = normalized;
      normalizedFlatConfigs[field] = normalized;
    });
    config.fieldConfigs = fieldConfigs;

    return normalizedFlatConfigs;
  }, {});
};

/**
 *
 * @param {嵌套的配置对象} nestedFieldConfigs
 * @param {针对特定属性的 normalizer , 比如针对文本的 i18n 转换函数} normalizerOptions
 * @param {触发配置对象更新的条件,一个布尔值或返回布尔值的函数} trigger: true | () => boolean
 * @returns
 */
export function useFlatFieldConfigs(nestedFieldConfigs, normalizerOptions, trigger) {
  const flatFieldConfigs = computed(
    () =>
      (trigger instanceof Function ? trigger() : trigger) &&
      normalizeNestedConfigs(nestedFieldConfigs, normalizerOptions)
  );

  return flatFieldConfigs;
}

const normalizeSimpleConfigs = (
  simpleFieldConfigs,
  normalizerOptions
) => {
  // 将字符串类型转为对象
  if (typeof simpleFieldConfigs[0] === 'string') {
    simpleFieldConfigs.forEach((field) => {
      simpleFieldConfigs[field] = { name: field };
    });
  }

  Object.entries(simpleFieldConfigs).forEach(([field, configs]) => {
    const normalized = normalizeFieldConfig(configs, normalizerOptions, field);
    simpleFieldConfigs[field] = normalized;
  });

  return simpleFieldConfigs;
};

/**
 *
 * @param {简单的一维数组嵌套一层对象,或两层嵌套的对象} simpleFieldConfigs
 * @param {针对特定属性的 normalizer , 比如针对文本的 i18n 转换函数} normalizerOptions
 * @param {触发配置对象更新的条件,一个布尔值或返回布尔值的函数} trigger: true | () => boolean
 * @returns
 */
export function useSimpleFieldConfigs(
  simpleFieldConfigs,
  normalizerOptions,
  trigger
) {
  const flatFieldConfigs = computed(
    () =>
      (trigger instanceof Function ? trigger() : trigger) &&
      normalizeSimpleConfigs(simpleFieldConfigs, normalizerOptions)
  );

  return flatFieldConfigs;
}

使用示例:

// 这里将 i18n 的判定在外部注入,使得此模块与 i18n 隔离,以便在未引入 i18n 的环境中也能使用
const { locale } = useI18n();
const moduleName = ref('shipmentManagement');

// nestedFieldConfigs 可以参考上面的示例

const flatFieldConfigs = useFlatFieldConfigs(
  nestedFieldConfigs,
  { text: setupFieldKeyI18nGlobal(moduleName.value) } ,
  locale.value
);


// 配置对象较为简单时,可以使用另一个函数 useSimpleFieldConfigs, 如
const simpleFieldConfigs = [ 'id', 'name', 'type' ];

setupFieldKeyI18nGlobal 长这样,输入一个模块名参数,返回一个新函数,用于获取当前模块对应的 i18n 文本:

import { i18n } from '@/i18n';
const { t: $t } = i18n.global;

export const setupFieldKeyI18nGlobal = (moduleName) => (fieldName) =>
  $t(`${moduleName}.fieldKeys.${fieldName}`);
posted @ 2022-12-07 22:54  CJc_3103  阅读(117)  评论(0编辑  收藏  举报