BLOG

「動く」だけで満足しないためのJavaScript実装メモ

Written by naganoma

INDEX

昨今、Claude CodeやGemini CLIといったエージェント機能を持つ生成AIによって、開発スタイルのあり方が劇的に進化しています。
ただ、いくら生成AIが進化しているといえど、時にアンチパターンとなるコードが出力されてしまうケースもしばしば見られます。

今回はこのような慌ただしい状況だからこそ、あえて基本に立ち返ろうということで、JavaScriptを書く上で個人的に大事にしているポイントについてご紹介します。

1. フォールバック編

コーポレートサイトなど複数ページを実装する場合において、「他のページで使用している処理がそのページ内では使用されないためにエラーとなる」といったパターンは回避する必要があります。

例えば、以下のコードでは、.js-sampleが存在しないページでクリックイベントが発火した場合に、.js-sampleが存在しないために.is-activeを付与する処理が実行できないというエラーが発生してしまいます。

const sampleElement = document.querySelector('.js-sample')

document.addEventListener('click', () => {
  // ページ上にsampleElementが存在しない場合はエラー
  sampleElement.classList.add('is-active')
})

回避策としては様々な方法がありますが、個人的によく使うのは以下の2つです。

① 早期リターン

.js-sampleが存在しない場合は、以降の処理を全て実行しないとする記法です。
仕様によっては適さないケースもありますが、最も直感的なアプローチとなります。

const sample = document.querySelector('.js-sample')
// sampleが存在しない場合は処理を終了 
if(!sample) return

document.addEventListener('click', () => {
  sample.classList.add('is-active')
})

② オプショナルチェーン演算子

今回のclassListのようなプロパティにアクセスする際に、そのプロパティの前に?を付けることで、アクセスするオブジェクト(sample)が undefined または nullの場合は式を途中で終了させることができます。

const sample = document.querySelector('.js-sample')

document.addEventListener('click', () => {
  // sampleがnullやundefinedの場合に何もしない
  sample?.classList.add('is-active')
});

2. DOM取得編

①スコープ内での取得

DOM(Document Object Model)上に存在する特定の要素は、Document オブジェクトからquerySelectorgetElementByIdgetElementsByClassNameなどのメソッドを呼び出すことで取得できます。
ただし、getElementByIdを除くこれらのメソッドが、Document オブジェクト以外にElementオブジェクトからも呼び出せることは意外と知られていません。

例えば、以下は.js-sample内に.js-sample-el.js-sample-btnを子要素として内包しているケースにおける処理です。

const sample = document.querySelector('.js-sample')
const sampleElement = document.querySelector('.js-sample-el')
const sampleButton = document.querySelector('.js-sample-btn')

sampleButton.addEventListener('click', () => {
  sampleElement.classList.toggle('is-active')
});

上記でも動作上は問題ありませんが、例えば.js-sampleの子要素以外で.js-sample-el.js-sample-btnがDOM上に存在する場合に意図した挙動にならない可能性があります。
よって、このケースでは1行目で取得したElementオブジェクト(sample)経由で子要素を取得した方が処理的にも安全です。

const sample = document.querySelector('.js-sample')
// sample内で処理をスコープ化
const sampleElement = sample.querySelector('.js-sample-el')
const sampleButton = sample.querySelector('.js-sample-btn')

sampleButton.addEventListener('click', () => {
  sampleElement.classList.toggle('is-active');
})

また親要素経由で取得することでDOM上の探索範囲も絞られるため、若干のパフォーマンス改善も期待できます。

② 一意性

先ほどDOM上の要素を取得する際のメソッドについて触れましたが、中でもgetElementByIdについてはid属性はDOM上で一意(重複しない)という仕様から、一般的に処理速度が速いとされています。

const sampleElement = document.getElementById('sample-el')
const sampleButton = document.getElementById('sample-btn')

sampleButton.addEventListener('click', () => {
  sampleElement.classList.toggle('is-active')
})

ただし、DOM取得を全てgetElementByIdで補うのは適切とは言えません。
例えば上記の機能が仕様変更により、同じページ上に複数配置する必要性が出てきた際に同じid属性は1つしか存在できないという点で詰んでしまいます。

よって、基本的にはquerySelectorquerySelectorAll)で取得し、一意であることが確定している要素のみgetElementByIdを使用するのが安全です。
個人的にはサイト内の共通ヘッダーやフッターといった、FLOCSSにおけるLayoutレイヤーに該当する要素に対しての使用を推奨しています。

