尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
JSON DOM实战:用数据描述页面,手写轻量级渲染器
如果你写过一阵子前端大概率会有这个困惑后端接口返回的数据和页面上的组件结构到底怎么优雅地“拼”到一起有人说用模板字符串有人说用模板引擎还有人直接用虚拟DOM库。那如果我只想要一个轻量、没有依赖、能看懂原理的解决方案呢答案可以落在一个非常朴素的概念上用JSON去描述DOM再递归地把JSON变成真实DOM。这就是“Json Dom”的核心思路——它不是一个必须装的重框架而是一套“数据即结构”的玩法学会了之后你能在没有框架的情况下轻松实现数据驱动页面、动态渲染、甚至自己写一个迷你版虚拟DOM。这篇内容我会从零开始把一个最小可用的JSON DOM渲染器怎么写、每一步为什么这么设计、踩过哪些坑全部摊开讲清楚。适合对原生DOM操作有一定基础、又想摆脱“字符串拼HTML”的前端开发者也适合想理解虚拟DOM原理但不想直接啃源码的朋友。1. 先搞清楚JSON DOM到底是个啥1.1 一句话说人话JSON DOM可以拆成两部分理解左边是JSON数据右边是DOM节点。它不是某种标准而是一种思路——用JSON的树形结构去描述一个页面的节点树然后写一个渲染器把JSON描述的节点逐个创建成真实的DOM元素。举个例子页面长这样div classcard h1你好/h1 p这是一段说明/p /div用JSON DOM的思路来描述就是{ tag: div, props: { class: card }, children: [ { tag: h1, props: {}, children: [你好] }, { tag: p, props: {}, children: [这是一段说明] } ] }看到没这个结构天生就是一棵树。DOM本身也是一棵树JSON里父子嵌套关系正好可以和DOM的父子节点关系一一对应。所以最直觉的做法就是读一个JSON节点根据tag创建对应元素处理props然后递归处理children最后挂到父节点上。1.2 它和拼接字符串、模板引擎、虚拟DOM有什么关系很多人第一次接触动态渲染用的是字符串拼接const html div classcardh1${title}/h1/div; container.innerHTML html;这种方式简单直接但问题也很明显结构复杂时括号和引号会乱成一团数据里有特殊字符时还有注入风险。模板引擎解决了部分问题但很多模板引擎还是要把字符串解析成AST再生成渲染函数本质上仍然是在“处理字符串”。JSON DOM则不一样。它从一开始就把页面结构当成“数据”而不是“字符串”来对待。字符串拼接是“文本层面”的组装。JSON DOM是“对象层面”的组装。因为它是对象所以你可以很方便地对结构做增删改查可以递归遍历可以在渲染前做各种程序化处理。这一点和虚拟DOM很像。实际上很多虚拟DOM库内部就是这么干的先用JS对象描述DOM结构也就是vnode再通过patch算法把对象变成真实DOM。你完全可以认为JSON DOM就是手写版、极简版、不加diff的虚拟DOM。所以学会JSON DOM不仅仅是为了写一个渲染函数更是为了理解虚拟DOM的第一步台阶。2. 核心设计拆解从JSON到DOM的映射规则2.1 节点类型怎么约定写渲染器之前得先约定JSON里面有哪些节点类型。不需要很复杂最基础三种就够了文本节点直接用一个字符串表示比如你好。元素节点用对象表示包含tag、props、children。组件节点用一个标记字段表示比如type: component然后指定组件名和参数。组件节点可以先不搞后面进阶再说。先把这个渲染函数写出来你会发现核心逻辑很短function render(vnode) { // 如果vnode是字符串直接创建文本节点 if (typeof vnode string) { return document.createTextNode(vnode); } // 如果有type字段且type为component就走组件渲染逻辑 if (vnode.type component) { return renderComponent(vnode); } // 普通元素节点 const el document.createElement(vnode.tag); // 处理props if (vnode.props) { for (const key in vnode.props) { const value vnode.props[key]; if (key class) { el.className value; } else if (key.startsWith(on)) { // onClick - click el.addEventListener(key.slice(2).toLowerCase(), value); } else { el.setAttribute(key, value); } } } // 递归处理子节点 (vnode.children || []).forEach(child { el.appendChild(render(child)); }); return el; }这里有一个关键设计文本节点直接用字符串表示而不是包一层对象。为什么因为绝大多数子节点都是文本如果每个文本都写{type: text, content: 你好}JSON的体积会大很多写起来也啰嗦。让字符串作为文本节点的“语法糖”简洁度立刻提升。2.2 属性、样式、事件的映射props的处理是很多人容易忽略细节的地方。创建一个DOM元素之后属性设置的方式并不统一id、title、>function setProps(el, props) { for (const key in props) { const value props[key]; if (key class) { el.className value; } else if (key style) { if (typeof value string) { el.style.cssText value; } else { Object.assign(el.style, value); } } else if (key value) { el.value value; } else if (key.startsWith(on)) { const eventName key.slice(2).toLowerCase(); el.removeEventListener(eventName, value); el.addEventListener(eventName, value); } else if (value true) { el.setAttribute(key, ); } else if (value ! false value ! null) { el.setAttribute(key, value); } } }为什么要单独拆一个setProps函数因为后面做更新的时候你需要对属性做对比、做替换把它独立出来会清爽很多。否则渲染和更新两个流程里都写一遍属性逻辑代码就重复了。2.3 为什么这种设计有利于数据驱动视图当你用JSON描述页面之后最直接的好处就是视图不再是“写死”的而是可以由数据生成。接口返回一个列表你就把列表map成一组JSON节点再交给render函数渲染。数据结构变了生成的JSON结构也就变了页面自然跟着变。这其实就是“UI f(data)”的思想。只要数据是纯的、可预测的渲染结果就是可预测的。调试的时候你甚至可以先把接口返回的数据打印出来然后手动构造一个JSON观察渲染结果再反推数据哪里不对。这种“数据驱动视图”的心智模型比“改一个值就拼接一遍字符串”要舒服得多。3. 动手实现一个最小可用的JSON DOM渲染器3.1 先跑通最简单的渲染流程现在我们把上一节的render函数补齐写一个可以直接跑的完整示例。!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleJson Dom Demo/title /head body div idapp/div script // render函数定义 function render(vnode) { if (typeof vnode string) { return document.createTextNode(vnode); } if (vnode.type component) { return renderComponent(vnode); } const el document.createElement(vnode.tag); setProps(el, vnode.props || {}); (vnode.children || []).forEach(child { el.appendChild(render(child)); }); return el; } // 属性设置 function setProps(el, props) { for (const key in props) { const value props[key]; if (key class) { el.className value; } else if (key style) { if (typeof value string) { el.style.cssText value; } else { Object.assign(el.style, value); } } else if (key.startsWith(on)) { const eventName key.slice(2).toLowerCase(); el.addEventListener(eventName, value); } else if (value true) { el.setAttribute(key, ); } else if (value ! false value ! null) { el.setAttribute(key, value); } } } // 定义一个JSON节点树 const appJSON { tag: div, props: { class: container, style: padding: 20px; }, children: [ { tag: h1, props: {}, children: [Json Dom 实战] }, { tag: ul, props: {}, children: [前端, 数据驱动, 轻量渲染].map(text ({ tag: li, props: { class: item }, children: [text] })) } ] }; const app document.getElementById(app); app.appendChild(render(appJSON)); /script /body /html这里有个小细节在map里生成li节点时我下意识地把props写成对象children里再放文本。文本节点用字符串表示所以一个li就是一个包含tag、props、children的对象。这样整棵节点树在维护上非常直观跟HTML结构几乎是一一对应的。3.2 支持事件绑定让页面“活”起来光渲染静态结构不够还得能响应交互。事件绑定上面已经提到在props里用onClick、onInput这样以on开头的key渲染时转成click、input事件。一个简单的点击计数例子let count 0; const vdom { tag: div, props: { class: counter }, children: [ { tag: button, props: { onClick: () { count; update(); } }, children: [加1] }, { tag: span, props: { id: count, class: number }, children: [String(count)] } ] }; function update() { const app document.getElementById(app); // 简单粗暴重新渲染后替换 app.innerHTML ; app.appendChild(render(vdom)); } update();这当然不是最优解每点一次按钮就清空整个容器重新渲染但对小页面来说完全够用。如果不想重刷整个页面可以只更新某个子节点const countEl document.getElementById(count); countEl.textContent String(count);业务复杂以后“全量重渲染”的代价会越来越大所以后面我们自然要引入“差异更新”。这部分到后面“进阶玩法”里再说。3.3 支持简单的组件化JSON DOM也可以玩“函数组件”。思路很简单如果某个vnode对象里标记了type: component并且指定了name和props那我们就调用一个对应的组件函数让它返回真实的vnode再交给render继续渲染。function Card(props) { return { tag: div, props: { class: card, style: border: 1px solid #eee; padding: 10px; }, children: [ { tag: h2, props: {}, children: [props.title] }, { tag: p, props: {}, children: [props.desc] } ] }; } function renderComponent(vnode) { const componentMap { Card }; const componentFn componentMap[vnode.name]; if (!componentFn) { throw new Error(组件未注册: ${vnode.name}); } const childVnode componentFn(vnode.props || {}); return render(childVnode); } const pageJSON { tag: div, props: {}, children: [ { type: component, name: Card, props: { title: 标题一, desc: 描述一 } }, { type: component, name: Card, props: { title: 标题二, desc: 描述二 } } ] };这里要注意组件函数返回的是一个“JSON节点树”并不是真正的DOM。这个设计很重要因为组件可以嵌套可以复用而且不会在渲染前就产生真实DOM对象方便后续做结构分析和对比。我在实际使用中会把组件函数放在一个注册表里用的时候按名字取。这样可以做到“数据描述页面结构组件提供样式和逻辑”。4. 反向操作把真实DOM转回JSON4.1 写一个序列化函数有正向渲染自然就有反向序列化。有时候我们拿到了一个真实DOM想知道它的结构是什么或者想把当前页面的某个区域变成JSON数据存起来这就要用DOM转JSON。function toJSON(node) { if (node.nodeType Node.TEXT_NODE) { return node.textContent; } if (node.nodeType Node.ELEMENT_NODE) { const props {}; for (const attr of node.attributes) { props[attr.name] attr.value; } const children []; for (const child of node.childNodes) { // 跳过纯空白文本节点减少噪音 if (child.nodeType Node.TEXT_NODE child.textContent.trim() ) { continue; } children.push(toJSON(child)); } return { tag: node.tagName.toLowerCase(), props, children }; } }注意几点node.attributes是一个NamedNodeMap不是数组要用of或展开运算符转成数组才能遍历。文本节点直接返回字符串和正向渲染的约定保持一致。跳过空白文本节点是为了避免格式化HTML中的换行缩进变成一堆无意义的文本节点。4.2 这个逆向操作能干什么实际开发中DOM转JSON的用处比想象中大。一个是页面快照。在单页应用里页面状态经常保存在内存或URL参数里。如果某个区域是动态生成的你可以把它序列化成JSON存到localStorage里下次打开直接恢复而不用重新请求接口拼数据。另一个是埋点上报。某些场景下需要分析页面上实际生成了哪些DOM结构。把DOM转成JSON后可以过滤掉一些无关节点只抽取关键区域然后作为结构化日志发到后端。后端拿到的不是一大段HTML而是一棵可遍历的JSON树分析起来方便很多。还有一个场景是做无头浏览器的页面结构化。比如在爬虫工具里抓取到的页面往往是一堆HTML如果先解析成DOM再转成干净的JSON后续处理起来会好很多。5. 进阶玩法细粒度更新和差异化渲染5.1 从“整页重渲染”到“局部更新”很多人在使用JSON DOM时都会遇到同一个瓶颈页面一大全量重渲染开始卡。每次状态变化都清空根节点重新append会带来两个问题一是频繁的DOM插入和删除会引发性能问题二是会丢失输入框焦点、滚动位置等状态。解决思路也很经典把更新拆成“对比旧结构和新结构找出差异只修改该改的地方”。这其实就是diff算法。5.2 一个简单的diff思路我们不需要实现一个完整的虚拟DOM diff先从一个最朴素的版本开始如果是文本节点直接比较内容不一致就替换。如果是元素节点先比较tagtag不同就整个替换。如果tag相同就比较props有变化就更新属性。然后递归比较children。伪代码大概是function patch(el, oldVnode, newVnode) { // 文本节点 if (typeof newVnode string || typeof oldVnode string) { if (oldVnode ! newVnode) { el.textContent newVnode; } return el; } // tag不同整节点替换 if (oldVnode.tag ! newVnode.tag) { const newEl render(newVnode); el.parentNode.replaceChild(newEl, el); return newEl; } // 更新props for (const key in newVnode.props) { const value newVnode.props[key]; const oldValue oldVnode.props[key]; if (oldValue ! value) { setProp(el, key, value); } } // props中旧有但新没有的需要删除 for (const key in oldVnode.props) { if (!(key in newVnode.props)) { removeProp(el, key); } } // 子节点对比这里做一个简易同步 const oldChildren oldVnode.children || []; const newChildren newVnode.children || []; const maxLen Math.max(oldChildren.length, newChildren.length); for (let i 0; i maxLen; i) { if (i newChildren.length i oldChildren.length) { // 新增节点 el.appendChild(render(newChildren[i])); } else if (i oldChildren.length i newChildren.length) { // 删除多余节点 el.removeChild(el.childNodes[i]); } else { // 递归patch el.childNodes[i] patch(el.childNodes[i], oldChildren[i], newChildren[i]); } } return el; }这里面有一个坑el.childNodes[i]可能不是元素节点因为在正渲染时文本节点也会被算进去。所以最好给每个vnode添加一个唯一标识或者用key来精确匹配子节点。完整方案比较复杂但核心套路就是这么一回事。我个人的经验是如果你只需要更新页面的一小部分不要急着上diff。先试试“事件委托 局部更新”或者是给每个叶子节点一个独立容器单独维护它们的更新。只有节点数量真的达到几百上千而且状态变化很频繁的时候才值得引入真正的diff算法。6. 常见问题与避坑指南6.1 不要盲目使用innerHTML很多人看到JSON DOM会想我直接把JSON字符串化之后用innerHTML插进去不就行了吗比如container.innerHTML JSON.stringify(vdom);这样一定不行。因为JSON不是HTML{tag:div}插进去只会显示成文本。正确做法始终是先构建真实DOM再插入。另一个和innerHTML相关的问题是XSS。如果你在某处还是想用HTML字符串一定要小心数据里的script、img onerror这类内容。JSON DOM方案有一个天然好处它走的是createElement和createTextNode路线文本内容不会被当成HTML解析所以只要不在props里使用dangerouslySetInnerHTML之类的黑科技安全性比字符串拼接高一个级别。6.2 事件监听的累积与泄漏在3.2的简单重渲染示例里每点一次按钮我就清空了容器然后重新渲染整个vdom。但清空容器时旧节点上的事件监听器并不会自动“归还”给事件系统。如果事件回调还被变量引用着浏览器会保留这些引用时间一长就可能内存泄漏。更常见的问题是同一个元素反复绑定相同事件。在setProps里我第二次绑定事件时先移除了旧监听再添加新监听这样可以避免回调重复执行。如果你的渲染函数没有做这一步页面可能会出现“点击一次触发多次”的灵异现象。解决建议不要在重渲染时无脑清空重建。尽量复用DOM节点只更新需要变化的内容。如果要全量重建务必先移除旧节点上挂的事件监听或者干脆使用事件委托在根容器上统一捕获事件。6.3 循环引用与深层嵌套JSON可以描述树但如果你把组件函数或事件回调直接放进props里然后又把这个JSON对象序列化成字符串就会遇到循环引用问题。比如组件函数里引用了外部对象外部对象可能引用了这个JSON节点树。处理方式通常有两种一种是在序列化前剔除函数和复杂对象另一种是使用JSON.stringify的第二、第三个参数或者自己封装一个安全的序列化函数遇到函数类型的值就统一转成标记字符串。比如事件回调不需要被存储那么序列化时遇到onClick这种props直接跳过即可。深层嵌套也有坑。如果JSON树特别深递归渲染时超过调用栈上限浏览器会直接报“Maximum call stack size exceeded”。我一般会建议把嵌套深度控制在几十层以内如果页面结构真的特别深可以考虑把递归改成显式栈遍历。6.4 性能优化的一些心得JSON DOM的性能关键点主要在四个方面遍历和创建DOM是最重的一部分批量操作时尽量用DocumentFragment减少对页面的强制布局。props处理要留意是否真的需要设置属性。比如class没有变化时就不用重新赋值className。子节点对比时宁可多做几个条件判断也不要对每个节点都执行完整属性遍历。如果数据量很大优先考虑把列表拆成“虚拟滚动”只在可视区域内渲染节点。我实际测试过一个包含几百个节点的JSON树用简单的render函数渲染初始化时间大约几十毫秒完全够用。真正卡顿通常来自“频繁全量重渲染”优化目标应该放在减少重复渲染上而不是微调单个节点的创建方式。最后再分享一点实操体会做前端这几年我最深的感受是框架会让你用得很顺但也会让你离底层越来越远。自己动手写一遍JSON到DOM的渲染器就像给“虚拟DOM”这个概念拆了一次零件——你会突然明白框架里那些看起来黑魔法一样的东西本质不过是一组约定和一个递归函数。如果你觉得这个方案有意思我建议找一个具体的小页面比如一个实时搜索框、一个待办事项列表完全用JSON DOM手写一遍。第一次跑通之后再尝试给渲染器加上diff更新、组件注册、key匹配这些功能。你会发现“玩转Json Dom”不只是学会一个函数而是打开了理解前端渲染原理的一扇门。提示如果要用在真实项目里建议封装成独立模块定义好统一的vnode规范并编写单元测试。JSON DOM的边界很清晰结构越规范后面要做性能优化、渲染调度、服务端渲染的时候就越省力。
RELATED

相关推荐

Hive高级调优实战:分区裁剪、窗口函数与数据倾斜解决方案

Hive高级调优实战:分区裁剪、窗口函数与数据倾斜解决方案

1. 为什么“会写SQL”和“玩转Hive”是两码事很多人刚接触大数据平台时,觉得Hive不就是写SQL吗,跟MySQL能有多大区别。我刚开始也这么想,直到第一次跑一个几十亿行的日志表,一条简单的count(distinct)直接把集群跑崩,才…

📅 2026/10/21 4:05:53
vue3-element-admin 全局字体大小完整指南:3 分钟调好字号与组件尺寸

vue3-element-admin 全局字体大小完整指南:3 分钟调好字号与组件尺寸

vue3-element-admin 全局字体大小完整指南:3 分钟调好字号与组件尺寸 【免费下载链接】vue3-element-admin 🔥基于 Vue 3 Vite 7 TypeScript element-plus 构建的后台管理前端模板(配套后端源码),vue-element-admin …

📅 2026/10/21 4:05:53
dsh-purge 深度解析:剥离宿主策略与模型能力的提示词清洗插件

dsh-purge 深度解析:剥离宿主策略与模型能力的提示词清洗插件

dsh-purge 深度解析:剥离宿主策略与模型能力的提示词清洗插件 【免费下载链接】dsh-purge DeepSeek Harness 破甲:让所有模型都能破甲,不同模型可换不同提示词;默认提示词面向国模「小码酱」。Jailbreak for every model — swap prompts per…

📅 2026/10/21 4:05:53
MORE NEWS

更多资讯

📰

施耐德平台API设备列表获取与同步实战指南

正在做设备数据对接的朋友,大概率都遇到过这种场景:上级要求把施耐德平台里的设备清单同步到自己的系统中,但平台后台只能一页一页手动导出,设备一多根本扛不住。更麻烦的是后续要定时刷新、做增量比对,手动操作完全是…

📰

GPU到国产AI加速卡迁移实战:推理服务适配与性能调优全记录

今年我们接了个有点棘手的活儿:把线上一个日调用量千万级的NLP推理服务,从国际主流的GPU平台平滑迁到国产AI加速卡上。任务听起来不复杂——换个硬件,模型重新导出一下不就行了?实际上从立项到灰度上线,我们整整折腾了…

📰

用Coze工作流搭建情绪共鸣短视频自动化生产线,实战30万涨粉全拆解

做过短视频的朋友应该都有这种感觉:刷到那种一条视频几百万赞、几十万评论的账号,点进去看内容,发现画面不算精致、文案也不算长,评论区里却全是“这就是我”“谁懂啊”“说到心坎里了”的共鸣留言。这类内容就是最近两年流量表现…

📰

基于YOLO的钢材表面缺陷检测系统设计与PyQt5界面实战

做工业视觉的人对钢材表面缺陷检测这个问题应该不陌生。热轧钢板在产线上高速通过,表面随时可能出现划痕、斑块、氧化皮、夹杂物这类缺陷,靠人工盯着屏幕看,时间一长就是视觉疲劳,漏检率根本控不住。我最近完整搭了一套基于深度学…

📰

SQL注入与XSS实战拆解:从攻击链到防御编码的完整指南

半夜两点收到告警短信,说是某业务后台的登录接口出现大量异常请求。我打开日志一看,一排排admin or 11和admin --整整齐齐,就像有人在拿着字典挨个试。那会儿我就知道,又有人盯上这个老项目的登录接口了。这类事情干得多了以后&am…

📰

AI写论文实战:千笔AI写作全流程测评与避坑指南

最近私信里被问得最多的一个词,就是“AI写论文”。打开任何学生聚集的社区,都能看到有人安利各种工具,其中“千笔AI写作”这个名字反复出现,评论区清一色的“收藏了”“真的好用”。我花了两周时间,拿真实选题跑了完整…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