前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >【前端技术丨主题周】漫谈前端性能本质 突破React应用瓶颈

【前端技术丨主题周】漫谈前端性能本质 突破React应用瓶颈

作者头像
博文视点Broadview
发布2020-06-12 16:10:07
9390
发布2020-06-12 16:10:07
举报

性能一直以来是前端开发中非常重要的话题。随着前端能做的事情越来越多,浏览器能力被无限放大和利用:从 web 游戏到复杂单页面应用,从 NodeJS 服务到 web VR/AR 和数据可视化,前端工程师总是在突破极限。随之而来的性能问题有的被迎刃而解,有的成为难以逾越的盾墙。

那么,当我们在谈论性能时,到底在说什么?基于 React 框架开发的应用,在性能上又有哪些特点?这篇文章我们从浏览器和 JavaScript 引擎角度来剖析前端性能,同时创新 React,充分利用浏览器能力突破局限。

  • 性能问题本质和阿喀琉斯之踵

事实上,性能问题多种多样:瓶颈可能出现在网络传输过程,造成前端数据呈现延迟;也可能是移动 hybrid 应用中,wbview 容器带来了瓶颈和限制。但是在分析性能问题时,经常逃不开一个概念——JavaScript 单线程。

浏览器解析渲染 DOM Tree 和 CSS Tree,解析执行 JavaScript,几乎所有的操作都是在主线程中执行。因为 JavaScript 可以操作 DOM,影响渲染,所以 JavaScript 引擎线程和 U I线程是互斥的。换句话说,JavaScript 代码执行时会阻塞页面的渲染。

通过下面的图示来进行了解:

图中的几个关键角色:

Call Stack:调用栈,即 JavaScript 代码执行的地方,Chrome 和 NodeJS 中对应 V8 引擎。遵循 LIFO(last-in-first-out)原则。当执行完当前所有任务时,栈为空,等待接收 Event Loop 中 next Tick 的任务。

Browser APIs:这是连接 JavaScript 代码和浏览器内部的桥梁,使得 JavaScript 代码可以通过 Browser APIs 操作 DOM,调用 setTimeout,AJAX 等。

Event queue: 每次通过 AJAX 或者 setTimeout 添加一个回调时,回调函数会加入到 Event queue 当中。

Job queue: 这是预留给 promise 且优先级较高的 queue,代表着“稍后执行这段代码,但是在 next Event Loop tick 之前执行”。它属于 ES 规范,注意区别对待,这里暂不展开。

Next Tick: 表示调用栈 call stack 在下一 tick 将要执行的任务。它由一个 Event queue 中的回调,全部的 job queue,部分或者全部 render queue 组成。注意 current tick 只会在 Job queue 为空时才会进入 next tick。这就涉及到 task 优先级了,可能大家对于 microtask 和 macrotask 更加熟悉。

Event Loop: 它会“监视”(轮询)call stack 是否为空,call stack 为空时将会由 Event Loop 推送 next tick 中的任务到 call stack 中。

在浏览器主线程中,JavaScript 代码在调用栈 call stack 执行时,可能会调用浏览器的 API,对 DOM 进行操作。也可能执行一些异步任务:这些异步任务如果是以回调的方式处理,那么往往会被添加到 Event queue 当中;如果是以 promise 处理,就会先放到 Job queue 当中。这些异步任务和渲染任务将会在下一个时序当中由调用栈处理执行。

理解了这些,大家就会明白:如果调用栈 call stack 运行一个很耗时的脚本,比如解析一个图片,call stack 就会像北京上下班高峰期的环路入口一样,被这个复杂任务堵塞。进而阻塞 UI 响应,主线程其他任务都要排队。这时候用户点击、输入、页面动画等都没有了响应。

这样的性能瓶颈,就如同阿喀琉斯之踵一样,在一定程度上限制着 JavaScript 的发挥。

  • 江湖救急——两方性能解药

我们一般有两种方案突破上文提到的瓶颈:

将耗时高、成本高的长任务切片,分成子任务,并异步执行

这样一来,这些子任务会在不同的 call stack 周期执行,进而主线程就可以在子任务间隙当中执行 UI 更新操作。设想常见的一个场景:如果我们需要渲染一个很长的列表,列表由十万条数据组成,那么相比一次性渲染全部数据内容,我们可以将数据分段,使用 setTimeout API 去分步处理,构建列表的工作就被分成了不同的子任务在浏览器中执行,在这些子任务间隙,浏览器得以处理 UI 更新。

另外一个创新性的做法:使用 HTML5 Web Worker

Web Worker 允许我们将 JavaScript 脚本在不同的浏览器线程中执行。因此,一些耗时的计算过程我们都可以放在 Web Worker 开启的线程当中处理。下文会有详解。

  • React 框架性能剖析

社区上关于 React 性能的内容往往聚焦在业务层面,主要是使用框架的“最佳实践”。这里我们不去谈论“使用 shoulComponentUpdate 减少不必要的渲染”,“减少 render 函数中 inline-function”等“老生常谈”的话题,本文会从 React 框架实现层面分析其性能瓶颈和突破策略。

原生的 JavaScript 一定是最高效的,这个毫无争议。相比其他框架,React 在 JavaScript 执行层面花费的时间较多,这显然是因为 Virtual DOM 构建,以及计算 DOM diff,生成 render patch 一系列复杂过程所造成的。也就是说 React 著名的调度策略 -- stack reconcile 是 React 的性能瓶颈。

这并不难理解,因为 UI 渲染只是 JavaScript 调用浏览器的 APIs,这个过程对所有框架以及原生 JavaScript 来讲是一样的,都是黑盒执行,这一部分的性能消耗是且无法取巧的。

再看我们的 React,stack reconcile 过程会深度优先遍历所有的 Virtual DOM 节点,进行 diff。整棵 Virtual DOM 计算完成之后,才将任务出栈释放主线程。所以,浏览器主线程被 React 更新状态任务占据的时候,用户与浏览器进行任何的交互都不能得到反馈,只有等到任务结束,才能突然得到浏览器的响应。

我们来看一个典型的场景,来自文章“React的新引擎—React Fiber是什么?

(http://www.infoq.com/cn/articles/what-the-new-engine-of-react)

这个例子会在页面中创建一个输入框,一个按钮,一个 BlockList 组件。BlockList 组件会根据 NUMBER_OF_BLOCK 数值渲染出对应数量的数字显示框,数字显示框显示点击按钮的次数。

在这个例子中,我们可以设置 NUMBER_OF_BLOCK 的值为 100000,将其变为一个“复杂”的网页。 点击按钮,触发 setState,页面开始更新。此时点击输入框,输入一些字符串,比如 “hi,react”。可以看到,页面没有任何的响应。等待 7s 之后,输入框中突然出现了之前输入的 “hireact”。同时, BlockList 组件也更新了。

显而易见,这样的用户体验并不好。

将浏览器主线程在这 7s 的 performance 如下图所示:

黄色部分是 JavaScript 执行时间,也是 React 占用主线程时间,紫色部分是浏览器重新计算 DOM Tree 的时间,绿色部分是浏览器绘制页面的时间。

三种任务,占用浏览器主线程 7s,此时间内浏览器无法与用户交互。但是DOM 改变之后,浏览器重新计算 DOM Tree,重绘页面是一个必不可少的阶段(紫色绿色阶段)。主要是黄色部分执行时间较长,占用了 6 s,即 React 较长时间占用主线程,导致主线程无法响应用户输入。

此处场景内容选自文章“React的新引擎—React Fiber是什么?”

  • React 性能——React Fiber

React 核心团队很早之前就预知性能风险的存在,并且持续探索可解决的方式。基于浏览器对 requestIdleCallback 和 requestAnimationFrame 这两个API 的支持,React 团队实现新的调度策略 -- Fiber reconcile。

在应用 React Fiber 的场景下,再重复刚才的例子。浏览器主线程的 performance 如下图所示:

可以看到,在黄色 JavaScript 执行过程中,也就是 React 占用浏览器主线程期间,浏览器在也在重新计算 DOM Tree,并且进行重绘,截图显示,浏览器渲染的就是用户新输入的内容。简单说,在 React 占用浏览器主线程期间,浏览器也在与用户交互。这显然是“更好的性能”体现。

以上是 React “将耗时高的任务分段”做法,下面我们再来看另一种“民间”做法,体现 Web Worker 应用。

  • React结合Web Worker

关于 Web Worker 的概念此文不再赘述,大家可以访问 MDN 地址进行了解。我们聚焦思考点:如果让 React 接入 Web Worker 的话,切入点在哪里,如何实施?

总所周知,标准的 React 应用由两部分构成:

  • React core:负责绝大部分的复杂的 Virtual DOM 计算;
  • React-Dom:负责与浏览器真实 DOM 交互来展示内容。

那么答案很简单,我们尝试在 Web Worker 中运行 React Virtual DOM 的相关计算,而不是传统的在主线程中进行。即将 React core 放入 Web Worker 线程中。

也确实有人提出了这样的想法,请参考 React 仓库第 #3092 号 Issue,这样的提议遭到了 React 官方的礼貌回绝:

“Relay in a worker on the other hand seems very plausible.”

具体原因可以在此 issue 中找到,内容很多,也吸引来了 Dan Abramov 的现身说法,当然如果我是 React 库的开发者,我也不会接受这样的变动。不过这并不妨碍我们让 React 结合 Worker 做试验。

Talk is cheap, show me the code, and demo: 读者可以访问

http://web-perf.github.io/react-worker-dom/,

该网站分别用原生 React 和接入 Web Worker 版 React 实现了两个应用,并对比其性能表现。

最终结论:不能绝对的说 Web Worker 可以对渲染速率有大幅度提升。只有当大量的节点发生变化的时,Web Worker 提升渲染性能才会有一些效果。实际上,当节点数量非常少的时候,Web Worker 的性能可能还不如 React 本身实现。这是由于 worker 线程和主线程之间的通信成本所致。

因此,Web Worker 版本的 React 仍有提升空间,我简单总结如下:

• 因为 worker 线程和主线程在使用 postMessage 通信时,成本较大,我们可以采用 batching 思想减少通信的次数。

如果在每次 DOM 需要改变时,都调用 postMessage 通知主线程,不是特别明智。所以可以用 batching 思想,将 worker 线程中计算出来的 DOM 待更新内容进行收集,再统一发送。这样一来,batching 的粒度就很有意思了。如果我们走极端,每次 batching 收集的变更都非常多,那么在一次 batching 时就给浏览器真正的渲染过程带来了压力,反而适得其反。

使用 postMessage 传递消息时,采用 transferable objects 进行数据负载

在 worker 和主线程之间,我想要传递的数据可能不是一个稳定的结构,因此,我需要制定一个公共的协议。使用 transferable objects 传递信息,能够有效提高效率。更多内容参见社区文档。

关于 Worker 版 syntheticEvent

原生 React 有一套 Event System 在最顶层监听所有的浏览器事件,将它们转化为合成事件,传递给我们在 Virtual DOM 上定义的事件监听者。

对于我们的 Web Worker,由于 web Worker 不能直接操作 DOM,也就是说不能监听浏览器事件。因此所有事件同样都在主线程中处理,转化为虚拟事件并传递给 worker 线程,也就意味着所有关于创建虚拟事件的操作还是都在主线程中进行,一个可能改善的方案是,可以直接将原始事件传递给 worker,由 worker 来生成模拟事件并冒泡传递。

关于 React 结合 worker 还有很多值得深挖的内容,比如事件处理方面 preventDefault 和 stopPropogation 的同步性;使用 multiple worker(一个以上 worker)探究等,如果读者有兴趣,我会专门写篇文章介绍。

  • Redux和Web Worker

既然 React 可以接入 Web Worker,状态管理工具 Redux 当然也能借鉴这样的思想,将 Redux 中 reducer 复杂的纯计算过程放在 worker 线程里,是不是一个很好的思路?

我使用 “N-皇后问题” 模拟大型计算,除了这个极其耗时的算法,页面中还运行这么几个模块来实现渲染逻辑:

  • 一个实时每 16 毫秒,显示计数(每秒增加 1)的 blinker 模块;
  • 一个定时每 500 毫秒,更新背景颜色的 counter 模块;
  • 一个永久往复运动的 slider 模块;
  • 一个每 16 毫秒翻转 5 度的 spinner 模块

这些模块都定时频繁地更新 DOM 样式,进行渲染。正常情况下,当 JavaScript 主线程进行 N-皇后计算时,这些渲染过程都将被卡顿。

如果将 N-皇后计算放置到 worker 线程,我们会发现 demo 展现了令人惊讶的性能提升,完全丝滑毫无卡顿。

如下图,左边为正常版本,不出意外出现了页面卡顿,右侧是介入 worker 之后的应用:

在实现层面,借助 Redux 库的 enchancer 设计,完成了抽象封装(类似中间件)。 一个 store enhancer,实际上就是一个颗粒化的高阶函数,最终返回值是一个可以创建功能更加强大的 store 的函数 (enhanced store creator),这和 React 中的高阶组件的概念很相似,同时也类似我们更加熟悉的中间件,其实参考 Redux 源码,会发现 Redux 源码中 applyMiddleware 方法,applyMiddleware(...middlewares) 的执行结果就是一个 store enhancer。

这个 Redux worker demo 所采用的公共库设计思路非常有趣,关于神奇的 Redux 高阶内容不再展开,感兴趣的读者可以在新书《React状态管理与同构实战》中找到更多内容。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2018-08-29,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 博文视点Broadview 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
相关产品与服务
消息队列 TDMQ
消息队列 TDMQ (Tencent Distributed Message Queue)是腾讯基于 Apache Pulsar 自研的一个云原生消息中间件系列,其中包含兼容Pulsar、RabbitMQ、RocketMQ 等协议的消息队列子产品,得益于其底层计算与存储分离的架构,TDMQ 具备良好的弹性伸缩以及故障恢复能力。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档