第三章 语言基础
3.1 语法
3.1.1 区分大小写
3.1.2 标识符
第一个字符必须是一个字母,下划线或$
3.1.3 注释
3.1.4 严格模式
"use strict"
这是一个预处理指令。任何支持的js看到它都会切换到严格模式。
3.1.5 语句
3.2 关键字与保留字
3.3 变量
var const和let
3.3.1 var关键字
不初始化的情况下,变量会保存一个特殊值undefined。ECMA变量实现变量初始化,因此可以同时定义变量并设置它的值。
var声明作用域
使用var操作符定义的变量会成为包含它的函数的局部变量。比如使用var在一个函数内部定义一个变脸,说明该变量会在函数退出时被销毁
function test(){
var message="hi";//局部变量
}
test();
console.log(message);
在函数内定义变量时省略var操作符,可以创建一个全局变量
function test(){
message="hi"; //全局变量
}
test()
console.log(message);
message变成了全局变量,只要调用一次test(),就会定义这个变量,可以在函数外部访问到。但不推荐这么做,因为在局部作用域中定义的全局变量很难维护。在严格模式下,这样给未声明的变量赋值,则会导致抛出ReferenceError
var变量提升
function foo(){
var age;//这句有没有都行
console.log(age);
age=26;
}
foo();//undefined
多次使用var变量声明同一个变量也没有问题
function foo(){
var age=16;
var age=26;
var age=36;
console.log(age);
}
foo();//36
3.2.2 let声明
和var的区别是let声明的是块作用域,var声明的是函数作用域。
if(true){
var name='matt';
console.log(name);
}
console.log(name);//matt
if(true){
let age=26;
console.log(age);//26
}
console.log(age);//ReferenceError:age没有定义
}
age变量之所以不能在if外部被引用,是因为它的作用域仅限于该块内部。
let不允许同一个块作用域中出现冗余声明,这会导致报错。SyntaxError;标识符age已经声明过了。
嵌套使用相同的标识符不会报错,因为同一个块中没有重复声明。
var name;
let name;//SyntaxError
let age;
var age;//SyntaxError
- 暂时性死区 let和var的另一个重要的区别是let声明的变量不会再作用域中被提升。 let声明之前的执行瞬间被称为暂时性死区,TDZ,在此区间引用任何后面才声明的变量都会抛出ReferenceError/
- 全局声明 与var关键字不同,使用let在全局作用域中声明的变量不会成为window对象的属性(var声明的变量会)。
var name = 'Matt';
console.log(window.name);//'Matt'
let age=26;
console.log(window.age);//undefined
不过,let声明仍然是在全局作用域中发生的,相应变量会在页面的生命周期延续。因此,为了避免SyntaxError,必须保证页面不会重复声明同一个变量。
- 条件声明 在使用var声明变量时,由于声明会被提升,JS引擎会自动将多余的声明在作用域顶部合并为一个声明。因为let的作用域是块,所以不可能检查前面是否已经使用let声明过同名变量。
<script>
var name='N';
let age=26;
</script>
<script>
var name='M';//这里没问题,因为可以被作为一个提升声明来处理
let age=36;//如果age之前声明过,这里会报错
</script>
使用try/catch语句或typeof操作符也不能解决,因为条件块中let声明的作用域仅限于该块。
<script>
let name='n';
let age=36;
</script>
<script>
//假设脚本不确定页面中是否声明了同名变量
//可以假设没有声明过
if(typeof name === 'undefined'){
//name被限制在if的作用域内
//因此这个赋值形同全局赋值
let name;
}
name='M';
try(age){
//如果age没有声明过,会报错
}
catch(error){
//age被限制在catch块的作用域内
//这个赋值形同全局赋值
let age;
}
age=26;
</script>
对于let这个新的es6声明关键字,不能依赖条件声明模式。
- 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,i没有定义
在使用var的时候,最常见的问题就是对迭代变量的奇特声明和修改
for(var i=0;i<5;i++){
setTimeout(()=> console.log(i),0)
}
//输出5,5,5,5,5
因为在退出循环时,跌代变量保存的是导致循环退出的值:5。在之后执行超时逻辑时,所有的i都是同一个变量,因此输出的都是同一个最终值。
而在使用let声明迭代变量时,js引擎会在后台为每个迭代循环声明一个新的迭代变量。每个setTimeout引用的都是不同的变量实例,所以console.log输出的循环执行过程中每个迭代变量的值。
for(var i=0;<5;i++){
setTimeout(()=>console.log(i),0)
}//输出0 1 2 3 4
3.3.3 const声明
const声明与let基本相同,区别是1.用const声明时必须初始化变量2.修改const声明的变量会导致运行时错误。
const age=26;
age=26;//TypeError:给常量赋值
//const也不允许重复声明
const name='Mat';
const name='fff';//SyntaxError
//const声明的作用域是块
const name='Matt'
if(trur){
const name='fff';
}
console.log(name);//Matt
const声明的限制只适用于它指向的变量的引用。也就是说如果const变量引用的是一个对象,那么修改这个对象内部的属性并不违反const的限制。
const person={};
person.name='Matt'; //ok
即使js引擎会为for循环中的let声明分别创建独立的变量实例,而且const变量跟let变量类似,也不能用const来声明迭代变量(因为迭代变量会自增):
for (const i=0;i<10;++i){}//TypeError:给常量赋值
不过,如果用const声明一个不会被修改的for循环变量,是可以的。js引擎会在后台为每个迭代循环声明一个新的迭代变量。这对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
声明风格及最佳实践
ECMA5增加let和const
- 不使用
var限制自己只使用let和const使得变量有了明确的作用域、声明位置和不变的值。 const优先,let次之使用const声明 1.可以让浏览器运行时保持变量不变
2.让静态代码分析工具提前发现不合法的赋值操作
只有在提前知道未来会有修改时,再使用let。
3.4数据类型
六种简单数据类型(原始类型)
undefined,null,boolean,number,string,symbol.
一种复杂类型Object。Object是一种无序名值对的集合。
在ECMAScript中不能定义自己的数据类型,所有的值都可以用上述的7种类型来表示。
3.4.1 typeof操作符
typeof会返回下列字符串之一:
undefinedbooleannumberstringsymbolobject表示值为对象(而不是函数)或nullfunctiontypeof比7种类型多了function,少了nulltypeof null返回的是object。因为特殊值null被认为是一个对空对象的引用。函数在ECMAScript中被认为是对象。
3.4.2 undefined类型
let message;
console.log(message == undefined);//true
比较message和undefined的字面值时,两者是相同的。
等同于
let message=undefined;
console.log(message == undefined);//true
这里变量message显式地以undefined来初始化。
未生声明和未定义是有区别的。
let message;
console.log(message);//undefined
console.log(age);//报错
对于未声明的变量只能执行typeof这一个操作。对未声明和未初始化的变量用typeof都是undefined
let message;
console.log(typeof message);//undefined
console.log(typeof age);//undefined
let message;
if(message){
//不会执行
}
if(!message){
//会执行
}
if(age){
//会报错
}
3.4.3 NULL类型
null表示空对象指针,所以typeof null是object
在定义将来要保存对象值的变量时,建议用null来初始化。这样只要检查这个变量的值是不是null就可以知道这个变量是否在后来被重新赋予了一个对象的引用。
if(car !=null){
//car是一个对象的引用
}
ECMA将undefined和null定义为表面上相等
console.log(null==undefined);//true
永远不必显式地将变量值设置为undefined。但是只要变量要保存对象,又没有对象可保存,就用null填充该变量。null是一个假值。
let message=null;
let age;
if(message){
//不会执行
}
if(!message){
//会执行
}
if(age){
//不会执行
}
if(!age){
//会执行
}
3.4.4 Boolean类型
有两个字面值:true和false。
| 数据类型 | 转为true的值 | 转为false的值 |
|---|---|---|
| Boolean | true | false |
| String | 非空字符串 | "" |
| Number | 非零字符(包括无穷值) | 0,NaN |
| Object | 任意对象 | null |
| Undefined | N/A(不存在) | undefined |
3.4.5 Number类型
let octalNum1 = 070; //8进制的56
let octalNum2 = 079; //无效的8进制,当成79处理
let octalNum3 = 08l //无效的8进制,当成8处理
严格模式下,前缀0会视为错误,要表示8进制,要用0o表示。
要创建16进制字面量,必须让真正的数值前缀0x。
let hexNum1 = 0xA; //16进制10
let hexNum2 = 0x1f; //16进制31
使用8进制和16进制格式创建的数值在所有数学操作中都被是为十进制数值。
由于js保存数值的方式,实际中可能存在正零和负零都被视为等同的。
- 浮点值
浮点值一定要有小数点,小数点后面一定要一个数值。因为存储浮点值使用的内存空间是整数值的两倍,所以ECMA要把值转为整数。在小数点后面没有 数字的情况下,数值会变为整数。
浮点值的精度最高可以达到17位小数,但在算术计算中远不如整数精确。 不要这么干if(a+b==0.3),因为0.1+0.2得到0.300 000 000 000 000 04. - 值的范围
浏览器能存的最小值
Number.MIN_VALUE5e-324
Number.MAX_VALUE
获得正负Infinity
Number.POSITIVE_INFINITY
Number.NEGATIVE_INFINITY
如果超出了js可以表示的范围,那么这个数值会被转为特殊的Infinity(无穷值)。任何无法表示的负数以-Infinity表示。
isFinite()来确定一个值是不是在可以表示的范围之内。 - NaN
有一个特殊的数值叫
NaN,意思是"不是数值"(Not a Number)"。用于表示本来要返回数值的操作失败了。比如,用0除任何数值在其他语言通常会导致错误,从而中止代码执行,在js中会得到NaN。
console.log(0/0)//NaN
console.log(-0/+0)//NaN
console.log(5/0)//Infinity
console.log(5/-0)//-Infinity
NaN有几个特殊属性。
1.任何涉及NaN的操作始终会返回NaN(如NaN/10),在连续多步计算时要注意。
2.NaN不等于任何值
console.log(NaN == NaN);//false
console.log(isNaN(isNaN)); //true
console.log(isNaN(10)); //false,10是数值
console.log(isNaN("10")); //false,可以转为数值10
console.log(isNaN("blue")); //true,不可以转为数值
console.log(isNaN(true)); //false,可以转换为数值1
虽然不常见,但isNaN可以用于测试对象,此时首先调用对象的valueOf()方法,然后再确定是否可以转为数值,如果不能,再调用toString()方法,并测试其返回值。
- 数值转换
1.
Number()是转型函数,可用于任何数值类型。
2.parseInt()将字符串转为数值。
3.parseFloat()将字符串转为数值。
Number()函数基于如下规则执行转换。
1.布尔值
2.数值,直接返回
null,返回0 4.undefined, 返回NaN
5.字符串。Number("123")返回123,Number("011")返回11- 浮点值
- 十六进制如
0xf,转为该十六进制对应的十进制。 - 空字符返回0
- 字符串包含除上述情况之外的其他字符,返回NaN
6.对象,调用valueOf()方法,按照上述规则转换返回的值。如果转换结果是NaN,则调用toString()方法,再按照转换字符串的规则转换。
let num1=Number("Hello world!");//NaN
考虑到用Number()函数转换字符串时相对复杂,通常在需要得到函数时可以优先使用parseInt()函数,该函数更专注于字符串是否包含数值模式,字符串最前面的空格会被忽略,从第一个非空格字符开始转换,如果第一个字符不是数值字符、加号或减号,parseInt()立即返回NaN。这意味着空字符串也会返回NaN(Number()返回0)。r如果第一个字符是数值字符、加号或减号,则继续一次检测每个字符,直到字符串末尾,或碰到非数值字符。比如1234blue会被转为1234。22.5会被转为22,因为小数点不是有效的整数字符。
假设字符串中的第一个字符是数值字符,parseInt()函数也能识别不同的整数格式(十进制、八进制、十六进制)。也就是说,如果字符串以0x开头,会被解释为十六进制整数,如果以0开头,且紧跟数值字符,会被解释为0进制整数。
不同的数值格式很容易混淆,因此parseInt()也接受第二个参数,用于指定底数(进制数).如果知道要解析的值是十六进制数,那么可以传入16作为第二个参数,以便正确解析。
let num = parseInt("0xAF",16);//175
事实上,如果提供了十六进制参数,那么字符串前面的0x可以省略掉。因为不传底数参数相当于让parseInt()自己决定如何解析,为避免出错,建议始终传给它第二个参数。
parseFloat()函数的工作方式跟parseInt()函数类型,都是从位置0开始检测每个字符,同样,它也是解析到字符串末尾或者解析到一个无效的浮点数值字符位置。这意味着第一次出现的小数点是有效的,第二次出现的小数点就无效了。22.34.5将被转为22.34。parseFloat()与别的函数不同之处在于,它始终忽略字符串开头的零。这个函数能识别前面讨论的所有浮点格式,以及十进制格式。所以十六进制数值始终返回0,因为parseFloat()始终值解析十进制数,因此不能指定底数。
let num1=parseFloat("1234blue");//1234
let num2=parseFloat("0xA")l//0
let num3=parseFloat("22.34.5");//22.34
let num5=parseFloat("0908.5");//908.5
let num6=parseFloat("3.125e7")//31250000
3.4.6 String类型
1.字符串字面量
| 字面量 | 含义 |
|---|---|
| \n | 换行 |
| \t | 制表 |
| \b | 退格 |
| \r | 回车 |
| \f | 换页 |
| \\ | 反斜杠() |
| ' | 单引号(') |
| " | 单引号(") |
| \' | 单引号('),在字符串以反引号标示时使用 |
| \xnn | 以十六进制编码nn表示的字符,\x41等于A |
转义序列表示一个字符,所以只算一个字符,\u03a3虽然6个字符长,但是只算一个。
如果字符串中包含双字节字符,那么length属性返回的值可能不是准确的字符数。
- 字符串的特点
ECMAScript中的字符串是不可变的。意思是要想修改,必须先销毁原先的字符串,然后将包含新值的另一个字符串保存到该变量。
let lang="Java";
lang = lang + "Java";
整个过程会先分配一个足以容纳10个字符的空间,然后填充上"Java"和"Script"。最后销毁原始的"Java"和"Script"。因为这两个字符串都没有用了,所有的处理都是在后台发生的。
- 转换为字符串
有两种方式把一个值转为字符串。toString()方法使用于数值、布尔值、对象和字符串值。toString()可以接收一个底数参数,例如传入16。
let num=10;
console.log(num.toString(16)); // "a"
String()转型函数,会始终返回相应类型值的字符串。String()函数遵循一下规则1.如果值有toString()方法,调用该方法,并返回结果,2.如果值是null,返回"null".3.如果值是undefined,返回"undefined".
let value4;
console.log(String(value4));//"undefined"
用加号操作给一个值加上一个空字符串""也可以将其转为字符串
- 模板字面量
ECMAScript 6新增了使用模板字面量定义字符串的能力。与使用单引号或双引号不同,模板字面量保留换行字符,可以跨行定义字符串:
let myMultiLineString = 'first line\n
secondline';
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
顾名思义,模板字面量在定义模板时特别有用,比如下面这个HTML模板:
let pageHTML = `
<div>
<a href="#">
<span>Jake</span>
</a>
</div>`;
由于模板字面量会保持反引号内部的空格,因此在使用时要格外注意。格式正确的模板字符串可能会看起来缩进不当:
// 这个模板字面量在换行符之后有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[0]);
// first line
// second line
5.字符串插值
模板字面量最常用的一个特性是支持字符串插值。模板字面量不是字符串,而是一种特殊的JavaScript语句表达式,只不过求值后得到的是字符串。模板字面量在定义时立即求值并转换为字符串实例,任何插入的变量也会从它们最接近的作用域中取值。字符串插值通过在${}中使用一个JavaScript表达式实现。
let value = 5;
let exponent = 'second';
// 以前,字符串插值是这样实现的:
let interpolatedString = value + ' to the ' + exponent + ' power is ' + (value * value);
// 现在,可以用模板字面量这样实现:
let interpolatedTemplateLiteral = `${ value } to the ${ exponent } power is ${ value * value }`;
console.log(interpolatedString);
//5 to the second power is 25
console.log(interpolatedTemplateLiteral);
//5 to the second power is 25
所有插入的值都会使用toString()强制转型为字符串,任何js表达式都可以用于插值。嵌套的模板字符串无须转义。
console.log('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('word')!'};//Hello,World!
此外,模板也可以插入自己之前的值:
let value='';
function append(){
value='${value}abc';
console.log(value);
}
append();//abc
append();//abcabc
append();//abcabcabc
6.模板字面量标签函数
模板字面量可以通过标签函数自定义插值行为。标签函数会接收被插值记号分隔后的模板和对每个表达式求值的结果。 标签函数本身是一个常规函数,通过前缀到模板字面量来应用自定义行为,如下例所示。标签函数接收到的参数依次是原始字符串数组和对每个表达式求值的结果。这个函数的返回值是对模板字面量求值得到的字符串
let a=6;
let b=9;
function simpleTag(strings,aValExpression,bvalExpression,sumExpression){
console.log(strings)
console.log(aValExpression)
console.log(bvalExpression)
console.log(sumExpression)
return 'foobar';
}
let untaggedResult='${a}+${b}=${a+b}';
let taggedResult='${a}+${b}=${a+b}';
//["","+","=",""]
console.log(untaggedResult);//"6+9=15"
console.log(taggedResult);//"foobar"
因为表达式参数的数量是可变的,所以通常应该使用剩余操作符(rest operator)将它们收集到一个数组中:
let a=6;
let b=9;
function simpleTag(strings,...expressions){
console.log(strings);
for(const expression of expressions){
console.log(expression);
}
return 'foobar';
}
let taggedResult=simpleTag'${a}+${b}=${a+b}';
//["","+","=",""]
console.log(taggedResult);//"foobar"
如果想把字符串和对表达式求值的结果拼接起来作为默认返回的字符串,可以这样做:
let a=6;
let b=9;
function zipTag(strings,...expressions){
return string[0]+expression.map((e,i)=>
'${e}${strings[i+1]}').join('');
}
let untaggedResult='${a}+${b}=${a+b}';
let taggedResult=zipTag'${a}+${b}=${a+b}';
console.log(untaggedResult);//"6+9=15"
console.log(taggedResult);//"6+9=15"
7.原始字符串
使用模板字面量也可以直接获取原始的模板字面量内容(如换行符或Unicode字符),而不是被转换后的字符表示。
console.log('\u00A9');//c
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
也可以通过标签函数的第一个参数,即字符串数组的.raw属性取得每个字符串的原始内容:
function printRaw(strings)
for (const string of strings){
console.log(string);
}
for(const rawString of strings.raw){
console.log(rawString);
}
}
printRaw '\u00A9${ 'and' }\n';
// ©
//(换行符)
// \u00A9
// \n
3.4.7 Symbol类型
Symbol是ECMA6新增的数据类型。符号是原始值,且符号实力是唯一,不可变的。用途是确保对象属性使用唯一标识符,不会发生属性冲突的危险。符号是用来创建唯一记号,从而用作非字符串形式的对象属性。
- 符号的基本用法
符号需要使用
Symbol()函数初始化。因为符号本身是原始类型,所以typeof操作符对符号返回symbol。
let sym = Symbol();
调用Symbol()函数时,也可以传入一个字符串参数作为对符号的描述,将来可以通过这个字符串来调试代码。
let genericSymbol = Symbol();
let otherGenericSymbol = Symbol();
let fooSymbol = Symbol('foo');
let otherFooSymbol = Symbol('foo');
console.log(genericSymbol ==otherGenericSymbol); // false
console.log(fooSymbol == otherFooSymbol);
// false
符号没有字面量语法,这也是它们发挥作用的关键。按照规范, 你只要创建 Symbol() 实例并将其用作对象的新属性,就可以 保证它不会覆盖已有的对象属性,无论是符号属性还是字符串属 性。
let genericSymbol = Symbol();
console.log(genericSymbol); // Symbol()
let fooSymbol = Symbol('foo');
console.log(fooSymbol); // Symbol(foo);
最重要的是, Symbol() 函数不能用作构造函数,与 new 关键 字一起使用。这样做是为了避免创建符号包装对象,像使用Boolean 、 String 或 Number 那样,它们都支持构造函数 且可用于初始化包含原始值的包装对象:
let myBoolean = new Boolean();
console.log(typeof myBoolean); // "object"
let myString = new String();
console.log(typeof myString); // "object"
let myNumber = new Number();
console.log(typeof myNumber); // "object"
let mySymbol = new Symbol(); // TypeError:
Symbol is not a constructor
如果你确实想使用符号包装对象,可以借用 Object() 函数:
let mySymbol = Symbol();
let myWrappedSymbol = Object(mySymbol);
console.log(typeof myWrappedSymbol); //
"object"
- 使用全局符号注册表
如果运行时的不同部分需要共享和重用符号实例,那么可以用一个字符串作为键,在全局符号注册表中创建并重用符号。
为此,需要使用Symbol.for()方法:
let fooGlobalSymbol = Symbol.for('foo');
console.log(typeof fooGlobalSymbol); //
symbol
Symbol.for() 对每个字符串键都执行幂等操作。第一次使用
某个字符串调用时,它会检查全局运行时注册表,发现不存在对
应的符号,于是就会生成一个新符号实例并添加到注册表中。后
续使用相同字符串的调用同样会检查注册表,发现存在与该字符
串对应的符号,然后就会返回该符号实例。
let fooGlobalSymbol = Symbol.for('foo');
// 创建新符号
let otherFooGlobalSymbol = Symbol.for('foo');
// 重用已有符号
console.log(fooGlobalSymbol ===otherFooGlobalSymbol); // true
即使采用相同的符号描述,在全局注册表中定义的符号跟使用
Symbol() 定义的符号也并不等同:
let localSymbol = Symbol('foo');
let globalSymbol = Symbol.for('foo');
console.log(localSymbol === globalSymbol); //false
全局注册表中的符号必须使用字符串键来创建,因此作为参数传
给 Symbol.for() 的任何值都会被转换为字符串。此外,注册表中使用的键同时也会被用作符号描述。
let emptyGlobalSymbol = Symbol.for();
console.log(emptyGlobalSymbol); //Symbol(undefined)
还可以使用 Symbol.keyFor() 来查询全局注册表,这个方法 接收符号,返回该全局符号对应的字符串键。如果查询的不是全 局符号,则返回 undefined 。
// 创建全局符号
let s = Symbol.for('foo');
console.log(Symbol.keyFor(s)); // foo
// 创建普通符号
let s2 = Symbol('bar');
console.log(Symbol.keyFor(s2)); // undefined
如果传给 Symbol.keyFor() 的不是符号,则该方法抛出
TypeError :
Symbol.keyFor(123); // TypeError: 123 is not a symbol
- 使用符号作为属性
凡是可以使用字符串或数值作为属性的地方,都可以使用符号。
这就包括了对象字面量属性和
Object.defineProperty() /Object.definedProperties()定义的属性。对象字面量只能在计算属性语法中使用符号作为属性。
let s1 = Symbol('foo'),
s2 = Symbol('bar'),
s3 = Symbol('baz'),
s4 = Symbol('qux');
let o = {
[s1]: 'foo val'
};
// 这样也可以:o[s1] = 'foo val';
console.log(o);
// {Symbol(foo): foo val}
Object.defineProperty(o, s2, {value: 'bar val'});
console.log(o);
// {Symbol(foo): foo val, Symbol(bar): bar val}
Object.defineProperties(o, {
[s3]: {value: 'baz val'},
[s4]: {value: 'qux val'}
});console.log(o);
// {Symbol(foo): foo val, Symbol(bar): bar val,
// Symbol(baz): baz val, Symbol(qux): qux val}
类似于 Object.getOwnPropertyNames() 返回对象实例的 常规属性数组, Object.getOwnPropertySymbols() 返回 对象实例的符号属性数组。这两个方法的返回值彼此互斥。 Object.getOwnPropertyDescriptors() 会返回同时包含 常规和符号属性描述符的对象。 Reflect.ownKeys() 会返回 两种类型的键:
let s1 = Symbol('foo'),
s2 = Symbol('bar');
let o = {
[s1]: 'foo val',
[s2]: 'bar val',
baz: 'baz val',
qux: 'qux val'
};
console.log(Object.getOwnPropertySymbols(o));
// [Symbol(foo), Symbol(bar)]
console.log(Object.getOwnPropertyNames(o));
//["baz","qux"]
console.log(Object.getOwnPropertyDescriptors(o));
/* {baz: {...}, qux: {...}, Symbol(foo):{...},Symbol(bar): {...}}*/
console.log(Reflect.ownKeys(o));
// ["baz", "qux", Symbol(foo), Symbol(bar)]
因为符号属性是对内存中符号的一个引用,所以直接创建并用作 属性的符号不会丢失。但是,如果没有显式地保存对这些属性的 引用,那么必须遍历对象的所有符号属性才能找到相应的属性 键:
let o = {
[Symbol('foo')]: 'foo val',
[Symbol('bar')]: 'bar val'
};
console.log(o);
// {Symbol(foo): "foo val", Symbol(bar): "bar val"}
let barSymbol = Object.getOwnPropertySymbols(o).find((symbol) =>
symbol.toString().match(/bar/));
console.log(barSymbol);
// Symbol(bar)
- 常用内置符号
ECMA引入了一批常用内置符号(well-known symbol),用于暴露语言内部行为,开发者可以直接访问、重写或模拟这些行为。这些内置符号都以
Symbol工厂函数字符串属性的形式存在。这些内置符号最重要的用途之一是重新定义它们,从而改变原生结构的行为。比如,我们知道for-of循环会在相关对象上使用Symbol.iterator属性,那么就可以通过在自定义对象上重新定义Symbol.iterator的值,来改变for-of在迭代该对象时的行为。这些内置符号也没有什么特别之处,它们就是全局函数 Symbol的普通字符串属性,指向一个符号的实例。所有内置符号属性都是不可写、不可枚举、不可配置的。
在提到ECMAScript规范时,经常会引用符号在规范中的名称,前缀为@@。比如,@@iterator指的就是Symbol.iterator。 - Symbol.asyncIterator
根据ECMAScript规范,这个符号作为一个属性表示“一个方法,该方法返回对象默认的
AsyncIterator,由for-await-of语句使用。”换句话说,这个符号表示实现异步迭代器API的函数。for-await-of循环会利用之歌函数执行异步迭代操作。循环时,它们会调用以Symbol.asyncIterator为键的函数,并期望这个函数会返回一个实现迭代器API的对象。很多时候,返回的对象是实现该API的AsyncGenerator:
class Foo{
async *[Symbol.asyncIteraor](){}
}
let f=new Foo();
console.log(f[Symbol.asyncIteraor]());
// AsyncGenerator {<suspended>}
技术上,这个由Symbol.asyncIterator函数生成的对象应该通过其next()方法陆续返回Promise实例。可以通过显式地调用next()方法返回,也可以隐式地通过异步生成器函数返回:
class Emitter{
constructor(max){
this.max=max;
this.asyncIdx=0;
}
async *[Symbol.asyncIterator](){
while(this.asyncIdx > this.max) {
yield new Promise((resolve)=>
resolve(this.asyncIdx++));
}
}
}
async function asyncCount(){
let emitter = new Emitter(5);
for await(const x of emitter){
console.log(x);
}
}
}
asyncCount();
// 0
// 1
// 2
// 3
// 4
- Symbol.hasInstance
根据ECMAScript规范,这个符号作为一个属性表示“一个方法,该方法决定一个构造器对象是否认可一个对象时它的实例。由
instanceof操作符使用,可以用来确认一个对象实例的原型链上是否有原型。
function Foo(){}
let f= new Foo();
console.log(f instanceof Foo);//true
class Bar{}
let b=new Bar();
console.log(b instancof Bar);//true
在ES6中,instanceof操作符会使用Symbol.hasInstance 函数来确定关系。以Symbol.hasInstance 为键的函数会执行同样的操作,只是操作数对调了一下:
function Foo(){}
let f=new Foo();
console.log(Foo[Symbol.hasInstance](f));//true
class Bar(){}
let b=new Bar();
console.log(Bar[Symbol.hasInstance](b));//true
这个属性定义在Function的原型上,因此默认在所有函数和类上都可以调用。在instanceof操作符会在原型链上寻找这个属性定义,就跟在原型链上寻找其他属性一样,因此可以在继承的类上通过静态方法重新定义一个这个函数:
class Bar{
class Baz extends Bar{
static [Symbol/hasInstance](){
return false;
}
}
let b=new Baz();
console.log(Bar[Symbol.hasInstance](b));//true
console.log(b instanceof Bar);//false;
console.log(Baz[Symbol.hasInstance](b));//false;
console.log(b instanceof Baz);//false
- Symbol.isConcatSpreadable
根据ECMAScript规范,这个符号作为一个属性表示"一个布尔值",如果
true,则意味着对象应该用Array.prototype.concat()打平其数组元素"。ES6中的Array.prototype.concat()会根据接收到的对象类型选择如何将一个类数组对象拼接成数组实例。覆盖Symbol.isConcatSpreadable的值可以修改这个行为。数组对象默认情况下会被打平到已有的数组,false或假值或假值会导致整个对象被追加到数组末尾,类数组对象默认情况下会被追加到数组末尾,true或真值会导致这个类数组对象被打平到数组实例。其他不是类数组对象的对象在Symbol.isConcatSpreadable被设置为true的情况下会被忽略。
let initial = ['foo'];
let array = ['bar'];
console.log(array[Symbol.isConcatSpreadable]); // undefined
console.log(initial.concat(array));
// ['foo', 'bar']
array[Symbol.isConcatSpreadable] = false;
console.log(initial.concat(array));
// ['foo', Array(1)]
let arrayLikeObject = { length: 1, 0: 'baz'};
console.log(arrayLikeObject[Symbol.isConcatSpreadable]);
// undefined
console.log(initial.concat(arrayLikeObject));
// ['foo', {...}]
arrayLikeObject[Symbol.isConcatSpreadable]=true;
console.log(initial.concat(arrayLikeObject));
// ['foo', 'baz']
let otherObject = new Set().add('qux');
console.log(otherObject[Symbol.isConcatSpreadable]);
// undefined
console.log(initial.concat(otherObject));
// ['foo', Set(1)]
otherObject[Symbol.isConcatSpreadable] =true;
console.log(initial.concat(otherObject));
// ['foo']
- Symbol.iterator
根据ECMAScript规范,这个符号作为一个属性表示"一个方法,该方法返回对象默认的迭代器。由
for-of语句使用”。换句话说,这个符号表示实现迭代器API的函数。for-of循环这样的语言结构会利用这个函数执行迭代操作。循环时,它们会调用以Symbol.iterator为键的函数,并默认这个函数会返回一个实现迭代器API的对象。很多时候,返回的对象是实现该API的Generator:
class Foo{
*[Symbol.iterator](){}
}
let f = new Foo();
console.log(f[Symbol.iteraor]());
// Generator {<suspended>}
技术上,这个由Symbol.iterator函数生成的对象应该通过其next()方法陆续返回值。
class Emitter{
constructor(max){
this.max=max;
this.idx=0;
}
*[Symbol.iterator](){
while(this.idx<this.max){
yield this.idx++;
}
}
}
function count(){
let emitter = new Emitter(5);
for(const x of emitter){
console.log(x)
}
}
count();
//0
//1
//2
//3
//4
- Symbol.match
根据ECMAScript规范,这个符号作为一个属性表示:一个正则表
达式方法,该方法用正则表达式去匹配字符串。由
String.prototype.match() 方法使用String.prototype.match() 方法会使用以
Symbol.match 为键的函数来对正则表达式求值。正则表达式
的原型上默认有这个函数的定义,因此所有正则表达式实例默认
是这个 String 方法的有效参数:
console.log(RegEXP.prototype[Symbol.match]);
//f [Symbol.match](){[native code]}
console.log('footbar'.match(/bar/));
//["bar", index: 3, input: "foobar", groups:undefined]
给这个方法传入非正则表达式值会导致该值被转换为 RegExp 对
象。如果想改变这种行为,让方法直接使用参数,则可以重新定
义 Symbol.match 函数以取代默认对正则表达式求值的行为,
从而让 match() 方法使用非正则表达式实例。Symbol.match 函数接收一个参数,就是调用 match() 方法的字符串实例。返回的值没有限制:
class FooMatcher {
static [Symbol.match](target) {
return target.includes('foo');
}
}
console.log('foobar'.match(FooMatcher)); //true
console.log('barbaz'.match(FooMatcher)); //false
class StringMatcher {
constructor(str) {
this.str = str;
}
[Symbol.match](target) {
return target.includes(this.str);
}
}
console.log('foobar'.match(new StringMatcher('foo'))); // true
console.log('barbaz'.match(new StringMatcher('qux'))); // false
- Symbol.replace
根据ECMAScript规范,这个符号作为一个属性表示“一个正则表达式方法,该方法替换一个字符串中匹配的子串。由
String.prototype.replace()方法使用”。String.prototype.replace()方法会使用以Symbol.replace为键的函数来对正则表达式求值。正则表达 式的原型上默认有这个函数的定义,因此所有正则表达式实例默 认是这个 String 方法的有效参数:
console.log(RegExp.prototype[Symbol.replace]);
// f [Symbol.replace]() { [native code] }
console.log('foobarbaz'.replace(/bar/,'qux'));
// 'fooquxbaz'
给这个方法传入非正则表达式值会导致该值被转换为 RegExp 对
象。如果想改变这种行为,让方法直接使用参数,可以重新定义
Symbol.replace 函数以取代默认对正则表达式求值的行为,
从而让 replace() 方法使用非正则表达式实例。Symbol.replace 函数接收两个参数,即调用 replace() 方法的字符串实例和替换字符串。返回的值没有限制:
class FooReplacer {
static [Symbol.replace](target,replacement) {
return target.split('foo').join(replacement);
}
}
console.log('barfoobaz'.replace(FooReplacer,'qux'));
// "barquxbaz"
class StringReplacer {
constructor(str) {
this.str = str;
}
[Symbol.replace](target, replacement) {
return target.split(this.str).join(replacement);
}
}
console.log('barfoobaz'.replace(new StringReplacer('foo'), 'qux'));
// "barquxbaz"
- Symbol.search
根据ECMAScript规范,这个符号作为一个属性表示“一个正则表达式方法,该方法返回字符串中匹配正则表达式的索引。由
String.prototype.search()方法使用”。String.prototype.search()方法会使用以Symbol.search为键的函数来对正则表达式求值。正则表达式的原型上默认有这个函数的定义,因此所有正则表达式实例默认是这个String方法的有效参数:
console.log(RegExp.prototype[Symbol.search]);
// f [Symbol.search]() { [native code] }
console.log('foobar'.search(/bar/));
// 3
给这个方法传入非正则表达式值会导致该值被转换为 RegExp 对象。如果想改变这种行为,让方法直接使用参数,可以重新定义
Symbol.search 函数以取代默认对正则表达式求值的行为,
从而让 search() 方法使用非正则表达式实例。
Symbol.search 函数接收一个参数,就是调用 match() 方法的字符串实例。返回的值没有限制:
class FooSearcher {
static [Symbol.search](target) {
return target.indexOf('foo');
}
}
console.log('foobar'.search(FooSearcher)); //0
console.log('barfoo'.search(FooSearcher)); //3
console.log('barbaz'.search(FooSearcher)); //-1
class StringSearcher {
constructor(str) {
this.str = str;
}
[Symbol.search](target) {
return target.indexOf(this.str);
}}
console.log('foobar'.search(new StringSearcher('foo'))); // 0
console.log('barfoo'.search(new StringSearcher('foo'))); // 3
console.log('barbaz'.search(new StringSearcher('qux'))); // -1
- Symbol.species
根据ECMAScript规范,这个符号作为一个属性表示“一个函数
值,该函数作为创建派生对象的构造函数”。这个属性在内置类型
中最常用,用于对内置类型实例方法的返回值暴露实例化派生对
象的方法。用
Symbol.species定义静态的获取器(getter)方法,可以覆盖新创建实例的原型定义:
class Bar extends Array {}
class Baz extends Array {
static get [Symbol.species]() {
return Array;
}
}
let bar = new Bar();
console.log(bar instanceof Array); // true
console.log(bar instanceof Bar); // true
bar = bar.concat('bar');
console.log(bar instanceof Array); // true
console.log(bar instanceof Bar); // true
let baz = new Baz();
console.log(baz instanceof Array); // true
console.log(baz instanceof Baz); // true
baz = baz.concat('baz');
console.log(baz instanceof Array); // true
console.log(baz instanceof Baz); // false
- Symbol.split
根据ECMAScript规范,这个符号作为一个属性表示“一个正则表
达式方法,该方法在匹配正则表达式的索引位置拆分字符串。由
String.prototype.split()方法使用”。String.prototype.split()方法会使用以Symbol.split为键的函数来对正则表达式求值。正则表达式 的原型上默认有这个函数的定义,因此所有正则表达式实例默认 是这个String方法的有效参数:
console.log(RegExp.prototype[Symbol.split]);
// f [Symbol.split]() { [native code] }
console.log('foobarbaz'.split(/bar/));
// ['foo', 'baz']
给这个方法传入非正则表达式值会导致该值被转换为 RegExp 对
象。如果想改变这种行为,让方法直接使用参数,可以重新定义
Symbol.split 函数以取代默认对正则表达式求值的行为,从
而让 split() 方法使用非正则表达式实例。 Symbol.split函数接收一个参数,就是调用 match() 方法的字符串实例。返回的值没有限制:
class FooSplitter {
static [Symbol.split](target) {
return target.split('foo');
}
}
console.log('barfoobaz'.split(FooSplitter));// ["bar", "baz"]
class StringSplitter {
constructor(str) {
this.str = str;
}
[Symbol.split](target) {
return target.split(this.str);
}
}
console.log('barfoobaz'.split(new StringSplitter('foo')));
// ["bar", "baz"]
- Symbol.toPrimitive
根据ECMAScript规范,这个符号作为一个属性表示“一个方法,
该方法将对象转换为相应的原始值。由
ToPrimitive抽象操作 使用”。很多内置操作都会尝试强制将对象转换为原始值,包括字 符串、数值和未指定的原始类型。对于一个自定义对象实例,通过在这个实例的Symbol.toPrimitive属性上定义一个函数 可以改变默认行为。根据提供给这个函数的参数(string、number或default),可以控制返回的原始值:
class Foo {}
let foo = new Foo();
console.log(3 + foo); // "3[objectObject]"
console.log(3 - foo); // NaN
console.log(String(foo)); // "[objectObject]"
class Bar {
constructor() {
this[Symbol.toPrimitive] = function(hint)
{
switch (hint) {
case 'number':
return 3;
case 'string':
return 'string bar';
case 'default':
default:
return 'default bar';
}
}
}
}
let bar = new Bar();
console.log(3 + bar); // "3default bar"
console.log(3 - bar); // 0
console.log(String(bar)); // "string bar"
- Symbol.toStringTag
根据ECMAScript规范,这个符号作为一个属性表示“一个字符
串,该字符串用于创建对象的默认字符串描述。由内置方法
Object.prototype.toString()使用”。通过toString()方法获取对象标识时,会检索由Symbol.toStringTag指定的实例标识符,默认为"Object"。内置类型已经指定了这个值,但自定义类实例,还需要明确定义:
let s = new Set();
console.log(s); //Set(0) {}
console.log(s.toString()); //[object Set]
console.log(s[Symbol.toStringTag]); // Setclass Foo {}
let foo = new Foo();
console.log(foo); // Foo{}
console.log(foo.toString()); //[object Object]
console.log(foo[Symbol.toStringTag]); //undefined
class Bar {
constructor() {
this[Symbol.toStringTag] = 'Bar';
}
}
let bar = new Bar();
console.log(bar); // Bar{}
console.log(bar.toString()); //[object Bar]
console.log(bar[Symbol.toStringTag]); // Bar
- Symbol.unscopables
根据ECMAScript规范,这个符号作为一个属性表示“一个对象,
该对象所有的以及继承的属性,都会从关联对象的
with环境绑定中排除”。设置这个符号并让其映射对应属性的键值为true,就可以阻止该属性出现在with环境绑定中,如下例所示:
let o = { foo: 'bar' };
with (o) {
console.log(foo); // bar
}
o[Symbol.unscopables] = {
foo: true
};
with (o) {
console.log(foo); // ReferenceError
}
3.4.8 Object类型
ECMAScript中的对象其实就是一组数据和功能的集合。对象通 过 new 操作符后跟对象类型的名称来创建。开发者可以通过创建 Object 类型的实例来创建自己的对象,然后再给对象添加属性和方法:
let o = new Object();
类似Java中的 java.lang.Object ,ECMAScript中的
Object 也是派生其他对象的基类。 Object 类型的所有属性和方法在派生的对象上同样存在。
每个 Object 实例都有如下属性和方法。
-
constructor:用于创建当前对象的函数。在前面的例子中,这个属性的值就是Object()函数。 -
hasOwnProperty(propertyName):用于判断当前对象实例(不是原型)上是否存在给定的属性。要检查的属性名必须是字符串(如 o.hasOwnProperty("name") )。 -
isPrototypeof(object):用于判断当前对象是否为另一个对象的原型。(第5章将详细介绍原型。) -
propertyIsEnumerable(propertyName):用于判断给定的属性是否可以使用(本章稍后讨论的) for-in 语句枚举。与hasOwnProperty()一样,属性名必须是字符串。 -
toLocaleString():返回对象的字符串表示,该字符串反映对象所在的本地化执行环境。 -
toString():返回对象的字符串表示。valueOf():返回对象对应的字符串、数值或布尔值表示。通常与toString()的返回值相同。 -
valueOf():返回对象对应的字符串、数值或布尔值表示。通常与toString()的返回值相同。
因为在ECMAScript中 Object 是所有对象的基类,所以任何对象都有这些属性和方法。第5章和第6章将介绍对象间的继承机制。
严格来讲,ECMA-262中对象的行为不一定适合JavaScript中的其他对象。比如浏览器环境中的BOM和DOM对象,都是由宿主环境定义和提供的宿主对象。而宿主对象不受ECMA-262约束,所以它们可能会也可能不会继承 Object 。
3.5 操作符
在应用给对象时,操作符通常会调用valueOf()和toString来取得可以计算的值。
3.5.1 一元操作符
只操作一个值的操作符。
-
对于字符串,如果是有效的数值形式,则转换为数值再应用改变。变量类型从字符串变成数值。
-
对于字符串,如果不是有效的数值形式,则将变量的值设置为
NaN。变量类型从字符串变成数值。 -
对于布尔值,如果是
false,则转换为0再应用改变。变量类型从布尔值变成数值。 -
对于布尔值,如果是
true,则转换为1再应用改变。变量类型从布尔值变成数值。 -
对于浮点值,加1或减1。
-
如果是对象,则调用其(第5章会详细介绍的)
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
- 一元加和减 一元加放在变量前头对数值没有任何影响。
let num=25;
num=+num;
console.log(num);//25
如果将一元减用到非数值,对象会调用它们的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
一元减由一个减号(-)表示,放在变量牵头,主要用于把数值变成负值,如把1转换为-1.
let num=25;
num=-num;//-25
3.5.2 位操作符
操作内存中表示数据的比特(位)。ECMAScript中的所有数值都以IEEE 754 64位格式存储,但未操作并不直接应用到64位表示,二十把值转换位32位整数,再进行位操作,之后再把结果转为64位。
有符号整数使用32位的前31位表示整数值。第32位表示数值的符号,如0表示正,1表示负。这一位称为符号位,它的值决定了数值其余部分的格式。正值以真正的二进制格式存储,即31位中的每一位都表示2的幂。
负值以一种称为二补数的二进制编码存储。一个数值的二补数通过如下3个步骤计算得到:
- 确定绝对值的二进制表示
- 找到数值的一补数,1变0,0变1
- 给结果加1 -18的二进制表示过程 首先是18的二进制表示
0000 0000 0000 0000 0000 0000 0001 0010
求补数 先反转
1111 1111 1111 1111 1111 1111 1110 1101
补数加1
1111 1111 1111 1111 1111 1111 1110 1110
所以-18的二进制表示是1111 1111 1111 1111 1111 1111 1110 1110。
let num=-18
console.log(num.toString(2));//"-10010"
在将-18转为二进制字符串时,结果得到-10010.转换过程会求得二补数,然后以更符合逻辑的形式表示出来。NaN和Infinity在位操作时会被当做0处理。
如果将位操作符应用到非数值,那么首先会使用Number()函数将该值转换为数值,然后再应用位操作,最终结果是数值。
- 按位非
~
let num1=25;
0000 0000 0000 0000 0000 0000 0001 1001
let num2= ~num1;
1111 1111 1111 1111 1111 1111 1110 0110
console.log(num2); //-26
- 按位与
& - 按位或
| - 按位异或
^ - 左移
<<
let oldValue = 2; //等于二进制10
let newValue = oldValue << 5; //等于二进制1000000,即十进制64
左移会保留它所操作数值的符号,比如,如果-2左移5位,将得到-64,而不是正64。
- 有符号右移
>>,会将数值的所有32位都向右移动,同时保留符号。 - 无符号右移动
>>>,会将数值的所有32位都向右移动,对于整数,无符号右移和有符号右移结果相同。对于负数,无符号右移会给空位补0,对于正数来说没有差别;对于负数,无符号右移操作符将负数的二进制表示当成正数的二进制表示来处理。因为负数是其绝对值的二补数,所以右移之后结果会变得非常大。
let oldValue = -64; //等于二进制
1111 1111 1111 1111 1111 1111 1100 0000
let newValue = oldValue >>> 5; //等于十进制
134217726
3.5.3 布尔操作符
- 逻辑非
如果操作数是对象,则返回
false
如果操作数是空字符串,则返回true
如果操作数是非空字符串,则返回false
如果操作数是数值0,则返回true
如果操作数是非0数值(包括Infinity),则返回false
如果操作数是null,则返回true
如果操作数是NaN,则返回true
如果操作数是undefined,则返回true
逻辑非操作符也可以用于把任意值转换为布尔值。同时使用两个叹号(!!),相当于调用了转型函数Boolean()。无论操作数是什么类型,第一个叹号总会返回布尔值。第二个叹号对该布尔值取反。 - 逻辑与 逻辑与是一种短路操作符,如果第一个操作数决定了结果,那么永远不会对第二个操作数求值。
let found = true;
let result = (found && some);//这里会报错,因为some没有定义
console.log(result);//不会执行这一行
let found = false;
let result = (found && some);//不会报错
console.log(result);//会执行这一行
- 逻辑或 如果有一个操作数不是布尔值,那么逻辑或操作符不一定返回布尔值。
- 第一个操作数是对象,则返回第一个操作数
- 第一个操作数是
false,返回第二个操作数, - 如果两个操作数都是对象,则返回第一个操作数
- 如果两个操作数都是
null,则返回null - 如果两个操作数都是
NaN,则返回NaN - 如果两个操作数都是
undefined,则返回undefined逻辑或也有短路的特性。第一个操作数求值为true,第二个操作数就不会再被求值了。
3.5.4 乘性操作符
- 乘法操作符
如果
ECMAScript不能表示乘积,则返回Infinity和-Infinity如果有任一操作数是NaN,则返回NaN
如果是Infinity乘以0,则返回NaN
如果是Infinity乘以非0的有限数值,则根据第二个操作数的符号返回Infinity或-Infinity。
如果是Infinity乘以Infinity,则返回Infinity. - 除法操作符
如果有任一操作数是NaN,则返回NaN。
如果是 Infinity 除以Infinity,则返回NaN。
如果是0除以0,则返回NaN。 - 乘法操作符
let result = 26 % 5; // 等于1
3.5.5 指数操作符
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
3.5.6 加性操作符
1.加法操作符
如果有任一操作数是 NaN ,则返回 NaN
如果是 Infinity 加 Infinity ,则返回 Infinity
如果是 -Infinity 加 -Infinity ,则返回 -Infinity
如果是 Infinity 加 -Infinity ,则返回 NaN
如果是 +0 加 +0 ,则返回 +0
如果是 -0 加 +0 ,则返回 +0
如果是 -0 加 -0 ,则返回 -0
如果只有一个操作数是字符串,则将另一个操作数转换为字符串,再将两个字符串拼接在一起
2.减法操作符
如果有任一操作数是NaN,则返回NaN。
如果是Infinity减Infinity,则返回NaN。
如果是-Infinity减-Infinity,则返回NaN。
如果是Infinity减-Infinity,则返回Infinity。
如果是-Infinity减Infinity,则返回-Infinity。
如果是+0减+0,则返回+0。
如果是+0减-0,则返回-0。
如果是-0减-0,则返回+0。
如果有任一操作数是对象,则调用其valueOf()方法取得表示它的数值。如果该值是NaN,则减法计算的结果是NaN。如果对象没有 valueOf()方法,则调用其toString()方法,然后再将得到的字符串转换为数值。
3.5.7 关系操作符
let result = "Brick" < "alphabet"; // true
在这里,字符串 "Brick" 被认为小于字符串 "alphabet" ,因为字母B的编码的66,字母a的编码是97。要得到确实按字母顺序比较的结果,就必须把两者都转换为相同的大小写形式(全大写或全小写),然后再比较:
let result = "Brick".toLowerCase() <"alphabet".toLowerCase(); // false
在比较两个数字字符串时
let result = "23" < "3"; // true
因为两个操作数都是字符串,所以会逐个比较它们的字符编码。
let result = "23" < 3; // false
这次会把字符串23转换为数值23,再跟3比较。
let result = "a" < 3; // 因为"a"会转换为NaN,所以结果是false
a转为NaN
let result1 = NaN < 3; // false
let result2 = NaN >= 3; // false
NaN和任何比较都会返回false
3.5.8 相等操作符
let result1 = ("55" == 55); // true,转换后相等
let result2 = ("55" === 55); // false,不相等,因为数据类型不同
let result1 = ("55" != 55); // false,转换后相等
let result2 = ("55" !== 55); // true,不相等,因为数据类型不同
null == undefined // true
null === undefined // false
由于相等和不相等操作符存在类型转换问题,因此推荐使用全等和不全等操作符。这样有助于在代码中保持数据类型的完整性。
3.5.9 条件操作符
3.5.10 赋值操作符
num += 10;和num = num + 10;一样
左移后赋值( <<= )
右移后赋值( >>= )
无符号右移后赋值( >>>= )
3.5.11 逗号操作符
let num1 = 1, num2 = 2, num3 = 3;
在赋值时使用逗号操作符分隔值,最终会返回表达式中最后一个值
let num = (5, 1, 4, 8, 0); // num的值为0
3.6 语句
3.6.1 if 语句
3.6.2 do-while 语句
do-while 语句是一种后测试循环语句,即循环体中的代码执行后才会对退出条件进行求值。do-while 语句是一种后测试循环语句,即循环体中的代码执行后才会对退出条件进行求值。
do {
statement
} while (expression);
3.6.3 while 语句
3.6.4 for 语句
无法通过 while 循环实现的逻辑,同样也无法使用 for 循环实现。因此 for 循环只是将循环相关的代码封装在了一起而已。
for (;;) { // 无穷循环
doSomething();
}
3.6.5 for-in 语句
for-in 语句是一种严格的迭代语句,用于枚举对象中的非符号键属性,语法如下:
for (property in expression) statement
for (const propName in window) {
document.write(propName);
}
这个例子使用 for-in 循环显示了BOM对象 window 的所有属性。每次执行循环,都会给变量 propName 赋予一个 window 对象的属性作为值,直到 window 的所有属性都被枚举一遍。与 for 循环一样,这里控制语句中的 const 也不是必需的。但为了确保这个局部变量不被修改,推荐使用 const 。
ECMAScript中对象的属性是无序的,因此 for-in 语句不能保证返回对象属性的顺序。换句话说,所有可枚举的属性都会返回一次,但返回的顺序可能会因浏览器而异。
如果 for-in 循环要迭代的变量是 null 或 undefined ,则不执行循环体。
3.6.6 for-of 语句
for-of 语句是一种严格的迭代语句,用于遍历可迭代对象的元素,语法如下:
for (property of expression) statement
for-of 循环会按照可迭代对象的 next() 方法产生值的顺序迭代元素。如果尝试迭代的变量不支持迭代,则 for-of 语句会抛出错误。ES2018对 for-of 语句进行了扩展,增加了 for-await-of 循环,以支持生成期约(promise)的异步可迭代对象。
3.6.7 标签语句
标签语句用于给语句加标签
label: statement
start: for (let i = 0; i < count; i++) {
console.log(i);
}
在这个例子中, start 是一个标签,可以在后面通过 break 或 continue 语句引用。标签语句的典型应用场景是嵌套循环。
3.6.8 break 和 continue 语句
let num = 0;
for (let i = 1; i < 10; i++) {
if (i % 5 == 0) {
continue;
}
num++;
}
console.log(num); // 8
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 语句在比较每个条件的值时会使用全等操作符,因此不会强制转换数据类型(比如,字符串 "10" 不等于数值10)。
3.7 函数
ECMAScript中的函数使用 function 关键字声明,后跟一组参数,然后是函数体。
function sum(num1, num2) {
return num1 + num2;
}
return 语句也可以不带返回值。这时候,函数会立即停止执行并返回 undefined 。这种用法最常用于提前终止函数执行,并不是为了返回值。函数不能以 eval 或 arguments 作为名称;函数的参数不能叫 eval 或 arguments ;两个函数的参数不能叫同一个名称。
3.8 小结
ECMAScript中的基本数据类型包括 Undefined 、 Null 、Boolean 、 Number 、 String 和 Symbol 。
与其他语言不同,ECMAScript不区分整数和浮点值,只有 Number 一种数值数据类型。
Object 是一种复杂数据类型,它是这门语言中所有对象的基类。
第 4 章 变量、作用域与内存
4.1 原始值与引用值
如ECMA-262所规定的,JavaScript变量是松散类型的,而且变量不过就是特定时间点一个特定值的名称而已。由于没有规则定义变量必须包含什么数据类型,变量的值和数据类型在脚本生命期内可以改变。
4.1 原始值与引用值
ECMAScript变量可以包含两种不同类型的数据:原始值和引用值。原始值(primitive value)就是最简单的数据,引用值(reference value)则是由多个值构成的对象。6种原始值: Undefined 、 Null 、Boolean 、 Number 、 String 和 Symbol 。保存原始值的变量是按值(by value)访问的,因为我们操作的就是存储在变量中的实际值。
引用值是保存在内存中的对象。与其他语言不同,JavaScript不允许直接访问内存位置,因此也就不能直接操作对象所在的内存空间。 在操作对象时,实际上操作的是对该对象的引用(reference)而非实际的对象本身。为此,保存引用值的变量是按引用(by reference)访问的。
4.1.1 动态属性
原始值和引用值的定义方式很类似,都是创建一个变量,然后给它赋一个值。不过,在变量保存了这个值之后,可以对这个值做什么,则大有不同。对于引用值而言,可以随时添加、修改和删除其属性和方法。比如,看下面的例子:
let person = new Object();
person.name = "Nicholas";
console.log(person.name); // "Nicholas"
首先创建了一个对象,并把它保存在变量 person 中。然后,给这个对象添加了一个名为 name 的属性,并给这个属性赋值了一个字符串 "Nicholas" 。在此之后,就可以访问这个新属性,直到对象被销毁或属性被显式地删除。原始值不能有属性,尽管尝试给原始值添加属性不会报错。比如:
let name = "Nicholas";
name.age = 27;
console.log(name.age); // undefined
只有引用值可以动态添加后面可以使用的属性。
注意,原始类型的初始化可以只使用原始字面量形式。如果使用的是 new 关键字,则JavaScript会创建一个 Object 类型的实例,但其行为类似原始值。
4.1.2 复制值
除了存储方式不同,原始值和引用值在通过变量复制时也有所不同。在通过变量把一个原始值赋值到另一个变量时,原始值会被复制到新变量的位置。请看下面的例子:
let num1 = 5;
let num2 = num1;
这里, num1 包含数值5。当把 num2 初始化为 num1 时,num2 也会得到数值5。这个值跟存储在 num1 中的5是完全独立的,因为它是那个值的副本。这两个变量可以独立使用,互不干扰。
在把引用值从一个变量赋给另一个变量时,存储在变量中的值也会被复制到新变量所在的位置。区别在于,这里复制的值实际上是一个指针,它指向存储在堆内存中的对象。操作完成后,两个变量实际上指向同一个对象,因此一个对象上面的变化会在另一个对象上反映出来。
let obj1 = new Object();
let obj2 = obj1;
obj1.name = "Nicholas";
console.log(obj2.name); // "Nicholas"
4.1.3 传递参数
ECMAScript中所有函数的参数都是按值传递的。这意味着函数外
的值会被复制到函数内部的参数中,就像从一个变量复制到另一个变量一样。如果是原始值,那么就跟原始值变量的复制一样,如果是引用值,那么就跟引用值变量的复制一样。对很多开发者来说,这一块可能会不好理解,毕竟变量有按值和按引用访问,而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"
创建了一个对象并把它保存在变量 person 中。然后,这个对象被传给 setName() 方法,并被复制到参数 obj 中。在函数内部, obj 和 person 都指向同一个对象。结果就是,即使对象是按值传进函数的, obj 也会通过引用访问对象。当函数内部给 obj 设置了 name 属性时,函数外部的对象也会反映这个变化,因为 obj 指向的对象保存在全局作用域的堆内存上。很多开发者错误地认为,当在局部作用域中修改对象而变化反映到全局时,就意味着参数是按引用传递的。为证明对象是按值传递的,我们再来看看下面这个修改后的例子:
function setName(obj) {
obj.name = "Nicholas";
obj = new Object();
obj.name = "Greg";
}
let person = new Object();
setName(person);
console.log(person.name); // "Nicholas"
将 obj 重新定义为一个有着不同 name 的新对象。当 person 传入setName()时,其name属性被设置为"Nicholas"。然后变量obj被设置为一个新对象且name属性被设置为Greg。如
果person是按引用传递的,那么person应该自动将指针改为指向name为"Greg"的对象。可是,当我们再次访person.name时,它的值是"Nicholas",这表明函数中参数的值改变之后,原始的引用仍然没变。当obj在函数内部被重写时,它变成了一个指向本地对象的指针。而那个本地对象在函数执行结束时就被销毁了。
ECMAScript中函数的参数就是局部变量。
4.1.4 确定类型
前一章提到的typeof操作符最适合用来判断一个变量是否为原始类型。更确切地说,它是判断一个变量是否为字符串、数值、布尔值或 undefined 的最好方式。如果值是对象或null,那么typeof返回"object"
typeof虽然对原始值很有用,但它对引用值的用处不大。我们
通常不关心一个值是不是对象,而是想知道它是什么类型的对象。为了解决这个问题,ECMAScript提供了instanceof操作符,语法如下:
result = variable instanceof constructor
console.log(person instanceof Object); // 变量persion是Object吗?
console.log(colors instanceof Array); // 变量colors是Array吗?
console.log(pattern instanceof RegExp); // 变量pattern是RegExp吗?
按照定义,所有引用值都是 Object 的实例,因此通过instanceof操作符检测任何引用值和Object构造函数都会返
回true。类似地,如果用instanceof检测原始值,则始终会返回false,因为原始值不是对象。
typeof 操作符在用于检测函数时也会返回"function"
4.2 执行上下文与作用域
这段好难懂啊
变量或函数的上下文决定了它们可以访问哪些数据,以及它们
的行为。每个上下文都有一个关联的变量对象(variable object),而这个上下文中定义的所有变量和函数都存在于这个对象上。虽然无法通过代码访问变量对象,但后台处理数据会用到它。
全局上下文是最外层的上下文。根据ECMAScript实现的宿主环境,表示全局上下文的对象可能不一样。在浏览器中,全局上下文就是我们常说的window对象(第12章会详细介绍),因此所有通var定义的全局变量和函数都会成为window对象的属性和方法。使用let和const的顶级声明不会定义在全局上下文中,但在作用域链解析上效果是一样的。上下文在其所有代码都执行完毕后会被销毁,包括定义在它上面的所有变量和函数(全局上下文在应用程序退出前才会被销毁,比如关闭网页或退出浏览器)。
每个函数调用都有自己的上下文。当代码执行流进入函数时,函数的上下文被推到一个上下文栈上。在函数执行完之后,上下文栈会
弹出该函数上下文,将控制权返还给之前的执行上下文ECMAScript程序的执行流就是通过这个上下文栈进行控制的。
上下文中的代码在执行的时候,会创建变量对象的一个作用域链 (scope chain)。这个作用域链决定了各级上下文中的代码在访问变量和函数时的顺序。代码正在执行的上下文的变量对象始终位于作用域链的最前端。如果上下文是函数,则其活动对象(activation object)用作变量对象。活动对象最初只有一个定义变量:arguments 。(全局上下文中没有这个变量。)作用域链中的下一个变量对象来自包含上下文,再下一个对象来自再下一个包含上下文。以此类推直至全局上下文;全局上下文的变量对象始终是作用域链的最后一个变量对象。
代码执行时的标识符解析是通过沿作用域链逐级搜索标识符名称
完成的。搜索过程始终从作用域链的最前端开始,然后逐级往后,直到找到标识符。(如果没有找到标识符,那么通常会报错。)
var color = "blue";
function changeColor() {
if (color === "blue") {
color = "red";
} else {
color = "blue";
}
}
changeColor();
对这个例子而言,函数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();
3个上下文:全局上下文、 changeColor() 的局部上下文和 swapColors() 的局部上下文。全局上下文中有一个变量 color 和一个函数 changeColor() 。 changeColor() 的局部上下文中有一个变量 anotherColor 和一个函数swapColors() ,但在这里可以访问全局上下文中的变量color 。 swapColors() 的局部上下文中有一个变量tempColor ,只能在这个上下文中访问到。全局上下文和changeColor() 的局部上下文都无法访问到 tempColor 。而在
swapColors() 中则可以访问另外两个上下文中的变量,因为它们
都是父上下文。图4-3展示了前面这个例子的作用域链。
内部上下文可以通过作用域链。
访问外部上下文中的一切,但外部上下文无法访问内部上下文中的任 何东西。上下文之间的连接是线性的、有序的。每个上下文都可以到 上一级上下文中去搜索变量和函数,但任何上下文都不能到下一级上 下文中去搜索。 swapColors() 局部上下文的作用域链中有3个对 象: swapColors() 的变量对象、 changeColor() 的变量对象 和全局变量对象。 swapColors() 的局部上下文首先从自己的变量 对象开始搜索变量和函数,搜不到就去搜索上一级变量对象。 changeColor() 上下文的作用域链中只有2个对象:它自己的变量 对象和全局变量对象。因此,它不能访问 swapColors() 的上下文。
注意 函数参数被认为是当前上下文中的变量,因此也跟上下文中 的其他变量遵循相同的访问规则。
4.2.1 作用域链增强
执行上下文有全局上下文和函数上下文两种(evak()调用内部存在第三种上下文),但有其他方式来增强作用域链。某些语句会导致在作用域链前端临时添加一个上下文,这个上下文在代码执行后会被删除。通常在两种情况下会出现这个现象,即代码执行到下面任意一种情况时:
- try / catch 语句的 catch 块
- with 语句
这两种情况都对在作用域链前端添加一个变量对象,对
with语句来说,会向作用域链添加指定的对象;catch会创建一个新的变量对象,这个变量对象会包含要抛出的错误对象的声明:
function buildUrl() {
let qs = "?debug=true";
with(location){
let url = href + qs;
}
return url;
}
这里, with 语句将 location 对象作为上下文,因此 location 会被添加到作用域链前端。buildUrl() 函数中定义了
一个变量 qs 。当 with 语句中的代码引用变量 href 时,实际上引用的是 location.href ,也就是自己变量对象的属性。在引用 qs 时,引用的则是定义在 buildUrl() 中的那个变量,它定义在函数上下文的变量对象上。而在 with 语句中使用 var 声明的变量 url 会成为函数上下文的一部分,可以作为函数的值被返回;但像这里使用 let 声明的变量 url ,因为被限制在块级作用域(稍后介绍),所以在 with 块之外没有定义。
4.2.2 变量声明
1.使用var的函数作用域声明
在使用var声明变量时,变量会被自动添加到最接近的上下文。在函数中,最接近的上下文就是函数的局部上下文。在 with 语句中,最接近的上下文也是函数上下文。如果变量未经声明就被初始化了,那么它就会自动被添加到全局上下文,如下面的例子所示:
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 被添加到了全局上下文,在函数退出之后依然存在,从而在后面可以访问到。
未经声明而初始化变量是JavaScript编程中一个非常常见 的错误,会导致很多问题。为此,读者在初始化变量之前一定 要先声明变量。在严格模式下,未经声明就初始化变量会报 错。
var 声明会被拿到函数或全局作用域的顶部,位于作用域中所
有代码之前。这个现象叫作“提升”(hoisting)。提升让同一作用
域中的代码不必考虑变量是否已经声明就可以直接使用。可是在
实践中,提升也会导致合法却奇怪的现象,即在变量声明之前使
用变量。下面的例子展示了在全局作用域中两段等价的代码:
var name = "Jake";
// 等价于:
name = 'Jake';
var name;
通过在声明之前打印变量,可以验证变量会被提升。声明的提升
意味着会输出 undefined 而不是 Reference Error :
console.log(name); // undefined
var name = 'Jake';
function() {
console.log(name); // undefined
var name = 'Jake';
}
- 使用 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 声明会被忽略,而重复的 let 声明会抛出
SyntaxError 。
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 变量。因此,从写JavaScript代码的角度说, let 的提升
跟 var 是不一样的。
- 使用 const 的常量声明
除了
let,ES6同时还增加了const关键字。使用const声 明的变量必须同时初始化为某个值。一经声明,在其生命周期的 任何时候都不能再重新赋予新值。
const a; // SyntaxError: 常量声明时没有初始化
const b = 3;
console.log(b); // 3
b = 4; // TypeError: 给常量赋值
const 除了要遵循以上规则,其他方面与 let 声明是一样的
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
由于 const 声明暗示变量的值是单一类型且不可修改, JavaScript 运行时编译器可以将其所有实例都替换成实际的值,而不会通过查询表进行变量查找。谷歌的V8引擎就执行这种优化。
- 标识符查找 当在特定上下文中为读取或写入而引用一个标识符时,必须通过 搜索确定这个标识符表示什么。搜索开始于作用域链前端,以给 定的名称搜索对应的标识符。如果在局部上下文中找到该标识 符,则搜索停止,变量确定;如果没有找到变量名,则继续沿作用域链搜索。(注意,作用域链中的对象也有一个原型链,因此 搜索可能涉及每个对象的原型链。)这个过程一直持续到搜索至 全局上下文的变量对象。如果仍然没有找到标识符,则说明其未 声明。
为更好地说明标识符查找,我们来看一个例子:
var color = 'blue';
function getColor() {
return color;
}
console.log(getColor()); // 'blue'
在这个例子中,调用函数 getColor() 时会引用变量
color 。为确定 color 的值会进行两步搜索。第一步,搜索
getColor() 的变量对象,查找名为 color 的标识符。结果
没找到,于是继续搜索下一个变量对象(来自全局上下文),然
后就找到了名为 color 的标识符。因为全局变量对象上有
color 的定义,所以搜索结束。
对这个搜索过程而言,引用局部变量会让搜索自动停止,而不继
续搜索下一级变量对象。也就是说,如果局部上下文中有一个同
名的标识符,那就不能在该上下文中引用父上下文中的同名标识
符,如下面的例子所示:
对这个搜索过程而言,引用局部变量会让搜索自动停止,而不继
续搜索下一级变量对象。也就是说,如果局部上下文中有一个同
名的标识符,那就不能在该上下文中引用父上下文中的同名标识
符,如下面的例子所示:
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 。
标识符查找并非没有代价。访问局部变量比访问全局变量要快,因为不用切换作用域。
4.3垃圾回收
执行环境负责在代码执行时管理内存。通过自动内存管理实现内存分配和闲置资源回收。基本思路很简单:确定哪个变量不会再使用,然后释放它占用的内存。这个过程是周期性的,即垃圾回收程序每隔一定时间(或者说在代码执行过程中某个预定的收集时间)就会自动运行。垃圾回收过程是一个近似且不完美的方案,因为某块内存是否还有用,属于“不可判定的”问题,意味着靠算法是解决不了的。
我们以函数中局部变量的正常生命周期为例。函数中的局部变量 会在函数执行时存在。此时,栈(或堆)内存会分配空间以保存相应的值。函数在内部使用了变量,然后退出。此时,就不再需要那个局部变量了,它占用的内存可以释放,供后面使用。这种情况下显然不再需要局部变量了,但并不是所有时候都会这么明显。垃圾回收程序必须跟踪记录哪个变量还会使用,以及哪个变量不会再使用,以便回收内存。如何标记未使用的变量也许有不同的实现方式。不过,在浏览器的发展史上,用到过两种主要的标记策略:标记清理和引用计数。
4.3.1 标记清理
JavaScript最常用的垃圾回收策略是标记清理(mark-and
sweep)。当变量进入上下文,比如在函数内部声明一个变量时,这个变量会被加上存在于上下文中的标记。而不在上下文中的变量,逻辑上讲,永远不应该释放它们的内存,因为只要上下文中的代码在运行,就有可能用到它们。当变量离开上下文时,也会被加上离开上下文的标记。
给变量加标记的方式有很多种。比如,当变量进入上下文时,反 转某一位;或者可以维护“在上下文中”和“不在上下文中”两个变量列表,可以把变量从一个列表转移到另一个列表。标记过程的实现并不重要,关键是策略。
垃圾回收程序运行的时候,会标记内存中存储的所有变量(记 住,标记方法有很多种)。然后,它会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉。在此之后再被加上标记的变量就是待删除的了,原因是任何在上下文中的变量都访问不到它们了。随后垃圾回收程序做一次内存清理,销毁带标记的所有值并收回它们的内存。
到了2008年,IE、Firefox、Opera、Chrome和Safari都在自己的 JavaScript实现中采用标记清理(或其变体),只是在运行垃圾回收的频率上有所差异。
4.3.2 引用计数
另一种没那么常用的垃圾回收策略是引用计数(reference
counting)。其思路是对每个值都记录它被引用的次数。声明变量并给它赋一个引用值时,这个值的引用数为1。如果同一个值又被赋给另一个变量,那么引用数加1。类似地,如果保存对该值引用的变量被其他值给覆盖了,那么引用数减1。当一个值的引用数为0时,就说明没办法再访问到这个值了,因此可以安全地收回其内存了。垃圾回收程序下次运行的时候就会释放引用数为0的值的内存。
引用计数最早由Netscape Navigator 3.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。如果函数被多次调用,则会导致大量内存永远不会被释放。为此,Netscape在4.0版放弃了引用计数,转而采用标记清理。事实上,引用计数策略的问题还不止于此。
在IE8及更早版本的IE中,并非所有对象都是原生JavaScript对 象。BOM和DOM中的对象是C++实现的组件对象模型(COM,Component Object Model)对象,而COM对象使用引用计数实现垃圾回收。因此,即使这些版本IE的JavaScript引擎使用标记清理,JavaScript存取的COM对象依旧使用引用计数。换句话说,只要涉及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 实际上会切断变量与其之前引用值之间的关 系。当下次垃圾回收程序运行时,这些值就会被删除,内存也会被回收。为了补救这一点,IE9把BOM和DOM对象都改成了JavaScript对象,这同时也避免了由于存在两套垃圾回收算法而导致的问题,还消除了常见的内存泄漏现象。
4.3.3 性能
垃圾回收程序会周期性运行,如果内存中分配了很多变量,则可 能造成性能损失,因此垃圾回收的时间调度很重要。尤其是在内存有限的移动设备上,垃圾回收有可能会明显拖慢渲染的速度和帧速率。开发者不知道什么时候运行时会收集垃圾,因此最好的办法是在写代码时就要做到:无论什么时候开始收集垃圾,都能让它尽快结束工作。
现代垃圾回收程序会基于对JavaScript运行时环境的探测来决定何时运行。探测机制因引擎而异,但基本上都是根据已分配对象的大小和数量来判断的。比如,根据V8团队2016年的一篇博文的说法:“在一次完整的垃圾回收之后,V8的堆增长策略会根据活跃对象的数量外加一些余量来确定何时再次垃圾回收。”
由于调度垃圾回收程序方面的问题会导致性能下降,IE曾饱受诟 病。它的策略是根据分配数,比如分配了256个变量、4096个对象/数组字面量和数组槽位(slot),或者64KB字符串。只要满足其中某个条件,垃圾回收程序就会运行。这样实现的问题在于,分配那么多变量的脚本,很可能在其整个生命周期内始终需要那么多变量,结果就会导致垃圾回收程序过于频繁地运行。由于对性能的严重影响,IE7最终更新了垃圾回收程序。
IE7发布后,JavaScript引擎的垃圾回收程序被调优为动态改变分
配变量、字面量或数组槽位等会触发垃圾回收的阈值。IE7的起始阈值都与IE6的相同。如果垃圾回收程序回收的内存不到已分配的15%,这些变量、字面量或数组槽位的阈值就会翻倍。如果有一次回收的内存达到已分配的85%,则阈值重置为默认值。这么一个简单的修改,极大地提升了重度依赖JavaScript的网页在浏览器中的性能。
在某些浏览器中是有可能(但不推荐)主动触发垃圾回收的。在IE中, window.CollectGarbage() 方法会立即触发垃圾回收。在Opera 7及更高版本中,调用 window.opera.collect() 也会启动垃圾回收程序。
4.3.4 内存管理
在使用垃圾回收的编程环境中,开发者通常无须关心内存管理。 不过,JavaScript运行在一个内存管理与垃圾回收都很特殊的环境。分配给浏览器的内存通常比分配给桌面软件的要少很多,分配给移动浏览器的就更少了。这更多出于安全考虑而不是别的,就是为了避免运行大量JavaScript的网页耗尽系统内存而导致操作系统崩溃。这个内存限制不仅影响变量分配,也影响调用栈以及能够同时在一个线程中执行的语句数量。
将内存占用量保持在一个较小的值可以让页面性能更好。优化内存占用的最佳手段就是保证在执行代码时只保存必要的数据。如果数据不再必要,那么把它设置为 null ,从而释放其引用。这也可以叫作 解除引用。这个建议最适合全局变量和全局对象的属性。局部变量在超出作用域后会被自动解除引用,如下面的例子所示:
function createPerson(name){
let localPerson = new Object();
localPerson.name = name;
return localPerson;
}
let globalPerson = createPerson("Nicholas");
// 解除globalPerson对值的引用
globalPerson = null;
在上面的代码中,变量 globalPerson 保存着 createPerson() 函数调用返回的值。在 createPerson() 内部, localPerson 创建了一个对象并给它添加了一个 name 属性。然后, localPerson 作为函数值被返回,并被赋值给 globalPerson 。 localPerson 在createPerson() 执行完成超出上下文后会自动被解除引用,不需要显式处理。但 globalPerson 是一个全局变量,应该在不再需要时手动解除其引用,最后一行就是这么做的。
不过要注意,解除对一个值的引用并不会自动导致相关内存被回收。解除引用的关键在于确保相关的值已经不在上下文里了,因此它在下次垃圾回收时会被回收。
-
通过 const 和 let 声明提升性能 ES6增加这两个关键字不仅有助于改善代码风格,而且同样有助于改进垃圾回收的过程。因为
const和let都以块(而非函数)为作用域,所以相比于使用var,使用这两个新关键字可能会更早地让垃圾回收程序介入,尽早回收应该回收的内存。在块作用域比函数作用域更早终止的情况下,这就有可能发生。 -
隐藏类和删除操作 根据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 关键字会导致生成相同的隐藏类片段。看一下这个例子:
function Article() {
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;
- 内存泄漏 写得不好的JavaScript可能出现难以察觉且有害的内存泄漏问题。在内存有限的设备上,或者在函数会被调用很多次的情况下,内存泄漏可能是个大问题。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;
};
};
这会导致分配给 name 的内存被泄漏。以上代码创建了一个内部闭包,只要 outer 函数存在就不能清理 name ,因为闭包一直在引用着它。假如 name 的内容很大(不止是一个小字符串),那可能就是个大问题了。
- 静态分配与对象池
减少浏览器执行垃圾回收的次数。开发者无法直接控制什么时候开始收集垃圾,但可以间接控制触发垃圾回收的条件。理论上,如果能够合理使用分配的内存,同时避免多余的垃圾回收,那就可以保住因释放内存而损失的性能。
浏览器决定何时运行垃圾回收程序的一个标准就是对象更替的速度。如果有很多对象被初始化,然后一下子又都超出了作用域,那么浏览器就会采用更激进的方式调度垃圾回收程序运行,这样当然会影响性能。看一看下面的例子,这是一个计算二维矢量加法的函数:
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元素)。
- 引用计数在代码中存在循环引用时会出现问题。
- 解除变量的引用不仅可以消除循环引用,而且对垃圾回收也有帮助。为促进内存回收,全局对象、全局对象的属性和循环引用都应该在不需要时解除引用。