React 框架进阶(性能优化、组件懒加载、HOC、Hook)

1,928 阅读13分钟

前言

大部分语言或者框架库的使用,一旦涉及到进阶部分,绝对离不开三点:

1、性能优化 2、组件封装 3、语言难点

本文将会围绕这3个方向讲解React相关的进阶知识。

React 性能优化

SCU

在 React 中默认父组件更新,子组件无条件更新,有很多时候子组件的状态是没有变化的,并不需要更新,那么有没有什么办法可以处理吗?答案是肯定的,可以使用生命周期函数shouldComponentUpdate 简称SCU对组件的状态进行对比从而判断组件是否需要更新。

shouldComponentUpdate(nextProps, nextState) {
  if (nextProps.color !== this.props.color) {
    return true // 可以渲染
  }
  if (nextState.count !== this.state.count) {
    return true // 可以渲染
  }
  return false // 不重复渲染
}

通过判断组件

  • 当前state与变化之后的nextState
  • 当前props与变化之后的nextProps

来决定组件自身是否更新。

我们来看一个简单的例子:

import React from "react";

class Scu extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      count: 0,
      content:"我是 ScuChild 组件"
    }
  }
  
  onIncrease = () => {
    this.setState({
      count: this.state.count + 1
    })
  }
  
  render() {
    console.log("Scu,渲染了~");
    return <div>
      <ScuChild content={this.state.content} />
      <span>{this.state.count}</span>
      <button onClick={this.onIncrease}>increase</button>
    </div>
  }
}

class ScuChild extends React.Component{
  render() {
    console.log("ScuChild 渲染了~")
    return <div>
      {this.props.content}
    </div>
  }
}

export default Scu

父组件每次点击时 count 增加 1,这么个简单的例子,每次都是改变了Scu组件自身的状态,而它的子组件 ScuChild 获取到的props.content从未改变过,但是每次点击都更新了它,这很明显不合理,但是React框架就是这样设计的,没有帮我们处理,只能自己动手了。

在 ScuChild 组件中添加:

shouldComponentUpdate(nextProps, nextState) {
  // 当content不一样时,我们进行更新,否则不更新
  return nextProps.content !== this.props.content;
}

当content值不变化时,它是不会再更新了。

PureComponent

每次都手写 shouldComponentUpdate 函数太麻烦了,React提供了 PureComponent 基类给我们继承,它用当前与之前 props 和 state 的浅比较覆写了 shouldComponentUpdate() 的实现。

也就是说,如果是引用类型的数据,只会比较是不是同一个地址,而不会比较具体这个地址存的数据是否完全一致。

这也解答了为什么setState时,我们必须要使用不可变值,每次都需要浅复制state中的对象,而不能直接使用push,pop等改变对象或数组的方法。

setState({
  list:this.state.list.push(1)
})

如果我们这样 setState 的话,如果是它的子组件接收 list 来渲染,那么 PureComponent 对比 list 对象的地址没有变化就不会去渲染,实际上已经 push 了一个值进去了。

class ScuChild extends React.PureComponent{
  
  render() {
    console.log("ScuChild 渲染了~")
    return <div>
      {this.props.content}
    </div>
  }

}

改造过后是不是写起来非常方便。

Memo

在函数组件中并没有提供 shouldComponentUpdate 生命周期钩子给我们,可以使用React.Memo函数进行包装

function ScuChild2(props) {
  console.log("ScuChild2");
  return (
    <div>
      {props.content}
    </div>
  )
}

export const MemodScuChild = React.memo(ScuChild2);

Reack.memo 还可以接受一个比较函数:

export const MemodScuChild = React.memo(ScuChild2,(prevProps, nextProps)=> prevProps.content===nextProps.content);

组件懒加载

通过懒加载来优化项目性能是常规的操作手段了,TC39提案有 import()动态导入方法,它使得懒加载变的非常简单。React v16.6 发布了 lazy 函数让 React 实现组件懒加载更加方便。

