从零开始封装一个优雅的图片上传组件 - 二次改装 Layui-Upload 的教程(附完整封装代码)

从零开始封装一个优雅的图片上传组件 - 基于 Layui 的实战教程

写在前面:最近在做一个后台管理系统,需要实现头像上传和证件照上传的功能。市面上的上传组件要么太重,要么不够灵活,索性自己封装了两个组件。今天就来分享一下整个封装过程,希望能给大家一些启发。

先看效果吧

例1:默认效果:

上传图片效果:

点击Replace可进行替换,点击图片可以进行预览

例2:还有一种样式是这样的效果

预览效果:

例3:这里又新增了一个预览效果(右侧上传完图片后会显示在左侧,然后右侧是空的可以重新添加,点击图片可以放大预览和例2一个效果)

一、为什么要自己封装

说实话,一开始我也想直接用现成的。Layui 自带的 upload 模块功能挺全的,但是有几个痛点:

  1. 每次用都要写一堆重复代码,什么 before、done、error 回调
  2. 预览功能要自己实现,hover 放大、点击查看大图这些都得手写
  3. 样式和业务逻辑耦合在一起,换个页面又得 copy 一遍

后来项目里有十几个地方要用上传,我实在受不了了,决定封装一下。封装完之后,一行代码就能搞定一个上传功能,真香。

二、整体设计思路

在动手之前,我先梳理了一下需求:

第一个场景是头像上传。特点是图片比较小,通常是正方形,需要 hover 时显示放大预览,点击可以重新上传。

第二个场景是证件/文档上传。特点是图片比较大,需要显示替换按钮,点击图片要能查看大图,还要支持只读模式(比如审核页面只能看不能改)。

基于这两个场景,我设计了两个类:

  • AvatarUploadLayui:轻量级的头像上传,基于 Layui upload 模块
  • DocUploadBox:功能更丰富的文档上传,使用原生 Ajax + Tailwind CSS

为什么要分成两个?因为它们的交互逻辑差异还挺大的,硬塞到一个类里会很臃肿。分开之后,各司其职,代码也更清晰。

三、代码结构概览

先看一下整体的代码结构:

javascript 复制代码
(function (window) {
    'use strict';

    const $ = layui.$;
    const layer = layui.layer;

    // 头像上传组件
    class AvatarUploadLayui {
        // ...
    }

    // 文档上传组件
    class DocUploadBox {
        // ...
    }

    // 工具函数
    function setUploadReadOnly(containerId) {
        // ...
    }

    // 暴露到全局
    window.AvatarUploadLayui = AvatarUploadLayui;
    window.DocUploadBox = DocUploadBox;
    window.setUploadReadOnly = setUploadReadOnly;

})(window);

这里用了 IIFE(立即执行函数表达式)来包裹整个代码。这样做的好处是:

  1. 避免污染全局命名空间,内部变量不会泄露出去
  2. 只暴露需要的接口,保持 API 的简洁
  3. 代码更容易维护,改内部实现不影响外部调用

开头的 'use strict' 是启用严格模式,能帮我们避免一些常见的 JavaScript 陷阱,比如意外创建全局变量。

四、AvatarUploadLayui 详解

4.1 构造函数与参数校验

先来看头像上传组件的构造函数:

javascript 复制代码
class AvatarUploadLayui {
    constructor(options) {
        if (!options.elem) {
            console.error('AvatarUploadLayui: elem 参数是必填的');
            return;
        }
        if (!options.uploadUrl) {
            console.error('AvatarUploadLayui: uploadUrl 参数是必填的');
            return;
        }

        this.options = {
            elem: options.elem,
            uploadUrl: options.uploadUrl,
            placeholderId: options.placeholderId || 'uploadPlaceholder',
            previewId: options.previewId || 'avatarPreview',
            valueId: options.valueId || 'avatarValue',
            previewSize: options.previewSize || 200,
            onSuccess: options.onSuccess || null,
            onError: options.onError || null
        };

        this.tipsIndex = null;
        this.init();
    }
    // ...
}

这段代码做了几件事:

首先是参数校验。elemuploadUrl 是必填的,没有这两个参数组件根本没法工作。这里用 console.error 而不是抛异常,是因为抛异常会中断整个页面的 JavaScript 执行,对用户体验不太友好。当然,如果你的项目有完善的错误处理机制,抛异常也是可以的。

然后是参数合并。用户传入的 options 和默认值合并,这样用户只需要传必要的参数,其他的用默认值就行。这种模式在 jQuery 插件里很常见,叫做"配置对象模式"。

this.tipsIndex 是用来存储 layer.tips 的索引的,后面关闭提示框的时候会用到。

最后调用 this.init() 开始初始化。把初始化逻辑单独抽成一个方法,而不是全写在构造函数里,代码会更清晰,也方便以后扩展(比如加一个 reinit 方法)。

4.2 初始化与上传逻辑

javascript 复制代码
init() {
    layui.use(['upload', 'layer'], () => {
        const upload = layui.upload;

        this.uploadInst = upload.render({
            elem: this.options.elem,
            url: this.options.uploadUrl,
            before: (obj) => {
                obj.preview((index, file, result) => {
                    $(`#${this.options.previewId}`).removeClass('layui-hide').attr('src', result);
                    $(`#${this.options.placeholderId}`).addClass('layui-hide');
                });
                layer.msg('Uploading...', { icon: 16, time: 0 });
            },
            done: (res) => {
                layer.closeAll('loading');
                if (res.code > 0) {
                    layer.msg('Upload failed');
                    this.options.onError?.(res);
                } else {
                    layer.msg('Upload successful');
                    $(`#${this.options.placeholderId}`).parent().addClass('layui-hide');
                    $(`#${this.options.previewId}`).removeClass('layui-hide');
                    $(`#${this.options.valueId}`).val(res.data.pic);
                    this.bindHoverPreview();
                    this.options.onSuccess?.(res);
                }
            },
            error: () => {
                layer.closeAll('loading');
                layer.msg('Upload error');
                $(`#${this.options.previewId}`).addClass('layui-hide');
                $(`#${this.options.placeholderId}`).removeClass('layui-hide');
                this.options.onError?.();
            }
        });
    });
}

这里有几个值得注意的点:

第一,layui.use 的使用。Layui 是模块化的,使用任何模块之前都要先 use。这里我们需要 upload 和 layer 两个模块。回调函数用的是箭头函数,这样 this 指向的就是组件实例,而不是 window 或者 undefined。

第二,before 回调里的预览逻辑。obj.preview 是 Layui 提供的方法,可以在上传之前预览图片。result 是图片的 base64 编码,直接设置到 img 的 src 就能显示。这样用户选完图片立刻就能看到效果,不用等上传完成,体验很好。

第三,done 回调里的判断逻辑。这里约定 res.code > 0 表示失败,res.code === 0 表示成功。这是后端接口的约定,你的项目可能不一样,需要根据实际情况调整。

第四,可选链操作符 ?. 的使用。this.options.onSuccess?.(res) 这种写法是 ES2020 引入的,意思是"如果 onSuccess 存在就调用它"。比起 if (this.options.onSuccess) this.options.onSuccess(res) 简洁多了。

第五,this.uploadInst 保存了 upload.render 的返回值。虽然在这个组件里没用到,但保留它是个好习惯,以后可能需要调用 uploadInst.reload() 之类的方法。

4.3 Hover 预览功能

javascript 复制代码
bindHoverPreview() {
    const avatarPreview = $(`#${this.options.previewId}`);
    const size = this.options.previewSize;

    avatarPreview.off('mouseenter').on('mouseenter', (e) => {
        const imgSrc = $(e.currentTarget).attr('src');
        this.tipsIndex = layer.tips(
            `<img src="${imgSrc}" style="width:${size}px;height:${size}px;object-fit:cover;border-radius:8px;">`,
            e.currentTarget,
            { tips: [2, '#fff'], time: 0, area: [`${size + 20}px`, `${size + 20}px`] }
        );
    });

    avatarPreview.off('mouseleave').on('mouseleave', () => {
        if (this.tipsIndex !== null) {
            layer.close(this.tipsIndex);
            this.tipsIndex = null;
        }
    });

    avatarPreview.off('click').on('click', () => {
        $(`#${this.options.elem.replace('#', '')}`).click();
    });
}

这个方法实现了三个交互:

鼠标移入时显示放大预览。用的是 layer.tips,它会在目标元素旁边显示一个提示框。tips: [2, '#fff'] 表示提示框显示在右边,背景色是白色。time: 0 表示不自动关闭。area 设置提示框的大小,比图片大 20px 是为了留点内边距。

鼠标移出时关闭预览。这里用 this.tipsIndex 来追踪当前打开的提示框,关闭后置为 null。为什么要判断 !== null?因为 layer.tips 返回的索引可能是 0,直接用 if (this.tipsIndex) 会出问题。

