当前期刊数: 97

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,组件就不受控了:

1
2
3
4
5
6
7
8
9
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**,直接渲染即可:

1
2
3
4
5
6
function Button({ color, children }) {
return (
// ✅ `color` is always fresh!
<button className={"Button-" + color}>{children}</button>
);
}

如果需要对 props 进行加工,可以利用 useMemo 对加工过程进行缓存,仅当依赖变化时才重新执行:

1
2
3
4
const textColor = useMemo(
() => slowlyCalculateTextColor(color),
[color] // ✅ Don’t recalculate until `color` changes
);

不要阻塞副作用的数据流

发请求就是一种副作用,如果在一个组件内发请求,那么在取数参数变化时,最好能重新取数。

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) {
// ✅ 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,笔者进行了加工。

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();
// Do the fetching...
}, [getFetchUrl]); // ✅ Refetch on change

// ...
}

Function Component 对 propsstate 的数据都一视同仁,且可以将取数逻辑与 “更新判断” 通过 useCallback 完全封装在一个函数内,再将这个函数作为整体依赖项添加到 useEffect,如果未来再新增一个参数,只要修改 getFetchUrl 这个函数即可,而且还可以通过 eslint-plugin-react-hooks 插件静态分析是否遗漏了依赖项。

Function Component 不但将依赖项聚合起来,还解决了 Class Component 分散在多处生命周期的函数判断,引发的无法静态分析依赖的问题。

不要因为性能优化而阻塞数据流

相比 PureComponentReact.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) {
// 🔴 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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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,情况就不一样了,比如下面两种情况:

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 });
// 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 之间切换。

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,同时利用 useCallbackuseContext 有助于解决这个问题。

时刻准备渲染

确保你的组件可以随时重渲染,且不会导致内部状态管理出现 BUG。

要做到这一点其实挺难的,比如一个复杂组件,如果接收了一个状态作为起点,之后的代码基于这个起点派生了许多内部状态,某个时刻改变了这个起始值,组件还能正常运行吗?

比如下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 🤔 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 来解决:

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

将整个应用渲染两遍,看看是否能各自正确运作?

除了组件本地状态由本地维护外,具有弹性的组件不应该因为其他实例调用了某些函数,而 “永远错过了某些状态或功能”。

笔者补充:一个危险的组件一般是这么思考的:没有人会随意破坏数据流,因此只要在 didMountunMount 时做好数据初始化和销毁就行了。

那么当另一个实例进行销毁操作时,可能会破坏这个实例的中间状态。一个具有弹性的组件应该能 随时响应 状态的变化,没有生命周期概念的 Function Component 处理起来显然更得心应手。

隔离本地状态

很多时候难以判断数据属于组件的本地状态还是全局状态。

文章提供了一个判断方法:“想象这个组件同时渲染了两个实例,这个数据会同时影响这两个实例吗?如果答案是 不会,那这个数据就适合作为本地状态”。

尤其在写业务组件时,容易将业务数据与组件本身状态数据混淆。

根据笔者的经验,从上层业务到底层通用组件之间,本地状态数量是递增的:

1
2
3
4
5
业务
-> 全局数据流
-> 页面(完全依赖全局数据流,几乎没有自己的状态)
-> 业务组件(从页面或全局数据流继承数据,很少有自己状态)
-> 通用组件(完全受控,比如 input;或大量内聚状态的复杂通用逻辑,比如 monaco-editor)

3. 精读

再次强调,一个有弹性的组件需要同时满足下面 4 个原则:

  1. 不要阻塞数据流。
  2. 时刻准备好渲染。
  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)}>
);
});

虽然将子组件 CountName 拆分出来,逻辑更加解耦,但子组件需要更新父组件的状态就变得麻烦,我们不希望将函数作为参数透传给子组件。

一种办法是将函数通过 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; // 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,让代码稍微好看些:

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("");
// 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:

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

但其实 useReducerstateaction 的定义可以很随意,因此我们可以利用 useReducer 打造一个 useState

比如我们创建一个拥有复数 key 的 useState:

1
2
3
4
5
6
7
const [state, setState] = useState({ count: 0, name: "nick" });

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

// 修改 name
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" });

// 修改 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 许可证


本站由 钟意 使用 Stellar 1.28.1 主题创建。
又拍云 提供CDN加速/云存储服务
vercel 提供托管服务
湘ICP备2023019799号-1
总访问 次 | 本页访问