import React from 'react'

const Scu = React.lazy(() => import('./Scu'));

class Lazy extends React.Component {
    constructor(props) {
        super(props)
    }
    render() {
        return <div>
            <p>引入一个动态组件</p>
            <hr />
            <React.Suspense fallback={<div>Loading...</div>}>
                <Sku />
            </React.Suspense>
        </div>
        // 刷新,可看到 loading (看不到就限制一下 chrome 网速)
    }
}

export default Lazy

Scu 组件就是上面编写好的组件,现在通过 Lazy 组件的懒加载的方式引入。

有两点需要注意的:

  1. 需要懒加载的组件必须通过 export default 导出组件;
  2. React.lazySuspense 技术不支持服务端渲染。

懒加载原理分析

lazy 函数源码

export function lazy<T, R>(ctor: () => Thenable<T, R>): LazyComponent<T> {
  return {
    ?typeof: REACT_LAZY_TYPE,
    _ctor: ctor,
    _status: -1, // 资源的状态
    _result: null, // 存放加载文件的资源
  };
}
const Scu = React.lazy(() => import('./Scu')); 

就相当于

const Scu = {
  ?typeof: REACT_LAZY_TYPE, // 表明懒加载类型
  _ctor: () => import('./Scu'),
  _status: -1, // 初始化的状态 -1 == pending
  _result: null,
}

还有另外一个关键函数 readLazyComponentType 它是React中负责解析懒加载的函数:

export function readLazyComponentType<T>(lazyComponent: LazyComponent<T>): T {
  const status = lazyComponent._status;
  const result = lazyComponent._result;
  switch (status) {
    case Resolved: {
      const Component: T = result;
      return Component;
    }
    case Rejected: {
      const error: mixed = result;
      throw error;
    }
    case Pending: {
      const thenable: Thenable<T, mixed> = result;
      throw thenable;
    }
    default: {
      lazyComponent._status = Pending;
      const ctor = lazyComponent._ctor;
      const thenable = ctor();
      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._result = thenable;
      throw thenable;
    }
  }
}

源码解释:

首先 switch (status) 是通过组件的状态进行执行,默认状态是 -1 因此会走defalut分支。defalut有一段核心代码:

import('./Scu').then(
  moduleObject => {
    if (lazyComponent._status === Pending) {
      const defaultExport = moduleObject.default; // {1}
      lazyComponent._status = Resolved;  // {2}
      lazyComponent._result = defaultExport; // {3}
    }
  },
  error => {
    if (lazyComponent._status === Pending) {
      lazyComponent._status = Rejected;
      lazyComponent._result = error;
    }
  },
);
  • {1} 获取异步加载组件的输出对象的default属性,还记得上面有个注意点吗?“需要懒加载的组件必须通过 export default 导出组件”,是不是从源码 const defaultExport = moduleObject.default 就可以理解为什么要这样做了。
  • {2} 更改它的状态为完成 Resolved 
  • {3} lazyComponent._result  属性赋值为组件的实际内容

因此可以分析出其实核心依然是ES2020提出的 import("...") 动态加载模块的方案。

webpack 打包 import() 的基本原理:

  1. 遇到 import() 加载的组件,则打包成一个单独的 chunk 
  2. 当需要满足加载条件时,则动态插入一个 <script src="chunk path"></script> 
  3. 当加载成功则触发了 import('./Scu').then 里面的第一个函数,当加载失败则触发第二个函数。

Suspense 原理

在使用 lazy 组件时必须使用 React.Suspense 进行包裹,当懒加载组件还没有加载完成时,会显示自定义 loading 状态。我们通过一个 Suspense 的伪代码来理解其原理

import React from 'react'

class Suspense extends React.Component {
  state = {
    promise: null
  }

  componentDidCatch(e) {
    if (e instanceof Promise) {
      this.setState({
        promise: e
      }, () => {
        e.then(() => {
          this.setState({
            promise: null
          })
        })
      })
    }
  }

