Vite简介

Vite(读音类似于[weɪt],法语,快的意思) ,是一个由原生 ES Module 驱动的 Web 开发构建工具。在开发环境下基于浏览器原生 ES imports 开发,在生产环境下基于 Rollup 打包。对于Vite,下面是作者的原话:【Vite是一个基于浏览器原生 ES Modules 开发的服务器。利用浏览器去解析模块,在服务器端按需编译返回,完全跳过了打包这个概念,服务器随起随用。同时不仅有 Vue 文件支持,还搞定了热更新,而且热更新的速度不会随着模块增多而变慢。】

Vite具有以下特点: - 快速的冷启动 - 即时的模块热更新 - 真正的按需编译

为了实现上述的目标,Vite 要求项目完全由 ES Module 模块组成, 模块不能直接在 Vite 上使用,也不能直接在生产环境使用,在打包上依旧还是使用 rollup 等传统打包工具。因此 Vite 目前更像是一个类似于 webpack-dev-server 的开发工具.

ES Modules

ES Modules 是浏览器支持的一种模块化方案,允许在代码中实现模块化。关于 ES Module 的更多介绍 以 vite 自带的 demo 为例子,如下所示。



<template><imgalt="Vue logo"src="./assets/"/><HelloWorldmsg="Hello Vue 3.0 + Vite"/></template><script>importHelloWorldfrom'./components/'exportdefault{name:'App',components:{HelloWorld}}</script>



运行上面的代码,当浏览器解析 import HelloWorld from './components/'时,就会往域名发送一个请求获取对应的资源。

需要说明的是,我们平时在 Webpack 中写的 mjs 格式的代码最终被 Webpack 打包成 cjs。最终在浏览器上还是以 cjs 的形式运行的。

Webpack 与 Vite

Webpack是 Instagram 团队开发的一个自动化项目构建工具,可以将JS代码、css、图片文件打包在一起。不过,使用Webpack 打包的项目,如果遇到需要改动代码的请客,重新打包时Webpack 常常需要耗时数秒甚至十几秒,这是因为 Webpack 需要将所有模块打包成一个一个或者多个模块。下面是Webpack官网给的一个Webpack打包的说明图。

正是由于Webpack的这种机制,造成我们使用Webpack打包后,如果更新某个文件就需要重新打包,随着项目规模的扩大,重新打包(热更新)的时间也会越来越长。

Webpack 之所以慢,是因为 Webpack 会将许多资源构成一个或者多个 bundle 。如果我们跳过打包的过程,当需要某个模块时再通过请求去获取它岂不是更完美。而Vite 就是这么一个由原生 ES Module 驱动的 Web 开发构建工具,完全做到按需加载,一劳永逸的解决了热更新慢的问题,示意图如下所示。

冷启动速度对比

我们以vite 与 vue-cli 创建的模板项目为例,来对比下他们的启动速度。

通过对比

| 对比对象| 启动时间 | |--|--| |vue-cli3 + vue3 | 5s | |vite 1.0.0-rc + vue 3 | 4s| |vue-cli3 + vue2 | 1s |

可以发现,vue-cli3 启动Vue2大概需要5s左右,vue-cli3 启动Vue3需要4s左右,而vite 只需要1s 左右的时间。并且,理论上讲 Vite 是ES module 实现的,随着项目的增大启动时间也不会因此增加,而 Webpack 随着代码体积的增加启动时间是要明显增加的。

热更新速度对比

Vite 热更新速度很难用图直接比较(在项目较小时热更新速度都挺快的),只能从理论上讲讲,因为 Vite 修改代码后只是重新请求修改部分的代码不受代码体积的影响,而且使用了esbuild这种理论上快webpack打包几十倍的工具。所以相比于webpack这种每次修改都需要重新打包 ,Vite 的项目是能明显提升热更新速度的。

Vite使用

和其他开发工具一样,Vite 提供了用 npm 或者 yarn 一建生成项目结构的方式,使用 yarn 在终端执行如下的命令。



yarncreatevite-app<project-name>cd<project-name>yarnyarndev



默认情况下,Vite初始化的默认应用模板为 ,生成的项目结构十分简洁,如下所示。



|____node_modules|____App.vue//应用入口
|____index.html//页面入口
|____vite.config.js//vite配置文件
|____package.json



