צוללים למים העמוקים של טעינת תסריט

Jake Archibald
Jake Archibald

מבוא

במאמר הזה אראה לכם איך לטעון קצת JavaScript בדפדפן ולהריץ אותו.

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

בתור התחלה, במפרט מפורטות הדרכים השונות שבהן סקריפט יכול להוריד ולהריץ:

המאמר של WHATWG בנושא טעינה של סקריפטים
ה-WHATWG בנושא טעינת סקריפטים

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

ה-include הראשון של הסקריפט

<script src="//other-domain.com/1.js"></script>
<script src="2.js"></script>

אה, פשטות נעימה. במקרה כזה, הדפדפן יוריד את שני הסקריפטים במקביל ויבצע אותם בהקדם האפשרי, תוך שמירה על הסדר שלהם. הקובץ 2.js לא יפעל עד שהקובץ 1.js יפעל (או ייכשל בכך), הקובץ 1.js לא יפעל עד שהסקריפט או גיליון הסגנונות הקודם יפעלו וכו'.

לצערנו, הדפדפן חוסם את העיבוד הנוסף של הדף בזמן שהכל קורה. הסיבה לכך היא ממשקי DOM API מ'הדור הראשון של האינטרנט' שמאפשרים להוסיף מחרוזות לתוכן שהמנתח מעבד, כמו document.write. דפדפנים חדשים יותר ימשיכו לסרוק או לנתח את המסמך ברקע ולהפעיל הורדות של תוכן חיצוני שעשוי להידרש לו (js, תמונות, css וכו'), אבל העיבוד עדיין חסום.

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

תודה IE! (לא, זה לא סרקזם)

<script src="//other-domain.com/1.js" defer></script>
<script src="2.js" defer></script>

Microsoft זיהתה את בעיות הביצועים האלה והוסיפה את האפשרות 'השהיה' ל-Internet Explorer 4. בעצם, המשמעות של הקוד הזה היא "אני מתחייב לא להחדיר דברים למנתח באמצעות דברים כמו document.write. אם אפר את ההבטחה הזו, תהיה לך אפשרות להעניש אותי בכל דרך שתרצה". המאפיין הזה נכלל ב-HTML4 והופיע בדפדפנים אחרים.

בדוגמה שלמעלה, הדפדפן יוריד את שני הסקריפטים במקביל ויבצע אותם ממש לפני שהאירוע DOMContentLoaded יופעל, תוך שמירה על הסדר שלהם.

כמו פצצת מצרר במפעל כבשים, הפונקציה 'השהיה' הפכה לבלגן צמרירי. יש 6 דפוסים להוספת סקריפט, כולל מאפייני src ו-defer, ותגי סקריפט לעומת סקריפטים שנוספו באופן דינמי. כמובן, הדפדפנים לא הסכימו על הסדר שבו הם צריכים לבצע את הפעולות. Mozilla כתבה מאמר מעולה על הבעיה כפי שהיא הייתה בשנת 2009.

ב-WHATWG הגדירו את ההתנהגות באופן מפורש, והצהירו ש-'השהיה' לא משפיעה על סקריפטים שנוספו באופן דינמי או שלא הכילו את הפרמטר 'src'. אחרת, סקריפטים שהושהו צריכים לפעול אחרי ניתוח המסמך, בסדר שבו הם נוספו.

תודה IE! (טוב, עכשיו זה סרקזם)

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

1.js

console.log('1');
document.getElementsByTagName('p')[0].innerHTML = 'Changing some content';
console.log('2');

2.js

console.log('3');

בהנחה שיש פסקאות בדף, הסדר הצפוי של היומנים הוא [1, 2, 3], אבל ב-IE9 ובגרסאות ישנות יותר מקבלים את הסדר [1, 3, 2]. פעולות DOM מסוימות גורמות ל-IE להשהות את ההרצה הנוכחית של הסקריפט ולבצע סקריפטים אחרים בהמתנה לפני שהוא ממשיך.

עם זאת, גם בהטמעות ללא באגים, כמו IE10 ודפדפנים אחרים, ביצוע הסקריפט מתעכב עד שהמסמך כולו יוריד וינותח. זה יכול להיות נוח אם בכל מקרה אתם מתכוונים להמתין ל-DOMContentLoaded, אבל אם אתם רוצים לשפר את הביצועים בצורה אגרסיבית, תוכלו להתחיל להוסיף מאזינים ולבצע את ה-bootstrapping מוקדם יותר…

HTML5 מציל את המצב

<script src="//other-domain.com/1.js" async></script>
<script src="2.js" async></script>

ב-HTML5 יש מאפיין חדש, 'async', שמניח שאתם לא מתכוונים להשתמש ב-document.write, אבל לא מחכה עד שהניתוח של המסמך יושלם כדי לבצע את הפקודה. הדפדפן יוריד את שני הסקריפטים במקביל ויבצע אותם בהקדם האפשרי.

לצערנו, מכיוון שהם יבוצעו בהקדם האפשרי, יכול להיות ש-'2.js' יבוצע לפני '1.js'. זה בסדר אם הם עצמאיים, אולי '1.js' הוא סקריפט מעקב שאין לו שום קשר ל-'2.js'. אבל אם '1.js' הוא עותק CDN של jQuery ש-'2.js' תלוי בו, הדף שלכם יהיה מכוסה בשגיאות, כמו פצצת מצרר ב… לא יודעת… אין לי מושג.

אני יודע מה אנחנו צריכים, ספריית JavaScript!

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

הבעיה טופלה באמצעות JavaScript בכמה גרסאות. בחלק מהן נדרשת שינוי בקוד ה-JavaScript, תוך עטיפה שלו בקריאה חוזרת (callback) שהספרייה קוראת לה בסדר הנכון (למשל RequireJS). אחרים השתמשו ב-XHR כדי להוריד במקביל ואז ב-eval() בסדר הנכון, אבל זה לא עבד עם סקריפטים בדומיין אחר, אלא אם הם כללו כותרת CORS והדפדפן תמך בה. חלק מהמשתמשים אפילו השתמשו בהאקים סופר-קסומים, כמו LabJS.

הפריצות כללו הטעיה של הדפדפן להורדת המשאב באופן שיגרום להפעלת אירוע בסיום, אבל ימנע את הביצוע שלו. ב-LabJS, הסקריפט יתווסף עם סוג MIME שגוי, למשל <script type="script/cache" src="...">. אחרי שהסקריפטים יורדים, הם מתווספים שוב עם הסוג הנכון, בתקווה שהדפדפן יקבל אותם ישירות מהמטמון ויבצע אותם מיד, לפי הסדר. הפתרון הזה היה תלוי בהתנהגות נוחה אבל לא צוינה, והוא הפסיק לפעול כש-HTML5 הכריז שדפדפנים לא צריכים להוריד סקריפטים עם סוג לא מזוהה. חשוב לציין ש-LabJS התאים את עצמו לשינויים האלה, ועכשיו הוא משתמש בשילוב של השיטות שמפורטות במאמר הזה.

עם זאת, למטעני הסקריפטים יש בעיית ביצועים משלהם: צריך להמתין להורדה ולניתוח של קובץ ה-JavaScript של הספרייה לפני שאפשר להתחיל להוריד את הסקריפטים שהיא מנהלת. בנוסף, איך נטען את ה-script loader? איך נטען את הסקריפט שמורה למטען הסקריפטים מה לטעון? מי שומר על השומרים? למה אני עירום? אלה שאלות קשות.

בעיקרון, אם אתם צריכים להוריד קובץ סקריפט נוסף לפני שאתם אפילו חושבים על הורדת סקריפטים אחרים, אתם כבר הפסדתם את הקרב על הביצועים.

DOM מציל את המצב

התשובה נמצאת למעשה במפרט HTML5, אבל היא מוסתרת בתחתית הקטע של טעינת הסקריפט.

ננסה לתרגם את זה ל'אנושי':

[
  '//other-domain.com/1.js',
  '2.js'
].forEach(function(src) {
  var script = document.createElement('script');
  script.src = src;
  document.head.appendChild(script);
});

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

[
  '//other-domain.com/1.js',
  '2.js'
].forEach(function(src) {
  var script = document.createElement('script');
  script.src = src;
  script.async = false;
  document.head.appendChild(script);
});

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

צריך לכלול את הסקריפט שלמעלה בקוד של ה-head של הדפים, להוסיף את ההורדות של הסקריפטים לתור בהקדם האפשרי בלי לשבש את העיבוד המתקדם, ולהפעיל אותם בהקדם האפשרי בסדר שציינתם. אפשר להוריד את 2.js לפני 1.js, אבל היא לא תופעל עד שההורדה וההפעלה של 1.js יסתיימו בהצלחה, או עד שהן ייכשלנה. הידד! הורדה אסינכרונית אבל ביצוע מסודר!

כל מה שתומך במאפיין async תומך בחיבור סקריפטים באופן הזה, מלבד Safari 5.0 (5.1 תקין). בנוסף, יש תמיכה בכל הגרסאות של Firefox ו-Opera, כי בגרסאות שלא תומכות במאפיין האסינכרוני, סקריפטים שנוספו באופן דינמי מבוצעים בכל מקרה בסדר שבו הם נוספו למסמך.

זו הדרך המהירה ביותר לטעון סקריפטים, נכון? נכון?

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

כדי להחזיר את האפשרות למצוא את המסמך, אפשר להוסיף את הקטע הבא לחלק העליון של המסמך:

<link rel="subresource" href="//other-domain.com/1.js">
<link rel="subresource" href="2.js">

כך הדפדפן יידע שהדף צריך את 1.js ואת 2.js. link[rel=subresource] דומה ל-link[rel=prefetch], אבל עם סמנטיקה שונה. לצערנו, התכונה נתמכת כרגע רק ב-Chrome, וצריך להצהיר על הסקריפטים שרוצים לטעון פעמיים – פעם אחת באמצעות רכיבי קישור ופעם נוספת בסקריפט.

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

המאמר הזה מדכא

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

<script src="dependencies.js"></script>
<script src="enhancement-1.js"></script>
<script src="enhancement-2.js"></script>
<script src="enhancement-3.js"></script>
…
<script src="enhancement-10.js"></script>

כל סקריפט שיפור מטפל ברכיב דף מסוים, אבל נדרש לו פונקציות שירות ב-dependencies.js. באופן אידיאלי, אנחנו רוצים להוריד את כולם באופן אסינכררוני, ואז להריץ את סקריפטי השיפורים בהקדם האפשרי, בסדר כלשהו, אבל אחרי dependencies.js. זהו שיפור מתקדם ומתמשך! לצערנו, אין דרך להצהיר על כך, אלא אם משנים את הסקריפטים עצמם כדי לעקוב אחרי סטטוס הטעינה של dependencies.js. גם האפשרות async=false לא פותרת את הבעיה הזו, כי ההפעלה של enhancement-10.js תיחסם ב-1-9. למעשה, יש רק דפדפן אחד שמאפשר לעשות זאת בלי פריצות…

יש ל-IE רעיון!

דפדפן Internet Explorer טוען סקריפטים בצורה שונה מדפדפנים אחרים.

var script = document.createElement('script');
script.src = 'whatever.js';

דפדפן Internet Explorer מתחיל להוריד את 'whatever.js' עכשיו, בדפדפנים אחרים ההורדה מתחילה רק אחרי שהסקריפט נוסף למסמך. ב-IE יש גם אירוע, 'readystatechange', ומאפיין, 'readystate', שמספרים לנו על התקדמות הטעינה. זה מאוד שימושי, כי כך אנחנו יכולים לשלוט בנפרד בחיוב על הטעינה וביצוע הסקריפטים.

var script = document.createElement('script');

script.onreadystatechange = function() {
  if (script.readyState == 'loaded') {
    // Our script has download, but hasn't executed.
    // It won't execute until we do:
    document.body.appendChild(script);
  }
};

script.src = 'whatever.js';

אנחנו יכולים ליצור מודלים מורכבים של יחסי תלות על ידי בחירה של מועד ההוספה של סקריפטים למסמך. דגם זה נתמך ב-IE החל מגרסה 6. מעניין למדי, אבל עדיין יש לו את אותה בעיה של חשיפה של מטען טעינה מראש כמו async=false.

מספיק! איך צריך לטעון סקריפטים?

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

<script src="//other-domain.com/1.js"></script>
<script src="2.js"></script>

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

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

איכס, בטח יש משהו טוב יותר שאפשר להשתמש בו עכשיו?

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

קודם כול, מוסיפים את ההצהרה על משאב המשנה, לצורך טעינה מראש:

<link rel="subresource" href="//other-domain.com/1.js">
<link rel="subresource" href="2.js">

לאחר מכן, בתוך הקוד בחלק העליון של המסמך, אנחנו טוענים את הסקריפטים שלנו באמצעות JavaScript, באמצעות async=false, ועוברים לטעינה של סקריפטים מבוססת-readystate של IE, ועוברים לטעינה מאחרית.

var scripts = [
  '1.js',
  '2.js'
];
var src;
var script;
var pendingScripts = [];
var firstScript = document.scripts[0];

// Watch scripts load in IE
function stateChange() {
  // Execute as many scripts in order as we can
  var pendingScript;
  while (pendingScripts[0] && pendingScripts[0].readyState == 'loaded') {
    pendingScript = pendingScripts.shift();
    // avoid future loading events from this script (eg, if src changes)
    pendingScript.onreadystatechange = null;
    // can't just appendChild, old IE bug if element isn't closed
    firstScript.parentNode.insertBefore(pendingScript, firstScript);
  }
}

// loop through our script urls
while (src = scripts.shift()) {
  if ('async' in firstScript) { // modern browsers
    script = document.createElement('script');
    script.async = false;
    script.src = src;
    document.head.appendChild(script);
  }
  else if (firstScript.readyState) { // IE<10
    // create a script and add it to our todo pile
    script = document.createElement('script');
    pendingScripts.push(script);
    // listen for state changes
    script.onreadystatechange = stateChange;
    // must set src AFTER adding onreadystatechange listener
    // else we'll miss the loaded event for cached scripts
    script.src = src;
  }
  else { // fall back to defer
    document.write('<script src="' + src + '" defer></'+'script>');
  }
}

אחרי כמה טריקים ומיזונו של הקוד, הוא מורכב מ-362 בייטים + כתובות ה-URL של הסקריפט:

!function(e,t,r){function n(){for(;d[0]&&"loaded"==d[0][f];)c=d.shift(),c[o]=!i.parentNode.insertBefore(c,i)}for(var s,a,c,d=[],i=e.scripts[0],o="onreadystatechange",f="readyState";s=r.shift();)a=e.createElement(t),"async"in i?(a.async=!1,e.head.appendChild(a)):i[f]?(d.push(a),a[o]=n):e.write("<"+t+' src="'+s+'" defer></'+t+">"),a.src=s}(document,"script",[
  "//other-domain.com/1.js",
  "2.js"
])

האם כדאי להוסיף את הבייטים הנוספים בהשוואה להכללת סקריפט פשוטה? אם אתם כבר משתמשים ב-JavaScript כדי לטעון סקריפטים באופן מותנה, כמו BBC, כדאי להפעיל את ההורדות האלה מוקדם יותר. אם לא, אולי כדאי להשתמש בשיטה הפשוטה של הוספת הקוד בסוף הגוף.

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

הסבר מהיר

רכיבי סקריפט פשוטים

<script src="//other-domain.com/1.js"></script>
<script src="2.js"></script>

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

דחייה

<script src="//other-domain.com/1.js" defer></script>
<script src="2.js" defer></script>

המפרט אומר: מורידים יחד, מריצים לפי הסדר ממש לפני DOMContentLoaded. התעלמות מ-'defer' בסקריפטים ללא 'src'. ב-IE גרסה 10 ומטה כתוב: יכול להיות שאריץ את 2.js באמצע ההרצה של 1.js. זה לא כיף? הדפדפנים באדום אומרים: אין לי מושג מה זה 'השהיה', אטען את הסקריפטים כאילו הוא לא קיים. בדפדפנים אחרים כתוב: בסדר, אבל יכול להיות שלא אתעלם מ-'defer' בסקריפטים ללא 'src'.

אסינכרוני

<script src="//other-domain.com/1.js" async></script>
<script src="2.js" async></script>

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

Async false

[
  '1.js',
  '2.js'
].forEach(function(src) {
  var script = document.createElement('script');
  script.src = src;
  script.async = false;
  document.head.appendChild(script);
});

המפרט אומר: מורידים את כולם יחד ומריצים לפי הסדר ברגע שההורדה מסתיימת. ב-Firefox גרסה 3.6 ומטה, ב-Opera כתוב: אין לי מושג מה זה "async", אבל במקרה אני מפעיל סקריפטים שנוספו באמצעות JS בסדר שבו הם נוספו. ב-Safari 5.0 כתוב: הבנתי את 'async', אבל לא הבנתי את ההגדרה שלו ל-'false' באמצעות JS. אפעיל את הסקריפטים שלך ברגע שהם יגיעו, בסדר כלשהו. ב-IE גרסה 10 ומטה כתוב: אין לי מושג מה זה "async", אבל יש פתרון עקיף באמצעות "onreadystatechange". בדפדפנים אחרים שמופיעים באדום כתוב: אין לי מושג מה זה "async", אפעיל את הסקריפטים שלך ברגע שהם יגיעו, בסדר כלשהו. כל שאר האפשרויות אומרות: אני חבר שלך, אנחנו נעשה את זה לפי הספר.