Javascript:核心基础原理

js 代码执行过程、执行上下文、作用域、变量提升、This的绑定

执行上下文

  1. 全局执行上下文:任何不在函数内部的都是全局执行上下文,它首先会创建一个全局的window对象,并且设置this的值等于这个全局对象,一个程序中只有一个全局执行上下文。
  2. 函数执行上下文:当一个函数被调用时,就会为该函数创建一个新的执行上下文,函数的上下文可以有任意多个。
  3. eval函数执行上下文:执行在eval函数中的代码会有属于他自己的执行上下文。

执行上下文栈(Execution context stack,ECS):JavaScript引擎使用执行上下文栈来管理执行上下文。当JavaScript执行代码时,首先遇到全局代码,会创建一个全局执行上下文并且压入执行栈中,每当遇到一个函数调用,就会为该函数创建一个新的执行上下文并压入栈顶,引擎会执行位于执行上下文栈顶的函数,当函数执行完成之后,执行上下文从栈中弹出,继续执行下一个上下文。当所有的代码都执行完毕之后,从栈中弹出全局执行上下文。

每个执行上下文,都有三个重要属性:

  • 变量对象 / 活动对象 (Variable object, VO ) / (activation object, AO)在进入执行上下文时会给变量对象添加形参、函数声明、变量声明等初始的属性值。
    • 全局上下文的变量对象初始化是全局对象
    • 函数上下文的变量对象(活动对象)初始化只包括 Arguments 对象
    • 在进入执行上下文时会给变量对象添加形参、函数声明、变量声明等初始的属性值
    • 在代码执行阶段,会再次修改变量对象的属性值 function foo(a) {
         var b = 2;
         function c() {}
         var d = function() {};
         b = 3;
       }
       foo(1);
       //进入执行上下文后,活动对象AO:
       AO = {
           arguments: {
               0: 1,
               length: 1
          },
           a: 1,
           b: undefined,
           c: reference to function c(){},
           d: undefined
       }
       //当代码执行完后,这时候的 AO 是:
       AO = {
           arguments: {
               0: 1,
               length: 1
          },
           a: 1,
           b: 3,
           c: reference to function c(){},
           d: reference to FunctionExpression “d”
       }
  • 作用域链(Scope chain)
  • this

作用域、作用域链

作用域:是指程序源代码中定义变量的区域,确定当前执行代码对变量的访问权限,JS采用词法作用域(lexical scoping),也就是静态作用域。 作用域链: 在当前作用域中查找所需变量,但是该作用域没有这个变量,那这个变量就是自由变量。如果在自己作用域找不到该变量就去父级作用域查找,依次向上级作用域查找,直到访问到window对象就被终止,这一层层的关系就是作用域链。作用域链的作用是保证对执行环境有权访问的所有变量和函数的有序访问,通过作用域链,可以访问到外层环境的变量和函数。

静态作用域 / 词法作用域:函数的作用域在函数定义时候就决定了。这是因为函数有一个内部属性 [[scope]],当函数创建的时候,就会保存所有父变量对象到其中,你可以理解 [[scope]] 就是所有父变量对象的层级链,但是注意:[[scope]] 并不代表完整的作用域链 动态作用域:函数的作用域是在函数调用的时候才决定的。

 var value = 1;
 function foo() {
     console.log(value);
 }
 function bar() {
     var value = 2;
     foo();
 }
 bar();//1
 //因为输出了1,证明是JS静态作用域,在定义时就决定了
 //如果输出了2,说明是动态作用域,在函数调用的时候才决定(bash就是动态作用域)
 function foo() {
     function bar() {
         ...
    }
 }
 //函数创建定义时,各自的[[scope]]为:
 foo.[[scope]] = [
   globalContext.VO
 ];
 ​
 bar.[[scope]] = [
     fooContext.AO,
     globalContext.VO
 ];
   
 //函数激活:当函数激活时,进入函数上下文,创建 VO/AO 后,就会将活动对象添加到作用链的前端。这时候执行上下文的作用域链,我们命名为 Scope:
 Scope = [AO].concat([[Scope]]);
 //至此,作用域链创建完毕。
  1. 全局作用域
    • 最外层函数和最外层函数外面定义的变量拥有全局作用域
    • 所有未定义直接赋值的变量自动声明为全局作用域
    • 所有window对象的属性拥有全局作用域
    • 全局作用域有很大的弊端,过多的全局作用域变量会污染全局命名空间,容易引起命名冲突
  2. 函数作用域
    • 函数作用域声明在函数内部的变零,一般只有固定的代码片段可以访问到
    • 作用域是分层的,内层作用域可以访问外层作用域,反之不行
  3. 块级作用域
    • 使用ES6中新增的letconst指令可以声明块级作用域,块级作用域可以在函数中创建也可以在一个代码块中的创建(由{ }包裹的代码片段)
    • let和const声明的变量不会有变量提升,也不可以重复声明
    • 在循环中比较适合绑定块级作用域,这样就可以把声明的计数器变量限制在循环内部。

