前端檔案下載和瀏覽器自動嗅探

HelKyle發表於2019-03-03

這篇文章聊聊跟前端檔案下載相關的一些知識。

說到前端下載檔案,我最先想到的是在學校的時候,自己搭建 nginx + php 環境,之後開啟頁面 http://localhost:80/index.php, 卻奇怪的發現,每次開啟都會變成檔案下載。

index.php

後來我才知道,請求頭裡面會有 Accept 欄位,響應頭裡面會有 Content-Type 欄位,前者用來告訴 S 端能接受哪些型別的內容,後者告訴 C 端返回來的又是什麼型別的內容。

MIME

MIME 是一種標準化的方式來表示文件的性質和格式,瀏覽器通常使用 MIME 來確定型別(而不是副檔名)。

content-type 使用的都是 MIME 型別,jpg 檔案對應 image/jpeg , js 檔案對應 application/javascript,xlsx 則是 application/vnd.openxmlformats-officedocument.spreadsheetml.sheet

MIME 有兩種預設型別:

  • text/plain 表示文字檔案的預設值。一個文字檔案應當是人類可讀的,並且不包含二進位制資料。
  • application/octet-stream 表示所有其他情況的預設值。一種未知的檔案型別應當使用此型別。

完整的 MIME 型別列表

?index.php 會變成檔案下載的原因是我由於安裝錯誤,沒有正確解析 php 檔案,nginx 直接訪問到檔案,並加上預設 contentType application/octet-stream。因為 Chrome 不能執行 application/octet-stream 格式的檔案,預設操作是把它下載下來,(不同瀏覽器對待不能處理的檔案執行的操作不一樣,有些瀏覽器則會嘗試去嗅探)。

這也能解釋為什麼我們直接訪問https://xxx/foo/bar.zip 等資源的時候,瀏覽器會直接下載。

插播安全小課堂:

當服務端返回瀏覽器不支援的 MIME 型別,部分瀏覽器會嘗試去嗅探它,幫大意的開發者修正這一錯誤,但這可能會導致你的網站遭受攻擊。比方說,使用者上傳一張大熊貓圖片,內容如下:

evil

實際上是個 html 檔案,但是字尾名寫成 jpeg 上傳。這時候服務端如果沒有設定 contentType 直接讀取檔案返回給前端。

# koa router 演示程式碼
router.get('/assets/:file.jpeg', (ctx) => {
  ctx.body = fs.createReadStream(`./public/assets/${ctx.params.file}.jpeg`);
});
複製程式碼

好心的瀏覽器拿到 MIME type 為 application/octet-stream,再讀取內容發現,誒,這是個 html 啊,我們應該展現直接展示出來。???

evil

使用者看到可愛的大熊貓同時,順便把個人資訊也告訴了黑客。

為了避免發生這種安全事故,設定

  • 給返回內容加上對應的 contentType。
  • 新增響應頭X-Content-Type-Options: nosniff,讓瀏覽器不要嘗試去嗅探
router.get('/assets/:file.jpeg', (ctx) => {
  ctx.type = 'image/jpeg';
  ctx.set('X-Content-Type-Options', 'nosniff');
  ctx.body = fs.createReadStream(`./public/assets/${ctx.params.file}.jpeg`);
});
複製程式碼

僅作為演示用,koa 提供靜態資源服務應該用 koa-static 等開源包,它們會自動加上 contentType。

如何讓瀏覽器下載圖片

上面說了對應瀏覽器不支援的文件型別,預設會下載。那對於能處理的那些型別呢?比如圖片,js,json 等內容呢?

以 json 為例,由於瀏覽器知道怎麼解析,會在頁面上列印出 json 的內容。

json

如果需求就是讓使用者下載 json 檔案怎麼辦呢?

有另外一個響應頭部欄位 Conten-disposition ? ,Content-Disposition 指定響應的內容該以哪種形式展示,是以內聯的形式(即網頁或者頁面的一部分),還是以附件的形式下載並儲存到本地,分別對應 inlineattachment

