Teknik agar aplikasi web dimuat dengan cepat, bahkan di ponsel menengah

Cara kami menggunakan pembagian kode, penyisipan kode, dan rendering sisi server di PROXX.

Di Google I/O 2019, Mariko, Jake, dan saya merilis PROXX, clone Minesweeper modern untuk web. Hal yang membedakan PROXX adalah fokus pada aksesibilitas (Anda dapat memainkannya dengan pembaca layar!) dan kemampuan untuk berjalan dengan baik di ponsel fitur seperti di perangkat desktop kelas atas. Ponsel fitur dibatasi dalam beberapa cara:

  • CPU lemah
  • GPU yang lemah atau tidak ada
  • Layar kecil tanpa input sentuh
  • Jumlah memori yang sangat terbatas

Namun, perangkat ini menjalankan browser modern dan sangat terjangkau. Karena alasan ini, ponsel menengah muncul kembali di pasar negara berkembang. Titik harga produk ini memungkinkan audiens baru, yang sebelumnya tidak mampu membayarnya, untuk mengakses internet dan menggunakan web modern. Untuk tahun 2019, diperkirakan sekitar 400 juta ponsel menengah akan dijual di India saja, sehingga pengguna ponsel menengah dapat menjadi sebagian besar dari jumlah audiens Anda. Selain itu, kecepatan koneksi yang mirip dengan 2G adalah hal yang wajar di pasar negara berkembang. Bagaimana cara kami membuat PROXX berfungsi dengan baik dalam kondisi ponsel fitur?

Gameplay PROXX.

Performa penting, dan hal ini mencakup performa pemuatan dan performa runtime. Telah terbukti bahwa performa yang baik berkorelasi dengan peningkatan retensi pengguna, peningkatan konversi, dan—yang paling penting—peningkatan inklusivitas. Jeremy Wagner memiliki lebih banyak data dan insight tentang alasan performa penting.

Artikel ini adalah bagian 1 dari seri yang terdiri dari dua bagian. Bagian 1 berfokus pada performa pemuatan, dan bagian 2 akan berfokus pada performa runtime.

Merekam status quo

Menguji performa pemuatan di perangkat sebenarnya sangatlah penting. Jika Anda tidak memiliki perangkat sungguhan, sebaiknya gunakan WebPageTest, khususnya penyiapan "sederhana". WPT menjalankan serangkaian pengujian pemuatan pada perangkat sebenarnya dengan koneksi 3G yang diemulasi.

3G adalah kecepatan yang bagus untuk mengukur. Meskipun Anda mungkin terbiasa dengan 4G, LTE, atau bahkan 5G dalam waktu dekat, kenyataannya internet seluler terlihat sangat berbeda. Mungkin Anda sedang naik kereta, menghadiri konferensi, menonton konser, atau berada di pesawat. Kualitas yang akan Anda rasakan di sana kemungkinan lebih mendekati 3G, dan terkadang bahkan lebih buruk.

Meskipun demikian, kami akan berfokus pada 2G dalam artikel ini karena PROXX secara eksplisit menargetkan ponsel menengah dan pasar negara berkembang di target audiensnya. Setelah WebPageTest menjalankan pengujiannya, Anda akan mendapatkan waterfall (mirip dengan yang Anda lihat di DevTools) serta setrip film di bagian atas. Strip film menunjukkan apa yang dilihat pengguna saat aplikasi Anda dimuat. Pada jaringan 2G, pengalaman pemuatan versi PROXX yang tidak dioptimalkan sangat buruk:

Video filmstrip menunjukkan apa yang dilihat pengguna saat PROXX dimuat di perangkat low-end sungguhan melalui koneksi 2G yang diemulasi.

Saat dimuat melalui 3G, pengguna akan melihat layar kosong berwarna putih selama 4 detik. Dengan jaringan 2G, pengguna sama sekali tidak melihat apa pun selama lebih dari 8 detik. Jika Anda membaca alasan pentingnya performa, Anda tahu bahwa sekarang kami telah kehilangan sebagian besar calon pengguna karena ketidaksabaran. Pengguna harus mendownload semua JavaScript sebesar 62 KB agar apa pun dapat muncul di layar. Sisi positif dalam skenario ini adalah bahwa apa pun yang muncul di layar akan menjadi interaktif. Tapi, apa benar begitu?

[First Meaningful Paint][FMP] dalam versi PROXX yang tidak dioptimalkan _secara teknis_ [interaktif][TTI], tetapi tidak berguna bagi pengguna.

Setelah sekitar 62 KB JS gzip didownload dan DOM dibuat, pengguna dapat melihat aplikasi kita. Aplikasi ini secara teknis interaktif. Namun, melihat visualnya menunjukkan realitas yang berbeda. Font web masih dimuat di latar belakang dan hingga siap, pengguna tidak dapat melihat teks. Meskipun status ini memenuhi syarat sebagai First Meaningful Paint (FMP), status ini tentu tidak memenuhi syarat sebagai interaktif yang benar, karena pengguna tidak dapat mengetahui apa yang dimaksud dengan input. Perlu waktu satu detik lagi pada jaringan 3G dan 3 detik pada jaringan 2G hingga aplikasi siap digunakan. Secara keseluruhan, aplikasi ini memerlukan waktu 6 detik pada jaringan 3G dan 11 detik pada jaringan 2G untuk menjadi interaktif.

Analisis waterfall

Setelah mengetahui apa yang dilihat pengguna, kita perlu mencari tahu alasannya. Untuk melakukannya, kita dapat melihat waterfall dan menganalisis alasan resource dimuat terlalu lambat. Dalam pelacakan 2G PROXX, kita dapat melihat dua penanda penting utama:

  1. Ada beberapa garis tipis multiwarna.
  2. File JavaScript membentuk rantai. Misalnya, resource kedua hanya mulai dimuat setelah resource pertama selesai, dan resource ketiga hanya dimulai saat resource kedua selesai.
Waterfall memberikan insight tentang resource mana yang dimuat, kapan, dan berapa lama waktu yang diperlukan.

Mengurangi jumlah koneksi

Setiap garis tipis (dns, connect, ssl) menunjukkan pembuatan koneksi HTTP baru. Menyiapkan koneksi baru mahal karena membutuhkan sekitar 1 detik pada 3G dan sekitar 2,5 detik pada 2G. Di waterfall, kita melihat koneksi baru untuk:

  • Permintaan #1: index.html kami
  • Permintaan #5: Gaya font dari fonts.googleapis.com
  • Permintaan #8: Google Analytics
  • Permintaan #9: File font dari fonts.gstatic.com
  • Permintaan #14: Manifes aplikasi web

Koneksi baru untuk index.html tidak dapat dihindari. Browser harus membuat koneksi ke server kami untuk mendapatkan konten. Koneksi baru untuk Google Analytics dapat dihindari dengan menyisipkan sesuatu seperti Analytics Minimal, tetapi Google Analytics tidak memblokir aplikasi kita agar tidak merender atau menjadi interaktif, jadi kita tidak terlalu peduli dengan seberapa cepat aplikasi dimuat. Idealnya, Google Analytics harus dimuat pada waktu tidak ada aktivitas, saat semua hal lainnya telah dimuat. Dengan demikian, aplikasi tidak akan menggunakan bandwidth atau daya pemrosesan selama pemuatan awal. Koneksi baru untuk manifes aplikasi web ditetapkan oleh spesifikasi pengambilan, karena manifes harus dimuat melalui koneksi tanpa kredensial. Sekali lagi, manifes aplikasi web tidak memblokir aplikasi kita agar tidak merender atau menjadi interaktif, jadi kita tidak perlu terlalu memperhatikannya.

Namun, kedua font dan gayanya menjadi masalah karena memblokir rendering dan juga interaktivitas. Jika kita melihat CSS yang dikirimkan oleh fonts.googleapis.com, CSS tersebut hanyalah dua aturan @font-face, satu untuk setiap font. Gaya font sebenarnya sangat kecil, sehingga kami memutuskan untuk menyisipkannya ke dalam HTML, sehingga menghapus satu koneksi yang tidak diperlukan. Untuk menghindari biaya penyiapan koneksi untuk file font, kita dapat menyalinnya ke server kita sendiri.

Melakukan paralelisasi beban

Melihat waterfall, kita dapat melihat bahwa setelah file JavaScript pertama selesai dimuat, file baru akan langsung mulai dimuat. Hal ini umum untuk dependensi modul. Modul utama kita mungkin memiliki impor statis, sehingga JavaScript tidak dapat berjalan hingga impor tersebut dimuat. Hal penting yang perlu dipahami di sini adalah jenis dependensi ini diketahui pada waktu build. Kita dapat menggunakan tag <link rel="preload"> untuk memastikan semua dependensi mulai memuat saat kita menerima HTML.

Hasil

Mari kita lihat apa yang telah dicapai oleh perubahan kita. Penting untuk tidak mengubah variabel lain dalam penyiapan pengujian yang dapat mendistorsi hasil, jadi kita akan menggunakan penyiapan sederhana WebPageTest untuk sisa artikel ini dan melihat filmstrip:

Kita menggunakan filmstrip WebPageTest untuk melihat hasil perubahan yang telah kita lakukan.

Perubahan ini mengurangi TTI dari 11 menjadi 8,5, yang kira-kira adalah waktu penyiapan koneksi 2,5 detik yang ingin kami hapus. Bagus.

Pra-rendering

Meskipun baru saja mengurangi TTI, kami belum benar-benar memengaruhi layar putih yang sangat lama yang harus dilalui pengguna selama 8,5 detik. Bisa dibilang, peningkatan terbesar untuk FMP dapat dicapai dengan mengirim markup bergaya di index.html Anda. Teknik umum untuk mencapai hal ini adalah pra-rendering dan rendering sisi server, yang sangat terkait dan dijelaskan dalam Rendering di Web. Kedua teknik tersebut akan menjalankan aplikasi web di Node dan melakukan serialisasi DOM yang dihasilkan ke HTML. Rendering sisi server melakukan hal ini per permintaan di sisi server, sedangkan pra-rendering melakukannya pada waktu build dan menyimpan output sebagai index.html baru Anda. Karena PROXX adalah aplikasi JAMStack dan tidak memiliki sisi server, kami memutuskan untuk mengimplementasikan pra-rendering.

Ada banyak cara untuk mengimplementasikan pra-perender. Di PROXX, kami memilih untuk menggunakan Puppeteer, yang memulai Chrome tanpa UI dan memungkinkan Anda mengontrol instance tersebut dari jarak jauh dengan Node API. Kita menggunakannya untuk memasukkan markup dan JavaScript, lalu membaca kembali DOM sebagai string HTML. Karena kita menggunakan Modul CSS, kita mendapatkan CSS sebaris gaya yang dibutuhkan secara gratis.

  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.setContent(rawIndexHTML);
  await page.evaluate(codeToRun);
  const renderedHTML = await page.content();
  browser.close();
  await writeFile("index.html", renderedHTML);

Dengan adanya hal ini, kami dapat mengharapkan peningkatan untuk FMP. Kita masih perlu memuat dan mengeksekusi jumlah JavaScript yang sama seperti sebelumnya, sehingga kita tidak boleh berharap TTI akan banyak berubah. Jika ada, index.html kita menjadi lebih besar dan mungkin sedikit menunda TTI. Hanya ada satu cara untuk mengetahuinya: menjalankan WebPageTest.

Strip film menunjukkan peningkatan yang jelas untuk metrik FMP kami. TTI sebagian besar tidak terpengaruh.

First Meaningful Paint kami telah berpindah dari 8,5 detik menjadi 4,9 detik, yang merupakan peningkatan yang sangat besar. TTI kita masih terjadi sekitar 8,5 detik sehingga sebagian besar tidak terpengaruh oleh perubahan ini. Apa yang kami lakukan di sini adalah perubahan persepsi. Beberapa orang bahkan mungkin menyebutnya sulap tangan. Dengan merender visual perantara game, kami mengubah performa pemuatan yang dirasakan menjadi lebih baik.

Penggabungan

Metrik lain yang diberikan DevTools dan WebPageTest adalah Time To First Byte (TTFB). Ini adalah waktu yang diperlukan dari byte pertama permintaan yang dikirim hingga byte pertama respons yang diterima. Waktu ini juga sering disebut Waktu Round Trip (RTT), meskipun secara teknis ada perbedaan antara kedua angka ini: RTT tidak menyertakan waktu pemrosesan permintaan di sisi server. DevTools dan WebPageTest memvisualisasikan TTFB dengan warna terang dalam blok permintaan/respons.

