JavaScriptのDOM操作完全ガイド|要素の取得・作成・追加・削除から動的HTML生成まで


はじめに

前回の記事ではJavaScriptのクラスとオブジェクト指向プログラミングの基本としてconstructorやメソッドの書き方・継承・プライベートフィールドを解説しました。今回はJavaScriptのDOM操作を徹底解説します。「JavaScriptでHTMLを動的に変更したい」「ボタンを押したらリストにアイテムを追加したい」「APIから取得したデータをページに表示したい」という場面はすべてDOM操作で実現します。基本から実践的なパターンまでしっかり解説しますので、ぜひ手を動かしながら読み進めてください。


1. DOMとは何か

1.1 DOMの基本概念

DOM(Document Object Model)とは:

HTMLのドキュメントをJavaScriptから
操作できるようにしたツリー構造のオブジェクト。

ブラウザはHTMLを読み込むと
DOMツリーを作成する。
JavaScriptはこのDOMツリーを通じて
HTMLを読み取ったり・変更したりできる。

HTMLとDOMの関係:

<body>                    → document.body
  <header>               → headerElement
    <h1>タイトル</h1>    → h1Element
    <nav>                → navElement
      <a>ホーム</a>      → anchorElement
    </nav>
  </header>
  <main>                 → mainElement
    <p>本文</p>          → paragraphElement
  </main>
</body>

→ すべてのHTML要素がJavaScriptのオブジェクトとして扱える
→ プロパティを変更するとページの表示も変わる

2. 要素を取得する

2.1 querySelector・querySelectorAll

/*
  querySelector:CSSセレクターで要素を1つ取得する
  querySelectorAll:CSSセレクターで要素を複数取得する
*/

// タグ名で取得
const h1    = document.querySelector( 'h1' );
const links = document.querySelectorAll( 'a' );

// クラスで取得
const card  = document.querySelector( '.card' );
const cards = document.querySelectorAll( '.card' );

// IDで取得
const main  = document.querySelector( '#main' );

// 属性で取得
const input = document.querySelector( '[type="email"]' );
const forms = document.querySelectorAll( '[data-validate]' );

// 組み合わせ
const navLinks  = document.querySelectorAll( '.nav a' );
const activeBtn = document.querySelector( '.btn.is-active' );
const firstCard = document.querySelector( '.card-list .card:first-child' );

// 特定の要素の中から検索する(スコープを絞る)
const section  = document.querySelector( '.news-section' );
const newsTitle = section.querySelector( 'h2' ); // sectionの中だけ検索

/*
  querySelector の注意点:

  → 見つからない場合は null を返す
  → null のままメソッドを呼ぶとエラーになる

  ❌ 危険な書き方
  document.querySelector( '.btn' ).addEventListener( 'click', fn );
  // → .btn が存在しないとエラー

  ✅ 安全な書き方
  const btn = document.querySelector( '.btn' );
  if ( btn ) {
      btn.addEventListener( 'click', fn );
  }

  // またはオプショナルチェーン
  document.querySelector( '.btn' )?.addEventListener( 'click', fn );
*/

2.2 querySelectorAllの使い方

// querySelectorAll は NodeList を返す
const items = document.querySelectorAll( '.list-item' );

// forEach で1件ずつ処理する
items.forEach( ( item ) => {
    item.classList.add( 'is-initialized' );
} );

// NodeListを配列に変換するとより多くのメソッドが使える
const itemsArray = Array.from( items );

// または スプレッド構文で変換
const itemsArray2 = [ ...items ];

// 変換後はmap・filter・reduceなども使える
const activeItems = itemsArray.filter(
    ( item ) => item.classList.contains( 'is-active' )
);

const itemTexts = itemsArray.map( ( item ) => item.textContent.trim() );

3. 要素の内容を変更する

3.1 textContent・innerHTML・innerText

const el = document.querySelector( '.message' );

/*
  textContent:
  → テキストだけを取得・変更する
  → HTMLタグはテキストとして扱われる(XSS対策になる)
  → 非表示の要素のテキストも含む
  → 最も安全で高速・基本的にこれを使う

  innerHTML:
  → HTML含めて取得・変更する
  → タグとして解釈される
  → ユーザー入力をそのまま渡すのは危険(XSS)

  innerText:
  → 画面に表示されているテキストだけを取得
  → CSSで非表示の要素は含まない
  → textContent より処理が遅い
*/

// ✅ テキストの変更(推奨)
el.textContent = '新しいメッセージ';

// ✅ HTMLを含む変更(信頼できるデータのみ)
el.innerHTML = '<strong>太字のメッセージ</strong>';

// ❌ ユーザー入力をそのままinnerHTMLに渡すのはNG
// el.innerHTML = userInput; // XSSの危険あり

// ✅ ユーザー入力はtextContentで安全に扱う
el.textContent = userInput; // タグとして解釈されない

3.2 テンプレートリテラルでHTMLを生成する

/*
  テンプレートリテラル(バッククォート)を使うと
  複数行のHTMLを変数と組み合わせて
  読みやすく書ける。
*/

// 投稿データ
const post = {
    title:     'JavaScriptの非同期処理',
    date:      '2024年3月15日',
    category:  'JavaScript',
    excerpt:   'Promiseとasync/awaitの使い方を解説します。',
    thumbnail: 'thumbnail.jpg',
    url:       '/posts/async-js',
};

// テンプレートリテラルでHTMLを生成する
const postCard = `
    <article class="card">
        <a href="${ post.url }" class="card-image">
            <img
                src="${ post.thumbnail }"
                alt="${ post.title }"
                width="400"
                height="225"
                loading="lazy"
            >
        </a>
        <div class="card-body">
            <span class="card-category">${ post.category }</span>
            <h2 class="card-title">
                <a href="${ post.url }">${ post.title }</a>
            </h2>
            <p class="card-excerpt">${ post.excerpt }</p>
            <time class="card-date">${ post.date }</time>
        </div>
    </article>
`;

// DOMに挿入する
document.querySelector( '.post-list' ).innerHTML += postCard;

// ============================================

// 配列からカードリストを生成する
const posts = [
    { id: 1, title: '記事1', category: 'CSS',        date: '2024/03/01' },
    { id: 2, title: '記事2', category: 'JavaScript', date: '2024/03/10' },
    { id: 3, title: '記事3', category: 'HTML',       date: '2024/03/15' },
];

const postsHTML = posts
    .map( ( post ) => `
        <article class="card" data-id="${ post.id }">
            <span class="category">${ post.category }</span>
            <h2>${ post.title }</h2>
            <time>${ post.date }</time>
        </article>
    ` )
    .join( '' ); // 配列を文字列に連結する

document.querySelector( '.post-list' ).innerHTML = postsHTML;

4. 要素を作成・追加・削除する

4.1 createElement で要素を作成する

/*
  createElement:
  新しいHTML要素をJavaScriptで作成する。

  innerHTML より安全で
  イベントリスナーを追加しやすい。
*/

// 要素を作成する
const li = document.createElement( 'li' );

// テキストを設定する
li.textContent = '新しいアイテム';

// クラスを追加する
li.classList.add( 'list-item' );

// 属性を設定する
li.setAttribute( 'data-id', '123' );

// リストに追加する
const ul = document.querySelector( '.todo-list' );
ul.appendChild( li ); // 末尾に追加

// ============================================

// リンク要素を作成する
const link = document.createElement( 'a' );
link.href        = 'https://example.com';
link.textContent = 'リンクテキスト';
link.target      = '_blank';
link.rel         = 'noopener noreferrer';
link.classList.add( 'external-link' );

// イベントリスナーを追加する
link.addEventListener( 'click', ( e ) => {
    console.log( 'リンクがクリックされました' );
} );

document.querySelector( '.links' ).appendChild( link );

4.2 要素の追加位置を制御する

