Перевод Laravel на Vue.js

Создаем API для работы с JavaScript фреймворком и переносим генерацию HTML из файла Blade

В этом туториале мы реализуем вывод товаров абстрактного интернет-магазина с помощью фреймворка Vue.js, имея при этом готовый вариант для вывода товаров с помощью обычных Blade шаблонов.

Требования

  1. Laravel 6.*, 7.*
  2. Node.js (для npm)

Дано

Допустим, у нас есть такой простенький (очень простенький) интернет магазин где есть две сущности - товары и категории.

Для хранения этих данных у нас есть таблицы products (id, name price, category_id) и categories (id, name).

Их модели выглядят следующим образом:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
<?php   namespace App;   use Illuminate\Database\Eloquent\Model;   class Product extends Model { // У меня тип этого поля decimal // По-этому я перевожу его в int protected $casts = [ 'price' => 'int', ];   public function category() { return $this->belongsTo(Category::class); } }
1 2 3 4 5 6 7 8 9 10 11 12 13
<?php   namespace App;   use Illuminate\Database\Eloquent\Model;   class Category extends Model { public function posts() { return $this->hasMany(Product::class); } }

Также есть маршрут:

Route::get('/', 'HomeController@index');

И контроллер HomeController, который просто отдает все записи моделей Product и Categories в отображение - файл Blade:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
<?php   namespace App\Http\Controllers;   use App\Category; use App\Product;   class HomeController extends Controller { public function index() { return view('pages.products.index', [ 'products' => Product::all(), 'categories' => Category::all(), ]); } }

Отображение у нас тоже крайне простое - банальный вывод всех товаров и категорий с помощью цикла foreach():

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
{{-- Наследуемся от шаблона base (подключаем все стили + header + footer --}}   @extends('layout.base')   @section('content') <div class="py-5 bg-light"> <div class="container"> <div class="row"> <div class="col-8"> @if($products->isNotEmpty()) <div class="row"> @foreach($products as $product) <div class="col-md-4"> <div class="card mb-4 shadow-sm"> <div class="card-body"> <h2 class="card-title">{{ $product->name }}</h2> <p class="card-text">{{ $product->price }} руб.</p> </div> </div> </div> @endforeach </div> @else <p>Товаров не найдено.</p> @endif </div>   <aside class="col-4"> <h4>Категории</h4> @if($categories) @foreach($categories as $category) <a href="" class="d-block">{{ $category->name }}</a> @endforeach @else <p>Нет категорий.</p> @endif </aside> </div> </div> </div> @endsection

Как вы уже поняли, в своем примере я использую Bootstrap и в результате получаю такую симпатичную главную страничку:

Давайте реализуем тоже самое средствами Vue.js 😊

Шаг 1. Настройка получения данных по API

В текущем варианте контроллер возвращает категории и товары в рамках единого запроса. Vue.js же будет обращаться к нашему API и запрашивать эти данные по отдельности, по-этому сделаем новые маршруты в файле /routes/api.php:

Route::get('products', 'Api\ProductController@index');
Route::get('categories', 'Api\CategoryController@index');

Теперь создадим эти контроллеры с помощью artisan:

php artisan make:controller Api/ProductController
php artisan make:controller Api/CategoryController

В каждом из этих контроллеров нужно реализовать публичный метод index, который будет отдавать нужные записи из таблицы (в нашем примере будем отдавать все записи):

1 2 3 4 5 6 7 8 9 10 11 12 13 14
<?php   namespace App\Http\Controllers\Api;   use App\Http\Controllers\Controller; use App\Product;   class ProductController extends Controller { public function index() { return Product::all(); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14
<?php   namespace App\Http\Controllers\Api;   use App\Category; use App\Http\Controllers\Controller;   class CategoryController extends Controller { public function index() { return Category::all(); } }

Теперь, если перейти по адресу site.loc/api/products и site.loc/api/categories - то там будут необходимые данные в формате JSON.

Как видите, тут присутствует лишняя информация в виде двух полей - created_at и updated_at (их мы на странице не выводим). Как от них избавиться?

Можно отфильтровать с помощью методов коллекции, но мы поступим красивее и создадим новый класс - API ресурс (они будут созданы в директории /app/Http/Resources):

php artisan make:resource ProductResource
php artisan make:resource CategoryResource

В этих классах, в методе toArray(), мы задаем необходимые поля для вывода:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
<?php   namespace App\Http\Resources;   use Illuminate\Http\Resources\Json\JsonResource;   class ProductResource extends JsonResource { /** * Transform the resource into an array. * * @param \Illuminate\Http\Request $request * @return array */ public function toArray($request) { return [ 'id' => $this->id, 'name' => $this->name, 'price' => $this->price, 'category_id' => $this->category_id, ]; } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
<?php   namespace App\Http\Resources;   use Illuminate\Http\Resources\Json\JsonResource;   class CategoryResource extends JsonResource { /** * Transform the resource into an array. * * @param \Illuminate\Http\Request $request * @return array */ public function toArray($request) { return [ 'id' => $this->id, 'name' => $this->name, ]; } }

Теперь пропускаем возвращаемые значения в наших контроллерах (в методе index()) через вновь созданные ресурсы:

/* ProductController */
 
// Было:
return Product::all();
 
// Стало:
return ProductResource::collection(Product::all());
/* CategoryController */
 
// Было:
return Category::all();
 
// Стало:
return CategoryResource::collection(Category::all());

Проверяем результат.

Что мы получили в итоге?

Во первых, как и ожидалось, пропали ненужные данные о создании/обновлении каждого элемента.

Во вторых, весь массив данных обернулся под ключ data. Это хорошая практика, так как в будущем, помимо элементов, мы можем возвращать другую информацию (номер текущей страницы, суммарное количество страниц, текст ошибок и т.д.).

Шаг 2. Подключение Vue.js

Первым делом, необходимо установить пакет laravel/ui с помощью composer:

composer require laravel/ui

Затем, нужно дать Laravel знать, что мы будем работать с Vue.js. Для этого необходимо выполнить:

php artisan ui vue
 
# Можно добавить приставку --auth
# ... чтобы заодно сгенерировать страницы входа и регистрации

Эта команда автоматически проинициализирует Vue.js в файле /resources/app.js. Откройте его.

Теперь, этот файл выглядит так (я убрал комментарии):

1 2 3 4 5 6 7 8 9
require('./bootstrap');   window.Vue = require('vue');   Vue.component('example-component', require('./components/ExampleComponent.vue').default);   const app = new Vue({ el: '#app', // app - идентификатор (атрибут id) ключевого контейнера в файле Blade });

Как видите, в этом файле происходит инициализация компонентов Vue.js в контейнер с id #app.

Создание компонента

Давайте поменяем компонент по умолчанию (example-component) на какой-нибудь другой. Назовем его, к примеру, front-component.

Таким образом, вместо строки с example-component мы должны вставить:

Vue.component('front-component', require('./components/FrontComponent.vue').default);

Теперь нужно создать этот компонент. Для этого создадим новую папку /resources/js/components и в ней создадим новый файл под названием FrontComponent.vue.

Каждый компонент Vue.js содержит две части в виде двух тегов:

  1. <template>...</template> - содержит весь HTML, которое нужно выводить.
  2. <script>...</script> - содержит весь JavaScript.

Давайте пока вставим что-нибудь между тегом <template>, чтобы проверить работу Vue.js.

1 2 3 4 5
<template> <div class="container"> <h2>Test</h2> </div> </template>

Вернемся в наш файл Blade и удалим необходимый контейнер с динамическим содержимым (в нашем случае это весь блок <div class="container>). Пока можете его куда-нибудь скопировать, т.к. в будущем он нам понадобится.

Вместо удаленного блока, создайте ключевой контейнер для Vue.js (со значением app в атрибуте id) и вставьте внутрь него открывающий и закрывающий HTML тег с названием нашего компонента (front-component).

В результате, наш Blade файл будет таким:

1 2 3 4 5 6 7 8 9
@extends('layout.base')   @section('content') <div class="py-5 bg-light"> <div id="app"> <front-page></front-page> </div> </div> @endsection

Теперь, основное содержимое нашей страницы будет выводиться с помощью Vue.js.

Чтобы проверить это, первым делом вы должны знать, что файл /resources/js/app.js, который мы редактировали ранее, недоступен в публичной части вашего сайта. Для этого, сначала необходимо его скомпилировать с помощью WebPack.

Сборка статичных файлов с помощью WebPack

Перед тем как это сделать, нужно установить npm:

npm install && npm run dev

После этого, создастся/обновится файл /public/app.js и мы сможем открыть на нашем сайте скомпилированный файл по адресу site.loc/js/app.js.

Изменение маршрута

Как вы помните, сейчас у нас есть HomeController, который возвращает все записи моделей Product и Category:

Но нам это уже ненужно, так как мы уже настроили получение данных по API. Таким образом мы можем вообще удалить файл HomeController.php и в файле маршрутов для веба (/routes/web.php) выполнить замену:

// с
Route::get('/', 'HomeController@index');
 
// на
Route::view('/', 'pages.products.index');
 
/*
 * Таким образом, при вызове этого маршрута
 * мы сразу отдаем Blade файл, минуя контроллер.
*/

Проверка работы Vue.js

Перейдя на главную страницу нашего сайта, мы увидим, что Vue.js ничего не выводит:

Это происходит, потому что мы забыли подключить наш файл /public/js/app.js и браузер посетителей попросту его не загружает.

Чтобы это исправить, укажите его путь в файле Blade вашего футера с помощью хелпера mix() (где-нибудь поближе к </body>):

<script src="{{ mix('js/app.js') }}"></script>

Проверим ещё раз:

Всё работает 😎 Осталось повторить вывод товаров и категорий средствами Vue.

Настройка вывода данных

Как я уже писал, файл компонента содержит две части - <template> и <script>. Для создания вывода данных с помощью Vue.js нам нужно:

  1. Определить свойства компонента
  2. Создать методы, которые будут обращаться к API и класть полученный результат в свойства компонента
  3. Настроить вызов этих методов при загрузке компонента
  4. Изменить синтаксис для работы с Vue.js

Определение свойств компонента

Начнем с определения свойств компонента. Вообще, свойства компоненты сильно напоминают свойства классов в PHP. В них мы будем складывать полученные с API товары и категории.

Для этого, внутрь тега <script> вставим новый параметр data:

1 2 3 4 5 6 7 8 9
export default { data() { return { /* Товары и категории по умолчанию - пустой массив */ products: [], categories: [] } } }

Определение методов компонента

От данного компонента нам необходимо:

  1. Получать и выводить товары
  2. Получать и выводить категории

По-этому, реализуем отдельный метод для каждого действия.

Для этого, также внутри тега <script>, после ключа data, вставим ещё один параметр - methods, который в себе содержит необходимые нам методы:

methods: {
    loadProducts() {
 
    },
    loadCategories() {
 
    }
}

Теперь нам нужно определить, что мы хотим от этих методов:

  1. Загружать данных с API
  2. Полученные данные загружать в соответствующие свойства компонента
  3. Обрабатывать ошибки, если таковые имеются

Для выполнения этих задач, можно воспользоваться пакетом axios, который Laravel содержит изначально.

Этот пакет использует следующий синтаксис:

axios
    .get('') // URL для запроса
    .then(function (response) {}) // Событие после загрузки данных
    .catch(function (error) {}); // Событие, если загрузить не получилось

Реализуем процесс получения данных с API в наших методах:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
methods: { loadProducts() { axios .get('/api/products') .then((response) => { this.products = response.data.data; }) .catch((error) => { console.log(error); }); }, loadCategories() { axios .get('/api/categories') .then((response) => { this.categories = response.data.data; }) .catch((error) => { console.log(error); }); } },

Выполнение методов после загрузки компонента

Сами по себе эти методы не выполнятся - нам нужно дать Vue.js понять, что нужно делать после загрузки компонента (по аналогии с $(document).ready() в jQuery и public function __construct() в PHP).

Для этого определим ещё один ключ mounted(), в котором укажем методы, которые нужно выполнять.

mounted() {
    this.loadProducts();
    this.loadCategories()
}

Таким образом, код компонента front-component будет иметь следующий вид:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
<template> <div class="container"> <h2>Test</h2> </div> </template>   <script> export default { data() { return { /* Товары и категории по умолчанию - пустой массив */ products: [], categories: [] } }, methods: { loadProducts() { axios .get('/api/products') .then((response) => { this.products = response.data.data; }) .catch((error) => { console.log(error); }); }, loadCategories() { axios .get('/api/categories') .then((response) => { this.categories = response.data.data; }) .catch((error) => { console.log(error); }); } }, mounted() { this.loadProducts(); this.loadCategories() } } </script>

Осталось лишь дать Vue.js понять, какие данные и в каком виде показывать пользователю 🤗

Перевод синтаксиса Blade в синтаксис Vue.js

Помните код из файла Blade, который я ещё просил вас куда-нибудь сохранить? Вставьте его в тело тега <template> нашего компонента:

Как вы уже поняли, Vue.js не понимает язык файлов Blade. Адаптируем этот код под Vue.js:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
<template> <div class="container"> <div class="row"> <div class="col-8"> <template v-if="products.length"> <div class="row"> <div class="col-md-4" v-for="product in products"> <div class="card mb-4 shadow-sm"> <div class="card-body"> <h2 class="card-title">{{ product.name }}</h2> <p class="card-text">{{ product.price }} руб.</p> </div> </div> </div> </div> </template> <template v-else> <p>Товаров не найдено.</p> </template> </div>   <aside class="col-4"> <h4>Категории</h4> <template v-if="categories.length"> <a href="#" class="d-block" v-for="category in categories">{{ category.name }}</a> </template> <template v-else> <p>Нет категорий.</p> </template> </aside> </div> </div> </template>

Откроем нашу страницу и проверим работает ли всё как надо.

  1. При открытии страницы идёт загрузка:
  1. Через мгновение мы видим нашу страницу, которую мы имели изначально:

В принципе, всё уже неплохо работает. Но мне кажется, нужно добавить какой-то знак пользователю, что ещё идёт загрузка элементов.

Создание эффекта загрузки страницы

Решить эту задачу можно несколькими способами. Для этого примера я воспользуюсь самым простым из них - созданием класса loading в файлах стилей и добавлением этого класса к ключевому контейнеру.

Класс loading будет просто уменьшать прозрачность страницы и делает невозможным клик по её элементам, но вы можете найти варианты с анимацией и так далее.

Давайте создадим новый файл /resources/sass/_loading.scss и вставим туда следующий код:

1 2 3 4
.loading { pointer-events: none; opacity: .3; }

В файле /resources/sass/app.scss подключим вновь созданный файл со стилями для страницы загрузки. Для этого добавьте в конец этого файла (не забудьте потом скомпилировать css файл, выполнив npm run dev):

@import 'loading';

После этого, добавим нашему компоненту (в файле FrontComponent.vue) свойство loaded, которое по умолчанию ложное:

// ...
data() {
    return {
        products: [],
        categories: [],
        loaded: false
    }
},
// ...

Теперь необходимо реализовать изменение этого свойство на true при загрузке страницы. При вызове компонента, последним вызывается метод loadCategories(), по-этому добавим вконец этого метода:

this.loaded = true;

Также, в нашем компоненте, внутри тега <template> найдем наш ключевой контейнер (с классом container), и добавим ему специальный Vue аттрибут :class, который означает, что пока идёт загрузка добавить элементу класс loading:

:class="{'loading': !loaded}"

Финальный код нашего компонента такой:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72
<template> <div class="container" :class="{'loading': !loaded}"> <div class="row"> <div class="col-8"> <template v-if="products.length"> <div class="row"> <div class="col-md-4" v-for="product in products"> <div class="card mb-4 shadow-sm"> <div class="card-body"> <h2 class="card-title">{{ product.name }}</h2> <p class="card-text">{{ product.price }} руб.</p> </div> </div> </div> </div> </template> <template v-else> <p>Товаров не найдено.</p> </template> </div>   <aside class="col-4"> <h4>Категории</h4> <template v-if="categories.length"> <a href="#" class="d-block" v-for="category in categories">{{ category.name }}</a> </template> <template v-else> <p>Нет категорий.</p> </template> </aside> </div> </div> </template>   <script> export default { data() { return { products: [], categories: [], loaded: false } }, methods: { loadProducts() { axios .get('/api/products') .then((response) => { this.products = response.data.data; }) .catch((error) => { console.log(error); }); }, loadCategories() { axios .get('/api/categories') .then((response) => { this.categories = response.data.data; this.loaded = true; }) .catch((error) => { console.log(error); }); } }, mounted() { this.loadProducts(); this.loadCategories() } } </script>

Заключение

Как видите, перестроить своё приложение на одностраничное (SPA) не так то и сложно.

Если есть какие-то вопросы или мысли по этому поводу - пишите в комментарии.

На этом всё ☺

Источник: Laravel Business

Комментарии

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: