本文约 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)里只做两件事——
- 通过
Timber::context()收集全局数据(站点信息、菜单、body class 等); - 调用
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 }},把格式化逻辑从模板里抽出去。
常见踩坑
- Timber 1.x 与 2.x API 混用:
Timber::get_context()在 2.x 里已改为Timber::context(),照抄老教程会报方法不存在。 {{ post.content }}不输出 HTML 或被转义成实体:需要|e('wp_kses_post'),否则post.content在 Timber 2 里默认返回已处理的字符串但可能被自动转义。{{ function('wp_head') }}写成{{ function('wp_head()') }}:函数名不带括号,带了会静默返回空。- 缓存目录不可写:Twig 每次请求都重新编译模板,TTFB 明显变高,检查服务器临时目录权限。
- 与区块编辑器/FSE 冲突:Timber 是经典主题方案,区块主题用
templates/与theme.json渲染,Timber 无法接管,混用会两套机制打架。 - 案例:某站在文章列表页用
{{ post.author.name }},上线后发现作者名在 Twig 里被自动转义,中文没问题但英文作者名里的&变成了&显示成字面量。原因是 Timber 2 对author.name返回的是原始值又叠加了自动转义,最后统一改成{{ post.author.name|e('esc_html') }}显式声明策略才一致。 - 在 Twig 里做大量数据库查询:
{{ function('get_posts', ...) }}写在循环里会造成 N+1 查询,应统一在 PHP 侧准备数据。 - ACF 字段取不到:
post.meta('field')需要字段名存在且已保存,空值返回 false,模板里要做{% if %}判断。
总结
可行性结论:经典主题的定制项目,Timber 依然是提升可维护性的高性价比选择——尤其适合模板逻辑复杂、有前端同事协作、或要做多套皮肤的场景。成本主要在三点:多一层依赖与升级负担、与区块生态不兼容、新人上手要学 Twig。如果你的目标是区块主题(FSE),就别绕 Timber 这条路,直接用 theme.json 全局样式 与 注册区块模式;如果只是想让现有经典主题干净一点,先在一个小模板上试水,测过缓存与性能再全量迁移。环境准备可参考 本地开发环境搭建 里的 Composer 部分。
