昨天把音效分成三個獨立函式之後,心情輕鬆很多,想調哪個聲音就改哪個函式,不用再大海撈針。
然後我盯著整包程式碼看了一下,意識到還有語言的問題:
這整間診所,從頭到尾只有中文。
其實,我身邊的人幾乎都說中文,英文或者是韓文日文根本用不太到。
以上面的道理來看的話,這功能完全不急,甚至可以說沒必要。
但我就是忍不住想做。
純粹是因為我自己很喜歡那種「整個畫面瞬間切換語言」的感覺,每次用別人做的網站看到這種效果都會多點兩下玩一玩。既然是自己的專題,想加什麼功能本來就不需要什麼正當理由,單純覺得好玩就夠了。
所以今天不是為了接軌國際,單純是想親手刻一次這個我一直很喜歡的效果。
目標:中文、英文、日文、韓文,四種語言即時切換,不重新整理頁面。
目前的寫法,大概長這樣:
<h1>🧠 賽博紓壓診所與解碼器</h1>
<button onclick="goToBreathe()">進入紓壓診所</button>
文字直接焊在標籤裡面。想加一種語言,等於要把整個網站的文字再抄一份,貼到另一個檔案,改錯一個字也沒有人知道。
正確的做法是反過來:先把所有文字集中管理,HTML 只負責留位置。
我準備一張語言表,每個 key 對應四種語言的翻譯:
const i18n = {
title: { zh: "賽博紓壓診所與解碼器", en: "Cyber Stress Clinic", ja: "サイバー・ストレスクリニック", ko: "사이버 스트레스 클리닉" },
enter: { zh: "進入紓壓診所", en: "Enter the Clinic", ja: "クリニックへ入る", ko: "클리닉 입장하기" }
};
然後 HTML 裡的文字元素,不直接寫死內容,而是掛一個標記,告訴程式「這格要填哪個 key」:
<h1 data-i18n="title"></h1>
<button data-i18n="enter" onclick="goToBreathe()"></button>
切換語言的時候,只要做一件事:
掃過頁面上所有有 data-i18n 標記的元素,照著目前選的語言,把對應的文字填進去。
function applyLanguage(lang) {
document.querySelectorAll("[data-i18n]").forEach(el => {
const key = el.dataset.i18n;
el.innerText = i18n[key][lang];
});
}
一行 querySelectorAll 掃過整個頁面,不用管畫面上到底有幾個文字,新增文字只要多掛一個 data-i18n,多加一個 key,完全不用改這段邏輯。
今天的示範只留首頁跟幾個常見文字,重點放在切換機制本身:
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>賽博紓壓診所:多國語系切換</title>
<style>
body {
font-family: 'Segoe UI', Microsoft JhengHei, sans-serif;
background: #e3f2fd;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
padding: 20px;
}
.card {
background: white;
padding: 30px;
border-radius: 20px;
box-shadow: 0 12px 30px rgba(0,0,0,0.15);
width: 100%;
max-width: 460px;
text-align: center;
}
.lang-switch {
display: flex;
justify-content: center;
gap: 8px;
margin-bottom: 20px;
}
.lang-btn {
background-color: #f5f5f5;
border: 2px solid transparent;
border-radius: 50px;
padding: 8px 16px;
font-size: 0.85rem;
cursor: pointer;
color: #263238;
transition: 0.2s;
}
.lang-btn:hover { background-color: #e3f2fd; }
.lang-btn.active {
background-color: #1e88e5;
color: white;
border-color: #1e88e5;
}
h1 { color: #1e88e5; margin: 0 0 10px; font-size: 1.4rem; }
p { color: #546e7a; font-size: 0.9rem; margin-bottom: 20px; }
.feature-box {
background: #e3f2fd;
border-radius: 14px;
padding: 14px;
margin-bottom: 10px;
text-align: left;
font-size: 0.9rem;
color: #263238;
}
.feature-box b { color: #1e88e5; }
.btn {
background-color: #1e88e5;
color: white;
border: none;
border-radius: 50px;
padding: 12px 30px;
font-size: 1rem;
cursor: pointer;
width: 100%;
margin-top: 15px;
transition: 0.2s;
}
.btn:hover { background-color: #1565c0; transform: translateY(-2px); }
.avatar-grid { display: flex; gap: 10px; justify-content: center; margin: 15px 0; }
.avatar-option { font-size: 1.8rem; cursor: pointer; padding: 5px; border-radius: 50%; border: 2px solid transparent; transition: 0.2s; }
.avatar-option.selected { border-color: #1e88e5; background-color: #e3f2fd; transform: scale(1.1); }
</style>
</head>
<body>
<div class="card">
<div class="lang-switch" id="lang-switch">
<button class="lang-btn" data-lang="zh">中文</button>
<button class="lang-btn" data-lang="en">EN</button>
<button class="lang-btn" data-lang="ja">日本語</button>
<button class="lang-btn" data-lang="ko">한국어</button>
</div>
<h1 data-i18n="title"></h1>
<p data-i18n="subtitle"></p>
<div class="avatar-grid">
<div class="avatar-option selected">🐱</div>
<div class="avatar-option">🐶</div>
<div class="avatar-option">🦊</div>
<div class="avatar-option">🐼</div>
<div class="avatar-option">🦄</div>
</div>
<div class="feature-box">
<b data-i18n="feature1Title"></b><br>
<span data-i18n="feature1Desc"></span>
</div>
<div class="feature-box">
<b data-i18n="feature2Title"></b><br>
<span data-i18n="feature2Desc"></span>
</div>
<button class="btn" data-i18n="enterBtn"></button>
</div>
<script>
// ---------- 今天的重點:一張集中管理的語言表 ----------
const i18n = {
title: {
zh: "賽博紓壓診所與解碼器",
en: "Cyber Stress Clinic",
ja: "サイバー・ストレスクリニック",
ko: "사이버 스트레스 클리닉"
},
subtitle: {
zh: "結合廟宇抽籤、雷達圖分析與惡搞處方籤的頂級心靈小工具",
en: "Fortune draws, radar charts, and absurd prescriptions, all in one app",
ja: "おみくじ、レーダーチャート、ふざけた処方箋が詰まった癒しツール",
ko: "운세뽑기, 레이더 차트, 엉뚱한 처방전이 담긴 힐링 도구"
},
feature1Title: {
zh: "🎲 廟宇抽籤",
en: "🎲 Temple Fortune Draw",
ja: "🎲 おみくじを引く",
ko: "🎲 운세 뽑기"
},
feature1Desc: {
zh: "把死線交給命運,搖一搖籤筒看看今天運勢",
en: "Leave your deadlines to fate — give the stick a shake",
ja: "締切を運命に任せて、今日の運勢を占おう",
ko: "마감을 운명에 맡기고 오늘의 운세를 뽑아보세요"
},
feature2Title: {
zh: "🔋 腦內電池檢測",
en: "🔋 Brain Battery Check",
ja: "🔋 脳内バッテリー診断",
ko: "🔋 뇌 배터리 검사"
},
feature2Desc: {
zh: "測完告訴你今天到底還剩幾格電",
en: "Find out how much mental battery you have left today",
ja: "測定後、今日残っているバッテリー量がわかります",
ko: "테스트 후 오늘 남은 배터리 양을 알려드립니다"
},
enterBtn: {
zh: "進入紓壓診所",
en: "Enter the Clinic",
ja: "クリニックへ入る",
ko: "클리닉 입장하기"
}
};
let currentLang = "zh";
// ---------- 掃過頁面上所有標記,填入對應語言的文字 ----------
function applyLanguage(lang) {
currentLang = lang;
document.querySelectorAll("[data-i18n]").forEach(el => {
const key = el.dataset.i18n;
const entry = i18n[key];
if (entry && entry[lang]) {
el.innerText = entry[lang];
}
});
// 切換按鈕本身的 active 樣式
document.querySelectorAll(".lang-btn").forEach(btn => {
btn.classList.toggle("active", btn.dataset.lang === lang);
});
document.documentElement.lang =
lang === "zh" ? "zh-Hant" : lang === "ja" ? "ja" : lang === "ko" ? "ko" : "en";
}
// ---------- 綁定四顆語言按鈕 ----------
document.querySelectorAll(".lang-btn").forEach(btn => {
btn.addEventListener("click", () => applyLanguage(btn.dataset.lang));
});
// 一開始預設中文
applyLanguage("zh");
</script>
</body>
</html>
再存成 .html 打開,點四顆語言按鈕輪流切換,標題、說明、兩個功能卡片、按鈕文字會一起變,畫面不會閃一下重新整理,就是單純的文字抽換。
1. 為什麼是 data-i18n 這種自訂屬性,不是直接用 id?
一開始我想說用 id 就好,切換的時候 document.getElementById("title").innerText = ...,一個一個寫。結果寫到第五個文字就想放棄,因為每加一個文字,都要多寫一行對應的 JavaScript。
用 data-i18n 搭配 querySelectorAll 之後,新增文字只需要兩個步驟:HTML 掛一個 data-i18n="newKey",語言表裡加一個 newKey 的四種翻譯,完全不用碰 applyLanguage() 這個函式本身。
2. 為什麼要判斷 entry && entry[lang],不直接 entry[lang]?
這是防呆。如果我手滑漏寫了某個 key 的某個語言翻譯,或是哪天不小心打錯 key 的名字,直接存取會拿到 undefined,畫面上那格文字就會憑空消失,變成一片空白,使用者根本不知道發生什麼事。
加上這個判斷之後,最糟的情況是「這段文字保持原樣沒有變」,至少不會整格消失不見。
3. document.documentElement.lang 是幹嘛的?
這是切換 <html> 標籤上的 lang 屬性,螢幕閱讀器跟瀏覽器的翻譯功能都會參考這個值。單純只是切換畫面上看得到的文字,卻沒更新這個屬性,對有使用輔助工具的使用者來說其實是不太禮貌的做法,所以我也一起處理掉了。
4. 四種語言的翻譯是怎麼生出來的?
老實說,英、日、韓文我都不是母語者,初稿是先用翻譯工具抓個大概,再自己對照意思調整用字,盡量讓語氣跟中文版本接近,不是逐字死翻。如果有該語言的母語者願意幫忙抓蟲,我會很感謝,歡迎留言告訴我哪裡怪怪的。
做這個功能最大的感想是:麻煩的地方全部在「內容」,不是「邏輯」。
applyLanguage() 這個函式從頭到尾沒有變得多複雜,真正花時間的是把每一句話翻成四種語言,還要盡量讓語氣一致,不要中文版吐槽意味很重,翻成英文卻變得很正經。
另外一個意外的收穫是,做這個功能倒逼我把整個網站的文字重新整理了一遍。以前文字直接寫在 HTML 標籤裡,想找一句話要翻遍好幾個檔案;現在全部集中在 i18n 這一張表裡,等於順便做了一份「全站文案清單」,看起來反而比以前更清楚且好找。
我知道這個功能對這個專題來說,某種程度上是一個「不太實用」的功能。它不會讓測驗更準,不會讓雷達圖更好看懂,甚至大概率不會有真的講日文、韓文的人點開這個網站。
但純前端專題做到這裡,好玩的地方不就是這個嗎?想試試看某個效果,就動手做一次,不需要先證明它有商業價值才能做。
今天我們把原本焊死在 HTML 裡的所有文字,抽成一張集中管理的語言表,搭配 data-i18n 標記跟一個掃描函式,讓診所現在有了四種語言可以切換!
不過做完之後我馬上發現一個破口:
呼吸過場那個「吸氣...吐氣...」的動畫文字,是寫死在計時器裡面的,根本沒有掛 data-i18n。
切到英文模式,深呼吸的時候畫面還是會跳出中文字,整個沉浸感瞬間被打斷。
所以明天 Day 20,我們要回頭把呼吸過場這個區塊也接進語言系統,順便處理另一個問題:計時器跟語言切換要怎麼同步,萬一使用者在呼吸動畫跑到一半的時候切換語言,畫面該怎麼反應才不會露餡。
連呼吸都要會說四種語言。明天見!