头图

Front-End Performance Checklist 2021[1]
https://www.smashingmagazine....

前端性能优化(一):准备工作[2]
前端性能优化(二):资源优化[3]

一、ES2017+新增<script type="module">,也被成为module/nomodule模式。

首先,盘点出所有资源的清单( JavaScript 、图片、字体、第三方脚本和页面上开销较大的模块,例如轮播、复杂的信息图和多媒体内容),然后将它们按组细分:针对旧版浏览器加载核心体验,针对现代浏览器加载增强体验,最后加载额外体验(不是绝对必需的并且可以延迟加载的资源,例如网络字体、不必要的样式、轮播脚本、视频播放器、社交媒体按钮、大图片)。

其思想就是编译并提供2个单独的JavaScript包:一个是“常规”构建,即带有Babel-transforms和polyfills的构建,提供给旧版浏览器;另一个bundle(同样的功能)没有transform和polyfills,提供给现代浏览器。使用该技术,减少浏览器需要处理的脚本数量的同时也减少了主线程阻塞。

Rollup、Parcel2、Snowpack、Vite等都支持module/nomodule模式打包,webpack也有相应的插件module-nomodule-plugin支持,这里我们以webpack为例[4],在不使用插件的情况下可进行如下配置:

1、babel配置

针对不同环境设置不同配置

// 来自:https://calendar.perfplanet.com/2018/doing-differential-serving-in-2019/
// babel.config.js
module.exports = {
  env: {
    // This is the config we'll use to generate bundles for legacy browsers.
    legacy: {
      presets: [
        [
          "@babel/preset-env", {
            modules: false,
            useBuiltIns: "entry",
            // This should reasonably target older browsers.
            targets: "> 0.25%, last 2 versions, Firefox ESR"
          }
        ]
      ],
      plugins: [
        "@babel/plugin-transform-runtime",
        "@babel/plugin-syntax-dynamic-import"
      ]
    },
    // This is the config we'll use to generate bundles for modern browsers.
    modern: {
      presets: [
        [
          "@babel/preset-env", {
            modules: false,
            targets: {
              // This will target browsers which support ES modules.
              esmodules: true
            }
          }
        ]
      ],
      plugins: [
        "@babel/plugin-transform-runtime",
        "@babel/plugin-syntax-dynamic-import"
      ]
    }
  }
};

2、webpack配置

// 首先是公共部分
// webpack.config.js
const commonConfig = {
  // `devMode` is the result of process.env.NODE_ENV !== "production"
  mode: devMode ? "development" : "production",
  entry: path.resolve(__dirname, "src", "index.js"),
  plugins: [
    // Plugins common amongst both configurations
  ]
};
// 针对旧版浏览器
// webpack.config.js
const legacyConfig = {
  name: "client-legacy",
  output: path.resolve(__dirname, "src", "index.js"),
  module: {
    rules: [
      // Loaders go here...
    ]
  },
  // Spread syntax merges commonConfig into this object.
  ...commonConfig
};
// 针对现代l浏览器
const modernConfig = {
  name: "client-modern",
  // Note the use of the .mjs extension for the modern config.
  output: path.resolve(__dirname, "src", "index.mjs"),
  module: {
    rules: [
      // Loaders go here...
    ]
  },
  // Ditto.
  ...commonConfig
};

// Slap 'em in there
module.exports = [legacyConfig, modernConfig];

3、webpack babel-loader配置

// webpack.config.js
const legacyConfig = {
  // ...
  module: {
    rules: [
      {
        test: /\.js$/i,
        // Make sure you're bundling your vendor scripts to a separate chunk,
        // otherwise this exclude pattern may break your build on the client!
        exclude: /node_modules/i,
        use: {
          loader: "babel-loader",
          options: {
            envName: "legacy" // Points to env.legacy in babel.config.js
          }
        }
      },
      // Other loaders...
    ]
  },
  // ...
};
// webpack.config.js
const modernConfig = {
  // ...
  module: {
    rules: [
      {
        test: /\.m?js$/i,
        exclude: /node_modules/i,
        use: {
          loader: "babel-loader",
          options: {
            envName: "modern" // Points to env.modern in babel.config.js
          }
        }
      },
      // Other loaders...
    ]
  },
  // ...
};

<script type="module">默认defered。使用type="module"的内联JavaScript也是defered的。
image.png

值得注意的是,使用module/nomodule在某些浏览器可能会适得其反。

<script type="module" src="/js/modern.mjs"></script>
<script nomodule defer src="/js/legacy.js"></script>

内容如下:

