基于 Laravel + Vue 框架實(shí)現(xiàn)文件異步上傳組件和文章封面圖片功能
在今天這篇教程中,學(xué)院君將給大家演示如何基于 Laravel + Vue 框架編寫一個(gè)基本的圖片異步上傳組件,并且?guī)ьA(yù)覽功能,然后把這個(gè)圖片上傳組件嵌入到文章發(fā)布/編輯表單中,用于給文章設(shè)置封面圖片,這樣一來,文章列表視圖就不再那么單調(diào)了。
一、后端接口支持
開始之前,我們先要在 Laravel 后端準(zhǔn)備好相應(yīng)的數(shù)據(jù)庫字段、圖片上傳接口以及文章封面圖片設(shè)置功能。
新增文章封面圖片字段
在 component-practice 項(xiàng)目根目錄下,運(yùn)行如下 Artisan 命令為 posts 數(shù)據(jù)表生成一個(gè)新的數(shù)據(jù)庫遷移文件:
php artisan make:migration alter_posts_table_add_image_path --table=posts

打開這個(gè)剛剛創(chuàng)建的遷移文件,在 posts 表的 title 字段后面新增一個(gè) image_path 字段作為文章封面圖片:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AlterPostsTableAddImagePath extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('posts', function (Blueprint $table) {
$table->string('image_path')->nullable()->after('title');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('posts', function (Blueprint $table) {
$table->dropColumn('image_path');
});
}
}
接著運(yùn)行 php artisan migrate 讓這個(gè)新增字段生效:


編寫圖片上傳接口
接下來,我們新建一個(gè) ImageController 控制器:
php artisan make:controller ImageController
在這個(gè)新生成的控制器中編寫圖片上傳處理代碼如下:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class ImageController extends Controller
{
public function upload(Request $request)
{
$request->validate([
'image' => 'required|image|max:1024', // 圖片尺寸不能超過 1 MB
]);
if ($request->hasFile('image')) {
$image = $request->file('image');
$local_path = $image->storePublicly('images', ['disk' => 'public']);
$image_path = '/storage/' . $local_path;
return ['success' => true, 'path' => $image_path];
}
return ['success' => false, 'message' => '圖片上傳失敗'];
}
}
非常簡單,如果你對(duì)此不熟悉的話可以結(jié)合 Laravel 官方文檔驗(yàn)證、請(qǐng)求和文件存儲(chǔ)部分進(jìn)行進(jìn)一步的了解。
由于我們使用了 public 這個(gè)對(duì)外開放的磁盤路徑,所以需要在運(yùn)行如下 Artisan 命令在項(xiàng)目根目錄下的 public 目錄中創(chuàng)建一個(gè) storage 軟鏈接指向 storage 目錄下的 public 目錄:
php artisan storage:link
最后,控制器方法需要通過路由對(duì)外提供服務(wù),因此需要在 routes/web.php 中注冊(cè)對(duì)應(yīng)的 image/upload 路由:
Route::post('image/upload', [\App\Http\Controllers\ImageController::class, 'upload']);
這樣一來,用戶就可以通過 image/upload 路由進(jìn)行文件上傳操作了。
編寫測試用例測試圖片上傳
我們可以基于 Laravel 內(nèi)置的 HTTP 測試功能編寫一個(gè)測試用例,來測試文件上傳接口是否可以正常工作。
使用如下 Artisan 命令創(chuàng)建一個(gè) ImageUploadTest 測試類:
php artisan make:test ImageUploadTest

然后打開這個(gè)文件編寫文件上傳測試用例:
<?php
namespace Tests\Feature;
use Illuminate\Http\UploadedFile;
use Tests\TestCase;
class ImageUploadTest extends TestCase
{
/**
* A basic feature test example.
*
* @return void
*/
public function testImageUpload()
{
$response = $this->json('POST', '/image/upload', [
'image' => UploadedFile::fake()->image('test.jpg')
]);
// 斷言響應(yīng)是否正常
$response->assertStatus(200);
$response->assertJsonPath('success', true);
}
}
這里使用了 UploadedFile::fake() 方法偽造一個(gè) JPG 文件通過 POST 請(qǐng)求上傳到 /image/upload 路由,這樣就完成了模擬用戶在前端上傳文件的操作,非常方便。接下來就可以通過對(duì)返回的響應(yīng)實(shí)例編寫斷言看返回結(jié)果是否符合預(yù)期了,如果符合則測試通過,否則不通過(更多斷言方法參考 Laravel 官方文檔)。
我們可以通過 php artisan test 命令對(duì)這個(gè)測試用例進(jìn)行測試(PhpStorm 中亦可通過圖形化界面運(yùn)行指定測試用例):

