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