dev-memo
Tags Activity
Svelte 5のbind:にはletが必須 2026/2/7
svelte

Svelte 5でbind:ディレクティブを使う場合、バインド先の変数はletで宣言する必要がある。constだとコンパイルエラーになる。

<script>
  // NG: constだとbind:できない
  const name = $state('');

  // OK: letで宣言
  let name = $state('');
</script>

<input bind:value={name} />

Svelte 4ではexport letだったが、Svelte 5では$props()に変わったため、バインディング周りの挙動も変わっている。

.jpドメインのCloudflare移行は慎重に — NS変更 vs レコード追加 2025/7/17
cloudflare dns domain infrastructure

.jpドメインとCloudflareの制約

  • .jp / .co.jp はCloudflare Registrarに移管不可(非対応TLD)
  • できるのはNS変更(DNS管理のみCFへ) のみ
  • .jp のNS変更はJPRS経由のため反映に24〜72時間かかる(.comは数時間)
  • 問題発生時の切り戻しも同様に遅い

CF Pages vs Firebase App Hosting(ドメイン設定の差)

ホスティングドメイン設定方法リスク
CF PagesNS変更が必須(CF DNS経由のみ)メール不達・ダウンタイムリスクあり
Firebase App HostingAレコード2つ + TXT追加のみほぼゼロ

判断基準

  • 既にFirebase資産(Firestore, Functions)を使っているなら、Firebase App Hostingの方がドメイン移行コストが圧倒的に低い
  • CF Pagesを使う明確な理由(Workers活用、プレビューデプロイ重視等)がなければ、.jpドメインではNS変更を避けた方が安全
  • NS変更する場合のチェック: TTL事前短縮 → 全レコード目視確認(特にMX/SPF/DKIM) → 低トラフィック時間帯に実施
Astro移行の可行性と戦略分析 2025/1/15
astro next-js migration architecture

現状技術スタック

フロントエンド: Next.js 14.2.35 (App Router) + React 18.3.1 + TypeScript 5.x スタイリング: Tailwind CSS v3 + SCSS Module(microCMSリッチテキスト用) 状態管理: Zustand(2ストア) CMS: microCMS BaaS: Firebase(Firestore, Functions, Auth) デプロイ: Google App Engine

ページ構成: 約50ページ(LeanGoコーポレート、dejamサービス、ad-future)、動的ルート多数

移行のネック項目

項目影響度課題解決策
ISR (revalidateTag)高Next.js固有、microCMS Webhook対応が必要Astro APIルートでWebhook受信→オンデマンドリビルド
breakpoint検出高useLayoutEffect でwindowサイズ判定、全体ローディングCSSメディアクエリで代替、JS削減可能
Firestoreクライアント中/dejam/website/ のみclient:load Island に分離
Firebase Functions中httpsCallable(‘sendMail’)Astro APIエンドポイント or SSRで代替
HubSpotフォーム中クライアント専用インタラクションAstro Componentで Island化

Astro採用の優位性

Astro Islands パターン:

  • ページ全体をJSで制御せず、インタラクティブ必須部分のみを「島」として独立
  • ページの大部分は静的HTMLで高速配信、フォーム/カルーセル等のみJS送信

Next.jsと比較した利点:

  1. ファイル形式がHTML的 → デザイナーが読める
  2. 概念が少ない → HTML + Tailwind で完結、React hooks/Server-Client判断不要
  3. Claude Code との相性 → 生成コードがシンプル、副作用少、スコープ局所
  4. 役割分離が自然 → 静的部分(デザイナー) vs Islands/API(エンジニア)
  5. サイト特性に合致 → 90%以上が静的 → ゼロJS配信で最高速

データフェッチング戦略

  • microCMS: Content Collections + ISR相当(オンデマンドビルド)
  • JSON: readFileSync → Astro loaders に置換
  • Firestore: Client Islands のみ(useEffect内で直接呼び出し)
  • Firebase Functions: Astro SSR APIエンドポイントに移行

デザイナー・エンジニア作業領域分離

領域対象担当
静的マークアップ + Tailwindほぼ全ページデザイナー(.astro)
Islands(React/Svelte)フォーム、カルーセル、Firestoreエンジニア
API, デプロイ, CMS連携機能実装エンジニア

リスク・注意点

  • テスト未整備 → 移行時のリグレッション検知は手動確認に依存
  • GAEデプロイ対応 → Node adapter 検証が必須
  • breakpoint検出の移行 → CSSメディアクエリ設計の見直し

結論

大半が静的生成可能で、クライアントJS が限定的。Astro Islands は本件の要件と完全マッチ。ビルドキャッシング戦略の設計が鍵。

React脱却: Astro + vanilla JS への変換テクニック集 2025/1/15
astro vanilla-js svelte react migration

概要

Phase 5.8で React 19 を完全脱却し、19コンポーネントを Astro + vanilla JS / Svelte 5 に移植。削除パッケージ数:10+、バンドルサイズ削減:30% 以上。

変換パターン別の実装解説

パターン1: React の状態管理 → data-* 属性 + vanilla JS

