Lynx.js Workshop ตอนที่ 1: เริ่มต้นเขียน Mobile App ด้วย ReactLynx

Lynx.js Workshop ตอนที่ 1: เริ่มต้นเขียน Mobile App ด้วย ReactLynx

ShowkhunIT

Lynx.js Workshop ตอนที่ 1: เริ่มต้นเขียน Mobile App ด้วย ReactLynx

อ้างอิงโค้ดจริงจาก: โปรเจกต์ frontend-mobile ใน kangana1024/showkhun-workshop (branch step-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> และ event bindtap
  • รัน dev server แล้วเปิดบนมือถือด้วย QR code + LynxExplorer
  • รู้จักโครงสร้างโฟลเดอร์ของแอปจริง ก่อนจะลงลึกในตอนถัดไป

Lynx.js คืออะไร? (WHY ก่อน HOW เสมอ)

ก่อนจะลงมือติดตั้งอะไร เรามาตอบคำถามนี้กันก่อน — ทำไมต้องมี framework แบบนี้ด้วย?

ลองนึกภาพว่าน้องเขียนเว็บด้วย React เก่งมาก แต่พอจะทำแอปบนมือถือ น้องมีทางเลือกอยู่ไม่กี่ทาง:

  1. เรียน Swift/Kotlin ใหม่หมด (เขียนสองภาษา สองโปรเจกต์)
  2. ใช้ 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 ที่ดีมากสองอย่าง:

  1. ไม่ฝัง URL ของ backend ลงในโค้ด — เราใช้ source.define inject ค่าจาก environment ตอน build ถ้าไม่ตั้งก็ fallback เป็น http://localhost:3000 (ตัว backend ใน docker-compose) เวลา deploy จริงก็แค่ตั้ง SHOWKHUN_API_BASE_URL ไม่มี host หลุดไปกับ bundle เด็ดขาด
  2. 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[เห็นแอปบนเครื่อง]
  1. ติดตั้งแอป LynxExplorer บนมือถือ (หรือ simulator)
  2. เปิด LynxExplorer แล้วสแกน QR ที่อยู่บน terminal
  3. แอปของเราจะเด้งขึ้นมาบนเครื่องทันที — แก้โค้ดแล้ว 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! 🚀