博客从旧版 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 \Throwable,TypeError 会一路冒泡到全局异常处理器,被渲染成一个"干干净净"的 500 错误页——php_errors.log 里连一行 Fatal 都看不到。调试时我只能临时把 catch 改成 \Throwable 并打印堆栈,才揪出真凶。
下面按发现顺序逐个来。
1. 文章页 500:评论组件在循环外取内容
现象:首页、列表页一切正常,点进任意文章页直接 500,日志无任何报错。
根因:VOID 的 libs/Comments.php 里,cancelReply() 在评论循环开始之前就被调用。此时评论组件还没有迭代到任何一行数据,$this->cid 是 null。它内部经由:
___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:47 和 includes/footer.php:86 无条件调用了 $this->permalink()。在 404 页或空归档页上,Archive 组件的 type 是 null,permalink 内部同样走到 Router::url(null) 抛 TypeError。
这里有个套娃细节:404 页本身就是异常处理器渲染出来的,而这个 TypeError 恰好发生在异常处理器自己渲染主题模板的过程中——处理器没法处理自己触发的错误,于是绕过一切兜底,以最原始的 Fatal 形式糊你一脸。
修复:两处都加上判断:
<?php echo $this->type ? $this->permalink : $this->options->siteUrl; ?>3. 友链页 [links] 短代码不解析
现象:友情链接独立页里,[links] 原样输出,不渲染成友链列表。诡异的是只有独立页/文章页失效,列表页正常。
根因:这是一个非常隐蔽的钩子注册时序问题。Typecho 1.3 的 Archive::execute() 执行顺序是:
- 先调用
singleHandle(),其中取plainExcerpt时触发了内容渲染,渲染结果被Widget::__get()缓存进row['#content']; - 之后才
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() 对这种无值参数返回 false,vote_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 里任何账号的 name 或 screenName,就判定为冒用注册身份,直接拒绝且不写库——所以哪里都找不到这条评论。
三组复现验证:
| 评论昵称 | 结果 |
|---|---|
| 管理员登录名 | 被拦,提示「用户名已经被注册」,未入库 |
| 管理员显示名 | 被拦,同上 |
| 普通昵称 | 正常,HTTP 200,入库显示 |
⚠️ 注意:触发只看昵称,不看邮箱。换邮箱但沿用管理员昵称照样被拦。这是 Typecho 的防冒名设计,不是 bug,普通访客不会撞上——但自己测试时很容易踩。
7. 总结
六个问题一张表:
| # | 现象 | 根因 | 性质 | 修复位置 |
|---|---|---|---|---|
| 1 | 文章页 500 | 评论循环外 cid=null → Router::url(null) | 1.3 不兼容 | VOID/libs/Comments.php |
| 2 | 404 页白屏 Fatal | type=null 时无条件调 permalink | 1.3 不兼容 | VOID/includes/head.php、footer.php |
| 3 | [links] 不解析 | 单页内容在主题钩子注册前渲染并缓存 | 1.3 时序变更 | VOID/functions.php(反射清缓存) |
| 4 | ENJOY 无反应 | Request::is() 对无值参数返回 false | 1.3 行为变更 | VOID/Action.php |
| 5 | 评论全部 500 | CommentFilter 空禁词列表误杀 | 插件 bug | CommentFilter/Plugin.php |
| 6 | 评论消失不入审核 | 匿名用了管理员昵称触发防冒名 | Typecho 设计 | 无需修复 |
几点心得:
- 升级 Typecho 1.3 前,先检查主题/插件里所有可能传
null给核心 API 的地方——类型声明会把以前的"坏链接"升级成"致命错误"。 - 遇到"日志里什么都没有"的 500,大概率是
TypeError被全局异常处理器吞了,临时把Router::dispatch()的 catch 改成\Throwable打印堆栈,立刻现形。 - 所有修复都只动主题和插件文件,没有改 Typecho 核心,后续升级不受影响。
如果你也在 Typecho 1.3 上用 VOID 主题遇到类似问题,希望这篇能帮你少走弯路。
—— 2026-07 void主题修复
能用,就不要动!!!升什么级
升一下呗,这不就能水一篇文章了,还不用自己敲代码,AI现在真好用啊