歌单排序
网易云音乐的歌单排序功能十分鸡肋,只能按照歌曲名、歌手名、专辑名排序。别说处理一些复杂的规则了,他连从 Z-A 这种简单的倒序逻辑都搞不定。这对像我这种红星单动不动就几千首曲子或是喜欢给曲子分类、制作歌单的朋友来说简直就是灾难。
于是,我写了一个专门用来给歌单中的歌曲排序的爬虫脚本,能够实现各种有意思的排序功能。
比方说,这个歌单的歌曲排序按照以下规则排序:
- 按专辑名称排序
- 再按专辑发布日期排序。
- 再按歌曲时长排序。
再举个例子,这个歌单的曲子按照以下规则排序:
- 按专辑作者名称排序,由于专辑可能有多个作者,优先选择其中有歌手名为“CLOCKWORKS TRACER”的专辑。
- 再按专辑发布日期排序。
- 再按歌曲在对应专辑中的顺序排序。
哇哈,这个排序功能比网易云默认的歌单排序强哈,这才真有点意思~ 接下来,我们一步一步来把它实现吧。名称和标语我都想好了,就叫做 Anysort:灵活、优雅、无依赖的排序库。
简单解决
确定问题
首先,得确定要解决什么问题。
在 JavaScript 中,排序其实是一个非常好写的功能。通过给 Array.prototype.sort 传入自定义排序方法,就能得到按期望顺序排好了的数组。
const nums = [1, 30, 4, 21, 100000]
function customCompare(a, b) {
if (a < b) return -1
if (a > b) return 1
else return 0
}
nums.sort(customCompare)
// 排序结果:[1, 4, 21, 30, 100000]
可以看到,给 Array.prototype.sort 传入的排序方法 customCompare,最终的返回值要么是 0,要么是 1 或 -1,这个返回值直接的指代了 a 和 b 的排序顺序。也就是说,无需关心 Array.prototype.sort 的具体实现到底是快速排序还是冒泡排序,只需要写一个比较两个值的大小的函数,返回它们的顺序到底是相等(0),还是 a 比 b 靠前(-1),或是 a 在 b 之后(1)就行了。
我们通常会使用 JSON 对象去描述一首曲子,比如《海阔天空》可能描述如下:
const song = {
name: '海阔天空',
authro: 'Beyond',
// 专辑信息
album: {
name: '海阔天空',
time: '1993-09-09',
brief: '《海阔天空》是黄家驹为BEYOND成立十周年而作的,刻画着他们十年来的心路历程。'
},
// 评论信息
comments: [
{
name: '冰凍七音',
content: '有些人死了,他还活着',
approve: '150283',
hot: true,
},
{
name: '独坐凭栏',
content: '钢铁锅,含着泪喊修瓢锅。。。',
approve: '77739'
}
]
}
给歌曲排序可要比给数字排序复杂。两个数字之间固然只有大于小于等于三种关系,但是同一首专辑的歌曲之间,也许我们还想继续按歌曲名、歌曲时间或其它属性排序。讲到这里,写排序函数时碰到的第一个问题就出来了:如何给多属性进行排序?
简单方案
先简单来写几个基础排序函数,尝试解决以下问题:
- 先按曲子的专辑名称的长度排序。
- 再按曲子的名称的字典顺序排序。
function sortByAlbumNameLen(songA, songB) {
const lenA = songA.album.name.length
const lenB = songB.album.name.length
// 专辑名称相同,则不改变顺序(0),名称不相同时,则按照专辑名称的长度由短到长排列。
return lenA === lenB ? 0 : (lenA < lenB ? -1 : 1)
}
const songs = [
{
name: 'songB', album: {
name: 'wow' } },
{
name: 'songC', album: {
name: 'other' } },
{
name: 'songA', album: {
name: 'wow' } },
]
songs.sort(sortByAlbumNameLen)
// 得到结果:songB、songA、songC
然后再来处理专辑名称相同的情况。
function sortBySongName(songA, songB) {
const nameA = songA.name
const nameB = songB.name
// 根据曲子的名称的字典顺序排序,如 'abort' 排在 'bank' 前面。
return nameA === nameB ? 0 : (nameA < nameB ? -1 : 1)
}
由于 Array.prototype.sort 方法只接受一个函数,所以,需要把 sortByAlbumNameLen 和 sortBySongName 两个函数合二为一。
function sortByAlbumLenThenSongName(songA, songB) {
const firstRes = sortByAlbumNameLen(songA, songB)
// 先以 sortByAlbumNameLen 的结果为准,
// 如果 sortByAlbumNameLen 没能得出顺序,
// 返回了 0,
// 那么继续排序,以 sortBySongName 的结果为准
if (firstRes !== 0) return firstRes
else return sortBySongName(songA, songB)
}
const songs = [
{
name: 'songB', album: {
name: 'wow' } },
{
name: 'songC', album: {
name: 'other' } },
{
name: 'songA', album: {
name: 'wow' } },
]
songs.sort(sortByAlbumNameLen)
// 得到结果:
// [
// { name: 'songA', album: { name: 'wow' } },
// { name: 'songB', album: { name: 'wow' } },
// { name: 'songC', album: { name: 'other' } },
// ]
搞定。目前为止,先按曲子的专辑名称的长度排序,再按曲子的名称的字典顺序排序这个小功能就写好了。
问题抽象
有一个问题比较烦人,就是 Array.prototype.sort 只接受一个参数用作排序函数,还不能是数组。所以只要更改了排序的规则,那么传进去的参数(即上面的 sortByAlbumLenThenSongName)的逻辑就得跟着改。
这个小麻烦可以通过抽象解决。仔细观察,sortByAlbumLenThenSongName 的逻辑是:先按 sortByAlbumNameLen 的规则排序,如果 sortByAlbumNameLen 没能给出现后顺序(即返回 0),那么再按 sortBySongName 的顺序排序。这种现后顺序,不就是一个循环嘛!
function sortBy(...sortFns) {
return function fn(obj1, obj2) {
var fnsLen = sortFns.length,
result = 0,
i = 0
while(result === 0 && i < fnsLen) {
result = sort(sortFns[i], map)(obj1, obj2)
i++
}
return result
}
}
songs.sort(sortBy(
sortByAlbumNameLen,
sortBySongName
))
以后,就算我们继续更改排序规则,也不需要写新的函数去重构传入 Array.prototype.sort 的那个方法。假设替换一条排序规则:
- 先按曲子的专辑名称的长度排序。
再按曲子的名称的字典顺序排序。- 再按曲子的时长排序。
只需要改动以下几行代码:
// ...
function sortBySongTime(a, b) {
return a.time === b.time ? 0 : (a.time < b.time ? -1 : 1)
}
// ...
songs.sort(sortBy(
sortByAlbumNameLen,
sortBySongTime
))
妙哉妙哉~
逐步改进
上一小节,我们简单完成了一个具有多属性排序功能。接下来需进一步完善这个排序函数,使其功能完整,更加强大。
API 优化
程序员都是懒惰的。其实我甚至连基础的排序函数都不想写,要是能直接传入待排序的属性名那多好哇:
const songs = [
{
name: 'songB', album: {
name: 'wow' } },
{
name: 'songC', album: {
name: 'other' } },
{
name: 'songA', album: {
name: 'wow' } },
]
// 先按专辑名称、再按曲子名正序排列
songs.sort(
sortBy('album.name', 'name')
)
// Results:songA、songB、songC
也就是说,需要写一个读取对象的属性值的方法。既然是涉及属性存取符号(也就是点运算符),那我们就用 eval(‘song.album.name’) 就能读到属性啦。咳咳,划掉,动态语言不意味着就要用 eval 解决问题。当然了,new Function 也不行。以下继续写一个 while 循环吧。
const getVal = name => {
const pathsStore = name.split('.')
return x => {
const paths = [...pathsStore]
let val = x
while (val && paths.length) {
next = paths.shift()
val = val[next]
}
return val
}
}
getVal('b.c')(
{
b: {
c: 'test' } }
)
// Results:'test'
再接下来只要重构 sortBy 函数,把 getVal 的逻辑添加进去,就完成传入字符串属性的改造啦。
自定义排序函数
现在可以传入字符串了,我们再加一种 API:自定义排序函数,以支持某些特殊排序逻辑。比如以下代码:
songs.sort(sortBy(
// 优先选择 2020 年发布的曲子,并按时间倒序排列
(a, b) => {
const year2019 = +new Date('2019-12-31 23:59:59')
const year2020 = +new Date('2020-12-31 23:59:59')
const timeA = Math.min(year2019, Math.max(a.pubTime, year2020))
const timeB = Math.min(year2019, Math.max(b.pubTime, year2020))
return timeA === timeB ? 0 : ( timeA < timeB ?