มากกว่า SPA - สถาปัตยกรรมทางเลือกสำหรับ PWA

มาพูดถึง... สถาปัตยกรรมกันไหม

ผมจะพูดหัวข้อที่สำคัญแต่อาจมีที่คนเข้าใจผิด นั่นก็คือสถาปัตยกรรมที่คุณใช้สำหรับเว็บแอป โดยเฉพาะอย่างยิ่ง ผลจากการตัดสินใจด้านสถาปัตยกรรมของคุณเมื่อคุณสร้าง Progressive Web App

"สถาปัตยกรรม" อาจฟังดูคลุมเครือและอาจไม่ชัดเจนว่าทำไมจึงเป็นเช่นนั้น จริงๆ แล้ววิธีนึกถึงสถาปัตยกรรมแบบหนึ่งคือการถามตัวเองด้วยคำถามต่อไปนี้ กล่าวคือ เมื่อผู้ใช้เข้าชมหน้าเว็บในเว็บไซต์ HTML ใดที่โหลด แล้วอะไรที่โหลดขึ้นเมื่อผู้ใช้เข้าชมหน้าเว็บอื่น

คำตอบสำหรับคำถามเหล่านี้ไม่ได้ตรงไปตรงมาเสมอไป และ เมื่อคุณเริ่มคิดถึงการใช้ Progressive Web App แอปก็อาจซับซ้อนมากขึ้นไปอีก เป้าหมายของผมคือการแนะนำสถาปัตยกรรม ที่เป็นไปได้ที่คิดว่ามีประสิทธิภาพ ตลอดทั้งบทความนี้ ฉันจะติดป้ายกํากับการตัดสินใจของฉัน ว่าเป็น "แนวทางของฉัน" ในการสร้าง Progressive Web App

คุณสามารถใช้แนวทางของฉันเมื่อสร้าง PWA ของคุณเอง แต่ในขณะเดียวกันก็มีทางเลือกอื่นๆ ที่ใช้ได้เสมอ หวังว่าการได้เห็นว่าองค์ประกอบทั้งหมดสัมพันธ์กันจะเป็นแรงบันดาลใจ ได้ไหม และคุณรู้สึกมั่นใจที่จะปรับแต่งสิ่งนี้ให้เหมาะกับความต้องการของคุณ

PWA ของสแต็กโอเวอร์โฟลว์

เราได้สร้าง Stack Overflow PWA ขึ้นมาเพื่อประกอบบทความนี้ ฉันใช้เวลามากในการอ่านและร่วมให้ข้อมูลใน Stack Overflow และอยากจะสร้างเว็บแอปที่จะช่วยให้เรียกดูคำถามที่พบบ่อยสำหรับหัวข้อหนึ่งๆ ได้อย่างง่ายดาย ซึ่งสร้างขึ้นจาก Stack Exchange API สาธารณะ เนื่องจากเป็นโอเพนซอร์ส คุณดูข้อมูลเพิ่มเติมได้โดยไปที่โปรเจ็กต์ GitHub

แอปแบบหลายหน้า (MPA)

ก่อนที่จะลงลึก เรามานิยามคำศัพท์และ อธิบายเทคโนโลยีพื้นฐานกันก่อน ก่อนอื่นผมจะพูดถึงสิ่งที่ผมเรียกว่า "แอปแบบหลายหน้า" หรือ "MPA"

MPA เป็นชื่อที่จำง่ายสำหรับสถาปัตยกรรมแบบดั้งเดิมที่ใช้มาตั้งแต่เริ่มมีเว็บ ทุกครั้งที่ผู้ใช้ไปที่ URL ใหม่ เบราว์เซอร์จะค่อยๆ แสดงผล HTML ของหน้าเว็บนั้นโดยเฉพาะ เราไม่ได้พยายามเก็บรักษาสถานะของหน้าหรือเนื้อหาที่อยู่ระหว่างการนำทาง ทุกครั้งที่เข้าชมหน้าเว็บใหม่ คุณจะเริ่มต้นใหม่

