Web

Membuat Fitur Pencarian di Astro.js Menggunakan Fuse.js

diperbarui pada
9 min read
cover

Akhir akhir ini saya telah melakukan migrasi website ini dari gatsby.js ke astro.js, dan sekarang saya ingin mencoba meningkatkan fitur pencarian di halaman ini.

Saat masih menggunakan gatsby, saya mengandalkan Lunr sebagai sistem pencarian. Namun, karena library tersebut sepertinya kurang dimaintain, saya mulai mencari alternatif yang lain.

Perkenalkan Fuse.js

Fuse.js merupakan library pencarian yg menggunakan metode fuzzy searching. Fuzzy searching merupakan teknik untuk mencari sebuah kata yang mirip atau mendekati dengan pola-pola tertentu (tidak 100% harus sama dengan keyword yg kita cari).

Kenapa menggunakan Fuse.js?

  • Tanpa Pengaturan Backend: Kita tidak perlu melakukan setup backend hanya untuk sistem pencarian
  • Ringan dan bebas dependency: Fuse.js sangat ringan dan tidak bergantung ke dependency lain.

Persiapan

Ada beberapa hal yg perlu kita persiapkan sebelum memulai membuat komponen.

  1. Alpine sudah terinstall.

Karena project yg kita buat membutuhkan alpine.js, pastikan di project astro kalian juga sudah terinstall. jika belum, bisa cek Integrasi Alpine.js

  1. Menginstall Fuse.js
Terminal window
npm install fuse.js

Cara menggunakan Fuse.js

Untuk menggunakan fuse.js sangat mudah.

import Fuse from "fuse.js"
// data
const books = [
{
title: "Beauty Is a Wound",
author: "Eka Kurniawan"
},
{
title: 'Perahu Kertas',
author: "Dee Lestary"
}
];
// inisialisasi
const fuse = new Fuse(books, {
keys: ['title', 'author']
});
// cari
fuse.search('dee');

ketika kita console hasil pencarian akan muncul hasil seperti berikut:

// Output:
[
{
item: {
title: 'Perahu Kertas',
author: "Dee Lestary"
},
refIndex: 0
}
]

Ketika melakukan pencarian, fuse akan menampilkan hasil data pencarian berdasarkan keyword yang kita masukkan sesuai data yg relevan.

Demo

Klik tombol dibawah ini untuk melihat Demo Search Modal.

Membuat Komponen Modal.

Pada contoh ini saya akan membuat sebuah halaman baru di pages/search/index.astro. Di dalam halaman tersebut, kita akan mengimpor komponen <Search />.

pages/search/index.astro
---
import Layout from "../../layouts/Layout.astro";
import Search from "../../components/search.jsx";
---
<Layout>
<Search client:only="react" />
</Layout>

Di dalam komponen <Search/>, kita akan membuat sebuah tombol. Ketika tombol tersebut diklik, akan muncul modal pencarian. Namun, sebelum itu, tambahkan style berikut:

components/search/index.css
.modal-search-button {
background-color: #3b82f6;
border-radius: .25em;
padding: .25rem .75rem;
font-size: .9em;
color: white;
}
.modal-search-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: #2c2c2cdd;
backdrop-filter: blur(.25em);
z-index: 40;
}
.modal-search-body {
position: fixed;
top: 120px;
left: 50%;
transform: translateX(-50%);
z-index: 50;
padding: 1.25em;
background-color: #f2f2f2;
border-radius: .75em;
max-width: 600px;
width: 100%;
}
.modal-search-input {
width: 100%;
padding: .5em 1rem;
border-radius: .5em;
outline: none;
}
.modal-search-result {
cursor: pointer;
background-color: white;
display: flex;
justify-content: space-between;
padding: .75rem 1rem;
border-radius: .5rem;
gap: 1rem;
align-items: center;
margin-bottom: 0.625rem;
box-shadow: 0px 10px 15px -3px rgb(228 228 231 / 0.5);
}
.modal-search-result:hover {
background-color: #3b82f6;
color: white;
}
.modal-search-result-title {
font-weight: bold;
}
.modal-search-result-desc {
font-size: .8rem;
}

