开始阅读JavaScript高级程序设计(第5版)学习JS,总共有1000+页,非常全面,短期看完不太现实,找到了一篇博客,花些时间跟着这篇博客过一下红宝书。
红宝书《JavaScript高级程序设计(第5版)》学习大纲 - 大前端全栈开发 - SegmentFault 思否
相比于其他语言,JavaScript中的变量可谓独树一帜。正如ECMA-262所规定的,JavaScript变量是松散类型的,而且变量不过就是特定时间点一个特定值的名称而已。由于没有规则定义变量必须包含什么数据类型,变量的值和数据类型在脚本生命期内可以改变。这样的变量很有意思,很强大,当然也有不少问题。
原始值和引用值:
ECMAScript变量可以包含两种不同类型的数据:原始值(primitive value)就是最简单的数据,引用值(reference value)则是由多个值构成的对象。在把一个值赋给变量时,JavaScript引擎必须确定这个值是原始值还是引用值。保存原始值的变量是按值(by value)访问的。
引用值是保存在内存中的对象。与其他语言不同,JavaScript不允许直接访问内存位置,因此也就不能直接操作对象所在的内存空间。在操作对象时,实际上操作的是对该对象的引用(reference)而非实际的对象本身。为此,保存引用值的变量是按引用(by reference)访问的。
动态属性:
对于引用值而言,可以随时添加、修改和删除其属性和方法。
这里,首先创建了一个对象,并把它保存在变量person中。然后,给这个对象添加了一个名为name的属性,并给这个属性赋值了一个字符串 “Alice”。在此之后,就可以访问这个新属性,直到对象被销毁或属性被显式地删除。
1
2
3
let person = new Object();
person.name = "Alice";
console.log(person.name); // "Alice"
原始值不能有属性,尽管尝试给原始值添加属性不会报错。 在此,代码想给字符串name定义一个age属性并给该属性赋值27。紧接着在下一行,属性不见了。只有引用值可以动态添加后面可以使用的属性。
1
2
3
let name = "Alice";
name.age = 27;
console.log(name.age); // undefined
原始类型的初始化可以只使用原始字面量形式。如果使用的是new关键字,则JavaScript会创建一个Object类型的实例,但其行为类似原始值。
1
2
3
4
5
6
7
8
let name1 = "Alice";
let name2 = new String("Bob");
name1.age = 27;
name2.age = 26;
console.log(name1.age); // undefined
console.log(name2.age); // 26
console.log(typeof name1); // string
console.log(typeof name2); // object
复制值:
在通过变量把一个原始值赋值到另一个变量时,原始值会被复制到新变量的位置。num1包含数值5。当把num2初始化为num1时,num2也会得到数值5。这个值跟存储在num1中的5是完全独立的,因为它是那个值的副本。这两个变量可以独立使用,互不干扰。
在把引用值从一个变量赋给另一个变量时,存储在变量中的值也会被复制到新变量所在的位置。区别在于,这里复制的值实际上是一个指针,它指向存储在堆内存中的对象。操作完成后,两个变量实际上指向同一个对象,因此一个对象上面的变化会在另一个对象上反映出来。
1
2
3
4
let obj1 = new Object();
let obj2 = obj1;
obj1.name = "Alice";
console.log(obj2.name); // "Alice"
在这个例子中,变量obj1保存了一个新对象的实例。然后,这个值被复制到obj2,此时两个变量都指向了同一个对象。在给obj1创建属性name并赋值后,通过obj2也可以访问这个属性,因为它们都指向同一个对象。 
传递参数:
ECMAScript中所有函数的参数都是按值传递的。这意味着函数外的值会被复制到函数内部的参数中,就像从一个变量复制到另一个变量一样。如果是原始值,那么就跟原始值变量的复制一样;如果是引用值,那么就跟引用值变量的复制一样。
变量有按值和按引用访问,而传参只有按值传递。
在按值传递参数时,值会被复制到一个局部变量(即一个命名参数,或者用ECMAScript的话说,就是arguments对象中的一个槽位)。
在按引用传递参数时,值在内存中的位置会被保存在一个局部变量中,这意味着对局部变量的修改会反映到函数外部。
函数addTen()有一个参数num,是一个局部变量。在调用时,变量count作为参数传入。count的值是20,这个值被复制到参数num以便在addTen()内部使用。
在函数内部,参数num的值被加上了10,但这不会影响函数外部的原始变量count。参数num和变量count互不干扰,它们只不过碰巧保存了一样的值。如果num是按引用传递的,那么count的值也会被修改为30。
1
2
3
4
5
6
7
8
9
function addTen(num) {
num += 10;
return num;
}
let count = 20;
let result = addTen(count);
console.log(count); // 20,没有变化
console.log(result); // 30
这一次,我们创建了一个对象并把它保存在变量person中。然后,这个对象被传给setName()方法,并被复制到参数obj中。在函数内部,obj和person都指向同一个对象。结果就是,即使对象是按值传进函数的,obj也会通过引用访问对象。当函数内部给obj设置了name属性时,函数外部的对象也会反映这个变化,因为obj指向的对象保存在全局作用域的堆内存上。
很多开发者错误地认为,当在局部作用域中修改对象而变化反映到全局时,就意味着参数是按引用传递的。实际对象是按值传递的,函数的临时变量只是复制了一份引用值。
1
2
3
4
5
6
7
8
function setName(obj) {
obj.name = "Alice";
}
let person = new Object();
setName(person);
console.log(person.name); // "Alice"
这个例子前后唯一的变化就是setName()中多了两行代码,将obj重新定义为一个有着不同name的新对象。当person传入setName()时,其name属性被设置为 “Alice”。然后变量obj被设置为一个新对象且name属性被设置为 “Greg”,此时对于obj来说,它拥有了一个新的引用值,指向新的对象。
如果person是按引用传递的,那么person应该自动将指针改为指向name为 “Greg” 的对象。可是,当我们再次访问person.name时,它的值是 “Alice”,这表明函数中参数的值改变之后,原始的引用仍然没变。
当obj在函数内部被重写时,它变成了一个指向本地对象的指针。而那个本地对象在函数执行结束时就被销毁了。
1
2
3
4
5
6
7
8
9
function setName(obj) {
obj.name = "Alice";
obj = new Object();
obj.name = "Greg";
}
let person = new Object();
setName(person);
console.log(person.name); // "Alice"
ECMAScript中函数的参数就是局部变量。
确定类型:
typeof操作符是判断一个变量是否为字符串、数值、布尔值或undefined的最好方式。如果值是对象或null,那么typeof返回 “object”。
1
2
3
4
5
6
7
8
9
10
11
12
13
let s = "Alice";
let b = true;
let i = 22;
let u;
let n = null;
let o = new Object();
console.log(typeof s); // string
console.log(typeof i); // number
console.log(typeof b); // boolean
console.log(typeof u); // undefined
console.log(typeof n); // object
console.log(typeof o); // object
typeof虽然对原始值很有用,但它对引用值的用处不大(检测引用值固定只会返回对象)。我们通常不关心一个值是不是对象,而是想知道它是什么类型的对象。为了解决这个问题,ECMAScript提供了instanceof操作符。
1
result = variable instanceof constructor
如果变量是给定引用类型(由其原型链决定,将在第8章详细介绍)的实例,则instanceof操作符返回true。instanceof操作符的主要作用是判断一个引用值(对象)是否是某个特定构造函数的实例。
按照定义,所有引用值都是Object的实例,因此通过instanceof操作符检测任何引用值和Object构造函数都会返回true。类似地,如果用instanceof检测原始值,则始终会返回false,因为原始值不是对象。
1
2
3
console.log(person instanceof Object); // 变量person是Object吗?
console.log(colors instanceof Array); // 变量colors是Array吗?
console.log(pattern instanceof RegExp); // 变量pattern是RegExp吗?
执行上下文与作用域:
变量或函数的上下文决定了它们可以访问哪些数据,以及它们的行为。每个上下文都有一个关联的变量对象(variable object),而这个上下文中定义的所有变量和函数都存在于这个对象上。虽然无法通过代码访问变量对象,但后台处理数据会用到它。
全局上下文是最外层的上下文。根据ECMAScript实现的宿主环境,表示全局上下文的对象可能不一样。在浏览器中,全局上下文就是我们常说的window对象(第12章会详细介绍),因此所有通过var定义的全局变量和函数都会成为window对象的属性和方法。使用let和const的顶级声明不会定义在全局上下文中,但在作用域链解析上效果是一样的。上下文在其所有代码都执行完毕后会被销毁,包括定义在它上面的所有变量和函数(全局上下文在应用程序退出前才会被销毁,比如关闭网页或退出浏览器)。
每个函数调用都有自己的上下文。当代码执行流进入函数时,函数的上下文被推到一个上下文栈上。在函数执行完之后,上下文栈会弹出该函数上下文,将控制权返还给之前的执行上下文。ECMAScript程序的执行流就是通过这个上下文栈进行控制的。
上下文中的代码在执行的时候,会创建变量对象的一个作用域链(scope chain)。这个作用域链决定了各级上下文中的代码在访问变量和函数时的顺序。代码正在执行的上下文的变量对象始终位于作用域链的最前端。如果上下文是函数,则其活动对象(activation object)用作变量对象。活动对象最初只有一个定义变量:arguments。(全局上下文中没有这个变量。)作用域链中的下一个变量对象来自包含上下文,再下一个对象来自再下一个包含上下文。以此类推直至全局上下文;全局上下文的变量对象始终是作用域链的最后一个变量对象。
每个上下文都有一个”变量清单”,记录这个上下文里定义了哪些变量和函数。这个变量清单即是变量对象。
函数被调用时,它的变量对象有个特殊的地方——多了一个 arguments。
这时候就换个名字叫活动对象,表示”这个函数正在运行,它的变量清单被激活了”。
arguments 是一个自动创建的对象,里面装着调用函数时传进去的所有实参。
变量对象(Variable Object)
├── 全局上下文用 → 就叫”变量对象”(window)
└── 函数上下文用 → 就叫”活动对象”(多了 arguments)
代码执行时的标识符解析是通过沿作用域链逐级搜索标识符名称完成的。搜索过程始终从作用域链的最前端开始,然后逐级往后,直到找到标识符。
作用域链就是:找变量时,先找自己,没有就找外层,一直找到全局。这条查找路径就叫作用域链。
函数changeColor()的作用域链包含两个对象:一个是它自己的变量对象(就是定义arguments对象的那个),另一个是全局上下文的变量对象。这个函数内部之所以能够访问变量color,就是因为可以在作用域链中找到它。
1
2
3
4
5
6
7
8
9
10
11
var color = "blue";
function changeColor() {
if (color === "blue") {
color = "red";
} else {
color = "blue";
}
}
changeColor();
此外,局部作用域中定义的变量可用于在局部上下文中替换全局变量。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var color = "blue";
function changeColor() {
let anotherColor = "red";
function swapColors() {
let tempColor = anotherColor;
anotherColor = color;
color = tempColor;
// 这里可以访问color、anotherColor和tempColor
}
// 这里可以访问color和anotherColor,但访问不到tempColor
swapColors();
}
// 这里只能访问color
changeColor();
以上代码涉及3个上下文:全局上下文、changeColor()的局部上下文和swapColors()的局部上下文。
全局上下文中有一个变量color和一个函数changeColor()。
changeColor()的局部上下文中有一个变量anotherColor和一个函数swapColors(),但在这里可以访问全局上下文中的变量color。
swapColors()的局部上下文中有一个变量tempColor,只能在这个上下文中访问到。
全局上下文和changeColor()的局部上下文都无法访问到tempColor。而在swapColors()中则可以访问另外两个上下文中的变量,因为它们都是父上下文。 
内部上下文可以通过作用域链访问外部上下文中的一切,但外部上下文无法访问内部上下文中的任何东西。上下文之间的连接是线性的、有序的。每个上下文都可以到上一级上下文中去搜索变量和函数,但任何上下文都不能到下一级上下文中去搜索。
函数参数被认为是当前上下文中的变量,因此也跟上下文中的其他变量遵循相同的访问规则。
作用域链增强:
虽然执行上下文主要有全局上下文和函数上下文两种(eval()调用内部存在第三种上下文),但有其他方式来增强作用域链。某些语句会导致在作用域链前端临时添加一个上下文,这个上下文在代码执行后会被删除。通常在两种情况下会出现这个现象,即代码执行到下面任意一种情况时:
try/catch语句的catch块;
with语句。
这两种情况下,都会在作用域链前端添加一个变量对象。对with语句来说,会向作用域链前端添加指定的对象;对catch语句而言,则会创建一个新的变量对象,这个变量对象会包含要抛出的错误对象的声明。
变量声明:
使用var的函数作用域声明:
在使用var声明变量时,变量会被自动添加到最接近的上下文。在函数中,最接近的上下文就是函数的局部上下文。果变量未经声明就被初始化了,那么它就会自动被添加到全局上下文。
1
2
3
4
5
6
7
function add(num1, num2) {
var sum = num1 + num2;
return sum;
}
let result = add(10, 20); // 30
console.log(sum); // 报错:sum在这里不是有效变量
函数add()定义了一个局部变量sum,保存加法操作的结果。这个值作为函数的值被返回,但变量sum在函数外部是访问不到的。如果省略上面例子中的关键字var,那么sum在add()被调用之后就变成可以访问的了。
1
2
3
4
5
6
7
function add(num1, num2) {
sum = num1 + num2;
return sum;
}
let result = add(10, 20); // 30
console.log(sum); // 30
变量sum被用加法操作的结果初始化时并没有使用var声明。在调用add()之后,sum被添加到了全局上下文,在函数退出之后依然存在,从而在后面可以访问到。
未经声明而初始化变量是JavaScript编程中一个非常常见的错误,会导致很多问题。为此,在初始化变量之前一定要先声明变量。在严格模式下,未经声明就初始化变量会报错。
var声明会被拿到函数或全局作用域的顶部,位于作用域中所有代码之前。这个现象叫作“提升”(hoisting)。提升让同一作用域中的代码不必考虑变量是否已经声明就可以直接使用。可是在实践中,提升也会导致合法却奇怪的现象,即在变量声明之前使用变量。
通过在声明之前打印变量,可以验证变量会被提升。声明的提升意味着会输出undefined而不是Reference Error:
1
2
3
4
5
6
7
console.log(name); // undefined
var name = 'Jake';
function() {
console.log(name); // undefined
var name = 'Jake';
}
使用let的块级作用域声明:
块级作用域由最近的一对包含花括号 {} 界定。换句话说,if块、while块、function块,甚至连单独的块也是let声明变量的作用域。
严格来讲,let在JavaScript运行时中也会被提升,但由于“暂时性死区”(temporal dead zone)的缘故,实际上不能在声明之前使用let变量。因此,从写JavaScript代码的角度说,let的提升跟var是不一样的。
使用const的常量声明:
const声明只应用到顶级原语或者对象。换句话说,赋值为对象的const变量不能再被重新赋值为其他引用值,但对象的键则不受限制。
如果想让整个对象都不能修改,可以使用Object.freeze(),这样再给属性赋值时虽然不会报错,但会静默失败:
1
2
3
4
5
6
7
8
9
10
const o1 = {};
o1 = {}; // TypeError: 给常量赋值
const o2 = {};
o2.name = 'Jake';
console.log(o2.name); // 'Jake'
const o3 = Object.freeze({});
o3.name = 'Jake';
console.log(o3.name); // undefined
由于const声明暗示变量的值是单一类型且不可修改,JavaScript运行时编译器可以将其所有实例都替换成实际的值,而不会通过查找表进行变量查找。谷歌的V8引擎就执行这种优化。
开发实践表明,如果开发流程并不会受到很大影响,就应该尽可能地多使用const声明,除非确实需要一个将来会重新赋值的变量。这样可以从根本上保证提前发现重新赋值导致的bug。
标识符查找:
当在特定上下文中为读取或写入而引用一个标识符时,必须通过搜索确定这个标识符表示什么。搜索开始于作用域链前端,以给定的名称搜索对应的标识符。
如果在局部上下文中找到该标识符,则搜索停止,变量确定;如果没有找到变量名,则继续沿作用域链搜索。(注意,作用域链中的对象也有原型链,因此搜索可能涉及每个对象的原型链。)这个过程一直持续到搜索至全局上下文的变量对象。如果仍然没有找到标识符,则说明其未声明。
1
2
3
4
5
6
7
var color = 'blue';
function getColor() {
return color;
}
console.log(getColor()); // 'blue'
调用函数getColor()时会引用变量color。为确定color的值会进行两步搜索。第一步,搜索getColor()的变量对象,查找名为color的标识符。结果没找到,于是继续搜索下一个变量对象(来自全局上下文),然后就找到了名为color的标识符。因为全局变量对象上有color的定义,所以搜索结束。
引用局部变量会让搜索自动停止,而不继续搜索下一级变量对象。也就是说,如果局部上下文中有一个同名的标识符,那就不能在该上下文中引用父上下文中的同名标识符。
使用块级作用域声明并不会改变搜索流程,但可以给词法层级添加额外的层次。
1
2
3
4
5
6
7
8
9
10
11
var color = 'blue';
function getColor() {
let color = 'red';
{
let color = 'green';
return color;
}
}
console.log(getColor()); // 'green'
在执行到函数返回语句时,代码引用了变量color。于是开始在局部上下文中搜索这个标识符,结果找到了值为 ‘green’ 的变量color。因为变量已找到,搜索随即停止,所以就使用这个局部变量,函数会返回 ‘green’。
在局部变量color声明之后的任何代码都无法访问全局变量color,除非使用完全限定的写法window.color。
标识符查找并非没有代价。访问局部变量比访问全局变量要快,因为不用切换作用域。不过,JavaScript引擎在优化标识符查找上做了很多工作,将来这个差异可能就微不足道了。
