เทรดเว็บด้วยผู้ปฏิบัติงานโมดูล

ตอนนี้การย้ายภาระงานหนักไปยังเทรดเบื้องหลังทำได้ง่ายขึ้นด้วยโมดูล JavaScript ใน Web Worker

เจสัน มิลเลอร์
เจสัน มิลเลอร์

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

บนแพลตฟอร์มเว็บ พื้นฐานหลักหลักสำหรับการแยกชุดข้อความและการทำงานพร้อมกันคือ Web Workers API ผู้ปฏิบัติงานเป็น Abstraction แบบเบาๆ เพิ่มเติมจากชุดข้อความของระบบปฏิบัติการที่แสดง API ที่ส่งผ่านข้อความสำหรับการสื่อสารระหว่างชุดข้อความ การดำเนินการนี้มีประโยชน์อย่างมากเมื่อทำการคำนวณราคาแพงหรือดำเนินการกับชุดข้อมูลขนาดใหญ่ ซึ่งช่วยให้เทรดหลักทำงานได้อย่างราบรื่นในขณะที่ดำเนินการที่มีราคาแพงกับเทรดเบื้องหลังอย่างน้อย 1 รายการ

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

page.js:

const worker = new Worker('worker.js');
worker.addEventListener('message', e => {
  console.log(e.data);
});
worker.postMessage('hello');

worker.js:

addEventListener('message', e => {
  if (e.data === 'hello') {
    postMessage('world');
  }
});

Web Worker API มีอยู่ในเบราว์เซอร์ส่วนใหญ่มานานกว่า 10 ปีแล้ว ซึ่งหมายความว่าพนักงานให้การสนับสนุนเบราว์เซอร์ได้อย่างดีเยี่ยมและได้รับการเพิ่มประสิทธิภาพอย่างเหมาะสม แต่ก็หมายความว่าพนักงานเหล่านั้นใช้โมดูล JavaScript ล่วงหน้ามาอย่างยาวนาน เนื่องจากไม่มีระบบโมดูลเมื่อผู้ปฏิบัติงานออกแบบ API สำหรับการโหลดโค้ดลงในผู้ปฏิบัติงานและการเขียนสคริปต์ จึงยังคงคล้ายกับวิธีโหลดสคริปต์แบบพร้อมกันที่ใช้กันทั่วไปในปี 2009

ประวัติ: ผู้ปฏิบัติงานแบบคลาสสิก

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

const worker = new Worker('worker.js');

ฟังก์ชัน importScripts() พร้อมใช้งานภายในเว็บผู้ปฏิบัติงานสำหรับโหลดโค้ดเพิ่มเติม แต่จะหยุดการดำเนินการของผู้ปฏิบัติงานเพื่อดึงข้อมูลและประเมินสคริปต์แต่ละรายการชั่วคราว นอกจากนี้ ยังเรียกใช้สคริปต์ในขอบเขตส่วนกลาง เช่น แท็ก <script> แบบคลาสสิก ซึ่งหมายความว่าตัวแปรในสคริปต์หนึ่งสามารถเขียนทับโดยตัวแปรในสคริปต์อื่นได้

worker.js:

importScripts('greet.js');
// ^ could block for seconds
addEventListener('message', e => {
  postMessage(sayHello());
});

greet.js:

// global to the whole worker
function sayHello() {
  return 'world';
}

ด้วยเหตุนี้เอง ที่ผ่านมา โปรแกรมทำงานบนเว็บจึงสร้างผลกระทบมหาศาลต่อสถาปัตยกรรมของแอปพลิเคชันไปอย่างมาก นักพัฒนาซอฟต์แวร์ต้องสร้างเครื่องมือที่ชาญฉลาดและวิธีแก้ปัญหาเฉพาะหน้าเพื่อให้ใช้ Web Worker ได้โดยไม่ต้องล้มเลิกแนวทางปฏิบัติด้านการพัฒนาสมัยใหม่ ตัวอย่างเช่น Bundler อย่าง Webpack จะฝังการใช้งานตัวโหลดโมดูลขนาดเล็กลงในโค้ดที่สร้างขึ้นซึ่งใช้ importScripts() สำหรับการโหลดโค้ด แต่รวมโมดูลไว้ในฟังก์ชันเพื่อหลีกเลี่ยงการชนของตัวแปรและจำลองการนำเข้าและส่งออกการพึ่งพากัน

ป้อนผู้ปฏิบัติงานโมดูล

โหมดใหม่สำหรับผู้ปฏิบัติงานบนเว็บที่มาพร้อมโมดูล JavaScript ที่ใช้งานง่ายและปรับให้ใช้งานได้อย่างมีประสิทธิภาพมากขึ้นใน Chrome 80 ซึ่งเรียกว่า "โมดูลผู้ปฏิบัติงาน" ขณะนี้ตัวสร้าง Worker ยอมรับตัวเลือก {type:"module"} ใหม่ ซึ่งจะเปลี่ยนการโหลดสคริปต์และการดำเนินการให้ตรงกับ <script type="module">

const worker = new Worker('worker.js', {
  type: 'module'
});

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

การย้ายไปยังโมดูล JavaScript ยังทำให้ใช้การนำเข้าแบบไดนามิกสำหรับโค้ดการโหลดแบบ Lazy Loading ได้โดยไม่ต้องบล็อกการดำเนินการของผู้ปฏิบัติงาน การนำเข้าแบบไดนามิกมีความชัดเจนกว่าการใช้ importScripts() เพื่อโหลดทรัพยากร Dependency เนื่องจากระบบจะส่งคืนการส่งออกของโมดูลที่นำเข้าแทนที่จะอาศัยตัวแปรร่วม

worker.js:

import { sayHello } from './greet.js';
addEventListener('message', e => {
  postMessage(sayHello());
});

greet.js:

import greetings from './data.js';
export function sayHello() {
  return greetings.hello;
}

เมธอด importScripts() เดิมจะไม่มีให้ใช้งานภายในโมดูลผู้ปฏิบัติงานเพื่อประสิทธิภาพการทำงานที่ยอดเยี่ยม การเปลี่ยนให้ผู้ปฏิบัติงานใช้โมดูล JavaScript หมายความว่าโค้ดทั้งหมดจะโหลดในโหมดเข้มงวด การเปลี่ยนแปลงที่เห็นได้ชัดอีกอย่างคือค่าของ this ในขอบเขตระดับบนสุดของโมดูล JavaScript คือ undefined ในขณะที่ในผู้ปฏิบัติงานคลาสสิก ค่าจะเป็นขอบเขตรวมของผู้ปฏิบัติงาน โชคดีที่มี self อยู่ทั่วโลกซึ่งให้การอ้างอิงถึงขอบเขตระดับโลกมาโดยตลอด สามารถใช้ได้ในผู้ปฏิบัติงานทุกประเภท รวมถึงโปรแกรมทำงานของบริการและใน DOM

โหลดผู้ปฏิบัติงานล่วงหน้าด้วย modulepreload

การปรับปรุงประสิทธิภาพที่สำคัญอย่างหนึ่งซึ่งมาพร้อมกับผู้ปฏิบัติงานโมดูลคือความสามารถในการโหลดผู้ปฏิบัติงานและทรัพยากร Dependency ล่วงหน้า ผู้ปฏิบัติงานโมดูลจะโหลดและเรียกใช้สคริปต์เป็นโมดูล JavaScript มาตรฐาน ซึ่งหมายความว่าจะโหลดล่วงหน้าและแม้แต่แยกวิเคราะห์ล่วงหน้าโดยใช้ modulepreload ได้ ดังนี้

<!-- preloads worker.js and its dependencies: -->
<link rel="modulepreload" href="worker.js">