变量提升

变量提升的表现是,无论在函数中何处位置声明的变量,好像都被提升到了函数的首部,可以在变量声明前访问到而不会报错。造成变量声明提升的本质原因是 js 引擎在代码执行前有一个解析的过程,创建了执行上下文,初始化了一些代码执行时需要用到的对象。当访问一个变量时,会到当前执行上下文中的作用域链中去查找,而作用域链的首端指向的是当前执行上下文的变量对象,这个变量对象是执行上下文的一个属性,它包含了函数的形参、所有的函数和变量声明,这个对象的是在代码解析的时候创建的。

变量提升的原因

  1. 提高性能 在JS代码执行之前,会进行语法检查和预编译,并且这一操作只进行一次。这么做就是为了提高性能,如果没有这一步,那么每次执行代码前都必须重新解析一遍该变量(函数),而这是没有必要的,因为变量(函数)的代码并不会改变,解析一遍就够了。在解析的过程中,还会为函数生成预编译代码。在预编译时,会统计声明了哪些变量、创建了哪些函数,并对函数的代码进行压缩,去除注释、不必要的空白等。这样做的好处就是每次执行函数时都可以直接为该函数分配栈空间(不需要再解析一遍去获取代码中声明了哪些变量,创建了哪些函数),并且因为代码压缩的原因,代码执行也更快了。
  2. 容错性更好变量提升可以在一定程度上提高JS的容错性,看下面的代码: a = 1;var a;console.log(a);如果没有变量提升,这两行代码就会报错,但是因为有了变量提升,这段代码就可以正常执行。虽然,在可以开发过程中,可以完全避免这样写,但是有时代码很复杂的时候。可能因为疏忽而先使用后定义了,这样也不会影响正常使用。由于变量提升的存在,而会正常运行。

js 代码执行过程:解析和执行

1. 解析阶段:

在JS解析阶段,会检查语法,并对函数进行预编译。解析的时候会先创建全局执行上下文环境,先把代码中即将执行的变量、函数声明都拿出来,变量先赋值为undefined,再函数先声明好可使用(变量提升)。在一个函数执行之前,也会创建一个函数执行上下文环境,跟全局执行上下文类似,不过函数执行上下文会多出this、arguments和函数的参数。函数声明优先级比变量声明高

  • 全局上下文:变量定义,函数声明
  • 函数上下文:变量定义,函数声明,this,arguments
this绑定
  • 全局执行上下文中this指向全局对象(window对象)
  • this指向取决于函数如何调用(按优先级排序):
    1. 构造器调用:this 指向这个新创建的对象
    2. call、apply、bind调用:绑定到指定对象
    3. 方法调用模式:被引用对象调用,那么 this 会被设置成那个对象
    4. 函数调用:否则 this 的值被设置为全局对象或者 undefined
添加形参
  • 由名称和对应值组成的一个变量对象的属性被创建
  • 没有实参,属性值设为 undefined
创建词法环境组件(函数声明)
  • 词法环境是一种有标识符 : 变量映射的数据结构,标识符是指变量/函数名,变量是对实际对象或原始数据的引用
  • 如果变量对象已经存在相同名称的属性,则完全替换这个属性。
  • 词法环境的内部有两个组件:
    1. 环境记录器:用来储存变量个函数声明的实际位置
    2. 外部环境的引用:可以访问父级作用域
创建变量环境组件(变量声明)
  • 变量环境也是一个词法环境,其环境记录器持有变量声明语句在执行上下文中创建的绑定关系。
  • 由名称和对应值(undefined)组成一个变量对象的属性被创建;
  • 如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性

2. 执行阶段:

会完成对变量的分配,最后执行完代码

总结案例:

结合着之前讲的变量对象(VO/AO)、执行上下文栈(ECStack),我们来总结一下函数执行上下文中作用域链和变量对象的创建过程:

 //示例1
 var scope = "global scope";
 function checkscope(){
     var scope2 = 'local scope';
     return scope2;
 }
 checkscope();
 ​
 //1.函数定义:checkscope 函数被创建,保存作用域链到 内部属性[[scope]]
 checkscope.[[scope]] = [ globalContext.VO ];
 ​
 //checkscope()开始运行:
 //2.函数解析:创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈
 ECStack = [
     checkscopeContext,
     globalContext
 ];
 ​
 //checkscope 函数并不立刻执行,开始做准备工作:
 //3.第一步:复制函数[[scope]]属性创建作用域链
 checkscopeContext = {
     Scope: checkscope.[[scope]],
 }
 //4.第二步:用 arguments 创建活动对象AO,随后初始化活动对象,加入形参、函数声明、变量声明
 checkscopeContext = {
     AO: {
         arguments: {
             length: 0
        },
         scope2: undefined
    },
     Scope: checkscope.[[scope]],
 }
 //5.第三步:将活动对象AO压入 checkscope 作用域链顶端
 checkscopeContext = {
     AO: {
         arguments: {
             length: 0
        },
         scope2: undefined
    },
     Scope: [AO, checkscope.[[Scope]]]
 }
 ​
 //准备工作做完
 //6.开始执行函数,随着函数的执行,修改 AO 的属性值
 checkscopeContext = {
     AO: {
         arguments: {
             length: 0
        },
         scope2: 'local scope'
    },
     Scope: [AO, checkscope.[[Scope]]]
 }
 ​
 //查找到 scope2 的值,返回后函数执行完毕,函数上下文从执行上下文栈中弹出
 ECStack = [
     globalContext
 ];
 //示例2
 var scope = "global scope";
 function checkscope(){
     var scope = "local scope";
     function f(){
         return scope;
    }
     return f();
 }
 checkscope();//local scope
 ​
 //执行上下文栈:
 //ECStack.push(<checkscope> functionContext);
 //ECStack.push(<f> functionContext);
 //ECStack.pop();
 //ECStack.pop();
 ​
 //详细运行过程:
 //1.执行全局代码,创建全局执行上下文,全局上下文被压入执行上下文栈
 ECStack = [ globalContext ];
 //2.全局上下文初始化,同时,checkscope 函数被创建,保存作用域链到函数的内部属性[[scope]]
 globalContext = {
     VO: [global],
     Scope: [globalContext.VO],
     this: globalContext.VO
 }
 checkscope.[[scope]] = [
   globalContext.VO
 ];
 ​
 //执行checkscope()
 //3.执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈
 ECStack = [ checkscopeContext , globalContext ];
 //4.checkscope 函数执行上下文初始化:
 // 4.1复制函数 [[scope]] 属性创建作用域链,
 // 4.2用 arguments 创建活动对象,
 // 4.3初始化活动对象,即加入形参、函数声明、变量声明,
 // 4.4将活动对象压入 checkscope 作用域链顶端。
 // 同时 f 函数被创建,保存作用域链到 f 函数的内部属性[[scope]]
 checkscopeContext = {
   AO: {
     arguments: {
       length: 0
    },
     scope: undefined,
     f: reference to function f(){}
  },
   Scope: [AO, globalContext.VO],
   this: undefined
 }
 //运行代码:checkscopeContext.AO.scope 赋值为 "local scope"
 ​
 //执行 f()
 //5.执行 f 函数,创建 f 函数执行上下文,f 函数执行上下文被压入执行上下文栈
 ECStack = [ fContext , checkscopeContext , globalContext ];
 //6.f 函数执行上下文初始化, 以下跟第 4 步相同:
 // 6.1复制函数 [[scope]] 属性创建作用域链
 // 6.2用 arguments 创建活动对象
 // 6.3初始化活动对象,即加入形参、函数声明、变量声明
 // 6.4将活动对象压入 f 作用域链顶端
 fContext = {
   AO: {
     arguments: {
       length: 0
    }
  },
   Scope: [AO, checkscopeContext.AO, globalContext.VO],
   this: undefined
 }
 //7.f 函数执行,沿着作用域链查找 scope 值,返回 scope 值(checkscopeContext.AO.scope="local scope")
 //8.f 函数执行完毕,f 函数上下文从执行上下文栈中弹出
 ECStack = [ checkscopeContext , globalContext ];
 //9.checkscope 函数执行完毕,checkscope 执行上下文从执行上下文栈中弹出
 ECStack = [ globalContext ];
 ​
 ​
 //示例3
 var scope = "global scope";
 function checkscope(){
     var scope = "local scope";
     function f(){
         return scope;
    }
     return f;
 }
 checkscope()();//local scope
 //执行上下文栈:
 //ECStack.push(<checkscope> functionContext);
 //ECStack.pop();
 //ECStack.push(<f> functionContext);
 //ECStack.pop();

