Блог на Laravel 7, часть 5. Категории блога и популярные теги — меню в левой колонке
10.12.2020
Теги: Composer • Laravel • MySQL • PHP • Web-разработка • Блог • Навигация • Практика • Фреймворк • ШаблонСайта
Меню в левой колонке
На всех страницах сайта в левой колонке показывается меню категорий блога и список популярных тегов. Это значит, что эти данные мы должны получать всегда, и отправлять их в layout-шаблон. Именно для таких случаев в Laravel предусмотрено готовое решение — View Composers.
Еще лучше, если мы создадим два маленьких шаблона в директории views/layout/part
— categories.blade.php
и popular-tags.blade.php
. Первый будет отвечать за показ категорий блога, а второй — за показ популярных тегов. И с помощью композера будем передавать в них данные.
@if ($items->count()) <ul> @foreach($items as $item) <li> <a href="{{ route('blog.category', ['category' => $item->slug]) }}">{{ $item->name }}</a> </li> @endforeach </ul> @endif
@if ($items->count()) <ul> @foreach($items as $item) <li> <a href="{{ route('blog.tag', ['tag' => $item->slug]) }}">{{ $item->name }}</a> <span class="badge badge-dark float-right">{{ $item->posts_count }}</span> </li> @endforeach </ul> @endif
В layout-шаблоне подключаем эти два маленьких шаблона:
<div class="col-md-3"> <h4>Категории блога</h4> @include('layout.part.categories') <h4>Популярные теги</h4> @include('layout.part.popular-tags') <!-- <h4>Категории блога</h4> <p>Здесь будут категории блога</p> <h4>Популярные теги</h4> <p>Здесь будут популярные теги</p> --> </div>
Теперь создадим поставщика услуг ComposerServiceProvider
:
> php artisan make:provider ComposerServiceProvider
И сразу добавим его в массив providers
файла конфигурации config/app.php
:
return [ /* ... */ 'providers' => [ /* ... */ App\Providers\ComposerServiceProvider::class, /* ... */ ], /* ... */ ];
Далее редактируем созданный app/Providers/ComposerServiceProvider.php
:
namespace App\Providers; use App\Tag; use App\Category; use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; class ComposerServiceProvider extends ServiceProvider { /** * Register services. * * @return void */ public function register() { // ..... } /** * Bootstrap services. * * @return void */ public function boot() { View::composer('layout.part.categories', function($view) { $view->with(['items' => Category::roots()]); }); View::composer('layout.part.popular-tags', function($view) { $view->with(['items' => Tag::popular()]); }); } }
В модель Category
добавляем метод roots()
, а в модель Tag
— метод popular()
:
namespace App; use Illuminate\Database\Eloquent\Model; class Category extends Model { /** * Связь модели Category с моделью Post, позволяет получить * все посты, размещенные в текущей категории */ public function posts() { return $this->hasMany(Post::class); } /** * Возвращает список корневых категорий блога */ public static function roots() { return self::where('parent_id', 0)->get(); } }
namespace App; use Illuminate\Database\Eloquent\Model; class Tag extends Model { /** * Связь модели Tag с моделью Post, позволяет получить посты, * связанные с тегом через сводную таблицу post_tag */ public function posts() { return $this->belongsToMany(Post::class)->withTimestamps(); } /** * Возвращает 10 самых популярных тегов, то есть тегов, которые * связаны с наибольшим количеством постов */ public static function popular() { return self::withCount('posts')->orderByDesc('posts_count')->limit(10)->get(); } }
Чтобы Laravel не ругался на маршруты, сразу их добавим в routes/web.php
:
/* * Блог: все посты, посты категории, посты тега, страница поста */ Route::group([ 'as' => 'blog.', // имя маршрута, например blog.index 'prefix' => 'blog', // префикс маршрута, например blog/index ], function () { // главная страница (все посты) Route::get('index', 'BlogController@index') ->name('index'); // категория блога (посты категории) Route::get('category/{category:slug}', 'BlogController@category') ->name('category'); // тег блога (посты с этим тегом) Route::get('tag/{tag:slug}', 'BlogController@tag') ->name('tag'); // страница поста блога Route::get('post/{post:slug}', 'BlogController@post') ->name('post'); });
Второй уровень категорий
При желании, мы можем показывать в меню категорий блога не только корневые категории, но и дочерние. Для этого добавим в модель Category
новую связь таблицы categories
с таблицей categories
.
namespace App; use Illuminate\Database\Eloquent\Model; class Category extends Model { /** * Связь модели Category с моделью Post, позволяет получить все * посты, размещенные в текущей категори */ public function posts() { return $this->hasMany(Post::class); } /** * Связь модели Category с моделью Category, позволяет получить все * дочерние категории текущей категории */ public function children() { return $this->hasMany(Category::class, 'parent_id'); } /** * Связь модели Category с моделью Category, позволяет получить * родителя текущей категории */ public function parent() { return $this->belongsTo(Category::class, 'parent_id'); } /** * Возвращает список корневых категорий каталога товаров */ public static function roots() { return self::where('parent_id', 0)->get(); } }
Теперь в шаблоне categories.blade.php
мы можем обратиться к виртуальному свойству children
, чтобы получить список дочерних категорий:
@if ($items->count()) <ul> @foreach($items as $item) <li> <a href="{{ route('blog.category', ['category' => $item->slug]) }}">{{ $item->name }}</a> @if ($item->children->count()) <ul> @foreach($item->children as $child) <li> <a href="{{ route('blog.category', ['category' => $child->slug]) }}"> {{ $child->name }} </a> </li> @endforeach </ul> @endif </li> @endforeach </ul> @endif
Оптимизация запросов к БД
Сейчас для построения меню у нас выполняется пять запросов к базе данных, которые получают корневые категории + дочерние категории для каждой корневой.
SELECT * FROM `categories` WHERE `parent_id` = 0
SELECT * FROM `categories` WHERE `categories`.`parent_id` = 1 AND `categories`.`parent_id` IS NOT NULL SELECT * FROM `categories` WHERE `categories`.`parent_id` = 2 AND `categories`.`parent_id` IS NOT NULL SELECT * FROM `categories` WHERE `categories`.`parent_id` = 3 AND `categories`.`parent_id` IS NOT NULL SELECT * FROM `categories` WHERE `categories`.`parent_id` = 4 AND `categories`.`parent_id` IS NOT NULL
Происходит это из-за так называемой ленивой загрузки данных (отложенная загрузка). Именно она используется при обращении к виртуальному свойству children
, доступному после реализации связи таблицы categories
с таблицей categories
. Использовать ленивую загрузку очень удобно, поскольку она работает только тогда, когда мы обращаемся к виртуальному свойству.
Но в данном случае вместо отложенной загрузки нужно использовать другой вариант загрузки связанных данных — жадную загрузку. Этот вариант выбирает данные всегда, вне зависимости от того, потребуются ли они в дальнейшем. Но мы точно знаем, что дочерние категории нам потребуются, так что будем их выбирать из базы данных сразу.
class Category extends Model { /** * Возвращает список корневых категорий каталога товаров */ public static function roots() { return self::where('parent_id', 0)->with('children')->get(); } }
Теперь вместо пяти запросов к базе данных будет выполнено только два:
SELECT * FROM `categories` WHERE `parent_id` = 0 SELECT * FROM `categories` WHERE `categories`.`parent_id` IN (1, 2, 3, 4)
Многоуровневое меню категорий
Хорошо, с этим разобрались, двигаемся дальше. Что делать, если уровень вложенности категорий больше двух? В этом случае наш шаблон categories.blade.php
должен подключать себя рекурсивно, чтобы на каждом уровне иерархии показывать категории текущего уровня.
@if ($items->count()) <ul> @foreach($items as $item) <li> <a href="{{ route('blog.category', ['category' => $item->slug]) }}">{{ $item->name }}</a> @include('layout.part.categories', ['items' => $item->children]) </li> @endforeach </ul> @endif
И надо разобраться с передачей данных в шаблон categories.blade.php
. Раньше шаблон подключался один раз и мы один раз передавали в него переменную $items
. Но теперь шаблон будет подключаться много раз — и мы каждый раз должны передавать разное значение переменной $items
. При первом подключении шаблона она равна значению, которое мы передадим в шаблон через View Composer. А при всех следующих подключених шаблона — тому значению, которое мы передадим вторым аргументом @include()
.
Поскольку имя переменной при передаче через композер и через @include()
одинаковое, то произойдет затирание это переменной при втором, третьем, четвертом подключении. Поэтому вносим изменения в класс ComposerServiceProvider
— значение переменной $items
будем передавать в шаблон через композер только при первом подключении. А при всех следующих подключениях — будем передавать через второй параметр @inclide()
.
class ComposerServiceProvider extends ServiceProvider { /* ... */ public function register() { View::composer('layout.part.categories', function($view) { static $first = true; if ($first) { $view->with(['items' => Category::roots()]); } $first = false; }); View::composer('layout.part.popular-tags', function($view) { $view->with(['items' => Tag::popular()]); }); } }
Теперь смотрим, что там у нас с количеством запросов к базе данных:
SELECT * FROM `categories` WHERE `parent_id` = 0 SELECT * FROM `categories` WHERE `categories`.`parent_id` IN (1, 2, 3, 4) SELECT * FROM `categories` WHERE `categories`.`parent_id` = 5 AND `categories`.`parent_id` IS NOT NULL SELECT * FROM `categories` WHERE `categories`.`parent_id` = 6 AND `categories`.`parent_id` IS NOT NULL SELECT * FROM `categories` WHERE `categories`.`parent_id` = 7 AND `categories`.`parent_id` IS NOT NULL SELECT * FROM `categories` WHERE `categories`.`parent_id` = 8 AND `categories`.`parent_id` IS NOT NULL SELECT * FROM `categories` WHERE `categories`.`parent_id` = 9 AND `categories`.`parent_id` IS NOT NULL SELECT * FROM `categories` WHERE `categories`.`parent_id` = 10 AND `categories`.`parent_id` IS NOT NULL SELECT * FROM `categories` WHERE `categories`.`parent_id` = 11 AND `categories`.`parent_id` IS NOT NULL SELECT * FROM `categories` WHERE `categories`.`parent_id` = 12 AND `categories`.`parent_id` IS NOT NULL
Нам опять нужна жадная загрузка, так что добавляем два новых метода в модель Category
:
class Category extends Model { /** * Связь модели Category с моделью Category, позволяет получить всех * потомков текущей категории */ public function descendants() { return $this->hasMany(Category::class, 'parent_id')->with('descendants'); } /** * Возвращает список всех категорий блога в виде дерева */ public static function hierarchy() { return self::where('parent_id', 0)->with('descendants')->get(); } }
В классе ComposerServiceProvider
будем обращаться к методу hierarchy()
вместо метода roots()
.
class ComposerServiceProvider extends ServiceProvider { /* ... */ public function boot() { View::composer('layout.part.categories', function($view) { static $first = true; if ($first) { $view->with(['items' => Category::hierarchy()]); } $first = false; }); View::composer('layout.part.popular-tags', function($view) { $view->with(['items' => Tag::popular()]); }); } }
В шаблоне categories.blade.php
будем обращаться к виртуальному свойству descendants
всесто свойства children
.
@if ($items->count()) <ul> @foreach($items as $item) <li> <a href="{{ route('blog.category', ['category' => $item->slug]) }}">{{ $item->name }}</a> @include('layout.part.categories', ['items' => $item->descendants]) </li> @endforeach </ul> @endif
children()
и roots()
, вместо того, чтобы создавать два новых метода descendants()
и hierarchy()
.
Теперь у нас только три запроса к базе данных вместо десяти:
SELECT * FROM `categories` WHERE `parent_id` = 0 SELECT * FROM `categories` WHERE `categories`.`parent_id` IN (1, 2, 3, 4) SELECT * FROM `categories` WHERE `categories`.`parent_id` IN (5, 6, 7, 8, 9, 10, 11, 12)
Это справедливо, когда у нас только два уровня — корневые категории + прямые потомки корневых. Как только мы добавим категории третьего уровня — добавится еще один запрос. Другими словами, количество запросов будет равно количеству уровней плюс один.
SELECT * FROM `categories` WHERE `parent_id` = 0 SELECT * FROM `categories` WHERE `categories`.`parent_id` IN (1, 2, 3, 4) SELECT * FROM `categories` WHERE `categories`.`parent_id` IN (5, 7, 9, 11) SELECT * FROM `categories` WHERE `categories`.`parent_id` IN (6, 8, 10, 12)
Один запрос к БД для меню
Но и это не предел — можно вообще обойтись одним запросом. Получить все категории, а потом в шаблоне на каждом уровне отбирать только нужные. Это можно сделать с помощью метода where()
коллекции, которую нам вернет метод Category::all()
.
class ComposerServiceProvider extends ServiceProvider { /* ... */ public function register() { View::composer('layout.part.categories', function($view) { $view->with(['items' => Category::all()]); }); View::composer('layout.part.popular-tags', function($view) { $view->with(['items' => Tag::popular()]); }); } }
@if ($items->where('parent_id', $parent)->count()) <ul> @foreach ($items->where('parent_id', $parent) as $item) <li> <a href="{{ route('blog.category', ['category' => $item->slug]) }}">{{ $item->name }}</a> @include('layout.part.categories', ['parent' => $item->id]) </li> @endforeach </ul> @endif
<div class="col-md-3"> <h4>Категории блога</h4> @include('layout.part.categories', ['parent' => 0]) <h4>Популярные теги</h4> @include('layout.part.popular-tags') </div>
Мне казалось, что теперь получилось хорошо. Но когда посмотрел DebugBar — увидел дюжину абсолютно одинаковых запросов.
Это потому, что View Composer при каждом подключении шаблона выполняет Category::all()
. Давайте это исправим.
class ComposerServiceProvider extends ServiceProvider { /* ... */ public function register() { View::composer('layout.part.categories', function($view) { static $items = null; if (is_null($items)) { $items = Category::all(); } $view->with(['items' => $items]); }); View::composer('layout.part.popular-tags', function($view) { $view->with(['items' => Tag::popular()]); }); } }
- Блог на Laravel 7, часть 13. Загрузка и ресайз изображений для категорий и постов блога
- Блог на Laravel 7, часть 12. Доп.страницы сайта в панели управления и в публичной части
- Блог на Laravel 7, часть 11. Панель управления — назначение ролей и прав для пользователей
- Блог на Laravel 7, часть 10. Личный кабинет — CRUD-операции над постами и комментариями
- Блог на Laravel 7, часть 9. Панель управления — создание, публикация, удаление комментариев
- Блог на Laravel 7, часть 8. Панель управления — CRUD для категорий, тегов и пользователей
- Блог на Laravel 7, часть 7. Панель управления — создание, публикация, удаление постов
Поиск: Composer • Laravel • MySQL • PHP • Web-разработка • Блог • Практика • Фреймворк • Шаблон сайта • Навигация • Меню