精讀《Function Component 入門》 1. 引言 2. 精讀 3. 總結

1. 引言

如果你在使用 React 16,可以嘗試 Function Component 風格,享受更大的靈活性。但在嘗試之前,最好先閱讀本文,對 Function Component 的思維模式有一個初步認識,防止因思維模式不同步造成的困擾。

2. 精讀

什麼是 Function Component?

Function Component 就是以 Function 的形式創建的 React 組件:

function App() {
  return (
    <div>
      <p>App</p>
    </div>
  );
}

也就是,一個返回了 JSX 或 createElement 的 Function 就可以當作 React 組件,這種形式的組件就是 Function Component。

所以我已經學會 Function Component 了嗎?

別急,故事纔剛剛開始。

什麼是 Hooks?

Hooks 是輔助 Function Component 的工具。比如 useState 就是一種 Hook,它可以用來管理狀態:

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

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

useState 返回的結果是數組,數組的第一項是 ,第二項是 賦值函數useState 函數的第一個參數就是 默認值,也支持回調函數。更詳細的介紹可以參考 Hooks 規則解讀

先賦值再 setTimeout 打印

我們再將 useStatesetTimeout 結合使用,看看有什麼發現。

創建一個按鈕,點擊後讓計數器自增,但是延時 3 秒後再打印出來

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

  const log = () => {
    setCount(count + 1);
    setTimeout(() => {
      console.log(count);
    }, 3000);
  };

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={log}>Click me</button>
    </div>
  );
}

如果我們 在三秒內連續點擊三次,那麼 count 的值最終會變成 3,而隨之而來的輸出結果是。。?

0
1
2

嗯,好像對,但總覺得有點怪?

使用 Class Component 方式實現一遍呢?

敲黑板了,回到我們熟悉的 Class Component 模式,實現一遍上面的功能:

class Counter extends Component {
  state = { count: 0 };

  log = () => {
    this.setState({
      count: this.state.count + 1
    });
    setTimeout(() => {
      console.log(this.state.count);
    }, 3000);
  };

  render() {
    return (
      <div>
        <p>You clicked {this.state.count} times</p>
        <button onClick={this.log}>Click me</button>
      </div>
    );
  }
}

嗯,結果應該等價吧?3 秒內快速點擊三次按鈕,這次的結果是:

3
3
3

怎麼和 Function Component 結果不一樣?

這是用好 Function Component 必須邁過的第一道坎,請確認完全理解下面這段話:

首先對 Class Component 進行解釋:

  1. 首先 state 是 Immutable 的,setState 後一定會生成一個全新的 state 引用。
  2. 但 Class Component 通過 this.state 方式讀取 state,這導致了每次代碼執行都會拿到最新的 state 引用,所以快速點擊三次的結果是 3 3 3

那麼對 Function Component 而言:

  1. useState 產生的數據也是 Immutable 的,通過數組第二個參數 Set 一個新值後,原來的值會形成一個新的引用在下次渲染時。
  2. 但由於對 state 的讀取沒有通過 this. 的方式,使得 每次 setTimeout 都讀取了當時渲染閉包環境的數據,雖然最新的值跟着最新的渲染變了,但舊的渲染裏,狀態依然是舊值。

爲了更容易理解,我們來模擬三次 Function Component 模式下點擊按鈕時的狀態:

第一次點擊,共渲染了 2 次,setTimeout 生效在第 1 次渲染,此時狀態爲:

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

  const log = () => {
    setCount(0 + 1);
    setTimeout(() => {
      console.log(0);
    }, 3000);
  };

  return ...
}

第二次點擊,共渲染了 3 次,setTimeout 生效在第 2 次渲染,此時狀態爲:

function Counter() {
  const [1, setCount] = useState(0);

  const log = () => {
    setCount(1 + 1);
    setTimeout(() => {
      console.log(1);
    }, 3000);
  };

  return ...
}

第三次點擊,共渲染了 4 次,setTimeout 生效在第 3 次渲染,此時狀態爲:

function Counter() {
  const [2, setCount] = useState(0);

  const log = () => {
    setCount(2 + 1);
    setTimeout(() => {
      console.log(2);
    }, 3000);
  };

  return ...
}

可以看到,每一個渲染都是一個獨立的閉包,在獨立的三次渲染中,count 在每次渲染中的值分別是 0 1 2,所以無論 setTimeout 延時多久,打印出來的結果永遠是 0 1 2

理解了這一點,我們就能繼續了。

如何讓 Function Component 也打印 3 3 3

所以這是不是代表 Function Component 無法覆蓋 Class Component 的功能呢?完全不是,我希望你讀完本文後,不僅能解決這個問題,更能理解爲什麼用 Function Component 實現的代碼更佳合理、優雅

第一種方案是藉助一個新 Hook - useRef 的能力:

function Counter() {
  const count = useRef(0);

  const log = () => {
    count.current++;
    setTimeout(() => {
      console.log(count.current);
    }, 3000);
  };

  return (
    <div>
      <p>You clicked {count.current} times</p>
      <button onClick={log}>Click me</button>
    </div>
  );
}

這種方案的打印結果就是 3 3 3

