这是我参与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 变成了 className,onclick变成了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 值,因为这样做会导致性能变差,还可能引起组件状态的问题。
上面的例子中,在数组新增元素后,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 的提供的一个hook,useState
我们使用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
在这个组件中,首先调用了useState,useState 会返回一对值:当前状态和一个让你更新它的函数,你可以在事件处理函数中或其他一些地方调用这个函数。当使用这个函数更新state之后,用到这个state的 UI 会重新渲染
useState 唯一的参数就是初始 state。在上面的例子中,我们的计数器是从零开始的,所以初始 state 就是 0。
父传子
了解了 useState 之后,我们就知道如何进行父子之间通信了
父组件将需要传递的参数通过 key={xxx} 方式传递至子组件,子组件通过 this.props.key 获取参数
如果需要改变传入子组件的 props,只需要将传给子组件的变量用useState 包裹,然后在需要改变 props 的时候,在父组件使用 useState 提供的更新 state 的函数更新 state,这样子组件就会重新渲染
子传父
父传子和子传父的方式十分类似,也是利用 useState,一种做法是,将useState 提供的更新函数一同传给子组件,接下来就可以在子组件中使用这个函数来更新父组件的 state 了。
兄弟组件通信
兄弟之间通信就更容易想到了,我们完全可以把兄弟组件之间都需要的状态提升到父组件来,让父组件管理 state,这样也可以完成兄弟组件通信
最后,我们也可以使用一些状态管理工具来进行组件间的通信,常见的有Context Hook、Redux、dva
、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 个步骤:
- 定义保存
input值的状态:const [value, setValue] = useState(")。 - 创建事件处理程序,该事件处理程序在值更改时更新状态:
const onChange = event => setValue(event.target.value);
- 为
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 能够在多次的 useState 和 useEffect 调用之间保持 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 会合成到一起进行批量更新
但是如果你自己去尝试在 FC 的 setTimeout 中去调用 setState 之后,打印 state ,你会发现他并没有改变,这时你就会很疑惑,为什么呢?这不是同步执行的吗?
这是因为一个闭包问题,你拿到的还是上一个 state ,那打印出来的值自然是上一次的,此时真正的 state 已经被改变了。
从源码的角度来说,setState 的行为是“异步”还是“同步”取决于 React 执行 setState 方法时的执行上下文(ExecutionContext)。
如果 ExecutionContext 为 NoContext (0),表示当前没有正在进行的其他任务,则 setState 是“同步”的。
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 可以看做 componentDidMount,componentDidUpdate 和 componentWillUnmount 这三个生命周期函数的组合。
无需清除的 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)
useReducer和useState作用类似,但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
- 自定义 Hook 必须以 use 开头
- 自定义 Hook 只是逻辑复用,其中的 state 是不会共享的。
- 自定义 Hook 内部可以调用其他 Hook。
- 避免过早的拆分抽象逻辑
💡 自定义数据获取 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 函数而已。
但我们可以利用 useState、 useEffect() 和 useLayoutEffect() 来模拟实现生命周期。
我们可以为 class 组件声明一些特殊的方法,当组件挂载或卸载时就会去执行这些方法,这些方法就叫做生命周期方法
每个组件都包含 “生命周期方法”,你可以重写这些方法,以便于在运行过程中特定的阶段执行这些方法
class 组件的生命周期
我们可以将生命周期分为三个阶段:
- 挂载阶段
- 组件更新阶段
- 卸载阶段
-
挂载
当组件实例被创建并插入 DOM 中时,其生命周期调用顺序如下:
constructor():构造函数(初始化),注意避免将 props 的值复制给 statestatic getDerivedStateFromProps():从props中获取派生的state,用来代替componentWillMountrender():组件挂载中,react 最重要的步骤,创建虚拟 dom,进行 diff 算法,更新 dom 树都在此进行componentDidMount():组件完成挂载
-
更新
当组件的 props 或 state 发生变化时会触发更新。组件更新的生命周期调用顺序如下:
static getDerivedStateFromProps():配合componentDidUpdate,可以覆盖componentWillReceiveProps的所有用法shouldComponentUpdate():判断是否进行状态更新render():组件挂载中getSnapshotBeforeUpdate:获取 DOM 更新前的快照componentDidUpdate():完成更新
-
卸载
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,componentDidUpdate:useLayoutEffect与它们两的调用阶段是一样的。但是,推荐你一开始先用useEffect,只有当它出问题的时候再尝试使用useLayoutEffect。useEffect可以表达所有这些的组合。// 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 执行的内容 } }, []) -
componentDidCatchandgetDerivedStateFromError:目前还没有这些方法的 Hook 等价写法,但很快会加上。
为方便记忆,大致汇总成表格如下。
| class 组件 | Hooks 组件 |
|---|---|
| constructor | useState |
| getDerivedStateFromProps | useState 返回的 update 函数 |
| shouldComponentUpdate | useMemo |
| render | 函数本身 |
| componentDidMount | useEffect |
| componentDidUpdate | useEffect |
| componentWillUnmount | useEffect 里面返回的函数 |
| componentDidCatch | 无 |
| getDerivedStateFromError | 无 |
多个组件的执行顺序
父子组件
-
挂载阶段
分 两个 阶段:
- 第 一 阶段,由父组件开始执行到自身的
render,解析其下有哪些子组件需要渲染,并对其中 同步的子组件 进行创建,按 递归顺序 挨个执行各个子组件至render,生成到父子组件对应的 Virtual DOM 树,并 commit 到 DOM。 - 第 二 阶段,此时 DOM 节点已经生成完毕,组件挂载完成,开始后续流程。先依次触发同步子组件各自的
componentDidMount,最后触发父组件的。
注意:如果父组件中包含异步子组件,则会在父组件挂载完成后被创建。
所以执行顺序是:
父组件 getDerivedStateFromProps —> 同步子组件 getDerivedStateFromProps —> 同步子组件 componentDidMount —> 父组件 componentDidMount —> 异步子组件 getDerivedStateFromProps —> 异步子组件 componentDidMount
- 第 一 阶段,由父组件开始执行到自身的
-
更新阶段
React 的设计遵循单向数据流模型 ,也就是说,数据均是由父组件流向子组件。
-
第 一 阶段,由父组件开始,执行
static getDerivedStateFromPropsshouldComponentUpdate
更新到自身的
render,解析其下有哪些子组件需要渲染,并对 子组件 进行创建,按 递归顺序 挨个执行各个子组件至render,生成到父子组件对应的 Virtual DOM 树,并与已有的 Virtual DOM 树 比较,计算出 Virtual DOM 真正变化的部分 ,并只针对该部分进行的原生DOM操作。 -
第 二 阶段,此时 DOM 节点已经生成完毕,组件挂载完成,开始后续流程。先依次触发同步子组件以下函数,最后触发父组件的。
getSnapshotBeforeUpdate()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则为包含路由/homehttp://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组件包裹住所有的 Route 和 Redirect,当出现多个匹配的路由时,只会渲染第一个匹配的组件。
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")
);
有两种路由器组件,分别是HashRouter 与 BrowserRouter(即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 变得更加方便