CMSで出力されたコンテンツに後からリンクを付けたい、特定クラスの要素を一括でクリッカブルにしたい、というケースはよくあります。JavaScriptでは「要素をアンカーリンクで囲む」操作をDOM操作で実現できますが、重複ラップの防止・URLの安全な設定・外部リンクへの属性付与など考慮点が複数あります。
この記事では基本実装から重複防止・data属性連携・XSS対策・応用パターンまで体系的に解説します。
特定クラスの要素をアンカーリンクで囲む基本実装
要素を <a> タグで囲む操作は「wrapする」と呼びます。JavaScriptのネイティブDOMメソッドで実装します。
<!-- .wrap-link クラスを持つ要素がリンクで囲まれる対象 --> <div class="wrap-link" data-href="https://example.com/page1"> コンテンツA </div> <p class="wrap-link" data-href="https://example.com/page2"> コンテンツB </p> <img class="wrap-link" src="thumbnail.jpg" alt="サムネイル" data-href="https://example.com/image.jpg">
/**
* 要素を <a> タグで囲む関数
* @param {Element} el - ラップ対象の要素
* @param {string} href - リンク先URL
* @param {object} opts - オプション(target, rel など)
*/
function wrapWithAnchor(el, href, opts = {}) {
const anchor = document.createElement('a');
anchor.href = href;
if (opts.target) anchor.target = opts.target;
if (opts.rel) anchor.rel = opts.rel;
// el の位置に anchor を挿入し、el を anchor の子にする
el.replaceWith(anchor); // anchor が el の位置に入る
anchor.appendChild(el); // el を anchor の中に移動
}
// .wrap-link を持つ全要素にリンクを付ける
document.querySelectorAll('.wrap-link').forEach(el => {
wrapWithAnchor(el, 'https://example.com/', {
target: '_blank',
rel: 'noopener noreferrer',
});
});
el.replaceWith(anchor)で、DOMツリー上のelの位置にanchorを置き換え挿入anchor.appendChild(el)で、elをanchorの子として移動
この2ステップにより <a href="..."><元の要素></a> という構造が完成します。
古いブラウザ(IE11)に対応する必要がある場合は insertBefore を使います。
function wrapWithAnchorLegacy(el, href) {
const anchor = document.createElement('a');
anchor.href = href;
// 1. el の親から見て el の直前に anchor を挿入
el.parentNode.insertBefore(anchor, el);
// 2. el を anchor の子として移動(自動的に元の位置から切り離される)
anchor.appendChild(el);
}
既にアンカーが存在する場合の重複防止
DOMContentLoaded 以外のタイミング(SPAのルーティング後、動的コンテンツ追加後)でも実行する場合、既にラップ済みの要素を二重にラップしないよう確認が必要です。
function wrapWithAnchor(el, href, opts = {}) {
// 既に <a> タグの中に含まれていたらスキップ
if (el.closest('a')) {
console.warn('既にアンカーに囲まれているためスキップ:', el);
return false;
}
const anchor = document.createElement('a');
anchor.href = href;
if (opts.target) anchor.target = opts.target;
if (opts.rel) anchor.rel = opts.rel;
el.replaceWith(anchor);
anchor.appendChild(el);
return true;
}
function wrapWithAnchor(el, href, opts = {}) {
// 既に処理済みの要素はスキップ
if (el.dataset.wrapped === 'true') return false;
if (el.closest('a')) return false;
const anchor = document.createElement('a');
anchor.href = href;
if (opts.target) anchor.target = opts.target;
if (opts.rel) anchor.rel = opts.rel;
el.replaceWith(anchor);
anchor.appendChild(el);
// 処理済みマークを付ける
el.dataset.wrapped = 'true';
return true;
}
<a> タグの入れ子は禁止HTML Living Standard では、
<a> 要素の子孫に別の <a> 要素を含めることはできません。el.closest("a") で確認せずにラップすると、ブラウザが自動的にDOMを修正してしまい意図しない構造になります。data属性でhrefを動的に指定する
要素ごとにリンク先が異なる場合、HTML側に data-href 属性でURLを持たせておくと柔軟に対応できます。
<div class="card wrap-link" data-href="https://example.com/product/1"> <img src="product1.jpg" alt="商品A"> <p>商品A - ¥1,980</p> </div> <div class="card wrap-link" data-href="https://example.com/product/2" data-target="_blank"> <img src="product2.jpg" alt="商品B"> <p>商品B - ¥2,980</p> </div>
document.querySelectorAll('.wrap-link').forEach(el => {
const href = el.dataset.href;
const target = el.dataset.target || '_self';
if (!href) {
console.warn('data-href が未設定の要素をスキップ:', el);
return;
}
const opts = { target };
if (target === '_blank') opts.rel = 'noopener noreferrer';
wrapWithAnchor(el, href, opts);
});
target="_blank" のみでは、開いた新しいタブから元のページを window.opener で参照・操作できるセキュリティリスクがあります。rel="noopener noreferrer" を付けることで window.opener への参照を切り、安全に外部リンクを開けます。URLのサニタイズとXSS対策
href に外部から取得した値や data 属性の値をそのまま設定すると、javascript: プロトコルを注入されるリスクがあります。
// BAD: data-href に javascript:alert(1) が入るとXSSになる const href = el.dataset.href; anchor.href = href; // 危険!
/**
* URLが安全かどうかを検証する
* @param {string} url
* @returns {string|null} - 安全なURLまたは null
*/
function sanitizeUrl(url) {
if (!url) return null;
try {
const parsed = new URL(url, location.origin);
// http: / https: / / (相対パス)のみ許可
if (!['http:', 'https:'].includes(parsed.protocol)) {
console.warn('許可されていないプロトコル:', parsed.protocol);
return null;
}
return parsed.href;
} catch {
// URL のパースに失敗した場合は相対パスとして扱う
if (url.startsWith('/') || url.startsWith('./') || url.startsWith('../')) {
return url;
}
return null;
}
}
// 使用例
document.querySelectorAll('.wrap-link').forEach(el => {
const safeHref = sanitizeUrl(el.dataset.href);
if (!safeHref) return; // 無効なURLはスキップ
wrapWithAnchor(el, safeHref, { target: '_blank', rel: 'noopener noreferrer' });
});
href="javascript:alert(document.cookie)" のような値をセットするとクリック時に任意のスクリプトが実行されます。特にCMSやAPIから動的にURLを取得する場合は sanitizeUrl() を必ず通すようにしましょう。なお setAttribute("href", url) も anchor.href = url も等価なので、どちらでもサニタイズは必要です。複数クラスや条件で対象要素をフィルタリングする
querySelectorAll のCSSセレクタと配列フィルタを組み合わせて、ラップする要素を細かく制御できます。
// 複数クラスを持つ要素(AND条件)
document.querySelectorAll('.card.wrap-link')
// 特定属性を持つ要素のみ
document.querySelectorAll('.wrap-link[data-href]')
// 特定の親要素の配下のみ
document.querySelectorAll('#product-list .wrap-link')
// :not() で除外
document.querySelectorAll('.wrap-link:not(.no-link)')
const targets = [...document.querySelectorAll('.wrap-link')]
.filter(el => {
// 非表示要素を除外
if (el.offsetParent === null) return false;
// disabled クラスを持つ要素を除外
if (el.classList.contains('disabled')) return false;
// data-href が空でないもののみ
if (!el.dataset.href?.trim()) return false;
return true;
});
targets.forEach(el => {
const safeHref = sanitizeUrl(el.dataset.href);
if (safeHref) wrapWithAnchor(el, safeHref);
});
完成形:AnchorWrapper クラス
これまでの実装をまとめたクラスです。サムネイル画像のリンク化・カードUIのクリッカブル化・MutationObserverによる動的要素への対応まで含みます。
class AnchorWrapper {
#selector;
#opts;
#observer;
/**
* @param {string} selector - 対象要素のCSSセレクタ
* @param {object} opts - オプション
* @param {string} opts.hrefAttr - href を取得するdata属性名(デフォルト: 'href')
* @param {string} opts.targetAttr - target を取得するdata属性名(デフォルト: 'target')
* @param {string} opts.defaultTarget - デフォルトの target 値
* @param {boolean} opts.observe - MutationObserver で動的追加要素を監視するか
*/
constructor(selector, opts = {}) {
this.#selector = selector;
this.#opts = {
hrefAttr: opts.hrefAttr ?? 'href',
targetAttr: opts.targetAttr ?? 'target',
defaultTarget: opts.defaultTarget ?? '_self',
observe: opts.observe ?? false,
};
this.#wrapAll(document);
if (this.#opts.observe) {
this.#startObserving();
}
}
/** ルート以下の対象要素を全ラップ */
#wrapAll(root) {
root.querySelectorAll(this.#selector).forEach(el => this.#wrap(el));
}
/** 1要素をラップ */
#wrap(el) {
if (el.closest('a')) return; // 既にアンカー内
if (el.dataset.wrapped === 'true') return; // 処理済み
const rawHref = el.dataset[this.#opts.hrefAttr];
const safeHref = this.#sanitize(rawHref);
if (!safeHref) return;
const target = el.dataset[this.#opts.targetAttr] ?? this.#opts.defaultTarget;
const anchor = document.createElement('a');
anchor.href = safeHref;
anchor.target = target;
if (target === '_blank') anchor.rel = 'noopener noreferrer';
el.replaceWith(anchor);
anchor.appendChild(el);
el.dataset.wrapped = 'true';
}
/** URLを検証して安全なURLを返す */
#sanitize(url) {
if (!url) return null;
try {
const parsed = new URL(url, location.origin);
if (!['http:', 'https:'].includes(parsed.protocol)) return null;
return parsed.href;
} catch {
return (url.startsWith('/') || url.startsWith('./') || url.startsWith('../'))
? url : null;
}
}
/** MutationObserver で動的追加要素を監視 */
#startObserving() {
this.#observer = new MutationObserver(mutations => {
for (const m of mutations) {
m.addedNodes.forEach(node => {
if (node.nodeType !== 1) return; // Elementのみ
if (node.matches(this.#selector)) this.#wrap(node);
this.#wrapAll(node); // 追加要素の子孫も処理
});
}
});
this.#observer.observe(document.body, { childList: true, subtree: true });
}
/** 監視停止 */
disconnect() {
this.#observer?.disconnect();
}
}
// 使用例
document.addEventListener('DOMContentLoaded', () => {
new AnchorWrapper('.wrap-link[data-href]', {
defaultTarget: '_blank',
observe: true, // SPA・動的コンテンツ対応
});
});
- Reactや Vue で動的にレンダリングされるコンポーネント内の要素
- 無限スクロールで追加されるカードリスト
- CMS のスロットに後から注入されるコンテンツ
observe: true にするとDOM追加を検知して自動的にラップを実行します。不要な場合は false(デフォルト)にしてパフォーマンスを節約してください。
実用パターン:サムネイル画像のリンク化
CMSで出力された <img> 要素を、画像の src 属性から拡大表示リンクに変換する典型的なパターンです。
<!-- CMS が出力した画像(リンクなし) --> <figure class="thumb-wrap"> <img src="https://example.com/images/photo1.jpg" alt="写真1" class="wrap-to-link"> </figure> <figure class="thumb-wrap"> <img src="https://example.com/images/photo2.jpg" alt="写真2" class="wrap-to-link"> </figure>
document.querySelectorAll('img.wrap-to-link').forEach(img => {
// 既にリンク内にある場合はスキップ
if (img.closest('a')) return;
const src = img.src; // 絶対URLが取得できる
// サムネイルURLから元画像URLへ変換(例: -thumb を除去)
const fullUrl = src.replace(/-thumb(\.[\w]+)$/, '$1');
const anchor = document.createElement('a');
anchor.href = fullUrl;
anchor.target = '_blank';
anchor.rel = 'noopener noreferrer';
// lightbox ライブラリ向け属性付与も可
anchor.dataset.lightbox = 'gallery';
img.replaceWith(anchor);
anchor.appendChild(img);
});
よくある質問(FAQ)
el.replaceWith(anchor) の後 anchor.appendChild(el) でDOM位置が変わるため、元の要素へのイベントリスナーは生きています。ただし、アンカーのデフォルト動作(リンク遷移)が優先されるため、el に付けた click リスナーはアンカーをクリックすると遷移が先に起きます。遷移を止めて処理したい場合は anchor.addEventListener("click", e => e.preventDefault()) を使ってください。<a> の中に <div> を入れてもいいですか?<a> の content model は「transparent」であるため、内側にブロック要素(<div> など)を含めることができます。ただし、<a> 自体が inline-level のため、CSSで display: block か display: inline-block を指定して見た目を整えることをおすすめします。querySelector は最初にマッチした1要素のみ返し、querySelectorAll は条件に合う全要素を NodeList で返します。複数の要素をまとめてラップするときは querySelectorAll を使います。詳しくはquerySelectorAll の使い方を参照してください。<a> タグをあとから外す(unwrap)するには?anchor の親として元の要素を取り出す逆操作を行います。anchor.replaceWith(...anchor.childNodes) でアンカーを子ノード群に置き換えることができます。複数の子要素がある場合は DocumentFragment を使うとより安全です。まとめ
| 要件 | 実装ポイント |
|---|---|
要素を <a> で囲む基本 |
el.replaceWith(anchor) → anchor.appendChild(el) |
| IE11対応が必要 | parentNode.insertBefore(anchor, el) → anchor.appendChild(el) |
| 重複ラップ防止 | el.closest("a") と el.dataset.wrapped で確認 |
| 要素ごとに URL を変える | data-href 属性からURLを取得 |
| XSS対策 | new URL(url) でプロトコルを検証・http:/https: のみ許可 |
| 外部リンク | target="_blank" に rel="noopener noreferrer" を必ず追加 |
| 動的コンテンツ対応 | MutationObserver で追加要素を監視して自動ラップ |
要素のDOM操作全般については要素を移動させる方法(appendChild・insertBefore 完全ガイド)もあわせて参照してください。また、テキスト文字列を特定のHTMLタグで囲む方法については文字列をHTMLタグで囲む完全ガイドで詳しく解説しています。
