【封裝小技巧】列表處理函式的封裝

未覺雨聲發表於2022-05-14
伸手請直接跳到【方法合集】~

列表 List(包括陣列和 Set 或者各種類陣列結構都可以是列表)資料的作為系列資料的載體可以說是隨處可見了,必然在專案開發中是少不了對列表的處理,或多或少的也會有對列表處理方法的封裝,這次我們就來看看有那些常見的列表處理函式。

如果你覺得文章對你有所幫助,希望你可以慷慨解囊地給一個贊~

List 結構轉 Map 結構

這個可以說是最常規的,也是最頻繁的處理了,當我們有一系列物件時,經常會遇到根據物件的 id 來查詢對應的物件,當列表比較大或者查詢次數比較多的時候,直接使用 Array.find 來查詢成本就會很高,於是將其轉成 id 作為 key,物件本身作為 vulue 的 Map 結構了。

// prop 指定使用哪個屬性的值作為 key
function transformListToMap<T = any>(list: T[], prop: keyof T) {
  const map = {} as Record<string, T>

  if (!prop) return map

  list.forEach(item => {
    // 這裡丟到 String 裡規避一下 ts 的型別限制
    map[String(item[prop])] = item
  })

  return map
}

不過咋一看,這方法好像單薄的一些,不能覆蓋一些相對複雜的情況。

比如當需要一些組合值或者計算值作為 key 時,那單傳一個 prop 是不能滿足情況的。

再比如,當需要作為 value 的部分不是物件本身,而是一些特定的屬性或者一些屬性的組合或計算,那顯然目前的引數也是無法支援的。

於是我們再加億點細節,完善一下這個函式:

// 這是上一期寫的 is 系列函式,在文章最底部有連結
import { isDefined, isFunction } from './is'

// 第二個引數同時支援傳入一個函式,以支援返回任意處理的值作為 key
// 增加第三個引數,擴充支援傳入一個函式以處理任意的值作為 value
function transformListToMap<T = any, K = T>(
  list: T[],
  prop: keyof T | ((item: T) => any),
  accessor: (item: T) => K = v => v as any
): Record<string, K> {
  const map = {} as Record<string, any>

  if (!isDefined(prop)) return map

  // 統一處理成讀取函式
  const propAccessor = isFunction(prop) ? prop : (item: T) => item[prop]

  list.forEach(item => {
    const key = propAccessor(item)

    // 防止傳入不規範函式出現 null 或 undefined,讓其靜默失效
    if (isDefined(key)) {
      map[key] = accessor(item)
    }
  })

  return map
}

移除 List 中的特定元素

我先貼一段程式碼,我相信大夥應該沒少寫過:

const list: any[] = [/* ... */]
const removedId = 'removedId'
const index = list.findIndex(item => item.id === removedId)

if (index !== -1) {
  list.splice(index, 1)
}

沒錯,根據條件刪除列表中的特定元素也是很常見的需求了,於是我們也可以來封裝一下:

function removeArrayItem<T = any>(
  array: T[],
  item: T | ((item: T) => boolean), // 老樣子支援傳入一個函式適配複雜情況
  isFn = false // 用來指示列表裡的元素是否是函式,以適配極少數情況
): T | null {
  let index = -1

  if (isFn || typeof item !== 'function') {
    index = array.findIndex(current => current === item)
  } else {
    index = array.findIndex(item as (item: T) => boolean)
  }

  if (~index) {
    return array.splice(index, 1)[0]
  }

  return null
}

不過有時候,我們可能需要同時刪除多個元素,那上面的方法是無法覆蓋的,於是我們還需要再改造一下:

// 在處理上,會直接操作源列表,並返回被移除的元素集合
function removeArrayItems<T = any>(
  array: T[],
  items: T | T[] | ((item: T) => boolean),
  isFn = false
): T[] {
  const multiple = Array.isArray(items)

  // 針對刪除單個元素單獨處理
  if (!multiple && (isFn || typeof items !== 'function')) {
    const index = array.findIndex(current => current === items)

    if (~index) {
      return array.splice(index, 1)
    }
  } else {
    let filterFn: (item: T) => boolean

    if (multiple) {
      const removedSet = new Set(items)
      filterFn = item => removedSet.has(item)
    } else {
      filterFn = items as (item: T) => boolean
    }

    // 淺克隆源列表,用來遍歷處理
    const originArray = Array.from(array)
    const removedItems: T[] = []

    // 用源列表來儲存刪除後的結果以達到直接操作源列表的目的
    array.length = 0
    originArray.forEach(item => (filterFn(item) ? removedItems : array).push(item))

    return removedItems
  }

  return []
}

函式的後半部分的處理可能有一些抽象,可以慢慢屢一下。

這個函式雖然涵蓋了多元素刪除的情況,不過當使用自定義函式來進行刪除時,可能原本只是希望刪除一個元素,但卻會對整個列表進行完整的遍歷,從而損失了一些效能。

對 List 中的元素進行歸類(GroupBy)

例如有下面這樣一組資料:

const list = [
  { type: 'a', name: 'x', count: 10 },
  { type: 'a', name: 'y', count: 11 },
  { type: 'a', name: 'x', count: 12 },
  { type: 'a', name: 'y', count: 13 },
  { type: 'b', name: 'x', count: 14 },
  { type: 'b', name: 'y', count: 15 }
]

現在需要針對同 type 且同 name 的數量進行求和,那這裡就會需要我們把資料按照 typename 兩個屬性進行歸類,也就是很經典的 GroupBy 問題了。

其實第一個案例的將 List 轉 Map 結構本質也是一個 GroupBy 問題,只不過是最簡單的一維歸類。

當然如果我們知道只會根據兩個屬性進行歸類的話,直接用一個兩層的 Map 來儲存結果是沒問題的:

const record = {}

arr.forEach(({ type, name, count }) => {
  if (!record[type]) {
    record[type] = {}
  }

  const typeRecord = record[type]

  if (!typeRecord[name]) {
    typeRecord[name] = 0
  }

  typeRecord[name] += count
})

record.a.x // 22

不過我們封裝通用的工具函式,肯定是要考慮儘量覆蓋可能出現的情況的(十倍原則),所以我們出發點是要支援無限層級的分組(只要記憶體夠用),這裡就直接上完全體程式碼了:

function groupByProps<T = any>(
  list: T[],
  // 可以傳入一個陣列按順序指定要 groupBy 的屬性
  props: Array<string | ((item: T) => any)> | string | ((item: T) => any) = []
) {
  // 如果傳入了單個屬性或者函式,先統一處理成陣列
  if (typeof props === 'string' || typeof props === 'function') {
    props = [props]
  }

  const propCount = props.length
  const zipData: Record<string, any> = {}

  for (const item of list) {
    // 需要一個變數用來記錄當前屬性對應的分組層級的 record 物件
    // 這裡的型別推斷需要額外定義不少變數,省事來個 any
    let data: any

    for (let i = 0; i < propCount; ++i) {
      const isLast = i === propCount - 1
      const prop = props[i]
      const value = typeof prop === 'function' ? prop(item) : item[prop as keyof T]

      if (!data) {
        if (!zipData[value]) {
          // 如果到最後一層時,應該初始化一個陣列來儲存分組後的結果
          zipData[value] = isLast ? [] : {}
        }

        data = zipData[value]
      } else {
        if (!data[value]) {
          data[value] = isLast ? [] : {}
        }

        data = data[value]
      }
    }

    data.push(item)
  }

  return zipData
}
這個函式返回結果的型別推斷目前沒想到特別好的辦法,只能先用 Record<string, any> 處理。

根據條件對 List 的元素進行排序

這是這次的最後一個函式了(並不是),也是一個跟高頻的場景。

根據我個人以往的經驗,但凡遇到需要用表格展示資料的場合,都會出現根據某列對資料進行排序的需求。

對於只針對單一屬性的排序,我相信大家應該倒著寫都能寫出來了,對於一個通用函式當然是需要支援多列的排序了(作為最後一個函式,我直接上完整程式碼給大家自己讀一讀):

import { isObject } from './is'

// 支援細粒度定製某個屬性的排序規則
interface SortOptions<T = string> {
  key: T,
  method?: (prev: any, next: any) => number, // 排序的方法
  accessor?: (...args: any[]) => any, // 讀取屬性的方法
  type?: 'asc' | 'desc',
  params?: any[] // 傳入讀取器的額外引數
}

// 預設的排序方法
const defaultSortMethod = (prev: any, next: any) => {
  if (Number.isNaN(Number(prev) - Number(next))) {
    return String(prev).localeCompare(next)
  }

  return prev - next
}

