React 函数式组件优化

6,693 阅读8分钟

前言

React 推出后,先后出现了三种定义组件的方式,分别是:

  • 函数式组件
  • React.createClass 创建组件
  • React.Component 创建组件

相信大家在日常中使用的最多的还是函数式组件和 React.Component 组件吧,今天就简单的说下函数式组件的两个优化方法。

函数式组件

什么是函数式组件

在谈到函数式组件之前我们先看一个概念 - 纯函数

何为纯函数?

引用一段维基百科的概念。

在程序设计中,若一个函数符合以下要求,则它可能被认为是纯函数

此函数在相同的输入值时,需产生相同的输出。函数的输出和输入值以外的其他隐藏信息或状态无关,也和由I/O设备产生的外部输出无关。

该函数不能有语义上可观察的函数副作用,诸如“触发事件”,使输出设备输出,或更改输出值以外物件的内容等。

可以看到,纯函数有着相同的输入必定产生相同的输出,没有副作用的特性。

同理,函数式组件的输出也只依赖于 propscontext,与 state 无关。

函数式组件的特点

  • 没有生命周期
  • 无组件实例,没有 this(相信很多同学被 this 烦过)
  • 没有内部状态(state)

函数式组件的优点

  • 不需要声明 class,没有 constructorextends等代码,代码简洁,占用内存小。
  • 不需要使用 this
  • 可以写成无副作用的纯函数。
  • 更佳的性能。函数式组件没有了生命周期,不需要对这部分进行管理,从而保证了更好地性能。

函数式组件的缺点

  • 没有生命周期方法。
  • 没有实例化。
  • 没有 shouldComponentUpdate,不能避免重复渲染。

React.memo

一个例子

这里先看一个例子,代码如下,也可点击这里进行查看。

import * as React from "react";
import { render } from "react-dom";

import "./styles.css";

function App() {
  const [n, setN] = React.useState(0);
  const onClick = () => {
    setN(n + 1);
  };
  return (
    <div className="App">
      <div>React.memo demo-1</div>
      <button onClick={onClick}>update {n}</button>
      <Child />
    </div>
  );
}

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

const rootElement = document.getElementById("root");
render(<App />, rootElement);

它实现了什么?就一个很简单的东西,一个父组件包了一个子组件。

这个大家判断一下,当我点击父组件的 button 更新 N 的时候,子组件中的 log 会不会执行?

按照一般的思维来看,你父组件更新关我子组件什么事?我子组件的 DOM 又不用更新,既然没有更新,那还打什么 log

但实际效果是,每点击一次 button,子组件的 log 就会执行一次,虽然子组件的 DOM 没更新,但并不代表子组件的渲染函数没有执行。

以下是执行的效果图。

React.memo demo-1.gif

优化

针对上述情况,class 组件可以使用 shouldComponentUpdate 来进行优化,但是函数式组件呢?React 同样也提供了一个优化方法,那就是 React.memo

memomemorized,意思是记住。如果输入的参数没有变,依据纯函数的定义,输出也不会变,那么直接返回之前记忆的结果不就行了。

const Child = React.memo(() => {
  console.log("render child");
  return <div className="child">Child Component</div>;
});

Child 使用 React.memo 处理一下,这样的话,无论你点击多少次父组件的 button,子组件的 log 都不会执行。

完整代码点这里

效果图如下:

React.memo demo-2.gif

React.useCallback

我们将上述代码稍微改一下, 让 Child 接受一个匿名函数,看看会产生什么后果。完整代码点这里

import * as React from "react";
import { render } from "react-dom";

import "./styles.css";

function App() {
  const [n, setN] = React.useState(0);
  const onClick = () => {
    setN(n + 1);
  };
  return (
    <div className="App">
      <div>React.useCallback demo-1</div>
      <button onClick={onClick}>update {n}</button>
      <Child onClick={() => {}} />
    </div>
  );
}

const Child = React.memo((props: { onClick: () => void }) => {
  console.log("render child");
  return <div className="child">Child Component</div>;
});

const rootElement = document.getElementById("root");
render(<App />, rootElement);

观察代码可以看到也没啥变化嘛,只是子组件接受了一个空函数。

那么问题又来了,这次点击 button 子组件的 log 会执行吗?

看到这里各位同学应该会想,每次都传一个空的匿名函数,props 也没变啊,那就不用重新渲染呗。具体结果如何,来看下效果:

React.useCallback demo-1.gif

可以看到每次点击 button 时,子组件的 log 依旧会再次执行。那么这是为什么呢?

