diff --git a/src/libs/json-tool.js b/src/libs/json-tool.js new file mode 100644 index 0000000..05d9c74 --- /dev/null +++ b/src/libs/json-tool.js @@ -0,0 +1,335 @@ +/** + * JSON 格式化工具核心库(零依赖) + * + * 设计要点: + * 1. 错误定位优先用 V8 新版报错自带的 (line X column Y), + * 旧版只给 position 时再按换行数换算——直接解析行列比换算更可靠 + * 2. 解析时保留大整数的原始字面量,避免 JSON.stringify 把 + * 超过 Number.MAX_SAFE_INTEGER 的整数转成科学计数法而丢精度 + */ + +// 超过这个体积就可能让页面卡住,先提示用户 +export const LARGE_TEXT_BYTES = 2 * 1024 * 1024 + +// 大整数占位符前缀 +const RAW_NUMBER_KEY = '@@__raw_num__' + +/** + * 保留大整数的原始字面量 + * + * JSON.parse 的第二个参数 reviver 在键已解析为数值后才被调用, + * 无法拿到原始文本。这里换一种思路:预先扫描出所有「键 -> 原始字面量」的映射, + * 序列化时用占位符替换,序列化完成后再把占位符换回原始字面量。 + * 只处理不带小数点、不带指数的纯整数(即 JSON 中表示「可能超大」的数字字面量)。 + */ + +/** + * 把超出安全范围的整数替换为字符串占位,避免解析时丢精度 + * + * 替换后的文本形如 {"id":"@@__raw_num__0"},序列化后 replace 掉引号即可还原。 + * 原始字面量记录在返回值的 literals 里,由调用方在序列化后使用。 + * + * @param {String} text + * @returns {{text:String, literals:Array, count:Number}} + */ +function protectBigIntegers (text) { + const literals = [] + let inString = false + let quoteChar = '' + let escaped = false + let out = '' + for (let i = 0; i < text.length; i++) { + const ch = text[i] + if (inString) { + if (escaped) escaped = false + else if (ch === '\\') escaped = true + else if (ch === quoteChar) inString = false + out += ch + continue + } + if (ch === '"' || ch === '\'') { + inString = true + quoteChar = ch + out += ch + continue + } + if (ch >= '0' && ch <= '9') { + let j = i + while (j < text.length && text[j] >= '0' && text[j] <= '9') j++ + const literal = text.slice(i, j) + // 16 位以内 JS 数字能精确表示,超过才需要保护 + if (literal.length > 15) { + literals.push(literal) + out += '"' + RAW_NUMBER_KEY + (literals.length - 1) + '"' + } else { + out += literal + } + i = j - 1 + continue + } + out += ch + } + return { text: out, literals: literals, count: literals.length } +} + +/** + * 把序列化结果里的 "@@__raw_num__N" 占位串替换回原始数字(去掉引号) + * @param {String} output + * @param {Array} literals 原始数字字面量数组 + * @returns {String} + */ +function restoreRawNumbers (output, literals) { + if (!literals || literals.length === 0) return output + return output.replace(/"@@__raw_num__(\d+)"/g, (match, idx) => { + const literal = literals[Number(idx)] + return literal === undefined ? match : literal + }) +} + +/** + * 把 position 换算成行列(position 从 0 起,行列从 1 起) + * @param {String} text + * @param {Number} position + * @returns {{line:Number, column:Number}} + */ +export function positionToLineColumn (text, position) { + const before = text.slice(0, Math.max(0, position)) + const lines = before.split('\n') + return { + line: lines.length, + column: lines[lines.length - 1].length + 1 + } +} + +/** + * 从 V8 报错信息中解析出位置 + * + * 新格式:'... in JSON at position 13 (line 3 column 3)' → 直接取行列 + * 旧格式:'... in JSON at position 13' → 回退按换行换算 + * 无位置:'Unexpected end of JSON input' → 取末尾 + * + * @param {String} text 出错的原始文本 + * @param {Error} error JSON.parse 抛出的异常 + * @returns {{line:Number, column:Number, reason:String, hasPosition:Boolean}} + */ +export function locateError (text, error) { + const message = (error && error.message) || String(error || '解析失败') + + // 优先取新版文案里的行列(最可靠) + const lineColMatch = message.match(/\(line\s+(\d+)\s+column\s+(\d+)\)/) + if (lineColMatch) { + return { + line: parseInt(lineColMatch[1], 10), + column: parseInt(lineColMatch[2], 10), + reason: stripPosition(message), + hasPosition: true + } + } + + // 回退:从 position 换算 + const posMatch = message.match(/position\s+(\d+)/) + if (posMatch) { + const pos = parseInt(posMatch[1], 10) + const lc = positionToLineColumn(text, pos) + return { + line: lc.line, + column: lc.column, + reason: stripPosition(message), + hasPosition: true + } + } + + // 连 position 都没有(如「Unexpected end of JSON input」),定位到末尾 + const lines = text.split('\n') + return { + line: lines.length, + column: lines[lines.length - 1].length + 1, + reason: message, + hasPosition: false + } +} + +/** + * 去掉报错文案里的 position/行列尾巴,得到干净的错误描述 + * @param {String} message + * @returns {String} + */ +function stripPosition (message) { + return message + .replace(/\s*in JSON at position\s+\d+(\s*\(line\s+\d+\s+column\s+\d+\))?/, '') + .replace(/\s*\(line\s+\d+\s+column\s+\d+\)/, '') + .trim() +} + +/** + * 键名排序:递归对对象的键排序(数组保持原顺序) + * @param {*} value + * @returns {*} 排序后的新对象 + */ +export function sortKeysDeep (value) { + if (Array.isArray(value)) { + return value.map(sortKeysDeep) + } + if (value && typeof value === 'object') { + const out = {} + Object.keys(value).sort().forEach(key => { + out[key] = sortKeysDeep(value[key]) + }) + return out + } + return value +} + +/** + * 统计对象结构信息 + * @param {*} value 已解析的对象 + * @returns {{keys:Number, values:Number, maxDepth:Number, arrays:Number}} + */ +export function analyze (value) { + const stat = { keys: 0, values: 0, maxDepth: 0, arrays: 0 } + const walk = (node, depth) => { + if (depth > stat.maxDepth) stat.maxDepth = depth + if (Array.isArray(node)) { + stat.arrays++ + node.forEach(item => { + stat.values++ + walk(item, depth + 1) + }) + return + } + if (node && typeof node === 'object') { + Object.keys(node).forEach(key => { + stat.keys++ + stat.values++ + walk(node[key], depth + 1) + }) + } + } + walk(value, 0) + return stat +} + +/** + * 序列化(可选排序键名) + * @param {Object} data 已解析的对象 + * @param {Number} indent 缩进空格数,0 表示压缩 + * @param {Boolean} sortKeys 是否按键名排序 + * @returns {String} + */ +function serialize (data, indent, sortKeys) { + const target = sortKeys ? sortKeysDeep(data) : data + return JSON.stringify(target, null, indent) +} + +/** + * 格式化 JSON + * @param {String} text 原始 JSON 文本 + * @param {Object} options + * @param {Number} options.indent 缩进空格数,默认 2 + * @param {Boolean} options.sortKeys 是否按键名排序,默认 false + * @returns {{ok:Boolean, output:String, error:Object, stat:Object}} + */ +export function formatJson (text, options) { + const opts = options || {} + const indent = opts.indent === undefined ? 2 : opts.indent + const sortKeys = !!opts.sortKeys + return process(text, indent, sortKeys) +} + +/** + * 压缩 JSON(去掉所有缩进与换行) + * @param {String} text 原始 JSON 文本 + * @returns {{ok:Boolean, output:String, error:Object, stat:Object}} + */ +export function minifyJson (text) { + return process(text, 0, false) +} + +/** + * 格式化的公共流程:保护大整数 → 解析 → 序列化 → 还原大整数 + * @param {String} text + * @param {Number} indent 0 表示压缩 + * @param {Boolean} sortKeys + * @returns {Object} + */ +function process (text, indent, sortKeys) { + const source = text == null ? '' : String(text) + if (source.trim() === '') { + return { + ok: false, + output: '', + error: { line: 1, column: 1, reason: '内容为空', hasPosition: false }, + stat: null + } + } + + const protectedText = protectBigIntegers(source) + let parsed + try { + parsed = JSON.parse(protectedText.text) + } catch (e) { + // 出错时定位要用「未保护」的原文,列号才对得上 + return { + ok: false, + output: '', + error: locateError(source, e), + stat: null + } + } + + let output + try { + output = serialize(parsed, indent, sortKeys) + } catch (e) { + return { + ok: false, + output: '', + error: { line: 1, column: 1, reason: '序列化失败:' + (e && e.message), hasPosition: false }, + stat: null + } + } + + // 把占位符还原成原始数字字面量(不带引号) + if (protectedText.count > 0) { + output = restoreRawNumbers(output, protectedText.literals) + } + + const stat = analyze(parsed) + return { + ok: true, + output: output, + error: null, + stat: stat, + bigIntCount: protectedText.count + } +} + + +/** + * 统计文本体积(字节) + * @param {String} text + * @returns {Number} + */ +export function byteSize (text) { + if (!text) return 0 + // 估算:中文按 3 字节计(UTF-8) + let bytes = 0 + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i) + if (code < 128) bytes += 1 + else if (code < 2048) bytes += 2 + else bytes += 3 + } + return bytes +} + +/** + * 人类可读的体积 + * @param {Number} bytes + * @returns {String} + */ +export function formatSize (bytes) { + if (bytes < 1024) return bytes + ' B' + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB' + return (bytes / 1024 / 1024).toFixed(2) + ' MB' +} \ No newline at end of file diff --git a/src/view/tools/json-format.vue b/src/view/tools/json-format.vue new file mode 100644 index 0000000..3c65691 --- /dev/null +++ b/src/view/tools/json-format.vue @@ -0,0 +1,523 @@ + + + + + \ No newline at end of file