Next.js 14 使用 Fuse.js 實作站內搜尋功能
July 1, 2024 程式語言
介紹如何在 Next.js 專案中使用 Fuse.js 實作站內搜尋功能,包含預先建立 Json 檔以及根據使用者輸入查詢關鍵字動態呈現對應內容。
為何要使用模糊搜尋
🔗要實作搜尋功能,最基礎的邏輯就是直接判斷輸入字串,回傳對應的資料。但這在實際使用上其實不太使用者友好,畢竟很常發生拼寫錯誤或是可能記不起完整的單詞或短語,只記得一部分,在這種情況下就會完全搜尋不到資料。
若是使用模糊查詢則可以有效解決,幫助識別同義詞和變體,使搜索更加靈活。這也是為什麼要選擇 Fuse.js。至於模糊搜尋演算法的部分則不是本篇的範圍。
實作模糊搜尋
🔗下載Fuse.js
🔗shnpm install fuse.js
如何使用Fuse.js
🔗範例:
JSimport Fuse from "fuse.js";
import React, { useState } from "react";
import searchData from '@/lib/search-data.json';
const posts = searchData;
const [input, setInput] = useState<any[]>([]);
const handleSearch = (event: any) => {
const { value } = event.target;
const fuse = new Fuse(posts, {
keys: ["title", "description"],
});
const results = fuse.search(value);
const items = results.map((result) => result.item);
setInput(items);
};
使用方式:
- 建立所有能搜尋的資料
const posts = searchData;
資料為 Json 格式 範例:
Json[
{
"id": "Implementing Multilingual i18n with Next.js 14",
"title": "Implementing Multilingual i18n with Next.js 14",
"slug": "tutorials/nextjs-i18n",
"date": "2024-06-22T23:44:38.000Z",
"draft": false,
"description": "Implementing Multilingual i18n with Next.js 14 and negotiator.",
"thumbnail": "img/thumbnail/056en.webp",
"categories": [
"Program"
],
"tags": [
"Next.js",
"React",
"Web"
],
"lang": "en"
}
]
- 設定搜尋的欄位
keys: ["title", "description"]
- 代入查詢字串
const results = fuse.search(value);
- 即能取得搜尋結果,非常容易使用。
以下為實作步驟:
1.在導覽列加入搜尋輸入框
🔗導覽列: Navbar.tsx
,部分代碼已省略,主要請參考 {/* Search bar */}
的區塊。
JS"use client"
import { Input, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure } from "@nextui-org/react";
import { SearchIcon } from "./SearchIcon";
import Search from "./Search";
export default function App({ params: { lang, layouti18n, categories, tags } }: {
params: { lang: Locale, layouti18n: any, categories: any, tags: any }
}) {
// Code omitted
const { isOpen, onOpen, onClose, onOpenChange } = useDisclosure();
return (
<Navbar className="bg-background text-foreground transition-colors duration-700 opacity-95" isMenuOpen={isMenuOpen} onMenuOpenChange={setIsMenuOpen}>
{/* Code omitted */}
<NavbarContent justify="end">
{/* Search bar */}
<Input
classNames={{
base: "hidden sm:block max-w-full sm:max-w-[10rem] h-10",
mainWrapper: "h-full",
input: "text-small",
inputWrapper: "h-full font-normal text-default-500 bg-default-400/20 dark:bg-default-500/20",
}}
placeholder="Type to search..."
size="sm"
startContent={<SearchIcon size={18} width={undefined} height={undefined} />}
type="search"
onClick={onOpen}
/>
<Modal className="dark:bg-gray-400"
isOpen={isOpen}
onOpenChange={onOpenChange}
size="2xl" backdrop="blur">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">{layouti18n.sitesearch}</ModalHeader>
<ModalBody>
<Search params={{ lang: lang, layout: layouti18n, onClose: onClose }} />
</ModalBody>
<ModalFooter>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</NavbarContent>
</Navbar>
);
}
詳細解釋:
主要就是在導覽列建立一個搜尋輸入欄,點選時會跳出 Modal 視窗。可以依據喜好做修改。
2.搜尋功能組件
🔗Search.tsx
JS"use client"
import React, { useState } from "react";
import Fuse from "fuse.js";
import searchData from '@/lib/search-data.json';
import { Locale } from "../../../../i18n.config";
import Image from "next/image";
import Link from "next/link";
import { Input, ScrollShadow } from "@nextui-org/react";
import { SearchIcon } from "./SearchIcon";
export default function Search({ params: { lang, layout, onClose } }: {
params: { lang: Locale, layout: any, onClose: any }
}) {
let posts = searchData;
posts = posts.filter((post: any) => post.lang === lang && post.draft === false);
const [input, setInput] = useState<any[]>([]);
const handleSearch = (event: any) => {
const { value } = event.target;
const fuse = new Fuse(posts, {
keys: ["title", "description"],
});
const results = fuse.search(value);
const items = results.map((result) => result.item);
setInput(items);
};
return (
<div>
<Input
onChange={handleSearch}
// label="Search"
isClearable
radius="lg"
classNames={{
label: "text-black/50 dark:text-white/90",
input: [
"text-xl",
"bg-transparent",
"text-black/90 dark:text-white/90",
"placeholder:text-default-700/50 dark:placeholder:text-white/60",
],
innerWrapper: "bg-transparent",
inputWrapper: [
"text-xl",
"shadow-xl",
"bg-default-200/50",
"dark:bg-default/60",
"backdrop-blur-xl",
"backdrop-saturate-200",
"hover:bg-default-200/70",
"dark:hover:bg-default/70",
"group-data-[focus=true]:bg-default-200/50",
"dark:group-data-[focus=true]:bg-default/60",
"!cursor-text",
],
}}
placeholder="Type to search..."
startContent={
<SearchIcon className="text-black/50 mb-0.5 dark:text-white/90 text-slate-400 pointer-events-none flex-shrink-0" />
}
/>
<table>
<tbody>
<ScrollShadow hideScrollBar className="w-full h-96 mt-4">
<ul className="space-y-4 max-w-screen-sm w-full">
{input.map(({ id, title, date, draft, thumbnail, categories, tags, slug, filePath, description }: any, index: number) => (
<li key={index} className="border-b border-gray-200 pb-4 flex flex-col md:flex-row md:min-h-40 relative transition-transform duration-300 transform hover:-translate-y-1">
{thumbnail &&
<div className="min-w-full md:min-w-48 h-auto md:w-48 md:h-32 ">
<Link href={'/' + lang + '/' + slug} onClick={onClose}>
<Image src={'/' + thumbnail} width={500}
height={500} alt={title} className="w-full h-52 md:w-44 md:h-full rounded-md" />
</Link>
</div>
}
<div className="flex-grow">
<div className="min-h-28">
<h2 className="text-lg font-normal text-textcolor break-normal md:line-clamp-2">
<Link href={'/' + lang + '/' + slug} onClick={onClose}>
{title}
</Link>
</h2>
<div className="flex flex-wrap text-sm justify-start">
<p className="flex items-center text-gray-500 mr-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="size-6 mr-2">
<path strokeLinecap="round" strokeLinejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" />
</svg>
{
date instanceof Date ?
date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) :
new Date(date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
}
</p>
<p className="flex items-center text-gray-500">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="size-6 mr-2">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z" />
</svg>
{categories.map((category: string, index: number) => (
<span key={index} className="inline-block">
{category}{index < categories.length - 1 ? ' ,' : ''}
</span>
))}
</p>
</div>
<p className="text-base text-gray-500 line-clamp-2">{description}</p>
</div>
<div className="text-right"> {/* Apply absolute positioning here */}
<Link href={'/' + lang + '/' + slug} className="p-0" onClick={onClose}>
<button role="link" className="relative after:absolute after:bottom-0 after:left-0 after:h-[0.5px] after:w-full after:origin-bottom-left after:scale-x-100 after:bg-neutral-800 after:transition-transform after:duration-150 after:ease-in-out hover:after:origin-bottom-right hover:after:scale-x-0 dark:after:bg-white">
{layout.readmore}
</button>
</Link>
</div>
</div>
</li>
))}
</ul>
</ScrollShadow>
</tbody>
</table>
</div>
);
}
詳細解釋:
- 已預先建立可查詢的文章資料
import searchData from '@/lib/search-data.json';
,所以只要引入直接帶入即可。 import { Locale } from "../../../../i18n.config";
因此專案有多語系功能,若無可忽略。- 引入 nextui 與 搜尋的提示 Icon,可依自己喜好調整或是選擇不同的UI庫。
JSimport { Input, ScrollShadow } from "@nextui-org/react";
import { SearchIcon } from "./SearchIcon";
SearchIcon.jsx
JSimport React from "react";
export const SearchIcon = ({
size = 24,
strokeWidth = 1.5,
width,
height,
...props
}) => (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={height || size}
role="presentation"
viewBox="0 0 24 24"
width={width || size}
{...props}
>
<path
d="M11.5 21C16.7467 21 21 16.7467 21 11.5C21 6.25329 16.7467 2 11.5 2C6.25329 2 2 6.25329 2 11.5C2 16.7467 6.25329 21 11.5 21Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={strokeWidth}
/>
<path
d="M22 22L20 20"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={strokeWidth}
/>
</svg>
);
結論
🔗成果展示
🔗以上即可輕易的實作網站搜尋功能,此篇主要紀錄如何使用 Fuse.js 的過程,因代碼也包含個人專案的其他功能,包含多語系與使用的 UI 庫,若不易閱讀,請多多包涵。
原本舊網站是使用 Algolia 提供的搜尋功能,但就會有搜尋次數的限制,現在改為使用 Fuse.js,就能輕鬆解決模糊查詢的困難點。
參考
Alvin
軟體工程師,喜歡金融知識、健康觀念、心理哲學、自助旅遊與系統設計。
相關文章
留言區 (0)
尚無留言