צירוף משאבים שאינם JavaScript

במאמר הזה אנחנו מסבירים איך לייבא סוגים שונים של נכסים מ-JavaScript ולקבץ אותם בהתאם.

אינגוואר סטאניאן
אינגוואר סטאניאן

נניח שאתם עובדים על אפליקציית אינטרנט. במקרה כזה, סביר להניח שתצטרכו לטפל לא רק במודולים של JavaScript, אלא גם בסוגים שונים של משאבים אחרים — Web Workers (שהם גם JavaScript, אבל לא חלק מתרשים המודול הרגיל), תמונות, גיליונות סגנונות, גופנים, מודולים של WebAssembly ואחרים.

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

הצגה בגרף של סוגים שונים של נכסים שיובאו ל-JS.

עם זאת, לרוב הפרויקטים הגדולים יש מערכות build שמבצעות אופטימיזציות נוספות וסידור מחדש של התוכן – לדוגמה, קיבוץ והקטנה. הם לא יכולים להפעיל את הקוד ולחזות מה תהיה תוצאת הביצוע, והם גם לא יכולים לעבור על כל ליטרל מחרוזת אפשרי ב-JavaScript ולנחש אם זו כתובת URL של משאב או לא. אז איך תוכלו לגרום להם "לראות" את הנכסים הדינמיים שנטענים על ידי רכיבי JavaScript, ולכלול אותם ב-build?

ייבוא מותאם אישית ב-Bundler

אחת מהגישות הנפוצות היא שימוש חוזר בתחביר של הייבוא הסטטי. ברכיבי Bundle מסוימים הוא עשוי לזהות באופן אוטומטי את הפורמט על ידי סיומת הקובץ, ואילו אחרים מאפשרים ליישומי פלאגין להשתמש בסכימה של כתובת URL מותאמת אישית, כמו בדוגמה הבאה:

// regular JavaScript import
import { loadImg } from './utils.js';

// special "URL imports" for assets
import imageUrl from 'asset-url:./image.png';
import wasmUrl from 'asset-url:./module.wasm';
import workerUrl from 'js-url:./worker.js';

loadImg(imageUrl);
WebAssembly.instantiateStreaming(fetch(wasmUrl));
new Worker(workerUrl);

כשפלאגין של Bundler מוצא ייבוא עם תוסף שהוא מזהה או עם סכימה מפורשת בהתאמה אישית (asset-url: ו-js-url: בדוגמה שלמעלה), הוא מוסיף את הנכס המקושר לתרשים ה-build, מעתיק אותו ליעד הסופי, מבצע אופטימיזציות רלוונטיות לסוג הנכס ומחזיר את כתובת ה-URL הסופית לשימוש בזמן ריצה.

היתרונות של הגישה הזו: שימוש חוזר בתחביר של ייבוא JavaScript מבטיח שכל כתובות ה-URL יהיו סטטיות ויחסיות לקובץ הנוכחי, וכך מערכת ה-build תוכל לאתר בקלות יחסי תלות כאלה.

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

תבנית אוניברסלית לדפדפנים וחבילות

אם אתם עובדים על רכיב לשימוש חוזר, כדאי שהוא יפעל בכל אחת מהסביבות, בין אם משתמשים בו ישירות בדפדפן ובין אם הם מובנים מראש כחלק מאפליקציה גדולה יותר. רוב המארזים המודרניים מאפשרים זאת על ידי קבלה של הדפוס הבא במודולים של JavaScript:

new URL('./relative-path', import.meta.url)

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

בעת שימוש בתבנית הזו, ניתן לשכתב את הדוגמה שלמעלה באופן הבא:

// regular JavaScript import
import { loadImg } from './utils.js';

loadImg(new URL('./image.png', import.meta.url));
WebAssembly.instantiateStreaming(
  fetch(new URL('./module.wasm', import.meta.url)),
  { /* … */ }
);
new Worker(new URL('./worker.js', import.meta.url));

איך זה עובד? בואו נקטע. הבנאי new URL(...) מקבל כתובת URL יחסית כארגומנט הראשון ופותר אותה מול כתובת URL מוחלטת שצוינה כארגומנט השני. במקרה שלנו, הארגומנט השני הוא import.meta.url, שמספק את כתובת ה-URL של מודול ה-JavaScript הנוכחי, כך שהארגומנט הראשון יכול להיות כל נתיב ביחס אליו.

