关于前端:一起来做一个json格式化工具吧

4次阅读

共计 12023 个字符,预计需要花费 31 分钟才能阅读完成。

说到 json 格式化你必定很相熟,毕竟压缩后的 json 数据根本不可读,为了不便查看,咱们能够在编辑器中能够通过插件一键格式化,也能够通过一些在线工具来丑化,当然,有时在开发中也会遇到 json 格式化的需要,有很多开源库或组件能咱们解决这个问题,不过并不障碍咱们本人实现一个。

最简略的形式应该就是应用 JSON.stringify() 办法了,能够通过它的第三个参数管制缩进的空格数:

JSON.stringify(json, null, 4)

不过它也只能帮你缩进一下,想要再多就没有了,靠它不如靠己,接下来咱们就来实现一个绝对欠缺的 json 格式化工具。

创立一个类

咱们的类临时只接管一个参数,那就是容器节点:

const type = obj => {return Object.prototype.toString.call(obj).slice(8, -1).toLowerCase()}

class JsonTreeView {constructor({ el}) {this.el = type(el) === 'string' ? document.querySelector(el) : el
    }
}

type办法用来获取一个数据的类型,前面还会用到。

而后再增加一个办法,作为格式化的办法:

class JsonTreeView {stringify(data) {}}

具体的逻辑咱们前面再写,当初就能够 new 一个对象了,说到对象,看到这里的敌人们你们都有了吗?

const jsonTreeView = new JsonTreeView({el: '#output'})
jsonTreeView.stringify({a: 1})

成果:

目前显然没有任何成果(^▽^)。

缩进

第一个也是最重要的性能就是缩进,先来看一下咱们最终要实现的缩进成果:

咱们的实现原理是将 json 数据转换成 html 字符串,换行能够通过块级元素,缩进能够通过 margin。所以问题就转换成了如何把json 数据转换成 html 字符串,原理其实就和咱们做深拷贝一样,深度遍历 json 对象,通过 html 标签包裹每个属性和值。

先把根本框架写一下:

const stringifyToHtml = data => {const dataType = type(data)
    const str = ''
    switch (dataType) {
        case 'object': // 对象
            // 递归
            break
        case 'array': // 数组
            // 递归
            break
        default: // 其余类型
            break
    }
    return str
}

接下来顺次看一下对三个分支的解决。

对象

对象咱们要转换成上面的构造:

能够看到次要是三个局部,开始的括号,两头的属性和值,完结的括号。开始和完结的括号能够用 div 来包裹,两头的整体局部也用一个 div 来包裹,并且给它设置 margin 来实现缩进,具体到每一行的属性和值,能够通过 div 包裹 span 标签。

const stringifyToHtml = data => {const dataType = type(data)
    let str = ''
    switch (dataType) {
        case 'object': // 对象
            const keys = Object.keys(data)
            // 开始的括号
            str += '<div>{</div>'
            // 两头整体
            str += '<div style="margin-left: 20px;">'
            // 两头的每一行
            keys.forEach((key, index) => {
                str += '<div>'
                str += `<span>${key}</span><span>:</span>`// 属性名和冒号
                str += stringifyToHtml(data[key])// 属性值
                str += '<span>,</span>'// 逗号不要忘了
                str += '</div>'
            })
            str += '</div>'
            // 完结的括号
            str += '<div>}</div>'
            break
        case 'array': // 数组
            break
        default: // 其余类型
            break
    }
    return str
}

成果如下:

因为咱们还没有解决数组和根本类型,所以值局部是缺失的。

能够看到有几个小问题,一是空对象的两个括号其实是不须要换行的,二是值是非空对象的开始括号应该和 key 显示在同一行,三是对象中的最初一个逗号是不须要的。

第一个问题能够判断一下是不是空对象,是的话就用 span 来包裹两个括号:

const keys = Object.keys(data)
const isEmpty = keys.length <= 0
// 开始的括号
str += isEmpty ? '<span>{</span>' : '<div>{</div>'
if (!isEmpty) {
    // 两头整体
    str += '<div style="margin-left: 20px;">'
    // 两头的每一行
    keys.forEach((key, index) => {
        str += '<div>'
        str += `<span>${key}</span><span>:</span>`
        str += stringifyToHtml(data[key])
        str += '<span>,</span>' // 逗号不要忘了
        str += '</div>'
    })
    str += '</div>'
}
// 完结的括号
str += isEmpty ? '<span>}</span>' : '<div>}</div>'

