第九章 JS进阶

299 阅读34分钟

原型和原型链

原型

所有函数都有一个属性 —— prototype,它称为函数的原型

函数的原型默认情况下就是一个Object对象

函数的原型可以更改

函数的原型中有一个属性 —— constructor,它指向函数本身

隐式原型

所有的对象都有一个属性 —— __proto__,它称为对象的隐式原型

除Object.prototype和Function外,其它所有对象的隐式原型,默认情况下指向创建该对象的构造函数的原型

image.png

对象的隐式原型可以更改

当对象访问自身成员时,会先在自身身上找,若找不到,就会去自身的隐式原型上找

原型链

除Object.prototype外,所有对象都有隐式原型,而对象的隐式原型也是一个对象,于是就形成了原型链

image.png

特殊:

  1. Object.prototype的隐式原型指向null
  2. Function的隐式原型指向Function.prototype,尽管Function不是通过new Function()创建的

当访问一个对象的成员时,其访问顺序为:

  1. 查找对象自身是否拥有该成员,若拥有,则使用自身成员
  2. 若其自身没有该成员,则看对象的隐式原型上是否有该成员,若拥有,则使用隐式原型上的成员
  3. 若其隐式原型上没有该成员,则看隐式原型的隐式原型上是否有该成员...

原型链的应用

W3C官方建议不要直接通过__proto__属性来访问和操作对象的隐式原型

基础方法

  • Object.getPrototypeOf(obj)

    获取obj的隐式原型

  • Object.setPrototypeOf(obj, prototype)

    设置obj的隐式原型为prototype

    第二个参数可以传入null,传入null后,obj将没有隐式原型

  • Object.prototype.isPrototypeOf(obj)

    判断this是否在obj的原型链上(判断obj的原型链上有没有this),返回布尔值

  • obj instanceof 函数

    判断函数的原型是否在obj的原型链上,返回布尔值

    注意:

    • ({}) instanceof Object得到true,而Object.isPrototypeOf({})得到false
    • 5 instanceof Number得到false,因为instanceof不会将5包装成Number对象
    • instanceof的右侧必须是一个函数
  • Object.create(obj)

    返回一个新对象,新对象的隐式原型为obj

    该方法可以传入null,传入null后,该函数返回的新对象将没有隐式原型

  • Object.prototype.hasOwnProperty("属性名")

    判断this自身是否拥有该属性,返回布尔值

    若属性是从原型链上继承下来的或属性根本不存在,则返回false,否则返回true

    该方法常与for in循环配合使用

类数组转为真数组

原型链上有Array.prototype的对象就是真数组

ES6:Array.from(类数组)

更早:Array.prototype.slice.call(类数组)

继承

在JS中,继承的本质就是将子构造函数原型的隐式原型,指向父构造函数的原型

Son.prototype.__proto__ = Father.prototype;

这样,子构造函数创建出的实例,既能访问到子构造函数原型上的成员,又能访问到父构造函数原型上的成员

var son = new Son();

// son访问成员时,先找自身,若自身没有,则找son.__proto__,即Son.prototype
// 若Son.prototype也没有,则找Son.prototype.__proto__,即Father.prototype
// ...
圣杯模式
function inherit(Son, Father){
    Son.prototype = Object.create(Father.prototype);
    // 手动创建的原型对象没有constructor属性,需要手动设置
    Son.prototype.constructor = Son;
    Son.prototype.uber = Father;		// 该属性可有可无,但圣杯模式就这样写的
}

Object.create()是ES5才出现的,因此可以使用兼容性更高的写法:

function inherit(Son, Father){
    var F = function(){};
    F.prototype = Father.prototype;
    Son.prototype = new F();	// 一定要先更改F.prototype,再创建F的实例
    Son.prototype.constructor = Son;
    Son.prototype.uber = Father;
}

在上面的版本中,若每调用一次inherit函数,都会在内部创建一个函数F,而它只是一个辅助函数,不需要每次调用inherit时都重新创建一个新的F,因此可以优化为下面的写法:

var inherit = (function(){
    var F = function(){};
    return function(son, Father){
        F.prototype = Father.prototype;
    	Son.prototype = new F();
  	  	Son.prototype.constructor = Son;
 	  	Son.prototype.uber = Father;
    }
})();

注意:一定要在使用函数构造对象以及给函数原型添加成员之前调用inherit方法,否则构造出的对象的隐式原型还是指向原来的原型(因为对象的隐式原型是在对象被构建的时候绑定的),添加的成员还是在原来的原型上添加的

更加符合面向对象语言中继承的模式是:在子构造函数中调用父构造函数,让其也参与到子构造函数创建实例的过程中(就像面向对象语言中子类构造器中调用super一样)

