React 面试题: 不一定最全但绝对值得收藏!!(1 ~ 10) (万字总结)

28,450 阅读38分钟

引言

最近在准备面试, 所以整理了些常见的 React 相关的面试题!!!! 有需求的欢迎 👏🏻👏🏻 点赞、收藏, 同时欢迎 👏🏻👏🏻 大家在评论区留下面试中经常被问到的问题, 一起讨论讨论(我也可以悄摸记下准备准备)!! 最后文章写得仓促如果错误, 请多多见谅!!

补充: 慢慢的 React 相关题目整理越来越多, 并且目前 掘金 编辑器不知道为啥内容多了编辑、修改起来很卡, 所以就针对该系列内容做了拆分(每篇 10 题)!!!!

更多专栏内容:

一、 类组件生命周期

1.1 React v16.0 前的生命周期

image

  1. 挂载阶段:
  • constructor(构造函数)
  • componentWillMount(组件将要渲染)
  • render(渲染组件)
  • componentDidMount(组件渲染完成)
  1. 更新阶段: 分两种情况一种是 state 更新、一种是 props 更新
  • componentWillReceiveProps(组件 props 变更)
  • shouldComponentUpdate(组件是否渲染)
  • componentWillUpdate(组件将要更新)
  • render(渲染组件)
  • componentDidUpdate(组件更新完成)
  1. 卸载阶段:
  • componentWillUnmount(组件将要卸载)

1.2 React v16.0 后的生命周期

image

  • 删除了几个 will 相关的生命周期(原因下面解释)
  • 新增了两个生命周期 getDerivedStateFromProps getSnapshotBeforeUpdate
  1. 挂载阶段:
  • constructor(构造函数)
  • getDerivedStateFromProps(派生 props)
  • render(渲染组件)
  • componentDidMount(组件渲染完成)
  1. 更新阶段:
  • getDerivedStateFromProps(派生 props)
  • shouldComponentUpdate(组件是否渲染)
  • render(渲染组件)
  • getSnapshotBeforeUpdate(获取快照)
  • componentDidUpdate(组件更新完成)
  1. 卸载阶段:
  • componentWillUnmount(组件将要卸载)

1.3 getDerivedStateFromProps

getDerivedStateFromProps 首先它是 静态 方法, 方法参数分别下一个 props、上一个 state, 这个生命周期函数是为了替代 componentWillReceiveProps 而存在的, 主要作用就是监听 props 然后修改当前组件的 state

// 监听 props 如果返回非空值, 则将返回值作为新的 state 否则不进行任何处理
static getDerivedStateFromProps(nextProps, prevState) {
  const { type } = nextProps;

  // 返回 nuyll: 对于 state 不进行任何操作
  if (type === prevState.type) {
    return null;
  }

  // 返回具体指则更新 state
  return { type }
}

1.4 getSnapshotBeforeUpdate

getSnapshotBeforeUpdate 生命周期将在 render 之后 DOM 变更之前被调用, 此生命周期的返回值将作为 componentDidUpdate 的第三个参数进行传递, 当然通常不需要此生命周期, 但在重新渲染期间需要手动保留 DOM 信息时就特别有用

getSnapshotBeforeUpdate(prevProps, prevState){
  console.log(5);
  return 999;
}

componentDidUpdate(prevProps, prevState, snapshot) {
  console.log(6, snapshot);
}

打印结果:

5
6 999

缘由:

  • 大多数开发者使用 componentWillUpdate 的场景是配合 componentDidUpdate, 分别获取 渲染 前后的视图状态, 进行必要的处理, 但随着 React 异步渲染 等机制的到来, 渲染 过程可以被分割成多次完成, 还可以被 暂停 甚至 回溯, 这导致 componentWillUpdatecomponentDidUpdate 执行前后可能会间隔很长时间, 足够使用户进行交互操作更改当前组件的状态, 这样可能会导致难以追踪的 BUG
    • 所以就新增了 getSnapshotBeforeUpdate 生命周期, 目的就是就是为了解决上述问题并取代 componentWillUpdate, 因为 getSnapshotBeforeUpdate 方法是在 componentWillUpdate 后(如果存在的话), 在 React 真正更改 DOM 前调用的, 它获取到组件状态信息会更加可靠
    • 除此之外, getSnapshotBeforeUpdate 还有一个十分明显的好处: 它调用的结果会作为第三个参数传入 componentDidUpdate 避免了 componentWillUpdatecomponentDidUpdate 配合使用时将组件临时的状态数据存在组件实例上浪费内存
    • 同时 getSnapshotBeforeUpdate 返回的数据在 componentDidUpdate 中用完即被销毁, 效率更高

1.5 React v16.0 之后为什么要删除 Will 相关生命周期

  1. 被删除的生命周期:
  • componentWillReceiveProps
  • componentWillMount
  • componentWillUpdate
  1. 删除原因:
  • 这些生命周期方法经常被误解和巧妙地误用
  • 它们的潜在误用可能会在异步渲染中带来更多问题, 所以如果现有项目中使用了这几个生命周期, 将会在控制台输出如下警告! 大致意思就是这几个生命周期将在 18.x 彻底下线, 如果一定要使用可以带上 UNSAFE_ 前缀

