目录
一、作用域
是什么:是用来存储变量并在之后方便查找变量的一套设计良好的规则。存在于JavaScript引擎内部,无法通过JavaScript代码访问。
何时生成:
- 定义函数时【编译阶段】会将所处环境中能访问到的属性包裹成对象(可引申出原型和原型链)放入作用域链中[[scope]];
- 函数执行前【预解析】会先生成活动对象(包含当前函数中的参数、局部对象、this等)放入作用域链头部;
- 函数执行时【执行阶段】用到的标识符【变量】会通过作用域链查找。(查找到某个对象,访问内部属性就用到了原型链)
1.1 编译原理
传统编译语言流程中,在源代码执行之前会经过三个步骤,最终生成可执行的机器代码,统称为“编译”。
-
分词/词法分析:将字符串拆分成有意义的代码块(词法单元)。可通过
左侧输入
var a = 2;
,右侧选择Tokens查看生成的词法单元。var a = 2; // 会被拆分为5个词法单元数组:["var", "a", "=", "2", ";"](空格是否作为词法单元,取决于空格在当前语言中是否有意义。)
-
解析/语法分析:将词法单元数组转换成代表程序语法结构的树,即:抽象语法树(AST)。可通过
左侧输入
var a = 2;
,右侧查看生成的AST结构。 -
代码生成:将AST转换成可执行代码(机器指令)的过程。该过程与语言、目标平台等相关。
生成
var a = 2;
的机器指令,用来创建a变量(包括分配内存等),并将其值 2 储存在 a 中。
JavaScript一直被称为解释型语言,但事实上它是一门编译型语言。其它大多数编译语言的编译过程是在构建前,JavaScript不同,它是在代码执行前进行编译(几微秒),然后做好准备马上就会执行。
1.2 理解作用域
成员 | 作用 |
---|---|
引擎 | 负责JavaScript全程编译及执行过程 |
编译器 | 负责词法分析、语法分析及代码生成等工作 |
作用域 | 负责收集并维护所有声明的标识符(变量)查询,确定当前执行代码对这些标识符的访问权限。 |
以var a = 2;
为例:会分为两步执行:预编译、执行
- 编译器在编译时处理(LHS查找,当前作用域添加声明):遇到
var a
,编译器会在当前作用域集合中声明一个变量并命名为a。如果之前声明过,编译器会忽略该声明,继续进行编译为引擎生成运行时所需的代码(用来处理a = 2
的赋值操作)。 - 引擎在运行时处理(RHS查找,嵌套作用域查找声明):执行
a = 2
操作,引擎首先会查找当前作用域及外层作用域中叫做a
的变量,找到会将 2 赋值给它。否则引擎就会抛出一个异常!
总结:执行var a = 2;
时,首先 var a
,在当前作用域进行LHS查找,没有则声明;其次 a = 2
,在嵌套作用域进行RHS查找,赋值为 2 (不用关心当前 a 的值是什么)。
1.3 作用域嵌套/作用域链
当一个块或函数嵌套在另一个块或函数中时,就会形成作用域的嵌套。
表现形式:[{ 当前作用域
}, { 上一级作用域
}, … , {全局作用域
}];
查找规则:引擎会从当前作用域开始查找变量,如果找不到,就会去上一级查找。当抵达最外层全局作用域时,未找到则抛出异常。
1.4 异常
- LHS 和 RHS 查找到顶层(全局作用域),还未找到目标变量的不同表现形式。
- 非严格模式
- LHS:全局作用域就会创建一个具有该名称的变量,并返回给引擎。
- RHS:引擎会抛出ReferenceError异常,未找到。
- Es5严格模式
"use strict"
:- LHS:引擎会抛出ReferenceError异常,未找到。
- RHS:引擎会抛出ReferenceError异常,未找到。
- LHS 和 RHS 查找到顶层(全局作用域),找到目标变量的不同使用情况。
- LHS查找到变量或方法,对赋值没有任何限制。
- 当RHS查找到变量,却试图进行函数调用,或者引用null、undefined类型的值中的属性,那么引擎会抛出TypeError异常,操作非法或不合理。
二、词法作用域
作用域共有两种主要的工作模型:词法作用域(即定义在词法阶段的作用域,被大多数编程语言所采用);动态作用域(Bash、Perl等)
function foo() {
console.log(a); // 词法作用域输出2(JavaScript);动态作用域输出3(很像this)
}
function bar() {
var a = 3;
foo();
}
var a = 2;
bar();
2.1 词法阶段
在编译过程的第一阶段词法分析
,对源代码中的字符进行检查,赋予单词语义。
词法作用域只由函数被声明时所处的位置决定。
// 1. 一个标识符: foo
function foo(a) {
// 2. 三个标识符: a, b, bar
var b = a * 2;
function bar(c) {
// 3. 一个标识符: c
console.log(a, b, c);
}
bar(b * 3);
}
foo(2);
2.2 欺骗词法作用域
以下两种方式会在运行时修改或创建新的作用域,以此来欺骗编译时生成的词法作用域。
在没有使用eval和with时,引擎会在编译阶段进行数项性能优化。有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。
副作用:在运行期间,eval会改变当前所在作用域;with会创建新词法作用域。引擎无法确定会执行什么内容,无法在编译时对作用域查找进行优化,均会导致性能下降。
2.2.1 eval(…)
在代码执行阶段,eval(…)函数以运行JavaScript方式执行一段字符串。
-
非严格模式:会将其中的内容视为原本就存在于这个位置的代码,执行期间会以动态形式插入到编译阶段生成的词法作用域中,影响后续代码的执行。
function foo(str) { // 编译后生成的词法作用域 0个标识符; 执行完eval函数,动态插入变为 1个标识符: b eval(str); console.log(b); } var b = 2; foo("var b = 3"); // 3
-
严格模式:会生成自己的词法作用域,不会影响后续代码的执行。
function foo(str) { "use strict"; // 编译后生成的词法作用域 0个标识符; 执行eval期间会生成自己的词法作用域,并不会修改所在的作用域 eval(str); console.log(b); } var b = 2; foo("var b = 3"); // 2
// 以下方式在运行期间均会动态生成函数
setTimeout(a: string);
setInterval(a: string)
new Function(arg1, arg2, func: string);
2.2.2 with关键字
with 可以将一个没有或多个属性的对象处理为一个完全隔离的全新词法作用域,对象中的属性会被处理为这个作用域中的词法标识符。
// 非严格模式下运行,严格模式下with关键字被禁止
function foo(obj) {
with(obj) {
a = 2; // 在全新词法作用域进行LHS查找,并将2赋值给它。
}
}
var o1 = {a: 3};
var o2 = {b: 3};
foo(o1); // with内部作用域一个标识符:a;找到该标识符赋值为2
console.log(o1.a); // 2
foo(o2); //with内部作用域一个标识符:b;with内部作用域、foo(..)作用域、全局作用域都没有a标识符,因此当 a=2 执行时,会自动创建一个全局变量。
consoe.log(o2.a); // undefined
console.log(a); // 2
三、函数作用域和块作用域
3.1 函数中的作用域
每声明一个函数,在编译词法解析阶段都会为其自身创建一个作用域。
当前函数内的全部变量可以在整个范围内使用及复用,这种设计方案,能充分利用JavaScript变量可以根据需要改变值类型的“动态”特性。
3.2 隐藏内部实现
最小暴露原则:在软件设计中,应该最小限度地暴露必要内容,而将其它内容隐藏起来。
-
弊端:当所有变量和函数都暴露在全局作用域中,当然所有内部作用域都能访问到它们(不好的一种设计),并且所占用内存不能被释放。
-
合理设计:应使用函数将某些变量或函数包裹私有化,只对外暴露一些合理API和变量。
-
隐藏内部实现同时可以规避同名标识符之间的冲突。比如:
- 第三方库都会在全局暴露一个名字足够独特的对象,对象的属性及暴露给外界的功能。
- 模块管理:将库的标识符显示地导入到另外一个特定的作用域中。
3.3 函数作用域
区分函数声明和表达式最简单的方法:function是声明中的第一个词即函数声明,否则就是一个函数表达式。
匿名函数表达式、立即执行函数(IIFE)
3.4 块级作用域
为什么不直接使用IIFE创建作用域?
IIFE和try/catch并不是完全等价的,如果将一段代码中的任意一部分包裹到函数中,会改变这段代码的含义,其中的this、return、break、continue都会发生变化。
3.4.1 with
with内部声明变量可以实现块级作用域效果(不推荐使用)。
3.4.2 ES3的try/ catch
Try/catch的catch分句会创建块级作用域,catch(err)的err属于当前块级作用域中的变量。(丑陋的代码,在向ES6过渡时,使用代码转换工具将ES6生成兼容ES5代码,大部分使用的该方案)。
try {
throw 2;
} catch(err) {
console.log(err); // 2
}
console.log(err); // ReferenceError
3.4.3 let、const
let和const声明均会附属于一个新的作用域。
-
有助于垃圾收集
// 由于click函数引用着外层作用域,导致bigData不能被释放(取决于引擎的具体实现)。 function process(data) { .... } var bigData = [...]; process(bigData); //此处执行完后续不在使用bigData大内存变量 dom.addEventListener('click', function click(e){....}); // 借助块级作用域优化 function process(data) { ... } // 块级作用域中执行完就可以销毁了 { let bigData = [...]; process(bigData); } dom.addEventListener('click', function click(e){....});
-
循环
// 循环的每个迭代的块级作用域,都生成了i标识符 for(let i = 0; i < 10; i++) { } console.log(i); // ReferenceError
四、提升
-
变量和函数在内的所有声明都会在任何代码被执行前首先被处理。
-
编译阶段:存在提升操作,函数声明、变量声明会被提升,函数表达式不会被提升。
// 变量声明提升 console.log(a); // undefined var a = 2; // 函数声明提升 func(); // func function func() { console.log("func"); }; // 函数表达式会当作变脸提升 foo(); // TypeError,此时foo值为undefined bar(); // ReferenceError,具名函数bar未在当前作用域 let foo = function bar() { console.log("foo"); } bar(); // ReferenceError,具名函数bar未在当前作用域
-
执行阶段:对标识符(变量)赋值。
-
-
提升时,函数重名后面替换前面,函数和变量重名函数替换变量。运行时,从前到后一行行执行,后面替换前面。
console.log(foo) // [Function: foo] function foo() { console.log("foo"); } var foo = 1 console.log(foo); // 1
-
if内的提升
变量和函数声明均会被当作变量提升。
console.log(foo); // undefined console.log(fooo); // undefined if (true) { function foo() { console.log("foo"); } var fooo = "fooo"; }
五、闭包
无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。
理解 模块机制的核心
可以深刻的了解!!!
-
闭包模拟模块
function People() { var name = "hang"; var playing = ["learn", "game"]; function printName() { console.log(name); } function printPlaying() { console.log(playing.join("-")) } return { printName: printName, printPlaying: printPlaying }; } var peo = People(); peo.printName(); // hang peo.printPlaying(); // learn-game
-
ES6模块化
有些人会说既然函数就可以实现模块化,为什么又会出现新的模块化机制呢?首先我们先了解基于函数的模块并不是一个能被静态识别的模块(编译器无法识别),它们的API只有在运行时才会被考虑。相比之下,ES6模块API是静态的,因此编译器可以在编译阶段检查对导入模块的API成员的引用是否真实存在,不存在抛出错误,而不会等到运行期再动态解析。
六、作用域总结
- 早期chrome引擎会将全部JavaScript编译(词法、语法、代码生成)生成可执行代码;在代码执行时遵循为标识符分配空间以及接下来的查找使用规则,相当于运行时生成作用域。
- 当一个函数被调用时:
- 预解析阶段(变量提升):生成当前词法作用域(词法亦指函数声明阶段即确认)及作用域链,向当前作用域中存储标识符;
- 执行阶段:通过作用域链查找变量,并对标识符赋值;
- 最终表现形式:[{
当前作用域
}, {上一级作用域
}, … , {全局作用域
}];
七、关于this
this
即不指向函数自身,也不指向函数的词法作用域,只取决于函数的调用方式。实际上this是在运行时进行绑定的,并不是在编写时绑定的。
当一个函数被调用时,会创建一个活动记录(执行上下文 / 作用域链)。包含:在哪里被调用(调用栈)、函数的调用方式、传入的参数、this等。
1.1 绑定规则
- 默认绑定:普通函数调用,foo()(定义时的函数体处于严格模式this为undefined、否则为全局对象)
- 隐式绑定:obj.foo()(foo中的this指向obj)、特例:回调函数中的this使用默认绑定。
- 显示绑定:apply、call、bind(绑定null、undefined会使用默认绑定规则)
- new绑定(构造函数):
- 申请一块内存区域(新对象);
__proto__
指向函数声明时的出现的prototype
;this
指向当前的新对象;- 执行函数内部代码,为当前对象赋值。如果函数没有返回值,那么会自动返回这个新对象;
1.2 绑定空对象
Object.create(null)
比{} “更空”,没有Object.prototype原型。是个很好的绑定空对象的方式。
1.3 箭头函数
-
箭头函数内部使用的this继承自外层作用域中的this;
-
没有prototype原型,所以没有构造函数constructor用于new对象;
八、对象
2.1 深拷贝
JSON.parse(JSON.stringify(obj));
2.2 浅拷贝
Object.assign({}, obj);
九、原型
JavaScript没有类都是对象,通过原型使对象与对象【引用类型】之间达到类继承的效果。
1.1 原型继承
function Parent(name) {
this.name = name;
}
Parent.prototype.myName = function() {
return this.name;
}
function Child(name, label) {
Foo.call(this, name);
this.label = label;
}
Child.prototype = Object.create(Parent.prototype);
Bar.prototype.myLabel = function() {
return this.label;
}
var a = new Child("a", "b"); // {name: "a", label: "b"}