提高 React 应用程序的内存效率 | Million.js 超越速度

如果您听说过 Million JS(来自其创建者Aiden BaiTobi Adedeji在 Twitter 上的 React puzzles),您可能会对标题感兴趣:“让 React 速度加快 70%”。

大多数开发人员都抱有“更快更好”的心态,原因有几个,即 SEO 和用户体验。如果我可以编写简单的 React 并使其与 Svelte 和 Vue(在某些情况下)等许多框架一样快或更快,那么这就是胜利,对吧?

然而,实际上,Million 优化 React 应用程序还有其他一些原因,这些原因与“速度”无关,更多地与兼容性有关,无论是旧设备、速度慢的笔记本电脑、资源有限的手机等。

最终,所有这一切都归结为记忆。

打开 10 个选项卡的 Chrome 窗口会让你的旧笔记本电脑突然停止运转,这一古老的模因在现实中的基础比人们意识到的要多得多。

如果我们看看应用程序在良好的网络上运行速度非常慢的情况,那么它通常与带宽关系不大,而与内存关系更大,这就是我们在本文。

没有百万的反应

典型的 React 应用程序在没有 Million 且没有像 Next.js 这样的服务器端框架的情况下工作的方式是,对于 JSX 中的每个组件,转译器 (Babel) 调用一个名为的函数,该函数不输出 HTML 元素,而是输出React.createElement()React元素

这些 React 元素实际上创建了 Javascript 对象,因此你的 JSX:

<div>Hello world</div>

变成React.createElement()如下所示的 Javascript 调用:

React.createElement('div', {}, 'Hello world')

这会得到一个 Javascript 对象,如下所示:

{
    $$typeof: Symbol(react.element),
    key: null,
    props: { children: "Hello world" },
    ref: null,
    type: "div"
}

现在,根据组件树的复杂程度,我们可以拥有越来越深的嵌套对象(DOM 节点),其中根元素的键props每页有数百或数千个子元素。

这个对象虚拟 DOM,ReactDOM 从中创建实际的 DOM 节点。

假设我们只有三个嵌套 div:

<div>
    <div>
        <div>
            Hello world
        </div>
    </div>
</div>

这将变得在幕后看起来更像这样:

