在这里插入图片描述
🌈个人主页:前端青山
🔥系列专栏:JavaScript篇
🔖人终将被年少不可得之物困其一生

依旧青山,本期给大家带来JavaScript篇专栏内容:JavaScript全面指南(一)

1、介绍一下JS的内置类型有哪些?

基本数据类型:Undefined Null Boolean Number String

内置对象:Object是Javascript中所有对象的父对象

​ 数据封装对象:Object Array Boolean Number String

​ 其他对象:Function Argument Math Date RegExp Error

2、介绍一下 typeof 区分类型的原理

instanceof的实现代码:

// L instanceof R
function instance_of(L, R) {//L 表示左表达式,R 表示右表达式
    var O = R.prototype;// 取 R 的显式原型
    L = L.__proto__;    // 取 L 的隐式原型
    while (true) { 
        if (L === null) //已经找到顶层
            return false;  
        if (O === L)   //当 O 严格等于 L 时,返回 true
            return true; 
        L = L.__proto__;  //继续向上一层原型链查找
    } 
}

首先typeof 能够判断基本数据类型,但是除了null,typeof null 返回的是object

但是对于对象来说typeof不能准确判断类型,typeof 函数会返回function,除此之外全部都是object,不能准确判断类型

instanceof可以判断复杂数据类型,基本数据类型不可以

instanceof是通过原型链来判断的 ,A instanceof B,在A的原型链中层层查找,是否有原型等于B.prototype,如果一直找到A的原型链的顶端(null,即Object.prototype.proto),仍然不等于B,那么返回false,否则返回true

3、JavaScript 中的强制转型是指什么?

在 JavaScript 中,两种不同的内置类型间的转换被称为强制转型。强制转型在 JavaScript 中有两种形式:显式和隐式。

这是一个显式强制转型的例子:

var a = "42";
var b = Number( a );
a; // "42"
b; // 42 -- 是个数字!

这是一个隐式强制转型的例子:

var a = "42";
var b = a * 1; // "42" 隐式转型成 42 
a; // "42"
b; // 42 -- 是个数字!

4、说说你对javascript的作用域的理解

前言

学习 JavaScript 也有一段时间,今天抽空总结一下作用域,也方便自己以后翻阅。

什么是作用域

如果让我用一句简短的话来讲述什么是作用域,我的回答是:

其实作用域的本质是一套规则,它定义了变量的可访问范围,控制变量的可见性和生命周期。

既然作用域是一套规则,那么究竟如何设置这些规则呢?

先不急,在这之前,我们先来理解几个概念。

编译到执行的过程

下面我们就拿这段代码来讲述 JavaScript 编译到执行的过程。

var a = 2;

首先我们来看一下在这个过程中,几个功臣所需要做的事。

  1. 引擎(总指挥):

从头到尾负责整个 JavaScript 程序的编译及执行过程。

  1. 编译器(劳工):
  2. 词法分析(分词)
    解析成词法单元,vara=2
  3. 语法分析(解析)
    将单词单元转换成抽象语法树(AST)。
  4. 代码生成
    将抽象语法树转换成机器指令。
  5. 作用域(仓库管理员):

负责收集并维护所有生命的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。

然后我们再来看,执行这段代码时,每个功臣是怎么协同工作的。

引擎:

其实这段代码有两个完全不同的声明,var aa = 2,一个由编译器在编译时处理,另一个则由引擎在运行时处理。

编译器:

  1. 一套编译器常规操作下来,到代码生成步骤。
  2. 遇到var a,会先询问作用域中是否已经存在同名变量,如果是,则忽略该声明,继续进行编译;否则它会要求作用域声明一个新的变量a
  3. 为引擎生成运行a = 2时所需的代码。

引擎:

会先询问作用域是否存在变量a,如果是,就会使用这个变量进行赋值操作;否则一直往外层嵌套作用域找(详见作用域嵌套),直至到全局作用域都没有时,抛出一个异常。

**总结:**变量的赋值操作会执行两个动作, 首先编译器会在当前作用域中声明一个变量( 如果之前没有声明过),然后在运行时引擎会在作用域中查找该变量, 如果能够找到就会对它赋值。

LHS & RHS 查询

从上面可知,引擎在获得编译器给出的代码后,还会对作用域进行询问变量。

聪明的你肯定一眼就看出,LR的含义,它们分别代表左侧和右侧。

现在我们把代码改成这样:

var a = b;

这时引擎对a进行 LHS 查询,对b进行 RHS 查询,但是LR并不一定指操作符的左右边,而应该这样理解:

LHS 是为了找到赋值的目标。 RHS 是赋值操作的源头。也就是 LHS 是为了找到变量这个容器本身,给它赋值,而 RHS 是为了取出这个变量的值。

作用域嵌套

当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套,进而形成了一条作用域链。因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量, 或抵达最外层的作用域(也就是全局作用域)为止。

词法作用域

作用域分为两种:

  1. 词法作用域(较为普遍,JavaScript所使用的也是这种)
  2. 动态作用域(使用较少,比如 Bash 脚本、Perl 中的一些模式等)

词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的。

看以下代码,这个例子中有三个逐级嵌套的作用域。

var a = 2; // 作用域1 全局
function foo(){ 
    var b = a * 2; // 作用域2 局部
    function bar(){
        var c = a * b; // 作用域3 局部
    }
}
  1. 作用域是由你书写代码所在位置决定的。
  2. 子级作用域可以访问父级作用域,而父级作用域则不能访问子级作用域。

引擎对作用域的查找

作用域查找会在找到第一个匹配的标识符时停止,在多层的嵌套作用域中可以定义同名的标识符,这叫做“遮蔽效应”(内部的标识符“遮蔽”了外部的标识符)。也就是说查找时会从运行所在的作用域开始,逐级往上查找,直到遇见第一个标识符为止。

全局变量(全局作用域下定义的变量)会自动变成全局对象(比如浏览器中的 window对象)。

var a = 1;
function foo(){
    var a = 2;
    console.log(a); // 2
    function bar(){
        var a = 3;
        console.log(a); // 3
        console.log(window.a); // 1
    }
}

