JS语言高级

86 阅读30分钟

1. 数据的使用和传递

1.1 引言

程序的本质就是通过代码操控数据,因此在计算机底层,数据是怎样存储的、怎样传递的,明白这些机制对于理解程序的性能优化、内存管理、垃圾回收等等核心概念是非常重要的。

1.2 存储介质

首先,数据要存储,就得有介质(物理层面的实体)。对计算机而言,数据的存储有两种最常见的物理介质:

  • 硬盘存储
    • HDD传统机械硬盘:内部有旋转的盘片和磁头,通过机械运动来访问数据,读写效率低下且容易损坏。
    • SSD固态硬盘:内部没有机械部件,通过闪存芯片组成,所有操作是电子级别的,读写效率高、成本高。
  • 内存存储-RAM
    • 使用半导体存储单元:通过电信号0/1表示数据,进行读写,响应速度极快(纳秒级别),是硬盘的指数倍。

两者的区别在于:

  • 不同的硬件出于架构的不同,读写速度、使用场景、造价都是不同的
  • 硬盘存储:主要用于大容量、持久存储的数据,常见的就是我们的电脑磁盘(文件系统)、数据库
  • 内存存储:主要用于读写高速、小容量、临时性的数据,造价和技术难度更高,适用于应用程序数据读写。

💁 温馨提示

对于内存存储而言,其存储的数据的生命周期仅限于程序运行期间。因此,大部分应用程序的数据操作都是基于内存的,程序开始时,数据初始化存储在内存中,结束时内存中的数据就会被销毁。

例如:浏览器加载网页,执行JS代码时,我们定义的各种变量、函数都存在内存中,当用户关闭网页时 浏览器会释放与该网页相关的内存资源。其他应用程序也是如此,程序退出时,操作系统会自动清理相关的内存,确保系统的资源被有效利用 。

低级语言例如CC++,它们可以操作内存的分配和释放、操控硬件和调用操作系统API,因此上手难度很大,因为操作底层硬件是很复杂的。而大部分高级语言相对简单是因为它们都是基于低级语言二次封装的,不需要直接操作底层硬件,不需要手动分配管理内存。诸如JSPython都实现了自动内存分配和自动垃圾回收。

高级语言的诞生是为了简化开发难度和流程,以适用于不同的需求场景,比如SQL专门用于数据库查询。JS则专门用于控制浏览器行为,这些语言一般都不具备直接和底层硬件交互的能力。

1.3 应用程序中的内存数据管理

ECMA规范中,明确定义了垃圾回收内存管理必须是自动的,因此各个JS引擎都实现了自动垃圾回收和内存管理。非必要的场景下,无需开发人员手动介入。但是了解其底层机制仍是必要的。


不同的编程语言其数据类型是不一样的,但核心思想和本质都差不多。这些数据虽然都存于内存中,但是具体的形式却有所差异。这是因为在内存中,不同类型的数据,存储的区域是不一样的。一般来说,内存分配数据的区域有两种:

栈内存 stack堆内存 heap。堆栈的概念是基于硬件架构和操作系统共协的结果,是非常复杂的,这里无需深入。

  • 栈内存空间:一般是连续的,空间大小是固定的,一般用于存储一些简单的、固定不变的数据。
  • 堆内存空间:一般是动态分配的,大小不受限制,一般用于存储复杂的数据结构,比如对象。

JS为例,其数据类型分为原始类型和对象类型。原始类型也就是值类型,一般存于栈内存。对象类型也叫引用类型,一般存于堆内存。前面我们提到过变量标识符,在代码层面,变量标识的是数据,实际上在系统底层,变量标识符,标识的是内存空间,下面我们将通过代码和图示,来进行分析(抛开那些繁琐的编译流程细节)。

1.4 JS原始值和对象值的存储差异

原始值示例:

var a = 10;
var b = true;
var c = a;
c+=20
console.log(a) // 10
console.log(c) // 30

拆解:

  1. 引擎发现a是原始类型,则在栈内存中开辟一块空间,并把10填入这个空间,然后将这个空间标记为a
  2. 引擎发现b是原始类型,则在栈内存中开辟一块空间,并把true填入这个空间,然后将这个空间标记为b
  3. 赋值操作,引擎发现a是原始类型,则在栈内存中开辟一块新的空间,并把a空间的10复制一份填入新空间,然后将这个空间标记为c,接着再把c空间中的值加上20。因此a打印输出10c打印输出30

