JavaScriptで大量のinput等のDOM操作がChromeだけ重いので解決する

Webアプリケーションの開発では、JavaScriptでの大量のDOM操作が嫌が応にも不可欠な場合があり、例えば膨大な関連商品・資材等を紐付けする際にinput(他、selectやtextarea)に対してそれを行うことになる。

このとき、最低設置するフィールドとしては、1行につき単純なものにしてもキーフィールドと個数フィールドだけでもふたつ。

これが、200行とか300行とかに渡ってレコード分の行数だけ設置される。

で、登録された関連商品・資材の参照を行う際には、非通信環境や非同期通信環境だとJSONなり生データなりを元に、これらのフィールドに対して値を出してくるわけであるが、参照と言いつつも実質的には表示データの書き換えであり、その際に大量のDOM操作をするということになる。

 

これはどれだけソースコードを効率化しても超えられない速度があり、それはデータ量が増えていく以上はある程度仕方がないにしても・・・

 

Chromeだけやたらめったら重い。

 

ロジック要件の修正やループ要件の修正など、実に2週間近くかけて見直し・書き直してみたが、いずれもことごとく失敗し、昨日ようやく解決の目を見た。

今回の記事はChromeでの大量のDOM操作を行うために、奮闘したことの記録として残すことにする。

 

単純な単対単のマスタメンテンナンスのデータを十数個程度変えるくらいなら、ある程度乱暴な書き方をしても速度的には大して気になるものとはならないが、単対多のデータとなるいわゆる連携マスタで設置するフィールド数がべらぼうに多くなると、それ相応に動作速度を気にしなければならないようになる。

 

で、最初にフィールド2つの200行とか300行とか書いたけど、具体的に言うと今回はフィールドとして8項目、最大1000行がフィールドグループ行として、実に8000項目のフィールドを基準として動作させることになった。

 

それを普通にPHP側からJSONを投げて、単純にカラム名から対応するHTML上のフィールドをJavaScriptで処理しようとすると・・・。

var json_table = [{'col1':'value1'}, {'col2':'value2'}, {'col3':'value3'}, ...];

for ( var row_count = 0; row_count <= json_table.length - 1; row_count++  ) {
    for ( col_name in json_table[row_count] ) {
        var element = document.getElementById( 'rec' + '-' + row_count + '-' + col_name ); // 対象のinputのHTMLのid
        element.value = json_table[row_count][col_name ];
    }
}

ざっくりサンプルコードはおおよそはこんな感じになってくる。(本来はきちんとjson_tableの中身の有無とか確認するが、その部分は上のコードでは割愛した。)

 

上記は、飽くまでも単純なJSONのループであり空行は処理していないが、ループ内にDOM取得とDOM操作を共存させて組み込んでいるという形だ。

もちろんこれはどのブラウザでも重くなる。

 

まぁこれは当然といえば当然。こんなのは想定内である。

 

実際は1000行のループや、inputの種類や、存在しないレコード対象やフィールドを読み飛ばすために少々複雑に条件文をいれなければならないため、今度はDOM取得とDOM操作を分けて、ざっくりもうほんの少しだけ詳しく書いてみる。

var json_table = [{'col1':'value1'}, {'col2':'value2'}, {'col3':'value3'}, ...];
var col_names = ['col1', 'col2', ... ];
var elements = new Array();
var itelation = 999; // 件のフィールドグループ1000行分のループ分

// 1000行分のDOM取得
for ( var row_count = 0; row_count <= itelation; row_count++ ) {
    var elements[row_count] = new Object();
    for ( var col_count = 0; col_count <= col_names.length - 1; col_count++ ) {
        elements[row_count][col_names[col_count]] = document.getElementById( 'rec' + '-' + row_count + '-' + col_names[col_count] );
    }
}
// レコードデータ参照とDOM操作
if ( 0 < json_table.length ) {
    for ( var row_count = 0; row_count <= itelation; row_count++ ) {
        for ( col_name in elements[row_count] ) {
            if ( 'undefined' !== typeof json_table[row_count][col_name] ) {
                elements[row_count][col_name].value = json_table[row_count][col_name]; // ここが今回のネックとなる部分
            } else {
                // レコードデータのない場合の空データ入力
            }
        }
    }
} else {
    // テーブルデータのない場合の空データ入力
}

こんな感じのニュアンスのコードで、キーフィールドを直接叩いて目的のデータを出してみた。

 

以外なことに、ループを2回に分けているのにも関わらず、DOMのノード全体をキャッシュしてあげるだけで、IE/Edge/Firefox/Safari/Operaでは、DOM取得と操作を一緒くたにしたときより体感上でもconsole.time()上でも速くなっている。

特にIE/Edgeで余裕を持って許せるレベルで動くことは良い意味で予想外で、MSブラウザもたまにはまともやんと思いつつ。

 

・・・だが、「ここが今回のネックとなる部分」と書いたところで、これまた予想外の問題が発生した。

 

