遠端搜尋+節流控制,記一次域名選擇器元件的改進過程
先來描述一下業務背景,我們公司是一個 cdn 行業的公司,我是運營支撐部門的一枚前端小屌絲,同行業的小可愛一定知道,我們日常最大一部分的工作就是做各種業務支撐系統(也就是後臺管理系統),然後最近在做的一個後臺管理系統中需要一個域名選擇元件。我和後臺小哥哥約好了,他那邊先開發介面,我這邊同時先寫業務元件和一些靜態頁面,然後各自的準備工作都做好之後就開始愉快地聯調,此為背景。
然後我們就開始聯調了,當我第一次毫無防備地把非同步獲取到的域名資料塞進 antd 提供的 select 元件時,我的 chrome 一下子就卡住了,然後它就悲劇地閃退了,到底是什麼神祕的力量擊倒了我的 chrome?!於是我看了一下介面請求,尼瑪域名資料有接近三萬條啊,後臺小哥哥無辜地說他的資料也是跟另外一個系統對接的,他並不知道這個情況啊,好吧,那隻能想想辦法解決了。
初步的解決方案
很快我們就機智地想到了解決方案——實時根據使用者輸入的關鍵字 模糊搜尋非同步獲取域名資料展示在下拉框中,然後使用者再從這些結果中選一條。
這種解決方案操作起來也很簡單,只要給 select 元件繫結一個 onSearch 監聽就可以了,onSearch 的回撥函式是一個 loadDomains 函式,如下所示:
/** * 非同步載入 域名資料 * val{String}: 使用者輸入內容 */ loadDomains = (val) => { if (!val) return // window.certApi.queryDomain() 是我的網路請求,返回一個 promise // 每次結果只擷取 30 條資料展示在下拉框中 window.certApi.queryDomain({ other: val }).then(res => { this.setState({ loadingList: false, dataSource: res.data.certDict && res.data.certDict.slice(0, 30) }) }).catch(err => { this.setState({ loadingList: false }) }) } 複製程式碼
第一波改進——節流控制
上面的方案做出來的效果依舊不理想,因為根據 域名關鍵詞 搜尋出來的域名資料依舊很多,比如我隨便輸入“asd”, select 的 onChange 事件就觸發了三次,第一次根據關鍵字“a”來搜尋,得到 16170 條域名資料,第二次再根據關鍵字“as”來搜尋, 然後是“asd”,這樣每次得到的搜尋結果都要 setState,然後頁面重新渲染,導致嚴重卡頓,怎麼辦呢?
機智的我想到了一個詞 節流控制!
函式節流讓一個函式無法在很短的時間間隔內連續呼叫,只有當上一次函式執行後過了你規定的時間間隔,才能進行下一次該函式的呼叫。 複製程式碼
不知道 函式節流 概念的小夥伴可以看下 ofollow,noindex">騰訊的 AlloyTeam 團隊寫的一篇文章
所以我就麻利地給 loadDomains 函式加上了一個節流,萬能的 lodash 已經給我們提供了一個 debounce 方法,直接用著就可以了。
import debounce from 'lodash/debounce' this.loadDomains = debounce(this.loadDomains, 800)// 函式節流控制, 複製程式碼
這樣使用者如果在 800ms 內連續輸入了 “asd” ,程式只會去用“asd”這個過濾條件傳送一次請求,這樣就解決了我們上面:point_up_2:遇到的那個問題啦!
第二波改進——請求時序控制
但是事情遠遠沒有這麼簡單,在測試過程中,我又發現了一個很坑爹的問題,就是如果我傳送完一個請求之後,在下一個 800ms 中使用者又輸入了一些內容,於是又傳送了一個請求,但是這時上一個請求的響應結果才回來,程式接收到響應結果後就直接 this.setState({dataSource: res.data.certDict && res.data.certDict.slice(0, 30) })
,於是使用者眼中看到的結果就是 第二次輸入搜尋詞後得到的結果不是他真正想要的(其實是上一個 800ms 輸入的關鍵詞搜尋後得到的結果),這該咋辦呢?
如果標記每一次請求不就可以避免這個問題了嗎?我機智地 Google 了一下,發現 ant.design 的元件使用文件中竟然提到了這個,而且給出了例子!!!太貼心了,看來這個可能還是一個常規的問題,只是我經驗不足沒有考慮,這裡給出官方的示例程式碼,想體驗 demo 的小可愛可以去官網 感受一下 :
import { Select, Spin } from 'antd'; import debounce from 'lodash/debounce'; const Option = Select.Option; class UserRemoteSelect extends React.Component { constructor(props) { super(props); this.lastFetchId = 0; this.fetchUser = debounce(this.fetchUser, 800); } state = { data: [], value: [], fetching: false, } fetchUser = (value) => { console.log('fetching user', value); this.lastFetchId += 1; const fetchId = this.lastFetchId; this.setState({ data: [], fetching: true }); fetch('https://randomuser.me/api/?results=5') .then(response => response.json()) .then((body) => { if (fetchId !== this.lastFetchId) { // for fetch callback order return; } const data = body.results.map(user => ({ text: `${user.name.first} ${user.name.last}`, value: user.login.username, })); this.setState({ data, fetching: false }); }); } handleChange = (value) => { this.setState({ value, data: [], fetching: false, }); } render() { const { fetching, data, value } = this.state; return ( <Select mode="multiple" labelInValue value={value} placeholder="Select users" notFoundContent={fetching ? <Spin size="small" /> : null} filterOption={false} onSearch={this.fetchUser} onChange={this.handleChange} style={{ width: '100%' }} > {data.map(d => <Option key={d.value}>{d.text}</Option>)} </Select> ); } } 複製程式碼
借鑑這個例子,我的程式碼最終改成了:
// 在 constructor 中建立一個 lastFetchId 變數,用於標記最新的請求 this.lastFetchId = 0 loadDomains = (val) => { if (!val) return // fetchId 作為每次 fetch 的唯一標示,lastFetchId 作為每一次 loadDomains 的行為標示 this.lastFetchId += 1 const fetchId = this.lastFetchId window.certApi.queryDomain({ other: val }).then(res => { // 若使用者在 800ms 之內沒有等到所需的請求結果就已經進行新一次的搜尋,則上一次的請求結果無效 if (fetchId !== this.lastFetchId) { return } this.setState({ loadingList: false, dataSource: res.data.certDict && res.data.certDict.slice(0, 30) }) }).catch(err => { this.setState({ loadingList: false }) }) } 複製程式碼
至此一個完整的非同步的域名選擇器已經完成了,效果如下:

第三波改進——體驗改進
我和後臺小哥哥滿意地看著這個不會導致頁面卡頓的選擇器,滿意地笑著,但是看著看著覺得有點不對了,尼瑪不是說好了是域名選擇器嗎,為啥這個域名選擇器剛獲得焦點下拉框中沒有填充資料呢,看著就像個輸入框啊,這樣不太行,因為我們這個搜尋的介面最終要傳送的域名的ID,所以必須在下拉框中找到一個特定的域名並選中它,最終這個輸入控制元件才有值。如果讓使用者感覺是個輸入框的話,他就會開始想為啥我隨便輸入的字串不能顯示在這個輸入框中。
為了解決這個問題,我決定給這個域名選擇器初始填充 30 條資料,讓域名選擇器剛獲得焦點下拉框中有 30 條填充資料 但是跟後臺小哥哥協商過程中得知,他並不能初始給我 30 條域名資料!!尼瑪!(這裡忽略我與後臺小哥哥 sibi 的過程,反正最終結果就是他並不能初始給我 30 條域名資料)
好吧,機智的我這麼能被這個問題擋住前進的腳步呢?!於是在頁面初始化的時候獲取了。。。全部的域名資料!!!

不要說我蠢,先聽我細細道來,因為這是非同步的請求,不阻塞其他語句,所以雖然請求全部資料差不多要十幾二十秒,但還是可以接受的,讓它在後臺靜靜地請求資料,然後靜靜地塞進 Select 中,嘿嘿嘿:grin:,讓我們來感受一下改進後的域名選擇器,是不是好了很多,對於使用者來說就是一個下拉選擇元件了。

具體程式碼如下, isNeverSearched
標記了使用者是否還未搜尋過,如果還未搜素過,則下拉框中的填充資料是我們擷取的前 30 條域名資料
static getDerivedStateFromProps(nextProps, prevState) { if (prevState.isNeverSearched && nextProps.domains && nextProps.domains.length > 0) { return { dataSource: nextProps.domains.slice(0, 30), isNeverSearched: false } } return null } 複製程式碼
我又轉念一想,現在我們手上不是已經擁有了全部域名資料嗎,所以使用者輸入關鍵詞後過濾域名資料就可以在前端進行了,這樣速度相比非同步獲取資料來說可以快很多啊。這樣也可以去掉函式節流控制了,因為現在不是非同步請求了,程式碼如下:
loadDataSource = (val) => { if (!val) return const getCorrectDomain = new Promise((resolve, reject) => { const newDataSource = this.props.domains.filter(item => item.label.indexOf(val) >= 0) resolve(newDataSource) }) this.setState({ loadingList: true }) getCorrectDomain.then(res => { this.setState({ loadingList: false, dataSource: res.slice(0, 30) }) }) } 複製程式碼
效果如下,過濾的速度是不是肉眼可見地快了很多:

總結
到這裡,其實已經相當於把原來的方案完全推翻了,但是獲得了更好的體驗效果,讓我們來總結一下,其實我們總共採用了兩種方案:
- 每次根據使用者輸入的關鍵詞來非同步獲取域名資料填充下拉框,用到的知識點有 函式節流 + 請求時序控制
- 先全量獲取所有域名資料,再根據使用者輸入的關鍵詞在前端做過濾,得到更小範圍的域名資料填充下拉框
最終我們採用了第二種方案,是因為我們希望這個輸入元件看起來更像一個具體的選擇器,在輸入框第一次得到焦點時下拉框中已經有填充資料了,但是後端介面不允許只獲取前 30 條資料,所以在全量獲取所有域名資料的前提下,只好順勢在前端做過濾,第一種方案還是很經典的,學到了以後類似的業務場景都可以很完美地解決了。
繼續改進
其實上面有提到全量獲取域名資料在網路狀況比較差的情況下差不多要十幾二十秒,我們基於使用者進入頁面不會馬上開始搜尋域名的假設下采用了第二種方案,但是一切皆有可能,如果上述場景真的發生了該怎麼辦呢,機智的我又想了一個好辦法:
如果在使用者第一次點選 域名選擇器 時,全量獲取域名資料的介面還沒有得到響應的話,就做非同步的過濾,否則就在前端做過濾,最終程式碼如下:
先把 非同步過濾域名資料 和 本地過濾域名資料 兩個操作分開:
/** * 非同步過濾域名資料 */ loadDomainsAsync = (val) => { // fetchId 作為每次 fetch 的唯一標示,lastFetchId 作為每一次 loadDomainsAsync 的行為標示 this.lastFetchId += 1 const fetchId = this.lastFetchId this.setState({ loadingList: true }) window.certApi.queryDomain({ other: val }).then(res => { // 若使用者在 800ms 之內沒有等到所需的請求結果就已經進行新一次的搜尋,則上一次的請求結果無效 if (fetchId !== this.lastFetchId) { return } this.setState({ loadingList: false, dataSource: res.data.certDict && res.data.certDict.slice(0, 30) }) }).catch(err => { this.setState({ loadingList: false }) }) } /** * 本地過濾域名資料 */ filterDomains = (val) => { return new Promise((resolve, reject) => { const newDataSource = this.props.domains.filter(item => item.label.indexOf(val) >= 0) resolve(newDataSource) }) } 複製程式碼
然後 onChange 的回撥函式改為:
/** * 非同步載入 域名資料 */ handleSearch = (val) => { if (!val) return if (!(this.props.domains && this.props.domains.length > 0)) { this.handleSearch = debounce(() => this.loadDomainsAsync(val), 800)// 函式節流控制 } else { this.setState({ loadingList: true }) this.filterDomains(val).then(res => { this.setState({ loadingList: false, dataSource: res.slice(0, 30) }) }) } } 複製程式碼
但是這樣會導致不管全部domains是否已經請求到,this.handleSearch 都是一個節流後的 this.loadDomainsAsync(val),需要在必要的時候把這個節流取消掉才行,我暫時沒想到該咋半,所以只能在 全部domains還未請求到的時候不做節流。這樣最終也勉勉強強解決了使用者進入頁面馬上開始搜尋域名的問題了。