【JS基础系列】变量、作用域和闭包

168 阅读9分钟

变量的类型

ECMAScript变量有两种类型:基本类型和引用类型。

  • 基本类型:Number、String、Boolean、Undefined、Null、Symbol、Bigint
  • 引用类型:Object

1. 动态的属性

对于引用类型的值,我们可以给其添加属性和方法,也可以改变和删除其属性和方法。但是我们不能给基本类型的值添加属性。

// 给引用类型添加属性
const obj = new Object();
obj.name = "MMZ";
console.log(obj.name);  // MMZ

// 给基本类型添加属性无效
const val = "aaa";
val.name = "MMZ"; // 此处不会报错
console.log(val.name); //undefined

2. 复制变量值

1.基本类型的变量和引用类型的变量的保存方式是不同的,基本类型的值保存在栈中,而引用类型的值保存在堆中。

如果一个变量向另一个变量复制基本类型的值,会在内存中开辟出一块新的内存,来存储新变量的值。例如:

var num1 = 5;
var num2 = num1;

这两个变量是完全独立的,可以参与任何操作,而不会相互影响。

var num1 = 5;
var num2 = num1;
console.log(num2); // 5
num2 = 7;
console.log(num1); // 5
console.log(num2); // 7
// 改变num2并不会影响num1的值,这两个值完全独立,互不影响。

2.当从一个变量向另一个变量复制引用类型的值时,复制的其实是一个指针,这个指针指向存储在堆中的一个对象,复制结束后,两个变量实际上将引用到同一个对象,改变其中的一个变量,就会影响另一个变量。

var obj1 = new Object();
var obj2 = obj1;
obj1.name = "MMZ";
console.log(obj2.name); // MMZ
obj2.name = "MinMin";
console.log(obj1.name); // MinMin

3. 传递参数

所有函数中的参数都是按值传递的。即把函数外部的值复制给函数内部的参数。

在向参数传递基本类型的值时,被传递的值会被复制给一个局部变量(即命名参数,即arguments对象中的一个元素),在向参数传递引用类型的值时,会把这个值在内存中的地址复制给一个局部变量,因此这个局部变量的变化会反应在函数的内部。

函数的参数可以理解为函数的局部变量

function setName(obj) {
    // 此时,在函数内部,obj和person引用的是同一个对象
    obj.name = "MMZ";
    // 在函数内部,修改了参数obj的值,obj是一个局部对象,此时obj指向一个新的内存地址,而person的引用仍保持不变。
    obj = new Object();
    obj.name = "MinMin";
}
var person = new Object();
setName(person);
console.log(person.name); // MMZ

⚠️ 函数参数arguments和命名参数:

function add(n1, n2){
    console.log(n1); // 0
    console.log(n2); // 0
    arguments[0] = 1;
    arguments[1] = 10;
    console.log(n1); // 1
    console.log(n2); // 10
    return n1 + n2; // 11
}

add(0, 0);

此时读取n1和arguments[0]、n2和arguments[1]并不会访问相同的内存空间,他们的内存空间是独立的,但他们的值保持同步

检测类型

  1. 检测基本类型,利用typeof操作符
var a = 11;
var b = "11";
var c = true;
var d;
var e = null;
var f = new Object();
var g = function() {};

typeof a; // "number"
typeof b; // "string"
typeof c; // "boolean"
typeof d; // "undefined"
typeof e; // "object",注意,当一个变量是null时,typeof返回"object"
typeof f; // "object"
typeof g; // "function"
  1. 检测引用类型时,可以利用instanceof操作符,或者 Object.prototype.toString.call();

instanceof 主要利用原型链,看该变量是否是给定引用类型的实例,如果是会返回true。所有的引用类型的值,都是Object的实例,因此在检测一个引用类型的值是Object的实例时,始终会返回true。如果用instanceof检测基本类型,会始终返回 false,因为基本类型不是对象。

var a = {name: "MMZ"};
var b = [1, 2, 3];
var c = new Date();
var d = new RegExp();

a instanceof Object; // true
b instanceof Array; // true
b instanceof Object; // true
c instanceof Date; // true
c instanceof Object; // true
d instanceof RegExp; // true
d instanceof Object; // true