Selanjutnya buat komponen search seperti contoh berikut

components/search/index.jsx
import "./index.css";
const Search = () => {
return (
<div x-data="{ isSearchOpen: false }">
<button x-on:click="isSearchOpen = true" className="modal-search-button">Search</button>
<div x-show="isSearchOpen" className="modal-search-overlay"></div>
<div
className="modal-search-body"
x-show="isSearchOpen"
x-cloak="true"
{...{ "x-on:click.outside": "isSearchOpen = false" }}
>
<input
type="text"
class="modal-search-input"
placeholder="Cari..."
/>
</div>
</div>
);
};
export default Search;

Kita telah berhasil membuat button & modal sederhana, tampilan yang kalian buat akan sama seperti contoh dibawah ini:

Implementasi Fuse.js

Selanjutnya, kita akan menambahkan fitur pencarian pada komponen yang sudah kita buat dengan mengimplementasikan fitur dari Fuse.js

components/search/index.jsx
import Fuse from 'fuse.js';
import { useState, useMemo } from 'react';
import "./index.css";
const SearchResults = ({input, results}) => {
19 collapsed lines
return (
<div className="mt-4">
{results.length === 0 && (
<>
{input === "" && <div>Cari sesuatu...</div>}
{input !== "" && <div>Pencarian tidak ditemukan...</div>}
</>
)}
{results.map(({ item: post }, index) => (
<div className="modal-search-result" key={index}>
<div>
<div class="modal-search-result-title">{post.title}</div>
<div class="modal-search-result-desc">{post.author}</div>
</div>
</div>
))}
</div>
)
}
const Search = () => {
const posts = [
10 collapsed lines
{
slug: 'beauty-is-a-wound',
title: "Beauty Is a Wound",
author: "Eka Kurniawan"
},
{
slug: 'perahu-kertas',
title: 'Perahu Kertas',
author: "Dee Lestary"
}
];
const [searchValue, setSearchValue] = useState('');
const fuse = useMemo(() => new Fuse(posts, { keys: ['title', 'author'] }), [posts])
const results = useMemo(() => fuse.search(searchValue), [fuse, searchValue]);
return (
<div x-data="{ isSearchOpen: false }">
<button x-on:click="isSearchOpen = true" className="modal-search-button">Search</button>
<div x-show="isSearchOpen" className="modal-search-overlay"></div>
<div
className="modal-search-body"
x-show="isSearchOpen"
x-cloak="true"
{...{ "x-on:click.outside": "isSearchOpen = false" }}
>
<input
className="modal-search-input"
placeholder="Cari..."
type="text"
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
/>
<SearchResults input={searchValue} results={results} />
</div>
</div>
);
};
export default Search;

Hasil pencarian anda sekarang akan tampil seperti berikut.

Kita telah berhasil melakukan pencarian sederhana, tetapi data yang kita cari dari contoh di atas hanya berupa array. Kita dapat mengganti data tersebut dengan data dari halaman artikel yang ada di website kita.

Membuat Endpoint

Disini kita akan membuat sebuah endpoint pada halaman astro.js untuk mengekspos semua halaman-halaman yang ingin kita masukkan ke fitur pencarian.

Untuk membuatnya kita perlu membuat file dengan nama search.json.js di dalam folder pages.

/src/pages/search.json.js
export async function GET() {
const posts = []; // get posts...
return new Response(
JSON.stringify({
posts
}),
{
status: 200,
headers: {
"Content-Type": "application/json"
}
}
);
}

contoh:

/src/pages/search.json.js
import { getCollection } from 'astro:content';
export async function GET() {
const blogCollection = await getCollection("blog");
const posts = blogCollection.map((post) => ({
slug: `/blog/${post.slug}`,
title: post.data.title.trim(),
type: "blog"
}));
return new Response(
JSON.stringify({
posts,
}),
{
status: 200,
headers: {
"Content-Type": "application/json"
}
}
);
}

