揭開React Hooks神祕面紗

揭開React Hooks神祕面紗

在這裏插入圖片描述

React Hooks簡介

React Hooks是React 16.8的新增特性,它可以讓你在不編寫類的情況下使用state和其他React功能。hook翻譯過來就是“鉤子”,即在函數式組件中“鉤入”React state以及生命週期等特性的函數。一個簡單的hook類似於

function Count(props) {
  const [count, setCount] = useState(0); // 鉤入state
  useEffect(() => { // 鉤入組件生命週期
    console.log("component did mount");
  }, []);
  return (
  	<div onClick={() => setCount(v => v - 1)}>
      count: {count}
    </div>
  )
}

Why React Hooks?

那麼,爲什麼會有React Hooks呢,簡而言之四個字:邏輯複用。React沒用提供將可複用代碼附加到組件中去的途徑,對於這種問題一般都使用render props高階組件來解決。然而這類方案需要重新組織結構,而且很麻煩,寫出來的代碼像下面的那樣,難以理解

// redux connect高階組件
export default connect(
  ({ reducer: { isLogin } }) => ({ isLogin })
)(Avatar);

而且當我們在使用Class在寫組件時會把UI與邏輯混在Class裏,導致邏輯複用困難。相信很多讀者都碰到過邏輯一致而UI不一致的問題,至少在字節跳動實習期間,大部分的重複代碼都是這種問題造成的。比如下面的UI

在這裏插入圖片描述

兩個部分都有PK邏輯,使用Class來編寫每一個組件,每個組件都得重新寫一遍PK邏輯。由於後端提供的接口比較噁心,爲了儘可能複用代碼,對PK接口也進行了儘可能的封裝:

API.match: 設置定時器,每3s給後端push一個發起匹配的消息,並在匹配成功後resolve結果,匹配失敗後reject

API.cancelMatch: 清除定時器,並給後端push一個取消匹配的消息,取消成功resolve,取消失敗(即在取消前一刻匹配成功了)reject

const PK_STATUS = {
  NO_MATCH: 0, // 不匹配
  MATCHING: 1, // 匹配中
  PK: 2 // 正在PK
};
// banner * 2
class PKBanner extends React.Component {
  constructor (props) {
    super(props);
    this.state = {
      status: PK_STATUS.NO_MATCH
    };
    this.handleMatch = this.handleMatch.bind(this);
    this.handleCancelMatch = this.handleCancelMatch.bind(this);
    this.autoCancelMatch = this.autoCancelMatch.bind(this);
  }

  handleMatch () { // 發起匹配
    this.setState({ status: PK_STATUS.MATCHING });
    API.match().then(data => this.setState({ status: PK_STATUS.PK })).catch(() => { // 出錯情況
      toast("當前不能發起PK");
      this.setState({ status: PK_STATUS.NO_MATCH });
      API.cancelMatch();
    });
  }

  handleCancelMatch () { // 取消匹配
    API.cancelMatch().then(() => this.setState({ status: PK_STATUS.NO_MATCH }), data => { // 在取消時又匹配成功了
      this.setState({ status: PK_STATUS.PK });
    });
  }

  autoCancelMatch () { // 自動取消匹配
    if (this.state.status === PK_STATUS.MATCHING) {
      API.cancelMatch().then(() => {
        toast('匹配超時');
        this.setState({ status: PK_STATUS.NO_MATCH })
      }, data => { // 在取消匹配時匹配成功了
        this.setState({ status: PK_STATUS.PK });
      })
    }
  }

  render () {
    // do something
  }
}

這還是對接口進行了比較好的封裝,如果把每3s給後端push匹配消息以及push取消匹配消息等邏輯直接往Class裏面寫可能會更多的重複代碼。所以可以考慮把這種重複邏輯抽象成一個useMatch hook

function useMatch () {
  const [status, setStatus] = useState(PK_STATUS.NO_MATCH);
  function match () {
    API.match().then(data => setStatus( PK_STATUS.PK)).catch(() => {
      toast("當前不能發起PK");
      setStatus(PK_STATUS.NO_MATCH );
      API.cancelMatch();
    });
  }
  function cancelMatch () {
    API.cancelMatch().then(() => setStatus( PK_STATUS.NO_MATCH ), data => {
      setStatus(PK_STATUS.PK);
    });
  }
  function autoCancelMatch () {
    if (status === PK_STATUS.MATCHING) {
      API.cancelMatch().then(() => {
        toast('匹配超時');
        setStatus(PK_STATUS.NO_MATCH)
      }, data => {
        setStatus(PK_STATUS.PK);
      })
    }
  }
  return [status, match, cancelMatch, autoCancelMatch];
}

然後在每一個需要使用PK邏輯的組件都能夠將邏輯“鉤入”組件中(也許這就是hooks的精髓)

function PKBanner (props) {
  const [status, match, cancelMatch, autoCancelMatch] = useMatch();
  // do something
}

揭開React Hooks神祕面紗-原理淺析

