JavaScript知识点面试总结(未完待续,持续更新中)

1,035 阅读31分钟

一、变量

1. 数据类型

  • 基本数据类型

    • String

    • Number

    • Boolean

    • null

      特殊: typeof null === 'Object' //true

    • Undefined

    • Symbol 符号(ES6新增)

  • 引用数据类型

    • Object
      • Function
      • Array
      • Date
      • RegExp
  • 基本数据类型和引用数据类型的区别(存储位置不同)

    • 原始数据类型直接存储在栈(stack)中的简单数据段,占据空间小、大小固定,属于被频繁使用数据,所以放入栈中存储;
    • 引用数据类型存储在堆(heap)中的对象,占据空间大、大小不固定,如果存储在栈中,将会影响程序运行的性能;引用数据类型在栈中存储了指针,该指针指向堆中该实体的起始地址。当解释器寻找引用值时,会首先检索其在栈中的地址,取得地址后从堆中获得实体

数据封装类对象:ObjectArrayBooleanNumberString

其他对象:FunctionArgumentsMathDateRegExpError

2. 类型判断

2.1 null

typeof null === 'Object' //true

null是唯一一个用typeof检测会返回object基本类型值(注意‘基本’两字)

原因:不同的对象在底层都表示为二进制 在JavaScript中二进制前三位为0的话都会被判断为object类型 null的二进制表示全是0,自然前三位也是0 所以 typeof null === “object”

2.2 引用类型的判断

参考链接:深入理解JS的类型、值、类型转换

2.2.1 typeof

  • typeof 除了能判断基本类型、Object外,还能判断function类型

2.2.2 instanceof

  • 判断对象用 instanceof,其内部机制是通过原型链来判断的

  • instanceof原理:判断实例对象的__proto__属性,和构造函数的prototype属性,是否为同一个引用(是否指向同一个地址)

  • 注意1:虽然说,实例是由构造函数 new 出来的,但是实例的__proto__属性引用的是构造函数的prototype。也就是说,实例的__proto__属性与构造函数本身无关。

    注意2:在原型链上,原型的上面可能还会有原型,以此类推往上走,继续找__proto__属性。这条链上如果能找到, instanceof 的返回结果也是 true。

我们也可以试着实现一下 instanceof
// 基于原型链去判断引用类型,返回true/false
function instanceof(left, right) {
    // 获得类型的原型
    let prototype = right.prototype
    // 获得对象的原型
    left = left.__proto__
    // 判断对象的类型是否等于类型的原型
    while (true) {
    //如果原型为null,则已经到了原型链顶端,判断结束
    	if (left === null)
    		return false
        //左边的原型等于右边的原型,则返回结果
    	if (prototype === left)
    		return true
        //否则就继续向上获取原型
    	left = left.__proto__
    }
}
  • 分析一个问题

问题:已知A继承了B,B继承了C。怎么判断 a 是由A直接生成的实例,还是B直接生成的实例呢?还是C直接生成的实例呢?

分析:这就要用到原型的constructor属性了。

  • foo.__proto__.constructor === Foo的结果为true,但是 foo.__proto__.constructor === Object的结果为false。

所以,用 consturctor判断就比用 instanceof判断,更为严谨。

  • 如果我们想获得一个变量的正确类型,可以通过 Object.prototype.toString.call(xx)

3. 类型转换

2.3.1 显示类型转换

  • 转换成字符串 String()

    toString() 可以被显式调用,或者在需要字符串化时自动调用

    null 转换为 "null",undefined 转换为 "undefined",true 转换为 "true"。 数字的字符串化则遵循通用规则 极小和极大的 数字使用指数形式:

    // 1.07 连续乘以七个 1000
    var a = 1.07 * 1000 * 1000 * 1000 * 1000 * 1000 * 1000 * 1000;
    // 七个1000一共21位数字 
    a.toString(); // "1.07e21"
    

    数组的默认 toString() 方法经过了重新定义,将所有单元字符串化以后再用 "," 连接起来

    var a = [1,2,3];
     a.toString(); // "1,2,3"
    
  • 转换成数字 Number()

    其中 true 转换为 1,false 转换为 0。undefined 转换为 NaN,null 转换为 0。 处理失败 时返回 NaN(处理数字常量失败时会产生语法错误)

  • 转换成布尔值 Boolean()

    undefinednullfalse
    • +0、-0NaN""
    

    除了上面以外的,都为true

2.3.2 隐式类型转换

  • 字符串和数字之间的隐式转换

    一个坑

    [] + {}; // "[object Object]" {} + []; // 0

    console.log([] + {}); //[object Object]

    console.log({} + []); //[object Object]

    第一行代码中,{} 出现在 + 运算符表达式中,因此它被当作一个值(空对象)来处理。 第二行代码中,{} 被当作一个独立的空代码块(不执行任何操作)。代码块结尾不需要分号,所以这里不存在语法上的问题。最后 + [] 将 [] 显式强制类型转换(参见第 4 章) 为 0。

    第四行代码中,{} 其实应该当成一个代码块,而不是一个 Object,当你在console.log使用的时候,{} 被当成了一个 Object

  • 隐式强制类型转换为布尔值

    下面的情况会发生 布尔值隐式强制类型转换。

    • (1)if (..)语句中的条件判断表达式。
    • (2)for ( .. ; .. ; .. )语句中的条件判断表达式(第二个)。
    • (3) while (..) 和 do..while(..) 循环中的条件判断表达式。
    • (4)? :中的条件判断表达式。
    • (5) 逻辑运算符 ||(逻辑或)和 &&(逻辑与)左边的操作数(作为条件判断表达式)。
  • || 与 &&

  • == 与 ===

    == 允许在相等比较中进行强制类型转换,而 === 不允许

    [] == ![] //true
    

    参考链接:为什么[] ==![]

4. 浅拷贝与深拷贝

4.1 浅拷贝

对于对象或数组类型,当我们将a赋值给b,然后更改b中的属性,a也会随着变化。

也就是说,a和b指向了同一块堆内存,所以修改其中任意的值,另一个值都会随之变化,这就是浅拷贝。

实现:Object.assign()

4.2 深拷贝

那么相应的,如果给b放到新的内存中,将a的各个属性都复制到新内存里,就是深拷贝。

也就是说,当b中的属性有变化的时候,a内的属性不会发生变化。

实现:

  • jQuery.extend()

  • JSON.parse(JSON.stringify(object))

    • 会忽略 undefined
    • 会忽略 symbol
    • 不能序列化函数
    • 不能解决循环引用的对象

参考链接:深拷贝与浅拷贝详解

二、函数

1. 函数调用

4种方式,每种方式的不同在于this的初始化

一般而言,在Javascript中,this指向函数执行时的当前对象。

1.1. 作为一个函数调用

function myFunction(a, b) {
    return a * b;
}
myFunction(10, 2);  // myFunction(10, 2) 返回 20
window.myFunction(10, 2); //myFunction() 和 window.myFunction() 是一样的

函数作为全局对象调用,会使 this 的值成为全局对象。 使用 window 对象作为一个变量容易造成程序崩溃。

1.2 函数作为方法调用

var myObject = {
    firstName:"John",
    lastName: "Doe",
    fullName: function () {
        return this.firstName + " " + this.lastName;
    }
}
myObject.fullName();         // 返回 "John Doe"

fullName 方法是一个函数。函数属于对象。 myObject 是函数的所有者。this对象,拥有 JavaScript 代码。实例中 this 的值为 myObject 对象。

函数作为对象方法调用,会使得 this 的值成为对象本身。

1.3 使用构造函数调用

// 构造函数:
function myFunction(arg1, arg2) {
    this.firstName = arg1;
    this.lastName  = arg2;
}
 
// This    creates a new object
var x = new myFunction("John","Doe");
x.firstName;                             // 返回 "John"

构造函数中 this 关键字没有任何的值。 this 的值在函数调用实例化对象(new object)时创建。

1.4 使用函数的方法调用

call()apply() 是预定义的函数方法。 两个方法可用于调用函数,两个方法的第一个参数必须是对象本身。

function myFunction(a, b) {
    return a * b;
}
myObject = myFunction.call(myObject, 10, 2);     // 返回 20
function myFunction(a, b) {
    return a * b;
}
myArray = [10, 2];
myObject = myFunction.apply(myObject, myArray);  // 返回 20

通过 call() 或 apply() 方法你可以设置 this 的值, 且作为已存在对象的新方法调用。

在 JavaScript 严格模式(strict mode)下, 在调用函数时第一个参数会成为 this 的值, 即使该参数不是一个对象。

在 JavaScript 非严格模式(non-strict mode)下, 如果第一个参数的值是 null 或 undefined, 它将使用全局对象替代。

2. call、apply、bind的区别

  • call和apply改变了函数的this上下文后便立即执行该函数,而bind不会立即执行函数,而是将函数返回。
  • 他们第一个参数都是要改变上下文的对象,而call、bind从第二个参数开始以参数列表的形式展现,apply则是把除了改变上下文对象的参数放在一个数组里面作为它的第二个参数。
//手写call
let obj = {
  msg: "我叫王大锤",
}

function foo() {
  console.log(this.msg)
}

// foo.call(obj)
//调用call的原理就跟这里一样,将函数挂载到对象上,然后在对象中执行这个函数
// obj.foo = foo
// obj.foo()

Function.prototype.myCall = function (thisArg, ...args) {
  const fn = Symbol("fn") // 声明一个独有的Symbol属性, 防止fn覆盖已有属性
  thisArg = thisArg || window // 若没有传入this, 默认绑定window对象
  thisArg[fn] = this //this指向调用者
  const result = thisArg[fn](...args) //执行当前函数
  delete thisArg[fn]
  return result
}

foo.myCall(obj)
// 手写apply (args传入一个数组的形式),原理其实和call差不多,只是入参不一样
Function.prototype.myApply = function (thisArg, args = []) {
  const fn = Symbol("fn")
  thisArg = thisArg || window
  thisArg[fn] = this
  //虽然apply()接收的是一个数组,但在调用原函数时,依然要展开参数数组
  //可以对照原生apply(),原函数接收到展开的参数数组
  const result = thisArg[fn](...args)
  delete thisArg[fn]
  return result
}

foo.myApply(obj)
// 手写apply (args传入一个数组的形式),原理其实和call差不多,只是入参不一样
Function.prototype.myApply = function (thisArg, args = []) {
  const fn = Symbol("fn")
  thisArg = thisArg || window
  thisArg[fn] = this
  //虽然apply()接收的是一个数组,但在调用原函数时,依然要展开参数数组
  //可以对照原生apply(),原函数接收到展开的参数数组
  const result = thisArg[fn](...args)
  delete thisArg[fn]
  return result
}