うん。Chromeだけで。

 

もう体感とかそういったレベルではなく、尋常じゃなく重い。

 

ブラウザ界において最高峰の欠作として名高いIE/Edgeでさえも1秒強程度で切り替えが終わっているのに、Chromeだけが20秒前後と凄まじく遅い。

console.time()上ではおおよそFirefox等と同じ速度を出しているのにも関わらず、表示にやたらと時間がかかるのだ。

 

試しに、上記で言うところのelements[row_count][col_name].valueをコメントアウトしてみたら・・・

 

なんのことなく、普通に動く。

 

更にいうと、classListとかでクラスを操作したりとかしてみたけど、あの異様な遅さは再現しない。

 

一体どういうこっちゃ・・・?

 

ネットで調べてみたが、ループの遅さに悩まされたり、そもそもループでデータ取得ができないとか言った人は大多数いたものの、DOM操作だけで同じような現象に悩まされている人は見受けられなかった。

 

国内の某コード掲載サイトとか、国外の某コード質問サイトとかをたどたどしいながらも約して読んでみたが・・・。

これらのサイトでのコードは一通り試してみても、まぁいつもどおりのまったくもっての筋違い、解決法は得られず。

とりあえず、なんとかせねばと解決のための先駆けとしてそれらを組み込んでみるものの・・・全部ダメ。

 

そこで、これまでの知恵と知識を総動員して事の解決を模索し始める。

 

例えば困ったときのお得意様、正規表現置換でこんなニュアンスなのとか。

 var html = document.getElementById( 'relateData' ).innerHTML; // 1000行のフィールドを入れた親要素から子要素引っ張る
 for ( var row_count = 0; row_count <= itelation; row_count++ ) {
~略~
    var before = elements[row_count][col_names[col_count]].outerHTML;
    var after = elements[row_count][col_names[col_count]].outerHTML;
    after = after.replace(); // ←afterに取得した文字列をvalueだけ置換
    html = html.relace( before, after );
~略~
}

 

Chrome、さほど変わらず。しかもEclipseとかその他諸々立ち上げてたらブラウザがメモリエラーする場合があった。

 

IE/Edgeでは凄まじく重くなり同じくクラッシュ。流石MSブラウザ!悪い意味で期待を裏切らないな^q^

 

Firefox/Opera/Safariは普通に動きます。

まぁ、replaceって大本のテキスト量が多いとダメみたいだ。

 

ならば、jQueryのreplaceWith()でと思ってこんな感じで。

 for ( var row_count = 0; row_count <= itelation; row_count++ ) {
~略~
    elements[row_count][col_names[col_count]].replaceWith( elements[row_count][col_names[col_count]].clone().val( '値' ).prop( 'outerHTML' ) );
~略~
}

 

Chromeはこれで早くなる・・・が、それでも4秒弱かかる。

IE/Edgeはもちろん安定のクラッシュ。

 

IE/Edgeがクラッシュするのはスクリプト開発においては様式美である。

