為什么順序調用對React Hook很重要

原文地址:Why Do React Hooks Rely on Call Order? - Dan Abramov

Hooks 重渲染時是依賴于固定順序調用的

Hook 規則

  • 請不要在循環、條件或者嵌套函數中調用 Hooks
  • 都有在 React 函數中才去調用 Hooks

React提供了一個 linter 插件來強制執行這些規則

只在最頂層使用Hook

不要在循環,條件或嵌套函數中調用 Hook, 確保總是在你的 React 函數的最頂層以及任何 return 之前調用他們

這條規則是為了確保Hook在每次渲染中都按照同樣的順序被調用,這讓React能夠在多次的useStateuseEffect調用之間保持hook狀態正確

為什么一定要強調Hook按照順序調用

通常函數組件會有多個state,讓我們通過一個例子來理解useState可能是如何工作的

// 和useState一樣,myUseState接收一個初始值,返回state和setState方法
const myUseState = initialValue => {
    let state = initialValue
    const setState = newValue => {
        state = newValue
        // 重新渲染
        render()
    }
    return [state, setState]
}

const render = () => {
    ReactDOM.render(<App />, document.getElementById('app'))
}

function App() {
    const [n, setN] = myUseState(0)
    ...
    return (
        <div>
            <p>{n}</p>
            <button onClick={() => setN(n + 1)}>
                +1
            </button>
        </div>
    );
}

點擊button,n沒有任何變化
原來每次state都變成了初始值0,因為myUseState會將state重置

我們需要一個不會被myUseState重置的變量,那么這個變量只要聲明在myUseState外面即可

let _state;
const myUseState = initialValue => {
    // 如果state是undefined,則賦給初始值,否則就賦值為保存在外面的_state
    _state = _state === undefined ? initialValue : _state;
    const setState = newValue => {
        _state = newValue;
        render();
    };
    return [_state, setState];
};

還有問題,如果一個組件有倆state咋整?由于所有數據都放在_state,產生沖突:

function App() {
    const [n, setN] = myUseState(0)
    const [m, setM] = myUseState(0)
    ...
}

解決:

  • 把_state做成對象(注意后面會對此種方案進行詳細討論)
    • 不可行,沒有key,useState(0)只傳入了一個參數0,并不知道是n還是m
  • 把_state做成數組
    • 可行,_state = [0, 0]
let _state = [];
// 同樣需要把index聲明在myUseState外面,用來記錄調用順序
let index = 0;
const myUseState = (initialValue) => {
    const currentIndex = index;
    // 對應調用順序下的state有值嗎?
    _state[currentIndex] = _state[currentIndex] === undefined ? initialValue : _state[currentIndex];
    const setState = (newValue) => {
        // 設置對應順序的state
        _state[currentIndex] = newValue;
        render();
    };
    // 下一個state的位置,使index加一位
    index += 1;
    return [_state[currentIndex], setState];
};

const render = () => {
    // 重新渲染要重置index
    // 注意觸發setter才會re-render
    index = 0;
    ReactDOM.render(<App />, document.getElementById('app'));
};

顯而易見的,因為數組根據調用順序存儲值,每一個下標會對應其相應的state,所以useState調用順序必須一致!
re-render時會從第一行代碼開始重新執行整個組件,所以調用順序依然是一致的

(需要注意的是,這部分內容只是API的一種可能實現方法,真實useState使用鏈表存儲,為了大家更好地的理解它此處使用數組替代)

帶著剛剛的思考再次回顧,舉個??:

function RenderFunctionComponent() {
  const [firstName, setFirstName] = useState("Rudi");
  const [lastName, setLastName] = useState("Yardley");

  return (
    <Button onClick={() => setFirstName("Fred")}>Fred</Button>
  );
}
  1. 初始化
    創建兩個空數組“state”與“setters”,設置指針“cursor”為 0


    初始化
  2. 首次渲染
    每當useState()被調用時,如果它是首次渲染,它會通過push將一個setter方法(綁定了指針“cursor”位置)放進“setters”數組中,同時,也會將另一個對應的狀態放進“state”數組中去
    首次渲染
  3. 后續渲染re-render
    每次的后續渲染都會重置指針“cursor”的位置(index=0),并會從每個數組中讀取對應的值(之前講了數據的存儲是獨立于組件之外的)


    后續渲染
  4. 處理事件
    每個setter都會有一個對應的指針位置的引用,因此當觸發任何setter調用的時候都會觸發去改變狀態數組中的對應的值
    處理事件

看到這里,想必大家對Hook的調用順序有了更深的印象了,那么讓我們做一些React團隊禁止去做的事情,比如在條件語句中使用Hook

let firstRender = true;

function RenderFunctionComponent() {
  let initName;
  
  if(firstRender){
    [initName] = useState("Rudi");
    firstRender = false;
  }
  const [firstName, setFirstName] = useState(initName);
  const [lastName, setLastName] = useState("Yardley");

  return (
    <Button onClick={() => setFirstName("Fred")}>Fred</Button>
  );
}

我們在條件語句中調用了useState函數,讓我們看看它對整個系統造成的破壞

糟糕組件的首次渲染

到此為止,我們的變量firstNamelastName依舊包含了正確的數據,讓我們繼續去看一下第二次渲染會發生什么事情
糟糕的第二次渲染

現在firstNamelastName這兩個變量全部被設置為“Rudi”(該位置讀取到的是Rudi),與我們實際的存儲狀態不符

這個例子的用法顯然是不正確的,但是它讓我們知道了為什么我們必須使用React團隊規定的規則去使用Hooks

當然了,多虧了React提供了linter插件幫我們強制執行了這條規則,在代碼編譯過程中會報個錯 React Hook "useState" is called conditionally. React Hooks must be called in the exact same order in every component render. React團隊yyds!orz!

所以你現在應該清楚為什么你不應該在條件語句或者循環語句中使用 Hooks 了嗎?

因為我們維護了一個指針“cursor”指向一個數組,如果你改變了 render 函數內部的調用順序,那么這個指針“cursor”將不會匹配到正確的數據,你的調用也將不會指向正確的數據或句柄

希望通過上面的兩個例子,為大家建立了一個關于 Hooks 的更加清晰的思維模型

另外,Dan也提到了幾個經常有人提出的修改Hooks的方案,并對其缺陷進行了詳細闡述以佐證Hooks的設計是yyds!接著往下看

缺陷1:無法提取custom hook

有個替代方案是限制一個組件調用多次 useState(),你可以把 state 放在一個對象里,這樣還可以兼容 class 不是更好嗎?

function Form() {
  const [state, setState] = useState({
    name: 'Mary',
    surname: 'Poppins',
    width: window.innerWidth,
  });
  // ...
}

Hooks 是允許這種風格寫的,你不必將 state 拆分成一堆 state 變量

但是useState的關鍵在于你可以從組件中提取出部分有狀態的邏輯(state + effect)到 custom hooks 中

function Form() {
  // 在組件內直接定義一些 state 變量
  const [name, setName] = useState('Mary');
  const [surname, setSurname] = useState('Poppins');

  // 我們將部分 state 和 effects 移至 custom hook
  const width = useWindowWidth();
  // ...
}

function useWindowWidth() {
  // 在 custom hook 內定義一些 state 變量
  const [width, setWidth] = useState(window.innerWidth);
  useEffect(() => {
    // ...
  });
  return width;
}

上面代碼,可以將部分state(width)提取到自定義組件(useWindowWidth)中,如果你只允許每個組件調用一次useState(),你將失去用custom hook引入state能力,這就是custom hooks的關鍵

缺陷2:命名沖突

一個常見的建議是讓組件內 useState() 接收一個唯一標識 key 參數(string 等)區分 state 變量

看起來大致是這樣:

function Form() {
  // 我們傳幾種 state key 給 useState()
  const [name, setName] = useState('name');
  const [surname, setSurname] = useState('surname');
  const [width, setWidth] = useState('width');
  // ...

這試圖擺脫依賴順序調用(顯示 key),但引入了另外一個問題 —— 命名沖突

而且,你可能無法在同一個組件調用兩次useState('name'),比方說每當你在custom hook里添加一個新的state變量時,就有可能破壞使用它的任何組件(直接或者間接),因為可能已經有同名的變量位于組件內

而通過一開始講到的Hooks提案,通過依賴順序調用來解決這個問題:即使兩個 Hooks都用name變量,它們也會彼此隔離,每次調用useState()都會獲得獨立的 「內存單元」

缺陷3:同一個 Hook 無法調用兩次

給 useState 「加key」的另一種衍生提案是使用像 Symbol 這樣的東西,這樣就不沖突了對吧?

const nameKey = Symbol();
const surnameKey = Symbol();
const widthKey = Symbol();

function Form() {
  // 我們傳幾種state key給useState()
  const [name, setName] = useState(nameKey);
  const [surname, setSurname] = useState(surnameKey);
  const [width, setWidth] = useState(widthKey);
  // ...

這個提案看起來好像有利于提取state到custom hook當中

function Form() {
  // ...
  const width = useWindowWidth();
  // ...
}

/*********************
 * useWindowWidth.js *
 ********************/
const widthKey = Symbol();
 
function useWindowWidth() {
  const [width, setWidth] = useState(widthKey);
  // ...
  return width;
}

但是如果多次調用,例如:

function Form() {
  // ...
  const name = useFormInput();
  const surname = useFormInput();
  // ...
  return (
    <>
      <input {...name} />
      <input {...surname} />
      {/* ... */}
    </>    
  )
}

/*******************
 * useFormInput.js *
 ******************/
const valueKey = Symbol();
 
function useFormInput() {
  const [value, setValue] = useState(valueKey);
  return {
    value,
    onChange(e) {
      setValue(e.target.value);
    },
  };
}

我們調用 useFormInput() 兩次,但 useFormInput() 總是用同一個 key 調用 useState(),就像這樣:

const [name, setName] = useState(valueKey);
const [surname, setSurname] = useState(valueKey);

又又又又發生了沖突:)
而Hooks提案沒有這種問題,因為每次 調用useState()會獲得單獨的state(狀態不與其他組件共享)。且依賴于固定順序調用使我們免于擔心命名沖突

缺陷4:鉆石問題(多層繼承問題)

比如useWindowWidth()useNetworkStatus()這兩個custom hooks可能要用像 useSubscription() 這樣的 custom hook,如下:

function StatusMessage() {
  const width = useWindowWidth();
  const isOnline = useNetworkStatus();
  return (
    <>
      <p>Window width is {width}</p>
      <p>You are {isOnline ? 'online' : 'offline'}</p>
    </>
  );
}

function useSubscription(subscribe, unsubscribe, getValue) {
  const [state, setState] = useState(getValue());
  useEffect(() => {
    const handleChange = () => setState(getValue());
    subscribe(handleChange);
    return () => unsubscribe(handleChange);
  });
  return state;
}

function useWindowWidth() {
  const width = useSubscription(
    handler => window.addEventListener('resize', handler),
    handler => window.removeEventListener('resize', handler),
    () => window.innerWidth
  );
  return width;
}

function useNetworkStatus() {
  const isOnline = useSubscription(
    handler => {
      window.addEventListener('online', handler);
      window.addEventListener('offline', handler);
    },
    handler => {
      window.removeEventListener('online', handler);
      window.removeEventListener('offline', handler);
    },
    () => navigator.onLine
  );
  return isOnline;
}

嵌套+嵌套+嵌套 = ??

       / useWindowWidth()   \                   / useState()  ?? Clash
Status                        useSubscription() 
       \ useNetworkStatus() /                   \ useEffect() ?? Clash

而固定順序調用的話

                                                / useState()  ? #1. State
       / useWindowWidth()   -> useSubscription()                    
      /                                          \ useEffect() ? #2. Effect
Status                         
      \                                          / useState()  ? #3. State
       \ useNetworkStatus() -> useSubscription()
                                                 \ useEffect() ? #4. Effect

缺陷5:復制粘貼的主意被打亂

或許我們可以通過引入某種命名空間來挽救給 state 加「key」提議,有幾種不同的方法可以做到這一點

一種方法是使用閉包隔離 state 的 key,這需要你在 「實例化」 custom hooks時給每個 hook 裹上一層 function:

/*******************
 * useFormInput.js *
 ******************/
function createUseFormInput() {
  // 每次實例化都唯一
  const valueKey = Symbol();  

  return function useFormInput() {
    const [value, setValue] = useState(valueKey);
    return {
      value,
      onChange(e) {
        setValue(e.target.value);
      },
    };
  }
}

可是要知道上面實例代碼只是一個input組件,但是可以看到,它的代碼已經很重了,真實的React App由多個類按照層級,一層層構成,復雜度成倍增長,嵌套地獄。

而Hooks 的設計目標之一就是避免使用高階組件和render props的深層嵌套函數

而且不得不操作兩次才能使組件用上custom hook

// 我們不得不在使用任何custom hook時進實例化
const useNameFormInput = createUseFormInput();
const useSurnameFormInput = createUseFormInput();

function Form() {
  // ...
  // 還有一次是最終的調用
  const name = useNameFormInput();
  const surname = useNameFormInput();
  // ...
}

這意味著即使一個很小的改動,你也得在頂層聲明和render函數間來回跳轉

你還需要非常精確的命名,總是需要考慮「兩層」命名 —— 像 createUseFormInput 這樣的工廠函數和 useNameFormInput、useSurnameFormInput這樣的實例 Hooks

參考資料:
Why Do React Hooks Rely on Call Order?
(譯)React hooks:它不是一種魔法,只是一個數組——使用圖表揭秘提案規則
Rules of Hooks
RFC: React Hooks

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容