多种方式实现前端图片下载

12/28/2020 JS

图片下载的功能,相信大家或多或少都有接触过.刚好这两天在回看以前项目中的代码时看见有这么一个需求,就是生成一张图片并下载.因为是个管理后台项目,并没有很多人使用,因此当时的代码写的也比较简陋.正好趁空点的时间,优化下代码,把有关下载图片的这块内容捋一下.

# 通过 a 标签 download 属性下载

.container {
  display: flex;
}
figure {
  display: flex;
  flex-direction: column;
}
img {
  width: 200px;
}
<div class="container">
  <figure>
    <figcaption>本地图片:</figcaption>
    <img src="images/gao.jpg" alt="" />
    <a href="images/gao.jpg">
      下载图片1
    </a>
  </figure>

  <figure>
    <figcaption>远程图片:</figcaption>
    <img
      src="https://ssyerv1.oss-cn-hangzhou.aliyuncs.com/picture/337f923e0cbd4b7b9a328135dec89c62.jpg?x-oss-process=image/resize,w_400"
      alt=""
    />
    <a
      href="https://ssyerv1.oss-cn-hangzhou.aliyuncs.com/picture/337f923e0cbd4b7b9a328135dec89c62.jpg?x-oss-process=image/resize,w_400"
    >
      下载图片2
    </a>
  </figure>
</div>

css 和 html 代码如上,首先我们以File 协议打开页面,就是我们平时最常用的双击一个 html 文件自动打开的协议格式,类似于 file:///Users/xxx/Desktop/index.html 这种形式的.

screenshot

点击下载图片就会直接在浏览器打开图片并显示,如下图:

screenshot

同样的,假如我们启动一个http 服务器,以http 协议来打开页面,比如 vscode 安装了 Live Server 等插件,url 格式类似于http://192.168.104.72:8080/index.html 这种形式的. 再次点击两个下载图片结果还是一样的,都是直接在浏览器中打开了.

那么我们有没有什么办法通过 a 标签来实现直接下载图片呢?有的,在MDN (opens new window)文档中提到了,a 标签具有一个叫做 download 的属性.这个属性会指示浏览器去下载 URL 而不是导航到它.如果这个属性有一个值的话,比如 download="test.png",那么这个test.png 将作为下载下来的图片的图片名.但是这个属性仅适用于同源URL.至于同源URL是啥,这个就不在这里做详细的解释了,不清楚的同学去MDN (opens new window) 看看就明白了,说白了就是浏览器的同源策略.

到现在我们明白了,HTML5 中 a 标签给我们提供了download 属性,就是让我们用来下载的.下面我们将此属性加入进去.修改后的代码如下,其余部分同上面保持一致,这里就不写出来了.

<a href="images/gao.jpg" download>
  下载图片1
</a>

<a
  href="https://ssyerv1.oss-cn-hangzhou.aliyuncs.com/picture/337f923e0cbd4b7b9a328135dec89c62.jpg?x-oss-process=image/resize,w_400"
  download
>
  下载图片2
</a>

修改后的代码,我们再次点击两个页面(同一个页面,一个以 file 协议打开,另一个以 http 协议打开)的下载,发现此时以 http 协议打开的页面的下载图片1 已经可以实现下载图片的功能了.即是在同源的情况下,download属性确实实现了下载的功能.那么对于跨域的图片,我们又该怎么下载呢?因为在实际的生产环境,我们很有可能是把图片放到 oss 上,通过 cdn 访问图片.

# 通过 js 触发 a 标签下载

# 直接生成 a 标签下载

这个方法的本质就是使用 js 生成一个 a 标签,然后添加 download 属性实现下载,和上面介绍的直接使用 a 标签下载并没有什么本质的区别.

<div class="container">
  <figure>
    <figcaption>本地图片:</figcaption>
    <img id="img1" src="images/ball_scroll.gif" alt="" />
    <button id="btn1">下载图片</button>
  </figure>
</div>
let btn1 = document.querySelector('#btn1')
btn1.onclick = function() {
  download('images/ball_scroll.gif', 'ball_scroll_copy.gif')
}
function download(link, filename) {
  let a = document.createElement('a')
  a.href = link
  a.download = filename || 'default.png'
  a.dispatchEvent(new MouseEvent('click'))
}

# 将图片转为 base64

到这里我们就开始介绍该怎么下载跨域的图片了.首先介绍的第一个方法是将图片资源请求到之后转为 canvas ,再转为 base64,最后生成图片下载的这么一个流程.

<div class="container">
  <figure>
    <figcaption>本地图片:</figcaption>
    <img src="images/gao.jpg" alt="" />
    <button id="btn1">下载图片</button>
  </figure>
</div>
let btn1 = document.querySelector('#btn1')
btn1.onclick = function() {
  download('images/gao.jpg', '888.png')
}
function download(link, picName) {
  let img = new Image()
  img.setAttribute('crossOrigin', 'Anonymous')
  img.onload = function() {
    let canvas = document.createElement('canvas')
    let context = canvas.getContext('2d')
    canvas.width = img.width
    canvas.height = img.height
    context.drawImage(img, 0, 0, img.width, img.height)
    let url = canvas.toDataURL('images/png')
    let a = document.createElement('a')
    let event = new MouseEvent('click')
    a.download = picName || 'default.png'
    a.href = url
    a.dispatchEvent(event)
  }
  img.src = link + '?v=' + Date.now()
}

其中toDataURL 方法返回一个包含图片展示的 data URI,即前缀为data: 协议的 URL.我们可以将图片转为base64 ,也可以将 base64 转为图片.点击工具网站 (opens new window),自行上传一张图片或者一段 base64 编码,可以查看效果.

在上面的代码中,我们下载的还是本地的资源.若是我们去下载一个 oss 上的图片,极有可能还是会出现跨域的报错.那么为了解决这个问题,这里我们需要做的事情有两个.第一个就是去我们的 oss 上配置跨域访问 CORS 的规则,让它能够允许访问.我今天花了不少时间在解决这个跨域问题,后来才发现原来我在 oss 上并没有配置规则.至于配置规则,每个厂商都有自己的方式,不过基本大同小异.因为我现在使用的企鹅的 COS,所以就贴一下我的配置规则.

cos的跨域配置规则

而在开启 CORS 规则之后,我们去下载图片,还是会有可能报跨域的错误,这也太蛋疼了吧.这是因为 cdn 缓存的缘故,当我们请求一张图片的时候,cdn 会先判断是否有这个资源.若没有的话则会去源站请求资源,若有的话则会直接将此资源返回.而 cdn 的配置则有可能是缓存了我们之前未设置 CORS 时候的规则.所以这时我们可以给请求的图片路径最后拼接上一个时间戳来解决这个问题.大家有兴趣的话,可以将代码中最后添加的时间戳去掉看一下效果.

# 将图片转为 blob

let btn1 = document.querySelector('#btn1')
btn1.onclick = function() {
  download('images/gao.jpg')
}
function download(link, picName) {
  let img = new Image()
  img.setAttribute('crossOrigin', 'Anonymous')
  img.onload = function() {
    let canvas = document.createElement('canvas')
    let context = canvas.getContext('2d')
    canvas.width = img.width
    canvas.height = img.height
    context.drawImage(img, 0, 0, img.width, img.height)
    canvas.toBlob((blob) => {
      let url = URL.createObjectURL(blob)
      let a = document.createElement('a')
      let event = new MouseEvent('click')
      a.download = picName || 'default.png'
      a.href = url
      a.dispatchEvent(event)
      URL.revokeObjectURL(url) // 内存管理,将这句代码注释掉,则将以 blob:http 开头的url复制到浏览器地址栏有效,否则无效.
    })
  }
  img.src = link + '?v=' + Date.now()
}

其中toBlob 方法可以创建 Blob 对象,用以展示 canvas 上的图片,这个图片文件可以被缓存或保存到本地.此方法可以传入三个参数,第一个就是上面的回调函数,第二个是图片格式,默认为image/png,第三个参数是介于 0 与 1 之间的一个数字,表示图片的质量,数值越高,质量越好.但是只有当第二个参数是image/jpegimage/webp 的时候才有效.而 createObjectURL 方法则可以创建一个 DOMString,其中包含了一个表示参数中给出的对象的 URL.这个 url 大概长这样:blob:http://192.168.104.72:8080/39f8b0f7-b946-4fc5-af70-d0562469a0f4 ,将其复制到浏览器的地址栏中可以看到图片,注意看地址哦.

Blob

但是这个 url 的生命周期和创建它的窗口中的 document 是绑定的,这又是什么意思呢? 意思就是只要我之前的那个 html 页面还在,那这个 url 就有效.要是我把那个 html 页面关掉了或者刷新了,那么这个 url 也就跟着失效了.这里需要注意一点的是,在每次调用 createObjectURL 方法的时候,都会创建一个新的 URL 对象,当我们不需要这些 URL 对象的时候,通过 URL.revokeObjectURL() 来释放一个之前已经存在的对象,以便更好的内存管理.

# XMLHttpRequest 配合 blob 下载 gif

但是上面两个通过 canvasbase64blob 的方法都有个问题,就是只支持 pngjpeg 格式的图片.如果是 gif 格式的话,就只会显示第一帧的内容,因为 canvas 就画了一幅画呀.这里提供一种思路,就是通过一个 XMLHttpRequest 对象去请求一张图片,获取返回的 blob. 在 download.js 中使用的就是这样的思路, github 地址 (opens new window),大概在第 51 行左右开始.

<div class="container">
  <figure>
    <figcaption>本地图片:</figcaption>
    <img id="img1" src="images/ball_scroll.gif" alt="" />
    <button id="btn1">下载图片</button>
  </figure>
</div>
let btn1 = document.querySelector('#btn1')
btn1.onclick = function() {
  download('images/ball_scroll.gif', 'ball_scroll.gif')
}
function download(link, filename) {
  let xhr = new XMLHttpRequest()
  xhr.open('get', link, true)
  xhr.responseType = 'blob'
  xhr.onload = function() {
    let url = URL.createObjectURL(xhr.response)
    let a = document.createElement('a')
    let event = new MouseEvent('click')
    a.href = link
    a.download = filename || 'default.png'
    a.dispatchEvent(event)
    URL.revokeObjectURL(url)
  }
  xhr.send()
}

这样下载 gif 格式的就是一张动图,不再是一张只有第一帧的图片了.若是上面通过 XMLHttpRequest 请求的结果报跨域错误,那就让后端设置下 Access-Control-Allow-Origin* 或者指定域即可.

# JS 下载这么麻烦,为啥还要用 JS

相信对比上面的代码后,大家都发现了直接使用 a 标签加上 download 多方便,为啥还要那么麻烦的用 JS 去做下载功能.其实使用 JS 的原理无非还是使用 a 标签的 download 属性,只是在 JS 中我们可以做更多的逻辑判断.比如这个用户是否有权限下载这张图片,或者这张图片是不是付费下载等.所以说,在啥逻辑都不用管,只管下载的场景下,我们直接使用 a 标签就完事了.但是在有复杂逻辑判断的场景下,我们还是需要使用 JS 来实现下载功能的.

# 补充:文本信息的下载

将文本或者 JS 字符串通过 Blob 转换成二进制下载

function downloadTxt(str, filename) {
  let a = document.createElement('a')
  a.download = filename
  a.style.display = 'none'
  let blob = new Blob([str])
  a.href = URL.createObjectURL(blob)
  document.body.appendChild(a)
  a.dispatchEvent(new MouseEvent('click'))
  document.body.removeChild(a)
}

# 总结

图片下载功能在日常开发中还是比较常见的.其中有单纯前端的下载,也有前端和后端配合的下载.至于采用哪种解决方案就要视具体情况而定了.

参考链接:

    希望像星光一样闪烁
    文雀