function sortByProps<T = any>(
  list: T[],
  props: keyof T | SortOptions<keyof T> | (keyof T | SortOptions<keyof T>)[]
) {
  if (
    !list.sort ||
    (isObject<SortOptions>(props) && !props.key) ||
    !(props as string | SortOptions[]).length
  ) {
    return list
  }

  const sortedList = Array.from(list)

  if (!Array.isArray(props)) {
    props = [props]
  }

  const formattedProps = props
    .map(
      value =>
        (typeof value === 'string'
          ? {
              key: value,
              method: defaultSortMethod,
              type: 'asc'
            }
          : value) as SortOptions<keyof T>
    )
    .map(value => {
      if (typeof value.accessor !== 'function') {
        value.accessor = (data: T) => data[value.key]
      }

      if (typeof value.method !== 'function') {
        value.method = defaultSortMethod
      }

      value.params = Array.isArray(value.params) ? value.params : []

      return value as Required<SortOptions>
    })

  sortedList.sort((prev, next) => {
    let lastResult = 0

    for (const prop of formattedProps) {
      const { method, type, accessor, params } = prop
      const desc = type === 'desc'
      const result = method(accessor(prev, ...params), accessor(next, ...params))

      lastResult = desc ? -result : result
      // 若不為0則無需進行下一層排序
      if (lastResult) break
    }

    return lastResult
  })

  return sortedList
}

List 結構與 Tree 結構的互轉

這裡引用一下我在兩年多前的一篇文章:js將扁平結構資料轉換為樹形結構

裡面解析了將列表資料轉樹形結構的幾種方式,不過是 js 寫的,最後的合集會貼上 ts 版本。

然後在合集裡會付上將樹形結構展平成列表結構的方法,採用的是迴圈取代遞迴的方式,樹展平的使用場景相對較少,就不細說了。

方法合集

沒有細緻校對,如果有一丟丟小錯自行修復一下~
import { isDefined, isObject, isFunction } from './is'

/**
 * 根據陣列元素中某個或多個屬性的值轉換為對映
 * @param list - 需要被轉換的陣列
 * @param prop - 需要被轉換的屬性或提供一個讀取方法
 * @param accessor - 對映的值的讀取方法,預設返回元素本身
 */
export function transformListToMap<T = any, K = T>(
  list: T[],
  prop: keyof T | ((item: T) => any),
  accessor: (item: T) => K = v => v as any
): Record<string, K> {
  const map = {} as Record<string, any>

  if (!isDefined(prop)) return map

  const propAccessor = isFunction(prop) ? prop : (item: T) => item[prop]

  list.forEach(item => {
    const key = propAccessor(item)

    if (isDefined(key)) {
      map[key] = accessor(item)
    }
  })

  return map
}

/**
 * 移除陣列中的某個元素
 * @param array - 需要被移除元素的陣列
 * @param item - 需要被移除的元素, 或一個查詢方法,如果元素為函式時則需要做一層簡單包裝
 * @param isFn - 標記陣列的元素是否為函式
 */
export function removeArrayItem<T = any>(
  array: T[],
  item: T | ((item: T) => boolean),
  isFn = false
): T | null {
  let index = -1

  if (isFn || typeof item !== 'function') {
    index = array.findIndex(current => current === item)
  } else {
    index = array.findIndex(item as (item: T) => boolean)
  }

  if (~index) {
    return array.splice(index, 1)[0]
  }

  return null
}

/**
 * 移除陣列中的某個或多個元素
 * @param array - 需要被移除元素的陣列
 * @param items - 需要被移除的元素, 或一個查詢方法
 * @param isFn - 標記陣列的元素是否為函式
 */
function removeArrayItems<T = any>(
  array: T[],
  items: T | T[] | ((item: T) => boolean),
  isFn = false
): T[] {
  const multiple = Array.isArray(items)

  if (!multiple && (isFn || typeof items !== 'function')) {
    const index = array.findIndex(current => current === items)

    if (~index) {
      return array.splice(index, 1)
    }
  } else {
    let filterFn: (item: T) => boolean

    if (multiple) {
      const removedSet = new Set(items)
      filterFn = item => removedSet.has(item)
    } else {
      filterFn = items as (item: T) => boolean
    }

    const originArray = Array.from(array)
    const removedItems: T[] = []

    array.length = 0
    originArray.forEach(item => (filterFn(item) ? removedItems : array).push(item))

    return removedItems
  }

  return []
}

/**
 * 按照一定順序的屬性對資料進行分組
 * @param list - 需要分數的資料
 * @param props - 需要按順序分組的屬性
 */
export function groupByProps<T = any>(
  list: T[],
  props: Array<string | ((item: T) => any)> | string | ((item: T) => any) = []
): Record<string, T[]> {
  if (typeof props === 'string' || typeof props === 'function') {
    props = [props]
  }

  const propCount = props.length
  const zipData: Record<string, any> = {}

  for (const item of list) {
    let data

    for (let i = 0; i < propCount; ++i) {
      const isLast = i === propCount - 1
      const prop = props[i]
      const value = typeof prop === 'function' ? prop(item) : item[prop as keyof T]

      if (!data) {
        if (!zipData[value]) {
          zipData[value] = isLast ? [] : {}
        }

        data = zipData[value]
      } else {
        if (!data[value]) {
          data[value] = isLast ? [] : {}
        }

        data = data[value]
      }
    }

    data.push(item)
  }

  return zipData
}

