Twikoo最新留言區塊效能最佳化

因為部落格的留言板改用Twikoo,其中的API可取得最新留言資料,所以特別在側邊欄增加一個「最新留言」區塊,目的有兩個:

  1. 先將後端喚醒(Vercel),前端執行背景更新,讓留言內容可以更快載入。
  2. 讓訪客知道最新留言有哪些(廢話XD)。

注意

本文由 Gemini 3.1 Pro 語言模型產生。

本文件記錄了 Hexo(Light 主題)從新增「最新留言」區塊,到解決效能瓶頸、消除佈局位移(CLS),以及解決 Vercel 冷啟動延遲的完整最佳化過程。

一、建立側邊欄小工具(Widget)

themes/light/layout/_widget/recent_comments.ejs 建立檔案。我們採用原生 fetch API 而非 SDK,以確保首頁載入負擔最小化。

1. HTML 結構與骨架載入效果(Skeleton Screen)

預先放置 5 個骨架元件,防止資料載入前的空白,並預留空間減少佈局位移。

<% if (theme.twikoo && theme.twikoo.enable){ %>
<div class="widget tag">
  <h3 class="title">最新留言</h3>
  <ul class="entry" id="twikoo-recent-comments">
    <li class="twikoo-skeleton">
      <div class="sk-text"></div><div class="sk-text" style="width: 70%;"></div>
      <div class="sk-meta"></div>
    </li>
  <li class="twikoo-skeleton">
      <div class="sk-text"></div><div class="sk-text" style="width: 40%;"></div>
      <div class="sk-meta" style="width: 30%;"></div>
  </li>
  <li class="twikoo-skeleton">
      <div class="sk-text"></div><div class="sk-text" style="width: 85%;"></div>
      <div class="sk-meta" style="width: 45%;"></div>
  </li>
  <li class="twikoo-skeleton">
      <div class="sk-text"></div><div class="sk-text" style="width: 50%;"></div>
      <div class="sk-meta" style="width: 35%;"></div>
  </li>
  <li class="twikoo-skeleton">
      <div class="sk-text"></div><div class="sk-text" style="width: 75%;"></div>
      <div class="sk-meta" style="width: 40%;"></div>
  </li>
</ul>
</div>
<% } %>

2. SWR(Stale-While-Revalidate)瞬間載入邏輯

利用 localStorage 實現「秒開」體驗:先顯示舊資料,背景更新後無縫替換。

在上述骨架元件最下方的<div><% } %>中間插入JS:

<script>
  document.addEventListener("DOMContentLoaded", function() {
    const container = document.getElementById('twikoo-recent-comments');
    if (!container) return;

    const cacheKey = 'twikoo-recent-cache';

    // === 模組化:建立專屬的「渲染畫面」函數 ===
    function renderComments(data) {
      if (!data || data.length === 0) {
        container.innerHTML = '<li style="color: #888;">暫無留言</li>';
        return;
      }

      container.innerHTML = ''; // 清除骨架元件或舊快取

      data.forEach(function(comment) {
        // 1. 內容截斷邏輯
        let text = comment.commentText;
        if (text.length > 25) {
          text = text.substring(0, 25) + '⋯';
        }

        // 2. 日期格式化
        let dateStr = comment.relativeTime;
        if (!dateStr) {
          const d = new Date(comment.created);
          dateStr = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
        }

        // 3. 建立 DOM
        const li = document.createElement('li');
        li.innerHTML = `
          <a href="${comment.url}#${comment.id}" class="twikoo-comment-text" title="${comment.commentText}">${text}</a>
          <div class="twikoo-comment-meta">
            <span class="twikoo-comment-nick">${comment.nick}</span> / <span class="twikoo-comment-date">${dateStr}</span>
          </div>
        `;
        container.appendChild(li);
      });
    }

    // === 步驟 1:[瞬間] 嘗試讀取本地快取 ===
    try {
      const cachedData = localStorage.getItem(cacheKey);
      if (cachedData) {
        renderComments(JSON.parse(cachedData)); // 瞬間蓋掉骨架元件,印出舊留言
      }
    } catch (e) {
      console.error('讀取快取失敗:', e);
    }

    // === 步驟 2:[背景] 向 Vercel 發送真實請求 ===
    fetch('<%= theme.twikoo.envId %>', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        event: 'GET_RECENT_COMMENTS',
        pageSize: 5,           // 抓取 5 筆
        includeReply: true     // 包含回覆
      })
    })
    .then(res => res.json())
    .then(res => {
      if (res.data) {
        // === 步驟 3:[更新] 存入快取並重新渲染最新內容 ===
        try {
          localStorage.setItem(cacheKey, JSON.stringify(res.data));
        } catch (e) {
          console.error('寫入快取失敗:', e);
        }
        renderComments(res.data); // 如果有新留言,這裡會無縫替換掉畫面
      }
    })
    .catch(err => {
      // 容錯機制:如果連線失敗,且畫面上還是骨架元件(代表也沒有快取),才顯示錯誤文字
      if (container.innerHTML.includes('twikoo-skeleton')) {
        console.error('取得最新留言失敗:', err);
        container.innerHTML = '<li style="color: red;">載入失敗</li>';
      } else {
        // 如果畫面上已經有快取舊資料了,就在後台默默報錯,不驚動訪客
        console.warn('背景更新留言失敗,繼續顯示快取資料:', err);
      }
    });
  });
