乐于分享
好东西不私藏

MDN 文档站的问题以及他们是如何重建的

MDN 文档站的问题以及他们是如何重建的

MDN 换脸背后:一次「文档站不该长这样」的前端重建

去年 MDN 上线了新版界面,多数人第一眼看到的是样式更统一、阅读更顺手。官方后来在这篇技术长文里把底牌摊开了:真正的大手术在代码层——旧前端叫 yari,是一坨积年累月的技术债;新栈则围绕 Web Components、服务端拼装、按需加载 重写了一轮。

这不是「追新框架」的故事,而是一个站错了队形的大型站点怎么把自己掰回来的案例。下面按他们的思路捋一遍,把几处关键设计说透,并顺带聊聊:长期维护的大项目,哪些坑能躲、哪些躲不掉、又该怎么接招。


先把 MDN 当成一条流水线

文档在多个 Git 仓库里用 Markdown 维护,由作者、合作方和社区一起写、一起翻。下一步,构建工具把这些文件吃进肚里,转成带元数据的 JSON——每一页对应一份结构化数据,而不是直接当成最终网页。

再往后才是「前端」这一环:代码遍历这些 JSON,拼出带浏览器兼容表、多语言、侧栏导航等功能的完整页面。团队在文章里把这一步叫做(或自嘲为)服务端渲染SSR):在 Node 里把页面算成 HTML,而不是让浏览器先下空壳再全靠客户端填。算完之后,HTMLCSSJS 进云存储桶,全球 CDN 分发。

把这条链捋清很重要:正文主体在进「壳」之前就已经是静态管线产出的 HTML 了。 站点上真正「动」的地方,是复制按钮、搜索框、主题切换、交互示例那一类小块。后面所有争论——要不要整站 SPAReact 该不该包文档——其实都是在争:静态正文和交互补丁,到底谁该当一等公民。


为什么要推倒重来?不是 React 不行,是「套娃」套错了

从 CRA 到 eject:第一个妥协会繁殖

旧前端是 Create React App 起家的 React 应用。CRA 给的是一套默认假设:目录结构、开发服务器、打包方式都按「典型单页应用」来。MDN 的真实需求——海量静态文档、多语言、与内容仓库紧耦合的构建——和这些默认并不对齐,于是只能一路 **eject**,把配置从脚手架里「抠」出来自己改。

结果是:极度复杂的 Webpack 配置,外加一堆为了绕过默认行为而写的脚本。这种事做过后端或老前端的都熟:第一个妥协会繁殖出下一串妥协;等你想收手时,已经没人敢动那坨配置了。

CSS:两种方言、没有边界、整包下发

样式侧同样失控。团队大量用 Sass,后来又引入现代 CSS 变量,两套习惯混在同一批文件里,读起来像两种方言掺着讲。更麻烦的是作用域弱或几乎没有:改一个组件的样式,经常在别的页面看到「顺手被改了」的副作用。构建工具也帮不上忙——样式缠在一起,没法按组件拆包,最后只能给所有访问者塞一大坨**阻塞渲染的 CSS**,其中不少对应的是当前页面根本不会出现的组件。

这不是「审美问题」,是变更成本高 + 性能浪费叠在一起。

结构性矛盾:React 壳套静态馅

真正要命的是这一层:React 应用本质上只是静态文档外面的一层壳。正文 HTML 由另一条管线生成;若要让 React「理解」并接管这些 DOM,就得在客户端反复解析 HTML、塞进大量逻辑——团队没有走这条路,于是边界只能落在 dangerouslySetInnerHTML文档以内,React 基本不碰。

但文档里又必须加交互:代码块旁的复制、折叠、嵌入的交互课等。壳子包不住馅,就只能在壳外用原生 DOM API 手写。一边是 React + JSX 的组织方式,一边是字符串插进去再手动绑事件,复杂一点的交互没法统一表达,严重时会出现两套实现(同一类能力,React 一份、DOM 一份),维护的人知道这意味着什么。

SPA 税:静态内容为什么要付 JS 运费?

