浏览器原理与实现-学习2-执行上下文/调用栈/作用域/闭包

175 阅读8分钟

1.代码如何执行

image.png

1. 执行代码2个阶段

  1. 编译:把当前定义的代码移到前面(代码提升),并赋予undefine,把剩下的赋值与执行调用代码做 可执行代码,这些信息保存在执行上下文。
  2. 执行:开始一行一行运行可执行代码。

例子

foo() // 正常打印 xxxxx
console.log(name) //当执行到这一行 打印的是 undefined
var name = 'jason'
function foo() {
    console.log('xxxxx');
}

// 等价于 拆成 =  声明代码 + 执行时代码
// 1. 编译
var name = undefined
function foo() {
    console.log('xxxxx');
}

// 2. 执行代码
foo() 
name = 'jason'

1.编译

声明提前(变量提升Hoisting)

  1. 把函数定义 和 变量的定义提到代码最前面

执行上下文(Execution context)

js是动态执行的语言,所以物理代码是不会发生变化,而是使用一个运行时的执行上下文记录和运行 声明提前的变量存储,以及编译后的可执行代码。

包含3部分: 变量环境,词法环境,可执行代码

  • 变量环境
    • 1.把声明的内容提前保存到变量环境
    • 2.变量提升hosting:出现一样的变量与函数的声明,后面覆盖前面
  • 词法环境
    • 1.存放const 和 let定义的变量

2.可执行代码

  1. 一行行执行,主要进行赋值操作
  2. 遇到函数,会进入函数,并再进行编译和执行代码,创建一个新的执行上下文

例子

1.函数表达式

var b = function foo() {console.log('xxxxx');}

这是一个表达式,var b 是定义,b = function foo() {console.log('xxxxx');} 是赋值。

b() //  
var b = function foo() {
    console.log('xxxxx');
}

// 等价于 
//1. 声明提前
var b = undefined

//2. 执行代码
b() //这里会报错,找不到该方法
b = function foo() {
    console.log('xxxxx');
}

提示:
// Uncaught TypeError: b is not a function  at <anonymous>:1:1

2.相同方法定义时

function foo() {
    console.log('xxxxx');
}
foo() //最终打印 yyyy 后面覆盖前面定义
function foo() {
    console.log('yyyy');
}
foo() //最终打印 yyyy 后面覆盖前面定义

// 等价于 
// 1.编译
function foo() { // 重复定义 后面直接覆盖前面
    console.log('yyyy');
}
// 2.执行
foo()  // 打印 yyyy
foo()  // 打印 yyyy

3.相同变量定义时

相同变量定义都一样,都是undefined,没有覆盖的说法,只声明一次。

console.log(x) //打印undefined
var x = 100
var x = 200
console.log(x)

// 等价于 
// 1.编译
 var x = undefined
// 2.执行
console.log(x) //undefined
x = 100;
x = 200;
console.log(x) //200

4. 变量与函数同名

最终都以后面定义的覆盖前面

foo()
var foo = function() {
    console.log('xxx')
}
function foo() {
    console.log('yyy')
}

//等价于
// 1.编译
var foo = undefined
function foo() { // foo 把上面var foo = undefined给覆盖了
    console.log('yyy')
}

// 2.执行
foo() // 输出yyy

2.调用栈

3种执行上下文

  • 全局
  • 函数
  • eval

全局

js执行全局代码时,会编译全局代码,创建全局执行上下文与可执行代码,整个页面的生存周期内,全局执行上下文只有一份。

函数

调用一个函数时,会进行编译,创建函数执行上下文与可执行代码,当函数结束后,函数执行上下文与可执行代码会被销毁。

eval

动态执行与动态销毁

调用栈

函数调用

使用函数名+() 进行调用 会自动创建 执行上下文 + 可执行代码

栈结构

  • 行为:入栈与出栈
  • 连续空间
  • 如果递归调用不结束,会栈溢出
  • 后进先出

 JavaScript 的调用栈

js引擎会将执行上下文压入栈中,管理执行上下文的栈称为执行上下文栈,又称调用栈