{
    $$typeof: Symbol(react.element),
    key: null,
    props: {
        children: {
            {
                $$typeof: Symbol(react.element),
                key: null,
                props: {
                    children: {
                        {
                            $$typeof: Symbol(react.element),
                            key: null,
                            props: { children: "Hello world" },
                            ref: null,
                            type: "div"
                        }
                    },
                ref: null,
                type: "div"
            }
        }
    },
    ref: null,
    type: "div"
}

相当大,对吧?请记住,这也仅适用于具有三个元素的嵌套对象!

从这里开始,当嵌套对象发生变化时(即当状态导致组件呈现不同的输出时),React 将比较旧的虚拟 DOM 和新的虚拟 DOM,更新实际 DOM,使其与新的虚拟 DOM 匹配,并丢弃旧组件树中的任何陈旧对象。

useState()请注意,这就是为什么大多数 React 教程会建议将或尽可能移到useEffect()树的下方,因为必须重新渲染的组件越小,完成此比较过程(差异)的效率就越高。

提高 React 应用程序的内存效率 | Million.js 超越速度

现在,与传统的服务器渲染相比,比较的成本非常高,在传统的服务器渲染中,浏览器只是接收 HTML 字符串,解析它,然后将其放入 DOM 中。

虽然服务器渲染不需要 Javascript,但 React 不仅需要它,而且还会在进程中创建这个巨大的嵌套对象,并且在运行时,React 必须不断检查更改,这非常消耗 CPU 资源。

内存使用情况

高内存使用率的产生有两种方式:存储大对象和连续比较大对象。如果您还将状态存储在内存中并使用也在内存中存储状态的外部库(大多数人可能都是这样,包括我自己),那么还要额外加上额外的费用。

在内存受限的环境中,存储大对象本身就是一个问题,因为移动和/或较旧的设备一开始可能没有太多 RAM,对于使用自己的小而有限的内存进行沙箱处理的浏览器选项卡来说更是如此。

您的浏览器选项卡是否曾经因为“消耗太多能量”而刷新过?这可能是高内存使用率加上设备无法处理的连续 CPU 操作以及运行其他操作(例如操作系统、后台应用程序刷新、保持其他浏览器选项卡打开等)的组合。

此外,比较大型组件树意味着每当 UI 更新时就用新对象替换旧对象,并将旧对象扔到垃圾收集器中,在应用程序的整个生命周期中不断重复该过程。对于更加动态、交互式的应用程序(又名 React 的主要卖点)来说尤其如此。


正如您所看到的,即使是一个简单的组件(您只需更改 div 中的一个单词)的比较过程也意味着需要垃圾收集来删除一个对象。但是,如果您的对象树中有数千个这样的节点,并且其中许多节点依赖于动态状态,会发生什么情况?

用于状态管理(如 Redux)的不可变对象存储通过不断向其 Javascript 对象添加越来越多的节点来增加内存负担。

因为这个对象存储是不可变的,所以它只会不断增长,这进一步限制了应用程序其余部分可用于更新 DOM 等操作的内存。所有这些都会给最终用户带来一种缓慢、有问题的体验。

V8 和垃圾收集

现代浏览器对此进行了优化,对吧?V8 的垃圾收集进行了令人难以置信的优化并且运行速度非常快,那么这真的是一个问题吗?

这种做法有两个问题。

  1. 即使垃圾收集运行得很快,垃圾收集也是一个阻塞操作,这意味着它会在后续的 Javascript 渲染中引入延迟
  • 必须进行垃圾收集的对象越大,这些延迟花费的时间就越长。最终,会有如此多的对象创建,以至于需要一遍又一遍地运行垃圾收集来为这些新对象释放内存,当您的 React 应用程序打开相当长的时间时,通常会出现这种情况。
  • 如果您曾经致力于优化 React 应用程序并将其打开几个小时,然后您单击一个按钮仅需要 10 秒以上的时间才能响应,那么您就知道这个过程。
  1. 即使 V8 进行了高度优化,React 应用程序通常也没有进行高度优化,事件侦听器通常不会被卸载、组件太大、组件的静态部分不会被记忆等。
  • 所有这些因素(即使它们通常错误和/或开发人员的不幸)都会增加内存使用量,有些因素(如未卸载的事件侦听器)甚至会导致内存泄漏。是的,内存泄漏。在托管内存环境中。


当存在内存泄漏时,Dynatrace 可以很好地可视化 Node JS 应用程序随时间的内存使用情况。即使垃圾收集(黄线的向下运动)在接近尾声时变得越来越积极,内存使用量(和分配)也会不断增加。

甚至 Dan Abramov 在播客中也提到,Meta 工程师编写了一些非常糟糕的 React 代码,因此编写“糟糕”的 React 并不困难,特别是考虑到在 React 中使用闭包(在内部编写的函数)创建内存是多么容易和),或者需要在 JSX 中循环数组,这会在内存中创建原始数组的克隆useEffect()useState()Array.prototype.map()

所以并不是说高性能 React 是不可能的。只是如何编写性能最佳的组件通常并不直观,而且性能测试的反馈循环通常必须等待使用各种浏览器和设备的现实用户。

注意:高性能 Javascript可能的(我强烈推荐 Colt McAnlis 的这篇演讲),但它也很难实现,因为它需要对象池和静态数组列表分配之类的东西才能实现。

