iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0
自我挑戰組

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

Day 27:院內自主健檢,重構、相容、效能一次掃完

  • 分享至 

  • xImage
  •  

前言

診所開到現在,替別人量過電池、畫過雷達圖、蓋過章,卻從來沒有人替診所自己做過健檢。

昨天拿手機測完分享功能,我看著那支沒反應的舊手機,突然有種「醫者不能自醫」的荒謬感。給使用者開處方開得很順手,自己家的程式碼放了二十幾天沒人檢查,裡面到底藏了什麼病,我完全沒底。

所以今天診所公休一天,輪到診所自己掛號。

我替自己開了三張檢驗單:

  • 檢驗單一,病歷室整理:重構,把散落各處的程式碼歸位
  • 檢驗單二,換裝置掛號:相容性,換不同手機、不同瀏覽器走一遍
  • 檢驗單三,量院內心跳:效能,看畫面有沒有在偷偷喘

三張單子照順序跑完,這個專題才算有資格拿去部署。


核心觀念:三張檢驗單,各抓一種病

檢驗單一:病歷室整理

Day 22 整理階段成果的時候,我就發現「算比例、換算百分比」那條公式,在雷達圖、耗損率、六維度歸一化、心電圖振幅四個地方各寫了一份。每份長得都差不多,改其中一份就得記得回頭改另外三份,漏掉一份,畫面上的數字就會對不起來。

整理的做法很樸素:開一個叫 Clinic 的大櫃子,裡面分抽屜,數學放一格、音效放一格,需要的人自己來拿。

const Clinic = (() => {
    const math = {
        toPercent(value, min, max) { /* 全站唯一一份換算公式 */ }
    };
    const sound = {
        tone(options) { /* 全站唯一一份音效引擎 */ }
    };
    return { math, sound };   // 只把要給人用的東西露出去
})();

外面包的那層 (() => { ... })() 叫立即執行函式,裡面宣告的變數出了這層就看不到,不會跟別人的變數撞名。對一個沒有打包工具的純前端專題來說,這是最省事的模組化手法。

另外一條路是 <script type="module"> 把檔案拆開,用 import 互相引用。拆檔的結構比較漂亮,但本機直接雙擊 .html 開檔時,瀏覽器會因為安全規則擋掉 module 的載入。今天的示範要能直接存檔就跑,所以先用單檔的 IIFE,等後天掛上 GitHub Pages 之後再決定要不要拆。

檢驗單二:換裝置掛號

昨天那兩個症狀,加上手機上另外兩個常見的老毛病,一共可以整理出三件事:

  • 音訊要先被「解鎖」:手機瀏覽器規定,AudioContext 一開始是睡著的,得在使用者真的碰過畫面的那一刻叫醒它。行動版對「什麼算使用者手勢」還挺挑的,所以我會同時監聽 touchend、click、keydown,哪個先到就用哪個,成功之後立刻把監聽拆掉。
  • 點擊反應與縮放:按鈕加上 touch-action: manipulation,告訴瀏覽器這顆按鈕只管點擊,不用等著判斷是不是雙擊縮放,手感會俐落不少。
  • 滿版高度:手機瀏覽器的網址列會伸縮,100vh 算出來的高度常常比實際看得到的區域還高,底部的按鈕被切掉半截。改用 100dvh,它會跟著網址列的伸縮動態調整。

比這三招更重要的是一個心態:判斷「能不能用」要靠功能偵測,不要靠猜裝置。 與其寫「如果是 iPhone 就怎樣」,不如直接問瀏覽器「你有沒有 Clipboard API」,有就用,沒有就走備案。裝置型號每年都在變,功能偵測不會過期。

檢驗單三:量院內心跳

效能這件事,我一開始的直覺是「大概是動畫太多」,後來實際量過才知道,真正的元兇通常更不起眼:

  • 逐筆寫入 DOM,順手還讀了版面:在迴圈裡每放一個元素,就接著讀一次 offsetHeight,瀏覽器每一圈都得停下來重新計算版面。一千多圈下來,就是一千多次重算。
  • 背景分頁還在跑動畫:使用者切去看別的分頁,診所裡的動畫迴圈照樣轉,白白燒電。