Bagian terang dari permintaan menunjukkan bahwa permintaan sedang menunggu untuk menerima byte pertama respons.

Melihat waterfall, kita dapat melihat bahwa semua permintaan menghabiskan sebagian besar waktunya untuk menunggu byte pertama respons tiba.

Masalah ini adalah tujuan awal dari Push HTTP/2. Developer aplikasi mengetahui bahwa resource tertentu diperlukan dan dapat mendorong resource tersebut. Pada saat klien menyadari bahwa klien perlu mengambil resource tambahan, resource tersebut sudah ada di cache browser. Push HTTP/2 ternyata terlalu sulit untuk dilakukan dengan benar dan tidak direkomendasikan. Ruang masalah ini akan ditinjau kembali selama standardisasi HTTP/3. Untuk saat ini, solusi termudah adalah menyejajarkan semua resource penting dengan mengorbankan efisiensi caching.

CSS penting kita sudah disematkan berkat Modul CSS dan pre-renderer berbasis Puppeteer. Untuk JavaScript, kami perlu menyejajarkan modul penting beserta dependensinya. Tugas ini memiliki tingkat kesulitan yang bervariasi, berdasarkan bundler yang Anda gunakan.

Dengan penyertaan JavaScript, kami telah mengurangi TTI dari 8,5 detik menjadi 7,2 detik.

Ini mengurangi 1 detik TTI kami. Sekarang kita telah mencapai titik saat index.html berisi semua yang diperlukan untuk rendering awal dan menjadi interaktif. HTML dapat dirender saat masih didownload, sehingga membuat FMP. Saat HTML selesai mengurai dan mengeksekusi, aplikasi akan menjadi interaktif.

Pemisahan kode yang agresif

Ya, index.html berisi semua yang diperlukan untuk menjadi interaktif. Tapi setelah diperiksa, ternyata isinya juga berisi hal-hal lain. index.html kami sekitar 43 KB. Mari kita hubungkan dengan hal yang dapat berinteraksi dengan pengguna di awal: Kita memiliki formulir untuk mengonfigurasi game yang berisi beberapa komponen, tombol mulai, dan mungkin beberapa kode untuk mempertahankan dan memuat setelan pengguna. Cukup begitu saja. 43 KB sepertinya banyak.

Halaman landing PROXX. Hanya komponen penting yang digunakan di sini.

Untuk memahami asal ukuran paket, kita dapat menggunakan source map explorer atau alat serupa untuk menguraikan komponen paket. Seperti yang diprediksi, paket kami berisi logika game, mesin rendering, layar menang, layar kalah, dan banyak utilitas. Hanya sebagian kecil dari modul ini yang diperlukan untuk halaman landing. Memindahkan semua hal yang tidak benar-benar diperlukan untuk interaktivitas ke dalam modul yang dimuat secara lambat akan menurunkan TTI secara signifikan.

Menganalisis konten `index.html` PROXX menunjukkan banyak resource yang tidak diperlukan. Resource penting ditandai.

Yang perlu kita lakukan adalah pemisahan kode. Pemisahan kode akan memecah paket monolitik Anda menjadi bagian-bagian yang lebih kecil yang dapat dimuat lambat sesuai permintaan. Bundler populer seperti Webpack, Rollup, dan Parcel mendukung pemisahan kode dengan menggunakan import() dinamis. Pemaket akan menganalisis kode Anda dan inline semua modul yang diimpor secara statis. Semua yang Anda impor secara dinamis akan dimasukkan ke dalam filenya sendiri dan hanya akan diambil dari jaringan setelah panggilan import() dieksekusi. Tentu saja menghabiskan biaya dan hanya bisa dilakukan jika Anda memiliki waktu luang. Mantranya di sini adalah mengimpor modul secara statis yang sangat diperlukan pada waktu pemuatan dan memuat semua yang lain secara dinamis. Namun, Anda tidak boleh menunggu hingga saat-saat terakhir untuk memuat lambat modul yang pasti akan digunakan. Idle Get Urgent dari Phil Walton adalah pola yang bagus untuk mendapatkan jalan tengah yang sehat antara pemuatan lambat dan pemuatan cepat.

