支持Laravel.io的持续发展 →

并发、分块、多文件上传与Livewire!

2023年3月6日 阅读时间:15分钟

今天我们将使用Livewire上传多选文件,并在请求中分别进行,以及分块——使用Fly.io,您可以在几分钟内启动Laravel应用程序

Livewire提供了一个快速的方法,使用其WithFileUploads特性可以一次性上传多个文件。但是,这只能以单方式上传文件:所有文件在一个请求中,每个文件作为一个整体发送。

有时我们希望分批次发送文件,或者自定义如何上传每个选定的文件——例如,为每个文件显示进度指示器或分块上传每个文件。

为了定制上传我们选择的多文件,今天我们将使用Livewire的上传函数。通过它我们将以并行请求上传文件,并在最后以分块方式进行上传!

这里是我们的GitHub映射,您可以在其中查看相关文件!

默认行为

用户希望从单个输入选中多个文件进行上传。这可以通过Livewire轻松设置,所以我们使用命令 php artisan make:livewire multiple-file-uploader 创建了一个Livewire组件。

这将创建一个名为 \app\Http\Livewire\MultipleFileUploader 的Livewire组件,以及一个对应的视图,位于 app\resources\livewire\multiple-file-uploader

在我们的视图中,我们设置了文件输入元素。它将允许选择多个文件,并与公共属性 $uploads 进行 绑定

<div>
  <input type="file" wire:model="uploads" multiple>
</div>

为了启用Livewire的上传功能,将 WithFileUploads 特征包含到我们的组件中。然后声明 $uploads 为一个数组,以支持多个文件选择。

use Livewire\WithFileUploads;

class MultipleFileUploader extends Component {
  use WithFileUploads;
  public $uploads = [];

太棒了!只需几个步骤,我们就可以设置多个文件上传。让我们来看看在上面的输入元素中选中两个文件会发生什么。

打开网络检查器并选择两个文件。我们将看到有三次对服务器的调用

第一次请求 是Livewire的JavaScript对服务器组件的调用。它发送一个 "更新元数据" 指示组件触发由 WithFileUploads 特征提供的 "startUpload" 方法。

在服务器上,"startUpload" 生成一个用于上传文件的签名URL。它发出一个 upload:generatedSignedUrl,Livewire视图中的JavaScript(在此 监听)会接收到。

Livewire的JavaScript使用这个签名URL通过 第二次请求 一起上传文件,将文件存放在一个 临时文件夹 中。上传完成后,它会向组件发送一个最终的 请求,以更新 $uploads 属性并带有上传文件的详细信息。

接下来,让我们添加一个进度条,以帮助我们的用户跟踪上传进度。我们将将其连接到公共属性 $progress(确保在我们的组件中声明了此属性!),并且只在该进度可用时显示此条。

@if( $progress )
    <progress max=100 wire:model="progress" />
@endif

设置了进度条之后,我们需要将其与上传进度同步。

Livewire提供了一个 派发事件 "livewire-upload-progress",它发送回一个 detail.progress 值。我们可以使用此值来跟踪上传进度。

我们只需监听这个浏览器事件,并将视图中的 $progressdetail.progress 同步。我们将使用Livewire的 set 方法来实现这一点。

<script>
window.addEventListener('livewire-upload-progress', event => {
  @this.set( 'progress', event.detail.progress );
});

set() 会更新客户端中的值,因此它会移动进度条的值。但是,它不会立即发送请求到服务器以更新组件中的属性。相反,它将与下一个可用的请求一起发送。

由于所有文件都是在单个请求中上传的,因此需要注意以下几点