ซึ่งไม่เหมือนกับรูปแบบแอปหน้าเว็บเดียว (SPA) สำหรับการสร้างเว็บแอป ซึ่งเบราว์เซอร์จะเรียกใช้โค้ด JavaScript เพื่ออัปเดตหน้าเว็บที่มีอยู่เมื่อผู้ใช้ไปที่ส่วนใหม่ ทั้ง SPA และ MPA เป็นรูปแบบที่ใช้ได้อย่างเท่าเทียมกัน แต่สำหรับโพสต์นี้ ผมจะพูดถึงแนวคิดของ PWA ในบริบทของแอปที่มีหลายหน้า

รวดเร็วเชื่อถือได้

คุณได้ยินฉัน (และคำถามอื่นๆ อีกนับไม่ถ้วน) ใช้วลี "Progressive Web App" หรือ PWA คุณอาจคุ้นเคยกับวัสดุพื้นหลังบางส่วนอยู่แล้วในที่อื่นๆ ในเว็บไซต์นี้

PWA เป็นเว็บแอปที่มอบประสบการณ์ของผู้ใช้ชั้นยอด และทำให้ได้พื้นที่บนหน้าจอหลักของผู้ใช้อย่างแท้จริง ตัวย่อ "FIRE" ซึ่งมาจากคำว่า Fast, Integrated, Reliable และ Engaging คือการรวมแอตทริบิวต์ทั้งหมดที่ต้องคำนึงถึงเมื่อสร้าง PWA

ในบทความนี้ ฉันจะมุ่งเน้นแอตทริบิวต์เพียงบางส่วน ได้แก่ รวดเร็วและเชื่อถือได้

ความรวดเร็ว: แม้ว่า "เร็ว" จะมีความหมายแตกต่างกันไปในแต่ละบริบท แต่เราจะพูดถึง ข้อดีด้านความเร็วในการโหลดจากเครือข่ายให้น้อยที่สุด

เชื่อถือได้: แต่ความเร็วจริงนั้นไม่เพียงพอ เพื่อให้รู้สึกว่าเป็น PWA เว็บแอปควรมีความเสถียร และต้องมีความยืดหยุ่นมากพอที่จะโหลดอะไรบางอย่างได้เสมอ แม้จะเป็นหน้าแสดงข้อผิดพลาดที่กำหนดเองก็ตาม ไม่ว่าเครือข่ายจะมีสถานะใดก็ตาม

รวดเร็วและเชื่อถือได้: และสุดท้าย ผมจะปรับเปลี่ยนคำจำกัดความของ PWA เล็กน้อยและดูว่าการสร้างบางสิ่งที่รวดเร็วและเชื่อถือได้คืออะไร วิธีที่เร็วและเสถียรนั้นไม่เพียงพอ เมื่อคุณใช้เครือข่ายที่มีเวลาในการตอบสนองต่ำ การทำงานที่รวดเร็วและเชื่อถือได้หมายความว่าความเร็วของเว็บแอปมีความสอดคล้องสม่ำเสมอไม่ว่าเครือข่ายพื้นฐานจะเป็นอย่างไรก็ตาม

การเปิดใช้เทคโนโลยี: Service Worker + Cache Storage API

PWA มีระดับความรวดเร็วและความยืดหยุ่นสูง โชคดีที่แพลตฟอร์มเว็บ มีพื้นฐานสำคัญที่จะทำให้ประสิทธิภาพประเภทนั้นเป็นจริงได้ เราหมายถึงโปรแกรมทำงานของบริการ และ Cache Storage API

คุณสามารถสร้าง Service Worker ที่รอรับคำขอที่เข้ามาใหม่ ส่งผ่านบางอย่างไปยังเครือข่าย และจัดเก็บสำเนาของการตอบกลับไว้ใช้ในอนาคตผ่านทาง Cache Storage API

โปรแกรมทำงานของบริการที่ใช้ Cache Storage API เพื่อบันทึกสำเนาของการตอบสนองของเครือข่าย

