关于setState

  1. **setState是异步的吗**?
  2. 出于性能考虑,ReactsetState进行异步处理,即调用setState时候不会马上更新,而是将setState的值放入一个队列,然后延时批量处理队列中的state。**在生命周期钩子和React合成事件中中调用setState**,会将数据添加到一个队列中,合并处理,**这时候setState是异步的**。**而在其他的React控制之外的地方调用(比如原生dom事件)则不是异步的。**
  3. setState异步特性,导致需要注意两个问题
  1. 设置完state,获取数据的时机

由于异步特性,setState改变state数据后,无法马上获取到state的最新值。
由于setState是异步的,如果想获取改变后的值,应该在setState的第二个参数回调函数中访问state的最新值。

  1. 修改数据依赖现有的数据
    由于setState会异步合并处理,因此如果设置的state值依赖于之前的state值,需要给setState传一个函数,

函数参数是之前的state,返回的值是新的改变的state属性。

  1. import React from 'react';
  2. class App extends React.Component {
  3. state = {
  4. digit: 1,
  5. text: 'a'
  6. };
  7. componentDidMount() {
  8. document.getElementById('btn')
  9. .addEventListener('click', this.onAddButtonClick);
  10. }
  11. onAddButtonClick = () => {
  12. // bad
  13. // const add1 = () => {
  14. // this.setState({digit: this.state.digit + 1});
  15. // };
  16. // add1();
  17. // add1();
  18. // add1();
  19. // good
  20. const add1 = () => {
  21. this.setState((state) => {
  22. return {digit: state.digit + 1};
  23. });
  24. };
  25. add1();
  26. add1();
  27. add1();
  28. };
  29. onAddButtonClick2 = () => {
  30. // work
  31. const add1 = () => {
  32. this.setState({digit: this.state.digit + 1});
  33. };
  34. add1();
  35. add1();
  36. add1();
  37. };
  38. onChangeButtonClick = () => {
  39. // bad
  40. // this.setState({text: 'b'});
  41. // console.log(this.state.text);
  42. // good
  43. this.setState({text: 'b'}, () => {
  44. console.log(this.state.text);
  45. });
  46. };
  47. render() {
  48. return (
  49. <div>
  50. <div>{this.state.digit}</div>
  51. <button onClick={this.onAddButtonClick}>+3</button>
  52. <button id="btn">+3(原生事件)</button>
  53. <hr />
  54. <div>{this.state.text}</div>
  55. <button onClick={this.onChangeButtonClick}>修改文本</button>
  56. </div>
  57. );
  58. }
  59. }
  60. export default App;

React更新界面的主要过程可以简单描述为,调用setState之后,React会更新state,然后调用组件的render得到新的state对应的虚拟dom,然后对比当前的和更新后端虚拟dom,判断是否需要更新dom,以及如何更新dom。

  1. import React from 'react';
  2. import './App.css';
  3. class App extends React.Component {
  4. state = {
  5. digit: 1
  6. };
  7. componentDidMount() {
  8. // 即使digit没有变,也还是会触发对比,调用render方法
  9. this.setState({digit: 1});
  10. }
  11. render() {
  12. console.log('render');
  13. return (
  14. <div>
  15. {this.state.digit}
  16. </div>
  17. );
  18. }
  19. }
  20. export default App;
  1. 由于diff工作量比较大,如果setState的值没有改变,其实是不需要进行diff的。如何让state没有改变的时候,不进行diff呢?可以使用shouldComponentUpdate这个生命周期钩子。

在调用setState更新数据后,React会判断是否需要进行更新操作,如果没有shouldComponentUpdate这个生命周期钩子,则默认进行对比和更新工作,如果有shouldComponentUpdate,则调用之,如果返回true则更新,返回false则不更新。利用这个钩子,我们可以对新的state和当前的state进行对比,如果有变化,返回true,如果没有变化,返回false。
React.PureComponent实现了浅比较的shouldComponentUpdate,因此我们的组件如果继承了 React.PureComponent就会有了对比state决定是否更新的特性。

  1. import React from 'react';
  2. import './App.css';
  3. class App extends React.PureComponent {
  4. state = {
  5. digit: 1
  6. };
  7. componentDidMount() {
  8. // 继承PureComponent,当数据未改变时候,不会触发对比更新
  9. this.setState({digit: 1});
  10. }
  11. render() {
  12. console.log('render');
  13. return (
  14. <div>
  15. {this.state.digit}
  16. </div>
  17. );
  18. }
  19. }
  20. export default App;
  1. 但是使用React.PureComponent时候需要注意一个问题,浅比较是比较值是否相同,因此当state中的数据是一个对象,其中属性变化但引用不变、或者state中的数据是一个数组,数组中的元素变化但数组引用不变,这时候比较结果是两者相同,因此不会触发更新。如果希望在对象属性变化、数组元素变化时候触发更新,应该setState时候传入新的对象或数组。
  1. import React from 'react';
  2. import './App.css';
  3. class App extends React.PureComponent {
  4. state = {
  5. obj: {
  6. text: 'obj'
  7. },
  8. arr: [1, 2]
  9. };
  10. componentDidMount() {
  11. this.state.obj.text = 'obj1';
  12. this.state.arr.push(3);
  13. // obj和arr引用未变,因此不会触发更新
  14. // this.setState({
  15. // obj: this.state.obj,
  16. // arr: this.state.arr
  17. // });
  18. // obj和arr复制为新的对象/数组,因此会触发更新
  19. this.setState({
  20. obj: {...this.state.obj},
  21. arr: [...this.state.arr]
  22. });
  23. }
  24. render() {
  25. console.log('render');
  26. return (
  27. <div>
  28. {this.state.obj.text}
  29. <br />
  30. {this.state.arr.join(',')}
  31. </div>
  32. );
  33. }
  34. }
  35. export default App;
  1. 通常情况下,我们应该让组件继承React.PureComponent,并且对象和数组变化时候要传入新的引用。这样既可以利用shouldComponentUpdate的浅比较避免不必要的对比更新操作,也能保证在需要的时候更新视图。

生命周期钩子

关于生命周期钩子,官方文档给出了详细的说明

React生命周期 - 官方文档

React进阶 - 图1

不同阶段调用的生命周期钩子

挂载阶段
当组件实例被创建并插入 DOM 中时,其生命周期调用顺序如下:

constructor()
static getDerivedStateFromProps()
render()
componentDidMount()

更新阶段
当组件的 props 或 state 发生变化时会触发更新。组件更新的生命周期调用顺序如下:

static getDerivedStateFromProps()
shouldComponentUpdate()
render()
getSnapshotBeforeUpdate()
componentDidUpdate()

卸载阶段
当组件从 DOM 中移除时会调用如下方法:

componentWillUnmount()

生命周期钩子函数介绍

constructor(props)

构造函数,创建实例时候会调用,通常用于绑定函数,初始化state。

static getDerivedStateFromProps(props, state)

静态方法,用于state的某些值只依赖于当前的props的场景,并且在初始挂载及后续更新时都会被调用

render()

渲染函数,返回JSX或者React.element。

componentDidMount()

挂载完成,通常在这个钩子中做初始化操作,请求接口、绑定事件、启动定时器等。

shouldComponentUpdate(nextProps, nextState)

用于优化性能,判断是否需要比较更新。

getSnapshotBeforeUpdate(prevProps, prevState)

它使得组件能在发生更改之前从 DOM 中捕获一些信息(例如,滚动位置)。此生命周期的任何返回值将作为参数传递给 componentDidUpdate()。

componentDidUpdate(prevProps, prevState, snapshot)

更新完成,做一些有副作用的操作,例如请求接口,弹出提示等等。

prevProps和prevState是更新前的props和state,用于逻辑判断中需要对比更新的改动。

componentWillUnmount()

会在组件卸载及销毁之前直接调用。在此方法中执行必要的清理操作,如解除订阅、清除定时器。

getDerivedStateFromProps和getSnapshotBeforeUpdate示例

对于getDerivedStateFromProps和getSnapshotBeforeUpdate不是很容易理解,给出示例代码:

getDerivedStateFromProps对props传入的digit和Child的state的sum计算求和。

注意getDerivedStateFromProps是静态方法,因此没有this。getDerivedStateFromProps的作用就是根据props和state计算新的state,不需要this。

  1. import React from 'react';
  2. class Child extends React.PureComponent {
  3. // state完全依赖于props.digit,因此可以使用getDerivedStateFromProps来进行处理
  4. static getDerivedStateFromProps(props, state) {
  5. return {
  6. sum: state.sum + props.digit
  7. };
  8. }
  9. state = {
  10. sum: 0
  11. };
  12. render() {
  13. return (
  14. <div>累计:{this.state.sum}</div>
  15. );
  16. }
  17. }
  18. class App extends React.PureComponent {
  19. state = {
  20. digit: 0
  21. };
  22. onButtonClick = () => {
  23. this.setState(preState => ({
  24. digit: preState.digit + 1
  25. }));
  26. };
  27. render() {
  28. return (
  29. <div>
  30. 数字:{this.state.digit}
  31. <button onClick={this.onButtonClick}>+1</button>
  32. <Child digit={this.state.digit} />
  33. </div>
  34. );
  35. }
  36. }
  37. export default App;

对getSnapshotBeforeUpdate的官方文档的示例代码稍加改造。

App有个按钮元素,点击则给list增加元素,ScrollingList组件渲染list,并且如果列表处于底部时候,增加新元素后会自动滚动到底部。

  1. import React from 'react';
  2. import './App.css';
  3. class ScrollingList extends React.Component {
  4. constructor(props) {
  5. super(props);
  6. this.listRef = React.createRef();
  7. }
  8. getSnapshotBeforeUpdate(prevProps, prevState) {
  9. // 如果有增加的item,则可能需要滚动
  10. if (prevProps.list.length < this.props.list.length) {
  11. const list = this.listRef.current;
  12. // 如果当前没有在回看,需要滚动
  13. return list.scrollHeight - list.scrollTop - list.clientHeight === 0;
  14. }
  15. return false;
  16. }
  17. componentDidUpdate(prevProps, prevState, snapshot) {
  18. // 如果我们 snapshot 有值,说明我们刚刚添加了新的 items,
  19. // 并且没有在会看,说明我们需要滚动到底部
  20. //(这里的 snapshot 是 getSnapshotBeforeUpdate 的返回值)
  21. if (snapshot) {
  22. const list = this.listRef.current;
  23. list.scrollTop = list.scrollHeight;
  24. }
  25. }
  26. render() {
  27. return (
  28. <div ref={this.listRef} style={{border: '1px solid', height: 100, overflow: 'auto', marginTop: 50}}>
  29. {
  30. this.props.list.map(text => (
  31. <li key={text} style={{fontSize: 20}}>{text}</li>
  32. ))
  33. }
  34. </div>
  35. );
  36. }
  37. }
  38. class App extends React.PureComponent {
  39. state = {
  40. list: []
  41. };
  42. onButtonClick = () => {
  43. this.setState(preState => ({
  44. list: [...preState.list, preState.list.length]
  45. }));
  46. };
  47. render() {
  48. return (
  49. <div>
  50. <button onClick={this.onButtonClick}>添加</button>
  51. <ScrollingList list={this.state.list} />
  52. </div>
  53. );
  54. }
  55. }
  56. export default App;

React16更新的生命周期钩子

移除的

UNSAFE_componentWillMount() UNSAFE_componentWillReceiveProps(nextProps)

UNSAFE_componentWillUpdate(nextProps, nextState)

新增的

static getDerivedStateFromProps(props, state)
getSnapshotBeforeUpdate(prevProps, prevState)

虚拟dom

了解虚拟dom有助于理解React执行原理

我们已经知道,React框架会帮助我们进行dom操作,我们只需要实现组件,当组件的状态发生变化时候,框架需要更新视图,最简单的方法是重新构建整个视图,但是这样做会产生昂贵的性能损耗。实际上,每次状态变化时候,React会根据render结果更新虚拟dom树,然后对比当前虚拟dom和更新后的虚拟dom,然后以最小的代价更新实际dom。

React的diff算法依据两个假设
① 两个不同类型的元素会产生出不同的树
② 开发者可以通过 key prop 来暗示哪些子元素在不同的渲染下能保持稳定

因此,在进行diff时候,如果两个节点是不同类型的dom(比如一个是div,一个是span)、或者是不同的组件(比如一个是Icon,一个是Modal)、或者两个节点的key值不同,则认为是不同类型节点,否则认为是相同的节点。

对比不同类型节点时候,直接卸载调之前的节点及其所有子节点,重新建立新的节点

对比相同类型节点时候,如果是原生的元素(如div、img),对比并更新有变化的属性;如果是组件,更新组件的props。

对比子节点时候,按顺序对比每个子节点,即当前的第一个子节点和新的第一个子节点对比、当前的第二个子节点和新的第二个子节点对比……,如果是同类型的节点,按照上述同类型的规则处理,如果是不同类型则按照上述不同类型节点的处理。

处理一个列表组件时候,可能存在性能问题,见官方示例

  1. <ul>
  2. <li>Duke</li>
  3. <li>Villanova</li>
  4. </ul>
  5. <ul>
  6. <li>Connecticut</li>
  7. <li>Duke</li>
  8. <li>Villanova</li>
  9. </ul>

在列表头部添加一个元素,由于React是按顺序对比,因此认为3个元素都有变化,这时候React会把三个元素都进行修改。可以通过添加key属性来让React更好地识别哪些元素改动。

  1. <ul>
  2. <li key="Duke">Duke</li>
  3. <li key="Villanova">Villanova</li>
  4. </ul>
  5. <ul>
  6. <li key="Connecticut">Connecticut</li>
  7. <li key="Duke">Duke</li>
  8. <li key="Villanova">Villanova</li>
  9. </ul>

添加key之后,React会知道新的列表只是在头部添加了一个元素,其他两个元素因为key能够对应,因此没有变化,所以React会只操作添加的新dom。

函数式组件

一个返回JSX的函数,就是一个函数式组件。

如果一个组件没有生命周期钩子和state,可以写成函数式组件的形式。

函数式组件更简洁,相比于class组件省去了创建实例的损耗,但是每次数据改变都需要进行对比工作,不能像class组件一样使用shouldComponentUpdate避免不必要的对比更新。

有了hook API之后,函数式组件也可以维护状态并在特定的声明周期做一些处理了。

  1. export default () => (
  2. <div>functional component</div>
  3. );

组件复用

在React中有很多组件复用的方式,包括HOC,render props、hook,它们在不同的场景有各自作用。

1. 高阶组件(HOC)

高阶组件(HOC)是 React 中用于复用组件逻辑的一种高级技巧。HOC 自身不是 React API 的一部分,它是一种基于 React 的组合特性而形成的设计模式。
具体而言,高阶组件是参数为组件,返回值为新组件的函数。
组件是将 props 转换为 UI,而高阶组件是将组件转换为另一个组件。

根据官网定义,高阶组件是一种复用组件的技巧。

关键点:高阶组件是一个函数,输入是一个组件(输入可能还会包含其他的参数,其他参数用来自定义组件的特性),输出是一个组件。

当我们拥有一个组件,而且需要在不同情况下对组件进行改造生成新的组件时候,高阶组件会有作用。

  1. import React from 'react';
  2. // withList是一个高阶组件,它包含了list的逻辑,给WrappedComponent组件传入list
  3. // length参数可以自定义list的长度
  4. function withList(WrappedComponent, length) {
  5. return class List extends React.PureComponent {
  6. state = {
  7. list: (new Array(length)).fill(123)
  8. };
  9. render() {
  10. return <WrappedComponent list={this.state.list} {...this.props} />
  11. }
  12. }
  13. }
  14. class App extends React.PureComponent {
  15. constructor(props) {
  16. super(props);
  17. this.state = {
  18. skin: 'red'
  19. };
  20. }
  21. onButtonClick = () => {
  22. this.setState(prevState => ({
  23. skin: prevState.skin === 'red' ? 'blue' : 'red'
  24. }));
  25. };
  26. render() {
  27. return (
  28. <div style={{backgroundColor: this.state.skin}}>
  29. <button onClick={this.onButtonClick}>换肤</button>
  30. <ul>
  31. {this.props.list.map((text, index) => (
  32. <li key={index}>{text}</li>
  33. ))}
  34. </ul>
  35. </div>
  36. );
  37. }
  38. }
  39. export default withList(App, 10);

2. render props

术语 “render prop” 是指一种在 React 组件之间使用一个值为函数的 prop 共享代码的简单技术
render props也不是React的API,而是一种复用组件的技术。

关键点:给组件传入一个方法props,组件在render中调用这个方法来渲染界面。

当我们拥有一个组件,它维护了可复用的数据和逻辑,我们需要使用组件的数据和逻辑,并且希望自定义不同的界面时候,会用到render props。

见下面示例

List组件维护list的逻辑,但它不负责界面,界面渲染交给通过props传入的render方法。

App组件使用了List,并传入Skin作为render,Skin负责界面渲染,而Skin渲染的数据list来自List组件。

这样如果还有其他的组件使用List渲染不同的界面,都可以复用List组件。

  1. import React from 'react';
  2. class List extends React.PureComponent {
  3. constructor(props) {
  4. super(props);
  5. this.state = {
  6. list: (new Array(props.length)).fill(123)
  7. };
  8. }
  9. render() {
  10. return (
  11. <div>
  12. {this.props.render(this.state.list)}
  13. </div>
  14. );
  15. }
  16. }
  17. class Skin extends React.PureComponent {
  18. constructor(props) {
  19. super(props);
  20. this.state = {
  21. skin: 'red'
  22. };
  23. }
  24. onButtonClick = () => {
  25. this.setState(prevState => ({
  26. skin: prevState.skin === 'red' ? 'blue' : 'red'
  27. }));
  28. };
  29. render() {
  30. return (
  31. <div style={{backgroundColor: this.state.skin}}>
  32. <button onClick={this.onButtonClick}>换肤</button>
  33. <ul>
  34. {this.props.list.map((text, index) => (
  35. <li key={index}>{text}</li>
  36. ))}
  37. </ul>
  38. </div>
  39. );
  40. }
  41. }
  42. class App extends React.PureComponent {
  43. render() {
  44. return <List length={10} render={list => <Skin list={list} />} />
  45. }
  46. }
  47. export default App;

3. hook

简介

什么是hook?

Hook 是一个特殊的函数,它可以让你“钩入” React 的特性

这里的React特性指的是生命周期、state状态等。

当我们希望使用函数式组件,并且希望能够让不同功能代码更聚合,更好维护时候,可以使用hook。

为什么需要hook?

为了让功能代码聚合在一起,增加了组件的可维护性。hook提供了功能逻辑代码复用的新的方式。React需要提供一些额外的API来实现,由于React更推崇函数式组件的简洁和声明式编程的理念,因此将这种API增加在了函数式组件中,而非class组件中。
hook给函数式组件增加状态和逻辑,并将功能代码聚合在一起。

hook的规则:

只能在函数最外层调用 Hook。不要在循环、条件判断或者子函数中调用。
只能在 React 的函数组件中调用 Hook。不要在其他 JavaScript 函数中调用。(还有一个地方可以调用 Hook —— 就是自定义的 Hook 中)

自定义hook

自定义hook是一个使用了React的原生hook和其他自定义hook的函数。自定义的hook就是一个功能复用的单元。

常用的hook

useState和useEffect

在函数式组件中添加状态需要使用useState这个hook;在函数式组件中添加一些有副作用的操作(如网络请求、操作DOM、绑定事件回调),需要用到useEffect这个hook。

useState

  • useState函数接收一个参数作为初始值
  • useState返回值为一个数组,数组第一个元素是状态变量,第二个元素是设置该状态的方法
  • 调用设置状态的方法,会同步触发diff和更新操作

注意:相比单个state,更推荐多个state,因为单个state无法达到聚合功能代码的目的,所以应该每个state属性都调用useState创建一个对应的状态。我应该使用单个还是多个 state 变量?

useEffect

  • useEffect接收第一个参数是一个回调callback,会在特定时机执行
  • 给useEffect传入的callback,可以返回一个方法clearEffect,React会在组件卸载时候调用clearEffect,清除副作用(比如停止定时器,解除事件绑定)
  • useEffect第二个参数是一个数组,数组中是依赖项,当依赖变化时候才会执行useEffect传入的方法
  • 如果是空数组,则只在组件初始挂载时候执行,类似componentDidMount
  • 如果第二个参数不传,则在组件更新时候执行,类似componentDidMount和componentDidUpdate

下面看一个简单的示例

useState返回的set方法,当数值相同时候不会触发子组件render,这个和class组件不同。另外每次set都会立即触发更新,而不是像this.setState一样,合并异步更新。

  1. import React, {useState, useEffect} from 'react';
  2. const ChildComponent = props => {
  3. let [digit, setDigit] = useState(0);
  4. useEffect(
  5. () => {
  6. const timer = setInterval(
  7. () => {
  8. // 立即触发更新,不会有延时
  9. setDigit(digit++);
  10. },
  11. 1000
  12. );
  13. return () => {
  14. clearInterval(timer);
  15. }
  16. },
  17. []
  18. );
  19. return <div>{digit}</div>;
  20. }
  21. class App extends React.PureComponent {
  22. state = {
  23. showChild: true
  24. };
  25. componentDidMount() {
  26. setTimeout(
  27. () => {
  28. this.setState({showChild: false});
  29. },
  30. 10 * 1000
  31. );
  32. }
  33. render() {
  34. return this.state.showChild && <ChildComponent />
  35. }
  36. };
  37. export default App;

useMemo和useCallback

useMemo的作用类似于Vue中的computed,会缓存计算结果,只有当依赖的数据改变时候,才会执行其中的计算方法。

useCallback一般用于生成事件处理函数,防止每次生成新的函数,导致不必要的子组件重新render。

场景是计算属性和注册回调。

useMemo

  • useMemo接收的第一个参数是一个函数,这个函数用来根据state计算新的值。
  • useMemo接收的第二个参数是一个依赖项数组。如果不传,则每次都会调用计算函数,没有任何优化效果;如果传空数组,则只在第一次调用计算函数,返回的结果永远不变;如果数组传入计算的依赖项,则依赖项变化时候调用计算函数,得到最新的结果,在依赖项不变时候,返回之前的结果,因此起到了“缓存计算结果”的效果。

useCallback

  • useCallback接收第一个参数callback。
  • useCallback接收第二个参数,是依赖项。
  • useCallback返回一个函数,如果依赖项不传,则每次生成一个新的函数;如果依赖项传空数组,则只在第一次生成一个函数,后面每次render都使用这个函数;如果传入依赖项,则当依赖项发生变化时候生成一个新的函数。
  • 注意,useCallback的回调中如果使用了某个state,就会形成一个闭包,如果给useCallback传入第二个参数是空数组,则生成的函数引用的永远是state初始化的值,因此如果回调中依赖了某个state,应该把依赖项加到第二个参数的数组中。(看下面注释加深理解)

<font style="color:rgb(26, 26, 26);">useCallback(fn, deps)</font> 相当于 <font style="color:rgb(26, 26, 26);">useMemo(() => fn, deps)</font>


看下面简单的示例

  1. import React, {useState, useMemo, useCallback} from 'react';
  2. class Button extends React.PureComponent {
  3. render() {
  4. console.log('render Button');
  5. return <button onClick={this.props.onButtonClick}>计算平方</button>
  6. }
  7. }
  8. const App = () => {
  9. console.log('render App');
  10. const [digit, setDigit] = useState(0);
  11. // 避免了Button组件每次点击计算按钮时候都会更新
  12. const onButtonClick = useCallback(() => {
  13. setDigit(document.getElementById('_input').value);
  14. // 这里访问的digit是在调用App方法时候,useState返回的
  15. // 每次调用App都会返回一个新的变量(但值可能不会变)
  16. // 如果第二个参数是"[]",则onButtonClick一直不会变
  17. // 初始的回调中访问的digit是0,因此后面即使digit改变,调用onButtonClick还是打印0
  18. // 除非在依赖项数组中加入digit,onButtonClick在digit改变时候重新创建新的函数,就可以打印最新的digit了
  19. console.log(digit);
  20. }, []);
  21. // 当digit不变时,square不会重新计算,而是返回之前的值
  22. const square = useMemo(() => {
  23. console.log('calculate square')
  24. return digit * digit;
  25. }, [digit]);
  26. return (
  27. <div>
  28. <input id="_input" type="number" placeholder="请输入整数" />
  29. <Button onButtonClick={onButtonClick}/>
  30. <div>平方:{square}</div>
  31. </div>
  32. );
  33. };
  34. export default App;

useRef

useRef可以用来实现获取一个原生DOM或者子组件实例的引用,本质上,它可以用来保存任何可变值。

  • useRef接收一个参数作为初始值。
  • useRef返回一个对象,这个对象包含一个current属性,这个current属性会被初始化为初始值,并且可以更改。
  • 每次调用渲染函数,都返回同一个对象。

看下面的示例

  1. // 获取input元素的引用
  2. function TextInputWithFocusButton() {
  3. const inputEl = useRef(null);
  4. const onButtonClick = () => {
  5. // `current` 指向已挂载到 DOM 上的文本输入元素
  6. inputEl.current.focus();
  7. };
  8. return (
  9. <>
  10. <input ref={inputEl} type="text" />
  11. <button onClick={onButtonClick}>Focus the input</button>
  12. </>
  13. );
  14. }
  1. // 用来保存timer的id
  2. function Timer() {
  3. const intervalRef = useRef();
  4. useEffect(() => {
  5. intervalRef.current = = setInterval(() => {
  6. // ...
  7. });
  8. }, []);
  9. const handleCancelClick = useCallback(() => {
  10. clearInterval(intervalRef.current);
  11. }, []);
  12. // ...
  13. }

useState原理

useState让函数式组件能够拥有自己的状态,如何让函数式组件拥有自己的状态呢?有两个关键:

  1. 状态存储在哪里?
  2. 状态以什么格式存储?

状态存储在哪里呢?对于class组件可以直接挂载实例上面,函数式组件又应该存储在哪里呢?答案是挂在虚拟DOM节点上,渲染过程中,调用函数组件时候React是知道在处理哪个节点的,因此当在函数式组件中调用useState方法,React就可以把创建的状态挂到相应的节点上面。在下一次调用函数组件时候,React发现已经有了这个状态,就把值返回。

状态如何存储?首先我们想到可以类比class组件,把整个state作为一个对象存储,但是上面提到,这样存储不能达到分离功能逻辑的目的;因此我们希望可以将状态分开,每个状态有自己对应的key,例如这样:

const [a, setA] = useState('a', '');

即用对象存储状态,useState方法传递一个key和一个初始值,React把这个key和对应value存储到对象上。但是这样会比较冗余。React实现方式是只需要传递初始值,不用传递key,React根据useState调用的顺序来识别是哪一个状态。那么函数式组件的状态就是一个数组格式,每个状态在函数式组件中调用useState声明的顺序,也是它们在状态数组中存储的顺序。

这也是useState必须在函数顶层调用的原因。

useState工作的过程如下:

  1. 首次调用函数组件,执行useState,React获取到当前正在遍历的节点,创建_state对象挂载到节点上,根据调用useState的顺序,在数组中加入状态,并根据传入的初始值初始化状态。
  2. 当调用状态的set方法时候,React修改_state上面的状态,并更新相应的组件。
  3. 后面再调用函数组件时候,useState找到对应的节点的_state,并根据useState的调用顺序,找到修改后的状态返回,这样组件就可以拿到正确的状态了。

context

1. 什么是context

context是React提供的多层组件传递数据的解决方案,它包括多个API。

  • React.createContext
  • context.Provider
  • context.Consumer
  • class.contextType

使用context,开发者需要关注几个点。

  • 祖先组件如何将数据传递下去
  • 子组件如何接收数据
    • class component如何接收数据
    • functional component如何接收数据
  • 如何动态修改数据
    • 祖先组件如何修改数据
    • 子组件如何修改数据
  • 如何接受多个context

2. 为什么需要context

为了避免多层组件传递数据使用prop,导致传递数据的组件和使用数据的组件中间的组件需要处理不必要的数据,增加代码复杂度,影响组件复用性,我们可以使用context来满足多层组件传递数据的需求。

3. 如何使用context

  • 父组件提供context
  1. // 默认值
  2. const ThemeContext = React.createContext('1');
  3. // 将theme数据传递下去
  4. const App = () => (
  5. <ThemeContext.Provider value={'1'}>
  6. <Pages />
  7. </ThemeContext.Provider>
  8. );
  • 子组件接受数据
    class component接受数据
  1. class Pages extends React.Component {
  2. componentDidMount() {
  3. console.log(this.context);
  4. }
  5. }
  6. Pages.contextType = ThemeContext;
  • functional component接受数据。注意,子组件接受的是最近Provider提供的值
  1. const WrappedPages = () => (
  2. <ThemeContext.Consumer>
  3. {
  4. theme => (
  5. <Pages theme={theme} />
  6. )
  7. }
  8. <ThemeContext.Consumer>
  9. );
  • 如何动态修改数据
    • 祖先组件如何修改数据
  1. // 创建context
  2. const ThemeContext = React.createContext('1');
  3. // 使用state可以动态修改context
  4. class App extends React.Component {
  5. state = {
  6. theme: '1'
  7. };
  8. componentDidMount() {
  9. this.setState({
  10. theme: '2'
  11. });
  12. }
  13. render() {
  14. return (
  15. <ThemeContext.Provider value={this.state.theme}>
  16. <Pages />
  17. </ThemeContext.Provider>
  18. );
  19. }
  20. };
  1. - 子组件如何修改数据
  1. // 创建context
  2. const ThemeContext = React.createContext('1');
  3. // 将修改theme的回调传递下去
  4. class App extends React.Component {
  5. state = {
  6. theme: '1'
  7. };
  8. changeTheme = theme => {
  9. this.setState({theme});
  10. };
  11. render() {
  12. return (
  13. <ThemeContext.Provider value={this.state.theme}>
  14. <Pages changeTheme={this.changeTheme} />
  15. </ThemeContext.Provider>
  16. );
  17. }
  18. };
  19. // 调用回调修改context
  20. const Pages = ({changeTheme}) => (
  21. <>
  22. <button
  23. onClick={() => {changeTheme('2')}}
  24. >
  25. 切换主题
  26. </button>
  27. {
  28. // 根据theme渲染一些元素
  29. }
  30. </>
  31. );
  • 如何消费多个context
  1. // 嵌套函数包裹子组件,将多个context传递给子组件
  2. const ThemeContext = React.createContext('1');
  3. const EnvContext = React.createContext('test');
  4. const App = () => (
  5. <ThemeContext.Consumer>
  6. {
  7. theme => (
  8. <EnvContext.Consumer>
  9. {
  10. env => (
  11. <Pages
  12. theme={theme}
  13. env={env}
  14. />
  15. )
  16. }
  17. </EnvContext.Consumer>
  18. )
  19. }
  20. </ThemeContext.Consumer>
  21. );

错误边界

1. 背景

React16对待组件渲染错误的策略是,任何未被捕获的错误将会导致整个React组件树整个被卸载。

因此React提供了一种机制让开发者捕获子组件树中的错误。这就是错误边界。

2. 错误边界相关的API

  • 如果一个class组件定义了static getDerivedStateFromError或者componentDidCatch中的任何一个,它就变成一个错误边界
  • 当抛出错误后,使用static DerivedStateFromError渲染备用UI,使用componentDidCatch打印信息。

3. 错误边界捕获的信息

错误边界捕获的是其子组件树的错误,它不能捕获

  • 事件处理
  • 异步代码
  • 服务端渲染
  • 它自身抛出的错误

【思考】由于异常捕获是try catch子组件生命周期钩子中的错误,因此上述情况无法捕获。

4. 错误边界应该放在哪里

大多数情况下, 你只需要声明一次错误边界组件, 并在整个应用中使用它。

  • 放在全局,以告诉开发者具体的子组件的错误
  • 可以将单独的部件(如第三方React组件)包在错误边界中,以保证不会影响到其他组件

5. 组件栈追踪

React16会把渲染期间发生的所有错误打印到控制台。

打印的信息除了错误信息和JavaScript调用堆栈外,React还支持组件栈追踪。即查看发生错误的组件名和行号。

组件栈错误追踪的功能需要相关支持,React需要知道: 1. 该组件的名称和 2. 源代码行号。这是通过一些第三方工具实现的

  1. 目前主流浏览器都支持function.name,这样React可以通过类的name属性知道组件的名称。对于不支持function.name的浏览器,可以通过function.name-polyfill或者组件上挂载displayName属性来实现。
  2. 使用babel插件babel-plugin-transform-react-jsx-source 给组件添加__source属性

6. 示例代码

  1. // 定义错误边界
  2. class ErrorBoundary extends React.Component {
  3. constructor(props) {
  4. super(props);
  5. this.state = { hasError: false };
  6. }
  7. static getDerivedStateFromError(error) {
  8. // 更新 state 使下一次渲染能够显示降级后的 UI
  9. return { hasError: true };
  10. }
  11. componentDidCatch(error, info) {
  12. // 你同样可以将错误日志上报给服务器
  13. logErrorToMyService(error, info);
  14. }
  15. render() {
  16. if (this.state.hasError) {
  17. // 你可以自定义降级后的 UI 并渲染
  18. return <h1>Something went wrong.</h1>;
  19. }
  20. return this.props.children;
  21. }
  22. }
  23. // 使用错误边界
  24. <ErrorBoundary>
  25. <MyWidget />
  26. </ErrorBoundary>

代码分割

1. 需求背景

组件代码分割的需求:有些组件可能在初始化时候不需要加载,后面需要的时候再加载。这样就需要我们对组件分片,构建时候该组件相关代码单独打包成一个代码片,运行时候按需加载下来并执行渲染。

组件代码分割需求有以下两个要点

  • 构建:如何将组件代码分片打包,并异步渲染
  • loading:在组件异步加载过程中,需要展示loading,如何在合适时机展示和移除loading

2. 代码分割的实现

  1. 第一个需求,组件代码分片打包。使用es6的动态import实现分片打包(需要babel-plugin-syntax-dynamic-import的支持),使用React.lazy实现异步加载。
    上述代码会在加载OtherComponent时候才去下载并执行渲染,如果React不渲染MyComponent,则不会加载OtherComponent
    React.lazy接受一个返回Promise的函数,这个Promise需要resolve一个export default React组件的模块
  1. const OtherComponent = React.lazy(() => import('./OtherComponent'));
  2. function MyComponent() {
  3. return (
  4. <div>
  5. <OtherComponent />
  6. </div>
  7. );
  8. }
  1. 第二个需求,loading。使用React.Suspense实现。
    Suspense能够在包含其中的懒加载组件加载出来之前渲染给定的fallback组件
    注意Suspense可以位于懒加载组件之上的任意位置,Suspense组件其中可以包含多个懒加载组件
  1. const OtherComponent = React.lazy(() => import('./OtherComponent'));
  2. function MyComponent() {
  3. return (
  4. <div>
  5. <Suspense fallback={<div>Loading...</div>}>
  6. <OtherComponent />
  7. </Suspense>
  8. </div>
  9. );
  10. }

3. 代码分割的原理

  1. 分片打包的原理
    import动态加载的功能是webpack实现的,通过一些插件,在编译时候把动态import的模块单独打包,再在webpack运行时代码中将之拉取并执行。动态import实际是返回一个Promise,该Promise成功时候会resolve该模块。因此使用异步加载的模块都需要在.then方法中获取该模块后再使用。
    React.lazy是接受一个function,然后调用该function返回的一个Promise,在Promise的resolve中取到该组件进行渲染。
    一个简单的实现:
  1. import React from 'react';
  2. export default loadComponent => (
  3. class AsyncComponent extends React.Component {
  4. state = {
  5. Component: null,
  6. };
  7. componentWillMount() {
  8. if (this._hasLoadedComponent()) {
  9. return;
  10. }
  11. loadComponent()
  12. .then(module => module.default)
  13. .then(Component => {
  14. this.setState({Component});
  15. })
  16. .catch(error => {
  17. console.error('Cannot load component in <AsyncComponent />');
  18. throw error;
  19. });
  20. }
  21. _hasLoadedComponent() {
  22. const {Component} = this.state;
  23. return Component !== null;
  24. }
  25. render() {
  26. const {Component} = this.state;
  27. return (Component) ? <Component {...this.props} /> : null;
  28. }
  29. }
  30. );
  1. loading原理【个人猜测,未看源码】
    React.Suspense应该是监测子组件树中通过React.lazy加载的异步组件,以决定渲染fallbak时机。

高阶组件

1. 什么是高阶组件

具体而言,高阶组件是参数为组件,返回值为新组件的函数。

— React 官方文档

2. 高阶组件的作用

高阶组件用来在不同的React组件之间共享一些逻辑。

分离高层和底层关注点:HOC只关注数据的的逻辑,而不关心数据怎么用;参数组件只关注数据如何使用,而不关注数据的逻辑。

总而言之,高阶组件就是一个生成组件的工厂方法,在我们需要对某些组件进行改造生成新的组件的时候,可以使用高阶组件。

3. 如何实现一个高阶组件

  1. 原则
    • 高阶组件即一个函数,接受一个组件和一些配置项,根据这些配置项对传入的组件进行包装
    • 高阶组件是纯函数,不会修改传入的参数组件,也不会使用继承来复制其行为(因为新组件和传入的组件并不是父子类关系)。它会返回一个新的组件。
    • 高阶函数返回的新组件可以认为是一个容器组件,而传入的组件从容器组件中接受props,HOC 视为参数化容器组件。
  2. 最佳实践
    • 高阶组件应该透传不相关的props
    • 如果有可能有多个高阶组件作用于同一个组件上,那么多个高阶组件最好是入参和出参格式相同,这样就可以使用compose将它们组合在一起,提高可读性。比如,react-redux中的connect可以接受一个参数返回一个高阶组件,这个高阶组件入参和出参都只是一个React组件,这样它就很容易和withRouter组合在一起(使用compose)。
    • 高阶组件名称开头小写(因为它是个函数而不是一个React组件),命名最好以“with”开头,后面的单词表明这个高阶组件做了什么工作。另外高阶组件返回的组件最好有个和高阶组件相同只是开头大写的名称,以方便调试报错时候清楚地知道错误来源
  3. 注意事项
  • 尽量使用静态高阶组件而不是动态地使用之。比如一个组件每次render时候都使用高阶组件生成一个新的组件并作为子组件的话,那这个子组件每次都会重新装载和卸载(因为React diff算法发现ReactElement的type不同的话,就认为整个子树都是不同的,就会直接卸载重新生成这个子树)。
  • 务必复制静态方法。使用高阶组件增强一个组件时候,要注意返回的组件上并没有传入组件的静态方法,那么如果外界使用包装过的组件时候,就无法使用它的静态方法了。为了解决这个问题,我们可以
    1. 通过hoist-non-react-statics这个第三方库来将所有非React的静态方法复制到新组件上面。
    2. 直接将静态方法显式导出
  • 使用React.forwardRef()传递ref。高阶组件不会传递ref给子组件,因此需要其他方式传递,以便让包装过的组件使用时候可以接收到ref,React.forwardRef()方法提供了这个支持。

Fragment

React.Fragment

1. 什么是Fragment

Fragment是一个React内置的组件类型。它允许开发者在一个组件中返回多个元素。

2. 为什么要用Fragment

React的组件要求返回的元素必须有一个根节点。但是实际开发中,组件可能是一系列的元素,所以我们需要一种方案可以让一个React组件可以返回多个元素,并且不用添加不必要的根节点。

这就是Fragment可以给我们提供的支持。

3. 如何使用Fragment

  1. render() {
  2. return (
  3. <React.Fragment>
  4. <ChildA />
  5. <ChildB />
  6. <ChildC />
  7. </React.Fragment>
  8. );
  9. }

上面的组件渲染出ChildA、B、C三个组件,没有根节点。

也可以使用短语法实现同样的功能

  1. render() {
  2. return (
  3. <>
  4. <ChildA />
  5. <ChildB />
  6. <ChildC />
  7. </>
  8. );
  9. }

Portals

1. 背景

通常组件render返回一个React元素时,它会被挂载到DOM节点中最近的父节点

而有时候,我们希望把一些React元素渲染到指定的元素,而非该组件最近父元素DOM节点中。比如,对话框、悬浮卡、提示框这种需要在视觉上跳出其容器的场景。

这时候,我们需要在render中返回一个portal元素而非一个普通的React元素。

2. React.createPortal

  1. render() {
  2. // React 并*没有*创建一个新的 div。它只是把子元素渲染到 `domNode` 中。
  3. // `domNode` 是一个可以在任何位置的有效 DOM 节点。
  4. return ReactDOM.createPortal(
  5. this.props.children,
  6. domNode
  7. );
  8. }

3. portal组件的事件

portal组件的事件冒泡是会传递到其父组件上,而非其DOM数的父节点

4. 示例,Modal的实现和info的实现

import React from 'react';

// Modal接收参数“visible”,控制其展示或者隐藏
// 展示时候,会在body元素下创建一个div并挂载之
// 隐藏时候,卸载组件并移除这个div元素
// Modal渲染children
class Modal extends React.Component {
    constructor() {
        super();
        this.createWrapper();
    }

    createWrapper() {
        this.destroyWrapper();
        this.wrapper = document.createElement('div);
        document.body.appendChild(this.wrapper);
    }

    destroyWrapper() {
        if (this.wrapper) {
            document.body.removeChild(this.wrapper);
            this.wrapper = null;
        }
    }

    componentWillReceiveProps({visible}) {
        if (visible) {
            this.createWrapper();
        }
    }

    componentDidUpdate() {
        const {visible} = this.props;
        if (!visible) {
            this.destroyWrapper();
        }
    }

    render() {
        const {children} = this.props;
        reuturn (
            React.createPortal(
                children
                this.wrapper
            );
        );
    }
}

ref转发

背景

使用React开发应用的过程中,可能会有获取子dom节点或者子组件实例引用的需求。React提供我们3种方式实现这个效果。

第一种方式是字符串ref

class Child extends React.Component {
    componentDidMount() {
        this.refs.input.focus();
    }
    render() {
        return (
            <input ref="input" />
        );
    }
}

第二种方式是回调refs

class Child extends React.Component {
    componentDidMount() {
        this.refs.input.focus();
    }

    onRef = element => {
        this.input = element;
    };

    render() {
        return (
            <input ref={this.onRef} />
        );
    }
}

第三种方式是使用React.createRef方法

class Child extends React.Component {
    constructor() {
        this.input = React.createRef();
    }

    componentDidMount() {
        this.input.current.focus();
    }

    render() {
        return (
            <input ref={this.input} />
        );
    }
}

比较推荐使用第三种方式实现子节点引用,原因可以参考官网,后面我们都使用这个方式进行讨论。

下面看下另一个需求:ref转发。开发应用时候,可能需要父组件获取子组件的子节点的引用。即一个组件需要获取自己的孙子组件实例的引用。

ref转发的实现

使用React提供的createRef很容易实现ref转发

class Child extends React.Component {
    render() {
        const {forwardedRef} = this.props;
        return (
            <input ref={forwardedRef} />
        );
    }
}

class Parent extends React.Component {
    constructor() {
        this.input = React.createRef();
    }

    componentDidMount() {
        this.input.current.focus();
    }

    render() {
        return (
            <Child forwardedRef={this.input} />
        );
    }
}

当然上面的Child也可以实现为一个函数式组件。

我们还需要考虑另一个场景,就是如果我们提供一个高阶组件wrap(),对传入的组件Comp进行包装。我们使用包装后的组件wrappedComp的时候希望可以直接给wrappedComp传入ref属性就能获取到Comp的引用,而不会获取到外层组件的引用。通常我们开发组件库时候会有这种需求。这时候我们希望能有一种方法把传给某个组件的ref属性转发给子组件(而不是像上面的例子演示的,把传给某个组件的forwardedRef属性转发给子组件的ref上)。

这就需要我们使用React.forwardRef()方法了。

function wrap(Comp) {
    class Wrapper extends React.Component {
        componentDidMount() {
            console.log(this.props);
        }
        render() {
            const {forwardedRef, ...rest} = this.props;
            return (
                <Comp {...rest} ref={forwardedRef} />
            );
        };
    }
    return React.forwardRef((props, ref) => (
        <Wrapper {...props} forwardedRef={ref} />
    ));
}

如果不用React.forwardRef()方法,组件是不可能获取到父组件传过来的ref属性的。因为ref属性是被React拦截了的。因此我们需要用到React.forwardRef()来在Wrapper组件外面包一层,可以让包裹后返回的组件可以获取到父组件传来的ref。这就实现了ref原样转发。

使用PropTypes进行类型检查

1. 什么是PropTypes

PropTypes是React组件的属性检查器。我们可以使用它校验我们的组件props。如果组件接受的props未通过校验,就会报错或者警告。

2. 为什么要用PropTypes

我们开发一个React组件时候,可能需要从父组件接受一些属性以控制当前组件的行为。在当前组件中,会使用父组件传入的props来做一些操作。如果这些props的数据类型与我们期望的不匹配,或者这些props不满足某些规则,那么我们依赖这些props所做的操作就会有问题。因此,我们需简单的方案对props进行校验。

使用propTypes来校验props,同时也声明了本组件的使用规范,即应该传递什么参数、参数的类型等。

prop-types这个工具提供了相关支持。

3. 如何使用PropTypes

简单使用示例:

class Test extends React.Component {
    static props = {
        a: PropTypes.number.isRequired,
        b: PropTypes.string.isRequired,
        c: PropTypes.bool
    };
    // 未标注为“isRequired”的参数(非必传),最好设定默认值
    static defaultProps = {
        c: true
    };
}

常见设定:

// 参数必须是数值类型
PropTypes.number
// 参数必须是数值类型,且必传
PropTypes.number.isRequired
// 字符串类型
PropTypes.string
// 布尔类型
PropTypes.bool
// 对象类型,字段满足shape定义的形式
PropTypes.shape({
    a: PropTypes.string,
    b: PropTypes.number
})
// 数组类型,数组的每个元素满足arrayOf中定义的类型
PropTypes.arrayOf(PropTypes.number)

// 限制传递给组件的children只有一个元素
PropTypes.element.isRequired

也可以自定义验证器,参考官方文档

浅层渲染

1. 什么是浅层渲染

浅层渲染是React组件测试的解决方案。由react-test-renderer/shallow工具提供支持。浅层渲染不依赖与DOM,它会返回React组件元素引用,并且不渲染其子元素。这样就可以对这个组件进行测试。

2. 为什么要用浅层渲染

对React组件测试是很常见的需求,对React组件测试时候,我们不希望其子元素也被渲染,只是希望对某个组件单独进行测试。因此需要对组件和子组件进行隔离测试。这就需要我们使用浅层渲染的结果。

3. 如何使用浅层渲染

react-test-renderer/shallow这个工具提供了浅层渲染的支持。示例如下:

import ShallowRenderer from 'react-test-renderer/shallow';

// 测试代码:
const renderer = new ShallowRenderer();
renderer.render(<MyComponent />);
const result = renderer.getRenderOutput();

expect(result.type).toBe('div');
expect(result.props.children).toEqual([
  <span className="heading">Title</span>,
  <Subcomponent foo="bar" />
]);

受控组件和非受控组件

1. 背景

受控组件和非受控组件都可以用来实现表单相关的需求。

React组件可以控制一个表单元素的状态(通过表单元素的value属性),React组件还可以通过这个表单元素的状态更改回调来获取这个表单元素的状态。这个表单元素就是一个“受控组件”。

“受控”体现在:React组件可以通过自己的state控制这个表单元素的值(value属性)

【思考】只要是这种形式的父子组件关系,都可以成为受控组件。不限于表单元素。那么如何实现一个受控组件?

2. 受控组件和非受控组件

受控组件和非受控组件都可以用来实现表单功能。非受控组件通过ref来直接获取表单的状态(也可以通过回调)。在某些需求中,React组件不需要控制非受控组件的状态。

注意,type为”file”的input组件无法受控,因为它的状态不能被代码赋值,只能是用户输入。

受控组件和非受控组件有什么区别呢?什么场景下该用受控组件,什么场景下该用非受控组件呢?

受控组件和非受控组件的优缺点

  • 受控组件的优点是,React组件可以将表单状态记录在state中,随时使用。
  • 非受控组件的优点是,更简洁。

使用受控组件还是非受控组件的依据是,是否需要记录表单组件的状态。

  • 如果表单需求很简单,不需要存储表单状态,只需要某个条件触发拿到表单的状态。那么应该使用非受控组件,这样代码会更简单。
  • 如果有需要将表单的数据存储,比如,输入时候实时校验输入并给出反馈;强制输入的值转换(如都改为大写);输入的数据要用来渲染其他元素(比如空值时候button置灰等)。这时候应该用受控组件。