工程師與貓
ESC
Content
    ↑↓ navigate open esc close
    Published on

    有人說我的部落格很絲滑,因為它幾乎不送 JavaScript

    Authors
    • avatar
      Name
      Alex Yu

    前陣子在 Threads 有人留言說我的部落格「很絲滑」。

    我很開心,但那跟我 CSS 寫得好沒什麼關係。是因為這個站現在幾乎不送 JavaScript 給瀏覽器。

    五月底我把它從 Next.js 15 App Router 搬到 Astro 6。搬家本身一天就做完了,收尾的坑修到半夜。

    一個 34 篇文章的部落格,在維護一套 app 框架

    這個部落格是從 tailwind-nextjs-starter-blog 起家的,模板本身很好。問題是我用它做的事情,跟它被設計來做的事情不一樣。

    34 篇文章、一個標籤頁、一個搜尋框。需要 JavaScript 的地方數得出來:深色模式切換、手機選單、留言區、Cmd+K 搜尋、回到頂端、圖片點擊放大。

    但當時的相依長這樣:

    {
      "next": "15.1.11",
      "contentlayer2": "0.5.3",        // 內容 pipeline 全長在這上面
      "next-contentlayer2": "0.5.3",
      "next-themes": "^0.3.0",         // 只為了切深色模式
      "@headlessui/react": "2.2.0",    // 只為了手機選單的滑入動畫
      "pliny": "0.4.0"                 // 模板附的 kbar 搜尋 / SEO / 分析封裝
    }

    真正推我一把的是第一行的那個 2

    Contentlayer 原專案已經停止維護,contentlayer2 是社群 fork 出來接手的版本。而我的整條內容 pipeline——frontmatter 驗證、readingTimetag-data.json、搜尋索引——全部長在這一個套件上。

    它現在還會動。但 Next.js 下一次出大版本的時候,這個 fork 會不會有人跟著更新,決定權不在我手上。

    那時候我才看清楚,我在維護的是一套為互動 app 準備、卻只拿來排版文章的建置流程。

    四個選項,風險最大的是「不動」

    攤開來評估的時候有四個:Astro、Remix、TanStack Start、維持現狀。

    直覺上維持現狀最安全——不用搬、不用學、不會多出新風險。

    可是不搬也不是真的沒事。Contentlayer 這條相依會擋住我以後每一次升級,哪一次會真的裝不起來,我也說不準。

    Remix 的 nested routing 和 data loading 很強,可是它仍然是為互動 app 設計的框架,我的複雜度沒有真的降下來。TanStack Start 我很喜歡,但 2026 年初它還太新。

    選 Astro 的理由只有一句話:預設是靜態的

    Islands:要互動的地方才給 JavaScript

    Astro 的心智模型很單純。整頁預設是純 HTML,零 JS。哪裡需要互動,就在哪裡放一個 island(島),只有那個 component 會在瀏覽器裡跑起來。

    ---
    // src/pages/blog/[...slug].astro
    import Comments from '../../components/Comments.tsx';   // React 島
    import Lightbox from '../../components/Lightbox.tsx';   // React 島
    ---
     
    <article set:html={post.body} />   <!-- 純 HTML,零 JS -->
     
    <Comments client:load {...giscusConfig} />
    <Lightbox client:idle />

    client:load 是「頁面載入就 hydrate」,client:idle 是「等瀏覽器閒下來再 hydrate」。還有 client:visible(進到 viewport 才 hydrate)跟 client:media(符合某個 media query 才 hydrate)。

    hydrate(水合):把伺服器送來的靜態 HTML 接上 JavaScript,讓它開始能互動。

    原本的 React component 不用丟掉,直接當島複用。這件事對搬家成本是關鍵——多數檔案是搬過去,不是重寫。

    搬家的紀律:URL 一個都不准變

    我給自己定了一條規則:這次只換框架,其他一律不動。

    • 檔名即 slug,YYYYMMDD- 前綴保留 → 舊網址全部原樣活著
    • SEO 輸出(JSON-LD、sitemap、RSS、per-tag RSS)重建,但吐出來的內容要一致
    • security headers 從 next.config.js 搬到 vercel.json
    • 只砍掉一個東西:圖片 lightbox,為了先讓 MVP 上線

    砍掉一個 dependency,多半得把它做的事自己寫出來。next-themes 變成 <head> 裡的一段 script:

    <!-- src/components/ThemeScript.astro -->
    <script is:inline>
      ;(function () {
        try {
          var stored = localStorage.getItem('theme')
          var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
          var resolved =
            stored === 'light' || stored === 'dark' ? stored : prefersDark ? 'dark' : 'light'
          if (resolved === 'dark') {
            document.documentElement.classList.add('dark')
          } else {
            document.documentElement.classList.remove('dark')
          }
          document.documentElement.style.colorScheme = resolved // ← 捲軸、表單元件跟著換
        } catch (e) {}
      })()
    </script>

    is:inline 是叫 Astro 原樣輸出、不要打包——這段必須在頁面繪製前就跑完,晚一步就會閃白。

    @headlessui/react 只用在手機選單的滑入,換成一個 data-state 屬性加兩條 CSS:

    [data-mobile-nav-overlay] {
      transform: translateX(100%);
      opacity: 0;
      pointer-events: none; /* ← 關閉時留在 DOM 裡,離場動畫才播得完 */
      transition:
        transform 300ms ease-in-out,
        opacity 200ms ease-in;
    }
    [data-mobile-nav-overlay][data-state='open'] {
      transform: translateX(0);
      opacity: 0.95;
      pointer-events: auto;
    }

    開關的部分就是 overlay.dataset.state = 'open' 一行。kbar 搜尋則換成 Pagefind,build 時產靜態索引,支援中日韓斷詞。

    搬完之後整個站只剩兩個 React 島:留言區和圖片放大。其餘全部是 .astro 加幾行原生 JS。package.json 的 dependencies 從 37 個變成 28 個。

    砍掉的 lightbox 還好當天就補回來了——放螢幕截圖的文章沒有點擊放大,讀者會覺得壞掉。

    收尾的坑,全部在部署那一天

    程式碼搬完、本機跑起來,本以為就這樣結束了。然後 push 上 Vercel。

    Vercel 還以為這是 Next.js 專案

    deploy 直接失敗,訊息是 No Next.js version detected

    這個 Vercel project 當初是以 Next.js 建立的,Framework Preset 就一直釘在那裡,程式碼換掉它也不會自己改。修法是在 vercel.json 裡明講:

    {
      "framework": "astro",
      "buildCommand": "pnpm build",
      "outputDirectory": "dist"
    }

    緊接著第二發:Astro 6 要 Node 22.12 以上,Vercel 專案預設是 Node 20。.nvmrc 在本機有效,Vercel 不看它。要寫在 package.json

    { "engines": { "node": ">=22.12.0" } }

    島裡讀不到環境變數

    留言區在預覽環境整個消失。島有掛上去,但 render 出來就是 null

    原因是環境變數的命名慣例。Next.js 會自動把 NEXT_PUBLIC_* 開頭的變數塞進 client bundle;Astro/Vite 不吃這個前綴,它認的是 PUBLIC_*。所以在瀏覽器裡那些值全是 undefined,component 判斷 repo 是空的就自己 return 掉了。

    我不想去改 Vercel 上的環境變數名稱,所以改成在 .astro 這一端先讀好,再當 props 傳進島:

    ---
    // ❌ 島裡面 import siteMetadata → 瀏覽器讀到 undefined
    // import siteMetadata from '../data/siteMetadata';
     
    // ✅ 在 .astro 裡讀(這裡跑在 build/server,process.env 拿得到)
    const giscusConfig = {
      repo: process.env.NEXT_PUBLIC_GISCUS_REPO,
      repoId: process.env.NEXT_PUBLIC_GISCUS_REPOSITORY_ID,
      category: process.env.NEXT_PUBLIC_GISCUS_CATEGORY,
    };
    ---
     
    <!-- Astro 會把這個物件寫進 astro-island 標籤,瀏覽器端從 props 讀回來 -->
    <Comments client:load {...giscusConfig} />

    client:visible 永遠不會觸發

    留言區改用 props 之後還是沒出現。這次的原因更陰。

    client:visible 底層是 IntersectionObserver,它要觀察一個元素有沒有進到畫面。但 Astro 對島的容器元素套了一條全域樣式:

    astro-island { display: contents; }

    display: contents 的意思是「這個元素本身不產生任何方框,只留下小孩」。沒有方框,IntersectionObserver 就量不到它,於是「進入畫面」這件事永遠不會發生,島也就永遠不 hydrate。

    改成 client:load 就好了。留言區這種東西本來就值得無條件載入,多出來的 bundle 很小。

    搜尋跳出一個白色的空框

    Cmd+K 按下去,modal 開了,裡面一片空白。

    原因很蠢:Pagefind 的預設 UI 我只 dynamic import 了 JS,忘了它的 CSS。修法是讓 <link> 在 SSR 階段就寫進 HTML,不要交給 Vite 去 resolve:

    <link rel="stylesheet" href="/pagefind/pagefind-ui.css" />

    順便補了一段錯誤處理——索引沒載到的時候顯示一行訊息,不要留一個白框讓人以為壞了。

    (後來我又把 Pagefind 的預設 UI 換掉,自己重刻成原本 kbar 那種指令面板:沒輸入時列出全部 34 篇,輸入了才打 Pagefind 的核心 API。這部分算功能改版,不算遷移。)

    這些坑其實是同一件事

    環境變數那個和 client:visible 那個,看起來一個是設定、一個是 CSS,但它們卡在同一個地方:.astro 檔跑在 build 或 server 上,島跑在瀏覽器裡,這是兩個分開的執行環境。

    兩邊之間只有一種傳遞方式:你傳給島的 props,Astro 會轉成一段文字寫進 HTML 裡的 <astro-island> 標籤,瀏覽器再把那段文字讀回來還原成物件。

    序列化(serialize):把記憶體裡的物件轉成一段可以存、可以傳的文字(多半是 JSON),到了另一邊再還原回來。能不能被轉成文字,決定了它過不過得去。

    所以 process.env 過不去——它是一個只存在於 server 的介面,不是一份資料。它讀出來的值才過得去。

    而承接這段文字的 <astro-island> 是一個自訂元素,它有自己的排版行為(就是那個 display: contents),會影響 hydration 策略能不能運作。

    從 Next.js 過來的人最容易踩這裡。App Router 的 'use client' 邊界是編譯期的事情,寫起來像同一份程式碼。Astro 的邊界是執行期真的分成兩邊

    Vercel 那個是另一種:搬家的時候會記得改程式碼,但部署平台上那些「當初建專案時選的」設定,沒有人會提醒你。之前那篇 _next/static 在多架構節點上回 403 也是同一類——程式碼沒問題,環境跟你想的不一樣。

    幾個帶走的判斷

    選框架的時候值得誠實問一句:我真的需要這些抽象嗎?34 篇文章的站跟一個 app,用同一把尺會量錯。而「不動」看起來沒成本,其實只是把問題留給以後的自己。

    遷移的那條紀律我會再用一次:只換框架,其他不動。一開始順手多改一樣東西,搬家就會變成重寫。

    搬完之後這個站送出去的 JavaScript 少了很多,讀者感覺到的「絲滑」就是這個。我沒有為它優化過,Astro 的預設值本來就長這樣。

    延伸閱讀

    參考資料