前端日拱一卒D11——ES6筆記之非同步篇

DerekZ95發表於2018-08-07

前言

餘為前端菜鳥,感姿勢水平匱乏,難觀前端之大局。遂決定循前端知識之脈絡,以興趣為引,輔以幾分堅持,望於己能解惑致知、於同道能助力一二,豈不美哉。

本系列程式碼及文件均在 此處

繼續啃老本...讓人又愛又恨的非同步

開始之前

  • 同步和非同步

    function sync(){
      const doA = '12'
      const doB = '34'
    }
    function async(){
      ajax('/api/doC1', (res) => {
        doC2(res)
      })
    }
    複製程式碼

    同步很好理解,任務一個個執行,doA以後才能doB。

    非同步任務可以理解為分兩個階段,doC的前一階段是發出請求,後一階段是在請求結束後的未來時刻處理。

    兩者各有優劣,同步任務會導致阻塞,非同步任務需要由有機制實現前後兩部分的分離,使得主執行緒能夠在這間歇內繼續工作而不浪費時間等待。

    以瀏覽器為例大致過程:

    主執行緒呼叫web api,通過工作執行緒發起請求,然後主執行緒繼續處理別的任務(這是part1)。工作執行緒執行完了非同步任務以後往事件佇列裡註冊回撥,等待主執行緒空閒後去佇列中取出到主執行緒執行棧中執行(這是part2)。

  • 併發和並行

    前端日拱一卒D11——ES6筆記之非同步篇

    簡單描述:併發是交替做不同事情,並行是同時做不同事情。

    我們可以通過多執行緒去處理併發,但說到底CPU只是在快速切換上下文來實現快速的處理。而並行則是利用多核,同時處理多個任務。

  • 單執行緒和多執行緒

    我們總說js是單執行緒的,node是單執行緒的,其實這樣的說法並不完美。所謂單執行緒指的是js引擎解釋和執行js程式碼的執行緒是一個,也即是我們常說的主執行緒。

    前端日拱一卒D11——ES6筆記之非同步篇

    又比如對於我們熟悉的node,I/O操作實際上都是通過執行緒池來完成的,js->呼叫c++函式->libuv方法->I/O操作執行->完畢後js執行緒繼續執行後續。

lesson1 Promise

callback

ajax('/a', (res) => {
  ajax('/b, (res) => {
    // ...
  })
})
複製程式碼

醜陋的callback形式,不再多說

你的名字

  • Promise 誕於社群,初為非同步程式設計之解決方案,後有ES6將其寫入語言標準,終成今人所言之 Promise 物件
  • Promise物件特點有二:狀態不受外界影響、一旦狀態改變後不會再次改變

基本用法

  • Promise為建構函式,用於生成Promise例項
    // 接收以resolve和reject方法為引數的函式
    const pr = new Promise((resolve, reject) => {
      // do sth
      resolve(1) // pending -> resolved
      reject(new Error()) // pending -> rejected
    })
    複製程式碼
  • 使用then方法傳入狀態更改後的回撥函式
    pr.then((value) => {
      // onresolved cb
    }, (err) => {
      // onrejected cb
    })
    複製程式碼

我愚蠢的孩子們

  • Promise.prototype.then

    採用鏈式寫法,返回一個新的Promise,上一個回撥的返回作為引數傳遞到下一個回撥

  • Promise.prototype.catch

    實際上是.then(null, rejection)的別名

    同樣支援鏈式寫法,最後一個catch可以catch到前面任一個Promise跑丟擲的未catch的error

  • Promise.all

    引數需具有Iterator介面,返回為多個Promise例項

    var p = Promise.all([p1, p2, p3]);
    複製程式碼

    p1, p2, p3均resolve後p才resolve,任一個reject則p就reject。

    若內部有catch,則外部catch捕獲不到異常。

  • Promise.race

    // 若5秒未返回則拋錯
    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(response => console.log(response));
    p.catch(error => console.log(error));
    複製程式碼

    第一個狀態改變的Promise會引起p狀態改變。

  • Promise.resolve/reject

    Promise.resolve('1')
    Promise.resolve({ then: function() {
      console.log(123)
    } })
    複製程式碼
    • 不傳引數/傳非thenable物件,生成一個立即resolve的Promise
    • 傳thenable物件,立即執行then方法,然後根據狀態更改執行then(普通Promise行為)
  • Promise.prototype.finally

    Promise.prototype.finally = function (callback) {
      let P = this.constructor;
      return this.then(
        value  => P.resolve(callback()).then(() => value),
        reason => P.resolve(callback()).then(() => { throw reason })
      );
    };
    複製程式碼

    無論如何都會執行最後的cb

