你好,我是大圣。
上一讲学完了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功能就可以重新组织我们的代码。
<script type="module" src="/src/main.js"></script>
了解了script的使用方式之后,我们来实现一个迷你的 Vite来讲解其大致的原理。
首先,浏览器的module功能有一些限制需要额外处理。浏览器识别出JavaScript中的import语句后,会发起一个新的网络请求去获取新的文件,所以只支持/、./和…/开头的路径。
而在下面的Vue项目启动代码中,首先浏览器并不知道Vue是从哪来,我们第一个要做的,就是分析文件中的import语句。如果路径不是一个相对路径或者绝对路径,那就说明这个模块是来自node_modules,我们需要去node_modules查找这个文件的入口文件后返回浏览器。然后 ./App.vue是相对路径,可以找到文件,但是浏览器不支持 .vue文件的解析,并且index.css也不是一个合法的JavaScript文件。
我们需要解决以上三个问题,才能让Vue项目很好地在浏览器里跑起来。
import { createApp } from 'vue'
import App from './App.vue'
import './index.css'
const app = createApp(App)
app.mount('#app')
怎么做呢?首先我们需要使用Koa搭建一个server,用来拦截浏览器发出的所有网络请求,才能实现上述功能。在下面代码中,我们使用Koa启动了一个服务器,并且访问首页内容读取index.html的内容。
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请求,然后我们来解决页面中的报错问题。
<!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 的请求,下一步我们要在Koa中拦截这个请求,并且返回Vue的代码内容。
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')
})
然后我们在Koa中判断请求地址,如果是@module的地址,就把后面的Vue解析出来,去node_modules中查询。然后拼接出目标路径 ./node_modules/vue/package.json去读取Vue项目中package.json的module字段,这个字段的地址就是 ES6 规范的入口文件。在我们读取到文件后,再使用rewriteImport处理后返回即可。
这里还要使用rewriteImport的原因是,Vue文件内部也会使用import的语法去加载其他模块。然后我们就可以看到浏览器网络请求列表中多了好几个Vue的请求。
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)
}
这样我们就实现了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函数。
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组件的解析。
接下来我们再来实现对CSS文件的支持。下面的代码中,如果url是CSS结尾,我们就返回一段JavaScript代码。这段JavaScript代码会在浏览器里创建一个style标签,标签内部放入我们读取的CSS文件代码。这种对CSS文件的处理方式,让CSS以JavaScript的形式返回,这样我们就实现了在Node中对Vue组件的渲染。
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
}
最后我们再来看一下热更新如何实现。热更新的目的就是在我们修改代码之后,浏览器能够自动渲染更新的内容,所以我们要在客户端注入一个额外的JavaScript文件,这个文件用来和后端实现WebSocket通信。然后后端启动WebSocket服务,通过chokidar库监听文件夹的变化后,再通过WebSocket去通知浏览器即可。
下面的代码中,我们通过chokidar.watch实现了文件夹变更的监听,并且通过handleHMRUpdate通知客户端文件更新的类型。
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去执行不同的函数即可。
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文件造成卡顿,我们该如何处理这种情况呢?
欢迎在评论区分享你的答案,我们下一讲再见!