JavaScript变量是松散类型的,由于没有规则定义变量必须包含什么数据类型,变量的值和数据类型在脚本生命期内可以改变。

# 原始值与引用值

ECMAScript变量可以包含两种不同类型的数据:原始值和引用值。原始值(primitive value)就是最简单的数据,引用值(reference value)则是由多个值构成的对象。

JS中原始值有6种:Undefined、Null、Boolean、Number、String和Symbol。保存原始值的变量是按值(by value)访问的。

引用值是保存在内存中的对象,由于JS不允许直接访问内存位置,在操作对象时实际操作的是该对象的引用。

# 动态属性

对于引用值而言,可以随时添加、修改和删除其属性和方法

let person = new Object();
person.name = "Nicholas";
console.log(person.name); // "Nicholas"

原始值不动态增删属性

let name = "Nicholas";
name.age = 27;
console.log(name.age);  // undefined

原始类型除了可以使用原始字面量形式进行初始化还可以使用new关键,如果使用的是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中所有函数的参数都是按值传递的。

# 确定类型

typeof操作符最适合用来判断一个变量是否为原始类型,如果值是对象或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

引出有一个小问题,在JS中怎么判断一个值是不是数组

  1. instanceof
[] instanceof Array
  1. Object.prototype.toString.call
Object.prototype.toString.call([]) === '[object Array]'
  1. constructor
[].constructor == Array

由于constructor的属性是可以被改写的,所以使用这种方式判断并不一定安全。

const arr = []
arr.constructor  = Object
arr.constructor == Array // false
arr.constructor == Object // true
  1. Array.isArray
Array.isArray([])

使用Array.isArray判断一个值是否是数组更可靠,但是Array.isArray是ES5的方法老版本的浏览器可能存在兼容问题,这里我们可以简单封装一个方法

const isArray = (arr) => {
  if (!Array.isArray) return Object.prototype.toString.call(arr) === '[object Array]'
  return Array.isArray(arr)
}

# 执行上下文与作用域

变量或函数的上下文决定了它们可以访问哪些数据,以及它们的行为。每个上下文都有一个关联的变量对象(variable object),而这个上下文中定义的所有变量和函数都存在于这个对象上。

在浏览器中,全局上下文就是我们常说的window对象,因此所有通过var定义的全局变量和函数都会成为window对象的属性和方法。使用let和const的顶级声明不会定义在全局上下文中,但在作用域链解析上效果是一样的。上下文在其所有代码都执行完毕后会被销毁,包括定义在它上面的所有变量和函数(全局上下文在应用程序退出前才会被销毁,比如关闭网页或退出浏览器)。

# 作用域链增强

某些语句会导致在作用域链前端临时添加一个上下文,这个上下文在代码执行后会被删除。

  • try/catch语句的catch块
  • with语句 这两种情况下,都会在作用域链前端添加一个变量对象。对with语句来说,会向作用域链前端添加指定的对象;对catch语句而言,则会创建一个新的变量对象,这个变量对象会包含要抛出的错误对象的声明。

# 变量声明

  1. 使用var的函数作用域声明

在使用var声明变量时,变量会被自动添加到最接近的上下文。在函数中,最接近的上下文就是函数的局部上下文。在with语句中,最接近的上下文也是函数上下文。如果变量未经声明就被初始化了,那么它就会自动被添加到全局上下文。var声明会被拿到函数或全局作用域的顶部,位于作用域中所有代码之前。这个现象叫作“提升”。

console.log(name); // undefined
var name = 'Jake';

// 等价于
var name
console.log(name)
name = 'Jake'


function() {
  console.log(name); // undefined
  var name = 'Jake';
}

// 等价于

function() {
  var name
  console.log(name); // undefined
  name = 'Jake';
}
  1. 使用let的块级作用域声明

let声明的变量的作用域是块级的,块级作用域由最近的一对包含花括号{}界定。换句话说,if块、while块、function块,甚至连单独的块也是let声明变量的作用域。

if (true) {
  let a;
}
console.log(a); // ReferenceError: a没有定义

while (true) {
  let b;
}
console.log(b); // ReferenceError: b没有定义

function foo() {
  let c;
}
console.log(c); // ReferenceError: c没有定义
                // 这没什么可奇怪的
                // var声明也会导致报错

// 这不是对象字面量,而是一个独立的块
// JavaScript解释器会根据其中内容识别出它来
{
  let d;
}
console.log(d); // ReferenceError: d没有定义

let与var的另一个不同之处是在同一作用域内不能声明两次。

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没有定义

let在JavaScript运行时中也会被提升,但由于“暂时性死区”(temporal dead zone)的缘故,实际上不能在声明之前使用let变量。

  1. 使用const的常量声明

