Vite 介紹
Vite 概念
Vite
是一個(gè)面向現(xiàn)代化瀏覽器的一個(gè)更輕瞻惋、更快的 web 應(yīng)用應(yīng)用開(kāi)發(fā)工具
它基于 ECMAScript
標(biāo)準(zhǔn)原生模塊系統(tǒng)(ES Module)實(shí)現(xiàn)的
它的出現(xiàn)是為了解決 Webpack 在開(kāi)發(fā)階段,使用 webpack-dev-server
冷啟動(dòng)時(shí)間過(guò)長(zhǎng)和 Webpack MHR
熱更新反應(yīng)慢的問(wèn)題。
使用 Vite
創(chuàng)建的項(xiàng)目,默認(rèn)就是一個(gè)普通的 Vue3應(yīng)用盐杂,相比于 Vue CLI
創(chuàng)建的項(xiàng)目辙纬,會(huì)少了很多文件和依賴。
Vite 的特性
- 快速冷啟動(dòng)
- 模塊熱更新
- 按需編譯
- 開(kāi)箱即用
Vite 項(xiàng)目依賴
Vite 創(chuàng)建的默認(rèn)項(xiàng)目难捌,開(kāi)發(fā)依賴很少也很簡(jiǎn)單,只包含了:
Vite
-
@vue/compiler-sfc
(用來(lái)編譯.vue 結(jié)尾的單文件文件)
需要注意的是皂贩,Vite
目前創(chuàng)建的 Vue 項(xiàng)目只支持 3.0
的版本栖榨。在創(chuàng)建項(xiàng)目的時(shí)候,通過(guò)指定不同的模板明刷,也可以創(chuàng)建其他框架的項(xiàng)目婴栽。
Vite 提供的命令
- vite serve
工作原理
用于啟動(dòng)一個(gè)開(kāi)發(fā)的 web 服務(wù)器,在啟動(dòng)服務(wù)器的時(shí)候不需要編譯所有的模塊啟動(dòng)速度非常的快辈末。
我們來(lái)看看下面這張圖:
在運(yùn)行vite serve
的時(shí)候愚争,不需要打包,直接開(kāi)啟了一個(gè) web 服務(wù)器挤聘。當(dāng)瀏覽器請(qǐng)求服務(wù)器時(shí)轰枝,例如是一個(gè) css,或者是一個(gè)單文件組件组去,這個(gè)時(shí)候在服務(wù)器會(huì)把這個(gè)瀏覽器請(qǐng)求的文件先編譯鞍陨,然后直接把編譯后的結(jié)果返回給瀏覽器。
這里的編譯是在服務(wù)器端从隆,并且诚撵,模塊的處理是在請(qǐng)求到服務(wù)器端處理的缭裆。
我們來(lái)回顧一下,Vue CLI 創(chuàng)建的應(yīng)用
Vue CLI
創(chuàng)建的項(xiàng)目啟動(dòng) web 服務(wù)器用的是 vue-cli-service
寿烟,當(dāng)運(yùn)行它的時(shí)候澈驼,它內(nèi)部會(huì)使用 Webpack 去打包所有的模塊(如果模塊很多的情況下,編譯的速度會(huì)很慢)筛武,打包完成后會(huì)將編譯好的模塊存儲(chǔ)到內(nèi)存中缝其,然后啟動(dòng)一個(gè) web 服務(wù)器,瀏覽器請(qǐng)求 web 服務(wù)器徘六,最后才會(huì)從內(nèi)存中把編譯好的內(nèi)容内边,返回到瀏覽器。
像Webpack
這樣的工具硕噩,它的做法是將所有的模塊提前都編譯打包進(jìn)內(nèi)存里假残,不管模塊是否被執(zhí)行是否被調(diào)用,它會(huì)都打包編譯炉擅,隨著項(xiàng)目越來(lái)越大,打包后的內(nèi)容也會(huì)越來(lái)越大阳惹,打包的速度也會(huì)越來(lái)越慢谍失。
而Vite
使用現(xiàn)代化瀏覽器原生支持的 ES Module 模塊化的特性,省略了模塊的打包環(huán)節(jié)莹汤。對(duì)于需要編譯的文件快鱼,例如樣式模塊和單文件組件等,vite 采用了即時(shí)編譯纲岭,也就是說(shuō)當(dāng)加載到這個(gè)文件的時(shí)候抹竹,才會(huì)去服務(wù)端編譯好這個(gè)文件。
所以止潮,這種即時(shí)編譯的好處體現(xiàn)在按需編譯窃判,速度會(huì)更快。
HMR
- Vite HMR
立即編譯當(dāng)前所修改的文件 - Webpack HMR
會(huì)自動(dòng)以這個(gè)文件為入口重新編譯一次喇闸,所有的涉及到的依賴也會(huì)被加載一次
Vite
默認(rèn)也支持 HMR 模塊熱更新袄琳,相對(duì)于Webpack
中的 HMR 效果會(huì)更好,因?yàn)?Webpack 的 HMR 模塊熱跟新會(huì)從你修改的文件開(kāi)始全部在編譯一遍
vite build
- Rollup
- Dynamic import
Polyfill
Vite
創(chuàng)建的項(xiàng)目使用Vite build
進(jìn)行生產(chǎn)模式的打包燃乍,這個(gè)命令內(nèi)部使用過(guò)的是 Rollup 打包唆樊,最終也是把文件都打包編譯在一起。對(duì)于代碼切割的需求刻蟹,Vite 內(nèi)部采用的是原生的動(dòng)態(tài)導(dǎo)入的方式實(shí)現(xiàn)的逗旁,所以打包的結(jié)果只能支持現(xiàn)代化的瀏覽器(不支持 ie)。不過(guò)相對(duì)應(yīng)的Polyfill
可以解決
是否還需要打包舆瘪?
隨著Vite
的出現(xiàn)片效,我們需要考慮一個(gè)問(wèn)題仓洼,是否還必要打包應(yīng)用。之前我們使用Webpack
進(jìn)行打包堤舒,會(huì)把所有的模塊都打包進(jìn)bundle.js
中色建,主要有兩個(gè)原因:
- 瀏覽器環(huán)境對(duì)原生 ES Module 的支持
- 零零散散的模塊文件會(huì)產(chǎn)生大量的 HTTP 請(qǐng)求
但是,現(xiàn)在目前大部分的瀏覽器都已經(jīng)支持了 ES Module
舌缤。并且我們也可以使用 HTTP2
長(zhǎng)鏈接去解決大量的 HTTP 請(qǐng)求箕戳。那是否還需要對(duì)應(yīng)用進(jìn)行打包,取決于你的團(tuán)隊(duì)和項(xiàng)目應(yīng)用的運(yùn)行環(huán)境国撵。
個(gè)人覺(jué)得這以后會(huì)是一個(gè)趨勢(shì)陵吸。
開(kāi)箱即用
- TypeScript - 內(nèi)置支持
- less/sass/stylus/postcss - 內(nèi)置支持(需要單獨(dú)安裝)
- JSX
- Web Assemby
實(shí)現(xiàn)一個(gè)簡(jiǎn)易版的 vite
接下來(lái),我們來(lái)實(shí)現(xiàn)一個(gè)簡(jiǎn)易版本的 vite介牙,來(lái)深入理解 vite 的工作原理壮虫,分為以下五個(gè)步驟:
- 靜態(tài) web 服務(wù)器
- 修改第三方模塊的路徑
- 加載第三方模塊
- 編譯單文件組件
- HMR(通過(guò) WebSocket 實(shí)現(xiàn),跳過(guò))
靜態(tài) web 服務(wù)器
vite
內(nèi)部使用過(guò)的是koa
來(lái)開(kāi)啟靜態(tài)服務(wù)器的环础,這里我們也使用 koa
來(lái)開(kāi)啟一個(gè)靜態(tài)服務(wù)器囚似,把當(dāng)前運(yùn)行的目錄作為靜態(tài)服務(wù)器的根目錄
創(chuàng)建一個(gè)名為 my-vite
的空文件夾,進(jìn)入該文件夾初始化 package.json
线得,并且安裝 koa
和 koa-send
在 package.json
來(lái)配置 bin 字段:
"bin": "index.js"
新建 index.js 文件饶唤,并且在第一行配置 node 的運(yùn)行環(huán)境(因?yàn)槲覀円_(kāi)發(fā)的是一個(gè)基于 node
的命令行工具,所以要指定運(yùn)行node
的位置)
#!/usr/bin/env node
接下來(lái)贯钩,基于koa
啟動(dòng)一個(gè) web 靜態(tài)服務(wù)器:
#!/usr/bin/env node
const Koa = require('koa')
const send = require('koa-send')
const app = new Koa()
// 1.開(kāi)啟靜態(tài)文件服務(wù)器
app.use(async (ctx, next) => {
await send(ctx, ctx.path, { root: process.cwd(), index: 'index.html' })
next()
})
app.listen(3000)
console.log('Serve running @ http://localhost:3000')
接著募狂,使用 npm link
到全局,然后打開(kāi)一個(gè)使用 vue3 寫(xiě)的項(xiàng)目(可以用 vite
創(chuàng)建一個(gè)默認(rèn)項(xiàng)目)角雷,進(jìn)入命令行終端祸穷,輸入 myvite
。如果沒(méi)有報(bào)錯(cuò)的話勺三,會(huì)打印出"Serve running @ http://localhost:3000"
這句話雷滚,我們打開(kāi)瀏覽器,打開(kāi)這個(gè)網(wǎng)址檩咱。
不過(guò)是一片空白的揭措,接著我們打開(kāi) F12,會(huì)看到一個(gè)報(bào)錯(cuò)刻蚯,報(bào)錯(cuò)的信息的意思是绊含,解析 vue 模塊的時(shí)候失敗了,使用 import 導(dǎo)入模塊的時(shí)候炊汹,模塊的開(kāi)頭必須是"/", "./", or "../"
這三種其中的一個(gè)躬充。
我們來(lái)做一個(gè)對(duì)比,我們把使用vite
創(chuàng)建的項(xiàng)目啟動(dòng)后, vite-cli
創(chuàng)建的項(xiàng)目啟動(dòng)后的 main.js 在瀏覽器響應(yīng)中的區(qū)別
通過(guò)上面兩幅圖的對(duì)比充甚,你會(huì)發(fā)現(xiàn)以政,vite 它會(huì)處理這個(gè)模塊引入的路徑,它會(huì)加載一個(gè)不存在的路徑@modules
伴找,并且請(qǐng)求這個(gè)路徑的 js 文件也是可以請(qǐng)求成功的盈蛮。
這是 vite
創(chuàng)建的項(xiàng)目啟動(dòng)后的 vue.js 的請(qǐng)求,觀察響應(yīng)頭中的 Content-Type
字段技矮,他是 application/javascript
;所以我們可以通過(guò)這個(gè)類(lèi)型抖誉,在返回的時(shí)候去處理這個(gè)js
中的第三方路徑問(wèn)題。
修改第三方模塊的路徑
通過(guò)上面的觀察和理解衰倦,我們得出一個(gè)思路袒炉,可以把不是"/", "./", or "../"
開(kāi)頭的引用,全部替換成“/@modules/”
樊零。
我們創(chuàng)建多一個(gè)中間件我磁,用來(lái)做這件事情。
// 2.修改第三方模塊的路徑
app.use(async (ctx, next) => {
// 判斷瀏覽器請(qǐng)求的文件類(lèi)型驻襟,如果是js文件夺艰,在這里進(jìn)行解析。
if (ctx.type === 'application/javascript') {
//將流轉(zhuǎn)化成字符串
const contents = await streamToString(ctx.body)
// 在js的import當(dāng)中塑悼,只會(huì)出現(xiàn)以下的幾種情況:
// 1劲适、import vue from 'vue'
// 2、import App from '/App.vue'
// 3厢蒜、import App from './App.vue'
// 4、import App from '../App.vue'
// 2烹植、3斑鸦、4這三種情況,現(xiàn)代化瀏覽器都可以識(shí)別草雕,只有第一種情況不能識(shí)別巷屿,這里只處理第一種情況
// 思路是用正則匹配到 (from ') 或者 是 (from ") 開(kāi)頭,替換成"/@modules/"
/**
* 這里進(jìn)行分組的全局匹配
* 第一個(gè)分組匹配以下內(nèi)容:
* from 匹配 from
* \s+ 匹配空格
* ['"]匹配單引號(hào)或者是雙引號(hào)
* 第二個(gè)分組匹配以下內(nèi)容:
* ?! 不匹配這個(gè)分組的結(jié)果
* \.\/ 匹配 ./
* \.\.\/ 匹配 ../
* $1表示第一個(gè)分組的結(jié)果
*/
ctx.body = contents.replace(
/(from\s+['"])(?![\.\/\.\.\\/])/g,
'$1/@modules/'
)
}
})
// 將流轉(zhuǎn)化成字符串墩虹,是一個(gè)異步線程嘱巾,返回一個(gè)promise
const streamToString = (stream) =>
new Promise((resolve, reject) => {
// 用于存儲(chǔ)讀取到的buffer
const chunks = []
//監(jiān)聽(tīng)讀取到buffer,并存儲(chǔ)到chunks數(shù)組中
stream.on('data', (chunk) => chunks.push(chunk))
//當(dāng)數(shù)據(jù)讀取完畢之后诫钓,把結(jié)果返回給resolve旬昭,這里需要把讀取到的buffer合并并且轉(zhuǎn)換為字符串
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
//如果讀取buffer失敗,返回reject
stream.on('error', reject)
})
加載第三方模塊
現(xiàn)在我們要做的是菌湃,將 /@modules/
開(kāi)頭的引用问拘,去 node_modules
中找到并且替換它的返回內(nèi)容。我們需要在創(chuàng)建一個(gè)中間件,這個(gè)中間件需要在創(chuàng)建靜態(tài)服務(wù)器之前被調(diào)用骤坐。
// 3.加載第三方模塊
app.use(async (ctx, next) => {
// ctx.path --> /@modules/vue
// 判斷第三方模塊的路徑是否有/@modules/開(kāi)頭
if (ctx.path.startsWith('/@modules/')) {
// 對(duì)字符串進(jìn)行截取绪杏,獲取到模塊名稱
const moduleName = ctx.path.substr(10)
// 找到該模塊名稱在node_moduls中的package.json路徑
const pkgPath = path.join(
process.cwd(),
'node_modules',
moduleName,
'package.json'
)
// 通過(guò)require加載當(dāng)前package.json
const pkg = require(pkgPath)
// 將內(nèi)容替換成node_modules中的內(nèi)容
ctx.path = path.join('/node_modules', moduleName, pkg.module)
}
// 返回執(zhí)行下一個(gè)中間件
await next()
})
編寫(xiě)完后,我們需要重新啟動(dòng)一下服務(wù)器纽绍,啟動(dòng)完成后蕾久,我們重新打開(kāi) network
網(wǎng)絡(luò)面板,看看 vue 這個(gè)模塊是否被加載了進(jìn)來(lái)拌夏。
我們看到僧著,vue 這個(gè)模塊已經(jīng)被加載進(jìn)來(lái)了。
但是辖佣,我們發(fā)現(xiàn) /@modules/@vue/runtime-dom
和 /@modules/@vue/shared
卻沒(méi)有被加載進(jìn)來(lái)霹抛,并且控制臺(tái)卻報(bào)了兩個(gè)錯(cuò)誤:加載模塊App.vue
和 index.css
失敗
編譯單文件組件
我們先觀察一下,原本的 vite 啟動(dòng)后卷谈,sfc 單文件夾組件的請(qǐng)求是如何處理的杯拐,
我們來(lái)看 app.vue
的響應(yīng)內(nèi)容,它引入了一些組件世蔗,然后把它編譯成一個(gè)選項(xiàng)對(duì)象端逼,然后它又去加載了app.vue
并且在后面加上了一個(gè)參數(shù) type=template
,并且解構(gòu)出了一個(gè) render
函數(shù)污淋,然后把 render
函數(shù)掛載到選項(xiàng)對(duì)象上顶滩,然后又設(shè)置了兩個(gè)屬性(這兩個(gè)屬性不模擬),最后導(dǎo)出這個(gè)選項(xiàng)對(duì)象寸爆。
從這段代碼我們可以觀察到礁鲁,當(dāng)請(qǐng)求到單文件組件的時(shí)候,服務(wù)器會(huì)來(lái)編譯這個(gè)單文件組件赁豆,并把相對(duì)應(yīng)的結(jié)果返回給瀏覽器仅醇。
我們?cè)趤?lái)編寫(xiě)一個(gè)中間件,在編寫(xiě)中間件的時(shí)候魔种,我們需要安裝一個(gè)模塊 @vue/compiler-sfc
并且導(dǎo)入析二,這個(gè)模塊的作用主要是編譯單文件組件的。
代碼如下:
// 4. 處理單文件組件
app.use(async (ctx, next) => {
// 當(dāng)請(qǐng)求的文件是單文件組件的時(shí)候节预,就是.vue結(jié)尾的時(shí)候
if (ctx.path.endsWith('.vue')) {
// 獲取文件內(nèi)容叶摄,它的內(nèi)容是一個(gè)流,需要轉(zhuǎn)換為字符串
const contents = await streamToString(ctx.body)
// compilerSFC.parse用來(lái)編譯單文件組件安拟,它返回一個(gè)對(duì)象蛤吓,它有兩個(gè)成員 descriptor、errors
const { descriptor } = compilerSFC.parse(contents)
// 最終返回瀏覽器的內(nèi)容
let code
// 第一次請(qǐng)求去扣,沒(méi)有參數(shù)的時(shí)候柱衔,就是沒(méi)有帶type的時(shí)候
if (!ctx.query.type) {
// 第一次請(qǐng)求樊破,把單文件組件編譯成一個(gè)對(duì)象
code = descriptor.script.content
code = code.replace(/export\s+default\s+/g, 'const __script = ')
code += `
import { render as __render } from "${ctx.path}?type=template"
__script.render = __render
export default __script
`
}
// 第二次請(qǐng)求,參數(shù)中是否有type參數(shù)唆铐,并且是否是template
else if (ctx.query.type === 'template') {
// compilerSFC.compileTemplate 編譯模板
const templateRender = compilerSFC.compileTemplate({
// 編譯內(nèi)容
source: descriptor.template.content,
})
code = templateRender.code
}
// 設(shè)置文件類(lèi)型
ctx.type = 'application/javascript'
// 轉(zhuǎn)化成流
ctx.body = stringToStream(code)
}
await next()
})
然后哲戚,重啟一下服務(wù),需要注意的是艾岂,需要把圖片和其他和 js
或者 vue
無(wú)關(guān)的文件都注釋掉顺少,因?yàn)槲覀冞@里只處理了vue
文件。
源碼
#!/usr/bin/env node
const path = require('path')
const { Readable } = require('stream')
const Koa = require('koa')
const send = require('koa-send')
const compilerSFC = require('@vue/compiler-sfc')
const app = new Koa()
// 3.加載第三方模塊
app.use(async (ctx, next) => {
// ctx.path --> /@modules/vue
// 判斷第三方模塊的路徑是否有/@modules/開(kāi)頭
if (ctx.path.startsWith('/@modules/')) {
// 對(duì)字符串進(jìn)行截取王浴,獲取到模塊名稱
const moduleName = ctx.path.substr(10)
// 找到該模塊名稱在node_moduls中的package.json路徑
const pkgPath = path.join(
process.cwd(),
'node_modules',
moduleName,
'package.json'
)
// 通過(guò)require加載當(dāng)前package.json
const pkg = require(pkgPath)
// 將內(nèi)容替換成node_modules中的內(nèi)容
ctx.path = path.join('/node_modules', moduleName, pkg.module)
}
await next()
})
// 1.開(kāi)啟靜態(tài)文件服務(wù)器
app.use(async (ctx, next) => {
await send(ctx, ctx.path, { root: process.cwd(), index: 'index.html' })
await next()
})
// 4. 處理單文件組件
app.use(async (ctx, next) => {
// 當(dāng)請(qǐng)求的文件是單文件組件的時(shí)候脆炎,就是.vue結(jié)尾的時(shí)候
if (ctx.path.endsWith('.vue')) {
// 獲取文件內(nèi)容,它的內(nèi)容是一個(gè)流氓辣,需要轉(zhuǎn)換為字符串
const contents = await streamToString(ctx.body)
// compilerSFC.parse用來(lái)編譯單文件組件秒裕,它返回一個(gè)對(duì)象,它有兩個(gè)成員 descriptor钞啸、errors
const { descriptor } = compilerSFC.parse(contents)
// 最終返回瀏覽器的內(nèi)容
let code
// 第一次請(qǐng)求几蜻,沒(méi)有參數(shù)的時(shí)候,就是沒(méi)有帶type的時(shí)候
if (!ctx.query.type) {
// 第一次請(qǐng)求体斩,把單文件組件編譯成一個(gè)對(duì)象
code = descriptor.script.content
code = code.replace(/export\s+default\s+/g, 'const __script = ')
code += `
import { render as __render } from "${ctx.path}?type=template"
__script.render = __render
export default __script
`
}
// 第二次請(qǐng)求梭稚,參數(shù)中是否有type參數(shù),并且是否是template
else if (ctx.query.type === 'template') {
// compilerSFC.compileTemplate 編譯模板
const templateRender = compilerSFC.compileTemplate({
// 編譯內(nèi)容
source: descriptor.template.content,
})
code = templateRender.code
}
// 設(shè)置文件類(lèi)型
ctx.type = 'application/javascript'
// 轉(zhuǎn)化成流
ctx.body = stringToStream(code)
}
await next()
})
// 2.修改第三方模塊的路徑
app.use(async (ctx, next) => {
// 判斷瀏覽器請(qǐng)求的文件類(lèi)型絮吵,如果是js文件弧烤,在這里進(jìn)行解析。
if (ctx.type === 'application/javascript') {
//將流轉(zhuǎn)化成字符串
const contents = await streamToString(ctx.body)
// 在js的import當(dāng)中蹬敲,只會(huì)出現(xiàn)以下的幾種情況:
// 1暇昂、import vue from 'vue'
// 2、import App from '/App.vue'
// 3伴嗡、import App from './App.vue'
// 4话浇、import App from '../App.vue'
// 2、3闹究、4這三種情況,現(xiàn)代化瀏覽器都可以識(shí)別食店,只有第一種情況不能識(shí)別渣淤,這里只處理第一種情況
// 思路是用正則匹配到 (from ') 或者 是 (from ") 開(kāi)頭,替換成"/@modules/"
/**
* 這里進(jìn)行分組的全局匹配
* 第一個(gè)分組匹配以下內(nèi)容:
* from 匹配 from
* \s+ 匹配空格
* ['"]匹配單引號(hào)或者是雙引號(hào)
* 第二個(gè)分組匹配以下內(nèi)容:
* ?! 不匹配這個(gè)分組的結(jié)果
* \.\/ 匹配 ./
* \.\.\/ 匹配 ../
* $1表示第一個(gè)分組的結(jié)果
*/
ctx.body = contents
.replace(/(from\s+['"])(?![\.\/\.\.\\/])/g, '$1/@modules/')
.replace(/process\.env\.NODE_ENV/g, '"development"') // 替換process對(duì)象
}
})
// 將流轉(zhuǎn)化成字符串吉嫩,是一個(gè)異步線程价认,返回一個(gè)promise
const streamToString = (stream) =>
new Promise((resolve, reject) => {
// 用于存儲(chǔ)讀取到的buffer
const chunks = []
//監(jiān)聽(tīng)讀取到buffer,并存儲(chǔ)到chunks數(shù)組中
stream.on('data', (chunk) => chunks.push(chunk))
//當(dāng)數(shù)據(jù)讀取完畢之后自娩,把結(jié)果返回給resolve用踩,這里需要把讀取到的buffer合并并且轉(zhuǎn)換為字符串
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
//如果讀取buffer失敗渠退,返回reject
stream.on('error', reject)
})
// 將字符串轉(zhuǎn)化成流
const stringToStream = (text) => {
const stream = new Readable()
stream.push(text)
stream.push(null)
return stream
}
app.listen(3000)
console.log('Serve running @ http://localhost:3000')