点击图片时触发重新上传。这里有个小技巧:this.options.elem 可能是 #avatarUpload 这样的选择器,但我们需要触发的是这个元素的 click 事件。用 replace('#', '') 去掉井号,再拼成选择器。

还有一个细节:每次绑定事件之前都先 off 解绑。这是为了防止重复绑定。比如用户上传了多次,每次上传成功都会调用 bindHoverPreview,如果不解绑,事件就会绑定多次,导致一些奇怪的 bug。

4.4 辅助方法

javascript 复制代码
setPreview(imgUrl) {
    $(`#${this.options.placeholderId}`).parent().addClass('layui-hide');
    $(`#${this.options.previewId}`).removeClass('layui-hide').attr('src', imgUrl);
    this.bindHoverPreview();
}

reset() {
    $(`#${this.options.placeholderId}`).parent().removeClass('layui-hide');
    $(`#${this.options.previewId}`).addClass('layui-hide').attr('src', '');
    $(`#${this.options.valueId}`).val('');
}

getValue() {
    return $(`#${this.options.valueId}`).val();
}

这三个方法都很简单,但很实用:

setPreview 用于手动设置预览图。比如编辑页面,需要显示用户之前上传的头像,就可以调用这个方法。

reset 用于重置组件状态。比如用户点了"取消"按钮,需要清空已选择的图片。

getValue 用于获取当前的图片地址。表单提交的时候会用到。

你可能会问,为什么不直接操作 DOM,而是提供这些方法?这就是封装的意义:外部代码不需要知道组件内部的 DOM 结构,只需要调用方法就行。以后如果要改 DOM 结构,只需要改组件内部的代码,外部调用不受影响。

五、DocUploadBox 详解

接下来看文档上传组件。这个组件比头像上传复杂不少,因为它需要自己渲染 DOM、处理更多的交互逻辑。

5.1 构造函数

javascript 复制代码
class DocUploadBox {
    constructor(options) {
        if (!options.container) {
            console.error('DocUploadBox: container 参数是必填的');
            return;
        }
        if (!options.uploadUrl) {
            console.error('DocUploadBox: uploadUrl 参数是必填的');
            return;
        }
        if (!options.fieldName) {
            console.error('DocUploadBox: fieldName 参数是必填的');
            return;
        }

        this.options = {
            container: options.container,
            uploadUrl: options.uploadUrl,
            fieldName: options.fieldName,
            width: options.width || 'w-[300px]',
            height: options.height || 'h-[200px]',
            defaultImage: options.defaultImage || '',
            replaceText: options.replaceText || '替换',
            placeholder: options.placeholder || '+',
            placeholderText: options.placeholderText || '',
            maxSize: options.maxSize || 5,
            readOnly: options.readOnly || false,
            onSuccess: options.onSuccess || null,
            onError: options.onError || null
        };

        this.uniqueId = `doc_upload_${Math.random().toString(36).slice(2, 11)}`;
        this.value = '';
        this.init();
    }
    // ...
}

和 AvatarUploadLayui 相比,这个组件多了几个参数:

container 是容器选择器,组件会把 DOM 渲染到这个容器里。

fieldName 是隐藏 input 的 name 属性,表单提交的时候会用到。

widthheight 用的是 Tailwind CSS 的类名,比如 w-[300px]。这样做的好处是可以直接用 Tailwind 的响应式类,比如 md:w-[400px]

placeholderText 是加号下面的提示文字,比如"点击上传身份证正面"。

readOnly 是只读模式,审核页面会用到。

maxSize 是文件大小限制,单位是 MB。

还有一个 this.uniqueId,用 Math.random().toString(36).slice(2, 11) 生成一个随机字符串。为什么需要这个?因为一个页面可能有多个上传组件,每个组件的 DOM 元素需要有唯一的 id,不然会冲突。

5.2 初始化流程

javascript 复制代码
init() {
    this.render();
    this.cacheElements();

    if (this.options.defaultImage) {
        this.setPreview(this.options.defaultImage);
    }

    if (this.options.readOnly) {
        this.setReadOnly();
    } else {
        this.bindEvents();
    }
}

cacheElements() {
    this.$container = $(this.options.container);
    this.$uploadBox = this.$container.find('.doc-upload-box');
    this.$previewImg = this.$container.find('.doc-preview-img');
    this.$placeholder = this.$container.find('.doc-upload-placeholder');
    this.$placeholderText = this.$container.find('.doc-placeholder-text');
    this.$replaceBtn = this.$container.find('.doc-replace-btn');
    this.$fileInput = this.$container.find('.doc-file-input');
    this.$valueInput = this.$container.find('.doc-value-input');
}

初始化分成几个步骤:

第一步,render() 渲染 DOM。这个方法会生成组件的 HTML 结构,插入到容器里。

第二步,cacheElements() 缓存 DOM 元素。把常用的元素存到实例属性里,后面用的时候就不用每次都 $container.find() 了。这是一个性能优化的小技巧,虽然现代浏览器的 DOM 查询很快,但能省则省。

第三步,如果有默认图片,调用 setPreview() 显示出来。

第四步,根据是否只读,决定是调用 setReadOnly() 还是 bindEvents()。这里用 if-else 而不是两个都调用,是因为只读模式下不需要绑定事件,绑了也没用。

5.3 DOM 渲染

这是整个组件最复杂的部分,我们一段一段来看:

javascript 复制代码
render() {
    const { defaultImage, placeholder, placeholderText, replaceText, fieldName, width, height } = this.options;
    const hasImg = defaultImage ? 'has-img' : '';
    const imgStyle = defaultImage ? '' : 'display:none;';
    const placeholderStyle = defaultImage ? 'display:none;' : '';

    const html = `
<div class="doc-upload-box ${hasImg} ${width} ${height} group relative flex cursor-pointer flex-col items-center justify-center overflow-hidden rounded-lg border border-solid border-gray-300 bg-gray-50 transition-all duration-300 hover:border-blue-500 hover:bg-blue-50/30 hover:shadow-md" id="${this.uniqueId}_box">
    
    <!-- 图片层 -->
    <img class="doc-preview-img h-full w-full object-contain transition-transform duration-500 ease-out group-hover:scale-105" 
         id="${this.uniqueId}_preview" 
         src="${defaultImage}" 
         style="${imgStyle}"
         data-preview="true">

    <!-- 占位符层 -->
    <div class="doc-upload-placeholder flex flex-col items-center justify-center transition-all duration-300" style="${placeholderStyle}">
        <span class="text-4xl font-light text-gray-300 transition-all duration-300 group-hover:scale-110 group-hover:text-blue-500">${placeholder}</span>
        ${placeholderText ? `<span class="doc-placeholder-text mt-2 text-sm text-gray-400 transition-colors duration-300 group-hover:text-blue-500">${placeholderText}</span>` : ''}
    </div>

    <!-- 操作栏 -->
    <div class="doc-action-buttons absolute bottom-0 left-0 w-full transform items-center justify-center bg-black/60 py-3 backdrop-blur-[2px] transition-transform duration-300 ease-out translate-y-full group-hover:translate-y-0" style="display:none;">
        <button type="button" class="doc-replace-btn rounded-full bg-white/90 px-4 py-1 text-xs font-medium text-gray-800 shadow transition-colors hover:bg-blue-500 hover:text-white">
            ${replaceText}
        </button>
    </div>

    <input type="file" class="doc-file-input" id="${this.uniqueId}_input" accept="image/*" style="display:none;">
    <input type="hidden" class="doc-value-input" name="${fieldName}" id="${this.uniqueId}_value" value="${defaultImage}">
</div>
`;

    $(this.options.container).html(html);
    this.addStyles();
}

这段代码信息量很大,我来拆解一下:

首先是解构赋值。const { defaultImage, placeholder, ... } = this.options 把需要的配置项提取出来,后面用起来更方便。

然后是条件变量。hasImg 决定是否添加 has-img 类,imgStyleplaceholderStyle 决定图片和占位符的初始显示状态。有默认图片就显示图片、隐藏占位符,没有就反过来。

接下来是 HTML 模板。这里用了模板字符串,可以很方便地插入变量和写多行字符串。

关于 CSS 类名,这里用的是 Tailwind CSS。如果你不熟悉 Tailwind,我简单解释一下几个关键的类:

group 是 Tailwind 的一个特殊类,配合 group-hover: 前缀使用。当鼠标悬停在带有 group 类的元素上时,它的子元素可以通过 group-hover: 来响应这个悬停状态。比如 group-hover:scale-105 表示父元素被悬停时,这个元素放大到 105%。

translate-y-full 表示向下偏移自身高度的 100%,也就是完全移出可视区域。配合 group-hover:translate-y-0,就实现了"从底部滑入"的效果。

