首页 > 解决方案 > Ramda:折叠一个对象

问题描述

我正在构建一个 PWA 并使用 Ramda 进行逻辑构建。我正在尝试构建一个给定Google Places Detail 响应返回自定义地址对象的函数。

让我通过向您展示我的测试来用代码描述它:

assert({
  given: 'a google places api response from Google Places',
  should: 'extract the address',
  actual: getAddressValues({
    address_components: [
      {
        long_name: '5',
        short_name: '5',
        types: ['floor'],
      },
      {
        long_name: '48',
        short_name: '48',
        types: ['street_number'],
      },
      {
        long_name: 'Pirrama Road',
        short_name: 'Pirrama Rd',
        types: ['route'],
      },
      {
        long_name: 'Pyrmont',
        short_name: 'Pyrmont',
        types: ['locality', 'political'],
      },
      {
        long_name: 'Council of the City of Sydney',
        short_name: 'Sydney',
        types: ['administrative_area_level_2', 'political'],
      },
      {
        long_name: 'New South Wales',
        short_name: 'NSW',
        types: ['administrative_area_level_1', 'political'],
      },
      {
        long_name: 'Australia',
        short_name: 'AU',
        types: ['country', 'political'],
      },
      {
        long_name: '2009',
        short_name: '2009',
        types: ['postal_code'],
      },
    ],
    geometry: {
      location: {
        lat: -33.866651,
        lng: 151.195827,
      },
      viewport: {
        northeast: {
          lat: -33.8653881697085,
          lng: 151.1969739802915,
        },
        southwest: {
          lat: -33.86808613029149,
          lng: 151.1942760197085,
        },
      },
    },
  }),
  expected: {
    latitude: -33.866651,
    longitude: 151.195827,
    city: 'Pyrmont',
    zipCode: '2009',
    streetName: 'Pirrama Road',
    streetNumber: '48',
  },
});

如您所见,我想要的地址对象更“扁平”(因为缺少更好的术语)。我正在努力编写这个转换函数。我尝试使用 Ramda's 进行操作evolve,但保留了密钥。我需要使用进化来转换对象,然后reduce对象传播键。

// Pseudo
({ address_components }) => ({ ...address_components })

我使用 Ramda adjunct 成功提取了相关信息并重evolve命名了键renameKeys,但我不知道之后如何展平该对象。你是怎样做的?或者是否有更简单的方法来实现所需的转换?

编辑:

我找到了一种方法来实现我的转变,但它非常冗长。我觉得有一种更简单的方法来提取地址数据。无论如何,这是我目前的解决方案:

export const getAddressValues = pipe(
  evolve({
    address_components: pipe(
      reduce(
        (acc, val) => ({
          ...acc,
          ...{
            [head(prop('types', val))]: prop('long_name', val),
          },
        }),
        {}
      ),
      pipe(
        pickAll([
          'route',
          'locality',
          'street_number',
          'country',
          'postal_code',
        ]),
        renameKeys({
          route: 'streetName',
          locality: 'city',
          street_number: 'streetNumber',
          postal_code: 'zipCode',
        }),
        map(ifElse(isNil, always(null), identity))
      )
    ),
    geometry: ({ location: { lat, lon } }) => ({
      latitude: lat,
      longitude: lon,
    }),
  }),
  ({ address_components, geometry }) => ({ ...address_components, ...geometry })
);

编辑:基于@codeepic 的回答,这是我最终使用的纯 JavaScript 解决方案(尽管@user3297291 很优雅,我喜欢它):

const getLongNameByType = (arr, type) => 
  arr.find(o => o.types.includes(type)).long_name;

const getAddressValues = ({ address_components: comp, geometry: { location: { lat, lng } } }) => ({
  latitude: lat,
  longitude: lng,
  city: getLongNameByType(comp, 'locality'),
  zipCode: getLongNameByType(comp, 'postal_code'),
  streetName: getLongNameByType(comp, 'route'),
  streetNumber: getLongNameByType(comp, 'street_number'),
  country: getLongNameByType(comp, 'country'),
});

标签: javascriptfunctional-programminggoogle-places-apiramda.js

解决方案


镜头可能是你最好的选择。Ramda 有一个通用lens函数,以及针对对象属性 ( lensProp)、数组索引 ( lensIndex) 和更深路径 ( lensPath) 的特定函数,但它不包括通过 id 在数组中查找匹配值的函数。不过,自己制作并不难。

镜头是通过将两个函数传递给lens:一个获取对象并返回相应值的获取器,以及一个获取新值和对象并返回对象的更新版本的设置器。

在这里,我们编写lensMatchwhich 在给定属性名称与提供的值匹配的数组中查找或设置值。并lensType简单地传递'type'lensMatch返回一个函数,该函数将采用一组类型并返回一个镜头。

使用任何镜头,我们都有viewsetover函数,它们分别获取、设置和更新值。

const lensMatch = (propName) => (key) => lens ( 
  find ( propEq (propName, key) ),
  (val, arr, idx = findIndex (propEq (propName, key), arr)) =>
     update(idx > -1 ? idx : length(arr), val, arr)
)
const lensTypes = lensMatch ('types')
const longName = (types) => 
  compose (lensProp ('address_components'), lensTypes (types), lensProp ('long_name'))
