页面上的源码和下发的文件,必须是同一个字符串

脚本栏目要在页面上展示源码,又要用 /scripts/x.sh 原样下发同一份。两份拷贝迟早漂移。这篇讲为什么用 import.meta.glob 的 ?raw 内联,比构建期读文件或拷进 public 都更可靠。

脚本栏目有个看起来很简单的要求:页面上展示的源码,必须和用户 curl 下来执行的文件一模一样。

听起来是句废话。但「一模一样」在工程上从来不是免费的。

先看有哪些做法

要求是「同一份源码,两个出口」:一个是渲染成 HTML 给人看,一个是当作纯文本发给 curl。

做法一:构建期读文件系统

最直观的写法是在页面里 fs.readFileSync('src/scripts/x/main.sh')。

问题在于路径的基准。构建时 cwd 是项目根,dev 时也是 —— 看起来没问题,直到:

  • 打包器把代码挪到别处执行,process.cwd() 不再是你以为的那个目录
  • 部署产物里没有 src/(这个站的 dist/ 是纯静态输出,源码不进去)
  • 静态分析工具看到 fs 就认为这段代码必须跑在 Node 里,于是它不能被复用

最后一条最要命:它把一个纯数据问题变成了运行时环境问题。

做法二:源文件放两处

src/scripts/x/main.sh 给页面读,public/scripts/x.sh 给下载。

问题不需要解释:两份。改了一份忘了另一份,页面展示的和实际执行的就分家了。而用户恰恰是因为看到页面上的源码才敢执行的 —— 这两者分家的时候,页面上那份变成了谎言。

做法三:只放 public/,页面去读它

比做法二好一点,只有一份。但页面要读它仍然要碰 fs(回到做法一),或者发一个 HTTP 请求去取(构建期没有服务器,只能客户端 fetch,于是「禁用 JS 看不到源码」)。

这个站用的做法

main.sh 由 Vite 在构建期内联成字符串:

const sources = import.meta.glob('../scripts/*/main.sh', {
  query: '?raw',
  import: 'default',
});

?raw 是 Vite 的能力:它在打包时把文件内容当成模块的默认导出塞进来。于是:

  • 页面的源码展示,读的是这个字符串
  • /scripts/<slug>.sh 端点吐出去的,也是这个字符串

同一个变量,两个出口。 中间没有文件系统、没有 cwd、没有第二份拷贝。

它换来了什么

三条,都不是「性能」:

  1. 不可能漂移。不是「我们小心一点就不会漂移」,而是没有第二个地方可以漂移。这比纪律可靠。
  2. dev 与 build 行为一致。因为没有 fs,就不存在「dev 能读、build 读不到」这类只在某一侧出现的问题。
  3. 不依赖运行时。注册表是一份纯数据,能被任何环境消费 —— 页面、端点、以及自检脚本。

代价

诚实地说,代价是构建期:改一个 .sh 文件,需要重新构建才会生效(dev 下有 HMR 会即时反映)。

对脚本栏目来说这不是问题 —— 脚本本来就不该在运行时被改。但如果你的内容是「用户随时上传、立刻要能看到」的那种,?raw 就不合适了,你需要真正的运行时读取。

另一个代价是打包体积:所有脚本的源码都会进 bundle。23 个脚本加起来几十 KB,可以接受;如果有几千个,就该换成运行时按需读。

一条更一般的规律

这个问题的形状其实很常见:

同一份事实有两个出口时,只要它被存了两份,迟早会不一致。

解决办法通常不是「加强同步」,而是让其中一份根本不存在 —— 让两个出口都指向同一个来源。

具体到静态站点,import.meta.glob 加 ?raw 是一个很干净的实现:它在构建期把文件变成数据,于是「读文件」这件需要环境配合的事,变成了「引一个模块」这件纯编译期的事。

站上另外两处也是同一个思路:<head> 的全部标签只由 SiteHead.astro 产出(不各页面各写一遍),@/ 别名要在三处配置(因为这三处各自独立,无法合并,只能靠测试守住一致)。前者是「消灭第二份」,后者是「承认消灭不了、那就守住」。能消灭就消灭,消灭不了才上纪律。

← 返回文章列表

评论

…