前言
本篇章是偏理解性的博客,主要讲述在js环境中变量、方法执行方式。理解执行顺序,能够更好地帮助你在开发中解决奇奇怪怪的问题。
面试回答
1.执行上下文:执行上下文可以简单理解成一个对象,这个对象包含变量对象、作用域链、this指向,一般就全局执行上下文和函数执行上下文。
2.变量提升:变量提升就是在赋值操作之前,就使用对应的变量,导致变量变成undefined。原因在于执行过程中,首先会建立活动对象,然后构建作用域链,再确定this指向,最后才是代码执行。创建变量或者函数的步骤都在建立活动对象阶段,而赋值操作是在代码执行阶段,所以才会找不到。
3.this:this永远指向函数运行时所在的对象,而不是函数被创建时所在的对象。改变this指向通常有三种方法,bind、call、apply,bind会返回一个新函数 ,call在改变this指向后还执行了函数,且能够接收多个参数,而apply与call的区别在于apply接收数组作为传入参数。
4.手写apply:首先判断传入的参数是否为值类型,如果是值类型,则直接返回该值类型,如果是引用类型,则给该参数添加fn属性用来保存当前this,这个this指向当前的调用函数。下一步判断是否存在其他参数,如果有就将它展开,并将它作为参数传入到上面的this函数中,并把执行结果保存到result里,然后删除fn属性,并返回result。至于call与apply的区别在于传入的参数不一样,bind与apply的区别在于bind返回一个新函数并不执行。
5.事件循环(Event Loop):事件循环是浏览器的一种解决JS单线程运行时不阻塞的机制,具体流程是这样的:1、首先所有同步任务都在主线程上执行,形成一个执行栈。2、如果遇到了异步任务,就丢到主线程外的任务队列,等异步任务有结果后,就会转移到调用栈中。3、再然后执行栈中所有同步任务执行完毕,就会读取调用栈,如果有任务就丢到执行栈,开始执行这个任务中同步任务。4、最后主线程不断重复上面的几个步骤,这就是事件循环的一个机制。从执行顺序上来看,就是一个主线程 > 微任务 > 宏任务,有结果 > 宏任务,无结果的顺序。
知识点
javascript函数执行过程主要由创建执行环境、进入函数调用栈、执行、销毁这四个阶段构成,下面我们来一一理解每一个阶段所做的事情。
1.创建执行环境
执行环境,也就是执行上下文,分为全局环境、函数环境、Eval 函数执行环境。
全局环境指的是JS默认的代码执行环境,是最外围的一个执行环境,在web浏览器中,全局执行环境被认为是window对象。一旦代码被载入,引擎最先进入的是这个环境,全局环境不会被自动回收,只有在关闭浏览器窗口的时候才会被销毁,所以在定义全局变量一定要格外小心。
函数环境是一个相对于全局环境的概念,由于在执行代码时,线程就是在全局环境和函数环境之间来回穿梭的,可以简单理解为函数环境即任何一个函数被调用都会创建一个新的执行环境,执行结束后返回全局环境,而创建的函数环境等待垃圾回收。
Eval 函数执行环境不经常用,尽量避免,这里不做讨论。
2.函数调用栈
在创建执行环境后,函数/代码下一个阶段会被放入一个栈中,这个栈被称为函数调用栈。js根据函数的调用(执行)来决定执行顺序。函数调用栈的栈底永远都是全局环境,而栈顶就是当前正在执行函数的环境。当栈顶的执行环境执行完之后,就会出栈,并把执行权交给之前的执行环境。举例:
javascript
function A(){
console.log("this is A");
function B(){
console.log("this is B");
}
B();
}
A();
1.首先 A() ;A 函数执行了,A执行环境入栈。2.A函数执行时,又调用了 B(),B又执行了,B入栈。3.B中没有可执行的函数了,B执行完出栈。4.继续执行A, A中没有可执行的函数了,A执行完出栈。
上述例子只是为了阐明函数调用栈的作用,具体的执行过程,包括执行上下文、作用域链、this指向等操作是在执行过程发生的。
3.执行过程
当函数被调用时,会创建一个新的函数执行环境,该创建过程主要由两个阶段组成:建立阶段 、代码执行阶段
A. 建立阶段(发生在调用/执行一个函数时,但是在执行函数内部的具体代码之前) 1.建立活动对象 2.构建作用域链 3.确定this的指向
B. 代码执行阶段 1.执行函数内部的具体代码
接下来,我们逐个理解其中的步骤:
A.1. 建立活动对象
这里我们首先理解两个概念:变量对象(Variable object,VO) 、活动对象(Activation object)
变量对象(Variable object,VO) 是一个与执行上下文相关的特殊对象,在函数上下文中,VO是不能直接访问的。变量对象用来存储上下文的函数声明、 函数形参、变量声明。优先级:函数声明>函数的形参>变量。
函数声明:每找到一个函数声明,就在活动对象下面用函数名建立一个属性,属性值就是指向该函数在内存中的地址的一个引用,如果上述函数名已经存在于活动对象下,那么则会被新的函数引用所覆盖。
函数形参:建立arguments对象,检查当前上下文中的参数,建立该对象下的属性以及属性值 。没有实参的话,属性值为undefined。
变量声明:每找到一个变量声明,就在活动对象下面用变量名建立一个属性,该属性值为undefined。如果变量名称跟已经声明的形式参数 或函数相同,则变量声明不会干扰已经存在的这类属性。
活动对象(Activation object,AO):由于变量对象不能访问,在函数执行阶段,由变量对象转化而来的可访问对象。
举例:
javascript
var a = 10;
function b () {
console.log('全局的b函数')
};
function bar(a, b) {
console.log('1', a, b)
var a = 1
function b() {
console.log('bar下的b函数')
}
console.log('2', a, b)
}
bar(2, 3)
console.log('3', a, b)
解析:我这边的理解跟参考资料有所不同,望指正。
csharp
// 创建阶段:
// 第一步,遇到了全局代码,进入全局上下文,此时的执行上下文栈是这样
ECStack = [
globalContext: {
VO: {
// 根据优先级顺序会优先处理全局下的b函数声明,值为该函数所在内存地址的引用
b: <reference to function>,
// 紧接着,按顺序再处理bar函数声明
bar: <refernce to function>,
// 根据1.3,再处理变量,并赋值为undefined
a: undefined
}
}
];
// 第二步,调用bar函数,就又新建了一个函数上下文,此时的执行上下文栈是这样
ECStack = [
globalContext: {
VO: {
b: <reference to function b() {}>,
bar: <refernce to function bar() {}>,
a: undefined
}
},
<bar>functionContext: {
VO: {
//根据优先级,先声明函数b,并且赋值为b函数所在内存地址的引用;
//紧接着,形参b重复,优先级低于函数b,因此,忽略新的声明,而a为2;
//最后是变量声明a,由于a=2已经存在,因此新的变量声明,a=undefined会被忽略,最后变量情况如下:
b: <refernce to function b() {}>,
a: 2
}
}
]
console.log('1', a, b): '1' , 2 , function b(){console.log('bar下的b函数')}
console.log('2', a, b): '2' , 1 , function b(){console.log('bar下的b函数')} ,这里a为1,是因为执行了代码,把1赋值给了a
console.log('3', a, b): '3' , 10 , function b(){console.log('全局的b函数')} ,a为10,function b(){console.log('全局的b函数')}是因为bar函数已经执行完了,就从调用执行栈中弹出。
块级作用域,简单来说就是函数内部和{}之间的部分。变量提升也是在建立阶段产生的问题,即在赋值操作之前(赋值操作在代码执行阶段),就使用对应的变量,从而使变量为undefined,举例:
css
console.log(a)
var a = 100
function b(){
console.log(a)
var a = 200
console.log(a)
}
b()
console.log(a)
// undefined undefined 200 100
javascript
var foo = function (){
console.log(1)
}
function foo(){
console.log(2)
}
foo() //1
转化一下-->
function foo(){console.log(2)}
var foo
//由于下述代码已经属于代码执行阶段,所以上述规则不能应用于此处
//function foo 与 var foo 属于同一个内存地址,所以可能产生覆盖,下述的赋值,即起到覆盖的作用,所以输出的为1
foo = function (){console.log(1)}
foo()
参考资料:article.itxueyuan.com/O0mA6
A.2.构建作用域链
作用域链的最前端,始终都是当前执行的代码所在函数的活动对象。下一个活动对象(AO)为包含本函数的外部函数的AO,以此类推。最末端,为全局环境的变量对象。
注意:
1.虽然作用域链是在函数调用时构建的,但是它跟调用顺序(进入调用栈的顺序)无关,因为它只跟包含关系(函数、包含函数的嵌套关系)有关。
2.作用域链是创建函数的时候就创建了,此时的链只有全局变量对象,保存在函数的[[Scope]]属性中,然后函数执行时的,只是通过复制该属性中的对象来构建作用域链。
举例:
javascript
function a(){
var va =1
function b(){
var vb = 2
console.log(va)
console.log(vb)
}
b()
}
a()
b函数被a函数包含,a函数被window全局环境包含。
参考资料:blog.csdn.net/weixin_3391...
A.3.确定this的指向
this的指向在函数定义的时候是确定不了的,只有函数被调用的时候才能确定,并且this的最终指向的是那个调用它的对象 。
情况1:匿名函数
匿名函数this的默认指向为windows
javascript
var age =1
var obj = {
age:2,
getAge:function(){
console.log('getAge',this.age)
return function(){
console.log('unkonw',this.age)
}
}
}
obj.getAge()()
//getAge 2
//unkonw 1
情况2:函数调用
1.如果一个函数中有this,但是它没有被上一级的对象所调用,那么this指向的就是window。
javascript
var va = 'abc'
function a(){
var va = 123
console.log(this) //window
console.log(this.va) //'abc'
}
a()
2.如果一个函数中有this,这个函数有被上一级的对象所调用,那么this指向的就是上一级的对象。
javascript
var obj={
a:'abc',
b:function(){
console.log(this.a) //'abc'
}
}
obj.b()
3.如果一个函数中有this,这个函数中包含多个对象,尽管这个函数是被最外层的对象所调用,this指向的也只是它上一级的对象,也就是说this指向的是直接调用它的对象。
css
var obj={
a:123,
b:{
a:'abc',
b:function(){
console.log(this.a) //'abc'
}
}
}
obj.b.b()
javascript
var a =1;
var obj = {
a:2,
b:{
a:3,
getValue:function(){
console.log(this.a)
}
}
}
console.log(obj.b.getValue());//obj.b.getValue()这里this指向obj.b,输出3
var test = obj.b.getValue;//此时仅做赋值,并没有调用函数,因为是.getValue而不是getValue()
console.log(test()); // 此时调用函数符合第一种情况,所以this指向window
情况3:构造函数中的this指向
首先new关键字会创建一个空的对象,然后会自动调用一个函数apply方法,将this指向这个空对象,这样的话函数内部的this就会被这个空的对象替代,可以参考后续关于new操作符的知识点。
javascript
function a(){
console.log(this) //a {}
}
var va = new a()
当构造函数的this碰到return时:如果返回值是一个对象,那么this指向的就是那个返回的对象,如果返回值不是一个对象那么this还是指向函数的实例。
javascript
function a(){
this.a = 1
return {a:'abc'}
}
var va = new a()
console.log(va.a) //'abc'
情况4:箭头函数的this指向
箭头函数的this指向,是指向箭头函数被创建时外部作用域(要么是window,要么是最近一层的局部函数)的this指向的对象,而不是调用时指定this指向。举例:
javascript
var name = 'window';
var A = {
name: 'A',
sayHello: () => {
console.log(this.name)
}
}
A.sayHello();
//此时箭头函数外部作用域为window,所以指向的是window
javascript
var name = 'window';
var A = {
name: 'A',
sayHello: function(){
var s = () => console.log(this.name)
return s//返回箭头函数s
}
}
var sayHello = A.sayHello(); //这里拿到的s,因为return了
sayHello();
//function()的this指向为对象A(因为A.sayHello,function被调用了,参考情况1),而箭头函数外部的this指向即为function()的this指向这个的this指向 对象A。这跟上述定义并不冲突,箭头函数的this指向仍然指向function的this,只不过function的this指向是在调用的时候才确定。
情况5:call、bind、apply的this指向
由于js中this的指向受函数运行环境、调用的影响,指向经常改变,使得开发变得困难和模糊,所以在写一些复杂函数的时候经常会用到this指向绑定,以避免出现不必要的问题,call、apply、bind基本都能实现这一功能。
1.bind:bind用于将函数体内的this绑定到某个对象,然后返回一个新函数
ini
var counter = {
count: 0,
inc: function () {
this.count++;
}
}
var obj = {
count: 100
};
var func = counter.inc.bind(obj);
func();
obj.count // 101
2.call:call方法可以指定this 的指向,然后再指定的作用域中,执行函数。call可以接受多个参数,第一个参数是this指向的对象,之后的是函数回调所需的入参
kotlin
var newObj = {a:1,b:2}
var a = 11
var b = 22
var obj = {
a:111,
b:222,
fn:function test(val){
return this.a + this.b + val
}
}
var func = obj.fn
func.call(newObj,4) //7
3.apply:apply 和call作用类似,也是改变this指向,然后调用该函数,唯一区别是apply接收数组作为函数执行时的参数
css
var newObj = {a:1,b:2}
var a = 11
var b = 22
var obj = {
a:111,
b:222,
fn:function test(a,b){
return this.a + this.b + a + b
}
}
var func = obj.fn
func.apply(newObj,[3,4]) // 10
PS:call、bind、apply能实现以下基础功能,
-
间接调用函数,改变this,劫持其他对象的方法
-
两个函数实现继承
-
为类数组(arguments和nodeList)添加数组方法,如push、pop
javascript(function(){ Array.prototype.push.call(arguments,'王五'); console.log(arguments);//['张三','李四','王五'] })('张三','李四')
-
合并数组、求数组内最大值
4.面试题:手动实现bind、call、apply
实现call(obj,arg,arg....)1.改变this的指向。2.传入参数。3.返回函数执行结果
javascript
// 函数原型上添加myCall方法来模拟call,这样就可以直接在函数后进行调用
Function.prototype.myCall = function (context) {
let currentObj = typeof context ==='object' ? context : {} // context就是传入的第一个参数
currentObj.fn = this // 设置一个临时属性,把this的值给这个临时属性,这里的this指向的是调用myCall方法的上一级对象(函数),将parent函数存起来,parent调用的myCall,此时this指向的就是该方法。
let arg = [...arguments].slice(1) // 将参数中除了第一个参数之后的全部存起来
currentObj.fn(...arg) // 将参数传入,此时使用第一个参数调用父级函数,使父级函数的this指向指为第一个参数(参考A.1.情况1)
delete currentObj.fn // 将函数删除,避免临时属性的干扰。
}
var a = {value: 1}
function test(a,b) {
console.log(this.value + a + b)
}
test.myCall(a, 2, 3) //6
实现apply(obj, [params1, params2, ....])apply与call的区别在于传参,所以把 let arg = [...arguments].slice(1) 替换一下,如下:
javascript
Function.prototype.myApply = function (context) {
let currentObj = typeof context ==='object' ? context : {} // 这里就是传入的第一个参数
currentObj.fn = this // 将parent函数存起来,parent调用的myCall,此时this指向的就是该方法
if(arguments[1]) {
currentObj.fn(...arguments[1])
} else {
currentObj.fn()
}
delete currentObj.fn
}
var a = {value: 1}
function test(a,b) {
console.log(this.value + a + b)
}
test.myApply(a, [2,3])
实现bind(obj, ...arg)bind函数与call、apply的区别有两点:一是返回一个新函数;二是柯里化,柯里化举例如下:
scss
function add(x) {
return function(y) {
return x + y;
};
};
add(1)(2); // 3
实现bind代码:
javascript
// 实现bind()方法,调用bind()必须是一个函数,因为要返回一个新函数;
// 可以通过new修改this,new的优先级最高bind()可以将参数分两次传递进来
Function.prototype.myBind = function (context) {
if(typeof this !== "function") { // 如果不是函数则直接抛出
throw new TypeError('Error')
}
let self = this // 保存this,即为parent
let arg = [...arguments].slice(1) // 将参数中除了第一个之后的全部存起来
// bind()返回的是一个函数,所以可以使用new,并且会修改this的指向
return function F() {
if(this instanceof F) { // 如果new执行此时即为true
return new self(...arg, ...arguments) // 返回new parent(第一次传递的参数, 第二次传递的参数)-->arguments是执行返回的函数时的参数
}else{
return self.apply(context, [...arg, ...arguments]) // 如果没有执行new,那么直接执行parent,通过apply会将this执行最初传进来的对象a
}
}
}
B.1.执行函数内部的具体代码
执行代码阶段最主要有两个事情:变量赋值、JS事件机制。其中变量赋值比较好理解,这里不做过多解释,接下来着重理解JS事件机制。
JavaScript是单线程指的是同一时间只能干一件事情,只有前面的事情执行完,才能执行后面的事情。导致遇到耗时的任务时后面的代码无法执行,因此有了同步、异步任务。
同步任务:for循环、new Promise等除了异步任务外的其他任务
异步任务:微任务、宏任务,其中微任务有Promise.then、process.nextTick、queueMicrotask,宏任务有setTimeout、setInterval、IO、UI渲染等
async/await只是一个语法糖,只是帮助我们返回一个Promise而已,如果方法被调用的话,async相当于new Promise ,await后面的代码相当于.then里的代码,且.then代码必须得在promise中resolve后才会执行,否则.then代码不会执行,await在async代码执行完毕后,进入微任务队列。
了解任务的概念后,现在整理一下任务执行的流程:
1、所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
2、主线程之外,还存在一个任务队列(task queue)。只要是异步任务,就丢到这个任务队列,有了运行结果,就在任务队列之中放置一个事件。
3、当任务队列中的异步任务有了结果之后,就会将任务移动到调用栈中。
4、一旦执行栈中的所有同步任务执行完毕,系统就会读取调用栈,开始执行该异步任务中同步任务。
5、主线程不断重复上面的第三步,称为事件循环(Event Loop)。
总结执行顺序:主线程(同步任务,new Promise)> Promise.then(微任务)> setTimeout(宏任务,有结果) > setTimeout(宏任务,无结果),同类型任务按照先进先出的顺序执行。理清流程可得:
面试题:
scss
async function async1() {
console.log(2);
await async2();
console.log(7);
}
async function async2() {
new Promise(function (resolve) {
console.log(3);
resolve();
}).then(function () {
console.log(6);
});
}
console.log(1);
setTimeout(function() {
console.log(8);
}, 0)
async1();
new Promise(function(resolve, reject){
console.log(4)
setTimeout(function(){
console.log(9)
resolve(13)
console.log(10)
setTimeout(function(){
console.log(12)
})
}, 0)
}).then(res =>{ // flag
console.log(11)
setTimeout(() =>{
console.log(13)
}, 0)
})
console.log(5);
//1-13按顺序执行,参考上述执行顺序及规则。
4.销毁执行环境和活动对象
某个执行环境所有代码执行完之后,该环境被销毁,保存在其中的所有变量和函数定义也随之销毁,这边闭包有所不同,将会在下篇博客中,进一步理解。全局执行环境只会在关了浏览器或者程序的时候才被销毁。
最后
走过路过,不要错过,点赞、收藏、评论三连~