image

  1. 为何移除 componentWillMount: 因为在 异步渲染机制 中允许对组件进行中断停止等操作, 可能会导致单个组件实例 componentWillMount 被多次调用, 很多开发者目前会将事件绑定、异步请求等写在 componentWillMount 中, 一旦异步渲染时 componentWillMount 被多次调用, 将会导致:
  • 进行重复的事件监听, 无法正常取消重复的事件, 严重点可能会导致内存泄漏
  • 发出重复的异步网络请求, 导致 IO 资源被浪费
  • 补充: 现在, React 推荐将原本在 componentWillMount 中的网络请求移到 componentDidMount 中, 至于这样会不会导致请求被延迟发出影响用户体验, React 团队是这么解释的: componentWillMountrendercomponentDidMount 方法虽然存在调用先后顺序, 但在大多数情况下, 几乎都是在很短的时间内先后执行完毕, 几乎不会对用户体验产生影响。
  1. 为何移除 componentWillUpdate:
  • 大多数开发者使用 componentWillUpdate 的场景是配合 componentDidUpdate, 分别获取 渲染 前后的视图状态, 进行必要的处理, 但随着 React 异步渲染 等机制的到来, 渲染 过程可以被分割成多次完成, 还可以被 暂停 甚至 回溯, 这导致 componentWillUpdatecomponentDidUpdate 执行前后可能会间隔很长时间, 足够使用户进行交互操作更改当前组件的状态, 这样可能会导致难以追踪的 BUG
  • 所以后面新增了 getSnapshotBeforeUpdate 生命周期, 目的就是就是为了解决上述问题并取代 componentWillUpdate, 因为 getSnapshotBeforeUpdate 方法是在 componentWillUpdate 后(如果存在的话), 在 React 真正更改 DOM 前调用的, 它获取到组件状态信息会更加可靠
  • 除此之外, getSnapshotBeforeUpdate 还有一个十分明显的好处: 它的返回结果会作为 componentDidUpdate 的第三个参数进行传递, 从而避免了 componentWillUpdatecomponentDidUpdate 配合使用时将组件临时的状态数据存在组件实例上引起的浪费内存
  • 同时 getSnapshotBeforeUpdate 返回的数据在 componentDidUpdate 中用完即被销毁, 效率更高

参考:

1.6 异步渲染

  1. 时间分片 (Time Slicing):
  • Time SlicingFiber 的完全体形态, React渲染 的时候, 会将任务拆分成多个小任务, 这些细分的任务则会在主线程空闲的时候进行执行, 在执行任务的期间可以随时进行暂停
  • 使用时间切片的缺点是, 任务运行的总时间变长了, 这是因为它每处理完一个小任务后, 主线程会空闲出来, 并且在下一个小任务开始处理之前有一小段延迟, 但是为了避免卡死浏览器, 这种取舍是很有必要的
  • 这里使用到了一个原生的 API, window.requestIdleCallback() 该方法参数是一个回调函数, 这个函数将在浏览器空闲时期被调用, 这使开发者能够在主事件循环上执行后台和低优先级工作, 而不会影响延迟关键事件, 如动画和输入响应
  • 更多参考: 时间切片 (Time Slicing)
  1. 悬停或者暂停 (Suspense): 调用 render 函数 -> 发现有异步请求 -> 悬停, 等待异步请求结果 -> 再渲染展示数据
  • render 函数中, 我们可以写入一个异步请求, 请求数据
  • react 会从我们缓存中读取这个缓存
  • 如果有缓存了, 直接进行正常的 render
  • 如果没有缓存, 那么会抛出一个 异常, 这个异常是一个 promise(很有意思, 通过抛出异常来实现)
  • 当这个 promise 完成后(请求数据完成), react 会继续回到原来的 render 中 (实际上是重新执行一遍 render), 把数据render 出来
  • 完全同步写法, 没有任何异步 callback 之类的东西
import { Suspense } from 'react'

const Spinner = () => {}
const ProfilePage = () => {}

<Suspense fallback={<Spinner />}>
  <ProfilePage />
</Suspense>

Suspense 的核心概念与错误边界非常相似, 错误边界能够在应用的任何地方捕捉未捕获的异常, 来处理从该组件下面抛出的所有异常。无独有偶, Suspense 组件捕获任何由子组件抛出的异常(Promise), 不同的是我们并不需要一个特定的组件来充当边界, 因为 Suspense 组件自己就是, 它可以让我们定义 fallback 来决定后备的渲染组件

二、虚拟 DOM

2.1 是什么?

虚拟 DOM: 本质上就是一个 JS 对象, 通过一个对象来描述了每个 DOM 节点的特征, 并且通过虚拟 DOM 就能够完整的绘制出对应真实的 DOM, 如下代码, 我们尝试将虚拟 DOM ele 打印出来, 看下对应的数据结构:

const ele = (
  <div className='xxx'>
    111
  </div>
);

console.log(ele);

image

那么问题来了, 为什么 JS 中能够识别 JSX 呢? 这里其实还得多亏了 babel, 通过 babelreact 预设包(@babel/preset-react), 我们就可以对 JSX 进行转换: JSX 转为 React.createElement(...)

// 转换前
const ele = (
  <div className='xxx'>
    111
  </div>
);

console.log(ele);
// 转换后
React.createElement("div", { class: "xxx" }, "111")

2.2 虚拟 DOM 好处

虚拟 DOM 设计的核心就是用高效的 js 操作, 来减少低性能的 DOM 操作, 以此来提升网页性能, 然后使用 diff 算法对比新旧虚拟 DOM, 针对差异之处进行重新构建更新视图, 以此来提高页面性能, 虚拟 DOM 这让我们更关注我们的业务逻辑而非 DOM 操作, 这一点即可大大提升我们的开发效率

  • 虚拟 DOM 本质上就是个对象, 对其进行任何操作不会引起页面的绘制
  • 一次性更新: 当页面频繁操作时, 不去频繁操作真实 DOM, 而是构建新的虚拟 DOM 对虚拟 DOM 进行频繁操作, 然后一次性渲染, 这将大大提高性能(因为操作 DOM 比操作 JS 代价更大, 后面有讲)
  • 差异化更新: 当状态改变时, 构建新的虚拟 DOM, 然后使用 diff 算法对比新旧虚拟 DOM, 针对差异之处进行重新构建更新视图, 这样也能够大大提高页面性能
  • 提高开发效率: 虚拟 DOM 本质上就是个对象, 相对于直接操作 DOM 来, 直接操作对象相对来说简单又高效
  • 虚拟 DOM 的总损耗等于 虚拟 DOM 增删改 + diff 算法 + 真实 DOM 差异增删改 + 排版与重绘
  • 真实 DOM 的总损耗是 真实 DOM 完全增删改 + 排版与重绘
  • 简单方便: 如果使用手动操作真实 DOM 来完成页面, 繁琐又容易出错, 在大规模应用下维护起来也很困难
  • 性能方面: 使用虚拟 DOM, 能够有效避免真实 DOM 数频繁更新, 减少多次引起重绘与回流, 提高性能
  • 跨平台: 虚拟 DOM 本质上就是用一种数据结构来描述界面节点, 借助虚拟 DOM, 带来了跨平台的能力, 一套代码多端运行, 比如: 小程序、React Native

2.3 缺点

  • 极致性能: 在一些性能要求极高的应用中, 虚拟 DOM 无法进行针对性的极致优化: 因为从虚拟 DOM 到更新真实 DOM 之间还需要进行一些额外的计算(比如 diff 算法), 而这中间就多了一些消耗, 肯定没有直接操作 DOM 来得快
  • 首次渲染: 首次渲染大量 DOM 时, 需要将虚拟树转换为实际的 DOM 元素, 并插入到页面中, 这个过程需要额外的计算和操作, 可能会比直接操作实际 DOM 更慢
  • 适用度: 虚拟 DOM 需要在内存中创建和维护一个额外的虚拟树结构, 用于表示页面的状态。这可能会导致一定的内存消耗增加, 特别是在处理大型或复杂的应用程序时, 所以虚拟 DOM 更适用于动态或频繁变化的内容, 而对于静态内容 (几乎不会变化的部分), 虚拟 DOM 的优势可能不明显, 因为它仍然需要进行比较和更新的计算

2.4 虚拟 DOM 一定会比直接操作真实 DOM 快

  • 同样的功能, 在虚拟 DOM 中必须需要进行更多的计算、损耗, 所以从理论上来讲虚拟 DOM 只会更慢, 但这里其实有个前提, 前提就是操作真实 DOM 的方式要做到最优, 但是单单这一点对于大部分开发人员来说其实是很难的、而且就算做到了也要耗费很多精力, 同时也会增加维护成本;

  • 首次渲染或者所有节点都需要进行更新的时候, 这个时候采用虚拟 DOM 会比直接操作原生 DOM 多一重构建虚拟 DOM 树的操作, 这会更大的占用内存和延长渲染时间

  • 对于频繁更新、删除操作: 直接操作真实 DOM(没有经过优化, 直接操作整个 DOM 树)的情况下, 虚拟 DOM 也行会更快, 因为相对来说操作 DOM 的消耗会比操作 JS

  • 得失: 在构建一个实际应用的时候, 出于可维护性的考虑, 我们很难为每一个地方都去做手动优化吗, 但是呢? 虚拟 DOM 在不需要手动优化的情况下, 却能够给我们带来一系列的优化、同时带来更好的开发体验, 当然为此我们也只需要付出一点点性能

  • 总结: 操作真实 DOM 如果能做到最优, 那么必然会比虚拟 DOM 更快, 否则结果就不好说咯

贴个 babyfish-ct 大大网上都说操作真实 DOM 慢, 但测试结果却比 React 更快, 为什么? 中的一个评论:

  • 举个例子, 一个列表, 如果要添加一个项, 那么直接 insert 一个新的 DOM 元素, 肯定最快。
  • 但是, 人性是懒惰的, 大部分人并不会直接基于原生 DOM 实现增量操作, 因为面向增量编程是痛苦的, 而面向全量编程是开心的。
  • 在这种懒惰的驱使下, 人们 会选择简单粗暴的办法, 把 list 下面所有项目清掉, 从新创建所有子项目。这样, 只是一个简单的循环, 不用考虑变化发生在什么位置。
  • 但为了一些局部变更, 把整个个列表子项全部清除再全部重建, 性能可想而知
  • 虚拟 DOM 的真正价值, 是把懒惰的人们喜欢的而面向全量编程, 转换为针对真实 DOM 的增量操作 (通过 diff, 找出发生变化的地方), 并保证这个过程引入的性能损失极可能低。即: 虚拟 DOM 以相对少的性能开销为代价, 让人们在不自不觉中以最高性能的方式操作真实 DOM。但和本身就坚持以最优方式操作真实 DOM 的程序相比, 其实它只会更慢。

2.5 为什么操作 DOM 会 JS 代价更大

  1. 对比:
  • 访问和修改 DOM 元素需要通过浏览器的底层接口提供的 API 来实现的, 与直接在内存中操作 JavaScript 对象相比, 通过浏览器接口进行 DOM 操作涉及到更多的层级和复杂性, 从而导致性能开销增加

  • DOM 操作引起页面重新渲染和重排, 当对 DOM 元素进行修改时, 浏览器需要重新计算元素的布局和样式, 并重新渲染整个页面或部分页面。这个过程称为重排 (reflow) 和重绘 (repaint), 它对于页面的性能和响应时间有一定的影响, 增加了页面的负担和性能开销

  1. 为了减少对 DOM 操作的代价, 可以采取以下优化措施:
  • 批量操作: 将多个 DOM 操作合并成一个批量操作, 减少页面的重排和重绘次数
  • 使用文档片段 (DocumentFragment): 将多个 DOM 元素的操作放在文档片段中, 然后一次性插入到页面中, 减少页面渲染的次数
  • 缓存 DOM 查询结果: 避免多次查询同一个 DOM 元素, 将查询结果缓存在变量中以提高性能。
  • 使用事件委托: 将事件处理程序绑定在父元素上, 通过事件冒泡机制处理子元素的事件, 减少事件绑定的数量

总的来说, 由于 DOM 操作涉及到浏览器底层接口、页面重排和重绘等因素, 相比于操作 JavaScript 对象, 其代价较大, 因此, 在编写网页或应用程序时, 应尽量减少对 DOM 的频繁操作, 优化 DOM 操作的方式和时机, 以提高性能和用户体验

三、diff 算法

3.1 是什么?

React 在执行 render 过程中会产生新的虚拟 DOM, 在浏览器平台下, 为了尽量减少 DOM 的创建, React 会对新旧虚拟 DOM 进行 diff 算法找到它们之间的差异, 尽量复用 DOM 从而提高性能; 所以 diff 算法主要就是用于查找新旧虚拟 DOM 之间的差异

那么请问可以不做 diff 算法, 每次 render 都重新创建新的 DOM 是否可以? 当然没有问题, 但重点在于 DOM 创建的性能成本很高, 如果不做 DOM 的复用, 那性能就太差了, diff 算法的目的就是对比两次渲染结果, 找到可复用的部分, 然后剩下的该删除删除, 该新增新增

需要额外提一嘴的是, 传统 diff 算法是通过循环递归对树节点进行依次对比, 效率比低下, 算法复杂度达到 O(n^3), 而在 React 中针对该算法进行一个优化, 复杂度能达到 O(n)

3.2 diff 策略

  1. tree 层级(同层级比较): 考虑到在实际 DOM 操作中需要跨层级操作的次数很少很少, 所以在进行 diff 操作时只会对 同一层级 进行比较, 这样只需要对树遍历一次就 OK 了, 如下图, react 会按同层级进行比较, 发现新树中 R 节点下没有了 A, 那么直接删除 AD 节点下创建 A 以及下属所有节点

image

  1. component 层级: 如果是同一个类型的组件, 则会继续往下 diff 运算, 如果不是一个类型组件, 那么将直接删除这个组件下的所有子节点, 然后创建新的 DOM, 如下图所示, 当 D 类型组件换成了 G 后, 即使两者的结构非常类似, 也会将 D 类型的组件删除再重新创建 G

image

  1. element 层级: 是同一层级的节点的比较规则, 根据每个节点在对应层级的唯一 key 作为标识, 并且对于同一层级的节点操作只有 3 种, 分别为 INSERT_MARKUP(插入)、MOVE_EXISTING(移动)、REMOVE_NODE(删除)

image

如上场景比较规则: 通过 key 发现新旧集合中的节点都是相同的节点, 因此无需进行节点删除和创建, 只需要将旧集合中节点的位置进行移动, 更新为新集合中节点的位置即可, 判断伪代码如下, 参考资料查看 这里

const old = ['a', 'b', 'c', 'd']
const newList = ['b', 'a', 'd', 'c']

let maxIndex = 0

newList.forEach((v, index) => {
  const oldIndex = old.indexOf(v)

  maxIndex = Math.max(oldIndex, maxIndex)

  if (oldIndex < maxIndex) {
    // 移动: 将 v 节点移动到 index 处
    console.log(index, v)
  }
})

3.3 注意事项

  1. key 的值必须保证 唯一稳定, 有了 key 属性后, 就可以与组件建立了一种对应关系, react 根据 key 来决定是销毁还是重新创建组件, 是更新还是移动组件

  2. index 的使用存在的问题: 大部分情况下可能没有啥问题, 但是如果涉及到数据变更(更新、新增、删除), 这时 index 作为 key 会导致展示错误的数据, 其实归根结底, 使用 index 的问题在于两次渲染的 index 是相同的, 所以组件并不会重新销毁创建, 而是直接进行更新

  3. 下面写法的问题: 每次 renderCom 都重新声明, 导致在进行 diffCom 都会被认为是新的组件, 需要被销毁、重新创建

const App = () => {
  const Com = () => (<div>3</div>)

  return (
    <div>
      <div>1</div>
      <div>2</div>
      <Com />
      <div>4</div>
    </div>
  )
}

四、Render 相关

  1. 类组件 render 函数返回 JSX
class Foo extends React.Component {
  render() {
    return <h1> Foo </h1>;
  }
}
  1. 函数直接组件 returnJSX
function Foo() {
  return <h1> Foo </h1>;
}
  1. React 中, 我们会通过 babel 将我们会编写的 jsx 转化成我们熟悉的 js 格式, 这里会用到一个 babelreact 的预设 @babel/preset-react
// 编译前
return (
  <div className='cn'>
    <Header> hello </Header>
    <div> start </div>
    Right Reserve
  </div>
)

// 编译后
return (
  React.createElement(
    'div',
    {
      className : 'cn'
    },
    React.createElement(
      Header,
      null,
      'hello'
    ),
    React.createElement(
      'div',
      null,
      'start'
    ),
    'Right Reserve'
  )
)
  1. 我们都知道如果在 js 文件中写了 jsx, 就需要再顶部引入 React, 而之所以要引入 React 从上面 👆🏻 编译结果也能看出来, JSX 将会被编译为 React.createElement 如果不引入将会报错(React 未定义)

  2. React 17 不再需要引入在组件中显式地引入 React 这又是为什么呢?

  • React 更新引入了 react/jsx-runtime, 改变了 JSX 编译模式, 不再是 React.createElement
_jsx('h1', { children: 'Hello world' });
  • 同时编译工具(react 的预设 @babel/preset-react), 针对 jsx 不但会帮我们进行编译, 还会帮我们手动引入所需要的包
// 由编译器引入(禁止自己引入!)
import { jsx as _jsx } from 'react/jsx-runtime';

function App() {
  return _jsx('h1', { children: 'Hello world' });
}
  • 那早期版本是不是更新了 @babel/preset-react 也可以不需要手动引入? 不可以, 因为这里是使用新的编译方式, 旧的版本并不支持
  1. 渲染流程
  • state 或者 props 更新, 会触发 render, 当然这里也有例外(props 可通过 shouldComponentUpdatememo 进行控制, 并且在 useState 中如果设置了相同的 state 也不会触发 render)
  • 每次 render 时, 整个 UI 都将以 虚拟 DOM 的形式进行呈现
  • 使用 diif 算法, 计算新旧 虚拟 DOM 对象之间的差异
  • 计算完成, 将只更新实际更改的真实 DOM 节点

五、React 事件机制

参考: toutiao.io/posts/28of1…

5.1 原生事件和 React 事件监听方法:

  • React 事件通过 JSX 方式绑定的事件, 比如 onClick={() => this.handle()}
  • 原生事件使用 addEventListener
const ref = useRef()
const onClick = useCallback(() => {
}. []);

useEffect(() => {
  // 绑定原生事件
  ref.current.addEventListener('click', event => {});
}, []);

return (
  <div
    ref={ref}
    onClick={onClick} // React 事件
  />
);

5.2 合成事件

如下代码 e 就是所谓的合成事件, 它并不是原生的一个 事件对象, 而是 React 根据 W3C 规范定义出来的一个合成事件, 所以使用合成事件对象我们就不需要担心浏览器的兼容性问题了, 同时如果我们想要访问原生的事件对象, 可通过 nativeEvent 属性来获取

function Form() {
  function handleSubmit(e) {
    e.preventDefault();
  }
  return (
    <form onSubmit={handleSubmit}>
      <button type="submit">Submit</button>
    </form>
  );
}

补充: 从 v0.14 开始, 事件处理函数, 返回 false 时, 不再阻止事件传递, 这里需要手动调用 e.stopPropagation()e.preventDefault() 作为替代方案

5.3 对原生事件的升级和改造

  1. react 在给注册事件的时候也是对浏览器兼容性处理

image

  1. 对于有些 dom 元素事件, 我们进行事件绑定之后, react 并不是只绑定处理我们所声明的事件类型, 还会额外的增加一些其他的事件, 帮助我们提升交互的体验, 这里就举一个例子来说明下:

我们都知道, 在原生事件中对于 input 我们如果只绑定 onchange 事件, 那么在持续输入时是无法触发该事件的, 只有在失去焦点时才会触发该事件! 但这个大部分情况下并不是一个好的体验! 所以在 React 中我们如果为 input 绑定 onChange 事件, 实际上 React 并不是只注册了 onchange 事件, 还会帮我们添加额外的事件, 做很多处理, 来弥补这个缺陷, 使得我们在每次输入内容时都能够正确触发 onChange 事件

import React, { useRef, useEffect } from 'react';

export default () => {
  const inputRef = useRef();

  useEffect(() => {
    const handler = (e) => {
      console.log('手动绑定:', e.target.value);
    };

    inputRef.current.addEventListener('change', handler);

    return () => document.removeEventListener('change', handler);
  }, []);

  return (
    <input
      ref={inputRef}
      onChange={(e) => console.log('React 绑定事件: ', e.target.value)}
    />
  );
};

image

5.4 事件注册机制

  1. 通过 事件委托 的方式, 将所有事件都绑定在了 document 来进行统一处理
  2. 每次绑定都会将事件处理函数, 存储起来

image

  1. 问: 对于同一个 DOM 分别绑定原生事件、合成事件, 在原生事件中阻止事件冒泡为什么会阻止合成事件的执行?

答: 合成事件是事件委托的一种实现, 主要是利用事件冒泡机制将所有事件在 document 进行统一处理, 根据 事件流, 事件执行顺序为 捕获阶段目标阶段冒泡阶段, 当我们在原生事件上阻止事件冒泡, 那么事件就无法冒泡到 document, 那么合成事件自然无法执行!

const ref = useRef()

const onClick = event => {
  event.stopPropagation();
  console.log('[ 合成事件 ]', event);
};

useEffect(() => {
  ref.current.addEventListener('click', event => {
    event.stopPropagation();
    console.log('[ 原生事件 ]', event);
  });
}, []);

return (
  <div 
    ref={ref} 
    onClick={onClick} 
  />
);

补充: 会先执行原生事件,然后处理 React 事件 原生事件(阻止冒泡)会阻止合成事件的执行 合成事件(阻止冒泡)不会阻止原生事件的执行 所以两者最好不要混合使用, 避免出现一些奇怪的问题

  1. 问: React 为什么要将所有事件绑定在 document 上, 这么做有什么优缺点吗?

优点:

  • 减少事件注册, 减少内存消耗, 提升性能, 不需要注册那么多的事件了, 一种事件类型只在 document 上注册一次即可; 举个例子, 若有 10w 项列表, 点击列表某一项要提示这一列表的某个信息, 若在每一个 li 节点挂载事件, 10w 个事件将会极大程度上拖慢你的浏览器性能
  • 统一处理, 并提供合成事件对象, 抹平浏览器的兼容性差异

缺点: 如果层级过多, 冒泡过程中可能会被某层给阻止掉

  1. v17.0.0 开始, React 不再将事件处理添加到 document 上, 而是将事件处理添加到渲染 React 树的根容器中这又是为什么呢?
  • 如果页面上有多个 React 版本, 事件都会被附加在 document 上, 这时嵌套的 React 树调用 e.stopPropagation() 停止了事件冒泡, 外部的树仍会接收到该事件(因为只是阻止了 React 事件的冒泡), 这就使嵌套不同版本的 React 难以实现

  • 如果你系统只用了一个 react 版本, 那没啥区别; 但有些复杂的系统, 由于历史原因, 或者用了微前端, 它就同时用很多个版本的 react, 这就不一样了, 如果很多个版本的 react, 都往 document 上去绑定, 就容易出现混乱

六、Fiber

6.1 缘由

  1. 首先 React 组件的渲染主要经历两个阶段:
  • 调度阶段(Reconciler): 这个阶段 React 用新数据生成新的虚拟 DOM, 遍历虚拟 DOM, 然后通过 Diff 算法, 快速找出需要更新的元素, 放到更新队列中去
  • 渲染阶段(Renderer): 这个阶段 React 根据所在的渲染环境, 遍历更新队列, 将对应元素更新(在浏览器中, 就是更新对应的 DOM 元素)
  1. 对于调度阶段, 新老架构中有不同的处理方式:
  • 早期 16 之前 Reactdiff 阶段是通过一个自顶向下递归算法, 来查找需要对当前 DOM 进行更新或替换的操作列表, 一旦开始, 会持续占用主线程, 很难被中断, 当虚拟 DOM 特别庞大的时候, 主线程就被长期占用, 页面的交互、布局、渲染会被停止, 造成页面的卡顿, 这里举个例子: 假设更新一个组件需要 1ms, 如果有 200 个组件要更新, 那就需要 200ms, 在这 200ms 的更新过程中, 浏览器唯一的主线程都在专心运行更新操作, 无暇去做任何其他的事情。想象一下,在这 200ms 内,用户往一个 input 元素中输入点什么,敲击键盘也不会获得响应,因为渲染输入按键结果也是浏览器主线程的工作,但是浏览器主线程被 React 占用,抽不出空,最后的结果就是用户敲了按键看不到反应,等 React 更新过程结束之后,那些按键会一下出现在 input 元素里,这就是所谓的界面卡顿。
  • FiberReact 16 中采用的新的调度处理方法, 主要目标是支持虚拟 DOM 的一个渐进式渲染

6.2 Fiber 的设计思路

因为浏览器的页面是一帧一帧绘制出来的, 当每秒绘制的帧数(FPS)达到 60 时, 页面是流畅的, 小于这个值时, 用户会感觉到卡顿; 转换成时间就是 16ms(1000 / 60) 内如果当前帧内执行的任务没有完成, 就会造成卡顿;

  1. Fiber: 是实现了一个基于优先级和 requestIdleCallback(执行的前提条件是当前浏览器处于空闲状态) 的一个循环 任务调度 算法, 他在渲染虚拟 DOMdiff 阶段将任务拆分为多个小任务、这样的话就可以随时进行中止和恢复、同时又根据每个任务的优先级来执行任务

  2. Fiber 是把 render/update 分片, 拆解成多个小任务来执行, 每次只检查树上部分节点, 做完此部分后, 若当前一帧 (16ms) 内还有足够的时间就继续做下一个小任务, 时间不够就停止操作, 等主线程空闲时再恢复

  3. Fiber 是根据一个 fiber 节点 (VDOM 节点) 进行来拆分, 以 fiber node 为一个任务单元, 一个组件实例都是一个任务单元, 任务循环中, 每处理完一个 fiber node, 可以中断/挂起/恢复

  4. 不同的任务分配不同的优先级, Fiber 根据任务的优先级来动态调整任务调度, 先做高优先级的任务

  • Immediate: 最高优先级, 会马上执行的不能中断
  • UserBlocking: 这一般是用户交互的结果, 需要及时反馈
  • Normal: 普通等级的, 比如网络请求等不需要用户立即感受到变化的
  • Low: 低优先级的, 这种任务可以延后, 但最后始终是要执行的
  • Idle: 最低等级的任务, 可以被无限延迟的, 比如 console.log()

6.3 带来的影响

由于 Fiber 采用了全新的调度方式, 任务的更新过程可能会被打断, 这意味着在组件更新过程中, render 及其下面几个生命周期函数可能会被调用多次, 所以这几个生命周期函数中不应出现副作用:

同时考虑到 componentWillMount componentWillReceiveProps componentWillUpdate 这几个生命周期经常被误用, 所以干脆就废弃了, 同时新增了几个生命周期用于替代(这里具体可参考上文中, 生命周期部分)

  • shouldComponentUpdate
  • componentWillMount(UNSAFE_componentWillMount)
  • componentWillReceiveProps(UNSAFE_componentWillReceiveProps)
  • componentWillUpdate(UNSAFE_componentWillUpdate)

6.4 React 调度流程图

image

6.5 参考

七、React State 那些事

7.1 是什么

一个组件的显示形态, 可以由 内部状态外部参数 所决定, 外部参数 指的则是 props内部状态 则是 state, 同时需要注意的是只有通过 setState 或者 useState 中指定的方法修改状态才会触发 render

export default () => {
  const [count, setCount] = useState(1);

  const handleClick = useCallback(() => {
    setCount(count + 1);
  }, [count]);

  return (
    <div onClick={handleClick}>
      {count}
    </div>
  );
};

注意的是, setState 或者 useState 中修改状态的方法, 它们的第一个参数还可以是一个函数, 函数的参数是当前的状态, 同时函数的返回值将最为新的状态值

// 类组件
this.setState((pre) => ({ count: pre.count + 1 }));

// 函数组件
const [count, setCount] = useState(1);
setCount((pre) => (pre + 1));

同时, setState 还有第二个参数, 当状态更新后, 并且组件已经重新渲染的时候会被调用, 一般用于获取修改后的状态

handleClick = () => {
  this.setState(
    (pre) => ({ count: pre.count + 1 }),
    () => {
      // 获取修改后的状态
      this.preState = this.state;
    },
  );
};

7.2 React 的更新机制: 异步 OR 同步

  1. 常见答案:
  • 在组件生命周期或 React 事件中, setState 是异步
  • setTimeout/setInterval 或者原生 dom 事件中, setState 是同步
  1. 本质上来讲 setState 是同步的, 之所以出现异步的假象是因为要进行 状态合并 或者说是 批处理, 需要等生命周期、事件处理器执行完毕, 再批量修改状态! 当然在实际开发中, 在合成事件和生命周期函数里, 完全可以将其视为异步的

  2. setState 机制:

  • ReactsetState 函数实现中, 会根据一个变量 isBatchingUpdates 判断是直接更新 this.state 还是放到队列中回头再说
  • isBatchingUpdates 默认是 false, 当 React 在执行生命周期或调用事件处理函数之前会将其设置为 true, 当执行完生命周期或者事件处理函数再改为 false 然后才会一起更新状态、更新组件, 所以整个过程看起来像异步的
  1. 当然实际开发中如果需要, 我们可以通过第二个参数 setState(partialState, callback) 中的 callback 拿到更新后的结果

  2. 在原生事件中, 由于不会调用 React 批处理机制, 所以 isBatchingUpdates 一直是 false, 所以如果调用 setState 会直接更新 this.state, 整个过程看起来就像是同步

  3. 那么在 setTimeout/setInterval 中又为什么看起来像同步的呢? 这里主要和微任务和宏任务有关, 如下是个演示代码, setTimeout 里面回调会等到, 主体代码执行完才会执行, 这时 isBatchingUpdates 已经是 false, 这时执行 setState 后会直接修改 this.state, 所以整个过程看起来就像是同步

isBatchingUpdates = true

// 即便延时为 0 也要主体代码全部执行完, 才会执行回调函数里面的代码, 这时 isBatchingUpdates 已经被改为 false, 
setTimeout(() => {
  this.setState({
    count: this.state.count + 1
  })
}, 0)

isBatchingUpdates = false
  1. 补充说明: React 18 中, 实现自动批处理, 所以不管任何情况所有状态的修改都会进行批处理了, 简单理解就是在 React 18 所以状态的修改都将是 "异步" 的, 具体参考 《React 面试题: 不一定最全但绝对值得收藏!!(11 ~ 20)》第九节 自动批处理部分内容

7.3 为什么要设计成异步(批处理)

参考: React 中 setState() 为什么是异步的?

  1. 保证 stateprops 的一致性
  • props 必然异步, 因为只有因为当父组件重渲染了我们才知道 props 是啥
  • 那么保证 propsstate 一致性就很重要了, 因为实际开发中我们经常会将状态提升到父组件, 和兄弟组件进行共享, 这时如果 state 和 props 表现不一致那么这个操作很大概率就会引起一些 bug
  • 所以 React 更愿意保证内部的一致性和状态提升的安全性, 而不总是追求代码的简洁性
  1. 提高性能: 在渲染前会有意地进行 等待, 直到所有在组件的事件处理函数内调用的 setState() 完成之后, 统一更新 state, 这样可以通过避免不必要的重新渲染来提升性能

  2. 更多的可能性: 当切换当新页面, 通过 setState 异步, 让 React 幕后渲染页面

八、高阶组件

高阶组件: 是 React 中用于复用组件逻辑的一种技巧, 是一种基于 React 特性而形成的设计模式

8.1 简述

  1. 本质: 本质上就是一个函数, 是一个参数为组件, 返回值为新组件的函数
  2. 高阶组件内部实现方式:
  • 属性代理: 创建新组件并渲染传入的组件, 通过 props 属性来为组件添加值或方法
  • 反向继承: 通过继承方式实现, 继承传人的组件, 然后新增一些方法、属性
// 方法一
const hoc1 = (Com) => {
  class NewCom extends Component {
    state = { count: 0 };

    updateCount = (count) => {
      this.setState({ count });
    };

    render () {
      return (
        <Com
          {...this.props}
          count={this.state.count}
          updateCount={this.updateCount}
        />
      );
    }
  }

  return NewCom;
};

// 方法二, 继承
const hoc2 = (Com) => {
  class NewCom extends Com {
    updateCount = (count) => {
      this.setState({ count });
    };
  }

  return NewCom;
};

  1. 调用方式:
  • @修饰符
  • 直接调用
// 使用修饰符
@hoc
class App extends {}


// 直接调用
class App extends {}
const AppUseHoc = hoc(App)

8.2 作用

  1. 强化 props: 类似 withRouter 为组件添加 props 属性, 强化组件功能
  2. 劫持控制渲染逻辑: 通过反向继承方式, 拦截原组件的生命周期、渲染、内部组件状态...
  3. 动态加载组件, 根据 props 属性, 动态渲染组件, 比如添加 logding、错误处理等待...
  4. 为组件添加事件: 为传入的组件包裹一层, 并绑定事件

8.3 注意事项(缺点)

  1. 高阶组件内部, 尽量不要试图通过继承的方式, 修改传入的组件, 那样可能会拦截原组件的生命周期、渲染、内部组件状态, 从而引起不必要的麻烦
  2. 透传与自身无关的 props, 同时需要避免属性的覆盖问题
  3. 不要在 render 方法中使用高阶组件: 在 render 中使用, 每次渲染都会重新生成新的组件, 造成不必要的卸载、挂载, 会造成性能问题, 而且重新挂载会导致组件以及子组件状态的丢失
  4. 务必复制静态属性(因为返回的是新的类, 原组件的静态属性会丢失): 手动绑定、或者使用 React 官方提供的工具
  5. Refs 不会被传递: 需要使用 React.forwardRef 进行处理

8.4 hooks 能取代 hoc 高阶组件吗?

完全替代是不能的(因为高阶组件被滥用了):

  1. 官方给出的答案是可以替代的, 因为高阶组件的出现主要目的就是为了复用状态相关逻辑(强化 props), 在这块 hooks 是可以完全替代的, 而还有其独到的优势
  2. 但是后来高阶组件除了用于逻辑的复用还被滥用:
  • 在内部实现动态渲染, 根据 props 动态渲染: 这个完全可以通过组件的方式来实现, 组件在渲染上拥有更高的自由度, 可以根据父组件提供的数据进行动态渲染
  • 通过继承拦截生命周期、或者篡改 props: 本身就不应该这么做, 容易出现各种问题 ...

