金曜日, 10月 3, 2025
金曜日, 10月 3, 2025
- Advertisment -
ホームニューステックニュースVue3 + TypeScript で「一度閉じたら二度と出ない」お知らせモーダルを実装する

Vue3 + TypeScript で「一度閉じたら二度と出ない」お知らせモーダルを実装する


対象読者

  • Vue 3(Composition API)と TypeScript でモーダルを実装したい人
  • 「一度閉じたら同端末では再表示しない」を安定して実現したい人
  • Blade 等のサーバテンプレートからユーザーIDをフロントに渡したい人
  • Safari のプライベートブラウズなどで localStorage が使えない場合にも“既読管理”を効かせたい人

この記事では、「初回だけ出す → 閉じたら次回以降は出さない」 告知モーダルを、最小の責務分割で実装します。
localStorage が使えない環境でも Cookie にフォールバックして安定動作させるのがポイントです。
※ 具体的なサービス名・日時・社内クラス名などは一般化しています。


ゴールと要件

  • 初回アクセス時に告知モーダルを表示
  • ユーザーが閉じたら「既読」として記録し、同端末では以後非表示
  • 既読記録は localStorage を優先、失敗時は Cookie を利用
  • ログイン中は ユーザーID単位で既読管理(未ログインは "guest" を共通キーに)
  • 背景スクロールの抑止、Esc キーで閉じる等の基本 UX 対応
  • ESLint no-empty ルールに配慮(空の catch {} を避ける)

全体像


サーバテンプレート(例:Blade)でルート要素と userId を埋め込む
↓
Vue 3(Composition API, TypeScript)

* 初回判定(localStorage → Cookie)
* モーダル開閉状態(ref)
* 閉じたら記録(localStorage → Cookie)
* 背景スクロールロック / Esc で閉じる
  ↓
  SCSS(外観・レイヤ)


1. ルート要素 & ユーザーID受け渡し(例:Blade)

{{-- ルート要素: data-user-id にログインユーザーID(未ログインなら guest) --}}

{{-- ここにモーダルの HTML(または Vue テンプレート)を置く --}}

ポイント

  • id="notice-modal-root" … Vue のマウント先
  • data-user-id … フロント側(TS)から dataset.userId で取得し、ユーザー単位の既読管理に利用
  • サーバ側が Laravel でない場合でも、同様の data 属性埋め込みができればOK

2. TypeScript(Vue 3 Composition API)

import { createApp, ref, onMounted, watch } from "vue";



const VERSION = "v1";
function getStorageKey(userId: string) {
  return `${VERSION}_notice_seen_user_${userId}`;
}


function hasSeen(key: string): boolean {
  try {
    if (localStorage.getItem(key) === "1") return true;
  } catch (_) {
    void 0; 
  }
  
  return document.cookie.split("; ").some((v) => v.startsWith(`${key}=`));
}


function markSeen(key: string): void {
  try {
    localStorage.setItem(key, "1");
    return; 
  } catch (_) {
    void 0; 
  }
  const expires = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toUTCString(); 
  document.cookie = `${key}=1; path=/; expires=${expires}`;
}


const el = document.getElementById("notice-modal-root");
if (el) {
  createApp({
    setup() {
      
      const open = ref(false);

      
      const close = () => {
        open.value = false;
        const userId = (el as HTMLElement).dataset.userId || "guest";
        markSeen(getStorageKey(userId));
      };

      
      const show = () => {
        open.value = true;
      };

      
      onMounted(() => {
        const userId = (el as HTMLElement).dataset.userId || "guest";
        const key = getStorageKey(userId);
        if (!hasSeen(key)) {
          show(); 
        }

        
        const onKey = (e: KeyboardEvent) => {
          if (e.key === "Escape") close();
        };
        window.addEventListener("keydown", onKey);

        
        window.addEventListener("beforeunload", () =>
          window.removeEventListener("keydown", onKey)
        );
      });

      
      watch(
        open,
        (v) => {
          document.body.style.overflow = v ? "hidden" : "";
        },
        { immediate: true }
      );

      return { open, close };
    },
  }).mount(el);
}

役割ごとの要点

  • getStorageKey(userId)
    既読フラグのキーを生成。バージョン文字列を前置しておくと再掲が容易。
  • hasSeen(key)
    既読確認。localStorage → Cookie の順で確認し、どちらかにあれば既読
  • markSeen(key)
    既読保存。localStorage → Cookie の順で保存。ESLint no-empty を避けるため catch { void 0; } を入れる。
  • open / close / show
    open は開閉状態。close既読保存show で表示。
  • onMounted
    初回判定 → 未既読なら show()。Esc で閉じるハンドラもここで設定。
  • watch(open)
    表示中のみ body のスクロールをロック。UX事故を防止。

もちろん!Zenn 記事にそのまま差し込める形で、「return document.cookie.split("; ").some((v) => v.startsWith(${key}=));」の丁寧な解説と、より堅牢な書き方も併記します。


📘補足

return document.cookie.split("; ").some((v) => v.startsWith(`${key}=`));
何をしているか(概要)
  • ブラウザが保持している 全 Cookie を 1 本の文字列document.cookie)として取得
    例:"foo=1; bar=xyz; notice_seen_user_123=1"
  • それを "; " で分割して配列にする
    例:["foo=1", "bar=xyz", "notice_seen_user_123=1"]
  • 配列のいずれかの要素が key= で始まる(=その名前の Cookie が存在)かを確認
  • 存在すれば true、なければ false
パーツごとの意味
  • document.cookie
    すべての “アクセス可能な” Cookiename=value; name2=value2; ... 形式で返す。
    HttpOnly 属性の Cookie は JS から見えないので、ここに含まれません。

  • .split("; ")
    セミコロン+半角スペースで各 Cookie を分割。

    ただし、ブラウザによっては ";" の後にスペースが入らないケースもあります(後述の堅牢版を推奨)。

  • .some((v) => ...)
    配列の いずれか 1 つでも条件を満たせば true を返す配列メソッド。

  • .startsWith(\${key}=)
    文字列が key= で始まるか を判定。

    • Cookie は name=value で格納されるので、name が一致していれば 必ず = が続く
    • key=...完全一致の先頭を見ているので、key2= に誤マッチすることはありません
  • テンプレートリテラル `${key}=`
    変数 key の中身を文字列に埋め込む構文です。
    例:key="notice_seen_user_123"`${key}=`"notice_seen_user_123="


👌 さらに堅牢な書き方(推奨)

実務では、ブラウザ実装差余分なスペースにも強い書き方にしておくと安心です。

function hasCookie(key: string): boolean {
  return document.cookie
    .split(";")               
    .map((s) => s.trim())     
    .some((v) => v.startsWith(`${key}=`));
}
  • ";" で分割 → スペースが無い ";key=..." 形式にも対応
  • .trim() → 先頭や末尾にスペースがある場合でも正しく判定

さらに厳密に値まで取得したいときは、decodeURIComponent を使って URL エンコードを戻すなどの処理を追加します(下の拡張版参照)。


🔧 拡張版:値の取得関数(ユーティリティ)

キーの 存在確認だけでなく、値も取りたい場合の汎用関数です。

function getCookie(name: string): string | null {
  const target = document.cookie
    .split(";")
    .map((s) => s.trim())
    .find((v) => v.startsWith(`${name}=`));

  if (!target) return null;
  const value = target.substring(name.length + 1); 
  try {
    return decodeURIComponent(value); 
  } catch {
    return value; 
  }
}

hasCookiegetCookie(name) !== null で置き換えられます。


❗ よくある落とし穴
  • ; の後にスペースが無い
    split("; ") だと "foo=1;bar=2" のようなケースで分割に失敗 → ; で分割 + trim() が安全。
  • HttpOnly Cookie は見えない
    JS からは取得できません。セキュアな Cookie 設計の前提として覚えておきましょう。
  • エンコード
    Cookie の値には ;= を含めないのが基本。含む場合は URL エンコードされることが多いので、decodeURIComponent を検討。
  • Cookie スコープ
    path / domain によっては 現在のパスでは見えないことがあります。今回の既読用途では path=/ を付けるのが基本。