call() 和 apply() 和 bind() 的区别

这三个方法都可以显示的指定调用函数的 this 指向。

  • apply 接受两个参数,第一个参数指定了函数体内 this 对象的指向,第二个参数为一个带下标的集合,这个集合可以为数组,也可以为类数组,apply 方法把这个集合中的元素作为参数传递给被调用的函数。
  • call 传入的参数数量不固定,跟 apply 相同的是,第一个参数也是代表函数体内的 this 指向,从第二个参数开始往后,每个参数被依次传入函数。
  • bind 和 call 差不多,从第二个参数开始往后,每个参数被依次传入函数,区别是bind不会立即执行,而是返回一个绑定了this的函数供以后执行

call 函数的实现

 Function.prototype.myCall = function(context) {
   // 判断调用对象
   if (typeof this !== "function") console.error("type error");
   // 获取参数
   let args = [...arguments].slice(1),
     result = null;
   // 判断 context 是否传入,如果未传入则设置为 window
   context = (context==null || context===undefined) ? window || Object(context);
   // 将调用函数设为对象的方法
   context.fn = this;
   // 调用函数
   result = context.fn(...args);
   // 将属性删除
   delete context.fn;
   return result;
 };

apply 函数的实现步骤:

 Function.prototype.myApply = function(context) {
   // 判断调用对象是否为函数
   if (typeof this !== "function") {
     throw new TypeError("Error");
  }
   let result = null;
   // 判断 context 是否存在,如果未传入则为 window
   context = (context==null || context===undefined) ? window || Object(context);
   // 将函数设为对象的方法
   context.fn = this;
   // 调用方法
   if (arguments[1]) {
     result = context.fn(...arguments[1]);
  } else {
     result = context.fn();
  }
   // 将属性删除
   delete context.fn;
   return result;
 };

bind 函数的实现步骤

 Function.prototype.myBind = function(context) {
   // 判断调用对象是否为函数
   if (typeof this !== "function") {
     throw new TypeError("Error");
  }
   // 获取参数
   var args = [...arguments].slice(1),
     fn = this;
   return function Fn() {
     // 根据调用方式,传入不同绑定值
     return fn.apply(
       this instanceof Fn ? this : context,
       args.concat(...arguments)
    );
  };
 };
 ​

尾调用

尾调用指的是函数的最后一步调用另一个函数。代码执行是基于执行栈的,所以当在一个函数里调用另一个函数时,会保留当前的执行上下文,然后再新建另外一个执行上下文加入栈中。使用尾调用的话,因为已经是函数的最后一步,所以这时可以不必再保留当前的执行上下文,从而节省了内存,这就是尾调用优化。但是 ES6 的尾调用优化只在严格模式下开启,正常模式是无效的。

强类型语言和弱类型语言

  • 强类型语言:强类型语言也称为强类型定义语言,是一种总是强制类型定义的语言,要求变量的使用要严格符合定义,所有变量都必须先定义后使用。Java和C++等语言都是强制类型定义的,也就是说,一旦一个变量被指定了某个数据类型,如果不经过强制转换,那么它就永远是这个数据类型了。例如你有一个整数,如果不显式地进行转换,你不能将其视为一个字符串。
  • 弱类型语言:弱类型语言也称为弱类型定义语言,与强类型定义相反。JavaScript语言就属于弱类型语言。简单理解就是一种变量类型可以被忽略的语言。比如JavaScript是弱类型定义的,在JavaScript中就可以将字符串’12’和整数3进行连接得到字符串’123’,在相加的时候会进行强制类型转换

