博客从旧版 Typecho 升级到 1.3 之后,VOID 主题接连出现文章页 500、404 页面直接崩溃、友链页 [links] 不解析、ENJOY 点赞无反应、评论提交失败等一连串问题。为了彻底搞清楚,我把正式环境完整搬到本地(PHP 8.3 + MariaDB + Typecho 1.3 + VOID)逐个复现排查。本文按时间线记录全部六个问题的现象、根因与修复,其中前三个都指向同一个源头——Typecho 1.3 的一处破坏性变更

0. 罪魁祸首:Typecho 1.3 给 Router::url() 加了类型声明

先说结论。Typecho 1.3 对核心路由方法加了 PHP 类型声明:

// Typecho 1.2 及以前
public static function url($name, ...)

// Typecho 1.3
public static function url(string $name, ...)

在旧版本里,传 null 进去顶多生成一个奇怪的链接;而在 1.3 里,任何传 null 的调用都会直接抛 TypeError,也就是致命错误。VOID 主题里恰好有多处会在特定场景下把 null 传进这条调用链,于是升级后各种页面接连崩溃。

更麻烦的是排查体验:Typecho 的 Router::dispatch()catch (\Exception) 而不 catch \ThrowableTypeError 会一路冒泡到全局异常处理器,被渲染成一个"干干净净"的 500 错误页——php_errors.log 里连一行 Fatal 都看不到。调试时我只能临时把 catch 改成 \Throwable 并打印堆栈,才揪出真凶。

下面按发现顺序逐个来。

1. 文章页 500:评论组件在循环外取内容

现象:首页、列表页一切正常,点进任意文章页直接 500,日志无任何报错。

根因:VOID 的 libs/Comments.php 里,cancelReply() 在评论循环开始之前就被调用。此时评论组件还没有迭代到任何一行数据,$this->cidnull。它内部经由:

___parentContent()
  → Widget\Base\Comments::___parentContent()
  → From::allocWithAlias($this->cid, ...)   // cid = null
  → 查不到内容 → type 为 null
  → Router::url(null, ...)                  // !! TypeError

在 Typecho 1.2 里这一路顶多生成个坏链接,1.3 直接炸。

修复:改 VOID/libs/Comments.php,让 ___parentContent() 不再依赖尚未赋值的 $this->cid,而是用参数里可靠传入的 parentId(即文章 cid)来构建内容组件。

2. 404 页与空归档页:直接白屏 Fatal

现象:访问不存在的地址,看到的不是主题的 404 页,而是一个原始的 PHP Fatal 白屏——连 Typecho 自己的错误页都没了。

根因:VOID 的 includes/head.php:47includes/footer.php:86 无条件调用了 $this->permalink()。在 404 页或空归档页上,Archive 组件的 typenullpermalink 内部同样走到 Router::url(null)TypeError

这里有个套娃细节:404 页本身就是异常处理器渲染出来的,而这个 TypeError 恰好发生在异常处理器自己渲染主题模板的过程中——处理器没法处理自己触发的错误,于是绕过一切兜底,以最原始的 Fatal 形式糊你一脸。

修复:两处都加上判断:

<?php echo $this->type ? $this->permalink : $this->options->siteUrl; ?>

3. 友链页 [links] 短代码不解析

现象:友情链接独立页里,[links] 原样输出,不渲染成友链列表。诡异的是只有独立页/文章页失效,列表页正常

根因:这是一个非常隐蔽的钩子注册时序问题。Typecho 1.3 的 Archive::execute() 执行顺序是:

  1. 先调用 singleHandle(),其中取 plainExcerpt 时触发了内容渲染,渲染结果被 Widget::__get() 缓存进 row['#content']
  2. 之后才 require 主题的 functions.php,注册 markdown / contentEx 等内容处理钩子。

也就是说,单页/文章的内容在主题钩子注册之前就已经渲染并缓存完毕——钩子注册了个寂寞。列表页的渲染时机在钩子注册之后,所以不受影响。

修复(只动主题 functions.php):在 themeInit($archive) 里用反射清掉 \Typecho\Widget::$row 中的 #content / #excerpt / #plainExcerpt / #summary 缓存键,强制内容在钩子就位后重新渲染。

4. ENJOY 点赞:点上去没反应

现象:文章页的 ENJOY 按钮点击后毫无反应,请求返回空 200。

根因:点赞走 VOID 插件的 VOID_Action,前端请求地址形如:

/index.php/action/void?content

注意 ?content无值参数。插件用 Request::is('content') 判断动作类型:

