为什么这个站没有 JavaScript
零脚本、零外链资源,CSP 才能收紧到 default-src 'none',不需要任何信任清单。文中给出用纯 CSS 替代脚本的具体做法、可审计性的收益、会失去的能力与替代方案,并说明 JSON-LD 这类 data block 为何不算脚本。
从一行响应头开始
站点在 nginx 里下发的 CSP 是这样的:
Content-Security-Policy: default-src 'none'; style-src 'self'; img-src 'self'; font-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'
default-src 'none' 只有在没有任何子资源需要放行时才写得出来。它不是"把 CSP 调得更严"的结果,而是页面形态定下来之后顺带写下的结论。所以真正的问题不是"要不要用 JavaScript",而是"页面到底需要从外部拿什么"。答案是:什么都不需要。脚本于是自然没有位置。
过去必须写脚本的事,现在 CSS 就能做
这个站是文本为主的静态内容站:文章、目录、标签、归档。这类页面需要的全部"能力"只有五项,而它们现在都属于 CSS 的职责范围。
跟随系统的深浅色。 用媒体查询翻转一组自定义属性,其余样式只引用变量:
:root {
--bg: #ffffff;
--fg: #1f2328;
--surface: #f6f8fa;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d1117;
--fg: #e6edf3;
--surface: #151b23;
}
}
这套写法还有一个副作用值得注意:没有主题闪烁。过去用脚本读 localStorage 再切主题的站,必须先渲染一次默认主题、再执行脚本覆盖,用户会看到白闪或者黑闪;prefers-color-scheme 在样式解析阶段就生效,不存在这个中间态。
流式字号与间距。 clamp() 让排版随视口连续变化,而不是断点跳变:
body { font-size: clamp(16px, 1.05vw + 12px, 17.5px); }
h1 { font-size: clamp(1.5rem, 3vw + .6rem, 1.95rem); }
main { padding-inline: clamp(1rem, 4vw, 1.5rem); }
同一个句型也用在容器内边距上(clamp(1rem, 4vw, 1.5rem))。这曾经是 resize 事件加一段计算逻辑的活,现在属于排版引擎本身。
表格斑马纹。 不需要在构建期算两套颜色,直接混合已有变量:
tbody tr:nth-child(even) {
background: color-mix(in srgb, var(--surface) 55%, transparent);
}
好处是深浅两套变量各自混合一次,两边自动都成立;如果靠构建期写死颜色,深浅色就成了两份颜色表。
平滑滚动。 一行声明,外加一条可访问性回退:
html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
}
第二段很关键。用脚本实现平滑滚动时,绝大多数实现不会去读 prefers-reduced-motion;用 CSS 实现时,把这条偏好写回去只是相邻三行的事。
目录与打印。 文章目录由构建器在 Markdown 阶段生成锚点列表写进 HTML,点击是原生 #fragment 跳转,scroll-behavior 负责滚动动画。打印样式则用 @media print 隐藏导航、页脚、目录和返回链接,把正文改成 11pt:
@media print {
header.site nav, footer.site, nav.toc, p.back { display: none; }
body { font-size: 11pt; }
a { color: inherit; }
}
对照一下这几件事的前后写法:
| 能力 | 曾经的脚本做法 | 现在的做法 |
|---|---|---|
| 深浅色 | 读存储 + 切换 class | prefers-color-scheme + CSS 变量 |
| 流式排版 | resize 监听 + 计算 |
clamp() |
| 斑马纹 | 构建期算色 / 运行时写样式 | color-mix() |
| 平滑滚动 | 自写滚动动画 | scroll-behavior: smooth |
| 目录跳转 | 事件委托 + scrollIntoView |
静态锚点 |
零子资源,CSP 才可能是白名单的反面
default-src 'self' 和 default-src 'none' 的差别常被描述成"严格程度",这个说法不准确。前者的语义是:同源被默认信任,只有跨源才需要列举;后者是:默认什么都不信任,每一类资源都必须显式列出。
所以真正的分界线不是严不严,而是你要不要维护一张信任清单。清单意味着两件事:一是它的每一项都是你主动做出的决定,二是它的每一项都可能在你不知情时改变。
| 新增的东西 | 必须写进 CSP 的指令 | 信任的对象 |
|---|---|---|
| 无(当前状态) | 只有 style-src 'self'; img-src 'self' 这类本地指令 |
无第三方 |
| 第三方统计脚本 | script-src https://example.com |
该域当天提供的代码 |
| 外链字体 | font-src https://example.com,通常还连带 style-src |
同上 |
| 评论组件 | script-src + frame-src + 其内部再拉的一切 |
递归的一串域 |
第三方字体那一栏特别容易被低估:字体文件本身要放行 font-src,而 @font-face 通常写在第三方 CSS 里,于是 style-src 也得连带放宽。清单就是这样一项带一项地长出来的。
而这个站不需要这份清单。nginx 配置里留了一句注释,意思是:如果哪天加了脚本或外链字体,这份 CSP 会直接把页面打坏——那不是配置该放宽的信号,而是那个新增的东西本身要先被质疑。把策略收紧到"加了坏东西就会坏",比"悄悄放宽也能跑"要好得多,因为失败是显式的。
可审计性:view-source 就是全部
零脚本带来的第二个性质是:页面的完整行为一次就能看完,而且可以机械地看完。构建产出里做一次子资源清点,输出是封闭的:
grep -hoE '(src|href)="/[^"]+"' public/**/*.html | sort -u
href="/apple-touch-icon.png"
href="/assets/style.css"
href="/favicon.svg"
href="/feed.xml"
全部同源、全部本地文件。头部里剩下的绝对 URL 只有 rel="canonical"、Open Graph 与 sitemap/feed 里的站点自身地址,它们是元数据,是给爬虫读的字符串,不会作为子资源被请求。再确认一遍没有脚本:
grep -r '<script' public/ | wc -l
0
这两条命令替代了以往一整套审计流程:不用开抓包看第三方脚本发了什么请求,不用读它混淆后的源码,不用判断它这周的版本有没有多上传一个字段。审计结论还具有传递性:零脚本站的审计结果,在下一次自己改动之前一直有效;引入第三方脚本的站,审计结论在对方下一次发版时就过期了——因为你审的不是自己的代码,而是一个会变的远端。
供应链的边界也因此清楚。第三方脚本与页面同源执行(除非另加 sandbox 或 CSP 限制),它读得到页面内容、发得出请求、改得动 DOM。零脚本不是"少一个功能",而是把这类信任关系整体去掉了。
性能与能耗
性能部分是机理层面的结论,这里不给出量化数字——本机没有做过对照测量,以下都是可解释的预期差异,不是实测结果。
- 无解析与执行开销。 浏览器拿到 HTML 和一张样式表就可以出首屏,没有脚本的下载、解析、编译、执行这四步。
- 无需等 hydration。 首屏渲染出的 DOM 就是最终 DOM,不存在"先用占位结构渲染、再由脚本接管"的中间态,也就没有由接管引发的重排与位移。
- 渲染阻塞源只有一个,且是本地文件。 唯一的阻塞资源是
/assets/style.css,同源、可缓存、可复用已有连接。 - 没有字体下载。 字体栈全部是系统字体(
-apple-system、Segoe UI、Noto Sans CJK SC等),因此没有字体文件请求,也没有等待字体期间的不可见文本或字形替换引发的布局抖动。 - 没有第三方域名的网络往返。 省掉的是 DNS 查询与 TLS 握手;在移动端,这类"短连接"对能耗的影响往往比传输字节数更大。
这些差异在低端设备与慢网络上尤其明显。
代价:失去什么,用什么替代
诚实地说,零脚本确实丢掉了若干能力。
| 失去的能力 | 替代方案 |
|---|---|
| 客户端搜索 | 构建期生成的全量文章索引页(列表 / 标签页),或交给搜索引擎做站内 site: 查询 |
| 评论 | 省掉。个人技术笔记的反馈不值得引入一个第三方域 |
| 图片懒加载 | 用 HTML 的 loading="lazy" 属性即可,它不需要脚本 |
| 可折叠目录、标签页等交互组件 | 直接展开成静态列表;代价是页面长一点 |
| 手动主题切换 | 只跟随系统偏好,接受这一限制 |
| 访问统计 | 放弃,或退回到看服务器访问日志 |
其中"目录展开"这一项最值得说明:本站的文章目录是构建期渲染好的完整锚点列表,不做折叠、也不高亮当前章节。这是有意的取舍——折叠与高亮都需要脚本读取滚动位置。
取舍原则可以落成三个问题,加一个功能之前先过一遍:
- 这个功能是不是站点的目的本身?
- 它会不会让 CSP 从
none变成一张需要维护的信任清单? - 加上它之后,
view-source还能不能一次看完?
三个问题里有两个是否定,这个功能就不加。核心判断是:不要为了一个小功能,把整站的可审计性交出去。 一个搜索框换掉的是"任何人五分钟内能确认这个页面没在偷偷上传数据"这件事,这笔交易通常不划算。
一个边界情况:JSON-LD 不是脚本
结构化数据用 <script type="application/ld+json"> 承载,看起来与"零 JS"矛盾,其实不然。
这个标签属于 data block:据规范,它的内容不会被当作代码执行,只作为数据被读取。因此它落在 CSP 的 script-src 管辖范围之外——在 default-src 'none' 的策略下也能正常提供结构化数据。这就是"零脚本"和"有 SEO 元数据"可以并存的原因。
两点保留意见。第一,它仍然是 <script> 元素,靠 type 区分用途;type 写错就变成真脚本,届时 CSP 会拒绝它(而这正是你希望发生的)。第二,上面的说法依据的是规范文本对 data block 的定义,不等同于每个浏览器引擎在所有细节上的一致实现;这里引用它只是为了说明 script-src 管的是"执行",不是"标签名"。本站目前的产出还没有这类块,若将来要加,也不需要为此放宽 CSP。
判断依据
把一个页面需要的能力分成两栏来问,答案通常就出来了:
- 这个能力能不能用声明式的 CSS / HTML 表达(偏好、自适应、动效、懒加载、打印)?能,就不需要脚本。
- 这个能力是不是必须跑代码(搜索、评论、实时协作)?是,就先写出它会给 CSP 增加的那一行,再决定值不值。
最后一条底线判据:如果引入某个东西之后,那份 Content-Security-Policy 从一行变成了一个域名列表,那么这个决定就不只是"加个功能",而是"新增一批需要长期信任的外部代码"。按后者的标准来审,很多功能会自己出局。