🧑‍💻 Promptopia 开发笔记(1)——搜索功能的实现与最佳实践

date
Jun 13, 2024
slug
Promptopia-dev-note-search-implementation
status
Published
tags
Next.js
React
Website
summary
如何实现搜索功能?
type
Post

前言

为了练手 Nextjs,最近在写一个项目,大概长这样,主要就是做一个大家分享大模型 Prompt 的网站:
notion image
主要技术栈如下:
  1. 使用 nextjs 作为整体框架
  1. 使用next-auth/react 实现谷歌登录
  1. 使用 MongoDB 实现后端数据库(不得不感叹实在太方便了,直接就是一个在线服务,可以直接在 github page 这样的静态网站托管服务上部署网站,而且小体量还是免费的,良心企业)
  1. 使用tailwindCSS 写样式(这个项目和我之前写的 Portfolio 项目同样都用了这个,对于写样式来说实在是太方便了)

搜索功能实现

结合 MongoDB 来实现增删改还是比较方便的,但在实现搜索功能方面如果只需要搜索 prompt 内容和 tag 内容还是比较简单的,但还要涉及搜索 username ,而 prompt 的 Schema 里只有 userId,需要对两个表进行关联搜索,实现起来还是需要动点脑子的。

从搜索框开始考虑

首先我们来看这个输入框:
notion image
const handleSearchChange = e => {
    setSearchText(e.target.value)
  }

<form className="relative w-full flex-center" onSubmit={handleSubmit}>
  <input
    type="text"
    placeholder="Search for a tag or username"
    value={searchText}
    onChange={handleSearchChange}
    required
    className="search_input peer"
  />
</form>
我们已经通过 onChange 这个事件把输入框的内容获取到了 searchText 这个变量,因为我想做到的效果是响应式地对输入框的改变进行搜索,也就是你一边输入,一边结果就跟着出来,而不需要你输入回车后才能有相应,所以实际上就是一个依赖于 searchTextuseEffect 。总结而言,我们对输入框的需求如下:
  1. 针对输入内容响应式的发送请求;
  1. 按回车后立刻发送请求
有了这样的思路,我们很容易写出这样的代码:
useEffect(() => {
    if (searchText.trim()) {
    // 搜索文本不为空,进行搜索
      fetchSearchPosts()
    } else {
    // 搜索文本为空,重新获取所有数据
      fetchPosts()
    }
  }, [searchText])
但是我们不需要进行任何测试就可以轻易得出一个结论:这个体验并不好。因为我们响应式地针对searchText 的每一次改变都发送了查询请求,这并不合理,当我输入 Apple 时,你应该等我输入完成再发送请求,而不是每当我输入一个字母就发送一次请求。
因此我们实际上需要等你完成输入之后等个 1s 钟,确认你没有输入了,再发送请求,这个功能有一个学名——防抖
关于防抖
笔者在本科上嵌入式的时候在设计键盘驱动时提到过这个防抖,当时这个功能主要是因为键盘的按键在按下时,输入信号可能会出现(以开关为例)多次通断,如果不在驱动层面进行处理,就会出现“双击”甚至“三击”,对这个现象的处理方式也叫“防抖”,所以实际上防抖就是对短时间内重复输入的过滤
那么我们该如何进行防抖处理呢,我们可以想到这样的方式:
  1. 每次输入触发一个定时器,1s 后触发发送请求;
  1. 在触发请求之前清空上一次的定时器,避免重复触发。
对于如何清除上一次的定时器,我们可以在useEffect 中的第一个参数传入的回调函数中返回一个用于清空定时器的函数,这个回调函数会在以下情况调用:
  1. 依赖项变化时;
  1. 组件卸载时。