原始类型的值存于栈中,原始值的复制再赋值,就是复制生成了一个独立的副本。两者互不影响。

引用值示例:

var obj = {
  name:'小明',
  age:18
}

var obj2 = obj;
obj2.age = 20;
console.log(obj.age) // 20
console.log(obj2.age) // 20

为什么obj2.age也会输出20呢? 这是因为对象类型的数据是存储在堆内存当中的,obj变量标识的依然是一个栈内存空间,只不过这个空间中存储的是对象数据在堆内存当中的地址,因此,当我们把obj赋值给obj2时。实际上是把obj表示的对象在堆当中的引用地址复制了一份给到obj2。由于内存地址是唯一的,因此地址源和地址副本指向同一片堆内存空间,无论修改哪一个。双方都会受到影响。比如我们点外卖,我们提供一个地址,不同的骑手都会找到这个地址,把货物准确送达。

image.png 例三:

var o1 = {
  name:"小明",
  info:{
    city:'上海'
  },
  list:[1,2,3]
}
var list2 = o1.list;
list2[0] = 100
console.log(list2) // [ 100 2 3 ]
console.log(o1.list) // [ 100 2 3 ]

image.png 上面这个例子中,o1中多了一个list数组,数组也是对象,在内存中,数组的下标充当对象的键,下标对应的值就是对象键对应的值。因此我们声明变量list2并赋值o1.list时,依然是引用地址。因此,当我们通过list2下标去更新数组时,o1list也会改变,因为地址均是一样。

简单来说就是,引擎遇到对象结构的数据就在堆中开辟空间存数据,然后把地址存储在栈中。但是如果有嵌套对象的情况,其内层嵌套的地址和值都是在堆中,因为它们属于对象的一部分。

总结:

  • 原始类型:直接存在栈内存中,变量名标识其栈空间。
  • 对象类型:对象本身的数据存在堆内存中,变量名依然标识其栈空间,只不过空间内的值存的是对象数据在堆当中的地址。需要注意的是,对象中的原始值以及嵌套的对象的数据,包括嵌套的对象地址都是在堆内存当中的,因为它们属于对象的一部分。只有最外层的对象的引用存在栈中(变量标识符,标识的都是栈空间)。
1.5 函数的参数传递

在了解了原始类型和对象类型数据在内存中存储方式以后。再来看看函数的参数传递。函数最强大的功能就是参数传递,因此明白函数参数的运作方式是特别重要的。这里要特别说明一下ECMA规范中关于函数参数的说明。

ECMA规范中明确说明了:函数参数按值传递,并且参数就是函数局部变量。

  • 按值传递:就是说无论参数是什么类型,传递的都是参数源的副本
  • 参数局部变量:就是说函数的参数,相当于是在函数体内声明的局部变量(在执行上下文中会说明)
function test (a,b){
  console.log(a)
  console.log(b)
}
test(1,2)

// 伪代码.....
function test (a,b){
  var a = a;
  var b = b;
}

var number = 10;
function test (num){
  num += 30;
  console.log(num) // 40
}
test(number)
console.log(number) // 10

// 伪代码
function test (num){
  var num = number
  num += 30
  console.log(num) // 40
}
test(number)
console.log(number) // 10

上面这段代码,我们把number的变量传递给了函数test作为num参数。实际上是在函数内部声明了一个num变量来接收number的副本。由于number是原始值,而原始值的赋值就是简单的拷贝粘贴。因此numnumber是两个独立的数据,所以num做了加操作后,不会影响外部的number


var obj  = {
  name:"小红"
}

function test (obj) {
  obj.name = '小海'
}
test(obj)
console.log(obj.name) // 小海

// 伪代码
function test (obj) {
  var obj = obj // 地址的拷贝
  obj.name = '小海'
}

上面这段代码,将obj对象传入了test函数,函数内部修改了对象的name属性,但是影响到了外部obj。这是因为obj是一个对象,它的变量存储的是对象的内存地址,因此,传给test函数的数据是地址的副本,而地址指向的都是同一个内存空间,所以在函数内部修改对象依然影响到了外部。

var obj = new Object()
function test (obj){
  obj.name = '小红'
  obj = {}
  obj.name = '小海'
}
test(obj)
obj.name // 小红

上面这段代码,外层的obj.name输出小红,因为obj传给函数后先进行了修改(此时参数obj指的还是外部的对象)

