Deux réponses JSON paraissent presque identiques, mais quelque chose a changé entre les déploiements et vos tests échouent. Ou vous relisez une pull request qui touche à un gros fichier de config et vous avez besoin de savoir exactement quelles valeurs ont bougé. Comparer des fichiers JSON paraît simple —— jusqu’à ce que vous réalisiez qu’un diff de texte naïf traite la réorganisation des clés comme un changement, et que les structures profondément imbriquées demandent une approche plus fine. Cet article explique les algorithmes qui font qu’un vrai diff JSON fonctionne.
Pourquoi un diff de texte brut ne suffit pas
La façon la plus simple de comparer deux fichiers est de lancer diff ou de les comparer ligne à ligne. Pour du texte brut, ça marche. Pour du JSON, ça casse d’au moins deux façons.
Problème 1 : ordre des clés
Selon la spec, les objets JSON sont non ordonnés. Les deux documents suivants sont sémantiquement identiques :
// Document A
{ "name": "Ada", "plan": "pro", "active": true }
// Document B
{ "active": true, "name": "Ada", "plan": "pro" }Un diff de texte ligne à ligne signalerait toutes les lignes comme modifiées. Un vrai diff JSON signale zéro différence.
Problème 2 : bruit de formatage
Indentation, espaces, et le fait qu’un long tableau soit écrit sur une ligne ou sur plusieurs lignes — tout ça est sans rapport avec les données. Un diff de texte traite chaque différence d’espace comme un changement.
La solution aux deux problèmes est la même : parsez d’abord, diffez la structure, pas le texte.
Étape 1 —— Parser et normaliser
Avant toute comparaison, les deux documents sont parsés en objets JavaScript via JSON.parse() puis re-sérialisés avec les clés triées et une indentation cohérente :
function normalise(json) {
const value = JSON.parse(json);
return JSON.stringify(sortKeys(value), null, 2);
}
function sortKeys(value) {
if (Array.isArray(value)) return value.map(sortKeys);
if (value !== null && typeof value === 'object') {
return Object.keys(value)
.sort((a, b) => a.localeCompare(b))
.reduce((acc, key) => {
acc[key] = sortKeys(value[key]);
return acc;
}, {});
}
return value;
}Après normalisation, les documents A et B ci-dessus produisent tous les deux la même chaîne :
{
"active": true,
"name": "Ada",
"plan": "pro"
}Maintenant un diff de texte entre les deux chaînes normalisées n’affichera que de vraies différences de données, pas le bruit de format ou d’ordre des clés.
Étape 2 —— Diff au niveau ligne avec LCS
Avec du texte normalisé, le problème devient : étant données deux séquences de lignes, trouver l’ensemble minimal de changements (insertions et suppressions) qui transforme l’une en l’autre. C’est le classique problème de la plus longue sous-séquence commune (LCS).
Qu’est-ce que la LCS ?
La LCS de deux séquences est la plus longue sous-séquence qui apparaît dans les deux, dans le même ordre, sans devoir être contiguë. Par exemple :
Before: [" active: true", " name: Ada", " plan: pro" ]
After: [" active: true", " name: Ada", " plan: team"]
LCS: [" active: true", " name: Ada"] // 2 lignes en commun
// Résultat :
// same: " active: true"
// same: " name: Ada"
// deleted: " plan: pro"
// added: " plan: team"La LCS nous donne exactement le diff voulu : lignes restées identiques, lignes supprimées, lignes ajoutées.
L’algorithme DP
La LCS se résout par programmation dynamique. Pour deux tableaux de longueurs m et n, on construit un tableau 2D où dp[i][j] est la longueur de la LCS des i premiers éléments du tableau « avant » et des j premiers du tableau « après » :
// Remplir le tableau DP (bottom-up)
for (let i = m - 1; i >= 0; i--) {
for (let j = n - 1; j >= 0; j--) {
dp[i][j] = before[i] === after[j]
? dp[i + 1][j + 1] + 1 // les lignes correspondent
: Math.max(dp[i + 1][j], dp[i][j + 1]); // prend la meilleure branche
}
}
// Retraverser le tableau pour reconstruire les opérations
let i = 0, j = 0;
while (i < m && j < n) {
if (before[i] === after[j]) {
ops.push({ type: 'same', line: before[i++] }); j++;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
ops.push({ type: 'del', line: before[i++] });
} else {
ops.push({ type: 'add', line: after[j++] });
}
}Complexité en temps : O(m × n). Complexité en espace : O(m × n). Pour des documents JSON typiques, c’est largement assez rapide. Pour de très gros documents (disons plus de 2000 lignes chacun), le tableau peut consommer une mémoire significative —— à cette échelle, ça vaut le coup de passer à l’algorithme de Myers, qui tourne en O(n + d²) où d est le nombre de différences.
Optimisation mémoire : typed arrays
Un tableau 2D JavaScript de nombres a un overhead substantiel par élément. Utiliser un Int32Array plat divise la mémoire par à peu près 8 et améliore la localité du cache :
const W = n + 1;
const dp = new Int32Array((m + 1) * W); // buffer plat
// Accéder à dp[i][j] via dp[i * W + j]
dp[i * W + j] = before[i] === after[j]
? dp[(i + 1) * W + (j + 1)] + 1
: Math.max(dp[(i + 1) * W + j], dp[i * W + (j + 1)]);Pour un document de 500 lignes, ça fait passer le tableau de ~2 Mo d’objets sur le tas à ~1 Mo de mémoire typée contiguë.
Étape 3 —— Apparier les changements adjacents en lignes « modifiées »
La sortie brute de la LCS ne connaît que les suppressions et les ajouts. Mais dans une vue diff côte à côte, c’est bien plus lisible de montrer une ligne supprimée appariée avec la ligne ajoutée qui l’a remplacée —— une ligne « modifiée » :
// Ops brutes de la LCS :
del " plan: pro"
add " plan: team"
// Après appariement :
modified left: " plan: pro" right: " plan: team" L’algorithme d’appariement collecte des blocs consécutifs d’opérations del et add et les zippe. Les suppressions non appariées reçoivent un placeholder vide à droite ; les ajouts non appariés, un placeholder vide à gauche :
// Collecte un bloc consécutif de del/add
const dels = [], adds = [];
while (ops[i].type !== 'same') {
if (ops[i].type === 'del') dels.push(ops[i].line);
else adds.push(ops[i].line);
i++;
}
// Zippe en paires « modifiées »
const pairs = Math.min(dels.length, adds.length);
for (let k = 0; k < pairs; k++)
rows.push({ type: 'modified', left: dels[k], right: adds[k] });
// Suppressions restantes non appariées
for (let k = pairs; k < dels.length; k++)
rows.push({ type: 'deleted', left: dels[k] });
// Ajouts restants non appariés
for (let k = pairs; k < adds.length; k++)
rows.push({ type: 'added', right: adds[k] });Étape 4 —— Diff sémantique pour les compteurs de résumé
Le diff de lignes vous dit quel texte a changé. Un diff sémantique vous dit quels champs ont changé et comment —— utile pour un résumé du type « 3 ajoutés, 1 retiré, 2 modifiés ».
Le diff sémantique parcourt récursivement les deux objets parsés en parallèle :
function diffValue(key, path, before, after) {
if (before === undefined) return { status: 'added', after };
if (after === undefined) return { status: 'removed', before };
if (isObject(before) && isObject(after)) {
const keys = union(Object.keys(before), Object.keys(after)).sort();
const children = keys.map(k =>
diffValue(k, path + '.' + k, before[k], after[k])
);
return {
status: children.every(c => c.status === 'unchanged') ? 'unchanged' : 'changed',
children,
};
}
if (Array.isArray(before) && Array.isArray(after)) {
const len = Math.max(before.length, after.length);
const children = Array.from({ length: len }, (_, i) =>
diffValue(i, path + '[' + i + ']', before[i], after[i])
);
return {
status: children.every(c => c.status === 'unchanged') ? 'unchanged' : 'changed',
children,
};
}
return deepEqual(before, after)
? { status: 'unchanged', before, after }
: { status: 'changed', before, after };
}Parcourir l’arbre obtenu et compter les feuilles par statut donne les chiffres du résumé. Contrairement au diff de lignes, le diff sémantique est par construction agnostique à l’ordre des clés —— il compare les valeurs au même chemin de clé, quel que soit l’ordre dans lequel elles apparaissent dans le JSON.
Tout assembler
Un outil de diff JSON complet enchaîne ces étapes :
function jsonDiff(leftText, rightText) {
// 1. Parser
const leftVal = JSON.parse(leftText);
const rightVal = JSON.parse(rightText);
// 2. Diff sémantique → compteurs de résumé
const summary = summarise(diffValue('root', '#x27;, leftVal, rightVal));
// 3. Normaliser (trier les clés, indentation cohérente)
const leftNorm = JSON.stringify(sortKeys(leftVal), null, 2);
const rightNorm = JSON.stringify(sortKeys(rightVal), null, 2);
// 4. Diff de lignes via LCS
const rows = buildLineDiff(leftNorm, rightNorm);
return { summary, rows };
}Deep-Equal vs diff structurel
Un raccourci courant est de comparer deux documents JSON avec deepEqual (le assert.deepStrictEqual de Node, isEqual de Lodash) —— rapide, mais booléen seulement. Ça vous dit que les documents diffèrent ; ça ne dit pas où ni comment.
Un diff structurel parcourt les deux arbres et produit un rapport (compteurs, chemins, valeurs avant/après) et, en option, un JSON Patch. Utilisez deepEqual quand la réponse est oui/non —— dans les tests, l’invalidation de cache. Utilisez un diff structurel quand un humain doit agir sur les différences.
Produire un JSON Patch à partir du diff
Une fois que vous avez un arbre de diff sémantique, le même parcours qui compte les nœuds ajoutés/retirés/modifiés peut émettre un JSON Patch (RFC 6902) —— une liste portable d’opérations add / remove / replace qui transforme le document « avant » en « après ». C’est comme ça qu’on prend un diff et qu’on l’envoie en corps d’une requête HTTP PATCH, ou qu’on l’applique plus tard ailleurs.
function toJsonPatch(node, pointer = '') {
const ops = [];
if (node.status === 'added') ops.push({ op: 'add', path: pointer, value: node.after });
if (node.status === 'removed') ops.push({ op: 'remove', path: pointer });
if (node.status === 'changed' && !node.children) {
ops.push({ op: 'replace', path: pointer, value: node.after });
}
for (const child of node.children ?? []) {
const seg = String(child.key).replace(/~/g, '~0').replace(/\//g, '~1');
ops.push(...toJsonPatch(child, pointer + '/' + seg));
}
return ops;
} Les segments de chemin suivent les règles de JSON Pointer (RFC 6901) —— notez les échappements ~0 / ~1 pour ~ et / dans les clés. Des bibliothèques comme fast-json-patch génèrent la même chose si vous préférez ne pas l’écrire vous-même. Quand le patch est envoyé en HTTP, utilisez le content type application/json-patch+json ; pour l’alternative « overlay » plus simple où null veut dire supprimer, voyez JSON Patch vs JSON Merge Patch.
Cas limites bons à connaître
- Réordonnancement des éléments de tableau —— le diff sémantique compare les tableaux par position (
[0]vs[0]), donc si un tableau est trié différemment entre versions, chaque élément peut apparaître « modifié » même si les données sont identiques. Bien gérer ça demande une LCS au niveau tableau, ce qui ajoute pas mal de complexité. - Très gros documents —— la table LCS O(m × n) peut épuiser la mémoire sur des documents de dizaines de milliers de lignes. Heuristique pratique : si m × n dépasse un seuil (disons 2 millions), repliez-vous sur un affichage de toutes les lignes comme modifiées au lieu de calculer le diff complet.
- Précision numérique ——
JSON.parse()convertit tous les nombres en doubles IEEE 754. Les très grands entiers (au-delà de 2⁵³) perdent leur précision silencieusement, donc deux documents qui ne diffèrent que sur les derniers chiffres d’un grand entier peuvent être comparés comme égaux après parsing.
Questions fréquentes
Comment comparer deux fichiers JSON ?
Parsez les deux, normalisez-les (clés triées, indentation cohérente), puis lancez un diff structurel plutôt qu’un diff de texte brut —— ou collez les deux dans JSON Diff, qui fait ça et affiche une vue côte à côte avec un code couleur.
Pourquoi un diff de texte brut ne marche pas pour JSON ?
Parce que les objets JSON sont non ordonnés et les espaces sont insignifiants. Un diff de texte signale les clés réordonnées et le reformatage comme des changements ; un diff conscient de JSON ignore les deux et ne signale que les vraies différences de données.
Qu’est-ce qu’un diff JSON sémantique ?
Un diff qui parcourt les deux structures parsées et compare les valeurs au même chemin de clé indépendamment de l’ordre, produisant des compteurs du type « 3 ajoutés, 1 retiré, 2 modifiés ». Par construction, agnostique à l’ordre des clés.
Comparer du JSON peut-il perdre de la précision numérique ?
Oui —— JSON.parse() convertit les nombres en doubles IEEE 754, donc les entiers au-delà de 2⁵³ peuvent être comparés comme égaux même quand leurs derniers chiffres diffèrent. Les règles de canonicalisation de RFC 8785 traitent des problèmes liés.
Essayez l’outil JSON Diff
JSON Diff sur fixjson.org implémente tout ce qui précède : il parse les deux documents, normalise avec des clés triées, lance le diff de lignes LCS et affiche une vue côte à côte avec des additions, suppressions et modifications color-codées —— plus une ligne de résumé indiquant le nombre de champs ajoutés, retirés, modifiés et inchangés. Il prend aussi en charge les documents YAML. Tout tourne dans votre navigateur ; aucune donnée n’est envoyée à un serveur.
- JSON Diff —— comparez deux documents JSON ou YAML côte à côte
- JSON Patch vs JSON Merge Patch —— représentez les différences entre deux documents sous forme de mise à jour
- RFC 8785 : JSON Canonicalization (JCS) —— comment le tri des clés et la normalisation des nombres produisent un hash stable de tout document JSON
- JSON Fix —— réparez du JSON invalide avant de faire le diff
- Gérer du JSON cassé en JavaScript —— que faire quand l’un de vos documents ne parse pas