因为每次点击 button 更新父组件的时候,会重新生成一个空的匿名函数,虽然它们都是空的匿名函数,但是它们不是同一个函数。

函数是一个复杂类型的值,JavaScript 在比较复杂类型的值时,是对比它们的内存地址。不是同一个函数,那么内存地址也就不同, React 会认为子组件的 props 发生了变化,子组件将重新渲染。

优化

那么怎么保证子组件每次都接受同一个函数呢?

很简单。既然父组件在更新的时候会重新生成一个函数,那么我把函数放到父组件外面不就可以了嘛,这样父组件在更新的时候子组件就会接受同一个函数。

代码如下。也可点击这里查看。

import * as React from "react";
import { render } from "react-dom";

import "./styles.css";

const childOnClick = () => {};

function App() {
  const [n, setN] = React.useState(0);
  const onClick = () => {
    setN(n + 1);
  };
  return (
    <div className="App">
      <div>React.useCallback demo-2</div>
      <button onClick={onClick}>update {n}</button>
      <Child onClick={childOnClick} />
    </div>
  );
}

const Child = React.memo((props: { onClick: () => void }) => {
  console.log("render child");
  return <div className="child">Child Component</div>;
});

const rootElement = document.getElementById("root");
render(<App />, rootElement);

效果图如下:

React.useCallback demo-2.gif

这样子看起来好像解决了子组件每次都接受不同的函数导致重新渲染的问题,但是好像哪里不对劲,实现也不优雅。

缺点

如果子组件的函数依赖父组件里面的值,那么这种方式就不可行。

怎么办呢?如果能将函数也 memorized 就好了。

Hook

React16.8.0 的版本中正式推出了 Hooks,其中有一个 Hook 叫做 useCallback,它能将函数也 memorized 化。

useCallback 接受两个参数,第一个参数是一个函数,第二个参数是一个依赖数组,返回一个 memorized 后的函数。只有当依赖数组中的值发生了变化,它才会返回一个新函数。

看看使用 useCallback 后的代码,也可以点击这里查看。

import * as React from "react";
import { render } from "react-dom";

import "./styles.css";

function App() {
  const [n, setN] = React.useState(0);
  const [m, setM] = React.useState(0);
  const childOnClick = React.useCallback(() => {
    console.log(`m: ${m}`);
  }, [m]);
  return (
    <div className="App">
      <div>React.useCallback demo-3</div>
      <button
        onClick={() => {
          setN(n + 1);
        }}
      >
        update n: {n}
      </button>
      <button
        onClick={() => {
          setM(m + 1);
        }}
      >
        update m: {m}
      </button>
      <Child onClick={childOnClick} />
    </div>
  );
}

const Child = React.memo((props: { onClick: () => void }) => {
  console.log("render child");
  return (
    <div className="child">
      <div>Child Component</div>
      <button onClick={props.onClick}>log m</button>
    </div>
  );
});

const rootElement = document.getElementById("root");
render(<App />, rootElement);

在上述代码中,子组件接受一个使用了 useCallback 的函数,它的依赖参数是 m,只有当 m 发生了变化,子组件接受的函数才会是一个重新生成的函数。也就是说,无论点击多少次更新 nbutton,子组件都不会更新,只有点击更新 mbutton 时,子组件才会更新。

看看效果如何:

React.useCallback demo-3.gif

实际效果符合我们的预期。

歪心思

看到这里有些同学就会想了,如果使用 useCallback 的时候,传一个空数组作为依赖数组,那么子组件就不再受父组件的影响了,即使你父组件的 m 变化了,我子组件依旧不会重新渲染,这样子岂不是性能更好?话不多说,我们来测试一下就好了。代码点击这里查看,效果如下:

React.useCallback demo-4.gif

可以看到,虽然子组件确实没重复渲染了,但同样的也导致一个问题,打印出来的 m 永远都是 0,再也读取不到更新后的 m 的值。

由此可以得出结论,传一个空数组作为依赖数组的后果就是,子组件接受的函数里面的参数永远都是初始化使用 useCallback 时的值,这样的结果并不是我们想要的。

所以歪心思还是少来了。

总结

随着 React 正式推出 Hooks,带来一系列新的特性,极大地增强了函数式组件的功能,利用这些新特性可以实现和 class 组件一样的效果。

有了 React Hooks,我们可以抛弃沉重的 class 组件,使用更加轻便,性能更加优异的函数式组件,因此掌握一些函数式组件的优化方法对我们使用函数式组件开发是非常有用处的。

React Hooks 不管你香不香,反正我是先香了。

默默说一句,Vue 3.0 也会推出函数式组件。