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) --}}
<div id="notice-modal-root" data-user-id="{{ Auth::id() ?? 'guest' }}">
{{-- ここにモーダルの HTML(または Vue テンプレート)を置く --}}
</div>
ポイント
-
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";
// 既読キー(ユーザー単位に管理したいので userId を含める)
// 告知内容を差し替えるときは VERSION を上げれば「再掲」できる
const VERSION = "v1";
function getStorageKey(userId: string) {
return `${VERSION}_notice_seen_user_${userId}`;
}
// --- 既読の「確認」(localStorage → Cookie の順で確認)---
function hasSeen(key: string): boolean {
try {
if (localStorage.getItem(key) === "1") return true;
} catch (_) {
void 0; // localStorage 不可(Safari プライベート等)を想定
}
// Cookie で確認
return document.cookie.split("; ").some((v) => v.startsWith(`${key}=`));
}
// --- 既読の「保存」(localStorage → Cookie の順で保存)---
function markSeen(key: string): void {
try {
localStorage.setItem(key, "1");
return; // 保存できたので終了
} catch (_) {
void 0; // 続いて Cookie 保存へ
}
const expires = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toUTCString(); // 1年
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(); // 未既読なら表示
}
// Esc で閉じる(アクセシビリティ配慮)
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 の順で保存。ESLintno-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}=));
」の丁寧な解説と、より堅牢な書き方も併記します。
📘補足
Cookie の既読確認ワンライナー徹底解説
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
すべての “アクセス可能な” Cookie をname=value; name2=value2; ...
形式で返す。
※HttpOnly
属性の Cookie は JS から見えないので、ここに含まれません。 -
.split("; ")
セミコロン+半角スペースで各 Cookie を分割。ただし、ブラウザによっては
";"
の後にスペースが入らないケースもあります(後述の堅牢版を推奨)。 -
.some((v) => ...)
配列の いずれか 1 つでも条件を満たせばtrue
を返す配列メソッド。 -
.startsWith(\
${key}=)
文字列がkey=
で始まるか を判定。- Cookie は
name=value
で格納されるので、name
が一致していれば 必ず=
が続く -
key=...
と 完全一致の先頭を見ているので、key2=
に誤マッチすることはありません
- Cookie は
-
テンプレートリテラル
`${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); // "name=" の直後から末尾まで
try {
return decodeURIComponent(value); // 値がURLエンコードされていた場合に対応
} catch {
return value; // エンコードされていなければそのまま返す
}
}
hasCookie
は getCookie(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(); // 1年
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();
// 現在のページが https なら Secure を付ける
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:
expires
とMax-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. テンプレート例(最小)
<!-- v-if="open" で存在自体を切り替える(アニメ重視なら v-show + CSS もあり) -->
<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;
が簡潔
no-empty
の対応
ESLint 空の catch {}
は no-empty
に違反します。
最小対応は void 0;
を 1 行入れること。
try {
localStorage.setItem(key, "1");
return;
} catch (_) {
void 0; // 空ブロック回避
}
より丁寧にするなら、ラッパー関数で戻り値を使って制御しても OK です。
よくあるハマりどころと対策
-
毎回表示されてしまう
- localStorage がブロックされている(Safari プライベート等)→ Cookie フォールバックで解決
-
ポートやサブドメインが違うと localStorage は別オリジン → Cookie は
path=/
指定で同ドメイン配下なら共有されやすい
-
再掲したい(内容差し替え)
-
VERSION
をv2
などに上げるだけで、既読状態をリセットできる
-
-
未ログイン時の扱い
-
"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 を担保
- バージョン付きキーで “再掲” も容易
最小の責務分割で、堅牢かつ拡張しやすい「一度閉じたら出ない」告知モーダルが実装できます。
Discussion