當咱們討論 hooks 時到底在討論什麼

在使用 React 開發的這段時間裏,我最大的感覺就是 「這是 React 最好的時代,也是最壞的時代」 !「好」在於 hooks 開啓了不同的開發模式,在思考方式上要求更關注於數據之間的依賴關係,同時書寫方式更加簡便,整體上提高了開發效率;「壞」在於項目中常常是類組件與函數組件共存,而類組件以類編程思想爲主導,開發過程當中更關注於整個組件的渲染週期,在維護項目時經常須要在兩種思惟模式中左右橫跳,這還不是最壞的一點。html

某日,老王問我:「你一直在「每週一瞥」搬運 hooks 的文章,你以爲 hooks 有哪些易形成內存泄露的點?」 引起了個人深思(由於個人腦子一片空白)。咱們一直在討論 hooks,到底在討論什麼?雖然社區內關於 hooks 的討論不少,但更多的是科普 Hooks API 怎麼使用,亦或是將其與類組件生命週期、redux 進行對比,而缺乏關於 hooks 最佳實踐的討論與共識,我想這纔是「最壞」的一點。今天,咱們不妨討論一下 hooks 所帶來的變化以及咱們如何去擁抱這些變化。react

注「每週一瞥」是團隊內翻譯並分享外網新鮮貨的一個專欄。

國內外關於 hooks 的一些討論

React 16.8 發佈以來,Hooks 深刻人心,帶來最大的變化有三點:思惟模式的轉變,渲染過程當中做用域的變化以及數據流的改變。git

思惟模式

React 官網能夠了解到,Hooks 的設計動機在於簡化組件間狀態邏輯的複用,支持開發者將關聯的邏輯抽象爲更小的函數,並下降認知成本,不用去理解 JS Class 中使人窒息的 this。在這樣的動機之下,hooks 中弱化了組件生命週期的概念,強化了狀態與行爲之間的依賴關係,這容易引導咱們更多的關注「作什麼」,而非「怎麼作」[[1]](https://jaredpalmer.com/blog/...github

假設有這麼一個場景:組件 Detail 中依賴父級組件傳入的 query 參數進行數據請求,那麼不管是基於類組件仍是 Hooks,咱們都須要定義一個異步請求方法 getData。不一樣的是,在類組件的開發模式中,咱們要思考的更傾向於「怎麼作」:在組件掛載完成時請求數據,並在組件發生更新時,比較新舊 query 值,必要時從新調用 getData 函數。編程

class Detail extends React.Component {
  state = {
    keyword: '',
  }

  componentDidMount() {
    this.getData();
  }

  getSnapshotBeforeUpdate(prevProps, prevState) {
    if (this.props.query !== prevProps.query) {
      return true;
    }
    return null;
  }

  componentDidUpdate(prevProps, prevState, snapshot) {
    if (snapshot) {
      this.getData();
    }
  }

  async getData() {
    // 這是一段異步請求數據的代碼
    console.log(`數據請求了,參數爲:${this.props.query}`);
    this.setState({
      keyword: this.props.query
    })
  }

  render() {
    return (
      <div>
        <p>關鍵詞: {this.state.keyword}</p>
      </div>
    );
  }
}

而在應用了 Hooks 的函數組件中,咱們思考「作什麼」:不一樣 query 值,展現不一樣的數據。json

function Detail({
  query
}) {
  const [keyword, setKeyword] = useState('');

  useEffect(() => {
    const getData = async () => {
      console.log(`數據請求了,參數爲:${query}`);
      setKeyword(query);
    }

    getData();
  }, [query]);

  return (
    <div>
      <p>關鍵詞: {keyword}</p>
    </div>
  );
}

在這種主導下,開發者在編碼過程當中的思惟模式也應隨之改變,須要考慮數據與數據、數據與行爲之間的同步關係。這種模式能夠更簡潔地將相關代碼組合到一塊兒,甚至抽象成自定義 hooks,實現邏輯的共享,彷佛有了插拔式編程的味道🤔。redux

雖然 Dan Abramov 在本身的博客中提到,從生命週期的角度思考並決定什麼時候執行反作用是在逆勢而爲[[2]](https://overreacted.io/a-comp...,可是瞭解各個 hooks 在組件渲染過程當中的執行時機,有助於咱們與 React 保持理解的一致性,可以更加準確地專一於「作什麼」。 Donavon 以圖表形式梳理對比了 hooks 範式與生命週期範式[[3]](https://github.com/donavon/ho...,可以幫助咱們理解 hooks 在組件中的工做機制。每次組件發生更新時,都會從新調用組件函數,生成新的做用域,這種變化也對咱們開發者提出了新的編碼要求。數組

hook flow

做用域

在類組件中,組件一旦實例化後,便有了本身的做用域,從建立到銷燬,做用域始終不變。所以,在整個組件的生命週期中,每次渲染時內部變量始終指向同一個引用,咱們能夠很輕易的在每次渲染中經過 this.state 拿到最新的狀態值,也可使用 this.xx 獲取到同一個內部變量。緩存

class Timer extends React.Component {
  state = {
    count: 0,
    interval: null,
  }

  componentDidMount() {
    const interval = setInterval(() => {
      this.setState({
        count: this.state.count + 1,
      })
    }, 1000);

    this.setState({
      interval
    });
  }

  componentDidUnMount() {
    if (this.state.interval) {
      clearInterval(this.state.interval);
    }
  }

  render() {
    return (
      <div>
        計數器爲:{this.state.count}
      </div>
    );
  }
}

Hooks 中, renderstate 的關係更像閉包與局部變量。每次渲染時,都會生成新的 state 變量,React 會向其寫入當次渲染的狀態值,並在當次渲染過程當中保持不變。也即每次渲染互相獨立,都有本身的狀態值。同理,組件內的函數、定時器、反作用等也是獨立的,內部所訪問的也是當次渲染的狀態值,所以經常會遇到定時器/訂閱器內沒法讀取到最新值的狀況。閉包

function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      setCount(count + 1);    // 始終只爲 1 
    }, 1000);

    return () => {
      clearInterval(interval);
    }
  }, []);

  return (
    <div>
      計數器爲:{count}
    </div>
  );
}

