代码异味 是什么意思? 简言之,就是暗示可能存在着深层次问题的代码结构。
代码异味
- 太多的 props 传递
- 矛盾的 props
- 从 props 派生 state
- 从函数中返回 JSX
- 多个布尔类型的 state
- 单组件中存在太多的 useState
- 庞大的 useEffect
太多的 props 传递
传递多个 props 到一个组件中暗示着也许这个组件应该被拆分。
你可能会问多少才算太多呢?嗯…“看情况”。你也许会面对这样一种情况,一个组件有着 20 或更多个 props ,但是你依然感到没问题,因为这个组件只做一件事。不过,当你被一个有太多 props 的组件给绊住时,又或者,你急切的想要在已经够长的 props 列表上,不知第几个的 “最后一次” 再加上一个 props ,你可能需要思考以下几个事情:
这个组件是不是做了多件事
和函数一样,组件也应该只做一件事,并做好,所以,时常检查能否把一个组件拆分成多个小组件是一个好习惯。比如说这个组件存在不合适的 props 或 从函数中返回 JSX 。
能进行组合抽象吗?
一个很好却经常被忽略模式是组合组件,而非在一个组件处理所有逻辑。假如说,我们有一个处理用户申请某组织的组件:
<ApplicationForm
user={userData}
organization={organizationData}
categories={categoriesData}
locations={locationsData}
onSubmit={handleSubmit}
onCancel={handleCancel}
...
/>
我们可以看到这个组件的所有 props 都和这个组件做什么有关系,但是依然存在优化的空间,可以将组件承担的一些责任迁移到它的 children 里
<ApplicationForm onSubmit={handleSubmit} onCancel={handleCancel}>
<ApplicationUserForm user={userData} />
<ApplicationOrganizationForm organization={organizationData} />
<ApplicationCategoryForm categories={categoriesData} />
<ApplicationLocationsForm locations={locationsData} />
</ApplicationForm>
现在,我们确保了 ApplicationForm 仅仅处理其应该承担的责任,提交和撤销表单。子组件可以很好在全局上仅仅处理与其自身相关的事情。这同样是一个使用 React Context 来进行父子组件通信的好机会。
我是不是传递了太多“配置” props
在一些场景下,将一些 props 聚合到一个单独的对象上是个好注意,比如说,可以更轻松的交换配置。如果我们有一个战士排序表格的组件:
<Grid
data={gridData}
pagination={false}
autoSize={true}
enableSort={true}
sortOrder="desc"
disableSelection={true}
infiniteScroll={true}
...
/>
这些 props 中除了 data ,都可以认为是一些 配置项 。面对这种情况,将 Grid 改为接受一个 options 为 prop 也许是一个好主意。
const options = {
pagination: false,
autoSize: true,
enableSort: true,
sortOrder: 'desc',
disableSelection: true,
infiniteScroll: true,
...
}
<Grid
data={gridData}
options={options}
/>
这同样意味着当我们想要切换不同的选项时,很容易排除掉我们不想要的
矛盾的 props
避免传递看起来会互相冲突的 props
举个🌰,我们从创建一个处理文本的 组件开始,但过了一会,我们想要这个组件可以处理手机号的输入。组件的实现可能是这样:
function Input({ value, isPhoneNumberInput, autoCapitalize }) {
if (autoCapitalize) capitalize(value)
return <input value={value} type={isPhoneNumberInput ? 'tel' : 'text'} />
}
问题在于 isPhoneNumberInput 和 autoCapitalize 两个 props 在逻辑上是冲突的。我们是无法将手机号进行大写的。
在这种情况下,解决方法是将组件拆分成多个小组件。如果我们仍然有一些逻辑需要复用,我们可以将其移动到 自定义 hook 中:
function TextInput({ value, autoCapitalize }) {
if (autoCapitalize) capitalize(value)
useSharedInputLogic()
return <input value={value} type="text" />
}
function PhoneNumberInput({ value }) {
useSharedInputLogic()
return <input value={value} type="tel" />
}
虽然这个例子显得有点刻意了,但找到逻辑上冲突的 props 是一个迹象,意味着,你应该检查组件是否需要被拆分。
从 props 派生 state
不要通过从 props 派生 state 使得数据之间的关联性消失。
思考下这个组件
function Button({ text }) {
const [buttonText] = useState(text)
return <button>{buttonText}</button>
}
通过传递 text prop 作为 useState 的初始值,现在这个组件会 忽略 后续发生变化的 text prop。即使 text prop 的发生了变化,这个组件依然只会渲染其第一次得到的值。对于大多数 props 来说,这通常是一种意料之外的行为,会导致组件更容易产生 bug 。
作为这种现象更实际的一个例子是,想要根据 prop 进行一些计算,来派生出 state 。在下面的这个例子中,我们调用 slowlyFormatText 函数来格式化我们的 text prop ,执行这个函数需要花费大量的时间。
function Button({ text }) {
const [formattedText] = useState(() => slowlyFormatText(text))
return <button>{formattedText}</button>
}
通过将其放到 state 中,我们避免了函数反复执行的问题,但是我们同样使得组件无法响应 props 的更新。一个更好的方法是通过使用 useMemo hook 来缓存函数结果,来解决这个问题
function Button({ text }) {
const formattedText = useMemo(() => slowlyFormatText(text), [text])
return <button>{formattedText}</button>
}
现在 slowlyFormatText 仅仅在 text 改变的时候重新执行,不会导致组件无法响应更新
有时候我们的确需要忽略某个 prop 的所有后续更新,比如说一个颜色选择器,我们通过配置项来设置了所选颜色的初始值后,当用户选了另一个颜色时,我们不想更新覆写用户的选择的初始数据。在这种情况下,从 prop 的值直接复制到 state 是完全没问题的,不过为了表明这种行为模式,大多数开发者一般将加一些前缀到 prop 的名字,比如 initial 或 default ( initialColor / defaultColor ) 。
从函数中返回 JSX
不要从一个组件内部使用函数返回 JSX
这种模式自从函数组件变得流行后已经大规模消失了,但我时而会遇到他们。用一个例子来演示下我的意思:
function Component() {
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 解决,因为一个庞大的返回值并不意味着 庞大 的问题,但更合理的做法是将这些部分拆分成不同的组件。
记住,创建一个新组件并不代表你必须将其移动到一个新文件中。将多个相关联的组件放在同一个文件中是同样合理的行为。
多个布尔类型的 state
避免使用多个布尔值来表征组件的状态。
当你正在写一个组件,并经常扩展组件的功能时,很容易就会遇到使用多个布尔值来表征组件当前状态的情况。对于一个被点击时进行网络请求的按钮组件来说,你可能会有这样的实现:
function Component() {
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 ,如果存在 error 的话。
尽管组件可以正常的完成工作,却很难去理解当前组件所处的状态,并且比其他方案更容易出错。我们同于可能陷入到 ”非法状态“ 中,假如我们意外的同时将 isLoading 和 isFinished 设置为 true。
解决这种状况的好办法是通过 ”枚举“ 来管理状态。在其他语言中,枚举是一种定义变量的方法,该变量仅允许设置为预定义的常量值集合,严格来讲,枚举在 JavaScript 并不存在,我们可以使用字符串作为枚举值来获得相关的好处
function Component() {
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 那就更好了,因为你可以这样来指示可能的 state
const [state, setState] = useState<'idle' | 'loading' | 'error' | 'finished'>('idle')
太多的 useState
避免在单组件中使用太多的 useState hooks 。
一个带有多个 useState hooks 的组件很可能在做多个事情,可能更好的做法是拆分成多个组件,但是确实也存在一些复杂的情况,我们需要在单组件中管理各种复杂的状态
这里有个例子,演示了一个 autocomplete 组件中的状态和函数是什么样子的:
function AutocompleteInput() {
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 函数来更新状态 。这些函数都必须使用一些来自 useState hook 返回的 setter 来完成相关的任务。 现在想象一下,我们还有许多其他的操作需要更新状态,并且很容易看出长期情况下,这种状况很难保证不产生任何 bug 。面对这些场景,通过使用 useReducer hook 来管理状态,能对我们起到很大的帮助。
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:
throw Error()
}
}
function AutocompleteInput() {
const [state, dispatch] = useReducer(reducer, initialState)
const reset = () => {
dispatch({ type: 'reset' })
}
const selectItem = (item) => {
dispatch({ type: 'selectItem', payload: item })
}
...
}
通过使用 reducer ,我们将状态管理相关的逻辑封装了起来,并将相关的复杂性与组件隔离了出来。我们可以单独的去思考我们的组件和状态,这使得我们理解到底发生了什么很有帮助。
useState 和 useReducer 有各自的 利弊 和 使用场景。我最喜欢的 reducer 模式的是 state reducer pattern by Kent C. Dodds 。
庞大的 useEffect
避免会同时做很多事情庞大的 useEffect 。这会使得代码容易出错和难以理解。
hooks 发布时我经常犯的一个错误是在一个 useEffect 里面放了太多东西 。 为了演示,这是仅含一个 useEffect 的组件:
function Post({ id, unlisted }) {
...
useEffect(() => {
fetch(`/posts/${id}`).then(/* do something */)
setVisibility(unlisted)
}, [id, unlisted])
...
}
尽管这个 effect 并不庞大,但依然同时做了多个事情。当 unlisted prop 更新后,我们同样会去拉取数据,尽管 id 并没有发生变化。
为了捕捉住这样的错误,我尝试写或说 ”当 [dependencies] 改变时做这个“ 来描述 effect ,给自己参考。将这种模式应用到上述的 effect 我们可以得到 ”当 id 或 unlisted 改变后,拉取数据 和 更新状态“。如果这个句子包括字词 ”和“ 或 ”或“ 这通常暗示了有问题存在。
应该将这个 effect 拆分成两个 effect:
function Post({ id, unlisted }) {
...
useEffect(() => { // when id changes fetch the post
fetch(`/posts/${id}`).then(/* ... */)
}, [id])
useEffect(() => { // when unlisted changes update visibility
setVisibility(unlisted)
}, [unlisted])
...
}
这样做,我们减少了组件的复杂度,使其更容易理解并降低了发生 bug 的可能性。
总结
好了,这就是全部了!记住,这些东西并不是意味着规则,更多是一种信号,指示着也许有些事情可能 ”出错“ 了。你肯定会遇到有充分理由要需要用到以上方式的情况。