理解JavaScript概念系列--非同步任務

上古神鵬發表於2019-04-28

思考一下

  1. 什麼是JavaScript非同步?
  2. 為什麼要實現JavaScript非同步?
  3. 怎麼實現JavaScript非同步?
  4. JavaScript非同步原理是什麼?

最近權利的遊戲第八季已經開播兩集了,權遊迷們看完第二集的時候不知道有沒有這樣一種體會,想象一下如果你是劇中的一位人物,在與異鬼大軍大戰前夜你會想什麼或者你會做些什麼事?不得不說導演把劇中人物在大戰前夜他們的心理活動以及表現描述的恰到好處,每一幀畫面都值得細細體味。

回到寫文章上來,如何在一篇文章中把想要總結的知識點深入淺出的羅列出來是我一直在思考的問題,歡迎同學們給出寶貴意見。

JavaScript非同步

所謂 "非同步",簡單說就是一個任務不是連續完成的,可以理解成該任務被人為分成兩段,先執行第一段,然後轉而執行其他任務,等做好了準備,再回過頭執行第二段。

Javascript語言的執行環境是 "單執行緒" (single thread)。所謂“單執行緒”執行也可以理解為JavaScript程式碼從上到下順序解釋(編譯)執行。在程式執行過程中遇到需要控制在未來某個時間點(比如setTimeout,callback等)才能執行的程式則將其放到一邊(訊息佇列),繼續執行其下面的程式。那些程式被控制在未來某個時間點執行就形成了JavaScript的 非同步執行機制

function add(a, b) {
    return a + b;
}
function sub(c, d) {
    return c - d;
}
function multiply(e, f) {
    return e * f;
}

console.log(add(1, 2));
setTimeout(function() {
    console.log(sub(2, 1));
}, 1000);
console.log(multiply(1, 2));
// 3 console.log(add(1, 2))
// 2 console.log(multiply(1, 2))
// 1 console.log(sub(2, 1))
複製程式碼

如果沒有這種非同步執行機制,當遇到一個任務耗時很長,後面的任務都必須排隊等著,會拖延整個程式的執行。常見的瀏覽器無響應(假死),往往就是因為某一段Javascript程式碼長時間執行(比如死迴圈),導致整個頁面卡在這個地方,其他任務無法執行。

JavaScript 單執行緒與任務

1. 執行在瀏覽器中的JavaScript程式是單執行緒執行的

  • Js程式始終在一個執行緒上執行,這個執行緒就是Js引擎執行緒。
  • 每個瀏覽器只有一個Js引擎執行緒。
  • 單執行緒,即Js引擎在同一時刻只能執行一個任務,其他任務要執行,需要排隊。
  • Js引擎執行緒和UI執行緒互斥,因為Js操作DOM導致Js執行時影響頁面的渲染。
  • HTML5提測Web Worker標準,執行Js指令碼建立多個執行緒,但是子執行緒完全受主執行緒的控制且不能操作DOM元素,所以並沒有改變Js單執行緒的本質。

後續會有文章介紹Web Worker

2. 瀏覽器是多執行緒的

  • Js引擎執行緒:執行JavaScript程式
  • UI渲染執行緒:渲染頁面
  • 瀏覽器事件觸發執行緒:控制互動,相應使用者觸發事件
  • 網路請求執行緒:處理網路請求,ajax是委託給瀏覽器新開的一個http執行緒
  • EventLoop處理執行緒:處理輪詢訊息佇列

後續會有文章介紹瀏覽器執行機制

JavaScript任務實際上是程式中的一個個程式碼塊(執行單元)或者一行程式碼,由於JavaScript引擎的單執行緒執行機制,程式中凡是在JavaScript引擎中順序執行的程式碼則為 同步任務,在未來某個時間點執行的程式碼則為 非同步任務

同步任務有哪些?非同步任務有哪些?

除了非同步任務以外的都是同步任務(有點廢話),同步任務太常見了,比如add(a, b)數學運算,document.getElementById('main').style.fontSize = '12px dom運算,quickSort(arr)陣列快速排序等。然而JavaScript中提供實現非同步任務的程式碼方式相對來說是有限的,主要是下文中的幾種。

JavaScript 建立非同步任務

1、JavaScript原生事件處理函式

由Js事件觸發的函式本身就是非同步任務。

JavaScript原生事件有哪些?

DOM元素上可以觸發的有onclickonmouseoveronmouseoutonmousedownonmouseupondblclickonkeydownonkeypressonkeyup等,視窗onresize ,滾動條onscroll等,資源載入onload等,網路請求onreadystatechange ···

// DOM0事件模型
var btn = document.getElementById("myBtn");
btn.onclick = function() {
    alert(this.id);
}
// DOM2事件模型
btn.addEventListener('click', function() {
    alert('hello '+this.id);
}, true)
複製程式碼

2、定時器

setImmediate(),setTimeout()setInterval()三種JavaScript中設定定時執行某些程式的函式屬於非同步任務。

setImmediate(fn)是將事件插入到事件佇列尾部,主執行緒和事件佇列的函式執行完成之後立即執行setImmediate指定的回撥函式,和setTimeout(fn,0)的效果差不多,但是當他們同時在同一個事件迴圈中時,執行順序是 不確定 的。

// note: setImmediate() 是node環境下的函式
setImmediate(function() {
    console.log('run setImmediate');
});
setTimeout(function() {
    alert('run setTimeout');
}, 0);

// run setImmediate -> run setTimeout
// 也有可能是
// run setTimeout -> run setImmediate
複製程式碼

非同步任務的執行順會在後面文章《理解JavaScript概念系列--Event Loop》中詳細介紹

3、MessageChannel

後續補充相關內容

4、Promise

Promise 物件用於表示一個非同步操作的最終狀態(完成或失敗),以及該非同步操作的結果值。

下面看一下Promise的兩個例子,一個是無條件觸發非同步函式,另一個是簡單模擬實現axios中的get方法。

// 無條件觸發非同步結果函式resolve和reject
console.log('before promise run');
var promise = new Promise(function(resolve, reject) {
    console.log('promise is running');
    resolve('promise is resolved');
});
promise.then(result => console.log(result));
console.log('I am a line of reference');
// before promise run
// promise is running
// I am a line of reference
// promise is resolved

// 在一定條件下觸發非同步結果函式resolve和reject
var axios = {};
axios.get = function(url) {
    return new Promise(function(resolve, reject) {
        var xhr = new XMLHttpRequest();
        xhr.open('GET', url, true);
        xhr.send();
        // 非同步事件
        xhr.onreadystatechange = function() {
            if(xhr.readyState == 4) {
                if(xhr.status == 200) {
                    try {
                        var response = JSON.parse(xhr.responseText);
                        resolve(response);
                    }catch(e) {
                        reject(e);
                    }
                }else {
                    reject(new Error(xhr.statusText));
                }
            }
        }
    });
}

axios.get('/userInfo').then(res => res.data)
複製程式碼

Promise是JavaScript中提供的原生解決非同步程式設計的一種方案,一般程式中最終非同步階段的呼叫是需要 外部事件 或者 定時器 等額外非同步單元來觸發,如果無條件觸發非同步結果函式,結果響應函式也要等JS引擎主執行緒所有同步任務執行結束後再執行。

5、Generator

生成器物件 是由一個 generator function 返回的,並且它符合可迭代協議和迭代器協議。

function* gen() {
    yield 1;
    yield 2;
    yield 3;
}
let g = gen();
console.log(g); // Generator {  }
console.log(g.next()); // Object { value: 1, done: false }
console.log(g.next()); // Object { value: 2, done: false }
console.log(g.next()); // Object { value: 3, done: false }
console.log(g.next()); // Object { value: undefined, done: true }
複製程式碼

上面程式碼中,呼叫 Generator 函式,會返回一個內部指標(即遍歷器)g。這是 Generator 函式不同於普通函式的另一個地方,即執行它不會返回結果,返回的是指標物件。呼叫指標gnext方法,會移動內部指標(即執行非同步任務的第一段),指向第一個遇到的yield語句,上例是執行到yield 3為止。

