Vite
在去年就已经出来了,但我真正的去了解它倒是在最近Vue Conf
上李奎关于Vite: 下一代web工具
的分享。其中他提到的几点吸引到了我。分享的开始,他简要说明了本次分享的关键点:html
其中的ESM
和esbuild
会在下文详细说明
接下来他提到了Bundle-Based Dev Server
。也就是咱们一直在用的webpack
的处理方式:前端
这里引用官网的一段话:vue
当咱们开始构建愈来愈大型的应用时,须要处理的JavaScript
代码量也呈指数级增加。大型项目包含数千个模块的状况并很多见。咱们开始遇到性能瓶颈 —— 使用JavaScript
开发的工具一般须要很长时间(甚至是几分钟!)才能启动开发服务器,即便使用HMR
,文件修改后的效果也须要几秒钟才能在浏览器中反映出来。如此循环往复,迟钝的反馈会极大地影响开发者的开发效率和幸福感。
简单总结下就是,若是应用比较复杂,使用Webpack
的开发过程相对没有那么丝滑:node
Webpack Dev Server
冷启动时间会比较长Webpack HMR
热更新的反应速度比较慢这就是Vite
出现的缘由,你能够把它简单理解为:No-Bundler
构建方案。其实正是利用了浏览器原生ESM
的能力。webpack
但首次提出利用浏览器原生ESM
能力的工具并不是是Vite
,而是一个叫作Snowpack
的工具。固然本文不会展开去对比Vite
与它的区别,想了解的可戳 Vite 与 X 的区别是?
到这里,我不由开始去想一个问题:为何Vite
这个工具能够出现,他又是基于哪些前提条件呢?git
带着这个问题,结合分享和Vite
的源码以及社区的一些文章,我发现了以下几个与Vite
能够出现密不可分的模块:github
ES Modules
HTTP2
ESBuild
这几块其实本身都听过,可是具体的细节也都没有深刻去了解。今天正好去深刻剖析一下。golang
ES Modules
在现代前端工程体系中,咱们其实一直在使用ES Modules
:web
import a from 'xxx' import b from 'xxx' import c from 'xxx'
多是过于日常化,你们早已习觉得常。但若是没有很深刻的了解ES Modules
,那么可能对于咱们理解现有的一些轮子(好比本文的Vite
),会有一些阻碍。算法
ES Modules
是浏览器原生支持的模块系统。而在以前,经常使用的是CommonJS
和基于 AMD
的其余模块系统 如 RequireJS
。
来看下目前浏览器对其的支持:
主流的浏览器(IE11 除外)均已经支持,其最大的特色是在浏览器端使用 export
、 import
的方式导入和导出模块,在 script
标签里设置 type="module"
,而后使用模块内容。
上面说了这么多,毕竟也只是ES Modules
的自我介绍
。一直以来,他就像黑盒
同样,咱们并不清楚内部的执行机制。下面就让咱们来一窥究竟。
咱们先来看一下模块系统的做用:传统script
标签的代码加载容易致使全局做用域污染,并且要维系一系列script
的书写顺序,项目一大,维护起来愈来愈困难。模块系统经过声明式的暴露和引用模块使得各个模块之间的依赖变得明显。
当你在使用模块进行开发时,实际上是在构建一张依赖关系图
。不一样模块之间的连线就表明了代码中的导入语句。
正是这些导入语句告诉浏览器或者Node
该去加载哪些代码。
咱们要作的是为依赖关系图指定一个入口文件。从这个入口文件开始,浏览器或者Node
就会顺着导入语句找出所依赖的其余代码文件。
对于 ES
模块来讲,这主要有三个步骤:
构造
。查找、下载并解析全部文件到模块记录中。实例化
。在内存中寻找一块区域来存储全部导出的变量(但尚未填充值)。而后让 export 和 import 都指向这些内存块。这个过程叫作连接(linking)。求值
。运行代码,在内存块中填入变量的实际值。在构造阶段,每一个模块都会经历三件事情:
Find
:找出从哪里下载包含该模块的文件(也称为模块解析)Download
:获取文件(从 URL 下载或从文件系统加载)Parse
:将文件解析为模块记录一般咱们会有一个主文件main.js
做为一切的开始:
<script src="main.js" type="module"></script>
而后经过import
语句去引入其余模块所导出的内容:
import
语句中的一部分称为 Module Specifier
。它告诉 Loader
在哪里能够找到引入的模块。
关于模块标识符有一点须要注意:它们有时须要在浏览器和Node
之间进行不一样的处理。每一个宿主都有本身的解释模块标识符字符串的方式。
目前在浏览器中只能使用 URL
做为 Module Specifier
,也就是使用 URL
去加载模块。
而有个问题也随之而来,浏览器在解析文件前并不知道文件依赖哪些模块,固然获取文件以前更没法解析文件。
这将致使整个解析依赖关系的流程是阻塞的。
像这样阻塞主线程会让采用了模块的应用程序速度太慢而没法使用。这是 ES
模块规范将算法分为多个阶段的缘由之一。将构造过程单独分离出来,使得浏览器在执行同步的初始化过程前能够自行下载文件并创建本身对于模块图的理解。
对于 ES
模块,在进行任何求值以前,你须要事先构建整个模块图。这意味着你的模块标识符中不能有变量,由于这些变量尚未值。
但有时候在模块路径使用变量确实很是有用。例如,你可能须要根据代码的运行状况或运行环境来切换加载某个模块。
为了让 ES
模块支持这个,有一个名为 动态导入
的提案。有了它,你能够像 import(${path} /foo.js
这样使用 import
语句。
它的原理是,任何经过 import()
加载的的文件都会被做为一个独立的依赖图的入口。动态导入的模块开启一个新的依赖图,并单独处理。
实际上解析文件是有助于浏览器了解模块内的构成,而咱们把它解析出来的模块构成表 称为 Module Record
模块记录。
模块记录包含了当前模块的 AST
,引用了哪些模块的变量,之前一些特定属性和方法。
一旦模块记录被建立,它会被记录在模块映射Module Ma
中。被记录后,若是再有对相同 URL
的请求,Loader
将直接采用 Module Map
中 URL
对应的Module Record
。
解析中有一个细节可能看起来微不足道,但实际上有很大的影响。全部的模块都被看成在顶部使用了 "use strict"
来解析。还有一些其余细微差异。例如,关键字 await
保留在模块的顶层代码中,this
的值是 undefined
。
这种不一样的解析方式被称为解析目标
。若是你使用不一样的目标解析相同的文件,你会获得不一样的结果。因此在开始解析前你要知道正在解析的文件的类型:它是不是一个模块?
在浏览器中这很容易。你只需在 script
标记中设置 type="module"
。这告诉浏览器此文件应该被解析为一个模块。
但在 Node
中,是没有 HTML
标签的,因此须要其余的方式来辨别,社区目前的主流解决方式是修改文件的后缀为 .mjs
,来告诉 Node
这将是一个模块。不过尚未标准化,并且还存在不少兼容问题。
到这里,在加载过程结束时,从普通的主入口文件变成了一堆模块记录Module Record
。
下一步是实例化此模块并将全部实例连接在一块儿。
为了实例化 Module Record
,引擎将采用 Depth First Post-order Traversal
(深度优前后序)进行遍历,JS
引擎将会为每个 Module Record
建立一个 Module Environment Record
模块环境记录,它将管理 Module Record
对应的变量,并为全部 export
分配内存空间。
ES Modules
的这种链接方式被称为 Live Bindings
(动态绑定)。
之因此 ES Modules
采用 Live Bindings
,是由于这将有助于作静态分析以及规避一些问题,如循环依赖。
而 CommonJS
导出的是 copy
后的 export
对象,这意味着若是导出模块稍后更改该值,则导入模块并不会看到该更改。
这也就是一般所见到的结论:CommonJS 模块导出是值的拷贝,而 ES Modules 是值的引用
。
最后一步是在内存中填值。还记得咱们经过内存链接好了全部 export
和 import
吗,但内存还还没有有值。
JS
引擎经过执行顶层代码(函数以外的代码),来向这些内存区添值。
至此ES Modules
的黑盒我就大体分析完了。
固然这部分我是参考了 es-modules-a-cartoon-deep-dive,而后结合本身的理解得出的分析,想更深刻了解其背后实现,可狠狠戳上面的连接。
ES Modules
在Vite
中的体现咱们能够打开一个运行中的Vite
项目:
从上图能够看到:
import { createApp } from "/node_modules/.vite/vue.js?v=2122042e";
与以往的import { createApp } from "vue"
不一样,这里对引入的模块路径进行了重写:
Vite
利用现代浏览器原生支持ESM
特性,省略了对模块的打包。(这也是开发环境下项目启动和热更新比较快的很重要的缘由)
HTTP2
来看HTTP2
前,咱们先来了解一下HTTP
的发展史。
咱们知道 HTTP
是浏览器中最重要且使用最多的协议,是浏览器和服务器之间的通讯语言。随着浏览器的发展,HTTP
为了能适应新的形式也在持续进化。
最开始出现的HTTP/0.9
实现相对较为简单:采用了基于请求响应的模式,从客户端发出请求,服务器返回数据。
从图中能够看出其只有一个请求行且服务器也没有返回头信息。
万维网的高速发展带来了不少新的需求,而 HTTP/0.9
已经不能适用新兴网络的发展,因此这时就须要一个新的协议来支撑新兴网络,这就是 HTTP/1.0
诞生的缘由。
而且在浏览器中展现的不单是 HTML
文件了,还包括了 JavaScript
、CSS
、图片、音频、视频等不一样类型的文件。所以支持多种类型的文件下载是 HTTP/1.0
的一个核心诉求。
为了让客户端和服务器能更深刻地交流,HTTP/1.0
引入了请求头和响应头,它们都是以 Key-Value
形式保存的,在 HTTP
发送请求时,会带上请求头信息,服务器返回数据时,会先返回响应头信息。HTTP/1.0
具体的请求流程能够参考下图:
HTTP/1.0
每进行一次 HTTP
通讯,都须要经历创建 TCP
链接、传输 HTTP
数据和断开 TCP
链接三个阶段。在当时,因为通讯的文件比较小,并且每一个页面的引用也很少,因此这种传输形式没什么大问题。可是随着浏览器普及,单个页面中的图片文件愈来愈多,有时候一个页面可能包含了几百个外部引用的资源文件,若是在下载每一个文件的时候,都须要经历创建 TCP链接
、传输数据
和断开链接
这样的步骤,无疑会增长大量无谓的开销。
为了解决这个问题,HTTP/1.1
中增长了持久链接
的方法,它的特色是在一个 TCP
链接上能够传输多个 HTTP
请求,只要浏览器或者服务器没有明确断开链接,那么该 TCP
链接会一直保持。而且浏览器中对于同一个域名,默认容许同时创建 6
个 TCP 持久链接
。
经过这些方式在某种程度上大幅度提升了页面的下载速度。
以前咱们使用Webpack
打包应用代码,使之成为一个bundle.js
,有一个很重要的缘由是:零散的模块文件会产生大量的HTTP
请求。而大量的HTTP
请求在浏览器端就会产生并发请求资源的问题:
如上图所示,红色圈起来的部分的请求就是并发请求,可是后面的请求就由于域名链接数已超过限制,而被挂起等待了一段时间。
在HTTP1.1
的标准下,每次请求都须要单独创建TCP
链接,通过完整的通信过程,很是耗时。之因此会出现这个问题,主要是由如下三个缘由致使的:
TCP
的慢启动TCP
链接之间相互竞争带宽前两个问题是因为TCP
自己的机制致使的,而队头阻塞是因为HTTP/1.1
的机制致使的。
为了解决这些已知问题,HTTP/2
的思路就是一个域名只使用一个 TCP 长链接来传输数据
,这样整个页面资源的下载过程只须要一次慢启动,同时也避免了多个 TCP
链接竞争带宽所带来的问题。
也就是常说的多路复用
,它能实现资源的并行传输。
上文中也提到了Vite
使用 ESM
在浏览器里使用模块,就是使用 HTTP
请求拿到模块。这样就会产生大量的HTTP
请求,但因为HTTP/2
的多路复用
机制的出现,很好的解决了传输耗时久的问题。
esbuild
官方的介绍:它是一个JavaScript Bundler
打包和压缩工具,它能够将JavaScript
和TypeScript
代码打包分发在网页上运行。esbuild
底层使用的golang
进行编写的,在对比传统web
构建工具的打包速度上,具备明显的优点。编译Typescript
的速度远超官方的tsc
。
对于JSX
、或者TS
等须要编译的文件,Vite
是用esbuild
来进行编译的,不一样于Webpack
的总体编译,Vite
是在浏览器请求时,才对文件进行编译,而后提供给浏览器。由于esbuild
编译够快,这种每次页面加载都进行编译的实际上是不会影响加速速度的。
结合上面的分析和源码,能够用一句话来简述Vite
的原理:Static Server + Compile + HMR
:
拦截部分文件请求
import node_modules
中的模块vue
单文件组件(SFC
)的编译WebSocket
实现HMR
固然关于相似手写Vite实现
的文章社区已经有不少了,这里就不赘述了,大体原理都是同样的。
本文写完带给我更多的是一些思考。从一次分享去发掘其背后庞大的生态体系以及那些咱们一直在用却并未深刻了解的技术黑盒
。
更多的是,感叹大佬们的想法,站在技术的制高点,拥有较高的深度和广度,开发一些对于提升生产力极其有用的轮子。
因此,文章写完了,学习的步伐任在前进~