JavaScript異步流程控制

JavaScript特性

JavaScript屬於單線程語言,即在同一時間,只能執行一個任務。在執行任務時,全部任務須要排隊,前一個任務結束,纔會執行後一個任務。javascript

當咱們向後臺發送一個請求時,主線程讀取 「向後臺發送請求」 這個事件並執行以後,到獲取後臺返回的數據這一過程會有段時間間隔,這時CPU處於空閒階段,直到獲取數據後再繼續執行後面的任務,這就下降了用戶體驗度,使得頁面加載變慢。因而,全部任務能夠分紅兩種:同步任務和異步任務。java

  1. 同步任務:在主線程上排隊執行的任務,只有前一個任務執行完畢,才能執行後一個任務;
  2. 異步任務:不進入主線程、而進入"任務隊列"(task queue)的任務,只有"任務隊列"通知主線程,某個異步任務能夠執行了,該任務纔會進入主線程執行。

只要主線程空了,就會去讀取"任務隊列",這就是JavaScript的運行機制,這個過程會不斷重複。"任務隊列"是一個事件的隊列(也能夠理解成消息的隊列),IO設備完成一項任務,就在"任務隊列"中添加一個事件,表示相關的異步任務能夠進入"執行棧"了。主線程從"任務隊列"中讀取事件,這個過程是循環不斷的,因此整個的這種運行機制又稱爲Event Loop(事件循環)。node

JavaScript異步實現的5種方式

1. callback(回調函數)

回調函數,也被稱爲高階函數,是一個被做爲參數傳遞給另外一個函數並在該函數中被調用的函數。看一個在JQuery中簡單廣泛的例子:編程

// 注意: click方法是一個函數而不是變量

$("#button").click(function() {
  alert("Button Clicked");
});

能夠看到,上述例子將一個函數做爲參數傳遞給了click方法,click方法會調用該函數,這是JavaScript中回調函數的典型用法,它在jQuery中普遍被使用。它不會當即執行,由於咱們沒有在後面加( ),而是在點擊事件發生時纔會執行。json

好比,咱們要下載一個gif,可是不但願在下載的時候阻斷其餘程序,能夠實現以下:api

downloadPhoto('http://coolcats.com/cat.gif', handlePhoto)

function handlePhoto (error, photo) {
  if (error) {
    console.error('Download error!', error);
  } else {
    console.log('Download finished', photo);
  }
}

console.log('Download started')

首先聲明handlePhoto函數,而後調用downloadPhoto函數並傳遞handlePhoto做爲其回調函數,最後打印出「Download started」。
請注意,handlePhoto還沒有被調用,它只是被建立並做爲回調傳入downloadPhoto。但直到downloadPhoto完成其任務後才能運行,這可能須要很長時間,具體取決於Internet鏈接的速度,因此運行代碼後,會先打印出Download started數組

這個例子是爲了說明兩個重要的概念:promise

  1. handlePhoto回調只是稍後存儲一些事情的一種方式;
  2. 事情發生的順序不是從頂部到底部讀取,而是基於事情完成時跳轉;

1. callback hell(回調地獄)

var fs = require('fs');
/**
 * 若是三個異步api操做的話 沒法保證他們的執行順序
 * 咱們在每一個操做後用回調函數就能夠保證執行順序
 */
 
fs.readFile('./data1.json', 'utf8', function(err, data){
  if (err) {
    throw err;
  } else {
    console.log(data);
    fs.readFile('./data2.json', 'utf8', function(err, data){
      if (err) {
        throw err;
      } else {
        console.log(data)
        fs.readFile('./data3.json', 'utf8', function(err, data){
        if (err) {
          throw err;
        } else {
          console.log(data);
        }
      })
     }
   })
  }
})

有沒有看到這些以"})"結尾的金字塔結構?因爲回調函數是異步的,在上面的代碼中每一層的回調函數都須要依賴上一層的回調執行完,因此造成了層層嵌套的關係最終造成相似上面的回調地獄。app

2. 代碼層面解決回調地獄

1. 保持代碼簡短

var form = document.querySelector('form')
form.onsubmit = function formSubmit (submitEvent) {
  var name = document.querySelector('input').value
  request({
    uri: "http://example.com/upload",
    body: name,
    method: "POST"
  }, function postResponse (err, response, body) {
    var statusMessage = document.querySelector('.status')
    if (err) return statusMessage.value = err
    statusMessage.value = body
  })
}

能夠看到,上面的代碼給兩個函數加了描述性功能名稱,使代碼更容易閱讀,當發生異常時,你將得到引用實際函數名稱而不是「匿名」的堆棧跟蹤。異步

如今咱們能夠將這些功能移到咱們程序的頂層:

document.querySelector('form').onsubmit = formSubmit;

function formSubmit (submitEvent) {
  var name = document.querySelector('input').value;
  request({
    uri: "http://example.com/upload",
    body: name,
    method: "POST"
  }, postResponse);
}
    
function postResponse (err, response, body) {
  var statusMessage = document.querySelector('.status');
  if (err) return statusMessage.value = err;
  statusMessage.value = body;
}

從新整改代碼結構以後,能夠清晰的看到這段函數的功能。

2. 模塊化

從上面取出樣板代碼,並將其分紅幾個文件,將其轉換爲模塊。
這是一個名爲formuploader.js的新文件,它包含了以前的兩個函數:

module.exports.submit = formSubmit;

function formSubmit (submitEvent) {
  var name = document.querySelector('input').value;
  request({
    uri: "http://example.com/upload",
    body: name,
    method: "POST"
  }, postResponse)
}

function postResponse (err, response, body) {
  var statusMessage = document.querySelector('.status');
  if (err) return statusMessage.value = err;
  statusMessage.value = body;
}

把它們exports後,在應用程序中引入並使用,這就使得代碼更加簡潔易懂了:

var formUploader = require('formuploader');
document.querySelector('form').onsubmit = formUploader.submit;

3. error first

處理每一處錯誤,而且回調的第一個參數始終保留用於錯誤

var fs = require('fs')
 fs.readFile('/Does/not/exist', handleFile);
 function handleFile (error, file) {
   if (error) return console.error('Uhoh, there was an error', error);
   // otherwise, continue on and use `file` in your code;
 }

有第一個參數是錯誤是一個簡單的慣例,鼓勵你記住處理你的錯誤。若是它是第二個參數,會更容易忽略錯誤。

除了上述代碼層面的解決方法,還可使用如下更高級的方法,也是另外4種實現異步的方法。可是請記住,回調是JavaScript的基本組成部分(由於它們只是函數),在學習更先進的語言特性以前學習如何讀寫它們,由於它們都依賴於對回調。

2. 發佈訂閱模式

訂閱者把本身想訂閱的事件註冊到調度中心,當該事件觸發時候,發佈者發佈該事件到調度中心(順帶上下文),由調度中心統一調度訂閱者註冊到調度中心的處理代碼。

好比有個界面是實時顯示天氣,它就訂閱天氣事件(註冊到調度中心,包括處理程序),當天氣變化時(定時獲取數據),就做爲發佈者發佈天氣信息到調度中心,調度中心就調度訂閱者的天氣處理程序。簡單來講,發佈訂閱模式,有一個事件池,用來給你訂閱(註冊)事件,當你訂閱的事件發生時就會通知你,而後你就能夠去處理此事件。

clipboard.png

使用發佈訂閱模式,來修改Ajax

xhr.onreadystatechange = function () {//監聽事件
  if (this.readyState === 4) {
    if (this.status === 200) {
      switch (dataType) {
        case 'json': {
          Event.emit('data '+method,JSON.parse(this.responseText));  //觸發事件
          break;
        }
        case 'text': {
          Event.emit('data '+method,this.responseText);
          break;
        }
        case 'xml': {
          Event.emit('data '+method,this.responseXML);
          break;
        }
        default: {
          break;
        }
      }
    }
  }
}

3. Promise

ES6Promise寫進了語言標準,統一了用法,原生提供了Promise對象。Promise,簡單說就是一個容器,裏面保存着一個異步操做的結果。從語法上說,Promise是一個對象,從它能夠獲取異步操做的消息。

Promise有3種狀態:pending(進行中)、fulfilled(成功)、rejected(失敗)。

Promise很重要的兩個特色:

  1. 狀態不受外界影響;只有異步操做的結果,能夠決定當前是哪種狀態,任何其餘操做都沒法改變這個狀態。
  2. 一旦狀態改變,就不會再變,任什麼時候候均可以獲得這個結果;Promise對象的狀態改變,只有兩種可能:從pending變爲fulfilled和從pending變爲rejected。只要這兩種狀況發生,狀態就凝固了,不會再變了,會一直保持這個結果,這時就稱爲resolved(已定型)。

1. 基本用法

const p = new Promise((resolve,reject) => {

    // resolve在異步操做成功時調用
    resolve('success');
    
    // reject在異步操做失敗時調用
    reject('error');
});

p.then(result => {
    console.log(result);
});

p.catch(result => {
    console.log(result);
})

ES6規定,Promise對象是一個構造函數,用來生成Promise實例。new一個Promise實例時,這個對象的起始狀態就是Pending狀態,再根據resolvereject返回Fulfilled狀態 / Rejected狀態。

2. Promise.prototype.then( )

前面能夠看到,Promise實例具備then方法,因此then方法是定義在原型對象Promise.prototype上的,它的做用是爲Promise實例添加狀態改變時的回調函數。

then方法返回的是一個新的Promise實例,所以then能夠採用鏈式寫法:

getJSON("/posts.json").then(function(json) {
  return json.post;
}).then(function(post) {
  // ...
});

3. Promise.prototype.catch( )

Promise.prototype.catch方法是.then(null, rejection).then(undefined, rejection)的別名,用於指定發生錯誤時的回調函數。

getJSON('/posts.json').then(function(posts) {
  // ...
}).catch(function(error) {
  // 處理 getJSON 和 前一個回調函數運行時發生的錯誤
  console.log('發生錯誤!', error);
});

4. Promise.all( )

Promise.all方法用於將多個Promise實例,包裝成一個新的Promise實例。

const p = Promise.all([p1, p2, p3]);

上面代碼中,p的狀態由p1p2p3決定,分紅兩種狀況:

  1. 只有p1p2p3的狀態都變成fulfilledp的狀態纔會變成fulfilled,此時p1p2p3的返回值組成一個數組,傳遞給p的回調函數。
  2. 只要p1p2p3之中有一個被rejectedp的狀態就變成rejected,此時第一個被reject的實例的返回值,會傳遞給p的回調函數。

5. Promise.race( )

Promise.race方法一樣是將多個Promise實例,包裝成一個新的Promise實例。不一樣的是,race()接受的對象中,哪一個對象返回快就返回哪一個對象,若是指定時間內沒有得到結果,就將Promise的狀態變爲reject

const p = Promise.race([
  fetch('/resource-that-may-take-a-while'),
  new Promise(function (resolve, reject) {
    setTimeout(() => reject(new Error('request timeout')), 5000)
  })
]);

p
.then(console.log)
.catch(console.error);

上面代碼中,若是 5 秒以內fetch方法沒法返回結果,變量p的狀態就會變爲rejected,從而觸發catch方法指定的回調函數。

6. Promise.resolve( )

Promise.resolve('foo')
// 等價於
new Promise(resolve => resolve('foo'))

7. Promise.reject( )

const p = Promise.reject('出錯了');
// 等同於
const p = new Promise((resolve, reject) => reject('出錯了'))

p.then(null, function (s) {
  console.log(s)
});
// 出錯了

下面是一個用Promise對象實現的Ajax操做的例子:

const getJSON = function(url) {
  const promise = new Promise(function(resolve, reject){
    const handler = function() {
      if (this.readyState !== 4) {
        return;
      }
      if (this.status === 200) {
        resolve(this.response);
      } else {
        reject(new Error(this.statusText));
      }
    };
    const client = new XMLHttpRequest();
    client.open("GET", url);
    client.onreadystatechange = handler;
    client.responseType = "json";
    client.setRequestHeader("Accept", "application/json");
    client.send();

  });

  return promise;
};

getJSON("/posts.json").then(function(json) {
  console.log('Contents: ' + json);
}, function(error) {
  console.error('出錯了', error);
});

8. callbackify & promisify

Node 8提供了兩個工具函數util.promisifyutil.callbackify用於在回調函數和Promise之間作方便的切換,咱們也能夠用JavaScript代碼來實現一下。

1. promisify:把callback轉化爲promise

function promisify(fn_callback) { //接收一個有回調函數的函數,回調函數通常在最後一個參數
  if(typeof fn_callback !== 'function') throw new Error('The argument must be of type Function.');

  //返回一個函數
  return function (...args) {

    //返回Promise對象
    return new Promise((resolve, reject) => {
      try {
        if(args.length > fn_callback.length) reject(new Error('arguments too much.'));
        fn_callback.call(this,...args,function (...args) {
        
          //nodejs的回調,第一個參數爲err, Error對象
          args[0] && args[0] instanceof Error && reject(args[0]);
          
          //除去undefined,null參數
          args = args.filter(v => v !== undefined && v !== null);
   
          resolve(args);
        }.bind(this)); //保證this仍是原來的this
      } catch (e) {
        reject(e)
      }
    })
  }
}

2. callbackify:promise轉換爲callback

function callbackify(fn_promise) {
  if(typeof fn_promise !== 'function') throw new Error('The argument must be of type Function.');
  return function (...args) {

    //返回一個函數 最後一個參數是回調
    let callback = args.pop();
    if(typeof callback !== 'function') throw new Error('The last argument must be of type Function.');
    if(fn_promise() instanceof Promise){
      fn_promise(args).then(data => {
      
        //回調執行
        callback(null,data)
      }).catch(err => {
      
        //回調執行
        callback(err,null)
      })
    }else{
      throw new Error('function must be return a Promise object');
    }
  }
}

我的而言,最好直接把代碼改爲promise形式的,而不是對已有的callback加上這個中間層,由於其實改動的成本差很少。但總有各類各樣的狀況,好比,你的回調函數已經有不少地方使用了,牽一髮而動全身,這時這個中間層仍是比較有用的。

4. generator(生成器)函數

Generator函數是ES6提供的一種異步編程解決方案,經過yield標識位和next()方法調用,實現函數的分段執行。

1. next( )方法

先從下面的例子看一下Generator函數是怎麼定義和運行的。

function *gen() {
  yield "hello";
  yield "generator";
  return;
}
gen();  // 沒有輸出結果
var g = gen();
console.log(g.next());  // { value: 'hello', done: false }
console.log(g.next());  // { value: 'generator', done: false }
console.log(g.next());  // { value: 'undefined', done: true }

從上面能夠看到,Generator函數定義時要帶*,在直接執行gen()時,沒有像普通的函數同樣,輸出結果,而是經過調用next()方法獲得告終果。

這個例子中咱們引入了yield關鍵字,分析下這個執行過程:

  1. 建立了g對象,指向gen的句柄
  2. 第一次調用next(),執行到yield hello,暫緩執行,並返回了hello
  3. 第二次調用next(),繼續上一次的執行,執行到yield generator,暫緩執行,並返回了generator
  4. 第三次調用next(),直接執行return,並返回done:true,代表結束。

通過上面的分析,yield實際就是暫緩執行的標示,每執行一次next(),至關於指針移動到下一個yield位置。
next()方法返回的結果是個對象,對象裏面的value是運行結果,done表示是否運行完成。

2. throw( )方法

