《JavaScript高级程序设计》(红宝书)学习笔记第一章至第四章

红宝书

第 1 章 什么是 JavaScript

JavaScript 是一门用来与网页交互的脚本语言,包含以下三个组成部分。

  • ECMAScript:由 ECMA-262 定义并提供核心功能。
  • 文档对象模型(DOM):提供与网页内容交互的方法和接口。
  • 浏览器对象模型(BOM):提供与浏览器交互的方法和接口。

第 2 章 HTML 中的 JavaScript

2.1 **

标签中再包含其他JavaScript 代码。如果两者都提供的话,则浏览器只会下载并执行脚本文件,从而忽略行内代码

《JavaScript高级程序设计》(红宝书)学习笔记第一章至第四章_第1张图片

不管包含的是什么代码,浏览器都会按照

2.1.1 标签位置

过去,所有

DOCTYPE html> 
<html> 
 <head> 
 <title>Example HTML Pagetitle> 
 <script src="example1.js">script> 
 <script src="example2.js">script> 
 head> 
 <body> 
  
 body> 
html>

这种做法的主要目的是把外部的 CSS 和 JavaScript 文件都集中放到一起

把所有 JavaScript文件都放在里,也就意味着必须把所有 JavaScript 代码都下载、解析和解释完成后,才能开始渲染页面**(页面在浏览器解析到的起始标签时开始渲染)**。对于需要很多 JavaScript 的页面,这会导致页面渲染的明显延迟,在此期间浏览器窗口完全空白。

解决方案:将所有 JavaScript 引用放在元素中的页面内容后面

例如:

DOCTYPE html> 
<html> 
 <head> 
 <title>Example HTML Pagetitle> 
 head> 
 <body> 
  
 <script src="example1.js">script> 
 <script src="example2.js">script> 
 body> 
html>

实现效果:页面会在处理 JavaScript 代码之前完全渲染页面。用户会感觉页面加载更快了,因为浏览器显示空白页面的时间短了

2.1.2 推迟执行脚本 defer属性

defer属性表示脚本在执行的时候不会改变页面的结构,即:脚本会被延迟到整个页面都解析完毕后再运行,因此,在

举个例子:

DOCTYPE html> 
<html> 
 <head> 
 <title>Example HTML Pagetitle> 
 <script defer src="example1.js">script> 
 <script defer src="example2.js">script> 
 head> 
 <body> 
  
 body> 
html>

虽然这个例子中的

2.1.3 异步执行脚本 async 属性

从改变脚本处理方式上看,async 属性与 defer 类似。当然,它们两者也都只适用于外部脚本,都会告诉浏览器立即开始下载。不过,与 defer 不同的是,标记为 async 的脚本并不保证能按照它们出现的次序执行,举个例子:

DOCTYPE html> 
<html> 
 <head> 
 <title>Example HTML Pagetitle> 
 <script async src="example1.js">script> 
 <script async src="example2.js">script> 
 head> 
 <body> 
  
 body> 
html>

在这个例子中,第二个脚本可能先于第一个脚本执行。因此,重点在于它们之间没有依赖关系。给脚本添加 async 属性的目的是告诉浏览器,不必等脚本下载和执行完后再加载页面,同样也不必等到该异步脚本下载和执行后再加载其他脚本。正因为如此,异步脚本不应该在加载期间修改 DOM。

异步脚本保证会在页面的 load 事件前执行,但可能会在 DOMContentLoaded之前或之后。使用 async 也会告诉页面你不会使用document.write

2.2 行内代码与外部文件

虽然可以直接在 HTML 文件中嵌入 JavaScript 代码,但通常认为最佳实践是尽可能将 JavaScript 代码放在外部文件中。不过这个最佳实践并不是明确的强制性规则。推荐使用外部文件的理由如下:

  • 可维护性。JavaScript 代码如果分散到很多 HTML 页面,会导致维护困难。而用一个目录保存所有 JavaScript 文件,则更容易维护,这样开发者就可以独立于使用它们的 HTML 页面来编辑代码。

  • 缓存。浏览器会根据特定的设置缓存所有外部链接的 JavaScript 文件,这意味着如果两个页面都用到同一个文件,则该文件只需下载一次。这最终意味着页面加载更快。

  • 适应未来。通过把 JavaScript 放到外部文件中,就不必考虑用 XHTML 或前面提到的注释黑科技。包含外部 JavaScript 文件的语法在 HTML 和 XHTML 中是一样的。

2.3 ****元素

被用于给不支持 JavaScript 的浏览器提供替代内容

元素可以包含任何可以出现在中的 HTML 元素,

  • 浏览器不支持脚本;

  • 浏览器对脚本的支持被关闭

任何一个条件被满足,包含在中的内容就会被渲染。否则,浏览器不会渲染中的内容。

举个例子:

DOCTYPE html> 
<html> 
 <head> 
 <title>Example HTML Pagetitle> 
 <script defer="defer" src="example1.js">script> 
 <script defer="defer" src="example2.js">script> 
 head> 
 <body> 
 <noscript> 
 <p>This page requires a JavaScript-enabled browser.p> 
 noscript> 
 body> 
html>

这个例子是在脚本不可用时让浏览器显示一段话。如果浏览器支持脚本,则用户永远不会看到它

2.4小结

  • 要包含外部 JavaScript 文件,必须将 src 属性设置为要包含文件的 URL。文件可以跟网页在同一台服务器上,也可以位于完全不同的域。

  • 所有

3 章 语言基础

3.1 语法

3.1.1 区分大小写

首先要知道的是,ECMAScript 中一切都区分大小写。无论是变量、函数名还是操作符,都区分大小写。

关键字不能作为函数名

3.1.2 标识符

所谓标识符,就是变量、函数、属性或函数参数的名称。

标识符可以由一或多个下列字符组成:

  • 第一个字符必须是一个字母、下划线(_)或美元符号($);

  • 剩下的其他字符可以是字母、下划线、美元符号或数字

ECMAScript 标识符使用驼峰大小写形式,即第一个单词的首字母小写,后面每个单词的首字母大写

关键字、保留字、true、false 和 null 不能作为标识符

3.1.3 注释

单行注释以两个斜杠字符开头,如:

// 单行注释

块注释以一个斜杠和一个星号(/)开头,以它们的反向组合(/)结尾,如:

/* 这是多行

注释 */ 
3.1.4 严格模式

要对整个脚本启用严格模式,在脚本开头加上这一行:

"use strict";

也可以单独指定一个函数在严格模式下执行,只要把这个预处理指令放到函数体开头即可:

function doSomething() { 
 "use strict"; 
 // 函数体 
}
3.1.5 语句

ECMAScript 中的语句以分号结尾。省略分号意味着由解析器确定语句在哪里结尾,例如:

let sum = a + b // 没有分号也有效,但不推荐
let diff = a - b; // 加分号有效,推荐
  • 记着加分号有助于防止省略造成的问题,比如可以避免输入内容不完整。

  • 加分号也便于开发者通过删除空行来压缩代码(如果没有结尾的分号,只删除空行,则会导致语法错误)

  • 加分号也有助于在某些情况下提升性能,因为解析器会尝试在合适的位置补上分号以纠正语法错误

多条语句可以合并到一个 C 语言风格的代码块中。代码块由一个左花括号({)标识开始,一个右花括号(})标识结束:

if (test) { 
 test = false; 
 console.log(test); 
}

if 之类的控制语句只在执行多条语句时要求必须有代码块。不过,最佳实践是始终在控制语句中使用代码块,即使要执行的只有一条语句

// 有效,但容易导致错误,应该避免
if (test) 
 console.log(test); 
// 推荐
if (test) { 
 console.log(test); 
}

3.2关键字和保留字

最好还是不要使用关键字和保留字作为标识符和属性名

3.3变量

ECMAScript 变量是松散类型的,意思是变量可以用于保存任何类型的数据。每个变量只不过是一个用于保存任意值的命名占位符

3.3.1 var 关键字

要定义变量,可以使用 var 操作符,后跟变量名:

var message; 

