前端系统级优化总结 -- 非侵入式优化能做到什么样子?

192 阅读6分钟

项目前言

由于大客户吐槽系统难用,所以要优化。

基础情况:

  1. 项目迭代5年以上
  2. Vue2技术框架 -- Vue@2.7 + Element UI + ...
  3. 大客户重度使用 -- 单客户2400万客单/年,高峰期QPS 10000+

执行线程卡顿优化 -- 消除90%的黄色评分

大客户说切换页面卡卡的。 解决这一问题的关键在于识别并减少主线程的阻塞操作。这里通过Chrome Dev Tools里的Performance进行相关分析操作。

image.png 分析过程中,推荐采用抓大放小的策略,优先解决影响最大的问题。

通过缩放,聚焦最明显的卡顿区间:

image.png

可以得出以下几个结论:

  1. 主线程里多次调用getNodeByValue
  2. getNodeByValue最差的时候需要111.7ms才能返回结果

直接分析getNodeByValue的实现,并没有太过复杂的实现,唯一值得注意的是,其内部引用了一个函数的递归调用。

image.png

这里,可以用过Logpoint来快速验证猜想。

操作步骤如下:

  1. 函数入口创建一个断点breakpoint
  2. 右键单击breakpoint,并选择Edit breakpoint
  3. 弹窗中将type修改为Logpoint,并输入测试代码。修改完成后,左侧的蓝色断点标志会变成红色

image.png

  1. 在console里面初始化赋值window.__testcount = 0
  2. 重新触发场景就可以测试了。最终测试结果为65252次。
  3. Logpoint会调用IO,高频场景需要及时复位,防止拖垮浏览器。

找到瓶颈点之后,根据当前场景,引入多级缓存实现,最终消除瓶颈。

另外,尽量避免在主线程进行大量对象的创建和销毁,因为这会触发垃圾回收,进一步增加主线程负担。JS的GC是阻塞式的GC,一旦频繁触发GC,势必会造成难以缓解的卡顿。所以适时的手动管理内存,如使用对象池技术复用对象,也是减轻执行线程压力的有效手段。

减少内存占用 -- 从600MB优化到280MB

内存管理是保证应用稳定运行的基础。根据实际测试结果(macOS 14.1 Chrome 127),当前页签内存到达4GB的时候,会触发OOM,直接crash。即使没有崩溃,对应的GC操作也会明显阻断主线程的业务逻辑。这就是著名的stop the world。

image.png

内存快照常见排序有Shallow Size和Retained Size。

Shallow Size意为本身占用的内存大小。 Retained Size意为因为本身导致其他内存也不能被GC的总计大小。

排查过程中,Shallow Size可以通过总数除以个数来快速判断是否值得深挖。例如Context这一项,平均大小为:103175280 ÷ 1892257 ≈ 55 字节。

通过Retained Size排序后,第一个有意义的统计项为Node。

image.png

根据源码反查,这里指向一个定制后的Element级联组件。根据业务数据反推,当前组件应该只有16000+个Node。通过计算,得出结论当前页面保持了5个组件的数据,与实际显示不符。有了目标组件就可以针对性的进行排查了,最后结论是有悬浮组件通过v-show隐藏,虽然没有显示,但是组件照常初始化并占用了内存。

后续继续排查,还发现一个典型的问题,为预加载了echarts。图表需求只有在指定页面才需要加载,前期考虑到减少打包文件体积和cdn缓存,这里echarts不管是否需要都进行了加载。

由于gc的特性,内存回收不是实时起效的。尤其是V8的堆内存进行了分区,当某些临时创建的数据意外从新生代进入了老生代,内存是不会快速回收的。这个时候可以通过memory面板的按钮手动触发GC,如果GC后内存有了明显的下降且基本保持不变,那就说明有需要被回收的对象被错误的标记。

image.png

解决这个问题的一个思路是使用对象池,受限于业务限制,现有代码数据源和状态是混在一起的,无法直接复用对象,这里受限于篇幅,不再展开。

构建过程优化

独立托管第三方公共库 -- 构建产物大小缩小50%

项目中历史存在的几个常用公共库,如vue、vuex、vue-router等处于public目录下。每次发版构建的时候,都会出现由public拷贝到dist的过程。实际上,由于这些第三方库不存在变更的可能。直接托管到独立的cdn服务上可以大幅减少构建产物上传的带宽压力。

消除意外引入的依赖

构建过程中,控制台抛错"对node_modules/typescirpt/lib/typescirpt.js没有合适的loader"。说明工程中意外引入了ts的代码。

解决步骤如下:

  1. 修改webpack配置,让webpack可以正确的处理typescript.js
config.module.rules.push({
  test: /.js$/,
  loader: "babel-loader",
  include: /node_modules\/.*typescript/,
});
  1. 通过webpack-bundle-analyzer分析构建产物,找到对应的chunk文件

image.png

  1. 关闭代码压缩和模块合并
config.optimization.minimize = false;
config.optimization.concatenateModules = false;
  1. 通过关键字"typescript"找到对应的导入链,找到最终的引入点

资源文件自动化处理

项目中存在多语言资源文件需要批量处理,其内容随着业务拓展不定期进行变更。上线后,跟随客户浏览器的语言配置自动选择对应的资源文件。

image.png

之前的流程需要人工干预多处,包括但不限于:改路径前缀(避免CDN缓存干扰)、改项目中加载的路径等。

这里的优化思路如下:

  1. 通过webpack的file-loader引入资源文件
    • file-loader的引入结果只是相对路径,相对比2.2MB的全量引入,路径引入结果不会超过1KB
    • 通过file-loader配置构建的产物名称中包含contenthash,内容变化,路径就会变化
config.module.rule("i18n")
  .set("type", "javascript/auto")
  .test(/[\/]lang[\/].*\.json$/)
  .use("file-loader")
  .loader("file-loader")
  .options({
    name: (resourcePath) => {
      const fileName = path.basename(resourcePath);
      const shortName = fileName.replace(".json", "");
      return `${shortName}-[contenthash:8].[ext]`;
    },
    outputPath: "i18n-lang/",
  });
  1. 通过require.resolve全量引入所有资源文件的路径
const langRequire = require.context("../lang", true, /\.json$/);
const loaders = langRequire.keys().reduce(
  (map, key) => {
    if (process.env.NODE_ENV === "development") {
      if (map[key]) throw new Error(`有重名的资源文件: ${key}`);
    }
    map[key] = langRequire(key);
    return map;
  },
  {},
);
  1. 劫持全局的fetch副本,防止sentry修改fetch导致资源文件请求需要跨域预检

加载速度优化

由于项目面向全球部署,有客户经常需要跨墙访问。大概有10MB左右的基础资源需要重新加载,网络不稳定的时候,项目的打开速度瓶颈明显。而常见的协商缓存至少需要浏览器对服务端进行一次完整的请求,而建立TCP虚拟电路的时间可能会占到总请求时间的80%以上。

出于这方面的考虑,在项目中引入了service worker来管理强缓存。

由于世界各地网络状况复杂,尤其是东南亚相关的网络,甚至于存在当地ISP屏蔽DNS解析的情况。当前系统部署了多个加速站点。由于客户的办公点遍布境内境外,对外发布的域名也需要有对应的管理。这对终端用户产生了不必要的复杂性。

对于此,可以尝试使用service worker劫持api请求到测速最快的站点。限于篇幅,不再继续展开。