3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Promiseでネストが深くなるのを防ぐための方法(やや黒魔術)(の提案)

Posted at

概要

  • Promiseは便利だが、うまくモデリングしないと(時にうまくモデリングされていても)、深いネストとアロー関数の海におぼれてしまうことがある
  • コンストラクタのresolveとrejectへの参照を保管しておくと、深いネストから解放されることがあるが結構書きづらく読みづらい
  • resolveとrejectを抜き出すためのモジュールを導入すると、多少書きやすくなる(この記事での提案部分)
    • 上手く使えばすっきりかけるが、多用が必要な時はたぶんモデリングを見直したほうがよい
  • そもそももっといい方法があるぞ、ちゃんとしたデザインパターンがあるぞ、ということをご存知の方は教えて下さい

Promiseでネストが深くなる例

非同期で外部やユーザのアクションを待つときに、Promiseを受け取ることでawait(やthen)で同期っぽいコードですっきりと利用側のコードを書ける場合がある。(以下のようなイメージ)

使う側.ts
this.ued = new UserEditorDialog
const data = await this.ued.show() // awaitでued.showが終わるのを待つ
// data を使った処理 

しかしながら、(別のイベントの発火を受けてPromiseの値を返せる場合などは)Promiseのコンストラクタが複雑になって、ネストの深いアロー関数群が生成され嬉しくない場合がある。

ネストが深い.ts
class UserEditorDialog {
    dialog: HTMLDivElement
    okButton: HTMLButtonElement
    ngButton: HTMLButtonElement
    textArea: HTMLTextAreaElement

    show() {
        return new Promise((resolve, reject)=>{
            this.dialog.hidden = false
            this.okButton.onclick = () => {
                this.dialog.hidden = true
                resolve(new Data(this.textArea.value))
            }
            this.ngButton.onclick = () => {
                this.dialog.hidden = true
                reject("Ng button clicked")
            } 
        })
    }
}

ネストを解消する方法

深くなってしまったネストを解消ための手順として、resolveとrejectへの参照を保持しておくことで後からPromiseの値を解決することができる。

ネストの解消.ts
class UserEditorDialog {
    dialog: HTMLDivElement
    okButton: HTMLButtonElement
    ngButton: HTMLButtonElement
    textArea: HTMLTextAreaElement

    private _resolve: (value?: Data) => void
    private _reject: (reason?: any) => void

    show() {
        const promise =  new Promise((resolve, reject)=>{
            this._resolve = resolve
            this._reject = reject
        })
        this.dialog.hidden = false
        this.okButton.onclick = this.onOkClicked
        this.ngButton.onclick = this.onNgClicked
        return promise
    }
    onOkClicked() {
        this.dialog.hidden = true
        this._resolve(new Data(this.textArea.value))
    }
    onNgClicked() {
        this.dialog.hidden = true
        this._reject("Ng button clicked")
    }
}

ただしこの方法のデメリットとして

  • resolveとrejectが分かれていて、ぱっと見だいぶわかりづらい
  • resolve/rejectし忘れて、永遠に解決されないPromiseが生じる(コンストラクタでも一緒だけど、忘れやすくなる気がする)

といった問題がある

(提案部分)いい感じのモジュールでラップすると少し良くかける

そこでresolveとrejectをまとめて扱えるように以下のようなモジュールを導入することを提案したい

promiseof.ts
export class PromiseOf<T> {
    promise: Promise<T>
    private _resolve: (value?: T | PromiseLike<T>) => void
    private _reject: (reason?: any) => void
    constructor() {
        this.promise = new Promise<T>((resolve, reject) => {
            this._resolve = resolve
            this._reject = reject
        })
    }

    resolve(value?: T | PromiseLike<T>){
        this._resolve(value)
    }

    reject(reason?: any){  
        this._reject(reason)
    }
}

export function getPromiseOf<T>(p:PromiseOf<T>): Promise<T> {
    return p.promise 
}

すると以下のように大分見通しよく書くことができる。

見やすい!.ts
class UserEditorDialog {
    completed: PromiseOf<Data> // PromiseOf<返り値>オブジェクトを追加

    dialog: HTMLDivElement
    okButton: HTMLButtonElement
    ngButton: HTMLButtonElement
    textArea: HTMLTextAreaElement
    show() {
        this.dialog.hidden = false
        this.completed = new PromiseOf<Data>()
        this.okButton.onclick = this.onOkClicked
        this.ngButton.onclick = this.onNgClicked
    }
    onOkClicked() {
        this.dialog.hidden = true
        this.completed.resolve(new Data(this.textArea.value)) // PromiseOf オブジェクトをresolveする
    }
    onNgClicked() {
        this.dialog.hidden = true
        this.completed.reject("Ng button clicked") // PromiseOf オブジェクトをrejectする
    }
}

使う側は直接Promiseを受け取らないので以下のような感じになる

使い方.ts
this.ued = new UserEditorDialog
this.ued.show()
const data = await getPromiseOf(this.ued.completed)
// dataを使った処理

メリットとして

  • 別のイベントを挟んでもネストとアロー関数が消えて見通しがよい
  • 「AがBを呼び出してPromiseを貰って、AがCにPromiseを渡し、CがPromiseが解決した後の値を使う」みたいな(実はいらない)A-C間の依存があった場合に、「AがBを呼び出し、CはBの持ってるPromiseを参照して解決した後の値を使う」といった形にでき、A-C間の結合を切ることができる(シーケンス図がきれいになるイメージ/アクターモデルっぽいイメージ)

また、やや悪用すると、AOPみたいな機能の突き刺しを作りやすくなり、例えばカウンタやロギングとかを仕込みやすくなる。例えば下記の例だと、ダイアログの表示時とユーザの保存時にそれぞれdialogDisplayed/saveCompletedが解決され、イベントハンドラのように外側から用いることができる(このControllerクラス自体は振る舞いを持たず、外から勝手にPromiseを参照して、振る舞いを追加することができる)

API呼び出しの例.ts
class Controller {
    dialogDisplayed = new PromiseOf<void>()
    saveCompleted = new PromiseOf<void>()

    // GET /edit/:id
    async edit(id: number) {
        try {
            const userEditor: UserEditor = new UserEditor
            const user: User = await userRepository.getUserById(id)
            userEditor.showDialogue(user)
            this.dialogDisplayed.resolve()
            const newUser = await getPromiseOf(userEditor.completed)
            await userRepository.save(newUser)
            this.saveCompleted.resolve()
        } catch (reason) {
            this.dialogDisplayed.reject(reason)
            this.saveCompleted.reject(reason)
        }
    }
}

上手く使えば、コードをきれいに整理でき、だいぶ見やすくなる(気がする)。
ただしトレーサビリティはそんなに高くないので多用すべきでなく、多用したくなったらモデリングを見直したほうがよい(気がする)

ご意見をください

イベントをまたいだPromiseの解決や、複数のパイプラインをまたぐデータ変換において、ネストやアロー関数地獄に陥ったので、こんなのあれば便利そうだぞと思って作りました。そして自分で使って実際に便利だと感じています。
が、「そもそもこんなものを導入しなくてもこう書けばいいんだぞ/こう書くのがセオリーだぞ」みたいなことをご存知の方がいたら是非教えてください。

3
4
2

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
3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?