背景
由于领导的梦想,最近开始分析JS编译的东西,本文主要从demo入手,概念部分会稍微涉及,更多的请search
概念
什么是编译时、运行时
- 编译:将源代码翻译成机器能识别的语言(二进制)。
编译器负责编译代码。在这个过程中会做一些简单的翻译工作,会进行词法分析,语法分析之类的。
编译器检查出来的错误就叫编译时错误,这个过程中做的类型检查也就叫编译时类型检查,或静态类型检查(此时没把真把代码放内存中运行起来,而只是把代码当作文本来扫描下)。 - 运行时:就是代码跑起来了,被装载到内存中去了。
代码保存在磁盘上没装入内存之前是个文件。只有跑到内存中才开始起作用。
而运行时类型检查就与前面讲的编译时类型检查(或者静态类型检查)不一样。不是简单的扫描代码.而是在内存中做些操作,做些判断以确定我们的程序是否存在错误。
解释型语言和编译型语言
- 编译型语言:程序在执行之前需要一个专门的编译过程,把程序编译成为机器语言的文件(即exe文件),运行时不需要重新编译,直接用编译后的文件(exe文件)就行了。
- 优点:执行效率高
- 缺点:跨平台性差
- 解释型语言:程序不需要编译,程序在运行的过程中才用解释器编译成机器语言,边编译边运行(没有exe文件)。
- 优点:跨平台性好、每个平台采用自有的编译机制
- 缺点:执行效率低、因为边编译边执行
ESModule
- ES6 模块的设计思想是尽量的静态化,它是一边编译一边执行,使得编译时就能确定模块的依赖关系、会检查引入变量/模块是否存在以及输入和输出的变量。
- CommonJS 和 AMD 模块,都是运行时加载,在编译阶段只判断语法层面对不对,如果引入了不存在的变量等只有在执行时才会发现。 。比如,CommonJS 模块就是对象,输入时必须查找对象属性。
// CommonJS模块
let { stat, exists, readFile } = require('fs');
// 等同于
let _fs = require('fs');
let stat = _fs.stat, exists = _fs.exists, readfile = _fs.readfile;
上面代码的实质是整体加载fs模块(即加载fs的所有方法),生成一个对象(_fs),然后再从这个对象上面读取3个方法。这种加载称为“运行时加载”,因为只有运行时才能得到这个对象,导致完全没办法在编译时做“静态优化”。
ES6 模块不是对象,而是通过export命令显式指定输出的代码,再通过import命令输入。不会加载整个fs。
// ES6模块
import { stat, exists, readFile } from 'fs';
上面代码的实质是从fs模块加载 3 个方法,其他方法不加载。这种加载称为“编译时加载”或者静态加载,即 ES6 可以在编译时就完成模块加载,效率要比 CommonJS 模块的加载方式高。当然,这也导致了没法引用 ES6 模块本身,因为它不是对象。
Demo
Demo -> 基础
code
这里写了三个js一个html
- 1.js
JS里写了一个特别长的for循环和一个普通方法,注意为了测试编译时长,所以不是简单把for循环次数增大,而是多写了很多代码。
function printA (){
console.log("A");
}
function printB (){
for(let i = 0;i< 1; i++) {
console.log(i);
}
for(let i = 0;i< 1; i++) {
console.log(i);
}
// .... 写了超级多for循环
for(let i = 0;i< 1; i++) {
console.log(i);
}
}
export { printA, printB};
- 2.js
import { printA } from "/WorkCode/tests/1.js";
printA();
- 3.js
import { printB } from "/WorkCode/tests/1.js";
printB();
- index.html
是一个"空"的html,2.js 和 3.js 都是通过动态加载script进去的
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
</head>
<body>
</body>
</html>
action
- 在cross浏览器(就是可以跨域的chrome,掘金搜一下配一个就行)下打开html,打开F12,然后在console里输入以下代码
setTimeout(function(){
var script = document.createElement("script");
script.setAttribute("src","./2.js"); script.setAttribute("type","module");
document.body.appendChild(script); },
5000); // 这里5s是留给自己的操作时间
setTimeout(function(){
var script = document.createElement("script");
script.setAttribute("src","./3.js"); script.setAttribute("type","module");
document.body.appendChild(script); },
7000);
- 随后立马开启
手动录制Performance,等待8s左右,停止 - 查看录制结果
分析结果
我们大致可以看到,就是有两块黄色,第二块明显长很多,按时间也可以知道短的(第一个)是2.js内容,长的是3.js内容,一个个分析
- 2.j
可以看出,这里的过程如下:
加载2.js
开始编译 2.js,确定模块引用关系(编译时长0.1ms)
加载1.js
编译1.js(编译时长21.5ms)
运行2.js(编译时长,很短)
- 3.js
加载3.js
开始编译 3.js,确定模块引用关系(编译时长0.1ms)
运行3.js(编译时长50.3ms)
- 分析
可以看出,虽然都 import 了 1.js ,但只有第一次 import 的时候加载了1.js并编译了其js主体部分。所以我们在后一张图中没发现加载和编译1.js的部分
而且,可以看到 2.js 和 3.js 代码量一致,但import的方法其大小差距很大(一个3行代码一个n行代码),所以在运行2.js和3.js时,所用的编译时长相差那么大,应该就是编译这两个print函数导致的。其实也就是运行到3.js的时候,发现要执行 printB 方法,这时候才去编译 printB 导致的
那么可以有以下猜想:
- 如果提前挂载了1.js,那加载2.js时也不应该出现1.js加载/编译时间
- 如果还有一个4.js,代码和3.js一样,那么它的编译运行时长应该和3.js差不多
我们来证明以上猜想
Demo -> 提前加载1.js
code
js代码同上,只需修改html
- index.html
html里已经加好了1.js,2.js 和 3.js 都是通过动态加载script进去的
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
</head>
<body>
<script src="./1.js" type="module"></script>
</body>
</html>
action
同上
分析结果
熟悉的一短一长,但明显短的更短,看分析
- 2.js
加载2.js
开始编译 2.js(编译时长0.1ms)
运行2.js(编译时长,很短)
- 3.js
加载3.js
开始编译 3.js(编译时长0.1ms)
运行3.js(编译时长50.8ms)
- 分析
结果和猜想一样,2.js 里并没有1.js的加载编译时长,那么:
多次导入同一module,module只会compile一次
Demo -> 新增4.js
code
增加 4.js,并且在1.js 里增加新方法 printC,其内容与 printB 一样
- 1.js
...
function printC (){
for(let i = 0;i< 1; i++) {
console.log(i);
}
for(let i = 0;i< 1; i++) {
console.log(i);
}
// .... 写了超级多for循环
for(let i = 0;i< 1; i++) {
console.log(i);
}
}
export { printA, printB, printC};
- 4.js
import { printC } from "/WorkCode/tests/1.js";
printC();
action
其他操作一致,就是定时器函数需要修改如下,并且等待录制的时间需延长(毕竟多了一个js嘛)
setTimeout(function(){
var script = document.createElement("script");
script.setAttribute("src","./2.js"); script.setAttribute("type","module");
document.body.appendChild(script); },
5000); // 这里5s是留给自己的操作时间
setTimeout(function(){
var script = document.createElement("script");
script.setAttribute("src","./3.js"); script.setAttribute("type","module");
document.body.appendChild(script); },
7000);
setTimeout(function(){
var script = document.createElement("script");
script.setAttribute("src","./4.js"); script.setAttribute("type","module");
document.body.appendChild(script); },
13000);
分析结果
现在是1短2长,现在具体我就不贴图了,直接上时间
| 文件 | 开始编译时长 | 运行编译时长 |
|---|---|---|
| 2.js | 0.06ms | 0.02ms |
| 3.js | 0.06ms | 52ms |
| 4.js | 0.1ms | 50ms |
函数只有在运行时才会被 compile
Demo -> 测试1.js编译时长
我们顺便看下,1.js在export不同数量函数时,本身编译时长有何改变。
code
- 1.js 旧的1.js 就只有 printA 和 printB 函数,新的多了 printC,代码在前两个demo里
- index.html html里已经加好了1.js
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
</head>
<body>
<script src="./1.js" type="module"></script>
</body>
</html>
action
在cross浏览器下打开html,打开F12,然后自动录制Performance
注意两次录制的1.js内容是不一样的
分析
| 1.js | 编译时长 |
|---|---|
| 旧(A、B) | 23.6ms |
| 新(A、B、C) | 45ms |
方法JS本身代码量太多,即使不执行,compile time也会变长
Demo -> 相同方法执行是否缓存?
code
4.js改为和3.js一样
- 4.js
import { printB } from "/WorkCode/tests/1.js";
printB();
action
其他操作一致,就是定时器函数需要修改如下,不加载2.js(没必要)
setTimeout(function(){
var script = document.createElement("script");
script.setAttribute("src","./3.js"); script.setAttribute("type","module");
document.body.appendChild(script); },
7000);
setTimeout(function(){
var script = document.createElement("script");
script.setAttribute("src","./4.js"); script.setAttribute("type","module");
document.body.appendChild(script); },
13000);
分析
我们看到还是两个很长的JS时间,但仔细看第一个长块下方有个小块,具体看发现是compile code 时间
而第二个长块是没有 compile 时间的
| 文件 | 运行编译时长 |
|---|---|
| 3.js | 51ms |
| 4.js | 几乎为0 |
多次调用同一方法,该方法只会在第一次调用时compile
Demo -> 相同方法不同入参执行是否缓存?
code
将printB方法改为需要入参的
// 1.js
function printB (j){
for(let i = 0;i< 1; i++) {
console.log(j);
}
for(let i = 0;i< 1; i++) {
console.log(j);
}
// .... 写了超级多for循环
for(let i = 0;i< 1; i++) {
console.log(j);
}
}
export { printA, printB};
- 3.js
import { printB } from "/WorkCode/tests/1.js";
printB("3");
- 4.js
import { printB } from "/WorkCode/tests/1.js";
printB("4");
action
同上
分析
结果同上
带不同入参多次调用同一方法,该方法只会在第一次调用时compile
V8
从上一个 Demo 可看出,编译其实是有缓存,那这到底是什么呢?我们将目光投向 chromium V8。关于这块还在了解中,主要是 JIT 技术,所以单独撸了一篇V8引擎与JIT原理