使用 rxjs 实现可靠的异步搜索框

945 阅读6分钟

异步搜索框是一个业务中非常常见的诉求,但是想实现一个可靠的异步搜索框却不是一个简单的任务,为了使其可靠(性能好 + Bug 少 + 体验好 + 易维护),实现者需要考虑非常多的方面.

异步搜索框的难点

  1. 针对于搜索做 debounce 操作,在用户的输入过程中不立即搜索(性能好,节省网络资源)
  2. 对于输入为空的时候不进行 debounce(体验好,从有搜索内容到无搜索内容立即响应)
  3. 对于 debounce 后的输入去重,不发送重复请求,例如从 a -> ab(debounce 掉,不发送)-> a,可能对 a 发送两次搜索请求(性能好,节省网络资源)
  4. 正确处理时序,不要被早发送的请求响应覆盖晚发送的请求响应(体验好)
  5. 正确处理异常(体验好)
  6. 正确处理 loading,只要还有请求没有返回就维持 loading(体验好)
  7. 在正确实现之前所有需求的前提下维持实现的可维护性(易维护 + 不容易出 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

codesandbox.io/s/vigilant-…

小结

通过上面的例子可以看出,rxjs 可以以非常清晰的逻辑将异步搜索框需要的特性分解并实现。这篇文章的介绍只是冰山一角,如果你有兴趣的话可以去参考 rxjs 的官网,rxjs.dev/

在了解之后你可能对于异步编程有一个新的认知,原来复杂的异步场景也可以以规整、可靠的形式来处理。

加入我们

我们来自字节跳动飞书商业应用研发部(Lark Business Applications),目前我们在北京、深圳、上海、武汉、杭州、成都、广州、三亚都设立了办公区域。我们关注的产品领域主要在企业经验管理软件上,包括飞书 OKR、飞书绩效、飞书招聘、飞书人事等 HCM 领域系统,也包括飞书审批、OA、法务、财务、采购、差旅与报销等系统。欢迎各位加入我们。

扫码发现职位&投递简历

官网投递:job.toutiao.com/s/FyL7DRg