用 Chrome 打开一个
.md文件,你会得到什么?等宽字体的纯文本:标题和正文混在一起,表格变成一排竖线,代码块没有高亮,链接淹没在字符里。本文记录我从零实现一个 Markdown Viewer 浏览器扩展(Chrome/Edge,Manifest 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 会把纯文本页面包进 <body><pre>…</pre></body>constpre=isSinglePreBody()?document.body.firstElementChild:null;if(!pre)returnfalse;// 是完整网页,不介入if(/^\s*(<!doctype\s+html|<html[\s>])/i.test(pre.textContent.slice(0,256)))returnfalse;// 内容本身是 HTML 文档if(ct.includes('markdown'))returntrue;// text/markdownreturnct==='text/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归一成\n):
article.innerHTML=marked.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 属性类保留中日韩字符:
constslugify=s=>s.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.length>1&&h.level<=stack[stack.length-1].level)stack.pop();lettop=stack[stack.length-1];if(h.level>top.level){// 更深:在上一条目下复用或新建嵌套列表letnested=top.item?top.item.querySelector(':scope > ul'):top.list.lastElementChild?.querySelector(':scope > ul');if(!nested){constholder=top.item||top.list.appendChild(document.createElement('li'));nested=document.createElement('ul');holder.appendChild(nested);}top={level:h.level,list:nested,item:null};stack.push(top);}constitem=document.createElement('li');/* + 链接 */top.list.appendChild(item);top.item=item;}要点:同级 → 弹栈后并入已有列表;更深 → 复用或新建嵌套<ul>;跳级(h1 直接跳 h4)自然退化为一层缩进,不会崩。滚动定位反而简单:scroll事件 +requestAnimationFrame节流,找视口顶部以上最近的标题——比 IntersectionObserver 直观,也好调。
四、主题:把「跟随系统」改造成「三态可切」
内容区样式我选了 github-markdown-css,但它有个限制:暗色变量组只写在@media (prefers-color-scheme: dark)里。想给用户「浅色 / 深色 / 跟随系统」三个选项,纯 CSS 撑不住——媒体查询不接受用户意志。
改造分两步。构建期把亮/暗两组变量从媒体查询里抽出来,以属性选择器重新作用域:
html[data-mdv-theme="dark"] .markdown-body{/* 暗色变量组 */}html[data-mdv-theme="light"] .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 })。
六、扩展页与 CSP:Mermaid 能不能在 MV3 里跑
MV3 扩展页的默认 CSP 是script-src 'self'——没有unsafe-eval。选型时我专门验证过:Mermaid 10+ 重写了生成器,KaTeX、highlight.js、marked 也都不依赖eval/new Function,四个库在严格 CSP 下全部正常工作。
本地调试有个小技巧:在测试页里放一个与扩展页一致的<meta http-equiv="Content-Security-Policy" content="script-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>#</a>append 进<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、商店审核、本地隐私)下严丝合缝地咬合在一起。而这恰恰是工程最有意思的部分。