function Father(name, age, sex){
       this.name = name;
       this.age = age;
       this.sex = sex;
}

function Son(name, age, sex, score){
       Father.call(this, name, age, sex);
       this.score = score;
}

inherit(Son, Father);

...

属性描述符

属性描述符用于描述对象中某个属性(方法也是属性)的相关信息,它本质上是一个对象

在JS中,属性分为两种:

  1. 数据属性

    描述符中没有配置getter和setter的属性就是数据属性

    不通过属性描述符定义出来的属性都是数据属性

  2. 存取器属性

    描述符中拥有getter或setter配置的属性就是访问器属性

    当获取或修改访问器属性的值时,都会自动运行某个函数

注意:一个属性,它要么为数据属性,要么为访问器属性

要想定义一个访问器属性,或将数据属性修改为访问器属性,需要使用到Object.defineProperty()方法

定义描述符

Object.defineProperty(obj, "prop", 属性描述符),该方法用于给obj定义一个新属性prop,或修改obj的prop属性

方法的第三个参数传入prop属性的相关描述信息,即属性描述符

属性描述符是一个对象,属性描述符中的一个成员就描述了obj的prop属性的一个方面的信息

属性描述符中的成员包括:

  • value

    反映了prop属性的内容,默认为undefined

    若value是一个函数,则该函数内部的this会自动指向obj

    注意:value只能配置在没有getter和setter的属性描述符中

  • configurable

    是否允许重新定义prop的属性描述符,默认为false

    当该配置为true时,将允许使用新的属性描述符来重新定义obj的prop属性,且允许obj删除prop属性

    当该配置为false时,将不允许使用新的属性描述符来重新定义obj的prop属性,且不允许obj删除prop属性

    更多细节请看属性描述符 MDN

  • writable

    是否允许prop属性被重新赋值,默认为false

    注意:writable只能配置在没有getter和setter的属性描述符中

  • enumerable

    prop属性是否是可枚举的,默认为false

    不可枚举的属性在Chrome浏览器中显示为浅粉色

  • get

    get是一个函数

    如果给属性的描述符中添加了该配置,则当获取prop属性的值时,会自动运行该函数,并将该函数的返回值作为获取到的值

    get函数中的this指向obj

  • set

    set是一个函数,并且该函数会接收一个参数

    如果给属性的描述符中添加了该配置,则当给prop属性赋值时,会自动运行该函数,并将赋的值作为set的实参值传入

    set函数中的this指向obj

注意:

  • 当给属性的描述符中配置了get或set时,属性就成为了一个访问器属性,当属性为访问器属性时,该属性的描述符中将不允许出现value、writable等配置

    反之,属性描述符中设置了value或writable后,就不允许出现get和set

  • 使用原始的方式定义对象的属性,都是数据属性,在定义时JS会自动给该属性设置一些属性描述符,包括设置writable为true、设置value为赋值符号右边的内容、设置enumerable为true、设置configurable为true,这才导致我们能够访问和修改这些属性的值,以及循环时可以遍历到这些的属性,以及允许我们重新定义这些属性的属性描述符

    当我们使用defineProperty()方法定义对象的属性,JS就是使用我们设置的属性描述符来描述属性(描述符中没有手动添加的配置会使用默认值)

  • 当一个属性,它的描述符的enumerable为false时,for in循环将遍历不到该属性,Object.keys()或Object.values()也将无法获取到该属性

应用

  • 限定属性的取值范围

    传统的检测合法的方式是在构造函数中进行判断,若超出边界则特殊处理

    // 只允许用户年龄在 0 ~ 100 之间
    
    function User(name, age){
        this.name = name;
        if(age < 0){
            age = 0;
        } else if(age > 100){
            age = 100;
        }
        this.age = age;
    }
    
    var user = new User("张三", 10000);		// age将被替换为100
    

    但这种方式无法对通过属性访问操作符进行修改的属性进行检测

    user.age = 10000;							// 还是修改成功了
    

    因此应该使用属性描述符进行检测

    // 只允许用户年龄在 0 ~ 100 之间
    
    function User(name, age){
        this.name = name;
        Object.defineProperty(this, "age", {
            get(){
                return age;
            },
            set(value){
                if(value < 0){
                    age = 0;
                }else if(value > 100){
                    age = 100;
                }
            }
        });
        this.age = age;
    }
    

    这样不管是使用哪种方式修改属性,属性的值都将处于合法范围

    var user = new User("张三", 10000);		// 100
    user.age = 10000;						// 100
    

    赋值表达式的返回值是赋的值,而不是赋值之后属性的值

    console.log(user.age = 10000);			// 10000
    console.log(user.age);					// 100
    
  • 数据响应式

    通过将属性配置为访问器属性,并在访问器中加入一些代码,就能实现设置属性值后,自动触发某些功能

