Skip to content

Commit 39b60a4

Browse files
committed
chore: improve object diff and isequal speed
1 parent 6cf30a4 commit 39b60a4

File tree

4 files changed

+161
-125
lines changed

4 files changed

+161
-125
lines changed

README.md

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -816,27 +816,25 @@ Issues and pull requests are welcome!
816816

817817
## BENCHMARK
818818

819-
Environment: Node.js 24.12.0 (LTS) • macOS Sequoia 15.1 • MacBook Pro M2 (2023) • 16GB RAM
819+
Environment: Node.js 24.12.0 (LTS) • macOS Sequoia 15.1 • MacBook Pro M2 (2023) • 16GB RAM.
820+
821+
Method: Warm up runs, then each script runs 20 times and we keep the median time.
820822

821823
### List diff
822824

823825
| Scenario | Superdiff | arr-diff | deep-diff |
824826
| ------------------------- | --------- | -------- | --------- |
825-
| 10k items array | 2.59 ms | 47.52 ms | 5.49 ms |
826-
| 100k items array | 36.44 ms | 4836.25 ms | 62.30 ms. |
827+
| 10k items array | **2.59 ms** | 47.52 ms | 5.49 ms |
828+
| 100k items array | **36.44 ms** | 4836.25 ms | 62.30 ms. |
827829

828-
Superdiff outperforms the competition on list diff and scales linearly.
830+
Despite providing a more complex diff, Superdiff outperforms the competition on lists and scales linearly.
829831

830832
### Object diff
831833

832834
| Scenario | Superdiff | deep-object-diff | deep-diff |
833835
| ------------------------------ | --------- | ---------------- | --------- |
834-
| 10k flat object keys | 2.85 ms | 2.40 ms | 39.47 ms |
835-
| 100k flat object keys | 36.44 ms | 31.49 ms | 3803.52 ms|
836-
| 100k nested nodes | 8.91 ms | 8.80 ms | 16.58 ms |
837-
838-
- deep-object-diff focuses on shallow key comparison
839-
- deep-diff performs recursive traversal with early‑exit behavior
840-
- Superdiff computes a full structural diff with richer output
836+
| 10k flat object keys | **2.31 ms** | 2.46 ms | 39.56 ms |
837+
| 100k flat object keys | **30.23 ms** | 31.86 ms | 3784.50 ms|
838+
| 100k nested nodes | **4.25 ms** | 9.67 ms | 16.51 ms |
841839

842-
Despite doing more work, Superdiff performance remains close to the fastest implementations, and scales efficiently on both wide and deeply nested data.
840+
Despite providing a full structural diff with a richer output, Superdiff is the fastest. It also scales linearly, even with deeply nested data.

benchmark/index.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
1-
// import { runListBench100K, runListBench10K } from "./list-benchmark";
2-
import { runTextBench10K } from "./text-benchmark";
3-
// import {
4-
// runObjectBench10K,
5-
// runObjectBench100K,
6-
// runNestedObjectBench,
7-
// } from "./object-benchmark";
1+
//import { runListBench10K } from "./list-benchmark";
2+
// import { runTextBench10K } from "./text-benchmark";
3+
import {
4+
runObjectBench10K,
5+
// runObjectBench100K,
6+
// runNestedObjectBench,
7+
} from "./object-benchmark";
88

99
console.log("Running Superdiff benchmarks");
1010

