Selami perairan suram pemuatan skrip

Jake Archibald
Jake Archibald

Pengantar

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

Tidak, tunggu, kembali! Saya tahu ini terdengar biasa dan sederhana, tetapi ingat, hal ini terjadi di browser tempat hal yang secara teoritis sederhana menjadi lubang keanehan yang didorong oleh warisan. Mengetahui keunikan ini memungkinkan Anda memilih cara tercepat dan paling tidak mengganggu untuk memuat skrip. Jika Anda memiliki jadwal yang padat, lewati ke referensi cepat.

Sebagai permulaan, berikut cara spesifikasi menentukan berbagai cara skrip dapat didownload dan dieksekusi:

WhatWG saat memuat skrip
WHATWG tentang pemuatan skrip

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

Skrip pertama saya mencakup

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

Ahh, kesederhanaan yang menyenangkan. Di sini browser akan mendownload kedua skrip secara paralel dan mengeksekusinya sesegera mungkin, dengan tetap mempertahankan urutannya. “2.js” tidak akan dieksekusi hingga “1.js” dieksekusi (atau gagal melakukannya), “1.js” tidak akan dieksekusi hingga skrip atau stylesheet sebelumnya dieksekusi, dll.

Sayangnya, browser memblokir rendering halaman lebih lanjut saat semua ini terjadi. Hal ini disebabkan oleh DOM API dari “zaman pertama web” yang memungkinkan string ditambahkan ke konten yang 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.

Itulah sebabnya para pakar performa merekomendasikan untuk menempatkan elemen skrip di akhir dokumen, karena elemen tersebut memblokir konten sesedikit mungkin. Sayangnya, hal ini berarti skrip Anda tidak akan dilihat oleh browser hingga mendownload semua HTML, dan pada saat itu browser akan mulai mendownload konten lain, seperti CSS, gambar, dan iframe. Browser modern cukup cerdas untuk memprioritaskan JavaScript daripada gambar, tetapi kita dapat melakukannya dengan lebih baik.

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

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

Microsoft menyadari masalah performa ini dan memperkenalkan “penundaan” ke Internet Explorer 4. Ini pada dasarnya mengatakan “Saya berjanji tidak akan memasukkan sesuatu ke dalam parser menggunakan hal-hal seperti document.write. Jika saya melanggar janji itu, Anda bebas menghukum saya dengan cara apa pun yang menurut Anda sesuai”. Atribut ini berhasil dimasukkan ke HTML4 dan muncul di browser lain.

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

Seperti bom cluster di pabrik domba, “menunda” menjadi berantakan. Antara atribut “src” dan “defer”, serta tag skrip vs skrip yang ditambahkan secara dinamis, kita memiliki 6 pola penambahan skrip. Tentu saja, browser tidak menyetujui perintah yang harus dieksekusi. Mozilla menulis artikel yang bagus tentang masalah ini pada tahun 2009.

WHATWG membuat perilaku ini eksplisit, mendeklarasikan “defer” agar tidak memengaruhi skrip yang ditambahkan secara dinamis, atau tidak memiliki “src”. Jika tidak, skrip yang ditangguhkan harus berjalan setelah dokumen diuraikan, sesuai urutan penambahannya.

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

Ada yang didapat, ada yang hilang. Sayangnya, ada bug yang tidak menyenangkan di IE4-9 yang dapat menyebabkan skrip dieksekusi dalam urutan yang tidak terduga. Berikut ini 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 paragraf di halaman, urutan log yang diharapkan adalah [1, 2, 3], meskipun di IE9 dan yang lebih lama, Anda mendapatkan [1, 3, 2]. Operasi DOM tertentu menyebabkan IE menjeda eksekusi skrip saat ini dan mengeksekusi skrip lain yang tertunda sebelum melanjutkan.

Namun, bahkan dalam implementasi yang tidak memiliki bug, seperti IE10 dan browser lainnya, eksekusi skrip akan tertunda hingga seluruh dokumen didownload dan diuraikan. Hal ini dapat memudahkan jika Anda akan menunggu DOMContentLoaded, tetapi jika ingin benar-benar agresif dengan performa, Anda dapat mulai menambahkan pemroses dan melakukan bootstrap lebih cepat…

HTML5 untuk menyelamatkan

<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 telah diuraikan untuk dijalankan. Browser akan mendownload kedua skrip secara paralel dan mengeksekusinya sesegera mungkin.

Sayangnya, karena akan dieksekusi sesegera mungkin, “2.js” dapat dieksekusi sebelum “1.js”. Hal ini tidak masalah jika keduanya independen, mungkin “1.js” adalah skrip pelacakan yang tidak ada hubungannya dengan “2.js”. Namun, jika “1.js” adalah salinan CDN jQuery yang menjadi dependensi “2.js”, halaman Anda akan dipenuhi error, seperti bom cluster di… Entahlah… Saya tidak punya analogi untuk ini.

