- 前言 这一节的笔记主要内容是记录浏览器引擎对js脚本的解析与执行操作流程;变量与函数、对象等的声明提升;函数的闭包等!
你不知道的引擎机制!
- 这么说其实是不太准确的,我既不知道你知不知道,也不知道你不知道!也许你无意间已经在用这个机制,并不会出什么问题,但是,很多人都不清楚引擎到底在执行代码的背后做了什么?
js铁三角
引擎
从到位负责js代码的编译与执行工作;
编译器
引擎的兄弟,负责语法分析和检测代码等动作;
作用域
负责收集并管理所有的标识符变量、方法和对象!
编译器的执拗之心
编译原理
第一步:分词/词法分析阶段
这个过程会将由字符组成的字符串分解成有意义的代码块(即浏览器能够看懂的代码块,就像通过括号、中括号或者大括号却别代码块一样),这些代码块会被成为词法单元(token),像var a=2就会被分解成var、a、=、2、;、这几个部分。
var color = "blue";
function changeColor() {
var anotherColor = "red";
if(color=="blue"){
console.log();
}
] //Uncaught SyntaxError: Unexpected token ]
changeColor();
//这下知道这个token错误的来源了吧!符号不成对匹配呢!!!!
第二步:解析/语法分析
这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为抽象语法树(Abstract Syntax Tree,AST)。
第三步:代码生成
将AST的结果转换为可执行代码的过程。由于html和js代码的特殊地位,这个过程通常由浏览器自己决定那组机器指令结果,所以不同浏览器的差异就出来了!
引擎的前台幕后
那些年,引擎做的查询工作
在一门语言中引擎自然占据了不可或缺的地位,编译器在编译过程的第二步中生成了代码,引擎在执行它时,会通过查找变量a来判断它是否已声明过。在查找的过程中,作用域会协助引擎工作,但是引擎会怎么查找呢?
在一套机制下,引擎会进行一个叫做LHS和RHS的查询操作!
你们可以给自己打个预防针,把“L”看做left,“R”看做right,而且实际上他们在门面上也是这样查询的!但是我在这里更喜欢用另外的说法看着两个查询:LHS:list his source value(++列举/索引++他的源值)和RHS:retrieve his source value(++获取/取得++他的源值),这样说起来你就更形象了,不仅仅是声明等号的左右边关系,因为这涉及到了下面我将讲述的声明提升一大特性!
简单的提一下声明提升先!
所谓声明提升,你可以简单的把它看做为将变量标识符等和函数体对象体等分开,建立一个目录,使得这个目录可以别LHS查询找到,使得函数体和对象体被RHS查询找到;同时,声明提升会建立一个查询优先级,和css一样,优先级高的则被使用,而后面的,引擎根本”不会看一眼”!
LHS查询和RHS查询
- 我们来看下面这一个例子,对函数foo()的调用过程:
function foo(a){
console.log(a); //2
}
foo(2)
我们一步一步来看一下这个函数的调用是怎么实现的:
- 首先foo(2)是一个对函数foo()函数体的执行,因此要知道函数体是什么,所以,进行了RHS引用,查找到了函数体,并把参数2传递过去;
- 紧接着,函数foo()通过参数2对a进行赋值,赋值就需要知道目标是否存在,因此,引擎对a进行了LHS查询,在声明索引目录中查找变量a;
- 查找到变量a之后,将2赋值给a,因为2是个常量,所以并没有查询机制;
- 然后,执行log打印,在js里,所有的内置对象和函数都会在window这个大对象里进行RHS查询,找到这个函数的具体你执行体;
- 最后,在log里调用了变量a,右进行了一次RHS查询!
从上面一个流程里,大家可以看出,索引查询和源值查询之间冥冥中包含着变量是否声明?函数体在哪儿?你要使用变量吗?你要调用函数吗?你要给变量赋值吗?你要把函数赋给某个变量吗?等等一系列的暗语!“偏方”不在于多,适合自己的才是最好的!
那么我在这里给大家留一个思考题:
function foo(a){
var b=a;
return a+b;
}
var c=foo(2);
答案我将放在最后面!
声明提升
简单理解声明的重要性
- 声明提升作为js一大特性,它影响众多。第一,后声明的会覆盖前面的声明,这里着重变现在函数和对象方面,因为js里没有重载!在我的js03笔记中就有解释道js函数没有重载的问题;第二,影响调用关系等等,因为函数的声明有普通声明和表达式声明,而这两种声明方式恰巧影响到了声明提升位置!js里,函数和变量有着错综复杂的关系,而函数的表达式声明则是将函数当做成了变量来用,自然遵循变量声明的规则。
- 对类C语言有很多了解的人自然不会有多大陌生!尤其是C语言,一个面向过程的语言里,函数的声明顺序变得尤为重要!在C语言里,在在前面的函数是不能访问到后面的函数的,因此,要想前面的函数访问到后面的函数,则必须将后面的函数在前面进行声明,让前面的函数“看得见”!就像下面一样:
int fun(){
fun2();
}
int fun2(){
}
C语言是个强类型语言,甚至强到要个变量的类型定义字节大小!因此函数都会有一个类型声明或者void空类型声明,作为返回值的类型!如上:fun()函数是不能访问fun2()函数的,因为fun()根本看不见它,如果要让fun()函数能够访问fun2(),则必须在前面声明函数fun2(),如下:
int fun2();//这里就是提前声明,让fun2()能被fun()看见
int fun(){
fun2();
}
int fun2(){
}
js的声明提升
- 而js也是类似的,但是它智能的为你的函数等做了提升,减少了你的工作量!但是因为js里根本没有重载,所以声明提升的规则和函数声明的顺序变得尤为总要起来!
- 我们来看一下js里的声明提升的大致情况:这是个正常情况下的代码
var num1 = 1;
var num2 = 2;
var num3 = 3;
console.log(firstFunction(num1, num2)); //3
console.log(thirdFunction); /*ƒ thirdFunction(s1) {
return s1+ " s1打印了"
}*/
console.log(thirdFunction(str1)); //undefined s1打印了
console.log(secondFunction); /*f secondFunction(s2){
return s2+ " s2打印了" + num3;
}*/
console.log(secondFunction(str2)); //undefined s2打印了3
function firstFunction(a, b) {
return a + b;
}
console.log(thirdFunction(str1)); //undefined s1打印了
function thirdFunction(s1) {
return s1 + " s1打印了"
}
console.log(thirdFunction(str1)); //undefined s1打印了
var str1 = "first string";
var str2 = "second string";
function secondFunction(s2) {
return s2 + " s2打印了" + num3;
}
console.log(thirdFunction(str1)); //first string s1打印了
console.log(secondFunction(str2)); //second string s2打印了3
- 看到了上面的代码是不是觉得有那么一丝的不可思议!明明函数都已经被找到了,怎么就不能使用那些变量呢!我们来看一下另一个版本的代码:这是模仿声明提升后的代码
//声明区 索引目录
window
function firstFunction(a, b) {
return a + b;
}
function thirdFunction(s1) {
return s1 + " s1打印了"
}
function secondFunction(s2) {
return s2 + " s2打印了" + num3;
}
var num1;//= undefined;
var num2;// = undefined;
var num3;// = undefined;
var str1;// = undefined;
var str2;// = undefined;
//执行区 变量和函数的调用
num1 = 1;
num2 = 2;
num3 = 3;
console.log(firstFunction(num1, num2)); //3
console.log(thirdFunction); /*ƒ thirdFunction(s1) {
return s1+ " s1打印了"
}*/
console.log(thirdFunction(str1)); //undefined s1打印了
console.log(secondFunction); /*f secondFunction(s2){
return s2+ " s2打印了" + num3;
}*/
console.log(secondFunction(str2)); //undefined s2打印了3
console.log(thirdFunction(str1)); //undefined s1打印了
console.log(thirdFunction(str1)); //undefined s1打印了
str1 = "first string";
str2 = "second string";
console.log(thirdFunction(str1)); //first string s1打印了
console.log(secondFunction(str2)); //second string s2打印了3
我个人喜欢将引擎的操作分为声明区(用于存储所有的变量和函数对象等的声明,并建立一个LHS索引),执行区(用于存储所有的执行体和变量值,他们的顺序就是执行时候关系的关键地方!)
你不能忽略的地方!
但是又一个值得注意的细节就是,在函数和变量都有声明提升的同时,函数的提升具有极大的优先优势,就是函数通常会被提升到改作用域的变量声明的前方!就像函数声明提升了后才轮得到变量一样!
不一样的函数表达式声明提升方式!请看我后面的函数”覆盖”一小节
这里自然也有需要非常注意的地方:
第一:函数声明时的作用域关系中,函数的作用域只和声明时形成的作用域有关,所以并和后面执行时所显示的顺序没有关系,尤其注意在函数嵌套时,每个函数的局部作用域都有一套单独引擎声明提升机制,与外界互不干扰,因此,在声明时,局部作用域的函数和变量关系在定义当时就已经被确定,后面无法修改!这个和this有着恰好相反的区别,this在不同的地方代表着不同的对象,所以有着显绑定、隐绑定之说,这个我将在后面的笔记为大家详细讲述!
第二,因为这种机制在C语言的大型项目中经常用到,但是js的智能性原因,大家还是在调用某个函数时确定好变量或者函数等等已经具备你的需求!
第三:在非严格模式下,js的变量是宽松定义的,如果函数内部或者任何地方使用某变量时没有发现该变量,它会沿着作用域链向上(作用域的父级层次的作用域)查找该变量,一直到全局作用域为止,到此,不管找没找到都会返回该变量,因为js的机制会自动在全局创建一个同名的变量并给它赋一个undefined的值,使脚本不会因为这个错误而终止下去!所以,我再声明提升版本的代码中给每个提升后的变量都注释了一个undefined的值,因为这是引擎本来就会做的事!而对于这些undefined的值进行数值操作会返回NaN,进行字符串操作会返回(“”)空字符串。
第四:在js的项目中,你不需要想上面的声明提升一样书写你的代码,这样对于模块化、分区并不友好,即使它使得变量和函数等等变得更容易查询!
函数”覆盖”
- 在这个只能的编程语言里,是没有函数重载一说的,所以,函数最后表现的是谁将由声明提升的规则决定!看如下例子:
var num1 = 2;
var num2 = 6;
function clacNum() {
return num1 + num2;
}
function clacNum(num1, num2) {
return num1 * num2;
}
console.log(clacNum);
console.log(clacNum(num1,num2));
console.log(clacNum());
//打印结果为:
// ƒ clacNum(num1, num2) {
// return num1 * num2;
// }
// 12
// NaN
- 嗯~?这时有人会问:你看最后两个打印的结果不是不一样吗?这不是重载吗?这当然不是重载,我们仔细回想一下声明里的规则,两个函数声明都会被提升,但是拥有参数的第二个函数后出现,因此在声明里靠后,在逐行执行的时候最后被解析,因此,第二个函数的函数体将把第一个函数覆盖,第一个函数只是”昙花一现”而已!所以第一个打印打印出的函数体才是有参数的第二个函数体,之所以会出现打印结果不一样,是因为变量的读取顺序是按照作用域链逐层进行,函数体本应该接受一个参数,但你没有传进来,所以参数的值被赋为了undefined,而undefined在进行数值操作的时候回返回一个NaN(not a number)值。即便num1和num2都是全局变量,但是js的参数传递依旧仅仅遵循作用域链查找变量的规则!
var num1 = 2;
var num2 = 6;
function clacNum(num1, num2) {
console.log(typeof num1,typeof num2);
return num1 * num2;
}
console.log(clacNum);
console.log(clacNum());
//打印结果
/*
ƒ clacNum(num1, num2) {
console.log(typeof num1, typeof num2);
return num1 * num2;
}
undefined undefined
NaN
*/
- 那么为什么还会有函数的表达式声明方式呢?请看下面的例子:
var num1 = 2;
var num2 = 6;
var clacNum = function () {
return num1 + num2;
}
function clacNum(num1, num2) {
console.log(typeof num1, typeof num2);
return num1 * num2;
}
console.log(clacNum);
console.log(clacNum());
console.log(clacNum(num1,num2));
//来看这个打印结果
/*
ƒ() {
return num1 + num2;
}
8
8
*/
- 看到没有,函数表达式声明的函数把普通函数声明给覆盖了,为什么会造成反向覆盖呢?这是因为函数表达式的声明将函数当做了一个变量进行声明提升,而普通的函数声明提升会优先提升,而变量的提升在后面,就把它给覆盖了!看下面:
function clacNum(num1, num2) {
console.log(typeof num1, typeof num2);
return num1 * num2;
}
var num1;
var num2;
var clacNum = function () {
}
clacNum = function () {
return num1 + num2;
}
num1 = 2;
num2 = 6;
console.log(clacNum);
console.log(clacNum());
console.log(clacNum(num1, num2));
//来看这个打印结果
/*
ƒ() {
return num1 + num2;
}
8
8
*/
在声明提升的规则下,表达式的clacNum函数被当做成了变量对待,但是因为它本身是个函数会被先赋值为空的函数体,然后将你写的函数体赋值给它!
这样是不是就清晰很多了!结合前一节内容和这一节内容,相信函数、作用域和声明等应该有不一样的认识了!那么我们接下来就看看js一大头,闭包!
我们先公布上面留题的答案
- 3 LHS:c=…;、 a=2(隐式传参赋值)、b=…;
- 4 RHS:foo(2)函数执行对函数体的查询;、b=a对变量a的查询;、return a+b 对a的查询;、return a+b对b的查询;
闭包
- 首先,JavaScript中的闭包无处不在,可能你随时都在用,只是他认识你,你不认识他而已!
那么我们来理解一下什么是闭包:
当每一个函数在创建的时候,都有它自己包含的词法作用域,它的作用域是个局部作用域,只能被自己内部的成员访问,同时,局部作用域里的变量也只能被内部成员修改;当函数在调用结束,意味着生命周期结束,函数本省该被销毁,而内部的成员也应该被销毁时,内部的变量却被内部的函数调用而重新投入使用,达到生命周期延长的效果,而全局作用域或者外层作用域根本就什么也不知道,这样就形成了闭包!
那我们来看一个简单的例子:
function foo() {
var a = 2;
function bar() {
console.log(a);
}
return bar;
}
var baz=foo();
console.log(baz);
console.log(baz());
//执行结果
/*
ƒ bar() {
console.log(a);
}
2
undefined
*/
看上面的例子,函数结构我就不解释了,先看一下执行流程:
- 首先,变量baz在赋值的时候执行了foo()函数,foo()将函数bar()的函数体返回给了baz,baz变成了函数bar()的分身;
- 接着,console.log(baz())在打印的同时将函数baz()执行,也就是bar()执行;
- 然后,log(baz())和log(a)分别打印了undefined 和 2;不信你把a改成3试一试!
这就是一个闭包,长简单经典的一个闭包,这回我们来看一下怎么形成的闭包:
- 函数foo()第一次调用执行后将bar()函数体返回给了baz,但是函数执行后是会被销毁的啊,当然,如果这是最后一次调用这个函数了!
- 这时,这个细节就是foo()函数内部的变量a,和函数bar()都被销毁了,所以后面的打印会出现一个undefined,意味着内部的这个函数bar()已经不再了!
- 而闭包的神奇之处就在于,看似被销毁的一切的背后,foo()函数的内部作用域依然存活着!就因为bar()函数声明在了foo()内部!
- 你可以把foo()的局部作用域看成一个封闭的房子,房子里装着变量a和函数bar();return语句偷偷在房子里装了摄像头,然后把摄像头的线连到了baz家,baz就可以随时通过摄像头查看屋内的情况!这个摄像头就是指bar()函数!
我们可以看到打印baz()时确实是返回了undefined,但是baz()又确实获取到了foo()里的变量a的值并打印了出来,这,就是闭包!
块级作用域
咦,前一节里不是说了没有块级作用域吗?为什么还这么提呢?
没错,js的确没有块级作用域,在其他语言中,他们的语句(fi…else;for;while等等),里面定义的变量都会暴露在全局作用域下!但是这个作用域却又不一样的用法!
循环大法好,循环和闭包
- 在所有的开发者中,都会遇到一个不大不小的问题,那就是for循环的终值问题!就像下面:
for (var i = 0; i <= 5; i++) {
setTimeout(function timer() {
console.log(i);
},0);
}
// 毫无疑问是6个6
- 这不只是延迟函数的问题,还有很多的DOM操作遍历节点等等都会出现这样的”错误”,因为这些函数每次都会执行在for的循环之后,尽管执行的次数不会因此减少!
- 具体原因是因为:我们认为的每次循环,体部都会执行并获取一个i的值,但是,根据作用域的规则,且js也没有块级作用域,他们始终都被封闭在一个共享的全局作用域中,使得每次获得的i实际上都只是一个值,循环的终值!
- 那么我们的目的是啥?我们需要很多的作用域,闭包作用域,使得每一次循环都能让闭包作用域获取到该获得的那个i值!
解决办法:
- 创建变量或者自定义属性
for (var i = 0; i <= 5; i++) {
(function(j){
setTimeout(function timer() {
console.log(j);
}, j * 500);
})(i)
}
这里我么通过创建立即执行函数(IIFE模式)在每次循环的时候都会有一个封闭的局部作用域被创建,同时通过变量将i传给了j,达到每次循环都能获得对应值的效果。因为立即执行函数创建的作用域是封闭独有的,所以变量j你可以随意定义,就算改成i也没有关系!
这和闭包啥关系呢?你看定义的变量j,然后内部延迟函数的调用!这就是个闭包!
- 如果联合了let操作符呢?
for (var i = 0; i <= 5; i++) {
let j=i;
setTimeout(function timer() {
console.log(j);
}, j * 500);
}
for (let i = 0; i <= 5; i++) {
setTimeout(function timer() {
console.log(i);
}, i * 500);
}
- let是一个特殊的工具,尤其是在循环迭代里面,在每一次迭代循环里,let都会声明一次变量,而随后的每一次迭代都会使用上一次迭代结束时的值来初始化这个变量!
- 用你不知道的JavaScript上的一句原话说:
块作用域和闭包联手便可天下无敌!
其实还有很多和闭包相关联的技巧,但是作为教程文章,这里就不再描述,我会在后面的技术文章中更新这些技巧,请关注:会飞的小鹿