精讀《Function Component 入門》

1. 引言

若是你在使用 React 16,能夠嘗試 Function Component 風格,享受更大的靈活性。但在嘗試以前,最好先閱讀本文,對 Function Component 的思惟模式有一個初步認識,防止因思惟模式不一樣步形成的困擾。html

2. 精讀

什麼是 Function Component?

Function Component 就是以 Function 的形式建立的 React 組件:前端

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

也就是,一個返回了 JSX 或 createElement 的 Function 就能夠看成 React 組件,這種形式的組件就是 Function Component。react

因此我已經學會 Function Component 了嗎?ios

別急,故事纔剛剛開始。git

什麼是 Hooks?

Hooks 是輔助 Function Component 的工具。好比 useState 就是一種 Hook,它能夠用來管理狀態:es6

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 規則解讀github

先賦值再 setTimeout 打印

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

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

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,而隨之而來的輸出結果是。。?axios

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 分別生效在第 一、二、3 次渲染中,所以值是 0 1 2
  • useEffect 的例子中,三次點擊也觸發了四次渲染,但 useEffect 分別生效在第 一、二、三、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

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

關注 前端精讀微信公衆號

special Sponsors

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

相關文章
相關標籤/搜索