Skip to content

deepGet / get:安全访问嵌套对象的终极方案

在 JavaScript 开发中,访问深层嵌套对象属性是高频需求,但也是 Cannot read property 'x' of undefined 错误的主要来源。

传统防御性写法冗长且难以维护,而 deepGet(或 get)函数通过路径字符串或数组,提供了一种简洁、安全、可复用的解决方案。

它不仅是“语法糖”,更是处理不确定数据结构的必要工具。

问题:嵌套访问的脆弱性

假设我们有用户数据:

ts
const user = {
  profile: {
    address: {
      city: 'Beijing'
    }
  }
}

直接访问:

ts
const city = user.profile.address.city // 'Beijing'

但若 user 来自 API,可能缺少字段:

ts
const user = { profile: null }
const city = user.profile.address.city // ❌ TypeError: Cannot read property 'address' of null

传统写法:冗长的防御链

方案 1:逻辑与操作符(&&

ts
const city = user && user.profile && user.profile.address && user.profile.address.city
  • 优点:无需额外依赖。
  • 缺点:代码重复、路径越深越难读、无法复用。

方案 2:可选链(?.

ts
const city = user?.profile?.address?.city
  • 优点:ES2020 原生支持,简洁。
  • 缺点:仅支持静态路径,无法动态传入路径字符串;旧浏览器需转译。

注意:可选链是 deepGet 的有力竞争者,但两者适用场景不同:

  • ?. 适合静态路径的直接访问。
  • deepGet 适合动态路径(如配置、表单校验规则)或需要默认值的场景。

deepGet 设计:路径支持与默认值

核心接口

ts
const deepGet = <T, D = undefined>(
  obj: T,
  path: string | string[],
  defaultValue?: D
): unknown => { /* 实现 */ }

使用示例

ts
// 路径字符串
deepGet(user, 'profile.address.city', 'Unknown')

// 路径数组(性能更优)
deepGet(user, ['profile', 'address', 'city'], 'Unknown')

// 路径不存在,返回默认值
deepGet(user, 'profile.phone.number', 'N/A') // 'N/A'

实现:递归遍历路径

ts
const deepGet = <T, D = undefined>(
  obj: T,
  path: string | string[],
  defaultValue?: D
): unknown => {
  // 处理空对象
  if (obj == null) return defaultValue

  // 统一为数组
  const paths = Array.isArray(path) ? path : path.split('.')

  let current: any = obj
  for (const key of paths) {
    // 任一环节为 null/undefined,中断并返回默认值
    if (current == null) {
      return defaultValue
    }
    current = current[key]
  }

  // 若最终值为 undefined,返回默认值
  return current !== undefined ? current : defaultValue
}

关键逻辑

  1. 提前终止:一旦 currentnullundefined,立即返回 defaultValue
  2. 路径分割:字符串路径通过 split('.') 转为数组。
  3. 默认值兜底:即使路径存在,但值为 undefined,也返回 defaultValue

性能优化:路径字符串 vs 数组

问题:字符串分割的开销

ts
deepGet(obj, 'a.b.c.d') // 每次调用都执行 split('.')

在高频调用场景(如渲染列表),split 的性能开销不可忽视。

解法:优先使用数组路径

ts
// 推荐:路径数组,避免重复分割
deepGet(obj, ['a', 'b', 'c', 'd'])

若必须使用字符串,可考虑缓存分割结果,但会增加复杂度。在大多数场景下,直接传数组是更优选择。

类型安全:精确推导路径返回类型

理想情况下,我们希望:

ts
const city = deepGet(user, 'profile.address.city')
// city 的类型应自动推导为 string | undefined

这需要高级类型系统支持。

工具类型:DotPathPathValue

1. 递归解析路径字符串

ts
// 将 'a.b.c' 转为 'a' | 'b' | 'c' 的联合类型
type Split<S extends string> = S extends `${infer T}.${infer U}`
  ? T | Split<U>
  : S

// 从对象 T 中,根据路径 P 获取值类型
type PathValue<T, P extends string> = P extends `${infer K}.${infer Rest}`
  ? K extends keyof T
    ? Rest extends keyof T[K]
      ? T[K][Rest]  // 直接命中
      : T[K] extends Record<string, any>
        ? PathValue<T[K], Rest> // 递归深入
        : never
    : never
  : P extends keyof T
  ? T[P]
  : never

2. 完整类型定义

ts
declare function deepGet<T, P extends string>(
  obj: T,
  path: P,
  defaultValue?: PathValue<T, P>
): PathValue<T, P> | undefined

declare function deepGet<T, P extends (keyof T)[], D>(
  obj: T,
  path: P,
  defaultValue: D
): D | PathValue<T, P[0]> // 简化版,实际需递归推导数组路径

挑战:TypeScript 对递归类型的深度有限制(默认 50),复杂路径可能导致 Type instantiation is excessively deep 错误。生产库(如 lodash)通常采用重载而非完全递归推导。

实战:在 Vue/React 中安全渲染用户信息

text
<script setup lang="ts">
import { computed } from 'vue'
import { deepGet } from '@/utils/object'

const props = defineProps<{
  user: User | null
}>()

// 安全获取用户城市,避免模板中报错
const userCity = computed(() => {
  return deepGet(props.user, 'profile.address.city', '未知城市')
})
</script>

<template>
  <div>
    <p>所在城市:{{ userCity }}</p>
  </div>
</template>

结语:deepGet 是处理“不确定性”的优雅封装

deepGet 的价值不仅在于避免错误,更在于:

  • 声明式:用路径描述访问逻辑,而非过程式 if 判断。
  • 可复用:一处定义,多处使用。
  • 可组合:可与 mapfilter 等结合处理数据流。
  • 类型安全:配合高级类型,提供编译时保障。

当你手写 deepGet 时,你不仅在实现一个函数,更在构建一种处理不完整、不确定数据的思维模式。

在 API 响应不可控、配置结构动态变化的现代应用中,这种能力至关重要。