document.writeを使った遅いブログパーツ(例えばzenback)を非同期化してサイトを高速表示する方法

広告やソーシャル系のサードパーティー製ブログパーツを張りたいんだけど、サイトの表示速度が遅くなるのって我慢なりませんよネ。こういったブログパーツの多くが JavaScript で作られています。自サーバーに設置したスクリプトならまだ手の打ちようはあるし、本サイトでも JavaScript 読み込みを高速化するための非同期化ネタを幾つか扱ってきました。例えば Google Analitics の非同期トラッキングスニペットLABjsHEAD JS などの非同期ローダーが有名なところでしょう。

ところが困ったことに、こういったサードパーティ製スクリプトに document.write が使われていると、Google Analitics のようなテクニックや非同期ローダーでは、ほとんどの場合でページが白紙となってしまい使えません。Twitter や Facebook など超有名どころは既に自社のブログパーツを非同期化していますが、まだまだ document.write が現役で幅を利かせているのではないでしょうか。

HTML (ドキュメント)、スタイルシート (見た目)、JavaScript (振る舞い) という3つの言語による分業や、セマンティックなマークアップ、DOM ベースのスクリプティングなどが主流の今日で、またページの表示速度が遅くなるとコンバージョン率を悪化させるという話は有名なのに、本当に困ったことです (タイムリーなことに、スクリプトに document.write を使うのはやめよう というエントリーが、先日 Steve Souders によってポストされました)。

ということで、今回は document.write を使ったスクリプトにも使える、古くて新しい非同期化の方法を紹介したいと思います。

2012年10月10日 修正

Friendly Iframe の具体的なサンプルコードを追記し、また記事中での一貫性を持たせるため、他のコードも若干修正しました。

2012年8月4日 修正

非同期読み込み遅延読み込み を少し厳密に区別しました。

2012年4月9日 追記

IE での動作確認が出来ましたので、記事の後半を大幅に修正しました。

document.writeが引き起こす問題との戦い

例えばサードパーティのサイト http://example.com/ から sample.js を読み込むスクリプトがあるとします。

<script src="http://example.com/sample.js"></script>

このスクリプトをページの途中に貼付た場合、そももそ http://example.com/ の反応が遅いと、次のようにページのレンダリングが途中でブロックされてしまうという問題が引き起こされます。

サイドバーのレンダリングがブロックされたページ

サイドバーのレンダリングがブロックされたページ

Mac のキー操作 「shift + command, F4, space」 で、スクリーンダンプする余裕があるぐらいですから ;-)。

sample.js を非同期に読み込めれば良いのですが、document.write が使われているスクリプトは、冒頭で述べたような非同期ローダーでは正しく機能しません。なぜなら document.write が非同期で実行されると、元のドキュメント・ストリームとは別に document.open() が実行され、新規にオープンしたページに出力されてしまうからです。

そこでこのようなスクリプトも非同期に読み込めるローダーが開発されてきました。

これらのローダーは、document.write の出力を innerHTML で元ページのどこかにリダイレクトするというものですが、innerHTML<script> タグを実行しないので、document.write によるスクリプト読み込みがネストしていると正しく働きません。zenback が正にこのパターンに当たり、ブートローダーが jQuery 1.4.2 の読み込みと同期を取るために document.write を使っています。

そこで拙作の記事 zenbackのCSSカスタマイズとハック・トライアル では、「</body> 直前の非表示にした <div> でスクリプトを読み込み、後から replaceChild() で表示されるべき位置に流し込む」 という古典的な方法を紹介しました。この方法はスクリプトの読み込み自体を非同期化するものではありませんが、もうこれ以上レンダリングする要素がない </body> 直前に置くことで、途中でレンダリングが止まってしまうということは防いではくれます。しかし、同じ HTTP リクエストが2回繰り返されることがあるという問題点を持っていました。

古くて新しい解決方法

そこで今回は、次の5つの方法の検証結果を紹介したいと思います。

  1. iframeへの動的挿入
  2. Friendly iframe
  3. javascript:URIスキーム
  4. Meebo Bar
  5. data:URIスキーム

1.iframeへの動的挿入

動的に生成した空ページの iframe<script> タグを埋め込む方式で、だいたいの骨格は次のようになります。

<!doctype html>
...
<body>
<div id="widget"></div>
...
<script>
function createIframe(id, script) {
	var iframe = document.createElement('iframe');
	document.getElementById(id).appendChild(iframe);

	var html = '';
	html += '<body>';
	html += '<script src="' + script + '"></script>';
	html += '</body>';

	var doc = iframe.contentWindow.document;
	doc.open();
	doc.write(html);
	doc.close();
}

createIframe('widget', 'http://example.com/sample.js');
</script>
</body>
</html>

通常 iframe は、親ページの onload をブロックします。つまり、スクリプトの読み込みが遅いとブラウザのビジー・インジケーターがクルクルと回り続ける事になります。コレがイヤな場合は、動的生成であることを活かし、次のようなヘルパー関数で親ページの onload 後に生成するのもアリでしょう (この場合には、非同期読み込みというより、遅延読み込みとなります)。

function lazyLoadIframe(id, script) {
	if (window.attachEvent) {
		window.attachEvent('onload', function() {
			createIframe(id, script);
		});
	} else {
		window.addEventListener('load', function() {
			createIframe(id, script);
		}, false);
	}
}

これらのスクリプトの動作状況は次のようになります。

OS ブラウザ 動作
Windows 7 IE 6 (IETester) ×
IE 7 (IETester) ×
IE 8 ×
IE 9 未検証
Firefox 11.0
Chrome 18.0
Safari 5.1.5
Opera 11 ×
Mac OS X 10.6.8 Firefox 11.0
Chrome 18.0
Opera 11 ×
iOS 5.1 Safari
Android 2.3 標準ブラウザ

これから分かる通り、IE が全滅です。この方式が古くからあるにも関わらず、これまで顧みられなかった理由でしょう。

IE は、動的に生成した空ページ (about:blank) の iframe を親ウィンドーとは異なるドメインと見なし、「同一生成元ポリシー (Same Origin Policy)」 に基づいて親子間の DOM アクセスを許可しないのだと思います。 (MSDN の この辺り にそのような記述があります)。

一方モダンブラウザも Same Origin Policy は遵守していますが、about:blank ページを同一ドメインと見なしているのでしょう。言い換えれば、IE は最もセキュリティのリスクが少ない実装をとっているということだと思います。

いずれにしても、IE で動作させるには 「同一生成元ポリシー」 の問題を解決する必要があります。

参考情報
  • 2005年3月9日 Javascript – write to an iframe
    • 動的に生成した iframe 内にコンテンツを書き出すスクリプトが紹介されています。document.open()document.close() を使うところがミソだと思います。
  • HTML DOM Frame/IFrame contentWindow Property
    • 親から子 iframe のドキュメントにアクセスする方法です。古い Opera (Opera 7) のバグを回避するスニペットを参考にしました。
2012年4月12日 追記

余談になりますが、これを適用すると YSlow のスコアがぐっと上がります。例えば 「CCV.jsで顔検出」 のページでは、iframe で読み込んだ jsdo.it のコンテンツが YSlow からはまるまる隠れてしまうようで、Grade E (score 54) が Grade B (score 83) になりました。ちょっとズルしてる感じですけどネ。

2.Friendly iframe

名前の由来は、「同一生成元ポリシー」 と 「親和性が良い」 ということでしょう。同一ドメイン内に踏み台となる HTML ファイルを用意し、呼び出し元の iframe オブジェクトに埋め込んでおいたスクリプトのファイル名 (次の例では script_src) を参照、動的に読み込む方式です。当然、HTTP リクエストを1つ余計に消費する事になりますので、踏み台の HTML ファイルには .htaccess などで適切なキャッシュポリシーを設定し、影響を最小限にとどめるのが定石でしょう。

踏み台となる fiframe.html
<!doctype html>
<html>
<head></head>
<body>
<script>
document.write('<script src="' + window.frameElement.script_src + '"></script>');
</script>
</body>
<html>
呼び出し側のスクリプト
function createIframe(id, script) {
	var iframe = document.createElement('iframe');
	iframe.script_src = script;
	iframe.src = 'fiframe.html';
	document.getElementById(id).appendChild(iframe);
}

この方式は、非同期読み込み出来ない レガシー・コード に対応する方法として、Make the Web Faster – Google Developers 中の Avoid document.write – Minimize round-trip times の最後にも紹介されています。

また、IE を含む全てのブラウザで動作します。

OS ブラウザ 動作
Windows 7 IE 6 (IETester)
IE 7 (IETester)
IE 8
IE 9 未検証
Firefox 11.0
Chrome 18.0
Safari 5.1.5
Opera 11
Mac OS X 10.6.8 Firefox 11.0
Chrome 18.0
Opera 11
iOS 5.1 Safari
Android 2.3 標準ブラウザ

ただしブラウザによっては、子フレーム内のスクリプトが親ウィンドーにアクセスする際、エラーが発生するケースがあります。例えば https://apis.google.com/ からのスクリプト (たぶん Google +1) が 「No relay set (used as window.postMessage targetOrigin), cannot send cross-domain message」 のエラーを吐くケースです。そのため広告に利用する場合は、iframe 内で使えるかどうか、スクリプトの作成元に確認すべきでしょう (例えば コレ)。

参考情報

3.javascript:URIスキーム

httpftp などの URI スキーム と同様、javascript というスキームを iframesrc に指定する方法です。ただし、ピュアな JavaScript を指定したのでは about:blank と同等と解釈されるようで、次のようなトリックが必要です。

function createIframe(id, script) {
	var iframe = document.createElement('iframe');
	document.getElementById(id).appendChild(iframe);

	var html = '';
	html += '<body>';
	html += '<script src="' + script + '"></script>';
	html += '</body>';

	iframe.contentWindow.contents = html;
	iframe.src = 'javascript:window["contents"]';
}

この方法の動作状態は次の通りです。

OS ブラウザ 動作
Windows 7 IE 6 (IETester) ×
IE 7 (IETester) ×
IE 8
IE 9 未検証
Firefox 11.0
Chrome 18.0
Safari 5.1.5
Opera 11
Mac OS X 10.6.8 Firefox 11.0
Chrome 18.0
Opera 11
iOS 5.1 Safari
Android 2.3 標準ブラウザ

は、document.write によるスクリプト読み込みがネストしていない場合には正しく機能することを意味しています。つまりこの方法が使える局面は限られているということです。

参考情報

4.Meebo Bar

Best Practices for Speeding Up Your Web Site」 の生みの親、かつ ControlJS の作者である Steve Souders は、サードパーティ製スクリプトに対する Meebo の取り組みをあちこちで絶賛しています。そのコードの骨格は次のようなものです。

(function(d){
	var iframe = d.body.appendChild(d.createElement('iframe')),
		doc = iframe.contentWindow.document;

	// style the iframe with some CSS
	iframe.style.cssText = "width: 200px; height: 100px;";

	doc.open().write('<body onload="' +
	'var d=document;d.domain='' + d.domain + '';' +
	'd.getElementsByTagName('head')[0].' +
	'appendChild(d.createElement('script')).src' +
	'='/path/to/file'">');

	doc.close(); // iframe onload event happens
})(document);

この方式のポイントは、次の3つだと思います。

  1. Google Analitics 同様、動的に生成した <script> 要素を <head> に追加しています。<script> 要素の async 属性の代わりに iframe の非同期性を利用するので、根本的にブラウザ依存がありません。
  2. <body> 自体は空なので、生成時の負荷が最小限に抑えられます。結果、親ページの onload をほとんどブロックしません。また iframe 内でも onload イベントがすぐに発生するので、スクリプトの読み込みまでのタイムラグもありません。
  3. document.domain を設定し、Same Origin の制限を緩和しています。

ただし、この方式では document.write() を使ったスクリプトを正しく実行することが出来ないため、今回は却下です。また、document.domain の使い方は参考になるとは思いますが、実際のコードはもう少し複雑で、イマイチ読み切れていません 😉 。

参考情報

5.data:URIスキーム

前述の 1. 〜 4. とは異なり、スクリプトを読み込む iframe を静的に埋め込む方式です。親ページの onload はブロックしますが、レンダリングのブロックは最小限になるハズです。Gist と jsdo.it に適用した jsfiddle のサンプル をご覧ください。

<iframe src="data:text/html,<script src='http://example.com/js/sample.js'></script>"></iframe>

ただし、IE はこのスキームを解釈できないため、この方式も却下です (迷走中)。

参考情報

まとめ

今回は上記の 1. と 2. を組み合わせ、さらにコンテンツ読み込み後に iframe の高さを自動的に調整する機構を組み込んだライブラリ iframed.js を作成してみました。また各種方式とレンダリングの様子を比較できる サンプルページ を用意ましたので、参考にしてください。

3rdパーティー製スクリプトによるブロッキングのテスト

3rdパーティー製スクリプトによるブロッキングのテスト

  • Original pattern
    • zenback の推奨するスクリプトを使ったパターンです。zenback サーバーの応答が遅いと右サイドバーが表示されるまで時間がかかります。また、onload 到着まで10秒ほどかかっています。
      オリジナル・パターンのウォーターフォール
  • Replace pattern
    • </body> 直前の非表示にした <div> 内で一旦スクリプトを読み込んだ後、表示すべき位置に置き換えています。オリジナル・パターン同様、onload まで10秒ほどを要しています。また、同じ HTTP リクエストが繰り返されていて、一方がゾンビと化しています。
      置き換えパターンのウォーターフォール
  • Iframed pattern
    • 動的に生成した iframe 内でスクリプトを読み込むパターンです。onload が1.2秒ほどで到着してます。必ずしも onload の早期到着だけが善ではありませんが (総読み込み時間や CPU 負荷は変わらない)、この場合、非同期読み込みにより親ページと切り離されるため、メインのコンテンツが早くレンダリングされる (いわゆるプログレッシブ・レンダリング) ことになります。
      インラインフレーム・パターンのウォーターフォール

おわりに

今回のお題は、ブラウザをハッキングすることと等しく、とても疲れました。解ければ車輪の再発明となること必至ですが、ブラウザ側で解決してくれることを切に願っています!

おまけ

今回、iframe の高さを自動調整するコードとして、facebook SDK_computeContentSize という関数を参考にさせてもらいました。facebook のデフォルトでは 100msec ごとにサイズ調整が走るので、サイトが重くならないようご注意を。

document.writeを使った遅いブログパーツ(例えばzenback)を非同期化してサイトを高速表示する方法」への3件のフィードバック

  1. tokkonoPapa

    連載:フロントエンドWeb戦略室|gihyo.jp … 技術評論社 が、ためになります。

    特に7月3日の記事 「フロントエンドWeb戦略室:第1回 外部サイトに貼り付けるJavaScriptの作法―ポリシー,速度,セキュリティ,プライバシー(2)」 は、大いに参考になります。

    曰く、

    ローダーは、ブラウザに長期間キャッシュされまた高速に読み込まれる事を保証した上で document.write を使用し、そこから js やコンテンツを追加で動的にロードする

    zenback のローダーは乱数を渡すように指定されているため、ブラウザにキャッシュされません。キャッシュが有効に使えればローダーをホストするサーバーの負荷も減り、サーバー/クライアントとも高速化が期待できるのに…。表示させるコンテンツは、スクロール位置による遅延読み込みのテクニックなんかを使っていますが、そもそもがチグハグという事ですネ。

    返信
  2. ピンバック: Prefer asynchronous resourcesという項目を向上させる。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

海外からの投稿は受け付けていません