变量、作用域与内存
相比于其他语言,JavaScript 中的变量可谓独树一帜。正如 ECMA-262 所规定的,JavaScript 变量是 松散类型的,而且变量不过就是特定时间点一个特定值的名称而已。由于没有规则定义变量必须包含什么数据类型,变量的值和数据类型在脚本生命期内可以改变。这样的变量很有意思,很强大,当然也有不少问题。本章会剖析错综复杂的变量。
原始值与引用值
ECMAScript 变量可以包含两种不同类型的数据:原始值和引用值。原始值(primitive value) 就是最简单的数据,引用值(reference value) 则是由多个值构成的对象。
在把一个值赋给变量时,JavaScript 引擎必须确定这个值是原始值还是引用值。 在《语言基础》章节了解到7种原始值:Undefined
、Null
、Boolean
、Number
、BigInt
、String
和 Symbol
。保存原始值的变量是 按值(by value) 访问的,因为我们操作的就是存储在变量中的实际值。
引用值是保存在内存中的对象。与其他语言不同,JavaScript 不允许直接访问内存位置,因此也就不能直接操作对象所在的内存空间。在操作对象时,实际上操作的是对该对象的引用(reference)而非实际的对象本身。为此,保存引用值的变量是 按引用(by reference) 访问的。
在很多语言中,字符串是使用对象表示的,因此被认为是引用类型。ECMAScript 打破了这个惯例。
动态属性
在JavaScript中,原始值和引用值的行为有很大的不同,尽管它们的定义方式看起来很类似。
原始值是不可变的,即它们的值不能被改变。 一旦一个原始值被创建,它的值就不能被改变。原始值不能有属性和方法。即使尝试给原始值添加属性,也不会报错,但这个属性实际上不会存在。
let name = 'Nicholas';
name.age = 27; // 试图给字符串添加属性
console.log(name.age); // undefined
引用值是可变的,可以动态添加、修改和删除属性和方法。当你将一个引用值赋值给一个变量时,实际上传递的是对该对象的引用,而不是对象本身。
let person = new Object();
person.name = 'Nicholas';
console.log(person.name); // "Nicholas"
注意,原始类型的初始化可以只使用原始字面量形式。如果使用的是 new 关键字,则 JavaScript 会创建一个 Object 类型的实例,但其行为类似原始值。下面来看看这两种初始化方式的差异:
let name1 = 'Nicholas';
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
复制值
原始值和引用值在通过变量复制时也有所不同。
原始值在赋值和传递时是值的复制。
想象你有一张纸(原始值),你想要一个副本。所以你用复印机复印了一张。现在,你有两张纸,其中每张纸上的内容都是一样的,但它们是独立的。如果你在其中一张纸上写了新的内容,另一张纸上的内容不会改变。
引用值包括对象、数组和函数。它们在赋值(复制)时实际上复制的是指向内存中实际对象的引用,而不是对象本身的副本。
想象你有一座房子(引用值),这座房子代表在内存中的一个对象。当你把这座房子过户给你的亲人时,他们获得了房子的所有权,也就是说他们可以进入房子、对房子进行装修或改动。即使这个房子焕然一新了,但房子还是最开始的房子。
传递参数
ECMAScript 中所有函数的参数都是按值传递的。这意味着函数外的值会被复制到函数内部的参数中, 就像从一个变量复制到另一个变量一样。如果是原始值,那么就跟原始值变量的复制一样,如果是引用值,那么就跟引用值变量的复制一样。
对很多开发者来说,这一块可能会不好理解,毕竟变量原始值按值访问和引用值按引用访问,而传参则只有按值传递。
在按值传递参数时,值会被复制到一个局部变量(即一个命名参数,或者用 ECMAScript 的话说,就是 arguments 对象中的一个槽位)。
按值传递的含义:
当你传递一个原始值作为参数时,实际上是将这个值的副本传递给了函数。这意味着函数内部操作的是这个值的副本,而不会影响到原始变量。
function addTen(num) {
num += 10;
return num;
}
let count = 20;
let result = addTen(count);
console.log(count); // 20,没有变化
console.log(result); // 30
当你传递一个引用值(如对象、数组、函数)作为参数时,实际上是将这个引用值在内存中的地址(指针)传递给了函数。因此,函数内部操作的是这个引用值所指向的对象的实际内容,而不是引用本身。
function setName(obj) {
obj.name = 'Nicholas';
}
let person = new Object();
setName(person);
console.log(person.name); // "Nicholas"
确定类型
在《语言基础》章节提到的 typeof
操作符最适合用来判断一个变量是否为原始类型。更确切地说,它是判断一个变量是否为字符串、数值、布尔值或 undefined 的最好方式。如果值是对象或 null,那么 typeof 返回"object"。
let s = 'Nicholas';
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
操作符。
result = variable instanceof constructor;
如果变量是给定引用类型(由其原型链决定)的实例,则 instanceof 操作符返回 true。
console.log(person instanceof Object); // 变量 person 是 Object 吗?
console.log(colors instanceof Array); // 变量 colors 是 Array 吗?
console.log(pattern instanceof RegExp); // 变量 pattern 是 RegExp 吗?
按照定义,所有引用值都是 Object 的实例,因此通过 instanceof
操作符检测任何引用值和 Object 构造函数都会返回 true。类似地,如果用 instanceof
检测原始值,则始终会返回 false,因为原始值不是对象。
typeof
操作符对于函数,在所有符合 ECMAScript 标准的浏览器中都应该返回 "function"。对于正则表达式,由于标准中未明确规定其 typeof
返回值,因此不同浏览器可能会有不同的行为。早期版本的 Safari 和 Chrome 可能会返回 "function",而其他浏览器可能会返回 "object"。
执行上下文与作用域
执行上下文(Execution Context)和作用域(Scope)是理解 JavaScript 中变量、函数调用和作用域链的核心概念。让我们逐步深入讲解它们的含义和关系。
执行上下文
执行上下文(后面简称上下文)是 JavaScript 中代码执行的环境的抽象概念,每当 JavaScript 代码运行时,都会创建上下文。上下文可以理解为包含了当前代码执行所需的所有信息,包括变量的值、函数的引用、调用栈信息等。
类型
JavaScript 中有三种上下文类型:
- 全局上下文(Global Execution Context):
- 在代码执行之前,全局上下文会被创建。它是默认的、最外层的上下文,整个 JavaScript 程序运行期间只有一个全局上下文。
- 全局上下文中定义的变量和函数可以被程序的任何部分访问,它们属于全局作用域。
- 函数上下文(Function Execution Context):
- 每当一个函数被调用时,都会创建一个新的函数上下文。每个函数都有自己的上下文。
- 函数上下文中包含了函数的局部变量、函数的参数以及函数在调用栈中的位置。
- Eval 上下文:
- 使用
eval()
函数执行的代码会在自己的上下文中运行。