文章出处:自如初博客
原文地址:https://www.ziruchu.com/art/455
原文中有少许错误,本人转载过来已做修正。


 Laravel9 已经使用 Vite 前端工具来构建应用,习惯了原有的方式时,再来使用 Vite 新工具,似乎有点不太会用了,一切都好像变了,但一切都好像又没变。一股熟悉的陌生感迎面而来。就以本篇文章作为拥抱新变化的开始吧!

目标:本篇文章将使用 Laravel9 & Inertia Js & Vue3 来构建一个 CURD 简单的 SPA 应用。

第一步:创建项目并配置数据库

1)创建项目:

composer create-project laravel/laravel la9vue3

2)在 .env 中配置数据库:

DB_DATABASE=la9vue3
DB_USERNAME=root
DB_PASSWORD=123456

第二步:安装 breeze & inertia js & vue3

1)安装 laravel/breeze 包:

composer require laravel/breeze --dev

此时,建议添加所有代码到 git 仓库,因为下面的 breeze:install 指令将会产生很多新文件,有了 git 版本控制才能更好了解有哪些文件发生了变化。

2)初始化 breeze:

php artisan breeze:install vue

这个指令自动安装了3个 composer 包 inertiajs/inertia-laravel:^0.6.3laravel/sanctum:^2.8tightenco/ziggy:^1.0,以及向 package.json 中添加了若干个 npm 依赖包,最后执行了指令 npm install && npm run build。它还自动配置了一些路由、添加中间件、生成控制器、vue页面等,具体请用 git 查看变化。

第三步:创建 Post 模型 迁移文件 控制器

1)创建 Post 模型文件,同时生成迁移文件和资源控制器:

php artisan make:model Post -mcr

2)编辑迁移文件(文件名前缀不一样,补全 up 方法即可):

<?php
// database/migrations/2022_08_07_131142_create_posts_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up()
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('title');
            $table->string('slug')->unique();
            $table->text('content');
            $table->timestamps();
        });
    }

    public function down()
    {
        Schema::dropIfExists('posts');
    }
};

然后执行 php artisan migrate 生成数据表。

3)修改 Post 模型文件(添加 $fillable 属性):

<?php
// app/Models/Post.php
namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    use HasFactory;

    protected $fillable = ['title', 'slug', 'content'];
}

4)添加 posts 资源路由(第21行):

<?php
// routes/web.php
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;

Route::get('/', function () {
    return Inertia::render('Welcome', [
        'canLogin' => Route::has('login'),
        'canRegister' => Route::has('register'),
        'laravelVersion' => Application::VERSION,
        'phpVersion' => PHP_VERSION,
    ]);
});

Route::get('/dashboard', function () {
    return Inertia::render('Dashboard');
})->middleware(['auth', 'verified'])->name('dashboard');

// 添加 posts 资源路由
Route::resource('posts', \App\Http\Controllers\PostController::class)->middleware('auth');

require __DIR__.'/auth.php';

5)编写 PostController 控制器:

<?php
// app/Http/Controllers/PostController.php
namespace App\Http\Controllers;

use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;

class PostController extends Controller
{
    public function index()
    {
        $posts = Post::all();
        return Inertia::render('Post/Index', compact('posts'));
    }

    public function create()
    {
        return Inertia::render('Post/Create');
    }

    public function store(Request $request)
    {
        $request->validate([
            'title' => 'required|string|max:255',
            'slug' => 'required|string|max:255',
            'content' => 'required',
        ]);

        Post::create([
            'title' => $request->input('title'),
            'slug' => Str::slug($request->input('slug')),
            'content' => $request->input('content'),
        ]);

        return redirect()->route('posts.index')->with('message', 'Post Created Successfully');
    }

    public function show(Post $post)
    {
        //
    }

    public function edit(Post $post)
    {
        return Inertia::render('Post/Edit', compact('post'));
    }

    public function update(Request $request, Post $post)
    {
        $request->validate([
            'title' => 'required|string|max:255',
            'slug' => 'required|string|max:255',
            'content' => 'required',
        ]);

        $post->title = $request->input('title');
        $post->slug = Str::slug($request->input('slug'));
        $post->content = $request->input('content');
        $post->save();

        return redirect()->route('posts.index')->with('message', 'Post Updated Successfully');
    }

