iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
自我挑戰組

賽博紓壓診所:從零打造純前端心理測驗與互動紓壓網頁系列 第 19 篇

Day 19:多國語系支援(i18n):實作中、英、韓、日四語即時動態切換

  • 分享至 

  • xImage
  •  

前言

昨天把音效分成三個獨立函式之後,心情輕鬆很多,想調哪個聲音就改哪個函式,不用再大海撈針。

然後我盯著整包程式碼看了一下,意識到還有語言的問題:

這整間診所,從頭到尾只有中文。

其實,我身邊的人幾乎都說中文,英文或者是韓文日文根本用不太到。

以上面的道理來看的話,這功能完全不急,甚至可以說沒必要。

但我就是忍不住想做。

純粹是因為我自己很喜歡那種「整個畫面瞬間切換語言」的感覺,每次用別人做的網站看到這種效果都會多點兩下玩一玩。既然是自己的專題,想加什麼功能本來就不需要什麼正當理由,單純覺得好玩就夠了。

所以今天不是為了接軌國際,單純是想親手刻一次這個我一直很喜歡的效果。

目標:中文、英文、日文、韓文,四種語言即時切換,不重新整理頁面。


核心觀念:文字不要寫死在 HTML 裡,要寫在一張表裡

目前的寫法,大概長這樣:

<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,完全不用改這段邏輯。


動手寫 Code:四語言切換器

今天的示範只留首頁跟幾個常見文字,重點放在切換機制本身:

<!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,我們要回頭把呼吸過場這個區塊也接進語言系統,順便處理另一個問題:計時器跟語言切換要怎麼同步,萬一使用者在呼吸動畫跑到一半的時候切換語言,畫面該怎麼反應才不會露餡。

連呼吸都要會說四種語言。明天見!


上一篇
Day 18:聲音多到打架,是時候幫它們分房間了
系列文
賽博紓壓診所:從零打造純前端心理測驗與互動紓壓網頁 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言