但紧接着把参数obj重新赋值为{},相当于把参数obj又指向了另外一个内存空间。因此当obj.name改为小海的时候,外部不受影响,因为这个两个不同的地址,指向不同的对象。 首先,我们知道了执行上下文的创建是在预编译阶段完成的,而预编译又是代码执行前发生的,下面我们就来看看,预编译具体做了哪些事,以及执行上下文的三个核心组成部分具体是些什么内容。

ECMA规范中,JS存在全局作用域和局部作用域的概念:

  • 全局作用域:就是script元素最外层
  • 局部作用域:就是函数体内

执行上下文其实指的就是这两种不同域的代码在执行前所构建的基础环境。这里不涉及ES6的块级作用域,因为块级作用域依然遵循作用域链的解析规则。只不过底层实现稍微不同,由于全局相对特殊,这优先讲局部上下文。

6.5 函数上下文

函数调用前,引擎就会为其构建好对应的执行上下文。整体的步骤如下

  1. 在底层创建一个变量对象 Variable Object
  2. 寻找函数的形参和函数体内的变量声明,作为变量对象上的属性名,值统一为undefined
  3. 形参实参相统一(有对应的值就赋,没有则不管)
  4. 在函数体内寻找函数声明 , 函数的名字作为变量对象的属性名(不论变量对象是否存在同名的属性),函数声明整体作为属性值直接赋过去,相当于覆盖。

如上四步,就是函数上下文的构建过程,至此,代码进入执行阶段,修改变量对象、或者访问变量对象。

function test (a,b) {
  console.log(a)
  var a = a;
  console.log(b)
  var b = 10;
  var fn = function () {
    console.log('fn')
  }
  fn2()
  console.log(a)
  function fn2 () {
    console.log('fn2')
  }
  fn()
  console.log(b)
}
test('你好')
// 创建变量对象
{
  a: undefined
  b: undefined
  fn: undefined
  fn2: fn2 () {}
}
function test (a,b) {
  console.log(a)
  var a = a;
  console.log(b)
  var b = 10;
  var fn = function () {
    console.log('fn')
  }
  fn2()
  console.log(a)
  function fn2 () {
    console.log('fn2')
  }
  fn()
  console.log(b)
}
test('你好')
// 你好
// undefine
// fn2
// 你好
// fn
// 10

上面这个这几个案例,就是上下文构建和运行的案例,需要切记的是,上下文的构建是执行前就完成的,固定四个步骤。代码真正执行时所使用到的变量和函数都来自于这个变量对象,如果执行过程中,有赋值语句,那么将更新变量对象的属性值,此时,后续的输出语句,输出的将是更新后的变量对象的属性值。下方给出一个变化的过程。

image.png 上面这个几个步骤,所谓变量对象的创建,还有一个专业的术语叫声明提升。这就是预编译环节最核心的步骤。

声明提升:具体细节就是把相关作用域内的变量声明和函数声明、函数参数部分都提升到作用域最顶部。但是赋值部分保持在原有位置。给代码执行创造一个环境,有点类似兵马未动,粮草先行的感觉。这也是为什么当我们在变量声明前,访问变量不报错,而得到undefined的原因所在。

细节在于:函数声明和变量声明重名时,函数声明会覆盖变量声明。函数表达式只有变量部分会提升(和普通变量一样)

function test (a,b,c){
  var a = 10;
  var b = 20;
  function fn () {
    console.log('fn')
  }
}
// 下面是一个声明提升的伪代码
function test (a,b,c){
  var a;
  var b;
  var c;
  var fn : function fn () {}
  a = 10
  b = 10
  
}
全局上下文

全局上下文的构建就非常简单了。

  1. 创建全局上下文变量对象 - 实际上就是window
  2. 寻找变量声明,作为全局对象的属性,值为undefined
  3. 函数声明的话,直接将函数名为全局对象的属性,值为函数声明整体
<script>
  var a = 10;
  function test () {
    console.log('test')
  }
</script>
{
  a:undefined
  test:function test () {}
}
<script>
  console.log(a) // undefined
  console.log(test) // function test () {}
  var a = 10;
  function test () {
    console.log('test')
  }
  console.log(window.a = 10)
  window.test()
</script>

总结:

函数上下文和全局上下文的构建,并没有太大的差异。全局上下文的变量对象默认是window。同时全局上下文只需要关注变量声明和函数声明,因为不存在函数参数。

全局对象补充说明