const list = document.querySelector( '.list' );
const newItem = document.createElement( 'li' );
newItem.textContent = '新しいアイテム';

// 末尾に追加
list.appendChild( newItem );
list.append( newItem );      // 複数・文字列も追加できる

// 先頭に追加
list.prepend( newItem );

// 特定の要素の前後に追加
const thirdItem = list.querySelector( 'li:nth-child(3)' );
thirdItem.before( newItem ); // 前に追加
thirdItem.after( newItem );  // 後に追加

// insertAdjacentHTML(位置を文字列で指定)
list.insertAdjacentHTML( 'beforeend',  '<li>末尾に追加</li>' );
list.insertAdjacentHTML( 'afterbegin', '<li>先頭に追加</li>' );
list.insertAdjacentHTML( 'beforebegin','<p>リストの前に追加</p>' );
list.insertAdjacentHTML( 'afterend',   '<p>リストの後に追加</p>' );

/*
  insertAdjacentHTML の位置の指定:

  <!-- beforebegin -->
  <ul class="list">
    <!-- afterbegin -->
    <li>既存のアイテム</li>
    <!-- beforeend -->
  </ul>
  <!-- afterend -->
*/

4.3 要素の削除・置き換え

// 要素を削除する
const item = document.querySelector( '.item-to-remove' );

// 現在の主流の方法
item.remove();

// 子要素をすべて削除する
const list = document.querySelector( '.list' );
list.innerHTML = ''; // 高速だが XSSに注意

// 安全に全削除する
while ( list.firstChild ) {
    list.removeChild( list.firstChild );
}

// 要素を置き換える
const oldEl = document.querySelector( '.old-element' );
const newEl = document.createElement( 'div' );
newEl.className   = 'new-element';
newEl.textContent = '新しい要素';

oldEl.replaceWith( newEl );

// 要素のコピーを作成する
const original = document.querySelector( '.card' );
const clone    = original.cloneNode( true ); // true = 子要素ごとコピー
clone.querySelector( '.card-title' ).textContent = 'コピーされたカード';
document.querySelector( '.card-list' ).appendChild( clone );

5. 属性・クラス・スタイルの操作

5.1 属性の操作

const img = document.querySelector( '.hero-image' );

// 属性の取得
const src = img.getAttribute( 'src' );
const alt = img.getAttribute( 'alt' );

// 属性の変更
img.setAttribute( 'src', 'new-image.jpg' );
img.setAttribute( 'alt', '新しい画像の説明' );

// 属性の削除
img.removeAttribute( 'loading' );

// 属性の存在確認
if ( img.hasAttribute( 'data-loaded' ) ) {
    console.log( '読み込み済み' );
}

// プロパティとして直接アクセスする(より高速)
img.src   = 'new-image.jpg';
img.alt   = '新しい画像';
img.width = 800;

// data属性の操作
const card = document.querySelector( '.card' );
// <div class="card" data-id="123" data-category="javascript">

const id       = card.dataset.id;       // '123'
const category = card.dataset.category; // 'javascript'

card.dataset.id       = '456';       // 値の変更
card.dataset.isLoaded = 'true';      // 新しいdata属性を追加
// → data-is-loaded="true" が追加される(キャメルケース→ケバブケース)

5.2 クラスの操作

const el = document.querySelector( '.btn' );

// クラスの追加
el.classList.add( 'is-active' );
el.classList.add( 'is-active', 'is-visible', 'is-loaded' ); // 複数一度に

// クラスの削除
el.classList.remove( 'is-active' );

// クラスの付け外し(toggle)
el.classList.toggle( 'is-open' );
el.classList.toggle( 'is-open', true );  // 強制的にON
el.classList.toggle( 'is-open', false ); // 強制的にOFF

// クラスの確認
if ( el.classList.contains( 'is-active' ) ) {
    console.log( 'アクティブ状態です' );
}

// クラスの置き換え
el.classList.replace( 'btn-primary', 'btn-secondary' );