第二个问题须要晓得以后对象是否是作为一个 key 的值,是的话就用 span 来包裹括号,要实现这个须要给 stringifyToHtml 增加第二个参数:

const stringifyToHtml = (data, isAsKeyValue = false) => {switch (dataType) {
        case 'object':
            str += isEmpty || isAsKeyValue ? '<span>{</span>' : '<div>{</div>'
            keys.forEach((key, index) => {str += stringifyToHtml(data[key], true)
            }
    }
}

第三个问题能够判断一下以后遍历到的是否是最初一个属性,是的的话就不增加逗号:

keys.forEach((key, index) => {
    str += '<div>'
    str += `<span>${key}</span><span>:</span>`
    str += stringifyToHtml(data[key])
    if (index < keys.length - 1) {// ++
        str += '<span>,</span>'
    }
    str += '</div>'
})

数组

数组的解决和对象根本是统一的,开始和完结的括号,两头的数组每一项:

const stringifyToHtml = data => {const dataType = type(data)
  let str = ''
  let isEmpty = false
  switch (dataType) {
    case 'object': // 对象
        // ...
      break
    case 'array': // 数组
      isEmpty = data.length <= 0
      // 开始的括号
      str += isEmpty || isAsKeyValue ? '<span>[</span>' : '<div>[</div>'
      if (!isEmpty) {
        // 两头整体
        str += '<div style="margin-left: 20px;">'
        // 两头的每一行
        data.forEach((item, index) => {
          str += '<div>'
          str += stringifyToHtml(item)
          if (index < data.length - 1) {str += '<span>,</span>' // 逗号不要忘了}
          str += '</div>'
        })
        str += '</div>'
      }
      // 完结的括号
      str += isEmpty ? '<span>]</span>' : '<div>]</div>'
      break
    default: // 其余类型
      break
  }
  return str
}

和对象的解决基本一致,包含对空数组和最初一个逗号的解决,只不过数组的每一项没有属性名。

能够看到又有一个小问题,数组或对象中某个数组或对象后的逗号应该紧跟完结括号才对,然而因为咱们的完结括号是用 div 包裹的,所以就产生换行了,要想放在一行,那么只能把逗号也放在括号的 div 里:

case 'object': // 对象
    str += isEmpty ? '<span>}</span>' : '<div>}<span>,</span></div>'
case 'array': // 数组
    str += isEmpty ? '<span>]</span>' : '<div>]<span>,</span></div>'

这样又会有两个新问题:

一个是逗号的多余问题,一个是逗号反复的问题。

解决逗号多余的问题须要给 stringifyToHtml 办法再加一个参数,代表以后解决的数据是否是所在对象或数组中的最初一项,是的话就不显示逗号:

const stringifyToHtml = (data, isAsKeyValue = false, isLast = true) => {switch (dataType) {
        case 'object': // 对象
            keys.forEach((key, index) => {str += stringifyToHtml(data[key], true, index >= keys.length - 1)
            }
            str += isEmpty
                ? '<span>}</span>'
                : `<div>}${isLast ? '':'<span>,</span>'}</div>`
           case 'array': // 数组
            data.forEach((item, index) => {str += stringifyToHtml(item, index >= data.length - 1)
            }
            str += isEmpty
                ? '<span>]</span>'
                : `<div>]${isLast ? '':'<span>,</span>'}</div>`
    }
}

解决逗号反复的问题须要判断值是否是非空对象或数组,是的话就不显示逗号:

const stringifyToHtml = (data, isLast = true) => {switch (dataType) {
        case 'object': // 对象
            keys.forEach((key, index) => {if (index < keys.length - 1 && !isNoEmptyObjectOrArray(data[key])) {str += '<span>,</span>'}
            }
        case 'array': // 数组
            data.forEach((item, index) => {if (index < data.length - 1 && !isNoEmptyObjectOrArray(item)) {str += '<span>,</span>'}
            }
    }
}

const isNoEmptyObjectOrArray = data => {const dataType = type(data)
    switch (dataType) {
        case 'object':
            return Object.keys(data).length > 0
        case 'array':
            return data.length > 0
        default:
            return false
    }
}

根本类型

其余类型咱们只思考数字、字符串、布尔值、null,字符串须要用双引号包裹,其余不必:

const stringifyToHtml = (data, isAsKeyValue = false, isLast = true) => {switch (dataType) {
        default: // 其余类型
            let isString = dataType === 'string'
            str += `<span>${isString ? '"':''}${data}${isString ? '"':''}</span>`
            break
    }
}

最初,因为咱们显示的是 json 数据,所以严格一点来说 key 也是要加双引号的:

case 'object': // 对象
    str += `<span>"${key}":</span>`

到这里缩进就曾经全副实现了,看一下成果:

高亮

紧接着让咱们来实现高亮的成果,没有高亮还是比拟丑的,高亮很简略,因为上一步咱们曾经用 html 标签包裹了 json 数据的各个局部,咱们只有给它们加上类名,而后写上 css 款式即可。

标签大略分为:大括号、中括号、逗号、冒号、对象和数组的整体、对象或数组的每一项、对象的key、根本类型的各种类型。比方对象局部:

str += isEmpty || isAsKeyValue ? '<span class="brace">{</span>' : '<div class="brace">{</div>'
if (!isEmpty) {
    str += '<div class="object">'
    keys.forEach((key, index) => {
        str += '<div class="row">'
        str += `<span class="key">"${key}"</span><span class="colon">:</span>`
        str += stringifyToHtml(data[key], true, index >= keys.length - 1)
        if (index < keys.length - 1 && !isNoEmptyObjectOrArray(data[key])) {str += '<span class="comma">,</span>'}
        str += '</div>'
    })
    str += '</div>'
}
str += isEmpty
    ? '<span class="brace">}</span>'
: `<div class="brace">}${isLast ? '':'<span class="comma">,</span>'}</div>`

后面写死在标签里的 margin 款式也能够提取到类的款式里,这样咱们略微针对不同的类名写点色彩就能够失去如下成果:

咱们能够把款式放在独自的 css 文件里,作为一个主题,这样能够提供多个主题,使用者也能够本人定义主题。

开展收起

接下来也是一个重要的性能,就是对象或数组的开展收起性能,这对于数据很多的状况来说是十分重要的,能够折叠起来临时不关怀的局部。

要能折叠,必定得有个折叠按钮,按钮个别有两种地位,一是紧挨着对象或数组的括号后面,二是对立在每一行的最后面:

小孩子才做抉择,咱们全都要,先来实现第一种。

按钮紧贴括号

首先须要在括号前加一下按钮:

const stringifyToHtml = (data, isAsKeyValue = false, isLast = true) => {
    const expandBtnStr = `<span class="expandBtn expand"></span>`
    switch (dataType) {
        case 'object': 
            str +=
                isEmpty || isAsKeyValue
                ? `<span class="brace">${isEmpty ? '' : expandBtnStr}{</span>`
            : `<div class="brace">${expandBtnStr}{</div>`
        case 'array': // 数组
            str +=
                isEmpty || isAsKeyValue
                ? `<span class="bracket">${isEmpty ? '' : expandBtnStr}[</span>`
            : `<div class="bracket">${expandBtnStr}[</div>`}
}

非空的数组或对象前都要加上按钮,并且默认是开展状态,为了不便批改按钮的款式,咱们通过 css 来定义按钮的款式,这样你能够用背景图片,也能够用字体图标,也能够用伪元素,咱们默认应用伪元素:

.expand::after,
.unExpand::after {
  cursor: pointer;
  display: inline-block;
  width: 14px;
  height: 14px;
  line-height: 14px;
  border: 1px solid #4a5560;
  border-radius: 50%;
  text-align: center;
  margin-right: 2px;
}

.expand::after {content: '-';}

.unExpand::after {content: '+';}

接下来就是实现点击的开展收起成果,点击事件咱们能够通过事件代理的形式来监听容器元素的点击事件,开展收起其实就管制对象和数组整体元素的显示与否,并且收起的时候还要在括号中显示 ... 的成果。

每个按钮只管制它前面的整体,所以咱们要能晓得哪个按钮管制的是哪个元素,这个很简略,拼接 html 字符串的时候能够在按钮和整体元素的标签上增加一个雷同值的自定义属性,而后点击按钮的时候依据这个 id 找到对应的元素即可。省略号能够在整体元素前创立一个省略号元素,也是同样的切换它的显示与否,具体实现如下:

let uniqueId = 0
const stringifyToHtml = (data, isAsKeyValue = false, isLast = true) => {
    let id = uniqueId++
    const expandBtnStr = `<span class="expandBtn expand" data-id="${id}"></span>`
    switch (dataType) {
        case 'object':
            str += `<div class="object" data-fid="${id}">`
        case 'array':    
            str += `<div class="array" data-fid="${id}">`
    }
}
class JsonTreeView {constructor({ el}) {this.onClick = this.onClick.bind(this)
        this.el.addEventListener('click', this.onClick)
    }

    onClick(e) {
        let target = e.target
        // 如果点击的是开展收起按钮
        if (target.classList.contains('expandBtn')) {
            // 以后是否是开展状态
            let isExpand = target.classList.contains('expand')
            // 取出 id
            let id = target.getAttribute('data-id')
            // 找到对应的元素
            let el = document.querySelector(`div[data-fid="${id}"]`)
            // 省略号元素
            let ellipsisEl = document.querySelector(`div[data-eid="${id}"]`)
            if (!ellipsisEl) {
                // 如果不存在,则创立一个
                ellipsisEl = document.createElement('div')
                ellipsisEl.className = 'ellipsis'
                ellipsisEl.innerHTML = '···'
                ellipsisEl.setAttribute('data-eid', id)
                ellipsisEl.style.display = 'none'
                el.parentNode.insertBefore(ellipsisEl, el)
            }
            // 依据以后状态切换开展收起按钮的类名、切换整体元素和省略号元素的显示与否
            if (isExpand) {target.classList.remove('expand')
                target.classList.add('unExpand')
                el.style.display = 'none'
                ellipsisEl.style.display = 'block'
            } else {target.classList.remove('unExpand')
                target.classList.add('expand')
                el.style.display = 'block'
                ellipsisEl.style.display = 'none'
            }
        }
    }
}

成果:

按钮对立在左侧

要显示在最后面,那显然要应用相对定位,咱们能够给容器元素设置成绝对定位,并且设置一点 padding-left,不然按钮就和树重叠了,而后给按钮元素设置相对定位,并且设置它的left=0,不要设置top,因为咱们也不晓得top 是多少,不设置按钮反而会在原来的高度。

其余性能

实现了后面三个外围的性能,其实还有一些晋升体验的性能,能够用作可选性能提供。

竖线

竖线能够不便的看到一个对象或数组的开始到完结的地位,实现也很简略,首先把缩进的形式由 margin 改为 padding,而后给对象或数组的整体元素设置border-left 即可:

.object, .array {
  padding-left: 20px;
  border-left: 1px solid #d0d7de;
}

鼠标滑入高亮

鼠标滑入某一行只高亮某一行,滑入对象或数组的括号那么高亮整体,这个实现不能简略的应用 csshover伪类,因为元素是嵌套的:

如果咱们给 .row 元素设置 hover 款式,那么滑入对象或数组的中的某一行,实际效果是这个对象或数组都被高亮了,所以只能手动监听 mouseovermouseout事件来解决,具体实现就是在 mouseover 事件里获取以后鼠标滑入元素最近的一个类名为 .row 的先人元素,而后给它增加高亮的类名,为了能革除上一个被高亮的元素,咱们还要减少一个变量把它保存起来,每次先革除上一个元素的高亮类名,而后再给以后滑入元素增加高亮类名:

class JsonTreeView {constructor(){
        this.lastMouseoverEl = null
        this.onMouseover = this.onMouseover.bind(this)
        this.onMouseout = this.onMouseout.bind(this)
        this.wrap.addEventListener('mouseover', this.onMouseover)
        this.wrap.addEventListener('mouseout', this.onMouseout)
    }

    onMouseover(e) {this.clearLastHoverEl()
        let el = getFirstAncestorByClassName(e.target, 'row')
        this.lastMouseoverEl = el
        el.classList.add('hover')
    }

    onMouseout() {this.clearLastHoverEl()
    }

    clearLastHoverEl() {if (this.lastMouseoverEl) {this.lastMouseoverEl.classList.remove('hover')
        }
    }
}

// 获取指定类名的第一个先人节点
const getFirstAncestorByClassName = (el, className) => {
  // 向上找到容器元素就进行
  while (!el.classList.contains('simpleJsonTreeViewContainer')) {if (el.classList.contains(className)) {return el}
    el = el.parentNode
  }
  return null
}

行号

行号没啥好说的,能够不便看到一共有多少行。

首先咱们不思考在递归中计算一共有多少行,因为能够收起,收起来行号计算就比拟麻烦了,所以咱们间接获取 json 树区域元素的高度,而后再获取某一行的高度,最初得出行数:

class JsonTreeView {constructor(){
        this.oneRowHeight = -1
        this.lastRenderRows = 0
    }
    
    // 渲染行
    renderRows() {
    // 获取树区域元素的理论高度
    let rect = this.treeWrap.getBoundingClientRect()
    // 获取每一行的高度
    let oneRowHeight = this.getOneRowHeight()
    // 总行数
    let rowNum = rect.height / oneRowHeight
    // 如果新行数比上一次渲染的行数多,那么要创立短少的行数
    if (rowNum > this.lastRenderRows) {let fragment = document.createDocumentFragment()
      for (let i = 0; i < rowNum - this.lastRenderRows; i++) {let el = document.createElement('div')
        el.className = 'rowNum'
        el.textContent = this.lastRenderRows + i + 1
        fragment.appendChild(el)
      }
      this.rowWrap.appendChild(fragment)
    } else if (rowNum < this.lastRenderRows) {
      // 如果新行数比上一次渲染的行数少,那么要删除多余的行数
      for (let i = 0; i < this.lastRenderRows - rowNum; i++) {let lastChild = this.rowWrap.children[this.rowWrap.children.length - 1]
        this.rowWrap.removeChild(lastChild)
      }
    }
    this.lastRenderRows = rowNum
  }

  // 获取一行元素的高度
  getOneRowHeight() {if (this.oneRowHeight !== -1) return this.oneRowHeight
    let el = document.createElement('div')
    el.textContent = 1
    this.treeWrap.appendChild(el)
    let rect = el.getBoundingClientRect()
    this.treeWrap.removeChild(el)
    return (this.oneRowHeight = rect.height)
  }
}

而后咱们只有在 json 树渲染结束和开展收起之后调用 renderRows 办法更新行数即可:

谬误揭示

如果输出的是非法的 json,那么渲染会报错,为了更好的体验,咱们应该提醒用户,所以须要显示报错信息,能够用try.catch 捕捉一下 JSON.parse 办法的执行,如果解析出错,有时候会返回如下错误信息:

能够看到出错地位的字符串,然而有时候返回的又是如下不带谬误地位字符串的信息:

尽管有地位的数字,然而对于用户来说是十分不敌对的,总不能让用户本人去数对应地位是哪个字符,所以咱们除了显示这行信息,也得帮用户把对应地位的字符串也显示进去,具体来说就是截取出错地位前后一段字符串显示进去,帮忙用户更好的定位:

class JsonTreeView {constructor(){
        this.errorWrap = null // 错误信息容器
        this.hasError = false // 是否呈现了谬误
    }

    stringify(data) {
        try {if (typeof data === 'string') {data = JSON.parse(data)
            }
            // 如果上一次解析出错了,那么须要删除错误信息
            if (this.hasError) {
                this.hasError = false
                this.treeWrap.removeChild(this.errorWrap)
            }
            this.treeWrap.innerHTML = `<div class="row">${this.stringifyToHtml(data)}</div>`
            this.renderRows()} catch (error) {
            // 解析出错,显示错误信息
            let str = ``
            let msg = error.message
            str += `<div class="errorMsg">${msg}</div>`
            // 获取出错地位,截取出前后一段
            let res = msg.match(/position\s+(\d+)/)
            if (res && res[1]) {let position = Number(res[1])
                str += `<div class="errorStr">${data.slice(
                    position - 20,
                    position
                )}<span class="errorPosition">${data[position]}</span>${data.slice(
                    position + 1,
                    position + 20
                )}</div>`
            }
            this.hasError = true
            this.treeWrap.innerHTML = ''
            this.errorWrap.innerHTML = str
            this.treeWrap.appendChild(this.errorWrap)
        }
    }
}

编辑

原本打算再做个编辑的性能,然而思考了一下,发现比拟麻烦,因为还要辨别你编辑的值类型,如果所有值都是字符串类型那还好说,然而波及到类型转换就比拟麻烦了,比方本来是字符串数字,然而我想改成纯数字,这个就很难操作,更不用说增加和删除节点,所以如果有编辑的需要,那更好的抉择可能是用 CodeMirror 之类的编辑器。

总结

本文从头实现了一个简略的 json 格式化工具,如果有更好的实现欢送评论区见。

这个小工具也公布到了npm,要用的能够间接下载应用,详见仓库:https://github.com/wanglin2/json-tree-view。

在线预览地址:https://wanglin2.github.io/json-tree-view/。

有缘再会 \~

正文完
 0