zl程序教程

您现在的位置是:首页 >  前端

当前栏目

【读书笔记】《你不知道的JavaScript(上卷)》——第一部分 作用域与闭包

JavaScript 知道 部分 第一 读书笔记 闭包 作用域
2023-09-27 14:26:51 时间


github:https://github.com/getify/You-Dont-Know-JS/tree/1ed-zh-CN
gitee:https://gitee.com/OliverDaDa/You-Dont-Know-JS —— forked from github


第1章 作用域是什么

1.1 编译原理

相对来说JavaScript并不能像其他编译语言一样,可以将编译结果直接作为服务发布使用,而是动态解释执行

传统编译语言在执行之前会经历三个步骤(统称为“编译”):

  • 分词/词法分析(Tokenizing/Lexing)

根据语言的规则,语句被词法单元生成器拆分为词法单元(token):

var a = 2;
=>
var、a、=2;
  • 解析/语法分析(Parsing)

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

  • 代码生成

将AST转换为可执行代码,这个过程与语言、目标平台等息息相关。

1.2 理解作用域

编译过程的几个关键词:

  • 引擎:boss,负责整个过程
  • 编译器:负责语法分析及代码生成等具体过程(可以理解为编译器依托引擎环境进行工作)
  • 作用域:负责变量(标识符)的curd权限

1.3 作用域嵌套

查找变量的过程类似冒泡,依次向上(直系),找到为止

1.4 异常

  • 严格模式禁止自动或隐式地创建全局变量。对于查找不到的变量,引擎会抛出ReferenceError异常。
  • ReferenceError同作用域判别失败相关,而TypeError则代表作用域判别成功了,但是对结果的操作是非法或不合理的。

第2章 词法作用域

作用域共有两种主要的工作模型。第一种叫词法作用域,另外一种叫作动态作用域。

2.1 词法阶段

  • 词法作用域由变量声明的位置决定
  • 全局变量会自动成为全局对象的属性

2.2 欺骗词法

欺骗词法作用域会导致性能下降

2.2.1 eval

eval() 函数会将传入的字符串当做 JavaScript 代码进行执行。

function foo(str, a) {
  eval(str); // 欺骗!
  console.log(a, b);
}
b = 2;
foo("var b = 3; ", 1); // 1, 3

在严格模式下,eval(…)在运行时有其自己的词法作用域,意味着其中的声明无法修改所在的作用域。

function foo(str) {
  "use strict";
  eval(str);
  console.log(a); // ReferenceError: a is not defined
}
foo("var a = 2");

我的理解:相当于:

function foo(str) {
  {
  	let a = 2;
  }
  console.log(a); // ReferenceError: a is not defined
}
  • setTimeout(…)和setInterval(…)的第一个参数可以是字符串(可以被解释为一段动态生成的函数代码),这些功能和eval(…)很相似,但是已经过时且并不被提倡。不要使用!
  • new Function(…)函数的行为也很类似,最后一个参数可以接受代码字符串,并将其转化为动态生成的函数(前面的参数是这个新生成的函数的形参)。这种构建函数的语法比eval(…)略微安全一些,但也要尽量避免使用。

这种方法的使用场景非常罕见,因为它所带来的好处无法抵消性能上的损失。

const sum = new Function('a', 'b', 'return a + b');
console.log(sum(2, 6)); // 8

2.2.2 with

  • with - JavaScript | MDN
    ——————
    with语句 扩展一个语句的作用域链
    争议很大,不建议使用!

with通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。

var obj = {
  a: 1,
  b: 2,
  c: 3
};
// 单调乏味的重复"obj"
obj.a = 2;
obj.b = 3;
obj.c = 4;
// 简单的快捷方式
with(obj) {
  a = 3;
  b = 4;
  c = 5;
}
function foo(obj) {
  with(obj) {
    a = 2;
  }
}
var o1 = {
  a: 3
};

var o2 = {
  b: 3
};
foo(o1);
console.log(o1.a); // 2
foo(o2);
console.log(o2.a); // undefined
console.log(a); // 2——不好,a被泄漏到全局作用域上了!

可以这样理解,当我们传递o1给with时,with所声明的作用域是o1,而这个作用域中含有一个同o1.a属性相符的标识符。但当我们将o2作为作用域时,o2的作用域、foo(…)的作用域和全局作用域中都没有找到标识符a,因此当a=2执行时,自动创建了一个全局变量(因为是非严格模式)。

严格模式下:with被完全禁止,而在保留核心功能的前提下,间接或非安全地使用eval(…)也被禁止。

2.2.3 性能

类似eval和with的使用会影响到引擎在编译阶段对代码的性能优化,导致得不偿失!

2.3 小结

词法作用域,有利于引擎在编译阶段对代码的性能优化,挑战规则的用法,得不偿失!


第3章 函数作用域和块作用域

3.1 函数中的作用域

属于这个函数的全部变量都可以在整个函数的范围内或嵌套范围内使用及复用

3.2 隐藏内部实现

最小授权或最小暴露原则——不仅可以保证私有变量的安全性还能最大限度地规避冲突

3.3 函数作用域

3.3.1 匿名和具名

  • 始终给函数表达式命名是一个最佳实践

在函数内部,有两个特殊的对象:arguments 和 this。其中, arguments 的主要用途是保存函数参数, 但这个对象还有一个名叫 callee 的属性,该属性是一个指针,指向拥有这个 arguments 对象的函数。 请看下面这个非常经典的阶乘函数:

function factorial(num){    
   if (num <=1) {         
      return 1;     
   } else {         
   return num * factorial(num-1)     
   } 
}  

定义阶乘函数一般都要用到递归算法;如上面的代码所示,在函数有名字,而且名字以后也不会变 的情况下,这样定义没有问题。但问题是这个函数的执行与函数名 factorial 紧紧耦合在了一起。为 了消除这种紧密耦合的现象,可以像下面这样使用 arguments.callee

function factorial(num){    
   if (num <=1) {         
      return 1;     
   } else {         
   return num * arguments.callee(num-1);
   } 
}  

现在已经不推荐使用arguments.callee();
原因:访问 arguments 是个很昂贵的操作,因为它是个很大的对象,每次递归调用时都需要重新创建。影响现代浏览器的性能,还会影响闭包。


选自:arguments.callee - 进击的前端狗 - 博客园

3.3.2 立即执行函数表达式

  • IIFE,立即执行函数表达式(Immediately Invoked Function Expression)
  • 包装函数或是说函数表达式并不能在其外部调用,因此不会污染外部环境
  • (function foo(){ … })()。第一个()将函数变成表达式,第二个()执行了这个函数
  • 另一个改进的形式:(function(){ … }())

第二个小括号的整体作为第一个小括号内函数的参数传入

包装函数的几种写法:

  • 第一种:
var a = 2; // 声明全局变量a
( function foo (globel) {
    var a = 3;
    console.log(a); // 调用局部变量 3
    console.log(globel.a); // 调用全局变量 2
}
( window ));
console.log(a); // 调用全局变量 2
  • 第二种写法和第一种很像:
var a = 2; // 声明全局变量a
( function foo (globel) {
    var a = 3;
    console.log(a); // 调用局部变量 3
    console.log(globel.a); // 调用全局变量 2
} )( window ); // 后面加这个括号是为了立即执行这个包装函数,并传入参数window
console.log(a); // 调用全局变量 2
  • 第三种是UMD模式(项目中被广泛使用,尽管这种模式略显冗长,但是更容易让人理解):
var a = 2;
  (function iife (def) {
    def(window);   // 2.执行def函数(def是被传入的函数),调用def函数并传入参数window.在下方执行代码块。
  })(function def (globel) { // 1.将def函数作为参数传入iife函数。
    var a = 3;
    console.log(a); // 打印局部变量 3
    console.log(globel.a); // 打印全局变量 2
  });
  console.log(a); // 打印全局变量 2

