UI 注入存在的原因
增加功能的插件通常需要在宿主 UI 的某个位置呈现该功能。直接的方案都有各自的弊端:fork 宿主的 Blade 布局会迫使插件在每次宿主升级时都维护自己的副本;在安装时打补丁则会让宿主源码与生产中运行的内容失去同步。两者都将插件与宿主锁定在随每次发布而增长的维护负担中。
插件系统通过在宿主主布局中预留具名槽来避免这种取舍。每个槽都是一个 REGISTRY 钩子 — 每个注册的插件贡献一段 HTML 字符串,宿主在渲染时收集它们、过滤掉虚值贡献,并按注册顺序逐段输出。插件永远看不到宿主的 Blade 源码;宿主也永远不知道哪些插件贡献了什么。
三个布局槽
三个 REGISTRY 钩子从主 app 与 admin 布局触发。它们合起来几乎覆盖了插件可能需要的所有 UI 扩展 — 文档头部资源、body 末尾小部件以及管理员侧边栏分组。
| Hook key | 调用点位置 | Args bag | 用途 |
layout.head.assets |
resources/views/refactor/layouts/{app,admin}.blade.php,紧接 @yield('head') 之前 |
[$layout, $context] |
必须在页面专属内容之前加载的 <link> / <style> / <script> 标签 — 聊天框 CSS、sparkle popover 脚本 |
layout.body.before_close |
同样的文件,紧接 </body> 之前 |
[$layout, $context] |
每页挂载一次的浮动小部件 — 聊天气泡、模态浮层、sparkle popover |
admin.sidebar.groups |
resources/views/refactor/components/nav/admin-sidebar.blade.php |
(无参数) |
插件贡献的管理员侧边栏分区 — 每一项渲染为一段 <div class="mc-nav-group">...</div> |
三者在宿主侧通过同一种惯用法收集。resources/views/refactor/layouts/admin.blade.php 中的 Blade 片段是:
@foreach (array_filter(\App\Library\Facades\Hook::collect('layout.head.assets', ['admin'])) as $html)
{!! $html !!}
@endforeach
从这段片段中可以读出三点:collect 接收一个 args bag(这里是 ['admin']);array_filter 丢弃所有 null / false / '' 贡献;存活下来的 HTML 通过 {!! !!} 未转义输出 — 因为它本身就是已渲染的 Blade。
契约 — 返回 HTML 或 null
面向三个布局槽中任意一个的 REGISTRY 回调返回以下两种之一:
- 一个 HTML 字符串 — 通常是
view('myname::partials.foo')->render() 的结果。宿主通过 {!! !!} 原样输出。
null(或任何虚值 — false、''、0)。宿主的 array_filter 会丢弃它。这是按功能标志、插件状态、环境或每次请求上下文来决定贡献与否的惯用方式。
返回 null 优于根本不注册。插件的 boot() 每个进程只运行一次;是否贡献应在每次渲染时判断,而不是在 boot 时。storage/app/plugins/acelle/ai/src/ServiceProvider.php:732 中的 aiPluginAvailable() 检查是规范示例 — 一旦 AI 模块被屏蔽,闭包就短路返回 null,其他每个插件的贡献都不受影响。
返回的 HTML 必须自包含。宿主把片段在调用点直接放入文档,不进行额外转义或包装。片段依赖的任何东西 — CSS、JS、字体文件 — 必须在渲染发生时就已加载完毕。这就是为什么 layout.head.assets 与 layout.body.before_close 并存:head 片段最先加载,body 片段最后挂载,插件可以按需把资源注册拆分到这两个槽中。
Args-bag — $layout 与 $context
layout.head.assets 与 layout.body.before_close 都传入两个位置参数:$layout(一个字符串,标识哪个主布局触发了该槽 — 'app'、'admin' 等)以及 $context(一个可选数组,承载该界面选择暴露的特定属性)。
// Plugin (in ServiceProvider::boot())
Hook::add('layout.head.assets', function ($layout = 'app', array $context = []) {
if (! $this->aiPluginAvailable()) {
return null;
}
return view('ai::partials.head_assets', [
'layout' => $layout,
'context' => $context,
])->render();
});
唯一共享的 hook key 从每一个主布局触发 — app、admin、邮件构建器、表单构建器、自动化编辑器。插件的 partial 内部根据 $layout 进行分发,渲染对应的资源集或聊天框配置。没有单独的 layout.app.head.assets / layout.admin.head.assets 钩子;布局名称只是同一个共享包里的鉴别字段。
向已有布局槽追加更多位置参数会破坏所有按原始入参数量绑定闭包的插件。新上下文应放在 $context 数组中(它可以在不改变闭包签名的情况下扩展),或放在另一个独立的 hook key 之后。宿主自己的 aiHooks 贡献正是这样做 — 构建器与自动化编辑器通过 $context 传递界面属性,插件在需要时读取 $context['kind']、$context['task'] 等。
实操示例 — acelle/ai 聊天气泡
布局注入的规范参考位于 storage/app/plugins/acelle/ai/src/ServiceProvider.php 第 678-728 行。插件向所有三个布局槽贡献内容,每一处都置于同一个 aiPluginAvailable() 门禁之后。完整的注册区块(按上文契约改写):
// In acelle/ai's ServiceProvider::boot()
private function registerLayoutInjections(): void
{
Hook::add('layout.head.assets', function ($layout = 'app', array $context = []) {
if (! $this->aiPluginAvailable()) {
return null;
}
return view('ai::partials.head_assets', [
'layout' => $layout,
'context' => $context,
])->render();
});
Hook::add('layout.body.before_close', function ($layout = 'app', array $context = []) {
if (! $this->aiPluginAvailable()) {
return null;
}
return view('ai::partials.body_assets', [
'layout' => $layout,
'context' => $context,
])->render();
});
}
private function registerAdminSidebarSection(): void
{
Hook::add('admin.sidebar.groups', function () {
if (! $this->aiPluginAvailable()) {
return null;
}
return view('ai::partials.admin_sidebar_group')->render();
});
}
这三段在生产中落地的效果:每一个继承 app 或 admin 布局的页面,都会在 <head> 中获得聊天框 CSS / JS,在 </body> 之前获得聊天气泡 HTML,且(仅在管理员页)在宿主内置分组之后渲染一个「AI」侧边栏分组。要让这一切工作,宿主的 Blade 文件没有被修改过任何一处 — 插件通过共享 hook key 贡献,宿主渲染落到的任何内容。
插件用 aiPluginAvailable() 给贡献加门禁,它会检查 ai_plugin_active() — 这个辅助方法最终归结为 Plugin::getByName('acelle/ai')->isActive()。当管理员从插件页面停用此插件时,每一个回调下次请求都会返回 null,聊天框 + sparkle UI 就此消失 — 无需重载路由、丢弃已注册服务或使任何缓存失效。
admin.sidebar.groups 是三个布局钩子中最简单的:无参数,回调返回一段自包含的 <div class="mc-nav-group">...</div>。宿主在内置分组(Customers、Plans、Settings、…)之后、任何闭合布局之前进行渲染。顺序就是注册顺序,所以需要争夺渲染位置的插件应在依赖项加载之后于 boot() 中较晚注册。
acelle/ai 的侧边栏分组位于插件内 resources/views/partials/admin_sidebar_group.blade.php,渲染一个根据套餐标志包含三或四个子项的 "AI" 分组。这一模式适用于任何需要顶层管理员分区的插件 — 积分插件、支付网关插件、地区发送驱动插件。
页面级槽 — page.{controller}.{action}.{slot}
布局级注入覆盖每一页都应出现的内容;页面级槽覆盖只在特定页面出现的内容。命名约定让绑定显式可见:
page.{controller_slug}.{action}.{slot}
示例:
page.maillist.show.body — 在邮件名单详情页 body 内渲染额外卡片
page.maillist.verification.body — 在验证状态块上方渲染的内容
page.campaign.index.sidebar — 营销活动索引页的侧边栏增项
page.customer.edit.footer — 客户编辑页的页脚增项
宿主的调用点和布局槽完全一样 — collect、array_filter,通过 {!! !!} 输出每个片段:
@foreach (array_filter(\App\Library\Facades\Hook::collect('page.maillist.show.body', [$list])) as $html)
{!! $html !!}
@endforeach
插件的贡献也与布局槽的写法一致,只是会传递槽专属的 args bag:
// Plugin (in ServiceProvider::boot())
Hook::add('page.maillist.show.body', function ($list) {
$points = LoyaltyPoints::getTotalForList($list);
return view('loyalty::list_points_card', [
'list' => $list,
'points' => $points,
])->render();
});
页面级槽的 args bag 通常会携带相关模型 — 邮件名单、营销活动、客户 — 这样插件可以在不发起额外数据库查询的情况下读取它需要的任何内容。遵循这一约定能让 hook 签名在宿主模型演进过程中保持稳定:给 MailList 增加新字段不会破坏任何插件的 hook 签名,因为插件始终接收模型本身。
页面重定向 — FILTER 变体
少数几个页面钩子使用的是 FILTER 模式而非 REGISTRY — 典型场景是「在核心渲染之前由插件决定用户是否应被重定向」。契约:
// Core controller
$redirect = \App\Library\Facades\Hook::filter('page.maillist.show.redirect', null, [$list, $request]);
if ($redirect) {
return $redirect;
}
// continue rendering the page
// Plugin (in ServiceProvider::boot())
Hook::modify('page.maillist.show.redirect', function ($redirect, $list, $request) {
if (MyPlugin::shouldRedirect($list, $request->user())) {
return redirect()->route('my_plugin.custom_page', $list->uid);
}
return $redirect; // null means "do not redirect"
});
之所以采用 FILTER(对值的链式变换)而非 REGISTRY(多个独立贡献),是因为每次请求只能发生一次重定向。从闭包返回未变的输入是惯用的退出方式 — 链中下一个插件看到的是上一个插件决定的内容。第一个非空值胜出,因为控制器在过滤链结束后会检查 if ($redirect)。
这正是 athena/evs 用来把用户路由到自己验证页的模式 — 当邮件验证插件需要接管邮件名单的验证界面时。FILTER 的完整机制见 Hook 系统深度文章;这里的实用要点是:页面级重定向用 FILTER,页面级渲染用 REGISTRY。
资源发布 — 把 CSS / JS / 图片与插件一起打包
布局槽决定插件在哪里渲染。资源发布决定渲染后的 HTML 可以引用什么。带 CSS、JavaScript、字体或图片的插件使用 Laravel 标准的 publishes() API,并使用宿主已知的 'plugin' 标签:
// In ServiceProvider::boot()
$this->publishes([
__DIR__ . '/../resources/assets' => public_path('plugins/acmecorp/loyalty'),
], 'plugin');
每次 Plugin::register() 时,宿主都会运行 artisan vendor:publish --tag=plugin --force,把插件的 resources/assets/ 目录拷贝到 public/plugins/{vendor}/{name}/。插件自身的 partial 通过该路径引用资源:
<link rel="stylesheet" href="{{ asset('plugins/acmecorp/loyalty/styles.css') }}">
来自 routes.php 的图标路由(具名路由 plugin.{vendor}.{name}.icon)是另一种选择 — 不把静态 SVG 发布到 public/,而是由插件暴露一个 HTTP 路由,直接从 storage/app/plugins/{vendor}/{name}/icon.svg 流式输出图标。权衡:发布路径更快(可被 CDN 缓存)但每次安装都需要发布;路由路径自包含但每次请求都会承担一次 Laravel 启动开销。
反模式
1. 返回 Blade View 对象而不是字符串
宿主通过 {!! !!} 输出闭包返回的任何东西。返回 view('foo') 会输出对象的 __toString,多数情况下能用,但会让闭包失去优雅处理渲染错误的机会。修复:始终调用 ->render() 并返回结果字符串,与 acelle/ai 的注册方式完全一致。
2. 忘记把关 null 分支
总是返回 HTML 的 REGISTRY 回调,无论插件激活与否都会持续贡献 — 因为 autoloadWithoutDbQuery() 也会加载未激活插件(参见 插件架构 § 为什么未激活插件依然影响应用)。修复:在每个闭包顶部用 Plugin::enabled('myvendor/myplugin') 或功能标志辅助方法把关,未启用时返回 null。
3. 用宿主没有传递的额外参数调用 collect()
插件不会自己调用 Hook::collect — 这是宿主做的事。如果插件需要根据布局名做自定义决策,应从闭包第一个参数读取。在插件内部尝试 Hook::collect 某个布局槽会让所有其他插件的回调多跑一次。修复:闭包已经收到了它需要的全部参数;永远不要在已注册处理器内部重新调用 collect。
4. 在闭包内做阻塞性工作
闭包每次页面渲染都会执行一次 — 在其中加入 Stripe API 调用或 200ms 的数据库查询,每次请求都要承担这个成本。修复:预计算、缓存或迁移到异步加载器。片段可以返回一个 <div data-async-loader> 占位,由插件的 JS 通过后台 fetch 进行填充。
5. 在 REGISTRY 回调中产生副作用
collect 会按注册顺序调用每一个回调。把写 session、cache、log 当作副作用的回调会使 hook 非确定。两个插件可能争相修改同一个键。修复:保持 add 回调纯粹 — 它们的目的是贡献一个值,而不是去做工作。如果需要副作用,请在另一个 hook 上注册一个 EVENT 监听器。
6. CSS 作用域重叠
两个插件都通过 layout.head.assets 注入 CSS,都定义了一个名为 .mc-popover 的类。插件的加载顺序决定 CSS 的落地顺序,后者覆盖前者。修复:为插件 CSS 类加命名空间(.acmecorp-loyalty-popover),或在包裹元素上用属性选择器限定作用域。宿主不会监管插件 CSS — 那是插件作者的自律。
下一步阅读
UI 注入是新插件作者最常问的界面,但很少是功能的全部。三页文章把同一套工具带得更远: