LoginSignup
2
0

More than 1 year has passed since last update.

配列操作中にasync/awaitする

Last updated at Posted at 2020-10-27

※新しい便利クラスを定義してやろうという記事です。普通に使うなら使いたいメソッドに合わせて関数を作るだけで充分かもしれません。

はじめに

Promiseとasync/awaitは便利ですが、そのまま配列と組み合わせて使おうとすると途端に面倒になります。

//並列に実行したい時
const res = array.map(url=>fetch(url).then(res=>res.json()))
Promise.all(res).then()
//直列に実行したい時
[5000, 2000, 6000].reduce((p, val)=>p.then(async ()=>{
  await wait(val)
  console.log(val)
}), Promise.resolve())

Promise.allreduceが途中に入るので、何をやっているかが分かりにくい書き方になっています。for-await-ofなどを使うという手もありますが、普通の配列のようにmap()とかfilterとかでチェーンしていきたい時もあると思います。
そこで、Promiseが入った配列を手軽に扱うために、メソッドチェーンのたびに内部で暗黙的にPromise.all()が呼ばれるような配列クラスを自作したいと思います。クラス名はAsyncArrayです。

配列のメソッドの分類

PromiseとArrayを組み合わせていくにあたって、配列系メソッドにはいくつかの種類があることが分かりました。以下のように分類できます。

A. メソッドを呼ぶ際に配列の中身がすべて決まっている必要があるもの

  • find, sort, indexOf, join, flat, reduce, toString, などなど

これらのメソッドは、実行の際に配列の中身がすべて求まっている必要があります。

B. メソッドを呼ぶ際に配列の中身がすべて決まっている必要がないもの

  • concat, fill, pop, push, reverse, slice, entries, forEach, filter, map, every, some, などなど

これらのメソッドは、実行の際に配列の中身がすべて求まっている必要がありせん。Promise.allを呼ぶことなく、Promiseが入った配列として操作することができます。
更に、Bは以下のようにさらに細かく分類することができます。

B-1. 配列の順番通りに実行してほしいもの
  • map(一部の場合), forEach(一部の場合), などなど
  • for-await-ofのような動作
  • 「forEachのasync関数が順番通り実行されなくて困る」みたいな
B-2. それ以外
  • その他大勢

メソッドを使う際にタイプAかタイプBかなんていちいち気にしてられないので、定義する際は、メソッド名にAsyncを付けたらタイプB-2で動作することにします。
例)
- .map() →実行前にPromise.all()が呼ばれる(順番通り)
- .mapAsync() →実行前にPromise.all()が呼ばれない(順番が前後する)

タイプB-1は要は「他のPromiseが解決前であっても処理を始める(ただし順番は守る)」という事です。しかしjavascriptはシングルスレッドなので、順番が前後しようが実行時間は変わりません。このタイプが有効なのはfetchとsetTimeoutだけだと思います。なのでforEachに対してしか実装してません(後述)。

前提

  • await hogehogeと書くと暗黙的にhogehoge.then()が実行されて値が返ります。
    • hogehogeがPromiseであってもなくても、then()が実行されます。
    • await 114514のようにthenメソッドを持たない値が来たら、値がそのまま返ります。
    • thenメソッドを持つオブジェクトをthenable(=then可能)といいます。

実装方針

  • Arrayは継承しない
    • Object.getOwnPropertyNames(Array.prototype)からメソッド一覧を取得し、definePropatyする
  • AsyncArray.from(arrat)でインスタンスを生成し、asyncArray.then(array=>~)又はawait asyncArrayで普通の配列に戻せる
    • await AsyncArray.from([0, 1, 2]).map(i=>i*1000).map(i=>wait(i))みたいな感じ
    • async関数も渡せる
  • メソッドチェーン可能
    • .then(arr=>arr.map(~)).then(arr=>arr.map(~))ではなく
    • .map(~).map(~)と書ける
  • 配列本体はは内部プロパティで保持する。内部プロパティは「「値を返すPromise」の配列を返すPromise」とする
    • Promise<Array<Promise<T>>>(でいいのか?)(知らない)
    • 途中で内部的にPromise.allを噛ませる必要があるため、配列がPromiseでラップされてしまう
    • lengthとかは無い (flatとか呼ばれると全てのPromiseが完了するまで取得できないので、同期的には無理)
    • なので[Symbol.iterator]もない([Symbol.asyncIterator]はいける)

実装内容

const currentValue = Symbol('currentValue')
const fromValue = Symbol('fromValue')

