写 Markdown 写到一定量的人基本都会经历同一个阶段刚开始截图直接往文档里拖图片跟 md 文件躺在同一个目录看着挺整齐写了十几篇之后目录里 md 和 png 混在一起找文件要翻半天再往后开始分目录、建 assets结果历史文章里的老图片路径还是散的想统一整理一次又怕把链接改坏。这篇聊的就是我在 VS Code 里解决这件事的完整做法——一套让新粘贴的图片自动落到约定目录的配置加上一个能安全批量迁移旧图片的脚本。前者解决增量问题后者解决存量问题两件事必须一起做才有意义只做其中一个目录早晚还会乱回去。内容适合三类人刚建立图库习惯、还在纠结目录怎么定的新手手里有几十上百篇旧文档、想一次性理顺路径的老用户以及给别人交付文档、需要保证图片链接在任何环境都能打开的写作者。全程只需要 VS Code 加一个 Node 环境不涉及任何云服务本地跑完即可。1. 先搞清楚Markdown 图片为什么会越攒越乱1.1 三种典型的目录形态对号入座我见过也亲手制造过的目录形态基本跑不出这三种。第一种是扁平混放笔记目录/下同时躺着网络基础.md、image.png、image-1.png、image-2.png。这种形态在文章少于十篇时体验还行超过二十篇就是灾难截图工具默认的image.png会不断覆盖或被系统自动加序号你根本分不清哪张图属于哪篇文章。我早期有篇讲抓包的文章回头看发现插图全变成了另一篇文章的拓扑图原因就是文件名撞车后被批量覆盖。第二种是按文章分目录每篇文档配一个同名文件夹网络基础/网络基础.md网络基础/assets/。这种结构最清晰缺点是文档多了以后目录层级深侧边栏要展开好几层才能点到正文而且跨文章复用同一张图会变成复制两份。第三种是集中资源池所有图片统一进assets/images/按日期或文章名再分子目录正文里用相对路径引用。这是我现在用的方案也是这篇主要讲的方案——增量粘贴时自动归类存量图片批量搬迁到同一套规则下。选第三种的理由很实在搜索图片时只需要在一个根目录下找迁移或换硬盘时目录结构可以整体打包Markdown 的相对路径写起来最短./assets/images/xxx/a.png在任何支持相对路径的编辑器里都能渲染。1.2 归类规则必须在动手前定死这一点是我踩过最大的坑。第一次整理图片时我边写边改规则前二十篇按日期分目录中间十篇按文章名分目录后面又觉得按标签分更好结果三套规则混在一起比整理前还乱。所以规则要在动手前定死并且写进配置文件让工具替人执行。我的规则是这样定的图片根目录固定为项目根下的assets/images/第二层按所属 Markdown 文件名去掉扩展名建子目录比如assets/images/网络基础/20250315-142301.png文件名用「日期时间」保证天然有序且不重名。为什么用文件名而不是日期做第二层因为文章标题是写作者最熟悉的检索词你想起某张图时第一反应是「那篇讲网络的文里用过」而不是「那篇文是哪天写的」。至于为什么不用文章名做图片文件名的一部分是因为文章标题可能被改一改文件名就全废而目录名改起来只是批量重命名一次的事。注意目录名里的文章名建议做一次字符清洗去掉/、\、:、*、?、、、、|这些在主流文件系统里不合法的字符否则配置写好之后粘贴图片会直接报错而且报错信息通常很含糊。1.3 为什么把入口放在 VS Code 而不是别处选择在 VS Code 里解决核心原因是粘贴动作发生在编辑器内。图片归类的时机只有两个粘贴的那一刻和事后批量整理。事后整理成本高、风险大能拦在源头就拦在源头。VS Code 的扩展 API 能把剪贴板里的图片直接写盘再按模板生成路径并插入正文整个过程不需要离开编辑界面这是外部脚本做不到的顺滑体验。另外VS Code 内置的 Markdown 编辑器从 1.79 版本起已经支持把图片文件粘贴进文档并自动复制到工作区只是命名和目录规则比较固定。如果你的需求简单甚至可以不装任何插件需求复杂一点再上插件。下面两节分别讲这两条路。2. 新图片自动归类插件选型与配置逐条拆解2.1 三条路线横向对比方案依赖目录可控性文件名可控性适合谁VS Code 内置粘贴无只能指定一级子目录名固定为image.png递增文档少、不介意文件名的人Paste Image 类插件需安装扩展支持项目根、当前目录、当前文件名等多变量支持日期时间模板需要按文章分目录、要时间戳命名的人Markdown Image 类插件需安装扩展支持按日期、按文件名分目录支持自定义格式串想同时管图片宽度、居中等排版属性的人我自己的最终选择是第一列和第二列的组合内置能力作为兜底插件负责日常粘贴。理由是插件把命名和路径都模板化了而模板是唯一能长期稳定执行规则的东西——靠人记得改文件名三天就破功。如果你只是想试试内置方案配置项只有三个写在settings.json里{ markdown.editor.filePaste.enabled: true, markdown.editor.filePaste.copyIntoWorkspace: media, markdown.editor.filePaste.folderName: assets }copyIntoWorkspace设为media时粘贴进来的图片会自动复制到工作区内的指定目录避免引用到工作区外的临时文件folderName就是那个一级子目录名。这套配置的局限很明确所有文章共用同一个文件夹文件名是image.png、image-1.png时间一长必然要靠肉眼辨认。文档总量在一二十篇以内可以接受超过就该换插件了。2.2 目录规则与命名规则怎么落到配置里插件方案里最关键的两个字段是「保存到哪里」和「叫什么名字」。绝大多数失败案例问题都出在这两个字段的变量用错而不是插件本身不行。先说保存路径。常见变量有四个${projectRoot}表示工作区根目录${currentFileDir}表示当前 md 文件所在目录${currentFileName}表示当前文件名含扩展名${currentFileNameWithoutExt}表示去掉扩展名后的文件名。把这四个变量按需要拼起来就能得到任意目录规则。比如按文章名分目录就是${projectRoot}/assets/images/${currentFileNameWithoutExt}。再说命名。日期时间类变量由 moment 格式串控制YYYY是四位年MM是两位月DD是两位日HH是 24 小时制mm分ss秒。我推荐YYYYMMDD-HHmmss这种紧凑写法一个原因是排序时天然按时间先后另一个原因是它不会因为中间出现空格而导致某些解析器把路径截断。如果你会在一分钟内连续粘贴多张图秒级精度可能撞车这时候在末尾补一个${random}之类的随机串或者干脆接受插件自动加序号的行为。2.3 settings.json 完整配置与逐项解释下面是我在用的配置。先解释一条操作顺序第一次配置时把showFilePathConfirmInputBox设为true这样每次粘贴都会弹出一个输入框让你确认落盘路径和文件名连续发三张图就能验证规则是否符合预期确认无误后改成false恢复无感粘贴。{ pasteImage.path: ${projectRoot}/assets/images/${currentFileNameWithoutExt}, pasteImage.basePath: ${projectRoot}, pasteImage.prefix: ./, pasteImage.forceUnixStyleSeparator: true, pasteImage.defaultName: YYYYMMDD-HHmmss, pasteImage.insertPattern: , pasteImage.showFilePathConfirmInputBox: true, pasteImage.escapePath: true, pasteImage.escapeCharacters: \\ () }逐项说清楚。path决定图片落盘位置这里用项目根加固定目录再加文章名。basePath是计算相对路径时的基准目录设成项目根之后正文里插入的引用就会是相对于项目根的路径而不是一长串绝对路径——绝对路径是图片管理里的头号隐患一旦换机器或换目录就全部失效。prefix会在生成的路径前面拼一个前缀我习惯加./因为部分静态站点生成器和编辑器对不带./的相对路径解析不一致加上之后基本不挑环境。forceUnixStyleSeparator解决的是 Windows 下反斜杠的问题Markdown 里写assets\images\a.png在多数渲染器里是打不开的必须强制转成正斜杠。insertPattern是插入到正文里的模板除了路径变量还可以用${imageFileNameWithoutExt}作为替代文本这样图片加载失败时至少能看到一个有意义的名字而不是一片空白或者一串乱码。最后两项与转义有关escapePath打开后路径里如果包含空格或括号插件会自动转义避免 Markdown 把路径中的空格当成标题分隔符。提示不同插件的字段名不完全相同而且会随版本变化。上面这套字段在 Paste Image 系插件里长期有效换用其他插件时请以扩展详情页的配置说明为准不要直接照搬字段名。2.4 为什么「落盘路径」和「引用路径」可以不一样这是很多人第一次配置时的困惑点我在配置里写的是${projectRoot}/assets/images/...一个绝对路径为什么插入正文的却是一个相对路径原因在于这两条路径由不同变量输出path控制文件实际写到哪basePath加prefix控制写进 md 的那串字符串长什么样。理解这一点之后你就能做一些更灵活的配置。比如图片目录不想放在项目里而是放在项目上一级的公共图库../shared-images/那就把path设为${projectRoot}/../shared-images/${currentFileNameWithoutExt}同时把basePath也设为${projectRoot}/..这样生成出来的引用就是./shared-images/网络基础/20250315-142301.png简洁且可用。再比如你想让正文里的引用直接指向工作区的绝对路径方便本地预览那就把basePath设成空代价是这份文档发给别人后图片全部显示不出来。我的建议始终是相对路径并且是相对于项目根的相对路径。它唯一的不便是项目外单独打开某一篇 md 时图片可能显示不出来但换来的是整套目录可以整体迁移、整体打包、整体交给别人这笔账怎么算都值。3. 旧图片批量迁移从盘点到落地脚本3.1 迁移前必须做的三项盘点批量改文件这种事最怕的就是「我以为我看清了」。动手前先跑三个盘点把风险摸清楚。第一统计待处理文件和图片引用的数量心里有个量级grep -rIn --include*.md -oE !\[[^]]*\]\([^)]\) . | wc -lWindows 下用 PowerShell 的等价写法Get-ChildItem -Recurse -Filter *.md | Select-String -Pattern !\[[^]]*\]\([^)]\) | Measure-Object第二把本地图片路径和网络图片路径分开。指向http://、https://、data:开头的引用必须原样保留任何脚本如果无脑处理所有都会把外链图改坏。这一步最好在脚本里直接判断而不是靠人工过滤。第三检查是否有重名图片。扁平目录里最常见的image.png、截图.png可能在十几个目录里各有一份内容完全不同迁移时如果直接按文件名搬到同一个目标目录就会互相覆盖。解决办法是迁移脚本必须做冲突检测内容不同时自动加序号后缀。3.2 迁移脚本的整体思路脚本的逻辑我拆成五步顺序不能变递归扫描项目下所有 md 文件跳过node_modules、.git这类目录对每个文件提取所有图片引用区分本地路径和网络路径把本地路径解析成绝对路径确认文件存在算出迁移后的目标路径目标路径冲突时按内容比对内容相同则复用同一份内容不同则加序号执行移动并把正文里的引用替换成迁移后的相对路径。这里有一个设计决策值得说明为什么是移动而不是复制。复制最安全但会在仓库里留下两份同样的图片git 历史体积照样翻倍而且以后你无法确定哪份是有效文件。移动更彻底风险由 git 承担——迁移前先提交一次快照出问题直接回滚这是比留副本更可靠的保险。另一个决策是为什么用脚本而不是手工改。手工改路径的问题在于你很容易漏掉藏在 HTML 标签里的引用、带 title 的引用、被尖括号包裹的带空格路径。这三类引用在一份写了两年的文档集里几乎必然存在靠眼睛找是不可靠的。3.3 完整脚本与逐段说明脚本用 Node.js 写跨平台不需要额外依赖。先建一个migrate-images.js放在项目根。#!/usr/bin/env node const fs require(fs); const path require(path); const ROOT process.argv[2] ? path.resolve(process.argv[2]) : process.cwd(); const TARGET_ROOT path.join(ROOT, assets, images); const APPLY process.argv.includes(--apply); const IMAGE_EXT new Set([ .png, .jpg, .jpeg, .gif, .webp, .bmp, .svg, .avif ]); const SKIP_DIRS new Set([node_modules, .git, .obsidian, dist, build]); const plan []; const movedMap new Map();前三行决定了「改哪个目录」和「是否真的写盘」。默认是干跑dry run只有显式传--apply才会执行移动和改写这条设计是我用血换来的第一次写这类脚本时没加干跑开关直接对着一个没有做版本控制的目录跑三百多张图被搬到一半报错退出回滚只能靠手工。接下来是文件扫描和引用提取function walk(dir, out []) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (SKIP_DIRS.has(entry.name)) continue; const full path.join(dir, entry.name); if (entry.isDirectory()) walk(full, out); else if (/\.(md|markdown)$/i.test(entry.name)) out.push(full); } return out; } const MD_RE /!\[([^\]]*)\]\(\s*(?)([^)\s])(?)((?:\s[^]*)?)\s*\)/g; const HTML_RE /img\b[^]*\bsrc\s*\s*[]([^])[][^]*/gi; function isRemote(p) { return /^(https?:|data:|mailto:|#)/i.test(p); }MD_RE里的?和?是用来匹配./assets/a b.png这种用尖括号包裹、路径里带空格的写法第三组捕获的是可选的 title迁移时必须原样保留否则会变成语义就丢了。核心迁移逻辑function calcTarget(file, rawPath) { const decoded decodeURIComponent(rawPath); const abs path.resolve(path.dirname(file), decoded); if (!fs.existsSync(abs)) return null; const ext path.extname(abs).toLowerCase(); if (!IMAGE_EXT.has(ext)) return null; const base path.basename(file, path.extname(file)).replace(/[\\/:*?|]/g, _); const dir path.join(TARGET_ROOT, base); let target path.join(dir, path.basename(abs)); let i 1; while (fs.existsSync(target)) { if (fs.readFileSync(target).equals(fs.readFileSync(abs))) return { abs, target, reuse: true }; target path.join(dir, ${path.basename(abs, ext)}-${i}${ext}); i 1; } return { abs, target, reuse: false }; }decodeURIComponent这一行非常关键。如果图片路径里含中文或空格有些编辑器会把它写成百分号编码的形式也就是%E7%BD%91%E7%BB%9C.png。实测下来VS Code 内置的粘贴一般不做编码但手动插入或者别的工具生成的时候就说不准。脚本里先解码再去找文件能避开一大类「文件明明在却提示找不到」的怪问题。主流程for (const file of walk(ROOT)) { let text fs.readFileSync(file, utf8); let changed false; text text.replace(MD_RE, (m, alt, lt, raw, gt, title) { if (isRemote(raw)) return m; const info calcTarget(file, raw); if (!info || info.reuse) return m; const rel ./ path.relative(path.dirname(file), info.target).split(path.sep).join(/); plan.push(info); movedMap.set(path.resolve(path.dirname(file), decodeURIComponent(raw)), rel); changed true; return ; }); // HTML img 标签的处理同理这里省略重复逻辑 if (changed APPLY) fs.writeFileSync(file, text, utf8); } if (APPLY) { for (const { abs, target } of plan) { fs.mkdirSync(path.dirname(target), { recursive: true }); fs.renameSync(abs, target); } console.log(已迁移 ${plan.length} 张图片); } else { console.log(干跑模式计划迁移 ${plan.length} 张图片加 --apply 执行); }这里有一个执行顺序上的硬性要求必须先改写所有 md 文本再移动图片文件。如果先移动文件、改写过程中报错退出就会留下「图片在新位置、引用还指向旧位置」的半成品状态而且旧位置已经空了肉眼排查起来极其痛苦。先改文本再移文件最坏情况也只是文本指向了新位置而文件还在旧位置重新跑一遍脚本就能自愈。3.4 执行顺序与回滚预案完整执行过程我固定成六步一步都不省cd your-project git status git checkout -b chore/image-migration git add -A git commit -m chore: 图片迁移前快照 node migrate-images.js . node migrate-images.js . --apply第一次不带--apply是干跑看输出数量对不对、路径样例有没有问题。确认之后再实际执行。执行完用下面的命令检查有没有残留的旧路径grep -rIn --include*.md assets/attachments\|\.\./images .如果干跑输出里出现了 0 张图片通常不是脚本坏了而是三种原因之一当前目录不是项目根、正则没匹配上你的引用写法、或者所有引用都是网络图片。这种情况先手工挑一篇文档把它的引用原样贴进一个测试文件里跑一遍定位到底是哪种。回滚就是一条命令git reset --hard回到迁移前的 commit图片位置和文本内容一起恢复。这就是为什么迁移前那次提交不能省——它比任何自动备份机制都可靠。4. 常见问题与排查技巧实录4.1 常见问题速查表现象大概率原因处理方式粘贴后图片存在但正文不显示引用路径是绝对路径或反斜杠未转换检查basePath与forceUnixStyleSeparator图片重复命名为image-1.png目标目录已有同名文件检查命名模板是否用了时间戳路径含空格导致链接断在中间未转义或未用尖括号包裹打开escapePath或手动加迁移后部分图显示为%E7%BD%91...引用被 URL 编码了脚本中加decodeURIComponent中文文件名在服务器上 404服务器环境对编码处理不一致统一改为英文或纯数字命名干跑显示 0 张图目录不对 / 正则不匹配 / 全是外链用单篇文档做最小复现4.2 中文、空格、括号引发的三类失效空格是最常见的坑。在多数渲染器里会被解析成路径./assets/我的加 title图.png图片自然出不来。两种修法一是用%20转义二是用尖括号包起来写成后者可读性更好也是 GitHub 风格 Markdown 推荐的做法。我的做法是在源头避免——命名模板里不产生空格。中文的问题更隐蔽。本地预览一切正常推送到某些静态站点或部署到服务器后就 404因为不同环节对文件名的编码处理不一致本地是 UTF-8 字节序列服务器可能按 Latin-1 解释。绕开的办法是在配置文件里就把文件名限制成 ASCII用时间戳命名天然满足这一点替代文本仍然可以用中文读者看到的信息一点不损失。括号的坑在于 Markdown 用)表示链接结束./assets/截图(1).png会在第一个)处被截断。修法是转义成\(和\)或者用尖括号包裹。这个坑在从聊天工具保存下来的截图里特别常见因为系统自动加序号时用的就是圆括号。4.3 Git 与图片的关系处理图片进 git 仓库会让仓库体积快速增长这是绕不开的现实。几个我实际用下来有效的习惯给图片加一条.gitattributes让 git 把它们当二进制处理避免 diff 时产生无意义的文本比较*.png binary *.jpg binary *.webp binary再用.gitignore排掉临时目录和缩略图缓存比如.vscode/、**/.thumbnails/。如果某篇文档的配图特别多、单张体积又大可以考虑把它拆到独立的图片仓库主仓库用子模块引用但这种方式对个人项目来说管理成本偏高我的建议是先把命名和目录规则跑顺体积问题等真的痛了再处理。注意不要用「删掉本地图片再重新粘贴」的方式做整理。这样做会丢失原始文件而且旧引用不会自动更新等于把一次可控的迁移变成了一堆需要手工修的死链。5. 长期维护几个用了很久才稳定的习惯5.1 命名与去重的一点取舍时间戳命名有个副作用同一篇文章里连续粘贴多张图文件名高度相似肉眼几乎无法区分。我试过在时间戳后面加一段语义化后缀比如20250315-142301-topology.png代价是每次粘贴都要手动输入一次。权衡之后我保留了纯时间戳因为检索场景几乎总是「按文章找图」而不是「按内容找图」而语义化信息完全可以通过替代文本承载写在![]的方括号里对读者可见也不影响文件名。去重策略上我在脚本里做的是内容比对而不是文件名比对。这个选择的原因是同一张架构图可能被三篇文章引用如果按文件名判断很可能被重复搬成三份按内容比对则只保留一份其余引用都指向它。代价是脚本要读文件内容图片多的时候慢一些但对几百张图的量级来说完全可以接受。5.2 图床与本地目录怎么选这两条路线我都不排斥关键看使用场景。文档只在本地和个人仓库里流转本地目录加相对路径是最省心的方案不依赖网络、不怕服务下线、打包即走。文档要发布到多个平台、需要外部访问图床更合适但要注意图床迁移时的批量替换问题最好从一开始就用固定前缀加文章名的路径结构迁移时只替换域名部分。我现在的做法是混合核心文档用本地目录对外发布的版本在构建阶段把本地路径替换成图床地址。这样写作时不用联网发布时一次批量替换搞定两边的路径规则保持同一套结构替换脚本只需要处理域名部分。5.3 多仓库复用的配置片段如果你有多个笔记仓库、多个项目仓库不需要每个都重写一遍配置。把公共部分放进 VS Code 的用户级settings.json仓库特有的部分放进工作区级的.vscode/settings.json后者会覆盖前者。比如用户级放命名模板和转义设置工作区级只覆盖pasteImage.path这样换仓库时只需要改一行。更彻底的做法是用 VS Code 的配置文件机制把整套设置和扩展列表导出成一个可复用的配置。这样换机器时导入即可避免出现「新机器上粘贴的图片跑到别的目录去了」这种因为少了一项配置导致的静默错误。我自己的习惯是新机器上第一次装完编辑器先跑一次粘贴测试确认落盘位置和引用格式都对再开始正式写东西。这套流程我从最开始的手工整理图片、到后来写半自动脚本、再到现在的「配置拦在源头 脚本处理存量」前后迭代了三四次中间因为缺少版本控制吃过一次不小的亏。现在回头看最有价值的其实不是脚本本身而是那条把所有规则写进配置文件的纪律——脚本可以重写规则一旦被工具强制执行就不会再乱。