bg-black/60 表示黑色背景,透明度 60%。这是 Tailwind 3.0 引入的语法,很方便。

backdrop-blur-[2px] 是背景模糊效果,让操作栏看起来更有质感。

最后是两个 input 元素。file 类型的用于选择文件,hidden 类型的用于存储上传后的图片地址,表单提交时会带上这个值。

5.4 动态样式注入

javascript 复制代码
addStyles() {
    const styleId = 'doc-upload-box-styles';
    if (document.getElementById(styleId)) return;

    const css = `
/* 当有图片时,显示操作栏容器(flex),配合 Tailwind 的 translate 类实现滑入滑出 */
.doc-upload-box.has-img .doc-action-buttons { display: flex !important; }

/* 当有图片时,彻底隐藏占位符 */
.doc-upload-box.has-img .doc-upload-placeholder { display: none !important; }
`;

    const style = document.createElement('style');
    style.id = styleId;
    style.textContent = css;
    document.head.appendChild(style);
}

这个方法往页面里注入一些 CSS。为什么不直接写在 CSS 文件里?因为这个组件是独立的,我希望它能"即插即用",不需要额外引入 CSS 文件。

注意这里的防重复逻辑:先检查页面里有没有这个 style 元素,有就直接返回。这样即使页面上有多个上传组件,样式也只会注入一次。

这两条 CSS 规则的作用是:

第一条,当上传框有 has-img 类时,显示操作栏。操作栏默认是 display:none,有图片后变成 display:flex,配合 Tailwind 的 translate-y-fullgroup-hover:translate-y-0,就实现了悬停时从底部滑入的效果。

第二条,当有图片时隐藏占位符。虽然在 render 的时候已经通过 inline style 隐藏了,但加上这条规则更保险,也方便后续通过 JavaScript 切换状态。

5.5 事件绑定

javascript 复制代码
bindEvents() {
    // 替换按钮点击
    this.$replaceBtn.on('click', (e) => {
        e.stopPropagation();
        this.$fileInput[0].click();
    });

    // 图片点击预览
    this.bindPreviewEvent();

    // 上传框点击(仅在没有图片时)
    this.$uploadBox.on('click', (e) => {
        if ($(e.target).hasClass('doc-replace-btn')) return;
        if ($(e.target).hasClass('doc-preview-img')) return;
        if (!this.$uploadBox.hasClass('has-img')) {
            this.$fileInput[0].click();
        }
    });

    // 文件选择
    this.$fileInput.on('change', (e) => {
        const file = e.target.files[0];
        if (file) {
            this.handleFileSelect(file);
        }
    });
}

这里绑定了四个事件:

替换按钮点击。e.stopPropagation() 阻止事件冒泡,不然会触发上传框的点击事件。this.$fileInput[0].click() 触发文件选择框,注意这里要用 [0] 获取原生 DOM 元素,因为 jQuery 对象没有 click 方法(准确说是有,但行为不一样)。

图片点击预览。这个逻辑单独抽成了 bindPreviewEvent 方法,因为上传成功后也需要重新绑定。

上传框点击。这里有几个判断:如果点击的是替换按钮或预览图片,就不处理(让它们各自的事件处理器来处理);如果已经有图片了,也不处理(有图片时应该点替换按钮)。只有在没有图片的情况下,点击上传框才会触发文件选择。

文件选择。当用户选择了文件后,调用 handleFileSelect 处理上传。

5.6 文件上传处理

javascript 复制代码
handleFileSelect(file) {
    // 验证文件类型
    if (!file.type.startsWith('image/')) {
        layer.msg('Please select an image file');
        return;
    }

    // 验证文件大小
    if (file.size > this.options.maxSize * 1024 * 1024) {
        layer.msg(`Image size cannot exceed ${this.options.maxSize}MB`);
        return;
    }

    const formData = new FormData();
    formData.append('file', file);

    const loadIndex = layer.load(1, { shade: [0.1, '#fff'] });

    $.ajax({
        url: this.options.uploadUrl,
        type: 'POST',
        data: formData,
        processData: false,
        contentType: false,
        success: (response) => {
            layer.close(loadIndex);

            if (typeof response === 'string') {
                try {
                    response = JSON.parse(response);
                } catch (e) {
                    layer.msg('Response format error');
                    this.options.onError?.('Response format error');
                    return;
                }
            }

            if (response.code === 0) {
                layer.msg(response.msg || 'Upload successful');
                const picUrl = response.data.pic_src || response.data.pic;
                this.setPreview(picUrl);
                this.value = response.data.pic;
                this.$valueInput.val(response.data.pic);
                this.options.onSuccess?.(response, picUrl);
            } else {
                layer.msg(response.msg || 'Upload failed');
                this.options.onError?.(response.msg || 'Upload failed');
            }
        },
        error: () => {
            layer.close(loadIndex);
            layer.msg('Network error, please try again');
            this.options.onError?.('Network error');
        }
    });
}

这个方法是上传的核心逻辑,我来逐段分析:

首先是文件验证。file.type.startsWith('image/') 检查是否是图片文件,file.size 检查文件大小。这两个验证在前端做一遍,可以避免无效的网络请求,提升用户体验。当然,后端也要做验证,前端验证只是第一道防线。

然后是构造 FormData。FormData 是浏览器提供的 API,专门用于构造表单数据,支持文件上传。formData.append('file', file) 把文件添加进去,键名是 file,后端要用这个键名来接收。

接下来是显示 loading。layer.load(1, { shade: [0.1, '#fff'] }) 显示一个加载动画,shade 是遮罩层的配置,[0.1, '#fff'] 表示透明度 0.1、颜色白色。返回值 loadIndex 用于后面关闭 loading。

然后是 Ajax 请求。这里有两个关键配置:

processData: false 告诉 jQuery 不要处理 data,因为 FormData 已经是处理好的格式了。

contentType: false 告诉 jQuery 不要设置 Content-Type,让浏览器自动设置。上传文件时,Content-Type 应该是 multipart/form-data,而且还要带上 boundary 参数,浏览器会自动处理这些。

成功回调里,先判断 response 是不是字符串。有些后端返回的是 JSON 字符串而不是对象,需要手动解析。这是一个兼容性处理,实际项目中最好和后端约定好返回格式。

response.data.pic_src || response.data.pic 这里用了短路运算,优先取 pic_src,没有的话取 pic。这也是为了兼容不同的后端接口。

5.7 预览与状态管理

javascript 复制代码
setPreview(imgUrl) {
    this.$previewImg.attr('src', imgUrl).css('display', 'block');
    this.$placeholder.css('display', 'none');
    this.$placeholderText.css('display', 'none');
    this.$uploadBox.addClass('has-img');
    this.value = imgUrl;
    this.$valueInput.val(imgUrl);
    this.bindPreviewEvent();
}

bindPreviewEvent() {
    const self = this;
    this.$previewImg.off('click').on('click', function (e) {
        e.stopPropagation();
        const imgSrc = $(this).attr('src');
        if (imgSrc) {
            layer.open({
                type: 1,
                title: false,
                closeBtn: 1,
                shadeClose: true,
                area: ['auto', 'auto'],
                maxWidth: window.innerWidth * 0.9,
                maxHeight: window.innerHeight * 0.9,
                content: `<img src="${imgSrc}" style="max-width:${window.innerWidth * 0.85}px;max-height:${window.innerHeight * 0.85}px;display:block;">`,
                skin: 'layui-layer-nobg'
            });
        }
    });
}

reset() {
    this.$previewImg.attr('src', '').css('display', 'none');
    this.$placeholder.css('display', 'block');
    this.$placeholderText.css('display', 'block');
    this.$uploadBox.removeClass('has-img');
    this.$valueInput.val('');
    this.$fileInput.val('');
    this.value = '';
}

getValue() {
    return this.$valueInput.val();
}

setPreview 方法做了这些事:显示图片、隐藏占位符、添加 has-img 类、更新 value、绑定预览事件。这个方法在上传成功后会调用,也可以在初始化时用来显示默认图片。

bindPreviewEvent 实现了点击图片查看大图的功能。这里用了 layer.open 打开一个弹窗,type: 1 表示页面层(可以放任意 HTML),shadeClose: true 表示点击遮罩层可以关闭,skin: 'layui-layer-nobg' 去掉弹窗的背景色。

图片的最大宽高设置为视口的 85%,这样在大屏幕上不会太大,在小屏幕上也不会超出屏幕。

reset 方法把组件恢复到初始状态。注意 this.$fileInput.val('') 这一行,清空 file input 的值,不然用户选择同一个文件时不会触发 change 事件。

5.8 只读模式

