【笔记】Jekyll 架构与构建原理
中心问题
Jekyll 是一次性构建的静态站点生成器:读入一批源文件,输出一整个纯静态网站。理解它的关键是三件事——构建流水线如何分阶段、各阶段如何串联、主题与插件如何扩展。这三件事都落在同一个 Jekyll::Site 对象上。
整体模型
Jekyll 的核心是 Site#process,六个阶段顺序执行:
1
2
3
4
5
6
7
8
def process
reset # 重置内部状态
read # 读入源文件,分类为文档对象
generate # Generator 插件介入,修改 site
render # 遍历 site,逐个渲染
cleanup # 清理
write # 写出到 _site/
end
一条贯穿全篇的主线:site 是一个共享的可变状态。read 填充它,generate 修改它,render 遍历它,write 把它落盘。阶段之间没有管道、没有消息,全在同一个对象上原地操作。
由此带来一个容易误判的特性:Jekyll 是批处理,不是流式处理。不是”读一个文件 → 渲染一个文件 → 输出一个文件”,而是”把所有文件先读入 site,再批量渲染”。所以单篇文章的 Liquid 模板里能用 {{ site.posts | where: 'tag', 'xxx' }} 跨文章查询——渲染任何一篇文章时,整个 site 的数据已经齐全。
graph TD
A[源文件系统] -->|read| B[site 数据结构]
B -->|generate<br/>Generator 修改| B
B -->|render<br/>逐个渲染| C[文档.output]
C -->|write| D[_site/]
数据模型:site 上挂着什么
read 阶段把源文件分类成几类对象,全部挂到 site 上。理解分类就理解了 Jekyll 的数据世界。
| site 上的属性 | 来源目录 | 对象类型 | 特殊行为 |
|---|---|---|---|
site.pages | 项目根目录非 _ 文件 | Jekyll::Page | 无日期约束,按路径排序 |
site.posts | _posts/ | Collection → Document | 文件名强制 YYYY-MM-DD-title,按日期倒序 |
site.collections | _xxx/(需配置声明) | Collection → Document | 通用集合,posts 是预定义的特例 |
site.static_files | 图片/CSS/JS 等 | Jekyll::StaticFile | 原样复制,不渲染 |
site.data | _data/ | Hash | YAML/JSON/CSV 解析结果 |
pages 与 posts 的根本区别
容易混淆的是 site.pages 和 site.posts,两者走不同路径:
site.pages来自Reader#read_pages,遍历项目根目录下所有不以_开头的文件,每个包装成Jekyll::Page。文件名任意,没有日期,没有前后文章链。代表”独立页面”(关于、404、首页)。site.posts实际上是site.collections["posts"]的别名——Site#posts直接返回collections["posts"]。它走的是集合路径,Collection#read解析文件名中的日期,创建Jekyll::Document,并按日期排序。每个 post 自动有date、next、previous、excerpt等元数据。
1
2
3
4
# Site#posts —— posts 只是预定义集合
def posts
collections["posts"] ||= Collection.new(self, "posts")
end
posts 是 Jekyll 硬编码的特例集合,默认会写出页面。其他自定义集合需要显式配置才写出(见下文 output 开关)。
集合的 output 开关
自定义集合不会自动变成页面。写出由 Collection#write? 控制:
1
2
3
4
5
6
7
8
9
# Collection#write?
def write?
!!metadata.fetch("output", false) # 默认 false
end
# Document#write? —— 文档受所属集合约束
def write?
@write_p = collection&.write? && site.publisher.publish?(self)
end
本项目的 _tabs/ 就是自定义集合:
1
2
3
4
collections:
tabs:
output: true # 没有这个,_tabs/ 下的文件只在内存中,不生成页面
sort_by: order
output: true 打开后,_tabs/ 下的 about.md、archives.md、tags.md 才会渲染成独立页面,并通过 site.tabs 在 Liquid 中可访问。sort_by: order 按 Front Matter 的 order 字段排序,用于控制导航栏顺序。
generate 与 render 的串联
这是理解 Jekyll 最关键的一环:两个阶段如何交接。
串联的接口:Generator 的输入输出
1
2
3
4
5
def generate
generators.each do |generator|
generator.generate(self) # 输入:整个 site 对象
end
end
直觉上会以为 Generator 返回一批新生成的页面,交给 render 去处理。实际不是——Generator 的输入是 site,没有返回值,”输出”是对 site 的副作用修改。generate 调用 g.generate(self) 后直接丢弃返回值,render 阶段读取的是同一个被改过的 site。典型操作:
1
2
3
4
5
# 往 site.pages 注入新页面(最常见)
site.pages << TagPage.new(site, site.source, tag)
# 修改已有文档
site.posts.docs.each { |post| post.data['summary'] = '...' }
之所以用共享可变状态而非返回值,是因为 Generator 要能同时”新增页面”和”改已有文档”两类操作,且 render 需要看到 Generator 之间累积的全部修改。返回值模型只能表达”产出新对象”,表达不了”改已有对象”,所以 Jekyll 选择了原地修改 site 的设计。
render 的输入
1
2
3
4
5
6
7
def render
payload = site_payload # 构建 Liquid 上下文
Jekyll::Hooks.trigger :site, :pre_render, self, payload
render_docs(payload) # 遍历 collections
render_pages(payload) # 遍历 pages
Jekyll::Hooks.trigger :site, :post_render, self, payload
end
render 遍历的就是 site.pages 和 site.collections——即 Generator 改完后同一个 site。payload 是 Drops::UnifiedPayloadDrop.new(self),把 site 包装成 Liquid 可访问的 site.xxx 变量,内部引用指向同一批被 Generator 修改过的对象。
graph LR
G[Generator] -->|修改| S[site.pages / site.collections]
S -->|遍历| R[Renderer]
R -->|payload 包裹 site| L[Liquid 模板]
Generator 往 site.pages 加一个 Page,render 阶段就遍历到它并渲染。没有中间数据结构,就是同一个可变对象。 这也解释了为什么 Generator 必须在 render 之前执行——它在渲染前对数据做结构性修改。
渲染管线:Document 级别三段式
render 阶段是两级嵌套:Site 级别遍历所有文档,每个文档交给 Renderer#run 走三段式流水线。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Renderer#render_document
def render_document
output = document.content
# ① Liquid 解释
output = render_liquid(output, payload, info, document.path) if document.render_with_liquid?
# ② Converter
output = convert(output.to_s)
document.content = output
# ③ 布局填充
output = place_in_layouts(output, payload, info) if document.place_in_layout?
output
end
| 阶段 | 输入 | 处理 | 输出 |
|---|---|---|---|
| ① Liquid | 原始正文 | {{ }}、{% %} 求值 | Liquid 展开后的字符串 |
| ② Converter | Liquid 输出 | Markdown→HTML、Sass→CSS | 转换后的字符串 |
| ③ Layout | Converter 输出 | 嵌入布局的 {{ content }} | 最终 HTML |
Liquid 解释是浅层的
第一步只做一次求值,不会对输出中包含的 Liquid 代码再次处理。这防止了无限递归,但也带来一个时序陷阱:布局中定义的变量,不能在子模板(文章内容)中使用。因为子模板的 Liquid 在布局填充之前已经执行完毕,那时布局变量还不存在。反向则可以——子模板中定义的变量能在父布局中用到。
Converter 按扩展名匹配并管道串联
1
2
3
4
5
6
7
def converters
@converters ||= site.converters.select { |c| c.matches(document.extname) }.tap(&:sort!)
end
def convert(content)
converters.reduce(content) { |output, converter| converter.convert output }
end
匹配上的 Converter 按 priority 排序后 reduce 串联——前一个的输出是下一个的输入。内置 MarkdownConverter(.md → .html)、SassConverter、ScssConverter、CoffeeScriptConverter。一个 .md 文件通常只匹配到 MarkdownConverter 一次。
布局嵌套是从内到外的
第三步把 Converter 输出塞进 Front Matter 声明的 layout 的 {{ content }} 位置。layout 可以再声明 layout,形成层级:
1
default.html ← post.html ← 文章内容
渲染从最内层(文章内容)开始,逐层向外包裹。Renderer#place_in_layouts 用 while 循环沿 layout.data["layout"] 链向上走,并用 Set 检测循环。
主题系统:纯主题 gem + 文件覆盖
主题的本质
Jekyll 主题是一个 Ruby Gem,包里放着 _layouts/、_includes/、_sass/、assets/ 等目录。文件查找优先级是:项目根目录 > 主题 gem 目录。在项目 _layouts/ 下创建与主题同名的文件,就会完全覆盖主题版本。这是 Jekyll 官方设计的扩展点,不是 hack。
覆盖是文件级别的,不是字段级别。只想改一个 CSS class 也得把整个布局文件复制到项目里改。代价是主题升级后需要手动合并变更——这是主题系统的已知局限。
Chirpy 是纯主题,不含 Ruby 代码
本项目的 Chirpy 主题(jekyll-theme-chirpy 7.3.0)gem 根目录只有 _data/、_includes/、_layouts/、_sass/、assets/,没有 lib/、没有任何 .rb 文件。gemspec 元数据明确标注 "plugin_type" => "theme"。
它的插件能力全靠 gemspec 声明的 5 个 runtime dependency:
1
2
3
4
5
s.add_runtime_dependency "jekyll-paginate", "~> 1.1" # Generator 分页
s.add_runtime_dependency "jekyll-seo-tag", "~> 2.8" # Liquid Tag SEO
s.add_runtime_dependency "jekyll-archives", "~> 2.2" # Generator 归档
s.add_runtime_dependency "jekyll-sitemap", "~> 1.4" # Generator sitemap
s.add_runtime_dependency "jekyll-include-cache", "~> 0.2" # Tag 缓存 include
这 5 个 gem 才是真正的插件。Chirpy 自己是”皮肤”,功能靠”外挂”。
主题依赖如何被加载
_config.yml 写 theme: jekyll-theme-chirpy 后,PluginManager#conscientious_require 自动 require 主题的 runtime deps:
1
2
3
4
5
6
7
8
9
10
11
12
def conscientious_require
require_theme_deps if site.theme # ← 主题依赖在这里
require_plugin_files # _plugins/ 目录
require_gems # config["plugins"] 列表
end
def require_theme_deps
site.theme.runtime_dependencies.each do |dep|
next if dep.name == "jekyll"
External.require_with_graceful_fail(dep.name) if plugin_allowed?(dep.name)
end
end
所以本项目 _config.yml 不需要写 plugins: 列表——theme: 字段间接把 5 个插件带进来了。
插件系统:inherited hook 而非 classloader
六种插件类型
| 类型 | 作用 | 介入阶段 |
|---|---|---|
| Generator | 创建/修改文档 | generate |
| Converter | 格式转换 | render 第二步 |
| Tag | 自定义 {% tag %} | render 第一步 |
| Filter | 自定义 \| filter | render 第一步 |
| Hook | 生命周期回调 | 分散各阶段 |
| Command | CLI 子命令 | CLI 入口 |
发现机制:inherited hook 被动注册
一个自然会有的直觉:Jekyll 会不会像 Java 的 getResources() / classloader 那样,运行时扫描 classpath 上所有 jar,主动找出所有 Generator 子类?实际不是。Jekyll 不扫描、不遍历目录找子类,它靠 Ruby 的 inherited hook 被动注册:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Plugin
def self.inherited(const)
catch_inheritance(const) do |const_|
catch_inheritance(const_) # 递归处理子类的子类
end
end
def self.catch_inheritance(const)
const.define_singleton_method :inherited do |const_|
(@children ||= Set.new).add const_ # 注册到 Set
yield const_ if block_given?
end
end
def self.descendants
@children ||= Set.new
out = @children.map(&:descendants)
out << self unless superclass == Plugin
Set.new(out).flatten
end
end
工作流:require 一个插件文件 → 文件中定义 class MyGenerator < Jekyll::Generator → Ruby 底层自动调用 inherited hook → 加入 @children Set → Site#setup 时 Plugin.descendants 收集所有子类 → 排序实例化。
与 classloader 主动扫描的关键区别在于”被动 vs 主动”:Jekyll 只能找到已经被 require 过的子类。一个 Generator 类如果定义在某个文件中、但这个文件从未被加载,inherited 不触发,descendants 也找不到它。所以插件发现不是全自动的——必须确保文件被加载(通过 _plugins/ 目录、_config.yml 的 plugins 列表,或 Gemfile 的 :jekyll_plugins 组)。代价是零扫描开销,前提是文件已被 require。
三条加载路径
1
2
3
4
5
6
7
8
9
10
11
12
13
# PluginManager.require_from_bundler —— CLI 启动时最早
def self.require_from_bundler
Bundler.require(:jekyll_plugins) # Gemfile 中 :jekyll_plugins 组的 gem
end
# conscientious_require —— Site 初始化时
def require_plugin_files # Dir.glob("_plugins/**/*.rb")
Utils.safe_glob(path, File.join("**", "*.rb"))
end
def require_gems # config["plugins"] 列表中的 gem
External.require_with_graceful_fail(site.gems.select { |p| plugin_allowed?(p) })
end
_plugins/ 目录下的 .rb 文件会被无条件 require——所以放进去的不一定是正经插件类型,也可以是直接打开 Jekyll 核心类改方法的 monkey patch(见本项目 utf8-url-path.rb)。
执行顺序:priority 排序
1
2
3
4
5
PRIORITIES = { lowest: -100, low: -10, normal: 0, high: 10, highest: 100 }.freeze
def self.<=>(other)
PRIORITIES[other.priority] <=> PRIORITIES[priority] # 高 → 低
end
Site#instantiate_subclasses 调 sort! 后实例化,Site#generate 按这个顺序执行。同优先级内按文件名字母序(Jekyll 1.4.0 引入的规则),所以可以用 01_foo.rb、02_bar.rb 控制同优先级的先后。
本项目插件生态全景
graph TD
T[theme: jekyll-theme-chirpy<br/>纯主题无代码] -->|runtime_deps<br/>require_theme_deps| P1[jekyll-paginate Generator]
T --> P2[jekyll-archives Generator]
T --> P3[jekyll-sitemap Generator]
T --> P4[jekyll-seo-tag Tag]
T --> P5[jekyll-include-cache Tag]
L[_plugins/ 本地] --> L1[my-element.rb<br/>2 个 Liquid Tag]
L --> L2[posts-lastmod-hook.rb<br/>Hook post_init]
L --> L3[utf8-url-path.rb<br/>Monkey patch]
本地三个插件各自的实现要点:
my-element.rb:用Liquid::Template.register_tag('box', ...)注册自定义标签,是 Tag 类型插件。posts-lastmod-hook.rb:Jekyll::Hooks.register :posts, :post_init,在文章初始化时用git log查修改时间写入last_modified_at。是 Hook 类型。utf8-url-path.rb:直接class Jekyll::URL; def unescape_path ... end end,重写核心类方法。不是任何插件类型,是 monkey patch,靠_plugins/加载即生效的副作用工作。
关键设计决策的理解
几个”为什么”能检验是否真正理解了机制:
为什么改
_config.yml要重启? Jekyll 是一次性构建工具,_config.yml在Site初始化时读一次,之后不重读。jekyll serve只监听内容文件变化,不监听配置。为什么 Front Matter 是必须的? Jekyll 用它区分”需要处理的文件”和”静态文件”。没有 Front Matter 的文件是 StaticFile,原样复制;有的才进入渲染管线。
为什么
_posts/文件名必须带日期? 博客天然需要时间排序,文件名承载日期免去在 Front Matter 重复声明,也用于生成默认 permalink(/:year/:month/:day/:title)。为什么主题覆盖要手动合并? 文件级覆盖只看同名文件优先级,主题作者后续修复的 bug 不会自动进入被覆盖的文件。
复习索引
- 一句话心智模型:Jekyll 是
reset→read→generate→render→cleanup→write六阶段流水线,全在同一个可变的site对象上原地操作。 - 串联核心:Generator 输入是
site、输出是对它的副作用;render 遍历被改完的同一个site。无管道,共享状态。 - 渲染管线:Site 级遍历 + Document 级三段式
render_liquid → convert → place_in_layouts。 - 易混点:
site.pages(根目录 Page)vssite.posts(collections["posts"]别名,Document)vs 自定义集合(需output: true才写出)。 - 主题 vs 插件:Chirpy 是纯主题 gem(无 Ruby),靠 5 个 runtime dependency 提供插件能力,走
require_theme_deps加载。 - 插件发现:不是 classloader 扫描,是
Plugin#inheritedhook 被动注册到@childrenSet;三条加载路径_plugins/、config["plugins"]、:jekyll_plugins组。 - 执行顺序:
priority排序(highest→lowest),同优先级按文件名字母序。 - 技术锚点:
Site#process、Site#generate、Renderer#render_document、Plugin#inherited、PluginManager#conscientious_require、Collection#write?、Document#write?。