JavaScript作用域和闭包理解总结

667 阅读7分钟

前置知识

作用域

变量作用域顾名思义就是变量作用范围,即我们可以在什么地方去使用这个变量,es6之前的js的作用域只有两种,即函数作用域和全局作用域。

词法作用域

词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)。
通俗一点就是,变量的作用域是由我们写代码时的位置决定的,跟运行时无关。
理解下面的代码:

let scope='global message'
function doSomething(){
    var scope='local message'
    function f(){
       console.log(scope);
    }
    reurn f();
}
doSomething();
let scope='global message'
function doSomething(){
    var scope='local message'
    function f(){
       console.log(scope);
    }
    reurn f;
}
doSomething()();

第一段代码毫无疑问会输出'local message',但是对于第二段代码的输出有的同学可能会有疑问,它还是会输出'local message',这即是以为js的词法作用域的机制,即我们编写代码是内部的函数的作用域已经决定了,f函数会在自己的作用域中查找scope变量,不存在即去上一级作用域即doSomething函数的作用域去查找,再找不到才回去全局作用域去查找,不会再运行时改变(不要与运行时的this绑定搞混了)。
可这么理解,js引擎会为全局作用域创建一个作用域对象global[scope],全局执行环境中创建的变量都保存到这个global[scope]中;同样为每个函数创建作用域functionName[scope],在函数作用域中创建的变量保存到这个functionName[scope]中,并且函数作用域中保存全局作用域的引用,以便我们在函数作用域对象上找不到某个变量是去全局作用域对象上去查找,总结一句话就是:作用域链本质上是一个指向变量对象的指针列表,它只引用但不实际包含变量对象。大概是如下的结构。
image.png 上图大概表示下面代码的作用域关系

var var1=1;
var var2=2;
function function1(){
    var var3=3;
    var var4=4;   
    function innerFunction(){
       var var6=5;
       var var5=6; 
    }
}

开始聊闭包

闭包的定义

❏ 在后台执行环境中,闭包的作用域链包含着它自己的作用域、包含函数的作用域和全局作用域。
❏ 通常,函数的作用域及其所有变量都会在函数执行结束后被销毁。
❏ 但是,当函数返回了一个闭包(函数)时,这个函数的作用域将会一直在内存中保存到闭包不存在为止。
这里解释一下为什么函数的作用域及其所有变量都会在函数执行结束后被销毁,因为函数执行实际上是一个栈式结构的执行顺序,我们称为执行栈,先调用的函数先入栈,后出栈,如下函数:

function a(){
  console.log('a入栈,开始执行')
  //......
  b()
  console.log('a执行结束,出栈')
}
function b(){
  console.log('b入栈,开始执行')
  //......
  c()
  console.log('b执行结束,出栈')
}
function c(){
  console.log('c入栈,开始执行')
  //......
  console.log('c执行结束,出栈')
}
a()

我们在代码的最后执行a()函数,a函数入栈,a函数中调用b()使得b入栈,b函数中调用c()使得c入栈;接下来c函数执行完成出栈,b函数执行完成出栈,最后栈顶的a出栈执行完成。
因此外部函数执行完成时,内部函数都已经出栈,所以可以释放内存。
但是当存在闭包时情况会有所不同,因为外部存在内部函数的引用时,因此即使执行栈都已清空,也不能回收外部函数的作变量对象所占用的内存,这就形成了闭包。
形如下面的函数,

function createPlus(){
    var num=1;
    function plus(){
       num+=1;
       return num;
    }
    return plus;
}
let plus=createPlus();
plus();

代码中plus函数作为createPlus函数的内部函数,它可以访问自己的作用域createPlus函数的作用域和全局作用域,我们在createPlus函数中把plus函数作为值返回,并且赋值给一个外部变量plus,这就形成了一个闭包,导致createPlus函数的作用域无法释放,除非外部的函数引用变量置空即plus=null,否则刺闭包内的变量不会被释放内存。
上面的代码只是一种闭包的形式,实际上任何把函数当做值来传递的场景中都会触发闭包的形成,比如我们最常用的回调函数的形式:

var message='hello'
function update(){
  message='Helllo'
}
setTimeout(update)
window.addEventListnener('click',update);

这也是我们需要及时remove移除事件监听程序的原因,因为我们把事件处理函数当做值来传递,实际上是创建了对函数的引用形成了闭包,这个事件处理函数会包含外部环境作用域的引用,导致外部作用域内存无法释放。

循环+闭包理解

使用var变量循环,因为不存在跨级作用域,所以,循环内的闭包函数访问的是共享的变量i,即闭包只能取得包含函数中任何变量的最后一个值。

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

我们知道上面的代码不会按照看上去的依次输出0-6,而是输出6个6。
这就是因为访问的是同一个作用域中的同一个变量i,而如果使用let块级作用域,就会在相当于每个for循环的块内都有一个let i=i++;创建了一个新的变量。

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

使用闭包原理即是把每个i作为值传递到一个新创建的闭包模拟的块级作用域中。

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