这行代码定义了一个名为 message 的变量,可以用它保存任何类型的值。(不初始化的情况下,变量会保存一个特殊值 undefined

  1. var 声明作用域

使用 var 操作符定义的变量会成为包含它的函数的局部变量,使用 var在一个函数内部定义一个变量,就意味着该变量将在函数退出时被销毁,例如:

function test() { 
 var message = "hi"; // 局部变量
} 
test(); 
console.log(message); // 出错!

在函数内定义变量时省略 var 操作符,可以创建一个全局变量:(不建议这样做)

function test() { 
 message = "hi"; // 全局变量
} 
test(); 
console.log(message); // "hi"

如果需要定义多个变量,可以在一条语句中用逗号分隔每个变量(及可选的初始化):

var message = "hi", 
 found = false, 
 age = 29;
  1. var 声明提升 也就是把所有变量声明都拉到函数作用域的顶部

使用 var 时,先使用后声明也不会报错,是因为使用这个关键字声明的变量会自动提升到函数作用域顶部:

function foo() { 
 console.log(age); 
 var age = 26; 
} 
foo(); // undefined

之所以不会报错,是因为 ECMAScript 运行时把它看成等价于如下代码:

function foo() { 
 var age; 
 console.log(age); 
 age = 26; 
} 
foo(); // undefined

此外,反复多次使用 var 声明同一个变量也没有问题:

这是因为:在使用 var 声明变量时,由于声明会被提升,JavaScript 引擎会自动将多余的声明在作用域顶部合并为一个声明

function foo() { 
 var age = 16; 
 var age = 26; 
 var age = 36; 
 console.log(age); 
} 
foo(); // 36
3.3.2 let 声明

关于块级作用域:ES6 的块级作用域必须有大括号,如果没有大括号,JavaScript 引擎就认为不存在块级作用域。

let 跟 var 的作用差不多,但有着非常重要的区别。最明显的区别是,let 声明的范围是块作用域,而 var 声明的范围是函数作用域

if (true) { 
 var name = 'Matt'; 
 console.log(name); // Matt 
} 
console.log(name); // Matt
if (true) { 
 let age = 26; 
 console.log(age); // 26 
} 
console.log(age); // ReferenceError: age 没有定义

块作用域是函数作用域的子集,因此适用于 var 的作用域限制同样也适用于 let。

let 也不允许同一个块作用域中出现冗余声明。会报错:

var name; 
var name; 
let age; 
let age; // SyntaxError;标识符 age 已经声明过了

对声明冗余报错不会因混用 let 和 var 而受影响

var name; 
let name; // SyntaxError 
let age; 
var age; // SyntaxError
  1. 暂时性死区

let 与 var 的另一个重要的区别,就是 let 声明的变量不会在作用域中被提升

// name 会被提升
console.log(name); // undefined 
var name = 'Matt'; 
// age 不会被提升
console.log(age); // ReferenceError:age 没有定义
let age = 26;

let 声明之前的执行瞬间被称为**“暂时性死区”**(temporal dead zone,简称 TDZ),在此阶段引用任何后面才声明的变量都会抛出 ReferenceError。举个例子:

if (true) {
  // TDZ开始
  tmp = 'abc'; // ReferenceError
  console.log(tmp); // ReferenceError

  let tmp; // TDZ结束
  console.log(tmp); // undefined

  tmp = 123;
  console.log(tmp); // 123
}
  1. 全局声明

使用 let 在全局作用域中声明的变量不会成为 window 对象的属性(var 声明的变量则会)。

var name = 'Matt'; 
console.log(window.name); // 'Matt' 
let age = 26; 
console.log(window.age); // undefined
  1. for 循环中的 let 声明

let 出现之前,for 循环定义的迭代变量会渗透到循环体外部:

for (var i = 0; i < 5; ++i) { 
 // 循环逻辑 
} 
console.log(i); // 5

改成使用 let 之后,这个问题就消失了,因为迭代变量的作用域仅限于 for 循环块内部:

for (let i = 0; i < 5; ++i) { 
 // 循环逻辑
} 
console.log(i); // ReferenceError: i 没有定义

在使用 var 的时候,最常见的问题就是对迭代变量的奇特声明和修改:

for (var i = 0; i < 5; ++i) { 
 setTimeout(() => console.log(i), 0) 
}
// 实际上会输出 5、5、5、5、5

原因如下:在退出循环时,迭代变量保存的是导致循环退出的值:5。在之后执行超时逻辑时,所有的 i 都是同一个变量,因而输出的都是同一个最终值。

而使用let时,则:

for (let i = 0; i < 5; ++i) { 
 setTimeout(() => console.log(i), 0) 
} 
// 会输出 0、1、2、3、4

for循环还有一个特别之处,就是设置循环变量的那部分是一个父作用域,而循环体内部是一个单独的子作用域。

for (let i = 0; i < 3; i++) {
  let i = 'abc';
  console.log(i);
}
// abc
// abc
// abc
3.3.3 const 声明

const 的行为与 let 基本相同,唯一一个重要的区别是用它声明变量时必须同时初始化变量,且尝试修改 const 声明的变量会导致运行时错误

const age = 26; 
age = 36;  // TypeError: 给常量赋值
// const 也不允许重复声明
const name = 'Matt'; 
const name = 'Nicholas'; // SyntaxError 
// const 声明的作用域也是块
const name = 'Matt'; 
if (true) { 
 const name = 'Nicholas'; 
} 
console.log(name); // Matt/ TypeError: 给常量赋值
// const 也不允许重复声明
const name = 'Matt'; 
const name = 'Nicholas'; // SyntaxError 
// const 声明的作用域也是块
const name = 'Matt'; 
if (true) { 
 const name = 'Nicholas'; 
} 
console.log(name); // Matt

const 声明的限制只适用于它指向的变量的引用。换句话说,如果 const 变量引用的是一个对象,那么修改这个对象内部的属性并不违反 const 的限制。

const person = {}; 
person.name = 'Matt'; // ok

不能用 const 来声明迭代变量(因为迭代变量会自增):

for (const i = 0; i < 10; ++i) {} // TypeError:给常量赋值

如果你只想用 const 声明一个不会被修改的 for 循环变量,那也是可以的。也就是说,每次迭代只是创建一个新变量。这对 for-of 和 for-in 循环特别有意义:

let i = 0; 
for (const j = 7; i < 5; ++i) { 
 console.log(j); 
} 
// 7, 7, 7, 7, 7 
for (const key in {a: 1, b: 2}) { 
 console.log(key); 
} 
// a, b 
for (const value of [1,2,3,4,5]) { 
 console.log(value); 
} 
// 1, 2, 3, 4, 5
3.3.4 声明风格及最佳实践
  1. 不使用 var
  2. const 优先,let 次之

3.4 数据类型

ECMAScript 有 6 种简单数据类型(也称为原始类型):Undefined、Null、Boolean、Number、String 和 Symbol。

还有一种复杂数据类型叫 Object(对象)。Object 是一种无序名值对的集合。

3.4.1 typeof 操作符

因为 ECMAScript 的类型系统是松散的,所以需要一种手段来确定任意变量的数据类型。

对一个值使用 typeof 操作符会返回下列字符串之一:

  • "undefined"表示值未定义;
  • "boolean"表示值为布尔值;
  • "string"表示值为字符串;
  • "number"表示值为数值;
  • "object"表示值为对象(而不是函数)或 null;
  • "function"表示值为函数;
  • "symbol"表示值为符
let message = "some string"; 
console.log(typeof message); // "string" 
console.log(typeof(message)); // "string" 
console.log(typeof 95); // "number"
let sym = Symbol();
console.log(typeof sym); //symbol

因为 typeof 是一个操作符而不是函数,所以不需要参数(但可以使用参数)

调用typeof null 。这是因为特殊值 null 被认为是一个对空对象的引用

3.4.2 Undefined 类型

Undefined 类型只有一个值,就是特殊值 undefined。

当使用 var 或 let 声明了变量但没有初始化时,就相当于给变量赋予了 undefined 值:

let message; 
console.log(message == undefined); // true
console.log(message === undefined); // true

一般来说,永远不用显式地给某个变量设置 undefined 值。字面值 undefined主要用于比较

包含 undefined 值的变量跟未定义变量是有区别的,举个例子:

let message; // 这个变量被声明了,只是值为 undefined 
// 确保没有声明过这个变量
// let age 
console.log(message); // "undefined" 
console.log(age); // 报错

未声明的变量,只能执行一个有用的操作,就是对它调用 typeof。

在对未初始化的变量调用 typeof 时,返回的结果是"undefined",但对未声明的变量调用它时,返回的结果还是"undefined",例如:

let message; // 这个变量被声明了,只是值为 undefined 
// 确保没有声明过这个变量
// let age 
console.log(typeof message); // "undefined" 
console.log(typeof age); // "undefined"
3.4.3 Null 类型

Null 类型同样只有一个值,即特殊值 null。逻辑上讲,null 值表示一个空对象指针,这也是给typeof 传一个 null 会返回"object"的原因:

let car = null; 
console.log(typeof car); // "object"

在定义将来要保存对象值的变量时,建议使用 null 来初始化,不要使用其他值。这样,只要检查这个变量的值是不是 null 就可以知道这个变量是否在后来被重新赋予了一个对象的引用,比如:

if (car != null) { 
 // car 是一个对象的引用
}

undefined 值是由 null 值派生而来的,因此 ECMA-262 将它们定义为表面上相等,如下面的例子所示:

console.log(null == undefined); // true
console.log(null === undefined); // false

用等于操作符(==)比较 null 和 undefined 始终返回 true。但要注意,这个操作符会为了比较而转换它的操作数

null 和 undefined 的用途是完全不一样的:

  • 永远不必显式地将变量值设置为 undefined。

  • 任何时候,只要变量要保存对象,而当时又没有那个对象可保存,就要用 null 来填充该变量。这样就可以保持 null 是空对象指针的语义,并进一步将其与 undefined 区分开来。

3.4.4 Boolean 类型

Boolean(布尔值)类型有两个字面值:true 和 false,严格区分大小写

这两个布尔值不同于数值,因此 true 不等于 1,false 不等于 0,但要注意,这个操作符会为了比较而转换它的操作数

 console.log(true === 1); //false
 console.log(false === 0); //false
 console.log(true == 1);  //true
 console.log(false == 0); //true

要将一个其他类型的值转换为布尔值,可以调用特定的 Boolean()转型函数:

let message = "Hello world!"; 
let messageAsBoolean = Boolean(message);

Boolean()转型函数可以在任意类型的数据上调用,而且始终返回一个布尔值。下表总结了不同类型与布尔值之间的转换规则:

《JavaScript高级程序设计》(红宝书)学习笔记第一章至第四章_第2张图片

let message = "Hello world!"; 
if (message) { 
 console.log("Value is true");  //Value is true
}
3.4.5 Number 类型

最基本的数值字面量格式是十进制整数,直接写出来即可:

let intNum = 55; // 整数

整数也可以用八进制(以 8 为基数)或十六进制(以 16 为基数)字面量表示。对于八进制字面量,第一个数字必须是零(0),然后是相应的八进制数字(数值 0~7)。如果字面量中包含的数字超出了应有的范围,就会忽略前缀的零,后面的数字序列会被当成十进制数,如下所示:

let octalNum1 = 070; // 八进制的 56 
let octalNum2 = 079; // 无效的八进制值,当成 79 处理
let octalNum3 = 08; // 无效的八进制值,当成 8 处理

要创建十六进制字面量,必须让真正的数值前缀 0x(区分大小写),然后是十六进制数字(0~9 以及 A~F)。十六进制数字中的字母大小写均可。举个例子:

let hexNum1 = 0xA; // 十六进制 10 
let hexNum2 = 0x1f; // 十六进制 31

注意:由于 JavaScript 保存数值的方式,实际中可能存在正零(+0)和负零(0)。正零和负零在所有情况下都被认为是等同的

  1. 浮点值

要定义浮点值,数值中必须包含小数点,而且小数点后面必须至少有一个数字。虽然小数点前面不是必须有整数,但推荐加上。

let floatNum1 = 1.1; 
let floatNum2 = 0.1; 
let floatNum3 = .1; // 有效,但不推荐

因为存储浮点值使用的内存空间是存储整数值的两倍,所以 ECMAScript 总是想方设法把值转换为整数。在小数点后面没有数字的情况下,数值就会变成整数。类似地,如果数值本身就是整数,只是小数点后面跟着 0(如 1.0),那它也会被转换为整数,如下例所示:

let floatNum1 = 1.; // 小数点后面没有数字,当成整数 1 处理
let floatNum2 = 10.0; // 小数点后面是零,当成整数 10 处理

对于非常大或非常小的数值,浮点值可以用科学记数法来表示。科学记数法用于表示一个应该乘以10 的给定次幂的数值。科学记数法的格式要求是一个数值(整数或浮点数)后跟一个大写或小写的字母 e,再加上一个要乘的 10 的多少次幂。例如:

let floatNum = 3.125e7; // 等于 31250000

浮点值的精确度最高可达 17 位小数,但在算术计算中远不如整数精确。例如,0.1 加 0.2 得到的不是 0.3,而是 0.300 000 000 000 000 04。由于这种微小的舍入错误,导致很难测试特定的浮点值。

  1. 值的范围

由于内存的限制,ECMAScript 并不支持表示这个世界上的所有数值。ECMAScript 可以表示的最小数值保存在 Number.MIN_VALUE 中,这个值在多数浏览器中是 5e-324;可以表示的最大数值保存在Number.MAX_VALUE 中,这个值在多数浏览器中是 1.797 693 134 862 315 7e+308。如果某个计算得到的数值结果超出了 JavaScript 可以表示的范围,那么这个数值会被自动转换为一个特殊的 Infinity(无穷)值。任何无法表示的负数以-Infinity(负无穷大)表示,任何无法表示的正数以 Infinity(正无穷大)表示。

如果计算返回正 Infinity 或负 Infinity,则该值将不能再进一步用于任何计算。这是因为Infinity 没有可用于计算的数值表示形式。要确定一个值是不是有限大(即介于 JavaScript 能表示的最小值和最大值之间),可以使用 isFinite()函数,如下所示:

let result = Number.MAX_VALUE + Number.MAX_VALUE; 
console.log(isFinite(result)); // false

使用 Number.NEGATIVE_INFINITY 和 Number.POSITIVE_INFINITY 也可以获取正、负 Infinity。

  1. NaN

NaN意思是“不是数值”(Not a Number),用于表示本来要返回数值的操作失败了(而不是抛出错误)。

比如,用 0 除任意数值在其他语言中通常都会导致错误,从而中止代码执行。但在 ECMAScript 中,0、+0 或 -0 相除会返回 NaN:

console.log(0/0); // NaN 
console.log(-0/+0); // NaN

如果分子是非 0 值,分母是有符号 0 或无符号 0,则会返回 Infinity 或-Infinity:

console.log(5/0); // Infinity 
console.log(5/-0); // -Infinity

NaN 有几个独特的属性:

  • 任何涉及 NaN 的操作始终返回 NaN(如 NaN/10),在连续多步计算时这可能是个问题

  • NaN 不等于包括 NaN 在内的任何值

    举个例子:

    console.log(NaN == NaN); // false
    

为此,ECMAScript 提供了 isNaN()函数。该函数接收一个参数,可以是任意数据类型,然后判断这个参数是否“不是数值”

console.log(isNaN(NaN)); // true 
console.log(isNaN(10)); // false,10 是数值
console.log(isNaN("10")); // false,可以转换为数值 10 
console.log(isNaN("blue")); // true,不可以转换为数值
console.log(isNaN(true)); // false,可以转换为数值 1
  1. 数值转换

有 3 个函数可以将非数值转换为数值:Number()、parseInt()和 parseFloat()

1.Number() Number()是转型函数,可用于任何数据类型

Number()函数基于如下规则执行转换。

  • 布尔值,true 转换为 1,false 转换为 0。

  • 数值,直接返回。

  • null,返回 0。

  • undefined,返回 NaN。

  • 字符串,应用以下规则。

    • 如果字符串包含数值字符,包括数值字符前面带加、减号的情况,则转换为一个十进制数值。因此,Number(“1”)返回 1,Number(“123”)返回 123,Number(“011”)返回 11(忽略前面的零)。
    • 如果字符串包含有效的浮点值格式如"1.1",则会转换为相应的浮点值(同样,忽略前面的零)。
    • 如果字符串包含有效的十六进制格式如"0xf",则会转换为与该十六进制值对应的十进制整数值。
    • 如果是空字符串(不包含字符),则返回 0。
    • 如果字符串包含除上述情况之外的其他字符,则返回 NaN。
  • 对象,调用 valueOf()方法,并按照上述规则转换返回的值。如果转换结果是 NaN,则调用toString()方法,再按照转换字符串的规则转换。

2.parseInt() 更专注于字符串是否包含数值模式

字符串最前面的空格会被忽略,从第一个非空格字符开始转换。如果第一个字符不是数值字符、加号或减号,parseInt()立即返回 NaN。这意味着空字符串也会返回 NaN(这一点跟 Number()不一样,它返回 0)。如果第一个字符是数值字符、加号或减号,则继续依次检测每个字符,直到字符串末尾,或碰到非数值字符。比如,"1234blue"会被转换为 1234,因为"blue"会被完全忽略。类似地,"22.5"会被转换为 22,因为小数点不是有效的整数字符。

假设字符串中的第一个字符是数值字符,parseInt()函数也能识别不同的整数格式(十进制、八进制、十六进制)。换句话说,如果字符串以"0x"开头,就会被解释为十六进制整数。如果字符串以"0"开头,且紧跟着数值字符,在非严格模式下会被某些实现解释为八进制整数。下面几个转换示例有助于理解上述规则:

let num1 = parseInt("1234blue"); // 1234 
let num2 = parseInt(""); // NaN 
let num3 = parseInt("0xA"); // 10,解释为十六进制整数
let num4 = parseInt(22.5); // 22 
let num5 = parseInt("70"); // 70,解释为十进制值
let num6 = parseInt("0xf"); // 15,解释为十六进制整数

不同的数值格式很容易混淆,因此 parseInt()也接收第二个参数,用于指定底数(进制数)如果知道要解析的值是十六进制,那么可以传入 16 作为第二个参数,以便正确解析:

let num = parseInt("0xAF", 16); // 175

事实上,如果提供了十六进制参数,那么字符串前面的"0x"可以省掉:

let num1 = parseInt("AF", 16); // 175 

通过第二个参数,可以极大扩展转换后获得的结果类型。比如:

let num1 = parseInt("10", 2); // 2,按二进制解析
let num2 = parseInt("10", 8); // 8,按八进制解析
let num3 = parseInt("10", 10); // 10,按十进制解析
let num4 = parseInt("10", 16); // 16,按十六进制解析

因为不传底数参数相当于让 parseInt()自己决定如何解析,所以为避免解析出错,建议始终传给它第二个参数。

3.parseFloat()函数

从位置 0 开始检测每个字符。它也是解析到字符串末尾或者解析到一个无效的浮点数值字符为止。这意味着第一次出现的小数点是有效的,但第二次出现的小数点就无效了,此时字符串的剩余字符都会被忽略。因此,"22.34.5"将转换成 22.34

它始终忽略字符串开头的零。这个函数能识别前面讨论的所有浮点格式,以及十进制格式(开头的零始终被忽略)。十六进制数值始终会返回 0。因为parseFloat()只解析十进制值,因此不能指定底数。最后,如果字符串表示整数(没有小数点或者小数点后面只有一零),则 parseFloat()返回整数。下面是几个示例:

let num1 = parseFloat("1234blue"); // 1234,按整数解析
let num2 = parseFloat("0xA"); // 0 
let num3 = parseFloat("22.5"); // 22.5 
let num4 = parseFloat("22.34.5"); // 22.34 
let num5 = parseFloat("0908.5"); // 908.5 
let num6 = parseFloat("3.125e7"); // 31250000
3.4.6 String 类型

String(字符串)数据类型表示零或多个 16 位 Unicode 字符序列。字符串可以使用双引号(")、单引号(')或反引号(`)标示,因此下面的代码都是合法的:

let firstName = "John"; 
let lastName = 'Jacob'; 
let lastName = `Jingleheimerschmidt`

ECMAScript 语法中表示字符串的引号没有区别

不过要注意的是,以某种引号作为字符串开头必须仍然以该种引号作为字符串结尾。

1.字符字面量

字符串数据类型包含一些字符字面量,用于表示非打印字符或有其他用途的字符,如下表所示:
《JavaScript高级程序设计》(红宝书)学习笔记第一章至第四章_第3张图片
《JavaScript高级程序设计》(红宝书)学习笔记第一章至第四章_第4张图片
为转义序列表示一个字符,所以只算一个字符

字符串的长度可以通过其 length 属性获取:

console.log(text.length); // 28

如果字符串中包含双字节字符,那么length 属性返回的值可能不是准确的字符数。

2.字符串的特点

ECMAScript 中的字符串是不可变的(immutable),意思是一旦创建,它们的值就不能变了。要修改某个变量中的字符串值,必须先销毁原始的字符串,然后将包含新值的另一个字符串保存到该变量,举个例子:

let lang = "Java"; 
lang = lang + "Script";

变量 lang 一开始包含字符串"Java"。紧接着,lang 被重新定义为包含"Java"和"Script"的组合,也就是"JavaScript"。整个过程首先会分配一个足够容纳 10 个字符的空间,然后填充上"Java"和"Script"。最后销毁原始的字符串"Java"和字符串"Script"

3.转换为字符串

有两种方式把一个值转换为字符串:toString()方法、String()方法

toString()方法唯一的用途就是返回当前值的字符串等价物,例如:

let age = 11; 
let ageAsString = age.toString(); // 字符串"11" 
let found = true; 
let foundAsString = found.toString(); // 字符串"true"

toString()方法可见于数值、布尔值、对象和字符串值。(没错,字符串值也有 toString()方法,该方法只是简单地返回自身的一个副本。)null 和 undefined 值没有 toString()方法。

多数情况下,toString()不接收任何参数。不过,在对数值调用这个方法时,toString()可以接收一个底数参数,即以什么底数来输出数值的字符串表示.默认情况下,toString()返回数值的十进制字符串表示。而通过传入参数,可以得到数值的二进制、八进制、十六进制,或者其他任何有效基数的字符串表示,比如:

let num = 10; 
console.log(num.toString()); // "10" 
console.log(num.toString(2)); // "1010" 
console.log(num.toString(8)); // "12" 
console.log(num.toString(10)); // "10" 
console.log(num.toString(16)); // "a"

如果你不确定一个值是不是 null 或 undefined,可以使用 String()转型函数,它始终会返回表示相应类型值的字符串。String()函数遵循如下规则:

  • 如果值有 toString()方法,则调用该方法(不传参数)并返回结果。

  • 如果值是 null,返回"null"。

  • 如果值是 undefined,返回"undefined"。

let value1 = 10; 
let value2 = true; 
let value3 = null; 
let value4; 
console.log(String(value1)); // "10" 
console.log(String(value2)); // "true" 
console.log(String(value3)); // "null" 
console.log(String(value4)); // "undefined"

因为 null 和 undefined 没有 toString()方法,所以 String()方法就直接返回了这两个值的字面量文本

4.模板字面量

ECMAScript 6 新增了使用模板字面量定义字符串的能力,模板字面量保留换行字符,可以跨行定义字符串:

let myMultiLineString = 'first line\nsecond line'; 
let myMultiLineTemplateLiteral = `first line 
second line`; 
console.log(myMultiLineString); 
// first line 
// second line" 
console.log(myMultiLineTemplateLiteral); 
// first line 
// second line 
console.log(myMultiLineString === myMultiLineTemplateLiteral); // true

模板字面量在定义模板时特别有用,比如:

let pageHTML = ` 
`;

模板字面量会保持反引号内部的空格,格式正确的模板字符串看起来可能会缩进不当:

// 这个模板字面量在换行符之后有 25 个空格符
let myTemplateLiteral = `first line 
 second line`; 
console.log(myTemplateLiteral.length); // 47 
// 这个模板字面量以一个换行符开头
let secondTemplateLiteral = ` 
first line 
second line`; 
console.log(secondTemplateLiteral[0] === '\n'); // true 
// 这个模板字面量没有意料之外的字符
let thirdTemplateLiteral = `first line 
second line`; 
console.log(thirdTemplateLiteral); 
// first line 
// second line
5.字符串插值

技术上讲,模板字面量不是字符串,而是一种特殊的 JavaScript 句法表达式,只不过求值后得到的是字符串。

模板字面量在定义时立即求值并转换为字符串实例,任何插入的变量也会从它们最接近的作用域中取值。

字符串插值通过在${}中使用一个 JavaScript 表达式实现:

let value = 5; 
let exponent = 'second';
let interpolatedTemplateLiteral = `${ value } to the ${ exponent } power is ${ value * value }`;
console.log(interpolatedTemplateLiteral); // 5 to the second power is 25

所有插入的值都会使用 toString()强制转型为字符串,而且任何 JavaScript 表达式都可以用于插值。嵌套的模板字符串无须转义:

console.log(`Hello, ${ `World` }!`); // Hello, World!

将表达式转换为字符串时会调用 toString():

let foo = { toString: () => 'World' }; 
console.log(`Hello, ${ foo }!`); // Hello, World! 

在插值表达式中可以调用函数和方法:

function capitalize(word) { 
 return `${ word[0].toUpperCase() }${ word.slice(1) }`; 
} 
console.log(`${ capitalize('hello') }, ${ capitalize('world') }!`); // Hello, World! 

此外,模板也可以插入自己之前的值:

let value = ''; 
function append() { 
 value = `${value}abc` 
 console.log(value); 
} 
append(); // abc 
append(); // abcabc 
append(); // abcabcabc 
7.原始字符串

使用模板字面量也可以直接获取原始的模板字面量内容(如换行符或 Unicode 字符),而不是被转换后的字符表示。为此,可以使用默认的 String.raw 标签函数:

// Unicode 示例
// \u00A9 是版权符号
console.log(`\u00A9`); // © 
console.log(String.raw`\u00A9`); // \u00A9 
// 换行符示例
console.log(`first line\nsecond line`); 
// first line 
// second line 
console.log(String.raw`first line\nsecond line`); // "first line\nsecond line" 
// 对实际的换行符来说是不行的
// 它们不会被转换成转义序列的形式
console.log(`first line
second line`); 
// first line 
// second line
console.log(String.raw`first line 
second line`); 
// first line 
// second line
3.4.8 Object 类型

ECMAScript 中的对象其实就是一组数据和功能的集合。对象通过 new 操作符后跟对象类型的名称来创建。

let o = new Object();

ECMAScript 只要求在给构造函数提供参数时使用括号。如果没有参数,如上面的例子所示,那么完全可以省略括号(不推荐):

let o = new Object; // 合法,但不推荐

ECMAScript 中的 Object 也是派生其他对象的基类。Object 类型的所有属性和方法在派生的对象上同样存在。

每个 Object 实例都有如下属性和方法。

  • constructor:用于创建当前对象的函数。在前面的例子中,这个属性的值就是 Object() 函数。

  • hasOwnProperty(propertyName):用于判断当前对象实例(不是原型)上是否存在给定的属性。要检查的属性名必须是字符串(如 o.hasOwnProperty(“name”))或符号。

  • isPrototypeOf(object):用于判断当前对象是否为另一个对象的原型。

  • propertyIsEnumerable(propertyName):用于判断给定的属性是否可以使用(本章稍后讨论的)for-in 语句枚举。与 hasOwnProperty()一样,属性名必须是字符串。

  • toLocaleString():返回对象的字符串表示,该字符串反映对象所在的本地化执行环境。

  • toString():返回对象的字符串表示。

  • valueOf():返回对象对应的字符串、数值或布尔值表示。通常与 toString()的返回值相同。

3.5 操作符

ECMA-262 描述了一组可用于操作数据值的操作符,包括数学操作符(如加、减)、位操作符、关系操作符和相等操作符等。ECMAScript 中的操作符是独特的,它们可用于各种值,包括字符串、数值、布尔值,甚至还有对象。在应用给对象时,操作符通常会调用 valueOf()和/或 toString()方法来取得可以计算的值

3.5.1 一元操作符

只操作一个值的操作符叫一元操作符(unary operator)

  1. 递增/递减操作符

递增和递减操作符两个版本:前缀版和后缀版。

前缀递增操作符会给数值加 1,把两个加号(++)放到变量前头即可:

let age = 29; 
++age; 

前缀递减操作符也类似,只不过是从一个数值减 1。使用前缀递减操作符,只要把两个减号(–)放到变量前头即可:

let age = 29; 
--age;

无论使用前缀递增还是前缀递减操作符,变量的值都会在语句被求值之前改变。(在计算机科学中,这通常被称为具有副作用。

请看下面的例子:

let age = 29; 
let anotherAge = --age + 2; 
console.log(age); // 28 
console.log(anotherAge); // 30

递增和递减的后缀版语法一样(分别是++和–),只不过要放在变量后面。后缀版与前缀版的主要区别在于,后缀版递增和递减在语句被求值后才发生。

let age = 29; 
age++;

后缀操作符与前缀操作符的区别:

let num1 = 2; 
let num2 = 20; 
let num3 = num1-- + num2; 
let num4 = num1 + num2;
console.log(num3); // 22 
console.log(num4); // 21

这 4 个操作符可以作用于任何值,递增和递减操作符遵循如下规则:

  • 对于字符串,如果是有效的数值形式,则转换为数值再应用改变。变量类型从字符串变成数值。

  • 对于字符串,如果不是有效的数值形式,则将变量的值设置为 NaN 。变量类型从字符串变成数值。

  • 对于布尔值,如果是 false,则转换为 0 再应用改变。变量类型从布尔值变成数值。

  • 对于布尔值,如果是 true,则转换为 1 再应用改变。变量类型从布尔值变成数值。

  • 对于浮点值,加 1 或减 1。

  • 如果是对象,则调用其valueOf()方法取得可以操作的值。对得到的值应用上述规则。如果是 NaN,则调用 toString()并再次应用其他规则。变量类型从对象变成数值。

下面的例子演示了这些规则:

let s1 = "2"; 
let s2 = "z"; 
let b = false; 
let f = 1.1; 
let o = { 
 valueOf() { 
 return -1; 
 } 
}; 
s1++; // 值变成数值 3 
s2++; // 值变成 NaN 
b++; // 值变成数值 1 
f--; // 值变成 0.10000000000000009(因为浮点数不精确)
o--; // 值变成-2 
  1. 一元加和减

一元加由一个加号(+)表示,放在变量前头,对数值没有任何影响:

let num = 25; 
num = +num; 
console.log(num); // 25

如果将一元加应用到非数值,则会执行与使用 Number()转型函数一样的类型转换:布尔值 false和 true 转换为 0 和 1,字符串根据特殊规则进行解析,对象会调用它们的 valueOf()和/或 toString()方法以得到可以转换的值。

下面的例子演示了一元加在应用到不同数据类型时的行为:

let s1 = "01"; 
let s2 = "1.1"; 
let s3 = "z"; 
let b = false; 
let f = 1.1; 
let o = { 
 valueOf() { 
 return -1; 
 } 
}; 
s1 = +s1; // 值变成数值 1 
s2 = +s2; // 值变成数值 1.1 
s3 = +s3; // 值变成 NaN 
b = +b; // 值变成数值 0 
f = +f; // 不变,还是 1.1 
o = +o; // 值变成数值-1 

一元减由一个减号(-)表示,放在变量前头,主要用于把数值变成负值

示例如下:

let num = 25; 
num = -num; 
console.log(num); // -25 

在应用到非数值时,一元减会遵循与一元加同样的规则,先对它们进行转换,然后再取负值:

let s1 = "01"; 
let s2 = "1.1"; 
let s3 = "z"; 
let b = false; 
let f = 1.1; 
let o = { 
 valueOf() { 
 return -1; 
 } 
}; 
s1 = -s1; // 值变成数值-1 
s2 = -s2; // 值变成数值-1.1 
s3 = -s3; // 值变成 NaN 
b = -b; // 值变成数值 0 
f = -f; // 变成-1.1 
o = -o; // 值变成数值 1

一元加和减操作符主要用于基本的算术,但也可以用于数据类型转换

3.5.2 位操作符

有符号整数使用 32 位的前 31 位表示整数值。第 32 位表示数值的符号,如 0 表示正,1 表示负。这一位称为符号位(sign bit),它的值决定了数值其余部分的格式。正值以真正的二进制格式存储,即 31位中的每一位都代表 2 的幂。第一位(称为第 0 位)表示 20 ,第二位表示 21 ,依此类推。如果一个位是空的,则以0填充,相当于忽略不计。比如,数值18的二进制格式为00000000000000000000000000010010,或更精简的 10010。后者是用到的 5 个有效位,决定了实际的值

二进制计算十进制的方式如下:
《JavaScript高级程序设计》(红宝书)学习笔记第一章至第四章_第5张图片

负值以一种称为二补数(或补码)的二进制编码存储。一个数值的二补数通过如下 3 个步骤计算得到:

(1) 确定绝对值的二进制表示(如,对于-18,先确定 18 的二进制表示);

(2) 找到数值的一补数(或反码),换句话说,就是每个 0 都变成 1,每个 1 都变成 0;

(3) 给结果加 1。

在把负值输出为一个二进制字符串时,我们会得到一个前面

加了减号的绝对值,如下所示:

let num = -18; 
console.log(num.toString(2)); // "-10010"

特殊值NaN 和Infinity在位操作中都会被当成 0 处理

如果将位操作符应用到非数值,那么首先会使用 Number()函数将该值转换为数值(这个过程是自动的),然后再应用位操作。最终结果是数值。

1.按位非 按位非的最终效果是对数值取反并减 1

按位非操作符用波浪符(~)表示

看下面的例子:

let num1 = 25; // 二进制 00000000000000000000000000011001 
let num2 = ~num1; // 二进制 11111111111111111111111111100110 
console.log(num2); // -26

按位非的最终效果是对数值取反并减 1,就像执行如下操作的结果一样:但是位运算的速度快得多

let num1 = 25; 
let num2 = -num1 - 1; 
console.log(num2); // "-26"
2.按位与

按位与操作符用和号(&)表示,有两个操作数。两个数都为1时,返回1,两个数有一个为0,返回0
《JavaScript高级程序设计》(红宝书)学习笔记第一章至第四章_第6张图片

let result = 25 & 3; 
console.log(result); // 1

看下面的二进制计算过程:

25 = 0000 0000 0000 0000 0000 0000 0001 1001

3 = 0000 0000 0000 0000 0000 0000 0000 0011


AND = 0000 0000 0000 0000 0000 0000 0000 0001

3.按位或

按位或操作符用管道符(|)表示,同样有两个操作数。两个数都为0时,返回0,两个数有一个为1,返回1

《JavaScript高级程序设计》(红宝书)学习笔记第一章至第四章_第7张图片

let result = 25 | 3; 
console.log(result); // 27

可见 25 和 3 的按位或操作的结果是 27:

25 = 0000 0000 0000 0000 0000 0000 0001 1001

3 = 0000 0000 0000 0000 0000 0000 0000 0011

---------------------------------------------

OR =0000 0000 0000 0000 0000 0000 0001 1011

4.按位异或

按位异或用脱字符(^)表示,同样有两个操作数。相同为0,不同为1

let result = 25 ^ 3; 
console.log(result); // 26

可见,25 和 3 的按位异或操作结果为 26,如下所示:

25 = 0000 0000 0000 0000 0000 0000 0001 1001

3 = 0000 0000 0000 0000 0000 0000 0000 0011

---------------------------------------------

XOR=0000 0000 0000 0000 0000 0000 0001 1010

5.左移

左移操作符用两个小于号(<<)表示,会按照指定的位数将数值的所有位向左移动。

let oldValue = 2; // 等于二进制 10 
let newValue = oldValue << 5; // 等于二进制 1000000,即十进制 64

注意在移位后,数值右端会空出 5 位。左移会以 0 填充这些空位,让结果是完整的 32 位数值
《JavaScript高级程序设计》(红宝书)学习笔记第一章至第四章_第8张图片

**注意,左移会保留它所操作数值的符号。**比如,如果2 左移 5 位,将得到64,而不是正 64。

6.有符号右移

有符号右移由两个大于号(>>)表示,会将数值的所有 32 位都向右移,同时保留符号(正或负)。有符号右移实际上是左移的逆运算。

let oldValue = 64; // 等于二进制 1000000 
let newValue = oldValue >> 5; // 等于二进制 10,即十进制 2

同样,移位后就会出现空位。不过,右移后空位会出现在左侧,且在符号位之后。ECMAScript 会用符号位的值来填充这些空位,以得到完整的数值。
《JavaScript高级程序设计》(红宝书)学习笔记第一章至第四章_第9张图片

7.无符号右移

无符号右移用 3 个大于号表示(>>>),会将数值的所有 32 位都向右移。对于正数,无符号右移与有符号右移结果相同。

let oldValue = 64; // 等于二进制 1000000 
let newValue = oldValue >>> 5; // 等于二进制 10,即十进制 2

无符号右移操作符将负数的二进制表示当成正数的二进制表示来处理。因为负数是其绝对值的二补数,所以右移之后结果变得非常之大,如下面的例子所示:

let oldValue = -64; // 等于二进制 11111111111111111111111111000000 
let newValue = oldValue >>> 5; // 等于十进制 134217726

在对64 无符号右移 5 位后,结果是 134 217 726。这是因为64 的二进制表示是 1111111111111111111 1111111000000,无符号右移却将它当成正值,也就是 4 294 967 232。把这个值右移 5 位后,结果是00000111111111111111111111111110,即 134 217 726。

3.5.3 布尔操作符

布尔操作符一共有 3 个:逻辑非、逻辑与和逻辑或

1.逻辑非

逻辑非操作符由一个叹号(!)表示,可给 ECMAScript 中的任何值。这个操作符始终返回布尔值,逻辑非操作符会遵循如下规则:

  • 如果操作数是对象,则返回 false。

  • 如果操作数是空字符串,则返回 true。

  • 如果操作数是非空字符串,则返回 false。

  • 如果操作数是数值 0,则返回 true。

  • 如果操作数是非 0 数值(包括 Infinity),则返回 false。

  • 如果操作数是 null,则返回 true。

  • 如果操作数是 NaN,则返回 true。

  • 如果操作数是 undefined,则返回 true。

以下示例验证了上述行为:

console.log(!false); // true
console.log(!"blue"); // false 
console.log(!0); // true 
console.log(!NaN); // true 
console.log(!""); // true 
console.log(!12345); // false 

逻辑非操作符也可以用于把任意值转换为布尔值。同时使用两个叹号(!!),相当于调用了转型函数 Boolean()。无论操作数是什么类型,第一个叹号总会返回布尔值。第二个叹号对该布尔值取反,从而给出变量真正对应的布尔值。结果与对同一个值使用 Boolean()函数是一样的:

console.log(!!"blue"); // true 
console.log(!!0); // false 
console.log(!!NaN); // false
console.log(!!""); // false 
console.log(!!12345); // true 
2.逻辑与 短路操作

逻辑与操作符由两个和号(&&)表示,应用到两个值,如下所示:

let result = true && false; 
```![请添加图片描述](https://img-blog.csdnimg.cn/4f47992e67b246a69d49d05b9715ccd5.png)


逻辑与操作符可用于任何类型的操作数,不限于布尔值。如果有操作数不是布尔值,则逻辑与并不一定会返回布尔值,而是遵循如下规则:

- 如果第一个操作数是对象,则返回第二个操作数。

- 如果第二个操作数是对象,则只有第一个操作数求值为 true 才会返回该对象。

- 如果两个操作数都是对象,则返回第二个操作数。

- 如果有一个操作数是 null,则返回 null。

- 如果有一个操作数是 NaN,则返回 NaN。

- 如果有一个操作数是 undefined,则返回 undefined。

```javascript
let found = true; 
let result = (found && someUndeclaredVariable); // 这里会出错
console.log(result); // 不会执行这一行
let found = false; 
let result = (found && someUndeclaredVariable); // 不会出错
console.log(result); // 会执行
3.逻辑或 短路操作

逻辑或操作符由两个管道符(||)表示,

比如:let result = true || false;
《JavaScript高级程序设计》(红宝书)学习笔记第一章至第四章_第10张图片

如果有一个操作数不是布尔值,那么逻辑或操作符也不一定返回布尔值。它遵循如下规则:

  • 如果第一个操作数是对象,则返回第一个操作数。

  • 如果第一个操作数求值为 false,则返回第二个操作数。

  • 如果两个操作数都是对象,则返回第一个操作数。

  • 如果两个操作数都是 null,则返回 null。

  • 如果两个操作数都是 NaN,则返回 NaN。

  • 如果两个操作数都是 undefined,则返回 undefined。

let found = true; 
let result = (found || someUndeclaredVariable); // 不会出错
console.log(result); // 会执行
let found = false; 
let result = (found || someUndeclaredVariable); // 这里会出错
console.log(result); // 不会执行这一行

利用这个行为,可以避免给变量赋值 null 或 undefined。比如:

let myObject = preferredObject || backupObject;

解析:变量 myObject 会被赋予两个值中的一个。其中,preferredObject 变量包含首选的值,backupObject 变量包含备用的值。如果 preferredObject 不是 null,则它的值就会赋给myObject;如果 preferredObject 是 null,则 backupObject 的值就会赋给 myObject

3.5.4 乘性操作符

ECMAScript 定义了 3 个乘性操作符:乘法、除法和取模,但在处理非数值时,它们也会包含一些自动的类型转换。如果乘性操作符有

不是数值的操作数,则该操作数会在后台被使用 Number()转型函数转换为数值。这意味着空字符串会被当成 0,而布尔值 true 会被当成 1。

1.乘法操作符

乘法操作符由一个星号(*)表示,可以用于计算两个数值的乘积。比如:

let result = 34 * 56;

不过,乘法操作符在处理特殊值时也有一些特殊的行为:

  • 如果操作数都是数值,则执行常规的乘法运算,即两个正值相乘是正值,两个负值相乘也是正值,正负符号不同的值相乘得到负值。如果 ECMAScript 不能表示乘积,则返回 Infinity 或-Infinity。

  • 如果有任一操作数是 NaN,则返回 NaN。

  • 如果是 Infinity 乘以 0,则返回 NaN。

  • 如果是 Infinity 乘以非 0的有限数值,则根据第二个操作数的符号返回 Infinity 或-Infinity。

  • 如果是 Infinity 乘以 Infinity,则返回 Infinity。

  • 如果有不是数值的操作数,则先在后台用 Number()将其转换为数值,然后再应用上述规则。

2.除法操作符

除法操作符由一个斜杠(/)表示,用于计算第一个操作数除以第二个操作数的商,比如:

**let result = 66 / 11;** 

除法操作符针对特殊值也有一些特殊的行为:

  • 如果操作数都是数值,则执行常规的除法运算,即两个正值相除是正值,两个负值相除也是正值,符号不同的值相除得到负值。如果ECMAScript不能表示商,则返回Infinity或-Infinity。

  • 如果有任一操作数是 NaN,则返回 NaN。

  • 如果是 Infinity 除以 Infinity,则返回 NaN。

  • 如果是 0 除以 0,则返回 NaN。

  • 如果是非 0 的有限值除以 0,则根据第一个操作数的符号返回 Infinity 或-Infinity。

  • 如果是 Infinity 除以任何数值,则根据第二个操作数的符号返回 Infinity 或-Infinity。

  • 如果有不是数值的操作数,则先在后台用 Number()函数将其转换为数值,然后再应用上述规则。

3.取模操作符

取模(余数)操作符由一个百分比符号(%)表示,比如:

let result = 26 % 5; //等于1

取模操作符对特殊值也有一些特殊的行为:

  • 如果操作数是数值,则执行常规除法运算,返回余数。

  • 如果被除数是无限值,除数是有限值,则返回 NaN。

  • 如果被除数是有限值,除数是 0,则返回 NaN。

  • 如果是 Infinity 除以 Infinity,则返回 NaN。

  • 如果被除数是有限值,除数是无限值,则返回被除数。

  • 如果被除数是 0,除数不是 0,则返回 0。

  • 如果有不是数值的操作数,则先在后台用 Number()函数将其转换为数值,然后再应用上述规则。

3.5.5 指数操作符

ECMAScript 7 新增了指数操作符,Math.pow()现在有了自己的操作符**,结果是一样的:

console.log(Math.pow(3, 2); // 9 
console.log(3 ** 2); // 9 
console.log(Math.pow(16, 0.5); // 4 
console.log(16** 0.5); // 4 

不仅如此,指数操作符也有自己的指数赋值操作符**=,该操作符执行指数运算和结果的赋值操作:

let squared = 3; 
squared **= 2; 
console.log(squared); // 9 
let sqrt = 16; 
sqrt **= 0.5; 
console.log(sqrt); // 4
3.5.6 加性操作符

加性操作符,即加法和减法操作符,在 ECMAScript中,这两个操作符拥有一些特殊的行为。加性操作符在后台会发生不同数据类型的转换。只不过对这两个操作符来说,转换规则不是那么直观。

1.加法操作符

加法操作符(+)用于求两个数的和,比如:

let result = 1 + 2; 

如果两个操作数都是数值,加法操作符执行加法运算并根据如下规则返回结果:

  • 如果有任一操作数是 NaN,则返回 NaN;

  • 如果是 Infinity 加 Infinity,则返回 Infinity;

  • 如果是-Infinity 加-Infinity,则返回-Infinity;

  • 如果是 Infinity 加-Infinity,则返回 NaN;

  • 如果是+0 加+0,则返回+0;

  • 如果是-0 加+0,则返回+0;

  • 如果是-0 加-0,则返回-0。

不过,如果有一个操作数是字符串,则要应用如下规则:

  • 如果两个操作数都是字符串,则将第二个字符串拼接到第一个字符串后面;

  • 如果只有一个操作数是字符串,则将另一个操作数转换为字符串,再将两个字符串拼接在一起。

  • 如果有任一操作数是对象、数值或布尔值,则调用它们的 toString()方法以获取字符串,然后再应用前面的关于字符串的规则。对于 undefined 和 null,则调用 String()函数,分别获取"undefined"和"null"。

看下面的例子:

let result1 = 5 + 5; // 两个数值
console.log(result1); // 10 
let result2 = 5 + "5"; // 一个数值和一个字符串
console.log(result2); // "55" 

ECMAScript 中最常犯的一个错误,就是忽略加法操作中涉及的数据类型。比如下面这个例子:

let num1 = 5; 
let num2 = 10; 
let message = "The sum of 5 and 10 is " + num1 + num2; 
console.log(message); // "The sum of 5 and 10 is 510" 

解析:每次加法运算都是独立完成的。第一次加法的操作数是一个字符串和一个数值(5),结果还是一个字符串。第二次加法仍然是用一个字符串去加一个数值(10),同样也会得到一个字符串。

如果想真正执行数学计算,然后把结果追加到字符串末尾,只要使用一对括号即可:

let num1 = 5; 
let num2 = 10; 
let message = "The sum of 5 and 10 is " + (num1 + num2); 
console.log(message); // "The sum of 5 and 10 is 15"
2.减法操作符

减法操作符(-)也是使用很频繁的一种操作符,比如:

let result = 2 - 1; 

与加法操作符一样,减法操作符也有一组规则用于处理 ECMAScript 中不同类型之间的转换。

  • 如果两个操作数都是数值,则执行数学减法运算并返回结果。

  • 如果有任一操作数是 NaN,则返回 NaN。

  • 如果是 Infinity 减 Infinity,则返回 NaN。

  • 如果是-Infinity 减-Infinity,则返回 NaN。

  • 如果是 Infinity 减-Infinity,则返回 Infinity。

  • 如果是-Infinity 减 Infinity,则返回-Infinity。

  • 如果是+0 减+0,则返回+0。

  • 如果是+0 减-0,则返回-0。

  • 如果是-0 减-0,则返回+0。

  • 如果有任一操作数是字符串、布尔值、null 或 undefined,则先在后台使用 Number()将其转换为数值,然后再根据前面的规则执行数学运算。如果转换结果是 NaN,则减法计算的结果是NaN。

  • 如果有任一操作数是对象,则调用其 valueOf()方法取得表示它的数值。如果该值是 NaN,则减法计算的结果是 NaN。如果对象没有 valueOf()方法,则调用其 toString()方法,然后再将得到的字符串转换为数值。

以下示例演示了上面的规则:

let result1 = 5 - true; // true 被转换为 1,所以结果是 4 
let result2 = NaN - 1; // NaN 
let result3 = 5 - 3; // 2 
let result4 = 5 - ""; // ""被转换为 0,所以结果是 5 
let result5 = 5 - "2"; // "2"被转换为 2,所以结果是 3 
let result6 = 5 - null; // null 被转换为 0,所以结果是 5 
3.5.7 关系操作符

关系操作符执行比较两个值的操作,包括小于(<)、大于(>)、小于等于(<=)和大于等于(>=),这几个操作符都返回布尔值,

如下所示:

let result1 = 5 > 3; // true 
let result2 = 5 < 3; // false 

在将它们应用到不同数据类型时也会发生类型转换和其他行为。

  • 如果操作数都是数值,则执行数值比较。

  • 如果操作数都是字符串,则逐个比较字符串中对应字符的编码。

  • 如果有任一操作数是数值,则将另一个操作数转换为数值,执行数值比较。

  • 如果有任一操作数是对象,则调用其 valueOf()方法,取得结果后再根据前面的规则执行比较。如果没有 valueOf()操作符,则调用 toString()方法,取得结果后再根据前面的规则执行比较。

  • 如果有任一操作数是布尔值,则将其转换为数值再执行比较。

对字符串而言,关系操作符会比较字符串中对应字符的编码,而这些编码是数值。比较完之后,会返回布尔值。问题的关键在于,大写字母的编码都小于小写字母的编码,因此以下这种情况就会发生:

let result = "Brick" < "alphabet"; // true 

在这里,字符串"Brick"被认为小于字符串"alphabet",因为字母 B 的编码是 66,字母 a 的编码-是 97。要得到确实按字母顺序比较的结果,就必须把两者都转换为相同的大小写形式(全大写或全小写),然后再比较:

let result = "Brick".toLowerCase() < "alphabet".toLowerCase(); // false 

将两个操作数都转换为小写,就能保证按照字母表顺序判定"alphabet"在"Brick"前头。

另一个奇怪的现象是在比较两个数值字符串的时候,比如下面这个例子:

let result = "23" < "3"; // true 

这里在比较字符串"23"和"3"时返回 true。因为两个操作数都是字符串,所以会逐个比较它们的字符编码(字符"2"的编码是 50,而字符"3"的编码是 51)。不过,如果有一个操作数是数值,那么比较的结果就对了:

let result = "23" < 3; // false 

因为这次会将字符串"23"转换为数值 23,然后再跟 3 比较,结果当然对了。只要是数值和字符串比较,字符串就会先被转换为数值,然后进行数值比较。对于数值字符串而言,这样能保证结果正确。但如果字符串不能转换成数值呢?比如下面这个例子:

let result = "a" < 3; // 因为"a"会转换为 NaN,所以结果是 false 

因为字符"a"不能转换成任何有意义的数值,所以只能转换为 NaN。这里有一个规则,即任何关系操作符在涉及比较 NaN 时都返回 false。这样一来,下面的例子有趣了:

let result1 = NaN < 3; // false 
let result2 = NaN >= 3; // false 

在大多数比较的场景中,如果一个值不小于另一个值,那就一定大于或等于它。但在比较 NaN 时,无论是小于还是大于等于,比较的结果都会返回 false。

3.5.8 相等操作符 推荐使用全等和不全等操作符

ECMAScript提供了两组操作符。第一组是等于和不等于,它们在比较之前执行转换。第二组是全等和不全等,它们在比较之前不执行转换。

1.等于和不等于

ECMAScript 中的等于操作符用两个等于号(==)表示,如果操作数相等,则会返回 true。不等于操作符用叹号和等于号(!=)表示,如果两个操作数不相等,则会返回 true。这两个操作符都会先进行类型转换(通常称为强制类型转换)再确定操作数是否相等。在转换操作数的类型时,相等和不相等操作符遵循如下规则。

  • 如果任一操作数是布尔值,则将其转换为数值再比较是否相等。false 转换为 0,true 转换为 1。

  • 如果一个操作数是字符串,另一个操作数是数值,则尝试将字符串转换为数值,再比较是否相等。

  • 如果一个操作数是对象,另一个操作数不是,则调用对象的 valueOf()方法取得其原始值,再根据前面的规则进行比较。在进行比较时,这两个操作符会遵循如下规则。

  • null 和 undefined 相等。

  • null 和 undefined 不能转换为其他类型的值再进行比较。

  • 如果有任一操作数是 NaN,则相等操作符返回 false,不相等操作符返回 true。记住:即使两个操作数都是 NaN,相等操作符也返回 false,因为按照规则,NaN 不等于 NaN。

  • 如果两个操作数都是对象,则比较它们是不是同一个对象。如果两个操作数都指向同一个对象,则相等操作符返回 true。否则,两者不相等。

下表总结了一些特殊情况及比较的结果。

《JavaScript高级程序设计》(红宝书)学习笔记第一章至第四章_第11张图片

2.全等和不全等

全等和不全等操作符与相等和不相等操作符类似,只不过它们在比较相等时不转换操作数。全等操作符由 3 个等于号(===)表示,只有两个操作数在不转换的前提下相等才返回 true,比如:

let result1 = ("55" == 55); // true,转换后相等
let result2 = ("55" === 55); // false,不相等,因为数据类型不同

另外,虽然 null == undefined 是 true(因为这两个值类似),但 null === undefined 是false,因为它们不是相同的数据类型。

3.5.9 条件操作符

条件操作符语法:

variable = boolean_expression ? true_value : false_value; 

上面的代码执行了条件赋值操作,即根据条件表达式 boolean_expression 的值决定将哪个值赋给变量 variable 。如果 boolean_expression 是 true ,则赋值 true_value ;如果boolean_expression 是 false,则赋值 false_value。比如:

let max = (num1 > num2) ? num1 : num2; 
3.5.10 赋值操作符

简单赋值用等于号(=)表示,将右手边的值赋给左手边的变量,如下所示:

let num = 10; 

复合赋值使用乘性、加性或位操作符后跟等于号(=)表示。这些赋值操作符是类似如下常见赋值操作的简写形式:

let num = 10; 
num = num + 10; 

以上代码的第二行可以通过复合赋值来完成:

let num = 10; 
num += 10; 

每个数学操作符以及其他一些操作符都有对应的复合赋值操作符

  • 乘后赋值(*=)

  • 除后赋值(/=)

  • 取模后赋值(%=)

  • 加后赋值(+=)

  • 减后赋值(-=)

  • 左移后赋值(<<=)

  • 右移后赋值(>>=)

  • 无符号右移后赋值(>>>=)

这些操作符仅仅是简写语法,使用它们不会提升性能。

3.5.11 逗号操作符

逗号操作符可以用来在一条语句中执行多个操作,如下所示:

let num1 = 1, num2 = 2, num3 = 3; 

在一条语句中同时声明多个变量是逗号操作符最常用的场景。不过,也可以使用逗号操作符来辅助赋值。在赋值时使用逗号操作符分隔值,最终会返回表达式中最后一个值:

let num = (5, 1, 4, 8, 0); // num 的值为 0 

在这个例子中,num 将被赋值为 0,因为 0 是表达式中最后一项。逗号操作符的这种使用场景并不多见,但这种行为的确存在。

3.6 语句

3.6.1 if 语句 建议使用语句块

if 语句,语法如下:

if (condition) statement1 else statement2 

这里的条件(condition)可以是任何表达式,并且求值结果不一定是布尔值。ECMAScript 会自动调用 Boolean()函数将这个表达式的值转换为布尔值。如果条件求值为 true,则执行语句statement1;如果条件求值为 false,则执行语句 statement2。这里的语句可能是一行代码,也可能是一个代码块(即包含在一对花括号中的多行代码)。

来看下面的例子:

if (i > 25) 
 console.log("Greater than 25."); // 只有一行代码的语句
else { 
 console.log("Less than or equal to 25."); // 一个语句块
} 

可以像这样连续使用多个 if 语句:

if (condition1) statement1 else if (condition2) statement2 else statement3 

下面是一个例子:

if (i > 25) { 
 console.log("Greater than 25."); 
} else if (i < 0) { 
 console.log("Less than 0."); 
} else { 
 console.log("Between 0 and 25, inclusive."); 
} 
3.6.2 do-while 语句

do-while 语句是一种后测试循环语句,循环体内的代码至少执行一次。do-while 的语法如下:

do { 
 statement 
} while (expression); 
//举个例子
let i = 0; 
do { 
 i += 2; 
} while (i < 10); 
3.6.3 while 语句

while 语句是一种先测试循环语句,即先检测退出条件,再执行循环体内的代码。因此,while 循环体内的代码有可能不会执行。下面是 while 循环的语法:

while(expression) statement 

这是一个例子:

let i = 0; 
while (i < 10) { 
 i += 2; 
} 
3.6.4 for 语句

for 语句也是先测试语句,只不过增加了进入循环之前的初始化代码,以及循环执行后要执行的表达式,语法如下:

for (initialization; expression; post-loop-expression) statement 
//下面是一个用例:
let count = 10; 
for (let i = 0; i < count; i++) { 
 console.log(i); 
} 

**无法通过 while 循环实现的逻辑,同样也无法使用 for 循环实现。**因此 for 循环只是将循环相关的代码封装在了一起而已。

在 for 循环的初始化代码中,其实是可以不使用变量声明关键字的。不过,初始化定义的迭代器变量在循环执行完成后几乎不可能再用到了。因此,最清晰的写法是使用 let 声明迭代器变量,这样就可以将这个变量的作用域限定在循环中。初始化、条件表达式和循环后表达式都不是必需的。因此,下面这种写法可以创建一个无穷循环:

for (;;) { // 无穷循环
 doSomething(); 
} 

如果只包含条件表达式,那么 for 循环实际上就变成了 while 循环:

let count = 10; 
let i = 0;
for (; i < count; ) { 
 console.log(i); 
 i++; 
} 
3.6.5 for-in 语句

for-in 语句是一种严格的迭代语句,用于枚举对象中的非符号键属性,语法如下:

for (property in expression) statement 

下面是一个例子:

for (const propName in window) { 
 document.write(propName); 
} 

for-in 控制语句中的 const 不是必需的。但为了确保这个局部变量不被修改,推荐使用 const。ECMAScript 中对象的属性是无序的,因此 for-in 语句不能保证返回对象属性的顺序。换句话说,所有可枚举的属性都会返回一次,但返回的顺序可能会因浏览器而异。

如果 for-in 循环要迭代的变量是 null 或 undefined,则不执行循环体。

3.6.6 for-of 语句 不可以直接遍历普通对象

for-of 语句是一种严格的迭代语句,用于遍历可迭代对象的元素,语法如下:

for (property of expression) statement 

下面是示例:

for (const el of [2,4,6,8]) { 
 document.write(el); 
} 

在这个例子中,我们使用 for-of 语句显示了一个包含 4 个元素的数组中的所有元素。循环会一直持续到将所有元素都迭代完。与 for 循环一样,这里控制语句中的 const 也不是必需的。但为了确保这个局部变量不被修改,推荐使用 const。for-of 循环会按照可迭代对象的 next()方法产生值的顺序迭代元素

如果尝试迭代的变量不支持迭代,则 for-of 语句会抛出错误。

3.6.7 标签语句

标签语句用于给语句加标签,语法如下:

label: statement 
//下面是一个例子:
start: for (let i = 0; i < count; i++) { 
 console.log(i); 
} 

在这个例子中,start 是一个标签,可以在后面通过 break 或 continue 语句引用。标签语句的典型应用场景是嵌套循环。

3.6.8 breakcontinue 语句

break 语句用于立即退出循环,强制执行循环后的下一条语句。而 continue 语句也用于立即退出循环,但会再次从循环顶部开始执行。下面看一个例子:

//使用break
let num = 0; 
for (let i = 1; i < 10; i++) { 
 if (i % 5 == 0) { 
 break; 
 } 
 num++; 
} 
console.log(num); // 4  直接结束循环   
//使用continue
let num = 0; 
for (let i = 1; i < 10; i++) { 
 if (i % 5 == 0) { 
 continue; 
 } 
 num++; 
} 
console.log(num); // 8  只会结束本次循环

break 和 continue 都可以与标签语句一起使用,返回代码中特定的位置。这通常是在嵌套循环中,如下面的例子所示:

//break标签语句一起使用
let num = 0; 
outermost: 
for (let i = 0; i < 10; i++) { 
 for (let j = 0; j < 10; j++) { 
 if (i == 5 && j == 5) { 
 break outermost; 
 } 
 num++; 
 } 
} 
console.log(num); // 55 outermost 标签标识的是第一个 for 语句,直接结束整个循环
//continue和标签语句一起使用
let num = 0; 
outermost: 
for (let i = 0; i < 10; i++) { 
 for (let j = 0; j < 10; j++) {
     if (i == 5 && j == 5) { 
 continue outermost; 
 } 
 num++; 
 } 
} 
console.log(num); // 95
3.6.9 with 语句

with 语句的用途是将代码作用域设置为特定的对象,其语法是:

with (expression) statement; 

使用 with 语句的主要场景是针对一个对象反复操作,这时候将代码作用域设置为该对象能提供便利,如下面的例子所示:

let qs = location.search.substring(1); 
let hostName = location.hostname; 
let url = location.href; 

上面代码中的每一行都用到了 location 对象。如果使用 with 语句,就可以少写一些代码:

with(location) { 
 let qs = search.substring(1); 
 let hostName = hostname; 
 let url = href; 
} 

这里,with 语句用于连接 location 对象。这意味着在这个语句内部,每个变量首先会被认为是一个局部变量。如果没有找到该局部变量,则会搜索 location 对象,看它是否有一个同名的属性。如果有,则该变量会被求值为 location 对象的属性。

严格模式不允许使用 with 语句,否则会抛出错误。

由于 with 语句影响性能且难于调试其中的代码,通常不推荐在产品代码中使用 with语句。

3.6.10 switch 语句

ECMAScript中 switch语句如下所示:

switch (expression) { 
 case value1: 
 statement 
break; 
 case value2: 
 statement 
 break; 
 case value3: 
 statement 
 break; 
 case value4: 
 statement 
 break; 
 default: 
 statement 
} 

如果没有 break,则代码会继续匹配下一个条件,default关键字用于在任何条件都没有满足时指定默认执行的语句(相当于 else 语句)

switch (i) { 
case 25: 
/*跳过*/ 
case 35: 
console.log("25 or 35"); 
break; 
case 45: 
console.log("45"); 
break; 
default:
console.log("Other"); 
}

switch 语句可以用于所有数据类型(在很多语言中,它只能用于数值),因此可以使用字符串甚至对象。其次,条件的值不需要是常量,也可以是变量或表达式。看下面的例子:

switch ("hello world") { 
 case "hello" + " world": 
 console.log("Greeting was found."); 
 break; 
 case "goodbye": 
 console.log("Closing was found."); 
 break; 
 default: 
 console.log("Unexpected message was found."); 
}

能够在条件判断中使用表达式,就可以在判断中加入更多逻辑:

let num = 25; 
switch (true) { 
 case num < 0: 
 console.log("Less than 0."); 
 break; 
 case num >= 0 && num <= 10: 
 console.log("Between 0 and 10."); 
 break; 
 case num > 10 && num <= 20: 
 console.log("Between 10 and 20."); 
 break; 
 default: 
 console.log("More than 20."); 
}

注意 switch 语句在比较每个条件的值时会使用全等操作符,因此不会强制转换数据类型(比如,字符串"10"不等于数值 10)。

3.7 函数

ECMAScript 中的函数使用 function 关键字声明,后跟一组参数,然后是函数体。

以下是函数的基本语法:

function functionName(arg0, arg1,...,argN) { 
 statements 
}
//举个例子
function sayHi(name,message){
    console.log("Hello"+name+","+message);
}
//调用
sayHi("Nicholas", "how are you today?");
//输出 "Hello Nicholas, how are you today?"

ECMAScript 中的函数不需要指定是否返回值。任何函数在任何时间都可以使用 return 语句来返回函数的值,用法是后跟要返回的值。

比如:

function sum(num1, num2) { 
 return num1 + num2; 
}

要注意的是,只要碰到 return 语句,函数就会立即停止执行并退出。因此,return 语句后面的代码不会被执行。

比如:

function sum(num1, num2) { 
 return num1 + num2; 
 console.log("Hello world"); // 不会执行
}

一个函数里也可以有多个 return 语句,像这样:

function diff(num1, num2) { 
 if (num1 < num2) { 
 return num2 - num1; 
 } else { 
 return num1 - num2; 
 } 
} 

return 语句也可以不带返回值。这时候,函数会立即停止执行并返回 undefined。这种用法最常用于提前终止函数执行,并不是为了返回值。比如在下面的例子中,console.log 不会执行:

function sayHi(name, message) { 
 return; 
 console.log("Hello " + name + ", " + message); // 不会执行
}

严格模式对函数也有一些限制:

  • 函数不能以 eval 或 arguments 作为名称;

  • 函数的参数不能叫 eval 或 arguments;

  • 两个命名参数不能拥有同一个名称。

3.8小结

ECMAScript 包含

所有基本语法、操作符、数据类型和对象,能完成基本的计算任务,但没有提供获得输入和产生输出的机制。

下面总结一下ECMAScript 中的基本元素:

  • ECMAScript 中的基本数据类型包括 Undefined、Null、Boolean、Number、String 和 Symbol。

  • 与其他语言不同,ECMAScript 不区分整数和浮点值,只有 Number 一种数值数据类型。

  • Object 是一种复杂数据类型,它是这门语言中所有对象的基类。

  • 严格模式为这门语言中某些容易出错的部分施加了限制。

  • ECMAScript 提供了 C 语言和类 C 语言中常见的很多基本操作符,包括数学操作符、布尔操作符、关系操作符、相等操作符和赋值操作符等。

  • 这门语言中的流控制语句大多是从其他语言中借鉴而来的,比如 if 语句、for 语句和 switch语句等。

  • ECMAScript 中的函数与其他语言中的函数不一样。不需要指定函数的返回值,因为任何函数可以在任何时候返回任何值。不指定返回值的函数实际上会返回特殊值 undefined。

第4章 变量、作用域与内存

4.1 原始值与引用值

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

在把一个值赋给变量时,JavaScript 引擎必须确定这个值是原始值还是引用值。保存原始值(Undefined、Null、Boolean、Number、String 和 Symbol)的变量是按值(by value)访问的,因为我们操作的就是存储在变量中的实际值。引用值是保存在内存中的对象。在操作对象时,实际上操作的是对该对象的引用(reference)而非实际的对象本身。为此,保存引用值的变量是按引用(by reference)访问的。

4.1.1 动态属性 只有引用值可以动态添加后面可以使用的属性

原始值和引用值的定义方式都是创建一个变量,然后给它赋一个值。不过,在变量保存了这个值之后,可以对这个值做什么,则大有不同。对于引用值而言,可以随时添加、修改和删除其属性和方法。看下面的例子:

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

原始值不能有属性,尽管尝试给原始值添加属性不会报错。比如:

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

注意,原始类型的初始化可以只使用原始字面量形式。如果使用的是 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

4.1.2 复制值

原始值和引用值在通过变量复制时也有所不同。在通过变量把一个原始值赋值到另一个变量时,原始值会被复制到新变量的位置。

请看下面的例子:

let num1 = 5; 
let num2 = num1;

这里,num1 包含数值 5。当把 num2 初始化为 num1 时,num2 也会得到数值 5。这个值跟存储在num1 中的 5 是完全独立的,因为它是那个值的副本。这两个变量可以独立使用,互不干扰。

《JavaScript高级程序设计》(红宝书)学习笔记第一章至第四章_第12张图片

在把引用值从一个变量赋给另一个变量时,存储在变量中的值也会被复制到新变量所在的位置。区别在于,这里复制的值实际上是一个指针,它指向存储在堆内存中的对象。操作完成后,两个变量实际上指向同一个对象,因此一个对象上面的变化会在另一个对象上反映出来,如下面的例子所示:

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

在这个例子中,变量 obj1 保存了一个新对象的实例。然后,这个值被复制到 obj2,此时两个变量都指向了同一个对象。在给 obj1 创建属性 name 并赋值后,通过 obj2 也可以访问这个属性,因为它们都指向同一个对象。

4.1.3 传递参数

《JavaScript高级程序设计》(红宝书)学习笔记第一章至第四章_第13张图片

ECMAScript 中所有函数的参数都是按值传递的。在按值传递参数时,值会被复制到一个局部变量(即一个命名参数,或者用 ECMAScript 的话说,就是 arguments 对象中的一个槽位)。在按引用传递参数时,值在内存中的位置会被保存在一个局部变量,这意味着对本地变量的修改会反映到函数外部。(这在 ECMAScript 中是不可能的。)

来看下面这个例子:

//传递数值
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"
//两个对象的引用不同了
function setName(obj) { 
 obj.name = "Nicholas"; 
 obj = new Object(); 
 obj.name = "Greg"; 
} 
let person = new Object(); 
setName(person); 
console.log(person.name); // "Nicholas"
4.1.4 确定类型

前一章提到的 typeof 操作符最适合用来判断一个变量是否为原始类型。更确切地说,它是判断一个变量是否为字符串、数值、布尔值、symbol或 undefined 的最好方式。如果值是对象或 null,那么 typeof返回"object",如下面的例子所示:

let s = "Nicholas"; 
let b = true; 
let i = 22; 
let u; 
let n = null; 
let o = new Object(); 
let sym = Symbol();
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
console.log(typeof sym);//symbol

我们通常不关心一个值是不是对象,而是想知道它是什么类型的对象。为了解决这个问题,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,因为原始值不是对象。

4.2 执行上下文与作用域

变量或函数的上下文决定了它们可以访问哪些数据,以及它们的行为。每个上下文都有一个关联的变量对象(variable object),而这个上下文中定义的所有变量和函数都存在于这个对象上。虽然无法通过代码访问变量对象,但后台处理数据会用到它。

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

每个函数调用都有自己的上下文。当代码执行流进入函数时,函数的上下文被推到一个上下文栈上。在函数执行完之后,上下文栈会弹出该函数上下文,将控制权返还给之前的执行上下文。ECMAScript程序的执行流就是通过这个上下文栈进行控制的

上下文中的代码在执行的时候,会创建变量对象的一个作用域链(scope chain)。这个作用域链决定了各级上下文中的代码在访问变量和函数时的顺序。代码正在执行的上下文的变量对象始终位于作用域链的最前端。如果上下文是函数,则其活动对象(activation object)用作变量对象。活动对象最初只有一个定义变量:arguments。(全局上下文中没有这个变量。)作用域链中的下一个变量对象来自包含上下文,再下一个对象来自再下一个包含上下文。以此类推直至全局上下文;全局上下文的变量对象始终是作用域链的最后一个变量对象。

代码执行时的标识符解析是通过沿作用域链逐级搜索标识符名称完成的。搜索过程始终从作用域链的最前端开始,然后逐级往后,直到找到标识符。(如果没有找到标识符,那么通常会报错。)

举个例子:

var color = "blue"; 
function changeColor() { 
 if (color === "blue") { 
 color = "red"; 
 } else { 
 color = "blue"; 
 } 
} 
changeColor(); //red

对这个例子而言,函数 changeColor()的作用域链包含两个对象:一个是它自己的变量对象(就是定义 arguments 对象的那个),另一个是全局上下文的变量对象。这个函数内部之所以能够访问变量color,就是因为可以在作用域链中找到它。此外,局部作用域中定义的变量可用于在局部上下文中替换全局变量。

看一看下面这个例子:

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();

内部上下文可以通过作用域链访问外部上下文中的一切,但外部上下文无法访问内部上下文中的任何东西。上下文之间的连接是线性的、有序的。每个上下文都可以到上一级上下文中去搜索变量和函数,但任何上下文都不能到下一级上下文中去搜索。

注意:函数参数被认为是当前上下文中的变量,因此也跟上下文中的其他变量遵循相同的访问规则。

4.2.1 作用域链增强

虽然执行上下文主要有全局上下文和函数上下文两种(eval()调用内部存在第三种上下文),但有其他方式来增强作用域链。某些语句会导致在作用域链前端临时添加一个上下文,这个上下文在代码执行后会被删除。通常在两种情况下会出现这个现象:

  • try/catch 语句的 catch 块
  • with 语句

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

function buildUrl() { 
 let qs = "?debug=true"; 
 with(location){ 
 let url = href + qs; 
 } 
 return url; 
}
4.2.2 变量声明
1.使用 var 的函数作用域声明

在使用 var 声明变量时,变量会被自动添加到最接近的上下文。在函数中,最接近的上下文就是函数的局部上下文。如果变量未经声明就被初始化了,那么它就会自动被添加到全局上下文,如下面的例子所示:

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()被调用之后就变成可以访问的了,如下所示:

function add(num1, num2) { 
 sum = num1 + num2; 
 return sum; 
} 
let result = add(10, 20); // 30 
console.log(sum); // 30

变量 sum 被用加法操作的结果初始化时并没有使用 var 声明。在调用 add()之后,sum被添加到了全局上下文,在函数退出之后依然存在,从而在后面可以访问到。

var 声明会被拿到函数或全局作用域的顶部,位于作用域中所有代码之前。这个现象叫作“提升”(hoisting)。提升让同一作用域中的代码不必考虑变量是否已经声明就可以直接使用。

var name = "Jake"; 
// 等价于:
name = 'Jake'; 
var name; 
//下面是两个等价的函数:
function fn1() { 
 var name = 'Jake'; 
} 
// 等价于:
function fn2() { 
 var name; 
 name = 'Jake'; 
}

通过在声明之前打印变量,可以验证变量会被提升。声明的提升意味着会输出 undefined 而不是Reference Error:

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

ES6 新增的 let 关键字跟 var 很相似,但它的作用域是块级的。块级作用域由最近的一对包含花括号{}界定。换句话说,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 声明会被忽略,而重复的 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.使用 const 的常量声明

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

const a; // SyntaxError: 常量声明时没有初始化
const b = 3; 
console.log(b); // 3 
b = 4; // TypeError: 给常量赋值
//const 除了要遵循以上规则,其他方面与 let 声明是一样的:
if (true) { 
 const a = 0; 
} 
console.log(a); // ReferenceError: a 没有定义
while (true) { 
 const b = 1; 
} 
console.log(b); // ReferenceError: b 没有定义
function foo() { 
 const c = 2; 
} 
console.log(c); // ReferenceError: c 没有定义
{ 
 const d = 3; 
} 
console.log(d); // ReferenceError: d 没有定义

const 声明只应用到顶级原语或者对象。换句话说,赋值为对象的 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
4.标识符查找

当在特定上下文中为读取或写入而引用一个标识符时,必须通过搜索确定这个标识符表示什么。搜索开始于作用域链前端,以给定的名称搜索对应的标识符。如果在局部上下文中找到该标识符,则搜索停止,变量确定;如果没有找到变量名,则继续沿作用域链搜索。(注意,作用域链中的对象也有一个原型链,因此搜索可能涉及每个对象的原型链。)这个过程一直持续到搜索至全局上下文的变量对象。如果仍然没有找到标识符,则说明其未声明。

举个例子:

var color = 'blue'; 
function getColor() { 
 return color; 
} 
console.log(getColor()); // 'blue'

对这个搜索过程而言,引用局部变量会让搜索自动停止,而不继续搜索下一级变量对象。也就是说,如果局部上下文中有一个同名的标识符,那就不能在该上下文中引用父上下文中的同名标识符,如下面的例子所示:

var color = 'blue'; 
function getColor() { 
 let color = 'red'; 
 return color; 
} 
console.log(getColor()); // 'red'

使用块级作用域声明并不会改变搜索流程,但可以给词法层级添加额外的层次:

var color = 'blue'; 
function getColor() { 
 let color = 'red'; 
 { 
 let color = 'green'; 
 return color; 
 } 
} 
console.log(getColor()); // 'green'

在这个例子中,getColor()内部声明了一个名为 color 的局部变量。在调用这个函数时,变量会被声明。在执行到函数返回语句时,代码引用了变量 color。于是开始在局部上下文中搜索这个标识符,结果找到了值为’green’的变量 color。因为变量已找到,搜索随即停止,所以就使用这个局部变量。这意味着函数会返回’green’。在局部变量 color 声明之后的任何代码都无法访问全局变量color,除非使用完全限定的写法 window.color。

注意:标识符查找并非没有代价。访问局部变量比访问全局变量要快,因为不用切换作用域。不过,JavaScript 引擎在优化标识符查找上做了很多工作,将来这个差异可能就微不足道了。

4.3 垃圾回收

JavaScript 是使用垃圾回收的语言,也就是说执行环境负责在代码执行时管理内存。。基本思路很简单:确定哪个变量不会再使用,然后释放它占用的内存。这个过程是周期性的,即垃圾回收程序每隔一定时间(或者说在代码执行过程中某个预定的收集时间)就会自动运行。垃圾回收过程是一个近似且不完美的方案,因为某块内存是否还有用,属于“不可判定的”问题,意味着靠算法是解决不了的。

在浏览器的发展史上,用到过两种主要的标记策略:标记清理和引用计数。

4.3.1 标记清理

JavaScript 最常用的垃圾回收策略是标记清理(mark-and-sweep)。

给变量加标记的方式有很多种。比如,当变量进入上下文时,反转某一位;或者可以维护“在上下文中”和“不在上下文中”两个变量列表,可以把变量从一个列表转移到另一个列表。

垃圾回收程序运行的时候,会标记内存中存储的所有变量(记住,标记方法有很多种)。然后,它会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉。在此之后再被加上标记的变量就是待删除的了,原因是任何在上下文中的变量都访问不到它们了。随后垃圾回收程序做一次内存清理,销毁带标记的所有值并收回它们的内存.

4.3.2 引用计数

另一种没那么常用的垃圾回收策略是引用计数(reference counting)。其思路是对每个值都记录它被引用的次数。声明变量并给它赋一个引用值时,这个值的引用数为 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; 
}

在这个例子中,objectA 和 objectB 通过各自的属性相互引用,意味着它们的引用数都是 2。在标记清理策略下,这不是问题,因为在函数结束后,这两个对象都不在作用域中。而在引用计数策略下,objectA 和 objectB 在函数结束后还会存在,因为它们的引用数永远不会变成 0。如果函数被多次调用,则会导致大量内存永远不会被释放。

在 IE8 及更早版本的 IE 中,并非所有对象都是原生 JavaScript 对象。BOM 和 DOM 中的对象是 C++实现的组件对象模型(COM,Component Object Model)对象,只要涉及 COM 对象,就无法避开循环引用问题。下面这个简单的例子展示了涉及 COM对象的循环引用问题:

let element = document.getElementById("some_element"); 
let myObject = new Object(); 
myObject.element = element; 
element.someObject = myObject;

这个例子在一个 DOM 对象(element)和一个原生 JavaScript 对象(myObject)之间制造了循环引用。myObject 变量有一个名为 element 的属性指向 DOM 对象 element,而 element 对象有一个someObject 属性指回 myObject 对象。由于存在循环引用,因此 DOM 元素的内存永远不会被回收,即使它已经被从页面上删除了也是如此。

为避免类似的循环引用问题,应该在确保不使用的情况下切断原生 JavaScript 对象与 DOM 元素之间的连接。比如,通过以下代码可以清除前面的例子中建立的循环引用:

myObject.element = null; 
element.someObject = null;

把变量设置为 null 实际上会切断变量与其之前引用值之间的关系。当下次垃圾回收程序运行时,这些值就会被删除,内存也会被回收。

4.3.3 性能 建议看书

无论什么时候开始收集垃圾,都能让垃圾回收尽快结束工作。

4.3.4 内存管理

将内存占用量保持在一个较小的值可以让页面性能更好。优化内存占用的最佳手段就是保证在执行代码时只保存必要的数据。如果数据不再必要,那么把它设置为 null,从而释放其引用。这也可以叫作解除引用。这个建议最适合全局变量和全局对象的属性。局部变量在超出作用域后会被自动解除引用,如下面的例子所示:

function createPerson(name){ 
 let localPerson = new Object(); 
 localPerson.name = name; 
 return localPerson; 
} 
let globalPerson = createPerson("Nicholas"); 
// 解除 globalPerson 对值的引用
globalPerson = null;

localPerson 在 createPerson()执行完成超出上下文后会自动被解除引用,不需要显式处理。但 globalPerson 是一个全局变量,应该在不再需要时手动解除其引用.

解除对一个值的引用并不会自动导致相关内存被回收。解除引用的关键在于确保相关的值已经不在上下文里了,因此它在下次垃圾回收时会被回收。

1.通过 constlet 声明提升性能

相比于使用 var,使用这两个新关键字可能会更早地让垃圾回收程序介入,尽早回收应该回收的内存。在块作用域比函数作用域更早终止的情况下,这就有可能发生

2.隐藏类和删除操作

根据 JavaScript 所在的运行环境,有时候需要根据浏览器使用的 JavaScript 引擎来采取不同的性能优化策略。截至 2017 年,Chrome 是最流行的浏览器,使用 V8 JavaScript 引擎。V8 在将解释后的 JavaScript代码编译为实际的机器码时会利用“隐藏类”。如果你的代码非注重性能,那么这一点可能对你很重要。

运行期间,V8 会将创建的对象与隐藏类关联起来,以跟踪它们的属性特征。能够共享相同隐藏类的对象性能会更好,V8 会针对这种情况进行优化,但不一定总能够做到。比如下面的代码:

function Article() {
this.title = 'Inauguration Ceremony Features Kazoo Band'; 
} 
let a1 = new Article(); 
let a2 = new Article();

V8 会在后台配置,让这两个类实例共享相同的隐藏类,因为这两个实例共享同一个构造函数和原型。假设之后又添加了下面这行代码:

a2.author = 'Jake';

此时两个 Article 实例就会对应两个不同的隐藏类。根据这种操作的频率和隐藏类的大小,这有可能对性能产生明显影响。

当然,解决方案就是避免 JavaScript 的“先创建再补充”(ready-fire-aim)式的动态属性赋值,并在构造函数中一次性声明所有属性,如下所示:

function Article(opt_author) { 
 this.title = 'Inauguration Ceremony Features Kazoo Band'; 
 this.author = opt_author; 
} 
let a1 = new Article(); 
let a2 = new Article('Jake');

这样,两个实例基本上就一样了(不考虑 hasOwnProperty 的返回值),因此可以共享一个隐藏类,从而带来潜在的性能提升。不过要记住,使用 delete 关键字会导致生成相同的隐藏类片段。看一下这个例子:

 this.title = 'Inauguration Ceremony Features Kazoo Band'; 
 this.author = 'Jake'; 
} 
let a1 = new Article(); 
let a2 = new Article(); 
delete a1.author;

在代码结束后,即使两个实例使用了同一个构造函数,它们也不再共享一个隐藏类。动态删除属性与动态添加属性导致的后果一样。最佳实践是把不想要的属性设置为 null。这样可以保持隐藏类不变和继续共享,同时也能达到删除引用值供垃圾回收程序回收的效果。比如:

function Article() { 
 this.title = 'Inauguration Ceremony Features Kazoo Band'; 
 this.author = 'Jake'; 
} 
let a1 = new Article(); 
let a2 = new Article(); 
a1.author = null;
3.内存泄漏

JavaScript 中的内存泄漏大部分是由不合理的引用导致的。意外声明全局变量是最常见但也最容易修复的内存泄漏问题。下面的代码没有使用任何关键字声明变量:

function setName() { 
 name = 'Jake'; 
}

此时,解释器会把变量 name 当作 window 的属性来创建(相当于 window.name = ‘Jake’)。可想而知,在 window 对象上创建的属性,只要 window 本身不被清理就不会消失。解决这个问题,只要在变量声明前头加上 var、let 或 const 关键字即可,这样变量就会在函数执行完毕后离开作用域。

定时器也可能会悄悄地导致内存泄漏。下面的代码中,定时器的回调通过闭包引用了外部变量:

let name = 'Jake'; 
setInterval(() => { 
 console.log(name); 
}, 100);

只要定时器一直运行,回调函数中引用的 name 就会一直占用内存。垃圾回收程序当然知道这一点,因而就不会清理外部变量。

使用 JavaScript 闭包很容易在不知不觉间造成内存泄漏。请看下面的例子:

let outer = function() { 
 let name = 'Jake'; 
 return function() { 
 return name; 
 }; 
};

调用 outer()会导致分配给 name 的内存被泄漏。以上代码执行后创建了一个内部闭包,只要返回的函数存在就不能清理 name,因为闭包一直在引用着它。假如 name 的内容很大(不止是一个小字符串),那可能就是个大问题了。

4.静态分配与对象池

为了提升 JavaScript 性能,最后要考虑的一点往往就是压榨浏览器了。此时,一个关键问题就是如何减少浏览器执行垃圾回收的次数。开发者无法直接控制什么时候开始收集垃圾,但可以间接控制触发垃圾回收的条件。理论上,如果能够合理使用分配的内存,同时避免多余的垃圾回收,那就可以保住因释放内存而损失的性能。

浏览器决定何时运行垃圾回收程序的一个标准就是对象更替的速度。如果有很多对象被初始化,然后一下子又都超出了作用域,那么浏览器就会采用更激进的方式调度垃圾回收程序运行,这样当然会影响性能。看一看下面的例子,这是一个计算二维矢量加法的函数:

function addVector(a, b) { 
 let resultant = new Vector(); 
 resultant.x = a.x + b.x; 
 resultant.y = a.y + b.y; 
 return resultant; 
}

调用这个函数时,会在堆上创建一个新对象,然后修改它,最后再把它返回给调用者。如果这个矢量对象的生命周期很短,那么它会很快失去所有对它的引用,成为可以被回收的值。假如这个矢量加法函数频繁被调用,那么垃圾回收调度程序会发现这里对象更替的速度很快,从而会更频繁地安排垃圾回收。

该问题的解决方案是不要动态创建矢量对象,比如可以修改上面的函数,让它使用一个已有的矢量对象:

function addVector(a, b, resultant) { 
 resultant.x = a.x + b.x; 
 resultant.y = a.y + b.y; 
 return resultant; 
}

当然,这需要在其他地方实例化矢量参数 resultant,但这个函数的行为没有变。那么在哪里创建矢量可以不让垃圾回收调度程序盯上呢?

一个策略是使用对象池。在初始化的某一时刻,可以创建一个对象池,用来管理一组可回收的对象。应用程序可以向这个对象池请求一个对象、设置其属性、使用它,然后在操作完成后再把它还给对象池。由于没发生对象初始化,垃圾回收探测就不会发现有对象更替,因此垃圾回收程序就不会那么频繁地运行。下面是一个对象池的伪实现:

// vectorPool 是已有的对象池 
let v1 = vectorPool.allocate(); 
let v2 = vectorPool.allocate(); 
let v3 = vectorPool.allocate(); 
v1.x = 10; 
v1.y = 5; 
v2.x = -3; 
v2.y = -6; 
addVector(v1, v2, v3); 
console.log([v3.x, v3.y]); // [7, -1] 
vectorPool.free(v1); 
vectorPool.free(v2); 
vectorPool.free(v3); 
// 如果对象有属性引用了其他对象
// 则这里也需要把这些属性设置为 null 
v1 = null; 
v2 = null; 
v3 = null;

如果对象池只按需分配矢量(在对象不存在时创建新的,在对象存在时则复用存在的),那么这个实现本质上是一种贪婪算法,有单调增长但为静态的内存。这个对象池必须使用某种结构维护所有对象,数组是比较好的选择。不过,使用数组来实现,必须留意不要招致额外的垃圾回收。比如下面这个例子:

let vectorList = new Array(100); 
let vector = new Vector(); 
vectorList.push(vector);

由于 JavaScript 数组的大小是动态可变的,引擎会删除大小为 100 的数组,再创建一个新的大小为200 的数组。垃圾回收程序会看到这个删除操作,说不定因此很快就会跑来收一次垃圾。要避免这种动态分配操作,可以在初始化时就创建一个大小够用的数组,从而避免上述先删除再创建的操作。不过,必须事先想好这个数组有多大。

注意:静态分配是优化的一种极端形式。如果你的应用程序被垃圾回收严重地拖了后腿,可以利用它提升性能。但这种情况并不多见。大多数情况下,这都属于过早优化,因此不用考虑。

4.4 小结

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

  • 原始值大小固定,因此保存在栈内存上。

  • 从一个变量到另一个变量复制原始值会创建该值的第二个副本。

  • 引用值是对象,存储在堆内存上。

  • 包含引用值的变量实际上只包含指向相应对象的一个指针,而不是对象本身。

  • 从一个变量到另一个变量复制引用值只会复制指针,因此结果是两个变量都指向同一个对象。

  • typeof 操作符可以确定值的原始类型,而 instanceof 操作符用于确保值的引用类型。

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

  • 执行上下文分全局上下文、函数上下文和块级上下文。

  • 代码执行流每进入一个新上下文,都会创建一个作用域链,用于搜索变量和函数。

  • 函数或块的局部上下文不仅可以访问自己作用域内的变量,而且也可以访问任何包含上下文乃至全局上下文中的变量。

  • 全局上下文只能访问全局上下文中的变量和函数,不能直接访问局部上下文中的任何数据。

  • 变量的执行上下文用于确定什么时候释放内存。

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

  • 离开作用域的值会被自动标记为可回收,然后在垃圾回收期间被删除。

  • 主流的垃圾回收算法是标记清理,即先给当前不使用的值加上标记,再回来回收它们的内存。

  • 引用计数是另一种垃圾回收策略,需要记录值被引用了多少次。JavaScript 引擎不再使用这种算法,但某些旧版本的 IE 仍然会受这种算法的影响,原因是 JavaScript 会访问非原生 JavaScript 对象(如 DOM 元素)。

  • 引用计数在代码中存在循环引用时会出现问题。

  • 解除变量的引用不仅可以消除循环引用,而且对垃圾回收也有帮助。为促进内存回收,全局对象、全局对象的属性和循环引用都应该在不需要时解除引用。

你可能感兴趣的:(javascript,前端)