Help us understand the problem. What is going on with this article?

JavaScriptのPromise

More than 3 years have passed since last update.

JavaScriptのPromiseについて。

同期処理 / 非同期処理

同期処理

JSはシングルスレッドで、ひとつ処理が終了したら次の処理を実行、という形で、通常は同期処理がおこなわれる。

var i = 0;

function doSomething1() {
  console.log(i);
}

function doSomething2() {
  i += 1;
  console.log(i);
}

doSomething1();  // 0
doSomething2();  // 1

同期処理では、1つの処理が詰まると次の処理が行われない。
(例: APIのレスポンス待機, 複雑な計算 etc.)

そのため、非同期処理ももちろん用意されている。

非同期処理

1つの処理の終了を持たないまま別の処理に進める方法。
例えば、setTimeout, addEventListener, jQueryの$.ajaxなどが挙げられる。

function doSomething1() {
  console.log("hoge");
}

function getUrl(url) {
  $.ajax({
    url: url
  }).done(function() {
    console.log("success");
  }).fail(function() {
    console.error("error");
  });
}

getUrl("https://example.com");  // 非同期
doSomething1();  // 同期

従来の非同期処理の課題

例えば、API1にリクエストしてアイテムの一覧を取得した後、API2で先頭のアイテムの詳細情報を取得、という処理を書くとする。関数にコールバックを渡す形式にする。

function getFirstItem(callback) {
  getUrl("/items", function(err, items) {
    if (err) {
      return callback(err);
    }

    getUrl("/items/" + items[0].id, function(err, detail) {
      if (err) {
        return callback(err);
      }
      callback(null, detail);
    })
  });
}

getFirstItem(function(err, item) {
  if (err) {
    console.error(e);
    return
  }
  successProcess(item);
});

また、1番目のアイテムを取得した後に2番目も取得しようとすると、例えば以下のようになる。

function getSecondItem(callback) {
  getUrl("/items", function(err, items) {
    if (err) {
      return callback(err);
    }

    getUrl("/items/" + items[0].id, function(err, detail1) {
      if (err) {
        return callback(err);
      }
      callback(null, detail1);

      getUrl("/items/" + items[1].id, function(err, detail2) {
        if (err) {
          return callback(err); 
        }

        callback(null, detail2);
      }
    })
  });
}

さらに、

  • 他の全てのアイテムの詳細を逐次取得していく直列処理
  • 各アイテムの取得のリクエストを同時に行い、全てレスポンスが返ってきてから描画、といった並列処理

などを行うとなったらどうするか?なかなか辛そう。

このように、従来の非同期処理には、

  • 処理が多段階になると、コールバックのネストがどんどん深くなり、書きづらい / 読みづらい
  • コールバックの設定の仕方も様々
    • 非同期処理の関数にコールバック関数を引数として渡す
    • 非同期処理を行うオブジェクトにコールバック関数を登録
    • etc.

といった課題があった。そこで登場したのがPromise。

Promise

Promiseを使うと、上記の処理は以下の様な感じになって短く書けるようになる。

function getFirstItem() {
  return getUrl("/items").then((items) => {
    return getUrl("/items/" + items[0].id);
  });
}

getFirstItem().then((item) => {
  successProcess(item);
}).catch((e) => {
  console.error(e);
});

Promiseの使い方

  • Promiseオブジェクトを、Promiseコンストラクタで生成
  • コンストラクタの引数に、resolverejectという2つの関数を渡す
const promise = new Promise((resolve, reject) => {
  if ('処理が成功') {
    resolve(1);
  } else {
    reject(new Error('error'));
  }
});

こうして生成したPromiseオブジェクトは、thenとセットで使う。

promise.then((value) => {
  console.log(value);  // 1
}, (error) => {
  console.error("error:", error.message);
});

Promiseの使い方の基本は以下。

  • resolve()を呼ぶと、「処理が成功した状態 (fullfilled)」になる
    • 引数に値を受け取れ、thenのコールバックに渡せる
  • reject()を呼ぶと、「処理が失敗した状態 (rejected)」になる
    • resolveと同様に引数に値を渡せる
    • Errorオブジェクトを渡すのが通例
  • Promiseオブジェクトはthen()メソッドを持ち、処理が成功 or 失敗した際の処理をコールバックとして渡せる
    • 処理が成功だと第1引数の関数を実行
    • 失敗だと第2引数の関数を実行 ※省略可
  • then()が返すのもPromise

Promiseの状態

上述したとおり、Promiseには処理に応じた「状態」があり、次の3つのうちの1つになる

  • pending: 初期状態、まだ処理が成功も失敗もしていない
  • fullfilled: 処理が成功し、完了した状態
  • rejected: 処理が失敗した状態

基本的に、Promiseオブジェクトはpendingの状態で生成され、関連付けた処理の実行結果に応じて、fullfilled あるいは rejectedの状態に変化する。この状態の変更は一方通行で、pendingに戻ることはない。

そして、fullfilled もしくは rejected への状態変更が起こった時に実行する関数を登録できるのがthen()

使ってみる例

指定時間後に何らかの処理を実行する関数waitを実装する。

wait.js
function wait(time) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(`wait: ${time}`);
    }, time);
  });
}

