שרשורי אינטרנט עם עובדי מודולים

עכשיו קל יותר להעביר משימות כבדות לשרשורים ברקע באמצעות מודולים של JavaScript ב-Web Workers.

‫JavaScript הוא חד-הברגה, כלומר הוא יכול לבצע רק פעולה אחת בכל פעם. השיטה הזו אינטואיטיבית ועובדת טוב בהרבה מקרים באינטרנט, אבל היא עלולה להיות בעייתית כשצריך לבצע משימות כבדות כמו עיבוד נתונים, ניתוח, חישוב או אנליזה. ככל שיותר ויותר אפליקציות מורכבות מופעלות באינטרנט, כך גדל הצורך בעיבוד מרובה-הליכים.

בפלטפורמת האינטרנט, הפרימיטיב העיקרי לשרשור ולמקביליות הוא Web Workers API. ‫Workers הם הפשטה קלה מעל threads של מערכת ההפעלה, שחושפת API להעברת הודעות לצורך תקשורת בין threads. האפשרות הזו יכולה להיות שימושית מאוד כשמבצעים חישובים יקרים או כשעובדים עם מערכי נתונים גדולים. היא מאפשרת לשרשור הראשי לפעול בצורה חלקה בזמן שפעולות יקרות מתבצעות בשרשור רקע אחד או יותר.

דוגמה אופיינית לשימוש ב-worker, שבה סקריפט worker מאזין להודעות מה-thread הראשי ומגיב בשליחת הודעות משלו:

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 זמין ברוב הדפדפנים כבר יותר מעשר שנים. המשמעות היא שהעובדים נהנים מתמיכה מצוינת בדפדפן ומאופטימיזציה טובה, אבל גם שהם קדמו בהרבה למודולי JavaScript. מכיוון שלא הייתה מערכת מודולים כשמכונות worker תוכננו, ה-API לטעינת קוד לתוך מכונת worker ולחיבור סקריפטים נשאר דומה לגישות הסינכרוניות לטעינת סקריפטים שהיו נפוצות בשנת 2009.

היסטוריה: עובדים קלאסיים

הבונה של Worker מקבל כתובת URL של סקריפט קלאסי, שהיא יחסית לכתובת ה-URL של המסמך. הפונקציה מחזירה מיד הפניה למופע החדש של העובד, שחושף ממשק העברת הודעות וגם שיטה terminate() שמפסיקה מיד את העובד ומשמידה אותו.

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

פונקציית importScripts() זמינה ב-web workers לטעינת קוד נוסף, אבל היא משהה את הביצוע של ה-worker כדי לאחזר ולהעריך כל סקריפט. הוא גם מריץ סקריפטים בהיקף הגלובלי כמו תג <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 Workers הייתה השפעה גדולה מדי על הארכיטקטורה של אפליקציה. מפתחים נאלצו ליצור כלים חכמים ופתרונות עקיפים כדי לאפשר שימוש ב-Web Workers בלי לוותר על שיטות פיתוח מודרניות. לדוגמה, חבילות כמו webpack מטמיעות הטמעה קטנה של טוען מודולים בקוד שנוצר, שמשתמש ב-importScripts() לטעינת קוד, אבל עוטף מודולים בפונקציות כדי למנוע התנגשויות משתנים ולדמות ייבוא וייצוא של תלות.

הזנת עובדים במודול

ב-Chrome 80 אנחנו משיקים מצב חדש לעובדי אינטרנט עם היתרונות של מודולים של JavaScript מבחינת נוחות השימוש והביצועים. המצב החדש נקרא module workers. הקונסטרוקטור Worker מקבל עכשיו אפשרות חדשה {type:"module"}, שמשנה את הטעינה וההפעלה של הסקריפט כך שיתאימו ל-<script type="module">.

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