全局对象一般都是宿主环境提供的,包含程序运行期间可以访问的全局方法和全局属性。在程序初始化时,就会自动构建全局上下文。在客户端浏览器环境下,全局上下文指的是window对象。当浏览器打开窗口加载网页时,就会为该窗口创建一个全局上下文对象,DOMAPIBOMAPIWEBAPI都会被浏览器整合后然后挂载在window对象上。最终暴露给JS引擎供开发者调用。

  • 全局上下文在程序运行期间会一直存在,直到程序退出(也就是网页关闭)才会销毁。
  • 关键字this在全局环境下就是指window
  • 访问window上的属性或方法时可以省略window前缀,换句话说,window支持隐式调用
console.log(this) // window
console.log(this === window) // true
document === window.document // true
location === window.location // true

在前面章节中,我们说JS中声明变量的方式有三种:var let const 三种,现在我们可以了解其区别了。

首先我们要明白一点,在宏观层面,全局上下文对象指的是的window。在代码层面,全局上下文指的也是window,因此,由于执行上文的规范,在<script>元素最外层(即不在任何函数作用域、块级作用域)内声明定义的变量或函数均归属于window,换句话说,全局作用域内声明的变量、对象、函数都归属于window对象。

重点

  • var定义的全局变量和全局函数以及不通过关键字声明的变量都会成为window对象上的属性和方法。
  • letconst声明的全局变量和函数不会归属window,但是在作用域链的解析规则上是一致的。
  • letconst具有块级作用域,存在声明提升,但是声明前访问会报错,因为存在暂时性死区。
  • const声明常量而不是变量

下面是这些特性和规则的案例和深入说明:

<script>
  var abc = 10;
  var abd = '10'
  console.log(window.abc) // 10
  console.log(window.abd) // '10'
  console.log(abc === window.abc) // true
  console.log(abd === window.abd) // true

  function test () {
    console.log('我是test')
  }
  console.log(test === window.test) // true
  window.test() // '我是test'

  function test2 () {
    hello = '我是字符串hello'
  }
  console.log(hello) // '我是字符串hello'
  console.log(window.hello) // '我是字符串hello'
  console.log(window.hello === hello) // true
</script>

💁 温馨提示

严格模式下,未使用关键字声明变量,会引发错误。并且this不再指向window而是undefined

因此最佳实践中已经不推荐使用var了,主推letconst


letconstES6提出的新的声明变量和常量的方式,它们具有块级作用域,块级的界定是{}单花括号,因此if语句循环语句函数语句、单独的{}语句都会被引擎视为块。在块内声明的变量,仅限块内访问。

if(true){
  let a = 10;
  console.log(a) // 10
}
console.log(a); // ReferenceError: a is not defined 只能在块内访问

const用于常量的声明:

  • 一经声明就必须赋值,不赋值就会发引发错误,这是语法层面的错误,在程序未执行时就会报出。
  • const声明的常量,不允许在程序中动态修改,引擎会报错类型错误

由于对象值存储的是引用。因此如果const声明的常量是一个对象,我们修改对象的成员时是不会引发错误的。

const a = 10;
a = 12; // TypeError: Assignment to constant variable. 类型错误,给常量变量赋值


const obj = {
  name:"小红"
}
obj.name = "小刚" // 不会引发错误,因为obj的引用地址没有改变
obj = {} // TypeError: Assignment to constant variable. 类型错误,给常量变量赋值

如果不允许修改常量对象的成员,可以通过Object.freeze()方法冻结对象。

const obj = Object.freeze({
  name:"小红"
})
obj.name = "小刚"
console.log(obj.name) // 小红

在同一作用域内,使用var关键字重复声明一个变量,引擎会忽略重复的部分,代码执行时,赋值操作按顺序即可。但是letconst重复声明一个变量就会报出语法错误(标识符已经被声明),这个错误在语法分析阶段就会报出。

var a = 10;
var a = 20;
console.log(a) // 20
const b = 10;
const b = 20; // Uncaught SyntaxError: Identifier 'a' has already been declared
let c = 10;
let c = 20; // Uncaught SyntaxError: Identifier 'a' has already been declared

var存在声明提升,因此在声明前访问不会报错,会得到undefined。严格来说letconst也会提升,但是存在暂时性死区的问题,因此在声明前访问会直接报出引用错误,引用错误不是语法错误,处于代码执行阶段。

