最近我开始用 Monaco Editor 当作部落格的后台编辑器。这是 VS Code 的核心、一个为写程式而生的编辑器,却被我拿来写部落格,想想有点硬核。但这篇的主角不是 Monaco 本身,而是「在它里面塞一张图」这件看似无聊的小事——结果从一个简单需求,一路牵出 Base64、背景上传、NAS 挂载、到图片墙重写的一整条坑。

Monaco 不吃图,只吃连结#

Monaco 和一般所见即所得的编辑器完全是两个世界。它不像 Word 或 编辑器,拖张图进去就即时显示;在它的世界里,图片只是一串 Markdown 连结。也就是说,想插入图片,得先让图片变成一个「可用的网址」。当下能想到的路只有两条:把图转成 直接内嵌,或上传到某处生成 URL。

第一个直觉是 Base64。图片直接变成一长串字元嵌进 Markdown,文字和图片就绑死在一起,文章搬来搬去也不会掉图,听起来很方便:

markdown
![描述](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA... 后面还有数十万个字元)

但实测就是一个深坑。一张普通图片转成 Base64 后,字串会膨胀到数十万个字元,而 Monaco 一碰到这种超长的单行文本,渲染就卡到怀疑人生;更别说把这么巨大的文本直接塞进资料库,查询效能会直线下滑、容量还暴增。这条路显然行不通。

于是回到业界标准:上传到伺服器生成 URL,再用 Markdown 引用。文章文本保持轻量,资料库只存文字,图片交给档案系统、NAS 或 CDN 提供,载入快又好加快取:

