← 全部文章
ISSUE 001 · 2026/09/02

MDX 代码块工程:分组、预览与被低估的细节

代码块是技术文章的第二作者。这篇笔记拆解墨栈的代码块体系:group 分组、多文件切换、沙箱预览、复制降级,以及那些决定体感的微小细节。

⏱ 7 分钟2095 字2026/09/02MDX前端工程化

技术文章里,代码块通常是最被低估的部分。读者会跳过你的开场白,跳过你的总结,但绝不会跳过一段好代码。它被复制、被运行、被截图发到群里——它是文章里唯一具有「生产价值」的部分。

问题是,大多数博客把代码块当成一块灰色的砖头:没有背景交代、没有文件切换、没有预览。这篇笔记记录墨栈如何把代码块从「砖头」升级成「工作台」。

代码块是文章的第二作者§

在动手之前,我先写下了三个设计目标:

  • 代码块应该知道自己是「谁」——属于哪个示例、哪个文件;
  • 多文件示例应该被合并叙述,而不是散落在文章各处;
  • 能跑的代码就在代码上方直接跑起来,刷新按钮负责清零重跑。

这三个目标最终收敛成一套写在围栏标注里的 mini DSL:~~~tsx group="tabs" file="Tabs.tsx" preview。一行标注,同时表达分组、文件名、是否可预览。

Meta DSL:一行标注胜过一百个组件属性§

语法本身很简单,三个 token 各管一件事:

语法作用说明
group="名字"分组相邻同组代码块合并为一个多文件组件
file="文件名"命名显示在 tab 上;单文件且未命名时改显示语言名
preview预览在代码上方展示沙箱,不占文件 tab

标注是声明而非指令:相邻、同组的围栏会自动合并,中间可以有空行;一旦出现正文或不同组,立刻「封口」。组里任意一块写了 preview,整组都会带上预览。

file 也可以写成 filename,preview 也可以写成 live。裸写一个带扩展名的 token(例如 store.ts)同样会被当成文件名;写了 title 且没有文件名时,标题会补上这个空位。复制不会在源码前加注释头,剪贴板里只有当前文件去掉尾部空行之后的原文。

多文件示例:一个故事,三块拼图§

切换器是一个「文件列表」的纯状态机。它不关心语言、不关心内容,也不画滑动指示条。当前项靠颜色和 aria-selected 表达;键盘用方向键循环,Home / End 跳到两端。只有当前 tab 的 tabIndex 是 0,其余是 -1,焦点不会在一排按钮里迷路。

function onTabKeyDown(event: KeyboardEvent) {
  if (event.key === "ArrowRight") {
    event.preventDefault();
    focusTab((active + 1) % files.length);
  } else if (event.key === "ArrowLeft") {
    event.preventDefault();
    focusTab((active - 1 + files.length) % files.length);
  } else if (event.key === "Home") {
    event.preventDefault();
    focusTab(0);
  } else if (event.key === "End") {
    event.preventDefault();
    focusTab(files.length - 1);
  }
}

高亮发生在构建期。resolveLang 先把 ts、html、sh 这类别名收成 Prism 认识的名字;没有语法表,或高亮抛错时,原样返回源码。

好的 DSL 不发明新概念,只是把「人本来就有的心智模型」显式化。分组对应「这是一个示例」,文件对应「这是它的零件」,预览对应「它能跑」。

相邻才算是一家人§

合并算法只认物理相邻:组名相同但中间隔着正文的两个围栏,不会被合并。这个决定牺牲了一点灵活性,换来的是可预测性——读者永远不会困惑「这个 tab 为什么跑到那么远之外」。

沙箱预览:让 HTML 活过来§

对于纯 Web 的三件套——HTML、CSS、JavaScript——「看代码」远不如「玩代码」。只有这三种语言会进沙箱:TypeScript 没有客户端运行时,写了 preview 也不会跑。HTML 文件负责骨架,CSS 插到 </head> 前,脚本插到 </body> 前;缺骨架时,组件会补一份居中的深色文档。

预览
<div class="stage">
  <button class="lamp">点亮</button>
  <p class="readout">还没有点亮</p>
</div>

预览就在代码上方,点几下按钮——这个效果就是文章要说的「体感」。右上角的刷新会换掉 iframe 的 key,把沙箱整份重挂;图标跟着转 550ms,告诉你这一下已经重跑。

沙箱的四道安全门§

预览 iframe 的 sandbox 只开了四个权限:allow-scripts、allow-forms、allow-modals、allow-popups。

  1. 脚本执行——预览需要跑 JS;
  2. 表单提交——demo 可能有表单;
  3. 模态框——alert / confirm 属于被允许的调试手段;
  4. 弹窗——window.open 可以开,但没有 allow-popups-to-escape-sandbox,也没有顶层导航权限,窗口逃不出沙箱。

没有 allow-same-origin:沙箱文档与站点不同源,即使代码里有恶意脚本也摸不到站点的 Cookie 或 DOM。拼文档时还会把源码里的 </style、</script 拆开,避免示例把自己的标签提前闭合。

复制:被低估的可用性曲线§

复制按钮的体验只有两个瞬间,但缺一不可:点击瞬间的反馈,和失败时的兜底。现代浏览器的 clipboard.writeText 需要安全上下文(HTTPS 或 localhost),部署环境一换就可能失灵——所以降级路径必须存在,而且降级本身失败时要明确返回 false,而不是让异常漏出去:

async function writeClipboard(text: string): Promise<boolean> {
  try {
    await navigator.clipboard.writeText(text);
    return true;
  } catch {
    try {
      const ta = document.createElement("textarea");
      ta.value = text;
      ta.setAttribute("readonly", "");
      ta.style.position = "fixed";
      ta.style.opacity = "0";
      document.body.appendChild(ta);
      ta.select();
      const ok = document.execCommand("copy");
      document.body.removeChild(ta);
      return ok;
    } catch {
      return false;
    }
  }
}

成功之后图标换成对勾,1.5 秒后收回。视觉反馈之外还有一条 aria-live 的「已复制」,给读屏留一句。

细节清单§

回头看,真正决定体感的是一次次不起眼的决定:

  1. 行号不进剪贴板——行号画在独立的一栏里,user-select: none,宽度跟着位数走 --cb-digits。展示和复制都会先去掉尾部空行;
  2. 方向键循环 tab——ArrowLeft / ArrowRight 在文件间绕圈,Home / End 跳到首尾;
  3. 文件类型图标——tsx / jsx 用一枚原子记号,ts、js、html、css、json、md、py、go、rs、shell 各有一块色标,认不出的扩展名退回通用文件图标;
  4. 预览重新运行——刷新在预览栏右侧,用新的 iframe key 把有状态的沙箱清零。

代码块终归是给「想偷走它的人」服务的。把复制按钮做得体面,是对他们最基本的礼貌。