提高 React 应用内存效率 | Million.js 超越速度
如果您听说过 Million JS(来自其创建者Aiden Bai或Twitter 上Tobi Adedeji的 React 谜题),您可能会对标题“让 React 速度提高 70%”感到好奇。
大多数开发者都认为速度越快越好,原因有几个,比如 SEO 和用户体验。如果我能写一个简单的 React,并让它的速度和 Svelte、Vue 等框架一样快,甚至更快(在某些情况下),那就算成功了,对吧?
然而,Million 优化 React 应用程序实际上还有其他一些原因,这些原因与“速度”关系不大,而与兼容性有关,无论是与旧设备、速度慢的笔记本电脑、资源受限的手机等。
最终,所有这一切都归结于记忆。
一个古老的梗是,Chrome 窗口打开 10 个标签页,导致旧笔记本电脑停止运行,而这个梗的现实依据比人们意识到的要多得多。
如果我们观察一下应用程序在良好的网络上运行速度却非常缓慢的情况,那么这通常与带宽关系不大,而与内存关系更大,这就是我们在本文中要研究的内容。
无需百万即可做出反应
典型的 React 应用程序在没有 Million 和没有 Next.js 等服务器端框架的情况下工作的方式是,对于 JSX 中的每个组件,转译器(Babel)都会调用一个名为的函数,React.createElement()
该函数输出的不是 HTML 元素而是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 匹配,并丢弃旧组件树中任何陈旧的对象。
请注意,这就是为什么大多数 React 教程都会建议将你的
useState()
或useEffect()
尽可能向下移动到树中,因为需要重新渲染的组件越小,完成这个比较过程(差异)的效率就越高。
现在,与传统的服务器渲染相比,差异化的成本非常高昂,在传统的服务器渲染中,浏览器只是接收一串 HTML,对其进行解析,然后将其放入 DOM 中。
虽然服务器渲染不需要 Javascript,但 React 不仅需要它,而且还会在过程中创建这个巨大的嵌套对象,并且在运行时,React 必须不断检查更改,这会占用大量的 CPU 资源。
内存使用情况
内存占用过高的原因有两个:存储大对象以及持续对大对象进行 diff 操作。此外,如果你同时在内存中存储状态,并且使用了也将状态存储在内存中的外部库(大多数人可能都是这样,包括我自己),那么内存占用还会更高。
在内存受限的环境中,存储大对象本身就是一个问题,因为移动和/或旧设备可能一开始就没有太多的 RAM,对于使用自己小而有限的内存进行沙盒化的浏览器选项卡来说就更少了。
你的浏览器标签页是否因为“耗电太多”而频繁刷新?这很可能是内存占用过高、CPU 持续运行(设备无法处理)以及运行其他操作(例如操作系统、后台应用刷新、保持其他浏览器标签页打开等)造成的。
此外,对大型组件树进行 diff 操作意味着每当 UI 更新时,都会用新对象替换旧对象,并将旧对象丢弃到垃圾收集器中,并在应用程序的整个生命周期内不断重复此过程。对于更具动态性、交互性的应用程序尤其如此(这也是 React 的主要卖点)。
如你所见,即使是一个简单的组件,比如你只修改了 div 中的一个单词,它的 diff 过程也意味着一个对象会被垃圾回收器清除。但是,如果你的对象树中有数千个这样的节点,并且其中许多节点依赖于动态状态,会发生什么情况呢?
用于状态管理的不可变对象存储(如 Redux)通过不断向其 Javascript 对象添加越来越多的节点,对内存造成了更大的负担。
由于此对象存储是不可变的,它会不断增长,这进一步限制了应用程序其余部分可用于执行诸如更新 DOM 等操作的内存。所有这些都会给最终用户带来迟缓、充满 bug 的体验。
V8 和垃圾收集
不过,现代浏览器已经针对这个问题进行了优化,不是吗?V8 的垃圾回收机制优化得非常好,而且运行速度非常快,所以这真的会是个问题吗?
这种看法有两个问题。
- 即使垃圾收集运行得很快,垃圾收集也是一个阻塞操作,这意味着它会在后续的 Javascript 渲染中引入延迟。
-
需要垃圾回收的对象越大,延迟就越长。最终,当对象创建过多时,垃圾回收需要反复运行才能为这些新对象释放内存。当你的 React 应用长时间运行的时候,这种情况经常发生。
-
如果您曾经致力于优化 React 应用程序并将其打开几个小时,并且您单击一个按钮只需要 10 多秒即可做出响应,那么您就知道这个过程。
- 即使 V8 经过高度优化,React 应用程序通常也不会,例如事件监听器通常不会被卸载、组件太大、组件的静态部分不会被记忆等等。
- 所有这些因素(即使它们通常是bug 和/或开发人员的失误)都会增加内存使用量,有些因素(例如未卸载的事件监听器)甚至会导致内存泄漏。没错,内存泄漏。在托管内存环境中。
Dynatrace 可以很好地可视化 Node JS 应用在发生内存泄漏时随时间变化的内存使用情况。即使垃圾收集(黄线向下移动)在最后阶段变得越来越激进,内存使用量(和分配量)仍然持续上升。
甚至 Dan Abramov 在播客中也提到,Meta 工程师编写了一些非常糟糕的 React 代码,因此编写“糟糕的” React 并不难,尤其是考虑到在 React 中使用闭包useEffect()
(在和内部编写的函数useState()
)等很容易创建内存,或者需要Array.prototype.map()
在 JSX 中循环遍历数组,这会在内存中创建原始数组的克隆。
所以,并非说 React 不可能实现高性能。只是如何编写性能最佳的组件通常不太直观,而且性能测试的反馈循环通常需要等待使用各种浏览器和设备的真实用户。
注意:高性能 Javascript是可能的(我强烈推荐 Colt McAnlis 的这次演讲),但实现起来也很困难,因为它需要对象池和静态数组列表分配之类的东西才能实现。
这两种技术在 React 中都很难利用,因为 React 本质上是组件化的,并且通常不提倡使用大量可回收的全局对象(例如 Redux 的大型、不可变的单一对象存储)。
然而,这些优化技术仍然经常在底层被使用,例如虚拟化列表,它会在大型列表中回收 DOM 节点,即使这些节点的行超出了视图范围。你可以在LG 的 Seungho Park 的演讲中了解更多这类 React 特有的优化技术(特别是针对电视等低端设备)。
百万反应
请记住,尽管内存限制确实存在,但开发者通常会留意在运行开发服务器时打开的标签页或应用数量,因此除了一些可能导致开发过程中刷新或重启服务器的错误体验外,我们通常不会注意到这些限制。然而,您的用户可能比您更频繁地注意到这些限制,尤其是在使用老款手机、平板电脑和笔记本电脑的用户,因为他们不会为了您的应用而清除已打开的应用/标签页。
那么 Million 采取了哪些不同措施来解决这个问题呢?
好吧,Million 是一个编译器,虽然我不会在这里详细介绍所有内容(您可以在这些链接中阅读有关块 DOM和 Million 的block()
功能的更多信息),但 Million 可以静态分析您的 React 代码并自动将 React 组件编译为严格优化的高阶组件,然后由 Million 进行渲染。
Million 使用更接近细粒度反应性的技术(大声喊出Solid JS),其中观察者被放置在必要的 DOM 节点上以跟踪状态变化以及其他优化,而不是使用虚拟 DOM。
这使得 Million 的性能和内存开销更接近优化的原生 JavaScript,甚至比 Preact 或 Inferno 等注重性能的虚拟 DOM 更接近,但却无需在 React 之上添加抽象层。也就是说,使用 Million 并不意味着要将您的 React 应用迁移到使用“兼容 React”的库。它只是简单的 React,Million 本身可以通过我们的CLI自动优化。
请记住,Million 并非适用于所有用例。稍后我们将讨论 Million 的适用和不适用情况。
内存使用情况
在内存使用方面,Million 占用的内存约为 React 页面加载后待机状态的 55%,这是一个显著的差异。根据Krausest 的 JS Framework Benchmark测试,即使在 Chrome 113 版本(我们目前使用的是 117 版本)上, Million 每次操作占用的内存还不到 React 的一半。
与使用原始 Javascript 相比,使用 Million 所占用的内存最多高出 28%(15MB 对 11.9MB),当向页面添加 10,000 行(基准测试中最繁重的操作)时,而与原始 Javascript 相比,React 完成相同任务所占用的内存约为 303%(36.1 MB 对 11.9 MB)。
再加上您的应用程序在其生命周期内完成的总操作,当使用纯虚拟 DOM 与混合块 DOM 方法时,性能和内存使用情况都会有很大差异,尤其是在考虑状态管理、库/依赖项等之后。当然,Million 是占优势的。
等等,但是_怎么办?
正如所有事物一样,使用 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 />
组件),没有办法静态分析返回的元素列表,事实上,像这样的情况就是为什么 React 最初是在 Facebook 上推出的(他们的 UI 中的新闻部分,一个高度动态的列表)。
这是 React 运行时的一个很好的用例,因为直接操作 DOM 成本很高,并且每秒对大量元素进行这样的操作也很昂贵。
然而,使用像React 这样的框架会更快,因为它只会比较并重新渲染页面的细微部分,而传统的服务器渲染可能会替换整个页面。因此,Million 更适合处理页面的其他静态部分,以减少 React 的占用空间。
这是否意味着只有这种极端的组件才会被 Million 忽略,并使用 React 的运行时?不一定。如果你的组件甚至倾向于这种用例,即组件依赖于高度动态的方面,例如不断变化的状态、三元驱动的返回值,或者任何无法轻松适应“静态和/或接近静态”框架的内容,那么 Million 可能无法正常工作。
再说一次,React 的构建是有原因的,我们选择改进它而不是创建新框架也是有原因的!
Million将在哪些方面表现优异?
我们当然希望看到 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 如何影响内存和性能,敬请关注!
文章来源:https://dev.to/ricardonunezio/millionjs-beyond-speed-making-react-apps-memory-efficient-2amn