class AsyncArray {
    constructor(...args) {
        //Arrayコンストラクタのように動作
        this[currentValue] = Promise.resolve(new Array(...args))
    }
    then(resolve, reject) {
        //currentValueのPromiseが全て解決された状態で返す
        return this[currentValue]
        .then(v=>Array.isArray(v)?Promise.all(v):v)
        .then(resolve, reject)
    }
    async forAwaitEach(callback, thisArg) {
        //for-await-ofのように動作
        const useThisArg = 1<arguments.length
        const array = await this[currentValue]
        let promise = Promise.resolve()
        array.forEach((value, ...args)=>{
            promise = Promise.all([promise, Promise.resolve(value)]).then(
                ([_, resolvedValue])=>useThisArg
                    ?callback.call(thisArg, resolvedValue, ...args)
                    :callback(resolvedValue, ...args)
            )
        })
        await promise
    }
    get [Symbol.toStringTag]() {
        return 'AsyncArray';
    }
    /*[Symbol.iterator]() {
        //値で解決されるPromiseを取り出す
        //lengthが確定しないので無理
    }*/
    async *[Symbol.asyncIterator]() {
        //値を取り出す
        const array = await this[currentValue]
        for await (const value of array) {
            yield value
        }
    }
    toString() {//Array#toString.applyで上書きされないように
        return Object.prototype.toString.call(this)//'[object AsyncArray]'
    }
}
AsyncArray[fromValue] = function (promiseValue) {
    //AsyncArrayを生成して返す(arrayへキャストしない)
    //メソッドチェーン用(Arrayが期待されるが、Arrayでない場合もthen()を呼ぶことで値を取り出せる)
    const result = new AsyncArray()
    result[currentValue] = Promise.resolve(promiseValue)
    return result
}
AsyncArray.from = function (...args) {
    //AsyncArrayを生成して返す(arrayへキャストする)
    const result = new AsyncArray()
    result[currentValue] = Promise.resolve(Array.from(...args))
    return result
}
AsyncArray.of = function (...args) {
    //AsyncArrayを生成して返す(arrayへキャストする)
    const result = new AsyncArray()
    result[currentValue] = Promise.resolve(Array.of(...args))
    return result
}
AsyncArray.isAsyncArray = function (obj) {
    return Object.prototype.toString.call(arg)==='[object AsyncArray]';
}

//constructorなどを上書きしない
const originalMethods = new Set(Object.getOwnPropertyNames(AsyncArray.prototype))
//Array.prototypeのメソッドを追加
for (const prop of Object.getOwnPropertyNames(Array.prototype)){
    if (typeof(Array.prototype[prop])!=='function'||originalMethods.has(prop)) {
        continue
    }
    Object.defineProperty(AsyncArray.prototype, prop, {
        writable: true,
        enumerable: false,
        configurable: true,
        value(...args) {
            const result = this[currentValue].then(
                promiseArray=>Promise.all(promiseArray).then(
                    resolvedArray=>[Array.prototype[prop].apply(resolvedArray, args), resolvedArray]
                )
            )
            this[currentValue] = result.then(([returnValue, promiseArray])=>promiseArray)
            return AsyncArray[fromValue](result.then(([returnValue, promiseArray])=>returnValue))
        }
    });
}

//Array.prototypeのメソッドで、実行の際にPromiseが解決されている必要がないメソッド
//hogehogeAsyncのような名前で追加
const asyncableMethods = ['concat', 'copyWithin', 'fill', 'pop', 'push', 'reverse', 'shift', 'unshift', 'slice', 'splice', 'keys', 'entries', 'values', 'forEach', 'filter', 'map', 'every', 'some']
for (const originalProp of asyncableMethods){
    const prop = `${originalProp}Async`
    Object.defineProperty(AsyncArray.prototype, prop, {
        writable: true,
        enumerable: false,
        configurable: true,
        value(...args) {
            const result = this[currentValue].then(
                promiseArray=>promiseArray.map(v=>Promise.resolve(v))
            ).then(
                promiseArray=>[Array.prototype[originalProp].apply(promiseArray, args), promiseArray]
            )
            this[currentValue] = result.then(([returnValue, promiseArray])=>promiseArray)
            return AsyncArray[fromValue](result.then(([returnValue, promiseArray])=>returnValue))
        }
    });
}

使用方法

const asyncArray = AsyncArray.from([5, 1, 2])
await asyncArray.map(i=>i*1000).map(async i=>{
    await wait(i)
    return i
}).forAwaitEach(i=>console.log(i))
const wait = n => new Promise(r=>setTimeout(_=>r(n), n))
const asyncArray = AsyncArray.from([3, 5, 1, 2])
for await (const v of asyncArray.map(i=>i*1000).map(wait)) {
    console.log(v);
}
await AsyncArray.from([0, 1, 2])
.mapAsync(async i=>await(i)+1)
.flatMap(i=>[i, i+10, i+50])
.sort((a, b)=>b-a)
.slice(0, 5)
.join('-')

forEachやmapに普通にasync functionが渡せます。
注意点として、メソッドチェーンの返り値は必ず(stringであっても)AsyncArrayにラップされます。これは、値を返す時点では返り値の型が確定していないからです。(Promiseを返した場合はメソッドチェーンできなくなる)
配列にない独自のメソッドとしてforAwaitEachを実装しています。上で分類したタイプB-1にあたるメソッドで、for-await-ofのように動作します。

mapAsyncなどの非同期メソッドで配列内の値にアクセスすると、promiseが返されるので、内部で適宜awaitしてください。

おわりに

AsyncArrayを実装している人は他にもいらっしゃるようです。ただmapやfilterなどを全部自作しているのがほとんどで、配列にもとからあるメソッドを流用するやり方をしている方がいなかったので、作ってみました。今後Array.prototypeのメソッドが増えても、この方式なら対応可能だと思います。

2
0
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0