DOM イベントを破壊せずにテキストを置換する方法

Angular・Vue・React などの SPA サイトなんかで、

const elem = document.querySelector('.example');
elem.innerHTML = elem.innerHTML.replace((/HOGE/u), 'FUGA');

このように innerHTML を置換するようなブックマークレットを動かしたりすると、対象の要素内に登録されていたイベントリスナが動かなくなってしまう。

// Vanilla JS の場合、サイト製作者が用意していた次のようなコードが動かなくなる
document.querySelector('.example').addEventListener('click', () => {
  alert('Hello');
});

DOM 要素を外部から操作することで、元々あった DOM イベントを破壊してしまうこの挙動は、よく知られたものだろう。


ところで、Chrome 拡張機能の「Google 翻訳」は、SPA のサイトでも正しく翻訳が効いて、かつ DOM イベントが破壊されず保たれている。

翻訳処理の動作を開発者コンソールで見てみると、確かに元のテキストを消して翻訳されたテキストに置換しているのだが、はて、どうやって元の DOM イベントを壊さずにテキストを置換しているのだろう。調べてみた。

Google 翻訳のソースコードを追う

開発者ツールを開いた状態で、Chrome 拡張機能の「このページを翻訳」を押してみる。すると、次のような JavaScript が読み込まれていた。

main_ja.js の方は UI 周りのコードらしい。さほど実処理はない。element_main.js の方が本体で、難読化された大量のコードが見える。

この中には、前回の記事で紹介した MutationObserver による DOM 監視の仕組みも含まれている。コールバック関数の中を紐解いていくと、DOM 要素の nodeType を頻繁にチェックしていることが分かった。

Node.nodeType

Node.nodeType は、それが HTML 要素なのか、テキストノードなのかといった種類を返してくれるプロパティ。

innerHTMLinnerText での DOM 操作は「Element」の操作になるが、それよりももっと細かい粒度である「Node」単位に探索しているようだ。

例えば、以下のような HTML の場合。

<div id="example">
  Hello
  <span id="inner">World</span>
  !!
</div>

document.getElementById('example').childNodes をループして探索してみると、

の3つのノードが取得できる。

そしてこのテキストノードの nodeValue を置換してやれば、DOM イベントを破壊することなくテキスト置換ができるようだ。

テキストノードを探索するコード例

次のコードは、「Word Replacer」という、ページ中のテキストを置換する Chrome 拡張機能のコードだ。

コチラのコードの walk() 関数を見ると、nodeType3、つまりテキストノードの時に、置換処理を呼ぶようにしている。それ以外の時は walk() 関数を再帰呼び出ししている。

このコードはもう少し簡略化されていて、再帰呼び出しを使っていた walk() と違い、getElementsByTagName('*') で全要素を拾って順に見ている。

いずれも、最終的な文字列置換の結果は Node.nodeValue に代入している。innerHTMLinnerText の代わりに nodeValue というワケだ。

document.getElementById('example').childNodes.forEach((node) => {
  if(node.nodeType === 3) {
    node.nodeValue = node.nodeValue.replace((/HOGE/u), 'FUGA');
  }
});

サンプルコード

Node.nodeType を見て、テキストノードの Node.nodeValue を置換するサンプルコードを組んでみた。

以下のサンプルページの「Walk」ボタンを押下すると、ページ中にある小文字が全て大文字に置換される。

「About」という箇条書きのリンクには、クリックすると alert() を表示するイベントを定義してある。「Walk」ボタンを押下してテキストを置換した後も、「ABOUT」リンクを押下して alert() が表示される、つまり addEventListener() の内容が残っていることが分かるだろう。

開発者コンソールで簡素に動かす版

開発者コンソールにて以下のコードを実行すると、ページ内の DOM ツリーを一覧出力する作りにアレンジしてみた。

tree = [];
function walk(elem, depth) {
  if(elem  === undefined) elem  = document.body;
  if(depth === undefined) depth = 0;
  const name = '  '.repeat(depth)
             + elem.tagName.toLowerCase()
             + (elem.id && `#${elem.id}`)
             + [...elem.classList].map(cls => `.${cls}`).join('');
  tree.push(name);
  elem.children.forEach(child => walk(child, depth + 1));
}
walk();
copy(tree.join('\n'));

以上

安易に innerHTMLinnerText で置換すると、DOM イベントを破壊してしまう。

探索処理は少々面倒だが、Element ではなく Node を探索し、テキストノードnodeValue を置換してやれば、DOM イベントを破壊せずに文字列置換できることが分かった。