Aplikasi multihalaman yang lebih cepat dengan streaming

Saat ini, situs—atau aplikasi web jika Anda mau—cenderung menggunakan salah satu dari dua skema navigasi:

  • Browser skema navigasi disediakan secara default—yaitu, Anda memasukkan URL di kolom URL browser dan permintaan navigasi akan menampilkan dokumen sebagai respons. Kemudian Anda mengklik link, yang akan menghapus muatan dokumen saat ini untuk dokumen lainnya, ad infinitum.
  • Pola aplikasi satu halaman, yang melibatkan permintaan navigasi awal untuk memuat shell aplikasi dan mengandalkan JavaScript untuk mengisi shell aplikasi dengan markup yang dirender klien dengan konten dari API backend untuk setiap "navigasi".

Manfaat dari setiap pendekatan telah dipuji oleh pendukungnya:

  • Skema navigasi yang disediakan browser secara default bersifat tangguh, karena rute tidak memerlukan JavaScript agar dapat diakses. Rendering markup oleh klien melalui JavaScript juga dapat menjadi proses yang berpotensi mahal, artinya perangkat kelas bawah mungkin berakhir dalam situasi tertunda karena perangkat diblokir pemrosesan skrip yang menyediakan konten.
  • Di sisi lain, Aplikasi Web Satu Halaman (SPA) dapat menyediakan navigasi yang lebih cepat setelah pemuatan awal. Alih-alih mengandalkan browser untuk menghapus dokumen untuk dokumen yang benar-benar baru (dan mengulanginya untuk setiap navigasi), mereka dapat menawarkan apa yang terasa seperti lebih cepat dan lebih "mirip aplikasi" — bahkan jika JavaScript diperlukan agar dapat berfungsi.

Dalam postingan ini, kita akan membahas tentang metode ketiga yang mencapai keseimbangan antara dua pendekatan yang dijelaskan di atas: mengandalkan pekerja layanan untuk melakukan precache elemen umum situs—seperti markup header dan footer—dan menggunakan stream untuk memberikan respons HTML ke klien secepat mungkin, sambil tetap menggunakan skema navigasi default browser.

Mengapa men-streaming respons HTML di pekerja layanan?

Streaming adalah sesuatu yang dilakukan browser web Anda saat membuat permintaan. Hal ini sangat penting dalam konteks permintaan navigasi, karena memastikan browser tidak diblokir menunggu keseluruhan respons sebelum dapat mulai mengurai markup dokumen dan merender halaman.

Diagram yang menggambarkan HTML non-streaming versus HTML streaming. Dalam kasus pertama, seluruh payload markup tidak diproses hingga payload tiba. Selain itu, markup diproses secara bertahap saat masuk dalam potongan dari jaringan.

Untuk pekerja layanan, streaming sedikit berbeda karena menggunakan Streams API JavaScript. Tugas terpenting yang dipenuhi pekerja layanan adalah mencegat dan merespons permintaan—termasuk permintaan navigasi.

Permintaan ini dapat berinteraksi dengan cache dalam beberapa cara, tetapi pola caching yang umum untuk markup adalah dengan mendukung penggunaan respons dari jaringan terlebih dahulu, tetapi kembali ke cache jika salinan lama tersedia—dan secara opsional memberikan respons penggantian generik jika respons yang dapat digunakan tidak ada dalam cache.

Ini adalah pola yang telah teruji waktu untuk markup yang berfungsi dengan baik, tetapi meskipun membantu keandalan dalam hal akses offline, pola ini tidak menawarkan keuntungan performa yang melekat pada permintaan navigasi yang mengandalkan strategi jaringan-first atau network saja. Di sinilah streaming berperan, dan kita akan mempelajari cara menggunakan modul workbox-streams yang didukung Streams API di pekerja layanan Workbox untuk mempercepat permintaan navigasi di situs multihalaman Anda.

Menguraikan laman web pada umumnya

Secara struktural, situs cenderung memiliki elemen umum yang ada di setiap halaman. Susunan elemen halaman yang biasa sering kali berjalan seperti:

  • Tajuk.
  • Konten.
  • Footer.

Menggunakan web.dev sebagai contoh, perincian elemen umum tersebut akan terlihat seperti ini:

Perincian elemen umum di situs web.dev. Area umum yang ditandai ditandai sebagai 'header', 'content', dan 'footer'.

Tujuan di balik mengidentifikasi bagian halaman adalah kami menentukan apa yang dapat dipra-cache dan diambil tanpa masuk ke jaringan—yaitu markup header dan footer yang umum digunakan di semua halaman—dan bagian halaman yang akan selalu kami masuki ke jaringan terlebih dahulu—konten dalam kasus ini.

Setelah mengetahui cara menyegmentasi bagian halaman dan mengidentifikasi elemen umum, kita dapat menulis pekerja layanan yang selalu mengambil markup header dan footer secara instan dari cache dan hanya meminta konten dari jaringan.

Kemudian, dengan menggunakan Streams API melalui workbox-streams, kita dapat menggabungkan semua bagian ini dan langsung merespons permintaan navigasi—sekaligus meminta jumlah minimum markup yang diperlukan dari jaringan.

Membuat pekerja layanan streaming

Ada banyak hal yang bergerak dalam hal streaming konten parsial di pekerja layanan, tetapi setiap langkah proses ini akan dipelajari secara mendetail seiring Anda berjalan, dimulai dengan cara menyusun situs Anda.

Membagi situs web menjadi beberapa bagian

Sebelum dapat mulai menulis pekerja layanan streaming, Anda perlu melakukan tiga hal:

  1. Buat file yang hanya berisi markup header situs Anda.
  2. Buat file yang hanya berisi markup footer situs web Anda.
  3. Tarik konten utama setiap halaman ke dalam file terpisah, atau siapkan backend agar hanya menayangkan konten halaman berdasarkan header permintaan HTTP secara kondisional.

Seperti yang mungkin Anda duga, langkah terakhir adalah yang tersulit, terutama jika situs Anda statis. Jika demikian, Anda harus membuat dua versi untuk setiap halaman: satu versi akan berisi markup halaman lengkap, sedangkan versi lainnya hanya akan berisi konten.

Menulis pekerja layanan streaming

Jika belum menginstal modul workbox-streams, Anda harus melakukannya selain modul Workbox apa pun yang saat ini diinstal. Untuk contoh spesifik ini, yang melibatkan paket-paket berikut:

npm i workbox-navigation-preload workbox-strategies workbox-routing workbox-precaching workbox-streams --save

Dari sini, langkah berikutnya adalah membuat pekerja layanan baru dan melakukan precache sebagian header dan footer.

Precaching parsial

Hal pertama yang perlu Anda lakukan adalah membuat pekerja layanan di root project Anda yang diberi nama sw.js (atau nama file apa pun yang Anda inginkan). Di dalamnya, Anda akan memulai dengan hal berikut:

// sw.js
import * as navigationPreload from 'workbox-navigation-preload';
import {NetworkFirst} from 'workbox-strategies';
import {registerRoute} from 'workbox-routing';
import {matchPrecache, precacheAndRoute} from 'workbox-precaching';
import {strategy as composeStrategies} from 'workbox-streams';

// Enable navigation preload for supporting browsers
navigationPreload.enable();

// Precache partials and some static assets
// using the InjectManifest method.
precacheAndRoute([
  // The header partial:
  {
    url: '/partial-header.php',
    revision: __PARTIAL_HEADER_HASH__
  },
  // The footer partial:
  {
    url: '/partial-footer.php',
    revision: __PARTIAL_FOOTER_HASH__
  },
  // The offline fallback:
  {
    url: '/offline.php',
    revision: __OFFLINE_FALLBACK_HASH__
  },
  ...self.__WB_MANIFEST
]);

// To be continued...

Kode ini melakukan beberapa hal:

  1. Mengaktifkan pramuat navigasi untuk browser yang mendukungnya.
  2. Melakukan pra-cache markup header dan footer. Artinya, markup header dan footer untuk setiap halaman akan diambil secara instan, karena tidak akan diblokir oleh jaringan.
  3. Melakukan pra-cache aset statis di placeholder __WB_MANIFEST yang menggunakan metode injectManifest.

Respons aliran data

Membuat pekerja layanan Anda melakukan streaming respons gabungan adalah bagian terbesar dari seluruh upaya ini. Meski begitu, Workbox dan workbox-streams-nya membuat proses ini jauh lebih ringkas dibandingkan jika Anda harus melakukan semua ini sendiri:

// sw.js
import * as navigationPreload from 'workbox-navigation-preload';
import {NetworkFirst} from 'workbox-strategies';
import {registerRoute} from 'workbox-routing';
import {matchPrecache, precacheAndRoute} from 'workbox-precaching';
import {strategy as composeStrategies} from 'workbox-streams';

// ...
// Prior navigation preload and precaching code omitted...
// ...

// The strategy for retrieving content partials from the network:
const contentStrategy = new NetworkFirst({
  cacheName: 'content',
  plugins: [
    {
      // NOTE: This callback will never be run if navigation
      // preload is not supported, because the navigation
      // request is dispatched while the service worker is
      // booting up. This callback will only run if navigation
      // preload is _not_ supported.
      requestWillFetch: ({request}) => {
        const headers = new Headers();

        // If the browser doesn't support navigation preload, we need to
        // send a custom `X-Content-Mode` header for the back end to use
        // instead of the `Service-Worker-Navigation-Preload` header.
        headers.append('X-Content-Mode', 'partial');

        // Send the request with the new headers.
        // Note: if you're using a static site generator to generate
        // both full pages and content partials rather than a back end
        // (as this example assumes), you'll need to point to a new URL.
        return new Request(request.url, {
          method: 'GET',
          headers
        });
      },
      // What to do if the request fails.
      handlerDidError: async ({request}) => {
        return await matchPrecache('/offline.php');
      }
    }
  ]
});

// Concatenates precached partials with the content partial
// obtained from the network (or its fallback response).
const navigationHandler = composeStrategies([
  // Get the precached header markup.
  () => matchPrecache('/partial-header.php'),
  // Get the content partial from the network.
  ({event}) => contentStrategy.handle(event),
  // Get the precached footer markup.
  () => matchPrecache('/partial-footer.php')
]);

// Register the streaming route for all navigation requests.
registerRoute(({request}) => request.mode === 'navigate', navigationHandler);

// Your service worker can end here, or you can add more
// logic to suit your needs, such as runtime caching, etc.

Kode ini terdiri dari tiga bagian utama yang memenuhi persyaratan berikut:

  1. Strategi NetworkFirst digunakan untuk menangani permintaan untuk parsial konten. Dengan menggunakan strategi ini, nama cache kustom content ditentukan untuk memuat bagian konten, serta plugin kustom yang menangani apakah akan menyetel header permintaan X-Content-Mode untuk browser yang tidak mendukung pramuat navigasi (sehingga tidak mengirim header Service-Worker-Navigation-Preload). Plugin ini juga menentukan apakah akan mengirim versi cache sebagian konten yang terakhir di-cache, atau mengirim halaman penggantian offline jika tidak ada versi yang di-cache untuk permintaan saat ini yang disimpan.
  2. Metode strategy di workbox-streams (dialiaskan sebagai composeStrategies di sini) digunakan untuk menyambungkan parsial header dan footer yang telah disimpan sebelumnya bersama dengan sebagian konten yang diminta dari jaringan.
  3. Seluruh skema disesuaikan melalui registerRoute untuk permintaan navigasi.

Dengan menerapkan logika ini, kami telah menyiapkan respons bertahap. Namun, mungkin ada beberapa pekerjaan yang perlu Anda lakukan di backend untuk memastikan bahwa konten dari jaringan adalah halaman parsial yang bisa Anda gabungkan dengan sebagian yang telah disimpan sebelumnya.

Jika situs Anda memiliki backend

Anda akan mengingat bahwa saat pramuat navigasi diaktifkan, browser akan mengirimkan header Service-Worker-Navigation-Preload dengan nilai true. Namun, dalam contoh kode di atas, kami mengirim header kustom X-Content-Mode jika pramuat navigasi tidak didukung di browser. Di backend, Anda akan mengubah respons berdasarkan keberadaan header ini. Di backend PHP, hal itu mungkin terlihat seperti ini untuk halaman tertentu:

<?php
// Check if we need to render a content partial
$navPreloadSupported = isset($_SERVER['HTTP_SERVICE_WORKER_NAVIGATION_PRELOAD']) && $_SERVER['HTTP_SERVICE_WORKER_NAVIGATION_PRELOAD'] === 'true';
$partialContentMode = isset($_SERVER['HTTP_X_CONTENT_MODE']) && $_SERVER['HTTP_X_CONTENT_MODE'] === 'partial';
$isPartial = $navPreloadSupported || $partialContentMode;

// Figure out whether to render the header
if ($isPartial === false) {
  // Get the header include
  require_once($_SERVER['DOCUMENT_ROOT'] . '/includes/site-header.php');

  // Render the header
  siteHeader();
}

// Get the content include
require_once('./content.php');

// Render the content
content($isPartial);

// Figure out whether to render the footer
if ($isPartial === false) {
  // Get the footer include
  require_once($_SERVER['DOCUMENT_ROOT'] . '/includes/site-footer.php');

  // Render the footer
  siteFooter();
}
?>

Pada contoh di atas, parsial konten dipanggil sebagai fungsi, yang menggunakan nilai $isPartial untuk mengubah cara sebagian dirender. Misalnya, fungsi perender content mungkin hanya menyertakan markup tertentu dalam kondisi saat diambil sebagai sebagian. Hal ini akan segera dibahas.

Pertimbangan

Sebelum Anda men-deploy pekerja layanan untuk melakukan streaming dan menggabungkan sebagian, ada beberapa hal yang harus Anda pertimbangkan. Memang benar bahwa menggunakan pekerja layanan dengan cara ini tidak secara mendasar mengubah perilaku navigasi default browser, ada beberapa hal yang mungkin perlu Anda tangani.

Memperbarui elemen halaman saat bernavigasi

Bagian tersulit dari pendekatan ini adalah bahwa beberapa hal perlu diperbarui pada klien. Misalnya, markup header precache berarti halaman akan memiliki konten yang sama di elemen <title>, atau bahkan pengelolaan status aktif/nonaktif untuk item navigasi harus diperbarui pada setiap navigasi. Hal-hal ini—dan yang lainnya—mungkin harus diperbarui pada klien untuk setiap permintaan navigasi.

Cara untuk menyiasatinya adalah dengan menempatkan elemen <script> inline ke dalam sebagian konten yang berasal dari jaringan untuk mengupdate beberapa hal penting:

<!-- The JSON below contains information about the current page. -->
<script id="page-data" type="application/json">'{"title":"Sand Wasp &mdash; World of Wasps","description":"Read all about the sand wasp in this tidy little post."}'</script>
<script>
  const pageData = JSON.parse(document.getElementById('page-data').textContent);

  // Update the page title
  document.title = pageData.title;
</script>
<article>
  <!-- Page content omitted... -->
</article>

Ini hanyalah satu contoh dari apa yang mungkin harus Anda lakukan jika Anda memutuskan untuk menggunakan pengaturan pekerja layanan ini. Untuk aplikasi yang lebih kompleks dengan informasi pengguna, misalnya, Anda mungkin harus menyimpan bit data yang relevan di web store seperti localStorage dan memperbarui halaman dari sana.

Menangani jaringan yang lambat

Satu kelemahan dari respons streaming yang menggunakan markup dari pra-cache dapat terjadi saat koneksi jaringan lambat. Masalahnya adalah markup header dari precache akan segera tiba, tetapi sebagian konten dari jaringan bisa memerlukan waktu cukup lama untuk tiba setelah penggambaran awal markup header.

Hal ini dapat menciptakan pengalaman yang membingungkan, dan jika jaringan sangat lambat, halaman bahkan dapat terasa seperti halaman rusak dan tidak merender lebih jauh. Dalam kasus semacam ini, Anda dapat memilih untuk menempatkan ikon atau pesan pemuatan di markup sebagian konten yang dapat Anda sembunyikan setelah konten dimuat.

Salah satu cara untuk melakukan ini adalah melalui CSS. Misalnya sebagian header Anda diakhiri dengan elemen <article> pembuka yang kosong hingga sebagian konten tiba untuk mengisinya. Anda dapat menulis aturan CSS yang mirip dengan ini:

article:empty::before {
  text-align: center;
  content: 'Loading...';
}

Cara ini berfungsi, tetapi akan menampilkan pesan pemuatan pada klien terlepas dari kecepatan jaringannya. Jika ingin menghindari flash pesan yang aneh, Anda dapat mencoba pendekatan ini di mana kami mengumpulkan pemilih pada cuplikan di atas dalam class slow:

.slow article:empty::before {
  text-align: center;
  content: 'Loading...';
}

Dari sini, Anda dapat menggunakan JavaScript di sebagian header untuk membaca jenis koneksi efektif (setidaknya di browser Chromium) untuk menambahkan class slow ke elemen <html> pada jenis koneksi tertentu:

<script>
  const effectiveType = navigator?.connection?.effectiveType;

  if (effectiveType !== '4g') {
    document.documentElement.classList.add('slow');
  }
</script>

Ini akan memastikan bahwa jenis koneksi efektif yang lebih lambat dari jenis 4g akan mendapatkan pesan pemuatan. Kemudian, di sebagian konten, Anda dapat menempatkan elemen <script> inline untuk menghapus class slow dari HTML guna menghilangkan pesan pemuatan:

<script>
  document.documentElement.classList.remove('slow');
</script>

Memberikan respons penggantian

Katakanlah Anda menggunakan strategi yang mengutamakan jaringan untuk sebagian konten. Jika pengguna sedang offline dan membuka halaman yang sudah mereka kunjungi, halaman tersebut akan ditutup. Namun, jika mereka membuka halaman yang belum mereka kunjungi, mereka tidak akan mendapatkan apa pun. Untuk menghindari hal ini, Anda harus menayangkan respons penggantian.

