Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
ohhoney1 opened this issue Sep 25, 2020 · 2 comments
Open
Labels
thought My thought and summary

Comments

@ohhoney1
Copy link
Owner

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

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

image

第一种方式:我们首先想到的是维护一个对象格式的常量 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 结构。

第二种方式: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)

第三种方式: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 编译,所以还是存在局限性。

第四种方式:用一个最直接的方式,维护一个数组常量: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 }。

image
image

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

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

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

@ohhoney1 ohhoney1 added the thought My thought and summary label Sep 25, 2020
@bigzyp
Copy link

bigzyp commented Mar 30, 2024

非常不错的方案! 请问如果要加一个value为空的全部选项 有好的方案吗

@ohhoney1
Copy link
Owner Author

非常不错的方案! 请问如果要加一个value为空的全部选项 有好的方案吗

类似的吧。看对接接口时,为“全部”选项时,后端需要什么内容。如果是为空或不传代表 “全部”,其实可以考虑增加一个特殊常量 Symbol('ALL') 来表示。然后在统一的请求拦截器中处理:删除 value 为 Symbol('ALL') 的参数。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
thought My thought and summary
Projects
None yet
Development

No branches or pull requests

2 participants