例子

var g1 = 'g1'  
main()  
function main(){   

   var m1 = 'm1'   
    a(m1)  
}  
function a(para) {  
    var a1 = 'a1'  
    console.log("aaa",para)  
}

image.png

image.png

其他

console.trace()调试

栈溢出

当递归没有终止的条件,会出现栈溢出

function test(a,b){
    return test(a,b)
}
console.log(test(1,2))

//提示
Uncaught RangeError: Maximum call stack size exceeded
    at test (<anonymous>:2:5)

尾递归调用

当函数出现递归时,如果次数过多,效率会十分低,并且栈有可能被挤爆。

所以编译器会针对return 的内容是方法调用,而不是表达式时候,就不压栈,直接调用完就销毁方法。

例子1 , 求1加到5

1+2+3+4+5=15


function sum(n) {
    if (n <= 1) return n;
    return n + sum(n-1);
}
sum(5) // 1+2+3+4+5=15

//尾递归实现
function sum2(n,total = 0) {
    console.log("n",n," tatal",total)
    if (n == 0) return total;
    return sum2(n-1,n+total);// 
}

//打印的结果
// n 5  tatal 0
// n 4  tatal 5
// n 3  tatal 9
// n 2  tatal 12
// n 1  tatal 14
// n 0  tatal 15

例子2 斐波那契数列

1,1,2,3,5,8,13,21,34,55,89

前面两个之和等于第三个数

image.png

//转化成的逻辑
F(4) = F(3)  + F(2) 
=  [F(2) + F(1)]  + [F(1) + F(1)]
=  [[F(1) + F(1)] + F(1)]  + [F(1) + F(1)] 
= 5

实现的代码

function mysum(n) {  
    if (n == 1 || n == 0) {  
        return 1  
    }  
    return mysum(n-1) + mysum(n-2)  
}
mysum(4) //从4开始

入栈& 出栈

image.png 上面的代码由于是嵌套递归,所有复杂度是 O(2^n) 指数阶

会依次把所有递归的方法压入栈,然后依次出栈,返回。

尾代码优化

把return 的内容改成方法调用,每次压入栈,都会立刻出栈。每次方法的空间都是立即释放销毁。

image.png 所以我们要做的是把每一次计算的结果保留下来,并传递给下个执行的方法。

'use strict'
/**
 * 
 * @param {*} n 代表到第几位结束
 * @param {0} total1 代表每次循环的合计结果 包含n-1
 * @param {1} total2 代表每次循环的合计结果 包含n-1 + n-2
 * @returns 
 */
function mysum(n, total1 = 0, total2 = 1) {// 
    console.log(`n:${n} total1:${total1} total2:${total2} `)
    if ( n == 0) {
        return total2
    }
    return mysum(n-1,total2,total1+total2) 
}

注意:只有在es6开启严格模式才生效

3.作用域

作用域:变量可以被访问的范围

1.词法作用域

词法作用域:代码阶段就决定好的,和函数是怎么调用的没有关系

例子

var name = 'global'
main()
function main(){   
    var name = 'main'
    a()
}
function a() { 
    console.log(name)  //这打印的是 全局的 global ,而不是main函数里面的 main
}

image.png

let name = 'global'
function a() { 
    let name = 'a'
    function b(){
        let name = 'b'
        function c(){
            let name = 'c' 
            console.log(name) //这里会依次查找 c -> b -> a -> 全局
        }
    }
}
//输出 c
//当没有定义 let name = 'c'  name 打印的是 b 

2.动态作用域

同样的例子,如果是动态作用域时,是实时访问所在的运行嵌套关系访问作用域

例子

var name = 'global'
main()
function main(){   
    var name = 'main'
    a()
}
function a() { 
    console.log(name)  //如果是动态作用域的语言,这是打印的是 main
}

2.作用域链

  • 执行上下文中包含 outer引用,指向上一级的指向上下文
  • 访问顺序先访问当前执行上下文的变量,没有再去outer 上一级上下文的变量中找
  • outer的指向基于词法作用域,即代码定义时的位置已经决定了outer的指向