Promise為我們提供了優於callback巢狀的非同步選擇,但實際上還是基於回撥來實現的。

實現

簡單的Promise實現程式碼可以看這裡 github

lesson2 Generator

初探

  • 基本概念

    function * gen() {
      const a = yield 1;
      return 2
    }
    const m = gen() // gen{<suspended>}
    m.next() // {value: 1, done: false}
    m.next() // {value: 2, done: true}
    m.next() // {value: undefined, done: true}
    m // gen {<closed>}
    複製程式碼
    • Generator一個遍歷器生成函式,一個狀態機
    • 執行返回一個遍歷器,代表Generator函式的內部指標(此時yield後的表示式不會求值)
    • 每次呼叫遍歷器的next方法會執行下一個yield前的語句並且返回一個{ value, done }物件。
    • 其中value屬性表示當前的內部狀態的值,是yield表示式後面那個表示式的值,done屬性是一個布林值,表示是否遍歷結束
    • 若沒有yield了,next執行到函式結束,並將return結果作為value返回,若無return則為undefined。
    • 這之後呼叫next將返回{ value: undefined, done: true },Generator的內部屬性[[GeneratorStatus]]變為closed狀態
  • yield

    • 呼叫next方法時,將yield後的表示式的值作為value返回,只有下次再呼叫next才會執行這之後的語句,達到了暫停執行的效果,相當於具備了一個惰性求值的功能
    • 沒有yield時,Generator函式為一個單純的暫緩執行函式(需要呼叫next執行)
    • yield只能用於Generator函式

方法

  • Generator.prototype.next()

    通過傳入引數為Generator函式內部注入不同的值來調整函式接下來的行為

    // 這裡利用引數實現了重置
    function* f() {
      for(var i = 0; true; i++) {
        var reset = yield i;
        if(reset) { i = -1; }
      }
    }
    var g = f();
    g.next() // { value: 0, done: false }
    g.next() // { value: 1, done: false }
    // 傳遞的引數會被賦值給i(yield後的表示式的值(i))
    // 然後執行var reset = i賦值給reset
    g.next(true) // { value: 0, done: false }
    複製程式碼
  • Generator.prototype.throw()

    • Generator函式返回的物件都具有throw方法,用於在函式體外丟擲錯誤,在函式體內可以捕獲(只能catch一次)
    • 引數可以為Error物件
    • 如果函式體內沒有部署try...catch程式碼塊,那麼throw丟擲的錯會被外部try...catch程式碼塊捕獲,如果外部也沒有,則程式報錯,中斷執行
    • throw方法被內部catch以後附帶執行一次next
    • 函式內部的error可以被外部catch
    • 如果Generator執行過程中內部拋錯,且沒被內部catch,則不會再執行下去了,下次呼叫next會視為該Generator已執行結束
  • Generator.prototype.return()

    • try ... finally存在時,return會在finally執行完後執行,最後的返回結果是return方法的引數,這之後Generator執行結束,下次訪問會得到{value: undefined, done: true}
    • try ... finally不存在時,直接執行return,後續和上一條一致

以上三種方法都是讓Generator恢復執行,並用語句替換yield表示式

yield*

  • 在一個Generator內部直接呼叫另一個Generator是沒用的,如果需要在一個Generator內部yield另一個Generator物件的成員,則需要使用yield*

    function* inner() {
      yield 'a'
      // yield outer() // 返回一個遍歷器物件
      yield* outer() // 返回一個遍歷器物件的內部值
      yield 'd'
    }
    function* outer() {
      yield 'b'
      yield 'c'
    }
    let s = inner()
    for (let i of s) {
      console.log(i)
    } // a b c d
    複製程式碼
  • yield*後跟一個遍歷器物件(所有實現了iterator的資料結構實際上都可以被yield*遍歷)

  • 被代理的Generator函式如果有return,return的值會被for...of忽略,所以next不會返回,但是實際上可以向外部Generetor內部返回一個值,如下:

    function *foo() {
      yield 2;
      yield 3;
      return "foo";
    }
    function *bar() {
      yield 1;
      var v = yield *foo();
      console.log( "v: " + v );
      yield 4;
    }
    var it = bar();
    it.next()
    // {value: 1, done: false}
    it.next()
    // {value: 2, done: false}
    it.next()
    // {value: 3, done: false}
    it.next();
    // "v: foo"
    // {value: 4, done: false}
    it.next()
    // {value: undefined, done: true}
    複製程式碼
  • 舉個?

    // 處理巢狀陣列
    function* Tree(tree){
      if(Array.isArray(tree)){
        for(let i=0;i<tree.length;i++) {
          yield* Tree(tree[i])
        }
      } else {
        yield tree
      }
    }
    let ss = [[1,2],[3,4,5],6,[7]]
    for (let i of Tree(ss)) {
      console.log(i)
    } // 1 2 3 4 5 6 7
    // 理解for ...of 實際上是一個while迴圈
    var it = iterateJobs(jobs);
    var res = it.next();
    while (!res.done){
      var result = res.value;
      // ...
      res = it.next();
    }
    複製程式碼

