13

[译] React 组件中的七种代码坏味道

 3 years ago
source link: https://mp.weixin.qq.com/s/duNmKn1o4uaZwJ2boaf4QA
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

React 组件中的七种代码坏味道

原文链接 https://dev.to/awnton/7-code-smells-in-react-components-5f66 译文中的我是指代原文作者,译者的话会以译者注的形式出现。

以下是我目前收集到的 React 组件中的7种坏味道

组件使用过多的属性 组件的属性之间不兼容 拷贝组件的属性到组件的状态 组件的中又定义函数式组件 多个布尔类型的组件状态 组件中使用过多的 useState useEffect 中实现太多功能

组件使用过多的属性

2iumamI.png!mobile

如果一个组件有太多的属性,说明这个组件应该拆分下。较真的程序员会问,那多少个属性算多呢?嗯。。。看情况。如果一个组件有 20 多个属性,但是这个组件依然满足“只做一件事情”的原则,那这个 20 个属性也不算多。(译者:20个属性,我觉得基本不可能是只做一件事情)但是如果当你遇到了一个有很多属性的组件,或者要给这样的组件再添加一个属性的时候,从下面几个点看看。

这个组件是不是做了多件事情?

和函数一样,组件最好只做好一件事,所以还是看看能否把这个组件拆分成多个较小的组件。关于拆分可以参考后面提到的“不相关的组件属性”和“组件的中又定义函数式组件”的处理方式。

能不能使用组合

组合是一个经常被忽略的很好的设计模式,采用组合的方式可以避免在一个组件里面处理所有的逻辑。假设我们有一个处理用来处理用户提交申请的组件。

<ApplicationForm

user={userData}

organization={organizationData}

categories={categoriesData}

locations={locationsData}

onSubmit={handleSubmit}

onCancel={handleCancel}

...

/>

虽然这个组件接收的属性都是它需要的,但是还是有改进的空间,比如把一些功能拆分到子组件里面。

<ApplicationFormonSubmit={handleSubmit}onCancel={handleCancel}>

<ApplicationUserFormuser={userData}/>

<ApplicationOrganizationFormorganization={organizationData}/>

<ApplicationCategoryFormcategories={categoriesData}/>

<ApplicationLocationsFormlocations={locationsData}/>

</ApplicationForm>

这样我们就保证了 ApplicationForm 组件处理了更加内聚的职责; 只负责提交或者撤销这个表单的功能。而那些子组件则负责处理了他们在整个功能中自己的小功能;这样的模块划分也非常适合使用 React 的 Context ,让父子组件通信,实现数据共享。

把过多的配置属性可以组合到一起

把一些配置属性放到一个配置对象里面是一个好主意,这样做可以很容易的切换另外一套配置。看下面的例子

<Grid

data={gridData}

pagination={false}

autoSize={true}

enableSort={true}

sortOrder="desc"

disableSelection={true}

infiniteScroll={true}

...

/>

除了 data 属性其他的属性都是配置属性。在种情况下,把这些配置属性组成一个配置对象,把配置对象给到组件是一个很好的方案。

const options ={

pagination:false,

autoSize:true,

enableSort:true,

sortOrder:'desc',

disableSelection:true,

infiniteScroll:true,

...

}

<Grid

data={gridData}

options={options}

/>

这样方法也可以让我们需要使用不同的配置的时候,很方便的切换不同的配置。

组件的属性之间不兼容

避免把一些不相兼容的属性放到一个组件里面。

比如,我们一开始写了个处理文本输入的 Input 组件,没过多久我们可能就还需要一个处理电话号码的组件,我们的实现可能变成这样。

functionInput({ value, isPhoneNumberInput, autoCapitalize }){

if(autoCapitalize) capitalize(value)

return<input value={value}

type={isPhoneNumberInput ?

'tel':

'text'

}/>

}

这个组件的问题再于,如果属性 isPhoneNumberInput (是否是电话号码) 和 autoCapitalize (自动大写)同时为 true 时是没有意义的,因为我们从来没有大写电话号码的需要(译者注:这就是这两个属性不兼容的意思)。

在这个问题上,最好的解决方法就是拆分成两个更小的组件。如果我们想在两个组件里面共享一些相同的逻辑,那我们可以自定义一些 hooks。

functionTextInput({ value, autoCapitalize }){

if(autoCapitalize) capitalize(value)

useSharedInputLogic()

return<input value={value} type="text"/>

}

functionPhoneNumberInput({ value }){

useSharedInputLogic()

return<input value={value} type="tel"/>

}

虽然这个例子看起来很生硬 ,但是当发现组件的属性之间有不兼容的情况,这是一个信号提醒你可以尝试把这个组件拆分一下。

拷贝属性到组件状态

2uyayum.png!mobile

拷贝属性到组件的状态里面的话就会中断组件的数据流,我们来看看下面的例子:

functionButton({ text }){

const[buttonText]= useState(text)

return<button>{buttonText}</button>

}