若是咱們想要拿到最新值,有兩種解決方法:一是利用 setCount 的 lambada 形式,傳入一個以上一次的狀態值爲參數的函數;二是藉助 useRef 鉤子,在其 current 屬性中存儲最新的值。

function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);

    return () => {
      clearInterval(interval);
    }
  }, []);

  return (
    <div>
      計數器爲:{count}
    </div>
  );
}

在 hook-flow 的圖中,咱們能夠了解到當父組件發生從新渲染時,其全部(狀態、局部變量等)都是新的。一旦子組件依賴於父組件的某一個對象變量,那麼不管對象是否發生變化,子組件拿到的都是新的對象,從而使子組件對應的 diff 失效,依舊會從新執行該部分邏輯。在下面的例子中,咱們的反作用依賴項中包含了父組件傳入的對象參數,每次父組件發生更新時,都會觸發數據請求。

function Info({
  style,
}) {
  console.log('Info 發生渲染');

  useEffect(() => {
    console.log('從新加載數據'); // 每次發生從新渲染時,都會從新加載數據
  }, [style]);

  return (
    <p style={style}>
      這是 Info 裏的文字
    </p>
  );
}

function Page() {
  console.log('Page 發生渲染');

  const [count, setCount] = useState(0);
  const style = { color: 'red' };

  // 計數器 +1 時,引起 Page 的從新渲染,進而引起 Info 的從新渲染
  return (
    <div>
      <h4>計數值爲:{count}</h4>
      <button onClick={() => setCount(count + 1)}> +1 </button>
      <Info style={style} />
    </div>
  );
}

React Hooks 給咱們提供瞭解決方案,useMemo 容許咱們緩存傳入的對象,僅當依賴項發生變化時,才從新計算並更新相應的對象。

function Page() {
  console.log('Page 發生渲染');

  const [color] = useState('red');
  const [count, setCount] = useState(0);
  const style = useMemo(() => ({ color }), [color]); // 只有 color 發生實質性改變時,style 纔會變化

  // 計數器 +1 時,引起 Page 的從新渲染,進而引起 Info 的從新渲染
  // 可是因爲 style 緩存了,所以不會觸發 Info 內的數據從新加載
  return (
    <div>
      <h4>計數值爲:{count}</h4>
      <button onClick={() => setCount(count + 1)}> +1 </button>
      <Info style={style} />
    </div>
  );
}

數據流

React Hooks 在數據流上帶來的變化有兩點:一是支持更友好的使用 context 進行狀態管理,避免層級過多時向中間層承載無關參數;二是容許函數參與到數據流中,避免向下層組件傳入多餘的參數。

