我们基于 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,优化起来貌似简单一些,在开始之前,它们是这样子
可以看到由于全局状态的影响,任何点击、输入操作都会触发整个面板全部重新渲染,这显然是不合理的。
const SearchView = () => {
const searchService = useInjectable(SearchService);
return (
);
}
我们将这些 Input
、Checkbox
各自拆分出来,例如根据功能不同,将它们拆分为 SearchInput
、SearchExclude
、SearchInclude
、SearchResult
等组件。将组件各自依赖的状态通过 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 栏右侧的按钮,以及侧边面板中标题栏的各种按钮,还有状态栏的一些可点击的标签等等。
menu-actions-before.gif
如图所示,这些不起眼的 Menu 随着操作一直在触发 Render,理论上这些不必要也不应该这么频繁的渲染,然而在为这些 Menu 添加了简单的 memo
包裹后并没有效果,因为其依赖的 menuitem
每次都是新的。随便打一些 log 可以发现问题,任何面板的 Rerender 都会生成新的 Menu 实例,而每个 Menu 又会重新构造内部的 menuitem。
每一个 Menu 实例都可能会有上百个 menitem ,看似不起眼的 menu 实则非常昂贵,然而经过简单的排查发现这种重复创建不止一处。
title-menu.png
这段手风琴折叠面板的逻辑,在这个组件每次渲染时都会尝试获取 titleMenu
,没有则重新创建,titleMenu 即上文中 id 为 view/title
的一个 Menu 实例。
return-menu.png
实际的实现中则直接 return 了 menu,而没有将其缓存下来,这导致面板上任何操作引起的渲染都会重新创建 Menu 实例,进而导致内部的 menuitem 全部重新渲染。
然而事实并没有这么简单,当我安装最新版本的 GitLens 插件,在其加载 commit
列表时整个面板瞬间卡住。并且等待其加载完毕卡顿结束后,再次左右拖拽,整个界面帧率掉到个位数。
再打一些 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 的频繁创建之外,另一个问题就是由于视图拖拽后宽度变化导致的。
ext-tree-before.gif这里没有很明显的卡顿是因为截图是用满血版 M1 Pro 测试的,另一台 Intel i7 的表现差不多相当于开启了 CPU 4X Slowdown 的效果
使用 React DevTools 录制的截图可以很明显看出,随着拖拽操作,整个 TreeView 中的 treeitem 都在重新渲染。原因实际上也很简单,由于拖拽时面板 width
和height
都会改变,而这部分代码在使用 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 脚本。
火焰图的细节这里不再赘述,点这段耗时很长的任务看调用栈,发现耗时最长的竟然是之前一直被忽略的 iconService
。
我们熟悉的老朋友 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;}`;
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
,但组件也会触发渲染。
例如这里切换任何一个 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
最后
我们是大淘宝前端技术工程团队,负责大淘宝及集团的前端工程研发全链路解决方案的建设,欢迎加入。
橙子说
大淘宝技术新春拜年
“虎虎虎”
纸质红包大派送
关注”淘系技术“回复"红包“即可获得领取方式
(2月28日18:00截止)
✿ 拓展阅读
作者|柳千
编辑|橙子君
出品|阿里巴巴新零售淘系技术