使用动态嵌套属性键对数组中的对象进行排序

8

我正在尝试对一个嵌套对象的数组进行排序。使用静态选定键可以正常工作,但我无法弄清如何动态获取它。

到目前为止,我已经得到了这段代码:

sortBy = (isReverse=false) => {
    this.setState(prevState => ({
        files: prevState.files.sort((a, b) => {
            const valueA = (((a || {})['general'] || {})['fileID']) || '';
            const valueB = (((b || {})['general'] || {})['fileID']) || '';

            if(isReverse) return valueB.localeCompare(valueA);

            return valueA.localeCompare(valueB);
        })
    }));
}

目前这些键是硬编码的['general']['orderID'],但我希望通过向sortBy函数添加一个keys参数来使其成为动态部分:

sortBy = (keys, isReverse=false) => { ...

keys是一个带有嵌套键的数组。对于上面的例子,它将是['general', 'fileID']

需要采取哪些步骤使其具有动态性?

注意:子对象可能未定义,因此我使用了a || {}

注意2:我正在使用es6。没有外部软件包。


它会只包含两个键,还是也可以是动态的? - Shubham Khatri
抱歉没有提到。在我的当前项目中,最多可以有4个键,因此必须是动态的。 - Thore
@Thore,我更新了我的答案,并找到了一个2行解决方案 - [这里](https://dev59.com/3LHma4cB1Zd3GeqPSurs#54736113) - Kamil Kiełczewski
7个回答

4

除了在你的代码中放置错误,目前被接受的答案并没有太大帮助。使用一个简单的函数deepProp可以减轻繁琐的重复 -

const deepProp = (o = {}, props = []) =>
  props.reduce((acc = {}, p) => acc[p], o)

现在没有那么多噪音了 -

sortBy = (keys, isReverse = false) =>
  this.setState ({
    files: // without mutating the previous state!
      [...this.state.files].sort((a,b) => {
        const valueA = deepProp(a, keys) || ''
        const valueB = deepProp(b, keys) || ''
        return isReverse
          ? valueA.localeCompare(valueB)
          : valueB.localeCompare(valueA)
      })
  })

然而,这对于实际改进您的程序来说作用不大。它充斥着复杂性,更糟的是,这种复杂性将在需要类似功能的任何组件中被重复。React采用函数式风格,因此本答案从函数式角度解决问题。在本文中,我们将编写sortBy如下-

sortBy = (comparator = asc) =>
  this.setState
    ( { files:
          isort
            ( contramap
                ( comparator
                , generalFileId
                )
            , this.state.files
            )
      }
    )

您的问题让我们学习了两个强大的函数概念; 我们将使用它们来回答问题 -
- 单子(Monads) - 逆变函子(Contravariant Functors)
让我们不要被术语所压倒,而是专注于获得对事物如何工作的直觉。起初,看起来我们有一个检查空值的问题。必须处理一些输入可能没有嵌套属性的可能性使我们的函数变得混乱。如果我们可以概括这个“可能值”的概念,我们就可以稍微整理一下。
您的问题特别指出您现在没有使用外部包,但这是使用外部包的好时机。让我们简要地看一下data.maybe包-
引用: “用于可能不存在的值或可能失败的计算的结构。`Maybe(a)`明确地模拟了隐含在`Nullable`类型中的效果,因此没有使用`null`或`undefined`带来的问题-例如`NullPointerException`或`TypeError`。”

听起来很合适。我们将从编写一个函数safeProp开始,该函数接受一个对象和一个属性字符串作为输入。直观地说,safeProp 安全地返回对象o的属性p

const { Nothing, fromNullable } =
  require ('data.maybe')

const safeProp = (o = {}, p = '') =>

  // if o is an object
  Object (o) === o

    // access property p on object o, wrapping the result in a Maybe
    ? fromNullable (o[p])

    // otherwise o is not an object, return Nothing
    : Nothing ()

我们不仅会返回可能是null或undefined值的o[p],而且还会返回一个Maybe对象,以指导我们如何处理结果-

const generalFileId = (o = {}) =>

  // access the general property
  safeProp (o, 'general')

    // if it exists, access the fileId property on the child
    .chain (child => safeProp (child, 'fileId'))

    // get the result if valid, otherwise return empty string
    .getOrElse ('') 

现在我们有一个函数,它可以接受不同复杂度的对象,并且保证我们感兴趣的结果。
console .log
  ( generalFileId ({ general: { fileId: 'a' } })  // 'a'
  , generalFileId ({ general: { fileId: 'b' } })  // 'b'
  , generalFileId ({ general: 'x' })              // ''
  , generalFileId ({ a: 'x '})                    // ''
  , generalFileId ({ general: { err: 'x' } })     // ''
  , generalFileId ({})                            // ''
  )

这已经解决了一半的问题。现在我们可以从复杂的对象中获取精确的字符串值,以供比较使用。

我有意避免在此展示Maybe的实现,因为这本身就是一个有价值的教训。当一个模块承诺能力X时,我们假设我们拥有能力X,并忽略模块内部黑盒子中发生的事情。数据抽象的关键之处在于隐藏问题,以便程序员可以以更高的层次思考问题。

也许问一下Array是如何工作的会有所帮助?当向数组添加或删除元素时,它如何计算或调整length属性?mapfilter函数如何产生一个新的数组?如果你以前从未想过这些问题,那没关系!数组是一个方便的模块,因为它从程序员的思维中移除了这些问题;它只是像广告中说的那样工作。

无论 JavaScript 提供模块,第三方库(例如 npm)提供模块,还是你自己编写模块,这都适用。如果没有数组存在,我们可以实现自己的数据结构,并具备等效的便利性。我们模块的用户获得了有用的功能,而不会引入额外的复杂性。当你意识到程序员是自己的用户时,就会有顿悟时刻:当你遇到棘手的问题时,编写一个模块,使自己摆脱复杂性的枷锁。发明自己的便利! 我们将在答案后面展示 Maybe 的基本实现,但现在,我们只需完成排序即可...
我们从两个基本比较器开始,asc表示升序排序,desc表示降序排序。
const asc = (a, b) =>
  a .localeCompare (b)

const desc = (a, b) =>
  asc (a, b) * -1

在React中,我们不能改变之前的状态,而是必须创建新的状态。因此,为了进行不可变的排序,我们必须实现isort,它不会改变输入对象。
const isort = (compare = asc, xs = []) =>
  xs
    .slice (0)      // clone
    .sort (compare) // then sort

当然,ab有时是复杂对象,因此我们无法直接调用ascdesc。下面,contramap将使用一个函数g转换我们的数据,然后再将数据传递给另一个函数f
const contramap = (f, g) =>
  (a, b) => f (g (a), g (b))

const files =
  [ { general: { fileId: 'e' } }
  , { general: { fileId: 'b' } }
  , { general: { fileId: 'd' } }
  , { general: { fileId: 'c' } }
  , { general: { fileId: 'a' } }
  ]

isort
  ( contramap (asc, generalFileId) // ascending comparator
  , files
  )

// [ { general: { fileId: 'a' } }
// , { general: { fileId: 'b' } }
// , { general: { fileId: 'c' } }
// , { general: { fileId: 'd' } }
// , { general: { fileId: 'e' } }
// ]

使用另一个比较器desc,我们可以看到排序按相反方向工作 -
isort
  ( contramap (desc, generalFileId) // descending comparator
  , files 
  )

// [ { general: { fileId: 'e' } }
// , { general: { fileId: 'd' } }
// , { general: { fileId: 'c' } }
// , { general: { fileId: 'b' } }
// , { general: { fileId: 'a' } }
// ]

现在为您的React组件编写方法sortBy。该方法基本上被简化为this.setState({files:t(this.state.files)}),其中t是程序状态的不可变转换。这很好,因为复杂性被保持在难以测试的组件之外,并且它驻留在通用模块中,这些模块易于测试 -
sortBy = (reverse = true) =>
  this.setState
    ( { files:
          isort
            ( contramap
                ( reverse ? desc : asc
                , generalFileId
                )
            , this.state.files
            )
      }
    )

这个示例使用了类似于您原始问题中的布尔开关,但是由于React采用函数式编程模式,我认为将其作为高阶函数会更好 -

sortBy = (comparator = asc) =>
  this.setState
    ( { files:
          isort
            ( contramap
                ( comparator
                , generalFileId
                )
            , this.state.files
            )
      }
    )

如果您需要访问的嵌套属性不能保证是generalfileId,我们可以编写一个通用函数,它接受一个属性列表并且可以查找任意深度的嵌套属性 -
const deepProp = (o = {}, props = []) =>
  props .reduce
    ( (acc, p) => // for each p, safely lookup p on child
        acc .chain (child => safeProp (child, p))
    , fromNullable (o) // init with Maybe o
    )

const generalFileId = (o = {}) =>
  deepProp (o, [ 'general', 'fileId' ]) // using deepProp
    .getOrElse ('')

const fooBarQux = (o = {}) =>
  deepProp (o, [ 'foo', 'bar', 'qux' ]) // any number of nested props
    .getOrElse (0)                      // customizable default

console.log
  ( generalFileId ({ general: { fileId: 'a' } } ) // 'a'
  , generalFileId ({})                            // ''
  , fooBarQux ({ foo: { bar: { qux: 1 } } } )     // 1
  , fooBarQux ({ foo: { bar: 2 } })               // 0
  , fooBarQux ({})                                // 0
  )

上面,我们使用了 data.maybe 包,它为我们提供了处理 可能值 的能力。该模块导出了将普通值转换为 Maybe,反之亦然的函数,以及许多适用于可能值的有用操作。然而,并没有强制要求您使用这个特定的实现。这个概念非常简单,您可以用几十行代码实现 fromNullableJustNothing,我们稍后在本答案中会看到 -

repl.it 上运行完整演示。

const { Just, Nothing, fromNullable } =
  require ('data.maybe')

const safeProp = (o = {}, p = '') =>
  Object (o) === o
    ? fromNullable (o[p])
    : Nothing ()

const generalFileId = (o = {}) =>
  safeProp (o, 'general')
    .chain (child => safeProp (child, 'fileId'))
    .getOrElse ('')

// ----------------------------------------------
const asc = (a, b) =>
  a .localeCompare (b)

const desc = (a, b) =>
  asc (a, b) * -1

const contramap = (f, g) =>
  (a, b) => f (g (a), g (b))

const isort = (compare = asc, xs = []) =>
  xs
    .slice (0)
    .sort (compare)

// ----------------------------------------------
const files =
  [ { general: { fileId: 'e' } }
  , { general: { fileId: 'b' } }
  , { general: { fileId: 'd' } }
  , { general: { fileId: 'c' } }
  , { general: { fileId: 'a' } }
  ]

isort
  ( contramap (asc, generalFileId)
  , files
  )

// [ { general: { fileId: 'a' } }
// , { general: { fileId: 'b' } }
// , { general: { fileId: 'c' } }
// , { general: { fileId: 'd' } }
// , { general: { fileId: 'e' } }
// ]

这种方法的优点是显而易见的。我们将一个难以编写、阅读和测试的大型复杂函数转化为多个较小的函数,更容易编写、阅读和测试。较小的函数还有另一个优点,就是可以在程序的其他部分中使用,而大型复杂函数只能在一个部分中使用。
最后,sortBy作为高阶函数实现,这意味着我们不仅限于升序和降序排序,还可以使用任何有效的比较器。这意味着我们甚至可以编写一个专门处理自定义逻辑或先比较年份,然后比较月份,再比较日期等的特殊比较器;高阶函数极大地扩展了您的可能性。
我不喜欢空口承诺,所以我想向您展示像 Maybe 这样设计自己的机制并不困难。这也是数据抽象的一个很好的教训,因为它向我们展示了一个模块具有自己的一套关注点。模块的导出值是访问模块功能的唯一途径;模块的所有其他组件都是私有的,并且可以根据其他需求自由更改或重构。
// Maybe.js
const None =
  Symbol ()

class Maybe
{ constructor (v)
  { this.value = v }

  chain (f)
  { return this.value == None ? this : f (this.value) }

  getOrElse (v)
  { return this.value === None ? v : this.value }
}

const Nothing = () =>
  new Maybe (None)

const Just = v =>
  new Maybe (v)

const fromNullable = v =>
  v == null
    ? Nothing ()
    : Just (v)

module.exports =
  { Just, Nothing, fromNullable } // note the class is hidden from the user

然后我们将在模块中使用它。我们只需要更改导入 (require),但其他所有内容都可以按原样工作,因为我们的模块的公共 API 匹配 -

const { Just, Nothing, fromNullable } =
  require ('./Maybe') // this time, use our own Maybe

const safeProp = (o = {}, p = '') => // nothing changes here
  Object (o) === o
    ? fromNullable (o[p])
    : Nothing ()

const deepProp = (o, props) => // nothing changes here
  props .reduce
    ( (acc, p) =>
        acc .chain (child => safeProp (child, p))
    , fromNullable (o)
    )

// ...

如果想更好地理解如何使用contramap,或者发现一些意外的惊喜,请查看以下相关答案:

  1. 使用contramap进行多重排序
  2. 使用contramap进行递归搜索

3
你可以使用循环从对象中提取嵌套属性路径:

const obj = {
  a: {
    b: {
      c: 3
    }
  } 
}

const keys = ['a', 'b', 'c']

let value = obj;
for (const key of keys) {
  if (!value) break; // stop once we reach a falsy value. Optionally you can make this a tighter check accounting for objects only
  value = value[key];
}

console.log(`c=${value}`);

然后你可以将上面的函数封装成一个帮助函数:
function getPath(obj, keys) {
  let value = obj;
  for (const key of keys) {
    if (!value) break; // stop once we reach a falsy value. Optionally you can make this a tighter check accounting for objects only
    value = value[key];
  }
  return value;
}

在获取值时使用它:

sortBy = (isReverse = false, keys = []) => {
  this.setState(prevState => ({
    files: prevState.files.sort((a, b) => {
      const valueA = getPath(a, keys) || '';
      const valueB = getPath(b, keys) || '';

      // ...
    })
  }));
}

3
您可以循环遍历键来获取值,然后进行比较,例如:
sortBy = (keys, isReverse=false) => {

    this.setState(prevState => ({
        files: prevState.files.sort((a, b) => {
            const clonedKey = [...keys];
            let valueA = a;
            let valueB = b
            while(clonedKey.length > 0) {
                const key = clonedKey.shift();
                valueA = (valueA || {})[key];
                valueB = (valueB || {})[key];
            }
            valueA = valueA || '';
            valueB = valueB || '';
            if(isReverse) return valueB.localeCompare(valueA);

            return valueA.localeCompare(valueB);
        })
    }));
}

1
一种方法可能是在新的keys参数上使用reduce(),类似这样:
sortBy = (keys, isReverse=false) =>
{
    this.setState(prevState =>
    ({
        files: prevState.files.slice().sort((a, b) =>
        {
            const valueA = (keys.reduce((acc, key) => (acc || {})[key], a) || '').toString();
            const valueB = (keys.reduce((acc, key) => (acc || {})[key], b) || '').toString();
            return (isReverse ? valueB.localeCompare(valueA) : valueA.localeCompare(valueB));
        })
    }));
}

请注意在 setState 中调用的可变操作,比如 sort,可能会导致代码中出现错误。详情请参考此处 - Mulan

1
为了处理任意数量的键,您可以创建一个函数,该函数可以与 .reduce() 一起重复使用,以深入遍历嵌套对象。我还将键放在最后一个参数中,这样您就可以使用“rest”和“spread”语法。

const getKey = (o, k) => (o || {})[k];

const sorter = (isReverse, ...keys) => (a, b) => {
  const valueA = keys.reduce(getKey, a) || '';
  const valueB = keys.reduce(getKey, b) || '';

  if (isReverse) return valueB.localeCompare(valueA);

  return valueA.localeCompare(valueB);
};

const sortBy = (isReverse = false, ...keys) => {
  this.setState(prevState => ({
    files: prevState.files.sort(sorter(isReverse, ...keys))
  }));
}

我还将排序函数移动到自己的const变量中,并使其返回一个使用isReverse值的新函数。


|| is mostly an anti-pattern in modern JavaScript thanks to default arguments - const getKey = (o = {}, k) => o[k] - Mulan
@user633183:在这里需要防止空属性值引起的错误。默认参数无法处理这种情况。 - ziggy wiggy

0

以以下方式比较排序函数中的元素:

let v= c => keys.reduce((o,k) => o[k]||'',c)
return (isReverse ? -1 : 1) * v(a).localeCompare(v(b));

像这样:

sortBy = (keys, isReverse=false) => {
    this.setState(prevState => ({
        files: prevState.files.sort((a, b) => {
            let v=c=>keys.reduce((o,k) => o[k]||'',c)
            return (isReverse ? -1 : 1)*v(a).localeCompare(v(b));
        })
    }));
}

这里是这个想法如何运作的示例:

let files = [
 { general: { fileID: "3"}},
 { general: { fileID: "1"}},
 { general: { fileID: "2"}},
 { general: { }}
];


function sortBy(keys, arr, isReverse=false) {
    arr.sort((a,b,v=c=>keys.reduce((o,k) => o[k]||'',c)) =>             
      (isReverse ? -1 : 1)*v(a).localeCompare(v(b)) )        
}


sortBy(['general', 'fileID'],files,true);
console.log(files);


0

这也处理了路径解析为非字符串值的情况,通过将其转换为字符串来避免 .localeCompare 可能会失败。

sortBy = (keys, isReverse=false) => {
    this.setState(prevState => ({
        files: prevState.files.sort((a, b) => {
            const valueA = getValueAtPath(a, keys);
            const valueB = getValueAtPath(b, keys);

            if(isReverse) return valueB.localeCompare(valueA);

            return valueA.localeCompare(valueB);
        })
    }));
}

function getValueAtPath(file, path) {
    let value = file;
    let keys = [...path]; // preserve the original path array

    while(value && keys.length) {
      let key = keys.shift();
      value = value[key];
    }

    return (value || '').toString();
}

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