ブックマークレットを使ってDeferredを使う方法です。普通のjQueryを使った解法なのでブックマークレット特有のことは出てきません。
困ったこと
いろんなAPIを叩くブックマークレットを作って調子に乗ってAPIを叩いていたら、API側が高負荷な状態になってしまった。HTTP status code 429とか怖い。
今までの問題点
jqueryのajaxが非同期処理をちゃんと理解してなかったこと。
Javascriptにsleepなどの簡単な高負荷対策への仕組みがなかったのでさぼってたこと。
ブックマークレット側でAPI側へのやさしさが足りなかったこと。
ということで、非同期処理を乱発しないようにしてみます。
使ったもの
jQuery3
非同期処理を乱発させないようにするには
\$.ajaxは処理結果を待たずにメインのコードの制御が戻ってきてしまうので、何も考慮をしないとメインのコードから\$.ajaxを乱発してしまいます。\$.ajaxを同期処理し、メインのコードにsleepなど入れられればいいのですが、そうもいきません。
jQueryのDeferred(Promise)を使い、暴れん坊の$.ajaxを制御しつつsleepっぽいものを組み込みAPIの呼び出し間隔を調整できるようにして、ブックマークレットに優しさを組み込みます。
コールバック関数に引数がいらない場合
実際に処理をする部分(今回の例だとother_api)が引数を受け取らないような設計できるときは、deferredのthenメソッドにコールバック関数名を直接書くだけでよいので、コードは比較的シンプルにできます。other_apiを連続でコールするのを避けるために、wait関数とother_api関数の両方をthenでつないであげます。
例えば、ブックマークレット実行時に画面の値を取り出し、その値を元に外部のAPIを呼び出し、コールバック内で画面を書き換えてしまう用途の場合、jQueryのセレクタを使えば画面のHTMLに直接結果を書き込みすればよいです。
コード
void((function(f){
if(window.jQuery && jQuery().jquery > '3.2') {
f(jQuery);
}else{
var script = document.createElement('script');
script.src = '//code.jquery.com/jquery-3.2.1.min.js';
script.onload = function(){
var $ = jQuery.noConflict(true);
f($);
};
document.body.appendChild(script);
}
})(
function($, undefined){
var interval=2;
var max_times = 3;
var data = ['a','b','c','d','e','f','g'];
var answer = [];
var root = $.Deferred();
var deferred = root.then(wait);
//呼び出す関数が引数がない場合。コレクションをループしているけど、何も渡してはいない
for( i = 0; i < data.length && i < max_times ; i++){
console.log('counter is %s', i);
deferred= deferred.then(wait).then(other_api);
}
deferred.then(function(){
//全部終わってからやる処理はここ
console.log('final answer is');
console.log(answer);
});
//ここでは何も得られない
console.log('answer is');
console.log(answer);
//ここで起動
root.resolve();
console.log('--end function--');
//ただ止めるだけの関数
function wait() {
var df = new $.Deferred;
console.log('wait() :setTimeout with %s', interval);
setTimeout(function (){
df.resolve();
console.log('wait() :resolve after %s second', interval);
}, interval * 1000);
return df.promise();
}
//高負荷にさせるのを避けたい処理
//引数はないので、関数外の変数を使って作業する。
function other_api(){
var df = new $.Deferred;
mydata = data.shift();
console.log('[%s] api called', mydata);
setTimeout(function (){
console.log(' api():resolve');
console.log('[%s] finish api', mydata);
//仕事の結果は関数外の変数へ格納
answer.push( mydata.toUpperCase() );
df.resolve();
}, 500);
console.log("[%s] return api method", mydata);
return df.promise();
}
}
))
実行結果
(1) コード上の //ここでは何も得られない
と書いてある部分。非同期なプログラムでなければforが回り終わったら値が得られそうな部分に値が入ってこないです。
(2) deferred= deferred.then(wait).then(other_api);
が時間差で動き始めた部分です。waitをしたあとにapiをコールしているので、wait->apiの順で出力されます。apiはdata配列の1つ目の小文字のaを使えています。今回はmax_times
を3で設定しているので、配列が多くても3回で終わります。同じものが3回繰り返されています。
(3) //全部終わってからやる処理はここ
の部分で出力しているanswer
配列の中身です。A,B,Cの順で入ってます。
関数外の変数であるdataからshiftしているところと、answerにpushしているところを見逃せば、目的は果たせるプログラムです。ブックマークレットですから十分だとは思います。
コールバックに引数を渡したい場合
でも、コールバック関数に引数を渡してきれいに書きたいんだ、というケースもあるかもしれません。これは理解するまで時間がかかりました。1か月後の自分に向けて、ダメだった思考を順を追って書いておきます。
まず、thenとdoneのほかに\$.whenというjQueryのメソッドがあることを知ります。then, doneは関数自体を引数に求めるのに、\$.whenはdeferredを求めます。この違いが最初のドはまりポイントでした。
最終的に正しく動作したコード
コード
void((function(f){
if(window.jQuery && jQuery().jquery > '3.2') {
f(jQuery);
}else{
var script = document.createElement('script');
script.src = '//code.jquery.com/jquery-3.2.1.min.js';
script.onload = function(){
var $ = jQuery.noConflict(true);
f($);
};
document.body.appendChild(script);
}
})(
function($, undefined){
var interval=2;
var max_times = 3;
var data = ['a','b','c','d','e','f','g'];
var answer = [];
var root = $.Deferred();
var deferred = root.then(function(){wait2(interval)});
//呼び出す関数が引数を持つ場合
for( i = 0; i < data.length && i < max_times ; i++){
console.log('counter is %s', i);
deferred = deferred
.then(
function(counter){
return function(){
return $.when( other_api2(data[counter]), wait2(interval) );}}(i)
)
.done(function(a,b){
//apiとwaitが終わったら答えが受け取れる部分
console.log('return value is %s,%s',a,b);
answer.push(a);
});
}
deferred.then(function(){
//全部終わったら実行する処理
console.log('final answer is');
console.log(answer);
});
console.log('answer is');
console.log(answer);
root.resolve();
console.log('--end function--');
//ただ止めるだけの関数(引数で指定された秒数止めるように変更)
function wait2(second) {
var df = new $.Deferred;
console.log('wait() :setTimeout with %s', second);
setTimeout(function (){
df.resolve();
console.log('wait() :resolve after %s second', second);
}, second * 1000);
return df.promise();
}
//高負荷にさせるのを避けたい処理
//必要なものは関数内に閉じる。引数を受け取って、結果はresoleで返す
function other_api2(mydata){
var df = new $.Deferred;
console.log('[%s] api called', mydata);
setTimeout(function (){
console.log(' api():resolve');
console.log('[%s] finish api', mydata);
//resolveに答えを渡す
df.resolve(mydata.toUpperCase());
}, 500);
console.log("[%s] return api method", mydata);
return df.promise();
}
}
))
引数をとる場合からの変更点
1. wait2は、呼び出し時に止まる秒数を指定できるように変更しています。resolveするときには何も返していないです。
2. other_api2は、引数として1つの値を受け取ります。(0.5秒くらい考えてから)受け取った値を大文字に変更する処理をしています。最初の例と違うのは、関数の外側にあるanswer配列を直接触らず、resolveで返したい値を渡しているところです。
3. resolveで値を渡すようにしたので、doneでanswer配列に入れるようにしました。
4. thenのところは大幅チェンジです。
then周辺は複雑なので、未来の自分ためにも解説しておきます。
NG集 No3で直接カウンター変数を渡してもダメだったわけなので、もう一つ無名関数で囲み、引数iを渡して即時実行しています。
deferred = deferred
.then(
function(counter){
return function(){
return $.when( other_api2(data[counter]), wait2(interval) );
}
}(i)
)
2行目から始まるthen
から見ると、3行目から始まる無名関数にiを与えて、何らかの関数が返ってくることを期待しています。
3行目から始まる無名関数function(counter)
は、引数を受け取ってreturn function()
をしているので、何かしらの関数を返しています。ここは、呼び出し元のthen
が期待するように、実行したらdeferredを戻す関数を返さなければダメです。
一番内側の1文は$.when()
を使って、deferredを取り出したら、そのまま外に受け流します。NG例3と違うのは、$.when
するときに変数i
ではなくて、さらに内側に定義した仮引数counter
`を使って、other_api2に渡しています。
実行結果
(1)の時点では、answer
配列は空っぽの状態でメインのプログラムは終了します。
(2)の枠で囲んだ部分が非同期の1つの塊です。then, when, doneが連携している部分です。緑はAPI、オレンジはwait、青いラインはdoneのログです。青が出るまで次の処理(この場合はBの呼び出し)に行かないようになっています。
(3)で一番最後のanswer
配列を確認しています。abcの順番で呼び出され正しい結果が入っています。
whenとthen().then()
whenで2つの関数を囲みましたが、コールバック関数には引数を渡したいけどコールバックの返りをdoneで受けないでよい場合にはthen().then()でもよいです。waitが何かを戻すことはないので、then(wait用).then(api用).done(apiの結果を処理する用)でよかったかも。
この2つの違いは、例えばAPIは0.5秒で終わる処理でwaitを1秒の場合、
then().then()の場合には、APIの0.5秒、そのあとwait1秒なので、1.5秒で次の処理へ
whenの場合は、APIとwaitがほぼ同時に動きはじめ両方が終わったら次へ行けるので、1秒で次の処理へ行きます
APIが2秒かかってしまった場合、then().then()は3秒、whenは2秒で次の処理にいきます。
deferred = deferred
.then(
function(){return wait2(interval);}
).then(
function(counter){
return function(){
return other_api2(data[counter]);
}
}(i)
).done(function(a,b,c){
//apiとwaitが終わったら答えが受け取れる部分
//aにしか値が入ってこない
console.log('return value is %s,%s',a,b);
answer.push(a);
});
で、実際のコードはどうなるか?
以上のサンプルでは自分で作った2つの非同期APIを呼ぶようにしましたが、片方の非同期メソッドは\$.ajaxになることが多いです。\$.ajax自体がdeferredを返すので、実際にはこのような作りにすることもできます。
function other_api2(){
return $.ajax({
type: "get",
url: url
})
.done(function (response, textStatus, jqXHR) {
//省略
})
.fail(function (jqXHR, textStatus, errorThrown) {
//省略
})
.always(function (data_or_jqXHR, textStatus, jqXHR_or_errorThrown) {
//省略
});
}
こうした場合には、もしAPIのコールがfailする(404系もfail扱い)と呼び出し元にはrejectステータスが伝わってしまい、処理の全体が停止してしまいます。それでは都合が悪いときは、このように書いたほうがよいかもしれません。
function other_api2(){
//非同期メソッド内で新しいdeferredを作る
dfd = new $.Deferred;
$.ajax({
type: "get",
url: url
})
.done(function (response, textStatus, jqXHR) {
//非同期成功でresolve返す
dfd.resolve();
})
.fail(function (jqXHR, textStatus, errorThrown) {
//非同期失敗でもresolveしたい
dfd.resolve();
})
.always(function (data_or_jqXHR, textStatus, jqXHR_or_errorThrown) {
//省略
});
//$.ajaxのほうではなく自作のほうを返す
return dfd.promise();
}
まとめ
もっと簡単かと思いましたが、なかなか手ごわかったですね。特にNG例のNo3がまったく理解できず悩み抜きました。他の方の記事を見ると2013年くらいからの仕組みっぽいので、何をいまさらの感じではありますね。
参考資料にした資料
jQuery Deferredまとめ
爆速でわかるjQuery.Deferred超入門
jQuery.Deferredをforループで回したい
[JavaScript] jQuery.Deferredで登録時に引数を持たせたい
▼この先は非同期プログラムのNG集 ▼
deferredを単純に考えるとハマります。自分が陥ったNGな思考を順に紹介します。
forの部分のコードの断片のみ
NGの例 その1
thenを呼ぶときに関数に引数渡せばいいんでしょ?のパターンです。
コード
//呼び出す関数が引数を持つ場合
for( i = 0; i < data.length && i < max_times ; i++){
console.log('counter is %s', i);
deferred = deferred
.then( wait2(i) )
.then( other_api2(data[i]))
.done(function(a,b,c){console.log('%s, %s, %s', a,b,c);});
}
実行結果
まったく止まる気配がないですね、APIを連続で呼び出しすぎです。end functionする前に全部呼び出し終わりました。しかもdoneしない。目的はゆっくりやさしくAPIを呼びだしたいわけなので、これではだめです。
原因
thenには関数を渡さないといけないのに、関数にカッコを付けて引数を書いたがために、その場で関数が実行されてしまい、結果としてwait2とother_apiの実行結果であるdeferredがthenに渡っている状態です。thenを呼び出すためにapiを呼び出してしまっているため、ゆっくり制御とはいきませんでした。
NGの例 その2
thenには、deferredを戻す関数を渡す必要がある。\$.whenはdeferredを返すんだよな、、ならwhen越しに呼べばいいんでしょ、のパターン
コード
//呼び出す関数が引数を持つ場合
for( i = 0; i < data.length && i < max_times ; i++){
console.log('counter is %s', i);
deferred = deferred
.then( $.when(wait2(i), other_api2(data[i])))
.done(function(a,b,c){console.log('done(%s, %s, %s)', a,b,c);});
}
deferred.then(function(){
//全部終わったら実行する処理
console.log('final answer is');
console.log(answer);
});
実行結果
これも全くダメでした。1つ前と変わらないですね。
原因
2つの非同期な関数、wait2, other_api2がdeferred(promise)を返すのをwhenで束ねたところで、thenに渡っているのはwhen実行後の2つのdeferredだからですね。目的の関数は実行済みになってしまいます。
NGな例 その3
thenにwhen関数を直接渡したらダメなのか。thenが欲しがっているのは関数か、、それならwhenを実行してdeferredを戻すだけの無名関数を登録したろ、の例。実際にthenするタイミングまで関数の実行が遅らせられるはずで、APIが乱発されることは防げるはずだ。
コード
//呼び出す関数が引数を持つ場合
for( i = 0; i < data.length && i < max_times ; i++){
console.log('counter is %s', i);
deferred = deferred
.then( function(){
console.log('call callbackmetod by then.');
return $.when( wait2(1), other_api2(data[i])) })
.done(
function(a,b,c){console.log('done(%s, %s, %s)', a,b,c);});
}
deferred.then(function(){
//全部終わったら実行する処理
console.log('final answer is');
console.log(answer);
});
実行結果
おー、いい感じにAPIの呼び出しが順番に制御できたっぽい。さらに、今までと明らかに違うのはdoneのコールバックに何かしら値が返ってくるようになったことです。doneの第2引数のbには、whenで指定した2番目の値が戻ってくるようだ。なるほど。
ただ、順番制御はできたっぽいんだけど、、、APIに渡ってる値が全部同じになってます。a,b,cを順番に渡さないといけないのにdで3回実行している。なんでや。
原因
無名関数を登録はしたけど、その関数が実行されるのはdoneしてthenするタイミングなので、ループが回りきって、i=4の状態で呼ばれてしまっているわけですね。惜しいけど、これじゃダメですね。