React核心原理

React哲学-简单之美


  • React 通过 UI = renderWithJSX(state) 完美的解决了如何将 动态数据/频繁交互 高效地反映到复杂的用户界面上。

  • React 通过 虚拟DOM,用 整体刷新 的方式替代了传统的局部刷新。开发人员不需要频繁进行复杂的 DOM 操作,只需要关注 数据状态变化 和最终的 UI 呈现,其他的 React 自动解决,大大降低了开发的复杂度。

  • React 通过 Diff 算法,高效的解决了整体刷新带来的性能问题。

  • React 把 组件 作为构建用户界面的基本单位。

  • React 通过单向数据流动模型,来管理组件之间,组件和数据模型直接的通信。

  • React 还提倡使用只读数据建立数据模型。并开发了一整套框架 immutable.js ,将只读数据的概念引入 JavaScript。只读的数据可以让代码更加的安全和易于维护,你不再需要担心数据在某个角落被某段神奇的代码所修改;也就不必再为了找到修改的地方而苦苦调试。

  • React 项目经理在演讲中说过,React 最有价值的其实是声明式的,直观的编程方式。以简单直观,复合习惯的方式编程,让代码更容易被理解,从而易于维护和不断演进。这就是 React 的设计哲学。

软件工程向来不提倡用高深莫测的技巧去编程,相反,如何写出可理解可维护的代码才是质量和效率的关键。(深有同感)

虚拟DOM & JSX


虚拟DOM 是 React 的核心机制之一。

虚拟 DOM 其实就是用 JavaScript对象表示的一个 DOM 节点, 内部包含了节点的 tag , props 和 children 。

React 利用 虚拟DOM 将一部分昂贵的浏览器重绘工作转移到相对廉价的存储和计算资源上,以此减少对实际 DOM 的操作从而提升性能。

虚拟DOM 可以通过 JavaScript 来创建,例如:

var child1 = React.createElement('li', null, 'First Text Content');
var child2 = React.createElement('li', null, 'Second Text Content');
var root = React.createElement('ul', { className: 'my-list' }, child1, child2);

但这样的代码可读性并不好,于是 React 发明了 JSX,利用我们熟悉的 Html 语法来创建 虚拟DOM:

var root =(
  <ul className="my-list">
    <li>First Text Content</li>
    <li>Second Text Content</li>
  </ul>
);

JSX 并不等同于传统 MVC 框架中的 模版引擎,而是一种类似 XML 的高级语法糖,它完美的将 Javascript 和 Dom 融合在一起,你中有我,我中有你。

JSX 其实并没有增加 React 的学习门槛,只要你熟悉 Html 结构,会 Javascript 就很容易掌握。其并不比学习一些模版引擎的学习成本高。

JSX 通过 babel 编译后,其实也是通过 React.createElement 创建的虚拟Dom对象。

DIFF算法


React 中最神奇的部分莫过于 虚拟DOM,以及其高效的 Diff 算法。这让我们可以无需担心性能问题而 “毫无顾忌” 的随时 “刷新”整个页面。

在 React 中,构建 UI 界面的思路是由当前状态决定界面。前后两个状态就对应两套界面,然后由 React 来比较两个界面的区别,这就需要对 DOM 树进行 Diff 算法分析。

但是树的标准 Diff 算法复杂度需要 O(n^3),这显然无法满足性能要求。Facebook 工程师结合 Web 界面的特点做出了两个简单的假设,使得 Diff 算法复杂度直接降低到 O(n)。

  • 两个相同组件产生类似的 DOM 结构,不同的组件产生不同的 DOM 结构。
  • 对于同一层次的一组子节点,它们可以通过唯一的 id 进行区分。

首先,React 的 DOM Diff 算法实际上只会对树进行逐层比较。

如果节点类型/组件不同:
React 直接删除前面的节点(包括所有子节点),然后创建并插入新的节点。同样的,当 React 在同一个位置遇到不同的组件时,会简单的销毁第一个组件,而把新创建的组件加上去。

如果节点类型相同:
比较简单,React 会对属性进行重设来实现节点的转换。对于同一类型的组件,当组件的props更新时, 组件实例保持不变, React调用组件的 componentWillReceiveProps(), componentWillUpdate() 和 componentDidUpdate() 生命周期方法, 并执行 render()方法.

列表节点比较特殊:
同层元素较多,经常会有删除,插入,排序操作。比如插入一条数据,按前面的逻辑,会频繁的进行销毁和重建,Dom操作过于频繁。React 没法高效的进行更新。所以,对于列表节点提供唯一的 key 属性可以帮助 React 定位到正确的节点进行比较,从而大幅减少 DOM 操作次数,提高性能。

