JavaScript で画面の表示を書き換えるとき、真っ先に使うことになるのが innerHTML と textContent です。どちらも「要素の中身を差し替える」プロパティですが、渡した文字列を HTML として解釈するかどうかが決定的に違い、ここを取り違えるとタグがそのまま表示されたり、逆に意図しないタグを埋め込まれたりします。この記事では、2つの違いと innerText との使い分け、中身を消さずに追加できる insertAdjacentHTML、そして innerHTML を使うときに避けて通れないセキュリティ上の注意点までを解説します。
目次
innerHTML と textContent の違い
両者の違いは一言でいえば「渡した文字列をどう扱うか」です。innerHTML に文字列を代入すると、ブラウザーはその文字列を HTML として解析(パース)し、タグは実際の要素になります。一方 textContent は文字列をただの文字の並びとして扱うので、<strong> と書けば画面にも <strong> という文字がそのまま表示されます。
実際に同じ文字列を両方にセットして比べてみましょう。下のボタンを押すと、まったく同じ文字列が2つの段落に入ります。
<div class="demo">
<div class="box">
<h3>innerHTML</h3>
<p id="by-innerhtml">まだ書き換えていません</p>
</div>
<div class="box">
<h3>textContent</h3>
<p id="by-textcontent">まだ書き換えていません</p>
</div>
</div>
<button id="run" type="button">同じ文字列をセットする</button>
body {
font-family: sans-serif;
margin: 16px;
color: #333;
}
.demo {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.box {
flex: 1 1 200px;
border: 1px solid #ddd;
border-radius: 6px;
padding: 12px 16px;
}
.box h3 {
margin: 0 0 8px;
font-size: 14px;
color: #007bff;
}
.box p {
margin: 0;
min-height: 1.5em;
word-break: break-all;
}
button {
margin-top: 16px;
padding: 8px 16px;
border: none;
border-radius: 4px;
background: #007bff;
color: #fff;
cursor: pointer;
}
// タグを含んだ同じ文字列を用意する
const source = '<strong>50%OFF</strong> セール中';
document.getElementById('run').addEventListener('click', () => {
// HTML として解釈される → strong が効いて太字で表示される
document.getElementById('by-innerhtml').innerHTML = source;
// ただの文字列として扱われる → タグがそのまま文字で表示される
document.getElementById('by-textcontent').textContent = source;
});
左の innerHTML 側では <strong> が要素として組み立てられるため「50%OFF」が太字になり、右の textContent 側ではタグを含む文字列がそのまま見えています。タグを書きたいなら innerHTML、書きたくないなら textContent、という関係です。
この違いは読み取るときにも表れます。同じ要素から値を取り出すと、innerHTML はタグを含んだ HTML 文字列を、textContent は子孫のテキストだけをつなげた文字列を返します。
<p id="price"><strong>50%OFF</strong> セール中</p>
const price = document.getElementById('price');
// タグを含んだ HTML 文字列が返る
console.log(price.innerHTML); // => '<strong>50%OFF</strong> セール中'
// テキストだけをつなげた文字列が返る
console.log(price.textContent); // => '50%OFF セール中'
なお、要素そのものを含めた HTML が欲しいときは outerHTML を使います。innerHTML が「中身だけ」を指すのに対し、outerHTML は開始タグと終了タグを含めた要素全体を表します。
どちらを使うか迷ったときの基準はシンプルで、表示したいのが文字だけなら textContent を選ぶのが原則です。処理も速く、後述するセキュリティ上の問題も起きません。innerHTML はタグ入りのマークアップを組み立てる必要があるときだけに限定します。
innerText と textContent はどう違うのか
テキストを扱うプロパティにはもう一つ innerText があります。名前が似ているうえ、単純な要素では同じ結果になるため混同されがちですが、textContent は DOM の中身をそのまま、innerText は画面に見えている見た目を返すという性質の違いがあります。
| プロパティ | 返す内容 | CSS で非表示の要素 |
|---|---|---|
innerHTML | 中身をタグごと HTML 文字列にしたもの | 含む |
textContent | 子孫のテキストノードをそのまま連結したもの | 含む |
innerText | その要素を選択してコピーしたときに近い、表示されているテキスト | 含まない |
もっとも分かりやすいのが display: none を指定した要素の扱いです。textContent は表示状態を一切気にしないため隠れているテキストも返しますが、innerText は「見えていないもの」として除外します。
<p id="box">表示されるテキスト<span style="display: none;">隠れたテキスト</span></p>
const box = document.getElementById('box');
// 非表示の span の中身も含まれる
console.log(box.textContent); // => '表示されるテキスト隠れたテキスト'
// 画面に見えている分だけが返る
console.log(box.innerText); // => '表示されるテキスト'
この「見た目を返す」という仕様には代償があります。ブラウザーは innerText の値を求めるために、その要素が実際にどう表示されるかを知る必要があり、レイアウトの計算が必要になる場合があります。ループの中で何度も読むと処理が重くなる可能性があるため、単純に文字列が欲しいだけの場面では textContent を使うほうが無難です。
逆に innerText が向いているのは、ユーザーが画面上で見たままの文字列を取り出したいときです。また代入する側にも違いがあり、innerText に改行を含む文字列をセットすると改行が <br> として反映されるのに対し、textContent は改行文字をそのまま持つテキストノードになります(HTML では通常の改行は空白として表示されます)。
innerHTML で中身を置き換えたときに失われるもの
innerHTML への代入は「文字列を書き込む」という見た目に反して、実際には元の子要素をすべて捨てて、文字列から作り直すという処理です。見た目が同じ HTML に戻したとしても、そこにある要素はまったく別のオブジェクトに置き換わっています。
登録したイベントリスナーが消える
イベントリスナーは要素そのものに紐づいているため、要素が作り直されればリスナーも一緒に失われます。次のコードでは、ボタンにクリックイベントを登録したあとに親要素の innerHTML を書き換えているので、ボタンは見えていてもクリックに反応しなくなります。
const list = document.getElementById('list');
const button = document.getElementById('reload');
button.addEventListener('click', () => {
console.log('クリックされました');
});
// list の中に button があると仮定する
// 中身を作り直したので、button は別の要素に置き換わっている
list.innerHTML += '<p>項目を追加しました</p>';
// → 新しい button にはリスナーが付いていないので反応しない
同じ理由で、置き換えた範囲に <input> があればユーザーが入力していた値も失われます。入力値は HTML の属性ではなくオブジェクトのプロパティとして保持されているため、文字列に書き出した時点で消えてしまうからです。動画や音声の再生位置、スクロール位置なども同様にリセットされます。
innerHTML += は「追加」ではない
上のコードで使った innerHTML += '...' は、追加しているように見えて実際は違います。+= は「現在の中身を文字列として読み出し、末尾に連結し、その結果を丸ごと代入し直す」という意味なので、既存の部分まで含めて作り直しが発生します。要素数が多いほど無駄が大きく、追加のたびに前述の副作用が起きます。
末尾に足したいだけなら、次に紹介する insertAdjacentHTML を使えば既存の中身に触れずに済みます。
insertAdjacentHTML で置き換えずに追加する
insertAdjacentHTML(position, html) は、指定した位置に HTML 文字列を解析して挿入するメソッドです。innerHTML と違って既存の中身を作り直さないので、すでに存在する要素やそのイベントリスナーはそのまま残ります。
第1引数の position には、基準となる要素から見た挿入位置を次の4つの文字列で指定します。大文字・小文字は区別されません。
| position | 挿入される位置 |
|---|---|
'beforebegin' | 要素の直前(兄弟として要素の前に入る) |
'afterbegin' | 要素の中の先頭(最初の子になる) |
'beforeend' | 要素の中の末尾(最後の子になる) |
'afterend' | 要素の直後(兄弟として要素の後ろに入る) |
名前の begin / end は要素の開始タグ・終了タグだと考えると覚えやすいです。afterbegin は「開始タグの直後」、beforeend は「終了タグの直前」なので中側、beforebegin と afterend はタグの外側になります。
下のデモで、それぞれのボタンを押したときにどこへ挿入されるか確かめてみてください。青い枠が基準の要素で、押すたびに黄色いブロックが増えていきます。何度押しても基準の要素の中身が消えない点にも注目してください。
<div id="wrap" class="wrap">
<div id="target" class="target">基準の要素(#target)</div>
</div>
<div class="buttons">
<button type="button" data-position="beforebegin">beforebegin</button>
<button type="button" data-position="afterbegin">afterbegin</button>
<button type="button" data-position="beforeend">beforeend</button>
<button type="button" data-position="afterend">afterend</button>
<button type="button" id="reset">リセット</button>
</div>
body {
font-family: sans-serif;
margin: 16px;
color: #333;
}
.wrap {
border: 1px dashed #bbb;
padding: 12px;
border-radius: 6px;
}
.target {
border: 2px solid #007bff;
border-radius: 4px;
padding: 12px;
}
.added {
display: block;
margin: 4px 0;
padding: 4px 8px;
background: #fff3cd;
border: 1px solid #ffe08a;
border-radius: 4px;
font-size: 13px;
}
.buttons {
margin-top: 16px;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
button {
padding: 6px 12px;
border: 1px solid #007bff;
border-radius: 4px;
background: #fff;
color: #007bff;
cursor: pointer;
}
const wrap = document.getElementById('wrap');
const initialHTML = wrap.innerHTML;
document.querySelectorAll('[data-position]').forEach((button) => {
button.addEventListener('click', () => {
const position = button.dataset.position;
const target = document.getElementById('target');
// 押した位置に span を挿入する(既存の中身は消えない)
target.insertAdjacentHTML(
position,
'<span class="added">' + position + '</span>'
);
});
});
// innerHTML で中身をまるごと元に戻す
document.getElementById('reset').addEventListener('click', () => {
wrap.innerHTML = initialHTML;
});
'beforebegin' と 'afterend' は挿入先が「基準要素の親」になるため、その要素が DOM ツリーに入っていて親要素を持っていることが前提になります。document.createElement() で作っただけのまだどこにも挿入していない要素に対して使っても、期待どおりには動きません。
実務でよくあるのは、リストの末尾に項目を積んでいくパターンです。'beforeend' を使えば、それまでの項目に手を触れずに追加できます。
const list = document.getElementById('list');
const items = ['りんご', 'みかん', 'ぶどう'];
items.forEach((item) => {
// 既存の子要素はそのままに、末尾へ追加する
list.insertAdjacentHTML('beforeend', '<li>' + item + '</li>');
});
なお、挿入するのが単なるテキストなら insertAdjacentText(position, text)、すでに作ってある要素オブジェクトなら insertAdjacentElement(position, element) が使えます。位置の指定方法は3つとも共通なので、挿入したいものの種類で選べば大丈夫です。
ユーザーが入力した値を innerHTML に渡してはいけない
innerHTML(および insertAdjacentHTML)は、渡された文字列を HTML として解釈します。つまり、その文字列にタグが混ざっていればそのとおりの要素が作られてしまうということです。フォームの入力値や URL のクエリ、外部 API から受け取ったデータをそのまま渡すと、攻撃者が仕込んだマークアップを自分のページで動かすことになります。これがクロスサイトスクリプティング(XSS)と呼ばれる脆弱性です。
script タグが動かないから安全、ではない
よくある誤解が「innerHTML で挿入した <script> は実行されないので危険ではない」というものです。たしかに HTML の仕様上、innerHTML で挿入された <script> の中身は実行されません。しかし危険なのはそこではなく、要素の属性として書ける JavaScript のほうです。
const input = document.getElementById('keyword');
const result = document.getElementById('result');
// NG: 入力された文字列をそのまま HTML として解釈させている
result.innerHTML = '「' + input.value + '」の検索結果';
ここで <img src="x" onerror="alert(1)"> のような文字列を入力されると、読み込みに失敗した画像の onerror 属性が発火し、任意のコードが実行されます。<script> を使わなくても攻撃は成立するので、「タグを含む文字列を HTML として解釈させている」こと自体が問題だと考えてください。
textContent に変えるのが最も確実
対処はほとんどの場合とても簡単で、innerHTML を textContent に置き換えるだけです。textContent は受け取った文字列を必ずテキストとして扱うので、どんなタグが混ざっていても要素にはならず、そのまま文字として表示されます。表示したいのが文字だけなら、これで問題は根本的になくなります。
// OK: 入力された文字列はテキストとして表示される
result.textContent = '「' + input.value + '」の検索結果';
タグが必要なら、埋め込む値だけをエスケープする
どうしてもマークアップを組み立てたい場合は、外側の HTML は自分で書き、変動する値だけをエスケープして埋め込むという形にします。エスケープとは、HTML で特別な意味を持つ記号を文字参照に置き換えて、タグとして解釈されないようにすることです。
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&') // & を最初に置換する(順番が重要)
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// 変動する値だけをエスケープしてから埋め込む
list.insertAdjacentHTML(
'beforeend',
'<li class="item">' + escapeHtml(input.value) + '</li>'
);
& を最初に置換するのは、あとから置換すると自分が生成した < の & まで二重に変換してしまうためです。ただし、この程度の単純なエスケープで安全といえるのは要素の内容や、引用符で囲んだ属性値に埋め込む場合に限られます。href や src の値、<script> の中などは別のルールが必要になるので、埋め込む場所が複雑なら手書きせず、textContent で組み立てるか、実績のあるサニタイズ用ライブラリ(DOMPurify など)を使うほうが安全です。
createElement とどう使い分けるか
要素を増やす方法としては document.createElement() で要素を作り appendChild() で追加する書き方もあります。こちらは文字列を経由しないので、値を textContent や setAttribute() でセットしている限りエスケープ漏れが起きません。作った直後の要素にイベントリスナーを登録できるのも利点です。
一方で、階層の深いマークアップを組み立てようとすると行数が増えて構造が読み取りにくくなります。目安としては、変動する値が絡むところや個別にイベントを付けたい要素は createElement、固定のマークアップをまとめて差し込むだけなら insertAdjacentHTML と考えると選びやすいでしょう。表示するのが文字だけなら、そもそも textContent の代入で済みます。
まとめ
innerHTML は渡した文字列を HTML として解析するプロパティ、textContent はただの文字列として扱うプロパティです。似た名前の innerText は「画面に見えているテキスト」を返すため、display: none の要素を含まない代わりにレイアウト計算が必要になることがあります。単に文字を読み書きしたいだけなら textContent を選んでおけば間違いありません。
innerHTML への代入は中身を作り直す処理なので、既存要素に付けたイベントリスナーや入力中の値が失われます。innerHTML += も同じ副作用を持つため、追加したいだけなら insertAdjacentHTML(position, html) を使い、'beforebegin' / 'afterbegin' / 'beforeend' / 'afterend' の4つから位置を選びます。そして最も重要なのは、ユーザー由来の文字列を innerHTML や insertAdjacentHTML にそのまま渡さないことです。文字を表示したいだけなら textContent に置き換え、マークアップが必要なら埋め込む値だけを確実にエスケープしてください。