乐趣区

如何提升VS-Code扩展的启动速度不只是Webpack

概述

扩展可以让用户在 VS Code 中向开发工作流程添加新的语言、调试器和工具。VS Code 提供了丰富的可扩展模块,允许扩展访问用户界面、提供扩展功能。

通常情况下 VS Code 会安装多个扩展,所以作为一名扩展开发者,我们应该时刻关注扩展的性能,避免拖慢其它扩展,甚至是 VS Code 的主进程。

下面是我们在开发一款扩展时应该遵循的原则:

  1. 避免使用同步方法。同步方法会阻塞整个 Node 的进程,直到其返回结果。所以,你应该尽可能地使用异步方法。如果发现很难用异步方法替换同步方法,那么你应该考虑一下重构代码。
  2. 只引用你需要的模块。有一些依赖模块非常巨大,比如说 lodash。通常我们不需要 lodash 的全部方法,所以引用整个 lodash 模块并不合理。lodash 的每个方法都有独立的模块,你应该只引用你需要的部分。
  3. 谨慎对待启动条件。大多数情况下,你的扩展并不需要启动。不用使用“*”作为启动条件。如果你的扩展确实需要一直监听一些事件,考虑将主要的代码放在 setTimeout 里以低优先级运行。
  4. 按需加载模块。import ... from ...是比较常用的引用模块的方法,但是有时这并不一定是个好的方法。比如一个叫做 request-promise 的模块,加载起来会耗费非常多的时间(在我自己这边测试需要 1 至 2 秒),但可只能有在特定的情况下我们才会需要请求远程的资源,比如本地的缓存过期了。

上面提到的前三个原则很多开发者已经遵守了,在这篇文章中,我们会讨论按一种需加载的方法。这种方法要符合我们平时写 TypeScript 和 JavaScript 的习惯,同时也要尽可能减少更改现有代码的工作量。

按需加载模块

符合习惯

一般来说,我们在脚本的最顶端使用 import 来加载模块,比如下面的代码:

import * as os from 'os';

Node 会同步加载指定的模块,同时阻塞后面的代码。

我们需要一个新的方法,比如叫做 impor 吧,用它可以引入模块,但并不马上加载这个模块:

const osModule = impor('os'); // osModule 不可访问,因为 os 模块还没有被加载

为了达到这一目的,我们需要使用 Proxy 对象。Proxy 对象被用来自定义一些基本操作的行为。

我们可以自定义 get 方法,只有当这个模块被调用时我们才开始加载它。

get: (_, key, reciver) => {if (!mod) {mod = require(id);
    }
    return Reflect.get(mod, key, reciver);
}

使用 Proxy 对象后,osModule 是一个 Proxy 实例,并且只有当我们调用它的一个方法后,os 模块才会被加载。

const osModule = impor('os'); // os 模块还没有被加载
...
const platform = osModule.platform() // os 模块从这里开始加载

当我们只想使用模块的一部分时,广泛使用 import {...} for ... 的写法。可是这让 Node 不得不访问这个模块来检查其属性值。这样 getter 就会被调用,模块也会在那个时候被加载。

使用后台任务加载模块

按需加载还不够,我们可以进一步来优化用户体验。在扩展启动和用户运行命令来加载模块之间,我们有充足的时间来提前加载模块。

很容易想到的一个办法,是创建一个后台任务来加载队列里的模块。

时间线

我们开发了一个名叫 Azure IoT Device Workbench 的扩展,它可以结合多个 Azure 服务和流行的物联网开发板,简单地进行物联网项目的开发、编译、部署和调试。

由于 Azure IoT Device Workbench 涉及到的范围非常广泛,所以这个扩展启动起来非常繁重。同时它又需要监听 USB 事件,当物联网设备插入计算机后做出响应。

图一:Azure IoT Device Workbench 使用懒加载和正常加载的启动时间

我们对比了 Azure IoT Device Workbench 在多种情况下使用懒加载和正常加载的启动时间。图一中由上到下的图表分别是没有工作区、打开非物联网项目工作区和打开物联网项目工作区时启动。左侧的图表是冷启动,右侧是热启动。冷启动只发生在第一次安装扩展时,VS Code 做一些缓存之后,都将是热启动。X 轴表示时间,以毫秒为单位。Y 轴是已加载的模块数量。