使用const声明的变量必须同时初始化为某个值。一经声明,在其生命周期的任何时候都不能再重新赋予新值。

const a; // SyntaxError: 常量声明时没有初始化

const b = 3;
console.log(b); // 3
b = 4; // TypeError: 给常量赋值

赋值为对象的const变量不能再被重新赋值为其他引用值,但对象的键则不受限制。

const o1 = {};
o1 = {}; // TypeError: 给常量赋值

const o2 = {};
o2.name = 'Jake';
console.log(o2.name); // 'Jake'

如果想让整个对象都不能修改,可以使用Object.freeze(),这样再给属性赋值时虽然不会报错,但会静默失败:

const o3 = Object.freeze({});
o3.name = 'Jake';
console.log(o3.name); // undefined

# 垃圾回收

如何标记未使用的变量也许有不同的实现方式。不过,在浏览器的发展史上,用到过两种主要的标记策略:标记清理和引用计数

# 标记清理

# 引用计数

其思路是对每个值都记录它被引用的次数。声明变量并给它赋一个引用值时,这个值的引用数为1。如果同一个值又被赋给另一个变量,那么引用数加1。类似地,如果保存对该值引用的变量被其他值给覆盖了,那么引用数减1。当一个值的引用数为0时,就说明没办法再访问到这个值了,因此可以安全地收回其内存了。垃圾回收程序下次运行的时候就会释放引用数为0的值的内存。

引用计数遇到的问题是:循环引用,如对象A有一个指针指向对象B,而对象B也引用了对象A。

function problem() {
  let objectA = new Object();
  let objectB = new Object();

  objectA.someOtherObject = objectB;
  objectB.anotherObject = objectA;
}

# 性能

垃圾回收程序会周期性运行,如果内存中分配了很多变量,则可能造成性能损失,因此垃圾回收的时间调度很重要。尤其是在内存有限的移动设备上,垃圾回收有可能会明显拖慢渲染的速度和帧速率。开发者不知道什么时候运行时会收集垃圾,因此最好的办法是在写代码时就要做到:无论什么时候开始收集垃圾,都能让它尽快结束工作。

现代垃圾回收程序会基于对JavaScript运行时环境的探测来决定何时运行。探测机制因引擎而异,但基本上都是根据已分配对象的大小和数量来判断的。

# 总结

JavaScript变量可以保存两种类型的值:原始值和引用值。原始值可能是以下6种原始数据类型之一:Undefined、Null、Boolean、Number、String和Symbol。原始值和引用值有以下特点。

  • 原始值大小固定,因此保存在栈内存上。
  • 从一个变量到另一个变量复制原始值会创建该值的第二个副本。
  • 引用值是对象,存储在堆内存上。
  • 包含引用值的变量实际上只包含指向相应对象的一个指针,而不是对象本身。
  • 从一个变量到另一个变量复制引用值只会复制指针,因此结果是两个变量都指向同一个对象。
  • typeof操作符可以确定值的原始类型,而instanceof操作符用于确保值的引用类型。

任何变量(不管包含的是原始值还是引用值)都存在于某个执行上下文中(也称为作用域)。这个上下文(作用域)决定了变量的生命周期,以及它们可以访问代码的哪些部分。执行上下文可以总结如下。

  • 执行上下文分全局上下文、函数上下文和块级上下文。
  • 代码执行流每进入一个新上下文,都会创建一个作用域链,用于搜索变量和函数。
  • 函数或块的局部上下文不仅可以访问自己作用域内的变量,而且也可以访问任何包含上下文乃至全局上下文中的变量。
  • 全局上下文只能访问全局上下文中的变量和函数,不能直接访问局部上下文中的任何数据。
  • 变量的执行上下文用于确定什么时候释放内存。

JavaScript是使用垃圾回收的编程语言,开发者不需要操心内存分配和回收。JavaScript的垃圾回收程序可以总结如下。

  • 离开作用域的值会被自动标记为可回收,然后在垃圾回收期间被删除。
  • 主流的垃圾回收算法是标记清理,即先给当前不使用的值加上标记,再回来回收它们的内存。
  • 引用计数是另一种垃圾回收策略,需要记录值被引用了多少次。JavaScript引擎不再使用这种算法,但某些旧版本的- IE仍然会受这种算法的影响,原因是JavaScript会访问非原生JavaScript对象(如DOM元素)。
  • 引用计数在代码中存在循环引用时会出现问题。
  • 解除变量的引用不仅可以消除循环引用,而且对垃圾回收也有帮助。为促进内存回收,全局对象、全局对象的属性和循- 环引用都应该在不需要时解除引用。