如何从包含字符串、对象和数组的JavaScript对象中检索动态指定、任意和深度嵌套的值?

3

更新:尽管下面的答案提供了很好的代码价值,但此问题的改进版本及其答案可以在此处找到。

编辑:更正示例数据对象并简化(希望)问题描述

目标:给定以下对象,函数应该遍历所有嵌套的对象并返回与keypath字符串参数相对应的值,该参数可能是一个简单的字符串,也可能包括方括号/点符号表示法。解决方案应在Angular中工作(纯JavaScript、TypeScript或适用于Angular的库)。

我的对象:

const response = {
  "id": "0",
  "version": "0.1",
  "interests": [ {
    "categories": ["baseball", "football"],
    "refreshments": {
      "drinks": ["beer", "soft drink"],
    }
  }, {
    "categories": ["movies", "books"],
    "refreshments": {
      "drinks": ["coffee", "tea"]
    }
  } ],
  "goals": [ {
    "maxCalories": {
      "drinks": "350",
      "pizza": "700",
    }
  } ],
}

最初的函数是:

function getValues(name, row) {
  return name
    .replace(/\]/g, '') 
    .split('[')
    .map(item => item.split('.'))
    .reduce((arr, next) => [...arr, ...next], [])
    .reduce ((obj, key) => obj && obj[key], row);
}

所以,如果我们运行getValues("interests[refreshments][drinks]", response);函数,它应该返回一个包含所有适用值的数组:["beer", "soft drink", "coffee", "tea"]。

对于简单的字符串键,上述方法运行良好。使用getRowValue("version", response)将像预期的那样返回"0.1"。但是,getRowValue("interests[refreshments][drinks]", response)返回undefined

我查阅了此文以及相关链接,但难以理解如何处理这个对象的复杂性。


用户可以选择显示给定的表列,该列映射到模型。例如:ID 映射到 "id"类别 > "interests.categories"(数组)饮料 > "interests.refreshments.drinks"(嵌套在对象中的数组)饮料卡路里 > "goals.maxCalories.drinks"(嵌套在对象中的对象中的数组)披萨卡路里 > "goals.maxCalories.pizza"(如上所述) - Joe H.
这似乎并没有改变数据。我的问题更多的是关于为什么“interests.refreshments.drinks”包括了来自“interests [0]”中没有“refreshments”节点的饮料。如果我们要包括任何层次结构中的“drinks”节点,那么为什么不包括“goals”内部的节点呢?如果我们不这样做,我们如何选择包含其中一个而不包含另一个?请参见我的答案,了解一种可能的解决方案。 - Scott Sauyet
抱歉,我又搞砸了。数据如下: let response = { "id": "0", "version": "0.1", "interests": [ { "categories": ["baseball", "football"], "refreshments": { "drinks": ["beer", "soft drink"], } }, { "categories": ["movies", "books"], "refreshments": { "drinks": ["coffee", "tea"] } } ], "goals": [ { "maxCalories": { "drinks": "350", "pizza": "700", } } ], } - Joe H.
我认为这非常准确。将其转换到我的其他环境中,那里有我的数据。 - Joe H.
1
@ScottSauyet,我已经将它移动到我的另一个环境中,找到了所有的拼写错误,并且可以告诉你它非常完美!非常感谢! - Joe H.
显示剩余12条评论
2个回答

2
这里有一个使用object-scan的解决方案。
唯一棘手的部分是将搜索输入转换为object-scan所需的格式。

// const objectScan = require('object-scan');

const response = { id: '0', version: '0.1', interests: [{ categories: ['baseball', 'football'], refreshments: { drinks: ['beer', 'soft drink'] } }, { categories: ['movies', 'books'], refreshments: { drinks: ['coffee', 'tea'] } }], goals: [{ maxCalories: { drinks: '350', pizza: '700' } }] };

const find = (haystack, needle) => {
  const r = objectScan(
    [needle.match(/[^.[\]]+/g).join('.')],
    { rtn: 'value', useArraySelector: false }
  )(haystack);
  return r.length === 1 ? r[0] : r.reverse();
};

console.log(find(response, 'interests[categories]'));
// => [ 'baseball', 'football', 'movies', 'books' ]
console.log(find(response, 'interests.refreshments.drinks'));
// => [ 'beer', 'soft drink', 'coffee', 'tea' ]
console.log(find(response, 'goals[maxCalories][drinks]'));
// => 350
.as-console-wrapper {max-height: 100% !important; top: 0}
<script src="https://bundle.run/object-scan@13.8.0"></script>

免责声明:本人是object-scan的作者


