插件开发
插件用来给系统"加功能而不改主程序"。一个插件就是一个文件夹,放进去、后台点启用就生效,删掉文件夹就彻底消失。
一、插件能做什么
插件通过钩子(hook)介入系统。系统在固定位置触发钩子,插件在钩子上挂自己的代码:
| 钩子名 | 触发位置 | 典型用途 |
|---|---|---|
head | 前台每页 <head> 末尾({tw:seo /} 输出时) | 站长验证 meta、统计代码、自定义 CSS、补充 JSON-LD、hreflang 扩展 |
admin_menu | 后台菜单同步时 | 给后台加一级菜单或子菜单项 |
| 自定义 | 由插件自己 Plugin::run('xxx') 触发 | 多个插件之间协作(A 触发,B 响应) |
目前没有"body 注入"钩子。要往页面主体插内容(悬浮客服、弹窗),正规做法是在模板里直接写,或用站点设置里的 footer_code 字段。插件适合做"head 里加东西"和"后台加菜单"这类事。
二、目录结构
命名空间固定为 plugins\{插件名},由系统自动加载,不需要写 require、不需要 composer。
三、最小可跑插件(3 个文件)
目标:在前台 head 里输出一段搜索引擎站长验证 meta。
1. plugin.json
{
"title": "站点验证",
"version": "1.0.0",
"author": "你的名字",
"description": "在 head 输出 Google / Bing 站长验证 meta 标签",
"homepage": "https://example.com"
}
| 字段 | 说明 |
|---|---|
title | 后台插件列表显示的名称 |
version | 版本号,自己维护 |
author / description / homepage | 可选,展示用 |
2. Plugin.php
<?php
declare(strict_types=1);
namespace plugins\seo_verify;
use app\common\library\Plugin as PluginManager;
class Plugin
{
/**
* 插件被启用时由系统调用,在这里挂钩子
*/
public static function register(): void
{
PluginManager::listen('head', [self::class, 'head']);
}
/**
* 输出到前台 head
* 返回字符串会被拼进 <head> 里
*/
public static function head(): string
{
$google = (string)PluginManager::config('seo_verify', 'google', '');
$bing = (string)PluginManager::config('seo_verify', 'bing', '');
$out = '';
if ($google !== '') {
$out .= '<meta name="google-site-verification" content="' . htmlspecialchars($google, ENT_QUOTES) . '">' . "\n";
}
if ($bing !== '') {
$out .= '<meta name="msvalidate.01" content="' . htmlspecialchars($bing, ENT_QUOTES) . '">' . "\n";
}
return $out;
}
}
1)类名必须是 Plugin,命名空间必须是 plugins\{文件夹名};
2)必须有 public static function register(),系统只调这一个方法;
3)register() 里只挂钩子,别做耗时操作(每页都会执行)。
3. config.php(配置项定义)
<?php
// 后台「插件管理 → 设置」弹窗里的表单定义
return [
[
'key' => 'google',
'title' => 'Google 验证码',
'type' => 'text',
'default' => '',
],
[
'key' => 'bing',
'title' => 'Bing 验证码',
'type' => 'text',
'default' => '',
],
];
支持的字段类型
| type | 控件 | 额外参数 |
|---|---|---|
text | 单行输入框 | — |
textarea | 多行文本域(4 行) | — |
code | 等宽多行(写 JS/CSS 用) | — |
select | 下拉框 | 'options' => ['left' => '左侧', 'right' => '右侧'] |
switch | 开关(值 1 / 0) | — |
四、读写配置
use app\common\library\Plugin as PluginManager;
// 读单个
$phone = PluginManager::config('my-plugin', 'phone', '默认值');
// 读全部(返回数组)
$all = PluginManager::config('my-plugin');
// 写(会覆盖整个配置数组,先读再合并)
$cfg = PluginManager::config('my-plugin');
$cfg['phone'] = '+86 13800000000';
PluginManager::saveConfig('my-plugin', $cfg);
配置存放在 tw_plugin 表的 config 字段(JSON),跟着数据库走,换服务器不会丢。
五、给后台加菜单
public static function register(): void
{
PluginManager::listen('admin_menu', [self::class, 'menu']);
}
public static function menu(): array
{
return [
[
'title' => '站点验证',
'name' => 'seo_verify',
'path' => '',
'icon' => 'layui-icon-vercode',
'children' => [
['title' => '验证设置', 'name' => 'seo_verify/index', 'path' => 'seo_verify/index'],
],
],
];
}
返回的结构和 app/admin/menu.php 完全一致。菜单同步会在下次进入后台时自动执行(菜单版本号变了才同步)。
path 写的是「控制器/方法」,路由器去 app\admin\controller 找。插件目录里的类不会被后台路由识别,所以要么指向已有的方法,要么在 app/admin/controller/ 放一个"薄控制器"(几行代码,逻辑仍写在插件目录里)。
注意:app/admin/ 属于主程序目录,升级会覆盖,自建的控制器要记在升级清单里。
六、完整示例:JSON-LD 增强
给所有产品页补一条 Product 结构化数据(系统已内置,这里演示如何用插件追加 aggregateRating 之类扩展字段)。
<?php
declare(strict_types=1);
namespace plugins\rich_snippet;
use app\common\library\Plugin as PluginManager;
use app\common\library\Tags;
class Plugin
{
public static function register(): void
{
PluginManager::listen('head', [self::class, 'head']);
}
public static function head(): string
{
if (!PluginManager::config('rich_snippet', 'enable', '1')) {
return '';
}
// 只有详情页才输出
$content = Tags::ctx('content');
if (!$content) {
return '';
}
$data = [
'@context' => 'https://schema.org',
'@type' => 'Product',
'name' => (string)$content['title'],
'sku' => (string)($content['model_no'] ?? ''),
'offers' => [
'@type' => 'Offer',
'price' => (string)($content['price'] ?? '0'),
'priceCurrency' => 'USD',
'availability' => 'https://schema.org/InStock',
],
];
$json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return '<script type="application/ld+json">' . $json . '</script>';
}
}
要点:Tags::ctx('content') 能拿到当前页面的内容数组,没有就是 null(说明不是详情页)。所有输出都要 json_encode + 不转义斜杠,否则 URL 会带 \/。
七、启用 / 停用流程
- 把插件文件夹放进
plugins/ - 后台 → 插件 → 插件管理,列表里会自动出现(系统扫描磁盘)
- 点「启用」→ 写入
tw_plugin表status=1 - 前台刷新即可看到效果(
head钩子每页都会跑) - 「停用」只是把 status 改 0,文件还在;删文件夹则彻底移除
静态化页面里已经烤进了插件输出,不清缓存会继续显示旧内容。
八、安全与性能约定
| 要求 | 原因 |
|---|---|
| 所有输出必须转义 | 插件输出的 HTML 直接进页面,用户可编辑的字段一律 htmlspecialchars() |
| 数据库查询用构造器 | 用 Db::name('xxx')->where(...),不要拼字符串,防注入 |
register() 里不做 IO | 每页请求都会执行,做重活会拖慢全站 |
| 异常自己 try/catch | 系统已兜底(插件异常不会白屏),但你自己 catch 能拿到日志更准 |
| 不要改主程序文件 | 升级会覆盖;要改行为就给官方提需求或写插件 |
| 插件名加前缀 | 如 acme_seo,避免和别人重名 |
九、调试排查
| 现象 | 原因 |
|---|---|
| 后台插件列表里没有我的插件 | 缺 plugin.json,或 JSON 格式错(多余逗号最常见) |
| 启用了但前台没输出 | ① 钩子名写错;② register() 没被调用(类名/命名空间不对);③ 静态化缓存没清 |
报 Class "plugins\xxx\Plugin" not found | 文件夹名和命名空间不一致(大小写也算) |
| 后台没有「设置」按钮 | 缺 config.php,或它 return 的不是数组 |
| 保存配置后读到的还是旧值 | Plugin::config() 有进程内缓存,同一请求内改完要重新读库;跨请求无影响 |
| 插件导致白屏 | 看 runtime/log/,通常是语法错或用了不存在的类 |
十、打包分发
给自己的插件做个 zip,让别人也能装:
# 目录结构要带插件名这一层
zip -r my-plugin.zip my-plugin/
# Windows 上用系统自带 tar(注意用正斜杠,别用 PowerShell 的 Compress-Archive)
C:/Windows/System32/tar.exe -a -c -f my-plugin.zip --format zip my-plugin
Compress-Archive 打出来的 zip 内部是反斜杠路径,Linux 服务器解压会生成带 \ 的怪文件名,插件直接失效。