Lynx.js Workshop ตอนที่ 1: เริ่มต้นเขียน Mobile App ด้วย ReactLynx
Lynx.js Workshop ตอนที่ 1: เริ่มต้นเขียน Mobile App ด้วย ReactLynx
อ้างอิงโค้ดจริงจาก: โปรเจกต์
frontend-mobileใน kangana1024/showkhun-workshop (branchstep-14-mobile-alerts)
เคยมั้ยครับ? เขียน React บนเว็บมาหลายปี อยากทำ mobile app ขึ้นมาบ้าง แต่พอเปิดดูก็เจอศัพท์ใหม่เต็มไปหมด — native module, gradle, podfile, bridge… จนถอดใจไปก่อน 😅
วันนี้พี่โชว์มีตัวช่วยครับ ชื่อว่า Lynx.js — framework ที่ให้เราเขียน mobile app ด้วย React + TypeScript ตัวเดิมที่เราคุ้นเคย แต่ render ออกมาเป็น UI native บนมือถือจริง ๆ และที่เด็ดคือ workshop ของเราใช้มันสร้าง mobile client ของระบบ IoT จริง ดังนั้นทุกบรรทัดในซีรีส์นี้ดึงมาจากโค้ดที่ run ได้จริง ไม่ได้แต่งขึ้นมาเอง (ʘ‿ʘ)
สิ่งที่จะได้เรียนรู้
- Lynx.js (ReactLynx) คืออะไร และต่างจาก React Native ตรงไหน
- ทำไมโปรเจกต์เราถึงเลือกใช้มันทำ mobile client ของระบบ IoT
- ติดตั้งและ setup โปรเจกต์
frontend-mobileจากpackage.jsonจริง - เข้าใจ element พื้นฐานของ Lynx:
<view>,<text>,<scroll-view>และ eventbindtap - รัน dev server แล้วเปิดบนมือถือด้วย QR code + LynxExplorer
- รู้จักโครงสร้างโฟลเดอร์ของแอปจริง ก่อนจะลงลึกในตอนถัดไป
Lynx.js คืออะไร? (WHY ก่อน HOW เสมอ)
ก่อนจะลงมือติดตั้งอะไร เรามาตอบคำถามนี้กันก่อน — ทำไมต้องมี framework แบบนี้ด้วย?
ลองนึกภาพว่าน้องเขียนเว็บด้วย React เก่งมาก แต่พอจะทำแอปบนมือถือ น้องมีทางเลือกอยู่ไม่กี่ทาง:
- เรียน Swift/Kotlin ใหม่หมด (เขียนสองภาษา สองโปรเจกต์)
- ใช้ React Native (เขียน React ได้ แต่ก็มีเรื่อง native build ให้ปวดหัวอยู่ดี)
Lynx.js เลือกทางที่สาม — มันให้เราเขียนด้วย React (ผ่าน flavor ที่ชื่อ ReactLynx) แล้วใช้ engine ของ Lynx render เป็น UI native ที่ลื่นและเริ่มเร็ว
อุปมา: ถ้าโค้ด React คือ “สูตรอาหาร” — React Native ก็คือเชฟที่ต้องเตรียมครัวเยอะหน่อย ส่วน Lynx.js คือเชฟที่ครัวพร้อมอยู่แล้ว เราแค่ยื่นสูตรให้ เดี๋ยวมันจัดให้เสร็จลงจานสวย ๆ บนทั้ง iOS และ Android
จุดที่ทำให้พี่โชว์ชอบคือ build tool ของมัน (ชื่อ Rspeedy ซึ่งสร้างบน Rspack) เร็วมาก และ dev workflow คือ “รันแล้วสแกน QR” — ไม่ต้องรอ build native นานเป็นชาติ
ภาพรวม: mobile app อยู่ตรงไหนในระบบ IoT ของเรา
ก่อนจะ setup เรามาดูกันก่อนว่า แอปที่เราจะสร้างมันคุยกับใครบ้าง ระบบ workshop ของเรามี backend (เขียนด้วย Go) เป็นคนกลาง ส่วน mobile app เป็นแค่ “หน้าจอ” ที่ดึงข้อมูลมาแสดงและสั่งงาน device:
graph LR
A[🌡️ IoT Devices] -->|telemetry| B[🖥️ Go Backend]
B -->|REST /api/v1| C[📱 Lynx Mobile App]
B -->|WebSocket /api/v1/ws| C
C -->|ส่งคำสั่ง POST| B
เห็นมั้ยครับว่า mobile app ไม่ได้คุยกับ sensor ตรง ๆ — มันคุยกับ backend ผ่าน REST กับ WebSocket เท่านั้น ตอนนี้รู้แค่นี้พอ เดี๋ยวเรื่องดึงข้อมูล real-time เราจะลงลึกในตอนที่ 2
Step 1: เตรียมเครื่องก่อนเริ่ม
Lynx.js เป็น JavaScript toolchain ดังนั้นสิ่งที่ต้องมีก่อนคือ Node.js โดยเวอร์ชันที่โปรเจกต์เราระบุไว้ใน package.json คือ:
{
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}
แปลว่าใช้ Node 20.19 ขึ้นไป หรือ Node 22.12 ขึ้นไปก็ได้ เช็กเวอร์ชันด้วย:
node --version
# ควรได้ v20.19.x ขึ้นไป หรือ v22.12.x ขึ้นไป
ถ้าเวอร์ชันต่ำกว่านี้ แนะนำใช้
nvmสลับเวอร์ชัน จะได้ไม่ต้องไปยุ่งกับ Node ตัวหลักของเครื่อง
นอกจากนี้ ตอนรันจริงบนมือถือ เราจะใช้แอปชื่อ LynxExplorer (มีทั้ง iOS/Android) เป็นตัว render bundle ของเรา — เดี๋ยวค่อยลงตอน Step 4
Step 2: รู้จักหน้าตา package.json ตัวจริง
หัวใจของการเข้าใจโปรเจกต์คือดู dependencies ว่ามีอะไรบ้าง นี่คือ package.json จริงของ frontend-mobile (ตัดให้เห็นส่วนสำคัญ):
{
"name": "showkhun-mobile",
"description": "ShowKhun IoT platform mobile client built with Lynx.js (ReactLynx)",
"type": "module",
"scripts": {
"build": "rspeedy build",
"dev": "rspeedy dev",
"preview": "rspeedy preview",
"lint": "eslint .",
"typecheck": "tsc --build --noEmit"
},
"dependencies": {
"@lynx-js/react": "0.121.2",
"@lynx-js/websocket": "0.0.4"
},
"devDependencies": {
"@lynx-js/rspeedy": "0.15.0",
"@lynx-js/react-rsbuild-plugin": "0.17.0",
"@lynx-js/qrcode-rsbuild-plugin": "0.5.0",
"@lynx-js/types": "3.7.0",
"typescript": "5.9.3"
}
}
มาทำความเข้าใจตัวสำคัญทีละตัวนะ — ไม่ใช่แค่ copy ไปวาง:
| Package | หน้าที่ |
|---|---|
@lynx-js/react |
ตัว ReactLynx เอง — ให้เราใช้ useState, useEffect, JSX ได้เหมือน React ปกติ |
@lynx-js/websocket |
WebSocket client ของ Lynx ใช้รับข้อมูล real-time จาก backend (ตอนที่ 2) |
@lynx-js/rspeedy |
build tool / dev server (อยู่บน Rspack) — ตัวที่รันเวลา npm run dev |
@lynx-js/react-rsbuild-plugin |
plugin ที่ทำให้ Rspeedy เข้าใจ ReactLynx |
@lynx-js/qrcode-rsbuild-plugin |
ตัวที่พ่น QR code ออกมาบน terminal เพื่อให้สแกนเปิดบนมือถือ |
@lynx-js/types |
type definitions ของ element อย่าง <view> / <text> ให้ TypeScript รู้จัก |
สังเกตว่า
"type": "module"— โปรเจกต์นี้เป็น ESM ล้วน ดังนั้นเวลา import ไฟล์ใน source เราจะเห็นนามสกุล.jsต่อท้าย (เช่นimport { App } from './App.js') ซึ่งเป็นปกติของ ESM อย่าเพิ่งงงว่าทำไม import ไฟล์.tsxแต่เขียน.js
Step 3: ติดตั้งและเข้าใจ config
ก่อนจะเริ่ม code ได้ เราต้อง “ซื้อของ” เข้าครัวก่อน — สั่ง npm install ให้มันไปดึง package ตามรายการใน package.json มาให้:
# เข้าไปในโฟลเดอร์ของ mobile app
cd frontend-mobile
# ติดตั้ง dependencies ทั้งหมด
npm install
# รอสักครู่... กำลังไปเอาของให้ 🛒
ทีนี้มาดู “สมองสั่งการ” ของ build — ไฟล์ lynx.config.ts ตัวจริง:
import { defineConfig } from '@lynx-js/rspeedy'
import { pluginQRCode } from '@lynx-js/qrcode-rsbuild-plugin'
import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin'
import { pluginTypeCheck } from '@rsbuild/plugin-type-check'
export default defineConfig({
source: {
define: {
// Backend base URL ถูก inject ตอน build จาก environment
// จึงไม่มี host หรือ credential ฝังอยู่ใน source เลย
// ถ้าไม่ตั้ง จะ fallback เป็น backend ใน docker-compose ตอน dev
'process.env.SHOWKHUN_API_BASE_URL': JSON.stringify(
process.env.SHOWKHUN_API_BASE_URL ?? 'http://localhost:3000',
),
},
},
plugins: [
pluginQRCode({
schema(url) {
return `${url}?fullscreen=true`
},
}),
pluginReactLynx(),
pluginTypeCheck(),
],
})
ทำไมต้องสนใจ config ตัวนี้? เพราะมันสอน design pattern ที่ดีมากสองอย่าง:
- ไม่ฝัง URL ของ backend ลงในโค้ด — เราใช้
source.defineinject ค่าจาก environment ตอน build ถ้าไม่ตั้งก็ fallback เป็นhttp://localhost:3000(ตัว backend ใน docker-compose) เวลา deploy จริงก็แค่ตั้งSHOWKHUN_API_BASE_URLไม่มี host หลุดไปกับ bundle เด็ดขาด pluginQRCode— ตัวนี้แหละที่จะพ่น QR ให้เราสแกน
วิธีตั้งค่า base URL ก็แค่ copy ไฟล์ตัวอย่าง:
cp .env.example .env
# ข้างในมีบรรทัดเดียว:
# SHOWKHUN_API_BASE_URL=http://localhost:3000
Step 4: รันบนมือถือจริงด้วย QR Code
มาถึงตอนสนุกแล้ว! สั่ง dev server:
npm run dev
# rspeedy จะ build แล้วพ่น QR code ออกมาบน terminal
ขั้นตอนการเอาขึ้นมือถือเป็นแบบนี้:
graph LR
A[npm run dev] -->|พ่น QR| B[📟 Terminal]
B -->|สแกน| C[📱 LynxExplorer]
C -->|โหลด bundle| D[เห็นแอปบนเครื่อง]
- ติดตั้งแอป LynxExplorer บนมือถือ (หรือ simulator)
- เปิด LynxExplorer แล้วสแกน QR ที่อยู่บน terminal
- แอปของเราจะเด้งขึ้นมาบนเครื่องทันที — แก้โค้ดแล้ว reload เห็นผลได้เลย
เกร็ด: ตัว Lynx render ผ่าน LynxExplorer (engine native) ไม่ใช่ผ่าน browser ดังนั้น UI ที่ได้คือ native จริง ๆ ส่วน gate ที่เราใช้ใน CI ของโปรเจกต์นี้คือ
typecheck,lint, และbuild(ดูได้จาก scripts ใน package.json)
╔══════════════════════════════╗
║ ║
║ (ノ◕ヮ◕)ノ*:・゚✧ ║
║ ║
║ "สแกนแล้วแอปเด้งขึ้นเลย!" ║
║ ║
╚══════════════════════════════╝
Step 5: รู้จัก element พื้นฐานของ Lynx
นี่คือจุดที่คน React มักสะดุดครั้งแรก — ใน Lynx เราไม่ได้ใช้ <div> กับ <p> แต่ใช้ element ของ Lynx แทน ลองดู TabBar ตัวจริงของเรา (ตัด CSS ออกให้เห็น JSX ชัด ๆ):
import { TABS, type TabKey } from './tabs.js'
interface TabBarProps {
active: TabKey
onChange: (key: TabKey) => void
}
/** TabBar คือแถบ navigation ด้านล่างของแอป */
export function TabBar(props: TabBarProps) {
return (
<view className='TabBar'>
{TABS.map((tab) => {
const isActive = tab.key === props.active
return (
<view
key={tab.key}
className={isActive ? 'TabBar__item TabBar__item--active' : 'TabBar__item'}
bindtap={() => props.onChange(tab.key)}
>
<text className='TabBar__icon'>{tab.icon}</text>
<text className='TabBar__label'>{tab.label}</text>
</view>
)
})}
</view>
)
}
เห็นความต่างมั้ยครับ? มาเทียบกับ React บนเว็บแบบเข้าใจง่าย:
| เว็บ (React) | Lynx (ReactLynx) | ใช้ทำอะไร |
|---|---|---|
<div> |
<view> |
กล่อง container จัด layout |
<p> / <span> |
<text> |
แสดงข้อความ (ใน Lynx ข้อความต้องอยู่ใน <text> เสมอ) |
onClick |
bindtap |
event ตอนแตะ/กด |
| scroll container | <scroll-view> |
พื้นที่ที่เลื่อนได้ |
ส่วนที่เหลือ — useState, props, .map(), ternary — เหมือน React ทุกอย่าง นี่แหละข้อดีที่พี่โชว์บอกตั้งแต่ต้น น้องรู้ React อยู่แล้ว แทบไม่ต้องเรียนใหม่ แค่จำชื่อ element ชุดใหม่
อุปมา: เหมือนย้ายจากร้านกาแฟเจ้าหนึ่งไปอีกเจ้า เมนูเรียกชื่อต่างกันนิดหน่อย (
viewแทนdiv) แต่วิธีสั่ง วิธีจ่ายเงิน เหมือนเดิมหมด ปรับตัวแป๊บเดียวก็คล่อง
ตัว tabs.ts ที่ TabBar ดึงมาใช้ก็เป็นแค่ data ธรรมดา:
export type TabKey = 'dashboard' | 'devices' | 'alerts' | 'settings'
export const TABS = [
{ key: 'dashboard', label: 'Dashboard', icon: '📊' },
{ key: 'devices', label: 'Devices', icon: '🔌' },
{ key: 'alerts', label: 'Alerts', icon: '🔔' },
{ key: 'settings', label: 'Settings', icon: '⚙️' },
]
Step 6: Entry point — แอปเริ่มต้นยังไง
ทุกแอปต้องมี “ประตูหน้าบ้าน” — จุดที่ทุกอย่างเริ่มทำงาน ของ Lynx คือไฟล์ src/index.tsx:
import '@lynx-js/preact-devtools'
import '@lynx-js/react/debug'
import { root } from '@lynx-js/react'
import { App } from './App.js'
root.render(<App />)
if (import.meta.webpackHot) {
import.meta.webpackHot.accept()
}
ถ้าน้องเคยเขียน React บนเว็บ ตัวนี้จะคุ้นมาก — มันคือ root.render(<App />) เหมือน ReactDOM.createRoot(...).render() เป๊ะ! ต่างแค่ root มาจาก @lynx-js/react และมีบรรทัด webpackHot.accept() ไว้ทำ hot-reload ตอน dev
ส่วน App.tsx ก็ประกอบร่างทุกอย่างเข้าด้วยกัน โดยห่อด้วย ThemeProvider แล้วสลับหน้าตาม tab ที่เลือก:
import { useState } from '@lynx-js/react'
import { TabBar } from './navigation/TabBar.js'
import type { TabKey } from './navigation/tabs.js'
import { DashboardScreen } from './screens/DashboardScreen.js'
import { DevicesScreen } from './screens/DevicesScreen.js'
import { AlertsScreen } from './screens/AlertsScreen.js'
import { SettingsScreen } from './screens/SettingsScreen.js'
import { ThemeProvider } from './theme/ThemeProvider.js'
function ActiveScreen(props: { tab: TabKey }) {
switch (props.tab) {
case 'dashboard':
return <DashboardScreen />
case 'devices':
return <DevicesScreen />
case 'alerts':
return <AlertsScreen />
case 'settings':
return <SettingsScreen />
default:
return <DashboardScreen />
}
}
export function App() {
const [tab, setTab] = useState<TabKey>('dashboard')
return (
<ThemeProvider initial='light'>
<view className='AppShell'>
<view className='AppShell__content'>
<ActiveScreen tab={tab} />
</view>
<TabBar active={tab} onChange={setTab} />
</view>
</ThemeProvider>
)
}
แค่ useState เก็บ tab ที่ active แล้ว <TabBar> ก็เรียก setTab เวลากด — pattern เดียวกับ React ทุกประการ ไม่มีอะไรลึกลับเลย
Step 7: โครงสร้างโปรเจกต์จริง
ปิดท้ายตอนแรกด้วยแผนที่ — โครงสร้างโฟลเดอร์จริงของ frontend-mobile เพื่อให้น้องเห็นภาพรวมก่อนเราจะเจาะแต่ละส่วนในตอนถัด ๆ ไป:
frontend-mobile/
├── lynx.config.ts # config ของ Rspeedy + plugins
├── package.json
├── .env.example # ตัวอย่างค่า SHOWKHUN_API_BASE_URL
└── src/
├── index.tsx # entry point (root.render)
├── App.tsx # ประกอบ shell + tab navigation
├── api/ # REST client, WebSocket client, types
├── navigation/ # TabBar ด้านล่าง
├── screens/ # dashboard, devices, charts, alerts, settings + hooks
├── theme/ # ThemeProvider (light/dark) + tokens
└── ui/ # primitive ที่ใช้ซ้ำ (Card, Button, Badge, Dialog, ...)
จะเห็นว่ามันถูกแบ่งหน้าที่ชัดมาก — api/ คุยกับ backend, screens/ คือแต่ละหน้า, ui/ คือชิ้นส่วนที่ใช้ซ้ำ การแยกแบบนี้ทำให้ตอนเราเพิ่ม feature ในตอนถัด ๆ ไป รู้เลยว่าควรไปแก้ที่ไหน
สรุป: ตอนนี้เราอยู่ตรงไหนแล้ว
ในตอนแรกนี้ น้อง ๆ ได้:
- รู้ว่า Lynx.js (ReactLynx) คือการเขียน mobile app ด้วย React + TypeScript ตัวเดิม แล้ว render เป็น UI native
- เข้าใจว่า mobile app ของเรา คุยกับ Go backend ผ่าน REST + WebSocket ไม่ได้แตะ sensor ตรง
- ติดตั้งจาก
package.jsonจริง (@lynx-js/react,@lynx-js/rspeedy, …) และรู้จัก config ที่ไม่ฝัง URL ลง bundle - รัน
npm run devแล้วเปิดบนมือถือด้วย QR code + LynxExplorer - จำ element พื้นฐานได้:
<view>แทน<div>,<text>แทน<p>,bindtapแทนonClick,<scroll-view>สำหรับเลื่อน - เห็นโครงสร้างโฟลเดอร์จริง พร้อมลงลึกในตอนถัดไป
ถ้ามาถึงตรงนี้แล้วรู้สึกว่า “เออ มันก็เหมือน React นี่หว่า” — นั่นแหละครับคือเป้าหมาย 🎉
Next Step
ตอนต่อไปเราจะลงมือทำของจริงแล้ว — สร้าง Dashboard ที่ดึงข้อมูล IoT แบบ real-time ผ่าน REST + WebSocket, เข้าใจ typed API client, และเห็น sensor card อัปเดตค่าสด ๆ ต่อหน้าต่อตา
Happy Coding! 🚀