一日一度はクラッシュさせないと、それはMSブラウザを知り尽くしたとは到底言えないだろう。(`・ω・´)

 

言わずもがな、Firefox/Opera/Safariは普通に動く。

どんなちゃちぃコードも高速で動作するブラウザってすごいよね。これらのブラウザは良い意味でおかしいだけ。

 

じゃあ、非同期ループで回してみっかー(遠い目)。

for ( var row_count = 0; row_count <= itelation; row_count++ ) {
    (function( counter ) { window.setTimeout(function() {
~略~
        elements[counter][col_names[col_count]].value = '値';
~略~
        }, 1 * counter ); })( row_count );
}

 

よっしゃ、いけるやんk・・・いやいや、あかんわ!!

 

何があかんって、たしかに全てのブラウザで動作は安定するんだ。

 

が、これだとsetTimeoutでクロックされている最中に、データの送信を走らせたら中途半端なところで中途半端なデータが送信できてしまう。

 

その対策にクロック中は送信できんようにしたろうと思ったけど、これじゃあ解決じゃなくてただの対処。

解決じゃなくて対処が許されるのは、ITのヒエラルキーの中で最もゆる~いWebサイト制作の世界だけ。それはWebアプリケーション開発の領分ではない。

 

そんなこんなで組み替えること、書き直しすること、諦めないこと・・・で、2週間が経過した。

 

まぁこれだけをやっていたわけじゃないので、別のところで手を付けられるところは同時に進めていたから、これだけ経過したってのもあるが・・・。

 

結局、問題を先送りするのって愚の骨頂だよね。

 

数多ある作業の片脇で解決に取り組んだこの問題だが、いよいよ本腰で対応しなければならなくなりそもそもの前提と根本的な自問をしたのが、昨日。

 

・・・ひょっとして8000項目のinputとかをDOM操作しようとしているのは俺だけなんやろか?

 

ここまでやってもどうにもならないときは、何が何でも自分一人で解決を得るしか無いだろう。

 

そこで、回り回って元のコードに戻ってきて、再びconsole.time()を利用して時間を計測した。

時間の計測としてはやはり変わらず、画面に値が反映されるまでがChromeだけ重い。

 

ただ、そこでひとつ当たり前過ぎて見逃していたことに気づいた。

 

console.time()で計測している時間としてはFirefoxとほぼ同じだったということ。

すなわち、DOM操作でオブジェクトが更新される処理としては、計測している時間で既に終わっているということである。

事実、valueを書き換えた後で再度valueをconsole.log()で確認すると、なんと更新後のデータとなっている。

 

ここまで大量のデータをDOM操作することは全くと言っていいほどなかったので、何がこんなに時間がかかっているのか?というところにここでようやく疑問を呈したわけだが、あることに気づいた。

 

ひょっとして自分が書いたプログラム的な問題と言うよりは、Chromeのブラウザサイドでのフィールド描画~反映の問題なのではないだろうか?

 

試しに、1000行の大本のラッパーである要素を表示的に消してみた。

さて・・・どうなるか・・・。

var json_table = [{'col1':'value1'}, {'col2':'value2'}, {'col3':'value3'}, ...];
var col_names = ['col1', 'col2', ... ];
var elements = new Array();
var itelation = 999; // 件のフィールドグループ1000行分のループ分
var base = document.getElementById( 'tableBase' ); // jQueryは$( '#tableBase' )

base.classList.add( 'display-none' ); // jQueryはbase.addClass( 'display-none' );

// 1000行分のDOM取得
for ( var row_count = 0; row_count <= itelation; row_count++ ) {
    var elements[row_count] = new Object();
    for ( var col_count = 0; col_count <= col_names.length - 1; col_count++ ) {
        elements[row_count][col_names[col_count]] = document.getElementById( 'rec' + '-' + row_count + '-' + col_names[col_count] );
    }
}
// レコードデータ参照とDOM操作
if ( 0 < json_table.length ) {
    for ( var row_count = 0; row_count <= itelation; row_count++ ) {
        for ( col_name in elements[row_count] ) {
            if ( 'undefined' !== typeof json_table[row_count][col_name] ) {
                elements[row_count][col_name].value = json_table[row_count][col_name]; // ここが今回のネックとなる部分
            } else {
                // レコードデータのない場合の空データ入力
            }
        }
    }
} else {
    // テーブルデータのない場合の空データ入力
}

 

結果:こいつ!?動くぞ・・・!!?

 

何を2週間も悩んでいたんだ、というのが嘘のように一瞬にして解決した。

 

本当に本当に、俺は何をこんなに悩んでいたんや・・・。

 

8000項目もDOM操作すると当然重たくなることはわかりきっていたけど、そのせいじゃなくて、コードそのものはきちんと短時間で動作しているというところで疑問を持つべきやったね・・・。

 

ともあれ、原因の説明として結論づけるならば。

 

これまで試行錯誤した上で確実に特定できたのは、DOMのオブジェクトがJavaScriptにより変化したとしてもブラウザ上では視覚的にすぐには反映されない動きをしているということ。

 

これは表示したときと非表示したときの動きの違いを見るに、ブラウザが裏側で保持しているDOMオブジェクトやツリーを元にしてレンダリングするといったアプリケーション上の制約が関係している風に見受けられた。

 

Chrome以外のブラウザではこれが高速に行われており、Chromeに関してはこれら大量のinput等をDOM操作して描画する動作が他ブラウザに比べて処理に時間を要すため重い・・・というのが真相だった。

 

要は、Chromeが他のブラウザに比べて大量のDOMの変化を読み込んで画面に映し出すのが苦手であるということだ。

 

このことから問題の解決策としては大量のinput等のDOM操作を行うことについては、HTMLを一度非表示にしてあげるというのが効果的だということがわかった。

 

従って、DOM操作の前後に付与するコードとしてはこれだけ。

// JavaScript プレーン使用
var base = document.getElementById( 'tableBase' );
base.classList.add( 'display-none' );
~~DOM操作~~
window.setTimeout( function() {
    base.classList.remove( 'display-none' );
}, 500 );

// jQuery ライブラリ利用
var base = $( '#tableBase' );
base.addClass( 'display-none' );
~~DOM操作~~
window.setTimeout( function() {
    base.addClass( 'display-none' );
}, 500 );

 

こうして、display: none;を持ったhtmlのクラスを付与して、最終的にsetTimeoutでdisplay: none;だけを消して上げると、描画の動作に惑わされることなく高速にDOM操作が行える。

 

ちなみに、visibility: hidden;とかopacity: 0;とかの描画領域が残るものではダメ。

 

これだけで20秒前後かかっていたのが、1秒かからない程度になった。

 

 

今回の件は、一昔前のWebアプリケーション開発の意識とかWebサイト制作が作業のメインだと、まず見落としがちな基本的なワナだったと思う。

 

ワナってハマるためにあるんやろうけど(´・ω・`)