获取描述符

Object.getOwnPropertyDescriptor(obj, "prop")返回obj的prop属性的属性描述符对象

注意:prop必须直接隶属于obj(而不是在obj的原型链上),否则将返回undefined

还有一个方法叫做Object.getOwnPropertyDescriptors(obj),这是获取obj的所有属性的描述符

扩展

在控制台查看一个对象的访问器属性时,它的值会显示为(...)的形式,当点击该区域时,会自动调用该访问器属性的getter,然后将对应的值显示出来

const obj = {};

let temp = undefined;

Object.defineProperty(obj, "name", {
	get(){
        return temp;
    },
    
    set(value){
    	temp = value;
	}
});

console.log(obj);

image.png

执行上下文

执行上下文:JS代码运行之前创建的一块内存空间,该空间中包含了代码执行所需要的数据,只有执行上下文创建完成后,相应的代码才能开始执行

执行上下文分为全局执行上下文和函数执行上下文

执行上下文栈:call stack,用于存放执行上下文的一片连续的内存空间

每调用一次函数,都需要先在执行上下文栈的栈顶创建一个新的函数执行上下文,上下文创建完成后就会开始执行该函数中的JS代码

当调用的函数运行完后,该函数对应的执行上下文将会出栈,然后再转而执行新的栈顶执行上下文对应的代码

也就是说,JS引擎始终执行的是处在栈顶的执行上下文所对应的代码

当全局代码运行完后,全局执行上下文也将会出栈

执行上下文中的内容

  1. this

    this是在创建执行上下文时确定的,因此可以在JS代码中使用this

    全局执行上下文中this始终指向全局对象

    函数执行上下文中,会根据函数调用的形式确定this指向

    箭头函数在创建执行上下文时,没有这一步骤,也没有下面的往VO中加入arguments属性的一步

  2. 变量对象VO(Variable Object)

    VO是一个对象,对象中记录了当前作用域中声明的所有的形参、变量以及子函数

    VO对象中的所有属性,在相应代码块中可以直接使用

    全局执行上下文的VO也称为GO(Global Object)

    正在执行的代码的执行上下文的VO也称为AO(Active Object)

    全局执行上下文的VO中记录了全局作用域下声明的所有变量、函数以及全局对象上的所有属性

    函数执行上下文的VO中记录了该函数的作用域中声明的所有变量、子函数、以及该函数的形参

创建执行上下文时,先确定this,再确定VO,两者确定完成后执行上下文创建完成,然后开始执行相应代码

VO对象中属性的确定顺序:

  1. 找到当前函数中所有的形参,将形参的名称作为AO对象的属性名,并将对应实参值作为其属性值

  2. 在AO对象中加入arguments属性,其属性值根据所有实参值来确定

  3. 找到当前函数中所有通过var关键字声明的变量

    包括if判断中,return之后,break后面的var声明

    若变量的名称没有与AO对象中已存在的属性名称发生冲突,则将变量的名称作为AO对象的属性名,属性的值设置为undefined

    否则使用原来的形参值(不会覆盖为undefined)

    经过这一步骤,var变量的声明部分从源代码中消失

  4. 找到当前函数中所有的函数字面量

    包括return后面的函数字面量

    若函数名与AO对象中已存在的属性名发生冲突,则将原来确定的值覆盖为函数字面量的函数体

    否则直接将函数名作为AO对象的属性名,并将属性值设置为函数字面量的函数体

    经过这一步骤,函数字面量从源代码中消失

    注意:if判断以及循环中不要出现函数字面量,如果出现了,JS会对它们看作是使用var声明的函数表达式,而不会视为一个函数字面量

    // function test() {
    //     console.log(a);
    //     if (false) {
    //         function a() { }
    //     }
    //     console.log(a);
    // }
    
    function test() {
        console.log(a);
        while(false){
            function a() { }		// 尝试把该函数注释后,看一下控制台中的结果
        }
        console.log(a);
    }
    
    test();
    

准确来说上面的步骤是函数执行上下文中VO对象的属性的确定顺序,全局执行上下文中VO对象的属性的确定顺序是直接从第3步开始的

之前一直强调的所谓变量的声明提升和函数整体提升,其本质上是因为在执行上下文的创建过程中就已经把var变量声明和函数字面量整体给加入到执行上下文的VO中并成为VO的属性了,因此执行代码时就可以直接访问到