然后执行 yarn dev 即可启动应用 。

Vite 启动链路

当我们发送请求时,Vite请求链路会经历拦截请求、解析 /@modules、。

1,拦截请求

当发送请求后,Vite 会拦截这些请求,并对请求的文件进行特殊的处理。

当通过 import 试图导入 node_modules内的文件时,Vite 会对路径进行替换,因为在浏览器只能识别相对路径 和 绝对路径。



importVuefrom'/@modules/vue'



涉及的代码如下:



// server.js
constKoa=require('koa');constfs=require('fs');constpath=require('path');constapp=newKoa();app.use(async(ctx)=>{const{request:{url,query}}=ctx;if(url=='/'){// 返回静态资源
ctx.type='text/html';ctx.body=fs.readFileSync('./','utf-8');}if(url.endsWith('.js')){// 处理 js 文件
constp=path.resolve(__dirname,url.slice(1));constres=fs.readFileSync(p,'utf-8');ctx.type='application/javascript';// 返回替换路径后的文件
ctx.body=rewriteImports(res);}});functionrewriteImports(content){returncontent.replace(/from ['|"]([^'"]+)['|"]/g,function($0,$1){// 要访问 node_modules 里的文件
if($1[0]!=='.'&&$1[1]!=='/'){return`from '/@modules/${$1}'`;}else{return$0;}});}app.listen(3000,function(){console.log('success listen 3000');});



2,解析 /@modules

就是要把 /@modules 开头的路径解析为真正的文件地址,并且返回给浏览器。之前,我们使用Webpack来帮我们做了这件事,即通过 import 导入的文件 webpack 会去 文件内找 moduel 属性。



{"license":"MIT","main":"","module":"dist/","name":"vue","repository":{"type":"git","url":"git+"},"types":"dist/","unpkg":"dist/","version":""}



可以发现, dist/ 就是需要返回的文件的地址。涉及的源代码如下。



if(url.startsWith('/@modules/')){//找到 node_modules 内的文件夹
constprefix=path.resolve(__dirname,'node_modules',url.replace('/@modules/',''));// 获取  内的 module 属性
constmodule=require(prefix+'/').module;constp=path.resolve(prefix,module);// 读取文件
constres=fs.readFileSync(p,'utf-8');ctx.type='application/javascript';// 读取的文件内还通过 import 导入了其他的依赖,继续把路径替换为 /@modules/
ctx.body=rewriteImports(res);}



3,解析单文件

我们知道,vue 文件包含了三个部分,分别是 template、script和style。在解析文件时,Vite 对这三个部分分别进行了处理,如下图所示。

,处理 script

接下来,就是分别处理 template、script和style三个文件。首先,@vue/compiler-sfc 会被用来script的,就像是 vue-loader 做的事情,涉及的代码如下。



constcompilerSfc=require('@vue/compiler-sfc');if(url.includes('.vue')){constp=path.resolve(__dirname,url.slice(1));const{descriptor}=compilerSfc.parse(fs.readFileSync(p,'utf-8'));if(!query.type){ctx.type='application/javascript';ctx.body=`
        // 拿到 script 的内容
        const __script = ${descriptor.script.content.replace('export default ','')}        // 如果有 style 就发送请求获取 style 的部分
${descriptor.styles.length?`import "${url}?type=style"`:''}        // 发送请求获取 template 的部分
        import { render as __render } from "${url}?type=template"
        // 渲染 template 的内容
        __script.render = __render;
        export default __script;
      `;}}



处理 template

@vue/compiler-dom 是用来编译 template 的,由于Vite返回给浏览器的 vue 是 runtime 版本,是没有编译器处理的,所有要在服务端编译后返回给浏览器,涉及的代码如下。



constcompilerDom=require('@vue/compiler-dom');...if(query.type==='template'){consttemplate=descriptor.template;// 在服务端编译 template 并且返回
constrender=compilerDom.compile(template.content,{mode:'module',}).code;ctx.type='application/javascript';ctx.body=render;}



处理 style

对 style 的处理有一些特殊,因为我们可以看到请求的返回内容中调用了 updateStyle()方法,而在 Vite 中是我们把它放在了 热更新 的模块中,由于此处我们并不需要实现热更新,所以先 hash 下,在 client 实现该功能,涉及的代码如下。