換言之,next方法的作用是分階段執行Generator函式。每次呼叫next方法,會返回一個物件,表示當前階段的資訊(value屬性和done屬性)。value屬性是yield語句後面表示式的值,表示當前階段的值;done屬性是一個布林值,表示 Generator 函式是否執行完畢,即是否還有下一個階段。

// generator函式非同步任務封裝
var fetch = require('node-fetch');
function* gen() {
    var url = 'https: //api.xxx.users';
    var result = yield fetch(url);
    console.log(result);
}

var g = gen(); // g為遍歷器物件
var result = g.next(); // fetch(url)返回一個promise物件

result.value.then(
    data => data.json;
).then(
    data => g.next(data); // 本次next,跳出當前遍歷器
)
複製程式碼

上面程式碼中,首先執行 Generator 函式,獲取遍歷器物件,然後使用next方法(第二行),執行非同步任務的第一階段。由於Fetch模組返回的是一個 Promise 物件,因此要用then方法呼叫下一個next方法。

可以看到,雖然 Generator 函式將非同步操作表示得很簡潔,但是流程管理卻不方便(即何時執行第一階段、何時執行第二階段)。

Generator建立的非同步任務,必須通過觸發next才能執行完成非同步階段的任務,要不然將一直不返回非同步結果。

6、async函式

async函式是Generator函式的一個語法糖。

const fs = require('fs');

const readFile = function(fileName) {
    return new Promise(function(resolve, reject) {
        fs.readFile(fileName, function(error, data) {
            if(error) {
                return reject(error);
            }
            resolve(data);
        });
    });
}
// Generator函式讀取檔案過程
const gen = function* () {
    const f1 = yield readFile('../etc/text');
    const f2 = yield readFile('../etc/xml');
    console.log(f1.toString());
    console.log(f2.toString());
}

// 將上面函式gen改寫成async函式
const asyncReadFile = async function() {
    const f1 = await readFile('../etc/text');
    const f1 = await readFile('../etc/xml');
    console.log(f1.toString());
    console.log(f2.toString());
}
複製程式碼

async函式就是將 Generator 函式的星號(*)替換成async,將yield替換成awaitasync函式對 Generator 函式的改進,體現在以下四點。

  1. 內建執行器
  2. 更好的語義
  3. 更廣的適用性
  4. 返回值是Promise

更加詳細的介紹請閱讀阮一峰老師的總結 《async 函式》。下面看一下async函式的 核心—返回Promise物件

async function fn() {
    return 'hello async'; // 等同於 return await 'hello async'
}
var p = fn();
console.log(p); // Promise { <state>: "fulfilled", <value>: "hello async" }
p.then(value => console.log(value)); // hello async
複製程式碼

正常情況下,await命令後面是一個Promise物件,返回該物件的結果。如果不是Promise物件,就直接返回對應的值。

process.nextTick(node環境)

Node.js服務端環境也是單執行緒的,除了系統I/O之外,在它的時間輪詢過程中同一時間(點)只會處理一個事件。在I/O型應用中,給每一個輸入輸出定義回撥函式,他們會自動新增到時間輪詢的處理佇列中。當I/O操作完成後,這些回撥函式會被觸發並執行。process.nextTick的意思就是定義一個(非同步)動作,並且這個動作在下一個時間輪詢的時間點上執行。

function f() {
    console.log('foo')
}

process.nextTick(f);

setTimeout(function() {
    console.log('bar');
}, 0);

process.nextTick(f);
console.log('I am the first, although at the end of the code');

// I am the first, although at the end of the code
// foo
// foo
// bar
複製程式碼

從上面程式的執行過程可以看出,process.nextTick建立的是一個非同步任務,但是它優先於setTimeout執行。

現在再回顧一下文章開頭那幾個問題,你心中有答案了嗎?

總結

本篇文章總結了一些使用原生Js建立非同步任務的方法(也就是最小的非同步任務建立單元),注意不是非同步程式設計的方法,非同步程式設計有回撥函式,釋出/訂閱等方式,後續會有相關文章介紹。

參考文章

這一次,徹底弄懂 JavaScript 執行機制
JS 演變、單執行緒、非同步任務
對瀏覽器端javaScript執行機制的理解
Generator 函式的非同步應用
async 函式
理解Node.js裡的process.nextTick()

相關文章