wait(3000).then((value) => {
  console.log(value);
  return `finish ${value}`;
}).then((value) => {
  console.log(value);
}).then((value) => {
  console.log(value);
});

setTimeoutという非同期アクションの実行結果に応じた処理が行われる(上記は処理成功が前提になっているが)。waitは未来のある時点でPromiseを返す。Promiseを利用することで、同期処理と同じように値を返せるようになった。

コードを実行すると、約3,000ms後に"wait: 3000"が出力され、そのあとすぐに"finish wait: 3000"と"undefined"が出力される。

$ node -v
v6.3.1

$ node wait.js
wait: 3000
finish wait: 3000
undefined

thenのメソッドチェーン

上記の例では、3つのthen()を繋げている。先述したように、then()が返すのもPromiseオブジェクトであるためこのようなことが可能、何個でもチェーンできる。

  • thenの中でreturn <値>することは、resolve(<値>)と等しい
  • 何もreturnしないときは、resolve()と同じ

このようなルールがある。

catch

アイテムの一覧 => 詳細を取得する非同期処理をもう一度。

function getUrl(url) {
  return new Promise((resolve, reject) => {
    let xhr = XMLHttpRequest();
    xhr.open("GET", url);
    xhr.onload = () => {
      if (xhr.status === 200) {
        resolve(xhr.response);
      } else {
        reject(new Error(xhr.statusText));
      }
    };
    xhr.onerror = () => {
      reject(new Error(xhr.statusText));
    };
    xhr.send();
  });
}

function getFirstItem() {
  return getUrl("/items").then((items) => {
    return getUrl("/items/" + items[0].id);
  });
}

getFirstItem().then((item) => {
  successProcess(item);
}).catch((e) => {
  console.error(e);
});

getUrl()でPromiseを返し、通信の成功/失敗で処理を分岐させる。
getFirstItem()で、2つのAPIエンドポイントに順次リクエストし、最後まで処理が完了したらsuccessProcess()、失敗したらエラーを出力。

ここでcatch((e) => {})を利用しているが、これはthen(null, (e) => {});と同義。
then()のチェーンの最後に付けておけば、一連の処理中にreject()で明示的に処理失敗を宣言した場合だけでなく、予想外のエラーも拾ってくれる。

これまで書いてきたが、Promiseが統一的なインターフェースを提供してくれるおかげで、コードの見通しがだいぶ良くなる。ただし、これだけで終わりではなく、非同期処理の並列実行/直列実行も簡単に実装できるようになる。

並列処理

APIを多数コールし、全てのレスポンスが返った後に処理を行う、という場合はPromise.all()を使うと楽に書ける。

parallel.js
function task(value, ms) {
  console.log(`${value} called`);

  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(value);
      console.log(`${value} fullfilled`);
    }, ms);
  });
}
function taskA() { return task("A", 3000); }
function taskB() { return task("B", 2000); }
function taskC() { return task("C", 1000); }

Promise.all([taskA(), taskB(), taskC()]).then((values) => {
  console.log(values);
  console.info('all fullfilled');
}).catch((error) => {
  console.error(error);
});

コードを実行すると、以下の出力に。

$ node parallel.js
A called
B called
C called
C fullfilled
B fullfilled
A fullfilled
[ 'A', 'B', 'C' ]
all fullfilled