React の useState + useEffect を以下のように置き換え:

Before(React):

const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
  if (isOpen) document.body.style.overflow = 'hidden';
}, [isOpen]);

After(Astro + vanilla JS):

<button id="sp-menu-toggle" aria-expanded="false">...</button>
<nav id="sp-menu-overlay" class="hidden" />

<script>
  const toggle = document.getElementById('sp-menu-toggle');
  const overlay = document.getElementById('sp-menu-overlay');
  let isOpen = false;

  toggle.addEventListener('click', () => {
    isOpen = !isOpen;
    if (isOpen) {
      overlay.classList.remove('hidden');
      overlay.classList.add('flex');
      document.body.style.overflow = 'hidden';
    } else {
      overlay.classList.add('hidden');
      document.body.style.overflow = '';
    }
  });
</script>

Key points:

  • Props を data-* 属性で受け渡し(const formId = container.dataset.formId)
  • クロージャで状態管理(モジュールスコープの let 変数)
  • document.addEventListener('astro:after-swap', initFunction) で View Transitions 対応

パターン2: React hooks → vanilla JS(body scroll lock, IntersectionObserver)

useBodyScrollLock の置き換え:

function lockScroll(): void {
  document.documentElement.style.overflowY = 'hidden';
  document.body.style.overflowY = 'hidden';
  document.body.style.position = 'fixed';
  document.body.style.width = '100%';
}

function unlockScroll(): void {
  document.documentElement.style.overflowY = '';
  document.body.style.overflowY = '';
  document.body.style.position = '';
  document.body.style.width = '';
}

IntersectionObserver の直接使用:

// 追従CTA: FV要素が画面外に出たら表示
const container = document.getElementById('dejam-following-cta');
const target = document.getElementById('first-view');

window.addEventListener('scroll', () => {
  const { bottom } = target.getBoundingClientRect();
  if (bottom < 0) {
    container.classList.remove('hidden');
    container.classList.add('flex');
  } else {
    container.classList.add('hidden');
    container.classList.remove('flex');
  }
}, { passive: true });

パターン3: embla-carousel-react → vanilla embla-carousel

useEmblaCarousel の廃止:

<div class="carousel">
  <div class="carousel__viewport">
    <ul class="carousel__container">
      {imgs.map((src) => <li><img src={src} /></li>)}
    </ul>
  </div>
  <ul class="carousel__dots">
    {imgs.map((_, i) => <li class="dot" data-index={i} />)}
  </ul>
</div>

<script>
  import EmblaCarousel from 'embla-carousel';
  import Autoplay from 'embla-carousel-autoplay';

  function initCarousel(root) {
    const viewport = root.querySelector('.carousel__viewport');
    const embla = EmblaCarousel(viewport, { loop: true }, [Autoplay()]);
    const dots = [...root.querySelectorAll('.dot')];

    function updateDots() {
      const selected = embla.selectedScrollSnap();
      dots.forEach((dot, i) => {
        dot.classList.toggle('bg-primary', i === selected);
      });
    }

    embla.on('select', updateDots);
    dots.forEach((dot) => {
      dot.addEventListener('click', () => {
        const index = Number(dot.dataset.index);
        embla.scrollTo(index);
      });
    });
  }

  document.querySelectorAll('.carousel').forEach(initCarousel);
</script>

Key points:

  • EmblaCarousel(viewport, options, plugins) で直接初期化
  • embla.on('select', callback) でイベントリスナー登録
  • embla.scrollTo(index) / scrollPrev() / scrollNext() で操作

パターン4: 複雑な UI 状態管理(PC/SP ヘッダー)

Zustand store → クロージャ変数:

function initDejamHeaderPC() {
  const wrapper = document.querySelector('[data-dejam-header-pc]');
  const dropdownPanels = wrapper.querySelectorAll('[data-dropdown-panel]');
  const navTriggers = wrapper.querySelectorAll('[data-nav-trigger]');

  let activeDropdownId = ''; // ← クロージャで状態管理

  function showDropdown(id) {
    activeDropdownId = id;
    dropdownPanels.forEach((panel) => {
      if (panel.dataset.dropdownPanel === id) {
        panel.classList.remove('hidden');
      } else {
        panel.classList.add('hidden');
      }
    });
    updateNavStyles();
  }

  function closeDropdown() {
    activeDropdownId = '';
    dropdownPanels.forEach((p) => p.classList.add('hidden'));
    updateNavStyles();
  }

  // mouseenter / mouseleave リスナー
  navTriggers.forEach((trigger) => {
    trigger.addEventListener('mouseenter', () => {
      showDropdown(trigger.dataset.navTrigger);
    });
  });

  wrapper.addEventListener('mouseleave', closeDropdown);
}

initDejamHeaderPC();
document.addEventListener('astro:after-swap', initDejamHeaderPC);

SP メニューのネストされたアコーディオン:

