Butterfly 的页面布局是固定的:顶部导航 + 左侧文章区 + 右侧 widget 侧栏 + 页脚。大多数时候这够用,但一旦想放一个「不像博客文章」的东西——交互式工具、数据看板、带自己一套 UI 的应用页——主题布局反而变成束缚。


先判断:要的是哪一种「自定义页面」

动手之前先对号入座,选错路线会白折腾。

路线 最终外观 内容自由度 要改哪些文件 适合做什么
A. 原生页面 有导航栏、页脚、侧栏 Markdown + 内嵌 HTML 只加 source/ 下的文件 关于页、友链页、图文介绍
B. skip_render 独立页 完全由你决定 任意 HTML/CSS/JS source/ + _config.yml 一行 单页应用、工具、论文库
C. iframe 壳 有导航栏,内容区完全自定义 任意 两处(A + B 各一份) 既要站点外壳又要复杂内容
D. 主题自定义 type 主题内,与站点风格统一 Pug 模板级 改主题文件 长期维护的深度定制

一句话判断:

  • 只是写内容 → A
  • 是在做一个应用 → B
  • 做应用但舍不得导航栏 → C
  • 要跟主题的卡片、配色、响应式完全融为一体 → D(但代价见第 4 节)

路线 A:Hexo 原生页面

最省事,Hexo 原生支持,几乎零配置。

建文件

新建 source/xxx/index.md,路径就是访问路径(/xxx/):

1
2
3
4
5
6
7
8
9
10
11
12
---
title: 我的页面
date: 2026-09-14 21:00:00
top_img: false
comments: false
---

正文用 **Markdown** 写。

也可以直接嵌 HTML:

<iframe src="https://example.com" style="width:100%;height:400px"></iframe>

Butterfly 支持的页面级 front-matter

Butterfly 会读取这些页面级字段来覆盖全局配置:

字段 作用
title 页面标题(也会写进 <title> 和 og 标签)
type 页面类型,决定走哪个模板分支(见第 4 节)
top_img 顶部大图;false 表示不显示,会改成一个简单的页面标题栏
comments false 关闭评论区
aside 控制右侧 widget 侧栏
toc / toc_number / toc_expand 目录相关,页面级覆盖
cover 封面图
mathjax / katex 单页开启数学公式(全局 math.per_page: false 时用得上)
highlight_shrink 代码块是否默认折叠

这些字段是在 themes/butterfly/layout/ 下的 pug 模板里读 page.xxx,想确认某个字段生效与否,直接在主题目录里搜 page.字段名。

实测结果

Deepseek用一个带 iframe 的测试页跑了一遍 hexo generate,输出 14848 字符,特征是:

  • ✅ 被主题包裹(<html data-theme="light">、导航栏、widget 侧栏、页脚都在)
  • ✅ Markdown 渲染成 HTML(这是 <strong>Markdown 正文</strong>)
  • ✅ 原始 HTML 原样通过,<iframe> 完整保留
  • ✅ top_img: false 生效,变成 <div class="page-title">我的页面</div>

要点:.md 文件里可以直接混写 HTML,Hexo 默认的 marked 渲染器不会转义它。 这一点很多人不知道,导致白白去改主题。

接进菜单

1
2
3
# _config.butterfly.yml
menu:
我的页面: /xxx/ || fas fa-star

路线 B:skip_render 独立静态页(重点)

这是本文的重点,也是「真·自定义静态页面」的标准做法。

它解决什么问题

写了一个完整的 index.html(自带 <html>、<head>、<style>、<script>),扔进 source/。

默认情况下 Hexo 不会原样拷贝它,而是把它当成一个 page 来渲染——套上主题的 page 布局。结果就是整个 HTML 被塞进主题的 <head> 里,页面彻底报废。

症状很好认,生成结果的 <head> 里会出现主题的东西:

1
2
3
<html lang="zh-CN" data-theme="light">
<title>你的站名 | 你的站名</title>
<meta name="description" content="你的页面标题 :root{ --bg:#f6f8fa; ...">

看到 data-theme 和 og 标签出现在独立页里,就是这个病。

放文件

把完整的 HTML 放到一个目录下:

1
2
3
4
5
6
source/
└─ myapp/
├─ index.html ← 要原样输出的页面
├─ style.css
├─ app.js
└─ data.json

加 skip_render

在站点根目录的 _config.yml(不是主题那一个)里:

1
2
3
4
5
# Directory
source_dir: source
public_dir: public
skip_render:
- 'myapp/**'

路径是相对于 source/ 的,用 minimatch 通配符匹配:

写法 匹配范围
'myapp/**' source/myapp/ 下的所有文件(含子目录)——最常用
'myapp/index.html' 只跳过这一个文件,同目录的 css/js 仍会被处理
'**/*.html' 全站所有 .html 都不渲染(慎用)

