IDE 性能优化策略

IDE 性能优化策略_第1张图片

我们基于 LSP 的机制进行了体积压缩等优化。而对于界面渲染性能实际上并没有进行过针对性的优化,主要原因是对于一款 IDE 来说,视图太过于复杂,以至于谈到性能优化,一时间似乎无处下手。

前言

性能优化是一个老生常谈的话题,前辈们常说 不要过早进行性能优化,但对于性能决定了几乎一切的 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. 之后随意进行一些操作,会发现一些看起来本不该渲染的地方也触发了渲染。

sumi-optimize-before

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

搜索面板

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

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

  如何拆分组

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

const SearchView = () => {
  const searchService = useInjectable(SearchService);
  return (
    
                                
  ); }

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

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

const SearchView = () => {
  const searchService = useInjectable(SearchService);
  return (
    
           
  ); } const SearchInclude = React.memo((props) => ());

search-optimize-after.gif

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

不起眼的 Menu

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

IDE 性能优化策略_第2张图片

IDE 性能优化策略_第3张图片 sumi-menu2.png

  Menu 会有什么问题

menu-actions-before.gif

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

  不起眼的开销

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

IDE 性能优化策略_第5张图片

title-menu.png

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

IDE 性能优化策略_第6张图片

return-menu.png

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

然而事实并没有这么简单,当我安装最新版本的 GitLens 插件,在其加载 commit 列表时整个面板瞬间卡住。并且等待其加载完毕卡顿结束后,再次左右拖拽,整个界面帧率掉到个位数。

IDE 性能优化策略_第7张图片再打一些 log 发现在这一瞬间 GitLens 相关的 Menu 实例创建了近 900 次(这里还有另一个坑)。而随着拖动面板,这个数字还在不停的上涨,最终重复创建了 3k 次,虽然这些多余的 Menu 会被销毁,但这个过程依然会带来非常昂贵的性能开销。

IDE 性能优化策略_第8张图片

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 的频繁创建之外,另一个问题就是由于视图拖拽后宽度变化导致的。

ext-tree-before.gif

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

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

// before


// after

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

去掉不必要的 width props 之后

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

相关 PR

  • https://github.com/opensumi/core/pull/133

  • https://github.com/opensumi/core/pull/149

  • https://github.com/opensumi/core/pull/176

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 上)。

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

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

IDE 性能优化策略_第9张图片

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

IDE 性能优化策略_第10张图片

  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;}`;
IDE 性能优化策略_第11张图片

avatar.png

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

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

虽然还有一点卡顿,但相比之前已经好了很多,不会再卡住几秒,同时编辑器的 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 (
); }

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

panel-render.gif

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

panel-render-after.gif

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

最后

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

欢迎 star 和把玩 OpenSumi (小名叫 KAITIAN) :

https://github.com/opensumi/core

最后

我们是大淘宝前端技术工程团队,负责大淘宝及集团的前端工程研发全链路解决方案的建设,欢迎加入。

橙子说

大淘宝技术新春拜年

“虎虎虎”

纸质红包大派送

IDE 性能优化策略_第12张图片

关注”淘系技术“回复"红包“即可获得领取方式

(2月28日18:00截止)

✿  拓展阅读

IDE 性能优化策略_第13张图片

IDE 性能优化策略_第14张图片

作者|柳千

编辑|橙子君

出品|阿里巴巴新零售淘系技术

IDE 性能优化策略_第15张图片

IDE 性能优化策略_第16张图片

你可能感兴趣的:(java,vue,python,js,react)