這篇文章是我用AI整理,重寫了我與AI關於 GraphQL 的問題與討論,目標讀者是剛開始接觸 GraphQL 的開發者。文章不從規格或名詞出發,而是從實際開發時「你一定會遇到的問題」切入。


一、GraphQL 回傳的到底是什麼?為什麼不是 Array?

許多第一次接觸 GraphQL 的工程師,常會卡在一個直覺問題:為什麼不能像 REST 一樣,直接回傳一個 JSON array?例如查詢使用者清單時,直覺會希望 API 回傳 [{...}, {...}]

在 REST 中,你可能會這樣設計:

GET /users
[
  { "id": "1", "name": "Alice" },
  { "id": "2", "name": "Bob" }
]

但在 GraphQL 中,這樣的回傳是不合法的。GraphQL 規範要求 response 的最外層一定是 object,因為它需要同時承載 dataerrors 等資訊。

正確的 GraphQL 寫法會是:

query {
  users {
    id
    name
  }
}
{
  "data": {
    "users": [
      { "id": "1", "name": "Alice" },
      { "id": "2", "name": "Bob" }
    ]
  }
}

這代表你不是在「呼叫一個回傳 array 的 API」,而是在「查詢一個 object,而這個 object 裡的某個欄位是 array」。這個心智模型會影響你之後對錯誤處理與快取的理解。


二、Enum 與 Union 的差別:不要把值和型別混在一起

在設計 GraphQL schema 時,Enum 和 Union 都看起來像是在處理「多選一」,但它們解決的是完全不同層級的問題。

先看 Enum。Enum 用來表示「一個值有哪些合法選項」,例如訂單狀態:

enum OrderStatus {
  PENDING
  PAID
  CANCELED
}
 
type Order {
  id: ID!
  status: OrderStatus! // 永遠只會是一個值
}

這裡的 status 永遠只會是一個值,client 不能對它再查詢欄位。

再看 Union。Union 用來表示「這個欄位回傳的物件型別不確定」:

union SearchResult = User | Organization // 回傳的物件型別不確定
 
type Query {
  search(keyword: String!): [SearchResult!]!
}
query {
  search(keyword: "acme") {
    __typename
    ... on User {
      name
    }
    ... on Organization {
      legalName
    }
  }
}

如果你試圖用 Enum 來模擬這種情境,例如加一個 type 欄位再配多個 nullable 欄位,會讓 client 必須自己猜資料結構,這正是 GraphQL 想避免的事情。


三、為什麼 GraphQL 需要 Aliases?

假設你在同一個畫面中,需要同時顯示兩個使用者的資料。如果你這樣寫:

query {
  user(id: 1) {
    name
  }
  user(id: 2) {
    name
  }
}

這個 query 是不合法的,因為 response 會出現重複的 key。

GraphQL 的解法是使用 alias:

query {
  leftUser: user(id: 1) {
    name
  }
  rightUser: user(id: 2) {
    name
  }
}
{
  "data": {
    "leftUser": { "name": "Alice" }, // key1: leftUser
    "rightUser": { "name": "Bob" } // key2: rightUser,這樣就不會重複
  }
}

Alias 讓你在不改 schema 的情況下,為 response 定義清楚且不衝突的結構,這在比較畫面或 dashboard 類型的 UI 中非常常見。


四、Variables:為什麼不能用字串拼 Query?

在學 GraphQL 的時候,會有一個自然的疑問:「既然 Query 本質上就是一段字串,為什麼不能在前端直接用字串拼出我要的 Query?」

一個看起來沒問題的 Query

假設你有這樣一個 GraphQL Query:

query {
  hero(episode: JEDI) {
    name
  }
}

這個 Query 的意思很單純: 取得在  JEDI  這一集出現的主角名字。

如果需求永遠固定,這樣寫完全沒問題。但實際的產品幾乎不可能這麼單純。

真正的情境:資料來自使用者

在前端畫面上,使用者可能會選擇不同的集數,例如 NEWHOPEEMPIREJEDI。 這代表 episode 不是寫死的,而是來自使用者操作。

最天真的直覺會這樣做:用 JavaScript 字串把 Query 拼出來。

const episode = "JEDI";
 
const query = `
  query {
    hero(episode: ${episode}) {
      name
    }
  }
`;

這種寫法在一開始看起來可以運作,但很快就會變得難以控制。 隨著參數變多,你會開始處理引號、型別、條件判斷,Query 不再只是「描述資料」,而變成「被動態組裝的字串」。

這正是 GraphQL 想要避免的事情。

GraphQL 正確的做法:使用 Variables

GraphQL 提供了 variables,讓你可以把「Query 結構」和「實際資料」分開。

query Hero($episode: Episode!) {
  # 注意$episode前面的$
  hero(episode: $episode) {
    name
  }
}

Query 本身是固定的,它只描述你「要什麼資料」。 真正會變動的值,透過 variables 在執行時傳入。

{
  "episode": "JEDI"
}

每次呼叫時你只需要換 variables,Query 本身完全不需要改。

五、Fragments:當查詢開始重複時,你已經需要它了

在實務中,一個頁面往往不只一個地方需要顯示角色資訊。例如,假設你的頁面上有兩個地方都要顯示角色資訊:

  • 左邊:主角卡片
  • 右邊:推薦角色卡片

兩者都需要角色的名字(name)與朋友清單(friends)。這時如果不使用 fragment,很容易在不同的 Query 裡重複寫同樣的欄位:

hero {
  name
  friends {
    name
  }
}

一開始看起來只是多寫幾行,但隨著需求改變,問題會逐漸浮現。當角色需要多顯示一個欄位,或朋友清單的結構改變時,你必須同時修改所有重複出現的 Query,只要漏掉一個地方,就可能導致畫面或資料不一致。

Fragment 的目的,就是把這組「會重複出現的資料需求」集中定義在一個地方。你可以替這組欄位取一個名字,例如:

fragment CharacterBasic on Character {
  name
  friends {
    name
  }
}

這段 fragment 的意思是:在 Character 這個型別上,有一組常用的欄位集合,叫做 CharacterBasic。當你真正撰寫 Query 時,只需要引用它即可:

query {
  hero(episode: EMPIRE) {
    ...CharacterBasic
  }
}

GraphQL 在執行時會自動將 fragment 展開成實際的欄位,因此功能上等同於直接寫完整欄位,但維護上只需要改一個地方

新手疑問

為什麼 fragment 一定要寫 on Type?fragment 不就是把欄位抽出來重用嗎?

假設有這個 fragment:

fragment previewInfo on Email {
  subject
  bodyPreview
}

為什麼一定要寫 on Email

原因

因為 GraphQL 是強型別系統:

  • fragment 只能套用在「某一種節點型別」
  • 如果現在拿到的是 Sms、Notification,就不一定有這些欄位

重點

fragment 不是單純文字替換,而是:

「當目前節點是這個型別時,才合法展開的子結構」

六、Inline Fragments 與 __typename:處理不確定型別的必要工具

在 GraphQL 中,當你查詢的是 Interface 或 Union 型別時,代表後端只保證會回傳「某一種類型的資料」,但不保證實際是哪一個具體型別。例如以下 Query:

hero(episode: Episode!): Character

這表示回傳結果一定符合 Character 介面,但它可能是 Human,也可能是 Droid。因此,你只能直接存取 Character 介面明確保證存在的欄位,像是 name,而不能假設回傳結果一定具有某個具體型別才有的欄位。

當你需要依據實際型別,取得 HumanDroid 各自專屬的欄位時,就必須使用 inline fragment。inline fragment 的語意是「只有在回傳結果屬於某個型別時,才套用這段欄位定義」:

query HeroForEpisode($ep: Episode!) {
  hero(episode: $ep) {
    name
    ... on Droid {
      primaryFunction
    }
    ... on Human {
      height
    }
  }
}

在這個 Query 中,name 一定會被回傳,而 primaryFunctionheight 則只會在實際型別符合時才出現。GraphQL 會在執行階段自動判斷實際型別,確保不會回傳不合法的欄位

在 client 端,通常會搭配 __typename 來判斷目前回傳的是哪一種型別。__typename 是 GraphQL 內建欄位,會回傳實際型別名稱:

{
  search(text: "an") {
    __typename
    ... on Human {
      name
    }
    ... on Droid {
      name
    }
  }
}

透過 __typename,前端可以安全地依據型別分支處理資料與 UI,而不需要猜測資料結構。這在同一個列表中渲染不同 UI,或在使用快取系統正確識別資料時,都是不可或缺的工具

七、Directives:動態改變查詢結構,而不是拼字串

假設你的畫面有「摘要模式」與「詳細模式」,你可能只在詳細模式才需要朋友清單。

使用 directive 的寫法如下:

query Hero($withFriends: Boolean!) {
  hero {
    name
    friends @include(if: $withFriends) {
      name
    }
  }
}
{ "withFriends": false }

withFriendsfalse 時,friends 欄位完全不會被執行。這讓你不需要在前端動態拼 query,而是用變數正式地控制查詢結構。

下一篇

下一篇會介紹GraphQL 常見的 N+1問題:GraphQL 的「Graph」到底是什麼?為什麼會有 N+1 問題?