Let's make a json formatting tool together

Speaking of jsonformatting, you must be familiar with it. After all, the compressed jsondata is basically unreadable. In order to facilitate viewing, we can format it with one click in the editor through a plug-in, or use some online tools to beautify it. Of course, sometimes in the development There will also be jsonformatting requirements in . There are many open source libraries or components that can solve this problem for us, but it does not prevent us from implementing one ourselves.

The easiest way should be to use JSON.stringify()the method, you can control the number of indented spaces through its third parameter:

JSON.stringify(json, null, 4)

However, it can only help you indent a little bit. If you want more, you won’t be able to rely on it. Next, we will implement a relatively complete jsonformatting tool.

create a class

Our class only accepts one parameter for the time being, which is the container node:

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
    }
}

typeThe method is used to get the type of a data, which will be used later.

Then add another method as a formatting method:

class JsonTreeView {
    stringify(data) {}
}

We will write the specific logic later, and now we can newhave an object. When it comes to objects, do you have all the friends here?

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

Effect:

Apparently nothing works at the moment ( ^▽^ ).

indentation

The first and most important function is indentation. Let’s take a look at the final indentation effect we want to achieve:

Our implementation principle is to jsonconvert data into htmlstrings, newlines can pass through block-level elements, and indentation can pass through margin. So the problem is transformed into how to convert jsondata into htmlstrings. The principle is actually the same as we do deep copy, traversing jsonobjects in depth, and htmlwrapping each attribute and value with tags.

First write the basic framework:

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

Next, let's look at the processing of the three branches in turn.

object

The object we want to convert to the following structure:

可以看到主要是三个部分,开始的括号,中间的属性和值,结束的括号。开始和结束的括号可以用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…

有缘再会~

Guess you like

Origin juejin.im/post/7248137735311310909