// server.js
if(query.type==='style'){conststyleBlock=descriptor.styles[0];ctx.type='application/javascript';ctx.body=`
      const css = ${JSON.stringify(styleBlock.content)};
      updateStyle(css);
      export default css;
    `;}



vite 运行原理

请求拦截原理

Vite 的基本实现原理,就是启动一个 koa 服务器拦截浏览器请求ES Module的请求,然后通过 path找到目录下对应的文件,经过一定的处理后最终以 ES Modules 格式返回给客户端,其请求拦截流程图如下所示。

node_modules

众所周知,由于 ES Module 模块的局限性,如何不是相对路径的引用,而是直接引用一个 node_modules模块时,可以使用如下的格式。

浏览器只能通过相对路径去寻找,但Webpack 和 gulp 等打包工具会帮我们找到模块的路径。当然,Vite也可以完成这一操作。以 Vite 官方 demo 为例,当我们请求 localhost:3000时,实际的请求链路如下图所示。

同时,Vite 会先渲染返回 代码, 然后再发送请求src/, 代码如下。



import{createApp}from'vue'importAppfrom'./'import'./'createApp(App).mount('#app')



我们可以通过查看请求来看下Vite的的相关内容。

可以观察到浏览器请求 时, 请求路径是 @modules/。 在 Vite 中约定若 path 的请求路径满足 /^/@modules// 格式时,被认为是一个 node_modules 模块。

那如何将代码中的 /:id 转化为 /@modules/:id呢?首先,Vite 对 ES module 形式的js文件模块的处理使用了 ES Module Lexer 处理。Lexer 会返回js文件中导入的模块并以数组形式返回。Vite 通过该数组判断是否为一个 node_modules 模块,若是则进行对应重写。

除此之外,我们还能有另一个形式进行一个ES module 形式的导入,那就是直接使用script标签,对于 script标签导入的模块也会有对应的处理。那 /@modules/:id 又是如何在 node_modules 文件下找到对应模块代码的呢?

浏览器发送 path 为 /@modules/:id 的对应请求后。会被 Vite 客户端做一层拦截,最终找到对应的模块代码进行返回。

vue 文件的处理

当 Vite 遇到一个 .vue 后缀的文件时,由于 .vue 模板文件的特殊性,它被拆分成 template、css 和script 模块三个模块进行分别处理,最后会对 script、template和我css 发送多个请求进行获取。通过chorme的调试工具就可以看到,如下图所示。

如上图 获取script , ?type=template 获取 template , ?type=style,这些代码都被插入在 返回的代码中。

静态资源加载

当请求的路径符合 imageRE, mediaRE , fontsRE 或 JSON 格式,会被认为是一个静态资源,静态资源通常会被处理成 ES Module 模块返回,如下所示。



// src/node/utils/pathUtils.ts
constimageRE=/\.(png|jpe?g|gif|svg|ico|webp)(\?.*)?$/constmediaRE=/\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/constfontsRE=/\.(woff2?|eot|ttf|otf)(\?.*)?$/iexportconstisStaticAsset=(file:string)=>{returnimageRE.test(file)||mediaRE.test(file)||fontsRE.test(file)}// src/node/server/serverPluginAssets.ts
app.use(async(ctx,next)=>{if(isStaticAsset(ctx.path)&&isImportRequest(ctx)){ctx.type='js'ctx.body=`export default ${JSON.stringify(ctx.path)}`return}returnnext()})exportconstjsonPlugin:ServerPlugin=({app})=>{app.use(async(ctx,next)=>{awaitnext()// handle .json imports
// note  could be null if upstream set status to 304
if(ctx.path.endsWith('.json')&&isImportRequest(ctx)&&ctx.body){ctx.type='js'ctx.body=dataToEsm(JSON.parse((awaitreadBody(ctx.body))!),{namedExports:true,preferConst:true})}})}



Vite 热更新

说到Vite的热加载原理,其实就是在客户端与服务端建立了一个 websocket 链接,当代码被修改时,服务端发送消息通知客户端去请求修改模块的代码,进而完成热更新。

服务器端

服务端做的就是监听代码文件的改变,在合适的时机向客户端发送 websocket 信息通知客户端去请求新的模块代码。例如,下面是示例代码。



