爲何順序調用對 React Hooks 很重要?

在 React Conf 2018 上,React團隊提出了Hooks提案html

若是你想知道什麼是 Hooks,及它能解決什麼問題,查看咱們的講座(介紹),理解React Hooks(常見的誤解)。react

最初你可能會不喜歡 Hooks:git

Negative HN comment

它們就像一段音樂,只有通過幾回用心聆聽纔會慢慢愛上:程序員

Positive HN comment from the same person four days later

當你閱讀文檔時,不要錯過關於最重要的部分——創造屬於你本身的Hooks!太多的人糾結於反對咱們的觀點(class學習成本高等)以致於錯過了Hooks更重要的一面,Hooks像 functional mixins,可讓你創造和搭建屬於本身的Hook。github

Hooks受啓發於一些現有技術,但在 Sebastian 和團隊分享他的想法以後,我才知道這些。不幸的是,這些API和如今在用的之間的關聯很容易被忽略,經過這篇文章,我但願能夠幫助更多的人理解 Hooks提案中爭議較大的點。spring

接下來的部分須要你知道 Hook API 的useState和如何寫自定義Hook。若是你還不懂,能夠看看早先的連接。數組

(免責說明:文章的觀點僅表明我的想法,與React團隊無關。話題大且複雜,其中可能有錯誤的觀點。)瀏覽器


一開始當你學習時你可能會震驚,Hooks 重渲染時是依賴於固定順序調用的,這裏有說明安全

這個決定顯然是有爭議的,這也是爲何會有人反對咱們的提案。咱們會在恰當的時機發布這個提案,當咱們以爲文檔和講座能夠足夠好的描繪它時。bash

若是你在關注 Hooks API 的某些點,我建議你閱讀下 Sebastian對 1000+ 評論RFC的所有回覆足夠透澈但內容很是多,我可能會將評論中的每一段都變成本身的博客文章。(事實上,我已經作過一次!)

我今天要關注一個具體部分。你可能還記得,每一個 Hook 能夠在組件裏被屢次使用,例如,咱們能夠用 useState 聲明多個state

function Form() {
  const [name, setName] = useState('Mary');              // State variable 1
  const [surname, setSurname] = useState('Poppins');     // State variable 2
  const [width, setWidth] = useState(window.innerWidth); // State variable 3

  useEffect(() => {
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  });

  function handleNameChange(e) {
    setName(e.target.value);
  }

  function handleSurnameChange(e) {
    setSurname(e.target.value);
  }

  return (
    <>
      <input value={name} onChange={handleNameChange} />
      <input value={surname} onChange={handleSurnameChange} />
      <p>Hello, {name} {surname}</p>
      <p>Window width: {width}</p>
    </>
  );
}
複製代碼

注意咱們用數組解構語法來命名 useState() 返回的 state 變量,但這些變量不會鏈接到React組件上。相反,這個例子中,React將 name 視爲「第一個state變量」,surname視爲「第二個state變量」,以此類推。它們在從新渲染時用 順序調用 來保證被正確識別。這篇文章詳細的解釋了緣由。

表面上看,依賴於順序調用只是 感受有問題,直覺是一個有用的信號,但它有時會誤導咱們 —— 特別是當咱們尚未徹底消化困惑的問題。 這篇文章,我會提到幾個常常有人提出修改Hooks的方案,及它們存在的問題


這篇文章不會詳盡無遺,如你所見,咱們已經看過十幾種至數百種不一樣的替代方案,咱們一直在考慮替換組件API。

諸如此類的博客很棘手,由於即便你涉及了一百種替代方案,也有人強行提出一個來:「哈哈,你沒有想到 這個 」!

在實踐中,不一樣替代方案提到的問題會有不少重複,我不會列舉 全部 建議的API(這須要花費數月時間),而是經過幾個具體示例展現最多見的問題,更多的問題就考驗讀者觸類旁通的能力了。🧐

這不是說 Hooks 就是完美的,可是一旦你瞭解其餘解決方案的缺陷,你可能會發現 Hooks 的設計是有道理的。


缺陷 #1:沒法提取 custom hook

