hitode909の日記

以前はプログラミング日記でしたが、今は子育て日記です

はてなブログ編集画面JSのページャ見どころ紹介 #pagernight

昨日,ページャNightという勉強会で,はてなブログのJSの見どころを紹介するLTをした.(昨日の日記).
資料公開しようかと思ったのだけど,発表資料そのまま公開しても意味不明なので,エントリに書き直すことにした.
たとえば,このLGTM画像は発表資料の1枚目で,もし発表資料をそのまま公開したら,こういう謎の画像を解説もないまま見ることになっていたはず.

f:id:hitode909:20140706014252p:plain

JSのページャいっぱいある

はてなブログの編集画面には編集サイドバーというのがあって,写真とかAmazon検索とかTwitterとかinstagramとかあれこれ貼れるようになってる.
Amazon検索しても画面遷移するわけじゃなくて,ウェブ2.0という感じで,XHRでJSONを取ってきて,HTMLを組み立てて表示,クリックすると選択,貼り付けを押すとエディタに挿入される,という仕組み.
編集サイドバーから貼れるサービスは10種類くらいあって,UIがちがったり,各社のAPIごとにページングの方法はちがったりするので,それぞれページャを実装してる.下の絵はブログにInstagramを貼ってるところ.右のリストから選んで貼ると左の本文に貼れる.

f:id:hitode909:20140706014534p:plain

見どころ紹介

編集画面のページャの実装は見どころ満載なのでおすすめポイントを紹介していきます.
ちなみに,はてなブログのJSはminifyしていないので,誰でも読むことができる.minify知らないからやってないわけじゃなくて,パフォーマンスと,開発効率を検討した上でこうなっていて,経緯とかこのへんに書いてある.

前提

はてなブログでは,AngularとかBackboneとか使っていなくて,jQueryとunderscoreくらいを使ってJS書いてる.素朴にjQueryで書いてるところもあれば,ちょっとオブジェクト作ってみたり,複雑なインタラクションが発生するときには,自前でModelとViewみたいな役割のオブジェクトを作ってやりとりさせたりとか,フレームワークに依存せず,自分たちで必要なものは書いてる.
このエントリでは,ひたすらページャ自作してるけど,モダンなライブラリ使えば,1行ちょろっと書くだけでページャ完成するかもしれない.

しばらくページングして終わりというパターン

Amazon検索は,キーワードを入力して検索押すと,5ページ目まで問答無用で読んでいって,そこで終わり.1ページあたり20件返るとして,100件も出せば十分だろう,という感じ.
これは一番簡単で,ざっくり書くとこんな感じ.実際はもっと長いけど疑似コードとしてはこんな感じという意味です.本当は,エラー処理とか,読み込み中です…とか出したり,とかしないといけない.

var search = function(keyword, page) {
    $.ajax({
        url: '/amazon/search',
        data: {
            keyword: keyword,
            page: page
        }
    }).done(function(res) {
        showItems(res.items);
        // 5ページ目までは再帰的に次のページを検索
        if (page < 5) {
            search(keyword, page+1);
        }
    });
};

もっと読む押すと次が出てくるパターン

fotolife貼り付けは,下のほうにもっと読むボタンがあって,押すと続きが出てくる.クリックイベントを監視するとこういうのができる.

$('.more').on('click', function() {
    loadMore();
});

スクロールすると勝手に出てくる

下のほうにスクロールすると勝手につぎ足されるオートページャというのがあって,これすると,クリックしなくても,どんどん続きが読めるようになる.その昔AutopagerizeというFirefox拡張があって,みんな使ってた.若者はもう知らないのかな.
スクロール位置をコンテナの高さで割って,0.7越えたら続きをロードする.スクロールイベント大量に発生するので,throttleっていう技を使って,イベントを間引きつつスクロール位置を観察するとよい.たくさん呼ばれても無視して,最大0.1秒に1回実行する,というやつです.throttleはUnderscoreに入ってるし簡単なので自作しても良い.

$itemsContainer.on('scroll', _.throttle(function() {
    var rate = ($itemsContainer.scrollTop() + $itemsContainer.height() ) / $itemsContainer[0].scrollHeight;
    if (rate > 0.7 && !itemsReachedEnd) {
        requestLoadItems();
    }
}, 100);


あとは,大量に通信発生すると困るので,通信中なら何もしない,みたいな機構が必要になる.こんな感じでできる.loadItemsはDeferredを返して,それが終わったらフラグを落とす,みたいな感じ.

requestLoadItems = function() {
    // 通信中だったのであきらめる
    if (loading) return;

    // loadingをtrueにして通信して,終わったらloadingをfalseに戻す
    loading = true;
    loadItems().always(function() {
          loading = false;
    });
};

?page=2みたいなページング

?page=2, 3, 4みたいにだんだん増えると次のページが出てくるAPIがよくある.順番に取ってくるには,最後に通信したページを覚えておいて,次に読むとき1足せばよい.

var page = 0; // これがどんどん増える
var loadItems = function() {
    return $.ajax({
        url: '/some/api',
        data: {
            page: page++
        }
    });
});


簡単だけど,これは本当はよくなくて,みんな大好きMySQLでpage=1000000000とかすると,LIMIT 20 OFFSET 20000000000みたいなクエリが発行されてパフォーマンス上問題がある.TwitterのAPIなんかは,max_id=485028679609622528 みたいな,ここまで読んだので次ここからください,みたいなページングする形になっている.これだとWHERE max_id > 485028679609622528 LIMIT 20とかになって安心.普通にAPI実装するときは,特に理由がない限りはこっちで作るとよい.昔はTwitterにも?page=2とかあったけど廃止された.

max_id方式のページング

page=3とかだと,3ページ目がほしかったら,いきなり3って入れればいいけど,max_id方式だと,そうはいかない.前回の通信結果をもとに,次にリクエストするURLを決める必要がある.

var max_id; // 通信結果をもとに決める
var loadItems = function() {
    var data = { };
    if (max_id) data.max_id = max_id;
    return $.ajax({
        url: '/some/api',
        data: data
    }).done(res) {
        // 受信した最後の要素のidがmax_id
        max_id = res.items[res.items.length-1].id;
    });
});

タブで表示内容切り替えたい

タブで貼り付け内容切り替えると,さらに大変になる.Twitter貼り付けるとき,自分のツイートを貼りたいのか,お気に入りから貼りたいのか,とか.
タブ変えて戻ってきたときはキャッシュが出てほしいので,XHRの結果をキャッシュする必要がある.リロードボタンもほしいので,キャッシュをオフにする仕組みが必要.URLをキー,Response Bodyを値に持つハッシュなどを使うと簡単.

var cache = { };
var ajaxGetOrCache = function(url, is_force) {
    if (cache[url] && !is_force) {
        var d = $.Deferred();
        d.resolve(cache[url]);
        return d.promise();
    }

    return $.ajax({
        url: url
    }).done(function(res) {
        cache[url] = res;
    });
};


タブがあると困ったことがあって,通信中にユーザーがタブ切り替えたときは,結果を表示してはいけない.自分のツイートのタブにお気に入りが出ては困る.通信終わったときに現在表示中のタブを見て,変わってたら通信し直す,という処理が必要.

var loadItems = function() {
    var tab = getTab();

    ajaxGetOrCache("?tab=" + tab).done(function(res) {
        // がんばって通信したけどタブ変わってたのでやり直し
        if (tab !== getTab()) {
            loadItems();
            return;
        }

        // こっちに来たら成功
        showItems(res);
    });
};

おしゃれフレームワークなどを使ってればこんなことないかもしれないけど,jQueryとかで素朴に書いてると,こういうことも面倒見ないといけない.
このように,ページャ手作りすると様々なテクニックが必要になる.

ここで遅延リストの話

遅延リストというのはリストの要素のそれぞれが遅延評価されるリストで,無限に連結することができる.
コンピュータプログラミングの概念・技法・モデルっていう本を読むといろいろ教えてくれる.

コンピュータプログラミングの概念・技法・モデル (IT Architects' Archiveクラシックモダン・コンピューティング)

コンピュータプログラミングの概念・技法・モデル (IT Architects' Archiveクラシックモダン・コンピューティング)


C言語などでは,普通に配列で無限に続く配列っていうのは表現できなくて,何も考えずにやるとmalloc(無限)とかしてメモリなくなって失敗すると思う.
遅延評価のある言語なら気楽に無限に続く数列とかを表現できるけど,JSにはそんなものはないので,無限に続く構造を手作りする必要がある.
コンピュータプログラミングの概念・技法・モデル読んでるときに,なんか高まってしまって,遅延リスト作ってみた.遅延といえばDeferredなので,jQuery Deferredを基にしている.jquery-lazylistっていう名前で,GitHubにも上がってる.

こういうデータ構造で,双方向リストで,前後のノードへの参照と,Deferredを生成するための計算本体の関数と,生成されたDeferredを持つ.コンストラクタでgeneratorを指定して,インスタンスができたら,nextで次に行って,promise()したら計算開始する.
f:id:hitode909:20140706015108p:plain

10まで数えるカウンター

0から10まで順番に数えるカウンターを作ってみる.これは一番簡単な利用法で,prev.promise().doneすると,前のノードの値が分かるので,それに足してresolveするとよい.ちょっとずつ計算して値を返したいので,計算は再帰で書くことになる.forとかでループするとブロックしてしまうので仕方ない.
Deferredなので,resolveしたら値の計算に成功,rejectしたら失敗,というのを表せる.

var counter = new $.LazyList(function(prev) {
    var d = $.Deferred();
    if (prev) {
        prev.promise().done(function(i) {
            if (i < 10) {
                d.resolve(i+1);
            } else {
                d.reject();
            }
        });
    } else {
        d.resolve(0);
    }
    return d;
});

こうやって使う.

for (var i = 0; i <= 13; i++) {
    counter.promise().done(function(j) {
        console.log(j);
    }).fail(function() {
        console.log('failed');
    });
    counter = counter.next();
}

実行結果.ループでは13までやってるけど,10まで行ったらrejectされるので,それ以降は進まない.rejectしたら終わりというルールがあるわけではなくて,Counterの実装がpromise().done()なので,成功しないと次の値をゲットできない,という感じ.promise().always()とか書くと失敗しても進める.

0
1
2
3
4
5
6
7
8
9
10
failed

内部的にはこんなことが起きている.nextするとただLazyListのインスタンスだけが伸びていって,実際の計算はしない.promise()すると計算する.
f:id:hitode909:20140706015332g:plain

memoize付きフィボナッチ数列

カウンター作るときは,最初は0,1と来て,以後prev.prev.promiseとprev.promiseの結果を足すとフィボナッチ数列を作れる.見た目は異常だけど,無限に計算を続けることができてクール.

var fib = new $.LazyList(function(prev) {
    console.log('generate');
    var dfd = $.Deferred();
    if (prev && prev.prev()) {
        prev.prev().promise().done(function(a) {
            prev.promise().done(function(b) {
                console.log(a + ' + ' + b + ' = ' + (a+b));
                dfd.resolve(a + b);
            });
        });
    } else if (prev) {
        dfd.resolve(1);
    } else {
        dfd.resolve(0);
    }
    return dfd;
});

使ってみる.10まで進んで,root()して最初に戻って,また進める.

for (var i = 0; i <= 10; i++) with({i: i}) {
    fib.promise().done(function(v) {
        console.log('fib(' + i + ') = ' + v);
    });
    fib = fib.next();
}
// re-culculate from 0
fib = fib.root();
for (var i = 0; i <= 10; i++) with({i: i}) {
    fib.promise().done(function(v) {
        console.log('fib(' + i + ') = ' + v);
    });
    fib = fib.next();
}

1週目はgenerateって出てるけど,2週目はresolve済のDeferredにdoneしてるだけなので,generate出ない.

generate
fib(0) = 0
generate
fib(1) = 1
generate
0 + 1 = 1
fib(2) = 1
generate
1 + 1 = 2
fib(3) = 2
generate
1 + 2 = 3
fib(4) = 3
generate
2 + 3 = 5
fib(5) = 5
generate
3 + 5 = 8
fib(6) = 8
generate
5 + 8 = 13
fib(7) = 13
generate
8 + 13 = 21
fib(8) = 21
generate
13 + 21 = 34
fib(9) = 34
generate
21 + 34 = 55
fib(10) = 55
fib(0) = 0
fib(1) = 1
fib(2) = 1
fib(3) = 2
fib(4) = 3
fib(5) = 5
fib(6) = 8
fib(7) = 13
fib(8) = 21
fib(9) = 34
fib(10) = 55

遅延リストでページャを実装

遅延リストを使ってページャを実装することができる.prevがあったらdoneするとmax_idが分かるので,それをもとに通信する.

var pager = new $.LazyList(function(prev) {
    console.log('generate');
    var d = $.Deferred();
    var get = function(data) {
      $.ajax({
        url: '/some/api',
        data: data,
      }).done(function(res){
        d.resolve(res);
      }).fail(function(error) {
        d.reject(error);
      });
    };

    if (prev) {
        prev.promise().done(function(res) {
            get({max_id: max_id});
        });
    } else {
        get({});
    }

    return d;
});

計算内容がフィボナッチ数列からAPIの呼び出しに変わっただけで,使い方は変わらない.Hash作らなくてもXHRのキャッシュできて便利.キャッシュ破棄したくなったらインスタンス作り直せばよい.

// get first page
pager.resolve().done(function(page1) {
  console.log(page1);
});

// get first page(cached)
pager.resolve().done(function(page1) {
  console.log(page1);
});

5ページ目を見たかったら,next().next().ってつなげていけば書ける.最後にresolveすると,1ページ目から5ページ目まで順番に通信される.透過的でかっこいい.

pager.next().next().next().next().resolve().done(function(page5) {
  console.log(page5);
});

最適なパラダイムを選ぼう

はてなブログでも,遅延リストでページャ書いてるところあるけど,max_idを変数に持っておく方式と比べると複雑になってしまう.
ユーザー体験的には,遅延リストでリストごとキャッシュされていれば,素朴な実装よりリッチな体験ができるので,やってよかったと思うけど,再帰でかつ非同期なプログラムになるので,保守するのは難しくなる.
どんなアプリケーションでも,そのアプリケーション固有の問題を対象にしたり,固有の価値を見出したりしているはずで,二つとして同じアプリケーションはない.解決したい課題に合わせて,最適な技術とかパラダイムを決めるのが重要だと思う.
f:id:hitode909:20140706015416p:plain

広告

はてなでは,一緒にページャを作るエンジニアを募集しています.学生向きサマーインターンも月曜まで応募しているので,ここまで読んだ人いたら全員応募してほしい.

まとめ

LTと言いつつも,記事にしたら大変長くなってしまった.数分間でこんだけしゃべってるのは偉い.

リンク集

以下のエントリに他の発表の資料とかポエムとか載ってる.