Aplikasi multihalaman yang lebih cepat dengan streaming

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

  • Skema navigasi yang disediakan browser 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 web 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 back-end untuk setiap "navigasi".

Manfaat dari setiap pendekatan telah dipuji oleh para pendukungnya:

  • Skema navigasi yang disediakan browser secara default lebih tangguh, karena rute tidak memerlukan JavaScript agar dapat diakses. Rendering klien dari markup melalui JavaScript juga dapat menjadi proses yang berpotensi mahal, yang berarti bahwa perangkat kelas bawah dapat berakhir dalam situasi ketika konten tertunda karena perangkat diblokir memproses skrip yang menyediakan konten.
  • Di sisi lain, Aplikasi Web Satu Halaman (SPA) dapat memberikan navigasi yang lebih cepat setelah pemuatan awal. Daripada mengandalkan browser untuk menghapus muatan dokumen untuk dokumen yang benar-benar baru (dan mengulanginya untuk setiap navigasi), mereka dapat menawarkan apa yang terasa seperti pengalaman yang lebih cepat dan lebih "seperti aplikasi"—bahkan jika itu memerlukan JavaScript agar berfungsi.

Dalam postingan ini, kita akan membahas metode ketiga yang mencapai keseimbangan antara dua pendekatan yang dijelaskan di atas: mengandalkan service worker untuk meng-cache elemen umum situs—seperti markup header dan footer—dan menggunakan stream untuk memberikan respons HTML kepada klien secepat mungkin, sambil tetap menggunakan skema navigasi default browser.

Mengapa melakukan streaming respons HTML di pekerja layanan?

Streaming adalah sesuatu yang sudah 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 sampai tiba. Yang kedua, markup diproses secara bertahap karena tiba dalam potongan dari jaringan.

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

Permintaan ini dapat berinteraksi dengan cache dalam berbagai cara, tetapi pola caching umum untuk markup adalah 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 oleh waktu untuk markup yang berfungsi dengan baik, tetapi meskipun membantu keandalan dalam hal akses offline, pola ini tidak menawarkan keunggulan performa yang melekat untuk permintaan navigasi yang mengandalkan strategi khusus jaringan atau jaringan terlebih dahulu. Di sinilah streaming berperan, dan kita akan mempelajari cara menggunakan modul workbox-streams yang didukung Streams API di pekerja layanan Workbox Anda untuk mempercepat permintaan navigasi di situs multihalaman.

Menguraikan halaman web biasa

Secara struktural, situs cenderung memiliki elemen umum yang ada di setiap halaman. Pengaturan elemen halaman yang umum sering kali seperti ini:

  • Header.
  • Konten.
  • Footer.

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

Perincian elemen umum di {i>website<i} web.dev. Area umum yang ditandai adalah &#39;header&#39;, &#39;content&#39;, dan &#39;footer&#39;.

Tujuan di balik mengidentifikasi bagian halaman adalah kami menentukan apa yang dapat di-precache dan diambil tanpa masuk ke jaringan—yaitu markup header dan footer yang umum untuk semua halaman—dan bagian halaman yang akan selalu kita buka jaringan terlebih dahulu—konten dalam hal ini.

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

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

Membuat pekerja layanan streaming

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

Membagi situs menjadi beberapa bagian

Sebelum dapat mulai menulis pekerja layanan streaming, Anda harus 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. Pindahkan konten utama setiap halaman ke dalam file terpisah, atau siapkan backend Anda untuk secara kondisional menayangkan konten halaman saja berdasarkan header permintaan HTTP.

Seperti yang mungkin Anda harapkan, langkah terakhir adalah yang tersulit, terutama jika situs Anda statis. Jika itu yang terjadi, Anda harus membuat dua versi dari 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 telah 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 akan Anda lakukan adalah membuat pekerja layanan di root project Anda yang bernama 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. Meng-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 melakukan streaming respons gabungan adalah bagian terbesar dari seluruh upaya ini. Meski begitu, Workbox dan workbox-streams-nya membuat proses ini menjadi jauh lebih ringkas daripada 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 sebagian konten. Dengan menggunakan strategi ini, nama cache kustom content ditentukan untuk memuat sebagian konten, serta plugin kustom yang menangani apakah akan menetapkan header permintaan X-Content-Mode untuk browser yang tidak mendukung pramuat navigasi (sehingga tidak mengirim header Service-Worker-Navigation-Preload). Plugin ini juga mencari tahu apakah akan mengirim sebagian konten versi cache terakhir, atau mengirim halaman penggantian offline jika tidak ada versi yang di-cache untuk permintaan saat ini yang disimpan.
  2. Metode strategy di workbox-streams (beralias sebagai composeStrategies di sini) digunakan untuk menyambungkan sebagian header dan footer yang telah di-cache bersama dengan sebagian konten yang diminta dari jaringan.
  3. Seluruh skema telah dicurangi melalui registerRoute untuk permintaan navigasi.

Dengan logika ini, kita telah menyiapkan respons streaming. Namun, mungkin ada beberapa pekerjaan yang harus Anda lakukan di backend untuk memastikan bahwa konten dari jaringan adalah sebagian halaman yang dapat Anda gabungkan dengan sebagian yang telah di-cache.

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, kita mengirim header kustom X-Content-Mode jika pramuat navigasi peristiwa tidak didukung di browser. Di backend, Anda perlu 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 mengambil nilai $isPartial untuk mengubah cara bagian tersebut dirender. Misalnya, fungsi perender content hanya dapat menyertakan markup tertentu dalam kondisi saat diambil sebagai parsial—sesuatu yang akan segera dibahas.

Pertimbangan

Sebelum men-deploy pekerja layanan untuk melakukan streaming dan menggabungkan sebagian, ada beberapa hal yang harus dipertimbangkan. Meskipun 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 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 lainnya—mungkin harus diperbarui pada klien untuk setiap permintaan navigasi.

Cara untuk menyiasati hal ini mungkin adalah dengan menempatkan elemen <script> inline ke dalam sebagian konten yang berasal dari jaringan untuk memperbarui 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 salah satu contoh yang mungkin harus Anda lakukan jika Anda memutuskan untuk menggunakan penyiapan 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 precache dapat terjadi saat koneksi jaringan lambat. Masalahnya adalah markup header dari pra-cache akan tiba seketika, tetapi sebagian konten dari jaringan dapat memerlukan beberapa waktu untuk tiba setelah gambar awal markup header.

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

Salah satu cara untuk melakukannya adalah melalui CSS. Misalnya header Anda diakhiri sebagian 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 akan berfungsi, tetapi akan menampilkan pesan pemuatan pada klien terlepas dari kecepatan jaringan. Jika Anda ingin menghindari flash pesan yang aneh, Anda dapat mencoba pendekatan ini saat kami menempatkan pemilih di cuplikan di atas dalam class slow:

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

Dari sini, Anda dapat menggunakan JavaScript di header sebagian 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>

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

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

Memberikan respons penggantian

Misalnya Anda menggunakan strategi yang mengutamakan jaringan untuk sebagian konten Anda. Jika pengguna offline dan membuka halaman yang pernah mereka kunjungi, mereka akan terlindungi. Namun, jika mereka membuka halaman yang belum dikunjungi, mereka tidak akan mendapatkan apa pun. Untuk menghindari hal ini, Anda harus menayangkan respons penggantian.

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

  1. Melakukan pra-cache respons penggantian offline.
  2. Siapkan callback handlerDidError di plugin untuk strategi yang mengutamakan jaringan 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.

Caching dan CDN

Jika menggunakan pola streaming ini di pekerja layanan, pertimbangkan apakah hal berikut berlaku untuk situasi Anda:

  • Anda menggunakan CDN atau jenis 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 terjadi pada Anda, cache perantara mungkin menyimpan respons untuk permintaan navigasi. Namun, perhatikan bahwa ketika Anda menggunakan pola ini, Anda mungkin menampilkan dua tanggapan yang berbeda untuk setiap URL yang diberikan:

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

Hal ini dapat menyebabkan beberapa perilaku yang tidak diinginkan, yang mengakibatkan markup header dan footer dua kali lipat, karena pekerja layanan mungkin mengambil respons penuh dari cache CDN dan menggabungkannya dengan markup header dan footer yang di-cache.

Untuk mengatasi hal ini, Anda harus mengandalkan header Vary, yang memengaruhi perilaku caching dengan memasukkan 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 sebagian untuk permintaan navigasi, menghindari masalah dengan markup header dan footer dua kali lipat, seperti halnya cache perantara.

Hasil

Sebagian besar saran performa waktu pemuatan berfokus pada "tunjukkan apa yang Anda miliki"—jangan menahan diri, jangan menunggu sampai Anda memiliki semuanya sebelum menunjukkan apa pun kepada pengguna.

Jake Archibald dalam Fun Hacks untuk Konten yang Lebih Cepat

Browser unggul dalam menangani respons atas permintaan navigasi, bahkan untuk respons HTML yang sangat banyak. Secara default, browser secara bertahap melakukan streaming dan memproses markup dalam potongan yang menghindari tugas yang berjalan lama, yang bagus untuk performa startup.

Hal ini berguna saat kita menggunakan pola pekerja layanan streaming. Setiap kali Anda merespons permintaan dari cache pekerja layanan dari awal, respons akan diterima hampir seketika. Saat Anda menggabungkan markup header dan footer yang di-cache dengan respons dari jaringan, Anda akan mendapatkan beberapa keunggulan performa:

  • Time to First Byte (TTFB) akan sering berkurang secara signifikan, karena byte pertama respons terhadap permintaan navigasi bersifat instan.
  • First Contentful Paint (FCP) akan sangat cepat, karena markup header yang di-cache akan berisi referensi ke style sheet yang di-cache, yang berarti halaman akan menggambar dengan sangat, sangat cepat.
  • Dalam beberapa kasus, Largest Contentful Paint (LCP) juga dapat lebih cepat, terutama jika elemen terbesar di layar disediakan oleh sebagian header yang di-cache yang telah di-cache. Meskipun demikian, hanya 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 untuk disiapkan dan diiterasi, tetapi kompleksitas yang terlibat sering kali tidak lebih berat daripada SPA dalam teori. Manfaat utamanya adalah bahwa Anda tidak mengganti skema navigasi default browser—Anda sedang meningkatkan skema navigasi tersebut.

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

Referensi