JavaScript 使用 Web Worker 處理複雜任務


隨著數據變得越來越大,若應用程式保持在單線程中運行 UI 性能將受到越多的影響,處理效能瓶頸時的解決方式,透過 Browser Web Worker 讓 JavaScript 可以有 multi thread 而不會影響到原來的 main thread,讓網頁使用者體驗上,穩定畫面的流暢度。

單執行緒阻塞

首先,先提供範例來演示長時間影響 Web 應用程式的狀況:

See the Pen
demo
by Eric Wang (@wweitinio)
on CodePen.0

在上面的範例中,使用遊戲中人物的動畫效果來展示,點擊按鈕將執行下面這段程式碼來模擬資源密集型計算,將觀察到阻塞了 main thread 造成動畫凍結了幾秒鐘。

function sleep(milliseconds) {
    const currentTime = new Date().getTime();

    let i = 0;
    while (currentTime + milliseconds >= new Date().getTime()) {
        i++;
        if(i % 1000000 === 0){
            console.log('rows processed : ' + i);
        }
    }
}

我們來了解凍結動畫的原因,開啟 Chrome 開發者工具(F12 或 Ctrl + Shift + I),切換到 Performance 分頁,點選左上角的灰色圓圈 Record (Ctrl + E) 以啟動 JavaScript 分析,然後點擊範例應用程式中的按鈕執行頁面互動,最後按下 Stop 按鈕結束分析,此時就會跑出一張效能表:

上圖中可以看出在觸發按鈕事件時佔了 CPU 的多數時間, 這樣的時間花費會讓使用者感受到頁面嚴重的停頓,甚至使用者執行其他所觸發的事件瀏覽器也會沒辦法即時處理,如果網頁在停頓更長的時間沒有任何響應,瀏覽器還可能會提示用戶是否要停止關閉頁面。

可以想像它就是瀑布式的執行每一個動作,雖然可以利用 event callback 來完成 "非同步" 的工作,但終究是在同一個瀑布上面流動。

在開發案例中也常會遇到密集的計算,例如報表資料,從 server 端取回資料之後,在前端進行排序,或是從 API 中接收巨大的數據集並調用 JSON.parse(),JavaScript 的 main thread 就會阻塞,畫面就像當掉了一樣。

不可預測的性能問題

fps (Frame per second) 表示的是每秒畫面更新次,越高的 fps 代表每秒更新次數越多,人眼會感覺越來越流暢,對於使用者反應回饋也會更快,在開發更複雜邏輯時,除了測試資料邏輯處理運算之間經過的時間外,還需要確保運行設備的渲染刷新率。

在開發過程中我們可以在自己的設備上,專注處理可能會導致過度的運算,對整體性能產生負面影響,然而一段 JavaScript 需要多長時間才能完成,也取決於在設備上運行的速度。

過往可能的做法我們可以在定義開發介於廣泛的設備上,將過於複雜的工作分解為更小部分的任務,保持應用程式流暢且穩定以最小的延遲響應的使用者界面。

拆分同步邏輯,也並不是所有適用,可能遇到的問題:

  • 不是所有邏輯都可拆分

    數組排序、迴圈搜尋、圖像處理 ⋯⋯ 等,還有執行中需要維護當前狀態,也無法輕易地拆分爲子任務

  • 拆分的粒度難以掌控

    在其他低功率的設備上開發可能再拆分到更小,才能控制在 16ms 內讓瀏覽器完成每畫面的渲染

  • 拆分的邏輯難以維護

    對同步邏輯進行再拆分,每次改動業務邏輯,都需要去 review 子邏輯

使用 Worker 的改善

為了確保範例中的動畫效果不會受到執行計算的影響,我們將計算的邏輯移動到不同的線程,我們就可以長時間運行也不會影響 UI 線程 (main thread)

首先,要創建計算的線程,我們使用 Worker() 通過指定文件路徑來構建,讓文件在 main thread 中加載和運行:

const worker = new Worker("./worker.js");

如果使用的只是一段程式碼時則可以使用 Blob 搭配URL.createObjectURL() 產生的臨時網址來構建:

const code = `(function () {

})();`;

const createBlobObjectURL = (code: string) => {
  const blob = new Blob([`${code}`], { type: "text/javascript" });        
  const url = URL.createObjectURL(blob); 
  return url;
};

const worker = new Worker(createBlobObjectURL(code));

接著在 main thread 中,再增加一個按鈕並發送給 worker

$('#button2').click(function () {
    worker.postMessage('long task started');
});

worker script 裡,我們將原本計算的 sleep 函式移過來,使用 onmessage 事件偵聽接收 main thread 所發送過來的資料進行資料運算,在調用 postMessage 將計算結果發送回去:

const code = `(function () {
    function sleep(milliseconds) {
        const currentTime = new Date().getTime();

        let i = 0;
        while (currentTime + milliseconds >= new Date().getTime()) {
            i++;
            if(i % 1000000 === 0){
                console.log('rows processed : ' + i);
            }
        }
    }

    self.onmessage = function (event){
        console.log(event.data);

        sleep(10000);

        self.postMessage('long task completed');
                self.close();
    }
})();`;

最後要在 main thread 中接收 worker 計算的結果,我們也要在 main thread 使用 onmessage 事件偵聽接收 worker 發送過來的資料:

worker.onmessage = function (event) {
    console.log(event.data)
}

整理使用 Worker 改善的範例演示:

See the Pen
demo 2
by Eric Wang (@wweitinio)
on CodePen.0

在上面的範例中,點擊 “worker測試” 按鈕執行模擬運算,可以觀察到計算的結果仍在記錄到 Chrome Devtools Console,但不會影響頁面上人物的動畫效果。

再次使用 Chrome Performance 分析,確定 Web Worker 的性能影響,應該會看到類似於下圖的結果:

從上圖中,明顯的觀察是在 main thread 下方出現一個 worker thread,而模擬資源密集計算都在 worker thread 中計算不再發生在 main thread 上,因此改善了動畫效果的性能讓使用者體驗上穩定畫面的流暢度。

Worker 在開發時需要注意及思考的

終止 Web Worker

建立 Web Worker 會在使用者的設備上建構真正的線程,這也是會消耗系統資源。因此 worker 執行完成時終止釋放 worker thread:

// close main script
worker.terminate();

// close worker script
self.close();

Web Worker 的局限性

  • 沒有 DOM 訪問權限

    無法讀取 main thread 網頁的 DOM 元素,也不能取得 document、window 等對象,但是可以獲取 navigator、location(只允許讀取)、XMLHttpRequest、setTimeout ⋯⋯ 等瀏覽器 API。

  • 沒有共享狀態

    worker script 無法訪問 main script 的數據,對任何一個線程的雙方都使用 postMessage() 方法發送各自的資料,使用 onmessage() 事件接收處理資料。

Transferable Objects

在 worker 相互發送數據這個過程中,數據並不是被共享而是被複製,如果要傳遞一個 50MB 的大文件(File、Blob、ArrayBuffer、JSON),那麼會產生明顯的資源開銷。

為了解決這個問題,postMessage()方法也支持傳輸 Transferable 數據類型 (類似 C/C++ Pass by reference),使用 Transferable 傳輸時,會直接把數據從一個執行環境 (worker thread 或 main thread) 傳輸到另一個執行環境,這樣不會額外增加一份資源消耗,並且傳輸速度極快因為不需要數據拷貝。

JavaScript 使用 Web Audio 和 Canvas 設計音樂韻律動畫

在開發過程遇到了一個對我來說特別有趣的應用,藉由這次的開發來介紹相關的功能應用,這個項目是利用擷取音樂波形數據來設計與音樂播放的同時並轉換視覺化動態效果,以往在 Flash 應用時期常常會利用音訊及動畫製作許多開發,如今 HTML5 對於裝置硬體訪問以及動畫的設計也能輕鬆的訪問及制作複雜的動畫。

接下來開始描述這個項目建構,並一同了解每個相關的工作原理,在最後提供完整的示例。