export interface TreeOptions<T = string> {
  keyField?: T,
  childField?: T,
  parentField?: T,
  rootId?: any
}

/**
 * 轉換扁平結構為樹形結構
 * @param list - 需要轉換的扁平資料
 * @param options - 轉化配置項
 */
export function transformTree<T = any>(list: T[], options: TreeOptions<keyof T> = {}) {
  const {
    keyField = 'id' as keyof T,
    childField = 'children' as keyof T,
    parentField = 'parent' as keyof T,
    rootId = null
  } = options

  const hasRootId = isDefined(rootId) && rootId !== ''
  const tree: T[] = []
  const record = new Map<T[keyof T], T[]>()

  for (let i = 0, len = list.length; i < len; ++i) {
    const item = list[i]
    const id = item[keyField]

    if (hasRootId ? id === rootId : !id) {
      continue
    }

    if (record.has(id)) {
      (item as any)[childField] = record.get(id)!
    } else {
      (item as any)[childField] = []
      record.set(id, (item as any)[childField])
    }

    if (item[parentField] && (!hasRootId || item[parentField] !== rootId)) {
      const parentId = item[parentField]

      if (!record.has(parentId)) {
        record.set(parentId, [])
      }

      record.get(parentId)!.push(item)
    } else {
      tree.push(item)
    }
  }

  return tree
}

/**
 * 轉換樹形結構為扁平結構
 * @param tree - 需要轉換的樹形資料
 * @param options - 轉化配置項
 */
export function flatTree<T = any>(tree: T[], options: TreeOptions<keyof T> = {}) {
  const {
    keyField = 'id' as keyof T,
    childField = 'children' as keyof T,
    parentField = 'parent' as keyof T,
    rootId = null
  } = options

  const hasRootId = isDefined(rootId) && rootId !== ''
  const list: T[] = []
  const loop = [...tree]

  let idCount = 1

  while (loop.length) {
    const item = loop.shift()!

    let id
    let children: any[] = []

    const childrenValue = item[childField]

    if (Array.isArray(childrenValue) && childrenValue.length) {
      children = childrenValue
    }

    if (item[keyField]) {
      id = item[keyField]
    } else {
      id = idCount++
    }

    if (hasRootId ? item[parentField] === rootId : !item[parentField]) {
      (item as any)[parentField] = rootId
    }

    for (let i = 0, len = children.length; i < len; ++i) {
      const child = children[i]

      child[parentField] = id
      loop.push(child)
    }

    list.push(item)
  }

  return list
}

export interface SortOptions<T = string> {
  key: T,
  method?: (prev: any, next: any) => number,
  accessor?: (...args: any[]) => any,
  type?: 'asc' | 'desc',
  params?: any[] // 傳入讀取器的額外引數
}

const defaultSortMethod = (prev: any, next: any) => {
  if (Number.isNaN(Number(prev) - Number(next))) {
    return String(prev).localeCompare(next)
  }

  return prev - next
}

/**
 * 根據依賴的屬性逐層排序
 * @param list - 需要排序的陣列
 * @param props - 排序依賴的屬性 key-屬性名 method-排序方法 accessor-資料獲取方法 type-升降序
 */
export function sortByProps<T = any>(
  list: T[],
  props: keyof T | SortOptions<keyof T> | (keyof T | SortOptions<keyof T>)[]
) {
  if (
    !list.sort ||
    (isObject<SortOptions>(props) && !props.key) ||
    !(props as string | SortOptions[]).length
  ) {
    return list
  }

  const sortedList = Array.from(list)

  if (!Array.isArray(props)) {
    props = [props]
  }

  const formattedProps = props
    .map(
      value =>
        (typeof value === 'string'
          ? {
              key: value,
              method: defaultSortMethod,
              type: 'asc'
            }
          : value) as SortOptions<keyof T>
    )
    .map(value => {
      if (typeof value.accessor !== 'function') {
        value.accessor = (data: T) => data[value.key]
      }

      if (typeof value.method !== 'function') {
        value.method = defaultSortMethod
      }

      value.params = Array.isArray(value.params) ? value.params : []

      return value as Required<SortOptions>
    })

  sortedList.sort((prev, next) => {
    let lastResult = 0

    for (const prop of formattedProps) {
      const { method, type, accessor, params } = prop
      const desc = type === 'desc'
      const result = method(accessor(prev, ...params), accessor(next, ...params))

      lastResult = desc ? -result : result
      // 若不為0則無需進行下一層排序
      if (lastResult) break
    }

    return lastResult
  })

  return sortedList
}

往期傳送門

【封裝小技巧】is 系列方法的封裝

相關文章