Extra

  • 作為物件的屬性的Generator函式

    寫法很清奇

    let obj = {
      * sss() {
        // ...
      }
    }
    let obj = ={
      sss: function* () {
        // ...
      }
    }
    複製程式碼
  • Generator函式的this

    Generator函式返回的是遍歷器物件,會繼承prototype的方法,但是由於返回的不是this,所以會出現:

    function* ss () {
      this.a = 1
    }
    let f = ss()
    f.a // undefined
    複製程式碼

    想要在內部的this繫結遍歷器物件?

    function * ss() {
      this.a = 1
      yield this.b = 2;
      yield this.c = 3;
    }
    let f = ss.call(ss.prototype)
    // f.__proto__ === ss.prototype
    f.next()
    f.next()
    f.a // 1
    f.b // 2
    f.c // 3
    複製程式碼

應用

  • 舉個?

    // 利用暫停狀態的特性
    let clock = function* () {
      while(true) {
        console.log('tick')
        yield
        console.log('tock')
        yield
      }
    }
    複製程式碼
  • 非同步操作的同步化表達

    // Generator函式
    function* main() {
      var result = yield request("http://some.url");
      var resp = JSON.parse(result);
        console.log(resp.value);
    }
    // ajax請求函式,回撥函式中要將response傳給next方法
    function request(url) {
      makeAjaxCall(url, function(response){
        it.next(response);
      });
    }
    // 需要第一次執行next方法,返回yield後的表示式,觸發非同步請求,跳到request函式中執行
    var it = main();
    it.next();
    複製程式碼
  • 控制流管理

    // 同步steps
    let steps = [step1Func, step2Func, step3Func];
    function *iterateSteps(steps){
      for (var i=0; i< steps.length; i++){
        var step = steps[i];
        yield step();
      }
    }
    // 非同步後續討論
    複製程式碼

實現

TO BE CONTINUED

lesson3 Generator的非同步應用

回到最初提到的非同步:將非同步任務看做兩個階段,第一階段現在執行,第二階段在未來執行,這裡就需要將任務 暫停。而前面說到的Generator似乎恰好提供了這麼一個當口,暫停結束後第二階段開啟不就對應下一個next呼叫嘛!

想像我有一個非同步操作,我可以通過Generator的next方法傳入操作需要的引數,第二階段執行完後返回值的value又可以向外輸出,maybe Generator真的可以作為非同步操作的容器?

before it

協程coroutine

協程A執行->協程A暫停,執行權轉交給協程B->一段時間後執行權交還A->A恢復執行

// yield是非同步兩個階段的分割線
function* asyncJob() {
  // ...其他程式碼
  var f = yield readFile(fileA);
  // ...其他程式碼
}
複製程式碼

Thunk函式

  • 引數的求值策略

    • 傳名呼叫和傳值呼叫之爭
    • 後者更簡單,但是可能會有需要大量計算求值卻沒有用到這個引數的情況,造成效能損失
  • js中的Thunk函式

    • 傳統的Thunk函式是傳名呼叫的一種實現,即將引數作為一個臨時函式的返回值,在需要用到引數的地方對臨時函式進行求值
    • js中的Thunk函式略有不同 js中的Thunk函式是將多引數函式替換為單引數函式(這個引數為回撥函式)
      const Thunk = function(fn) {
        return function (...args) {
          return function (callback) {
            return fn.call(this, ...args, callback);
          }
        };
      };
      複製程式碼
      看起來只是換了個樣子,好像並沒有什麼用

自執行

Generator看起來很美妙,但是next呼叫方式看起來很麻煩,如何實現自執行呢?