foo.myApply(obj)

参考链接:详解call、apply、bind

3. 作用域及作用域链

  • 作用域就是变量与函数的可访问范围,即作用域控制着变量与函数的可见性和生命周期

  • 内层函数可访问外层函数局部变量

  • 外层函数不能访问内层函数局部变量

  • 通俗地讲,当声明一个函数时,局部作用域一级一级向上包起来,就是作用域链。

    1.当执行函数时,总是先从函数内部找寻局部变量

    2.如果内部找不到(函数的局部作用域没有),则会向创建函数的作用域(声明函数的作用域)寻找,依次向上

4. 闭包

4.1 什么是闭包?

闭包就是能够读取其他函数内部变量的函数。

函数 A 内部有一个函数 B,函数 B 可以访问到函数 A 中的变量,那么函数 B 就是闭包。

4.2 闭包的用途

  • 可以读取函数内部的变量
  • 让这些变量始终保持在内存中

经典面试题,循环中使用闭包解决 var 定义函数的问题

for (var i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i)
  }, i * 1000)
}
//我们希望输出的是1,2,3,4,5,但因为 setTimeout 是个异步函数,所以会先把循环全部执行完毕,这时候 i就是 6 了,所以会输出五个6

解决方法:

  1. 闭包

    for (var i = 1; i <= 5; i++) {
      (function(j) {
        setTimeout(function timer() {
          console.log(j)
        }, j * 1000)
      })(i)
    }
    
  2. 使用 setTimeout 的第三个参数,这个参数会被当成 timer 函数的参数传入

    for (var i = 1; i <= 5; i++) {
      setTimeout(
        function timer(j) {
          console.log(j)
        },
        i * 1000,
        i
      )
    }
    
  3. 使用let

    for (let i = 1; i <= 5; i++) {
      setTimeout(function timer() {
        console.log(i)
      }, i * 1000)
    }
    

4.3 闭包的优缺点

  • 优点:避免全局变量的污染
  • 缺点:由于闭包会使得函数中的变量都被保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题,在IE中可能导致内存泄露
  • 解决方法:在退出函数之前,将不使用的局部变量全部删除

三、对象

1. 创建对象的方式

  1. 对象字面量

    person={firstname:"Mark",lastname:"Yun",age:25,eyecolor:"black"};
    
  2. 工厂方式(内置对象)

    var wcDog =new Object();
         wcDog.name="旺财";
         wcDog.age=3;
         wcDog.work=function(){
           alert("我是"+wcDog.name+",汪汪汪......");
         }
         wcDog.work();
    

    方式1与方式2效果一样,第一种写法,person会指向Object

  3. 通过构造函数

    // 无参
    function Person(){}
    	var person=new Person();//定义一个function,如果使用new"实例化",该function可以看作是一个Class
            person.name="Mark";
            person.age="25";
            person.work=function(){
            alert(person.name+" hello...");
    }
    person.work();
    
    // 带参(用this关键字定义构造的上下文属性)
    function Pet(name,age,hobby){
           this.name=name;//this作用域:当前对象
           this.age=age;
           this.hobby=hobby;
           this.eat=function(){
               alert("我叫"+this.name+",我喜欢"+this.hobby+",是个程序员");
           }
    }
    var maidou =new Pet("麦兜",25,"coding");//实例化、创建对象
    maidou.eat();//调用eat方法
    
  4. Object.create

    var p = {name:'smyhvae'};
    var obj3 = Object.create(p);  //此方法创建的对象,是用原型链连接的,obj3是实例,p是obj3的原型(name是p原型里的属性),构造函数是Objecet
    

2. new

2.1 new的作用

  • new出来的实例可以访问到构造函数中的属性
  • new出来的实例可以访问到构造函数原型链中的属性(实例与构造函数通过原型链连接了起来)

2.2 new的原理

  • 创建一个新的空对象实例。
  • 将此空对象的隐式原型指向其构造函数的显示原型。
  • 执行构造函数(传入相应的参数,如果没有参数就不用传),同时 this 指向这个新实例。
  • 如果返回值是一个新对象,那么直接返回该对象;如果无返回值或者返回一个非对象值,那么就将步骤(1)创建的对象返回。

2.3 如何实现new

function create(Con, ...args) {
  let obj = {}
  Object.setPrototypeOf(obj, Con.prototype)
  let result = Con.apply(obj, args)
  return result instanceof Object ? result : obj
}
  1. 首先函数接受不定量的参数,第一个参数为构造函数,接下来的参数被构造函数使用

  2. 然后内部创建一个空对象 obj

  3. 因为 obj 对象需要访问到构造函数原型链上的属性,所以我们通过 setPrototypeOf 将两者联系起来。这段代码等同于 obj.__proto__ = Con.prototype

  4. obj 绑定到构造函数上,并且传入剩余的参数

  5. 判断构造函数返回值是否为对象,如果为对象就使用构造函数返回的值,否则使用 obj,这样就实现了忽略构造函数返回的原始值

参考链接:new操作符

2.4 new创建对象和字面量创建对象有何区别?

  • 无论是function Foo()还是let a = { b : 1 },其实都是通过new产生的
  • 使用 new Object() 的方式创建对象需要通过作用域链一层层找到 Object,但是你使用字面量的方式就没这个问题
function Foo() {}
// function 就是个语法糖
// 内部等同于 new Function()
let a = { b: 1 }
// 这个字面量内部也是使用了 new Object()