מכיוון שעובדי מודולים הם מודולי JavaScript רגילים, הם יכולים להשתמש בהצהרות import ו-export. כמו בכל מודול JavaScript, יחסי תלות מופעלים רק פעם אחת בהקשר נתון (השרשור הראשי, מכונת worker וכו'), וכל הייבוא העתידי מתייחס למופע המודול שכבר הופעל. הדפדפנים גם מבצעים אופטימיזציה של הטעינה וההרצה של מודולי JavaScript. אפשר לטעון את התלויות של מודול לפני שמריצים אותו, וכך אפשר לטעון עצי מודולים שלמים במקביל. בנוסף, טעינת מודולים שומרת במטמון קוד שעבר ניתוח, כך שמודולים שמשמשים בשרשור הראשי וב-worker צריכים לעבור ניתוח רק פעם אחת.

מעבר למודולי JavaScript מאפשר גם שימוש בייבוא דינמי לטעינה עצלה של קוד בלי לחסום את ההפעלה של ה-worker. ייבוא דינמי הוא מפורש הרבה יותר מאשר שימוש ב-importScripts() כדי לטעון תלות, כי הייצוא של המודול המיובא מוחזר במקום להסתמך על משתנים גלובליים.

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() לא זמינה ב-module workers. המעבר לשימוש במודולי JavaScript ב-workers אומר שכל הקוד נטען במצב קפדני. שינוי משמעותי נוסף הוא שהערך של this בהיקף ברמה העליונה של מודול JavaScript הוא undefined, בעוד שב-workers קלאסיים הערך הוא ההיקף הגלובלי של ה-worker. למזלנו, תמיד היה self גלובלי שסיפק הפניה להיקף הגלובלי. הוא זמין בכל סוגי ה-worker, כולל service worker, וגם ב-DOM.

טעינה מראש של עובדים באמצעות modulepreload

שיפור משמעותי בביצועים שמתקבל באמצעות module workers הוא היכולת לטעון מראש (preload) את ה-workers ואת התלויות שלהם. ב-module workers, הסקריפטים נטענים ומופעלים כמודולים רגילים של 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>

גם ה-thread הראשי וגם מכונות ה-worker של המודולים יכולים להשתמש במודולים שנטענו מראש. השיטה הזו שימושית למודולים שיובאו בשני ההקשרים, או במקרים שבהם אי אפשר לדעת מראש אם ישתמשו במודול בשרשור הראשי או ב-worker.

בעבר, האפשרויות שהיו זמינות לטעינה מראש של סקריפטים של web worker היו מוגבלות ולא תמיד אמינות. למופעי worker קלאסיים היה סוג משאב משלהם, worker, לטעינה מראש, אבל אף דפדפן לא הטמיע את <link rel="preload" as="worker">. כתוצאה מכך, הטכניקה העיקרית שהייתה זמינה לטעינה מראש של Web Workers הייתה שימוש ב-<link rel="prefetch">, שהסתמכה לחלוטין על מטמון ה-HTTP. השימוש בשיטה הזו בשילוב עם כותרות ה-cache הנכונות מאפשר להימנע ממצב שבו יצירת מופע של worker צריכה להמתין להורדה של סקריפט ה-worker. עם זאת, בניגוד לטכניקה modulepreload, היא לא תמכה בטעינה מראש של תלות או בניתוח מראש.

מה לגבי עובדים משותפים?

ה-Shared workers עודכנו בגרסה Chrome 83 עם תמיכה במודולים של JavaScript. בדומה ל-workers ייעודיים, כשמגדירים worker משותף עם האפשרות {type:"module"}, סקריפט ה-worker נטען עכשיו כמודול ולא כסקריפט קלאסי:

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

לפני התמיכה במודולים של JavaScript, הקונסטרוקטור SharedWorker() ציפה רק לכתובת URL ולארגומנט name אופציונלי. האפשרות הזו תמשיך לפעול בשימוש ב-Shared Worker קלאסי, אבל כדי ליצור מודול Shared Worker צריך להשתמש בארגומנט options החדש. האפשרויות הזמינות זהות לאלה של עובד ייעודי, כולל האפשרות name שמחליפה את הארגומנט הקודם name.

מה לגבי קובץ שירות (service worker)?

המפרט של Service Worker כבר עודכן כדי לתמוך בקבלת מודול JavaScript כנקודת הכניסה, באמצעות אותה אפשרות {type:"module"} כמו במודול workers, אבל השינוי הזה עדיין לא הוטמע בדפדפנים. אחרי שזה יקרה, יהיה אפשר ליצור מופע של Service Worker באמצעות מודול JavaScript באמצעות הקוד הבא:

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

עכשיו, אחרי שהמפרט עודכן, הדפדפנים מתחילים להטמיע את ההתנהגות החדשה. התהליך הזה לוקח זמן כי יש כמה סיבוכים נוספים שקשורים להוספת מודולים של JavaScript ל-service worker. כשקובעים אם להפעיל עדכון, צריך להשוות בין סקריפטים מיובאים לבין הגרסאות הקודמות שלהם שנשמרו במטמון במהלך רישום של Service Worker. צריך להטמיע את זה במודולים של JavaScript כשמשתמשים בהם בשביל Service Workers. בנוסף, במקרים מסוימים, כשבודקים אם יש עדכונים, ל-Service Workers צריכה להיות אפשרות לעקוף את המטמון של סקריפטים.

מקורות מידע נוספים וקריאה נוספת