想要理解爲什麼,首先要理解 useRef 的功能:通過 useRef 創建的對象,其值只有一份,而且在所有 Rerender 之間共享

所以我們對 count.current 賦值或讀取,讀到的永遠是其最新值,而與渲染閉包無關,因此如果快速點擊三下,必定會返回 3 3 3 的結果。

但這種方案有個問題,就是使用 useRef 替代了 useState 創建值,那麼很自然的問題就是,如何不改變原始值的寫法,達到同樣的效果呢?

如何不改造原始值也打印 3 3 3

一種最簡單的做法,就是新建一個 useRef 的值給 setTimeout 使用,而程序其餘部分還是用原始的 count:

function Counter() {
  const [count, setCount] = useState(0);
  const currentCount = useRef(count);

  useEffect(() => {
    currentCount.current = count;
  });

  const log = () => {
    setCount(count + 1);
    setTimeout(() => {
      console.log(currentCount.current);
    }, 3000);
  };

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={log}>Click me</button>
    </div>
  );
}

通過這個例子,我們引出了一個新的,也是 最重要的 Hook - useEffect,請務必深入理解這個函數。

useEffect 是處理副作用的,其執行時機在 每次 Render 渲染完畢後,換句話說就是每次渲染都會執行,只是實際在真實 DOM 操作完畢後。

我們可以利用這個特性,在每次渲染完畢後,將 count 此時最新的值賦給 currentCount.current,這樣就使 currentCount 的值自動同步了 count 的最新值。

爲了確保大家準確理解 useEffect,筆者再囉嗦一下,將其執行週期拆解到每次渲染中。假設你在三秒內快速點擊了三次按鈕,那麼你需要在大腦中模擬出下面這三次渲染都發生了什麼:

第一次點擊,共渲染了 2 次,useEffect 生效在第 2 次渲染:

function Counter() {
  const [1, setCount] = useState(0);
  const currentCount = useRef(0);

  useEffect(() => {
    currentCount.current = 1; // 第二次渲染完畢後執行一次
  });

  const log = () => {
    setCount(1 + 1);
    setTimeout(() => {
      console.log(currentCount.current);
    }, 3000);
  };

  return ...
}

第二次點擊,共渲染了 3 次,useEffect 生效在第 3 次渲染:

function Counter() {
  const [2, setCount] = useState(0);
  const currentCount = useRef(0);

  useEffect(() => {
    currentCount.current = 2; // 第三次渲染完畢後執行一次
  });

  const log = () => {
    setCount(2 + 1);
    setTimeout(() => {
      console.log(currentCount.current);
    }, 3000);
  };

  return ...
}

第三次點擊,共渲染了 4 次,useEffect 生效在第 4 次渲染:

function Counter() {
  const [3, setCount] = useState(0);
  const currentCount = useRef(0);

  useEffect(() => {
    currentCount.current = 3; // 第四次渲染完畢後執行一次
  });

  const log = () => {
    setCount(3 + 1);
    setTimeout(() => {
      console.log(currentCount.current);
    }, 3000);
  };

  return ...
}

注意對比與上面章節展開的 setTimeout 渲染時有什麼不同。

要注意的是,useEffect 也隨着每次渲染而不同的,同一個組件不同渲染之間,useEffect 內閉包環境完全獨立。對於本次的例子,useEffect 共執行了 四次,經歷瞭如下四次賦值最終變成 3:

currentCount.current = 0; // 第 1 次渲染
currentCount.current = 1; // 第 2 次渲染
currentCount.current = 2; // 第 3 次渲染
currentCount.current = 3; // 第 4 次渲染

請確保理解了這句話再繼續往下閱讀:

  • setTimeout 的例子,三次點擊觸發了四次渲染,但 setTimeout 分別生效在第 1、2、3 次渲染中,因此值是 0 1 2
  • useEffect 的例子中,三次點擊也觸發了四次渲染,但 useEffect 分別生效在第 1、2、3、4 次渲染中,最終使 currentCount 的值變成 3

用自定義 Hook 包裝 useRef

是不是覺得每次都寫一堆 useEffect 同步數據到 useRef 很煩?是的,想要簡化,就需要引出一個新的概念:自定義 Hooks

首先介紹一下,自定義 Hooks 允許創建自定義 Hook,只要函數名遵循以 use 開頭,且返回非 JSX 元素,就是 Hooks 啦!自定義 Hooks 內還可以調用包括內置 Hooks 在內的所有自定義 Hooks

也就是我們可以將 useEffect 寫到自定義 Hook 裏:

function useCurrentValue(value) {
  const ref = useRef(0);

  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref;
}

這裏又引出一個新的概念,就是 useEffect 的第二個參數,dependences。dependences 這個參數定義了 useEffect 的依賴,在新的渲染中,只要所有依賴項的引用都不發生變化,useEffect 就不會被執行,且當依賴項爲 [] 時,useEffect 僅在初始化執行一次,後續的 Rerender 永遠也不會被執行。

這個例子中,我們告訴 React:僅當 value 的值變化了,再將其最新值同步給 ref.current

那麼這個自定義 Hook 就可以在任何 Function Component 調用了:

