当前期刊数: 97
1. 引言 读了 精读《useEffect 完全指南》 之后,是不是对 Function Component 的理解又加深了一些呢?
这次通过 Writing Resilient Components 一文,了解一下什么是有弹性的组件,以及为什么 Function Component 可以做到这一点。
2. 概述 相比代码的 Lint 或者 Prettier,或许我们更应该关注代码是否具有弹性。
Dan 总结了弹性组件具有的四个特征:
不要阻塞数据流。
时刻准备好渲染。
不要有单例组件。
隔离本地状态。
以上规则不仅适用于 React,它适用于所有 UI 组件。
不要阻塞渲染的数据流 不阻塞数据流的意思,就是 不要将接收到的参数本地化, 或者 使组件完全受控 。
在 Class Component 语法下,由于有生命周期的概念,在某个生命周期将 props
存储到 state
的方式屡见不鲜。 然而一旦将 props
固化到 state
,组件就不受控了:
1 2 3 4 5 6 7 8 9 class Button extends React.Component { state = { color : this .props .color }; render ( ) { const { color } = this .state ; return <button className ={ "Button- " + color }> {this.props.children}</button > ; } }
当组件再次刷新时,props.color
变化了,但 state.color
不会变,这种情况就阻塞了数据流,小伙伴们可能会吐槽组件有 BUG。这时候如果你尝试通过其他生命周期(componentWillReceiveProps
或 componentDidUpdate
)去修复,代码会变得难以管理。
然而 Function Component 没有生命周期的概念,**所以没有必须要将 props
存储到 state
**,直接渲染即可:
1 2 3 4 5 6 function Button ({ color, children } ) { return ( <button className ={ "Button- " + color }> {children}</button > ); }
如果需要对 props
进行加工,可以利用 useMemo
对加工过程进行缓存,仅当依赖变化时才重新执行:
1 2 3 4 const textColor = useMemo ( () => slowlyCalculateTextColor (color), [color] );
不要阻塞副作用的数据流 发请求就是一种副作用,如果在一个组件内发请求,那么在取数参数变化时,最好能重新取数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 class SearchResults extends React.Component { state = { data : null }; componentDidMount ( ) { this .fetchResults (); } componentDidUpdate (prevProps ) { if (prevProps.query !== this .props .query ) { this .fetchResults (); } } fetchResults ( ) { const url = this .getFetchUrl (); } getFetchUrl ( ) { return "http://myapi/results?query" + this .props .query ; } render ( ) { } }
如果用 Class Component 的方式实现,我们需要将请求函数 getFetchUrl
抽出来,并且在 componentDidMount
与 componentDidUpdate
时同时调用它,还要注意 componentDidUpdate
时如果取数参数 state.query
没有变化则不执行 getFetchUrl
。
这样的维护体验很糟糕,如果取数参数增加了 state.currentPage
,你很可能在 componentDidUpdate
中漏掉对 state.currentPage
的判断。
如果使用 Function Component,可以通过 useCallback
将整个取数过程作为一个整体:
原文没有使用 useCallback
,笔者进行了加工。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 function SearchResults ({ query } ) { const [data, setData] = useState (null ); const [currentPage, setCurrentPage] = useState (0 ); const getFetchUrl = useCallback (() => { return "http://myapi/results?query=" + query + "&page=" + currentPage; }, [currentPage, query]); useEffect (() => { const url = getFetchUrl (); }, [getFetchUrl]); }
Function Component 对 props
与 state
的数据都一视同仁,且可以将取数逻辑与 “更新判断” 通过 useCallback
完全封装在一个函数内,再将这个函数作为整体依赖项添加到 useEffect
,如果未来再新增一个参数,只要修改 getFetchUrl
这个函数即可,而且还可以通过 eslint-plugin-react-hooks
插件静态分析是否遗漏了依赖项。
Function Component 不但将依赖项聚合起来,还解决了 Class Component 分散在多处生命周期的函数判断,引发的无法静态分析依赖的问题。
不要因为性能优化而阻塞数据流 相比 PureComponent
与 React.memo
,手动进行比较优化是不太安全的,比如你可能会忘记对函数进行对比:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class Button extends React.Component { shouldComponentUpdate (prevProps ) { return this .props .color !== prevProps.color ; } render ( ) { const onClick = this .props .onClick ; 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class MyForm extends React.Component { handleClick = () => { }; render ( ) { return ( <> <h1 > Hello!</h1 > <Button color ="green" onClick ={this.handleClick} > Press me </Button > </> ); } }
但是一旦换一种方式实现 onClick
,情况就不一样了,比如下面两种情况:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 class MyForm extends React.Component { state = { isEnabled : true }; handleClick = () => { this .setState ({ isEnabled : false }); }; 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
之间切换。
1 2 3 4 5 6 7 8 9 10 11 12 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。
要做到这一点其实挺难的,比如一个复杂组件,如果接收了一个状态作为起点,之后的代码基于这个起点派生了许多内部状态,某个时刻改变了这个起始值,组件还能正常运行吗?
比如下面的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class TextInput extends React.PureComponent { state = { value : "" }; 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
来解决:
1 <EmailInput defaultEmail={this .props .user .email } key={this .props .user .id } />
另外也可以通过 ref
解决,让子元素提供一个 reset
函数,不过不推荐使用 ref
。
不要有单例组件 一个有弹性的应用,应该能通过下面考验:
1 2 3 4 5 6 7 ReactDOM .render ( <> <MyApp /> <MyApp /> </> , document .getElementById ("root" ) );
将整个应用渲染两遍,看看是否能各自正确运作?
除了组件本地状态由本地维护外,具有弹性的组件不应该因为其他实例调用了某些函数,而 “永远错过了某些状态或功能”。
笔者补充:一个危险的组件一般是这么思考的:没有人会随意破坏数据流,因此只要在 didMount
与 unMount
时做好数据初始化和销毁就行了。
那么当另一个实例进行销毁操作时,可能会破坏这个实例的中间状态。一个具有弹性的组件应该能 随时响应 状态的变化,没有生命周期概念的 Function Component 处理起来显然更得心应手。
隔离本地状态 很多时候难以判断数据属于组件的本地状态还是全局状态。
文章提供了一个判断方法:“想象这个组件同时渲染了两个实例,这个数据会同时影响这两个实例吗?如果答案是 不会,那这个数据就适合作为本地状态”。
尤其在写业务组件时,容易将业务数据与组件本身状态数据混淆。
根据笔者的经验,从上层业务到底层通用组件之间,本地状态数量是递增的:
1 2 3 4 5 业务 -> 全局数据流 -> 页面(完全依赖全局数据流,几乎没有自己的状态) -> 业务组件(从页面或全局数据流继承数据,很少有自己状态) -> 通用组件(完全受控,比如 input;或大量内聚状态的复杂通用逻辑,比如 monaco-editor)
3. 精读 再次强调,一个有弹性的组件需要同时满足下面 4 个原则:
不要阻塞数据流。
时刻准备好渲染。
不要有单例组件。
隔离本地状态。
想要遵循这些规则看上去也不难,但实践过程中会遇到不少问题,笔者举几个例子。
频繁传递回调函数 Function Component 会导致组件粒度拆分的比较细,在提高可维护性同时,也会导致全局 state
成为过去,下面的代码可能让你觉得别扭:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 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
传给子组件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 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
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 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 ={dispatch} > <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 的函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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
规避这个问题:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 function Form ( ) { const [text, updateText] = useState ("" ); const textRef = useRef (); useEffect (() => { textRef.current = text; }); const handleSubmit = useCallback (() => { const currentText = textRef.current ; alert (currentText); }, [textRef]); return ( <> <input value ={text} onChange ={e => updateText(e.target.value)} /> <ExpensiveTree onSubmit ={handleSubmit} /> </> ); }
当然,也可以将这个过程封装为一个自定义 Hooks,让代码稍微好看些:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 function Form ( ) { const [text, updateText] = useState ("" ); 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
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 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
:
1 2 3 4 5 6 7 const [state, setState] = useState ({ count : 0 , name : "nick" });setState (state => ({ ...state, count : 1 }));setState (state => ({ ...state, name : "jack" }));
利用 useReducer
实现相似的功能:
1 2 3 4 5 6 7 8 9 10 11 function reducer (state, action ) { return action (state); }const [state, dispatch] = useReducer (reducer, { count : 0 , name : "nick" });dispatch (state => ({ ...state, count : 1 }));dispatch (state => ({ ...state, name : "jack" }));
因此针对如上情况,我们可能滥用了 useReducer
,建议直接用 useState
代替。
4. 总结 本文总结了具有弹性的组件的四个特性:不要阻塞数据流、时刻准备好渲染、不要有单例组件、隔离本地状态。
这个约定对代码质量很重要,而且难以通过 lint 规则或简单肉眼观察加以识别,因此推广起来还是有不小难度。
总的来说,Function Component 带来了更优雅的代码体验,但是对团队协作的要求也更高了。
讨论地址是:精读《编写有弹性的组件》 · Issue ##139 · dt-fe/weekly
如果你想参与讨论,请 点击这里 ,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。
关注 前端精读微信公众号
special Sponsors
版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证 )