IDE 性能优化策略

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

1.gif

们基于 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. 之后随意进行一些操作,会发现一些看起来本不该渲染的地方也触发了渲染。


以下视频来源于

Alibaba F2E

图片.png

sumi-optimize-before

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


搜索面板


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

以下视频来源于

Alibaba F2E

图片.png2


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


 如何拆分组件


除了前面提到的将局部 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} />));

11.gif

search-optimize-after.gif

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


不起眼的 Menu


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

图片.png图片.png

sumi-menu2.png


 Menu 会有什么问题

001.gif

image.gifmenu-actions-before.gif

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

2.png


 不起眼的开销


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

image.gif图片.png

title-menu.png

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

image.gif图片.png

return-menu.png


实际的实现中则直接 return 了 menu,而没有将其缓存下来,这导致面板上任何操作引起的渲染都会重新创建 Menu 实例,进而导致内部的 menuitem 全部重新渲染。然而事实并没有这么简单,当我安装最新版本的 GitLens 插件,在其加载 commit 列表时整个面板瞬间卡住。并且等待其加载完毕卡顿结束后,再次左右拖拽,整个界面帧率掉到个位数。

图片.png

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

图片.png

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

image.gif

002.gif

ext-tree-before.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

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

以下视频来源于

Alibaba F2E

图片.png


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


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

图片.png

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

image.gif图片.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.gif图片.png

avatar.png

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

以下视频来源于

Alibaba F2E

图片.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 ,但组件也会触发渲染。

以下视频来源于

Alibaba F2E

图片.png

panel-render.gif

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

以下视频来源于

Alibaba F2E

图片.png

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


最后


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



相关文章
|
机器学习/深度学习 人工智能 监控
人工智能之人脸识别技术应用场景
人脸识别技术是一种通过计算机技术和模式识别算法来识别和验证人脸的技术。它可以用于识别人脸的身份、检测人脸的表情、年龄、性别等特征,以及进行人脸比对和活体检测等应用。
823 1
|
4月前
|
Web App开发 人工智能 JavaScript
主流自动化测试框架的技术解析与实战指南
本内容深入解析主流测试框架Playwright、Selenium与Cypress的核心架构与适用场景,对比其在SPA测试、CI/CD、跨浏览器兼容性等方面的表现。同时探讨Playwright在AI增强测试、录制回放、企业部署等领域的实战优势,以及Selenium在老旧系统和IE兼容性中的坚守场景。结合六大典型场景,提供技术选型决策指南,并展望AI赋能下的未来测试体系。
|
前端开发 持续交付 UED
模块联邦的适用场景
【10月更文挑战第25天】模块联邦适用于需要实现模块共享、组合、拆分和重组的场景,可以提高应用的可维护性、扩展性、灵活性和性能。在实际应用中,需要根据具体的需求和项目特点选择合适的模块联邦方案,并结合其他技术和工具进行综合应用。
|
人工智能 机器人 Linux
阿里云RPA(机器人流程自动化)干货系列之四:阿里云RPA产品架构
导读:本文是阿里云RPA(机器人流程自动化)干货系列之四,详细介绍了阿里云RPA产品架构和技术架构(包括客户端和服务端)等。
8966 0
|
JSON 网络协议 Java
使用Jmeter进行功能和性能测试
使用Jmeter进行功能和性能测试
|
人工智能 算法 自动驾驶
人工智能浪潮中的伦理困境:技术发展与道德责任的平衡
在人工智能技术飞速发展的今天,我们面临着前所未有的伦理挑战。本文深入探讨了AI技术带来的伦理问题,包括数据隐私、算法偏见和自动化失业等。通过分析这些挑战,本文提出了一系列解决策略,旨在促进AI技术的健康发展,同时保护人类社会的福祉。
|
机器学习/深度学习 人工智能 前端开发
探索未来:2024年前端技术趋势解读
探索未来:2024年前端技术趋势解读
601 4
|
数据可视化 Python
Python的Matplotlib库创建动态图表
【8月更文挑战第19天】Matplotlib是Python中广泛使用的数据可视化库,擅长生成静态图表如折线图、散点图等。本文介绍如何利用其创建动态图表,通过动画展示数据变化,加深对数据的理解。文章涵盖动态折线图、散点图、柱状图、饼图及热力图的制作方法,包括开启交互模式、更新数据和重绘图表等关键步骤,帮助读者掌握Matplotlib动态图表的实用技巧。
|
IDE Java Maven
性能工具之Jmeter扩展配置元件插件
【5月更文挑战第20天】性能工具之Jmeter扩展配置元件插件
643 1
|
缓存 搜索推荐 算法
Java排序实战:如何高效实现电商产品排序
在当今的数字化时代,电子商务已成为人们日常生活的重要组成部分。消费者可以在电商平台上浏览和购买来自全球的商品,这无疑为我们的生活带来了极大的便利。然而,随着电商平台的规模不断扩大,商品数量的急剧增加,如何对海量商品进行高效排序成为了电商系统开发的一大挑战。