// すべてのクラスを取得(DOMTokenList)
console.log( el.classList ); // DOMTokenList ['btn', 'is-active']
console.log( [ ...el.classList ] ); // 配列に変換 ['btn', 'is-active']

5.3 スタイルの操作

const box = document.querySelector( '.box' );

// インラインスタイルを直接変更する
box.style.backgroundColor = '#1A5FC0';  // background-color → camelCase
box.style.fontSize        = '1.6rem';
box.style.transform       = 'translateX( 100px )';

// 複数のスタイルを一度に変更する
Object.assign( box.style, {
    backgroundColor: '#1A5FC0',
    padding:         '24px',
    borderRadius:    '8px',
} );

// スタイルをリセットする
box.style.backgroundColor = ''; // 空文字でインラインスタイルを削除

// 計算済みスタイルを取得する(CSSから適用されたスタイルも含む)
const computed = getComputedStyle( box );
const color    = computed.color;
const width    = computed.width;
console.log( width ); // '320px'(px単位の文字列)

/*
  ⚠️ スタイルを直接変更するよりも
  クラスの付け外しで対応する方が推奨:

  → CSSとJSの責務が分離される
  → スタイルの一元管理がしやすい
  → パフォーマンスも良い

  ✅ 推奨
  el.classList.toggle( 'is-highlighted' );

  .is-highlighted {
      background-color: yellow;
      font-weight: bold;
  }
*/

6. 実践パターン集

6.1 Todoリストの実装

class TodoList {
    #todos  = [];
    #nextId = 1;

    constructor( selector ) {
        this.el       = document.querySelector( selector );
        this.input    = this.el.querySelector( '.todo-input' );
        this.addBtn   = this.el.querySelector( '.todo-add' );
        this.list     = this.el.querySelector( '.todo-list' );
        this.countEl  = this.el.querySelector( '.todo-count' );

        this.#init();
    }

    #init() {
        this.addBtn.addEventListener( 'click', () => this.#add() );

        this.input.addEventListener( 'keydown', ( e ) => {
            if ( e.key === 'Enter' ) this.#add();
        } );

        // イベント委譲でリストを管理する
        this.list.addEventListener( 'click', ( e ) => {
            const item = e.target.closest( '.todo-item' );
            if ( !item ) return;

            const id = Number( item.dataset.id );

            if ( e.target.classList.contains( 'todo-check' ) ) {
                this.#toggle( id );
            }
            if ( e.target.classList.contains( 'todo-delete' ) ) {
                this.#delete( id );
            }
        } );
    }

    #add() {
        const text = this.input.value.trim();
        if ( !text ) return;

        const todo = {
            id:        this.#nextId++,
            text,
            completed: false,
            createdAt: new Date(),
        };

        this.#todos.push( todo );
        this.input.value = '';
        this.#render();
    }

    #toggle( id ) {
        const todo = this.#todos.find( ( t ) => t.id === id );
        if ( todo ) todo.completed = !todo.completed;
        this.#render();
    }

    #delete( id ) {
        this.#todos = this.#todos.filter( ( t ) => t.id !== id );
        this.#render();
    }

    #render() {
        // リストを再描画する
        this.list.innerHTML = this.#todos
            .map( ( todo ) => `
                <li
                    class="todo-item ${ todo.completed ? 'is-completed' : '' }"
                    data-id="${ todo.id }"
                >
                    <button class="todo-check" aria-label="完了にする">
                        ${ todo.completed ? '✓' : '' }
                    </button>
                    <span class="todo-text">${ this.#escape( todo.text ) }</span>
                    <button class="todo-delete" aria-label="削除">×</button>
                </li>
            ` )
            .join( '' );

        // カウントを更新する
        const remaining = this.#todos.filter( ( t ) => !t.completed ).length;
        this.countEl.textContent = `残り ${ remaining } 件`;
    }

    // XSS対策:テキストをエスケープする
    #escape( text ) {
        const div = document.createElement( 'div' );
        div.textContent = text;
        return div.innerHTML;
    }
}

const todoList = new TodoList( '#todo-app' );

6.2 APIからデータを取得してDOMに描画する

class PostList {
    constructor( selector ) {
        this.container = document.querySelector( selector );
        this.#fetchAndRender();
    }

    async #fetchAndRender() {
        this.#renderSkeleton(); // スケルトン表示

        try {
            const response = await fetch(
                'https://jsonplaceholder.typicode.com/posts?_limit=6'
            );

            if ( !response.ok ) {
                throw new Error( `HTTP error: ${ response.status }` );
            }

            const posts = await response.json();
            this.#renderPosts( posts );

        } catch ( error ) {
            this.#renderError( error.message );
        }
    }

    #renderSkeleton() {
        this.container.innerHTML = Array.from( { length: 6 } )
            .map( () => `
                <div class="card card--skeleton">
                    <div class="skeleton-image"></div>
                    <div class="skeleton-text"></div>
                    <div class="skeleton-text skeleton-text--short"></div>
                </div>
            ` )
            .join( '' );
    }

    #renderPosts( posts ) {
        this.container.innerHTML = posts
            .map( ( post ) => `
                <article class="card" data-id="${ post.id }">
                    <div class="card-body">
                        <span class="card-id">#${ post.id }</span>
                        <h2 class="card-title">${ post.title }</h2>
                        <p class="card-body-text">${ post.body }</p>
                    </div>
                </article>
            ` )
            .join( '' );
    }

    #renderError( message ) {
        this.container.innerHTML = `
            <div class="error-state">
                <p class="error-message">
                    データの取得に失敗しました:${ message }
                </p>
                <button class="btn btn-retry">再試行</button>
            </div>
        `;

        this.container.querySelector( '.btn-retry' )
            ?.addEventListener( 'click', () => this.#fetchAndRender() );
    }
}

const postList = new PostList( '#post-list' );

6.3 無限スクロールの実装

class InfiniteScroll {
    #page    = 1;
    #loading = false;
    #hasMore = true;

    constructor( containerSelector, loadFn ) {
        this.container = document.querySelector( containerSelector );
        this.loadFn    = loadFn;

        this.#init();
        this.#loadMore(); // 初回ロード
    }

    #init() {
        // IntersectionObserver でセンチネル要素を監視する
        this.sentinel = document.createElement( 'div' );
        this.sentinel.className = 'scroll-sentinel';
        this.container.after( this.sentinel );

        const observer = new IntersectionObserver(
            ( entries ) => {
                if ( entries[0].isIntersecting && !this.#loading && this.#hasMore ) {
                    this.#loadMore();
                }
            },
            { threshold: 0.1 }
        );

        observer.observe( this.sentinel );
    }

    async #loadMore() {
        this.#loading = true;
        this.#showLoader();

        try {
            const items = await this.loadFn( this.#page );

            if ( items.length === 0 ) {
                this.#hasMore = false;
                this.sentinel.textContent = 'これ以上ありません';
                return;
            }

            items.forEach( ( item ) => {
                const el = document.createElement( 'div' );
                el.className   = 'item';
                el.textContent = item.title;
                this.container.appendChild( el );
            } );

            this.#page++;

        } catch ( error ) {
            console.error( '読み込みエラー:', error );
        } finally {
            this.#loading = false;
            this.#hideLoader();
        }
    }

    #showLoader() {
        this.sentinel.innerHTML = `
            <div class="loader" aria-label="読み込み中">
                <span></span><span></span><span></span>
            </div>
        `;
    }

    #hideLoader() {
        this.sentinel.innerHTML = '';
    }
}

// 使用例
const scroll = new InfiniteScroll( '#content', async ( page ) => {
    const res = await fetch(
        `https://jsonplaceholder.typicode.com/posts?_page=${ page }&_limit=10`
    );
    return res.json();
} );

7. パフォーマンスを意識したDOM操作

7.1 DocumentFragmentで一括追加する

