精讀《編寫有彈性的組件》

1. 引言

讀了 精讀《useEffect 徹底指南》 以後,是否是對 Function Component 的理解又加深了一些呢?html

此次經過 Writing Resilient Components 一文,瞭解一下什麼是有彈性的組件,以及爲何 Function Component 能夠作到這一點。前端

2. 概述

相比代碼的 Lint 或者 Prettier,或許咱們更應該關注代碼是否具備彈性。react

Dan 總結了彈性組件具備的四個特徵:git

  1. 不要阻塞數據流。
  2. 時刻準備好渲染。
  3. 不要有單例組件。
  4. 隔離本地狀態。

以上規則不只適用於 React,它適用於全部 UI 組件。github

不要阻塞渲染的數據流

不阻塞數據流的意思,就是 不要將接收到的參數本地化, 或者 使組件徹底受控api

在 Class Component 語法下,因爲有生命週期的概念,在某個生命週期將 props 存儲到 state 的方式家常便飯。 然而一旦將 props 固化到 state,組件就不受控了:緩存

class Button extends React.Component {
  state = {
    color: this.props.color
  };
  render() {
    const { color } = this.state; // 🔴 `color` is stale!
    return <button className={"Button-" + color}>{this.props.children}</button>;
  }
}
複製代碼

當組件再次刷新時,props.color 變化了,但 state.color 不會變,這種狀況就阻塞了數據流,小夥伴們可能會吐槽組件有 BUG。這時候若是你嘗試經過其餘生命週期(componentWillReceivePropscomponentDidUpdate)去修復,代碼會變得難以管理。安全

然而 Function Component 沒有生命週期的概念,因此沒有必需要將 props 存儲到 state,直接渲染便可:性能優化

function Button({ color, children }) {
  return (
    // ✅ `color` is always fresh!
    <button className={"Button-" + color}>{children}</button>
  );
}
複製代碼

若是須要對 props 進行加工,能夠利用 useMemo 對加工過程進行緩存,僅當依賴變化時才從新執行:bash

const textColor = useMemo(
  () => slowlyCalculateTextColor(color),
  [color] // ✅ Don’t recalculate until `color` changes
);
複製代碼

不要阻塞反作用的數據流

發請求就是一種反作用,若是在一個組件內發請求,那麼在取數參數變化時,最好能從新取數。

class SearchResults extends React.Component {
  state = {
    data: null
  };
  componentDidMount() {
    this.fetchResults();
  }
  componentDidUpdate(prevProps) {
    if (prevProps.query !== this.props.query) {
      // ✅ Refetch on change
      this.fetchResults();
    }
  }
  fetchResults() {
    const url = this.getFetchUrl();
    // Do the fetching...
  }
  getFetchUrl() {
    return "http://myapi/results?query" + this.props.query; // ✅ Updates are handled
  }
  render() {
    // ...
  }
}
複製代碼

若是用 Class Component 的方式實現,咱們須要將請求函數 getFetchUrl 抽出來,而且在 componentDidMountcomponentDidUpdate 時同時調用它,還要注意 componentDidUpdate 時若是取數參數 state.query 沒有變化則不執行 getFetchUrl

這樣的維護體驗很糟糕,若是取數參數增長了 state.currentPage,你極可能在 componentDidUpdate 中漏掉對 state.currentPage 的判斷。

若是使用 Function Component,能夠經過 useCallback 將整個取數過程做爲一個總體:

原文沒有使用 useCallback,筆者進行了加工。

function SearchResults({ query }) {
  const [data, setData] = useState(null);
  const [currentPage, setCurrentPage] = useState(0);

  const fetchResults = useCallback(() => {
    return "http://myapi/results?query" + query + "&page=" + currentPage;
  }, [currentPage, query]);

  useEffect(() => {
    const url = getFetchUrl();
    // Do the fetching...
  }, [getFetchUrl]); // ✅ Refetch on change

  // ...
}
複製代碼

Function Component 對 propsstate 的數據都一視同仁,且能夠將取數邏輯與 「更新判斷」 經過 useCallback 徹底封裝在一個函數內,再將這個函數做爲總體依賴項添加到 useEffect,若是將來再新增一個參數,只要修改 fetchResults 這個函數便可,並且還能夠經過 eslint-plugin-react-hooks 插件靜態分析是否遺漏了依賴項。

Function Component 不但將依賴項聚合起來,還解決了 Class Component 分散在多處生命週期的函數判斷,引起的沒法靜態分析依賴的問題。

不要由於性能優化而阻塞數據流

相比 PureComponentReact.memo,手動進行比較優化是不太安全的,好比你可能會忘記對函數進行對比:

class Button extends React.Component {
  shouldComponentUpdate(prevProps) {
    // 🔴 Doesn't compare this.props.onClick
    return this.props.color !== prevProps.color;
  }
  render() {
    const onClick = this.props.onClick; // 🔴 Doesn't reflect updates
    const textColor = slowlyCalculateTextColor(this.props.color);
    return (
      <button onClick={onClick} className={"Button-" + this.props.color + " Button-text-" + textColor} > {this.props.children} </button>
    );
  }
}
複製代碼

上面的代碼手動進行了 shouldComponentUpdate 對比優化,可是忽略了對函數參數 onClick 的對比,所以雖然大部分時間 onClick 確實沒有變化,所以代碼也不會有什麼 bug:

class MyForm extends React.Component {
  handleClick = () => {
    // ✅ Always the same function
    // Do something
  };
  render() {
    return (
      <> <h1>Hello!</h1> <Button color="green" onClick={this.handleClick}> Press me </Button> </> ); } } 複製代碼

可是一旦換一種方式實現 onClick,狀況就不同了,好比下面兩種狀況:

class MyForm extends React.Component {
  state = {
    isEnabled: true
  };
  handleClick = () => {
    this.setState({ isEnabled: false });
    // Do something
  };
  render() {
    return (
      <> <h1>Hello!</h1> <Button color="green" onClick={ // 🔴 Button ignores updates to the onClick prop this.state.isEnabled ? this.handleClick : null } > Press me </Button> </> ); } } 複製代碼

onClick 隨機在 nullthis.handleClick 之間切換。

drafts.map(draft => (
  <Button color="blue" key={draft.id} onClick={ // 🔴 Button ignores updates to the onClick prop this.handlePublish.bind(this, draft.content) } > Publish </Button>
));
複製代碼

若是 draft.content 變化了,則 onClick 函數變化。

也就是若是子組件進行手動優化時,若是漏了對函數的對比,頗有可能執行到舊的函數致使錯誤的邏輯。

因此儘可能不要本身進行優化,同時在 Function Component 環境下,在內部申明的函數每次都有不一樣的引用,所以便於發現邏輯 BUG,同時利用 useCallbackuseContext 有助於解決這個問題。

時刻準備渲染

確保你的組件能夠隨時重渲染,且不會致使內部狀態管理出現 BUG。

要作到這一點其實挺難的,好比一個複雜組件,若是接收了一個狀態做爲起點,以後的代碼基於這個起點派生了許多內部狀態,某個時刻改變了這個起始值,組件還能正常運行嗎?

好比下面的代碼:

// 🤔 Should prevent unnecessary re-renders... right?
class TextInput extends React.PureComponent {
  state = {
    value: ""
  };
  // 🔴 Resets local state on every parent render
  componentWillReceiveProps(nextProps) {
    this.setState({ value: nextProps.value });
  }
  handleChange = e => {
    this.setState({ value: e.target.value });
  };
  render() {
    return <input value={this.state.value} onChange={this.handleChange} />; } } 複製代碼

componentWillReceiveProps 標識了每次組件接收到新的 props,都會將 props.value 同步到 state.value。這就是一種派生 state,雖然看上去能夠作到優雅承接 props 的變化,但 父元素由於其餘緣由的 rerender 就會致使 state.value 非正常重置,好比父元素的 forceUpdate

固然能夠經過 不要阻塞渲染的數據流 一節所說的方式,好比 PureComponent, shouldComponentUpdate, React.memo 來作性能優化(當 props.value 沒有變化時就不會重置 state.value),但這樣的代碼依然是脆弱的。

健壯的代碼不會由於刪除了某項優化就出現 BUG,不要使用派生 state 就能避免此問題。

筆者補充:解決這個問題的方式是,1. 若是組件依賴了 props.value,就不須要使用 state.value,徹底作成 受控組件。2. 若是必須有 state.value,那就作成內部狀態,也就是不要從外部接收 props.value。總之避免寫 「介於受控與非受控之間的組件」。

補充一下,若是作成了非受控組件,卻想重置初始值,那麼在父級調用處加上 key 來解決:

<EmailInput defaultEmail={this.props.user.email} key={this.props.user.id} />
複製代碼

另外也能夠經過 ref 解決,讓子元素提供一個 reset 函數,不過不推薦使用 ref

不要有單例組件

一個有彈性的應用,應該能經過下面考驗:

ReactDOM.render(
  <> <MyApp /> <MyApp /> </>, document.getElementById("root") ); 複製代碼

將整個應用渲染兩遍,看看是否能各自正確運做?

除了組件本地狀態由本地維護外,具備彈性的組件不該該由於其餘實例調用了某些函數,而 「永遠錯過了某些狀態或功能」。

筆者補充:一個危險的組件通常是這麼思考的:沒有人會隨意破壞數據流,所以只要在 didMountunMount 時作好數據初始化和銷燬就好了。

那麼當另外一個實例進行銷燬操做時,可能會破壞這個實例的中間狀態。一個具備彈性的組件應該能 隨時響應 狀態的變化,沒有生命週期概念的 Function Component 處理起來顯然更駕輕就熟。

隔離本地狀態

不少時候難以判斷數據屬於組件的本地狀態仍是全局狀態。

文章提供了一個判斷方法:「想象這個組件同時渲染了兩個實例,這個數據會同時影響這兩個實例嗎?若是答案是 不會,那這個數據就適合做爲本地狀態」。

尤爲在寫業務組件時,容易將業務數據與組件自己狀態數據混淆。

根據筆者的經驗,從上層業務到底層通用組件之間,本地狀態數量是遞增的:

業務
  -> 全局數據流
    -> 頁面(徹底依賴全局數據流,幾乎沒有本身的狀態)
      -> 業務組件(從頁面或全局數據流繼承數據,不多有本身狀態)
        -> 通用組件(徹底受控,好比 input;或大量內聚狀態的複雜通用邏輯,好比 monaco-editor)
複製代碼

3. 精讀

再次強調,一個有彈性的組件須要同時知足下面 4 個原則:

  1. 不要阻塞數據流。
  2. 時刻準備好渲染。
  3. 不要有單例組件。
  4. 隔離本地狀態。

想要遵循這些規則看上去也不難,但實踐過程當中會遇到很多問題,筆者舉幾個例子。

頻繁傳遞迴調函數

Function Component 會致使組件粒度拆分的比較細,在提升可維護性同時,也會致使全局 state 成爲過去,下面的代碼可能讓你以爲彆扭:

const App = memo(function App() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("nick");

  return (
    <>
      <Count count={count} setCount={setCount}/>
      <Name name={name} setName={setName}/>
    </>
  );
});

const Count = memo(function Count(props) {
  return (
      <input value={props.count} onChange={pipeEvent(props.setCount)}>
  );
});

const Name = memo(function Name(props) {
  return (
  <input value={props.name} onChange={pipeEvent(props.setName)}>
  );
});
複製代碼

雖然將子組件 CountName 拆分出來,邏輯更加解耦,但子組件須要更新父組件的狀態就變得麻煩,咱們不但願將函數做爲參數透傳給子組件。

一種辦法是將函數經過 Context 傳給子組件:

const SetCount = createContext(null)
const SetName = createContext(null)

const App = memo(function App() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("nick");

  return (
    <SetCount.Provider value={setCount}>
      <SetName.Provider value={setName}>
        <Count count={count}/>
        <Name name={name}/>
      </SetName.Provider>
    </SetCount.Provider>
  );
});

const Count = memo(function Count(props) {
  const setCount = useContext(SetCount)
  return (
      <input value={props.count} onChange={pipeEvent(setCount)}>
  );
});

const Name = memo(function Name(props) {
  const setName = useContext(SetName)
  return (
  <input value={props.name} onChange={pipeEvent(setName)}>
  );
});
複製代碼

但這樣會致使 Provider 過於臃腫,所以建議部分組件使用 useReducer 替代 useState,將函數合併到 dispatch

const AppDispatch = createContext(null)

class State = {
  count = 0
  name = 'nick'
}

function appReducer(state, action) {
  switch(action.type) {
    case 'setCount':
      return {
        ...state,
        count: action.value
      }
    case 'setName':
      return {
        ...state,
        name: action.value
      }
    default:
      return state
  }
}

const App = memo(function App() {
  const [state, dispatch] = useReducer(appReducer, new State())

  return (
    <AppDispatch.Provider value={dispaych}>
      <Count count={count}/>
      <Name name={name}/>
    </AppDispatch.Provider>
  );
});

const Count = memo(function Count(props) {
  const dispatch = useContext(AppDispatch)
  return (
      <input value={props.count} onChange={pipeEvent(value => dispatch({type: 'setCount', value}))}>
  );
});

const Name = memo(function Name(props) {
  const dispatch = useContext(AppDispatch)
  return (
  <input value={props.name} onChange={pipeEvent(pipeEvent(value => dispatch({type: 'setName', value})))}>
  );
});
複製代碼

將狀態聚合到 reducer 中,這樣一個 ContextProvider 就能解決全部數據處理問題了。

memo 包裹的組件相似 PureComponent 效果。

useCallback 參數變化頻繁

精讀《useEffect 徹底指南》 咱們介紹了利用 useCallback 建立一個 Immutable 的函數:

function Form() {
  const [text, updateText] = useState("");

  const handleSubmit = useCallback(() => {
    const currentText = text;
    alert(currentText);
  }, [text]);

  return (
    <>
      <input value={text} onChange={e => updateText(e.target.value)} />
      <ExpensiveTree onSubmit={handleSubmit} />
    </>
  );
}
複製代碼

但這個函數的依賴 [text] 變化過於頻繁,以致於在每一個 render 都會從新生成 handleSubmit 函數,對性能有必定影響。一種解決辦法是利用 Ref 規避這個問題:

function Form() {
  const [text, updateText] = useState("");
  const textRef = useRef();

  useEffect(() => {
    textRef.current = text; // Write it to the ref
  });

  const handleSubmit = useCallback(() => {
    const currentText = textRef.current; // Read it from the ref
    alert(currentText);
  }, [textRef]); // Don't recreate handleSubmit like [text] would do

  return (
    <>
      <input value={text} onChange={e => updateText(e.target.value)} />
      <ExpensiveTree onSubmit={handleSubmit} />
    </>
  );
}
複製代碼

固然,也能夠將這個過程封裝爲一個自定義 Hooks,讓代碼稍微好看些:

function Form() {
  const [text, updateText] = useState("");
  // Will be memoized even if `text` changes:
  const handleSubmit = useEventCallback(() => {
    alert(text);
  }, [text]);

  return (
    <>
      <input value={text} onChange={e => updateText(e.target.value)} />
      <ExpensiveTree onSubmit={handleSubmit} />
    </>
  );
}

function useEventCallback(fn, dependencies) {
  const ref = useRef(() => {
    throw new Error("Cannot call an event handler while rendering.");
  });

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

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

不過這種方案並不優雅,React 考慮提供一個更優雅的方案

有可能被濫用的 useReducer

精讀《useEffect 徹底指南》 「將更新與動做解耦」 一節裏提到了,利用 useReducer 解決 「函數同時依賴多個外部變量的問題」。

通常狀況下,咱們會這麼使用 useReducer:

const reducer = (state, action) => {
  switch (action.type) {
    case "increment":
      return { value: state.value + 1 };
    case "decrement":
      return { value: state.value - 1 };
    case "incrementAmount":
      return { value: state.value + action.amount };
    default:
      throw new Error();
  }
};

const [state, dispatch] = useReducer(reducer, { value: 0 });
複製代碼

但其實 useReducerstateaction 的定義能夠很隨意,所以咱們能夠利用 useReducer 打造一個 useState

好比咱們建立一個擁有複數 key 的 useState:

const [state, setState] = useState({ count: 0, name: "nick" });

// 修改 count
setState(state => ({ ...state, count: 1 }));

// 修改 name
setState(state => ({ ...state, name: "jack" }));
複製代碼

利用 useReducer 實現類似的功能:

function reducer(state, action) {
  return action(state);
}

const [state, dispatch] = useReducer(reducer, { count: 0, name: "nick" });

// 修改 count
dispatch(state => ({ ...state, count: 1 }));

// 修改 name
dispatch(state => ({ ...state, name: "jack" }));
複製代碼

所以針對如上狀況,咱們可能濫用了 useReducer,建議直接用 useState 代替。

4. 總結

本文總結了具備彈性的組件的四個特性:不要阻塞數據流、時刻準備好渲染、不要有單例組件、隔離本地狀態。

這個約定對代碼質量很重要,並且難以經過 lint 規則或簡單肉眼觀察加以識別,所以推廣起來仍是有不小難度。

總的來講,Function Component 帶來了更優雅的代碼體驗,可是對團隊協做的要求也更高了。

討論地址是:精讀《編寫有彈性的組件》 · Issue #139 · dt-fe/weekly

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

關注 前端精讀微信公衆號

special Sponsors

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

相關文章
相關標籤/搜索