资讯动态

自研轻量级Markdown渲染引擎:架构解析与跨端实践

发布时间:2026/10/21 13:36:35 来源:尧图企业网站定制
去年下半年我一直在折腾一个内部小项目代号 rea全称是“Reading Engine App”。它是一个从零手写的轻量级 Markdown 渲染引擎目标很简单让任何只有文本内容的模块都能以极小的接入成本获得一套结构化、可定制、可跨端嵌入的阅读界面。做这件事的起因很直接——团队某个嵌入式帮助文档模块要求增量包体控制在 80KB 以内还要跟现有 UI 体系完全统一、支持暗色主题翻了几个现成方案功能确实全但样式体系独立、定制链路漫长怎么都透着一股“开着卡车去送快递”的别扭感。所以我就决定自己写一个解析器顺带把渲染层、主题系统和扩展机制全部握在自己手里。如果你也在做内容类页面、阅读器或工具型应用被类似问题卡过那这篇笔记应该能帮你少走不少弯路内容包括核心架构、解析流程、跨端嵌入和性能实测。1. 项目动机与整体设计思路1.1 为什么放着现成方案不用非要自研先说背景。当时我们接到的需求是一个内嵌式文档模块用户点开帮助按钮弹层里要展示一份带标题结构、列表、代码块和表格的说明文档。这个模块会打包在好几个不同的宿主环境里普通浏览器、桌面客户端的内嵌页、还有移动端的 WebView。需求本身不算复杂真正麻烦的是三条硬性约束。第一是体积。做这种轻量模块最怕动不动就几百 KB 的依赖用户为了看一份说明文档要额外下载那么多字节页面加载体验直接崩掉。第二是样式隔离。现成方案大多带着自己的默认样式跟公司内部设计规范很难完全对齐改主题要一层层覆盖选择器越改越乱。第三是裁剪自由度。我们只想要标题、段落、列表、代码块、行内样式这几类语法不需要 LaTeX 公式、流程图、任务列表这些重型扩展但现成方案通常是全家桶不用的功能也得一起带着。我当时把现成的几类方案列了个对比表。方案类型包体增量样式定制成本可裁剪性对宿主的依赖通用文档渲染库大通常 200KB高需覆盖深层选择器低按需引入仍偏重强部分依赖特定运行时在线文档转 HTML 服务依赖网络不在考虑范围中样式由接口返回低强离线场景不可用自研解析 渲染可控制在 60KB 左右低从设计上就接入主题变量高只支持需要的语法弱纯逻辑可嵌入任意环境看完这个表结论已经很明确如果不自研体积和定制性这两条很难同时满足。就算强行用现成库后面接暗色主题、加自定义代码块样式、跟宿主环境的 UI 组件打通每一项都是无休止的补丁工程。与其打好几个补丁不如把地基换成自己能掌控的。1.2 rea 的设计目标与边界动手之前我先给 rea 划了四条设计目标后面所有实现都在为这四条服务。轻量接入后增量包体不超过 80KB压缩后目标 50KB 左右。为了这个目标我决定不引入任何第三方解析器和渲染库解析和渲染全部手写数据结构也尽量精简。纯净不污染宿主页面样式。rea 渲染出来的所有节点都带统一前缀同时用 CSS 变量管理主题色宿主覆盖样式只需要改几个变量不用跟一堆类名搏斗。快速在 5000 行以内的 Markdown 文本上解析加首次渲染总耗时不超过 100ms。日常文档根本不需要那么长的文本但必须保证极端情况下用户无感知。可扩展核心内核只做解析和基础渲染代码高亮、目录提取、图片懒加载这些能力全部通过插件注册。这样既保证了内核的干净又给了后续需求足够的生长空间。同时我也明确划了边界rea 不做 Word 那样的复杂排版不搞分页不搞目录自动编号不支持嵌入任意 HTML。它就是为“阅读型内容”服务的——把写好的文本变成结构清楚、颜值在线、能嵌入任何页面的阅读视图。边界清楚有个好处很多设计取舍不用争论直接按边界一刀切就行。1.3 技术选型的取舍逻辑技术选型这块我最看重的是“控制力”。自研引擎意味着解析规则、节点结构、渲染行为全部可读可改。遇到一个新的边角需求比如“表格里加个合计行”我可以直接改解析器不需要翻第三方文档找钩子。第二个考虑是包体。按以往经验一个正经的解析器加渲染器压缩后也就三四十 KB 的量级远小于那些动辄几百 KB 的通用方案。而且只有自己写的代码才清楚哪些逻辑可以被摇树优化掉。第三个考虑是兼容性。rea 的核心内核我设计成不依赖 DOM 的纯逻辑模块任何 JavaScript 环境都能跑。浏览器端用一套适配层处理节点创建桌面端和 WebView 里只要宿主暴露了 DOM 能力就能复用同一套核心。不过自研也有代价最大的代价是坑要自己踩。解析器的边界情况非常多比如表格里嵌套代码块、列表项里多行引用、行内代码里出现星号。这些问题后面我在排查章节里会详细讲。总的选择逻辑其实一句话核心能力必须自己造外围功能尽量插件化。2. 核心流程拆解从纯文本到结构化界面2.1 两个阶段的解析块级与行内rea 的解析流程我拆成了两个阶段块级解析和行内解析。块级解析负责划分文档的大结构识别出哪些行是标题、哪些是段落、哪些是列表、哪些是代码块、哪些是引用行内解析负责处理每一块内部的小元素比如一句普通文本里的加粗、行内代码、链接、图片。为什么非要拆成两遍直接一遍扫到底不行吗我一开始也试过一遍式解析遇到一对星号就切加粗但很快发现会互相干扰。比如一个列表项里的代码块代码块内的一堆反引号和星号不应该被当作行内语法再比如段落中的链接文本里也有方括号如果提前解析链接后面的图片语法就会错乱。拆开之后块级解析先把代码块、引用块这类“特殊区域”隔离出来行内解析只在合理的区域里执行冲突天然消解。打个比方这就像整理餐桌先把冷菜、热菜、汤分别端上桌再在每一盘里摆盘。如果一开始就把所有食材混在一起炒后面怎么分都分不干净。2.2 块级解析器的实现块级解析我用的方法是“逐行扫描 首行判定 延续状态”。核心思路是每一行先根据正则判断它可能开启什么类型的块然后进入一个状态循环后续行只要满足延续条件就继续挂在同一个块下面直到遇到终止条件。直接看代码更直观。function parseBlocks(lines) { const blocks []; let current null; for (let i 0; i lines.length; i) { const line lines[i]; // 当前块是代码块先检查是否结束 if (current current.type codeBlock) { if (/^/.test(line.trim())) { current.lang current.lang || ; current.lines.push(line); // 保留结束行方便还原 current null; continue; } current.lines.push(line); continue; } // 当前块是引用块以 开头就继续 if (current current.type blockquote) { if (/^\s?/.test(line)) { current.lines.push(line.replace(/^\s?/, )); continue; } else { current null; } } // 尝试开启新块 let match; if ((match /^(#{1,6})\s(.*)/.exec(line))) { blocks.push({ type: heading, level: match[1].length, raw: line }); current null; continue; } if ((match /^(-{3,}|\*{3,})$/.exec(line.trim()))) { // 分隔线忽略除非需要 blocks.push({ type: hr, raw: line }); current null; continue; } if ((match /^(\w*)/.exec(line))) { current { type: codeBlock, lang: match[1], lines: [] }; blocks.push(current); continue; } if ((match /^\s?/.exec(line))) { current { type: blockquote, lines: [line.replace(/^\s?/, )] }; blocks.push(current); continue; } if ((match /^[-*]\s/.exec(line)) || /^\d\.\s/.test(line)) { // 独立的列表块比较麻烦稍后专门处理 current null; // 这里先标记交给列表合并器 blocks.push({ type: listItem, ordered: /^\d\./.test(line), raw: line }); continue; } // 默认段落块持续收集直到空行或新块开始 if (!current || current.type ! paragraph) { current { type: paragraph, lines: [] }; blocks.push(current); } current.lines.push(line); } return mergeListItems(blocks); }注意上面这段代码里块类型主要由首行决定后续行只负责“延续”或者“终止”。这样做的好处是复杂度保持在线性级别不需要反复往回看已经处理过的行。如果每遇到一个缩进变化就去回溯前面所有行解析 5000 行文档会成为灾难。2.3 列表嵌套与缩进处理列表是所有 Markdown 解析器里最容易出幺蛾子的部分。正常文档里的列表层级可能是这样的一级项二级项三级项但真实用户输入的缩进往往不整齐有的用两个空格有的用 Tab还有的混用。rea 的处理策略是把缩进统一换算成“层级深度”规则是 2 空格 1 级缩进Tab 按 4 空格展开。每个列表项记录它自己的层级合并阶段根据层级关系构建嵌套结构。关键算法是维护一个栈。遇到一个新列表项时看它的层级比栈顶大、相等还是小更大就入栈成为子级相同就平级更小就弹栈直到层级匹配。这个栈机制能很自然地把平铺的列表项还原成树形结构。function mergeListItems(blocks) { const result []; const stack []; for (const block of blocks) { if (block.type ! listItem) { stack.length 0; result.push(block); continue; } const level getIndentLevel(block.raw); const content block.raw.replace(/^(\s*)([-*]\s|\d\.\s)/, ); while (stack.length 0 stack[stack.length - 1].level level) { stack.pop(); } const node { type: listItem, level, content, children: [] }; if (stack.length 0) { // 新的顶层列表 const list { type: list, ordered: block.ordered, items: [] }; list.items.push(node); result.push(list); stack.push({ level, list, currentItem: node }); } else if (stack[stack.length - 1].level level - 1) { // 子级列表项 if (!stack[stack.length - 1].currentItem.children.includes(node)) { stack[stack.length - 1].currentItem.children.push(node); } stack.push({ level, list: null, currentItem: node }); } else { // 同级列表项追加到同一个 list 里 const top stack[stack.length - 1]; if (top.list) { top.list.items.push(node); } else { // 需要找到最近的 list 容器 const parent stack.findLast(s s.list s.level level); if (parent) parent.list.items.push(node); } stack.push({ level, list: null, currentItem: node }); } } return result; }这里最需要注意的一个坑是空行会中断列表。用户有时候会在列表项之间留空行期望仍然是一个列表但实际上很多解析器会把它拆成两个列表。rea 的选择是折中列表项之间的单个空行允许保留两个连续空行才强制中断。这个规则能覆盖绝大多数真实文档习惯。2.4 行内解析的顺序与优先级块级结构确定之后每一块的文本内容再交给行内解析器。行内解析的核心问题是优先级顺序。我定的解析顺序是行内代码反引号图片链接加粗双星号删除线强调单星号这个顺序是有讲究的。行内代码里的内容必须最先被保护起来否则代码片段里的星号会被误判成强调。图片和链接都依赖方括号但图片前面多一个感叹号必须优先处理图片。加粗要优先于强调因为两个连续星号如果被拆成两组单星号语义就错了。技术上我用组合正则一次性匹配所有候选然后在回调里按顺序做替换。这样比多次全量 replace 更高效也更好控制嵌套关系。const INLINE_PATTERN /([^]|!\[([^\]]*)\]\(([^)])\)|\[([^\]])\]\(([^)])\)|\*\*([^*])\*\*|~~([^~])~~|\*([^*])\*)/g; function parseInline(text) { return text.replace(INLINE_PATTERN, (match, ...args) { if (match[0] ) { return { type: inlineCode, content: match.slice(1, -1) }; } if (match.startsWith(![)) { return { type: image, alt: args[1], src: args[2] }; } if (match.startsWith([)) { return { type: link, text: args[3], href: args[4] || args[5] }; } if (match.startsWith(**)) { return { type: strong, content: parseInline(match.slice(2, -2)) }; } if (match.startsWith(~~)) { return { type: delete, content: parseInline(match.slice(2, -2)) }; } return { type: emphasis, content: parseInline(match.slice(1, -1)) }; }); }注意上面的设计加粗和强调的内容会再次递归调用 parseInline这样**一段带*强调*的加粗**才能被正确解析。递归深度要控制住正常文本不会超过 5 层嵌套我在循环里加了一个深度计数器超过 15 层就停止递归防止有人拿构造文本攻击。2.5 AST 设计让解析和渲染彻底解耦解析器最终产出的不是直接可用的 HTML 字符串而是一棵抽象语法树。每个节点统一长这个样子{ type: paragraph, children: [ { type: text, content: 这是一段 }, { type: strong, children: [{ type: text, content: 加粗 }] } ] }AST 的好处是解析过程和渲染过程完全解耦。调试解析器时我只看 AST 不关心页面长什么样写渲染器时只需要遍历这棵树映射到对应宿主节点就行。后续要做编辑器、导出 PDF、生成目录只要复用这棵树就能完成。节点类型我控制在 18 个以内document、heading、paragraph、list、listItem、blockquote、codeBlock、hr、table、tableRow、tableCell、text、strong、emphasis、delete、inlineCode、link、image。少而清晰的类型集合让渲染器的分支逻辑很干净也让插件作者更容易上手。3. 渲染器与主题系统让界面可控3.1 递归遍历 AST映射到宿主节点渲染器本身不复杂本质就是一棵树的递归遍历。每个节点类型对应一个创建函数返回一个宿主元素。function renderNode(node, context) { switch (node.type) { case document: return renderChildren(node, context); case heading: { const el document.createElement(h${Math.min(node.level, 6)}); el.className rea-heading; el.setAttribute(data-level, node.level); appendChildren(el, node.children, context); return el; } case paragraph: { const el document.createElement(p); el.className rea-paragraph; appendChildren(el, node.children, context); return el; } case strong: { const el document.createElement(strong); appendChildren(el, node.children, context); return el; } // ... 其他节点 default: const el document.createElement(span); el.textContent node.content || ; return el; } }这里有一个很多人会忽略的点每个节点都要生成一个稳定的 key。这个 key 不是给渲染用的是给后续“局部刷新”和“编辑器光标定位”用的。rea 的渲染器会对每个节点生成一个从根到自身的路径数组比如[0, 3, 2]。当源码片段改变时可以通过路径直接找到对应节点做局部更新而不是整棵树重渲。这个能力在做增量渲染时非常关键。3.2 主题系统CSS 变量加两层覆盖rea 的样式体系从一开始就坚持“类名前缀 CSS 变量”的策略。所有内部类都以rea-开头比如.rea-heading、.rea-list-item、.rea-code-block。颜色、间距、字体大小这些可调节项全部用 CSS 变量定义默认值写在内核样式表里。:root { --rea-base-font-size: 16px; --rea-heading-color: #1a1a1a; --rea-text-color: #333; --rea-code-bg: #f6f8fa; --rea-border-color: #e5e5e5; --rea-link-color: #0366d6; } .rea-container { font-size: var(--rea-base-font-size); color: var(--rea-text-color); line-height: 1.7; } .rea-container.dark { --rea-heading-color: #e6e6e6; --rea-text-color: #ccc; --rea-code-bg: #2d2d2d; --rea-border-color: #444; --rea-link-color: #58a6ff; }宿主接入时只需要覆盖这几个变量就能整体换肤不需要关心内部几十个类名。暗色主题甚至不需要做任何 JS 逻辑加一个dark类就行。这种两层设计在真实业务里给我省了很多事产品改主色调我改一个变量涉及特殊场景的局部样式我再写一层.rea-container下的额外覆盖规则。如果宿主环境对样式隔离要求更高比如嵌入别人家的页面时怕类名冲突rea 还提供一个 Shadow DOM 挂载模式。把整个渲染结果挂到 Shadow Root 里外部样式完全进不来内部变量也不会泄漏出去。代价是 Shadow DOM 下弹层、锚点跳转这些交互要自己额外处理所以我只在比较极端的环境才推荐这种模式。3.3 扩展机制与插件rea 的插件机制参考的是静态钩子模式核心内核暴露了三个时机beforeParse、afterParse、beforeRender。插件就是一个普通对象在初始化时注册进去。const plugin { name: code-copy-button, hooks: { beforeParse(source) { // 可以在解析前对源码做预处理 return source; }, afterParse(ast) { // 在 AST 上做转换比如给代码块加上文件名属性 walk(ast, (node) { if (node.type codeBlock !node.filename) { node.filename extractFilename(node.raw); } }); return ast; }, beforeRender(ast) { // 渲染前的最后调整 return ast; } } }; rea.use(plugin);让我觉得这套设计比较稳的原因是它把真正高频的需求都变成了二十行以内的插件。目录提取插件只需要在afterParse时收集所有 heading 节点生成目录树代码高亮插件只需要在beforeRender时把代码块节点替换成高亮后的节点序列图片懒加载插件只需要在渲染时给 image 节点加上>// 适配层接口设计 class DomAdapter { createElement(tag, attrs, children) { // 浏览器 / WebView 环境 } updateText(node, text) { // 更新文本节点 } removeElement(node) { // 移除元素 } }4.2 体积控制的关键动作体积是 rea 的立身之本我在写代码时做了几项针对性控制。第一项解析器不追求完整 Markdown 规范。常见语法全支持但表格的复杂对齐、脚注、自动链接等冷门特性直接不做从源头控制代码量。第二项不使用重型运行时和框架。整个引擎就是纯 JavaScript 函数加一个轻量 DOM 操作层。第三项按需编译。打包时把行内解析器和块级解析器拆成独立 chunk如果宿主只渲染预解析好的 AST甚至可以不加载解析器。第四项压缩混淆时开启高级特性裁剪把没被引用的插桩代码全部摇掉。实际压缩后的体积构成大致是块级解析器 18KB、行内解析器 12KB、AST 工具 6KB、DOM 渲染器 15KB、基础样式 4KB总计 55KB 左右。没有超过我之前定的 60KB 目标。跟那些动辄两三百 KB 的方案相比接入成本明显低了一个量级用户打开帮助弹层的速度体感上快了很多。4.3 渲染性能实测与优化路径我先说实测数据。拿一份 3000 行的 Markdown 文档做基准解析耗时大约 45ms渲染成 DOM 节点并插入页面大约 80ms整个过程在 150ms 内完成。对阅读场景来说这个速度已经无感知。但真正到生产环境后问题并不在解析而在滚动和重排。长文档一次性渲染一万个 DOM 节点页面在低端设备上滚动会出现掉帧。优化方案是按需渲染也就是虚拟列表。页面可视区域高度是已知的每个节点的高度可以通过样式估算rea 在初始化时预先计算所有节点的累计偏移量滚动到哪一段就只渲染那一段的节点。窗口外的节点用空白占位元素顶住高度保证滚动条长度正确。function renderVirtualList(container, nodes, viewportHeight) { let startIndex findNodeIndexAtOffset(scrollTop); let endIndex findNodeIndexAtOffset(scrollTop viewportHeight); // 只渲染 startIndex 到 endIndex 之间的节点 const fragment document.createDocumentFragment(); for (let i startIndex; i endIndex; i) { fragment.appendChild(renderNode(nodes[i])); } container.replaceChildren(fragment); }另一个重要的优化是缓存 AST。文档内容不常变化用户只是切换主题、展开收起目录如果每次都重新解析一遍就太浪费了。rea 在模块实例里缓存了解析结果只有调用setSource传入不同的内容时才触发重新解析。这个缓存还做得比较细如果新内容和旧内容只有局部差异parse 阶段会先做一次字符串 diff然后只重新解析被影响到的块。4.4 跨端场景下的特殊适配不同宿主环境带来的坑比预想的多。桌面端常见的问题是滚动容器不确定帮助弹层可能嵌在某个自定义滚动区域内而不是window。rea 的做法是提供scrollParent配置项让宿主显式声明滚动容器虚拟列表的滚动监听就绑在它上面。移动端 WebView 常见的问题是安全区域、软键盘弹出和点击延迟我这边主要做了三件事渲染容器加padding-bottom: env(safe-area-inset-bottom)代码块点击加入 300ms 延迟消除逻辑图片懒加载考虑IntersectionObserver不可用时的回退方案。最让我头疼的是桌面客户端里某些页面的 DOM 环境并不标准比如缺少完整的document.createDocumentFragment实现。后来我在适配层里加了一个环境检测发现没有 Fragment 就退回到逐节点插入。这种兼容性处理没什么技术含量全靠真机测试积累但每一处都能救一次命。5. 常见问题与排查技巧实录5.1 解析错位问题我把开发过程中踩过的解析问题整理成了一张速查表。症状根因解决方案列表项里的多行文本被拆成多个列表项没有正确处理列表项的延续行新增“列表项延续”状态只有空两行才中断引用块里嵌套代码块无法结束整个引用被当成普通文本块级解析时先处理代码块围栏再处理引用标记表格行里的竖线被行内解析误判表格内容进入了行内解析器表格单元格单独处理先分割竖线再解析行内样式标题里的 # 符号在加粗中消失标题解析优先于行内解析但没保留原文本行内解析前先恢复标题标记行内代码里的星号变成斜体行内代码保护顺序不对行内解析第一优先处理反引号闭合的代码段排查解析错位我总结出一个非常高效的方法在开发环境里开启 AST 可视化。每解析一段文本页面下半部分实时输出格式化好的 AST 树一眼就能看到某个区块被理解成了什么结构。很多问题根本不需要读代码看 AST 就知道是哪个环节理解歪了。我在 rea 里专门写了一个 debug 模式接收源文本后返回完整 AST 的 JSON 字符串配合页面上显示渲染结果的对比排查效率高了一大截。5.2 性能回退问题项目初期我遇到过一次性能翻车。当时测试了一个接近 20000 行的文档解析耗时飙到了 3 秒页面直接卡死。起初以为是解析器本身慢后来用分析工具一查发现问题出在一个递归函数上行内解析里对加粗和强调的递归调用没有做缓存导致同一段文本被反复解析多次。尤其是一大段包含十几个强调号的段落指数级增长很可怕。修复方案是增加记忆化缓存按文本字符串做 key一段文本在同一个解析周期内只解析一次。改完之后20000 行的文档降到 700ms 以内虽然这个量级依然不推荐在真实场景使用但至少不会卡死。另外一个常见性能问题是长列表的 DOM 重排。虚拟列表上线的初期滚动时每帧都要replaceChildren大量节点被频繁销毁重建低端设备依然掉帧。后来我改成增量更新只处理进入视口的节点和离开视口的节点其他节点保持不动。实测下来滚动帧率从 30fps 提升到了接近 60fps。5.3 安全与 XSS 问题Markdown 解析器必须考虑安全问题这一点很多人容易忽略。rea 渲染的是用户可控内容如果不过滤就插入页面攻击者完全可以在文档里塞一个javascript:协议的链接或者直接写一段原始 HTML 注入脚本。rea 做了三层防护。第一层链接协议白名单解析链接时检查href的协议只允许http、https、mailto其他协议一律改成无链接的纯文本。第二层原始 HTML 处理Markdown 语法允许内嵌 HTML但 rea 默认把原始 HTML 标签全部当作纯文本渲染只会原样显示代码块里的 HTML。确实需要渲染白名单标签的场景可以在渲染前调用sanitizeHtml方法只放行b、i、img这类安全标签并且过滤掉所有on*事件属性和style属性里的expression。第三层渲染结果不依赖innerHTML拼接而是通过 DOM API 创建节点、设置textContent从源头避免 HTML 字符串注入。function sanitizeUrl(url) { try { const protocol new URL(url, location.href).protocol; const allowed [http:, https:, mailto:]; return allowed.includes(protocol) ? url : ; } catch { return ; } }5.4 兼容性与诡异环境问题最后说几个兼容性的坑这些只有真机反复测才能遇到。第一个是 WebView 里老版本内核对 CSS 变量的支持不完整。我在某个低端安卓测试机上发现整个 rea 页面的颜色全部乱掉排查下来发现是那台设备的浏览器内核不支持 CSS 变量。解决方案是打了一个“变量展开”的补丁检测到环境不支持CSS.supports(--a, 0)时主动把默认变量值直接写进每个类的属性里牺牲一点灵活性换取基础显示正确。第二个是字体渲染差异。同一个字号在 Windows 桌面端和 macOS 上显示行高差异明显导致虚拟列表的偏移量计算出现累计误差。rea 的做法是在渲染完成后通过getBoundingClientRect校准一次实际行高并刷新累计偏移表。第三个是滚动容器的scrollHeight在图片懒加载过程中会变化。图片加载前后高度不同虚拟列表滚动位置会被顶开或者挤压。我最终在图片外层套了一个固定高宽比例的容器用 CSSaspect-ratio提前占位把布局偏移降到最低。这个方案虽然限制了图片展示的自由度但换来了长文档滚动的稳定性我个人认为是值得的。说实话做这类引擎最难的不是写代码而是把所有边角情况都照顾到。每个问题单拎出来都不复杂但组合在一起就是巨大的工作量。好在这些坑一旦填平后面维护起来就很省心了。目前 rea 已经在好几种环境里稳定跑了大半年我自己的体会是文本解析和渲染的能力并不神秘真正拉开差距的是对细节的较真程度。如果你也想做类似的轻量引擎我给你的第一个建议是先把解析和渲染彻底分成两层第二建议是尽早做一个能把 AST 打印出来的调试工具第三建议是别追求功能齐全满足真实需求的 80% 就足以让自己的创造非常有价值。最后再分享一个小技巧所有涉及递归的解析逻辑入参永远带上深度标记一旦超过预设阈值立刻终止这个习惯能帮你省掉无数因构造文本导致的无穷递归事故。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