【JavaScript】特定のクラスを持つ要素をアンカーリンクで囲む方法|DOM操作・data属性連携・XSS対策・重複防止まで解説

CMSで出力されたコンテンツに後からリンクを付けたい、特定クラスの要素を一括でクリッカブルにしたい、というケースはよくあります。JavaScriptでは「要素をアンカーリンクで囲む」操作をDOM操作で実現できますが、重複ラップの防止・URLの安全な設定・外部リンクへの属性付与など考慮点が複数あります。

この記事では基本実装から重複防止・data属性連携・XSS対策・応用パターンまで体系的に解説します。

スポンサーリンク

特定クラスの要素をアンカーリンクで囲む基本実装

要素を <a> タグで囲む操作は「wrapする」と呼びます。JavaScriptのネイティブDOMメソッドで実装します。

HTML(囲む対象の要素)
<!-- .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">
基本実装:replaceWith() でラップする
/**
 * 要素を <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',
  });
});
replaceWith() の動作:

  1. el.replaceWith(anchor) で、DOMツリー上の el の位置に anchor を置き換え挿入
  2. anchor.appendChild(el) で、elanchor の子として移動

この2ステップにより <a href="..."><元の要素></a> という構造が完成します。

古いブラウザ(IE11)に対応する必要がある場合は insertBefore を使います。

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のルーティング後、動的コンテンツ追加後)でも実行する場合、既にラップ済みの要素を二重にラップしないよう確認が必要です。

closest() で祖先の <a> を確認して重複防止
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;
}
data-wrapped フラグを使う方法(同じ要素への再実行防止)
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;
}
HTMLの仕様:<a> タグの入れ子は禁止
HTML Living Standard では、<a> 要素の子孫に別の <a> 要素を含めることはできません。el.closest("a") で確認せずにラップすると、ブラウザが自動的にDOMを修正してしまい意図しない構造になります。

data属性でhrefを動的に指定する

要素ごとにリンク先が異なる場合、HTML側に data-href 属性でURLを持たせておくと柔軟に対応できます。

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>
data属性を読み取って一括ラップ
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);
});
data-target=”_blank” に rel=”noopener noreferrer” を自動付与する理由:
target="_blank" のみでは、開いた新しいタブから元のページを window.opener で参照・操作できるセキュリティリスクがあります。rel="noopener noreferrer" を付けることで window.opener への参照を切り、安全に外部リンクを開けます。

URLのサニタイズとXSS対策

href に外部から取得した値や data 属性の値をそのまま設定すると、javascript: プロトコルを注入されるリスクがあります。

NG:URLを検証せずそのままセット
// BAD: data-href に javascript:alert(1) が入るとXSSになる
const href = el.dataset.href;
anchor.href = href;  // 危険!
OK:URLを検証してから設定する
/**
 * 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' });
});
javascript: スキームによるXSS:
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)')
JavaScriptで追加条件フィルタリング
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による動的要素への対応まで含みます。

AnchorWrapper クラス(完成形)
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・動的コンテンツ対応
  });
});
MutationObserver を使う場面:

  • Reactや Vue で動的にレンダリングされるコンポーネント内の要素
  • 無限スクロールで追加されるカードリスト
  • CMS のスロットに後から注入されるコンテンツ

observe: true にするとDOM追加を検知して自動的にラップを実行します。不要な場合は false(デフォルト)にしてパフォーマンスを節約してください。

実用パターン:サムネイル画像のリンク化

CMSで出力された <img> 要素を、画像の src 属性から拡大表示リンクに変換する典型的なパターンです。

HTML(CMS出力の img 要素)
<!-- 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>
src からリンクURLを生成してラップ
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)

Qラップ後に元のクリックイベントが動かなくなりました。
Ael.replaceWith(anchor) の後 anchor.appendChild(el) でDOM位置が変わるため、元の要素へのイベントリスナーは生きています。ただし、アンカーのデフォルト動作(リンク遷移)が優先されるため、el に付けた click リスナーはアンカーをクリックすると遷移が先に起きます。遷移を止めて処理したい場合は anchor.addEventListener("click", e => e.preventDefault()) を使ってください。
Q<a> の中に <div> を入れてもいいですか?
AHTML5(HTML Living Standard)では <a> の content model は「transparent」であるため、内側にブロック要素(<div> など)を含めることができます。ただし、<a> 自体が inline-level のため、CSSで display: blockdisplay: inline-block を指定して見た目を整えることをおすすめします。
QquerySelector vs querySelectorAll の使い分けは?
AquerySelector は最初にマッチした1要素のみ返し、querySelectorAll は条件に合う全要素を NodeList で返します。複数の要素をまとめてラップするときは querySelectorAll を使います。詳しくはquerySelectorAll の使い方を参照してください。
Qラップした <a> タグをあとから外す(unwrap)するには?
Aanchor の親として元の要素を取り出す逆操作を行います。anchor.replaceWith(...anchor.childNodes) でアンカーを子ノード群に置き換えることができます。複数の子要素がある場合は DocumentFragment を使うとより安全です。
QReactコンポーネントの要素は直接DOMを操作していいですか?
AReactが管理するDOMを直接操作するのは推奨されません。Reactが再レンダリングするとDOM操作がリセットされます。ReactではJSX内でリンクを条件付きレンダリングするか、ラッパーコンポーネントを使うのが正しいアプローチです。本記事の方法はReactを使わない通常のHTML+JSの場合に使用してください。

まとめ

要件 実装ポイント
要素を <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タグで囲む完全ガイドで詳しく解説しています。