likes
comments
collection
share

前端常量维护:TypeScript 项目中维护常量引发的思考

作者站长头像
站长
· 阅读数 13

背景:项目基于antd + typescript 开发,经常用到字段映射列表项,这里叫做常量,并配合 Select 筛选联动。具体需求:

  1. Select 中水果列表严格按顺序展示;
  2. “种类”一栏里,通过接口返回的 fruitType 字段匹配对应的中文。

前端常量维护:TypeScript 项目中维护常量引发的思考

第一种方式: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)保持不变:

  1. 优先数字类型的 key,升序排列;
  2. 其次是字符串类型的 key,按照定义顺序排列;
  3. 最后是 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 的几个知识点:

  1. const 断言:3.4版本新增的一个类型断言功能:不扩展字面类型;把对象断言为只读的对象属性;把数组断言为只读的元祖。例如:const foo = 1,foo 的类型为 1 ,而不是 number。
  2. 索引访问类型:可以通过索引访问类型访问类型的属性,支持 [number] 方式访问数组项的类型。
  3. infer 关键字:2.8版本新增的映射类型中的类型推论方法。相关的常见工具类型包括:提取函数返回值 ReturnType,提取函数函数 ParamType 等。

通过调用 genMapObject 我们可以获取到安全的映射对象类型,而不是: { apple: string; banana: string; pear: string }。

前端常量维护:TypeScript 项目中维护常量引发的思考 前端常量维护:TypeScript 项目中维护常量引发的思考

总结:

讨论了上述几个维护常量列表的方法,再针对数据源我们分为两类:一是可控且固定的数据源,即 key 只是字符串类型,形如 { apple: '苹果', banana: '香蕉', pear: '梨' };二是不可控数据源,即 key 可能有数字、Symbol 类型,形如 { [Symbol()]: 'aa', 2: 'bb', name: 'cc' }。按照不同类推荐不同的方案。

对于第一类数据源:Map 方式拿不到 fruitType 的类型,不考虑。enum 满足需求,但是性能有损耗,不如直接用对象,即推荐选用第一种方式。

对于第二类数据源:enum 和 Object 都无法保证,Map 和 数组方式满足。为了保证 TypeScript 环境下有良好的类型提示,我们优先选择数组,即推荐选用第四种方式——数组:既能保证有次序遍历,又能保证更安全的类型。