手寫 p-map(控制併發數以及迭代處理 promise 的庫)

xingba-coder發表於2024-10-08

介紹

p-map 是一個迭代處理 promise 並且能控制 promise 執行併發數的庫。作者是 sindresorhus,他還建立了許多關於 promise 的庫 promise-fun,感興趣的同學可以去看看。

之前 提到的 p-limit 也是一個控制請求併發數的庫,控制併發數方面,兩者作用相同,不過 p-map 增加了對請求(promise)的迭代處理。

之前 p-limit 的用法如下,limit 接受一個函式;

var limit = pLimit(8); // 設定最大併發數量為 2
var input = [ // Limit函式包裝各個請求
    limit(() => fetchSomething('1')),
    limit(() => fetchSomething('2')),
    limit(() => fetchSomething('3')),
    limit(() => fetchSomething('4'))
];
// 執行請求
Promise.all(input).then(res =>{
    console.log(res)
})

p-map 則透過使用者傳進來的 mapper 處理函式處理的一個集合(準確的說是一個可迭代物件);

import pMap from 'p-map';
import got from 'got';

const sites = [
	getWebsiteFromUsername('sindresorhus'), //=> Promise
	'https://avajs.dev',
	'https://github.com'
];

const mapper = async site => {
	const {requestUrl} = await got.head(site);
	return requestUrl;
};

// 接收三個引數,一個是可迭代物件,一個是對可迭代物件進行處理的函式,一個是配置選項
const result = await pMap(sites, mapper, {concurrency: 2});

console.log(result);
//=> ['https://sindresorhus.com/', 'https://avajs.dev/', 'https://github.com/']

預設的可迭代物件有StringArrayTypedArrayMapSetIntl.Segments,而要成為可迭代物件,該物件必須實現 [Symbol.iterator]() 方法;

遍歷可迭代物件時,實際上是根據迭代器協議進行遍歷;

比如,迭代一個陣列是這樣的

var iterable = [1,2,3,4]
var iterator = iterable[Symbol.iterator]() // iterator 是迭代器
iterator.next() // {value: 1, done: false}
iterator.next() // {value: 2, done: false}
iterator.next() // {value: 3, done: false}
iterator.next() // {value: 4, done: false}
iterator.next() // {value: undefined, done: true}

當陣列迭代完成後,會返回 {value: undefined, done: true}

p-pap 控制併發請求的原理是,對傳進來的集合進行迭代,當集合第一個元素(元素可能是非同步函式)執行完後,會交給 mapper 函式處理,mapper 處理完後,才開始迭代下一個元素,這樣就保持了按照順序一個個迭代,此時併發數是1

要做到併發是n,並且還能執行上面的迭代,作者很巧妙的用了 for 迴圈

for(let i=0;i<n;i++)
    next()
}

手寫 p-map

下面按照作者思路實現一個 p-map,現在有一個需要處理的可迭代物件 arr

var fetchSomething = (str,ms) =>{
    return new Promise((resolve,reject) =>{
        setTimeout(() =>{
            resolve(parseFloat(str))
        },ms)
    })
}

var arr= [
    fetchSomething('1a' ,1000), // promise
    2,3, 
    fetchSomething( '4a' , 5000), // promise
    5,6,7,8,9,10
]

集合第一個元素和第四個元素是一個 promise 函式

p-map 接收三個引數,分別是要迭代的物件,mapper 處理函式,自定義配置;返回值是 promise, 如下面所示

var pMap = (iterable, mapper, options) => new Promise((resolve,reject) => {});

拿到可迭代物件後,對它進行遞迴迭代,直至迭代完畢;這裡定義一個內部遞迴迭代函式 next

var pMap = (iterable, mapper, options) => new Promise((resolve,reject) => {
    var iterator = iterable[Symbol.iterator]()

    var next= ()=>{
        var item=iterator.next()
        if(item.done){
            return
        }
        next()
    }
    
});

迭代物件中每個元素都是按順序迭代的;如果元素是非同步函式時,需要先等非同步函式兌現,並且兌現後的值傳給 mapper 函式,等到 mapper 函式兌現或者拒絕後才繼續迭代下一個元素

var iterator = iterable[Symbol.iterator]()
var next = () => {
    var item = iterator.next()

    if (item.done) {
        return
    }

    Promise.resolve(item.value)
        .then(res => mapper(res))
        .then(res2 => {
            next()
        })
}

並且每次迭代徹底完成後儲存兌現的結果

var iterator = iterable[Symbol.iterator]()
var index = 0 // 序號,根據可迭代物件的順序儲存結果
var ret = []// 儲存結果
var next = () => {
    var item = iterator.next()

    if (item.done) {
        return
    }

    var currentIndex = index //儲存當前元素序號,用於存入結果
    index++ //下一個元素的序號
    Promise.resolve(item.value)
        .then(res => mapper(res))
        .then(res2 => {
            ret[currentIndex] = res2
            next()
        })
}

當整個迭代完後,並且元素全部執行(兌現)完,輸出結果集

