打造一款简单易用功能全面的图片上传组件

多年前我曾搞过Winform,也被WPF折磨得死去活来。后来我学会了对她们冷眼旁观,就算老鸨巨硬说又推了一个新头牌UWP,问我要不要试试,我也不再回应。时代变了,她们古板的舞步已经失去了往日的魅力,那些为了适应潮流勉强加上的几个动作反而显得更加可笑、和可悲。我四处流浪,跟着年轻的小伙们去到远处的移动村、微服务村、AI村,一呆就是几月几年。直到某天有人告诉我,有位妙龄女郎孤身一人在那座荒废的村落安顿下来,她的名字叫——electron。


场景

博主十一宅家写了一个图文发布器,关键是图片上传区域,如下:

该区域功能相对独立,完全可以封装为组件以供其它项目使用,且易于维护。本人计划包含的功能如下:

  1. 可拖拽图片和文件夹到上传区域
  2. 图片可拖拽调整顺序
  3. 可删除,可设为封面
  4. 上传图片至OSS
  5. 根据图片大小生成若干比率压缩图,同样上传至OSS(用户对此无感知)
  6. 若图片大小超过阈值,自动分片,分片上传为不同文件(为后续并行下载做好准备)
  7. 加密后上传(防盗链、防和谐)
  8. [压缩、加密、分片、上传]进度显示
  9. 秒传或提示冲突不予上传(需服务端接口)
  10. 暂停、错误提示、重传等辅助功能

以上功能需求前8条基本完成,如果要封装为组件供第三方使用的话,最好还要支持:

  1. 国际化&本地化
  2. 插件机制
  3. 可自定义模板&皮肤

造轮子?

博主是一个拿来主义者,对盲目造轮子的行为一向嗤之以鼻。考虑到互联网这么多年,一般网站都有文件/图片上传功能,开源出来的应该不在少数,选一两款优良的自己再稍微改改,分分钟搞定。结果网上搜了一圈,出乎意料,都不是很满意,少数几个知名点的,要么是工具而非组件形式不好集成(如PicGo),要么功能太简单(如Layui,不知道上传组件是否开源,不过我是他家的会员),要么太过复杂和花里胡哨(如bootstrap-fileinput)。其实按照我的要求,就算找到勉强凑合的,也要深度改造过,有这时间还不如老老实实自己撸。

当然就算现成的轮子不好转,借鉴还是可以的。由于几年前我曾使用bootstrap-fileinput上传文件到oss,对它还算有一点了解。github上看了下,发现这个组件一直在更新,官方文档比记忆中要稍显清晰些,但巨多的配置项依旧让我眼花缭乱。深入其源码,核心文件的代码行数已经6000+,要理清短时间内是不可能了。而且其中关键的异步任务(主要是上传)基于jQuery.Deferred,jQuery.Deferred又是对Promise的封装,bootstrap-fileinput用起来复杂许多。而我们的异步任务除了上传外,至少还有压缩、加密、分片,本着实操ES6之Promise一文打下的良好基础,这部分代码就自己写好了(迷之自信:)。所以,剩下能借鉴的就只有边角料的UI、拖拽代码了,而这两块也着实可以再剪几刀。


实现

由于本组件一开始是在Nodejs/Electron环境下开发的,所以就没考虑过一些古老浏览器的感受,而是假设执行环境支持File/FileReader/FormData等类型及相关API。

文件拖拽选择

重点是在用户“拖”着[若干]文件[夹],在拖拽区域内释放时,如何获取相关文件信息,代码如下:

    _zoneDrop: async function (e) {
        let dataTransfer = e.originalEvent.dataTransfer,
            files = dataTransfer.files, items = dataTransfer.items, folderCount = this._getDragDropFolderCount(items)
        e.preventDefault()
        if (this._isEmpty(files)) {
            return
        }
        if (folderCount > 0) {
            files = []
            for (let i = 0; i < items.length; i++) {
                let item = items[i].webkitGetAsEntry()
                if (item) {
                    await this._scanDroppedItems(item, files)
                }
            }
        }
        this.$dropZone.removeClass('file-highlighted')

        this.$dropZone.trigger("filesChanged", files)
    }