text 属性作为初始值传给了useState ,这样一来 Button 组件就忽略了 text 的更新。如果 text 属性更新, Button 组件渲染的还是一开始的初始值。大多数情况这样的行为不是期望的,而且这样的做法也会带来 bug。

一个更加常见的例子是,我们希望从属性继承一些值进行复杂耗时的计算。比如下面的例子,我们需要调用非常耗时的文本格式化函数 slowlyFormatText 。

functionButton({ text }){

const[formattedText]= useState(()=> slowlyFormatText(text))

return<button>{formattedText}</button>

}

把 text 放到 state 确实避免了重新渲染带来的不必要的对slowlyFormatText的调用,但是一样还是有 text 本身变化,而导致组件文本不更新的问题。一个更好的方法来解决这个问题就是使用useMemo 这个 hook,来缓存复杂的计算结果。

functionButton({ text }){

const formattedText = useMemo(()=> slowlyFormatText(text),[text])

return<button>{formattedText}</button>

}

这个 slowlyFormatText 值会在 text 属性变化了才会被调用,同样组件显示的文本也会随着 text 的更新而更新。

在某些情况下我们也确实需要对某些属性的更新进行忽略,比如一个颜色选择器组件,我们只需要属性来设置它的初始的选择的颜色,同时用户如果选择了新的颜色,我们应该使用用户选择的颜色(而不是初始值),在这样的需求下把属性传给状态就非常的适合。但是最好还是通过在属性名前面加上一些前缀(initialColor/defaultColor)来强调你这么做的意图。