Object.prototype.toString.call(a) === "[object Object]"; //true
Object.prototype.toString.call(b) === "[object Array]"; // true
Object.prototype.toString.call(c) === "[object Date]"; //true
Object.prototype.toString.call(d) === "[object RegExp]"; //true

执行上下文

执行上下文是javascript执行一段代码时的运行环境。

创建执行上下文的三种情况

  1. 当 JavaScript 执行全局代码的时候,会编译全局代码并创建全局执行上下文,而且在整个页面的生存周期内,全局执行上下文只有一份
  2. 当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。
  3. 当使用 eval 函数的时候,eval 的代码也会被编译,并创建执行上下文。

调用栈

调用栈就是用来管理函数调用关系的一种数据结构。

JavaScript 引擎正是利用栈的这种结构来管理执行上下文的。在执行上下文创建好后,JavaScript 引擎会将执行上下文压入栈中,通常把这种用来管理执行上下文的栈称为执行上下文栈,又称调用栈。

调用栈是 JavaScript 引擎追踪函数执行的一个机制,当一次有多个函数被调用时,通过调用栈就能够追踪到哪个函数正在被执行以及各函数之间的调用关系。

调用栈是有大小的,当入栈的执行上下文超过一定数目,JavaScript 引擎就会报错,我们把这种错误叫做栈溢出。在你写递归代码的时候,就很容易出现栈溢出的情况

作用域

变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。

3种作用域

  1. 全局作用域
  2. 函数局部作用域
  3. 块级作用域

全局作用域

全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。

函数局部作用域

函数作用域就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。

块级作用域

块级作用域就是使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句,甚至单独的一个{}都可以被看作是一个块级作用域。

ES6 引入了 let 和 const 关键字,从而使 JavaScript 也能像其他语言一样拥有了块级作用域。从而解决变量提升带来变量覆盖、变量污染等设计缺陷。

  • let 关键字声明的变量是可以被改变的
  • const 声明的变量其值是不可以被改变的

函数内部通过 var 声明的变量,在编译阶段全都被存放到变量环境里面了。通过 let/const 声明的变量,在编译阶段会被存放到词法环境(Lexical Environment)中。

在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构。

块级作用域就是通过词法环境的栈结构来实现的,而变量提升是通过变量环境来实现,通过这两者的结合,JavaScript 引擎也就同时支持了变量提升和块级作用域了。

作用域链

在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为 outer。

当一段代码使用了一个变量时,JavaScript 引擎首先会在“当前的执行上下文”中查找该变量,如果在当前的变量环境中没有查找到,那么 JavaScript 引擎会继续在 outer 所指向的执行上下文中查找。这个查找的链条就称为作用域链。

在 JavaScript 执行过程中,其作用域链是由词法作用域决定的。词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。

闭包

在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。

// 例子1
var a = 1;

function b() {
    var a = 2;
    function c() {
        // 内部函数c中引用了a变量,c本身没有变量a,在outer指向的外部执行上下文中查找,即在函数b中查找,函数b中定义了变量a等于2,于是函数c中的变量保持对外部函数b中变量的引用,形成一个闭包
        console.log(a);
    }
    return c;
}

var m = b();
m(); // 2
// 例子2
var a = 1;
function b() {
    var a = 2;
    function c() {
        console.log(a);
    }
    c();
}

b(); // 2
function add (x) {
    return function (y) {
        return x + y;
    }
}

var a = add(4);
a(2); // 6
a(3); // 7

var b = add(5);
b(3); // 8

闭包的应用

用于封装变量,收敛权限

var Foo = function(){
      var name = 'fooname';
      var age = 12;
      this.getName = function(){
          return name;
      };
      this.getAge = function(){
          return age;
      };
  };
  var foo = new Foo();

  foo.name;        //  => undefined
  foo.age;         //  => undefined
  foo.getName();   //  => 'fooname'
  foo.getAge();    //  => 12

闭包的回收

  1. 如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭;但如果这个闭包以后不再使用的话,就会造成内存泄漏。
  2. 如果引用闭包的函数是个局部变量,等函数销毁后,在下次 JavaScript 引擎执行垃圾回收时,判断闭包这块内容如果已经不再被使用了,那么 JavaScript 引擎的垃圾回收器就会回收这块内存。

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