  render() {
    const { fallback, children } = this.props;
    const { promise } = this.state;
    return <>
      { promise ? fallback : children }
    </>
  }
}

代码解释:

核心代码就在 componentDidCatch 生命周期函数中,它可以捕获子组件树发生的任何错误。然后通过错误类型去 setState promise 的值,最后根据 promise 的值决定渲染什么内容。

这样就完全可以理解为什么 readLazyComponentType 会 throw thenable ,因为 Suspense 是通过捕获错误的方式来实现的。 截获到对应的错误时就去展示懒加载的组件,否则就展示设置好的loading组件。

React 组件封装

提起封装,在 JavaScript 中我们第一个想到的肯定是高阶函数(例如回调方法的封装),那么在 React 中我们也是使用类似的思想去做封装,叫做高阶组件 HOC 。

HOC

高阶组件就是一个函数,且该函数接受一个组件作为参数,并返回一个新的组件。

我们想象一个汽车装配流水线,一个空架子的汽车,经过一个流水线就装配上了底盘,再经过另外一条流水线就装配上了方向盘...一次经过。把能力抽象出来,给所有不具备该能力的组件加上一个能力。这就是高阶组件的具象说明了。

在将设计模式那篇文章时也将了,HOC其实就是装饰罩模式,而且只需要通过更改配置文件就支持装饰罩模式的写法 @HOC 

在编程的世界里面我们没有方向盘、车载音响安装。但是我们会有我们的实际业务也是同样需要装配的。

实现高阶组件

1、属性代理

函数返回一个我们自己定义的组件,然后在 render 中返回要包裹的组件,这样我们就可以代理所有传入的 props,并且决定如何渲染,实际上 ,这种方式生成的高阶组件就是原组件的父组件

function proxy(WrappedComponent) {
  return class extends React.Component {
    render() {
      return <WrappedComponent {...this.props} />;
    }
  }
}

2、反向继承

返回一个组件,继承原组件,在 render中调用原组件的 render。由于继承了原组件,能通过this访问到原组件的 生命周期、props、state、render等,相比属性代理它能操作更多的属性。

function inherit(WrappedComponent) {
  return class extends WrappedComponent {
    render() {
      return super.render();
    }
  }
}

HOC 可以实现的功能

组合渲染
function CombProxy(WrappedComponent) {
  return class extends React.Component {
    render() {
      return (
        <>
          <div>{this.props.title}</div>
          <WrappedComponent {...this.props} />
        </>
      )
    }
  }
}

class Child extends React.Component{
  render() {
    return (
      <div>child 组件</div>
    )
  }
}

export default CombProxy(Child);

这样任意组件经过 CombProxy 组件的包装,都具备可以渲染标题的能力了,而不再需要自己单独添加该功能。

双向数据绑定
import React from "react";

function proxyHoc(WrappedComponent){
  return class extends React.Component{
    constructor(props) {
      super(props);
      this.state = {value:""}
    }
    onChange = (event)=>{
      const {onChange} = this.props;

      this.setState({
        value: event.target.value
      },()=>{
        if(typeof onChange === 'function'){
          onChange(this.state.value);
        }
      })
    }
    render(){
      const newProps = {
        value: this.state.value,
        onChange: this.onChange
      }
      const props = Object.assign({},this.props,newProps);
      return <WrappedComponent {...props} />
    }
  }
}

class HOC extends React.Component{
  render() {
    return <input {...this.props} />
  }
}

export default proxyHoc(HOC);

代码解释:

  • input 表单元素传递进入高阶组件
  • 高阶组件中自定义了 value 状态,以及 onChange 事件,并且传递到 input 表单元素上
  • 这样表单元素就相当于 <input onChange={...} value="..."> 并且双向绑定的逻辑都在高阶组件完成。
表单校验

我们基于上面这个高阶函数继续给表单一个校验的能力。