注意:确定VO对象的内容是在相应代码运行之前发生的,因此即使是实际执行过程中不可能被执行到的(比如在if判断中或在return语句之后的)var变量声明和函数字面量,也会参与到VO对象内容的确定过程中

执行代码的过程中,若要获取变量的值,或修改变量中保存的内容时(可以理解为函数字面量也是一种变量,因为它最终也是要和其它变量一样,会成为VO对象的属性),首先会在自身执行上下文中的VO对象中找是否有同名属性,若找到了,则获取得到的是该属性的值,修改时修改的就是该属性;若没有找到,则找上一层执行上下文中的VO对象中是否有同名属性...;若最终都没有找到,则直接报错

这里只是简要描述一下,具体细节请看下一小节

作用域链

函数执行上下文中的VO中除了之前所说的那些属性外,还会包含一个额外的属性(之后将其记作extra),该属性指向了该函数执行上下文所对应的函数(即该属性中保存着其对应函数的引用)

调用fn时,会创建fn的函数执行上下文,函数fn的执行上下文的VO中,extra属性指向的就是fn

任何函数在创建时,JS都会自动往函数中加入一个隐藏属性[[scope]],该属性指向创建该函数时所在的执行上下文的VO(即AO)

创建函数是指在内存中为函数开辟一块内存空间

对于函数字面量,创建函数发生在确定VO对象的属性的第4步中,第4步结束后,函数字面量就被创建出来了

对于函数表达式,创建函数发生在执行代码的过程中,当执行到了对应的函数表达式语句时,函数表达式就会被创建出来

注意:创建函数并不是发生在函数被调用时,函数调用时创建的是函数执行上下文, 而且只有函数被创建完成之后,才能够调用该函数

当访问一个变量时,会首先查找自身的执行上下文的VO中是否有相应属性,如果找不到,则根据VO的extra属性找到对应的函数,再在根据函数的[[scope]]属性找到上一层执行上下文中的VO,然后找该VO中是否有相应属性,若找不到,则再继续向上查找...,直至查找成功或找到头为止

若一直向上找,直到找完GO后也没有找到,则会报xxx is not defined错误

闭包

function A(){
    var count = 0;
    return function(){
        count++;
        console.log(count);
    }
}

var test = A();

test();		// 1
test();		// 2
test();		// 3

从广义上讲,闭包就是内部函数使用了外部函数的变量

从狭义上讲,闭包就是外部函数的执行上下文已经消失(字面上的消失,其实并没有真正消失),但内部函数仍能访问到已消失的外部函数的执行上下文中的变量

在vscode的debug控制台中可以查看到闭包中的内容

事件循环

JS的事件循环在不同的宿主环境中会存在差异,但核心原理不变

本节只介绍事件循环最核心的部分,更多事件循环知识还需要结合nodejs或ES6进行学习

异步:某些函数不会立即执行,需要等到某个时机到达之后才会执行,并且在这些函数等待执行的期间不会阻塞其它代码的执行,这样的函数叫做异步函数

JS中的异步不同于操作系统中的异步

setTimeout(function(){
       console.log(1);
}, 1000);
console.log(2);

可以从同步的角度出发来间接理解JS中的异步,若JS只存在同步现象,则上面的代码一定会是先输出1,再输出2,也就是说setTimeout之后的代码需要等待1000ms后才能执行,而异步代码则不会阻塞后续代码的执行

JS是单线程的语言,是指执行JS代码的线程只有一个

虽然执行JS代码的线程只有一个,但JS的宿主环境(如浏览器环境,node环境)却有很多个线程,而宿主环境中的多个线程可以“并行”地工作

不同宿主环境中包含的线程种类可能不一样,但一定都包含着多个线程

以浏览器环境为例,浏览器中与JS有关的线程包括:

  • JS执行线程(JS执行引擎)

    负责执行JS代码

  • 计时器线程

    负责记时

  • http网络线程

    负责网络通信

  • 渲染线程

    负责渲染页面

  • 事件监听线程

    负责监听事件

  • ...

事件队列:一块内存空间,用于存放执行时机到达的异步函数

当执行栈中没有可以执行的上下文时,JS引擎(JS执行线程)就会从事件队列中取出一个函数来执行

当JS执行线程执行所有的代码后,执行栈就会被清空。当JS执行线程发现执行栈已经处于空的状态时,JS执行线程就会从事件队列中取出一个函数,然后将其执行,直至该函数也执行结束,然后该函数的执行上下文又出栈,执行栈又变为空,于是JS执行线程再从事件队列中取出函数来执行...

setTimeout(function(){
    console.log("计时器到时");
}, 1000);

以上面代码为例,当JS执行线程在执行JS代码时,执行到了setTimeout函数,于是JS执行线程会立即向记时线程通信,并把记时的时长以及到时之后要执行的函数传送给记时线程,于是记时线程便开始倒计时。而JS执行线程把必要的信息交代给记时线程之后,自己会立即转身执行之后的代码,而不会等待计时线程的计时结束

当计时线程计时结束后,会把之前JS执行线程交代过来的函数送入事件队列中

之后当JS执行线程执行完本次所有该执行的代码后,执行栈就会被清空,于是便查看事件队列中是否有要执行的函数,如果有,则将其取出,并为其创建函数执行上下文,创建完成后开始执行

事件循环:函数在执行栈、宿主环境的某个线程、事件队列中循环移动

通过事件循环可以发现,JS中同步执行的代码一定不可能被异步代码给打断,并且异步代码总是要等到同步代码执行结束后才能执行

浏览器中的事件循环

浏览器中的进程和线程

  1. 浏览器进程

    负责创建新标签页,监听和处理用户交互以及创建其他进程等

    一个浏览器应用窗口只会对应一个浏览器进程

  2. 网络进程

    负责加载网络资源

  3. 渲染进程

    渲染进程被创建后,会自动启动一个主线程,称之为渲染主线程,该线程负责解析HTML、CSS、执行JS代码

    大多数情况下,每打开一个新的标签页,浏览器进程就会为其创建一个对应的渲染进程

在计算机的世界中,加载即下载

渲染主线程

渲染主线程要完成工作非常多,包括:

  1. 解析HTML
  2. 解析CSS
  3. 计算样式
  4. 页面布局
  5. 处理图层
  6. 绘制页面
  7. 执行JS代码
  8. ...

思考:为什么渲染进程不启动多个线程来分别处理不同的事情

渲染主线程采用排队的方式来处理这些事务

image.png

当渲染主线程正在处理任务的过程中,若出现了新任务需要执行,则新任务需要到消息队列中排队,以等待被执行

当渲染主线程执行完当前的所有任务后,就进入了空闲状态,于是便会检查消息队列中是否有其他任务需要执行,若有则将其取出并执行,否则渲染主线程会进入休眠状态,直到消息队列中出现新任务为止

因此,事件循环的过程可以概括如下:

  1. 在一开始,渲染主线程会进入一个无限循环

  2. 每一次循环都会检查消息队列中是否有任务存在

    如果有,就取出第一个任务执行,执行完一个后进入下一次循环

    如果没有,则进入阻塞状态

  3. 其它线程(可以是其他进程中的线程)可以在合适的时候向消息队列中添加任务(如浏览器进程中的某个线程可以监听事件,在事件触发时可以将事件处理函数包装为任务加入到消息队列中)

    在添加新任务时,若渲染主线程处于阻塞状态,则会将其唤醒以继续循环取任务

异步

在JS代码的执行过程中,常常会出现一些无法立即处理的任务,比如:setTimeout、setInterval、fetch、addEventListener等

渲染主线程在遇到这种场景时,并不会原地等待,而是采用异步的方式进行处理

setTimeout(()=>{
    console.log(1);
}, 1000);
console.log(2);

对于同步,一定是先输出1,再输出2,即渲染主线程会原地等待记时结束,期间不进行任何操作

而异步刚好相反

以setTimeout为例,当渲染主线程执行到该函数时,会通知记时线程帮忙记时,通知完后渲染主线程就会处理后续任务,而记时线程会在时机到达时将回调函数包装为任务加入到消息队列中

image.png

因此,JS中的异步可以概括为:

​ JS是一门单线程的语言,这是因为它运行在浏览器的渲染主线程中,而渲染主线程只有一个。渲染主线程承担着诸多的工作,包括渲染页面、执行 JS 等。如果使用同步的方式,就可能导致主线程产生阻塞,从而导致消息队列中的很多其他任务无法得到执行。这样一来,一方面会导致繁忙的主线程白白的消耗时间,另一方面导致页面无法及时更新,造成卡死现象。所以浏览器采用异步的方式来解决。具体做法是当某些任务发生时,比如计时器、网络、事件监听,主线程将任务交给其他线程去处理,自身立即结束任务的执行,转而执行后续代码。当其他线程监控到时机到达时,会将先前传递过来的回调函数包装成任务,加入到消息队列中,等待主线程调度执行。在这种异步模式下,浏览器就不会发生阻塞,从而保证了在单线程也能流畅运行

JS阻塞渲染

<!DOCTYPE html>
<div id="div">div text</div>
<button id="button">click</button>
<script>
    function delay(duration){
        var now = Date.now();
		while(Date.now() - now < duration){}
    }
    
	button.onclick = function(){
		div.innerText = "change div text";
        delay(3000);
    }
</script>

在点击button时,div的文本会在3秒之后才发生变化

原因在于页面的渲染和JS代码的执行都是由渲染主线程负责,而(一个页面中)渲染主线程只有一个,因此在执行其中一个任务的期间另一个只能等待

当代码执行到button.onclick时,渲染主线程会通知交互线程去监听用户的单击动作,之后当用户单击button时,交互线程会将对应的事件处理函数包装为任务送至消息队列中,于是渲染主线程就能将其取出执行

当渲染主线程执行事件处理函数任务时,发现第一个语句是修改页面,于是渲染主线程会发出一个页面绘制任务并加入到消息队列中(注意:页面绘制是一个单独的任务,不会在执行到该语句时就立即重新渲染),然后接着执行之后的delay函数。当delay函数运行完,消息队列中的页面绘制任务才能得到执行

消息队列的优先级

消息队列不止有一个,任务的种类也不止一种

W3C规定:

  • 一个队列中允许出现多种类型的任务,但同一类型的任务必须处于同一个队列中

  • 浏览器必须准备好一个微队列,微队列中的任务会比其他消息队列中的任务优先得到执行

    Promise中的then、catch、finally中的回调,MutationObserver,就是进入微队列等待执行的

除了微队列外,还有很多其他类型的消息队列,如交互队列(存放交互任务)和延时队列(存放记时任务),这些队列之间也存在优先级顺序(如交互队列的优先级高于延时队列),但都可以概括为宏队列

JS的计时器为什么不精确:

  1. 计算机中没有原子种,因此无法做到精确记时
  2. 操作系统的记时函数存在微小的偏差,而JS的计时器调用的其实是操作系统的记时函数
  3. 受事件循环的影响,计时器的回调函数只能在渲染主线程空闲时运行,因此又带来了一些偏差

扩展:浏览器实现计时器时,如果计时器函数嵌套 > 5层,则内部的记时器函数的延迟时间会被强制更改为 ≥ 4ms,这也会导致计时器记时不精确

setTimeout(function(){
    setTimeout(function(){
    	setTimeout(function(){
            setTimeout(function(){
                setTimeout(function(){
					setTimeout(function(){
						setTimeout(function(){
							...
                		}, 4);
                	}, 4);
                }, 0);
            }, 0);
		}, 0);
	}, 0);
}, 0);

浏览器的渲染流程

解析HTML

最开始,浏览器从网络或本地文件中获取到HTML源代码,其本质上就是一个字符串

之后浏览器就会对其从上到下进行解析,这称之为解析HTML

若解析过程中遇到了CSS代码或JS代码,就会立即停止解析HTML(阻塞),然后转去解析CSS或执行JS

若CSS或JS是以外部文件的形式存在,浏览器会在加载外部文件时就停止解析HTML,直到外部文件加载完并解析或执行完后才会继续解析HTML

image.png

生成DOM树

浏览器会一边生成解析HTML,一边生成DOM树

当DOM树生成完成后,页面就会触发DOMContentLoaded事件

生成渲染树

浏览器会一边生成DOM树,一边计算DOM树中每个结点(即每个DOM元素)的最终样式,即生成渲染树

而计算DOM树中每个结点的样式的过程就是属性值的计算过程

image.png

布局 Layout

这个步骤也称之为重排 reflow,是指浏览器一边生成渲染树,一边计算每个元素最终的尺寸和位置

当页面中所有DOM元素都确定完尺寸和位置后,它们就可以被渲染到页面中了

由于元素与元素之间可能会影响位置和尺寸,因此单单确定完某一个元素的尺寸和位置还不能立即将它绘制到页面中

这个步骤不仅仅只会发生一次,而是会伴随页面的变化而频繁发生,下面这些操作均会导致重排reflow:

  • 直接或间接改变了元素的尺寸和位置时

    如改变某个元素的宽高、坐标、改变图片的src属性(有可能新图片的尺寸会更大)等

  • 获取元素的尺寸和位置时

    浏览器为了提高性能,往往不会执行到了一个改变元素尺寸和位置的代码后就立即重排页面,因此为了保证获取元素的尺寸和位置是准确的、最新的,浏览器会立即重排一次页面,在此过程中得到元素的准确尺寸和位置信息后再将其返回

reflow是非常耗时的,浏览器为了提升性能,会让reflow异步进行,而代码执行过程中连续导致reflow的代码,最终也会合并为一次

dom.style.width = '100px'
dom.style.height = '200px'
dom.style.left = '10px'
dom.style.top = '10px'

image.png

这种优化仅限于改变元素的尺寸和位置的情况,若中间获取了元素尺寸和位置,则还是会立即发生重排

dom.style.width = '100px'
dom.style.height = '200px'
dom.clientHeight; // 读取高度,导致强行reflow
dom.style.left = '10px'
dom.style.top = '10px'

image.png

重绘 repaint

当重排发生过后,浏览器就需要对重排后的页面进行重新绘制,这个过程称之为重绘

只要发生了重排,就必定会导致重绘

绘制的过程是由GPU完成的,速度非常快,因此仅会导致repaint的代码,比会导致reflow的代码要高效得多

下面这些行为仅会造成repaint而不会导致reflow:

  • 改变元素的背景颜色
  • 改变元素的字体颜色
  • 将边框变为圆角
  • 改变背景图片
  • 改变outline
  • 设置盒子阴影

ES5严格模式

为了提高JS代码的执行效率,ES5标准取消了部分在ES3中可以使用的功能

并且ES5为了减少由于代码书写混乱所带来的一系列问题,对代码的书写形式也有了更严格的规定

默认情况下,浏览器是采用ES3.0的模式去执行JS代码,这就代表着在ES5中不允许出现的功能以及代码仍然可以正常执行

开启ES5严格模式

全局的严格模式

在当前脚本块的顶部写上"use strict";

"use strict";

// JS代码

注意:

  • 在全局严格模式下,当前代码块的"use strict"前面不允许出现JS代码,否则严格模式将不会开启

    可以在"use strict"前面加上空格或注释,只是不允许在其前面出现能够执行的JS代码

  • 这种全局的严格模式只是影响当前脚本块,并不是影响整个页面

局部的严格模式

在函数体的最前面写上"use strict";

function test(){
    "use strict";

    // JS代码
}

注意:

  • 在局部严格模式下,函数体中的"use strict"前面不允许出现JS代码,否则严格模式将不会开启
  • 这种全局的严格模式只是影响当前函数内部(包括所有后代函数),并不会影响其它部分

ES5严格模式开启后

  1. 不允许使用arguments.callee

  2. 不允许使用fn.caller

  3. 不允许使用with语句

    with能够改变内部代码块的作用域(可以理解为with将内部代码的最直接的VO修改为了with语句括号中的对象),虽然功能强大,但会导致JS代码执行效率降低

    var obj = {
    	a: 1
    };
    
    function test(){
    	var a = 2;
    	with(obj){
    		console.log(a);		// 1
    	}
    }
    
    test();
    
    
  4. 不允许出现未声明就赋值的变量

  5. 直接调用的(非箭头)函数,其this为undefined而不是全局对象

    function test(){
        "use strict";
        console.log(this);		// undefined
    }
    
    test();
    
  6. 不允许出现重复的形参声明

    function test(a, a){
    	"use strict";
    }
    
    test();		// 报错
    
  7. arguments的值确定后,将不再与形参具有映射关系

    function test(a, b) {
        "use strict";
        console.log(arguments);		// [1, 2]
        arguments[0] = 3;
        console.log(arguments);		// [3, 2]
        console.log(a);				// 1
    }
    
    test(1, 2);
    

eval

eval是一个函数,该函数可以接收一个字符串参数,eval会将该字符串作为JS代码执行

var str = `
	var a = 10;
	var b = 20;
	console.log(a + b);
`;

eval(str);		// 控制台输出 30 

若传入的是标准格式的JS代码字符串,则eval会将字符串中最后一个表达式的执行结果返回

var num = eval("2 + 3; 3 + 4;");

console.log(num, typeof num);		// 7 "number"

如果传入的字符串不是一个标准的JS代码格式,则eval会抛出异常

var str = "!@#$%^&*";

eval(str);							// SyntaxError: Invalid or unexpected token

若eval接收到的参数不是字符串,则eval会将其原封不动地返回

var obj1 = {
    name: "zs"
};

var obj2 = eval(obj1);

console.log(obj1 === obj2);			// true

eval作用域

在非严格模式下,eval执行代码字符串时,相当于把eval的位置替换为了一个代码块,代码块中的代码就是字符串所对应的真正的JS代码,然后执行该代码块

function test(){
    eval(`
        var num = 10;
        console.log(this);
    `);
    console.log(num);
}

var obj = {
    name: "zhangsan"
};

test.call(obj);

// { name: "zhangsan" }
// 10

等价于

function test(){
    {
        var num = 10;
    	console.log(this);
    }
    console.log(num);
}

var obj = {
    name: "zhangsan"
};

test.call(obj);

// { name: "zhangsan" }
// 10

在严格模式下,eval执行代码字符串时,相当于把eval的位置替换为了一个立即执行函数,并且立即执行函数中的函数表达式是一个箭头函数,箭头函数中的代码就是字符串所对应的真正的JS代码,然后执行该函数

function test(){
    "use strict";
    eval(`
    	var num = 10;
        console.log(num);
        console.log(this);
    `);
    console.log(num);
}

var obj = {
    name: "zhangsan"
};

test.call(obj);

// 10
// { name: "zhangsan" }
// 异常:num is not defined

等价于

function test(){
    "use strict";
    (()=>{
        var num = 10;
        console.log(num);
        console.log(this);
    })();
    console.log(num);
}

var obj = {
    name: "zhangsan"
};

test.call(obj);

// 10
// { name: "zhangsan" }
// 异常:num is not defined

归纳:

  • 严格模式下,eval作用域类似于箭头函数的函数作用域
  • 非严格模式下,eval作用域类似于块级作用域

不要使用eval

理由:

  1. eval的执行效率低下,因为它需要调用到JS解释器,这导致它比其它JS代码的执行要慢得多
  2. 容易出现代码注入等危险的操作,因为实际开发中根本无法得知eval中的字符串参数是来自于哪里的
  3. 容易写出逻辑混乱的代码

对象混合和克隆

对象混合

function mixin(obj1, obj2){
    var obj3 = {};
    for (var prop in obj2) {
        obj3[prop] = obj2[prop];
    }
    for(var prop in obj1){
        if(!(prop in obj3)){
            obj3[prop] = obj1[prop];
        }
    }
    return obj3;
}

对象克隆

function clone(target, isDepth) {
    var res = null;
    if (target && typeof (target) === "object") {
        if (Array.isArray(target)) {
            res = [];
        } else {
            res = {};
        }
        for (const key in target) {
            var value = target[key];
            if (!isDepth || value === null) {
                res[key] = value;
            } else {
                res[key] = clone(target[key], isDepth);
            }
        }
    }
    return res;
}

函数防抖和节流

防抖和节流:

  • 防抖的函数会在上一次函数还没执行时将它覆盖,因此如果在上一次函数还没有执行时就重新调用该函数,则无论调用多少次,最终只运行最后一次的调用
  • 节流的函数只会在一个时间段内执行一次,而无论这期间调用了多少次函数,因此上一次的函数调用不会受下一次调用的影响,它只受到时间的限制

函数防抖

function debounce(fn, duration){
    var timer;
    return function(){
        clearTimeout(timer);
        var that = this;
        var args = Array.prototype.slice.call(arguments);
		timer = setTimeout(function(){
            fn.apply(that, args);
        }, duration);
    }
}

函数节流

方法一:第一次和最后一次的调用都会延迟执行

function throttle(fn, duration){
    var timer;
    return function(){
        if(timer){
            return;
        }
        var that = this;
        var args = arguments;
        timer = setTimeout(function(){
			fn.apply(that, args);
            timer = null;
		}, duration);
    }
}

方法二:第一次和最后一次的调用都会立即执行

function throttle(fn, duration){
    var timer;
    return function(){
        if(timer){
            return;
        }
        fn.apply(this, arguments);
        timer = setTimeout(function(){
            timer = null;
		}, duration);
    }
}
function throttle(fn, duration){
    var pre;
    return function(){
        var now = Date.now();
        if(!pre || now - pre >= duration){
            pre = now;
            fn.apply(this, arguments);
        }
    }
}

函数柯里化

柯里化函数:固定某个函数的一些参数,得到该函数剩余参数的一个新函数,如果剩余参数数量为0,则直接调用

function curry(fn) {
    var args = Array.prototype.slice.call(arguments, 1);	// 去除参数fn
    if (args.length >= fn.length) {
        return fn.apply(this, args);
    } else {
    	var that = this;
        return function () {
            var curArgs = Array.prototype.slice.call(arguments);
            var totalArgs = args.concat(curArgs);	// 拼接形成完整的参数列表
            if (totalArgs.length >= fn.length) {
                // 参数总数量满足需求,直接返回fn的调用结果
                return fn.apply(that, totalArgs);
            } else {
                // 参数总数量不足需求,继续返回一个新函数
                totalArgs.unshift(fn);				// 向数组最前面添加一个元素
                return curry.apply(that, totalArgs);
            }
        }
    }
}

函数管道

函数管道:将多个单参函数组合起来,形成一个新的函数,这些函数中,前一个函数的输出,是后一个函数的输入

image.png

注意:函数管道实现的前提是所有函数都必须是单参函数

function pipe(){
    var args = Array.from(arguments);
    return function(value){
        for(var i = 0; i < args.length; i++){
        	value = args[i](value);
    	}
        return value;
    }
}