JS的变量和作用域
概述
JavaScript的变量是松散类型的,所谓松散类型就是可以用来保存任何类型的数据。 这样的变量很有意思,很强大,当然也有不少问题,本文会分析js的变量。
- 首先,会从变量数据的分类:原始值与引用值,阐述这两种类型的区别。
- 其次,会从变量的作用范围——作用域进行分析,在这里,需要了解一个比较重要的概念:作用域链。
- 最后,会阐述在ES6引入let和const后的三种变量声明。
1 原始值与引用值
ECMAScript可以包含两种不同类型的数据:原始值和引用值。
注意:原始值和引用值是js数据的分类,而变量是用来存储数据的,并不是数据本身。
概念
- 原始值就是最简单的数据,也就是六种简单数据类型,Undefined,Null,Boolean,Number,String和Symbol。
- 引用值则是由多个值构成的对象,比如Object,function,Array。
核心区别
- 保存原始值的变量是按值(by value)访问的,因为我们操作的就是存储在变量中的实际值。
- 保存引用值的变量是按引用(by reference)访问的,在操作对象时,实际上操作的是对该对象的引用而非实际的对象本身。
1.1 动态属性
原始值和引用值的定义方式类似,都是创建一个变量,然后给它赋一个值。不过在变量保存了这个值后,可以对这个值做什么,则大有不同。
① 对于引用值而言,可以随时添加、修改和删除其属性和方法。
let person = new Object();
person.name = 'Jack';
console.log(person.name); // "Jack"
② 对于原始值而言,不能有属性。
//尝试给原始值添加属性不会报错
let name = 'Jack';
name.age = 27;
console.log(name.age); //undefined
注意,原始类型的初始化可以只使用字面量形式,如果使用的是new关键字,则JavaScript会创建一个Object类型的实例(原始值包装类型),但是其行为类似原始值。
let name1 = 'Jack';
let name2 = new String('Matt');
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
1.2 复制值
① 原始值复制
在通过变量把一个原始值赋值到另一个变量时,原始值会被复制到新变量的位置。
let num1 = 5;
let num2 = num1; //num1将值5拷贝到num2,num1中的5和num2中的5,二者完全独立
num2 = 6;
console.log(num1); // 5
② 引用值复制
在把引用值从一个变量赋给另一个变量时,存储在变量中的值也会被复制到新变量的位置,区别在于,这里复制的值实际是一个指针,它指向存储在堆内存中的对象。
//两个变量指向同一对象
//对一个对象的操作会反映到另一个对象上
let obj1 = new Object();
let obj2 = obj1;
obj1.name = 'Jack';
console.log(obj2.name); // "Jack"
1.3 传递参数
ECMAScript中所有函数的参数传递都是按值传递的。
这意味着函数外的值会被复制到函数内部的参数中,就像一个变量复制到另一个变量一样。
① 按值传递和按引用传递
- 按值传递参数时,值会被复制到一个局部变量。
- 按引用传递参数时,值在内存中的位置会被保存在一个局部变量,这意味着对本地变量的修改会反映到函数外部。
② 按值传递的两种情况
(1)传递的参数是原始变量
function addTen(num) {
num += 10;
return num;
}//num的值是复制而来,参数num和变量count互不干扰
let count = 20;
let result = addTen(count);
console.log(count); //20,没有变化
console.log(result); //30
(2)传递的参数是对象变量
function setName(obj) {
obj.name = "Nicholas";
} //传递的值是引用,obj和person指向同一个对象
var person = new Object();
setName(person);
alert(person.name); //"Nicholas"
//结果就是,即使对象是按值传进函数,obj也会通过引用访问对象
③ 注意点
有很多开发人员错误地认为:在局部作用域中修改的对象会在全局作用域中反映出来,就说明参数是按引用传递的。这里要注意按引用传递和按值传递且传递的值是引用是不一样的,为了证明对象是按值传递的,我们再看一看下面这个经过修改的例子:
function setName(obj) {
obj.name = "Nicholas";
obj = new Object();
obj.name = "Greg";
}
var person = new Object();
setName(person);
alert(person.name); //"Nicholas"
这个例子与前一个例子的唯一区别,就是在 setName()函数中添加了两行代码:一行代码为 obj 重新定义了一个对象,另一行代码为该对象定义了一个带有不同值的 name 属性。在把 person 传递给 setName()后,其 name 属性被设置为"Nicholas"。然后,又将一个新对象赋给变量 obj,同时将其 name 属性设置为"Greg"。如果 person 是按引用传递的,那么 person 就会自动被修改为指向其 name 属性值 为"Greg"的新对象。但是,当接下来再访问 person.name 时,显示的值仍然是"Nicholas"。这说明 即使在函数内部修改了参数的值,但原始的引用仍然保持未变。实际上,当在函数内部重写 obj 时,这 个变量引用的就是一个局部对象了。而这个局部对象会在函数执行完毕后立即被销毁。
1.4 确定类型
① typeof
typeof操作符适合用来判断一个变量是否为原始类型。但是对引用值的用处不大,因为typeof只能确定一个值是不是对象,而不能知道它是什么类型的对象。
var s = "Nicholas";
var b = true;
var i = 22;
var u;
var n = null;
var o = new Object();
alert(typeof s); //string
alert(typeof i); //number
alert(typeof b); //boolean
alert(typeof u); //undefined
alert(typeof n); //object
alert(typeof o); //object
② instanceof
如果变量是给定引用类型的实例,则instanceof操作符返回true,否则返回false,适合判断对象的具体类型。
alert(person instanceof Object); // 变量 person 是 Object 吗?
alert(colors instanceof Array); // 变量 colors 是 Array 吗?
alert(pattern instanceof RegExp); // 变量 pattern 是 RegExp 吗?
2 执行上下文与作用域
2.1 执行上下文
- 执行上下文(execution context )是 JavaScript中颇为重要的一个概念。
- 执行上下文决定了变量或函数有权访问的数据,决定了它们各自的行为。
- 每个执行上下文都有一个与之关联的变量对象(variable object),上下文中定义的所有变量和函数都保存在这个对象中。虽然我们编写的代码无法访问这个对象,但解析器在处理数据时会在后台使用它。
2.2 全局上下文和局部上下文
上下文,又称为环境。全局上下文又称为全局环境,局部上下文又称为局部环境,以下均使用上下文这个描述,但是环境这个描述在一些地方也经常使用。
(1)全局上下文
- 全局执行上下文是最外围的一个执行上下文。
- 根据 ECMAScript 实现所在的宿主环境不同,表示执行上下文的对象也不一样。在 Web 浏览器中,全局执行环境被认为是 window 对象,因此所有通过var定义的全局变量和函数都会作为 window 对象的属性和方法,使用let和const的顶级声明不会定义在全局上下文中,但是在作用域解析效果上是一样的。
- 某个执行上下文中的所有代码执行完毕后,该上下文被销毁,保存在其中的所有变量和函数定义也随之销毁(全局执行上下文直到应用程序退出——例如关闭网页或浏览器时才会被销毁)
(2)局部上下文
- 每个函数都有自己的执行上下文。
- 当执行流进入一个函数时,函数的上下文就会被推入一个上下文栈中。
- 而在函数执行之后,栈将其上下文弹出,把控制权返回给之前的执行环境。ECMAScript 程序中的执行流正是由这个方便的机制控制着。
2.3 作用域链
(1)什么是作用域链
当上下文中的代码执行时,会创建变量对象的一个作用域链(scope chain)。作用域链的用途,是保证对执行上下文有权访问的所有变量和函数的有序访问。
(2)作用域链长什么样
作用域链的最前端,始终都是当前执行的代码所在上下文的变量对象。如果这个上下文是函数,则将其活动对象(activation object)作为变量对象。活动对象在最开始时只包含一个变量,即 arguments 对象(这个对象在全局环境中是不存在的)。作用域链中的下一个变量对象来自包含(外部)环境,而再下一个变量对象则来自下一个包含环境。这样,一直延续到全局执行环境;全局执行环境的变量对象始终都是作用域链中的最后一个对象。
上面这段解析还是过于抽象,接下来通过一个例子说明,看完例子再回来看说明会清楚很多。下面是一个例子
//原则:内部上下文可以通过作用域链访问外部上下文中的一切
//但是外部上下文无法访问内部上下文中的任何东西
//此外,局部作用域中定义的变量在局部上下文中可以替换同名的全局变量
var color = "blue";
function changeColor(){
var anotherColor = "red";
function swapColors(){
var 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()内部 则可以访问其他两个上下文中的所有变量,因为那两个上下文是它的父执行上下文。下图形象地展示了前面这个例子的作用域链。
(3)作用域链查找原则
- 当在某个上下文中为了读取或写入而引用一个标识符时,必须通过搜索来确定该标识符实际代表什么。
- 搜索过程从作用域链的前端开始,向上逐级查询与给定名字匹配的标识符。如果在局部上下文中找到了该标识符,搜索过程停止,变量就绪。
- 如果在局部上下文中没有找到该变量名,则继续沿作用域链向上搜索。搜索过程将一直追溯到全局上下文的变量对象。如果在全局环境中也没有找到这个标识符,则意味着该变量尚未声明。
总结一句话,作用域链采取就近原则的方式来查找变量最终的值。
通过下面这个示例,可以理解查询标识符的过程:
var color = "blue";
function getColor(){
return color;
}
alert(getColor()); //"blue"
调用本例中的函数 getColor()时会引用变量 color。为了确定变量 color 的值,将开始一个两步的搜索过程。首先,搜索 getColor()的变量对象,查找其中是否包含一个名为 color 的标识符。 在没有找到的情况下,搜索继续到下一个变量对象(全局环境的变量对象),然后在那里找到了名为 color 的标识符。因为搜索到了定义这个变量的变量对象,搜索过程宣告结束。下图形象地展示了上述搜索过程。
(4)作用域链增强
虽然执行环境的类型总共只有两种——全局和局部(函数),但还是有其他办法来延长作用域链。 这么说是因为有些语句可以在作用域链的前端临时增加一个变量对象,该变量对象会在代码执行后被移除。
在两种情况下会发生这种现象。具体来说,就是当执行流进入下列任何一个语句时,作用域链就会得到加长:
- try-catch 语句的 catch 块;
- with 语句。
这两个语句都会在作用域链的前端添加一个变量对象。对 with 语句来说,会将指定的对象添加到作用域链中。对 catch 语句来说,会创建一个新的变量对象,其中包含的是被抛出的错误对象的声明。 下面看一个例子。
function buildUrl() {
var qs = "?debug=true";
with(location){
var url = href + qs;
}
return url;
}
/*在此,with 语句接收的是 location 对象,因此其变量对象中就包含了 location 对象的所有属
性和方法,而这个变量对象被添加到了作用域链的前端。buildUrl()函数中定义了一个变量 qs。当在
with 语句中引用变量 href 时(实际引用的是 location.href),可以在当前执行上下文的变量对象中
找到。当引用变量 qs 时,引用的则是在 buildUrl()中定义的那个变量,而该变量位于函数上下文的变
量对象中。至于 with 语句内部,则定义了一个名为 url 的变量,因而 url 就成了函数执行上下文的一
部分,所以可以作为函数的值被返回。*/
3 变量声明
3.1 使用var的函数作用域声明
① 在使用var声明变量时,变量会自动添加到最接近的上下文
在函数中,最接近的上下文就是函数的局部上下文。在with语句中,最接近的上下文也是函数上下文。如果变量未经声明就被初始化,那么它就会自动被添加到全局上下文。
function add(num1,num2){
var sum = num1 + num2;
return sum;
}
let result = add(10,20);
console.log(sum); //报错:sum在这里不是有效变量
function add(num1,num2) {
sum = num1 + num2;
return sum;
}
let result = add(10,20);
console.log(sum);//30
② var声明提升
var声明会被拿到函数或全局作用域的顶部,位于作用域中所有代码之前。这种现象叫做“提升”。
console.log(name); //undefined
var name = 'Jake';
//相当于下面
var name;
console.log(name);
name = 'Jake';
3.2 使用let的块级作用域声明
① let的作用域是块级的
if(true) {
let a;
}
console.log(a); //ReferenceError,a没有定义
function foo() {
let c;
}
console.log(c);//ReferenceError,c没有定义
② let在同一作用域不能声明两次
重复的var声明会被忽略,而重复的let声明会抛出SyntaxError。
var a;
var a;
//不会出错
{
let b;
let b;
}
//SyntaxError:标识符b已经声明过了
③ let的行为非常适合在循环中声明迭代
//使用var声明的迭代变量会泄露到循环外部,这种情况应该避免
for(var i = 0; i < 10; ++i){
}
console.log(i);//10
for(let j = 0; j < 10; ++j){
}
console.log(j);//ReferenceError,j没有定义
3.3 使用const的常量声明
① const的作用域也是块级的
② const声明的变量必须同时初始化,在其生命周期的任何时候都不能再重新赋值
const a; //SyntaxError: 常量声明时没有初始化
const b = 3;
console.log(b); //3
b = 4;//TypeError:给常量赋值
③ const声明只应用到顶级原语或者对象
换句话说,赋值为对象的const变量不能再被重新赋值为其他引用值,但是对象的键则不受影响
const o1 = {};
o1 = {};//TypeError:给常量赋值
const o2 = {};
o2.name = "Jake";
console.log(o2.name) //‘Jake’