简体中文
文章标题

在 Next.js 应用中为导航栏添加带智能定位的搜索功能

本文档详细记录了如何在 Next.js 项目中,为导航栏(Navigation Bar)添加一个具有本地搜索能力的搜索框,并特别优化了其在移动设备上的显示效果,使其能够智能地调整下拉结果列表的位置。同时也包含了生成所需本地搜索数据的方法。

前提条件

  • 你已经有一个基于 Next.js 的项目。
  • 你了解基本的 React 和 TypeScript/JavaScript。
  • 项目中已安装并配置好 clsxnext/link
  • (推荐) 项目使用 next-mdx-remote 或类似工具处理 Markdown/MDX 文件,以便从中提取数据。或者,你有其他方式组织你的网站内容数据。

步骤

第一部分:生成本地搜索数据 (search-data.js)

为了实现客户端本地搜索,我们需要先准备好一份包含所有可搜索项的数据文件。这份数据通常来源于你的网站内容,比如博客文章、页面等。

方案 A: 使用 getStaticProps 在构建时生成 (适用于 Next.js Pages Router)

  1. 收集内容数据:

    • getStaticProps (通常在 pages/index.tsx 或一个专用的数据生成页面) 中,遍历你的内容源(如 posts 目录下的 MDX 文件)。
    • 对于每个内容项,提取你需要的信息,如 title, slug (或最终的 url)。
    // pages/index.tsx 或 lib/generate-search-data.ts (辅助脚本)
    import fs from 'fs';
    import path from 'path';
    import matter from 'gray-matter'; // 用于解析 frontmatter
    import { serialize } from 'next-mdx-remote/serialize';
    
    // 假设你的博客文章在 posts 目录下
    const postsDirectory = path.join(process.cwd(), 'posts');
    
    export async function getStaticProps() {
      const filenames = fs.readdirSync(postsDirectory);
    
      const allPostsData = filenames.map((filename) => {
        const filePath = path.join(postsDirectory, filename);
        const fileContents = fs.readFileSync(filePath, 'utf8');
    
        // 使用 gray-matter 解析 frontmatter
        const { data } = matter(fileContents);
    
        // 假设你的 slug 是文件名去掉扩展名
        const slug = filename.replace(/\.mdx?$/, '');
    
        // 返回用于搜索的数据对象
        return {
          title: data.title, // 假设 frontmatter 中有 title 字段
          url: `/blog/${slug}`, // 构造最终 URL
          // 可以添加 excerpt, tags 等其他字段用于更丰富的搜索
        };
      });
    
      // 也可以包含静态页面
      const staticPages = [
        { title: '首页', url: '/' },
        { title: '关于', url: '/about' },
        // ... 其他静态页面
      ];
    
      // 合并所有数据
      const searchData = [...staticPages, ...allPostsData];
    
      // 1. 将数据写入文件
      const outputPath = path.join(process.cwd(), 'lib', 'search-data.js');
      const fileContent = `export const searchData = ${JSON.stringify(searchData, null, 2)};`;
      fs.writeFileSync(outputPath, fileContent);
    
      // 2. 或者将数据作为 props 传递下去 (如果需要在页面上显示)
      return {
        props: {
          // posts: allPostsData, // 如果需要在主页显示文章列表
        },
      };
    }
    
    export default function Home(/* { posts } */) {
      // ...
    }
    
  2. 运行构建或开发服务器:

    • 当你运行 npm run devnpm run build 时,getStaticProps 会被执行,从而生成 lib/search-data.js 文件。

方案 B: 使用 Node.js 脚本独立生成

如果你不想在每次构建时都执行此操作,或者使用的是 App Router,可以创建一个独立的 Node.js 脚本来生成这个文件。

  1. 创建脚本文件:

    # 在项目根目录或 scripts 目录下创建 generate-search-data.js
    touch scripts/generate-search-data.js
    
  2. 编写脚本:

    • 脚本内容与 getStaticProps 中的部分相似,但它是独立运行的。
    // scripts/generate-search-data.js
    const fs = require('fs');
    const path = require('path');
    const matter = require('gray-matter'); // 需要安装: npm install gray-matter
    
    const postsDirectory = path.join(__dirname, '../posts'); // Adjust path as needed
    const outputDirectory = path.join(__dirname, '../lib'); // Adjust path as needed
    const outputFile = path.join(outputDirectory, 'search-data.js');
    
    function generateSearchData() {
      if (!fs.existsSync(postsDirectory)) {
        console.warn(`Posts directory not found: ${postsDirectory}`);
        return [];
      }
    
      const filenames = fs.readdirSync(postsDirectory);
    
      const allPostsData = filenames
        .filter(
          (filename) => filename.endsWith('.md') || filename.endsWith('.mdx')
        ) // Filter for markdown files
        .map((filename) => {
          const filePath = path.join(postsDirectory, filename);
          const fileContents = fs.readFileSync(filePath, 'utf8');
    
          const { data } = matter(fileContents);
          const slug = filename.replace(/\.mdx?$/, '');
          return {
            title: data.title || slug, // Fallback to slug if no title
            url: `/blog/${slug}`,
            // Add other fields like excerpt if available in frontmatter
            // excerpt: data.excerpt || '',
            // tags: data.tags || [],
          };
        });
    
      const staticPages = [
        { title: '首页', url: '/' },
        { title: '关于', url: '/about' },
        // ... add more static pages
      ];
    
      const searchData = [...staticPages, ...allPostsData];
      return searchData;
    }
    
    function writeSearchDataToFile(data) {
      const fileContent = `export const searchData = ${JSON.stringify(data, null, 2)};\n`; // Add newline at the end
    
      // Ensure output directory exists
      if (!fs.existsSync(outputDirectory)) {
        fs.mkdirSync(outputDirectory, { recursive: true });
      }
    
      fs.writeFileSync(outputFile, fileContent, 'utf8');
      console.log(`Search data generated successfully at ${outputFile}`);
    }
    
    // Run the script
    const data = generateSearchData();
    writeSearchDataToFile(data);
    
  3. 添加 NPM Script:

    • package.json 中添加一个脚本命令,方便运行。
    {
      "scripts": {
        // ... existing scripts
        "generate-search-data": "node scripts/generate-search-data.js"
      }
    }
    
  4. 运行脚本:

    • 执行 npm run generate-search-data 来生成 lib/search-data.js 文件。

定义搜索数据接口 (TypeScript)

为了更好的类型安全,可以在 lib/search-data.ts (或 .d.ts) 中定义接口。

// lib/search-data.ts (or .d.ts for just types)
export interface SearchItem {
  title: string;
  url: string;
  // Optional: Add other fields for richer search (if included in generation)
  // excerpt?: string;
  // tags?: string[];
}

// This will be populated by the generation script
export const searchData: SearchItem[] = []; // Initial empty array or placeholder

创建一个新的组件文件,例如 components/navigations/NavSearchBar.tsx

// components/navigations/NavSearchBar.tsx
'use client';

import clsx from 'clsx';
import Link from 'next/link';
import { useEffect, useRef, useState } from 'react';
import { searchData } from '@/lib/search-data'; // 确保路径正确

interface SearchResult {
  title: string;
  url: string;
  // 如果 search-data.js 中还有其他字段,可以在这里添加类型定义
  // excerpt?: string;
  // tags?: string[];
}

interface NavSearchBarProps {
  quickAccessButtonRef?: React.RefObject<HTMLButtonElement>; // 可选的 ref prop
}

export default function NavSearchBar({
  quickAccessButtonRef,
}: NavSearchBarProps) {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<SearchResult[]>([]);
  const [loading, setLoading] = useState(false);
  const [showResults, setShowResults] = useState(false);

  const searchInputRef = useRef<HTMLInputElement>(null);
  const dropdownRef = useRef<HTMLUListElement>(null);

  const [isDropdownAlignedLeft, setIsDropdownAlignedLeft] = useState(false);
  const [dropdownWidth, setDropdownWidth] = useState<number | null>(null);

  const calculateWidthAndPosition = () => {
    if (!searchInputRef.current) {
      if (searchInputRef.current) {
        const inputWidth = searchInputRef.current.offsetWidth || 0;
        setDropdownWidth(inputWidth + 20);
        setIsDropdownAlignedLeft(false);
      } else {
        setDropdownWidth(null);
        setIsDropdownAlignedLeft(false);
      }
      return;
    }

    try {
      const inputWidth = searchInputRef.current.offsetWidth || 0;
      const buttonWidth = quickAccessButtonRef?.current?.offsetWidth || 0;
      const totalWidth = inputWidth + buttonWidth;
      const finalWidth = totalWidth > 0 ? totalWidth + 2 : null;
      setDropdownWidth(finalWidth);

      if (typeof window !== 'undefined') {
        const MOBILE_BREAKPOINT = 768; // Tailwind's 'md' breakpoint
        const viewportWidth = window.innerWidth;

        // 强制移动端向左对齐
        if (viewportWidth <= MOBILE_BREAKPOINT) {
          setIsDropdownAlignedLeft(true);
          return;
        }

        // 桌面端智能判断
        const inputRect = searchInputRef.current.getBoundingClientRect();
        const estimatedDropdownWidth =
          finalWidth ?? Math.min(viewportWidth * 0.8, 320);
        const spaceOnRight = viewportWidth - inputRect.right;
        const shouldAlignLeft = spaceOnRight < estimatedDropdownWidth;

        setIsDropdownAlignedLeft(shouldAlignLeft);
      } else {
        setIsDropdownAlignedLeft(false);
      }
    } catch (err) {
      console.error('计算搜索下拉框宽度失败:', err);
      setDropdownWidth(null);
      setIsDropdownAlignedLeft(false);
    }
  };

  useEffect(() => {
    calculateWidthAndPosition();
    const handleResize = () => calculateWidthAndPosition();
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, [quickAccessButtonRef]);

  useEffect(() => {
    if (!query.trim()) {
      setResults([]);
      setShowResults(false);
      return;
    }
    setLoading(true);

    const timer = setTimeout(() => {
      try {
        const filteredResults = searchData
          .filter(
            (item) => item.title.toLowerCase().includes(query.toLowerCase())
            // 可选:扩展搜索范围
            // || (item.excerpt && item.excerpt.toLowerCase().includes(query.toLowerCase()))
            // || (item.tags && item.tags.some(tag => tag.toLowerCase().includes(query.toLowerCase())))
          )
          .slice(0, 10); // 限制返回结果数量

        setResults(filteredResults);
        setShowResults(true);
      } catch (err) {
        console.error('本地搜索处理失败:', err);
        setResults([]);
      } finally {
        setLoading(false);
      }
    }, 150); // 防抖时间

    return () => clearTimeout(timer);
  }, [query]);

  return (
    <div className="relative flex items-center">
      <input
        ref={searchInputRef}
        type="text"
        placeholder="🔍"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        onFocus={() => query && results.length > 0 && setShowResults(true)}
        className={clsx(
          'rounded-lg border bg-transparent px-3 py-1.5 text-sm outline-none transition-all',
          'border-slate-300 focus:border-violet-500 focus:ring-1 focus:ring-violet-500',
          'dark:border-slate-600 dark:focus:border-violet-400 dark:focus:ring-violet-400',
          query ? 'w-24 sm:w-40' : 'w-10',
          'min-w-0 transition-[width]'
        )}
      />
      {showResults && (results.length > 0 || loading) && (
        <ul
          ref={dropdownRef}
          className={clsx(
            'absolute z-50 mt-1 max-h-60 overflow-y-auto rounded-lg border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-900',
            'w-max min-w-[120px] max-w-xs text-left'
          )}
          style={{
            left: isDropdownAlignedLeft ? 'auto' : '0',
            right: isDropdownAlignedLeft ? '0' : 'auto',
            top: '100%',
          }}
        >
          {loading ? (
            <li className="px-4 py-2 text-sm text-gray-500">搜索中...</li>
          ) : results.length === 0 ? (
            <li className="px-4 py-2 text-sm text-gray-500">无结果</li>
          ) : (
            results.map((item) => (
              <li key={item.url}>
                <Link
                  href={item.url}
                  className="block px-4 py-2 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-800"
                  title={item.title}
                  onClick={() => {
                    setShowResults(false);
                    setQuery('');
                  }}
                >
                  {item.title}
                </Link>
              </li>
            ))
          )}
        </ul>
      )}
      {showResults && (
        <div
          className="fixed inset-0 z-40"
          onClick={() => setShowResults(false)}
        />
      )}
    </div>
  );
}

第三部分:在导航栏中集成组件

在你的主导航栏组件中引入并使用 NavSearchBar

// components/navigations/Navbar.tsx (示例)
import NavSearchBar from './NavSearchBar'; // 确保路径正确

export default function Navbar() {
  // ... 其他导航栏逻辑 ...

  return (
    <nav className="...">
      {/* ... 其他导航元素 ... */}
      <NavSearchBar />
      {/* ... 其他导航元素 ... */}
    </nav>
  );
}

第四部分:(可选)优化与父级元素的对齐

如果 NavSearchBar 需要与其旁边的按钮(如快速访问按钮)对齐并共享宽度,请传递相应的 ref

// components/navigations/Navbar.tsx (示例 - 优化版)
import { useRef } from 'react';
import NavSearchBar from './NavSearchBar';
import SomeQuickAccessButton from './SomeQuickAccessButton';

export default function Navbar() {
  const quickAccessButtonRef = useRef<HTMLButtonElement>(null);

  return (
    <nav className="...">
      {/* ... 其他导航元素 ... */}
      <div className="flex items-center">
        <NavSearchBar quickAccessButtonRef={quickAccessButtonRef} />
        <SomeQuickAccessButton ref={quickAccessButtonRef} />
      </div>
      {/* ... 其他导航元素 ... */}
    </nav>
  );
}

功能亮点

  • 客户端本地搜索: 不依赖外部 API,速度快。
  • 响应式搜索框: 输入时变宽,无输入时变窄。
  • 智能下拉定位: 桌面端根据空间自动左右对齐;移动端强制向左对齐,充分利用屏幕空间。
  • 自适应宽度: 下拉菜单宽度根据内容自动调整,最大宽度受控。
  • 用户体验优化: 点击外部区域关闭下拉菜单;链接带有 title 提示;结果限制数量;加载状态提示。
  • TypeScript 支持: 代码结构清晰,类型安全。
  • 灵活的数据生成: 提供了两种生成搜索数据文件的方法。

注意事项

  • 确保 search-data.js/ts 文件路径正确。
  • MOBILE_BREAKPOINT 的值可以根据你的项目设计断点进行调整。
  • 如果搜索数据量很大,可能需要更复杂的算法或索引机制来提高性能。
  • 样式(如颜色、圆角、阴影)使用了 Tailwind CSS 类名,可根据你的 UI 设计系统进行替换。
  • 生成数据的脚本或逻辑需要根据你实际的内容结构和存放方式进行调整。
文章在技术分类中;
0
0
0
0