访问顺序 

当前执行上下文中的 词法环境 -> 变量环境 -> outer -> 词法环境 -> 变量环境

例子

function b() {
    var name = "b"
    let num2 = 100
    if (1) {
        let name = "bb"
        console.log(num) //这里在 b上下文找不到,会去全局找到 1
    }
}
function a() {
    var name = "a"
    let num = 2
    {
        let num = 3 // 这里使用的是栈结构,num=3 是最后压入,所以最先取出
        b()
        console.log(num) // 这里在a 上下文找到num,直接打印 3
    }
}
var name = "global" 
let num = 1
a()

image.png

js中的块级作用域

  1. js没有块级作用域的问题
  2. 变量提升的缺点
    • 块里面的代码变量提升到函数顶部或全局,导致变量污染 和 变量覆盖

例子

//场景1
for (var i =0; i < 10; i++)
//场景2
var a = 10 ;
function fun(){
    console.log(a)
    if(false) {
        var a = 100
    }
}
  • 这里a访问了的是变量提升后的100,而不是全局的10

使用const 和let 实现块级作用域

  • 普通变量存储在变量环境
  • 块级作用域的变量 存储在词法环境,压入以栈的形式压入
  • 访问变量的顺序:
    • 1.先访问词法环境,从栈顶到栈底,
    • 2.再访问变量环境
//1.先访问词法环境,从栈顶到栈底,
//2.再访问变量环境
function foo(){
var a = 1
let b = 2
{
    let b = 3
    var c = 4
    let d = 5
    console.log(a)
    console.log(b)
}
console.log(b) 
console.log(c)
console.log(d)
}   
foo()
//词法环境依次压入 b = 2 , b =3, d = 5,访问的时候从栈顶开始,所以括号内的 b取得是3 

编程语言的好坏

  • 没有好坏之分,语言只是开发实现功能的工具,是否有完善的框架和社区,好的生态,非常多的应用,以及可以给开发实实在在的收益
  • js借鉴了 java的虚拟机,新引入了协程,迭代器,作用域,一直往好的方向发展

4.闭包

内部函数引用外部函数的变量,则形成闭包。外部函数已经执行结束,执行上下文已经销毁,但是由于内部函数已经引用者,所以外部函数的变量不会被销毁。

function myFun() {
    var name = "jason"
    let test1 = 1
    const test2 = 2
    var innerBar = {
        getName:function(){ // 在词法作用中,他的outer是myFun函数
            console.log(test1)
            return name
        },
        setName:function(newName){// 在词法作用中,他的outer是myFun函数
            name = newName
        }
    }
    return innerBar
}
var myFunObj = myFun() //这里返回的是一个对象,同时 myFun执行完应该被销毁,包括执行上下文
myFunObj.setName("jason2") // 这里setName方法里面还引用着外部的name变量,name变量形成了闭包
myFunObj.getName() //  这里getName 方法里面还引用着外部的test1变量,test1 变量形成闭包
console.log(myFunObj.getName()) // 再次调用 访问的依然是统一闭包变量。
//最终 name 和 test1 会被 closeure(myFun) 闭包集合保留下来
//当调用 setName方法,调用栈 栈顶 setName, closeure(myFun) , 全局,
//所以查找变量的时候,依次查询顺序 setName -> closeure(myFun) ,找到变量

var myFunObj = myFun() 系统会创建一个集合来保存这些closure(fun),内容都存放在堆里

myFunObj.setName("jason2") 当调用引用闭包的函数时,该函数的执行上下文已经 引用着closure(fun)的集合。作用域访问依次顺序是 Local–>Closure(myFun)–>Global image.png

myFunObj.getName() 个setName一样,中间共用Closure(myFun)

image.png

  • 垃圾回收问题
    • 如果引用的是局部变量,则会被垃圾回收
    • 如果是全局变量将会永久保存

使用闭包的准则:如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,而且占用内存又比较大的话,那就尽量让它成为一个局部变量**。

5.this

包含在执行上下文中

image.png

  • 全局执行上下文
    • 指向window
  • 函数中的执行上下文
    • 默认指向window
    • 当使用对象调用函数
      • js会隐式调用 call方法改变this指向 (js框架自动处理了)
    • 主要调用call方法 设置this
  • eval执行上下文
//主动调用call ,传入this的对象
let obj = {
    name : "jason", 
}
function myfun(){
    this.name = "sinky"
}
myfun.call(obj) //传入对象
console.log(obj.name) // 打印 sinky 


//对象调用方法改变this
let obj2 = {
    name : "jason", 
    myfun : function(){
        this.name = "sinky"
    }
}
obj2.myfun()  // 这里等价于 隐式调用了 myfun.call(obj2)
console.log(obj2.name) // 打印 sinky 



特殊情况, 赋给全局对象var myfun1,等同于window调用

// 
var myObj = {
    name : "jason",
    myfun: function(){
      this.name = "sinky" //这里修改的是 window.name ,不是当前对象
      console.log(this) //这里打印的是window
    }
  }
  var myfun1 = myObj.myfun //这里的var myfun1 等价于 window.myfun1 
  myfun1() //等价于 window.myfun1() 所以等同于: myfun1.call(window) 

例子

var obj = {
    name:"jason",
    printName: function () {
        console.log(name) //这里读取的是 outer 的 name,和对象里定义的 name: "jason" 没有半毛钱关系
    }    
}
function myFun() {
    let name = "myFun"
    return obj.printName
}
let name = "global"
let printNameFun = myFun() //这里拿到的是方法
printNameFun() //调用方法,最终还是调用 obj.printName,printName 函数的词法环境outer指向全局,访问name是global
obj.printName() // 和上面同理访问name是global
  • 在全局环境中调用一个函数,函数内部的 this 指向的是全局变量 window。
  • 通过一个对象来调用其内部的一个方法,该方法的执行上下文中的 this 指向对象本身。

构造函数中的this

function CreateObj(){
    this.name = "jason"
}
var myObj = new CreateObj()
console.log(myObj.name) //打印jason


// 原理:
var myObj = new CreateObj()
//等价于
var myObj = {}
CreateObj.call(myObj) //由于系统会自动加入设置的这一行
this.name = "jason" //当执行这一行中的this 变成了上面的 myObj = {}这个对象 ,myObj就设置了 name属性。
return myObj

this的缺陷

嵌套中的this,不会继承与外层

var myObj = {
    name : "jason", 
    showThis: function(){
        console.log(this) //这里打印{ name : "jason" }是因为 myObj.showThis() 被系统转化为 myObj.showThis.call(myObj)
        function bar(){
           console.log(this) // 这里的this 访问的是全局的this
        }
        bar()
    }
}
myObj.showThis()
//打印this是window

解决方案

1. 使用 self = this,临时存储,变量会根据作用域链访问到 self

var myObj = {
    name : "jason", 
    showThis: function(){
        console.log(this) //这里打印{ name : "jason" }是因为 myObj.showThis() 被系统转化为 myObj.showThis.call(myObj)
        let self = this
        function bar(){
           console.log(self) // 这里的this 访问的是全局的this
        }
        bar()
    }
}
myObj.showThis() //{name: 'jason', showThis: ƒ}

2. 使用箭头函数,因为箭头函数不会产生执行上下文

箭头函数没有自己的执行上下文,所以它会继承调用函数中的 this。

var myObj = {
    name : "jason", 
    showThis: function(){
        console.log(this) //这里打印{ name : "jason" ... }是因为 myObj.showThis() 被系统转化为 myObj.showThis.call(myObj)
        bar = () => {
           console.log(this) // 这里打印{ name : "jason" ...  }
        }
        bar()
    }
}
myObj.showThis()

3.普通函数的中的this 指向window

可以通过严格模式,this只会指向undefined,从而限制this指向window

参考

time.geekbang.org/column/intr…