Vite 介紹
Vite 概念
Vite
是一個面向現代化瀏覽器的一個更輕、更快的 web 應用應用開發工具
它基于 ECMAScript
標準原生模塊系統(ES Module)實現的
它的出現是為了解決 Webpack 在開發階段,使用 webpack-dev-server
冷啟動時間過長和 Webpack MHR
熱更新反應慢的問題。
使用 Vite
創建的項目,默認就是一個普通的 Vue3應用,相比于 Vue CLI
創建的項目,會少了很多文件和依賴。
Vite 的特性
- 快速冷啟動
- 模塊熱更新
- 按需編譯
- 開箱即用
Vite 項目依賴
Vite 創建的默認項目,開發依賴很少也很簡單,只包含了:
Vite
-
@vue/compiler-sfc
(用來編譯.vue 結尾的單文件文件)
需要注意的是,Vite
目前創建的 Vue 項目只支持 3.0
的版本。在創建項目的時候,通過指定不同的模板,也可以創建其他框架的項目。
Vite 提供的命令
- vite serve
工作原理
用于啟動一個開發的 web 服務器,在啟動服務器的時候不需要編譯所有的模塊啟動速度非常的快。
我們來看看下面這張圖:
在運行vite serve
的時候,不需要打包,直接開啟了一個 web 服務器。當瀏覽器請求服務器時,例如是一個 css,或者是一個單文件組件,這個時候在服務器會把這個瀏覽器請求的文件先編譯,然后直接把編譯后的結果返回給瀏覽器。
這里的編譯是在服務器端,并且,模塊的處理是在請求到服務器端處理的。
我們來回顧一下,Vue CLI 創建的應用
Vue CLI
創建的項目啟動 web 服務器用的是 vue-cli-service
,當運行它的時候,它內部會使用 Webpack 去打包所有的模塊(如果模塊很多的情況下,編譯的速度會很慢),打包完成后會將編譯好的模塊存儲到內存中,然后啟動一個 web 服務器,瀏覽器請求 web 服務器,最后才會從內存中把編譯好的內容,返回到瀏覽器。
像Webpack
這樣的工具,它的做法是將所有的模塊提前都編譯打包進內存里,不管模塊是否被執行是否被調用,它會都打包編譯,隨著項目越來越大,打包后的內容也會越來越大,打包的速度也會越來越慢。
而Vite
使用現代化瀏覽器原生支持的 ES Module 模塊化的特性,省略了模塊的打包環節。對于需要編譯的文件,例如樣式模塊和單文件組件等,vite 采用了即時編譯,也就是說當加載到這個文件的時候,才會去服務端編譯好這個文件。
所以,這種即時編譯的好處體現在按需編譯,速度會更快。
HMR
- Vite HMR
立即編譯當前所修改的文件 - Webpack HMR
會自動以這個文件為入口重新編譯一次,所有的涉及到的依賴也會被加載一次
Vite
默認也支持 HMR 模塊熱更新,相對于Webpack
中的 HMR 效果會更好,因為 Webpack 的 HMR 模塊熱跟新會從你修改的文件開始全部在編譯一遍
vite build
- Rollup
- Dynamic import
Polyfill
Vite
創建的項目使用Vite build
進行生產模式的打包,這個命令內部使用過的是 Rollup 打包,最終也是把文件都打包編譯在一起。對于代碼切割的需求,Vite 內部采用的是原生的動態導入的方式實現的,所以打包的結果只能支持現代化的瀏覽器(不支持 ie)。不過相對應的Polyfill
可以解決
是否還需要打包?
隨著Vite
的出現,我們需要考慮一個問題,是否還必要打包應用。之前我們使用Webpack
進行打包,會把所有的模塊都打包進bundle.js
中,主要有兩個原因:
- 瀏覽器環境對原生 ES Module 的支持
- 零零散散的模塊文件會產生大量的 HTTP 請求
但是,現在目前大部分的瀏覽器都已經支持了 ES Module
。并且我們也可以使用 HTTP2
長鏈接去解決大量的 HTTP 請求。那是否還需要對應用進行打包,取決于你的團隊和項目應用的運行環境。
個人覺得這以后會是一個趨勢。
開箱即用
- TypeScript - 內置支持
- less/sass/stylus/postcss - 內置支持(需要單獨安裝)
- JSX
- Web Assemby
實現一個簡易版的 vite
接下來,我們來實現一個簡易版本的 vite,來深入理解 vite 的工作原理,分為以下五個步驟:
- 靜態 web 服務器
- 修改第三方模塊的路徑
- 加載第三方模塊
- 編譯單文件組件
- HMR(通過 WebSocket 實現,跳過)
靜態 web 服務器
vite
內部使用過的是koa
來開啟靜態服務器的,這里我們也使用 koa
來開啟一個靜態服務器,把當前運行的目錄作為靜態服務器的根目錄
創建一個名為 my-vite
的空文件夾,進入該文件夾初始化 package.json
,并且安裝 koa
和 koa-send
在 package.json
來配置 bin 字段:
"bin": "index.js"
新建 index.js 文件,并且在第一行配置 node 的運行環境(因為我們要開發的是一個基于 node
的命令行工具,所以要指定運行node
的位置)
#!/usr/bin/env node
接下來,基于koa
啟動一個 web 靜態服務器:
#!/usr/bin/env node
const Koa = require('koa')
const send = require('koa-send')
const app = new Koa()
// 1.開啟靜態文件服務器
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
到全局,然后打開一個使用 vue3 寫的項目(可以用 vite
創建一個默認項目),進入命令行終端,輸入 myvite
。如果沒有報錯的話,會打印出"Serve running @ http://localhost:3000"
這句話,我們打開瀏覽器,打開這個網址。
不過是一片空白的,接著我們打開 F12,會看到一個報錯,報錯的信息的意思是,解析 vue 模塊的時候失敗了,使用 import 導入模塊的時候,模塊的開頭必須是"/", "./", or "../"
這三種其中的一個。
我們來做一個對比,我們把使用vite
創建的項目啟動后, vite-cli
創建的項目啟動后的 main.js 在瀏覽器響應中的區別
通過上面兩幅圖的對比,你會發現,vite 它會處理這個模塊引入的路徑,它會加載一個不存在的路徑@modules
,并且請求這個路徑的 js 文件也是可以請求成功的。
這是 vite
創建的項目啟動后的 vue.js 的請求,觀察響應頭中的 Content-Type
字段,他是 application/javascript
;所以我們可以通過這個類型,在返回的時候去處理這個js
中的第三方路徑問題。
修改第三方模塊的路徑
通過上面的觀察和理解,我們得出一個思路,可以把不是"/", "./", or "../"
開頭的引用,全部替換成“/@modules/”
。
我們創建多一個中間件,用來做這件事情。
// 2.修改第三方模塊的路徑
app.use(async (ctx, next) => {
// 判斷瀏覽器請求的文件類型,如果是js文件,在這里進行解析。
if (ctx.type === 'application/javascript') {
//將流轉化成字符串
const contents = await streamToString(ctx.body)
// 在js的import當中,只會出現以下的幾種情況:
// 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這三種情況,現代化瀏覽器都可以識別,只有第一種情況不能識別,這里只處理第一種情況
// 思路是用正則匹配到 (from ') 或者 是 (from ") 開頭,替換成"/@modules/"
/**
* 這里進行分組的全局匹配
* 第一個分組匹配以下內容:
* from 匹配 from
* \s+ 匹配空格
* ['"]匹配單引號或者是雙引號
* 第二個分組匹配以下內容:
* ?! 不匹配這個分組的結果
* \.\/ 匹配 ./
* \.\.\/ 匹配 ../
* $1表示第一個分組的結果
*/
ctx.body = contents.replace(
/(from\s+['"])(?![\.\/\.\.\\/])/g,
'$1/@modules/'
)
}
})
// 將流轉化成字符串,是一個異步線程,返回一個promise
const streamToString = (stream) =>
new Promise((resolve, reject) => {
// 用于存儲讀取到的buffer
const chunks = []
//監聽讀取到buffer,并存儲到chunks數組中
stream.on('data', (chunk) => chunks.push(chunk))
//當數據讀取完畢之后,把結果返回給resolve,這里需要把讀取到的buffer合并并且轉換為字符串
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
//如果讀取buffer失敗,返回reject
stream.on('error', reject)
})
加載第三方模塊
現在我們要做的是,將 /@modules/
開頭的引用,去 node_modules
中找到并且替換它的返回內容。我們需要在創建一個中間件,這個中間件需要在創建靜態服務器之前被調用。
// 3.加載第三方模塊
app.use(async (ctx, next) => {
// ctx.path --> /@modules/vue
// 判斷第三方模塊的路徑是否有/@modules/開頭
if (ctx.path.startsWith('/@modules/')) {
// 對字符串進行截取,獲取到模塊名稱
const moduleName = ctx.path.substr(10)
// 找到該模塊名稱在node_moduls中的package.json路徑
const pkgPath = path.join(
process.cwd(),
'node_modules',
moduleName,
'package.json'
)
// 通過require加載當前package.json
const pkg = require(pkgPath)
// 將內容替換成node_modules中的內容
ctx.path = path.join('/node_modules', moduleName, pkg.module)
}
// 返回執行下一個中間件
await next()
})
編寫完后,我們需要重新啟動一下服務器,啟動完成后,我們重新打開 network
網絡面板,看看 vue 這個模塊是否被加載了進來。
我們看到,vue 這個模塊已經被加載進來了。
但是,我們發現 /@modules/@vue/runtime-dom
和 /@modules/@vue/shared
卻沒有被加載進來,并且控制臺卻報了兩個錯誤:加載模塊App.vue
和 index.css
失敗
編譯單文件組件
我們先觀察一下,原本的 vite 啟動后,sfc 單文件夾組件的請求是如何處理的,
我們來看 app.vue
的響應內容,它引入了一些組件,然后把它編譯成一個選項對象,然后它又去加載了app.vue
并且在后面加上了一個參數 type=template
,并且解構出了一個 render
函數,然后把 render
函數掛載到選項對象上,然后又設置了兩個屬性(這兩個屬性不模擬),最后導出這個選項對象。
從這段代碼我們可以觀察到,當請求到單文件組件的時候,服務器會來編譯這個單文件組件,并把相對應的結果返回給瀏覽器。
我們在來編寫一個中間件,在編寫中間件的時候,我們需要安裝一個模塊 @vue/compiler-sfc
并且導入,這個模塊的作用主要是編譯單文件組件的。
代碼如下:
// 4. 處理單文件組件
app.use(async (ctx, next) => {
// 當請求的文件是單文件組件的時候,就是.vue結尾的時候
if (ctx.path.endsWith('.vue')) {
// 獲取文件內容,它的內容是一個流,需要轉換為字符串
const contents = await streamToString(ctx.body)
// compilerSFC.parse用來編譯單文件組件,它返回一個對象,它有兩個成員 descriptor、errors
const { descriptor } = compilerSFC.parse(contents)
// 最終返回瀏覽器的內容
let code
// 第一次請求,沒有參數的時候,就是沒有帶type的時候
if (!ctx.query.type) {
// 第一次請求,把單文件組件編譯成一個對象
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
`
}
// 第二次請求,參數中是否有type參數,并且是否是template
else if (ctx.query.type === 'template') {
// compilerSFC.compileTemplate 編譯模板
const templateRender = compilerSFC.compileTemplate({
// 編譯內容
source: descriptor.template.content,
})
code = templateRender.code
}
// 設置文件類型
ctx.type = 'application/javascript'
// 轉化成流
ctx.body = stringToStream(code)
}
await next()
})
然后,重啟一下服務,需要注意的是,需要把圖片和其他和 js
或者 vue
無關的文件都注釋掉,因為我們這里只處理了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/開頭
if (ctx.path.startsWith('/@modules/')) {
// 對字符串進行截取,獲取到模塊名稱
const moduleName = ctx.path.substr(10)
// 找到該模塊名稱在node_moduls中的package.json路徑
const pkgPath = path.join(
process.cwd(),
'node_modules',
moduleName,
'package.json'
)
// 通過require加載當前package.json
const pkg = require(pkgPath)
// 將內容替換成node_modules中的內容
ctx.path = path.join('/node_modules', moduleName, pkg.module)
}
await next()
})
// 1.開啟靜態文件服務器
app.use(async (ctx, next) => {
await send(ctx, ctx.path, { root: process.cwd(), index: 'index.html' })
await next()
})
// 4. 處理單文件組件
app.use(async (ctx, next) => {
// 當請求的文件是單文件組件的時候,就是.vue結尾的時候
if (ctx.path.endsWith('.vue')) {
// 獲取文件內容,它的內容是一個流,需要轉換為字符串
const contents = await streamToString(ctx.body)
// compilerSFC.parse用來編譯單文件組件,它返回一個對象,它有兩個成員 descriptor、errors
const { descriptor } = compilerSFC.parse(contents)
// 最終返回瀏覽器的內容
let code
// 第一次請求,沒有參數的時候,就是沒有帶type的時候
if (!ctx.query.type) {
// 第一次請求,把單文件組件編譯成一個對象
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
`
}
// 第二次請求,參數中是否有type參數,并且是否是template
else if (ctx.query.type === 'template') {
// compilerSFC.compileTemplate 編譯模板
const templateRender = compilerSFC.compileTemplate({
// 編譯內容
source: descriptor.template.content,
})
code = templateRender.code
}
// 設置文件類型
ctx.type = 'application/javascript'
// 轉化成流
ctx.body = stringToStream(code)
}
await next()
})
// 2.修改第三方模塊的路徑
app.use(async (ctx, next) => {
// 判斷瀏覽器請求的文件類型,如果是js文件,在這里進行解析。
if (ctx.type === 'application/javascript') {
//將流轉化成字符串
const contents = await streamToString(ctx.body)
// 在js的import當中,只會出現以下的幾種情況:
// 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這三種情況,現代化瀏覽器都可以識別,只有第一種情況不能識別,這里只處理第一種情況
// 思路是用正則匹配到 (from ') 或者 是 (from ") 開頭,替換成"/@modules/"
/**
* 這里進行分組的全局匹配
* 第一個分組匹配以下內容:
* from 匹配 from
* \s+ 匹配空格
* ['"]匹配單引號或者是雙引號
* 第二個分組匹配以下內容:
* ?! 不匹配這個分組的結果
* \.\/ 匹配 ./
* \.\.\/ 匹配 ../
* $1表示第一個分組的結果
*/
ctx.body = contents
.replace(/(from\s+['"])(?![\.\/\.\.\\/])/g, '$1/@modules/')
.replace(/process\.env\.NODE_ENV/g, '"development"') // 替換process對象
}
})
// 將流轉化成字符串,是一個異步線程,返回一個promise
const streamToString = (stream) =>
new Promise((resolve, reject) => {
// 用于存儲讀取到的buffer
const chunks = []
//監聽讀取到buffer,并存儲到chunks數組中
stream.on('data', (chunk) => chunks.push(chunk))
//當數據讀取完畢之后,把結果返回給resolve,這里需要把讀取到的buffer合并并且轉換為字符串
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
//如果讀取buffer失敗,返回reject
stream.on('error', reject)
})
// 將字符串轉化成流
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')