    public function destroy(Post $post)
    {
        $post->delete();
        return redirect()->route('posts.index')->with('message', 'Post Delete Successfully');
    }
}

6)中间件 HandleInertiaRequests 添加 flash message 支持(第46~48行):

<?php
// app/Http/Middleware/HandleInertiaRequests.php
namespace App\Http\Middleware;

use Illuminate\Http\Request;
use Inertia\Middleware;
use Tightenco\Ziggy\Ziggy;

class HandleInertiaRequests extends Middleware
{
    /**
     * The root template that is loaded on the first page visit.
     *
     * @var string
     */
    protected $rootView = 'app';

    /**
     * Determine the current asset version.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return string|null
     */
    public function version(Request $request)
    {
        return parent::version($request);
    }

    /**
     * Define the props that are shared by default.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    public function share(Request $request)
    {
        return array_merge(parent::share($request), [
            'auth' => [
                'user' => $request->user(),
            ],
            'ziggy' => function () use ($request) {
                return array_merge((new Ziggy)->toArray(), [
                    'location' => $request->url(),
                ]);
            },
            'flash' => [
                'message' => session('message')
            ]
        ]);
    }
}

第四步:创建 Vue 视图文件

1)创建 Index.vue:resources/js/Pages/Post/Index.vue

<script setup>
import BreezeAuthenticatedLayout from '@/Layouts/Authenticated.vue';
import BreezeButton from "@/Components/Button.vue";
import { Head, Link, useForm } from '@inertiajs/inertia-vue3';

// 接收控制器数据
const props = defineProps({
    posts: {
        type: Object,
        default: () => ({}),
    },
});

const form = useForm();

function destroy(id) {
    if (confirm("Are you sure you want to Delete")) {
        form.delete(route('posts.destroy', id));
    }
}
</script>

<template>
    <Head title="Post List"/>

    <BreezeAuthenticatedLayout>
        <template #header>
            <h2 class="text-xl font-semibold leading-tight text-gray-800">
                文章列表
            </h2>
        </template>

        <div class="py-12">
            <div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
                <!-- flash message start -->
                <div
                    v-if="$page.props.flash.message"
                    class="p-4 mb-4 text-sm text-green-700 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-800"
                    role="alert"
                >
                    <span class="font-medium">
                        {{ $page.props.flash.message }}
                    </span>
                </div>
                <!-- flash message end -->
                <div class="overflow-hidden bg-white shadow-sm sm:rounded-lg">
                    <div class="p-6 bg-white border-b border-gray-200">
                        <div class="mb-2">
                            <Link :href="route('posts.create')">
                                <BreezeButton>添加文章</BreezeButton>
                            </Link>
                        </div>
                        <div class="relative overflow-x-auto shadow-md sm:rounded-lg">
                            <table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
                                <thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
                                <tr>
                                    <th scope="col" class="px-6 py-3">#</th>
                                    <th scope="col" class="px-6 py-3">
                                        Title
                                    </th>
                                    <th scope="col" class="px-6 py-3">
                                        Slug
                                    </th>
                                    <th scope="col" class="px-6 py-3">
                                        Edit
                                    </th>
                                    <th scope="col" class="px-6 py-3">
                                        Delete
                                    </th>
                                </tr>
                                </thead>
                                <tbody>
                                <tr
                                    v-for="post in posts"
                                    :key="post.id"
                                    class="bg-white border-b dark:bg-gray-800 dark:border-gray-700"
                                >
                                    <th
                                        scope="row"
                                        class="px-6 py-4 font-medium text-gray-900 dark:text-white whitespace-nowrap"
                                    >
                                        {{ post.id }}
                                    </th>
                                    <th
                                        scope="row"
                                        class="px-6 py-4 font-medium text-gray-900 dark:text-white whitespace-nowrap"
                                    >
                                        {{ post.title }}
                                    </th>
                                    <td class="px-6 py-4">
                                        {{ post.slug }}
                                    </td>

                                    <td class="px-6 py-4">
                                        <Link
                                            :href="route('posts.edit',post.id)"
                                            class="px-4 py-2 text-white bg-blue-600 rounded-lg"
                                        >
                                            编辑
                                        </Link>
                                    </td>
                                    <td class="px-6 py-4">
                                        <BreezeButton
                                            class="bg-red-700"
                                            @click="destroy(post.id)"
                                        >
                                            删除
                                        </BreezeButton>
                                    </td>
                                </tr>
                                </tbody>
                            </table>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </BreezeAuthenticatedLayout>
</template>

2)创建 Create.vue:resources/js/Pages/Post/Create.vue

<script setup>
import BreezeAuthenticatedLayout from '@/Layouts/Authenticated.vue';
import BreezeButton from "@/Components/Button.vue";
import { Head, useForm } from '@inertiajs/inertia-vue3';

const props = defineProps({
    posts: {
        type: Object,
        default: () => ({}),
    },
});

const form = useForm({
    title: '',
    slug: '',
    content: '',
});

const submit = () => {
    form.post(route("posts.store"));
};
</script>

<template>
    <Head title="Post Create"/>

    <BreezeAuthenticatedLayout>
        <template #header>
            <h2 class="text-xl font-semibold leading-tight text-gray-800">
                添加文章
            </h2>
        </template>

        <div class="py-12">
            <div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
                <div class="overflow-hidden bg-white shadow-sm sm:rounded-lg">
                    <div class="p-6 bg-white border-b border-gray-200">
                        <form @submit.prevent="submit">
                            <div class="mb-6">
                                <label
                                    for="Title"
                                    class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300"
                                >Title</label>
                                <input
                                    type="text"
                                    v-model="form.title"
                                    name="title"
                                    class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
                                    placeholder=""
                                />
                                <div
                                    v-if="form.errors.title"
                                    class="text-sm text-red-600"
                                >
                                    {{ form.errors.title }}
                                </div>
                            </div>
                            <div class="mb-6">
                                <label
                                    for="Slug"
                                    class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300"
                                >Slug</label>
                                <input
                                    type="text"
                                    v-model="form.slug"
                                    name="title"
                                    class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
                                    placeholder=""
                                />
                                <div
                                    v-if="form.errors.slug"
                                    class="text-sm text-red-600"
                                >
                                    {{ form.errors.slug }}
                                </div>
                            </div>
                            <div class="mb-6">
                                <label
                                    for="slug"
                                    class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300"
                                >Content</label>
                                <textarea
                                    type="text"
                                    v-model="form.content"
                                    name="content"
                                    id=""
                                    class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
                                ></textarea>
                                <div
                                    v-if="form.errors.content"
                                    class="text-sm text-red-600"
                                >
                                    {{ form.errors.content }}
                                </div>
                            </div>

                            <BreezeButton
                                class="ml-4 mt-4"
                                :class="{ 'opacity-25': form.processing }"
                                :disabled="form.processing"
                            >
                                创建文章
                            </BreezeButton>
                        </form>
                    </div>
                </div>
            </div>
        </div>
    </BreezeAuthenticatedLayout>
</template>

3)创建 Edit.vue:resources/js/Pages/Post/Edit.vue

<script setup>
import BreezeAuthenticatedLayout from '@/Layouts/Authenticated.vue';
import BreezeButton from "@/Components/Button.vue";
import { Head, useForm } from '@inertiajs/inertia-vue3';

const props = defineProps({
    post: {
        type: Object,
        default: () => ({}),
    },
});

const form = useForm({
    id: props.post.id,
    title: props.post.title,
    slug: props.post.slug,
    content: props.post.content,
});

const submit = () => {
    form.put(route("posts.update", props.post.id));
};
</script>
<template>
    <Head title="Post Edit"/>

    <BreezeAuthenticatedLayout>
        <template #header>
            <h2 class="text-xl font-semibold leading-tight text-gray-800">
                编辑文章
            </h2>
        </template>

        <div class="py-12">
            <div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
                <div class="overflow-hidden bg-white shadow-sm sm:rounded-lg">
                    <div class="p-6 bg-white border-b border-gray-200">
                        <form @submit.prevent="submit">
                            <div class="mb-6">
                                <label
                                    for="Title"
                                    class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300"
                                >Title</label>
                                <input
                                    type="text"
                                    v-model="form.title"
                                    name="title"
                                    class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
                                    placeholder=""
                                />
                                <div
                                    v-if="form.errors.title"
                                    class="text-sm text-red-600"
                                >
                                    {{ form.errors.title }}
                                </div>
                            </div>
                            <div class="mb-6">
                                <label
                                    for="Slug"
                                    class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300"
                                >Slug</label>
                                <input
                                    type="text"
                                    v-model="form.slug"
                                    name="title"
                                    class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
                                    placeholder=""
                                />
                                <div
                                    v-if="form.errors.slug"
                                    class="text-sm text-red-600"
                                >
                                    {{ form.errors.slug }}
                                </div>
                            </div>
                            <div class="mb-6">
                                <label
                                    for="slug"
                                    class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300"
                                >Content</label>
                                <textarea
                                    type="text"
                                    v-model="form.content"
                                    name="content"
                                    id=""
                                    class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
                                ></textarea>
                                <div
                                    v-if="form.errors.content"
                                    class="text-sm text-red-600"
                                >
                                    {{ form.errors.content }}
                                </div>
                            </div>
                            <BreezeButton
                                class="ml-4 mt-4"
                                :class="{ 'opacity-25': form.processing }"
                                :disabled="form.processing"
                            >
                                修改
                            </BreezeButton>
                        </form>
                    </div>
                </div>
            </div>
        </div>
    </BreezeAuthenticatedLayout>
</template>

4)在 resources/js/Layouts/Authenticated.vue 中添加导航链接代码(下面的36~40行):

<script setup>
import { ref } from 'vue';
import BreezeApplicationLogo from '@/Components/ApplicationLogo.vue';
import BreezeDropdown from '@/Components/Dropdown.vue';
import BreezeDropdownLink from '@/Components/DropdownLink.vue';
import BreezeNavLink from '@/Components/NavLink.vue';
import BreezeResponsiveNavLink from '@/Components/ResponsiveNavLink.vue';
import { Link } from '@inertiajs/inertia-vue3';

const showingNavigationDropdown = ref(false);
</script>

<template>
    <div>
        <div class="min-h-screen bg-gray-100">
            <nav class="bg-white border-b border-gray-100">
                <!-- Primary Navigation Menu -->
                <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
                    <div class="flex justify-between h-16">
                        <div class="flex">
                            <!-- Logo -->
                            <div class="shrink-0 flex items-center">
                                <Link :href="route('dashboard')">
                                    <BreezeApplicationLogo class="block h-9 w-auto" />
                                </Link>
                            </div>

                            <!-- Navigation Links -->
                            <div class="hidden space-x-8 sm:-my-px sm:ml-10 sm:flex">
                                <BreezeNavLink :href="route('dashboard')" :active="route().current('dashboard')">
                                    Dashboard
                                </BreezeNavLink>
                            </div>

                            <!-- 添加下面的代码 -->
                            <div class="hidden space-x-8 sm:-my-px sm:ml-10 sm:flex">
                                <BreezeNavLink :href="route('posts.index')" :active="route().current('posts.*')">
                                    Posts
                                </BreezeNavLink>
                            </div>
                            <!-- 添加结束 -->
                        </div>

                        <!-- 无关代码已省略 -->

这一个简单的案例到这里就已经完成了。

第五步:启动

在终端执行 php artisan serve --port 8080 启动服务(或在 nginx 中配置 php-fpm 访问)。

在另一个终端执行 npm run dev(或执行 npm run build 编译资源文件)。

最后,在浏览器中访问 http://127.0.0.1:8080/ 地址,页面右上角有登录和注册入口,先注册账号再登录。

第六步:总结

这个案例使用了 Inertia Js & Vue3 & Tailwind CSS,从中可以看出,应用的构建发生了巨大的变化,初次转过来时,可能会有种难度加大的感觉,除了这个种感觉外,还会有种新颖强大的感觉。

后面,通过更多的案例走进新的世界。

附:Inertia 文档的中文翻译:https://doc.ztlcoder.com/inertia/