● 没有浏览器会重复执行(提供Safari hack)。

● 现代的Chrome, Edge, Firefox从不获取多余的东西。

● Safari <11可能会也可能不会同时加载(即使使用hack);它不会在小的测试页面上,但在真正的复杂页面上是这样的(它似乎是确定的,但不清楚确切的触发器是什么)。

● Safari 11+在某些情况下仍然会同时加载,但2020年10月已经在Safari 14.0中修复。2018年之前的浏览器会同时加载。

● Edge 17-18执行重复加载3次(2xmodule+ 1x nomodule)。截至2020年4月,这一比例约为80%,但Edge 80+版本将逐步淘汰。

有一个hack的方式,可以判断浏览器是否支持type="module"属性:

<script>
  // Create a new script element to slot into the DOM.
  var scriptEl = document.createElement("script");

  // Check whether the script element supports the `nomodule` attribute.
  if ("noModule" in scriptEl) {
    scriptEl.src = "/js/modern.mjs";
    scriptEl.type = "module";
  } else {
    scriptEl.src = "/js/legacy.js";
    scriptEl.defer = true;
  }

  document.body.appendChild(scriptEl);
</script>

另外,还需说明的是:功能检测本身不足以对发送到该浏览器的有效载荷做出明智的决定。就其本身而言,我们无法从浏览器版本推断设备性能。我们可以使用设备内存客户端提示头(仅Blink支持,Device-Memory : <value>。<value>是RAM的近似值,是单位GB,计算方式:使用MB中实际的设备内存,然后四舍五入到最接近的数字,其中只有最高位可以设置,其余的都是0(最接近的2的幂)。然后除以1024得到GB中的值。),或在Chrome中使用JavaScript API Device Memory(navigator.deviceMemory,header的取值一样)。

二、使用Tree-shaking

只加载使用的代码,删除未使用代码。

三、使用Code-splitting

将代码分割成按需加载的“块”。

在哪里定义分割点,通过跟踪使用了哪些CSS/JavaScript块,哪些没有使用。Umar Hansa 指出可通过Chrome的devtool确定,有兴趣的同学可以查看视频,这里简单介绍一下:

打开我们的控制台 -> 点击Console旁边垂直的一个三点icon -> 点击Coverage -> 弹出Coverage面板,与Performance面板类似 -> 点击面板上的刷新按钮 -> 即可看见资源未使用大小比例
image.png

四、考虑使用<link rel="preload"> or <link rel="prefetch">

接受代码分割的路由,然后提示浏览器预加载它们。

五、提高webpack输出

推荐工具:

● Uglify和Terser支持在函数调用前加/*#__PURE__*/删除未使用结果的函数,如下:
function getColor() {
  // 多行代码
}
const color = /*#__PURE__*/ getColor()
● purgecss-webpack-plugin:移除未使用的classes。

● 启用split-chunks-plugin的optimization.splitChunks: 'all':让Webpack自动对入口包进行代码拆分,以实现更好的缓存。

● 设置optimization.runtimeChunk: true:将webpack的运行时移动到一个单独的块中——也会改进缓存。

● google-fonts-webpack-plugin:下载字体文件,所以可以从我们自己的服务器上提供它们。

● workbox-webpack-plugin:允许为所有webpack资产生成一个预先准备的service worker。另外,检查Service Worker Packages,这是一个可以立即应用的模块的全面指南。或者      ● 使用preload-webpack-plugin生成所有JavaScript块的预加载/预取。

● speed-measure-webpack-plugin:测量webpack构建速度,提供构建过程中哪些步骤最耗时的见解。

● duplicate-package-checkin-webpack-plugin:当包含同一个包的多个版本时,发出警告。

● 在编译时使用作用域隔离并动态缩短CSS类名。

六、考虑将繁重的 JavaScript 抽离到 Web Worker

当代码长时间阻塞时使用web worker,但当你依赖DOM,处理输入响应,需要最小延迟时避免使用web worker。

七、考虑将计算密集的JavaScript抽离到WebAssembly

WebAssembly不应该取代JavaScript,但是当你注意到CPU占用时,它可以补充JavaScript。对于大多数web应用来说,JavaScript是一个更好的选择,而WebAssembly最适合用于计算密集型的web应用,比如游戏。

如果还不确定什么时候使用Web Workers, Web Assembly, streams, 或者是 WebGL JavaScript API 访问GPU,Accelerating JavaScript是一个简短但很有用的指导,它解释了什么时候用,为什么要用,有兴趣的同学可以看一下。