非全局的变量如果被遮蔽了,就无论如何都无法被访问到,所以在上述代码中,bar内的作用域无法访问到foo下定义的变量a
词法作用域查找只会查找一级标识符,比如ab,如果是foo.bar,词法作用域查找只会试图查找foo标识符,找到这个变量后,由对象属性访问规则接管属性的访问。

欺骗语法

虽然词法作用域是在代码编写时确定的,但还是有方法可以在引擎运行时动态修改词法作用域,有两种机制:

  1. eval
  2. with

eval

JavaScript 的 eval函数可以接受一个字符串参数并作为代码语句来执行, 就好像代码是原本就在那个位置一样,考虑以下代码:

function foo(str){
    eval(str) // 欺骗
    console.log(a);
}
var a = 1;
foo("var a = 2;"); // 2

仿佛eval中传入的参数语句原本就在那一样,会创建一个变量a,并遮蔽了外部作用域的同名变量。

注意

  • eval通常被用来执行动态创建的代码,可以根据程序逻辑动态地将变量和函数以字符串形式拼接在一起之后传递进去。
  • 在严格模式下,eval无法修改所在的作用域。
  • eval相似的还有,setTimeoutsetIntervalnew Function

with

with通常被当作重复引用同一个对象中的多个属性的快捷方式, 可以不需要重复引用对象本身。

使用方法如下:

var obj1 = { a:1,b:2 };
function foo(obj){
    with(obj){
        a = 2;
        b = 3;
    }
}
foo(obj1);
console.log(obj1); // {a: 2, b: 3}

然而考虑以下代码:

var obj2 = { a:1,b:2 };
function foo(obj){
    with(obj){
        a = 2;
        b = 3;
        c = 4;
    }
}
foo(obj2);
console.log(obj2); // {a: 2, b: 3}
console.log(c); // 4 不好,c被泄露到全局作用域下

尽管with可以将对象处理为词法作用域,但是这样块内部正常的var操作并不会限制在这个块的作用域下,而是被添加到with所在的函数作用域下,而不通过var声明变量将视为声明全局变量。

性能

evalwith会在运行时修改或创建新的作用域,以此来欺骗其他书写时定义的词法作用域,然而 JavaScript 引擎会在编译阶段进行性能优化,有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有的变量和函数的定义位置,才能在执行过程中快速找到标识符。但是通过evalwith来欺骗词法作用域会导致引擎无法知道他们对词法作用域做了什么样的改动,只能对部分不进行优化,因此如果在代码中大量使用evalwith就会导致代码运行起来变得非常慢。

函数作用域和块作用域

函数作用域

在 JavaScript 中每声明一个函数就会创建一个函数作用域,同时属于这个函数的所有变量在整个函数的范围内都可以使用。

块作用域

从 ES3 发布以来,JavaScript 就有了块作用域,创建块作用域的几种方式有:

  • with

上面已经讲了,这里不再复述。

  • try/catch

try/catchcatch 分句会创建一个块作用域,其中声明的变量仅在 catch 内部有效。

javascript try{ throw 2; }catch(a){ console.log(a); }
  • letconst

ES6 引入的新关键词,提供了除 var 以外的变量声明方式,它们可以将变量绑定到所在的任意作用域中(通常是{}内部)。

javascript { let a = 2; } console.log(a); // ReferenceError: a is not defined

注意:使用 letconst 进行的声明不会在块作用域中进行提升。

提升

考虑这段代码:

console.log( a ); 
var a = 2;

输入结果是undefined,而不是ReferenceError

为什么呢?

前面说过,编译阶段时,会把声明分成两个动作,也就是只把var a部分进行提升。

所以第二段代码真正的执行顺序是:

var a; // 这时 a 是 undefined
console.log(a);
a = 2;
  • 编译阶段时会把所有的声明操作提升,而赋值操作原地执行。
  • 函数声明会把整个函数提升,而不仅仅是函数名。

函数优先

虽然函数和变量都会被提升,但函数声明的优先级高于变量声明,所以:

foo(); // 1
var foo;
function foo(){
    console.log(1);
}
foo = function(){
    console.log(2);
}

因为这个代码片段会被引擎理解为如下形式:

function foo(){
    console.log(1);
}
foo(); // 1
foo = function() { 
  console.log( 2 );
};

这个值得一提的是,尽管var foo出现在function foo()...之前,但由于函数声明会被优先提升,所以它会被忽略(因为重复声明了)。 注意:

JavaScript 会忽略前面已经声明过的声明,不管它是变量还是函数,只要其名称相同。

后记

5、什么是作用域链

在了解作用域链之前,先来了解下什么是作用域

作用域(scope),通常来说,一段程序代码中所用到的名字并不总是有效/可用的,而限定这个名字的可用性的代码范围就是这个名字的作用域。

js是没有块级作用域的,也就是说外面可以使用{}里面的变量,包括do while/for中的{}。

例如

for ( var i=0; i<10; i++ ) {
    var a=3
}
console.log(a)//3

但函数内定义的变量函数外是不可以使用的,例如console.log(a),不能使用fn()里的var a=1,没有声明变量,就会报错。

function fn() {
   var a=1;
}
console.log(a);//"ReferenceError:a is not defined"

但函数里面的却可以使用外面的变量,这就是引申出出了作用域链。

作用域链:

  1. 函数在执行的过程中,先从自己内部找变量,
  2. 如果找不到,再从创建当前函数所在的作用域去找, 以此往上,
  3. 注意找的是变量的当前的状态。

例如

var a = 1
function fn1(){
  function fn2(){
    console.log(a)
  }
  function fn3(){
    var a = 4
    fn2()
  }
  var a = 2
  return fn3
}
var fn = fn1()
fn() //输出2

解析:var fn = fn1(),执行函数fn1(),因为return fn3,再执行函数fn3(),再执行函数fn2(),再执行console.log(a),那么a是多少呢?fn2里没有变量a,去fn2的上一级(声明fn2的地方)fn1里去找,找到了 var a=2, 那么console.log(2),输出了2。

