用户通过浏览器访问页面的过程,除了输入URL地址到所访问页面完成首屏渲染,更多的时候页面在相应与用户的交互。
高性能网站的要求不仅是执行顺畅无BUG,还希望对用户的页面操作能够更快速响应,而且在执行完任务的同时占用更少的资源。
无论哪种计算机语言,说到底它们的作用都是对数据的存取与处理,JavaScript也不例外。若能在处理数据之前,更快速的读取到数据,那么必然会对程序执行性能产生积极的作用。本节建工数据的存取及作用域链的角度,探讨一些在JavaScript中能提升性能的方式。
5.1.1 数据存取方式
一般来说JavaScript有四种数据存取方式:
数组元素和对象属性不仅可以是直接字面量的形式,还可以是由其他数组对象或对象属性组成的更为复杂的数据结构。从读取速度看,直接字面量与变量是非常快的,相比之下数组元素和对象属性由于需要索引,其读取速度也会因其组成结构的复杂度越高而越慢。
5.1.2 作用域和作用域链
ES6发布前JavaScript没有明确的块级作用域概念。它只有全局作用域和每个函数内的局部作用域。全局作用域就是无论此时执行的上下文是在函数内部还是函数外部的,都能访问到存在于全局作用域中的变量或对象;而定义存储在函数的局部作用域中的对象,只有在该函数内部执行上下文时才能够访问,而对函数外是不可见的。
对于能够访问到的数据,其在不同作用域中的查询也有先后顺序。这就涉及作用域链的概念。JavaScript引擎会在页面加载后创建一个全局的作用域,然后每碰到一个要执行的函数时,又会为其创建对应的作用域,最终不同的块级作用域和嵌套在内部的函数作用域,会形成一个作用域堆栈。
当前生效的作用于在堆栈的最顶端,由上往下就是当前执行上下文所能访问的作用域链。
例子:
function plus(num){
return num +1;
}
const ret=plus(6);
函数plus的作用域链中仅有一个指向全局对象的作用域,其中包括this、函数对象plus及常量ret,而在执行到plus时,Js引擎会创建一个新的执行上下文和包含一些局部变量的活动对象。执行过程会先对num标识符进行解析,即从作用域链的最顶层依次向下查找,直至找到num标识符。
变量位于作用域链中的位置越深,被引擎访问到所需的时间就越长,所以我们应当留心对作用域链的管理。
5.1.3 实战经验
1. 对局部变量的使用
如果一个非局部变量在函数中使用次数不止一次,则最好对这个局部变量进行存储。
例子:
function process(){
const target = document.getElementById('target');
const imgs = document.getElementById('img');
for(let i = 0;i
函数process()中,首先通过document的两个不同的成员函数分别获取了特定的元素和元素列表,然后进行一些省略相关处理流程的操作。值得注意的是,document属于全局作用域的对象,位于作用域链最深处,在标识符解析过程中会被最后解析到。由于它在函数中使用了不止一次,所以可以考虑将其声明为一个局部变量,以提升其在作用域链中的查找顺序。
还值得注意的一点是,imgs.length执行了不止一遍。每次通过属性名或索引读取imgs的属性时,DOM都会重复执行一次对页面元素的查找,这个过程本身就会很缓慢。
改进后的代码:
function process(){
const doc = document;
const target = doc.getElementById('target');
const imgs = doc.getElementById('img');
const len = imgs.length;
for(let i = 0;i
2. 作用域链的增长
前面讲到可以通过将频繁使用的位于较深作用域链层级中的数据,声明为局部变量来提升标识符解析与访问的速度。若能将全局变量提升到局部变量的访问高度,是否还能提升到比局部变量更高的位置呢?答案是肯定的,在当前局部变量作用域前增加新的活动变量作用域,但这种增长了作用域链的做法会造成过犹不及的效果。
比如with语句,它能将函数外层的变量提升到比当前函数局部变量还要搞的作用域链访问级别上,如下代码由于使用with的缘故,在语句中可直接访问param中的属性值,虽然方便但降低了show()函数原本局部变量的访问速度,所以应尽量少用。
const param = {
name:'Tian',
value:100
}
function show(){
const content = 2;
with(param){
console.log('name is ${name}');
}
}
另一个例子就是try-catch语句,catch代码块被用来处理捕获到的异常,但其中包含错误信息的error的作用域高于当前局部变量所在代码块,所以建议不要在catch语句中处理过多复杂的业务逻辑,会降低数据的访问速度。
3. 警惕闭包的使用
function mkFunc(){
const name = 'A';
return function showName(){
console.log(name);
}
}
const myFunc = mkFunc();
myFunc();
showName()函数就是一个闭包,它在mkFunc()函数执行时被创建,并能访问mkFunc()函数的局部变量name,为此便需要创建一个独立与mkFunc()函数的作用域链。
一般的函数执行完成后,其中局部变量所占用的空间会被释放,但闭包的这种特性会延长父函数中局部变量的生命周期。也就意味着使用闭包可能会带来更大的内存开销及内存可能泄漏的影响。
流程控制在业务代码中的比重是很大的,因此优化流程控制方面的代码,必然能有效地提升代码运行的速度。
5.2.1 条件判断
1. if-else和switch
通常地if-else如下:
if(value === 0){
......
} else if(value === 1){
......
} else if(value > 3 && value < 9){
......
}
在if-else地判断条件中,变量的取值可以是相应的离散值,也可以是不同的区间范围。
当取值全部为离散值时,可将if-else写成switch:
switch(value){
case 0: ......; break;
case 1:.......; break;
case 2:......; break;
default:......;
}
对于多个离散值的取值条件判断,switch可以清晰地表明判断条件和返回值之间的对应关系,同时也使它具有更好的代码可读性。
如果只有一两个条件的判断,通常if-else处理条件的时间会比switch更短,当判断条件多到两个以上时,switch效率更高。
2. if-else的优化
如果程序最终的执行路径时最后一个else if子语句,那么当执行到此处之前,其余所有条件判断必然都要经历一遍。耗时也会比之前所有判断条件执行耗时都久。
基于此便有了两种优化方式,第一种时开发者可以预先估计条件被匹配到的频率,按照频率的降序顺序来排列if-else语句,可以让匹配频率高的条件更快执行,从而在整体上降低程序花费在条件判断上的时间,比如
if (value === 8){
// 匹配到8的概率最高
}else if (value === 7){
// 匹配到7的概率仅次于8
}else if (value === 6){
// 匹配到6的概率最低
}else {
其他判断
}
第二种方式时利用二分法的思路,可能开发人员在编写相应的业务代码时,并不能预先估计出各种条件在多次执行时被匹配到的概率,但却能对取值区间的边界有明确的划分,那么便可以用二分取值范围来降低匹配条件的执行次数,比如
if (value < 4){
if (value < 2){
// 值在小于2时的情况
}else {
// 值在2或3之间
}
}else {
if (value < 6){
// 值在4或5之间
}else {
//值在6到上界之间的取值
}
}
仅演示简单的数字区间,实际开发中需自行结合实际情况考虑。
3. 数组索引和对象属性
除了if-else和switch,利用数组的索引查询或对象的属性拆线呢也可以达到类似条件判断的目的,比如
//条件映射数组
const valueArray = [v0,v1,v2,v3,v4];
//提取对应数组索引的处理
valueArray[value];
由于数组中的每个元素既可以是对象,又可以是函数,那么便可将条件匹配的处理过程封装到数组的元素中,并用数组索引映射对应的value变量,通过匹配数组索引执行相应的处理过程。同样基于对象属性的映射方式,也能实现类似的条件查找行为,比如
//基于对象的属性映射
const valueMap = {
'condition0': ()=>{......},
'condition1': ()=>{......},
'condition2': ()=>{......}
}
//提取对应对象属性的处理
valueMap[value];
基于对象属性的索引可以不局限于整数取值,它能匹配符合对象属性名命名规范的任何字符串形式,,与switch类似,其取值范围是离散值。当匹配条件的数量较小时,并不适合使用这种基于数组或对象的查找方式,因为查找数组或映射对象属性值往往比执行少量的条件判断语句要慢,只有当取值范围变得非常大时,这种查找方式的性能优势才会凸显出来。
4. 策略模式
策略模式就是定义一系列的处理流程或算法,把它们分别封装起来,使得它们可以相互替代。其目的就是将算法的使用和实现分离。
一个基于策略模式的程序通常会包含两个部分——一部分是一组策略类,其中包含一系列具体的处理算法,有点类似于包含不同处理过程的映射对象 valueMap;另一部分是环境类,它将根据上下问操作运算,决定调用策略类中的那个策略处理过程,即完成流程控制中条件匹配的部分。
假设一个具体场景:年底公司要根据员工的绩效等级发奖金,绩效考核打S的发四倍工资,打A的发三倍月工资,打B的发两倍月工资……为简化说明,仅以三种绩效考核等级为例。通常的解决方案,直接的想法是定义一个函数,接受两个参数,分别是员工的月薪和绩效考核等级,然后在其中通过if-else判断来分别计算出奖金额度:
//计算奖金的方法
function calculateBonus(salary, level){
if(level === "S"){
return salary*4;
}else if(level === "A"){
return salary*3;
}else if(level === "B"){
return salary*2;
}
}
上述代码可以看出这样的实现本身没有什么问题,将所有奖金计算规则都包含在一个函数中解决。如果需要改写奖金计算规则,会发现这个函数不符合“对扩展开放,对修改封闭”的设计原则,扩展新功能的工作量不亚于推倒重做做。若重构为策略模式的写法:
//策略类
const strategies = {
"S":(salary)=> salary*4;
"A":(salary)=> salary*3;
"B":(salary)=> salary*2;
}
//计算具体奖金
function calculateBonus(salary,level){
return strategies[level](salary);
}
如此解耦了各种级别奖金计算的逻辑,如果要针对奖金发放算法进行调整,则只需修改策略类中对应的方法即可。
5. 条件判断的使用建议
· 当所要匹配的条件仅为一两个离散值时,或者容易划分不同取值范围时,使用if-else。
· 当所要匹配的条件超过一两个但少于十个离散值时,使用switch。
· 当所要匹配的条件超过十个离散值时,使用基于数组索引或对象属性的查找方式。
5.2.2 循环语句
与条件判断相比,循环语句对程序执行性能的影响更大。一个循环语句的循环执行次数直接影响程序的时间复杂度,如果代码中还存在去欸按导致循环不能及时停止,从而造成死循环,那么给用户带来的使用体验将会是非常糟糕的。
1. 三种常规循环语句
JavaScript中循环语句常见写法有三种,第一种是标准的for循环,这与大部分编程语言类似,包括初始化、循环结束条件、迭代语句及循环体四部分,第二种和第三种分别是while循环和do-while循环,二者唯一的差别就是do-while循环会先执行一遍循环体。
通常使用这三种循环语句时,基本都是对数组元素进行遍历。从索引的第一个元素开始直到数组的最后一个元素结束,每次在执行循环判断时,都需要将当前的数组索引与数组长度进行比较。由于该比较操作执行的过程中数组长度一般不会改变,且存取局部变量要比查找属性值时更省时,所以提前将要遍历的数组长度声明为局部变量,然后将该局部变量进行循环结束的条件判断,效率会更高。
//较差的循环结束判断
const array = [1,2,3,4,5];
for(let i =0;i
这在对包含较大规模DOM节点树的遍历过程中,效果会更加明显。此外还有一种更简单地提升循环语句性能的方式:将循环变量递减到0,而非递增到数组总长度。
for(let i=array.length-1;i>0;k--){
......
}
只有初始化时涉及到了属性值的读取,其比较的运算速度会更快。
2. for-in循环与函数迭代
//遍历object对象的所有属性
for(let prop in object){
//确保不会遍历到object原型链上的其他对象
if(object.hasOwnProperty(prop)){
......
}
}
可以看出for-in循环能够遍历对象的属性集,特别适合处理诸如JSON对象这样的未知属性集,但对通常的循环使用场景来说,由于它遍历属性的顺序不确定,循环的结束条件也无法改变,并且因为需要从目标对象中解析出每个可枚举的属性,即要检查对象的原型和整个原型链,所以其循环速度也会比其他循环方式慢许多,若有性能要求尽量避免使用for-in循环。
对于数组的循环,JavaScript原生提供了一中forEach函数迭代的方法,此方法会遍历数组上的所有元素,并对每个元素执行一个方法,所运行的方法作为forEach的入参。
Array.forEach((value,index,arr)=>{
…
})
这种方式会让数组元素的迭代看起来更加直观,但在通常情况下与三种基本的循环方法相比,其性能方面仅能达到后者的1/8,如果数组长度较大或对运行速度有比较严格的要求,则函数迭代的方式不建议使用。
另外,还有ES6新加入的for-of循环,可以用它来代替for-in和forEach循环,它不仅性能比这二者更好,还支持对任何可迭代的数据结构进行遍历,但与三种常规循环语句相比其性能还是稍逊色一些。
5.2.3 递归
简单来说,递归就是函数执行体内部调用自身的行为,这种方式有时可以让复杂的算法实现变得简单,如斐波那契数列或阶乘。
使用递归也有一些潜在的问题需要注意:比如缺少或不明确递归的终止条件会很容易造成用户界面卡顿,同时由于递归是一种通过空间换时间的算法,其执行过程中会入栈保存大量的中间运算结果,对内存的开销将与递归次数成正比,由于浏览器都会限制JavaScript的调用栈大小,超出限制递归执行便会失败。
1. 使用迭代
任何递归函数都可以改写成迭代的循环形式,虽然循环会引入自身的一些性能问题,但相比于长时间执行的递归函数,其性能开销还是要小很多的。
//递归实现归并排序
function merge(left, right) {
const result = [];
while (left.length > 0 && right.length > 0) {
//把最小的先取出来放到结果中
if (left[0] < right[0]) {
result.push(left.shift());
} else {
result.push(right.shift());
}
}
//合并
return result.concat(left).concat(right);
}
//递归函数
function mergeSort(array) {
if (array.length === 1) return array;
//计算数组中点
const middle = Math.floor(array.length / 2);
//分割数组
const left = array.slice(0, middle);
const right = array.slice(middle);
//进行递归合并与排序
return merge(mergeSort(left), mergeSort(right));
}
可以看出这段归并排序中,mergeSort()函数会被频繁调用,对于包含n个元素的数组来说,mergeSort()函数会被调用2n-1次,随着所处理数组元素的增多,这对浏览器的调用栈是一个严峻的考验。
//用迭代的方式改写递归函数
function mergeSort(array) {
if (array.length === 1) return array;
const len = array.length;
const work = [];
for (let i = 0; i < len; i++) {
work.push([array[i]]);
}
//确保总数组长度为偶数
if (len & 1) work.push([]);
//迭代两两归并
for (let lim = len; lim > 1; lim = (lim + 1) / 2) {
for (let j = 0, k = 0; k < lim; j += 1, k += 2) {
work[j] = merge(work[k], work[k + 1]);
//数组长度为奇数时,补一个空数组
if (lim & 1) work[j] = [];
}
}
return work[0];
}
此处通过迭代实现的mergeSort()函数,其功能上与递归方式相同,虽然在执行时间上来看要慢一些,但它不会受到浏览器对JavaScript调用栈的限制。
2. 避免重复工作
如果在递归过程中,前一次的计算结果能被后一次计算使用,那么缓存前一次的计算结果就能有效避免许多重复工作。比如阶乘操作:
//计算某个数的阶乘
function factorial(n){
if(n === 0){
return 1;
}else{
return n*factorial(n-1);
}
}
当我们计算多个数的阶乘(如2、3、4)时,如果分别计算这三个数的阶乘,则函数factorial()总共要被调用12次,其中国在计算4的阶乘时,会把3的阶乘重新计算一遍,计算3的阶乘时又会把2的阶乘重新计算一遍,可以看出如果在计算4的阶乘之前,将3的阶乘数缓存下来,那么在计算4的阶乘时,递归仅需要再执行一次。如此便通过缓存阶乘计算结果,避免多于计算过程,原本12次的递归调用,可以减少到5次。
如:
function memoize(func,cache){
const cache = cache || {};
return function(args){
if(!cache.hasOwnProperty(args)){
cache[args] = func(args);
}
return cache[args];
}
}
该方法利用函数闭包有效避免了类似计算多次阶乘时的重复操作,确保只有当一个计算在之前从未发生过时,才产生新的计算值,这样前面的阶乘函数便可改写为:
const memorizeFactorial = memorize(factorial,{'0':1,'1':1});
这种方式也存在性能问题,比如函数闭包延长了局部变量的存活期,如果数据量过大又不能有效回收,则容易导致内存溢出。这种方案只有在程序中有相同参数多次调用时才会比较省时,所以综合而言,优化方案还需根据具体使用场景具体考虑。
为了搞笑处理字符串,使用正则表达式是必不可少的,但两个匹配同一文本的正则表达式并不意味着它们具有相同的执行速度。
5.3.1 字符串拼接
字符串拼接是前端开发中的常规操作,但在大规模数据的循环迭代中进行字符串拼接时,可能稍有不慎就会造成严重的性能问题。
几种常见的拼接方式:
//使用"+"运算符
const str1 = "a" + "b";
//使用"+="运算符
const str2 = "a";
str2 += "b";
//使用数组的join()方法
const str3 =["a","b"].join();
//使用字符串的concat()方法
const str4 = "a";
str4.concat("b");
当处理少量单次或少量字符串拼接时,这些方法的运行速度都很快,根据自己偏好使用即可,但随着需要迭代合并的字符串数量增加,他们之间性能的差异逐渐显现,如下是一个字符串迭代拼接的处理过程:
let len = 1000;
let str = "";
while(len--){
str += "a" + "b";
}
但看循环内部的字符串拼接操作,其代码运行过程可分为四步:首先在内部创建一个临时的字符串变量,然后将拼接的字符串"ab"赋值给它,接着把该临时变量与str的当前值进行拼接,最后将结果赋值给str。可见由于存在临时变量的存取,其性能并不满足预期,若避免临时变量存取直接向str变量上拼接,在大部分浏览器中,都能将这一操作步骤的执行速度提升20%左右。
//不生产中间临时变量的字符串拼接
str = str + “a” + “b”;
数组对象的 join()方法和字符串对象的concat()方法比使用赋值表达式实现字符串拼接在性能上稍慢。
5.3.2 正则表达式
1. 正则表达式处理步骤
(1) 编译表达式:一个正则表达式对象可以通过RegExp构造函数创建,也可以定义成正则直接量,档期被创建出来后,浏览器会先去验证然后将它转化为一个原生待程序,用于执行匹配任务。
(2) 设置起始位置:当匹配开始时,需要先确定目标字符串的其实搜索位置,初始查询时为整个字符串的起始字符的位置,在回溯查询时为正则表达式对象的lastIndex属性指定的索引位置。
(3) 匹配过程:在确定了起始位置后,便会从左到右逐个测试正则表达式的各组成部分,看能否找到匹配的字元。如果遇到表达式中的量词和分支,则需要进行决策,对于量词(*、+或{3}),需要判断从何时开始进行更多字符的匹配尝试;对于分支("|"或操作),每次需从选项中选择一个分支进行接下来的匹配。在正则表达式做这种决策的时候,会进行记录以备回溯时使用。
(4) 匹配结束:若当前完成一个字元的匹配,则正则表达式会继续向右扫描,如果接下来的部分也都能匹配上,则宣布匹配成功并结束匹配;若当前字元匹配不到,或者后续字元匹配失败,则会回溯到上一个决策点选择余下可选字元继续匹配过程,直到匹配到目标子字符串,宣布匹配成功,或者尝试了所有排列组合后也没找到。宣布匹配失败,结束本轮匹配。回到起始字符串的下一个字符,重复匹配过程。
2. 分支与重复
看看正则表达式在匹配过程中是如何处理分支重复的:
/<(img|p)>.*<\img>/.test("![](photo.jpg)
")
开始匹配时,首先从左向右查找<字符,恰好目标字符串的第一个字符时<,然后进行img|p分支子表达式的处理,分支选择也是从左到右的,先检查img是否能匹配成功,发现字符<随后的字符p并不能匹配,此分支无法继续。需要回溯到最近一次的分支决策点,即首字母<的后面,尝试第二个分支p字符的匹配,发现匹配成功。
接下来的.号标识匹配除换行符外的任意字符,并且带有量词符号*,这是一个贪婪量词,需要重复0次或多次的匹配.号,由于目标字符串中不存在换行符,所以它会过滤掉接下来的所有字符,至字符串尾部往回继续匹配接下来的字元<\/img>
,最后一个字符为>,不匹配所需的<,尝试倒数第二个字符p,p也不匹配,如此循环知道匹配到目标字元或找不到目标字元匹配失败。
如果我们仅想查找距离字元<(img|p)>
最近的<\/img>
,显然这种贪婪量词的搜索过程会扩大正则表达式的匹配空间,为此可以使用惰性量词.*?进行替换。
可以看出这里的目标字符串,对于贪婪量词与惰性量词的正则匹配结果是相同的,但它们的匹配过程却是不同的,所以当目标字符串变得非常大时,获得相同匹配结果的正则表达式,其执行性能可能会存在较大差异。
3. 回溯失控
当某个正则表达式的执行使浏览器卡顿数秒或更长时间,可能出现了回溯失控。如下:
/.*?.*?.*?<\/title>.*?<\/head>.*? .*?<\/body>.*?<\/html>/
该正则表达式在匹配常规html文件时不会存在运行问题,但如果碰到一些必要的html标签缺失,那么其匹配效率或变得非常糟糕。比如当html文件中缺少结束标签时,在匹配最后一个惰性量词后并未能找到符合字元
<\/html>
的字符串,此时正则表达式并不会结束,而是向前回溯到上一次的惰性量词出,继续字元<\/body>
的匹配,以试图找到第二个标签。如果没有找到,则会一次继续向前回调,可想而知这样的查询性能会很低。
如何解决?首先可以想到具体化模糊字元,比如将.?具体化为[^\r\n],进而去除集中回溯时可能发生的情况。虽然这种方式控制了可能的回溯失控,但在匹配不完整html文件时所需时间依然喝文件大小成线性关系,所以性能并没有得到有效提高。
更为有效的方式时预查找,它作为全局匹配的一部分,能够仅检查自己所包含的正则表达式字元于当前位置是否能够匹配,并且不消耗字符,即在一个匹配发生后立即开始下一次匹配搜索,而非从包含预查找的字符后开始。预查找的形式是:(?=pattern),pattern代表一个正则表达式,改写上面的例子:
/(?=(.*?))\1(?=(.*?))\2(?=(.*?<\/title>))\3(?=(.*?<\/head>))\4(?=(.*? ))\5(?=(.*?<\/body>))\6.*?<\/html>/
这样当html问而建结尾部分缺少标签时,最后的惰性量词.*?会展开至整个字符串末尾,由于没有有效回溯点,所以正则表达式会立即宣布失败。
4. 量词嵌套
另一个可能会引起回溯失控的写法就是量词嵌套,即在一个整体被量词修饰的组中有量词的使用,如:对一个包含大量T字符的字符串使用如下正则表达式进行匹配:
/(T+T+)+Q/
这种排列组合会产生数量巨大的查找分支。为防止这种情况的发生,关键要确保正则表达式不对字符串相同的部分进行匹配,并且尽量保持正则表达式简洁易懂,可以改写为:/TT+Q/
,但对于较复杂的正则表达式可能难以避免,必要时可采取预查找进行规避。
5.3.3 优化正则表达式
一些有效提升正则表达式匹配效率的方法:
分支方式 | 字符集 |
---|---|
bat\mat | [bm]at |
red\read | rea?d |
(.|\r|\n) | [\s\S] |
注意采用非捕获组:由于捕获组需要记录反向引用,会更加消耗内存和引用。比如可以使用(?:pattern)代替(pattern)。
使用反向引用避免后处理:如果使用场景需要引用匹配的一部分,则应尽量用捕获组捕获目标片段,然后通过反向引用进一步处理,而不是剥离出目标字符串后再手动处理。
JavaScript代码的执行通常会阻塞页面的渲染,考虑到用户体验,这就会限制我们在编写代码时需要注意减少或避免一些执行时间过长的逻辑运算。
5.4.1 浏览器的限制
由于JavaScript是单线程,这就意味着浏览器的每个窗口或页签在同一时间内,要么执行JavaScript脚本,要么响应用户操作刷新页面,也就是说这二者的行为是相互阻塞的。
对于浏览器的这种限制,我们可能就需要对长时间运行的脚本进行重构,尽量保证一段脚本的执行不超过100ms,若果超过这个时间阈值,用户明显就会网站卡顿变慢的使用体验。
引起JavaScript执行时间过长的原因概括有三点:
第一类是对DOM的频繁操作,相比于JavaScript的运算,DOM操作的开销都是极高的,这也是现代前端框架中普遍采取虚拟DOM的原因。
第二类是不恰当的循环,可能因为循环次数执行过多,或者每次循环中执行了过多操作,若能将功能尽可能分解就会明显缓解这个问题。
第三类是存在过深的递归,前面章节有提到过浏览器对JavaScript调用栈存在限制,将递归改写成迭代能有效地避免此类问题。
5.4.2 异步队列
JavaScript既要处理运算又要响应与用户的交互,就是通过异步队列完成的。
当创建一个异步任务时,它其实并没有马上执行,而是被JavaScript引擎放置到了一个队列中,当执行完成一个任务脚本后,JavaScript引擎便会挂起让浏览器去做其他工作,比如更新页面,当页面更新完后JavaScript引擎便会查看此异步队列,并从中取出一个任务脚本去执行,只要该队列不为空,这个过程就会不断重复。
故此便有了对执行过长任务的一种优化策略,即将一个长任务拆分为多个异步任务,从而让浏览器给刷新页面留出时间,但过短的延迟时间也可能会让浏览器响应不及时,通常可以用定时器来控制一个100ms左右的延迟。
除了前面的几种方法,还有些小技巧也能帮助浏览器高效执行JavaScript,比如避免多重求值、使用位操作及使用原生方法。
5.5.1 避免多重求值
多重求值是脚本语言中普遍存在的一种语法特性,即动态执行包含可执行代码的字符串,虽然当前的主流前端项目很少会有类似的方法,但如果面临优化历史代码的场景,就需要对此多加留意。能够运行代码字符串的方法通常有如下四种:setTimeout()、setInterval()、eval()及Function()构造函数。
const a = 1, b = 2;
let result = 0;
//使用setTimeout() 函数执行代码字符串
setTimeout("result = a + b", 100);
//使用setInterval() 函数执行代码字符串
setInterval("result = a + b", 100);
//使用eval() 函数执行代码字符串
result = eval("a + b");
//使用Function() 函数执行代码字符串
result = new Function("a", "b", "return a + b");
这四段代码的执行过程,首先会以正常的方式进行求值,接着在执行过程中对字符串里的代码进行一次额外的求值运算,这个运算操作的代价,与直接执行字符串中代码的代价相比时巨大的。在开发中应当避免使用Function()与eval()函数,同时切记在使用setTimeout()和setInterval()函数时第一个参数使用字符串。
5.5.2 使用位操作
几乎在所有编程语言中,微操作的执行速度都是非常快的,因为位操作通常发生在系统底层。在JavaScript中使用有符号的32位二进制来表示一个数字,位操作就是直接按照二进制方式进行计算的,这要比其他数学运算和布尔操作快得多。
5.5.3 使用原生方法
使用位操作来优化数学运算的场景也是比较有限的,面对复杂的数学运算时可以多使用JavaScript的原生方法。
常用的原生方法:
属性\方法 | 含义 |
---|---|
Math.abs(num) | 计算num的绝对值 |
Math.pow(num,power) | 计算num的power次幂 |
Math.sqrt(num) | 计算num的平方根 |
Math.exp(num) | 计算自然对数底的指数 |
Math.cos(x) | 计算余弦函数 |
Math.PI | 计算圆周率 |
Math.SQRT2 | 计算2的平方根 |
从代码书写角度介绍了许多与前端性能相关的内容,包括数据存取、流程控制、字符串处理、不阻塞页面渲染流程的快速响应。以及能让JavaScript执行更快的一些技巧。
同时详细介绍了如何优化正则表达式,从执行过程到回溯失控以及相关的一些优化注意事项都有介绍。
前端性能优化系列:
前端性能优化:1.什么是前端性能优化
前端性能优化:2.前端页面的生命周期
前端性能优化:3.图像资源优化
前端性能优化:4.资源加载优化
前端性能优化:5.高性能的JavaScript代码
前端性能优化:6.项目构建优化
前端性能优化:7.页面渲染优化