console.log(a) // undefined
var a = 10;
console.log(a)
// Uncaught ReferenceError: Cannot access 'a' before initialization
// 表示在初始化之前无法访问 cons同理
let a = 10;

既然声明前访问会引发引用错误,那为什么还说也会提升呢,其实这涉及到引擎解析和编译的机制。代码能被执行,说明是通过了语法编译的,已经为程序执行构建好了词法环境的。暂时性死区将在预编译章节后说明。

关于varletconst我们将会在后面的案例中去说明。

  • 1️⃣出于JS特性:
    • JS是弱类型语言,数据类型是可以随意变更的,变量未声明或者作用域混乱会导致程序错误或崩溃,预编译机制会 检查变量声明和作用域,及时将错误抛出,提升代码健壮性。减少出错的可能。
  • 2️⃣出于JS运行环境:
    • 浏览器客户端作为直面用户的软件,需要及时响应用户操作(一般都是毫秒级的),不能有明显延迟,因此无论是对于页面渲染还是JS脚本的执行速度和性能都要求非常高。

总之,预编译机制的设计就是为了优化代码,根据AST进行执行上下文的创建,从而为代码执行阶段做好解析和优化的准备工作,提升其健壮性、运行效率,减少错误发生。

6.4 执行栈的概念

JS引擎中有一个特殊的数据结构叫执行栈,也叫调用栈。其作用就是管理执行上下文对象。

执行栈是一个线性的层级结构,每一层都表示一个正在执行的上下文。代码初始化时,引擎构建全局上下文,压入栈底,全局上下文只有在程序退出时才会销毁。当遇到函数调用时,引擎就会为其创建一个新的执行上下文对象,并把它推入栈中(压入栈中),引擎会根据上下文对象中的变量对象作用域this等信息来逐行执行函数中的代码,进行变量赋值、函数调用。执行完毕后,上下文弹出栈,控制权返回给函数。

由于JS是单线程的设计,所以V8引擎只有一个执行栈。执行栈线性的层级结构,遵循后进先出的原则,这种设计确保程序运行中的所有函数调用,都是排队、严格按顺序执行的。

控制权也是抽象概念,指的是程序执行顺序的变化,控制权返回就是说当前执行上下文结束后,程序的控制流程将回到调用该函数调用的位置,继续执行上一层的上下文,或全局上下文。

调用栈为空时,则说明没有可执行的代码了。

function foo() {
  console.log("Inside foo");
}

function bar() {
  console.log("Inside bar");
  foo();  // 调用 foo
  console.log("Back to bar");
}

bar();  // 调用 bar

上面案例:定义了两个函数,分别是foobar

  1. 程序开始执行时:
    1. 构建全局上下文,全局上下文被推入栈中,处于栈底(在程序关闭时才销毁)。
  1. 开始调用bar函数:
    1. bar的执行上下文被推入栈中,控制权交给bar,引擎开始逐行执行bar中的代码。
  1. 调用foo函数:
    1. 在执行bar的过程中,遇到foo调用,foo的上下文被推入栈中,控制权转移到foo,引擎开始执行foo内的代码。
  1. foo函数执行完毕:
    1. foo的上下文从栈中弹出,控制权返回到bar,继续执行bar的代码,输出Back to bar
  1. bar函数执行完毕:
    1. bar的执行上文从栈弹出,控制权返回全局上下文,程序结束。
6.4.1 执行栈和内存管理

执行栈除了管理执行上下文以外,还参与内存的管理和分配。每个执行上下文压入栈中,实际上都会在内存中分配空间存储数据,比如变量对象中的变量、函数等。分配好数据后,代码才能在执行时使用到真实的数据。当上下文弹出栈时,相关的内存空间就会被释放(当然这不是绝对的,释放与否,取决于引擎的GC机制)。

引擎会通过一系列算法来判定哪些数据需要回收,因为有的数据弹出栈后还可能在其他地方使用到。因此执行上下文弹出,仅仅只是GC中的一小部分。

6.4.2 执行栈溢出

一般来说,执行栈都是有大小的(主要是操作系统分配给浏览器的内存有限)。因此如果不停地压栈,会导致栈溢出stack overflow控制台报出-RangeError: Maximum call stack size exceeded

举个简单例子,JS支持函数嵌套,自身调用自身,也就是递归算法,后面会详细解释。递归不停地压栈,很容易就会造成栈溢出,从而导致程序缓慢甚至崩溃。

还有就是,异步任务不会直接进入执行栈,而是通过其他浏览器线程进行执行,有了结果以后放入对应的消息队列,在事件循环的机制下,把对应的回到任务压入栈中。(在浏览器章节会详细概述)

6.5 作用域和作用域链
6.5.1 概述

作用域和作用域链是上下文中和变量对象一样重要的内容,因为在JS中,函数是可以嵌套调用的,因此,会涉及到大量参数传递、变量访问,有时候我们需要明确参数或变量的来源,也就是明确变量的作用域,以此来写出健壮性或扩展性更强的代码。

实际上呢,ECMA官方并没有明确定义作用域链作用域的术语。这是基于JS执行模型衍生的概念。本质上来说,作用域和作用域链是一个执行上文中如何查找变量标识符的一种机制。


通过前面的学习,我们知道,引擎在代码初始化时会创建全局上下文,在遇到函数调用时又会创建函数上下文、而函数内部可以嵌套调用,这样还会再创建函数上下文。这么多的上下文存在着紧密的关系,那么变量查找时就需要给定一个机制才能确保其正确访问,不造成混乱,而作用域链的机制就解决了这个问题。

上下文有三个核心的组成部分,变量对象我们已经熟悉了,而作用域链,其实就是由1个或者多个执行上下文的变量对象所构成的链条结构,或者理解成数组,一般称为scope chain。只不过这个数据不暴露给开发者,是通过引擎维护,引擎会按照既定规则在这个数据结构中去查找变量。

每个执行上下文都有着一个作用域链,作用域链包含了当前执行上下文的变量对象外层上下文的变量对象直到全局window对象

我们函数上下文中查找变量时,引擎就会按照这个链条逐次查找。优先查找自身。再查找父级上下文、直到链的顶端window。找到则返回,找不到则报错。全局下查找的话,则直接在window上查找。

在现代化的浏览器控制台中,我们可以通过打印函数可以看到一般都具有一个Scope属性,它表示的就是就函数上下文的作用域链。需要注意的是,这并不是ECMA原生的属性,而是引擎暴露给开发者,便于审查调试的,接下来看几个案例。

var a = 10;
fucntion test () {
  console.log(a)
}
test() // 10
var a = 10;
fucntion test () {
  var a = 20
  console.log(a)
}
test() // 20

上面两段代码,第一个函数输出10,第二个输出20,原因在于:

  • 第一段代码,函数自身上下文的变量对象中不存在a,于是它找到了父级(window),输出了10
  • 第二段代码,函数输出20,原因是它自身的执行上下文变量对象中有a,于是输出20

再来看一个案例,主要是看函数嵌套和浏览器给函数提供的Scopes

 var b = 10;

    function test() {
        var b = 20;

        function fn() {
            var a = 10;
            console.log(b)

            function c() {

                console.log(a)
            }
            c()
            console.dir(c)
        }
        fn()
        console.dir(fn)
    }
    test() // 20


6.5.2 关于let和const的说明

在全局上下文中,我们提到letconst以及var的区别。letconstES6后新增的声明变量的方式。两者都具备块级作用域,因此,他们不会被绑定到全局window对象上,同时他们也不属于执行上下文中的变量对象。但是他们依然要参与到作用域链的解析中,并且遵守其解析机制。

实际上执行上下文中的变量对象,只负责var关键字的声明的变量和函数声明。而letconst class声明的变量,是通过上下文中的词法环境在管理,词法环境类似变量对象,他的范围更广泛、机制更复杂。在作用域链的解析过程中,除了考虑变量对象还会考虑词法环境。

这就是为什么,letvar以及const在底层机制不一样,但是作用域解析时保持一致的原因 。

还有一点需要注意的是,letconst在声明前访问会报错,因此普遍认为,他们不存在声明提升。实际上,他们也是经过了提升的。只不过和var的提升机制不一样。原因很简单,代码访问虽然报错,但是这说明代码通过了预编译的阶段。达到了可执行的标准。

letconst的声明部分实际上被提升到了一个暂时性的死区当中 Temporal Dead Zone 简称TDZ ,这个区域在代码没有执行到赋值阶段时,是不允许的访问,这就是在letconst在声明前访问报错的本质原因。只有当他们进行了初始化赋值后才允许访问。这是JS引擎专门为其设计的机制,确保代码健壮性。

闭包
6.6.1 概述

