用 Astro 重寫我的 Nuxt Content 部落格

用 Astro 重寫我的 Nuxt Content 部落格

開發筆記

前言

不久前才寫過這篇 用 Nuxt Content 重寫我的 Hugo 部落格,結果過不到幾個月,我又要重寫我的部落格了。

部落格裡的第一篇文章 踏上自架部落格之路 ,紀錄了我開始自架部落格的始末,從選擇自架用 Wordpress、Hexo、Hugo,到前幾個月改用 Nuxt Content,這些過程到現在大概快一年的時間。

也就是說,我的部落格在這一年之內就已經寫了 4 個版本…

連我自己其實都有點納悶,我的「寫部落格」似乎跟別人不太一樣,大家是在寫文章,而我是在寫部落格本身,以經營部落格這件事來說,這樣似乎是有點本末倒置了。

但我就是有點完美主義的人,只要我覺得不夠理想,就會繼續嘗試其他工具或方法。

也因此我意外發現了 Astro,把玩了一陣子後,決定試試用 Astro 來重寫我的部落格。

為何想重構部落格

部落格改用 Nuxt Content 後,維護的體驗好了不少,有種升級到現代的感覺,想要擴充或使用套件都相當容易。

但嚴格來說這都算是 Nuxt 所帶來的好處,單純論 Nuxt Content 來說,我認為 Nuxt Content 還不算是個穩定的套件,Bug 數量不少外,文檔資訊也有點模糊。

在 Nuxt Content 遇到的問題

小問題的話,如 build 失敗或元件 Bug,例如 ProsePre 這個 Prose Components,文件上顯示的效果看起來很不錯:

Nuxt Content ProsePre 效果

但實際使用後我的樣式和 HightLight 完全不見,最後只好自幹一個。

大問題的話,我認為是 Nuxt Content 缺乏專案結構上的最佳實踐或範例,通常需要一番嘗試才能搞懂。

例如建立頁面在 Nuxt3 中一般都是使用 Page,但如果開啟了 Document Driven ,在使用 Page 就會報錯。

因為 Nuxt Content 會將放置 Markdown 的 content 資料夾當作 page route,使用 Nuxt 本身的 page route 可能會產生衝突,得改用 Layout 才行。

然而如果我想建立一個並非由 Markdown 產出的頁面,例如文章列表頁面,那就會出問題了…實在有點惱人。

當然只要不使用 Document Driven 就能解決,但就不能用 useContent() 這個方便取得文章資料的方法,得使用像是 queryContent() 來處理文章資料,對我想要的部落格架構來說會稍微麻煩一點,最終我還是開啟了 Document Driven

結果完成後不久,Nuxt Content 文件突然來個頁面大升級的同時,我也發現 Document Driven Mode 被提示為實驗功能…這也讓我更確信了遲早得重構現在的部落格架構。

Document Driven Mode Hint

總之,我認為 Nuxt Content 本質上還是跟 VuePress 或 VitePress 比較相似,更適合用來生成 Markdown 文件用,套件本身的設計理念不太服務於建立部落格。但因為 Nuxt 足夠強大,在對於頁面內容需求單純的情況下,仍然能讓你開發出一個像樣的部落格。

效能不如預期

部落格初期時效能幾乎沒什麼問題,稍微優化一下就能有不錯的表現。

Nuxt Content 部落格效能初期效能表現

但隨著文章數量越來越多後,部落格的效能開始有了明顯的下降。

Nuxt Content 部落格效能後期效能表現

除了圖片載入導致 Largest Contentful Paint 表現較差外,Nuxt 本身在 Client 端啟動所執行的 JavaScript 也會造成一定的載入延遲,因而導致部落格效能下降。

Nuxt Content 部落格效能問題

當然 PageSpeed 的分數並不是絕對,即使效能顯示黃燈,但實際操作的感受上並不明顯,所以起初我並不太在意。

直到寫了 用 Nuxt3 + TresJS 簡單製作 3D 互動場景 這篇文章,因為文章中有較多的程式碼、圖片甚至是 GIF。

除了網頁效能分數低下外,實際操作 SPA 換頁時用手機 4G 下會卡個 4 秒左右,體驗相當差。

