非同步多圖載入這件小事兒(Promise與async)

Bob-Chen發表於2018-05-02

日常開發過程中,時不時會遇到要同時預載入幾張圖片,並且等都載入完再幹活的情況,結合 Promise 和 async/await 程式碼會優雅很多,但也容易遇到坑,今天就來簡單聊聊。

ES5

先從最基本的 ES5 說起,基本思路就是做一個計數器,每次 image 觸發 onload 就加一,達到次數後觸發回撥函式。

var count = 0,
	 imgs = [];
	 
function loadImgs(imgList, cb) {
	imgList.forEach(function(url, i) {
		imgs[i] = new Image();
		imgs[i].onload = function() {
			if( ++count === imgList.length) {
				cb && cb()
			}
		}
		imgs[i].src = url;
	})
}
複製程式碼

呼叫方法:

loadImgs(["xxx/a.png","xxx/b.png"],function() {
	console.log("開始幹活");
})
複製程式碼

這樣做基本功能是能滿足的,但是這種回撥的方式跳來跳去,程式碼顯得比較混亂。

俗話說,非同步程式設計的最高境界,就是根本不用關心它是不是非同步。能用同步的方式寫出非同步的程式碼,才是好的編碼體驗。於是乎,到 Promise 和 async/await 出場了。

ES6

讓我們用 Promise 和 async/await 來改寫一下。(注意,這個例子有個很大的問題)

async function loadImgs(imgList, cb) {

    console.log("start")
    for( var i =0; i<imgList.length; i++) {
        await imgLoader(imgList[i], i);
        console.log("finish"+i)
    }
    cb();
}

async function imgLoader(url, num){
    return new Promise((resolve, reject) => {
        console.log("request"+num)
        
        setTimeout(resolve, 1000);
        // let img = new Image();
        // img.onload = () => resolve(img);
        // img.onerror = reject;

        console.log("return"+num)
    })
}

loadImgs(["xxx/a.png","xxx/b.png"],function() {
	console.log("開始幹活");
})
複製程式碼

為了方便在 node 環境中執行程式碼,這裡我用 setTimeout 代替了真正的圖片載入。

執行的結果是:

start
request0
return0
finish0
request1
return1
finish1
開始幹活
複製程式碼

有沒有發現問題,雖然我們期望的是用同步程式碼的形式寫出非同步的效果,雖然我們用了 async/await Promise 等吊炸天的東西,但是實際執行的結果卻是同步的。 request0 finish 之後,request1 才發出。

這樣的程式碼雖然語義清晰,通俗易懂,但等圖片一張一張順序載入是我們不能接受的,同時發出幾個請求非同步載入是我們的目標。

產生這種錯誤的原因是 async/await 其實只是語法糖並不是說加了就非同步了,其本質上是為了解決回撥巢狀過多的問題。

回撥函式

N 年前,通過分發 jQuery 武器,大家捲起袖子加入了前端大潮,然而他們遇到的一個大問題就是”回撥地獄“。

比如下面這個例子,發完三個 ajax 請求之後才能開始幹活。

$.ajax({
    url: "xxx/xxx",
    data: 123,
    success: function () {
        $.ajax({
            url: "xxx/xxx2",
            data:456,
            success: function () {
                $.ajax({
                    url: "xxx/xxx3",
                    data:789,
                    success: function () {
                        // 終於完了可以開始幹事情了
                    }
                })
            }
        })
    }
})
複製程式碼

這個還只是把簡單的程式碼結構寫出來,括號就多到眼花,如果再加上業務邏輯、錯誤處理等,那就是實實在在的”地獄“。

救世主 Promise ?

Promise 的出現大大改善了回撥地獄,寫法也更加接近同步。

簡單來說,Promise 就是一個容器,裡面儲存著某個已經發生未來才會結束的事件,當事件結束時,會自動呼叫一個統一的介面告訴你。

