SPA приложения с помощью Laravel и Vue

Благодаря чистому синтаксису и выразительности, а также многим другим качествам, Laravel является одним из самых популярных PHP-фреймворков, используемых разработчиками. До запуска пользовательского интерфейса Laravel одной из его ключевых функций была поддержка Vue.js по умолчанию от Laravel v5.3 до v6. Vue — это современная интерфейсная среда JavaScript, используемая для создания пользовательских интерфейсов.

В этой статье мы покажем, как создать одностраничное приложение с помощью Laravel и Vue.

Почему Laravel и Vue хороши вместе?

Вот некоторые из ключевых преимуществ использования Laravel и Vue для создания полноценного рабочего процесса для ваших проектов:

  • Исходный код объединен в один проект, а не отдельные проекты для бэкэнда и фронтенда.
  • Установка и настройка просты
  • Одно развертывание может работать с обеими платформами вместе.

Что такое SPA? (одностраничное приложение)

Одностраничное приложение (сокращенно SPA) динамически загружает новые данные с веб-сервера на веб-страницу без необходимости обновления всей страницы.

Примеры популярных веб-сайтов, использующих SPA, включают gmail.com и youtube.com — другими словами, SPA широко распространены. Большинство панелей администратора, с которыми вы можете работать ежедневно, создаются с использованием SPA.

Преимущества SPA:

  • Пользовательский опыт стал более гибким
  • Кэширует данные в браузере
  • Быстрое время загрузки

Недостатки SPA:

  • Может поставить под угрозу SEO (поисковую оптимизацию)
  • Потенциальные проблемы безопасности
  • Потребляет много ресурсов браузера

Настройка проекта

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

В этом уроке используется Laravel 9, который требует PHP 8.1 и Vue 3; нам также необходимо установить PHP и NGINX .

Начнем со следующей команды:

composer create-project --prefer-dist laravel/laravel laravel-vue-demo

Далее мы установим зависимости JavaScript.

npm install

Нам необходимо установить несколько пакетов, прежде чем мы сможем добавить Vue в наш проект.

Кроме того, необходимо установить plugin-vue, поскольку Laravel 9 поставляется с Vite, а не с webpack-mix, который был предыдущим сборщиком Laravel для JavaScript. Давайте сделаем это сейчас:

npm install vue@next vue - loader@next @vitejs / plugin-vue

Откройте файл с именем vite.config.js и добавьте vue() в конфиг:

import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue'

export default defineConfig({
    plugins: [
        vue(),
        laravel([
            'resources/css/app.css',
            'resources/js/app.js',
        ]),
    ],
});

Отредактируйте файл app.js и фрагмент загрузочного файла приложения Vue 3:

require('./bootstrap');

import {createApp} from 'vue';
import App from './App.vue';

createApp(App).mount("#app");

Создайте файл с именем App.vue и добавьте следующее:

<template>
  <h1> Hello, Vuejs with Laravel </h1>
</template>
<script>
export default {
  setup() {

   }
}
</script>

Наконец, откройте файл welcome.blade.php, расположенный в папке resources/views, и добавьте следующее:

<!DOCTYPE html>
<html>
<head>
 ....
        @vite('resources/css/app.css')
</head>
<body>
  <div id="app"></div>
  @vite('resources/js/app.js')
</body>
</html>

Чтобы просмотреть наше приложение, нам нужно запустить наше приложение Vue и сервер Laravel на двух разных терминалах/командных строках:

npm run dev

php artisan serve

Предварительный просмотр приложения на локальном хосте

Чтобы создать наше приложение для дел, нам нужно создать еще несколько файлов. Vue создаст несколько страниц, в основном:

  • Страница входа
  • Страница регистрации
  • Домашняя страница

Для связи с конечными точками Laravel нам нужно установить Axios:

npm install axios

Маршрутизация Vue

Используя пакет vue-router в Vue можно использовать различные стратегии маршрутизации, эти стратегии также известны как history modes .