若拖拽的项目不包含文件夹,那么直接返回dataTransfer.files,否则递归加载所有文件:

    _scanDroppedItems: async function (item, files, path) {
        path = path || ''
        let self = this
        if (item.isFile) {
            let task = new Promise((resolve, reject) => {
                item.file(function (file) {
                    if (path) {
                        file.relativePath = path + file.name;
                    }
                    resolve(file)
                }, e => reject(e))
            })
            let file = await task.catch(e => { throw e })
            files.push(file)
        } else {
            if (item.isDirectory) {
                let i, dirReader = item.createReader()
                let readDir = function () {
                    return new Promise((resolve, reject) => {
                        dirReader.readEntries(async function (entries) {
                            if (entries && entries.length > 0) {
                                let tasks = []
                                for (i = 0; i < entries.length; i++) {
                                    tasks.push(self._scanDroppedItems(entries[i], files, path + item.name + '/'))
                                }
                                Promise.all(tasks).then(() => resolve()).catch(e => reject(e))
                                // recursively call readDir() again, since browser can only handle first 100 entries.
                                await readDir().catch(e => { throw e })
                            } else
                                resolve()
                        }, e => reject(e))
                    })
                }
                await readDir()
            }
        }
    }

这里理解上的难点是异步递归调用,且同时使用了Promist.then(不阻塞)及await(阻塞)模式,且同时有两个函数交错递归——_scanDroppedItemsreadDir。老实说,这个函数当时也是凭感觉写,此处就不展开讲了,道可道,非常道:)

压缩

使用了compressorjs库,代码如下:

    compress: async function (file, level, quality = 0.8) {
        //以下若干情况不需要压缩,直接返回原file
        switch (true) {
            case file.size < 51200:
            case file.size < 524288 && level != 'thumbnail':
            case file.size < 1048576 && level == 'big':
                file.asLevels = file.asLevels || []
                file.asLevels.push(level)
                return file;
        }

        let opt = {
            quality: quality
        }
        let img = await utility.getImage(file.path) //转成img以得到width/height属性
        let scale = Math.min(img.width, img.height, this.levels[level])
        opt[img.width < img.height ? 'width' : 'height'] = scale
        return new Promise((resolve, reject) => {
            Object.assign(opt, {
                success(result) {
                    result.level = level
                    resolve(result)
                },
                error(err) {
                    reject(err)
                },
            })
            new Cmp(file, opt)
        })
    }

看注释,不是所有图片过来都无脑压缩,本身size已经在压缩级别内了就直接返回。另外scale变量表示短边长度,是业务需求,可无视。

加密

使用AES加密标准,首先要知道,AES是基于数据块的加密方式,每个加密块大小为128位。它又有几种实现方式:

  • ECB:是一种基础的加密方式,明文被分割成分组长度相等的块(不足补齐),然后单独一个个加密,一个个输出组成密文。
  • CBC:是一种循环模式,前一个分组的密文和当前分组的明文异或操作后再加密,这样做的目的是增强破解难度。需要初始化向量IV,参看加密算法IV的作用
  • CFB/OFB实际上是一种反馈模式,目的也是增强破解的难度。

使用crypto库的AES加密。

    _encrypt: async function (file, key, iv, destDir = 'temp') {
        key = key || await this._md5(file) //128bit length
        key = Buffer.from(key, 'hex')
        iv = iv || "stringwith16byte"
        iv = Buffer.from(iv, 'utf8')
        let cipher = crypto.createCipheriv('aes-128-cbc', key, iv)
        cipher.setAutoPadding(true)

        let stm = file.stream()      
        let writerStream = fs.createWriteStream(path.join(__dirname, destDir,file.name))
        stm.pipe(cipher).pipe(writerStream)
    }

cipher.setAutoPadding(true)表示明文分块后位数不足自动补足,标准的补足算法有多种,crypto使用PKCS7。设为false的话,就要自己考虑如何补足。参看Node.js Crypto, what's the default padding for AES?

