正确的打开方式!河里地编写 React hooks 实践心得

前言

hello 大家好,我是做了四年前端开发 Xekin,这几年来兜兜转转几家大大小小的公司,也做过各种技术栈的前端工作,但也许是运气问题,这些年看过的 React 项目确实是很少有能令人舒心的 React 代码,基本每个用 React hooks 写的项目都有不少大坑。所以想写这篇文章来分享一下自己写 React 的一些心得,希望对各位有所帮助。

现实情况

React hooks 自发布到现在已有 3 年之久,但其最佳实践的相关文章在网络上与 vue3 一样寥寥无几,倒是有着很多 React 各种源码剖析,讲着 React 内部各式各样的机制和设计,但最终落实在项目上,仍然很少有人能写好 React hooks。所以我根据我自身实践,写出这篇文章,希望能对各位 React 开发者有所帮助。如有错漏,还望海涵和协助反馈。

React 官方文档 中,hooks 的代码非常的简单易用,一个函数式组件返回 TSX 就可以渲染。

function App() {
    const [state, setState] = useState<string>('hello world!')
    return (
        <div> { state } </div>
    )
}
复制代码

即使是现在,很多 Hooks 的实战文章也都是以这样简单的模式去实现并展示了他们的 demo,代码量精简外加数据量小容易给看客们造成 demo 里的写法就是正确写法的误解。

React hooks 在官方的介绍中也就只有十几个 Api,而对于这些 Api,文档里只是介绍了如何使用,但是却没有说明在什么情况下才去使用。以致于在项目里使用后才发觉各种 rerender 造成的界面卡顿、输入阻塞等问题,于是有了 “写 React 心智负担很重” 的开发者评价,在今天这个时候仍然有大量的开发者因为没有很好地组织好 hooks 代码而导致后期维护成本越来越高,以至于重构也无济于事。

对于如何组织好 hooks 的代码,我在这里总结了项目里关键的几点心得。

1.函数式组件的状态声明

很多人都知道,React 会在组件状态发生变更后重新渲染组件(也就是重新调用 hooks 的组件函数),但是什么才属于 React 状态?stateprops

    function App() {
        const { text } = props
        const [state, setState] = useState("text")
        const getState = () => state
        const getStateCallback = useCallback(() => console.log(state), [state])
        const getStateMemo = useMemo(() => state, [state])
        const myState = "text"
        const refState = useRef("text")
        ...
    }
复制代码

我的理解是,会触发组件更新渲染的任何函数组件内的变量都可以是状态

为了减少组件 rerender,我们应当尽少地在组件内使用状态,这样从根源上就降低了组件重复更新的“心智负担”。

优先选择使用 useRef 或者是依赖监听数组为空数组的 useCallbackuseMemo

比较常见的是项目里充斥着 useCallback 来存储方法,再给每个子组件套 React.memo 结合使用。

    const Input:React.FC = memo(() => {
        const [state, setState] = useState(...)
        const funA = useCallback(() => {...}, [state])
        const funB = useCallback(() => {...}, [state])
        const funC = useCallback(() => {...}, [state])
    
        useEffect(() => {
            funA()
            funB()
            funC()
        }, [state, funA, funB, funC])
        ...
    })
复制代码

而在实际中绝大部分情况下我们根本不需要让一个方法去触发渲染更新,这些方法完全可以用 useRef 存储,在调用方法的时候方法内部可以直取当前的状态。

function useMethod <T>(fn:T):T {
    const ref = useRef(fn)
    ref.current = fn
    const methodRef = useRef()
    if (!methodRef.current) {
        methodRef.current = (...args: Parameters<T>) => {
            return ref.current(...args)
        }
    }
    return methodRef.current as T
}
复制代码

ahooks 里的 useMemorizedFn 也差不多是这样的例子。

    const Input:React.FC = memo(() => {
        const [state, setState] = useState(...)
        const funA = useMethod(() => {...})
        const funB = useMethod(() => {...})
        const funC = useMethod(() => {...})
    
        useEffect(() => {
            funA()
            funB()
            funC()
        }, [state])
        ...
    })
复制代码