1111
async function main() {
1212
console.log("=== SUPERDIFF BENCHMARKS ===");
1313

1414
// Objects
15-
// runObjectBench10K();
16-
// runObjectBench100K();
17-
// runNestedObjectBench();
15+
runObjectBench10K();
16+
//runObjectBench100K();
17+
//runNestedObjectBench();
1818

1919
// List
2020
// runListBench10K();
@@ -23,7 +23,7 @@ async function main() {
2323
// await runListStreamBench();
2424

2525
// Text
26-
runTextBench10K();
26+
//runTextBench10K();
2727
//runTextBench100K();
2828

2929
console.log("\n=== BENCHMARK COMPLETE ===");

src/lib/object-diff/index.ts

Lines changed: 98 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -14,25 +14,38 @@ function getLeanDiff(
1414
showOnly: ObjectDiffOptions["showOnly"] = DEFAULT_OBJECT_DIFF_OPTIONS.showOnly,
1515
): ObjectDiff["diff"] {
1616
const { statuses, granularity } = showOnly;
17+
const statusesSet = new Set(statuses);
1718
const res: ObjectDiff["diff"] = [];
19+
const deep = granularity === Granularity.DEEP;
1820
for (let i = 0; i < diff.length; i++) {
19-
const value = diff[i];
20-
if (granularity === Granularity.DEEP && value.diff) {
21-
const leanDiff = getLeanDiff(value.diff, showOnly);
22-
if (leanDiff.length > 0) {
23-
res.push({ ...value, diff: leanDiff });
21+
const entry = diff[i];
22+
if (deep && entry.diff) {
23+
const sub = getLeanDiff(entry.diff, showOnly);
24+
if (sub.length > 0) {
25+
res.push({
26+
property: entry.property,
27+
previousValue: entry.previousValue,
28+
currentValue: entry.currentValue,
29+
status: entry.status,
30+
diff: sub,
31+
});
2432
}
25-
} else if (statuses.includes(value.status)) {
26-
res.push(value);
33+
continue;
34+
}
35+
if (statusesSet.has(entry.status)) {
36+
res.push(entry);
2737
}
2838
}
2939
return res;
3040
}
3141

3242
function getObjectStatus(diff: ObjectDiff["diff"]): ObjectStatus {
33-
return diff.some((property) => property.status !== ObjectStatus.EQUAL)
34-
? ObjectStatus.UPDATED
35-
: ObjectStatus.EQUAL;
43+
for (let i = 0; i < diff.length; i++) {
44+
if (diff[i].status !== ObjectStatus.EQUAL) {
45+
return ObjectStatus.UPDATED;
46+
}
47+
}
48+
return ObjectStatus.EQUAL;
3649
}
3750

3851
function formatSingleObjectDiff(
@@ -41,123 +54,117 @@ function formatSingleObjectDiff(
4154
options: ObjectDiffOptions = DEFAULT_OBJECT_DIFF_OPTIONS,
4255
): ObjectDiff {
4356
if (!data) {
44-
return {
45-
type: "object",
46-
status: ObjectStatus.EQUAL,
47-
diff: [],
48-
};
57+
return { type: "object", status: ObjectStatus.EQUAL, diff: [] };
4958
}
5059
const diff: ObjectDiff["diff"] = [];
51-
52-
for (const [property, value] of Object.entries(data)) {
60+
const added = status === ObjectStatus.ADDED;
61+
for (const key in data) {
62+
const value = data[key];
5363
if (isObject(value)) {
54-
const subPropertiesDiff: Diff[] = [];
55-
for (const [subProperty, subValue] of Object.entries(value)) {
56-
subPropertiesDiff.push({
57-
property: subProperty,
58-
previousValue: status === ObjectStatus.ADDED ? undefined : subValue,
59-
currentValue: status === ObjectStatus.ADDED ? subValue : undefined,
64+
const sub: Diff[] = [];
65+
for (const subKey in value) {
66+
sub.push({
67+
property: subKey,
68+
previousValue: added ? undefined : value[subKey],
69+
currentValue: added ? value[subKey] : undefined,
6070
status,
6171
});
6272
}
6373
diff.push({
64-
property,
65-
previousValue:
66-
status === ObjectStatus.ADDED ? undefined : data[property],
67-
currentValue: status === ObjectStatus.ADDED ? value : undefined,
74+
property: key,
75+
previousValue: added ? undefined : data[key],
76+
currentValue: added ? value : undefined,
6877
status,
69-
diff: subPropertiesDiff,
78+
diff: sub,
7079
});
7180
} else {
7281
diff.push({
73-
property,
74-
previousValue:
75-
status === ObjectStatus.ADDED ? undefined : data[property],
76-
currentValue: status === ObjectStatus.ADDED ? value : undefined,
82+
property: key,
83+
previousValue: added ? undefined : data[key],
84+
currentValue: added ? value : undefined,
7785
status,
7886
});
7987
}
8088
}
81-
82-
if (options.showOnly && options.showOnly.statuses.length > 0) {
83-
return {
84-
type: "object",
85-
status,
86-
diff: getLeanDiff(diff, options.showOnly),
87-
};
89+
const showOnly = options.showOnly;
90+
if (showOnly && showOnly.statuses.length > 0) {
91+
return { type: "object", status, diff: getLeanDiff(diff, showOnly) };
8892
}
89-
return {
90-
type: "object",
91-
status,
92-
diff,
93-
};
94-
}
95-
96-
function getValueStatus(
97-
previousValue: unknown,
98-
nextValue: unknown,
99-
options?: ObjectDiffOptions,
100-
): ObjectStatus {
101-
if (isEqual(previousValue, nextValue, options)) {
102-
return ObjectStatus.EQUAL;
103-
}
104-
return ObjectStatus.UPDATED;
93+
return { type: "object", status, diff };
10594
}
10695

10796
function getDiff(
108-
previousValue: Record<string, unknown> | undefined = {},
109-
nextValue: Record<string, unknown>,
97+
prev: Record<string, unknown> = {},
98+
next: Record<string, unknown>,
11099
options?: ObjectDiffOptions,
111100
): Diff[] {
112101
const diff: Diff[] = [];
113-
const allKeys = new Set([
114-
...Object.keys(previousValue),
115-
...Object.keys(nextValue),
116-
]);
117102

118-
for (const property of allKeys) {
119-
const prevSubValue = previousValue[property];
120-
const nextSubValue = nextValue[property];
121-
if (!(property in nextValue)) {
103+
for (const key in prev) {
104+
const prevVal = prev[key];
105+
106+
if (!Object.prototype.hasOwnProperty.call(next, key)) {
122107
diff.push({
123-
property,
124-
previousValue: prevSubValue,
108+
property: key,
109+
previousValue: prevVal,
125110
currentValue: undefined,
126111
status: ObjectStatus.DELETED,
127112
});
128113
continue;
129114
}
130-
if (!(property in previousValue)) {
131-
diff.push({
132-
property,
133-
previousValue: undefined,
134-
currentValue: nextSubValue,
135-
status: ObjectStatus.ADDED,
136-
});
137-
continue;
138-
}
139-
if (isObject(nextSubValue) && isObject(prevSubValue)) {
140-
const subDiff = getDiff(prevSubValue, nextSubValue, options);
141-
const isUpdated = subDiff.some(
142-
(entry) => entry.status !== ObjectStatus.EQUAL,
115+
116+
const nextVal = next[key];
117+
118+
if (isObject(prevVal) && isObject(nextVal)) {
119+
const sub = getDiff(prevVal, nextVal, options);
120+
let updated = false;
121+
122+
for (let i = 0; i < sub.length; i++) {
123+
if (sub[i].status !== ObjectStatus.EQUAL) {
124+
updated = true;
125+
break;
126+
}
127+
}
128+
129+
diff.push(
130+
updated
131+
? {
132+
property: key,
133+
previousValue: prevVal,
134+
currentValue: nextVal,
135+
status: ObjectStatus.UPDATED,
136+
diff: sub,
137+
}
138+
: {
139+
property: key,
140+
previousValue: prevVal,
141+
currentValue: nextVal,
142+
status: ObjectStatus.EQUAL,
143+
},
143144
);
144-
diff.push({
145-
property,
146-
previousValue: prevSubValue,
147-
currentValue: nextSubValue,
148-
status: isUpdated ? ObjectStatus.UPDATED : ObjectStatus.EQUAL,
149-
...(isUpdated && { diff: subDiff }),
150-
});
151145
} else {
152-
const status = getValueStatus(prevSubValue, nextSubValue, options);
146+
const status = isEqual(prevVal, nextVal, options)
147+
? ObjectStatus.EQUAL
148+
: ObjectStatus.UPDATED;
149+
153150
diff.push({
154-
property,
155-
previousValue: prevSubValue,
156-
currentValue: nextSubValue,
151+
property: key,
152+
previousValue: prevVal,
153+
currentValue: nextVal,
157154
status,
158155
});
159156
}
160157
}
158+
159+
for (const key in next) {
160+
if (Object.prototype.hasOwnProperty.call(prev, key)) continue;
161+
diff.push({
162+
property: key,
163+
previousValue: undefined,
164+
currentValue: next[key],
165+
status: ObjectStatus.ADDED,
166+
});
167+
}
161168
return diff;
162169
}
163170

src/lib/utils/index.ts

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,53 @@ export function isEqual(
1212
b: unknown,
1313
options: isEqualOptions = { ignoreArrayOrder: false },
1414
): boolean {
15+
if (a === b) return true;
1516
if (typeof a !== typeof b) return false;
17+
if (a === null || b === null) return a === b;
18+
if (typeof a !== "object") return a === b;
19+
1620
if (Array.isArray(a) && Array.isArray(b)) {
17-
if (a.length !== b.length) {
18-
return false;
21+
if (a.length !== b.length) return false;
22+
if (!options.ignoreArrayOrder) {
23+
for (let i = 0; i < a.length; i++) {
24+
if (!isEqual(a[i], b[i], options)) return false;
25+
}
26+
return true;
27+
}
28+
29+
const counts = new Map<unknown, number>();
30+
for (const item of a) {
31+
const key = JSON.stringify(item);
32+
counts.set(key, (counts.get(key) || 0) + 1);
1933
}
20-
if (options.ignoreArrayOrder) {
21-
return a.every((v) =>
22-
b.some((nextV) => JSON.stringify(nextV) === JSON.stringify(v)),
23-
);
34+
for (const item of b) {
35+
const key = JSON.stringify(item);
36+
const count = counts.get(key);
37+
if (!count) return false;
38+
if (count === 1) counts.delete(key);
39+
else counts.set(key, count - 1);
2440
}
25-
return a.every((v, i) => JSON.stringify(v) === JSON.stringify(b[i]));
41+
return counts.size === 0;
2642
}
27-
if (typeof a === "object") {
28-
return JSON.stringify(a) === JSON.stringify(b);
43+
44+
if (a && b) {
45+
const aObj = a as Record<string, unknown>;
46+
const bObj = b as Record<string, unknown>;
47+
let bKeyCount = 0;
48+
for (const _ in bObj) {
49+
if (Object.prototype.hasOwnProperty.call(bObj, _)) bKeyCount++;
50+
}
51+
let matchedKeys = 0;
52+
for (const key in aObj) {
53+
if (!Object.prototype.hasOwnProperty.call(aObj, key)) continue;
54+
matchedKeys++;
55+
if (!Object.prototype.hasOwnProperty.call(bObj, key)) return false;
56+
if (!isEqual(aObj[key], bObj[key], options)) return false;
57+
}
58+
return matchedKeys === bKeyCount;
2959
}
30-
return a === b;
60+
61+
return true;
3162
}
3263

3364
/**
@@ -36,5 +67,5 @@ export function isEqual(
3667
* @returns value is Record<string, unknown>
3768
*/
3869
export function isObject(value: unknown): value is Record<string, unknown> {
39-
return !!value && typeof value === "object" && !Array.isArray(value);
70+
return typeof value === "object" && !!value && !Array.isArray(value);
4071
}

0 commit comments

Comments
 (0)