Когда пользователь запрашивает такие маршруты http://localhost:8000/home, которые возвращают ошибку 404 при обновлении страницы, мы можем положиться на то, что Laravel обнаружит любые резервные маршруты, а затем обслужит файл Blade, содержащий наше приложение.

По этой причине мы будем использовать режим HTML5:

Route::get('/{vue_capture?}', function() {
    return view('welcome');
})->where('vue_capture', '[\/\w\.-]*');
import {createRouter, createWebHistory} from 'vue-router';

const router = createRouter({
    history: createWebHistory(),
    routes: [
        {
            path: '/',
            component: () => import('./pages/Login.vue')
        },
        {
            path: '/register',
            component: () => import('./pages/Register.vue')
        },
        {
            path: '/home',
            component: () => import('./pages/Home.vue')
        }
    ],
})

Из-за простоты проекта мы по сути обрабатываем аутентификацию для страницы входа с помощью Laravel Sanctum , а затем сохраняем наш токен в локальном хранилище.

Чтобы другие запросы были успешными, токен присоединяется к заголовку, что позволяет Laravel идентифицировать пользователя, отправляющего запрос.

Вот как будет выглядеть наша страница входа:

Пример страницы входа

А вот как будет выглядеть наша страница регистрации:

Пример скриншота страницы регистрации

Наконец, вот соответствующие блоки кода для обоих:

Login.vue:

<template>
    <div class="mx-auto w-4/12 mt-10 bg-blue-200 p-4 rounded-lg">
        <div
            class="bg-white shadow-lg rounded-lg px-8 pt-6 pb-8 mb-2 flex flex-col"
        >
            <h1 class="text-gray-600 py-5 font-bold text-3xl"> Login </h1>
            <ul class="list-disc text-red-400" v-for="(value, index) in errors" :key="index" v-if="typeof errors === 'object'">
                <li>{{value[0]}}</li>
            </ul>
            <p class="list-disc text-red-400" v-if="typeof errors === 'string'">{{errors}}</p>
            <form method="post" @submit.prevent="handleLogin">
            <div class="mb-4">
                <label
                    class="block text-grey-darker text-sm font-bold mb-2"
                    for="username"
                >
                    Email Address
                </label>
                <input
                    class="shadow appearance-none border rounded w-full py-2 px-3 text-grey-darker"
                    id="username"
                    type="text"
                    v-model="form.email"
                    required
                />
            </div>
            <div class="mb-4">
                <label
                    class="block text-grey-darker text-sm font-bold mb-2"
                    for="password"
                >
                    Password
                </label>
                <input
                    class="shadow appearance-none border border-red rounded w-full py-2 px-3 text-grey-darker mb-3"
                    id="password"
                    type="password"
                    v-model="form.password"
                    required
                />
            </div>
            <div class="flex items-center justify-between">
                <button
                    class="bg-blue-500 hover:bg-blue-900 text-white font-bold py-2 px-4 rounded"
                    type="submit"
                >
                    Sign In
                </button>
                <router-link
                    class="inline-block align-baseline font-bold text-sm text-blue hover:text-blue-darker"
                    to="register"
                >
                    Sign Up
                </router-link>
            </div>
            </form>
        </div>
    </div>
</template>

export default {
    setup() {
        const errors = ref()
        const router = useRouter();
        const form = reactive({
            email: '',
            password: '',
        })
        const handleLogin = async () => {
            try {
                const result = await axios.post('/api/auth/login', form)
                if (result.status === 200 && result.data && result.data.token) {
                    localStorage.setItem('APP_DEMO_USER_TOKEN', result.data.token)
                    await router.push('home')
                }
            } catch (e) {
                if(e && e.response.data && e.response.data.errors) {
                    errors.value = Object.values(e.response.data.errors)
                } else {
                    errors.value = e.response.data.message || ""
                }
            }
        }

        return {
            form,
            errors,
            handleLogin,
        }
    }
}

Представление/страница Vue Home обрабатывает все действия, такие как создание, удаление, обновление и составление списка задач. Все действия отправляют запросы к конечным точкам с токеном пользователя для авторизации через Axios.

