Laravel9使用Alpinejs+Livewire时图片预览重复如何解决
Laravel 9 中 Alpine.js 搭配 Livewire 出现图片预览重复问题
问题表现
- 项目内 Alpine.js 已通过 defer 方式随 Laravel 默认配置引入
- 实现拖拽上传、提交前图片预览功能时,给文件输入框添加
wire:model="photo"属性后出现图片预览重复异常,移除该属性后功能运行完全正常 - 问题对应组件初始代码如下:
<div x-data="dataFileDnD()" class="relative flex flex-col p-4 text-gray-400 border border-gray-200 rounded"> <form wire:submit.prevent="save" x-ref="dnd" class="relative flex flex-col text-gray-400 border border-gray-200 border-dashed rounded cursor-pointer"> <input accept="images/*" type="file" multiple wire:model="photo" class="absolute inset-0 z-50 w-full h-full p-0 m-0 outline-none opacity-0 cursor-pointer" x-on:change="addFiles($event)" x-on:dragover="$refs.dnd.classList.add('border-blue-400'); $refs.dnd.classList.add('ring-4'); $refs.dnd.classList.add('ring-inset');" x-on:dragleave="$refs.dnd.classList.remove('border-blue-400'); $refs.dnd.classList.remove('ring-4'); $refs.dnd.classList.remove('ring-inset');" x-on:drop="$refs.dnd.classList.remove('border-blue-400'); $refs.dnd.classList.remove('ring-4'); $refs.dnd.classList.remove('ring-inset');" title="" /> <div class="flex flex-col items-center justify-center py-10 text-center"> <svg class="w-6 h-6 mr-1 text-current-50" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /> </svg> <p class="m-0">Drag your files here or click in this area.</p> </div> </form> <template x-if="files.length > 0"> <div class="grid grid-cols-2 gap-4 mt-4 md:grid-cols-6" x-on:drop.prevent="drop($event)" x-on:dragover.prevent="$event.dataTransfer.dropEffect = 'move'"> <template x-for="(_, index) in Array.from({ length: files.length })"> <div class="relative flex flex-col items-center overflow-hidden text-center bg-gray-100 border rounded cursor-move select-none" style="padding-top: 100%;" x-on:dragstart="dragstart($event)" x-on:dragend="fileDragging = null" :class="{ 'border-blue-600': fileDragging == index }" draggable="true" :data-index="index"> <button class="absolute top-0 right-0 z-50 p-1 bg-white rounded-bl focus:outline-none" type="button" x-on:click="remove(index)"> <svg class="w-4 h-4 text-gray-700" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> </svg> <span x-text="index"></span> </button> <template x-if="files[index].type.includes('image/')"> <img class="absolute inset-0 z-0 object-cover w-full h-full border-4 border-white preview" x-bind:src="loadFile(files[index])" /> </template> <div class="absolute bottom-0 left-0 right-0 flex flex-col p-2 text-xs bg-white bg-opacity-50"> <span class="w-full font-bold text-gray-900 truncate" x-text="files[index].name">Loading</span> <span class="text-xs text-gray-900" x-text="humanFileSize(files[index].size)">...</span> </div> <div class="absolute inset-0 z-40 transition-colors duration-300" x-on:dragenter="dragenter($event)" x-on:dragleave="fileDropping = null" :class="{ 'bg-blue-200 bg-opacity-80': fileDropping == index && fileDragging != index }"> </div> </div> </template> </div> </template> </div> <script src="https://unpkg.com/create-file-list"></script> <script> function dataFileDnD() { return { files: [], fileDragging: null, fileDropping: null, humanFileSize(size) { const i = Math.floor(Math.log(size) / Math.log(1024)); return ( (size / Math.pow(1024, i)).toFixed(2) * 1 + " " + ["B", "kB", "MB", "GB", "TB"][i] ); }, remove(index) { let files = [...this.files]; files.splice(index, 1); this.files = createFileList(files); }, drop(e) { let removed, add; let files = [...this.files]; removed = files.splice(this.fileDragging, 1); files.splice(this.fileDropping, 0, ...removed); this.files = createFileList(files); this.fileDropping = null; this.fileDragging = null; }, dragenter(e) { let targetElem = e.target.closest("[draggable]"); this.fileDropping = targetElem.getAttribute("data-index"); }, dragstart(e) { this.fileDragging = e.target .closest("[draggable]") .getAttribute("data-index"); e.dataTransfer.effectAllowed = "move"; }, loadFile(file) { const preview = document.querySelectorAll(".preview"); const blobUrl = URL.createObjectURL(file); preview.forEach(elem => { elem.onload = () => { URL.revokeObjectURL(elem.src); // free memory }; }); return blobUrl; }, addFiles(e) { const files = createFileList([...this.files], [...e.target.files]); this.files = files; } }; } </script>
问题原因
x-for循环缺少唯一key标识:添加wire:model后,Livewire会在文件选中、临时上传阶段触发组件DOM重绘,Alpine做DOM diff时因为没有唯一key,无法识别已渲染的预览项,会重复创建预览节点。- 事件重复触发:
wire:model绑定文件输入框后,会自动监听input的change事件做文件同步,和Alpine绑定的x-on:change="addFiles($event)"产生冲突,导致addFiles被重复调用,相同文件被多次加入files数组。 loadFile方法逻辑缺陷:每次生成预览地址时都会全局查询所有.preview元素,重复绑定onload事件、重复创建Blob URL,加剧重复渲染问题。
修复方法
- 给
x-for添加唯一key,用文件的名称、大小、最后修改时间作为唯一标识,替换原有循环写法,循环内所有files[index]的引用直接替换为file,减少索引查询出错概率:
<template x-for="(file, index) in files" :key="file.name + file.size + file.lastModified">
- 给预览区域容器添加
wire:ignore属性,避免Livewire重绘时破坏Alpine渲染的预览DOM:
<div wire:ignore class="grid grid-cols-2 gap-4 mt-4 md:grid-cols-6" x-on:drop.prevent="drop($event)" x-on:dragover.prevent="$event.dataTransfer.dropEffect = 'move'">
- 修复
loadFile方法,移除全局DOM查询逻辑,Blob URL回收逻辑直接绑定到对应img元素的onload事件上:
loadFile(file) { return URL.createObjectURL(file); },
对应img标签修改为:
<img class="absolute inset-0 z-0 object-cover w-full h-full border-4 border-white preview" x-bind:src="loadFile(file)" x-on:load="URL.revokeObjectURL($event.target.src)" />
- 在
addFiles方法中添加文件去重逻辑,避免重复加入相同文件,同时重置input值解决相同文件重复选择不触发change事件的问题:
addFiles(e) { const existingFiles = [...this.files]; const newFiles = [...e.target.files].filter(newFile => { return !existingFiles.some(exist => exist.name === newFile.name && exist.size === newFile.size && exist.lastModified === newFile.lastModified ); }); this.files = createFileList(existingFiles, newFiles); e.target.value = ''; }
- 由于输入框开启了
multiple多选,将Livewire绑定值改为复数形式wire:model="photos",和Livewire组件内的数组属性对应。
内容的提问来源于stack exchange,提问作者Najib Ahmed
相关产品推荐
相关产品推荐