出乎意料的是,大多數替代方案徹底沒有提到 custom hooks。多是由於咱們在「motivation」文檔中沒有足夠強調 custom hooks,不過在弄懂 Hooks 基本原理以前,這是很難作到的。就像雞和蛋問題,但很大程度上 custom hooks 是提案的重點。

例如:有個替代方案是限制一個組件調用屢次 useState(),你能夠把 state 放在一個對象裏,這樣還能夠兼容class不是更好嗎?

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

要清楚,Hooks 是容許這種風格寫的,你沒必要將state拆分紅一堆state變量(請參閱參見問題解答中的建議)。

但支持屢次調用 useState() 的關鍵在於,你能夠從組件中提取出部分有狀態邏輯(state + effect)到 custom hooks 中,同時能夠單獨使用本地 state 和 effects:

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;
}
複製代碼

若是你只容許每一個組件調用一次 useState(),你將失去用 custom hook 引入 state 能力,這就是 custom hooks 的關鍵。

缺陷 #2: 命名衝突

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

和這主意有些出入,但看起來大體像這樣:

// ⚠️ This is NOT the React Hooks API
function Form() {
  // 咱們傳幾種state key給useState()
  const [name, setName] = useState('name');
  const [surname, setSurname] = useState('surname');
  const [width, setWidth] = useState('width');
  // ...
複製代碼

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

固然除了錯誤以外,你可能沒法在同一個組件調用兩次 useState('name'),這種偶然發生的能夠歸結於其餘任意bug,可是,當你使用一個 custom hook 時,你總會遇到想添加或移除state變量和effects的狀況。

這個提議中,每當你在 custom hook 裏添加一個新的 state 變量時,就有可能破壞使用它的任何組件(直接或者間接),由於 可能已經有同名的變量 位於組件內。

這是一個沒有針對變化而優化的API,當前代碼可能看起來老是「優雅的」,但應對需求變化時十分脆弱,咱們應該從錯誤中吸收教訓。

實際中 Hooks 提案經過依賴順序調用來解決這個問題:即便兩個 Hooks 都用 name state變量,它們也會彼此隔離,每次調用 useState() 都會得到獨立的 「內存單元」。

咱們還有其餘一些方法能夠解決這個缺陷,但這些方法也有自身的缺陷。讓咱們加深探索這個問題。

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

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

// ⚠️ This is NOT the React Hooks API
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);
  // ...
複製代碼

這個提案看上去對提取出來的 useWindowWidth Hook 有效:

// ⚠️ This is NOT the React Hooks API
function Form() {
  // ...
  const width = useWindowWidth();
  // ...
}

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

但若是嘗試提取出來的 input handling,它會失敗:

// ⚠️ This is NOT the React Hooks API
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() Hook 不是特別好用,但你能夠想象下它處理諸如驗證和 dirty state 標誌之類,如Formik。)

你能發現這個bug嗎?

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

const [name, setName] = useState(valueKey);
  const [surname, setSurname] = useState(valueKey);
複製代碼

咱們再次發生了衝突。

實際中 Hooks 提案沒有這種問題,由於每次 調用 useState() 會得到單獨的state。依賴於固定順序調用使咱們免於擔憂命名衝突。

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

從技術上來講這個和上一個缺陷相同,但它的臭名值得說說,甚至維基百科都有介紹。(有些時候還被稱爲「致命的死亡鑽石」 —— cool!)

咱們本身的 mixin 系統就受到了傷害

好比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;
}
複製代碼

這是一個真實可運行的示例。 custom hook 做者準備或中止使用另外一個 custom hook 應該是要安全的,而沒必要擔憂它是否已在鏈中某處「被用過了」

(做爲反例,遺留的 React createClass() 的 mixins 不容許你這樣作,有時你會有兩個 mixins,它們都是你想要的,但因爲擴展了同一個 「base」 mixin,所以互不兼容。)

這是咱們的 「鑽石」:💎

/ 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);
      },
    };
  }
}
複製代碼

