我们都知道,JavaScript 是一种描述型脚本语言,它不同于 Java 或 C# 等编译性语言,它不需要进行编译成中间语言,而是由浏览器进行动态地解析与执行。如果你不能了解浏览器是如何工作的,不能了解 JavaScript 的执行顺序,那你就犹如伯乐驾驭不了千里马。
1. 术语
在了解 JavaScript 执行顺序之前,我们先来认识几个重要的术语:
1.1 代码块
JavaScript 中的代码块是指由 <script> 标签分隔的代码段。JavaScript 是按照代码块来进行编译和执行的,代码块之间相互独立的,但是变量和函数可以共享。举个粟子:
1
2
3
4
5
6
7
8
9
10
11
12
<script type="text/javascript">
/* 代码块一 */
var val = "variable in first block"; // 定义变量
console.log(info); // 因为没有定义 info,浏览器会出错,下面的语句都不能运行
console.log("first block");
</script>
<script type="text/javascript">
/* 代码块二 */
console.log("second block...");
console.log(val);
</script>
运行结果:
1
2
3
Uncaught ReferenceError: info is not defined
second block...
variable in first block
从结果中可以看出,在代码块一中运行报错,则报错行后的剩余部分不再执行,但它不会影响代码块二的执行,这就是代码块间的独立性,而代码块二中能调用到代码一中的变量 val,则是块间共享性。
1.2 函数声明与函数表达式
我在 JavaScript 函数入门 一文中已经介绍了函数声明与函数表达式:
1
2
3
4
5
6
7
8
9
10
11
// 函数声明
function functionName([param1[, param2]...) {
// function_body;
// [return exp;]
}
// 函数表达式
var func = function([param1[, param2]...){
// function_body;
// [return exp;]
}
函数声明与函数表达式的区别在于:在 JS 的预编译期,函数声明将会先被提取出来,然后再按顺序执行 JS 代码。
1.3 预编译期与执行期
预编译期 JavaScript 会对本代码块中所有声明的变量和函数进行处理(类似与 C 语言的编译),但需要注意的是此时处理函数的只是声明式函数,而且变量也只是进行了声明但未进行初始化以及赋值。细节可参考 【翻译】JavaScript Scoping and Hoisting。
执行期当然就是负责 JS 代码块的执行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script type="text/javascript">
console.log(str);
var str = "aaa";
func();
// 声明函数
function func(){
console.log("执行了声明式函数");
}
// 函数表达式
var func = function(){
console.log("执行了赋值式函数");
}
</script>
执行结果:
1
2
undefined
执行了声明式函数
以上结果是因为在预编译期,变量名称 str、func 和函数声明都做了提升 (hoisting),但因为函数声明优先级高于变量声明,所以调用 func()
时仍然调用到了命名函数来执行。
我们再来看一段代码:
1
2
3
4
5
6
7
8
9
<script type="text/javascript">
func();
</script>
<script type="text/javascript">
function func(){
console.log("hello.......");
}
</script>
这段代码执行后的结果是什么呢?Uncaught ReferenceError: func is not defined
。报错了!!!
为什么运行上面的代码浏览器会报错呢?声明函数不是会在预处理期就会被处理了吗,怎么还会找不到 func() 函数呢?其实这是一个理解误点,我们上面说了 JS 引擎是按照代码块来顺序执行的,其实完整的说应该是按照代码块来进行预处理和执行的,也就是说预处理的只是执行到的代码块的声明函数和变量,而对于还未加载的代码块,是没法进行预处理的,这也是边编译边处理的核心所在。
2. 执行顺序
有了以上的知识点铺垫,我们来总结一下 JavaScript 的执行顺序:
step 1. 读入第一个代码块。
step 2. 做语法分析,有错则报语法错误(比如括号不匹配等),并跳转到step5。
step 3. 对 va r变量和 function 定义做“预编译处理”。
step 4. 执行代码段,有错则报错(比如变量未定义)。
step 5. 如果还有下一个代码块,则读入下一个代码块,重复step2。
step 6. 结束。
3. 发散:JavaScript 脚本引入
当浏览器遇到 <script>标签(内嵌或是外链 JavaScript 文件)时,因为无从获知 JavaScript 是否会修改页面内容,因此,这时浏览器会停止处理页面,先执行 JavaScript 代码,然后再继续解析和渲染页面。在外链 JavaScript 文件时,浏览器必须先花时间下载文件中的代码,然后解析并执行它。在这个过程中,页面渲染和用户交互完全被阻塞了,细节可参考 浏览器是如何工作的?。
常见在 HTML 页面中引入 JavaScript 脚本的做法有:
惯例的做法
最传统的方式是在 head 标签内插入 <script>标签:
然而这种常规的做法却隐藏着严重的性能问题。根据上述对 <script> 标签特性的描述,我们知道,在该示例中,当浏览器解析到 <script> 标签时,浏览器会停止解析其后的内容,而优先下载脚本文件,并执行其中的代码,这意味着,其后的 *.css 样式文件和 <body> 标签都无法被加载,由于 <body> 标签无法被加载,那么页面自然就无法渲染了。因此在该 JavaScript 代码完全执行完之前,页面都是一片空白。
经典的做法
既然 <script> 标签会阻塞其后内容的加载,那么将 <script> 标签放到所有页面内容之后不就可以避免这种糟糕的状况了吗? 将所有的 <script> 标签尽可能地放到 <body> 标签底部(即 </body> 之前),以尽量避免对页面其余部分下载的影响。实际开发中,这种方式使用最多。
动态脚本
通过文档对象模型(DOM),我们可以几乎可以页面任意地方创建脚本:
1
2
3
4
var _script = document.createElement("script");
_script.type = "text/javaScript";
_script.src = "tools.js";
document.getElementsByTagName("body")[0].appendChild(_script);
上述代码动态创建了一个外链文件 tools.js 的 <script> 标签,并将其追加到 <body> 标签内。这种技术的重点在于:无论在何时启动下载,文件的下载和执行过程不会阻塞页面其他进程(包括脚本加载)。
当然这种做法也存在问题:这种方法加载的脚本会在下载完成后立即执行,那么意味着多个脚本之间的运行顺序可能是无法保证的,当某个脚本对另一个脚本有依赖关系时,就很可能发生错误了。如何解决这个问题,让我们留待后继来解决吧……