/*
  DOM操作は1回ごとに描画が走るため
  大量に追加する場合はDocumentFragmentを使う。

  DocumentFragmentはメモリ上の仮のコンテナで
  まとめてDOMに追加できる。
*/

const items  = Array.from( { length: 1000 }, ( _, i ) => `アイテム ${ i + 1 }` );
const list   = document.querySelector( '.list' );

// ❌ 遅い:1000回DOMを更新する
items.forEach( ( text ) => {
    const li = document.createElement( 'li' );
    li.textContent = text;
    list.appendChild( li ); // 毎回描画が走る
} );

// ✅ 速い:DocumentFragmentで一括追加する
const fragment = document.createDocumentFragment();

items.forEach( ( text ) => {
    const li = document.createElement( 'li' );
    li.textContent = text;
    fragment.appendChild( li ); // メモリ上の操作
} );

list.appendChild( fragment ); // 描画は1回だけ

7.2 レイアウトスラッシングを避ける

/*
  レイアウトスラッシング:
  読み取りと書き込みを交互に行うと
  ブラウザが何度もレイアウト計算をやり直すため
  パフォーマンスが低下する。
*/

const items = document.querySelectorAll( '.item' );

// ❌ 遅い:読み取りと書き込みが交互に起きる
items.forEach( ( item ) => {
    const height = item.offsetHeight; // ← 読み取り(レイアウト計算)
    item.style.height = height * 2 + 'px'; // ← 書き込み
    // 次のループでまた読み取り → 再計算 → を繰り返す
} );

// ✅ 速い:読み取りをまとめてから書き込む
const heights = [ ...items ].map( ( item ) => item.offsetHeight ); // 読み取りをまとめる
items.forEach( ( item, i ) => {
    item.style.height = heights[i] * 2 + 'px'; // 書き込みをまとめる
} );

8. 実践チェックリスト

✓ document.querySelector で要素を取得するとき null チェックまたはオプショナルチェーン(?.)を使っているか

✓ ユーザーが入力したテキストを表示するとき textContent を使ってXSSを防いでいるか

✓ テンプレートリテラルで複数のデータを map().join('') でHTML文字列に変換できているか

✓ 動的に追加した要素へのイベントは親要素へのイベント委譲で設定しているか

✓ 大量の要素を追加するとき DocumentFragment を使ってパフォーマンスを改善しているか

✓ classList.add / remove / toggle でクラスを操作してスタイルを制御しているか

✓ APIのデータを取得するとき スケルトン表示 → データ表示 → エラー表示 の3状態を実装しているか

✓ data-* 属性と dataset プロパティでHTML要素とJavaScriptのデータを紐付けられているか


まとめ

今回はJavaScriptのDOM操作を基礎から実践パターンまで解説しました。ポイントをまとめると:

  • DOMはHTMLドキュメントをJavaScriptから操作できるツリー構造のオブジェクトで document.querySelector でCSSセレクターと同じ書き方で要素を取得できる
  • テキストの変更は textContent・HTMLを含む変更は innerHTML を使う・ユーザー入力は必ず textContent で安全に扱う
  • テンプレートリテラルと map().join('') の組み合わせで配列データから動的なHTMLを生成するパターンが実務でよく使われる
  • createElement で要素を作成して appendChild / append / prepend / insertAdjacentHTML で位置を指定して追加する
  • classList.add / remove / toggle でクラスを操作する・スタイルを直接変更するよりクラスの付け外しで対応する方が推奨
  • dataset プロパティで data-* 属性を操作してHTML要素とJavaScriptのデータを紐付ける
  • 動的に追加する要素へのイベントは親要素へのイベント委譲(closest を使ったパターン)で設定する
  • 大量の要素を一括追加するときは DocumentFragment を使うと描画が1回で済みパフォーマンスが改善する

次の記事では、JavaScriptのエラーハンドリングを解説します。try・catch・finallyの使い方からカスタムエラーの作り方・Promiseのエラーハンドリング・ユーザーへのエラー表示パターンまでお届けします。お楽しみに!

コメント

タイトルとURLをコピーしました