MDN 文档站的问题以及他们是如何重建的
MDN 换脸背后:一次「文档站不该长这样」的前端重建
去年 MDN 上线了新版界面,多数人第一眼看到的是样式更统一、阅读更顺手。官方后来在这篇技术长文里把底牌摊开了:真正的大手术在代码层——旧前端叫 yari,是一坨积年累月的技术债;新栈则围绕 Web Components、服务端拼装、按需加载 重写了一轮。
这不是「追新框架」的故事,而是一个站错了队形的大型站点怎么把自己掰回来的案例。下面按他们的思路捋一遍,把几处关键设计说透,并顺带聊聊:长期维护的大项目,哪些坑能躲、哪些躲不掉、又该怎么接招。
先把 MDN 当成一条流水线
文档在多个 Git 仓库里用 Markdown 维护,由作者、合作方和社区一起写、一起翻。下一步,构建工具把这些文件吃进肚里,转成带元数据的 JSON——每一页对应一份结构化数据,而不是直接当成最终网页。
再往后才是「前端」这一环:代码遍历这些 JSON,拼出带浏览器兼容表、多语言、侧栏导航等功能的完整页面。团队在文章里把这一步叫做(或自嘲为)服务端渲染(SSR):在 Node 里把页面算成 HTML,而不是让浏览器先下空壳再全靠客户端填。算完之后,HTML、CSS、JS 进云存储桶,全球 CDN 分发。
把这条链捋清很重要:正文主体在进「壳」之前就已经是静态管线产出的 HTML 了。 站点上真正「动」的地方,是复制按钮、搜索框、主题切换、交互示例那一类小块。后面所有争论——要不要整站 SPA、React 该不该包文档——其实都是在争:静态正文和交互补丁,到底谁该当一等公民。
为什么要推倒重来?不是 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 时举过一个典型痛点:用户为了渲染生命周期内根本不会变的静态内容,要先多下几十 KB(gzip 后)的库、多等一次请求,再在客户端跑一遍协调逻辑——只为确认「确实没变」。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 Components(RSC)来回应,但用好 RSC 往往意味着绑定某一类全栈框架;对 MDN 而言,为 RSC 整体换栈,约等于再来一轮大重写。
他们换了个问法:站的主体既然是静态 HTML + 局部交互,真的需要一个统领全页状态的「大应用」吗? 若所有交互都是独立的 Web Components,「谁拼页面」反而变得自由——Markdown 管线产出的 HTML 和模板层的 HTML平起平坐,不再存在「壳包不住馅、馅里没法用框架」的结构性矛盾。
于是他们在 Node 里用 Lit 的 html 模板实现了自己的 Server Component:一个带 render(context) 的类,逻辑像 Lit 组件,但只在服务端跑一次,没有客户端生命周期。导航栏这类东西可以组合子组件(如 Logo、Menu),也可以混用真正的自定义元素(如 `<mdn-search-button>`)。
样式方面,配合 Declarative Shadow DOM(DSD):在支持的浏览器里,可以在 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/2、HTTP/3 下不等于慢
传统上常说「资源合并减少请求数」;在 HTTP/2、HTTP/3 多路复用、连接复用成熟之后,多而小的文件可以并行拉取,配合缓存粒度变细,反而可能更贴合「按组件加载」的模型。团队也强调:要以实测为准(冷缓存、热缓存都要看),构建里仍留了「把小组件再合并」的杠杆,方便以后按数据调优。
Baseline:敢用新平台,又知道底线
MDN 本身参与 Baseline 相关工作,这次也用在自家站台上:把 API 分成广泛可用、新近可用、受限几档——广泛可用的直接上;新近可用的先讨论 polyfill 或渐进增强;受限的要想清楚是不是真需要。这样既不必为了保守永远停在旧世界,也不会无节制地把 polyfill 铺满地。像 DSD 就属于「能用则用、没有则降级」;个别 CSS 前沿用法(博文中提到图片上的 light-dark 扩展)则用构建期工具包一层,作者写起来简单,落地仍可控。
开发体验:从两分钟到两秒钟
旧环境冷启动本地 SPA 大约两分钟;package.json 里一长串脚本,有的为快跳过部分步骤,新人根本不知道该用哪条;改个静态资源有时要重启;默认只跑客户端 SPA,要看 SSR 表现得另跑慢命令打生产包——本地和线上形态不一致,排障像猜谜。
新栈目标很具体:**npm run start 大约 2 秒起来,基本一条命令;架构上只有「服务端拼装」这一条主路径,本地默认就接近生产组装方式,很少需要重启**,除非动构建配置本身。
构建工具换成 Rspack:Webpack 兼容的 API,核心用 Rust 写,速度差了一个数量级。配置仍有六百多行,但作者强调:少魔法、多显式,行为更可预期——这对内部工程师和外部贡献者同样重要:文档站很大一部分价值来自社区能顺畅地改内容、改站点;开发体验不是虚荣指标,是协作成本。
给长期大项目的几条实在话(展开版)
下面不抄 MDN 的清单,只把这件事抽象成可迁移的经验。
能尽量早避免的
-
框架默认值与业务形态长期不一致。 若你的站其实是「静态为主、交互是岛」,却选了一套为「富客户端整页应用」优化的脚手架,迟早走到
eject、魔改脚本、不敢升级的境地。越早承认形状不匹配,越早换叙事(不是换团队,是换架构故事),利息越少。 -
样式与组件边界模糊。 没有分层、没有约定,
CSS会像毛线团;再叠加「整站一大包」,性能和可维护性双输。MDN用目录约定 + 服务端追踪 + 全局兜底分层,本质上是用工程约束换人脑记忆。 -
壳与内容两套真相来源。 文档与
CMS产一份HTML,应用壳里再维护一套假设,若从不设计「壳如何安全地增强内容」,就会在innerHTML、手写DOM、重复实现之间来回扯。要么让增强组件离内容足够近(如自定义元素进内容),要么让壳有能力正式接管内容模型——没有第三条无痛路。 -
开发体验与生产形态脱节。 本地只跑
SPA、线上才SSR,会把水合、hydration、样式顺序等问题推到上线前。一条命令、一种组装方式听起来朴素,长期能救命。
很难完全躲掉的(不必自责)
-
技术会过期,债务会复利。 五年前合理的脚手架,今天可能是绊脚石;这不是当年的人蠢,是软件有寿命。
-
组织与协作会刻在架构里。 多仓、多团队、多发布节奏,会把「四个仓库改一个示例」这类结构固化;要动刀往往需要产品愿意给重构时间,而不是只堆功能。
-
性能与工程化没有一劳永逸。
HTTP/1时代「合并大包」的经验,在HTTP/2、HTTP/3下要重新论证;要以基准测试和真实监控说话,而不是抄一篇「最佳实践」管十年。 -
平台能力有梯度。
Baseline、polyfill、渐进增强——完美跨浏览器和用上最新能力之间永远要谈判,没有零成本全都要。
可以怎么应对
-
先对齐「站点本质」再选栈: 文档站、营销站、后台控制台、实时协作产品,对状态与网络的要求完全不同;
MDN的结论是:文档站不该用「整页SPA」的心智硬扛。 -
把交互拆成可插入的单元(他们选
Web Components),让静态流水线和交互增量解耦;迁移时分模块替换,而不是Big Bang。 -
用约定和工具把「正确做法」变成默认路径——目录规范、
lint、构建时断言,让「忘了按需加载」在流程上变难。 -
给开发者一条短路径看见真实形态(快速启动、接近生产的本地环境),降低「只有核心成员敢改」的门槛。
-
对不可避免的老化留预算:定期评估核心依赖、预留重构窗口;对平台能力用
Baseline这类坐标系做决策,而不是拍脑袋或永远不敢升级。
结语
MDN 这次重建,说到底是承认了一件事:文档站的主体是字和代码,不是应用状态机。 他们用 Web 平台原生的组件模型接住交互,用服务端模板接住布局与样式拆分,用目录约定和构建钩子接住按需加载——不是为了炫技,而是让今后的修改不必每次都在旧债上再叠一层。
你若也在维护一个活了好多年的前端,不必被「全面换新」吓到;更值得抄的是:先画清业务与技术的形状,再决定哪里该动刀——有时换一张脸是结果,把骨架和流水线摆正才是原因。官方也在文末邀请有兴趣的开发者去 Discord 的 #platform 频道交流、在 fred 仓库提 issue 或贡献代码——文档站是写给人看的,也是写给愿意一起修的人协作的。
本文主要依据 MDN 官方博文 Under the hood of MDN's new frontend 整理与解读,并结合工程实践延伸讨论。
夜雨聆风