6、解释下 let 和 const 的块级作用域

在ES6之前,我们都是用 var 关键字声明变量。无论声明在何处,都会被视为声明在函数的最顶部(不在函数内即在全局作用域的最顶部)。这就是函数变量提升 例如:

	  function aa() {
    if(flag) {
        var test = 'hello man'
    } else {
        console.log(test)
    }
  }

12345678

变量声明后代码实际上是:

  function aa() {
    var test // 变量提升,函数最顶部
    if(flag) {
        test = 'hello man'
    } else {
        //此处访问 test 值为 undefined
        console.log(test)
    }
    //此处访问 test 值为 undefined
  }
12345678910

所以不用关心 flag 是否为 true or false。实际上,无论如何 test 都会被创建声明。

接下来ES6主角登场:
我们通常用 let 和 const 来声明,let 表示变量、const 表示常量。let 和 const 都是块级作用域。怎么理解这个块级作用域?

在一个函数内部
在一个代码块内部
只要在 {}花括号内 的代码块即可以认为 let 和 const 的作用域。

  function aa() {
    if(flag) {
       let test = 'hello man'
    } else {
        //test 在此处访问不到
        console.log(test)
    }
  }
12345678

et 的作用域是在它所在当前代码块,但不会被提升到当前函数的最顶部。

再来说说 const
const 声明的变量必须提供一个值,而且会被认为是常量,意思就是它的值被设置完成后就不能再修改了。

    const name = 'cc'
    name = 'yy' // 再次赋值此时会报错
12

还有,如果 const 的是一个对象,对象所包含的值是可以被修改的。抽象一点儿说,就是对象所指向的地址不能改变,而变量成员是可以修改的。

看以下例子就非常清楚:

    const student = { name: 'cc' }
    
    student.name = 'yy' // 修改变量成员,一点儿毛病没有
    
    student  = { name: 'yy' } // 修改变量绑定,这样子就会报错了
12345

letconst命令,这个区块对这些命令声明的变量,从一开始就形成了封闭作用域。凡是在声明之前就使用这些变量,就会报错。

总之,在代码块内,使用let命令声明变量之前,该变量都是不可用的。这在语法上,称为“暂时性死区”(temporal dead zone,简称TDZ)。

