Selami perairan suram pemuatan skrip

Jake Archibald
Jake Archibald

Pengantar

Dalam artikel ini, saya akan mengajari Anda cara memuat beberapa JavaScript di browser dan menjalankannya.

Tidak, tunggu, kembalilah! Saya tahu kedengarannya biasa dan sederhana, namun ingat, hal ini terjadi di browser, di mana secara teori sederhana menjadi lubang unik yang digerakkan oleh versi lama. Dengan mengetahui keunikan ini, Anda dapat memilih cara tercepat dan paling tidak mengganggu untuk memuat skrip. Jika Anda memiliki jadwal yang padat, lewati ke referensi cepat.

Sebagai permulaan, berikut ini cara spesifikasi menentukan berbagai cara mendownload dan mengeksekusi skrip:

HOWWG pada pemuatan skrip
WhatWG saat memuat skrip

Seperti semua spesifikasi HOWWG, awalnya terlihat seperti akibat dari bom gugus di sebuah pabrik scribble, tetapi begitu Anda membacanya untuk ke-5 kalinya dan menyeka darah dari mata Anda, sebenarnya ini cukup menarik:

Skrip pertama saya berisi

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

Ah, kesederhanaan yang membahagiakan. Di sini browser akan mengunduh kedua skrip secara paralel dan menjalankannya sesegera mungkin, dengan tetap mempertahankan urutannya. "2.js" tidak akan dieksekusi sampai "1.js" telah dieksekusi (atau gagal melakukannya), "1.js" tidak akan dieksekusi hingga skrip atau stylesheet sebelumnya telah dieksekusi, dll.

Sayangnya, browser memblokir rendering halaman lebih lanjut saat semua ini terjadi. Hal ini disebabkan oleh DOM API dari "masa awal web" yang memungkinkan string ditambahkan ke konten yang sedang diproses parser, seperti document.write. Browser yang lebih baru akan terus memindai atau mengurai dokumen di latar belakang dan memicu download untuk konten eksternal yang mungkin diperlukan (js, gambar, css, dll.), tetapi rendering masih diblokir.

Inilah sebabnya mengapa hal yang hebat dan baik dari dunia performa merekomendasikan untuk menempatkan elemen skrip di akhir dokumen Anda, karena akan memblokir konten sesedikit mungkin. Sayangnya itu berarti skrip tidak terlihat oleh browser hingga semua HTML Anda terunduh, dan pada saat itu skrip mulai mengunduh konten lain, seperti CSS, gambar, dan iframe. Browser modern cukup pintar untuk memprioritaskan JavaScript daripada citra, namun kita dapat melakukan yang lebih baik.

Terima kasih IE! (tidak, saya tidak bermaksud menyindir)

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

Microsoft mengenali masalah kinerja ini dan memperkenalkan “menunda” ke Internet Explorer 4. Pada dasarnya, pesan ini menyatakan “Saya berjanji untuk tidak memasukkan sesuatu ke dalam parser menggunakan hal-hal seperti document.write. Jika saya melanggar janji tersebut, Anda bebas menghukum saya sesuai dengan keinginan Anda”. Atribut ini berhasil masuk ke HTML4 dan muncul di browser lain.

Pada contoh di atas, browser akan mendownload kedua skrip secara paralel dan menjalankannya tepat sebelum DOMContentLoaded diaktifkan, dengan mempertahankan urutannya.

Seperti bom gugus di pabrik domba, “menunda” menjadi berantakan. Antara atribut “src” dan “defer”, serta tag skrip vs skrip yang ditambahkan secara dinamis, kita memiliki 6 pola untuk menambahkan skrip. Tentu saja, browser tidak menyetujui urutan yang harus dijalankannya. Mozilla menulis bagian penting tentang masalah ini karena berhenti digunakan pada 2009.

HOWWG membuat perilaku menjadi eksplisit, dengan mendeklarasikan “menunda” agar tidak berpengaruh pada skrip yang ditambahkan secara dinamis, atau tidak memiliki “src”. Jika tidak, skrip yang ditangguhkan akan berjalan setelah dokumen diuraikan, sesuai urutan penambahannya.

Terima kasih IE! (oke, sekarang aku sedang menyindir)

Yang memberi manfaat, itu yang diambil. Sayangnya, ada bug jahat di IE4-9 yang dapat menyebabkan skrip dieksekusi dalam urutan yang tidak terduga. Inilah yang terjadi:

1.js

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

2.js

console.log('3');