其實React Hooks的原理和redux的實現有些相似(redux原理淺析),都是手動render,比如useStatehook實現

import ReactDOM from "react-dom";
import React from "react";

const MyReact = function () {
  function isFunc (fn) {
    return typeof fn === "function";
  }
  let state = null; // 全局變量保存state
  function useState (initState) {
    state = state || initState;
    function setState(newState) {
      // 保存新的state,注意setState裏面可接個函數setState(v => v + 1);
      state = isFunc(newState) ? newState(state) : newState; 
      render(); // 手動調用render => ReactDOM.render => diff => 重構UI
    }
    return [state, setState];
  }

  return { useState }
}();

function App(props) {
  const [count, setCount] = MyReact.useState(0);
  return (
    <div onClick={() => setCount(count => count + 1)}>
      count: { count }
    </div>
  )
}

function render () {
  ReactDOM.render(<App/>, document.getElementById("root"));
}

render();

上面實現了一個簡單的useStatehook,其基本原理並不複雜(理解App會一直調用)。當App執行時,調用MyReact.useState(0)將state初始爲0(state = state || initState),並將statesetState返回,也就是countsetCount,當點擊事件發生時,state = newState並且手動調用render,然後執行App,調用MyReact.useState(0),上一個步驟state = newState將state賦值,因此state = state || initState得到的就是上一個的state,在UI上的變化就是count + 1了。

同理我們可以實現useEffecthook,useEffect有幾個特點:接受callbackdepArr兩個參數;當depArr不存在時callback會一直執行;depArr存在且和上一個depArr不相等時執行callback

let deps = null;
function useEffect (callback, depArr) {
  const hasDepsChange = deps ? deps.some((v, i) => depArr[i] !== v) : true; //檢查依賴數組是否變化
  if (!depArr || hasDepsChange) { // depArr = undefined 或者 依賴數組變化時才調用callback
    deps = depArr; // 記錄下上一個依賴數組
    isFunc(callback) && callback();
  }
}

每一次調用useEffect時都對依賴數組做一個判斷,來判斷callback是否需要執行,所以像下面的代碼,依賴數組一直沒變化,所以callback也就只會執行一次

MyReact.useEffect(() => {
  console.log("count: ", count);
}, []);

上面的實現存在問題,即state只有一個,所以寫兩個hooks前一個會被覆蓋,比如

const [count, setCount] = MyReact.useState(0);
const [text, setText] = MyReact.useState("");

我們可以使用一個hooks數組來記錄第幾個hook,修改後的useState的實現

let hooksOfState = []; // 記錄state的數組
let hooksOfStateIndex = 0; // 當前索引
function useState (initState) {
  const currentIndex = hooksOfStateIndex; // 記錄這是第幾個useState
  hooksOfState[currentIndex] = hooksOfState[currentIndex] || initState;
  function setState(newState) { // 利用閉包的特性,這裏的currentIndex就是對應編號的useState
    hooksOfState[currentIndex] = isFunc(newState) ? newState(hooksOfState[currentIndex]) : newState;
    render();
  }
  return [hooksOfState[hooksOfStateIndex++], setState]; // 記得索引++
}

useEffect也是類似

let hooksOfEffect = []; // 記錄依賴數組
let hooksOfEffectIndex = 0; // 記錄索引
function useEffect (callback, depArr) {
  const currentIndex = hooksOfEffectIndex;
  const hasDepsChange = !hooksOfEffect[currentIndex] || hooksOfEffect[currentIndex].some((v, i) => depArr[i] !== v);
  if (!depArr || hasDepsChange) {
    hooksOfEffect[currentIndex] = depArr; // 這兩行順序很重要,如果callback是一個setState的操作,導致render,可以避免出現棧溢出
    isFunc(callback) && callback();
  }
  hooksOfEffectIndex ++; // 索引++
}

需要注意在每一次render時都需要將索引設爲0,保證render後還能按順序找到對應的hooks

function resetIndex () {
    hooksOfStateIndex = 0;
    hooksOfEffectIndex = 0;
}

function render () {
  MyReact.resetIndex(); // render時將索引設置爲初始值
  ReactDOM.render(<App/>, document.getElementById("root"));
}

這裏可以思考,爲什麼hooks不讓寫在條件分支或循環內,答案也很明顯,如果寫在條件分支或者循環內將無法保證下一次render時hooks的順序。

function App(props) {
  const [a, setA] = useState('a'); // 索引0
  if (condition) {
    const [b, setB] = useState('b'); // 假設條件執行,索引爲1,如果在下一次render時條件不成立,則跳過這個hook
  }

  const [c, setC] = useState('c'); // 索引 2,假設render時上一個條件不成立,這裏的索引將會變成1,導致發生意想不到的錯誤

  return <div> </div>;
}

其他hooks比如useMemouseCallback的實現也是類似

