如何优雅地实现图片局部预览组件? 下面是"bilibili用户头像上传" (https://link.segmentfault.com/?enc=cILJdevgB%2Ba74Eb%2FpXXOww%3D%3D.2ZtA42N7SwiXfvc3VGpBKuALaguBU4%2Bt%2ByQClylY4tF3JN3FyaC%2BP1TmBUiiSKtFKCSelalRQ9OIEva%2Fj3Ikyw%3D%3D)的界面,左侧我们可以通过移动选择框和缩放选择框来选择图片的某一部分,右侧是头像预览。 "image.png" (https://wmprod.oss-cn-shanghai.aliyuncs.com/c/user/20240930/f1a895ce7a04bcfd84380447d9c376f5.png) 我自己尝试使用"react+tailwindcss"来实现了一下,但是实现的并不太理想。 * 通过四个角来缩放的效果并不好。有时候没有反应,有时候又突然之间变换很大。 * 应该是当我按下鼠标之后,抬起鼠标之前,才能移动和缩放选择框。但是有时候,抬起了依然可以移动选择框。 * 鼠标移动操作的速度一快,就会出现一些意料之外的效果(如前两条所示)。 * 性能也不太好,处理了太多的"mousemove"事件了(可以考虑加上防抖?)。 可以在我实现的基础上给出一些改进建议或者直接重新给出一个新的方案。希望可以尽可能详细,有完整的代码,最终的效果可以交互。 简要说明一下我的实现思路: HTML结构 * "image"和"选择框"为兄弟元素。它们的父元素为"相对定位",选择框为"绝对定位"。父元素的宽度固定,"image"的最大宽度为"100%",父元素的高度由"image"的大小决定。 * "选择框"中有四个元素,分别表示四个角上的小方框,采用的也是"绝对定位" setPress({ ...press, topLeft: true })} > setPress({ ...press, topRight: true })} > setPress({ ...press, bottomLeft: true })} > setPress({ ...press, bottomRight: true })} > 状态 * 鼠标是否在选择框中按下。用一个对象来记录,并设置了5个属性,以区分按下的位置。根据按下的位置不同,鼠标移动操作时的行为也不同,是移动选择框还是缩放选择框,缩放的话该怎么缩放。 * 鼠标的位置。通过记录相邻两次鼠标的位置并计算其差值来决定选择框应移动多远的距离。 * "imagRef"用来获取图片渲染之后的"width"、"height"、"naturalWdith"、"naturalHeight",在计算图片缩放比例、选择框越界判断的时候有用。 * "selectorRef"获取选择框的"width"和"height"。const [press, setPress] = React.useState({ topLeft: false, topRight: false, bottomLeft: false, bottomRight: false, other: false, }); const [mousPosition, setMousePosition] = React.useState(null); const [selector, setSelector] = React.useState({ x: 0, y: 0, width: null, height: null, }); const imgRef = React.useRef(); const selectorRef = React.useRef(); 事件处理函数 * "mouseDownUp"重置"press"状态。 * "mouseDownKey"设置对应的"press"状态,设置鼠标的位置。 * 主要逻辑在"mouseMove"上。 * 根据按下位置的不同,更新选择框的位置和大小。 * 防止选择框越界。 * 这里的"onChange"函数主要用来设置一些用于在canvas上绘制图片时所需的信息("drawImage(img, x, y, sw, sh, dx, dy, dw, dh")。 * 设置鼠标的位置。 function handleMouseMove(event) { if (Object.values(press).every((e) => !e)) return;const { clientX, clientY } = event;const offsetX = clientX - mousPosition.x; const offsetY = clientY - mousPosition.y;let newX; let newY; let newWidth; let newHeight;if (press.topLeft) { console.log('press top left'); // bottom right don't change newX = selector.x + offsetX; newY = selector.y + offsetY; newWidth = selector.width - offsetX; newHeight = selector.height - offsetY; if (newWidth imgRef.current.width) newX = imgRef.current.width - selector.width; if (newX imgRef.current.height) newY = imgRef.current.height - selector.height; if (newY < 0) newY = 0;if (newHeight < 0) newHeight = 0; if (newWidth < 0) newWidth = 0; setSelector({ ...selector, x: newX, y: newY, width: newWidth, height: newHeight, }); onChange( imgRef.current, newX * imgRef.current.xScale, newY * imgRef.current.yScale, selector.width * imgRef.current.xScale, selector.height * imgRef.current.yScale ); setMousePosition({ x: clientX, y: clientY }); } * "stackblitz" (https://link.segmentfault.com/?enc=R7XEe8zJEdeF%2BZnUbB0leA%3D%3D.Wi1%2Be3fmEHg49jMNetZaHPWrTRoNOCc5j17U3iyThTHz5G036rIWFJHfr03H0DPULDRHmxSr%2BkQZYdL4BuWGNQ%3D%3D) * "github" (https://link.segmentfault.com/?enc=YacX4EMYJcpguUIsQ5N6vg%3D%3D.HyXu1ALUJHUBFmt76oPZTJ8f0QD5GMHDtxPzFtB9GPcEa1UJddTaLM6lkF8PieeU)
如何优雅地实现文件上传+文件夹上传+拖拽上传+进度追踪+...? 需求分析: 基础功能 * 显示 * 上传文件或文件夹的"名字"、"类型"、"大小"、"状态"。 * 类型 * 文件夹的类型可以"文件夹"或或者没有 * 文件的类型范围"MIME"。 * 大小 * 文件夹的大小为该文件夹及其子文件夹下所有文件的大小总和 * 状态 * 最少应该有的状态(假设上传一定成功,不会出现错误) * 未上传 * 上传中(当处于此状态的时候要实时显示上传进度) * 上传完成 * 其他扩展状态 * 暂停 * ... * 文件夹显示可以展开和折叠 * 上传按钮在上传中是"disabled",并且在没有文件上传的时候点击是不会触发上传的(可以在此时给出一个提醒) * 多个文件或文件夹上传 * 文件选中方式 * 通过"input[type="file"]"来选中 * 拖拽 * 进度追踪 * 一个文件的上传进度为"loaded/total"。 * 一个文件夹的上传进度为该文件夹下(包括子文件夹)的所有文件的"loaded"之和除以所有文件的"total"之和。 "image.png" (https://wmprod.oss-cn-shanghai.aliyuncs.com/c/user/20240930/322e5850e479cd1272a4c9017c50d788.png) 扩展功能 * 取消某些选中的将要上传文件 * 暂停 * 可以暂停上传的文件,之后再继续上传的时候不重新开始上传而是从上传完成的部分之后上传。 * 中断重试 * 和暂停功能类似,只是这里的中断是不是人主动要求的,而是外部的一些不可预料的事件(网络中断、服务器故障..)造成的。 * 文件上传性能优化 * 大文件分片上传 * 并发上传 * .... 我自己用"react+tailwindcss"尝试勉强实现了基础功能。 维护了一个树结构来记录文件上传的状态。 其中每个节点的字段含义 * "type" * "0"表示文件, "1"表示文件夹 * "file" * "File": 对于文件来说是"File对象",记录了文件的一些信息。 * "{ name: , size: , type: "文件夹"}": 对于文件夹来说只是记录了文件夹的名字、大小和类型。 * "children": * 对于文件来说该字段是没有的。 * 对于文件夹来说它是一个数组,包含一些节点,表示该文件夹下的所有文件和文件夹。 * "parent" * "[node, idx]": "node"表示该节点的父节点,"idx"表示该节点在其父节点的"children"中位于第几个。 * 问题: 如果我们想要删除某一个节点(相当于取消某个文件的上传) * 方案1 "splice" * 其后面的兄弟节点的"parent"也要改变,所有依赖"idx"的数据也改变。 * 当我们需要恢复该节点的时候,还需要回退我们的修改(需要记住之前的"idx",否则渲染的顺序就发生变化了。) * 实现起来还是比较麻烦,但没有占据不必要的空间。 * 方案2 "假的删除" * 在其父元素的"children数组"的对应位置("idx")设置为"null",表示该节点已经被删除。 * * 恢复的时候也容易。 * 实现起来简单,但是可能浪费了一些空间,特别是当一层中文件条目比较多的时候。 * 你有什么更好的方案吗?也可以重新设计这个node,使得删除、恢复更容易,且占据的空间少。 * "null": 表明该节点为根节点。 * "progress": * "num": 对于文件来说,它就是一个数值。由"XMLHttpRequest"实例的中的"upload"对象上"progress"事件中得到的"event.target"和"event.loaded"计算而来得到。("loaded * 100 /tatoal")。 * "{loaded: [...], total: [....]}": 对于文件夹来说,我们需要统计其子节点的"loaded"和"toatal"之后再计算得到其最终的进度。 * 当一个子节点的的进度发生改变的时候,它会顺着"parent",去修改其祖先节点中"progress.loaded"和"progress.total"。(这就是我们需要"parent"字段的原因) 问题:这棵树在该"react"项目中为一个状态,应该保持它的不可变性。对于这种复杂的数据结构该怎么保持它的不可变性呢?借助"immer"? 下面为更新进度的过程,该怎么修改保持不可变性呢? // src/utils.js export function uploadProcess(uploadFileList, setUploadFileList, setStatus) { // TopNode用作哨兵,让边界条件处理更容易。 for (let i = 0; i { setUploadFileList([...uploadFileList]); }, setStatus ); } function uploadFileOrDirectory(entry, update, setStatus) { if (entry === null) return; if (entry.type === 0) { const xhr = new XMLHttpRequest(); const data = new FormData(); data.append("file", entry.file); xhr.open("POST", UPLOAD_URL); xhr.upload.addEventListener("progress", (e) => { const percent = Number(((e.loaded * 100) / e.total).toFixed(0)); entry.progress = percent; propagateProcessUpward(entry, e.loaded, e.total, setStatus); update(); }); xhr.send(data); } else { for (const e of entry.children) { uploadFileOrDirectory(e, update, setStatus); } } } function propagateProcessUpward(entry, loaded, total, setStatus) { if (entry.parent) { const [parent, idx] = entry.parent; parent.progress.loaded[idx] = loaded; parent.progress.total[idx] = total; propagateProcessUpward( parent, parent.progress.loaded.reduce((acc, cur) => acc + cur, 0), parent.progress.total.reduce((acc, cur) => acc + cur, 0), setStatus ); } else { // topNode if ( entry.progress.loaded.every( (item, idx) => entry.progress.total[idx] === item ) ) setStatus(2); } } * 我的实现一点也不优雅而且应该还有很多问题,欢迎指正。 * 如果你有更好的的方案,欢迎在解答区分享。最好有完整的代码和较为详细的思路讲解。 附: * "codesandbox" (https://link.segmentfault.com/?enc=y8sR4UCUkxcM%2FaynFrYBGw%3D%3D.JrviR7EriTZERW%2FjGEoZsAyZJuU3hmxwyq%2FRSXlX3kjmxVS86QKamOFfpYzDX%2BGtZ5PcyaEykVokJs7gykuOLiOPEi4s%2FfeZY7snYWvnf54%3D) * "github" (https://link.segmentfault.com/?enc=do3fysL86mHU98QMdfIs9w%3D%3D.VkgPCk16nxDcspwmHC1Qfyd%2FkX3eburupeH8E4EI0DUoobLYyoaGmt7%2FoNcBw2Kj)(问题可以直接在"issue"里提) * "做的一些笔记" (https://link.segmentfault.com/?enc=jxECAEYPBVRluBdlep6Lpg%3D%3D.6OFsTae%2BotLr4XKBD3pJ5TfvCvfHXg3JT%2Bx9hOM5G9YRGpNBIsyON1hsg2XPgkqZpnQsTZeX8IfZlZk6Xof587f97KUxomXw6jN%2BZbixfzR38HrdqSfidcR43RZuJKJK)