Next.js 14 使用 Fuse.js 實作站內搜尋功能


July 1, 2024 程式語言

Next.js 14 使用 Fuse.js 實作站內搜尋功能


介紹如何在 Next.js 專案中使用 Fuse.js 實作站內搜尋功能,包含預先建立 Json 檔以及根據使用者輸入查詢關鍵字動態呈現對應內容。

為何要使用模糊搜尋

🔗

要實作搜尋功能,最基礎的邏輯就是直接判斷輸入字串,回傳對應的資料。但這在實際使用上其實不太使用者友好,畢竟很常發生拼寫錯誤或是可能記不起完整的單詞或短語,只記得一部分,在這種情況下就會完全搜尋不到資料。

若是使用模糊查詢則可以有效解決,幫助識別同義詞和變體,使搜索更加靈活。這也是為什麼要選擇 Fuse.js。至於模糊搜尋演算法的部分則不是本篇的範圍。

實作模糊搜尋

🔗

下載Fuse.js

🔗
sh
npm install fuse.js

如何使用Fuse.js

🔗

範例:

JS
import 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);
};

使用方式:

  1. 建立所有能搜尋的資料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"
  }
]
  1. 設定搜尋的欄位keys: ["title", "description"]
  2. 代入查詢字串const results = fuse.search(value);
  3. 即能取得搜尋結果,非常容易使用。

以下為實作步驟:

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>
  );
}

詳細解釋:

  1. 已預先建立可查詢的文章資料import searchData from '@/lib/search-data.json';,所以只要引入直接帶入即可。
  2. import { Locale } from "../../../../i18n.config"; 因此專案有多語系功能,若無可忽略。
  3. 引入 nextui 與 搜尋的提示 Icon,可依自己喜好調整或是選擇不同的UI庫。
JS
import { Input, ScrollShadow } from "@nextui-org/react";
import { SearchIcon } from "./SearchIcon"; 

SearchIcon.jsx

JS
import 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,就能輕鬆解決模糊查詢的困難點。

參考

Next.jsReactWeb



Avatar

Alvin

軟體工程師,喜歡金融知識、健康觀念、心理哲學、自助旅遊與系統設計。

相關文章






留言區 (0)



  or   

尚無留言