论如何与后端同学battle文件下载需求

150 阅读5分钟

零、前言

在真实项目中,文件下载是常见的需求,但在前后端分离开发作为主流的今天,有的前端同学可能并不清楚如何选择合适的实现方法。
本文将分别讨论基于服务端或客户端的方式去实现需求。

后续代码展示,将使用原生nodeJS启动服务端,及使用原生前端技术开发前端页面,讨论需求实现的方法。

一、服务端方式

1.1 客户端配置

使用服务器配置的方式,前端只需要一个最简朴的a标签,其href属性值就是资源下载的路径。

<a href="http://127.0.0.1:3000/pdf">点击下载</a>    

1.2 服务端配置

服务端需要设置的是Content-Disposition 响应头,设置其值为附件,并且可以使用filename指定下载的默认文件名。

const http = require('http')
const fs = require('fs')

const app = http.createServer((request, response) => {

  const urlInfo = new URL(request.url, 'http://127.0.0.1')
  // 获取请求路径
  const { pathname } = urlInfo
  // 获取请求类型
  const { method } = request
  
  // 请求路径为'/pdf',且请求类型为GET请求时才进行处理
  if (pathname === '/pdf' && method === 'GET') {
  
    // 读取需要发送的文件数据
    const file = fs.readFileSync('./haha.pdf')
    
    // 关键步骤,设置响应头以表明此资源应进行下载,文件名不必与真实文件名相同,但后缀需要正确
    response.setHeader('Content-Disposition', 'attachment; filename=xixi.pdf')

    // 表明响应资源的类型为pdf。实际上不设置也不会影响下载效果。
    response.setHeader('Content-Type', 'application/pdf')

    // 发送数据给客户端
    response.end(file)
  }
})

app.listen('3000', () => {
  console.log('listen 3000...');
})

1.3 总结

通过如上设置后,用户在页面点击a标签后,就会自动触发下载行为,且不会受到跨域的影响,因为点击a标签发送请求的行为并不是ajax技术,浏览器不会对此进行限制。

1.4 补充

之前我认为既然服务端已经正确设置了Content-Disposition响应头,点击a标签是发送get请求,那么我使用ajax技术来发送get请求,是不是也能做到相同效果,能直接触发下载?

不知道有没有小伙伴有过与我相同的疑惑,针对这个疑惑,我实践了一下,发现是不行的。使用ajax发送请求后(注意跨域问题),不会触发下载行为,只会接收到来自服务端的文件数据,并且可以赋值给变量,对数据进行进一步的处理(下文将会讨论使用此方式进行下载)。

二、客户端方式

2.1 服务端配置

服务端只需正确处理请求类型和请求路径,直接将文件数据返回给客户端

const http = require('http')
const fs = require('fs')

const app = http.createServer((request, response) => {

  const urlInfo = new URL(request.url, 'http://127.0.0.1')
  // 获取请求路径
  const { pathname } = urlInfo
  // 获取请求类型
  const { method } = request
  
  // 请求路径为'/pdf',且请求类型为GET请求时才进行处理
  if (pathname === '/pdf' && method === 'GET') {
  
    // 读取需要发送的文件数据
    const file = fs.readFileSync('./haha.pdf')

    // 发送数据给客户端
    response.end(file)
  }

})

app.listen('3000', () => {
  console.log('listen 3000...');
})

2.2 客户端配置

客户端仍然是需要使用a标签,并且需要添加一个重要的属性download
download属性告诉浏览器:用户点击a标签之后需要触发下载行为,其值为文件的默认文件名(不需要与服务器中的文件名字一致)

<a href="http://127.0.0.1:3000/pdf" download="enen.pdf">点击下载</a>  

值得注意的是,设置download属性之后,href的值需要是同源url,才能正确触发下载行为。
如不是同源url,点击a标签之后,将不会下载资源,而是在网页中展示出来(但遇到浏览器不能识别的文件时,是会触发下载行为的。这种情况在2.4中会展开讨论)。

2.3 总结

使用客户端的方式,虽然不需与后端同学battle就可实现,但href的值需要是同源url,这在某些项目中可能并不适用,但如果项目部署时使用了nginx代理,那么这不失为一个可行的方案。

2.4 补充

有一个细节:当使用a标签请求回来的资源,浏览器无法识别时(如word文档,excel文档等),是会自动触发下载行为的(即使服务端没有设置Content-Disposition或a标签没有设置download属性)。

即,当客户端不设置download属性,且服务端不设置Content-Disposition(而返回了文件数据)时,用户点击a标签时:

  1. 浏览器能认识服务器返回的资源数据(如pdf文件,PNG图片等),则会直接展示在网页中。
  2. 浏览器不认识服务器返回的资源数据(如word文档),则会自动触发下载行为。

三、总结

如上两种方式都可以实现文件下载,它们都有各自需要注意的配置点。
但个人认为服务端配置Content-Disposition响应头的方式似乎是更加方便,通用的方式。

四、拓展:当下载资源时需要验证用户身份

这里拓展一个场景:在某个网页中,前后端使用token进行会话管理,客户端向服务端发送请求时,需要在请求头中携带token,以便服务端鉴别其身份,如果用户有权限,才会返回正确的数据。

其实在用户点击a标签,发送请求时,浏览器是会自动在请求报文中携带同源cookie的。但上述场景中,此网站使用的是token而不是cookie,这就无法被自动携带,且也不能以编码的方式,使点击a标签时携带token。

4.1 解决方法

遇到上述情况,我们可以使用ajax技术,并携带token,获取文件数据后,在前端生成一个临时的url和临时的a标签,并使用JS模拟点击a标签,以实现下载。

// 获取“下载”按钮
const oBtn = document.getElementById('download-btn')
oBtn.addEventListener('click', handleDownload)

async function handleDownload() {
    // 使用ajax技术,获取文件数据。(使用XHR同理)
    const res = await fetch('http://127.0.0.1:3000/pdf', {
        header: {
            // 手动携带手动携带token
            authorization: `Bearer ${localStorage.getItem('token')}`
        }
    })
    
    // 将数据处理为Blob形式。
    const blob = await res.blob()
    // 在前端生成临时url,代表了文件数据
    const url = URL.createObjectURL(blob)
    
    // 生成一个临时a标签
    const a = document.createElement('a')
    // 设置其download属性,以便触发其下载行为
    a.download = 'haha.pdf'
    a.href = url
    // 触发下载行为
    a.click();
}

上述方法使用ajax技术,发送GET请求并携带token,获取了文件数据,并使用res变量保存数据,再作后续操作。这在处理一般需求时,的确是可行且简单的方式。只需要服务端设置CORS允许跨域请求即可。

4.2 优化:大文件下载

在这里补充一点:当直接用户直接点击a标签,触发下载行为后,服务器会源源不断地发送数据给客户端,但是这些数据将不再被浏览器所接收,而是直接被写入到计算机的硬盘中(即直接保存到选中的文件夹中)。

我们接下来再思考一种情况,当需要下载的文件较大时,使用上述方法实现下载,是否就存在以下瑕疵了:

  1. 文件数据需要先完全下载到浏览器后,才能模拟点击a标签触发下载行为,可能增加用户等待时间,影响用户体验。
  2. 文件数据需要下载并使用变量接收,换而言之,这些数据需要先保存在计算机内存中,可能会增加浏览器或计算机负担。

那么要对这种情况进行优化,现在我们将拥有两个问题:

  1. 需要下载的文件较大,不能直接保存在计算机内存中。
  2. 发送请求时需要携带token(用户身份证明)。

其实对于上述这两个问题,棘手的地方在于:当前网站使用token进行会话管理。如果使用的是cookie,那么用户直接点击真实a标签就可以自动携带,进行下载了。

那么我们是否可以考虑使用ajax技术发送请求,并手动携带token,服务端校验了用户的身份之后,为其生成一个cookie(过期时间较短),并返回下载文件的url给客户端。
当客户端收到响应报文之后,cookie就会自动设置,然后再生成a标签,设置其href属性和download属性,然后模拟点击,触发下载(此时cookie就会被自动携带,服务端也就能鉴别用户信息了。)

const oBtn = document.getElementById('download-btn')
oBtn.addEventListener('click', handleDownload)

async function handleDownload() {
    // 使用ajax技术,携带token,收到响应报文后,cookie将被自动设置
    const res = await fetch('http://127.0.0.1:3000/pdf', {
        header: {
            authorization: `Bearer ${localStorage.getItem('token')}`
        }
    })
    // 服务端返回的数据是真实下载文件的url,而不是文件数据
    const data = await res.json()
    
    const a = document.createElement('a')
    a.download = 'haha.pdf'
    // 设置服务端返回的文件下载地址
    a.href = data.url
    // 点击a标签,cookie将会被自动携带
    a.click();
}

注意:使用这种方法,文件下载的url需要是同源url(或部署项目时使用nginx进行代理)。