所以实际上我们只需要把清空定时器的函数返回就行了,不需要特意记录定时器的清空器;但还记得吗,我们还需要在用户输入回车后立即进行发送请求,所以实际上我们还是要记录最新的定时器钩子,方便我们在onSubmit 事件上清除最新的定时器,并立即发送请求,所以具体的实现代码如下:
// 处理防抖,同时记录最新的 timeoutId,便于清除
const [timeoutId, setTimeoutId] = useState(null)
const fetchSearchPosts = async () => {
  const response = await fetch('/api/prompt/search?searchText=' + searchText)
  const data = await response.json()
  setPosts(data)
}
useEffect(() => {
  if (searchText.trim()) {
    const newTimeout = setTimeout(() => {
      fetchSearchPosts()
    }, 1000)

    setTimeoutId(newTimeout)
    // 返回一个清除 timeout 的函数,会在下次 useEffect 调用/组件卸载时执行(so sweet)
    return () => clearTimeout(newTimeout)
  } else {
    fetchPosts()
    return () => clearTimeout(timeoutId)
  }
}, [searchText])

const handleSubmit = e => {
  e.preventDefault()
  clearTimeout(timeoutId)
  fetchSearchPosts()
}

搜索 api 实现

在实现了搜索框的响应式输入查询、防抖后,我们需要具体实现查询的功能,我们的需求如下:
  1. 基于 username 进行查询
  1. 基于 tag 进行查询
  1. 基于 prompt 的正文进行查询
数据库表设计如下:
// Prompt
import { Schema, model, models } from 'mongoose'

const PromptSchema = new Schema({
  creator: {
    type: Schema.Types.ObjectId,
    ref: 'User'
  },
  prompt: {
    type: String,
    required: [true, 'Prompt is required']
  },
  tag: {
    type: String,
    required: [true, 'Tag is required']
  }
})

const Prompt = models.Prompt || model('Prompt', PromptSchema)
export default Prompt
// User
import { Schema, model, models } from 'mongoose'

const UserSchema = new Schema({
  email: {
    type: String,
    unique: [true, 'Email already exists'],
    required: [true, 'Email is required']
  },
  username: {
    type: String,
    required: [true, 'Username is required'],
    match: [
      /^(?=.{8,20}$)(?![_.])(?!.*[_.]{2})[a-zA-Z0-9._]+(?<![_.])$/,
      'Username invalid, it should contain 8-20 alphanumeric letters and be unique!'
    ]
  },
  image: {
    type: String
  }
})

const User = models.User || model('User', UserSchema)

export default User
其实后两个都比较简单,主要是基于 username 的查询涉及关联查询,我的实现方式如下:
  1. 首先在 User 表中搜索符合条件的 username 对应的 id;
  1. 然后在 Prompt 表中使用 or 条件(或者 and 条件,看你想做什么,我这里为了搜索出来结果多一点就用 or 了)加上 id 的判断
具体实现如下:
import Prompt from '@/models/prompt'
import User from '@/models/user'
import { connectToDB } from '@/utils/database'

export const GET = async request => {
  const { searchParams } = new URL(request.url)
  const searchText = searchParams.get('searchText')

  try {
    await connectToDB()
    // 拆分关键词
    const keywordArray = searchText.split(' ').filter(kw => kw.trim() !== '')

    // 构建关键词表达式组
    const regexArray = keywordArray.map(kw => new RegExp(kw, 'i'))

    // 查询所有满足条件的用户
    const userConditions = regexArray.map(kw => ({ username: { $regex: kw } }))
    const users = await User.find({ $or: userConditions })
    const userIds = users.map(user => user._id)

    // 查询满足条件的prompts
    const prompts = await Prompt.find({
    // 用空格区分的关键词更合理的方式是用 and,但我这里先用 or 了,看起来显得多一点
      $or: [
        { prompt: { $in: regexArray } },
        { tag: { $in: regexArray } },
        { creator: { $in: userIds } }
      ]
    }).populate('creator') // populate 方法,自动关联 User 表,但是前提需要在表设计时使用
    // ref 指向你要关联的表

    return new Response(JSON.stringify(prompts), { status: 200 })
  } catch (error) {
    return new Response('Failed to fetch prompts', { status: 500 })
  }
}
这样我们就实现了搜索功能。

后谈——优雅的封装

