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