性能优化,有时候是件体力活

简介: 性能优化,有时候是件体力活

image.png



前言

性能优化是一个老生常谈的话题,先辈们常说 不要过早进行性能优化,但对于性能决定了几乎一切的 IDE 产品来说,何时进行性能优化都不嫌早,除非我们当它(性能问题)不存在,直到你的用户卡到受不了并表示 我忍你们很久了。在对我们自研的 IDE 用户做了第一次问卷调查后,我们得出了一个初步结论,就是,这个卡主要体现在打开慢、补全慢、界面卡顿等等。前几篇文章中逐步对启动做了一定程度的优化(并且还在持续中),对于代码补全,我们基于  LSP 的机制进行了体积压缩等优化。而对于界面渲染性能实际上并没有进行过针对性的优化,主要原因是对于一款 IDE  来说,视图太过于复杂,以至于谈到性能优化,一时间似乎无处下手。

Re-render

OpenSumi 使用 React 来渲染视图层,通常意义上针对 React 应用的性能优化不外乎 减少不必要的重复渲染(Re-render)。在这方面,不论是官方文档还是社区都已经有大量的实践,我们  Google 一下可以找到许多这方面的经验与原理解释。比如这篇来自 React Core Team 成员 Dan Abramov 的文章  Before You memo(),通过非常简单的组件拆分,将不变的部分分离出来以避免昂贵的重复渲染。

当然这篇文章的前提是你的应用状态划分足够合理,例如对于只有局部视图所依赖的状态,避免将他们提升到过高的层级,这会导致其他不关心这部分状态的组件被动更新,然而对于  OpenSumi 来说,一个坏消息是我们大量的使用了 Mobx,通过 model/view 分离的方式来管理整个应用的状态,这些 model  在某种意义上都是全局的。借助于 Mobx observable 的特性,我们可以很方便的在 service 中使用类似下面这样的方式来更新状态。

class MyService {
  @Autowried()
  private viewModel: IMyViewModel;
  private updateView(value) {
    // someState 是一个 observeable,视图中可以直接使用
    this.viewModel.someState = value;
  }
}

通常情况下,组件会有大量来自父组件的 props 一层一层传递下来,对于最底层的子组件来说,任何 props 的变化都会触发组件的渲染,这意味着,Before You memo 这篇文章中的方式可优化的空间非常有限,在不进行重构去掉 Mobx 的前提下,我们似乎只能使用常规并且流行的 memo 以及 useMemo 来着手优化。

借助于 React Dev Tools,可以很快找到一些明显存在的性能问题。比如当我们勾选 Highlight updates when components render. 之后随意进行一些操作,会发现一些看起来本不该渲染的地方也触发了渲染。

image.png

sumi-optimize-before

OpenSumi近 30w 行代码,包含大量的 Tree、列表、输入款、按钮、菜单等等,对于上图中这类表现,可以说是惨不忍睹了,可以明显的看到一些菜单、按钮都在不该渲染的时候渲染了。我们先找到这其中看起来相对简单的一部分开始着手优化。

搜索面板

相较于其他面板,搜索只包含 4 个输入框以及少量的按钮、Checkbox,优化起来貌似简单一些,在开始之前,它们是这样子

image.png

可以看到由于全局状态的影响,任何点击、输入操作都会触发整个面板全部重新渲染,这显然是不合理的。

如何拆分组件

除了前面提到的将局部 state 及其依赖的视图拆分为独立的组件这种方式之外,对于大量全局状态/props 变化引起的渲染,我们依然可以拆分组件,不同之处在于这种拆分的思路是尽可能降低全局状态/props 变化对其他组件的影响。例如上图中,点击 显示搜索条件 时会修改 SearchSerice 中的某个状态,而这些状态都会在 SearchView 中引入。这会引起包括图中的 Checkbox 、Input 等所有组件的 render,因为它们共同依赖的 uiState 发生了变化。

const SearchView = () => {
  const searchService = useInjectable<ISearchService>(SearchService);
  return (
    <div>
      <Input value={searchService.uiState.searchKeyWord} />
      <Checkbox checked={searchService.uiState.checkbox} />
      <Input />
      <Input />
    </div>
  );
}

我们将这些 InputCheckbox各自拆分出来,例如根据功能不同,将它们拆分为 SearchInputSearchExcludeSearchIncludeSearchResult 等组件。将组件各自依赖的状态通过 props 传递给它们,此时我们只需要简单的用 React.memo 包裹,即可避免这种多余的渲染,而由于 React.memo 对比的仅为简单的基础类型,开销可以忽略不计。

