页面上的源码和下发的文件,必须是同一个字符串
脚本栏目要在页面上展示源码,又要用 /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、没有第二份拷贝。
它换来了什么
三条,都不是「性能」:
- 不可能漂移。不是「我们小心一点就不会漂移」,而是没有第二个地方可以漂移。这比纪律可靠。
- dev 与 build 行为一致。因为没有
fs,就不存在「dev 能读、build 读不到」这类只在某一侧出现的问题。 - 不依赖运行时。注册表是一份纯数据,能被任何环境消费 —— 页面、端点、以及自检脚本。
代价
诚实地说,代价是构建期:改一个 .sh 文件,需要重新构建才会生效(dev 下有 HMR 会即时反映)。
对脚本栏目来说这不是问题 —— 脚本本来就不该在运行时被改。但如果你的内容是「用户随时上传、立刻要能看到」的那种,?raw 就不合适了,你需要真正的运行时读取。
另一个代价是打包体积:所有脚本的源码都会进 bundle。23 个脚本加起来几十 KB,可以接受;如果有几千个,就该换成运行时按需读。
一条更一般的规律
这个问题的形状其实很常见:
同一份事实有两个出口时,只要它被存了两份,迟早会不一致。
解决办法通常不是「加强同步」,而是让其中一份根本不存在 —— 让两个出口都指向同一个来源。
具体到静态站点,import.meta.glob 加 ?raw 是一个很干净的实现:它在构建期把文件变成数据,于是「读文件」这件需要环境配合的事,变成了「引一个模块」这件纯编译期的事。
站上另外两处也是同一个思路:<head> 的全部标签只由 SiteHead.astro 产出(不各页面各写一遍),@/ 别名要在三处配置(因为这三处各自独立,无法合并,只能靠测试守住一致)。前者是「消灭第二份」,后者是「承认消灭不了、那就守住」。能消灭就消灭,消灭不了才上纪律。

评论
…