八、识别并删除未使用的CSS/JS

使用Chrome提供的Coverage。此外,purgecss, UnCSS和Helium也可以帮助从CSS中删除未使用的样式。

九、修剪 JavaScript 包大小

● webpack-libs-optimizations:在构建过程中删除未使用的方法和 polyfills。

● polyfill.io:是它是一个服务,它接受对一组浏览器特性的请求,并只返回请求浏览器需要的polyfill。

● 添加包审核到日常工作流程中:使用更小巧轻便的库替换你可能在几年前添加的一些大型的库,比如Moment.js替换为date-fns,fns可以在3G和低端手机上节省大约300毫秒的时间。

Bundlephobia:发现添加一个npm包到项目中的成本。

size-limit:扩展了基本的包大小检查与JavaScript执行时间的细节。

还可以将这些成本与Lighthouse Custom Audit集成在一起。

这也适用于框架,通过删除或修剪Vue MDC适配器(Vue的Material Components),样式从194KB减少到10KB。

● 其他工具:webpack-bundle-analyzer、Source Map Explorer、Bundle Buddy、Bundlephobia、Webpack analyze(说明为什么一个特定的模块被包含到包中)、bundle-wizard (为整个页面构建依赖关系映射)、Webpack size-plugin、Import Cost for Visual Code。

● 除了提供整个框架之外,还可以对框架进行调整,并将其编译为不需要额外代码的原始JavaScript包。

十、使用部分合成

不是使用SSR,然后将整个应用程序发送到客户端,而是将应用程序的一小部分JavaScript发送到客户端,然后合成。

方法:除了基于路由级别,还基于组件级别进行代码分割。

十一、使用预测方式预取JavaScript块

Guess.js是一组工具和库,它们使用Google Analytics数据来确定用户最有可能从给定页面访问下一个页面。基于从Google Analytics或其他来源收集到的用户导航模式,Guess.js构建了一个机器学习模型来预测和预取每个后续页面所需的JavaScript。

每个交互元素都有一个参与的概率分数,客户端脚本基于该分数决定提前预加载资源。使用guess-webpack可以自动执行设置过程。

为避免预取到不需要的页面,需预限制加载的请求数量。比如预取在检查出来的脚本中经过确认的,或者在关键的动作调用进入可视区域时进行推测性预取。

如果需要一些不那么复杂的,DNStradamus 会对 a 标签出现在可视区时对 DNS 进行预取。Quicklink, InstantClick和Instant.page是小型库,它们在空闲时间自动在视口中预取链接,以尝试加快下一页导航的加载速度。Quicklink允许预取React Router路由和Javascript;会考虑到数据量,因此它不会在 2G 或 Data-Saver 启用状态下预取,Instant.page 在将模式设置为使用 viewport prefetching(默认设置)时也是如此。

十二、利用目标JavaScript引擎的优化

例如V8引擎,加载JS时可以使用async或defer。下载开始后在一个单独的后台线程上解析,这在某些情况下可将页面加载时间提高10%。实际上,在 header 中使用<script defer>,可以使浏览器更早的发现资源,然后在后台线程解析它。

警告:Opera Mini 不支持脚本延迟,因此,如果你是为印度或非洲开发的, defer 则将被忽略,从而导致渲染被阻塞,直到对脚本执行完毕。

总的来说,针对JavaScript,有一些实践需记住:

(一)JavaScript代码整洁

用于编写可读、可重用和可重构代码的大量模式集合。

(二)压缩JavaScript数据

如使用Gzip。

(三)识别并修复棘手的JavaScript内存泄漏

在JavaScript中,当对象不再需要,但仍被函数或其他对象引用时,就会发生内存泄漏。

1、最常见的内存泄漏源

● addEventListener:最常见的一种,调用removeEventListener清除。

● setTimeout / setInterval:使用 clearTimeout/clearInterval及时清除。

● IntersectionObserver, ResizeObserver, MutationObserver等:如在组件中创建了一个,并将它附加到一个全局可用的元素,那么需要调用disconnect()来清理它们。(注意,被垃圾回收的DOM节点其监听器和观察者也将被垃圾回收。所以通常,你只需要担心全局元素,例如,document,无处不在的header/footer元素,等等。)

● Promises, Observables, EventEmitters等:如果忘记停止侦听,则设置侦听器的任何编程模型都可能泄漏内存。(如果Promise从未被resolved或rejected,它就会泄漏,在这种情况下,任何附加到它的.then()回调都会泄漏。)

● 全局对象。