javascript 复制代码
setReadOnly() {
    // 禁用所有交互
    this.$uploadBox.css({
        'pointer-events': 'none',
        'cursor': 'default'
    });

    // 移除 hover 效果的类
    this.$uploadBox.removeClass('hover:border-blue-500 hover:bg-blue-50/30 hover:shadow-md');

    // 如果没有图片,显示"未上传"提示
    if (!this.options.defaultImage) {
        // 移除 + 号图标
        this.$container.find('.doc-upload-placeholder span:first-child').remove();
        
        // 替换提示文字为"未上传"
        const docType = this.options.placeholderText || 'Document';
        this.$placeholderText.text(`No ${docType} uploaded`)
            .removeClass('group-hover:text-blue-500')
            .addClass('text-gray-400');
    } else {
        // 有图片时,移除占位符相关元素
        this.$container.find('.doc-upload-placeholder span:first-child').remove();
        this.$container.find('.doc-placeholder-text').removeClass('group-hover:text-blue-500');
    }

    // 如果有图片,允许点击预览(但不允许替换)
    this.$previewImg.css({
        'pointer-events': 'auto',
        'cursor': 'pointer'
    });

    // 隐藏替换按钮
    this.$container.find('.doc-action-buttons').remove();
}

只读模式在审核页面很常用,用户只能查看,不能修改。这个方法做了几件事:

禁用整个上传框的交互。pointer-events: none 让元素不响应任何鼠标事件。

移除 hover 效果。Tailwind 的 hover 类是通过 CSS 实现的,用 JavaScript 移除类名就能去掉效果。

处理占位符。如果没有图片,把加号和提示文字改成"未上传";如果有图片,移除占位符相关的元素。

保留图片预览功能。虽然整个上传框禁用了交互,但图片元素单独设置了 pointer-events: auto,这样用户还是可以点击图片查看大图。

移除替换按钮。直接从 DOM 里删掉,比隐藏更彻底。

5.9 销毁方法

javascript 复制代码
destroy() {
    $(this.options.container).empty();
}

这个方法很简单,就是清空容器。在单页应用里,组件不用了要记得销毁,不然可能会有内存泄漏。

虽然这个方法只有一行,但提供它是个好习惯。以后如果需要做更多清理工作(比如解绑全局事件、清除定时器),直接在这个方法里加就行。

六、工具函数

javascript 复制代码
function setUploadReadOnly(containerId) {
    const $container = $(containerId);
    const $uploadBox = $container.find('.doc-upload-box');

    // 禁用所有交互
    $uploadBox.css({
        'pointer-events': 'none',
        'cursor': 'default'
    });

    // 移除 hover 效果的类
    $uploadBox.removeClass('hover:border-blue-500 hover:bg-blue-50/30 hover:shadow-md');

    // 修改占位符样式
    $container.find('.doc-upload-placeholder span:first-child').remove();
    $container.find('.doc-placeholder-text').removeClass('group-hover:text-blue-500');

    // 如果有图片,允许点击预览
    $container.find('.doc-preview-img').css({
        'pointer-events': 'auto',
        'cursor': 'pointer'
    });

    // 隐藏替换按钮
    $container.find('.doc-action-buttons').remove();
}

这个函数和 DocUploadBox.setReadOnly 方法功能类似,但它是独立的,可以用于已经创建好的组件实例。

什么时候会用到?比如页面上有多个上传组件,初始化的时候不知道是否只读,后来根据某个条件需要把它们都设成只读。这时候如果没有保存组件实例的引用,就可以用这个函数。

不过代码注释里标记了 @deprecated,意思是"已弃用"。推荐的做法是在初始化时就传入 readOnly: true,而不是后来再调用这个函数。为什么?因为初始化时设置只读,可以避免不必要的事件绑定,性能更好,代码也更清晰。

七、使用示例

7.1 AvatarUploadLayui 基础用法

HTML 结构:

html 复制代码
<div class="avatar-upload-wrapper">
    <!-- 上传触发区域 -->
    <div id="avatarUpload" class="upload-trigger">
        <!-- 占位符 -->
        <div id="uploadPlaceholder" class="placeholder">
            <i class="layui-icon layui-icon-upload"></i>
            <span>点击上传头像</span>
        </div>
        <!-- 预览图 -->
        <img id="avatarPreview" class="layui-hide" src="" alt="头像预览">
    </div>
    <!-- 存储上传后的图片地址 -->
    <input type="hidden" id="avatarValue" name="avatar">
</div>

JavaScript 初始化:

javascript 复制代码
const avatarUpload = new AvatarUploadLayui({
    elem: '#avatarUpload',
    uploadUrl: '/api/upload/avatar',
    placeholderId: 'uploadPlaceholder',
    previewId: 'avatarPreview',
    valueId: 'avatarValue',
    previewSize: 200,
    onSuccess: (res) => {
        console.log('上传成功,图片地址:', res.data.pic);
    },
    onError: (res) => {
        console.error('上传失败:', res);
    }
});

编辑页面显示已有头像:

javascript 复制代码
// 假设从接口获取到了用户数据
const userData = await fetchUserData();
if (userData.avatar) {
    avatarUpload.setPreview(userData.avatar);
}

表单提交时获取值:

javascript 复制代码
$('#submitBtn').on('click', () => {
    const avatar = avatarUpload.getValue();
    // 或者直接从隐藏 input 获取
    // const avatar = $('#avatarValue').val();
    
    $.post('/api/user/update', { avatar }, (res) => {
        // 处理响应
    });
});

7.2 DocUploadBox 基础用法

HTML 结构(只需要一个容器):

html 复制代码
<div id="idCardFront"></div>

JavaScript 初始化:

javascript 复制代码
const idCardUpload = new DocUploadBox({
    container: '#idCardFront',
    uploadUrl: '/api/upload/document',
    fieldName: 'id_card_front',
    width: 'w-[300px]',
    height: 'h-[200px]',
    placeholder: '+',
    placeholderText: '身份证正面',
    maxSize: 5,
    onSuccess: (res, picUrl) => {
        console.log('上传成功:', picUrl);
    },
    onError: (msg) => {
        console.error('上传失败:', msg);
    }
});

7.3 响应式布局

利用 Tailwind 的响应式类,可以轻松实现不同屏幕尺寸下的不同大小:

javascript 复制代码
const docUpload = new DocUploadBox({
    container: '#uploadContainer',
    uploadUrl: '/api/upload',
    fieldName: 'document',
    // 移动端 200px,平板 300px,桌面 400px
    width: 'w-[200px] md:w-[300px] lg:w-[400px]',
    height: 'h-[150px] md:h-[200px] lg:h-[250px]'
});

7.4 只读模式

审核页面通常需要只读模式:

javascript 复制代码
const docUpload = new DocUploadBox({
    container: '#uploadContainer',
    uploadUrl: '/api/upload',
    fieldName: 'document',
    defaultImage: 'https://example.com/uploaded-image.jpg',
    readOnly: true  // 开启只读模式
});

7.5 多个上传组件

一个页面上可以有多个上传组件,它们互不干扰:

javascript 复制代码
const uploads = {};

// 身份证正面
uploads.idFront = new DocUploadBox({
    container: '#idFront',
    uploadUrl: '/api/upload',
    fieldName: 'id_front',
    placeholderText: '身份证正面'
});

// 身份证背面
uploads.idBack = new DocUploadBox({
    container: '#idBack',
    uploadUrl: '/api/upload',
    fieldName: 'id_back',
    placeholderText: '身份证背面'
});

// 营业执照
uploads.license = new DocUploadBox({
    container: '#license',
    uploadUrl: '/api/upload',
    fieldName: 'business_license',
    placeholderText: '营业执照',
    width: 'w-[400px]',
    height: 'h-[300px]'
});

// 表单提交时获取所有值
$('#submitBtn').on('click', () => {
    const formData = {
        idFront: uploads.idFront.getValue(),
        idBack: uploads.idBack.getValue(),
        license: uploads.license.getValue()
    };
    
    // 验证必填项
    if (!formData.idFront || !formData.idBack) {
        layer.msg('请上传身份证正反面');
        return;
    }
    
    // 提交表单
    $.post('/api/submit', formData, (res) => {
        // 处理响应
    });
});

7.6 动态创建和销毁

在单页应用中,可能需要动态创建和销毁组件:

javascript 复制代码
let uploadInstance = null;

// 打开弹窗时创建
function openUploadModal() {
    layer.open({
        type: 1,
        title: '上传文件',
        content: '<div id="dynamicUpload"></div>',
        success: () => {
            uploadInstance = new DocUploadBox({
                container: '#dynamicUpload',
                uploadUrl: '/api/upload',
                fieldName: 'file'
            });
        },
        end: () => {
            // 关闭弹窗时销毁
            if (uploadInstance) {
                uploadInstance.destroy();
                uploadInstance = null;
            }
        }
    });
}

八、设计思想与最佳实践

