玩React,必知必会的n个组件优化技巧

在前端框架频繁迭代的背景下,组件的重要性已不言而喻。而如何开发出一个健壮的组件,也便成为了当下重中之重。以上便是本文的写作前提,笔者会围绕在React开发中与组件相关的13个方面上展开详细叙述,并附加实践代码。无论你是初学者还是有经验的React开发者,本文都将为你提供实用的技巧和策略,帮助你在玩转React时获得更好的性能和开发体验。

文章对React组件、自定义Hooks等进行了详细阐述,所以会产生篇幅过长的情况,因此笔者建议可自行分为上、中、下三部分进行阅读,合理安排时间,点赞收藏不迷路。

组件相关

组件划分

所谓组件划分,其实就是分而治之的思想,如果要比喻的更形象一点,那无非就是把组件比做成一块砖,哪里需要哪里搬。在这种思想的引导下,所开发出来的组件也就拥有了与生俱来的可维护性、可扩展性、以及灵活性等。

组件创建与组件划分都应当遵循单一职责原则 ,比如组件只负责实现某个特定的功能 或只负责呈现特定部分的UI。如下图所示

红色区域为消息列表,蓝色区域则为消息列表中的每一条消息,按照分而治之的思想来讲,红色区域应当是一个独立的组件,蓝色区域也应当是一个独立的组件。

如果说现在要增加功能:

  1. 要求在单击首页左上角头像时,跳转到一个新的页面,而新的页面也要展示消息列表
  2. 要求在顶部展示弹窗(类似于手机顶部的App通知)

笔者在这里认为,对于需求1,只需要在新页面中放入红色区域的组件即可;对于需求2,只需要将蓝色区域的组件通过定位的方式呈现在顶部即可。

上例只是一个简简单单的组件划分原则 ,但通常在组件的开发中,所考虑的因素又往往不止这一点。比如当前组件(即封装后的组件)对Props有一定要求,或当前组件是个与Redux相关的组件,亦或组件包含了一些额外的无关功能。那么这些要求在实现起组件的可维护性、可扩展性和灵活性方面上都是大打折扣的。

而如何开发出一款高可维护性、扩展性和灵活性的组件呢?笔者认为仅仅依靠单一职责原则来进行组件划分是远远不够的,还应掌握更多的技巧。

UI分离(容器组件和展示组件)

UI分离 可能是个不太官方的名字(但笔者却经常这么称呼它),真正的概念介绍中应该称其为容器组件和展示组件。顾名思义,容器组件主要负责管理数据的交互(逻辑处理等),展示组件主要负责渲染页面,也就是UI渲染。如下图所示。

细心的同学可能会发现上图就是上面的蓝色区域组件(暂时将上图称为Message组件)。假设Message组件自己获取用户的头像、昵称、消息内容、消息时间,如下例所示。

typescript 复制代码
import { timeMap } from '@utils/time'

const Message = () => {
    // 存储用户信息(头像、昵称、消息内容、...)
    const [info, setInfo] = useState({})
    useEffect(()=>{
        // ajax请求用户信息,请求完毕后更改状态进行渲染
        ajaxInfo().then((data) => setInfo(data))
    }, [])
    // HTML部分
    return (
        <div>
            <img src={info.url} />
            <p>
                <span>昵称{info.nickname}</span>
                <!-- 对消息内容进行转换,当长度大于3时显示为数字123 -->
                <span>消息内容{info.msg.length > 3 ? 123 : info.msg}</span>
            </p>
            <!-- 对时间进行转换 -->
            <time>时间{timeMap(info.time)}</time>
        </div>
    )
}

以上代码中的Message组件,大致做了4件事:

  1. 通过ajax请求用户信息(数据处理)
  2. 将请求后的用户信息进行UI展示(UI渲染)
  3. 对消息内容进行转换(数据处理)
  4. 对消息时间进行转换(数据处理)

也就是说,现在Message组件既负责数据的逻辑处理,又负责数据的UI展示。这在容器组件和展示组件的设计模式下是不被许可的,因为它认为应当把数据(容器)与UI(展示)进行分离,这样做的好处就是数据只负责处理数据,UI只负责展示UI,并且也可以在一定程度上提高代码的可维护性与可读性。下面是经过修改后的代码。

typescript 复制代码
// 展示组件
const MessageUI = (props) => (
    <div>
        <img src={props.url} />
        <p>
            <span>昵称{props.nickname}</span>
            <span>消息内容{props.msg}</span>
        </p>
        <time>时间{props.time}</time>
    </div>
)

通过上例可以看出MessageUI组件只负责 通过Props对数据进行展示,而不负责其它任何东西。

typescript 复制代码
import { timeMap } from '@utils/time'

// 容器组件
const Message = () => {
    // 存储用户信息(头像、昵称、消息内容、...)
    const [info, setInfo] = useState({})
    useEffect(()=>{
        // ajax请求用户信息,请求完毕后更改状态进行渲染
        ajaxInfo().then((data) => setInfo(data))
    }, [])
    // HTML部分
    return (
        <MessageUI 
            {...props}
            msg={info.msg.length > 3 ? 123 : info.msg}
            time={timeMap(info.time)}
        />
    )
}

