读了 精读《useEffect 彻底指南》 以后,是否是对 Function Component 的理解又加深了一些呢?html
此次经过 Writing Resilient Components 一文,了解一下什么是有弹性的组件,以及为何 Function Component 能够作到这一点。前端
相比代码的 Lint 或者 Prettier,或许咱们更应该关注代码是否具备弹性。react
Dan 总结了弹性组件具备的四个特征:git
以上规则不只适用于 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。这时候若是你尝试经过其余生命周期(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)
再次强调,一个有弹性的组件须要同时知足下面 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 效果。
在 精读《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 考虑提供一个更优雅的方案。
在 精读《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
代替。
本文总结了具备弹性的组件的四个特性:不要阻塞数据流、时刻准备好渲染、不要有单例组件、隔离本地状态。
这个约定对代码质量很重要,并且难以经过 lint 规则或简单肉眼观察加以识别,所以推广起来仍是有不小难度。
总的来讲,Function Component 带来了更优雅的代码体验,可是对团队协做的要求也更高了。
讨论地址是: 精读《编写有弹性的组件》 · Issue #139 · dt-fe/weekly
若是你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。
关注 前端精读微信公众号
<img width=200 src="https://img.alicdn.com/tfs/TB...;>
special Sponsors
版权声明:自由转载-非商用-非衍生-保持署名( 创意共享 3.0 许可证)