☰
自研轻量级Markdown渲染引擎:架构解析与跨端实践
2026/10/21 13:36:10 网站建设 项目流程

去年下半年我一直在折腾一个内部小项目,代号 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 行内解析的顺序与优先级

块级结构确定之后,每一块的文本内容再交给行内解析器。行内解析的核心问题是优先级顺序。我定的解析顺序是:

  1. 行内代码(反引号)
  2. 图片
  3. 链接
  4. 加粗(双星号)
  5. 删除线
  6. 强调(单星号)

这个顺序是有讲究的。行内代码里的内容必须最先被保护起来,否则代码片段里的星号会被误判成强调。图片和链接都依赖方括号,但图片前面多一个感叹号,必须优先处理图片。加粗要优先于强调,因为两个连续星号如果被拆成两组单星号,语义就错了。

技术上我用组合正则一次性匹配所有候选,然后在回调里按顺序做替换。这样比多次全量 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% 就足以让自己的创造非常有价值。最后再分享一个小技巧:所有涉及递归的解析逻辑,入参永远带上深度标记,一旦超过预设阈值立刻终止,这个习惯能帮你省掉无数因构造文本导致的无穷递归事故。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询