function Counter() {
  const [count, setCount] = useState(0);
  const currentCount = useCurrentValue(count);

  const log = () => {
    setCount(count + 1);
    setTimeout(() => {
      console.log(currentCount.current);
    }, 3000);
  };

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={log}>Click me</button>
    </div>
  );
}

封裝以後代碼清爽了很多,而且最重要的是將邏輯封裝起來,我們只要理解 useCurrentValue 這個 Hook 可以產生一個值,其最新值永遠與入參同步。

看到這裏,也許有的小夥伴已經按捺不住迸發的靈感了:useEffect 第二個參數設置爲空數組,這個自定義 Hook 就代表了 didMount 生命週期!

是的,但筆者建議大家 不要再想生命週期的事情,這樣會阻礙你更好的理解 Function Component。因爲下一個話題,就是要告訴你:永遠要對 useEffect 的依賴誠實,被依賴的參數一定要填上去,否則會產生非常難以察覺與修復的 BUG。

setTimeout 換成 setInterval 會怎樣

我們回到起點,將第一個 setTimeout Demo 中換成 setInterval,看看會如何:

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <h1>{count}</h1>;
}

這個例子將引發學習 Function Component 的第二個攔路虎,理解了它,才深入理解了 Function Component 的渲染原理。

首先介紹一下引入的新概念,useEffect 函數的返回值。它的返回值是一個函數,這個函數在 useEffect 即將重新執行時,會先執行上一次 Rerender useEffect 第一個回調的返回函數,再執行下一次渲染的 useEffect 第一個回調。

以兩次連續渲染爲例介紹,展開後的效果是這樣的:

第一次渲染:

function Counter() {
  useEffect(() => {
    // 第一次渲染完畢後執行
    // 最終執行順序:1
    return () => {
      // 由於沒有填寫依賴項,所以第二次渲染 useEffect 會再次執行,在執行前,第一次渲染中這個地方的回調函數會首先被調用
      // 最終執行順序:2
    }
  });

  return ...
}

第二次渲染:

function Counter() {
  useEffect(() => {
    // 第二次渲染完畢後執行
    // 最終執行順序:3
    return () => {
      // 依此類推
    }
  });

  return ...
}

然而本 Demo 將 useEffect 的第二個參數設置爲了 [],那麼其返回函數只會在這個組件被銷燬時執行

讀懂了前面的例子,應該能想到,這個 Demo 希望利用 [] 依賴,將 useEffect 當作 didMount 使用,再結合 setInterval 每次時 count 自增,這樣期望將 count 的值每秒自增 1。

然而結果是:

1
1
1
...

理解了 setTimeout 例子的讀者應該可以自行推導出原因:setInterval 永遠在第一次 Render 的閉包中,count 的值永遠是 0,也就是等價於:

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(0 + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <h1>{count}</h1>;
}

然而罪魁禍首就是 沒有對依賴誠實 導致的。例子中 useEffect 明明依賴了 count,依賴項卻非要寫 [],所以產生了很難理解的錯誤。

所以改正的辦法就是 對依賴誠實

永遠對依賴項誠實

一旦我們對依賴誠實了,就可以得到正確的效果:

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, [count]);

  return <h1>{count}</h1>;
}

我們將 count 作爲了 useEffect 的依賴項,就得到了正確的結果:

1
2
3
...

既然漏寫依賴的風險這麼大,自然也有保護措施,那就是 eslint-plugin-react-hooks 這個插件,會自動訂正你的代碼中的依賴,想不對依賴誠實都不行!

然而對這個例子而言,代碼依然存在 BUG:每次計數器都會重新實例化,如果換成其他費事操作,性能成本將不可接受。

如何不在每次渲染時重新實例化 setInterval?

最簡單的辦法,就是利用 useState 的第二種賦值用法,不直接依賴 count,而是以函數回調方式進行賦值:

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <h1>{count}</h1>;
}

這這寫法真正做到了:

  1. 不依賴 count,所以對依賴誠實。
  2. 依賴項爲 [],只有初始化會對 setInterval 進行實例化。

而之所以輸出還是正確的 1 2 3 ...,原因是 setCount 的回調函數中,c 值永遠指向最新的 count 值,因此沒有邏輯漏洞。

但是聰明的同學仔細一想,就會發現一個新問題:如果存在兩個以上變量需要使用時,這招就沒有用武之地了。

同時使用兩個以上變量時?

如果同時需要對 countstep 兩個變量做累加,那 useEffect 的依賴必然要寫上一種某一個值,頻繁實例化的問題就又出現了:

function Counter() {
  const [count, setCount] = useState(0);
  const [step, setStep] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + step);
    }, 1000);
    return () => clearInterval(id);
  }, [step]);

  return <h1>{count}</h1>;
}

這個例子中,由於 setCount 只能拿到最新的 count 值,而爲了每次都拿到最新的 step 值,就必須將 step 申明到 useEffect 依賴中,導致 setInterval 被頻繁實例化。

這個問題自然也困擾了 React 團隊,所以他們拿出了一個新的 Hook 解決問題:useReducer

什麼是 useReducer

先別聯想到 Redux。只考慮上面的場景,看看爲什麼 React 團隊要將 useReducer 列爲內置 Hooks 之一。

先介紹一下 useReducer 的用法:

