万字总结 React 知识点 来看看你会了没?

687 阅读24分钟

这是我参与8月更文挑战的第3天,活动详情查看:8月更文挑战

本文章使用的 React 版本为17.0.2,非特殊声明默认使用函数式组件

⚛️ 环境准备

我们使用官方脚手架create-react-app来创建我们的 react 应用

首先确保 Node >= 10.16 和 npm >= 5.6

node -v
npm -v

然后执行

npx create-react-app react-demo
cd react-demo
npm start

一个 React 项目就创建好了

🏰 JSX

JSX 是一个 JavaScript 的语法扩展,让我们可以在 js 中写 html、css...

const element = <h1>Hello, world!</h1>

上面这段代码就是一个简单的 JSX,我们也可以在 JSX 中写表达式,使用 {} 包裹

const element = <h1>Today is {new Date().toLocaleDateString()}</h1>

JSX 里的注释也需要{},当然你可以直接使用 vscode 快捷键 Ctrl + /

 {/* 正确注释的写法 */}
 { // 正确注释的写法 }

JSX 也是一个表达式,也就是说,你可以在 if 语句和 for 循环的代码块中使用 JSX,将 JSX 赋值给变量,把 JSX 当作参数传入,以及从函数中返回 JSX

function getGreeting(user) {
  if (user) {
    return <h1>Hello, {formatName(user)}!</h1>
  }
  return <h1>Hello, Stranger.</h1>
}

另外在 JSX 中写 html 标签的 attribute 时,要使用小驼峰命名,例如,JSX 里的 class 变成了 classNameonclick变成了onClick

const element = <div className='classname'></div>

使用内联样式时,要用双括号写法(使用小驼峰写法写css样式)

 <h1 style={{ fontSize: "16px" }}>{count}</h1>

JSX 标签里能够包含很多子元素,使用()包裹

const element = (
  <div>
    <h1>Hello!</h1>
    <h2>Good to see you here.</h2>
  </div>
)

还需注意一点,JSX 的最外层必须有且只有一个元素, 否则会报错,当然你也可以使用 <React.Fragment></React.Fragment> 或者 <></>来让你的最外层元素为空

const element = (
  <>
    <h1>Hello!</h1>
    <h2>Good to see you here.</h2>
  </>
)

最后,使用ReactDOM.render()渲染元素到根 DOM 节点中

const element = <h1>Hello, world</h1>
ReactDOM.render(element, document.getElementById("root"))

条件渲染

React 中的条件渲染和 JavaScript 中的一样,使用 JavaScript 运算符 if 或者条件运算符去创建元素来表现当前的状态,然后让 React 根据它们来更新 UI。

function UserGreeting(props) {
  return <h1>Welcome back!</h1>
}

function GuestGreeting(props) {
  return <h1>Please sign up.</h1>
}

function Greeting(props) {
  const isLoggedIn = props.isLoggedIn
  if (isLoggedIn) {
    return <UserGreeting />
  }
  return <GuestGreeting />
}

也可以直接在 JSX 里用条件运算符

例如使用三目运算符 condition ? true : false

function GuestGreeting(props) {
  return <div>{props.isLoggedIn ? <UserGreeting /> : <GuestGreeting />}</div>
}

或者使用 &&

function Mailbox(props) {
  const unreadMessages = props.unreadMessages
  return (
    <div>
      <h1>Hello!</h1>
      {unreadMessages.length > 0 && <h2>You have {unreadMessages.length} unread messages.</h2>}
    </div>
  )
}

列表 & Key

一种很常见的场景是我们需要多个重复的组件,通过对数据数组遍历来渲染它们

下面,我们使用 Javascript 中的 map() 方法来遍历 numbers 数组。将数组中的每个元素变成 <li> 标签,最后我们将得到的数组赋值给 listItems

const numbers = [1, 2, 3, 4, 5]
const listItems = numbers.map(number => <li>{number}</li>)

当我们运行这段代码,将会看到一个警告 a key should be provided for list items,意思是当你创建一个元素时,必须包括一个特殊的 key 属性。

让我们来给每个列表元素分配一个 key 属性来解决上面的那个警告:

const numbers = [1, 2, 3, 4, 5]
const listItems = numbers.map(number => <li key={number.toString()}>{number}</li>)

key

在上面我们根据报错信息给每个列表项加上了一个独一无二的 key,那么这个 key 是干什么用的呢?

key 帮助 React 识别哪些元素改变了,比如被添加或删除,注意这个 key 不是给开发者用的,而是给 react 自己用的,你不能在组件的 props 中拿到 key

一个元素的 key 最好是这个元素在列表中拥有的一个独一无二的字符串。通常,我们使用数据中的 id 来作为元素的 key:

const todoItems = todos.map(todo => <li key={todo.id}>{todo.text}</li>)

当元素没有确定 id 的时候,万不得已你可以使用元素索引 index 作为 key:

const todoItems = todos.map((todo, index) => (
  // Only do this if items have no stable IDs
  <li key={index}>{todo.text}</li>
))

如果列表项目的顺序可能会变化,不建议使用索引来用作 key 值,因为这样做会导致性能变差,还可能引起组件状态的问题。

一个🌰:用 index 作为 key 带来的问题

上面的例子中,在数组新增元素后,key 对应的实例都没有销毁,而是重新更新。具体更新过程我们拿key=0 的元素来说明,

  • 组件重新render得到新的虚拟dom
  • 新老两个虚拟 dom 进行 diff,新老版的都有 key=0 的组件,react认为同一个组件,则只可能更新组件
  • 然后比较其 children,发现内容的文本内容不同,而 input 组件并没有变化,这时触发组件的componentWillReceiveProps 方法,从而更新其子组件文本内容
  • 因为组件的children中input组件没有变化,其又与父组件传入的 props 没有关联,所以 input 组件不会更新(即其 componentWillReceiveProps 方法不会被执行),导致用户输入的值不会变化

🎉 组件

组件,从概念上类似于 JavaScript 函数。它接受任意的入参(即 “props”),并返回用于描述页面展示内容的 React 元素。

什么时候需要组件?

当我们重复使用结构十分相似的一段 JSX 的时候,我们就应该想到可以将这段 JSX 抽离成一个独立的组件

定义组件最简单的方式就是编写 JavaScript 函数:

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

或者你也可以将一个组件单独写为一个 jsx 文件,然后在其他文件中引入

组件的命名开头必须大写,否则会被识别为 html 标签,若 html 没有对应同名元素则报错

使用组件的时候,类似于一个 html 标签那样使用

<div>
  <Welcome name='Sara' />
  <Welcome name='Cahal' />
  <Welcome name='Edite' />
</div>

当 React 元素为用户自定义组件时,它会将 JSX 所接收的属性(attributes)以及子组件(children)转换为单个对象传递给组件,这个对象被称之为 “props”。

props 中有一个特别的 prop children,也就是组件的包裹的子元素

function FancyBorder(props) {
  return <div className={"FancyBorder FancyBorder-" + props.color}>{props.children}</div>
}

function WelcomeDialog() {
  return (
    <FancyBorder color='blue'>
      <h1 className='Dialog-title'>Welcome</h1>
      <p className='Dialog-message'>Thank you for visiting our spacecraft!</p>
    </FancyBorder>
  )
}

需要注意的是 props 是只读的,也就是说,组件不能修改自己的 props,这样的函数被称为“纯函数”,因为该函数不会尝试更改入参,且多次调用下相同的入参始终返回相同的结果,即没有副作用。

也就是说,我们想改变 props 只能在传入 props 的时候改变

💬 组件通信

使用 props ,其实就完成了一次父组件对子组件的通信传值,但是我们想改变 props 的时候,如果你直接去改变 props 的值,你会发现页面并没有重新渲染,这是怎么回事呢?

这就得介绍一下 React 的提供的一个hookuseState

我们使用useState来写一个计数器组件

import { useState } from "react"

function IncreaseButton() {
  const [count, setCount] = useState(0)
  return (
    <>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </>
  )
}

export default IncreaseButton

在这个组件中,首先调用了useStateuseState 会返回一对值:当前状态和一个让你更新它的函数,你可以在事件处理函数中或其他一些地方调用这个函数。当使用这个函数更新state之后,用到这个state的 UI 会重新渲染

useState 唯一的参数就是初始 state。在上面的例子中,我们的计数器是从零开始的,所以初始 state 就是 0

父传子

了解了 useState 之后,我们就知道如何进行父子之间通信了

父组件将需要传递的参数通过 key={xxx} 方式传递至子组件,子组件通过 this.props.key 获取参数

如果需要改变传入子组件的 props,只需要将传给子组件的变量用useState 包裹,然后在需要改变 props 的时候,在父组件使用 useState 提供的更新 state 的函数更新 state,这样子组件就会重新渲染

子传父

父传子和子传父的方式十分类似,也是利用 useState,一种做法是,将useState 提供的更新函数一同传给子组件,接下来就可以在子组件中使用这个函数来更新父组件的 state 了。

兄弟组件通信

兄弟之间通信就更容易想到了,我们完全可以把兄弟组件之间都需要的状态提升到父组件来,让父组件管理 state,这样也可以完成兄弟组件通信

最后,我们也可以使用一些状态管理工具来进行组件间的通信,常见的有Context HookReduxdva

mobx 等等

事件处理

  • React 事件的命名采用小驼峰式(camelCase),而不是纯小写。
  • 使用 JSX 语法时你需要传入一个函数作为事件处理函数,而不是一个字符串。

例如,传统的 HTML:

<button onclick="activateLasers()">Activate Lasers</button>

在 React 中略微不同:

<button onClick={activateLasers}> Activate Lasers </button>
// 注意如果你的函数需要传入参数,需要这样写
<button onClick={() => activateLasers(params)}> Activate Lasers </button>
//因为{}里面的表达式会立即执行

在 React 中另一个不同点是你不能通过返回 false 的方式阻止默认行为。你必须显式的使用 preventDefault。例如,传统的 HTML 中阻止表单的默认提交行为,你可以这样写:

<form onsubmit="console.log('You clicked submit.'); return false">
  <button type="submit">Submit</button>
</form>

在 React 中,可能是这样的:

function Form() {
  function handleSubmit(e) {
    e.preventDefault()
    console.log("You clicked submit.")
  }

  return (
    <form onSubmit={handleSubmit}>
      <button type='submit'>Submit</button>
    </form>
  )
}

受控组件

在 HTML 中,表单元素(如<input>(是非受控组件)、 <textarea><select>)通常自己维护 state,并根据用户输入进行更新。而在 React 中,可变状态(mutable state)通常保存在组件的 state 属性中,并且只能通过使用 setState()来更新。

我们可以把两者结合起来,使 React 的 state 成为“唯一数据源”。渲染表单的 React 组件还控制着用户输入过程中表单发生的操作。被 React 以这种方式控制取值的表单输入元素就叫做“受控组件”。

设置受控组件需要 3 个步骤:

  1. 定义保存input值的状态:const [value, setValue] = useState(")
  2. 创建事件处理程序,该事件处理程序在值更改时更新状态:
const onChange = event => setValue(event.target.value);
  1. input字段分配状态值,并添加事件处理程序:<input type="text" value={value} onChange={onChange} />

下面是一个简单的例子

import { useState } from "react"

const MyInput = () => {
  const [value, setValue] = useState("")
  return (
    <>
      <input value={value} type='text' onChange={e => setValue(e.target.value)} />
      <p>{value}</p>
    </>
  )
}

export default MyInput

高阶组件 HOC

高阶组件是参数为组件,返回值为新组件的函数

例如redux里的connect

function mapStateToProps(state) {
  return { todos: state.todos }
}

export default connect(mapStateToProps)(TodoApp)

🚀 Hook

在介绍组件的时候,我们了解到了useState这个 hook,接下来,我们就来详细了解一下 hook

Hook 是一些可以让你在函数组件里“钩入” React state 及生命周期等特性的函数。Hook 不能在 class 组件中使用 —— 这使得你不使用 class 也能使用 React。

⚠️ 还有两点需要注意

  • 只能在函数内部的最外层调用 Hook,不要在循环、条件判断或者子函数中调用
  • 只能在函数式组件中调用 Hook,不要在其他 JavaScript 函数中调用

🚫 限制原因

第二点我们很容易想到,但是第一点是为什么呢?

这一点是为了保证我们的 Hooks 在每一次渲染中都按照同样的顺序被调用。这让 React 能够在多次的 useStateuseEffect 调用之间保持 hook 状态的正确。

以 Preact 的 Hook 的实现为例,它用数组和下标来实现 Hook 的查找(React 使用链表,但是原理类似)

// 当前 hook 的全局索引
let currentIndex

// 当前正在运行的组件
let currentComponent

// 第一次调用 currentIndex 为 0
useState('first') 

// 第二次调用 currentIndex 为 1
useState('second')

可以看出,每次 Hook 的调用都对应一个全局的 index 索引,通过这个索引去当前运行组件 currentComponent 上的 _hooks 数组中查找保存的值,也就是 Hook 返回的 [state, useState]

那么假如条件调用的话,比如第一个 useState 只有 0.5 的概率被调用:

// 当前 hook 的全局索引
let currentIndex

// 当前正在运行的组件
let currentComponent

// 第一次调用 currentIndex 为 0
if (Math.random() > 0.5) {
  useState('first')
}

// 第二次调用 currentIndex 为 1
useState('second')

在 Preact 第一次渲染组件的时候,假设 Math.random() 返回的随机值是 0.6,那么第一个 Hook 会被执行,此时组件上保存的 _hooks 状态是:

_hooks: [
  { value: 'first', update: function },
  { value: 'second', update: function },
]

用图来表示这个查找过程是这样的:

假设第二次渲染的时候,Math.random() 返回的随机值是 0.3,此时只有第二个 useState 被执行了,那么它对应的全局 currentIndex 会是 0,这时候去 _hooks[0] 中拿到的却是 first 所对应的状态,这就会造成渲染混乱。

没错,本应该值为 second 的 value,莫名其妙的被指向了 first,渲染完全错误!

当然我们能想到既然按数组顺序查找 hook 可能错误,那我们给每一个 hook 都指定一个唯一的 key 呢?事实上 React 团队也考虑过给每次调用加一个 key 值的设计,但多重的缺陷导致这个提案被否决了,在 为什么顺序调用对 React Hooks 很重要? 中可以详细了解原因。

useState

const [state, setState] = useState(initialState);

每次渲染都是独立的闭包

  • 每一次渲染都有它自己的 Props 和 State
  • 每一次渲染都有它自己的事件处理函数
  • 当点击更新状态的时候,函数组件都会重新被调用,那么每次渲染都是独立的,取到的值不会受后面操作的影响
function Counter2() {
  let [number, setNumber] = useState(0)
  function alertNumber() {
    setTimeout(() => {
      // alert 只能获取到点击按钮时的那个状态
      alert(number)
    }, 3000)
  }
  return (
    <>
      <p>{number}</p>
      <button onClick={() => setNumber(number + 1)}>+</button>
      <button onClick={alertNumber}>alertNumber</button>
    </>
  )
}

函数式更新

如果新的 state 需要通过使用先前的 state 计算得出,那么可以将回调函数当做参数传递给 setState。该回调函数将接收先前的 state,并返回一个更新后的值

function Counter() {
  let [number, setNumber] = useState(0)
  function lazy() {
    setTimeout(() => {
      // setNumber(number+1);
      // 这样每次执行时都会去获取一遍 state,而不是使用点击触发时的那个 state
      setNumber(number => number + 1)
    }, 3000)
  }
  return (
    <>
      <p>{number}</p>
      <button onClick={() => setNumber(number + 1)}>+</button>
      <button onClick={lazy}>lazy</button>
    </>
  )
}

惰性的初始值

useState 的初始值是惰性的,只会在初次渲染组件的时候起作用。如果 state 的初始值需用通过复杂计算得到,useState 的初始值也可以是一个函数,函数的返回值将是 useState 的初始值。

function Counter5(props) {
  console.log("Counter5 render")
  // 这个函数只在初始渲染时执行一次,后续更新状态重新渲染组件时,该函数就不会再被调用
  function getInitState() {
    return { number: props.number }
  }
  let [counter, setCounter] = useState(getInitState)
  return (
    <>
      <p>{counter.number}</p>
      <button onClick={() => setCounter({ number: counter.number + 1 })}>+</button>
      <button onClick={() => setCounter(counter)}>setCounter</button>
    </>
  )
}

不要传入相同的 state

在使用 useState 的 dispatchAction 更新 state 的时候,记得不要传入相同的 state,这样会使视图不更新。比如下面这么写:

export default function Index(){
    const [ state  , dispatchState ] = useState({ name:'alien' })
    const  handleClick = ()=>{ // 点击按钮,视图没有更新。
        state.name = 'Alien'
        dispatchState(state) // 直接改变 `state`,在内存中指向的地址相同。
    }
    return <div>
         <span> { state.name }</span>
        <button onClick={ handleClick }  >changeName++</button>
    </div>
}

如上例子🌰中,当点击按钮后,发现视图没有改变,为什么会造成这个原因呢?

在 useState 的 dispatchAction 处理逻辑中,会浅比较两次 state ,发现 state 相同,不会开启更新调度任务; demo 中两次 state 指向了相同的内存空间,所以默认为 state 相等,就不会发生视图更新了。

解决方法:把上述的 dispatchState 改成 dispatchState({...state}) 根本解决了问题,浅拷贝了对象,重新申请了一个内存空间。

setState 是同步的还是异步的?

关于 state 还有一个经典的问题,那就是 setState 是“同步”的还是“异步”的?

我们知道Promise.then(),setTimeout是异步执行. 从 js 执行来说, setState肯定是同步执行.

所以这里讨论的同步和异步并不是指setState是否异步执行, 而是指调用setState之后this.state能否立即更新.

constructor(props) {
    super(props);
    this.state = {
      data: 'data'
    }
  }

  componentDidMount() {
    this.setState({
      data: 'did mount state'
    })

    console.log("did mount state ", this.state.data);
    // did mount state data

    setTimeout(() => {
      this.setState({
        data: 'setTimeout'
      })

      console.log("setTimeout ", this.state.data);
    })
  }

而这段代码的输出结果,第一个 console.log 会输出 data ,而第二个 console.log 会输出 setTimeout 。也就是第一次 setState 的时候,它是异步的,第二次 setState 的时候,它又变成了同步的。

那到底 setState 什么情况下是异步什么情况下是同步呢?为什么?

先说结论

只要你进入了 react 的调度流程,那就是异步的。只要你没有进入 react 的调度流程,那就是同步的。

即由 React 控制的事件处理程序,以及生命周期函数调用 setState 不会同步更新 state 。 React 控制之外的事件中调用 setState 是同步更新的。比如原生事件,setTimeout/setInterval等。

setState 同步执行的情况下, DOM 也会被同步更新,也就意味着如果你多次 setState ,会导致多次更新,这是毫无意义并且浪费性能的。异步执行的情况下多个 state 会合成到一起进行批量更新

domo

但是如果你自己去尝试在 FC 的 setTimeout 中去调用 setState 之后,打印 state ,你会发现他并没有改变,这时你就会很疑惑,为什么呢?这不是同步执行的吗?

这是因为一个闭包问题,你拿到的还是上一个 state ,那打印出来的值自然是上一次的,此时真正的 state 已经被改变了。

从源码的角度来说,setState 的行为是“异步”还是“同步”取决于 React 执行 setState 方法时的执行上下文(ExecutionContext)。

如果 ExecutionContextNoContext (0),表示当前没有正在进行的其他任务,则 setState 是“同步”的。

React 源码地址:

 if (
      (executionContext & LegacyUnbatchedContext) !== NoContext &&
      (executionContext & (RenderContext | CommitContext)) === NoContext
    ) {
       // .... 省略部分本次讨论不会涉及的代码
    } else {
      ensureRootIsScheduled(root, eventTime); // 触发scheduler调度(调度是异步的) , 所以该函数不会立即触发render.
      if (executionContext === NoContext) {  // 当执行上下文为0时, 会刷新同步队列
         // .... 省略部分本次讨论不会涉及的代码

        // 这里是关键,  执行同步回调队列. 有兴趣的同学可以继续在源码中查看, 可以得到结论:
        // if分支之外的ensureRootIsScheduled(root, eventTime)和此处的flushSyncCallbackQueue()
        // 最终都是调用performSyncWorkOnRoot进行fiber树的循环构建
        flushSyncCallbackQueue(); 
      }
    }

注意这里的执行上下文不是浏览器的,为了更好的控制渲染任务,避免长时间占用浏览器的主线程, React 实现了自己的执行上下文

只要绕开 react 内部触发更改executionContext的逻辑, 就能保证executionContext为空, 进而实现setState为同步.

还需要注意的是

以上分析都是基于legacy模式进行分析的, 众所周知react即将(可能)全面进入concurrent模式(可以参考react 启动模式). 在concurrent模式下, 这个题目可能就没有意义了, 因为从目前最新代码来看, 在concurrent模式下根本就不会判断executionContext, 所以concurrent模式下setState都为异步

第 18 题:React 中 setState 什么时候是同步的,什么时候是异步的?这个 issues 下有详细的讨论

useEffect

useEffect(didUpdate);

useEffect 可以让我们在函数组件中执行副作用操作。事件绑定,数据请求,动态修改 DOM。

useEffect 将会在每一次 React 渲染之后执行。无论是初次挂载时,还是更新。(当然这种行为我们可以控制)

useEffect Hook 可以看做 componentDidMountcomponentDidUpdatecomponentWillUnmount 这三个生命周期函数的组合。

无需清除的 effect

比如发送网络请求,手动变更 DOM,记录日志,这些都是常见的无需清除的操作。因为我们在执行完这些操作之后,就可以忽略他们了。

一个简单的示例

import { useState, useEffect } from "react"

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

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

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

需要清除的 effect

比如监听事件,定时器等

effect 可以返回一个函数,当 react 进行清除时, 会执行这个返回的函数。每当执行本次的 effect 时,都会对上一个 effect 进行清除。组件卸载时也会执行进行清除。

也就是说,下面的代码中。每一次更新,都会对上一次的 effect 进行卸载,并执行本次的 effect。

import { useEffect, useState } from "react"

function App() {
  const [count, setCount] = useState(0)
  useEffect(() => {
    function handleClick() {
      console.log("click body")
    }
    console.log("添加事件")
    document.body.addEventListener("click", handleClick)
    return () => {
      console.log("卸载事件")
      document.body.removeEventListener("click", handleClick)
    }
  })

  return (
    <div className='App'>
      <h1>{count}</h1>
      <button onClick={() => setCount(count + 1)}>更新</button>
    </div>
  )
}

export default App

effect 性能优化

每次执行 effect,清除上一次 effect 可能会造成不必要的性能浪费。我们可以通过 effect 的第二个参数,控制 effect 的执行。 第二个参数是 useEffect 的依赖,只有当依赖发生变化时,useEffect 才会更新。

import { useEffect, useState } from "react"

function App() {
  const [count, setCount] = useState(0)
  const [num, setNum] = useState(0)

  useEffect(() => {
    console.log("初次渲染和count更新时执行")
  }, [count])

  return (
    <div className='App'>
      <h1>count:{count}</h1>
      <h1>num:{num}</h1>
      <button onClick={() => setCount(count + 1)}>更新 count</button>
      <button onClick={() => setNum(num + 1)}>更新 num</button>
    </div>
  )
}

export default App

当我们传递传递一个空数组作为依赖时,会告诉 React,effect 不依赖任何 state 或者 props。我们可以使用此行为模拟 componentDidMount 或者 componentWillUnmount最常见的应用就是在componentDidMount阶段用来发请求

import { useEffect, useState } from "react"

function App() {
  const [count, setCount] = useState(0)
  const [num, setNum] = useState(0)

  useEffect(() => {
    console.log("只在组件挂载时调用")
  }, [])

  return (
    <div className='App'>
      <h1>count:{count}</h1>
      <h1>num:{num}</h1>
      <button onClick={() => setCount(count + 1)}>更新 count</button>
      <button onClick={() => setNum(num + 1)}>更新 num</button>
    </div>
  )
}

export default App

如何在 useEffect 中使用 async/await?

async 函数默认会返回一个 Promise 对象,而 useEffect 中,只允许什么都不返回或者返回一个清除函数。控制台中,会触发如下警告 Warning: useEffect function must return a cleanup function or nothing. Promises and useEffect(async () => …) are not supported, but you can call an async function inside an effect..

解决方案如下:

useEffect(() => {
  const fetchData = async () => {
    const response = await fetch("https://api.example.com/")
    const data = await response.json()
    setCount(data.length)
  }
  fetchData()
}, [])

useContext

const value = useContext(MyContext);

接收一个 context 对象(React.createContext 的返回值),并返回当前的 context 的 value 值。useContext 可以订阅 context 的变化。但是仍然需要上层组件使用<MyContext.Provider>来为下层组件提供 context。

import { createContext, useContext, useState } from "react"

const themes = {
  light: {
    foreground: "#000000",
    background: "#eeeeee",
  },
  dark: {
    foreground: "#ffffff",
    background: "#222222",
  },
}

const ThemeContext = createContext(themes.light)

function App() {
  const [theme, setTheme] = useState(themes.dark)
  return (
    <>
      <ThemeContext.Provider value={theme}>
        <ThemedButton />
      </ThemeContext.Provider>
      <ThemedButton />
      <button
        onClick={() => {
          setTheme(themes.light)
          console.log("click")
        }}
      >
        change theme
      </button>
    </>
  )
}

function ThemedButton() {
  const theme = useContext(ThemeContext)
  return (
    <button style={{ background: theme.background, color: theme.foreground }}>
      I am styled by theme context!
    </button>
  )
}

export default App

useReducer

useReducer 接收三个参数,形如 (state, action) => newState 的 reducer 函数,initialArg 初始值,init 惰性初始值函数。如果传入三个参数,init(initialArg)将作为初始值。

const [state, dispatch] = useReducer(reducer, initialArg, init)

useReduceruseState作用类似,但useReducer在复杂场景下比useState更适用。例如 state 逻辑较复杂且包含多个子值,或者下一个 state 依赖于之前的 state 等。并且,使用 useReducer 还能给那些会触发深更新的组件做性能优化,因为你可以向子组件传递 dispatch 而不是回调函数

以下是使用 reducer 的计数器示例:

const initialState = { count: 0 }

function reducer(state, action) {
  switch (action.type) {
    case "increment":
      return { count: state.count + 1 }
    case "decrement":
      return { count: state.count - 1 }
    default:
      throw new Error()
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState)
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
    </>
  )
}

useCallback

useCallback 接收回调函数和依赖数组作为参数。useCallback 会返回memoized函数。当依赖项改变的时候,会返回的新的memoized函数。

const memoizedCallback = useCallback(() => {
  doSomething(a, b)
}, [a, b])

useMemo

useMemo 和 useCallback 类似。useMemo 会返回memoized值。当依赖项改变时,会重新计算memoized值。

感觉类似vue中的计算属性

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b])

useCallback(fn, deps) 相当于 useMemo(() => fn, deps)

useRef

const refContainer = useRef(initialValue)

useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数(initialValue)。返回的 ref 对象在组件的整个生命周期内保持不变。

一个常见的用例便是命令式地访问子组件:

function TextInputWithFocusButton() {
  const inputEl = useRef(null)
  const onButtonClick = () => {
    // `current` 指向已挂载到 DOM 上的文本输入元素
    inputEl.current.focus()
  }
  return (
    <>
      <input ref={inputEl} type='text' />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  )
}

useRef 除了获取 dom 节点的功能外,useRef 的 current 属性,可以方便保存任何可变值。useRef 每一次渲染时,都会返回同一个 ref 对象。

我们可以用useRef来保存定时器的 timer,以便在组件被销毁时,正确销毁定时器。

const timerRef = useRef()
const [count, setCount] = useState(0)
useEffect(() => {
  timerRef.current = setInterval(() => {
    setCount(pre => pre + 1)
  }, 1000)
  return () => {
    timerRef.current && clearInterval(timerRef.current)
  }
}, [])

⚙️ 自定义 Hook

  1. 自定义 Hook 必须以 use 开头
  2. 自定义 Hook 只是逻辑复用,其中的 state 是不会共享的。
  3. 自定义 Hook 内部可以调用其他 Hook。
  4. 避免过早的拆分抽象逻辑

💡 自定义数据获取 Hook

我们通过 ajax 请求表哥数据时,很多逻辑都是通用的。比如 loading 的状态的处理,错误信息的处理,翻页的处理。我们可以把这些逻辑抽象成一个公共的 Hook。不同的 api,作为自定义 Hook 的参数。

下面是一个数据请求自定义 Hook 的例子:

import { useEffect, useState } from "react"

function useDataApi(api) {
  // 页数
  const [page, setPage] = useState(1)
  // loading状态
  const [loading, setLoading] = useState(false)
  // 错误信息
  const [error, setError] = useState("")
  // 数据
  const [data, setData] = useState([])
  useEffect(() => {
    const fetchData = async () => {
      try {
        setLoading(true)
        setError("")
        const result = await getData(api + page)
        setData(result.data)
      } catch (e) {
        setError(e)
      } finally {
        setLoading(false)
      }
    }
    fetchData()
  }, [page])
  return { loading, error, data, page, setPage }
}

export default useDataApi

🔁 生命周期

首先说明函数式组件不具有真正的生命周期,因为函数组件的本质是函数,没有 state 的概念,因此不存在生命周期一说,仅仅是一个 render 函数而已。

但我们可以利用 useStateuseEffect()useLayoutEffect() 来模拟实现生命周期。

我们可以为 class 组件声明一些特殊的方法,当组件挂载或卸载时就会去执行这些方法,这些方法就叫做生命周期方法

每个组件都包含 “生命周期方法”,你可以重写这些方法,以便于在运行过程中特定的阶段执行这些方法

class 组件的生命周期

我们可以将生命周期分为三个阶段:

  • 挂载阶段
  • 组件更新阶段
  • 卸载阶段
  1. 挂载

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

    • constructor():构造函数(初始化),注意避免将 props 的值复制给 state
    • static getDerivedStateFromProps():从props中获取派生的state,用来代替componentWillMount
    • render():组件挂载中,react 最重要的步骤,创建虚拟 dom,进行 diff 算法,更新 dom 树都在此进行
    • componentDidMount():组件完成挂载
  2. 更新

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

    • static getDerivedStateFromProps():配合 componentDidUpdate,可以覆盖 componentWillReceiveProps 的所有用法
    • shouldComponentUpdate():判断是否进行状态更新
    • render():组件挂载中
    • getSnapshotBeforeUpdate:获取 DOM 更新前的快照
    • componentDidUpdate():完成更新
  3. 卸载

    • componentWillUnmount:组件卸载前

    可以去**生命周期图谱**详细查看

constructor

在 mount 阶段,首先执行的函数,用来实例化 React 组件,如果不初始化 state 或不进行方法绑定,则不需要为 React 组件实现构造函数。

constructor(props) {
  super(props);
  // 不要在这里调用 this.setState()
  this.state = { counter: 0 };
  this.handleClick = this.handleClick.bind(this);
}

getDerivedStateFromProps

触发时机:state 变化、props 变化、forceUpdate,如上图。

这是一个静态方法, 是一个和组件自身"不相关"的角色. 在这个静态方法中, 除了两个默认的位置参数 nextProps 和 currentState 以外, 你无法访问任何组件上的数据。

// 初始化/更新时调用
static getDerivedStateFromProps(nextProps, currentState) {
  console.log(nextProps, currentState, "getDerivedStateFromProps方法执行");
  // 返回值是对currentState进行合并
  return {
    fatherText: nextProps.text,
  };
}

render

render() 方法是 class 组件中唯一必须实现的方法,返回 JSX

注意:如果 shouldComponentUpdate() 返回 false,则不会调用 render()

componentDidMount

主要用于组件加载完成时做某些操作,比如发起网络请求或者绑定事件。当做 vue 的 mounted 用就行了,这里需要注意的是:

componentDidMount() 里直接调用 setState()。它将触发额外渲染,也就是两次 render,不过问题不大,主要还是理解。

shouldComponentUpdate

该方法通过返回 true 或者 false 来确定是否需要触发新的渲染。因为渲染触发最后一道关卡,所以也是性能优化的必争之地。通过添加判断条件来阻止不必要的渲染。 注意:首次渲染或使用 forceUpdate() 时不会调用该方法。

React 官方提供了一个通用的优化方案,也就是 PureComponent。PureComponent 的核心原理就是默认实现了 shouldComponentUpdate 函数,在这个函数中对 props 和 state 进行浅比较,用来判断是否触发更新。

当然 PureComponent 也是有缺点的,使用的时候一定要注意:由于进行的是浅比较,可能由于深层的数据不一致导致而产生错误的否定判断,从而导致页面得不到更新。不适合使用在含有多层嵌套对象的 state 和 prop 中。

shouldComponentUpdate(nextProps, nextState) {
  // 浅比较仅比较值与引用,并不会对 Object 中的每一项值进行比较
  if (shadowEqual(nextProps, this.props) || shadowEqual(nextState, this.state) ) {
    return true
  }
  return false
}

getSnapshotBeforeUpdate

在 DOM 更新前被调用,返回值将作为componentDidUpdate() 的第三个参数,否则为 undefined

componentDidUpdate

componentDidUpdate() 会在更新后会被立即调用。首次渲染不会执行此方法。

当组件更新后,可以在此处对 DOM 进行操作。如果你对更新前后的 props 进行了比较,也可以选择在此处进行网络请求。(例如,当 props 未发生变化时,则不会执行网络请求)。

componentDidUpdate(prevProps) {
  // 典型用法(不要忘记比较 props):
  if (this.props.userID !== prevProps.userID) {
    this.fetchData(this.props.userID);
  }
}

你也可以在 componentDidUpdate()直接调用 setState(),但请注意它必须被包裹在一个条件语句里,正如上述的例子那样进行处理,否则会导致死循环。

componentWillUnmount

componentWillUnmount() 会在组件卸载及销毁之前直接调用。在此方法中执行必要的清理操作,例如,清除 timer,取消网络请求或清除在 componentDidMount() 中创建的订阅等。

错误处理

还有两个用来进行错误处理的生命周期

  • static getDerivedStateFromError()
  • componentDidCatch()

函数式组件中模拟生命周期

  • constructor:函数组件不需要构造函数,我们可以通过调用 useState 来初始化 state。如果计算的代价比较昂贵,也可以传一个函数给 useState

    const [num, UpdateNum] = useState(0)
    
  • getDerivedStateFromProps:一般情况下,我们不需要使用它,我们可以在渲染过程中更新 state,以达到实现 getDerivedStateFromProps 的目的。

    function ScrollView({row}) {
      let [isScrollingDown, setIsScrollingDown] = useState(false);
      let [prevRow, setPrevRow] = useState(null);
    
      if (row !== prevRow) {
        // Row 自上次渲染以来发生过改变。更新 isScrollingDown。
        setIsScrollingDown(prevRow !== null && row > prevRow);
        setPrevRow(row);
      }
    
      return `Scrolling down: ${isScrollingDown}`;
    }
    

    React 会立即退出第一次渲染并用更新后的 state 重新运行组件以避免耗费太多性能。

  • shouldComponentUpdate:可以用 React.memo 包裹一个组件来对它的 props 进行浅比较

    const Button = React.memo(props => {
      // 具体的组件
    })
    

    注意:React.memo 等效于 PureComponent,它只浅比较 props。这里也可以使用 useMemo 优化每一个节点。

  • render:这是函数组件体本身。

  • componentDidMount, componentDidUpdateuseLayoutEffect 与它们两的调用阶段是一样的。但是,推荐你一开始先用 useEffect,只有当它出问题的时候再尝试使用 useLayoutEffectuseEffect 可以表达所有这些的组合。

    // componentDidMount
    useEffect(() => {
      // 需要在 componentDidMount 执行的内容
    }, [])
    
    useEffect(() => {
      // 在 componentDidMount,以及 count 更改时 componentDidUpdate 执行的内容
      document.title = `You clicked ${count} times`
      return () => {
        // 需要在 count 更改时 componentDidUpdate(先于 document.title = ... 执行,遵守先清理后更新)
        // 以及 componentWillUnmount 执行的内容
      } // 当函数中 Cleanup 函数会按照在代码中定义的顺序先后执行,与函数本身的特性无关
    }, [count]) // 仅在 count 更改时更新
    
  • componentWillUnmount:相当于 useEffect 里面返回的 cleanup 函数

    // componentDidMount/componentWillUnmount
    useEffect(() => {
      // 需要在 componentDidMount 执行的内容
      return function cleanup() {
        // 需要在 componentWillUnmount 执行的内容
      }
    }, [])
    
  • componentDidCatch and getDerivedStateFromError:目前还没有这些方法的 Hook 等价写法,但很快会加上。

为方便记忆,大致汇总成表格如下。

class 组件Hooks 组件
constructoruseState
getDerivedStateFromPropsuseState 返回的 update 函数
shouldComponentUpdateuseMemo
render函数本身
componentDidMountuseEffect
componentDidUpdateuseEffect
componentWillUnmountuseEffect 里面返回的函数
componentDidCatch
getDerivedStateFromError

多个组件的执行顺序

父子组件

  • 挂载阶段

    两个 阶段:

    • 阶段,由父组件开始执行到自身的 render,解析其下有哪些子组件需要渲染,并对其中 同步的子组件 进行创建,按 递归顺序 挨个执行各个子组件至 render,生成到父子组件对应的 Virtual DOM 树,并 commit 到 DOM。
    • 阶段,此时 DOM 节点已经生成完毕,组件挂载完成,开始后续流程。先依次触发同步子组件各自的 componentDidMount,最后触发父组件的。

    注意:如果父组件中包含异步子组件,则会在父组件挂载完成后被创建。

    所以执行顺序是:

    父组件 getDerivedStateFromProps —> 同步子组件 getDerivedStateFromProps —> 同步子组件 componentDidMount —> 父组件 componentDidMount —> 异步子组件 getDerivedStateFromProps —> 异步子组件 componentDidMount

  • 更新阶段

    React 的设计遵循单向数据流模型 ,也就是说,数据均是由父组件流向子组件。

    • 阶段,由父组件开始,执行

      1. static getDerivedStateFromProps
      2. shouldComponentUpdate

      更新到自身的 render,解析其下有哪些子组件需要渲染,并对 子组件 进行创建,按 递归顺序 挨个执行各个子组件至 render,生成到父子组件对应的 Virtual DOM 树,并与已有的 Virtual DOM 树 比较,计算出 Virtual DOM 真正变化的部分 ,并只针对该部分进行的原生DOM操作。

    • 阶段,此时 DOM 节点已经生成完毕,组件挂载完成,开始后续流程。先依次触发同步子组件以下函数,最后触发父组件的。

      1. getSnapshotBeforeUpdate()
      2. componentDidUpdate()

      React 会按照上面的顺序依次执行这些函数,每个函数都是各个子组件的先执行,然后才是父组件的执行。

      所以执行顺序是:

      父组件 getDerivedStateFromProps —> 父组件 shouldComponentUpdate —> 子组件 getDerivedStateFromProps —> 子组件 shouldComponentUpdate —> 子组件 getSnapshotBeforeUpdate —> 父组件 getSnapshotBeforeUpdate —> 子组件 componentDidUpdate —> 父组件 componentDidUpdate

  • 卸载阶段

    componentWillUnmount(),顺序为 父组件的先执行,子组件按照在 JSX 中定义的顺序依次执行各自的方法

    注意 :如果卸载旧组件的同时伴随有新组件的创建,新组件会先被创建并执行完 render,然后卸载不需要的旧组件,最后新组件执行挂载完成的回调。

兄弟组件

  • 挂载阶段

    若是同步路由,它们的创建顺序和其在共同父组件中定义的先后顺序是 一致 的。

    若是异步路由,它们的创建顺序和 js 加载完成的顺序一致。

  • 更新阶段、卸载阶段

    兄弟节点之间的通信主要是经过父组件(Redux 和 Context 也是通过改变父组件传递下来的 props 实现的),满足React 的设计遵循单向数据流模型因此任何两个组件之间的通信,本质上都可以归结为父子组件更新的情况

    所以,兄弟组件更新、卸载阶段,请参考 父子组件

⚓ 路由

当我们写一个项目的时候,可能不只有一个页面,这时候就需要引入路由,下面介绍常用的react-router

首先安装依赖

npm install react-router-dom
yarn add react-router-dom

路由跳转

路由跳转可以使用Link或者是NavLink组件

import {Link, NavLink} from "react-router";
<Link activeClassName="nav-active" className="nav" to="/about">About</Link>
<NavLink activeClassName="nav-active" className="nav" to="/home">Home</NavLink>
  • activeClassName: 处于当前路由时,对应的组件会自动添加该类
  • className: 当前组件类名
  • to: 当前组件所对应的路由

Link组件与 NavLink组件都可以进行路由的跳转,区别在于:当前路由对应的NavLink会自动添加class: active,而 Link不会。

也可以使用useHistory

import { useHistory } from "react-router-dom";

function HomeButton() {
  let history = useHistory();

  function handleClick() {
    history.push("/home");
  }

  return (
    <button type="button" onClick={handleClick}>
      Go home
    </button>
  );
}

注册路由

import {Route} from "react-router";
<Route path="/home" component={Home}></Route>
<Route exact path="/about" component={About}></Route>
  • path: 所要监听的路由
  • component: 该路由要绑定的组件(或者直接将组件放在Route的子元素)
  • exact: 可选,不写时为 false,是否选择严格匹配

当当前路由对应上了路由组件所绑定的路由时,则会展示所绑定的组件。

路由严格匹配与模糊匹配

路由不仅仅只有一级,有的时候是有多级嵌套的,比如:

http://localhost:3000/home/a/b/c

模糊匹配: 如果当前路由与匹配的路由成相等或包含(注意层级)的情况,则启用该组件

  • http://localhost:3000/home/a/b/c 则为包含路由 /home
  • http://localhost:3000/a/b/home/c 则为不包含路由 /home(层级不对)

严格匹配: 如果当前路由与匹配的路由相等的话,才启用该组件

  • http://localhost:3000/home 则为与路由 /home 相等
  • http://localhost:3000/home/a 则为与路由 /home 不相等

重定向路由

当用户输入了一个没有的路由,这是后我们需要将页面重定向到一个已有的页面

这时候我们就需要<Redirect />

import {Redirect, Route} from "react-router";
<Route ....../>
<Route ....../>
<Redirect to="/home"/>
  • to: 需要重定向到哪个路由

Redirect需放在所有Route下面,当上面的 Route都没有匹配到时,则路由将重定向到指定的路由。

Switch 路由

使用 Switch组件包裹住所有的 RouteRedirect,当出现多个匹配的路由时,只会渲染第一个匹配的组件。

import {Switch, Route, Redirect} from "react-router";
<Switch>
    <Route ..../>
    <Route ..../>
    <Redirect to="..."/>
</Switch>

路由器

你想要使用路由跳转组件和路由组件,还差一个路由器组件,同时路由器组件必须包裹着这两个组件。

一般为了使整个React应用都可以使用到路由组件,所以一般我们都是把路由器包裹在根组件上的。

import {HashRouter, BrowserRouter} from "react-router";
ReactDOM.render( 
    <HashRouter>
        <App/>
    </HashRouter>,
    document.querySelector("#root")
);

有两种路由器组件,分别是HashRouterBrowserRouter(即vue中的history模式),分别对应者两种路由方式。

两者的区别是HashRouter 模式下的 url 最后有一个#号,而BrowserRouter没有

通常使用HashRouter,因为它最为简单,不需要服务器端渲染

路由中的参数传递

params

<Route path="/home/message/detail/:id" component={Child} />

:id代表id是一个可变参数

子组件通过useParams获取

const { id } = useParams();

search

<Route path="/home/message/detail/?id='xxx'" component={Child} />

react-router没有提供解析search参数的 api,需要我们自己解析

利用qs

const {id} = qs.parse(this.props.location.search);

状态管理

Context Hooks

利用 context + reducer 实现类似 redux 的状态管理,但有一些性能问题需要我们自己额外优化

redux

知名的 JavaScript 状态管理库,还有 react-redux,使得在 react 中使用 redux 变得更加方便

dva

mobx

参考文章

React 官方文档

react-router 官方文档

Hooks 与 React 生命周期的关系

「学习笔记」ReactHooks 入门

我打破了 React Hook 必须按顺序、不能在条件语句中调用的枷锁