JavaScript入门③-函数(2)原理{深入}执行上下文

时间:2022-12-02 11:07:42

JavaScript入门③-函数(2)原理{深入}执行上下文

被JavaScript的闭包、上下文、嵌套函数、this搞得很头痛,这语言设计的,感觉比较混乱,先勉强理解总结一下????????????。

  • 为什么有闭包这么个东西?闭包包的是什么?
  • 什么是词法作用域?
  • 函数是如执行的呢?

JavaScript入门③-函数(2)原理{深入}执行上下文


01、执行上下文 (execution context)

名称 描述
是什么? 执行上下文 (execution context) 是JavaScript代码被解析和执行时所在环境的抽象概念。每一个函数执行的时候都会创建自己的执行上下文,保存了函数运行时需要的信息,并通过自己的上下文来运行函数。
干什么用的? 当然就是运行函数自身的,实现自我价值。
有那些种类? ① 全局上下文:全局环境最基础的执行上下文,所有不在任何函数内的代码都在这里面。
???? 浏览器中的全局对象就是window,全局作用域下var申明、隐式申明的变量都会成为全局属性变量,全局的this指向window
???? 其中会初始化一些全局对象或全局函数,如代码中的consoleundefinedisNaN
② 函数上下文:每个函数都有自己的上下文,调用函数的时候创建。可以把全局上下文看成是一个*根函数上下文。
eval() 调用内部上下文eval的代码会被编译创建自己的执行上下文,不常用也不建议使用。基于这个特点,有些框架会用eval()来实现沙箱Sandbox。
保存了什么信息? 初始化上下文的变量、函数等信息
???? thisValuethis环境对象引用。
???? 内部(Local)环境:函数本地的所有变量、函数、参数(arguments)。
???? 作用域链:具有访问作用域的其他上下文信息。
谁来用? 执行上下文函数调用栈来统一保存和调度管理。
生命周期 创建(入栈)=> 执行=> 销毁(出栈),函数调用的时候创建,执行完成后销毁。

JavaScript入门③-函数(2)原理{深入}执行上下文


02、函数调用栈是干啥的?

函数调用栈(Function Call Stack),管理函数调用的一种栈式结构(后进先出 )队列,或称执行栈,存储了当前程序所有执行上下文(正在执行的函数)。最早入栈的理所当然就是程序初始化时创建的全局上下文了,他是VIP会员,会一直在栈底,直到程序退出。

2.1、函数执行流程

????函数执行上下文调用流程(也是函数的生命周期):

  • 创建-入栈:创建执行上下文,并压入栈,获得控制权。
  • 执行-干活:执行函数的代码,给变量赋值、查找变量。如有内部函数调用,递归重复函数流程。
  • 出栈-销毁:函数执行完成出栈,释放该执行上下文,其变量也就释放了,全都销毁,控制权回到上一层执行上下文。

JavaScript入门③-函数(2)原理{深入}执行上下文

function first() {
    second();	//调用second()
}
function second() {
}
first();

上面的代码执行过程如下图所示

  1. 程序初始化运行时,首先创建的是全局上下文Global,进入执行栈。
  2. 调用first()函数,创建其下文并入栈。
  3. first()函数内部调用了second()函数,创建second()下文入栈并执行。
  4. second()函数执行完成并出栈,控制权回到first()函数上下文。
  5. first()函数执行完成并出栈,控制权回到全局上下文。

JavaScript入门③-函数(2)原理{深入}执行上下文

????再来一个函数调用栈的示例:

var a = 1;
let b = 1;
function FA(x) {
    function FB(y) {
        function FC(z) {
            console.log(a + b + x + y + z);
        }
        FC(3);
    }
    FB(2);
}
FA(1); //8

上面函数在执行FC()时的函数调用堆栈如下图(Edge浏览器断点调试):

JavaScript入门③-函数(2)原理{深入}执行上下文

✅ 执行FC函数代码时,其作用域保留了所有要用到的作用域变量,从自己往上,直到全局对象,闭包就是这么来的!

  • var a = 1;:var申明的变量会作为全局对象window的变量。
  • let b = 1;:全局环境申明的变量,任何函数都可以访问,放在全局脚本环境中,可以看做全局的一部分。

✅ 调用堆栈中有FC、FB、FA,因为是嵌套函数,FB、FA并未结束,所以还在堆栈中,函数执行完毕就会被立即释放抛弃。

JavaScript入门③-函数(2)原理{深入}执行上下文

2.2、堆栈溢出

???? 函数调用栈容量是有限的!—— 递归函数

递归函数就是一个多层+自我嵌套调用的过程,所以执行递归函数时,会不停的入栈,而没有出栈,循环次数太多会超出堆栈容量限制,从而引发报错。比如下面示例中一个简单的加法递归,在Firefox浏览器中递归1500次,就报错了(InternalError: too much recursion),Edge浏览器是11000次超出调用栈容量(Maximum call stack size exceeded)。

