CommonJS和UMD兼容性
在开发阶段中,vite的开发服务器将所有的代码视为原生ES模块。因此,vite必须先将作为CommonJS或者UMD发布的依赖项转换为ESM。
这是vite的一个特色,也是为什么会相对于webpack比较快的原因,我们从原理上来看一下为什么需要转为ESM。
webpack是为了兼容性的考虑,支持多种模块化,当我们使用webpack启动项目的时候,webpack会根据我们配置文件(webpack.config.js)中的入口文件(entry),分析出项目所有的依赖关系,然后打包成一个文件(bundle.js),然后再启动开发服务器。
这样带来的问题就是,项目越大,需要打包的东西就越多,启动的时间越长。
目前绝大多数现代浏览器都已经支持ES module了,浏览器遇到在遇到内部的import引用的时候,会自动发起http请求,去加载对应的模块,vite也是借助了浏览器的这个特性,只接受ESM规范,在代码中不可以使用CommonJS,那么这个时候就不需要再去遍历依赖文件夹了,直接启动开发服务器,然后在浏览器请求依赖的时候,请求到哪个依赖,再根据需要对模块进行实时编译。这种动态编译的方式在项目越大的时候优点越明显。
依赖预构建仅仅适用于开发模式,并使用esbuild将依赖项转换为ES模块。在生产构建中,将使用@rollup/plugin-commonjs。
性能
vite将很多内部模块的ESM依赖关系转换为单个模块,以提高后续页面加载性能。
一些包将它们的 ES 模块构建作为许多单独的文件相互导入。例如,lodash-es 有超过 600 个内置模块!当我们执行import { debounce } from ‘lodash-es’ 时,浏览器同时发出 600 多个 HTTP 请求!尽管服务器在处理这些请求时没有问题,但大量的请求会在浏览器端造成网络拥塞,导致页面的加载速度相当慢。
缓存判断
默认的vite预构建产物缓存目录是node_modules/.vite/deps,在缓存目录中找到_metadata.json并进行解析读取,将_metadata.json文件中解析出的hash值与vite根据项目中包含的锁文件以及相关配置项信息进行hash得到的值进行比较。如果相同,则说明上次预构建产物结果无需进行变更,跳过预构建流程。如果不相同,则表示关联的依赖信息有更新或已过期,需要进行依赖预构建。
怎么去计算hash值呢?
根据几个源来决定是否需要重新运行预构建步骤:
依赖扫描
如果没有找到符合的预构建产物缓存,vite将为预构建行为进行源码扫描,从代码中找到需要预构建的依赖,最终返回一个类似于下面这样的deps对象:
{
'vue': '/path/to/your/project/node_modules/vue/dist/vue.runtime.esm-bundler.js',
'element-plus': '/path/to/your/project/node_modules/element-plus/es/index.mjs',
'vue-router': '/path/to/your/project/node_modules/vue-router/dist/vue-router.esm-bundler.js'
}
具体实现就是,调用esbuild的build api,以index.html作为查找入口,将所有的来自node_modules以及在配置文件的optimizeDeps.include选项中指定的模块找出来。但是熟悉esbuild的同学都知道,esbuild默认支持的入口文件类型有js、ts、jsx、css、json、base64、dataurl、binary、file,并不包含html。vite是如何将index.html作为入口文件的呢?原因是vite自己实现了一个esbuild插件esbuildScanPlugin,来处理.vue和.html这种类型的文件。具体做法就是读取html的内容,然后将里面的script提取到一个esm格式的js模块。
问题又来了:在扫描的过程中,所有遇到的import 都会被预构建吗?
并不是的,预构建只会处理裸依赖,什么是裸依赖呢?
// 以名称导入即为裸依赖
import xxx from 'vue'
// 以路径导入则不是裸依赖
import xxx from "./a.ts"
Vite也是这样判断的。为什么要处理裸依赖呢?一方面是因为在浏览器环境下,是不支持这种裸模块引用的。另一方面,如果不进行构建,浏览器面对成千上万的子模块组成的依赖,依靠原生esm的加载机制,每个依赖的import都将产生一次http请求,所以,需要对裸模块引入进行打包,并处理成浏览器支持的路径导入方式。
依赖打包
到上一步,已经得到了需要预构建的依赖列表,把他们交给esbuild打包就行了。
我们回忆一下,在webpack中,是如何去处理文件的,是通过loader,通过各种loader去读取各种文件,比如css-loader、style-loader处理css文件,因为webpack遵循的是CommonJS规范,并且只能识别js文件,所以就需要各种loader来做代码的转换,loader的本质就是一个函数,最后返回的也是一段可执行的javascript代码。但是vite呢?
我们发现在head标签里新建了一个style标签,我们的样式被写进了标签里面,vite对css的处理就是通过创建一个新的style标签,在页面引入样式文件来处理的。需要注意的是:vite会把css文件转化为js脚本文件,设置Content-Type:“text/javascript”来告知浏览器应该以javascript的方式来解析css脚本。这样做的好处有三点:
// a.module.css
.wrapper {
font-size: 20px;
color: blue
}
// b.module.css
.wrapper {
font-size: 100px;
color: red
}
//main.js
import AModuleCss from './a.module.css'
import BModuleCss from './b.module.css'
const ele1 = document.createElement('div');
ele1.className = AModuleCss.wrapper;
ele1.innerHTML = 'aaaaaa';
document.body.append(ele1)
const ele2 = document.createElement('div');
ele2.className = BModuleCss.wrapper;
ele2.innerHTML = 'bbbbb';
document.body.append(ele2)
我们发现,同样的类名,却只对自己作用域下的元素生效,并且,样式会被处理成key:value的形式,以类名作为键名,处理后的类名作为键值。
真实的类名会被替代为处理后的类名。
原理:
1、对css文件取名为xxx.module.css (module是一种约定,表示需要开启css模块化)
2、vite会将我们所有的类名进行一定规则的替换,(将footer替换为_footer_i22st_1)
3、同时创建一个映射对象{footer:“_footer_i22st_1”}
4、将替换过后的内容塞进style标签里然后放入到head标签中
5、将xxx.module.css内容进行全部抹除,替换成js脚本
6、将创建的映射对象在脚本中进行默认导出
静态资源处理
在vue中,静态资源分为两种:
1.存放在public中的静态资源,这部分资源会被完全的复制,不会经过构建工具处理,可以通过根绝对路径来引用他们,(即使没有被用到,也不会被tree shaking掉,会直接被完全复制)
2.存放在assets目录下,属于代码的一部分,只支持相对路径,会被构建工具打包,并且和样式会被压缩打到一起,可以避免额外的网络请求。
官方文档中有这样一句话:
public 中的资源不应该被 JavaScript 文件引用。
我们试着同时引入public、assets文件下的图片,看一下会有什么样的不同:
我们可以看到以import方式引入的图片,都会被处理成xxx?import请求,返回解析后的代码,得到的是一个可访问的url,但是public里的文件是不需要的,可以直接拿到图片。
那么假如我们在javascript文件中以import的方式去请求public中的静态资源,像这样
我们看到原本直接可以访问到的public静态资源也被处理了一次,那么这一次就有些多余了。所以不建议在javascript文件中引入public资源。
那么是如何实现的这样的差别化请求呢?
当请求资源的时候,首先会判断是否是import的资源:
export function servePublicMiddleware(){
return viteServePublicMiddleware(req,res,next){
const url = req.url;
//如果是import的资源会继续后续中间件transform的处理
if(isImportRequest(req.url)){
return next();
}
//public静态文件会直接返回
serve(req,res,next);
}
}
是import的资源,会继续importAnalysis插件中定义的transform方法。
importAnalysis:是vite内置的一个非常重要的插件,它的作用:
1、解析请求文件中的导入,是否存在,并且将相对路径重写为绝对路径 2、如果导入的模块需要更新,会在导入URL上挂一个参数,从未强制浏览器更新
3、对于引入 CommonJS 转成 ESM 的模块,会注入一段代码,以支持获取模块内容 4、更新 Module
Graph,以及收集请求文件接收的热更新模块 5、如果代码中环境变量import.meta.env,注入import.meta.env定义
async transform(source, importer, ssr) {
// 用?import标识非 js/css 的 import 依赖
url = markExplicitImport(url)
}
//判断是否是js请求或者css请求
function isEcplicitImportRequired(){
return !isJSRequest(cleanUrl(url)) && !isCSSRequest(url)
}
function markExplicitImport(url){
if(isEcplicitImportRequired(url)){
//如果不是就加上query=import
return injectQuery(url,'import');
}
}
我们通过ESM的方式导入静态资源,但是vite对他们的处理方式并不同,
比如:
css、js文件被直接输出内部定义的内容,json被转为了对象形式,而url和mp4被输出他们文件的url地址。
当然,有一些场景需要的时候,我们可以通过一些手段来改变他们的输出方式,
// vite.config.ts
{
assetsInclude: ['.gltf']
}
在介绍热更新之前,我们先来思考一下,从修改代码到界面更新,这个过程发生了什么?
1、修改代码
2、重新编译(怎么编译?)
3、告诉前端要更新啦(怎么告诉?)
4、前端执行热更新代码进行热更新(怎么更新?)
实际上也就是这几个步骤:
Vite server:指vite在开发时启动的server
什么是热更新边界?
比如说两个文件,a.ts被import进b.vue
(vue文件自带了热更新逻辑,但是ts文件是没有热更新逻辑的)
那么当a.ts改变的时候,怎么样进行热更新呢?b.vue作为最近的可接受热更新的模块,就被称为热更新边界。
沿着依赖树,往上找到最近的一个可以热更新的模块,即热更新边界,对其进行热更新即可。
websocket是什么时候创建的呢?
Vite server 会在index.html中注入@vite/client的脚本,当访问到index.html的时候,就会拉取这个脚本。
handleMessage主要负责根据不同的类型执行不同的操作,在热更新里会触发handleMessage里的update操作,告诉client需要更新的模块。
当client拿到需要更新的模块的时候,会进行老代码的退出和新代码的更新,这里涉及到了hot.dispose和hot.accept两个API。
accept用于传入一个回调函数,来定义该模块修改之后,需要怎么去热更新。
if (import.meta.hot) {
// 调用的时候,调用的是老的模块的 accept 回调
import.meta.hot.accept((mod) => {
// 老的模块的 accept 回调拿到的是新的模块
mod.render();
});
}
dispose 类似 hot,只是 dispose 定义的是老模块如何退出,而 hot 定义的是新模块如何更新。
在vite中,热更新是开箱即用的,vite 的 vite-plugin 插件,在编译模块时加入了 vue 热更新的代码。
vite 本身只提供热更新 API,不提供具体的热更新逻辑,具体的热更新行为,由 vue、react 这些框架提供。
语言优势
大多数前端打包工具是基于javascript实现的,而Esbuild选择使用Go语言编写。
在CPU密集的场景下,Go更加具有性能优势。
因为javascript本质上是一门解释型的语言,每次执行的时候都会先将源码翻译成机器码,一边调度执行;而Go是一种编译型语言,在编译阶段就已经将源码转移成机器码,启动的时候只需要直接执行这些机器码即可。也就意味着,Go语言编写的程序比js少了一个动态解释的过程。
多线程优势
Go天生具有多线程运行能力,而javascript本质上是一门单线程语言,直到引入WebWoker才可能在浏览器/node中实现多线程。
Rollup/Webpack并没有使用WebWoker提供的多线程能力,而ESbuild的实现尽可能的使用各个CPU核,特别是打包过程的解析/代码生成阶段已经实现完全并行的处理。
并且Go语言多个线程之间还能共享相同的内存空间,而javascript的每个线程都有自己的内存堆,这意味着Go中多个处理单元,例如解释资源A的线程,可以直接读取资源B线程的运行结果,而js中相同的操作需要调用通讯接口woker.postMessage在线程间复制数据。
节制
ESbuild仅仅提供了构建一个现代Web应用所需的最小功能的集合
从上面可以知道,虽然esbuild快的惊人,但是因为一些重要功能的缺失–特别是代码分割和 CSS
处理方面,并不适合作为生产环境的打包工具。vite在生产环境用了rollup,vite需要保证,同一套配置,在开发环境和生产环境下的表现是一致的。想要达到这个效果,vite只能在开发环境模拟RollUp的行为,然后在生产环境打包的时候,将这部分替换成Rollup打包。
生产环境:vite直接调用了Rollup进行打包
开发环境:vite模拟了Rollup的插件机制,通过PluginContainer插件容器对象调度执行各个插件。
实现思想
实现Rollup的插件行为,实际上是实现相同的插件钩子行为。
插件钩子是在构建不同阶段调用的函数。钩子可以影响构建的行为、提供有关构建的信息或在构建完成后修改构建。
钩子行为,主要包括以下内容:
什么是钩子的调度?
按照一定的规则,在构建对应的阶段,执行对应的钩子。
举个栗子:当 Rollup 开始运行时,会先调用 options 钩子,然后是 buildStart
什么是钩子的 Context 上下文对象?
在 Rollup 的钩子函数中,可以调用 this.xxx 来使用一些 Rollup 提供的实用工具函数,而这个 this 就是钩子的 Context 上下文对象。Vite 需要在运行时,实现一套相同的 Context 上下文对象,才能保证插件能够正确地执行 Context 上下文对象的属性/方法。
什么是对钩子的返回值做相应的处理?
部分钩子的返回值,是会影响到Rollup的行为。
例如:
export default function myExample () {
return {
name: 'my-example',
options(options) {
// 修改 options
return options
}
};
}
options钩子的返回值,会覆盖Rollup当前的运行配置,从而影响到Rollup的行为。
Vite 同样需要实现这个行为 —— 根据返回值做相应的处理。每个钩子的返回值(如果有),对应的处理是不同的,都需要实现。
什么是钩子类型?
Webpack
优点