随着 Web 的发展,JavaScript 从以前只承担简单的脚本功能,到现在被用于构建大型、复杂的前端应用,经历了很大的发展。这也让它在当下的前端应用中扮演了一个非常重要的角色,因此在这一节首先来看看的我们熟悉的 JavaScript。
1. 减少不必要的请求
在进行 JavaScript 优化时,我们还是秉承总体思路,首先就是减少不必要的请求。
1.1. 代码拆分(code split)与按需加载
相信熟练使用 webpack 的同学对这一特性都不陌生。
虽然整体应用的代码非常多,但是很多时候,我们在访问一个页面时,并不需要把其他页面的组件也全部加载过来,完全可以等到访问其他页面时,再按需去动态加载。核心思路如下所示:
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 脚本。
1.2. 代码合并
我们在总体思路里有提到,减少请求的一个方法就是合并资源。试想一个极端情况:我们现在不对 node_modules 中的代码进行打包合并,那么当我们请求一个脚本之前将可能会并发请求数十甚至上百个依赖的脚本库。同域名下的并发请求数过高会导致请求排队,同时还可能受到 TCP/IP 慢启动的影响。
总之,千万不要让你的碎文件散落一地。
2. 减少包体大小
2.1. 代码压缩
另一个代码压缩的常用手段是使用一些文本压缩算法,gzip 就是常用的一种方式。
响应头上图中响应头的 Content-Encoding
表示其使用了 gzip。
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,将它打包进去没有意义,该方法是永远不会被使用到的。
2.3. 优化 polyfill 的使用
前端技术的一大特点就是需要考虑兼容性。为了让大家能顺畅地使用浏览器的新特性,一些程序员们开发了新特性对应的 polyfill,用于在非兼容浏览器上也能使用新特性的 API。后续升级不用改动业务代码,只需要删除相应的 polyfill 即可。
这种舒适的开发体验也让 polyfill 成为了很多项目中不可或缺的一份子。然而 polyfill 也是有代价的,它增加了代码的体积。毕竟 polyfill 也是 JavaScript 写的,不是内置在浏览器中,引入的越多,代码体积也越大。所以,只加载真正所需的 polyfill 将会帮助你减小代码体积。
<script type="module" src="main.mjs"></script>
<script nomodule src="legacy.js"></script>
这样,在能够处理 module
属性的浏览器(具有很多新特性)上就只需加载 main.mjs
(不包含 polyfill),而在老式浏览器下,则会加载 legacy.js
(包含 polyfill)。
2.4. webpack
webpack-bundle-analyzer3. 解析与执行
除了 JavaScript 下载需要耗时外,脚本的解析与执行也是会消耗时间的。
3.1. JavaScript 的解析耗时
很多情况下,我们会忽略 JavaScript 文件的解析。一个 JavaScript 文件,即使内部没有所谓的“立即执行函数”,JavaScript 引擎也是需要对其进行解析和编译的。
js 处理同时,我们从前一节已经知道,JavaScript 的解析、编译和执行会阻塞页面解析,延迟用户交互。所以有时候,加载同样字节数的 JavaScript 对性能的影响可能会高于图片,因为图片的处理可以放在其他线程中并行执行。
3.2. 避免 Long Task
对于一些单页应用,在加载完核心的 JavaScript 资源后,可能会需要执行大量的逻辑。如果处理不好,可能会出现 JavaScript 线程长时间执行而阻塞主线程的情况。
long task例如在上图中,帧率下降明显的地方出现了 Long Task,伴随着的是有一段超过 700 ms 的脚本执行时间。而性能指标 FCP 与 DCL 处于其后,一定程度上可以认为,这个 Long Task 阻塞了主线程并拖慢了页面的加载时间,严重影响了前端性能与体验。
3.3. 是否真的需要框架
相信如果现在问大家,我们是否需要 React、Vue、Angular 或其他前端框架(库),大概率是肯定的。
但是我们可以换个角度来思考这个问题。类库/框架帮我们解决的问题之一是快速开发与后续维护代码,很多时候,类库/框架的开发者是需要在可维护性、易用性和性能上做取舍的。对于一个复杂的整站应用,使用框架给你的既定编程范式将会在各个层面提升你工作的质量。但是,对于某些页面,我们是否可以反其道行之呢?
例如产品经理反馈,咱们的落地页加载太慢了,用户容易流失。这时候你会开始优化性能,用上这次「性能之旅」里的各种措施。但你有没有考虑过,对于像落地页这样的、类似静态页的页面,是不是可以“返璞归真”?
当然,还是强调一下,并不是说不要使用框架/类库,只是希望大家不要拘泥于某个思维定式。做工具的主人,而不是工具的“奴隶”。
3.4. 针对代码的优化
<font style="color:#d65">请注意,截止目前(2019.08)以下内容不建议在生产环境中使用。</font>
还有一种优化思路是把代码变为最优状态。它其实算是一种编译优化。在一些编译型的静态语言上(例如 C++),通过编译器进行一些优化非常常见。
(function () {
function hello() {return 'hello';}
function world() {return 'world';}
global.s = hello() + ' ' + world();
})();
可以优化为:
s = 'hello world';
不过很多时候,代码体积和运行性能是会有矛盾的。同时 Prepack 也还不够成熟,所以不建议在生产环境中使用。
4. 缓存
4.1. 发布与部署
这里简单提一下:大多数情况下,我们对于 JavaScript 与 CSS 这样的静态资源,都会启动 HTTP 缓存。当然,可能使用强缓存,也可能使用协商缓存。当我们在强缓存机制上发布了更新的时候,如何让浏览器弃用缓存,请求新的资源呢?
一般会有一套配合的方式:首先在文件名中包含文件内容的 Hash,内容修改后,文件名就会变化;同时,设置不对页面进行强缓存,这样对于内容更新的静态资源,由于 uri 变了,肯定不会再走缓存,而没有变动的资源则仍然可以使用缓存。
4.2. 将基础库代码打包合并
为了更好利用缓存,我们一般会把不容易变化的部分单独抽取出来。例如一个 React 技术栈的项目,可能会将 React、Redux、React-Router 这类基础库单独打包出一个文件。
这样做的优点在于,由于基础库被单独打包在一起了,即使业务代码经常变动,也不会导致整个缓存失效。基础框架/库、项目中的 common、util 仍然可以利用缓存,不会每次发布新版都会让用户花费不必要的带宽重新下载基础库。
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
4.3.2. 将 runtime chunk 单独拆分出来
// webpack.config.js
module.exports = {
//...
optimization: {
runtimeChunk: {
name: 'runtime'
}
},
}
4.3.3. 使用 records
// webpack.config.js
module.exports = {
//...
recordsPath: path.join(__dirname, 'records.json')
};
「性能优化」系列内容
-
5.1. 如何针对 JavaScript 进行性能优化?(本文)
5.2. 🔜 如何针对 CSS 进行性能优化?
5.3. 图片虽好,但也会带来性能问题
5.4. 字体也需要性能优化么?
5.5. 如何针对视频进行性能优化?
-
如何避免运行时的性能问题?
-
如何通过预加载来提升性能?
-
尾声