🧑💻 Promptopia 开发笔记(1)——搜索功能的实现与最佳实践
date
Jun 13, 2024
slug
Promptopia-dev-note-search-implementation
status
Published
tags
Next.js
React
Website
summary
如何实现搜索功能?
type
Post
前言
为了练手 Nextjs,最近在写一个项目,大概长这样,主要就是做一个大家分享大模型 Prompt 的网站:
主要技术栈如下:
- 使用 nextjs 作为整体框架
- 使用
next-auth/react
实现谷歌登录
- 使用
MongoDB
实现后端数据库(不得不感叹实在太方便了,直接就是一个在线服务,可以直接在 github page 这样的静态网站托管服务上部署网站,而且小体量还是免费的,良心企业)
- 使用
tailwindCSS
写样式(这个项目和我之前写的 Portfolio 项目同样都用了这个,对于写样式来说实在是太方便了)
搜索功能实现
结合
MongoDB
来实现增删改还是比较方便的,但在实现搜索功能方面如果只需要搜索 prompt 内容和 tag 内容还是比较简单的,但还要涉及搜索 username ,而 prompt 的 Schema 里只有 userId,需要对两个表进行关联搜索,实现起来还是需要动点脑子的。从搜索框开始考虑
首先我们来看这个输入框:
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
这个变量,因为我想做到的效果是响应式地对输入框的改变进行搜索,也就是你一边输入,一边结果就跟着出来,而不需要你输入回车后才能有相应,所以实际上就是一个依赖于 searchText
的 useEffect
。总结而言,我们对输入框的需求如下:- 针对输入内容响应式的发送请求;
- 按回车后立刻发送请求
有了这样的思路,我们很容易写出这样的代码:
useEffect(() => {
if (searchText.trim()) {
// 搜索文本不为空,进行搜索
fetchSearchPosts()
} else {
// 搜索文本为空,重新获取所有数据
fetchPosts()
}
}, [searchText])
但是我们不需要进行任何测试就可以轻易得出一个结论:这个体验并不好。因为我们响应式地针对
searchText
的每一次改变都发送了查询请求,这并不合理,当我输入 Apple 时,你应该等我输入完成再发送请求,而不是每当我输入一个字母就发送一次请求。因此我们实际上需要等你完成输入之后等个 1s 钟,确认你没有输入了,再发送请求,这个功能有一个学名——防抖。
关于防抖
笔者在本科上嵌入式的时候在设计键盘驱动时提到过这个防抖,当时这个功能主要是因为键盘的按键在按下时,输入信号可能会出现(以开关为例)多次通断,如果不在驱动层面进行处理,就会出现“双击”甚至“三击”,对这个现象的处理方式也叫“防抖”,所以实际上防抖就是对短时间内重复输入的过滤。
那么我们该如何进行防抖处理呢,我们可以想到这样的方式:
- 每次输入触发一个定时器,1s 后触发发送请求;
- 在触发请求之前清空上一次的定时器,避免重复触发。
对于如何清除上一次的定时器,我们可以在
useEffect
中的第一个参数传入的回调函数中返回一个用于清空定时器的函数,这个回调函数会在以下情况调用:- 依赖项变化时;
- 组件卸载时。
所以实际上我们只需要把清空定时器的函数返回就行了,不需要特意记录定时器的清空器;但还记得吗,我们还需要在用户输入回车后立即进行发送请求,所以实际上我们还是要记录最新的定时器钩子,方便我们在
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 实现
在实现了搜索框的响应式输入查询、防抖后,我们需要具体实现查询的功能,我们的需求如下:
- 基于 username 进行查询
- 基于 tag 进行查询
- 基于 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 的查询涉及关联查询,我的实现方式如下:
- 首先在 User 表中搜索符合条件的 username 对应的 id;
- 然后在 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 不解释他为什么这么写,你也不会去思考这个功能为什么要分这么多个文件?分这么多层?但一旦从头开始思考一个功能如何实现,需要思考的问题就很多了,比如我们实际上只是实现了一个非常简单的搜索功能,但本身涉及的有:
- 防抖功能的实现
- 搜索 api 的实现
- 自定义 Hook 的封装与调用
Wish you have a good day! 😇