JS中一些重要的概念总结

195 阅读11分钟

1. 执行上下文

1.1 重要概念

  • 全局对象
    • js 引擎在执行代码之前,会在堆内存中创建一个全局对象:Global Object(GO)
      • 该对象所有的作用域(scope) 都可以访问;
      • 里面会包含Date、Array、String、Number、setTimeout、setInterval等等;
      • 其中,还有一个window属性指向自己

image.png

  • 执行上下文
    • js 引擎内部有一个执行上下文(Execution Context Stack,简称 ECS), 它是用于执行代码的调用栈

image.png

  • 一开始,执行的是全局的代码块:

    • 全局的代码块为了执行会构建一个 Global Execution Context(GEC)
    • GEC 会被放入到 ECS 中执行;
  • GEC 被放入到 ECS 中里面包含两部分内容

    • 第一部分: 在执行代码前,在 parser 转成 AST 的过程中,会将全局定义的变量、函数等加入到GlobalObject中,但是并不会赋值
      • 这个过程也称之为变量的作用域提升(hoisting)

    image.png

    • 第二部分: 在代码执行中,对变量赋值,或者执行其他的函数;

    image.png

  • VO对象

    • 每一个执行上下文会关联一个 VO(Variable Object,变量对象),变量和函数声明会被添加到这个 VO 对象中。
    • 当全局代码被执行的时候,VO 就是 GO 对象了
  • 函数执行上下文

    • 在执行的过程中执行到一个函数时,就会根据函数体创建一个函数执行上下文(Functional Execution Context,简称 FEC) ,并且压入到 EC Stack 中。
    • 因为每个执行上下文都会关联一个 VO,那么函数执行上下文关联的 VO 是什么呢?
      • 当进入一个函数执行上下文时,会创建一个 AO 对象(Activation Object)
      • 这个 AO 对象会使用 arguments 作为初始化,并且初始值是传入的参数
      • 这个 AO 对象会作为执行上下文的 VO 来存放变量的初始化

    image.png

    image.png

1.2 上述代码执行流程

  1. 在执行代码之前,会在堆内存中创建一个全局对象(GO)。此外,代码是在全局中执行的,并且执行代码是放在执行栈(ECS) 中被执行的,所以 js 引擎会创建一个全局执行上下文(GEC) ,并将这个执行上下文放到 ECS 中;

  2. 首先,会对代码进行解析,这一阶段只是将全局定义的变量、函数等加入到 GO 中,并不会赋值;由于每个执行上下文都会关联一个 VO 对象,此阶段 VO 指向 GO;

    • 函数声明会放到变量、对象声明之前,并且在解析时,普通对象并不会分配新的内存,和变量声明方式相同
    • 因此,首先,给 函数 bar、foo 分配新的内存空间;然后,声明变量 message,值为 undefined;对象 obj 值也为 undefined
    • PS: 定义一个函数 foo,然后在函数前面执行代码var foo = 'abc',此时 foo 的值为 abc
    var foo = 'abc'
    console.log(foo)  // abc
    
    function foo() {
      ...
    }
    
  3. 执行代码

    • 将 message 值变为 Global Message
    • 给对象 obj 创建一块内存空间,此时 obj 就保存的是内存空间的地址;
    • 执行函数 foo,会给函数体创建一个函数执行上下文(FEC),并且压入到 ECS 中,并为此函数体创建一个 AO 对象
      • 这个 AO 对象会使用 arguments 作为初始化,并且初始值是传入的参数,因此,解析过程时,会声明函数 bar,为 bar 创建一块内存空间,bar 指向此内存空间,
      • message 值为 undefined,
      • height 值为 undefined,
      • num 值为 undefined
      • 执行 AO 中代码,message 赋值为 "Foo Message",然后,执行 bar(), 创建方式同 foo;
        • 为 bar 创建一个 FEC,并压入 ECS 中,然后为此函数创建一个 AO 对象,解析函数,声明 address 值为 undefined
        • 执行函数代码,打印代码,address 赋值为 bar
        • 执行完成后,bar 函数对应的 VO 会出栈,相应堆中的 AO 对象也会被销毁(会不会销毁取决于 GC 垃圾回收器),之后继续执行 foo 中后面的代码
    • age 赋值为 18,height 赋值为 1.88,执行打印代码
    • 代码执行完成后,foo 对应的 VO 出栈,然后执行全局对象(GO)中的代码。
  4. 代码执行完成

1.3 作用域链

image.png

image.png

  • 原理解析

    • 首先会在栈内存中创建一个GO(window)对象,此时全局上下文中的VO对象指向的是GO。而GO对象中首先会初始化,message值为undefined;由于foo是一个函数,会创建一个foo的函数对象,此函数对象会使用arguments作为初始化,包含length、name等属性。还包括一个scopes作用域链,此作用域链是一个列表,第一个指向GO。
    • 创建完GO对象后,会执行全局代码,首先执行的 foo 函数,会为该函数创建一个foo AO 对象,bar是一个函数,在内存中会创建一个bar函数对象,此函数对象也包括length、name等属性,还包含scopes作用域链,此作用域链的列表含两个值:0(foo AO对象),1:GO对象;name初始化为 undefined。然后执行 foo 中的代码,将 name 的值赋值为 foo,并将 bar 函数返回。
    • foo执行上下文结束后,会从栈中被弹出。
    • 之后为 bar 创建一个 执行上下文VO,指向的是 bar AO 对象,使用 arguments 作为初始化。然后执行函数中的代码,由于此 AO 对象中不包括 name 属性,所以会沿着作用域链优先去 foo AO 对象中去找,找到后打印 name。
  • 函数作用域链

    • 函数的作用域链在一开始创建(解析)的时候就已经确定,跟它的调用位置没有关系
      var message = 'Global Message'
    
      function foo() {
        console.log(message)  // Global Message
      }
    
      foo()
    
      var obj = {
        name: 'obj',
        bar: function() {
          var message = 'bar message'
          foo()
        }
      }
    
      obj.bar()
    
    • 在上述代码中,执行函数打印的结果为 Global Message,因为函数创建的时候是在全局作用域创建的,所以它的作用域链列表第一个指向的是 GO。
  • 面试题

function foo() {
  var a = b = 100
}
foo()
console.log(a)  // Error: a is not defined
// 由于 b 前面没有声明,所以浏览器会将其作为全局对象,但是这种写法不严谨,不推荐这样写
console.log(b)  // 100

PS:这样设计的目的是为了闭包。 方便我们更好的编写代码。

1.4 内存管理

  • 代码执行过程需要为它分配内存,有些编程语言手动管理内存,有些自动帮助我们管理内存

  • 内存管理生命周期:

    • 分配内存;
    • 使用分配的内存;
      • 原始数据类型内存的分配:直接在栈空间进行分配;
      • 复杂数据类型内存的分配:在堆内存中进行分配;
    • 不需要使用时,释放内存;
  • 垃圾回收机制

  • Garbage Collection 简称 GC。最早出现在 Lisp 语言中。

    1. 引用计数(Reference counting)
    • retainCount 有一个对象有引用它时,那么这个对象的引用就+1。当计数值为 0 的时候就会被销毁。
    • 弊端:会产生循环引用
    1. 标记清除
    • 核心思路:可达性
    • 设置一个根对象(window对象),垃圾回收器会定期从这个根开始,找所有从根开始有引用到的对象,对于没有引用到的对象,就认为是不可用的对象,那么就将它回收。
    • 可以很好地解决循环引用的问题。
    1. 标记整理
    • 回收期间同时会将保留的存储对象搬运汇集到连续的内存管理,从而整合空闲空间,避免内存碎片化
    1. 分代收集:对象会被分成两组:新的旧的
    • 许多对象出现,完成它们的工作并很快死去,它们可以很快被清理;
    • 哪些长期存活的对象会变得老旧,而且被检查的频次也会减少

1.5 闭包

  • 一个普通的函数 function ,如果它可以访问外层作用域的自由变量,那么这个函数就是一个闭包;
  • 广义的角度来说:JavaScript中的函数都是闭包
  • 狭义的角度来说:JavaScript中一个函数,如果访问了外层作用域的变量,那么它就是一个闭包

为什么会存在闭包,因为闭包的存在能够让我们很好的调用外部变量。 请看下面实例:如果没有闭包,那么我们在使用外部变量的时候,首先在函数定义的时候要一一讲这些形参,其次,在调用的时候还有一一讲这些参数传进入,使用起来相当麻烦,因此闭包的出现大大解决了这个问题。

var name = 'global name'
var age = 18
var height = 1.88
var addres = 'bj'
var intro = 'large'

function foo(name, age, height, address, intro, num1, num2) {
  var message = 'Hello World'
  console.log(message, name, height, address, intro)

  function bar() {
    console.log(name)
  }

  bar()
}

foo(name, age, height, address, intro, 20, 30)
  • 浏览器优化

    • 在执行内部函数的时候,如果没有引用外部变量,浏览器会自动将没有引用到的参数销毁

    image.png

1.6 内存泄漏

  • 对于那些永远不会再使用的对象,但是对于GC来说,也不知道要进行释放,对应内存会依然保留着。
  • 解决方法:只需要将其指针指向 null

2. 函数属性、arguments、函数柯里化以及组合函数

2.1 函数属性

  • 最重要的是name、length属性。

2.2 arguments

  • 类数组,含有 length 属性,但是不包括 push、slice 等方法。

  • 类数组转数组的几种方法:

    • 遍历 arguments,依次添加到数组;

    • 数组的 slice 方法:

      • Array.prototype.slice.call(arguments)
      • [].slice.call(arguments)
    • ES6 方法:

      • [...arguments]
      • Array.from()
  • 箭头函数不绑定 arguments,会去上层作用域查找。

  • 函数的 rest 剩余参数:可以将不定数量参数放到一个数组中。

  • rest 与 arguments 的区别:

    • 剩余参数只包含那些没有对应形参的实参,而arguments对象包含了传给实参的所有实参
    • arguments对象不是一个真正的数组,而rest参数是一个真正的数组,可以进行数组的所有操作;
    • arguments是早期ECMAScript中为方便获取所有的参数提供的一个数据结构,而 rest参数是 ES6中提供并且来代替arguments的
  • 剩余参数必须放到最后一个位置,否则会报错。

2.3 纯函数

  • 确定的输入,一定会产生确定的输出;
  • 函数在执行过程中,不能产生副作用;

2.4 副作用

  • 在执行函数时,除了返回函数值,还对调用函数产生了附加的影响,比如修改了全局变量,修改参数或者改变外部的存储

2.5 柯里化

2.5.1 概念

  • 传递给函数一部分参数来调用它,让它返回一个函数去处理剩余的参数,这个过程就称之为柯里化

2.5.2 优势

  • 函数的职责单一
    • 在函数编程中,我们其实希望一个函数处理的问题尽可能的单一,而不是将一大堆的处理过程交给一个函数来处理
    • 我们可以将每次传入的参数在单一的函数中进行处理,处理完之后在下一个函数中在使用处理后的结果
  • 函数的参数复用

2.5.3 实现自动函数柯里化

function foo1(x, y, z) {
  console.log(x + y + z)
}
// foo1 柯里化实现效果
const foo2 = x => y => z => console.log(x + y + z)

// 自动柯里化函数
function customCurrying(fn) {
  // 分为两类:
  // 1. 继续返回一个新的函数,继续接受其他参数
  // 2. 直接执行fn的函数
  function curried(...args) {
    if (args.length >= fn.length) { // 执行第二类
      // return fn(...args) // 不包含 this
      return fn.apply(this, args)
    } else { // 执行第一类
      return function (...args2) {
        // return curried(...args.concat(args2))
        return curried.apply(this, args.concat(args2))
      }
    }
  }

  return curried
}

var fooCurry = customCurrying(foo1)
fooCurry(10)(20)(30)

2.6 组合函数

2.6.1 概念

  • 某一个数据进行函数的调用,执行两个函数fn1和fn2,这两个函数是依次执行的;
  • 每次都需要进行两个函数的调用,操作上就会显得重复
  • 将这两个函数组合起来,实现自动依次调用,这个过程就称为组合函数

2.6.2 实现

// 封装的函数:传入多个函数,自动将多个函数组合在一起依次调用
function composeFn(...fns) {
  // 1. edge case
  var length = fns.length
  if (length <= 0) return
  for (var i = 0; i < length; i++) {
    var fn = fns[i]
    if (typeof fn !== 'function') {
      throw new Error('index position ${i} must be function')
    }
  }

  // 2. 返回的新函数
  return function (...args) {
    var result = fns[0].apply(this, args)
    for (var i = 1; i < length; i++) {
      var fn = fns[i]
      result = fn.apply(this, [result])
    }
    return result
  }
}

// 第一步对数字*2
function double(num) {
  return num * 2
}

// 第二步对数字**2
function pow(num) {
  return num ** 2
}
var newFn = composeFn(double, pow, console.log)
newFn(100)

2.7 call & apply

call、apply 使用场景:

  1. 一般需要在一个对象中调用另一个对象的方法。这个方法可能在两个对象中都存在,因此需要明确指定要使用另一个对象的方法。
  2. 绑定 this

区别:

  • call:Function.call(obj, params1, params2, ...)
  • apply: Function.apply(obj, [params1, params2, ...])