● 无限的DOM增长:如果在没有虚拟化的情况下实现无限滚动列表,那么DOM节点的数量将无限制地增长。

2、识别内存泄漏

在Chrome DevTools中,我们选择的主要工具是“Memory”选项卡中的“Heap snap
image.png

3、queryObject

另一个有用的内存调试技巧:在Chrome Console中调用queryObjects(Constructor)方法,它返回一个用指定构造函数创建的对象数组。如:

queryObjects(Promise):返回所有的Promise。
queryObjects (HTMLElement):返回所有HTML元素。
queryObjects(foo),其中foo是一个函数名:返回通过new foo()实例化的所有对

Code splitting无效,其次,它会使包初始化更加昂贵。常见demo如下:
image.png

(五)使用passive event listeners提高滚动性能

罪魁祸首主要是touch event,浏览器无法知道触摸事件监听器是否会取消滚动,所以它们总是等待监听器在滚动页面之前完成。Passive event listeners可以解决这个问题,它允许在addEventListener的options参数中设置一个标志,表明侦听器永远不会取消滚动。这些信息使浏览器能够立即滚动页面,而不是在侦听器完成后。

(六)如果你有scroll或touch*listeners,传递passive: true给addEventListener

这告诉浏览器你不打算在内部调用event.preventDefault(),因此它可以优化处理这些事件的方式。

(七)isInputPending()[5]

一个新的JavaScript API可以帮助你避免在加载性能和输入响应性之间的权衡。该API从chrome 87+开始支持,目前没有其他浏览器表示有意发布这个API。

加载过程中为了让用户输入得到及时响应,通常我们将JavaScript拆分成很多小块,页面可以运行一点JavaScript,然后将控制权交还给浏览器。然后浏览器可以检查它的输入事件队列,看看是否有什么需要告诉页面的内容。然后浏览器可以在添加JavaScript块时返回运行它们。这是有帮助的,但也会引起其他问题。每次页面将控制权返回给浏览器时,浏览器都要花一些时间来检查其输入事件队列、处理事件并选择下一个JavaScript块。虽然浏览器对事件的响应更快了,但是页面的整体加载时间变慢了。如果我们屈服得太频繁,页面加载就会太慢。如果我们不那么频繁,浏览器响应用户事件的时间就会变长,人们就会感到沮丧。

isInputPending() API是第一个为web上的用户输入使用中断概念的API,并且允许JavaScript能够在不向浏览器屈服的情况下检查输入。

const DEADLINE = performance.now() + QUANTUM;
// By default, "continuous" events are not returned from isInputPending(). These include mousemove, pointermove, and others. If you're interested in yielding for these as well, no problem. By providing a dictionary to isInputPending() with includeContinuous set to true
const options = { includeContinuous: true };
while (workQueue.length > 0) {
  if (navigator.scheduling.isInputPending(options) || performance.now() >= DEADLINE) {
    // Yield if we have to handle an input event (any of them!), or we're out of time.
    setTimeout(processWorkQueue);
    return;
  }
  let job = workQueue.shift();
  job.execute();
}

(八)当一个事件监听执行完成后,可以自动删除它。

(九)JIT优化策略[6]

Firefox最近发布了Warp, 是SpiderMonkey的一个重要更新(在Firefox 83中发布),还有Baseline Interpreter,以及一些可用的JIT优化策略。

十三、自托管第三方资源

默认自托管你的静态文件。可能有人说如果很多网站都使用同一个C DN上的同一个版本的JS库或Web字体文件,如果浏览器已经缓存这些资源了,那么访问我们的网站时就可以直接访问缓存的资源,从而提高用户体验,然而这是不可能的。出于安全原因,浏览器实现了分区缓存,该技术在2013年的Safari和去年的Chrome中引入,因此,如果两个站点指向完全相同的第三方资源 URL,则每个域都将代码下载一次,并且由于隐私问题,缓存将存在关联域名的“沙盒”中,所以,使用公共的CDN并不会对性能有提升。

而自托管第三方资源,可以减少DNS解析,TCP连接和TLS握手;此外,如果你使用HTTP2,浏览器将利用在主域上建立的HTTP / 2复用和优先级。你可以更好的利用浏览器的压缩算法(Brotli/gzip),还可以更好的控制资源缓存时长(通常在公共CDN上,缓存时间较短)。如果没有自我托管,则由于第三方资源是从与主页不同的域中提供的,因此无法确定它们的优先级,最终导致彼此争夺下载带宽。这可能导致其他关键主要资源的实际提取时间比最佳情况长得多。

