从“手写 Markdown 工具”这个念头到真正跑通一条完整链路,我折腾了不少时间。最初的需求很简单:团队知识库里的 Markdown 文档要导出一份可以分发给外部的 HTML,但里面嵌了好几个本地视频和一堆高清截图,直接转换出来的 HTML 又大又卡。后来我干脆用 Node.js 的一票核心模块——path、OS、process、child_process、FS、crypto、zlib——搭了一套能扫描目录、解析文档、调用 ffmpeg 处理媒体、最终生成静态 HTML 的小工具。整个过程走下来,最大的感受是:原生模块足够搞定 80% 的场景,配合 ffmpeg 这类外部命令,能做的事情远超预期。
这篇内容适合已经写过一点 Node.js、但想把文件处理、子进程调用和构建流程吃透的开发者。我会把整体设计、核心模块拆解、Markdown 转 HTML 的完整实现、环境配置和常见问题全部过一遍,保证你能照着复现,也能理解每一步为什么这么写。
1. 整体设计与核心思路
1.1 为什么用 Node.js 原生模块搭这套工具链
看到标题里那一串模块名,很多人第一反应是“有必要这么复杂吗?直接装个 webpack 或者 vite 不就行了?”但现实场景往往没到需要上重型构建工具的地步。我这次要处理的是一批固定目录下的 Markdown 文档,要求输出到另一个目录供内网访问,同时要把里面的视频转成 H.264 MP4、图片压缩成 WebP,再生成 gzip 压缩包方便传输。用 Node.js 原生模块加一个 Markdown 渲染器就能闭环,不需要引入几十个依赖。
原生模块的好处是稳定、可控、跨平台。FS 负责文件和目录操作,path 规范化路径,process 读命令行参数,OS 拿系统 CPU 核数,child_process 调用 ffmpeg,crypto 生成内容哈希用于增量构建,zlib 压缩输出。这些模块都是 Node.js 自带,不用锁版本,也不会有依赖冲突。配合一个宽松的解析流程,整个工具的核心代码不到 300 行,后续给非技术同事用也不需要他们装额外环境。
1.2 工具链组成:解析、资源处理、构建输出
把这套工具的流水线拆开,大概是这样的:
- 输入阶段:用 FS 递归扫描源目录,过滤出
.md、.markdown文件,以及文档里引用的图片、视频文件。 - 解析阶段:读取 Markdown 文本,交给 markdown-it 渲染成 HTML,同时解析出里面的本地媒体路径。
- 资源处理阶段:对图片执行压缩/格式转换,对视频调用 ffmpeg 转码/裁剪封面,处理结果输出到构建目录。
- 输出阶段:把 HTML 写入目标目录,替换媒体路径,用 zlib 生成
.gz版本,顺便记录一份内容哈希映射表。
这个设计最大的优势是把“解析”和“资源处理”解耦。解析只关心文本结构,资源处理只关心文件二进制,两边通过路径和元数据对接。比如 Markdown 里写,渲染器会把demo.png作为一个相对路径暴露出来,资源处理模块再去决定是压缩成.webp还是原样复制。这样即使某天你换了 Markdown 渲染器,或者改成用 Pandoc 转换,资源处理部分完全不用动。
2. 核心模块逐个拆解
2.1 FS 与 Path:文件读写、目录遍历和跨平台路径
在 Node.js 里,FS 和 path 基本是黄金搭档。FS 提供readFile、writeFile、mkdir、readdir、stat这些底层能力;path 则负责处理 Windows 的反斜杠和 Linux/macOS 的正斜杠差异,避免你手写字符串拼接。
我经常遇到新手直接写dir + '/' + fileName,在 Windows 上跑没问题,但一到 Linux 上部署就可能因为分隔符不统一导致路径找不到。更稳妥的写法是path.join(dir, fileName)。反过来,如果你想从一个文件反推相对路径,用path.relative(from, to),它能自动处理.和..。
下面是一个递归扫描目录的示例,我更喜欢用readdir加withFileTypes: true,这样不用额外调用stat去判断是不是目录:
const fs = require('fs'); const path = require('path'); function walkDir(dir, fileList = []) { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { walkDir(fullPath, fileList); } else if (entry.isFile()) { fileList.push(fullPath); } } return fileList; }这里用了同步 API,因为 CLI 工具在启动阶段需要先拿到文件清单,同步写起来更直观。如果文档量大,你可以在后续改成fs.promises.readdir,配合Promise.all并行扫描。
另一个容易踩坑的是 Windows 路径的 drive letter。比如C:\docs\a.md,在path.relative之后可能会得到..\docs\a.md,但放到 HTML 的src属性里需要用正斜杠/。这时可以做一个toWebPath转换:
function toWebPath(p) { return p.split(path.sep).join('/'); }否则生成的 HTML 在 Windows 上本地打开没问题,部署到 Linux 服务器上图片全都裂了。这个坑我至少踩过两次,简历上甚至值得单独写一条。
2.2 Process 与 OS:命令行参数、环境变量和系统资源感知
process是 Node.js 的全局对象,不需要 require。在 CLI 工具里,最常用的三个东西是:process.argv、process.env、process.exitCode。
process.argv的前两个固定是 node 路径和脚本路径,真正的参数从下标 2 开始。简单场景可以直接用process.argv.slice(2),但参数如果多,建议手动解析成键值对。我不会一上来就推荐commander或者yargs,因为小工具里自己解析更干净。
来看一个轻量解析方案:
function parseArgs(argv) { const args = {}; for (let i = 0; i < argv.length; i++) { if (argv[i].startsWith('--')) { const key = argv[i].slice(2); const next = argv[i + 1]; if (next && !next.startsWith('--')) { args[key] = next; i++; } else { args[key] = true; } } } return args; }OS模块用来获取系统资源。最实用的场景是根据 CPU 核数决定并行调用几个 ffmpeg 进程。os.cpus().length返回逻辑核数,我一般取Math.max(1, cpus - 1),留一个核给主线程,避免资源耗尽导致系统卡顿。
os.platform()也可以用来写平台相关的逻辑,比如 Windows 上 ffmpeg 可执行文件要加.exe后缀,macOS/Linux 则不需要。用os.tmpdir()可以生成临时目录,转码中间文件放到系统临时目录里,比放在项目目录下更干净,也不会污染 git 提交记录。
2.3 Child_process:调用 ffmpeg 的正确姿势
child_process是链接 Node.js 和外部命令的桥梁。调用 ffmpeg 有两种常见方式:execFile和spawn。很多人习惯用exec,因为它可以带上 shell 语法,但exec把整个命令字符串交给 shell 执行,如果文件名里包含空格或特殊字符,很容易出问题,甚至会有命令注入风险。
我更推荐execFile。它把可执行文件路径和参数数组分开,Node.js 会直接创建子进程,不经过 shell 解析,既安全又稳定。下面是一个调用 ffmpeg 转码视频的例子:
const { execFile } = require('child_process'); function convertVideo(input, output) { return new Promise((resolve, reject) => { const args = [ '-i', input, '-c:v', 'libx264', '-preset', 'fast', '-crf', '23', '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', '-y', output, ]; execFile('ffmpeg', args, { timeout: 60000 }, (error, stdout, stderr) => { if (error) { reject(error); return; } resolve(output); }); }); }这里重点解释几个参数:-movflags +faststart是让 MP4 的元数据放到文件头部,浏览器才能边下载边播放。-crf 23是 H.264 编码的质量因子,数值越小画质越高,文件越大。23 是通用平衡点,对大多数视频足够。-preset fast是编码速度和压缩率的折中,如果你有充足时间可以改成slow,文件体积会进一步下降。
你有没有想过stdout和stderr里到底有什么?ffmpeg 默认把进度信息写到 stderr,如果调用失败,错误原因也在 stderr 里。所以诊断问题时要看error对象里stderr字段,而不是stdout。我在调试时就会把 stderr 打出来,能快速定位是不是编码器不支持、输入路径不存在、或者输出目录没权限。
2.4 Crypto 与 Zlib:内容哈希、增量缓存和压缩输出
crypto最常用的功能不是加解密,而是计算哈希。在构建工具里,我用哈希来判断文件内容有没有变化,从而实现增量构建:只有 Markdown 内容或媒体文件变了,才重新转换和转码,否则直接复用上次的输出。
来看一个计算文件哈希的函数:
const crypto = require('crypto'); const fs = require('fs'); function fileHash(filePath) { const content = fs.readFileSync(filePath); return crypto.createHash('sha256').update(content).digest('hex').slice(0, 12); }有人会问,为什么用 SHA-256 而不是 MD5?虽然 MD5 更快,但碰撞概率更高,而且很多安全扫描工具会对 MD5 报 warning。构建工具里的哈希只是为了判断内容变化,不涉及安全认证,所以用截断到 12 位的 SHA-256 完全够用。缓存文件命名成index-3f7a2b1c4d2e.html,当源文件内容变化时哈希值变化,旧文件自然不会被引用。
zlib模块用来生成 gzip 文件。静态服务器一般会自动开启 gzip,但如果你的目标环境没有开启,手动生成一份.gz文件也能达到一样的效果。Node.js 的zlib.gzipSync用起来很简单:
const zlib = require('zlib'); function writeGzip(filePath, content) { const gzip = zlib.gzipSync(Buffer.from(content), { level: 9 }); fs.writeFileSync(filePath + '.gz', gzip); }level: 9表示压缩率最高,但耗时也最长。HTML 文本一般几十 KB,耗时几乎可以忽略,所以可以放心用最高压缩率。如果是大文件,建议改成level: 6平衡速度。
3. Markdown 转 HTML 的完整实现
3.1 渲染器选型:为什么我选 markdown-it,而不自己手写
虽然标题里没有提到任何 Markdown 库,但真正做转换时,我不会劝你手写一个 Markdown 解析器。Markdown 规范里的嵌套列表、代码块、表格、引用块,看似简单,实际上有一堆边界情况。手写解析器可能支持 90% 的语法,但剩下的 10% 会在真实文档里以诡异的方式出现。
我用的是markdown-it,它成熟、插件丰富、解析速度快,而且支持开箱即用的 HTML 标签。安装命令:
npm install markdown-it一个最基础的渲染循环:
const MarkdownIt = require('markdown-it'); const md = new MarkdownIt({ html: true, linkify: true, typographer: true, }); function renderMarkdown(mdPath) { const src = fs.readFileSync(mdPath, 'utf8'); return md.render(src); }html: true允许 Markdown 里直接包含原生 HTML,像嵌入 iframe 或者视频标签。linkify: true自动把裸链接变成可点击链接。typographer会把直引号转换成弯引号,但这个功能对中文内容有时候会误伤代码块,我一般保守一点,设置为false。
3.2 解析媒体引用:从 Markdown 文本里提取本地资源
Markdown 渲染成 HTML 之后,图片和视频的路径仍然保留在src属性里。要做资源处理,第一步是从渲染后的 HTML 里提取路径。可以用正则表达式粗暴匹配,但更可靠的是用markdown-it的插件机制,在 token 解析阶段就把图片地址收集起来。
下面是用markdown-it插件收集图片路径的写法:
const md = new MarkdownIt(); function collectAssets(mdSource) { const assets = []; const plugin = (md) => { const defaultImageRule = md.renderer.rules.image || ((tokens, idx, options, env, self) => self.renderToken(tokens, idx, options)); md.renderer.rules.image = (tokens, idx, options, env, self) => { const src = tokens[idx].attrGet('src'); if (src && !/^(https?:)?\/\//.test(src)) { assets.push(src); } return defaultImageRule(tokens, idx, options, env, self); }; }; md.use(plugin); md.render(mdSource); return assets; }这里过滤掉了http://、https://和//开头的绝对链接。剩下的本地相对路径,交给后续资源处理模块。视频路径通常出现在原生 HTML 的<video><source src="...">里,一样可以用 token 规则收集,或者直接用正则提取<source[^>]+src="([^"]+)"。具体选哪个取决于你 Markdown 的书写习惯。
3.3 集成 ffmpeg 处理图片和视频:压缩、转码、提取封面
资源处理是整个工具里最“重”的部分。ffmpeg 不只是视频转码工具,它也能处理图片。比如把 PNG/JPG 统一压缩成 WebP,并对图片尺寸做限制:
ffmpeg -i input.png -vf "scale='min(1200,iw)':-2" -quality 80 output.webp这条命令的关键是scale滤镜。'min(1200,iw)'意思是如果原图宽度小于 1200 就保持原宽,大于 1200 就缩到 1200。-2表示高度按比例自动计算,并且强制为偶数,因为某些编码格式不支持奇数高度。-quality 80是 WebP 的压缩质量,80 是个肉眼几乎感知不到损失、体积又明显缩小的档位。
视频转码我在前面已经写过基础命令。这里补一个提取视频封面的用法:
ffmpeg -i demo.mp4 -ss 00:00:03 -vframes 1 -vf "scale=1280:-2" cover.jpg-ss 00:00:03表示定位到 3 秒处,-vframes 1表示只输出一帧。注意-ss放在-i后面是“精确解码到 3 秒”,速度慢但更准;放在-i前面是“快速定位”,速度极快,但成功输出关键帧的位置可能不是精确 3 秒。对于提取封面这种场景,我建议把-ss放前面,速度快很多,封面差个零点几秒没人看得出来。
如果你要在 Node.js 里动态拼接这些参数,记住一个原则:所有来自文件系统的路径,必须作为独立参数传入,不要拼进命令行字符串。比如:
execFile('ffmpeg', ['-i', inputPath, '-vf', `scale=${width}:-2`, '-quality', '80', outputPath]);inputPath和outputPath作为数组元素传给 execFile,即使在空格和中文路径下也安全。
3.4 构建 HTML 模板和目录结构
回到 Markdown 转 HTML 本身。Markdown 渲染出来的只是文章正文,还需要包一层完整页面,包括<head>、CSS、目录导航。我习惯准备一个简单的模板字符串:
function buildHtml(title, contentHtml, metadata) { return `<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>${title}</title> <link rel="stylesheet" href="assets/style.css"> </head> <body> <main class="content"> <h1>${title}</h1> ${contentHtml} </main> </body> </html>`; }模板里的assets/style.css指向构建目录里的样式文件。这里的路径要特别小心:如果 HTML 输出在dist/post/a.html,而样式在dist/assets/style.css,那么href="assets/style.css"就不对了,应该用../assets/style.css。我发现最不容易出错的方案是先确定输出 HTML 的相对目录,再用path.relative计算资源路径:
const rel = path.relative(path.dirname(htmlOutput), assetFile); const webPath = toWebPath(rel);这样不管是单层目录还是嵌套多层目录,资源路径永远不会断。目录结构我建议长这样:
docs/ ├── markdown/ │ ├── 001-intro.md │ └── images/ │ ├── demo.png │ └── video.mp4 └── dist/ ├── 001-intro.html └── assets/ ├── demo.webp ├── video-processed.mp4 └── style.css输出目录里的assets集中存放所有经过处理的媒体文件和样式,HTML 文件通过相对路径引用,整包拷到任何服务器上都能独立运行。
4. 实操过程:从零跑通这个工具
4.1 环境准备:Node.js 安装与 ffmpeg 配置
先说 Node.js。去官网下载 LTS 版本,Windows 下直接安装.msi,安装时注意勾选“Add to PATH”。macOS 用户可以用 Homebrew 装:brew install node。Linux 用包管理器,Ubuntu 上sudo apt install nodejs npm,不过我更推荐用nvm装,方便切换版本。
安装完验证:
node -v npm -v如果提示node 不是内部或外部命令,十有八九是 PATH 没配好。Windows 下打开“编辑系统环境变量”,在Path里加一行 Node.js 的安装目录,比如C:\Program Files\nodejs\。改完后一定要重新打开终端,否则环境变量不生效。
ffmpeg 的安装稍微隐蔽一点。Windows 用户大多数人下载的是 “Windows builds” 版本,解压后其实是一个文件夹,里面有个bin/ffmpeg.exe。你需要做的不是把文件夹整个放进去,而是把bin目录加到 PATH。比如解压到D:\ffmpeg,那就把D:\ffmpeg\bin加进系统变量。否则在终端里运行ffmpeg -version就会看到经典报错:ffmpeg 不是内部或外部命令。
还有一个高频问题:Windows 的 PowerShell 下执行npm或node脚本时报错:
npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本这不是 Node 环境有问题,而是 PowerShell 的脚本执行策略默认是Restricted。解决方法有两个:
- 在管理员 PowerShell 里执行
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser,允许本机脚本运行。 - 不用 PowerShell,改用
cmd或 Windows Terminal 里的 Command Prompt 跑 npm 命令。
个人建议方式一,顺手还能让你后面写 npm 测试脚本时不再遇到莫名的执行策略问题。
4.2 初始化项目和目录
环境准备好后,初始化一个 Node 项目:
mkdir md2html-tool cd md2html-tool npm init -y npm install markdown-it然后建一个build.js入口文件。我习惯把所有模块按功能拆成单独的.js文件,比如scanner.js、renderer.js、media.js、cache.js,但下面演示为了阅读方便,我先合在一起讲。
创建源文档目录和输出目录:
mkdir docs mkdir docs/markdown mkdir dist在docs/markdown/test.md写一个简单测试文档:
# 测试文档 你好,这是一个 **Markdown 转换测试**。  <video controls> <source src="./images/sample.mp4" type="video/mp4"> </video>放一张图片和一个视频进去。如果手头没有视频,可以先随便录一段十来秒的屏幕录制,或者用 ffmpeg 生成测试视频:
ffmpeg -f lavfi -i testsrc=duration=5:size=640x480:rate=30 test.mp4这条命令会生成一个 5 秒的彩色测试视频,用来验证转码流程足够了。
4.3 编写构建脚本:从读取到输出
下面是一个能跑的build.js核心逻辑,包含扫描、渲染、媒体处理和写入。我会拆成几个小块讲解。
第一段,扫描所有 Markdown 文件并建立输出映射:
const fs = require('fs'); const path = require('path'); const MarkdownIt = require('markdown-it'); const { execFileSync } = require('child_process'); const crypto = require('crypto'); const zlib = require('zlib'); const SOURCE_DIR = path.join(__dirname, 'docs/markdown'); const DIST_DIR = path.join(__dirname, 'dist'); const CACHE_FILE = path.join(__dirname, 'cache.json'); const md = new MarkdownIt({ html: true, linkify: true }); if (!fs.existsSync(DIST_DIR)) { fs.mkdirSync(DIST_DIR, { recursive: true }); } const mdFiles = []; walkDir(SOURCE_DIR, mdFiles).forEach((file) => { if (/\.(md|markdown)$/i.test(file)) { processMarkdownFile(file); } });这里的walkDir在前面已经写过,直接复用。processMarkdownFile是整个流程的核心:
function processMarkdownFile(mdFilePath) { const src = fs.readFileSync(mdFilePath, 'utf8'); const contentHtml = md.render(src); const title = path.basename(mdFilePath, path.extname(mdFilePath)); const htmlOutput = path.join(DIST_DIR, title + '.html'); const html = buildHtml(title, contentHtml); fs.writeFileSync(htmlOutput, html); writeGzip(htmlOutput, html); console.log(`[build] ${path.relative(__dirname, htmlOutput)}`); }这么说起来很简单,但还没处理媒体。补上进阶版本:遍历contentHtml里的图片和视频路径,先按原路径转成绝对路径,复制或转码到dist/assets下,再替换 HTML 里的src属性。
由于用正则替换会显得比较乱,我先给每个媒体资源生成新的文件名。命名的规则是原文件名 + 内容哈希前缀。这样同一个文件重复转换时可以直接复用输出,不需要重新跑 ffmpeg。
function ensureMedia(srcPath, destDir) { const hash = crypto.createHash('sha1').update(fs.readFileSync(srcPath)).digest('hex').slice(0, 8); const ext = path.extname(srcPath).toLowerCase(); const destName = `${path.basename(srcPath, ext)}-${hash}${ext}`; const destPath = path.join(destDir, destName); if (!fs.existsSync(destPath)) { processMedia(srcPath, destPath); } return destPath; }ext这里我要强调一下,如果原图是.png,你希望转成.webp,那ext不应该取原始扩展名,而是要按目标格式拼。后面我会单独说怎么处理格式转换。
4.4 验证运行结果
运行:
node build.js正常的话终端会打印构建文件列表,dist目录下出现测试 HTML、压缩后的图片、转码后的 MP4,还有每个 HTML 的.gz版本。再用浏览器直接打开 HTML,能看到图片正常显示、视频能播放,说明路径替换成功。
这个阶段最容易出问题的点是路径替换。如果你打开 HTML 后图片裂了,优先按F12打开开发者工具,看图片请求的 URL 是不是 404,然后对照dist目录里的实际文件名。如果文件名一模一样,那就是当前位置相对于图片的路径算错了。建议在ensureMedia里返回一个relative路径,而不是绝对路径,这样后续模板替换更直观。
5. 常见问题与排查技巧实录
5.1 PATH 配置类问题
下面这几个问题基本是环境变量引起的,我把高频情况列成表格:
| 报错信息 | 原因 | 解决方式 |
|---|---|---|
node 不是内部或外部命令 | Node 安装目录未加入 PATH | 把 Node 安装目录加到系统 PATH,重新开终端 |
ffmpeg 不是内部或外部命令 | ffmpeg 的 bin 目录未加入 PATH | 将 ffmpeg 解压目录下的bin路径加入 PATH |
ffmpeg: command not found | Linux/macOS 下未安装 ffmpeg | brew install ffmpeg或apt install ffmpeg |
npm 无法加载文件 ... npm.ps1 | PowerShell 执行策略限制 | Set-ExecutionPolicy RemoteSigned -Scope CurrentUser |
改完 PATH 以后务必重启终端,因为环境变量读取是在终端启动时完成的。旧的终端窗口不会感知新的 PATH。如果你改完没有任何反应,还报同样错误,先敲echo $env:Path(PowerShell)或echo %Path%(CMD)确认一下有没有生效。
5.2 Windows 下 npm 脚本执行策略问题
除了 npm.ps1 的报错,还有一个比较隐蔽的问题:很多教程让你在 package.json 里写"build": "node build.js"然后npm run build,如果之前装了 Yarn 或者 nvm,可能会因为 PATH 里的 Node 路径顺序不对,导致npm调用了错误版本的 node。这个问题的排查方法是先跑where node和where npm,看它们指向哪里。如果npm指向一个旧的 npm 脚本而node是新的,那就要手动调整 PATH 顺序,把C:\Program Files\nodejs\排在前面。
5.3 文件路径过长 warning
Git 在 Windows 上经常报warning: path too long,其实 Git 默认限制了路径长度,Windows 本身也有限制。解决办法是以管理员身份打开 Git Bash,运行:
git config --system core.longpaths true如果你遇到的不是 Git,而是 Node.js 读写文件时报ENAMETOOLONG,那通常是因为输出目录嵌套太深或者文件名太长。建议在ensureMedia里缩短哈希长度,或直接扁平化输出目录,不要保留多层嵌套。HTML 的内链用相对路径就能有效避免把整个绝对路径暴露给浏览器。
5.4 child_process 调用 ffmpeg 失败排查
调用 ffmpeg 失败时,Node.js 抛出的错误对象里往往只有一段提示,不够明确。我的排查套路是:先把execFile的参数数组原样打印出来,然后手动把它们拼成一行命令在终端里跑一遍。如果终端一跑就成功,说明问题出在 Node.js 的调用方式上;如果终端也失败,那就是 ffmpeg 命令或者输入文件本身有问题。
典型错误包括:
No such file or directory:输入路径不对,多半是相对路径计算错。Unknown encoder 'libx264':你的 ffmpeg 编译版本没带 H.264 编码器。这是 Windows 某个精简版 ffmpeg 的老问题,换成官网的 full build 或者从 gyan.dev 下载版本能解决。Permission denied:输出目录没有写权限,检查dist目录属性。Invalid data found when processing input:文件本身不是完整的视频,可能是录制过程中中断导致。
5.5 增量缓存和哈希冲突问题
哈希缓存也不是万无一失。如果你在ensureMedia里用了sha1并截断到 8 位,理论上碰撞概率很低,但磁盘文件一旦被截断名覆盖,哈希对应关系就丢了。我一般会在首次生成时把哈希写到cache.json,下次先读缓存,如果缓存里已经存在相同哈希的文件就直接引用,不再读取原文件。这样可以省一次fs.readFileSync的开销,在文件数量大时优势明显。
但也有一种情况会造成缓存失效:你把原文件重命名了,但内容没变。这时哈希变了,旧文件会被重新转码一次。这是增量构建的正常行为,不用太担心。真正需要留意的是:如果你把图片格式从 PNG 转成 WebP,缓存 key 应该包含目标格式,否则同一个源文件第二次转成 JPEG 时会错误复用 WebP 的输出。最简单的做法是 key 用源哈希 + 目标扩展名拼接。
const cacheKey = `${hash}.${targetExt}`;5.6 调试 ffmpeg 进度和性能的小技巧
在转码长视频时,execFile回调里拿不到逐行进度,因为 ffmpeg 的进度是写到 stderr 的,而且带有\r回车符。如果你用的是spawn,可以监听 stderr 数据,写一个简单的进度条。但这个属于锦上添花,我实际使用中更关注的是:不要在主进程里同步调用 ffmpeg,否则视频转码 10 分钟,你的工具就干等 10 分钟,体验极差。改成spawn或者用execFile的异步回调,用Promise.all并行处理多个视频。
另一个性能优化是控制并行度。我有一次在一台 4 核机器上同时跑 6 个 ffmpeg 转码任务,结果不仅每个任务慢了一圈,还导致系统整体卡顿。后来统一用os.cpus().length - 1作为并发数,用简单的计数器把任务分配到固定并行度,效果立马改善。这个细节在输出日志中变化很直观,也值得记下来。
写在最后的一点个人体会
把 Node.js 的 path、FS、child_process 这些模块串起来做工具,比较考验你对“进程边界”和“路径语义”的理解。Markdown 转 HTML 只是入口,真正的复杂度全在资源处理和构建缓存上。踩过几次格式转换、路径拼接和 PATH 配置的坑之后,我养成了一个习惯:每写一个调用外部命令的函数,先把参数打印出来人工验一遍,再丢给 execFile。这是目前对我帮助最大的调试手段。
如果你也想自己搭一套类似的工具,建议从一个小目录开始,不要一开始就想着处理上百篇文章。先把一个文件、一张图、一个视频完整跑通,再把循环和缓存加上去。工具类项目的复杂度往往是循环带来的,单个文件的正确性是地基。等这套东西稳定了,再考虑往里面加 TOC 生成、样式主题、语法高亮,甚至接一个静态站点生成器,都有很自然的扩展路径。