まとめ(この 1 行の要点)
  • document.cookie を分割して key= で始まる要素があるか」 を見ている
  • 存在すれば true(=その Cookie がセット済み)
  • 実務では ; 分割 + trim() の堅牢版がおすすめ

本記事の「既読確認」関数では、このロジックを使って Cookie へのフォールバックでも既読状態を正しく判定できるようにしています。

期限日時の生成及び、Cookie の書き込み

const expires = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toUTCString(); 
document.cookie = `${key}=1; path=/; expires=${expires}`;
1行目:期限日時の生成
const expires = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toUTCString();
  • Date.now()
    現在時刻のUNIX エポック(1970/1/1 UTC)からのミリ秒を返します(例:1720000000000 のような数値)。

  • 365 * 24 * 60 * 60 * 1000
    「1年分のミリ秒」を計算しています。

    • 365日 × 24時間 × 60分 × 60秒 × 1000ミリ秒
    • 注意:うるう年は考慮していないため、厳密に“365日後”です(“翌年の同日”ではない)。
  • Date.now() + ...
    「今」+「365日分のミリ秒」=365日後の時刻(UTCではない生のミリ秒値)

  • new Date( ... )
    ミリ秒値から Date オブジェクトを作成します。

  • .toUTCString()
    Cookie の expires 属性は GMT/UTC形式の文字列が求められるため、ここでUTCの文字列に変換します。
    例:"Tue, 30 Sep 2025 14:59:59 GMT"

まとめ:「今から365日後のUTC日時」を、Cookieが理解できる文字列にしている

2行目:Cookie の書き込み
document.cookie = `${key}=1; path=/; expires=${expires}`;
  • document.cookie = "..."
    フロントエンド(JS)からブラウザに Cookie を設定する書式。
    右辺は「key=value と属性のセミコロン区切り」を1本の文字列で渡します。

  • ${key}=1
    Cookie 名と値。ここでは「既読フラグ」を 1 で保存しています。

    • 例:notice_seen_user_123=1
  • path=/
    サイト全体でこの Cookie を有効にする指定。

    • 省略すると現在のパス以下しか送信されず、予期しない“届かない”が起きやすいので、基本は / 推奨
  • expires=${expires}
    先ほど作ったUTC文字列の有効期限

    • 期限を過ぎるとブラウザが Cookie を破棄します。
    • 期限を付けないと「セッションクッキー」になり、ブラウザ終了で消える可能性があります。
    • 代替として Max-Age=31536000(秒)も使えますが、expires は互換性が広いためよく使われます。

まとめ:「キー=値」を1年有効・サイト全体有効で保存している。


実務のベストプラクティス(強化版の例)

HTTPS 環境なら属性を少し足すとより安全・安定になります。

const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
const expires = new Date(Date.now() + ONE_YEAR_MS).toUTCString();


const isSecure = location.protocol === "https:";
const base = `${key}=1; path=/; expires=${expires}; SameSite=Lax`;
const secure = isSecure ? "; Secure" : "";
document.cookie = base + secure;
  • SameSite=Lax
    別サイト遷移時に送られにくい設定。CSRF耐性が上がり、最近のブラウザ仕様にも沿っています。
    追跡やサードパーティ用途でなければ Lax 推奨

  • Secure
    HTTPS 通信に限定して Cookie を送受信。

    • これが付いていると HTTP のページでは送受信されない(=安全)。
    • HTTPSのときだけ付けるのが一般的(location.protocol 判定)。
  • domain=
    サブドメイン間で共有したいときのみ指定(例:.example.com)。不用意に指定すると無駄に広いスコープになるので基本は不要。