這種做法很是繁瑣,Hooks 的設計目標之一就是避免使用高階組件和render props的深層嵌套函數。在這裏,咱們不得不在使用 任何 custom hook 時進行「實例化」 —— 並且在組件主體中只能單次使用生產的函數,這比直接調用 Hooks 麻煩好多。

另外,你不得不操做兩次才能使組件用上 custom hook。一次在最頂層(或在編寫 custom hook 時的函數裏頭),還有一次是最終的調用。這意味着即便一個很小的改動,你也得在頂層聲明和render函數間來回跳轉:

// ⚠️ This is NOT the React Hooks API
const useNameFormInput = createUseFormInput();
const useSurnameFormInput = createUseFormInput();

function Form() {
  // ...
  const name = useNameFormInput();
  const surname = useNameFormInput();
  // ...
}
複製代碼

你還須要很是精確的命名,老是須要考慮「兩層」命名 —— 像 createUseFormInput 這樣的工廠函數和 useNameFormInputuseSurnameFormInput這樣的實例 Hooks。

若是你同時調用兩次相同的 custom hook 「實例」,你會發生state衝突。事實上,上面的代碼就是這種錯誤 —— 發現了嗎? 它應該爲:

const name = useNameFormInput();
  const surname = useSurnameFormInput(); // Not useNameFormInput!
複製代碼

這些問題並不是不可克服,但我認爲它們會比遵照 Hooks規則 的阻力大些。

重要的是,它們打破了複製粘貼的小算盤。在沒有封裝外層的狀況下這種 custom hook 仍然可使用,但它們只能夠被調用一次(這在使用時會產生問題)。不幸的是,當一個API看起來能夠正常運行,一旦你意識到在鏈的某個地方出現了衝突時,就不得不把全部定義好的東西包起來了。

缺陷 #6:咱們仍然須要一個代碼檢查工具

還有另一種使用密鑰state來避免衝突的方法,若是你知道,可能會真的很生氣,由於我不看好它,抱歉。

這個主意就是每次寫 custom hook 時 組合 一個密鑰,就像這樣:

// ⚠️ This is NOT the React Hooks API
function Form() {
  // ...
  const name = useFormInput('name');
  const surname = useFormInput('surname');
  // ...
  return (
    <>
      <input {...name} />
      <input {...surname} />
      {/* ... */}
    </>    
  )
}

function useFormInput(formInputKey) {
  const [value, setValue] = useState('useFormInput(' + formInputKey + ').value');
  return {
    value,
    onChange(e) {
      setValue(e.target.value);
    },
  };
}
複製代碼

和其餘替代提議比,我最不喜歡這個,我以爲它沒有什麼價值。

一個 Hook 通過屢次調用或者與其餘 Hook 衝突以後,代碼可能 意外產出 非惟一或合成無效密鑰進行傳遞。更糟糕的是,若是它是在某些條件下發生的(咱們會試圖 「修復」 它對吧?),可能在一段時間後才發生衝突。

咱們想提醒你們,記住全部經過密鑰來標記的 custom hooks 都很脆弱,它們不只增長了運行時的工做量(別忘了它們要轉成 密鑰 ),並且會漸漸增大 bundle 大小。但若是說咱們非要提醒一個問題,是哪一個問題呢

若是非要在條件判斷裏聲明 state 和 effects,這種方法多是有做用的,但按過去經驗來講,我發現它使人困惑。事實上,我不記得有人會在條件判斷裏定義this.state或者componentMount的。

這段代碼到底意味着什麼?

// ⚠️ This is NOT the React Hooks API
function Counter(props) {
  if (props.isActive) {
    const [count, setCount] = useState('count');
    return (
      <p onClick={() => setCount(count + 1)}>
        {count}
      </p>;
    );
  }
  return null;
}
複製代碼

props.isActivefalsecount 是否被保留?或者因爲 useState('count') 沒有被調用而重置 count

若是條件爲保留 state,effect 又會發生什麼?

// ⚠️ This is NOT the React Hooks API
function Counter(props) {
  if (props.isActive) {
    const [count, setCount] = useState('count');
    useEffect(() => {
      const id = setInterval(() => setCount(c => c + 1), 1000);
      return () => clearInterval(id);
    }, []);
    return (
      <p onClick={() => setCount(count + 1)}>
        {count}
      </p>;
    );
  }
  return null;
}
複製代碼