הוא מתאפיין באופנים דומים לייבוא דינמי. אפשר להשתמש ב-import(...) עם ביטויים שרירותיים כמו import(someUrl), אבל ה-bundleers מספקים טיפול מיוחד לדפוס עם כתובת ה-URL הסטטית import('./some-static-url.js') כדרך לעיבוד מראש של תלות שידועה בזמן ההידור, אבל מפצלים אותה לחלק נפרד שנטען באופן דינמי.

באופן דומה, אפשר להשתמש ב-new URL(...) עם ביטויים שרירותיים כמו new URL(relativeUrl, customAbsoluteBase), אבל הדפוס new URL('...', import.meta.url) הוא אות ברור ל-Bundlers לבצע עיבוד מראש ולכלול תלות לצד ה-JavaScript הראשי.

כתובות URL יחסיות מעורפלות

יכול להיות ששאלת את השאלה הבאה: למה קובצי Bundle לא יכולים לזהות דפוסים נפוצים אחרים, למשל fetch('./module.wasm') בלי רכיבי ה-wrapper של new URL?

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

  • index.html:
    html <script src="src/main.js" type="module"></script>
  • src/
    • main.js
    • module.wasm

אם תרצו לטעון את module.wasm מ-main.js, יכול להיות שיפתו להשתמש בנתיב יחסי, כמו fetch('./module.wasm').

עם זאת, fetch לא יודע מה כתובת ה-URL של קובץ ה-JavaScript שבו הוא הופעל, אלא רק מפנה כתובות URL ביחס למסמך. כתוצאה מכך, fetch('./module.wasm') ינסה לטעון את http://example.com/module.wasm במקום ה-http://example.com/src/module.wasm המיועד וייכשל (או, גרוע יותר, יטען בשקט משאב שונה ממה שהתכוונתם).

אם ממירים את כתובת ה-URL היחסית ל-new URL('...', import.meta.url), אפשר להימנע מהבעיה הזו ולהבטיח שכל כתובת URL שסיפקתם תיפתר ביחס לכתובת ה-URL של מודול ה-JavaScript הנוכחי (import.meta.url) לפני שהיא תועבר לטוענים אחרים.

מחליפים את fetch('./module.wasm') ב-fetch(new URL('./module.wasm', import.meta.url)). הוא יטען את המודול הצפוי של WebAssembly, וגם יאפשר ל-bundles למצוא את הנתיבים היחסיים האלה גם במהלך ה-build.

תמיכה בכלים

מארזרים

החבילות הבאות כבר תומכות בסכימה new URL:

WebAssembly

בעבודה עם WebAssembly בדרך כלל לא טוענים את מודול Wasm באופן ידני, אלא מייבאים את דבק ה-JavaScript שנוצר על ידי 'צרור הכלים'. רשתות הכלים הבאות יכולות לפלוט את דפוס new URL(...) המתואר עבורך מתחת למכסה.

C/C++ דרך Emscripten

כשמשתמשים ב-Emscripten, אפשר לבקש ממנו להמיר את ההדבקה של JavaScript כמודול ES6 במקום כסקריפט רגיל, באמצעות אחת מהאפשרויות הבאות:

$ emcc input.cpp -o output.mjs
## or, if you don't want to use .mjs extension
$ emcc input.cpp -o output.js -s EXPORT_ES6

כשמשתמשים באפשרות הזו, הפלט ישתמש בתבנית new URL(..., import.meta.url) מתחת למכסה, כדי שהחבילות יוכלו למצוא את קובץ Wasm המשויך באופן אוטומטי.

אפשר להשתמש באפשרות הזו גם עם שרשורי WebAssembly על ידי הוספת הדגל -pthread:

$ emcc input.cpp -o output.mjs -pthread
## or, if you don't want to use .mjs extension
$ emcc input.cpp -o output.js -s EXPORT_ES6 -pthread

במקרה כזה, ה-Web Worker שנוצר ייכלל באותו אופן ויהיה גם גלוי לרכיבי Bundle וגם לדפדפנים.