我自诩为有代码洁癖(当然了,由于水平有限可能写出来的代码在别人看来也是💩,但至少我自己还要看的过去),之前实现的防抖逻辑全部都放在的 Feed 组件里,这并不优雅,Feed 组件应该只需要考虑这个组件内部的逻辑,但搜索这个功能其实跟 Feed 信息流没什么关系,只是这里有一个搜索框,而我们又需要调用搜索功能而已,所以我们不该把无关的功能全都堆在一个组件内部,这既不利于我们对搜索功能进行复用,也不利于别人理解我们的代码。所以实际上我们需要对这个搜索功能进行封装
在前端开发中,这种用于处理与组件状态、生命周期相关的逻辑,我们一般称其为 Hook,并使用 use 开头的函数对其进行封装,并组织在 src/hooks 这一文件夹中,所以我封装了以下 hook:
import { useState, useEffect } from 'react'

const useSearchPosts = (initialSearchText = '') => {
  const [searchText, setSearchText] = useState(initialSearchText)
  const [posts, setPosts] = useState([])
  const [timeoutId, setTimeoutId] = useState(null)

  // 获取所有 prompt
  const fetchPosts = async () => {
    const response = await fetch('/api/prompt')
    const data = await response.json()
    setPosts(data)
  }

  // 根据搜索关键词获取 prompt
  const fetchSearchPosts = async () => {
    const response = await fetch('/api/prompt/search?searchText=' + searchText)
    const data = await response.json()
    setPosts(data)
  }

  useEffect(() => {
    fetchPosts()
  }, [])

  // 防抖
  useEffect(() => {
    if (searchText.trim()) {
      const newTimeout = setTimeout(() => {
        fetchSearchPosts()
      }, 1000)

      setTimeoutId(newTimeout)
      return () => clearTimeout(newTimeout)
    } else {
      fetchPosts()
      return () => clearTimeout(timeoutId)
    }
  }, [searchText])

  const handleSearchChange = e => {
    setSearchText(e.target.value)
  }

  const handleSubmit = e => {
    e.preventDefault()
    clearTimeout(timeoutId)
    fetchSearchPosts()
  }

  return {
    searchText,
    posts,
    fetchPosts,
    handleSearchChange,
    handleSubmit
  }
}

export default useSearchPosts
并在 Feed 组件内调用,使用方式如下:
'use client'

import React, { useContext, useState, useEffect } from 'react'
import PromptCard from './PromptCard'
import basePathContext from '@/context/basePathContext'
import useSearchPosts from '@/hooks/useSearchPosts'

const PromptCardList = ({ data, handleTagClick }) => {
  return (
    <div className="mt-16 prompt_layout">
      {data.map(post => (
        <PromptCard key={post.id} post={post} handleTagClick={handleTagClick} />
      ))}
    </div>
  )
}

const Feed = ({ basePath }) => {
  const { searchText, posts, fetchPosts, handleSearchChange, handleSubmit } =
    useSearchPosts()

  useEffect(() => {
    fetchPosts()
  }, [])

  return (
    <basePathContext.Provider value={basePath}>
      <section className="feed">
        <form className="relative w-full flex-center" onSubmit={handleSubmit}>
          <input
            type="text"
            placeholder="Search for a tag or username"
            value={searchText}
            onChange={handleSearchChange}
            required
            className="search_input peer"
          />
        </form>

        <PromptCardList data={posts} handleTagClick={() => {}} />
      </section>
    </basePathContext.Provider>
  )
}

export default Feed

总结

跟着视频敲代码很简单,很多时候 youtuber 不解释他为什么这么写,你也不会去思考这个功能为什么要分这么多个文件?分这么多层?但一旦从头开始思考一个功能如何实现,需要思考的问题就很多了,比如我们实际上只是实现了一个非常简单的搜索功能,但本身涉及的有:
  1. 防抖功能的实现
  1. 搜索 api 的实现
  1. 自定义 Hook 的封装与调用

Wish you have a good day! 😇

© Musher 2019 - 2024

powered by nobelium