闭包是一个非常难的特性,在几乎所有函数式编程语言中都存在。许多不明白闭包底层原理的开发者,99%都是因为不熟悉执行上下文作用域以及执行栈的概念。我们熟悉了执行上下文相关的内容后,理解闭包就非常简单了,JS是函数式的编程语言,函数具有局部作用域、函数可以嵌套调用。因此,闭包的核心思想是内层函数通过作用域链的机制向上访问到父级函数内的变量,但是这还无法形成闭包,内层函数要被返回到外部才能形成闭包。看如下示例

function outer () {
  let count = 10;
  function inner () {
    console.log(count)
  }
  return inner;
}
const innerFn = outer();
innerFn();

我们定义了outer函数,在outer内部定义了count变量,以及inner函数。在inner函数内部,我们访问了outer中的count。最终把inner返回了。在外部我们通过innerFn常量来接收outer的返回值。

关键在于,countouter的执行上下文的变量对象中的属性。而inner定义在outer内部,因此inner的执行上下文中的作用域链包含了outer的执行上下文。这就导致了outer在调用完毕后,它的执行上下文没有得到释放。从而一直在占用内存空间。这就是闭包。

闭包形成的两大条件:

  • 函数嵌套函数
  • 内层函数必须被返回保存到外部

闭包的本质:

    • 函数嵌套,并且在内部做了返回,从而造成外层函数的执行上下文弹出栈时无法销毁,占用内存。

闭包带来的问题:

  • 是内存泄漏,内存泄漏并不是安全问题。而是指垃圾回收机制无法回收相关的数据,从而导致可用的内存空间减少。泄漏就像一个沙漏一下,可用的内存就一点点被漏掉了。当一个程序中,存在大量的闭包或者说一个闭包内引用了大量数据,内存泄漏就会越严重,程序性能就会下降,甚至崩溃。

如何解除

function outer() {
  let count = 0;

  function inner() {
    count++;
    console.log(count);
  }
}

outer(); 
// 这个例子虽然也是函数套函数,但是没有形成闭包,
// 因为Inner没有被返回,而外部无法读取到ouetr内的数据,outer就是普通函数,调用后就销毁了

不要将闭包和栈溢出、和死循环混淆了。

  • 栈溢出是影响的是执行栈,例如无限递归一直压栈,导致引擎忙不过来,从而造成程序卡顿或崩溃。
  • 闭包影响的是内存空间,也会造成程序卡顿和崩溃。这是有本质区别的。
  • 死循环是因为无限计算,导致一直占用CPU资源导致系统或者浏览器崩溃。

后面会详述案例。

6.6.2 解除闭包

那么,我们应该如何解除闭包呢?这里简单说下三种方式。

  1. 手动解除引用
    1. 在不再需要闭包中的数据时,可以手动将引用解除 ,null数据类型就可以做到,因为表示空引用
  1. 避免不必要的闭包
    1. 主要是在开发层面规避
  1. 通过 WeakMapWeakSet 管理数据
    1. ES6新特性
6.7 垃圾回收机制GC
6.7.1 引言

要理解垃圾回收,就要明白什么是垃圾。在程序运行过程中,不再使用的内存数据就是垃圾,比如不再使用的对象、变量。这些数据如果不被清除,那么就会一直占用系统内存。如果不进行垃圾回收,随着内存占用过多,容易导致程序崩溃。而垃圾回收就避免了内存资源浪费,从而提升程序性能。

垃圾回收机制Garbage Collection简称GC机制。前面讲到,高级语言是低级语言的抽象封装,因此高级语言一般为了降低开发层面的复杂性,都要求自动进行垃圾回收、内存分配。ECMA规范明确要求内存分配和垃圾回收是自动完成。这意味着任何符合ECMAScript标准的JS引擎,都必须实现自动垃圾回收。

6.7.2 垃圾回收策略浅析

不同的引擎的GC策略或算法实现是不一致的,但是其基本思想一致。V8采用了分代回收的策略。

具体表现大致如下:

  • 新生代:存放短生命周期的对象 ,通过Scavenge算法快速回收, 垃圾回收频率更高,速度更快。
  • 老生态:采用标记清除标记整理等算法, 回收较少,但会使用更复杂的算法进行清理。

在早期,还有引用计数的算法,不过由于循环依赖的问题无法被解决,因此现代引擎一般不使用这种算法,即使使用也是作为辅助手段。

下面我们将简单讲解一下,引擎中垃圾回收的算法策略,便于我们进一步提升理解。