这是我参与更文挑战的第8天,活动详情查看: 更文挑战
前言
作为一个前端,服务器返回的数据易用,能极大的提升开发效率。
能一个接口提供的数据,就不要用去调用两次或者更多网络请求,然后进行数据合并。
然而,理想和现实两者,现实总是找我,感觉不到理想对的温暖。
残酷的经历
说起来,你可能不信,但是请您相信我,因为我很善良。
我们某个业务,有一个列表数据,需要调用三个接口合并数据,具体如下:
- 单次返回10条基础信息
- 再分10次去请求详情信息
- 从第2步的结果中用某个ID值,再发10次请求,请求用户信息
打断你的施法,别问我为什么,之前就是这么实现的。
我们来做一个简单的算术:
1 + 10 + 10
= 21
我的天,原天堂没有数据合并。
后来在我再三的要求下,终于变成了调用两个接口:
- 单次返回10条基础信息
- 用第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查找替换数组查找。
核心点:
- 找到能标记对象唯一的属性键,本文为
uid
- 遍历数组, 以单条数据属性
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;
}
到此,多级属性的读取设置问题解决。
准入下一个稍微复杂的问题,就是正序和倒叙的问题。
正序和倒叙-迭代器
不管是正序遍历还是倒叙遍历,其本质都是迭代。
按照传统实现,三种方式
- if/else + 两个 for循环
- while
- 数组的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
}
]
到此为止,真可以算是高一段段落。
提问
- 当前的版本还有什么问题,应该怎么解决。
- 当前的版本还有哪些改进方案。
凡是留言者,笔者100%回复并进入你的掘金空间去踩踩。
写在最后
写作不易,如果觉得还不错, 一赞一评,就是我最大的动力。