從 Fetch 到 Streams —— 以流的角度處理網絡請求

Streams API 示意圖,做者 Mozilla Contributors,基於 CC-BY-SA 2.5 協議使用。html

本文篇幅較長,建議配合目錄食用分次閱讀。前端

本文做者:ccloliios


自第一個實現的瀏覽器開始計算,Fetch API 已經快要五歲了。這五年 Chrome 和 Firefox 刷了很多版本號,IE 也不知死了多少年,而它的繼任者更是上演了一出名爲《Edge: Become Chromium》的好劇。再加上 ES6+ 的普及,咱們早已習慣了基於 Promise 和 async/await 的異步編程,因此估計很多同窗也轉而使用 Fetch API 做異步請求。陪伴了咱們將近 20 年曆史的 XMLHttpRequest 也被很多同窗「打入冷宮」,畢竟誰讓 Fetch API 那麼好用呢?可憐的 XHR 只能獨守空房終日以淚洗面,看着你和 Fetch API 嬉戲的樣子,口中喃喃說着「是我,是我先,明明都是我先來的」——呃,很差意思扯歪了。git

Fetch API 不香嗎?

不不不,沒有這個意思。相比較於 XMLHttpRequest 來講,fetch() 的寫法簡單又直觀,只要在發起請求時將整個配置項傳入就能夠了。並且相較於 XHR 還提供了更多的控制參數,例如是否攜帶 Cookie、是否須要手動跳轉等。此外 Fetch API 是基於 Promise 鏈式調用的,必定程度上能夠避免一些回調地獄。舉個例子,下面就是一個簡單的 fetch 請求:github

fetch('https://example.org/foo', {
    method: 'POST',
    mode: 'cors',
    headers: {
        'content-type': 'application/json'
    },
    credentials: 'include',
    redirect: 'follow',
    body: JSON.stringify({ foo: 'bar' })
}).then(res => res.json()).then(...)
複製代碼

若是你不喜歡 Promise 的鏈式調用的話,還能夠用 async/awaitweb

const res = await fetch('https://example.org/foo', { ... });
const data = await res.json();
複製代碼

再回過頭來看久經風霜的 XMLHttpRequest,若是你已經習慣使用諸如 jQuery 的 $.ajax() 或者 axios 這類更爲現代的封裝 XHR 的庫的話,估計已經忘了裸寫 XHR 是什麼樣子了。簡單來講,你須要調用 open() 方法開啓一個請求,而後調用其餘的方法或者設置參數來定義請求,最後調用 send() 方法發起請求,再在 onload 或者 onreadystatechange 事件裏處理數據。看,這一通下來你已經亂了。ajax

課後習題 Q0:試試看將上面的 fetch 請求用原生 XMLHttpRequest 實現一遍,看看你還記得多少知識?編程

Fetch API 真香嗎?

看起來 Fetch API 相比較於傳統的 XHR 優點很多,不過在「真香」以前,咱們先來看三個在 XHR 上很容易實現的功能:json

  1. 如何中斷一個請求?canvas

    XMLHttpRequest 對象上有一個 abort() 方法,調用這個方法便可中斷一個請求。此外 XHR 還有 onabort 事件,能夠監聽請求的中斷並作出響應。

  2. 如何超時中斷一個請求?

    XMLHttpRequest 對象上有一個 timeout 屬性,爲其賦值後若在指定時間請求還未完成,請求就會自動中斷。此外 XHR 還有 ontimeout 事件,能夠監聽請求的超時中斷並作出響應。

  3. 如何獲取請求的傳輸進度?

    在異步請求一個比較大的文件時,因爲可能比較耗時,展現文件的下載進度在 UI 上會更友好。XMLHttpRequest 提供了 onprogress 事件,因此使用 XHR 能夠很方便地實現這個功能。

    const xhr = new XMLHttpRequest();
    xhr.open('GET', '/foo');
    xhr.addEventListener('progress', (event) => {
        const { lengthComputable, loaded, total } = event;
        if (lengthComputable) {
            console.log(`Downloaded ${loaded} of ${total} (${(loaded / total * 100).toFixed(2)}%)`);
        } else {
            console.log(`Downloaded ${loaded}`);
        }
    });
    xhr.send();
    複製代碼

對於第一個問題其實已經有比較好的解決方案了,只是在瀏覽器上的實現距離 Fetch API 晚了近三年。隨着 AbortControllerAbortSignal 在各大瀏覽器上完整實現,Fetch API 也能像 XHR 那樣中斷一個請求了,只是稍微繞了一點。經過建立一個 AbortController 實例,咱們獲得了一個 Fetch API 原生支持的控制中斷的控制器。這個實例的 signal 參數是一個 AbortSignal 實例,還提供了一個 abort() 方法發送中斷信號。只須要將 signal 參數傳遞進 fetch() 的初始化參數中,就能夠在 fetch 請求以外控制請求的中斷了:

const controller = new AbortController();
const { signal } = controller;
fetch('/foo', { signal }).then(...);
signal.onabort = () => { ... };
controller.abort();
複製代碼

對於第二個問題,既然已經稍微繞路實現中斷請求了,爲什麼再也不繞一下遠路呢?只須要 AbortController 配合 setTimeout() 就能實現相似的效果了。

可是第三個獲取請求進度的問題呢?你打開了 MDN,仔細地看了 fetch() 方法的全部參數,都沒有找到相似 progress 這樣的參數,畢竟 Fetch API 並無什麼回調事件。難道 Fetch API 就不能實現這麼簡單的功能嗎?固然能夠,這裏就要繞一條更遠的路,提一提和它相關的 Streams API 了——不是 Web Socket,也不是 Media Stream,更不是隻能在 Node.js 上使用的 Stream,不過和它很像。

Streams API 能作什麼?

對於非 Web 前端的同窗來講,流應該是個很常見的概念,它容許咱們一段一段地接收與處理數據。相比較於獲取整個數據再處理,流不只不須要佔用一大塊內存空間來存放整個數據,節省內存佔用空間,並且還能實時地對數據進行處理,不須要等待整個數據獲取完畢,從而縮短整個操做的耗時。

此外流還有管道的概念,咱們能夠封裝一些相似中間件的中間流,用管道將各個流鏈接起來,在管道的末端就能拿處處理後的數據。例如,下面的這段 Node.js 代碼片斷實現瞭解壓 zip 中的文件的功能,只須要從 zip 的中央文件記錄表中讀取出各個文件在 zip 文件內的起止偏移值,就能將對應的文件解壓出來。

const input = fs.createReadStream(null, {
    fd, start, end, autoClose: false
});
const output = fs.createWriteStream(outputPath + name);
// 能夠從流中直接讀取數據
input.on('data', (chunk) => { ... });
// 或者直接將流引向另外一個流
input.pipe(zlib.createInflateRaw()).pipe(output);
複製代碼