kita dapat lihat endpoint tersebut dengan mengakses ke halaman /search.json di url. berikut merupakan contoh jika kita telah berhasil membuat endpoint

// output
{
"posts": [
{
"slug": "/blog/absolute-paths-di-gatsby-js",
"title": "Absolute Paths di Gatsby.js",
"type": "blog"
},
{
"slug": "/blog/backup-dan-restore-sistem-linux-menggunakan-timeshift",
"title": "Backup dan Restore Sistem Linux Menggunakan Timeshift",
"type": "blog"
},
{
"slug": "/blog/full-bleed",
"title": "Full Bleed Layout",
"type": "blog"
},
// ... etc
]
}

Update Search Component

Kita perlu mengupdate komponen search kita agar dapat mengambil data menggunakan fetch ke endpoint yang telah kita buat sebelumnya.

components/search.jsx
import Fuse from "fuse.js";
import { useState, useMemo, useEffect } from "react";
import "./index.css";
const SearchResults = ({input, results}) => {
13 collapsed lines
return (
<div className="mt-4">
{results.length === 0 && (
<>
{input === "" && <div>Cari sesuatu...</div>}
{input !== "" && <div>Pencarian tidak ditemukan...</div>}
</>
)}
{results.map(({ item: post }, index) => (
<div className="modal-search-result" key={index}>
<div>
<div className="modal-search-result-title">{post.title}</div>
<div className="modal-search-result-desc">{post.type}</div>
5 collapsed lines
</div>
</div>
))}
</div>
)
}
const Search = () => {
const posts = [
10 collapsed lines
{
slug: "beauty-is-a-wound",
title: "Beauty Is a Wound",
author: "Eka Kurniawan"
},
{
slug: "perahu-kertas",
title: "Perahu Kertas",
author: "Dee Lestary"
}
];
const [posts, setPosts] = useState(null);
const [searchValue, setSearchValue] = useState("");
const fuse = useMemo(() => new Fuse(posts, { keys: ["title", "author"] }), [posts]);
const fuse = useMemo(() => new Fuse(posts ?? [], { keys: ['title', 'type'] }), [posts]);
const results = useMemo(() => fuse.search(searchValue), [fuse, searchValue]);
useEffect(() => {
fetch('/search.json')
.then((resp) => resp.json())
.then(({ posts }) => setPosts(posts));
}, []);
return (
<div x-data="{ isSearchOpen: false }">
<button x-on:click="isSearchOpen = true" className="modal-search-button">Search</button>
<div x-show="isSearchOpen" className="modal-search-overlay"></div>
<div
className="modal-search-body"
x-show="isSearchOpen"
x-cloak="true"
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
{...{ "x-on:click.outside": "isSearchOpen = false" }}
>
<input type="text" className="modal-search-input" placeholder="Cari..." />
<SearchResults input={searchValue} results={results} />
</div>
</div>
);
};
export default Search;

Kita dapat menambahkan limit pada hasil pencarian. Dalam contoh ini, saya akan membatasi hasil yang ditampilkan hanya sebanyak 5 item.

const results = useMemo(() => fuse.search(searchValue, {limit: 5}), [fuse, searchValue]);

Sekarang kita sudah bisa melakukan pencarian berdasarkan data blog dari website kita.

Pre Generated Index

Langkah terakhir yang dapat kita implementasikan adalah membuat pre-generated fuse index. Dalam contoh yang sudah kita terapkan, index digenerate tiap ada request pencarian. Pendekatan ini tidak masalah selama kita tidak memiliki ratusan atau ribuan data.

Namun, jika kamu tetap ingin membuat pre-generated index, kita akan melakukannya di dalam API yang telah kita buat sebelumnya.

Silakan update file search.json.js seperti contoh di bawah ini.

/src/pages/search.json.js
import { getCollection } from 'astro:content';
import Fuse from 'fuse.js';
export async function GET() {
const blogCollection = await getCollection("blog");
const posts = blogCollection.map((post) => ({
slug: `/blog/${post.slug}`,
title: post.data.title.trim(),
type: "blog"
}));
const postsIndex = Fuse.createIndex(['title', 'type'], posts);
return new Response(
JSON.stringify({
posts,
index: postsIndex.toJSON(),
}),
{
status: 200,
headers: {
"Content-Type": "application/json"
}
}
);
}

Dan yang terakhir, kita update juga komponen Search agar menggunakan data dari index

components/search.jsx
import Fuse from "fuse.js";
import { useState, useMemo, useEffect } from "react";
import "./index.css";
const SearchResults = ({input, results}) => {
19 collapsed lines
return (
<div className="mt-4">
{results.length === 0 && (
<>
{input === "" && <div>Cari sesuatu...</div>}
{input !== "" && <div>Pencarian tidak ditemukan...</div>}
</>
)}
{results.map(({ item: post }, index) => (
<div className="modal-search-result" key={index}>
<div>
<div className="modal-search-result-title">{post.title}</div>
<div className="modal-search-result-desc">{post.type}</div>
</div>
</div>
))}
</div>
)
}
const Search = () => {
const [posts, setPosts] = useState(null);
const [index, setIndex] = useState(null);
const [searchValue, setSearchValue] = useState("");
const fuse = useMemo(() => new Fuse(posts ?? [], { keys: ['title', 'type'] }), [posts]);
const fuse = useMemo(() => {
const parsedIndex = index ? Fuse.parseIndex(index) : undefined;
return new Fuse(posts ?? [], { keys: ['title', 'type']}, parsedIndex);
}, [posts, index]);
const results = useMemo(() => fuse.search(searchValue), [fuse, searchValue]);
useEffect(() => {
fetch('/search.json')
.then((resp) => resp.json())
.then(({ posts }) => setPosts(posts));
.then(({ posts, index }) => {
setPosts(posts);
setIndex(index);
});
}, []);
return (
16 collapsed lines
<div x-data="{ isSearchOpen: false }">
<button x-on:click="isSearchOpen = true" className="modal-search-button">Search</button>
<div x-show="isSearchOpen" className="modal-search-overlay"></div>
<div
className="modal-search-body"
x-show="isSearchOpen"
x-cloak="true"
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
{...{ "x-on:click.outside": "isSearchOpen = false" }}
>
<input type="text" className="modal-search-input" placeholder="Cari..." />
<SearchResults input={searchValue} results={results} />
</div>
</div>
);
};
export default Search;

Untuk mempelajari lebih lanjut bisa cek dokumentasi tentang Fuse.js index .

Catatan

Sampai saat ini. kalian bisa tingkatkan sendiri tampilannya serta bisa juga tambahkan untuk fitur handle loading maupun penanganan error saat fetch. Selain menggunakan fetch dan useEffect kalian bisa juga gunakan alternatif lainnya seperti TanStack/React Query .

Kesimpulan

Kita telah belajar cara mengimplementasikan fitur search di aplikasi Astro.js menggunakan Fuse.js. Mulai Dari persiapan awal hingga pembuatan modal pencarian dan integrasi dengan alpine, hingga setup endpoint dan juga indexing pre-generate yang membantu dalam proses pencarian, terutama saat menangani data dalam jumlah besar.

Jangan ragu untuk mengupdate tampilan serta fungsionalitas pencarian sesuai dengan kebutuhan anda. Dengan dasar yang telah dibangun di sini, anda bisa mengeksplorasi lebih lanjut berbagai cara untuk meningkatkan fitur pencarian pada website anda.

Fuse.js merupakan tools yang fleksibel, ringan & simple. Hasil pencarian yg ditawarkan Fuse.js memang kadang tidak sempurna. Namun selama pencarian kita tidak membutuhkan akurasi yang tinggi, Fuse.js sudah lebih dari cukup.