这两种技术都很难在 React 中利用,因为 React 本质上是组件化的,并且通常不会促进大量回收全局对象的使用(例如 Redux 的大型、不可变的单一对象存储

然而,这些优化技术仍然经常在幕后使用,例如虚拟化列表,这些列表会回收行在视图之外的大型列表中的 DOM 节点。您可以在LG 的 Seungho Park 的演讲中看到更多此类特定于 React 的优化技术(特别是针对电视等低端设备)。

与百万反应

请记住,尽管内存限制是真实存在的,但开发人员通常会意识到在运行开发服务器时打开的选项卡或应用程序的数量,因此,除了一些可能会提示刷新或更新的错误体验之外,我们通常不会注意到它们。开发中服务器重启。但是,您的用户可能会比您更频繁地注意到,尤其是在旧手机、平板电脑、笔记本电脑上,因为他们没有清除您的应用程序的打开应用程序/选项卡。

那么 Million 采取了什么不同的做法来解决这个问题呢?

好吧,Million 是一个编译器,虽然我不会在这里详细介绍所有内容(您可以在这些链接中阅读有关块 DOM和 Millionblock()函数的更多信息),Million 可以静态分析您的 React 代码并自动将 React 组件编译为紧密优化的更高版本订购组件,然后由 Million 渲染。

Million 使用更接近细粒度反应性的技术(大呼Solid JS),其中观察者被放置在必要的 DOM 节点上以跟踪其他优化中的状态变化,而不是使用虚拟 DOM。

这使得 Million 的性能和内存开销比 Preact 或 Inferno 等注重性能的虚拟 DOM 更接近优化的普通 Javascript,但在 React 之上没有抽象层。也就是说,使用 Million 并不意味着将您的 React 应用程序转移到使用“React 兼容”库。这只是简单的 React,Million 本身可以通过我们的CLI自动优化。

请记住,Million 并不适合所有用例。稍后我们将讨论 Million 适合/不适合的地方。

内存使用情况

在内存使用方面,Million 使用的内存大约是 React 在页面加载后待机时使用的内存的 55%,这是一个很大的差异。即使在 Chrome 113 上(我们目前使用的是 117),它使用的内存还不到 React 对于每个操作使用的内存的一半,否则通过Krausest 的 JS Framework Benchmark进行测试。

提高 React 应用程序的内存效率 | Million.js 超越速度

当向页面添加 10,000 行(基准测试中最繁重的操作)时,与使用普通 Javascript 相比,使用 Million 所占用的内存最多最多高出 28% 左右(15MB 与 11.9MB),而 React 会使用与普通 Javascript 相比,完成相同任务的速度大约为 303%(36.1 MB vs. 11.9 MB)。

再加上您的应用程序在其生命周期内完成的总操作,当使用纯粹的虚拟 DOM 与混合块 DOM 方法时,性能和内存使用量都会发生巨大变化,特别是当您考虑状态管理、库/依赖项等时。当然,这对百万有利。

等等,但是_呢?

与所有事情一样,使用 Million 和块 DOM 方法时需要权衡。毕竟,React 的发明是有原因的,而且肯定还有理由使用它。

动态组件

假设您有一个高度动态的组件,其中的数据经常更改。

例如,也许您有一个正在使用股票市场数据的应用程序,并且您有一个显示最近 1,000 笔股票交易的组件。该组件本身是一个列表,它会根据每次股票交易是买入还是卖出而改变呈现的列表项组件。

为简单起见,我们假设它已经预先填充了 1000 笔股票交易。

import { useState, useEffect } from "react";
import { BuyComponent, SellComponent } from "@/components/recent-trades"

export function RecentTrades() {
    const [trades, setTrades] = useState([]);
    useEffect(() => {
        // set a timer to make this event run every second
        const tradeTimer = setInterval(() => {
            let tradeRes = fetch("example.com/stocks/trades");
            // errors? never heard of them
            tradeRes = JSON.parse(tradeRes);
            setTrades(previousList => {
                // remove the amount of elements returned from
                // our fetch call to stay at 1,000 elements
                previousList.slice(0, tradeRes.length);
                // add the most recent elements
                for (i, i < tradeRes.length, i++) {
                    previousList.push(tradeRes[i]);
                };
                return previousList;
            });
        }, 1000);

        return () => clearInterval(tradeTimer);
    }, [])

    return (
        <ul>
            {trades.map((trade, index) => (
                <li key={index}>
                    {trade.includes("+") ? 
                        <BuyComponent>BUY: {trade}</BuyComponent> 
                        : <SellComponent>SELL: {trade}</SellComponent>
                    }
                </li>
            ))}
        </ul>
    )
}

忽略可能有更有效的方法来做到这一点,这是一个很好的例子,说明 Million 不会做得很好。数据每秒都在变化,渲染的组件取决于数据本身,总的来说,这个组件没有什么真正静态的。

如果您查看返回的 HTML,您可能会想“拥有一个优化的<For />组件在这里会很棒!” 然而,就 Million 的编译器而言(除了 Million 的<For />组件),无法静态分析返回的元素列表,事实上,类似这样的情况就是Facebook 首次引入 React 的原因(他们 UI 的新闻部分,高度动态的列表)。

对于 React 运行时来说,这是一个很好的用例,因为直接操作 DOM 的成本很高,而且每秒对大量元素进行操作的成本也很高。

然而,当使用像React 这样的东西时,它会更快,因为它只会比较和重新渲染页面的这个细粒度部分,而不是传统的服务器渲染的东西,这可能会替换整个页面。因此,Million 更适合处理页面的其他静态部分,以保持 React 的占用空间更小。

这是否意味着只有这种极端的组件才应该被 Million 忽略并使用 React 的运行时?不必要。如果您的组件甚至倾向于这种用例,其中组件依赖于高度动态的方面,例如不断变化的状态、三元驱动的返回值或任何不能轻松适应“静态和/或接近静态”的内容“盒子,那么百万可能效果不好。

再说一遍,构建 React 是有原因的,我们选择改进它,而不是创建一个新框架也是有原因的!

百万将在 哪些方面表现出色?

我们当然希望看到 Million 的使用范围达到极限,但就目前而言,Million 确实有一些闪光点。

显然,静态组件对于Million来说非常有用,而且这些很容易想象,所以我不会深入研究它们。这些可能是博客、登陆页面、具有 CRUD 类型操作(其中数据不太动态)的应用程序等。

然而,Million 的其他重要用例是具有嵌套数据的应用程序,即内部具有数据列表的对象。这是因为,由于树遍历(即遍历整个数据树以查找应用程序所需的数据点),嵌套数据的渲染速度通常有点慢。

Million 针对这个用例进行了优化,我们的<For />组件是专门为尽可能高效地循环数组而设计的,并且(就像我们之前提到的)在滚动时回收 DOM 节点,而不是创建和丢弃它们。

<For />这是一个例子,即使使用动态、有状态的数据,也可以通过仅使用而不是Array.prototype.map()为映射数组中的每个项目创建 DOM 节点来本质上免费优化性能。

例如:

import { For } from 'million/react';

export default function App() {
    const [items, setItems] = useState([1, 2, 3]);

    return (
        <>
            <button onClick={() => setItems([...items, items.length + 1])}>
                Add item
            </button>
            <ul>
                <For each={items}>{(item) => <li>{item}</li>}</For>
            </ul>
        </>
    );
}

同样,这种性能几乎可以免费获得,唯一的要求是知道如何/何时使用<For />

例如,服务器渲染往往会导致水合错误,因为我们没有将数组元素与 DOM 节点 1:1 映射,并且我们的服务器渲染算法与客户端渲染的算法不同,但它是动态、有状态组件的一个很好的示例,只需做一点工作就可以使用 Million 进行优化!

尽管此示例使用 Million 提供的自定义组件,但这只是 Million 可以正常工作的特定用例的示例。然而,正如我们之前讨论的,可以是有状态且相对静态的非列表组件与 Million 的编译器配合得非常好,例如 CRUD 样式的组件(如表单)、CMS 驱动的组件(如文本块、登陆页面等)。又名我们作为前端开发人员开发的大多数应用程序,或者至少我是这样的)。

值得花上百万吗?

我们当然这么认为。很多人在优化性能时都会关注最容易跟踪的指标:页面速度。这是您可以在pagespeed.web.dev上立即测量的内容,虽然这当然很重要,但初始页面加载时间通常不会对用户体验造成很大影响,特别是在编写针对以下情况进行优化的单页应用程序时:页面转换,而不是整页加载。

然而,尽可能避免和减少内存使用也是使用 Million JS 的一个令人难以置信的引人注目的用例。

如果用户执行的每个操作都不需要时间来完成并为他们提供即时反馈,那么用户体验会感觉更加原生,如果您不小心,这通常是性能问题蔓延的地方,因为输入延迟通常很大程度上受内存使用情况的影响。

那么是否有必要使用虚拟 DOM 来实现这一点呢?我们当然这么认为。特别是如果这意味着在低端设备上需要运行更多的 Javascript、创建更多的对象以及需要担心更多的内存开销。

这并不意味着 Million 适合所有用例,也不能解决所有性能问题。事实上,我们建议细粒度地使用它,因为在某些情况下(即我们讨论的更多动态数据),虚拟 DOM 实际上会具有更高的性能。

但是,在你的工具带中拥有一个几乎不需要设置时间或配置的工具肯定会让我们更接近让 React 成为一个更可靠、更高性能的库,以便在构建一个在野外运行的应用程序时,在其他开发人员之外使用它。 8 核、32GB 机器。

很快,我们将对常见的 React 模板进行基准测试,以了解 Million 如何影响内存和性能,敬请关注!

给TA打赏
共{{data.count}}人
人已打赏
0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索