React 文档里讲 Server Components 时举过一个典型痛点:用户为了渲染生命周期内根本不会变的静态内容,要先多下几十 KBgzip 后)的库、多等一次请求,再在客户端跑一遍协调逻辑——只为确认「确实没变」。MDN 上的绝大多数篇幅正是这类内容。

所以重建动机可以概括得很直白:用整页 SPA 的心智去服务「以静态文档为主、交互是零星岛屿」的站,长期一定会拧巴。 每修一个功能都可能叠一层债,不是人不努力,是架构形状和内容形状不匹配


他们怎么走出来的:Web Components 当「插件」,服务端当「组装车间」

先在小场景验证:Lit + Web Components

2024 年起,团队用 Lit 和 Web Components 做实验。第一个像样的落地是课程里嵌入 Scrimba 的交互课Scrims:可嵌入的交互编程小课)。

需求其实很具体:在用户点击之前,不要把用户数据发给第三方——所以 iframe 必须懒加载;又要能全屏、不跳出 MDN,于是用上原生 <dialog>。这类能力做成一个自定义元素(如 <scrim-inline>),写进内容里就像插组件,而不是在全局再挂一坨不优雅的 DOM 脚本。

Lit 这边:模板写法接近 JSX 的顺手,带事件绑定(如 @click)、状态更新和重绘;但本质是**原生 JavaScript**,不需要为「在文档里长出一小块 UI」单独背一整套客户端框架运行时。对「馅里的交互」来说,这比硬写 DOM API 可维护得多。

交互示例:从四个仓库收束到「内容里长出来」

更棘手的是文档页顶部的「Try it」交互示例。旧实现拆在四个 Git 仓库里:改一个例子可能要跨仓同步;示例在独立环境里写,没法和最终 MDN 页面一起预览,作者调试成本很高。根因是:这些示例复杂到不适合用裸 DOM API 维护,于是历史上单独搞了一套构建和示例仓库,用 iframe 加载另一套生成好的 HTML

新方向是:用 Web Components 让能力直接长在内容侧,并把已有 Playground(在线试代码)里的渲染逻辑从 React 迁到自定义元素上。这里有个务实点:Lit 提供 React 集成,可以在旧壳里按块替换,而不必「停站三个月一次性重写」。编辑器、控制台、多标签模板等被拆成多个自定义元素后,Playground 和正文里的交互示例可以复用同一套积木

作者在 Markdown 里通过宏 + 带标记的代码块描述示例;构建侧再把这些变成页面上的自定义元素。团队也坦言:要不要在非课程正文里直接写自定义元素,内部还在讨论——但方向已经清晰:作者离最终页面越近,协作成本越低。

「我们的 Server Components」:不是 Next 那套

经典 SPA 的痛点前面说过:大量本可静态交付的东西,仍要打进客户端 bundle,只为在浏览器里再跑一遍协调。React 用 Server ComponentsRSC)来回应,但用好 RSC 往往意味着绑定某一类全栈框架;对 MDN 而言,为 RSC 整体换栈,约等于再来一轮大重写

他们换了个问法:站的主体既然是静态 HTML + 局部交互,真的需要一个统领全页状态的「大应用」吗? 若所有交互都是独立的 Web Components,「谁拼页面」反而变得自由——Markdown 管线产出的 HTML 和模板层的 HTML平起平坐,不再存在「壳包不住馅、馅里没法用框架」的结构性矛盾。

于是他们在 Node 里用 Lit 的 html 模板实现了自己的 Server Component:一个带 render(context) 的类,逻辑像 Lit 组件,但只在服务端跑一次,没有客户端生命周期。导航栏这类东西可以组合子组件(如 LogoMenu),也可以混用真正的自定义元素(如 `<mdn-search-button>`)。

样式方面,配合 Declarative Shadow DOMDSD:在支持的浏览器里,可以在 HTML 里就把影子树和样式声明好,让样式尽量在交互 JS 到达前就位,减轻闪动和布局抖动。这和 React 的 RSC 不是同一产品,但目标一致:别把静态渲染的税交到用户下载的 JS 上。


只送当前页要的东西:约定大于自觉

旧站的 bundle 困境

