搜索全站
主题 17 分钟阅读

Timber + Twig 做 WordPress 主题:可行性、收益与成本分析

把 PHP 逻辑和 HTML 分开写,是不是更舒服?Timber 让 WordPress 主题用 Twig 模板渲染。本文讲清安装方式、context 数据准备、模板语法、转义处理,以及它带来的性能与维护成本到底值不值。

U
UFO_PF 2016-10-09 · 阅读 17 分钟
Ti 主题
阅读提示
本文约 6,757 字,预计阅读 17 分钟。文中命令与配置均经过实测,可直接参考执行。

引言:模板里塞满 PHP 标签的那几年

经典 WordPress 主题的模板长这样:<article> 里夹着 <?php while ( have_posts() ) : the_post(); ?>,中间再穿插 <?php if ( has_post_thumbnail() ) : ?>,缩进靠 PHP 的开闭标签硬撑。功能一复杂,模板就变成「HTML 里嵌 PHP,PHP 里拼 HTML」的双面胶,前端改样式时要小心翼翼避开 PHP 标签,后端改逻辑时又要在 HTML 里找位置。

Timber 的思路很直接:PHP 文件只负责准备数据,Twig 模板只负责渲染。模板里写 {{ post.title }} 而不是 the_title(),循环写 {% for post in posts %} 而不是 while ( have_posts() )。干净、可维护、前端同学也能读懂。代价是引入 Twig 这一层抽象,以及与区块编辑器生态的兼容问题——后者是 2026 年选择 Timber 时必须认真权衡的点。

原理分析:一次请求里发生了什么

不用 Timber 时:WordPress 解析模板层级 → 直接 include 模板 PHP 文件 → 模板内部边执行 PHP 边输出 HTML。

用 Timber 时:模板 PHP 文件(如 index.php)里只做两件事——

  1. 通过 Timber::context() 收集全局数据(站点信息、菜单、body class 等);
  2. 调用 Timber::render( 'index.twig', $context ) 把数据交给 Twig 引擎编译并输出。

Twig 会把模板编译成 PHP 文件并缓存(默认缓存目录在系统临时目录或 wp-content/uploads/twig-cache 之类,取决于配置),只有模板文件变更时才重新编译,所以运行时的开销主要是一次数组传递和一次 include,并不比原生慢多少。真正会拖慢的是「在 Twig 里调用复杂函数」——每次 {{ function('xxx') }} 都是一次 PHP 函数调用,如果用得太多,性能劣势才会显现。

值得注意的版本差异:Timber 1.x 用 Timber::get_context() 和 new Timber\Post();Timber 2.x 改成了 Timber::context() 和 Timber::get_post(),且要求 PHP 7.4+(建议 8.0+)与 Twig 3。迁移老项目时这两处是必改项。

保姆级实操

第一步:安装

最快是插件方式:

wp plugin install timber --activate

需要版本可控时用 Composer:

composer require timber/timber

functions.php 引入:

require_once __DIR__ . '/vendor/autoload.php';

第二步:改造 index.php 为「数据准备 + 渲染」

<?php
$templates = array( 'index.twig' );

$context = Timber::context();

$context['posts'] = Timber::get_posts();
$context['title'] = is_home() ? __( '最新文章', 'mimangfei' ) : get_the_archive_title();

Timber::render( $templates, $context );

Timber::context() 已经包含了 site(站点信息)、request、body_class、wp_head/wp_footer 的钩子内容等,不必自己拼。

第三步:写 Twig 模板

在主题根目录建 views/index.twig(Timber 默认查找 views/ 目录,可用 Timber::$locations 改):

{% extends "base.twig" %}

{% block content %}
    <h1 class="archive-title">{{ title }}</h1>

    <div class="post-list">
    {% for post in posts %}
        <article class="entry {{ post.class }}">
            <h2 class="entry-title">
                <a href="{{ post.link }}">{{ post.title }}</a>
            </h2>

            <div class="entry-meta">
                {{ post.date|date('Y-m-d') }} · {{ post.author.name }}
            </div>

            {% if post.thumbnail %}
                <img src="{{ post.thumbnail.src('medium_large') }}"
                     alt="{{ post.thumbnail.alt }}"
                     loading="lazy">
            {% endif %}

            <div class="entry-summary">
                {{ post.preview.length(80).read_more('继续阅读') }}
            </div>
        </article>
    {% else %}
        <p>{{ __('暂无内容', 'mimangfei') }}</p>
    {% endfor %}
    </div>

    {% include "partials/pagination.twig" %}
{% endblock %}

base.twig 作为布局母版:

<!doctype html>
<html {{ site.language_attributes }}>
<head>
    <meta charset="{{ site.charset }}">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    {{ function('wp_head') }}
</head>
<body class="{{ body_class }}">
    <a class="skip-link screen-reader-text" href="#content">{{ __('跳到主要内容', 'mimangfei') }}</a>

    {% include "partials/header.twig" %}

    <main id="content" class="site-main">
        {% block content %}{% endblock %}
    </main>

    {% include "partials/footer.twig" %}

    {{ function('wp_footer') }}