当你了解了 Diff算法,在使用 React 开发组件的过程中,也要有意识的书写高性能的代码:

  • 保持稳定的 DOM 结构,比如通过CSS显示隐藏节点,而不是真的移除和添加。
  • 对于列表,尽量设置唯一 Key 属性,让 React 更高效的更新。

切记,这里的 key 一定是能唯一标示这一条数据的,遍历方法中((item,index) => {}) 的 index 属性是不行的,还会引发不确定的bug。因为插入,排序,删除操作以后重新遍历,相同的 index 已经指向了不同的数据。

但是,我们面临着一个重大的性能问题:

刷新率:大部分显示器屏幕都有固定的刷新率(比如最新的一般在 60Hz),所以浏览器更新最好是在 60fps。如果在两次硬件刷新之间浏览器进行两次重绘是没有意义的只会消耗性能。 浏览器会利用这个间隔 16ms(一帧)适当地对绘制进行节流,如果在 16ms 内做了太多事情,会阻塞渲染,造成页面卡顿, 因此 16ms 就成为页面渲染优化的一个关键时间。

浏览器的 渲染线程 和JS的 执行线程 是互斥的。当如果 组件树 的层级很深,递归处理 虚拟Dom 会长时间占用主线程。这使得一些需要高优先级处理的操作如用户输入、动画等被阻塞,造成卡顿, 严重影响使用体验。

上述问题是普遍存在的,浏览器中其实已经提供了 window.requestIdleCallback() 方法试图解决这个问题(目前还处于实验性阶段)。这个方法将在浏览器的空闲时段内调用方法设置的函数队列。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。函数一般会按先进先调用的顺序执行,然而,如果回调函数指定了执行超时时间timeout,则有可能为了在超时前执行函数而打乱执行顺序。

Fiber


为了解决高优先级处理任务被阻塞的问题,React16 版本对其核心进行了一系列重构,React16 架构可以分为三层:

  • Scheduler(调度器)—— 调度任务的优先级,高优任务优先进入Reconciler
  • Reconciler(协调器)—— 负责找出变化的组件
  • Renderer(渲染器)—— 负责将变化的组件渲染到页面上

可以看到,相较于React15,React16中 新增了 Scheduler(调度器)。Reconciler 内部采用了Fiber的架构。

*时间切片策略 要求我们将 虚拟DOM 的更新操作分解为小的工作单元, 同时具备以下特性:

  • 可暂停、可恢复的更新;
  • 可跳过的重复性、覆盖性更新;
  • 具备优先级的更新.

对于递归形式的程序来说, 这些是难以实现的。 于是就需要一个处于递归形式的 虚拟DOM 树更上层的一种数据结构, 来辅助完成这些特性。

这就是 React16 在重构中引入的算法核心 —— Fiber 链表数据结构。

从概念上来说, Fiber 就是重构后的 虚拟DOM 节点, 一个Fiber就是一个JS对象。

Fiber节点之间构成 单向链表 结构。React 使用 “双缓存” 来完成 Fiber 树的构建与替换——对应着 DOM 树的创建与更新。

在内存中构建并直接替换的技术叫做双缓存.

Scheduler 是 React 引入 时间切片(Time Slice)策略的产物。考虑到浏览器的兼容性以及 requestIdleCallback 方法的不稳定性(没秒执行20次,正常浏览器刷新是60), React 自己实现了 React 专用的的类似 requestIdleCallback 且功能更完备的 Scheduler 来实现空闲时触发回调, 并提供了多种优先级供任务设置。

主要用到的技术点如下:

  • requestAnimationFrame

  • macrotasks(宏任务)。

  • MessageChannel/postMessage 生成高优先级宏任务(比 setTimeout 执行时机更靠前)

浏览器一帧内工作:一个task(宏任务) – 队列中全部job(微任务) – requestAnimationFrame – 浏览器重排/重绘 – requestIdleCallback

调度器主要工作流程如下:

  • 更新队列产生以后,调度器启动。

  • 调度时通过 requestAnimationFrame 在浏览器每次重绘前做想做的事。给requestAnimationFrame 设置的回调方法animationTick 会在浏览器动画执行前执行。

  • 在 animationTick 中可以确定下一帧结束的时间点,因为不知道 react 更新需要多少时间,所以不在 animationTick 中判断当前帧剩余时间来执行 react 更新,而是通过 postMessage 把用于更新 的 flushWork 推入宏任务队列 macrotasks。

  • 在 window.addEventListener('message', idleTick, false); 的 idleTick回调中,会一直拖到当前帧完全过期时才把 didTimeout = true, 才去执行这次 react 更新。

  • 这样, react 更新 的 flushWork 作为 宏任务 会先于 requestAnimationFrame 执行。这时 flushWork 就算更新时间超过当前帧剩余时间借用了下一帧的时间,也是最大限度的保证了浏览器动画的流畅性和优先级。

合成事件 SyntheticEvent


React 提供了一种 “顶层注册,事件收集,统一触发” 的事件机制。我们称其为合成事件(SyntheticEvent)。它自己遵循W3C的规范又实现了一遍浏览器的事件对象接口,这样可以抹平差异,而原生的事件对象只不过是它的一个属性(nativeEvent)

SyntheticEvent 是浏览器的原生事件的跨浏览器包装器,除了兼容所有浏览器外,还拥有浏览器原生事件接口,包括e.stopPropagation(),e.preventDefault()。当你需要使用浏览器底层事件时,只需要使用 nativeEvent属性来获取即可。比如 e.nativeEvent.stopImmediatePropagation()。

我们在 React 组件中通过 JSX 语法中的绑定的所有事件都挂载在 document 上( 不是 window,也不是 document.body )。当真实 Dom 触发后冒泡到 document 后才会对 React 事件进行处理。

当同时存在 原生API注册事件 和 合成事件 的情况下,事件触发顺序如下:

  • 声明为 捕获阶段 执行的原生事件 执行(父元素上事件先执行)。
  • 其他 绑定在 document 子元素上的 原生事件(默认为冒泡阶段执行) 执行。
  • React事件按实际触发元素的 冒泡顺 序执行 (子 > 父)。
  • 绑定在 docoment 元素上的 原生事件 执行。
  • 绑定在 window 上的 原生事件 执行。

依次举例如下:

  • document.addEventListener('click', () => {}), true);
  • document.body.addEventListener('click', () => {}), false);
  • <div onClick={ () => {} }></div>
  • document.addEventListener('click', () => {}), false);
  • window.addEventListener('click', () => {}), false);

阻止冒泡:

  • e.stopPropagation():能阻止下层 React合成事件 到 上层 React 合成事件的冒泡。因为 React 合成事件 都是注册在document 上,所以对于原生事件,只能阻止向 window 事件冒泡。
  • e.nativeEvent.stopImmediatePropagation():阻止 原生事件执行。条件是这个原生事件一定是绑定在document元素上,并且是冒泡阶段执行。

下面我们来探究以下合成事件的实现原理,我们可以分为两个阶段:

  • 事件注册 和 存储。
  • 事件触发 并 执行。

事件注册/存储

每当组件进入 render 阶段的 complete阶段时,名称为 onClick... 的 prop 会被识别为 事件 进行事件注册处理。通过lastProps、nextProps 判断事件是新增还是删除,删除会调用事件卸载方法。

React会根据 事件名称匹配它所依赖的原生事件,例如 onMouseEnter 事件依赖了 mouseout 和 mouseover 两个原生事件,onClick只依赖了click一个原生事件。

并将 事件回调 存储在 EventPluginHub.listenerBank中,并通过元素的唯一 key 来标识:listenerBank[registrationName][key],其中 registrationName 是事件名称,如onClick。

最终 document 元素上会注册所有涉及类型的原生事件。事件处理函数则是 根据事件类型创建的各类事件监听器 listener 。一般有以下三种事件监听器:

  • dispatchDiscreteEvent: 处理离散事件
  • dispatchUserBlockingUpdate:处理用户阻塞事件
  • dispatchEvent:处理连续事件

事件触发/执行

当 React 在 document 元素上注册的原生事件被触发,对应的事件监听器 listener 开始工作。它按照事件的优先级去安排接下来的工作:构造合成事件、将事件处理函数收集到执行路径、 事件执行。

根据事件类型,通过 SyntheticEvent 构造函数生成对应的合成事件对象。

从触发事件的的最深层元素开始,遍历这个元素的所有父元素,根据事件名称,收集到之前存储的所有 事件处理函数 到 执行路径。

最后会生产如下的 dispatchQueue 结构的 eventQueue:

[
  {
    event: SyntheticEvent,
    listeners: [ listener1, listener2, ... ]
  }
]

可以看到,我们将 同一类事件 构造的合成事件存储在 listeners 事件队列中,用于冒泡/捕获的模拟处理。我们遍历事件队列,执行带有合成事件对象(event)参数的回调函数。

  • 冒泡阶段事件,从前往后遍历。通过 isPropagationStopped 判断当前事件是否执行了阻止冒泡方法。如果阻止了冒泡,则停止遍历。
  • 捕获阶段事件,从后往前遍历 即可。

参考文献


深入浅出 React
React事件机制
深入React合成事件机制原理
React核心原理浅析
React技术揭秘
Build Your Own React
React 源码解析 - 调度模块原理 - 实现 requestIdleCallback