const [state, dispatch] = useReducer(reducer, initialState);

useReducer 返回的結構與 useState 很像,只是數組第二項是 dispatch,而接收的參數也有兩個,初始值放在第二位,第一位就是 reducer

reducer 定義瞭如何對數據進行變換,比如一個簡單的 reducer 如下:

function reducer(state, action) {
  switch (action.type) {
    case "increment":
      return {
        ...state,
        count: state.count + 1
      };
    default:
      return state;
  }
}

這樣就可以通過調用 dispatch({ type: 'increment' }) 的方式實現 count 自增了。

那麼回到這個例子,我們只需要稍微改寫一下用法即可:

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const { count, step } = state;

  useEffect(() => {
    const id = setInterval(() => {
      dispatch({ type: "tick" });
    }, 1000);
    return () => clearInterval(id);
  }, [dispatch]);

  return <h1>{count}</h1>;
}

function reducer(state, action) {
  switch (action.type) {
    case "tick":
      return {
        ...state,
        count: state.count + state.step
      };
  }
}

可以看到,我們通過 reducertick 類型完成了對 count 的累加,而在 useEffect 的函數中,竟然完全繞過了 countstep 這兩個變量。所以 useReducer 也被稱爲解決此類問題的 “黑魔法”。

其實不管被怎麼稱呼也好,其本質是讓函數與數據解耦,函數只管發出指令,而不需要關心使用的數據被更新時,需要重新初始化自身。

仔細的讀者會發現這個例子還是有一個依賴的,那就是 dispatch,然而 dispatch 引用永遠也不會變,因此可以忽略它的影響。這也體現了無論如何都要對依賴保持誠實。

這也引發了另一個注意項:儘量將函數寫在 useEffect 內部

將函數寫在 useEffect 內部

爲了避免遺漏依賴,必須將函數寫在 useEffect 內部,這樣 eslint-plugin-react-hooks 才能通過靜態分析補齊依賴項:

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

  useEffect(() => {
    function getFetchUrl() {
      return "https://v?query=" + count;
    }

    getFetchUrl();
  }, [count]);

  return <h1>{count}</h1>;
}

getFetchUrl 這個函數依賴了 count,而如果將這個函數定義在 useEffect 外部,無論是機器還是人眼都難以看出 useEffect 的依賴項包含 count

然而這就引發了一個新問題:將所有函數都寫在 useEffect 內部豈不是非常難以維護?

如何將函數抽到 useEffect 外部?

爲了解決這個問題,我們要引入一個新的 Hook:useCallback,它就是解決將函數抽到 useEffect 外部的問題。

我們先看 useCallback 的用法:

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

  const getFetchUrl = useCallback(() => {
    return "https://v?query=" + count;
  }, [count]);

  useEffect(() => {
    getFetchUrl();
  }, [getFetchUrl]);

  return <h1>{count}</h1>;
}

可以看到,useCallback 也有第二個參數 - 依賴項,我們將 getFetchUrl 函數的依賴項通過 useCallback 打包到新的 getFetchUrl 函數中,那麼 useEffect 就只需要依賴 getFetchUrl 這個函數,就實現了對 count 的間接依賴。

換句話說,我們利用了 useCallbackgetFetchUrl 函數抽到了 useEffect 外部。

爲什麼 useCallbackcomponentDidUpdate 更好用

回憶一下 Class Component 的模式,我們是如何在函數參數變化時進行重新取數的:

class Parent extends Component {
  state = {
    count: 0,
    step: 0
  };
  fetchData = () => {
    const url =
      "https://v?query=" + this.state.count + "&step=" + this.state.step;
  };
  render() {
    return <Child fetchData={this.fetchData} count={count} step={step} />;
  }
}

class Child extends Component {
  state = {
    data: null
  };
  componentDidMount() {
    this.props.fetchData();
  }
  componentDidUpdate(prevProps) {
    if (
      this.props.count !== prevProps.count &&
      this.props.step !== prevProps.step // 別漏了!
    ) {
      this.props.fetchData();
    }
  }
  render() {
    // ...
  }
}

上面的代碼經常用 Class Component 的人應該很熟悉,然而暴露的問題可不小。

我們需要理解 props.count props.stepprops.fetchData 函數使用了,因此在 componentDidUpdate 時,判斷這兩個參數發生了變化就觸發重新取數。

然而問題是,這種理解成本是不是過高了?如果父級函數 fetchData 不是我寫的,在不讀源碼的情況下,我怎麼知道它依賴了 props.countprops.step 呢?更嚴重的是,如果某一天 fetchData 多依賴了 params 這個參數,下游函數將需要全部在 componentDidUpdate 覆蓋到這個邏輯,否則 params 變化時將不會重新取數。可以想象,這種方式維護成本巨大,甚至可以說幾乎無法維護。

換成 Function Component 的思維吧!試着用上剛纔提到的 useCallback 解決問題:

function Parent() {
  const [ count, setCount ] = useState(0);
  const [ step, setStep ] = useState(0);

  const fetchData = useCallback(() => {
    const url = 'https://v/search?query=' + count + "&step=" + step;
  }, [count, step])

  return (
    <Child fetchData={fetchData} />
  )
}

function Child(props) {
  useEffect(() => {
    props.fetchData()
  }, [props.fetchData])

  return (
    // ...
  )
}

可以看出來,當 fetchData 的依賴變化後,按下保存鍵,eslint-plugin-react-hooks 會自動補上更新後的依賴,而下游的代碼不需要做任何改變,下游只需要關心依賴了 fetchData 這個函數即可,至於這個函數依賴了什麼,已經封裝在 useCallback 後打包透傳下來了。

不僅解決了維護性問題,而且對於 只要參數變化,就重新執行某邏輯,是特別適合用 useEffect 做的,使用這種思維思考問題會讓你的代碼更 “智能”,而使用分裂的生命週期進行思考,會讓你的代碼四分五裂,而且容易漏掉各種時機。

useEffect 對業務的抽象非常方便,筆者舉幾個例子:

  1. 依賴項是查詢參數,那麼 useEffect 內可以進行取數請求,那麼只要查詢參數變化了,列表就會自動取數刷新。注意我們將取數時機從觸發端改成了接收端。
  2. 當列表更新後,重新註冊一遍拖拽響應事件。也是同理,依賴參數是列表,只要列表變化,拖拽響應就會重新初始化,這樣我們可以放心的修改列表,而不用擔心拖拽事件失效。
  3. 只要數據流某個數據變化,頁面標題就同步修改。同理,也不需要在每次數據變化時修改標題,而是通過 useEffect “監聽” 數據的變化,這是一種 “控制反轉” 的思維。

說了這麼多,其本質還是利用了 useCallback 將函數獨立抽離到 useEffect 外部。

那麼進一步思考,可以將函數抽離到整個組件的外部嗎?

這也是可以的,需要靈活運用自定義 Hooks 實現。

將函數抽到組件外部

以上面的 fetchData 函數爲例,如果要抽到整個組件的外部,就不是利用 useCallback 做到了,而是利用自定義 Hooks 來做:

function useFetch(count, step) {
  return useCallback(() => {
    const url = "https://v/search?query=" + count + "&step=" + step;
  }, [count, step]);
}

可以看到,我們將 useCallback 打包搬到了自定義 Hook useFetch 中,那麼函數中只需要一行代碼就能實現一樣的效果了:

function Parent() {
  const [count, setCount] = useState(0);
  const [step, setStep] = useState(0);
  const [other, setOther] = useState(0);
  const fetch = useFetch(count, step); // 封裝了 useFetch

  useEffect(() => {
    fetch();
  }, [fetch]);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>setCount {count}</button>
      <button onClick={() => setStep(c => c + 1)}>setStep {step}</button>
      <button onClick={() => setOther(c => c + 1)}>setOther {other}</button>
    </div>
  );
}

隨着使用越來越方便,我們可以將精力放到性能上。觀察可以發現,countstep 都會頻繁變化,每次變化就會導致 useFetchuseCallback 依賴的變化,進而導致重新生成函數。然而實際上這種函數是沒必要每次都重新生成的,反覆生成函數會造成大量性能損耗。

換一個例子就可以看得更清楚:

function Parent() {
  const [count, setCount] = useState(0);
  const [step, setStep] = useState(0);
  const [other, setOther] = useState(0);
  const drag = useDraggable(count, step); // 封裝了拖拽函數
}

假設我們使用 Sortablejs 對某個區域進行拖拽監聽,這個函數每次都重複執行的性能損耗非常大,然而這個函數內部可能因爲僅僅要上報一些日誌,所以依賴了沒有實際被使用的 count step 變量:

function useDraggable(count, step) {
  return useCallback(() => {
    // 上報日誌
    report(count, step);

    // 對區域進行初始化,非常耗時
    // ... 省略耗時代碼
  }, [count, step]);
}

這種情況,函數的依賴就特別不合理。雖然依賴變化應該觸發函數重新執行,但如果函數重新執行的成本非常高,而依賴只是可有可無的點綴,得不償失。

利用 Ref 保證耗時函數依賴不變

一種辦法是通過將依賴轉化爲 Ref:

function useFetch(count, step) {
  const countRef = useRef(count);
  const stepRef = useRef(step);

  useEffect(() => {
    countRef.current = count;
    stepRef.current = step;
  });

  return useCallback(() => {
    const url =
      "https://v/search?query=" + countRef.current + "&step=" + stepRef.current;
  }, [countRef, stepRef]); // 依賴不會變,卻能每次拿到最新的值
}

這種方式比較取巧,將需要更新的區域與耗時區域分離,再將需更新的內容通過 Ref 提供給耗時的區域,實現性能優化。

然而這樣做對函數的改動成本比較高,有一種更通用的做法解決此類問題。

通用的自定義 Hooks 解決函數重新實例化問題

我們可以利用 useRef 創造一個自定義 Hook 代替 useCallback使其依賴的值變化時,回調不會重新執行,卻能拿到最新的值!

這個神奇的 Hook 寫法如下:

function useEventCallback(fn, dependencies) {
  const ref = useRef(null);

  useEffect(() => {
    ref.current = fn;
  }, [fn, ...dependencies]);

  return useCallback(() => {
    const fn = ref.current;
    return fn();
  }, [ref]);
}

