主题 Boot 与动态数据注入
说明
主题不仅能覆盖模板和样式,还可以通过 setup/boot.php 在应用启动阶段注册运行时逻辑:给视图绑定数据、监听 Hook、注册事件等。本文档介绍主题 Boot 机制、主题自带路由,并以「首页拉取官方市场推荐插件/主题」为实战案例,说明 View composer 与 Hook 的选型。
典型场景:
- 首页展示来自官方市场的推荐插件、最新主题(无需安装任何插件)
- 从外部 API 拉取汇率、汇率播报、行业资讯等动态数据注入主题模板
- 主题需要自己的前台路由(落地页、自定义页面)
Boot 机制原理
themes/{theme}/setup/boot.php 在每次请求时由 FrontServiceProvider::bootTheme() 自动加载:
// innopacks/front/src/FrontServiceProvider.php
protected function bootTheme(): void
{
$currentTheme = system_setting('theme');
if (! $currentTheme) {
return;
}
$bootFile = base_path("themes/{$currentTheme}/setup/boot.php");
if (! is_file($bootFile)) {
return;
}
$boot = require $bootFile;
if (is_callable($boot)) {
$boot();
}
}约定:文件 return 一个闭包,加载后立即执行。文件不存在时静默跳过,不报错。
<?php
// themes/my-theme/setup/boot.php
use Illuminate\Support\Facades\View;
return function () {
// 在这里注册 View composer、Hook 监听、事件监听等
};setup/ 目录加载机制
| 文件 | 触发方式 | 说明 |
|---|---|---|
setup/boot.php | 每次前台请求自动执行 | FrontServiceProvider::bootTheme() require 后调用闭包 |
setup/seeder.php | 后台「主题 Demo 数据一键导入」时执行一次 | 由 Panel 侧 ThemeDemoService(innopacks/panel/src/Services/ThemeDemoService.php)触发,不走 boot |
其他文件(如 helpers.php) | 不会自动加载 | 需要 boot.php 自行 require |
Boot 能力与边界
可以做的(boot 闭包内):
- 注册路由(或使用
routes/目录) View::composer/View::creator绑定视图数据listen_blade_insert/add_filter注册 Hook 与 FilterEvent::listen事件监听、DB 查询、Cache 读写
做不到的:
- Panel 后台注入 —
bootTheme()只存在于FrontServiceProvider,后台请求不执行主题 boot,主题无法往后台界面注入内容 - 中间件注册 — 主题没有 Middleware 机制(插件有),需要改请求流时做成插件
- 生命周期管理 — 无安装/卸载/升级钩子、无后台配置页(见下文「Boot 不等于插件」)
- 异常隔离 — boot 闭包抛出的异常会直接导致前台 500,必须自行 try/catch
执行时机与主题切换:boot 在 service provider boot 阶段执行,每次请求读取 system_setting('theme') 定位当前主题。后台切换主题后下个请求立即生效,旧主题的 boot/composer/hook 不再执行、无需清理。
主题自带路由
主题可携带两类路由文件,由 FrontServiceProvider::loadThemeRoutes() 自动注册:
| 文件 | 前缀 | 说明 |
|---|---|---|
routes/root.php | 无 | 不带 locale 前缀,走 front 中间件 |
routes/front.php | 按需 | 多语言开启时自动挂到 /{locale}/ 下,路由名带 locale 前缀 |
<?php
// themes/my-theme/routes/front.php
use Illuminate\Support\Facades\Route;
Route::get('/campaign', fn () => inno_view('pages.campaign'))->name('campaign');数据注入选型:View composer vs Hook
主题 Boot 里最常见的两种注入方式,解决的是不同的问题:
| View composer | Hook(listen_blade_insert) | |
|---|---|---|
| 本质 | 给视图绑定数据变量 | 往插槽注入渲染好的 HTML 片段 |
| 渲染方 | 主题自己的模板遍历数据 | 回调自己 render 片段 |
| 样式 | 直接使用主题现有设计体系 | 片段需自带样式 |
| 适用 | 区块模板在主题内,只需补数据 | 第三方往别人的模板注入内容 |
选型结论:
- 区块模板本来就在你主题里(如
home/plugins.blade.php)→ 用 View composer 拉数据,模板遍历渲染,路径最短、样式不割裂。 - 想让其他插件能往主题页面注入内容(可插拔生态位)→ 在模板里留
@hookinsert('home.xxx.extra')插槽,由插件实现回调。 - 两者可以共存:模板遍历真实数据,区块尾部留一个 Hook 插槽给生态。
实战:首页拉取市场推荐数据
以 innointl 主题为例,首页 home/plugins.blade.php 与 home/themes.blade.php 展示官方市场推荐的最新插件与主题,不依赖任何插件——数据客户端 MarketplaceService 是核心包 innopacks/plugin 自带的类。
第 1 步:boot.php 注册 composer
<?php
// themes/innointl/setup/boot.php
use Illuminate\Support\Facades\View;
require __DIR__.'/helpers.php';
return function () {
// 视图名以控制器实际返回为准:HomeController 返回 inno_view('home')
// 多个页面需要同一份数据时传数组:View::composer(['home', 'plugins.index'], ...)
View::composer('home', function ($view) {
$view->with('marketPlugins', innointl_market_products('plugins', 6));
$view->with('marketThemes', innointl_market_products('themes', 6));
});
};第 2 步:封装数据获取(含缓存与失败回退)
MarketplaceService::getMarketProductsWithParams() 自带缓存(key 按 query 参数哈希、可配置 TTL 与缓存存储),推荐直接使用;再在外层包一层异常保护,市场站不可用时返回 null,模板回退静态展示。
注意:helpers 里的函数位于全局命名空间,必须加主题前缀并用 function_exists 防重复定义,否则多主题共存或主题切换时会出现 Cannot redeclare 致命错误:
<?php
// themes/innointl/setup/helpers.php(由 boot.php require,系统不会自动加载)
use InnoShop\Plugin\Services\MarketplaceService;
if (! function_exists('innointl_market_products')) {
function innointl_market_products(string $type, int $limit): ?array
{
try {
$result = MarketplaceService::getInstance()
->setPerPage($limit)
->getMarketProductsWithParams(['parent_slug' => $type]);
return $result['data'] ?? null;
} catch (\Throwable $e) {
return null; // 市场站不可用时回退,首页不崩
}
}
}第 3 步:模板遍历 + 回退
{{-- themes/innointl/views/home/plugins.blade.php --}}
@if($marketPlugins)
<div class="marketplace-grid">
@foreach($marketPlugins as $item)
<a href="{{ front_route('plugins.index') }}" class="marketplace-card">
<div class="card-cover">
<img src="{{ $item['image'] }}" alt="{{ $item['name'] }}">
</div>
<div class="card-body">
<div class="card-name">{{ $item['name'] }}</div>
<p class="card-excerpt">{{ $item['summary'] }}</p>
</div>
</a>
@endforeach
</div>
@else
{{-- 市场数据不可用时的静态回退展示 --}}
<div class="marketplace-grid">
{{-- 原静态卡片 ... --}}
</div>
@endif注意事项
- 视图名以控制器实际返回为准。
HomeController返回inno_view('home', $data),所以 composer 绑定'home';绑错视图名时 composer 静默不生效。写之前先确认目标页面的控制器。 - composer 里不要做重活。composer 在每次渲染该视图时执行,网络请求务必走缓存(
MarketplaceService已内置),并设置合理 TTL。 - 异常必须兜底。外部接口超时、宕机时返回
null,模板回退静态内容,不能让首页 500。 - 数据源前置条件。
MarketplaceService请求config('innoshop.api_url') . '/api/marketplace/*'(.env的INNOSHOP_API_URL),该地址须为已部署官方市场(装有 InnoSite / InnoOfficial 插件的站点)。若主题想固定数据源、不跟随站点全局配置,可在 helper 中直接Http::get('https://store.example.com/api/marketplace/...')并自行缓存。 - Boot 不等于插件。
setup/boot.php没有生命周期管理(无安装/卸载/升级钩子)、没有后台配置页。需要配置界面、数据库迁移、计划任务时仍应做成插件。
调试与常见坑
composer 注册了但不生效 九成是视图名不对。用 php artisan tinker 确认控制器返回值:app(InnoShop\Front\Controllers\HomeController::class) 或直接看控制器源码里的 inno_view('xxx')。composer 绑错名字不会报任何错。
改了主题 Blade 文件,页面没变化 Blade 按文件 mtime 判断是否重新编译。用 rsync -a / cp -p 等保留时间戳的方式同步主题文件后,mtime 未变会导致旧编译缓存一直被使用。执行:
php artisan view:clear启用主题后前台直接 500 boot 闭包内的异常没有隔离层,会冒泡成整站前台 500。检查 storage/logs/laravel.log,把不可靠的操作(外部请求、文件读写)包进 try/catch。
helper 函数 Cannot redeclaresetup/ 下的 PHP 文件定义的是全局函数。两个主题都定义 theme_market_products() 时后加载的会致命错误——用主题前缀命名 + function_exists 守卫(见上文示例)。
后台没有主题 boot 效果bootTheme() 仅在 FrontServiceProvider(前台)执行,Panel 后台请求不加载主题 boot。这是设计行为,不是 bug。