DOM 要素の複製は「フォームに行を追加するボタン」「カード一覧の動的生成」「テンプレートから複数アイテムを作る」など、インタラクティブな UI を構築するときに必ず登場するテクニックです。
JavaScript の cloneNode() を使えばどんな要素でも1行で複製できますが、イベントリスナーが引き継がれない・id が重複するという落とし穴があります。この記事では基本的な使い方から落とし穴の回避策、<template> 要素を使った実用パターンまで体系的に解説します。
cloneNode() の基本
element.cloneNode(deep) は指定した要素のコピーを返します。引数 deep によって「子要素ごとコピーするか」が変わります。
// 深いコピー(子要素・テキストノードもすべて複製) const clone = element.cloneNode(true); // 浅いコピー(要素自体のみ。子要素は含まない) const shallowClone = element.cloneNode(false); // 複製した要素を DOM に挿入(挿入しないと画面に表示されない) document.body.appendChild(clone);
| 引数 | 複製内容 | 主な用途 |
|---|---|---|
cloneNode(true) |
要素+全子要素+テキスト | カード・リストアイテムの複製 |
cloneNode(false) |
要素本体のみ(空の外枠) | 空コンテナを作って中身を後で設定 |
<!-- 元の要素 -->
<ul id="original">
<li>りんご</li>
<li>みかん</li>
</ul>
<script>
const original = document.getElementById('original');
// 深いコピー → <ul><li>りんご</li><li>みかん</li></ul>
const deepClone = original.cloneNode(true);
// 浅いコピー → <ul></ul>(中身なし)
const shallowClone = original.cloneNode(false);
document.body.appendChild(deepClone); // リスト全体が複製される
document.body.appendChild(shallowClone); // 空の ul が追加されるだけ
</script>
id 重複問題と対処法
id はページ内で一意でなければなりません。cloneNode() は id 属性もそのままコピーするため、同一ページに同じ id が複数存在する状態になります。document.getElementById() が期待通り動かなくなるため、クローン後に必ず id を処理する必要があります。
// NG: id="card" が 2 つ存在してしまう
const card = document.getElementById('card');
const clone = card.cloneNode(true);
document.body.appendChild(clone);
// getElementById('card') は常に最初の要素しか返さない
const card = document.getElementById('card');
const clone = card.cloneNode(true);
// 方法①: id を削除する(参照が不要なら)
clone.removeAttribute('id');
// 方法②: 連番の id を付ける
let cloneCount = 1;
function cloneCard() {
const clone = card.cloneNode(true);
clone.id = `card-${++cloneCount}`;
// 子要素の id も再帰的に変更する(必要な場合)
clone.querySelectorAll('[id]').forEach(el => {
el.id = `${el.id}-${cloneCount}`;
});
document.body.appendChild(clone);
}
cloneNode(true) は子要素に付いた id もすべてコピーします。clone.querySelectorAll('[id]') で子要素の id 一覧を取得して、同様に処理してください。id を参照するラベルの for 属性や ARIA 属性も対応が必要です。イベントリスナーは複製されない:解決策
cloneNode() は addEventListener() で登録したイベントリスナーを引き継ぎません。クローン後にボタンを押しても何も起こらない、という問題はこれが原因です。
// 元の要素にイベントを登録
const btn = document.getElementById('btn');
btn.addEventListener('click', () => alert('クリック!'));
// クローンにはイベントリスナーが引き継がれない
const clone = btn.cloneNode(true);
document.body.appendChild(clone);
// clone をクリックしても alert は発火しない
function cloneWithEvent(original, container) {
const clone = original.cloneNode(true);
clone.removeAttribute('id');
// クローンの中のボタンにイベントを再登録
clone.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', handleClick);
});
container.appendChild(clone);
}
function handleClick(e) {
console.log('クリック:', e.currentTarget.textContent);
}
// クローンの都度イベントを登録する代わりに、親要素で委譲する
// → クローンが何個増えても addEventListener を追加しなくてよい
const list = document.getElementById('list');
list.addEventListener('click', (e) => {
// クリックされたのが .delete-btn なら削除
if (e.target.classList.contains('delete-btn')) {
e.target.closest('.list-item').remove();
}
// クリックされたのが .edit-btn なら編集
if (e.target.classList.contains('edit-btn')) {
const item = e.target.closest('.list-item');
console.log('編集:', item.dataset.id);
}
});
- 動的に追加した要素にも自動で適用される(クローンのたびに addEventListener 不要)
- リスナーを何十個も登録せずに済むのでメモリ効率がよい
- 後から追加した要素でも親のリスナーが動作する
template 要素を使ったテンプレートパターン
複製する「元ネタ」を HTML 内に用意しておきたい場合、<template> 要素が最適です。<template> の中身はブラウザがレンダリングせず、CSS も適用されないため、ページ表示に影響を与えません。
<!-- template の中身は画面に表示されない -->
<template id="card-template">
<div class="card">
<h3 class="card-title"></h3>
<p class="card-body"></p>
<button class="delete-btn">削除</button>
</div>
</template>
<!-- カードを追加するボタン -->
<div id="card-list"></div>
<button id="add-card">カードを追加</button>
const template = document.getElementById('card-template');
const cardList = document.getElementById('card-list');
let cardIndex = 0;
document.getElementById('add-card').addEventListener('click', () => {
// template の中身を複製(template.content が DocumentFragment)
const clone = template.content.cloneNode(true);
// 中身を書き換えてから挿入
cardIndex++;
clone.querySelector('.card-title').textContent = `カード ${cardIndex}`;
clone.querySelector('.card-body').textContent = `これは ${cardIndex} 枚目のカードです`;
clone.querySelector('.card').dataset.id = cardIndex;
cardList.appendChild(clone);
});
// イベント委譲で削除を管理
cardList.addEventListener('click', (e) => {
if (e.target.classList.contains('delete-btn')) {
e.target.closest('.card').remove();
}
});
template.content は DocumentFragment オブジェクトです。cloneNode(true) で複製すると、中のすべての要素がコピーされた新しい DocumentFragment が返ります。appendChild() すると中身が DOM に挿入され、DocumentFragment 自体はコンテナとして残りません。実践:フォームに行を動的に追加する
実務で最もよく使う「テーブルフォームに行を追加する」パターンを template と cloneNode で実装します。
<table id="item-table">
<thead>
<tr><th>商品名</th><th>数量</th><th>操作</th></tr>
</thead>
<tbody id="item-body">
<tr class="item-row">
<td><input type="text" name="item_name[]" placeholder="商品名"></td>
<td><input type="number" name="qty[]" min="1" value="1"></td>
<td><button type="button" class="remove-row">削除</button></td>
</tr>
</tbody>
</table>
<button type="button" id="add-row">行を追加</button>
const tbody = document.getElementById('item-body');
const addBtn = document.getElementById('add-row');
// 最初の行をテンプレートとして使う
const rowTemplate = tbody.querySelector('.item-row');
addBtn.addEventListener('click', () => {
const clone = rowTemplate.cloneNode(true);
// 入力値をクリア(元の行の値がコピーされてしまうため)
clone.querySelectorAll('input').forEach(input => {
input.value = input.type === 'number' ? '1' : '';
});
tbody.appendChild(clone);
});
// イベント委譲で削除ボタンを管理
tbody.addEventListener('click', (e) => {
if (!e.target.classList.contains('remove-row')) return;
// 行が1つだけの場合は削除しない
if (tbody.querySelectorAll('.item-row').length <= 1) return;
e.target.closest('.item-row').remove();
});
cloneNode(true) は HTML 属性の value をコピーしますが、ユーザーが入力した値(DOM プロパティの .value)も一緒にコピーされます。新しい行には入力済み値が残らないよう input.value = '' でリセットしてください。cloneNode と innerHTML の使い分け
要素の複製・生成には cloneNode() のほかに innerHTML や insertAdjacentHTML() を使う方法もあります。
| 手法 | メリット | デメリット・注意点 |
|---|---|---|
cloneNode(true) |
既存の DOM 構造を素早く複製できる。XSS リスクなし | イベントリスナーは引き継がない。id の重複に注意 |
template.content.cloneNode(true) |
HTML と JS を分離できる。最も可読性が高い | template 要素の定義が別途必要 |
innerHTML = |
テンプレート文字列で柔軟に書ける | ユーザー入力を含む場合 XSS リスクあり。毎回パースされる |
createElement() |
プログラムで構造を完全制御できる | 要素が多いとコードが長くなる |
// innerHTML は文字列でHTMLを生成できるが、ユーザー入力が混入すると XSS になる
function addCardHTML(title, body) {
const safeTitle = title.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
const safeBody = body.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
cardList.insertAdjacentHTML('beforeend', `
<div class="card">
<h3 class="card-title">${safeTitle}</h3>
<p class="card-body">${safeBody}</p>
<button class="delete-btn">削除</button>
</div>
`);
}
// cloneNode を使う場合は textContent で設定するため XSS リスクがない
function addCardClone(title, body) {
const clone = template.content.cloneNode(true);
clone.querySelector('.card-title').textContent = title; // 自動エスケープ
clone.querySelector('.card-body').textContent = body; // 自動エスケープ
cardList.appendChild(clone);
}
よくある質問(FAQ)
cloneNode() は addEventListener() で登録したイベントリスナーを複製しません。解決策は2つあります。①クローン後に addEventListener() を再登録する、②親要素でイベント委譲(event delegation)を使って e.target で判定する。複数のクローンが増える場合はイベント委譲が効率的です。cloneNode() は独立したコピーを返すため、クローンを変更しても元の要素に影響はありません。ただし要素に紐づいた JS のオブジェクト参照(element.myData = { ... } のようなカスタムプロパティ)は複製されず、元オブジェクトへの参照が共有されます。こうしたデータは複製後に別途設定するか、data- 属性として HTML 側に持たせてください。style 属性はそのまま複製されます。ただし element.style.color = 'red' のように JS でインラインスタイルを設定していた場合、その値も複製されます。スタイルシートのルールは HTML 属性ではなくブラウザの CSS エンジンが適用するため、クラスが同じなら自動的に同じスタイルになります。document.createTextNode(element.textContent) で元の要素のテキスト内容をコピーした新しいテキストノードを作れます。要素ごとコピーしたくない場合はこちらを使います。単純にテキストの内容だけ欲しい場合は element.textContent で文字列として取得し、別の要素に .textContent = ... で設定するのが最もシンプルです。DocumentFragment にクローンをまとめてから1回の appendChild() で挿入するとリフローの回数を抑えられます。const frag = document.createDocumentFragment() でフラグメントを作り、frag.appendChild(clone) を繰り返した後、container.appendChild(frag) で一括挿入します。詳細はcreateDocumentFragment() を使った効率的な要素の追加方法を参照してください。まとめ
| やりたいこと | 方法 |
|---|---|
| 子要素ごと複製 | element.cloneNode(true) |
| 外枠だけ複製 | element.cloneNode(false) |
| id 重複を防ぐ | クローン後に clone.removeAttribute('id') または連番付与 |
| イベントを引き継ぐ | クローン後に addEventListener 再登録、またはイベント委譲 |
| HTML テンプレートから複製 | <template> + template.content.cloneNode(true) |
| XSS を防ぎつつ内容を設定 | textContent で値をセット(innerHTML を使わない) |
| 複数クローンをまとめて挿入 | DocumentFragment に集めてから appendChild() |
要素の移動と複製を組み合わせた操作については複数の要素を移動・複製する方法も参照してください。