这仅仅是读React技术揭秘的笔记.
理念
为了构建快速响应的大型 Web 应用程序。
快速响应的制约瓶颈
-
- CPU瓶颈: 大量计算导致的页面掉帧
-
- IO瓶颈: 页面数据需要等待网络数据返回,即网络延迟
react如何解决上述两个瓶颈
CPU瓶颈
JS可以操作DOM,GUI渲染线程与JS线程是互斥的。所以JS脚本执行和浏览器布局、绘制不能同时执行。
在一次页面刷新时间里(16.6ms = 1000ms / 60Hz),
JS脚本执行 ----- 样式布局 ----- 样式绘制
如果脚本执行时间过长超出了16.6ms,这次刷新就没有时间执行样式布局和样式绘制
- 解决方案:每一帧的时间中,预留
5ms给JS线程,React利用这部分时间更新组件,当预留时间不够用时,React将线程控制权交还给浏览器使其有时间渲染UI,React则等待下一帧时间到来继续被中断的工作。
将长任务分拆到每一帧中,一次执行一小段任务的操作,被称为
时间切片(time slice)
IO瓶颈
网络延迟是肯定存在的,所以React做法是:减少用户对网络延迟的感知-- 将人机交互研究的结果整合到真实的 UI 中;
- 抽出一小段时间用来网络请求,超出这段时间再显示loading。如果这一小段时间够短,那么用户是几乎感知不到的
React实现了Suspense (opens new window)功能及配套的hook——useDeferredValue
React在解决上述两个瓶颈的时候,都用到了 将同步的更新变为可中断的异步更新。
老的React(React 15)
React15架构可以分为两层:
- Reconciler(协调器)—— 负责找出变化的组件
- Renderer(渲染器)—— 负责将变化的组件渲染到页面上
Reconciler(协调器)
React中可以通过 this.setState、this.forceUpdate、ReactDOM.render等触发更新,每次更新Reconciler都会入如下工作:
- 1.调用函数组件或者class组件的render 方法,将返回的JSX转化为虚拟DOM
- 2.将虚拟DOM和上次更新时的虚拟DOM对比
- 3.通过对比找出变化的DOM
- 4.通知Renderer将变化渲染到页面上
Renderer(渲染器)
由于React支持跨平台,所以不同平台有不通的Renderer。
- ReactDOM-- 渲染浏览器
- ReactNative-- 渲染App原生组件
- ReactTest-- 渲染出纯Js对象用于测试
- ReactArt-- 渲染到Canvas, SVG 或 VML (IE8)
老的React缺点
在Reconciler中,mount的组件会调用mountComponent,update的组件会调用updateComponent。这两个方法都会递归更新子组件。而递归更新一旦开始,就无法中断,当递归层级很深耗时超过16.6ms时,就会卡顿。
新的React(React 16)
基于老的React的递归更新缺点,React16在Reconciler前添加了Scheduler调度器,即三层架构
- Scheduler(调度器)—— 调度任务的优先级,高优任务优先进入Reconciler
- Reconciler(协调器)—— 负责找出变化的组件
- Renderer(渲染器)—— 负责将变化的组件渲染到页面上
Scheduler(调度器)
- 利用浏览器空闲时间触发是否进行更新
- 类似于浏览器的requestIdleCallback,但是比这更加完善
- 提供了多种调度优先级供任务设置
- Scheduler是独立于
React的库
Reconciler(协调器)
而在新的Reconciler(协调器)中,更新工作从递归变成了可以中断的循环过程。每次循环都会调用shouldYield判断当前是否有剩余时间。
/** @noinline */
function workLoopConcurrent() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
workInProgress = performUnitOfWork(workInProgress);
}
}
并且React16中,Reconciler与Renderer不再是交替工作,而是当Scheduler将任务交给Reconciler后,Reconciler会为变化的虚拟DOM打上代表增/删/更新的标记,类似这样:
export const Placement = /* */ 0b0000000000010;
export const Update = /* */ 0b0000000000100;
export const PlacementAndUpdate = /* */ 0b0000000000110;
export const Deletion = /* */ 0b0000000001000;
整个Scheduler与Reconciler的工作都在内存中进行。只有当所有组件都完成Reconciler的工作,才会统一交给Renderer。
新的
Reconciler内部采用了Fiber的架构
Renderer(渲染器)
- 只对上面被打上
Update标签的虚拟dom进行更新 - Scheduler与Reconciler的工作都在内存中进行,所以新的DOM即使被反复中断,用户也不会看见更新不完全的DOM.
Fiber架构
代数效应
代数效应是函数式编程中的一个概念,用于将副作用从函数调用中分离。
function getTotalPicNum(user1, user2) {
const picNum1 = getPicNum(user1);
const picNum2 = getPicNum(user2);
return picNum1 + picNum2;
}
上面是一个获取Picnum的方法,而getPicNum是一个异步耗时的过程,为了保持上面的getTotalPicNum这种调用方式,我们可以使用async await
async function getTotalPicNum(user1, user2) {
const picNum1 = await getPicNum(user1);
const picNum2 = await getPicNum(user2);
return picNum1 + picNum2;
}
但是,async await是有传染性的 —— 当一个函数变为async后,这意味着调用他的函数也需要是async,这破坏了getTotalPicNum的同步特性。所以我们虚构一个类似try...catch的语法 —— try...handle与两个操作符perform、resume
function getPicNum(name) {
const picNum = perform name;
return picNum;
}
try {
getTotalPicNum('kaSong', 'xiaoMing');
} handle (who) {
switch (who) {
case 'kaSong':
resume with 230;
case 'xiaoMing':
resume with 122;
default:
resume with 0;
}
}
- 当执行到
getTotalPicNum内部的getPicNum方法时,会执行perform name. - 此时函数调用栈会从
getPicNum方法内跳出,被最近一个try...handle捕获。类似throw Error后被最近一个try...catch捕获. - 与
try...catch最大的不同在于:当Error被catch捕获后,之前的调用栈就销毁了。而handle执行resume后会回到之前perform的调用栈。
再次申明,
try...handle的语法是虚构的,只是为了演示代数效应的思想。
React中代数效应- Hooks
对于类似useState、useReducer、useRef这样的Hook,我们不需要关注FunctionComponent的state在Hook中是如何保存的,React会为我们处理。我们只需要假设useState返回的是我们想要的state,并编写业务逻辑就行。
function App() {
const [num, updateNum] = useState(0);
return (
<button onClick={() => updateNum(num => num + 1)}>{num}</button>
)
}
再来一个例子:
ProfileDetails用于展示用户名称。而用户名称是异步请求的。
但是Demo中完全是同步的写法
function ProfileDetails() {
const user = resource.user.read();
return <h1>{user.name}</h1>;
}
fiber含义
Fiber并不是计算机术语中的新名词,他的中文翻译叫做纤程,与进程(Process)、线程(Thread)、协程(Coroutine)同为程序执行过程。React Fiber-React内部实现的一套状态更新机制。支持任务不同优先级,可中断与恢复,并且恢复后可以复用之前的中间状态。其中每个任务更新单元为React Element对应的Fiber节点,具体为以下三点:
React16的Reconciler基于Fiber节点实现,被称为Fiber Reconciler- 作为静态的数据结构来说,每个
Fiber节点对应一个React element,保存了该组件的类型(函数组件/类组件/原生组件...)、对应的DOM节点等信息 - 作为动态的工作单元来说,每个
Fiber节点保存了本次更新中该组件改变的状态、要执行的工作(需要被删除/被插入页面中/被更新...)
Fiber的结构
function FiberNode(
tag: WorkTag,
pendingProps: mixed,
key: null | string,
mode: TypeOfMode,
) {
###作为静态数据结构的属性,保存了组件相关的信息###
// Fiber对应组件的类型 Function/Class/Host...
this.tag = tag;
// key属性
this.key = key;
// 大部分情况同type,某些情况不同,比如FunctionComponent使用React.memo包裹
this.elementType = null;
// 对于 FunctionComponent,指函数本身,对于ClassComponent,指class,对于HostComponent,指DOM节点tagName
this.type = null;
// Fiber对应的真实DOM节点
this.stateNode = null;
###用于连接其他Fiber节点形成Fiber树###
// 指向父级Fiber节点
this.return = null;
// 指向子Fiber节点
this.child = null;
// 指向右边第一个兄弟Fiber节点
this.sibling = null;
this.index = 0;
this.ref = null;
### 作为动态的工作单元的属性, 保存了本次更新相关的信息###
//保存本次更新造成的状态改变相关信息
this.pendingProps = pendingProps;
this.memoizedProps = null;
this.updateQueue = null;
this.memoizedState = null;
this.dependencies = null;
this.mode = mode;
//保存本次更新会造成的DOM操作
this.effectTag = NoEffect;
this.nextEffect = null;
this.firstEffect = null;
this.lastEffect = null;
// 调度优先级相关
this.lanes = NoLanes;
this.childLanes = NoLanes;
// 指向该fiber在另一次更新时对应的fiber
this.alternate = null;
}
这里需要提一下,为什么父级指针叫做
return而不是parent或者father呢?因为作为一个工作单元,return指节点执行完completeWork(本章后面会介绍)后会返回的下一个节点。子Fiber节点及其兄弟节点完成工作后会返回其父级节点,所以用return指代父级节点。
Fiber架构原理
目标:解决React15 Reconciler递归更新虚拟DOM无法中断、耗时卡顿。
上节我们知道了 Fiber节点可以保存对应的DOM节点,Fiber节点构成的Fiber树就对应DOM树.
双缓存Fiber树
双缓存: 在内存中构建并直接替换。
在React中最多会同时存在两棵Fiber树。当前屏幕上显示内容对应的Fiber树称为current Fiber树,正在内存中构建的Fiber树称为workInProgress Fiber树,他们通过alternate(交替)属性连接
currentFiber.alternate === workInProgressFiber;
workInProgressFiber.alternate === currentFiber;
React应用的根节点通过使current指针在不同Fiber树的rootFiber间切换来完成current Fiber树指向的切换- 即当
workInProgress Fiber树构建完成交给Renderer渲染在页面上后,应用根节点的current指针指向workInProgress Fiber树,此时workInProgress Fiber树就变为current Fiber树 - 每次状态更新都会产生新的
workInProgress Fiber树,通过current与workInProgress的替换,完成DOM更新 workInProgress fiber的创建可以复用current Fiber树对应的节点数据,而决定是否复用的过程就是Diff算法
架构
Diff算法
计算出Virtual DOM中真正变化的部分,并只针对该部分进行原生DOM操作,而非重新渲染整个页面
传统diff算法
通过循环递归对节点进行依次对比,算法复杂度达到 O(n^3) ,n是树的节点数。一旦节点过多,计算量也是相当大的
React的diff算法-和调(将Virtual DOM树转换成actual DOM树的最少操作的过程)
使用三大策略将将O(n^3)复杂度 转化为 O(n)复杂度
-
- tree diff
- 对Virtual DOM树进行层级控制
- 两棵树 只对同一层次节点进行比较,如果节点不存在就直接删除该节点,其子节点也就不再进一步比较
- 这样只需遍历一次,就能完成整棵树的比较
如果是跨层级操作,那就直接创建或者删除节点。所以官方建议不要进行DOM节点跨层级操作,可以通过CSS隐藏、显示节点,而不是真正地移除、添加DOM节点。
-
- component diff
- 同一类型的两个组件,就继续按原层级比较即可
- 同一类型的两个组件如果Virtual DOM没有任何变化,就通过 shouldComponentUpdate() 来判断是否需要 判断计算
- 不同类型的组件,就是需要改变的,直接替换就好
-
- element diff :当节点处于同一层级时,使用三种diff操作:删除、插入、移动
- 插入:组件 C 不在集合(A,B)中,需要插入
- 删除: 有两种情况:1.组件 D 在集合(A,B,D)中,但 D的节点已经更改,不能复用和更新,所以需要删除 旧的 D ,再创建新的。 2.组件 D 之前在 集合(A,B,D)中,但集合变成新的集合(A,B)了,D 就需要被删除。
- 移动:组件D由在集合(A,B,C,D)变成(A,B,C,D),直接通过对其唯一添加的
key进行区分移动就好
hooks
useState
function App() {
const [num, setNum] = useState < number >(0);
return (
<div>
<div>num: {num}</div>
<button onClick={() => setNum(num + 1)}>加 1</button>
</div>
);
}
- 调用 useState 传入initState的时候,会返回形如
(变量, 函数)的一个元组。 - 返回的
setNum函数,内部自动调用了render方法来触发视图更新。类似于
function setState(newState: T) {
state = newState;
render();
}
多个useState
- 多个useState是存放在一个全局array容器中
- 第一渲染时,根据 useState 顺序,逐个声明 state 并且将其放入全局 Array 中。并且还有一个全局的hookIndex = 0。每次调用
useState,就相当于array数组lenght就+1, 而cursor 也会加1; - 当setState的时候,hookIndex 被重置为 0,按照 useState 的声明顺序,依次拿出最新的 state 的值,视图更新。
const states: any[] = [];
let hookIndex: number = 0;
function useState<T>(initialState: T): [T, (newState: T) => void] {
const currenHookIndex = cursor;
states[currenHookIndex] = states[currenHookIndex] || initialState; // 检查是否渲染过
function setState(newState: T) {
states[currenHookIndex] = newState;
render();
}
++hookIndex; // update:hookIndex
return [states[currenHookIndex], setState];
}
因为useState是由array保存的,如果将 放在
循环、判断内部使用 Hook,就会打乱 hookIndex,导致setState失败。所以在使用 Hook 的时候,请在函数组件顶部使用!
useEffect
useEffect(() => {
}, []);
在 useEffect 的第二个参数中,我们可以指定一个数组,去指定什么时候变化。相比于直接裸写在函数组件顶层,useEffect 能根据需要,避免多余的 render。下面是useEffect 的ts简易实现,不包含销毁useEffect的代码
// 还是利用 Array + Cursor的思路
const allDeps: any[][] = [];
let effectCursor: number = 0;
function useEffect(callback: () => void, deps: any[]) {
if (!allDeps[effectCursor]) {
// 初次渲染:赋值 + 调用回调函数
allDeps[effectCursor] = deps;
++effectCursor;
callback();
return;
}
const currenEffectCursor = effectCursor;
const rawDeps = allDeps[currenEffectCursor];
// 检测依赖项是否发生变化,发生变化需要重新render
const isChanged = rawDeps.some(
(dep: any, index: number) => dep !== deps[index]
);
if (isChanged) {
callback();
allDeps[effectCursor] = deps; // 感谢 juejin@carlzzz 的指正
}
++effectCursor;
}
function render() {
ReactDOM.render(<App />, document.getElementById("root"));
effectCursor = 0; // 注意将 effectCursor 重置为0
}