markdown
![描述](https://koimsurai.com/uploads/xxx.webp)

TIP

Base64 是「最诱人的一行解」——文图绑死、零依赖、复制即用。但它把成本藏在你看不到的地方:编辑器的渲染效能、资料库的体积与查询。很多「简单」的方案,只是把帐单延后、记在别的地方而已。

让「上传」这件事,在体验里消失#

方向定了,但手动上传图片、复制连结、再贴回编辑器,这流程太破坏写作的连贯性。理想状况是:无论贴上还是拖曳,图片都能自动完成上传、生成连结、插进游标处,整个过程「隐形」。逻辑其实很直观,拆成四步:

  • 监听事件:挂上 Monaco 的
  • 拦截图片:检查剪贴簿或拖曳的内容里有没有 ——是纯文字就放行,是图片就拦截预设行为。
  • 背景上传:把这张图丢给后端 POST /api/upload,后端写档后回传 URL。
  • 插回游标:前端拿到 URL,用 在游标当下的位置插入 ![alt](url)

写成程式大概长这样(这是当时的设计雏形):

ts
// 贴上时拦截图片,背景上传,拿到 URL 后插入游标处
editor.onDidPaste(async () => {
  const file = getImageFromClipboard();     // 剪贴簿里有 File Object 吗?
  if (!file) return;                        // 纯文字就放行
  const url = await uploadToNAS(file);      // POST /api/upload → 回传图片 URL
  editor.executeEdits('paste-image', [{     // 在游标处插入 markdown
    range: editor.getSelection(),
    text: `![](${url})`,
  }]);
});

这条上传线后来是真的做起来了:后端收到图片会自动压缩并转成 WebP,存进 NAS,吐回一个形如 /uploads/2026/02/<时间戳>-<乱数>.webp 的网址——日期分资料夹、时间戳命名。从此贴一张截图,几乎是瞬间插入,写作流完全不断。

但档案不会偷偷留在系统碟吗?#

上传有了,但这里我卡了一下,冒出一个很自然的疑问:我的 server 开在系统碟上,就算最后要存进 NAS,图片经过 Node.js 后端时,不还是会在 web 端(系统槽 SSD)留一份吗?

答案是不会,而关键在于 。我在 NAS 硬碟上开一个专用目录,然后把它挂进容器:

yaml
services:
  blog-backend:
    volumes:
      # 左边是真实的 HDD 路径,右边是容器内的路径
      - /mnt/hdd16tb_01/blog_data/images:/app/public/uploads

这样一来,Node.js 只管把图存到 /app/public/uploads,档案就会穿过这道映射、直接落在 NAS 的磁区上。本质上是一道「传送门」:写进 /mnt/hdd16tb_01 底下的东西,一个 KB 都不会消耗到系统碟——系统槽在整个过程里,只是个「路过的通道」。(相对地,如果让 Docker 自己建 named volume,档案反而会躺在 /var/lib/docker/volumes/ 底下,那才会吃系统碟。)

但这道传送门有唯一一个会出包的情况:如果那颗 HDD 其实没有成功挂载上去,Linux 会把 /mnt/hdd16tb_01 当成一个普通的空资料夹,这时写进去的档案就会静默地落回系统碟——没有报错,你甚至不会发现,直到某天系统碟莫名其妙被塞满。所以每次都得先用 df -h 确认挂载状态:

text
档案系统        容量  已用  可用 已用% 挂载点
/dev/nvme0n1p2  1.9T  255G  1.5T   15% /
/dev/sdb1        15T   28K   15T    1% /mnt/hdd16tb_02
/dev/sda1        15T   22G   15T    1% /mnt/hdd16tb_01

看到 /mnt/hdd16tb_01/mnt/hdd16tb_02 对应的是那两颗 15T 的 HDD(而不是并回 /),我才敢放心把图往里面写。最后再让 nginx 把 /uploads/ 开头的请求直接对应到硬碟上的图片(这是当时的规划):

nginx
location /uploads/ {
    alias /mnt/hdd16tb_01/blog_data/images/;
    expires 30d;
    add_header Cache-Control "public, max-age=2592000";
}

WARNING

「档案存进 NAS」这件事最阴险的失败,不是报错,而是没报错。挂载掉了、路径打错,系统照样把档案乖乖写进系统碟,一切看起来都正常,直到系统碟爆掉。凡是靠挂载点的储存,df -h 确认挂载,值得变成一个肌肉记忆。

手机拍的照片,怎么引用进文章?#

上传截图解决了,但还有一个情境:我人在外面用手机拍照,传进 NAS 之后,要怎么在文章里引用?这时我在两个做法之间权衡:

做法一,nginx 静态代理。 让 NAS 上某个相片资料夹透过 nginx 暴露成公开网址,任何丢进去的图片都能用 https://koimsurai.com/nas-images/档名.jpg 存取。优点是几乎零开发、照片一传进 NAS 就能引用;缺点也很明显:得自己记住或去 NAS 里复制档名,图片一多,找图就很痛,写作体验会断。

做法二,图库选择器(当时想做的升级版)。 由 NAS 提供一支 GET /api/photos,回传某资料夹下所有图片的档名与网址 JSON;然后在 Monaco 编辑区上方放一个「从 NAS 插入图片」按钮,点击后弹出一个缩图网格的 Modal,点一张就自动把 ![描述](网址) 插进游标处。不用再手动复制网址,而是用视觉化的方式选图。

工具很简单、体验差很多。做法一适合当过渡,做法二才是我心里的目标形态——不过在那个当下,它还停在「规划」这一格。

既有的图片墙,要不要打掉重练?#

顺着这条线,我看向自己那面既有的图片墙。它当时是很典型的「静态构建」:前端透过 manifestLoader.ts 去读一个写死的 /photos-manifest.json,里面列好每张图的路径、缩图、以及 占位资料;万一连 manifest 都找不到,还会降级用 Vite 的 import.meta.glob 去硬读写死在前端专案 assets/Portfolio/ 里的图。我也写了一支 image-processor.ts,会自动压缩、转档、算出给 Blurhash 用的 thumbHash,但输出路径写死成 /generated/ 这种网页静态资料夹。

最大的痛点是:新增一张照片的流程有够繁琐。 每加一张图,我都得把档案放进专案的静态目录、跑一次脚本更新 photos-manifest.json,然后重新部署整个网站。哪怕只是多一张猫的照片。摄影作品本身又很吃空间,挤在 1.9T 的系统碟上也不是办法。

于是我画了一套重写的设计(同样是当时的规划):

  1. image-processor.ts 转成 NAS 背景服务——照片一上传到 NAS,背景就自动生成高画质图、缩图、算 thumbHash,只是输出路径改成 nginx 代理的网址。
  2. 建一支 GET /api/gallery/photos——回传跟原本 PhotosManifestData 一模一样结构的 JSON 阵列。
  3. 前端无痛切换——PhotoGallery.tsx 只要改一行资料源:
前端唯一要动的一行+11
const response = await fetch('/photos-manifest.json');
const response = await fetch('https://koimsurai.com/api/gallery/photos');

因为回传的资料结构完全相同,前端那些 、Blurhash 占位、hover 互动的组件一行都不用改——只是把资料源从「Web 专案本地」搬到「NAS 动态 API」。改造完,新增照片再也不用重新部署,把图丢进 NAS 就会自己出现。

UI 先行,资料后补#

照片墙的视觉我倒是先重做了。旧版卡片间距太大、又压了一张「摄影作品集锦」的大标题卡,看起来更像个「资料夹管理介面」,而不是展示视觉的艺廊。参考 Innei 的 Afilmory,核心是让照片成为唯一的主角:舍弃大方块、改用等高自适应网格,标题置中收敛,hover 时把旁边的照片淡下去、只让当前这张浮起来。改完像这样:

然后,美美的墙做完,我才想到一件事:我的照片根本没有上 tag。 上面那排「全部 / 猫咪 / 风景」的筛选钮,压根筛不了东西。

一条线,拉出一整面墙#

回头看,这整趟从「Monaco 只吃连结」出发,一路拉出 Base64 的陷阱、传送门与 df -h、再到图片墙的重写。把已经跑起来的那条上传线画成图,大概是这样:

每一块技术细节,单独看都很孤立:Monaco 的事件、Base64 的编码、一行 bind mount、一支 df -h。但它们其实都在为同一件事服务——让「放一张图」这个动作,对写作的人来说变得自然、甚至无感。而这趟最好玩的,反而是那个最朴素的教训:很多看起来玄的问题(档案为什么跑到系统碟?),答案往往就藏在「先搞清楚它实际上到底怎么运作」的下一步。挂载点是一道传送门,只要你记得先确认它真的接通了。