扩展阅读 Redux 作者 Dan Abramov 的博客《编写有弹性的组件》(https://overreacted.io/zh-hans/writing-resilient-components/), 里面也谈到了把属性赋值给状态的问题。

组件的中又定义函数式组件

UjmA3mf.jpg!mobile

不要在组件的里面定义组件了。

自从函数组件流行起来以后,这种做法已经很少出现了。但是隔三差五我还是会碰到,先举个例子给你们看看。

functionComponent(){

const topSection =()=>{

return(

<header>

<h1>Component header</h1>

</header>

)

}

const middleSection =()=>{

return(

<main>

<p>Some text</p>

</main>

)

}

const bottomSection =()=>{

return(

<footer>

<p>Some footer text</p>

</footer>

)

}

return(

<div>

{topSection()}

{middleSection()}

{bottomSection()}

</div>

)

}

这样的代码乍一看觉得没啥问题,但是它让代码更难阅读,阻碍了好实践的模式;这样的做法是应该避免的。怎么解决这个问题呢,我一般会选择把 JSX 内联到 return 中去,但是更多的情况下应该把那些 JSX 提取到一个单独的组件里面去。

注意!当你创建一个新的组件你没有必要一定要新建一个文件;如果组件之间有很强的耦合,把它们都放在一个文件里面也无可厚非。

多个布尔类型的组件状态

ErQfu2m.png!mobile

避免在组件中使用过多的 boolean 类型的状态。

在开发一个组件的时,需要扩展这个组件的时候很容易就会通过用布尔类型的值来表示组件的不同状态。

假设一个这样的场景,组件中的按钮点击后,组件就发起一个 web 网络请求,那你的代码大致是这样的。

functionComponent(){

const[isLoading, setIsLoading]= useState(false)

const[isFinished, setIsFinished]= useState(false)

const[hasError, setHasError]= useState(false)

const fetchSomething =()=>{

setIsLoading(true)

fetch(url)

.then(()=>{

setIsLoading(false)

setIsFinished(true)

})

.catch(()=>{

setHasError(true)

})

}

if(isLoading)return<Loader/>

if(hasError)return<Error/>

if(isFinished)return<Success/>

return<button onClick={fetchSomething}/>

}

当按钮被点击后,我们先赋值 isLoading 为 true , 再用 fetch 发送一个网络请求。如果请求成功返回,我们就把 isLoading 设置为 false,同时标记 isFinished 为 true;如果出错了设置 hasError 为 true。

这样方法是好用,但是这样的方法很难确定当前组件的状态,而且相对其他方案非常容易引入 bug(上面例子里面,进入错误状态时候,代码依然是按 loading 状态再处理,因为 isLoading 为 true)。在上面的例子里面,我们同时设置 isLoading 和 isFinished 为 true,那组件就进入到了一种不可能的状态(译者注:这是状态之间不兼容。)。

处理这类问题一个更推荐的做法是:使用枚举来管理状态。枚举在其他语言中的定义是那些只能预先赋值成常量的变量,虽然在 javascript 中是没有枚举,但我们可以用字符串来代替,而且同样能获得枚举的好处(译者注:大家有空可以看看 xstate 这个库 https://github.com/davidkpiano/xstate,在状态机方面是专业的,也支持 Reac)。

functionComponent(){

const[state, setState]= useState('idle')

const fetchSomething =()=>{

setState('loading')

fetch(url)

.then(()=>{

setState('finished')

})

.catch(()=>{

setState('error')

})

}

if(state ==='loading')return<Loader/>

if(state ==='error')return<Error/>

if(state ==='finished')return<Success/>

return<button onClick={fetchSomething}/>

}

通过这样的方法,我就消除不可能的状态的隐患,而且这样非常容易确定当前组件的状态。如果我们再使用一些类型系统比如 typescript,提前定义好状态机的状态,那样会更好了。

const [state, setState] = useState<'idle' | 'loading' | 'error' | 'finished'>('idle')

组件中使用过多的 useState

qMvUzeB.png!mobile

不要在一个组件里面用太多的 useState。

一个组件用了过多的 useState 说明这个组件做了太多的事情,这样的组件非常适合拆分成多个小组件。当然也有些情况我们也确实需要再一个组件内处理复杂的状态机逻辑。

下面这个例子,一个自动补全的输入框,里面有多个状态和函数。

functionAutocompleteInput(){

const[isOpen, setIsOpen]= useState(false)

const[inputValue, setInputValue]= useState('')

const[items, setItems]= useState([])

const[selectedItem, setSelectedItem]= useState(null)

const[activeIndex, setActiveIndex]= useState(-1)

const reset =()=>{

setIsOpen(false)

setInputValue('')

setItems([])

setSelectedItem(null)

setActiveIndex(-1)

}

const selectItem =(item)=>{

setIsOpen(false)

setInputValue(item.name)

setSelectedItem(item)

}

...

}

我们有一个 reset 函数,重置所有的状态,一个  selectItem 函数,用来更新组件的状态。这两个函数都用到了一些状态的设置函数来完成他们的任务。

试想下,如果我们有更多对状态的更新操作,那这样的做法势必是越来越难维护,而且长期来看很容易引入 bug。在这样的情况下使用 useReducer 来管理状态的就会好很多。

const initialState ={

isOpen:false,

inputValue:"",

items:[],

selectedItem:null,

activeIndex:-1

}

function reducer(state, action){

switch(action.type){

case"reset":

return{

...initialState

}

case"selectItem":

return{

...state,

isOpen:false,

inputValue: action.payload.name,

selectedItem: action.payload

}

default:

throwError()

}

}

functionAutocompleteInput(){

const[state, dispatch]= useReducer(reducer, initialState)

const reset =()=>{

dispatch({ type:'reset'})

}

const selectItem =(item)=>{

dispatch({ type:'selectItem', payload: item })

}

}

通过使用 reducer 我们把状态的复杂逻辑封装起来,移到组件外。把组件的 UI 和组件的逻辑分离让组件变得易于理解。使用 useState 和 useReducer 在不同的场景下各有利弊。关于 reducer 使用方法,个人最推荐这篇文章由著名 React 布道师、培训师 Kent C. Dodds 的文章 《The State Reducer Pattern with React Hooks 》 https://kentcdodds.com/blog/the-state-reducer-pattern-with-react-hooks/。

useEffect 中实现太多功能

BrYjEr6.png!mobile

避免在 useEffect 的回调用中实现大量的逻辑;这样会让你的代码会更加容易引入 bug ,并且降低可读性。

当 react hooks 特性刚发布的使用,我在使用的时候经常犯的错误就是在一个 useEffect 中实现过多的逻辑。举个例子,下面的组件的一个 useEffect 中做了两件事情。

functionPost({ id, unlisted }){

...

useEffect(()=>{

fetch(`/posts/${id}`).then(

/* do something */

)

setVisibility(unlisted)

},[id, unlisted])

}

虽然例子中的副总用(effect)并不是特别的庞大,但是当只有 unlisted 属性变化了之后会额外的触发一次网络请求,即使 id 没有发生变化。

为了发现这种类似错误,我这样来自己检查 useEffect 。“如果xx 或者 yy 发生变化,那就执行 zz”。应用到这个例子中,“当  id 或者  unlisted 属性发生变化,那么我们就获取新的帖子  并且 更新它是否可见”。

如果一个句子里面同时出现了或者和并且,通常它是有问题的。那把这个 effect 拆成两个就好很多。

functionPost({ id, unlisted }){

useEffect(()=>{

// when id changes fetch the post

fetch(`/posts/${id}`).then(/* ... */)

},[id])

useEffect(()=>{

// when unlisted changes update visibility

setVisibility(unlisted)

},[unlisted])

}

通过这样的拆分我们,我们降低了组件的复杂度,让它更加容易理解,同时降低引入bug的风险。

总结

好了,终于讲完了。但是一定要记住这7个点只是代码外在的可疑的表现,并不是一杆子说代码一定有错。因为在实际开发的过程中还是会碰到需要这么做的特殊情况。

如果你觉得我的文章写的有问题,或者你想提议一个其他的代码坏味道你,欢迎在文章下面留言,或者在 twitter(@awnton)上联系我。

如果你觉得本文对你有帮助欢迎点赞,转发和赞赏。

mYjEja.jpg!mobile


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK