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

Jake Archibald
Jake Archibald

מבוא

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

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

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

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

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

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

<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, תמונות ו-iframes. הדפדפנים המודרניים חכמים מספיק כדי לתת עדיפות ל-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, בלי לשנות את הסדר שלהם.

כמו פצצת מצרר במפעל כבשים, הפונקציה 'השהיה' הפכה למהומה. בין המאפיינים 'src' ו-'defer', ובין תגי סקריפט לבין סקריפטים שנוספו באופן דינמי, יש 6 דפוסים להוספת סקריפט. כמובן שהדפדפנים לא הסכימו עם הסדר שבו יש להפעיל. 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 נתן לנו מאפיין חדש, 'אסינכרוני', מתוך הנחה שלא תשתמשו ב-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 של הספרייה לפני שאפשר להתחיל להוריד את הסקריפטים שהיא מנהלת. בנוסף, איך נטען את טוען הסקריפטים? איך נטען את הסקריפט שמורה למטען הסקריפטים מה לטעון? מי שומר על השומרים? למה אני עירום? אלה שאלות קשות.

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

ה-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 סינכרוני, שזה אף פעם לא דבר טוב).

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