Thunk函式實現Generator函式自動執行

  • Generator函式自動執行

    function* gen() {
      yield a // 表示式a
      yield 2
    }
    let g = gen()
    let res = g.next()
    while(!res.done) {
      console.log(res.value)
      res = g.next() // 表示式b
    }
    複製程式碼

    但是,這不適合非同步操作。如果必須保證前一步執行完,才能執行後一步,上面的自動執行就不可行。

    next方法是同步的,執行時必須立刻返回值,yield後是同步操作當然沒問題,是非同步操作時就不可以了。處理方式就是返回一個Thunk函式或者Promise物件。此時value值為該函式/物件,done值還是按規矩辦事。

    var g = gen();
    var r1 = g.next();
    // 重複傳入一個回撥函式
    r1.value(function (err, data) {
      if (err) throw err;
      var r2 = g.next(data);
      r2.value(function (err, data) {
        if (err) throw err;
        g.next(data);
      });
    });
    複製程式碼
  • Thunk函式的自動流程管理

    • 思路:

      Generator函式中yield 非同步Thunk函式,通過yield將控制權轉交給Thunk函式,然後在Thunk函式的回撥函式中呼叫Generator的next方法,將控制權交回給Generator。此時,非同步操作確保完成,開啟下一個任務。

      Generator是一個非同步操作的容器,實現自動執行需要一個機制,這個機制的關鍵是控制權的交替,在非同步操作有了結果以後自動交回控制權,而回撥函式執行正是這麼個時間點。

      // Generator函式的執行器
      function run(fn) {
        let gen = fn()
        // 傳給Thunk函式的回撥函式
        function cb(err, data) {
          // 控制權交給Generator,獲取下一個yield表示式(非同步任務)
          let result = gen.next(data)
          // 沒任務了,返回
          if (result.done) return
          // 控制權交給Thunk函式,傳入回撥
          result.value(cb)
        }
        cb()
      }
      // Generator函式
      function* g() {
        let f1 = yield readFileThunk('/a')
        let f2 = yield readFileThunk('/b')
        let f3 = yield readFileThunk('/c')
      }
      // Thunk函式readFileThunk
      const Thunk = function(fn) {
        return function (...args) {
          return function (callback) {
            return fn.call(this, ...args, callback);
          }
        };
      };
      var readFileThunk = Thunk(fs.readFile);
      readFileThunk(fileA)(callback);
      // 自動執行
      run(g)
      複製程式碼

大名鼎鼎的co

  • 說明

    • 不用手寫上述的執行器,co模組其實就是將基於Thunk函式和Promise物件的兩種自動Generator執行器包裝成一個模組
    • 使用條件:yield後只能為Thunk函式或Promise物件或Promise物件陣列
  • 基於Promise的執行器

    function run(fn) {
      let gen = fn()
      function cb(data) {
        // 將上一個任務返回的data作為引數傳給next方法,控制權交回到Generator
        // 這裡將result變數引用{value, done}物件
        // 不要和Generator中的`let result = yield xxx`搞混
        let result = gen.next(data)
        if (result.done) return result.value
        result.value.then(function(data){
          // resolved之後會執行cb(data)
          // 開啟下一次迴圈,實現自動執行
          cb(data)
        })
      }
      cb()
    }
    複製程式碼
  • 原始碼分析

    其實和上面的實現類似

    function co(gen) {
      var ctx = this;
      var args = slice.call(arguments, 1) // 除第一個引數外的所有引數
      // 返回一個Promise物件
      return new Promise(function(resolve, reject) {
        // 如果是Generator函式,執行獲取遍歷器物件gen
        if (typeof gen === 'function') gen = gen.apply(ctx, args);
        if (!gen || typeof gen.next !== 'function') return resolve(gen);
        // 第一次執行遍歷器物件gen的next方法獲取第一個任務
        onFulfilled();
        // 每次非同步任務執行完,resolved以後會呼叫,控制權又交還給Generator
        function onFulfilled(res) {
          var ret;
          try {
            ret = gen.next(res); // 獲取{value,done}物件,控制權在這裡暫時交給非同步任務,執行yield後的非同步任務
          } catch (e) {
            return reject(e);
          }
          next(ret); // 進入next方法
        }
        // 同理可得
        function onRejected(err) {
          var ret;
          try {
            ret = gen.throw(err);
          } catch (e) {
            return reject(e);
          }
          next(ret);
        }
        // 關鍵
        function next(ret) {
          // 遍歷執行完非同步任務後,置為resolved,並將最後value值返回
          if (ret.done) return resolve(ret.value);
          // 獲取下一個非同步任務,並轉為Promise物件
          var value = toPromise.call(ctx, ret.value);
          // 非同步任務結束後會呼叫onFulfilled方法(在這裡為yield後的非同步任務設定then的回撥引數)
          if (value && isPromise(value)) return value.then(onFulfilled, onRejected);
          return onRejected(new TypeError('You may only yield a function, promise, generator, array, or object, '
            + 'but the following object was passed: "' + String(ret.value) + '"'));
        }
      })
    }
    複製程式碼

    其實還是一樣,為Promise物件then方法指定回撥函式,在非同步任務完成後觸發回撥函式,在回撥函式中執行Generator的next方法,進入下一個非同步任務,實現自動執行。

    舉個?

    'use strict';
    const fs = require('fs');
    const co =require('co');
    function read(filename) {
      return new Promise(function(resolve, reject) {
        fs.readFile(filename, 'utf8', function(err, res) {
          if (err) {
            return reject(err);
          }
          return resolve(res);
        });
      });
    }
    co(function *() {
      return yield read('./a.js');
    }).then(function(res){
      console.log(res);
    });
    複製程式碼

