livedoor Reader での記事サムネイルの表示をサーバーに優しく (+α)

最速インターフェース研究会 :: livedoor ReaderにSimpleAPIのサムネイルを加えるGreasemonkeyスクリプト に関して、何十もある記事のサムネイルを一気に取得しようとする (可能性がある) のはちょっとマズいのではと思い、改善策を考えてみました。

キューを使うのが良いんじゃないでしょうか?:

(function() {with(unsafeWindow){
  /* 自分用 (IE 用) に書いたものに with(unsafeWindow) を被せただけです。
   * Firefox では未テスト。コマンド等省略
   */
  var SimpleAPI = Class.create().extend({
    initialize: function(feed) {
      this.feed = feed;
    },

    add_thumbs: function() {
      var self = this;
      var items = this.feed.items.concat();

      if (!items.length)
        return;
      // 必要な記事を取り出す
      if (Config.reverse_mode)
        items = items.reverse();
      if (items.length > Config.max_view)
        items = items.slice(0, Config.max_view);

      var q = new Queue;

      items.forEach(function(item) {
        // if (item_exclude.test(item.link)) return;
        q.push(function() {
          self.add_thumb(item);
        });
      });

      // 3 秒間隔でサムネイルを取得
      q.interval = 3000;
      q.exec();
    },

    add_thumb: function(item) {
      var body = $("item_"+item.id);
      if (!body) return;

      with (body.style) {
        backgroundImage = [
          "url(http://img.simpleapi.net/small/",
          item.link.replace(/#.*$/, ""),
          ")"
        ].join("");
        backgroundPosition = "100% 0%";
        backgroundRepeat = "no-repeat";
      }
    }
  });

  register_hook("before_printfeed", function(feed) {
    new SimpleAPI(feed).add_thumbs();
  });
}})();

Queue クラスは、"o" キーでピンを開く時に少し時間差がありますよね? あの場面で使われているものです。また、複数のフィードを一括登録するコマンド :sub (キーワード参照) でも利用しています。

これにより、大量のリクエストであっても緩やかに行うことが可能になると思います。

画像の貼り付け方法をオリジナルと変えてあるんですが、これは IE だと表示がうまくいかない問題*1が生じたためです。回避方法が分からなかったため、背景画像としてみました。

こんな表示になります:
simpleapi


さて、実はここからが本題です。

上記拡張を書く際に気付いたんですが、「feed を受け取って表示に必要な items (記事の配列) を取り出す」という処理が 『livedoor Readerをはてブと合体』on IE と共通しているんですね。この重複を是非とも解消したい、というのが以下の試みです。

コールバック関数の利用と問題

まず、共通部分を括り出してみました:

function (feed) {
  var items = feed.items.concat();
  if (!items.length)
    return;

  if (Config.reverse_mode)
    items = items.reverse();
  if (items.length > Config.max_view)
    items = items.slice(0, Config.max_view);

  ...
}

ここからどうするかなんですが、まず思いついたのは、元々クラスとして実装した機能を関数化するというアプローチです: それらを、上の関数にコールバックとして与えておくと、"..." の部分で

  items.forEach(function(item) {
    // callbacks はコールバック関数の配列
    callbacks.forEach(function(f) {
      f(item);
    });
  });

  /* あるいはこんな書き方も不可能ではありません (多分)
  items.forEach(function(item) {
    callbacks.asCallback(item)();
  });
   */

という具合に、各コールバックに記事を渡していくことができるわけです。

この仕組みであれば、新機能を追加したい場合、原則的にコールバック関数だけを追加すれば良いわけですから、拡張性の高い、理想的なアプローチではないかと思いました。

ところが、幾つか問題が立ちはだかりました。

例えば記事毎の情報を管理したい場合などに、クラスだとインスタンス変数として保持することができますが、関数だとローカル変数として管理しなければなりません。そしてそのローカル変数は、(全体の情報を持ちたいですから) コールバック関数の一つ外のスコープに置くことになります:

var links2id = {};
var fails = {};

function bookmark_counter_as_callback(item) {
  ...
  links2id[item.link] = item.id;
  ...
}

// 他のサポート関数群 (links2id 等を利用する)
...

ここで問題と言うのは、不要になった情報は自分の手で削除する必要が出てくる、ということなんです (でないと肥大化する一方ですからね)。これは、ガーベジ・コレクタ任せにする元の実装から考えると、少々厄介なことではないでしょうか。

また、キューの例に関しても、「フィード単位」で作成・実行される元の形に比べ、コールバック式は「記事単位」ですから、キュー管理のための仕組みを別途設ける必要が生じてしまうでしょう。

クラス・ファクトリー関数の利用と効果

ということで、あっさりと切り替えることにしました。

考えてみると、「feed の受け渡し」を「items の受け渡し」へと変更するだけで、元のクラスをほぼそのまま利用できますよね。

その方向で、先に括り出した共通部分を整えてみましょう。こうなりました:

function items_feeder() {
  var Feeder = new (Class.create().extend({
    initialize: function() {
      this.classes = [];
    },

    add: function(f) {
      isFunction(f) && (f=f()).isClass && this.classes.push(f);
    },

    exec: function(feed) {
      var items = feed.items.concat();
      if (!items.length)
        return;

      if (Config.reverse_mode)
        items = items.reverse();
      if (items.length > Config.max_view)
        items = items.slice(0, Config.max_view);

      this.classes.forEach(function(klass) {
        new klass(items).exec();
      });
    }
  }));

  Array.from(arguments).forEach(function(f) {
    Feeder.add(f);
  });

  register_hook("before_printfeed", function(feed) {
    Feeder.exec(feed);
  });
}

記事を提供する Feeder というクラスを定義しています*2。クライアント・サーバーの比喩で言うとサーバーにあたります。

ではクライアントはと言うと、Feeder を包んでいる items_feeder 関数の引数として受け取る仕様になっています:

items_feeder(thumbnail_maker, hatena_bookmark_counter);

ここで渡されているものは、関数です。が、ただの関数ではありません。「クラス」を生成する関数なんです*3

例えば、最初の SimpleAPI クラスはこのような形になります:

function thumbnail_maker() {
  // var item_exclude = /mushi-url/;

  return Class.create().extend({
    initialize: function(items) {
      this.items = items;
    },

    exec: function() {
      var self = this;
      var q = new Queue;

      this.items.forEach(function(item) {
        // if (item_exclude.test(item.link)) return;
        q.push(function() {
          self.add_thumb(item);
        });
      });

      q.interval = 3000;
      q.exec();
    },

    add_thumb: function(item) {
      // 省略
    }
  });
}

戻り値としてクラスを返すことと、クラスが "exec" という名前のタスク実行メソッドを持っていることが要件となっています。

無名クラスを返していることに注意してください。外部からアクセスできないため、名前を付ける意味が無いのです。

このことから、ファクトリー関数は「機能毎に独立した名前空間が得られる」ことだけでなく、「関数が評価されるまでクラス (への参照) が存在しない」という、ちょっと変わった効果を生む手法だと言えるでしょう。


追記 [20060711]:

参考のため、ブックマーク数カウンタも含めたコード全体を貼っておきます。現在手元 (IE6) で動作しているものです。興味ある方はご覧下さい:

*1:フロート属性 (スタイル) と clear 属性が合わさるとドキュメントが部分的に表示されなくなる、等の IE 固有のバグです

*2:実際にはクラスには名前を付けずに即インスタンス化していて、シングルトン・クラスのような感じです。LDR の中でしばしば用いられている手法です

*3:クラス定義以外にも雑多な処理 (スタイルの追加など) を行いたい場合があるだろう、ということで、この方法を考えました