Kode yang diperlukan untuk mencapai respons penggantian ditunjukkan dalam contoh kode sebelumnya. Proses ini memerlukan dua langkah:

  1. Melakukan precache respons penggantian offline.
  2. Siapkan callback handlerDidError di plugin untuk strategi jaringan-first guna memeriksa cache versi halaman yang terakhir diakses. Jika halaman tidak pernah diakses, Anda harus menggunakan metode matchPrecache dari modul workbox-precaching untuk mengambil respons penggantian dari precache.

Cache dan CDN

Jika Anda menggunakan pola streaming ini di pekerja layanan, nilai apakah hal berikut sesuai dengan situasi Anda:

  • Anda menggunakan CDN atau cache perantara/publik lainnya.
  • Anda telah menentukan header Cache-Control dengan perintah max-age dan/atau s-maxage bukan nol yang dikombinasikan dengan perintah public.

Jika kedua hal tersebut sesuai untuk Anda, cache perantara mungkin menyimpan respons untuk permintaan navigasi. Namun, perhatikan bahwa saat menggunakan pola ini, Anda mungkin memberikan dua respons yang berbeda untuk URL yang diberikan:

  • Respons lengkap, yang berisi markup header, konten, dan footer.
  • Respons parsial, yang hanya berisi konten.

Hal ini dapat menyebabkan beberapa perilaku yang tidak diinginkan, yang mengakibatkan markup header dan footer berlipat ganda, karena pekerja layanan mungkin mengambil respons lengkap dari cache CDN dan menggabungkannya dengan markup header dan footer yang telah disimpan sebelumnya.

Untuk menyiasati hal ini, Anda harus mengandalkan header Vary, yang memengaruhi perilaku caching dengan mengunci respons yang dapat di-cache ke satu atau beberapa header yang ada dalam permintaan. Karena kita memvariasikan respons terhadap permintaan navigasi berdasarkan header permintaan Service-Worker-Navigation-Preload dan X-Content-Mode kustom, kita perlu menentukan header Vary ini dalam respons:

Vary: Service-Worker-Navigation-Preload,X-Content-Mode

Dengan header ini, browser akan membedakan antara respons lengkap dan parsial untuk permintaan navigasi, menghindari masalah dengan markup header dan footer ganda, seperti halnya cache perantara.

Hasil

Sebagian besar saran performa waktu muat diringkas dengan "tunjukkan apa yang Anda dapatkan"—jangan menahan diri, jangan menunggu sampai Anda memiliki semuanya sebelum menunjukkan apa pun kepada pengguna.

Jake Archibald dalam Fun Hacks for Faster Content

Browser unggul dalam menangani respons permintaan navigasi, bahkan untuk isi respons HTML yang besar. Secara {i>default<i}, browser secara bertahap melakukan streaming dan memproses markup dalam potongan-potongan yang menghindari tugas yang lama, yang bagus untuk kinerja startup.

Hal ini menguntungkan kita saat kita menggunakan pola pekerja layanan streaming. Kapan pun Anda merespons permintaan dari cache pekerja layanan sejak awal, respons awal akan muncul hampir secara instan. Saat menggabungkan markup header dan footer yang telah di-pracache dengan respons dari jaringan, Anda akan mendapatkan beberapa keuntungan performa yang signifikan:

  • Time to First Byte (TTFB) sering kali akan sangat berkurang, karena byte pertama respons terhadap permintaan navigasi bersifat instan.
  • First Contentful Paint (FCP) akan sangat cepat, karena markup header yang di-pra-cache akan berisi referensi ke style sheet yang di-cache, yang berarti halaman akan ditampilkan dengan sangat, sangat cepat.
  • Dalam beberapa kasus, Largest Contentful Paint (LCP) juga dapat lebih cepat, terutama jika elemen di layar terbesar disediakan oleh sebagian header pra-cache. Meski begitu, menyajikan sesuatu dari cache pekerja layanan sesegera mungkin bersama dengan payload markup yang lebih kecil dapat menghasilkan LCP yang lebih baik.

Arsitektur multihalaman streaming bisa sedikit sulit disiapkan dan diiterasi, tetapi secara teori, kompleksitas yang terkait sering kali tidak lebih berat dibandingkan SPA. Manfaat utamanya adalah Anda tidak mengganti skema navigasi default browser—Anda justru meningkatkannya.

Lebih baik lagi, Workbox membuat arsitektur ini tidak hanya memungkinkan, tetapi lebih mudah dibandingkan jika Anda menerapkannya sendiri. Cobalah di situs Anda sendiri dan lihat seberapa cepat situs multihalaman Anda bagi pengguna di lapangan.

Resource