⚠️ 注意:skip_render 一旦声明就不止影响目录。如果 'myapp/**' 里的文件同时还需要被其他 Hexo 插件处理(比如搜索索引),它也会被一并跳过。

必须 hexo clean

这一步极其重要,也是最容易浪费时间的地方。

改完 skip_render 直接 hexo generate,你会发现——没有任何变化。

原因是 Hexo 有构建缓存,它认为文件内容没变,直接复用了上次渲染的结果。必须清一次:

1
npx hexo clean && npx hexo generate

第一次遇到时反复检查配置,一度以为 skip_render 的写法不对。实际上配置是有效的,只是缓存没失效。

经验:以后凡是改了 _config.yml 里跟渲染路径相关的配置(skip_render、include、exclude、ignore),一律 clean 后再 generate,省得怀疑人生。

验证确实是「原样拷贝」

直接用哈希比对:

1
2
3
4
5
if ((Get-FileHash 'public\myapp\index.html').Hash -eq (Get-FileHash 'source\myapp\index.html').Hash) {
"原样输出 ✓"
} else {
"被处理过了,检查 skip_render"
}
1
2
# Linux / macOS
diff -q source/myapp/index.html public/myapp/index.html && echo "原样输出 ✓"

实测数据:一个 143 字节的测试页,源文件与输出文件哈希完全一致,输出里既没有 data-theme 也没有导航栏——确认原样拷贝。

接进菜单

1
2
3
# _config.butterfly.yml
menu:
我的应用: /myapp/ || fas fa-cube

注意写 /myapp/(带尾斜杠),让服务器去找目录下的 index.html。写成 /myapp 在某些 Nginx/静态托管配置下会 404 或触发一次重定向。

资源路径怎么写

强烈建议在独立页里全部用绝对路径:

1
2
3
4
5
6
<!-- 推荐 -->
<link rel="stylesheet" href="/myapp/style.css">
<script src="/myapp/app.js"></script>

<!-- 避免 -->
<link rel="stylesheet" href="style.css">

原因:相对路径的解析结果取决于当前 URL。/myapp/ 和 /myapp/index.html 这两种访问形式对相对路径的解析在部分服务器上不一致;如果哪天页面被嵌到别的路径下(比如用 iframe),相对路径会全部失效。绝对路径没有这些问题。

如果你的站点部署在子目录(_config.yml 里 url 带了路径前缀),绝对路径要加上那个前缀。


路线 C:iframe 壳

适用场景:想要 Butterfly 的导航栏和页脚(用户能点回博客),但内容区是一个完全自定义的应用。

做法

两件事:

  1. 用路线 B 生成真正的应用页 /myapp/
  2. 用路线 A 建一个壳页面 source/app/index.md 把它嵌进来
1
2
3
4
5
6
7
8
9
---
title: 我的应用
date: 2026-09-14 21:00:00
top_img: false
comments: false
aside: false
---

<iframe src="/myapp/" style="width:100%;height:calc(100vh - 200px);border:none"></iframe>

aside: false 去掉右侧 widget,给内容腾出整幅宽度。

让 iframe 高度自适应

固定 height 在不同屏幕上要么留白要么出现内部滚动条。用一点 JS 让 iframe 高度跟随内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<iframe id="app-frame" src="/myapp/" style="width:100%;border:none"></iframe>
<script>
(function () {
var f = document.getElementById('app-frame');
function fit() {
try {
var d = f.contentDocument || f.contentWindow.document;
f.style.height = d.documentElement.scrollHeight + 'px';
} catch (e) {
// 跨域时会抛错,退回固定高度
f.style.height = '80vh';
}
}
f.addEventListener('load', fit);
window.addEventListener('resize', fit);
// 内容里如果有异步渲染,可以用 setInterval 或 postMessage 再调一次
setInterval(fit, 1000);
})();
</script>

同源才能读取 contentDocument。如果 iframe 指向的是外部域名,只能退回固定高度或 vh。

三条要提前知道的局限

  1. SEO 很差。 iframe 里的内容通常不被搜索引擎当作父页面内容索引。如果这个页面的内容需要被搜到,别用 iframe。
  2. 深色模式不同步。 主题切到暗色时,iframe 内部是独立文档,不会跟着变。要么在应用页里自己读 prefers-color-scheme,要么用 postMessage 通知它。
  3. 滚动体验割裂。 页面滚动条和 iframe 内部滚动条容易打架。上面的自适应高度脚本能缓解,但内容里有 sticky 元素时会比较难办。

路线 D:主题自定义 page type(进阶)