<script>
  addEventListener('load', () => {
    // our worker code is likely already parsed and ready to execute!
    const worker = new Worker('worker.js', { type: 'module' });
  });
</script>

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

ก่อนหน้านี้ ตัวเลือกในการโหลดสคริปต์ของ Web Worker ไว้ล่วงหน้าจะมีข้อจำกัดและไม่จำเป็นต้องเชื่อถือได้ ผู้ปฏิบัติงานแบบคลาสสิกมีประเภททรัพยากร "ผู้ปฏิบัติงาน" ของตนเองสำหรับการโหลดล่วงหน้า แต่ไม่มีเบราว์เซอร์ที่ใช้ <link rel="preload" as="worker"> ด้วยเหตุนี้ เทคนิคหลักที่ใช้กับการโหลด Web Work ล่วงหน้าได้คือการใช้ <link rel="prefetch"> ซึ่งใช้แคช HTTP เพียงอย่างเดียว เมื่อใช้ร่วมกับส่วนหัวการแคชที่ถูกต้อง จะช่วยลดอินสแตนซ์ผู้ปฏิบัติงานที่ต้องรอดาวน์โหลดสคริปต์ผู้ปฏิบัติงาน อย่างไรก็ตาม เทคนิคนี้ต่างจาก modulepreload ตรงที่ไม่รองรับการโหลดทรัพยากร Dependency ล่วงหน้าหรือการแยกวิเคราะห์ล่วงหน้า

แล้วผู้ปฏิบัติงานที่แชร์กันจะเป็นอย่างไร

เราได้อัปเดตผู้ปฏิบัติงานที่แชร์เพื่อให้รองรับโมดูล JavaScript ตั้งแต่ Chrome 83 เป็นต้นไป การสร้างผู้ปฏิบัติงานที่ใช้ร่วมกันด้วยตัวเลือก {type:"module"} จะโหลดสคริปต์ผู้ปฏิบัติงานเป็นโมดูลแทนที่จะเป็นสคริปต์แบบคลาสสิก เช่นเดียวกับผู้ปฏิบัติงานเฉพาะ

const worker = new SharedWorker('/worker.js', {
  type: 'module'
});

ก่อนที่จะรองรับโมดูล JavaScript ตัวสร้าง SharedWorker() ต้องการเฉพาะ URL และอาร์กิวเมนต์ name ที่ไม่บังคับเท่านั้น การดำเนินการนี้จะยังมีผลต่อการใช้งานผู้ปฏิบัติงานที่แชร์แบบคลาสสิก แต่การสร้างผู้ปฏิบัติงานที่แชร์โมดูลจะต้องใช้อาร์กิวเมนต์ options ใหม่ ตัวเลือกที่ใช้ได้จะเหมือนกับตัวเลือกสำหรับผู้ปฏิบัติงานที่ได้รับมอบหมายโดยเฉพาะ ซึ่งรวมถึงตัวเลือก name ที่แทนที่อาร์กิวเมนต์ name ก่อนหน้านี้

แล้ว Service Worker ล่ะ

ข้อมูลจำเพาะของ Service Worker ได้รับการอัปเดตแล้วให้รองรับการยอมรับโมดูล JavaScript เป็นจุดแรกเข้าโดยใช้ตัวเลือก {type:"module"} เดียวกันกับผู้ปฏิบัติงานโมดูล อย่างไรก็ตาม การเปลี่ยนแปลงนี้ยังไม่มีการใช้งานในเบราว์เซอร์ เมื่อดำเนินการดังกล่าวแล้ว คุณจะสร้างอินสแตนซ์โปรแกรมทำงานของบริการได้โดยใช้โมดูล JavaScript โดยใช้โค้ดต่อไปนี้

navigator.serviceWorker.register('/sw.js', {
  type: 'module'
});

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

แหล่งข้อมูลเพิ่มเติมและอ่านเพิ่มเติม