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

黃子毅發表於2019-04-01

1. 引言

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

這次通過 Writing Resilient Components 一文,瞭解一下什麼是有彈性的元件,以及為什麼 Function Component 可以做到這一點。

2. 概述

相比程式碼的 Lint 或者 Prettier,或許我們更應該關注程式碼是否具有彈性。

Dan 總結了彈性元件具有的四個特徵:

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

以上規則不僅適用於 React,它適用於所有 UI 元件。

不要阻塞渲染的資料流

不阻塞資料流的意思,就是 不要將接收到的引數本地化, 或者 使元件完全受控

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

相關文章