Dengan asumsi ada sebuah paragraf pada halaman, urutan log yang diharapkan adalah [1, 2, 3], meskipun di IE9 dan di bawah Anda mendapatkan [1, 3, 2]. Operasi DOM tertentu menyebabkan IE menjeda eksekusi skrip saat ini dan mengeksekusi skrip lainnya yang tertunda sebelum melanjutkan.

Namun, meskipun dalam implementasi non-bug, seperti IE10 dan browser lainnya, eksekusi skrip tertunda hingga seluruh dokumen telah didownload dan diuraikan. Hal ini mungkin praktis jika Anda tetap akan menunggu DOMContentLoaded, tetapi jika ingin benar-benar agresif dalam menjalankan performa, Anda dapat mulai menambahkan pemroses dan melakukan bootstrap lebih cepat...

HTML5 siap membantu

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

HTML5 memberi kami atribut baru, “asinkron”, yang mengasumsikan bahwa Anda tidak akan menggunakan document.write, tetapi tidak menunggu hingga dokumen terurai untuk dieksekusi. Browser akan mendownload kedua skrip secara paralel dan menjalankannya sesegera mungkin.

Sayangnya, karena mereka akan mengeksekusi sesegera mungkin, "2.js" mungkin dieksekusi sebelum "1.js". Ini tidak masalah jika mereka independen, mungkin "1.js" adalah skrip pelacakan yang tidak ada hubungannya dengan "2.js". Tetapi jika "1.js" Anda adalah salinan CDN dari jQuery yang bergantung pada "2.js", halaman Anda akan terlapisi dalam kesalahan cluster ...

Saya tahu apa yang kita butuhkan, pustaka JavaScript!

Tujuannya adalah memiliki serangkaian skrip yang didownload secara langsung tanpa memblokir rendering dan mengeksekusi sesegera mungkin sesuai urutan penambahannya. Sayangnya HTML benci Anda dan tidak akan membiarkan Anda melakukannya.

Masalah ini telah diatasi oleh JavaScript dalam beberapa jenis. Beberapa mengharuskan Anda melakukan perubahan pada JavaScript, menggabungkannya dalam callback yang dipanggil library dalam urutan yang benar (misalnya RequireJS). Pengguna lain akan menggunakan XHR untuk mendownload secara paralel, lalu eval() dalam urutan yang benar, yang tidak berfungsi untuk skrip di domain lain kecuali jika mereka memiliki header CORS dan browser mendukungnya. Beberapa bahkan menggunakan trik super-sulap, seperti LabJS.

Peretasan tersebut meliputi menipu browser agar mendownload resource dengan cara yang akan memicu suatu peristiwa setelah selesai, tetapi menghindari mengeksekusinya. Di LabJS, skrip akan ditambahkan dengan jenis mime yang salah, misalnya <script type="script/cache" src="...">. Setelah semua skrip diunduh, skrip akan ditambahkan lagi dengan jenis yang benar, dengan harapan browser akan mendapatkannya langsung dari cache dan segera menjalankannya, secara berurutan. Proses ini bergantung pada perilaku yang mudah tetapi tidak ditentukan, dan tidak berfungsi saat browser yang dideklarasikan HTML5 tidak boleh mendownload skrip dengan jenis yang tidak dikenali. Perlu diperhatikan bahwa LabJS beradaptasi dengan perubahan ini dan sekarang menggunakan kombinasi metode dalam artikel ini.

Akan tetapi, pemuat skrip memiliki masalah performanya sendiri, Anda harus menunggu JavaScript library didownload dan diurai sebelum skrip yang dikelolanya bisa mulai didownload. Selain itu, bagaimana cara kita memuat loader skrip? Bagaimana kita akan memuat skrip yang memberi tahu loader skrip apa yang harus dimuat? Siapa yang menonton Watchmen? Mengapa saya telanjang? Semua ini adalah pertanyaan yang sulit.

Pada dasarnya, jika Anda harus mengunduh file skrip tambahan bahkan sebelum berpikir untuk mengunduh skrip lain, Anda sudah kehilangan persaingan kinerja.

DOM dapat membantu

Jawabannya sebenarnya ada di spesifikasi HTML5, meskipun tersembunyi di bawah bagian pemuatan skrip.

Mari kita terjemahkan ke dalam istilah “Bumi”:

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

Skrip yang dibuat dan ditambahkan secara dinamis ke dokumen secara default bersifat asinkron. Skrip ini tidak memblokir rendering dan eksekusi segera setelah didownload, artinya skrip tersebut dapat muncul dalam urutan yang salah. Namun, kita dapat secara eksplisit menandainya sebagai bukan asinkron:

[
  '//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);
});

Hal ini memberi skrip kami campuran perilaku yang tidak dapat dilakukan dengan HTML biasa. Dengan menjadi tidak asinkron secara eksplisit, skrip ditambahkan ke antrean eksekusi, antrean yang sama dengan yang ditambahkan ke contoh HTML biasa pertama kita. Namun, dengan dibuat secara dinamis, skrip dieksekusi di luar penguraian dokumen, sehingga rendering tidak diblokir saat didownload (jangan keliru memuat skrip yang tidak asinkron dengan sinkronisasi XHR, yang bukan hal baik).

Skrip di atas harus disertakan sebaris di bagian head halaman, mengantrekan download skrip sesegera mungkin tanpa mengganggu rendering progresif, dan dieksekusi sesegera mungkin sesuai urutan yang Anda tentukan. “2.js” gratis untuk diunduh sebelum “1.js”, tetapi tidak akan dieksekusi hingga “1.js” berhasil diunduh dan dieksekusi, atau gagal melakukan keduanya. Hore! Download-async tetapi eksekusi memerintahkan!

Pemuatan skrip dengan cara ini didukung oleh semua yang mendukung atribut asinkron, kecuali Safari 5.0 (5.1 tidak masalah). Selain itu, semua versi Firefox dan Opera didukung sebagai versi yang tidak mendukung atribut asinkron dengan mudah mengeksekusi skrip yang ditambahkan secara dinamis sesuai urutan penambahannya ke dokumen.

Itu cara tercepat untuk memuat skrip, bukan? Oke.

Nah, jika Anda secara dinamis memutuskan skrip mana yang akan dimuat, ya, jika tidak, mungkin tidak. Dengan contoh di atas, browser harus mengurai dan mengeksekusi skrip untuk menemukan skrip mana yang akan didownload. Tindakan ini akan menyembunyikan skrip Anda dari pemindai pramuat. Browser menggunakan pemindai ini untuk menemukan resource pada halaman yang mungkin akan Anda kunjungi berikutnya, atau menemukan resource halaman saat parser diblokir oleh resource lain.

Kita dapat menambahkan kembali visibilitas dengan menempatkannya di bagian head dokumen:

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

Ini memberi tahu browser bahwa halaman membutuhkan 1.js dan 2.js. link[rel=subresource] mirip dengan link[rel=prefetch], tetapi dengan semantik yang berbeda. Sayangnya, saat ini hanya didukung di Chrome, dan Anda harus mendeklarasikan skrip mana yang akan dimuat dua kali, sekali melalui elemen link, dan sekali lagi di skrip Anda.

Koreksi: Awalnya saya menyatakan bahwa hasil ini diambil oleh pemindai pramuat, tetapi tidak sebenarnya, keduanya diambil oleh parser biasa. Namun, pemindai pramuat dapat mendeteksinya, tetapi belum dapat mendeteksinya, sedangkan skrip yang disertakan oleh kode yang dapat dieksekusi tidak akan pernah dimuat sebelumnya. Terima kasih kepada Yoav Weiss yang mengoreksi komentar saya.

Saya merasa artikel ini menekan

Situasinya sedang tertekan dan Anda seharusnya merasa tertekan. Tidak ada cara deklaratif tetapi non-repetitif untuk mendownload skrip dengan cepat dan asinkron sambil mengontrol urutan eksekusi. Dengan HTTP2/SPDY, Anda dapat mengurangi overhead permintaan hingga titik pengiriman skrip dalam beberapa file kecil yang dapat di-cache secara individual bisa menjadi cara tercepat. Mendorong imajinasi:

<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>

Setiap skrip peningkatan berhubungan dengan komponen halaman tertentu, tetapi memerlukan fungsi utilitas dalam dependensi.js. Idealnya, kita ingin mendownload semua secara asinkron, lalu mengeksekusi skrip peningkatan sesegera mungkin, dalam urutan apa pun, tetapi setelah dependensi.js. Ini adalah {i>progressive enhancement<i}! Sayangnya tidak ada cara deklaratif untuk mencapai ini kecuali skrip itu sendiri dimodifikasi untuk melacak status pemuatan dependensi.js. Bahkan async=false tidak menyelesaikan masalah ini, karena eksekusi enhancement-10.js akan diblokir pada 1-9. Sebenarnya, hanya ada satu browser yang memungkinkan hal ini tanpa peretasan...

IE memiliki ide!

IE memuat skrip secara berbeda dengan browser lain.

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

IE mulai mengunduh “whatever.js” sekarang, browser lain tidak mulai mengunduh hingga skrip telah ditambahkan ke dokumen. IE juga memiliki peristiwa, “siapstatechange”, dan properti, “siapstate”, yang menunjukkan progres pemuatan. Fungsi ini sebenarnya sangat berguna karena memungkinkan kita mengontrol pemuatan dan eksekusi skrip secara independen.

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';

