浅谈“配置化”与 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.fieldKeys
和 shipmentManagement.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}`);