// can define `shortName` similarly if needed

const getAddressValues = applySpec ( {
  latitude:     view (lensPath (['geometry', 'location', 'lat']) ),
  longitude:    view (lensPath (['geometry', 'location', 'lng']) ),
  city:         view (longName (['locality', 'political']) ),
  zipCode:      view (longName (['postal_code']) ),
  streetName:   view (longName (['route']) ),
  streetNumber: view (longName (['street_number']) ),
})

const response = {"address_components": [{"long_name": "5", "short_name": "5", "types": ["floor"]}, {"long_name": "48", "short_name": "48", "types": ["street_number"]}, {"long_name": "Pirrama Road", "short_name": "Pirrama Rd", "types": ["route"]}, {"long_name": "Pyrmont", "short_name": "Pyrmont", "types": ["locality", "political"]}, {"long_name": "Council of the City of Sydney", "short_name": "Sydney", "types": ["administrative_area_level_2", "political"]}, {"long_name": "New South Wales", "short_name": "NSW", "types": ["administrative_area_level_1", "political"]}, {"long_name": "Australia", "short_name": "AU", "types": ["country", "political"]}, {"long_name": "2009", "short_name": "2009", "types": ["postal_code"]}], "geometry": {"location": {"lat": -33.866651, "lng": 151.195827}, "viewport": {"northeast": {"lat": -33.8653881697085, "lng": 151.1969739802915}, "southwest": {"lat": -33.86808613029149, "lng": 151.1942760197085}}}}

console .log (
  getAddressValues (response)
)
<script src="//cdnjs.cloudflare.com/ajax/libs/ramda/0.26.1/ramda.js"></script><script>
const {applySpec, compose, find, findIndex, lens, lensProp, lensPath, propEq, update, view} = R  </script>

我们可以用一个更简单的版本来lensMatch解决这个问题,因为我们没有使用 setter:

const lensMatch = (propName) => (key) => 
  lens (find (propEq (propName, key) ), () => {} )

但我不会推荐它。fulllensMatch是一个有用的效用函数。

有几种方法我们可能想要改变这个解决方案。我们可以移动view内部longName并编写另一个小助手来包装 in 的结果,lensPathview简化调用看起来更像这样。

  longitude:    viewPath (['geometry', 'location', 'lng']),
  city:         longName (['locality', 'political']),

或者我们可以为 编写一个包装器applySpec,也许viewSpec它只是将所有属性函数包装在view. 这些留给读者作为练习。


(对此的介绍几乎没有从我早期的答案中修改过。)


更新

我还尝试了一种完全独立的方法。我认为它的可读性较差,但它可能性能更高。比较选项很有趣。

const makeKey = JSON.stringify

const matchType = (name) => (
  spec,
  desc = spec.reduce( (a, [t, n]) => ({...a, [makeKey (t)]: n}), {})
) => (xs) => xs.reduce(
  (a, { [name]: fld, types }, _, __, k = makeKey(types)) => ({
    ...a,
    ...(k in desc ? {[desc[k]]: fld} : {})
  }), 
  {}
)
const matchLongNames = matchType('long_name')

const getAddressValues2 = lift (merge) (
  pipe (
    prop ('address_components'), 
    matchLongNames ([
      [['locality', 'political'], 'city'],
      [['postal_code'], 'zipCode'],
      [['route'], 'streetName'],
      [['street_number'], 'streetNumber'],
    ])
  ),
  applySpec ({
    latitude: path(['geometry', 'location', 'lat']),
    longitude: path(['geometry', 'location', 'lng']),
  })
)

const response = {"address_components": [{"long_name": "5", "short_name": "5", "types": ["floor"]}, {"long_name": "48", "short_name": "48", "types": ["street_number"]}, {"long_name": "Pirrama Road", "short_name": "Pirrama Rd", "types": ["route"]}, {"long_name": "Pyrmont", "short_name": "Pyrmont", "types": ["locality", "political"]}, {"long_name": "Council of the City of Sydney", "short_name": "Sydney", "types": ["administrative_area_level_2", "political"]}, {"long_name": "New South Wales", "short_name": "NSW", "types": ["administrative_area_level_1", "political"]}, {"long_name": "Australia", "short_name": "AU", "types": ["country", "political"]}, {"long_name": "2009", "short_name": "2009", "types": ["postal_code"]}], "geometry": {"location": {"lat": -33.866651, "lng": 151.195827}, "viewport": {"northeast": {"lat": -33.8653881697085, "lng": 151.1969739802915}, "southwest": {"lat": -33.86808613029149, "lng": 151.1942760197085}}}}

console .log (
  getAddressValues2 (response)
)
<script src="//cdnjs.cloudflare.com/ajax/libs/ramda/0.26.1/ramda.js"></script><script>
const {applySpec, lift, merge, path, pipe, prop} = R                          </script>

这个版本将问题分为两部分:一个用于更容易的字段,latitude一个longitude用于其他更难匹配的字段,然后简单地合并将每个应用到响应的结果。

更简单的字段不需要评论。这只是一个简单的应用applySpecpath。另一个封装为matchType接受将输入(以及要提取的字段的名称)上的类型匹配到输出的属性名称的规范。它基于类型构建索引desc(这里使用JSON.stringify,尽管有明显的替代方案)。然后,它减少一个对象数组,查找任何其types属性在索引中的对象,并将其值与适当的字段名称联系起来。

这是一个有趣的变体。我仍然更喜欢我原来的,但对于大型阵列,这可能会对性能产生重大影响。

另一个更新

在阅读了 user633183 的回答后,我一直在思考我想如何使用这样的东西。Maybe在这里使用 s有很多话要说。但是我可能希望通过两种不同的方式与结果进行交互。一个让我可以逐个字段地操作,每个字段都包含在自己的Maybe. 另一种是作为一个完整的对象,具有它的所有领域;但由于所展示的原因,它必须被包裹在它自己的 Maybe 中。

这是一个不同的版本,它生成第一个变体并包含一个将其转换为第二个变体的函数。

const maybeObj = pipe (
  toPairs,
  map(([k, v]) => v.isJust ? Just([k, v.value]) : Nothing()),
  sequence(Maybe),
  map(fromPairs)
)

const maybeSpec = (spec = {}) => (obj = {}) =>
  Object .entries (spec) .reduce (
    (a, [k, f] ) => ({...a, [k]: Maybe (is (Function, f) && f(obj))}), 
    {}
  )

const findByTypes = (types = []) => (xs = []) =>
  xs .find (x => equals (x.types, types) ) 

const getByTypes = (name) => (types) => pipe (
  findByTypes (types),
  prop (name)
)

const getAddressComponent = (types) => pipe (
  prop ('address_components'),
  getByTypes ('long_name') (types)
)
const response = {"address_components": [{"long_name": "5", "short_name": "5", "types": ["floor"]}, {"long_name": "48", "short_name": "48", "types": ["street_number"]}, {"long_name": "Pirrama Road", "short_name": "Pirrama Rd", "types": ["route"]}, {"long_name": "Pyrmont", "short_name": "Pyrmont", "types": ["locality", "political"]}, {"long_name": "Council of the City of Sydney", "short_name": "Sydney", "types": ["administrative_area_level_2", "political"]}, {"long_name": "New South Wales", "short_name": "NSW", "types": ["administrative_area_level_1", "political"]}, {"long_name": "Australia", "short_name": "AU", "types": ["country", "political"]}, {"long_name": "2009", "short_name": "2009", "types": ["postal_code"]}], "geometry": {"location": {"lat": -33.866651, "lng": 151.195827}, "viewport": {"northeast": {"lat": -33.8653881697085, "lng": 151.1969739802915}, "southwest": {"lat": -33.86808613029149, "lng": 151.1942760197085}}}}

getAddressComponent (['route']) (response)

const extractAddress = maybeSpec({
  latitude:     path (['geometry', 'location', 'lat']),
  longitude:    path (['geometry', 'location', 'lng']),
  city:         getAddressComponent (['locality', 'political']),
  zipCode:      getAddressComponent  (['postal_code']),
  streetName:   getAddressComponent  (['route']),
  streetNumber: getAddressComponent (['street_number']),  
})

const transformed = extractAddress (response)

// const log = pipe (toString, console.log)
const log1 = (obj) => console.log(map(toString, obj))
const log2 = pipe (toString, console.log)

// First variation
log1 (
  transformed
)

// Second variation
log2 (
  maybeObj (transformed)
)
<script src="https://bundle.run/ramda@0.26.1"></script>
<script src="https://bundle.run/ramda-fantasy@0.8.0"></script>
<script>
const {equals, fromPairs, is, map, path, pipe, prop, toPairs, sequence, toString} = ramda;
const {Maybe} = ramdaFantasy;
const {Just, Nothing} = Maybe;
</script>

该函数maybeObj转换如下结构:

{
  city: Just('Pyrmont'),
  latitude: Just(-33.866651)
}

变成这样的:

Just({
  city: 'Pyrmont',
  latitude: -33.866651
})

但有一个Nothing

{
  city: Just('Pyrmont'),
  latitude: Nothing()
}

回到Nothing

Nothing()

它对对象的作用与R.sequence对数组和其他可折叠类型的作用非常相似。(由于长期复杂的原因,Ramda 不将对象视为可折叠的。)

其余部分很像@user633183 的答案,但用我自己的成语写成。可能唯一值得注意的其他部分是maybeSpec,它的行为很像,R.applySpec但将每个字段包装在 aJust或 aNothing中。

(请注意,我正在使用Maybe来自 Ramda-Fantasy的项目。该项目已停止,我可能应该弄清楚使用其中一个最新项目需要进行哪些更改。将其归咎于懒惰。我想,唯一需要改变的就是Maybe用他们提供的任何函数替换对的调用[或你自己的],以将 nil 值转换为Nothing和其他所有值转换为Justs。)


推荐阅读