在浏览器里优雅地阅读 Markdown:一个 Viewer 扩展的完整实现
发布时间:2026/9/28 20:08:03 作者:尧图编辑部 阅读量:1,286

用 Chrome 打开一个.md文件你会得到什么等宽字体的纯文本标题和正文混在一起表格变成一排竖线代码块没有高亮链接淹没在字符里。本文记录我从零实现一个 Markdown Viewer 浏览器扩展Chrome/EdgeManifest V3的完整过程架构决策、渲染管线、主题系统、本地文件夹工作区以及一路上踩过的坑——每个坑都真实发生过。一、架构3.8MB 的渲染栈不能注入所有页面功能清单决定了依赖GFM 解析marked、代码高亮highlight.js、公式KaTeX 及其字体、图表Mermaid打包后约 3.8MB。最直接的做法是在 manifest 里声明content_scripts: { matches: [all_urls] }把渲染脚本全部注入。但这意味着用户打开的每一个网页都要付出 3.8MB 脚本的解析成本即使它和 Markdown 毫无关系。解法是把「检测」和「渲染」拆成两段打开 .md 文件 │ ▼ detector.js ~2KB无依赖注入所有页面只做检测 │ 命中 → sendMessage ▼ background.js MV3 Service Worker事件驱动不常驻 │ chrome.scripting 按需注入渲染栈 CSS ▼ content.js marked → 清理 → 高亮 → KaTeX → Mermaid → 锚点/目录探测器的判定规则是整个架构里最需要打磨的部分functiondetect(){constct(document.contentType||).toLowerCase();// Chrome 会把纯文本页面包进 bodypre…/pre/bodyconstpreisSinglePreBody()?document.body.firstElementChild:null;if(!pre)returnfalse;// 是完整网页不介入if(/^\s*(!doctype\shtml|html[\s])/i.test(pre.textContent.slice(0,256)))returnfalse;// 内容本身是 HTML 文档if(ct.includes(markdown))returntrue;// text/markdownreturncttext/plain/\.(md|markdown|mdown|mkd)$/i.test(location.pathname);}三条经验document.contentType是最好用的信号。服务器返回text/html的.mdURL比如 GitHub 上浏览 README 的页面会被直接排除——那是一个完整的 Web 应用把它的body.textContent抓来渲染结果是灾难。「body 是单一pre」是 Chrome 展示纯文本的标志结构比猜测 MIME 可靠得多。内容以!DOCTYPE或html开头的不碰——那是 HTML 文档被当作文本显示了把它当 Markdown 渲染反而帮倒忙。这套「极小探测器 按需注入」的组合让普通网页的额外成本趋近于零同时保留了「打开即渲染」的体验。二、渲染管线顺序敏感的五道工序内容脚本拿到原始文本后pre.textContent顺手把\r\n归一成\narticle.innerHTMLmarked.parse(raw,{gfm:true});sanitize(article);// 1. 去 script、on* 属性、javascript: 链接highlightCode(article);// 2. highlight.js跳过 mermaid 块renderMath(article);// 3. KaTeX auto-renderawaitrenderDiagrams(article,theme);// 4. MermaidaddHeadingAnchors(article);// 5. GitHub 风格标题锚点buildToc();两个顺序上的细节都是踩出来的KaTeX 必须在 Mermaid 替换之前跑。auto-render 的ignoredTags包含pre/code此时 Mermaid 源码还躺在code里天然免疫一旦先把 Mermaid 块替换成div图表脚本里的$符号就可能被公式引擎啃掉。sanitize 放在最先后面所有工序处理的都是干净 DOM。过滤策略取中庸删script/style/on*事件属性和javascript:链接保留details、表格这类文档里真正有用的 HTML——毕竟这是阅读器不是沙箱演示。另一个细节新版 marked 移除了headerIds标题 id 得自己生成。顺便就实现了 GitHub 风格的 slug——小写、去标点、空格转连字符、重复标题追加-1/-2用 Unicode 属性类保留中日韩字符constslugifyss.toLowerCase().trim().replace(/[^\p{L}\p{N}\-_ ]/gu,).replace(//g,-);于是## 数学公式的锚点就是#数学公式中文文档的目录链接和 GitHub 上一样自然。三、目录看似简单实则容易写死循环构建嵌套目录的朴素思路是「维护当前容器遇到更深的层级就下钻」。我第一版就是这么写的然后在「回到同级」时用closest(ul)回溯——它把容器指回了自己while循环永远退不出来。可靠的写法是显式栈conststack[{level:0,list:rootList,item:null}];for(consthofheadings){// 同级或更浅弹栈while(stack.length1h.levelstack[stack.length-1].level)stack.pop();lettopstack[stack.length-1];if(h.leveltop.level){// 更深在上一条目下复用或新建嵌套列表letnestedtop.item?top.item.querySelector(:scope ul):top.list.lastElementChild?.querySelector(:scope ul);if(!nested){constholdertop.item||top.list.appendChild(document.createElement(li));nesteddocument.createElement(ul);holder.appendChild(nested);}top{level:h.level,list:nested,item:null};stack.push(top);}constitemdocument.createElement(li);/* 链接 */top.list.appendChild(item);top.itemitem;}要点同级 → 弹栈后并入已有列表更深 → 复用或新建嵌套ul跳级h1 直接跳 h4自然退化为一层缩进不会崩。滚动定位反而简单scroll事件 requestAnimationFrame节流找视口顶部以上最近的标题——比 IntersectionObserver 直观也好调。四、主题把「跟随系统」改造成「三态可切」内容区样式我选了 github-markdown-css但它有个限制暗色变量组只写在media (prefers-color-scheme: dark)里。想给用户「浅色 / 深色 / 跟随系统」三个选项纯 CSS 撑不住——媒体查询不接受用户意志。改造分两步。构建期把亮/暗两组变量从媒体查询里抽出来以属性选择器重新作用域html[data-mdv-themedark] .markdown-body{/* 暗色变量组 */}html[data-mdv-themelight] .markdown-body{/* 亮色变量组 */}属性选择器的优先级高于媒体查询里的.markdown-body所以「系统深色 用户强制浅色」时后者稳定获胜。highlight.js 的两套配色同样处理把压缩的 CSS 按}拆行统一加前缀即可。运行期由 JS 把「auto」解析成具体的 light/dark并监听matchMedia变化。属性驱动一切代码高亮、公式、界面壳全部跟随同一个属性。切换主题时我做了一次整页重渲染。看起来浪费其实是对的选择Mermaid 的 SVG 颜色是渲染时烧进去的重渲染是让图表同步换肤的最短路径——代码上只是再调一次renderInto()。五、工作区File System Access API 的一次完整实战单文件渲染是及格线「打开文件夹 → 左侧文件树 → 点开任意文档」才是它成为日常工具的分水岭。浏览器为此提供了现成的能力showDirectoryPicker({ mode: read })拿到目录句柄entries()异步迭代递归扫描。跳过.git、node_modules和隐藏目录限制 3000 个文件 / 8 层深度单个条目读取失败就跳过并记录绝不让整个扫描中断——OneDrive 按需占位文件、受限目录都可能抛错。句柄支持结构化克隆存进 IndexedDB 就有了「最近打开」下次使用时handle.requestPermission()重新授权必须在用户手势里调用。拖拽导入webkitGetAsEntry()拿到的 Entry包装成和真实句柄同构的{ kind, name, entries() / getFile() }。三种来源——选择器、最近记录、拖拽——共用同一套扫描代码这是本次设计里性价比最高的抽象。相对路径是工作区的灵魂。渲染后对a[href]、img[src]做一次后处理按「当前文件所在目录」解析相对引用——.md链接改写为工作区内跳转点击 →getFile()→ 切换渲染图片改写成 blob URL 显示解析不到的加删除线样式并说明原因而不是留一个点了没反应的链接。这里有个非常隐蔽的坑new File([blob], name)不会继承 MIME 类型构造出的 File 类型为空blob URL 的 Content-Type 也是空——SVG 这种对 MIME 严格的格式直接裂图位图反而常常没事更具迷惑性。正确写法是new File([blob], name, { type: blob.type })。六、扩展页与 CSPMermaid 能不能在 MV3 里跑MV3 扩展页的默认 CSP 是script-src self——没有unsafe-eval。选型时我专门验证过Mermaid 10 重写了生成器KaTeX、highlight.js、marked 也都不依赖eval/new Function四个库在严格 CSP 下全部正常工作。本地调试有个小技巧在测试页里放一个与扩展页一致的meta http-equivContent-Security-Policy contentscript-src self用一个普通的 http 静态服务器就能等价验证 CSP 行为——不用每次改完代码都去chrome://extensions刷新扩展。进一步地把演示文档转义后内嵌进测试页的pre内容脚本就能「无扩展运行」整条渲染管线在浏览器里即开即测。七、踩坑实录那些「没反应」的时刻1. 未声明变量 静默 catch 用户授权后毫无反应。工作区代码里给wsLabel赋值但漏了let声明严格模式下直接 ReferenceError而调用链上的try/catch只写了console.error。用户看到的现象是授权弹窗点「允许」然后——什么都没发生。这条 bug 教会我一件事扩展页面里的任何失败都必须有可见反馈。现在页面底部有一个全局错误条unhandledrejection和error都会兜底显示console是给开发者看的不是给用户看的。2. 重建渲染壳抹掉了别人的状态。buildShell()里一句body.className mdv-active把工作区页面挂在body上的展开状态类整个抹掉布局当场错乱。教训重置一个共享节点之前先想想「有没有别人往它身上挂过东西」。3. 目录首屏空白。buildToc()函数写好了、测试了但忘了在渲染管线里调用。单元思维只覆盖了「零件」没覆盖「接线」——管线式代码里每加一个零件都要检查它是否真的被串进流水线。4. 页面标题多了个#。标题锚点是把a#/aappend 进h1的之后取textContent当文章标题锚点字符也跟着进来了。生成 DOM 的副作用总会以意想不到的方式还回来。八、发布商店审核的三件事注册开发者账号一次性 5 美元包体必须manifest.json在 zip 根层。打包有个平台坑Windows PowerShell 的Compress-Archive生成的 zip 条目用反斜杠分隔Chrome Web Store 上传器会解析失败。要用ZipArchive逐条目写入并强制/分隔。权限理由要经得起追问。扩展申请了all_urls主机权限和scripting理由是用户可能从任意域名打开 Markdown 文件代码托管平台的 raw 链接、网盘直链、内网文档系统、本地file://无法预知来源域名而探测器只有 2KB对普通网页不注入、不修改、不读取。宽泛权限会触发人工审核通常 1–4 周如果被拒备选方案是optional_host_permissions 运行时请求授权代价是牺牲一点开箱即用。结语回头看这个扩展的骨架可以浓缩成四句话探测器极小化2KB 注入所有页面普通网页零成本注入按需化重活留给命中后的那一个标签页渲染管线顺序化marked → 清理 → 高亮 → 公式 → 图表顺序本身就是设计失败可见化用户不该为「没反应」买单。剩下的——KaTeX 的字体、Mermaid 的图、GitHub 风格的排版——都是站在优秀开源库肩膀上的组装工作。真正花心思的是让这些零件在一套严格的约束CSP、MV3、商店审核、本地隐私下严丝合缝地咬合在一起。而这恰恰是工程最有意思的部分。