本文共 7735 字,大约阅读时间需要 25 分钟。
useEffect和useLayoutEffect是React Hooks中用于执行副作用的两个钩子函数,虽然它们功能相近,但执行时机存在显著差异。本文将从这两个钩子的执行时机入手,剖析React的运行原理和浏览器的渲染流程。
useLayoutEffect的函数签名与useEffect相同,但它会在所有DOM变更之后同步调用effect。它主要用于读取DOM布局并触发重渲染。在浏览器执行绘制之前,useLayoutEffect内部的更新计划会被同步刷新。相比之下,useEffect是异步的,执行时机在浏览器刷新屏幕Task之后。
简单来说,useEffect是异步的,而useLayoutEffect是同步的,这里的异、同步是相对于浏览器执行刷新屏幕Task而言。
通过一个简单的示例来说明具体执行过程。React 16.13.1版本:
import React, { useState, useEffect, useLayoutEffect } from 'react';const EffectDemo = () => { const [count, setCount] = useState(0); useEffect(() => { console.log('useEffect:', count); }, [count]); useLayoutEffect(() => { console.log('useLayoutEffect:', count); }, [count]); return ();};export default EffectDemo;
通过浏览器控制台Performance监控图可以看出,useLayoutEffect发生在页面渲染到屏幕(用户可见)之前,而useEffect发生在那之后,中间经历了DCL、FCP、FMP、LCP阶段。DCL是DOMContentLoaded,FCP是First Contentful Paint,FMP是First Meaningful Paint,LCP是Largest Contentful Paint。
在深入了解React运行之前,先在本地写一个简单的示例,模拟文章开始的例子:
// 例如,一个简单的useEffect和useLayoutEffect示例import React, { useState, useEffect, useLayoutEffect } from 'react';const Example = () => { const [count, setCount] = useState(0); useEffect(() => { console.log('useEffect:', count); }, [count]); useLayoutEffect(() => { console.log('useLayoutEffect:', count); }, [count]); return ();};export default Example;
启用Performance监控渲染情况:
React渲染页面分为两个阶段:
接下来跟随React的运行流程,具体看不同阶段的执行情况。
以上是整体流程,接下来深入一点,看看useEffect和useLayoutEffect是怎么解析和执行的。
从上图可知,useEffect和useLayoutEffect最终都会调用mountEffectImpl函数,然后初始化/更新Fiber的updateQueue。看一下mountEffectImpl函数:
function mountEffectImpl(fiberEffectTag, hookEffectTag, create, deps) { var hook = mountWorkInProgressHook(); var nextDeps = deps === undefined ? null : deps; currentlyRenderingFiber.effectTag |= fiberEffectTag; hook.memoizedState = pushEffect( HasEffect | hookEffectTag, create, undefined, nextDeps );}
这个函数的功能如下:
updateQueue是一个收尾相连的环形结构,用于存储需要执行的副作用。commitHookEffectListMount函数的遍历方式可以看出这一点:
function commitHookEffectListMount(tag, finishedWork) { var updateQueue = finishedWork.updateQueue; var lastEffect = updateQueue !== null ? updateQueue.lastEffect : null; if (lastEffect !== null) { var firstEffect = lastEffect.next; var effect = firstEffect; do { if ((effect.tag & tag) === tag) { // Mount var create = effect.create; effect.destroy = create(); { var destroy = effect.destroy; if (destroy !== undefined && typeof destroy !== 'function') { var addendum = void 0; if (destroy === null) { addendum = 'You returned null. If your effect does not require clean up, return undefined (or nothing).'; } else if (typeof destroy.then === 'function') { addendum = 'It looks like you wrote useEffect(async () => ...) or returned a Promise. Instead, write the async function inside your effect and call it immediately: useEffect(() => { async function fetchData() { ... } }, [someId]); // Or [] if effect doesn\'t need props or state. Learn more about data fetching with Hooks: https://fb.me/react-hooks-data-fetching'; } else { addendum = 'You returned: ' + destroy; } error('An effect function must not return anything besides a function, which is used for clean-up.%s%s', addendum, getStackByFiberInDevAndProd(finishedWork)); } } } effect = effect.next; } while (effect !== firstEffect); }}
这里根据effect的tag不同决定执行哪一种effect。我们的useEffectDemo和useLayoutEffectDemo的tag分别是5和3,因此需要执行useEffect中的副作用函数时,commitHookEffectListMount的tag肯定是5,执行useLayoutEffect中的副作用函数时,commitHookEffectListMount的tag肯定是3。
总的来说,所有的useEffect和useLayoutEffect的副作用函数都是在这里执行的,通过tag来控制他们的执行时机。
其实上面已经讲了commitHookEffectListMount的执行,这里再看下具体的执行过程:
function commitLifeCycles(finishedRoot, current, finishedWork, committedExpirationTime) { switch (finishedWork.tag) { case FunctionComponent: case ForwardRef: case SimpleMemoComponent: case Block: commitHookEffectListMount(Layout | HasEffect, finishedWork); return; case ClassComponent: var instance = finishedWork.stateNode; if (finishedWork.effectTag & Update) { if (current === null) { // 初次渲染 ... instance.componentDidMount(); stopPhaseTimer(); } else { // 更新渲染 ... instance.componentDidUpdate(prevProps, prevState, instance.__reactInternalSnapshotBeforeUpdate); stopPhaseTimer(); } } }}
可以看出,useLayoutEffect的入口是commitHookEffectListMount(Layout | HasEffect, finishedWork),而useEffect的入口是commitHookEffectListMount(HasEffect, finishedWork)。
现在对useEffect和useLayoutEffect的执行有了大致的了解,那么还有一个关于scheduler异步调度的问题。本文最开始的例子中是通过setTimeout来完成的,而React中则是通过MessageChannel来实现的。如果不熟悉可以查查使用方式,这里来看下异步执行的过程。
关于浏览器的渲染这里我就以推荐学习资料为主,因为我自己也没有这些讲解得好,就没必要重复了。
浏览器的渲染是一个十分复杂的过程,可以参考Google提供的介绍文章:https://developers.google.cn/web/fundamentals/performance/rendering
了解了浏览器的基本渲染之后,可以更加深入窥探浏览器的运行。首先上一张图:https://aerotwist.com/blog/the-anatomy-of-a-frame
推荐一篇讲解浏览器渲染的文章:https://juejin.im/entry/6844903476506394638
在学习Hooks的时候,难免会和class组件中的生命周期做比较。useEffect在某些程度上相当于componentDidMount、componentDidUpdate、componentWillUnmount这三个钩子函数的集合,因为这些函数都会阻塞浏览器的渲染。其中componentDidMount、componentDidUpdate的执行是在哪里呢,可以看一下上面提到的commitLifeCycles函数:
function commitLifeCycles(finishedRoot, current, finishedWork, committedExpirationTime) { switch (finishedWork.tag) { case FunctionComponent: case ForwardRef: case SimpleMemoComponent: case Block: commitHookEffectListMount(Layout | HasEffect, finishedWork); return; case ClassComponent: var instance = finishedWork.stateNode; if (finishedWork.effectTag & Update) { if (current === null) { // 初次渲染 ... instance.componentDidMount(); stopPhaseTimer(); } else { // 更新渲染 ... instance.componentDidUpdate(prevProps, prevState, instance.__reactInternalSnapshotBeforeUpdate); stopPhaseTimer(); } } }}