其中最大的原因是來自於我自己寫的 Prose Components,在處理程式碼 HightLight 的部分有效能上的問題。

但即使在我排除後,仍需要約 2.5 秒左右後才能載入畫面。

Nuxt Content 載入效能問題

當然這也有暫時性的手段能解決,只要加個 Loading 畫面即可。例如使用 Nuxt 提供的 <NuxtLoadingIndicator> 就能實現一個簡單的 Loading 效果。

NuxtLoadingIndicator 效果

但照理來說使用了 NuxtLink 中的 Prefetch,應該不會有那麼明顯的等待才對,更何況所有圖片都已經使用 webp,理論上不應該會那麼緩慢。

我推測可能是 Nuxt Content 的問題,之前也有載入效能相關的 issue 討論,但印象中已經被修復了,實際問題為何等哪天心血來潮再來研究了…

為何選擇 Astro

最初發現 Astro 是在 twitter 上,看到有人分享了這張圖片:

Real-World Performance Comparison

不過也有些貼文質疑這張圖的真偽,再加上原圖出處正是自家 Astro Blog 的文章,在不知道實際樣本的情況下,圖表的參考價值確實不大,所以起初並沒有太吸引我的注意。

然而某天讀到 Kalan 大大寫的這篇 用 Astro 寫電子報網站,又在同事一番推薦下,才讓我決定稍微研究一下 Astro 這個框架。

Astro 官方文件這篇 Why Astro? 寫了詳細關於 Astro 的優點,蠻建議可以看看。

其中我認為 Astro 有幾個特色相當吸引我:

  1. Zero JS, by default:
    也就是回到像是最古早撰寫 HTML 開發網站一樣,進入畫面時不會載入任何你不知道或不需要的 JS。

  2. Astro Islands:
    Islands 簡單來說就是將每個元件當作是一個獨立的「島」,彼此之間是獨立運作的,但彼此之間又可以互相溝通。

    在預設情況下,Astro 可以將 JS 與元件中的 HTML 和 CSS 分離,讓開發者根據需求決定要載入 JS 的時間點,理論上能大大的提高網頁的效能。

    除此之外,Islands 實現了讓 Astro 能使用不同框架,例如 React、Vue、Svelte 等,若之後有需求得用到其他框架的套件,也可以匯入使用。

  3. Customizable:
    Astro 的開發環境和主流框架幾乎一樣,預設使用 TypeScript,也能使用 Tailwind。

Astro 的設計原則,更是完全戳中了我在 Nuxt Content 部落格上遇到的問題:

  • SPA 框架導致的效能問題。
  • 需要能自訂義內容但開發體驗又好的靜態生成器。

換過那麼多部落格後,我很清楚我在意的就是網站效能、可維護性、結構容易管理。

主流框架功能強大,但 SPA 的解決方案又容易導致效能問題,傳統架構符合需求但開發體驗又往往不好;而 Astro 算是在中間點,幫規模小且內容為主的專案找到了一個合適的解決方案。

光這一頁文件就完全點燃了我對 Astro 這個框架的興致,也毅然決然的選擇用 Astro 來重構我的部落格。

使用 Astro 重構部落格

在安裝 Astro 的時候,CLI 就相當讓我驚訝,居然有可愛的對話機器人(?):

Astro install CLI

然後居然有提供部落格的範本:

Astro install CLI options

仔細一查,官方文件甚至有提供建立 Astro 部落格的教學文件…,能讓我感受到 Astro 真的就是為了建立部落格的用戶而設計的。

專案結構

├── public/
├── src/
│   ├── components/
│   ├── content/
│   │   ├── posts/
│   │   └── config.ts
│   ├── layouts/
│   ├── pages/
│   └── styles/
├── astro.config.mjs
├── README.md
├── package.json
└── tsconfig.json

專案結構跟主流框架非常相似,一樣會有 components、layouts、pages 等資料夾。

layouts 可以用來製作頁面的預設模板,並且可以用 來 inject 頁面中的內容。

pages 資料夾和 Nuxt 的 Routing 方式類似,pages 底下的檔案會渲染對應的頁面路由,並且可以使用 getStaticPaths 來處理動態路由

其中稍微不太一樣的是多了 content 資料夾,主要用來存放文章 markdown 檔案,其中 content 目錄下有一個 config.ts,可以設定 content schemas,並且支援 Zod 的所有功能。

例如我有一個 Markdown 文章的 schema 如下:

---
title: 文章標題
description: 文章描述
pubDate: 2023-09-30T02:40
draft: false
---

config.ts 中,我可以自定義 schema 的型別:

config.ts
import { defineCollection, z } from "astro:content";
 
const posts = defineCollection({
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.coerce.date(),
    draft: z.boolean().optional(),
  }),
});
 
export const collections = { posts };

這樣 schema 輸入的參數就會被檢查並提示。

其中我有設定一個 draft 的參數,可以讓我在渲染時判斷要不要顯示在 production 環境下。

index.astro
export async function getStaticPaths() {
  const posts = await getCollection("posts", ({ data }) => {
    return import.meta.env.PROD ? data.draft !== true : true;
  });
}

另外 schema 的部分可以搭配 VSCode 的 Snippet 功能:

settings.json
{
  "NewPost": {
    "prefix": "# NewPost",
    "body": [
      "---",
      "title: 請輸入標題",
      "pubDate: $CURRENT_YEAR-$CURRENT_MONTH-$CURRENT_DATE$T$CURRENT_HOUR:$CURRENT_MINUTE",
      "description: 大綱",
      "draft: true",
      "---"
    ],
    "description": "Create new post in blog"
  }
}

這樣一來要寫新文章時,只要在 Markdown 文件中直接輸入 # NewPost,就可以自動產生一個 schema 的 Markdown 文件,詳細資訊再手動更改即可。

Astro 模板語法

Astro 有一個強大的優勢在於,能撰寫 React、Vue、 Svelte 等其他框架的 Component 後使用,這讓不同框架的開發者上手變得相當容易。

但我個人一向是比較推崇原生支援度最好的作法,支援使用框架難免會有一些 Bug,所以我還是決定撰寫 .Astro

.Astro 是 Astro 提供的模板語法,下面是一個渲染 PostList 元件的結構程式碼:

---
import Default from "../../../layouts/Default.astro";
import PostList from "../../../components/PostList.astro";
import { SITE_TITLE } from "../../../consts";
 
const { page } = Astro.props;
const params = Astro.params;
---
 
<Default title={`${SITE_TITLE}`}>
  <div slot="main">
    <div>
      {
        page.data.map((post) => (
          <PostList
            slug={post.slug}
            title={post.data.title}
            pubDate={post.data.pubDate}
            description={post.data.description}
          />
        ))
      }
    </div>
  </div>
</Default>

Astro 模板語法中的 ---,跟寫 Vue 模板時所使用 <script setup> 來區分 JS 的部分有點相似。但模板寫法上跟 TSX 比較接近,一樣可以使用 props 傳入、變數用大括號 {} 傳遞、陣列或物件遍歷則用直接在 HTML 中處理。

只不過要注意,在 --- 裡面的程式碼只會運作在 Server Side

也就是說如果要使用 document 或是 window 是不行的:

Astro Error - window is not defined

如果要使用的話,直接在 HTML 模板區塊下面添加 <script> 標籤,並將程式碼放在 <script> 中即可:

---
console.log("Welcome, server console!");
---
 
<h1>Welcome, world!</h1>
 
<script>
  console.log("Welcome, browser console!");
</script>

Astro 環境 console 輸出

另外 <script> 內的程式碼還是會被 Astro 處理並優化,所以如果程式碼是從外部引入,例如從 CDN 或是專案中的 /public 目錄中,由於這些路徑不會被 Astro 處理過,因此最終 build 出來的連結會是錯誤的。

這時候會需要添加 is:inline 讓 Astro 不要處理 <script> 內的程式碼,詳細可以參考官方文件

<script is:inline src="https://my-analytics.com/script.js"></script>

Astro 事件觸發

上面介紹了 Astro 會用到的模板語法,理解後就可以製作部落格了。

不過想當然的,只要是框架都免不了坑,而在 Astro 中稍微麻煩的是 Dom 的事件觸發,例如下面這樣綁定 onClick 的方式是不 work 的:

---
function handleClick() {
  console.log("button clicked!");
}
---
 
<!-- ❌ This doesn't work! ❌ -->
<button type="button" onclick={handleClick}>click me!</button>

這是因為 Astro 的模板語法會把 HTML 直接轉換成字串如下:

<body>
  <button
    type="button"
    onclick='function handleClick() {
    console.log("button clicked!");
  }'
    data-astro-source-file="/src/pages/test/index.astro"
    data-astro-source-loc="7:45"
  >
    click me!
  </button>
</body>

當然有個取巧的方式,只要讓他轉換出來的字串是一個立即呼叫函式(IIFEs)就可以執行了:

---
function handleClick() {
  console.log("button clicked!");
}
---
 
<button type="button" onclick={`(${handleClick})()`}>click me!</button>

這樣一來渲染就會變成:

Astro IIFEs 觸發事件

就能正常運作了,不過這種作法並不安全,而且也不適合太複雜的 Function。

所以我還是建議依照 官方文件中提供的實作方法,使用 AddListener 來解決:

---
 
---
 
<button id="button">Click Me</button>
<script>
  function handleClick() {
    console.log("button clicked!");
  }
  document.getElementById("button").addEventListener("click", handleClick);
</script>

雖然比較麻煩,不過基本上不影響實作相關功能。

Astro 部落格效能表現

完成第一個版本後,我最想知道的就是 Astro 的部落格效能表現到底有多好?

直接跑一輪 PageSpeed 見真章,左邊為 Nuxt Content 部落格,右邊為 Astro 部落格:

Astro 部落格效能表現

結論:超乎我想像的好…

手機版居然是毫無懸念的滿分…

Astro 部落格效能表現詳細

仔細看分數指標可以發現,每項指標分數在 Astro 的部落格都是偏低且綠燈,另外麻煩的 LCP 和 TBT 都被解決了。

那我就開始好奇,頁面分數理論最糟的 TresJS 文章,在 Astro 的部落格中的效能分數會是多少?

首先是電腦版的差距:

TresJS 頁面效能電腦版對比

在 Nuxt Content 的部落格中,80 分左右的表現是我認為最低能接受的範圍,可想而知手機版的表現應該會慘不忍睹。

而在 Astro 的部落格中,效能依然能接近滿分。

然後是手機版的部分:

TresJS 頁面效能手機版對比

Nuxt Content 的部落格中,手機版的表現已經在 60 分邊緣,TBT 甚至超過了 2000 毫秒。

而 Astro 部落格在完全沒優化的情況下,手機版居然能保持在綠燈邊緣的 90 分,這倒是真的超乎了我的預期。

結語

更換到 Astro 的部落格後,意外的讓我相當滿意。

除了寫法稍微有點差異外,使用的開發工具幾乎沒有改變,除了不用煩惱套件帶來的煩人問題外,網頁效能還提升了不少。

若之後有擴充需求,可以另外安裝 React 或 Vue 等框架來解決;如果懷念起 SPA 網站的讀取換頁方式,Astro 甚至可以用 <ViewTransitions /> 變成 SPA 模式…簡直無懈可擊…

開發上如果有前端框架的經驗,那理解 Astro 模板語法及開發方式真的相當容易,官方文件還提供 Build your first Astro Blog 的教學文件,直接手把手帶你建立一個基本的部落格。

撰寫 Astro 的過程中,與開發 Nuxt 部落格的過程最主要差別還是在於生態圈的大小。

Nuxt 開發時可以直接使用許多現有的套件來實現一些 UI 功能,而目前為 Astro 設計的 UI Framework 並不多。

所以這次重構花費最多時間的部分,是將原本部落格使用的一些 UI Component 自己手寫實現,並改寫成 Astro 的版本。

雖然麻煩不少,但過程中也算是一併學習到了不少東西,而且以網站效能的成長幅度來看,轉換到 Astro 的投資報酬率相當高。

目前在台灣還沒有看過相關的就業機會,不然一些靜態網站如果用上 Astro 開發的話應該也很不錯,希望之後有機會看到 Astro 這個框架漸漸展露頭角。