用Middleman搭建静态站点的实战经验分享

Air-云超 框架 阅读 632
赞 11 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

这项目是个给客户做的静态内容站,主要是展示产品文档、更新日志和一些帮助中心的内容。需求明确:不需要后端逻辑,但结构要清晰,SEO 要好,还得方便客户自己后期维护 Markdown 文件。

用Middleman搭建静态站点的实战经验分享

一开始考虑过 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 &lt;li&gt;&lt;a href=&quot;${doc.id}&quot;&gt;${doc.title}&lt;/a&gt;&lt;/li&gt;
  }).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 测试页面,后续可能会继续分享。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论