【前端性能优化指南】5.1 - 优化你的 JavaScript

🔝 静态资源优化的整体思路javascript

随着 Web 的发展,JavaScript 从之前只承担简单的脚本功能,到如今被用于构建大型、复杂的前端应用,经历了很大的发展。这也让它在当下的前端应用中扮演了一个很是重要的角色,所以在这一节首先来看看的咱们熟悉的 JavaScript。css

1. 减小没必要要的请求

在进行 JavaScript 优化时,咱们仍是秉承整体思路,首先就是减小没必要要的请求。html

1.1. 代码拆分(code split)与按需加载

相信熟练使用 webpack 的同窗对这一特性都不陌生。前端

虽然总体应用的代码很是多,可是不少时候,咱们在访问一个页面时,并不须要把其余页面的组件也所有加载过来,彻底能够等到访问其余页面时,再按需去动态加载。核心思路以下所示:java

document.getElementById('btn').addEventListener('click', e => {
    // 在这里加载 chat 组件相关资源 chat.js
    const script = document.createElement('script');
    script.src = '/static/js/chat.js';
    document.getElementsByTagName('head')[0].appendChild(script);
});
复制代码

在按钮点击的监听函数中,我动态添加了 <script> 元素。这样就能够实如今点击按钮时,才加载对应的 JavaScript 脚本。node

代码拆分通常会配合构建工具一块儿使用。以 webpack 为例,在平常使用时,最多见的方式就是经过 dynamic import[1] 来告诉 webpack 去作代码拆分。webpack 编译时会进行语法分析,以后遇到 dynamic import 就会认为这个模块是须要动态加载的。相应的,其子资源也会被如此处理(除非被其余非动态模块也引用了)。react

在 webpack 中使用代码拆分最多见的一个场景是基于路由的代码拆分。目前不少前端应用都在使用 SPA(单页面应用)形式,或者 SPA 与 MPA(多页面应用)的结合体,这就会涉及到前端路由。而页面间的业务差别也让基于路由的代码拆分红为一个最佳实践。想了解如何在 react-router v4 中实现路由级别的代码拆分,能够看这篇文章[2]webpack

固然,若是你不使用 webpack 之类的构建工具,你也能够选择一个 AMD 模块加载器(例如 RequireJS)来实现前端运行时上的异步依赖加载。nginx

1.2. 代码合并

咱们在整体思路里有提到,减小请求的一个方法就是合并资源。试想一个极端状况:咱们如今不对 node_modules 中的代码进行打包合并,那么当咱们请求一个脚本以前将可能会并发请求数十甚至上百个依赖的脚本库。同域名下的并发请求数太高会致使请求排队,同时还可能受到 TCP/IP 慢启动的影响。git

固然,在不少流行的构建工具中(webpack/Rollup/Parcel),是默认会帮你把依赖打包到一块儿的。不过当你使用其余一些工具时,就要注意了。例如使用 FIS3 时,就须要经过配置声明,将一些 common 库或 npm 依赖进行打包合并。又或者使用 Gulp 这样的工具,也须要注意进行打包。

总之,千万不要让你的碎文件散落一地。

2. 减小包体大小

2.1. 代码压缩

JavaScript 代码压缩比较常见的作法就是使用 UglifyJS 作源码级别的压缩。它会经过将变量替换为短命名、去掉多余的换行符等方式,在尽可能不改变源码逻辑的状况下,作到代码体积的压缩。基本已经成为了前端开发的标配。在 webpack 的 production 模式下是默认开启的;而在 Gulp 这样的任务流管理工具上也有 gulp-uglify 这样的功能插件。

另外一个代码压缩的经常使用手段是使用一些文本压缩算法,gzip 就是经常使用的一种方式。

响应头

上图中响应头的 Content-Encoding 表示其使用了 gzip。

压缩效果

深色的数字表示压缩后的大小为 22.0KB,浅色部分表示压缩前的大小为 91.9KB,压缩比仍是挺大的,颇有效果。通常服务器都会内置相应模块来进行 gzip 处理,不须要咱们单独编写压缩算法模块。例如在 Nginx 中就包含了 ngx_http_gzip_module[3] 模块,经过简单的配置就能够开启。

gzip            on;
gzip_min_length 1000;
gzip_comp_level 6;
gzip_types      application/javascript application/x-javascript text/javascript;
复制代码

2.2. Tree Shaking

Tree Shaking 最先进入到前端的视线主要是由于 Rollup。后来在 webpack 中也被实现了。其本质是经过检测源码中不会被使用到的部分,将其删除,从而减少代码的体积。例如:

// 模块 A
export function add(a, b) {
    return a + b;
}

export function minus(a, b) {
    return a - b;
}
复制代码
// 模块 B
import {add} from 'module.A.js';
console.log(add(1, 2));
复制代码

能够看到,模块 B 引用了模块 A,可是只使用了 add 方法。所以 minus 方法至关于成为了 Dead Code,将它打包进去没有意义,该方法是永远不会被使用到的。

注意,我在上面的代码中使用了 ESM 规范的模块语法,而没有使用 CommonJS。这主要是因为 Tree Shaking 算是一种静态分析,而 ESM 自己是一种的静态的模块化规范,全部依赖能够在编译期肯定。若是想要更好得在 webpack 中使用,能够在查看其官网上的这部份内容[4]。关于 Tree Shaking 的介绍也能够从这里了解下[5]

注意,刚才说了 Tree Shaking 很是依赖于 ESM。像是前端流行的工具库 lodash 通常直接安装的版本是非 ESM 的,为了支持 Tree Shaking,咱们须要去安装它的 ESM 版本 —— lodash-es实现 Tree Shaking[6]

此外,Chrome DevTools 也能够帮助你查看加载的 JavaScript 代码的使用覆盖率[7]

2.3. 优化 polyfill 的使用

前端技术的一大特色就是须要考虑兼容性。为了让你们能顺畅地使用浏览器的新特性,一些程序员们开发了新特性对应的 polyfill,用于在非兼容浏览器上也能使用新特性的 API。后续升级不用改动业务代码,只须要删除相应的 polyfill 便可。

这种温馨的开发体验也让 polyfill 成为了不少项目中不可或缺的一份子。然而 polyfill 也是有代价的,它增长了代码的体积。毕竟 polyfill 也是 JavaScript 写的,不是内置在浏览器中,引入的越多,代码体积也越大。因此,只加载真正所需的 polyfill 将会帮助你减少代码体积。

首先,不是每一个业务的兼容性要求都同样。所以,按你业务的场景来肯定引入哪些 polyfill 是最合适的。然而,特性千千万,手动 import 或者添加 Babel Transformer 显然是一件成本极高的事。针对这点,咱们能够经过 browserslist 来帮忙,许多前端工具(babel-preset-env/autoprefixer/eslint-plugin-compat)都依赖于它。使用方式能够看这里

其次,在 Chrome Dev Summit 2018 上还介绍了一种 Differential Serving[8] 的技术,经过浏览器原生模块化 API 来尽可能避免加载无用 polyfill。

<script type="module" src="main.mjs"></script>
<script nomodule src="legacy.js"></script>
复制代码

这样,在可以处理 module 属性的浏览器(具备不少新特性)上就只需加载 main.mjs(不包含 polyfill),而在老式浏览器下,则会加载 legacy.js(包含 polyfill)。

最后,其实在理想上,polyfill 最优的使用方式应该是根据浏览器特性来分发,同一个项目在不一样的浏览器,会加载不一样的 polyfill 文件。例如 Polyfill.io 就会根据请求头中的客户端特性与所需的 API 特性来按实际状况返回必须的 polyfill 集合。

2.4. webpack

webpack 如今已经成为不少前端应用的构建工具,所以这里单独将其列了出来。咱们能够经过 webpack-bundle-analyzer 这个工具来查看打包代码里面各个模块的占用大小。

webpack-bundle-analyzer

不少时候,打包体积过大主要是由于引入了不合适的包,对于如何优化依赖包的引入,这里有一些建议能够帮助你减少 bundle 的体积[9]

3. 解析与执行

除了 JavaScript 下载须要耗时外,脚本的解析与执行也是会消耗时间的。

3.1. JavaScript 的解析耗时

不少状况下,咱们会忽略 JavaScript 文件的解析。一个 JavaScript 文件,即便内部没有所谓的“当即执行函数”,JavaScript 引擎也是须要对其进行解析和编译的。

js 处理

上图能够看出,解析与编译消耗了好几百毫秒。因此换一个角度来讲,删除没必要要的代码,对于下降 Parse 与 Compile 的负载也是颇有帮助的。

同时,咱们从前一节已经知道,JavaScript 的解析、编译和执行会阻塞页面解析,延迟用户交互。因此有时候,加载一样字节数的 JavaScript 对性能的影响可能会高于图片,由于图片的处理能够放在其余线程中并行执行。

3.2. 避免 Long Task

对于一些单页应用,在加载完核心的 JavaScript 资源后,可能会须要执行大量的逻辑。若是处理很差,可能会出现 JavaScript 线程长时间执行而阻塞主线程的状况。