3. 原型及原型链

3.1 概念

  • 每个对象都会在其内部初始化一个属性,就是prototype(原型)
  • 当我们访问一个对象的属性时,如果这个对象内部不存在这个属性,那么他就会去prototype里找这个属性,这个prototype又会有自己的prototype,于是就这样一直找下去,也就是我们平时所说的原型链的概念
  • 任何一个实例,通过原型链,找到它上面的原型,该原型对象中的方法和属性,可以被所有的原型实例共享。

3.2 原型、构造函数、实例

  • 构造函数通过new生成实例
  • 实例的构造函数属性(constructor)指向构造函数
  • 原型对象(Person.prototype)是 构造函数(Person)的一个实例
person1.constructor === Person
Person.prototype.constructor === Person
  • 实例的__proto__指向其构造函数的原型
person1.__proto__ === Person.prototype

4. 继承

4.1 构造函数继承

    function Parent1() {
        this.name = 'parent1 的属性';
    }

    function Child1() {
        Parent1.call(this);         //【重要】此处用 call 或 apply 都行:改变 this 的指向,parent的实例 --> 改为指向child的实例
        this.type = 'child1 的属性';
    }

    console.log(new Child1);

这种方式,虽然改变了 this 的指向,但是,Child1 无法继承 Parent1 的原型。也就是说,如果我给 Parent1 的原型增加一个方法,这个方法是无法被 Child1 继承的。

4.2 原型继承

    /*
    通过原型链实现继承
     */
    function Parent() {
        this.name = 'Parent 的属性';
    }

    function Child() {
        this.type = 'Child 的属性';
    }

    Child.prototype = new Parent(); //【重要】

    console.log(new Child());

我们把Parent的实例赋值给了Childprototye,从而实现继承。此时,new Child.__proto__ === new Parent()的结果为true

这种继承方式,Child 可以继承 Parent 的原型,但有个缺点:

如果修改 child1实例的name属性,child2实例中的name属性也会跟着改变。造成这种缺点的原因是:child1和child2共用原型。即:chi1d1.__proto__ === child2__proto__是严格相同。

4.3 组合继承

用原型链实现对原型属性和方法的继承,用借用构造函数技术来实现实例属性的继承。

	/*
    组合方式实现继承:构造函数、原型链
     */
    function Parent3() {
        this.name = 'Parent 的属性';
        this.arr = [1, 2, 3];
    }

    function Child3() {
        Parent3.call(this); //【重要1】执行 parent方法
        this.type = 'Child 的属性';
    }
    Child3.prototype = new Parent3(); //【重要2】第二次执行parent方法

    var child = new Child3();

这种方式,能解决之前两种方式的问题:既可以继承父类原型的内容,也不会造成原型里属性的修改。

这种方式的缺点是:让父亲Parent的构造方法执行了两次。

4.4 ES6类继承extends

ES6通过class关键字定义类,里面有构造方法,类之间通过extends关键字实现继承。子类必须在constructor方法中调用super方法,否则新建实例报错。因为子类没有自己的this对象,而是继承了父类的this对象,然后对其进行加工。如果不调用super方法,子类得不到this对象。

4.5 ES5的继承和ES6的继承有什么区别?

ES5的继承时通过prototype或构造函数机制来实现。ES5的继承实质上是先创建子类的实例对象,然后再将父类的方法添加到this上(Parent.apply(this))。

ES6的继承机制完全不同,实质上是先创建父类的实例对象this(所以必须先调用父类的super()方法),然后再用子类的构造函数修改this

四、DOM事件

1.DOM事件的级别

  • DOM0

        element.onclick = function () {
    
        }
    

