异步搜索框是一个业务中非常常见的诉求,但是想实现一个可靠的异步搜索框却不是一个简单的任务,为了使其可靠(性能好 + 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}
</>
}
这种实现最典型的问题是时序问题不能被正确的处理,没有个先来后到的讲究,谁来谁覆盖。
因此要进行处理的话要么维持发送时间,要么记下来发送的内容,来确保响应可以和请求匹配。
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 以解耦、内聚的形式实现,相互独立,各自维护,再统一串联。
rxjs 极速入门
流
出于简化理解,我们可以将 rxjs 理解为一个处理流的工具。
那么什么是流呢,流可以理解为一个随着时间发展不断发出值的对象,举例来说,我们可以把秒表理解为一个流:
1, 2, 3, 4, 5...
秒表流的内容随着时间发展发出值。
流可以被监听: 假如我们监听这个流,那么会在
第 1 秒收到 1,
第 2 秒收到 2,
第 3 秒收到 3,
...
流可以被转换: 假如我们在监听到这个流之后,只在奇数秒的时候报时,那么我们就是一个新的流
第 1 秒发出 1,
第 3 秒发出 2,
第 5 秒发出 5
用 rxjs 制造一个流
那么流长什么样子呢?我们用 rxjs 造一个:
import { Subject } from 'rxjs'
const timer$ = new Subject()
timer$ 就是一个我们说的流(你可能会好奇 Subject 是什么,Subject 是一个可以接受信息并将其广播出去的对象)。
下面我们让 timer$ 流开始运转,每秒往里塞一个值:
let value = 0
setInterval(() => {
timer$.next(++value)
}, 1000)
如果此时我们监听这个流,就可以每秒打印一个值了:
timer$.subscribe(v => { console.log(v) }) // 1, 2, 3, 4...
用 rxjs 转换一个流
Rxjs 提供了一些“操作符”,可以通过“操作符”去将一个流转化为另一个流,例如我们只保留奇数秒,并且往后延迟一秒发出:
import { filter } from 'rxjs'
const oddTimer$ = timer$.pipe(
filter(v => v % 2 === 1), // 过滤掉偶数秒
delay(1000) // 延迟 1000 ms
)
filter:
delay:
如果此时我们监听这个流,就可以每偶数秒打印一个奇数值了:
oddTimer$.subscribe(v => { console.log(v) }) // 1, 3, 5...
制作一个 input 流
利用我们之间讲到的内容,可以在应用中制作一个输入的流:
import { BehaviorSubject } from '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}
</>
}
通过制造一个流,在输入值改变的时候向流发送数据,并监听这个流,可以将输入内容实时的同步在页面上。
实现异步搜索框
Debounce
第一步我们先进行 debounce 的实现,在搜索值为空的时候立即响应,其他情况下 debounce:
我们利用 debounce 操作符,在输入值为空字符串的时候立马发送值,在输入不为空的时候等待 500ms 再发送值。
import { debounce, timer, of } from 'rxjs'
function SearchBox() {
const [result, setResult] = useState()
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}
</>
}
去重
去重没有现成的操作符,但是我们可以自己做转换一个新的 Observable(可以理解为一个单播的 Subject) 来实现去重的功能。
记录下 lastValue,在每次收到新 value 的情况下和 lastValue 对比,如果相同则丢弃。
import { Observable } from 'rxjs'
function SearchBox() {
const [result, setResult] = useState()
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)
}
}),
// 去重的实现 -----------------------------
(source) => {
return new Observable((observer) => {
let lastValue;
const subscription = source.subscribe((input) => {
if (input === lastValue) {
// 什么都不做 } else {
observer.next(input)
}
lastValue = input
});
return () => subscription.unsubscribe()
});
},
// ---------------------------------------
).subscribe(v => {
setResult(v)
})
return () => {
subscription.unsubscribe()
}
}, [])
return <>
<input onChange={handleInput} />
{result}
</>
}
网络请求 + 时序处理
Rxjs 提供了 switchMap 操作符来完成 Promise 到值的解包过程和异步时序控制能力。switchMap 可以将一个流映射为新的流,我们可以将一个文本流通过 Promise 映射为一个文本流到 Promise resolve 结果的流,同时 switchMap 还有一个特殊的能力就是会丢弃掉比最新输入发起时间晚到的值:
import { switchMap } from 'rxjs'
function SearchBox() {
const [result, setResult] = useState()
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)
}
}),
(source) => {
return new Observable((observer) => {
let lastValue
const subscription = source.subscribe((input) => {
if (input === lastValue) { } else {
observer.next(input)
}
lastValue = input
});
return () => subscription.unsubscribe()
});
},
// 网络请求的实现 -----------------------------
switchMap((input) => {
return request(input); // 取最新结果
})
// ---------------------------------------
).subscribe(v => {
setResult(v.data)
})
return () => {
subscription.unsubscribe()
}
}, [])
return <>
<input onChange={handleInput} />
{result}
</>
}
Loading + 异常处理
目前我们只考虑了网络请求正常的情况,从数据到返回结果的映射为:
string => Result,这里面缺少 error 的控制状态,我们可以通过将映射调整为 string => { value: Result, error: Error } 来进一步处理异常和 loading 态。
为了用户的体验,我们还可以稍微处理一下,只要在 error 态,就可以不 debounce 直接发送请求。
function SearchBox() {
const [result, setResult] = useState("")
// 加载状态
const [loading, setLoading] = useState(false)
// 异常状态
const [error, setError] = useState(false)
const input$ = useMemo(() => new BehaviorSubject(""), [])
const handleInput = (e) => {
input$.next(e.target.value)
}
const errorRef = useRef(false)
// 为下面的 useEffect 闭包提供最新的值
errorRef.current = error
useEffect(() => {
const subscription = input$
.pipe(
debounce((input) => {
// 补充 error 处理
if (input.length === 0 || errorRef.current) {
return of(null);
} else {
return timer(500);
}
}),
(source) => {
return new Observable((observer) => {
let lastValue;
const subscription = source.subscribe((input) => {
if (input === lastValue) { } else {
observer.next(input)
}
lastValue = input
});
return () => subscription.unsubscribe()
});
},
switchMap((input) => {
if (input.length === 0) {
setLoading(false)
setError(false)
return of({
value: "default",
error: false
});
} else {
setError(false)
setLoading(true)
return request(input).then(({ data }) => ({
error: false,
value: data
}));
}
})
)
.subscribe({
next: ({ error, value }) => {
if (error) {
setError(true)
setLoading(false)
} else {
setError(false)
setLoading(false)
setResult(value)
}
}
});
return () => {
subscription.unsubscribe()
}
}, [])
return <>
<input onChange={handleInput} />
{result}
</>
}
Demo
小结
通过上面的例子可以看出,rxjs 可以以非常清晰的逻辑将异步搜索框需要的特性分解并实现。这篇文章的介绍只是冰山一角,如果你有兴趣的话可以去参考 rxjs 的官网,rxjs.dev/
在了解之后你可能对于异步编程有一个新的认知,原来复杂的异步场景也可以以规整、可靠的形式来处理。
加入我们
我们来自字节跳动飞书商业应用研发部(Lark Business Applications),目前我们在北京、深圳、上海、武汉、杭州、成都、广州、三亚都设立了办公区域。我们关注的产品领域主要在企业经验管理软件上,包括飞书 OKR、飞书绩效、飞书招聘、飞书人事等 HCM 领域系统,也包括飞书审批、OA、法务、财务、采购、差旅与报销等系统。欢迎各位加入我们。
扫码发现职位&投递简历