两个数组数据的高效合并方案

6,844 阅读5分钟

这是我参与更文挑战的第8天,活动详情查看: 更文挑战

前言

作为一个前端,服务器返回的数据易用,能极大的提升开发效率。
能一个接口提供的数据,就不要用去调用两次或者更多网络请求,然后进行数据合并。

然而,理想和现实两者,现实总是找我,感觉不到理想对的温暖。

残酷的经历

说起来,你可能不信,但是请您相信我,因为我很善良。

我们某个业务,有一个列表数据,需要调用三个接口合并数据,具体如下:

  1. 单次返回10条基础信息
  2. 再分10次去请求详情信息
  3. 从第2步的结果中用某个ID值,再发10次请求,请求用户信息

打断你的施法,别问我为什么,之前就是这么实现的。

我们来做一个简单的算术: 1 + 10 + 10 = 21
我的天,原天堂没有数据合并。

后来在我再三的要求下,终于变成了调用两个接口:

  1. 单次返回10条基础信息
  2. 用第1步的数据集合中的ID值,再批量查询

我们来做一个更简单的算术: 1 + 1 = 2

虽然比一次返回全部数据仅仅多了一次,但是抛出了一个不可回避的问题,数组的数据合并。

今天,就让我们来一起探索数组数据合并。

demo数据

假设有两组数据, 一组是用户基础数据, 一组是分值数据,通过uid关联。

export const usersInfo = Array.from({ length: 10 }, (val, index) => {
    return {
        uid: `${index + 1}`,
        name: `user-name-${index}`,
        age: index + 10,
        avatar: `http://www.avatar.com/${index + 1}`
    }
});

export const scoresInfo = Array.from({ length: 8 }, (val, index) => {
    return {
        uid: `${index + 1}`,
        score: ~~(Math.random() * 10000),
        comments: ~~(Math.random() * 10000),
        stars: ~~(Math.random() * 1000)
    }
});

基础版本

两层循环,通过某个key进行比较,然后赋值。
我想说,简单好用,XDM,你们不骂我才怪。 搞不好还建议掘金搞一个踩的功能。

import * as datas from "./data";

const { usersInfo, scoresInfo } = datas;

console.time("merge data")
for (let i = 0; i < usersInfo.length; i++) {
    var user = usersInfo[i] as any;
    for (let j = 0; j < scoresInfo.length; j++) {
        var score = scoresInfo[j];
        if (user.uid == score.uid) {
            user.score = score.score;
            user.comments = score.comments;
            user.stars = score.stars;
        }
    }
}
console.timeEnd("merge data")
console.log(usersInfo);

基础-hash版本

这里的基本思路先把数组转换成对象。 当然也可以是转为Map对象。
用hash查找替换数组查找。

核心点:

  1. 找到能标记对象唯一的属性键,本文为 uid
  2. 遍历数组, 以单条数据属性uid的值作为属性键,单条数据作为值

对象属性的查找相比数组的查询要快得多,这里就能得到不少的性能提升。

你可能要问题,为啥要快得多,因为这里的数组查找不能按照下标查找的,而是链表形式来查找的。

import * as datas from "./data";

const { usersInfo, scoresInfo } = datas;

console.time("merge data")

const scoreMap = scoresInfo.reduce((obj, cur) => {
    obj[cur.uid] = cur;
    return obj;
}, Object.create(null));

for (let i = 0; i < usersInfo.length; i++) {
    const user = usersInfo[i] as any;
    const score = scoreMap[user.uid];

    if(score != null){
        user.score = score.score;
        user.comments = score.comments;
        user.stars = score.stars;
    }
}
console.timeEnd("merge data")

console.log(usersInfo);

到这里,你也许会伸个懒腰,喝口水。
一不小心喝多了,你喝多了。

这种实现就存在多遍历的情况,所以我们在达到目标后,跳出。

基础-hash-跳出版

这个版本和上个版本的最大区别,就是增加了计数功能,记录已经合并的次数,达到预期后,跳出。
你也许会笑,这记个数有啥意思。

我们用数据说话:

假如我们的列表里面有100条数据,需要合并的数据只有10条。 假设列表中第40-49条是要合并的数据。

我们来算个数:100-50 = 50

此场景会多遍历50次。随着各种场景的不同,多遍历的次数也不一样。

import * as datas from "./data";

const { usersInfo, scoresInfo } = datas;

console.time("merge data")

const scoreMap = scoresInfo.reduce((obj, cur) => {
    obj[cur.uid] = cur;
    return obj;
}, Object.create(null));