</script>

3. 啟用側邊欄小工具

打開主題設定檔 themes/light/_config.yml,在 widgets: 陣列中加入我們剛寫好的小工具:

widgets:
- search
- category
- recent_comments  # <--- 新增這一行
- tag

二、視覺樣式最佳化(CSS)

將以下樣式加入主題的 themes/light/source/css/_custom/twikoo.styl 檔案中。包含「緊湊排版」與「骨架載入效果的呼吸燈動畫」。

/* 1. 調整單則留言的外框 (增加底部空間來補償) */
#twikoo-recent-comments li {
  margin-bottom: 15px;
  line-height: 1.5;
  border-bottom: 1px dashed #eee;
  /* 原本是 10px,我們把它加厚到 15px,用來填補中間縮小的空隙 */
  padding-bottom: 15px;
}

/* 2. 調整留言內容 (拉近與下方作者的距離) */
.twikoo-comment-text {
  display: block;
  font-size: 1em;
  /* 把原本的 4px (甚至可能是被 Hexo 主題預設撐開的空間) 強制縮小到 2px */
  margin-bottom: 2px !important;
  line-height: 1.4; /* 稍微收緊多行文字本身的行距,看起來更緊湊 */
  word-break: break-all;
}

/* (原本的 .twikoo-comment-meta 保持不變即可) */
.twikoo-comment-meta {
  font-size: 0.9em;
  color: #888;
}

/* =========================================
   Twikoo 最新留言 - 骨架載入效果動畫
   ========================================= */
.twikoo-skeleton {
  margin-bottom: 15px;
  padding-bottom: 10px;
  border-bottom: 1px dashed #eee;
}
.twikoo-skeleton:last-child {
  border-bottom: none;
  margin-bottom: 0;
}
.twikoo-skeleton .sk-text {
  width: 95%;
  height: 14px;
  background-color: #e2e5e7;
  border-radius: 4px;
  margin-bottom: 6px; /* 增加假文字的行距 */
  animation: sk-pulse 1.5s infinite ease-in-out;
}
.twikoo-skeleton .sk-meta {
  width: 50%;
  height: 12px;
  background-color: #f0f2f3;
  border-radius: 4px;
  margin-top: 10px; /* 把假日期往下推,增加整體高度 */
  animation: sk-pulse 1.5s infinite ease-in-out;
}
@keyframes sk-pulse {
  0% { opacity: 0.5; }
  50% { opacity: 1; }
  100% { opacity: 0.5; }
}

三、解決 Vercel 冷啟動延遲(Cold Start Latency)

Serverless 函數閒置 5~10 分鐘會進入休眠,我們透過外部定時任務強迫 Vercel 保持清醒。

1. 為什麼 GET 請求無效?

一般的監控服務使用 GET 請求,常被邊緣快取(Edge Cache)直接回傳 HTML,無法觸發底層 Node.js 執行環境。

2. 使用 cron-job.org 發送 POST 預熱

  • URLhttps://comment.mynet.tw
  • Execution schedule:每 5 分鐘一次
  • Request methodPOST
  • HeadersContent-Type: application/json
  • Request body{"event": "GET_RECENT_COMMENTS", "pageSize": 1}

效果:
強迫 Vercel 喚醒 Node.js 並執行 MongoDB 查詢,將首次連線延遲從 1.5s+ 壓到 0.5s 左右。

四、機器人耗用資源試算

如果設定每 5 分鐘執行一次,一個月大約會喚醒 Vercel 伺服器 8,640 次。這數字聽起來很驚人,但對比 Vercel 與 MongoDB 的免費額度,實際消耗量連 1% 都不到,非常安全。

1. Vercel 資源消耗評估(每月)

  • 執行次數:佔用不到 1%
    免費上限為 100 萬次,我們僅消耗約 8,640 次。
  • 運算時間(Compute):
    免費上限為 Active CPU Time 4 小時(約 14,400 秒)。每次 ping 主動 CPU 時間遠低於 400ms(扣掉等待 MongoDB I/O 的時間),整月累積佔比極低。
  • 網路流量:佔用約 0.01%
    免費上限為 100 GB。由於每次只抓取 1 筆純文字留言,單月總流量不到 20 MB,比載入十幾張高畫質圖片還要小。

2. MongoDB 資料庫壓力評估

  • 讀寫頻率:毫無壓力
    免費版限制「每秒 100 次讀寫」,而我們的腳本是「每 300 秒才讀取 1 次」,完全不會拖慢其他正常留言的寫入速度。
  • 傳輸流量:佔用不到 0.1%
    免費版限制「連續 7 天內傳輸 10 GB」。經過計算,腳本一整個月的資料傳入與傳出總和僅約 35 MB,消耗佔比非常的低。

結論:
你可以 100% 放心掛著這隻每 5 分鐘 POST 一次的定時機器人,它不僅不會讓你超出免費額度,還能隨時維持在預熱狀態,讓留言板的內容不會延遲顯示。

五、整體方案總結

第一次訪問 → 骨架載入效果 → API 回傳後渲染 → 存入 localStorage
之後訪問 → 瞬間顯示快取舊資料 → 背景更新 → 有新留言就無縫替換

這樣即使 Vercel 偶爾冷啟動,訪客也不會看到等待時的空白畫面。而手機版 RWD 單欄布局讓側邊欄的 API 請求變成低優先級,反而不會拖慢主要內容的載入。