חלודה באמצעות Wam-pack / Wam-bindgen

ל-wasm-pack – שרשרת הכלים העיקרית של Rust עבור WebAssembly – יש גם כמה מצבי פלט.

כברירת מחדל, הוא יפלט מודול JavaScript שמסתמך על הצעת השילוב של WebAssembly ESM. נכון לרגע הכתיבה, ההצעה הזו עדיין ניסיונית והפלט יפעל רק בחבילה עם Webpack.

במקום זאת, אפשר לבקש מ-Wasm-pack לפלוט מודול ES6 שתואם לדפדפן באמצעות --target web:

$ wasm-pack build --target web

הפלט ישתמש בתבנית new URL(..., import.meta.url) שמתוארת, וגם קובץ Wasm יתגלה באופן אוטומטי על ידי מנהלי חבילות.

אם רוצים להשתמש בשרשורים של WebAssembly עם Rust, הסיפור קצת יותר מורכב. מידע נוסף זמין בקטע המתאים במדריך.

הגרסה הקצרה היא שלא ניתן להשתמש בממשקי API שרירותיים לשרשורים, אבל אם משתמשים ב-Rayon, אפשר לשלב אותו עם המתאם wasm-bindgen-rayon כדי שיוכל ליצור Workers באינטרנט. הדבק של JavaScript שבו נעשה שימוש על ידי Wasm-bindgen-rayon כולל גם את התבנית new URL(...) מתחת למכסה, כך שה-Workers יהיו גלויים ויכללו גם על ידי Bundler.

תכונות עתידיות

import.meta.resolve

שיחה ייעודית ל-import.meta.resolve(...) עשויה להיות שיפור פוטנציאלי בעתיד. היא תאפשר לפתור את המפרטים ביחס למודול הנוכחי בצורה פשוטה יותר, ללא פרמטרים נוספים:

new URL('...', import.meta.url)
await import.meta.resolve('...')

הוא גם ישלב טוב יותר עם מפות ייבוא ומקודדים בהתאמה אישית, כי הוא יפעל באותה מערכת רזולוציית מודולים כמו ב-import. זהו אות חזק יותר גם ל-Bundlers כי זה תחביר סטטי שלא תלוי בממשקי API של זמן ריצה, כמו URL.

הפקודה import.meta.resolve כבר מוטמעת כניסוי ב-Node.js, אבל עדיין יש כמה שאלות שלא נפתרו לגבי האופן שבו צריך לפעול באינטרנט.

ייבוא טענות נכונות (assertions)

טענות נכוֹנוּת (assertions) לייבוא הן תכונה חדשה שמאפשרת לייבא סוגים אחרים מלבד מודולים של ECMAScript. נכון לעכשיו הם מוגבלים ל-JSON:

foo.json:

{ "answer": 42 }

main.mjs:

import json from './foo.json' assert { type: 'json' };
console.log(json.answer); // 42

יכול להיות שהם ישמשו גם ב-bundlers ויחליפו את התרחישים לדוגמה שקיימים כרגע בתבנית new URL, אבל סוגים בטענות נכוֹנוּת (assertions) של ייבוא מתווספים על בסיס כל מקרה לגופו. נכון לעכשיו, הם מתייחסים רק ל-JSON, ובקרוב נוסיף מודולים של CSS. עם זאת, בסוגים אחרים של נכסים עדיין יהיה צורך בפתרון כללי יותר.

כדי לקבל מידע נוסף על התכונה הזו, אפשר לעיין בהסבר על התכונה v8.dev.

סיכום

כפי שניתן לראות, יש דרכים שונות להכללת משאבים שאינם JavaScript באינטרנט, אך יש להם חסרונות שונים והם לא פועלים בכמה רשתות כלים. הצעות עתידיות עשויות לאפשר לנו לייבא נכסים כאלה עם תחביר מיוחד, אבל אנחנו עדיין לא שם.

עד אז, התבנית של new URL(..., import.meta.url) היא הפתרון המבטיח ביותר שכבר פועל בדפדפנים, בחבילות כלים שונות ובכלי WebAssembly שונים.