8.1 为什么用 Class 而不是函数

ES6 的 Class 语法让代码更清晰、更易维护。虽然 JavaScript 的 Class 本质上还是基于原型的,但它提供了更直观的语法:

javascript 复制代码
// Class 写法
class DocUploadBox {
    constructor(options) { /* ... */ }
    init() { /* ... */ }
    render() { /* ... */ }
}

// 传统函数写法
function DocUploadBox(options) { /* ... */ }
DocUploadBox.prototype.init = function() { /* ... */ };
DocUploadBox.prototype.render = function() { /* ... */ };

Class 写法的优势:

  1. 所有方法都在一个代码块里,一目了然
  2. 不用写 prototype,代码更简洁
  3. constructor 明确标识了构造函数
  4. 更容易实现继承(虽然这个组件没用到)

8.2 配置对象模式

两个组件都采用了配置对象模式,即把所有参数放在一个对象里传入:

javascript 复制代码
// 配置对象模式
new DocUploadBox({
    container: '#upload',
    uploadUrl: '/api/upload',
    fieldName: 'file',
    maxSize: 10
});

// 对比:多参数模式
new DocUploadBox('#upload', '/api/upload', 'file', 10);

配置对象模式的优势:

  1. 参数有名字,代码自解释
  2. 参数顺序无所谓,不容易搞混
  3. 可以只传需要的参数,其他用默认值
  4. 扩展新参数不影响已有代码

8.3 关于默认值的处理

代码里用了 || 运算符来设置默认值:

javascript 复制代码
this.options = {
    maxSize: options.maxSize || 5,
    readOnly: options.readOnly || false
};

这种写法有个小坑:如果用户传入 0false,也会被当成"没传"而使用默认值。

对于 maxSize,传 0 没有意义,所以问题不大。但对于 readOnly,如果用户显式传入 false,我们应该尊重这个值。

更严谨的写法是用 ??(空值合并运算符)或者显式判断 undefined

javascript 复制代码
// 空值合并运算符(只有 null 和 undefined 才会使用默认值)
maxSize: options.maxSize ?? 5,
readOnly: options.readOnly ?? false

// 或者显式判断
readOnly: options.readOnly !== undefined ? options.readOnly : false

不过在这个组件里,|| 的行为是符合预期的,因为没人会传 maxSize: 0readOnly: false(false 本来就是默认值)。

8.4 事件委托 vs 直接绑定

代码里的事件绑定用的是直接绑定:

javascript 复制代码
this.$replaceBtn.on('click', (e) => { /* ... */ });

另一种方式是事件委托:

javascript 复制代码
this.$container.on('click', '.doc-replace-btn', (e) => { /* ... */ });

事件委托的优势是:即使元素是后来动态添加的,事件也能生效。但在这个组件里,DOM 结构是固定的,不会动态添加元素,所以直接绑定就够了。

直接绑定的好处是性能稍好一点(不需要事件冒泡和选择器匹配),而且代码意图更明确。

8.5 为什么要缓存 DOM 元素

cacheElements 方法把常用的 DOM 元素存到实例属性里:

javascript 复制代码
cacheElements() {
    this.$container = $(this.options.container);
    this.$uploadBox = this.$container.find('.doc-upload-box');
    this.$previewImg = this.$container.find('.doc-preview-img');
    // ...
}

这样做的好处:

  1. 性能优化。虽然现代浏览器的 DOM 查询很快,但频繁查询还是有开销的。缓存之后,后续操作直接用缓存的引用,不需要再查询。

  2. 代码更简洁。this.$previewImgthis.$container.find('.doc-preview-img') 短多了。

  3. 便于调试。可以在控制台直接访问 instance.$previewImg 查看元素状态。

8.6 错误处理策略

组件里的错误处理采用了"优雅降级"的策略:

javascript 复制代码
if (!options.container) {
    console.error('DocUploadBox: container 参数是必填的');
    return;
}

console.error 而不是 throw new Error,是因为:

  1. 不会中断页面其他 JavaScript 的执行
  2. 开发者可以在控制台看到错误信息
  3. 用户不会看到一个吓人的错误页面

当然,这种策略也有缺点:错误可能被忽略。如果你的项目有完善的错误监控系统,抛异常可能更合适,这样可以被监控系统捕获。

8.7 关于 this 的处理

JavaScript 里 this 的指向是个老大难问题。这个组件里用了两种方式来处理:

第一种是箭头函数:

javascript 复制代码
this.$replaceBtn.on('click', (e) => {
    e.stopPropagation();
    this.$fileInput[0].click();  // this 指向组件实例
});

箭头函数没有自己的 this,它会捕获外层的 this。在这里,外层的 this 就是组件实例。

第二种是保存引用:

javascript 复制代码
bindPreviewEvent() {
    const self = this;
    this.$previewImg.off('click').on('click', function (e) {
        // 这里如果用 this,指向的是被点击的元素
        // 用 self 才能访问组件实例
        const imgSrc = $(this).attr('src');
        // ...
    });
}

这里用普通函数而不是箭头函数,是因为需要用 this 来访问被点击的元素。同时用 self 保存组件实例的引用,以备不时之需(虽然这段代码里没用到 self)。

其实这段代码可以改成箭头函数 + e.currentTarget

javascript 复制代码
this.$previewImg.off('click').on('click', (e) => {
    const imgSrc = $(e.currentTarget).attr('src');
    // ...
});

两种写法都可以,看个人习惯。

九、Tailwind CSS 在组件中的应用

9.1 为什么选择 Tailwind

这个组件的样式用的是 Tailwind CSS,而不是传统的 CSS 文件。原因有几个:

  1. 组件更独立。不需要额外引入 CSS 文件,只要项目里有 Tailwind 就能用。

  2. 样式即文档。看类名就知道样式是什么,不用在 CSS 文件里找。

  3. 响应式很方便。md:w-[300px] 一个类就搞定了媒体查询。

  4. 状态样式很直观。hover:bg-blue-500group-hover:scale-105 这些类名一看就懂。

9.2 group 和 group-hover 的妙用

Tailwind 的 group 功能在这个组件里用得很多:

html 复制代码
<div class="group ...">
    <img class="group-hover:scale-105" ...>
    <div class="group-hover:translate-y-0" ...>
</div>

group 类标记了一个"组",它的子元素可以用 group-hover: 前缀来响应这个组的悬停状态。