if (true) {
  // TDZ开始
  tmp = 'abc'; // ReferenceError
  console.log(tmp); // ReferenceError
 
  let tmp; // TDZ结束
  console.log(tmp); // undefined
 
  tmp = 123;
  console.log(tmp); // 123
 
  typeof x; // ReferenceError
  let x;
 
  typeof undeclared_variable // "undefined"

1234567891011121314151617

上面代码中,在let命令声明变量tmp,x之前,都属于变量tmp,x的“死区”。只要用到该变量就会报错.

undeclared_variable是一个不存在的变量名,结果返回“undefined”。

function bar(x = y, y = 2) {
  return [x, y];
}
 
bar(); // 报错

123456

上面代码中,调用bar函数之所以报错(某些实现可能不报错),是因为参数x默认值等于另一个参数y,而此时y还没有声明,属于”死区“。如果y的默认值是x,就不会报错,因为此时x已经声明了。

function bar(x = 2, y = x) {
  return [x, y];
}
bar(); // [2, 2]

7、什么是JavaScript?(这是基本题,对很多程序员来说也是送分题!)

JavaScript是客户端和服务器端脚本语言,可以插入到HTML页面中,并且是目前较热门的Web开发语言。同时,JavaScript也是面向对象编程语言。

8、对闭包的看法,为什么要用闭包?说一下闭包的原理以及应用场景

一、什么是闭包

闭包就是能够读取其他函数内部变量的函数。例如在javascript中,只有函数内部的子函数才能读取局部变量,所以闭包可以理解成“定义在一个函数内部的函数“。在本质上,闭包是将函数内部和函数外部连接起来的桥梁。

函数执行后返回结果是一个内部函数,并被外部变量所引用,如果内部函数持有被执行函数作用域的变量,即形成了闭包。

可以在内部函数访问到外部函数作用域。使用闭包,一可以读取函数作用域中的变量,二可以将函数中的变量存储到内存中,保护变量不被污染。而正因闭包会把函数中的变量值存储到内存中,会对内存有消耗,所以不能滥用闭包,否则会影响网页性能,造成内存泄露。当不需要使用闭包时,要及时释放内存,可将内存函数对象的的变量赋值为 null。

二、闭包原理

函数执行分为两个阶段(预编译阶段和执行阶段)。

  • 在预编译阶段,如果发现内部函数使用了外部函数的变量,则会在内存中创建一个“闭包”对象并保持对应变量值,如果已存在“闭包”,则只需要增加对应的属性值即可。
  • 执行完后,函数执行上下文会被销毁,函数对“闭包”对象的引用也会销毁,但其内部函数还持有对该“闭包”的引用,所以内部函数还可以继续使用“外部函数”中的变量。

利用函数作用域链的特性,一个函数内部定义的函数会将包含外部函数的活动对象添加到他的作用域链中,函数执行完毕,其执行作用域链被销毁,但其因内部函数的作用域链仍然在引用这个活动对象,所以其活动对象不会被销毁,直到内部函数被销毁后才销毁。

三、优点

1.可以从内部函数访问外部函数的作用域中的变量,且访问到的变量长期驻扎在内存中,可供之后使用

2.避免变量污染全局变量

3.把变量存到独立的作用域中,作为私有成员存在

四、缺点

1.对内存消耗有负面影响。因内部函数保存了对外部函数变量的引用,导致无法被垃圾回收,增大内存使用量,所以使用不当会造成内存泄露

2.对处理速度有负面影响。闭包的层级决定了引用的外部变量在查找时经过的作用域链长度

3.可能获取到意外的值

五、应用场景

场景一:典型的应用是模块封装,在各模块规范出来之前,都是用这样的方式阻止变量污染全局。

var fn = (function(){



    //这样声明为模块的私有变量,外界无法直接访问



    var foo = 1;



    function fn(){};



    fn.prototype.bar = function(){



        return foo;



    }



    return fn;



}());

场景二:在循环中创建闭包,防止取到意外的值。如下,无论哪个元素触发事件,都会弹出3。因为函数执行后引用 i 是同一个,而 i 在循环结束后就是 3

for(var i=0;i<3;i++){



    document.getElementById('id'+i).onclick = function(){



        alert(i);



    };



}



 



//可用闭包解决



function makeCallbak(num){



    return function(){



        alert(num);



    };



}



for(var i=0;i<3;i++){



     document.getElementById('id'+i).onclick = makeCallbal(i);



}

9、列举Java和JavaScript之间的区别?

Java是一门十分完整、成熟的编程语言。相比之下,JavaScript是一个可以被引入HTML页面的编程语言。这两种语言并不完全相互依赖,而是针对不同的意图而设计的。 Java是一种面向对象编程(OOPS)或结构化编程语言,类似的如C ++或C,而JavaScript是客户端脚本语言,它被称为非结构化编程。

10、如何确定this指向

如果用一句话说明 this 的指向,那么即是: 谁调用它,this 就指向谁。

但是仅通过这句话,我们很多时候并不能准确判断 this 的指向。因此我们需要借助一些规则去帮助自己:

this 的指向可以按照以下顺序判断:

全局环境中的 this

浏览器环境:无论是否在严格模式下,在全局执行环境中(在任何函数体外部)this 都指向全局对象 window;

node 环境:无论是否在严格模式下,在全局执行环境中(在任何函数体外部),this 都是空对象 {};

是否是 new 绑定

如果是 new 绑定,并且构造函数中没有返回 function 或者是 object,那么 this 指向这个新对象。如下:

构造函数返回值不是 function 或 object。new Super() 返回的是 this 对象。

img

构造函数返回值是 function 或 object,new Super()是返回的是Super种返回的对象。

img

函数是否通过 call,apply 调用,或者使用了 bind 绑定,如果是,那么this绑定的就是指定的对象【归结为显式绑定】。

img

这里同样需要注意一种特殊情况,如果 call,apply 或者 bind 传入的第一个参数值是 undefined 或者 null,严格模式下 this 的值为传入的值 null /undefined。非严格模式下,实际应用的默认绑定规则,this 指向全局对象(node环境为global,浏览器环境为window)

img

隐式绑定,函数的调用是在某个对象上触发的,即调用位置上存在上下文对象。典型的隐式调用为: xxx.fn()

img

默认绑定,在不能应用其它绑定规则时使用的默认规则,通常是独立函数调用。

非严格模式: node环境,执行全局对象 global,浏览器环境,执行全局对象 window。

严格模式:执行 undefined

img

箭头函数的情况:

箭头函数没有自己的this,继承外层上下文绑定的this。

11、改变this指向的方式有哪些?

call、apply、bind三者为改变this指向的方法。

共同点:第一个参数都为改变this的指针。若第一参数为null/undefined,this默认指向window

call(无数个参数)

  • 第一个参数:改变this指向
  • 第二个参数:实参
  • 使用之后会自动执行该函数
function fn(a,b,c){
        console.log(this,a+b+c); // this指向window
    }
    fn();
    fn.call(document,1,2,3);//call改变之后this指向document  
    //输出 #document 6   1,2,3是实参 结果相加为6

apply(两个参数)

  • 第一个参数:改变this指向
  • 第二个参数:数组(里面为实参)
  • 使用时候会自动执行函数
function fn(a,b,c){
        console.log(this,a+b+c); 
    }
    fn();
    fn.apply(document,[1,2,3]); 

bind(无数个参数)

  • 第一个参数:改变this指向
  • 第二个参数之后:实参
  • 返回值为一个新的函数
  • 使用的时候需要手动调用下返回 的新函数(不会自动执行)
function fn(a,b,c){
    console.log(this,a+b+c); //window
}
let ff = fn.bind('小明',1,2,3); //手动调用一下

call、apply与bind区别:前两个可以自动执行,bind不会自动执行,需要手动调用

call、bind与apply区别:前两个都有无数个参数,apply只有两个参数,而且第二个参数为数组

12、 箭头函数的this

1.普通函数的this:指向它的调用者,如果没有调用者则默认指向window.
2.箭头函数的this: 指向箭头函数定义时所处的对象,而不是箭头函数使用时所在的对象,默认使用父级的this.

13、谈一下你对原型链的理解,画一个经典的原型链图示

img

具体参考 这篇文章

https://note.youdao.com/ynoteshare1/index.html?id=0c454a4f45787988f5b2e6f7043f70e7&type=note

14、举例说明JS如何实现继承

js继承总共分成5种,包括构造函数式继承、原型链式继承、组合式继承、寄生式继承和寄生组合式继承。

构造函数式继承

首先来看第一种,构造函数式继承,顾名思义,也就是利用函数去实现继承;

假设我们现在有一个父类函数:

// 父类构造函数
function Parent(color) {
    this.color = color;
    this.print = function() {
        console.log(this.color);
    }
}

现在要编写一个子类函数来继承这个父类,如下:

// 子类构造函数
function Son(color) {
    Parent.call(this, color);
}

上面代码可以看到,子类Son是通过Parent.call的方式去调用父类构造函数,然后把this对象传进去,执行父类构造函数之后,子类Son就拥有了父类定义的color和print方法。调用一下该方法,输出如下:

// 测试
var son1 = new Son('red');
son1.print(); // red

var son2 = new Son('blue');
son2.print(); // blue

可以看到son1和son2都正常继承了父类的print方法和各自传进去的color属性;

以上就是构造函数式继承的实现了,这是最原始的js实现继承的方式;

但是当我们深入想一下会发现,这种根本就不是传统意义上的继承!

因为每一个Son子类调用父类生成的对象,都是各自独立的,也就是说,如果父类希望有一个公共的属性是所有子类实例共享的话,是没办法实现的。什么意思呢,来看下面的代码:

function Flower() {
    this.colors = ['黄色', '红色'];
    this.print = function () {
        console.log(this.colors)
    }
}

function Rose() {
    Flower.call(this);
}

var r1 = new Rose();
var r2 = new Rose();

console.log(r1.print()); // [ '黄色', '红色' ]
console.log(r2.print()); // [ '黄色', '红色' ]

我们现在有一个基类Flower,它有一个属性colors,现在我们把某一个实例的colors值改一下:

r1.colors.push('紫色');

console.log(r1.print()); // [ '黄色', '红色', '紫色' ]
console.log(r2.print()); // [ '黄色', '红色' ]

结果如上,显然,改变的只有r1的值,因为通过构造函数创造出来的实例对象中,所有的属性和方法都是实例内部独立的,并不会跟其他实例共享。
总结一下构造函数的优缺点:

  • 优点:所有的基本属性独立,不会被其他实例所影响;
  • 缺点:所有希望共享的方法和属性也独立了,没有办法通过修改父类某一处来达到所有子实例同时更新的效果;同时,每次创建子类都会调用父类构造函数一次,所以每个子实例都拷贝了一份父类函数的内容,如果父类很大的话会影响性能;

原型链继承

下面我们来看第二种继承方式,原型链式继承;

同样先来看下例子:

function Parent() {
    this.color = 'red';
    this.print = function() {
        console.log(this.color);
    }
}
function Son() {
}

我们有一个父类和一个空的子类;

Son.prototype = new Parent();
Son.prototype.constructor = Son;

接着我们把子函数的原型属性赋值给了父函数的实例;

var son1 = new Son();
son1.print(); // red

最后新建子类实例,调用父类的方法,成功拿到父类的color和print属性方法;

我们重点来分析一下下面两行代码:

Son.prototype = new Parent();
Son.prototype.constructor = Son;

这段代码中,子函数的原型赋给了父函数的实例,我们知道prototype是函数中的一个属性,js的一个特性就是:**如果一个对象某个属性找不到,会沿着它的原型往上去寻找,直到原型链的最后才会停止寻找。**关于原型更多基础的知识,可以参考一下其他文章,或许以后我也会出一期专门讲解原型和原型链的文章。

回到代码,我们看到最后实例son成功调用了Print方法,输出了color属性,这是因为son从函数Son的prototype属性上面去找到的,也就是从new Parent这个对象里面找到的;

这种方式也不是真正的继承,因为所有的子实例的属性和方法,都在父类同一个实例上了,所以一旦某一个子实例修改了其中的方法,其他所有的子实例都会被影响,来看下代码:

function Flower() {
    this.colors = ['黄色', '红色'];
    this.print = function () {
        console.log(this.colors)
    }
}

function Rose() {}
Rose.prototype = new Flower();
Rose.prototype.constructor = Rose;

var r1 = new Rose();
var r2 = new Rose();

console.log(r1.print()); // [ '黄色', '红色' ]
console.log(r1.print()); // [ '黄色', '红色' ]

r1.colors.push('紫色');

console.log(r1.print()); // [ '黄色', '红色', '紫色' ]
console.log(r2.print()); // [ '黄色', '红色', '紫色' ]

还是刚才的例子,这次Rose子类选择了原型链继承,所以,子实例r1修改了colors之后,r2实例的colors也被改动了,这就是原型链继承不好的地方。

来总结下原型链继承的优缺点:

  • 优点:很好的实现了方法的共享;
  • 缺点:正是因为什么都共享了,所以导致一切的属性都是共享的,只要某一个实例进行修改,那么所有的属性都会变化;

组合式继承

这里来介绍第三种继承方式,组合式继承;

这种继承方式很好理解,既然构造函数式继承和原型链继承都有各自的优缺点,那么我们把它们各自的优点整合起来,不就完美了吗?

组合式继承做的就是这个事情~来看一段代码例子:

function Parent(color) {
    this.color = color;
}
Parent.prototype.print = function() {
    console.log(this.color);
}
function Son(color) {
    Parent.call(this, color);
}
Son.prototype = new Parent();
Son.prototype.constructor = Son;

var son1 = new Son('red');
son1.print(); // red

var son2 = new Son('blue');
son2.print(); // blue

上面代码中,在Son子类中,使用了Parent.call来调用父类构造函数,同时又将Son.prototype赋给了父类实例;为什么要这样做呢?为什么这样就能解决上面两种继承的问题呢?
我们接着分析一下,使用Parent.call调用了父类构造函数之后,那么,以后所有通过new Son创建出来的实例,就单独拷贝了一份父类构造函数里面定义的属性和方法,这是前面构造函数继承所提到的一样的原理;

然后,再把子类原型prototype赋值给父类的实例,这样,**所有子类的实例对象就可以共享父类原型上定义的所有属性和方法。**这也不难理解,因为子实例会沿着原型链去找到父类函数的原型。

因此,只要我们定义父类函数的时候,**将私有属性和方法放在构造函数里面,将共享属性和方法放在原型上,**就能让子类使用了。

以上就是组合式继承,它很好的融合了构造函数继承和原型链继承,发挥两者的优势之处,因此,它算是真正意义上的继承方式。

寄生式继承

既然上面的组合式继承都已经这么完美了,为什么还需要其他的继承方式呢?
我们细想一下,Son.prototype = new Parent();这行代码,它有什么问题没有?

显然,每次我们实例化子类的时候,都需要调用一次父类构造函数,那么,如果父类构造函数是一个很大很长的函数,那么每次实例化子类就会执行很长时间。

实际上我们并不需要重新执行父类函数,我们只是想要继承父类的原型。

寄生式继承就是在做这个事情,它是基于原型链式继承的改良版:

var obj = {
    color: 'red',
    print: function() {
        console.log(this.color);
    }
};

var son1 = Object.create(obj);
son1.print(); // red

var son2 = Object.create(obj);
son2.print(); // red

寄生式继承本质上还是原型链继承,Object.create(obj);方法意思是以obj为原型构造对象,所以寄生式继承不需要构造函数,但是同样有着原型链继承的优缺点,也就是它把所有的属性和方法都共享了。

寄生组合式继承

接下来到我们最后一个继承方式,也就是目前业界最为完美的继承解决方案:寄生组合式继承。

没错,它就是es6的class语法实现原理。
但是如果你理解了组合式继承,那么理解这个方式也很简单,只要记住,它出现的主要目的,是为了解决组合式继承中每次都需要new Parent导致的执行多一次父类构造函数的缺点。

下面来看代码:

function Parent(color) {
    this.color = color;
}
Parent.prototype.print = function() {
    console.log(this.color);
}
function Son(color) {
    Parent.call(this, color);
}
Son.prototype = Object.create(Parent.prototype);
Son.prototype.constructor = Son;

var son1 = new Son('red');
son1.print(); // red

var son2 = new Son('blue');
son2.print(); // blue

这段代码不同之处只有一个,就是把原来的Son.prototype = new Parent();修改为了Son.prototype = Object.create(Parent.prototype);

我们前面讲过,Object.create方法是以传入的对象为原型,创建一个新对象;创建了这个新对象之后,又赋值给了Son.prototype,因此Son的原型最终指向的其实就是父类的原型对象,和new Parent是一样的效果;

到这里,我们5中js的继承方式也就讲完了;

15、什么是负无穷大?

负无穷大是JavaScript中的一个数字,可以通过将负数除以零来得到。

16、你对事件循环有了解吗?说说看!

img

我们都知道,javascript从诞生之日起就是一门单线程的非阻塞的脚本语言。这是由其最初的用途来决定的:与浏览器交互。

单线程意味着,javascript代码在执行的任何时候,都只有一个主线程来处理所有的任务。

而非阻塞则是当代码需要进行一项异步任务(无法立刻返回结果,需要花一定时间才能返回的任务,如I/O事件)的时候,主线程会挂起(pending)这个任务,然后在异步任务返回结果的时候再根据一定规则去执行相应的回调。

单线程是必要的,也是javascript这门语言的基石,原因之一在其最初也是最主要的执行环境——浏览器中,我们需要进行各种各样的dom操作。试想一下 如果javascript是多线程的,那么当两个线程同时对dom进行一项操作,例如一个向其添加事件,而另一个删除了这个dom,此时该如何处理呢?因此,为了保证不会 发生类似于这个例子中的情景,javascript选择只用一个主线程来执行代码,这样就保证了程序执行的一致性。

当然,现如今人们也意识到,单线程在保证了执行顺序的同时也限制了javascript的效率,因此开发出了web worker技术。这项技术号称让javascript成为一门多线程语言。

然而,使用web worker技术开的多线程有着诸多限制,例如:所有新线程都受主线程的完全控制,不能独立执行。这意味着这些“线程” 实际上应属于主线程的子线程。另外,这些子线程并没有执行I/O操作的权限,只能为主线程分担一些诸如计算等任务。所以严格来讲这些线程并没有完整的功能,也因此这项技术并非改变了javascript语言的单线程本质。

可以预见,未来的javascript也会一直是一门单线程的语言。

话说回来,前面提到javascript的另一个特点是“非阻塞”,那么javascript引擎到底是如何实现的这一点呢?答案就是今天这篇文章的主角——event loop(事件循环)。

注:虽然nodejs中的也存在与传统浏览器环境下的相似的事件循环。然而两者间却有着诸多不同,故把两者分开,单独解释。

*正文*

浏览器环境下js引擎的事件循环机制

1.执行栈与事件队列

当javascript代码执行的时候会将不同的变量存于内存中的不同位置:堆(heap)和栈(stack)中来加以区分。其中,堆里存放着一些对象。而栈中则存放着一些基础类型变量以及对象的指针。 但是我们这里说的执行栈和上面这个栈的意义却有些不同。

我们知道,当我们调用一个方法的时候,js会生成一个与这个方法对应的执行环境(context),又叫执行上下文。这个执行环境中存在着这个方法的私有作用域,上层作用域的指向,方法的参数,这个作用域中定义的变量以及这个作用域的this对象。 而当一系列方法被依次调用的时候,因为js是单线程的,同一时间只能执行一个方法,于是这些方法被排队在一个单独的地方。这个地方被称为执行栈。

当一个脚本第一次执行的时候,js引擎会解析这段代码,并将其中的同步代码按照执行顺序加入执行栈中,然后从头开始执行。如果当前执行的是一个方法,那么js会向执行栈中添加这个方法的执行环境,然后进入这个执行环境继续执行其中的代码。当这个执行环境中的代码 执行完毕并返回结果后,js会退出这个执行环境并把这个执行环境销毁,回到上一个方法的执行环境。。这个过程反复进行,直到执行栈中的代码全部执行完毕。

img

2.macro task与micro task

以上的事件循环过程是一个宏观的表述,实际上因为异步任务之间并不相同,因此他们的执行优先级也有区别。不同的异步任务被分为两类:微任务(micro task)和宏任务(macro task)。

以下事件属于宏任务:

  • setInterval()
  • setTimeout()

以下事件属于微任务

  • new Promise()
  • new MutaionObserver()

前面我们介绍过,在一个事件循环中,异步事件返回结果后会被放到一个任务队列中。然而,根据这个异步事件的类型,这个事件实际上会被对应的宏任务队列或者微任务队列中去。并且在当前执行栈为空的时候,主线程会 查看微任务队列是否有事件存在。如果不存在,那么再去宏任务队列中取出一个事件并把对应的回到加入当前执行栈;如果存在,则会依次执行队列中事件对应的回调,直到微任务队列为空,然后去宏任务队列中取出最前面的一个事件,把对应的回调加入当前执行栈…如此反复,进入循环。

我们只需记住当当前执行栈执行完毕时会立刻先处理所有微任务队列中的事件,然后再去宏任务队列中取出一个事件。同一次事件循环中,微任务永远在宏任务之前执行

这样就能解释下面这段代码的结果:

setTimeout(function () {
    console.log(1);
});

new Promise(function(resolve,reject){
    console.log(2)
    resolve(3)
}).then(function(val){
    console.log(val);
})

结果为:

2
3
1
 

node环境下的事件循环机制

1.与浏览器环境有何不同?

在node中,事件循环表现出的状态与浏览器中大致相同。不同的是node中有一套自己的模型。node中事件循环的实现是依靠的libuv引擎。我们知道node选择chrome v8引擎作为js解释器,v8引擎将js代码分析后去调用对应的node api,而这些api最后则由libuv引擎驱动,执行对应的任务,并把不同的事件放在不同的队列中等待主线程执行。 因此实际上node中的事件循环存在于libuv引擎中。

2.事件循环模型

下面是一个libuv引擎中的事件循环的模型:

 ┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<──connections───     │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘

注:模型中的每一个方块代表事件循环的一个阶段

这个模型是node官网上的一篇文章中给出的,我下面的解释也都来源于这篇文章。我会在文末把文章地址贴出来,有兴趣的朋友可以亲自与看看原文。

3.事件循环各阶段详解

从上面这个模型中,我们可以大致分析出node中的事件循环的顺序:

外部输入数据–>轮询阶段(poll)–>检查阶段(check)–>关闭事件回调阶段(close callback)–>定时器检测阶段(timer)–>I/O事件回调阶段(I/O callbacks)–>闲置阶段(idle, prepare)–>轮询阶段…

以上各阶段的名称是根据我个人理解的翻译,为了避免错误和歧义,下面解释的时候会用英文来表示这些阶段。

这些阶段大致的功能如下:

  • timers: 这个阶段执行定时器队列中的回调如 setTimeout()setInterval()
  • I/O callbacks: 这个阶段执行几乎所有的回调。但是不包括close事件,定时器和setImmediate()的回调。
  • idle, prepare: 这个阶段仅在内部使用,可以不必理会。
  • poll: 等待新的I/O事件,node在一些特殊情况下会阻塞在这里。
  • check: setImmediate()的回调会在这个阶段执行。
  • close callbacks: 例如socket.on('close', ...)这种close事件的回调。

下面我们来按照代码第一次进入libuv引擎后的顺序来详细解说这些阶段:

poll阶段

当个v8引擎将js代码解析后传入libuv引擎后,循环首先进入poll阶段。poll阶段的执行逻辑如下: 先查看poll queue中是否有事件,有任务就按先进先出的顺序依次执行回调。 当queue为空时,会检查是否有setImmediate()的callback,如果有就进入check阶段执行这些callback。但同时也会检查是否有到期的timer,如果有,就把这些到期的timer的callback按照调用顺序放到timer queue中,之后循环会进入timer阶段执行queue中的 callback。 这两者的顺序是不固定的,收到代码运行的环境的影响。如果两者的queue都是空的,那么loop会在poll阶段停留,直到有一个i/o事件返回,循环会进入i/o callback阶段并立即执行这个事件的callback。

值得注意的是,poll阶段在执行poll queue中的回调时实际上不会无限的执行下去。有两种情况poll阶段会终止执行poll queue中的下一个回调:1.所有回调执行完毕。2.执行数超过了node的限制。

check阶段

check阶段专门用来执行setImmediate()方法的回调,当poll阶段进入空闲状态,并且setImmediate queue中有callback时,事件循环进入这个阶段。

close阶段

当一个socket连接或者一个handle被突然关闭时(例如调用了socket.destroy()方法),close事件会被发送到这个阶段执行回调。否则事件会用process.nextTick()方法发送出去。

timer阶段

这个阶段以先进先出的方式执行所有到期的timer加入timer队列里的callback,一个timer callback指得是一个通过setTimeout或者setInterval函数设置的回调函数。

I/O callback阶段

如上文所言,这个阶段主要执行大部分I/O事件的回调,包括一些为操作系统执行的回调。例如一个TCP连接生错误时,系统需要执行回调来获得这个错误的报告。

4.process.nextTick,setTimeout与setImmediate的区别与使用场景

在node中有三个常用的用来推迟任务执行的方法:process.nextTick,setTimeout(setInterval与之相同)与setImmediate

这三者间存在着一些非常不同的区别:

process.nextTick()

尽管没有提及,但是实际上node中存在着一个特殊的队列,即nextTick queue。这个队列中的回调执行虽然没有被表示为一个阶段,当时这些事件却会在每一个阶段执行完毕准备进入下一个阶段时优先执行。当事件循环准备进入下一个阶段之前,会先检查nextTick queue中是否有任务,如果有,那么会先清空这个队列。与执行poll queue中的任务不同的是,这个操作在队列清空前是不会停止的。这也就意味着,错误的使用process.nextTick()方法会导致node进入一个死循环。。直到内存泄漏。

那么合适使用这个方法比较合适呢?下面有一个例子:

const server = net.createServer(() => {}).listen(8080);

server.on('listening', () => {});

这个例子中当,当listen方法被调用时,除非端口被占用,否则会立刻绑定在对应的端口上。这意味着此时这个端口可以立刻触发listening事件并执行其回调。然而,这时候on('listening)还没有将callback设置好,自然没有callback可以执行。为了避免出现这种情况,node会在listen事件中使用process.nextTick()方法,确保事件在回调函数绑定后被触发。

setTimeout()和setImmediate()

在三个方法中,这两个方法最容易被弄混。实际上,某些情况下这两个方法的表现也非常相似。然而实际上,这两个方法的意义却大为不同。

setTimeout()方法是定义一个回调,并且希望这个回调在我们所指定的时间间隔后第一时间去执行。注意这个“第一时间执行”,这意味着,受到操作系统和当前执行任务的诸多影响,该回调并不会在我们预期的时间间隔后精准的执行。执行的时间存在一定的延迟和误差,这是不可避免的。node会在可以执行timer回调的第一时间去执行你所设定的任务。

setImmediate()方法从意义上将是立刻执行的意思,但是实际上它却是在一个固定的阶段才会执行回调,即poll阶段之后。有趣的是,这个名字的意义和之前提到过的process.nextTick()方法才是最匹配的。node的开发者们也清楚这两个方法的命名上存在一定的混淆,他们表示不会把这两个方法的名字调换过来—因为有大量的node程序使用着这两个方法,调换命名所带来的好处与它的影响相比不值一提。

setTimeout()和不设置时间间隔的setImmediate()表现上及其相似。猜猜下面这段代码的结果是什么?

setTimeout(() => {
    console.log('timeout');
}, 0);

setImmediate(() => {
    console.log('immediate');
});

实际上,答案是不一定。没错,就连node的开发者都无法准确的判断这两者的顺序谁前谁后。这取决于这段代码的运行环境。运行环境中的各种复杂的情况会导致在同步队列里两个方法的顺序随机决定。但是,在一种情况下可以准确判断两个方法回调的执行顺序,那就是在一个I/O事件的回调中。下面这段代码的顺序永远是固定的:

const fs = require('fs');

fs.readFile(__filename, () => {
    setTimeout(() => {
        console.log('timeout');
    }, 0);
    setImmediate(() => {
        console.log('immediate');
    });
});

答案永远是:

immediate
timeout

因为在I/O事件的回调中,setImmediate方法的回调永远在timer的回调前执行。

*尾声*

javascrit的事件循环是这门语言中非常重要且基础的概念。清楚的了解了事件循环的执行顺序和每一个阶段的特点,可以使我们对一段异步代码的执行顺序有一个清晰的认识,从而减少代码运行的不确定性。合理的使用各种延迟事件的方法,有助于代码更好的按照其优先级去执行。这篇文章期望用最易理解的方式和语言准确描述事件循环这个复杂过程,但由于作者自己水平有限,文章中难免出现疏漏。如果您发现了文章中的一些问题,欢迎在留言中提出,我会尽量回复这些评论,把错误更正。

17、微任务和宏任务有什么区别

宏任务

(macro)task,可以理解是每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)。

浏览器为了能够使得JS内部(macro)task与DOM任务能够有序的执行,会在一个(macro)task执行结束后,在下一个(macro)task 执行开始前,对页面进行重新渲染,流程如下:

(macro)task->渲染->(macro)task->...

宏任务包含:

script(整体代码)
setTimeout
setInterval
I/O
UI交互事件
postMessage
MessageChannel
setImmediate(Node.js 环境)

微任务

microtask,可以理解是在当前 task 执行结束后立即执行的任务。也就是说,在当前task任务后,下一个task之前,在渲染之前。

所以它的响应速度相比setTimeout(setTimeout是task)会更快,因为无需等渲染。也就是说,在某一个macrotask执行完后,就会将在它执行期间产生的所有microtask都执行完毕(在渲染前)。

微任务包含:

Promise.then
Object.observe
MutaionObserver
process.nextTick(Node.js 环境)

18、什么是===运算符?

*===被称为严格等式运算符,当两个操作数具有相同的值而没有任何类型转换时,该运算符返回true。*

19、异步解决方案有哪些?

promise 回调 async await

20、Promise, 说说你的理解

一、Promise是什么?

Promise是最早由社区提出和实现的一种解决异步编程的方案,比其他传统的解决方案(回调函数和事件)更合理和更强大。

ES6 将其写进了语言标准,统一了用法,原生提供了Promise对象。
ES6 规定,Promise对象是一个构造函数,用来生成Promise实例。

二、Promise是为解决什么问题而产生的?

promise是为解决异步处理回调金字塔问题而产生的

三、Promise的两个特点

1、Promise对象的状态不受外界影响

1)pending 初始状态

