一起来做一个json格式化工具吧

说到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,要用的可以直接下载使用,详见仓库:github.com/wanglin2/js…

在线预览地址:wanglin2.github.io/json-tree-v…

有缘再会~

猜你喜欢

转载自juejin.im/post/7248137735311310909