​ 一是在标签内写onclick事件 二是在JS写onclick=function(){}函数

  • DOM2

       //高版本浏览器
    	element.addEventListener('click', function () {
    
        }, false);
    	//IE8及以下版本
        element.attachEvent('onclick', function () {
    
        });
    	//兼容写法
                /*
                 * 参数:
                 *  element 要绑定事件的对象
                 *  eventStr 事件的字符串(不要on)
                 *  callback 回调函数
                 */
    	function myBind(element , eventStr , callback){
            if(element.addEventListener){
                //大部分浏览器兼容的方式
                element.addEventListener(eventStr , callback , false);
            }else{
                //IE8及以下
                element.attachEvent("on"+eventStr , function(){
                    //在匿名函数 function 中调用回调函数callback
                    callback.call(element);
                });
            }
    

    上面的第三参数中,true表示事件在捕获阶段触发,false表示事件在冒泡阶段触发(默认)。如果不写,则默认为false。

  • DOM3

        element.addEventListener('keyup', function () {
    
        }, false);
    

    DOM3中,增加了很多事件类型,比如鼠标事件、键盘事件等。

  • 为何事件没有DOM1的写法呢?因为,DOM1标准制定的时候,没有涉及与事件相关的内容。

2. 事件流

事件传播的三个阶段是:事件捕获、事件目标、事件冒泡

  • 事件捕获阶段:事件从祖先元素往子元素查找(DOM树结构),直到捕获到事件目标 target。在这个过程中,默认情况下,事件相应的监听函数是不会被触发的。
  • 事件目标:当到达目标元素之后,执行目标元素该事件相应的处理函数。如果没有绑定监听函数,那就不执行。
  • 事件冒泡阶段:事件从事件目标 target 开始,从子元素往冒泡祖先元素冒泡,直到页面的最上一级标签。

3. DOM事件模型(捕获、冒泡)

3.1 事件捕获

事件从祖先元素往子元素查找(DOM树结构),直到捕获到事件目标 target。

addEventListener可以捕获事件

	element.addEventListener('click', function () {

    }, true);

参数为true,代表事件在捕获阶段执行。

捕获阶段,事件依次传递的顺序是:window --> document --> html--> body --> 父元素、子元素、目标元素。

    window.addEventListener("click", function () {
        alert("捕获 window");
    }, true);

    document.addEventListener("click", function () {
        alert("捕获 document");
    }, true);

    document.documentElement.addEventListener("click", function () {
        alert("捕获 html");
    }, true);  //获取html节点

    document.body.addEventListener("click", function () {
        alert("捕获 body");
    }, true);  //获取body节点

    fatherBox.addEventListener("click", function () {
        alert("捕获 father");
    }, true);

    childBox.addEventListener("click", function () {
        alert("捕获 child");
    }, true);

3.2 事件冒泡

当一个元素上的事件被触发的时候(比如说鼠标点击了一个按钮),同样的事件将会在那个元素的所有祖先元素中被触发。这一过程被称为事件冒泡;这个事件从原始元素开始一直冒泡到DOM树的最上层。

通俗来讲,冒泡指的是:子元素的事件被触发时,父元素的同样的事件也会被触发。取消冒泡就是取消这种机制。

冒泡顺序

一般的浏览器: (除IE6.0之外的浏览器)

  • div -> body -> html -> document -> window

IE6.0:

  • div -> body -> html -> document

不能冒泡的事件:

blur、focus、load、unload、onmouseenter、onmouseleave

检查一个元素是否会冒泡:event.bubbles

阻止冒泡:

w3c的方法:(火狐、谷歌、IE11)

event.stopPropagation();

IE10以下:

event.cancelBubble = true

兼容代码:

childBox.onclick = function(event){
    event = event || window.event;
    if(event && event.stopPropagation){
        event.stopPropagation();
    }else{
        event.cancelBubble = true;
    }
}

上方代码中,我们对childBox进行了阻止冒泡,产生的效果是:事件不会继续传递到 father、grandfather、body了

4. event对象的常见应用(常用api方法)

  • 阻止默认事件

    event.preventDefault();
    
    function stopDefault( event ) { 
        //阻止默认浏览器动作(W3C) 
        if ( event && event.preventDefault ) 
            event.preventDefault(); 
        //IE中阻止函数器默认动作的方式 
        else 
            window.event.returnValue = false; 
    }
    

    比如,已知<a>标签绑定了click事件,此时,如果给<a>设置了这个方法,就阻止了链接的默认跳转

  • 阻止冒泡

    代码见上

  • 设置事件优先级

    event.stopImmediatePropagation();
    

    比如说,我用addEventListener给某按钮同时注册了事件A、事件B。此时,如果我单击按钮,就会依次执行事件A和事件B。现在要求:单击按钮时,只执行事件A,不执行事件B。该怎么做呢?这是时候,就可以用到stopImmediatePropagation方法了。做法是:在事件A的响应函数中加入这句话。

  • event.currentTarget   //当前所绑定的事件对象。在事件委托中,指的是【父元素】。
    
    event.target  //当前被点击的元素。在事件委托中,指的是【子元素】。
    

5. 自定义事件

    var myEvent = new Event('clickTest');
    element.addEventListener('clickTest', function () {
        console.log('smyhvae');
    });

	//元素注册事件
    element.dispatchEvent(myEvent); //注意,参数是写事件对象 myEvent,不是写 事件名 clickTest

6. 事件委托

事件代理/事件委托是利用事件冒泡的特性,将本应该绑定在多个元素上的事件绑定在他们的祖先元素上,尤其在动态添加子元素的时候,可以非常方便的提高程序性能,减小内存空间。

五、异步编程

1. 同步、异步

所谓"异步",简单说就是一个任务分成两段,先执行第一段,然后转而执行其他任务,等做好了准备,再回过头执行第二段。

**这种不连续的执行,就叫做异步。**相应地,连续的执行,就叫做同步。

2. 异步的解决方案

2.1 回调函数

回调是指在另一个函数执行完成之后被调用的函数,但回调函数有一个致命的弱点,就是容易写出回调地狱(Callback hell)。

回调地狱的根本问题就是:

  1. 嵌套函数存在耦合性,一旦有所改动,就会牵一发而动全身
  2. 嵌套函数一多,就很难处理错误

参考链接:回调是什么鬼?

2.2 Generator

Generator 函数是协程在 ES6 的实现,最大特点就是可以交出函数的执行权(即暂停执行),控制函数的执行。

function* gen(x){
  var y = yield x + 2;
  return y;
}

整个 Generator 函数就是一个封装的异步任务,或者说是异步任务的容器。异步操作需要暂停的地方,都用 yield 语句注明。Generator 函数的执行方法如下。

var g = gen(1);
g.next() // { value: 3, done: false }
g.next() // { value: undefined, done: true }

上面代码中,调用 Generator 函数,会返回一个内部指针(即遍历器 )g 。这是 Generator 函数不同于普通函数的另一个地方,即执行它不会返回结果,返回的是指针对象。调用指针 g 的 next 方法,会移动内部指针(即执行异步任务的第一段),指向第一个遇到的 yield 语句,上例是执行到 x + 2 为止。

换言之,next 方法的作用是分阶段执行 Generator 函数。每次调用 next 方法,会返回一个对象,表示当前阶段的信息( value 属性和 done 属性)。value 属性是 yield 语句后面表达式的值,表示当前阶段的值;done 属性是一个布尔值,表示 Generator 函数是否执行完毕,即是否还有下一个阶段。

参考链接:Generator 函数的含义与用法

2.3 Promise

Promise翻译过来就是承诺的意思,这个承诺会在未来有一个确切的答复

  1. Promise本身是一个状态机,每一个Promise实例只能有三个状态,pendingfulfilledreject,状态之间的转化只能是pending->fulfilledpending->reject,状态变化不可逆。
  2. Promise有一个then方法,该方法可以被调用多次,并且返回一个Promise对象(返回新的Promise还是老的Promise对象,规范没有提)。
  3. 支持链式调用。
  4. 内部保存有一个value值,用来保存上次执行的结果值,如果报错,则保存的是异常信息。

参考链接:实现promise

2.4 async及await

async 用于申明一个 function 是异步的,而 await 用于等待一个异步方法执行完成。

async 函数(包含函数语句、函数表达式、Lambda表达式)会返回一个 Promise 对象,如果在函数中 return 一个直接量,async 会把这个直接量通过 Promise.resolve() 封装成 Promise 对象。

await 等待的是一个表达式,这个表达式的计算结果是 Promise 对象或者其它值(换句话说,就是没有特殊限定)。

asyncawait可以说是异步终极解决方案了,相比直接使用Promise来说,优势在于处理then的调用链,能够更清晰准确的写出代码,毕竟写一大堆then也很恶心,并且也能优雅地解决回调地狱问题。当然也存在一些缺点,因为await将异步代码改造成了同步代码,如果多个异步代码没有依赖性却使用了await会导致性能上的降低。

3. setTimeout、setInterval、requestAnimationFrame 各有什么特点?

setTimeout延后执行,setInterval每隔一段时间执行一次回调函数,以上两种都不能保证在预期时间执行任务,requestAnimationFrame自带函数节流功能,且延时效果是精确的。

六、JavaScript运行机制

1. 进程与线程

进程描述了 CPU 在运行指令及加载和保存上下文所需的时间,放在应用上来说就代表了一个程序。线程是进程中的更小单位,描述了执行一段指令所需的时间。

把这些概念拿到浏览器中来说,当你打开一个 Tab 页时,其实就是创建了一个进程,一个进程中可以有多个线程,比如渲染线程、JS 引擎线程、HTTP 请求线程等等。当你发起一个请求时,其实就是创建了一个线程,当请求结束后,该线程可能就会被销毁。

2. 为什么JavaScript是单线程?

JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?

所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。

3. 任务队列

所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务指的是,不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

  1. 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。

  2. 主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。

  3. 一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。

  4. 主线程不断重复上面的第三步。

总结:只要主线程空了,就会去读取"任务队列",这就是JavaScript的运行机制

4. Event Loop

主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)。

