用Middleman搭建静态站点的实战经验分享
项目初期的技术选型
这项目是个给客户做的静态内容站,主要是展示产品文档、更新日志和一些帮助中心的内容。需求明确:不需要后端逻辑,但结构要清晰,SEO 要好,还得方便客户自己后期维护 Markdown 文件。
一开始考虑过 Jekyll,毕竟 GitHub Pages 支持它,但之前在另一个项目里被它的插件系统搞得很烦,尤其是 Windows 环境下 bundle install 动不动就崩。后来团队有人提了 Middleman,说轻量、灵活,基于 Ruby 但不用懂太多 Ruby 也能上手。我查了下文档,发现它对静态站点的控制粒度很细,而且可以自定义数据源、构建流程,甚至能模拟 API 接口做假数据调试——这点当时没觉得多重要,后来成了救命稻草。
最后拍板用 Middleman,主要是看中两点:一是可以用 layout + partial 的方式组织模板,二是 build 出来的就是纯静态文件,扔到任何 CDN 都能跑。客户也不用操心服务器运维。
最大的坑:性能问题
项目做到一半,页面数量上了 80 多个,每次改一个 Markdown 文件,middleman server 重启一次要 12 秒起步。本地开发体验直接崩了。我一开始以为是电脑问题,清缓存、重装 gem、换 rbenv 版本……折腾了半天发现不是环境问题。
后来打开 --verbose 模式看构建日志,发现每次启动都会重新加载所有 data 文件,包括一个从远程拉的 JSON 配置(用来生成导航栏)。虽然用了 cache,但 Middleman 默认不会持久化缓存,一重启全丢。
这里注意我踩过好几次坑:网上很多文章说加 activate :cache 就行,但其实这个只对模板输出缓存有效,对 data 目录下的动态加载没用。
最终解决方案是手动加一层文件缓存:
# helpers/data_helper.rb
def fetch_remote_config
cache_file = 'tmp/remote_config.json'
if File.exist?(cache_file) && (Time.now - File.mtime(cache_file)) < 3600
return JSON.parse(File.read(cache_file))
else
url = 'https://jztheme.com/api/v1/navigation.json'
response = open(url).read
data = JSON.parse(response)
FileUtils.mkdir_p('tmp') unless Dir.exist?('tmp')
File.write(cache_file, response)
data
end
end
# config.rb
helpers do
include DataHelper
end
data.navigation = fetch_remote_config
这样本地开发时,除非缓存过期,否则不会每次都打外网请求。启动时间从 12s 降到 4s 左右。虽然还是不够理想,但至少能忍了。
顺带一提,后来我们把那个远程接口改成本地 YAML 文件由客户维护,彻底解决了这个问题。但中间这段折腾让我意识到:Middleman 虽然静态,但你得自己管好“动态依赖”。
内容组织的混乱与重构
刚开始图省事,所有文档都堆在 source/docs 下面,用 frontmatter 控制分类。结果到第 50 篇的时候,根本找不到文件在哪。客户也反馈说目录结构不直观。
后来调整了方案,按模块拆成子目录:
source/docs/
├── product-a/
│ ├── intro.html.md
│ ├── setup.html.md
│ └── faq.html.md
├── product-b/
│ ├── overview.html.md
│ └── migration.html.md
└── shared/
└── _sidebar.erb
然后在 config.rb 里用正则匹配路径来设置 layout 和 meta:
page '/docs/*/index.html', layout: 'docs-layout'
page '/docs/**/*.html', layout: 'docs-layout'
ready do
sitemap.resources.select { |r| r.path&.start_with?('docs/') }.each do |resource|
next unless resource.data.title
# 自动生成面包屑
parts = resource.path.split('/')[1..-2]
resource.data.breadcrumbs = parts.map.with_index do |part, i|
path = '/' + parts[0..i].join('/')
{ label: part.humanize, url: path }
end
end
end
还写了个 helper 自动读取当前目录下的所有 Markdown 生成侧边栏:
# helpers/docs_helper.rb
def current_section_pages
current_dir = current_page.directory
sitemap.resources.select { |r|
r.path.start_with?(current_dir) &&
r.path.end_with?('.html') &&
r.path != 'index.html'
}.sort_by { |r| r.data.weight || 999 }
end
这样每个产品模块只要放一个 _sidebar.erb,就能自动列出本目录下的页面。虽然权重还得靠 frontmatter 手动写 weight: 10,但比纯手工维护链接强多了。
搜索功能的土法实现
客户非要加全文搜索。但我们是纯静态站,不可能上 ElasticSearch。本来想用 Algolia,但客户嫌贵又不想配 API 密钥。
最后我整了个“离线搜索”:构建时把所有页面标题和正文前 200 字抽出来,生成一个 search-data.json,前端用 lunr.js 做本地检索。
# config.rb
ready do
articles = sitemap.resources.select { |r|
r.path&.start_with?('docs/') && r.content_type == :text
}.map { |r|
{
id: r.url,
title: r.data.title,
body: strip_tags(r.render)[0..500],
tags: r.data.tags || []
}
}
File.open('build/search-data.json', 'w') do |f|
f.write(JSON.pretty_generate(articles))
end
end
// app/javascript/search.js
fetch('/search-data.json')
.then(r => r.json())
.then(function (docs) {
const idx = lunr(function () {
this.ref('id')
this.field('title', { boost: 10 })
this.field('body')
docs.forEach(doc => this.add(doc))
})
window.searchIndex = idx
window.searchDocs = docs
})
HTML 里加个 input 实时触发:
<input type="search" id="search-input" placeholder="搜点啥..." />
<ul id="search-results"></ul>
document.getElementById('search-input').addEventListener('input', function (e) {
const query = e.target.value
if (!window.searchIndex || !query) return
const results = window.searchIndex.search(query).slice(0, 10)
const list = document.getElementById('search-results')
list.innerHTML = results.map(r => {
const doc = window.searchDocs.find(d => d.id === r.ref)
return <li><a href="${doc.id}">${doc.title}</a></li>
}).join('')
})
效果不算完美,build 时间多了 3 秒,search-data.json 有 400KB,但好歹能用。移动端稍微卡顿,不过客户 Accept 了。
回顾与反思
现在回头看,Middleman 对这种中等复杂度的静态站是够用的。它不像 Gatsby 那么重,也不像 Hugo 那么难定制。Ruby DSL 写配置确实有点小众,但一旦熟悉了反而比 JSON 或 JS 配置更灵活。
做得好的地方:
- 模板复用做得干净,layout 嵌套清晰
- 通过 data + helper 实现了动态内容管理
- 搜索虽然土,但零成本上线
还能优化的:
- 构建速度还是慢,可以考虑拆 site into multiple sub-sites
- 没有真正的 i18n 支持,靠复制目录处理多语言太蠢
- Markdown 渲染层级太深时,highlight.js 有时失效,到现在没完全解决,只能手动加
language-类
有个小问题一直留着:某些嵌套很深的页面,frontmatter 修改后局部刷新不生效,必须强制 reload。怀疑是 livereload websocket 路径识别有问题,但不影响交付就没深挖。
以上是我的项目经验,希望对你有帮助
这个项目断断续续做了三个月,中间换了两版结构,踩了不少坑。Middleman 不是最潮的工具,但在特定场景下依然能打。如果你也在做内容密集型静态站,又不想搞 Node 生态那一套,不妨试试。
以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流。这类静态站点的技术细节还有很多可聊的,比如如何自动化部署、怎么对接 CI、如何做 A/B 测试页面,后续可能会继续分享。

暂无评论