在页面元素很多,且需要频繁刷新的场景下,React 15 会出现掉帧的现象。请看以下例子:
其根本原因,是大量的同步计算任务阻塞了浏览器的 UI 渲染。
默认情况下,JS 运算、页面布局和页面绘制都是运行在浏览器的主线程当中,他们之间是互斥的关系。如果 JS 运算持续占用主线程,页面就没法得到及时的更新。
当我们调用setState
更新页面的时候,React 会遍历应用的所有节点,计算出差异,然后再更新 UI。整个过程是一气呵成,不能被打断的。如果页面元素很多,整个过程占用的时机就可能超过 16 毫秒,就容易出现掉帧的现象。
针对这一问题,React 团队从框架层面对 web 页面的运行机制做了优化,得到很好的效果。
所以,React Fiber简单来说就是一个从React v16开始引入的新协调引擎,用来实现Virtual DOM的增量渲染。
说人话:就是一种比线程还要细粒度的处理机制、一种能让React视图更新过程变得更加流畅顺滑的处理手法。
出现掉帧的原因是因为 React 的前任 Reconciler —— Stack Reconciler,这种通过栈的方式实现任务调度的方式导致的。
Stack 这种架构的特点就是,所有任务都按顺序的压入了栈中,而执行的时候无法确认当前的任务是否会耗去过长的脚本运行时间,使得这 16ms 里浏览器能做的事不可控,甚至让 fetch data 这类实时意义很大的任务要等很久才能执行。
而对于fiber来说:
把耗时长的更新任务拆解成一个个小的任务分片,每执行完一个小的任务分片,都归还一次主线程,看看有没有什么其他紧急任务要做。如果在归还主线程时恰巧发现有紧急任务,那么会马上停掉当前更新任务,转而让主线程去做紧急任务,等主线程做完紧急任务,再重新做更新任务。(注意⚠️:是重新!不是从上次被打断的点继续);如果没有紧急任务,才敢唯唯诺诺地继续做接下来的任务分片。
如此一来,我们想要的就是:
解决方法:
const FetchTask = {
tag: 'sideEffect',
priority: 'high'
}
const ComputeTask = {
tag: 'compute',
priority: 'middle'
}
const DomTask = {
tag: 'dom-update',
priority: 'low'
}
然后,为了
fiber就是发挥作用了。
fiber设计原则
我们提炼一下官方给出的一些相关内容,大致可得:
更新 UI 是一件相当耗时的事情,而并不是每一次更新都需要立即的呈现出来;过多的执行 UI 的更新可能引起掉帧,影响用户体验;
不同类型的 UI 更新应该有不同的优先级,比如,相比把数据呈现到界面上,连续的动画具有更高的绘制优先权,一旦数据更新占用了过长的时间,就会导致动画掉帧,而 React 则做了一些事情来使得数据更新操作可以延迟执行,不打断动画的发挥
React 使用 pull-based 的方式来使得计算在必要的时候才执行
React 这个名字取得很差,应该叫 Schedule
知道 React 给更新任务分了优先级,那么它到底是怎么实现协程(微线程)的呢?这就要提到两个重点 API 了
requestAnimationFrame
window.requestAnimationFrame(callback);
实际上,这个 API 在前端世界可谓是曝光率极高了,它的作用就是将传入的 callback 在下一帧开始时立即执行。有时候,我们甚至会忌惮于框架本身的性能从而选择用它来实现一些 UI 的更新
而 React 则利用了它,在每一帧的浏览器任务开始的时候,将高优先级的任务从任务队列中 pull 下来(还记得之前说的 pull-based 吗?)这样就使得该先行的任务尽量优先的执行。
requestIdleCallback
**window.requestIdleCallback()**
方法将在浏览器的空闲时段内调用的函数排队。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。函数一般会按先进先调用的顺序执行,然而,如果回调函数指定了执行超时时间timeout
,则有可能为了在超时前执行函数而打乱执行顺序。by MDN(https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestIdleCallback)
var handle = window.requestIdleCallback(callback[, options])
j简单来说,浏览器一帧的剩余空闲时间内执行优先度相对较低的任务。使得低优先级的任务可以让出资源来供高优先级的任务和浏览器绘制先行,如果这些任务耗尽了这帧的时间,那低优先级的任务就会被排到下次帧空闲的时候执行。
总结
我们知道,React 组件的展示是由其 props 和 state 来控制的,那么对于最终的 DOM 呈现,我们可以将 props 和 state 都视为 props。然后在 Fiber 中,同样有着两个有关props的状态,一个 pendingProps 和 memorizedProps 状态,其中:
更新时,低优先级的任务的执行资源会被让出,也就是说整体的更新执行会被暂停,这就是 memorizedProps 被定义的时机;
而到了下次恢复这次整体的更新时,Fiber 会对比 memorizedProps 和新定义的 pendingProps,如果相等,则复用上次更新的结果,这就是所说的 Fiber 复用已完成任务结果的方式。
而这里又会引出另一个问题,既然是暂停,那么恢复之后应当继续执行当前的任务,怎么会涉及到 props 的对比呢?我们下面就进入 Algebraic Effect 的相关内容来解答它。
运行时系统会在某个效应产生的时候,寻找离这个产生的效应最近的处理器来处理它;如果这个处理器存在,那么它将根据导致效应产生的参数及本身的计算续体执行对应的操作。计算续体使得运行时可以应用这一系列操作产生的结果来恢复效应产生前的计算。
译自《Handling Polymorphic Algeraic Effects》
通俗点:代数效应
能够将副作用
从函数逻辑中分离,使函数关注点保持纯粹。
在 JavaScript 语境下,许多解释代数效应的文章都使用了 try/catch
块来帮助理解(其实都是在抄 Dan Aramov 那篇文章罢了)
这里也稍微提一下,来看这么一段代码:
function A_Computation() {
const x = getX()
const y = 1
return x + y
}
我们看到,当这个函数进行的时候,x
是从 getX
这个方法中拿取的,也就是说,x
的值由 getX
的返回值决定,那么如果这个 getX
有产生错误,即 throw Error
的可能,我们或许就要这么写:
function A_Computation() {
try {
const x = getX()
const y = 1
return x + y
} catch(E) {
// TODO
}
}
上面代码的意义就是,对 try 块中捕获到的错误,由 catch 块进行处理。
那么设想一下,如果有这么一种语句:
function A_Computation() {
effect {
getX()
} handle {
if (getX() > 3) {
resume parseInt(Math.random() * 3)
} else {
resume getX()
}
}
const x = getX()
const y = 1
return x + y
}
这段目前并不存在的语法表示的是,对于 getX
产生的效应,由 handle 块进行处理,并加上了一些逻辑,最后通过 resume
运算符将结果再恢复到函数的执行中,那么如此一来,我们的 x
就永远不会大于 3 了
这基本就是代数效应所蕴含的东西了。不懂的,在想想这句话:
代数效应
能够将副作用
从函数逻辑中分离,使函数关注点保持纯粹。
到现在,我们阐释了代数效应的一些基本机制,但是这和 React 有什么关系呢?
我们来看看 hooks 是怎么使用的:
function AComponent() {
const [x, setX] = useState(0)
const y = 1
useEffect(() => {
if (x > 3) {
setX(parseInt(Math.random() * 3))
}
}, [x])
return (
{ x + y }
)
}
function useX(initial, max) {
const [x, setX] = useState(initial)
useEffect(() => {
if (x > max) {
setX(parseInt(Math.random() * 3))
}
}, [x])
return [x, setX]
}
function AComponent() {
const [x, setX] = useX(0, 3)
return (
{ x + y )
)
}
这样以来,我们的 x 作为一个具有效应及效应处理器的代数,不仅为 AComponent 服务,还可以被复用于其他组件了
React 在底层搞了个 momorizedProps 来缓存状态,在上层,则向开发者提供了 useMemo
和 useCallback
两个 hooks 进行手动的优化。
function AComponent() {
const [x, setX] = useX(0, 3)
const [y, setY] = useY(0, 3)
const xMulti5 = useMemo(() => x * 5, [x])
const plusX = useCallback(() => setX(x + 1), [])
return (
{ x + xMulti5 + y }
)
}
xMulti5
仅在 x
发生改变的时候才会刷新值,而 plusX
则没有任何改变的理由(一定不能忘了这个空数组)了。
还记得前面那句话吗:Fiber 会对比 memorizedProps 和新定义的 pendingProps,如果相等,则复用上次更新的结果,这就是所说的 Fiber 复用已完成任务结果的方式。
除此之外,还有suspense。可以看看官方的Suspense Demo。
总结
React 通过代数效应产生一系列的任务,并交付 Fiber 去决定这些任务的执行时机,实现了一种类似于协程的调度优化。
Fiber & Algebraic Effects