两者对比:强类型语言在速度上可能略逊色于弱类型语言,但是强类型语言带来的严谨性可以有效地帮助避免许多错误。

解释性语言和编译型语言

解释型语言:使用专门的解释器对源程序逐行解释成特定平台的机器码并立即执行。是代码在执行时才被解释器一行行动态翻译和执行,而不是在执行之前就完成翻译。解释型语言不需要事先编译,其直接将源代码解释成机器码并立即执行,所以只要某一平台提供了相应的解释器即可运行该程序。其特点总结如下

  • 解释型语言每次运行都需要将源代码解释称机器码并执行,效率较低
  • 只要平台提供相应的解释器,就可以运行源代码,所以可以方便源程序移植
  • JavaScript、Python等属于解释型语言。

编译型语言:使用专门的编译器,针对特定的平台,将高级语言源代码一次性的编译成可被该平台硬件执行的机器码,并包装成该平台所能识别的可执行性程序的格式。在编译型语言写的程序执行之前,需要一个专门的编译过程,把源代码编译成机器语言的文件,如exe格式的文件,以后要再运行时,直接使用编译结果即可,如直接运行exe文件。因为只需编译一次,以后运行时不需要编译,所以编译型语言执行效率高。其特点总结如下:

  • 一次性的编译成平台相关的机器语言文件,运行时脱离开发环境,运行效率高
  • 与特定平台相关,一般无法移植到其他平台;
  • C、C++等属于编译型语言。

两者主要区别在于: 前者源程序编译后即可在该平台运行,后者是在运行期间才编译。所以前者运行速度快,后者跨平台性好。

闭包

闭包就是有权读取其他函数内部变量的函数。例如在javascript中,只有函数内部的子函数才能读取局部变量,所以闭包可以理解成“定义在一个函数内部的函数“。在本质上,闭包是将函数内部和函数外部连接起来的桥梁。

  • 闭包的第一个用途是使我们在函数外部能够访问到函数内部的变量。通过使用闭包,可以通过在外部调用闭包函数,从而在外部访问到函数内部的变量,可以使用这种方法来创建私有变量
  • 闭包的另一个用途是使已经运行结束的函数上下文中的变量对象继续留在内存中,因为闭包函数保留了这个变量对象的引用,所以这个变量对象不会被回收

柯里化

在数学和计算机科学中,柯里化是一种将使用多个参数的一个函数转换成一系列使用一个参数的函数的技术。

关键点

1.利用闭包来缓存原函数传递参数 2.如果传递的参数足够原函数使用(传递参数达到形参数量 fun.length )就执行,否则继续合并且缓存参数,返回柯里化函数。

function curry(fun,preArgs=[]){
return function(...args){
let curArgs = [...preArgs,...args]
if(curArgs.length>=fun.length){
return fun.apply(this,curArgs)
}else{
return curry.call(this,fun,curArgs)
}
}
}

function add(a,b,c,d,e) {
console.log('a,b,c,d,e:',a,b,c,d,e)
console.log('this.name:',this.name)
return a+b+c+d+e
}

var obj={
addFun:curry(add),
name:'Obj'
}
var name = 'Win'

var test1 = obj.addFun(1,2,3)()(4)(5)
//a,b,c,d,e: 1 2 3 4 5
//this.name: Win

var test2 = obj.addFun(1,2,3,4,5)
//a,b,c,d,e: 1 2 3 4 5
//this.name: Obj

var temp = obj.addFun(1,2,3)()(4)
var test3 = temp(5)
//a,b,c,d,e: 1 2 3 4 5
//this.name: Win

console.log(test1,test2,test3)
//15 15 15

垃圾回收与内存泄漏

垃圾回收:JavaScript代码运行时,需要分配内存空间来储存变量和值。当变量不在参与运行时,就需要系统收回被占用的内存空间,这就是垃圾回收。

内存生命周期:内存分配 -> 内存使用 -> 内存释放

内存泄漏:如果内存不需要时,没有经过生命周期的释放期,那么就存在内存泄漏

