不是很容易复现,当运行 npm install
安装依赖同时操作文件树时,左侧的文件树偶尔会闪一下,如果依赖比较多时闪烁会更频繁(复现条件比较苛刻)。文件树会监听当前工作目录,有文件变化(增、删、改)时会更新文件树,但是处于性能考虑,对于一些非源码的目录或文件较多的目录会默认排除,例如 .git
、node_modules
等,KAITIAN 也提供了默认的文件监听排除配置(一组 glob 匹配规则),出现图中这样的情况,第一反应当然是配置有问题,查看默认配置
// 代码有一定简化
export const fileWatcherExcludes = [
'**/.git/objects/**',
'**/.git/subtree-cache/**',
'**/node_modules/**/*'
];
配置是对的,那么考虑是不是没有生效,查看启动运行日志打印出当前设置的 filesWatcherExclude
配置
[node:log] set watch file exclude: [
[
'**/.git/objects/**',
'**/.git/subtree-cache/**',
'**/node_modules/**/*'
]
]
看起来似乎是正常的.....吗?
这里经过 RPC 调用,传到后端的值变成了 [['xxx', 'yyy']]
, 多了一层 []
,被 glob 接收后也解析成了错误的规则
# 代码有部分简化,实际运行时是生成 match 对象来匹配路径
# 数组被解析成了 ./0 ./1 ./2 这样的路径
[
{ allPaths: [ './0' ] },
{ allPaths: [ './1' ] },
{ allPaths: [ './2' ] },
{ allPaths: [ './length' ] }
]
实际应该被解析为
# 代码有部分简化,实际运行时是生成 match 对象来匹配路径
[
{ allPaths: [ './.git/objects/' ] },
{ allPaths: [ './.git/subtree-cache/' ] },
{ allPaths: [ './node_modules' ] }
]
也就是说这个配置从来就没有生效过。
之前提到的 RPC 调用
,是 KAITIAN 前/后端之间的通信方式,底层使用 vscode-jsonrpc
来实现基于 JSON 格式的 RPC 调用,简单来说前后端的调用方式可以简化为
// 前端调用后端示例代码,例如上文中设置 filesWatcherExcludes 属性
const fileWatcherExcludes = [
'**/.git/objects/**',
'**/.git/subtree-cache/**',
'**/node_modules/**/*'
];
async function doSetFilesWatcherExcludes () {
await this.fs.setWatchFileExcludes(fileWatcherExcludes);
}
这里的 fs 就是一个 Proxy,底层会通过 RPC 协议将调用运行在 Node.js 端的代码,看起来也没什么问题....吗?
CancellationToken 是在 VS Code 中广泛使用的一种模式,例如在跨进程、前后端调用中,传递一个 CancellationToken ,可以在方法执行完之前调用 CancellationTokenSource 的 cancel
方法来取消一次调用 (关于 CancellationToken 的介绍暂时略过,以后有机会再分享)。
// CancellationToken 的使用
// 前端
const source = new CancellationTokenSource();
doSomeAsyncCall([xxx], source.token);
setTimeout(() => {
source.cancel();
}, 100);
// 后端
function doSomeAsyncCall(arg, token) {
if (token.isCancellationRequested) {
// 已取消
return;
}
}
为什么会谈到 CancellationToken 呢? 因为在 vscode-jsonrpc 中会自动在方法调用时的参数尾部添加一个 CancellationToken, 目的是在前一个请求还未完成时,自动取消后续的相同请求(需要服务端编写取消逻辑)。但如果不知道这段隐藏的逻辑,代码很容易会写成这样
// 前端
doSomeAsyncCall([xxx], source.token);
// 后端, 使用 spread 运算符
function doSomeAsyncCall(...args) {
// args: [xxx, token]
// 期望的参数是 [[xxx], token]
/**...*/
}
如果正好参数是数组 [xxx],这样就会将参数与 CancellationToken
混到了一起,所以为了避免这种情况, KAITIAN 对单数组的参数做了处理,也就是将 [xxx]
转换成 [[xxx]]
, 这样即使在后端使用 spread
的情况下,参数展开以后就是 [[xxx], token]
。
问题在于并没有在接收到参数的地方处理这种情况(因为只有数组为参数的情况很少),于是出现了上文中的情况,setWatchFileExcludes
变成了 [['**/node_modules/**/*']]
, 进而导致了一开始的问题。
处理也很简单,在接收到参数的地方针对这种情况将数组展开即可。
function serializeArguments(args: any[]): any[] {
const maybeCancellationToken = args[args.length - 1];
if (
args.length === 2 &&
Array.isArray(args[0]) &&
maybeCancellationToken.hasOwnProperty('_isCancelled') // 判断为 CancellationToken
) {
return [...args[0], maybeCancellationToken];
}
return args;
}
callRPCService(...this.serializeArguments(args));
我们回到这个令人颤抖的问题,默认排除的选项是 **/node_modules/**/*
, 也就是不监听任何 node_modules 目录及其子目录,这样的话安装/删除依赖,文件树都不会有任何反应,只能手动刷新。但全量监听 node_modules 目录又的确对性能消耗很大,可以想象一下较大的前端项目,每次安装依赖都要受到几千上万个文件变更的事件(反正我是不敢想)。
打开 VS Code(1.57 版本,macOS) 安装依赖后发现他们也是一样默认排除了 node_modules 的所有事件,同样是出于性能原因,在这里可以找到对应的 Issue,原因是没有合适的 glob 配置可以在 macOS/Linux 平衡性能与体验。
This is due to the files.watcherExclude setting ignoring changes to the node_modules folder simply because it can contain so many file changes that we decided to ignore changes there for performance reasons. Closing as designed for now until we found a better way of dealing with the amount of file events from large folders.
理想的情况是仅监听 node_modules 与其第一层子目录的事件,排除更深层目录下的事件,VS Code 代码中对这部分也有特殊的处理,
'files.watcherExclude': { /**省略部分代码*/ 'default': isWindows /* https://github.com/microsoft/vscode/issues/23954 */ ? { '**/.git/objects/**': true, '**/.git/subtree-cache/**': true, '**/node_modules/*/**': true, '**/.hg/store/**': true } : { '**/.git/objects/**': true, '**/.git/subtree-cache/**': true, '**/node_modules/**': true, '**/.hg/store/**': true }, },
也就是在 Windows 针对 node_modules 的排除配置是 **/node_modules/*/**
,而在 macOS/Linux 下则是 **/node_modules/**
,这两种模式有什么区别呢?我们使用 Glob Online Test 对这两种模式进行测试,结果中紫色的表示匹配成功,也就是会被排除事件监听,黑色则反之。
再将这段 isWindows
的判断删掉,在 macOS 下运行,发现可以正常监听到 node_modules 及其第一级子目录的事件,而其余的目录变化都会被忽略(图不放了,1.58 版本默认值已经改成了 **/node_modules/*/**
)。
这个 bug 很容易复现,如图中的场景,打开任意 JavaScript/TypeScript
文件,只要问题面板中存在问题,修改任何设置都会导致闪烁。但其他语言的文件并不能复现。由于 KAITIAN 前端是基于 React 的,很容易联想到是不是问题相关的数据发生了改变,但经过排查发现并没有,只不过每次修改设置都会重新从语言服务拉取 diagnostics ,然后重新渲染界面。
这里所说的语言服务
,实际就是 VS Code 的 TypeScript Language Features
插件, 拉取 diagnostics
就是基于插件中 LSP 协议来从语言服务获取当前工作区内检测到的问题(warning、errors 等)。那么问题来了,为什么修改任何设置都会触发这个重新拉取的动作呢?
此时强大的 KT 同事发现了盲点
TypeScript 插件中有一段监听配置改变,判断 diagnostics 设置是否更新,然后 rebuild diagnostics 的逻辑
// 一直返回 truefunction areLanguageDiagnosticSettingsEqual(currentSettings: LanguageDiagnosticSettings, newSettings: LanguageDiagnosticSettings): boolean { return currentSettings.validate === newSettings.validate && currentSettings.enableSuggestions && currentSettings.enableSuggestions;}
那么 VS Code 是不是也会闪呢?
修改设置的确也会触发 VS Code 刷新问题面板, 只不过因为速度太快,界面上并没有明显的闪烁。1.58 版本中 VS Code 已经修复了这个问题。
本文主要是排查并修复两个「颤抖」的 bug,顺便为 VS Code 贡献了两个 PR 的故事,虽然并不算是非常硬核的内容,但也顺带熟悉了 VS Code 开源的贡献流程。最后也分享一下整个过程吧。
如果你阅读过 VS Code Repo 中的这篇文档,可以跳过这一节
除了常规的 GitHub 开源项目协作流程之外,你需要准备好相应的开发环境,对于有经验的工程师来有个很简单。
如果是自己发现并修复的 bug,建议不要直接提交 PR,一般这种没有任何 issue 关联的 PR 是不会被重视更不会被 Merge 的。如果没有对应的 issue,你应该创建一个 issue 并与核心成员或社区成员讨论后表达自己愿意提交 PR 修复。由于每个 PR 都要求有关联的 issue,所以当你提交 PR 时,信息可以非常简单,例如**This PR fixes #<issueid>**
。这样当 PR 被 Merge 时 issue 也会被自动关闭。
也可能会有一些看起来是 bug 实则是 feature 的情况,同样这种情况下直接提交 PR 都是不会被 Merge 的。
VS Code 的 issue 列表中可以通过标签筛选 bug
和 help-wanted
,这表示该 issue 是已确认的 bug,并且欢迎社区贡献,你可以在 issue 中说明你愿意提交 PR 修复该 bug,一般情况下都会获得同意。