taskA, B, Cの処理は配列のインデックス順にほぼ同時に開始され、task()の最初のconsole.logが実行される。その後処理はsetTimeoutに到達し、コールバック実行までの時間が最も短いtaskCから順にresolve()され、それと同時にログに出力が行われる。つまり、コードの実行から約1,000ms後にC、2,000ms後にB, 3,000ms後にAのPromiseがfullfilledになる。

Promise.all()

  • all()の引数の配列内のPromiseの状態が全て fullfilled になったら、all()自体も fullfilled
  • 1つでも rejected になると、all()も rejected

というルールになっており、上記の例ではいずれの処理も成功しているため、all()が返すPromiseも fullfilled になる。結果、taskAが終了した約3,000ms後に、catch()ではなくthen()の引数のコールバック関数が実行される。コールバック関数の引数(values)には、並列処理したそれぞれのPromiseがresolve()で返した値が配列として入っているのが分かる。

all()は全てのPromiseを一斉に実行し、全てが fullfilled になるのを待ってから処理を行ってくれるため、冒頭に書いたような「APIを多数コールし、全てのレスポンスが返った後に処理を行う」ケースなどで非常に有用である。

※1つでも rejected になれば、all()もすぐ rejected となり、catch()側の処理に入る

直列処理

非同期処理が成功したら次の非同期処理...といった直列 (逐次) 実行はどうするか。

then()をチェーンさせる

taskA()
  .then(taskB)
  .then(taskC)
  .catch(...);

数が増えると少しめんどいし、見難くなってくる。

Array.prototype.reduce()を使う

調べるとよく出てくるやり方。

const tasks = [taskA, taskB, taskC].reduce((promise, task) => (
  promise.then(task)
), Promise.resolve());

tasks.catch(...);

reduce()を使ってチェーンを繋げていく方式。
チェーンの先頭になるPromise.resolve()はStaticメソッドで、コンストラクタを利用せずにPromiseオブジェクトを生成でき、その状態は初めから fullfilled である。(同様にPromise.reject()も存在する)

これでも良さげだが、さらに良いやり方があるらしい。Generatorというのを利用する。

Generatorを使った直列処理

Generatorとは

  • こちらもES2015で導入された概念
  • 関数の任意の場所で処理を中断/再開できる仕組みを提供
  • 非同期処理を同期処理のように書けるようになる

ここはまだちょっと理解が怪しい

Generatorの利用例

Generatorを使った簡単なコードを書いてみる。
ジェネレータ関数は、functionと関数名の間に*を付けて定義する。また、ここで欠かせないのがyield

generator.js
function *generator() {
  yield 1;
  yield 2;
  return 3;
}

const g = generator();
console.log(g);
console.log(g.next());
console.log(g.next());
console.log(g.next());

コードの実行結果は以下

$ node generator.js
{}
{ value: 1, done: false }
{ value: 2, done: false }
{ value: 3, done: true }

ジェネレータ関数は実行するとイテレータを返し、nextメソッドというのを持っている。これを実行すると、generator()内の最初のyieldまで実行され、yield式の結果をvalueとして返す。またnextを実行すると次のyieldまで実行される。returnあるいは実行するyieldが無くなると、イテレータのdonetrueになる。

簡単な例で示したが、このようにGeneratorを利用することで、関数内の処理の中断・再開を行える。

非同期処理の直列処理

Generatorを利用したPromiseの直列処理も簡単に書ける。ここではcoというライブラリを利用する。

const co = require('co');

co(function *() {
  yield taskA();
  yield taskB();
  yield taskC();
});

同期処理のようにしか見えないが、それぞれのtaskがPromiseを返すまで処理中断 => 返したら再開、という挙動になり、結果として非同期処理が逐次処理される。これはすごい。。。

次の世代のECMAScriptでは、async/awaitという機能で、このGeneratorが標準搭載されるそう。

まとめ

  • 非同期処理が複雑になればなるほど、Promiseが威力を発揮する
  • 統一的な記法になるので、慣れてしまえば書きやすい・読みやすい
  • Promiseはただのクラスなので、ポリフィルライブラリを読み込むだけでも利用可能
  • Generatorは新しい概念だが、逐次処理などで有用
    • もうちょい学習が必要かな。。

参考

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした