前面一篇文章介绍了如何做 Vite 的插件开发 # 20分钟掌握 Vite 插件开发,这篇文章介绍清晰 Vite 的构建原理,为什么比 Webpack 构建速度快这么多。
1. 一句话解释 Webpack 的构建原理
前端之所有需要 类似于 Webpack 这样的构建工具,是为了提高项目的开发效率,Webpack 通过分析js中的 require 语句,分析出当前 js 文件所有的依赖文件,通过递归的方式层层分析后,得到整个项目的依赖关系图,对图中不同的文件执行不同的 loader,比如使用 css-loader 解析css代码,最后基于这个依赖关系图读取到整个项目中的所有文件代码,进行打包处理之后交给浏览器执行。
这个过程一气呵成,顺理成章。这里我们只用文字简单描述 Webpack 的工作原理。想要弄懂其中的详细过程,关注一点,我们下一篇文章再详细剖析。
这样的构建构过程,导致了在我们调试代码之前,需要等待 Webpack 的依赖收集过程,而当项目代码体量很大时,这个依赖收集的过程往往需要我们等待几十秒甚至一两分钟,程序员在这个时间段内只能摸鱼,开发体验非常差,有什么办法解决这个问题?
如果有办法能够做到更少的代码打包就好了!!!,于是 bundless 的打包思路就诞生了,Vite 便是这个这种思路的遥遥领先。
2. script 的模块化
需要打包工具的核心原因,就是浏览器在执行代码的时候,本身没有一个很好的方式去读懂我们的项目中的各个文件引入关系。
所以
Webpack 对浏览器说
:"你别纠结了,我把所有的文件引入关系都梳理好了,并且将项目中所有文件的代码打包在了一起,你就去执行找一个文件吧!"
浏览器开开心的说
:"好的大哥,谢谢大哥",然后毫无压力的哐哐运行
但是随着浏览器的进步,它开始能慢慢读的懂一些模块化的引入语法了,比如:
浏览器是可以正常那运行这份代码的:
再看一眼浏览器的是怎么处理这些文件引入关系的:
浏览器会将 import
语句处理成一个个HTTP网络请求,去获取 import
引入的各种模块, 就因为浏览器现在可以通过 type="module"
这种方式读懂项目中文件的模块化引入,所以,bundless 的思想得以发展
3. Vite 的一步一步实现
Vite 正是借助了刚刚我们说的浏览器的这一特性,将项目中的各种文件引入处理成网络请求,当浏览器执行到了模块依赖的代码,便自己去请求所需要的代码资源。这么简单?没错!那接下来我们一步一步自己打造一个 Vite。
首先,这里我们不使用构建工具,自己创建一个Vue的项目(参考vite的构建目录自己一个一个创建)
在index.html 中写入以下引入
但是现在浏览器是无法运行这份html的,因为这其中还有一个问题
来自于 node_modules 的模块浏览器无法读懂它的路径,进而无法请求到正确的资源,所以我们需要手动解决这一问题
在项目的根目录下创建 simple-vite.js 文件,通过 node 启动一个web服务,向浏览器响应资源
javascript
const http = require('http')
const fs = require('fs')
const server = http.createServer((req, res) => {
const { url, query } = req
if (url === '/') {
// 设置响应头的Content-Type是为了让浏览器以html的编码方式去加载这份资源
res.writeHead(200, {
'Content-Type': 'text/html'
})
let content = fs.readFileSync('./index.html', 'utf8')
res.end(content)
}
})
server.listen(8080, () => {
console.log('listening on port 8080');
})
用node运行这份js后:
这个应该就不用再详细解释了,node没有基础的看官只能自己先学习一下了
因为 html 代码中有 main.js 这个模块的引入,所以浏览器会自动发这个请求,但是浏览器访问的是我们自己的后端,我们没有写这个对应的响应,所以是看不到东西的
继续处理js文件的请求
javascript
const http = require('http')
const fs = require('fs')
const path = require('path') // ++++
const server = http.createServer((req, res) => {
const { url, query } = req
if (url === '/') {
// 设置响应头的Content-Type是为了让浏览器以html的编码方式去加载这份资源
res.writeHead(200, {
'Content-Type': 'text/html'
})
let content = fs.readFileSync('./index.html', 'utf8')
res.end(content)
} else if (url.endsWith('.js')) { // +++++++新增代码
const p = path.resolve(__dirname, url.slice(1)) // '/main.js' ==> 'main.js'的绝对路径
res.writeHead(200, {
'Content-Type': 'application/javascript'
})
const content = fs.readFileSync(p, 'utf8')
res.end(content)
}
})
server.listen(8080, () => {
console.log('listening on port 8080');
})
我们将js文件的请求都读取到资源代码返回给浏览器后:
main.js 的资源请求正常了,但是现在项目还是无法运行,因为mian.js中有浏览器无法处理的模块路径 'vue',所以我们需要实现,当浏览器加载到来自 node_modules 中的模块时,我们告诉浏览器该模块的正常路径是什么
javascript
const http = require('http')
const fs = require('fs')
const path = require('path')
function rewriteImport(content) { // ++++新增
return content.replace(/ from ['|"]([^'"]+)['|"]/g, function(s0, s1) { // 找到 from 'vue' 中的 'vue'
if (s1[0] !== '.' && s1[1] !== '/') {
return ` from '/@modules/${s1}'`
} else {
return s0
}
})
}
const server = http.createServer((req, res) => {
const { url, query } = req
if (url === '/') {
// 设置响应头的Content-Type是为了让浏览器以html的编码方式去加载这份资源
res.writeHead(200, {
'Content-Type': 'text/html'
})
let content = fs.readFileSync('./index.html', 'utf8')
res.end(content)
} else if (url.endsWith('.js')) {
const p = path.resolve(__dirname, url.slice(1))
res.writeHead(200, {
'Content-Type': 'application/javascript'
})
const content = fs.readFileSync(p, 'utf8')
res.end(rewriteImport(content)) // +++++修改
}
})
server.listen(8080, () => {
console.log('listening on port 8080');
})
再次启动后端,访问浏览器后:
我们给所有的从 node_modules 中引入的模块,路径中打上一个特殊标记 @modules
,让浏览器知道这是个路径,因为在浏览器眼里, "./", "/", "../" 这种才叫路径。同时顺势浏览器便执行 main.js 中的代码,并发起了新的请求
但是
这个路径依然不是最终的有效路径,不过现在我们可以在node中进行判断,如果出现请求的url是 /@modules/xxx
这种类型的,我们就去 node_modules 中为其读取源代码
javascript
const http = require('http')
const fs = require('fs')
const path = require('path')
function rewriteImport(content) { // ++++新增
return content.replace(/ from ['|"]([^'"]+)['|"]/g, function(s0, s1) { // 找到 from 'vue' 中的 'vue'
if (s1[0] !== '.' && s1[1] !== '/') {
return ` from '/@modules/${s1}'`
} else {
return s0
}
})
}
const server = http.createServer((req, res) => {
const { url, query } = req
if (url === '/') {
res.writeHead(200, {
'Content-Type': 'text/html'
})
let content = fs.readFileSync('./index.html', 'utf8')
res.end(content)
} else if (url.endsWith('.js')) {
const p = path.resolve(__dirname, url.slice(1))
res.writeHead(200, {
'Content-Type': 'application/javascript'
})
const content = fs.readFileSync(p, 'utf8')
res.end(rewriteImport(content))
} else if (url.startsWith('/@modules/')) { // ++++++ 新增代码
const prefix = path.resolve(__dirname, 'node_modules', url.replace('/@modules/', ''))
const module = require(prefix + '/package.json').module
const p = path.resolve(prefix, module)
const content = fs.readFileSync(p, 'utf8')
res.writeHead(200, {
'Content-Type': 'application/javascript'
})
res.end(rewriteImport(content))
}
})
server.listen(8080, () => {
console.log('listening on port 8080');
})
小知识:要读取到一个公共库有效的源码,可以在该库的 package.json 文件中找到源码有效地址
比如 node_modules中的 Vue 的源码:
如此一来,我们就帮浏览器解决了第三方模块源码资源请求不到的问题:
Vue 源码中涉及到的各种其他的源码资源也顺势被请求到了
现在还剩最后一个问题:
关于 .vue 后缀的文件资源,浏览器确实请求到了,比如上图的 App.vue,但是,浏览器是读不懂vue的语法的这我们都知道,所以在 Response 响应中啥也看不见,就是因为浏览器拿到了代码解析不出来,它懵了,我们还得帮它读懂这份代码
众所周知,vue是有自己的编译器的,它能将自己的vue独有的语法编译成js代码,所以我们只需要在浏览器请求 .vue 后缀的文件时用上 vue 自己的编译器就好了
php
const http = require('http')
const fs = require('fs')
const path = require('path')
const { URL } = require('url');
const compilerSfc = require('@vue/compiler-sfc') // +++++新增
const compilerDom = require('@vue/compiler-dom')
function rewriteImport(content) {
return content.replace(/ from ['|"]([^'"]+)['|"]/g, function(s0, s1) { // 找到 from 'vue' 中的 'vue'
if (s1[0] !== '.' && s1[1] !== '/') {
return ` from '/@modules/${s1}'`
} else {
return s0
}
})
}
const server = http.createServer((req, res) => {
const { url } = req
const query = new URL(req.url, `http://${req.headers.host}`).searchParams; // ++++ node 新版本,读取get请求的参数写法
// 省略部分代码...
else if (url.indexOf('.vue') !== -1) { // +++++ 返回.vue文件的js部分
const p = path.resolve(__dirname, url.split('?')[0].slice(1))
const { descriptor } = compilerSfc.parse(fs.readFileSync(p, 'utf8'))
if (!query.get('type')) {
res.writeHead(200, {'Content-Type': 'application/javascript'})
const content = `
${rewriteImport(descriptor.script.content.replace('export default', 'const __script = '))}
import { render as __render } from "${url}?type=template"
__script.render = __render
export default __script
`
res.end(content)
} else if (query.get('type') === 'template') { // 返回.vue文件的html部分
const template = descriptor.template
const render = compilerDom.compile(template.content, {mode: 'module'}).code
res.writeHead(200, {'Content-Type': 'application/javascript'})
res.end(rewriteImport(render))
}
}
})
server.listen(8080, () => {
console.log('listening on port 8080');
})
我们都知道,vue的组件由 tempalte,script,style,三部分构成,以上代码主要是将浏览器请求的 App.vue 中的template部分打造成新的请求,让浏览器再多请求一次,而js部分是不需要的
注意App.vue 中不要使用setup的语法糖,用语法糖需要额外的编译手段,上述代码没有包含该功能
到这里还剩一个css部分的请求,目前是这样的:
添加css的响应
dart
// 省略部分代码...
else if (url.endsWith('.css')) {
const p = path.resolve(__dirname, url.slice(1))
const file = fs.readFileSync(p, 'utf8')
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
`
res.writeHead(200, {'Content-Type': 'application/javascript'})
res.end(content)
}
// 省略部分代码...
将css代码改写成js响应给浏览器执行,最后我们试一试运行项目
别慌!这是因为在浏览器环境中找不到 process 进程(这是node中的),我们去最外面的index.html文件中加一点声明就好了
xml
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue</title>
</head>
<body>
<div id="app"></div>
<script> // ++++
window.process = {
env: {
NODE_ENV: 'dev'
}
}
</script>
<script type="module" src="/src/main.js"></script>
</body>
</html>
再次访问 http:localhost:8080 看一眼效果
长这样子是因为我的 App.vue 中写了个demo
终于,整个项目在不使用构建工具的情况下,我们自己将它运行起来了,也处理好了各个模块引入的问题
4. 总结一下
Vite 相比于 Webpack 之所以构建快是因为,Vite 借助新版本浏览器可以读懂模块化语法的特点,将项目中的模块化引入统一以一个又一个http请求的方式响应给浏览器,这样做的好处就是省去了 Webpack 构建过程中递归做依赖收集的耗时步骤,又因为Vite是开发环境的工具,绝大多数情况下我们不用不考虑兼容性,不会有人开发时还用老版本的浏览器吧>_<