// 共通ヘッダーはgetElementByIdで取得
const header = document.getElementById('header')

document.addEventListener('click', () => {
  header.classList.toggle('is-active');
})

3. レスポンシブ編

JavaScriptのレスポンシブ対応において、個人的に最も生成AIがやりがちだと感じるのが、リサイズイベントを用いた処理です。

const responsiveInit = () => {
  if (window.innerWidth >= 768) {
    console.log('768px以上の処理') 
  }
  else {
    console.log('768px未満の処理')
  } 
}

// 初期実行
responsiveInit()

// リサイズ時に再判定
window.addEventListener('resize', () => {
  responsiveInit()
})

上記は昔ながらの記法で、画面サイズが変わるたびにresponsiveInitの評価が走ってしまうため、パフォーマンス的に負荷が高くなってしまいます。

現在はwindow.matchMediaによりCSSのメディアクエリと同じような記法が可能なため、こちらを使用したレスポンシブ対応が推奨されています。

// メディアクエリの登録
const mediaQuery = window.matchMedia('(min-width: 768px)')

const responsiveInit = () => {
  if (mediaQuery.matches) {
    console.log('768px以上の処理')
  } else {
    console.log('768px未満の処理')
  }
}

// 初期実行
responsiveInit()

// メディアクエリが切り替わった際に再判定
mediaQuery.addEventListener('change', () => {
  responsiveInit()
})

注意点として、responsiveInitの実行のみだとページ初期化時の画面幅のみが判定対象になるため、初期化後も画面幅の変更を検知したい場合はchangeイベントでメディアクエリの変更を監視する必要があります。

【補足】 メディアクエリ判定時の一括リロードについて

上記のchangeイベント内で逐一処理を再実行する書き方が手間となる場合、メディアクエリが切り替わるタイミングでページをリロードする手法も考えられます。

const mediaQuery = window.matchMedia('(min-width: 768px)')

const responsiveInit = () => {
  if (mediaQuery.matches) {
    console.log('768px以上の処理')
  } else {
    console.log('768px未満の処理')
  }
}

// 初期実行
responsiveInit()

mediaQuery.addEventListener('change', () => {
  // リロードして再判定
  location.reload()
})

リロード時に自動的に処理の再評価が走るため、逐一changeイベントで呼び出す必要がなくなります。
ただ、ページ全体をリロードするのは一定のリスク(フォームの入力内容がリセットされるなど)があるため、個人的にはあまり推奨していません。

4. リサイズ編

① 特定要素のサイズ変更を検知

リサイズイベントを検知する際は、一般的にresizeイベントを用いて処理を実行します。
以下は共通ヘッダーの高さを取得してCSSのカスタムプロパティに格納する処理です。

const header = document.getElementById('header')

// ヘッダーの高さを取得してCSSのカスタムプロパティに格納
const setHeaderHeight = () => {
  const headerHeight = header.offsetHeight
  document.documentElement.style.setProperty('--header-height', `${headerHeight}px`)
}

// 初期実行
setHeaderHeight()

// 画面リサイズ時に再実行
window.addEventListener('resize', () => {
  setHeaderHeight()
})

上記は一見問題ないように見えますが、実はリサイズイベント以外でページ内のコンテンツ幅が変わった場合は変更を検知できないという問題があります。
例えばブラウザ設定の「フォントサイズ変更」や、「スクロールバーの非表示→常時表示化」といった要因で共通ヘッダー内の文字が折り返され、高さが変わるというケースも考えられます。

このようなケースでは、windowオブジェクトのサイズ変更ではなく、ResizeObserverを用いて要素(共通ヘッダー)自体のサイズ変更を検知する必要があります。

// ...略

setHeaderHeight()

// resizeObserverの初期化
const resizeObserver = new ResizeObserver(() => {
  setHeaderHeight()
})

// リサイズの監視開始(windowではなく、headerのサイズが変わったかどうかで判定)
resizeObserver.observe(header)

②リサイズ処理の実行頻度の制御

リサイズイベントはユーザーが意図的に画面幅を変更した際のみ発生するものと思われがちですが、実は端末によっては思わぬ部分で発火しているケースがあります。
例えば、iOSのSafariではスクロールの方向に応じてアドレスバーが見え隠れする際に、裏側でリサイズイベントが発火しています。