Aku tahu apa yang kita butuhkan, library JavaScript!

Tujuan utamanya adalah memiliki kumpulan skrip yang langsung didownload tanpa memblokir rendering dan dieksekusi sesegera mungkin sesuai urutan penambahannya. Sayangnya, HTML tidak menyukai Anda dan tidak mengizinkan Anda melakukannya.

Masalah ini ditangani oleh JavaScript dalam beberapa ragam. Beberapa mengharuskan Anda membuat perubahan pada JavaScript, menggabungkannya dalam callback yang dipanggil library dalam urutan yang benar (misalnya RequireJS). Yang lain akan menggunakan XHR untuk mendownload secara paralel, lalu eval() dalam urutan yang benar, yang tidak berfungsi untuk skrip di domain lain kecuali jika memiliki header CORS dan browser mendukungnya. Beberapa bahkan menggunakan hack super-ajaib, seperti LabJS.

Hack ini melibatkan cara mengelabui browser agar mendownload resource dengan cara yang akan memicu peristiwa saat selesai, tetapi menghindari eksekusi. Di LabJS, skrip akan ditambahkan dengan jenis mime yang salah, misalnya <script type="script/cache" src="...">. Setelah semua skrip didownload, skrip tersebut akan ditambahkan lagi dengan jenis yang benar, dengan harapan browser akan langsung mengambilnya dari cache dan segera mengeksekusinya secara berurutan. Hal ini bergantung pada perilaku yang praktis tetapi tidak ditentukan dan rusak saat HTML5 mendeklarasikan bahwa browser tidak boleh mendownload skrip dengan jenis yang tidak dikenal. Perlu diperhatikan bahwa LabJS beradaptasi dengan perubahan ini dan sekarang menggunakan kombinasi metode dalam artikel ini.

Namun, loader skrip memiliki masalah performa tersendiri. Anda harus menunggu JavaScript library didownload dan diuraikan sebelum skrip apa pun yang dikelolanya dapat mulai didownload. Selain itu, bagaimana kita akan memuat loader skrip? Bagaimana kita akan memuat skrip yang memberi tahu pemuat skrip apa yang harus dimuat? Siapa yang menonton Watchmen? Kenapa aku telanjang? Semua pertanyaan ini sulit.

Pada dasarnya, jika Anda harus mengunduh file skrip tambahan bahkan sebelum berpikir untuk mengunduh skrip lain, Anda telah kalah dalam pertempuran kinerja di sana.

DOM untuk menyelamatkan

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

Mari kita terjemahkan hal itu menjadi “Budaya”:

[
  '//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, dan tidak memblokir rendering dan eksekusi segera setelah didownload, yang berarti skrip dapat keluar dalam urutan yang salah. Namun, kita dapat menandainya secara eksplisit sebagai tidak 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 kita campuran perilaku yang tidak dapat dicapai dengan HTML biasa. Dengan secara eksplisit tidak asinkron, skrip ditambahkan ke antrean eksekusi, antrean yang sama dengan yang ditambahkan dalam contoh HTML biasa pertama kita. Namun, dengan dibuat secara dinamis, skrip dijalankan di luar penguraian dokumen, sehingga rendering tidak diblokir saat sedang diunduh (jangan bingung dengan pemuatan skrip yang tidak asinkron dengan XHR sinkron, yang ini bukan hal yang baik).

Skrip di atas harus disertakan secara inline di bagian header halaman, mengantrekan download skrip sesegera mungkin tanpa mengganggu rendering progresif, dan dieksekusi sesegera mungkin sesuai urutan yang Anda tentukan. “2.js” dapat didownload secara gratis sebelum “1.js”, tetapi tidak akan dieksekusi hingga “1.js” berhasil didownload dan dieksekusi, atau gagal dijalankan. Hore! download asinkron, tetapi eksekusi teratur!

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

Itu adalah cara tercepat untuk memuat skrip, bukan? Oke.

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

Kita dapat menambahkan visibilitas kembali dengan menempatkannya di header dokumen:

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

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

Koreksi: Awalnya saya menyatakan bahwa ini diambil oleh pemindai pramuat, tetapi tidak, ini diambil oleh parser reguler. Namun, pemindai pramuat dapat mengambilnya, tetapi belum melakukannya, sedangkan skrip yang disertakan oleh kode yang dapat dieksekusi tidak dapat dipramuat. Terima kasih kepada Yoav Weiss yang mengoreksi saya di kolom komentar.

Saya merasa artikel ini menyedihkan

Situasinya sangat tertekan dan Anda seharusnya merasa tertekan. Tidak ada cara deklaratif yang tidak berulang untuk mendownload skrip dengan cepat dan asinkron sekaligus mengontrol urutan eksekusi. Dengan HTTP2/SPDY, Anda dapat mengurangi overhead permintaan hingga titik pengiriman skrip dalam beberapa file kecil yang dapat di-cache satu per satu 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 menangani komponen halaman tertentu, tetapi memerlukan fungsi utilitas di dependencies.js. Idealnya, kita ingin mendownload semuanya secara asinkron, lalu mengeksekusi skrip peningkatan sesegera mungkin, dalam urutan apa pun, tetapi setelah dependencies.js. Ini adalah progressive progressive enhancement! Sayangnya, tidak ada cara deklaratif untuk mencapai hal ini kecuali jika skrip itu sendiri diubah untuk melacak status pemuatan dependencies.js. Bahkan async=false tidak menyelesaikan masalah ini, karena eksekusi enhanced-10.js akan diblokir di 1-9. Bahkan, hanya ada satu browser yang memungkinkan hal ini tanpa hack…

IE punya ide!

IE memuat skrip secara berbeda dengan browser lain.

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

IE mulai mendownload “whatever.js” sekarang, browser lain tidak mulai mendownload hingga skrip telah ditambahkan ke dokumen. IE juga memiliki peristiwa, “readystatechange”, dan properti, “readystate”, yang memberi tahu kita progres pemuatan. Hal 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 membuat model dependensi yang kompleks dengan memilih kapan harus menambahkan skrip ke dalam dokumen. IE telah mendukung model ini sejak versi 6. Cukup menarik, tetapi masih mengalami masalah visibilitas pramuat yang sama seperti async=false.

Cukup! Bagaimana cara memuat skrip?

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

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

Itu. Di akhir elemen isi. Ya, menjadi developer web itu seperti menjadi Raja Sisyphus (boom! 100 poin hipster untuk referensi mitologi Yunani!). Keterbatasan dalam HTML dan browser mencegah kami melakukan hal yang lebih baik.

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

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

Untuk mendapatkan poin bonus, jika Anda ingin benar-benar agresif dalam meningkatkan performa, dan tidak keberatan dengan sedikit kompleksitas dan pengulangan, Anda dapat menggabungkan beberapa trik di atas.

Pertama, kita menambahkan deklarasi sub-resource, untuk pramuat:

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

Kemudian, secara inline di bagian head dokumen, kita memuat skrip dengan JavaScript, menggunakan async=false, yang kembali ke pemuatan skrip berbasis readystate IE, yang kembali ke penundaan.

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 nantinya adalah 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 byte tambahan tersebut sebanding dengan menyertakan skrip sederhana? Jika sudah menggunakan JavaScript untuk memuat skrip secara bersyarat, seperti yang dilakukan BBC, Anda juga dapat memanfaatkan pemicu download tersebut lebih awal. Jika tidak, mungkin tidak, tetap gunakan metode akhir isi yang sederhana.

Wah, sekarang saya tahu mengapa bagian pemuatan skrip WHATWG begitu luas. Saya butuh minuman.

Referensi cepat

Elemen skrip biasa

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

Spesifikasinya menyatakan: Mendownload bersama, mengeksekusi secara berurutan setelah CSS yang tertunda, memblokir rendering hingga selesai. Browser berkata: Baik, 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. Mengabaikan “defer” pada skrip tanpa “src”. IE < 10 mengatakan: Saya mungkin mengeksekusi 2.js di tengah-tengah eksekusi 1.js. Bukankah itu menyenangkan? Browser berwarna merah mengatakan: Saya tidak tahu apa maksudnya. Saya akan memuat skrip seolah-olah tidak ada di sana. Browser lain mengatakan: Oke, tetapi saya dapat mengabaikan "menunda" pada skrip tanpa "src".

Asinkron

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

Spesifikasinya menyatakan: Mendownload bersama, mengeksekusi dalam urutan apa pun yang didownload. Browser berwarna merah mengatakan: Apa itu 'asinkron'? Saya akan memuat skrip seolah-olah tidak ada. Browser lain mengatakan: Ya, ok.

Asinkron salah

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

Spesifikasinya menyatakan: Mendownload bersama-sama, mengeksekusi secara berurutan segera setelah semua didownload. Firefox < 3.6, Opera mengatakan: Saya tidak tahu apa itu “asinkron”, tetapi kebetulan saya mengeksekusi skrip yang ditambahkan melalui JS sesuai urutan penambahannya. Safari 5.0 mengatakan: Saya memahami “asinkron”, tetapi tidak memahami cara menyetelnya ke “false” dengan JS. Kami akan mengeksekusi skrip Anda segera setelah skrip tersebut diaktifkan, dalam urutan apa pun. IE < 10 menampilkan: Tidak tahu tentang “asinkron”, tetapi ada solusi menggunakan “onreadystatechange”. Browser lain yang berwarna merah menampilkan: Saya tidak mengerti hal “asinkron” ini, saya akan mengeksekusi skrip Anda segera setelah skrip tersebut dimuat, dalam urutan apa pun. Semua hal lainnya mengatakan: Saya teman Anda, kita akan melakukan ini berdasarkan buku.