Vue3 Json 可视化组件

1,259 阅读2分钟

一个 JSON 数据的展示组件,支持节点展开收起,数据类型使用不同的颜色区分。也看过 vue-json-view 这个组件库,觉得里面使用到的第三方js库挺多,而且不利于根据自己的业务定制化。 我的处理逻辑很简单,就收一个 JSON 格式的数据,然后在组件内部对这个数据进行格式化处理,然后将处理完的数据进行节点渲染即可。

image.png

源码部分

index.vue

<script lang="tsx">
import { Transition, defineComponent } from 'vue'
import { jsonViewerProps } from './props'
import { useController } from './useControl'

export default defineComponent({
  name: 'JsonViewer',
  components: {
    // 使用不同的名称
    Transition,
  },
  props: jsonViewerProps,
  setup(props: any) {
    const { _nodes, isCollapsed, toggleRoot, handleCopy } = useController(props)
    const paseKey = (key: string) => {
      const keys = key.split('.')
      return keys[keys.length - 1]
    }
    const _colors = ['#fa541c', '#fa8c16', '#faad14', '#fadb14', '#a0d911', '#722ed1', '#eb2f96']
    const valueFormat = (node: any) => {
      if (node.value === null) {
        return <span class="jv-n">null</span>
      } else if (node.nodeType === 'string') {
        try {
          // 只有在 renderHTag 为 true 时才处理 HTML 标签
          if (props.renderHTag && /<[^>]*>/g.test(node.value)) {
            // 移除字符串两端的引号
            const htmlStr = node.value.replace(/^"|"$/g, '')
            // 解析可能包含的JSON字符串
            const processJsonInHtml = (str: string) => {
              return str.replace(/\{([^}]+)\}/g, (match) => {
                try {
                  const jsonObj = JSON.parse(match)
                  return JSON.stringify(jsonObj)
                } catch {
                  return match
                }
              })
            }
            const processedHtml = processJsonInHtml(htmlStr)
            return <div class="html-content" innerHTML={processedHtml}></div>
          }
          return <span class="jv-greed">{JSON.stringify(node.value)}</span>
        } catch {
          return <span class="jv-greed">{JSON.stringify(node.value)}</span>
        }
      } else if (node.nodeType === 'number' || node.nodeType === 'boolean') {
        return <span class="jv-red">{node.nodeType === 'boolean' ? String(node.value) : node.value}</span>
      }
    }

    const toggleExpand = (node: any) => {
      node.collapse = !node.collapse
    }

    // 定义一个箭头组件,用于显示展开和收起的状态
    const CollapseArrow = ({ toggleClick, isCollapsed }: any) => (
      <div
        style={{ cursor: 'pointer', display: 'inline-block' }}
        class={`color-f triangle-arrow ${isCollapsed ? 'triangle-right' : 'triangle-down'}`}
        onClick={toggleClick}
      ></div>
    )
    // 渲染单个节点的组件
    const JsonNode = ({ node }: any) => {
      // 渲染节点内容
      const renderNode = (key: string, value: any, children: [], type: string, index = 0, childNode: any) => {
        const _node = childNode || node
        if ((type === 'object' || type === 'array') && value !== null) {
          const colorIndex = index % _colors.length
          return (
            <div
              style={{
                padding: '2px 0',
                transition: 'background-color 0.2s',
              }}
            >
              <CollapseArrow toggleClick={() => toggleExpand(_node)} isCollapsed={_node.collapse} />
              <div style="display:inline-block;word-break: break-all;">
                {!_node.isArrayChild && (
                  <>
                    <span>{paseKey(key)}</span>
                    <span style="fontWeight:bold"></span>
                  </>
                )}
                <strong style={{ color: _colors[colorIndex] }}>{type === 'object' ? '{' : '['}</strong>
              </div>
              {_node.collapse ? <span style={{ color: _colors[colorIndex] }}>...</span> : ''}
              <Transition name="expand">
                <div v-show={!_node.collapse} style={{ paddingLeft: '16px' }}>
                  {children.map((child: any) => {
                    return (
                      <div key={child.level}>
                        {renderNode(child.key, child.value, child._children, child.nodeType, child.level, child)}
                      </div>
                    )
                  })}
                </div>
              </Transition>
              <span style={{ color: _colors[colorIndex] }}>
                <strong>{type === 'object' ? '}' : ']'}</strong>
              </span>
              {_node.isArrayChild && <span></span>}
            </div>
          )
        } else {
          return (
            <div style="display:inline-block;word-break: break-all;">
              {type !== 'array' && (
                <>
                  {!_node.isArrayChild && (
                    <>
                      <span class="json-key-span" style="display:inline-block">
                        {paseKey(key)}
                      </span>
                      <span style="fontWeight:bold"></span>
                    </>
                  )}
                </>
              )}
              {valueFormat(_node)}
              {_node.isArrayChild && <span></span>}
            </div>
          )
        }
      }

      return <div>{renderNode(node.key, node.value, node._children, node.nodeType, 0, node)}</div>
    }
    // 根组件,渲染整个JSON树
    const JsonTree = (data = []) => {
      return (
        <div>
          <div>
            <CollapseArrow toggleClick={toggleRoot} isCollapsed={isCollapsed.value} />
            <span class="json-key-span">{props.rootTagStart}</span>
            {isCollapsed.value && (
              <>
                <span>...</span>
                <span class="json-key-span">{props.rootTagEnd}</span>
              </>
            )}
          </div>
          <Transition>
            {/* {!isCollapsed.value && (
              <>
                <div style={{ marginLeft: "16px" }}>
                  {data.map((node, index) => (
                    <JsonNode node={node} />
                  ))}
                </div>
                <span class="json-key-span">{props.rootTagEnd}</span>
              </>
            )} */}
            {/* 使用 v-show 控制子节点的显示和隐藏 */}
            <div v-show={!isCollapsed.value} style={{ marginLeft: '16px' }}>
              <div style={{ paddingLeft: '16px' }}>
                {data.map((node) => (
                  <JsonNode node={node} />
                ))}
              </div>
              <span class="json-key-span">{props.rootTagEnd}</span>
            </div>
          </Transition>
        </div>
      )
    }

    // 搜索
    // const JsonSearch = () => {
    //   return (
    //     <div class="json-search">
    //       <input placeholder={props.splacholder} />
    //     </div>
    //   )
    // }

    return () => {
      return (
        <div class={`json-viewer ${props.theme === 'light' ? 'json-viewer-light' : 'json-viewer-dark'}`}>
          {props.copy && (
            <div class="json-copy" onClick={handleCopy}>
              复制
            </div>
          )}
          <div class="jdata-tree">{JsonTree(_nodes.value)}</div>
        </div>
      )
    }
  },
})
</script>

