Блог на Laravel 7, часть 5. Категории блога и популярные теги — меню в левой колонке

10.12.2020

Теги: ComposerLaravelMySQLPHPWeb-разработкаБлогНавигацияПрактикаФреймворкШаблонСайта

Меню в левой колонке

На всех страницах сайта в левой колонке показывается меню категорий блога и список популярных тегов. Это значит, что эти данные мы должны получать всегда, и отправлять их в layout-шаблон. Именно для таких случаев в Laravel предусмотрено готовое решение — View Composers.

Еще лучше, если мы создадим два маленьких шаблона в директории views/layout/partcategories.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()]);
        });
    }
}

Поиск: Composer • Laravel • MySQL • PHP • Web-разработка • Блог • Практика • Фреймворк • Шаблон сайта • Навигация • Меню

Каталог оборудования
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Производители
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Функциональные группы
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.