精讀《編寫(xiě)有彈性的組件》

1. 引言

讀了 精讀《useEffect 完全指南》 之后,是不是對(duì) Function Component 的理解又加深了一些呢?

這次通過(guò) Writing Resilient Components 一文,了解一下什么是有彈性的組件,以及為什么 Function Component 可以做到這一點(diǎn)。

2. 概述

相比代碼的 Lint 或者 Prettier,或許我們更應(yīng)該關(guān)注代碼是否具有彈性。

Dan 總結(jié)了彈性組件具有的四個(gè)特征:

  1. 不要阻塞數(shù)據(jù)流。
  2. 時(shí)刻準(zhǔn)備好渲染。
  3. 不要有單例組件。
  4. 隔離本地狀態(tài)。

以上規(guī)則不僅適用于 React,它適用于所有 UI 組件。

不要阻塞渲染的數(shù)據(jù)流

不阻塞數(shù)據(jù)流的意思,就是 不要將接收到的參數(shù)本地化, 或者 使組件完全受控。

在 Class Component 語(yǔ)法下,由于有生命周期的概念,在某個(gè)生命周期將 props 存儲(chǔ)到 state 的方式屢見(jiàn)不鮮。 然而一旦將 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>;
  }
}

當(dāng)組件再次刷新時(shí),props.color 變化了,但 state.color 不會(huì)變,這種情況就阻塞了數(shù)據(jù)流,小伙伴們可能會(huì)吐槽組件有 BUG。這時(shí)候如果你嘗試通過(guò)其他生命周期(componentWillReceivePropscomponentDidUpdate)去修復(fù),代碼會(huì)變得難以管理。

然而 Function Component 沒(méi)有生命周期的概念,所以沒(méi)有必須要將 props 存儲(chǔ)到 state,直接渲染即可:

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

如果需要對(duì) props 進(jìn)行加工,可以利用 useMemo 對(duì)加工過(guò)程進(jìn)行緩存,僅當(dāng)依賴變化時(shí)才重新執(zhí)行:

const textColor = useMemo(
  () => slowlyCalculateTextColor(color),
  [color] // ? Don’t recalculate until `color` changes
);

不要阻塞副作用的數(shù)據(jù)流

發(fā)請(qǐng)求就是一種副作用,如果在一個(gè)組件內(nèi)發(fā)請(qǐng)求,那么在取數(shù)參數(shù)變化時(shí),最好能重新取數(shù)。

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 的方式實(shí)現(xiàn),我們需要將請(qǐng)求函數(shù) getFetchUrl 抽出來(lái),并且在 componentDidMountcomponentDidUpdate 時(shí)同時(shí)調(diào)用它,還要注意 componentDidUpdate 時(shí)如果取數(shù)參數(shù) state.query 沒(méi)有變化則不執(zhí)行 getFetchUrl

這樣的維護(hù)體驗(yàn)很糟糕,如果取數(shù)參數(shù)增加了 state.currentPage,你很可能在 componentDidUpdate 中漏掉對(duì) state.currentPage 的判斷。

如果使用 Function Component,可以通過(guò) useCallback 將整個(gè)取數(shù)過(guò)程作為一個(gè)整體:

原文沒(méi)有使用 useCallback,筆者進(jìn)行了加工。

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 對(duì) propsstate 的數(shù)據(jù)都一視同仁,且可以將取數(shù)邏輯與 “更新判斷” 通過(guò) useCallback 完全封裝在一個(gè)函數(shù)內(nèi),再將這個(gè)函數(shù)作為整體依賴項(xiàng)添加到 useEffect,如果未來(lái)再新增一個(gè)參數(shù),只要修改 fetchResults 這個(gè)函數(shù)即可,而且還可以通過(guò) eslint-plugin-react-hooks 插件靜態(tài)分析是否遺漏了依賴項(xiàng)。

Function Component 不但將依賴項(xiàng)聚合起來(lái),還解決了 Class Component 分散在多處生命周期的函數(shù)判斷,引發(fā)的無(wú)法靜態(tài)分析依賴的問(wèn)題。

不要因?yàn)樾阅軆?yōu)化而阻塞數(shù)據(jù)流

相比 PureComponentReact.memo,手動(dòng)進(jìn)行比較優(yōu)化是不太安全的,比如你可能會(huì)忘記對(duì)函數(shù)進(jìn)行對(duì)比:

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>
    );
  }
}

上面的代碼手動(dòng)進(jìn)行了 shouldComponentUpdate 對(duì)比優(yōu)化,但是忽略了對(duì)函數(shù)參數(shù) onClick 的對(duì)比,因此雖然大部分時(shí)間 onClick 確實(shí)沒(méi)有變化,因此代碼也不會(huì)有什么 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>
      </>
    );
  }
}

但是一旦換一種方式實(shí)現(xiàn) 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 隨機(jī)在 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 函數(shù)變化。

也就是如果子組件進(jìn)行手動(dòng)優(yōu)化時(shí),如果漏了對(duì)函數(shù)的對(duì)比,很有可能執(zhí)行到舊的函數(shù)導(dǎo)致錯(cuò)誤的邏輯。

所以盡量不要自己進(jìn)行優(yōu)化,同時(shí)在 Function Component 環(huán)境下,在內(nèi)部申明的函數(shù)每次都有不同的引用,因此便于發(fā)現(xiàn)邏輯 BUG,同時(shí)利用 useCallbackuseContext 有助于解決這個(gè)問(wèn)題。

時(shí)刻準(zhǔn)備渲染

確保你的組件可以隨時(shí)重渲染,且不會(huì)導(dǎo)致內(nèi)部狀態(tài)管理出現(xiàn) BUG。

要做到這一點(diǎn)其實(shí)挺難的,比如一個(gè)復(fù)雜組件,如果接收了一個(gè)狀態(tài)作為起點(diǎn),之后的代碼基于這個(gè)起點(diǎn)派生了許多內(nèi)部狀態(tài),某個(gè)時(shí)刻改變了這個(gè)起始值,組件還能正常運(yùn)行嗎?

比如下面的代碼:

// ?? 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 標(biāo)識(shí)了每次組件接收到新的 props,都會(huì)將 props.value 同步到 state.value。這就是一種派生 state,雖然看上去可以做到優(yōu)雅承接 props 的變化,但 父元素因?yàn)槠渌虻?rerender 就會(huì)導(dǎo)致 state.value 非正常重置,比如父元素的 forceUpdate。

當(dāng)然可以通過(guò) 不要阻塞渲染的數(shù)據(jù)流 一節(jié)所說(shuō)的方式,比如 PureComponent, shouldComponentUpdate, React.memo 來(lái)做性能優(yōu)化(當(dāng) props.value 沒(méi)有變化時(shí)就不會(huì)重置 state.value),但這樣的代碼依然是脆弱的。

健壯的代碼不會(huì)因?yàn)閯h除了某項(xiàng)優(yōu)化就出現(xiàn) BUG,不要使用派生 state 就能避免此問(wèn)題。

筆者補(bǔ)充:解決這個(gè)問(wèn)題的方式是,1. 如果組件依賴了 props.value,就不需要使用 state.value,完全做成 受控組件。2. 如果必須有 state.value,那就做成內(nèi)部狀態(tài),也就是不要從外部接收 props.value。總之避免寫(xiě) “介于受控與非受控之間的組件”。

補(bǔ)充一下,如果做成了非受控組件,卻想重置初始值,那么在父級(jí)調(diào)用處加上 key 來(lái)解決:

<EmailInput defaultEmail={this.props.user.email} key={this.props.user.id} />

另外也可以通過(guò) ref 解決,讓子元素提供一個(gè) reset 函數(shù),不過(guò)不推薦使用 ref

不要有單例組件

一個(gè)有彈性的應(yīng)用,應(yīng)該能通過(guò)下面考驗(yàn):

ReactDOM.render(
  <>
    <MyApp />
    <MyApp />
  </>,
  document.getElementById("root")
);

將整個(gè)應(yīng)用渲染兩遍,看看是否能各自正確運(yùn)作?

除了組件本地狀態(tài)由本地維護(hù)外,具有彈性的組件不應(yīng)該因?yàn)槠渌麑?shí)例調(diào)用了某些函數(shù),而 “永遠(yuǎn)錯(cuò)過(guò)了某些狀態(tài)或功能”。

筆者補(bǔ)充:一個(gè)危險(xiǎn)的組件一般是這么思考的:沒(méi)有人會(huì)隨意破壞數(shù)據(jù)流,因此只要在 didMountunMount 時(shí)做好數(shù)據(jù)初始化和銷毀就行了。

那么當(dāng)另一個(gè)實(shí)例進(jìn)行銷毀操作時(shí),可能會(huì)破壞這個(gè)實(shí)例的中間狀態(tài)。一個(gè)具有彈性的組件應(yīng)該能 隨時(shí)響應(yīng) 狀態(tài)的變化,沒(méi)有生命周期概念的 Function Component 處理起來(lái)顯然更得心應(yīng)手。

隔離本地狀態(tài)

很多時(shí)候難以判斷數(shù)據(jù)屬于組件的本地狀態(tài)還是全局狀態(tài)。

文章提供了一個(gè)判斷方法:“想象這個(gè)組件同時(shí)渲染了兩個(gè)實(shí)例,這個(gè)數(shù)據(jù)會(huì)同時(shí)影響這兩個(gè)實(shí)例嗎?如果答案是 不會(huì),那這個(gè)數(shù)據(jù)就適合作為本地狀態(tài)”。

尤其在寫(xiě)業(yè)務(wù)組件時(shí),容易將業(yè)務(wù)數(shù)據(jù)與組件本身狀態(tài)數(shù)據(jù)混淆。

根據(jù)筆者的經(jīng)驗(yàn),從上層業(yè)務(wù)到底層通用組件之間,本地狀態(tài)數(shù)量是遞增的:

業(yè)務(wù)
  -> 全局?jǐn)?shù)據(jù)流
    -> 頁(yè)面(完全依賴全局?jǐn)?shù)據(jù)流,幾乎沒(méi)有自己的狀態(tài))
      -> 業(yè)務(wù)組件(從頁(yè)面或全局?jǐn)?shù)據(jù)流繼承數(shù)據(jù),很少有自己狀態(tài))
        -> 通用組件(完全受控,比如 input;或大量?jī)?nèi)聚狀態(tài)的復(fù)雜通用邏輯,比如 monaco-editor)

3. 精讀

再次強(qiáng)調(diào),一個(gè)有彈性的組件需要同時(shí)滿足下面 4 個(gè)原則:

  1. 不要阻塞數(shù)據(jù)流。
  2. 時(shí)刻準(zhǔn)備好渲染。
  3. 不要有單例組件。
  4. 隔離本地狀態(tài)。

想要遵循這些規(guī)則看上去也不難,但實(shí)踐過(guò)程中會(huì)遇到不少問(wèn)題,筆者舉幾個(gè)例子。

頻繁傳遞回調(diào)函數(shù)

Function Component 會(huì)導(dǎo)致組件粒度拆分的比較細(xì),在提高可維護(hù)性同時(shí),也會(huì)導(dǎo)致全局 state 成為過(guò)去,下面的代碼可能讓你覺(jué)得別扭:

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 拆分出來(lái),邏輯更加解耦,但子組件需要更新父組件的狀態(tài)就變得麻煩,我們不希望將函數(shù)作為參數(shù)透?jìng)鹘o子組件。

一種辦法是將函數(shù)通過(guò) 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)}>
  );
});

但這樣會(huì)導(dǎo)致 Provider 過(guò)于臃腫,因此建議部分組件使用 useReducer 替代 useState,將函數(shù)合并到 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})))}>
  );
});

將狀態(tài)聚合到 reducer 中,這樣一個(gè) ContextProvider 就能解決所有數(shù)據(jù)處理問(wèn)題了。

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

useCallback 參數(shù)變化頻繁

精讀《useEffect 完全指南》 我們介紹了利用 useCallback 創(chuàng)建一個(gè) Immutable 的函數(shù):

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} />
    </>
  );
}

但這個(gè)函數(shù)的依賴 [text] 變化過(guò)于頻繁,以至于在每個(gè) render 都會(huì)重新生成 handleSubmit 函數(shù),對(duì)性能有一定影響。一種解決辦法是利用 Ref 規(guī)避這個(gè)問(wèn)題:

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} />
    </>
  );
}

當(dāng)然,也可以將這個(gè)過(guò)程封裝為一個(gè)自定義 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]);
}

不過(guò)這種方案并不優(yōu)雅,React 考慮提供一個(gè)更優(yōu)雅的方案

有可能被濫用的 useReducer

精讀《useEffect 完全指南》 “將更新與動(dòng)作解耦” 一節(jié)里提到了,利用 useReducer 解決 “函數(shù)同時(shí)依賴多個(gè)外部變量的問(wèn)題”。

一般情況下,我們會(huì)這么使用 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 });

但其實(shí) useReducer 對(duì) stateaction 的定義可以很隨意,因此我們可以利用 useReducer 打造一個(gè) useState。

比如我們創(chuàng)建一個(gè)擁有復(fù)數(shù) key 的 useState:

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

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

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

利用 useReducer 實(shí)現(xiàn)相似的功能:

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" }));

因此針對(duì)如上情況,我們可能濫用了 useReducer,建議直接用 useState 代替。

4. 總結(jié)

本文總結(jié)了具有彈性的組件的四個(gè)特性:不要阻塞數(shù)據(jù)流、時(shí)刻準(zhǔn)備好渲染、不要有單例組件、隔離本地狀態(tài)。

這個(gè)約定對(duì)代碼質(zhì)量很重要,而且難以通過(guò) lint 規(guī)則或簡(jiǎn)單肉眼觀察加以識(shí)別,因此推廣起來(lái)還是有不小難度。

總的來(lái)說(shuō),F(xiàn)unction Component 帶來(lái)了更優(yōu)雅的代碼體驗(yàn),但是對(duì)團(tuán)隊(duì)協(xié)作的要求也更高了。

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

如果你想?yún)⑴c討論,請(qǐng) 點(diǎn)擊這里,每周都有新的主題,周末或周一發(fā)布。前端精讀 - 幫你篩選靠譜的內(nèi)容。

關(guān)注 前端精讀微信公眾號(hào)

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

special Sponsors

版權(quán)聲明:自由轉(zhuǎn)載-非商用-非衍生-保持署名(創(chuàng)意共享 3.0 許可證

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容