I built a JSON diff tool in a single HTML file (no build step)
Source: Dev.to
Overview
I kept needing to compare JSON payloads side‑by‑side (API response vs expected, before vs after, prod vs staging). Each time I would paste into a random online tool, squint at it, and then repeat the process minutes later.
To solve this, I built fknjsn.com – a local‑first JSON comparison tool that lives in a single HTML file with no build step.
- Vue 3 via CDN (global build)
vue-json-prettyvia CDN for tree renderinglocalStoragefor persistence
That’s it – no webpack, Vite, node_modules, or package.json. Just one index.html you can even email to someone.
Setup
The entire app is bootstrapped with two script tags and a destructuring of Vue’s global export:
const { createApp, ref, computed, watch, onMounted, nextTick } = Vue;
// ... the rest is just Vue
Global builds aren’t fashionable, but they work. No bundler, no import maps – just script tags like it’s 2014, yet you still get a reactive framework.
Paste‑anywhere UX
The app listens for paste events globally, but ignores them when an input or textarea is focused. This lets you paste JSON anywhere on the page and have it land in the selected row.
window.addEventListener('paste', (e) => {
const tag = document.activeElement?.tagName?.toLowerCase()
if (tag === 'input' || tag === 'textarea') return
try {
const json = JSON.parse(e.clipboardData.getData('text'))
addJsonToSelectedRow(json)
} catch {
// not valid JSON, ignore
}
})
Recursive Search
Each JSON block has its own search input. The filter walks the tree and keeps parent nodes if any descendant matches. Conceptually the implementation looks like this:
function filterJson(obj, search) {
if (!search) return obj
const lower = search.toLowerCase()
if (Array.isArray(obj)) {
const filtered = obj
.map(item => filterJson(item, search))
.filter(item => item !== undefined)
return filtered.length ? filtered : undefined
}
if (obj && typeof obj === 'object') {
const result = {}
for (const [key, value] of Object.entries(obj)) {
if (key.toLowerCase().includes(lower)) {
result[key] = value
} else {
const filtered = filterJson(value, search)
if (filtered !== undefined) result[key] = filtered
}
}
return Object.keys(result).length ? result : undefined
}
// primitives
const str = String(obj).toLowerCase()
return str.includes(lower) ? obj : undefined
}
Debounced Persistence
State is saved to localStorage, but debounced at 500 ms to avoid hammering storage during rapid changes:
watch(rows, () => {
clearTimeout(saveTimeout)
saveTimeout = setTimeout(() => {
localStorage.setItem('json-rows', JSON.stringify(state))
}, 500)
}, { deep: true })
Scaling Ideas
If the tool needed to handle larger JSON payloads or more features, I would consider:
- Adding a Web Worker for filtering large payloads
- Using virtual scrolling for the tree view
- Implementing a true diff view instead of just side‑by‑side comparison
For now it does exactly what I need, and the whole thing fits comfortably in my head.
Links
- Live demo: https://fknjsn.com
- Source: view‑source of the page (the entire app is right there)
The name is pronounced exactly as you think it is.