WordPress 为什么能撑起全球近半数网站?答案在于它的插件生态——任何人都能写插件扩展功能,而不必改动核心代码。这种"可扩展性"的底层支撑就是钩子机制。钩子(Hook)本质是"预埋的扩展点":核心代码在关键位置埋下钩子,插件把自己的函数挂上去,代码执行到此处时自动调用挂载的函数。本篇带你从零实现一套钩子与插件系统,理解建站框架的扩展原理。
一、钩子的两种形态:动作与过滤器
钩子分两类:动作(Action)和过滤器(Filter)。动作用于"在某个时机做点事",比如发布文章时发邮件通知,不关心返回值;过滤器用于"修改某个数据",比如把文章标题里的敏感词替换掉,必须返回修改后的值。两者底层实现几乎一样,区别只在于是否使用返回值。
// 钩子管理器(动作 + 过滤器统一实现)
class HookManager {
constructor() {
this.hooks = {}; // { hookName: [ {callback, priority} ] }
}
// 注册钩子:把回调挂到指定钩子上
addHook(name, callback, priority = 10) {
if (!this.hooks[name]) this.hooks[name] = [];
this.hooks[name].push({ callback, priority });
// 按 priority 升序排列,数字小的先执行
this.hooks[name].sort((a, b) => a.priority - b.priority);
}
// 执行动作:依次调用所有回调,不关心返回值
doAction(name, ...args) {
const handlers = this.hooks[name] || [];
for (const h of handlers) {
h.callback(...args);
}
}
// 应用过滤器:依次调用,把上一次返回值传给下一次
applyFilters(name, value, ...args) {
const handlers = this.hooks[name] || [];
for (const h of handlers) {
value = h.callback(value, ...args); // 链式传递
}
return value;
}
}
const hooks = new HookManager();
优先级参数让插件能控制执行顺序——尧图项目里"敏感词过滤"插件优先级设 5(先执行),"内容缓存"插件优先级设 20(后执行),保证缓存的是过滤后的干净内容。这种"优先级排序 + 链式传递"的设计,让多个插件互不冲突地协同工作。
二、在核心代码中埋钩子
钩子机制要发挥作用,核心代码必须"主动埋钩子"——在关键流程位置调用 doAction 或 applyFilters。埋点的位置就是扩展点,决定了插件能干预哪些环节。下面是一个文章发布流程的埋点示例:
// 文章发布流程(核心代码,预埋钩子)
async function publishPost(postData) {
// 1. 过滤器:发布前允许插件修改文章内容
postData.title = hooks.applyFilters('pre_publish_title', postData.title);
postData.content = hooks.applyFilters('pre_publish_content', postData.content);
// 2. 动作:保存前通知插件(如做内容校验、日志记录)
hooks.doAction('before_publish', postData);
// 3. 写入数据库
const postId = await db.insert('posts', postData);
// 4. 动作:发布完成后通知插件(如发邮件、推送、生成静态页)
hooks.doAction('after_publish', postId, postData);
return postId;
}
// 插件1:敏感词过滤(挂在过滤器上,修改内容)
hooks.addHook('pre_publish_content', (content) => {
const badWords = ['违规词1', '违规词2'];
badWords.forEach(w => content = content.replace(w, '***'));
return content; // 过滤器必须返回值
}, 5);
// 插件2:发布后发邮件通知(挂在动作上,不修改数据)
hooks.addHook('after_publish', (postId, postData) => {
mailer.send({
to: 'editor@ldpk.cn',
subject: '新文章已发布:' + postData.title,
body: '文章ID:' + postId
});
}, 10);
// 插件3:发布后生成静态缓存页(挂在动作上)
hooks.addHook('after_publish', (postId) => {
generateStaticPage(postId);
}, 20); // 优先级20,邮件发送后再生成缓存
核心代码完全不知道插件的存在,它只是在固定位置"喊一嗓子",有没有人响应都不影响主流程。这种"发布-订阅"模式让核心与插件彻底解耦——卸载某个插件,核心照常运行;新增插件,核心无需改动。尧图在做 CMS 二开时,几乎从不改核心文件,全部用钩子挂载自定义逻辑,升级核心时不会丢失定制功能。
三、插件加载与生命周期
光有钩子还不够,还要有"插件管理"——自动扫描插件目录、按顺序加载、提供启用/禁用开关。一个规范的插件系统,每个插件是一个独立文件,包含元信息(名称、版本、作者)和初始化逻辑(注册钩子)。下面是尧图常用的插件加载器实现:
// 插件加载器
class PluginLoader {
constructor(hookManager) {
this.hooks = hookManager;
this.plugins = {}; // { pluginName: { meta, enabled } }
}
// 扫描插件目录并加载
loadAll(pluginDir) {
const files = fs.readdirSync(pluginDir).filter(f => f.endsWith('.plugin.js'));
for (const file of files) {
const plugin = require(path.join(pluginDir, file));
// 校验插件元信息
if (!plugin.name || !plugin.init) {
console.warn('插件格式非法:' + file);
continue;
}
this.plugins[plugin.name] = {
meta: { name: plugin.name, version: plugin.version, author: plugin.author },
enabled: plugin.autoEnable !== false // 默认启用
};
// 启用的插件执行初始化(注册钩子)
if (this.plugins[plugin.name].enabled) {
plugin.init(this.hooks);
console.log('插件已加载:' + plugin.name + ' v' + plugin.version);
}
}
}
// 动态启用/禁用
toggle(name, enabled) {
if (!this.plugins[name]) return false;
this.plugins[name].enabled = enabled;
// 注意:禁用已加载的钩子需额外维护回调引用,此处从略
return true;
}
}
// 单个插件文件示例:email-notify.plugin.js
module.exports = {
name: 'email-notify',
version: '1.0.0',
author: '尧图建站',
autoEnable: true,
init(hooks) {
// 在初始化里注册钩子
hooks.addHook('after_publish', (postId, post) => {
mailer.send({ to: 'editor@ldpk.cn', subject: post.title });
});
}
};
插件加载顺序是个容易被忽视的坑——如果两个插件都过滤同一内容,执行顺序不同结果可能不同。尧图的实践是:在插件元信息里声明依赖(depends: ['sensitive-filter']),加载器先做拓扑排序再依次加载。另外插件不要直接操作数据库表结构,应该通过核心提供的 API,否则核心升级改了表结构,插件就崩了。理解钩子与插件原理,你就能在不碰核心代码的前提下,给任何建站系统"加料",这是从"用框架"到"玩转框架"的关键一步。