闭包应用

私有变量

我们知道js中本没有私有变量的概念,但是我们可以借助闭包模拟实现:

function privateObj(){
  var _privateStr="name";
  return {
    setStr(str){
      _privateStr=str;
    },
    getStr(){
      return _privateStr;
    },
  }
}
let obj=privateObj();
obj.setStr('new name');
obj.getStr();

这样我们就可以通过privateObj函数创建拥有_privateStr私有变量的对象了,这个的对象的_privateStr属性只能通过内部提供的setStr和getStr两个方法获取。

模块化

接下来让我们借助闭包实现一个简单的模块化功能

var MyModule=(
    function(){
      var modules={};
      //定义模块
      function defined(name,deps,moduleFn){
         deps.forEach((dep,index)=>{
            deps[index]=modules[dep]; 
         })
         modules[name]=moduleFn.apply(moduleFn,deps);//为模块赋值,即模块生成函数返回的对象
      }
      //使用模块
      function require(name){
        return modules[name];
      }
      return {
        defined,
        require
      }
    }
  )()
//使用
MyModule.defined('bar',[],function(){
   var _name='bar_name'
   function showName(){
      console.log(_name)
   }
   return {
     showName
   }
})
MyModule.defined('foo',['bar'],function(bar){
   var _name='foo_name'
   function showBarName(){
      bar.showName()
   }
   function showName(){
      console.log(_name)
   }
   return {
     showName,
     showBarName
   }
})
MyModule.defined('all',['bar','foo'],function(bar,foo){
    return {
        bar,
        foo
    }
 })
var foo=MyModule.require('foo');
foo.showName();
foo.showBarName();
var all=MyModule.require('all');
all.bar.showName()
all.foo.showName();

上述代码我们通过自执行函数的语法创建一个通过返回内部函数的形式创建了一个闭包,我们在闭包中保存了一个modules模块保存对象,我们在定义模块时调用defined方法,这个方法传入模块名称和模块生成代码,定义了require函数返回对应名称的模块。

思考webpack的模块化打包原理

有了上面的知识积累,不妨让我们去看看webpack把模块化编写的程序编译之后生成的代码是个啥样子,
./app/main.js

let message=require('./module1.js');
let app = document.getElementById("app");
app.innerHTML += `<span class="title">${message}</span>`;


./app/module1.js

let message = "Hello World"
module.exports = message;


编译后的代码如下:

 (
		function (modules) {
	     	//已加载的模块的缓存
			let installedModule = {};
			//CommonJS模块加载实现的核心方法
			function __webpack_require__(moduleId) {
				if (installedModule[moduleId]) {
					//模块加载过,直接返回缓存中的模块
					return installedModule[moduleId];
				}
				let module = installedModule[moduleId] = {
					id: moduleId,
					loaded: false, //是否已加载完成
					exports: {} //此文件模块对外的接口
				}
				//调用指定模块的加载函数,这个函数会执行模块代码,并为module.exports赋值
				modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

				module.loaded = true; //加载完成
				//返回此模块的导出值
				return module.exports;
			}
			//加载入口文件
			return __webpack_require__(entry);
		}
	)({
		'./app/main.js': function (module, __webpack_exports__, __webpack_require__) {
			//module1的CommonJs导入语句会编译成调用__webpack_require__加载函数的形式,
			//返回module1的导出值,即moduleId='./app/module1.js'的module对象的expots属性
			var _module1__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(
				/*! ./module1 */
				"./app/module1.js");
			//读取文件内容
			let app = document.getElementById("app");
			app.innerHTML += `<span class="title">${_module1__WEBPACK_IMPORTED_MODULE_0__}</span>`;

		},
		'./app/module1.js': function (module, __webpack_exports__, __webpack_require__) {

			let message = "Hello World"
			//修改本身module对象的exports值,作为导出值
			module.exports = message;

		}
	});

同样定义了一个自执行函数,函数中定义了一个__webpack_require__(id)函数,用来加载模块,分我们之前定义的require的作用基本一致,区别是没有了手动定义模块的操作,而是通过编译器把文件直接生成了一个key是文件路径,value是一个函数的对象,实际上跟我们defined方法中传入的name和moduleFn含义很相似,上面代码中已经有注释不再详细解释代码,如需了解可以看一下我之前的博客理解webpack模块加载原理

总结

作用域

作用域链本质上是一个指向变量对象的指针列表,它只引用但不实际包含变量对象。

闭包

当我们在一个作用域中创建一个函数,并把它当做值来传递时,就会形成闭包,即外部函数的作用域变量对象无法得到释放。
一句话就是,任何把函数当做值来传递的场景中都会触发闭包的形成。

最后

上文中的内容是自己在学习过程中的一点总结和理解,如果感觉不错并且对您有所帮助还望不吝点赞👍,如有错误之处还望不吝指正Thanks♪(・ω・)ノ。

参考文献:
你不知道的JavaScript(上卷)
JavaScript高级程序设计