<style lang="scss">
@import './index.scss';
</style>

index.css

.json-viewer {
  position: relative;
  width: 100%;
  height: 100%;
  padding: 12px;
  font-family: 'Menlo', 'Monaco', 'Consolas', 'Courier New', monospace;
  font-size: 14px;
  line-height: 1.5;
  background: #fff;
  border-radius: 6px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);

  // 深色主题
  &.json-viewer-dark {
    background: #1e1e1e;
    color: #ffffff;
  }

  // 复制按钮
  .json-copy {
    position: absolute;
    top: 12px;
    left: 85%;
    padding: 6px 12px;
    font-size: 13px;
    color: #722ed1;
    background: rgba(114, 46, 209, 0.1);
    border-radius: 4px;
    cursor: pointer;
    transition: all 0.2s;

    &:hover {
      background: rgba(114, 46, 209, 0.2);
      color: #531dab;
    }
  }
  .jdata-tree {
    height: 100%;
    overflow-y: auto;
    overflow-x: hidden;
  }

  // 折叠箭头
  .triangle-arrow {
    width: 0;
    height: 0;
    margin-right: 8px;
    transition: transform 0.2s ease-in-out;
    opacity: 0.6;
    display: inline-block;
    position: relative;
    top: 1px;
    // 基础箭头样式(始终朝下)
    border-top: 8px solid #722ed1;
    border-left: 6px solid transparent;
    border-right: 6px solid transparent;

    &:hover {
      opacity: 1;
      border-top-color: #531dab;
    }
  }

  .triangle-arrow.triangle-right {
    transform: rotate(-90deg);
  }

  .triangle-arrow.triangle-down {
    border-top: 8px solid #722ed1;
    border-left: 6px solid transparent;
    border-right: 6px solid transparent;
    transform: rotate(0);
  }

  // 添加点击区域
  .triangle-arrow::before {
    content: '';
    position: absolute;
    top: -8px;
    left: -8px;
    right: -8px;
    bottom: -8px;
    cursor: pointer;
  }

  // JSON 键值对样式
  .json-key-span {
    color: #722ed1;
    font-weight: 500;
    padding-right: 4px;
  }

  // 值的颜色
  .jv-greed {
    color: #13c2c2;
  } // 字符串
  .jv-red {
    color: #52c41a;
  } // 数字和布尔值
  .jv-n {
    color: #faad14;
  } // null

  // 缩进和间距
  div {
    transition: all 0.2s;
  }

  // 每一行的悬停效果
  div:hover > .json-key-span {
    background-color: rgba(114, 46, 209, 0.08);
    border-radius: 3px;
  }

  // 深色主题特殊处理
  &.json-viewer-dark {
    .json-copy {
      background: #2d2d2d;
      color: #ccc;

      &:hover {
        background: #3d3d3d;
        color: #fff;
      }
    }

    .triangle-arrow {
      border-top-color: #ffffff;
      &:hover {
        border-top-color: #ffffff;
      }
    }

    .json-key-span {
      color: #ffffff;
    }
    .jv-greed {
      color: #ffffff;
    }
    .jv-red {
      color: #ffffff;
    }
    .jv-n {
      color: #ffffff;
    }

    div:hover > .json-key-span {
      background-color: rgba(160, 217, 17, 0.08);
    }
  }

  // 展开收起动画
  .expand-enter-active {
    transition: all 0.25s ease-out;
    max-height: 2000px;
    opacity: 1;
    overflow: hidden;
  }

  .expand-leave-active {
    transition: all 0.2s ease-in;
    max-height: 2000px;
    opacity: 1;
    overflow: hidden;
  }

  .expand-enter-from,
  .expand-leave-to {
    max-height: 0;
    opacity: 0;
    padding: 0;
  }

  .html-content {
    display: inline-block;

    // 重置继承的样式,让内联样式生效
    * {
      all: initial;
      display: inline;
    }

    // 让带style属性的元素使用其内联样式
    [style] {
      all: revert;
      display: inline;
    }
  }
}

