Как динамически извлекать произвольно заданные глубоко вложенные значения из объекта JavaScript?

Репост как исправленная версия этого вопроса . Я просмотрел это, но это не помогло мне справиться с неизвестными вложениями массивов.

EDIT: добавлена ​​информация о том, почему Lodash _.get не работает. Вот Stackblitz. Есть библиотека npm, Object-Scan, которая будет работать, но я не могу Не заставить Angular работать с этим пакетом.

Это для компонента рендеринга столбцов в приложении Angular. Пользователь выбирает столбец для отображения, и этот столбец сопоставляется с тем, чем он является. Итак, в приведенном ниже примере, если пользователь выбирает отображение категорий, и они сопоставляются с интересами [категории], функция затем должна найти значения, соответствующие этому сопоставлению в данных.

Образец данных:

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

Возможные сопоставления для этих данных: id, version, drinks, interests[categories], interests[refreshments][drinks], goals[maxCalories][drinks], goals[maxCalories][pizza],

Требование: мне нужна рекурсивная функция, которая будет работать в Angular и принимать два аргумента: один для объекта данных response, а вторая строка сопоставления селектора, которая будет возвращать применимые, потенциально вложенные значения, перебирая любые объекты и/или встречающиеся массивы. Строка сопоставления может иметь нотацию с точками или скобками. Читаемый, самодокументирующийся код очень желателен.

Например, на основе вышеуказанного объекта данных:

  • getValues("id", response); должен вернуть ["1234"]
  • getValues("version", response); должен вернуть ["0.1"]
  • getValues("drinks", response); должно вернуть ["Scotch"]
  • getValues("interests.categories", response) должен вернуть ["baseball", "football", "movies", "books"]
  • getValues("interests[refreshments][drinks]", response); должно возвращать ["beer", "soft drink", "coffee", "tea", "soft drink" ] с учетом того, сколько элементов может быть в массиве interests.
  • getValues("goals.maxCalories.drinks", response); должно вернуть ["350"]

Возвращаемые значения в конечном итоге будут дедуплицированы и отсортированы.

Оригинальная функция:

function getValues(mapping, row) {
  let rtnValue = mapping
    .replace(/\]/g, "")
    .split("[")
    .map(item => item.split("."))  
    .reduce((arr, next) => [...arr, ...next], [])
    .reduce((obj, key) => obj && obj[key], row)
    .sort();

  rtnValue = [...new Set(rtnValue)]; //dedupe the return values

  return rtnValue.join(", ");
}

Вышеупомянутое работает отлично, так же, как и получить Lodash: если вы передаете, например: "interests[0][refreshments][drinks], он работает отлично. Но сопоставление не знает о вложенных массивах и передает просто "interests[refreshments][drinks]", что приводит к undefined. Необходимо учитывать любые последующие элементы массива.


person Joe H.    schedule 14.01.2021    source источник
comment
Похоже, вы в основном хотите реализовать функцию Lodash get.   -  person Chris Yungmann    schedule 14.01.2021
comment
Нет. Смотрите пояснение, которое я добавил к сообщению.   -  person Joe H.    schedule 14.01.2021
comment
getValues("interests[refreshments][drinks]", response); недействителен. Элементы индекса могут быть только числами или строками в кавычках.   -  person vitaly-t    schedule 08.03.2021
comment
Вы правы, но это строка, которая возвращается из базы данных и передается в функцию. Таким образом, в исходном вопросе танец методов replace.split.map.reduce преобразует входную строку во что-то более приемлемое: ["interests", "refreshments", "drinks"] В приведенном ниже ответе это обрабатывается гораздо более элегантно в однострочнике: query.split (/[[\].]+/g).filter (Boolean)   -  person Joe H.    schedule 09.03.2021
comment
Все еще нужно перенести объектное сканирование на машинописный текст, чтобы angular мог с ним справиться... Могу опубликовать здесь, как только доберусь до него.   -  person vincent    schedule 08.04.2021


Ответы (3)


Обновлять

После того, как я дал ответ ниже, я поужинал. Еда, казалось, заставила меня понять, насколько я нелепа. Мой ответ на предыдущий вопрос явно повлиял на то, как я думал об этом. Но есть гораздо более простой подход, похожий на то, как я написал бы функцию path, только с небольшой дополнительной сложностью для обработки отображения массивов. Вот более чистый подход:

// main function
const _getValues = ([p, ...ps]) => (obj) =>
  p == undefined 
    ? Array.isArray(obj) ? obj : [obj]
  : Array .isArray (obj)
    ?  obj .flatMap (o => _getValues (ps) (o[p] || []))
  : _getValues (ps) (obj [p] || [])


// public function
const getValues = (query, obj) =>
  _getValues (query .split (/[[\].]+/g) .filter (Boolean)) (obj)


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


// demo
[
  'id',                               //=> ['1234']
  'version',                          //=> ['0.1']
  'drinks',                           //=> ['Scotch']
  'interests[categories]',            //=> ['baseball', 'football', 'movies', 'books']
  'interests[refreshments][drinks]',  //=> ['beer', 'soft drink', 'coffee', 'tea', 'soft drink']
  'goals.maxCalories.drinks',         //=> ['350']
  'goals.maxCalories.notFound',       //=> []
  'foo.bar.baz',                      //=> []
  'interests[categories][drinks]',    //=> []
] .forEach (
  name => console.log(`"${name}" --> ${JSON.stringify(getValues(name, response))}`)
)
.as-console-wrapper {max-height: 100% !important; top: 0}

Об этом особо нечего сказать. Это достаточно простая рекурсия, но с отдельной функцией-оболочкой для преобразования вашего строкового формата ввода в массивы, которые я считаю более естественными для таких вещей. (Поскольку эта функция была очень простой, я встроил функцию name2path, которую использовал в предыдущих версиях.

Мораль

В приведенной ниже ошибочной версии есть сильная мораль: слишком заманчиво воспринимать изменение требований как инструкцию по изменению нашего кода. Мы всегда должны смотреть на новые требования как на время, чтобы переосмыслить наш подход и, возможно, переписать наш код. Это стоит делать, когда это приводит к более простой системе. Вот это явно получилось.

Начальный ответ

(Это следует считать полностью замененным приведенным выше обновлением. Я держу его здесь, чтобы показать, как глупо забывать думать о проблеме с самого начала, когда у вас появляются новые требования. Приведенный выше подход настолько чище, что эта версия теперь выглядит глупый.)

У меня сейчас не так много времени, чтобы описывать эту технику. Многое из того, что я сказал в ответ на предыдущий вопрос, по-прежнему актуально для этого кода. getPaths немного улучшен, чтобы возвращать числовые узлы для индексов массива.

Основная функция getValues сначала получает все пути в текущем объекте (с такими значениями, как ["interests", 0, "categories", 1]). Она также преобразует ваш запрос в ключ, объединяя все значения с помощью '~'. Затем мы фильтруем пути, чтобы найти, какие из них соответствуют ключу, когда мы удаляем числовые значения. Попутно удаляем те, которые заканчиваются цифрой, так как их родители дадут нам все нужные нам значения. Затем мы flatMap вызываем path для получения значения каждого из этих путей в исходном объекте.

предыдущий ответ довольно сильно неверно истолковал ваши требования, но в любом случае сделал интересную функцию. Я думаю, что это ближе к тому, что вам действительно нужно.

// utility functions
const isNumber = (n) =>
  Number(n) === n

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

const last = (xs) =>
  xs [xs .length - 1]
  
const getPaths = (obj, arr = Array .isArray (obj)) =>
  typeof obj == 'object' 
    ? Object .entries (obj) 
        .flatMap (([k, v]) => [
          [arr ? Number (k): k], 
          ...getPaths (v) .map (p => [arr ? Number (k): k, ...p])
        ])
    : []


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


// main function
const getValues = (query, obj, key = name2path (query) .join ('~')) => 
  getPaths (obj)
    .filter (
      p => p.filter(n => ! isNumber (n)) .join ('~') == key && ! isNumber (last (p))
    )
   .flatMap (p => path (p) (obj))


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


// demo
[
  'id',
  'version',
  'drinks',
  'interests[categories]',
  'interests[refreshments][drinks]',
  'goals.maxCalories.drinks',
] .forEach (
  name => console.log(`"${name}" --> ${JSON.stringify(getValues(name, response))}`)
)

Ограничение этого метода заключается в том, что аргумент запроса не может включать числовые индексы. Это означает, что вы не можете искать только первый экземпляр некоторого массива. Хотя я уверен, что мы могли бы изменить это, чтобы разрешить это, я предполагаю, что код будет значительно сложнее, чем то, что у нас есть.

person Scott Sauyet    schedule 14.01.2021
comment
Спасибо, Скотт. Поскольку мы обсуждали офлайн, мне пришлось изменить метод flatMap(), как описано в Сайт MDN. Ваше обновленное решение теперь отлично работает! - person Joe H.; 20.01.2021

Зачем изобретать велосипед? То, что вы описываете, это именно то, что делает функция Lodash get. См. https://lodash.com/docs/4.17.15#get.

Если вы действительно хотите иметь ванильное решение, посмотрите, как они это делают. См. https://github.com/lodash/lodash/blob/master/get.js

person zwbetz    schedule 14.01.2021
comment
Для получения Lodash требуется что-то вроде: _.get(response, "interests[0].categories", "default");, но будет передано: interests.categories (без индекса (ов) массива; может быть заключено в квадратные скобки или с точкой) - person Joe H.; 14.01.2021

Используя синтаксис path-value:

const {tokenizePath, resolveValue} = require('path-value');

function getValues(path, obj) {
    const tokens = tokenizePath(path); // to support full ES5 syntax
    return resolveValue(obj, tokens);
}

getValues("id", response); //=> 1234
person vitaly-t    schedule 08.03.2021