重学React(二):函数组件和类组件

303 阅读12分钟

组件化开发的优势

  • 利于团队协作开发
  • 利于组件复用
  • 利于SPA单页面应用开发
  • ……

Vue中的组件化开发

详情参考:fivedodo.com/upload/html…

  1. 全局组件和局部组件
  2. 函数组件(functional)和类组件(Vue3不具备functional函数组件)

React中的组件化开发

React没有明确全局和局部的概念(可以理解为都是局部组件,不过可以把组件注册到React上,这样每个组件中只要导入React即可使用)

  1. 函数组件
  2. 类组件
  3. Hooks组件:在函数组件中使用React Hooks函数

1. 函数组件

创建一个函数返回jsx元素

1.1 属性和插槽

新建文件FunctionComponent.jsx

import React from "react";

const FunctionComponent = function FunctionComponent(props) {
    console.log(props);
    return <div className="box">
        函数组件
    </div>;
};

export default FunctionComponent;

在index.jsx中:

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <>
    <FunctionComponent x={20} y='30' arr={[10, 20, 30]}>
      <div className="slot-box">
        额外信息
      </div>
    </FunctionComponent>
  </>
);

render渲染的时候,如果发现type值是一个函数,会将这个函数执行,并且将解析出来的props当作实参传递给函数。

函数组件单闭合调用,不能传递子节点信息(没有children);双闭合调用,可以有children,实现出类似于Vue中插槽的概念(有助于组件的复用性更强)

打印FunctionComponent组件接收的参数props,如图所示:

image.png

模拟插槽

React.Children中提供的方法:

image.png

通过React.Children.toArray()保证children一定是个数组,这样就可以通过数组的索引去取值。

可以将每一个子节点添加slot属性,这样在函数组件中拿到的变量children内部会含有slot属性,属性值是header的放在头部,是footer的放到尾部,这样就不用通过数组的索引来取值。

index.jsx

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <>
    <FunctionComponent x={20} y='30' arr={[10, 20, 30]}>
      <div className="slot-box" slot="header">
        头部信息
      </div>
      <div className="slot-box" slot="footer">
        尾部信息
      </div>
    </FunctionComponent>
  </>
);

FunctionComponent.jsx

const FunctionComponent = function FunctionComponent(props) {
    let children = React.Children.toArray(props.children),
        headerSlots = children.filter(item => item.props.slot === 'header'),
        footerSlots = children.filter(item => item.props.slot === 'footer');
    return <div className="box">
        { headerSlots }
        函数组件
        { footerSlots }
    </div>;
};

这样就实现了Vue中具名插槽的概念。

我们通过 Object.isFrozen() 来检测当前对象是否被冻结。将props传进去结果为true,即props是被冻结的,这就意味着不能新增、删除、修改、劫持props中的属性/值。

如果直接操作props对象,通过成员访问的形式来修改props某个属性的值,如:props.x = 2000,则会报错:

image.png

这些属性都是只读的!

要想实现对传递进来的props的属性值进行修改,可以对props进行解构,解构出 x 相当于定义了一个 x 的变量,将props的x的属性值赋给它,然后再对其更改值。

const FunctionComponent = function FunctionComponent(props) {
    let children = React.Children.toArray(props.children),
        headerSlots = children.filter(item => item.props.slot === 'header'),
        footerSlots = children.filter(item => item.props.slot === 'footer');
        
    let { x } = props;
    x = 2000;
        
    return <div className="box">
        { headerSlots }
        函数组件 { x }
        { footerSlots }
    </div>;
};

渲染后的结果:

image.png

给函数组件传进来的属性设置默认值: 因为函数也是一个对象,它里面也有自己的键值对,即静态的私有属性和方法,那我们给这个函数设置静态的属性就可以实现了

// 给props属性设置默认值
FunctionComponent.defaultProps = {
  num: 100,
};

这时候,打印props会发现,尽管没有传num,但是结果里还是包含了:

image.png

除了设置默认值以外,还想给某些属性设置一些规则校验,这时候需要React官方提供的一个插件,叫作 prop-types。同样,也是给当前函数设置静态私有属性。

详见:github.com/facebook/pr…

// 给属性设置规则校验
FunctionComponent.propTypes = {
    x: PropTypes.number.isRequired,  // 必须是number类型,必传
    y: PropTypes.string,
    arr: PropTypes.array
};

如果这时候我们在index.jsx中不给函数组件传递 x 属性,这时候触发了验证器的校验,那么就会报错:

image.png

属性和插槽都可以让组件具备更强的复用性

1.2 函数组件的特点

做一个小需求:现有计数器,值为0,每点击一次按钮值就加1

