Lynx.js Workshop ตอนที่ 2: ดึงข้อมูล IoT แบบ Real-time
Lynx.js Workshop ตอนที่ 2: ดึงข้อมูล IoT แบบ Real-time
อ้างอิงโค้ดจริงจาก: โปรเจกต์
frontend-mobileใน kangana1024/showkhun-workshop (branchstep-14-mobile-alerts)
ในตอนที่แล้วเราต่อโครงแอปขึ้นมาได้ แต่มันยังเป็น “หุ่นโชว์” อยู่เลย — สวยแต่ไม่มีข้อมูลอะไรไหลเข้ามา 😅
ตอนนี้เราจะเสกชีวิตให้มัน! ทำให้ Dashboard ของเรา ดึงข้อมูล sensor จาก backend จริง แล้วอัปเดตค่าสด ๆ ต่อหน้าต่อตาผ่าน WebSocket — แบบที่เปิดแอปทิ้งไว้ ตัวเลขอุณหภูมิก็ขยับเองโดยไม่ต้องกด refresh เลย มาลุยกัน!
สิ่งที่จะได้เรียนรู้
- ทำไม mobile app ต้องมี typed API client แยกเป็น layer ไม่ยิง
fetchกระจาย - สร้าง REST client ที่มี timeout + abort + error envelope จากโค้ดจริง
- เข้าใจว่าทำไมเราต้องใช้ทั้ง REST และ WebSocket (คนละหน้าที่กัน)
RealtimeClientที่ auto-reconnect แบบ exponential backoff และ re-subscribe ห้องเองอัตโนมัติ- ประกอบทุกอย่างเข้าด้วยกันใน hook
useDashboardแล้ว merge ข้อมูล live เข้า list - render เป็น
SensorCardที่อัปเดตค่าเอง
WHY: ทำไมต้องแยก API client เป็น layer?
ก่อนจะเขียน คำถามแรกคือ — ทำไมไม่ยิง fetch('...') ในหน้า component ไปเลยล่ะ?
ลองนึกถึงร้านอาหารครับ ถ้าลูกค้าทุกโต๊ะเดินเข้าครัวไปทำอาหารเองหมด ครัวจะวุ่นวายแค่ไหน ทุกโต๊ะต้องรู้สูตร รู้ว่าวัตถุดิบอยู่ไหน ถ้าวันหนึ่งเปลี่ยนสูตร ต้องไปไล่บอกทุกโต๊ะ
API client ก็คือ “พนักงานเสิร์ฟ” — หน้า component แค่บอกว่า “ขอ list devices” พนักงานก็จัดการเรื่อง URL, timeout, การแปลง error ให้หมด หน้าจอไม่ต้องรู้รายละเอียดพวกนี้เลย ถ้าวันหนึ่ง backend เปลี่ยน path เราแก้ที่เดียวจบ
ในโปรเจกต์เรา layer นี้อยู่ในโฟลเดอร์ src/api/ แบ่งเป็น 4 ไฟล์:
src/api/
├── config.ts # base URL + helper สร้าง WebSocket URL
├── types.ts # type ที่ตรงกับ JSON ของ backend เป๊ะ
├── client.ts # typed REST client (fetch + timeout + error)
└── realtime.ts # WebSocket client + auto-reconnect
Step 1: config — base URL ที่ไม่ฝังลงโค้ด
จำจากตอนที่ 1 ได้ไหมครับว่าเรา inject SHOWKHUN_API_BASE_URL ตอน build ไฟล์ config.ts คือคนอ่านค่านั้นมาใช้ แล้วยังมี helper สำคัญ — แปลง URL ของ REST ให้กลายเป็น URL ของ WebSocket:
/** Base URL ของ backend เช่น http://localhost:3000 */
export const API_BASE_URL: string = readBaseUrl()
/** prefix ของ REST API */
export const API_PREFIX = '/api/v1'
/** timeout เริ่มต้น (ms) ของทุก request */
export const REQUEST_TIMEOUT_MS = 10_000
/**
* แปลง REST URL เป็น WebSocket URL โดยสลับ scheme
* https -> wss, นอกนั้น -> ws
*/
export function websocketUrl(): string {
const httpUrl = `${API_BASE_URL}${API_PREFIX}/ws`
if (httpUrl.startsWith('https://')) {
return `wss://${httpUrl.slice('https://'.length)}`
}
if (httpUrl.startsWith('http://')) {
return `ws://${httpUrl.slice('http://'.length)}`
}
return httpUrl
}
ทำไมต้อง derive WebSocket URL จาก REST? เพราะ backend ตัวเดียวกันเสิร์ฟทั้งสอง protocol — ถ้า REST ชี้ไป
https://...ตัว WS ก็ต้องเป็นwss://...ให้ตรงกันโดยอัตโนมัติ จะได้ไม่มีทางตั้งสองที่ให้หลุดกัน
Step 2: types ที่ตรงกับ backend เป๊ะ
นี่คือเคล็ดลับที่ทำให้ TypeScript ช่วยเราจับ bug ได้ — เรานิยาม type ให้ field ตรงกับ JSON ที่ Go ส่งมา ทุกตัวอักษร (ตรงกับ json:"..." tag ฝั่ง Go) เลยไม่ต้อง remap อะไรเลย:
export type DeviceStatus = 'online' | 'offline' | 'error' | 'maintenance'
export interface Device {
id: string
device_id: string
name: string
type: DeviceType
status: DeviceStatus
enabled: boolean
last_seen_at?: string
created_at: string
updated_at: string
// ... location, tags, group_id ฯลฯ
}
// LiveReading คือ payload ที่มากับ message ชนิด sensor_data ทาง WebSocket
export interface LiveReading {
device_id: string
fields: Record<string, number> // เช่น { temperature: 28.5, humidity: 60 }
tags?: Record<string, string>
timestamp: string
}
// WSMessage คือ envelope ที่ hub ฝั่ง backend broadcast ออกมา
export interface WSMessage<T = unknown> {
type: string // เช่น 'sensor_data'
room?: string // เช่น 'device:sensor-01'
payload: T
}
backend ห่อ response ทุกตัวด้วย envelope มาตรฐาน เราเลยมี type ของ envelope ไว้ด้วย:
export interface DataEnvelope<T> {
data: T
}
export interface ListEnvelope<T> {
data: T[]
pagination: Pagination
}
อุปมา: type พวกนี้เหมือน “แม่พิมพ์” — ถ้าวันหนึ่ง backend เปลี่ยนชื่อ field แต่เราลืมแก้ฝั่งนี้ TypeScript จะเตือนทันทีตอน
npm run typecheckไม่ต้องไปเจอ bug ตอน runtime บนเครื่องลูกค้า
Step 3: REST client ที่มี timeout + abort
ทีนี้มาดูหัวใจของ client.ts ตัวจริง — ฟังก์ชัน request ที่ทุก endpoint เรียกใช้ มันทำสามอย่างที่ production app ควรมี: ตั้ง timeout, รองรับ abort ของ caller, แปลง error envelope ให้เป็น ApiError
ก่อนอื่นดู ApiError ที่ช่วยให้หน้าจอ branch ตาม status ได้:
/** ApiError พก HTTP status + ข้อความจาก error envelope ของ backend มาด้วย
* หน้าจอจะได้เช็ก notFound / status ได้ */
export class ApiError extends Error {
readonly status: number
constructor(status: number, message: string, fields?: Record<string, string>) {
super(message)
this.name = 'ApiError'
this.status = status
}
get notFound(): boolean {
return this.status === 404
}
}
แล้วนี่คือ request (ตัดให้เห็น flow หลัก):
async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const controller = new AbortController()
// ตั้งนาฬิกาจับเวลา — เกิน 10 วิ ก็ abort เอง
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
// ถ้า caller ส่ง signal มาด้วย (เช่นหน้าจอถูกปิด) ก็ abort ตามไป
if (opts.signal) {
opts.signal.addEventListener('abort', () => controller.abort(), { once: true })
}
try {
const response = await fetch(buildUrl(path, opts.query), {
method: opts.method ?? 'GET',
headers: { Accept: 'application/json' },
signal: controller.signal,
})
const text = await response.text()
const parsed: unknown = text ? JSON.parse(text) : undefined
if (!response.ok) {
// แปลง error envelope ของ backend เป็น ApiError ที่หน้าจอเข้าใจ
if (isErrorBody(parsed)) {
throw new ApiError(response.status, parsed.error.message, parsed.error.fields)
}
throw new ApiError(response.status, `request failed (${response.status})`)
}
return parsed as T
} catch (err) {
if (err instanceof ApiError) throw err
if (err instanceof DOMException && err.name === 'AbortError') {
throw new ApiError(0, 'request timed out')
}
throw new ApiError(0, 'network error')
} finally {
clearTimeout(timer) // เก็บกวาดนาฬิกาเสมอ
}
}
ทำไม timeout ถึงสำคัญบนมือถือ? เพราะมือถืออยู่บนเน็ตที่ขาด ๆ หาย ๆ ถ้าไม่ตั้ง timeout แล้วเน็ตหลุด หน้าจอจะค้างหมุนติ้ว ๆ ตลอดกาล — มีนาฬิกาจับเวลา 10 วิ ถ้าไม่ตอบก็ขึ้น “request timed out” ให้ผู้ใช้กดลองใหม่ได้
ส่วนหน้า public ของ client ก็เป็นแค่ method สวย ๆ ที่ห่อ request ไว้ เช่น list devices กับส่งคำสั่ง:
export const api = {
async listDevices(params = {}, signal?: AbortSignal) {
const env = await request<ListEnvelope<Device>>('/devices', { query: params, signal })
return { devices: env.data, pagination: env.pagination }
},
async sendCommand(id: string, cmd: CommandPayload, signal?: AbortSignal) {
const env = await request<DataEnvelope<{ device_id: string; action: string; status: string }>>(
`/devices/${encodeURIComponent(id)}/commands`,
{ method: 'POST', body: cmd, signal },
)
return env.data
},
// ... getReadings, listAlertRules ฯลฯ
}
WHY: REST อย่างเดียวไม่พอเหรอ?
มาถึงคำถามสำคัญของตอนนี้ — ในเมื่อมี REST แล้ว ทำไมต้องมี WebSocket อีก?
ลองนึกภาพการอ่านข่าว 📰
- REST = ซื้อหนังสือพิมพ์ — ได้ snapshot ณ ตอนนั้น อยากได้ข่าวใหม่ต้องไปซื้อฉบับใหม่ (poll ซ้ำ) เปลืองและช้า
- WebSocket = สมัครให้คนเอาข่าวด่วนมาส่งถึงบ้าน — มีอะไรใหม่ เขาวิ่งมาบอกทันที ไม่ต้องไปถามเอง
ในแอปเราเลยใช้ทั้งคู่ตามจุดแข็ง:
graph TD
A[📱 Dashboard เปิดขึ้น] -->|1. REST: ขอ list devices| B[🖥️ Backend]
B -->|ได้ list ครบ| A
A -->|2. subscribe ห้องของแต่ละ device| C[🔌 WebSocket Hub]
C -->|sensor_data ค่าใหม่| A
A -->|merge เข้า list ไม่ต้อง fetch ใหม่| A
- ตอนเปิดหน้า → ใช้ REST ดึง list device มาทีเดียว (ได้ snapshot ครบ)
- จากนั้น → WebSocket ส่งค่าใหม่ของแต่ละ device เข้ามาเรื่อย ๆ เรา merge ทับเฉพาะตัวที่เปลี่ยน
Step 4: RealtimeClient — auto-reconnect ที่ฉลาด
นี่คือชิ้นที่พี่โชว์ชอบที่สุด เพราะมันแก้ปัญหาจริงของ mobile — เน็ตหลุด บนมือถือเป็นเรื่องปกติ (เข้าลิฟต์ เข้าอุโมงค์ สลับ WiFi/4G) RealtimeClient ใน realtime.ts จัดการเรื่องนี้ให้หมดด้วย exponential backoff:
const INITIAL_BACKOFF_MS = 1_000 // เริ่มลองใหม่หลัง 1 วิ
const MAX_BACKOFF_MS = 30_000 // เพดานสูงสุด 30 วิ
export class RealtimeClient {
private socket: WebSocket | null = null
private desiredRooms = new Set<string>() // ห้องที่อยากฟัง
private backoff = INITIAL_BACKOFF_MS
private closedByUser = false
private open(): void {
let socket = new WebSocket(websocketUrl())
this.socket = socket
socket.onopen = () => {
this.backoff = INITIAL_BACKOFF_MS // ต่อติดแล้ว reset backoff
this.setState('open')
// กลับมาต่อใหม่ทีไร subscribe ห้องเดิมทั้งหมดให้อัตโนมัติ
this.desiredRooms.forEach((room) => this.sendCommand('subscribe', room))
}
socket.onmessage = (event) => this.handleMessage(event.data)
socket.onclose = () => {
this.teardownSocket()
if (!this.closedByUser) this.scheduleReconnect() // ไม่ใช่ผู้ใช้สั่งปิด = ลองใหม่
}
}
private scheduleReconnect(): void {
this.setState('reconnecting')
const delay = this.backoff
// เพิ่มเวลารอเป็นเท่าตัวทุกครั้งที่พลาด แต่ไม่เกินเพดาน
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS)
setTimeout(() => { if (!this.closedByUser) this.open() }, delay)
}
}
อุปมา exponential backoff: เหมือนโทรหาเพื่อนแล้วไม่รับ — เราไม่กดโทรรัว ๆ ทุกวินาที (เพื่อนยิ่งหงุดหงิด) แต่เว้นนานขึ้นเรื่อย ๆ 1 วิ → 2 วิ → 4 วิ … เพื่อไม่ให้ทั้งเรา (มือถือ) และเพื่อน (server) เหนื่อยเกินไป
อีกจุดที่ดีงามคือ re-subscribe อัตโนมัติ — เราเก็บรายชื่อห้องที่อยากฟังไว้ใน desiredRooms พอ socket กลับมาต่อใหม่ มันก็ subscribe ห้องเดิมให้เองใน onopen ผู้ใช้ไม่รู้สึกเลยว่าเน็ตเพิ่งหลุดไป
ส่วนการ “ฟังห้อง” ก็ทำผ่าน room name ของ backend ตรง ๆ:
/** ชื่อห้อง live ของ device ตัวเดียว (ตรงกับ ws.DeviceRoom ฝั่ง backend) */
export function deviceRoom(deviceId: string): string {
return `device:${deviceId}`
}
แล้วตอนรับ message ก็ decode envelope แล้วยิง callback เฉพาะชนิด sensor_data:
private handleMessage(data: unknown): void {
if (typeof data !== 'string') return
const msg = JSON.parse(data) as WSMessage
if (msg.type === 'sensor_data') {
const payload = msg.payload as LiveReading
if (payload && typeof payload.device_id === 'string') {
this.handlers.onSensorData?.(payload, msg.room ?? '')
}
}
}
Step 5: ประกอบทุกอย่างใน hook useDashboard
ทีนี้เราเอา REST + WebSocket มาผูกกันใน custom hook ตัวเดียว ให้หน้าจอเรียกใช้ง่าย ๆ นี่คือ useDashboard ของจริง (ตัดให้เห็น flow):
export function useDashboard(): DashboardState {
const [devices, setDevices] = useState<Device[]>([])
// เก็บค่าล่าสุดต่อ device แยกจาก list หลัก
const [live, setLive] = useState<Record<string, { fields: Record<string, number>; updatedAt: string }>>({})
const [connection, setConnection] = useState<ConnectionState>('connecting')
const clientRef = useRef<RealtimeClient | null>(null)
// 1) โหลด list device ผ่าน REST แล้ว subscribe ห้องของทุกตัว
const load = useCallback(async (isRefresh: boolean) => {
const { devices: list } = await api.listDevices({ limit: 100, sort: 'name' })
setDevices(list)
clientRef.current?.setRooms(list.map((d) => deviceRoom(d.device_id)))
}, [])
// 2) เปิด WebSocket ตอน mount — ค่าใหม่เข้ามาก็ merge เก็บไว้ใน live
useEffect(() => {
const client = new RealtimeClient({
onStateChange: setConnection,
onSensorData: (reading) => {
setLive((prev) => ({
...prev,
[reading.device_id]: { fields: reading.fields, updatedAt: reading.timestamp },
}))
},
})
clientRef.current = client
client.connect()
void load(false)
return () => { client.close() } // ปิด socket ตอน unmount
}, [load])
// 3) รวม device (จาก REST) กับ live (จาก WS) เป็น list เดียวให้หน้าจอ
const items: DeviceLive[] = devices.map((device) => {
const l = live[device.device_id]
return { device, fields: l?.fields, updatedAt: l?.updatedAt }
})
return { /* loading, error, */ connection, items, refresh: () => load(true) }
}
เห็น pattern สวย ๆ มั้ยครับ? list หลักมาจาก REST, ค่าสดมาจาก WebSocket, แล้วเรา merge ตอน render — ค่าใหม่เข้ามาทีก็อัปเดตเฉพาะ device ตัวนั้น ไม่ต้องยิง REST ใหม่ทั้งก้อน ประหยัดทั้ง network และ battery
Step 6: render เป็น SensorCard
ส่วนสุดท้ายคือเอา items มาวาด ตัว DashboardScreen มี indicator บอกสถานะ connection (Live / Reconnecting…) และ pull-to-refresh ผ่าน bindscrolltoupper ของ <scroll-view>:
<scroll-view
className='Dashboard__scroll'
scroll-orientation='vertical'
upper-threshold={4}
bindscrolltoupper={onScrollToUpper} // เลื่อนชนขอบบน = refresh
>
<view className='Screen__body'>
{items.map((item) => (
<SensorCard key={item.device.id} item={item} />
))}
</view>
</scroll-view>
แล้ว SensorCard ก็แสดงชื่อ device, status badge, และวน fields ออกมาเป็นตัวเลข ถ้ายังไม่มีค่า live ก็ขึ้น “Waiting for live data…”:
export function SensorCard(props: SensorCardProps) {
const { device, fields, updatedAt } = props.item
const entries = fields ? Object.entries(fields) : []
return (
<Card bindtap={props.onTap}>
<view className='SensorCard__head'>
<text className='SensorCard__name'>{device.name}</text>
<Badge label={device.status} tone={deviceStatusTone(device.status)} />
</view>
{entries.length > 0 ? (
<view className='SensorCard__metrics'>
{entries.map(([field, value]) => (
<view key={field} className='SensorCard__metric'>
<text className='SensorCard__metricValue'>{formatValue(value)}</text>
<text className='SensorCard__metricLabel'>{humanize(field)}</text>
</view>
))}
</view>
) : (
<text className='SensorCard__waiting'>Waiting for live data…</text>
)}
</Card>
)
}
พอ WebSocket ส่ง sensor_data เข้ามา → live ใน hook อัปเดต → React re-render → ตัวเลขใน SensorCard__metricValue ขยับเอง ครบลูปพอดี!
╔══════════════════════════════╗
║ ║
║ ٩(◕‿◕)۶ ║
║ ║
║ "ตัวเลขขยับเองโดยไม่ต้องกด" ║
║ ║
╚══════════════════════════════╝
สรุป: เราทำอะไรไปบ้างในตอนนี้
- เข้าใจว่าทำไมต้องแยก API client เป็น layer — หน้าจอแค่สั่ง ไม่ต้องรู้ URL/timeout/error
- มี typed REST client ที่ field ตรงกับ backend เป๊ะ มี timeout 10 วิ, abort, และ
ApiError - รู้ว่า REST = snapshot, WebSocket = ข่าวด่วน ใช้คู่กันตามจุดแข็ง
- ได้
RealtimeClientที่ auto-reconnect แบบ exponential backoff และ re-subscribe ห้องให้เอง - ผูกทุกอย่างใน hook
useDashboardที่ merge ค่า live เข้า list โดยไม่ fetch ซ้ำ - render เป็น
SensorCardที่อัปเดตค่าสด ๆ ต่อหน้าต่อตา
ตอนนี้แอปเรา “มีชีวิต” แล้วครับ — มีข้อมูลจริงไหลเข้ามาแบบ real-time
Next Step
ตอนหน้าเป็นตอนสุดท้าย เราจะยกระดับเป็น advanced integration — สั่งงาน device ผ่าน command + dialog ยืนยัน, วาด chart จาก time-series ด้วย SVG ที่ปลอดภัยจาก injection, จัดการ alert rules, theme dark/light ผ่าน Context และ pattern production อื่น ๆ ที่ดึงจากโค้ดจริง
- ก่อนหน้า: Lynx.js Workshop ตอนที่ 1: เริ่มต้นเขียน Mobile App ด้วย ReactLynx
- ถัดไป: Lynx.js Workshop ตอนที่ 3: Advanced Integration และ Best Practices
Happy Building! 🌱