前者的解法是 DocumentFragment:先在記憶體裡的「暫存盤」把元素排好,最後一次整盤端上桌,版面只算一次。後者用 visibilitychange 事件,頁面被藏起來就暫停,回來再繼續。


動手寫 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>
    * { box-sizing: border-box; }
    body {
        font-family: 'Segoe UI', Microsoft JhengHei, sans-serif;
        background: #e3f2fd;
        display: flex;
        justify-content: center;
        align-items: flex-start;
        min-height: 100vh;
        min-height: 100dvh;   /* 手機網址列會伸縮,dvh 會跟著變 */
        margin: 0;
        padding: 20px;
    }
    .card {
        background: white;
        padding: 22px;
        border-radius: 20px;
        box-shadow: 0 12px 30px rgba(0,0,0,0.15);
        width: 100%;
        max-width: 520px;
    }
    h2 { color: #1e88e5; margin: 0 0 4px; text-align: center; }
    .subtitle { color: #546e7a; font-size: 0.85rem; text-align: center; margin-bottom: 16px; }

    .sheet {
        border: 2px dashed #90caf9;
        border-radius: 14px;
        padding: 14px;
        margin-bottom: 14px;
    }
    .sheet h3 { margin: 0 0 8px; font-size: 1rem; color: #263238; }
    .sheet h3 span { font-size: 0.72rem; color: #90a4ae; margin-left: 6px; font-weight: normal; }

    .row {
        display: flex;
        justify-content: space-between;
        gap: 10px;
        font-size: 0.85rem;
        padding: 5px 0;
        border-bottom: 1px dashed #cfd8dc;
        color: #37474f;
    }
    .row:last-of-type { border-bottom: none; }
    .ok { color: #2e7d32; font-weight: bold; }
    .bad { color: #d32f2f; font-weight: bold; }

    .run {
        background-color: #1e88e5;
        color: white;
        border: none;
        border-radius: 50px;
        padding: 9px 20px;
        font-size: 0.85rem;
        cursor: pointer;
        margin-top: 10px;
        touch-action: manipulation;              /* 不等雙擊縮放判斷,點擊更俐落 */
        -webkit-tap-highlight-color: transparent; /* 拿掉手機點擊時那層灰色閃光 */
    }
    .run:active { transform: scale(0.96); }

    #benchBox {
        display: flex;
        flex-wrap: wrap;
        gap: 2px;
        height: 40px;
        overflow: hidden;
        margin-top: 8px;
    }
    .dot { width: 8px; height: 8px; background: #90caf9; border-radius: 2px; }

    .pulse-track {
        height: 10px;
        background: #eceff1;
        border-radius: 5px;
        overflow: hidden;
        margin-top: 10px;
    }
    .pulse-bar { height: 100%; width: 30%; background: #1e88e5; border-radius: 5px; }
    .pulse-status { font-size: 0.75rem; color: #90a4ae; margin-top: 4px; }

    @media (prefers-reduced-motion: reduce) {
        .pulse-bar { display: none; }
    }
</style>
</head>
<body>

    <div class="card">
        <h2>🩺 院內自主健檢</h2>
        <p class="subtitle">診所替自己掛號,三張檢驗單</p>

        <!-- 檢驗單一:重構 -->
        <div class="sheet">
            <h3>📁 檢驗單一:病歷室整理<span>重構</span></h3>
            <div id="refactorRows"></div>
            <button class="run" onclick="runRefactorCheck()">三處共用同一條公式</button>
        </div>

        <!-- 檢驗單二:相容性 -->
        <div class="sheet">
            <h3>📱 檢驗單二:換裝置掛號<span>相容性</span></h3>
            <div id="compatRows"></div>
            <button class="run" onclick="testSound()">試播一聲</button>
            <div class="pulse-status" id="audioStatus">音訊狀態:等待第一次觸碰</div>
        </div>

        <!-- 檢驗單三:效能 -->
        <div class="sheet">
            <h3>💓 檢驗單三:量院內心跳<span>效能</span></h3>
            <div id="benchRows"></div>
            <div id="benchBox"></div>
            <button class="run" onclick="runBenchmark()">開始量測</button>
            <div class="pulse-track"><div class="pulse-bar" id="pulseBar"></div></div>
            <div class="pulse-status" id="pulseStatus"></div>
        </div>
    </div>

    <script>
        // ================= 檢驗單一:重構 =================
        // 用 IIFE 包成一個「大櫃子」,內部變數外面看不到,只露出需要的抽屜
        const Clinic = (() => {
            const math = {
                // 全站唯一一份「換算百分比」公式
                toPercent(value, min, max) {
                    if (max === min) return 50;
                    const ratio = (value - min) / (max - min);
                    return Math.max(0, Math.min(100, Math.round(ratio * 100)));
                }
            };

            const sound = {
                ctx: null,
                // 喚醒(或建立)音訊環境,回傳 Promise
                unlock() {
                    const AC = window.AudioContext || window.webkitAudioContext;
                    if (!AC) return Promise.resolve(false);
                    if (!this.ctx) this.ctx = new AC();
                    if (this.ctx.state === "suspended") return this.ctx.resume().then(() => true);
                    return Promise.resolve(true);
                },
                tone({ type, startFreq, endFreq, duration, volume }) {
                    try {
                        if (!this.ctx) return;
                        const t = this.ctx.currentTime;
                        const osc = this.ctx.createOscillator();
                        const gain = this.ctx.createGain();
                        osc.type = type;
                        osc.frequency.setValueAtTime(startFreq, t);
                        osc.frequency.exponentialRampToValueAtTime(endFreq, t + duration);
                        gain.gain.setValueAtTime(volume, t);
                        gain.gain.exponentialRampToValueAtTime(0.001, t + duration);
                        osc.connect(gain);
                        gain.connect(this.ctx.destination);
                        osc.start(t);
                        osc.stop(t + duration);
                    } catch (e) {}
                }
            };

            return { math, sound };
        })();

        function runRefactorCheck() {
            // 三個原本各寫各的地方,現在全部呼叫同一個函式
            const drain = 100 - Clinic.math.toPercent(14, -5, 24);  // 耗損率
            const mindset = Clinic.math.toPercent(2, -10, 5);        // 心態歸一化
            const amplitude = Clinic.math.toPercent(3, 0, 5);        // 心電圖進度

            document.getElementById("refactorRows").innerHTML = `
                <div class="row"><span>耗損率(總分 14,範圍 -5 ~ 24)</span><b>${drain}%</b></div>
                <div class="row"><span>心態歸一化(原始 2,範圍 -10 ~ 5)</span><b>${mindset} 分</b></div>
                <div class="row"><span>心電圖進度(答 3 題 / 共 5 題)</span><b>${amplitude}%</b></div>`;
        }

        // ================= 檢驗單二:相容性 =================
        // 功能偵測:直接問瀏覽器有沒有,不靠猜裝置型號
        const checks = [
            { name: "觸控支援",       test: () => "ontouchstart" in window || navigator.maxTouchPoints > 0 },
            { name: "Pointer Events", test: () => "PointerEvent" in window },
            { name: "Web Audio",      test: () => !!(window.AudioContext || window.webkitAudioContext) },
            { name: "Clipboard API",  test: () => !!(navigator.clipboard && navigator.clipboard.writeText) },
            { name: "安全環境(https / localhost)", test: () => window.isSecureContext },
            { name: "localStorage",   test: () => {
                try { localStorage.setItem("__t", "1"); localStorage.removeItem("__t"); return true; }
                catch (e) { return false; }
            }},
            { name: "dvh 單位",       test: () => window.CSS && CSS.supports("height", "100dvh") }
        ];

        function renderCompat() {
            document.getElementById("compatRows").innerHTML = checks.map(c => {
                let ok = false;
                try { ok = !!c.test(); } catch (e) {}
                return `<div class="row"><span>${c.name}</span><span class="${ok ? "ok" : "bad"}">${ok ? "✅ 支援" : "❌ 不支援"}</span></div>`;
            }).join("");
        }

        // 音訊解鎖:多掛幾種事件,哪個先到就用哪個,成功後立刻拆掉
        const unlockEvents = ["touchend", "click", "keydown"];
        function unlockOnce() {
            Clinic.sound.unlock().then(ok => {
                if (!ok) return;
                unlockEvents.forEach(ev => document.removeEventListener(ev, unlockOnce));
                document.getElementById("audioStatus").innerText = "音訊狀態:已解鎖 ✅";
            });
        }
        unlockEvents.forEach(ev => document.addEventListener(ev, unlockOnce));

        function testSound() {
            Clinic.sound.unlock().then(() => {
                Clinic.sound.tone({ type: "sine", startFreq: 600, endFreq: 200, duration: 0.08, volume: 0.15 });
            });
        }

        // ================= 檢驗單三:效能 =================
        const BENCH_COUNT = 2000;

        // 壞習慣:每放一個就讀一次版面,逼瀏覽器每一圈都重算
        function renderNaive(n) {
            const box = document.getElementById("benchBox");
            box.innerHTML = "";
            const t0 = performance.now();
            for (let i = 0; i < n; i++) {
                const el = document.createElement("div");
                el.className = "dot";
                box.appendChild(el);
                el.offsetHeight;
            }
            return performance.now() - t0;
        }

        // 好習慣:先在暫存盤排好,最後一次端上桌,版面只算一次
        function renderBatch(n) {
            const box = document.getElementById("benchBox");
            box.innerHTML = "";
            const t0 = performance.now();
            const frag = document.createDocumentFragment();
            for (let i = 0; i < n; i++) {
                const el = document.createElement("div");
                el.className = "dot";
                frag.appendChild(el);
            }
            box.appendChild(frag);
            box.offsetHeight;
            return performance.now() - t0;
        }

        function runBenchmark() {
            // 先暖機,避免第一次執行比較慢影響比較
            renderNaive(200);
            renderBatch(200);

            const naive = renderNaive(BENCH_COUNT);
            const batch = renderBatch(BENCH_COUNT);
            const times = naive / Math.max(batch, 0.01);

            document.getElementById("benchRows").innerHTML = `
                <div class="row"><span>逐筆寫入(每圈強迫重算版面)</span><b>${naive.toFixed(1)} ms</b></div>
                <div class="row"><span>批次寫入(Fragment 一次放)</span><b>${batch.toFixed(1)} ms</b></div>
                <div class="row"><span>差距</span><b>約 ${times.toFixed(1)} 倍</b></div>`;
        }

        // 背景分頁暫停動畫:頁面被藏起來就停,回來再繼續
        const pulseBar = document.getElementById("pulseBar");
        const pulseStatus = document.getElementById("pulseStatus");
        const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
        let rafId = null;
        let px = -100;

        function loop() {
            px += 1.5;
            if (px > 340) px = -100;
            pulseBar.style.transform = `translateX(${px}%)`;
            rafId = requestAnimationFrame(loop);
        }
        function startPulse() {
            if (reduceMotion) { pulseStatus.innerText = "系統設定為減少動態,已停用動畫"; return; }
            if (!rafId) rafId = requestAnimationFrame(loop);
            pulseStatus.innerText = "動畫:執行中(切到別的分頁會自動暫停)";
        }
        function stopPulse() {
            if (rafId) cancelAnimationFrame(rafId);
            rafId = null;
        }
        document.addEventListener("visibilitychange", () => {
            if (document.hidden) stopPulse(); else startPulse();
        });

        // 一打開頁面就把三張檢驗單的初始內容備好
        runRefactorCheck();
        renderCompat();
        startPulse();
    </script>
</body>
</html>

再存成 .html 打開,先看第二張檢驗單有哪幾項是紅色的,再按「開始量測」看逐筆跟批次差了幾倍。最好用手機也開一次,同一份程式在兩台裝置上的勾勾跟毫秒數,通常不會一樣。


幾個健檢時抓到的小毛病

  • 效能數字每台裝置都不同,只能拿來看相對差距
    同一份程式在我電腦上跑出來的毫秒數,跟在手機上差很多,絕對值沒有參考價值。真正有意義的是「逐筆」跟「批次」之間的倍數。另外這個量測只計算 JavaScript 同步執行的時間,不包含畫面真正繪製出來的時間,所以拿來比較寫法的優劣夠用,但別把它當成使用者實際感受到的卡頓時間。

  • iPhone 側邊的靜音撥片,會連 Web Audio 一起吞掉
    據我查到的資料,iOS 上用 Web Audio 發出的聲音會受靜音撥片影響,撥片切到靜音,程式再正確也聽不到。手機測試時要是聽不到聲音,先確認這個撥片,不用急著懷疑程式。

  • isSecureContext 那一列,決定了 Clipboard API 的命運
    Clipboard API 只在安全環境下才開放,也就是 https 或 localhost。這代表等專題掛上 GitHub Pages 的 https 網址之後,才是這個功能真正的考場,目前在本機看到的結果只能當預演。

  • 功能偵測的結果,要有對應的備案
    偵測只是第一步,偵測完要有路可走。Day 26 的複製功能就是範例:Clipboard API 不支援,就退回 execCommand。每一項紅色的勾,都應該對應到一個「那我改走哪條路」的答案。


開發心得與設計考量

  • 重構的價值,在於縮短下一次修改的路
    整理完的程式碼不見得比較漂亮,也不會多出任何新功能。但現在想調整換算公式,只要改 Clinic.math.toPercent 這一個地方,四個畫面一起生效。省下的是「記得回頭改另外三份」這份心理負擔。

  • 相容性的問題,全部住在自己沒測過的裝置裡
    電腦上跑得好好的功能,到手機上才露出馬腳。昨天那一輪手機測試,一次就冒出兩個症狀,一個是舊手機的複製功能沒反應,一個是音效要點兩下才出聲。今天做的功能偵測清單,等於是替每台裝置準備一張體檢表,之後拿到任何一支沒見過的手機,打開這頁就知道哪裡會出狀況。

  • 效能要先量,再改
    開工前我認定「應該是動畫太多」,結果量出來,最誇張的差距出在那個看起來人畜無害的 offsetHeight。憑直覺猜的瓶頸,很常不是真正的瓶頸。這讓我決定以後覺得「好像有點卡」的時候,先打開量測,再決定要動哪裡。


今日結語與明日預告

今天診所替自己做了一次完整的健檢:把重複的公式收進同一個 Clinic 櫃子、用功能偵測替每台裝置列出體檢表、再把 DOM 寫入跟背景動畫的效能問題量過一輪。三張檢驗單跑完,這間診所終於不是只有在我自己的電腦上才站得住。

不過健檢查的是程式碼的體質,使用者實際走過一遍會遇到什麼,單子上看不出來。

所以做完這三張檢驗單,我又關掉編輯器,換成使用者的身分,從首頁開始認真玩了一輪。這一輪玩下來,挑出三個一定要在上線前處理掉的破綻:

  • 切到英文、日文、韓文之後,測驗的題目、選項、抽到的籤詩,全部還是整排的中文
  • 處方、飄過去的紅色氣泡、發洩牆上的三句示範留言,同樣停在中文
  • 呼吸過場的文字吐了兩口氣之後就不動了,圓圈卻還在一縮一放

三個問題各有各的來歷,今天的健檢報告上完全查不出來,只有真的走過一遍才會發現。

明天,診所繼續公休,我們分三段把這三個破綻一個一個補起來。

把這三個破洞補齊。我們明天見!


上一篇
Day 26:出院通知書產生器,一份病歷四種語氣
下一篇
Day 28:上線前的三項修補,漏翻的題目、漏翻的資料、斷氣的呼吸
系列文
賽博紓壓診所:從零打造純前端心理測驗與互動紓壓網頁 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言