本文由字節跳動折曜原創,授權前端鐵蛋公衆號發表,原文鏈接:https://juejin.im/post/6881078539756503047
Vite是什麼
Vite,一個基於瀏覽器原生ES模塊的開發服務器。利用瀏覽器去解析模塊,在服務器端按需編譯返回,完全跳過了打包這個概念,服務器隨起隨用。同時另有有Vue文件支持,還搞定定了熱更新,而且熱更新的速度不會隨着模塊增加而變慢。
Vite(讀音)[法語],法語,快的意思是一個由原生ES模塊驅動的Web開發工具。在開發環境下基於瀏覽器原生ES導入開發,在生產環境下進行彙總打包。
閃電般快速的冷服務器啓動-閃電般的冷啓動速度
即時熱模塊更換(HMR)-即時熱模塊更換(熱更新)
真正的按需編譯-真正的按需編譯
爲了實現上述特點,Vite要求項目完全由ES模塊模塊組成,common.js模塊不能直接在Vite上使用。因此不能直接在生產環境中使用。在打包上依舊還是使用rollup等傳統打包工具。因此Vite目前更像是一個webpack-dev-server的開發工具。
ES Module的更多介紹以vite自帶的demo爲示例。
<template>
<img alt="Vue logo" src="./assets/logo.png" />
<HelloWorld msg="Hello Vue 3.0 + Vite" />
</template><script>
import HelloWorld from './components/HelloWorld.vue'
export default {
name: 'App',
components: {
HelloWorld
}
}
</script>
當瀏覽器解析從'./components/HelloWorld.vue'時,會往當前域名發送一個請求獲取對應的資源。
值得一提的是我們平時在Webpack中寫的mjs格式的代碼最終被Webpack打包成cjs。最終在瀏覽器上還是以cjs的形式運行的。所以並不是真正的mjs。
Vite採用了ES模塊來實現模塊的加載。目前基於web標準的ES模塊已經覆蓋了超過90%的瀏覽器。
Webpack&Vite原理對比
當我們使用如webpack的打包工具時,經常會遇到遇到一小行代碼,webpack常常需要耗時數秒甚至幾秒鐘進行重新打包。這是因爲webpack需要將所有模塊打包成一個一個或多個模塊。
如下面的代碼爲例,當我們使用如webpack類的打包工具時。最終將所有代碼打包入一個bundle.js文件中。
// a.js
export const a = 10
// b.js
export const b = 20;
// main.js
import { a } from 'a.js'
import { b } from 'b.js'
export const getNumber = () => {
return a + b;
}
// bundle.js
const a = 10;
const b = 20;
const getNumber = () => {
return a + b;
}
export { getNumber };
不可避免的,當我們修改模塊中的一個子模塊b.js,整個bundle.js都需要重新打包,隨着項目規模的擴大,重新打包(熱更新)的時間越來越長。我們常用如thread-loader
,cache-loader
,代碼分片
等方法進行優化。但通過項目規模的進一步擴大,熱更新速度又將變慢,又將開始新一輪的優化。通過項目規模的不斷擴大,基於bunder的項目優化也將達到一定的極限。
Webpack之所以慢,是因爲Webpack會使多個資源構成一個或多個捆綁。如果我們跳過打包的過程,當需要某個模塊時再通過請求去獲取是不是能完美解決這個問題呢?
因此,Vite來了。一個由原生ES模塊驅動的Web開發的工具,完全做到按需加載,一勞永逸的解決了熱更新慢的問題!
Vite實現
Vite的基本實現原理,就是啓動一個koa服務器攔截瀏覽器請求ES模塊的請求。通過路徑查找目錄下對應文件的文件做一定的處理最終以ES模塊格式返回給客戶端
這裏稍微提一下Vite對js / ts的處理沒有使用如gulp,rollup等傳統打包工具,其他使用了esbuild。esbuild是一個全新的js打包工具,支持如babel,壓縮等的功能,他的特點是快(比rollup等工具會快上幾十倍)!你可以點擊這裏瞭解更多關於esbuild的知識。
而快的首要是他使用了go作爲另一種語言(go這樣的靜態語言會比動態語言快很多)。
首先說一下基於ES模塊模塊的侷限性,在我們平時寫代碼時。怎麼不是相對路徑的引用,又直接引用一個node_modules模塊時,我們都是以如下的格式進行引用。
import vue from 'vue'
如Webpack&gulp等打包工具會幫我們找到模塊的路徑。但瀏覽器只能通過相對路徑去尋找。爲了解決這個問題,Vite採取了一些特殊處理。以Vite官方演示如何,當我們請求時本地主機:3000
Vite先返回index.html代碼,渲染index.html後發送請求src / main.js。main.js代碼如下。
import { createApp } from 'vue'
import App from './App.vue'
import './index.css'
createApp(App).mount('#app')
可以觀察到瀏覽器請求vue.js時,請求路徑是@ modules / vue.js。在Vite中約定路徑中的請求路徑滿足/ ^ \ / @ modules \ //格式時,被認爲是一個node_modules模塊。
如何將代碼中的/:id轉換爲/ @ modules /:id
Vite對ES模塊進行形式化的js文件模塊的處理使用了。Lexer會返回js文件中引入的模塊並以交換形式返回。Vite通過該變量判斷是否爲一個node_modules模塊。。
// Plugin for rewriting served js.
// - Rewrites named module imports to `/@modules/:id` requests, e.g.
// "vue" => "/@modules/vue"
export const moduleRewritePlugin: ServerPlugin = ({
root,
app,
watcher,
resolver
}) => {
app.use(async (ctx, next) => {
await initLexer
const importer = removeUnRelatedHmrQuery(
resolver.normalizePublicPath(ctx.url)
)
ctx.body = rewriteImports(
root,
content!,
importer,
resolver,
ctx.query.t
)
}
})
我們還能有另一個形式進行一個ES模塊形式的引入,那就是直接使用腳本標籤,對於腳本標籤引入的模塊也會有對應的處理。
const scriptRE = /(<script\b[^>]*>)([\s\S]*?)<\/script>/gm
const srcRE = /\bsrc=(?:"([^"]+)"|'([^']+)'|([^'"\s]+)\b)/
async function rewriteHtml(importer: string, html: string) {
await initLexer
html = html!.replace(scriptRE, (matched, openTag, script) => {
if (script) {
} else {
const srcAttr = openTag.match(srcRE)
if (srcAttr) {
// register script as a import dep for hmr
const importee = resolver.normalizePublicPath(
cleanUrl(slash(path.resolve('/', srcAttr[1] || srcAttr[2])))
)
ensureMapEntry(importerMap, importee).add(importer)
}
return matched
}
})
return injectScriptToHtml(html, devInjectionCode)
}
通過/ @ modules /:id在node_modules文件下找到對應模塊
瀏覽器發送路徑爲/ @ modules /:id的對應請求後。會被Vite客戶端做一層攔截,最終找到對應的模塊代碼進行返回。
export const moduleRE = /^\/@modules\//
// plugin for resolving /@modules/:id requests.
app.use(async (ctx, next) => {
if (!moduleRE.test(ctx.path)) {
return next()
}
// path maybe contain encode chars
const id = decodeURIComponent(ctx.path.replace(moduleRE, ''))
ctx.type = 'js'
const serve = async (id: string, file: string, type: string) => {
moduleIdToFileMap.set(id, file)
moduleFileToIdMap.set(file, ctx.path)
debug(`(${type}) ${id} -> ${getDebugPath(root, file)}`)
await ctx.read(file)
return next()
} }
// alias
const importerFilePath = importer ? resolver.requestToFile(importer) : root
const nodeModulePath = resolveNodeModuleFile(importerFilePath, id)
if (nodeModulePath) {
return serve(id, nodeModulePath, 'node_modules')
}
})
.vue文件的處理
當Vite遇到一個.vue後綴的文件時。由於.vue模板文件的特殊性,它被分割成template,css,腳本模塊三個模塊進行分別處理。最後放入script,template,css發送多個請求獲取。
如上圖App.vue獲取腳本,App.vue?type = template獲取模板,App.vue?type = style。這些代碼都被插入在app.vue中。
if (descriptor.customBlocks) {
descriptor.customBlocks.forEach((c, i) => {
const attrsQuery = attrsToQuery(c.attrs, c.lang)
const blockTypeQuery = `&blockType=${qs.escape(c.type)}`
let customRequest =
publicPath + `?type=custom&index=${i}${blockTypeQuery}${attrsQuery}`
const customVar = `block${i}`
code += `\nimport ${customVar} from ${JSON.stringify(customRequest)}\n`
code += `if (typeof ${customVar} === 'function') ${customVar}(__script)\n`
})
}
if (descriptor.template) {
const templateRequest = publicPath + `?type=template`
code += `\nimport { render as __render } from ${JSON.stringify(
templateRequest
)}`
code += `\n__script.render = __render`
}
code += `\n__script.__hmrId = ${JSON.stringify(publicPath)}`
code += `\n__script.__file = ${JSON.stringify(filePath)}`
code += `\nexport default __script`
當請求的路徑符合imageRE,mediaRE,fontsRE或JSON格式,會被認爲是一個靜態資源。靜態資源將處理成ES模塊模塊返回。
// src/node/utils/pathUtils.ts
const imageRE = /\.(png|jpe?g|gif|svg|ico|webp)(\?.*)?$/
const mediaRE = /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/
const fontsRE = /\.(woff2?|eot|ttf|otf)(\?.*)?$/i
export const isStaticAsset = (file: string) => {
return imageRE.test(file) || mediaRE.test(file) || fontsRE.test(file)
}
// src/node/server/serverPluginAssets.ts
app.use(async (ctx, next) => {
if (isStaticAsset(ctx.path) && isImportRequest(ctx)) {
ctx.type = 'js'
ctx.body = `export default ${JSON.stringify(ctx.path)}`
return
}
return next()
})
export const jsonPlugin: ServerPlugin = ({ app }) => {
app.use(async (ctx, next) => {
await next()
// handle .json imports
// note ctx.body could be null if upstream set status to 304
if (ctx.path.endsWith('.json') && isImportRequest(ctx) && ctx.body) {
ctx.type = 'js'
ctx.body = dataToEsm(JSON.parse((await readBody(ctx.body))!), {
namedExports: true,
preferConst: true
})
}
})
}
Vite的熱加載原理,實際上就是在客戶端與服務端建立了一個websocket鏈接,當代碼被修改時,服務端發送消息通知客戶端去請求修改模塊的代碼,完成熱更新。
服務端做的就是監聽代碼文件的更改,在適當的時機向客戶端發送websocket信息通知客戶端去請求新的模塊代碼。
Vite的websocket相關代碼在處理html中時被編寫代碼中。
export const clientPublicPath = `/vite/client`
const devInjectionCode = `\n<script type="module">import "${clientPublicPath}"</script>\n`
async function rewriteHtml(importer: string, html: string) {
return injectScriptToHtml(html, devInjectionCode)
}
當request.path路徑是/ vite / client時,請求獲取對應的客戶端代碼,因此在客戶端中我們創建了一個websocket服務並與服務端建立了連接。Vite會接受到來自客戶端的消息。通過不同的消息觸發一些事件。做到瀏覽器端的即時熱模塊更換(熱更新)。
// Listen for messages
socket.addEventListener('message', async ({ data }) => {
const payload = JSON.parse(data) as HMRPayload | MultiUpdatePayload
if (payload.type === 'multi') {
payload.updates.forEach(handleMessage)
} else {
handleMessage(payload)
}
})
async function handleMessage(payload: HMRPayload) {
const { path, changeSrcPath, timestamp } = payload as UpdatePayload
console.log(path)
switch (payload.type) {
case 'connected':
console.log(`[vite] connected.`)
break
case 'vue-reload':
queueUpdate(
import(`${path}?t=${timestamp}`)
.catch((err) => warnFailedFetch(err, path))
.then((m) => () => {
__VUE_HMR_RUNTIME__.reload(path, m.default)
console.log(`[vite] ${path} reloaded.`)
})
)
break
case 'vue-rerender':
const templatePath = `${path}?type=template`
import(`${templatePath}&t=${timestamp}`).then((m) => {
__VUE_HMR_RUNTIME__.rerender(path, m.render)
console.log(`[vite] ${path} template updated.`)
})
break
case 'style-update':
// check if this is referenced in html via <link>
const el = document.querySelector(`link[href*='${path}']`)
if (el) {
el.setAttribute(
'href',
`${path}${path.includes('?') ? '&' : '?'}t=${timestamp}`
)
break
}
// imported CSS
const importQuery = path.includes('?') ? '&import' : '?import'
await import(`${path}${importQuery}&t=${timestamp}`)
console.log(`[vite] ${path} updated.`)
break
case 'style-remove':
removeStyle(payload.id)
break
case 'js-update':
queueUpdate(updateModule(path, changeSrcPath, timestamp))
break
case 'custom':
const cbs = customUpdateMap.get(payload.id)
if (cbs) {
cbs.forEach((cb) => cb(payload.customData))
}
break
case 'full-reload':
if (path.endsWith('.html')) {
// if html file is edited, only reload the page if the browser is
// currently on that page.
const pagePath = location.pathname
if (
pagePath === path ||
(pagePath.endsWith('/') && pagePath + 'index.html' === path)
) {
location.reload()
}
return
} else {
location.reload()
}
}
}
Vite做的一些優化
Vite基於的ES模塊,在使用某些模塊時。由於模塊依賴了另一些模塊,依賴的模塊又基於另一些模塊。會出現頁面初始化時一次發送多個模塊請求的情況。此處以lodash-es實際上,一共發送了651個請求。一共花費1.53s。
Vite爲了優化這個情況,給了一個Optimize指令。我們可以直接使用vite Optimize使用它
優化原理性webpack的dll插件,提前將package.json中依賴項打包成一個esmodule模塊。這樣在頁面初始化時能減少大量請求。
優化後僅發送了14個請求
順便提一嘴,有的人肯定會問:如果我的組件封裝很深,一個組件import了十個組件,十個組件又import了十個組件怎麼處理。這是粗略的提一下我的想法:
首先可以看到請求lodash時651個請求只耗時1.53s。這個耗時是完全可以接受的。
Vite是完全按需加載的,在頁面初始化時只會請求初始化頁面的一些組件。(使用一些如dynamic import的優化)
ES模塊是有一些優化的,瀏覽器會給請求的模塊做一次緩存。當請求路徑完全相同時,瀏覽器會使用瀏覽器緩存的代碼。關於ES模塊的更多信息可以看https://segmentfault.com/a/1190000014318751
Vite只是一個用於開發環境的工具,上線仍會打包成一個commonJs文件進行調用。正基於上述這些原因,Vite啓動的項目在剛進入頁面時會發送大量請求。但是它耗費的時候是完全可以接受的(會比webpack打包快)。而且由於緩存的原因,當修改代碼時,只會請求修改部分的代碼(發送請求會附上一個t = timestamp的參數)。
Vite vs Webpack
我們以vite與vue-cli創建的模板項目爲例。
從左到右依次是:vue-cli3 + vue3的演示,vite 1.0.0-rc + vue 3的演示,vue-cli3 + vue2的演示。-cli3啓動Vue2大概需要5s左右,vue-cli3啓動Vue3需要4s左右,而vite只需要1s左右的時間。從理論上講Vite是ES模塊實現的。增加。而Webpack隨着代碼體積的增加啓動時間是要明顯增加的。
Vite熱更新速度很難用圖直接比較(在項目多個時熱更新速度都挺快的),只能從理論上講講,因爲Vite修改代碼後只是重新請求修改部分的代碼不受代碼體積的影響,而且使用了esbuild這種理論上快的webpack打包數十倍的工具。因此,在webpack這種時候修改都需要重新打包bundle的項目是能明顯提升熱更新速度的。
已經說了這麼多,是不是很想在React中也嘗試Vite呢?由於社區的貢獻,Vite已經支持react開發了。你可以使用npm init vite-app --template react嘗試使用。
推薦閱讀
Vue3 全家桶 + Element Plus + Vite + TypeScript + Eslint 項目配置最佳實踐
關注下方公衆號,回覆 電子書,獲得 160 本精華電子書哦。
本文分享自微信公衆號 - 全棧修煉(QuanZhanXiuLian)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。