回收机制

  • Javascript 具有自动垃圾回收机制,会定期对那些不再使用的变量、对象所占用的内存进行释放,原理就是找到不再使用的变量,然后释放掉其占用的内存。
  • JavaScript中存在两种变量:局部变量全局变量全局变量的生命周期会持续要页面卸载;而局部变量声明在函数中,它的生命周期从函数执行开始,直到函数执行结束,在这个过程中,局部变量会在堆或栈中存储它们的值,当函数执行结束后,这些局部变量不再被使用,它们所占有的空间就会被释放。
  • 不过,当局部变量被外部函数使用时,其中一种情况就是闭包,在函数执行结束后,函数外部的变量依然指向函数内部的局部变量,此时局部变量依然在被使用,所以不会回收

V8引擎的GC垃圾回收机制

V8 实现了准确式 GC,GC 算法采用了分代式垃圾回收机制。因此,V8 将内存(堆)主要分为新生代和老生代两部分,还有其他部分。

  1. 新生代(new_space):大多数的对象开始都会被分配在这里,这个区域相对较小但是垃圾回收特别频繁,新生代中的对象一般存活时间较短,使用 Scavenge GC 算法。 在新生代空间中,内存空间分为两部分,分别为 From 空间To 空间。在这两个空间中,必定有一个空间是使用的,另一个空间是空闲的。新分配的对象会被放入 From 空间中,当 From 空间被占满时,新生代 GC 就会启动了。算法会检查 From 空间中存活的对象并复制到 To 空间中,如果有失活的对象就会销毁。当复制完成后将 From 空间和 To 空间互换,这样 GC 就结束了。
  2. 老生代(old_space):新生代中的对象在存活一段时间后就会被转移到老生代内存区,相对于新生代该内存区域的垃圾回收频率较低。老生代又分为老生代指针区老生代数据区,前者包含大多数可能存在指向其他对象的指针的对象,后者只保存原始数据对象,这些对象没有指向其他对象的指针。老生代中的对象一般存活时间较长数量也多。为了避免循环引用导致的内存泄漏问题,截至2012年所有的现代浏览器均放弃了引用计数算法,转为使用两个算法,分别是标记清除算法和标记压缩算法。对象晋升:当一个对象在经过多次复制之后依旧存活,那么它会被认为是一个生命周期较长的对象,在下一次进行垃圾回收时,该对象会被直接转移到老生代中,晋升条件有:
    1. 新生代中的对象是否已经经历过一次 Scavenge 算法,如果经历过的话,会将对象从新生代空间移到老生代空间中。
    2. To 空间的对象占比大小超过 25 %。在这种情况下,为了不影响到内存分配,会将对象从新生代空间移到老生代空间中。
    标记清除(Mark-Sweep):在老生代中,以下情况会先启动标记清除算法
    • 某一个空间没有分块的时候
    • 空间中被对象超过一定限制
    • 空间不能保证新生代中的对象移动到老生代中
    标记清除算法分为两个阶段:标记 => 清除 ,具体步骤如下
    1. 垃圾回收器会在内部构建一个根列表,用于从根节点出发去寻找那些可以被访问到的变量。比如在JavaScript中,window全局对象、本地函数的局部变量和参数、当前嵌套调用链上的其他函数的变量和参数 可以看成一个根节点。
    2. 垃圾回收器从所有根节点出发,遍历其可以访问到的子节点,并将其标记为活动的,根节点不能到达的地方即为非活动的,将会被视为垃圾。
    3. 最后,垃圾回收器将会释放所有非活动的内存块,并将其归还给操作系统。
    在标记大型对内存时,可能需要几百毫秒才能完成一次标记。JS是单线程,在垃圾回收过程中会阻塞主线程,即全停顿(stop-the-world),这就会导致性能上的问题。为了解决这个问题,2011 年,V8 从 stop-the-world 标记切换到增量标志(Incremental Marking)。在增量标记期间,GC 将标记工作分解为更小的模块,可以让 JS 应用逻辑在模块间隙执行一会,从而不至于让应用出现停顿情况。同时还引入延迟清理(lazy sweeping)。在 2018 年,GC 技术引入了并行标记并行清理。该技术可以让 GC 扫描和标记对象时,同时允许 JS 运行压缩算法(Mark-Compact):清除对象后会造成堆内存出现碎片的情况,当碎片超过一定限制后会启动压缩算法。在压缩过程中,将活的对象向一端移动,直到所有对象都移动完成然后清理掉不需要的内存。
  3. 大对象区(large_object_space):存放体积超越其他区域大小的对象,每个对象都会有自己的内存,垃圾回收不会移动大对象区。
  4. 代码区(code_space):代码对象,会被分配在这里,唯一拥有执行权限的内存区域。
  5. map区(map_space):存放Cell和Map,每个区域都是存放相同大小的元素,结构简单