無疑它不會在 props.isActive 第一次是 true 以前 運行,但一旦變成 true,它會中止運行嗎?當 props.isActive 轉變爲 false 時 interval 會重置嗎?若是是這樣,effect 與 state(咱們說不重置時) 的行爲不一樣使人困惑。若是 effect 繼續運行,那麼 effect 外層的 if 再也不控制 effect,這也使人感到困惑,咱們不是說咱們想要基於條件控制的 effects 嗎?

若是在渲染期間咱們沒有「使用」 state 但 它卻被重置,若是有多個 if 分支包含 useState('count') 但只有其中一個會在給定時間裏運行,會發生什麼?這是有效的代碼嗎?若是咱們的核心思想是 「以密鑰分佈」,那爲何要 「丟棄」 它?開發人員是否但願在這以後從組件中提早 return 以重置全部state呢? 其實若是咱們真的須要重置state,咱們能夠經過提取組件使其明確:

function Counter(props) {
  if (props.isActive) {
    // Clearly has its own state
    return <TickingCounter />; } return null; } 複製代碼

不管如何這可能成爲是解決這些困惑問題的「最佳實踐」,因此無論你選擇哪一種方式去解釋,我以爲條件裏 聲明 state 和 effect的語義怎樣都很怪異,你可能會不知不覺的感覺到。

若是還要提醒的是 —— 正確地組合密鑰的需求會變成「負擔」,它並無給咱們帶來任何想要的。可是,放棄這個需求(並回到最初的提案)確實給咱們帶來了一些東西,它使組件代碼可以安全地複製粘貼到一個 custom hook 中,且不須要命名空間,減少bundle大小及輕微的效率提高(不須要Map查找)。

慢慢理解。

缺陷 #7:Hooks 之間沒法傳值

Hooks 有個最好的功能就是能夠在它們之間傳值。

如下是一個選擇信息收件人的模擬示例,它顯示了當前選擇的好友是否在線:

const friendList = [
  { id: 1, name: 'Phoebe' },
  { id: 2, name: 'Rachel' },
  { id: 3, name: 'Ross' },
];

function ChatRecipientPicker() {
  const [recipientID, setRecipientID] = useState(1);
  const isRecipientOnline = useFriendStatus(recipientID);

  return (
    <>
      <Circle color={isRecipientOnline ? 'green' : 'red'} />
      <select
        value={recipientID}
        onChange={e => setRecipientID(Number(e.target.value))}
      >
        {friendList.map(friend => (
          <option key={friend.id} value={friend.id}>
            {friend.name}
          </option>
        ))}
      </select>
    </>
  );
}

function useFriendStatus(friendID) {
  const [isOnline, setIsOnline] = useState(null);
  const handleStatusChange = (status) => setIsOnline(status.isOnline);
  useEffect(() => {
    ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
    };
  });
  return isOnline;
}
複製代碼

當改變收件人時,useFriendStatus Hook 就會退訂上一個好友的狀態,訂閱接下來的這個。

這是可行的,由於咱們能夠將 useState() Hook 返回的值傳給 useFriendStatus() Hook:

const [recipientID, setRecipientID] = useState(1);
  const isRecipientOnline = useFriendStatus(recipientID);
複製代碼

Hooks之間傳值很是有用。例如:React Spring能夠建立一個尾隨動畫,其中多個值彼此「跟隨」:

const [{ pos1 }, set] = useSpring({ pos1: [0, 0], config: fast });
  const [{ pos2 }] = useSpring({ pos2: pos1, config: slow });
  const [{ pos3 }] = useSpring({ pos3: pos2, config: slow });
複製代碼

(這是 demo。)

在Hook初始化時添加默認參數或者將Hook寫在裝飾器表單中的提議,很難實現這種狀況的邏輯。

