文章

【笔记】Jekyll 架构与构建原理

【笔记】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/HashYAML/JSON/CSV 解析结果

pages 与 posts 的根本区别

容易混淆的是 site.pagessite.posts,两者走不同路径:

  • site.pages 来自 Reader#read_pages,遍历项目根目录下所有不以 _ 开头的文件,每个包装成 Jekyll::Page。文件名任意,没有日期,没有前后文章链。代表”独立页面”(关于、404、首页)。

  • site.posts 实际上是 site.collections["posts"] 的别名——Site#posts 直接返回 collections["posts"]。它走的是集合路径,Collection#read 解析文件名中的日期,创建 Jekyll::Document,并按日期排序。每个 post 自动有 datenextpreviousexcerpt 等元数据。

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.mdarchives.mdtags.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.pagessite.collections——即 Generator 改完后同一个 sitepayloadDrops::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 展开后的字符串
② ConverterLiquid 输出Markdown→HTML、Sass→CSS转换后的字符串
③ LayoutConverter 输出嵌入布局的 {{ 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)、SassConverterScssConverterCoffeeScriptConverter。一个 .md 文件通常只匹配到 MarkdownConverter 一次。

布局嵌套是从内到外的

第三步把 Converter 输出塞进 Front Matter 声明的 layout 的 {{ content }} 位置。layout 可以再声明 layout,形成层级:

1
default.html ← post.html ← 文章内容

渲染从最内层(文章内容)开始,逐层向外包裹。Renderer#place_in_layoutswhile 循环沿 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.ymltheme: 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自定义 \| filterrender 第一步
Hook生命周期回调分散各阶段
CommandCLI 子命令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#setupPlugin.descendants 收集所有子类 → 排序实例化。

与 classloader 主动扫描的关键区别在于”被动 vs 主动”:Jekyll 只能找到已经被 require 过的子类。一个 Generator 类如果定义在某个文件中、但这个文件从未被加载,inherited 不触发,descendants 也找不到它。所以插件发现不是全自动的——必须确保文件被加载(通过 _plugins/ 目录、_config.ymlplugins 列表,或 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_subclassessort! 后实例化,Site#generate 按这个顺序执行。同优先级内按文件名字母序(Jekyll 1.4.0 引入的规则),所以可以用 01_foo.rb02_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.rbJekyll::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/ 加载即生效的副作用工作。

关键设计决策的理解

几个”为什么”能检验是否真正理解了机制:

  1. 为什么改 _config.yml 要重启? Jekyll 是一次性构建工具,_config.ymlSite 初始化时读一次,之后不重读。jekyll serve 只监听内容文件变化,不监听配置。

  2. 为什么 Front Matter 是必须的? Jekyll 用它区分”需要处理的文件”和”静态文件”。没有 Front Matter 的文件是 StaticFile,原样复制;有的才进入渲染管线。

  3. 为什么 _posts/ 文件名必须带日期? 博客天然需要时间排序,文件名承载日期免去在 Front Matter 重复声明,也用于生成默认 permalink(/:year/:month/:day/:title)。

  4. 为什么主题覆盖要手动合并? 文件级覆盖只看同名文件优先级,主题作者后续修复的 bug 不会自动进入被覆盖的文件。

复习索引

  • 一句话心智模型:Jekyll 是 reset→read→generate→render→cleanup→write 六阶段流水线,全在同一个可变的 site 对象上原地操作。
  • 串联核心:Generator 输入是 site、输出是对它的副作用;render 遍历被改完的同一个 site。无管道,共享状态。
  • 渲染管线:Site 级遍历 + Document 级三段式 render_liquid → convert → place_in_layouts
  • 易混点site.pages(根目录 Page)vs site.postscollections["posts"] 别名,Document)vs 自定义集合(需 output: true 才写出)。
  • 主题 vs 插件:Chirpy 是纯主题 gem(无 Ruby),靠 5 个 runtime dependency 提供插件能力,走 require_theme_deps 加载。
  • 插件发现:不是 classloader 扫描,是 Plugin#inherited hook 被动注册到 @children Set;三条加载路径 _plugins/config["plugins"]:jekyll_plugins 组。
  • 执行顺序priority 排序(highest→lowest),同优先级按文件名字母序。
  • 技术锚点Site#processSite#generateRenderer#render_documentPlugin#inheritedPluginManager#conscientious_requireCollection#write?Document#write?
本文由作者按照 CC BY 4.0 进行授权