Webview คุยกับ native shell: ออกแบบ bridge ให้ทน race และ lifecycle
แอปที่เป็น web ห่อใน native shell ต้องมี bridge สองทาง — และถ้าคุณคิดว่ามันคือ function call ธรรมดา คุณจะเจอ bug ตอน boot กับตอน app กลับมาจาก background แน่นอน. บทความนี้เล่าวิธีออกแบบ bridge ให้เหมือน network protocol
อ่าน ~9 นาที
ปัญหา: bridge ไม่ใช่ function call มันคือ async message passing
ผมเคยทำระบบที่หน้าจอลูกค้าทั้งหมดเป็น web (Next.js) แต่ถูกห่ออยู่ใน native shell (Expo/React Native) แล้ว render ผ่าน WebView. รูปแบบนี้ได้ความเร็วในการ ship ของ web บวกกับความสามารถ native อย่างกล้อง สแกน QR haptic push notification. ตัวเชื่อมสองโลกนี้คือสิ่งที่เรียกกันว่า bridge.
ความเข้าใจผิดที่แพงที่สุดคือคิดว่าเรียก native จาก web เหมือนเรียกฟังก์ชัน — const token = nativeBridge.auth.getAuthToken() แล้วได้ค่ากลับมาทันที. ความจริงคือ web กับ native เป็นคนละ process คุยกันผ่าน string ที่ serialize เป็น JSON ส่งข้าม postMessage. มันคือ message passing แบบ async — ทุกอย่างที่เป็นจริงกับ network ก็เป็นจริงกับ bridge: มี latency, ข้อความหายได้, ปลายทางอาจยังไม่พร้อม, และ payload อาจผิดรูป. ถ้าออกแบบ bridge เหมือน network protocol ตั้งแต่แรก คุณจะรอดจาก bug ทั้งกลุ่มที่คนอื่นเจอตอน production.
Race ตอน boot: web โหลดเสร็จก่อน native inject bridge
bug คลาสสิกที่สุดคือลำดับการ boot. WebView เริ่มโหลด HTML/JS ทันทีที่เปิด แต่ native ต้องใช้เวลา bootstrap (auth, init module) ก่อนจะ inject bridge เข้าไปใน window. ผลคือ web โค้ดอาจรัน nativeBridge.scanQRCode() ในจังหวะที่ native ยังไม่ hook อะไรเข้ามาเลย — เรียกไปก็เงียบ.
ทางแก้คือทำ handshake เหมือน TCP: web ต้องรู้ว่า bridge "พร้อม" ก่อนถึงจะยิง. ผมใช้ pattern whenReady() ที่ resolve เมื่อ native ส่งสัญญาณ ready กลับมา และ queue message ที่ถูกเรียกก่อนหน้านั้นไว้ แล้ว flush ทีเดียวตอนพร้อม. ที่สำคัญคือมี chain ของแหล่งข้อมูล fallback — เช่นการหา auth token ผมไล่จาก localStorage (เร็วสุด persist ไว้แล้ว) → ค่าที่ native inject ลง global → เรียกผ่าน bridge จริง → สุดท้าย retry รอ context init เสร็จ. ไม่ผูกทุกอย่างไว้กับ bridge ที่อาจยังไม่มา.
// รอ bridge พร้อมก่อนค่อยเรียก + queue สิ่งที่มาก่อน
const pending = []
let ready = false
function whenReady() {
if (ready) return Promise.resolve()
return new Promise((resolve) => pending.push(resolve))
}
// native เรียกเมื่อ inject เสร็จ
window.__onBridgeReady = () => {
ready = true
pending.splice(0).forEach((r) => r()) // flush queue
}
// call site: อย่ายิงตรงๆ ให้รอ handshake ก่อน
async function scanQR() {
await whenReady()
return nativeBridge.scanQRCode()
}
Contract ชัด: message shape, correlation id, และ versioning
เพราะเป็น async message passing แบบ fire-and-forget คุณต้อง match "คำตอบ" กลับไปหา "คำถาม" ให้ถูก. วิธีมาตรฐานคือแนบ correlation id ไปกับทุก request แล้วให้ native ส่ง id เดิมกลับมา web จะได้ resolve promise ตัวที่ถูกต้อง. shape ที่ผมใช้เรียบง่ายและตายตัว: web ส่ง { type, id, payload } → native route ตาม type → ตอบกลับ { type, id, payload, success }.
const inflight = new Map()
function call(type, payload) {
const id = crypto.randomUUID()
return new Promise((resolve, reject) => {
inflight.set(id, { resolve, reject })
window.ReactNativeWebView.postMessage(
JSON.stringify({ type, id, payload })
)
})
}
// native ยิงกลับผ่าน global นี้
window.NativeBridge = {
receiveMessage(raw) {
const msg = JSON.parse(raw) // อันตราย — ดู section ถัดไป
const waiter = inflight.get(msg.id)
if (!waiter) return // id ไม่รู้จัก ทิ้ง อย่า throw
inflight.delete(msg.id)
msg.success ? waiter.resolve(msg.payload) : waiter.reject(msg.payload)
},
}
อีกเรื่องที่มักลืมคือ versioning. web กับ native deploy คนละรอบ — web push ได้เร็วผ่าน server แต่ native ต้องรอ user อัปเดตแอปจาก store. แปลว่าจะมีช่วงที่ web เวอร์ชันใหม่คุยกับ native เวอร์ชันเก่าเสมอ. ใส่ field version ใน handshake แล้วให้ web ตัดสินใจว่า capability ไหนเรียกได้ ไม่ใช่สมมติว่า native รุ่นล่าสุดเสมอ.
Native module ที่ web เรียก: timeout + fallback เมื่อ bridge เงียบ
สมมติ web เรียก native module (สแกน QR, อ่าน battery, haptic) แล้ว native ไม่ตอบ — เพราะ handler พัง เพราะรุ่นเก่าไม่มี method นั้น หรือเพราะรันใน browser ธรรมดาที่ไม่มี native เลย. ถ้าไม่มี timeout promise จะค้างตลอดกาล UI จะหมุนไม่เลิก.
ทุก bridge call ต้องมีนาฬิกาจับเวลาและ fallback ที่ชัด. ผม dev บน browser ปกติได้ด้วยการ inject fake bridge (mock) ก่อน React mount — feature ที่จำเป็นต้องมี native จะ degrade อย่างสุภาพแทนที่จะพัง.
function callWithTimeout(type, payload, ms = 8000) {
return Promise.race([
call(type, payload),
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`bridge timeout: ${type}`)), ms)
),
])
}
// call site ต้องมี fallback เสมอ — อย่าให้ UI ค้าง
async function vibrate() {
try {
await callWithTimeout('haptics.light', {}, 2000)
} catch {
// ไม่มี native (เช่นรันใน browser) หรือ timeout — ข้ามไปเงียบๆ
}
}
Lifecycle: background/foreground และ state ที่ต้อง resync
สิ่งที่ web-only ไม่เคยเจอแต่ native shell เจอตลอดคือ lifecycle. user กด home แอปไป background OS อาจ suspend WebView — timer หยุด, polling ค้าง, WebSocket หลุด. พอกลับมา foreground state ใน web อาจเก่าไปหลายนาทีแล้ว.
อย่าเชื่อว่า timer ที่ตั้งไว้ก่อน background จะเดินต่อ. ให้ native ส่ง app state event (active/background) ผ่าน bridge แล้ว web subscribe เพื่อ resync เมื่อกลับมา active. ในฟีเจอร์ที่มีสถานะเปลี่ยนตลอด (เช่นหน้าที่กำลังเช่าอุปกรณ์อยู่ ต้อง poll สถานะทุก 30 วินาที) การ resync ตอน foreground สำคัญกว่าการ poll ถี่ๆ เสียอีก — เพราะข้อมูลที่ค้างตอน suspend คือข้อมูลที่ user จะเห็นเป็นอย่างแรกตอนเปิดกลับมา.
// resync เมื่อแอปกลับมา foreground แทนที่จะพึ่ง timer ที่อาจถูก suspend
nativeBridge.appState.subscribe((state) => {
if (state === 'active') {
revalidate() // ดึงข้อมูลล่าสุดทันที
resumePolling() // เริ่ม timer ใหม่ อย่าเชื่อตัวเก่า
} else {
pausePolling() // หยุด work ที่ไม่จำเป็นตอน background
}
})
อย่า trust ข้อมูลข้าม bridge: parse แบบ defensive ทั้งสองฝั่ง
bridge คือ boundary เหมือน API — และกฎเดียวกับ API ใช้ได้เป๊ะ: อย่าเชื่อ input ที่ข้ามเข้ามาโดยไม่ validate. payload ที่ native ส่งมาเป็น JSON string ที่คุณ JSON.parse แบบดิบๆ ไม่ได้ — รุ่น native ต่างกัน อาจส่ง field ไม่ครบ ชื่อ key เพี้ยน หรือ null ในที่ที่คุณคาดว่าเป็น object.
ผมเคยเจอ bug จากการ map ข้อมูล vendor ที่มาผ่าน bridge ตรงๆ เข้า UI โดยไม่มี defensive access — พอ field หนึ่งหายไป component ทั้งหน้า crash. บทเรียนคือทำ mapping layer ที่ทน field หาย: ให้ default, guard ทุก nested access, และแยก "รูป contract ที่ UI ต้องใช้" ออกจาก "รูปดิบที่ข้าม bridge มา" เสมอ.
// map payload ดิบ → รูปที่ UI ใช้ ด้วย defensive access
function mapCabinetToUI(raw) {
return {
id: raw?.deviceId ?? 'unknown',
name: raw?.name?.trim() || 'ตู้ไม่ระบุชื่อ',
battery: Number.isFinite(raw?.battery) ? raw.battery : 0,
// อย่า assume array — vendor รุ่นเก่าอาจส่ง null
slots: Array.isArray(raw?.slots) ? raw.slots : [],
}
}
ทดสอบ bridge แยกจาก UI: mock ฝั่ง native
ข้อดีของการมองว่า bridge เป็น protocol ที่มี contract ชัดคือ test ได้โดยไม่ต้องมี native จริง. เพราะฝั่ง web คุยกับ bridge ผ่าน interface เดียว (ส่ง {type,id,payload} รับ {id,payload,success}) แค่ mock ฝั่ง native ก็ test ครบทุกสถานการณ์ที่ native จริงทำให้เกิดได้ยาก: timeout, success, error, payload ผิดรูป.
// mock native: จำลองทั้ง happy path และ delay/timeout
function installMockBridge({ delay = 0, fail = false } = {}) {
window.ReactNativeWebView = {
postMessage(raw) {
const { id, type } = JSON.parse(raw)
setTimeout(() => {
window.NativeBridge.receiveMessage(JSON.stringify({
type, id, success: !fail,
payload: fail ? { message: 'mock error' } : { ok: true },
}))
}, delay) // ตั้ง delay สูงกว่า timeout เพื่อ test เคส bridge เงียบ
},
}
}
สรุป + สิ่งที่เอาไปใช้ได้
- มอง bridge เป็น network protocol ไม่ใช่ function call — async, มี latency, ข้อความหายได้ ทุกกฎของ network ใช้กับ bridge
- ทำ handshake + queue ตอน boot — web โหลดก่อน native inject เสมอ อย่ายิง call ก่อน bridge พร้อม และมี fallback chain แทนการผูกทุกอย่างกับ bridge
- contract ตายตัว + correlation id + versioning — match คำตอบกับคำถามด้วย id และรับมือกับ web ใหม่ที่คุยกับ native เก่าเสมอ
- ทุก call มี timeout + fallback — bridge เงียบต้องไม่ทำให้ UI ค้าง
- จัดการ lifecycle — subscribe app state แล้ว resync ตอน foreground อย่าเชื่อ timer ที่อาจถูก suspend
- validate ข้าม bridge แบบ defensive — เหมือน API boundary ทำ mapping layer ที่ทน field หาย
- test แยกด้วย mock native — contract ชัดทำให้จำลอง timeout/error/payload ผิดรูป ได้โดยไม่ต้องมีเครื่องจริง




