javascript-js高阶解析

js高阶解析

关于JavaScript的一些高阶问题,建议在入门之后当作Q&A查看。

语法形式

JavaScript的语法与C/C++,Java类似,通过;、{...}来分割语句,区分大小写,而非像Python一样通过缩进。Javascript解析器对缩进没有讲究。但是,JavaScript并不强制要求在每个语句的结尾加;,浏览器中负责执行JavaScript代码的引擎会自动在每个语句的结尾补上;,但是自动加分号在某些情况下会改变程序的语义,导致运行结果与期望不一致。因此在实际编程中,强烈建议像C/C++,Java一样严谨使用等号。例子如下:

 1var x = 1;
 2function test1(par){
 3    if(x === 1)
 4        return true
 5}
 6console.log(test1(x));
 7
 8function test2(par){
 9    if(x === 1)
10        return 
11            true
12}
13console.log(test2(x));

两个函数test1(par),test2(par)的区别就是return语句是否分成了两行写。但是执行结果却不一样:

1test1: true
2test2: undefined

原因就在在引擎执行时,在test2函数的return后面自动加了;,在执行时就成了:

1function test2(par){
2    if(x === 1)
3        return ; //自动加了分号,函数到此处直接返回
4            true;
5}

因此test2的返回结果就是undefined

变量与作用域

JavaScript提供了三类变量声明方法。

  1. 直接赋值使用,x=100
  2. var声明,var x = 100
  3. let, const声明,let x = 100; const y = 3.14;

它们三者各有区别。

首先,当我们使用方式1,直接给未声明变量赋值时,所产生的变量都是全局变量!无论是在函数体内,还是函数体外,只要使用这种声明方式的都是全局变量。

当我们使用方式2中var声明时,在函数外部声明就是全局变量;在函数内部声明就是局部变量。var变量可以重新声明和修改。需要注意的是,var的局部变量范围是整个函数体,不像C/C++,Java那样作用域是代码块。

为了实现更细代码块级别的作用域划分,在ES6中引入了letconst。块是由{}界定的代码块,匹配的大括号中就是一个块,代码块可以嵌套定义。大括号内的任何内容都包含在一个块级作用域中,而letconst声明的变量都只在对应的代码块中有效。如果在全局中使用let那么定义的就是全局变量。例子如下:

1let times = 4;
2
3if (times > 3) {
4    let hello = 'say Hello';
5    console.log(hello); // "say Hello"
6}
7console.log(hello); // hello is not defined

我们看到在其代码块(定义它的花括号)之外使用hello会返回错误。这是因为let变量是块范围的。

就像var一样,用let声明的变量可以在其范围内被修改。但与var不同的是,let变量无法在其作用域内被重新声明。 来看下面的例子

1let greeting = 'say Hi';
2greeting = 'say Hello instead';//正常执行

这是重新给greeting变量赋值,修改时允许的。

1let greeting = 'say Hi';
2let greeting = 'say Hello instead'; // SyntaxError: Identifier 'greeting' has already been declared

两个let相当于重新声明greeting,会报错:变量已经被声明。但是,如果在不同的作用域中定义了相同的变量,则不会有错误,这属于作用域的覆盖。这个事实说明:使用let,是比var更好的选择。当使用let时,你不必费心思考变量的名称,因为变量仅在其块级作用域内存在。现在推荐使用let来声明变量。

const声明的变量保持常量值,和let一样也是在对应代码块内有效。const不能被修改并且不能被重新声明,因此每个const声明都必须在声明时进行初始化。虽然const声明的变量不可以修改,但是可以修改const对象的属性,比如:

 1const greeting = {
 2    message: 'say Hi',
 3    times: 4,
 4};//声明const对象
 5
 6const greeting = {
 7    words: 'Hello',
 8    number: 'five',
 9}; // error:  Assignment to constant variable.
10
11greeting.message = 'say Hello instead';//可以修改其属性

变量提升

变量提升是JavaScript的一种机制:在执行代码之前,变量和函数声明会移至其作用域的顶部。注意,仅仅是声明,赋值操作并不会提升。这意味着如果我们这样做:

1console.log(greeter);
2var greeter = 'say hello';

生面的代码会被解释为:

1var greeter;
2console.log(greeter); // greeter is undefined
3greeter = 'say hello';

var,let,const都会被提升,区别是var提升到顶部后使用undefined值对其进行初始化。用let声明的变量被提升到作用域的顶部时不会对值进行初始化,因此,如果你尝试在声明前使用let变量,则会收到Reference Errorconst声明也会被提升到顶部,但是没有初始化,最好将const声明都放到代码顶部。

作用域的覆盖

当全局变量跟局部变量重名时,局部变量的作用域会覆盖掉全局变量的作用域,当离开局部变量的作用域后,又重回到全局变量的作用域。如果代码块内的局部变量与外部局部变量重名,代码块内局部变量作用域优先级最高。

 1var str = "我是全局变量";
 2
 3function fn() {
 4    var str = "我是局部变量";
 5    console.log(str); //结果:我是局部变量
 6    {
 7        let str = "我是块内局部变量";
 8        console.log(str); //结果:我是块内局部变量
 9    }
10    console.log(str); //结果:我是局部变量
11}
12fn();
13console.log(str);//结果:我是全局变

运行结果为:

1我是局部变量
2我是块内局部变量
3我是局部变量
4我是全局变量

nullundefined

目前,nullundefined基本是同义的,只有一些细微的差别。

null表示没有对象,即该处不应该有值。典型用法是:

  1. 作为函数的参数,表示该函数的参数不是对象。
  2. 作为对象原型链的终点。
1Object.getPrototypeOf(Object.prototype)
2// null

undefined表示缺少值,就是此处应该有一个值,但是还没有定义。典型用法是:

  1. 变量被声明了,但没有赋值时,就等于undefined
  2. 调用函数时,应该提供的参数没有提供,该参数等于undefined
  3. 对象没有赋值的属性,该属性的值为undefined
  4. 函数没有返回值时,默认返回undefined
 1var i;
 2i // undefined
 3
 4function f(x){console.log(x)}
 5f() // undefined
 6
 7var  o = new Object();
 8o.p // undefined
 9
10var x = f();
11x // undefined

=====

JavaScript在设计时,有两种比较运算符:

第一种是==比较,它会自动转换数据类型再比较,很多时候,会得到非常诡异的结果;

第二种是===比较,它不会自动转换数据类型,如果数据类型不一致,返回false,如果一致,再比较。

由于JavaScript这个设计缺陷,不要使用==比较,始终坚持使用===比较。

另一个例外是NaN这个特殊的Number与所有其他值都不相等,包括它自己:

1NaN === NaN; // false

唯一能判断NaN的方法是通过isNaN()函数:

1isNaN(NaN); // true

注意浮点数的相等比较:

11 / 3 === (1 - 2 / 3); // false

这不是JavaScript的设计缺陷。浮点数在运算过程中会产生误差,因为计算机无法精确表示无限循环小数。要比较两个浮点数是否相等,只能计算它们之差的绝对值,看是否小于某个阈值:

1Math.abs(1 / 3 - (1 - 2 / 3)) < 0.0000001; // true`

对于Array,Object等高级类型,=====是没有区别的进行 "指针地址" 比较。

for ... offor ... in 遍历

for ... in是ES5标准引入的语法,用于遍历键值对对象(可遍历对象,数组,字符串,map等),输出的是键(key)。此外,其不仅可以遍历数字键名,还会遍历原型(prototype)和用户手动添加的其他键。

for ... of是ES6标准引入的语法,用于拥有迭代器对象的集合遍历(可遍历对象,数组,字符串,mapsetarguments对象,普通对象没有迭代器无法遍历)输出的是值(value)。

此外,还可以使用可迭代对象内置的forEach方法(ES5.1标准引入),它接收一个函数,每次迭代就自动回调该函数。

类中的方法

在一个对象中绑定函数,称为这个对象的方法。绑定到对象上的函数称为方法,和普通函数也没啥区别。例子如下:

1var xiaoming = {
2    name: '小明',
3    birth: 1990,
4    age: function () {
5        var y = new Date().getFullYear();
6        return y - this.birth;
7    }
8};

此时,age()就是对象xiaoming的方法,使用时直接调用即可xiaoming.age()

它在内部使用了一个this关键字,这个东东是什么?在一个方法内部,this是一个特殊变量,它始终指向当前对象,也就是xiaoming这个变量。所以,this.birth可以拿到xiaomingbirth属性。

this的指向问题

总结:一般情况下,this指向生成实例时的上一级对象。难点就是判断何时生成对象实例。

  1. this永远指向一个对象;
  2. this的指向完全取决于函数调用时的位置,而非声明时的位置;

箭头函数

ES6标准新增了一种新的函数:Arrow Function(箭头函数)。 像Lambda表达式,是一种语法糖,简化匿名函数。

1x => x * x

上面的箭头函数相当于:

1function (x) {
2    return x * x;
3}

箭头函数有两种格式,一种像上面的,只包含一个表达式,连大括号{ ... }return都省略掉了。还有一种可以包含多条语句,这时候就不能省略大括号{ ... }return

面向对象编程

JavaScript的面向对象编程和大多数其他语言如Java、C#的面向对象编程都不太一样。如果你熟悉Java或C#,很好,你一定明白面向对象的两个基本概念:类(对象的类型模板)和实例(根据类创建的对象)。不过,在JavaScript中,这个概念需要改一改。JavaScript不区分类和实例的概念,而是通过原型(prototype)来实现面向对象编程。

1class1.__proto__=class2//低版本浏览器可能不适用

表示class1通过class2来生成新的对象。对于低版本浏览器不适用的场景,建议使用Object.create()方法可以传入一个原型对象,并创建一个基于该原型的新对象,但是新对象什么属性都没有。

1var class1 = Object.create(class2)//默认class1的所有值为空

JavaScript的原型链和Java的Class区别就在,它没有“Class”的概念,所有对象都是实例,所谓继承关系不过是把一个对象的原型指向另一个对象而已。

构造函数

new一个函数,就可以把这个函数当成构造函数使用。

类中属性的类型

由于Javascript在设计之处并不是面向对象的语言,因此在类设计方面没有现在常见的特性例如访问级别修饰符(public,private,protected),读方法(getter)和写方法(setter),属性的枚举等等。尤其在Java语言中出现的Javabean规范被证明对减少面向对象编程中的BUG具有积极意义,这促使其他编程语言包括Javascript也想方设法实现类似的功能,其中属性类型就是此方面的实践。为了表示方便,标准中一般使用[[Prooerty]]来表示类中内容的属性,本文中依照标准的表示方法使用。

JavaScript属性类型分为两种,数据属性和访问器属性。最开始Javascript只有数据属性,基本上一般教程里看到的类属性都是数据属性;在ES5标准中,为了增加数据的封装性和可控性,又增加了访问器属性(Accessor)。访问器属性更类似于面向对象编程中成员属性的get(),set()函数,在这些函数中,我们能够对数据的读写进行一定控制。

我们先看数据属性,其包含四个特性:

  1. [[Configurable]]:可配置性。表示属性是否可以通过delete删除并重新定义,是否可以修改它的特性,以及是否可以把它改为访问器属性。默认情况下,所有直接定义在对象上的属性的这个特性都是true
    • 例外,当[[Configurable]]false时,若[[Writable]]true,我们可以修改[[value]]的值或将[[Writable]]改为false
  2. [[Enumerable]]:可遍历性。表示属性是否可以通过for-in循环返回。默认情况下,所有直接定义在对象上的属性的这个特性都是true
    • 由于Javascript中Array也是对象,因此我们给Array自定义的属性也会在for-in循环中带出来。目前建议数组Array的数据遍历用for-of循环。
  3. [[Writable]]:是否可写。表示属性的值是否可以被修改。默认情况下,所有直接定义在对象上的属性的这个特性都是true
  4. [[Value]]:包含属性实际的值。未初始化的默认值为 undefined

在上面四个数据属性中,最容易理解的是[[value]],我们用各种方法初始化对象就是设置属性的[[value]]

1let person1 = {name: "Alice"}; // 数据属性name的[[value]]是Alice
2let person2 = {};
3person2.name = "Bob"; // 数据属性name的[[value]]是Bob

之后对这个值的任何修改都会保存在[[value]]这个特性。相对于 [[value]]可以方便地修改,剩下三个数据属性的特性并不能直接修改,就必须使用Object.defineProperty()方法。这个方法接收 3 个参数:要给其添加属性的对象、属性的名称和一个特性描述符对象。最后一个参数,即描述符对象上的属性可以包含:configurableenumerablewritablevalue,跟相关特性的名称一一对应。根据要修改的特性,可以设置其中一个或多个值。

需要指出,区别于特性初始化默认为true的情形,在调用Object.defineProperty()时,configurableenumerablewritable的值如果不指定,则都默认为false。多数情况下,可能都不需要Object.defineProperty()提供的这些强大的设置,但要理解 JavaScript对象,就要理解这些概念。

例子:

 1let person = {};
 2
 3//数值上等同于 person.name = "Bill";person.income=10000;person.tax=2000;person.gender="male"。但是属性特性不同
 4Object.defineProperty(person,"name", {configurable:true, enumerable:true,writable:true,value: "Bill"});
 5Object.defineProperty(person,"income", {configurable:true, enumerable:true,writable:true,value: 10000});
 6Object.defineProperty(person,"tax", {configurable:true, enumerable:false,writable:true,value: 2000});
 7Object.defineProperty(person,'gender',{enumerable:true,value: 'male'});
 8//for-in 遍历
 9for(let par in person) {
10    console.log(par+":"+person[par]);
11}
12console.log('Tax: '+person.tax)
13
14//尝试修改gender属性与income属性
15console.log('尝试修改gender属性与income属性');
16person.income = 20000;
17console.log('Income: '+person.income);
18person.gender = 'female';
19console.log('Gender: '+person.gender);

显示结果:

1name:Bill
2income:10000
3gender:male
4Tax: 2000
5尝试修改gender属性与income属性
6Income: 20000
7Gender: male

由于没有将person.tax的可遍历[[enumerable]]设置为true,因此js采用默认值false,在使用for-in遍历时,不会显示person.tax。不过我们依旧可以使用person.tax来访问。由于person.income[[writable]]true,我们可以顺利地修改其值。然而person.gender[[writable]]false,这个属性的值就不能再修改了,在非严格模式下尝试给这个属性重新赋值会被忽略。在严格模式下,尝试修改只读属性的值会抛出错误。

原型对象prototype

在ES6中新增了面向对象编程的模式,支持了Class,extends等面向对象关键字。不过在ES6之前,JavaScript也是能够实现面向对象编程的,用的就是原型对象protptype,新增的功能不过是对既有功能的封装,让其更符合现代编程模式。

javascript中的prototype更像是面向对象设计中的类,prototype属性指向的是原型类,构造函数的原型prototype是这个prototype对象,原型对象的constructor指向构造函数。所以说prototype扮演了js中类class的角色,对象与类直接有关系,构造函数也和类直接有关系,实例对象与构造函数通过类间接联系在一起。

构造函数通过prototype属性指向原型对象,实例通过__proto__属性指向原型对象。关系如下图:

js原型构造函数实例类比

上图展示了Person构造函数、Person的原型对象和Person现有两个实例之间的关系。注意,Person.prototype指向原型对象,而Person.prototype.contructor指回Person构造函数。原型对象包含constructor属性和其他后来添加的属性。Person的两个实例person1person2都只有一个内部属性指回Person.prototype,而且两者都与构造函数没有直接联系。另外要注意,虽然这两个实例都没有属性和方法,但person1.sayName()person2.sayName()可以正常调用。这是由于对象属性查找机制的原因。