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

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 對加工過程進行緩存,僅當依賴變化時才從新執行:微信

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 許可證

相關文章
相關標籤/搜索