在学习作用域和作用域链知识的时候,我一度都是处于犯迷糊的边缘,直到前两天,有人在群里聊了有关作用域的面试题,我当时闭上眼睛心想要是问到我该怎么回答。这两天查了资料,做了几道面试题,觉得要输出一点我自己的理解,本文将通过几个简单的代码片段和面试题对JavaScript作用域和闭包的相关知识一探究竟。文中的表述如有不对,欢迎指正~ 如觉得还行请点亮左侧的:+1::see_no_evil:
var name = 'Jake Zhang'
当我们看到 var name = 'Jake Zhang' 的时候,我们认为是一条声明,但是对于js引擎来说,这是一个编译过程,分为下面两部分:
1、遇到 var name ,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作用域的集合中声明一个新的变量,并命名为 name (严格模式下报错)。
2、接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理 name = 'Jake Zhang' 这个赋值操作。引擎运行时会首先询问作用域,在当前的作用域集合中是否存在一个叫作 name 的变量。如果是,引擎就会使用这个变量;反之,引擎会继续查找该变量。
证明以上的说法:
console.log(name); // 输出undefinedvar name = 'Jake Zhang';
在 var name = 'Jake Zhang' 的上一行输出name变量,并没有报错,输出undefined,说明输出的时候该变量已经存在了,只是没有赋值而已。由以上两步操作一个名为 name 的变量就此诞生。
上面提到本文的核心词—— 作用域 ,那什么是作用域呢,接下来咱们一探究竟。
先看这段代码:
function fun() { var name = 'Jake Zhang'; console.log(name); }fun();// 输出"Jake Zhang"
在 fun() 执行的时候,输出一个 name 变量 ,那么这个 name 变量是哪里来?有看到函数第一行有 定义 name 变量的代码 var name = 'Jake Zhang'
继续看另外一段代码:
var name2 = 'Jake Zhang2';function fun() { console.log(name2);}fun(); // 输出"Jake Zhang2"
同样,在输出 name2 时,自己函数内部没有找到变量 name2 ,那么就 在外层的全局中查找 ,找到了就停止查找并输出结果。
可以注意到以上两段代码都有查找变量。第一段代码是在 函数 fun中找到 name 变量,第二段代码是在 全局 中找到 name2 变量。 现在给加粗的这两个词的后面加上 作用域 三个字,再读一遍:第一段代码是在 函数作用域 fun中找到 name 变量,第二段代码是在 全局作用域 中找到 name2 变量。
其实我们可以发现,作用域,本质是一套规则,用于确定在何处以及如何查找变量(标识符)的规则。关键点在于:查找变量(或标识符)。
由此我们便可引出
在上面的作用域介绍中,我们将作用域定义为一套规则,这套规则来管理浏览器引擎如何在当前作用域以及嵌套的作用域中根据变量(标识符)进行变量查找。
现在我们提出一个概念: “词法作用域是作用域的一种工作模型” ,作用域有两种工作模型,在JavaScript中的词法作用域(静态作用域)是比较主流的一种,另一种动态作用域( 是不关心函数和变量是如何声明以及在何处声明的,只关心它们从何处调用 )。 所谓的词法作用域就是在你写代码时将变量和块作用域写在哪里来决定,也就是词法作用域是静态的作用域,在你书写代码时就确定了。
请看以下代码:
function fn1(x) {var y = x + 4;function fn2(z) {console.log(x, y, z);}fn2(y * 5);}fn1(6); // 6 10 50
复制代码这个例子中有个三个嵌套的作用域,如图:
作用域是由期代码写在哪里决定的,并且是逐级包含的。
在ES6之前JavaScript并没有块级作用域的概念,我们来看一段代码:
for(var i=0;i<5;i++){console.log(window.i) } //0 1 2 3 4
如果你没在函数内使用for循环的话,你会惊奇的发现,妈耶,我这个var不等于白var嘛,反正都是全局变量,要知道我们的变量只能从下往上查找,不能反过来。所以JavaScript并没有块级作用域的概念。 块级作用域 是ES6中新添加的概念,常指的是{}中的语句,如 if 和 switch 条件语句或 for 和 while 循环语句,不像函数,它们不会创建一个新的作用域。块级作用域通常通过let或const来体现。
for(let j=0;j<5;j++)(console.log(window.j));//undefined *5复制代码
看上面的代码,可以和上一个的 var i 的循环做对比。其实,提到let,const,这里还涉及到变量提升、暂时性死区等知识点(限于篇幅这里不做展开,之后会写相关文章)。
好了,现在如果面试再问你什么是作用域,这下应该清晰明了了吧。记得顺便提一下词法作用域。 接下来让我们继续探索作用域链。
我们回到刚开始讲作用域的那段代码:
var name2 = 'Jake Zhang2';function fun() { console.log(name2);}fun(); // 输出"Jake Zhang2"
我们在查找 name2 变量时,先在函数作用域中 查找,没有找到,再去 全局作用域中 查找。你会注意到,这是一个往外层查找的过程,即顺着一条链条 从下往上查找变量 。这条链条,我们就称之为 作用域链 。
这样我们就得出作用域链的概念: 在作用域的多层嵌套中查找自由变量的过程是作用域链的访问机制。而层层嵌套的作用域,通过访问自由变量形成的关系叫做作用域链。
来两张图帮助理解:
代码表示:
var a = 1function fn1(){ function fn2(){ console.log(a) } function fn3(){ var a = 4 fn2() } var a = 2 return fn3 }var fn = fn1() fn() //输出?//输出a=2//执行fn2函数,fn2找不到变量a,接着往上在找到创建当前fn2所在的作用域fn1中找到a=2
var a = 1 function fn1(){ function fn3(){ var a = 4 fn2() } var a = 2 return fn3 }function fn2(){ console.log(a) }var fn = fn1() fn() //输出多少//输出a=1//最后执行fn2函数,fn2找不到变量a,接着往上在找到创建当前fn2所在的全局作用域中找到a=1
var a = 1function fn1(){ function fn3(){ function fn2(){ console.log(a) } var a fn2() a = 4 } var a = 2 return fn3}var fn = fn1()fn() //输出多少//输出undefined//函数fn2在执行的过程中,先从自己内部找变量找不到,再从创建当前函数所在的作用域fn去找,注意此时变量声明前置,a已声明但未初始化为undefined
堆栈溢出:由于过多的函数调用,导致调用堆栈无法容纳这些调用的返回地址,一般在递归中产生。堆栈溢出很可能由无限递归产生,但也可能仅仅是过多的堆栈层级。
内存泄漏:是指你向系统申请分配内存进行使用(new),可是使用完了以后却不归还(delete),结果你申请到的那块内存你自己也不能再访问(也许你把它的地址给弄丢了),而系统也不能再次将它分配给需要的程序。简单的理解就是不再使用的内存,没有及时释放,就是内存泄漏。
如何防止:对于递归导致的堆栈溢出,常用的方法就是使用闭包或匿名函数;对于内存泄漏常见的方法就是再不实用变量的时候,解除引用,或指向null。
前面所说的作用域及词法作用域都是为讲闭包做准备,所以如果对 作用域还有点模糊的可以回头再看一遍。
闭包(closure),是基于词法作用域书写代码时产生的一种现象。各种专业文献的闭包定义都非常抽象,我的理解是:** 闭包就是能够读取其他函数内部变量的函数**。通过下面的实践你会知道,闭包在代码中随处可见,不用特意为其创建而创建,随着深入做项目后,打代码的不经意间就已经用了闭包。
function a(){ var n = 0; function add(){ n++; console.log(n); } return add;}var a1 = a(); //注意,函数名只是一个标识(指向函数的指针),而()才是执行函数;a1(); //1a1(); //2
分析如下:
通过引用的关系,a1就是a函数本身(a1=a)。执行a1能正常输出变量n的值,这不就是“a能记住并访问它所在的词法作用域”,而a(被a1调用)的运行是在当前词法作用域之外。
当add函数执行完毕之后,其作用域是会被销毁的,然后垃圾回收器 会释放闭包那段内存空间,但是闭包就这样神奇地将add的作用域存活了下来,a依然持有该作用域的引用。
为什么会这样呢?原因就在于a是add的父函数,而add被赋给了一个全局变量,这导致add始终在内存中,而add的存在依赖于a,因此a也始终在内存中,不会在调用结束后,被垃圾回收机制(garbage collection)回收。 所以,在本质上,闭包是将函数内部和函数外部连接起来的桥梁。
闭包可以用在许多地方。它的最大用处有两个, 一个是前面提到的可以读取函数内部的变量,另一个就是让这些变量的值始终保持在内存中 。
使用闭包,我们可以做很多事情。比如模拟面向对象的代码风格;更优雅,更简洁的表达出代码;在某些方面提升代码的执行效率。
function waitSomeTime(msg, time) {setTimeout(function () {console.log(msg)}, time);}waitSomeTime('hello', 1000);
定时器中有一个匿名函数,该匿名函数就有涵盖waitSomeTime函数作用域的闭包,因此当1秒之后,该匿名函数能输出msg。
var fnArr = [];for (var i = 0; i < 10; i++) { fnArr[i] = function(){ return i };}console.log( fnArr[3]() ) // 10
通过 for 循环,预期的结果我们是会输出0-9,但最后执行的结果,在控制台上显示则是全局作用域下的10个10。
这是因为当我们执行 fnArr[3] 时,先从它当前作用域中找 i 的变量,没找到 i 变量,从全局作用域下找。开始了从上到下的代码执行,要执行匿名函数 function 时, for 循环已经结束( for 循环结束的条件是当 i 大于或等于10时,就结束循环),然后执行函数 function ,此时当 i 等于 [0,1,2,3,4,5,6,7,8,9] 时,此时 i 再执行函数代码,输出值都是 i 循环结束时的最终值为:10,所以是输出10次10。
由此可知: i 是声明在全局作用域中, function 匿名函数也是执行在全局作用域中,那当然是每次都输出10了。
那么,让 i 在每次迭代的时候都产生一个私有作用域,在这个私有的作用域中保存当前 i 的值
var fnArr = [];for (var i = 0; i < 10; i++) { fnArr[i] = (function(){ var j = i return function(){ return j } })()}console.log(fnArr[3]()) //3
用一种更简洁、优雅的方式改造:
将每次迭代的 i 作为实参传递给自执行函数,自执行函数用变量去接收输出值
var fnArr = []for (var i = 0; i < 10; i ++) { fnArr[i] = (function(j){ return function(){ return j } })(i)}console.log( fnArr[3]() ) // 3
在这里我先通过Java的抽象类讲一下抽象的概念:
学过Java的对抽象的思想一定不会陌生,先来看Java中的一个抽象类:
public abstract class SuperClass { public abstract void doSomething();}
复制代码这是Java中的一个类,类里面有一个抽象方法 doSomething ,现在不知道子类中要 doSomething 方法做什么,所以将该方法定义为抽象方法,具体的逻辑让子类自己去实现。 创建子类去实现 SuperClass :
public class SubClass extends SuperClass{ public void doSomething() { System.out.println("say hello"); }}
复制代码 SubClass 中的 doSomething 输出字符串 “say hello” ,其他的子类会有其他的实现,这就是Java中的抽象类与实现。
那么JS中的抽象是怎么样的,我想回调函数应该是一个:
function createDiv(callback) { let div = document.createElement('div'); document.body.appendChild(div); if (typeof callback === 'function') { callback(div); }}createDiv(function (div) { div.style.color = 'red';})
复制代码这个例子中,有一个 createDiv 这个函数,这个函数负责创建一个 div 并添加到页面中,但是之后要再怎么操作这个 div , createDiv 这个函数就不知道,所以把权限交给调用 createDiv 函数的人,让调用者决定接下来的操作,就通过回调的方式将div给调用者。
这也是体现出了抽象,既然不知道 div 接下来的操作,那么就直接给调用者,让调用者去实现。 这也是我们在学习vue等框架组件开发的一个基本思想。
好了,现在总结一下抽象的概念: 抽象就是隐藏更具体的实现细节,从更高的层次看待我们要解决的问题。
在编程的时候,并不是所有功能都是现成的,比如上面例子中,可以创建好几个 div ,对每个 div 的处理都可能不一样,需要对未知的操作做抽象,预留操作的入口。
接下来看一下JavaScript的几个数组操作方法,可以更深入的理解抽象的思想:
var arr = [1, 2, 3, 4, 5];for (var i = 0; i < arr.length; i++) { var item = arr[i]; console.log(item);}
这段代码中用for循环,然后按顺序取值,有没有觉得如此操作有些不够优雅,为出现错误留下了隐患,比如把 length 写错了,一不小心复用了i。既然这样,能不能抽取一个函数出来呢?最重要的一点,我们要的只是数组中的每一个值,然后操作这个值,那么就可以把遍历的过程隐藏起来:
function forEach(arr, callback) { for (var i = 0; i < arr.length; i++) { var item = arr[i]; callback(item); }}forEach(arr, function (item) { console.log(item);});
复制代码以上的 forEach 方法就将遍历的细节隐藏起来的了,把用户想要操作的 item 返回出来,在 callback 中还可以将 i、arr 本身返回: callback(item, i, arr) 。 JS原生提供的 forEach 方法就是这样的:
arr.forEach(function (item) { console.log(item);});
跟 forEach 相似的方法还有 map、some、every 等。思想都是一样的,通过这种抽象的方式可以让使用者更方便,同时又让代码变得更加清晰。
好了,抽象的简单介绍就到这了,再往后就是高阶函数的知识了,我这小白对高阶函数也还是懵懵懂懂,等我长本事儿了,再来更新。
这篇在我草稿箱躺了大概有两周了,之前写了一半硬是写不下去了,今天终于写完:sleepy:~ 再来默写一遍作用域: 作用域是定义变量的区域,它有一套访问变量的规则,这套规则来管理浏览器引擎如何在当前作用域以及嵌套的作用域中根据变量(标识符)进行变量查找 。