With normal load, the extension is activated at end of the chart. We find the extension is activated very advanced with lazy load with both cold boot and warm boot, especially when VS Code launches without workspace open.

对于没有工作区冷启动的情况,懒加载的启动速度大约有 30 倍的提升,热启动时有大约 20 倍的提升。打开非物联网项目工作区时,冷启动懒加载比正常加载快了 10 倍,热启动时快 20 倍。当 VS Code 打开物联网项目时,Azure IoT Device Workbench 需要引用大量模块来加载项目,即使这样,我们冷启动时也偶两倍的启动速度,热启动时有 3 倍的启动速度。

下面是懒加载的完整时间线:

图二:Azure IoT Device Workbench 使用懒加载的完整时间线

和图一一样,图二中的图表也表示冷启动和热启动下没有工作区、打开非物联网项目工作区和打开物联网项目工作区。

在图中可以看到后台任务加载模块的加载时间阶梯非常清晰。用户很难注意到这个小动作,扩展启动得非常顺畅。

为了使这个提升性能的方法可以被所有 VS Code 扩展开发者使用,我们发布了一个名叫 impor 的 Node 模块,并且我们已经将这个模块用于 Azure IoT Device Workbench。你可以对代码进行很少的更改就将它应用到你的项目中。

模块打包

几乎所有的 VS Code 扩展都有 Node 模块依赖。因为 Node 模块的工作方式,依赖的曾经可能会非常深。另外,模块的结果也可能非常复杂,也就是 Node 模块黑洞所说的事情。

为了清理 Node 模块,我们使用一个非常棒的工具,webpack。

webpack 是一个现代 JavaScript 应用程序的静态模块打包器(module bundler)。当 webpack 处理应用程序时,它会递归地构建一个依赖关系图(dependency graph),其中包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个 bundle。

Tree shaking

tree shaking 是一个术语,通常用于描述移除 JavaScript 上下文中的未引用代码(dead-code)。它依赖于 ES2015 模块系统中的静态结构特性,例如 importexport。这个术语和概念实际上是兴起于 ES2015 模块打包工具 rollup。

使用 webpack 进行 tree shaking 非常简单。我们需要指定一个入口文件和输出文件名就可以,剩下的事情 webpack 会处理好。

使用 tree shaking 后,没有被引用的文件,包括 JavaScript 代码、markdown 文件等等都会被移除。之后 webpack 会把所有文件整合成一个单独的打包文件。

代码分离

把所有代码都合并成一个文件可不是一个好主意。为了与按需加载一同协作,我们需要把代码分割成多个部分,并且只加载我们需要的部分。

现在,需要一种分离代码的方法是我们需要解决的问题。一种可行的方案是将每个 Node 模块分离成一个文件。不过手动将每个 Node 模块的路径写进 webpack 配置文件中是无法接受的。幸好我们可以使用 npm-ls 来获取产品模式下所有的 Node 模块。这样在 webpack 配置文件的输出部分,我们使用 [name].js 作为输出来编译每个模块。

应用打包后的模块

当我们要加载一个模块时,比如叫 happy-broccoli,Node 会先试着在 node_modules 文件夹中查找 happy-broccoli.js。如果这个文件不存在,Node 接着查找 happy-broccoli 文件夹下的 index.js 文件,如果还是找不到,就查看 package.json 里的main

为了应用打包后的模块,我们可以把它们放进 tsc 输出目录下的 node_modeles 文件夹里。

如果哪个模块不兼容 webpack 打包,就直接将它复制到输出目录的 node_modules 文件夹里。

这是一个扩展项目结构的例子:

|- src
|  |- extension.ts
|
|- out
|  |- node_modules
|  |  |- happy-broccoli.js
|  |  |- incompatible-with-bundle-module
|  |     |- package.json
|  |
|  |- extension.js
|
|- node_modules
|  |- happy-broccoli
|     |- package.json
|
|  |- incompatible-with-bundle-module
|     |- package.json
|
|- package.json
|- webpack.config.js
|- tsconfig.json

未打包 Node 模块时 Azure IoT Device Workbench 包含了 4368 个文件,打包后只剩下了 343 个文件。

Webpack 配置实例