上述代码采用的是CBC模式,如果考虑到效率,可使用ECB模式,明文分块之后,各个块之间相互独立,互不影响,可并行计算加密,但安全性稍差,不过在我们的场景下够用了。

ps:OSS提供了对上传文件的服务端加密(需要设置x-oss-server-side-encryption)。当下载时,OSS会先在服务端解密再传输,整个加解密过程可以做到用户端无感,所以它的目的只是保证文件在OSS服务器上的安全,怕服务器被盗?还是对OSS本身的存储安全性不自信?不是很懂OSS工程师的想法。

分片

网上资料欠缺,不知Blob是否把数据全部加载进内存中,而没有其它内存方面的考量,至少以URL形式获取的Blob是如此,参看https://javascript.info/blob#blob-as-url。同时,若手动构造Blob,也只能将所有数据一股脑给出[到内存中],而不是更简单更高效的方式比如传递文件路径,然后按需获取数据。当然,这应该是安全方面考量,避免js随意调动本地文件。但对我们现在的场景来说就有点麻烦了。

用户选择要上传的文件后,上一步我们对它们进行了加密,并另存为临时文件到磁盘中,此时要再将该文件主动转为Blob或File对象[用于后续上传]就比较麻烦。在Nodejs下还好,大不了将文件全部加载到内存中,通过字节数组转换,但在浏览器环境下由于屏蔽了对本地文件的读写,这是不可能的。

fs.createReadStream()可接受Buffer类型的参数,然而并不是用于传递文件内容的,而仍然只能是文件路径。You can apparently pass the path in a Buffer object, but it still must be an acceptable OS path when the Buffer is converted to a string.

为了满足不同场景下的使用,并考虑到开销问题,最好能以流的形式,边加密边上传,然而OSS的PostObject似乎不支持流模式(PutObject倒是可以,参看流式上传)。不过我们可以实现stream.Writable模拟流上传,其实内部是分片上传,但这种方式并不推荐,参看下面stream一节。

所以目前来说最简单直接有效的方式还是基于Blob.slice()分片,如下:

        let pieces
        if (this.pieceSize && !file.level && file.size > this.pieceSize) { //目前只对原图进行分片处理
            pieces = []
            let startIndex = 0
            do {
                pieces.push(file.slice(startIndex, startIndex += this.pieceSize))
            }
            while (startIndex < file.size)
        } else
            pieces = [file]

前面说到,分片的目的之一是并行下载。其实Http1.1(RFC2616)引入的Range & Content-Range开始支持获取文件的部分内容,这已经为对整个文件的并行下载以及断点续传提供了技术支持。上传前分片似乎多此一举了,其实不尽然。现在很多文件服务提供商会限制单用户的连接数和传输速率,如果基于Http1.1 Range做并行下载,假设服务器限制了同时最多3个连接,就算你开10个线程也于事无补;而我们的物理分片可以将一个文件拆分到不同的服务器甚至不同服务商,自主可控,同时也提高了盗链和爬虫的难度。

上传

    uploadFile: async function (file, uploadUrl, opt) {
        uploadUrl = uploadUrl || await this._get(this._getOssUploadUrl)
        opt = opt || {
            headers: {
                "Cache-Control": "max-age=2592000",
                'Content-Type': 'multipart/form-data'
            }
        }
        let policy = await this._getPolicy()
        let key = utility.getRandomString(20)
        let formData = new FormData()
        formData.append('Cache-Control', 'max-age=2592000')
        formData.append('key', key)
        for (let k in policy) {
            formData.append(k, policy[k])
        }
        formData.append('file', file)

        opt.onUploadProgress = evt => opt.processCallback(evt, key)
        axios.post(uploadUrl, formData, opt).then((data) => {
            console.info(data)
        })
    }

其中policy是服务端上传策略加上签名返回给前端的,OSS用其鉴别请求的合法性。

上传进度采用axios.post回调实现,特别注意onUploadProgress的参数ProgressEvent,它的total和待上传文件的size是不同的,会多个1.4k左右,猜测是加了请求头等信息的字节数。所以我们在计算上传百分比时需按ProgressEvent.total而非文件本身的size。


