深入浅出React-笔记

80 阅读12分钟

React组件间流动的数据

简单的发布订阅

class myEventEmitter {
  constructor() {
    // eventMap 用来存储事件和监听函数之间的关系
    this.eventMap = {};
  }
  // type 这里就代表事件的名称
  on(type, handler) {
    // hanlder 必须是一个函数,如果不是直接报错
    if (!(handler instanceof Function)) {
      throw new Error("哥 你错了 请传一个函数");
    }
    // 判断 type 事件对应的队列是否存在
    if (!this.eventMap[type]) {
      // 若不存在,新建该队列
      this.eventMap[type] = [];
    }
    // 若存在,直接往队列里推入 handler
    this.eventMap[type].push(handler);
  }
  // 触发时是可以携带数据的,params 就是数据的载体
  emit(type, params) {
    // 假设该事件是有订阅的(对应的事件队列存在)
    if (this.eventMap[type]) {
      // 将事件队列里的 handler 依次执行出队
      this.eventMap[type].forEach((handler, index) => {
        // 注意别忘了读取 params
        handler(params);
      });
    }
  }
  off(type, handler) {
    if (this.eventMap[type]) {
      this.eventMap[type].splice(this.eventMap[type].indexOf(handler) >>> 0, 1);
    }
  }
}

React生命周期

React 16 的生命周期被划分为了 render 和 commit 两个阶段,而 commit 阶段又被细分为了 pre-commit 和 commit。每个阶段所涵盖的生命周期如下图所示:

我们先来看下三个阶段各自有哪些特征(以下特征翻译自上图)。

    • render 阶段:纯净且没有副作用,可能会被 React 暂停、终止或重新启动。
    • pre-commit 阶段:可以读取 DOM。
    • commit 阶段:可以使用 DOM,运行副作用,安排更新。

总的来说,render 阶段在执行过程中允许被打断,而 commit 阶段则总是同步执行的。

为什么这样设计呢?简单来说,由于 render 阶段的操作对用户来说其实是“不可见”的,所以就算打断再重启,对用户来说也是零感知。而 commit 阶段的操作则涉及真实 DOM 的渲染,再狂的框架也不敢在用户眼皮子底下胡乱更改视图,所以这个过程必须用同步渲染来求稳。

getDerivedStateFromProps

对于getDerivedStateFromProps 这个生命周期,React 16.4 的挂载和卸载流程都是与 React 16.3 保持一致的,差异在于更新流程上:

  • 在 React 16.4 中,任何因素触发的组件更新流程(包括由 this.setState 和 forceUpdate 触发的更新流程)都会触发 getDerivedStateFromProps;
  • 而在 v 16.3 版本时,只有父组件的更新会触发该生命周期。

虚拟DOM

真正理解虚拟 DOM:React 选它,真的是为了性能吗?

在 MVVM 框架这个领域分支,有一道至今仍然非常经典的面试题:“为什么我们需要虚拟 DOM?”。首先我们先来搞懂,什么是虚拟DOM。虚拟 DOM(Virtual DOM)本质上是JS 和 DOM 之间的一个映射缓存,它在形态上表现为一个能够描述 DOM 结构及其属性信息的 JS 对象。

就这个示例来说,我们需要把握住以下两点:

    1. 虚拟 DOM 是 JS 对象
    2. 虚拟 DOM 是对真实 DOM 的描述

在整个 DOM 操作的演化过程中,主要矛盾并不在于性能,而在于开发者写得爽不爽,在于研发体验/研发效率。虚拟 DOM 并不一定会带来更好的性能,React 官方也从来没有把虚拟 DOM 作为性能层面的卖点对外输出过。虚拟 DOM 的优越之处在于,它能够在提供更爽、更高效的研发模式(也就是函数式的 UI 编程方式)的同时,仍然保持一个还不错的性能。


虚拟DOM真的快吗? 其实不然,在极端情况下,比如都是全量更新DOM时,虚拟DOM渲染肯定是比模板渲染要慢的。但是只要两者在最终 DOM 操作量上拉开那么一点点的差距,虚拟 DOM 就将具备战胜模板渲染的底气。

因为虚拟 DOM 的劣势主要在于 JS 计算的耗时,而 DOM 操作的能耗和 JS 计算的能耗根本不在一个量级,极少量的 DOM 操作耗费的性能足以支撑大量的 JS 计算。


最终希望你明白的事情只有一件:虚拟 DOM 的价值不在性能,而在别处。

虚拟 DOM 解决的关键问题有以下两个:


  1. 提高研发体验和研发效率:虚拟 DOM 的出现,为数据驱动视图这一思想提供了高度可用的载体,使得前端开发能够基于函数式 UI 的编程方式实现高效的声明式编程
  2. 跨平台的问题:虚拟 DOM 是对真实渲染内容的一层抽象。若没有这一层抽象,那么视图层将和渲染平台紧密耦合在一起,为了描述同样的视图内容,你可能要分别在 Web 端和 Native 端写完全不同的两套甚至多套代码。但现在中间多了一层描述性的虚拟 DOM,它描述的东西可以是真实 DOM,也可以是iOS 界面、安卓界面、小程序......同一套虚拟 DOM,可以对接不同平台的渲染逻辑,从而实现“一次编码,多端运行”,如下图所示。其实说到底,跨平台也是研发提效的一种手段,它在思想上和1是高度呼应的。(通过抽象的虚拟DOM层,几乎可以去描述任何平台的东西,灵活性非常强)

在这里理解虚拟DOM应该不能站在React的角度上去看,因为React是只应用在了web端,而React native的视图标签是全新的一套。他们倒是有些思想比较类似,都是一套代码,多端实现。比如React是抽离了的数据层,但是能通过React-dom、React-native渲染成不同端的视图。

这里虚拟DOM的一套代码多端实现,用现在的一些跨端解决方案框架解释更合适,比如UniApp、Taro,一套代码多端实现。

虚拟 DOM 还有非常多的亮点值得去挖掘,这里再着重拓展一下前面聊到的性能层面的优化。

除了差量更新以外,“批量更新”也是虚拟 DOM 在性能方面所做的一个重要努力: “批量更新”在通用虚拟 DOM 库里是由 batch 函数来处理的。在差量更新速度非常快的情况下(比如极短的时间里多次操作同一个 DOM),用户实际上只能看到最后一次更新的效果。这种场景下,前面几次的更新动作虽然意义不大,但都会触发重渲染流程,带来大量不必要的高耗能操作。

这时就需要请 batch 来帮忙了,batch 的作用是缓冲每次生成的补丁集,它会把收集到的多个补丁集暂存到队列中,再将最终的结果交给渲染函数,最终实现集中化的 DOM 批量更新。

React 中的“栈调和”(Stack Reconciler)过程是怎样的?

这里主要理解 React 15 的“栈调和”算法和diff算法。从它开始把握 React 15 的局限性,从根本上理解 React 16 大改版背后的设计动机。

调和(Reconciliation)过程与 Diff 算法

栈调和是让虚拟DOM和真实DOM一致,通过如 ReactDOM 等类库使虚拟 DOM 与“真实的” DOM 同步,这一过程叫作协调(调和)。

因此严格来说,调和过程并不能和 Diff 画等号。调和是“使一致”的过程,而 Diff 是“找不同”的过程,它只是“使一致”过程中的一个环节。调和器所做的工作是一系列的,包括组件的挂载、卸载、更新等过程,其中更新过程涉及对 Diff 算法的调用。

所以说调和 !== Diff这个结论,是站得住脚的。

Diff 确实是调和过程中最具代表性的一环 根据 Diff 实现形式的不同,调和过程被划分为了以 React 15 为代表的“栈调和”以及 React 16 以来的“Fiber 调和”。

在React中,当组件的 state 或 props 更改时,React通过比较新返回的元素和先前呈现的元素来决定是否需要更新实际的DOM。当它们不相等时,React 会找出最小变化集合,然后更新实际 DOM。这个过程叫做“调和”。

为什么叫“栈调和”,貌似是这个名称衍生于“栈”这个数据结构,“栈”是一个后入先出的机制。栈和我们刚说的有什么关系?好吧,事实证明,当我们递归的时候,事实上就是利用栈来递归的。

setState的同步异步

 setState 异步的一个重要的动机——避免频繁的 re-render。在实际的 React 运行时中,setState 异步的实现方式有点类似于 Vue 的 $nextTick 和浏览器里的 Event-Loop:每来一个 setState,就把它塞进一个队列里“攒起来”。等时机成熟,再把“攒起来”的 state 结果做合并,最后只针对最新的 state 值走一次更新流程。这个过程,叫作“批量更新”。

首先明确一个结论,只要是在 React 管控下的 setState,一定是异步的。

任务锁

在enqueueUpdate方法中,它引出了一个关键的对象——batchingStrategy,该对象所具备的isBatchingUpdates属性直接决定了当下是要走更新流程,还是应该排队等待;其中的batchedUpdates 方法更是能够直接发起更新流程。

由此我们可以大胆推测,batchingStrategy 或许正是 React 内部专门用于管控批量更新的对象。batchingStrategy 对象并不复杂,你可以理解为它是一个“锁管理器”。

这里的“锁”,是指 React 全局唯一的 isBatchingUpdates 变量,isBatchingUpdates 的初始值是 false,意味着“当前并未进行任何批量更新操作”。每当 React 调用 batchedUpdate 去执行更新动作时,会先把这个锁给“锁上”(置为 true),表明“现在正处于批量更新过程中”。

当锁被“锁上”的时候,任何需要更新的组件都只能暂时进入 dirtyComponents 里排队等候下一次的批量更新,而不能随意“插队”。此处体现的“任务锁”的思想,是 React 面对大量状态仍然能够实现有序分批处理的基石。


理解 React 中的 Transaction(事务) 机制

理解了批量更新整体的管理机制,还需要注意 batchedUpdates 中,有一个引人注目的调用:

transaction.perform(callback, null, a, b, c, d, e)

这行代码为我们引出了一个更为硬核的概念——React 中的 Transaction(事务)机制。

Transaction 在 React 源码中的分布可以说非常广泛。如果你在 Debug React 项目的过程中,发现函数调用栈中出现了 initialize、perform、close、closeAll 或者 notifyAll 这样的方法名,那么很可能你当前就处于一个 Trasaction 中。

function enqueueUpdate(component) {
  ensureInjected();
  // 注意这一句是问题的关键,isBatchingUpdates标识着当前是否处于批量创建/更新组件的阶段
  if (!batchingStrategy.isBatchingUpdates) {
    // 若当前没有处于批量创建/更新组件的阶段,则立即更新组件
    batchingStrategy.batchedUpdates(enqueueUpdate, component);
    return;
  }
  // 否则,先把组件塞入 dirtyComponents 队列里,让它“再等等”
  dirtyComponents.push(component);
  if (component._updateBatchNumber == null) {
    component._updateBatchNumber = updateBatchNumber + 1;
  }
}
/**
	ReactDefaultBatchingStrategy.js
*/
var ReactDefaultBatchingStrategy = {
  isBatchingUpdates: false,

  /**
   * Call the provided function in a context within which calls to `setState`
   * and friends are batched such that components aren't updated unnecessarily.
   */
  batchedUpdates: function (callback, a, b, c, d, e) {
    var alreadyBatchingUpdates = ReactDefaultBatchingStrategy.isBatchingUpdates;

    ReactDefaultBatchingStrategy.isBatchingUpdates = true;

    // The code is written this way to avoid extra allocations
    if (alreadyBatchingUpdates) {
      //! 这里是当isBatchingUpdates设为true了,则会继续执行上面的enqueueUpdate方法,
      // 此时会将该组件实例又push进入dirtyComponents。
      callback(a, b, c, d, e);
    } else {
      // 这里会进入所谓的Transaction(事务) 机制,进行代码执行
      transaction.perform(callback, null, a, b, c, d, e);
    }
  }
};

这里还留下个疑问,就是setState这个动作在Transaction中执行的具体位置在哪。

下面代码示例能看到这里定义了两个对象,它们会被传入Transaction 事务机制中,每一次perform被执行完后,都会调用事务中的close方法, 先调用flushBatchedUpdates批量更新, 再结束本次batch。

/**
	ReactDefaultBatchingStrategy.js	
*/

var RESET_BATCHED_UPDATES = {
  initialize: emptyFunction,
  close: function() {
    ReactDefaultBatchingStrategy.isBatchingUpdates = false;
  },
};

var FLUSH_BATCHED_UPDATES = {
  initialize: emptyFunction,
  close: ReactUpdates.flushBatchedUpdates.bind(ReactUpdates),
};

var TRANSACTION_WRAPPERS = [FLUSH_BATCHED_UPDATES, RESET_BATCHED_UPDATES];

我猜想,setTImeout会绕过任务锁,直接将setState动作打入dirtyComponent中,此时perform结束,运行flushBatchedUpdates方法,循环dirtyComponent队列,使用updateComponent来执行所有的生命周期方法。

Fiber

Fiber 架构核心:“可中断”“可恢复”与“优先级”

在 React 16 之前,React 的渲染和更新阶段依赖的是如下图所示的两层架构:

Reconciler 这一层负责对比出新老虚拟 DOM 之间的变化,Renderer 这一层负责将变化的部分应用到视图上,从 Reconciler 到 Renderer 这个过程是严格同步的。

而在 React 16 中,为了实现“可中断”和“优先级”,两层架构变成了如下图所示的三层架构:

多出来的这层架构,叫作“Scheduler(调度器)”,调度器的作用是调度更新的优先级。

在这套架构模式下,更新的处理工作流变成了这样:首先,每个更新任务都会被赋予一个优先级。当更新任务抵达调度器时,高优先级的更新任务(记为 A)会更快地被调度进 Reconciler 层;此时若有新的更新任务(记为 B)抵达调度器,调度器会检查它的优先级,若发现 B 的优先级高于当前任务 A,那么当前处于 Reconciler 层的 A 任务就会被中断,调度器会将 B 任务推入 Reconciler 层。

当 B 任务完成渲染后,新一轮的调度开始,之前被中断的 A 任务将会被重新推入 Reconciler 层,继续它的渲染之旅,这便是所谓“可恢复”。

以上,便是架构层面对“可中断”“可恢复”与“优先级”三个核心概念的处理。

特别的事件系统

React 的事件系统沿袭了事件委托的思想。在 React 中,除了少数特殊的不可冒泡的事件(比如媒体类型的事件)无法被事件系统处理外,绝大部分的事件都不会被绑定在具体的元素上,而是统一被绑定在页面的 document 上。当事件在具体的 DOM 节点上被触发后,最终都会冒泡到 document 上,document 上所绑定的统一事件处理程序会将事件分发到具体的组件实例。

在分发事件之前,React 首先会对事件进行包装,把原生 DOM 事件包装成合成事件。

合成事件是 React 自定义的事件对象,它符合W3C规范,在底层抹平了不同浏览器的差异,在上层面向开发者暴露统一的、稳定的、与 DOM 原生事件相同的事件接口。开发者们由此便不必再关注烦琐的兼容性问题,可以专注于业务逻辑的开发。