❓怎么解决呢?

  • 避免递归:封装处理逻辑,转换成循环的方式来处理。或用setTimeout(func,0)发送到任务队列单独执行。
  • 拆分执行:合理拆分代码为多个递归函数。
function add(x) {
    if (x <= 0)
        return 0;
    return x + add(x - 1);  //递归求和
}
add(1000); //Firefox:1000可以,1500就报错 InternalError: too much recursion
add(10000);//Edge:10000可以执行,11000就报错 Maximum call stack size exceeded

» Firefox 的调用堆栈:

JavaScript入门③-函数(2)原理{深入}执行上下文


03、什么是词法作用域?

作用域(scope)就是一套规定变量作用范围(权限),并按此去查找变量的规则。包括静态作用域动态作用域,JavaScript中主要是静态作用域(词法作用域)

  • ???? 静态作用域(就是词法作用域):JavaScript是基于词法作用域来创建作用域的,基于代码的词法分析确定变量的作用域、作用域关系(作用域链)。词法环境就是我们写代码的顺序,所以是静态的,就是说函数、变量的作用域是在其申明的时候就已经确定了,在运行阶段不再改变。
  • ???? 动态作用域:基于动态调用的关系确定的,其作用域链是基于运行时的调用栈的。比如this,一般就是基于调用来确定上下文环境的。因此this值可以在调用栈上来找,注意的是this指向一个引用对象,不是函数本身,也不是其词法作用域。

JavaScript入门③-函数(2)原理{深入}执行上下文

因此,词法作用域主要作用是规定了变量的访问权限,确定了如何去查找变量,基本规则

  • 代码位置决定:变量(包括函数)申明的的地方决定了作用域,跟在哪调用无关。
  • 拥有父级权限:函数(或块)可以访问其外部的数据,如果嵌套多层,则递归拥有父级的作用域权限,直到全局环境。
  • 函数作用域:只有函数可以限定作用域,不能被上级、外部其他函数访问。
  • 同名就近使用:如果有和上级同名的变量,则就近使用,先找到谁就用谁。
  • 逐层向上查找:变量的查找规则就是先内部,然逐级往上,直到全局环境,如果都没找到,则变量undefined

这里的词法作用域,就是前文所说JS变量作用域。而闭包保留了上下文作用域的变量,就是为了实现词法作用域。

❓那词法作用域是怎么实现的呢?——作用域链、闭包

父级函数FA()执行完成后就出栈销毁了(典型场景就是返回函数)FB()可以到任何地方执行,那内部函数FB()执行的时候到哪里去找父级函数的变量x呢?

  • ✅ 函数内部作用域:首先每个函数执行都会创建自己作用域(执行上下文),查找变量时优先本地作用域查找。
  • ✅ 闭包:引用的外部(词法上级)函数作用域就形成了一个闭包,用一个Closure_(Closure /ˈkləʊʒə(r)/ 闭包)_对象保存,多个(外部引用)逐级保存到函数上下文的[[Scope]](Scope /skoʊp/ 作用域)集合上,形成作用域链
  • ✅ 作用域链的最底层就是指向全局对象的引用,她始终都在,不管你要不要她。
  • ✅ 变量查找就在这个作用域链上进行:自己上下文(词法环境,变量环境) => 作用域链逐级查找=> 全局作用域 => undefined

JavaScript入门③-函数(2)原理{深入}执行上下文

function FA(x) {
    function FB(y) {
        x+=y;
        console.log(x);
    }
    console.dir(FB);
    return FB;  //返回FB()函数
}
let fb = FA(1);  //FA函数执行完成,出栈销毁了
fb(2);  //3  //返回的fb()函数保留了他的父级FA()作用域变量x
fb(2);  //5	 //闭包中的x:我又变大了
fb(2);  //7  //同一个闭包函数重复调用,内部变量被改变

JavaScript入门③-函数(2)原理{深入}执行上下文

????闭包简单理解就是,当前环境中存放在指向父级作用域的引用。如果嵌套子函数完全没有引用父级任何变量,就不会产生闭包。不过全局对象是始终存在其作用域链[[Scope]]上的。

????举个例子

var a = 1;
let b = 2;
function FunA(x) {
    let x1 = 1;
    var x2 = 2;
    function FunB(y) {
        console.log(a + b + x + x1 + x2 + y);
    }
    FunB(2);
    console.dir(FunB)
}
FunA(1); //9
console.dir(FunA)

上面的代码示例中,FunA()函数嵌套了FunB()函数,如下图FunB()函数的[[Scope]]集合上有三个对象:

JavaScript入门③-函数(2)原理{深入}执行上下文

  • Closure (FunA) FunA()函数的闭包,包含他的参数x、私有变量x1x2
  • Script:Script Scope 脚本作用域(可以当做全局作用域的一部分),存放全局Script脚本环境内可访问的letconst变量,就是全局作用域内的变量。var变量a被提升为了全局对象window的“属性”了。
  • Global:全局作用域对象,就是window,包含了var申明的变量,以及未申明的变量。