  1. 进度百分比是针对所有文件的上传,而不是单个文件
  2. 如果所有上传文件的总量超过 POST_MAX_SIZE,则所有文件可能都不会适用于上传
  3. 在单个请求中上传多个文件肯定需要更长时间来完成请求,这可能会导致可怕的 504 网关超时错误

当然,在这篇文章中,我们将解决上述限制。我们将通过使用 Livewire 的 上传 API,将文件分别在上传中完成。

分别请求上传文件

为什么不一次上传所有文件,而是逐个上传每个文件呢?这样可以将一个文件的上传与其他文件的上传完全分开,允许我们跟踪单个文件的上传进度,将失败的文件上传与其他选定的文件隔开,并调用较轻量级的请求。

首先,让我们为使用上传 API 准备我们的文件输入元素。我们将删除其与 $uploads 的绑定,并添加一个 ID,myFiles

<input type="file" id="myFiles" multiple>

然后,我们将添加一个自定义的 "on-change" 监听器到这个输入元素(通过 ID #myFiles 识别),以便单独上传选定的每个文件

<script>
const filesSelector = document.querySelector('#myFiles');

filesSelector.addEventListener('change', () => {
    const fileList = [...filesSelector.files];
    fileList.forEach((file, index) => {

这里的技巧是将每个文件分配到 $uploads 数组中的特定索引,以避免不同文件上传之间的变量冲突。我们将使用 Livewire JavaScript 的 upload() 函数上传每个文件。

它接收 uploads.<index> 作为我们要绑定文件的属性名,file 是当前上传文件的引用,以及几个回调子句

        @this.upload( 
          'uploads.'+index, 
          file, 
          (n)=>{}, ()=>{}, (e)=>{} 
        );
    });
}); 

在我们上一个部分中,我们简要检查了 Livewire 上传文件时做出的三个请求。第一个请求触发一个 startUpload 请求。

现在,让我们检查“分别”上传请求背后的情况。确保您打开了网络检查器,并选择了任意两个文件。

我们应该看到五个网络调用!如果我们检查第一个调用,我们将看到在更新包中我们没有一个 startUpload,而是两个——每个单独的文件上传都有一个请求。

接下来,检查两个“上传文件”的网络调用——这是实际的文件上传请求——它们是并行运行的!第二个上传请求是在第一个完成之前发出的,这使得我们可以并发表格的两个文件上传!

关于最后两个剩余的调用呢?一旦第一个上传完成,Livewire 的 JavaScript 就会对组件做出更新请求,以便更新 $uploads[<index>] 上的各自的文件详细信息。然后,一旦更新完成,Livewire 就继续对下一个完成的文件上传执行相同的操作。

自定义每个文件上传

每个文件都将组件中的 $uploads 数组分配到特定的索引,这使我们能够避免同一变量同时上传的冲突。同时,这为我们可以自定义每个文件上传铺平了道路!

我们不是将 Livewire 文件引用直接分配为数组的每个索引的值,而是给出一个数组。然后我们可以将不同的属性添加到这个数组中。实际上,我们将 Livewire 的文件引用分配为该数组的属性之一

// app\resources\views\livewire\multi-file-uploder.php
fileList.forEach((file, index) => {
-   @this.upload('uploads.'+index, file, (n)=>{},()=>{}, (e) => {});
+   @this.upload('uploads.'+index+'.fileRef',file,(n)=>{},()=>{},(e)=>{});

我们还可以将其他自定义详细信息添加到这个数组索引中,如文件的原始名称、大小或其上传进度!在上传函数调用之前添加这些内容

+   @this.set('uploads.'+index+'.fileName', file.name );
+   @this.set('uploads.'+index+'.fileSize', file.size );
+   @this.set('uploads.'+index+'.progress', 0 );
    @this.upload('uploads.'+index+'.fileRef',file,(n)=>{},()=>{},(e)=>{});

由于每个文件都是单独上传的,因此每个文件都有自己的 $uploads[index]['progress'] 进度值。每个进度将从 0% 开始,并通过每个文件的 upload() 进度回调进行更新

    @this.upload('uploads.'+index+'.fileRef',file,(n)=>{},()=>{},(e)=>{
      // Progress Callback
+      @this.set( 
+         'uploads.'+index+'.progress',
+         e.detail.progress );      
    });
});
</script>

有了这些属性,我们可以在 HTML 中为每个文件显示自定义详细资料,如下所示

<input type="file" id="myFiles" multiple>
@foreach( $uploads as $i=>$upl )
    <div>
        <label>
            <div>{{ $upl['fileName'] }}</div>
            <div>{{ $upl['progress'] }}%</div>
        </label>
        <progress max="100" wire:model="uploads.{{$i}}.progress" />
    </div>
@endforeach

由于每个文件都是单独上传的,我们最终为每个文件分别获取进度条!

对每文件进行分块上传

现在是最有趣的部分。多亏了我们将文件详细信息记录到数组中的索引,我们现在可以——敲鼓——分块我们的 单独 上传!

有什么额外的好处?考虑上传一个超出我们配置的 upload_max_file_size 的文件大小。我们不需要增加应用程序的上传大小限制来实现此上传,而是可以将文件切成更小的块,这些块在我们的应用程序限制范围内,然后分别通过单独的请求上传每个“块”。

要开始分割文件,我们得决定一个 $chunkSize。我们将用它来从文件中分割块。这应该在我们的应用程序配置的上传限制之内,并且尽可能靠近。当它靠近限制时,我们可以在一次请求中上传更大的块,我们需要的请求次数就越少,从而完整上传文件。

public $uploads = [];
+   public $chunkSize = 5_000_000; // 5MB

让我们修改上传每个文件的逻辑:我们将在这里删除 Livewire 的 upload() 函数调用,并用一个自定义函数 livewireUploadChunk() 代替。

fileList.forEach((file, index) => {
    @this.set('uploads.'+index+'.fileName', file.name, true );
    @this.set('uploads.'+index+'.fileSize', file.size, true );
    @this.set('uploads.'+index+'.progress', 0, true );
-   @this.upload(...);
+   livewireUploadChunk( index, file );
});

此函数依赖于一个起始点和结束点来分割一个块,因此我们需要这两个变量的值。然而,重要的是要注意,我们正在并行上传多个文件。由于这种并发性,我们必须将这些文件的起始点作为与其它文件的独立变量来保持,以避免值冲突。

在我们的当前设置中,一个文件对应一个 $uploads 索引。因此,我们可以通过将每个文件的“起始点”分配给相应的索引来实现这种分离。但是,由于切割是在客户端进行的,我们不会将它分配给组件中的 $uploads 数组。相反,我们将声明一个 JavaScript 数组 chnkStarts,由客户端来处理。

<script>
    const filesSelector = document.querySelector('#myFiles');
+   let chnkStarts=[];

然后,我们将每个文件的块起始点初始化为 0。

    filesSelector.addEventListener('change', () => {
        const fileList = [...filesSelector.files];
        fileList.forEach((file, index) => {
          //...
+         chnkStarts[index] = 0;    
          livewireUploadChunk( index, file );
        });
    });

现在,“块起始点”数组已设置好了,让我们继续 livewireUploadChunk() 的逻辑。这个函数将接收 $uploads 数组中文件的 index 和文件本身。它将从文件中切割一块,并使用 upload() 函数上传此块。

function livewireUploadChunk( index, file ){
  // End of chunk is start + chunkSize OR file size, whichever is greater
  const chunkEnd = Math.min(chnkStarts[index]+@js($chunkSize), file.size);
  const chunk    = file.slice(chnkStarts[index], chunkEnd);

请注意,我们正在同时上传多个文件。根据所选文件的数量(这应该受到限制,但此处没有涉及),请求的数量可能会激增。我们希望在这并发请求上节省资源,所以让我们限制一次只对一个文件发起一个请求。

我们将每个文件的一个块上传到 $uploads[index]['fileChunk']。之后,我们将连接到 Livewire 的 upload() 进度回调,并检查 e.detail.progress 以确定完成情况。一旦这个值达到 100,我们将通过递归调用函数传递到下一个可用的块起始点来上传下一个块。

    @this.upload('uploads.'+index+'.fileChunk',chunk,(n)=>{},()=>{},(e)=>{
      if( e.detail.progress == 100 ){
        // Get next start
        chnkStarts[index] = 
          Math.min( chnkStarts[index] + @js($chunkSize), file.size );
          
        // Upload if within file size
        if( chnkStarts[index] < file.size )
            livewireUploadChunk( index, file );
      }
    });
} // End livewireUploadChunk

以上更改现在应该已经通过块上传每个文件到我们的视图中。

合并文件块

移动到服务器上的组件,在文件上传时,我们需要将块正确地合并在一起。但我们如何在上传完成后注入逻辑呢?

当 Livewire 完成上传文件后,它会更新“wired”属性以包含上传文件的详细信息(记住这个第三请求)。这意味着我们可以在 Livewire 的公共属性上使用 updated 钩子 来在上传后注入逻辑!

在我们的情况下,文件的块绑定到 $uploads[index]['fileChunk'],因此我们将拦截到 updatedUploads()。请注意,$uploads 是一个数组的数组。每当该数组发生变化时,都会运行 updatedUploads()。记得我们还在每个索引上为 fileNamefileSizeprogress 设置了值吗?这也会为它们中的每一个运行!

我们希望在$uploads[index]['fileChunk']被更新时特别注入逻辑。Livewire提供了一种识别数组中已更新索引的方法。我们只需将两个参数传递到我们的挂钩中

public function updatedUploads( $value, $key ){

$key给出了$uploads中被更新项当前索引的值。你能猜出当$uploads[0]['fileChunks']被更新时$key返回的值吗?

它返回了0.fileChunk!我们将不得不解析这个字符串,并且只有当属性改变的是fileChunk时才继续。从这里我们得到第0个索引,我们可以用它来获取该索引下所有属性的引用

    list($index, $attribute) = explode('.',$key);
    if( $attribute == 'fileChunk' ){
        $fileDetails = $this->uploads[intval($index)];

现在我们能够截获特定文件的fileChunk更新(这将指示块上传完成),现在我们可以开始合并块了。

我们将合并传入的块到一个“最终文件”中。这个“最终文件”需要一个唯一的名称以避免冲突的文件上传。现在让我们假设$fileDetails[index]['fileName']是一个尽可能唯一的文件名,因此我们将使用它作为最终文件

        // Final File
        $fileName  = $fileDetails['fileName'];
        $finalPath = Storage::path('/livewire-tmp/'.$fileName);  

然后我们将访问已上传的文件块。由于Livewire已经用上传的文件块详情更新了我们的$uploads[index]['fileChunk'],我们可以在这里使用这些详情来访问我们的块。例如,我们可以获取块名,最终得到块的路径引用

        // Chunk File
        $chunkName = $fileDetails['fileChunk']->getFileName();
        $chunkPath = Storage::path('/livewire-tmp/'.$chunkName);
        $chunk      = fopen($chunkPath, 'rb');
        $buff       = fread($chunk, $this->chunkSize);
        fclose($chunk);

然后只是合并当前块与最终文件,并进行清理

        // Merge Together
        $final = fopen($finalPath, 'ab');
        fwrite($final, $buff);
        fclose($final);
        unlink($chunkPath);

然后,当我们在这里时,我们可以更新文件块文件上传的进度。我们可以通过比较已累积的大小和总文件大小来获取我们的进度

        // Progress
        $curSize = Storage::size('/livewire-tmp/'.$fileName);
        $this->uploads[$index]['progress'] = 
        $curSize/$fileDetails['fileSize']*100;

最终,一旦我们达到100%的进度,我们最终可以将我们的最终文件设置为文件Ref!我们将最终文件传递给Livewire的TemporaryUploadedFile类以利用Livewire的上传文件功能

        if( $this->uploads[$index]['progress'] == 100 ){
          $this->uploads[$index]['fileRef'] = 
          TemporaryUploadedFile::createFromLivewire(
            '/'.$fileDetails['fileName']
          );
        }
    }
} // End updatedUploads

从这里,我们可以在代码中添加一个提交按钮,并通过遵循Livewire的指南在服务器上存储多个文件来最终化保存每个文件在$uploads[<index>]['fileRef']中的过程。

注意事项

使用Livewire真是太令人印象深刻了,不是吗?看看这个框架如何不仅提供了一个实施功能的简单方法,更重要的是,提供了一个定制默认实现的方法。以生命周期挂钩为例——一种挂钩到Livewire处理中不同部分的方式!

同样,借助于其上传API,我们能够定制我们的多文件上传:从在一个请求中选择和上传多个文件,到通过单独的、并发、分块请求上传文件。

当然,与在一个请求中上传文件有缺点一样,单独在每个请求中上传每个文件也有缺点。

  1. 单独上传多个文件意味着对服务器进行多个请求。尽可能多的情况下,我们希望定制触发这些请求的速度,甚至考虑限制文件数量以减少这些调用。
  2. 将文件分块意味着将这些都块合并成一个最终文件。由于我们已经手动配置了最终文件,这意味着我们还需要确保这个最终文件名与其他现有文件不冲突。
  3. 最后,“定制”请求意味着我们可以进一步决定如何处理新文件的选择。当用户选择新文件时,是否删除旧文件,还是将新文件与现有列表合并?

生活中似乎每件事都有它的注意事项!但正是这些小“啊哈!”和“再来一次”的情况,让我们每天都能探索不同的细微差别,在路上发现一些小小的宝藏。

今天,多亏了单次请求上传文件的注意事项,我们发现了一个令人瞩目的“定制”宝藏,它是通过 Laravel Livewire 的上传 API 💎

最后更新 1 年前。

claudio1994-oliveira, lupete, roquib, driesvints, waynedv 喜欢了这篇文章

5
喜欢这篇文章? 让作者知道并为他们鼓掌!

你可能还会喜欢以下文章

2024年3月11日

如何使用 Larastan 搭建从0到9的 Laravel 应用

使用 Larastan 在应用执行前发现错误是可能的,这是一个...

阅读文章
2024年7月19日

不使用 traits 标准化 API 响应

我发现,大多数为 API 响应创建的库都是使用 traits 实现的,并且...

阅读文章
2024年7月17日

在 Laravel 项目中通过 Discord 通知收集反馈

如何在 Laravel 项目中创建反馈模块,并在有消息时接收到 Discord 通知...

阅读文章

我们感谢这些 了不起的公司 对我们的支持

这里可以放上您的logo吗?

Laravel.io

Laravel 问题的解决、知识共享和社区建设平台。

© 2024 Laravel.io - 版权所有。