29
Safari/Webkitのおせっかいキャッシュとその対策。
Filed Under (article) by on 29-11-2008
どうもひろきのだいちです。
最近のモダンなブラウザには、Backfowardキャッシュと呼ばれるブラウザの戻るボタンを押した際に利用されるキャッシュ機構が用意されています。この機構は普段ウェブブラウジングを行う際には間違った操作からの復帰が早く非常に重宝するのですが、一部のWebアプリケーション設計の際にはこの仕組みが厄介に働くことがあります。
そういったケースでよく使用されるテクニックとしてはwindow unloadイベントへのハンドリングで、このページはすでに終端処理を済ませていますよ、ということを通知することで再度インラインjsや、onload後の処理を行うことができます。
-
window.onload=function(){alert('test');}
だけではページ遷移後、戻るを押してもloadEventは発火されません。
しかし、
-
window.onload=function(){alert('test');}
-
window.onunload=function(){};
とすることで、「戻る」のあとにもonloadイベントは発火されるようになります。
これである程度の問題は解決するはずが、Safariなどの場合、このunloadを付与した場合のForm要素系のタグ、つまりinput,select,option,textareaなどの動作がおかしくなります。
input追加をクリックするたびに、innerHTMLをつかってinputタグが生成されるというケースを考えて見ましょう。

このときに、inputタグのvalueにユーザ入力によって値を設定します。

削除を押すと、inputタグがinnerHTML=""によってクリアされます。

さらに2つほどinputタグを生成して、再びユーザ入力によって値を設定します。

この後、何らかのリンクをたどって「戻る」という状態にすると、unloadが設定されているので、onloadEventが発火し一番最初の状態に戻ります。なので、Backforwardキャッシュ機構は停止されて、まったく最初と同じ状態になっているはずです。ところが、ふたたびinput追加をクリックしてinput要素を追加すると以下のように、いままでのユーザ入力のデータが保存された状態で現れてしまいます。

ちなみにinputタグはname属性やid属性もふられておらず、ただ同じような位置に存在するということだけでそのvalueがキャッシュされてしまっているようなのです。
これではJemplate/EJS/HTMLTemplate.jsなどのテンプレートモジュールを利用してElementの値を再構築するようなアプリケーションやinput hiddenなどで値をサーバサイドから提供するアプリケーションに深刻な不具合をもたらしてしまいます。
これはappendChildのみですべてのUIを実装するというかなりおそろしいやり方をするというのは、中規模以上のアプリケーションではかなり問題になります。ちなみにcreateContextualFragmentを用いてstringからHTML要素を生成する場合でもどうようにこのようなバグが発生します。
では、その対策にはどのような方法があるでしょうか。
まずぱっと思いつくのはそのノード自体をガベコレの対象とするためにremoveChildするような方法です。
-
window.onload=function(){
-
// inputタグすべてにたいして
-
e.parent.removeChild(e);
-
}
ところが、このおせっかいキャッシュ機構のいやらしいところは、Elementがremoveされたとしてもvalueの汚染が続くということです。なので、Elementのremoveは効果がありません。
さらに、innerHTML=""などでclearされたElementはjavascript領域からは消えてしまうので取得することができないというやっかいさも抱えています。
このバグ(といっても差し支えないだろう)的なキャッシュ機構に対応するためには、そのページで生成されては消えていったすべてのinput系タグを、"戻る"後に生成されるElementとは異なるものであることをレンダリングエンジンに教えてあげなければいけません。
なので
-
window.onload=function(){
-
// inputタグすべてにたいして
-
e.name = (new Date).getTime();
-
}
のようにname属性を二度と参照されない値に変更することで、valueを汚染されることを防ぎます。
さらに、そのページで生成されては消えていったすべてのinput系タグを取得する方法として以下のような戦略をとります。
DOM Event Level2のMutation Eventの中でSafariで利用可能なDOMNodeInsertedを利用して、DOMに変化が生じるたびに"まだマークされていない"すべてのinputエレメントを取得し、キャッシュに保存しておきます。
最後にキャッシュされたすべてのエレメントのname属性を変更することで、このようなバグから開放されることとなります。
以下、これらをPrototype.js 1.60をベースに実装したものを晒しておきます。
ただ、これにも一部既知のバグが存在していて、innerHTML+=で発生するバグや途中でidやname属性を動的に変更されたElementなどをフォローすることができないのです。
しかし、いずれもjavascript的にはマナーの悪いこととされているのでコーディングルールなどでこれらのバグを避けるようにすることが得策かもしれません。
-
if (Prototype.Browser.WebKit)(function() {
-
Event._observe = Event.observe;
-
function _search(hash, searchText) {
-
if (hash[searchText]) {
-
return (Object.isFunction(hash[searchText])) ? hash[searchText] : Prototype.emptyFunction;
-
} else {
-
return (Object.isFunction(hash['_default'])) ? hash['_default'] : Prototype.emptyFunction;
-
}
-
}
-
var firstUnload = true;
-
Event.observe = function(element, name, func) {
-
return _search({
-
'beforeunload': function() {
-
return element.addEventListener('beforeunload',
-
function(evt) {
-
return func.bind(element)(evt);
-
});
-
},
-
'unload': function() {
-
if (firstUnload) Event._observe(window, 'unload', (function() {
-
var cache = {};
-
var selector = $w('input option select textarea').join(':not(._marked),') + ':not(.marked)';
-
function storeCache() {
-
$$(selector).each(function(e) {
-
cache[e.identify()] = e;
-
e.addClassName('_marked');
-
});
-
}
-
Event._observe(document.body, 'DOMNodeInserted', storeCache);
-
Event._observe(window, 'load', storeCache);
-
return function() {
-
var time = (new Date).getTime();
-
alert(Object.toJSON($H(cache).keys()));
-
$H(cache).each(function(e, i) {
-
e.value.name = time + "_" + i;
-
});
-
};
-
})());
-
firstUnload = false;
-
return Event._observe(element, name, func);
-
},
-
'_default': function() {
-
return Event._observe(element, name, func);
-
}
-
},
-
name)();
-
}
-
Object.extend(window, {
-
observe: Event.observe.methodize()
-
});
-
})();


やりたいこととしては、動的に追加されたテキストボックスの値を常にデフォルトの値にする、ということでよろしいでしょうか。だとすれば、たとえば以下のようにするのはいかがでしょうか。
if (isWebKit) {
document.addEventListener("DOMContentLoaded", function () {
function resetValue(input) {
if (input.type != "text") return;
input.value = input.defaultValue;
}
document.body.addEventListener("DOMNodeInserted", function (event) {
var target = event.target;
if (target instanceof HTMLInputElement)
resetValue(target);
else if (target.nodeType == Node.ELEMENT_NODE)
Array.prototype.forEach.call(target.getElementsByTagName("input"),
resetValue);
}, false);
}, false);
}
ありがとうございます。エレガントですばらしい方法ですね。
今冷静になって考えれば、当たり前のことなんですがこのバグに直面した当初ためしたさまざまな方法でdefaultValueも受け付けなかったような気がしていて、(おそらくinsert時ではなく、DOMContentLoadedのタイミングで)
もーnameを破壊するしかねぇ!と思い込んでいました。
removeChildで消えないなんて、もう常識的な方法では通用しない気がしていたので><