Content-Disposition: inline
Content-Disposition: attachment
複製程式碼

attachment 模式,還可以指定下載檔案的檔名和副檔名。

Content-Disposition: attachment; filename="filename.jpg"
複製程式碼

示例程式碼:

router.get('/hello.json', (ctx) => {
  ctx.type = 'application/json';
  ctx.set('Content-Disposition', 'attachment; filename="hello.json"');
  // 上面兩行程式碼,可以簡寫成 ctx.attachment('hello.json');
  ctx.body = {
    hello: 'world',
  };
});
複製程式碼

然後訪問剛才的路由,就能看到檔案下載下來了。

export

HTML Download 屬性

還有一種方式讓瀏覽器把檔案儲存到本地。就是 html5 a 標籤增加的 download 屬性。

<a href="/images/xxx.jpg" download="panda.jpg" >My Panda</a>
複製程式碼

當使用者點選標籤時會去下載 href 指定的檔案,並且 download 屬性的 value 對應的就是下載檔案的名字。更靈活地方式是封裝成方法,動態建立 link,觸發 click 直接下載並另存為。

<script>
function downloadAs (url, fileName) {
  const link = document.createElement('a');
  link.href = url;
  link.download = fileName;
  link.target = '_blank'

  document.body.appendChild(link);
  link.click();
  link.remove();
}

downloadAs('http://localhost:3001/hello.json', 'world.json');
</script>
複製程式碼

發起非同步獲取資源再下載

還有些場景,只能通過非同步請求返回二進位制內容再由前端下載。

藉助 download 屬性,結合 Blob, Url.createObjectURL() 可以實現前端非同步請求資源並匯出檔案。

const xhr = new XMLHttpRequest();
xhr.open('GET', 'http://localhost:3001/pack.zip');
xhr.responseType = 'blob';

xhr.onload = function () {
  const blob = xhr.response;
  const url = URL.createObjectURL(blob);
  downloadAs(url, 'mypack.zip');
  URL.revokeObjectURL(url);
};
xhr.send();
複製程式碼

設定 xhr.responseType = 'blob' 那麼請求正常完成時 xhr.response 得到的就是 Blob 物件,URL.createObjectURL(Blob),得到一個 blob 的連結,形如:blob:http://localhost:3001/11a01a60-e10c-4515-825f-fb4a4219b33b。然後就能直接當成普通 url 給 a 標籤設定 href。

async-download

Blob 物件表示一個不可變、原始資料的類檔案物件。File 物件也是基於它擴充套件的,暫時理解為抽象的檔案物件。

通過 URL.createObjectURL 會建立一個連結到 Blob 或 File 物件的 URL。這個 URL 的生命週期跟視窗繫結,避免記憶體洩漏用完應該呼叫URL.revokeObjectURL()釋放。

Blob 可以接受的 Javascript 原生型別資料作為引數,比方說純前端造 mock 資料,並匯出成 csv 檔案。

const rows = [
  ["id", "firstname", "lastname"],
  ["1", "foo", "foo"],
  ["2", "bar", "baz"],
];

const data = rows.reduce(function(cur, next) {
  return cur + next.join(',') + '\n';
}, '');
const blob = new Blob([data]);
const url = URL.createObjectURL(blob);
downloadAs(url, 'mock.csv');
複製程式碼

相容性

download 屬性的相容性並不高,目前只有只有 80%。可以直接使用 FileSaver.js 做了 fallback 處理。

download

擴充套件閱讀

吐槽

這篇文章原本標題叫《宇宙最強前端拖拽上傳和檔案下載》,寫到一半查資料的時候發現掘金已經有很多人寫過類似的文章。

心態崩了,改稿已經來不及,就這樣吧。(浪費了大半天時間)
祝大家春節快樂,年終獎紅紅火火。

相關文章