需要的相關工具

  • Web Audio API
  • Canvas API
  • jQuery library
  • CreateJS library

在這個項目中使用是 HTML5 提供 Web Audio API 以及 Canvas API 作為我們的主要應用,在使用 jQuery 使某些操作更容易完成,以及藉由 CreateJS 函式庫來建構精彩的動畫。

基本設定

建立一個基本的 HTML 頁面,它加載了這次項目會使用的 2 個函式庫,以及我們在項目中需要的 CSS 規則的樣式表。

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title></title>
    <style>
        body{
            background-color: #000;
        }
        #upload {
            position: fixed;
            top: 10px;
            left: 10px;
            z-index: 100;
        }
        #canvas {
            position: fixed;
            left: 0;
            top: 0;
            width: 100%;
            height: 100%;
        }
        audio {
            position: fixed;
            left: 10px;
            bottom: 10px;
            width: calc(100% - 20px);
        }
    </style>
</head>
<body>
<input type="file" id="upload" accept="audio/*"/>
<canvas id="canvas"></canvas>
<audio id="audio" controls></audio>
<script src="//ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="//code.createjs.com/1.0.0/createjs.min.js"></script>
<script>
    (function ($, createjs) {
        ....
    })(jQuery, createjs);
</script>
</body>
</html>

聲音數據處理

首先使用 jQuery 註冊 input[type="file"]  上傳事件來取得使用者上傳的音檔並播放。

let audioElement = $('#audio')[0];

$(document).ready(function () {
    $('#upload').change(function () {
        const input = this;

        if (input.files && input.files[0]) {
            audioElement.src = URL.createObjectURL(input.files[0]);
            audioElement.load();
            audioElement.play();
        }
    });
});

現在我們已經可獲得所需要的音檔文件源,可以開始連接分析聲音的數據。

連接音訊的處理容器

所先需要建立一個 AudioContext 容器來幫助我們管理所有聲音,單個 AudioContext 可以支持多個聲音輸入,因此我們在單個應用中只需要初始化一次,就可以對多個不同的聲音來源同時使用。

try {
    window.AudioContext = window.AudioContext||window.webkitAudioContext;

    let audioCtx = new AudioContext();
} catch(e) {
    alert('Web Audio API is not supported in this browser');
}

如果使用 Safari,則需要使用 webkitAudioContext 而不是 AudioContext。

接下來,建立一個 AnalyserNode,它會為我們提供用於實現視覺效果的頻率原始數據。

let analyser = audioCtx.createAnalyser();

設置 AnalyserNode fftSize 屬性值,這將告訴 AnalyserNode 傳回給我們的訊號數據數組應該有多大,另外要注意的是必須給它一個 2 的根值。

analyser.fftSize = 128;

最後再把我們 HTML 所用的 audio element 利用 AudioContext 提供的 createMediaElementSource 方法將它建立為 MediaElementAudioSourceNode 。

const source = audioCtx.createMediaElementSource(audioElement);

現在我們需要將所有 Node 連接在一起,以便它們可以讀取彼此的數據。

const source = audioCtx.createMediaElementSource(audioElement);

source.connect(analyser);
source.connect(audioCtx.destination);

循環讀取音訊數據

建立並連接所有 Node 後,我們建立一個長度為 frequencyBinCount (frequencyBinCount 是 fftSize 數量的一半) 空的 Uint8 陣列,作為未來讀取數據的容器。

let freqData = new Uint8Array(analyser.frequencyBinCount);

現在一切都準備就緒,可以開始我們的循環讀取所需要的數據。

首先,為我們開發項目構建自定義事件與監聽器,作為數據更新時可方便管理將數據發送給所有我們項目內所需要這些數據的程式。

let listeners = [];

function registerAudioListener(listener) {
    listeners.push(listener);
}

function audioListener(data) {
    listeners.forEach(l => l(data));
}

利用 requestAnimationFrame,它做的事情很簡單,就是在大約 1/60 秒後呼叫傳給它的 Callback function,並且用當時的「高精度」timestamp 當做參數傳給這個 Callback。

requestAnimationFrame(tick);