需求对我来说从未完全清晰,但是做到像我的答案一样,例如让find('interests.drinks')返回['beer', 'soft drink', 'coffee', 'tea'],就像find('interests.refreshments.drinks')一样,或者让find('drinks')返回['beer', 'soft drink', 'coffee', 'tea', 350],需要付出多少更多的努力呢?我对object-scan的灵活性很感兴趣,因为迄今为止的版本比我的代码要少得多,但功能也弱得多。匹配是否很容易? - Scott Sauyet
同意,这个问题不够清晰。我使用了评论中的最后一个数据集和问题中明确的三个要求。要重现您的结果只需要进行最小的更改 - 只需用 [\.${needle.match(/[^.[]]+/g).join('..')}`]替换我的代码中的needles` 数组即可。哦,如果您对此有任何疑问,请告诉我! - vincent
1
非常好。我需要花些时间研究一下object-scan。你之前的几个答案都显示它修改了输入结构,而我从根本上反对这种做法。我原以为那就是它的特点所在。但显然,它的核心功能必须是一个对象查询工具,我觉得这更有趣。我会去看看的。 - Scott Sauyet
1
谢谢,没错。中心特点是基于搜索条件(needles、filterFn、breakFn)的高性能对象遍历,重点关注性能。针对您的担忧:修改现有数据结构本质上更具性能,并且始终可以通过首先深度克隆输入来“修复”它(而反之则不成立)。这就是为什么我通常更喜欢在我的答案中进行修改,如果两者都可以的话。 - vincent
这段 JavaScript 代码在我的测试中完美运行。但是,当我在 Angular 应用程序中使用它时,由于库中没有 TypeScript 声明文件,我遇到了困难,据我所知。我能够在我的 Angular 测试环境中(通过一些技巧)让 @ScottSauyet 的答案正常工作,但是在生产环境中似乎不喜欢这些技巧。 - Joe H.
显示剩余4条评论

1

更新

经过一夜的思考,我决定真的不喜欢在这里使用coarsen。(你可以看到我一开始对此有些犹豫。)以下是一种跳过coarsen的替代方案。这确实意味着,例如传递"id"将返回包含该ID的一个数组,但这是有道理的。传递"drinks"会返回找到的饮料的数组。统一的接口更加清晰。除了coarsen外,下面所有关于此问题的讨论仍然适用。

// utility functions
const last = (xs) =>
  xs [xs.length - 1]

const endsWith = (x) => (xs) =>
  last(xs) == x

const path = (ps) => (obj) =>
  ps .reduce ((o, p) => (o || {}) [p], obj)

const getPaths = (obj) =>
  typeof obj == 'object' 
    ? Object .entries (obj) 
        .flatMap (([k, v]) => [
          [k], 
          ...getPaths (v) .map (p => [k, ...p])
        ])
    : []

const hasSubseq = ([x, ...xs]) => ([y, ...ys]) =>
  y == undefined
    ? x == undefined
  : xs .length > ys .length
    ? false
  : x == y
    ? hasSubseq (xs) (ys)
  : hasSubseq ([x, ...xs]) (ys)


// helper functions
const findPartialMatches = (p, obj) =>
  getPaths (obj)
    .filter (endsWith (last (p)))
    .filter (hasSubseq (p))
    .flatMap (p => path (p) (obj))
  

const name2path = (name) => // probably not a full solutions, but ok for now
  name .split (/[[\].]+/g) .filter (Boolean)


// main function
const newGetRowValue = (name, obj) =>
  findPartialMatches (name2path (name), obj)


// sample data
let response = {id: "0", version: "0.1", interests: [{categories: ["baseball", "football"], refreshments: {drinks: ["beer", "soft drink"]}}, {categories: ["movies", "books"], refreshments: {drinks: ["coffee", "tea"]}}], goals: [{maxCalories: {drinks: "350", pizza: "700"}}]};

// demo
[
  'interests[refreshments].drinks',
  'interests[drinks]',
  'drinks',
  'interests[categories]',
  'goals',
  'id',
  'goals.maxCalories',
  'goals.drinks'
] .forEach (
  name => console.log(`"${name}" --> ${JSON.stringify(newGetRowValue(name, response))}`)
)
.as-console-wrapper {max-height: 100% !important; top: 0}

原始答案

我还有一些关于您的要求的问题。请参见问题上我的评论中的详细信息。我在这里做出一个假设,即您的要求比建议的要求略微一致:大多数情况下,您的名称中的节点必须存在,并且嵌套结构必须如所示,但可能存在未提及的中间节点。因此,"interests.drinks"将包括interests[0].drinks"interests[1].refreshments.drinks"的值,但不包括"goals.maxCategories.drinks",因为它不包括任何"interests"节点。

这个答案还有一个小技巧:基本代码会返回任何输入的数组。但有时,该数组只有一个值,通常我们只想返回该值。这就是在findPartialMatches中使用coarsen函数的原因。这是一个丑陋的技巧,如果您可以接受id以数组["0"]的形式返回,我会删除对coarsen的调用。
这里大部分工作使用路径的数组而不是名称值。我发现这更简单,只需在进行任何实质性操作之前将其转换为该格式即可。
以下是此想法的实现:

// utility functions
const last = (xs) =>
  xs [xs.length - 1]

const endsWith = (x) => (xs) =>
  last(xs) == x

const path = (ps) => (obj) =>
  ps .reduce ((o, p) => (o || {}) [p], obj)

const getPaths = (obj) =>
  typeof obj == 'object' 
    ? Object .entries (obj) 
        .flatMap (([k, v]) => [
          [k], 
          ...getPaths (v) .map (p => [k, ...p])
        ])
    : []

const hasSubseq = ([x, ...xs]) => ([y, ...ys]) =>
  y == undefined
    ? x == undefined
  : xs .length > ys .length
    ? false
  : x == y
    ? hasSubseq (xs) (ys)
  : hasSubseq ([x, ...xs]) (ys)


// helper functions
const coarsen = (xs) => 
  xs.length == 1 ? xs[0] : xs

const findPartialMatches = (p, obj) =>
  coarsen (getPaths (obj)
    .filter (endsWith (last (p)))
    .filter (hasSubseq (p))
    .flatMap (p => path (p) (obj))
  )

const name2path = (name) => // probably not a full solutions, but ok for now
  name .split (/[[\].]+/g) .filter (Boolean)


// main function
const newGetRowValue = (name, obj) =>
  findPartialMatches (name2path (name), obj)


// sample data
let response = {id: "0", version: "0.1", interests: [{categories: ["baseball", "football"], refreshments: {drinks: ["beer", "soft drink"]}}, {categories: ["movies", "books"], refreshments: {drinks: ["coffee", "tea"]}}], goals: [{maxCalories: {drinks: "350", pizza: "700"}}]};

// demo
[
  'interests[refreshments].drinks',
  'interests[drinks]',
  'drinks',
  'interests[categories]',
  'goals',
  'id',
  'goals.maxCalories',
  'goals.drinks'
] .forEach (
  name => console.log(`"${name}" --> ${JSON.stringify(newGetRowValue(name, response))}`)
)
.as-console-wrapper {max-height: 100% !important; top: 0}

我们从两个简单的实用函数开始:
  • last 返回数组的最后一个元素

  • endsWith 简单地报告数组的最后一个元素是否等于测试值

然后是更多实质性的实用函数:
  • path takes an array of node names, and an object and finds the value of at that node path in an object.

  • getPaths takes an object and returns all the paths found in it. For instance, the sample object will yield something like this:

    [
      ["id"],
      ["version"],
      ["interests"],
      ["interests", "0"],
      ["interests", "0", "categories"],
      ["interests", "0", "categories", "0"],
      ["interests", "0", "categories", "1"],
      ["interests", "0", "drinks"],
      // ...
      ["goals"],
      ["goals", "0"],
      ["goals", "0", "maxCalories"],
      ["goals", "0", "maxCalories", "drinks"],
      ["goals", "0", "maxCalories", "pizza"]
    ]
    
  • hasSubseq reports whether the elements of the first argument can be found in order within the second one. Thus hasSubseq ([1, 3]) ([1, 2, 3, 4) returns true, but hasSubseq ([3, 1]) ([1, 2, 3, 4) returns false. (Note that this implementation was thrown together without a great deal of thought. It might not work properly, or it might be less efficient than necessary.)

在此之后,我们有三个辅助函数。(我这样区分实用程序函数和辅助函数:实用程序函数可以在项目中的许多地方甚至跨项目使用。辅助函数是特定于手头问题的。):
  • coarsen 已经在上面讨论过了,它只是将单元素数组转换为标量值。有充分的理由完全删除它。

  • findPartialMatches 是核心。它执行我们的主要功能,但使用节点名称的数组而不是点/括号分隔的字符串。

  • name2path 将点/括号分隔的字符串转换为数组。我会将其上移到实用程序部分,但我担心它可能不如我们想要的那样健壮。

最后,主要函数只需调用 name2pathname 参数上的结果,并使用 findPartialMatches

有趣的代码是findPartialMatches,它获取对象中的所有路径,然后将列表过滤为以我们路径的最后一个节点结尾的路径,进一步将这些过滤为具有我们路径作为子序列的路径,检索每个这些路径处的值,将其包装在一个数组中,然后对此结果调用不幸的coarsen

更新了 getPaths 的简化版本。原来的版本适用于我们想要使用路径来重建对象的情况。但在这里并不是必要的,而且它使实现变得复杂。 - Scott Sauyet

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接