Jacleklm's Blog

React基本原理 & 一些特性 & 性能优化

2020/02/04

基本原理

Virtual Dom

虚拟 DOM 本质上是 JavaScript 对象,是对真实 DOM 的抽象,状态变更时,通过 diff 算法计算出记录新树和旧树的差异,最后把差异更新到真正的 dom 中

虚拟 DOM 的实现

相较于 DOM 来说,操作 JS 对象会快很多,并且我们也可以通过 JS 来模拟 DOM

1
2
3
4
5
6
7
8
9
10
const ul = {
tag: 'ul',
props: {
class: 'list'
},
children: {
tag: 'li',
children: '1'
}
}

上述代码对应的 DOM 就是

1
2
3
<ul class="list">
<li>1</li>
</ul>

那么既然 DOM 可以通过 JS 对象来模拟,反之也可以通过 JS 对象来渲染出对应的 DOM。难点在于如何判断新旧两个 JS 对象的最小差异并且实现局部更新 DOM。这就需要 Diff 算法了

虚拟 DOM 真的能提升性能吗?

使用虚拟 DOM,在 DOM 阶段操作少了通讯的确是变高效了,但代价是在 JS 阶段需要完成额外的工作(diff 计算),这项额外的工作是需要耗时的!
虚拟 DOM并不是说比原生 DOM API 的操作快,而是说不管数据怎么变化,都可以以最小的代价来进行更新 DOM。在每个点上,其实用手工的原生方法会比 diff 好很多。比如说仅仅是修改了一个属性,需要整体重绘吗?显然这不是虚拟 DOM 提出来的意义。框架的意义在于掩盖底层的 DOM 操作,用更声明式的方式来描述,从而让代码更容易维护。

diff 算法

详解
首先 DOM 是一个多叉树的结构,如果需要完整的对比两颗树的差异,那么需要的时间复杂度会是 O(n3)。React 团队优化了算法,实现了 O(n) 的复杂度来对比差异。 实现 O(n) 复杂度的关键就是只对比同层的节点,而不是跨层对比,这也是考虑到在实际业务中很少会去跨层的移动 DOM 元素。 所以判断差异的算法就分为了两步

  • 首先从上至下,从左往右遍历对象,也就是树的深度遍历,这一步中会给每个节点添加索引 ,便于最后渲染差异
  • 一旦节点有子元素,就去判断子元素是否有不同

在第一步算法中,需要判断新旧节点的 tagName 是否相同,如果不相同的话就代表节点被替换了。如果没有更改 tagName 的话,就需要判断是否有子元素,有的话就进行第二步算法。
在第二步算法中,需要判断原本的列表中是否有节点被移除,在新的列表中需要判断是否有新的节点加入,还需要判断节点是否有移动。
举个例子来说,假设页面中只有一个列表,我们对列表中的元素进行了变更

1
2
3
4
5
// 假设这里模拟一个 ul,其中包含了 5 个 li
;[1, 2, 3, 4, 5][
// 这里替换上面的 li
(1, 2, 5, 4)
]

从上述例子中,我们一眼就可以看出先前的 ul 中的第三个 li 被移除了,四五替换了位置。
那么在实际的算法中,我们如何去识别改动的是哪个节点呢?这就引入了 key 这个属性。这个属性是用来给每一个节点打标志的,用于判断是否是同一个节点
当然在判断以上差异的过程中,我们还需要判断节点的属性是否有变化等等。
当我们判断出以上的差异后,就可以把这些差异记录下来。当对比完两棵树以后,就可以通过差异去局部更新 DOM,实现性能的最优化。

如何正确使用key

生命周期管理

详解

React 16.8 +的生命周期分为三个阶段,分别是挂载阶段、更新阶段、卸载阶段

挂载阶段:

  • constructor: 构造函数,最先被执行,我们通常在构造函数里初始化 state 对象或者给自定义方法绑定 this
  • getDerivedStateFromProps: static getDerivedStateFromProps(nextProps, prevState),这是个静态方法,当我们接收到新的属性想去修改我们 state,可以使用 getDerivedStateFromProps
  • render: render 函数是纯函数,只返回需要渲染的东西,不应该包含其它的业务逻辑,可以返回原生的 DOM、React 组件、Fragment、Portals、字符串和数字、Boolean 和 null 等内容
  • componentDidMount: 组件装载之后调用,此时我们可以获取到 DOM 节点并操作,比如对 canvas,svg 的操作,服务器请求,订阅都可以写在这个里面,但是记得在 componentWillUnmount 中取消订阅

更新阶段:

  • getDerivedStateFromProps: 此方法在更新个挂载阶段都可能会调用
  • shouldComponentUpdate: shouldComponentUpdate(nextProps, nextState),有两个参数 nextProps 和 nextState,表示新的属性和变化之后的 state,返回一个布尔值,true 表示会触发重新渲染,false 表示不会触发重新渲染,默认返回 true,我们通常利用此生命周期来优化 React 程序性能
  • render: 更新阶段也会触发此生命周期
  • getSnapshotBeforeUpdate: getSnapshotBeforeUpdate(prevProps, prevState),这个方法在 render 之后,componentDidUpdate 之前调用,有两个参数 prevProps 和 prevState,表示之前的属性和之前的 state,这个函数有一个返回值,会作为第三个参数传给 componentDidUpdate,如果你不想要返回值,可以返回 null,此生命周期必须与 componentDidUpdate 搭配使用
  • componentDidUpdate: componentDidUpdate(prevProps, prevState, snapshot),该方法在 getSnapshotBeforeUpdate 方法之后被调用,有三个参数 prevProps,prevState,snapshot,表示之前的 props,之前的 state,和 snapshot。第三个参数是 getSnapshotBeforeUpdate 返回的,如果触发某些回调函数时需要用到 DOM 元素的状态,则将对比或计算的过程迁移至 getSnapshotBeforeUpdate,然后在 componentDidUpdate 中统一触发回调或更新状态。

卸载阶段:

  • componentWillUnmount: 当我们的组件被卸载或者销毁了就会调用,我们可以在这个函数里去清除一些定时器,取消网络请求,清理无效的 DOM 元素等垃圾清理工作

废弃

React 16 之后有三个生命周期被废弃(但并未删除):componentWillMount,componentWillReceiveProps,componentWillUpdate

周期内禁忌的事

  • componentWillMount 里 请求数据,订阅,setState

React Fiber

ReaxtV16 版本中引入了 Fiber 机制,生命周期部分发生变化。
React Fiber 是一种基于浏览器的单线程调度算法.
React 16之前 ,diff 算法实际上是递归,想要中断递归是很困难的,React 16 开始使用了循环来代替之前的递归。Fiber将diff拆分成无数个小任务的算法;它随时能够停止,恢复。停止恢复的时机取决于当前的一帧(16ms)内,还有没有足够的时间允许计算
Fiber 本质上是一个虚拟的堆栈帧,新的调度器会按照优先级自由调度这些帧,从而将之前的同步渲染改成了异步渲染,在不影响体验的情况下去分段计算更新
异步渲染,现在渲染有两个阶段:reconciliation 和 commit 。前者过程是可以打断的,后者不能暂停,会一直更新界面直到完成。
Reconciliation 阶段

  • componentWillMount
  • componentWillReceiveProps
  • shouldComponentUpdate
  • componentWillUpdate

Commit 阶段

  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

因为 Reconciliation 阶段是可以被打断的,所以 Reconciliation 阶段会执行的生命周期函数就可能会出现调用多次的情况,从而引起 Bug。由此对于 Reconciliation 阶段调用的几个函数,除了 shouldComponentUpdate 以外,其他都应该避免去使用,并且 V16 中也引入了新的 API 来解决这个问题。

  • getDerivedStateFromProps 用于替换 componentWillReceiveProps ,该函数会在初始化和 update 时被调用
  • getSnapshotBeforeUpdate 用于替换 componentWillUpdate ,该函数会在 update 后 DOM 更新前被调用,用于读取最新的 DOM 数据。

setState 机制

PS: useState 它不香吗

理想情况:

setState 是“异步”的,调用 setState 只会提交一次 state 修改到队列中,不会直接修改 this.state,等到满足一定条件时,react 会合并队列中的所有修改,触发一次 update 流程,更新 this.state。因此 setState 机制减少了 update 流程的触发次数,从而提高了性能。所以会有以下情况:

1
2
3
4
5
6
7
handle() {
// 初始化 `count` 为 0
console.log(this.state.count) // -> 0
this.setState({ count: this.state.count + 1 })
this.setState({ count: this.state.count + 1 })
console.log(this.state.count) // -> 0
}

当然你也可以通过以下方式来实现调用三次 setState 使得 count 为 3

1
2
3
4
handle() {
this.setState((prevState) => ({ count: prevState.count + 1 }))
this.setState((prevState) => ({ count: prevState.count + 1 }))
}

由于 setState 会触发 update 过程,因此在 update 过程中必经的生命周期中调用 setState 会存在循环调用的风险。

另外用于监听 state 更新完成,可以使用 setState 方法的第二个参数,回调函数。在这个回调中读取 this.state 就是已经批量更新后的结果。

1
2
3
4
5
handle() {
this.setState((prevState) => ({ count: prevState.count + 1 }), () => {
console.log(this.state)
})
}

特殊情况:

在实际开发中,setState 的表现有时会不同于理想情况。主要是以下两种

  • 在 mount 流程中调用 setState。
  • 在 setTimeout/Promise 回调中调用 setState。

在第一种情况下,不会进入 update 流程,队列在 mount 时合并修改并 render
在第二种情况下,setState 将不会进行队列的批更新,而是直接触发一次 update 流程。这是由于 setState 的两种更新机制导致的,只有在批量更新模式中,才会是“异步”的。

React 事件机制

更详细的源码解析版
React 其实自己实现了一套事件机制,首先我们考虑一下以下代码:

1
2
3
4
5
const Test = ({ list, handleClick }) => ({
list.map((item, index) => (
<span onClick={handleClick} key={index}>{index}</span>
))
})

上面的点击事件是否绑定在了每一个标签上?当然不是,JSX 上写的事件并没有绑定在对应的真实 DOM 上,而是通过事件代理的方式,将所有的事件都统一绑定在了 document 上。这样的方式不仅减少了内存消耗,还能在组件挂载销毁时统一订阅和移除事件。

另外冒泡到 document 上的事件也不是原生浏览器事件,而是 React 自己实现的合成事件(SyntheticEvent)。因此在React中,用 return false阻止默认行为,而应该在事件处理程序中调用 event.preventDefault 或 event.stopPropagation 来阻止冒泡(所以也不会有兼容性问题)

合成事件的优点

  • 抹平了浏览器之间的兼容问题
  • 性能优化。对于原生浏览器事件来说,浏览器会给监听器创建一个事件对象。如果你有很多的事件监听,那么就需要分配很多的事件对象,造成高额的内存分配问题。但是对于合成事件来说,有一个事件池专门来管理它们的创建和销毁,当事件需要被使用时,就会从池子中复用对象,事件回调结束后,就会销毁事件对象上的属性,从而便于下次复用事件对象

一些特性

React 组件

见此博文

React Router & React Redux

见此博文

通信

  • 父子组件通信:单向数据流,父组件通过 props 传递数据,子组件不能直接修改 props, 而是必须通过调用父组件函数的方式告知父组件修改数据
  • 兄弟组件通信:可以通过共同的父组件来管理状态和事件函数。比如说其中一个兄弟组件调用父组件传递过来的事件函数修改父组件中的状态,然后父组件将状态传递给另一个兄弟组件
  • 跨多层次组件通信
    • Context API。此外,还可以通过 context 传递一个函数,使得 consumers 组件更新 context
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      // 创建 Context,可以在开始就传入值
      const StateContext = React.createContext()
      class Parent extends React.Component {
      render () {
      return (
      // value 就是传入 Context 中的值
      <StateContext.Provider value='yck'>
      <Child />
      </StateContext.Provider>
      )
      }
      }
      class Child extends React.Component {
      render () {
      return (
      <ThemeContext.Consumer>
      // 取出值
      {context => (
      name is { context }
      )}
      </ThemeContext.Consumer>
      );
      }
      }
    • useContext不香吗,其实是同理的
  • 任意组件:Redux等状态管理工具 或者 Event Bus 解决,另外如果你不怕麻烦的话,可以使用这种方式解决上述所有的通信情况

render props

性能优化

react 中性能主要耗费在于 update 阶段的 diff 算法,因此性能优化也主要是减少 diff 算法触发次数

setState

setState 机制在正常运行时,由于批更新策略,已经降低了 update 过程的触发次数。
因此,setState 优化主要在于非批更新阶段中(timeout/Promise 回调),减少 setState 的触发次数。
常见的业务场景即处理接口回调时,无论数据处理多么复杂,保证最后只调用一次 setState

父组件 Render

当组件的 state 或 props 变化时,自身 render() 会重新执行
父组件的 render 必然会触发子组件 render,子组件会进入 update 阶段(无论 props 是否更新)

  • 此时最常用的优化方案即为 shouldComponentUpdate 方法。在 shouldComponentUpdate 函数中我们可以通过返回布尔值来决定当前组件是否需要更新,一般来说不推荐完整地对比当前 state 和之前的 state 是否相同,一般只比较 stated 某个或某几个值。最常见的方式为进行 this.props 和 this.state 的浅比较来判断组件是否需要更新,如下,组件只有当 props.color 或者 state.count 的值改变才会更新
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Test extends React.PureComponent {
constructor(props) {
super(props)
this.state = { count: 1 }
}
shouldComponentUpdate(nextProps, nextState) {
if (this.props.color !== nextProps.color) {
return true
}
if (this.state.count !== nextState.count) {
return true
}
return false
}
render() {
return <div>PureComponent</div>
}
}
  • 或者直接使用 PureComponent,原理一致
1
2
3
4
5
class Test extends React.PureComponent {
render() {
return <div>PureComponent</div>
}
}
  • 函数组件用不了 shouldComponentUpdate,可以使用 React.memo 来实现相同的功能
1
const Test = React.memo(() => <div>PureComponent</div>)

DOM 操作方面减少 diff

  • 不使用跨层级移动节点的操作
  • 对于条件渲染多个节点时,尽量采用隐藏等方式切换节点,而不是替换节点
  • 尽量避免将后面的子节点移动到前面的操作,当节点数量较多时,会产生一定的性能问题

React的优点

React速度很快

它并不直接对DOM进行操作,引入了一个叫做虚拟DOM的概念,安插在javascript逻辑和实际的DOM之间,性能好。最大限度减少DOM交互。

跨浏览器兼容

虚拟DOM帮助我们解决了跨浏览器问题,它为我们提供了标准化的API,甚至在IE8中都是没问题的

一切都是component

代码更加模块化,重用代码更容易,可维护性高。这样当某个或某些组件出现问题是,可以方便地进行隔离。每个组件都可以进行独立的开发和测试,并且它们可以引入其它组件。这等同于提高了代码的可维护性。

单向数据流

Flux是一个用于在JavaScript应用中创建单向数据层的架构,它随着React视图库的开发而被Facebook概念化。减少了重复代码,这也是它为什么比传统数据绑定更简单。

同构、纯粹的javascript

因为搜索引擎的爬虫程序依赖的是服务端响应而不是JavaScript的执行,预渲染你的应用有助于搜索引擎优化。

兼容性好

比如使用RequireJS来加载和打包,而Browserify和Webpack适用于构建大型应用。它们使得那些艰难的任务不再让人望而生畏。

Vue

Vue 从一开始的定位就是尽可能的降低前端开发的门槛,让更多的人能够更快地上手开发。Vue 首先考虑的是假设用户只掌握了 web 基础知识 (HTML, CSS, JS) 的情况下,如何能够最快理解和上手,实现一个看得见摸得着的应用

React设计模式

React模式。这篇文章写的非常好,观点简明扼要。看完想写一篇笔记发现这篇文章已经总结的很精简了,直接多看几遍吧吧233

单向数据流这种模式十分适合跟 React 搭配使用。它的主要思想是组件不会改变接收的数据。它们只会监听数据的变化,当数据发生变化时它们会使用接收到的新值,而不是去修改已有的值。当组件的更新机制触发后,它们只是使用新值进行重新渲染而已

参考资料
react 基本原理及性能优化
React 官方文档-性能优化
React v16.3 版本新生命周期函数浅析及升级方案
前端面试之道
React事件机制源码解析
conardli

CATALOG
  1. 1. 基本原理
    1. 1.1. Virtual Dom
      1. 1.1.1. 虚拟 DOM 的实现
      2. 1.1.2. 虚拟 DOM 真的能提升性能吗?
    2. 1.2. diff 算法
    3. 1.3. 如何正确使用key
    4. 1.4. 生命周期管理
      1. 1.4.1. 挂载阶段:
      2. 1.4.2. 更新阶段:
      3. 1.4.3. 卸载阶段:
      4. 1.4.4. 废弃
      5. 1.4.5. 周期内禁忌的事
    5. 1.5. React Fiber
    6. 1.6. setState 机制
      1. 1.6.1. 理想情况:
      2. 1.6.2. 特殊情况:
    7. 1.7. React 事件机制
  2. 2. 一些特性
    1. 2.1. React 组件
    2. 2.2. React Router & React Redux
    3. 2.3. 通信
    4. 2.4. render props
  3. 3. 性能优化
    1. 3.1. setState
    2. 3.2. 父组件 Render
    3. 3.3. DOM 操作方面减少 diff
  4. 4. React的优点
    1. 4.1. React速度很快
    2. 4.2. 跨浏览器兼容
    3. 4.3. 一切都是component
    4. 4.4. 单向数据流
    5. 4.5. 同构、纯粹的javascript
    6. 4.6. 兼容性好
    7. 4.7. Vue
  5. 5. React设计模式