Callback function 我們需要利用 analyserNode 取得當下的音頻訊號來填充我們的剛剛所建立數據數組,為了循環我們必須在 function 內部裡面再次調用 requestAnimationFrame

function tick() {
    analyser.getByteFrequencyData(freqData);
    audioListener(freqData);
    requestAnimationFrame(tick);
}

可視化數據的呈現

現在我們可以進入有趣的部分,既然我們有了數據,終於可以用這些數據進行繪製,我們將使用 JavaScript Canvas API 與 CreateJS 將圖形繪製到 HTML 元素中。

初始化 Canvas

首先,我們建立一個初始化的 function 在畫面載入時使用,當我們還未取得任何音訊源時,我們先將音訊的數據填充數據,另外這裡填充的數據是 128,是為了讓視覺效果是對等的呈現,實際示例的項目擷取的音訊數據還是為 64,在稍後會在說明數據的處理。

let transitionAudioData = [];

function init() {
    $("#canvas").attr({
        width: $(document).width(),
        height: $(document).height()
    });

    for(let i = 0; i < 128; i++) {
        transitionAudioData.push(0);
    }

        ....
}

建立 Canvas 的場景創建了一個新的 Stage 對象,為了優化性能我們將啟用 snapToPixel,這樣做會導致運動(每像素)的平滑度有所下降,但會提高 FPS 並降低 CPU 負載。

stage = new createjs.Stage("canvas");

stage.snapToPixel = true;
stage.snapToPixelEnabled = true;

使用 createjs.Ticker 事件來更新場景,通過 addEventListener 方法中 tick 監聽事件來定期執行更新我們在音訊所得到的數據並繪製出來。

預設情況下,tick 事件每秒發生 24 次,但可以使用 setFPS 方法指定更改此頻率,每秒將被調用 60 次(大約),但如如果當您達到 CPU 的限制時,FPS 則 會下降。

createjs.Ticker.setFPS(60);
createjs.Ticker.addEventListener("tick", _.draw);

將數據繪製成線條

先定義兩個會使用的 color interpolation function,用來達到我們項目中每個線條顏色漸變的效果,我們將指定兩個 RGB 的顏色並在顏色中間已給定的數量來產生一系列的漸變顏色,再利用 getLineColors 方法生成這項目中所需要的 128 個線條顏色。

function getLineColors(){
    const colorgroup = [
        ['rgb(211, 212, 214)','rgb(170, 209, 225)'],
        ['rgb(51, 110, 168)','rgb(120, 67, 188)'],
        ['rgb(145, 58, 158)', 'rgb(204, 53, 131)'],
        ['rgb(215, 109, 79)','rgb(201, 143, 89)']
    ];

    let colors = [];

    for (let i = 0; i < colorgroup.length; i++){
        let _c = interpolateColors(colorgroup[i][0], colorgroup[i][1], transitionAudioData.length / colorgroup.length);

        for (let v = 0; v < _c.length; v++){
            colors.push(_c[v].join(","));
        }
    }

    return colors;
}

function interpolateColor(color1, color2, factor) {
    if (arguments.length < 3) {
        factor = 0.5;
    }
    let result = color1.slice();
    for (let i = 0; i < 3; i++) {
        result[i] = Math.round(result[i] + factor * (color2[i] - color1[i]));
    }
    return result;
}

function interpolateColors(color1, color2, steps) {
    let stepFactor = 1 / (steps - 1),
        interpolatedColorArray = [];

    color1 = color1.match(/\d+/g).map(Number);
    color2 = color2.match(/\d+/g).map(Number);

    for(var i = 0; i < steps; i++) {
        interpolatedColorArray.push(interpolateColor(color1, color2, stepFactor * i));
    }

    return interpolatedColorArray;
}

接下來建立 draw 的方法將所有的數據排列繪製呈現。

一開始調用 stage.removeAllChildren 當每次更新場景時就清除場景上所有的物件,並再以全新的數據組重新繪製線條,並依照所需要的設計效果定義變量來控制線條的寬度、高度、間距和顏色。