8.5 缺点

  1. 属性代理方式的缺点:
  • 无法直接获取原始组件的状态, 需要通过 ref 获取
  • 无法直接继承静态属性,需要额外实现或者使用第三方库才行
  • ref 被隔断, 如果需要保持 ref 的正确指向,需要配合 forwardRef 转发 ref 到原始组件上
  1. 反向继承缺点:
  • 代理组件与原始组件高耦合
  • 函数组件无法使用
  • 嵌套使用有风险, 内层组件的生命周期会覆盖外层组件的生命周期
  1. 使用上: 高阶组件更像是一个黑盒子, 如下代码嵌套了很多层高阶组件, 同时 a b c d 这几个属性, 具体是在哪个高阶组件中被使用, 哪些是组件自身的, 如果不仔细查看代码完全是未知的
const Com = hoc3(hoc2(hoc1(App())))
<Com a="1" b="2" c="3" d="4" />
  1. 会产生无用的空组件, 加深层级组件多层嵌套, 增加复杂度与理解成本

  2. 重复命名的问题: 若父子组件有同样名称的 props, 或使用的多个 HOC 中存在相同名称的 props 则存在覆盖问题, 而且 react 并不会报错, 当然可以通过规范命名空间的方式避免

  3. 来源不清晰: 高阶组件是通过增强组件的 props (赋予一个新的属性或者方法到组件的 props 属性) 实现起来比较隐式, 如何使用了多个高阶组件, 你难以区分这个 props 是来自哪个高阶组件

  4. 高阶组件需要实例化一个父组件来实现, 不管是在代码量还是性能上, 都不如 hooks

  5. 依赖不清晰: 高阶组件对入参的依赖是隐式的, 入参发生在看不到的上层的高阶组件里面。

8.6 参考

九、render props

9.1 简述

render prop 是指在 React 组件中使用一个值为函数的属性(props)来渲染代码块的技术

  1. 组件允许通过属性传入一个函数, 该函数返回一个 React 元素
  2. 组件内部通过调用该函数, 来渲染部分内容
  3. 组件内调用函数时允许为函数传递任意参数, 可以是组件内部状态、方法、或其他任意数据
const renderHeader = (data) => {
  return (<h1>Hello {data.target}</h1>)
}

<DataProvider render={renderHeader}/>

9.2 好处

  1. render 函数可以通过参数, 可以拿到组件内部状态、方法、任意数据; 在方法内也可调用当前组件的状态、方法、props 等任何数据;

render 函数中, 既可以拿到父组件的数据、也可以拿到子组件的数据

renderDom = data => {
  // data 是组件内部调用时的传参, 可以是任意数据(状态、组件内方法、组件内的 Props、或其他数据)
  // 这里也能拿到当前组件的, 状态、方法、props等任何数据
  return <h1>Hello {data.target}</h1>
}
<DataProvider renderDom={this.renderDom}/>
  1. 可以进行组件的复用, 把组件无关的视图渲染逻辑抽象出来, 交给用户自己定义

9.3 注意点(缺点)

如果在 render 方法里直接创建函数, render prop 会使得 PureComponentshouldComponentUpdate 无效, 因为每次 render 总会重新创建函数, 导致浅比较总是返回 false

// 不推荐写法
render() {
  return <DataProvider renderDom={() => { }}/>
}

十、错误边界

10.1 简述

  • 默认情况下, 若一个组件在渲染期间 render 发生错误, 会导致整个组件树全部被卸载(页面白屏), 这当然不是我们期望的结果
  • 部分组件的错误不应该导致整个应用崩溃, 为了解决这个问题, React 16 引入了一个新的概念 —— 错误边界
  • 错误边界是一种 React 组件, 这种组件可以捕获发生在其子组件树任何位置的异常, 我们可以针对这些异常进行打印、上报等处理, 同时渲染出一个降级(备用) UI, 而并不会渲染那些发生崩溃的子组件树
  • 白话就是, 被错误边界包裹的组件, 内部如果发生异常会被错误边界捕获到, 那么这个组件就可以不被渲染, 而是渲染一个错误信息或者是一个友好提示!避免发生整个应该崩溃现象

10.2 实现代码

  1. componentDidCatch(): 捕获错误, 在这儿可以打印出错误信息、也可以对错误信息进行上报
  2. static getDerivedStateFromError(): 捕获错误, 返回一个对象, 更新 state
class ErrorBoundary extends React.Component {
  state = { hasError: false };

  static getDerivedStateFromError(error) {
    // 发生错误则: 更新 state
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    // 捕获到错误: 可以打印或者上报错误
    logErrorToMyService(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // 你可以自定义降级后的 UI 并渲染
      return <h1>深感抱歉, 系统出现错误!! 开发小哥正在紧急维护中.... </h1>;
    }
    return this.props.children; 
  }
}

// 错误边界使用
<ErrorBoundary>
  <MyWidget />
</ErrorBoundary>

10.3 注意事项(缺点)

  1. 错误边界目前只在类组件中实现了, 没有在 hooks 中实现: 因为 Error Boundaries 的实现借助了 this.setState 可以传递 callback 的特性, useState 无法传入回调, 所以无法完全对标
  2. 错误边界无法捕获以下四种场景中产生的错误: 仅处理渲染子组件期间的同步错误
  • 自身的错误
  • 异步的错误
  • 事件中的错误
  • 服务端渲染的错误
  1. 补充: 错误边界只能在类组件中实现了, 并不是指 Error BoundaryHooks 不生效, 而是指 Error Boundary 无法以 Hooks 方式指定, 但是对功能是没有影响! 你依然可以使用错误边界组件包裹使用了 hooks 的组件

本小节到处结束...

Group 3143