要点
-
contentEditable="true"
な要素において入力内容(文章量)が多くなると、文字の入力やカーソル(キャレット)の移動が遅くなる問題がある。 - 特にChromeやChromiumのelectronにおいて顕著。
- テキストの文字数よりも、DOMのノード数が多い時に顕著になる。
-
contentEditable="true"
な要素中でKaTeXやMathJaxなどの数式を大量に使うと起こりやすい。 - 解決策として、画面外に出た要素はstyleで
display:none
にする。 - ただし、その他の要素の表示位置が崩れないようにラッパーspan要素を作ってstyleのwidthとheightをセットしておく。
モチベーション
Webアプリとしてリッチテキストエディタを作る際など、contentEditable
は大活躍していると思います。とても便利なのですが、入力内容が多くなると文字入力やカーソル(キャレット)の移動が極端に遅くなることがあります。
経験則としては特徴がありました
- ChromeやChromium系のElectronなどで顕著で、FirefoxやEdge(Legacy)ではそれほど遅くならない。
- ChromeのDevTool等を使ってパフォーマンスを調べてみても、何もやっていないはずの
keydown
イベントに時間がかかっていたり(もちろんEventListenerの登録もしてない状態)と、不明な点が多い。 - DevToolを信じればレンダリングに時間がかかっているわけでもなさそう。
- 入力内容として、テキスト文字が多いだけよりも、DOMツリー内の要素数が多い時に顕著になる。
- 実際(私の開発中のアプリ)はKaTeXを使って数式を描画しており、その数式部分で大量のDOM要素が生成されている。
- 数式のDOM要素を全て非表示(
display: none
)にすると速度が改善する - カーソル移動だけでも遅い。
- データの先頭付近ではカーソル移動や文字入力があまり遅くない。データの末尾付近だと極端に遅い。
これらを鑑みて、個人的に予想している原因は、Chromium系ブラウザの挙動として、カーソル移動や文字入力の度に次のカーソル位置(座標)を再計算しており、その際にDOMツリーを先頭から全て辿っているためではないかという考えに至りました。
解決策(回避策)
テキストの文字数よりも、とにかくDOMの要素を減らすしかないです。ここでは、減らすDOMはKaTeXで生成した数式のDOM要素を想定して話を進めますが、他の場合でも通じる対策だと思います。
パフォーマンス改善のためにDOMを減らせというのはよく言われていることですが、リッチテキストエディタを作っているので、DOMを減らすとそもそも編集中の内容が変わってしまいます。一部の要素がなくなると、他の要素の表示位置(レイアウト)も変わってしまうので困ります。よって、DOM自体は削除したりしたくありません。
ですが、幸いにもDOM要素自体はツリー構造を触らないまま、非表示にするだけでも速度が改善することがわかりました。このためには、非表示にしたいDOM要素のstyleにdisplay: none
をセットします。
ただし、非表示にするだけだと、結局その他の要素の表示位置が変わってしまうので、サイズを持たせたまま非表示にします。そのために、非表示にしたいDOMの親としてラッパーのSPANタグでも付けてやって、そのSPANタグのstyleのwidthやheightに、非表示にしたいDOMの表示時のサイズをセットします。
例えば、次のようなDOMツリーを考えます。
<div id="editableDiv" contentEditable="true">
<p>
第一段落の文章です。
ここに文章がいっぱいあります。
数式はKateXで生成すると次のようになります。
<span class="math">
<span class="katex">
...
数式レイアウト用の大量のspanタグ
...
</span>
</span>
ただし、最上位のSPAN(class=math)は筆者が付け加えたダミーラッパーです。
</p>
</div>
一番大外のDIVタグがcontentEditableなリッチテキストエディタの部分です。文章の中で数式を表示させるために、KaTeXを利用すると<span class="katex">
が生成され、その中に大量のDOMが含まれています。今回は、これを非表示にします。つまり、<span class="katex" style="display: none;">
と変更します。その時に、この数式の表示サイズだけは残すために、わざとダミー(ラッパー)の<span class="math">
を設けておいて、これにwidthとheightを追加します。つまり、<span class="math" style="width: X.XXXpx; height: Y.YYYpx;
をセットします。ここで、widthとheightの値は、getBoundingClientRect()
関数などで取得します。
非表示にするタイミング
今回は、リッチテキストエディタの文章量(入力内容)が多い場合に遅くなる問題です。文章量が多くなると、文章のうちの一部だけが画面に表示されて、それ以外の部分は画面外(ブラウザウィンドウの表示領域よりも外)に配置されるでしょう。ですので、数式が画面外にある場合には上記の方法でサイズを残したまま非表示にするようにします。表示範囲は画面上のスクロールバーで切り替わるものとします。キーボードやマウスホイールでスライドした時でも、スクロールバーが動き、"scroll"イベントが発生しますので、これをトリガーにします。
例としては非表示と表示を切り替えるコードは次のようになります。"scroll"イベントやリサイズイベントの度に、この関数をcallしてやります。
function HideMath(){
const matches = nt_render_div.querySelectorAll('span.math');
const whole_height = document.documentElement.clientHeight;
for (let i=0; i<matches.length; i++) {
const math = matches[i];
const katex = math.firstChild;
const rect = math.getBoundingClientRect();
if(katex.style.display == "none"){
//すでに非表示の場合//
if((rect.bottom > 0.0) && (rect.top < whole_height)){
//数式が画面内に入った
katex.style.display = "block"; //or "inline" //
math.removeAttribute("style");
}
}else{
//表示状態だった場合//
if((rect.bottom < 0.0) || (rect.top > whole_height)){
//数式が画面外に出た//
if(math.style.length==0){
const text = "width: " + String(rect.width)
+ "px; height: " + String(rect.height)
+ "px; display: block;"; //or inline//
math.style.cssText = text;
}
katex.style.display = "none";
}
}
}
}
ここで、elementWithScrollbar
はスクロールバーを持つ要素で、おそらくcontentEditableなdiv要素の親や、おじいちゃん、になるでしょう。
あらためて、ラッパー<span class"math">
にwidthとheightでサイズをセットしておくことが重要です。これをしておくことで
- その他の要素の表示位置に影響を与えない
-
<span class="katex">
が非表示状態でも、getBoundingClientRect()
でサイズを取得できる(画面内外の判定に必要)。
という重要な効果があります。
ただし、実際の運用ではscrollイベントの度に上記を呼び出すと、今度は別の負荷が過剰になるので、requestAnimationFrameなどを使って回数を減らしましょう。
さいごに
Webアプリ開発って、やればやるほどブラウザの仕様なのかバグなのかわからない挙動が多くて大変ですね。それでもリッチテキストエディタ開発は面白い。
だれかのお役に立てば幸いです。