vlambda博客
学习文章列表

我该怎样写好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传递过来的,比如上面提到的contexthook里,组件则无法更新,但如果组件接收了不必要的props或是被计算过但没有变化的props,组件依然会重复渲染,所以需要ReselectImmutable来配合使用,来保证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

  1. 属性应当是展开的,必需的,命名规范的,非结构化的

  2. 属性的类型应当尽量是基本类型(string, boolean, integer)

  3. 属性的类型也可以是对象类型(object, array),但对象属性的层级不应当超过两层

  4. 属性触发渲染应该是直接的和被动的


坏模式

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。

  1. 纯UI组件中不应该使用Context

  2. Context有限制的作用域,Context的Provider不应作用在根节点上,除了一些框架内置的Context,大多作用于根节点的Context数据都可以维护在Redux中

  3. Context可以用作其本来的用途:上下文。一旦初始化Context数据后,就不应该再被修改。

  4. Context如果一定要作为部分数据源的用途,那么一定要提供修Context的方法,这个时候Context可以当作作用域更大的State,应该提供类似于State的功能,如const [value, setValue] = useContext(); 

  5. 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应当被更谨慎的使用。

  1. HOC不应是获取数据的快捷方式,不要隐式的传递属性值

  2. HOC不应当有顺序依赖

  3. 不要在HOC中做一些复杂的魔法逻辑

  4. 不要在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组件不应当有太多的额外依赖,比如contexthooks,以及selectors,大多数情况应该只会依赖其它纯UI组件,所有计算值及取值逻辑都应该外置在包装组件中,而且不应用使用过多的HOC来对纯UI组件进行装饰,HOC应当只是功能性的,不要引入额外的属性和UI,最常用的装饰功能就是通过React.memo进行渲染缓存。

总结

可以看到,如果基于上面的这些原则,几乎绝大多数的React的API都是不被推荐使用的,那么React推出的这些API的意义又在哪里呢?我们应当把React当作一个渲染引擎,所以它的API很大程度上会被二次封装和整合,例如React-Redux,它便是基于Context来实现的,然而日常开发当中,大多数对React的API使用都是没有被精心维护的,相比直接使用API,将大量的业务逻辑外置到更专业的框架中,似乎是更合理的选择,比如Redux connect的mapStateToProps中,这就是为何要区别纯UI组件和包装组件的原因,关于包装组件的开发原则,我会在另一篇文章中来为大家讲述。