ครั้งถัดไปที่เว็บแอปส่งคำขอเดียวกัน โปรแกรมทำงานของบริการจะตรวจสอบแคชและแสดงผลการตอบกลับที่แคชไว้ก่อนหน้านี้ได้

โปรแกรมทำงานของบริการที่ใช้ Cache Storage API ในการตอบกลับโดยการข้ามเครือข่าย

การหลีกเลี่ยงเครือข่ายทุกครั้งที่ทำได้เป็นส่วนสำคัญในการมอบประสิทธิภาพที่รวดเร็วและเชื่อถือได้

JavaScript แบบ "Isomorphic"

อีกแนวคิดหนึ่งที่อยากพูดถึงคือ JavaScript แบบ "isomorphic" หรือ "Universal" พูดง่ายๆ ก็คือเราแชร์โค้ด JavaScript เดียวกันระหว่างสภาพแวดล้อมรันไทม์ที่แตกต่างกันได้ ตอนที่สร้าง PWA ฉันอยากแชร์โค้ด JavaScript ระหว่างเซิร์ฟเวอร์แบ็กเอนด์กับโปรแกรมทำงานของบริการ

มีวิธีที่ถูกต้องมากมายในการแชร์โค้ดด้วยวิธีนี้ แต่วิธีการของฉันคือการใช้โมดูล ES เป็นซอร์สโค้ดที่สมบูรณ์ จากนั้นฉันได้เปลี่ยนรูปแบบและรวมโมดูลเหล่านั้นสำหรับ เซิร์ฟเวอร์และ Service Worker โดยใช้ Babel และ Rollup รวมกัน ในโปรเจ็กต์ของฉัน ไฟล์ที่มีนามสกุลไฟล์ .mjs คือโค้ดที่อยู่ในโมดูล ES

เซิร์ฟเวอร์

เมื่อจำแนวคิดและคำศัพท์เหล่านั้นแล้ว เรามาเจาะลึกวิธีสร้าง PWA สำหรับ Stack Overflow กันดีกว่า ผมจะเริ่มด้วยการอธิบายเกี่ยวกับ เซิร์ฟเวอร์แบ็กเอนด์ของเรา และอธิบายว่าสิ่งนี้เหมาะสมกับสถาปัตยกรรมโดยรวมอย่างไร

ผมกำลังมองหาการผสมผสานระหว่างแบ็กเอนด์แบบไดนามิกกับโฮสติ้งแบบคงที่ และแนวทางของผมคือการใช้แพลตฟอร์ม Firebase

Firebase Cloud Functions จะสร้างสภาพแวดล้อมแบบโหนดขึ้นโดยอัตโนมัติเมื่อมีคำขอขาเข้า และผสานรวมกับเฟรมเวิร์ก HTTP แบบ Express ยอดนิยมที่ฉันคุ้นเคยอยู่แล้ว และยังมีการโฮสต์แบบพร้อมใช้งานทันทีสำหรับทรัพยากรแบบคงที่ทั้งหมดของเว็บไซต์ด้วย เรามาดูวิธีที่เซิร์ฟเวอร์ จัดการคำขอกัน

เมื่อเบราว์เซอร์ส่งคำขอการนำทางกับเซิร์ฟเวอร์ของเรา เบราว์เซอร์จะดำเนินการตามขั้นตอนต่อไปนี้

ภาพรวมของการสร้างการตอบสนองการนำทางฝั่งเซิร์ฟเวอร์

เซิร์ฟเวอร์จะส่งคำขอตาม URL และใช้ตรรกะที่มีเทมเพลตเพื่อสร้างเอกสาร HTML ที่สมบูรณ์ ผมใช้ทั้งข้อมูลจาก Stack Exchange API รวมทั้งส่วนย่อย HTML บางส่วนที่เซิร์ฟเวอร์จัดเก็บไว้ในเครื่อง เมื่อโปรแกรมทำงานของเราทราบวิธีตอบสนองแล้ว ก็จะเริ่มสตรีม 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 เริ่มต้นบางส่วนกลับมาทันที และเบราว์เซอร์จะสามารถแสดงผลบางส่วนของหน้านั้นได้ทันที เมื่อเซิร์ฟเวอร์รวบรวมแหล่งข้อมูลที่เหลือเข้าด้วยกัน ระบบจะสตรีมไปยังเบราว์เซอร์จนกว่าเอกสารจะเสร็จสมบูรณ์

