本文主要梳理JS 函数执行的整个过程,包括执行上下文,作用域链,内存空间,闭包,this指向和call,apply,bind等,会持续补充更新哦!

执行上下文

评估和执行 JavaScript 代码的环境的抽象概念。

  • 全局执行上下文— 这是默认或者说基础的上下文,任何不在函数内部的代码都在全局上下文中(一个程序中只会有一个全局执行上下文)。
  • 函数执行上下文 — 每当一个函数被调用时, 都会为该函数创建一个新的上下文(函数上下文可以有任意多个)。每当一个新的执行上下文被创建,它会按定义的顺序执行一系列步骤。
  • Eval 函数执行上下文 — 执行在 eval 函数内部的代码也会有它属于自己的执行上下文,但由于 JavaScript 开发者并不经常使用 eval,暂不讨论。

生命周期

创建阶段

全局上下文

  • 生成变量对象:全局对象(浏览器的情况下为window )
  • 建立作用域链:全局对象
  • 确定this指向:设置** this **的值等于全局对象(var === this. === winodw.)

函数上下文

  • 生成变量对象:用活动对象(activation object, AO)来表示变量对象(活动对象是在进入函数上下文时刻被创建的,它通过函数的 arguments 属性初始化)。
  • 建立作用域链:
    • 函数有一个内部属性 [[scope]],当函数创建的时候,就会保存所有父变量对象到其中。
    • 当函数激活时,进入函数上下文,创建 VO/AO后,就会将活动对象添加到作用链的前端。
    • Scope (作用域链)= [AO].concat([[Scope]]);
  • 确定this指向:this 永远指向最后调用它的那个对象(参见后文)

执行阶段

进入执行上下文

这时候还没有执行代码,变量对象会加入:

  • 函数的所有形参 (如果是函数上下文)
    • 名称和对应值组成的一个变量对象的属性被创建
    • 没有实参,属性值设为** undefined**
  • 函数声明
    • 由名称和对应值(函数对象(function-object))组成一个变量对象的属性被创建
    • 如果变量对象已经存在相同名称的属性,则完全替换这个属性
  • 变量声明
    • 名称和对应值(undefined)组成一个变量对象的属性被创建(var);
    • 如果变量名称跟已经声明的形参函数相同,则变量声明不会干扰已经存在的这类属性
    • 变量声明提升:可以在声明之前访问 var 定义的变量(虽然是 undefined),但是在声明之前访问 let 和 const 的变量会得到一个引用错误(未初始化)。

代码执行

  • 顺序执行代码,根据代码,修改变量对象的值

总结一下函数执行上下文的整个过程

var scope = "global scope";
function checkscope(){
    var scope2 = 'local scope';
    return scope2;
}
checkscope();

执行过程如下:

  1. checkscope 函数被创建,保存作用域链到 内部属性[[scope]]
  2. 执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈
  3. checkscope 函数并不立刻执行,开始做准备工作,第一步:复制函数[[scope]]属性创建作用域链
  4. 第二步:用 arguments 创建活动对象,随后初始化活动对象,加入形参、函数声明、变量声明
  5. 第三步:将活动对象压入 checkscope 作用域链顶端
  6. 准备工作做完,开始执行函数,随着函数的执行,修改 AO 的属性值
  7. 查找到 scope2 的值,返回后函数执行完毕,函数上下文从执行上下文栈中弹出

执行上下文栈

存储代码运行时创建的所有执行上下文。

  • 当 JavaScript 引擎第一次遇到你的脚本时,它会创建一个全局的执行上下文并且压入当前执行栈。每当引擎遇到一个函数调用,它会为该函数创建一个新的执行上下文并压入栈的顶部。
  • 引擎会执行那些执行上下文位于栈顶的函数。当该函数执行结束时,执行上下文从栈中弹出,控制流程到达当前栈中的下一个上下文。

作用域和作用域链

  • 作用域是指程序源代码中定义变量的区域。
  • 作用域规定了如何查找变量,也就是确定当前执行代码对变量的访问权限。
  • JavaScript 采用词法作用域(lexical scoping),也就是静态作用域。

静态和动态作用域

  • 词法作用域,函数的作用域在函数定义的时候就决定了
  • 动态作用域,函数的作用域是在函数调用的时候才决定的。

作用域链

  • 当查找变量的时候,会先从当前上下文的变量对象中查找。
  • 如果没有找到,就会从父级(词法层面上的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。
  • 这样由多个执行上下文的变量对象构成的链表就叫做作用域链。
  • 作用域链和原型继承查找时的区别:如果去查找一个普通对象的属性,但是在当前对象和其原型中都找不到时,会返回undefined;但查找的属性在作用域链中不存在的话就会抛出ReferenceError

内存空间

JS内存空间分为栈(stack)堆(heap)池(一般也会归类为栈中)。 其中存放变量,存放复杂对象,存放常量,所以也叫常量池。

内存生命周期

  • 1、分配你所需要的内存
  • 2、使用分配到的内存(读、写)
  • 3、不需要时将其释放、归还

垃圾回收

JS有自动垃圾收集机制,常用标记清除算法来找到哪些对象是不再继续使用的,当将变量设为null时释放引用,脱离执行环境,这个值会在下一次垃圾收集器执行操作时被找到并释放。

  • 局部变量:局部作用域中,函数执行完毕,局部变量没有存在意义,垃圾收集器很容易做出判断并回收。
  • 全局变量:全局变量什么时候需要自动释放内存空间则很难判断,所以在开发中尽量避免使用全局变量。

常见垃圾回收算法

  • 引用计数(现代浏览器不再使用):
    • 看一个对象是否有指向它的引用。如果没有其他对象指向它了,说明该对象已经不再需要了。
    • 循环引用:如果两个对象相互引用,尽管他们已不再使用,但是垃圾回收器不会进行回收,最终可能会导致内存泄露。
  • 标记清除(常用):
    • 从根部(在JS中就是全局对象)出发定时扫描内存中的对象,凡是能从根部到达的对象,保留。那些从根部出发无法触及到的对象被标记为不再使用,稍后进行回收。
    • 无法触及的对象包含了没有引用的对象这个概念,但反之未必成立。

常见的内存泄漏

1、意外的全局变量

function foo(arg) {
    a = "this is a hidden global variable"; //未使用var定义
    this.b = "potential accidental global"; //this指向全局
}

解决方法
在 JavaScript 文件头部加上 'use strict',使用严格模式避免意外的全局变量,此时上例中的this指向undefined。如果必须使用全局变量存储大量数据时,确保用完以后把它设置为 null 或者重新定义。

2、被遗忘的计时器或回调函数

  • 必须手动终止定时器
  • 现代的浏览器(包括 IE 和 Microsoft Edge)使用了更先进的垃圾回收算法(标记清除),已经可以正确检测和处理循环引用了。即回收节点内存时,不必非要调用 removeEventListener 了。

3、脱离 DOM 的引用

如果把DOM 存成字典(JSON 键值对)或者数组,此时,同样的 DOM 元素存在两个引用:一个在 DOM 树中,另一个在字典中。那么将来需要把两个引用都清除。

闭包

  • 闭包是一个可以访问外部作用域的内部函数,即使这个外部作用域已经执行结束。闭包的外部作用域是在其定义的时候已决定,而不是执行的时候。
  • 闭包中的变量并不保存中栈内存中,而是保存在堆内存中,这也就解释了函数之后之后为什么闭包还能引用到函数内的变量,这些被引用的变量直到闭包被销毁时才会被销毁。
  • 闭包使得 timer 定时器,事件处理,AJAX 请求等异步任务更加容易,可以通过闭包来达到封装性。
  • 能不能访问关键看在哪里定义**,而不是在哪里调用,调用方法的时候,会跳转到定义方法时候的环境里,而不是调用方法的那一行代码所在的环境。**
  • 闭包引起的内存泄露那都是因为浏览器的gc问题(IE8以下为首)导致的,跟js本身没有关系,所以,请不要再问js闭包会不会引发内存泄露了
  • 闭包只存储外部变量的引用,而不会拷贝这些外部变量的值。var 只有函数作用域 let,coast有函数作用域和块作用域。

问一个问题

下面的两段代码中,checkscope()执行完成后,闭包f所引用的自由变量scope会被垃圾回收吗?为什么?

var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f;
}

checkscope()();
var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f;
}

var foo = checkscope(); 
foo();

第一段中自由变量特定时间之后回收:执行完毕后出栈,该对象没有绑定给谁,从Root开始查找无法可达,此活动对象一段时间后会被回收
第二段中自由变量不回收:此对象赋值给 var foo = checkscope();,将foo压入栈中,foo指向堆中的f活动对象,对于Root来说可达,不会被回收。

如果想让第二段中自由变量回收,要怎么办?
foo = null;,把引用断开就可以了。

this指向

this 永远指向 最后调用它的那个对象
this的值不会被保存在作用域链中,this的值取决于函数被调用的时候的情景(也就是执行上下文被创建时确定的)。

判断函数上下文中this的绑定对象

  • new绑定:作为一个构造函数,this绑定到新创建的对象,注意:显示return函数或对象,返回值不是新创建的对象,而是显式返回的函数或对象。
  • 显示绑定(call,apply,bind):
    • call()、apply()–this指向绑定的对象上
    • bind()–this将永久地被绑定到了bind的第一个参数
  • 隐式绑定:this指向调用函数的对象,由上下文对象调用时,绑定到上下文对象
  • 默认绑定: 非严格模式情况下,this 指向 window(全局变量), 严格模式下,this指向 undefined
  • 箭头函数–所有的箭头函数都没有自己的this
    • 箭头函数不绑定this,箭头函数中的this相当于普通变量。
    • 箭头函数的this寻值行为与普通变量相同,在作用域中逐级寻找。
    • 箭头函数的this无法通过bind,call,apply来直接修改(可以间接修改)。
    • 改变作用域中this的指向可以改变箭头函数的this。
    • eg. function closure(){()=>{//code }},在此例中,我们通过改变封包环境closure.bind(another)(),来改变箭头函数this的指向
  • 作为一个DOM事件处理函数–this指向触发事件的元素,也就是始事件处理程序所绑定到的DOM节点。
  • 立即执行函数(function() {})()中的this指向的window对象,因为完整写法就是window.(function() {})()

    new 绑定 > 显式绑定 > 隐式绑定 > 默认绑定

call,apply,bind

call,apply,bind三者之间的区别

  • 三者都是用来改变函数的this指向
  • 三者的第一个参数都是this指向的对象
  • bind是返回一个绑定函数可稍后执行,call、apply是立即调用
  • 三者都可以给定参数传递
  • call和bind给定参数需要将参数全部列出,apply给定参数数组

模拟一个call

//ES6实现
Function.prototype.myCall=function(context){
    context=context || window  //当参数为null时指向window
    var args=[...arguments].slice(1)//将类数组对象转为数组并截取从1到结尾的参数
    var fn = Symbol() //设定fn为唯一属性
    context[fn]=this //fn绑定当前函数
    var result=context[fn](...args) //传入参并执行函数,考虑有返回值的情况
    delete context[fn] //删除fn
    return result //返回return值
}
//ES3实现
Function.prototype.myCall=function(context){
    context=context || window
    var args=[]
    for(var i=1;i<arguments.length;i++){ //for循环取参数数组
        args.push(arguments[i])
    }
    context.fn=this
    var result=eval('context.fn('+args+')') //eval解析参数列表
    delete context.fn
    return result
}

模拟一个apply

apply和call的区别是call需要列出所有参数,而apply传入一个参数数组

//ES6实现
Function.prototype.myApply=function(context){
    context=context || window
    var args=arguments[1]||[] //与call不同的地方是直接传入一个参数数组,获取该数组
    var fn = Symbol();
    context[fn]=this
    var result=context[fn](...args)
    delete context[fn]
    return result
}
//ES3实现
Function.prototype.myApply=function(context){
    context=context || window
    var args=arguments[1]||[] //与call不同的地方是直接传入一个参数数组,获取该数组
    context.fn=this
    var result=eval('context.fn('+args+')')
    delete context.fn
    return result
}

模拟一个bind

  • 1、可以指定this
  • 2、返回一个函数
  • 3、可以传入参数
  • 4、柯里化
    Function.prototype.myBind = function (context) {
        if (typeof this !== "function") {
            throw new Error("Function.prototype.bind - what is trying to be bound is not callable");
        }
        var self = this
        var args1 = [...arguments].slice(1)
        var fn = function () { };
        var newContext = function () {
            var args2 = [...arguments]
            return self.myCall(this instanceof fn ? this : context, ...args1, ...args2);
        }
        if (this.prototype) {
            fn.prototype = this.prototype
        }
        newContext.prototype = new fn();
        return newContext;
    }

    疯狂自测一波

    Q1:判断下面两段代码分别输出什么?

    var scope = "global scope";
    function checkscope(){
        var scope = "local scope";
        function f(){
            return scope;
        }
        return f();
    }
    checkscope();
    var scope = "global scope";
    function checkscope(){
        var scope = "local scope";
        function f(){
            return scope;
        }
        return f;
    }
    checkscope()();

    两段代码都会打印:local scope
    JavaScript采用的是词法作用域,函数的作用域基于函数创建的位置。

Q2:下面两段代码执行的结果一样,但是两段代码究竟有哪些不同呢?

var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f();
}
checkscope();
var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f;
}
checkscope()();

执行上下文栈的变化不一样

//模拟第一段代码:
ECStack.push(<checkscope> functionContext);
ECStack.push(<f> functionContext);
ECStack.pop();
ECStack.pop();
//模拟第二段代码:
ECStack.push(<checkscope> functionContext);
ECStack.pop();
ECStack.push(<f> functionContext);
ECStack.pop();

Q3:判断下面两段代码分别输出什么?

function foo() {
    console.log(a);
    a = 1;
}
foo();
function bar() {
    a = 1;
    console.log(a);
}
bar();

第一段会报错:Uncaught ReferenceError: a is not defined( “a” 并没有通过 var 关键字声明,所有不会被存放在 AO 中)
第二段会打印:1(执行 console 的时候,全局对象已经被赋予了 a 属性)

Q4:判断下面这段代码输出什么?

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

会打印函数,而不是 undefined
(如果变量名称跟已经声明的形参函数相同,则变量声明不会干扰已经存在的这类属性)

Q5:判断下面两段代码分别输出什么?

var foo = function () {
    console.log('foo1');
}
foo(); 
var foo = function () {
    console.log('foo2');
}
foo();
function foo() {
    console.log('foo1');
}
foo(); 
function foo() {
    console.log('foo2');
}
foo();

第一段会打印:foo1 和 foo2 变量声明提升 (提升为undefined,边执行边赋值)
第二段会打印:foo2 和 foo2 函数声明提升 (函数提升,当重复时后一个会对前一个进行覆盖)

Q6:判断下面这段代码输出什么?

var a = {n: 1};
var b = a;
a.x = a = {n: 2};

console.log(a.x)
console.log(b.x)

a.x:undefined
b.x:{n: 2}
原因:

  • 1、优先级。.的优先级高于=,所以先执行a.x,堆内存中的{n: 1}就会变成{n: 1, x: undefined},改变之后相应的b.x也变化了,因为指向的是同一个对象。
  • 2、赋值操作是从右到左,所以先执行a = {n: 2}a的引用就被改变了,然后这个返回值又赋值给了a.x需要注意的是这时候a.x是第一步中的{n: 1, x: undefined}那个对象,其实就是b.x,相当于b.x = {n: 2}

Q7:判断下面两段代码的this对象是什么及输出什么?

var person = {
  name: "personName",
  getName: function(){
    return this.name;
  }
}
console.log(person.getName());
var name = "windowName";
var person = {
  name: "axuebin",
  getName: function(){
    return this.name;
  }
}
var getName = person.getName;
console.log(getName());

第一段会打印:personName this指向person
第二段会打印:windowName this指向全局变量
this的指向取决于函数调用时

巨人的肩膀

最后

欢迎纠错,看到会及时修改哒!❤
温故而知新,希望我们都可以保持本心,念念不忘,必有回响。