function draw() {
    stage.removeAllChildren();

    const spacing = 10;
    const lineWidth = 5;
    const lineHeightMultiplier = .5;

    const totalWidth = transitionAudioData.length * spacing - spacing;
    const offsetX = (stage.canvas.width - totalWidth) / 2;
    const offsetY = stage.canvas.height / 2;
    const colors = getLineColors();

    for (let x = 0; x < transitionAudioData.length; x++) {
        const audioValue = transitionAudioData[x];
        const lineHeight = audioValue * lineHeightMultiplier;
        const line = new createjs.Shape();

        line.graphics.setStrokeStyle(lineWidth, "round")
        line.graphics.beginStroke("rgb("+colors[x]+")");

        line.graphics.moveTo(x * spacing + offsetX, -lineHeight + offsetY);
        line.graphics.lineTo(x * spacing + offsetX, lineHeight + offsetY);

        stage.addChild(line);
    }

    stage.update();
}

註冊音訊數據的監聽

最後回到 init 方法中,使用 registerAudioListener 自定義事件來取得音訊數據更新至 Canvas 所存取的數組中,這邊會分兩段更新數據組是因為保留原始數據並為效果所需要的數據再次轉換。

在下面 Callback 的方法中我們事先使用 correctWithPinkNoiseResults 方法進行轉換一次,再將數據組複製一份進行翻轉,再將兩組數據進行合併,讓呈現的視覺效果上達到對稱的動態效果。

registerAudioListener(function(data) {
    const newAudioData = getPinkNoiseResults(data);
    const reverse_ay = newAudioData.slice();
    const _newAudioData = reverse_ay.reverse().concat(newAudioData);

    if (transitionAudioData.length === _newAudioData.length) {
        createjs.Tween.get(transitionAudioData, {
            override: true
        }).to(_newAudioData, 50);
    } else {
        transitionAudioData = _newAudioData;
    }
});

聲音頻率校正

Web Audio API 擷取得每個數據音量總是從 0255 的值,當我們稍微將每個數值做調整,視覺效果所呈現的方式又會更加完美,所以才會將原始數據組分開來並建立 getPinkNoiseResults 方法來依照所需要的效果再次校正。

function getPinkNoiseResults(data) {
    let data2 = [];
    let pinkNoise = [0.7060367470305, 0.85207379418243, 0.68842437227852, 0.63767902570829, 0.5452348949654, 0.50723325864167, 0.4677726234682, 0.44204182748767, 0.41956517802157, 0.41517375040002, 0.41312118577934, 0.40618363960446, 0.39913707474975, 0.38207008614508, 0.38329789106488, 0.37472136606245, 0.36586428412968, 0.37603017335105, 0.39762590761573, 0.39391828858591, 0.37930603769622, 0.39433365764563, 0.38511504613859, 0.39082579241834, 0.3811852720504, 0.40231453727161, 0.40244151133175, 0.39965366884521, 0.39761103827545, 0.51136400422212, 0.66151212038954, 0.66312205226679, 0.7416276690995, 0.74614971301133, 0.84797007577483, 0.8573583910469, 0.96382997811663, 0.99819377577185, 1.0628692615814, 1.1059083969751, 1.1819808497335, 1.257092297208, 1.3226521464753, 1.3735992532905, 1.4953223705889, 1.5310064942373, 1.6193923584808, 1.7094805527135, 1.7706604552218, 1.8491987941428, 1.9238418849406, 2.0141596921333, 2.0786429508827, 2.1575522518646, 2.2196355526005, 2.2660112509705, 2.320762171749, 2.3574848254513, 2.3986127976537, 2.4043566176474, 2.4280476777842, 2.3917477397336, 2.4032522546622, 2.3614180150678,];

    for (var i = 0; i < 64; i++) {
        data2[i] = data[i] / pinkNoise[i];
        data2[i] = data[i] / pinkNoise[i];
    }
    return data2;
}

整體設計的視化音樂:

2022-01-09 22.44.22.gif

完整的範例:


See the Pen Web Audio by Eric Wang (@wweitinio) on CodePen
1