// 深色主题处理
.json-viewer.json-viewer-dark {
  .html-content {
    color: #ffffff;

    [style] {
      color: unset; // 允许内联样式覆盖
    }
  }
}

props.ts

import type { ExtractPropTypes } from 'vue'
export const jsonViewerProps = {
  data: {
    type: [Object, String],
    required: true,
  },
  expanded: {
    type: Boolean,
    default: false,
  },
  copy: {
    type: Boolean,
    default: false,
  },
  theme: {
    type: String,
    default: 'light',
  },
  rootTagStart: {
    type: String,
    default: '{',
  },
  rootTagEnd: {
    type: String,
    default: '}',
  },
  renderHTag: {
    type: Boolean,
    default: true,
  },
  hideSearch: {
    type: Boolean,
    default: false,
  },
  splacholder: {
    type: String,
    default: '请输入 key 或者 value 进行搜索',
  },
}

export type JsonViewerProps = ExtractPropTypes<typeof jsonViewerProps>

interface.ts

export type { JsonViewerProps } from './props'

useControl.ts

import { JsonViewerProps } from './interface'
import { ElMessage } from 'element-plus'
export const useController = (props: JsonViewerProps) => {
  const _nodes = ref([])
  const isCollapsed = ref(false)
  const toggleRoot = () => {
    isCollapsed.value = !isCollapsed.value
  }
  const copyed = ref(false)
  const handleCopy = async () => {
    try {
      const copyData = JSON.stringify(props.data)

      // 使用现代的 Clipboared API
      await navigator.clipboard.writeText(copyData)
      copyed.value = true
      ElMessage.success('复制成功')
    } catch (error) {
      console.log('复制失败', error)
    }
  }

  watchEffect(() => {
    isCollapsed.value = false
    _nodes.value = jsonToNestedArray(props.data)
  })

  return {
    isCollapsed,
    _nodes,
    handleCopy,
    toggleRoot,
  }
}