var promise = new Promise(function(resolve, reject) {
    $.ajax({
        url: "xxx/xxx3",
        success: function () {
           resolve(rs)
        },
        
    })
}

// 呼叫的時候
promise.then(function(rs){
	// 返回另一個 Promise
	return new Promise(...)
})
.then(function(rs){
	// 又返回另一個 Promise
	return new Promise(...)
})
.then(function(rs){
	// 開始幹活
})
.catch(function(err){
	// 出錯了
});
複製程式碼

Promise 的建構函式有兩個引數,都是 javascript 引擎提供的,不用自己實現,分別是 resolve 和 reject。

  • resolve 的作用是將 Promise 的狀態從“未完成”變成“解決了”,即非同步操作完成,可以將結果作為引數傳遞給下一步。
  • reject 的作用是將 Promise 的狀態從“未完成”變成“失敗”,即非同步操作失敗,並將錯誤傳遞出去。

then 方法可以接受兩個函式作為引數,分別對應 resolve 和 reject 時的處理,其中 reject 是可選的。

promise.then(function(value) {
  // success
}, function(error) {
  // failure
});
複製程式碼

Promise 至少把廣大開發者從回撥地獄中拯救出來,把回撥變為鏈式呼叫。

注意這裡只是拿 ajax 做例子,實際上 jQuery 的 ajax 已經 Promise 化,可以直接類似 Promise 的用法。

$.ajax({
  url: "test.html",
  context: document.body
}).done(function() {
  $( this ).addClass( "done" );
});
複製程式碼

這種寫法已經比回撥函式的寫法要直觀多了,但是還是有一些巢狀,不夠直觀。

async/await 降臨

Promise 和 async/await 之間其實還有一個 Generator,用的也不多,簡單說下,形式是這樣的:

function* gen(x){
  var y = yield x + 2;
  return y;
}

var g = gen(1);
g.next() // { value: 3, done: false }
g.next(2) // { value: 2, done: true }
複製程式碼

Generator 函式要用 * 來標識,用 yield 表示暫停,通過 yield 把函式分割出好多個部分,每呼叫一次 next 會返回一個物件,表示當前階段的資訊 (value 屬性和 done 屬性)。value 屬性是 yield 語句後面表示式的值,表示當前階段的值;done 屬性是一個布林值,表示 Generator 函式是否執行完畢,即是否還有下一個階段。

關於 Generator 的詳細資訊可以參考 www.ruanyifeng.com/blog/2015/0…

async/await 其實 Generator 的語法糖,用 async 這種更明確的標識代替 *,用 await 代替 yield。

說了這麼多,我們終於明白 async/await 是為了能用同步的方式寫出非同步的程式碼,同時解決回撥地獄。

所以在多圖片非同步載入這個場景下,我們期望的應該是多個非同步操作都完成之後再告訴我們。

async function loadImgs(imgList){
    let proList = [];
    for(var i=0; i<imgList.length; i++ ){
        let pro = new Promise((resolve, reject) => {
            console.log("request"+i)
            setTimeout(resolve, 2000);
            console.log("return"+i)
        })
        proList.push(pro)
    }

    return Promise.all(proList)
            .then( ()=>{
                console.log("finish all");
                return Promise.resolve();
            })
}

async function entry(imgList, cb) {
    await loadImgs(imgList);
    cb();
}

entry(["xxx/a.png","xxx/b.png"], function(){
    console.log("開始幹活")
})
複製程式碼

執行結果是:

request0
return0
request1
return1
finish all
開始幹活
複製程式碼

會看到一開始就立馬列印出

request0
return0
request1
return1
複製程式碼

過了兩秒之後,才列印出 finish all

完整例子

上面我們都是在 node 命令列裡面執行的,在理解整個過程之後,讓我們在瀏覽器裡面實際試試,由於相容性問題,我們要藉助 webpack 轉換一下。

上程式碼:


function loadImgs(imgList){
    let proList = [];
    for(var i=0; i<imgList.length; i++ ){
        let pro = new Promise((resolve, reject) => {
            let img = new Image();
            img.onload = function(){
                resolve(img)
            }
            img.src = imgList[i];
        })
        proList.push(pro)
    }

    return Promise.all(proList)
            .then( (rs)=>{
                console.log("finish all");
                return Promise.resolve(rs);
            })
}

async function entry(imgList, cb) {
    try {
        let rs = await loadImgs(imgList);
        cb(rs);
    } catch(err) {
        console.log(err)
        cb([])
    }
    
}

var imgUrlList = [
    "http://111.231.236.41/vipstyle/cartoon/v4/release/pic/index/recomment-single-s3.png",
    "http://111.231.236.41/vipstyle/cartoon/v4/release/pic/index/recomment-single-s2.png"
]
entry(imgUrlList, function(rs){
    console.log("開始幹活")
    console.log(rs)
})
複製程式碼

注意, await 命令後的 Promise 物件是有可能 rejected 的,所以最好放到 try...catch 塊中執行。

需要用 webpack 轉換下,可以參考我們 webpack.config.js:

module.exports = {
  entry: ['./index.js'],
  output: {
    filename: 'bundle.js'
  },
  devtool: 'sourcemap',
  watch: true,
  module: {
    loaders: [{
      test: /index.js/,
      exclude: /(node_modules)/,
      loader: 'babel',
      query: {
        presets: ['es2015', 'stage-3'],
        plugins: [
          ["transform-runtime", {
            "polyfill":false,
            "regenerator":true
          }]
        ]
      }                                          
    }]
  }
}
複製程式碼

跑完之後寫個頁面在瀏覽器執行一下,開啟 console,可以看到

微信公眾號:程式設計師的詩和遠方

返回的結果有兩個圖片物件,是我們期望的。

再看看 network,檢查下是否是併發的:

微信公眾號:程式設計師的詩和遠方

ok,搞定。

one more thing

其實到上面那一步關於 async/await 非同步載入圖片的相關東西已經講完了,這裡我們回過頭來看下生成的檔案,會發現特別的大,就那麼幾行程式碼生成的檔案居然有 80k。

把 webpack 具體打了哪些包列印出來看看:

微信公眾號:程式設計師的詩和遠方

其中,我們本來的 index.js 只有 4.08k ,但是 webpack 為了支援 async/await 打包了一個 24k 的 runtime.js 檔案,除此之外為了支援 es6 語法還打包了一大堆別的檔案進去。

如果你在打包的時候使用了 babel-polyfill 最後出來的檔案可以達到可怕的 200k。

於是我想起了 TypeScript。

TypeScript 具有優秀的自編譯能力,不需要額外引入 babel,而且比 babel 做的更好。以我上面的程式碼為例,安裝 TypeScript 之後,不需要任何修改,只要把字尾名改成 ts,直接就可以開始編譯。

來感受一下:

微信公眾號:程式設計師的詩和遠方

bundle-ts.js 就是用 TypeScript 編譯出來的,只有 5.5k

看一下編譯出來的檔案中 async/await 的實現,不到 40 行,乾淨利落。

微信公眾號:程式設計師的詩和遠方

TypeScript 編譯出的檔案跟你使用了多少特性有關係,而 bable 可能一開始就會給你打包一堆進去,即使你現在還沒用到,而且一些實現上 TypeScript 也要比 bable 更好。

當然,這裡並不是說用 TypeScript 就一定比 bable 好,還是要根據專案實際情況來,但 TypeScript 絕對值得你去花時間瞭解一下。

總結

有時候我們不能單從表面看問題,而要從一個事情的演化來看,比如 async/await 咋一看非同步,就認為加了就非同步,這樣很容易走入誤區。有空多想想背後的故事,會有更深刻的認識,你我共勉。

相關程式碼

github.com/bob-chen/de…

碎碎念

記錄一些所思所想,寫寫科技與人文,寫寫生活狀態,寫寫讀書心得,主要是扯淡和感悟。 歡迎關注,交流。

微信公眾號:程式設計師的詩和遠方

公眾號ID : MonkeyCoder-Life

程式設計師的詩和遠方

參考文章

Generator 函式的含義與用法

async 函式的含義和用法

相關文章