Node.js原生模块实战:用内置API构建Markdown转HTML静态博客工具
用 Node.js 把 Markdown 批量转成 HTML这件事本身不新鲜但如果你全程只用 Node.js 内置的 path、fs、process、child_process、os、crypto、zlib 这些模块不套任何重型框架再把 ffmpeg 也塞进构建流程里体验会完全不一样。年初我在折腾个人博客的时候实在受不了各种主题框架的模板语法一怒之下用原生 Node.js 自己写了一个小构建工具把src/posts下的 Markdown 文件批量渲染成 HTML 页面自动处理 CSS、JS 指纹生成 gzip 压缩产物还能顺带调用 ffmpeg 把文章里的视频压成小体积版本并配一张封面图。整个过程没有框架只有一堆 Node.js 原生模块和两个 Markdown 解析相关的库跑通之后我对这些模块的理解比看一个月文档都深。这篇文章不打算写成 API 手册我按实际开发顺序来还原整个工具环境怎么搭、目录怎么扫、Markdown 怎么转 HTML、产物怎么做指纹和压缩、外部视频工具怎么接入、命令行参数怎么解析最后是几处我重写时才踩明白的坑。适合刚学完 Node.js 基础、想把这些模块真正练起来的读者也适合想写一个私有静态博客工具的人。1. 从“又出 bug 了”到“还是自己写吧”这个工具到底在解决什么问题1.1 折腾主题的时间比写文章还多以前我用的博客系统生态很丰富但每次想改一个细节都要去翻主题源代码模板继承、数据注入、自定义短代码一层套一层。后来我想通了我的需求其实就是把 Markdown 变成一整套静态 HTML 页面外加处理好视频资源。这个需求完全可以靠 Node.js 原生能力完成还能顺便把内置模块摸熟。这个工具最终长这样一个build.js脚本读取配置后递归扫描src/posts找到所有.md文件逐个渲染成完整的 HTML 文档写到dist目录同时把 CSS 和 JS 复制到产物目录并追加哈希指纹再对全部文本产物做一次 gzip 预压缩。如果素材目录里有 MP4/MOV 视频就通过子进程调用 ffmpeg 转成 WebM/MP4 小体积版本并抽一帧作为封面图。1.2 为什么坚持用 Node.js 内置模块项目里除了markdown-it和highlight.js这两个专门负责内容解析和代码高亮的库其余文件操作、路径处理、外部进程调用、哈希计算、压缩、参数解析全部由 Node.js 原生模块完成。这样做不是因为“原生的一定比轮子好”而是因为对这个体量的静态构建器来说fs足够快path足够安全child_process足够灵活引入框架反而增加了概念负担。下面这张表是整个工具会用到的东西也是你看完全文之后的模块地图模块在实际构建中负责的事fs递归扫描目录、读写 Markdown / HTML / CSS 文件path跨平台拼接路径、提取扩展名、生成相对路径process解析命令行参数、读环境变量、设置退出码os获取 CPU 核数、系统临时目录、平台差异处理child_process调用 ffmpeg 处理视频、生成封面crypto对文件内容做摘要生成哈希指纹zlib预压缩 HTML / CSS / JS生成.gz文件ffmpeg不需要 node 包通过命令行集成2. 动手前先理环境Node.js 版本、包管理器路径和项目结构2.1 版本选择和初始化套路我用的 Node.js LTS 版本这里建议至少18.x因为后面fs.promises、fs.rmSync、node:os等 API 在低版本上不够稳。初始化项目时我保留了 CommonJS 而不是type: module理由很朴素CommonJS 的require在写构建脚本时不需要处理import的路径后缀问题而且大量现成示例都是 CommonJS新手抄起来更安全。项目结构我建议这样├── build/ │ └── index.js # 构建主脚本 ├── src/ │ ├── posts/ # 放 Markdown 文件 │ ├── assets/ # 放 CSS / JS / 图片 │ └── templates/ # 页面模板片段 ├── videos/ # 原始视频素材 ├── dist/ # 最终产物 └── package.jsonpackage.json先通过npm init -y生成再手动加两个依赖markdown-it和highlight.js。执行构建只需要一句脚本{ scripts: { build: node build/index.js } }2.2 这些没配置好后面全是坑我见过太多新手卡在环境变量上npm命令找不到多半是安装 Node.js 后C:\Program Files\nodejs\这个目录没有进系统 PATHffmpeg不是内部或外部命令同样是 bin 目录没配好。这里可以先检查一下命令行分别执行node -v、npm -v、ffmpeg -version哪个报错就去补哪个的 PATH。在 Windows 上如果安装时提示path too long installer unable to modify path!不要硬装手动打开系统环境变量把 Node.js 安装目录和 ffmpeg 的bin目录单独新增进去即可。这个坑本身和构建代码无关但只要 PATH 没配好后面 Node 子进程调用 ffmpeg 时会直接报 ENOENT那时候排查会以为自己的代码写错了。还有一个 PowerShell 下非常典型的问题执行npm时报“禁止运行脚本”这是因为默认 ExecutionPolicy 不允许.ps1脚本运行。解决办法是在 PowerShell 里执行一次Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser然后重开终端。这个跟代码逻辑无关但几乎每个 Windows 新手都会遇到提前处理完后面写脚本时不用反复被环境问题打断。3. 用 fs 和 path 把目录扫清楚并安全地构造每一个路径3.1 递归扫描 Markdown 文件构建的第一步是拿到所有待转换的文件路径。很多第一次写的人会用字符串拼接路径比如dir / entry.name这在 Linux/macOS 上勉强能用到 Windows 上就乱了因为 Windows 的分隔符是\。我改用path.join由 Node 根据当前系统自动选择正确分隔符const fs require(fs); const path require(path); async function listMarkdownFiles(dir) { const results []; const entries await fs.promises.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath path.join(dir, entry.name); if (entry.isDirectory()) { results.push(...(await listMarkdownFiles(fullPath))); } else if (entry.isFile() /\.md$/i.test(entry.name)) { results.push(fullPath); } } return results; }readdir的withFileTypes: true选项很有用它能直接告诉你每一项是文件还是目录省掉了一次fs.stat调用。递归的时候要注意entry.name可能包含特殊字符比如空格和中文所以路径拼接必须交给path.join而不是手动加/。3.2 用 path 系列 API 处理文件名和输出目录拿到文件路径后还要生成对应的输出路径。path.parse非常方便const parsed path.parse(/src/posts/hello-world.md); // { root: /, dir: /src/posts, base: hello-world.md, // ext: .md, name: hello-world }输出 HTML 文件名就取parsed.name .html。如果文件在一个子目录里你需要保留目录结构这时候用path.relative(projectRoot, filePath)拿到相对路径再把扩展名换成.html放到dist下。比如const relativeDir path.relative(path.join(process.cwd(), src/posts), filePath); const htmlFileName path.basename(filePath, path.extname(filePath)) .html; const outFilePath path.join(config.outDir, path.dirname(relativeDir), htmlFileName);这里我没有存心写复杂是因为静态博客通常希望 URL 目录干净。如果不处理所有页面会挤在一起about.md和about/foo.md就会撞名后面做资源引用也会是一锅粥。3.3 写入和删除目录时最容易忽视的细节在复制静态资源到dist时如果目录不存在fs.writeFile会直接报错。fs.mkdir的recursive: true能从根目录一次性创建所有层级的目录await fs.promises.mkdir(outDir, { recursive: true });重新构建时还需要清理旧产物。Node.js 14.14 之后的fs.rmSync是最好用的const fs require(fs); fs.rmSync(config.outDir, { recursive: true, force: true });force: true保证目录不存在时不抛异常。不少老教程还在用fs.rmdirSync({ recursive: true })它在现代 Node 里已经废除了不要再抄那种写法。4. Markdown 到 HTML 这层壳解析器选型、自定义渲染与模板注入4.1 自己写解析器不划算但也不能直接甩锅给别人我见过有人试图用一个正则把#、**、-全处理掉最后在表格和代码块上崩掉。Markdown 解析是个完整的问题域边角规则太多这不是 Node.js 核心模块该干的活。所以我引入markdown-it它是解析库而非框架行为和配置都很透明。这样安排很明确工程化的脏活累活全用 Node.js 内置模块内容解析交给专业库。安装依赖后初始化一个解析器实例npm install markdown-it highlight.jsconst MarkdownIt require(markdown-it); const hljs require(highlight.js); const md new MarkdownIt({ html: true, // 允许原始 HTML 出现在 Markdown 中 linkify: true, // 自动把 URL 转成链接 breaks: false, // 单个换行是否转成 br后面会说 highlight(code, lang) { if (lang hljs.getLanguage(lang)) { return hljs.highlight(code, { language: lang }).value; } return ; } });然后在读入每个 Markdown 文件后调用md.render(content)就能得到中间 HTML 字符串。这段 HTML 还不能直接落到磁盘需要套模板、处理资源路径、生成完整页面。4.2 自定义渲染规则解决图片懒加载和外链新窗口markdown-it的发行版渲染器允许你替换规则。我想给所有图片加上loadinglazy给所有外链加上target_blank和relnoopener noreferrer默认语法并不提供这个能力。修改图片渲染规则可以这样写const defaultImageRender md.renderer.rules.image || md.renderer.renderToken.bind(md.renderer); md.renderer.rules.image (tokens, idx, options, env, self) { const token tokens[idx]; token.attrSet(loading, lazy); token.attrSet(decoding, async); return defaultImageRender(tokens, idx, options, env, self); };外链新窗口需要在打开链接时判断是不是站内链接我选择直接扫描渲染后的 HTML把hrefhttp开头且不指向自己域名的链接统一处理。这部分用简单字符串替换就够了因为markdown-it输出的格式相对固定。但更好的做法是注册link_open规则在 token 阶段处理避免操作字符串。4.3 模板注入页面必须有头有脚生成完整 HTML 时我用一个模板函数把标题、CSS 路径和正文拼起来function renderPage(title, body, cssPath) { return !doctype html html langzh-CN head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1 title${title}/title link relstylesheet href${cssPath} /head body main classcontent ${body} /main footer...页面底部信息.../footer /body /html; }cssPath不要写死应该用path.relative(path.dirname(outFilePath), cssFileUrl)计算出来保证所有子目录页面都能正确引到静态资源。否则你首页能打开posts/2025/下的页面全找不到 CSS。4.4 Markdown 换行这个坎新手经常在 Markdown 里写完一个段落后按一次回车想换行结果渲染后却是同一个段落。原因是标准 Markdown 的“硬换行”需要行尾加两个空格单独一个换行通常被解释为空格。如果你的内容基本是从聊天工具或便签里复制的建议把breaks设为true这样每个换行都变成br所见即所得const md new MarkdownIt({ breaks: true });代价是段落内的换行也会变成换行标签在中文博客里影响不大。我个人的选择是保留标准行为写作时主动在行尾留两个空格这样源头更规范。5. 给产物上两道保险用 crypto 生成文件指纹用 zlib 做预压缩5.1 哈希指纹解决缓存不更新的老问题静态站发布后浏览器可能会缓存旧 CSS/JS。CSS 内容变了文件名不变用户刷新看到的还是旧样式。解决办法叫“内容寻址”把文件内容经 SHA-256 哈希取前 12 位拼进文件名例如style.8a3f2c1b4d5e.css。内容变了哈希就变浏览器会当成新文件拉取。Node.js 内置的crypto做这件事非常顺手const crypto require(crypto); function contentHash(input) { return crypto.createHash(sha256).update(input).digest(hex).slice(0, 12); } const cssContent fs.readFileSync(src/assets/style.css, utf8); const hashedCssName style.${contentHash(cssContent)}.css;如果你在 HTML 生成前就把哈希算好模板里引用的就是带哈希的文件名发布时把dist全部上传即可。这个方案比给 URL 手动加?v1可靠得多不会因为忘了改版本号而翻车。5.2 gzip 预压缩让静态站少一次 CPU 消耗很多静态托管服务在返回.gz文件时会自动选择压缩版本前提是你在部署前已经生成好了带.gz后缀的文件。Node.js 内置zlib可以很轻松地做到这一点const zlib require(zlib); function writeGzipArtifact(content, outputFile) { const gzipped zlib.gzipSync(content, { level: 9, mtime: 0 }); fs.writeFileSync(outputFile .gz, gzipped); }对纯文本类产物HTML、CSS、JS、JSON压缩率非常可观一个 30KB 的 HTML 往往能压到 6KB 左右。但千万别对图片、视频做 gzip它们是已压缩格式强行压一遍只会白白消耗 CPU甚至让体积变大。我通常把所有产物写完后统一遍历dist只对指定的文本扩展名生成.gz伴生文件。注意mtime: 0能保证不同机器上生成的 gzip 结果一致方便做增量发布。6. 构建链里接上 ffmpeg用 child_process 处理视频和封面6.1 为什么用 child_process 而不是 ffmpeg 的 npm 包视频处理这件事Node.js 内置模块里没有能直接读懂 MP4 容器的东西但系统上如果有 ffmpeg 命令行工具Node 就能通过child_process调用它。我不推荐在项目里套一层 ffmpeg 的 npm 封装因为封装抽象得再好底层命令参数你还是要懂而且一旦 ffmpeg 升级封装包跟不上就卡壳。直接使用spawn调用二进制是最透明的方式。先检查 ffmpeg 是否可用。最简单的是执行一次ffmpeg -version用promisify(execFile)包一层也能做但如果后面要抓大量 stderr 日志更推荐spawn。6.2 spawn 才是和 ffmpeg 打交道的正确姿势我最早用execFile去调 ffmpeg结果日志一多就报错。因为execFile默认会把 stdout/stderr 缓冲到内存上限 1MBffmpeg 的进度信息全在 stderr 上超过这个量直接抛异常。后来改成spawn把 stderr 当数据流读问题才彻底消失。下面是一个并发压视频的简化实现用os.cpus()决定并行数避免一次性把所有转码任务都丢出去const { spawn } require(child_process); const os require(os); async function convertVideo(input, output) { return new Promise((resolve, reject) { const args [ -i, input, -c:v, libx264, -preset, medium, -crf, 23, -pix_fmt, yuv420p, -c:a, aac, -b:a, 128k, -movflags, faststart, -y, output ]; const child spawn(ffmpeg, args, { stdio: [ignore, pipe, pipe] }); child.stderr.on(data, chunk { // ffmpeg 进度和日志默认都走 stderr process.stderr.write([ffmpeg] ${chunk}); }); child.on(error, reject); child.on(exit, code { if (code 0) resolve(output); else reject(new Error(ffmpeg exited with code ${code})); }); }); }参数里-movflags faststart对网页播放很重要它把 moov atom 移到文件头部浏览器可以更早开始播放。-pix_fmt yuv420p是兼容性保险避免某些播放器对yuv444支持不好。视频封面图可以单独跑一条命令抽取第 2 秒附近的一帧ffmpeg -y -i input.mp4 -ss 00:00:02 -frames:v 1 poster.jpg6.3 控制并发和临时目录别把系统资源拖垮对一批视频转码时我会先收集所有视频文件路径然后写一个最小并发池const workers Math.max(1, os.cpus().length - 1); let cursor 0; async function runTask() { while (cursor videoFiles.length) { const input videoFiles[cursor]; const output path.join(config.videoOutDir, path.basename(input, path.extname(input)) .mp4); await convertVideo(input, output); } } await Promise.all(Array.from({ length: workers }, runTask));os.cpus().length - 1意味着留一个核给系统适合在后台构建。中转文件可以放到os.tmpdir()里处理完用fs.rmSync清理避免污染项目目录。6.4 ffmpeg 找不到别怪代码如果spawn报了Error: spawn ffmpeg ENOENT基本就是 PATH 环境变量没有 ffmpeg 的位置。Windows 下安装 ffmpeg 后要把安装目录的bin文件夹加入系统 PATH然后重新打开终端和 IDE子进程才能继承到新的 PATH。这条前面说过在这里确实值得再强调一次因为它和子进程调用强相关。7. 用 process 和 os 让构建命令能配置、能并发、能退出得漂亮7.1 不引入 yargs手写一个迷你参数解析器构建命令如果只能写死路径用起来很难受。我加了一点点命令行参数能力解析--outDirpublic、--skip-video这种格式function parseArgs(argv) { const args {}; for (let i 2; i argv.length; i) { const item argv[i]; const eqIndex item.indexOf(); if (eqIndex -1) { const key item.slice(2, eqIndex); args[key] item.slice(eqIndex 1); } else { const key item.replace(/^--?/, ); args[key] true; } } return args; } const config { srcDir: path.join(process.cwd(), src/posts), outDir: path.join(process.cwd(), dist), video: true, ...parseArgs(process.argv) };这样运行node build/index.js --outDirpublic --skip-video时就能跳过转码输出到public目录。不引入命令行解析库的原因是参数就几个手写十行比装包更可控也让process.argv这个模块真正落到实处。7.2 用 process.exitCode 收尾而不是直接 exit构建脚本里发生错误时不能一律process.exit(1)。如果还有异步任务没结束直接退出会丢掉日志甚至把正在写的文件截断。更好的做法是记录错误设置退出码让 Node 事件循环自然结束后退出process.on(unhandledRejection, (err) { console.error(构建失败:, err); process.exitCode 1; });这样 CI/CD 能拿到非 0 退出码同时所有 pending 的日志和文件操作都能尽可能完成。7.3 os 模块不是摆设EOL、tmpdir、cpusos在这个项目里至少有三个用途os.cpus().length控制并发上一节已经用到os.tmpdir()拿系统临时目录os.EOL在写日志或生成 Windows 批处理文件时保证换行符正确。生成 HTML 时我依然用\n因为浏览器对换行符不敏感但写.cli或调试信息时用os.EOL更稳。还有一个容易被忽略的场景判断当前平台以便打开浏览器预览。Windows 用startmacOS 用openLinux 用xdg-open。通过process.platform判断然后交给child_process.spawn可以做一个--preview参数构建完自动打开首页。8. 修过的一串坑从 npm 报错到 execFile 的缓冲区8.1 npm.ps1 执行策略和 PATH 过长这是两个环境问题不算代码问题却足以耽误一下午。Windows 的 PowerShell 默认禁止运行 npm 的.ps1脚本所以要先把执行策略改成RemoteSigned。安装 Node.js 时如果弹出 “PATH too long”不要去改系统里那一长串既有路径直接手工添加C:\Program Files\nodejs\到用户 PATH 就完了。类似地ffmpeg 的 bin 目录也要这样加。8.2 execFile 的 maxBuffer 坑我最早调 ffmpeg 时用的是promisify(execFile)看起来代码很简洁但跑一段长视频就报Error: stdout maxBuffer length exceeded。原因是 ffmpeg 的进度日志全在 stderr默认缓冲太大就越限。换成spawn后日志变成流就没有这个问题了。如果你的场景只是调用命令并等待结果且确实要用execFile记得把maxBuffer设成足够大的值const { execFile } require(child_process); execFile(ffmpeg, [-version], { maxBuffer: 10 * 1024 * 1024 }, callback);但我不建议这样用spawn才是正路。8.3 路径含空格时的参数传递如果视频文件名是my video.mp4用exec或execSync拼命令时必须手工加引号很容易漏。用spawn和execFile这类 API 时参数本来就是数组空格会被原样传给子进程不需要额外处理。这也是我坚持不用exec调用 ffmpeg 的原因之一。8.4 Markdown 允许 HTML 带来的安全问题markdown-it的html: true意味着 Markdown 里的原始 HTML 会被原样输出。如果只有你自己写博客问题不大如果未来有其他人投稿就存在 XSS 风险比如img srcx onerroralert(1)。我在渲染前对允许的 HTML 标签做了白名单过滤或者至少要把config设为不可对非可信源放开这个选项。对个人工具来说心里有数就行。8.5 最后补一句个人经验重写这第三版构建器时我最大的体会是Node.js 内置模块不是“玩具”fs、path、process、os、crypto、zlib、child_process组合起来已经能覆盖大多数日常工具链需求。遇到问题优先查这些模块的官方文档比自己重复造轮子和盲目引包都靠谱。没有框架约束的代码反而让我把每一步流程都想清楚了。如果你也想练手别急着去写复杂的博客系统先拿一个 Markdown 转 HTML 脚本开工跑通之后再去接 ffmpeg、加缓存策略每一层都会带给你真实的正反馈。