JavaScript 代码的下载与加载对网页性能有着很大影响,良好的 JS 代码能够让网页运行更快,这里总结一些优化 JavaScript 代码的方法供自己参考和复习。
压缩代码与减少渲染阻塞
压缩代码
现在很多构建工具都自带压缩代码的组件,比如uglifyjs-webpack-plugin就是 webpack 生产模式中默认集成的用来压缩 js 代码的插件;vite 使用 ESBuild minify 的功能进行代码压缩。除了对代码自身的冗余部分(中间变量和未使用的变量等)进行修剪外,这些构建工具还能对依赖结构进行分析,分割出最小依赖的模块,从而避免了完整导入体积过大的外部模块,实现了代码有效压缩。JavaScript 代码还能使用一些压缩技术比如 gzip 进行压缩,很多服务器软件比如 nginx 就提供了 gzip 压缩的配置项,但是无论压缩还是解压的过程都会消耗性能,因此要充分考虑资源压缩的效价再选择是否压缩。
减少渲染阻塞
了解为什么 JavaScript 会引起渲染阻塞,我们就需要知道浏览器的渲染过程到底是怎么样的。当我们输入 url 后,浏览器主进程开始接管,开启一个下载线程,浏览器会将 http 请求返回的内容转交给 Renderer 进程进行渲染,问题出在这里:JS 代码如果需要下载,那么浏览器会等待下载完成执行后再解析接下来的 DOM 结构,这就构成了阻塞;另外,JS 代码执行与绘制是互斥的,执行占据了主线程,页面就无法继续绘制,也会造成阻塞。那么,如何减少 JS 造成的渲染阻塞?
使用 defer 和 async 属性
<script async="async"></script>
<script defer="defer"></script>
使用 defer 和 async 属性都可以帮助减少 JS 代码对渲染的阻塞,但是两者在特性上又有所不同,async 脚本的特点如下:
async脚本的下载不会阻塞页面的解析渲染。async脚本的执行会阻塞页面的解析渲染- 多个
async脚本的下载是并行, 但执行不按照页面中的脚本先后顺序。哪个async脚本先下载完, 哪个async脚本就先立刻执行; async脚本的下载和执行不计入DOMContentLoaded事件统计;async脚本的执行有可能在DOMContentLoaded事件前, 也有可能在DOMContentLoaded事件后;- 当
async脚本的执行在DOMContentLoaded事件前时,async脚本的执行时间才会影响DOMContentLoaded事件的触发时间。又因为脚本的执行时间一般都比较短, 所以可以认为async脚本基本不影响DOMContentLoaded事件的触发时间。
defer 脚本的特点如下:
defer脚本的下载和执行都不会阻塞页面的解析渲染。因为等到页面的解析渲染完毕后,defer脚本才执行;- 多个
defer脚本的下载是并行, 但按照顺序依次执行; - 等页面的解析渲染完毕后, 触发
DOMContentLoaded事件前,defer脚本才依次执行。 所以defer脚本的下载和执行如果慢, 会延迟DOMContentLoaded事件的触发时间; - 考虑有的浏览器不支持 defer 场景, 多个
defer脚本不一定会按照顺序执行, 最佳实践是只使用一个defer脚本。
同时使用两个属性,忽略 defer
所以,我们需要注意对修改 DOM 的脚本或者有渲染作用的代码谨慎使用这两个属性,因为渲染完毕后执行可能导致重新渲染,另外,对有执行顺序要求的脚本使用 defer,互不依赖的脚本则使用 async,防止脚本之间互相阻塞。
使用 webworker 技术
webworker 技术允许 JavaScript 使用多线程环境,一些计算密集型或者高延迟任务被 worker 新开线程承担后,就不会阻塞主线程的任务,从而让网页更流畅。
Web Workers 使得一个Web应用程序可以在与主执行线程分离的后台线程中运行一个脚本操作。这样做的好处是可以在一个单独的线程中执行费时的处理任务,从而允许主(通常是UI)线程运行而不被阻塞;
worker 一旦新建,就会一直运行,不会被主线程的活动打断,或者说,如果 worker 无实例引用,该 worker 空闲后立即会被关闭;如果 worker 实列引用不为 0,该 worker 空闲也不会被关闭,这样有利于随时响应主线程的通性,但是也会造成资源的浪费,所以不应过度使用,用完注意关闭。
减少重绘和重排
浏览器下载完组件后会解析生成 DOM 树和渲染树。DOM 树为页面结构,渲染树表示 DOM 节点如何显示。DOM 元素几何属性发生变化或者 DOM 树结构变化就需要重排,渲染树重新计算;重绘是元素外观改变触发的浏览器行为,如 visibility、outline、背景色等属性变化。相比重绘,重排涉及到页面元素的重新计算,因此非常消耗浏览器性能,因此我们重点关注哪些行为会触发重排:
- 页面渲染初始化(不可避免)
- 浏览器窗口改变尺寸
- 元素尺寸、位置、内容改变
- 添加或删除可见 DOM 元素
如果我们的 JavaScript 代码不可避免要修改页面元素,怎么才能减少性能开销呢?下面是一些参考办法:
- 将多次改变结构、样式、属性的操作合并成一次操作,减少 DOM 访问;
- 如果要批量添加 DOM,可以先让元素脱离文档流,操作完后再带入文档流,这样只会触发一次重排(fragment元素的应用);
- 将需要多次重排的元素,position 属性设为 absolute 或 fixed,这样此元素就脱离了文档流,它的变化不会影响到其他元素;
- 由于 display 属性为 none 的元素不在渲染树中,对隐藏的元素操作不会引发其他元素的重排。如果要对一个元素进行复杂的操作时,可以先隐藏它,操作完成后再显示。这样只在隐藏和显示时触发两次重排。1
防抖与节流
现在很多引擎的搜索框都会展示服务器传回的提示关键词,但是用户会不停键入内容,这样就会向服务器发送大量请求,而需要提示的时机只不过是用户键入完后的那会,这个时候就需要防抖函数来减小请求次数,防抖函数保证了在事件触发后一段时间再执行回调,如果中途事件再次触发就重新计时。下面是一段最简单的防抖函数:
function debounce(fn, delay){//回调函数和延迟时间
let timer = null;//初始化timer,因为在闭包中被引用,因此延迟回调执行完毕前不会被重复初始化
return function(){
const context = this;//在计时器中上下文强制为window
const args = arguments;//接收函数传入参数,以便于内层调用
if (timer) clearTimeout(timer);//在回调中不会立即从内存删除定时器,这就需要主动判断和删除
timer = setTimeout(()=>{
fn.apply(context, args);
}, delay);
}
}
我们想象这样一个场景:用户因为网页响应缓慢疯狂点击链接,结果弹出了一大堆页面把自己整崩溃了,同时服务器还收到了大量本来没必要响应的请求。这就体现了节流的价值,节流函数可以保证在一定时间内最多只执行一次回调,即使事件被触发多次。下面展示一个最简单的节流函数:
function throttle(fn, delay){//回调,延迟时间
let timer = null;//函数未执行完毕不会被初始化,也就不会执行这个
return function(){
const context = this;
const args = arguements;
if(!timmer){//证明上一个回调已经执行完毕了
timmer = setTimeout(()=>{
fn.apply(context, args);
timmer = null;//假如执行完毕就需要主动置空以便设置下一个定时器
}, delay);
}
}
}
当然,现在很多库比如 lodash 集成了防抖节流函数,也不用我们大费周章去写。