Chips

为什么这个站没有 JavaScript

· 约 8 分钟 · 2788 字

零脚本、零外链资源,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。零脚本不是"少一个功能",而是把这类信任关系整体去掉了。

性能与能耗

性能部分是机理层面的结论,这里不给出量化数字——本机没有做过对照测量,以下都是可解释的预期差异,不是实测结果。

这些差异在低端设备与慢网络上尤其明显。

代价:失去什么,用什么替代

诚实地说,零脚本确实丢掉了若干能力。

失去的能力 替代方案
客户端搜索 构建期生成的全量文章索引页(列表 / 标签页),或交给搜索引擎做站内 site: 查询
评论 省掉。个人技术笔记的反馈不值得引入一个第三方域
图片懒加载 用 HTML 的 loading="lazy" 属性即可,它不需要脚本
可折叠目录、标签页等交互组件 直接展开成静态列表;代价是页面长一点
手动主题切换 只跟随系统偏好,接受这一限制
访问统计 放弃,或退回到看服务器访问日志

其中"目录展开"这一项最值得说明:本站的文章目录是构建期渲染好的完整锚点列表,不做折叠、也不高亮当前章节。这是有意的取舍——折叠与高亮都需要脚本读取滚动位置。

取舍原则可以落成三个问题,加一个功能之前先过一遍:

  1. 这个功能是不是站点的目的本身?
  2. 它会不会让 CSP 从 none 变成一张需要维护的信任清单?
  3. 加上它之后,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。

判断依据

把一个页面需要的能力分成两栏来问,答案通常就出来了:

最后一条底线判据:如果引入某个东西之后,那份 Content-Security-Policy 从一行变成了一个域名列表,那么这个决定就不只是"加个功能",而是"新增一批需要长期信任的外部代码"。按后者的标准来审,很多功能会自己出局。

← 全部文章