綠色就表示通過,紅色則不通過。
文章封面圖片設(shè)置
接下來,我們來完善文章封面圖片設(shè)置功能。
在 Post 模型類中,在批量賦值字段白名單中新增一個(gè) image_path 字段:
class Post extends Model
{
use HasFactory;
protected $fillable = ['title', 'content', 'image_path'];
...
}
然后打開 PostController 控制器,在發(fā)布文章和更新文章后端處理方法中添加針對(duì) image_path 的驗(yàn)證規(guī)則即可:
public function store(Request $request)
{
$data = $request->validate([
'title' => 'required|max:128',
'image_path' => 'required',
'content' => 'required'
]);
...
if ($post->save()) {
return ['success' => true, 'message' => '文章發(fā)布成功', 'id' => $post->id];
}
...
}
...
public function update(Request $request, Post $post)
{
$data = $request->validate([
'title' => 'required|max:128',
'image_path' => 'required',
'content' => 'required'
]);
...
if ($post->save()) {
return ['success' => true, 'message' => '文章更新成功', 'id' => $post->id];
}
...
}
注意到我們?cè)谖恼卤4娉晒蠓祷氐捻憫?yīng)字段中新增了一個(gè) id 字段,用于客戶端處理表單提交成功后的頁面重定向。
至此,所有的后端代碼就編寫好了,接下來,我們進(jìn)入前端實(shí)現(xiàn)圖片上傳和封面圖片字段設(shè)置。
二、在前端表單組件中上傳封面圖片
編寫 InputFile 組件
首先,我們?cè)?resources/js/components/form 目錄下新建一個(gè) InputFile.vue 文件作為圖片上傳組件,并初始化組件代碼如下:
<template>
<div class="form-group">
<input class="form-control-file" ref="file" type="file" :name="name" @change="fileUpload">
<template v-if="file_path">
<InputText type="hidden" :name="field" v-model="file_path"></InputText>
<img :src="file_path" class="img-thumbnail" style="width: 50%;" alt="封面圖片預(yù)覽" v-if="name === 'image'">
</template>
</div>
</template>
<script>
import InputText from "./InputText";
export default {
components: {InputText},
props: ['name', 'field', 'path'],
data() {
return {
file_path: this.path
}
},
methods: {
fileUpload() {
this.$emit('clear');
let form_data = new FormData();
form_data.append(this.name, this.$refs.file.files[0]);
axios.post('/' + this.name + '/upload', form_data, {
headers: {
'Content-Type': 'multipart/form-data'
}
}).then(resp => {
this.file_path = resp.data.path;
this.$emit('success', this.field, this.file_path);
}).catch(error => {
let errors = {};
let error_bag = error.response.data.errors;
errors[this.field] = error_bag[this.name];
this.$emit('error', errors);
});
}
}
}
</script>
在這段模板代碼中,我們引入了一個(gè) Bootstrap 的文件上傳表單元素,如果從父級(jí)作用域傳遞過來的文件路徑不為空,或者在當(dāng)前組件中上傳文件成功的話,還會(huì)渲染一個(gè)包含對(duì)應(yīng)文件路徑值的隱藏字段,如果文件類型是圖片的話,還支持對(duì)該圖片進(jìn)行預(yù)覽。
顯然我們這個(gè)文件上傳組件是通用的,不僅僅支持圖片上傳,還支持其他類型文件上傳。
相關(guān)的 props 屬性和數(shù)據(jù)模型屬性綁定就不多做介紹了,我們看下這里監(jiān)聽的文件事件:當(dāng)我們?cè)谖募韱卧厣线x擇一個(gè)本地文件后,會(huì)觸發(fā)該元素的 change 事件,然后執(zhí)行與該事件綁定的 fileUpload 方法。
在這個(gè)方法中,我們首先會(huì)觸發(fā)父級(jí)作用域上的 clear 事件清空?qǐng)?bào)錯(cuò)消息(馬上會(huì)展示父級(jí)作用域?qū)?yīng)的設(shè)置),然后基于 axios 庫 API 發(fā)起一個(gè)文件上傳異步請(qǐng)求,對(duì)應(yīng)的后端接口正是上面 Laravel 后端新增的 image/upload 接口。注意到這里設(shè)置了額外的請(qǐng)求頭,因?yàn)槲募蟼饕髢?nèi)容類型必須是 multipart/form-data。
文件上傳成功后,會(huì)覆蓋 file_path 屬性值填充隱藏的文件路徑字段,如果是圖片類型的話會(huì)渲染預(yù)覽圖片,并且還會(huì)觸發(fā)父級(jí)作用域的 success 事件以便執(zhí)行對(duì)應(yīng)的業(yè)務(wù)邏輯,比如填充文章封面圖片路徑字段值。
文件上傳失敗后,則會(huì)觸發(fā)父級(jí)作用域的 error 事件以便執(zhí)行相應(yīng)的錯(cuò)誤顯示邏輯。
在表單組件中引入 InputFile
接下來,我們打開文章發(fā)布/編輯表單組件 PostForm,在其中引入文件上傳組件實(shí)現(xiàn)封面圖片的上傳和設(shè)置:
<template>
<FormSection @store="store">
<template slot="title">{{ title }}</template>
<template slot="input-group">
<div class="form-group">
<Label name="title" label="標(biāo)題"></Label>
...
</div>
<div class="form-group">
<Label name="title" label="封面圖片"></Label>
<InputFile name="image" field="image_path" :path="form.image_path"
@clear="clear('image_path')" @success="uploadSuccess" @error="uploadError">
</InputFile>
<ErrorMsg :error="form.errors.get('image_path')"></ErrorMsg>
</div>
...
</template>
...
</FormSection>
</template>
<script>
import FormSection from './form/FormSection';
import InputText from './form/InputText';
import InputFile from "./form/InputFile";
import TextArea from './form/TextArea';
import Button from './form/Button';
import ToastMsg from './form/ToastMsg';
import Label from "./form/Label";
import ErrorMsg from "./form/ErrorMsg";
export default {
components: {FormSection, InputText, InputFile, TextArea, Label, ErrorMsg, Button, ToastMsg},
props: ['title', 'url', 'action', 'post'],
data() {
let post_data = this.post ? JSON.parse(this.post) : null;
return {
form: new Form({
title: this.post ? post_data.title : '',
content: this.post ? post_data.content : '',
image_path: this.post ? post_data.image_path : ''
})
}
},
methods: {
store() {
let method = this.action === 'create' ? 'post' : 'put';
this.form[method](this.url)
.then(data => {
// 發(fā)布/更新成功后都跳轉(zhuǎn)到詳情頁
window.location.href = '/posts/' + data.id;
})
.catch(data => console.log(data)); // 自定義表單提交失敗處理邏輯
},
clear(field) {
this.form.errors.clear(field);
},
uploadSuccess(field, path) {
this.form[field] = path;
},
uploadError(errors) {
this.form.errors.set(errors);
}
}
}
</script>
我們?cè)跇?biāo)題之后引入了文件上傳組件,并設(shè)置了一應(yīng)需要傳遞到子組件的 props 屬性,以及三個(gè)事件函數(shù),分別處理子組件上報(bào)的 clear、success 和 error 事件,我們通過這種機(jī)制實(shí)現(xiàn)子組件和父級(jí)作用域的通信。
clear 事件函數(shù)前面已經(jīng)介紹過了,就是清理封面圖片字段相關(guān)的錯(cuò)誤信息;success 事件函數(shù)用于圖片上傳成功后設(shè)置 form 實(shí)例的 image_path 屬性,以便后續(xù)提交表單時(shí)使用;error 事件函數(shù)用于設(shè)置 image_path 字段的錯(cuò)誤消息。
另外,我們還改造了編輯表單數(shù)據(jù)初始化邏輯,因?yàn)樵瓉淼漠惒郊虞d存在延時(shí),相應(yīng)的需要在 views/posts/edit.blade.php 視圖中引入 PostForm 組件的地方傳遞 JSON 格式的 $post 實(shí)例數(shù)據(jù)過來:
<post-form title="編輯文章" action="update" post="{{ json_encode($post) }}" url="{{ route('posts.update', ['post' => $post->id]) }}">
</post-form>
當(dāng)然,PostController 控制器中 edit 方法傳遞進(jìn)視圖的數(shù)據(jù)也要調(diào)整:
public function edit(Post $post)
{
return view('posts.edit', ['pageTitle' => '編輯文章', 'post' => $post]);
}
此外,在 PostForm 中,我們也將文章發(fā)布和文章更新成功后的處理邏輯也統(tǒng)一成跳轉(zhuǎn)到文章詳情頁。
以上就是 PostForm 表單針對(duì)封面圖片上傳和設(shè)置功能的所有代碼調(diào)整了,此時(shí)打開文章發(fā)布頁面,就可以看到包含封面圖片字段的表單了:

上傳文章封面圖片
我們可以在這個(gè)發(fā)布文章表單中測試封面圖片上傳功能,上傳成功后可以立即看到預(yù)覽圖片的渲染,表明圖片上傳成功:

你還可以在 Vue Devtools 面板中看到 form.image_path 屬性中也成功設(shè)置了圖片路徑:

點(diǎn)擊「立即發(fā)布」按鈕發(fā)布這篇文章,就可以跳轉(zhuǎn)到文章詳情頁了,點(diǎn)擊編輯鏈接,進(jìn)入該文章的編輯頁面,由于初始 image_path 值不為空,所以可以看到對(duì)應(yīng)的封面圖片預(yù)覽:

你可以在文章編輯頁面上傳其他圖片覆蓋老的封面圖片:

發(fā)布成功,則表明文章發(fā)布和編輯表單中的封面圖片上傳和設(shè)置功能可以正常工作。至此,我們就完成了文章封面圖片上傳和設(shè)置的全部工作。
三、在文章列表頁展示封面圖片
最后,我們打開 list/CardItem 組件,在文章列表頁卡片視圖中渲染文章封面圖片,讓列表頁看起來更好看一些,這里我們把 Bootstrap 帶封面圖片的 Card 組件 HTML 模板拷貝過來,加上 Vue 條件渲染指令進(jìn)行展示即可(如果有封面圖片的話顯示封面圖片,否則顯示灰色背景):
<template>
<div class="col mb-4">
<div class="card">
<img :src="post.image_path" class="card-img-top" height="180" :alt="'點(diǎn)擊閱讀' + post.title" v-if="post.image_path">
<svg class="bd-placeholder-img card-img-top" width="100%" height="180" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid slice" focusable="false" role="img" v-else>
<rect width="100%" height="100%" fill="#868e96"></rect>
</svg>
<div class="card-body">
<h5 class="card-title"><slot name="title"></slot></h5>
<p class="card-text">
<small class="text-muted">
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-person-circle" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M13.468 12.37C12.758 11.226 11.195 10 8 10s-4.757 1.225-5.468 2.37A6.987 6.987 0 0 0 8 15a6.987 6.987 0 0 0 5.468-2.63z"/>
<path fill-rule="evenodd" d="M8 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/>
<path fill-rule="evenodd" d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zM0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8z"/>
</svg>
{{ post.author.name}}
</small>
...
</p>
...
</div>
</div>
</div>
</template>
另外,我們還將卡片中的狀態(tài)字段調(diào)整為了作者字段,相應(yīng)的,需要在后端接口數(shù)據(jù)中添加這段渴求式加載邏輯:
public function all()
{
return Post::with('author')->orderByDesc('created_at')->get();
}
為了優(yōu)化用戶體驗(yàn),我們還在異步加載文章列表數(shù)據(jù)期間為列表頁加上了用戶友好的加載提示效果(和文章詳情頁一樣),在 list/ListSection.vue 組件中通過插槽定義加載提示位置:
<div class="card-body">
<slot name="loading"></slot> // 在這里插入加載動(dòng)態(tài)圖
<ul class="list-group" v-if="view.mode === 'list'">
<slot></slot>
</ul>
<div class="row row-cols-1 row-cols-md-3" v-else>
<slot></slot>
</div>
</div>
然后在父級(jí)作用域 PostList 中編寫對(duì)應(yīng)的動(dòng)態(tài)加載效果,以及將默認(rèn)視圖模式切換成卡片視圖:
<template>
<div class="post-list">
<ListSection :view_mode="view_mode" @view-mode-changed="change_view_mode">
...
<template #loading>
<div class="spinner-border" role="status" v-if="!loaded">
<span class="sr-only">Loading...</span>
</div>
</template>
</ListSection>
</div>
</template>
<script>
...
export default {
...
data() {
return {
posts: [],
view_mode: 'card',
loaded: false
}
},
...
}
</script>
現(xiàn)在,我們?cè)L問文章列表頁,數(shù)據(jù)加載完成之前會(huì)有一個(gè)動(dòng)態(tài)加載中的提示:

加載完成后,則會(huì)默認(rèn)以卡片模式展示包含封面圖片的文章列表數(shù)據(jù),是不是比原來好看多了:

關(guān)于文件異步上傳和封面圖片設(shè)置我們就簡單介紹到這里,下篇教程,學(xué)院君將給大家演示如何在 Vue 框架中實(shí)現(xiàn)拖放式圖片上傳組件。
本系列教程首發(fā)在Laravel學(xué)院(laravelacademy.org),你可以點(diǎn)擊頁面左下角閱讀原文鏈接查看最新更新的教程。