Давайте посмотрим, как они будут выглядеть, а также соответствующие фрагменты:

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

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

Home:

<template>
    <div class="w-6/12 p-10 mx-auto">
        <div class="flex justify-between">
            <h1 class="text-2xl"> Todo </h1>
            <span class="capitalize">Welcome {{ user && user.name }}, <button
                class="text-orange-500 underline hover:no-underline rounded-md"
                @click="handleLogout">Logout</button></span>
        </div>
        <input type="text" class="p-2 w-64 border rounded-md" v-model="todo" placeholder="Enter your todo"/>
        <button class="bg-blue-600 text-white px-5 py-2 rounded-md ml-2 hover:bg-blue-400" @click="addTodo">Add</button>
        <Loader v-if="isLoading"/>
        <ul class="border-t mt-3 cursor-pointer">
            <li :class="`py-3 border-b text-gray-600 ${val.has_completed ? 'line-through' : ''}`"
                v-for="(val, idx) in todos" :key="idx">
                <input type="checkbox" :checked="val.has_completed" @click="checked(idx)"/>
                <span @click="checked(val, idx)" class="pl-3">{{ val.title }} </span>
                <button class="float-right bg-red-400 px-2 text-white font-bold rounded-md hover:bg-red-600"
                        @click="deleteTodo(val, idx)">&times;
                </button>
            </li>
        </ul>
    </div>
</template>

setup() {
    const todo = ref('')
    const todos = ref([])
    const user = ref()
    const isLoading = ref()

    let router = useRouter();
    onMounted(() => {
        authentication()
        handleTodos()
    });

    const authentication = async () => {
        isLoading.value = true
        try {
            const req = await request('get', '/api/user')
            user.value = req.data
        } catch (e) {
            await router.push('/')
        }
    }

    const handleTodos = async () => {
        try {
            const req = await request('get', '/api/todos')
            todos.value = req.data.data
        } catch (e) {
            await router.push('/')
        }
        isLoading.value = false
    }

    const handleNewTodo = async (title) => {
        try {
            const data = {title: title}
            const req = await request('post', '/api/todos', data)
            if (req.data.message) {
                isLoading.value = false
                return alert(req.data.message)
            }
            todos.value.push(req.data.data)
        } catch (e) {
            await router.push('/')
        }
        isLoading.value = false
    }

    const handleLogout = () => {
        localStorage.removeItem('APP_DEMO_USER_TOKEN')
        router.push('/')
    }

    const addTodo = () => {
        if (todo.value === "") {
            return alert("Todo cannot be empty");
        }
        isLoading.value = true
        handleNewTodo(todo.value)
        todo.value = ""
    }

    const checked = async (val, index) => {
        try {
            const data = {has_completed: !val.has_completed}
            const req = await request('put', `/api/todos/${val.id}`, data)
            if (req.data.message) {
                isLoading.value = false
                return alert(req.data.message)
            }
            todos.value[index].has_completed = !val.has_completed
        } catch (e) {
            await router.push('/')
        }
        isLoading.value = false
    }

    const deleteTodo = async (val, index) => {
        if (window.confirm("Are you sure")) {
            try {
                const req = await request('delete', `/api/todos/${val.id}`)
                if (req.data.message) {
                    isLoading.value = false
                    todos.value.splice(index, 1)
                }
            } catch (e) {
                await router.push('/')
            }
            isLoading.value = false
        }
    }

Для Laravel мы создадим следующее:

  • Controllers (AuthController, TodoController)
  • Models (Todo, User)
  • Routes (api)
  • Middleware (auth:sanctum).

Наши маршруты находятся в api.php, который обрабатывает все конечные точки, используемые Vue.

Route::post('/auth/register', [AuthController::class, 'register']);
Route::post('/auth/login', [AuthController::class, 'login']);

Route::apiResource('todos', TodoController::class)->middleware('auth:sanctum');

Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
    return $request->user();
});

Логика регистрации AuthController регистрирует пользователя и создает токен.