หากต้องการดูว่าเราหมายถึงอะไร ให้ดูที่รหัสด่วนสำหรับเส้นทางใดเส้นทางหนึ่งของเรา

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 ที่สอดคล้องกัน

ภาษาที่ใช้เทมเพลต

เทมเปิลอาจเป็นหัวข้อที่ถกเถียงกันอย่างไม่น่าเชื่อ สิ่งที่ผมใช้เป็นวิธีหนึ่งจากหลายวิธี คุณจะต้องใช้โซลูชันของตนเอง โดยเฉพาะอย่างยิ่งหากมีความเชื่อมโยงกับเฟรมเวิร์กเทมเพลตเดิมที่มีอยู่แล้ว

เหตุผลที่เหมาะกับกรณีการใช้งานของฉันคือการใช้เทมเพลตลิเทอรัลของ 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

แผนภาพนี้น่าจะดูคุ้นตา ส่วนหลายๆ ชิ้นส่วนที่ผมเคยพูดถึงก่อนหน้านี้ก็อยู่ในรูปแบบที่ต่างไปเล็กน้อย มาดูขั้นตอนการส่งคำขอ โดยพิจารณาถึงโปรแกรมทำงานของบริการด้วย

โปรแกรมทำงานของบริการจะจัดการกับคำขอการนำทางขาเข้าสำหรับ URL หนึ่งๆ และเช่นเดียวกับที่เซิร์ฟเวอร์ของฉันดำเนินการ โดยใช้ตรรกะการกำหนดเส้นทางและการกำหนดเทมเพลตร่วมกันเพื่อคิดหาวิธีตอบสนอง

วิธีการนี้เหมือนกับก่อนหน้านี้ แต่มีแบบพื้นฐานในระดับต่ำที่แตกต่างกัน เช่น fetch() และ Cache Storage API ผมใช้แหล่งข้อมูลเหล่านั้นเพื่อสร้างการตอบสนอง HTML ซึ่ง Service Worker จะส่งกลับไปยังเว็บแอป

Workbox

ฉันจะสร้าง Service Worker บนชุดไลบรารีระดับสูงที่เรียกว่า Workbox แทนที่จะเริ่มต้นจากเริ่มต้นด้วยค่าพื้นฐานระดับต่ำ โดยเป็นรากฐานที่มั่นคงสำหรับตรรกะการสร้างการแคช การกำหนดเส้นทาง และการสร้างการตอบสนองของ Service Worker

การกำหนดเส้นทาง

เช่นเดียวกับโค้ดฝั่งเซิร์ฟเวอร์ โปรแกรมทำงานของบริการจำเป็นต้องรู้วิธีจับคู่คำขอขาเข้ากับตรรกะการตอบกลับที่เหมาะสม

วิธีที่ผมใช้คือแปลเส้นทาง Express แต่ละเส้นทางเป็นนิพจน์ทั่วไปที่สอดคล้องกัน โดยใช้ไลบรารีที่มีประโยชน์ชื่อ regexparam เมื่อแปลเสร็จแล้ว เราจะใช้ประโยชน์จากการสนับสนุนในตัวของ Workbox สำหรับการกำหนดเส้นทางนิพจน์ทั่วไป

หลังจากนำเข้าโมดูลที่มีนิพจน์ทั่วไปแล้ว ฉันจะลงทะเบียนนิพจน์ทั่วไปแต่ละรายการกับเราเตอร์ของ Workbox ในแต่ละเส้นทาง ผมจะมีตรรกะ เทมเพลตที่กำหนดเองเพื่อสร้างคำตอบ เทมเปสต์ใน Service Worker จะเกี่ยวข้องกว่า ในเซิร์ฟเวอร์แบ็กเอนด์ของผม แต่ Workbox ช่วยลดงานลงได้มาก

