zl程序教程

您现在的位置是:首页 >  Javascript

当前栏目

【最佳实践之性能篇】编码惯例与作用域意识

2023-04-18 14:23:25 时间

本文适用于任何编程语言,但从JavaScript角度来讲解。

编码习惯

1. 尊重对象所有权

尊重对象所有权就意味着不要修改不属于你的对象。简单来说就是,如果你不负责创建和维护某个对象及其构造函数或方法,就不应该对其进行任何修改。具体来说就是遵循以下惯例:

  • 不要给实例或原型添加属性
  • 不要给实例或原型添加方法
  • 不要重定义已有的方法

问题在于,假如有一个stopEvent()的方法用于取消某个事件的默认行为,你将其修改了,除了取消事件的默认行为还添加了其他行为,别人对于你添加的副作用并不知情,也使用了这个方法,就会导致别人出现错误或损失。

2. 不要声明全局变量、命名空间

最多可以创建一个全局变量作为其他函数或对象的命名空间。比如:

// 全局变量name
var name = "CODER-V";
// 全局变量sayName
function sayName(){
	console.log(name);
};

上面代码声明了两个全局变量,我们可以像下面这样将其包含在一个对象中:

var MyApplicatioin = {
	name: "CODER-V",
	sayName: function(){
		console.log(name);
	}
};

重写后的版本值声明了一个全局对象MyApplicatioin 。该对象包含了name和sayName()。这样就避免了变量name会覆盖window.name属性,而且还可能会影响其他功能。其次,有助于分清功能都集中在哪里。

这样一个全局对象可以扩展为命名空间的概念。命名空间涉及创建一个对象,然后通过这个对象来暴露能力。关于命名空间,最重要的是确定一个大家都同意的全局对象名称。这个名称要足够独特,不能与其他人的冲突。大多数情况下会选择使用公司名。下面的示例演示了coder最为命名空间来组织功能:

// 创建全局对象
var Coder = {};

// 为CODER-V创建命名空间
Coder.Coder-v = {};
// 为CODER-V添加用到的对象
Coder.Coder-v.EventUtil = {};
Coder.Coder-v.CookieUtil = {};
...

以上代码以Coder作为全局命名空间,然后它的下面又创建了命名空间,这样将相应的变量放到相应的命名空间下,就可以避免命名冲突的问题,因为它们在不同的命名空间下。虽然命名孔家需要多写一点代码,但是从可维护性角度来看,这个代价还是非常值得的。命名空间还可以保证代码不与页面上的其他代码互不干扰。

3. 不要比较null

JavaScript不会自动做任何类型检查,因此就需要开发者来承担这个责任。最常见的类型检查就是看值是不是null。然而,与null进行比较的代码太多了,其中很多因为类型检查不够而频繁引发错误。来看下面的例子:

function sortArray(values){
	if(values != null){// 不要这样比较
		values.sort(comparator);
	}
}

这个函数的目的是使用给定的比较函数对数组进行排序。为保证函数正常执行,values必须是数组。但是,if语句在这里只是简单的检查了这个值是不是null。实际上,字符串、数值还是有很多其他类型都可以通过这里的检查,结果就会导致错误。

注意:类型检查要检查的是它的类型,而不是检查它不能是什么!。比如前面的values应该检查它到底是不是数值,而不是检查它是不是null,应该这样做:

function sortArray(values){
	if(values instanceof Array){// 检查类型
		values.sort(comparator);
	}
}

如果看到null值比较的代码,可以使用以下技术替换它:

  • 如果值应该是引用类型,则使用 instanceof 操作符检查其构造函数。
  • 如果值应该是原始类型,则使用 typeof 检查其类型。
  • 如果希望值是有特定方法名的对象,则使用 typeof 操作符确保对象上存在给定名称的方法。

注:

  • typeof():返回参数类型
  • instanceof:返回boolean,检查一个对象是否某个类的实例,会查找原型链

4. 使用常量

依赖常量的目标是从应用程序逻辑中分离数据,以便修改数据时不会引发错误。将一些可能会变的数值,字符串,url等提取出来放在单独定义的常量中,以实现逻辑和数据分离,方便后期维护,同时也避免了魔法数字魔法值(对于魔法值不了解的可以看一下我的另一篇文章:代码优化通用准则)。

作用域意识

《执行上下文与作用域》一文中,我们了解了作用域的工作原理。随着作用域链中作用域数量的增加,访问当前作用域外部变量所需的时间也会增加。访问全局变量始终比访问局部变量要慢,因为必须遍历作用域链。任何可以缩短遍历作用域链的时间的措施都会提升代码的性能。

1. 避免全局查找

全局变量或函数相比于局部值,始终是最费时间的,因为许需要遍历作用域链来查找,看以下代码:

function updateUrl(){
	let imgs = document.getElementsByTagName("img");
	for(let i=0,len=img.length; i<len; i++){
		imgs[i].title = '${document.title} image ${i}';
	}
	
let msg = document.getElementById("msg");
msg.innerHTML = "Update complete."	
}

这个函数看起来没什么问题,但是其中有三个地方引用了全局对象 document 。如果网页的图片非常多,那么每次 for 循环都需要遍历作用域链是十分耗时的。

解决方案就是:通过在局部作用域中保存 document 对象的引用,可以将全局查找的数量限制为1个来提升这个函数的性能。

function updateUrl(){
	let doc = document;// 用一个引用来保存这个全局对象,将全局查找次数限制为1次
	let imgs = doc.getElementsByTagName("img");
	for(let i=0,len=img.length; i<len; i++){
		imgs[i].title = '${doc.title} image ${i}';
	}
	
let msg = doc.getElementById("msg");
msg.innerHTML = "Update complete."	
}

