我该怎样写好React? —— 基础组件
Facebook在2013年开源了前端MVC框架 —— React,立刻就为Web前端带来了一个新的开发模式,数据驱动渲染,相比之前通过dom接口操作HTML字符串,能做到更好的逻辑分离。按理说,优秀的框架往往让开发人员使用起来更简洁,维护起来更方便,阅读起来更流畅,不应当让开发人员去关注太多的框架细节,不应该有心智负担。
但是在我长久的工作和独立开发的经验中,每当使用React项目变得庞大臃肿之后,总是会变得难以维护,而且因为莫名的重复渲染导致页面变得异常卡顿,这让我偶尔不得不表达一些疑虑,React真的是一个优秀的框架吗?我要怎样才写好它?或是在长期的功能迭代中,是不是应该基于一套基础原则来保持项目的稳定性?
问题所在
React是一个优秀的开发框架吗?不是,React是优秀的渲染引擎。一般来说框架的API要保证功能独立性和完整性,既想要实现一个功能,只需要使用其中某一个独立的API就可完成,而且功能是完整的,不会因为使用了这个API而产生另外的问题,显然React并不是,React只提供了一些基础的API,但这些的API并不能独立的解决实际问题,它需要开发者自行关注并合理使用这些API,比如是否是单一数据源,是否重复渲染等。所以React只能是优秀的渲染引擎,而并非一个开箱即用的开发框架,而大型React项目的维护及页面卡顿问题,正是开发者对React基础API的滥用导致的,开发者应该更谨慎地使用这些API。
单一数据源
Single source of truth,单一数据源是React一直提倡的开发原则,然而React本身的API却无法保证这一原则,而且因为开发者对基础API的滥用而严重违背了这一原则,例如:
从props里传递值到state
从context里取值
从hook里取值
React组件的渲染的数据主要是从上层props传递过来,但是由于某些特殊的业务场景,开发者会在组件内部定义state,然后通过一些逻辑修改这个state并调用setState触发重新渲染,但如果渲染这个state的初始值是从props里来呢,是不是需要把props的值传递过来,如果state发生改变,要不要同步改变外面的props?如果外面的props变化后,已经发生改变的state要不要接受props的值?React作者曾在官网博客上写了一篇文章来描述这个问题[1]。
[1]https://reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html
同理从context或hook取值,如果context或hook关联的数据发生变化,组件要不要重新渲染?如何检测这个数据的变化?组件内部事件如何来改变context或hook的数据?React都没有给出解决方案,它交给了开发者自己来处理。这就是为什么React需要状态管理框架如Redux的原因。
重复渲染
React如何避免重复渲染?答案是不能。由于React结构化的组件描述方式,父组件的重渲染必然触发所有子组件的重渲染,只能缓存,虽然官方曾提供生命周期方法shouldComponentUpdate来避免重渲染,但增加了组件的复杂性和耦合性。目前更推荐通过React.memo或是Redux connect来缓存组件,但还是有问题,缓存功能只会对组件props进行浅比对,如果数据源不是从props传递过来的,比如上面提到的context和hook里,组件则无法更新,但如果组件接收了不必要的props或是被计算过但没有变化的props,组件依然会重复渲染,所以需要Reselect和Immutable来配合使用,来保证props的不可变性,从而解决重复渲染的问题。
解决方案
既然React只是渲染引擎,在实际开发中,依然需要Redux、Reselect、Immutable这类库来帮助项目开发,那是否可以减少React基础API的使用,React组件只作为纯渲染组件,将更多的逻辑交到外部框架来处理,这样的话,组件就可以分为两大类:
纯UI组件
包装组件
纯UI组件,只包含UI的逻辑,所有需要数据都只从props传递过来,应当只包含与UI相关的逻辑和简单的计算中间值。纯UI组件只关注props来渲染UI,所以具有高可复用性。
包装组件,用来组装纯UI组件,连接Redux和Reselect、Context等,包装组件里不应该有任何UI渲染逻辑,只用作组件组装,也不应当有任何数据处理逻辑,数据应该放在Redux和Reselect之中。
那么具体怎么才能写好纯UI组件和包装组件呢,包装组件我会在另一篇文章中介绍,本文会列出一些原则作为开发纯UI组件的依据,这些开发原则会遵循一些通用的设计原理:
KISS(Keep It Simple and Stupid),应该尽量保证API的使用更简单,去除不必要的API,保证易用性,易读性,可维护复用性。
Single source of truth,单一数据源,既然状态管理框架Redux是项目开发中必需的,应该尽量将所有的数据都存放在Redux Store里。
开发原则
使用函数组件
如上据说,纯UI组件只应当包含UI的逻辑,应当是纯渲染的受控组件,函数组件相比类组件更适合渲染纯UI,这也是React官方推荐的组件开发方式。
组件props应当被精心设计
纯UI组件只会依据传递进来的props来渲染UI,所以props是非常重要的,可以按如下原则来设计props
属性应当是展开的,必需的,命名规范的,非结构化的
属性的类型应当尽量是基本类型(string, boolean, integer)
属性的类型也可以是对象类型(object, array),但对象属性的层级不应当超过两层
属性触发渲染应该是直接的和被动的
坏模式
function Item({ data, debugFlag }) {
const {
t, i, p,
desc: { main: { content: { text, vedio } } },
models,
} = data;
return <div>...</div>;
}
好模式
function Item({ title, image, price, descMainContent, models }) {
const {
descMainContentText,
descMainContentVedio,
} = descMainContent;
return <div>...</div>
}
如上所示,在设计组件props时,应当以UI为主,所有的属性名应当符合包含其功能的命名规范,而且应当与业务无关,业务数据应该在包装组件中进行计算并转化再传递到纯UI组件中,而且传递的属性一定要是必需的,不应当有完全不需要的属性被传入,实际开发中,可以通过Typescript来进行编码约束,props的类型尽量以基本类型为主,对象类型层级结构一定要简单,最多不超过两层。这些原则都利于通过React.memo的浅比较进行渲染缓存,从而避免重复渲染。
坏模式
function Item({ itemId }) {
const [data, setData] = useState(null);
useEffect(()=> {
fetchItem(itemId).then(data => setData(data))
}, [itemId])
return data && <div>...</div>;
}
纯UI组件在渲染数据时,应当直接使用props,而且组件本身应当是被动的,不应当根据props的值去另一个地方取别的数据,数据应当是自上而下的,直接而被动,请求数据的操作更应当放在包装组件里去处理。
函数props应该是静态的或是被缓存的
组件经常会接收函数属性,例如onClick,onSubmit,很多情况下,开发者为了方便,直接使用闭包函数作为属性入参,但这样是不正确的,组件渲染将无法被缓存,因为这个函数属性每次都是被重新创建,应使用静态函数,如果函数依赖上层props,那就和普通props一样,使用Reselect进行缓存。当然有一种情况是可以直接使用闭包函数的,当作用于基础组件时,如div, button时,是可以直接使用的,因为基础组件必然会被重渲染,不需要作缓存。
坏模式
function BoxItem({ title, itemId }) {
return <Item title={title} onClick={() => {
dispatch({ type: 'detail', payload: { id: itemId } });
} />;
}
好模式
function BoxItem({ title, itemId, onClick }) {
return <Item title={title} onClick={onClick} />;
}
const selectOnItemClick = createSelector((dispatch, itemId) => {
return () => {
dispatch({ type: 'itemDetail', payload: { itemId } });
}
})
export default connect((state, ownProps) => {
return {...};
}, (dispatch, ownProps) => {
return {
onClick: selectOnItemClick(dispatch, ownProps.itemId);
}
})(BoxItem);
避免在组件内使用计算值
计算值应该放在纯UI组件之外,在外部计算好之后,再通过props传入纯UI组件中,大多情况下会通过Reselect缓存,并在Redux中的
mapStateToProps,mapDispatchToProps中执行。
避免使用生命周期
纯UI组件本身应该是被动的,它只会由传入的属性值的不同,而渲染不同的UI,而且触发渲染也应是自上而下的,不应在生命周期中作一些额外的重渲染逻辑。
尽量避免使用State
如上面单一数据源章节所说,不应尝试将props传递给state,也不应将组件内部state的变化回传给props,绝大多数情况下,是可以直接使用props来代替state,只有可能在特别极少数情况下,state作为完全内部的变量来重渲染,但依然不推荐使用state,将所有数据交到外部单一的数据源依然是一个更好的选择。
谨慎使用使用Context
Context在很多场景下,可以实现多层级的数据共享,而且有一定的作用域,作用域之间数据是隔离的,这能帮忙项目实现很多特别的功能,但也因为Context的数据穿透特性,这严重影响了组件的渲染缓存能力,有以下开发原则可以帮忙开发者更好的使用Context。
纯UI组件中不应该使用Context
Context有限制的作用域,Context的Provider不应作用在根节点上,除了一些框架内置的Context,大多作用于根节点的Context数据都可以维护在Redux中
Context可以用作其本来的用途:上下文。一旦初始化Context数据后,就不应该再被修改。
Context如果一定要作为部分数据源的用途,那么一定要提供修改Context的方法,这个时候Context可以当作作用域更大的State,应该提供类似于State的功能,如const [value, setValue] = useContext();
Context的取值方法,不能使用官方提供的API,<Consumer />和useContext,它们会破坏组件的渲染缓存功能,无法因为数据的变化而重渲染,应当使用HOC将Context的数据进行属性混入。
如何更好的将Context的数据进行属性混入呢,可以参考Redux connect功能,开发一个通用的Context数据混入的HOC —— getContext。
function getContext(contextObjects, mapContextsToProps) {
return Component => props => {
if (contextObjects && mapContextsToProps) {
const contexts = contextObjects
.map(context => useContext(context));
const contextProps =
mapContextsToProps(contexts, props);
return <Component {...props} {...contextProps} />;
}
return <Component {...props} />;
}
}
坏模式
function Item({ title }) {
const theme = useContext(ThemeContext);
return <div>...</div>;
}
好模式
function Item({ title, theme }) {
return <div>...</div>;
}
function BoxItem({ title, theme }) {
return <>
<Item title={title} theme={theme} />
...
</>
}
export default compose(
getContext(
[themeContext, otherContext],
(themeData, otherData) => {
const [value, setValue] = themeData;
return {
theme: value,
changeTheme: setValue
};
},
),
connect((state, ownProps) => {}, (dispatch, ownProps) => {}),
)(props => {
return <ThemeProvider data={useState(null)}>
<BoxItem {...props} />
</ThemeProvider>;
});
通过通用HOC getContext来取出Context数据,并混入到组件的props,这样依然可以通过渲染缓存能力来避免重复缓存。
避免使用Hooks
Hooks一直是一个非常耦合的API,除了在一些特殊的二次封装逻辑中使用,绝大数纯UI组件中并不合适,它给组件带来更多的依赖,而且Hooks只能在函数组件内部中使用,远比不上纯函数带来的通用性,通过在包装组件中进行混入是一个更好的选择。
坏模式
function Item({ onClick }) {
const price = useSelector((state) => state.item.price);
const dispatch = useDispatch();
const onItemClick = (itemId) => {
dispatch({ type: 'itemDetail', payload: { itemId } });
onClick && onClick();
}
return <div>...</div>
}
好模式
function Item({ title, image, price, onClick }) {
return <div>...</div>
}
function BoxItem({ ...props }) {
return <>
<Item
title={itemTitle}
image={itemImage}
price={itemPrice},
onClick={onItemClick}
/>
...
</>
}
const selectOnItemClick = createSelector((dispatch, onClick) => {
return (itemid) => {
dispatch({ type: 'itemDetail', payload: { itemId } });
onClick && onClick();
}
})
export default connect((state, ownProps) => {
return {
itemTitle: selectItemTitle(state),
itemImage: selectItemImage(state),
itemPrice: selectItemPrice(state),
}
}, (dispatch, ownProps) => {
return {
onItemClick:
selectOnItemClick(dispatch, ownProps.onClick);
}
})(BoxItem);
谨慎使用HOC
不应当滥用HOC,开发者喜欢将所有可复用的逻辑,都放到HOC里,这样是不正确的,HOC应当被更谨慎的使用。
HOC不应是获取数据的快捷方式,不要隐式的传递属性值
HOC不应当有顺序依赖
不要在HOC中做一些复杂的魔法逻辑
不要在HOC中混杂UI渲染,显示地表达UI是更好的选择
坏模式
function BoxItem({ ... }) {
return <>
<User />
<Item />
<Address />
</>;
}
const withBody = Component => props => {
return <Body><Component {...props} /></Body>
}
const withUser = Component => props => {
const dispatch = useDispatch();
const user = useSelector(state => state.user);
useEffect(() => {
dispatch({ type: 'fetchUser' })
}, [])
return <Component {...props} user={user} />
}
const withAddress = Component => props => {
const user = props.user;
const dispatch = useDispatch();
const address = useSelector(state => state.address);
useEffect(() => {
user && dispatch({ type: 'fetchUser', payload: { userId: user.id } })
}, [user])
return <Component {...props} address={address} />
}
const withAddress = Component => props => {
const theme = useContext(ThemeContext);
return <Component {...props} theme={theme} />
}
export default compose(
withBody,
withUser,
withAddress,
withTheme,
)(BoxItem);
好模式
function BoxItem({ ... }) {
return <Body>
<User />
<Item />
<Address />
</Body>;
}
export default compose(
getContext([ThemeContext], themeData => {
const [value, setValue] = themeData;
return { theme: value, changeTheme: setValue };
}),
connect((state, ownProps) => {
return {
user: selectUser(state),
item: selectItem(state),
address: selectAddress(state),
}
}, (dispatch, ownProps) => {
return {
fetchUser: () => dispatch({ type: 'fetchUser' });
fetchItem: () => dispatch({ type: 'fetchItem' });
fetchAddress: createSelector((userId) => dispatch({ type: 'fetchUser', payload: { userId } }));
}
})
)(BoxItem);
如上所述示例,在坏模式中,开发者做了太多了魔法逻辑,而且对HOC有顺序依赖,address依赖user,而且为了方便,将Context的数据隐式的传入到组件中。而在好模式中,将更多的逻辑集中到Redux connect中,而且使用更通用的getContext对context的数据进行显示混入。
纯UI组件不应有过多的依赖和装饰
纯UI组件不应当有太多的额外依赖,比如context,hooks,以及selectors,大多数情况应该只会依赖其它纯UI组件,所有计算值及取值逻辑都应该外置在包装组件中,而且不应用使用过多的HOC来对纯UI组件进行装饰,HOC应当只是功能性的,不要引入额外的属性和UI,最常用的装饰功能就是通过React.memo进行渲染缓存。
总结
可以看到,如果基于上面的这些原则,几乎绝大多数的React的API都是不被推荐使用的,那么React推出的这些API的意义又在哪里呢?我们应当把React当作一个渲染引擎,所以它的API很大程度上会被二次封装和整合,例如React-Redux,它便是基于Context来实现的,然而日常开发当中,大多数对React的API使用都是没有被精心维护的,相比直接使用API,将大量的业务逻辑外置到更专业的框架中,似乎是更合理的选择,比如Redux connect的mapStateToProps中,这就是为何要区别纯UI组件和包装组件的原因,关于包装组件的开发原则,我会在另一篇文章中来为大家讲述。