旧站不是完全没拆包:JS 还能按路由切 chunk。但 CSS 缠成一团,同一页用不到的样式也很难甩掉;更烦的是,有些组件只为 SSR 存在,却**仍可能被编进客户端 bundle**——只要路由「有可能」用到,保守策略就会让你多背一截代码。

扁平目录:每个组件一个小文件夹

新架构用扁平的组件目录(在 fred 仓库的 components/ 下),文件名约定死角色,例如:

  • element.js:自定义元素(如 <mdn-xxx>
  • server.js:服务端组件
  • server.css:仅当该服务端组件被渲染时才应加载的样式
  • global.css:全站或兜底场景需要的样式

一部分靠 lint,一部分在构建时不符合就报错,减少「随手写全局样式」的路径依赖。

客户端:按标签名懒加载 mdn-*

页面加载后,脚本会扫一遍 DOM 里出现的标签:凡是 mdn- 开头的自定义元素,就按名字去 import 对应的 element.js。这意味着:

  • 服务端组件里不用手动 import 每个Web 组件,当普通标签写即可;
  • 内容 Markdown 里通过宏插入的元素,也不必在别处登记导出;
  • 页面上没出现的组件,对应的 JS 根本不会加载——工程师不必靠记忆做「会不会增大包体」的心算。

各组件 JS并行、异步加载;更新其中一个,浏览器可只失效这一块缓存,其余仍走缓存——这对回访用户很友好。

DSD + 渐进增强:<mdn-dropdown> 的例子

博文里用 <mdn-dropdown> 举了一个渐进增强的细节:用 slot 插内容,影子 DOM 几乎不抢文档流里的样式;再通过 firstUpdated 把 loaded 设为 true 并反映到属性上,配合一段 :host 选择器——在 JS 未就绪时,用 CSS 先保证键盘可达、原生下拉可用JS 到位后再增强行为。顶导航大量链接藏在下拉后面:这意味着首屏即可用,不必等一大包 JS 解析完才能点菜单。

没有 DSD 的老环境,则靠 global.css 给 mdn-button 等写最小占位样式,减轻「空标签闪一下」的布局偏移;这是平台能力与工程兜底之间的诚实交易。

服务端:只给真的渲染过的组件挂 CSS

服务端组件基类在静态 render 前后打点,记录哪些组件这次真的渲染出了内容(空渲染会回滚登记)。最外层布局再据此拼 <head> 里的 <link>只加载本页用到的 server.css,外加打包好的全局 global 样式。这和客户端「按元素懒加载 JS」是同一哲学:默认路径就是按需,而不是靠自觉。

性能观:多文件在 HTTP/2HTTP/3 下不等于慢

传统上常说「资源合并减少请求数」;在 HTTP/2HTTP/3 多路复用、连接复用成熟之后,多而小的文件可以并行拉取,配合缓存粒度变细,反而可能更贴合「按组件加载」的模型。团队也强调:要以实测为准(冷缓存、热缓存都要看),构建里仍留了「把小组件再合并」的杠杆,方便以后按数据调优。

Baseline:敢用新平台,又知道底线

MDN 本身参与 Baseline 相关工作,这次也用在自家站台上:把 API 分成广泛可用新近可用受限几档——广泛可用的直接上;新近可用的先讨论 polyfill 或渐进增强;受限的要想清楚是不是真需要。这样既不必为了保守永远停在旧世界,也不会无节制地把 polyfill 铺满地。像 DSD 就属于「能用则用、没有则降级」;个别 CSS 前沿用法(博文中提到图片上的 light-dark 扩展)则用构建期工具包一层,作者写起来简单,落地仍可控。


开发体验:从两分钟到两秒钟

旧环境冷启动本地 SPA 大约两分钟package.json 里一长串脚本,有的为快跳过部分步骤,新人根本不知道该用哪条;改个静态资源有时要重启;默认只跑客户端 SPA,要看 SSR 表现得另跑慢命令打生产包——本地和线上形态不一致,排障像猜谜。

新栈目标很具体:**npm run start 大约 2 秒起来,基本一条命令;架构上只有「服务端拼装」这一条主路径,本地默认就接近生产组装方式,很少需要重启**,除非动构建配置本身。

构建工具换成 RspackWebpack 兼容的 API,核心用 Rust 写,速度差了一个数量级。配置仍有六百多行,但作者强调:少魔法、多显式,行为更可预期——这对内部工程师外部贡献者同样重要:文档站很大一部分价值来自社区能顺畅地改内容、改站点;开发体验不是虚荣指标,是协作成本。


给长期大项目的几条实在话(展开版)

下面不抄 MDN 的清单,只把这件事抽象成可迁移的经验。

能尽量早避免的

  1. 框架默认值与业务形态长期不一致。 若你的站其实是「静态为主、交互是岛」,却选了一套为「富客户端整页应用」优化的脚手架,迟早走到 eject、魔改脚本、不敢升级的境地。越早承认形状不匹配,越早换叙事(不是换团队,是换架构故事),利息越少。

  2. 样式与组件边界模糊。 没有分层、没有约定,CSS 会像毛线团;再叠加「整站一大包」,性能和可维护性双输。MDN 用目录约定 + 服务端追踪 + 全局兜底分层,本质上是用工程约束换人脑记忆

  3. 壳与内容两套真相来源。 文档与 CMS 产一份 HTML,应用壳里再维护一套假设,若从不设计「壳如何安全地增强内容」,就会在 innerHTML、手写 DOM、重复实现之间来回扯。要么让增强组件离内容足够近(如自定义元素进内容),要么让壳有能力正式接管内容模型——没有第三条无痛路。

  4. 开发体验与生产形态脱节。 本地只跑 SPA、线上才 SSR,会把水合、hydration、样式顺序等问题推到上线前。一条命令、一种组装方式听起来朴素,长期能救命。

很难完全躲掉的(不必自责)

  1. 技术会过期,债务会复利。 五年前合理的脚手架,今天可能是绊脚石;这不是当年的人蠢,是软件有寿命

  2. 组织与协作会刻在架构里。 多仓、多团队、多发布节奏,会把「四个仓库改一个示例」这类结构固化;要动刀往往需要产品愿意给重构时间,而不是只堆功能。

  3. 性能与工程化没有一劳永逸。HTTP/1 时代「合并大包」的经验,在 HTTP/2HTTP/3 下要重新论证;要以基准测试和真实监控说话,而不是抄一篇「最佳实践」管十年。

  4. 平台能力有梯度。Baselinepolyfill、渐进增强——完美跨浏览器用上最新能力之间永远要谈判,没有零成本全都要。

可以怎么应对

  • 先对齐「站点本质」再选栈: 文档站、营销站、后台控制台、实时协作产品,对状态与网络的要求完全不同;MDN 的结论是:文档站不该用「整页 SPA」的心智硬扛。

  • 把交互拆成可插入的单元(他们选 Web Components),让静态流水线和交互增量解耦;迁移时分模块替换,而不是 Big Bang

  • 用约定和工具把「正确做法」变成默认路径——目录规范、lint、构建时断言,让「忘了按需加载」在流程上变难。

  • 给开发者一条短路径看见真实形态(快速启动、接近生产的本地环境),降低「只有核心成员敢改」的门槛。

  • 对不可避免的老化留预算:定期评估核心依赖、预留重构窗口;对平台能力用 Baseline 这类坐标系做决策,而不是拍脑袋或永远不敢升级。


结语

MDN 这次重建,说到底是承认了一件事:文档站的主体是字和代码,不是应用状态机。 他们用 Web 平台原生的组件模型接住交互,用服务端模板接住布局与样式拆分,用目录约定和构建钩子接住按需加载——不是为了炫技,而是让今后的修改不必每次都在旧债上再叠一层

你若也在维护一个活了好多年的前端,不必被「全面换新」吓到;更值得抄的是:先画清业务与技术的形状,再决定哪里该动刀——有时换一张脸是结果,把骨架和流水线摆正才是原因。官方也在文末邀请有兴趣的开发者去 Discord 的 #platform 频道交流、在 fred 仓库提 issue 或贡献代码——文档站是写给人看的,也是写给愿意一起修的人协作的。


本文主要依据 MDN 官方博文 Under the hood of MDN's new frontend 整理与解读,并结合工程实践延伸讨论。