再次體會到自定義 Hook 的無所不能。

首先看這一段:

useEffect(() => {
  ref.current = fn;
}, [fn, ...dependencies]);

fn 回調函數變化時, ref.current 重新指向最新的 fn 這個邏輯中規中矩。重點是,當依賴 dependencies 變化時,也重新爲 ref.current 賦值,此時 fn 內部的 dependencies 值是最新的,而下一段代碼:

return useCallback(() => {
  const fn = ref.current;
  return fn();
}, [ref]);

又僅執行一次(ref 引用不會改變),所以每次都可以返回 dependencies 是最新的 fn,並且 fn 還不會重新執行。

假設我們對 useEventCallback 傳入的回調函數稱爲 X,則這段代碼的含義,就是使每次渲染的閉包中,回調函數 X 總是拿到的總是最新 Rerender 閉包中的那個,所以依賴的值永遠是最新的,而且函數不會重新初始化。

React 官方不推薦使用此範式,因此對於這種場景,利用 useReducer,將函數通過 dispatch 中調用。 還記得嗎?dispatch 是一種可以繞過依賴的黑魔法,我們在 “什麼是 useReducer” 小節提到過。

隨着對 Function Component 的使用,你也漸漸關心到函數的性能了,這很棒。那麼下一個重點自然是關注 Render 的性能。

用 memo 做 PureRender

在 Fucntion Component 中,Class Component 的 PureComponent 等價的概念是 React.memo,我們介紹一下 memo 的用法:

const Child = memo((props) => {
  useEffect(() => {
    props.fetchData()
  }, [props.fetchData])

  return (
    // ...
  )
})

使用 memo 包裹的組件,會在自身重渲染時,對每一個 props 項進行淺對比,如果引用沒有變化,就不會觸發重渲染。所以 memo 是一種很棒的性能優化工具。

下面就介紹一個看似比 memo 難用,但真正理解後會發現,其實比 memo 更好用的渲染優化函數:useMemo

用 useMemo 做局部 PureRender

相比 React.memo 這個異類,React.useMemo 可是正經的官方 Hook:

const Child = (props) => {
  useEffect(() => {
    props.fetchData()
  }, [props.fetchData])

  return useMemo(() => (
    // ...
  ), [props.fetchData])
}

可以看到,我們利用 useMemo 包裹渲染代碼,這樣即便函數 Child 因爲 props 的變化重新執行了,只要渲染函數用到的 props.fetchData 沒有變,就不會重新渲染。

這裏發現了 useMemo 的第一個好處:更細粒度的優化渲染

所謂更細粒度的優化渲染,是指函數 Child 整體可能用到了 AB 兩個 props,而渲染僅用到了 B,那麼使用 memo 方案時,A 的變化會導致重渲染,而使用 useMemo 的方案則不會。

useMemo 的好處還不止這些,這裏先留下伏筆。我們先看一個新問題:當參數越來越多時,使用 props 將函數、值在組件間傳遞非常冗長:

function Parent() {
  const [count, setCount] = useState(0);
  const [step, setStep] = useState(0);
  const fetchData = useFetch(count, step);

  return <Child fetchData={fetchData} setCount={setCount} setStep={setStep} />;
}

雖然 Child 可以通過 memouseMemo 進行優化,**但當程序複雜時,可能存在多個函數在所有 Function Component 間共享的情況 **,此時就需要新 Hook: useContext 來拯救了。

使用 Context 做批量透傳

在 Function Component 中,可以使用 React.createContext 創建一個 Context:

const Store = createContext(null);

其中 null 是初始值,一般置爲 null 也沒關係。接下來還有兩步,分別是在根節點使用 Store.Provider 注入,與在子節點使用官方 Hook useContext 拿到注入的數據:

在根節點使用 Store.Provider 注入:

function Parent() {
  const [count, setCount] = useState(0);
  const [step, setStep] = useState(0);
  const fetchData = useFetch(count, step);

  return (
    <Store.Provider value={{ setCount, setStep, fetchData }}>
      <Child />
    </Store.Provider>
  );
}

在子節點使用 useContext 拿到注入的數據(也就是拿到 Store.Providervalue):

const Child = memo((props) => {
  const { setCount } = useContext(Store)

  function onClick() {
    setCount(count => count + 1)
  }

  return (
    // ...
  )
})

這樣就不需要在每個函數間進行參數透傳了,公共函數可以都放在 Context 裏。

但是當函數多了,Providervalue 會變得很臃腫,我們可以結合之前講到的 useReducer 解決這個問題。

使用 useReducer 爲 Context 傳遞內容瘦身

使用 useReducer,所有回調函數都通過調用 dispatch 完成,那麼 Context 只要傳遞 dispatch 一個函數就好了:

const Store = createContext(null);

function Parent() {
  const [state, dispatch] = useReducer(reducer, { count: 0, step: 0 });

  return (
    <Store.Provider value={dispatch}>
      <Child />
    </Store.Provider>
  );
}

這下無論是根節點的 Provider,還是子元素調用都清爽很多:

const Child = useMemo((props) => {
  const dispatch = useContext(Store)

  function onClick() {
    dispatch({
      type: 'countInc'
    })
  }

  return (
    // ...
  )
})

