炒冷饭系列4:JavaScript中的作用域和闭包

1,049 阅读12分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第6天,点击查看活动详情

前言

上一篇炒冷饭系列3:面试你必须准备构造函数、原型、原型链和继承的相关知识!介绍了Javascript中的第二座大山,而第一座大山已在炒冷饭系列2中介绍,即炒冷饭系列2:来看看面试题中的Javascript事件循环机制!。至此,Javascript的三座大山已介绍了两座,这次就来将其完结,接下来介绍第三座大山:作用域和闭包

背景

这次没有背景,有背景也是由于和前面的是个系列,循序渐进的介绍第三座大山。其实这次的主题在面试中也是经常被问到的,那就是闭包,要想弄清楚闭包到底怎么回事,那就得从原理着手,原理即作用域。故此次主题就是介绍作用域和闭包,啃下这块硬骨头,那Javascript的基础算是掌握得相当牢靠了,入良企那可就是指日可待了。冲!

作用域和闭包

相信绝大多数人都听过闭包这个概念,但闭包具体是什么估计很少有人能够说的很详细。说实话闭包在我们平时开发中应该是很常见的,并且在前端面试中闭包也是常见的重要考点,在了解闭包之前先来看看作用域,因为这是闭包的关键原理。

一、作用域

作用域其实就是一套规则,用于确定在何处以及如何查找变量。简单地说就是你提出查询需求,但是能不能得到想要的由作用域决定。

如:var a = 1;编译器是如何处理的?

  1. 首先,遇到var a,编译器询问作用域是否有该名称的变量存在于用一个作用域中,如果有,编译器忽略,继续下一步;否则则在当前作用域中声明一个新变量命名为a
  2. 其次,编译器为引擎生成运行时所需的代码,用来处理a=1的赋值操作。运行时先询问作用域当前是否有一个叫a的变量,如果有则使用;否则继续查找,找到了赋值1a,没有则报错。

那么,引擎是如何查找变量的?

引擎会为变量a进行LHS查询RHS查询LHS查询也叫左查询,指的是当变量位于赋值操作的左侧时将进行做查询,引擎将要查找到存储变量的容器本身,以便对变量进行赋值操作。而RHS查询则叫右查询,指当变量位于赋值操作符右侧时进行的查询操作。总之就是左查询发生在赋值时,右查询发生在引用变量时

1.1 作用域嵌套

当一个块或函数嵌套在另一个块或函数中时,就发生了作用域嵌套。所以,在当前作用域中无法找到某个变量时,就会在外层嵌套的作用域中继续查找,直到找到该变量为止。

function add(a) {
    console.log(a + b);
}
var b = 2;
add(1) // 3

就像这里的代码一样,给add()方法传递了一个参数a,为a复制为1,但是此时add()方法内没有b的声明,就往上查找,在全局作用域中找到b,给b赋值为2,最后打印结果为3

1.2 词法作用域

作用域有两种主要的工作模型:词法作用域和动态作用域

词法作用域简单来说就是定义在词法阶段的作用域。大白话就是在你写代码时将变量和作用域写在哪里来决定的,也就是词法作用域,是静态的作用域,在你写代码时就决定了。

1.3 函数作用域

函数作用域指的是属于这个函数的全部变量都可以在这个函数范围内使用及复用。如下:

var a = 1;
function foo() {
    var a = 2;
    console.log(a); // 2
}
foo();
console.log(a); // 1

如果要使用函数作用域,那就必须定义一个函数,这就会让全局作用域多了一个函数,污染了全局作用域,且必须执行一次该函数才能运行其中的代码块。那有没有一种方法,既不污染全局作用域,函数也能自己执行呢?

那就是下面要介绍的→立即执行函数

1.3.1 立即执行函数

(function foo() {
    var a = 1;
    console.log(a); // 1
})();

这就是一个立即执行函数,它也可以是下面这种,如下:

(function foo() {
    var a = 1;
    console.log(a); // 1
}());

注意这里有两个'()',第一个()使得这个函数声明成为一个函数表达式,此外第二个()实现了函数调用。

1.3.2 块作用域

如果没有写过块作用域的代码,但是一定会知道下面这段代码:

for(var i = 0; i < 5; i++) {
    console.log(i);
}

可以看到在for循环的头部定义了变量i,是因为想在for循环中使用i,但是这样会忽略一点:i会被绑定在全局作用域中。

ES6引入了let关键字,提供了另一种声明变量的方式。

for(let i = 0; i < 5; i++) {
    console.log(i); // 0 1 2 3 4
}
console.log(i); // ReferenceError: a is not defined

ES6还引入了const关键字,它也是声明变量的方式。

const a = 1;
const a; // Identifier 'a' has already been declared

const b; // Missing initializer in const declaration

let、const和var的区别:

  1. var 声明的变量存在变量提升,即变量可以在声明之前调用,值为undefinedletconst不存在变量提升,即它们所声明的变量一定要在声明后使用,否则报错;
  2. var不存在暂时性死区;letconst存在暂时性死区,只有等到声明变量的那一行代码出现,才可以获取和使用该变量;
  3. var不存在块级作用域;letconst存在块级作用域;
  4. var允许重复声明变量;letconst在同一作用域不允许重复声明变量;
  5. varlet可以修改声明的变量;const声明一个只读的常量。一旦声明,常量的值就不能改变;

注:能用const的情况尽量使用const,其他情况下大多数使用let,避免使用var

1.3.2 变量提升