import regExpRoutes from './regexp-routes.mjs';

workbox.routing.registerRoute(
  regExpRoutes.get('index')
  // Templating logic.
);

การแคชเนื้อหาแบบคงที่

ส่วนสำคัญอย่างหนึ่งของสตอรี่เทมเพลตคือการตรวจสอบว่าเทมเพลต HTML บางส่วนของฉันพร้อมใช้งานในเครื่องผ่าน API พื้นที่เก็บข้อมูล Cache และมีระบบอัปเดตให้เป็นปัจจุบันอยู่เสมอเมื่อฉันนำการเปลี่ยนแปลงไปใช้กับเว็บแอป การบำรุงรักษาแคชอาจเกิดข้อผิดพลาดได้ง่ายที่ดำเนินการด้วยตนเอง ฉันจึงหันมาใช้กล่องงานเพื่อจัดการการแคชล่วงหน้าในกระบวนการสร้าง

ฉันบอก Workbox ว่าจะต้องแคช URL ใดล่วงหน้าโดยใช้ไฟล์การกำหนดค่า ซึ่งชี้ไปที่ไดเรกทอรีที่มีเนื้อหาในเครื่องทั้งหมดพร้อมกับชุดรูปแบบที่จะจับคู่ CLI ของ Workbox จะอ่านไฟล์นี้โดยอัตโนมัติ ซึ่งจะrunทุกครั้งที่ฉันสร้างเว็บไซต์อีกครั้ง

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 API รองรับได้อย่างกว้างขวางมากขึ้น เรื่องราวโดยรวมเป็นไปในเชิงบวก และด้วยโค้ดสำรองที่เหมาะสม คุณก็ไม่จำเป็นต้องใช้สตรีมในโปรแกรมทำงานของบริการในปัจจุบัน

เอาล่ะ... อาจมีสิ่งหนึ่งที่หยุดใจคุณ และนั่นทำให้คุณปวดหัวกับวิธีการทำงานของ 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);
        }
      });
  },
});

แต่การทำความเข้าใจผลที่ตามมาทั้งหมดของโค้ดนี้อาจไม่ใช่สำหรับทุกคน แทนการแยกวิเคราะห์ผ่านตรรกะนี้ เรามาพูดถึงแนวทางของฉัน ในการสตรีมมิงโปรแกรมทำงานของบริการกัน

ฉันใช้ Wrapper ระดับสูงใหม่ล่าสุด workbox-streams การใช้วิธีนี้ทำให้ผมส่งผ่านแหล่งที่มาต่างๆ ของสตรีมมิง ทั้งจากแคชและข้อมูลรันไทม์ที่อาจมาจากเครือข่าย Workbox จะทำหน้าที่ประสานงานแต่ละแหล่งและรวมแหล่งที่มาต่างๆ เข้าด้วยกันเป็นคำตอบสตรีมมิงฉบับเดียว

นอกจากนี้ Workbox จะตรวจหาโดยอัตโนมัติว่ารองรับ Streams API หรือไม่ และเมื่อไม่รองรับ Workbox จะสร้างการตอบสนองที่ไม่ใช่สตรีมมิงที่เทียบเท่าโดยอัตโนมัติ ซึ่งหมายความว่าคุณไม่ต้องกังวลเรื่องการเขียนวิดีโอสำรอง เนื่องจากการสตรีมจะใกล้เคียงกับการรองรับเบราว์เซอร์ 100% ยิ่งขึ้น

การแคชรันไทม์

ลองดูว่าโปรแกรมทำงานของบริการของฉันจัดการกับข้อมูลรันไทม์จาก Stack Exchange API อย่างไร ผมจะใช้การสนับสนุนในตัวของ Workbox สำหรับกลยุทธ์การแคชที่ไม่มีอัปเดตขณะตรวจสอบใหม่ พร้อมกับการหมดอายุเพื่อให้มั่นใจว่าพื้นที่เก็บข้อมูลของเว็บแอปจะไม่ขยายตัวโดยไร้ขอบเขต