img

垃圾回收的方式

  • 标记清除(主流)
    • 标记清除是浏览器常见的垃圾回收方式,当变量进入执行环境时,就标记这个变量“进入环境”,被标记为“进入环境”的变量是不能被回收的,因为他们正在被使用。当变量离开环境时,就会被标记为“离开环境”,被标记为“离开环境”的变量会被内存释放
    • 垃圾收集器在运行的时候会给存储在内存中的所有变量都加上标记。然后,它会去掉环境中的变量以及被环境中的变量引用的标记。而在此之后再被加上标记的变量将被视为准备删除的变量,原因是环境中的变量已经无法访问到这些变量了。最后。垃圾收集器完成内存清除工作,销毁那些带标记的值,并回收他们所占用的内存空间。
  • 引用计数(已放弃)
    • 这个用的相对较少。引用计数就是跟踪记录每个值被引用的次数。当声明了一个变量并将一个引用类型赋值给该变量时,则这个值的引用次数就是+1。相反,如果包含对这个值引用的变量又取得了另外一个值,则这个值的引用次数就-1。当这个引用次数变为0时,说明这个变量已经没有价值,因此,在在机回收期下次再运行时,这个变量所占有的内存空间就会被释放出来。
    • 这种方法会引起循环引用的问题:例如:obj1obj2通过属性进行相互引用,两个对象的引用次数都是2。当使用循环计数时,由于函数执行完后,两个对象都离开作用域,函数执行结束,obj1obj2还将会继续存在,因此它们的引用次数永远不会是0,就会引起循环引用。这种情况下,就要手动释放变量占用的内存。
    • ES6 把引用有区分为强引用弱引用,这个目前只有再 Set 和 Map 中才有。强引用才会有引用计数叠加,只有引用计数为 0 的对象的内存才会被回收,所以一般需要手动回收内存(手动回收的前提在于标记清除法还没执行,还处于当前执行环境)。而弱引用没有触发引用计数叠加,只要引用计数为 0,弱引用就会自动消失,无需手动回收内存。

减少垃圾回收的方式

  • 对数组进行优化: 在清空一个数组时,最简单的方法就是给其赋值为[ ],但是与此同时会创建一个新的空对象,可以将数组的长度设置为0,以此来达到清空数组的目的。
  • 对object进行优化: 对象尽量复用,对于不再使用的对象,就将其设置为null,尽快被回收。
  • 对Map、Set进行优化:需要记得set.delete()或者map.delete()进行释放,或者用WeakSet,WeakMap弱引用。
  • 对函数进行优化: 在循环中的函数表达式,如果可以复用,尽量放在函数的外面。

哪些情况会导致内存泄漏

  • 意外的全局变量: 由于使用未声明的变量,而意外的创建了一个全局变量,而使这个变量一直留在内存中无法被回收。
  • 被遗忘的计时器或回调函数: 设置了 setInterval 定时器,而忘记取消它,如果循环函数有对外部变量的引用的话,那么这个变量会被一直留在内存中,而无法被回收。
  • 被遗忘的Map和Set:需要记得set.delete()或者map.delete()进行释放,或者用WeakSet,WeakMap弱引用。
  • 被遗忘的订阅发布事件监听器:组件销毁时忘记取消订阅,导致一直监听
  • 脱离 DOM 的引用: 获取一个 DOM 元素的引用,而后面这个元素被删除,由于一直保留了对这个元素的引用,所以它也无法被回收。
  • 闭包: 不合理的使用闭包,从而导致某些变量一直被留在内存当中。

如何发现内存泄漏

内存泄漏时,内存一般都是会周期性的增长,我们可以借助谷歌浏览器的开发者工具进行判别。 Chrome => F12开发者工具 => 性能 => 内存 => 录制 => 运行一段时间 => 停止录制 => 分析周期内存走势

查找内存泄漏出现的位置

Chrome => F12开发者工具 => 内存 => 录制 => 运行一段时间 => 停止录制 => 分析内存较大的记录查看是哪个对象,对应哪一行代码

原型链

发表评论

您的电子邮箱地址不会被公开。