2. 不要使用with语句

可能很多人都不知道with语句,不知道那就更不会使用了。这里来就简单介绍一下。

with语句会创建自己的作用域,因此也会增长作用域链(在作用域链前端增加)。在with语句中执行的代码一定比其他外部作用域执行的更慢,因为它多了异步作用域查找。

选择正确的方法

1. 避免使用对象属性查找

在计算机科学中,算法复杂度使用大 O 表示法来表示。最简单最快的算法可以表示为 常量值 或 O(1)。时间长一点的由以下方式表示

表示法名称说明
O(1)常量无论多少值,执行时间都不变。表示简单值和保存在变量中的值。
O(logn)对数执行时间随着值的增加而增加,但算法完成不需要读取每个值。比如:二分查找
O(n)线性执行时间与值的数量直接相关。比如:迭代数组中的所有元素。
O(n2)二次方执行时间随着值的增加而增加,而且每个值至少需要读取n次,比如:插入排序

查找效率从高到底排列:
常量 、O(1) > 变量、数组 > 对象属性

另外,如果某个需求既可以是使用数组的数字索引,又可以使用命名属性,那么推荐使用 数值索引

对象属性查找慢,是因为查找属性名要查找原项链。解决方案就是将对象的属性保存在变量中,这样查找的时间复杂度就是O(1)。比如:

let url = window.location.href;
let query = url.xxx;

只要对象属性的访问超过了1次,就应该这样做来提升性能。

2. 优化循环

优化循环是性能优化的重要内容,因为循环会多次运行相同的代码,所以运行期间会自动增加。优化循环的基本步骤如下:

  1. 简化终止条件。因为每次循环都会计算终止条件,所以应该让他尽可能的快。这意味着要避免属性查找或其他O(n)操作。
  2. 简化循环体。循环体是最花时间的。因此要尽可能优化。要确保其中不会包含轻松转移到循环外部的密集计算。
  3. 使用后测试循环do-while。最常见的循环就是for循环和while循环,这两种循环都属于先测试循环。do-while 就是后测试循环,避免了对终止条件的初始评估,因此会更快,本人实测有效。

来看一下这个示例:

for(let i=0; i<values.length; i++){
	console.log(i);
}

使用 do-while 优化:

let i = 0;
do{
	console.log(valuse[i]);
}while( --i >= 0 );// 注意这里是 --i,而没有使用i++<value.length自己想想为什么,不懂的评论区评论

可以自行测试一下,博主自测使用后测试循环执行时间比for循环快了一半。

3. 展开循环

如果循环的次数是有限的,那么通常抛弃循环,直接多次调用函数会更快,以前面的数组为例,如果数组的长度始终一样,则可能对每一个元素都调用一次console.log(values[i]);效率更高。

console.log(valuse[0]);
console.log(valuse[1]);
console.log(valuse[2]);
console.log(valuse[3]);
console.log(valuse[4]);

假设这个数组始终只有5个元素,像这样展开循环可以节省创建循环、计算终止条件的消耗,从而让代码运行更快。

如果不能提前预知循环的次数,也可以使用一种叫做**达夫设备(Duff’s Device)**的技术,达夫设备的基本思路是:以8的倍数作为迭代次数从而将循环展开为一系列语句。下面介绍一种基于达夫设备的优化,其效率约比原始达夫设备高40%。

let iterations = Math.floor(values.length / 8);//迭代次数,主循环中只能有8个元素
let leftover = values.length % 8;//leftover以为剩下的,也就是除主循环中剩下的元素
let i = 0;

if(leftover > 0){//先处理剩下的
	do{ //前面提到了,使用后测试循环会更快
		console.log(values[i]);
	}while( --leftover > 0);
}

do{//再执行主循环
	console.log(values[i++]);
	console.log(values[i++]);
	console.log(values[i++]);
	console.log(values[i++]);
	console.log(values[i++]);
	console.log(values[i++]);
	console.log(values[i++]);
	console.log(values[i++]);//主循环只能有8个语句
}while( --iterations > 0 );

这个达夫设备实现,首先通过用values数组的长度除以8计算需。要多少次循环,floor()保证取得的数据是整数,leftover(剩余的、额外的)中保存着不会在主循环中处理,因而需要在第一个循环中处理的次数。处理完这些额外的次数之后进入主循环,每次循环调用八次console.log()。

展开循环对于大型数据集可以节省很多时间,但对于小型数据而言,则可能不值得。因为实现同样的任务需要写很多代码,所以,如果处理的数据量不大,那么显然没有必要。

4. 尽量使用原生方法

原生方法都是使用c或c++等编译型语言写的,因此比JavaScript写的代码运行要快得多。

5. 尽量使用switch语句

如果代码中有复杂得if-else语句,将其转换成switch语句可以变得更快。然后,通过重组分支,将最可能得放前面,不太可能的放后面,进一步提升代码性能。

6. 尽量使用 位操作运算符

在执行数学运算操作时,位操作一定比任何布尔值或数字计算更快。像求模、逻辑与AND、逻辑或OR都很适合使用位操作代替。甚至某些计算可以考虑使用位移操作符代替。

语句最少化

1. 减少多个声明

2. 插入迭代性值

3. 使用数组和对象字面量

优化DOM交互

1. 实时更新最小化

2. 使用innerHTML

3. 使用事件委托

4. 注意HTMLCollection