function jsonToNestedArray(obj: Record<string, any> | string | undefined) {
  // 处理 undefined 情况
  if (obj === undefined) {
    console.error('JSON Viewer: 输入数据不能为空')
    return []
  }

  let jsonData: Record<string, any>

  // 校验并转换输入数据
  try {
    if (typeof obj === 'string') {
      // 如果是字符串,尝试解析成 JSON 对象
      jsonData = JSON.parse(obj)
    } else if (obj && typeof obj === 'object') {
      // 如果是对象,直接使用
      jsonData = obj
    } else {
      console.error('JSON Viewer: 输入数据必须是 JSON 对象或 JSON 字符串')
      return []
    }
  } catch (error) {
    console.error('JSON Viewer: JSON 字符串解析失败,请检查格式是否正确', error)
    return []
  }

  // 校验转换后的数据是否为有效对象
  if (!jsonData || typeof jsonData !== 'object' || jsonData === null) {
    console.warn('JSON Viewer: 解析后的数据必须是有效的对象或数组')
    return []
  }

  // 定义一个帮助函数递归地处理对象和数组,新增一个level参数来表示当前层级
  function processNode(key: any, value: any, path: any, level: any, isArrayChild = false) {
    // 获取完整的路径
    const fullPath = path ? `${path}.${key}` : key
    // 初始化节点,增加level属性
    const node: any = {
      key: fullPath,
      value: '',
      nodeType: typeof value,
      _children: [],
      level: level,
      collapse: false,
    }

    if (typeof value === 'object' && value !== null) {
      // 如果值是一个对象,则为每个子属性创建新的节点
      if (Array.isArray(value)) {
        // 处理数组类型
        node.nodeType = 'array'
        // node.value = value.toString();
        node.value = JSON.stringify(value)
        node.isArrayChild = isArrayChild
        // node.type = "array";
        value.forEach((item, index) => {
          node._children.push(processNode(`${index}`, item, '', level + 1, true))
        })
      } else {
        // 处理对象类型
        node.nodeType = 'object'
        // node.value = value?.toString();
        node.value = JSON.stringify(value)
        node.isArrayChild = isArrayChild
        // node.type = "object";
        Object.entries(value).forEach(([childKey, childValue]) => {
          node._children.push(processNode(childKey, childValue, fullPath, level + 1))
        })
      }
    } else if (typeof value === 'function') {
      // 如果值不是对象或数组,直接设置值和类型
      node.nodeType = 'function'
      node.value = value?.toString()
      node.isArrayChild = isArrayChild
    } else {
      node.nodeType = typeof value
      node.value = value
      node.isArrayChild = isArrayChild
    }
    return node
  }

  const result: any = []
  Object.entries(jsonData).forEach(([key, value]) => {
    result.push(processNode(key, value, '', 0))
  })

  return result
}