ผมได้ตั้งค่า 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 บางส่วน

อีกกลยุทธ์หนึ่งจะใช้ตรรกะการแคชที่ไม่มีการอัปเดตขณะตรวจสอบใหม่ ควบคู่ไปกับการหมดอายุของแคชที่ใช้ล่าสุดเมื่อถึงขีดจำกัด 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 ที่เว็บแอปต้องการ

กลยุทธ์ "ไม่มีอัปเดตขณะตรวจสอบใหม่ " หมายความว่าหากฉันมีการตอบกลับที่แคชไว้ก่อนหน้านี้สำหรับการเรียก API นี้ ฉันจะสตรีมไปยังหน้าดังกล่าวได้ทันที ขณะเดียวกันก็อัปเดตรายการแคช"ในเบื้องหลัง" เมื่อมีการส่งคำขอครั้งถัดไป

สุดท้าย ผมสตรีมสำเนาที่แคชไว้ของส่วนท้ายและปิดแท็ก HTML สุดท้ายเพื่อตอบกลับให้เสร็จสมบูรณ์

การแชร์รหัสช่วยให้สิ่งต่างๆ ซิงค์กัน

คุณจะเห็นว่าโค้ดของ Service Worker บางส่วนดูคุ้นเคย HTML บางส่วนและตรรกะการกำหนดเทมเพลตที่ใช้โดย Service Worker เหมือนกับที่ตัวแฮนเดิลฝั่งเซิร์ฟเวอร์ใช้ การแชร์โค้ดนี้ช่วยให้ผู้ใช้ได้รับประสบการณ์การใช้งานที่สอดคล้องกัน ไม่ว่าผู้ใช้จะเข้าชมเว็บแอปเป็นครั้งแรกหรือกลับไปยังหน้าเว็บที่โปรแกรมทำงานของบริการแสดงผลก็ตาม นี่ล่ะคือจุดเด่นของ isomorphic JavaScript

การเพิ่มประสิทธิภาพแบบก้าวหน้าแบบไดนามิก

ฉันได้แนะนำทั้งเซิร์ฟเวอร์และ Service Worker สำหรับ PWA แล้ว แต่ยังมีตรรกะข้อสุดท้ายที่ต้องพูดถึง นั่นคือมี JavaScript ขนาดเล็กที่ทำงานในแต่ละหน้าของฉัน หลังจากที่สตรีมเสร็จแล้ว

โค้ดนี้จะปรับปรุงประสบการณ์ของผู้ใช้อย่างต่อเนื่อง แต่ไม่สำคัญมาก เว็บแอปจะยังทำงานอยู่ถึงแม้จะไม่ได้ทำงานอยู่

ข้อมูลเมตาของหน้า

แอปของฉันใช้ JavaScipt ฝั่งไคลเอ็นต์เพื่ออัปเดตข้อมูลเมตาของหน้าเว็บตามการตอบสนองของ 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 ทำงานไม่ตรงกัน วิธีการของฉันคือใช้ JavaScript แบบ Isomorphic เพื่อให้เรียกใช้โค้ดเดียวกันในทั้ง 2 ที่ ซึ่งไม่สามารถทำได้เสมอไป ทั้งนี้ขึ้นอยู่กับสถาปัตยกรรมของเซิร์ฟเวอร์ที่มีอยู่

ไม่ว่าคุณจะตัดสินใจเรื่องสถาปัตยกรรมแบบใด คุณควรมีกลยุทธ์ในการเรียกใช้โค้ดการกำหนดเส้นทางและการกำหนดเทมเพลตที่เทียบเท่ากันในเซิร์ฟเวอร์และ Service Worker

สถานการณ์ที่แย่ที่สุด

เลย์เอาต์ / การออกแบบไม่สอดคล้องกัน