// server/
// 1. 通过chokidar来监听整个项目根目录
constchokidar_1=__importDefault(require("chokidar"));constwatcher=chokidar_1.default.watch(process.cwd(),{// () 当前工作目录
ignored:[/\bnode_modules\b/,/\b\.git\b/]});constcontext={root,app,server,watcher,resolver,config,port:config.port||3000};app.use((ctx,next)=>{Object.assign(ctx,context);// 2. 把watcher方法绑定到上下文中
ctx.read=utils_1.cachedRead.bind(null,ctx);returnnext();});constresolvedPlugins=[//...
serverPluginHmr_1.hmrPlugin,// const serverPluginHmr_1 = require("./serverPluginHmr");
];resolvedPlugins.forEach((m)=>m&&m(context));// 3. 把上下文(即watcher)传给hmrPlugin函数执行,hmrPlugin
// serverPluginHmr.js
exports.hmrPlugin=({root,app,server,watcher,resolver,config})=>{// 4. 创建服务端的socket
constws_1=__importDefault(require("ws"));constwss=newws_1.default.Server({noServer:true});// 5. 在watcher上挂载send方法,调用send时就相当于wss发送消息给client
constsend=(watcher.send=(payload)=>{conststringified=JSON.stringify(payload,null,2);exports.debugHmr(`update: ${stringified}`);// 广播到所有连接的WebSocket客户端
wss.clients.forEach((client)=>{if(client.readyState===ws_1.default.OPEN){client.send(stringified);}});});}// serverPluginCss.js
// 6. 通过watcher监听.vue文件的修改
watcher.on('change',(filePath)=>{// ...
vueStyleUpdate(styleImport)})functionvueStyleUpdate(styleImport){constpublicPath=utils_1.cleanUrl(styleImport);constindex=querystring_1.default.parse(styleImport.split('?',2)[1]).index;constpath=`${publicPath}?type=style&index=${index}`;// 7. 通过send()方法向浏览器推送类型及文件路径的信息
watcher.send({type:'style-update',path,changeSrcPath:path,timestamp:Date.now()});}



客户端

Vite的 websocket 相关代码会在处理 html 中时被写入代码中。



exportconstclientPublicPath=`/vite/client`constdevInjectionCode=`\n<script type="module">import "${clientPublicPath}"</script>\n`asyncfunctionrewriteHtml(importer:string,html:string){returninjectScriptToHtml(html,devInjectionCode)}



当 路径是 /vite/client 时,请求得到对应的客户端代码,因此在客户端中我们创建了一个 websocket 服务并与服务端建立了连接。 Vite 会接受到来自客户端的消息,然后通过不同的消息触发一些事件,最终做到浏览器端的即时热模块更换(热更新)。



// client.js
/* 1. 利用WebSocket来和服务端建立连接,轮询监听服务端消息。
    如果服务端监听文件修改,编译完后通知浏览器
*/constsocketUrl=`${socketProtocol}://${location.hostname}:${__PORT__}`;constsocket=newWebSocket(socketUrl,'vite-hmr');// 2. 监听message事件,如果message事件有变化,就根据不同的类型做不同的处理
socket.addEventListener('message',async({data})=>{constpayload=JSON.parse(data);// multi表示.vue文件中依赖的其他文件的修改
if(payload.type==='multi'){payload.updates.forEach(handleMessage);}else{// .vue文件中的 template、js、css修改走这个函数
handleMessage(payload);}});// 3. 比如vue中修改了样式,会走这个方法,请求对应地址
case'style-update':// path  ===>  /src/?type=style&index=0
constel=document.querySelector(`link[href*='${path}']`);if(el){el.setAttribute('href',`${path}${path.includes('?')?'&':'?'}t=${timestamp}`);break;}// imported CSS
constimportQuery=path.includes('?')?'&import':'?import';// 请求这个地址 /src/?type=style&index=0&import&t=1599126893699
awaitimport(`${path}${importQuery}&t=${timestamp}`);console.log(`[vite] ${path} updated.`);break;



说了这么多,那有人会问,是不是可以在React中使用Vite呢? 答案是肯定的,我们只要使用 npm init vite-app --template react 插件后即可使用。