如果把FunB()函数放到外面申明,只在FunA()调用,其作用域链就不一样了。


04、执行上下文是怎么创建的?

执行上下文的创建过程中会创建对应的词法作用域,包括词法环境变量环境

  • 创建词法环境(LexicalEnvironment):
    • 环境记录EnvironmentRecord:记录变量、函数的申明等信息,只存储函数声明和let/const声明的变量。
    • 外层引用outer:对(上级)其他作用域词法环境的引用,至少会包含全局上下文。
  • 创建变量环境(VariableEnvironment):本质上也是词法环境,只不过他只存储var申明的变量,其他都和词法环境差不多。
ExecutionContext = {
    ThisBinding = <this value>,
    LexicalEnvironment = { ... },
    VariableEnvironment = { ... },
}

❗变量查找:变量查找的时候,是先从词法环境中找,然后再到变量环境。就是优先查找const、let变量,其次才var变量。

JavaScript入门③-函数(2)原理{深入}执行上下文

换几个角度来总结下,创建执行上下文主要搞定下面三个方面:

① 确定 this 的值(This Binding)

  • 在全局上下文中this指向window
  • 函数执行上下文中,如果它被一个对象引用调用,那么 this 的值被设置为该对象,否则 this 的值被设置为全局对象或 undefined(严格模式下)
  • call(thisArg)、apply(thisArg)、bind(thisArg)会直接指定thisValue值。

② 内部环境:包括词法环境变量环境,就是函数内部的变量、函数等信息,还有参数arguments信息。

③ 作用域链(外部引用):外部的词法作用域存放到函数的[[Scope]]集合里,用来查找上级作用域变量。


05、❓有什么结论?

  • ❓ 变量越近越好:最好都本地化,尽量避免让变量查找链路过长,一层层切换作用域去找也是很累的。
  • ❓ 优先const,其次let,尽量(坚决)不用var
  • ❓ 注意函数调用堆栈的长度,比如递归。
  • ❓ 闭包函数使用完后,手动释放一下,fun = null;,尽早被垃圾回收。
  • ❓尽量避免成为全局环境的变量,特别是一些临时变量,全局对象始终都在,不会被垃圾回收。
    • 包括全局环境申明的的letconstvar
    • 切记不用未申明变量str='',不管在哪里都会成为全局变量。

远离JavaScript、远离前端......我以为已经学会了,其实可能还没入门。

JavaScript入门③-函数(2)原理{深入}执行上下文


10、GC内存管理

值类型变量的生命周期随函数,函数执行完就释放了。垃圾回收GC(Garbage Collection)内存管理主要针对引用对象,当检测到对象不再会被使用,就释放其内存。GC是自动运行的,不需干预也无法干预

GC回收一个对象的关键就是——确定他确是一个废物,么有任何地方使用他了,主要采用的方法就是标记清理。

  • 标记清理(mark-and-sweep):标记内存中的所有的可达对象和他所有引用的对象),剩下的就是没人要的,可以删除了。
  • 引用计数:按变量被引用的次数,这个策略已不再使用了,由于该回收垃圾的策略太垃圾从而被抛弃了。

❓什么是可达性?

  • ????根(roots):当前执行环境(window)最直接的变量,包括当前执行函数的局部变量、参数;当前函数调用链上的其他函数的变量、参数;全局变量。
  • ????可达性(Reachability):如果一个值(对象)可以从根开始链式访问到他,就是可达的,就说明这个数据对象还有利用价值。

JavaScript入门③-函数(2)原理{深入}执行上下文

上图中FuncA函数中的局部变量 obj1,其值对象{P}存放在内存堆中,此时的值对象{P}被根变量obj1引用了,是可达的。

  • 如果函数执行完毕,函数就销毁了,变量引用obj1也一起随她而去。值对象{P}就没有被引用了,就不可达了。
  • 如果在函数中显示执行 obj1=null; 同样的值对象{P}没有被引用了,就不可达了。

JavaScript入门③-函数(2)原理{深入}执行上下文

GC定期执行垃圾回收的两个步骤:

① 标记阶段:找到可达对象并标记,实际的算法会更加精细。

  • 垃圾收集器找到所有的根,并“标记”(记住)它们。
  • 继续遍历并“标记”被根引用的对象。
  • ...继续遍历,直到找到所有可达对象并标记。

② 清除阶段:没有被标记的对象都会被清理删除。

⚠️全局变量不会被清理:属于window的全局变量就是根,始终不会被清理,有背景靠山就是不一样!


©️版权申明:版权所有@安木夕,本文内容仅供学习,欢迎指正、交流,转载请注明出处!原文编辑地址-语雀