读了 精读《useEffect 完全指南》 之后, 是不是对 Function Component 的理解又加深了一些呢?
这次通过 Writing Resilient Components 一文, 了解一下什么是有弹性的组件, 以及为什么 Function Component 可以做到这一点.
2. 概述
相比代码的 Lint 或者 Prettier, 或许我们更应该关注代码是否具有弹性.
Dan https://mobile.twitter.com/dan_abramov 总结了弹性组件具有的四个特征:
不要阻塞数据流.
时刻准备好渲染.
不要有单例组件.
隔离本地状态.
以上规则不仅适用于 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. 这时候如果你尝试通过其他生命周期 (componentWillReceiveProps 或 componentDidUpdate) 去修复, 代码会变得难以管理.
然而 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 抽出来, 并且在 componentDidMount 与 componentDidUpdate 时同时调用它, 还要注意 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 对 props 与 state 的数据都一视同仁, 且可以将取数逻辑与 "更新判断" 通过 useCallback 完全封装在一个函数内, 再将这个函数作为整体依赖项添加到 useEffect, 如果未来再新增一个参数, 只要修改 fetchResults 这个函数即可, 而且还可以通过 eslint-plugin-react-hooks 插件静态分析是否遗漏了依赖项.
Function Component 不但将依赖项聚合起来, 还解决了 Class Component 分散在多处生命周期的函数判断, 引发的无法静态分析依赖的问题.
不要因为性能优化而阻塞数据流
相比 PureComponent 与 React.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 随机在 null 与 this.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, 同时利用 useCallback 与 useContext 有助于解决这个问题.
时刻准备渲染
确保你的组件可以随时重渲染, 且不会导致内部状态管理出现 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")
- );
将整个应用渲染两遍, 看看是否能各自正确运作?
除了组件本地状态由本地维护外, 具有弹性的组件不应该因为其他实例调用了某些函数, 而 "永远错过了某些状态或功能".
笔者补充: 一个危险的组件一般是这么思考的: 没有人会随意破坏数据流, 因此只要在 didMount 与 unMount 时做好数据初始化和销毁就行了.
那么当另一个实例进行销毁操作时, 可能会破坏这个实例的中间状态. 一个具有弹性的组件应该能 随时响应 状态的变化, 没有生命周期概念的 Function Component 处理起来显然更得心应手.
隔离本地状态
很多时候难以判断数据属于组件的本地状态还是全局状态.
文章提供了一个判断方法:"想象这个组件同时渲染了两个实例, 这个数据会同时影响这两个实例吗? 如果答案是 不会, 那这个数据就适合作为本地状态".
尤其在写业务组件时, 容易将业务数据与组件本身状态数据混淆.
根据笔者的经验, 从上层业务到底层通用组件之间, 本地状态数量是递增的:
业务
-> 全局数据流
-> 页面(完全依赖全局数据流, 几乎没有自己的状态)
-> 业务组件(从页面或全局数据流继承数据, 很少有自己状态)
-> 通用组件(完全受控, 比如 input; 或大量内聚状态的复杂通用逻辑, 比如 monaco-editor)
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)}>
- );
- });
虽然将子组件 Count 与 Name 拆分出来, 逻辑更加解耦, 但子组件需要更新父组件的状态就变得麻烦, 我们不希望将函数作为参数透传给子组件.
一种办法是将函数通过 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 });
但其实 useReducer 对 state 与 action 的定义可以很随意, 因此我们可以利用 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 https://github.com/dt-fe/weekly/issues/139
如果你想参与讨论, 请 点击这里, 每周都有新的主题, 周末或周一发布. 前端精读 - 帮你筛选靠谱的内容.
关注 前端精读微信公众号
special Sponsors
DevOps 全流程平台 https://e.coding.net/?utm_source=weekly
来源: https://www.cnblogs.com/ascoders/p/10634500.html