Butterfly 的 page.pug 里有一个类型分发:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// themes/butterfly/layout/page.pug
case page.type
when 'tags'
include includes/page/tags.pug
when 'link'
include includes/page/flink.pug
when 'categories'
include includes/page/categories.pug
when '404'
include includes/page/404.pug
when 'shuoshuo'
include includes/page/shuoshuo.pug
default
include includes/page/default-page.pug
+commentLoad

所以可以自己接一个类型进去:

  1. 新建 themes/butterfly/layout/includes/page/mytype.pug
  2. 在 page.pug 的 case 里加一个 when 'mytype' 分支
  3. 页面 front-matter 写 type: mytype

这样能拿到主题的卡片样式、配色变量和响应式布局,观感最统一。

但是有个大坑

Butterfly 通常是作为 git submodule 引入的。改主题文件意味着:

  • themes/butterfly 变成了「有本地未提交改动」的状态
  • 升级主题时容易产生冲突,或者被 git submodule update 直接覆盖
  • 服务器端构建时如果没有同步你的主题改动,效果和本地不一致

如果要走这条路,正确做法是把主题 fork 下来,用自己的 fork 当 submodule,而不是直接改 submodule 里的文件。参考另一篇文章《网站部署问题总结:子模块、版本分歧与同步》里踩过的坑。

建议:能用路线 A/B/C 解决的,就不要动主题。真正需要 D 的场景比想象中少得多——很多时候只是想要个卡片背景,用路线 B 自己写几行 CSS 更快。


补充:全站注入 inject

Butterfly 还提供了一组 inject 配置,把代码插到所有页面的 </head> 前和 </body> 前:

1
2
3
4
5
6
7
# 主题的 _config.yml(或复制到站点根目录的 _config.butterfly.yml 里覆盖)
inject:
head:
- <link rel="stylesheet" href="/css/my-extra.css">
- <script src="/js/analytics.js"></script>
bottom:
- <script src="/js/my-widget.js"></script>

主题模板里的实现位置在 layout/includes/head.pug 和 layout/includes/additional-js.pug。

适用:给全站加统计脚本、额外样式、悬浮挂件。
不适用:整页自定义——它只是往现有页面里塞片段,不会替换布局。


避坑清单

现象 原因 解决
独立页被套进了主题布局 忘了配 skip_render 在站点根目录的 _config.yml 里加,注意是相对 source/ 的路径
改了 skip_render 但没效果 Hexo 构建缓存 npx hexo clean && npx hexo generate
独立页里 --- 那几行显示成文本了 给 skip_render 的文件写了 front-matter 独立页会被原样拷贝,front-matter 不会被剥离,删掉它
source/xxx/ 目录下的文件没进 public/ 目录名以 _ 开头,Hexo 直接忽略 改名,或改用 include 配置
页面里的 css/js 加载 404 用了相对路径 改成绝对路径 /myapp/style.css
访问 /myapp 报 404 少了尾斜杠 用 /myapp/
iframe 高度不对、有内部滚动条 固定 height 用第 3.2 节的 height 自适应脚本
站点开了 pjax 后自定义页表现异常 pjax 接管了导航 在 pjax.exclude 里排除该路径;或者像本博客一样 pjax.enable: false

关于 _ 开头的目录

这条值得单独说:Hexo 会忽略 source/ 下所有以 _ 开头的目录。_posts、_drafts、_data 是 Hexo 自己用的特殊目录,其他 _xxx(比如 _draft/)会被完全跳过。

把没写完的草稿放在 source/_draft/,这样它们绝不会被误发布

速查表

1
2
3
# 站点根目录 _config.yml
skip_render:
- 'myapp/**' # source/myapp/ 下全部文件原样输出
1
2
3
# _config.butterfly.yml
menu:
我的应用: /myapp/ || fas fa-cube
1
2
3
4
5
6
7
8
9
<!-- source/app/index.md —— 路线 C 的壳页面 -->
---
title: 我的应用
top_img: false
comments: false
aside: false
---

<iframe src="/myapp/" style="width:100%;height:80vh;border:none"></iframe>
1
2
3
# 改完配置后一定要清缓存
npx hexo clean && npx hexo generate
npx hexo server # 本地预览 http://localhost:4000/myapp/
1
2
# 确认是原样拷贝
(Get-FileHash 'public\myapp\index.html').Hash -eq (Get-FileHash 'source\myapp\index.html').Hash

小结

  • 只用一个配置项(skip_render)就能让 Hexo 别再插手页面。
  • 改完配置**必须 hexo clean**,否则会以为配置没生效。
  • 能用 A/B/C 解决的就别改主题,尤其是主题以 submodule 方式引入的时候。
  • 独立页里一律用绝对路径引用资源,能避掉一整类「本地正常、线上 404」的问题。