const len = scoresInfo.length;
let count = 0;
let  walkCount = 0;
for (let i = 0; i < usersInfo.length; i++) {
    const user = usersInfo[i] as any;
    const score = scoreMap[user.uid];

    walkCount++;
    if(score != null){
        count++
        user.score = score.score;
        user.comments = score.comments;
        user.stars = score.stars;       

        if(count>=len){
            break;
        }
    }
}
console.log(`合并完毕:遍历次数${walkCount}, 实际命中次数${count}, 预期命中次数${len}`)

console.timeEnd("merge data");

console.log(usersInfo);

到这里,你微微一笑很迷人,打开掘金,发现自己上了首页的文章,居然从首页跑到第二页去了。

划重点 第二页, 是的我们的数据一般是分页加载的。

我们理论上是拉取一页,合并一次数据。也就是说,大多数情况,后面拉取的数据都是追加原列表后面。

新数据都是追加到原列表后,那是不是倒序遍历就会更快呢? 答案,肯定是。
当然你要是后拉取的数据,往前放,顺序遍历更快。

基础-hash-跳出-倒序版

倒序遍历和顺序遍历的区别就是一个从前到后,一个从后往前。 代码如下

import * as datas from "./data";

const { usersInfo, scoresInfo } = datas;

console.time("merge data")

const scoreMap = scoresInfo.reduce((obj, cur) => {
    obj[cur.uid] = cur;
    return obj;
}, Object.create(null));


const len = scoresInfo.length;
let count = 0;
let  walkCount = 0;
for (let i = usersInfo.length - 1; i>=0 ; i--) {
    const user = usersInfo[i] as any;
    const score = scoreMap[user.uid];

    walkCount++;
    if(score != null){
        count++
        user.score = score.score;
        user.comments = score.comments;
        user.stars = score.stars;       

        if(count>=len){
            break;
        }
    }
}
console.log(`合并完毕:遍历次数${walkCount}, 实际命中次数${count}, 预期命中次数${len}`)

console.timeEnd("merge data");

console.log(usersInfo);

到这里,你刚准备走,突然有同事甲围观你,发现你在写数组合并。

同事甲说: 哇,写数据合并啊,我这也有需求,你顺便抽象封装一下吧。 我的数据是追加型,也就是倒序遍历。

同事甲说完, 好几个同事站了起来,说,我这也有需求。

同事乙说:我的是插入头部的,需要顺序遍历。
同事丙说:我要合并的属性是 a.b.c这种。
同事丁说:我要合并的是 a[0].b这种。

此时的你,改怎么做呢?

求助开源

underscore和lodash都有关于数据的操作,但是还达不到我们的需求。 当然可以借助其完成。但是如果都依赖lodash了,那你这个可迁移性就有待商榷。

array-union, array-merge-by-key, deep-merger等等有合并的能力,但是都不如我们所期望。

算了,自己写吧。

准备工具类

属性读取和设置

我们先整理一下,数组的合并,具体操作的时候,其实是对象的合并。 对象的合并一级属性合并好办,多级呢,是个问题。

其他lodash.get已经完美实现,我们一起来看一下其官方的demo:

var object = { 'a': [{ 'b': { 'c': 3 } }] };
 
_.get(object, 'a[0].b.c');
// => 3
 
_.get(object, ['a', '0', 'b', 'c']);
// => 3
 
_.get(object, 'a.b.c', 'default');
// => 'default'

是不是很强大,同理,属性的设置,lodash提供了lodash.set, 我们一睹风采。

var object = { 'a': [{ 'b': { 'c': 3 } }] };
 
_.set(object, 'a[0].b.c', 4);
console.log(object.a[0].b.c);
// => 4
 
_.set(object, ['x', '0', 'y', 'z'], 5);
console.log(object.x[0].y.z);
// => 5

这里,你可能要说,你不是不依赖lodash嘛,这不都提的都是lodash嘛。
请掘友莫着急,我们要实施 拿来主义

删掉我们不太关心的,其核心就三个方法

  • stringToPath:路径转换成数组
  • getProperty:读属性
  • setProperty:设属性
const stringToPath = (string: string) => {
    const result = [];
    if (string.charCodeAt(0) === charCodeOfDot) {
        result.push('');
    }
    string.replace(rePropName, ((match, expression, quote, subString) => {
        let key = match;
        if (quote) {
            key = subString.replace(reEscapeChar, '$1');
        }
        else if (expression) {
            key = expression.trim();
        }
        result.push(key);
    }) as any);
    return result;
};

function getProperty(obj: Object, key: string, defaultValue: any = undefined) {

    if (!isObject(obj)) {
        return defaultValue;
    }

    const path = stringToPath(key);

    let index = 0;
    const length = path.length;

    while (obj != null && index < length) {
        obj = obj[path[index++]];
    }
    return (index && index == length) ? obj : undefined || defaultValue;
}

function setProperty(obj: Object, path: string, value: any = undefined) {

    if (!isObject(obj)) {
        return obj;
    }
    const keys = stringToPath(path);

    const length = keys.length;
    const lastIndex = length - 1;

    let index = -1;
    let nested = obj;

    while (nested != null && ++index < length) {
        const key = keys[index];
        let newValue = value;

        if (index != lastIndex) {
            const objValue = nested[key];
            newValue = undefined;
            if (newValue === undefined) {
                newValue = isObject(objValue) ? objValue : (isIndexLike[keys[index + 1]] ? [] : {})
            }
        }
        nested[key] = newValue;
        nested = nested[key];
    }
    return obj;
}

到此,多级属性的读取设置问题解决。

准入下一个稍微复杂的问题,就是正序和倒叙的问题。

正序和倒叙-迭代器

不管是正序遍历还是倒叙遍历,其本质都是迭代。
按照传统实现,三种方式

  1. if/else + 两个 for循环
  2. while
  3. 数组的forEach, reduce等

但是都逃不出要分开逻辑判断是顺序还是倒叙, 这些判断写在遍历代码里,代码可读性会变差。

我们这里应该想到迭代器,迭代器。

利用高阶函数,封装递归逻辑,外部只关心hasNext和current。

function getStepIter(min: number, max: number, desc: boolean) {

    let start = desc ? max : min;
    let end = desc ? min : max;

    if (desc) {
        return {
            hasNext() {
                return start >= end
            },
            get current() {
                return start;
            },
            next() {
                return --start

            }
        }
    }
    return {
        hasNext() {
            return end >= start
        },
        get current() {
            return start;
        },
        next() {
            return ++start
        }
    }
}

这两个大问题解决,就开始撸起袖子, just do it!.

感谢 SSShuai1999 提供的优化方案, 代码行数减少。 在 hasNext的时候有一个三目运算。

function getStepIter(min: number, max: number, desc: boolean): any { 
    let [start, end, operator] = desc ? [max, min, -1] : [min, max, +1] 
     return { 
         hasNext() { 
             return desc ? start >= end : end >= start 
         }, 
         get current() { 
             return start; 
         }, 
         next() { 
             return start += operator 
         } 
     } 
 }

具体实现

略。
会不会有那个羊驼跑过,哈哈。
篇幅问题,全部代码请移步到 arrayMerge

演示

看代码

data.ts, 为了方便看到结果,我们减少数据量。
这里的usersInfo的uid是 1,2,3
这里的scoresInfo的uid是2,3
这么设计是为了演示倒叙遍历。

// data.ts
export const usersInfo = Array.from({ length: 3 }, (val, index) => {
    return {
        uid: `${index + 1}`,
        name: `user-name-${index}`,
        age: index + 10,
        avatar: `http://www.avatar.com/${index + 1}`
    }
});

export const scoresInfo = Array.from({ length: 2 }, (val, index) => {
    return {
        uid: `${index + 2}`,
        score: ~~(Math.random() * 10000),
        comments: ~~(Math.random() * 10000),
        stars: ~~(Math.random() * 1000)
    }
});


test.ts

import { mergeArray } from "../lib/array";

import * as datas from "./data";

const { usersInfo, scoresInfo } = datas;


const arr = mergeArray(usersInfo, scoresInfo, {
    sourceKey: "uid", // 源列表对象的用来比较的属性键
    targetKey: "uid",  // 目标列表对象的用来比较的属性键
    sKMap: {
        "score": "data.score",  // 把源的score属性映射到目标对象的data.score属性上
        "comments": "data.comments"// 把源的comments属性映射到目标对象的data.comments属性上
        "stars": "stars" // 把源的stars属性映射到目标对象的stars属性上
    }
});

console.log("arr", arr);

运行结果:
targetArr(3), sourceArr(2), 统计:遍历次数2, 命中次数2
其表示:列表长度为3,要合入的长度为2,累计遍历2次,命中2次。

mergeArray:: targetArr(3), sourceArr(2), 统计:遍历次数2, 命中次数2
arr [
  {
    uid: '1',
    name: 'user-name-0',
    age: 10,
    avatar: 'http://www.avatar.com/1'
  },
  [Object: null prototype] {
    uid: '2',
    name: 'user-name-1',
    age: 11,
    avatar: 'http://www.avatar.com/2',
    data: { score: 6979, comments: 3644 },
    stars: 434
  },
  [Object: null prototype] {
    uid: '3',
    name: 'user-name-2',
    age: 12,
    avatar: 'http://www.avatar.com/3',
    data: { score: 6348, comments: 320 },
    stars: 267
  }
]

到此为止,真可以算是高一段段落。

提问

  1. 当前的版本还有什么问题,应该怎么解决。
  2. 当前的版本还有哪些改进方案。

凡是留言者,笔者100%回复并进入你的掘金空间去踩踩。

写在最后

写作不易,如果觉得还不错, 一赞一评,就是我最大的动力。