变量提升就是在一个作用域中,包括变量和函数在内的所有声明都会在任何代码被执行前首先被“移动”到作用域的最顶端。

例子1:
a = 1;
var a;
console.log(a); // 1

// 解析:
var a;
a = 1;
console.log(a); // 1

例子2:
console.log(a); // undefined
var a = 2;

// 解析:
var a;
console.log(a); // undefined
a = 2;

可以发现,当运行var a = 2;时,会分成两个阶段:编译阶段执行阶段

结论:先定义后赋值

  • 函数和变量都会提升,但函数会首先被提升,然后是变量。
foo(); // 2
var foo = 1;

function foo() {
  console.log(2);
}

foo = function() {
  console.log(3);
}

// 引擎解析:
function foo() {...}
foo()
foo = function() {...}
  • 多个同名函数,后面的会覆盖前面的函数:
foo(); // 3
var foo = 1;

function foo() {
  console.log(2);
}

function foo() {
  console.log(3);
}
  • 提升不受条件判断控制
foo(); // 2

if (true) {
  function foo() {
    console.log(1);
  }
} else {
  function foo() {
    console.log(2);
  }
}

注:尽量避免普通的var声明和函数声明混合在一起使用。

好了,到此作用域相关的知识就聊完了,其实作用域的知识很基础,也是日常开发中涉及最多的,只是平时的开发只是知道怎么用,而没有去追究其深层的原理,但是你看了这篇文章,那你应该可以掌握Javascript中的作用域知识,这都是进阶需要必备的!接下来将介绍闭包相关的知识,这也是必须掌握的难点!

参考资料:彻底搞懂JavaScript中的作用域和闭包——作者:kyrie的前端之路

二、闭包

2.1 什么是闭包?

闭包:函数中的函数,里面的函数可以访问外面函数的变量,外面的变量是这个内部函数的一部分。其本质原理是:上级作用域无法直接访问下级作用域中的变量

function outer(){
    var num = 0; // 内部变量
    return function add() { // 通过返回add函数,即可在outer外访问
        num++; // 内部函数引用,作为add函数的一部分了
        console.log(num);
    }
}

var func = outer();
func(); // 1
func(); // 2

上述代码就是闭包。正常情况下,当outer()执行后,outer()内部的作用域都会被销毁(垃圾回收机制),而闭包的“神奇”之处就是可以阻止这件事情的发生。事实上outer()内部的作用域依然存在,不然add()里面无法访问到outer()作用域内的变量numouter()执行后,add()依然持有该作用域的引用,而这个引用就叫作闭包

2.2 闭包的作用

最基本的作用:就是可以通过闭包返回函数或者方法,用来修改函数内部的数据

function bar() {
    var name = '张三';
    var age = 18;
    return {
        getName: function() {
            return name;
        },`
        setName: func`tion(value) {
            name = value;
            return name;
        }
    }
}

var obj = bar();
console.log(obj.getName()) // 张三
console.log(obj.setName('李四')); // 李四
console.log(obj.getName()); // 李四

看上述代码,本来外部是无法访问到name的,但是经过闭包的加持,外部就能通过getName()方法获得name的值并可以使用setName()改变name的值。这就是闭包最基本的作用,可以用来修改函数内部的数据。

下面就用面试中,出场率最高的题来检验上述的学习成果,如下:

for(var i = 0; i < 5; i++) {
  setTimeout(function timer() {
    console.log(i);
  }, i * 1000)
}

问:这段代码将输出什么?这是老演员了,结果为每隔一秒打印一个4

那怎么修改让它依次输出04的结果呢?

首先使用立即执行函数, 如下:

for(var i = 0; i < 5; i++) {
  (function(i) {
    setTimeout(function timer() {
        console.log(i);
    }, i * 1000)
  })(i);
}

也可以使用let关键字,如下:

for(let i = 0; i < 5; i++) {
  setTimeout(function timer() {
    console.log(i);
  }, i * 1000);
}

至此,你应该对闭包有了深层次的理解,如果不理解,回过头多看几遍,试着分析一下每一行代码,自己也动手敲一敲代码,毕竟好记性不如烂笔头,争取把它弄懂学精,不在以后的工作面试中再次跌倒就行!

2.3 闭包的缺陷

  • 由于闭包的存在可能会造成变量常驻内存,使用不当会造成内存泄漏
  • 内存泄漏可能会导致应用程序卡顿或崩溃

其实严格的来说闭包不是内存泄漏,内存泄漏是非预期的情况(希望它被回收但是没有被回收),而闭包是符合预期的,既然使用了闭包,像上面返回了一个函数让大家可以去修改name,然后就不能指望这个数据可以被销毁的,所以它存在内存中是合理的情况,如果被销毁了就不能用了。平时常说的“闭包是内存泄漏”,其实是一种不严谨的说法,其实表达的是闭包的数据是不能被垃圾回收的。

至此,作用域和闭包的相关知识就介绍了,这是人们口中常说的Javascript的三座大山中的最后一座了,也恭喜你跟着这系列翻过了这三座大山,相信一定会有收获的。学而不易,还是要多看几遍才能深刻理会,而不是将其收藏起来就代表着会了,所以闲时真的要多翻翻书,这样走起路来才会昂首挺胸!

最后,xdm看文至此,点个赞👍再走哦,3Q^_^

往期精彩文章

后语

伙伴们,如果觉得本文对你有些许帮助,点个👍或者➕个关注在走呗^_^ 。另外如果本文章有问题或有不理解的部分,欢迎大家在评论区评论指出,我们一起讨论共勉。