在 Node.js 服务端使用 marked.js 生成静态 HTML 页面的实践
详细介绍如何在 Node.js 服务端使用 marked.js 将 Markdown 源码编译生成静态 HTML 页面的完整实践,包含环境准备、解析配置、语法高亮、模板组装与工程化批量输出。
假设一个常见的工程场景:团队需要为开源项目编写技术文档或维护内部知识库,所有文档均采用 Markdown 编写保存在 Git 仓库中。如果为此引入 Next.js、Nuxt 或 Gatsby 等重型全栈框架,往往会带来庞大的依赖体积、复杂的水合过程(Hydration)以及额外的框架升级心智负担;而如果直接在浏览器端加载 Markdown 并在前端执行解析,又会面临首屏白屏时间长、对搜索引擎爬虫极不友好的致命短板。
本文的解决路径是:借助轻量、高效的 marked.js,在 Node.js 服务端脱机阶段将 Markdown 批量编译为原生静态 HTML,并结合母版布局(Layout)生成完整文档。这种方式不依赖任何前端框架运行时,产物可直接投递至 Nginx、GitHub Pages 或 CDN 边缘对象存储,兼具毫秒级生成性能与极致的首屏加载速度。
一、准备工作与环境依赖
在开始构建之前,需要初始化一个轻量化的 Node.js 工程。推荐使用 Node.js 18 LTS 或更高版本,以获得原生 node:fs/promises 与稳定模块解析支持。
本方案所涉及的核心依赖库包括:
- marked:轻量且高度兼容 CommonMark 与 GFM(GitHub Flavored Markdown)规范的解析与渲染核心库。
- marked-highlight 与 highlight.js:用于在服务端构建阶段对代码块执行语法高亮分词,直接输出带样式类名的 HTML 节点,避免客户端加载大型高亮库。
- gray-matter:解析 Markdown 文件开头的 YAML Frontmatter 格式头部信息,提取页面标题、发布日期、标签等元数据。
- sanitize-html(可选/安全防护):在需要处理不可信用户输入内容时,执行严格的 HTML 标签清洗,阻断 XSS 风险。
在终端中执行以下命令初始化工程并安装依赖包:
# 创建工程目录并初始化
mkdir marked-ssg-demo && cd marked-ssg-demo
npm init -y
# 安装核心依赖
npm install marked marked-highlight highlight.js gray-matter
# 可选:安装安全净化库
npm install sanitize-html
安装完成后,检查 package.json。建议在其中添加 "type": "module",以便采用现代 ES 模块语法编写构建脚本。
// package.json 片段
{
"name": "marked-ssg-demo",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "node build.js"
}
}
二、静态构建流程设计与核心步骤
整个静态生成系统遵循“数据采集 → 核心编译 → 模板包装 → 磁盘写入”的管道化设计思想。将每个步骤保持单一职责,便于后续扩展与单独调试。
1. 读取源文件与分离元数据
通过 Node.js 原生文件系统读取 .md 文件内容。技术文档通常包含 YAML 格式的元数据(如标题、日期、分类),必须在传递给 marked 前将其与正文拆分,否则 YAML 头部会被误识别为引用块或普通文本。
2. 配置 marked 编译引擎与扩展
调用 marked.use() 装配所需插件。在现代版本的 marked 中,语法高亮等能力已解耦为官方维护的专用扩展,通过 marked-highlight 拦截代码块渲染,由 highlight.js 执行着色转换。
3. 母版 HTML 骨架注入
Markdown 解析出的产物仅为 <p>、<h1>、<pre> 等正文 HTML 片段,无法独立作为网页渲染。必须将其包裹进包含 <!DOCTYPE html>、<head>、导航栏、CSS 链接与页脚的完整 HTML 骨架中。
4. 多级目录创建与写入
根据源文件的路径层级,在目标 dist 目录下递归创建对应目录(例如 content/posts/demo.md 映射为 dist/posts/demo.html),最后将完整 HTML 写入磁盘并同步拷贝静态样式资源。
三、编写完整的静态页面生成脚本
下面给出工程中完整的 build.js 核心脚本。代码采用现代 ES 模块规范,具备目录递归扫描、Frontmatter 解析、代码高亮集成与 HTML 模板渲染能力。
// build.js
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { marked } from 'marked';
import { markedHighlight } from 'marked-highlight';
import hljs from 'highlight.js';
import matter from 'gray-matter';
// 解析当前目录路径
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const CONTENT_DIR = path.join(__dirname, 'content');
const DIST_DIR = path.join(__dirname, 'dist');
const TEMPLATES_DIR = path.join(__dirname, 'templates');
const STATIC_DIR = path.join(__dirname, 'static');
// 1. 初始化并配置 marked 解析引擎
marked.use(
markedHighlight({
emptyLangClass: 'hljs',
langPrefix: 'hljs language-',
highlight(code, lang) {
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
return hljs.highlight(code, { language }).value;
}
})
);
// 2. 递归获取目录下所有 Markdown 文件的绝对路径
async function getMarkdownFiles(dir) {
let results = [];
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
results = results.concat(await getMarkdownFiles(fullPath));
} else if (entry.isFile() && entry.name.endsWith('.md')) {
results.push(fullPath);
}
}
return results;
}
// 3. 母版 HTML 模板拼装函数
function renderFullHtml({ title, description, contentHtml, relativeRoot }) {
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title || '文档页面'}</title>
<meta name="description" content="${description || ''}">
<link rel="stylesheet" href="${relativeRoot}static/style.css">
<link rel="stylesheet" href="${relativeRoot}static/highlight.css">
</head>
<body>
<header class="site-header">
<div class="container">
<a class="brand" href="${relativeRoot}index.html">文档中心</a>
</div>
</header>
<main class="container content">
<article>
${contentHtml}
</article>
</main>
<footer class="site-footer">
<div class="container"><p>由 Node.js & marked.js 构建生成</p></div>
</footer>
</body>
</html>`;
}
// 4. 拷贝静态资源目录
async function copyDirectory(src, dest) {
await fs.mkdir(dest, { recursive: true });
const entries = await fs.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await copyDirectory(srcPath, destPath);
} else {
await fs.copyFile(srcPath, destPath);
}
}
}
// 5. 主构建执行流水线
async function build() {
console.log('[Build] 开始静态站点构建流水线...');
const startTime = Date.now();
// 清理并确保 dist 目录就绪
await fs.rm(DIST_DIR, { recursive: true, force: true });
await fs.mkdir(DIST_DIR, { recursive: true });
// 获取所有 Markdown 文件
const files = await getMarkdownFiles(CONTENT_DIR);
console.log(`[Build] 发现 ${files.length} 个 Markdown 源码文档`);
for (const filePath of files) {
const rawContent = await fs.readFile(filePath, 'utf-8');
// 提取 Frontmatter 元数据与正文
const { data: metadata, content: markdownBody } = matter(rawContent);
// 编译 Markdown 为 HTML 片段
const contentHtml = marked.parse(markdownBody);
// 计算相对路径层级以适配静态资源引入 (例如 posts/demo.html 为 '../')
const relativePath = path.relative(CONTENT_DIR, filePath);
const depth = relativePath.split(path.sep).length - 1;
const relativeRoot = depth > 0 ? '../'.repeat(depth) : './';
// 拼装完整 HTML
const finalHtml = renderFullHtml({
title: metadata.title,
description: metadata.description,
contentHtml,
relativeRoot
});
// 计算输出目标文件路径(将 .md 替换为 .html)
const targetRelativePath = relativePath.replace(/\.md$/, '.html');
const targetFullPath = path.join(DIST_DIR, targetRelativePath);
// 递归创建父级目录并写入文件
await fs.mkdir(path.dirname(targetFullPath), { recursive: true });
await fs.writeFile(targetFullPath, finalHtml, 'utf-8');
console.log(`[Build] 已生成: ${targetRelativePath}`);
}
// 同步静态资源
try {
await copyDirectory(STATIC_DIR, path.join(DIST_DIR, 'static'));
console.log('[Build] 静态资源复制完成');
} catch (err) {
console.warn('[Build] 未发现 static 目录或复制跳过:', err.message);
}
const duration = Date.now() - startTime;
console.log(`[Build] 静态页面生成完成,耗时 ${duration}ms`);
}
build().catch(err => {
console.error('[Build Error] 构建失败:', err);
process.exit(1);
});
运行命令与控制台输出
在项目根目录执行构建命令:
node build.js
示例构建输出如下:
[Build] 开始静态站点构建流水线...
[Build] 发现 3 个 Markdown 源码文档
[Build] 已生成: index.html
[Build] 已生成: posts/intro.html
[Build] 已生成: posts/marked-guide.html
[Build] 静态资源复制完成
[Build] 静态页面生成完成,耗时 38ms
四、进阶定制:代码高亮与自定义渲染规则
默认情况下,marked 按照通用标准输出 HTML 标签。但在实际业务中,我们通常需要补充定制化需求:例如为所有外部链接添加 rel="noopener noreferrer" 防范安全钓鱼、为文章中的图片统一添加 loading="lazy" 以优化加载体验,或为标题自动生成可点击跳转的锚点 ID。
通过 marked.use 拦截并扩展 Renderer
通过自定义 renderer,可以无侵入地拦截特定 Markdown 语法节点的渲染输出。以下展示重写链接和图片的典型配置:
// 扩展 marked 的渲染逻辑
marked.use({
renderer: {
// 重写链接渲染:外链自动在新窗口打开并附加安全属性
link({ href, title, text }) {
const isExternal = href.startsWith('http://') || href.startsWith('https://');
const targetAttr = isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
const titleAttr = title ? ` title="${title}"` : '';
return `<a href="${href}"${titleAttr}${targetAttr}>${text}</a>`;
},
// 重写图片渲染:增加原生懒加载属性
image({ href, title, text }) {
const titleAttr = title ? ` title="${title}"` : '';
return `<img src="${href}" alt="${text}"${titleAttr} loading="lazy" class="responsive-img">`;
}
}
});
在较新的 marked(v9+ 与 v12+)中,renderer 方法接收由对象封装的命名参数(例如 { href, title, text }),替代了早期版本按位置传递的多形参签名。在编写扩展时,建议以官方当前规范为准。
五、工程化组织与资源流向关系
建立规范清晰的工程目录结构是保证静态生成脚本长期可维护的关键。源码目录与产物目录必须完全解耦,确保构建时可以随时安全清空产物目录而不影响源文件。
核心目录职能划分
| 目录 / 文件 | 所属环境 | 职责说明 |
|---|---|---|
content/ |
源码目录 | 存放纯 Markdown 内容源文件,支持按业务主题建立多层嵌套子目录。 |
templates/ |
源码目录 | 保存基础 HTML 骨架、布局母版及页眉页脚组件片段。 |
static/ |
源码目录 | 存放 CSS、高亮主题样式、Logo、字体等不需要经过 Markdown 编译的原始静态资源。 |
build.js |
构建脚本 | 纯 Node.js 服务端编译脚本,负责协调解析、模板组装与文件输出。 |
dist/ |
发布产物 | 静态 HTML 与静态资源的输出目录,完全脱机,可直接推送到静态托管平台。 |
六、安全防范与静态生成注意事项
在实际落地过程中,由于环境差异与内容来源不同,存在若干容易引发故障或安全隐患的关键细节:
Markdown 规范原生允许混编 HTML 代码。如果 Markdown 内容来源于第三方用户提交或开源协作 PR,攻击者可能在 Markdown 中植入 <script>alert(1)</script> 或 <img src="x" onerror="steal()">。此时必须在 marked.parse() 后接入 sanitize-html 过滤不安全标签:
import sanitizeHtml from 'sanitize-html';
const rawHtml = marked.parse(userProvidedMarkdown);
const safeHtml = sanitizeHtml(rawHtml, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat([ 'img', 'h1', 'h2' ]),
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
img: [ 'src', 'alt', 'loading' ],
a: [ 'href', 'target', 'rel' ]
}
});
2. 相对路径与静态资源定位
对于存放在多级子目录中的文件(如 posts/engineering/doc.html),若在模板中写死 href="static/style.css",在子页面访问时会导致相对路径解析错误。通常有两种应对方案:
- 绝对路径方案:在部署环境域名根目录下运行时,静态资源统一使用绝对根路径
/static/style.css。 - 相对根深动态计算:如前文代码所示,根据源文件相对于根目录的深度,动态计算
../前缀,保证页面无论在本地本地文件预览还是任意子路径下均可正确加载。
3. 增量构建机制(优化千页规模项目)
对于拥有数千篇文档的中大型文档库,全量重新生成会造成磁盘 I/O 浪费。可通过比对源 Markdown 文件的 mtime(最后修改时间)与目标 HTML 文件的修改时间,仅对有更新的页面执行编译,使构建时间缩短至秒级。
七、实践总结与核心考量矩阵
在服务端使用 marked.js 实现静态页面生成,是一种返璞归真的高性价比技术路线。它剔除了复杂前端单页应用的运行时包袱,专注于快速将结构化内容转换为语义化 HTML。
在技术选型时,可根据以下四项关键指标进行权衡评估:
- 构建速度与轻量级:单页转换在毫秒内完成,极度适合 CI/CD 快速部署。
- 安全与可控性:服务端完成语法解析与属性重写,脱离动态服务端执行风险。
- 生态通用性:输出标准 HTML5 结构,无缝兼容任意全文搜索系统(如 Pagefind、Algolia)与 CDN 缓存策略。
- 运维维护性:单文件或极简脚本即可维护全站构建流水线,避免前端构建框架版本频繁断代升级带来的维护困境。
Node.js中使用path模块获取文件名
2026-11-05 15:15:15
Node.js中url模块与querystring模块处理查询字符串的区别
2026-11-05 15:04:45
JavaScript(JS)主要用来做什么
2026-11-05 10:10:47
怎么修改Node.js和npm的全局安装路径与缓存目录
2026-11-04 19:48:28
NW.js 常见问题排查:Node 模块加载失败与版本兼容解决
2026-11-04 11:22:45