var pMap = (iterable, mapper, options) => new Promise((resolve,reject) => {
    var activeCount = 0 //正在執行的元素個數
    var next = () => {
        var item = iterator.next()
        if (item.done) {  //元素全部迭代完
            if (activeCount == 0) {
                resolve(ret)  //元素全部執行(兌現)完,輸出結果集
            }
            return
        }
        var currentIndex = index // 儲存當前元素序號,用幹存入結果
        index++ //下一個元素的序號
        activeCount++
        Promise.resolve(item.value)
            .then(res => mapper(res))
            .then(res2 => {
                ret[currentIndex] = res2
                activeCount--
                next()
            })
            .catch(err => {
                activeCount--
            })
    }
})
配置項 stopOnError

傳入 p-map 配置項中有一個引數是 stopOnError,表示當執行遇到錯誤,是否終止迭代迴圈,所以這裡在 .catch() 裡面做判斷;

Promise.resolve(item.value)
    .then(res = mapper(res))
    .then(res2 => {
        // ...
    })
    .catch(err => {
        ret[currentIndex] == err // 將錯誤的結果也儲存起來
        
        if (stopOnError) {
            hasError = true
            reject(err) // 發生錯誤,終止迴圈
        }else {
            hasError = false
            activeCount--
            next()
        }
    }
忽略錯誤執行結果 pMapSkip

mapper 函式是使用者自定義的, 如果 mapper 執行錯誤,使用者期望忽略錯誤執行結果,只保留正確結果,這該怎麼做呢?,此時 pMapSkip 就登場了;

p-map 原始碼中提供了 pMapSkippMap5kip 是一個 Symbol 值,p-map 內部處理則是:當結果集收到的結果是 pMapSkip,則會在迭代完成後清除返回值是 pMapSkip 的元素,也就是說 mapper 處理時發生錯誤, 使用者不想要這個值,可以 reject(pMapSkip) 比如:

import pMap, { pMapSkip } from 'p-map'

var arr = [
    fetchSomething('1a', 1000, true),
    2, 3
]

var mapper = (item, index) => {
    return new Promise((resolve, reject) => {
        return item == 2 ? reject(pMapSkip): resolve(parseFloat(item)) // 元素是 2 ,丟擲錯誤
    })
}

(async () => {
    const result = await pMap(arr, mapper, { concurrency: 2 });
    console.log(result); //=>[1,3]
})();

所以當 mapper 返回 pMapSkip 時,需要標記對應的元素

var skipIndexArr= []

記錄需要剔除的元素的位置

var skipIndexArr = [];

Promise.resolve(item.value)
  .then((res = mapper(res)))
  .then((res2) => {
    // ...
  })
  .catch((err) => {
    if (err === pMapSkip) {
      skipIndexArr.push(currentIndex); //記錄需要剔除的元素的位置
    } else {
      ret[currentIndex] == err; 

      if (stopOnError) {
        hasError = true;
        reject(err);
      } else {
        hasError = false;
        activeCount--;
        next();
      }
    }
  });

並且在迭代結束時剔除結果集中的有 pMapSkip 的元素

if (item.done) {
  if (activeCount == 0) {
    for (var k of skipIndexArr) {
      ret.splice(k, 1);
    }
    resolve(ret);
    return;
  }
}

在資料裡大的情況下,頻繁使用 splice 效能可能沒那麼好,因為執行 splice 後,其後的元素的索引都會改變;那麼就要改造下,將 skipIndexArr 改為 Map 形式。

// var skipIndexArr= []
var skipIndexArr= new Map()

記錄需要刪除的元素的位置

if (err === pMapSkip) {
  skipIndexArr.set(currentIndex, err);
}

然後迭代結束時,不再在原陣列裡面 splice ,改為用新陣列接收;pushsplice 效能好;

if (item.done) {
  if (activeCount == 0) {
    if (skipIndexArr.size === 0) {
      resolve(ret);
      return;
    }

    const pureRet = [];

    for (const [index, value] of ret.entries()) {
      if (skipIndexArr.get(index) === pMapSkip) {
        continue;
      }

      pureRet.push(value);
    }

    resolve(pureRet);
  }
  return;
}

在外部取消 p-map 的請求或者取消迭代: AbortController

存在某些情況,當我們不再需要 p-map 返回的結果,或者不再想要使用 p-map 時,我們就需要在外部取消 p-map 的請求或者取消迭代,這時就可以使用 AbortController

簡單介紹下 AbortController 的用法,有一個請求 fetchSomething

var fetchSomething = (str) => {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(str)
        }, 1000)
    })
}

想要取消 fetchSomething 請求,就需要傳一個 signal 到裡面;signalAbortController 的例項屬性;AbortController 和請求之間就是由 signal 建立關聯;

var controller = new AbortController()
var signal = controller.signal

var fetchSomething = (str,signal) => {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(str)
        }, 1000)
    })
}

fetchSomething('fetch',signal).then(res => {
    console.log('res:', res)
}).catch(err => {
    console.log('err:', err)
})

