Liquid 模板引擎运行原理与 TagInclude 执行流程

本文说明本项目中 Liquid 模板从 .liquid 源码到 HTML 输出 的完整链路,并以 liquidExtend\tags\TagInclude 为例拆解 每个方法在何时被调用。与实现冲突时以代码为准。

依赖composer.jsonliquid/liquid 1.4.25;业务扩展在 extend/liquidExtend/

相关文档home-liquid-rendering-and-cod-one-page-product.md(Layout / Template 分层)、theme-decoration-and-liquid-rendering.md(装修与积木块)。


一、总览:三阶段

一次前台页面渲染可概括为三个阶段:

阶段入口产出
初始化HomeBaseController::fetch()配置 Liquid、注册 Tag/Filter、准备变量 $data
解析 parseliquidExtend\template\Template::parse($source)Document AST(节点树),可选写入 APCu/File 缓存
渲染 renderTemplate::render($data)遍历 AST,输出 HTML 字符串
sequenceDiagram
    participant C as Controller
    participant H as HomeBaseController::fetch
    participant T as liquidExtend Template
    participant D as Document AST
    participant Ctx as Liquid Context
    participant Tag as TagInclude

    C->>H: fetch(file, data)
    H->>H: mergeData / registerTag / setCache
    H->>T: parse(layout liquid)
    T->>D: tokenize + new Document
    Note over D,Tag: 遇 {% include %} 时构造 TagInclude 并 parse 子 snippet
    H->>T: render(data)
    T->>Ctx: new Context(assigns)
    T->>D: root.render(context)
    D->>Tag: tag.render(context)
    Tag->>D: 子 document.render(context)
    D-->>H: HTML

二、入口:HomeBaseController::fetch()

主入口在 app/home/HomeBaseController.phpfetch() 方法,典型调用链为:业务 Controller → $this->fetch('', $renderData)

2.1 Liquid 全局配置

Liquid::set('INCLUDE_SUFFIX', 'liquid');  // snippet 文件后缀
Liquid::set('INCLUDE_PREFIX', '');        // 无前缀(Shopify 风格,非 Rails _partial)
Liquid::set('ESCAPE_BY_DEFAULT', false);

2.2 模板引擎实例

$template = new liquidExtendTemplate($include_path);
// $include_path = public_path('theme/default/snippets')

liquidExtend\template\Template 继承 Liquid\Template,构造时传入的 path 作为根 FileSystem 的默认根目录(layout 解析时由 Document 持有;各 Tag 往往再建自己的 OemsaasLocal / OemsaasDatabase)。

2.3 变量注入

  1. handlerTemplate($data):写入 app('Context')->template 等路由模板名。
  2. mergeData($data):合并店铺 Context、page_cacdn_url、国家/货币等,得到传给 Liquid 的 $data 数组。
  3. variableEncrypt($data):敏感字段处理。

这些键值在 render($data) 时成为 Context顶层 assign,模板内 {{ product.title }}{% if cart_number %} 等均从这里解析。

2.4 解析缓存(AST 缓存)

当请求参数 liquid_cache 不为 false 时(默认开启):

  • 品牌 + file 主题 + liquid_cache_type=apculiquidExtend\cache\Apcu
  • 否则 → liquidExtend\file\Fileruntime/liquid/cache/{themeName}/

缓存的是 parse 后的 Document 对象树,不是最终 HTML。

2.5 注册 Filter 与 Tag