'use strict';

const cp = require('child_process');
const fs = require('fs-plus');
const path = require('path');

function getEntry() {const entry = {};
  const npmListRes = cp.execSync('npm list -only prod -json', {encoding: 'utf8'});
  const mod = JSON.parse(npmListRes);
  const unbundledModule = ['impor'];
  for (const mod of unbundledModule) {
    const p = 'node_modules/' + mod;
    fs.copySync(p, 'out/node_modules/' + mod);
  }
  const list = getDependeciesFromNpm(mod);
  const moduleList = list.filter((value, index, self) => {return self.indexOf(value) === index &&
        unbundledModule.indexOf(value) === -1 &&
        !/^@types\//.test(value);
  });

  for (const mod of moduleList) {entry[mod] = './node_modules/' + mod;
  }

  return entry;
}

function getDependeciesFromNpm(mod) {let list = [];
  const deps = mod.dependencies;
  if (!deps) {return list;}
  for (const m of Object.keys(deps)) {list.push(m);
    list = list.concat(getDependeciesFromNpm(deps[m]));
  }
  return list;
}

/**@type {import('webpack').Configuration}*/
const config = {
    target: 'node',
    entry: getEntry(),
    output: {path: path.resolve(__dirname, 'out/node_modules'),
        filename: '[name].js',
        libraryTarget: "commonjs2",
        devtoolModuleFilenameTemplate: "../[resource-path]",
    },
    resolve: {extensions: ['.js']
    }
}

module.exports = config;

与典型的 webpack 解决方案对比

不将整个扩展打包,而是对每个模块分别打包会带来很大的好处。使用 webpack 打包后,扩展极有可能会抛出数十个错误。把每个模块分离开使调试变得非常容易。同时,按需加载指定的模块也能尽可能地降低对性能的影响。

实验结果

模块打包应用在使用懒加载的 Azure IoT Device Workbench 上,来同正常加载进行对比。

图三:Azure IoT Device Workbench 懒加载打包模块的启动时间和正常加载对比

模块打包大幅减少了启动时间。对于冷启动,在一起情况下懒加载甚至加载完所有模块所消耗的全部时间都比正常加载所需的时间少。

正常 Webpack 典型的解决方案 * 懒加载 懒加载打包的模块 **
没有工作区,冷启动 19474 ms 1116 ms 599 ms 196 ms
没有工作区,热启动 2713 ms 504 ms 118 ms 38 ms
非物联网项目工作区,冷启动 11188 ms 1050 ms 858 ms 218 ms
非物联网项目工作区,热启动 4825 ms 530 ms 272 ms 102 ms
物联网项目工作区,冷启动 15625 ms 1178 ms 7629 ms 2001 ms
物联网项目工作区,热启动 5186 ms 588 ms 1513 ms 517 ms

*,** Azure IoT Device Workbench 需要的一些模块与 webpack 不兼容,没有被打包。

表一:Azure IoT Device Workbench 在不同情况下的启动时间

表一中所示的启动时间是指扩展入口最开始到 activate 函数结束之间的时间:

// 开始启动
import * as vscode from 'vscode';
...
export async function activate(context: vscode.ExtensionContext) {
    ...
    // 启动完成
}
...

通常启动之前的时间要比 VS Code 正在运行的扩展页面中显示的启动时间长。比如以热启动打开物联网项目工作区的启动时间在表中是 517 毫秒,但是 VS Code 正在运行的扩展页面中大约是 200 毫秒。

典型的 webpack 解决方案中,启动时间只有启动模式有关,因为所有模块都总是以同样的方式被加载。当在 Azure IoT Device Workbench 中应用懒加载时,无论是否使用打包模块,没有工作区时启动速度都远快于打开物联网工作区。当我们打开物联网项目工作区时,大部分模块都被引用,懒加载带来的优势不是很明显,所以懒加载打包模块和典型 webpack 解决方案有相近的启动时间。

结论

在这篇文章中,提出了一种按需加载打包模块的方法。一款叫做 Azure IoT Device Workbench 的繁重扩展被用来在多种情况下测试这个方法。并且它的启动速度被提升了数十倍。在某些情况下,这个方法比典型的 webpack 方案带来了更优异的性能提升。

退出移动版