lesson4 async函式

語法糖

  • 比較

    function* asyncReadFile () {
      const f1 = yield readFile('/etc/fstab');
      const f2 = yield readFile('/etc/shells');
      console.log(f1.toString());
      console.log(f2.toString());
    };
    const asyncReadFile = async function () {
      const f1 = await readFile('/etc/fstab');
      const f2 = await readFile('/etc/shells');
      console.log(f1.toString());
      console.log(f2.toString());
    };
    複製程式碼

    看起來只是寫法的替換,實際上有這樣的區別

    • async函式內建執行器,不需要手動執行next方法,不需要引入co模組
    • async適用更廣,co模組對yield後的內容嚴格限制為Thunk函式或Promise物件,而await後可以是Promise物件或原始型別值
    • 返回Promise,這點和co比較像
  • 用法

    • async標識該函式內部有非同步操作
    • 由於async函式返回的是Promise,所以可以將async函式作為await命令的引數
    • async函式可以使用在函式、方法適用的許多場景

語法

  • 返回的Promise

    • async函式只有在所有await後的Promise執行完以後才會改變返回的Promise物件的狀態(return或者拋錯除外)即只有在內部操作完成以後才會執行then方法
    • async函式內部return的值會作為返回的Promise的then方法回撥函式的引數
    • async函式內部丟擲的錯誤會使得返回的Promise變成rejected狀態,同時錯誤會被catch捕獲
  • async命令及其後的Promise

    • async命令後如果不是一個Promise物件,則會被轉成一個resolved的Promise
    • async命令後的Promise如果拋錯了變成rejected狀態或者直接rejected了,都會使得async函式的執行中斷,錯誤可以被then方法的回撥函式catch到
    • 如果希望async的一個await Promise不影響到其他的await Promise,可以將這個await Promise放到一個try...catch程式碼塊中,這樣後面的依然會正常執行,也可以將多個await Promise放在一個try...catch程式碼塊中,此外還可以加上錯誤重試

使用注意

  • 相互獨立的非同步任務可以改造下讓其併發執行(Promise.all)

    let [foo, bar] = await Promise.all([getFoo(), getBar()]);
    複製程式碼
  • await 與 for ... of

    應該還在提案階段吧

    for await (const item of list) {
      console.log(item)
    }
    複製程式碼

實現

  • 其實就是將執行器和Generator函式封裝在一起,詳見上一課

舉舉?

  • 併發請求,順序輸出
    async function logInOrder(urls) {
      // 併發讀取遠端URL
      const textPromises = urls.map(async url => {
        const response = await fetch(url);
        return response.text();
      });
      // 按次序輸出
      for (const textPromise of textPromises) {
        console.log(await textPromise);
      }
    }
    複製程式碼

目前瞭解到的非同步解決方案大概就這樣,Promise是主流,Generator作為容器,配合async await語法糖提供了看起來似乎更加優雅的寫法,但實際上因為一切都是Promise,同步任務也會被包裝成非同步任務執行,個人感覺還是有不足之處的。

雖發表於此,卻畢竟為一人之言,又是每日學有所得之筆記,內容未必詳實,看官老爺們還望海涵。

相關文章