มาพูดถึง... สถาปัตยกรรมกัน
ฉันจะพูดถึงหัวข้อที่สำคัญแต่อาจเข้าใจผิดได้ นั่นคือสถาปัตยกรรมที่คุณใช้สำหรับเว็บแอป และโดยเฉพาะอย่างยิ่ง การตัดสินใจด้านสถาปัตยกรรมมีบทบาทอย่างไรเมื่อคุณสร้าง Progressive Web App
"สถาปัตยกรรม" อาจฟังดูคลุมเครือ และอาจไม่ชัดเจนในทันทีว่าทำไมเรื่องนี้จึงสำคัญ วิธีหนึ่งในการพิจารณาสถาปัตยกรรมคือการถามตัวเองด้วยคำถามต่อไปนี้ เมื่อผู้ใช้เข้าชมหน้าเว็บในเว็บไซต์ของฉัน ระบบจะโหลด HTML ใด แล้วระบบจะโหลดอะไรเมื่อผู้ใช้เข้าชมหน้าอื่น
คำตอบของคำถามเหล่านั้นไม่ได้ตรงไปตรงมาเสมอไป และเมื่อคุณเริ่มคิดถึง Progressive Web App คำตอบเหล่านั้นก็อาจซับซ้อนยิ่งขึ้น ดังนั้น เป้าหมายของฉันคือการแนะนำสถาปัตยกรรมที่เป็นไปได้แบบหนึ่งซึ่งฉันพบว่ามีประสิทธิภาพ ตลอดทั้งบทความนี้ ฉันจะติดป้ายกำกับการตัดสินใจที่ฉันทำว่าเป็น "แนวทางของฉัน" ในการสร้าง Progressive Web App
คุณสามารถใช้วิธีการของฉันเมื่อสร้าง PWA ของคุณเองได้ แต่ในขณะเดียวกันก็ยังมีทางเลือกอื่นๆ ที่ใช้ได้เสมอ เราหวังว่าการได้เห็นว่าแต่ละส่วนประกอบเข้ากันได้อย่างไรจะช่วยสร้างแรงบันดาลใจให้คุณ และทำให้คุณรู้สึกมั่นใจที่จะปรับแต่งสิ่งนี้ให้เหมาะกับความต้องการของตนเอง
Stack Overflow PWA
ฉันได้สร้าง Stack Overflow PWA เพื่อใช้ประกอบบทความนี้ ฉันใช้เวลาส่วนใหญ่ไปกับการอ่านและมีส่วนร่วมใน Stack Overflow และฉันต้องการสร้างเว็บแอปที่ช่วยให้เรียกดูคำถามที่พบบ่อยสำหรับหัวข้อหนึ่งๆ ได้ง่าย ซึ่งสร้างขึ้นบน Stack Exchange API สาธารณะ ซึ่งเป็นโอเพนซอร์ส และคุณสามารถดูข้อมูลเพิ่มเติมได้โดยไปที่โปรเจ็กต์ GitHub
แอปหลายหน้า (MPA)
ก่อนที่จะลงรายละเอียด เรามาทำความเข้าใจคำศัพท์บางคำและอธิบายเทคโนโลยีเบื้องหลังกันก่อน ก่อนอื่น ฉันจะพูดถึงสิ่งที่ฉันเรียกว่า "แอปแบบหลายหน้า" หรือ "MPA"
MPA เป็นชื่อที่ดูดีของสถาปัตยกรรมแบบดั้งเดิมที่ใช้ตั้งแต่เริ่มมีเว็บ ทุกครั้งที่ผู้ใช้ไปยัง URL ใหม่ เบราว์เซอร์จะแสดงผล HTML เฉพาะสำหรับหน้านั้นๆ อย่างต่อเนื่อง ระบบจะไม่พยายามรักษาสถานะของหน้าหรือเนื้อหาไว้ระหว่างการไปยังส่วนต่างๆ ทุกครั้งที่คุณเข้าชมหน้าเว็บใหม่ คุณจะเริ่มต้นใหม่ทั้งหมด
ซึ่งตรงกันข้ามกับโมเดลแอปหน้าเดียว (SPA) สำหรับการสร้างเว็บแอป ซึ่งเบราว์เซอร์จะเรียกใช้โค้ด JavaScript เพื่ออัปเดตหน้าเว็บที่มีอยู่เมื่อผู้ใช้เข้าชมส่วนใหม่ ทั้ง SPA และ MPA เป็นโมเดลที่ใช้ได้เหมือนกัน แต่สำหรับโพสต์นี้ ฉันต้องการสำรวจแนวคิด PWA ภายในบริบทของแอปหลายหน้า
รวดเร็วและเชื่อถือได้
คุณคงเคยได้ยินฉัน (และคนอื่นๆ อีกนับไม่ถ้วน) ใช้คำว่า "เว็บแอปแบบโปรเกรสซีฟ" หรือ PWA คุณอาจคุ้นเคยกับเนื้อหาพื้นหลังบางส่วนในส่วนอื่นๆ ของเว็บไซต์นี้อยู่แล้ว
คุณอาจมองว่า PWA เป็นเว็บแอปที่มอบประสบการณ์ของผู้ใช้ระดับเฟิร์สคลาส และได้รับตำแหน่งบนหน้าจอหลักของผู้ใช้อย่างแท้จริง คำย่อ "FIRE" ซึ่งย่อมาจาก Fast (รวดเร็ว), Integrated (ผสานรวม), Reliable (เชื่อถือได้) และ Engaging (น่าสนใจ) สรุป คุณลักษณะทั้งหมดที่ควรคำนึงถึงเมื่อสร้าง PWA
ในบทความนี้ เราจะมุ่งเน้นที่ชุดย่อยของแอตทริบิวต์เหล่านั้น ซึ่งก็คือรวดเร็วและเชื่อถือได้
รวดเร็ว: แม้ว่า "รวดเร็ว" จะมีความหมายแตกต่างกันไปในบริบทต่างๆ แต่เราจะพูดถึงประโยชน์ด้านความเร็วของการโหลดจากเครือข่ายให้น้อยที่สุด
เชื่อถือได้: แต่ความเร็วเพียงอย่างเดียวยังไม่พอ เว็บแอปควรมีความน่าเชื่อถือเพื่อให้ผู้ใช้รู้สึกเหมือนกำลังใช้ PWA โดยต้องมีความยืดหยุ่นมากพอที่จะโหลดบางอย่างได้เสมอ แม้ว่าจะเป็นเพียงหน้าแสดงข้อผิดพลาดที่กำหนดเองก็ตาม ไม่ว่าสถานะของเครือข่ายจะเป็นอย่างไร
เร็วอย่างเสถียร: สุดท้ายนี้ เราจะปรับเปลี่ยนคำจำกัดความของ PWA เล็กน้อยและดูว่าการสร้างสิ่งที่เร็วอย่างเสถียรหมายความว่าอย่างไร การทำงานที่รวดเร็วและเชื่อถือได้เฉพาะเมื่ออยู่ในเครือข่ายที่มีเวลาในการตอบสนองต่ำนั้นยังไม่ดีพอ การทำงานที่รวดเร็วและเชื่อถือได้หมายความว่าความเร็วของเว็บแอปมีความสม่ำเสมอ ไม่ว่าสภาพเครือข่ายที่ใช้จะเป็นอย่างไร
เทคโนโลยีที่ช่วยให้ทำงานได้: Service Worker + Cache Storage API
PWA มีมาตรฐานสูงในด้านความเร็วและความยืดหยุ่น โชคดีที่แพลตฟอร์มเว็บมีองค์ประกอบบางอย่างที่ช่วยให้ประสิทธิภาพประเภทนั้นเป็นจริงได้ ซึ่งก็คือ Service Worker และ Cache Storage API
คุณสามารถสร้าง Service Worker ที่คอยฟังคำขอขาเข้า ส่งต่อบางคำขอไปยังเครือข่าย และจัดเก็บสำเนาการตอบกลับไว้ใช้ในอนาคตผ่าน Cache Storage API
ครั้งถัดไปที่เว็บแอปส่งคำขอเดียวกัน Service Worker จะตรวจสอบแคชและแสดงผลการตอบกลับที่แคชไว้ก่อนหน้านี้ได้
การหลีกเลี่ยงการใช้เครือข่ายเมื่อเป็นไปได้เป็นส่วนสำคัญในการมอบประสิทธิภาพที่รวดเร็วและเชื่อถือได้
JavaScript แบบ "Isomorphic"
อีกแนวคิดหนึ่งที่ฉันต้องการพูดถึงคือสิ่งที่บางครั้งเรียกว่า "Isomorphic" หรือ "Universal" JavaScript พูดง่ายๆ ก็คือแนวคิดที่ว่าโค้ด JavaScript เดียวกันสามารถแชร์ระหว่างสภาพแวดล้อมรันไทม์ที่แตกต่างกันได้ ตอนที่สร้าง PWA ฉันต้องการแชร์โค้ด JavaScript ระหว่างเซิร์ฟเวอร์แบ็กเอนด์กับ Service Worker
มีวิธีที่ถูกต้องมากมายในการแชร์โค้ดในลักษณะนี้ แต่วิธีของฉันคือการใช้โมดูล ES เป็นซอร์สโค้ดที่ชัดเจน จากนั้นฉันก็ทำการ Transpile และรวมโมดูลเหล่านั้นสำหรับเซิร์ฟเวอร์และ Service Worker โดยใช้ Babel และ Rollup ร่วมกัน ในโปรเจ็กต์ของฉัน ไฟล์ที่มีนามสกุลไฟล์ .mjs คือโค้ดที่อยู่ในโมดูล ES
เซิร์ฟเวอร์
เมื่อคำนึงถึงแนวคิดและคำศัพท์เหล่านั้นแล้ว เรามาเจาะลึกวิธีที่ฉันสร้าง Stack Overflow PWA กัน ฉันจะเริ่มด้วยการพูดถึงเซิร์ฟเวอร์แบ็กเอนด์ และอธิบายว่าเซิร์ฟเวอร์นี้เข้ากับสถาปัตยกรรมโดยรวมอย่างไร
ผมกำลังมองหาการผสมผสานระหว่างแบ็กเอนด์แบบไดนามิกกับโฮสติ้งแบบคงที่ และแนวทางของผมคือการใช้แพลตฟอร์ม Firebase
Firebase Cloud Functions จะสร้างสภาพแวดล้อมที่ใช้ Node โดยอัตโนมัติเมื่อมีคำขอเข้ามา และผสานรวมกับเฟรมเวิร์ก Express HTTP ยอดนิยมที่ฉันคุ้นเคยอยู่แล้ว นอกจากนี้ยังให้บริการโฮสติ้งที่พร้อมใช้งานทันทีสำหรับทรัพยากรแบบคงที่ทั้งหมดของเว็บไซต์ด้วย มาดูวิธีที่เซิร์ฟเวอร์จัดการคำขอกัน
เมื่อเบราว์เซอร์ส่งคำขอไปยังส่วนต่างๆ ไปยังเซิร์ฟเวอร์ของเรา คำขอดังกล่าวจะผ่านขั้นตอนต่อไปนี้
เซิร์ฟเวอร์จะกำหนดเส้นทางคำขอตาม URL และใช้ตรรกะการสร้างเทมเพลตเพื่อสร้างเอกสาร HTML ที่สมบูรณ์ ฉันใช้ข้อมูลจาก Stack Exchange API ร่วมกับข้อมูลโค้ด HTML บางส่วนที่เซิร์ฟเวอร์จัดเก็บไว้ในเครื่อง เมื่อ Service Worker ของเราทราบวิธีตอบสนองแล้ว ก็จะเริ่มสตรีม HTML กลับไปยังเว็บแอปของเราได้
ภาพนี้มี 2 ส่วนที่ควรสำรวจรายละเอียดเพิ่มเติม ได้แก่ การกำหนดเส้นทางและการสร้างเทมเพลต
การกำหนดเส้นทาง
เมื่อพูดถึงการกำหนดเส้นทาง แนวทางของฉันคือการใช้ไวยากรณ์การกำหนดเส้นทางแบบเนทีฟของเฟรมเวิร์ก Express ซึ่งมีความยืดหยุ่นมากพอที่จะจับคู่คำนำหน้า URL แบบง่าย รวมถึง URL ที่มีพารามิเตอร์เป็นส่วนหนึ่งของเส้นทาง ในส่วนนี้ ฉันจะสร้างการแมประหว่างชื่อเส้นทางกับรูปแบบ Express ที่เกี่ยวข้องเพื่อจับคู่
const routes = new Map([
['about', '/about'],
['questions', '/questions/:questionId'],
['index', '/'],
]);
export default routes;
จากนั้นฉันจะอ้างอิงการแมปนี้ได้โดยตรงจากโค้ดของเซิร์ฟเวอร์ เมื่อมีการจับคู่สำหรับรูปแบบ Express ที่ระบุ ตัวแฮนเดิลที่เหมาะสมจะตอบสนองด้วยตรรกะการสร้างเทมเพลตที่เจาะจงสำหรับเส้นทางที่ตรงกัน
import routes from './lib/routes.mjs';
app.get(routes.get('index'), async (req, res) => {
// Templating logic.
});
การใช้เทมเพลตฝั่งเซิร์ฟเวอร์
แล้วตรรกะการสร้างเทมเพลตนั้นมีลักษณะอย่างไร ผมเลือกใช้วิธีที่นำส่วนย่อยของ HTML มาต่อกันตามลำดับทีละส่วน โมเดลนี้เหมาะอย่างยิ่งสำหรับการสตรีม
เซิร์ฟเวอร์จะส่งต้นแบบ HTML เริ่มต้นกลับมาทันที และเบราว์เซอร์จะแสดงผลหน้าบางส่วนนั้นได้ทันที ขณะที่เซิร์ฟเวอร์รวบรวมแหล่งข้อมูลที่เหลือเข้าด้วยกัน เซิร์ฟเวอร์จะสตรีมแหล่งข้อมูลเหล่านั้นไปยังเบราว์เซอร์จนกว่าเอกสารจะสมบูรณ์
เพื่อให้เห็นภาพชัดเจนขึ้น ลองดูโค้ด Express สำหรับเส้นทางหนึ่งของเรา
app.get(routes.get('index'), async (req, res) => {
res.write(headPartial + navbarPartial);
const tag = req.query.tag || DEFAULT_TAG;
const data = await requestData(...);
res.write(templates.index(tag, data.items));
res.write(footPartial);
res.end();
});
เมื่อใช้เมธอด write() ของออบเจ็กต์ response และอ้างอิงเทมเพลตบางส่วนที่จัดเก็บไว้ในเครื่อง ฉันจะเริ่มสตรีมการตอบกลับได้ทันทีโดยไม่บล็อกแหล่งข้อมูลภายนอก เบราว์เซอร์จะใช้ HTML เริ่มต้นนี้และแสดงผลอินเทอร์เฟซที่มีความหมายและข้อความการโหลดทันที
ส่วนถัดไปของหน้าเว็บใช้ข้อมูลจาก Stack Exchange API การรับข้อมูลดังกล่าวหมายความว่าเซิร์ฟเวอร์ของเราต้องส่งคำขอเครือข่าย เว็บแอปจะแสดงผลอย่างอื่นไม่ได้จนกว่าจะได้รับการตอบกลับและประมวลผล แต่ผู้ใช้อย่างน้อยก็ไม่ต้องจ้องหน้าจอว่างเปล่าขณะรอ
เมื่อเว็บแอปได้รับการตอบกลับจาก Stack Exchange API แล้ว เว็บแอปจะเรียกใช้ฟังก์ชันการสร้างเทมเพลตที่กำหนดเองเพื่อแปลข้อมูลจาก API เป็น HTML ที่เกี่ยวข้อง
ภาษาเทมเพลต
การสร้างเทมเพลตอาจเป็นหัวข้อที่ถกเถียงกันอย่างน่าประหลาดใจ และสิ่งที่ฉันเลือกใช้ก็เป็นเพียงแนวทางหนึ่งจากหลายๆ แนวทาง คุณจะต้องใช้โซลูชันของคุณเอง โดยเฉพาะอย่างยิ่งหากคุณมีความสัมพันธ์เดิมกับเฟรมเวิร์กเทมเพลตที่มีอยู่
สิ่งที่เหมาะสมกับ Use Case ของฉันคือการใช้ Template Literal ของ JavaScript โดยแยกตรรกะบางอย่างออกเป็นฟังก์ชันตัวช่วย ข้อดีอย่างหนึ่งของการสร้าง MPA คือคุณไม่จำเป็นต้องติดตามการอัปเดตสถานะและแสดงผล HTML ซ้ำ ดังนั้นแนวทางพื้นฐานที่สร้าง HTML แบบคงที่จึงเหมาะกับฉัน
ต่อไปนี้คือตัวอย่างวิธีที่ฉันสร้างเทมเพลตส่วน HTML แบบไดนามิกของดัชนีเว็บแอป เช่นเดียวกับเส้นทางของฉัน ตรรกะการสร้างเทมเพลตจะจัดเก็บไว้ในโมดูล ES ที่สามารถนำเข้าทั้งเซิร์ฟเวอร์และ Service Worker
export function index(tag, items) {
const title = `<h3>Top "${escape(tag)}" Questions</h3>`;
const form = `<form method="GET">...</form>`;
const questionCards = items
.map(item =>
questionCard({
id: item.question_id,
title: item.title,
})
)
.join('');
const questions = `<div id="questions">${questionCards}</div>`;
return title + form + questions;
}
ฟังก์ชันเทมเพลตเหล่านี้เป็น JavaScript ล้วนๆ และการแยกตรรกะออกเป็นฟังก์ชันตัวช่วยขนาดเล็กเมื่อเหมาะสมก็เป็นประโยชน์ ในที่นี้ ฉันจะส่งแต่ละรายการที่แสดงผลในการตอบกลับจาก API ไปยังฟังก์ชันดังกล่าว ซึ่งจะสร้างองค์ประกอบ HTML มาตรฐานที่มีการตั้งค่าแอตทริบิวต์ที่เหมาะสมทั้งหมด
function questionCard({id, title}) {
return `<a class="card"
href="/questions/${id}"
data-cache-url="${questionUrl(id)}">${title}</a>`;
}
สิ่งที่น่าสังเกตเป็นพิเศษคือแอตทริบิวต์ข้อมูลที่ฉันเพิ่มลงในแต่ละลิงก์ data-cache-url ซึ่งตั้งค่าเป็น URL ของ Stack Exchange API ที่ฉันต้องการเพื่อแสดงคำถามที่เกี่ยวข้อง โปรดคำนึงถึงเรื่องนี้ด้วย ฉันจะกลับมาดูอีกครั้งในภายหลัง
กลับไปที่เครื่องมือจัดการเส้นทาง เมื่อการสร้างเทมเพลตเสร็จสมบูรณ์แล้ว ฉันจะสตรีมส่วนสุดท้ายของ HTML ของหน้าไปยังเบราว์เซอร์ และสิ้นสุดสตรีม ซึ่งเป็นสัญญาณบอกเบราว์เซอร์ว่าการแสดงผลแบบต่อเนื่องเสร็จสมบูรณ์แล้ว
app.get(routes.get('index'), async (req, res) => {
res.write(headPartial + navbarPartial);
const tag = req.query.tag || DEFAULT_TAG;
const data = await requestData(...);
res.write(templates.index(tag, data.items));
res.write(footPartial);
res.end();
});
นี่ก็เป็นภาพรวมคร่าวๆ ของการตั้งค่าเซิร์ฟเวอร์ของฉัน ผู้ใช้ที่เข้าชมเว็บแอปของฉันเป็นครั้งแรกจะได้รับการตอบกลับจากเซิร์ฟเวอร์เสมอ แต่เมื่อผู้เข้าชมกลับมาที่เว็บแอปของฉัน Service Worker จะเริ่มตอบกลับ มาดูรายละเอียดกัน
Service Worker
แผนภาพนี้ควรดูคุ้นตา เพราะองค์ประกอบหลายอย่างที่ฉันได้พูดถึงไปก่อนหน้านี้ก็อยู่ในนี้ด้วย โดยมีการจัดเรียงที่แตกต่างกันเล็กน้อย มาดูขั้นตอนของคำขอโดยพิจารณาถึง Service Worker กัน
Service Worker ของเราจะจัดการคำขอการนำทางขาเข้าสำหรับ URL หนึ่งๆ และใช้ตรรกะการกำหนดเส้นทางและการกำหนดเทมเพลตร่วมกันเพื่อดูวิธีตอบสนอง เช่นเดียวกับที่เซิร์ฟเวอร์ของเราทำ
แนวทางนี้เหมือนกับก่อนหน้านี้ แต่มีองค์ประกอบระดับต่ำที่แตกต่างกัน เช่น fetch() และ Cache Storage API ฉันใช้แหล่งข้อมูลเหล่านั้นเพื่อสร้างการตอบกลับ HTML ซึ่ง Service Worker จะส่งกลับไปยังเว็บแอป
Workbox
แทนที่จะเริ่มต้นใหม่ตั้งแต่ต้นด้วยพื้นฐานระดับต่ำ ฉันจะสร้าง Service Worker บนชุดไลบรารีระดับสูงที่เรียกว่า Workbox ซึ่งเป็นรากฐานที่มั่นคงสำหรับตรรกะการแคช การกำหนดเส้นทาง และการสร้างการตอบกลับของ Service Worker
การกำหนดเส้นทาง
Service Worker ต้องทราบวิธีจับคู่คำขอที่เข้ามากับตรรกะการตอบกลับที่เหมาะสม เช่นเดียวกับโค้ดฝั่งเซิร์ฟเวอร์
แนวทางของฉันคือแปลเส้นทาง Express แต่ละเส้นทางเป็นนิพจน์ทั่วไปที่สอดคล้องกัน โดยใช้ไลบรารีที่มีประโยชน์ชื่อ regexparam เมื่อทำการแปลแล้ว ฉันจะใช้ประโยชน์จากการรองรับในตัวของ Workbox สำหรับการกำหนดเส้นทางนิพจน์ทั่วไปได้
หลังจากนำเข้าโมดูลที่มีนิพจน์ทั่วไปแล้ว ฉันจะลงทะเบียนนิพจน์ทั่วไปแต่ละรายการกับเราเตอร์ของ Workbox ภายในแต่ละเส้นทาง ฉันสามารถระบุตรรกะการสร้างเทมเพลตที่กำหนดเองเพื่อสร้างการตอบกลับได้ การสร้างเทมเพลตใน Service Worker มีความซับซ้อนกว่าในเซิร์ฟเวอร์แบ็กเอนด์เล็กน้อย แต่ Workbox ช่วยลดภาระงานหนักๆ ได้มาก
import regExpRoutes from './regexp-routes.mjs';
workbox.routing.registerRoute(
regExpRoutes.get('index')
// Templating logic.
);
การแคชชิ้นงานแบบคงที่
ส่วนสำคัญอย่างหนึ่งของเรื่องราวการใช้เทมเพลตคือการตรวจสอบว่าเทมเพลต HTML บางส่วนของฉันพร้อมใช้งานในเครื่องผ่าน Cache Storage API และอัปเดตอยู่เสมอเมื่อฉันทำให้การเปลี่ยนแปลงใช้งานได้ในเว็บแอป การบำรุงรักษาแคชอาจเกิดข้อผิดพลาดได้ง่ายเมื่อทำด้วยมือ ฉันจึงหันไปใช้ Workbox เพื่อจัดการการแคชล่วงหน้าซึ่งเป็นส่วนหนึ่งของกระบวนการบิลด์
ฉันบอก Workbox ว่า URL ใดที่ควรแคชล่วงหน้าโดยใช้ไฟล์การกำหนดค่า ซึ่งชี้ไปยังไดเรกทอรีที่มีชิ้นงานในเครื่องทั้งหมดพร้อมกับชุดรูปแบบที่ตรงกัน CLI ของ Workbox จะอ่านไฟล์นี้โดยอัตโนมัติ ซึ่งจะทำงานทุกครั้งที่ฉันสร้างเว็บไซต์ใหม่
module.exports = {
globDirectory: 'build',
globPatterns: ['**/*.{html,js,svg}'],
// Other options...
};
Workbox จะจับภาพสแนปชอตของเนื้อหาในแต่ละไฟล์ และแทรกรายการ URL และการแก้ไขลงในไฟล์ Service Worker สุดท้ายโดยอัตโนมัติ ตอนนี้ Workbox มีทุกอย่างที่จำเป็นในการทำให้ไฟล์ที่แคชไว้ล่วงหน้าพร้อมใช้งานเสมอและอัปเดตอยู่เสมอ ผลลัพธ์ที่ได้คือไฟล์ service-worker.js ที่มีข้อมูลคล้ายกับตัวอย่างต่อไปนี้
workbox.precaching.precacheAndRoute([
{
url: 'partials/about.html',
revision: '518747aad9d7e',
},
{
url: 'partials/foot.html',
revision: '69bf746a9ecc6',
},
// etc.
]);
สำหรับผู้ที่ใช้กระบวนการบิลด์ที่ซับซ้อนมากขึ้น Workbox มีทั้งปลั๊กอิน webpack และโมดูลโหนดทั่วไป นอกเหนือจากอินเทอร์เฟซบรรทัดคำสั่ง
สตรีมมิง
ต่อไป ฉันต้องการให้ Service Worker สตรีม HTML บางส่วนที่แคชไว้ล่วงหน้ากลับไปยังเว็บแอปทันที ซึ่งเป็นส่วนสำคัญของการเป็น "ความเร็วที่เชื่อถือได้" โดยฉันจะได้รับสิ่งที่มีความหมายบนหน้าจอทันทีเสมอ โชคดีที่การใช้ Streams API ภายใน Service Worker ของเราทำให้ทำเช่นนั้นได้
คุณอาจเคยได้ยินเกี่ยวกับ Streams API มาก่อน Jake Archibald เพื่อนร่วมงานของฉันได้ชื่นชมเรื่องนี้มาหลายปีแล้ว เขาคาดการณ์อย่างมั่นใจว่าปี 2016 จะเป็นปีแห่งสตรีมบนเว็บ และ Streams API ก็ยังคงยอดเยี่ยมเหมือนเมื่อ 2 ปีก่อน แต่มีความแตกต่างที่สำคัญอย่างหนึ่ง
แม้ว่าในตอนนั้นจะมีเพียง Chrome เท่านั้นที่รองรับ Streams แต่ตอนนี้ Streams API ได้รับการรองรับอย่างแพร่หลายมากขึ้น โดยรวมแล้วถือว่าเป็นเรื่องดี และด้วยโค้ดสำรองที่เหมาะสม คุณก็สามารถใช้สตรีมใน Service Worker ได้แล้ววันนี้
แต่ก็อาจมีสิ่งหนึ่งที่ทำให้คุณลังเล นั่นคือการทำความเข้าใจวิธีการทำงานของ Streams API จริงๆ โดยจะแสดงชุดพื้นฐานที่มีประสิทธิภาพมาก และนักพัฒนาซอฟต์แวร์ที่ใช้งานได้อย่างสะดวกจะสร้างกระแสข้อมูลที่ซับซ้อนได้ เช่น
const stream = new ReadableStream({
pull(controller) {
return sources[0]
.then(r => r.read())
.then(result => {
if (result.done) {
sources.shift();
if (sources.length === 0) return controller.close();
return this.pull(controller);
} else {
controller.enqueue(result.value);
}
});
},
});
แต่การทำความเข้าใจผลกระทบทั้งหมดของโค้ดนี้อาจไม่ใช่เรื่องง่ายสำหรับทุกคน แทนที่จะแยกวิเคราะห์ผ่านตรรกะนี้ เรามาพูดถึงแนวทางของฉันในการสตรีม Service Worker กันดีกว่า
ฉันใช้ Wrapper ระดับสูงแบบใหม่ที่ชื่อว่า workbox-streams
ซึ่งช่วยให้ฉันส่งข้อมูลในรูปแบบสตรีมมิงที่ผสมผสานกันได้ ทั้งจากแคชและข้อมูลรันไทม์ที่อาจมาจากเครือข่าย Workbox จะดูแลการประสานงานแหล่งที่มาแต่ละรายการและเชื่อมโยงเข้าด้วยกันเป็นการตอบกลับแบบสตรีมมิงรายการเดียว
นอกจากนี้ Workbox จะตรวจหาโดยอัตโนมัติว่าระบบรองรับ Streams API หรือไม่ และหากไม่รองรับ ก็จะสร้างการตอบกลับที่เทียบเท่าและไม่ใช่สตรีมมิง ซึ่งหมายความว่าคุณไม่ต้องกังวลเกี่ยวกับการเขียนฟังก์ชันสำรอง เนื่องจากสตรีมใกล้จะรองรับเบราว์เซอร์ได้ 100% แล้ว
การแคชรันไทม์
มาดูกันว่า Service Worker ของฉัน จัดการกับข้อมูลรันไทม์จาก Stack Exchange API อย่างไร ฉันใช้การรองรับในตัวของ Workbox สำหรับกลยุทธ์การแคชแบบ stale-while-revalidate พร้อมกับการหมดอายุเพื่อให้แน่ใจว่าพื้นที่เก็บข้อมูลของเว็บแอปจะไม่เพิ่มขึ้นอย่างไม่จำกัด
ฉันตั้งค่ากลยุทธ์ 2 อย่างใน Workbox เพื่อจัดการแหล่งที่มาต่างๆ ที่จะสร้างการตอบกลับแบบสตรีมมิง Workbox ช่วยให้เราทำสิ่งที่ปกติแล้วต้องใช้โค้ดที่เขียนด้วยมือหลายร้อยบรรทัดได้ด้วยการเรียกใช้ฟังก์ชันและการกำหนดค่าเพียงไม่กี่ครั้ง
const cacheStrategy = workbox.strategies.cacheFirst({
cacheName: workbox.core.cacheNames.precache,
});
const apiStrategy = workbox.strategies.staleWhileRevalidate({
cacheName: API_CACHE_NAME,
plugins: [new workbox.expiration.Plugin({maxEntries: 50})],
});
กลยุทธ์แรกจะอ่านข้อมูลที่แคชไว้ล่วงหน้า เช่น เทมเพลต HTML บางส่วน
อีกกลยุทธ์หนึ่งใช้ตรรกะการแคชแบบ stale-while-revalidate พร้อมกับการหมดอายุของแคชที่ใช้ล่าสุดเมื่อมีรายการถึง 50 รายการ
เมื่อมีกลยุทธ์เหล่านั้นแล้ว สิ่งที่เหลืออยู่ก็คือบอก Workbox ว่าจะใช้กลยุทธ์เหล่านั้นเพื่อสร้างการตอบกลับแบบสตรีมมิงที่สมบูรณ์ได้อย่างไร ฉันส่งอาร์เรย์ของแหล่งที่มาเป็นฟังก์ชัน และแต่ละฟังก์ชันจะทำงานทันที Workbox จะนำผลลัพธ์จากแต่ละแหล่งที่มาและสตรีมไปยังเว็บแอปตามลำดับ โดยจะหน่วงเวลาเฉพาะในกรณีที่ฟังก์ชันถัดไปในอาร์เรย์ยังไม่เสร็จสมบูรณ์
workbox.streams.strategy([
() => cacheStrategy.makeRequest({request: '/head.html'}),
() => cacheStrategy.makeRequest({request: '/navbar.html'}),
async ({event, url}) => {
const tag = url.searchParams.get('tag') || DEFAULT_TAG;
const listResponse = await apiStrategy.makeRequest(...);
const data = await listResponse.json();
return templates.index(tag, data.items);
},
() => cacheStrategy.makeRequest({request: '/foot.html'}),
]);
แหล่งที่มา 2 รายการแรกคือเทมเพลตบางส่วนที่แคชไว้ล่วงหน้าซึ่งอ่านโดยตรงจาก Cache Storage API จึงพร้อมใช้งานทันทีเสมอ วิธีนี้ช่วยให้มั่นใจได้ว่าการติดตั้งใช้งาน Service Worker จะตอบสนองต่อคำขอได้อย่างรวดเร็วและเชื่อถือได้ เช่นเดียวกับโค้ดฝั่งเซิร์ฟเวอร์
ฟังก์ชันแหล่งที่มาถัดไปจะดึงข้อมูลจาก Stack Exchange API และประมวลผลการตอบกลับเป็น HTML ที่เว็บแอปคาดหวัง
กลยุทธ์ stale-while-revalidate หมายความว่าหากฉันมีการตอบกลับที่แคชไว้ก่อนหน้านี้สำหรับการเรียก API นี้ ฉันจะสตรีมไปยังหน้าเว็บได้ทันที พร้อมกับอัปเดตรายการแคช "ในเบื้องหลัง" สำหรับครั้งถัดไปที่มีการขอ
สุดท้าย ฉันจะสตรีมสำเนาที่แคชไว้ของส่วนท้ายและปิดแท็ก HTML สุดท้ายเพื่อทำให้การตอบกลับเสร็จสมบูรณ์
การแชร์โค้ดช่วยให้ทุกอย่างซิงค์กัน
คุณจะสังเกตเห็นว่าโค้ด Service Worker บางส่วนดูคุ้นเคย HTML บางส่วนและตรรกะการสร้างเทมเพลตที่ Service Worker ของฉันใช้เหมือนกับที่ตัวแฮนเดิลฝั่งเซิร์ฟเวอร์ใช้ การแชร์โค้ดนี้ช่วยให้ผู้ใช้ได้รับประสบการณ์การใช้งานที่สอดคล้องกัน ไม่ว่าจะเข้าชมเว็บแอปเป็นครั้งแรกหรือกลับมาที่หน้าเว็บที่แสดงผลโดย Service Worker และนี่คือความสวยงามของ Isomorphic JavaScript
การเพิ่มประสิทธิภาพแบบไดนามิกและต่อเนื่อง
ฉันได้อธิบายทั้งเซิร์ฟเวอร์และ Service Worker สำหรับ PWA ของฉันแล้ว แต่ยังมีตรรกะสุดท้ายที่ต้องพูดถึง นั่นคือมีโค้ด JavaScript จำนวนเล็กน้อยที่ทำงานในแต่ละหน้าหลังจากที่สตรีมเข้ามาอย่างสมบูรณ์
โค้ดนี้จะช่วยปรับปรุงประสบการณ์ของผู้ใช้ให้ดียิ่งขึ้น แต่ก็ไม่ได้สำคัญมากนัก เว็บแอปจะยังคงทำงานได้แม้ว่าจะไม่ได้เรียกใช้โค้ดนี้
ข้อมูลเมตาของหน้า
แอปของฉันใช้ JavaScript ฝั่งไคลเอ็นต์เพื่ออัปเดตข้อมูลเมตาของหน้าเว็บตามการตอบกลับจาก API เนื่องจากฉันใช้ HTML ที่แคชไว้ส่วนแรกเหมือนกันสำหรับแต่ละหน้า เว็บแอปจึงมีแท็กทั่วไปในส่วนหัวของเอกสาร แต่ด้วยการประสานงานระหว่างเทมเพลตและโค้ดฝั่งไคลเอ็นต์ ฉันสามารถอัปเดตชื่อของหน้าต่างโดยใช้ข้อมูลเมตาเฉพาะหน้าเว็บได้
ในส่วนของโค้ดเทมเพลต แนวทางของฉันคือการใส่แท็กสคริปต์ที่มีสตริงที่หลีกอย่างถูกต้อง
const metadataScript = `<script>
self._title = '${escape(item.title)}';
</script>`;
จากนั้น เมื่อหน้าเว็บโหลดเสร็จแล้ว ฉันจะอ่านสตริงนั้นและอัปเดตชื่อเอกสาร
if (self._title) {
document.title = unescape(self._title);
}
หากมีข้อมูลเมตาเฉพาะหน้าเว็บอื่นๆ ที่ต้องการอัปเดตในเว็บแอปของคุณเอง คุณก็ใช้วิธีเดียวกันนี้ได้
UX ออฟไลน์
การเพิ่มประสิทธิภาพแบบต่อเนื่องที่ฉันเพิ่มเข้ามาอีกอย่างหนึ่งคือการดึงดูดความสนใจไปยังความสามารถแบบออฟไลน์ ฉันสร้าง PWA ที่เชื่อถือได้ และต้องการให้ผู้ใช้ทราบว่าเมื่อออฟไลน์ พวกเขายังคงโหลดหน้าเว็บที่เคยเข้าชมได้
ก่อนอื่น ฉันใช้ Cache Storage API เพื่อดูรายการคำขอ API ทั้งหมดที่แคชไว้ก่อนหน้านี้ และแปลเป็นรายการ URL
จำแอตทริบิวต์ข้อมูลพิเศษที่ฉันพูดถึงได้ไหม ซึ่งแต่ละแอตทริบิวต์จะมี URL สำหรับคำขอ API ที่จำเป็นในการแสดงคำถาม ฉันสามารถตรวจสอบเพื่อเปรียบเทียบแอตทริบิวต์ข้อมูลเหล่านั้นกับรายการ URL ที่แคชไว้ และสร้างอาร์เรย์ของลิงก์คำถามทั้งหมดที่ไม่ตรงกันได้
เมื่อเบราว์เซอร์เข้าสู่สถานะออฟไลน์ ฉันจะวนซ้ำรายการลิงก์ที่ไม่ได้แคช และทำให้ลิงก์ที่ใช้งานไม่ได้จางลง โปรดทราบว่านี่เป็นเพียงคำแนะนำแบบภาพสำหรับผู้ใช้เกี่ยวกับสิ่งที่ควรคาดหวังจากหน้าเหล่านั้น ฉันไม่ได้ปิดใช้ลิงก์หรือป้องกันไม่ให้ผู้ใช้ไปยังส่วนต่างๆ จริงๆ
const apiCache = await caches.open(API_CACHE_NAME);
const cachedRequests = await apiCache.keys();
const cachedUrls = cachedRequests.map(request => request.url);
const cards = document.querySelectorAll('.card');
const uncachedCards = [...cards].filter(card => {
return !cachedUrls.includes(card.dataset.cacheUrl);
});
const offlineHandler = () => {
for (const uncachedCard of uncachedCards) {
uncachedCard.style.opacity = '0.3';
}
};
const onlineHandler = () => {
for (const uncachedCard of uncachedCards) {
uncachedCard.style.opacity = '1.0';
}
};
window.addEventListener('online', onlineHandler);
window.addEventListener('offline', offlineHandler);
ข้อผิดพลาดที่พบบ่อย
ตอนนี้เราได้ดูแนวทางในการสร้าง PWA แบบหลายหน้าแล้ว มีปัจจัยหลายอย่างที่คุณจะต้องพิจารณาเมื่อคิดแนวทางของตัวเอง และคุณอาจเลือกแนวทางที่แตกต่างจากฉัน ความยืดหยุ่นดังกล่าวเป็นหนึ่งในข้อดีของการสร้างสำหรับเว็บ
มีข้อผิดพลาดที่พบบ่อยบางประการที่คุณอาจพบเมื่อทำการตัดสินใจด้านสถาปัตยกรรมของคุณเอง และเราต้องการช่วยคุณหลีกเลี่ยงปัญหาเหล่านั้น
อย่าแคช HTML ทั้งหมด
เราไม่แนะนำให้จัดเก็บเอกสาร HTML ทั้งหมดไว้ในแคช อย่างแรกเลยคือมันเปลืองพื้นที่ หากเว็บแอปใช้โครงสร้าง HTML พื้นฐานเดียวกันสำหรับแต่ละหน้า คุณจะต้องจัดเก็บสำเนาของมาร์กอัปเดียวกันซ้ำแล้วซ้ำอีก
ที่สำคัญกว่านั้นคือ หากคุณทำการเปลี่ยนแปลงโครงสร้าง HTML ที่ใช้ร่วมกันของเว็บไซต์ หน้าเว็บที่แคชไว้ก่อนหน้านี้ทั้งหมดจะยังคงติดอยู่กับเลย์เอาต์เก่า ลองนึกถึงความหงุดหงิดของผู้เข้าชมที่กลับมาซึ่งเห็นทั้งหน้าเก่าและหน้าใหม่ปะปนกัน
ความคลาดเคลื่อนของเซิร์ฟเวอร์/Service Worker
ข้อผิดพลาดอีกอย่างหนึ่งที่ควรหลีกเลี่ยงคือเซิร์ฟเวอร์และ Service Worker ไม่ซิงค์กัน แนวทางของฉันคือการใช้ Isomorphic JavaScript เพื่อให้โค้ดเดียวกันทำงานได้ทั้ง 2 ที่ ซึ่งอาจทำไม่ได้เสมอไป ทั้งนี้ขึ้นอยู่กับสถาปัตยกรรมเซิร์ฟเวอร์ที่มีอยู่
ไม่ว่าคุณจะตัดสินใจเลือกสถาปัตยกรรมแบบใด คุณควรมีกลยุทธ์ในการเรียกใช้โค้ดการกำหนดเส้นทางและเทมเพลตที่เทียบเท่ากันในเซิร์ฟเวอร์และ Service Worker
สถานการณ์ที่เลวร้ายที่สุด
เลย์เอาต์/การออกแบบที่ไม่สอดคล้องกัน
จะเกิดอะไรขึ้นหากคุณไม่สนใจข้อผิดพลาดเหล่านั้น ข้อผิดพลาดทุกประเภทอาจเกิดขึ้นได้ แต่สถานการณ์ที่เลวร้ายที่สุดคือผู้ใช้ที่กลับมาเข้าชมหน้าเว็บที่แคชไว้ซึ่งมีเลย์เอาต์ที่ล้าสมัยมาก เช่น หน้าเว็บที่มีข้อความส่วนหัวที่ล้าสมัย หรือใช้ชื่อคลาส CSS ที่ไม่ถูกต้องอีกต่อไป
สถานการณ์ที่เลวร้ายที่สุด: การกำหนดเส้นทางขัดข้อง
หรือผู้ใช้อาจพบ URL ที่เซิร์ฟเวอร์ของคุณจัดการ แต่ไม่ใช่ Service Worker เว็บไซต์ที่เต็มไปด้วยเลย์เอาต์ซอมบี้และทางตันไม่ใช่ PWA ที่เชื่อถือได้
เคล็ดลับเพื่อความสำเร็จ
แต่คุณไม่ได้อยู่เพียงลำพัง เคล็ดลับต่อไปนี้จะช่วยให้คุณหลีกเลี่ยงข้อผิดพลาดเหล่านั้นได้
ใช้ไลบรารีเทมเพลตและการกำหนดเส้นทางที่มีการใช้งานหลายภาษา
ลองใช้ไลบรารีการกำหนดเทมเพลตและการกำหนดเส้นทางที่มีการใช้งาน JavaScript เราทราบดีว่านักพัฒนาซอฟต์แวร์บางรายอาจไม่สามารถย้ายข้อมูลออกจากเว็บเซิร์ฟเวอร์และภาษาเทมเพลตปัจจุบันได้
แต่เฟรมเวิร์กการกำหนดเส้นทางและการสร้างเทมเพลตยอดนิยมจำนวนมากมีการใช้งานในหลายภาษา หากคุณพบไลบรารีที่ทำงานร่วมกับ JavaScript และภาษาของเซิร์ฟเวอร์ปัจจุบันได้ คุณก็เข้าใกล้การทำให้ Service Worker และเซิร์ฟเวอร์ซิงค์กันได้อีกขั้น
ใช้เทมเพลตตามลำดับแทนเทมเพลตที่ซ้อนกัน
ต่อไป เราขอแนะนำให้ใช้ชุดเทมเพลตตามลำดับที่สามารถสตรีมต่อกันได้ คุณสามารถใช้ตรรกะการสร้างเทมเพลตที่ซับซ้อนมากขึ้นในส่วนหลังๆ ของหน้าเว็บได้ ตราบใดที่คุณสามารถสตรีมส่วนแรกของ HTML ได้เร็วที่สุด
แคชทั้งเนื้อหาแบบคงที่และแบบไดนามิกใน Service Worker
คุณควรแคชทรัพยากรแบบคงที่ที่สำคัญทั้งหมดของเว็บไซต์ไว้ล่วงหน้าเพื่อให้ได้ประสิทธิภาพที่ดีที่สุด นอกจากนี้ คุณควรตั้งค่าตรรกะการแคชรันไทม์เพื่อจัดการเนื้อหาแบบไดนามิก เช่น คำขอ API การใช้ Workbox หมายความว่าคุณสามารถต่อยอดจากกลยุทธ์ที่ผ่านการทดสอบอย่างดีและพร้อมใช้งานจริง แทนที่จะต้องเริ่มสร้างใหม่ทั้งหมดตั้งแต่ต้น
บล็อกในเครือข่ายเมื่อจำเป็นจริงๆ เท่านั้น
และในเรื่องที่เกี่ยวข้อง คุณควรบล็อกในเครือข่ายเฉพาะเมื่อไม่สามารถสตรีมการตอบกลับจากแคชได้เท่านั้น การแสดงการตอบกลับจาก API ที่แคชไว้ทันทีมักจะช่วยให้ผู้ใช้ได้รับประสบการณ์ที่ดีกว่าการรอข้อมูลใหม่