其它

stream

nodejs中,stream有pipe,管道的概念,说白了就是链式处理,只不过这里处理的是stream罢了。以前大家都使用through2库自定义处理器,nodejs在v1.2.0开始引入了Simplified Stream Construction,可以替代through2。它声明了stream.Writablestream.Readablestream.Duplexstream.Transform四种流类型。

注意stream.Duplexstream.Transform 的区别:stream.Duplex不要求输入输出流有关系,它们可以没一毛钱关系,只要实现stream.Duplex的类既能read又能write就可以了;stream.Transform继承自stream.Duplex,从字面意思上说就是转换,很明显,输入流经过某种转换转变为输出流,输入输出是有关系的。上述加密一节用到的Cipher就实现了stream.Transform,因此我们可以方便地将源文件加密并另存为一个新文件。

我们要区分Nodejs的stream定义和HTML5的stream Web API,两者有相似之处,但不能混用。以ReadableStream为例,前者pipe(WritableStream),返回的是传递的可写流,后者pipeTo(WritableStream),返回的是Promise对象,且虽然它们都叫ReadableStream或WritableStream,但它们不是同一个东西。目前也没有发现能方便转换它俩的方法。

stream.Writable

原本想通过实现stream.Writable模拟流上传的形式实现分片上传,但在我们的场景下其实没有必要,反而可能影响效率。不过看一下如何实现也无妨。

  1. 在实现类的构造函数中增加一行Writable.call(this, this._options.streamOpts)

  2. 给实现类定义_write函数,比如:

        _write: function (chunk, _, callback) {
            console.info(chunk.length) //65536/64k max
            this.block += chunk
            if (block.length >= 1048576) //当到达1M时,开始上传
            {
                // 上传代码,注意可能需要阻塞直到上传完成,避免block的变化影响到上传数据
                let blob = new Blob(this.block) //伪代码
                this._upload(blob, () => {
                    this.block.clear() //伪代码
                    callback() // 必须,告知已顺利执行,否则_write只会被调用一次
                }) 
            }
        }
    

    每处理一段数据,就要callback一次,告知程序可以开始处理下一段数据了。如果全局block的变化会影响到上传,那么我们就必须等待本次上传成功之后再进行下一个分片的上传,这就降低了效率。
    如果给callback传递了参数,则是表明本次处理发生了错误。

    注意上游的ReadableStream并不知道你处理数据的速度,所以如果未做处理的话,可能出现数据积压(back pressure)的问题,即数据源源不断地往内存输入却得不到及时处理的情况,此时highWaterMark选项就派上了用场。当积压的数据大小超过highWaterMark预设值的话,WritableStream.write()会返回false,用于告知上游,上游就可以暂停喂数据。同时上游监听下游的drain事件,当待处理数据大小小于 highWaterMark 时下游会触发 drain 事件,上游就可以重启输出。pipe函数内部已经实现了这部分逻辑。参看NodeJS Stream 四:Writable

    所以,highWaterMark指的并不是单次处理数据的大小。测试发现,单次write传入的chunk大小<=64k,这是为啥呢?在w3c项目中也有人对此提出了疑问,参看Define chunk size for ReadableStream created by blob::stream() #144

    还可以实现_writev()函数,用于有积压数据时一次性处理完所有积压数据,当然,何时调用不需要我们关心,WritableStream会自动处理。具体来说, If implemented and if there is buffered data from previous writes, _writev() will be called instead of _write().`

  3. util.inherits(实现类, Writable)

stream.Readable

上面说到,ReadableStream在w3c标准里和Nodejs里都有,但是不同的类,不能通用。那如果要将Blob.stream()转成Nodejs里的ReadableStream怎么办呢?至少我没找到一键转换的方法。以下是借助ArrayBufferBuffer的转换实现的。

    let ab = await file.arrayBuffer()
    let buf = Buffer.from(ab)
    let stm = new Readable({
        read() {
            // 空实现
        }
    })
    stm.push(buf)

其实这种方式失去了strem本身的意义,因为数据都已经全部在内存中了,直接操作反而来得更加方便。这也是分片实现为什么不这么做的原因之一,期待w3c和Nodejs在stream方面统一的那天吧。

Electron

原本本文是围绕Electron展开的,题目都取了一阵子了——“Electron构建桌面应用程序实战指南之实现酷炫图文发布器”。后来发现其实Electron没啥好写的,难点还是在业务的实现,不过有些坑仍然值得一提。

npm与cnpm

npm是 Node.js 标准的软件包管理器。但由于默认的仓库地址位于国外,package的下载速度可能会比较慢。

淘宝团队做了一个npm官方仓库的镜像仓库,同步频率目前为10分钟。地址是https://registry.npm.taobao.org。使用npm install -g cnpm --registry=https://registry.npm.taobao.org安装cnpm命令即可。
一般来说,只要使用npm config set registry https://registry.npm.taobao.org改变默认仓库地址,就可以使下载速度加快。

打包

打包出现打包过慢(几个小时),原因很可能是因为依赖包都是通过cnpm安装,删除cnpm安装的依赖包,替换成npm安装的依赖包即可。详情参看electron打包:electron-packager及electron-builder两种方式实现(for Windows)

[使用electron-packager]打包后所有的代码及资源文件会在ProductName\resources\app下,若代码中是以相对路径定位依赖文件,将以ProductName为基目录查找,会报找不到的错误。因此我们一般在代码中使用path.join(__dirname, 'xxxx'),使得在开发过程中还是部署之后都能正确定位文件。

打包后就可以生成安装包,参看【Electron】 NSIS 打包 Electron 生成exe安装包(asar的步骤可以跳过)。如果觉得安装包的体积过大,可在electron-packager打包前删除package-lock.json文件,这将极大地减少node_modules目录体积,进而减小最终生成的安装包大小(网上说的其它一些方法有点复杂,没有太去了解)。

奇怪的问题

本人使用一个名叫node-stream-zip的库解析zip包,将其中一些操作封装为Promise模式,然后发现初次加载时可以正常执行,reload后状态就一直pending了。遇到这种问题可以尝试设置app.allowRendererProcessReuse = false,猜测是由于electron重用渲染层进程导致某些类库异常。相关链接https://github.com/electron/electron/issues/18397
但这又会使得node-stream-zip第一次加载无法按预期执行,后来采用先预加载一个空白页(其它页面也可以)解决,如下:

window.loadURL('about:blank').then(() => { //同上
	window.loadFile(path.join(__dirname, "package.html"))
})

alert bug

electron有个bug一直没有得到解决——原生alert弹出框会导致页面失去焦点,文本框无法输入,需要整个窗口重新激活下才可以(比如最小化一下再还原,或者鼠标点击其它应用后再返回)。可以重定义alert覆盖原生实现,如下示例:

window.alert = function () {
    let $alert = $(`
    <div id="alert" class="modal" tabindex="-1">
        <div class="modal-dialog modal-dialog-centered">
            <div class="modal-content">
                <div class="modal-body">
                    <p class="alert-msg text-info"></p>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-info" data-dismiss="modal">确定</button>
                </div>
            </div>
        </div>
    </div>`).appendTo('body')
    let fun = msg => {
        $alert.find('.alert-msg').text(msg)
        $alert.modal('show')
    }
    return fun
}()

是否开源

由于本组件写的较为仓促,尚有不完善的地方,一些计划的功能尚未实现或代码较为丑陋(丑陋主要是因为依赖的框架、库和协议标准不一致,各自的“缺陷”使然),且和OSS关联较为紧密,运行环境也框死在Nodejs下,没有达到博主心中开源的标准。若关注的朋友较多,那么等忙完了这一阵,空闲时候再考虑完善后开源。


参考资料

Stream highWaterMark misunderstanding
多线程下载一个大文件的速度更快的真正原因是什么?

posted @ 2020-10-28 11:03  莱布尼茨  阅读(1125)  评论(3编辑  收藏  举报