Di PROXX, kita membuat file lazy.js yang secara statis mengimpor semua hal yang tidak kita perlukan. Dalam file utama, kita dapat secara dinamis mengimpor lazy.js. Namun, beberapa komponen Preact kami berakhir di lazy.js, yang ternyata sedikit rumit karena Preact tidak dapat menangani komponen yang dimuat lambat secara langsung. Karena alasan ini, kita menulis wrapper komponen deferred kecil yang memungkinkan kita merender placeholder hingga komponen yang sebenarnya dimuat.

export default function deferred(componentPromise) {
  return class Deferred extends Component {
    constructor(props) {
      super(props);
      this.state = {
        LoadedComponent: undefined
      };
      componentPromise.then(component => {
        this.setState({ LoadedComponent: component });
      });
    }

    render({ loaded, loading }, { LoadedComponent }) {
      if (LoadedComponent) {
        return loaded(LoadedComponent);
      }
      return loading();
    }
  };
}

Dengan ini, kita dapat menggunakan Promise komponen dalam fungsi render(). Misalnya, komponen <Nebula>, yang merender gambar latar belakang animasi, akan diganti dengan <div> kosong saat komponen dimuat. Setelah komponen dimuat dan siap digunakan, <div> akan diganti dengan komponen yang sebenarnya.

const NebulaDeferred = deferred(
  import("/components/nebula").then(m => m.default)
);

return (
  // ...
  <NebulaDeferred
    loading={() => <div />}
    loaded={Nebula => <Nebula />}
  />
);

Dengan semua ini, kami mengurangi index.html menjadi 20 KB saja, kurang dari setengah dari ukuran aslinya. Apa dampaknya terhadap FMP dan TTI? WebPageTest akan memberi tahu!

Strip film mengonfirmasi: TTI kami sekarang berada pada 5,4 dtk. Peningkatan drastis dari 11s asli kami.

FMP dan TTI kami hanya berbeda 100 md, karena hanya masalah penguraian dan eksekusi JavaScript yang disisipkan. Hanya dalam waktu 5,4 detik di jaringan 2G, aplikasi sudah sepenuhnya interaktif. Semua modul lainnya yang kurang penting dimuat di latar belakang.

Lebih Mudah

Jika melihat daftar modul penting di atas, Anda akan melihat bahwa mesin rendering bukan bagian dari modul penting. Tentu saja, game tidak dapat dimulai hingga kita memiliki mesin rendering untuk merender game. Kita dapat menonaktifkan tombol "Mulai" hingga mesin rendering siap memulai game, tetapi menurut pengalaman kami, pengguna biasanya memerlukan waktu yang cukup lama untuk mengonfigurasi setelan game sehingga hal ini tidak diperlukan. Sebagian besar waktu, mesin rendering dan modul lainnya yang tersisa selesai dimuat pada saat pengguna menekan "Mulai". Dalam kasus yang jarang terjadi, jika pengguna lebih cepat daripada koneksi jaringannya, kami akan menampilkan layar pemuatan sederhana yang menunggu modul yang tersisa selesai.

Kesimpulan

Pengukuran itu penting. Untuk menghindari pemborosan waktu pada masalah yang tidak nyata, sebaiknya selalu lakukan pengukuran terlebih dahulu sebelum menerapkan pengoptimalan. Selain itu, pengukuran harus dilakukan pada perangkat nyata dengan koneksi 3G atau di WebPageTest jika tidak ada perangkat nyata.

Filmstrip dapat memberikan insight tentang rasa pengguna saat memuat aplikasi Anda. Waterfall dapat memberi tahu Anda resource apa yang bertanggung jawab atas waktu pemuatan yang berpotensi lama. Berikut adalah checklist hal-hal yang dapat Anda lakukan untuk meningkatkan performa pemuatan:

  • Kirim sebanyak mungkin aset melalui satu koneksi.
  • Pemuatan ulang atau bahkan resource inline yang diperlukan untuk rendering dan interaktivitas pertama.
  • Lakukan pra-rendering aplikasi untuk meningkatkan performa pemuatan yang dirasakan.
  • Gunakan pemisahan kode yang agresif untuk mengurangi jumlah kode yang diperlukan untuk interaktivitas.

Nantikan bagian 2 yang membahas cara mengoptimalkan performa runtime di perangkat yang sangat terbatas.