如果你想知道你的网站引用了哪些第三方资源,你可以访问Simon Hearne’s Request Map[7],可以可视化的了解页面上的第三方资源,以及它们的大小、类型和触发负载的详细信息。

第三方资源在性能方面的影响除了增加了DNS解析、TCP连接和TLS握手(使用dns-prefetch或preconnect解决,但往往也是不够的)等,还通常表现在两方面,争夺网络带宽和JS处理时间。如果无法避免使用第三方资源,至少要对它进行懒加载。

另外还要关注一下如果获取第三方资源超时或第三方资源服务器挂掉的情况,要掌握出现这些情况对你的网站有什么影响。如果处理其影响?我们可以使用service worker。如果资源在一定时间内未响应,则返回一个空响应,告诉浏览器继续进行页面解析。也可以记录或阻止不成功或不符合特定条件的第三方请求。另一种选择是建立内容安全策略(CSP),以限制第三方脚本的影响。还可以通过浏览器内部性能与功能策略的配合来检查第三方,Feature-Policy[8]是一项相对较新的功能,可让你选择加入或退出网站上的某些浏览器功能(它也可以用于避免图像尺寸过大和未优化,媒体尺寸过大,同步脚本等)。当前在基于Blink的浏览器中受支持。

如何选择第三方资源,考虑使用Patrick Hulce's ThirdPartyWeb.Today[9]检查,它提供服务,该服务将所有第三方脚本按类别(分析,社交,广告,托管,标签管理器等)分组,并可视化执行实体脚本所需的时间(平均)。最大的实体对其所在页面的性能影响最差。

十四、正确设置HTTP缓存头

HTTP缓存主要有与cache-control、expires、max-age有关。

总的来说,资源应该在很短的时间内(如果它们可能会更改)或无限期(如果它们是静态的)可缓存的。静态资源一般可以仅在需要时在URL中更改其版本,可以将其称为Cache-Forever策略,在该策略中,我们可以依赖Cache-Control和Expires头,允许资产在浏览器一年内过期,如果浏览器在缓存中有该资源,浏览器甚至不会请求该资源,即我们常说的强缓存。

针对接口请求,应防止缓存,可以使用private,no-store,但不使用max-age = 0,no-store:

Cache-Control: private, no-store

针对带hash的静态资源,尽可能的长时间缓存:

Cache-Control: max-age=31556952, immutable

Cache-control: immutable,当资源未过期时,不再向服务发送请求验证资源是否过期。虽然Firefox,Edge和Safari已经支持该功能,但Chrome仍在争论这个问题。

stale-while-revalidate,使用Cache-Control响应头指定了缓存时间,例如 Cache-Control: max-age=604800。经过604800秒后,缓存将重新获取请求的内容,从而导致页面加载速度变慢。可以通过使用 stale-while-revalidate 来避免这种减速;它基本上定义了一个额外的时间窗口,在此期间,缓存可以使用过期的资源,只要它可以在后台重新异步验证它的状态即可。因此,它“隐藏”了客户端的延迟(在网络中和在服务器上)。Chrome和Firefox在2019年6-7月开始支持,由于过期的资产不再在关键路径中,它可以改善后续的页面加载延迟。效果:对于重复视图,RTT 为零。

值得注意的是,网络请求可能比从缓存中获取更快。主要由两方面因素导致的:设备硬件、缓存加载资源总数。有兴趣的可以阅读When Network is Faster than Cache[10]。
image.png

更多关于缓存可阅读Harry Roberts的Cache-Control for Civilians、Jake Archibald的Caching Best Practices、Ilya Grigorik的HTTP caching primer、Lydia Hallie的CS Visualized: CORS、Eric Portis的Same-Origin Policy。

欢迎关注我的个人公众号:
image.png

参考资料
Front-End Performance Checklist 2021[1]:https://www.smashingmagazine....
前端性能优化(一):准备工作[2]:https://mp.weixin.qq.com/s/QD...
前端性能优化(二):资源优化[3]:https://mp.weixin.qq.com/s/Yb...
Doing Differential Serving in 2019[4]:https://calendar.perfplanet.c...
Better JS scheduling with isInputPending()[5]:https://web.dev/isinputpending/
JIT Optimization Strategies[6]:https://developer.mozilla.org...
Request Map[7]:https://requestmap.webperf.to...
Feature-Policy[8]:https://timkadlec.com/remembe...
ThirdPartyWeb.Today[9]:https://www.thirdpartyweb.today/
When Network is Faster than Cache[10]:https://simonhearne.com/2020/...


花伊浓
55 声望2 粉丝