gitbook/玩转Vue 3全家桶/docs/476156.md
2022-09-03 22:05:03 +08:00

311 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
<script type="module" src="/src/main.js"></script>
```
## 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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<h1>geek time</h1>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
```
首先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文件造成卡顿,我们该如何处理这种情况呢?
欢迎在评论区分享你的答案,我们下一讲再见!