import React from "react";

function proxyHoc(WrappedComponent){
  return class extends React.Component{
    constructor(props) {
      super(props);
      this.state = {
        value:"",
        error:""
      }
    }
    onChange = (event)=>{
      const {onChange,validator} = this.props;

      if(validator && typeof validator.func === 'function'){
        if(validator.func(event.target.value)){
          this.setState({
            error : ""
          })
        }else{
          this.setState({
            error : validator.msg
          })
        }
      }
      this.setState({
        value: event.target.value
      },()=>{
        if(typeof onChange === 'function'){
          onChange(this.state.value);
        }
      })
    }
    render(){
      const newProps = {
        value: this.state.value,
        onChange: this.onChange
      }
      const props = Object.assign({},this.props,newProps);
      return (
        <>
          <WrappedComponent {...props} />
          <div>{this.state.error}</div>
        </>
      )
    }
  }
}

class HOC extends React.Component{
  render() {
    return <input {...this.props} />
  }
}

export default proxyHoc(HOC);

同时还是这个高阶组件,既完成 onChange 双向绑定的功能,又可以根据传递进来的校验规则进行校验

import Input from "./input";

function Hoc() {
  
  const validatorName = {
    func: (val) => val && val.length > 2,
    msg : "名字必须大于2位"
  }

  return (
    <div className="App">
      <Input validator={validatorName} onChange={(val)=>{console.log(val)}} />
    </div>
  );
}

export default Hoc;

调用起来也是非常简单的。这样进行抽象组件层次分明,代码易于维护。

高阶组件的缺陷:

  • HOC需要在原组件上进行包裹或者嵌套,如果大量使用 HOC,将会产生非常多的嵌套,这让调试变得非常困难。

  • HOC可以劫持 props,在不遵守约定的情况下也可能造成冲突。

function 组件

Function Component 是更彻底的状态驱动抽象,甚至没有 Class Component 生命周期的概念,只有一个状态,而 React 负责同步到 DOM。

function Welcome(props) {
    return <h1>Hello, {props.name}</h1>;
}

函数组件容易阅读和测试,没有状态或生命周期。因此可以让我们快速的写一个渲染UI的组件,这也与React推崇函数式编程的思想契合。

在React v16.8 推出之前函数组件只能简单的渲染组件,没有自身的状态,Hook的到来改变了这一现状,它赋予函数组件更多的能力。

通过useEffect Hook让函数组件拥有“生命周期”,它跟 class 组件中的componentDidMountcomponentDidUpdatecomponentWillUnmount具有相同的用途,只不过被合并成了一个 API。

通过useState让函数组件拥有了自身的状态。

好吧,讲了这么多了,估计你应该对函数组件已经有所了解了。那么就让我们深入学习下函数组件的特性。

Capture Value

先看一个例子:

class App extends React.Component{
  state = {
    count: 0
  }
  show = ()=>{
    setTimeout(()=>{
      console.log(`1秒前 count = 0,现在 count = ${this.state.count}`);
    },1000)
  }
  render() {
    return (
      <div onClick={()=>{
        this.show();
        this.setState({
          count: 5
        })
      }}>
        点击Class组件
      </div>
    );
  }
}

点击输出为:1秒前 count = 0,现在 count = 5。

这个很好理解,定时器1秒后获取到的是组件实例更改后的状态,符合我们正常的思维。

function App(){
  const [count , setCount] = React.useState(0);
  const show = ()=>{
    setTimeout(()=>{
      console.log(`1秒前 count = 0,现在 count = ${count}`);
    },1000)
  }
  return (
    <div onClick={()=>{
      show();
      setCount(5);
    }}>
      点击函数组件
    </div>
  );
}

点击输出为:1秒前 count = 0,现在 count = 0。

这个结果与Class组件的结果就完全不一样了,1秒之后获取到的状态任然是之前的状态。这个现象我们称之为函数组件的 Capture Value 特性,就像每次点击拍了一张快照保存了当时的数据,我们来分析具体原理。

