ข้ามไปยังเนื้อหา

Relay Connections

offset pagination ระบุ ตำแหน่ง ซึ่งเลื่อนไปเมื่อ list เปลี่ยนแปลง cursor pagination แก้เรื่องนี้ด้วยการระบุ item: แต่ละ item จะได้ cursor ทึบแสงหนึ่งตัว และคุณขอ “item ถัดไป N อัน หลังจาก cursor นี้” เพราะ cursor ตั้งชื่อให้ item เฉพาะตัวแทนที่จะเป็นช่อง การแทรกและลบที่อื่นใน list จึงไม่ทำให้ผลลัพธ์ซ้ำหรือถูกข้ามอีกต่อไป

รูปร่างของ cursor pagination ที่ใช้กันแพร่หลายที่สุดคือ Relay Connection specification ซึ่งกำหนดชื่อ field ให้เป็นมาตรฐาน tooling และ client จึงทำ pagination กับ list ไหนก็ได้ด้วยวิธีเดียวกัน

connection ห่อ list ไว้ในสามชั้นที่ซ้อนกัน แทนที่จะคืน [Post!]! โดยตรง field ที่เป็น list จะคืน PostConnection:

type Query {
posts(first: Int, after: String): PostConnection!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}

ถอดรหัสแต่ละชั้น:

  • edges คือ list — แต่แต่ละรายการคือ edge ไม่ใช่ node เปล่า ๆ
  • node คือ item ตัวจริง (Post) การห่อไว้ใน edge เผื่อที่สำหรับข้อมูลเฉพาะของแต่ละ edge
  • cursor คือ string ทึบแสงที่ระบุตำแหน่งของ edge นั้นใน connection นี้ ให้ปฏิบัติเหมือนกล่องดำ อย่า parse เด็ดขาด
  • pageInfo บรรจุ metadata สำหรับ paging hasNextPage บอกว่ามี item เพิ่มอีกหรือไม่ ส่วน endCursor คือ cursor ของ edge สุดท้าย ซึ่งคุณป้อนเข้าไปใน request ถัดไป

pagination ไปข้างหน้าใช้ argument สองตัว first: N ขอ item ถัดไป N อัน after: cursor บอกว่า “เริ่ม หลังจาก cursor นี้” หน้าแรกละ after ไว้:

{
posts(first: 2) {
edges { node { title } cursor }
pageInfo { hasNextPage endCursor }
}
}

ในการเอาหน้าถัดไป คุณหยิบ pageInfo.endCursor จาก response แล้วส่งกลับเป็น after (สเปกยังนิยาม last/before สำหรับ paging ย้อนกลับ ที่เป็นภาพสะท้อนกระจกของ first/after)

flowchart TD
  Conn["PostConnection"]
  Conn --> Edges["edges: [PostEdge!]!"]
  Conn --> PI["pageInfo: PageInfo!"]
  Edges --> Edge["PostEdge"]
  Edge --> Node["node: Post!"]
  Edge --> Cursor["cursor: String! (opaque)"]
  PI --> HNP["hasNextPage: Boolean!"]
  PI --> EC["endCursor: String — feed into next 'after'"]
connection ห่อแต่ละ item ไว้ใน edge ที่มี cursor; pageInfo บอกคุณว่าจะดึงหน้าถัดไปอย่างไร

runner ด้านล่างสร้าง schema PostConnection ตัวจริง แล้ว resolve first/after ด้วยการเข้ารหัส id ของแต่ละ item เป็น cursor รันดูเพื่อดึงหน้าหนึ่ง จากนั้นคัดลอก endCursor จากผลลัพธ์ไปใส่ argument after: แล้วรันใหม่เพื่อเดินหน้าต่อ

JavaScript

ผลลัพธ์ให้ edge สองอัน แต่ละอันมี node และ cursor ทึบแสง พร้อมกับ pageInfo ที่บอก hasNextPage: true และส่ง endCursor กลับมา ส่ง endCursor นั้นเป็น after แล้วคุณจะได้สองอันถัดไป — และเพราะ cursor ตั้งชื่อให้ item ไม่ใช่ตำแหน่ง การแทรก post ใหม่ที่ด้านบนของ list จะไม่ทำให้หน้าสองซ้ำหรือข้ามอะไรไป ความเสถียรนั้นคือเหตุผลทั้งหมดที่ connection มีอยู่

ข้อดีข้อแลกเปลี่ยน
stable pagination — item ไม่หาย/ซ้ำเมื่อ data เปลี่ยนverbose กว่า offset — edges.node แทน item ตรง ๆ
รองรับ infinite scroll ได้ดีต้องออกแบบ cursor strategy อย่างดี
pageInfo ให้ข้อมูล hasNextPage, hasPreviousPage ครบcursor ที่ opaque ทำให้ skip ไปหน้าใดไม่ได้
มาตรฐาน spec ที่ client library รองรับ (Apollo, Relay)implementation ซับซ้อนกว่า offset

Cursor ที่เป็น Offset ซ่อนอยู่ อาการ:

  • cursor เป็นแค่ base64(offset) — ไม่ stable
  • เมื่อ item ถูกลบ cursor เดิมชี้ไปผิด position
  • ใช้ opaque cursor จาก unique identifier เช่น base64(id) หรือ base64(createdAt:id)

Edge Type ที่ไม่มี Custom Field อาการ:

  • Edge มีแค่ node และ cursor — ไม่ใช้ประโยชน์ของ Edge
  • edge สามารถมี relationship metadata เช่น joinedAt สำหรับ user-in-group

💡 ตัวอย่างจากของจริง

GitHub:

  • ทุก list ใช้ Relay Connection spec: repository.issues(first: 10, after: $cursor)
  • ทำให้ paginate ผ่าน issue, PR, comment ได้อย่าง consistent

Shopify:

  • Storefront API ใช้ Connection ทุกที่ — products, orders, collections
  • client library เช่น Hydrogen ใช้ Connection spec สำหรับ automatic pagination
ใน Relay connection แต่ละรายการใน edges บรรจุอะไร?
คุณดึงหน้าที่อยู่ถัดจากหน้าปัจจุบันอย่างไร?
ทำไม cursor จึงทนทานกว่า offset เมื่อ list เปลี่ยนแปลงระหว่างการเรียกดู?