建立關聯後,外部取消請求使用的是 AbortController 例項方法 controller.abort()

controller.abort()

然後在請求裡面監聽外部是否呼叫了 controller.abort();有兩種方式

signal.addEventListener('abort', () => {

}, false)

或者

if (signal.aborted) {

}

完整示例:

var controller = new AbortController()
var signal = controller.signal

var fetchSomething = (str,signal) => {
    return new Promise((resolve, reject) => {
        signal.addEventListener('abort', () => {
            console.log(' addEventListener')
            reject('addEventListener取消')
        }, false)
        setTimeout(() => {
            if (signal.aborted) {
                console.log('aborted')
                reject('aborted取消')
                return
            }
            console.log('進入setTimeout')
            resolve(str)
        }, 1000)
    })
}

setTimeout(() => {
    controller.abort()
}, 500)

fetchSomething('fetch',signal).then(res => {
    console.log('res:', res)
}).catch(err => {
    console.log('err:', err)
})

500ms 後輸出:

addEventListener
err: addEventListener取消
aborted

結合 p-map 使用如下:

import pMap from 'p-map';

const abortController = new AbortController();

setTimeout(() => {
	abortController.abort();
}, 500);

const mapper = async value => value;

await pMap([fetchSomething(1000), fetchSomething(1000)], mapper, {signal: abortController.signal});
// 500 ms 結束 pMap 方法,丟擲錯誤資訊.

那麼 p-map 內部實現就好寫了:

var pMap = (iterable, mapper, options) => new Promise((resolve, reject) => {
    var { signal } = options

    if (signal) {
        if (signal.aborted) {
            reject(signal.reason);
        }

        signal.addEventListener('abort', () => {
            reject(signal.reason);
        });
    }

    var next = () =>{
        //...
    }
})
手寫的完整原始碼:

下面的原始碼也可以把 promise.then() 和 .catch() 寫法改進為 async await + try catch 寫法;

let pMapSkip = Symbol('skip')

var pMap = (iterable, mapper, options) => new Promise((resolve, reject) => {
    var iterator = iterable[Symbol.iterator]()
    var index = 0 // 序號,根據可迭代物件的順序儲存結果
    var ret = []// 儲存結果
    var activeCount = 0 //正在執行的元素個數
    var isIterableDone = false
    var hasError = false
    var skipIndexArr = new Map()
    var { signal, stopOnError, concurrency } = options

    if (signal) {
        if (signal.aborted) {
            reject(signal.reason);
        }

        signal.addEventListener('abort', () => {
            reject(signal.reason);
        });
    }

    var next = () => {
        var item = iterator.next()
        if (item.done) {
            isIterableDone = true
            if (activeCount == 0) {
                if (skipIndexArr.size === 0) {
                    resolve(ret);
                    return;
                }

                const pureRet = [];

                for (const [index, value] of ret.entries()) {
                    if (skipIndexArr.get(index) === pMapSkip) {
                        continue;
                    }

                    pureRet.push(value);
                }

                resolve(pureRet);
            }
            return;
        }
        var currentIndex = index // 儲存當前元素序號,用幹存入結果
        index++ //下一個元素的序號
        activeCount++
        Promise.resolve(item.value)
            .then(res => mapper(res))
            .then(res2 => {
                ret[currentIndex] = res2
                activeCount--
                next()
            })
            .catch(err => {
                ret[currentIndex] == err;
                if (stopOnError) {
                    hasError = true;
                    reject(err);
                } else {
                    ret[currentIndex] == err;
                    if (err === pMapSkip) {
                        skipIndexArr.set(currentIndex, err);
                    }
                    hasError = false;
                    activeCount--;
                    next();
                }

            })
    }

    for (let k = 0; k < concurrency; k++) {
        if (isIterableDone) {
            break
        }
        next()
    }
})
測試一下

1、測試 pMapSkip

var arr= [
    fetchSomething('1a' ,1000), // promise
    2,3, 
    fetchSomething( '4a' , 5000), // promise
    5,6,7,8,9,10
]
var mapper = (item, index) => {
    return new Promise((resolve, reject) => {
        return item == 3 ? reject(pMapSkip): resolve(parseFloat(item))
    })
}

pMap(arr, mapper, { concurrency: 2 }).then(res => {
    console.log(res)  // [1, 2, 4, 5, 6, 7, 8, 9, 10] ,剔除了 3
})

2、測試中止請求

var controller = new AbortController()
var signal = controller.signal

pMap(arr, mapper, { concurrency: 2,signal:signal }).then(res => {
    console.log(res)
}).catch(err =>{
    console.log(err) // 500ms 後列印 AbortError: signal is aborted without reason
})

setTimeout(() =>{
    controller.abort()
},500)

3、測試 stopOnError

pMap(arr, mapper, { concurrency: 2,signal:signal,stopOnError:true }).then(res => {
    console.log(res)
}).catch(err =>{
    console.log(err) // Symbol(skip)
})

至此,p-map 核心功能實現完了;感興趣的同學可以點點贊;

相關文章