JS的一些小知识点
一、变量声明提升
概述
ES6之前的时代,声明变量常量用var关键字。但由于JS天生作用域机制的残缺,不存在局部作用域
的概念,极容易发生变量声明提升
为全局变量造成命名空间污染冲突等后果。
比较常见的解决方法是利用函数作用域
来伪造局部作用域以隔绝变量间可能存在的冲突。
1 | console.log('x:', x) |
结果为:
即使是log(x)为undefined也是不符合常理的,变量不能在未声明之前使用
一直是一个共通的编程规范。在某些静态类型的语言中如上的用法会导致解析器的报错。
而JS这里发生了变量声明提升
,会将变量声明提升到文件头部,等同于如下代码1
2
3var x;
console.log('x:', x)
x = 10;
利用let取代var定义变量
1 | console.log('x:', x) |
结果为:
let声明定义的变量,不会发生变量声明提升,不能在被声明前使用,也不能在同一作用域内重复声明。
利用函数作用域伪造局部作用域
1 | console.log('x:', x) |
结果为:
在局部作用域内定义声明的变量,即使是使用var声明定义,由于作用域间的相互隔绝,也不会污染其他作用域的命名空间。
for循环中的变量提升
1 | body> |
上面的代码,按照我们通常的逻辑,每次点击li都会log出对应的i的值0, 1, 2, 3。然而每次log(i)的结果都为4。
为什么会这样呢?
首先,JS是由事件驱动
的语言。上述代码中我们注册了四个点击事件,但是JS不知道该事件什么时候会发生,可能在未来的某个时刻,我们点击li元素触发。于是JS解释器会在解析完代码后将之放入内存。
- for循环中使用var定义的变量i会因为
变量提升
为全局变量
。 - 在JS解释器解析完代码后,for循环亦也执行完毕了,那么
变量i
的值已经是4
了。 - 因此,即使在未来的某个时刻我们点击了li元素,其中的i全部都指向全局变量i,所以值都为4。
这就是一个典型的由于变量提升为全局变量所引发的命名空间污染所导致的逻辑与预期不符。
可以用如下的解决方式
- for循环中var改为let
- 闭包
- 变量寄存
闭包
一个函数作用域内引用另一个作用域内的变量,只要该函数作用域没有消亡,该引用关系一直存在。1
2
3
4
5
6
7for(var i = 0; i < oli.length; i ++) {
(function(j) {
oli[i].onclick = function() {
console.log(j) // 代替轮播逻辑
}
})(i)
};
我这里使用了一个匿名函数(自执行函数)做了一个闭包,构造了一个由 局部变量j
到全局变量i
引用,j会一直保持着对当时i
的引用(也可以说是利用每个函数作用域制造的局部作用域保存每个当时i
的值)
什么叫做当时
,在这里是执行每次的loop时i的值。如,第一次loop,i为0,第二次loop,i为1。
闭包的使用,会使与之引用的变量不能被垃圾回收机制所回收,使用频繁的话可能会有性能损耗。
变量寄存
1 | for(var i = 0; i < oli.length; i ++) { |
给每个li元素节点新增一个属性index寄存当时i
的值,之后访问的也是新增的属性。
总结
为了规避由var关键字带来的声明提升,所带来的全局命名空间的冲突混乱所造成的代码逻辑与预期不符,我们应尽量使用let关键字声明变量。
二、函数声明提升
函数声明同变量声明一样,也会发生函数声明提升
1
2
3foo() // 函数调用
function foo() { } // 函数声明
虽然可以正常调用,但函数调用在函数声明前,一样的不符合流程规范违反直觉。
使用函数表达式来声明定义函数
1 | let foo = function() { }; |
使用函数表达式来声明定义函数的话,如果调用在声明前,会报错。
三、const 常量
概述
const 一般用来定义常量,何谓常量,便是当定义后,所指向的地址
不会被更改的变量。
1 | const a = 10; |
报错:
其实常量也是可以更改的,只要常量所指向的地址
没有改变。
每个变量定义赋值,并不是单纯的只是赋值而已。而是会在内存中分配一个内存空间地址存储着这个值,变量再指向这个地址。
依据上面的原理1
2const arr = []
arr[0] = 10
为什么常量a更改后报错,而常量arr却可以更改?
因此对常量a的重新赋值会改变a所指向的内存地址,自然是违背了常量的规范了。
我们也可以改变arr的地址,给它赋值其他的基本数据类型或另一种引用数据类型(这样的话,arr会指向新的地址)。看一看这样的话会不会报错呢?1
2const arr = []
arr = 2;
结果:
总结
- JS中的number,string,boolean属于基本数据类型,由单个内存空间地址存储。
- 而数组[], object{}属于引用数据类型,会由连续的内存空间地址存储。
- 对基本数据类型重新赋值的话,又会开辟一个新的内存空间地址存储。
- 对引用数据类型如改变数组某个下标的的值,并不会开辟新的内存空间地址存储。
tips: 其实这个概念很类似于C语言中的指针,指针便直接是对内存中的地址进行操作。
四、深浅拷贝
概述
- 基本数据类型number,string,boolean的深拷贝与浅拷贝结果相同。
- 引用数据类型数组[], object{}的深拷贝与浅拷贝有显著区别。
基本数据类型的深浅拷贝
1 | let a = 10; |
变量a的值仍然为10,不会因为变量b的值更改而受到影响。
数组的浅拷贝
1 | // 浅拷贝 |
结果:
第一个log()a1中的值没有改变,当对a2[0]重新赋值后,a1竟然也改变了。
这就是数组的浅拷贝,所谓的a1赋值给a2,只是把这个数组所在的内存空间地址
赋予了a2。于是a1与a2指向同一个地址,对a2进行操作自然会影响到a1。
数组的深拷贝
1 | // 深拷贝-利用扩展运算符 |
结果:
这样看来,深拷贝的目的确实达到了。但是,这种方法只会拷贝数组对象的第一层属性。第二层属性还是公用的地址。1
2
3
4
5
6let a1 = [1, 2, 3, [5, 6]];
let a2 = [...a1];
a2[0] = 10;
a2[3][0] = 10;
console.log(a1);
结果:
数组a1第一层属性中的下标为0的值并没有改变,只有第二层属性(子数组[5,6])中的下标为0的值被改变为10了。
这样的话,扩展运算符对于深层属性的拷贝,并没有另外的开辟新地址存储,依然是地址引用的传值。
利用JSON序列化与反序列化深拷贝
1 | let a1 = [1, 2, 3, [5, 6]] |
结果:
数组a1的第一层与第二层都没有改变,由此证明第二层属性的地址也独立开来了。
其实利用JSON的序列化与反序列化在某些极端情况下也并不能完全深拷贝,还有其他的一些如递归拷贝
等实现深拷贝的方法。但各有缺点。
在大多数只需要拷贝一层属性的情况下,扩展运算符与JSON序列化都足够胜任。感兴趣的可以了解了解递归拷贝。
{}深浅拷贝情况与数组无异。
五、一些小技巧
两个变量值的交换
两个值的交换-利用ES6解构1
[a, b] = [b, a]
数组合并
合并数组,合并对象同理
1 | let a = [1, 2, 3, 4, 5] |
数组去重
利用ES6中的Set结构类型特性,进行数组去重1
2let a = [1, 1, 2, 3, 4, 4]
console.log([...new Set(a)])
取数组中的最大值
最大值-利用扩展运算符结合Math方法1
Math.max(...[3, 1, 2, 9, 1])
最小值同理Math.min()
利用JS的隐式转换类型特性将字符串转为number
1 | let a = -'123.56' // let a = +'123.56' |
利用JS的隐式转换我们可以做很多事情,隐式转换有利有弊,褒贬不一。
- 空string,undefined,NaN,0都会隐式转换为false
- 需要注意的是空数组会转为true,
这是一个坑
。
python中对空数组的隐式转换,会将空数组转为false。
not 逻辑运算符等同于JS中的“!”,也就是与或非中的非。
JS的隐式转换会经历一个颇为复杂的过程,详情可以看这里
总而言之,可以适度使用,但不能滥用。
实际中,使用最多的应该是number与字符串的拼接,分支语句中的条件判断吧。