Vite 预构建的核心原理
1. 兼容性与性能的双重目标
Vite 的预构建旨在解决两个主要问题:兼容性和性能。对于兼容性,由于 Vite 在开发阶段将所有代码视为原生 ES 模块,因此需要将 CommonJS 或 UMD 格式的依赖转换为 ESM 格式。对于性能,Vite 通过预构建将多个内部模块的 ESM 依赖关系转换为单个模块,减少了网络请求的数量,从而提高了页面加载速度。
2. 自动依赖搜寻
Vite 通过扫描项目源码自动寻找引入的依赖项,并将这些依赖项作为预构建包的入口点。这一过程通过 esbuild
执行,因此非常快速。如果在服务器启动后遇到新的依赖关系导入,Vite 将重新运行依赖构建进程并重新加载页面。
2. 工作过程
当声明一个script
标签类型为module
时,如
<script type="module" src="/src/main.js"></script>
- 当浏览器解析资源时,会往当前域名发起一个
GET
请求main.js
文件
// main.js
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
- 请求到了
main.js
文件,会检测到内部含有import
引入的包,又会import
引用发起HTTP
请求获取模块的内容文件,如App.vue
、vue
文件
Vite
其核心原理是利用浏览器现在已经支持ES6
的import
,碰见import
就会发送一个HTTP
请求去加载文件,Vite
启动一个koa
服务器拦截这些请求,并在后端进行相应的处理将项目中使用的文件通过简单的分解与整合,然后再以ESM
格式返回返回给浏览器。Vite
整个过程中没有对文件进行打包编译,做到了真正的按需加载,所以其运行速度比原始的webpack
开发编译速度快出许多。
预构建的实现细节
1.依赖预构建的触发
当首次启动 Vite 开发服务器时,Vite 会检查是否存在预构建的依赖。如果没有找到相应的缓存,Vite 将抓取源码并自动寻找引入的依赖项。这个过程是通过 Vite 的内部插件 esbuildScanPlugin
实现的,它会遍历所有的入口文件,解析出依赖列表,并进行预构建。
2.预构建过程
预构建过程是通过 Vite 的 optimizeDeps
函数触发的。该函数首先会检查是否存在一个名为 _metadata.json
的文件,该文件记录了预构建模块的信息。如果文件存在且哈希值与当前依赖的哈希值一致,Vite 将跳过预构建过程。如果哈希值不一致或文件不存在,Vite 将执行预构建,并更新 _metadata.json
文件。
3.缓存策略
Vite 的预构建依赖会缓存在 node_modules/.vite
目录下。这个目录中的文件会根据 package.json
、lockfile 以及 vite.config.js
中的配置来决定是否需要重新构建。这种缓存策略大大减少了重复构建的开销,提高了开发效率。
模拟实践
vite会拦截import,对于相对地址的文件,浏览器可以直接加载,但是对于像import { createApp } from 'vue'
这种加载一个裸模块,vite就会通过一次预打包,将第三方模块放在node_modules/.vite
,然后将裸模块地址替换成相对地址。以及加载的是vue文件浏览器无法解析,vite也是需要将vue文件转化成js文件。
所以我们第一步创建一个服务器,将裸模块替换相对地址让浏览器可以加载文件,第二步解析vue成js文件,让浏览器可以识别
1、js加载和裸模块路径重写
直接加载vue会浏览器会报错
对裸模块路径重写
const Koa = require('koa')
const fs=require('fs')
const path=require('path')
const app=new Koa();
app.use(async (ctx)=>{
const {url}=ctx.request;
if(url==='/'){
//返回主页
ctx.type='text/html'
ctx.body=fs.readFileSync('./index.html','utf-8')
}else if(url.endsWith('.js')){
// js文件加载路径处理
const p=path.join(__dirname,url);
ctx.type='application/javascript'
ctx.body=rewriteImport(fs.readFileSync(p,'utf-8'))
}
})
//裸模块路径重写
//将import xxx from './xx' 替换成 import xxx from '/@moudle/xxx'
//将裸模块进行替换和重写,官方的处理方式是先使用esbuild打包后缓存在node_modules中
function rewriteImport(content){
return content.replace(/ from ['"](.*)['"]/g,function(s1,s2){
if(s2.startsWith('./')||s2.startsWith('/')||s2.startsWith('../')){
return s1
}else{
//裸模块,需要替换
return ` from '/@moudles/${s2}'`
}
})
}
app.listen(3000,()=>{
console.log('kvite start')
})
重写后
但是又有新的问题,裸模块无法加载
2、对裸模块加载进行处理
app.use(async (ctx)=>{
...
else if(url.startsWith('/@moudles/')){
const moudleName=url.replace('/@moudles/','');
// node_moudle中找
const prefix=path.join(__dirname,'../node_modules',moudleName)
//package中匹配
const moudle=require(prefix+'/package.json').moudle
const filePath=path.join(prefix,moudle)
const ret=fs.readFileSync(filePath,'utf-8');
ctx.type='application/javascript'
ctx.body=rewriteImport(ret)
}
...
})
处理后可以加载vue模块了
对main.js文件进行丰富
<template>
<div>
{{ title }}
</div>
</template>
<script>
import { reactive } from "@vue/composition-api";
export default {
setup() {
const state = reactive({
title: "hello,kvite!!!",
});
},
};
</script>
3、开始解析SFC
app.use(async (ctx)=>{
...
else if(url.indexOf('.vue')>-1){
const p=path.join(__dirname,url.split('?')[0])
const ast=compilerSFC.parse(fs.readFileSync(p,'utf-8'))
if(!query.type){
//SFC请求
//读取vue文件,解析为js文件
//获取脚本内容
const scriptContent=ast.descriptor.script.content;
const script=scriptContent.replace('export defalut ','const __script=')
ctx.type='application/javascript'
ctx.body=`
${rewriteImport(script)}
//解析tpl
import {render as __render} from '${url}?type=template'
__sciprt.render=__render
export defalut __sctipt
`
}else if(query.type==='template'){
const tpl=ast.descriptor.template.content;
const render=compilerDOM.compiler(tpl,{mode:module}).code
ctx.type='application/javascript'
ctx.body=rewriteImport(render)
}
}
})
成功输出
完整代码
const Koa = require('koa')
const fs=require('fs')
const path=require('path')
const compilerSFC =require('vue/compiler-sfc')
const compilerDOM=require('vue/compiler-dom')
const app=new Koa();
app.use(async (ctx)=>{
const {url}=ctx.request;
if(url==='/'){
ctx.type='text/html'
ctx.body=fs.readFileSync('./index.html','utf-8')
}else if(url.endsWith('.js')){
// js文件加载路径处理
const __filenameNew = fileURLToPath(import.meta.url)
const __dirnameNew = path.dirname(__filenameNew)
const p=path.join(__dirnameNew,url);
ctx.type='application/javascript'
// ctx.body=fs.readFileSync(p,'utf-8')
ctx.body=rewriteImport(fs.readFileSync(p,'utf-8'))
}else if(url.startsWith('/@moudles/')){
const moudleName=url.replace('/@moudles/','');
// node_moudle中找
const __filenameNew = fileURLToPath(import.meta.url)
const __dirnameNew = path.dirname(__filenameNew)
const prefix=path.join(__dirnameNew,'../node_modules',moudleName)
//package中匹配
const moudle=require(prefix+'/package.json').moudle
const filePath=path.join(prefix,moudle)
const ret=fs.readFileSync(filePath,'utf-8');
ctx.type='application/javascript'
ctx.body=rewriteImport(ret)
}else if(url.indexOf('.vue')>-1){
const p=path.join(__dirname,url.split('?')[0])
const ast=compilerSFC.parse(fs.readFileSync(p,'utf-8'))
if(!query.type){
//SFC请求
//读取vue文件,解析为js文件
//获取脚本内容
const scriptContent=ast.descriptor.script.content;
const script=scriptContent.replace('export defalut ','const __script=')
ctx.type='application/javascript'
ctx.body=`
${rewriteImport(script)}
//解析tpl
import {render as __render} from '${url}?type=template'
__sciprt.render=__render
export defalut __sctipt
`
}else if(query.type==='template'){
const tpl=ast.descriptor.template.content;
const render=compilerDOM.compiler(tpl,{mode:module}).code
ctx.type='application/javascript'
ctx.body=rewriteImport(render)
}
}
})
//裸模块重写
//将import xxx from './xx' 替换成 import xxx from '/@moudle/xxx'
//将裸模块进行替换和重写,官方的处理方式是先使用esbuild打包依赖在地址上
function rewriteImport(content){
return content.replace(/ from ['"](.*)['"]/g,function(s1,s2){
if(s2.startsWith('./')||s2.startsWith('/')||s2.startsWith('../')){
return s1
}else{
//裸模块,需要替换
return ` from '/@moudles/${s2}'`
}
})
}
app.listen(3000,()=>{
console.log('dvite start')
})