let hooksOfMemo = []; // {deps: , result: }
let hooksOfMemoIndex = 0;
function useMemo (callback, depArr) {
  const currentIndex = hooksOfMemoIndex;
  const hasDepsChange = !hooksOfMemo[currentIndex] || hooksOfMemo[currentIndex].deps.some((v, i) => depArr[i] !== v); // 
  if (!depArr || hasDepsChange) {
    hooksOfMemo[currentIndex] = {
      deps: depArr,
      result: isFunc(callback) && callback()
    };
  }
  hooksOfMemoIndex ++;
  return hooksOfMemo[currentIndex].result;
}

let hooksOfCallback = []; // {deps: , callback}
let hooksOfCallbackIndex = 0;
function useCallback (callback, depArr) {
  const currentIndex = hooksOfCallbackIndex;
  const hasDepsChange = !hooksOfCallback[currentIndex] || hooksOfCallback[currentIndex].deps.some((v, i) => depArr[i] !== v);
  if (!depArr || hasDepsChange) {
    hooksOfCallback[currentIndex] = {
      deps: depArr,
      callback: callback
    };
  }
  hooksOfCallbackIndex ++;
  return hooksOfCallback[currentIndex].callback;
}

當然上面的實現並不是React中Hooks的真正實現,它的實現比這要複雜很多,比如React中是通過類似單鏈表的形式來代替數組的,通過next按順序串聯所有的hook。並且hooks的數據作爲組件的一個信息,存儲在這些節點上,伴隨組件整個生命週期。

完整代碼

import ReactDOM from "react-dom";
import React from "react";

const MyReact = function () {
  function isFunc (fn) {
    return typeof fn === "function";
  }

  let hooksOfState = [];
  let hooksOfStateIndex = 0;

  function useState (initState) {
    const currentIndex = hooksOfStateIndex;
    hooksOfState[currentIndex] = hooksOfState[currentIndex] || initState;

    function setState (newState) {
      hooksOfState[currentIndex] = isFunc(newState) ? newState(hooksOfState[currentIndex]) : newState;
      render();
    }

    return [hooksOfState[hooksOfStateIndex ++], setState];
  }

  let hooksOfEffect = [];
  let hooksOfEffectIndex = 0;

  function useEffect (callback, depArr) {
    const currentIndex = hooksOfEffectIndex;
    const hasDepsChange = !hooksOfEffect[currentIndex] || hooksOfEffect[currentIndex].some((v, i) => depArr[i] !== v);
    if (!depArr || hasDepsChange) {
      hooksOfEffect[currentIndex] = depArr;
      isFunc(callback) && callback();
    }
    hooksOfEffectIndex ++;
  }

  let hooksOfMemo = []; // { deps, result }
  let hooksOfMemoIndex = 0;

  function useMemo (callback, depArr) {
    const currentIndex = hooksOfMemoIndex;
    const hasDepsChange = !hooksOfMemo[currentIndex] || hooksOfMemo[currentIndex].deps.some((v, i) => depArr[i] !== v);
    if (!depArr || hasDepsChange) {
      hooksOfMemo[currentIndex] = {
        deps: depArr,
        result: isFunc(callback) && callback()
      };
    }
    hooksOfMemoIndex ++;
    return hooksOfMemo[currentIndex].result;
  }

  let hooksOfCallback = []; // {deps, callback}
  let hooksOfCallbackIndex = 0;

  function useCallback (callback, depArr) {
    const currentIndex = hooksOfCallbackIndex;
    const hasDepsChange = !hooksOfCallback[currentIndex] || hooksOfCallback[currentIndex].deps.some((v, i) => depArr[i] !== v);
    if (!depArr || hasDepsChange) {
      hooksOfCallback[currentIndex] = {
        deps: depArr,
        callback: callback
      };
    }
    hooksOfCallbackIndex ++;
    return hooksOfCallback[currentIndex].callback;
  }

  function resetIndex () {
    hooksOfStateIndex = 0;
    hooksOfEffectIndex = 0;
    hooksOfMemoIndex = 0;
    hooksOfCallbackIndex = 0;
  }

  return { resetIndex, useEffect, useState, useMemo, useCallback }
}();

function App (props) {
  const [count, setCount] = MyReact.useState(0);
  const [text, setText] = MyReact.useState("count");
  MyReact.useEffect(() => {
    console.log("count: ", count);
  }, [count]);
  MyReact.useEffect(() => {
    console.log("changed");
    // setText("change start");
  }, []);
  const sum = MyReact.useMemo(function () {
    let result = 0;
    for (let i = 0; i < count; i ++) result += i;
    return result;
  }, [count]);
  const fn = MyReact.useCallback(() => 20 + count, [count]);
  return (
    <div onClick={ () => setCount(count => count + 1) }>
      <div>{ text }: { count } </div>
      <div>sum: { sum }, const: { fn() }</div>
    </div>
  )
}

function render () {
  MyReact.resetIndex();
  ReactDOM.render(<App/>, document.getElementById("root"));
}

render();

參考

React Hooks文檔

Deep dive: How do React hooks really work?

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章