</body>
</html>

第四步:single.php / page.php 的对应写法

<?php
$context         = Timber::context();
$context['post'] = Timber::get_post();

if ( is_singular( 'post' ) ) {
    $context['related'] = Timber::get_posts( array(
        'post_type'      => 'post',
        'posts_per_page' => 4,
        'post__not_in'   => array( get_the_ID() ),
    ) );
}

Timber::render( array( 'single-' . $context['post']->post_type . '.twig', 'single.twig' ), $context );

Timber::render() 的第一个参数支持数组,按优先级依次查找,这正好对应 WordPress 的模板层级思路。

第五步:转义与过滤

Twig 默认开启自动转义,但 WordPress 场景常需要放行 HTML(正文里本来就有标签):

{{ post.content|e('wp_kses_post') }}
{{ post.title|e('esc_html') }}
{{ post.link|e('esc_url') }}

Timber 注册了 wp_kses_post、esc_html、esc_attr、esc_url 等 WordPress 函数作为 Twig 转义策略,直接用即可。不要关掉自动转义(autoescape: false),那是把 XSS 大门敞开。

第六步:菜单、分页与自定义数据

$context['menu'] = Timber::get_menu( 'primary' );

add_filter( 'timber/context', function( $context ) {
    $context['options'] = get_fields( 'option' ); // ACF 主题选项
    return $context;
} );

模板里遍历菜单:

<nav aria-label="主导航">
    <ul>
    {% for item in menu.items %}
        <li class="{{ item.classes|join(' ') }}">
            <a href="{{ item.link }}"{% if item.current %} aria-current="page"{% endif %}>{{ item.title }}</a>
            {% if item.children %}
                <ul>{% for child in item.children %}
                    <li><a href="{{ child.link }}">{{ child.title }}</a></li>
                {% endfor %}</ul>
            {% endif %}
        </li>
    {% endfor %}
    </ul>
</nav>

第七步:扩展 Timber(过滤器示例)

add_filter( 'timber/twig', function( $twig ) {
    $twig->addFunction( new \Twig\TwigFunction( 'get_option', function( $key ) {
        return get_option( $key );
    } ) );

    $twig->addFilter( new \Twig\TwigFilter( 'phone_link', function( $phone ) {
        return 'tel:' . preg_replace( '/\D/', '', $phone );
    } ) );

    return $twig;
} );

模板里就能写 {{ site_phone|phone_link }},把格式化逻辑从模板里抽出去。

常见踩坑

  1. Timber 1.x 与 2.x API 混用:Timber::get_context() 在 2.x 里已改为 Timber::context(),照抄老教程会报方法不存在。
  2. {{ post.content }} 不输出 HTML 或被转义成实体:需要 |e('wp_kses_post'),否则 post.content 在 Timber 2 里默认返回已处理的字符串但可能被自动转义。
  3. {{ function('wp_head') }} 写成 {{ function('wp_head()') }}:函数名不带括号,带了会静默返回空。
  4. 缓存目录不可写:Twig 每次请求都重新编译模板,TTFB 明显变高,检查服务器临时目录权限。
  5. 与区块编辑器/FSE 冲突:Timber 是经典主题方案,区块主题用 templates/ 与 theme.json 渲染,Timber 无法接管,混用会两套机制打架。
  6. 案例:某站在文章列表页用 {{ post.author.name }},上线后发现作者名在 Twig 里被自动转义,中文没问题但英文作者名里的 & 变成了 &amp; 显示成字面量。原因是 Timber 2 对 author.name 返回的是原始值又叠加了自动转义,最后统一改成 {{ post.author.name|e('esc_html') }} 显式声明策略才一致。
  7. 在 Twig 里做大量数据库查询:{{ function('get_posts', ...) }} 写在循环里会造成 N+1 查询,应统一在 PHP 侧准备数据。
  8. ACF 字段取不到:post.meta('field') 需要字段名存在且已保存,空值返回 false,模板里要做 {% if %} 判断。

总结

可行性结论:经典主题的定制项目,Timber 依然是提升可维护性的高性价比选择——尤其适合模板逻辑复杂、有前端同事协作、或要做多套皮肤的场景。成本主要在三点:多一层依赖与升级负担、与区块生态不兼容、新人上手要学 Twig。如果你的目标是区块主题(FSE),就别绕 Timber 这条路,直接用 theme.json 全局样式 与 注册区块模式;如果只是想让现有经典主题干净一点,先在一个小模板上试水,测过缓存与性能再全量迁移。环境准备可参考 本地开发环境搭建 里的 Composer 部分。

U
UFO_PF作者

专注 WordPress 生态的实战作者,内容以「可复制、可验证」为第一原则。

留下你的想法

邮箱地址不会被公开。带 * 的为必填项。