前端常量维护:TypeScript 项目中维护常量引发的思考
背景:项目基于antd + typescript 开发,经常用到字段映射列表项,这里叫做常量,并配合 Select 筛选联动。具体需求:
- Select 中水果列表严格按顺序展示;
- “种类”一栏里,通过接口返回的 fruitType 字段匹配对应的中文。
第一种方式:Object
我们首先想到的是维护一个对象格式的常量 FRUITS_OBJECT,这样很容易完成需求2。然后用工具函数 mapObjectToArray,把 FRUITS_OBJECT 转成 FRUITS_LIST 数组,这样可以满足需求1,循环展示列表项。而且,我们可以借助 ts 的索引类型拿到 fruitType 的类型。
interface ListItem {
label: string
value: string
}
export const FRUITS_OBJECT = {
apple: '苹果',
banana: '香蕉',
pear: '梨'
}
function mapObjectToArray(o: Record<string, string>) {
const arr: ListItem[] = []
Object.keys(o).forEach(item => {
arr.push({ label: o[item], value: item })
})
return arr
}
export const FRUITS_LIST = mapObjectToArray(FRUITS_OBJECT)
export type FruitType = keyof typeof FRUITS_OBJECT // 'apple' | 'banana' | 'pear'
此时,问题出现了:对 Object 遍历的时候,我们无法保证 key 的顺序。比如我们的数据源 FRUITS_OBJECT 新增了一个特殊值:{ '2': '两个未知水果' }
,调用 mapObjectToArray 后的输出为:
const FRUITS_OBJECT = {
apple: '苹果',
banana: '香蕉',
pear: '梨',
2: '两个未知水果'
}
mapObjectToArray(FRUITS_OBJECT)
// 结果为:[{label: "两个未知水果", value: "2"}, {label: "苹果", value: "apple"}, {label: "香蕉", value: "banana"}, {label: "梨", value: "pear"}]
这种情况,列表渲染(比如 Select 的下拉选择)就出现了我们不想要的数据顺序展示,显然不满足需求2:列表项第一个不是 “苹果”。探究原因首先会想到,遍历对象的 key 时,不能保证的 key 的顺序。但是为什么呢?怎么让 Object 的遍历输出时保证有序?
题外探讨
遍历 Object 的 key 时,顺序是怎样的呢?上网查下资料,大多直接写着:
Chrome Opera 的 JavaScript 解析引擎遵循的是 ECMA-262 第五版规范。因此,使用 for-in 语句遍历对象属性时遍历顺序并非属性的构建顺序。而 IE6 IE7 IE8 Firefox Safari 的 JavaScript 解析引擎遵循的是较老的 ECMA-262 第三版规范,属性遍历顺序由属性构建的顺序决定。
查了下 ECMS 文档,嗯,确实是这样。另外还有:
Chrome Opera 中使用 for-in 语句遍历对象属性时会遵循一个规律:它们会先提取所有 key 的 parseFloat 值为非负整数的属性,然后根据数字顺序对属性排序首先遍历出来,然后按照对象定义的顺序遍历余下的所有属性。
这个就不好说了,浏览器版本更新太快了,在一些现代浏览器的新版本中测试了下,发现并不会对 key 进行 parseFloat 转换。猜测是 js 解析引擎已经原生支持了 es6及以上规范。查了下文档,证明是猜测对的:es6 规范中 OrdinaryOwnPropertyKeys 明确了遍历 Object 的 key 时,顺序是可预期的,截止目前的es11(es2020)保持不变:
- 优先数字类型的 key,升序排列;
- 其次是字符串类型的 key,按照定义顺序排列;
- 最后是 Symbol 类型的 key,按照定义顺序排列。
以上规定只能特定语法中生效:Object.assign
| Object.defineProperties
| Object.getOwnPropertyNames
| Object.getOwnPropertySymbols
| Reflect.ownKeys
。对于另外一些常用的遍历对象方法仍不能保证有序:Object.keys
| for ... in
| JSON.parse
| JSON.stringify
。
说了这么多,目前看来使用 Object 或者说 JSON 有次序的保存数据都是不靠谱的。有没有一种方案能保证键值对数据结构的次序呢?答案是肯定的:Map 结构。
第二种方式:Map
ES6新增了一种数据结构类型 Map,我们可以用它来保证顺序。通过 getMapValue 方法一次性拿到我们想要的常量:FRUITS_LIST 和 FRUITS_OBJECT。缺点:拿不到 fruitType 的类型 FruitType,这在 TypeScript 项目中有点难受,类型推断没有达到预期。
const mapFruits = new Map([
['apple', '苹果'],
['banana', '香蕉'],
['pear', '梨'],
[2, '两个未知水果']
])
function getMapValue(map: Map<string, string>) {
const o: Record<string, string> = {}
const arr: { label: string; value: string }[] = []
map.forEach((value, key) => {
o[key] = value
arr.push({
label: value,
value: key
})
})
return { o, arr }
}
export const { o: FRUITS_OBJECT, arr: FRUITS_LIST } = getMapValue(mapFruits)
第三种方式:Enum
TypeScript 中的枚举 enum 是否更符合我们的要求呢,比如这里用字符串枚举实现需求:
export enum EFruits {
apple = '苹果',
banana = '香蕉',
pear = '梨'
}
function mapEnumToList(eu: { [key in string]: string }) {
const arr: ListItem[] = []
Object.keys(eu).forEach(item => {
arr.push({ label: eu[item], value: item })
})
return arr
}
// 这个方法也是可以拿到 fruitType 的类型。
export type FruitType = keyof typeof EFruits // 'apple' | 'banana' | 'pear'
// 渲染调用
const fruitType: FruitType = 'apple'
const currentType = EFruits[FruitType]
目前看来,enum 似乎满足我们的需求:可以拿到 fruitType 的类型 FruitType,而且利用 enum 本身的优势做映射也比较合适,如果value是数字的话,还可以做双向映射。但是,enum 的成员不能是数字,enum { '2' = '两个未知水果' } 不能通过 tsc 编译,所以还是存在局限性。
第四种方式:Array
用一个最直接的方式,维护一个数组常量:FRUITS_LIST,通过一个通用工具函数 mapArrayToObject,把 FRUITS_LIST 转成 FRUITS_OBJECT 形式,这样既能保证顺序,也能保证 TypeScript 中高效的类型安全。下面针对数组保存常量的方式,我们写个完整的示例。
// util.ts
export interface SelectItem {
label: string
value: string | number
}
// 编写一个工具类型:从联合类型中找到想要的某一类型,并提取相应属性 label 的值
type ExtractValue<T, K> = T extends { value: K; label: infer R } ? R : never
export const genMapObject = <T extends Readonly<SelectItem[]>>(originData: T) => {
const o: {
[K in T[number]['value']]: ExtractValue<T[number], K>
} = Object.create(null)
originData.forEach(item => {
// ;(o as any)[item.value] = item.value
o[item.value as T[number]['value']] = item.label as ExtractValue<T[number], T[number]['value']>
})
return o
}
// constant.ts
export const FRUITS_LIST = [
{ label: '苹果', value: 'apple' },
{ label: '香蕉', value: 'banana' },
{ label: '梨', value: 'pear' }
] as const
export const FRUITS_OBJECT = genMapObject(FRUITS_LIST)
export type T_FRUITS_TYPE = keyof typeof FRUITS_OBJEC
// page.ts
const t: T_FRUITS_TYPE = 'apple'
const currentFruit = FRUITS_OBJECT[t] // '苹果'
此外,利用 TypeScript 的特性做了一些类型推论的优化。这里需要了解 ts 的几个知识点:
- const 断言:3.4版本新增的一个类型断言功能:不扩展字面类型;把对象断言为只读的对象属性;把数组断言为只读的元祖。例如:const foo = 1,foo 的类型为 1 ,而不是 number。
- 索引访问类型:可以通过索引访问类型访问类型的属性,支持 [number] 方式访问数组项的类型。
- infer 关键字:2.8版本新增的映射类型中的类型推论方法。相关的常见工具类型包括:提取函数返回值 ReturnType,提取函数函数 ParamType 等。
通过调用 genMapObject 我们可以获取到安全的映射对象类型,而不是: { apple: string; banana: string; pear: string }。
总结:
讨论了上述几个维护常量列表的方法,再针对数据源我们分为两类:一是可控且固定的数据源,即 key 只是字符串类型,形如 { apple: '苹果', banana: '香蕉', pear: '梨' };二是不可控数据源,即 key 可能有数字、Symbol 类型,形如 { [Symbol()]: 'aa', 2: 'bb', name: 'cc' }。按照不同类推荐不同的方案。
对于第一类数据源:Map 方式拿不到 fruitType 的类型,不考虑。enum 满足需求,但是性能有损耗,不如直接用对象,即推荐选用第一种方式。
对于第二类数据源:enum 和 Object 都无法保证,Map 和 数组方式满足。为了保证 TypeScript 环境下有良好的类型提示,我们优先选择数组,即推荐选用第四种方式——数组:既能保证有次序遍历,又能保证更安全的类型。
转载自:https://juejin.cn/post/6876624667533115400