useContext 做爲 hooks 的核心模塊之一,能夠獲取到傳入 context 的當前值,以此達到跨層通訊的目的。React 官網有着詳細的介紹,須要關注的是一旦 context 值發生改變,全部使用了該 context 的組件都會從新渲染。爲了不無關的組件重繪,咱們須要合理的構建 context ,好比從第一節提到的新思惟模式出發,按狀態的相關度組織 context,將相關狀態存儲在同一個 context 中。

在過去,若是父子組件用到同一個數據請求方法 getData ,而該方法又依賴於上層傳入的 query 值時,一般須要將 querygetData 方法一塊兒傳遞給子組件,子組件經過判斷 query 值來決定是否從新執行 getData

class Parent extends React.Component {
   state = {
    query: 'keyword',
  }

  getData() {
    const url = `https://mocks.alibaba-inc.com/mock/fO87jdfKqX/demo/queryData.json?query=${this.state.query}`;
    // 請求數據...
    console.log(`請求路徑爲:${url}`);
  }

  render() {
    return (
      // 傳遞了一個子組件不渲染的 query 值
      <Child getData={this.getData} query={this.state.query} />
    );
  }
}

class Child extends React.Component {
  componentDidMount() {
    this.props.getData();
  }

  componentDidUpdate(prevProps) {
    // if (prevProps.getData !== this.props.getData) { // 該條件始終爲 true
    //   this.props.getData();
    // }
    if (prevProps.query !== this.props.query) { // 只能藉助 query 值來作判斷
      this.props.getData();
    }
  }

  render() {
    return (
      // ...
    );
  }
}

在 React Hooks 中 useCallback 支持咱們緩存某一函數,當且僅當依賴項發生變化時,才更新該函數。這使得咱們能夠在子組件中配合 useEffect ,實現按需加載。經過 hooks 的配合,使得函數再也不僅僅是一個方法,而是能夠做爲一個值參與到應用的數據流中。

function Parent() {
  const [count, setCount] = useState(0);
  const [query, setQuery] = useState('keyword');

  const getData = useCallback(() => {
    const url = `https://mocks.alibaba-inc.com/mock/fO87jdfKqX/demo/queryData.json?query=${query}`;
    // 請求數據...
    console.log(`請求路徑爲:${url}`);
  }, [query]);  // 當且僅當 query 改變時 getData 才更新

  // 計數值的變化並不會引發 Child 從新請求數據
  return (
    <>
      <h4>計數值爲:{count}</h4>
      <button onClick={() => setCount(count + 1)}> +1 </button>
      <input onChange={(e) => {setQuery(e.target.value)}} />
      <Child getData={getData} />
    </>
  );
}

function Child({
  getData
}) {
  useEffect(() => {
    getData();
  }, [getData]);    // 函數能夠做爲依賴項參與到數據流中

  return (
    // ...
  );
}

總結

回到最初的問題:「 hooks 有哪些易形成內存泄露的點?」,我理解形成內存泄露風險的在於 hooks 所帶來的做用域的變化。因爲每次渲染都是獨立的,一旦有反作用引用了局部變量,而且未在組件銷燬時及時釋放,那麼就極易形成內存泄露。關於如何更好的使用 hooks, Sandro Dolidze 在博客中列了一個 checkList[[4]](https://medium.com/@sdolidze/...,我以爲是個不錯的建議,能夠幫助咱們寫出正確的 hooks 應用。

  1. 遵循 Hooks 規則;
  2. 不要在函數體中使用任何反作用,而是將其放到 useEffect 中執行;
  3. 取消訂閱/處理/銷燬全部已使用的資源;
  4. 首選 useReduceruseState 的函數更新,以防止在鉤子中讀寫相同的值;
  5. 不要在 render 函數中使用可變變量,而是使用 useRef
  6. 若是在 useRef 中保存的內容的生命週期比組件自己小,那麼在處理資源時不要釋放該值;
  7. 當心死循環和內存泄露;
  8. 當須要提升性能是,能夠 memoize 函數和對象;
  9. 正確設置依賴項(undefined => 每次渲染; [a, b] => 當 ab 改變時;[] => 僅執行一次);
  10. 在可複用用例中使用自定義 hooks.

本文主要從自身體感出發,對比總結了在開發過程當中,hooks 所帶來的變化以及如何去應對這種變化。理解有誤之處,歡迎指正~

參考

[1] React is becoming a black box
[2] A Complete Guide to useEffect
[3] hook-flow
[4] The Iceberg of React Hooks

文章可隨意轉載,但請保留此原文連接。
很是歡迎有激情的你加入 ES2049 Studio,簡歷請發送至 caijun.hcj@alibaba-inc.com。
相關文章
相關標籤/搜索