public function action()
{
    $this->body = json_decode(file_get_contents('php://input'), true);
    $this->on($this->request->is('content'))->vote_content();
    $this->on($this->request->is('comment'))->vote_comment();
    // ...
}

Typecho 1.3 的 Request::is() 对这种无值参数返回 falsevote_content() 根本不会执行。

修复:改为直接判断 GET 参数是否存在:

public function action()
{
    $this->body = json_decode(file_get_contents('php://input'), true);

    if (isset($_GET['content']))        $this->vote_content();
    elseif (isset($_GET['comment']))    $this->vote_comment();
    elseif (isset($_GET['show']))       $this->vote_show();
    elseif (isset($_GET['getimginfo'])) $this->void_img_info();
    // ...
}

修复后请求正常返回 {"code":200,"msg":"done"}

5. 评论 500:「评论内容中包含禁止词汇」

现象:任何人提交任何评论都 500,提示「评论内容中包含禁止词汇」。

根因:这次不是主题,是 CommentFilter 插件的经典 bug。禁止词汇列表(words_ban)是空的,但它的 check_in() 对空列表处理不当:

$words = explode("\n", $words_str);   // 空字符串 -> [""]
foreach ($words as $word) {
    if (false !== strpos($str, trim($word))) {  // strpos(任意文本, "") === 0 永远成立
        return true;                            // 每条评论都"命中禁词"
    }
}

explode 空字符串得到 [""],而 strpos($str, "") 恒为 0——空禁词表反而拦下了全部评论

修复:跳过空词,让「空列表 = 不拦截」:

private static function check_in($words_str, $str)
{
    $words = array_filter(array_map('trim', explode("\n", $words_str)), function ($w) {
        return $w !== '';
    });
    if (empty($words)) return false;
    foreach ($words as $word) {
        if (false !== strpos($str, $word)) return true;
    }
    return false;
}

6. 匿名评论失败,却不进待审核(差点误判)

现象:修完上面所有问题后,匿名评论仍然失败,而且失败的评论既不显示,也不在后台待审核里——像是凭空消失了。

我一度怀疑是「白名单审核 + 主题 JS 误报」,折腾半天后发现真相很简单:测试时匿名评论填的是管理员自己的昵称

Typecho 核心 var/Widget/Feedback.php 有一道防冒名校验:

public function requireUserLogin(string $userName): bool
{
    if (
        !$this->user->hasLogin()
        && $this->db->fetchRow($this->db->select('uid')
            ->from('table.users')
            ->where('screenName = ? OR name = ?', $userName, $userName)->limit(1))
    ) {
        return false;  // 「您所使用的用户名已经被注册,请登录后再次提交」
    }
    return true;
}

访客未登录时,只要昵称匹配到 typecho_users 里任何账号的 namescreenName,就判定为冒用注册身份,直接拒绝且不写库——所以哪里都找不到这条评论。

三组复现验证:

评论昵称结果
管理员登录名被拦,提示「用户名已经被注册」,未入库
管理员显示名被拦,同上
普通昵称正常,HTTP 200,入库显示
⚠️ 注意:触发只看昵称,不看邮箱。换邮箱但沿用管理员昵称照样被拦。这是 Typecho 的防冒名设计,不是 bug,普通访客不会撞上——但自己测试时很容易踩。

7. 总结

六个问题一张表:

#现象根因性质修复位置
1文章页 500评论循环外 cid=nullRouter::url(null)1.3 不兼容VOID/libs/Comments.php
2404 页白屏 Fataltype=null 时无条件调 permalink1.3 不兼容VOID/includes/head.phpfooter.php
3[links] 不解析单页内容在主题钩子注册前渲染并缓存1.3 时序变更VOID/functions.php(反射清缓存)
4ENJOY 无反应Request::is() 对无值参数返回 false1.3 行为变更VOID/Action.php
5评论全部 500CommentFilter 空禁词列表误杀插件 bugCommentFilter/Plugin.php
6评论消失不入审核匿名用了管理员昵称触发防冒名Typecho 设计无需修复

几点心得:

  1. 升级 Typecho 1.3 前,先检查主题/插件里所有可能传 null 给核心 API 的地方——类型声明会把以前的"坏链接"升级成"致命错误"。
  2. 遇到"日志里什么都没有"的 500,大概率是 TypeError 被全局异常处理器吞了,临时把 Router::dispatch() 的 catch 改成 \Throwable 打印堆栈,立刻现形。
  3. 所有修复都只动主题和插件文件,没有改 Typecho 核心,后续升级不受影响。

如果你也在 Typecho 1.3 上用 VOID 主题遇到类似问题,希望这篇能帮你少走弯路。

—— 2026-07 void主题修复