2)fulfilled 成功状态

3)rejected 失败状态

Promise 有以上三种状态,只有异步操作的结果可以决定当前是哪一种状态,其他任何操作都无法改变这个状态

2、Promise的状态一旦改变,就不会再变,任何时候都可以得到这个结果,状态不可以逆,只能由 pending变成fulfilled或者由pending变成rejected

四、Promise的三个缺点

1)无法取消Promise,一旦新建它就会立即执行,无法中途取消
2)如果不设置回调函数,Promise内部抛出的错误,不会反映到外部
3)当处于pending状态时,无法得知目前进展到哪一个阶段,是刚刚开始还是即将完成

五、Promise在哪存放成功回调序列和失败回调序列?

1)onResolvedCallbacks 成功后要执行的回调序列 是一个数组

2)onRejectedCallbacks 失败后要执行的回调序列 是一个数组

以上两个数组存放在Promise 创建实例时给Promise这个类传的函数中,默认都是空数组。
每次实例then的时候 传入 onFulfilled 成功回调 onRejected 失败回调,如果此时的状态是pending 则将onFulfilled和onRejected push到对应的成功回调序列数组和失败回调序列数组中,如果此时的状态是fulfilled 则onFulfilled立即执行,如果此时的状态是rejected则onRejected立即执行

上述序列中的回调函数执行的时候 是有顺序的,即按照顺序依次执行

六、Promise的用法

1、Promise构造函数接受一个函数作为参数,该函数的两个参数分别是resolve和reject。它们是两个函数,由 JavaScript 引擎提供,不用自己部署。

    const promise = new Promise(function(resolve, reject) {
      // ... some code

      if (/* 异步操作成功 */){
        resolve(value);
      } else {
        reject(error);
      }
    });

2、resolve函数的作用是,将Promise对象的状态从“未完成”变为“成功”(即从 pending 变为 resolved),在异步操作成功时调用,并将异步操作的结果,作为参数传递出去;reject函数的作用是,将Promise对象的状态从“未完成”变为“失败”(即从 pending 变为 rejected),在异步操作失败时调用,并将异步操作报出的错误,作为参数传递出去。

3、Promise实例生成以后,可以用then方法分别指定resolved状态和rejected状态的回调函数。

    promise.then(function(value) {
      // success
    }, function(error) {
      // failure
    });

then方法可以接受两个回调函数作为参数。第一个回调函数是Promise对象的状态变为resolved时调用,第二个回调函数是Promise对象的状态变为rejected时调用。其中,第二个函数是可选的,不一定要提供。这两个函数都接受Promise对象传出的值作为参数

Logo

助力广东及东莞地区开发者,代码托管、在线学习与竞赛、技术交流与分享、资源共享、职业发展,成为松山湖开发者首选的工作与学习平台

更多推荐