看下面日常使用的两种 hooks 代码

    function App() {
        const [text, setText] = useState("text")
        
        const onChange = useCallback((e) => {
            setText(e.target.value)
        }, [])
        
        const onSubmit = useCallback(() => {
            http.post('/post', { text })
        }, [text])
        
        return (
            <>
                <Input value={text} onChange={onChange} />
                <Button onClick={onSubmit} />
            </>
        )
    }
复制代码

在上面这个例子中,只要 text 发生改变,使用 onSubmit 方法的组件就一定会被跟着重新渲染,如果这里 Button 是一个渲染开销巨大的组件,那一定会阻碍用户的正常输入

        ...
        // 或者直接使用 ahooks 的 useMemorizedFn
        const onSubmit = useMethod(() => {
            http.post('/post', { text })
        })
        
        return (
            <>
                <Input value={text} onChange={onChange} />
                <Button onClick={onSubmit} />
            </>
        )
    }
复制代码

经过缓存函数优化后,Button 组件就再也不会因为 onSubmit 更新而渲染了。

所以,如果不是为了渲染更新需要,不要在组件内使用状态。能用 useRef 就用 useRef

2. 组件拆分,动静分离

在项目当中,编写组件绝不像官方文档 demo 中那样任意地铺张代码,有些时候,你需要将组件进行“动静分离”。也就是将有状态的组件和无状态的组件进行拆分,这样,当状态更新时,才不会顺带着把应该静态的组件重新渲染了。

静态组件

静态组件,包括以下两种情况(这里的静态组件不是指静态节点):

a. 组件不用渲染

举个栗子,在有关发布订阅的代码模式下,有的组件需要订阅一些状态变更的事件,之后触发状态管理数据更新,例如最近我最近在项目里见到的代码如下

function Foo () {
    const setState = useSetRecoilState() // recoil
    
    useEffect(() => eventBus.addEventListener('something-changed', (event:any) => {
        setState(event.detail)
    }), [])

    return (
        <div>
            <Apple />
            <Boy />
            <Cat />
        </div>
    )
}
复制代码

在上面这种情况中,一旦触发事件,就会导致 Foo 组件重新渲染,但是我们可以将这些事件单独放在一个 React 组件内。

function EventRc () {
    const setState = useSetRecoilState() // recoil
    
    useEffect(() => eventBus.addEventListener('something-changed', (event) => {
        setState(event.detail)
    }), [])
    
    return <></>
}

function Foo () {
    return (
        <div>
            <Apple />
            <Boy />
            <Cat />
            <EventRc />
        </div>
    )
}

复制代码

这样再触发事件,也不会重新渲染整个组件。同时这个抽出来的组件也可以重复使用。

b. 组件内没有状态,不会因为内部有状态变更产生渲染更新

const Input: React.FC = () => {
    const value = useRef("")
    const onChange = useCallback((e) => {
        value.current = e.target.value
    }, [])
    
    const submit = useCallback(() => {
        http.post('/post', { text: value.current })
    }, [])
    
    return (
        <>
            <input onChange={onChange}  />
            <button onClick={submit} >submit</button>
        </>
    )
}
复制代码

这个组件套个 memo 将是无敌的存在

有状态组件

有状态的组件,需要考虑将组件按行为归类进行拆分,举个栗子

function List () {
    const { children } = props
    const [title, setTitle] = useState('')
    const [list, setList] = useState([])
    
    const onInputChange = (e) => {
        setTitle(e.target.value)
    }
    
    useEffect(getList, [])
    
    return (
        <div>
            <input value={title} onChange={onInputChange} />
            <ul>
                {list.map(item => (
                     <li key={item.id}>{item.name}</li>
                ))}
            </ul>
            {children}
        </div>
    )
}
复制代码

来看看我们的想法是否一致吧~

function RcInput () {
    const [title, setTitle] = useState('')
    const onInputChange = useMethod((e) => {
        setTitle(e.target.value)
    })
    return (
            <input value={title} onChange={onInputChange} />
    )
}
function RcList () {
    const [list, setList] = useState([])
    useEffect(getList, [])
    return (
        <ul>
            {list.map(item => (
                 <li key={item.id}>{item.name}</li>
            ))}
        </ul>
    )
}
function List (props) {
    const { children } = props
    return (
        <div>
            <RcInput />
            <RcList />
            {children}
        </div>
    )
}
复制代码