其中的 input 是一個可讀取的流,output 是一個可寫入的流,而 zlib.createInflateRaw() 就是建立了一個既可讀取又可寫入的流,它在寫入端以流的形式接受 Deflate 壓縮的數據,在讀取端以流的形式輸出解壓縮後的數據。咱們想象一下,若是輸入的 zip 文件是一個上 GB 的大文件,使用流的方式就不須要佔用一樣大小的上 GB 的內存空間。並且從代碼上看,使用流實現的代碼邏輯一樣簡潔和清晰。

很惋惜,過去在客戶端 JavaScript 上並無原生的流 API——固然你能夠本身封裝實現流,好比 JSZip 在 3.0 版本就封裝了一個 StreamHelper,可是基本上除了使用這些 stream 庫的庫之外,沒有其它地方能 產生 兼容這個庫的流了。沒有能產生流的數據源纔是大問題,好比想要讀取一個文件?過去 FileReader 只能在 onload 事件上拿到整個文件的數據,或者對文件使用 slice() 方法獲得 Blob 文件片斷。如今 Streams API 已經在瀏覽器上逐步實現(或者說,早在 2016 年 Chrome 就開始支持一部分功能了),能用上流處理的 API 想必也會愈來愈多,而 Streams API 最先的受益者之一就是 Fetch API。

Streams API 賦予了網絡請求以片斷處理數據的能力,過去咱們使用 XMLHttpRequest 獲取一個文件時,咱們必須等待瀏覽器下載完整的文件,等待瀏覽器處理成咱們須要的格式,收到全部的數據後才能處理它。如今有了流,咱們能夠以 TypedArray 片斷的形式接收一部分二進制數據,而後直接對數據進行處理,這就有點像是瀏覽器內部接收並處理數據的邏輯。甚至咱們能夠將一些操做以流的形式封裝,再用管道把多個流鏈接起來,管道的另外一端就是最終處理好的數據。

Fetch API 會在發起請求後獲得的 Promise 對象中返回一個 Response 對象,而 Response 對象除了提供 headersredirect() 等參數和方法外,還實現了 Body 這個 mixin 類,而在 Body 上咱們纔看到咱們經常使用的那些 res.json()res.text()res.arrayBuffer() 等方法。在 Body 上還有一個 body 參數,這個 body 參數就是一個 ReadableStream

既然本文是從 Fetch API 的角度出發,而如前所述,能產生數據的數據源纔是流處理中最重要的一個部分,那麼下面咱們來重點了解下這個在 Body 中負責提供數據的 ReadableStream

這篇文章不會討論流的排隊策略(也就是下文即將提到的構造流時傳入的 queuingStrategy 參數,它能夠控制流的緩衝區大小,不過 Streams API 有一個開箱即用的默認配置,因此能夠不指定),也不會討論沒有瀏覽器實現的 BYOR reader,感興趣的同窗能夠參考相關規範文檔

ReadableStream

The image of ReadableStream Concept by Mozilla Contributors is licensed under CC-BY-SA 2.5.

ReadableStream 示意圖,做者 Mozilla Contributors,基於 CC-BY-SA 2.5 協議使用。

下面是一個 ReadableStream 實例上的參數和可使用的方法,下文咱們將會詳細介紹它們:

ReadableStream

  • locked
  • cancel()
  • pipeThrough()
  • pipeTo()
  • tee()
  • getReader()

其中直接調用 getReader() 方法會獲得一個 ReadableStreamDefaultReader 實例,經過這個實例咱們就能讀取 ReadableStream 上的數據。

ReadableStream 中讀取數據

ReadableStreamDefaultReader 實例上提供了以下的方法:

ReadableStreamDefaultReader

  • closed
  • cancel()
  • read()
  • releaseLock()

假設咱們須要讀取一個流中的的數據,能夠循環調用 reader 的 read() 方法,它會返回一個 Promise 對象,在 Promise 中返回一個包含 value 參數和 done 參數的對象。

const reader = stream.getReader();
let bytesReceived = 0;
const processData = (result) => {
    if (result.done) {
        console.log(`complete, total size: ${bytesReceived}`);
        return;
    }
    const value = result.value; // Uint8Array
    const length = value.length;
    console.log(`got ${length} bytes data:`, value);
    bytesReceived += length;
    // 讀取下一個文件片斷,重複處理步驟
    return reader.read().then(processData);
};
reader.read().then(processData);
複製代碼

其中 result.value 參數爲此次讀取獲得的片斷,它是一個 Uint8Array,經過循環調用 reader.read() 方法就能一點點地獲取流的整個數據;而 result.done 參數負責代表這個流是否已經讀取完畢,當 result.donetrue 時代表流已經關閉,不會再有新的數據,此時 result.value 的值爲 undefined

回到咱們以前的問題,咱們能夠經過讀取 Response 中的流獲得正在接收的文件片斷,累加各個片斷的 length 就能獲得相似 XHR onprogress 事件的 loaded,也就是已下載的字節數;經過從 Responseheaders 中取出 Content-Length 就能獲得相似 XHR onprogress 事件的 total,也就是總字節數。因而咱們能夠寫出下面的代碼,成功獲得下載進度:

let total = null;
let loaded = 0;
const logProgress = (reader) => {
    return reader.read().then(({ value, done }) => {
        if (done) {
            console.log('Download completed');
            return;
        }
        loaded += value.length;
        if (total === null) {
            console.log(`Downloaded ${loaded}`);
        } else {
            console.log(`Downloaded ${loaded} of ${total} (${(loaded / total * 100).toFixed(2)}%)`);
        }
        return logProgress(reader);
    });
};
fetch('/foo').then((res) => {
    total = res.headers.get('content-length');
    return res.body.getReader();
}).then(logProgress);
複製代碼

看着好像沒問題是吧?問題來了,數據呢?我那麼大一個返回數據呢?上面的代碼只顧着輸出進度了,結果並無把返回數據傳回來。雖然咱們能夠直接在上面的代碼裏處理二進制數據片斷,但是有時咱們仍是會偷懶,直接獲得完整的數據進行處理(好比一個巨大的 JSON 字符串)。

若是咱們但願接收的數據是文本,一種解決方案是藉助 TextDecoder 獲得解析後的文本並拼接,最後將整個文本返回:

let text = '';
const logProcess = (res) => {
    const reader = res.body.getReader();
    const decoder = new TextDecoder('utf-8');
    const push = ({ value, done }) => {
        if (done) return JSON.parse(text);
        text += decoder.decode(value, { stream: true });
        // ...
        return reader.read().then(push);
    };
    return reader.read().then(push);
};
fetch('/foo').then(logProgress).then((res) => { ... });
複製代碼

不過若是你犯了強迫症,必定要像原來那樣顯示調用 res.json() 之類的方法獲得數據,這該怎麼辦呢?既然 fetch() 方法返回一個 Response 對象,而這個對象的數據已經在 ReadableStream 中讀取下載進度時被使用了,那我再構造一個 ReadableStream,外面再包一個 Response 對象並返回,問題不就解決了嗎?

構造一個 ReadableStream

構造一個 ReadableStream 時能夠定義如下方法和參數:

const stream = new ReadableStream({
    start(controller) {
        // start 方法會在實例建立時馬上執行,並傳入一個流控制器
        controller.desiredSize
            // 填滿隊列所需字節數
        controller.close()
            // 關閉當前流
        controller.enqueue(chunk)
            // 將片斷傳入流的隊列
        controller.error(reason)
            // 對流觸發一個錯誤
    },
    pull(controller) {
        // 將會在流的隊列沒有滿載時重複調用,直至其達到高水位線
    },
    cancel(reason) {
        // 將會在流將被取消時調用
    }
}, queuingStrategy); // { highWaterMark: 1 }
複製代碼

而構造一個 Response 對象就簡單了,Response 對象的第一個參數便是返回值,能夠是字符串、BlobTypedArray,甚至是一個 Stream;而它的第二個參數則和 fetch() 方法很像,也是一些初始化參數。

const response = new Response(source, init);
複製代碼

瞭解以上的內容後,咱們只須要構造一個 ReadableStream,而後把「從 reader 中循環讀取數據」的邏輯放在這個流的 start() 方法內,它會在流實例化後當即調用。當 reader 讀取數據時能夠輸出下載進度,同時調用 controller.enqueue() 把獲得的數據推動咱們構造出來的流,最後在讀取完畢時調用 controller.close() 關閉這個流,問題就能輕鬆解決。

const logProgress = (res) => {
    const total = res.headers.get('content-length');
    let loaded = 0;
    const reader = res.body.getReader();
    const stream = new ReadableStream({
        start(controller) {
            const push = () => {
                reader.read().then(({ value, done }) => {
                    if (done) {
                        controller.close();
                        return;
                    }
                    loaded += value.length;
                    if (total === null) {
                        console.log(`Downloaded ${loaded}`);
                    } else {
                        console.log(`Downloaded ${loaded} of ${total} (${(loaded / total * 100).toFixed(2)}%)`);
                    }
                    controller.enqueue(value);
                    push();
                });
            };
            push();
        }
    });
    return new Response(stream, { headers: res.headers });
};
fetch('/foo').then(logProgress).then(res => res.json()).then((data) => { ... });
複製代碼

分流一個 ReadableStream

感受是否是繞了一個遠路?就爲了這點功能咱們竟然構造了一個 ReadableStream 實例?有沒有更簡單的方法?實際上是有的,若是你稍有留意的話,應該會注意到 ReadableStream 實例上有一個名字看起來有點奇怪的 tee() 方法。這個方法能夠將一個流分流成兩個如出一轍的流,兩個流能夠讀取徹底相同的數據。

The image of Teeing a ReadableStream by Mozilla Contributors is licensed under CC-BY-SA 2.5.

分流 ReadableStream 示意圖,做者 Mozilla Contributors,基於 CC-BY-SA 2.5 協議使用。

因此咱們能夠利用這個特性將一個流分紅兩個流,將其中一個流用於輸出下載進度,而另外一個流直接返回:

const logProgress = (res) => {
    const total = res.headers.get('content-length');
    let loaded = 0;
    const [progressStream, returnStream] = res.body.tee();
    const reader = progressStream.getReader();
    const log = () => {
        reader.read().then(({ value, done }) => {
            if (done) return;
            // 省略輸出進度
            log();
        });
    };
    log();
    return new Response(returnStream, { headers: res.headers });
};
fetch('/foo').then(logProgress).then(res => res.json()).then((data) => { ... });
複製代碼

另外其實 fetch 請求返回的 Response 實例上有一個一看就知道是什麼意思的 clone() 方法,這個方法能夠獲得一個克隆的 Response 實例。因此咱們能夠將其中一個實例用來獲取流並獲得下載進度,另外一個實例直接返回,這樣就省去了構造 Response 的步驟,效果是同樣的。其實這個方法通常用在 Service Worker 裏,例如將請求獲得的結果緩存起來等等。

課後習題 Q1:若是咱們調用了流的 tee() 方法獲得了兩個流,但咱們只讀取了其中一個流,另外一個流在以後讀取,會發生什麼嗎?

很好,下載進度的問題完美解決了,那麼讓咱們回到最先的問題。Fetch API 最先是沒有 signal 這個參數的,因此早期的 fetch 請求很難中斷——對,是「很難」,而不是「不可能」。若是瀏覽器實現了 ReadableStream 並在 Response 上提供了 body 的話,是能夠經過流的中斷實現這個功能的。

中斷一個 ReadableStream

總結一下咱們如今已經知道的內容,fetch 請求返回一個 Response 對象,從中能夠獲得一個 ReadableStream,而後咱們還知道了如何本身構造 ReadableStreamResponse 對象。再回過頭看看 ReadableStream 實例上還沒提到的方法,想必你必定注意到了那個 cancel() 方法。

經過 ReadableStream 上的 cancel() 方法,咱們能夠關閉這個流。此外你可能也注意到 reader 上也有一個 cancel() 方法,這個方法的做用是關閉與這個 reader 相關聯的流,因此從結果上來看,二者是同樣的。而對於 Fetch API 來講,關閉返回的 Response 對象的流的結果就至關於中斷了這個請求。

因此,咱們能夠像以前那樣構造一個 ReadableStream 用於傳遞從 res.body.getReader() 中獲得的數據,並對外暴露一個 aborter() 方法。調用這個 aborter() 方法時會調用 reader.cancel() 關閉 fetch 請求返回的流,而後調用 controller.error() 拋出錯誤,中斷構造出來的傳遞給後續操做的流:

let aborter = null;
const abortHandler = (res) => {
    const reader = res.body.getReader();
    const stream = new ReadableStream({
        start(controller) {
            let aborted = false;
            const push = () => {
                reader.read().then(({ value, done }) => {
                    if (done) {
                        if (!aborted) controller.close();
                        return;
                    }
                    controller.enqueue(value);
                    push();
                });
            };
            aborter = () => {
                reader.cancel();
                controller.error(new Error('Fetch aborted'));
                aborted = true;
            };
            push();
        }
    });
    return new Response(stream, { headers: res.headers });
};
fetch('/foo').then(abortHandler).then(res => res.json()).then((data) => { ... });
aborter();
複製代碼

課後習題 Q2:從上面的結果來看,當咱們調用 aborter() 方法時,請求被成功停止了。不過若是不調用 controller.error() 拋出錯誤強制中斷流,而是繼續以前的流程調用 controller.close() 關閉流,會發生什麼事嗎?

流的鎖機制

或許你仍是很奇怪,既然流自己就有一個 cancel() 方法,爲何咱們不直接暴露這個方法,反而要繞路構造一個新的 ReadableStream 呢?例如像下面這樣:

let aborter = null;
const abortHandler = (res) => {
    aborter = () => res.body.cancel();
    return res;
};
fetch('/foo').then(abortHandler).then(res => res.json()).then((data) => { ... });
aborter();
複製代碼

惋惜這樣執行會獲得下面的錯誤:這個流被鎖了。

TypeError: Failed to execute 'cancel' on 'ReadableStream': Cannot cancel a locked stream
複製代碼

你不信邪,既然流的 reader 被關閉時會關閉相關聯的流,那麼只要再獲取一個 reader 並 cancel() 不就行了?