index.jsx,

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <>
    <TextOne />
  </>
);

新建文件 TestOne.jsx

import React from "react";

const TestOne = function TestOne() {
  let num = 0;
  return (
    <div className="box">
      {num}
      <br />
      <button
        onClick={() => {
          num++;
          console.log(num);
        }}
      >
        新增
      </button>
    </div>
  );
};

export default TestOne;

这个事件是一个合成事件,关于合成事件的原理和知识后面再叙。

在连续对按钮点击了7次后,控制台正确显示了结果

image.png

然后视图中的num还是0,没有更新:

image.png

原因:第一次调用这个组件,会产生私有上下文,里面有个初始值为0的私有变量num。开始渲染,把返回的JSX元素交给render方法变为真实DOM。当点击按钮的时候,num变量加1,这时候只是把私有变量num进行了修改,但是不会触发视图更新。这时候只有让函数重新执行才能更改视图。

函数组件的视图更新,就是让函数重新执行

ps:如果父组件(调用的组件)更新了,给函数组件(被调用组件)传递了最新的值,也会重新渲染。

总结:函数组件是静态组件,具有以下特点:

  • 不具备状态、生命周期函数、ref等内容
  • 第一次渲染完毕,除非父组件控制其重新渲染,否则内容不会再更新
  • 优势:渲染速度快
  • 弊端:静态组件,无法实现组件动态更新

要想在第一次渲染完成后,通过内部某些操作让其更新视图这个目前是没有办法实现的。后面会叙述的Hooks函数可以让函数组件动态化。

函数组件的使用场景:

一个当面当中包含了很多板块,其中有几个板块我只需要第一次渲染成什么样后面就一直什么样,不会再更新,这种情况下用函数组件会更好一点。真实项目中用函数组件来做的需求还是比较多的。

1.3 Vue中的函数组件

在 template 加上关键字 functional

<template functional>
  <div class="box">
    {{ num }}
    <br />
    <button @click="change">新增</button>
  </div>
</template>

<script>
export default {
  name: "Demo",
  data() {
    return {
      num: 0,
    };
  },
  methods: {
    change() {
      this.num++:
    }
  }
};
</script>

在Vue2的函数组件中,第一次渲染后视图也没有发生更新。在Vue3中,函数式组件2.x的性能提升可以忽略不计,官方建议只使用有状态的组件。

2. 类组件

创建一个,并继承React.Component/PureComponent,基于render返回JSX视图。

2.1 类组件特点

新建文件ClassDemo.jsx:

import React from "react";

class ClassDemo extends React.Component {
    render() {
        return <div className="boc">
            类组件
        </div>
    }
}

export default ClassDemo;

render渲染的时候,如果发现type值是一个类,会将这个类基于new执行,创建其一个实例。

按照面向对象的思想绘画ClassDemo的原型链:

image.png

同样的,在index.jsx中调用ClassDemo的时候,传入两个属性

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <>
    <ClassDemo x={10} y={20} />
  </>
);

在ClassDemo的构造函数中接收传进来的参数props,new ClassDemo的时候会将constructor执行,

通过babel将类组件转换成 React.creatElement 格式

image.png

执行 React.createElement() 方法,将返回结果用一个变量接收并打印后得到:

image.png

import React from "react";

class ClassDemo extends React.Component {
    constructor(props) {
        super(); // 一旦使用了ES6继承,编写constructor函数,则进来需先执行super
    }

    render() {
        return <div className="boc">
            类组件
        </div>
    }
}

export default ClassDemo;

通过 Object.isFrozen 可以知道类组件也被冻结了,那就不能直接对props进行增删改和劫持。同样给类组件做一些属性规则校验和默认值,也要引入PropTypes插件,添加静态私有属性和方法

static defaultProps = {
    x: 0,
    y: 0,
  };
  
static propTypes = {
    x: PropTypes.number,
    y: PropTypes.number,
};

类组件还有自己完善的状态管理,只要修改状态,就能控制当前组件重新渲染(类不用重新new,只要render方法重新执行就行了)

// 设置初始状态
state = {
    num: 10
}

或者写在 constructor 里

constructor(props) {
  super();
  this.state = {
      num: 10
  };
}

如果要将props中的某些值作为初始状态值,这时候将状态写在constructor里更方便点;如果初使状态值是固定的,就直接在外面写即可。

在render方法中找到状态里的num并进行修改,并更新视图。在Vue当中,是给指定的状态做数据劫持,所以通过 this.state.num 的形式就可以,但这在React中没有做任何劫持,只会修改值,视图不会更新。要想视图也会更新,则需要调用该类继承的 React.Component 原型对象上的方法 forceUpdate,控制视图强制更新。