よくある疑問・落とし穴
  • Q:expiresMax-Age はどっちが良い?
    どちらでも可Max-Age は「秒数」で明快、expires は「日時」で古いブラウザにも広く互換。両方書く例もあります。

  • Q:うるう年は?
    この式は きっちり365日後 です。厳密に“1年後の同日”である必要があるなら、日付操作ライブラリ(例:Day.js/Date-fns)で “add(1, ‘year’)” を使うのが確実。

  • Q:ITP(Safari のトラッキング防止)に消されない?
    サードパーティ Cookie は制限が厳しいですが、ファーストパーティのこの用途(既読フラグ)は影響を受けにくいです。それでも localStorage が使えないケースに備え、今回のように localStorage → Cookie の順で冗長化しておくのが実務的。

  • Q:削除したいときは?
    過去日時を set します:

    document.cookie = `${key}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`;
    

    もしくは Max-Age=0


まとめ
  • 1行目は「今から365日後のUTC日時」を expires 用にフォーマット。
  • 2行目は document.cookie で「キー=1」「path=/」「expires=…」をセットして、1年間有効な既読フラグを保存。
  • 可能なら SameSite=Lax; Secure を足すとより安全(HTTPS時)。
  • 機微情報は Cookie(JS可視)に入れない。今回は“既読フラグ”なので問題なし。

3. テンプレート例(最小)


div class="modal-underlay" v-if="open" @click.self="close" role="dialog" aria-modal="true" aria-labelledby="notice-title">
  div class="modal" tabindex="-1">
    h2 id="notice-title" class="modal__title">お知らせh2>
    p class="modal__body">(例)来月のメンテナンスに関するご案内です…p>
    button class="modal__close" @click="close" aria-label="閉じる">×button>
  div>
div>
  • @click.self="close"オーバーレイの空白クリックで閉じる(モーダル本体クリックは無効)
  • role="dialog" aria-modal="true" など、簡易アクセシビリティ属性も付与

4. SCSS の注意点(抜粋)

.modal-underlay {
  position: fixed;
  inset: 0;
  z-index: 10000; 
  background: rgba(0, 0, 0, 0.6);
  display: grid;
  place-items: center;
}

.modal {
  background: #fff;
  max-width: 600px;
  width: min(90vw, 600px);
  border-radius: 12px;
  padding: 24px;
  position: relative;
}

.modal__close {
  position: absolute;
  top: 8px;
  right: 12px;
  font-size: 20px;
  cursor: pointer;
}
  • z-index 競合に注意(サイトのレイヤ設計があるなら従う)
  • 中央寄せは display: grid; place-items: center; が簡潔

ESLint no-empty の対応

空の catch {}no-empty に違反します。
最小対応void 0; を 1 行入れること。

try {
  localStorage.setItem(key, "1");
  return;
} catch (_) {
  void 0; 
}

より丁寧にするなら、ラッパー関数で戻り値を使って制御しても OK です。


よくあるハマりどころと対策

  • 毎回表示されてしまう

    • localStorage がブロックされている(Safari プライベート等)→ Cookie フォールバックで解決
    • ポートやサブドメインが違うと localStorage は別オリジン → Cookie は path=/ 指定で同ドメイン配下なら共有されやすい
  • 再掲したい(内容差し替え)

    • VERSIONv2 などに上げるだけで、既読状態をリセットできる
  • 未ログイン時の扱い

    • "guest" を共通キーとし端末単位で既読管理
    • ユーザー単位を厳密にしたいなら、ログイン時のみ表示する、サーバ側でもフラグを持つ等の設計も検討
  • 背景がスクロールしてしまう

    • watch(open, ...)document.body.style.overflow='hidden' を忘れずに

参考リンク(公式)

  • Vue 3 – Composition API(ref / onMounted / watch

  • Web Storage API(localStorage)

  • Document.cookie

  • ESLint no-empty


まとめ

  • 既読確認hasSeen):localStorage → だめなら Cookie
  • 既読保存markSeen):まず localStorage、失敗なら Cookie
  • ユーザー単位で管理するために data-user-id をサーバから渡す
  • Esc で閉じる / 背景スクロール抑止 で基本 UX を担保
  • バージョン付きキーで “再掲” も容易

最小の責務分割で、堅牢かつ拡張しやすい「一度閉じたら出ない」告知モーダルが実装できます。



Source link

Views: 0

RELATED ARTICLES

返事を書く

あなたのコメントを入力してください。
ここにあなたの名前を入力してください

- Advertisment -