3.4 块作用域

3.4.1 with

用with从对象中创建出的作用域仅在with声明中而非外部作用域中有效。

3.4.2 try/catch

JavaScript的ES3规范中规定try/catch的catch分句会创建一个块作用域

3.4.3 let

  • let为其声明的变量隐式地劫持了所在的块作用域
  • 使用let进行的声明不会在块作用域中进行提升。声明的代码被运行之前,声明并不“存在”。
  • 块作用域有利于垃圾回收

3.4.4 const

定义常量,之后任何试图修改值的操作都会引起错误。


第4章 提升

4.1 先有鸡还是先有蛋

第一个例子:

a = 2;
var a;
console.log(a);

提升后:

var a;
a = 2;
console.log(a);

4.2 编译器再度来袭

只有声明本身会被提升,而赋值或其他运行逻辑会留在原地。如果提升改变了代码执行的顺序,会造成非常严重的破坏。

第二个例子:

console.log(a);
var a = 2;

提升后:

var a;
console.log(a);
a = 2;

4.3 函数优先

函数会首先被提升,然后才是变量。

foo(); // 1

var foo;

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

提升后:

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

foo(); // 1

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

var foo尽管出现在function foo()….的声明之前,但它是重复的声明(因此被覆盖),因为函数声明会被提升到普通变量之前。

变量提升不受if-else控制:

foo(); // "b"

var a = true;
if(a) {
    functionfoo() { console.log("a"); }
}
else{
    functionfoo() { console.log("b"); }
}
  • 我们习惯将var a = 2;看作一个声明,而实际上JavaScript引擎并不这么认为。它将var a和a = 2当作两个单独的声明,第一个是编译阶段的任务,而第二个则是执行阶段的任务。
  • 这意味着无论作用域中的声明出现在什么地方,都将在代码本身被执行前首先进行处理。可以将这个过程形象地想象成所有的声明(变量和函数)都会被“移动”到各自作用域的最顶端,这个过程被称为提升。
  • 声明本身会被提升,而包括函数表达式的赋值在内的赋值操作并不会提升。
  • 要注意避免重复声明,特别是当普通的var声明和函数声明混合在一起的时候,否则会引起很多危险的问题!

第5章 作用域闭包

5.1 启示

闭包并不神秘,它在我们的代码中随处可见!

5.2 实质问题

使函数是在当前词法作用域之外执行:

let outerFun = () => {
	let a = 1
	let innerFun = () => {
		console.log(a)
	}
	return innerFun
}
let b = outerFun()
b() // 1

传递函数当然也可以是间接的:

var fn;

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

    fn = baz; // 将baz分配给全局变量
}

function bar() {
    fn(); // 这就是闭包!
}

foo();

bar(); // 2

5.3 现在我懂了

常见的例子:

function wait(message) {
    setTimeout(function timer() {
      console.log(message);
    }, 1000 );

}

wait("Hello, closure! ");

jq中的例子:

function setupBot(name, selector) {
    $(selector).click(function activator() {
      console.log("Activating: " + name);
    } );
}

setupBot("Closure Bot 1", "#bot 1");
setupBot("Closure Bot 2", "#bot 2");

尽管IIFE本身并不是观察闭包的恰当例子,但它的确创建了闭包,并且也是最常用来创建可以被封闭起来的闭包的工具。

5.4 循环和闭包

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

输出:66666(setTimeout共享i所在作用域)

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

输出:66666(虽setTimeout有各自作用域,但i仍在共享作用域)

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

输出:12345(setTimeout有各自作用域,i仍在共享作用域,但调用的j在其各自作用域保留了之前调用setTimeouti的值)

改进:

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

当然还是用i也没有问题:

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

实际上let就可以实现:

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

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

因此:

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

5.5 模块

案例:

let Module = () => {
	let something = 'I am a module.'
	let another = ['A', 'good', 'module', '!']
	let doSomething = () => {
		console.log(something)
	}
	let doAnother = () => {
		console.log(another.join(' '))
	}
	return {
		doSomething,
		doAnother
	}
}

let module = Module()
module.doSomething() // I am a module.
module.doAnother() // A good module !

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

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

模块函数转换成了IIFE:

let module = (() => {
	let something = 'I am a module.'
	let another = ['A', 'good', 'module', '!']
	let doSomething = () => {
		console.log(something)
	}
	let doAnother = () => {
		console.log(another.join(' '))
	}
	return {
		doSomething,
		doAnother
	}
})()

module.doSomething() // I am a module.
module.doAnother() // A good module !

接收参数:

let Module = (something, another) => {
	let doSomething = () => {
		console.log(something)
	}
	let doAnother = () => {
		console.log(another.join(' '))
	}
	return {
		doSomething,
		doAnother
	}
}

let module = Module('I am a module.', ['A', 'good', 'module', '!'])
module.doSomething() // I am a module.
module.doAnother() // A good module !

5.5.1 现代的模块机制

现在看来可以算是过去的模块机制

let MyModules = (() => {
	let modules = {}
	let define = (name, deps, impl) => {
		for(let i = 0; i < deps.length; i++) {
			deps[i] = modules[deps[i]]
		}
		modules[name] = impl.apply(impl, deps)
	}
	let get = name => modules[name]
	return {
		define,
		get
	}
})()

这段代码的核心是modules[name] = impl.apply(impl,deps)。为了模块的定义引入了包装函数(可以传入任何依赖),并且将返回值,也就是模块的API,储存在一个根据名字来管理的模块列表中。


我的理解:

  • define(modules.name, […modules.funcNameArr], […modules.funcArr])
  • fun.apply(fun中this指向,传到fun的参数列表)

使用:

MyModules.define('introduce', [], () => {
	let hello = who => `My name is ${who}!`
	return {
		hello
	}
})
MyModules.define('toChinese', ['introduce'], introduce => {
	let say = cName => console.log(
		introduce.hello(cName),
		`我的名字是${cName}!`
	)
	return {
		say
	}
})
let introduce = MyModules.get('introduce')
let toChinese = MyModules.get('toChinese')
console.log(introduce.hello('小军')) // My name is 小军!
toChinese.say('小明') // My name is 小明! 我的名字是小明!

这里根据书上的例子,改动了一番,感觉这样合理些:

  • 首先通过MyModules.define,定义了一个模块功能:introduce,它只有一个功能,即默认功能:hello, 通过MyModules.get('introduce') 即可引用此功能模块,通过MyModules.get('introduce').hello() 调用具体功能;
  • 另一个:定义了一个模块功能:toChinese,它调用了introduce功能,并在自己的功能say中复用introduce的功能;

。。。初次接触,还是不太习惯的。。。若有理解不妥,欢迎指出!

5.5.2 未来的模块机制

现在看来可以算是现在的模块机制

  • introduce,js
let hello = who => `My name is ${who}!`
export { hello }
  • toChinese,js
import { hello } from 'introduce'

let say = cName => console.log(
	hello(cName),
	`我的名字是${cName}!`
)
export { say } 
  • use.js
import { hello } from 'introduce'
import { say } from 'toChinese'

console.log(hello('小军')) // My name is 小军!
say('小明') // My name is 小明! 我的名字是小明!

果然是简单易用了很多!

附录A 动态作用域

词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。

附录B 块作用域的替代方案

附录C this词法

箭头函数中我们常用:

let self = this

或:

let _this = this

不妨试一下:

let fun = () => {...}.bind(this)

读书笔记由于是摘录和感想,因此逻辑感和阅读体验都会比较差!!!

需要原书pdf的可以私聊我,直接摆出来不太好 😂, 到微信读书阅读,有书友划线交流,体验更棒哦!