long task

例如在上图中,帧率降低明显的地方出现了 Long Task,伴随着的是有一段超过 700 ms 的脚本执行时间。而性能指标 FCP 与 DCL 处于其后,必定程度上能够认为,这个 Long Task 阻塞了主线程并拖慢了页面的加载时间,严重影响了前端性能与体验。

想要了解更多关于 Long Task 的内容,能够看看 Long Task 相关的标准[10]

3.3. 是否真的须要框架

相信若是如今问你们,咱们是否须要 React、Vue、Angular 或其余前端框架(库),大几率是确定的。

可是咱们能够换个角度来思考这个问题。类库/框架帮咱们解决的问题之一是快速开发与后续维护代码,不少时候,类库/框架的开发者是须要在可维护性、易用性和性能上作取舍的。对于一个复杂的整站应用,使用框架给你的既定编程范式将会在各个层面提高你工做的质量。可是,对于某些页面,咱们是否能够反其道行之呢?

例如产品经理反馈,我们的落地页加载太慢了,用户容易流失。这时候你会开始优化性能,用上此次「性能之旅」里的各类措施。但你有没有考虑过,对于像落地页这样的、相似静态页的页面,是否是能够“返璞归真”?

也许你使用了 React 技术栈 —— 你加载了 React、Redux、React-Redux、一堆 Reducers…… 好吧,整个 JavaScript 可能快 1MB 了。更重要的是,这个页面若是是用于拉新的,这也表明着访问者并无缓存能够用。好吧,为了一个静态页(或者还有一些很是简单的表单交互),用户付出了高额的成本,而本来这只须要 50 行不到的代码。因此有时候考虑使用原生 JavaScript 来实现它也是一种策略。Netflix 有一篇文章介绍了他们是如何经过这种方式大幅缩减加载与操做响应时间的[11]

固然,仍是强调一下,并非说不要使用框架/类库,只是但愿你们不要拘泥于某个思惟定式。作工具的主人,而不是工具的“奴隶”。

3.4. 针对代码的优化

请注意,截止目前(2019.08)如下内容不建议在生产环境中使用。

还有一种优化思路是把代码变为最优状态。它其实算是一种编译优化。在一些编译型的静态语言上(例如 C++),经过编译器进行一些优化很是常见。

这里要提到的就是 facebook 推出的 Prepack。例以下面一段代码:

(function () {
    function hello() {return 'hello';}
    function world() {return 'world';}
    global.s = hello() + ' ' + world();
})();
复制代码

能够优化为:

s = 'hello world';
复制代码

不过不少时候,代码体积和运行性能是会有矛盾的。同时 Prepack 也还不够成熟,因此不建议在生产环境中使用。

4. 缓存

JavaScript 部分的缓存与咱们在第一部分里提到的缓存基本一致,若是你记不太清了,能够回到我们的第一站

4.1. 发布与部署

这里简单提一下:大多数状况下,咱们对于 JavaScript 与 CSS 这样的静态资源,都会启动 HTTP 缓存。固然,可能使用强缓存,也可能使用协商缓存。当咱们在强缓存机制上发布了更新的时候,如何让浏览器弃用缓存,请求新的资源呢?

通常会有一套配合的方式:首先在文件名中包含文件内容的 Hash,内容修改后,文件名就会变化;同时,设置不对页面进行强缓存,这样对于内容更新的静态资源,因为 uri 变了,确定不会再走缓存,而没有变更的资源则仍然可使用缓存。

上面说的主要涉及前端资源的发布和部署,详细能够看这篇内容[12],这里就不展开了。

4.2. 将基础库代码打包合并

为了更好利用缓存,咱们通常会把不容易变化的部分单独抽取出来。例如一个 React 技术栈的项目,可能会将 React、Redux、React-Router 这类基础库单独打包出一个文件。

这样作的优势在于,因为基础库被单独打包在一块儿了,即便业务代码常常变更,也不会致使整个缓存失效。基础框架/库、项目中的 common、util 仍然能够利用缓存,不会每次发布新版都会让用户花费没必要要的带宽从新下载基础库。

因此一种常见的策略就是将基础库这种 Cache 周期较长的内容单独打包在一块儿,利用缓存减小新版本发布后用户的访问速度。这种方法本质上是将缓存周期不一样的内容分离了,隔离了变化。

webpack 在 v3.x 以及以前,能够经过 CommonChunkPlugin 来分离一些公共库。而升级到 v4.x 以后有了一个新的配置项 optimization.splitChunks:

// webpack.config.js
module.exports = {
    //...
    optimization: {
        splitChunks: {
            chunks: 'all',
            minChunks: 1,
            cacheGroups: {
                commons: {
                    minChunks: 1,
                    automaticNamePrefix: 'commons',
                    test: /[\\/]node_modules[\\/]react|redux|react-redux/,
                    chunks: 'all'
                }
            }
        }
    }
}
复制代码

4.3. 减小 webpack 编译不当带来的缓存失效

因为 webpack 已经成为前端主流的构建工具,所以这里再特别提一下使用 webpack 时的一些注意点,减小一些没必要要的缓存失效。

咱们知道,对于每一个模块 webpack 都会分配一个惟一的模块 ID,通常状况下 webpack 会使用自增 ID。这就可能致使一个问题:一些模块虽然它们的代码没有变化,但因为增/删了新的其余模块,致使后续全部的模块 ID 都变动了,文件 MD5 也就变化了。另外一个问题在于,webpack 的入口文件除了包含它的 runtime、业务模块代码,同时还有一个用于异步加载的小型 manifest,任何一个模块的变化,最后必然会传导到入口文件。这些都会使得网站发布后,没有改动源码的资源也会缓存失效。

规避这些问题有一些经常使用的方式。

4.3.1. 使用 Hash 来替代自增 ID

你可使用 HashedModuleIdsPlugin 插件,它会根据模块的相对路径来计算 Hash 值。固然,你也可使用 webpack 提供的 optimization.moduleIds,将其设置为 hash,或者选择其余合适的方式。

4.3.2. 将 runtime chunk 单独拆分出来

经过 optimization.runtimeChunk 配置可让 webpack 把包含 manifest 的 runtime 部分单独分离出来,这样就能够尽量限制变更影响的文件范围。

// webpack.config.js
module.exports = {
    //...
    optimization: {
        runtimeChunk: {
            name: 'runtime'
        }
    },
}
复制代码

若是你对 webpack 模块化 runtime 运行的原理不太了解,能够看看这篇文章[13]

4.3.3. 使用 records

你能够经过 recordsPath 配置来让 webpack 产出一个包含模块信息记录的 JSON 文件,其中包含了一些模块标识的信息,能够用于以后的编译。这样在后续的打包编译时,对于被拆分出来的 Bundle,webpack 就能够根据 records 中的信息来尽可能避免破坏缓存。

// webpack.config.js
module.exports = {
  //...
  recordsPath: path.join(__dirname, 'records.json')
};
复制代码

若是对上述避免或减小缓存失效的方法感兴趣,也能够再读一读这篇文章14。在 webpack v5.x 的计划中,也有针对 module 和 chunk ID 的一些工做计划来提升长期缓存


「性能优化」系列内容

  1. 带你全面掌握前端性能优化 🚀

  2. 如何利用缓存减小远程请求?

  3. 如何加快请求速度?

  4. 如何加速页面解析与处理?

  5. 静态资源优化的整体思路是什么?

    5.1. 如何针对 JavaScript 进行性能优化?(本文)

    5.2. 🔜 如何针对 CSS 进行性能优化?

    5.3. 图片虽好,但也会带来性能问题

    5.4. 字体也须要性能优化么?

    5.5. 如何针对视频进行性能优化?

  6. 如何避免运行时的性能问题?

  7. 如何经过预加载来提高性能?

  8. 尾声

目前内容已所有更新至 ✨ fe-performance-journey ✨ 仓库中,陆续会将内容同步到掘金上。若是但愿尽快阅读相关内容,也能够直接去该仓库中浏览。


参考资料

  1. Proposal Dynamic Import
  2. 在 react-router4 中进行代码拆分
  3. Module ngx_http_gzip_module
  4. Tree Shaking - webpack
  5. Tree Shaking 性能优化实践 - 原理篇
  6. Tree Shaking for Lodash
  7. CSS and JS code coverage - Chrome DevTools
  8. Chrome Dev Summit 2018
  9. Optimize your libraries with webpack
  10. Long Tasks API 1
  11. A Netflix Web Performance Case Study
  12. 大公司里怎样开发和部署前端代码?
  13. webpack进阶:前端运行时的模块化设计与实现
  14. Separating a Manifest
  15. The cost of JavaScript in 2019
  16. [译] 2019 年的 JavaScript 性能
  17. webpack 4: Code Splitting, chunk graph and the splitChunks optimization
  18. 文本压缩算法的对比和选择
  19. 简单聊聊 GZIP 的压缩原理与平常应用
  20. Text Compression
  21. Better tree shaking with deep scope analysis
  22. How we reduced our initial JS/CSS size by 67%
相关文章
相关标签/搜索