react点击改变样式_深入探究React性能优化

b9f198ffb1342a43d7d767253bb3c0ff.png

探究React 性能优化

React为设计高性能的React应用程序提供了很多优化,可以通过遵循一些最佳实践来实现。性能优化的关键在于是否能够减少不必要的Render,触发Render主要有下面的情况:

  • 发生setState。
  • props的改变。
  • 使用forceUpdate

下面给出了一些常见的优化方案,我们将解读、实践它们,对于部分内容我们会深入源码分析其原理。

React.PureComponent

组件嵌套造成的额外渲染

案例

来看下面这个组件嵌套的代码:

import React from "react";

class Footer extends React.Component {
  render() {
    console.log("Footer component render!");
    return (
      <div>Footer组件</div>
    )
  }
}

const List = () => {
  console.log("List component render!");
  return (
    <ul>
      <li>Hello</li>
      <li>world</li>
    </ul>
  )
}

class Main extends React.PureComponent {
  constructor(props) {
    super(props);

    this.state = {
      count: 0
    }
  }

  add() {
    console.log("add按钮被单击!");
    this.setState({
      count: this.state.count + 1
    })
  }

  render() {
    console.log("Main render!");
    return (
      <div>
        <div>current:{this.state.count}</div>
        <button onClick={() => this.add()}>add one</button>
        <List/>
        <Footer/>
      </div>
    )
  }
}

export default Main;

运行之后,页面如图所示:

08f0ed26fa223b696e286c888a065f88.png

页面初次渲染,打印了如上图的内容,这很正常 -- 每个组件都得被render一次。

但当我们点击Main组件中的add按钮时(如下图),三个组件被重新render了!但是Footer组件list组件的render是毫无必要的。

bef1fe671660c97fa4c207804fc7fa05.png

使用PureComponent

设想一下,假如我们能够在List和Footer组件被渲染之前对比一下前后的props是否改变 、state是否改变,再决定是否渲染不就可以了吗?我们可以使用shouldComponentUpdate这个生命周期函数来实现,它返回一个布尔值,来定义是否render,下面是官方文档的截图:

2cf5b9093cbf4a71de60a2cc5c7c4572.png

但是如果我们每个文件都写一遍,那么实在太麻烦了,所以我们可以使用PureComponent,下面我们尝试修改上面的Footer组件。

class Footer extends React.PureComponent {
  render() {
    console.log("Footer component render!");
    return (
      <div>Footer组件</div>
    )
  }
}

从下图中可以看出,Footer组件没有被重新渲染,美中不足的是,List组件(它是一个函数式组件)仍然发生了渲染,我们下面会解决它。

b2836906ff3edb8a183715e688bf1b45.png

PureComponent原理

根据上面的描述,我们可以猜出PureComponent的原理无非就是比较前后props、state是否改变,我们先看看PureComponent

c2b4f0e3b1648f4f04d5c1105eb3172a.png

注意最后设置isPureReactComponenttrue,React通过调用checkShouldComponentUpdate来判断,这个函数位于packages/react-reconciler/src/ReactFiberClassComponent.js下,注意下面的两个红框:

  • 第一部分:判断开发者是否使用了shouldComponentUpdate,如果是,执行并返回结果。(ps.出现的startPhaseTimer貌似是一个计时功能,我们这里不做探讨)
  • 第二部分:如果这个组件是PureComponent,执行第二个红框的代码,也是核心部分了 -- 它通过调用shallowEqual比较stateprops来决定是否需要更新。

e044486bf07f5f671b4225c1918c8d72.png

来看看shallowEqual,它位于packages/shared/shallowEqual.js,下面以注释的形式给出解析:

function shallowEqual(objA: mixed, objB: mixed): boolean {
  // 面向基本数据类型的比较,下面会单独提
  if (is(objA, objB)) {
    return true;
  }

  // object 和 null 的情况,也返回false
  if (
    typeof objA !== 'object' ||
    objA === null ||
    typeof objB !== 'object' ||
    objB === null
  ) {
    return false;
  }

  // 拿出所有的keys
  const keysA = Object.keys(objA);
  const keysB = Object.keys(objB);

  // 先比较长度,长度不等直接返回false
  if (keysA.length !== keysB.length) {
    return false;
  }

  // 循环遍历比较
  for (let i = 0; i < keysA.length; i++) {
    if (
      // 判断为true的条件:
      // 1.hasOwnProperty方法判断B中是否有A的key
      // 2.对value的基本数据类型进行比较
      !hasOwnProperty.call(objB, keysA[i]) ||
      !is(objA[keysA[i]], objB[keysA[i]])
    ) {
      return false;
    }
  }
  return true;
}

其中,is函数来自下面的代码,可以看出is间接地调用了Object.is,如果出现浏览器不支持的情况,那么调用自己写的is,这个函数可以被称为Object.is()polyfill

function is(x: any, y: any) {
  return (
    (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y)
  );
}

const objectIs: (x: any, y: any) => boolean =
  typeof Object.is === 'function' ? Object.is : is;

export default objectIs;

可以看出,Object.is可以对基本数据类型做出非常精确的比较,但是对引用类型无能为力:

Object.is([1, 2], [1, 2])
//false
Object.is({a:231}, {a:231})
//false

至此,我们搞懂了PureComponent的原理,但是它只支持类组件,下面我们来介绍一下如何优化函数式组件

Memo

使用Memo

同样是上面的例子,针对函数式组件,我们可以使用memo来避免多余的渲染,例如针对我们的List组件:

const List = memo(() => {
  console.log("List component render!");
  return (
    <ul>
      <li>Hello</li>
      <li>world</li>
    </ul>
  )
})

来看看效果:

000fd14ad84baff24e1e303b9cf21561.png

可以看出,在PureComponentMemo的配合下,计数器的更新值引起Main组件渲染,其他的组件没有出现无意义的渲染。

Memo原理

下面是Memo的代码:

932f8452278135c8a7b2930c7b63a24b.png

packages/react-reconciler/src/ReactFiberBeginWork.js下有如下代码,:

e7f2afdc36e85216f893fabcae22c6ba.png

注意红框的部分,compare在这里被执行 ,如果用户传入compare,则执行用户的逻辑,否则执行我们上面刚刚提到的shallowEqual

避免内联样式、对象的使用

大量使用内联样式和内联对象不仅让代码变得难以维护,而且会带来性能问题。:smile:

避免内联样式

使用内联样式,浏览器将花费更多时间执行脚本、渲染。例如下面的内联样式backgroundColor会被Babel解析成css中的background-color

import React from "react";

export default class InlineStyledComponents extends React.Component {
  render() {
    return (
      <>
        <b style={{"backgroundColor": "blue"}}>Welcome to Sample Page</b>
      </>
    )
  }
}

我们可以使用cssModule等方法来实现组件私有样式,具体实现这里不再赘述。

避免内联对象

来看下面的代码:

class User extends React.PureComponent {
  render() {
    return (
      <div>
        <div>{this.props.user.name}</div>
        <div>{this.props.user.age}</div>
      </div>
    )
  }
}

class DoNotUseInlineObject extends React.PureComponent {
  constructor(props) {
    super(props);
    this.state = {
      cnt: 0
    }
  }
  add() {
    console.log("add按钮被单击!");
    this.setState({
      cnt: this.state.cnt + 1
    })
  }
  render() {
    return (
      <div>
        <div>{this.state.cnt}</div>
        <button onClick={() => this.add()}>add!</button>
        <User user={{
          name: "yzl",
          age: 20
        }}/>
      </div>
    )
  }
}

export default DoNotUseInlineObject;

DoNotUseInlineObject这个组件的重点在于父组件传给User的Props是个行内元素,当调用render时,React会重新创建对此对象的引用,这会导致两者判断不相同,从而触发多余的render。类似于下面的代码:

const oldInfo = {
   name: "yzl",
   age: 20
}
const newInfo = {
  name: "yzl",
  age: 20
}
oldInfo === newInfo // false
one === one // true

这个props比较在哪里呢?我们上面刚刚说过,其实就是shallowEqual,接下来,我们使用浏览器的开发者工具来Debug,以感受两者的区别。

我们在shallowEqual函数的入口打上断点,它在react-dom.development.js的第12537行(不同版本可能会有差异,建议通过函数关键字来搜索)

使用内联对象

触发User组件的props比较,本质上是调用shallowEqual:

28eb37094ca67c929368d7664d60c6fc.png

一直单步执行,直到这个地方,结果返回了一个false:

926ca7f2433d7d874f4d32b7d8cd7c70.png

35db1a67027147c13598928d5138f7a5.png

究其原因,其实是objAObjB引用的对象不同。

不使用内联对象

接下来我们不使用内联对象,进行同样的调试操作:

先对代码作出一些修改(省略了没有发生改变的代码):

import React from "react";

const userInfo = {
  name: "yzl",
  age: 20
};

class DoNotUseInlineObject extends React.PureComponent {
  render() {
    return (
      <div>
        <div>{this.state.cnt}</div>
        <button onClick={() => this.add()}>add!</button>
        <User user={userInfo}/>
      </div>
    )
  }
}

export default DoNotUseInlineObject;

可以看出,这里判断为true了,这是因为objAObjB引用了同一个对象userInfo

b2924211c47da6c60ac410987d2f8bd4.png

React优化条件渲染

条件渲染指的是根据某个值的不同来渲染不同的组件,例如,下面的代码会根据flag的不同来渲染不同的组件树:

import React from "react";
import {useState} from "react";

const ConditionalRenderingCmp = () => {
  const [flag, setFlag] = useState(false);
  if (flag) {
    return (
      <>
        <Flag></Flag>
        <Header></Header>
        <Content></Content>
      </>
    )
  } else {
    return (
      <>
        <Header></Header>
        <Content></Content>
      </>
    )
  }
}

export default ConditionalRenderingCmp;

这里会发生什么性能问题呢?要回答此问题,我们必须知道React中Diff算法针对同层节点是采用同时遍历来进行对比的,也就是说,当上面代码的flag改变,两个组件树进行diff,过程如下:

fb70a95ffee643b25c68fe1a0b2bdfba.png
  • flag vs Header,不同,生成mutation。
  • Header vs Content,不同,生成mutation。
  • Content,生成mutation。

但如果我们使用后者的方法,那么,diff将变成这样:

5e91ab9f0f7a65072497624c866132e0.png

本质上是diff时,header和一个null节点进行比较,从而让下面得兄弟元素进行比较时是相等的,从而带来了性能优化。

提示:我们也可以使用设置key来达到类似的效果,关于key的内容后面也会讲到。

我们可以这样优化代码:

const ConditionalRenderingCmp = () => {
  const [flag, setFlag] = useState(false);
  useEffect(() => {
    setTimeout(() => {
      setFlag(true);
    }, 1000);
  }, []);

  return (
    <>
      {flag && <Header/>}
      <Content/>
      <Footer/>
    </>
  )
}

怎么来验证上面的结论呢,我们可以查看浏览器的调试工具,尤其注意DOM元素的变化:

优化之前的版本,我们可以看到三个div都重新执行了DOM操作(观察深色区域):

857530ac2abdd8615143f8c5e3f6aa99.png

优化之后的版本,注意只有header进行了DOM操作

030855366a526f5a4feafd29b53fac59.png

正确地使用key

key的原理

key是服务于react的diff算法的,正确的使用key可以发挥出diff算法的效果。我们上面提到过类似的情况,我们再来温习一下:

对于下面的DOM结构,React会同时遍历两个子节点的列表,有差异时会生成一个mutation

d00439653e5607c95e2d5ab1e7512658.png

但是这种情况太理想了!如果是下面这种情况,那么就会带来不必要的DOM操作了(创建了较多的mutation)!

81ce57a241ab97d1855b4255410c6d8f.png

这种情况下,Key的作用就体现了,我们可以使用key来匹配。

6b11e7e990e520452855d21e07353815.png

比较时,key为a的元素不变,添加了key为c的元素mutation,同时key为b的元素只进行位移,无需额外修改,最终。我们只创建了一个mutation。当然,我们的key必须唯一!除了这个注意的地方,下面还有几个关于key的注意点。

key的注意点

不要使用随机数

随机数在下一次render时,每个元素会重新生成key,前后就无法匹配到了。

不要使用index作为key

一般情况下,我们不要使用index作为key。类似于上面的例子,当我们将一个<li>插入ul的最前面,由于key的存在(是一个固定的唯一数),其它的元素只是进行了位移

但是如果我们使用index作为key,那么在插入之后,最初具有键值1的元素具有键值2,React会认为所有的组件都被修改,于是进行了额外的渲染。

使用Hooks

useCallback

useCallback的功能在于让一个函数"可记忆化"(memoized),当依赖(第二个参数)被改变时它才会执行更新,利用它我们可以让某些组件避免render。

来看下面的代码:

import React, {memo, useCallback, useState} from "react";

const Child = () => {
  console.log("child render!");
  return (
    <div>
      child!
    </div>
  )
}

const MyButton = memo((props) => {
  console.log("button render! ==> " + props.flag);
  return (
    <div>
      <button onClick={() => props.add()}>set!</button>
    </div>
  )
});

const TryUseCallBack = () => {
  const [flag, setFlag] = useState(false);
  const [cnt, setCnt] = useState(0);

  const setMyFlag = () => {
    setFlag(!flag);
  };

  const addOne = () => {
    setCnt(cnt + 1);
  }

  const addTwo = useCallback(() => {
    setCnt(cnt + 2);
  },[cnt])

  console.log("main render!");
  return (
    <div>
      <div>{cnt}</div>
      <div>{flag ? "yes" : "no"}</div>
      <Child/>
      <MyButton add={addOne} flag={"button1"}/>
      <MyButton add={addTwo} flag={"button2"}/>
      <button onClick={() => setMyFlag()}>add</button>
    </div>
  )
}

export default TryUseCallBack;

d91897e7be715b8ee8145458d1ccf072.png
  • 点击第一个set按钮时,addOne被调用,cnt修改,导致Main组件重新render,addOne、addTwo被重新更新。
  • 点击第二个set按钮,道理一样。
  • 点击第三个set按钮,setFlag被调用,flag改变,但是addTwo由于memoized了,不会进行更新,从而第二个button不会触发render。

useMemo

useMemouseCallback适用性更加广:

import React, {useMemo} from "react";
import {useState} from "react";

const getTenBigger = (cnt) => {
  console.log("函数被重新定义");
  return cnt + 10;
}

const TryUseMemo = () => {
  const [cnt, setCnt] = useState(0);
  const [flag, setFlag] = useState(false);

  let tenBigger = useMemo(() => getTenBigger(cnt), [cnt]);
  return (
    <div>
      <div>{tenBigger}</div>
      <div>{flag ? "yes" : "no"}</div>
      <button onClick={() => setCnt(cnt + 1)}>add!</button>
      <button onClick={() => setFlag(!flag)}>set flag</button>
    </div>
  )
}

export default TryUseMemo;

按下setFlag按钮之后,函数getTenBigger并没有重新定义,第五行的打印也就不会执行。它只在cnt改变之后才会重新定义:

9e8da8c0e7a083438291ac9f15b7afda.png

相对于useCallbackuseMemo的返回值可以是多样的,更加灵活,前者只能是函数。

使用懒加载

介绍

想象一下我们直接将项目打包,体积可能会非常大。上线必然会遇到一些令人不适的问题 -- 极慢的首屏加载、CDN流量的浪费......懒加载可以让我们做到按需加载,来看下面这个案例:

import React, {useState} from "react";
import OtherComponent from "./OtherComponent";


const LazyLoad = () => {
  const [show, setShow] = useState(false);
  return (
    <div>
      <button onClick={() => setShow(!show)}>show!</button>
      {show && <OtherComponent/>}
    </div>
  )
}

export default LazyLoad;

LazyLoad组件有一个按钮,按下按钮显示OtherComponent组件,在现实中这可能是个登录业务 -- 用户登录则展示管理面板。在页面一打开时就把所有组件加载意义不大,且会导致加载缓慢,我们可以使用react提供的懒加载组件React.lazy

尝试React.lazy

我们将上面的代码稍作修改:

React的懒加载通过,import()lazy()Suspense组件实现。

import React, {lazy, Suspense, useState} from "react";

const Other = lazy(() => import("./OtherComponent"));

const LazyLoad = () => {
  const [show, setShow] = useState(false);
  return (
    <div>
      <button onClick={() => setShow(!show)}>show!</button>
      <Suspense fallback={null}>
        {show && <Other/>}
      </Suspense>
    </div>
  )
}


export default LazyLoad;

使用懒加载前:

26abc51fbf30533680fe6366bf1f2175.png

使用懒加载后:

afd55177822f6be47e175fc5897ca273.png

红色方框的js文件只有在按钮被单击时才会加载。

React.lazy原理

从import()说起

import()被称为动态import,来看这样一个例子:

// index.js
const Component = () => {
  let element = document.createElement('div');
  let button = document.createElement('button');
  let br = document.createElement('br');

  button.innerHTML = '单击我加载 print.js';
  element.innerHTML = "hello world~";
  element.appendChild(br);
  element.appendChild(button);
  button.onclick = () => import('./print')
    .then((m) => {
      console.log(m);
      m.default();
    });
  return element;
}

document.body.appendChild(Component());
// 被懒加载的模块 -- print.js
console.log('print.js懒加载....');

export default () => {
  console.log('按钮被按下啦~');
}

当 Webpack 解析到该import()语法时,会自动进行代码分割。

React-lazy实现

lazy的代码如下,删除了开发环境下额外处理的部分,它返回一个LazyComponent对象,请务必留意这些代码,下面会提到:

import type {LazyComponent, Thenable} from 'shared/ReactLazyComponent';

import {REACT_LAZY_TYPE} from 'shared/ReactSymbols';

export function lazy<T, R>(ctor: () => Thenable<T, R>): LazyComponent<T> {
  let lazyType = {
    $$typeof: REACT_LAZY_TYPE,
    _ctor: ctor,
    // React uses these fields to store the result.
    _status: -1,
    _result: null,
  };
  return lazyType;
}

packages/react-reconciler/src/ReactFiberBeginWork.js下有一个mountLazyComponent函数,我们一眼可以看出红框部分是加载lazy组件的关键代码:

31a2a96469d84149bbd10a2d86dcc78e.png

它的代码如下:

export function readLazyComponentType<T>(lazyComponent: LazyComponent<T>): T {
  initializeLazyComponentType(lazyComponent);
  if (lazyComponent._status !== Resolved) {
    throw lazyComponent._result;
  }
  return lazyComponent._result;
}

来看initializeLazyComponentType,这里是核心部分:

export const Uninitialized = -1;
export const Pending = 0;
export const Resolved = 1;
export const Rejected = 2;


export function initializeLazyComponentType(
  lazyComponent: LazyComponent<any>,
): void {
  if (lazyComponent._status === Uninitialized) {
    lazyComponent._status = Pending;
    const ctor = lazyComponent._ctor;
    const thenable = ctor();
    lazyComponent._result = thenable;
    thenable.then(
      moduleObject => {
        if (lazyComponent._status === Pending) {
          const defaultExport = moduleObject.default;
          lazyComponent._status = Resolved;
          lazyComponent._result = defaultExport;
        }
      },
      error => {
        if (lazyComponent._status === Pending) {
          lazyComponent._status = Rejected;
          lazyComponent._result = error;
        }
      },
    );
  }
}

这个函数主要干了这些事情:

  • 判断lazyComponent对象_status变量是否为Uninitialized(未初始化,值为 -1) 。如果您对前面的内容还有记忆的话,开发者调用lazy函数时会初始化_result-1
  • 准备加载组件,将状态设置为Pending(加载中,值为 0 )。
  • 调用ctor()函数,这个ctor的类型为Thenable(即带有then方法) :
export type Thenable<T, R> = {
  then(resolve: (T) => mixed, reject: (mixed) => mixed): R,
  ...
};

其实,他就是我们上面提到的import("./xxxxxx")这个动态导入语法。

  • 执行thenable.then(),拿到对应的模块,如果此时状态为Pending,让_result指向moduleObject.default,至此,我们的lazyComponent初始化完毕。
  • 如果加载失败了,在error处捕获它,将状态置为Reject,结果置为error

我们回到上面的readLazyComponentType函数,如果结果不为resolved,则抛出“异常”,这个异常会交给Suspence组件处理,Suspence组件会渲染fallback中的内容。

参考资料 & 推荐阅读

【推荐阅读】

22 React Performance Optimization Techniques​medium.com

【参考资料】

Polyfill​developer.mozilla.org
605f2dbece0c049af66b6d0ac136fa56.png
reconciliation​zh-hans.reactjs.org react代码仓库​github.com
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值