你也許很快就想到,將 state 也通過 Provider 注入進去豈不更妙?是的,但此處請務必注意潛在性能問題。

state 也放到 Context 中

稍稍改造下,將 state 也放到 Context 中,這下賦值與取值都非常方便了!

const Store = createContext(null);

function Parent() {
  const [state, dispatch] = useReducer(reducer, { count: 0, step: 0 });

  return (
    <Store.Provider value={{ state, dispatch }}>
      <Count />
      <Step />
    </Store.Provider>
  );
}

Count Step 這兩個子元素而言,可需要謹慎一些,假如我們這麼實現這兩個子元素:

const Count = memo(() => {
  const { state, dispatch } = useContext(Store);
  return (
    <button onClick={() => dispatch("incCount")}>incCount {state.count}</button>
  );
});

const Step = memo(() => {
  const { state, dispatch } = useContext(Store);
  return (
    <button onClick={() => dispatch("incStep")}>incStep {state.step}</button>
  );
});

其結果是:無論點擊 incCount 還是 incStep,都會同時觸發這兩個組件的 Rerender。

其問題在於:memo 只能擋在最外層的,而通過 useContext 的數據注入發生在函數內部,會 繞過 memo

當觸發 dispatch 導致 state 變化時,所有使用了 state 的組件內部都會強制重新刷新,此時想要對渲染次數做優化,只有拿出 useMemo 了!

useMemo 配合 useContext

使用 useContext 的組件,如果自身不使用 props,就可以完全使用 useMemo 代替 memo 做性能優化:

const Count = () => {
  const { state, dispatch } = useContext(Store);
  return useMemo(
    () => (
      <button onClick={() => dispatch("incCount")}>
        incCount {state.count}
      </button>
    ),
    [state.count, dispatch]
  );
};

const Step = () => {
  const { state, dispatch } = useContext(Store);
  return useMemo(
    () => (
      <button onClick={() => dispatch("incStep")}>incStep {state.step}</button>
    ),
    [state.step, dispatch]
  );
};

對這個例子來說,點擊對應的按鈕,只有使用到的組件纔會重渲染,效果符合預期。 結合 eslint-plugin-react-hooks 插件使用,連 useMemo 的第二個參數依賴都是自動補全的。

讀到這裏,不知道你是否聯想到了 ReduxConnect?

我們來對比一下 ConnectuseMemo,會發現驚人的相似之處。

一個普通的 Redux 組件:

const mapStateToProps = state => (count: state.count);

const mapDispatchToProps = dispatch => dispatch;

@Connect(mapStateToProps, mapDispatchToProps)
class Count extends React.PureComponent {
  render() {
    return (
      <button onClick={() => this.props.dispatch("incCount")}>
        incCount {this.props.count}
      </button>
    );
  }
}

一個普通的 Function Component 組件:

const Count = () => {
  const { state, dispatch } = useContext(Store);
  return useMemo(
    () => (
      <button onClick={() => dispatch("incCount")}>
        incCount {state.count}
      </button>
    ),
    [state.count, dispatch]
  );
};

這兩段代碼的效果完全一樣,Function Component 除了更簡潔之外,還有一個更大的優勢:全自動的依賴推導

Hooks 誕生的一個原因,就是爲了便於靜態分析依賴,簡化 Immutable 數據流的使用成本。

我們看 Connect 的場景:

由於不知道子組件使用了哪些數據,因此需要在 mapStateToProps 提前寫好,而當需要使用數據流內新變量時,組件裏是無法訪問的,我們要回到 mapStateToProps 加上這個依賴,再回到組件中使用它。

useContext + useMemo 的場景:

由於注入的 state 是全量的,Render 函數中想用什麼都可直接用,在按保存鍵時,eslint-plugin-react-hooks 會通過靜態分析,在 useMemo 第二個參數自動補上代碼裏使用到的外部變量,比如 state.countdispatch

另外可以發現,Context 很像 Redux,那麼 Class Component 模式下的異步中間件實現的異步取數怎麼利用 useReducer 做呢?答案是:做不到。

當然不是說 Function Component 無法實現異步取數,而是用的工具錯了。

使用自定義 Hook 處理副作用

比如上面拋出的異步取數場景,在 Function Component 的最佳做法是封裝成一個自定義 Hook:

const useDataApi = (initialUrl, initialData) => {
  const [url, setUrl] = useState(initialUrl);

  const [state, dispatch] = useReducer(dataFetchReducer, {
    isLoading: false,
    isError: false,
    data: initialData
  });

  useEffect(() => {
    let didCancel = false;

    const fetchData = async () => {
      dispatch({ type: "FETCH_INIT" });

      try {
        const result = await axios(url);
        if (!didCancel) {
          dispatch({ type: "FETCH_SUCCESS", payload: result.data });
        }
      } catch (error) {
        if (!didCancel) {
          dispatch({ type: "FETCH_FAILURE" });
        }
      }
    };

    fetchData();

    return () => {
      didCancel = true;
    };
  }, [url]);

  const doFetch = url => setUrl(url);

  return { ...state, doFetch };
};

