news 2026/9/15 15:14:57

Node.js原生模块实战:用内置API构建Markdown转HTML静态博客工具

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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-ithighlight.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.promisesfs.rmSyncnode:os等 API 在低版本上不够稳。初始化项目时我保留了 CommonJS 而不是"type": "module",理由很朴素:CommonJS 的require在写构建脚本时不需要处理import的路径后缀问题,而且大量现成示例都是 CommonJS,新手抄起来更安全。

项目结构我建议这样:

├── build/ │ └── index.js # 构建主脚本 ├── src/ │ ├── posts/ # 放 Markdown 文件 │ ├── assets/ # 放 CSS / JS / 图片 │ └── templates/ # 页面模板片段 ├── videos/ # 原始视频素材 ├── dist/ # 最终产物 └── package.json

package.json先通过npm init -y生成,再手动加两个依赖:markdown-ithighlight.js。执行构建只需要一句脚本:

{ "scripts": { "build": "node build/index.js" } }

2.2 这些没配置好,后面全是坑

我见过太多新手卡在环境变量上:npm命令找不到,多半是安装 Node.js 后,C:\Program Files\nodejs\这个目录没有进系统 PATH;ffmpeg不是内部或外部命令,同样是 bin 目录没配好。这里可以先检查一下:命令行分别执行node -vnpm -vffmpeg -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; }

readdirwithFileTypes: 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.mdabout/foo.md就会撞名,后面做资源引用也会是一锅粥。

3.3 写入和删除目录时最容易忽视的细节

在复制静态资源到dist时,如果目录不存在,fs.writeFile会直接报错。fs.mkdirrecursive: 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.js
const 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的发行版渲染器允许你替换规则。我想给所有图片加上loading="lazy",给所有外链加上target="_blank"rel="noopener 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,把href="http开头且不指向自己域名的链接统一处理。这部分用简单字符串替换就够了,因为markdown-it输出的格式相对固定。但更好的做法是注册link_open规则,在 token 阶段处理,避免操作字符串。

4.3 模板注入:页面必须有头有脚

生成完整 HTML 时,我用一个模板函数把标题、CSS 路径和正文拼起来:

function renderPage(title, body, cssPath) { 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="${cssPath}"> </head> <body> <main class="content"> ${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 手动加?v=1可靠得多,不会因为忘了改版本号而翻车。

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 缓冲到内存,上限 1MB,ffmpeg 的进度信息全在 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.jpg

6.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,手写一个迷你参数解析器

构建命令如果只能写死路径,用起来很难受。我加了一点点命令行参数能力,解析--outDir=public--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 --outDir=public --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、cpus

os在这个项目里至少有三个用途:os.cpus().length控制并发(上一节已经用到);os.tmpdir()拿系统临时目录;os.EOL在写日志或生成 Windows 批处理文件时保证换行符正确。生成 HTML 时我依然用\n,因为浏览器对换行符不敏感,但写.cli或调试信息时用os.EOL更稳。

还有一个容易被忽略的场景:判断当前平台以便打开浏览器预览。Windows 用start,macOS 用open,Linux 用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,用execexecSync拼命令时必须手工加引号,很容易漏。用spawnexecFile这类 API 时,参数本来就是数组,空格会被原样传给子进程,不需要额外处理。这也是我坚持不用exec调用 ffmpeg 的原因之一。

8.4 Markdown 允许 HTML 带来的安全问题

markdown-ithtml: true意味着 Markdown 里的原始 HTML 会被原样输出。如果只有你自己写博客,问题不大;如果未来有其他人投稿,就存在 XSS 风险,比如<img src=x onerror=alert(1)>。我在渲染前对允许的 HTML 标签做了白名单过滤,或者至少要把config设为不可对非可信源放开这个选项。对个人工具来说,心里有数就行。

8.5 最后补一句个人经验

重写这第三版构建器时,我最大的体会是:Node.js 内置模块不是“玩具”,fspathprocessoscryptozlibchild_process组合起来已经能覆盖大多数日常工具链需求。遇到问题优先查这些模块的官方文档,比自己重复造轮子和盲目引包都靠谱。没有框架约束的代码,反而让我把每一步流程都想清楚了。如果你也想练手,别急着去写复杂的博客系统,先拿一个 Markdown 转 HTML 脚本开工,跑通之后,再去接 ffmpeg、加缓存策略,每一层都会带给你真实的正反馈。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 15:07:58

拆解无JS电商静态页:多CSS文件与纯CSS交互方案

简介&#xff1a;一套源自一号店早期官网的静态HTML源代码&#xff0c;面向前端入门者、网页设计人员及电商平台研究者&#xff0c;用于学习纯HTMLCSS构建电商页面的经典方式。压缩包共330个文件&#xff0c;约2.97MB&#xff0c;图片素材占绝大多数&#xff0c;包括189个JPG、…

作者头像 李华
网站建设 2026/9/15 15:06:10

NotepadNext 如何按文档步骤升级 thirdparty 中的 Scintilla 依赖

NotepadNext 如何按文档步骤升级 thirdparty 中的 Scintilla 依赖 【免费下载链接】NotepadNext A cross-platform, reimplementation of Notepad 项目地址: https://gitcode.com/GitHub_Trending/no/NotepadNext NotepadNext 把 Scintilla 以源码形式内嵌在 thirdparty…

作者头像 李华