let aborter = null;
const abortHandler = (res) => {
    aborter = () => res.body.getReader().cancel();
    return res;
};
fetch('/foo').then(abortHandler).then(res => res.json()).then((data) => { ... });
aborter();
複製代碼

惋惜這樣執行仍是會獲得下面的錯誤:

TypeError: Failed to execute 'getReader' on 'ReadableStream': ReadableStreamReader constructor can only accept readable streams that are not yet locked to a reader
複製代碼

或許你還會想,像以前那樣使用 tee() 克隆一個流,而後關閉克隆的流不就行了?惋惜即使成功調用了其中一個流的 cancel() 方法,請求仍是沒有中斷,由於另外一個流並無被中斷,而且還在不斷地接收數據。

因而咱們接觸到了流的鎖機制。一個流只能同時有一個處於活動狀態的 reader,當一個流被一個 reader 使用時,這個流就被該 reader 鎖定了,此時流的 locked 屬性爲 true。若是這個流須要被另外一個 reader 讀取,那麼當前處於活動狀態的 reader 能夠調用 reader.releaseLock() 方法釋放鎖。此外 reader 的 closed 屬性是一個 Promise,當 reader 被關閉或者釋放鎖時,這個 Promise 會被 resolve,能夠在這裏編寫關閉 reader 的處理邏輯:

reader.closed.then(() => {
  console.log('reader closed');
});
reader.releaseLock();
複製代碼

但是上面的代碼彷佛沒用上 reader 啊?再仔細思考下 res => res.json() 這段代碼,是否是有什麼啓發?

讓咱們翻一下 Fetch API 的規範文檔,在 5.2. Body mixin 中有以下一段話:

Objects implementing the Body mixin also have an associated consume body algorithm, given a type, runs these steps:

  1. If this object is disturbed or locked, return a new promise rejected with a TypeError.

  2. Let stream be body’s stream if body is non-null, or an empty ReadableStream object otherwise.

  3. Let reader be the result of getting a reader from stream. If that threw an exception, return a new promise rejected with that exception.

  4. Let promise be the result of reading all bytes from stream with reader.

  5. Return the result of transforming promise by a fulfillment handler that returns the result of the package data algorithm with its first argument, type and this object’s MIME type.

簡單來講,當咱們調用 Body 上的方法時,瀏覽器隱式地建立了一個 reader 讀取了返回數據的流,並建立了一個 Promise 實例,待全部數據被讀取完後再 resolve 並返回格式化後的數據。因此,當咱們調用了 Body 上的方法時,其實就建立了一個咱們沒法接觸到的 reader,此時這個流就被鎖住了,天然也沒法從外部取消。

示例:斷點續傳

如今咱們能夠隨時中斷一個請求,以及獲取到請求過程當中的數據,甚至還能修改這些數據。或許咱們能夠用來作些有趣的事情,好比各個下載器中很是流行的斷點續傳功能。

首先咱們先來了解下斷點續傳的原理,簡述以下:

  1. 發起請求
  2. 從響應頭中拿到 Content-Length 屬性
  3. 在響應過程當中拿到正在下載的數據
  4. 終止下載
  5. 從新下載,可是此時根據已經拿到的數據設置 Range 請求頭
  6. 重複步驟 3-5,直至下載完成
  7. 下載完成,將已拿到的數據拼接成完整的

在過去只能使用 XMLHttpRequest 或者尚未 Stream API 的時候,咱們只能在請求完成時拿到數據。若是期間請求中斷了,那也不會獲得已經下載的數據,也就是這部分請求的流量被浪費了。因此斷點續傳最大的問題是獲取已拿到的數據,也就是上面的第 3 步,根據已拿到的數據就能算出還有哪些數據須要請求。

其實在 Streams API 誕生以前,你們已經有着各類各樣奇怪的方式實現斷點續傳了。例如國外的 Mega 網盤在下載文件時不會直接通知瀏覽器下載,而是先把數據放在瀏覽器內,傳輸完成後再下載文件。此外它還能夠暫停傳輸,在瀏覽器內實現了斷點續傳的功能。仔細觀察網絡請求就會發現,Mega 在下載時不是下載整個文件,而是下載文件的一個個小片斷。因此 Mega 是經過創建多個小的請求獲取文件的各個小片斷,待下載完成後再拼接爲一個大文件。即使用戶中途暫停,已下載的塊也不會丟失,繼續下載時會從新請求未完成的片斷。雖然暫停時正在下載的片斷仍是會被丟棄(注意下面的視頻中,暫停下載後從新請求的 URL 和以前的請求是同樣的),不過相比較於丟棄整個文件來講,如今的實現已是很大的優化了。

除了創建多個小請求獲得零散文件塊,變相實現斷點續傳外,其實 Firefox 瀏覽器上的私有特性容許開發者獲取正在下載的文件片斷,例如雲音樂就使用了該特性優化了 Firefox 瀏覽器上的音頻文件請求。Firefox 瀏覽器的 XMLHttpRequestresponseType 屬性提供了私有的可用參數 moz-chunked-arraybuffer。請求還未完成時,能夠在 onprogress 事件中請求 XHR 實例的 response 屬性,它將會返回上一次觸發事件後接收到的數據,而在 onprogress 事件外獲取該屬性將始終是 null

let chunks = [];
const xhr = new XMLHttpRequest();
xhr.open('GET', '/foo');
xhr.responseType = 'moz-chunked-arraybuffer';
xhr.addEventListener('progress', (event) => {
    chunks.push(xhr.response);
});
xhr.addEventListener('abort', () => {
    const blob = new Blob(chunks);
});
xhr.send();
複製代碼

看起來是個很不錯的特性,只惋惜在 Bugzilla 上某個 和雲音樂相關的 issue 裏,有人發現這個特性已經在 Firefox 68 中移除了。緣由也能夠理解,Firefox 如今已經在 fetch 上實現 Stream API 了,有標準定義固然仍是跟着標準走(雖然至今仍是 LS 階段),因此也就再也不須要這些私有屬性了。

從以前的示例咱們已經知道,咱們能夠從 fetch 請求返回的 ReadableStream 裏獲得正在下載的數據片斷,只要在請求的過程當中把它們放在一個相似緩衝區的地方就能夠實現以前的第 3 步了,而這也是在瀏覽器上實現這個功能的難點。請求中斷後再次請求時,只須要根據已下載片斷的字節數就能夠算出接下來要請求哪些片斷了。簡單來看,邏輯大概是下面這樣:

const chunks = [];
let length = 0;
const chunkCache = (res) => {
    const reader = res.body.getReader();
    const stream = new ReadableStream({
        start(controller) {
            const push = () => {
                reader.read().then(({ value, done }) => {
                    if (done) {
                        let chunk;
                        while (chunk = chunks.shift()) {
                            controller.enqueue(chunk);
                        }
                        controller.close();
                        return;
                    }
                    chunks.push(value);
                    length += value.length;
                    push();
                });
            };
            push();
        }
    });
    return new Response(stream, { headers: res.headers });
};
const controller = new AbortController();
fetch('/foo', {
    headers: {
        'Range': `bytes=${length}-`
    },
    signal: controller.signal
}).then(chunkCache).then(...);
// 請求中斷後再次執行上述 fetch() 方法
複製代碼

下面的例子對上述代碼簡單封裝獲得了 ResumableFetch,並使用它實現了圖片下載的斷點續傳。示例完整代碼可在 CodePen 上查看。

注意:該示例中的代碼僅進行了簡單封裝,沒有作諸如 If-RangeRangeContent-Length 等 header 的校驗,也沒有作特殊的錯誤處理,也沒有包含以前提到的中斷請求兼容代碼,使用上可能也不夠友好,僅供示例使用,請謹慎用於生產環境。

封裝的 ResumableFetch 類會在請求過程當中建立一個 ReadableStream 實例並直接返回,同時已下載的片斷將會放進一個數組 chunks 並記錄已下載的文件大小 length。當請求中斷並從新下載時會根據已下載的文件大小設置 Range 請求頭,此時拿到的就是還未下載的片斷。下載完成後再將片斷從 chunks 中取出,此時不須要對片斷進行處理,只須要逐一傳遞給 ReadableStream 便可獲得完整的文件。

管道

到這裏 ReadableStream 上的方法已經描述的差很少了,最後只剩下 pipeTo() 方法和 pipeThrough() 方法沒有提到了。從字面意思上來看,這就是咱們以前提到的管道,能夠將流直接指向另外一個流,最後拿處處理後的數據。Jake Archibald 在他的那篇《2016 — 屬於 web streams 的一年》中提出了下面的例子,或許在(當時的)將來能夠經過這樣的形式以流的形式獲得解析後的文本:

var reader = response.body
    .pipeThrough(new TextDecoder()).getReader();
reader.read().then(result => {
    // result.value will be a string
});
複製代碼

如今那個將來已經到了,爲了避免破壞兼容性,TextEncoderTextDecoder 分別擴展出了新的 TextEncoderStreamTextDecoderStream,容許咱們以流的方式編碼或者解碼文本。例以下面的例子會在請求中檢索 It works! 這段文字,當找到這段文字時返回 true 同時斷開請求。此時咱們不須要再接收後續的數據,能夠減小請求的流量:

fetch('/index.html').then((res) => {
    const decoder = new TextDecoderStream('gbk', { ignoreBOM: true });
    const textStream = res.body.pipeThrough(decoder);
    const reader = textStream.getReader();
    const findMatched = () => reader.read().then(({ value, done }) => {
        if (done) {
            return false;
        }
        if (value.indexOf('It works!') >= 0) {
            reader.cancel();
            return true;
        }
        return findMatched();
    });
    return findMatched();
}).then((isMatched) => { ... });
複製代碼

或者在將來,咱們甚至在流裏實現實時轉碼視頻並播放,或者將瀏覽器還不支持的圖片以流的形式實時渲染出來:

const encoder = new VideoEncoder({
    input: 'gif', output: 'h264'
});
const media = new MediaStream();
const video = document.createElement('video');
fetch('/sample.gif').then((res) => {
    response.body.pipeThrough(encoder).pipeTo(media);
    video.srcObject = media;
});
複製代碼

從中應該能夠看出來這兩種方法的區別:pipeTo() 方法應該會接受一個能夠寫入的流,也就是 WritableStream;而 pipeThrough() 方法應該會接受一個既可寫入又可讀取的流,也就是 TransformStream

The image of Stream Pipe Chains Concept by Mozilla Contributors is licensed under CC-BY-SA 2.5.

Stream 管道鏈示意圖,做者 Mozilla Contributors,基於 CC-BY-SA 2.5 協議使用。

接下來咱們將介紹這兩種流,不過在繼續以前,咱們先來看看 ReadableStream 在瀏覽器上的支持程度:

Image of Stream API Browser Compatibilty Table by Mozilla Contributors is licensed under CC-BY-SA 2.5.

ReadableStream 瀏覽器兼容表,做者 Mozilla Contributors,本圖片爲表格的截圖,基於 CC-BY-SA 2.5 協議使用。

從表中咱們注意到,這兩個方法支持的比較晚。而緣由估計你也能猜獲得,當數據從一個可讀取的流中流出時,管道的另外一端應該是一個可寫入的流,問題就在於可寫入的流實現的比較晚。

WritableStream

The image of WritableStream Concept by Mozilla Contributors is licensed under CC-BY-SA 2.5.

WritableStream 示意圖,做者 Mozilla Contributors,基於 CC-BY-SA 2.5 協議使用。

咱們已經從 ReadableStream 中瞭解到不少關於流的知識了,因此下面咱們簡單過一下 WritableStreamWritableStream 就是可寫入的流,若是說 ReadableStream 是一個管道中流的起點,那麼 WritableStream 能夠理解爲流的終點。下面是一個 WritableStream 實例上的參數和可使用的方法:

WritableStream

  • locked
  • abort()
  • getWriter()

可用的方法和參數不多,估計你們從名字就能知道它們是作什麼的。其中直接調用 getWriter() 方法會獲得一個 WritableStreamDefaultWriter 實例,經過這個實例咱們就能向 WritableStream 寫入數據。一樣的,當咱們激活了一個 writer 後,這個流就會被鎖定(locked = true)。這個 writer 上有以下屬性和方法:

WritableStreamDefaultWriter

  • closed
  • desiredSize
  • ready
  • abort()
  • close()
  • write()
  • releaseLock()

看起來和 ReadableStreamDefaultReader 沒太大區別,多出的 abort() 方法至關於拋出了一個錯誤,使這個流不能再被寫入。另外這裏多出了一個 ready 屬性,這個屬性是一個 Promise,當它被 resolve 時,代表目前流的緩衝區隊列再也不過載,能夠安全地寫入。因此若是須要循環向一個流寫入數據的話,最好放在 ready 處理。

一樣的,咱們能夠本身構造一個 WritableStream,構造時能夠定義如下方法和參數:

const stream = new WritableStream({
    start(controller) {
        // 將會在對象建立時馬上執行,並傳入一個流控制器
        controller.error(reason)
            // 對流拋出一個錯誤
    },
    write(chunk, controller) {
        // 將會在一個新的數據片斷寫入時調用,能夠獲取到寫入的片斷
    },
    close(controller) {
        // 將會在流寫入完成時調用
    },
    abort(reason) {
        // 將會在流強制關閉時調用,此時流會進入一個錯誤狀態,不能再寫入
    }
}, queuingStrategy); // { highWaterMark: 1 }
複製代碼

下面的例子中,咱們經過循環調用 writer.write() 方法向一個 WritableStream 寫入數據:

const stream = new WritableStream({
    write(chunk) {
        return new Promise((resolve) => {
            console.log('got chunk:', chunk);
            // 在這裏對數據進行處理
            resolve();
        });
    },
    close() {
        console.log('stream closed');
    },
    abort() {
        console.log('stream aborted');
    }
});
const writer = stream.getWriter();
// 將數據逐一寫入 stream
data.forEach((chunk) => {
    // 待前一個數據寫入完成後再寫入
    writer.ready.then(() => {
        writer.write(chunk);
    });
});
// 在關閉 writer 前先保證全部的數據已經被寫入
writer.ready.then(() => {
    writer.close();
});
複製代碼

下面是 WritableStream 的瀏覽器支持狀況,可見 WritableStream 在各個瀏覽器上的的實現時間和 pipeTo()pipeThrough() 方法的實現時間是吻合的,畢竟要有了可寫入的流,管道纔有存在的意義。

Image of Stream API Browser Compatibilty Table by Mozilla Contributors is licensed under CC-BY-SA 2.5.

WritableStream 瀏覽器兼容表,做者 Mozilla Contributors,本圖片爲表格的截圖,基於 CC-BY-SA 2.5 協議使用。

TransformStream

從以前的介紹中咱們知道,TransformStream 是一個既可寫入又可讀取的流,正如它的名字同樣,它做爲一箇中間流起着轉換的做用。因此一個 TransformStream 實例只有以下參數:

TransformStream

  • readable: ReadableStream
  • writable: WritableStream

TransformStream 上沒有其餘的方法,它只暴露了自身的 ReadableStreamWritableStream。咱們只須要在數據源流上鍊式使用 pipeThrough() 方法就能實現流的數據傳遞,或者使用暴露出來的 readablewritable 直接操做數據便可使用它。

TransformStream 的處理邏輯主要在流內部實現,下面是構造一個 TransformStream 時能夠定義的方法和參數:

const stream = new TransformStream({
    start(controller) {
        // 將會在對象建立時馬上執行,並傳入一個流控制器
        controller.desiredSize
            // 填滿隊列所需字節數
        controller.enqueue(chunk)
            // 向可讀取的一端傳入數據片斷
        controller.error(reason)
            // 同時向可讀取與可寫入的兩側觸發一個錯誤
        controller.terminate()
            // 關閉可讀取的一側,同時向可寫入的一側觸發錯誤
    },
    transform(chunk, controller) {
        // 將會在一個新的數據片斷傳入可寫入的一側時調用
    },
    flush(controller) {
        // 當可寫入的一端獲得的全部的片斷徹底傳入 transform() 方法處理後,在可寫入的一端即將關閉時調用
    }
}, queuingStrategy); // { highWaterMark: 1 }
複製代碼

有了 ReadableStreamWritableStream 做爲前置知識,TransformStream 就不須要作太多介紹了。下面的示例代碼摘自 MDN,是一段實現 TextEncoderStreamTextDecoderStream 的 polyfill,本質上只是對 TextEncoderTextDecoder 進行了一層封裝:

const tes = {
    start() { this.encoder = new TextEncoder() },
    transform(chunk, controller) {
        controller.enqueue(this.encoder.encode(chunk))
    }
}
let _jstes_wm = new WeakMap(); /* info holder */
class JSTextEncoderStream extends TransformStream {
    constructor() {
        let t = { ...tes }
        super(t)
        _jstes_wm.set(this, t)
    }
    get encoding() { return _jstes_wm.get(this).encoder.encoding }
}
複製代碼
const tes = {
    start() {
        this.decoder = new TextDecoder(this.encoding, this.options)
    },
    transform(chunk, controller) {
        controller.enqueue(this.decoder.decode(chunk))
    }
}
let _jstds_wm = new WeakMap(); /* info holder */
class JSTextDecoderStream extends TransformStream {
    constructor(encoding = 'utf-8', { ...options } = {}) {
        let t = { ...tds, encoding, options }
        super(t)
        _jstes_wm.set(this, t)
    }
    get encoding() { return _jstds_wm.get(this).decoder.encoding }
    get fatal() { return _jstds_wm.get(this).decoder.fatal }
    get ignoreBOM() { return _jstds_wm.get(this).decoder.ignoreBOM }
}
複製代碼

Polyfilling TextEncoderStream and TextDecoderStream 源代碼,做者 Mozilla Contributors,基於 CC-BY-SA 2.5CC0 協議使用。


到這裏咱們已經把 Streams API 中所提供的流瀏覽了一遍,最後是 caniuse 上的瀏覽器支持數據,可見目前 Streams API 的支持度不算太差,至少主流瀏覽器都支持了 ReadableStream,讀取流已經不是什麼問題了,可寫入的流使用場景也比較少。不過其實問題不是特別大,咱們已經簡單知道了流的原理,作一些簡單的 polyfill 或者額外寫些兼容代碼應該也是能夠的,畢竟已經有很多第三方實現了。

Image of Streams Support Table by caniuse.com is licensed under CC-BY 4.0.

Streams 瀏覽器支持總覽,做者 caniuse.com,本圖片爲圖表的截圖,基於 CC-BY 4.0 協議使用。

在 Service Worker 中使用 Streams API

控制請求的響應速度

首先讓咱們來模擬體驗一下龜速到只有大約 30B/s 的網頁看起來是什麼樣子的:

你會注意到頁面中的文字是一個個顯示出來的(甚至標題欄也是這樣的),其實這是藉助 Service Worker 的 onfetch 事件配合 Streams API 實現的。熟悉 Service Worker 的同窗應該知道 Service Worker 裏有一個 onfetch 事件,能夠在事件內捕獲到頁面全部的請求,onfetch 事件的事件對象 FetchEvent 中包含以下參數和方法,排除客戶端 id 之類的參數,咱們主要關注 request 屬性以及事件對象提供的兩個方法:

addEventListener('fetch', (fetchEvent) => {
    fetchEvent.clientId
    fetchEvent.preloadResponse
    fetchEvent.replacesClientId
    fetchEvent.resultingClientId
    fetchEvent.request
        // 瀏覽器本來須要發起請求的 Request 對象
    fetchEvent.respondWith()
        // 阻止瀏覽器默認的 fetch 請求處理,本身提供一個返回結果的 Promise
    fetchEvent.waitUntil()
        // 延長事件的生命週期,例如在返回數據後再作一些事情
});
複製代碼

使用 Service Worker 最多見的例子是藉助 onfetch 事件實現中間緩存甚至離線緩存。咱們能夠調用 caches.open() 打開或者建立一個緩存對象 cache,若是 cache.match(event.request) 有緩存的結果時,能夠調用 event.respondWith() 方法直接返回緩存好的數據;若是沒有緩存的數據,咱們再在 Service Worker 裏調用 fetch(event.request) 發出真正的網絡請求,請求結束後咱們再在 event.waitUntil() 裏調用 cache.put(event.request, response.clone()) 緩存響應的副本。因而可知,Service Worker 在這之間充當了一箇中間人的角色,能夠捕獲到頁面發起的全部請求,而後根據狀況返回緩存的請求,因此能夠猜到咱們甚至能夠改變預期的請求,返回另外一個請求的返回值。

Streams API 在 Service Worker 中一樣可用,因此咱們能夠在 Service Worker 裏監聽 onfetch 事件,而後用上咱們以前學習到的知識,改變 fetch 請求的返回結果爲一個速度很緩慢的流。這裏咱們讓這個流每隔約 30 ms 才吐出 1 個字節,最後就能實現上面視頻中的效果:

globalThis.addEventListener('fetch', (event) => {
    event.respondWith((async () => {
        const response = await fetch(event.request);
        const { body } = response;
        const reader = body.getReader();
        const stream = new ReadableStream({
            start(controller) {
                const sleep = time => new Promise(resolve => setTimeout(resolve, time));
                const pushSlowly = () => {
                    reader.read().then(async ({ value, done }) => {
                        if (done) {
                            controller.close();
                            return;
                        }
                        const length = value.length;
                        for (let i = 0; i < length; i++) {
                            await sleep(30);
                            controller.enqueue(value.slice(i, i + 1));
                        }
                        pushSlowly();
                    });
                };
                pushSlowly();
            }
        });
        return new Response(stream, { headers: response.headers });
    })());
});
複製代碼

在 Service Worker 裏 Streams API 能夠作出更多有趣的事情,感興趣的同窗能夠參考下以前提到的那篇《2016 - the year of web streams》

下載一個前端生成的大文件

看着不是很實用?那麼再舉一個比較實用的例子吧。若是咱們須要讓用戶在瀏覽器中下載一個文件,通常都是會指向一個服務器上的連接,而後瀏覽器發起請求從服務器上下載文件。那麼若是咱們須要讓用戶下載一個在客戶端生成的文件,好比從 canvas 上生成的圖像,應該怎麼辦呢?其實讓客戶端主動下載文件已經有現成的庫 FileSaver.js 實現了,它的原理能夠用下面的代碼簡述:

const a = document.createElement('a');
const blob = new Blob(chunk, options);
const url = URL.createObjectURL(blob);
a.href = url;
a.download = 'filename';
const event = new MouseEvent('click');
a.dispatchEvent(event);
setTimeout(() => {
    URL.revokeObjectURL(url);
    if (blob.close) blob.close();
}, 1e3);
複製代碼

這裏利用了 HTML <a> 標籤上的 download 屬性,當連接存在該屬性時,瀏覽器會將連接的目標視爲一個須要下載的文件,連接不會在瀏覽器中打開,轉而會將連接的內容下載到設備的硬盤上。此外在瀏覽器中還有 Blob 對象,它至關於一個相似文件的二進制數據對象(File 就是繼承於它)。咱們能夠將須要下載的數據(不管是什麼類型,字符串、TypedArray 甚至是其餘 Blob 對象)傳進 Blob 的構造函數裏,這樣咱們就獲得了一個 Blob 對象。最後咱們再經過 URL.createObjectURL() 方法能夠獲得一個 blob: 開頭的 Blob URL,將它放到有 download 屬性的 <a> 連接上,並觸發鼠標點擊事件,瀏覽器就能下載對應的數據了。

順帶一提,在最新的 Chrome 76+ 和 Firefox 69+ 上,Blob 實例支持了 stream() 方法,它將返回一個 ReadableStream 實例。因此如今咱們終於能夠直接以流的形式讀取文件了——看,只要 ReadableStream 實現了,相關的原生數據流源也會完善,其餘的流或許也只是時間問題而已。

不過問題來了,若是須要下載的文件數據量很是大,好比這個數據是經過 XHR/fetch 或者 WebRTC 傳輸獲得的,直接生成 Blob 可能會遇到內存不足的問題。

下面是一個比較極端的糟糕例子,描述了在瀏覽器客戶端打包下載圖片的流程。客戶端 JavaScript 發起多個請求獲得多個文件,而後經過 JSZip 這個庫生成了一個巨大的 ArrayBuffer 數據,也就是 zip 文件的數據。接下來就像以前提到的那樣,咱們基於它構造一個 Blob 對象並用 FileSaver.js 下載了這個圖片。如你所想的同樣,全部的數據都是存放在內存中的,而在生成 zip 文件時,咱們又佔用了近乎同樣大小的內存空間,最終可能會在瀏覽器內佔用峯值爲總文件大小 2-3 倍的內存空間(也就是下圖中黃色背景的部分),流程事後可能還須要看瀏覽器的臉色 GC 回收。

如今有了 Streams API,咱們就有了另外一種解決方式。StreamSaver.js 就是這樣的一個例子,它藉助了 Streams API 和 Service Worker 解決了內存佔用過大的問題。閱讀它的源碼,能夠看出它的工做流程相似下面這樣:

StreamSaver.js 包含兩部分代碼,一部分是客戶端代碼,一部分是 Service Worker 的代碼(對於不支持 Service Worker 的狀況,做者在 GitHub Pages 上提供了一個運行 Service Worker 的頁面供跨域使用)。

在初始化時客戶端代碼會建立一個 TransformStream 並將可寫入的一端封裝爲 writer 暴露給外部使用,在腳本調用 writer.write(chunk) 寫入文件片斷時,客戶端會和 Service Worker 之間創建一個 MessageChannel,並將以前的 TransformStream 中可讀取的一端經過 port1.postMessage() 傳遞給 Service Worker。Service Worker 裏監聽到通道的 onmessage 事件時會生成一個隨機的 URL,並將 URL 和可讀取的流存入一個 Map 中,而後將這個 URL 經過 port2.postMessage() 傳遞給客戶端代碼。

客戶端接收到 URL 後會控制瀏覽器跳轉到這個連接,此時 Service Worker 的 onfetch 事件接收到這個請求,將 URL 和以前的 Map 存儲的 URL 比對,將對應的流取出來,再加上一些讓瀏覽器認爲能夠下載的響應頭(例如 Content-Disposition)封裝成 Response 對象,最後經過 event.respondWith() 返回。這樣在當客戶端將數據寫入 writer 時,通過 Service Worker 的流轉,數據能夠馬上下載到用戶的設備上。這樣就不須要分配巨大的內存來存放 Blob,數據塊通過流的流轉後直接被回收了,下降了內存的佔用。

因此藉助 StreamSaver.js,以前下載圖片的流程能夠優化以下:JSZip 提供了一個 StreamHelper 的接口來模擬流的實現,因此咱們能夠調用 generateInternalStream() 方法以小文件塊的形式接收數據,每次接收到數據時數據會寫入 StreamSaver.js 的 writer,通過 Service Worker 後數據直接被下載。這樣就不會再像以前那樣在生成 zip 時佔用大量的內存空間了,由於 zip 數據在實時生成時被劃分紅了小塊並迅速被處理掉了。

課後習題 Q3:StreamSaver.js 在不支持 TransformStream 的瀏覽器下實際上是能夠正常工做的,這是怎麼實現的呢?

總結