若是不在函數體內調用 Hooks,就不能夠輕鬆地在它們之間傳值了。你能夠改變這些值結構,讓它們不須要在多層組件之間傳遞,也能夠用 useMemo 來存儲計算結果。但你也沒法在 effects 中引用這些值,由於它們沒法在閉包中被獲取到。有些方法能夠經過某些約定來解決這些問題,但它們須要你在內心「覈算」輸入和輸出,這違背了 React 直接了當的風格。

在 Hooks 之間傳值是咱們提案的核心,Render props 模式在沒有 Hooks 時是你最早能想到的,但像 Component Component 這樣的庫,是沒法適用於你遇到的全部場景的,它因爲「錯誤的層次結構」存在大量的語法干擾。Hooks 用扁平化層次結構來實現傳值 —— 且函數調用是最簡單的傳值方式。

缺陷 #8:步驟繁瑣

有許多提議處於這種範疇裏。他們儘量的想讓React擺脫對 Hooks 的依賴感,大多數方法是這麼作的:讓 this 擁有內置 Hooks,使它們變成額外的參數在React中無處不在,等等等。

我以爲 Sebastian的回答 比個人描述,更能說服這種方式,我建議你去了解下「注入模型」。

我只想說這和程序員傾向於用 try/catch 捕獲方法中的錯誤代碼是同樣的道理,一樣對比 AMD由咱們本身傳入 require 的「顯示」聲明,咱們更喜歡 import(或者 CommonJS require) 的 ES模塊。

// 有誰想念 AMD?
define(['require', 'dependency1', 'dependency2'], function (require) {
  var dependency1 = require('dependency1'),
  var dependency2 = require('dependency2');
  return function () {};
});
複製代碼

是的,AMD 可能更「誠實」 的陳述了在瀏覽器環境中模塊不是同步加載的,但當你知道了這個後,寫 define三明治 就變成作無用功了。

try/catchrequire和 React Context API都是咱們更喜歡「環境」式體驗,多於直接聲明使用的真實例子(即便一般咱們更喜歡直爽風格),我以爲 Hooks 也屬於這種。

這相似於當咱們聲明組件時,就像從 React 抓個 Component 過來。若是咱們用工廠的方式導出每一個組件,可能咱們的代碼會更解耦:

function createModal(React) {
  return class Modal extends React.Component {
    // ...
  };
}
複製代碼

但在實際中,這最後會變得畫蛇添足而使人厭煩。當咱們真的想以某種方式抓React時,咱們應該在模塊系統層面上實現。

這一樣適用於 Hooks。儘管如此,正如 Sebastian的回答 中提到的,在 技術上 能夠作到從 react 中「直接」導入不一樣實現的 Hooks。(我之前的文章有提到過。)

另外一種強行復雜化想法是把Hooks monadic(單子化) 或者添加像 React.createHook() 這樣的class理念。除了runtime以外,其餘任何添加嵌套的方案都會失去普通函數的優勢:便於調試

在調試過程當中,普通函數中不會夾雜任何類庫代碼,且能夠清晰的知道組件內部值的流向,間接性很難作到這點。像啓發於高階組件(「裝飾器」 Hooks)或者 render props(adopt 提案 或 generators的yield等)相似的方案,都存在這樣的問題。間接性也使靜態類型變得複雜。


如我以前提到的,這篇文章不會詳盡無遺,在其餘提案中有許多有趣的問題,其中有一些更加晦澀(例如於併發和高級編譯相關),這多是在將來另外一篇文章的主題。

Hooks並不是完美無瑕,但這是咱們能夠找到解決這些問題的最佳權衡。還有一些咱們仍然須要修復的東西,這些問題在 Hooks 中比在 class 中更加彆扭,這也會寫在別的文章裏頭。

不管我是否覆蓋掉你喜歡的替換方案,我但願這篇文章有助於闡述咱們的思考過程及咱們在選擇API時考慮的標準。如你所見,不少(例如確保複製粘貼、移動代碼、按但願的方式進行增刪依賴包)不得不針對變化而優化。我但願React開發者們會看好咱們所作的這些決定。

翻譯原文Why Do React Hooks Rely on Call Order?(2018-12-13)

相關文章
相關標籤/搜索