Kita dapat mem-build model dependensi yang kompleks dengan memilih kapan akan menambahkan skrip ke dalam dokumen. IE telah mendukung model ini sejak versi 6. Cukup menarik, tetapi masih mengalami masalah visibilitas prapemuat yang sama dengan async=false.

Cukup! Bagaimana cara memuat skrip?

Oke oke. Jika Anda ingin memuat skrip dengan cara yang tidak memblokir rendering, tidak melibatkan pengulangan, dan memiliki dukungan browser yang sangat baik, berikut ini saran saya:

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

Begitulah. Di akhir elemen isi. Ya, menjadi pengembang web sama seperti menjadi Raja Sisyphus (boom! 100 poin hipster untuk referensi mitologi Yunani!). Keterbatasan HTML dan browser mencegah kita melakukan jauh lebih baik.

Dengan harapan modul JavaScript akan menyelamatkan kita dengan menyediakan cara non-pemblokiran deklaratif untuk memuat skrip dan memberikan kontrol atas urutan eksekusi, meskipun ini mengharuskan skrip untuk ditulis sebagai modul.

Wah, pasti ada yang lebih baik yang bisa kita gunakan sekarang?

Cukup adil, sebagai poin bonus, jika Anda ingin benar-benar agresif dalam menjalankan performa, dan tidak merasa rumit serta diulangi, Anda bisa menggabungkan beberapa trik di atas.

Pertama, kita tambahkan deklarasi subresource, untuk pramuat:

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

Kemudian, di bagian head dokumen, kita memuat skrip dengan JavaScript, menggunakan async=false, kembali ke pemuatan skrip berbasis readystate IE, dan beralih untuk menunda.

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>');
  }
}

Beberapa trik dan minifikasi kemudian, yaitu 362 byte + URL skrip Anda:

!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"
])

Apakah sepadan dengan byte tambahan dibandingkan dengan skrip sederhana? Jika Anda sudah menggunakan JavaScript untuk memuat skrip secara kondisional, seperti yang dilakukan BBC, Anda mungkin juga mendapatkan manfaat dengan memicu download tersebut lebih awal. Jika tidak, mungkin tidak, tetap gunakan metode akhir tubuh yang sederhana.

Fiuh, sekarang saya tahu mengapa bagian pemuatan skrip HOWWG begitu luas. Aku butuh minuman.

Referensi cepat

Elemen skrip biasa

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

Spesifikasi menyatakan: Download secara bersamaan, jalankan secara berurutan setelah ada CSS yang tertunda, blokir rendering hingga selesai. Browser bilang: Ya, pak!

Tunda

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

Spesifikasi menyatakan: Download bersama, jalankan secara berurutan tepat sebelum DOMContentLoaded. Abaikan “defer” pada skrip tanpa “src”. IE < 10 berkata: Saya mungkin mengeksekusi 2.js di tengah eksekusi 1.js. Menyenangkan bukan? Browser berwarna merah bertuliskan: Saya tidak tahu apa fungsi “menunda” ini, saya akan memuat skrip seolah-olah tidak ada. Browser lain mengatakan: Oke, tetapi saya mungkin tidak mengabaikan “menunda” skrip tanpa “src”.

Asinkron

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

Spesifikasi: Mendownload bersama-sama, mengeksekusi dalam urutan apa pun yang didownload. Browser berwarna merah bertuliskan: Apa itu 'asinkron'? Saya akan memuat skrip seolah-olah tidak ada di sana. Browser lain mengatakan: Ya, oke.

Async false

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

Spesifikasi: Download bersama-sama, jalankan secara berurutan segera setelah semua download. Firefox < 3.6, Opera mengatakan: Saya tidak tahu apa yang dimaksud dengan "asinkron", tetapi kebetulan saya mengeksekusi skrip yang ditambahkan melalui JS sesuai urutan penambahannya. Safari 5.0 mengatakan: Saya memahami “async”, tetapi tidak memahami menyetelnya ke “false” dengan JS. Saya akan langsung mengeksekusi skrip Anda, dalam urutan apa pun. IE < 10 mengatakan: Tidak tahu tentang “async”, tetapi ada solusi untuk menggunakan “onsiapstatechange”. Browser berwarna merah lainnya mengatakan: Saya tidak memahami hal “asinkron” ini, saya akan mengeksekusi skrip Anda segera setelah skrip tersebut mendarat, dalam urutan apa pun. Semua yang lain mengatakan: Saya teman Anda, kita akan melakukannya sebelum buku.