• 基于Node的React图片上传组件实现


    写在前面

    红旗不倒,誓把JavaScript进行到底!今天介绍我的开源项目 Royal 里的图片上传组件的前后端实现原理(React + Node),花了一些时间,希望对你有所帮助。

    这里写图片描述

    前端实现

    遵循React 组件化的思想,我把图片上传做成了一个独立的组件(没有其他依赖),直接import即可。

    import React, { Component } from 'react'
    import Upload from '../../components/FormControls/Upload/'
    
    //......
    
    render() {
        return (
            <div><Upload uri={'http://jafeney.com:9999/upload'} /></div>
        )
    }

    uri 参数是必须传的,是图片上传的后端接口地址,接口怎么写下面会讲到。

    渲染页面

    组件render部分需要体现三个功能:

    • 图片选取(dialog窗口)

    • 可拖拽功能(拖拽容器)

    • 可预览(预览列表)

    • 上传按钮 (button)

    • 上传完成图片地址和链接 (信息列表)

    render函数

    render() {
        return (
            <form action={this.state.uri} method="post" encType="multipart/form-data">
                <div className="ry-upload-box">
                    <div className="upload-main">
                        <div className="upload-choose">
                            <input
                                onChange={(v)=>this.handleChange(v)}
                                type="file"
                                size={this.state.size}
                                name="fileSelect"
                                accept="image/*"
                                multiple={this.state.multiple} />
                            <span ref="dragBox"
                                onDragOver={(e)=>this.handleDragHover(e)}
                                onDragLeave={(e)=>this.handleDragHover(e)}
                                onDrop={(e)=>this.handleDrop(e)}
                                className="upload-drag-area">
                                或者将图片拖到此处
                            </span>
                        </div>
                        <div className={this.state.files.length?
                             "upload-preview":"upload-preview ry-hidden"}>
                            { this._renderPreview();   // 渲染图片预览列表 }
                        </div>
                    </div>
                    <div className={this.state.files.length?
                         "upload-submit":"upload-submit ry-hidden"}>
                         <button type="button"
                             onClick={()=>this.handleUpload()}
                             class="upload-submit-btn">
                             确认上传图片
                         </button>
                    </div>
                    <div className="upload-info">
                        { this._renderUploadInfos();   // 渲染图片上传信息 }
                    </div>
                </div>
            </form>
        )
    }

    渲染图片预览列表

    _renderPreview() {
        if (this.state.files) {
            return this.state.files.map((item, idx) => {
                return (
                    <div className="upload-append-list">
                        <p>
                            <strong>{item.name}</strong>
                            <a href="javascript:void(0)"
                               className="upload-delete"
                               title="删除" index={idx}></a>
                            <br/>
                            <img src={item.thumb} className="upload-image" />
                         </p>
                         <span className={this.state.progress[idx]?
                             "upload-progress":
                             "upload-progress ry-hidden"}>
                             {this.state.progress[idx]}
                         </span>
                     </div>
                )
            })
        } else {
            return null
        }
    }

    渲染图片上传信息列表

    _renderUploadInfos() {
        if (this.state.uploadHistory) {
            return this.state.uploadHistory.map((item, idx) => {
                return (
                    <p>
                        <span>上传成功,图片地址是:</span>
                        <input type="text" class="upload-url" value={item.relPath}/>
                        <a href={item.relPath} target="_blank">查看</a>
                     </p>
                );
            })
        } else {
            return null;
        }
    }

    文件上传

    前端要实现图片上传的原理就是通过构建FormData对象,把文件对象append()到该对象,然后挂载在XMLHttpRequest对象上 send() 到服务端。

    获取文件对象

    获取文件对象需要借助 input 输入框的 change 事件来获取 句柄参数 e

    onChange={(e)=>this.handleChange(e)}

    然后做以下处理:

    e.preventDefault()
    let target = event.target
    let files = target.files
    let count = this.state.multiple ? files.length : 1
    for (let i = 0; i < count; i++) {
        files[i].thumb = URL.createObjectURL(files[i])
    }
    // 转换为真正的数组
    files = Array.prototype.slice.call(files, 0)
    // 过滤非图片类型的文件
    files = files.filter(function (file) {
        return /image/i.test(file.type)
    })

    这时 files 就是 我们需要的文件对象组成的数组,把它 concat 到原有的 files里。

    this.setState({files: this.state.files.concat(files)})

    如此,接下来的操作 就可以 通过 this.state.files 取到当前已选中的 图片文件。

    利用Promise处理异步上传

    文件上传对于浏览器来说是异步的,为了处理 接下来的多图上传,这里引入了 Promise来处理异步操作:

    upload(file, idx) {
        return new Promise((resolve, reject) => {
            let xhr = new XMLHttpRequest()
            if (xhr.upload) {
                // 上传中
                xhr.upload.addEventListener("progress", (e) => {
                    // 处理上传进度
                    this.handleProgress(file, e.loaded, e.total, idx);
                }, false)
                // 文件上传成功或是失败
                xhr.onreadystatechange = (e) => {
                    if (xhr.readyState === 4) {
                        if (xhr.status === 200) {
                        // 上传成功操作
                        this.handleSuccess(file, xhr.responseText)
                        // 把该文件从上传队列中删除
                        this.handleDeleteFile(file)
                        resolve(xhr.responseText);
                     } else {
                        // 上传出错处理 
                        this.handleFailure(file, xhr.responseText)
                        reject(xhr.responseText);
                     }
                }
            }
            // 开始上传
            xhr.open("POST", this.state.uri, true)
            let form = new FormData()
            form.append("filedata", file)
            xhr.send(form)
        })
    }

    上传进度计算

    利用XMLHttpRequest对象发异步请求的好处是可以 计算请求处理的进度,这是fetch所不具备的。
    我们可以为 xhr.upload 对象的 progress 事件添加事件监听:

    xhr.upload.addEventListener("progress", (e) => {
        // 处理上传进度
        this.handleProgress(file, e.loaded, e.total, i);
    }, false)

    说明:idx参数是纪录多图上传队列的索引

    handleProgress(file, loaded, total, idx) {
        let percent = (loaded / total * 100).toFixed(2) + '%';
        let _progress = this.state.progress;
        _progress[idx] = percent;
        this.setState({ progress: _progress })  // 反馈到DOM里显示
    }

    拖拽上传

    拖拽文件对于HTML5来说其实非常简单,因为它自带的几个事件监听机制可以直接做这类处理。主要用到的是下面三个:

    onDragOver={(e)=>this.handleDragHover(e)}
    onDragLeave={(e)=>this.handleDragHover(e)}
    onDrop={(e)=>this.handleDrop(e)}

    取消拖拽时的浏览器行为:

    handleDragHover(e) {
        e.stopPropagation()
        e.preventDefault()
    }

    处理拖拽进来的文件:

    handleDrop(e) {
        this.setState({progress:[]})
        this.handleDragHover(e)
        // 获取文件列表对象
        let files = e.target.files || e.dataTransfer.files
        let count = this.state.multiple ? files.length : 1
        for (let i = 0; i < count; i++) {
            files[i].thumb = URL.createObjectURL(files[i])
        }
        // 转换为真正的数组 
        files = Array.prototype.slice.call(files, 0)
        // 过滤非图片类型的文件
        files = files.filter(function (file) {
            return /image/i.test(file.type)
        })
        this.setState({files: this.state.files.concat(files)})
    }

    多图同时上传

    支持多图上传我们需要在组件调用处设置属性:

    multiple = { true }  // 开启多图上传 
    size = { 50 }        // 一次最大上传数量(虽没有上限,为保证服务端正常,建议50以下)

    然后我们可以使用 Promise.all() 处理异步操作队列:

    handleUpload() {
        let _promises = this.state.files.map((file, idx) => this.upload(file, idx))
        Promise.all(_promises).then( (res) => {
            // 全部上传完成 
            this.handleComplete()
        }).catch( (err) => { console.log(err) })
    }

    好了,前端工作已经完成,接下来就是Node的工作了。

    后端实现

    为了方便,后端采用的是express框架来快速搭建Http服务和路由。具体项目见我的github node-image-upload。逻辑虽然简单,但还是有几个可圈可点的地方:

    跨域调用

    本项目后端采用的是express,我们可以通过 res.header() 设置 请求的 "允许源" 来允许跨域调用:

    res.header('Access-Control-Allow-Origin', '*'); 

    设置为 * 说明允许任何 访问源,不太安全。建议设置成 你需要的 二级域名,如 jafeney.com

    除了 "允许源" ,其他还有 "允许头" 、"允许域"、 "允许方法"、"文本类型" 等。常用的设置如下:

    function allowCross(res) {
        res.header('Access-Control-Allow-Origin', '*');   
        res.header('Access-Control-Allow-Headers', 'Content-Type, Content-Length, Authorization, Accept, X-Requested-With , yourHeaderFeild');
        res.header('Access-Control-Allow-Methods', 'PUT, POST, GET, DELETE, OPTIONS');
        res.header("X-Powered-By",' 3.2.1')
        res.header("Content-Type", "application/json;charset=utf-8");
    }

    ES6下的Ajax请求

    ES6风格下的Ajax请求和ES5不太一样,在正式的请求发出之前都会先发一个 类型为 OPTIONS的请求 作为试探,只有当该请求通过以后,正式的请求才能发向服务端。

    所以服务端路由 我们还需要 处理这样一个 请求:

    router.options('*', function (req, res, next) {
        res.header('Access-Control-Allow-Origin', '*');
        res.header('Access-Control-Allow-Headers', 'Content-Type, Content-Length, Authorization, Accept, X-Requested-With , yourHeaderFeild');
        res.header('Access-Control-Allow-Methods', 'PUT, POST, GET, DELETE, OPTIONS');
        res.header("X-Powered-By",' 3.2.1')
        res.header("Content-Type", "application/json;charset=utf-8");
        next();
    });

    注意:该请求同样需要设置跨域。

    处理上传

    处理上传的图片引人了multiparty模块,用法很简单:

    /*使用multiparty处理上传的图片*/
    router.post('/upload', function(req, res, next) { 
        // 生成multiparty对象,并配置上传目标路径
        var form = new multiparty.Form({uploadDir: './public/file/'});
        // 上传完成后处理
        form.parse(req, function(err, fields, files) {
            var filesTmp = JSON.stringify(files, null, 2);
            var relPath = '';
            if (err) {
                // 保存失败 
                console.log('Parse error: ' + err);
            } else {
                // 图片保存成功!
                console.log('Parse Files: ' + filesTmp);
                // 图片处理
                processImg(files);
            }
        });
    });

    图片处理

    Node处理图片需要引入 gm 模块,它需要用 npm 来安装:

    npm install gm --save

    BUG说明

    注意:node的图形操作gm模块前使用必须 先安装 imagemagickgraphicsmagickLinux (ubuntu)上使用apt-get 安装:

        sudo apt-get install imagemagick
        sudo apt-get install graphicsmagick  --with-webp  // 支持webp格式的图片

    MacOS上可以用 Homebrew 直接安装:

        brew install imagemagick
        brew install graphicsmagick --with-webp   // 支持webp格式的图片  

    预设尺寸

    有些时候除了原图,我们可能需要把原图等比例缩小作为预览图或者缩略图。这个异步操作还是用Promise来实现:

    function reSizeImage(paths, dstPath, size) {
        return new Promise(function(resolve, reject) {
            gm(dstPath)
            .noProfile()
            .resizeExact(size)
            .write('.' + paths[1] + '@' + size + '00.' + paths[2], function (err) {
                if (!err) {
                    console.log('resize as ' + size + ' ok!')
                    resolve()
                }
                else {
                    reject(err)
                };
            });
        });
    }

    重命名图片

    为了方便排序和管理图片,我们按照 "年月日 + 时间戳 + 尺寸" 来命名图片:

    var _dateSymbol = new Date().toLocaleDateString().split('-').join('');
    var _timeSymbol = new Date().getTime().toString();

    至于图片尺寸 使用 gmsize() 方法来获取,处理方式如下:

    gm(uploadedPath).size(function(err, size) {
        var dstPath = './public/file/' + _dateSymbol + _timeSymbol 
            + '_' + size.width + 'x' + size.height + '.' 
            + _img.originalFilename.split('.')[1];
        var _port = process.env.PORT || '9999';
            relPath = 'http://' + req.hostname + ( _port!==80 ? ':' + _port : '' ) 
            + '/file/' + _dateSymbol + _timeSymbol + '_' + size.width + 'x' 
            +  size.height + '.' + _img.originalFilename.split('.')[1];
        // 重命名
        fs.rename(uploadedPath, dstPath, function(err) {
            if (err) {
                reject(err)
            } else {
                console.log('rename ok!');
            }
        });
    });

    总结

    对于大前端的工作,理解图片上传的前后端原理仅仅是浅层的。我们的口号是 "把JavaScript进行到底!",现在无论是 ReactNative的移动端开发,还是NodeJS的后端开发,前端工程师可以做的工作早已不仅仅是局限于web页面,它已经渗透到了互联网应用层面的方方面面,或许,叫 全栈工程师 更为贴切吧。

    当然,全栈 两个字的分量很重,不积跬步,无以至千里,功力低下的我还需要不断修炼和实践!

    参考

    张鑫旭 《基于HTML5的可预览多图片Ajax上传》


    @欢迎关注我的 github个人博客 -Jafeney

  • 相关阅读:
    window.onload与$(document).ready()的区别
    性能优化篇
    Redis配置文件参考
    Redis基础介绍&安装部署
    lazyfree 和memory usage源码分析
    Greenplum启动失败Error occurred: non-zero rc: 1的修复
    MongoDB启动文件配置参数详解
    MongoDB添加仲裁节点报错replica set IDs do not match办法
    Greenplum扩容
    MPP架构海量数据分析仓库——Greenplum介绍
  • 原文地址:https://www.cnblogs.com/10manongit/p/12934140.html
Copyright © 2020-2023  润新知