このような状況に備え、リサイズ処理はある程度発火条件を絞った上で実行させるのが通例となっています。

// ...略

setHeaderHeight()

// デバウンス関数
const debounce = (callback, timeout) => {
  let timer
  timeout = timeout !== undefined ? timeout : 300
  return () => {
    const context = this
    const args = arguments
    clearTimeout(timer)
    timer = setTimeout(() => {
      callback.apply(context, args)
    }, timeout)
  }
}

// 300ms経過するまでに複数回リサイズを検知しても一度しか実行しない
const resizeObserver = new ResizeObserver(debounce(() => {
  setHeaderHeight();
}, 300))

resizeObserver.observe(header)

上記では、300ms(0.3秒)内で複数回リサイズイベントが発火したとしても、リサイズ時の処理は1度のみとする条件を追加しています。このような処理を一般的にデバウンス処理と呼びます。

【補足1】 デバウンス関数について

先ほどご紹介したデバウンス関数はsetTimeoutを用いて待機時間を指定していましたが、requestAnimationFrameを用いることでディスプレイのリフレッシュレートに同期して待機時間を動的に決定することもできます。
(※60Hz ディスプレイで 約16.67ms)

// requestAnimationFrame版
const debounce = (callback) => {
  let timeout
  return function (...args) {
    if (timeout !== undefined) cancelAnimationFrame(timeout)
    timeout = requestAnimationFrame(() => callback.apply(this, args))
  }
}

【補足2】 リサイズ方向の検知について

デバウンスとは別のアプローチとして、特定方向(横or縦)のリサイズ時のみ実行させる手法もあります。

// ...略

// ヘッダーの高さを一時保存
let previousHeight = header.offsetHeight

const resizeObserver = new ResizeObserver((entries) => {
  const entry = entries[0]
  // 現在のヘッダーの高さを取得
  const currentHeight = entry.contentRect.height

  // ヘッダーの高さが変わった場合のみ発火
  if (currentHeight !== previousHeight) {
    setHeaderHeight()
    previousHeight = currentHeight
  }
})

resizeObserver.observe(header)

例えば、先ほど例に挙げたiOSはスクロール時に縦方向のリサイズが発火するため、横方向にリサイズした場合のみ実行できれば良い場合は、横方向の変更時のみ呼び出すことで実行頻度を軽減できます。

5. アニメーション編

① スムーススクロール

アンカーリンク(<a href="#*">)を実装する際、一般的にはクリック時に対象のセクションまでスムーズにスクロールされる動作(スムーススクロール)が求められます。
よくある実装方法としては、以下のようなwindow.scrollToメソッドを用いた手法です。

const anchorLink = document.querySelectorAll("a[href^='#']")

for (const link of anchorLink) {
  link.addEventListener('click', (e) => {
    e.preventDefault()
    const hash = link.hash
    const target = document.getElementById(decodeURIComponent(hash.slice(1)))
    const position = target.getBoundingClientRect().top + window.scrollY
    window.scrollTo({
      top: position,
      behavior: 'smooth'
    })
    history.pushState(null, '', hash)
  })
}

上記でも動作上は問題ありませんが、window.scrollToメソッドはCSSのscroll-margin-topがスクロール停止位置に適用されず、常にtopで指定した位置となるため、そのままでは拡張性が乏しいという懸念があります。
特定の要素のみスクロール停止位置のオフセットを大きくor小さくしたいというのは、しばしばあることなので、この例においてはscrollIntoViewメソッドを使用して書き直すのが適切です。

const anchorLink = document.querySelectorAll("a[href^='#']")

for (const link of anchorLink) {
  link.addEventListener('click', (e) => {
    e.preventDefault()
    const hash = link.hash
    const target = document.getElementById(decodeURIComponent(hash.slice(1)))
    // scrollIntoViewメソッドを使用
    target.scrollIntoView({
      block: 'start',
      behavior: 'smooth',
    })
    history.pushState(null, '', hash)
  })
})

上記はブロック方向の上部(端末の画面方向が縦の場合は要素のy=0の部分)で停止しますが、CSSで該当のid属性を持つ要素に対して、scroll-margin-topを指定することでオフセット値を柔軟に制御することができます。

[id] {
  scroll-margin-top: 80px;
}

② アニメーションの待機

ローディング画面などの演出を実装する際、「特定のアニメーションの完了を検知したい」という場面がしばしばあります。

最も直感的な書き方は、アニメーションの実行時間分を遅延させて次の処理を実行させる手法です。

const animationElement = document.querySelector('.js-animation')

// .is-animationのtransition-durationに0.3sを設定している想定
animationElement.classList.add('is-animation')

// アニメーション時間の分待機してクラスを外す
setTimeout(() => {
  animationElement.classList.remove('is-animation')
}, 300)

ただ、setTimeoutによる待機はいかなる環境であろうと指定された秒数待機するため、例えば何らかの理由で端末に負荷がかかり、アニメーションの描画に時間がかかってしまうと、アニメーションが完了する前に次の処理が実行されてしまう可能性があります。

CSSアニメーションについては、transitionendanimationendといった完了を検知するイベントが存在するため、これらを適切に使用することが推奨されています。

const animationElement = document.querySelector('.js-animation')

animationElement.classList.add('is-animation')

// CSS transitionの完了を検知してクラスを削除
animationElement.addEventListener('transitionend', () => {
  animationElement.classList.remove('is-animation')
}, { once: true })

【補足】 transition / animation双方の完了を検知

transitionendanimationendは同じ機能を持つわけではなく、CSSのtransitionプロパティ、animationプロパティのどちらの完了を検知するかで使い分ける必要があります。
また、CSS上でtransitionプロパティ、animationプロパティの指定がない状態でこれらのイベントを呼び出すとエラーになってしまいます。

仕様変更によってtransitionanimationに書き換えたり、アニメーション自体を削除するというケースがしばしばあるため、transitionanimation の双方に対応させつつ、CSSに対象のプロパティがなくてもエラーとならない構成とするのがベストです。
現状では、Animationオブジェクトからアニメーションの完了を検知して実行する手法が最適だと考えています。

// アニメーションの完了まで待機する関数
const waitAnimation = async (element) => {
  const animations = element.getAnimations()
  if (animations.length === 0) {
    return
  } else {
    await Promise.allSettled(animations.map((animation) => animation.finished))
  }
}

const animationElement = document.querySelector('.js-animation')
animationElement.classList.add('is-animation')
// 完了まで待機
await waitAnimation(animationElement)
// 完了したらこの行以降の処理が実行される
animationElement.classList.remove('is-animation')

③ スクロールアニメーション

「画面内に特定の要素が表示された場合にフェードインさせる」といったアニメーションの実装方法には様々な手法があります。

最も一般的なのは、GSAPのScrollTriggerなどの「要素の座標」をトリガーとした実装です。

import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)

const targetElement = document.querySelector('.js-target');

// 画面内に入ったら'is-active'クラスを付与、出た時に削除
const scrollTriggerInstance = ScrollTrigger.create({
  trigger: targetElement,
  start: 'top top',
  end: 'bottom bottom',
  toggleClass: "is-active",
});

動作上は問題ありませんが、「特定の要素が見えたタイミングでクラスを付与する」という処理にScrollTriggerのようなライブラリを使用するのはオーバースペックなため、InterSectionObserver APIに置き換えるのが適切です。

const targetElement = document.querySelector('.js-target');

// IntersectionObserverの初期化
const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    entry.target.classList.toggle('is-active', entry.isIntersecting)
  }
}, 
{
  rootMargin: '0px',
})

// 監視開始
observer.observe(targetElement);

細かい計算をブラウザに委ねているため、パフォーマンス面に関しても圧倒的に軽くなります。

【補足】 スクロール系ライブラリの導入基準

スクロール系ライブラリの問題点については以下の通りです(※あくまで個人の見解です)

  • bundle sizeが大きい(上記の処理で114KB)
  • scrollイベント、resizeイベントなどの負荷の高い処理が多数
  • 発火の検知が座標ベース(getBoundingClientRect)のため、計算量が多い
  • 時代的に使わなくなった機能が多い(GSAP ScrollTriggerの場合はtoggleClasspinなど)

ScrollTriggerにおけるscrubのようなスクロール量に応じた演出に使用する分には適していますが、それ以外では経験上、InterSectionObserver APIで事足りるケースが多いと感じています。

6. イベントリスナー編

①リスナー内の処理

昨今の生成AIではあまり見られなくなりましたが、少し前まではイベントリスナー内に処理を詰め込む記述形式がしばしば見られました。

