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

App Shell Architecture

App-shell architecture คือ design pattern ที่แบ่ง PWA ออกเป็นสองชั้นที่แตกต่างกัน:

  • Shell — HTML, CSS, และ JavaScript ขั้นต่ำที่ render โครงของแอป (navigation, layout, header, footer) ส่วนนี้เปลี่ยนแปลงเฉพาะเมื่อคุณ deploy version ใหม่
  • Content — ข้อมูลและ markup เฉพาะสำหรับแต่ละ view (บทความ, สินค้า, โปรไฟล์ผู้ใช้) ส่วนนี้เปลี่ยนแปลงบ่อยและถูก fetch ที่ runtime

โดยการแยกสองชั้นนี้ คุณสามารถ precache shell อย่างจริงจังและใช้กลยุทธ์ที่เบากว่าสำหรับ content ผลลัพธ์คือการเข้าชมซ้ำโหลด UI frame จาก local storage ในมิลลิวินาที และ content จะ fill in แบบ asynchronous

Shell ควรมีทุกอย่างที่จำเป็นในการ render skeleton ที่มีความหมายโดยไม่มี network request:

index.html — application entry point
app.css — layout, typography, navigation styles
app.js — client-side router และ bootstrap code
icons/logo.svg — brand assets ที่ใช้ในทุกหน้า
offline.html — fallback page สำหรับ navigation ที่ล้มเหลว

ไม่ควรมี per-page data, user-specific content, หรือ assets ที่เปลี่ยนแปลงระหว่าง page views

const SHELL_CACHE = 'shell-v2';
const SHELL_ASSETS = [
'/',
'/app.css',
'/app.js',
'/icons/logo.svg',
'/offline.html',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL_ASSETS))
);
self.skipWaiting();
});

cache.addAll() เป็น atomic — หาก asset ใดหนึ่งดาวน์โหลดล้มเหลว การ install ทั้งหมดจะล้มเหลวและ SW เก่ายังคงทำงาน ควรให้ precache list มีขนาดเล็กและเชื่อถือได้

ใน fetch handler ให้แยกแยะ shell requests จาก content requests และใช้กลยุทธ์ที่แตกต่างกันกับแต่ละอัน

const CONTENT_CACHE = 'content-v1';
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Shell assets: cache-first (เร็ว, offline-safe)
if (SHELL_ASSETS.includes(url.pathname)) {
event.respondWith(
caches.match(event.request).then((cached) => cached || fetch(event.request))
);
return;
}
// API / content: network-first, fall back to cache
if (url.pathname.startsWith('/api/')) {
event.respondWith(
fetch(event.request)
.then((response) => {
const clone = response.clone();
caches.open(CONTENT_CACHE).then((cache) => cache.put(event.request, clone));
return response;
})
.catch(() => caches.match(event.request))
);
return;
}
// Navigation: serve shell, ให้ SPA router จัดการ route
if (event.request.mode === 'navigate') {
event.respondWith(
caches.match('/').then((cached) => cached || fetch(event.request))
);
}
});

ใน Single Page Application server มี HTML file เพียงหนึ่งไฟล์ (index.html / /) การ routing ทั้งหมดเกิดขึ้นใน JavaScript เมื่อ service worker ดักจับ navigation ไปยัง /profile หรือ /settings ควรให้บริการ index.html shell เดิม — SPA router จะอ่าน URL และ render view ที่ถูกต้อง

sequenceDiagram
  participant U as User
  participant SW as Service Worker
  participant CS as Cache Storage
  participant NET as Network

  U->>SW: navigate to /dashboard
  SW->>CS: match('/')
  CS-->>SW: shell (index.html) — instant
  SW-->>U: Shell renders in <100ms
  U->>SW: fetch /api/dashboard-data
  SW->>NET: fetch (network-first)
  NET-->>SW: JSON response
  SW->>CS: cache.put (content-v1)
  SW-->>U: Data fills in
App shell load vs content load sequence

เพราะ shell assets ถูก cache อย่างจริงจัง คุณต้องมีกลยุทธ์ในการ push การอัปเดต แนวทางมาตรฐานคือการเปลี่ยน cache name (เช่น shell-v2shell-v3) และ clean up old caches ใน activate event

self.addEventListener('activate', (event) => {
const keepCaches = [SHELL_CACHE, CONTENT_CACHE];
event.waitUntil(
caches.keys().then((names) =>
Promise.all(
names
.filter((name) => !keepCaches.includes(name))
.map((name) => caches.delete(name))
)
).then(() => self.clients.claim())
);
});
Runs a real service worker + manifest in your browser.
ตัวเลือกBenefitCost
App shell architectureโหลด UI shell ได้ทันทีจาก cache แม้ offline แล้วค่อยเติมข้อมูลต้องแยก shell กับ content ตั้งแต่ตอนออกแบบ เพิ่ม complexity ของ build
Server-render เต็มหน้าทุกครั้งโครงสร้างง่ายกว่า ไม่ต้องแยก shell/contentไม่มีอะไรให้แสดงเลยถ้า network ล้มเหลว เพราะทั้งหน้าขึ้นกับ response เดียว
  • cache เฉพาะ shell แต่ลืมว่า content ที่ dynamic ต้องมี fallback ของตัวเองด้วย ไม่งั้น offline จะเห็น shell เปล่าไม่มีข้อมูล
  • ทำ shell ใหญ่เกินไปจนแทบเท่าทั้งหน้า ทำให้เสียประโยชน์ของการแยก shell ออกจาก content
  • ไม่ versioned shell cache ทำให้ deploy ใหม่แล้ว shell เก่ายังค้างอยู่ ผู้ใช้เห็น UI เก่าปนกับ data ใหม่

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

Google Developers site — เป็นตัวอย่างต้นแบบของ app shell model ที่ Google ใช้สอนแนวคิดนี้ตั้งแต่แรกเริ่ม

Twitter Lite — แยก shell (navigation, layout) ออกจาก timeline content ทำให้เปิดแอปได้ทันทีแม้เน็ตช้าก่อนค่อยโหลด tweet

asset ใดบ้างที่ควรอยู่ใน app shell ที่ precache ไว้?
ทำไมใน SPA จึงควร serve shell (index.html) สำหรับทุก navigation request?
การใส่ asset ที่ใหญ่หรือไม่น่าเชื่อถือใน cache.addAll() ระหว่าง install จะส่งผลอย่างไร?
จะ push อัปเดต shell ให้ผู้ใช้ที่ cache เวอร์ชันเก่าไว้ได้อย่างไร?