מודול הוא קובץ עם scope משלו
לפני שהיו ES modules, כל תגית <script> שפכה את המשתנים שלה ל-namespace הגלובלי, וסדר הטעינה קבע מי רואה מה. ES modules מתקנים את זה בכך שכל קובץ מקבל scope משלו. שום דבר לא יוצא החוצה אלא אם עושים לו export במפורש. שום דבר לא נכנס אלא אם עושים לו import במפורש.
שני קבצים, אחד מייצא ואחד מייבא:
add ו-multiply חיים ב-math.js. הם הופכים לגלויים ב-main.js רק בזכות ה-import. שום דבר אחר ב-math.js, פונקציות עזר, קבועים או כל דבר אחר, לא נגיש מבחוץ.
משני הדברים האלה נובעים שני כללים ששווה להפנים מוקדם:
- מודולים רצים ב-strict mode אוטומטית. אין צורך ב-
'use strict'. thisברמה העליונה הואundefined, לא האובייקט הגלובלי.
Named exports: מייצאים תוך כדי
זו הצורה הנפוצה ביותר. שימו export לפני כל function, class, const או let, והוא הופך לחלק מהממשק הציבורי של המודול:
השמות בסוגריים המסולסלים חייבים להתאים בדיוק לשמות שיוצאו: import { circlearea } ייכשל. אם שם מתנגש במשהו שכבר יש לכם, שנו את שמו בזמן הייבוא עם as:
אפשר גם לרשום את ה-exports בתחתית הקובץ במקום בשורה עצמה. יש מי שמעדיפים את זה בשביל חלק ברור של "API ציבורי":
שני הסגנונות נותנים את אותה תוצאה. בחרו אחד והישארו עקביים בתוך הפרויקט.
Default exports: אחד לכל מודול
למודול יכול להיות גם export default יחיד. ה-default מיועד לקבצים שבאמת יש בהם דבר מרכזי אחד: קומפוננטה, מחלקה, אובייקט הגדרות:
שלושה דברים לשים לב אליהם:
- אין סוגריים מסולסלים סביב
logבצד הייבוא. - השם המיובא הוא כל מה שתרצו.
import shout from './logger.js'יעבוד בדיוק אותו דבר. - רק default export אחד לכל קובץ. נסו להוסיף שני, והקובץ לא יעבור פרסור.
Named exports ו-default export יכולים לחיות יחד:
קודם ה-default, ואחריו ה-named בסוגריים מסולסלים. הסדר קבוע.
במה כדאי להשתמש? קל יותר לעשות refactor ל-named exports: שינוי שם בכל הקוד הוא חיפוש והחלפה אחד, כי כל import משתמש באותו שם. Default exports גמישים, אבל נותנים לכל קורא לבחור שם אחר, וזה מקשה על grep. רוב מדריכי הסגנון המודרניים נוטים ל-named exports ושומרים את ה-default למודולים שבאמת יש להם מטרה אחת.
לייבא הכול, לייצא מחדש, תופעות לוואי
עוד כמה צורות של import שתיתקלו בהן.
לאסוף את כל ה-named exports לאובייקט namespace אחד:
לייצא מחדש ממודול אחר בלי לייבא לתוך ה-scope הנוכחי:
זו התבנית שבה נקודות הכניסה של ספריות אוספות חלקים מקבצים פנימיים לממשק ציבורי אחד.
ולבסוף, import בלי שום binding, למודולים שכל תפקידם הוא תופעות לוואי (polyfills, CSS-in-JS, רישום handlers):
הקובץ רץ פעם אחת, ושום דבר לא מיובא לפי שם.
Imports הם סטטיים וחיים
שתי תכונות של import שמדי פעם מפתיעות אנשים.
סטטיים. הצהרות import נפתרות לפני שקוד כלשהו שלכם רץ. אי אפשר לשים אחת בתוך if, בתוך פונקציה או בתוך try. הנתיב חייב להיות מחרוזת מילולית, לא משתנה. זה מה שמאפשר לכלים לנתח imports בלי להריץ את הקוד: bundlers, בודקי טיפוסים ו-tree-shakers כולם מסתמכים על זה.
// Not allowed - SyntaxError.
if (userWantsFancy) {
import { fancy } from './fancy.js';
}
אם צריך טעינה מותנית, השתמשו ב-import() (מיד בהמשך).
חיים. binding מיובא הוא הפניה לקריאה בלבד בחזרה ל-export, לא תמונת מצב. אם המודול המייצא מקצה ערך חדש, המייבאים רואים את הערך החדש:
גם אי אפשר להקצות מחדש import בצד הצרכן: count = 5 ב-main.js יזרוק שגיאה. Imports הם תצוגות לקריאה בלבד.
import() דינמי לטעינה לפי דרישה
כשצריך להחליט בזמן ריצה אם לטעון מודול, למשל פיצ'רים כבדים, code splitting לפי נתיבים או polyfills מותנים, השתמשו ב-import() כפונקציה. הוא מחזיר promise שמתממש עם ה-exports של המודול:
מכיוון שזו קריאה רגילה לפונקציה, אפשר:
- לעשות לה await בתוך פונקציה
async. - להעביר משתנה בתור הנתיב.
- להשתמש בה בתוך
ifאוtry/catch.
Destructuring של האובייקט שמתקבל עובד בדיוק כמו ב-import סטטי:
default הוא המפתח של ה-default export כשעושים destructuring. שנו את שמו לכל מה שתרצו.
השימושים המעשיים הם code-splitting (לשלוח את ספריית הגרפים רק כשהמשתמש לוחץ "show chart"), polyfills לפי זיהוי יכולות, ו-plugins שמתגלים בזמן ריצה.
הרצת ES Modules: דפדפנים ו-Node
התחביר זהה בכל מקום. מה שמשתנה הוא האופן שבו סביבת הריצה מוצאת וטוענת את הקובץ.
בדפדפן, סמנו את סקריפט הכניסה כמודול:
<script type="module" src="./main.js"></script>
עם type="module", הדפדפן מכבד import/export, מריץ את הקוד ב-strict mode, ודוחה את ההרצה עד שה-HTML מפורסר. נתיבים חייבים להיות יחסיים (./, ../) או כתובות URL מלאות: מזהים חשופים כמו import 'lodash' לא עובדים בלי import map או bundler.
ב-Node, יש שתי דרכים להפעיל את זה:
- לתת לקובץ סיומת
.mjs, או - להגדיר
"type": "module"ב-package.jsonהקרוב ביותר, מה שהופך כל קובץ.jsלמודול.
Node גם דורש נתיבים מלאים עם סיומות: import './utils.js', לא import './utils'.
// package.json
{
"type": "module",
"main": "./index.js"
}
שתי הסביבות דורשות סיומות מפורשות ב-ESM נייטיב. Bundlers (Vite, webpack, esbuild) יפתרו בשבילכם נתיבים בלי סיומת בזמן הפיתוח. זה נוח, אבל הסתמכות על זה אומרת שקוד המקור שלכם לא ירוץ בלי שלב ה-build.
מלכודות נפוצות
כמה דברים שמכשילים אנשים:
- שוכחים
type="module"בדפדפן. בלעדיו,<script>רץ כסקריפט קלאסי ו-importהוא שגיאת תחביר. - סיומות קבצים חסרות ב-Node.
import './utils'נכשל,import './utils.js'עובד. Bundlers מסתירים את זה, סביבות ריצה נייטיב לא. - מצפים ל-
__dirnameאוrequireב-ES module. אלה קיימים רק ב-CommonJS. ב-ESM, השתמשו ב-import.meta.urlוהמירו אותו כשאתם צריכים נתיב. - Imports מעגליים שנוגעים בערכים לפני שהם מוכנים. מותר ששני מודולים ייבאו זה את זה, אבל קריאת export שעוד לא הוקצה לו ערך תחזיר
undefined. בנו את הקוד כך שהמעגל לא ייפגע בזמן האתחול, או פרקו אותו. - מנסים לעשות
importמותנה. הצהרתimportהסטטית לא מאפשרת את זה. השתמשו ב-import()דינמי לכל דבר שתלוי בזמן ריצה.
הבא בתור: CommonJS מול ESM
ES modules הם הסטנדרט, אבל הרבה קוד Node בשטח עדיין משתמש ב-CommonJS: require, module.exports, וסט אחר של כללים לגבי מתי קוד רץ. להכיר את שניהם, ואיך הם עובדים יחד, זה העמוד הבא.
שאלות נפוצות
איך משתמשים ב-ES modules ב-JavaScript?
ייצאו ערכים מקובץ אחד עם export או export default, ואז משכו אותם לקובץ אחר עם import. בדפדפן, טענו את קובץ הכניסה עם <script type="module" src="main.js"></script>. ב-Node, השתמשו בסיומת .mjs או הגדירו "type": "module" ב-package.json.
מה ההבדל בין default export ל-named exports?
למודול יכולים להיות הרבה named exports (export function foo() {}) אבל רק default export אחד (export default ...). את ה-named exports צריך לייבא באותו שם בדיוק בתוך סוגריים מסולסלים: import { foo } from './x.js'. את ה-default export אפשר לייבא בכל שם: import whatever from './x.js'.
מה זה import() דינמי ב-JavaScript?
import() שנקרא כפונקציה מחזיר promise שמתממש עם ה-exports של המודול. בניגוד להצהרת import הסטטית, הוא רץ בזמן הקריאה, כך שאפשר לטעון קוד בתנאי או לפי דרישה. ככה מממשים code-splitting ו-lazy loading.
צריך סיומת קובץ בנתיבי import?
ב-ES modules נייטיב, כלומר בדפדפנים וב-loader של ESM ב-Node, כן. צריך לכתוב ./utils.js, לא ./utils. Bundlers כמו Vite ו-webpack סלחניים יותר ויפתרו בשבילכם נתיבים בלי סיומת, אבל הסתמכות על זה הופכת את הקוד שלכם ללא נייד.