使用 RxJS 实现可靠的异步搜索框
本文源于张乐聪同学的分享,在原有代码基础上进行了一些修改
前言
异步搜索框是一个业务中非常常见的诉求,但是想实现一个可靠的异步搜索框却不是一个简单的任务,为了使其可靠(性能好 + Bug 少 + 体验好 + 易维护),实现者需要考虑非常多的方面.
异步搜索框的难点
- 针对于搜索做 debounce 操作,在用户的输入过程中不立即搜索(性能好,节省网络资源)
- 对于输入为空的时候不进行 debounce(体验好,从有搜索内容到无搜索内容立即响应)
- 对于 debounce 后的输入去重,不发送重复请求,例如从
a -> ab(debounce 掉,不发送)-> a,可能对 a 发送两次搜索请求(性能好,节省网络资源) - 正确处理时序,不要被早发送的请求响应覆盖晚发送的请求响应(体验好)
- 正确处理异常(体验好)
- 正确处理 loading,只要还有请求没有返回就维持 loading(体验好)
- 在正确实现之前所有需求的前提下维持实现的可维护性(易维护 + 不容易出 Bug)
常见实现的问题
function SearchBox() {
const [result, setResult] = useState("")
const handleInput = (e) => {
const value = e.target.value
request(value).then((response) => {
setResult(response.data)
})
}
return (
<>
<input onChange={handleInput} />
{result}
</>
)
}
这种实现最典型的问题是时序问题不能被正确的处理,没有个先来后到的讲究,谁来谁覆盖。 因此要进行处理的话要么维持发送时间,要么记下来发送的内容,来确保响应可以和请求匹配。
// 产生 4000 2000 1000 4000 ...
const random2 = (function () {
let i = 1
const array = [4000, 2000, 1000]
return () => {
return array[i++ % array.length]
}
})()
function request(value) {
return new Promise<{
data: string
}>((resolve) => {
setTimeout(() => {
resolve({
data: value,
})
}, random2())
})
}
function SearchBox() {
const [result, setResult] = useState("")
const latestRequestTimeRef = useRef(0)
const handleInput = (e) => {
const value = e.target.value
const requestTime = Date.now() // 记录时间
latestRequestTimeRef.current = requestTime
request(value).then((response) => {
if (requestTime >= latestRequestTimeRef.current) {
// 对比时间
setResult(response.data)
}
})
}
return (
<>
<input onChange={handleInput} />
{result}
</>
)
}
如果涉及 debounce,通常我们都会直接使用工具函数比如 lodash 的 debounce,它无法实现条件 debounce,因此我们需要自己专门实现。
即使过了这关,在后续的 error、loading 处理中,你会发现,所有的代码都挤在 handleInput 中,状态相互纠缠。不光可靠性难以保证、持续维护的难度也会越来越大。
可靠实现的难度在哪?
如果你有一些编写异步操作的经验,会发现每增加一个 feature 都需要维护一些状态、并且由于逻辑关联,会和原有的逻辑搅在一起,就像一个线团一样。在没有高层次抽象的情况下,很难将不同的异步 feature 进行隔离。随着功能的增多,这个线团越来越大、越来越乱,直到艰难维护、崩溃、重写或者消亡。
所以解决问题的一个思路就是:将不同的 feature 以解耦、内聚的形式实现,相互独立,各自维护,再统一串联。
1. 引入 RxJS 做一个输入的流
通过制造一个流,在输入值改变的时候向流发送数据,并监听这个流,可以将输入内容实时的同步在页面上。
function SearchBox() {
const [result, setResult] = useState("")
// 下面的 BehaviorSubject 和 Subject 一模一样,除了有一个初始值会在订阅时立刻发出
const input$ = useMemo(() => new BehaviorSubject(""), [])
// 输入内容时向流发送值
const handleInput = (e) => {
input$.next(e.target.value)
}
useEffect(() => {
// 订阅这个流
const subscription = input$.subscribe((v) => {
setResult(v)
})
return () => {
// 组件卸载时取消订阅
subscription.unsubscribe()
}
}, [])
return (
<>
<input onChange={handleInput} />
{result}
</>
)
}
2. debounce
第一步我们先进行 debounce 的实现,在搜索值为空的时候立即响应,其他情况下 debounce: 我们利用 debounce 操作符,在输入值为空字符串的时候立马发送值,在输入不为空的时候等待 500ms 再发送值。
function SearchBox() {
const [result, setResult] = useState("")
// 下面的 BehaviorSubject 和 Subject 一模一样,除了有一个初始值会在订阅时立刻发出
const input$ = useMemo(() => new BehaviorSubject(""), [])
// 输入内容时向流发送值
const handleInput = (e) => {
input$.next(e.target.value)
}
useEffect(() => {
const subscription = input$
.pipe(
// 防抖的实现 -----------------------------
debounce((input) => {
if (input.length === 0) {
return of(null) // 立即响应
} else {
return timer(500) // 等待 500ms
}
})
)
.subscribe((v) => {
setResult(v)
})
return () => {
subscription.unsubscribe()
}
}, [])
return (
<>
<input onChange={handleInput} />
{result}
</>
)
}
3. 去重
function SearchBox() {
const [result, setResult] = useState("")
// 下面的 BehaviorSubject 和 Subject 一模一样,除了有一个初始值会在订阅时立刻发出
const input$ = useMemo(() => new BehaviorSubject(""), [])
// 输入内容时向流发送值
const handleInput = (e) => {
input$.next(e.target.value)
}
useEffect(() => {
const subscription = input$
.pipe(
// 防抖的实现 -----------------------------
debounce((input) => {
if (input.length === 0) {
return of(null) // 立即响应
} else {
return timer(500) // 等待 500ms
}
}),
distinctUntilChanged()
)
.subscribe((v) => {
setResult(v)
})
return () => {
subscription.unsubscribe()
}
}, [])
return (
<>
<input onChange={handleInput} />
{result}
</>
)
}
4. 网络请求 + 时序处理
Rxjs 提供了 switchMap 操作符来完成 Promise 到值的解包过程和异步时序控制能力。switchMap 可以将一个流映射为新的流,我们可以将一个文本流通过 Promise 映射为一个文本流到 Promise resolve 结果的流,同时 switchMap 还有一个特殊的能力就是会丢弃掉比最新输入发起时间晚到的值:
// 产生 4000 2000 1000 4000 ...
const random2 = (function () {
let i = 1
const array = [4000, 2000, 1000]
return () => {
return array[i++ % array.length]
}
})()
function request(value) {
return new Promise<{
data: string
}>((resolve) => {
setTimeout(() => {
resolve({
data: value,
})
}, random2())
})
}
function SearchBox() {
const [result, setResult] = useState("")
// 下面的 BehaviorSubject 和 Subject 一模一样,除了有一个初始值会在订阅时立刻发出
const input$ = useMemo(() => new BehaviorSubject(""), [])
// 输入内容时向流发送值
const handleInput = (e) => {
input$.next(e.target.value)
}
useEffect(() => {
const subscription = input$
.pipe(
// 防抖的实现 -----------------------------
debounce((input) => {
if (input.length === 0) {
return of(null) // 立即响应
} else {
return timer(500) // 等待 500ms
}
}),
distinctUntilChanged(),
// 网络请求的实现 -----------------------------
switchMap((input) => {
return request(input) // 取最新开发发起的结果
})
)
.subscribe((v) => {
setResult(v.data)
})
return () => {
subscription.unsubscribe()
}
}, [])
return (
<>
<input onChange={handleInput} />
{result}
</>
)
}
5. Loading + 异常处理
function fetcher(input: string): Promise<{
value: string
error: boolean
}> {
return new Promise<string>((resolve, reject) => {
setTimeout(() => {
if (Math.random() > 0.5) reject()
resolve("api " + input)
}, Math.random() * 1000)
})
.then((res) => {
return {
value: res,
error: false,
}
})
.catch(() => {
return {
value: "",
error: true,
}
})
}
function SearchBox() {
const [result, setResult] = useState("")
// 下面的 BehaviorSubject 和 Subject 一模一样,除了有一个初始值会在订阅时立刻发出
const input$ = useMemo(() => new BehaviorSubject(""), [])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(false)
const errorRef = useRef<boolean>(false)
errorRef.current = error
// 输入内容时向流发送值
const handleInput = (e) => {
input$.next(e.target.value)
}
useEffect(() => {
const subscription = input$
.pipe(
// 防抖的实现 -----------------------------
debounce((input) => {
// 补充 error 处理
if (input.length === 0 || errorRef.current) {
return of(null) // 立即响应
} else {
return timer(500) // 等待 500ms
}
}),
distinctUntilChanged(),
// 网络请求的实现 -----------------------------
switchMap((input) => {
if (input.length === 0) {
setLoading(false)
setError(false)
return of({
value: "default",
error: false,
})
}
setError(false)
setLoading(true)
return fetcher(input)
})
)
.subscribe({
next: ({ error, value }) => {
if (error) {
setError(true)
setLoading(false)
} else {
setError(false)
setLoading(false)
setResult(value)
}
},
})
return () => {
subscription.unsubscribe()
}
}, [])
return (
<>
<div>
<input onChange={handleInput} />
</div>
{error ? "error" : loading ? "loading" : result}
</>
)
}