可以看到,自定義 Hook 擁有完整生命週期,我們可以將取數過程封裝起來,只暴露狀態 - 是否在加載中:isLoading 是否取數失敗:isError 數據:data

在組件中使用起來非常方便:

function App() {
  const { data, isLoading, isError } = useDataApi("https://v", {
    showLog: true
  });
}

如果這個值需要存儲到數據流,在所有組件之間共享,我們可以結合 useEffectuseReducer

function App(props) {
  const { dispatch } = useContext(Store);

  const { data, isLoading, isError } = useDataApi("https://v", {
    showLog: true
  });

  useEffect(() => {
    dispatch({
      type: "updateLoading",
      data,
      isLoading,
      isError
    });
  }, [dispatch, data, isLoading, isError]);
}

到此,Function Component 的入門概念就講完了,最後附帶一個彩蛋:Function Component 的 DefaultProps 怎麼處理?

Function Component 的 DefaultProps 怎麼處理?

這個問題看似簡單,實則不然。我們至少有兩種方式對 Function Component 的 DefaultProps 進行賦值,下面一一說明。

首先對於 Class Component,DefaultProps 基本上只有一種大家都認可的寫法:

class Button extends React.PureComponent {
  defaultProps = { type: "primary", onChange: () => {} };
}

然而在 Function Component 就五花八門了。

利用 ES6 特性在參數定義階段賦值

function Button({ type = "primary", onChange = () => {} }) {}

這種方法看似很優雅,其實有一個重大隱患:沒有命中的 props 在每次渲染引用都不同。

看這種場景:

const Child = memo(({ type = { a: 1 } }) => {
  useEffect(() => {
    console.log("type", type);
  }, [type]);

  return <div>Child</div>;
});

只要 type 的引用不變,useEffect 就不會頻繁的執行。現在通過父元素刷新導致 Child 跟着刷新,我們發現,每次渲染都會打印出日誌,也就意味着每次渲染時,type 的引用是不同的。

有一種不太優雅的方式可以解決:

const defaultType = { a: 1 };

const Child = ({ type = defaultType }) => {
  useEffect(() => {
    console.log("type", type);
  }, [type]);

  return <div>Child</div>;
};

此時不斷刷新父元素,只會打印出一次日誌,因爲 type 的引用是相同的。

我們使用 DefaultProps 的本意必然是希望默認值的引用相同, 如果不想單獨維護變量的引用,還可以借用 React 內置的 defaultProps 方法解決。

利用 React 內置方案

React 內置方案能較好的解決引用頻繁變動的問題:

const Child = ({ type }) => {
  useEffect(() => {
    console.log("type", type);
  }, [type]);

  return <div>Child</div>;
};

Child.defaultProps = {
  type: { a: 1 }
};

上面的例子中,不斷刷新父元素,只會打印出一次日誌。

因此建議對於 Function Component 的參數默認值,建議使用 React 內置方案解決,因爲純函數的方案不利於保持引用不變。

最後補充一個父組件 “坑” 子組件的經典案例。

不要坑了子組件

我們做一個點擊累加的按鈕作爲父組件,那麼父組件每次點擊後都會刷新:

function App() {
  const [count, forceUpdate] = useState(0);

  const schema = { b: 1 };

  return (
    <div>
      <Child schema={schema} />
      <div onClick={() => forceUpdate(count + 1)}>Count {count}</div>
    </div>
  );
}

另外我們將 schema = { b: 1 } 傳遞給子組件,這個就是埋的一個大坑。

子組件的代碼如下:

const Child = memo(props => {
  useEffect(() => {
    console.log("schema", props.schema);
  }, [props.schema]);

  return <div>Child</div>;
});

只要父級 props.schema 變化就會打印日誌。結果自然是,父組件每次刷新,子組件都會打印日誌,也就是 子組件 [props.schema] 完全失效了,因爲引用一直在變化。

其實 子組件關心的是值,而不是引用,所以一種解法是改寫子組件的依賴:

const Child = memo(props => {
  useEffect(() => {
    console.log("schema", props.schema);
  }, [JSON.stringify(props.schema)]);

  return <div>Child</div>;
});

這樣可以保證子組件只渲染一次。

可是真正罪魁禍首是父組件,我們需要利用 Ref 優化一下父組件:

function App() {
  const [count, forceUpdate] = useState(0);
  const schema = useRef({ b: 1 });

  return (
    <div>
      <Child schema={schema.current} />
      <div onClick={() => forceUpdate(count + 1)}>Count {count}</div>
    </div>
  );
}

這樣 schema 的引用能一直保持不變。如果你完整讀完了本文,應該可以充分理解第一個例子的 schema 在每個渲染快照中都是一個新的引用,而 Ref 的例子中,schema 在每個渲染快照中都只有一個唯一的引用。

3. 總結

所以使用 Function Component 你入門了嗎?

本次精讀留下的思考題是:Function Component 開發過程中還有哪些容易犯錯誤的細節?

討論地址是:精讀《Function Component 入門》 · Issue #157 · dt-fe/weekly

如果你想參與討論,請 點擊這裏,每週都有新的主題,週末或週一發佈。前端精讀 - 幫你篩選靠譜的內容。

關注 前端精讀微信公衆號

<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">

special Sponsors

版權聲明:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證

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