# 35|Vite原理:写一个迷你的Vite 你好,我是大圣。 上一讲学完了Vue的编译原理后,我们就把Vue的整体流程梳理完毕了,但是我们在使用Vue的时候,还会用到很多Vue生态的库。所以从今天开始,我会带你了解几个Vue生态中重要成员的原理和源码,今天我先带你剖析一下我们项目中用的工程化工具Vite的原理。 ## 现在工程化的痛点 现在前端开发项目的时候,工程化工具已经成为了标准配置,webpack是现在使用率最高的工程化框架,它可以很好地帮助我们完成从代码调试到打包的全过程,但是随着项目规模的爆炸式增长,**webpack也带来了一些痛点问题**。 最早webpack可以帮助我们在JavaScript文件中使用require导入其他JavaScript、CSS、image等文件,并且提供了dev-server启动测试服务器,极大地提高了我们开发项目的效率。 webpack的核心原理就是通过分析JavaScript中的require语句,分析出当前JavaScript文件所有的依赖文件,然后递归分析之后,就得到了整个项目的一个依赖图。对图中不同格式的文件执行不同的loader,比如会把CSS文件解析成加载CSS标签的JavaScript代码,最后基于这个依赖图获取所有的文件。 进行打包处理之后,放在内存中提供给浏览器使用,然后dev-server会启动一个测试服务器打开页面,并且在代码文件修改之后可以通过WebSocket通知前端自动更新页面,**也就是我们熟悉的热更新功能**。 由于webpack在项目调试之前,要把所有文件的依赖关系收集完,打包处理后才能启动测试,很多大项目我们执行调试命令后需要等1分钟以上才能开始调试。这对于开发者来说,这段时间除了摸鱼什么都干不了,而且热更新也需要等几秒钟才能生效,极大地影响了我们开发的效率。所以针对webpack这种打包bundle的思路,社区就诞生了bundless的框架,Vite就是其中的佼佼者。 前端的项目之所以需要webpack打包,是因为**浏览器里的JavaScript没有很好的方式去引入其他文件**。webpack提供的打包功能可以帮助我们更好地组织开发代码,但是现在大部分浏览器都支持了ES6的module功能,我们在浏览器内使用type="module"标记一个script后,在src/main.js中就可以直接使用import语法去引入一个新的JavaScript文件。这样我们其实可以不依赖webpack的打包功能,利用浏览器的module功能就可以重新组织我们的代码。 ```javascript ``` ## Vite原理 了解了script的使用方式之后,我们来实现一个**迷你的 Vite**来讲解其大致的原理。 首先,浏览器的module功能有一些限制需要额外处理。浏览器识别出JavaScript中的import语句后,会发起一个新的网络请求去获取新的文件,所以只支持/、./和…/开头的路径。 而在下面的Vue项目启动代码中,首先浏览器并不知道Vue是从哪来,我们第一个要做的,就是分析文件中的import语句。如果路径不是一个相对路径或者绝对路径,那就说明这个模块是来自node\_modules,我们需要去node\_modules查找这个文件的入口文件后返回浏览器。然后 ./App.vue是相对路径,可以找到文件,但是浏览器不支持 .vue文件的解析,并且index.css也不是一个合法的JavaScript文件。 **我们需要解决以上三个问题,才能让Vue项目很好地在浏览器里跑起来。** ```javascript import { createApp } from 'vue' import App from './App.vue' import './index.css' const app = createApp(App) app.mount('#app') ``` 怎么做呢?首先我们需要使用Koa搭建一个server,用来拦截浏览器发出的所有网络请求,才能实现上述功能。在下面代码中,我们使用Koa启动了一个服务器,并且访问首页内容读取index.html的内容。 ```javascript const fs = require('fs') const path = require('path') const Koa = require('koa') const app = new Koa() app.use(async ctx=>{ const {request:{url,query} } = ctx if(url=='/'){ ctx.type="text/html" let content = fs.readFileSync('./index.html','utf-8') ctx.body = content } }) app.listen(24678, ()=>{ console.log('快来快来数一数,端口24678') }) ``` 下面就是首页index.html的内容,一个div作为Vue启动的容器,并且通过script引入src.main.js。我们访问首页之后,就会看到浏览器内显示的geektime文本,并且发起了一个main.js的HTTP请求,**然后我们来解决页面中的报错问题**。 ```javascript Vite App

geek time

