天天看點

Hooks 陷阱2-當 hooks 遇到 debounce

react 中遇到的奇怪的問題,基本可以從兩方面去思考:state 是否 immutable,以及是否形成了閉包。在  Hooks 陷阱 中,分析了 hooks 的一些陷阱,其中已經提到了閉包的問題。而當 hooks 遇到 debounce 或者 throttle 等科裡化函數的時候,外加一些 viewModel 抽象導緻的變量依賴分散時,情況變得更為複雜和難以了解。本文以項目中遇到的實際案例為例子,闡述如何在 hooks 遇到 debounce 等函數時進行程式設計的最佳實踐,避免一些出人意料的 bug。

場景 1:回調函數中使用 debounce 節流

下面是一個接近真實場景的例子,使用者在輸入框中輸入内容進行搜尋,我們需要對搜尋進行節流,防止太頻繁的網絡請求。

import React, { useState } from 'react';
import ReactDOM from 'react-dom';
import { Select } from 'antd';
import { debounce } from 'lodash';
import jsonp from 'fetch-jsonp';
import querystring from 'querystring';

const { Option } = Select;

let currentValue;


function SearchInput(props) {
  const [data, setData] = useState([]);
  const [value, setValue] = useState();
  const [loading, setLoading] = useState(false);

  async function rawFetch(value, callback) {
    console.log('====value====', value);
    currentValue = value;

    const str = querystring.encode({
      code: 'utf-8',
      q: value,
    });
    
    await jsonp(`https://suggest.taobao.com/sug?${str}`)
      .then(response => response.json())
      .then(d => {
      if (currentValue === value) {
        const { result } = d;
        const data = [];
        result.forEach(r => {
          data.push({
            value: r[0],
            text: r[0],
          });
        });
        callback(data);
        setLoading(false);
      }
    });
  }

  const fetch = debounce(rawFetch, 300);

  const handleSearch = value => {
    setLoading(true);
    if (value) {
      fetch(value, data => setData(data));
    } else {
      setData([]);
    }
  };

  const handleChange = value => {
    setValue(value);
  };

  const options = data.map(d => <Option key={d.value}>{d.text}</Option>);
  return (
    <Select
      showSearch
      value={value}
      style={props.style}
      onSearch={handleSearch}
      onChange={handleChange}
    >
      {options}
    </Select>
  );
}

ReactDOM.render(<SearchInput style={{ width: 200 }} />, mountNode);           

但是,當我們實際測試時,發現并沒有減少網絡請求:

Hooks 陷阱2-當 hooks 遇到 debounce
Hooks 陷阱2-當 hooks 遇到 debounce

這是因為,每次輸入觸發 setLoading,元件 re-render,每次都重新生成一個 debounce 的 fetch。雖然每個 fetch 是節流的,但是這裡生成了 n 個 fetch,當 timeout 之後都觸發了請求。

如何解決這個問題?方法就是阻止每次都重新生成一個 debounce 後的 fetch。一個簡單的方案是直接将 fetch 相關的代碼挪出 SearchInput 函數,不過很多時候我們對 SearchInput 中的很多變量有依賴,全部傳遞顯得很麻煩。此時,我們可以使用 useCallback:

  const fetch = useCallback(debounce(rawFetch, 300), []);           

需要注意的是,在我們的 rawFetch 函數中,參數 value 和 callback 都是外部調用時傳入的。如果我們在 rawFetch 中直接取目前作用域下的 value 和 setData,那麼會形成閉包,此時搜尋時取到的值将會是 undefinded。在該例中,搜尋的 value 必須傳入,是以無法複現,用一個新的例子來展示:

import React, { useState, useCallback, useRef } from 'react';
import ReactDOM from 'react-dom';
import { debounce } from 'lodash';

function App() {
    const [value, setValue] = useState();
    
    const f = () => console.log(value);
    
    const fn = useCallback(
        debounce(f, 500), 
    []);
    
    return (
        <div>
            <input 
                value={value} 
                onChange={(event) => {
                    const _v = event.target.value;
                    setValue(_v);
                    fn();
                }} 
            />
            <br />
            <button onClick={() => setValue('')}>清空</button>
        </div>
    );
}

ReactDOM.render(<App />, mountNode);           

如果我們不想每個依賴的參數都需要在回調函數中傳過去,那麼應該怎麼處理呢。此時就需要 useRef 來保證每次調用的都是最新的方法:

import React, { useState, useCallback, useRef } from 'react';
import ReactDOM from 'react-dom';
import { debounce } from 'lodash';

function App() {
    const [value, setValue] = useState();
    const fRef = useRef();

    const f = () => console.log(value);
    fRef.current = f;
    
    const fn = useCallback(
        debounce(() => fRef.current(), 500), 
    []);
    
    return (
        <div>
            <input 
                value={value} 
                onChange={(event) => {
                    const _v = event.target.value;
                    setValue(_v);
                    fn();
                }} 
            />
            <br />
            <button onClick={() => setValue('')}>清空</button>
        </div>
    );
}

ReactDOM.render(<App />, mountNode);           

封裝 useDebounce:

function useDebounce(fn, ms) {
    const fRef = useRef();
    fRef.current = fn;
    
    const result = useCallback(
        debounce(() => fRef.current(), ms), 
    []);
    return result;
}           

對比兩種解決方案,直接使用 useCallback 的情況,需要外部傳入最新的變量,保證調用時取到的是最新的變量。而使用 useRef 結合 useCallback 不需要外部傳入最新變量,但是每次都需要重新生成回調函數将其指派給 ref.current,性能上稍微差一點。

場景2:在 useEffect 中使用 throttle

當我們在 useEffect 中監聽 dom 事件,然後觸發回調,在回調中使用 throttle 來節流。

// Flow.jsx
useEffect(() => {
	const onScroll = throttle(() => {
    const result = findSelected();
    if (result) {
      setSelected(result);
    } else if (container.scrollTop < window.innerHeight) {
      setSelected(null);
    }
    // 滾動後隐藏工具欄
    removeFadeOutByScroll();
  }, 200);

  container.addEventListener('scroll', onScroll, { passive: true });
  return () => {
    container.removeEventListener('scroll', onScroll);
  };
}, [outline]);

// useViewModel.js
function removeFadeOutByScroll() {
  if (timerRef.current) {
    clearTimeout(timerRef.current);
  }
  if (!hoverToolbar) {
    setFadeOut(true);
  }
}           

這裡出現了一個讓人費解的問題,hoverToolbar 已經被設定為 true,但是在上方 removeFadeOutByScroll 的調用中,hoverToolbar 還是為 false。

其實這個問題還是由閉包引起的,useEffect 中的函數閉包使得裡面函數一直保留了第一次指派時的值,是以調用removeFadeOutByScroll 其中的 hoverToolbar 的值并不會因為 useViewModel 中通過 setState 改變了 hoverToolbar 而改變。為了解決這個問題,隻需要将 hoverToolbar 設定為 useEffect 的依賴。

useEffect(() => {
	const onScroll = throttle(() => {
    const result = findSelected();
    if (result) {
      setSelected(result);
    } else if (container.scrollTop < window.innerHeight) {
      setSelected(null);
    }
    // 滾動後隐藏工具欄
    removeFadeOutByScroll();
  }, 200);

  container.addEventListener('scroll', onScroll, { passive: true });
  return () => {
    container.removeEventListener('scroll', onScroll);
  };
}, [outline, hoverToolbar]);           

看到這裡發現其實這個問題和 throttle 關系不大,主要還是 useEffect 的問題。這裡主要我們每次移除了監聽,這樣不會産生多個 throttle 後的回調函數。其實在 react 推薦的 eslint 中有一條配置就是所有 effect 中用到的變量都要放入依賴數組中。不過這裡實在太隐蔽了,函數是從 viewModel 中傳入的,完全看不到内部實作所依賴的變量。

小結

  1. 使用 useCallback 使得 debounce 函數隻被調用了一次
  2. 如果想要 debounced 函數擷取到正确的值,那麼可以從外部将最新的值傳入進去,否則它會使用閉包中它建立時的那個值
  3. 如果不想每次從外部傳入最新的值,那麼可以使用 useRef ,需要每次 re-render 時重新生成回調函數,并指派給 ref,然後在 useCallback 的回調函數中調用 ref.current 使得每次調用的是最新的函數,使用的是最新的值
  4. 在 useEffect 中,特别需要注意其閉包中的函數調用的值,這些值都是在閉包建立時的值,如果要保證取到最新的值,那麼可以将其添加到依賴數組中,不過這會導緻 useEffect 中的函數多次執行

繼續閱讀