จะเกิดอะไรขึ้นเมื่อคุณเพิกเฉยต่อข้อผิดพลาดเหล่านั้น ความล้มเหลวเกิดขึ้นได้ทุกประเภท แต่กรณีที่เลวร้ายที่สุดคือ ผู้ใช้ที่กลับมาเข้าชมหน้าเว็บที่แคชไว้ซึ่งมีการออกแบบเก่ามาก อาจเป็นหน้าที่มีข้อความส่วนหัวล้าสมัย หรือใช้ชื่อคลาส CSS ที่ไม่สามารถใช้ได้อีกต่อไป

สถานการณ์ที่เลวร้ายที่สุด: การกำหนดเส้นทางเสีย

อีกวิธีหนึ่ง ผู้ใช้อาจพบเห็น URL ที่จัดการโดยเซิร์ฟเวอร์ของคุณ แต่ไม่ใช่ Service Worker ของคุณ เว็บไซต์ที่เต็มไปด้วยเลย์เอาต์ซอมบี้และทางตันไม่ใช่ PWA ที่เชื่อถือได้

เคล็ดลับเพื่อความสำเร็จ

แต่คุณไม่ได้อยู่ในกระบวนการนี้เพียงคนเดียว เคล็ดลับต่อไปนี้จะช่วยให้คุณหลีกเลี่ยงข้อผิดพลาดเหล่านั้นได้

ใช้ไลบรารีเทมเพลตและไลบรารีการกำหนดเส้นทางที่มีการใช้งานหลายภาษา

ลองใช้ไลบรารีเทมเพลตและไลบรารีการกำหนดเส้นทางที่มีการใช้งาน JavaScript ตอนนี้เราทราบดีว่านักพัฒนาซอฟต์แวร์บางรายไม่สามารถย้ายข้อมูล ออกจากเว็บเซิร์ฟเวอร์และภาษาที่ใช้เทมเพลตได้อย่างหรูหรา

แต่เฟรมเวิร์กเทมเพลตและการกำหนดเส้นทางยอดนิยมจำนวนหนึ่งมีการนำไปใช้ในหลายภาษา หากพบโปรแกรมที่ทำงานร่วมกับ JavaScript และภาษาของเซิร์ฟเวอร์ในปัจจุบันได้ คุณก็เข้าใกล้การทำให้โปรแกรมทำงานของบริการและเซิร์ฟเวอร์ซิงค์กันได้มากขึ้นไปอีก

เลือกใช้เทมเพลตตามลำดับแทนที่จะใช้เทมเพลตแบบซ้อน

จากนั้น ฉันขอแนะนำให้ใช้ชุดเทมเพลตตามลำดับที่สามารถสตรีมทีละรายการได้ ไม่ต้องกังวลหากส่วนต่อๆ มาของหน้าใช้ตรรกะที่มีเทมเพลตที่ซับซ้อนมากขึ้น ตราบใดที่คุณสตรีมในส่วนแรกของ HTML ได้โดยเร็วที่สุด

แคชทั้งเนื้อหาแบบคงที่และแบบไดนามิกในโปรแกรมทำงานของบริการ

คุณควรแคชทรัพยากรแบบคงที่ที่สำคัญทั้งหมดของเว็บไซต์ไว้ล่วงหน้าเพื่อประสิทธิภาพที่ดีที่สุด นอกจากนี้ คุณยังควรตั้งค่าตรรกะการแคชรันไทม์เพื่อจัดการเนื้อหาแบบไดนามิก เช่น คำขอ API ด้วย การใช้ Workbox จะช่วยให้คุณต่อยอดกลยุทธ์ที่ผ่านการทดสอบเป็นอย่างดีและพร้อมสำหรับการใช้งานจริง แทนที่จะนำทั้งหมดไปใช้ตั้งแต่ต้น

บล็อกเฉพาะในเครือข่ายเมื่อจำเป็นเท่านั้น

นอกจากนี้ คุณควรบล็อกในเครือข่ายเฉพาะในกรณีที่ไม่สามารถสตรีมการตอบกลับจากแคชได้ การแสดงการตอบสนองของ API ที่แคชไว้ในทันทีมักจะทำให้ผู้ใช้ได้รับประสบการณ์ที่ดีกว่าการรอข้อมูลใหม่

แหล่งข้อมูล