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

Jake Archibald
Jake Archibald

מבוא

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

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

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

ה-whatWG בטעינת הסקריפט
whatWG בטעינת הסקריפט

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

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

<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, תוך שמירה על הסדר שלהם.

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

ה-whatWG התיר את ההתנהגות מפורשת, והצהיר ש-"defer" אינו משפיע על סקריפטים שנוספו באופן דינמי או שאין בהם "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 בכל זאת, אבל אם רוצים לפעול באגרסיביות רבה על הביצועים, אפשר להתחיל להוסיף פונקציות listener ואתחול מוקדם יותר...

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" תלוי בו, הדף שלך יוצפן ב-Inocluster (2.js) עבור שגיאות Ino_צריך, כמו "InoChromebook"

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

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

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

ל-IE יש רעיון!

IE טוען סקריפטים באופן שונה בדפדפנים אחרים.

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

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

אסינכרוני

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

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