1. 项目概述:一个极简主义者的“数字花园”构建实践
最近在逛GitHub的时候,发现了一个挺有意思的项目,叫dinoDanic/diny。光看这个名字,你可能会有点摸不着头脑,diny是什么?一个工具?一个框架?还是一个库?点进去看,它的描述往往只有寥寥数语,比如“A tiny, fast, and fun static site generator”或者干脆就是“My personal site”。这其实是一个非常典型的个人项目,一个开发者为自己打造的、高度定制化的“数字花园”或静态博客生成器。这类项目在开源社区里遍地开花,像 Hugo、Jekyll、Hexo 都是其中的佼佼者。但diny这类项目最吸引我的地方,恰恰在于它的“小”和“个人化”。它不是要做一个功能大而全的通用平台,而是为了解决一个非常具体、非常个人的需求:如何用最少的代码、最清晰的逻辑,快速搭建和维护一个完全属于自己的线上空间。
对于很多开发者,尤其是前端或全栈开发者来说,使用现成的博客框架有时会感觉“太重了”。你只是想写点东西,却需要先理解一整套主题系统、插件机制、配置规则。diny这类项目的核心价值,就在于它的“极简”和“透明”。它通常只包含最核心的功能:将 Markdown 文件转换成 HTML 页面,加上一个简单的模板系统,或许再集成一个本地的开发服务器。所有的代码都在一个仓库里,所有的逻辑你都能看懂,所有的样式你都能随心所欲地修改。这就像自己动手搭一个乐高小屋,每一块积木你都清楚它的位置和作用,而不是直接搬进一个精装修的样板房。
所以,这篇内容我想深入聊聊,当我们看到dinoDanic/diny这样一个项目标题时,背后所代表的“个人静态站点生成器”这一领域的核心玩法。我会从一个实践者的角度,拆解从零开始构思、设计、实现这样一个工具的全过程,分享其中的技术选型、架构设计、踩坑经验以及最终的部署实践。无论你是想自己动手造一个轮子来学习,还是想寻找一个更轻量、更可控的方案来管理你的技术笔记和个人博客,相信这些经验都能给你带来直接的参考。
2. 核心设计思路:为什么选择“从零开始”?
2.1 需求定义与现有方案分析
在决定动手之前,明确自己的核心需求至关重要。对于个人站点生成器,需求通常非常聚焦:
- 内容创作友好:必须支持 Markdown,这是技术写作的事实标准。
- 构建速度极快:文章数量增多后,生成速度不能成为瓶颈。
- 部署极其简单:最好能直接生成纯静态文件,扔到任何对象存储或 GitHub Pages 上就能访问。
- 完全可控:样式、布局、交互逻辑,我都要能完全掌控,不受制于主题或插件的限制。
- 学习与乐趣:这个过程本身应该是有趣的,能让我深入理解静态站点生成(SSG)的原理。
基于这些需求,我们来看看主流方案:
- Hexo/Jekyll:功能丰富,插件生态庞大。但对于一个只有几十篇文章的小站来说,其复杂性和启动速度有时显得冗余。定制主题需要学习其特定的模板语法和目录结构。
- Hugo:以速度极快著称,用 Go 编写。但它的模板语言和配置方式有独特的学习曲线,且二进制文件的特性使得深入定制核心逻辑比较困难。
- VuePress/Next.js:基于现代前端框架,交互能力强。但带来了客户端运行时,对于纯内容展示站,有时显得“杀鸡用牛刀”,且构建配置相对复杂。
分析下来,现有方案在“完全可控”和“极简透明”上,与我的终极目标仍有差距。我希望我的站点生成器,代码量可能就几百行,我能在半小时内给任何人讲清楚它的每一部分是如何工作的。这就是diny这类项目的出发点:做减法,聚焦核心链路。
2.2 技术栈选型与权衡
技术选型决定了项目的开发体验和最终性能。我们的目标是轻量和高效。
核心语言:Node.js vs. Go vs. Python
- Node.js:这是我最推荐的选择,也是社区里类似工具的主流选择。原因有三:1) 对前端开发者极其友好,生态完善;2) 非阻塞I/O适合文件读写密集型任务;3) 有
marked、highlight.js、front-matter等大量成熟库处理 Markdown 和元数据,让我们可以专注于流程编排,而非底层解析。 - Go:性能无敌,编译成单文件二进制,分发方便。但如果你不熟悉 Go,其语法和生态的学习成本会冲淡“快速实现”的乐趣。适合对性能有极致要求且熟悉 Go 的开发者。
- Python:脚本语言,上手快,但通常在构建速度上不如前两者。适合小型、临时的脚本。
基于快速开发和生态丰富度,我们选择Node.js。
- Node.js:这是我最推荐的选择,也是社区里类似工具的主流选择。原因有三:1) 对前端开发者极其友好,生态完善;2) 非阻塞I/O适合文件读写密集型任务;3) 有
关键依赖库:
- Markdown 解析:
marked。它足够快,支持 GFM(GitHub Flavored Markdown),扩展性也不错。 - 语法高亮:
highlight.js。轻量,支持语言多,样式主题丰富。 - 前端元数据解析:
front-matter。用于从 Markdown 文件头部提取标题、日期、标签等信息。 - 模板引擎:
ejs或handlebars。它们简单直观。ejs允许在模板中直接写 JavaScript,灵活性极高;handlebars逻辑与表现分离更彻底。这里选择ejs,因为它更贴近“在HTML里写逻辑”的直观感觉。 - 开发服务器与文件监听:
live-server或browser-sync。它们能提供热重载,提升开发体验。我们甚至可以自己用chokidar库监听文件变化,然后调用构建逻辑,实现定制化的热更新。
- Markdown 解析:
项目结构规划一个清晰的结构是项目可维护的基础。我采用的典型结构如下:
diny/ ├── src/ # 源代码目录 │ ├── build.js # 核心构建脚本 │ ├── server.js # 开发服务器脚本 │ └── utils/ # 工具函数(如日期格式化、slug生成) ├── content/ # 原始 Markdown 文章 │ ├── posts/ │ │ ├── hello-world.md │ │ └── ... │ └── pages/ # 关于页、项目页等 ├── templates/ # EJS 模板文件 │ ├── layout.ejs # 基础布局 │ ├── post.ejs # 文章详情页模板 │ ├── index.ejs # 首页模板 │ └── partials/ # 头部、尾部等组件 ├── public/ # 构建输出目录(最终静态文件) ├── assets/ # 静态资源(CSS, JS, images) │ ├── css/ │ └── js/ ├── config.json # 站点配置文件(标题、描述、导航等) └── package.json这个结构将源文件、内容、模板、配置和输出清晰分离,符合“关注点分离”的原则。
3. 核心模块实现详解
3.1 构建引擎:从 Markdown 到 HTML 的流水线
构建引擎是diny的心脏。它的任务很明确:读取content/下的 Markdown 文件,结合模板和配置,生成最终的 HTML 文件到public/目录。
核心流程如下:
读取与解析内容:
// build.js 核心片段 const fs = require('fs-extra'); // 使用 fs-extra 增强文件操作 const path = require('path'); const matter = require('front-matter'); const marked = require('marked'); const hljs = require('highlight.js'); // 配置 marked 使用 highlight.js 进行代码高亮 marked.setOptions({ highlight: function(code, lang) { if (lang && hljs.getLanguage(lang)) { try { return hljs.highlight(code, { language: lang }).value; } catch (err) {} } return hljs.highlightAuto(code).value; }, pedantic: false, gfm: true, breaks: false, sanitize: false, smartLists: true, smartypants: false, xhtml: false }); async function buildPosts() { const postsDir = path.join(__dirname, '../content/posts'); const postsFiles = await fs.readdir(postsDir); const posts = []; for (const file of postsFiles) { if (!file.endsWith('.md')) continue; const filePath = path.join(postsDir, file); const content = await fs.readFile(filePath, 'utf8'); const { attributes, body } = matter(content); // 解析 front-matter const htmlContent = marked(body); // 将 Markdown 正文转为 HTML const post = { ...attributes, // 确保必须有 title 和 date title: attributes.title || 'Untitled', date: attributes.date ? new Date(attributes.date) : new Date(), // 从文件名生成 slug(用于URL) slug: attributes.slug || file.replace('.md', ''), content: htmlContent }; posts.push(post); } // 按日期倒序排列 posts.sort((a, b) => b.date - a.date); return posts; }这里的关键是使用
front-matter分离元数据(YAML格式)和正文。元数据(如标题、日期、标签)用于组织和渲染,正文则交给marked转换。模板渲染: 获取到所有文章数据(
posts数组)和站点配置后,就可以用模板引擎生成页面了。const ejs = require('ejs'); async function renderTemplate(templateName, data) { const templatePath = path.join(__dirname, `../templates/${templateName}.ejs`); const templateStr = await fs.readFile(templatePath, 'utf8'); return ejs.render(templateStr, data, { root: path.join(__dirname, '../templates') }); } async function generatePages(posts, siteConfig) { // 1. 生成每篇文章的独立页面 for (const post of posts) { const html = await renderTemplate('post', { post, siteConfig }); const outputPath = path.join(__dirname, `../public/posts/${post.slug}/index.html`); await fs.ensureDir(path.dirname(outputPath)); // 确保目录存在 await fs.writeFile(outputPath, html); } // 2. 生成首页(文章列表) const indexHtml = await renderTemplate('index', { posts, siteConfig }); await fs.writeFile(path.join(__dirname, '../public/index.html'), indexHtml); // 3. 生成其他页面(如关于页) // ... 类似逻辑 }ejs模板(如post.ejs)可能长这样:<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title><%= post.title %> | <%= siteConfig.title %></title> <link rel="stylesheet" href="/assets/css/style.css"> </head> <body> <%- include('partials/header') %> <article class="post"> <h1><%= post.title %></h1> <time datetime="<%= post.date.toISOString() %>"> <%= post.date.toLocaleDateString() %> </time> <div class="content"> <%- post.content %> <!-- 注意这里是<%-,表示输出原始HTML --> </div> </article> <%- include('partials/footer') %> </body> </html>静态资源处理: 构建时,需要将
assets/目录直接复制到public/下。async function copyAssets() { await fs.copy(path.join(__dirname, '../assets'), path.join(__dirname, '../public/assets')); }完整的构建函数: 最后,将所有步骤串联起来。
async function build() { console.time('Build finished'); // 清空输出目录 await fs.emptyDir(path.join(__dirname, '../public')); // 读取配置 const siteConfig = await fs.readJson(path.join(__dirname, '../config.json')); // 执行构建流水线 const posts = await buildPosts(); await generatePages(posts, siteConfig); await copyAssets(); console.timeEnd('Build finished'); console.log(`Generated ${posts.length} posts.`); } // 如果直接运行此脚本,则执行构建 if (require.main === module) { build().catch(console.error); } module.exports = build; // 方便其他模块(如开发服务器)调用
实操心得:在构建流水线中,错误处理和日志输出非常重要。我建议为每个步骤(读取文件、解析、渲染、写入)都加上 try-catch,并输出有意义的错误信息,比如是哪篇文章出了问题。这能在你写作或修改模板出错时,快速定位问题。
3.2 开发服务器与热重载
为了提高开发效率,一个带热重载的本地服务器必不可少。我们可以基于chokidar监听文件变化,然后重新构建并通知浏览器刷新。
// server.js const chokidar = require('chokidar'); const liveServer = require('live-server'); // 一个简单的静态服务器 const build = require('./build'); // 导入构建函数 async function startDevServer() { // 先执行一次初始构建 await build(); // 启动 Live Server const params = { port: 8080, host: 'localhost', root: './public', // 服务 public 目录 open: true, ignore: 'node_modules', file: 'index.html', wait: 500 // 文件变化后延迟刷新 }; liveServer.start(params); // 监听文件变化 const watcher = chokidar.watch([ './content/**/*.md', './templates/**/*.ejs', './assets/**/*', './config.json' ], { ignored: /(^|[\/\\])\../, // 忽略隐藏文件 persistent: true, ignoreInitial: true }); watcher .on('change', async (path) => { console.log(`File ${path} has been changed. Rebuilding...`); try { await build(); console.log('Rebuild successful.'); // 这里可以更优雅地通知浏览器刷新,live-server 的 wait 参数已做简单处理 } catch (error) { console.error('Rebuild failed:', error); } }) .on('error', error => console.error(`Watcher error: ${error}`)); console.log('Dev server is running on http://localhost:8080'); console.log('Watching for changes...'); } startDevServer().catch(console.error);这个服务器实现了:保存 Markdown 或修改模板后,自动重新构建站点,并且浏览器页面会自动刷新。开发体验瞬间提升。
3.3 功能增强:标签系统与 RSS 生成
一个基本的博客还需要一些增强功能。
1. 标签系统:我们需要在解析文章时,从 front-matter 中提取tags字段(假设是一个数组),然后生成一个标签索引页和每个标签的专属页面。
// 在 buildPosts 函数中,收集所有标签 const tagMap = {}; // { tagName: [post1, post2, ...] } for (const post of posts) { // ... 解析文章 ... if (post.tags && Array.isArray(post.tags)) { for (const tag of post.tags) { if (!tagMap[tag]) tagMap[tag] = []; tagMap[tag].push(post); } } } // 生成标签页面 async function generateTagPages(tagMap, siteConfig) { for (const [tagName, taggedPosts] of Object.entries(tagMap)) { const html = await renderTemplate('tag', { tagName, posts: taggedPosts, siteConfig }); const outputPath = path.join(__dirname, `../public/tags/${encodeURIComponent(tagName)}/index.html`); await fs.ensureDir(path.dirname(outputPath)); await fs.writeFile(outputPath, html); } // 生成标签总览页 const allTags = Object.keys(tagMap); const tagsHtml = await renderTemplate('tags', { tags: allTags, siteConfig }); await fs.writeFile(path.join(__dirname, '../public/tags/index.html'), tagsHtml); }2. RSS 生成:RSS 是内容分发的标准。生成一个feed.xml文件其实很简单,就是按照 RSS 2.0 的 XML 格式拼接字符串。
const { format } = require('date-fns'); // 用于日期格式化 async function generateRSS(posts, siteConfig) { const rssItems = posts.map(post => ` <item> <title><![CDATA[${post.title}]]></title> <link>${siteConfig.url}/posts/${post.slug}/</link> <guid isPermaLink="true">${siteConfig.url}/posts/${post.slug}/</guid> <pubDate>${format(post.date, 'EEE, dd MMM yyyy HH:mm:ss xx')}</pubDate> <description><![CDATA[${post.excerpt || post.content.substring(0, 200)}...]]></description> </item> `).join(''); const rss = `<?xml version="1.0" encoding="UTF-8"?> <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> <channel> <title><![CDATA[${siteConfig.title}]]></title> <link>${siteConfig.url}</link> <description><![CDATA[${siteConfig.description}]]></description> <language>en</language> <lastBuildDate>${format(new Date(), 'EEE, dd MMM yyyy HH:mm:ss xx')}</lastBuildDate> <atom:link href="${siteConfig.url}/feed.xml" rel="self" type="application/rss+xml"/> ${rssItems} </channel> </rss>`; await fs.writeFile(path.join(__dirname, '../public/feed.xml'), rss); }记得在build函数中调用generateRSS。
注意事项:RSS 中的日期格式必须严格遵守 RFC 822 标准(可使用
date-fns的format函数),否则一些 RSS 阅读器可能无法正确解析。另外,<![CDATA[ ]]>用于包裹可能包含 HTML 特殊字符的内容,如文章标题和描述。
4. 部署与持续集成
4.1 部署到 GitHub Pages
这是最简单、最流行的免费部署方案。我们的public目录就是标准的静态网站。
- 在 GitHub 上创建一个仓库,比如
username.github.io(这是个人主页仓库)或任意名称。 - 将本地代码推送到该仓库。
- 在仓库的Settings -> Pages中,将 Source 设置为
main分支的/root目录(如果你把构建输出的public目录内容直接放在根目录)或/docs目录。更常见的做法是使用 GitHub Actions 自动构建。
4.2 使用 GitHub Actions 自动化构建
我们不想将构建生成的public目录提交到代码库,而是希望每次推送 Markdown 源文件时,自动触发构建,并将生成的静态文件部署到gh-pages分支或直接发布到 GitHub Pages。
创建一个.github/workflows/deploy.yml文件:
name: Deploy to GitHub Pages on: push: branches: [ main ] # 只在 main 分支推送时触发 workflow_dispatch: # 允许手动触发 jobs: build-and-deploy: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v3 - name: Setup Node.js uses: actions/setup-node@v3 with: node-version: '18' cache: 'npm' - name: Install Dependencies run: npm ci # 使用 ci 命令确保依赖锁定 - name: Build run: npm run build # 假设你在 package.json 中定义了 build 脚本 - name: Deploy to GitHub Pages uses: peaceiris/actions-gh-pages@v3 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./public # 将 public 目录的内容部署到 gh-pages 分支 # 如果你用的是 username.github.io 仓库,可以设置 publish_branch: main这样,每次你写完文章并推送到 GitHub,大约一分钟后,你的网站就会自动更新。完全实现了“写作-推送-发布”的自动化流水线。
4.3 部署到其他平台
- Vercel/Netlify:它们对静态站点的支持是无与伦比的。只需将你的 Git 仓库连接到这些平台,它们会自动检测到你的项目(比如是 Node.js 项目),运行
npm run build,并将public目录部署到全球 CDN。通常还提供自定义域名、HTTPS、预览部署等功能。 - Cloudflare Pages:类似 Vercel,由 Cloudflare 提供,在全球边缘网络运行,速度极快。
- AWS S3 + CloudFront:如果你需要完全的控制权和 AWS 生态集成,这是一个企业级方案。将
public目录同步到 S3 存储桶,然后用 CloudFront 做 CDN 加速。
对于个人项目,Vercel或Netlify的体验是最佳的,几乎零配置。
5. 进阶优化与踩坑实录
5.1 性能优化:让构建更快
当文章数量达到几百篇时,构建速度可能变慢。优化点包括:
- 增量构建:只构建发生变化的文件。可以通过记录文件的哈希值(如
md5)来实现,但实现起来稍复杂。对于个人博客,全量构建通常在几秒内完成,增量构建的收益不大,除非文章量巨大。 - 并行处理:使用
Promise.all()来并行处理文章解析和页面生成。async function buildPostsParallel(postsDir) { const files = (await fs.readdir(postsDir)).filter(f => f.endsWith('.md')); const postPromises = files.map(async (file) => { // ... 解析单篇文章的逻辑 ... }); const posts = await Promise.all(postPromises); return posts.sort((a, b) => b.date - a.date); } - 缓存:将解析后的文章数据(front-matter 和转换后的 HTML)缓存到内存或一个临时 JSON 文件中,如果源文件未修改,则直接使用缓存。这需要更精细的文件监听和缓存失效策略。
5.2 常见问题与排查
构建后页面样式丢失或图片不显示
- 原因:最可能是资源路径问题。在开发服务器上,根路径是
/。但部署到子路径(如username.github.io/repo-name)时,你的资源路径href="/assets/css/style.css"会指向错误的绝对根路径。 - 解决:在站点配置
config.json中增加一个baseUrl字段,在模板中所有资源链接前加上它。
在本地开发时,<!-- 在模板中 --> <link rel="stylesheet" href="<%= siteConfig.baseUrl %>/assets/css/style.css">baseUrl设为空字符串"";在部署时,设为你的站点根路径,如"/my-blog"。
- 原因:最可能是资源路径问题。在开发服务器上,根路径是
Markdown 中的特殊语法或扩展不被支持
- 原因:
marked默认配置可能不支持某些语法(如脚注、定义列表、任务列表)。 - 解决:查阅
marked文档,启用对应的扩展选项,或者寻找并集成其他插件(如marked-footnote)。也可以考虑换用更强大的解析器,如remark生态系统,但复杂度会显著增加。
- 原因:
代码高亮样式不生效
- 原因:
highlight.js只生成了带 CSS 类名的 HTML,你需要额外引入一个高亮主题 CSS 文件。 - 解决:从
highlight.js的样式仓库中选择一个喜欢的主题(如github-dark.css),下载到assets/css/目录,并在模板中引入。<link rel="stylesheet" href="<%= siteConfig.baseUrl %>/assets/css/highlight-theme.css">
- 原因:
日期处理时区混乱
- 原因:JavaScript 的
Date对象和不同环境的时区处理可能造成显示不一致。 - 解决:在 front-matter 中,使用 ISO 8601 格式的日期字符串(如
date: 2023-10-27T09:00:00.000Z)。在构建时,使用new Date(dateString)解析,并考虑使用date-fns或dayjs这类库来进行时区无关的格式化和计算,或者在构建时统一转换为 UTC 时间。
- 原因:JavaScript 的
开发服务器热重载不触发
- 原因:
chokidar可能没有正确监听到文件变化,或者live-server的刷新机制没生效。 - 排查:首先确认
chokidar监听的路径模式是否正确。其次,可以尝试在change事件回调里,除了重新构建,还手动触发一个动作(比如向一个特定的服务器端点发送请求)来通知浏览器刷新。也可以考虑使用browser-sync替代live-server,它提供了更强大的 API 来控制浏览器刷新。
- 原因:
5.3 扩展思路:你的diny可以走得更远
一个基础版本完成后,你可以根据个人需求无限扩展:
- 分页功能:首页文章列表太长?实现一个分页组件,在构建时计算总页数,生成
page/1,page/2等目录。 - 搜索功能:客户端搜索可以使用
lunr.js或flexsearch在构建时生成搜索索引文件,然后在前端进行检索。 - 图片处理:集成
sharp库,在构建时自动将文章中的图片生成多种尺寸的 WebP 格式,并生成srcset,实现响应式图片。 - SEO 增强:自动为每篇文章生成
sitemap.xml和robots.txt。在模板中精心设计<meta>标签,特别是og:(Open Graph)和twitter:卡片信息,让文章在社交媒体分享时更美观。 - 评论系统:集成无后端评论方案,如 Giscus(基于 GitHub Discussions)或 Utterances(基于 GitHub Issues)。
- 自动化写作脚本:写一个 Node.js 脚本,通过命令行交互创建一篇新的 Markdown 文件,并自动生成 front-matter 模板(标题、日期、文件名等)。
通过这样一个从零构建diny的过程,你收获的不仅仅是一个用来写博客的工具。你深入理解了静态站点生成器的核心原理,掌握了文件处理、模板渲染、构建自动化等一系列工程化实践,并且拥有了一个完全贴合自己心意、可以随意“折腾”的数字基地。这种掌控感和创造力,正是开源社区中无数个dinoDanic/diny项目诞生的原动力。