public function register(Request $request): \Illuminate\Http\JsonResponse
{
    try {
        //Validated
        $validateUser = Validator::make($request->all(),
        [
            'name' => 'required',
            'email' => 'required|email|unique:users,email',
            'password' => 'required'
        ]);

        if($validateUser->fails()){
            return response()->json([
                'status' => false,
                'message' => 'validation error',
                'errors' => $validateUser->errors()
            ], 401);
        }

        $user = User::create([
            'name' => $request->name,
            'email' => $request->email,
            'password' => Hash::make($request->password)
        ], 201);

        return response()->json([
            'status' => true,
            'message' => 'User Created Successfully',
            'token' => $user->createToken("API_TOKEN")->plainTextToken
        ], 200);

    } catch (\Throwable $e) {
        return response()->json([
            'status' => false,
            'message' => $e->getMessage()
        ], 500);
    }
}

Логин проверяет запрос, проверяет, существует ли пользователь, а затем создает токен:

public function login(Request $request): \Illuminate\Http\JsonResponse
{
    try {
        //Validated
        $validateUser = Validator::make($request->all(),
            [
                'email' => 'required',
                'password' => 'required'
            ]);

        if($validateUser->fails()){
            return response()->json([
                'status' => false,
                'message' => 'validation error',
                'errors' => $validateUser->errors()
            ], 401);
        }

        if(!Auth::attempt($request->only(['email', 'password']))){
            return response()->json([
                'status' => false,
                'message' => 'Email & Password does not exist.',
            ], 401);
        }

        $user = User::where('email', $request->email)->first();

        return response()->json([
            'status' => true,
            'message' => 'Logged In Successfully',
            'token' => $user->createToken("API_TOKEN")->plainTextToken
        ], 200);

    } catch (\Throwable $e) {
        return response()->json([
            'status' => false,
            'message' => $e->getMessage()
        ], 500);
    }
}

Конечные точки POST для добавления новых задач управляются методом store в контроллере задач — ./api/todos

public function store(Request $request): \Illuminate\Http\JsonResponse
{
    $data = Todo::where('user_id', $request->user()->id)->where('title', $request->title);
    if ($data->first()) {
        return response()->json(['status' => false, 'message' => 'Already exist']);
    }
    $req = $request->all();
    $req['user_id'] = $request->user()->id;
    $data = Todo::create($req);
    return response()->json(['status' => true, 'data' => $data], 201);
}

Он управляет Todo конечной точкой обновления, которая вызывается после того, как пользователь завершает задачу и находится по адресу ./api/todos/id

public function update(Request $request, $id): \Illuminate\Http\JsonResponse
{
    $validateUser = Validator::make($request->all(),
        [
            'has_completed' => 'required',
        ]);

    if ($validateUser->fails()) {
        return response()->json([
            'status' => false,
            'message' => 'validation error',
            'errors' => $validateUser->errors()
        ], 401);
    }

    $data = Todo::find($id);
    $data->has_completed = $request->has_completed;
    $data->update();
    return response()->json(['status' => true, 'data' => $data], 202);
}

Когда пользователь удаляет Todo, вызывается конечная точка /api/todos/id, и об этом позаботятся:

public function destroy(int $id): \Illuminate\Http\JsonResponse
{
    throw_if(!$id, 'todo Id is missing');
    Todo::findOrFail($id)->delete();
    return response()->json(['status' => true, 'message' => 'todo deleted']);
}

Отлично, мы создали SPA приложение, используя Laravel и Vue! 🎉

Конечный результат одностраничного приложения Laravel Vue

Заключение

Нам удалось установить, насколько проще создать простое приложение для аутентификации пользователя и списка дел с использованием Vue и Laravel по сравнению с традиционной комбинацией PHP/Vue, которая может потребовать гораздо больше работы по настройке.

Комбинация Vue с Laravel упрощает разработку одностраничных приложений, поскольку нет необходимости беспокоиться о маршрутизации, промежуточном программном обеспечении или обработке CORS. Дайте нам знать о своем опыте разработки SPA в разделе комментариев ниже.