问题现象

在 Typecho 1.3.0 版本中,于主题 functions.php 内通过 Plugin::factory 注册 contentEx / excerptEx 钩子,实现短代码替换功能:

php
\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:

php
// 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)会经过两步处理:

php
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() 的实现:

php
public static function nativeClassName(string $className): string
{
    return trim(str_replace('\\', '_', $className), '_');
}

因此 Widget\Abstract\ContentsnativeClassName 处理后变为 Widget_Abstract_Contents,与别名表中的 key 一致,注册成功。

触发时,contentExWidget\Base\Contents 类发起:

php
// var/Widget/Base/Contents.php 第 859 行
protected function ___content(): ?string
{
    // ...
    return Contents::pluginHandle()->filter('contentEx', $content, $this);
}

pluginHandle() 使用 static::class

php
// 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() — 不缓存

php
// 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() — 引入结果缓存

php
// 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 调用之后

php
// 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

php
// 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

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.phpexecute() 方法。

修改前:

php
/** 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);
    }
}

修改后:

php
/** 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
<?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);
    }
}

注意事项:

  • themeInitsingleHandle 之后、模板渲染之前执行,此时缓存已生成,清除操作时机正确
  • archiveDescriptionsingleHandle 中已被赋值为 $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 时钩子已存在,缓存的就是已替换的内容。