Event Loop

上图中,主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部API,它们在"任务队列"中加入各种事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取"任务队列",依次执行那些事件所对应的回调函数。

5. node.js的Event Loop

Node.js

  1. V8引擎解析JavaScript脚本。

  2. 解析后的代码,调用Node API。

  3. libuv库负责Node API的执行。它将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给V8引擎。

  4. V8引擎再将结果返回给用户。

参考链接:详解Event Loop

七、Ajax

Ajax:Asynchronous Javascript And XML(异步 JavaScript 和 XML)

1. 创建Ajax

// 1.创建XMLHTTPRequest对象
var xhr = new XMLHttpRequest();
// 2.创建一个新的http请求,包括:请求的方法、请求的url
xhr.open("get", url, true) //true为异步,false同步
// 3.发送HTTP请求
xhr.send();
// 4.设置响应HTTP请求状态变化的函数
xhr.onreadystatechange = function(){
    if(xhr.readyState == 4 && xhr.status == 200){
            // 5.获取异步调用返回的数据
            console.log(xhr.responseText)
    }
}

注意:

  1. post请求

    如果想让 像form 表单提交数据那样使用POST请求,就需要使用 XMLHttpRequest 对象的 setRequestHeader()方法 来添加 HTTP 头。然后在 send() 方法中添加想要发送的数据:

    xmlhttp.open("POST","ajax_test.php", true);
    
    xmlhttp.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
    
    xmlhttp.send("name=smyhvae&age=27");
    
  2. onreadystatechange事件

    readyState:(存有 XMLHttpRequest 的状态。从 0 到 4 发生变化)

    • 0: 请求未初始化
    • 1: 服务器连接已建立
    • 2: 请求已接收
    • 3: 请求处理中
    • 4: 请求已完成,且响应已就绪

    status

    • 200: "OK"。
    • 404: 未找到页面。

    在 onreadystatechange 事件中,当 readyState 等于 4,且状态码为200时,表示响应已就绪

2. Ajax的优缺点

优点

  1. 通过异步模式,提升用户体验;
  2. 优化了浏览器和服务器之间的传输,减少不必要的数据往返,减少了带宽占用;
  3. Ajax在客户端运行,承担了一部分本来由服务器承担的工作,减少了大用户量下的服务器负载;
  4. Ajax可以实现局部刷新

缺点

  1. Ajax暴露了与服务器交互的的细节;
  2. 对搜索引擎的支持较弱;
  3. 不容易调试

八、跨域

1. 同源和跨域

同源策略是浏览器的一种安全策略,所谓同源是指,域名,协议,端口完全相同

也就是说,如果协议、域名或者端口有一个不同就是跨域Ajax 请求会失败。

2. 为什么使用同源策略

浏览器是从两个方面去做这个同源策略的,一是针对接口的请求,二是针对Dom的查询。

同源策略是为了防止CSRF攻击,CSRF攻击是利用用户的登录态发起恶意请求。如果没有同源策略,网站可以被任意来源的Ajax访问到内容。如果你当前A网站还存在登录态,那么对方就可以通过 Ajax获得你的任何信息。当然跨域并不能完全阻止CSRF。

3. 如何解决跨域问题

参考链接:不要再问我跨域了

3.1 JSONP

JSONP 的原理很简单,就是利用 <script> 标签没有跨域限制的漏洞。当需要通讯时,通过 <script>标签指向一个需要访问的地址并提供一个回调函数来接收数据。

// 自己封装一个JSONP
function jsonp(url, jsonpCallback, success) {
  let script = document.createElement('script')
  script.src = url
  script.async = true
  script.type = 'text/javascript'
  window[jsonpCallback] = function(data) {
    success && success(data)
  }
  document.body.appendChild(script)
}
jsonp('http://xxx', 'callback', function(value) {
  console.log(value)
})

JSONP 使用简单且兼容性不错,但是只限于 get 请求。

3.2 CORS

CORS是一个W3C标准,全称是"跨域资源共享"(Cross-origin resource sharing)。

浏览器会自动进行 CORS 通信,实现 CORS通信的关键是后端。只要后端实现了 CORS,就实现了跨域。

CORS有两种请求,简单请求和非简单请求。

只要同时满足以下两大条件,就属于简单请求。 (1) 请求方法是以下三种方法之一:

  • HEAD
  • GET
  • POST

(2)HTTP的头信息不超出以下几种字段:

  • Accept
  • Accept-Language
  • Content-Language
  • Last-Event-ID
  • Content-Type:只限于三个值application/x-www-form-urlencoded、multipart/form-data、text/plain

非简单请求会发出一次预检测请求,返回码是204,预检测通过才会真正发出请求,这才返回200。这里通过前端发请求的时候增加一个额外的headers来触发非简单请求。

3.3 document.domain

  • 该方式只能用于主域名相同的情况下,比如 a.test.comb.test.com 适用于该方式。
  • 只需要给页面添加 document.domain = 'test.com' 表示主域名都相同就可以实现跨域

3.4 postMessage

postMessage 通常用于获取嵌入页面的第三方页面数据,一个页面发送消息,另一个页面判断来源并接收消息

// 发送消息
window.parent.postMessage('message', 'http://www.test.com')
// 接收消息
let mc=new MessageChannel()
mc.addEventListener('message', event => {
    let origin = event.origin || event.originalEvent.origin;
    // 对来源做校验
    if(origin === 'http://www.test.com'){
        console.log('验证通过')
    }
})

九、存储

特性cookielocalStoragesessionStorageindexDB
数据生命周期一般由服务器生成,可以设置过期时间除非被清理,否则一直存在页面关闭就清理除非被清理,否则一直存在
数据存储大小4K5M5M无限
与服务端通信每次都会携带在 header 中,对于请求性能影响不参与不参与不参与

从上表可以看到,cookie 已经不建议用于存储。如果没有大量数据存储需求的话,可以使用 localStoragesessionStorage 。对于不怎么改变的数据尽量使用 localStorage 存储,否则可以用 sessionStorage存储

十、页面性能优化

1. 资源压缩合并,减少http请求

  1. html压缩
  2. css代码压缩
  3. js的压缩
  4. 文件合并
  • 合并图片(css sprites)、CSS和JS文件合并、CSS和JS文件压缩
  • 图片较多的页面也可以使用 lazyLoad 等技术进行优化。
  • 精灵图等

2. 非核心代码异步加载

2.1 动态创建script标签

通过window.onload方法确保页面加载完毕再将script标签插入到DOM中,具体代码如下:

function addScriptTag(src){  
    var script = document.createElement('script');  
    script.setAttribute("type","text/javascript");  
    script.src = src;  
    document.body.appendChild(script);  
}  
window.onload = function(){  
    addScriptTag("js/index.js");  
}  

2.2 defer

通过异步的方式加载defer1.js文件

<script src="./defer1.js" defer></script>

2.3 async

通过异步的方式加载async1.js文件(html5新增特性)

<script src="./async1.js" async></script>

区别:

  • defer是在HTML解析完之后才会执行,如果是多个,按照加载的顺序依次执行

  • async是在加载完之后立即执行,如果是多个,执行顺序和加载顺序无关

3. 利用浏览器缓存

资源文件(比如图片)在本地的硬盘里存有副本,浏览器下次请求的时候,可能直接从本地磁盘里读取,而不会重新请求图片的url。

良好的缓存策略可以降低资源的重复加载提高网页的整体加载速度。

通常浏览器缓存策略分为两种:强缓存和协商缓存。

3.1 强缓存

不用请求服务器,直接使用本地的缓存。

强缓存是利用 http 响应头中的ExpiresCache-Control实现的。

浏览器第一次请求一个资源时,服务器在返回该资源的同时,会把上面这两个属性放在response header中。

  1. Expires:服务器返回的绝对时间

Expires是http1.0提出的一个表示资源过期时间的header,它描述的是一个绝对时间,由服务器返回。 Expires 受限于本地时间,如果修改了本地时间,可能会造成缓存失效

  1. Cache-Control:服务器返回的相对时间

http1.1中新增的 response header。浏览器第一次请求资源之后,在接下来的相对时间之内,都可以利用本地缓存。超出这个时间之后,则不能命中缓存。重新请求时,Cache-Control会被更新。

两者同时存在的话,Cache-Control优先级高于Expires

3.2 协商缓存

浏览器发现本地有资源的副本,但是不太确定要不要使用,于是去问问服务器。

当浏览器对某个资源的请求没有命中强缓存(也就是说超出时间了),就会发一个请求到服务器,验证协商缓存是否命中。

  1. Last-ModifiedIf-Modified-Since

(1)浏览器第一次请求一个资源,服务器在返回这个资源的同时,会加上Last-Modified这个 response header,这个header表示这该资源在服务器上的最后修改时间

(2)浏览器再次请求这个资源时,会加上If-Modified-Since这个 request header,这个header的值就是上一次返回的Last-Modified的值

(3)服务器收到第二次请求时,会比对浏览器传过来的If-Modified-Since和资源在服务器上的最后修改时间Last-Modified,判断资源是否有变化。如果没有变化则返回304 Not Modified,但不返回资源内容(此时,服务器不会返回 Last-Modified 这个 response header);如果有变化,就正常返回资源内容(继续重复整个流程)。这是服务器返回304时的response header

(4)浏览器如果收到304的响应,就会从缓存中加载资源

但last-modified 存在一些缺点:

  • 某些服务端不能获取精确的修改时间

  • 文件修改时间改了,但文件内容却没有变

既然根据文件修改时间来决定是否缓存尚有不足,能否可以直接根据文件内容是否修改来决定缓存策略?

  1. ETagIf-None-Match

(1)浏览器第一次请求一个资源,服务器在返回这个资源的同时,会加上ETag这个 response header,这个header是服务器根据当前请求的资源生成的唯一标识。这个唯一标识是一个字符串,只要资源有变化这个串就不同,跟最后修改时间无关,所以也就很好地补充了Last-Modified的不足。

(2)浏览器再次请求这个资源时,会加上If-None-Match这个 request header,这个header的值就是上一次返回的ETag的值

(3)服务器第二次请求时,会对比浏览器传过来的If-None-Match和服务器重新生成的一个新的ETag,判断资源是否有变化。如果没有变化则返回304 Not Modified,但不返回资源内容(此时,由于ETag重新生成过,response header中还会把这个ETag返回,即使这个ETag并无变化)。如果有变化,就正常返回资源内容(继续重复整个流程)。这是服务器返回304时的response header

(4)浏览器如果收到304的响应,就会从缓存中加载资源。

672fb4ce-28f9-498d-9140-b3ff9f47d62f

3.3 用户行为对浏览器缓存的影响

  1. 地址栏访问,链接跳转是正常用户行为,将会触发浏览器缓存机制;

  2. F5刷新,浏览器会设置max-age=0,跳过强缓存判断,会进行协商缓存判断;

  3. ctrl+F5刷新,跳过强缓存和协商缓存,直接从服务器拉取资源。

4. 使用CDN

浏览器缓存始终只是为了提升二次访问的速度,对于首次访问的加速,我们需要从网络层面进行优化,最常见的手段就是CDN(Content Delivery Network,内容分发网络)加速。通过将静态资源(例如javascript,css,图片等等)缓存到离用户很近的相同网络运营商的CDN节点上,不但能提升用户的访问速度,还能节省服务器的带宽消耗,降低负载。

CDN服务商在全国各个省份部署计算节点,CDN加速将网站的内容缓存在网络边缘,不同地区的用户就会访问到离自己最近的相同网络线路上的CDN节点,当请求达到CDN节点后,节点会判断自己的内容缓存是否有效,如果有效,则立即响应缓存内容给用户,从而加快响应速度。如果CDN节点的缓存失效,它会根据服务配置去我们的内容源服务器获取最新的资源响应给用户,并将内容缓存下来以便响应给后续访问的用户。因此,一个地区内只要有一个用户先加载资源,在CDN中建立了缓存,该地区的其他后续用户都能因此而受益

5. DNS预解析(des-prefetch)

通过 DNS 预解析来告诉浏览器未来我们可能从某个特定的 URL 获取资源,当浏览器真正使用到该域中的某个资源时就可以尽快地完成 DNS 解析。

第一步:打开或关闭DNS预解析

你可以通过在服务器端发送 X-DNS-Prefetch-Control 报头。或是在文档中使用值为 http-equiv 的meta标签:

<meta http-equiv="x-dns-prefetch-control" content="on">

需要说明的是,在一些高级浏览器中,页面中所有的超链接(<a>标签),默认打开了DNS预解析。但是,如果页面中采用的https协议,很多浏览器是默认关闭了超链接的DNS预解析。如果加了上面这行代码,则表明强制打开浏览器的预解析。

第二步:对指定的域名进行DNS预解析

如果我们将来可能从 smyhvae.com 获取图片或音频资源,那么可以在文档顶部的 标签中加入以下内容:

<link rel="dns-prefetch" href="http://www.smyhvae.com/">

当我们从该 URL 请求一个资源时,就不再需要等待 DNS 解析的过程。该技术对使用第三方资源特别有用。