在chrome的开发者工具中,通过断点调试,我们可以非常方便的一步一步的观察JavaScript代码在执行过程中的细节变化。我们能够直观的感知函数调用栈、变量对象、作用域链、闭包、this等关键信息的变化过程。因此断点调试对于快速定位代码错误,了解代码执行过程有着非常重要的作用,这也是我们前端开发必不可少的一个高级技能。
函数在被调用执行时,会创建一个当前函数的执行上下文。在该执行上下文的创建阶段,变量对象、作用域链、闭包、this等会分别确认。而一个程序中一般来说会有多个函数执行,因此执行引擎会使用函数调用栈来管理这些函数的执行顺序。函数调用栈的执行顺序与栈数据结构一致。
尽量在最新版本的chrome浏览器中(不保证老版本的chrome浏览器与我的一致),调出chrome浏览器的开发者工具。并根据如下顺序打开如下界面。
浏览器右上角竖着的三点 -> 更多工具 -> 开发者工具 -> Sources
为了让大家更加清晰的明白我们需要重点关注的地方在哪里,我用箭头把他们标注出来。
左侧这个箭头指向的区域,表示代码的行数,当我们点击某一行时,就可以在该行设置一个断点。
右侧第一个箭头指向的区域有一排图标。我们可以通过这一排图标来控制函数的执行进程。从左到右他们一次是:
**resume/pause script execution**
恢复/暂停脚本执行
**step over next function call**
跨过。实际表示是不遇到函数时,执行下一步。遇到函数时,不进入函数直接执行下一步。
step into next function call
跨入。实际表现是不遇到函数时,执行下一步。遇到函数时,进入函数执行上下文。
step out of current function
跳出当前函数
deactivate breakpoints
停用断点
don‘t pause on exceptions
不暂停异常捕获
其中跨过、跨入、跳出是在调试过程中使用最多的三个操作。
上图中右侧第二个箭头所指的区域为Call Stack(当前所处执行上下文的函数调用栈)。
上图中右侧第三个箭头所指的区域为Scope。即为当前函数的作用域链。其中Local
表示当前正在执行的活动对象,Closure
表示闭包。
因此我们可以借助此处作用域链的展示,观察到谁是闭包,对于闭包的深入了解有非常大的帮助作用。
在显示代码行数的区域点击,即可在代码对应行数所在的地方设置一个断点。设置断点后刷新页面,代码会执行到断点位置处暂停,这时我们就可以通过上面介绍过的几个图标操作一步一步调试我们的代码了。
chrome中,在单独的变量声明(没有赋值操作)与函数声明的那一行,无法设置断点。
接下来为了进一步掌握断点调试,我们借助一些实例。通过断点调试来观察一下这些代码的执行过程。这里主要以闭包的例子来展示。
// demo01
var fn;
function foo() {
var a = 2;
function baz() {
console.log( a );
}
fn = baz;
}
function bar() {
fn();
}
foo();
bar(); // 2
我们可以先思考这个例子中闭包是如何产生的。然后通过调试来验证自己的想法是否正确。
很显然,fn在foo或者了foo内部函数baz的引用。因此当fn执行时,其实就是baz执行。而baz在执行时访问了foo中的变量,因此闭包产生。在chrome中,用foo来指代生成的闭包。
第一步:设置断点,然后刷新页面。
经过简单的分析可以看出,代码是从foo()这一行开始执行的,因此在这里设置一个断点。
第二步:点击上图箭头所指的图标(step into),该按钮的作用会根据代码的执行顺序,一步一步向下执行,当遇到函数时,跳入函数执行。多次点击,直到baz函数执行,如图所示:
我们应该关注代码执行过程中各个变量的变化情况,以及Call Stack与Scope的变化。当执行到baz函数时,Call Stack与Scope如上图所示。如果大家对前面的知识有足够的了解,就应该明白函数调用栈的不同就应该如此。而作用域链则不会因为闭包发生变化。
而我们还需要关注的一个点在于chrome对于作用域链所做的一个优化。
在《JavaScript高级编程3》中对于闭包有这样一段描述,“闭包所保存的是整个变量对象,而不是某个特殊的变量”。
在上面的例子中我们可以很明显的知道,在函数foo的变量对象中,应该保存有一个变量a与一个函数baz。但是从图中我们可看出,Closure(foo)
并没有函数baz,仅仅只有变量a。
其实这是chrome新版本对于闭包与作用域链所做的一个优化,它仅仅只保留了会被访问到的变量。我们可以用下面的例子来进一步证明这一点。
// demo02
function foo() {
var x = 20;
var y = 10;
function child() {
var m = 5;
return function add() {
var z = 'this is add';
return x + y;
}
}
return child();
}
foo()();
上面这个例子中,我们通过前面所学到的知识来思考一下函数add在执行时它的作用域链应该是怎么样的?
几秒钟之后就能思考出来应该是这样的对吧。
addEC = {
scopeChain: [AO(add), VO(child), VO(foo), VO(Global)]
}
可是chrome中的表现是什么呢?我们来看一下。
咦!中间的VO(child)
直接被省略了。这正是chrome的优化。因为child函数中的变量m与函数add在add的执行上下文中并没有被访问,因此就没有保留在内存中的必要。
同样的例子在firefox中调试,它已经明确的指出了那些已经优化掉的变量与函数。如下图。但是通过对比我们发现,chrome的优化其实更进一步。
在firefox中,仅仅只是对没有访问到的变量对象进行了优化,而访问到的变量对象中,未被访问到的变量并没有优化。而chrome中,未访问到的变量同样进行了优化。
这里需要注意的是arguments对象,在chrome中,是将参数展开显示在变量对象中,而firefox中,则是直接保存的是一个arguments对象。具体的通过之前的例子与图示可以区分。
接下来我们再来看一个特殊的例子。
// demo03
function foo() {
var a = 10;
function fn1() {
console.log(a);
}
function fn2() {
var b = 10;
console.log(b);
}
fn2();
}
foo();
我们再花几秒钟思考一下,当函数fn2执行时,这里例子中有没有闭包产生?
从图中可以看出,确实是产生闭包了。
在最新的MDN中,对闭包这样定义(结合上例):“闭包是指这样的作用域(foo),它包含有一个函数(fn1),这个函数(fn1)可以调用被这个作用域所封闭的变量(a)、函数、或者闭包等内容。通常我们通过闭包所对应的函数来获得对闭包的访问。”
这里的定义其实是包含了这种特殊情况,fn1与foo共同产生了闭包,但是fn1并没有任何地方可以调用它。在实际开发中我们几乎不会这样使用。因此如果在面试中被问及知道如何回答即可。