通過了這麼長時間的學習,咱們從 Fetch API 的角度出發探索 Streams API,大體瞭解瞭如下幾點:

  • Streams API 容許咱們以流的形式實時處理數據,每次只須要處理數據的一小部分
  • 可使用 pipeTo()pipeThrough() 方法方便地將多個流鏈接起來
  • ReadableStream 是可讀取的流,WritableStream 是可寫入的流,TransformStream 是既可寫入又可讀取的流
  • Fetch API 的返回值是一個 Response 對象,它的 body 屬性是一個 ReadableStream
  • 藉助 Streams API 咱們能夠實現中斷 fetch 請求或者計算 fetch 請求的下載速度,甚至能夠直接對返回的數據進行修改
  • 咱們學習瞭如何構造一個流,並將其做爲 fetch 請求的返回值
  • 在 Service Worker 裏也可使用 Streams API,使用 onfetch 事件能夠監聽全部的請求,並對請求進行篡改
  • 順帶了解了如何中斷一個 fetch 請求,使用 download 屬性下載文件,Blob 對象,MessageChannel 雙向通訊……

Streams API 提出已經有很長一段時間了,因爲瀏覽器支持的緣由再加上使用場景比較狹窄的緣由一直沒有獲得普遍使用,國內的相關資料也比較少。隨着瀏覽器支持逐漸鋪開,瀏覽器原生提供的可讀取流和可寫入流也會逐漸增長(好比在本文即將寫成時才注意到 Blob 對象已經支持 stream() 方法了),能使用上的場景也會愈來愈多,讓咱們拭目以待吧。

參考答案

  1. 試試看將上面的 fetch 請求用原生 XMLHttpRequest 實現一遍,看看你還記得多少知識?

    const xhr = new XMLHttpRequest();
    xhr.open('POST', 'https://example.org/foo');
    xhr.setRequestHeader('Content-Type', 'application/json');
    xhr.responseType = 'json';
    xhr.withCredentials = true;
    xhr.addEventListener('load', () => {
        const data = xhr.response;
        // ...
    });
    xhr.send(JSON.stringify({ foo: 'bar' }))
    複製代碼

    在使用 XHR 初始化請求時會有較多的配置項,雖然這些配置項能夠發出更復雜的請求,可是或許你也注意到了,發送請求時既有方法的調用,又有參數的賦值,看下來仍是不如 Fetch API 那樣直接傳入一個對象做爲請求參數那麼簡潔的。此外,若是須要兼容比較早的不支持 XHR 2 的瀏覽器,你可能還須要改爲使用 onreadystatechange 事件並手動解析 xhr.responseText

  2. 若是咱們調用了流的 tee() 方法獲得了兩個流,但咱們只讀取了其中一個流,另外一個流在以後讀取,會發生什麼嗎?

    使用 tee() 方法分流出來的兩個流之間是相互獨立的,因此被讀取的流會實時讀取到傳遞的數據,過一段時間讀取另外一個流,拿到的數據也是徹底同樣的。不過因爲另外一個流沒有被讀取,克隆的數據可能會被瀏覽器放在一個緩衝區裏,即使後續被讀取可能也沒法被瀏覽器即時 GC。

    const file = document.querySelector('input[type="file"]').files[0];
    const stream = file.stream();
    const readStream = (stream) => {
        let total = 0;
        const reader = stream.getReader();
        const read = () => reader.read().then(({ value, done }) => {
            if (done) return;
            total += value.length;
            console.log(total);
            read();
        });
        read();
    };
    
    const [s1, s2] = stream.tee();
    readStream(s1);
    readStream(s2);
    複製代碼

    例如在上述代碼中選擇一個 200MB 的文件,而後直接調用 readStream(stream),在 Chrome 瀏覽器下沒有較大的內存起伏;若是調用 stream.tee() 後獲得兩個流 s1s2,若是同時對兩個流調用 readStream() 方法,在 Chrome 瀏覽器下一樣沒有較大的內存起伏,最終輸出的文件大小也是一致的;若是隻對 s1 調用的話,會發現執行結束後 Chrome 瀏覽器下內存佔用多了約 200MB,此時再對 s2 調用,最終獲得的文件大小雖然一致,可是內存並無及時被 GC 回收,此時瀏覽器的內存佔用仍是以前的 200MB。

    可能你會好奇,以前咱們嘗試過使用 tee() 方法獲得兩段流,一個流直接返回另外一個流用於輸出下載進度,會有這樣的資源佔用問題嗎?會不會出現兩個流速度不一致的狀況?其實計算下載進度的代碼並不會很是耗時,數據計算完成後也不會再有多餘的引用,瀏覽器能夠迅速 GC。此外計算的速度是大於網絡傳輸自己的速度的,因此並不會形成瓶頸,能夠認爲兩個流最終的速度是基本同樣的。

  3. 若是不調用 controller.error() 拋出錯誤強制中斷流,而是繼續以前的流程調用 controller.close() 關閉流,會發生什麼事嗎?

    從上面的結果來看,當咱們調用 aborter() 方法時,請求被成功停止了。不過若是不調用 controller.error() 這個方法拋出錯誤的話,因爲咱們主動關閉了 fetch 請求返回的流,循環調用的 reader.read() 方法會接收到 done = true,而後會調用 controller.close()。這就意味着這個流是被正常關閉的,此時 Promise 鏈的後續操做不會被中斷,而是會收到已經傳輸的不完整數據。

    若是沒有作特殊的邏輯處理的話,直接返回不完整的數據可能會致使錯誤。不過若是能好好利用上的話,或許能夠作更多事情——好比斷點續傳的另外一種實現,這就有點像 Firefox 的私有實現 moz-chunked-arraybuffer 了。

  4. StreamSaver.js 在不支持 TransformStream 的瀏覽器下實際上是能夠正常工做的,這是怎麼實現的呢?

    記得咱們以前提到過構造一個 ReadableSteam 而後包裝成 Response 對象返回的實現吧?咱們最終的目的是須要構造一個流並返回給瀏覽器,這樣傳入的數據能夠當即被下載,而且沒有多餘引用而迅速 GC。因此對於不支持 TransformStream 甚至 WritableStream 的瀏覽器,StreamSaver.js 封裝了一個模擬 WritableStream 實現的 polyfill。當 polyfill 獲得數據時,會將獲得的數據片斷經過 MessageChannel 直接傳遞給 Service Worker。Service Worker 發現這不是一個流,會構造出一個 ReadableStream 實例,並將數據經過 controller.enqueue() 方法傳遞進流。後續的流程估計你已經猜到了,和當前的後續流程是同樣的,一樣是生成一個隨機 URL 並跳轉,而後返回封裝了這個流的 Response 對象。

    事實上,如今的 Firefox Send 就使用了這樣的實現,當用戶下載文件時會發出請求,Service Worker 接收到下載請求後會創建真實的 fetch 請求鏈接服務器,將返回的數據實時解密後直接下載到用戶的設備上。這樣的直觀效果是,瀏覽器直接下載了文件,文件會顯示在瀏覽器的下載列表中,同時頁面上還會有下載進度:

參考資料

本文發佈自 網易雲音樂前端團隊,基於 CC BY-SA 4.0 協議 進行許可,歡迎自由轉載,轉載請保留出處。咱們一直在招人,若是你剛好準備換工做,又剛好喜歡雲音樂,那就 加入咱們

相關文章
相關標籤/搜索