const button = document.querySelector('.js-button');
let isOpen = false;

button.addEventListener('click', () => {
  isOpen = !isOpen;
  // イベントリスナー内で要素を取得
  const modal = document.querySelector('.js-modal');

  // イベントリスナー内で関数を定義
  const toggleModal = (isOpen) => {
    document.body.classList.toggle('is-fixed', isOpen)
    modal.classList.toggle('is-open', isOpen);
    modal.setAttribute('aria-hidden', !isOpen);
    button.setAttribute('aria-expanded', isOpen);
  }

  toggleModal(isOpen);
});

変数modalや関数toggleModalclickイベント内で定義していますが、この記述形式だとクリックするたびにこれらが再定義され、無意味にメモリを圧迫してしまうため、明らかなアンチパターンとなります。

clickに留まらずscrollmousemoveなど、イベントリスナー内で実行する処理は最小限とし、スコープ的に外に出せるものはイベントリスナー外で宣言するのが適切です。

const button = document.querySelector('.js-button');
const modal = document.querySelector('.js-modal')
let isOpen = false

const toggleModal = (isOpen) => {
  document.body.classList.toggle('is-fixed', isOpen)
  modal.classList.toggle('is-open', isOpen)
  modal.setAttribute('aria-hidden', !isOpen)
  button.setAttribute('aria-expanded', isOpen)
}

// イベントリスナー内の記述は最小限に
button.addEventListener('click', () => {
  isOpen = !isOpen
  toggleModal(isOpen)
})

② イベント破棄

VueやReactといったSPAフレームワークを使用する際、イベントリスナーを登録する際は、イベント破棄の処理も合わせて記述するという暗黙のルールがあります。

イベント破棄はremoveEventListenerメソッドで実行できますが、ここでよく陥りがちなのが破棄する処理の参照元を間違えてしまうという点です。

const button = document.querySelector('.js-button');

const hello = () => {
  console.log('Hello, World!');
}

const goodbye = () => {
  console.log('Goodbye, World!');
}

button.addEventListener('click', () => {
  hello();
  goodbye();
});

// イベント破棄(静かに失敗)
button.removeEventListener('click', () => {
  hello();
  goodbye();
});

JavaScriptは、原始値(文字列、数字、Symbol等)以外のオブジェクトを作成する際、処理内容をメモリに保管し、メモリのどこに保管されているのかを示すアドレス(住所)を返します。

この辺りは難しい部分ですが、上記例ではhello()goodbye()という実行内容は同じではあるものの、処理を保管しているメモリのアドレスが異なるため、addEventListenerの実行内容=removeEventListenerでの実行内容とはなりません。

この例では、実行する関数を1箇所にまとめることでメモリのアドレスが共通化され、イベント破棄に成功します。

// ...略

// メモリの参照先を統一
const handleClick = () => {
  hello();
  goodbye();
}

button.addEventListener('click', handleClick);

// 成功
button.removeEventListener('click', handleClick);

また、イベントリスナーの参照を逐一管理するのは難易度が高いため、MDNではAbortControllerでの管理が推奨されています。

// ...略

const abortController = new AbortController()

button.addEventListener('click', () => {
  hello()
  goodbye()
}, 
{ 
 // AbortControllerに参照を保存
  signal: abortController.signal 
})

window.addEventListener('scroll', () => {
  console.log('scrolling')
}, 
{ 
  // AbortControllerに参照を保存
  signal: abortController.signal 
})

// イベント破棄
abortController.abort()

addEventListenerの第3引数であるsignalAbortSignalオブジェクトを紐づけることでリスナー内の処理の参照をabortControllerに持たせることができます。
あとはイベントを破棄する際にabortメソッドを実行するだけです。

また、AbortControllerでは複数のイベントリスナーとの紐づけが可能なため、個人的にはremoveEventListenerよりも扱いやすくオススメです。

参考文献

オプショナルチェーン演算子 (?.) - JavaScript | MDN

ResizeObserver - Web API | MDN

Element: getAnimations() メソッド - Web API | MDN

IntersectionObserver - Web API | MDN

JSのレスポンシブ対応をresizeからmatchMediaに移行した - Zenn

Debounce と Throttle(JavaScript)- Web Design Leaves

スムーススクロールの実装例 - TAKLOG

AbortControllerでEventListenerを取り外す方法 - Qiita