throw()方法在函數體外拋出一個錯誤,而後在函數體內捕獲。

function *gen1() {
  try{
    yield;
  } catch(e) {
    console.log('內部捕獲')
  }
}
let g1 = gen1();
g1.next();
g1.throw(new Error());

3. return( )方法

return()方法返回給定值,並終結生成器,在return後面的yield不會再被執行。

function *gen2(){
    yield 1;
    yield 2;
    yield 3;
}
let g2 = gen2();
g2.next();     // { value:1, done:false }
g2.return();   // { value:undefined, done:true }
g2.next();     // { value:undefined, done:true }

5. Promise + async & await

ES2017中,提供了async / await兩個關鍵字來實現異步,是異步編程的最高境界,就是根本不用關心它是不是異步,不少人認爲它是異步編程的終極解決方案。
async / await寄生於Promise,本質上仍是基於Generator函數,能夠說是Generator函數的語法糖,async用於申明一個function是異步的,而await能夠認爲是async wait的簡寫,等待一個異步方法執行完成。

async function demo() {
  let result = await Promise.resolve(123);
  console.log(result);
}
demo();

async函數返回的是一個Promise對象,在上述例子中,表示demo是一個async函數,await只能用在async函數裏面,表示等待Promise返回結果後,再繼續執行,await後面應該跟着Promise對象(固然,跟着其餘返回值也不要緊,只是會當即執行,這樣就沒有意義了)。

Promise雖然一方面解決了callback的回調地獄,可是相對的把回調 「縱向發展」 了,造成了一個回調鏈:

function sleep(wait) {
  return new Promise((res,rej) => {
    setTimeout(() => {
      res(wait);
    },wait);
  });
}

/*
let p1 = sleep(100);
let p2 = sleep(200);
let p =*/

sleep(100).then(result => {
  return sleep(result + 100);
}).then(result02 => {
  return sleep(result02 + 100);
}).then(result03 => {
  console.log(result03);
})

將上述代碼改爲async/await寫法:

async function demo() {
  let result01 = await sleep(100);

  //上一個await執行以後纔會執行下一句
  let result02 = await sleep(result01 + 100);

  let result03 = await sleep(result02 + 100);

  // console.log(result03);
  return result03;
}

demo().then(result => {
  console.log(result);
});

由於async返回的也是promise對象,因此用then接收就好了。

若是是reject狀態,能夠用try-catch捕捉:

let p = new Promise((resolve,reject) => {
  setTimeout(() => {
    reject('error');
  },1000);
});

async function demo(params) {
  try {
    let result = await p;
  } catch(e) {
    console.log(e);
  }
}

demo();

這是基本的錯誤處理,可是當內部出現一些錯誤時,和Promise有點相似,demo()函數不會報錯,仍是須要catch回調捕捉,這就是內部的錯誤被 「靜默」 處理了。

let p = new Promise((resolve,reject) => {
  setTimeout(() => {
    reject('error');
  },1000);
});

async function demo(params) {
  // try {
    let result = name;
  // } catch(e) {
  //  console.log(e);
  // }
}

demo().catch((err) => {
  console.log(err);
})

最後,總結一下JavaScript實現異步的5種方式的優缺點:

  1. 回調函數:寫起來方便,可是過多的回調會產生回調地獄,代碼橫向擴展,不易於維護和理解。
  2. 發佈訂閱模式:方便管理和修改事件,不一樣的事件對應不一樣的回調,可是容易產生一些命名衝突的問題,事件處處觸發,可能代碼可讀性很差。
  3. Promise對象:經過then方法來替代掉回調,解決了回調產生的參數不容易肯定的問題,可是相對的把回調 「縱向發展」 了,造成了一個回調鏈。
  4. Generator函數:確實很好的解決了JavaScript中異步的問題,可是得依賴執行器函數。
  5. async/await:這多是javascript中,解決異步的最好的方式了,讓異步代碼寫起來跟同步代碼同樣,可讀性和維護性都上來了。
相關文章
相關標籤/搜索