但你可能会问,如果 inputlist 组件有所交互怎么办?这个时候不还是要把 listvalue 又抽离出来?

两种解决方式,第一种是利用 useContext 包装一层数据层,第二种则是使用状态管理工具,其实在前端框架横向对比中,状态管理的通信机制对于 React 是非常重要的,因为 props 只能在父子之间通信,同时它的更新又必会触发组件 rerender。而通过状态管理库,我们可以轻松的进行组件通信。

以 recoil 为例

const titleState = atom({
    key: 'title'
    default: ""
})

const listState = selector({
    key: 'list'
    get: ({get}) => {
        const title = get(titleState)
        return getList(title)
    }
})

function RcInput () {
    const [title, setTitle] = useRecoilState('')
    const onInputChange = useMethod((e) => {
        setTitle(e.target.value)
    })
    return (
        <input value={title} onChange={onInputChange} />
    )
}
function RcList () {
    const list = useRecoilValue(listState)
    return (
        <ul>
            {list.map(item => (
                 <li key={item.id}>{item.name}</li>
            ))}
        </ul>
    )
}
function List (props) {
    const { children } = props
    return (
        <div>
            <RcInput />
            <RcList />
            {children}
        </div>
    )
}
复制代码

这样最终父组件 List 呈现的格式仍旧不变,RcListRcInput 仍然可以很好地管理属于自己的状态和渲染。

3. 循环体优化

一般的循环体是没有什么问题的,但是当循环体带上了回调事件,例如以下代码

const Child = memo((props) => <button onClick={props.onClick}>lorem</button>)

function App (props: { arr: any[] }) {
    const { arr } = props
    
    const onClick = useCallback((item) => (e) => {
        console.log(item)
    }, [])
    
    return arr.map(item => (
        <Child onClick={onClick(item)} />
    ))
}
复制代码

上面的代码中,当 arr 产生更新时,会发现所有的 Child 都在重新渲染,即使你的 Child 组件套了 React.memo

根本原因是 onClick 这个事件,为了获取被点击的数据项,需要多套一层箭头函数将数据闭包之后再回调,导致每一次赋值给子组件的 onClick 都是一个新的内存指向。致使组件一直会检测到 props 更新从而重新渲染。

const onCLick = (item) => (e) => {}

const item = {}
const onChildClickA = onCLick(item)
const onChildClickB = onCLick(item)

// expected to false
onChildClickA === onChildClickB
复制代码

这种场景在写一些列表或者表格组件的情况下尤为常见。目前我的唯一解决方式是把子组件拎出来再封装一层。

const ChildWrapper: React.FC<{item: any}> = (props) => {
    const {item} = props
    const onClick = useMemorizedFn((e) => {
        console.log(item)
    })
    return <Child onClick={onClick}/>
}

function App (props: { arr: any[] }) {
    const { arr } = props
    
    return useMemo(() => arr.map(item => (
        <ChildWrapper item={item} />
    )), [arr])
}
复制代码

这样,当循环体某个 item 产生更新时,就只会有这个 item 对应渲染的 Child 组件更新,其他的 Child 不会触发 rerender。

总结

总结一下,在编写 React 的过程中,我们需要河里的状态声明以及河里的组件拆分,再针对带有事件的循环体渲染进行优化。通过这些河里的编写规范落地,我们可以从根源上大幅降低组件渲染更新带来的心智负担,而且不单如此,河里的拆分也会使我们的代码更清晰,易维护。如果数据更新再用上 immer、immutable 之类的库,两者搭配,在 React 项目里基本就是横着走了。

(从而无法成为公司不可替代的人才而被优化)

Tips: 墙裂推荐使用 ahooks 库来协助编写 React, useMemorizedFn 永远底神~

结尾

以上就是本文所有内容了,有兴趣的同学可以点赞关注一下呢。

这里是公众号: 没有公众号(即将有)。

这里是作者微信号: newz10376。欢迎蕉榴~

猜你喜欢

转载自juejin.im/post/7142937921102807071