每次 Render 都有自己的 Props 与 State

当第一点击时相当于这样:

function App(){
  const count = 0;
  const show = ()=>{
    setTimeout(()=>{
      console.log(`1秒前 count = 0,现在 count = ${count}`); // 1秒前 count = 0,现在 count = 0
    },1000)
  }
  ...
}

当第二次点击时相当于这样:

function App(){
  const count = 5;
  const show = ()=>{
    setTimeout(()=>{
      console.log(`1秒前 count = 0,现在 count = ${count}`); // 1秒前 count = 0,现在 count = 5
    },1000)
  }
  ...
}

可以认为每次 Render 的内容都会形成一个快照并保留下来,因此当状态变更而 Rerender 时,就形成了 N 个 Render 状态,而每个 Render 状态都拥有自己固定不变的 PropsState

绕过 Capture Value 特性

利用 useRef 就可以绕过 Capture Value 的特性。可以认为 ref 在所有 Render 过程中保持着唯一引用,因此所有对 ref 的赋值或取值,拿到的都只有一个最终状态,而不会在每个 Render 间存在隔离。

function App(){
  const [count , setCount] = React.useState(0);
  const lastCount = React.useRef(count);
  const show = ()=>{
    setTimeout(()=>{
      console.log(`1秒前 count = 0,现在 count = ${lastCount.current}`);
    },1000)
  }
  return (
    <div onClick={()=>{
      show();
      lastCount.current = 5;
      setCount(5);
    }}>
      点击函数组件(useRef)
    </div>
  );
}

关于 Capture Value 的结论就是:一切均可 Capture,除了 Ref

class 组件与 function 组件的区别

  • function component 它是普通函数,不能使用setState,因此也称之为无状态组件
  • function component 没有生命周期的概念
  • function componentCapture Value 特性

React Hook

前面讲解函数组件时其实已经大量运用了Hook技术,但是还是需要单独来讲,主要是作为新特性,它实在是太重要了,几乎聊起React技术都会提及它,因此我们还是有必要学习它,并且知道它的实现原理。

useState 使用

它使得函数组件拥有了状态,而且使用也是非常简单:

import React, { useState } from 'react';
function Example() {
const [count, setCount] = useState(0);
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
      Click me
      </button>
    </div>
  );
}
  • 第一行: 引入 React 中的 useState Hook。它让我们在函数组件中存储内部 state 。
  • 第三行:Example 组件内部,我们通过调用 useState Hook 声明了一个新的 state  变量。它返回一对值给到我们命名的变量上。我们把变量命名为 count,因为它存储的是点击次数。我们通过传 0 作为 useState 唯一的参数来将其初始化为 0。第二个返回的值本身就是一个函数。它让我们可以更新 count 的值,所以我们叫它 setCount
  • 第七行: 当用户点击按钮后,我们传递一个新的值给 setCount。React 会重新渲染 Example 组件,并把最新的 count 传给它。

useState 原理

基于上面这个最简单的使用,我们来一个最简单的实现,揭开 useState 神秘的面纱。

import React from "react";
import { render } from "../../index";

let _state; // 把 state 存储在外面

function useState(initialValue) {
  // 如果没有 _state,说明是第一次执行,把 initialValue 复制给它
  _state = _state || initialValue;
  function setState(newState) {
    _state = newState;
    render();
  }
  return [_state, setState];
}

function Counter() {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <div>{count}</div>
      <button onClick={() => { setCount(count + 1); }}>
        点击 UseState
      </button>
    </div>
  );
}

export default Counter;

render 函数:

export const render = ()=>{
  ReactDOM.render(
    <App />,
    document.getElementById('root')
  );
}

render();

useState 函数使用闭包原理把值存储起来了,setState 则是每次调用render方法重新渲染组件。

useEffect 使用

