
上周帮后端同事排查一个接口报错他把报错贴过来的时候我人都看愣了failed to deserialize the json body into the target type: input: missing field。这种问题有个特点报错本身等于没说真正的病根全藏在请求体的JSON结构里。他习惯性想去找在线格式化网站翻了半天书签公司内网环境下外网工具一个都打不开。最后我直接盘了一个Vue3的小工具页面把那段JSON贴进去格式化完一眼就看出是userProfile里缺了nickname字段问题当场解决。这次之后我就认真把这件事做了全套一个完整可用的JSON格式化工具以模块化组件的形式嵌进了后台管理系统。今天这篇文章不聊什么高深架构就把这个工具在Vue3项目里的完整实现拆开讲清楚格式化引擎怎么写、语法高亮怎么做、错误提示怎么做到位、大JSON卡顿怎么优化以及在真实项目里落地会遇到哪些文档里不会写的坑。无论你是在做后台管理系统、调试工具集还是单纯想把JSON处理能力沉淀到团队的工具链里这篇都应该对你有用。1. 为什么要把格式化能力做进自己的Vue3项目1.1 在线工具的三大痛点先说动机没有动机后面全是空谈。很多人觉得用在线工具不就行了何必自己造轮子但这个想法在你写下第一行代码之前就已经被实际场景打脸了。第一个痛点就是安全合规。公司内网系统里的接口返回数据往往带着真实用户信息、内部订单号、业务敏感字段。你把这段JSON复制到第三方网站上数据就经过了别人的服务器这在绝大多数公司的安全审计里都是过不了的。越是正规团队对数据出内网管得越严我在第一家公司就被运维提醒过浏览器访问外网格式化网站会被网关记录。第二个痛点是效率。在线工具从打开浏览器、找到书签、等待页面加载、粘贴、点击格式化到格式化完成一套流程下来快则十秒慢则半分钟。一天排查十几个接口光这个动作就要吃掉十几分钟。而本地工具是常驻的粘贴、格式化、复制三秒钟内完成。第三个痛点是定制能力。在线工具功能是死的你没法按自己的使用习惯加键名排序压缩对比行号定位这些功能。自己做的工具想加什么加什么。1.2 工具的边界该做哪些不该做哪些动手之前先想清楚范围。很多人的第一个版本容易失控一上来就想做成一站式JSON工作台结果做了三个月还在改bug。我的建议是首版只做四件事格式化、压缩、语法高亮、错误定位。这四件事能覆盖90%的日常需求。不做哪些不做JSON树形折叠编辑——那是复杂得多的工作牵扯到响应式重建、节点操作、撤销重做工作量直接翻几倍。不做JSON和XML互转、不做JSONPath查询这些是独立的垂直功能后续可以在稳定版基础上慢慢加。首版把格式化主链路做稳比什么都强。另外我建议把工具做成一个独立的Vue3组件放在src/components/JsonFormatter/目录下这样以后不管是扔进管理系统的某个页面、还是单独挂一个/tools/json路由都很方便。2. 格式化引擎与组件分离先把核心逻辑从页面里抽出来2.1 纯函数引擎的代码结构很多人的第一版是直接在Vue组件里写一个handleFormat方法里面十行代码把JSON.parse和JSON.stringify串起来看起来没问题但一旦加上错误定位、键名排序、压缩模式这些需求组件会迅速膨胀成几百行的大杂烩。正确的做法是把格式化逻辑抽成一个纯函数模块组件只负责拿到输入和展示结果。这里说的纯函数指的是不依赖Vue的任何API、只做输入输出转换的函数。这样做的收益有三个可以单独写单元测试可以在Web Worker里复用以后换到React或者做成CLI工具也能直接搬。引擎模块我放在src/utils/jsonFormatter.ts核心代码如下。// src/utils/jsonFormatter.ts export interface JsonFormatResult { ok: boolean text: string errorMsg: string errorLine: number errorColumn: number errorPosition: number } export function formatJson( input: string, indent: number | string 2, options: { sortKeys?: boolean } {} ): JsonFormatResult { try { let value JSON.parse(input) if (options.sortKeys) { value deepSort(value) } return { ok: true, text: JSON.stringify(value, null, indent), errorMsg: , errorLine: 0, errorColumn: 0, errorPosition: -1 } } catch (err) { const error err as SyntaxError const position extractErrorPosition(error.message) const { line, column } lineColumnAtPosition(input, position) return { ok: false, text: , errorMsg: error.message, errorLine: line, errorColumn: column, errorPosition: position } } } export function compressJson(input: string): JsonFormatResult { try { JSON.parse(input) return { ok: true, text: JSON.stringify(JSON.parse(input)), errorMsg: , errorLine: 0, errorColumn: 0, errorPosition: -1 } } catch (err) { const error err as SyntaxError const position extractErrorPosition(error.message) const { line, column } lineColumnAtPosition(input, position) return { ok: false, text: , errorMsg: error.message, errorLine: line, errorColumn: column, errorPosition: position } } }这里有个细节值得说明JSON.stringify(value, null, indent)的第三个参数indent除了传数字表示几个空格还能直接传字符串。比如传\t就可以输出Tab缩进。所以组件里的缩进方式下拉框传入数字或Tab字符都行引擎不用做特殊处理。extractErrorPosition和lineColumnAtPosition这两个辅助函数作用是把浏览器报错信息里的position 42这种下标转成我们人能看懂的第几行第几列这个后面讲错误处理的时候详细展开。另一个容易出坑的地方是deepSort。键名排序不是简单地对最外层做Object.keys(obj).sort()必须递归处理嵌套对象和数组。我见过有人忽略了数组内的对象字段结果外层排好了内层还是乱序调试半天。function deepSort(value: unknown): unknown { if (Array.isArray(value)) { return value.map((item) deepSort(item)) } if (value ! null typeof value object) { const sorted: Recordstring, unknown {} const keys Object.keys(value as Recordstring, unknown).sort() for (const key of keys) { sorted[key] deepSort((value as Recordstring, unknown)[key]) } return sorted } return value }2.2 组件层用Composition API接住引擎组件层的代码同样要克制。我的做法是组件里只维护四类状态输入文本、输出文本、错误信息、若干配置项。所有派生状态用computed所有异步行为用watch加定时器管理。script setup langts import { ref, computed, watch, onUnmounted } from vue import { formatJson, compressJson } from /utils/jsonFormatter import { highlightJson } from /utils/jsonHighlighter const inputText ref() const outputText ref() const errorMessage ref() const sortKeys ref(false) const indentMode reftwo | four | tab(two) const highlightedResult computed(() { if (!outputText.value) return return highlightJson(outputText.value) }) function resolveIndent(): number | string { if (indentMode.value tab) return \t return indentMode.value two ? 2 : 4 } function runFormatter(mode: format | compress format): void { if (!inputText.value.trim()) { outputText.value errorMessage.value return } const result mode format ? formatJson(inputText.value, resolveIndent(), { sortKeys: sortKeys.value }) : compressJson(inputText.value) if (result.ok) { outputText.value result.text errorMessage.value } else { outputText.value errorMessage.value 第 ${result.errorLine} 行第 ${result.errorColumn} 列${result.errorMsg} } } /script用过Vue2的老开发应该能感觉到同样的逻辑如果用Options API写data、methods、computed、watch四个区域来回切而且watch里要访问this类型推导也费劲。Composition API最大的好处是让围绕一个能力的代码天然聚在一起格式化相关的状态和逻辑写在同一个函数作用域里这个工具的代码边界一下就清晰了。2.3 为什么不在computed里做防抖有个问题我见过不少人踩想要边输入边格式化的效果于是在computed里写formatJson(inputText.value)然后外面套防抖。这是行不通的因为computed是同步求值的它没办法被延迟。你在computed的getter里setTimeout返回的永远是上一次的值完全失去了响应式意义。正确的姿势是watch加定时器let formatTimer: ReturnTypetypeof setTimeout | null null watch(inputText, () { if (formatTimer) clearTimeout(formatTimer) formatTimer setTimeout(() { runFormatter(format) }, 400) }) onUnmounted(() { if (formatTimer) clearTimeout(formatTimer) })watch是对变化这个动作做响应它天然适合接防抖用户停了下来才触发真正的格式化。防抖间隔我实测400毫秒比较合适低于200毫秒会出现输入过程明显的闪跳高于800毫秒又会觉得卡卡的。当然这个值也取决于格式化引擎的耗时如果接入了大JSON场景的Worker方案防抖间隔可以放宽到300毫秒甚至更短因为格式化本身已经不占用主线程了。3. 格式化与语法高亮从JSON.parse到带颜色的输出3.1 JSON.parse校验加stringify格式化够用吗先回答一个很多人问过的问题格式化算法要不要自己手写解析器答案是首版完全不需要。JSON.parse本身就是一个经过万级测试的严格解析器用它做校验再用JSON.stringify(value, null, indent)做格式化输出这是最稳妥的组合。自己手写解析器要去处理字符串转义、Unicode代理对、数字边界工作量巨大且极易出bug除非你的需求是支持JSON5、注释、尾逗号这类非标准语法否则别碰。JSON.stringify的space参数之前的null位置也很讲究。如果写成JSON.stringify(value)得到的是压缩后的单行文本。这个行为被我们用来实现压缩功能先parse校验再stringify无缩进输出。所以压缩和格式化在引擎里只是space参数不一样其余逻辑完全复用。3.2 tokenize把格式化文本切成有语义的碎片格式化只是把JSON变得有缩进和换行真正让工具好用的是语法高亮。高亮的难点在于不能简单地把整个输出文本包一层颜色而要把文本拆成键名字符串数字布尔值null标点空白这些有语义的token再分别上色。我推荐的正则tokenize方案是这样的。先把格式化后的文本按语义切分核心是两条正则的顺序不能反键名正则/([^\\]|\\.)*(?\s*:)/一定要放到字符串正则前面因为键名本质上也是字符串只有靠后面的(?\s*:)前瞻判断这个引号后面跟的是冒号才能区分它是键名还是字符串值。顺序一旦搞反所有键名会被当成字符串高亮整个JSON的颜色就全乱套了。// src/utils/jsonHighlighter.ts export type TokenType key | string | number | boolean | null | punct | space export interface Token { type: TokenType value: string } export function tokenizeJson(text: string): Token[] { const tokens: Token[] [] const rules: { type: TokenType; pattern: RegExp }[] [ { type: key, pattern: /([^\\]|\\.)*(?\s*:)/y }, { type: string, pattern: /([^\\]|\\.)*/y }, { type: number, pattern: /-?\d(?:\.\d)?(?:[eE][-]?\d)?/y }, { type: boolean, pattern: /true|false/y }, { type: null, pattern: /null/y }, { type: punct, pattern: /[{}[\],:]/y }, { type: space, pattern: /\s/y } ] let pos 0 while (pos text.length) { let matched false for (const { type, pattern } of rules) { pattern.lastIndex pos const match pattern.exec(text) if (match match.index pos) { tokens.push({ type, value: match[0] }) pos match[0].length matched true break } } if (!matched) { tokens.push({ type: string, value: text[pos] }) pos 1 } } return tokens }这段代码用了正则的sticky标志y它和g标志的区别是g允许从lastIndex往后任意位置查找而y强制从lastIndex精确位置匹配。在这种必须逐字符顺序扫描的场景里y标志写起来要干净得多不需要每次判断match.index pos之外再处理跳过的情况。现代浏览器和Node.js对y标志的支持已经很完善可以放心用。生成高亮HTML的时候还有一个必须处理的点把所有原始文本做HTML转义。JSON里的字符串值完全可能是script、img onerror...这种内容如果不过转义直接拼进v-html就是妥妥的XSS注入点。function escapeHtml(str: string): string { return str .replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;) .replace(//g, quot;) .replace(//g, #39;) } export function highlightJson(text: string): string { return tokenizeJson(text) .map((token) { const typeClass: RecordTokenType, string { key: json-key, string: json-string, number: json-number, boolean: json-boolean, null: json-null, punct: json-punct, space: } const cls typeClass[token.type] if (!cls) return escapeHtml(token.value) return span class${cls}${escapeHtml(token.value)}/span }) .join() }3.3 v-html渲染的XSS坑上面这段代码把转义放在了最内层这个顺序非常重要。正确的思路是我们动态拼接的span标签是可信的标签内部包裹的内容必须全部经过escapeHtml。也就是说先转义用户内容再包上我们自己的标签顺序不能变。有人图省事直接对整段JSON文本先做一次JSON.stringify再替换这样确实能去掉危险字符但连引号引号都变成\了展示出来完全不是JSON的样子。还有人用DOMPurify做一遍消毒这当然也能用但性能开销不值当——我们自己就是产出方把好转义这一道关就够了。样式方面我用的配色方案是经典的明亮主题键名蓝色、字符串绿色、数字橙色、布尔值和null用紫色、标点灰色。如果你做的工具要支持暗色模式建议用CSS变量而不是硬编码颜色切换主题时只需要改一组变量。4. 错误输入的体验从浏览器堆栈到人话提示4.1 常见非法JSON形态清单格式化工具最难的一环其实不是格式化本身而是告诉用户你为什么错了。同样的错误浏览器返回的Unexpected token } in JSON at position 42一般用户根本看不懂position 42在哪。我整理了日常使用中最高频的几类非法JSON每类都有对应的提示场景。输入形态浏览器报错用户实际意图{a: 1,}Unexpected token}从对象字面量复制过来带了尾逗号{a: 1}Unexpected token单引号JSON不是标准JSON{a: undefined}Unexpected tokenuconsole复制对象值没序列化{a: 1} // 注释Unexpected token/误以为JSON支持注释空字符串无错误但无意义想格式化但没有输入文件内容是XML或纯文本Unexpected token复制错了内容源这些形态的错误提示如果只是原样抛出浏览器信息用户根本不知道该怎么改。所以错误处理这块的核心工作就是把position转成行列号再给出针对性的提示文案。4.2 行列号计算的细节从浏览器报错信息里提取position再用position反推行列号这个逻辑很直接但有一个性能细节要提一下。function extractErrorPosition(message: string): number { // 统一处理各浏览器的报错格式 const match message.match(/position\s(\d)/i) if (match) return parseInt(match[1], 10) return -1 } function lineColumnAtPosition(text: string, position: number): { line: number; column: number } { if (position 0) { return { line: 1, column: 1 } } let line 1 let column 1 for (let i 0; i position i text.length; i) { if (text[i] \n) { line 1 column 1 } else { column 1 } } return { line, column } }这里我故意没用text.slice(0, position).split(\n)的写法因为slice会拷贝一次字符串、split还会生成一个数组对于一两千行的JSON来说浪费内存。直接遍历到position的位置时间和内存都省。虽然单次看不出来但配合防抖后的高频调用积少成多。提取position的正则也要兼容不同浏览器。Chrome的格式是in JSON at position 42Firefox是at line 1 column 5Safari则直接没有位置信息只给描述文字。所以extractErrorPosition匹配不到时返回-1lineColumnAtPosition拿到-1就返回第一行第一列同时把原来的原始错误信息保留在errorMsg里作为兜底。4.3 实时格式化与防抖策略有了可靠的错误定位实时格式化才有意义。用户粘贴完JSON的瞬间就能看到红色错误提示比自己手动点格式化按钮再等结果要高效得多。但这里有个体验细节并不是所有输入阶段都适合实时格式化。用户在粘贴多行JSON的过程中会有无数个瞬间处于非法状态——粘贴到一半、括号还没闭合都会触发错误提示。如果每敲一个字符就格式化一次提示会疯狂闪跳非常烦躁。所以我的实现里分了两条路用户手动点击格式化按钮立即执行无视防抖。输入变化触发的自动格式化400毫秒防抖且只在输入文本非空时执行。这样既保证了主动操作的即时性又避免了被动触发时的抖动。watch里清掉上一个定时器的写法前面讲过注意组件卸载时也要清理否则定时器会继续触发已经卸载的组件状态更新。5. 大JSON卡顿优化从防抖到Web Worker5.1 先定位卡顿点做性能优化之前先说清楚JSON格式化工具卡顿到底卡在哪。很多人第一反应是格式化太慢但实测下来一两兆的JSON文本JSON.parse加JSON.stringify的耗时通常也就是几十到一两百毫秒没那么夸张。真正卡的是后面的渲染高亮tokenize加生成大量HTML字符串再让浏览器重绘一个巨大的pre节点这个阶段才是大户。所以优化的优先级是先看渲染再看解析。我的经验阈值是这样100KB以内的JSON怎么写都不会卡直接走同步逻辑就好100KB到500KB之间高亮渲染开始变慢需要做限流和渲染节流超过500KB解析本身开始走高就要考虑Web Worker了。这里的数值不是拍脑袋是我用一段10万行、约1.5MB的真实配置JSON测出来的。5.2 Worker方案落地Web Worker的核心价值是把JSON.parse和JSON.stringify这些CPU密集操作从主线程挪出去。注意我这里说的是解析和格式化不包括高亮和渲染——因为Worker只能返回字符串生成HTML字符串的部分如果也扔进去返回值会很大postMessage传递的成本反而可能高于直接在主线程做。实测下来高亮部分的耗时占比并不高留在主线程完全可以接受。用Vite创建项目的话Worker的使用有个非常方便的语法?worker后缀。在Vite 5以上的版本里直接import JsonFormatWorker from /workers/jsonFormat.worker?worker开发环境它会自动打包生产环境也会按Worker单独产出资源。// src/workers/jsonFormat.worker.ts import { formatJson } from /utils/jsonFormatter self.onmessage (e: MessageEvent) { const { input, indent, sortKeys } e.data const result formatJson(input, indent, { sortKeys }) self.postMessage(result) }组件侧接Worker时有一个特别容易踩的坑连续多次触发格式化时上一次Worker的返回结果可能晚于下一次的结果导致旧结果覆盖新结果。解决方法是用一个自增序列号每次发出新任务时seqWorker返回时校验序列号是否还是最新不是就丢弃。let formatWorker: Worker | null null let workerSeq 0 function formatInWorker(input: string): void { if (!formatWorker) { formatWorker new JsonFormatWorker() } const seq workerSeq formatWorker.onmessage (e: MessageEvent) { if (seq ! workerSeq) return const result e.data as JsonFormatResult applyResult(result) } formatWorker.postMessage({ input, indent: resolveIndent(), sortKeys: sortKeys.value }) }这个过期响应丢弃的套路不只是Worker适用将来你接流式接口、做搜索联想下拉凡是请求A晚于请求B发出但先于其返回的场景都用得上。5.3 渲染侧兜底截断与折叠Worker解决了解析但渲染侧还得有兜底方案。我的做法是给高亮输出加上超长截断策略当输出文本的行数超过5000行时只渲染前5000行后面用一个提示条提示内容过长已截断显示格式化结果不受影响。为什么是5000行因为实测一个pre节点挂大约5000行带内联标签的HTMLChrome和Edge的渲染还能保持在流畅范围再往上就会出现明显的滚动卡顿。截断的逻辑要放在tokenize之后的渲染阶段不要只截前半部分字符串——那会把最后一个token切断导致颜色错乱。正确做法是tokenize完按token的行信息累计超过阈值就停止组装HTML。另外v-html渲染出来的pre如果还要做点击高亮某一行之类的交互裁剪后行号会错位我的建议是截断模式下不做行级交互只保证能看。6. 集成进真实项目的几个实战细节6.1 组件还是独立路由页面很多人在做成组件还是做成页面上犹豫。我的建议很直接先做成组件再花五分钟包一层页面导出路由。组件保证了复用性页面保证了可达性。在Vue3里用一个简单的defineAsyncComponent按需加载这个页面组件还能避免把它打进主包影响首屏。// router/index.ts const JsonTool () import(/pages/JsonTool.vue) const routes [ { path: /tools/json, name: JsonTool, component: JsonTool } ]调试类工具天然适合异步路由因为用户的访问频率低完全可以等需要时再拉取。开发环境用Vite时这个文件的编译也不影响主项目的热更新速度。6.2 复制和下载功能的完整实现格式化完成之后复制结果比下载文件用得多而且多到不是同一数量级。复制要做的兼容处理主要是老版浏览器不支持navigator.clipboard得回退到document.execCommand(copy)方案。async function copyText(text: string): Promiseboolean { if (navigator.clipboard window.isSecureContext) { try { await navigator.clipboard.writeText(text) return true } catch { return legacyCopy(text) } } return legacyCopy(text) } function legacyCopy(text: string): boolean { const textarea document.createElement(textarea) textarea.value text textarea.style.position fixed textarea.style.opacity 0 document.body.appendChild(textarea) textarea.select() const ok document.execCommand(copy) document.body.removeChild(textarea) return ok }这里有个特别隐蔽的坑在非本地环境非localhost下navigator.clipboard即使存在也可能因为权限策略返回undefined所以判断条件必须是navigator.clipboard window.isSecureContext只判断前者是不够的。下载文件就简单很多Blob加URL.createObjectURL就行唯一要注意的是用完后revokeObjectURL释放资源否则频繁下载会把浏览器的对象URL池占满。function downloadJson(text: string, filename formatted.json): void { const blob new Blob([text], { type: application/json;charsetutf-8 }) const url URL.createObjectURL(blob) const a document.createElement(a) a.href url a.download filename a.click() URL.revokeObjectURL(url) }6.3 键名排序与大小写细节排序功能做之前我一直觉得它是鸡肋直到有次对比两个不同团队返回的JSON结构一个用字段顺序排列、一个乱序输出肉眼对比差点看瞎。加了sortKeys排序后两段JSON一格式化、一排序结构差异一目了然。所以排序功能强烈建议首版就做进去它几乎零成本收益却不小。还有一个跨端协作的场景值得注意。Java后端同事的Bean字段命名、Python后端返回的snake_case字段、前端自己拼的camelCase字段在同一个项目里混着出现太常见了。有次一个Java同事问我他Bean里首字母大写的字段接口返回的JSON里怎么变小写了——这是Jackson默认命名策略导致的序列化行为。当时的场景里他拿到的是格式化后的JSON一眼就看出键名和他想的对不上。这种键名大小写形态的问题虽然不是格式化工具能解决的但工具能通过清晰的键名高亮和排序帮你在第一时间发现它这就是个很实用的价值。6.4 后续还能扩展的方向工具上线用了一个月后我收到了不少真实需求按优先级排序值得做的扩展方向大致是这些。第一是JSON压缩率统计。在压缩按钮旁边显示原文本大小、压缩后大小、压缩率这个功能做起来十分钟但对运营同学来说很有价值他们经常要贴长JSON到配置系统里对长度有硬性限制。第二是结构对比功能。输入两段JSON输出字段差异明细这个功能做起来比想象中复杂需要先转成树形再diff建议放二期。第三是JSONPath测试输入$.data.items[*].id这样的路径实时标出匹配结果这个对接口联调帮助很大。三个方向里我最推荐先做压缩率统计因为代码量小、感知度高、几乎没有风险。我在实际落地中还有几个小提醒。一是组件尽量使用scoped样式高亮用的CSS变量建议定义在:root或主题配置里避免子组件样式互相污染。二是如果项目用了TypeScript给JsonFormatResult定义好完整的接口类型后面扩展字段时编译器能帮你兜底比到处写any省心得多。三是这个工具刚开始只在本地开发环境跑后来因为后端同学也总过来借我才把它挂到内网的静态资源服务上结果收到一批建议——所以做的时候务必当成一个长期维护的组件来设计别想着临时用用不然后面改起来全是债。