// 各ドロップダウントリガーをループで登録
dropdownTriggers.forEach((trigger) => {
  trigger.addEventListener('click', () => {
    const dropdownId = trigger.dataset.spDropdownTrigger;
    const content = root.querySelector(`[data-sp-dropdown-content="${dropdownId}"]`);
    const isExpanded = trigger.getAttribute('aria-expanded') === 'true';

    if (isExpanded) {
      content.classList.add('hidden');
      trigger.setAttribute('aria-expanded', 'false');
      icon.src = '/images/icon/icon_plus_primary.svg';
    } else {
      content.classList.remove('hidden');
      trigger.setAttribute('aria-expanded', 'true');
      icon.src = '/images/icon/icon_minus_primary.svg';
    }
  });
});

パターン5: react-hook-form → Svelte 5 Runes

React フォーム複雑化時は Svelte 5 を採用。理由:Runes による洗練された状態管理。

Svelte 5 の $state / $derived:

<script>
  import { z } from 'zod';

  // Props
  const { turnstileSiteKey } = $props();

  // State
  let companyName = $state('');
  let email = $state('');
  let fieldErrors = $state({});
  let isSubmitting = $state(false);

  // Derived(自動再計算)
  const formData = $derived({
    companyName,
    email,
    // ...
  });

  const isButtonDisabled = $derived(
    isSubmitting || !turnstileToken || !termCheckbox
  );

  // Validation(immutable pattern)
  function validateField(fieldId) {
    const result = contactSchema.safeParse(formData);
    if (result.success) {
      const { [fieldId]: _, ...rest } = fieldErrors;
      fieldErrors = rest; // 新オブジェクト生成
      return;
    }
    fieldErrors = { ...fieldErrors, [fieldId]: issue.message };
  }

  // Event handlers
  function handleInput(fieldId, event) {
    const target = event.target;
    switch (fieldId) {
      case 'companyName': companyName = target.value; break;
      case 'email': email = target.value; break;
    }
    if (touched[fieldId]) validateField(fieldId);
  }

  async function handleSubmit(event) {
    event.preventDefault();
    if (!validateAllFields()) return;

    isSubmitting = true;
    try {
      await sendContact({ companyName, email }, turnstileToken);
      window.location.href = '/complete/';
    } finally {
      isSubmitting = false;
    }
  }
</script>

<form onsubmit={handleSubmit}>
  {#each FORM_FIELDS as field}
    <input
      value={companyName}
      oninput={(e) => handleInput('companyName', e)}
      onblur={() => handleBlur('companyName')}
    />
    {#if fieldErrors.companyName}
      <p>{fieldErrors.companyName}</p>
    {/if}
  {/each}
  <button disabled={isButtonDisabled} type="submit">送信</button>
</form>

Why Svelte for forms:

  • $state + $derived で React の useState + useMemo を1行で表現
  • oninput / onchange で直接バインディング(event delegation 不要)
  • Zod スキーマとの統合が簡潔

共通の工夫ポイント

1. astro:after-swap 対応(View Transitions)

function init() { /* 初期化処理 */ }
init();
document.addEventListener('astro:after-swap', init);

ClientRouter でページ遷移時も再初期化が必要。

2. esbuild パースエラー回避

フロントマター内で HTML タグを使わない:

---
// これは NG(dev 時 esbuild がパースエラー)
/**
 * <script>alert('xss')</script>
 */

// これは OK(// コメント使用)
// - ドロップダウン表示
// - hover イベント管理
---

3. data-* 属性で Props 受け渡し

<div data-carousel data-autoplay="true" data-loop="false">
  ...
</div>

<script>
  const carousel = document.querySelector('[data-carousel]');
  const autoplay = carousel.dataset.autoplay === 'true';
  const loop = carousel.dataset.loop === 'true';
</script>

4. client: ディレクティブ不要

vanilla JS は自動的にクライアント実行。<script is:inline> で SSR をスキップ可能。

削除した npm パッケージ

react 19
react-dom 19
@react-aria/*(17 dependency)
embla-carousel-react → embla-carousel に統一
zustand(2つの store のみ利用、残すのは検討中)

バンドルサイズ削減:

  • Before: ~180KB (gzipped)
  • After: ~125KB (gzipped)
  • 削減: 30%

実装チェックリスト

各コンポーネント移植時:

  • data-* 属性で Props 受け渡し
  • astro:after-swap イベントリスナー登録
  • TypeScript 型定義(HTMLElement など)
  • null guard で TS narrowing(クロージャ内で確実に)
  • classList で class 切り替え(immutable pattern)
  • aria-* 属性で a11y 対応
  • { passive: true } で scroll リスナー最適化

まとめ

Reactvanilla JS / Svelte
useStateクロージャ変数 / $state
useEffectaddEventListener / onMount
useMemo$derived
Propsdata-* 属性 + dataset
Store(Zustand)クロージャ変数
Hook(embla)直接 SDK 呼び出し
Event delegation直接 addEventListener

結論: React を脱却することで、ビルド時間短縮・バンドルサイズ削減・保守性向上を同時実現。Astro + vanilla JS は小規模な UI インタラクションに最適。複雑な state 管理が必要な場合のみ Svelte 5 を検討。

← 前へ 6 / 6 次へ →
© 2026