当然由于这里的实现还包括各种规则,代码会比这个复杂一些

const SearchView = () => {
  const searchService = useInjectable<ISearchService>(SearchService);
  return (
    <div>
      <SearchInclude includes={searchService.includes} />
    </div>
  );
}
const SearchInclude = React.memo((props) => (<Input value={props.includes} />));

image.gif

1.gif

具体代码可以看这个 PR:https://github.com/opensumi/core/pull/94

不起眼的 Menu

Menu 在 IDE 中是看起来毫不起眼但大量存在的,特别是 OpenSumi 兼容 VS Code 插件的情况下,插件可以通过 ContributionPoint 来注入自定义的菜单、按钮等等,无论是按钮、图标、菜单等,它们在底层都是相同的抽象,只不过在不同的位置渲染出了不同的样式。例如编辑器菜 Tab 栏右侧的按钮,以及侧边面板中标题栏的各种按钮,还有状态栏的一些可点击的标签等等。image.png

image.png

Menu 会有什么问题


02.gif

如图所示,这些不起眼的 Menu 随着操作一直在触发 Render,理论上这些不必要也不应该这么频繁的渲染,然而在为这些 Menu 添加了简单的 memo 包裹后并没有效果,因为其依赖的 menuitem 每次都是新的。随便打一些 log 可以发现问题,任何面板的 Rerender 都会生成新的 Menu 实例,而每个 Menu 又会重新构造内部的 menuitem。image.png

不起眼的开销

每一个 Menu 实例都可能会有上百个 menitem ,看似不起眼的 menu 实则非常昂贵,然而经过简单的排查发现这种重复创建不止一处。

image.png

这段手风琴折叠面板的逻辑,在这个组件每次渲染时都会尝试获取 titleMenu ,没有则重新创建,titleMenu 即上文中 id 为 view/title 的一个 Menu 实例。

image.png

实际的实现中则直接 return 了 menu,而没有将其缓存下来,这导致面板上任何操作引起的渲染都会重新创建 Menu 实例,进而导致内部的 menuitem 全部重新渲染。

然而事实并没有这么简单,当我安装最新版本的 GitLens 插件,在其加载 commit 列表时整个面板瞬间卡住。并且等待其加载完毕卡顿结束后,再次左右拖拽,整个界面帧率掉到个位数。image.png再打一些 log 发现在这一瞬间 GitLens 相关的 Menu 实例创建了近 900 次(这里还有另一个坑)。而随着拖动面板,这个数字还在不停的上涨,最终重复创建了 3k 次,虽然这些多余的 Menu 会被销毁,但这个过程依然会带来非常昂贵的性能开销。

image.png

实际上代码也非常简单,只需要在插件注册 Menu 时添加一个 cache 即可

private getInlineMenu(viewItemValue: string) {
    if (this.cachedMenu.has(viewItemValue)) {
      return this.cachedMenu.get(viewItemValue)!;
    }
    // create new menu
    this.cachedMenu.set(viewItemValue);
}

可以看出涉及到视图的部分,任何疏漏都可能会造成性能瓶颈。并且往往造成性能瓶颈的都不是非常复杂的逻辑。

具体代码可以看这个 PR:https://github.com/opensumi/core/pull/131

TreeView

Tree 几乎是 IDE 中最重要的视图部分,例如文件树、Symbol 树、依赖树等等,大量的插件依靠  TreeView 来实现丰富多样的功能。在 OpenSumi 中,所有的 TreeView 都基于 RecycleTree 来实现,RecycTree 本身的性能实际上已经接近甚至部分超越大多数的 TreeView 。然而同样是看起来性能非常靠谱的组件,如果使用不当依然会产生严重的性能问题。

拖拽

TreeView 本身不支持拖拽,但大量使用 TreeView 的面板是支持左右、上下拖拽改变大小的。前文中提到在 GitLens 面板中拖拽卡顿掉帧,事实上除了 Menu 的频繁创建之外,另一个问题就是由于视图拖拽后宽度变化导致的。

3.gif

这里没有很明显的卡顿是因为截图是用满血版 M1 Pro 测试的,另一台 Intel i7 的表现差不多相当于开启了 CPU 4X Slowdown 的效果

使用 React DevTools 录制的截图可以很明显看出,随着拖拽操作,整个 TreeView 中的 treeitem 都在重新渲染。原因实际上也很简单,由于拖拽时面板 widthheight 都会改变,而这部分代码在使用 RecycleTree 时将 width 和 height 都通过 props 传递进去,导致拖拽引发了 Tree 的重新渲染,但实际上对于这种情况来说,width 本身是不必要的 props,所以解决方式非常简单。

// before
<RecycleTree ...otherProps width={width} height={height} />
// after
<RecycleTree ...otherProps height={height} />

整个 IDE 中 TreeView 有很多处,大多数的修复方式都类似

去掉不必要的 width props 之后image.gif

同样使用满血版的 M1 Pro,这里还是肉眼可见的流畅了一些

相关 PR

Icon

记得之前提到的 GitLens 吗,在与性能斗争的很长一段时间内,GitLens 插件都是作为我测量性能的最佳伙伴,GitLens 插件本身注册的 ContributionPoint 以及各种 Menu、TreeView 非常多,以至于这个项目的 package.json 达到了惊人的 1 万行。

vscode-gitlens/package.json at main · gitkraken/vscode-gitlens · GitHub

在 Menu 和 TreeView 这些有性能瓶颈的部分都经过了一轮优化后,还是会在激活时出现明显的卡顿(在另一台 Intel CPU 的 Mac 上)。

image.png

Intel Mac 下的截图,可以看到在 COMMITS 面板 Loading 时,界面明显的出现了卡顿,甚至编辑器的 Codelens 都在卡顿结束后才渲染出来。

这里是在构造  TreeView 的节点,但首屏加载的 Commit 并没有多到这种程度,这种卡顿更像是有大量的 DOM 重绘。这里使用 Chrome  Devtools Performance 记录这一段的性能,发现确实有 2.5 秒时间一直在执行 JavaScript 脚本。

image.png

火焰图的细节这里不再赘述,点这段耗时很长的任务看调用栈,发现耗时最长的竟然是之前一直被忽略的 iconService

image.png

Committer

我们熟悉的老朋友 GitLens 在这里注册了大量的 TreeNode,而其中的 icon 就是用于展示 committer 头像的。它会将 committer 的头像拉取下来,再将其注册为一个 icon,并且还区分 light/dark 模式。

vscode-gitlens/commit.ts at cf771368305dabed8d0939b293dfe30e181e2260 · gitkraken/vscode-gitlens · GitHub

然而即便是几百个 icon 也不太可能瞬间造成这么严重的卡顿,真正的问题出现在注册 icon 后将其转换为 CSS 样式这一步。

数量取决于 commit 和 committer 数量,在这个 case 中,使用 vscode repo 的情况下,第一次会加载 400 x 2 个 icon 根据 icon 注册时的模式不同,icon 会被转换为一个个的 CSS class,样式类似

`.${className} {background: url("${iconUrl}") no-repeat 50% 50%;background-size:contain;}`;

image.png

然后将每个 icon class 样式都插入到一个 style 标签中,问题就出在当这个操作重复上千次时,整个 DOM 树的重绘会导致了非常严重的卡顿。要解决这个问题,就是将来自插件注册的 icon class 操作合并后批量插入到 style 标签。

经过简单的优化,再次加载 GitLens 后的效果是这样

image.png

虽然还有一点卡顿,但相比之前已经好了很多,不会再卡住几秒,同时编辑器的 Codelens 也会正常加载出来。

代码可以直接看 PR:https://github.com/opensumi/core/pull/172

克制事件

仔细观察会发现整个 IDE 界面中几乎所有面板都是 可拖拽 的。要实现这种拖拽效果,整个 IDE 会监听全局的 Resize 事件,将左、右、底部面板按照位置分割,当对应某一边(例如宽度)  发生改变时,分别计算它们新的尺寸,并将该事件广播出去。对于实现面板的组件来说,可以从 props 获取到一个名为 viewState  的对象,它包含了面板的 width和 height 信息。

interface IViewState {
 width: number;
  height: number;
}
export Panel = ({viewState}: { viewState: IViewState }) => {
 return (<div></div>);
}

事实上除了前文提到的,某些组件确实不必要关系 width 变化之外,这里确实没有什么问题。只不过问题出现在当切换面板显示状态时,虽然底层实现只是 display: block/none ,但组件也会触发渲染。

image.png

例如这里切换任何一个 Tab,其面板都会重新渲染一次。原因就是 OpenSumi 使用 ResizeObserver 来监听事件,而当视图被隐藏(display: none) 时,依然触发了事件,同样当 display: block  时也会触发事件。也就是说一次切换会有两次渲染开销,但实际上这是不必要的,因为单纯的切换操作下,面板的尺寸没有任何变化。这里的优化方式就是针对这种情况不广播事件,具体实现上使用  useRef 来缓存前一次的尺寸,经过切换后如果没有变化或尺寸直接变为 0 的情况下发送事件。那么优化之后是这样

image.png

代码可以看 PR:https://github.com/opensumi/core/pull/101

最后

这篇文章只是列举了一些相对比较明显的性能优化过程,较小的一些优化太多这里就不再展开。可以看到绝大多数性能瓶颈都是由于开发时的疏漏导致的,严格来说也并不存在非常难优化的点,很多可能只是一个写法的改变,例如组件的 props function 使用 useCallback 包裹而非匿名函数,或者对于大量的数据/视图操作添加缓存、批量合并处理等策略。又或者拆分组件,分离出视图中变与不变的部分,避免额外的 props 更新导致的渲染。当然对于一款 IDE 来说,这些点都应该是一开始就要考虑到的。也希望这篇文章能给读者带来一些启发,也许性能优化有些时候不是一件技术问题,而是体力活。

相关文章
|
2月前
|
消息中间件 缓存 NoSQL
如何做性能优化?
如何做性能优化?
|
1月前
|
设计模式 缓存 Android开发
深入理解Android应用性能优化
【2月更文挑战第18天】在移动开发领域,应用性能是用户体验的关键因素之一。特别是对于安卓设备而言,由于硬件配置的多样性,确保应用在不同设备上都能流畅运行是一项挑战。本文将探讨Android应用的性能优化策略,包括内存管理、UI渲染、多线程处理以及电池效率等方面。通过实例和最佳实践,我们将展示如何诊断性能瓶颈,并提供解决方案来改善应用响应速度和稳定性。
|
2月前
|
缓存 小程序 前端开发
小程序 如何做性能优化?
小程序 如何做性能优化?
|
3月前
|
缓存 前端开发 JavaScript
web前端性能优化,这几点让你的代码质量变高
web前端性能优化,这几点让你的代码质量变高
|
3月前
|
缓存 Java 测试技术
总结|性能优化思路及常用工具及手段
性能优化是降低成本的手段之一,每年大促前业务平台都会组织核心链路上的应用做性能优化,一方面提升系统性能,另外一方面对腐化的代码进行清理。本文结合业务平台性能优化的经验,探讨一下性能优化的思路及常用工具及手段。
75408 0
|
9月前
|
运维 监控 Java
35-JVM性能优化总结-JVM性能优化到底该怎么做?
通过之前大量的案例和工具的介绍,相信大家对于JVM优化有了一定的了解和熟悉,接下来我们将整个JVM性能优化的步骤做一个总结。
106 0
|
6月前
|
缓存 固态存储 程序员
性能第二讲:性能优化-每个程序员都应该知道的数字
性能第二讲:性能优化-每个程序员都应该知道的数字
|
9月前
|
存储 缓存 JavaScript
我工作中用到的性能优化全面指南(1)
在Web开发中,Web的性能优化是一个重要的话题。无论是页面加载速度,用户体验,或者是程序运行效率,都与Web的性能优化息息相关。 最小化和压缩代码 在构建过程中,为了减少文件的大小和加载时间,通常会对JavaScript代码进行最小化和压缩处理。这包括移除不必要的空格、换行、注释,以及缩短变量和函数名。工具如UglifyJS和Terser等可以帮助我们完成这个任务。
47 0
|
9月前
|
Web App开发 存储 缓存
我工作中用到的性能优化全面指南(2)
使用WebGL进行3D渲染 WebGL是一种用于进行3D渲染的Web标准,它提供了底层的图形API,并且能够利用GPU进行加速,非常适合于进行复杂的3D渲染。
63 0
|
11月前
|
前端开发
一次性能优化思考过程
最近业务上空闲了下来,也是把之前在开发时自身感受比较大的白屏时间放在了主线上去排查优化,这里记录一下笔者对于移动端vConsole脚本的引入问题全过程。
127 0
一次性能优化思考过程