JS你不知道的JavaScript 笔记—— 作用域与闭包 - 编译原理 - LHS - RHS - 循环与闭包 - 模块 - 词法作用域 - 动态作用域

Posted YK菌

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JS你不知道的JavaScript 笔记—— 作用域与闭包 - 编译原理 - LHS - RHS - 循环与闭包 - 模块 - 词法作用域 - 动态作用域相关的知识,希望对你有一定的参考价值。

之前看了一遍《你不知道的javascript(上卷)》之后感觉醍醐灌顶,过了几个月又感觉都快忘了,今天准备边二刷,边做个笔记,把书中的一些重点思想记录整理下来,方便以后复习。

今天先来复习总结第一部分:【作用域与闭包】

1. 什么是作用域

在程序语言中,存储访问变量的能力将状态带给了程序

关于【变量存储在哪】和【程序如何找到变量】的规则被称为作用域

1.1 编译原理

任何JavaScript代码片段在执行前都要进行编译(通常就在执行前)

  1. 分词/词法分析(Tokenizing/Lexing

将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为词法单元(token

  1. 解析/语法分析(Parsing

将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。
这个树被称为“抽象语法树”(Abstract Syntax Tree, AST)。

  1. 代码生成

AST转换为可执行代码

1.2 JS编译原理

  1. 引擎——负责JS程序的编译及执行过程
  2. 编译器——负责语法分析及代码生成
  3. 作用域——负责收集并维护由所有变量(标识符)组成的一系列查询,并实施一套规则,确定当前执行的代码对这些标识符的访问权限

用变量的赋值来举个例子

变量的赋值操作会执行两个动作:

  1. 编译器会在当前作用域声明一个变量(如果之前没有声明过)
  2. 在运行时引擎会在作用域查找该变量,如果能够找到就会对它赋值

① 引擎怎么查找变量

引擎通过 LHS查询与RHS查询 来查找变量

当变量出现在赋值操作的左侧时进行LHS查询(找到存储的位置并赋值),出现在右侧时进行RHS查询(找到它具体的值)

LHS和RHS的含义是“赋值操作的左侧或右侧”并不一定意味着就是“=赋值操作符的左侧或右侧”。
赋值操作还有其他几种形式,因此在概念上最好将其理解为“赋值操作的目标是谁(LHS)”以及“谁是赋值操作的源头(RHS)”

简单一点来记就是:【左找位置,右找值

② LHS与RHS的练习

function foo(a){ // 2. LHS找a的位置,给a赋值2
	var b = a; // 3. RHS找a的值  4. LHS找b的位置,给b赋a的值2
	return a + b; // 5. RHS找a的值 6. RHS找b的值
}
var c = foo(2) // 1. RHS找foo的值 7. LHS找c的位置,给c赋值foo(2)的值4

③ BB几句

这里的LHS和RHS让我想起了getter和setter
LHS相当于是setter,找到它的位置,并给它赋值
RHS相当于是getter,找到它的值

1.3 作用域链

发生作用域嵌套时,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或抵达最外层的作用域(也就是全局作用域)为止

1.4 RHS与LHS找不到的情况

如果RHS查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出ReferenceError异常。

当引擎执行LHS查询时,如果在顶层(全局作用域)中也无法找到目标变量,全局作用域中就会创建一个具有该名称的变量,并将其返还给引擎

在这里插入图片描述

在严格模式中LHS查询失败时,并不会创建并返回一个全局变量,引擎会抛出同RHS查询失败时类似的ReferenceError异常

1.5 总结

  1. 作用域是一套规则,用于确定在何处以及如何查找变量(标识符)

  2. 如果查找的目的是对变量进行赋值,那么就会使用LHS查询;
    如果目的是获取变量的值,就会使用RHS查询

  3. LHS和RHS查询都会在当前执行作用域中开始,沿着作用域一直找到全局作用域

  4. LHS找不到不报错,RHS找不到会报错

2. JS作用域

2.1 词法作用域

作用域共有两种主要的工作模型:词法作用域动态作用域
JS采用的是词法作用域

词法作用域就是定义在词法阶段的作用域。

词法作用域是由你在写代码时将变量块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变。

看下面的作用域气泡,
在这里插入图片描述

❶ 包含着整个全局作用域,其中只有一个标识符:foo
❷ 包含着foo所创建的作用域,其中有三个标识符:a、bar 和 b
❸ 包含着bar所创建的作用域,其中只有一个标识符:c

作用域气泡的结构和互相之间的位置关系给引擎提供了足够的位置信息,引擎用这些信息来查找标识符的位置。
作用域查找会在找到第一个匹配的标识符时停止。

无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定。

词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。
编译的词法分析阶段基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它们进行查找。

2.2 函数作用域

函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用)

函数作用域的好处是可以隐藏内部实现 ,外部作用域无法访问包装函数内部的任何内容

关于函数声明与函数表达式

如果function是声明中的第一个词,那么这就是一个函数声明,否则就是一个函数表达式

立即执行函数表达式(IIFE

(function foo(){//...})()

(function(){//...}())

(function IIFE(global){//...})(window)

2.3 块级作用域

ES6用letconst声明的变量具有块级作用域

try/catch中的块级作用域

try/catchcatch分句会创建一个块作用域,其中声明的变量仅在catch内部有效。

2.4 总结

任何声明在某个作用域内的变量,都将附属于这个作用域

3. 变量提升

编译阶段的一部分工作就是找到所有的声明,并用合适的作用域将它们关联起来。

包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理

函数的提升是先于变量的

声明本身会被提升,而包括函数表达式的赋值在内的赋值操作不会提升

4. 作用域闭包

闭包是基于词法作用域书写代码时所产生的自然结果

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行

换句话说

变量的查找,是在函数定义的地方,向上级作用域查找,而不是在执行的地方

看一个例子【函数作为返回值】

function foo() {
	var a = 2;
	function bar() {
		console.log(a);
	}
	return bar;
}
var baz = foo();
baz(); // 2 

这里实际上只是通过不同的标识符引用调用了内部的函数bar()

bar是在它自己定义的词法作用域以外的地方执行

因为bar声明的位置在foo中,它拥有涵盖foo内部作用域的闭包,使得该作用域能够一直存活,不会被GC;bar()持有对该作用域的引用,这个引用就叫做闭包

无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。

再看一个例子【函数作为参数】

function wait(message) {
	setTimeout(function timer() {
		console.log(message);
	}, 1000);
}
wait('Hello, closure!')

将函数(timer)传递给setTimeout(..)

因为timer保有对变量message的引用,所以timer具有涵盖wait作用域的闭包,。

因此 wait(..)执行1000毫秒后,它的内部作用域并不会消失。

在引擎内部,内置的工具函数setTimeout(..)持有对一个参数(回调函数)的引用。
引擎会调用这个函数(timer),而词法作用域在这个过程中保持完整。

这就是闭包

本质上无论何时何地,如果将(访问它们各自词法作用域的)函数当作第一级的值类型并到处传递,你就会看到闭包在这些函数中的应用。
在定时器、事件监听器、Ajax请求、跨窗口通信、Web Workers或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包!

练习

① 函数作为【返回值】

function create() {
	let a = 100
	return function() { // 定义
		console.log(a)
	}
}
let fn = create()
let a = 200
fn() // 执行 100

② 函数作为【参数】

function print(fn) {
	let a = 200
	fn() // 执行
}
let a = 100
function fn() { // 定义
	console.log(a)
}
print(fn) // 100

变量的查找,是在函数定义的地方,向上级作用域查找,而不是在执行的地方

【特别提醒】 this取值是在执行时确定的,不是在定义时确定的

闭包在实例开发中的应用

拓展 for循环与闭包

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

执行输出五个6,原因就是:这里timer会在循环结束之后才执行

具体的原因就是:我们试图假设循环中的每个迭代在运行时都会给自己“捕获”一个i的副本。但是根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个i

为此,我们需要更多的闭包作用域,特别是在循环的过程中每个迭代都需要一个闭包作用域

【改进1】考虑用IIFE创建作用域(效果不对)

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

然而这个IIFE只是一个什么都没有的空的作用域,所以还是没有达到我们想要的效果

【改进2】在IIFE中创建非空作用域(达到效果)

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

IIFE中需要有自己的变量,用来在每个迭代中储存i的值

【改进3】在IIFE中作为定义参数传递

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

在迭代内使用IIFE会为每个迭代都生成一个新的作用域,使得timer可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问

【改进4】使用let的块级作用域

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

本质上这是【将一个块转换成一个可以被关闭】的作用

【改进5】终结版本

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

for循环头部的let声明还会有一个特殊的行为。
这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。
随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。

总结

当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包

5. 闭包在实际开发中的作用:隐藏数据

例子

闭包隐藏数据吗,只提供API

function createCache(){
	const data = {} // 闭包中的数据,被隐藏,不被外界访问
	return {
		set: function (key, val){
			data[key] = val
		},
		get: function (key) {
			return data[key]
		}
	}
}
const c = createCache()
c.set('a', 100)
console.log(c.get('a'))

模块

这个模式在JavaScript中被称为模块。最常见的实现模块模式的方法通常被称为模块暴露,下面展示的是其变体。

function CoolModule() {
  let something = "cool";
  let another = [1, 2, 3];

  function doSomething() {
    console.log(something);
  }
  function doAnther() {
    console.log(another.join("!"));
  }

  return {
    doSomething,
    doAnther,
  };
}

var foo = CoolModule();
foo.doSomething(); // cool
foo.doAnther(); // 1!2!3

如果不执行外部函数,内部作用域闭包都无法被创建

模块模式需要具备两个必要条件:

  1. 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)。
  2. 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。

【单例模式】下的模块

将模块函数转换成了IIFE, 立即调用这个函数并将返回值直接赋值给单例的模块实例标识符foo

var foo = (function CoolModule() {
  let something = "cool";
  let another = [1, 2, 3];

  function doSomething() {
    console.log(something);
  }
  function doAnther() {
    console.log(another.join("!"));
  }

  return {
    doSomething,
    doAnther,
  };
})();

foo.doSomething(); // cool
foo.doAnther(); // 1!2!3

总结

模块有两个主要特征:

  1. 为创建内部作用域而调用了一个包装函数
  2. 包装函数的返回值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭包

6. 动态作用域

词法作用域是一套关于引擎如何寻找变量以及会在何处找到变量的规则
其最重要的特征是它的定义过程发生在代码的书写阶段(假设你没有使用eval()with

动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调用
换句话说,作用域链是基于调用栈的,而不是代码中的作用域嵌套

事实上JavaScript并不具有动态作用域。它只有词法作用域
但是this机制某种程度上很像动态作用域

主要区别:词法作用域是在写代码或者说定义时确定的,而动态作用域是在运行时确定的。(this也是!)词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用

拓展 this词法

箭头函数在涉及this绑定时的行为和普通函数的行为完全不一致。它放弃了所有普通this绑定的规则,取而代之的是用当前的词法作用域覆盖了this本来的值。

后面读书笔记博文会重点介绍this

以上是关于JS你不知道的JavaScript 笔记—— 作用域与闭包 - 编译原理 - LHS - RHS - 循环与闭包 - 模块 - 词法作用域 - 动态作用域的主要内容,如果未能解决你的问题,请参考以下文章

你不知道的Javascript(上卷)读书笔记之二 ---- 词法作用域

JS你不知道的JavaScript笔记- this - 四种绑定规则 - 绑定优先级 - 绑定例外 - 箭头函数

你不知道的javascript--上卷--读书笔记1

你不知道的JavaScript上卷 - 读书笔记 - 第2章词法作用域-2.2 欺骗词法

你不知道的JavaScript上卷 - 读书笔记 - 第2章词法作用域-2.2 欺骗词法

你不知道的JavaScript学习笔记1——作用域