通过上例可以看出Message组件只负责 对数据进行处理或加工,随后便将Message组件处理后的数据传递给MessageUI组件进行UI渲染。

小结

经过分离后的Message组件只负责数据的处理,比如对昵称、时间等的处理和转换,而MessageUI组件则只负责对UI进行展示。

这么做的好处是,如果后期需要对UI进行调整,那么只需要更改MessageUI组件即可,并且在更改MessageUI组件时,根本不需要考虑数据流的交互,因为它只负责对UI进行展示。反过来说,如果要对数据的处理逻辑进行更改,则只需要更改Message组件即可,根本不需要关心UI组件是如何进行展示的。

组件的DOM结构

在React中,每个组件(或者说组件所返回的虚拟DOM)都必须拥有一个根标签,例如

typescript 复制代码
// Test组件只有一个根标签
const Test = () => <p></p>

而在开发的过程中,又经常不可避免的去添加多个根标签,例如

typescript 复制代码
// Test拥有多个根标签
const Test = () => (
    <p></p>
    <p></p>
    <p></p>
)

不过由于React渲染机制的原因,它并不会容忍这种情况的发生,所以在React16以前 ,一种常见的解决方案就是为其增加额外的根标签,例如

typescript 复制代码
const Test = () => (
    <!-- 通过额外的div标签来包裹三个p标签 -->
    <div>
        <p></p>
        <p></p>
        <p></p>
    </div>
)

但这么做会有以下弊端:

  1. 产生额外的DOM层级结构
  2. CSS选择器的影响(例如子元素选择器等)
  3. HTML标签的语义化影响

以第一种弊端来说,如果Test组件的外层使用了flex布局或其它与布局有关的CSS样式,那么这里的UI渲染就会出现问题。因为本来是三个P标签,现在只有一个div标签了,所以在UI的渲染上会出现各种奇怪的效果。

为此,React16推出了Fragment来避免被额外奖励的DOM元素。Fragment可以包裹多个根标签,且Fragment本身不会创建额外的DOM层级,例如

typescript 复制代码
const Test = () => (
    <Fragment key="0">
        <p></p>
        <p></p>
        <p></p>
    </Fragment>
)

经过Fragment组件包裹后,所渲染出的HTML DOM层级结构为

typescript 复制代码
(
    <p></p>
    <p></p>
    <p></p>
)

由此可见,Fragment组件本身并不会创建额外的DOM层级结构。

状态提升

状态提升,也称为在组件间共享状态。其原理是将多个子组件中的共有数据,都提升至公共父组件中。如下图所示。

红色区域中的Tab栏,从左到右依次为消息(Message)、联系人(Contacts)、朋友圈(FriendsCircle),需求则是点击哪个图标,哪个图标就要高亮。

当下的一种实现方式则是,MessageContactsFriendsCircle这三个组件,每个组件都各自维护一个状态(例如isHighed),此状态用来决定当前图标(即当前组件所对应的图标)是否高亮。

假设当前点击的图标为Contacts,则需要将Contacts组件的isHighed状态设为true,并通过兄弟组件通信的方式来通知MessageFriendsCircle组件的isHighed状态需要设为false。显然,如果是交由每个图标(组件)自身来维护状态,那么就需要同时维护三个状态,且需要做到兄弟组件之间通信。

简单的点击高亮需求却需要同时维护三个state,这种方式是不可取的。而如果是通过状态提升的方式来完成这个需求,那么只需要父组件维护一个state即可。如下例所示。

typescript 复制代码
// icon(url1111为高亮图标,url222为非高亮图标)
const Message = (props) => props.isHighed ? <img src={url1111} /> : <img src={url222} />
const Contacts = (props) => props.isHighed ? <img src={url1111} /> : <img src={url222} />
const FriendsCircle = (props) => props.isHighed ? <img src={url1111} /> : <img src={url222} />

const Icon = { Message, Contacts, FriendsCircle }
typescript 复制代码
// tab
const Tab = () => {
    // 默认选中索引为0的图标(切换高亮图标时只需通过setSelected进行切换)
    const [selected, setSelected] = useState(0)
    return (
        <>
            <Icon.Message isHighed={selected === 0} />
            <Icon.Contacts isHighed={selected === 1} />
            <Icon.FriendsCircle isHighed={selected === 2} />
        </>
    )
}

可见,通过状态提升的方式来完成这个需求时,无论有多少个子组件(图标),都只需要维护父组件的一个状态即可。而如果是通过每个子组件(图标)自己维护自己的状态来完成这个需求,那么所维护状态的数量会随着子组件数量的增长而增长。

高阶组件

在开发React组件时,大多数情况下可能会遇到多个组件都需要某个功能的问题,而且这个功能往往和界面并没有直接关系,所以也不能简单地抽取成一个新的组件,但如果让同样的逻辑在各个组件里各自实现,无疑会导致重复代码的增加。

去掉这些重复性代码的方式有很多,而高阶组件正是其中之一。在高阶组件 (HOC)的众多实现方式中有两种常用的实现方式,即代理继承

代理式高阶组件

当谈到高阶组件时,代理式高阶组件是一种常见的实现方式。代理式高阶组件允许在不修改原始组件的情况下,对其进行包装并添加额外的功能或行为。如下例所示。

typescript 复制代码
// WrappedComponent为要被包装的组件
const A = function (WrappedComponent){
    return function (props) { // 第一个组件(完整的生命周期)
        const _props = { name: '鲨鱼辣椒', age: 18, ...props }
        return <WrappedComponent {..._props} /> // 第二个组件(完整的生命周期)
    }
}

上例中A组件就是一个代理式的高阶组件 ,它接收一个组件作为参数,并返回一个新的组件。这个新的组件其实就是传入的WrappedComponent组件,但有所不同的是,WrappedComponent组件的props参数已经不再是之前的props了,而是经过加工后的props,即_props_props为其增添了新的属性,即name与age)。

代理式的高阶组件需要注意的是,WrappedComponent组件与所返回的新组件是两个完全不同的组件,既然是两个完全不同的组件,那么就要经历各自所不同的生命周期。换句话说,一次渲染,需要经历两次生命周期。

继承式高阶组件

继承式高阶组件也是一种常见的高阶组件实现方式。它通过继承原始组件的类(class)来实现,也正是由于继承类的缘故,所以它也会继承原始组件的所有属性和方法,这样一来就可以在包装组件中对其进行扩展、修改或重写等。

typescript 复制代码
// WrappedComponent为要被包装的组件
function A(WrappedComponent){
    // 这里的新组件继承于WrappedComponent组件
    return class SonComponent extends WrappedCpmponent {
        render() {
            this.props = { name: '鲨鱼辣椒', age: 18, ...this.props }
            return super.render() // WrappedCpmponent完整的生命周期
        }
    }
}

上例中的A组件就是一个继承式的高阶组件 ,它接收一个组件作为参数,并返回一个组件。这个返回的组件是继承于WrappedComponent的组件,但有所不同的是,它不再返回一个全新的组件,而是返回父组件的值 (JSX),因为super.render就是在调用父组件的render方法。也正是由于这个缘故,所以继承式的高阶组件只会经历一次生命周期。

注意,当使用继承式的高阶组件时,可能会需要不断的继承或依赖某些组件,这样势必会导致组件层级过深,从而增加代码的复杂性和难理解性。此外,由于继承式的高阶组件可以对原始组件的内部实现进行修改,所以这样可能会带来一些副作用和难以预测的行为(尤其是当多个组件都继承自同一个组件时)。

假设AB两个组件都继承自C组件,而A组件重写了C组件的play方法,但B组件却依赖于A组件原始的play方法,如此一来就出现了冲突。一种常见的解决方案就是通过TypeScript中一些类(Class)常用的关键词来对类(A)进行限制,例如readonlyprivate抽象类等等。

小结

在实际开发中,有些组件可能并不是我们自己开发的,它有可能来自于同事或第三方组件库。或者,即使是我们自己开发的,但如果这个组件的内部逻辑过于复杂,而我们又希望不要去触碰这些过于复杂的逻辑,这时候高阶组件就有了用武之地,它通过一个独立于原有组件的函数 来产生一个新的组件,并且对原有组件没有任何侵害。

组件的Key

在《高性能JavaScript》中有这么一句话:"把DOM看成一个岛屿,把JavaScript(ECMAScript)看成另一个岛屿,两者之间以一座收费桥连接。每次ECMAScript需要访问DOM时,你需要过桥,交一次"过桥费"。你操作DOM次数越多,费用就越高。一般的建议是尽量减少过桥次数,努力停留在ECMAScript岛上。"

其实虚拟DOM的出现,就是为了减少过桥次数 ,也就是减少频繁操作DOM的可能性 。而虚拟DOM的diff算法则是根据Key来进行比较的。关于组件的Key值,笔者想表达的观念很多很多,正好之前专门写过这么一篇文章,所以有需要的小伙伴可以移步这篇文章《Vue/React中的key解析》

re-render相关

hooks的shouldComponentUpdate

React类式组件的生命周期方法shouldComponentUpdate的返回值用于决定一个组件是否应该重新渲染,该方法会收到新state与旧state ,开发者可以根据新旧状态来自己决定本次要不要重新渲染,以及要不要进行深度比较。当然,shouldComponentUpdate默认是浅比较。

函数式组件 就没这么幸运了,因为函数式组件并没有生命周期这一系列的方法,转而代之的则是一系列的hook。与shouldComponentUpdate生命周期方法所相似的钩子则是memo,而memo大多数情况下又与useCallbackuseMemo这两个hook相结合。在了解memo、useMemo、useCallback之前,笔者想通过下面这段背景来加深各位小伙伴对这三个hook的认识。

电视剧按照年代来划分类型的话,可以分为古装剧和现代剧,而无论哪种类型的电视剧,它的拍摄都离不开舞台的搭建。现代剧的取景范围很松散,比如在当下某某公园中就可以进行拍摄,换句话说,现代剧的舞台搭建时长较低;古装剧则不同,它需要彻头彻尾的搭建出一个古装舞台,而且搭建的时候也要注意,比如清朝的茶杯、玩物等,是绝不能出现在清朝之前的古装剧中的,所以古装剧舞台的搭建是耗时比较高的。

假设你已身临其境,你现在是一个导演且你要拍摄一部秦朝的古装剧。泰酷辣,好了,这部电视剧你拍完了。你拍完之后,你觉得古装剧的搭建时长很高,而且说不定以后还会再拍秦朝的古装剧,你不想再次花费同样的财力、人力了,所以你找了个助理帮你记录一下都拍过哪些朝代的电视剧。如果以后再开机的话,你会先问问你助理,我之前拍过这部电视剧吗?如果拍过,那就不用重新搭建舞台了(古装剧的舞台搭建耗时高),如果之前没有拍过,那么只能重新搭建古装剧的舞台了。

现在换位至React中,假设现在有一个高耗时的组件 (古装剧舞台)需要被渲染,但是渲染成本很高,因为它包含了多个网络请求与复杂的逻辑计算,所以应该找一个助理 (memo)来记录一下数据有没有变化(当前古装剧的舞台之前是否已搭建过)。如果数据流没有变化,那就无需重新调用这个高耗时的组件(直接使用之前的古装剧舞台),如果数据流发生了变化,比如用户修改了信息等,那么这个时候就需要重新渲染这个组件(搭建一个新的古装剧舞台)。而数据流的变化与否,一般则是由useMemouseCallback来维系的。

以上就是memo与useMemo、useCallback之间的关系,下面简要介绍一下它们的用法。

memo

typescript 复制代码
const Test = memo(A, cb?)

memo函数接收两个参数。第一个参数是要被记忆的组件 (memo并不会对此组件进行任何更改),例如上例中的A组件就是要被记忆的组件,而memo函数则可以理解成它是A组件的shouldComponentUpdate生命周期方法。第二个参数cb则是渲染A组件时所调用的回调函数,这个函数会收到当前组件的新旧state以决定本次是否重新渲染。

useCallback

typescript 复制代码
const test = useCallback(fn, dependencies)

useCallback函数接收两个参数。第一个参数是要缓存的函数 ,useCallback会在其依赖项发生变化时调用此函数。第二个参数则是依赖项列表,只有依赖发生变化时,才会重新调用所传入的回调函数。

useMemo

typescript 复制代码
const test = useMemo(calculateValue, dependencies)

useMemouseCallback同理,只不过useMemo是记忆值,而不是记忆函数。

示例

以上是memo、useMemo、useCallback这三个hook,下面是使用未使用这些hook的代码示例。

typescript 复制代码
// 假设SlowComponent是一个耗时较高的组件
const SlowComponent = () => <p>{Math.random()}</p>

const App = () => {
    const [text, setText] = useState('')
    return (
        <>
            <SlowComponent />
            <input value={text} onChange={(ev) => setText(ev.target.value)} />
        </>
    )
}

上例中input输入框的onchange事件每调用一次,SlowComponent组件就会重新渲染一次(因为在onchange事件中会修改当前App组件的状态),而每重新渲染一次,SlowComponent组件就要经历一次完整的生命周期 ,如果SlowComponent组件的内部逻辑比较复杂,那么每次onchange事件触发时,对于SlowComponent来说,这都将是非常低效的。

从上例中的DOM层级来看,其中一种解决方式就是将SlowComponent组件提取至父组件 中,或将其提取为兄弟组件,例如

typescript 复制代码
// 提取为兄弟组件
const Root = () => {
    return (
        <>
            <SlowComponent />
            <App />
        </>
    )
}

这样App组件内的状态无论如何更改,只要不涉及到Root组件的状态,那么SlowComponent组件就不会在App组件的onchange事件每次触发时都重新渲染。

当然,这只是局限于DOM层级上的优化,在开发中,你还可以使用memo来进行优化,例如

typescript 复制代码
// memo(SlowComponent)
const SlowComponent = memo(() => <p>{Math.random()}</p>)

const App = () => {
    const [text, setText] = useState('')
    return (
        <>
            <SlowComponent />
            <input value={text} onChange={(ev) => setText(ev.target.value)} />
        </>
    )
}

上例中通过memo函数将SlowComponent组件的函数体进行包裹,这样只要SlowComponent组件的props不变,那么SlowComponent就不会重新渲染。很明显的是,上例中的调用方式为<SlowComponent />,所以App传给SlowComponentprops永远都是空({})。

但如果SlowComponent需要props呢?例如

typescript 复制代码
const SlowComponent = memo((props) => <p>props是{props.info.name}--{Math.random()}</p>)

const App = () => {
    const [text, setText] = useState('')
    const info = {
        name: '鲨鱼辣椒'
    }
    return (
        <>
            <SlowComponent info={info}/>
            <input value={text} onChange={(ev) => setText(ev.target.value)} />
        </>
    )
}

在上例中,虽然SlowComponent组件使用了memo进行包裹,但它仍然会在onchange事件每次被触发时重新渲染。这是由于memo是浅比较的缘故,在每次重新渲染时,传递给SlowComponent组件的info都是一个新引用 ,这也就是memo不会生效的原因。

修复这个问题的方式有很多,比较简单的是将info对象定义在App组件外部,这样info就会保证始终都是同一个引用地址 ,也就并不会在onchange事件触发时引起SlowComponent组件的重新渲染了,例如

typescript 复制代码
// info定义在外部
const info = {
    name: '鲨鱼辣椒'
}

const SlowComponent = memo((props) => <p>props是{props.info.name}--{Math.random()}</p>)
const App = () => {
    const [text, setText] = useState('')
    return (
        <>
            <SlowComponent info={info}/>
            <input value={text} onChange={(ev) => setText(ev.target.value)} />
        </>
    )
}

但这么做有两个比较显著的弊端

  1. 如果info中的值依赖于App中的状态
  2. info中的值发生变化时,可能无法更新页面UI显示

所以比较常见的一种解决方案就是使用useMemouseCallback,例如

typescript 复制代码
const SlowComponent = memo((props) => <p>count是{props.info.count}--{Math.random()}</p>)

const App = () => {
    const [text, setText] = useState('')
    const [count, setCount] = useState(0)
    const info = useMemo(() => ({
        name: '鲨鱼辣椒',
        count: count
    }), [count])
    return (
        <>
            <SlowComponent info={info}/>
            <input value={text} onChange={(ev) => setText(ev.target.value)} />
            <span onClick={() => setCount(count + 1)}>单击事件</span>
        </>
    )
}

上例中的SlowComponent组件,只会在count发生变化时进行重新渲染(count不发生变化时,info所指向的引用地址也就不会变化),因为count是传入useMemo的依赖项,且count同时也是App组件的状态之一。

这么一看,memo、useMemo、useCallback确实是用来做性能优化的一把利剑,但真的如此吗?

我们知道redis缓存是通过内存进行读写的,与通过SQL语句去读写数据库的速度是完全不同的,因为前者是内存,后者则是耗时较慢的硬盘。换句话说,redis是空间换时间 ,而数据库是时间换空间。类似的还有分布式session与token。

那memo、useMemo、useCallback呢?笔者认为,这也是一个空间换时间,还是时间换空间的问题。因为这些带有缓存作用的hook,它的内存占用会随着使用数量的增加而增加,所以是否大量使用这些hook,还应认真讨论一番才对。

无论用还是不用,千万不要滥用,例如

typescript 复制代码
const SlowComponent = memo((props) => <p>count是{props.info.count}--{Math.random()}</p>)

const App = () => {
    const [text, setText] = useState('')
    const [count, setCount] = useState(0)
    const info = useMemo(() => ({
        name: '鲨鱼辣椒',
        count: count
    // 依赖项为text
    }), [text])
    return (
        <>
            <SlowComponent info={info}/>
            <input value={text} onChange={(ev) => setText(ev.target.value)} />
            <span onClick={() => setCount(count + 1)}>单击事件</span>
        </>
    )
}

上例中useMemo的依赖项变为了text,这也就导致了SlowComponent组件会在每次onchange事件触发时都会重新渲染。因为当依赖项text发生变化时,useMemo会重新进行计算,然后返回一个新的引用,新引用又与memo所记住的原引用不同,所以就触发了重新渲染。

注意点

在使用useMemo、useCallback时经常会遇到被缓存的值/函数并不是最新值

换句话说就是,useMemo、useCallback中的值并不是最新值

例如

typescript 复制代码
const App = () => {
    const [info, setInfo] = useState({
        name: '鲨鱼辣椒',
        age: 10
    })
    const [count, setCount] = useState(0)
    // useMemo
    const intro = useMemo(() => ({
        name: '蝎子莱莱',
        age: info.age * -1
    }), [count])
    return (
        <>
            <div>intro中age的值是:{intro.age}</div>
            <span onClick={() => setInfo({ ...info, age: info.age + 1 })} >修改info中的年龄</span>
            <span onClick={() => setCount(count + 1)}>修改count的值</span>
        </>
    )
}

上例中的逻辑大概是:

  1. 修改info中age的值时,age加1
  2. 修改count的值,count加1。且因为useMemo依赖了count,所以当count变化时,useMemo会重新计算
  3. 每次状态更新时,都会读取intro.age的值

这种情况是由于闭包 的原因,所以每次useMemo都拿不到最新的值。这种情况可以通过ref去解决,例如

typescript 复制代码

当然,每次都通过ref进行额外设置是比较繁琐的,应该将其封装为一个hook,这样使用时只需要使用封装的hook即可避免useMemo因为闭包所产生的问题。

原生JavaScript闭包

如果您对原生JavaScript闭包还存有一些疑问,那么您可以在《JavaScript每日一题》专栏中找到答案,以下是两道比较经典的闭包问题

  1. 《JavaScript每日一题------闭包1》
  2. 《JavaScript每日一题------闭包2》

避免使用内联对象

在JS中,万物皆对象,例如1'1'[1]{1: 1}function() {},这些内联对象每次都会产生新的引用,例如

typescript 复制代码
console.log([] !== []) // true

所以不应该出现内联对象配合memo进行缓存的情况,例如

typescript 复制代码
const SlowComponent = memo((props) => <p>props是{props.info.name}--{Math.random()}</p>)

const App = () => {
    const [text, setText] = useState('')
    return (
        <>
            <SlowComponent info={{name: '鲨鱼辣椒'}}/>
            <input value={text} onChange={(ev) => setText(ev.target.value)} />
        </>
    )
}

这种情况下,SlowComponent组件仍然会在inputonchange事件触发时进行重新渲染。这是因为内联对象每次都会产生新的引用,所以上面这种示例是不可取的。

v-show

Vue中v-show的初衷是在进行UI挂载与卸载时,对于繁重的组件,可以不必频繁挂载与卸载 ,而是通过display来减少dom更新。而在React中也可以实现类似的功能,例如

类似的,也可以通过opacity、position等方式来让元素消失在视野中。其目的都是为了能够不必频繁挂载与卸载组件。

typescript 复制代码
const App = () => {
    const [isShow, setIsShow] = useState(true)
    return (
        <>
            <div style={{ display: isShow ? 'inherit': 'none' }}>我是元素内容</div>
            <span>{isShow ? '隐藏' : '显示'}</span>
        </>
    )
}

上例中通过isShow来决定divcss display状态,因此也就达到了页面UI的显示与隐藏。

但是要注意,笔者所说的是通过displayopacitymargin这些能够让元素消失在视野中的方法,而不是要卸载组件方法,例如

typescript 复制代码
const App = () => {
    const [isShow, setIsShow] = useState(true)
    return (
        <span>{isShow ? <SlowComponent /> : null}</span>
    )
}

上例中的isShowtrue变为false时,会经历SlowComponent组件的componentWillUnmount生命周期方法,所以这种方式属于挂载/卸载组件来达到隐藏元素的目的。

综上,在使用时具体使用哪种方法,应当视情况而定。

useContext

useContext用于在组件中访问全局的上下文数据。useContext 的默认行为是在每次组件渲染时都重新计算上下文的值 。这可能会导致性能问题,尤其是当上下文的值频繁变化时。为了避免不必要的重新渲染,可以使用 memo 结合 useContext 进行优化。

typescript 复制代码
const MyComponent = memo(() => {
  const contextValue = useContext(MyContext);

  // 组件渲染逻辑...

  return (
    <p>内容</p>
  );
});

通过将组件包裹在 memo 中,可以确保只有在 MyContext 上下文值发生变化时才触发重新渲染。这样可以避免不必要的渲染,提高组件性能。

懒加载

懒加载将围绕两个方面去介绍

  1. 使用React所提供的Suspense组件和lazy函数来完成组件懒加载
  2. 使用由JavaScript提供的intersectionObserver属性进行视觉层面上的懒加载

组件懒加载

在React应用中,当页面加载时,组件及其依赖项(import)都会一次性地全部加载和渲染。而这种设计对于首屏的负担是很大的,因为这样做会导致初始加载时间过长。

为了减少首屏的资源加载时长,可以对非首屏的组件进行懒加载。懒加载只会在用到(渲染)该组件时被加载(请求),这么做可以减少初始加载的时间,从而最大让利于首屏要加载的资源。

通过使用Suspense组件和lazy函数来共同完成组件的懒加载。lazy函数会将组件进行动态导入,并返回一个可被调用的组件(未加载),而Suspense组件则用来处理组件的加载过程。

Suspense用法

对于懒加载的组件,可以使用Suspense组件对其进行包裹。

typescript 复制代码
<Suspense fallback={<div>Loading...</div>}>
    <LazyComponent />
</Suspense>

Suspense组件接受一个fallback属性,该属性的值必须是一个JSX内容,该内容(例如加载动画)会在组件加载过程中进行显示(fallback的值相当于占位符 )。当懒加载的组件加载完成后,Suspense组件就会将fallback中的(JSX)内容替换为懒加载组件的内容。

lazy用法

typescript 复制代码
const A = lazy(() => import('url'))

当使用lazy时,只需要导入要懒加载的组件,然后将其包装在lazy函数中即可。

lazy函数将返回一个可被渲染的懒加载组件。在组件需要被渲染时,React会自动加载(请求)该组件的代码。(这为代码分割提供了强而有力的支持)

下面是一个使用Suspense组件和lazy函数实现组件懒加载的示例

typescript 复制代码
import { Suspense, lazy } from 'react'

// 懒加载组件
const LazyComponent = lazy(() => import('./Message'));

const App = () => (
    <div>
      <p>我是内容</p>
      <Suspense fallback={<div>Loading...</div>}>
        <LazyComponent />
      </Suspense>
    </div>
)

上例中通过lazy函数将./Message组件进行动态导入 。然后在App组件中将LazyComponent组件包裹在 Suspense组件内,并由fallback属性实现组件在加载过程中的占位符显示。

intersectionObserver

假设一个图片展示类的网站,它需要在当前屏幕(例如1980px*1060px)下以瀑布流的形式展示10000张图片。

有一种设计方案是,在用户进入网站的瞬间,就给用户去加载全部的这10000张图片,但这种行为对于用户来说是非常无奈的,因为浏览器要在瞬间去发出10000个GET请求,而且每请求回一张图片,就要展示(绘制)一张图片。那么这种实现方案对于浏览器来说,无异于让其处于一直增压的状态,所以便有极大可能会让用户的浏览器卡死。

因此,在这种背景下也就催生了一种非常常见的需求------图片懒加载。

简单提一下,图片懒加载就是说如果首屏只能容纳20张图片,那么就只在首屏展示20张图片,剩下的21-10000张图片,只会在装载每个图片的DOM容器进入屏幕后,再去请求当前图片。(有多少个DOM容器进入,就请求多少张图片)

scroll事件或定时器

intersectionObserver之前,视觉上的懒加载通常会由scroll事件定时器来实现,但这两种方式都在性能和资源消耗方面存在一些问题。

scroll

每当用户滚动页面时,scroll事件就会被频繁触发,而scroll事件的触发频率也会随着内容的增多而增多,所以这可能会导致性能下降。

定时器

其次可以通过定时器来不断地检查元素的位置,但无法精确地捕获DOM容器进入或离开视口的时间点,而且这种方式在性能上也是有很大消耗的,比如有10000个DOM容器去装载图片,那么就可能要检测10000个DOM。

intersectionObserver

为了解决scroll事件定时器带来的这些问题,Intersection Observer API应运而生,它能够在元素进入或离开视口时提供准确的回调,并且不会影响页面的性能。如下例所示

typescript 复制代码
let observer = new IntersectionObserver((a) => {
    if (!a[0].isIntersecting) return
    callback()
    // 取消观察
    observer.unobserve(document.querySelector('.selector')!)
})
// 观察
observer.observe(document.querySelector('.selector')!)

上例是一个非常简单的示例,它出自一个基于TS+Vue3可视化的项目

通过observer.obsever监听document.querySelector('.selector')所对应的DOM元素,当该DOM元素进入视口范围内时将调用回调函数callback(),并同时取消对该DOM元素的监听。

当然,这只是一个非常简单的示例。有需要的话,可以将其封装为一个组件,例如

typescript 复制代码
<IntersectionObserver
    enter={() => {}}
    leave={() => {}}
    isRepeat={false}
>
    <div>看见我了吗?我被监听了!</div>
</IntersectionObserver>

enter会在div进入视口范围内时触发,leave会在div离开视口范围内时触发,isRepeat则代表是否需要对div进行重复监听。

视觉层面上的懒加载,其应用场景非常广泛,例如埋点统计(前端监控)、图片懒加载等

自定义hooks

习惯之所以难以改变,就是因为它是自我巩固的------越用越强,越强越用。

以上大概就是一些比较常见的组件优化技巧了,所谓熟能生巧,各位小伙伴还是要经常用才能习惯性的掌握这些优化技巧。文章并不会因此而结束,这里最后总结了一些笔者在项目中自己封装的且也是经常会用到的一些自定义hooks。

useDidUpdateEffect

在使用useEffect时,可能会遇到有时候只想在某个依赖项发生变化时再调用所传入的回调函数,而不想在初次加载时就调用所传入的回调函数。例如

typescript 复制代码
const App = () => {
    const [text, setText] = useState('')
    useEffect(() => {
        console.log('我被调用了...')
    }, [text])
    return <p>{text}</p>
}

上例中所传入useEffect的回调函数会执行1+n次,即初始挂载+text依赖项发生变化 ,但有时候只想在text依赖项发生变化时调用,并不需要在初始挂载(componentDidMount)时被调用。这个时候可以使用自定义hook------useDidUpdateEffect。

typescript 复制代码
// 只在state变化时调用,而不在初始挂载后调用
const useDidUpdateEffect = (cb: () => void, depend: any[]) => {
    const ref = useRef(true)
    useEffect(() => {
        if (ref.current) {
            ref.current = false
            return
        }
        cb()
    }, depend)
}

useDidUpdateEffect的使用方式与useEffect相同,只不过useDidUpdateEffect会保证所传入的回调函数不会在初始挂载时被调用,例如

typescript 复制代码
const App = () => {
    const [text, setText] = useState('')
    useDidUpdateEffect(() => {
        // 该函数只会在text依赖项发生变化时调用
        // 并不会在初始挂载后调用
        console.log('我被调用了...')
    }, [text])
    return <p>{text}</p>
}

useEventCallback

上面在hooks的shouldComponentUpdate 一节中已经讲过在使用useCallback时可能会造成由于闭包问题而无法在useCallback中获取新值的问题,因此笔者也对其封装为了一个自定义hook。

typescript 复制代码
// 用于解决使用useCallback时造成的闭包问题
const useEventCallback = <T extends (...args: any[]) => any>(fn: T) => {
    const ref = useRef(fn)
    useLayoutEffect(() => {
        ref.current = fn
    })
    return useCallback(((...args) => ref.current.call(window, ...args)) as T, [])
}

useEventCallbackuseCallback使用方式一致,只不过useEventCallback不会再因为闭包问题而拿不到新值,例如

typescript 复制代码
const App = () => {
    const [text, setText] = useState('')
    const [info, setInfo] = useState({
        name: '鲨鱼辣椒',
        age: 2
    })
    useEventCallback(() => {
        // 逻辑处理...
        console.log(info.name)
    }, [text])
    useEvent(() => {
        setTimeout(() => {
            setText(text + 1)
        }, 1000)
    }, [])
    return <p>{text}</p>
}

useForceUp

在React组件中,当组件的状态(state)被更改时,会使当前组件进行重新计算并渲染,例如

typescript 复制代码
const App = () => {
    const [count, setCount] = useState(0)
    useEffect(() => {
        setTimeout(() => {
            // 改变状态,会引发当前组件的重新渲染
            setCount(count + 1)
        }, 1000)
    }, [])
    return <p>{count}</p>
}

但有时候可能会有一种代码层面上的需求,比如某个组件没有涉及到要更改的状态(state),但却还想触发当前组件的重新渲染,这个情况下可以让父级组件重新带动子组件的渲染,除此之外,还可以自定义一个用于强制更新组件的hook。

typescript 复制代码
// 用于强制更新组件
const useForceUp = () => {
    const [_val, _setVal] = useState(0)
    return [undefined, () => _setVal(Math.random())] as const
}

useForceUp的使用形式与正常hooks的使用形式无异,例如

typescript 复制代码
const App = () => {
    const [, forceUp] = useForceUp()
    useEffect(() => {
        // 强制更新组件
        forceUp()
    }, [])
    return <p>我是内容</p>
}

useObjectState

在通过useState自定义对象类型的状态时,可能每次都需要通过扩展运算符(...)来得到之前的对象值,例如

typescript 复制代码
const App = () => {
    const [info, setInfo] = useState({
        name: '蜘蛛侦探',
        age: 18,
        sex: 1,
        signature: 'etc..'
    })
    useEffect(() => {
        setTimeout(() => {
            // 扩展运算符
            setCount({ ...info, name: '蝎子莱莱' })
        }, 1000)
    }, [])
    return <p>{info.name}</p>
}

如果你觉得每次更新Object对象时都要使用扩展运算符(...),那么此时也可以自定义一个hook。

typescript 复制代码
// 用于更新对象类型的State
const useObjectState = <T extends CustomValueByStringObject<any>>(val: T) => {
    const [_val, _setVal] = useState(val)
    const setVal = useEventCallback(<V extends { [U in keyof T]?: T[U] } | T>(val: V) => _setVal({ ..._val, ...val }))
    return [_val, setVal] as const
}

useObjectState的使用形式与useState的使用形式无异,例如

typescript 复制代码
const App = () => {
    const [info, setInfo] = useObjectState({
        name: '蜘蛛侦探',
        age: 18,
        sex: 1,
        signature: 'etc..'
    })
    useEffect(() => {
        setTimeout(() => {
            // 无需使用扩展运算符,正常更新即可
            setCount({ name: '蝎子莱莱' })
        }, 1000)
    }, [])
    return <p>{info.name}</p>
}

但有时useState({ ... })中会是一个比较复杂key-value结构,这个时候可以借助于第三方库(例如immutable)来修改对象类型的状态。

文末

至此,本文共列举了13种React组件的优化方式,期间也阐述了它们各自的概念和特点,并结合了Demo对其实际使用场景进行举例和分析。希望看到这篇文章的同学,可以记住每一个组件优化的技巧,并在项目中熟练使用起来。

本文所有代码均存在于GitHub Package仓库下,该仓库会持续制作一些package,欢迎Star

灵感来源

文章灵感来源于此项目(拥抱开源,React全家桶+Socket.io+Ts+Antd的IM即时通讯项目开源啦)。当然,这篇文章也是此项目的产物之一,后续笔者也会根据此项目继续产出更多文章。欢迎Star此项目

附录

  1. 《Vue/React中的key解析》
  2. 基于TS+Vue3可视化的项目
  3. 《JavaScript每日一题专栏》
  4. 《JavaScript每日一题------闭包1》
  5. 《JavaScript每日一题------闭包2》
  6. GitHub Package(本文所有代码)
  7. 《拥抱开源,React全家桶+Socket.io+Ts+Antd的IM即时通讯项目开源啦》
  8. GitHub: 基于React全家桶与Socket.io的多功能实时社交平台

由于时间匆忙,文中错误之处在所难免,敬请读者斧正。如果你觉得本篇文章还不错,欢迎点赞收藏和关注,我们下篇文章见!

相关推荐
栈老师不回家12 分钟前
Vue 计算属性和监听器
前端·javascript·vue.js
前端啊龙17 分钟前
用vue3封装丶高仿element-plus里面的日期联级选择器,日期选择器
前端·javascript·vue.js
一颗松鼠22 分钟前
JavaScript 闭包是什么?简单到看完就理解!
开发语言·前端·javascript·ecmascript
小远yyds42 分钟前
前端Web用户 token 持久化
开发语言·前端·javascript·vue.js
阿伟来咯~2 小时前
记录学习react的一些内容
javascript·学习·react.js
吕彬-前端2 小时前
使用vite+react+ts+Ant Design开发后台管理项目(五)
前端·javascript·react.js
学前端的小朱2 小时前
Redux的简介及其在React中的应用
前端·javascript·react.js·redux·store
guai_guai_guai2 小时前
uniapp
前端·javascript·vue.js·uni-app
bysking3 小时前
【前端-组件】定义行分组的表格表单实现-bysking
前端·react.js
王哲晓3 小时前
第三十章 章节练习商品列表组件封装
前端·javascript·vue.js