它使得函数组件拥有了生命周期,等同于以下三个生命周期:

  • componentDidMount 组件已经挂载成 DOM
  • componentDidUpdate 组件已经更新
  • componentWillUnmount 组件卸载
import React, { useState, useEffect } from 'react';

function Example() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  },[count]);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

代码分析:

  • 当组件挂载成DOM结构后,会调用effect,会去改变页面的标题
  • 当发生点击事件后,组件执行更新后,又会去调用effect,从而更新了页面的标题
  • useEffect 第二个参数是一个数组,里面写count,意思是count值变化了才会去执行effect

React 保证了每次运行 effect 的同时,DOM 都已经更新完毕。

useEffect 原理

上面介绍了简单的使用,那么我们来总结下它的特性:

  1. 有两个参数 callbackdependencies 数组
  2. 如果 dependencies 不存在,那么 callback 每次都会执行
  3. 如果 dependencies 存在,只有当它发生了变化,callback才会执行
import React,{useState} from "react";

let _deps; // _deps 记录 useEffect 上一次的 依赖

function useEffect(callback, depArray) {
  const hasNoDeps = !depArray; // 如果 dependencies 不存在
  const hasChangedDeps = _deps
    ? !depArray.every((el, i) => el === _deps[i]) // 两次的 dependencies 是否完全相等
    : true;
  /* 如果 dependencies 不存在,或者 dependencies 有变化*/
  if (hasNoDeps || hasChangedDeps) {
    callback();
    _deps = depArray;
  }
}

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(()=>{
    document.title = `You clicked ${count} times`;
  });

  return (
    <div>
      <div>{count}</div>
      <button onClick={() => { setCount(count + 1); }}>
        点击 UseState
      </button>
    </div>
  );
}

export default Counter;

到这里,我们又实现了一个可以工作的 useEffect,似乎没有那么难。

到现在为止,我们已经实现了可以工作的 useStateuseEffect。但是有一个很大的问题:它俩都只能使用一次,因为只有一个 _state 和 一个 _deps。比如

const [count, setCount] = useState(0);
const [username, setUsername] = useState('fan');

countusername 永远是相等的,因为他们共用了一个 _state,并没有地方能分别存储两个值。我们需要可以存储多个 _state_deps

此时我们肯定会想到例如对象,数组,链表等数据结构都可以处理这种情况。

关键在于:

  1. 初次渲染的时候,按照 useStateuseEffect 的顺序,把 statedeps 等按顺序塞到 memoizedState 数组中。
  2. 更新的时候,按照顺序,从 memoizedState 中把上次记录的值拿出来。
let memoizedState = []; // hooks 存放在这个数组
let cursor = 0; // 当前 memoizedState 下标

function useState(initialValue) {
  memoizedState[cursor] = memoizedState[cursor] || initialValue;
  const currentCursor = cursor;
  function setState(newState) {
    memoizedState[currentCursor] = newState;
    render();
  }
  return [memoizedState[cursor++], setState]; // 返回当前 state,并把 cursor 加 1
}

function useEffect(callback, depArray) {
  const hasNoDeps = !depArray;
  const deps = memoizedState[cursor];
  const hasChangedDeps = deps
    ? !depArray.every((el, i) => el === deps[i])
    : true;
  if (hasNoDeps || hasChangedDeps) {
    callback();
    memoizedState[cursor] = depArray;
  }
  cursor++;
}

代码解释:

  • 初始化的时候,按照顺序依次把所有的state放进数组中。
  • 当执行set操作时,利用了闭包原理,每个state相对应的currentCursor都在内存中已经有一份存储,而且互相不会污染。这样就可以获取到相应的下标去更新数组中的值,然后执行render操作更新界面。

React中,每个组件存储自己的Hook信息,并且不是使用数组实现而是使用链表实现。

像一些高阶的面试题:

  • 函数组件是如何从无状态组件变为有状态的?
  • useStateuseEffect 的工作原理 ?

这些题目是不是心里已经有答案了。