看不懂JS编译?让Demo教你啊!!!

1,199 阅读7分钟

背景

由于领导的梦想,最近开始分析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

  1. 在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); 

  1. 随后立马开启手动录制Performance,等待8s左右,停止
  2. 查看录制结果

分析结果

image.png

我们大致可以看到,就是有两块黄色,第二块明显长很多,按时间也可以知道短的(第一个)是2.js内容,长的是3.js内容,一个个分析

  • 2.j image.png 可以看出,这里的过程如下:
加载2.js
开始编译 2.js,确定模块引用关系(编译时长0.1ms)

加载1.js
编译1.js(编译时长21.5ms)

运行2.js(编译时长,很短)
  • 3.js image.png
加载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. 如果提前挂载了1.js,那加载2.js时也不应该出现1.js加载/编译时间
  2. 如果还有一个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

同上

分析结果

image.png 熟悉的一短一长,但明显短的更短,看分析

  • 2.js

image.png

加载2.js
开始编译 2.js(编译时长0.1ms)

运行2.js(编译时长,很短)
  • 3.js

image.png

加载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); 

分析结果

image.png

现在是1短2长,现在具体我就不贴图了,直接上时间

文件开始编译时长运行编译时长
2.js0.06ms0.02ms
3.js0.06ms52ms
4.js0.1ms50ms

函数只有在运行时才会被 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内容是不一样的

分析

image.png

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); 

分析

image.png

我们看到还是两个很长的JS时间,但仔细看第一个长块下方有个小块,具体看发现是compile code 时间

image.png

而第二个长块是没有 compile 时间的

image.png

文件运行编译时长
3.js51ms
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原理