likes
comments
collection
share

谈谈React中useLayoutEffect钩子函数与浏览器的故事让我们再谈谈 React 中的 DOM 访问。在上一

作者站长头像
站长
· 阅读数 49

文章摘要: 如何根据DOM的一些是测量值来更改元素:

  1. useEffect 的问题是什么;
  2. useLayoutEffect 如何修复它;
  3. 什么是浏览器绘制;
  4. 以及 SSR 在这里如何发挥作用。

那么,它到底有什么问题,为什么“正常”策略不够好?让我们编写一些代码并弄清楚。在此过程中,我们将了解有关 useLayoutEffect 的所有信息,何时以及为什么要使用它而不是 useEffect,浏览器如何渲染我们的 React 代码,什么是重绘,为什么所有这些都很重要以及 SSR 如何在这里发挥作用。

useEffect 有什么问题?

我们来看一个示例:一个响应式导航组件。它可以渲染一行链接,并可以根据容器大小调整这些链接的数量。

谈谈React中useLayoutEffect钩子函数与浏览器的故事让我们再谈谈 React 中的 DOM 访问。在上一

如果某些链接不能在一行当中展示,则显示一个“更多”按钮,点击后将在下拉菜单中打开它们。

谈谈React中useLayoutEffect钩子函数与浏览器的故事让我们再谈谈 React 中的 DOM 访问。在上一

现在,组件本身。它将只是一个接受数据数组并呈现正确链接的组件:

const Component = ({ items }) => {
  return (
    <div className="navigation">
      {items.map((item) => (
        <a href={item.href}>{item.name}</a>
      ))}
    </div>
  );
};

现在,我们如何让它具有响应性?这里的问题是,我们需要计算可用空间中可以容纳多少个项目。为了做到这一点,我们需要知道渲染它们的容器的宽度以及每个项目的尺寸。我们不能提前假设任何事情,例如通过计算字符:文本在浏览器中的呈现方式将在很大程度上取决于所使用的字体、语言、浏览器,甚至表示一个周期变化的状态量。

在这里获取实际大小的唯一方法是让浏览器渲染这些项目,然后通过javascript API(如 getBoundingClientRect)提取大小。

我们必须分几步完成。首先,获取元素。我们可以创建一个 ref 并将其分配给包装这些项目的 div:

const Component = ({ items }) => {
  const ref = useRef(null);

  return (
    <div className="navigation" ref={ref}>
      // ...
    </div>
}

其次,在 useEffect 中或取 div 元素并获取其大小。

const Component = ({ items }) => {

  useEffect(() => {
    const div = ref.current;
    const { width } = div.getBoundingClientRect();
  }, [ref]);

  // return ...
}

第三,遍历 div 的子元素并将它们的宽度提取到一个数组中。

const Component = ({ items }) => {
    useEffect(() => {
        // 与之前相同的代码
        // 将 div 的子元素转换为数组
        const children = [...div.childNodes];
        // 所有宽度
        const childrenWidths = children.map(child => child.getBoundingClientRect().width)
    }, [ref]);

    // return ...
}

现在,我们需要做的就是遍历该数组,计算子元素的宽度总和,并将总和与父元素 div 进行比较,最后找到最后一个可见元素。

但是,我们忘了一件事:“更多”按钮,我们还需要考虑它的宽度,否则,我们可能会发现有些元素可以放进去,但“更多”按钮放不进去。

谈谈React中useLayoutEffect钩子函数与浏览器的故事让我们再谈谈 React 中的 DOM 访问。在上一

再次强调,我们只有在浏览器中渲染时才能获取其宽度。因此,我们必须在初始渲染期间明确添加按钮:

const Component = ({ items }) => {
  return (
    <div className="navigation">
      {items.map(item => <a href={item.href}>{item.name}</a>)}
      <button id="more">...</button>
    </div>
  )
}

如果我们将计算宽度的所有逻辑抽象到一个函数中,我们将在 useEffect 中得到类似这样的内容:

useEffect(() => {
  const itemIndex = getLastVisibleItem(ref.current)
}, [ref]);

getLastVisibleItem 函数会进行所有计算并返回一个数字-最后一个可以放入可用空间的链接的索引,这里不会深入研究逻辑本身,尽管有无数种方法可以做到这一点,稍后将在最终的代码示例中展现。

这里重要的是我们得到了这个数字。从 React 的角度来看,我们接下来应该做什么?如果我们保持原样,所有链接和“更多”按钮都将可见。这里只有一个解决方案-我们需要触发组件的更新并使其删除所有不应该在那里的项目。

而且几乎只有一种方法可以做到这一点:我们需要在获取它时将该数字保存在状态中:

const Component = ({ items }) => {
    // 将初始值设置为 -1,表示我们尚未运行计算
    const [lastVisibleMenuItem, setLastVisibleMenuItem] = useState(-1);

    useEffect(() => {
        const itemIndex = getLastVisibleItem(ref.current);
        // 使用实际数字更新状态
        setLastVisibleMenuItem(itemIndex);
    }, [ref]);
}

然后在呈现菜单时,我们需要考虑到这一点:

const Component = ({ items }) => {

    // 如果这是第一次传递并且值仍然是默认值,则渲染所有内容
    if (lastVisibleMenuItem === -1) {
        // 在这里渲染所有项,与之前相同
        // return ...
    }

    // 如果最后一个可见项不是数组中的最后一个,则显示“更多”按钮
    const isMoreVisible = lastVisibleMenuItem < items.length - 1;

    // 过滤掉索引大于最后一个可见项的项
    const filteredItems = items.filter((item, index) => index <= lastVisibleMenuItem);

    return (
        <div className="navigation">
            {filteredItems.map(item => <a href={item.href}>{item.name</a>)}
            {isMoreVisible && <button id="more">...</button>}
        </div>
    )
}

就是这样!现在,在用实际数字更新状态后,它将触发导航的重新渲染,React 将重新渲染项目并删除不可见的项目。为了获得“正确”的响应体验,我们还需要监听 resize 事件并重新计算数字,但我将留给你来实现。

前往这里查看完整示例。

只是不要太兴奋:这里的用户体验有一个主要缺陷。

尝试刷新几次,尤其是在 CPU 速度变慢的情况下,不幸的是,导航内容闪烁得非常厉害。你应该能够清楚地看到初始渲染-当菜单中的所有项目和“更多”按钮都可见时,我们绝对需要在投入生产之前修复它。

使用 useLayoutEffect 修复此问题

闪烁的原因应该很明显:我们在移除不必要的项目之前,先渲染这些项目并使其可见。我们必须先渲染它们,否则,响应将无法工作。因此,一个可能的解决方法是仍然渲染第一遍,但不可见:将不透明度设置为 0,或者在可见区域之外的某个 div 中,并且只有在我们提取尺寸和魔法数字后,才使它们可见,这是我们过去处理此类情况的方法。

然而,在从React 版本 16.8(带有钩子)开始,我们需要做的就是用 useLayoutEffect 替换我们的 useEffect 钩子。

const Component = ({ items }) => {
    // 所有内容完全相同,只有钩子名称不同
    useLayoutEffect(() => {
        // 代码仍然相同
    }, []);
}

这是纯粹的魔法,并且不再有初始闪烁,前往这里查看示例看看吧。

但是这样做安全吗?为什么我们不经常使用它而选择使用 useEffect?文档明确指出 useLayoutEffect 会影响性能,应避免使用。为什么呢?它还说它是在“浏览器重新绘制屏幕之前”触发的,这意味着 useEffect 是在之后触发的。但从实际意义上讲,这到底意味着什么?现在编写简单的下拉菜单时,我是否需要考虑浏览器重绘之类的低级概念?

要回答这些问题,我们需要暂时放下 React,转而讨论浏览器和老旧的 Javascript。

修复为何有效:渲染、重绘和浏览器

这里我们需要的第一件事是“浏览器渲染”。在 React 世界中,它也被称为“重绘”,只是为了将其与 React 的渲染区分开来 - 它们是非常不同的!这里的想法相对简单。浏览器不会持续更新需要实时显示在屏幕上的所有内容。它不像在白板上画画,你画线、擦线、写一些文字或画一只猫头鹰。

相反,它更像是向人们展示幻灯片:你展示一张幻灯片,等待他们理解上面的天才想法,然后过渡到下一张幻灯片,依此类推。如果要求一个非常慢的浏览器给出如何画猫头鹰的说明,它可能就是臭名昭著的图片:

谈谈React中useLayoutEffect钩子函数与浏览器的故事让我们再谈谈 React 中的 DOM 访问。在上一

只有图片才能做到非常非常快,通常,现代浏览器会尝试保持 60 FPS 的速率,即每秒 60 帧,每 13 毫秒左右,一张幻灯片就会切换到下一张,这就是我们在 React 中所说的“重绘”。

更新这些幻灯片的信息被分成“任务”,任务被放入队列中,浏览器从队列中抓取任务并执行,如果还有更多时间,它会执行下一个任务,依此类推,直到在 ~13ms 的间隙中没有剩余时间,然后刷新屏幕。并继续不停地工作,不知疲倦地工作,以便我们能够做诸如在 Twitter 上滚动浏览等重要的事情,甚至不会注意到它所花费的努力。

什么是“任务”?对于普通的 Javascript,它是我们放入脚本标记并同步执行的所有内容。考虑以下代码:

const app = document.getElementById("app");
const child = document.createElement("div");
child.innerHTML = "<h1>Heyo!</h1>";
app.appendChild(child);

child.style = "border: 10px solid red";
child.style = "border: 20px solid green";
child.style = "border: 30px solid black";

我通过 id 获取一个元素,将其放入 app 变量中,创建一个 div,更新其 HTML,将该 div 附加到应用程序,并更改 div 的边框三次。整个过程将被视为浏览器的一项任务。因此它将执行每一行,然后才绘制最终结果:带有黑色边框的 div,你将无法在屏幕上看到这种红绿黑过渡。

如果“任务”花费的时间超过 13 ms会发生什么?不过很遗憾,浏览器无法停止或拆分它。它会继续执行直到完成,然后绘制最终结果,如果我在这些边框更新之间添加 1 秒的同步延迟:

const waitSync = (ms) => {
  let start = Date.now(),
    now = start;
  while (now - start < ms) {
    now = Date.now();
  }
};

child.style = "border: 10px solid red";
waitSync(1000);
child.style = "border: 20px solid green";
waitSync(1000);
child.style = "border: 30px solid black";
waitSync(1000);

我们仍然无法看到“中间的过程”,我们只能盯着空白屏幕,直到浏览器整理好,然后看到最终的黑色边框。这就是我们所说的“阻塞渲染”“阻塞重绘”代码。

前往这里查看示例。

现在,尽管 React 只是 javascript,但它当然不是作为一项单一任务执行的。将整个应用程序渲染成较小的任务等大型任务“分解”的方法是使用各种“异步”方法:回调、事件处理程序、期约(Promise)等。

如果我将这些样式调整包装在 setTimeout 中,即使延迟为 0:

setTimeout(() => {
  child.style = "border: 10px solid red";
  wait(1000);
  setTimeout(() => {
    child.style = "border: 20px solid green";
    wait(1000);
    setTimeout(() => {
      child.style = "border: 30px solid black";
      wait(1000);
    }, 0);
  }, 0);
}, 0);

那么每个超时都将被视为一项新“任务”。因此,浏览器将能够在完成一个任务之后、开始下一个任务之前重新绘制屏幕。我们将能够看到从红色到绿色再到绿色的缓慢但清晰的过渡,而不是在白色屏幕上等待三秒钟。

前往这里查看示例。

这就是 React 为我们所做的,本质上,它是一个非常复杂且非常高效的引擎,它将我们庞大的数百个 npm 依赖项与我们自己的编码相结合的庞大块分割成浏览器能够在 13 毫秒内(理想情况下)处理的最小块。

所有这些都是一个非常简短和简化的介绍,否则,这篇文章本身就会变成一本书。

关于浏览器事件循环和队列主题的非常好的综合指南在这里:浏览器事件循环:微任务和宏任务、调用堆栈、渲染队列:布局、绘制、复合。

回到 useEffect 与 useLayoutEffect

现在,终于回到 useEffect 与 useLayoutEffect 的比较以及如何回答我们一开始的问题。

useLayoutEffect 是 React 在组件更新期间同步运行的。在此代码中:

const Component = () => {
  useLayoutEffect(() => {
    // do something
  })

  return ...
}

无论我们在 Component 中渲染什么,都会使用 useLayoutEffect 作为同一个“任务”运行。React 保证了这一点。即使我们在 useLayoutEffect 中更新状态(我们通常认为这是一个异步任务),React 仍将确保整个流程同步运行。

如果我们回到一开始实现的“导航组件”示例,从浏览器的角度来看,它只是一个“任务”。

谈谈React中useLayoutEffect钩子函数与浏览器的故事让我们再谈谈 React 中的 DOM 访问。在上一

这种情况和我们看不到的红绿黑边框过渡一模一样!

另一方面,使用 useEffect 的流程将分为两个任务:

谈谈React中useLayoutEffect钩子函数与浏览器的故事让我们再谈谈 React 中的 DOM 访问。在上一

  1. 第一个渲染带有所有按钮的“初始”导航过程。
  2. 第二个删除了我们不需要的子项。

中间会重新绘制屏幕!与超时内的边框情况完全相同。

因此,回答我们一开始的问题,使用 useLayoutEffect 安全吗?是的!它会影响性能吗?绝对!我们最不希望看到的是整个 React 应用程序变成一个巨大的同步“任务”。

仅当你需要摆脱由于需要根据元素的实际大小调整 UI 而导致的视觉“故障”时才使用 useLayoutEffect,对于其他所有情况,useEffect 都是可行的方法,而且你甚至可能也不需要:你可能不需要effect - React

关于 useEffect 的更多信息

虽然 useEffect 在 setTimeout 中运行的思维模型便于理解差异,但从技术上讲并不正确。首先,为了使实现细节清晰,React 改用 postMessage 结合 requestAnimationFrame 技巧。这里为那些喜欢细节的人描述了它:React:React 如何确保在浏览器有机会绘制后调用 useEffect?

其次,它实际上并不能保证异步运行,虽然 React 会尝试尽可能地优化它,但在某些情况下它可以在浏览器重绘之前运行,并因此阻止它。其中一种情况是当你已经在更新链中的某个地方使用了 useLayoutEffect 时。如果你需要了解其原因及其工作原理,可以前往这里查看:useEffect 有时会在重绘之前触发

Next.js 和其他 SSR 框架中的 useLayoutEffect

低级 javascript 和浏览器的东西说得够多了,让我们回到我们的生产代码。因为在实际项目当中,我们不需要经常关心所有这些。在实际项目当中,我们只想编写漂亮的响应式导航,并在 Next.js(或任何其他框架)等花哨的框架中使用它构建一些不错的用户体验。

当我们尝试这样做时,我们首先会注意到它像往常一样根本不起作用,故障仍然存在,尝试打开此示例并刷新页面几次,或者,如果你有 Next.js 应用程序,请将之前修复的导航复制粘贴到其中。

发生了什么?

这是 SSR,服务器端渲染,一个很酷的功能,一些框架默认支持它,当涉及到这样的事情时,真的很痛苦。

你看,当我们启用 SSR 时,在代码到达浏览器之前,渲染 React 组件和调用所有生命周期事件的第一步是在服务器上完成的。如果你不熟悉 SSR 的工作原理,它只意味着在后端的某个地方,某个方法调用了类似 React.renderToString(<App />) 的东西。

然后,React 会遍历应用程序中的所有组件,“渲染”它们(即只调用它们的函数,毕竟它们只是函数),然后生成这些组件所代表的 HTML。

谈谈React中useLayoutEffect钩子函数与浏览器的故事让我们再谈谈 React 中的 DOM 访问。在上一

然后,此 HTML 被注入到将要发送到浏览器的页面中,然后它就开始运行了,就像在以前的互联网时代一样,一切都在服务器上生成,我们只使用 javascript 来打开菜单。之后,浏览器下载页面,向我们显示,下载所有脚本(包括 React),运行它们(再次包括 React),React 浏览预先生成的 HTML,在其上添加一些交互性,我们的页面现在又活跃起来了。

这里的问题是:当我们生成初始 HTML 时,还没有浏览器,因此,任何涉及计算元素实际大小的操作(就像我们在 useLayoutEffect 中所做的那样)都无法在服务器上工作:还没有具有尺寸的元素,只有字符串,由于 useLayoutEffect 的全部目的是访问元素的大小,因此在服务器上运行它没有多大意义,而 React 则没有。

因此,当浏览器向我们显示尚未交互的页面时,我们在第一次加载时看到的是我们在组件的“第一遍”阶段渲染的内容:所有按钮的行,包括“更多”按钮。在浏览器有机会执行所有操作并且 React 启动后,它终于可以运行 useLayoutEffect,按钮最终被隐藏,但视觉故障仍然存在。

如何修复它是一个用户体验问题,完全取决于你愿意“默认”向用户显示什么,我们可以向他们显示一些“加载”状态而不是菜单,或者显示一两个最重要的菜单项,或者甚至完全隐藏项目并仅在客户端上渲染它们,这取决于你。

一种方法是引入一些“shouldRender”状态变量并在 useEffect 中将其更改为“true”:

const Component = () => {
  const [shouldRender, setShouldRender] = useState(false);

  useEffect(() => {
    setShouldRender(true);
  }, []);

  if (!shouldRender) return <SomeNavigationSubstitude />;

  return <Navigation />
}

useEffect 只会在客户端上运行,因此初始 SSR 传递将向我们显示替代组件,然后,客户端代码将启动,useEffect 将运行,状态将更改,React 将用正常的响应式导航替换它。

不要害怕在这里引入状态,也不要尝试像这样进行条件渲染:

const Component = () => {
  // 通过检查窗口是否存在来检测是否是服务端环境
  if (typeof window === undefined) return <SomeNavigationSubstitude />;

  return <Navigation />
}

虽然从技术上讲 typeof window === undefined 表示 SSR 环境(服务器上没有窗口),但这不适用于我们的示例,React 需要来自 SSR 的 HTML 和客户端上第一次初始渲染的 HTML 才能完全匹配,否则,你的应用程序会表现得像醉了一样。如果在经历了所有这些之后,你仍然渴望了解更多细节,可以前往这里查看:the-perils-of-rehydration

the-perils-of-rehydration个人理解应该是客户端重新激活。

哎呀,本来应该是一篇轻松愉快的“嘿,这是一个很酷的技巧”的文章,不知何故几乎变成了一场渲染深度探索,以下是一些参考文章链接。

本文参考这篇文章,在原版上有做改动。

转载自:https://juejin.cn/post/7419907933243588619
评论
请登录