$template->registerFilter(new OemsaasFilter());
foreach (extend/liquidExtend/tags/*.php as $tagFile) {
    $tagName  = strtolower(Str::snake(str_replace(['Tag', '.php'], '', $filename)));
    // TagInclude.php → include,TagGetBlocks.php → get_blocks,TagRender.php → render
    $template->registerTag($tagName, $tagClass);
}

自定义 Tag 会覆盖 Liquid\Template::$tags 中同名内置 Tag。因此项目里的 {% include %} 实际由 liquidExtend\tags\TagInclude 处理,而非 vendor 里的 Liquid\Tag\TagInclude

2.6 parse 与 render

$template->parse(file_get_contents($file_fullPath));  // 或 parse($content)
$html_source = $template->render($data);

三、解析阶段(parse)

⚡⚡⚡ 解析阶段 = 把 .liquid 源码用正则切成 token 数组,然后遍历数组,把每块变成 AST节点(纯文本 / Variable / Tag),构建出 Document 节点树。

3.1 Template::parse($source)

逻辑在 vendor/liquid/liquid/src/Liquid/Template.php

  1. 若未设置 cache → 直接 parseAlways($source)
  2. 若有 cache → 以 md5($source) 为 key 读缓存的 Document
  3. 若缓存未命中,或 root->hasIncludes() === true → 重新 parseAlways 并写缓存。

parseAlways 做两件事:

$tokens = Template::tokenize($source);
$this->root = new Document($tokens, $this->fileSystem);

3.2 第一步:Template::tokenize($source) — 正则切块

TOKENIZATION_REGEXP 把源码切成 token 数组:

// Liquid.php 第128行
'TOKENIZATION_REGEXP' => '/(' . TAG_START . '.*?' . TAG_END . '|' . VARIABLE_START . '.*?' . VARIABLE_END . ')/'
// 即 /({%.*?%}|{{.*?}})/

以这段模板为例:

<section>
  <h1>{{ page_title }}</h1>
  {% if show_icon %}
    {% include 'icon-cart' %}
  {% endif %}
</section>

tokenize 后得到的数组:

[
  "<section>\n  <h1>",
  "{{ page_title }}",
  "\n  ",
  "{% if show_icon %}",
  "\n    ",
  "{% include 'icon-cart' %}",
  "\n  ",
  "{% endif %}",
  "\n</section>"
]

注意:此时还不知道里面是 if / include / for,只是按 {% %}{{ }} 分隔符切块,每个 token 整体是一块。

3.3 第二步:Document 消费 token — 生成 nodelist

Document 继承 AbstractBlock,构造时调 parse($tokens)逐个 array_shift 消费 token 数组

// AbstractBlock::parse() 核心循环
while (count($tokens)) {
    $token = array_shift($tokens);   // 弹出一个
 
    if ($tagRegexp->match($token)) {
        // 解析出 tag 名 + markup({% if show_icon %} → tag="if", markup="show_icon")
        $tagName = ...;
        $this->nodelist[] = new $tagName($markup, $tokens, $this->fileSystem);
    } elseif ($variableRegexp->match($token)) {
        $this->nodelist[] = $this->createVariable($token);
    } else {
        $this->nodelist[] = $token;  // 纯文本直接进 nodelist
    }
}

三种 token 的处理方式:

Token 类型处理方式示例
纯文本直接进 $this->nodelist[]"<section>"
{{ var }}new Variable(...) 进 nodelistVariable("page_title")
{% tag %}new $tagClass($markup, $tokens, $fileSystem) 进 nodelistTagIf("show_icon")

3.4 块级标签如何消费 token(解耦设计)

问题:Document 怎么知道 {% if %} 的结束标签是 {% endif %}{% for %}{% endfor %}

答案:不把判断逻辑写在 Document 里,而是把 $tokens 数组传引用给每个 Tag 对象,让块级标签自己消费。

// Document 的 parse 循环里
$token = array_shift($tokens);   // 弹出 "{% if ok %}"
new TagIf($markup, $tokens, $fs);  // 把剩余数组传进去
 
// TagIf 继承 AbstractBlock,构造时调 parse($tokens):
while (count($tokens)) {
    $token = array_shift($tokens);  // 继续弹出,直到遇到 "{% endif %}"
    if ($tagRegexp->matches[1] == $this->blockDelimiter()) {
        // blockDelimiter() 返回 "endif",匹配则 return
        $this->endTag();
        return;   // 把控制权交回 Document 的外层循环
    }
    // 其余 token 进 TagIf 自己的 nodelist
}

这样 Document 完全不关心每种块级标签的结束标签是什么,新增块级标签只需加一个类,Document 无需改动。

include标签不同:include 是自闭合标签,{% include 'x' %} 没有配套的 {% endinclude %},所以 TagInclude::parse() 不消费 $tokens 数组,直接读子文件返回。

3.5 以完整例子走一遍 token 消费过程

模板:

A {% include 'icon' %} B {% if ok %} C {% endif %} D

tokenize 后:

$tokens = [
  0 => "A ",
  1 => "{% include 'icon' %}",
  2 => " B ",
  3 => "{% if ok %}",
  4 => " C ",
  5 => "{% endif %}",
  6 => " D "
]

Document parse 循环

第1次 shift:"A " → 纯文本 → nodelist: ["A "]
第2次 shift:"{% include 'icon' %}" → new TagInclude,传 $tokens(从 " B " 开始)
             TagInclude 读文件建子 AST,不消费 $tokens
             nodelist: ["A ", TagInclude对象]
第3次 shift:" B " → 纯文本 → nodelist: ["A ", TagInclude, " B "]
第4次 shift:"{% if ok %}" → new TagIf,传 $tokens(从 " C " 开始)
             TagIf 内部 shift " C " → 进 TagIf.nodelist
             TagIf 内部 shift "{% endif %}" → 匹配 endif → return
             nodelist: ["A ", TagInclude, " B ", TagIf对象]
第5次 shift:" D " → 纯文本 → nodelist: ["A ", TagInclude, " B ", TagIf, " D "]

最终 Document.nodelist:

Document.nodelist = [
  "A ",                    // 纯文本
  TagInclude('icon'),      // 自闭合,不吃额外 token
  " B ",
  TagIf('ok'),             // 块级,内部 nodelist = [" C "]
  " D "
]

3.6 $tokens 参数的作用总结

标签类型是否使用 $tokens用途
块级标签(if/for/section等)必须使用继续弹 token,找到自己的 {% endxxx %} 并消费中间所有内容
自闭合标签(include/render)不使用只读文件,不消费外层 token 数组

本质:Document 把 $tokens 数组传给块级标签,让标签自己从数组里弹出元素找到自己的结束边界。这是一种解耦——Document 不需要知道各种 endifendforendxxx 在哪,每种块级标签自己负责边界解析。

3.7 markup 是什么

markup = {% %}{{ }} 去掉分隔符后的参数字符串

{% include 'icon-cart' with product %}
          ↑^^^^^^^^^^^^^^^^^^^^^^^^ markup = "'icon-cart' with product"

{{ product.title | escape }}
   ↑^^^^^^^^^^^^^^^^^^^^^ markup = "product.title | escape"

每个 Tag 类用正则把 markup 解析成具体参数(如 templateNamevariable)。

markup vs nodelist:

markupnodelist
是什么tag 声明行里的参数tag 内部的子节点
内容字符串,如 show_icon'icon-cart' with product对象数组,如 [Variable, TagInclude, string]
用途决定 tag 怎么工作(include 哪个文件、if 判断什么条件)决定 tag 输出什么 HTML

3.8 $fileSystem 参数的作用

$fileSystem模板引擎的「文件读取器」,用来读 include/section/render 等标签引用的模板文件。

// TagInclude 读 snippet 文件
$source = $this->fileSystem->readTemplateFile('icon-cart');

3.9 TagInclude 为什么要 new自己的 FileSystem

父级 Document传进来的 $fileSystem 通常被 TagInclude 覆盖

// TagInclude::__construct 第105-114行
$include_path = public_path('theme/default/snippets');
if (theme['file_system'] == 'database') {
    $fileSystem = new FileSystem\OemsaasDatabase($include_path);
} else {
    $fileSystem = new FileSystem\OemsaasLocal($include_path);
}
parent::__construct($markup, $tokens, $fileSystem);  // 用自己的,覆盖父级

原因:父级 Document 的 FileSystem 读 layout 目录;TagInclude 要读 snippets 目录,路径不同,所以 TagInclude 必须 new 一个自己的 FileSystem 实例。

父级传进来的第 3 个参数在 TagInclude 里没有用到,自己 new了一个专用的。

3.10 hasIncludes() 与缓存策略

Document::hasIncludes()(vendor)会检查 nodelist 里是否存在 instanceof Liquid\Tag\TagIncludehasIncludes() 为 true 的节点。

注意:项目自定义的 liquidExtend\tags\TagInclude 并不继承 Liquid\Tag\TagInclude,因此 vendor 的 instanceof 匹配不到 业务 Tag。结果是:根 layout 的 hasIncludes() 往往对 {% include %} 返回 false,根 AST 更容易被整页缓存;snippet 子树的缓存则完全依赖 TagInclude 自身的 parse() / render() 内逻辑。


四、渲染阶段(render)

4.1 Template::render($assigns)

  1. new Context($assigns, $registers)
  2. registerFilter 注册的 filter 挂到 ContextFilterbank
  3. 调用 $this->root->render($context)

4.2 Context 变量栈

4.2.1 为什么需要栈

模板里变量会嵌套定义:

{% assign name = "Alice" %}
{% for item in items %}
  {{ name }}   ← 这里想读到外层定义的 Alice
  {{ item }}   ← 循环内部定义的 item
{% endfor %}

渲染 for 循环时,nameitem 需要同时存在。循环每次迭代结束时 pop,新迭代开始时 push 新的 item,不会污染外层。

4.2.2 栈的结构

$context->stack = [
    ['name' => 'Alice'],           // 第0层:全局/外层
    ['item' => 'apple'],           // 第1层:for 循环新压入的
]

$assigns 是「数组的数组」,每个元素是一层作用域。

4.2.3 四个核心操作

操作代码行为
getget($key)从栈顶向栈底查找,子作用域可读到父作用域变量
setset($key, $value)默认只写栈顶,不影响其他层
pushpush()压入新空层
poppop()弹出当前层,恢复外层

get 的查找顺序:

get('name') → 先查第1层,没有 → 查第0层,找到 'Alice'
get('item') → 第1层有,直接返回

set 的写入位置:

set('item', 'banana') → 只改第1层,第0层的 name 不受影响

4.2.4 TagInclude 的 push/set/render/pop 流程

4.2.4.1 源码解析

TagInclude::render()extend/liquidExtend/tags/TagInclude.php 第179-256行)渲染子 snippet 时,严格按以下顺序操作 Context:

public function render(Context $context)
{
    // ... 读文件、建子 Document(省略)...
 
    $result   = '';
    $variable = $context->get($this->variable);   // ① 从父 Context取出 with/for 的变量
 
    $context->push(); // ② 压入新层
 
    foreach ($this->attributes as $key => $value) {
        $context->set($key, $context->get($value)); // ③ 设置 attributes(key: value形式)
    }
 
    if ($this->collection) { // ④ for 循环:每个元素渲染一次
        foreach ($variable as $item) {
            $context->set($this->templateName, $item);
            $result .= $this->document->render($context);
        }
    } else {                                        // ⑤ 普通 include 或 with
        if (!is_null($this->variable)) {
            $context->set($this->templateName, $variable); // 设置 with 的变量
        }
        $result .= $this->document->render($context);
    }
 
    $context->pop(); // ⑥ 弹出新层,清掉本次设置的变量
    return $result;
}
4.2.4.2 四步流程分解

第一步:$context->get($this->variable) — 从父 Context 取 with/for 的变量

{% include 'product-card' with p %}

此时 p 在父模板的 Context 第0层里,get('p') 从栈顶往栈底找到它,暂存到 $variable

第二步:$context->push() — 压入新空层

$context->stack = [
    ['name' => 'Alice', 'shop' => 'StoreA', ...],  // 第0层:父模板全局变量
    [] // 第1层:新空层
]

新层是空的,保护第0层的所有变量不被子 snippet 污染。

第三步:$context->set($this->templateName, $variable) — 在新层写入 include 变量

$context->set('product-card', $variable);

现在栈顶(第1层)有 product-card,子 snippet 里 {{ product-card.title }} 能读到。

同时 attributes 里的 key: value 形式也会被逐条 set 进栈顶:

foreach ($this->attributes as $key => $value) {
    $context->set($key, $context->get($value));
}

第四步:$this->document->render($context) — 子 Document 用完整栈渲染

子 snippet 里的 {{ product.title }}get('product') → 在栈顶第1层找到。

{{ name }}get('name') → 第1层没有 → 查第0层,找到 'Alice'(子 snippet仍能读到父变量,非隔离)。

第五步:$context->pop() — 弹出新层

$context->stack = [
    ['name' => 'Alice', 'shop' => 'StoreA', ...]  //恢复第0层
]

第1层所有变量(product-card、attributes 等)全部清掉,恢复父模板状态。

4.2.4.3 三种用法的对比
用法代码push/pop 行为
普通 include{% include 'icon-cart' %}push → pop,不设额外变量
with 单个{% include 'product-card' with p %}push → set(‘product-card’, p) → render → pop
for 循环{% include 'product-card' for items %}push → 每次迭代 set(‘product-card’, item) → render → pop;所有迭代结束后 pop
4.2.4.4 注意:非隔离作用域

TagInclude 使用 push/pop 不是隔离作用域——子 snippet 通过 get() 仍能读到父模板所有 assign(nameshop 等)。若需完全隔离,使用 {% render %}TagRender + IsolatedContextHelper),它会用全新的空 Context 而非栈。

4.3 AbstractBlock::render() / renderAll()

nodelist 顺序遍历:

  • 对象且有 render 方法 → 调用 $token->render($context),拼接返回值。
  • 字符串 → 原样输出。
  • Variable → 求值并输出。

每渲染一个节点会调用 $context->tick()(若设置了 tick 回调)。


五、TagInclude 详解:方法与调用时机

源文件:extend/liquidExtend/tags/TagInclude.php
继承:BasetagsLiquid\AbstractTag

5.1 类职责简述

解析 {% include 'snippet_name' [with|for variable] [, key: value] %},从 snippets 目录 加载子模板,在共享 Context 下渲染子 Document,返回 HTML 片段。

5.2 方法调用时序表

方法何时调用调用方作用
__construct($markup, &$tokens)parse 阶段AbstractBlock 遇到 {% include ... %}new TagInclude(...)AbstractBlock::parse()解析 markup、选 FileSystem、触发 parse()
parse(&$tokens)构造末尾AbstractTag::__construct 固定调用 $this->parse($tokens)AbstractTag::__construct读 snippet 文件、tokenize、建子 Document、写 AST 缓存
hasIncludes()parse 阶段(子 Document 缓存判断);vendor Document::hasIncludes 对自定义 Tag 通常不调TagInclude::parse 内;理论上 vendor Document判断子 AST 是否含嵌套 include,决定是否缓存子 Document
render($context)render 阶段,父 Document::renderAll 遍历到该节点时AbstractBlock::renderAll()可能再次加载子 Document、push 作用域、渲染子树、pop、指纹

Basetags 上的 params() 不被 TagInclude 使用;Basetags::render() 为空实现,被 TagInclude::render 覆盖。

5.3 __construct 逐步说明

时机:仅 parse 阶段一次(每个 {% include %} 对应一个 TagInclude 实例)。

  1. 正则解析 markup

    • 提取 templateName(如 icon-cart)。
    • 可选 with / for 及变量表达式 → $this->collection$this->variable
  2. extractAttributes($markup)(继承自 AbstractTag

    • 扫描 key: value 形式命名参数 → $this->attributes
  3. 选择 FileSystem

    • 路径基准:public_path('theme/default/snippets')
    • theme.file_system == databaseOemsaasDatabase;否则 OemsaasLocal(支持 theme_dir 覆盖)。
  4. parent::__construct($markup, $tokens, $fileSystem)

    • 保存 markup、fileSystem。
    • 立即调用 $this->parse($tokens)

5.4 parse(&$tokens) 逐步说明

时机:构造时 自动调用一次不会render() 里再次调用(但 render() 内有重复的「读文件 + 建 Document」逻辑,见下)。

  1. readTemplateFile($templateName) → 得到 snippet 源码字符串。

    • 磁盘:snippets/{name}.liquidINCLUDE_PREFIX 为空、INCLUDE_SUFFIXliquid)。
    • 主题覆盖:theme/{theme_dir}/snippets/ 存在则优先。
  2. 子 Document AST 缓存liquidExtendTemplate::getCache()):

    • 无 cache → tokenize + new Document
    • 有 cache → key 为 md5($source),读缓存;未命中或 document->hasIncludes() → 重建并 write
  3. 不消费 $tokens 中 layout 剩余部分(include 非块标签)。

5.5 hasIncludes() 逐步说明

时机

  • 主要在 parse() 写子 Document 缓存前 判断能否缓存。
  • vendor 根 Document::hasIncludes()instanceof 限制,一般不会调用到自定义 TagInclude::hasIncludes()

逻辑:

  1. $this->document->hasIncludes() → true(子 snippet AST 内还有 vendor 意义上的 include/extends/block 组合)。
  2. 否则检查缓存 key 与当前 md5($source) 是否一致。
  3. 一致 → false(可认为缓存有效);否则 true。

5.6 render($context) 逐步说明

时机每次页面 render、遍历 AST 到该 include 节点时执行(同一请求内多个 include 各执行一次)。

  1. 性能日志PerformanceService::logRuntime 开始。

  2. 再次加载子 Document(与 parse() 类似,受 liquid_include_method 请求参数影响):

    • file(默认):按 md5($source) 读/写 AST 缓存。
    • file:按 templateName 作为 cache key。
    • 若 AST 未就绪或 hasIncludes(),重新 tokenize + new Document

    设计意图:parse 阶段与 render 阶段都能拿到最新 snippet;render 阶段重复读文件是为 DB 主题或缓存失效场景兜底,也是性能优化关注点。

  3. $context->get($this->variable)

    • 解析 with/for 绑定的变量(for 时为可遍历集合)。
  4. $context->push()

    • 新作用域层;下面 set 的变量仅影响 include 子树(pop 后恢复)。
  5. 命名参数

    foreach ($this->attributes as $key => $value) {
        $context->set($key, $context->get($value));
    }
  6. 分支渲染

    • for 集合:每项 set($templateName, $item),累加 $this->document->render($context)
    • with 单对象set($templateName, $variable) 后 render 一次。
    • 无 with/for:直接 $this->document->render($context)(子 snippet 可读父级全部变量)。
  7. $context->pop()

    • 弹出 include 临时作用域。
  8. appendFingerPrint($result, $templateName)

    • website_js_mode 为空时,为 HTML 追加 snippet 指纹(调试/溯源,见 app/common.php)。
  9. 性能日志结束。

5.7 TagInclude 执行流(单节点)

flowchart TD
    A["AbstractBlock 遇到 {% include 'x' %}"] --> B["new TagInclude(markup, tokens)"]
    B --> C["TagInclude::__construct"]
    C --> D["parent::__construct → parse()"]
    D --> E["读 snippets/x.liquid"]
    E --> F["tokenize → new Document 子 AST"]
    F --> G["TagInclude 放入父 nodelist"]

    H["Template::render(data)"] --> I["Document::renderAll"]
    I --> J["TagInclude::render(context)"]
    J --> K["可选:再次加载子 Document"]
    K --> L["context.push()"]
    L --> M["设置 attributes / with / for 变量"]
    M --> N["子 Document::render(context)"]
    N --> O["context.pop()"]
    O --> P["appendFingerPrint → 返回 HTML 片段"]

六、文件系统:snippet 如何解析路径

TagInclude 使用 OemsaasLocal / OemsaasDatabase,而非 layout 构造时传入的默认 LocalFileSystem

OemsaasLocal::fullPath() 要点:

  • 模板名仅允许安全字符。
  • 拼接 {root}/{name}.liquid
  • 若配置了 theme_dir,优先 theme/{theme_dir}/snippets/{name}.liquid
  • readTemplateFile 失败时返回空字符串(需注意空 snippet 行为)。

TagSection 使用 sections/ 目录;TagTemplate 使用 templates/;逻辑与 include 类似,仅根路径不同。


七、同类 Tag 对照

Tag加载目录作用域典型用途
includeTagIncludesnippets/共享父 Context(push/pop旧版 partial
renderTagRendersnippets/隔离 Context + 显式参数对齐 Shopify OS 2.0
sectionTagSectionsections/共享 + 装修缓存主题积木 section
templateTagTemplatetemplates/共享页面主体 {% template %}
get_blocksTagGetBlocks无文件向 Context merge 数据装修积木列表

数据类 Tag(get_products 等)在 render() 里查库/缓存并 context->merge()不返回 HTML,供后续 {% for %} / {{ }} 使用。


八、缓存层次小结

层级Key内容失效条件
根 layout ASTmd5(layout源码)整页 Documentvendor hasIncludes(对自定义 include 常不触发)
snippet ASTmd5(snippet源码)templateNameDocumentTagInclude::hasIncludes()、render 时重建
业务 HTML 片段LiquidCacheServicesection 级 HTMLTagSectioncached 属性

调试参数:

  • ?liquid_cache=0:关闭 AST 缓存。
  • ?liquid_include_method=file:include AST 按文件 md5 缓存(默认)。

九、从模板源码到 Tag 的完整例子

假设 theme.liquid 片段:

<main>
  {% get_blocks route: template_route, limit: 10 %}
  {% for block in blocks %}
    {% section block.type, section: block.params %}
  {% endfor %}
  {% include 'social-icons' %}
</main>

Parse 阶段(一次)

  1. tokenize 整份 layout。
  2. Document 依次实例化:TagGetBlocksTagFor(块)、TagSectionTagInclude('social-icons')
  3. TagInclude 构造 → parse → 加载 snippets/social-icons.liquid 为子 AST。

Render 阶段(每次请求)

  1. Context 注入 mergeDataproductblocks 等。
  2. TagGetBlocks::rendercontext->merge(['blocks' => ...])
  3. TagFor 循环 → 每次迭代 TagSection::render
  4. TagInclude::render → push → 渲染 social-icons 子树 → pop。
  5. 拼接所有节点输出为 HTML。

十、排障切入点

现象建议查看
include 404 / 空内容OemsaasLocal::fullPaththeme_dir、DB 主题 OemsaasDatabase
子 snippet 变量未定义include 共享父作用域;若用 render 则需显式传参
改 snippet 不生效AST 缓存 runtime/liquid/cache/ 或 APCu;liquid_cache=0
嵌套 include 性能差render 阶段重复 readTemplateFile + hasIncludes 导致重建 AST
自定义 Tag 未生效extend/liquidExtend/tags/ 文件名与 registerTag 蛇形命名

维护:Liquid 扩展 Tag、缓存策略或 TagInclude 行为变更时,请同步更新本文。