问题现象
在 Typecho 1.3.0 版本中,于主题 functions.php 内通过 Plugin::factory 注册 contentEx / excerptEx 钩子,实现短代码替换功能:
\Typecho\Plugin::factory('Widget\Abstract\Contents')->contentEx = ['ContentFilter', 'parseContent'];
\Typecho\Plugin::factory('Widget\Abstract\Contents')->excerptEx = ['ContentFilter', 'parseContent'];注册语句本身能正常执行(通过打印确认 hook 已注册成功),但实际渲染时短代码替换完全不生效。
令人困惑的是:将完全相同的注册代码和过滤器类放入插件(usr/plugins/xxx/Plugin.php)中,短代码替换就能正常生效。唯独在主题 functions.php 中注册时失效。
社区反馈此为 Typecho 1.3 版本特有的问题。本文通过完整对比 Typecho 1.2.1 与 1.3.0 的源码,定位根本原因并给出修复方案。
源码分析
1. 钩子注册与触发机制
Typecho 的插件钩子通过 Plugin::factory() 注册,存储在 Plugin::$plugin['handles'] 静态数组中,以 handle:component 为 key:
// var/Typecho/Plugin.php
public function __set(string $component, callable $value)
{
// ...
$component = $this->handle . ':' . $component;
self::$plugin['handles'][$component][strval($weight)] = $value;
}注册时传入的 handle(如 Widget\Abstract\Contents)会经过两步处理:
public function __construct(string $handle)
{
// 第一步:反查类别名表
if (defined('__TYPECHO_CLASS_ALIASES__')) {
$alias = array_search('\\' . ltrim($handle, '\\'), __TYPECHO_CLASS_ALIASES__);
$handle = $alias ?: $handle;
}
// 第二步:将命名空间分隔符 \ 替换为 _
$this->handle = Common::nativeClassName($handle);
}Common::nativeClassName() 的实现:
public static function nativeClassName(string $className): string
{
return trim(str_replace('\\', '_', $className), '_');
}因此 Widget\Abstract\Contents 经 nativeClassName 处理后变为 Widget_Abstract_Contents,与别名表中的 key 一致,注册成功。
触发时,contentEx 从 Widget\Base\Contents 类发起:
// var/Widget/Base/Contents.php 第 859 行
protected function ___content(): ?string
{
// ...
return Contents::pluginHandle()->filter('contentEx', $content, $this);
}pluginHandle() 使用 static::class:
// var/Typecho/Widget.php
public static function pluginHandle(): Plugin
{
return Plugin::factory(static::class);
}由于是 Contents::pluginHandle() 显式调用,static::class 解析为 Widget\Base\Contents,经别名反查得到 Widget_Abstract_Contents,最终存储 key 为 Widget_Abstract_Contents:contentEx。
注册与触发的 key 一致,钩子注册本身没有问题。问题出在触发时机。
2. 核心原因:1.3.0 新增的 __get() 属性缓存机制
这是 1.3.0 版本引入的关键变化,也是导致 functions.php 钩子失效的根本原因。
1.2.1 的 __get() — 不缓存
// var/Typecho/Widget.php (1.2.1)
public function __get(string $name)
{
if (array_key_exists($name, $this->row)) {
return $this->row[$name];
} else {
$method = '___' . $name;
if (method_exists($this, $method)) {
return $this->$method(); // 每次访问都重新调用,不缓存结果
}
}
return null;
}1.3.0 的 __get() — 引入结果缓存
// var/Typecho/Widget.php (1.3.0)
public function __get(string $name)
{
$method = '___' . $name;
$key = '#' . $name;
if (array_key_exists($key, $this->row)) {
return $this->row[$key]; // 命中缓存,直接返回,不再调用方法
} elseif (method_exists($this, $method)) {
$this->row[$key] = $this->$method(); // 首次调用后将结果缓存到 $this->row['#content']
return $this->row[$key];
} elseif (array_key_exists($name, $this->row)) {
return $this->row[$name];
}
// ...
}关键变化:1.3.0 在首次访问 $this->content 时调用 ___content() 并将结果缓存到 $this->row['#content'],此后再次访问直接返回缓存值,不再重新调用 ___content(),也就不再触发 contentEx 钩子。
3. 执行时序冲突
在 Widget\Archive::execute() 中,functions.php 的加载位于 handle 调用之后:
// var/Widget/Archive.php execute() 方法
// ...
if (isset($handles[$this->parameter->type])) {
$handle = $handles[$this->parameter->type];
$this->{$handle}($select, $hasPushed); // ← 第 663 行:先执行 handle(如 singleHandle)
} else {
$hasPushed = self::pluginHandle()->call('handle', $this->parameter->type, $this, $select);
}
/** 初始化皮肤函数 */
$functionsFile = $this->themeDir . 'functions.php';
if (/* ... */) {
require_once $functionsFile; // ← 第 674 行:后加载 functions.php(注册钩子)
if (function_exists('themeInit')) {
themeInit($this);
}
}而 singleHandle() 内部在查询数据后,访问了 $this->plainExcerpt:
// var/Widget/Archive.php singleHandle() 方法
private function singleHandle(Query $select, bool &$hasPushed)
{
// ...
$this->query($select); // ← 查询文章数据
// ...
if (!$this->makeSinglePageAsFrontPage) {
// ...
$this->archiveDescription = $this->plainExcerpt; // ← 第 1705 行:访问 plainExcerpt
}
// ...
}这触发了一条属性依赖链:
$this->plainExcerpt
→ ___plainExcerpt() → 访问 $this->excerpt
→ ___excerpt() → 访问 $this->content
→ ___content() → 触发 contentEx 钩子 ← 此时 functions.php 尚未加载,钩子未注册!具体调用链在 var/Widget/Base/Contents.php:
// 第 794 行
protected function ___plainExcerpt(): ?string
{
$plainText = str_replace("\n", '', trim(strip_tags($this->excerpt))); // ← 访问 excerpt
$plainText = $plainText ?: $this->title;
return Common::subStr($plainText, 0, 100);
}
// 第 777 行
protected function ___excerpt(): ?string
{
if ($this->hidden) {
return $this->text;
}
$content = Contents::pluginHandle()->filter('excerpt', $this->content, $this); // ← 访问 content
[$excerpt] = explode('<!--more-->', $content);
return Common::fixHtml(Contents::pluginHandle()->filter('excerptEx', $excerpt, $this));
}
// 第 846 行
protected function ___content(): ?string
{
if ($this->hidden) {
return $this->text;
}
$content = Contents::pluginHandle()->trigger($plugged)->filter('content', $this->text, $this);
if (!$plugged) {
$content = $this->isMarkdown ? $this->markdown($content) : $this->autoP($content);
}
return Contents::pluginHandle()->filter('contentEx', $content, $this); // ← 触发 contentEx
}4. 两个版本的行为差异
| 步骤 | 1.2.1(不缓存) | 1.3.0(缓存) |
|---|---|---|
singleHandle 内访问 plainExcerpt,触发 contentEx | 钩子未注册,返回原文 | 钩子未注册,返回原文,结果缓存到 $this->row['#content'] |
加载 functions.php,注册钩子 | 钩子注册成功 | 钩子注册成功 |
模板渲染时访问 $this->content | 重新调用 ___content(),钩子已注册,替换生效 | 直接返回 $this->row['#content'] 缓存值,钩子不再触发,替换不生效 |
5. 为什么插件方式不受影响
插件的钩子通过 Plugin::init() 在 Widget\Init::execute() 中加载,这在 Archive::execute() 之前执行:
index.php 第 17 行:\Widget\Init::alloc(); ← Plugin::init() 加载持久化的插件钩子
index.php 第 23 行:\Typecho\Router::dispatch(); ← 路由分发,之后才进入 Archive::execute()因此当 singleHandle 触发 contentEx 时,插件的钩子已经注册,替换在首次调用时就生效。即使 1.3.0 缓存了结果,缓存的也正是已替换的内容,后续读取缓存没有问题。
functions.php 的钩子注册晚于 singleHandle,首次触发时钩子不存在,缓存了未替换的原文,后续读取缓存永远跳过钩子,导致替换不生效。
修复方案
方案一:修改核心文件 — 调整 functions.php 加载时机
将 functions.php 的加载拆为两步:require_once(注册钩子)提前到 handle 调用之前,themeInit()(使用文章数据)保持在 handle 之后。
修改文件:var/Widget/Archive.php 的 execute() 方法。
修改前:
/** handle初始化 */
self::pluginHandle()->call('handleInit', $this, $select);
/** 初始化其它变量 */
$this->archiveFeedUrl = $this->options->feedUrl;
$this->archiveFeedRssUrl = $this->options->feedRssUrl;
$this->archiveFeedAtomUrl = $this->options->feedAtomUrl;
$this->archiveKeywords = $this->options->keywords;
$this->archiveDescription = $this->options->description;
$this->archiveUrl = $this->options->siteUrl;
if (isset($handles[$this->parameter->type])) {
$handle = $handles[$this->parameter->type];
$this->{$handle}($select, $hasPushed);
} else {
$hasPushed = self::pluginHandle()->call('handle', $this->parameter->type, $this, $select);
}
/** 初始化皮肤函数 */
$functionsFile = $this->themeDir . 'functions.php';
if (
(!$this->invokeFromOutside || $this->parameter->type == 404 || $this->parameter->preview)
&& file_exists($functionsFile)
) {
require_once $functionsFile;
if (function_exists('themeInit')) {
themeInit($this);
}
}修改后:
/** handle初始化 */
self::pluginHandle()->call('handleInit', $this, $select);
/** 初始化其它变量 */
$this->archiveFeedUrl = $this->options->feedUrl;
$this->archiveFeedRssUrl = $this->options->feedRssUrl;
$this->archiveFeedAtomUrl = $this->options->feedAtomUrl;
$this->archiveKeywords = $this->options->keywords;
$this->archiveDescription = $this->options->description;
$this->archiveUrl = $this->options->siteUrl;
/** 提前加载皮肤函数,使钩子在 handle 触发 content 链之前注册 */
$functionsFile = $this->themeDir . 'functions.php';
$hasFunctionsFile = (!$this->invokeFromOutside || $this->parameter->type == 404 || $this->parameter->preview)
&& file_exists($functionsFile);
if ($hasFunctionsFile) {
require_once $functionsFile;
}
if (isset($handles[$this->parameter->type])) {
$handle = $handles[$this->parameter->type];
$this->{$handle}($select, $hasPushed);
} else {
$hasPushed = self::pluginHandle()->call('handle', $this->parameter->type, $this, $select);
}
/** 在 handle 执行完毕后调用 themeInit,确保此时已有文章数据 */
if ($hasFunctionsFile && function_exists('themeInit')) {
themeInit($this);
}修改后执行时序:
① require_once functions.php ← 钩子注册(提前到 handle 之前)
② singleHandle ← query 数据 → plainExcerpt → contentEx 命中已注册的钩子,替换生效
③ themeInit($this) ← 此时已有文章数据,不破坏 themeInit 依赖为什么需要拆分: themeInit($this) 通常会访问 $this->cid、$this->title 等文章数据,而数据在 singleHandle 内部的 $this->query($select) 之后才可用。如果将 themeInit 也提前,会在无数据的情况下执行,导致出错。利用 require_once 的幂等特性,将文件加载(注册钩子)和函数调用(使用数据)分离:文件加载提前,函数调用保持原位。
方案二:不修改核心文件 — 通过反射清除缓存
如果不想修改 Typecho 核心代码,可以在 functions.php 中利用 themeInit 钩子清除已被缓存的属性,使模板渲染时重新触发 contentEx。
原理: 1.3.0 的缓存存储在 $this->row 数组中以 # 为前缀的键中(如 #content、#excerpt)。functions.php 加载时 themeInit 被调用,此时缓存已生成。通过反射访问 private $row 属性,删除这些缓存键,__get() 找不到缓存就会重新调用 ___content(),此时钩子已注册,替换即可生效。
<?php
if (!defined('__TYPECHO_ROOT_DIR__')) exit;
// 注册钩子
\Typecho\Plugin::factory('Widget\Abstract\Contents')->contentEx = ['ContentFilter', 'parseContent'];
\Typecho\Plugin::factory('Widget\Abstract\Contents')->excerptEx = ['ContentFilter', 'parseContent'];
/**
* 通过 themeInit 钩子清除 1.3.0 的属性缓存
* 使 content/excerpt 等属性在模板渲染时重新触发钩子
*/
function themeInit($archive)
{
$ref = new \ReflectionProperty(\Typecho\Widget::class, 'row');
//$ref->setAccessible(true); PHP8.1及以上不需要
$row = $ref->getValue($archive);
// 清除 1.3.0 __get() 缓存的派生属性
// 这些属性在 singleHandle 中已被动生成缓存,需清除后才会重新触发钩子
unset(
$row['#content'],
$row['#excerpt'],
$row['#plainExcerpt'],
$row['#summary'],
$row['#description']
);
$ref->setValue($archive, $row);
}
class ContentFilter
{
/**
* 主处理函数:挂载到 contentEx 和 excerptEx
*/
public static function parseContent($content, $widget, $lastResult = null): string
{
$html = $content;
// 1. 处理自定义短代码
$html = self::handleShortcodes($html);
// 2. 给 H 标签添加 id
$html = self::addHeadingIds($html);
return $html;
}
/**
* 自定义短代码示例:[btn url="..."]文字[/btn]
*/
private static function handleShortcodes($html)
{
$html= preg_replace_callback(
'/\[btn\s+url="([^"]+)"\](.*?)\[\/btn\]/i',
function ($matches) {
$url = trim($matches[1], '`');
$text = $matches[2];
return '<a href="' . $url . '" class="custom-btn">' . $text . '</a>';
},
$html
);
return $html;
}
/**
* 为所有 <h1>~<h6> 标签添加 id 属性
*/
private static function addHeadingIds($html)
{
// 仅给还没有 id 属性的标题添加 id,避免重复处理
$html = preg_replace_callback(
'/<h([1-6])(?![^>]*\bid=)[^>]*>(.*?)<\/h\1>/i',
function ($matches) {
$level = $matches[1];
$text = strip_tags($matches[2]);
$id = self::slugify($text);
return "<h{$level} id=\"{$id}\">{$matches[2]}</h{$level}>";
},
$html
);
return $html;
}
/**
* 简单的中英文 slug 化(复用 Typecho 内置方法)
*/
private static function slugify($text)
{
if (empty($text)) {
return 'heading-' . uniqid();
}
// Typecho_Common::slugName 可以将字符串转为合法的 URL 片段
return Typecho_Common::slugName($text);
}
}注意事项:
themeInit在singleHandle之后、模板渲染之前执行,此时缓存已生成,清除操作时机正确archiveDescription在singleHandle中已被赋值为$this->plainExcerpt的结果(已缓存的原文),如果需要在页面 meta description 中也体现替换效果,需额外处理- 反射操作依赖
$row属性可见性为private,如果未来版本调整可能需要适配 - 此方案无需修改任何核心文件,升级 Typecho 后只需确认
row属性名和缓存键格式未变即可
两种方案对比
| 方案一:修改核心文件 | 方案二:反射清除缓存 | |
|---|---|---|
| 修改范围 | var/Widget/Archive.php | 仅主题 functions.php |
| 根治程度 | 从根源解决加载时机问题 | 绕过缓存,治标方案 |
| 性能 | 无额外开销 | 每次渲染多一次反射操作和重复计算 |
| 适用场景 | 可修改核心代码的项目 | 不便修改核心、需保持可升级性的项目 |
总结
Typecho 1.3.0 在 Widget::__get() 中引入了属性结果缓存机制:首次访问 $this->content 时调用 ___content() 并将结果缓存到 $this->row['#content'],此后直接返回缓存值。
而 Archive::execute() 中 functions.php 的加载位于 singleHandle() 之后。singleHandle() 内部访问 $this->plainExcerpt 时,触发了 plainExcerpt → excerpt → content → contentEx 依赖链。此时 functions.php 尚未加载,contentEx 钩子未注册,返回的是未替换的原文,并被缓存。
后续加载 functions.php 注册的钩子永远不会被触发,因为模板渲染时读取的是缓存值,不再调用 ___content()。
插件方式不受影响,是因为插件钩子在 Init::execute() 阶段通过 Plugin::init() 加载,早于 Archive::execute(),首次触发 contentEx 时钩子已存在,缓存的就是已替换的内容。
chat_bubble_outline 评论 1 条
哼,代码世界的“先来后到”还真是严格呢 ( ̄ー ̄) 原来1.3版本偷偷给content加了缓存,让functions.php的钩子完美错过首发车,最后只能吃冷饭…结果就是主题里注册的短代码变透明了! (´・_・`) 不过插件党因为早早上车倒是畅通无阻——这波啊,是时序的锅。解决方案倒是很懂:要么插队(提前加载functions.php),要么清除缓存(反射大法好)。但我还是想问,Typecho团队为啥不直接让新缓存兼容旧习惯呢?( ̄▽ ̄*)ゞ
——DeepSeek酱