render() {
  let { num } = this.state;
  return <div className="boc">
      { num }
      <br />
      <button onClick={() => {
        this.state.num = 200;
        this.forceUpdate();
      }}>按钮</button>
  </div>;
}

或者使用 React.Component 原型对象上的方法 setState,它不仅能修改状态值,还能让视图更新。

this.setState({
   num: 200
});

总结:

类组件是动态组件

  • 具备属性及规则校验

  • 具备状态,修改状态可以控制视图更新

    • setState
    • forceUpdate
  • 具备ref可以获取DOM元素或者组件实例

    • ref='xxx'
    • ref=(x=>this.xxx=x)
    • React.createRef
  • 具备周期函数

    • 严格模式下,一些不安全的周期函数是禁止使用的

2.2 类组件流程

(1)整个类组件从new开始到最后视图呈现出来,所经历的步骤:

  1. getDefaultProps && 属性规则校验
  2. 初始化
    • constructor执行,将处理好的props传递给constructor
      • super()等价于 React.Component.call(this),也就是将父类当作普通方法执行,并将this指向子类实例。会往this上挂载props/refs/context/updater这些属性
      • super(props) 这是直接将传递进来的props挂载到实例上 this.props={...}
    • 初始化状态 this.state={...}
  3. 初始化结束后,会把props/context这些信息全部挂载到实例上
  4. 触发一个周期函数 componentWillMount:第一次渲染之前
    • 不安全的周期函数(未来要移除掉)
    • 用 UNSAFE_componentWillMount 取代 componentWillMount,但不要在严格模式下使用
    • 建议放弃对该函数的使用
  5. 触发 render 周期函数
    • 把render执行返回的JSX元素对象(虚拟DOM对象)进行渲染解析
    • render必须要有,必须返回JSX
  6. 触发 componentDidMount 周期函数:第一次渲染完
    • 获取真实的DOM元素
    • 从服务器获取数据
    • 设置定时器or监听器
    • ...

image.png

查看源码,在 node_modules --> react --> cjs --> react.development.js 中:

image.png

由此可知,在super执行后,实例上会挂载四个私有属性

在执行super后,打印this:

image.png

因为浏览器控制台展开后看到的永远是堆内存最新的值,所以这时候的this里的props其实是undefined

super执行的时候,将props传进去即可,这时候挂载的this.props就不是undefined了

constructor(props) {
    super(props)
}

使用不安全的生命周期函数 componentWillMount,控制台会抛出警告:

componentWillMount() {
   console.log('第一次渲染之前');
}

image.png

在React18中,开始使用 UNSAFE_componentWillMount 来取代 componentWillMount。但是在 React.StrictMode 严格模式下使用会报错

index.jsx

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <React.StrictMode>
    <ClassDemo x={10} y={20} />
  </React.StrictMode>
);

ClassDemo.jsx

UNSAFE_componentWillMount() {
   console.log("第一次渲染之前");
}

image.png

(2)基于setState修改状态,通知视图更新,所经历的步骤:

  1. 触发 shouldComponentUpdate 周期函数:是否允许更新
    • 返回true:允许更新,继续执行后续步骤
    • 返回false:停止更新,状态/属性值也不会修改、视图也不会更新
    • 可以基于这个周期函数做项目性能优化
    • 进行到这一步,状态和属性还没有改为最新的值
  2. 触发 componentWillUpdate 周期函数:不安全的周期函数
    • 进行到这一步,状态和属性还没有改为最新的值
  3. 修改状态/属性为最新的值,基于this.props/state访问,获取的也是最新的值
  4. 触发 render:让视图按照最新的值进行渲染更新
  5. 触发 componentDidUpdate 周期函数:视图更新完毕
  6. 如果 setState 设置了回调函数,则把回调函数触发执行(类似于Vue中的$nextTick)
shouldComponentUpdate(nextProps, nextState) {
    console.log(this.props, this.state); // 原有的属性和状态
    console.log(nextProps, nextState); // 即将要修改的属性和状态
    return true;
}
componentDidUpdate() {
    console.log('视图更新完毕');
  }

  render() {
    let { num } = this.state;
    return (
      <div className="boc">
        {num}
        <br />
        <button
          onClick={() => {
            this.setState({
              num: 200,
            }, () => {
                console.log('setState回调函数');
            });
          }}
        >
          按钮
        </button>
      </div>
    );
  }

点击按钮,查看打印结果:

image.png

如果 shouldComponentUpdate 中返回的是false,那么setState中的回调函数也会被触发执行。

(3)基于forceUpdate强制让视图更新:

直接跳过shouldComponentUpdate,继续下一步操作(后续操作和setState一致)