``` 首先import {createApp} from Vue这一步由于浏览器无法识别Vue的路径,就会直接抛出错误,所以我们要在Koa中把Vue的路径重写。为了方便演示,我们可以直接使用replace语句,把Vue改成/@modules/vue,使用@module开头的地址来告诉Koa这是一个需要去node\_modules查询的模块。 在下面的代码中,我们判断如果请求地址是js结尾,就去读取对应的文件内容,使用rewriteImport函数处理后再返回文件内容。在rewriteImport中我们实现了路径的替换,把Vue变成了 @modules/vue, 现在浏览器就会发起一个[http://localhost:24678/@modules/vue](http://localhost:24678/@modules/vue) 的请求,下一步我们要在Koa中拦截这个请求,并且返回Vue的代码内容。 ```javascript const fs = require('fs') const path = require('path') const Koa = require('koa') const app = new Koa() function rewriteImport(content){ return content.replace(/ from ['|"]([^'"]+)['|"]/g, function(s0,s1){ // . ../ /开头的,都是相对路径 if(s1[0]!=='.'&& s1[1]!=='/'){ return ` from '/@modules/${s1}'` }else{ return s0 } }) } app.use(async ctx=>{ const {request:{url,query} } = ctx if(url=='/'){ ctx.type="text/html" let content = fs.readFileSync('./index.html','utf-8') ctx.body = content }else if(url.endsWith('.js')){ // js文件 const p = path.resolve(__dirname,url.slice(1)) ctx.type = 'application/javascript' const content = fs.readFileSync(p,'utf-8') ctx.body = rewriteImport(content) } }) app.listen(24678, ()=>{ console.log('快来快来说一书,端口24678') }) ``` ![图片](https://static001.geekbang.org/resource/image/c3/62/c39f700e37b638345ae4cbd0228fd762.png?wh=1125x387) 然后我们在Koa中判断请求地址,如果是@module的地址,就把后面的Vue解析出来,去node\_modules中查询。然后拼接出目标路径 ./node\_modules/vue/package.json去读取Vue项目中package.json的module字段,这个字段的地址就是 ES6 规范的入口文件。在我们读取到文件后,再使用rewriteImport处理后返回即可。 这里还要使用rewriteImport的原因是,Vue文件内部也会使用import的语法去加载其他模块。然后我们就可以看到浏览器网络请求列表中多了好几个Vue的请求。 ```javascript else if(url.startsWith('/@modules/')){ // 这是一个node_module里的东西 const prefix = path.resolve(__dirname,'node_modules',url.replace('/@modules/','')) const module = require(prefix+'/package.json').module const p = path.resolve(prefix,module) const ret = fs.readFileSync(p,'utf-8') ctx.type = 'application/javascript' ctx.body = rewriteImport(ret) } ``` ![图片](https://static001.geekbang.org/resource/image/7f/fb/7fb5564ac59ffba085d9c7fd24f8f9fb.png?wh=1681x512) **这样我们就实现了node\_modules模块的解析,然后我们来处理浏览器无法识别 .vue文件的错误。** .vue文件是Vue中特有的文件格式,我们上一节课提过Vue内部通过@vue/compiler-sfc来解析单文件组件,把组件分成template、style、script三个部分,我们要做的就是在Node环境下,把template的内容解析成render函数,并且和script的内容组成组件对象,再返回即可。 其中,compiler-dom解析template的流程我们学习过,今天我们来看下如何使用。 在下面的代码中,我们判断 .vue的文件请求后,通过compilerSFC.parse方法解析Vue组件,通过返回的descriptor.script获取JavaScript代码,并且发起一个type=template的方法去获取render函数。在query.type是template的时候,调用compilerDom.compile解析template内容,直接返回render函数。 ```javascript const compilerSfc = require('@vue/compiler-sfc') // .vue const compilerDom = require('@vue/compiler-dom') // 模板 if(url.indexOf('.vue')>-1){ // vue单文件组件 const p = path.resolve(__dirname, url.split('?')[0].slice(1)) const {descriptor} = compilerSfc.parse(fs.readFileSync(p,'utf-8')) if(!query.type){ ctx.type = 'application/javascript' // 借用vue自导的compile框架 解析单文件组件,其实相当于vue-loader做的事情 ctx.body = ` ${rewriteImport(descriptor.script.content.replace('export default ','const __script = '))} import { render as __render } from "${url}?type=template" __script.render = __render export default __script ` }else if(query.type==='template'){ // 模板内容 const template = descriptor.template // 要在server端吧compiler做了 const render = compilerDom.compile(template.content, {mode:"module"}).code ctx.type = 'application/javascript' ctx.body = rewriteImport(render) } ``` 上面的代码实现之后,我们就可以在浏览器中看到App.vue组件解析的结果。App.vue会额外发起一个App.vue?type=template的请求,最终完成了整个App组件的解析。 ![图片](https://static001.geekbang.org/resource/image/f9/90/f986571970188eac47bb4fac1af37d90.png?wh=1920x552)![图片](https://static001.geekbang.org/resource/image/cc/46/cc696c23a2a6d4e9eacf401375320146.png?wh=1920x384) **接下来我们再来实现对CSS文件的支持。**下面的代码中,如果url是CSS结尾,我们就返回一段JavaScript代码。这段JavaScript代码会在浏览器里创建一个style标签,标签内部放入我们读取的CSS文件代码。这种对CSS文件的处理方式,让CSS以JavaScript的形式返回,这样我们就实现了在Node中对Vue组件的渲染。 ```javascript if(url.endsWith('.css')){ const p = path.resolve(__dirname,url.slice(1)) const file = fs.readFileSync(p,'utf-8') const content = ` const css = "${file.replace(/\n/g,'')}" let link = document.createElement('style') link.setAttribute('type', 'text/css') document.head.appendChild(link) link.innerHTML = css export default css ` ctx.type = 'application/javascript' ctx.body = content } ``` ![图片](https://static001.geekbang.org/resource/image/9f/f7/9f50c5ca0d9b74b680e41963055c99f7.png?wh=1920x628) ## Vite的热更新 最后我们再来看一下热更新如何实现。热更新的目的就是在我们修改代码之后,**浏览器能够自动渲染更新的内容**,所以我们要在客户端注入一个额外的JavaScript文件,这个文件用来和后端实现WebSocket通信。然后后端启动WebSocket服务,通过chokidar库监听文件夹的变化后,再通过WebSocket去通知浏览器即可。 下面的代码中,我们通过chokidar.watch实现了文件夹变更的监听,并且通过handleHMRUpdate通知客户端文件更新的类型。 ```javascript export function watch() { const watcher = chokidar.watch(appRoot, { ignored: ['**/node_modules/**', '**/.git/**'], ignoreInitial: true, ignorePermissionErrors: true, disableGlobbing: true, }); watcher; return watcher; } export function handleHMRUpdate(opts: { file: string; ws: any }) { const { file, ws } = opts; const shortFile = getShortName(file, appRoot); const timestamp = Date.now(); console.log(`[file change] ${chalk.dim(shortFile)}`); let updates; if (shortFile.endsWith('.css')) { updates = [ { type: 'js-update', timestamp, path: `/${shortFile}`, acceptedPath: `/${shortFile}`, }, ]; } ws.send({ type: 'update', updates, }); } ``` 然后客户端注入一段额外的JavaScript代码,判断后端传递的类型是js-update还是css-update去执行不同的函数即可。 ```javascript async function handleMessage(payload: any) { switch (payload.type) { case 'connected': console.log(`[vite] connected.`); setInterval(() => socket.send('ping'), 30000); break; case 'update': payload.updates.forEach((update: Update) => { if (update.type === 'js-update') { fetchUpdate(update); } }); break; } } ``` ## 总结 以上就是今天的主要内容,我们来总结一下吧! 首先,我们通过了解webpack的大致原理,知道了现在webpack在开发体验上的痛点。除了用户体验UX之外,开发者的体验DX也是项目质量的重要因素。 webpack启动服务器之前需要进行项目的打包,而Vite则是可以直接启动服务,通过浏览器运行时的请求拦截,实现首页文件的按需加载,这样开发服务器启动的时间就和整个项目的复杂度解耦。任何时候我们启动Vite的调试服务器,基本都可以在一秒以内响应,这极大地提升了开发者的体验,这也是Vite的使用率越来越高的原因。 并且我们可以看到,Vite的主要目的就是提供一个调试服务器。Vite也可以和Vue解耦,实现对任何框架的支持,如果使用Vite支持React,只需要解析React中的JSX就可以实现。这也是Vite项目的现状,我们只需要使用框架对应的Vite插件就可以支持任意框架。 Vite能够做到这么快的原因,还有一部分是因为使用了esbuild去解析JavaScript文件。esbuild是一个用Go语言实现的JavaScript打包器,支持JavaScript和TypeScript语法,现在前端工程化领域的工具也越来越多地使用Go和Rust等更高效的语言书写,这也是性能优化的一个方向。 ## 思考题 最后留一个思考题吧。如果一个模块文件是分散的,导致Vite首页一下子要加载1000个JavaScript文件造成卡顿,我们该如何处理这种情况呢? 欢迎在评论区分享你的答案,我们下一讲再见!