这个功能特别适合做"悬停时子元素变化"的效果。比如这个组件里:

  • 悬停时图片放大 5%(group-hover:scale-105
  • 悬停时操作栏从底部滑入(group-hover:translate-y-0
  • 悬停时加号变蓝(group-hover:text-blue-500

如果用传统 CSS,需要写:

css 复制代码
.upload-box:hover .preview-img {
    transform: scale(1.05);
}
.upload-box:hover .action-bar {
    transform: translateY(0);
}

Tailwind 的写法更直观,而且不用起类名、不用写 CSS 文件。

9.3 任意值语法

Tailwind 3.0 引入了任意值语法,用方括号包裹:

html 复制代码
<div class="w-[300px] h-[200px]">

这样就不用在配置文件里定义 w-300 这样的类了。对于组件来说特别方便,因为用户可能需要各种尺寸。

9.4 透明度语法

html 复制代码
<div class="bg-black/60">

bg-black/60 表示黑色背景,透明度 60%。这是 Tailwind 3.0 的语法糖,等价于 background-color: rgba(0, 0, 0, 0.6)

9.5 动画与过渡

组件里用了很多过渡效果:

html 复制代码
<div class="transition-all duration-300 ease-out">
  • transition-all:所有属性都有过渡效果
  • duration-300:过渡时长 300ms
  • ease-out:缓动函数,开始快结束慢

还有 transition-transformtransition-colors 这些更精确的类,只对特定属性应用过渡。

9.6 backdrop-blur 毛玻璃效果

html 复制代码
<div class="backdrop-blur-[2px]">

这个类给操作栏加了毛玻璃效果,让它看起来更有质感。backdrop-blur 是 CSS 的 backdrop-filter: blur() 属性,可以模糊元素后面的内容。

注意这个属性在某些旧浏览器上不支持,但不影响功能,只是没有模糊效果而已。

十、常见问题与解决方案

10.1 上传后图片不显示

可能的原因:

  1. 后端返回的图片地址不对。检查 response.data.picresponse.data.pic_src 是否是完整的 URL。

  2. 图片地址是相对路径,但当前页面的 base URL 不对。可以在后端返回完整的 URL,或者在前端拼接。

  3. 跨域问题。如果图片在 CDN 上,可能需要配置 CORS。

解决方案:

javascript 复制代码
onSuccess: (res, picUrl) => {
    console.log('返回的图片地址:', picUrl);
    // 如果是相对路径,拼接成完整 URL
    if (!picUrl.startsWith('http')) {
        picUrl = 'https://your-cdn.com' + picUrl;
    }
}

10.2 选择同一个文件不触发上传

这是浏览器的行为:如果 file input 的值没变,change 事件不会触发。

解决方案是在每次上传后清空 file input 的值:

javascript 复制代码
// 在 handleFileSelect 的 success 回调里
this.$fileInput.val('');

组件的 reset 方法里已经有这个处理了,但如果你只是上传成功而不是重置,也需要清空。

10.3 大文件上传超时

默认的 Ajax 请求没有设置超时时间,大文件可能会超时。

解决方案:

javascript 复制代码
$.ajax({
    url: this.options.uploadUrl,
    type: 'POST',
    data: formData,
    processData: false,
    contentType: false,
    timeout: 60000,  // 60 秒超时
    // ...
});

或者在后端实现分片上传,但那就是另一个话题了。

10.4 移动端点击不灵敏

移动端的 click 事件有 300ms 延迟,可能导致点击不灵敏。

解决方案:

  1. 使用 FastClick 库消除延迟
  2. 或者用 touchend 事件代替 click
javascript 复制代码
this.$uploadBox.on('click touchend', (e) => {
    if (e.type === 'touchend') {
        e.preventDefault();  // 防止触发 click
    }
    // 处理逻辑
});

10.5 IE 兼容性

这个组件用了很多现代 JavaScript 特性(Class、箭头函数、模板字符串、可选链),不支持 IE。

如果需要支持 IE,可以用 Babel 转译,但 Tailwind CSS 的一些特性(比如 CSS 变量)在 IE 上也不支持。

说实话,2024 年了,建议直接放弃 IE 支持。微软自己都放弃了。

10.6 与 Vue/React 集成

这个组件是基于 jQuery 的,在 Vue 或 React 项目里使用需要注意:

  1. 在组件挂载后初始化(Vue 的 mounted,React 的 useEffect
  2. 在组件卸载前销毁(调用 destroy 方法)
  3. 避免和框架的响应式系统冲突

Vue 示例:

javascript 复制代码
export default {
    mounted() {
        this.uploadInstance = new DocUploadBox({
            container: this.$refs.uploadContainer,
            uploadUrl: '/api/upload',
            fieldName: 'file',
            onSuccess: (res) => {
                this.imageUrl = res.data.pic;  // 更新 Vue 的数据
            }
        });
    },
    beforeUnmount() {
        this.uploadInstance?.destroy();
    }
}

不过说实话,如果项目是 Vue 或 React,建议用对应框架的上传组件,比如 Element UI 的 el-upload 或 Ant Design 的 Upload。

十一、性能优化建议

11.1 图片压缩

上传前在前端压缩图片,可以减少上传时间和服务器存储压力:

javascript 复制代码
function compressImage(file, maxWidth = 1920, quality = 0.8) {
    return new Promise((resolve) => {
        const reader = new FileReader();
        reader.onload = (e) => {
            const img = new Image();
            img.onload = () => {
                const canvas = document.createElement('canvas');
                let width = img.width;
                let height = img.height;
                
                if (width > maxWidth) {
                    height = (maxWidth / width) * height;
                    width = maxWidth;
                }
                
                canvas.width = width;
                canvas.height = height;
                
                const ctx = canvas.getContext('2d');
                ctx.drawImage(img, 0, 0, width, height);
                
                canvas.toBlob(resolve, 'image/jpeg', quality);
            };
            img.src = e.target.result;
        };
        reader.readAsDataURL(file);
    });
}

// 在 handleFileSelect 里使用
async handleFileSelect(file) {
    // 如果文件大于 1MB,压缩一下
    if (file.size > 1024 * 1024) {
        file = await compressImage(file);
    }
    // 继续上传逻辑
}

11.2 懒加载

如果页面上有很多上传组件,可以考虑懒加载,只有滚动到可视区域才初始化:

javascript 复制代码
const observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
        if (entry.isIntersecting) {
            const container = entry.target;
            new DocUploadBox({
                container: container,
                // ...
            });
            observer.unobserve(container);  // 初始化后取消观察
        }
    });
});

document.querySelectorAll('.upload-container').forEach((el) => {
    observer.observe(el);
});

11.3 防抖处理

如果用户快速点击上传按钮,可能会触发多次上传。可以加个防抖:

javascript 复制代码
handleFileSelect(file) {
    if (this.uploading) return;  // 正在上传中,忽略
    this.uploading = true;
    
    // 上传逻辑...
    
    // 在 success 和 error 回调里
    this.uploading = false;
}

11.4 预加载

如果知道用户接下来可能会上传图片,可以预加载上传接口,减少首次上传的延迟:

javascript 复制代码
// 页面加载时预热接口
fetch('/api/upload', { method: 'OPTIONS' });

这会触发一次 CORS 预检请求,后续的实际上传请求就不需要预检了。

十二、扩展思路

12.1 添加裁剪功能

很多场景需要裁剪图片,比如头像要求正方形。可以集成 Cropper.js:

javascript 复制代码
// 选择文件后,先打开裁剪弹窗
handleFileSelect(file) {
    const reader = new FileReader();
    reader.onload = (e) => {
        this.openCropper(e.target.result, (croppedBlob) => {
            this.uploadFile(croppedBlob);
        });
    };
    reader.readAsDataURL(file);
}

openCropper(imageSrc, callback) {
    layer.open({
        type: 1,
        title: '裁剪图片',
        content: `<div id="cropperContainer"><img src="${imageSrc}"></div>`,
        btn: ['确定', '取消'],
        success: () => {
            this.cropper = new Cropper(document.querySelector('#cropperContainer img'), {
                aspectRatio: 1,  // 正方形
                viewMode: 1
            });
        },
        yes: () => {
            this.cropper.getCroppedCanvas().toBlob(callback);
            layer.closeAll();
        }
    });
}

12.2 添加拖拽上传

拖拽上传体验更好:

javascript 复制代码
bindDragEvents() {
    this.$uploadBox.on('dragover', (e) => {
        e.preventDefault();
        this.$uploadBox.addClass('drag-over');
    });
    
    this.$uploadBox.on('dragleave', () => {
        this.$uploadBox.removeClass('drag-over');
    });
    
    this.$uploadBox.on('drop', (e) => {
        e.preventDefault();
        this.$uploadBox.removeClass('drag-over');
        
        const file = e.originalEvent.dataTransfer.files[0];
        if (file) {
            this.handleFileSelect(file);
        }
    });
}

12.3 添加进度条

显示上传进度,体验更好:

javascript 复制代码
$.ajax({
    url: this.options.uploadUrl,
    type: 'POST',
    data: formData,
    processData: false,
    contentType: false,
    xhr: () => {
        const xhr = new XMLHttpRequest();
        xhr.upload.addEventListener('progress', (e) => {
            if (e.lengthComputable) {
                const percent = Math.round((e.loaded / e.total) * 100);
                this.updateProgress(percent);
            }
        });
        return xhr;
    },
    // ...
});

updateProgress(percent) {
    // 更新进度条 UI
    this.$progressBar.css('width', percent + '%');
    this.$progressText.text(percent + '%');
}

12.4 多文件上传

如果需要一次上传多个文件:

javascript 复制代码
// 修改 file input
<input type="file" multiple accept="image/*">

// 修改 handleFileSelect
handleFileSelect(files) {
    Array.from(files).forEach((file) => {
        this.uploadSingleFile(file);
    });
}

当然,多文件上传的 UI 也需要相应调整,比如显示文件列表、每个文件的上传状态等。

十三、总结

回顾一下这篇文章的内容:

我们从零开始封装了两个图片上传组件。AvatarUploadLayui 是一个轻量级的头像上传组件,基于 Layui 的 upload 模块,适合简单的头像上传场景。DocUploadBox 是一个功能更丰富的文档上传组件,支持自定义样式、只读模式、点击预览等功能,适合证件照、文档等上传场景。

在封装过程中,我们用到了很多实用的技巧:

  • IIFE 避免全局污染
  • Class 语法组织代码
  • 配置对象模式处理参数
  • 事件委托和直接绑定的选择
  • DOM 元素缓存优化性能
  • Tailwind CSS 实现样式
  • group-hover 实现联动效果

这两个组件的代码量不大,但涵盖了前端组件封装的很多知识点。希望通过这篇文章,你不仅学会了怎么用这两个组件,更重要的是理解了背后的设计思想和实现技巧。

下次遇到类似的需求,你也可以自己封装一个组件了。

如果你有任何问题或建议,欢迎在评论区留言交流。


十四、完整代码

完整封装代码如下:

js 复制代码
/**
 * 头像上传组件 (基于 Layui upload)
 * 依赖: Layui (upload, layer)
 *
 * 使用示例:
 * const avatarUpload = new AvatarUploadLayui({
 *     elem: '#avatarUpload',
 *     uploadUrl: '/index/uploadpiccompress/',
 *     placeholderId: 'uploadPlaceholder',
 *     previewId: 'avatarPreview',
 *     valueId: 'avatarValue',
 *     onSuccess: (res) => console.log('上传成功:', res)
 * });
 */

(function (window) {
    'use strict';

    const $ = layui.$;
    const layer = layui.layer;

    /**
     * 头像上传组件
     */
    class AvatarUploadLayui {
        constructor(options) {
            if (!options.elem) {
                console.error('AvatarUploadLayui: elem 参数是必填的');
                return;
            }
            if (!options.uploadUrl) {
                console.error('AvatarUploadLayui: uploadUrl 参数是必填的');
                return;
            }

            this.options = {
                elem: options.elem,
                uploadUrl: options.uploadUrl,
                placeholderId: options.placeholderId || 'uploadPlaceholder',
                previewId: options.previewId || 'avatarPreview',
                valueId: options.valueId || 'avatarValue',
                previewSize: options.previewSize || 200,
                onSuccess: options.onSuccess || null,
                onError: options.onError || null
            };

            this.tipsIndex = null;
            this.init();
        }

        init() {
            layui.use(['upload', 'layer'], () => {
                const upload = layui.upload;

                this.uploadInst = upload.render({
                    elem: this.options.elem,
                    url: this.options.uploadUrl,
                    before: (obj) => {
                        obj.preview((index, file, result) => {
                            $(`#${this.options.previewId}`).removeClass('layui-hide').attr('src', result);
                            $(`#${this.options.placeholderId}`).addClass('layui-hide');
                        });
                        layer.msg('Uploading...', { icon: 16, time: 0 });
                    },
                    done: (res) => {
                        layer.closeAll('loading');
                        if (res.code > 0) {
                            layer.msg('Upload failed');
                            this.options.onError?.(res);
                        } else {
                            layer.msg('Upload successful');
                            $(`#${this.options.placeholderId}`).parent().addClass('layui-hide');
                            $(`#${this.options.previewId}`).removeClass('layui-hide');
                            $(`#${this.options.valueId}`).val(res.data.pic);
                            this.bindHoverPreview();
                            this.options.onSuccess?.(res);
                        }
                    },
                    error: () => {
                        layer.closeAll('loading');
                        layer.msg('Upload error');
                        $(`#${this.options.previewId}`).addClass('layui-hide');
                        $(`#${this.options.placeholderId}`).removeClass('layui-hide');
                        this.options.onError?.();
                    }
                });
            });
        }

        bindHoverPreview() {
            const avatarPreview = $(`#${this.options.previewId}`);
            const size = this.options.previewSize;

            avatarPreview.off('mouseenter').on('mouseenter', (e) => {
                const imgSrc = $(e.currentTarget).attr('src');
                this.tipsIndex = layer.tips(
                    `<img src="${imgSrc}" style="width:${size}px;height:${size}px;object-fit:cover;border-radius:8px;">`,
                    e.currentTarget,
                    { tips: [2, '#fff'], time: 0, area: [`${size + 20}px`, `${size + 20}px`] }
                );
            });

            avatarPreview.off('mouseleave').on('mouseleave', () => {
                if (this.tipsIndex !== null) {
                    layer.close(this.tipsIndex);
                    this.tipsIndex = null;
                }
            });

            avatarPreview.off('click').on('click', () => {
                $(`#${this.options.elem.replace('#', '')}`).click();
            });
        }

        setPreview(imgUrl) {
            $(`#${this.options.placeholderId}`).parent().addClass('layui-hide');
            $(`#${this.options.previewId}`).removeClass('layui-hide').attr('src', imgUrl);
            this.bindHoverPreview();
        }

        reset() {
            $(`#${this.options.placeholderId}`).parent().removeClass('layui-hide');
            $(`#${this.options.previewId}`).addClass('layui-hide').attr('src', '');
            $(`#${this.options.valueId}`).val('');
        }

        getValue() {
            return $(`#${this.options.valueId}`).val();
        }
    }

    /**
     * 证件/文档上传组件 (带替换按钮样式,使用 Tailwind CSS)
     * 依赖: Layui (layer), jQuery, Tailwind CSS
     *
     * 使用示例:
     * const docUpload = new DocUploadBox({
     *     container: '#uploadContainer',
     *     uploadUrl: '/index/uploadpiccompress',
     *     fieldName: 'identity_file1',
     *     width: 'w-[300px]',
     *     height: 'h-[200px]',
     *     defaultImage: '',
     *     replaceText: 'Replace',
     *     placeholder: '+',
     *     maxSize: 5,
     *     onSuccess: (res, picUrl) => console.log('上传成功:', res)
     * });
     */
    class DocUploadBox {
        constructor(options) {
            if (!options.container) {
                console.error('DocUploadBox: container 参数是必填的');
                return;
            }
            if (!options.uploadUrl) {
                console.error('DocUploadBox: uploadUrl 参数是必填的');
                return;
            }
            if (!options.fieldName) {
                console.error('DocUploadBox: fieldName 参数是必填的');
                return;
            }

            this.options = {
                container: options.container,
                uploadUrl: options.uploadUrl,
                fieldName: options.fieldName,
                width: options.width || 'w-[300px]',
                height: options.height || 'h-[200px]',
                defaultImage: options.defaultImage || '',
                replaceText: options.replaceText || '替换',
                placeholder: options.placeholder || '+',
                placeholderText: options.placeholderText || '',  // 新增:+号下方的提示文字
                maxSize: options.maxSize || 5,
                readOnly: options.readOnly || false,  // 新增:是否只读模式,默认 false
                onSuccess: options.onSuccess || null,
                onError: options.onError || null
            };

            this.uniqueId = `doc_upload_${Math.random().toString(36).slice(2, 11)}`;
            this.value = '';
            this.init();
        }

        init() {
            this.render();
            this.cacheElements();

            if (this.options.defaultImage) {
                this.setPreview(this.options.defaultImage);
            }

            // 如果是只读模式,应用只读样式
            if (this.options.readOnly) {
                this.setReadOnly();
            } else {
                this.bindEvents();
            }
        }

        cacheElements() {
            this.$container = $(this.options.container);
            this.$uploadBox = this.$container.find('.doc-upload-box');
            this.$previewImg = this.$container.find('.doc-preview-img');
            this.$placeholder = this.$container.find('.doc-upload-placeholder');
            this.$placeholderText = this.$container.find('.doc-placeholder-text');
            this.$replaceBtn = this.$container.find('.doc-replace-btn');
            this.$fileInput = this.$container.find('.doc-file-input');
            this.$valueInput = this.$container.find('.doc-value-input');
        }

        render() {
            const { defaultImage, placeholder, placeholderText, replaceText, fieldName, width, height } = this.options;
            const hasImg = defaultImage ? 'has-img' : '';
            // 保持原来的显示逻辑
            const imgStyle = defaultImage ? '' : 'display:none;';
            const placeholderStyle = defaultImage ? 'display:none;' : '';

            const html = `
        <div class="doc-upload-box ${hasImg} ${width} ${height} group relative flex cursor-pointer flex-col items-center justify-center overflow-hidden rounded-lg border border-solid border-gray-300 bg-gray-50 transition-all duration-300 hover:border-blue-500 hover:bg-blue-50/30 hover:shadow-md" id="${this.uniqueId}_box">
            
            <!-- 图片层:放大效果-->
            <img class="doc-preview-img h-full w-full object-contain transition-transform duration-500 ease-out group-hover:scale-105" 
                 id="${this.uniqueId}_preview" 
                 src="${defaultImage}" 
                 style="${imgStyle}"
                 data-preview="true">

            <!-- 占位符层:添加 group-hover 变色逻辑 -->
            <div class="doc-upload-placeholder flex flex-col items-center justify-center transition-all duration-300" style="${placeholderStyle}">
                <!-- 图标/符号:悬停变蓝,轻微放大 -->
                <span class="text-4xl font-light text-gray-300 transition-all duration-300 group-hover:scale-110 group-hover:text-blue-500">${placeholder}</span>
                
                <!-- 提示文字:悬停变蓝 -->
                ${placeholderText ? `<span class="doc-placeholder-text mt-2 text-sm text-gray-400 transition-colors duration-300 group-hover:text-blue-500">${placeholderText}</span>` : ''}
            </div>

            <!-- 操作栏:改为底部滑入 (Slide Up) -->
            <!-- translate-y-full 把它推到容器外面,悬停时变回 0 -->
            <div class="doc-action-buttons absolute bottom-0 left-0 w-full transform items-center justify-center bg-black/60 py-3 backdrop-blur-[2px] transition-transform duration-300 ease-out translate-y-full group-hover:translate-y-0" style="display:none;">
                <button type="button" class="doc-replace-btn rounded-full bg-white/90 px-4 py-1 text-xs font-medium text-gray-800 shadow transition-colors hover:bg-blue-500 hover:text-white">
                    ${replaceText}
                </button>
            </div>

            <input type="file" class="doc-file-input" id="${this.uniqueId}_input" accept="image/*" style="display:none;">
            <input type="hidden" class="doc-value-input" name="${fieldName}" id="${this.uniqueId}_value" value="${defaultImage}">
        </div>
    `;

            $(this.options.container).html(html);
            this.addStyles();
        }




        addStyles() {
            const styleId = 'doc-upload-box-styles';
            if (document.getElementById(styleId)) return;

            const css = `
        /* 当有图片时,显示操作栏容器(flex),配合 Tailwind 的 translate 类实现滑入滑出 */
        .doc-upload-box.has-img .doc-action-buttons { display: flex !important; }
        
        /* 当有图片时,彻底隐藏占位符 */
        .doc-upload-box.has-img .doc-upload-placeholder { display: none !important; }
    `;

            const style = document.createElement('style');
            style.id = styleId;
            style.textContent = css;
            document.head.appendChild(style);
        }



        bindEvents() {
            // 替换按钮点击
            this.$replaceBtn.on('click', (e) => {
                e.stopPropagation();
                this.$fileInput[0].click();
            });

            // 图片点击预览
            this.bindPreviewEvent();

            // 上传框点击(仅在没有图片时)
            this.$uploadBox.on('click', (e) => {
                if ($(e.target).hasClass('doc-replace-btn')) return;
                if ($(e.target).hasClass('doc-preview-img')) return;
                if (!this.$uploadBox.hasClass('has-img')) {
                    this.$fileInput[0].click();
                }
            });

            // 文件选择
            this.$fileInput.on('change', (e) => {
                const file = e.target.files[0];
                if (file) {
                    this.handleFileSelect(file);
                }
            });
        }

        handleFileSelect(file) {
            // Validate file type
            if (!file.type.startsWith('image/')) {
                layer.msg('Please select an image file');
                return;
            }

            // Validate file size
            if (file.size > this.options.maxSize * 1024 * 1024) {
                layer.msg(`Image size cannot exceed ${this.options.maxSize}MB`);
                return;
            }

            const formData = new FormData();
            formData.append('file', file);

            const loadIndex = layer.load(1, { shade: [0.1, '#fff'] });

            $.ajax({
                url: this.options.uploadUrl,
                type: 'POST',
                data: formData,
                processData: false,
                contentType: false,
                success: (response) => {
                    layer.close(loadIndex);

                    if (typeof response === 'string') {
                        try {
                            response = JSON.parse(response);
                        } catch (e) {
                            layer.msg('Response format error');
                            this.options.onError?.('Response format error');
                            return;
                        }
                    }

                    if (response.code === 0) {
                        layer.msg(response.msg || 'Upload successful');
                        const picUrl = response.data.pic_src || response.data.pic;
                        this.setPreview(picUrl);
                        this.value = response.data.pic;
                        this.$valueInput.val(response.data.pic);
                        this.options.onSuccess?.(response, picUrl);
                    } else {
                        layer.msg(response.msg || 'Upload failed');
                        this.options.onError?.(response.msg || 'Upload failed');
                    }
                },
                error: () => {
                    layer.close(loadIndex);
                    layer.msg('Network error, please try again');
                    this.options.onError?.('Network error');
                }
            });
        }

        setPreview(imgUrl) {
            this.$previewImg.attr('src', imgUrl).css('display', 'block');
            this.$placeholder.css('display', 'none');
            this.$placeholderText.css('display', 'none');
            this.$uploadBox.addClass('has-img');
            this.value = imgUrl;
            this.$valueInput.val(imgUrl);
            this.bindPreviewEvent();
        }

        bindPreviewEvent() {
            const self = this;
            this.$previewImg.off('click').on('click', function (e) {
                e.stopPropagation();
                const imgSrc = $(this).attr('src');
                if (imgSrc) {
                    layer.open({
                        type: 1,
                        title: false,
                        closeBtn: 1,
                        shadeClose: true,
                        area: ['auto', 'auto'],
                        maxWidth: window.innerWidth * 0.9,
                        maxHeight: window.innerHeight * 0.9,
                        content: `<img src="${imgSrc}" style="max-width:${window.innerWidth * 0.85}px;max-height:${window.innerHeight * 0.85}px;display:block;">`,
                        skin: 'layui-layer-nobg'
                    });
                }
            });
        }

        reset() {
            this.$previewImg.attr('src', '').css('display', 'none');
            this.$placeholder.css('display', 'block');
            this.$placeholderText.css('display', 'block');
            this.$uploadBox.removeClass('has-img');
            this.$valueInput.val('');
            this.$fileInput.val('');
            this.value = '';
        }

        getValue() {
            return this.$valueInput.val();
        }

        /**
         * 设置为只读模式
         */
        setReadOnly() {
            // 禁用所有交互
            this.$uploadBox.css({
                'pointer-events': 'none',
                'cursor': 'default'
            });

            // 移除 hover 效果的类
            this.$uploadBox.removeClass('hover:border-blue-500 hover:bg-blue-50/30 hover:shadow-md');

            // 如果没有图片,显示"未上传"提示
            if (!this.options.defaultImage) {
                // 移除 + 号图标
                this.$container.find('.doc-upload-placeholder span:first-child').remove();
                
                // 替换提示文字为"未上传"
                const docType = this.options.placeholderText || 'Document';
                this.$placeholderText.text(`No ${docType} uploaded`)
                    .removeClass('group-hover:text-blue-500')
                    .addClass('text-gray-400');
            } else {
                // 有图片时,移除占位符相关元素
                this.$container.find('.doc-upload-placeholder span:first-child').remove();
                this.$container.find('.doc-placeholder-text').removeClass('group-hover:text-blue-500');
            }

            // 如果有图片,允许点击预览(但不允许替换)
            this.$previewImg.css({
                'pointer-events': 'auto',
                'cursor': 'pointer'
            });

            // 隐藏替换按钮
            this.$container.find('.doc-action-buttons').remove();
        }

        destroy() {
            $(this.options.container).empty();
        }
    }

    /**
     * 设置上传组件为只读状态(独立工具函数,用于已创建的实例)
     * @param {string} containerId - 容器选择器 (如 '#uploadContainer')
     * @deprecated 推荐在初始化时使用 readOnly: true 配置项
     */
    function setUploadReadOnly(containerId) {
        const $container = $(containerId);
        const $uploadBox = $container.find('.doc-upload-box');

        // 禁用所有交互
        $uploadBox.css({
            'pointer-events': 'none',
            'cursor': 'default'
        });

        // 移除 hover 效果的类
        $uploadBox.removeClass('hover:border-blue-500 hover:bg-blue-50/30 hover:shadow-md');

        // 修改占位符样式 - 移除图标和文字的动画效果
        $container.find('.doc-upload-placeholder span:first-child').remove(); // 移除 + 号图标
        $container.find('.doc-placeholder-text').removeClass('group-hover:text-blue-500');

        // 如果有图片,允许点击预览(但不允许替换)
        $container.find('.doc-preview-img').css({
            'pointer-events': 'auto',
            'cursor': 'pointer'
        });

        // 隐藏替换按钮
        $container.find('.doc-action-buttons').remove();
    }

    // 暴露到全局
    window.AvatarUploadLayui = AvatarUploadLayui;
    window.DocUploadBox = DocUploadBox;
    window.setUploadReadOnly = setUploadReadOnly;

})(window);

参考资料:

相关推荐
小白菜学前端2 小时前
Vue3 + TS 解决 ESLint 与 Prettier 格式化冲突
前端·javascript·vue.js
第二只羽毛2 小时前
搜索引擎项目
大数据·前端·c++·搜索引擎·vim
The_era_achievs_hero2 小时前
封装api方法(全面)
前端·javascript·uni-app·api·封装接口
一殊酒2 小时前
【前端开发】Vue项目多客户配置自动化方案【二】
javascript·vue.js·自动化
Mr Xu_2 小时前
深入解析 getBoundingClientRect 与 offsetTop:解决 Vue 平滑滚动偏移误差问题
前端·javascript·vue.js
Mr-Wanter2 小时前
vue 解决img图片路径存在但图片无法访问时显示错误的问题
前端·vue·img
muddjsv2 小时前
近些年前端开发主流技术全景:趋势、工具与实践指南
前端
沛沛老爹2 小时前
从Web到AI:多模态Agent Skills开发实战——JavaScript+Python全栈赋能视觉/语音能力
java·开发语言·javascript·人工智能·python·安全架构