(4)父组件重新调用该组件(可能传递新的值进来),组件也需要更新

  1. 触发 componentWillReceiveProps 周期函数:不安全
  2. 触发 shouldComponentUpdate 周期函数
  3. ...

(5)组件销毁:

  1. 触发 componentWillUnmount 周期函数:销毁之前
    • 把自己手动设置的事件、定时器、监听器...手动释放掉,来优化性能
    • 对目前组件中的一些信息,做缓存
    • ...
  2. 销毁

2.3 PureComponent的处理机制

创建一个类组件的时候,除了让其继承React.Component,还可以继承React.PureComponent

当点击按钮时,向数组中push数据

export default class PureClassDemo extends React.Component {
  state = {
      arr: [10, 20]
  };

  handle = () => {
    let { arr } = this.state;
    arr.push(30);
    this.setState({
        arr
    });
  };

  render() {
    // console.log('render');
    let { arr } = this.state;
    // console.log(arr);
    return <div>
        { arr }
        <br />
        <button onClick={ this.handle }>处理</button>
    </div>;
  }
};

ps:修改状态后的值和之前状态的值的堆内存地址是相同的

这时候点击页面按钮,发现30并没有渲染在页面上,但是控制台发现有打印出 'render',这说明 setState 这个操作确实通知了视图要重新更新,打印arr发现有新增的30,说明在render的时候已经拿到了最新的值了,但是视图没有更新。这其实是 dom diff 的问题,后续文章会提到关于React中dom diff的处理。

那我想在视图中看到渲染最新结果的效果怎么办?将arr变成字符串就可以了

render() {
    let { arr } = this.state;
    return <div>
        { arr.join('+') }
        <br />
        <button onClick={ this.handle }>处理</button>
    </div>;
}

这时候,我用 React.PureComponent 替换 React.Component,代码其他地方不变。结果发现,在点击按钮后,视图并没有更新,且连render方法都没有触发。

通过React生命周期可以知道当状态改变时,会触发一个钩子 shouldComponentUpdate,它的返回值决定了是否更新。

继承 React.PureComponent,会默认创建 shouldComponentUpdate 周期函数。它默认在这个周期函数中,做了一个“浅比较”:拿最新要修改的属性/状态和原始属性/状态去比较,如果一样,则不用更新。

模拟React.PureComponent的特点:

继承 React.Component,手写一个对象浅比较的方法:

因为 typeof null 的结果也是 object,所以再加一个粗略判断是不是object的方法

const isObject = function isObject(obj) {
  return obj !== null && typeof obj === 'object';
}

因为是浅比较,所以只对对象里的第一级做了比较

const shallowEqual = function shallowEqual(obj1, obj2) {
  // 1.比较是不是同一个堆内存地址
  if (obj1 === obj2) return true;
  // 2.保证两个都是对象
  if (!isObject(obj1) || !isObject(obj2)) return false;
  // 3.比较键值对数量
  let key1 = Reflect.ownKeys(obj1),
      key2 = Reflect.ownKeys(obj2);
  if (key1.length !== key2.length) return false;
  // 4.每个属性值是否相同
  for (let i = 0; i < key1.length; i++) {
    let key = key1[i];
    if (obj1[key] !== obj2[key]) return false;
  }
  return true;
};

因为是React.Component,可以写shouldComponentUpdate生命周期函数内的逻辑

shouldComponentUpdate(nextProps, nextState) {
    return !shallowEqual(this.props, nextProps) ||
      !shallowEqual(this.state, nextState);
};

这样就达到了模拟React.PureComponent不能render的效果

那么怎么做到继承了 React.PureComponent,修改前后对象的堆内存不变但页面又能重新渲染呢?

export default class PureClassDemo extends React.PureComponent {
  state = {
      arr: [10, 20]
  };

  handle = () => {
    let { arr } = this.state;
    arr.push(30);
    this.setState({
        arr: [...arr]; // *
    });
  };

  render() {
    let { arr } = this.state;
    return <div>
        { arr.join('+') }
        <br />
        <button onClick={ this.handle }>处理</button>
    </div>;
  }
};

通过 * 处操作让arr堆内存地址发生改变,这样就可以实现视图更新了。

或者使用 forceUpdate 进行强制更新。它会跳过 shouldComponentUpdate 钩子函数校验

handle = () => {
    let { arr } = this.state;
    arr.push(30);
    this.forceUpdate();
};

但是我们用 forceUpdate 强制更新就没有意义了,因为既然继承了React.PureComponent就是想用shouldComponentUpdate的浅比较规则来进行优化,却还是用 forceUpdate 跳过优化那就毫无必要了。因此这种做法并不推荐。