Vite 解析以及從 webpack 切換到 Vite 遇到的問題總結(jié)
作者: 德萊問
原文地址: https://segmentfault.com/a/1190000039690206
本文由作者授權轉(zhuǎn)載
hello,大家好,我是德萊問,今天為大家?guī)韛ite解析。
最后提供一個使用vite+react+concent的一個后臺項目。
寫在前面的話
vite,號稱是下一代前端開發(fā)和構(gòu)建工具。vite的出現(xiàn)得益于瀏覽器對module的支持,利用瀏覽器的新特性去實現(xiàn)了極速的開發(fā)體驗;能夠極快的實現(xiàn)熱重載(hmr).
開發(fā)模式下,利用瀏覽器的module支持,達到了極致的開發(fā)體驗;
正式環(huán)境的編譯打包,使用了首次提出tree-shaking的rollup進行構(gòu)建;
vite提供了很多的配置選項,包括vite本身的配置,esbuild的配置,rollup的配置等等,今天帶領大家從源碼的角度看看vite。
vite其實是可以分為三部分的,一部分是開發(fā)過程中的client部分;一部分是開發(fā)過程中的server部分;另外一部分就是與生產(chǎn)有關系的打包編譯部分,由于vite打包編譯其實是用的rollup,我們不做解析,只看前兩部分。
vite-client
vite的client其實是作為一個單獨的模塊進行處理的,它的源碼是放在packages/vite/src/client;這里面有四個文件:
client.ts:主要的文件入口,下面著重介紹; env.ts:環(huán)境相關的配置,這里會把我們在vite.config.js(vite配置文件)的define配置在這里進行處理; overlay.ts: 這個是一個錯誤蒙層的展示,會把我們的錯誤信息進行展示; tsconfig.json:這就是ts的配置文件了。
工具部分
client里面是提供了一系列工具函數(shù),主要是為了hmr;

websocket部分
建立websocket連接 調(diào)用上面的overlay進行錯誤展示 Message通信
其中message通信部分有多種事件類型,可以參見下圖:

舉例說明
使用vite-app創(chuàng)建了一個簡單的demo:
yarn create @vitejs/app my-react-ts-app --template react-ts
使用以上命令,可以簡單的創(chuàng)建一個react-ts的vite應用。
npm install
npm run dev
執(zhí)行以上命令,進行安裝依賴,然后啟動服務,打開瀏覽器: http://localhost:3000/,network界面,可以看到有如下請求:

我把這幾種類型的數(shù)據(jù)進行了劃分,按照不同的類型進行不同的劃分:

咱們接下來來分析下,html的內(nèi)容:
<!DOCTYPE html>
<html lang="en">
<head>
<script type="module" src="/@vite/client"></script>
<script type="module">
import RefreshRuntime from "/@react-refresh"
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true
</script>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
可以看到,涉及到js的一共三塊:
client,請求路徑為/@vite/client,請注意這個路徑,這是vite本身的依賴的路徑; react-refresh的模塊代碼,這是插件react-refresh注入的代碼;代碼內(nèi)部又請求了@react-refresh,這是插件react-refresh的sdk的請求; main,請求路徑為/src/main/tsc,這是與咱們項目中的真實代碼相關的;
除了上面的三個外,還有一個env,請求路徑為/@vite/env.js,這個就是@vite/client內(nèi)部發(fā)出的對env依賴的請求:import '/node_modules/vite/dist/client/env.js';;
當然還有@react-refresh的sdk請求;
除了上面所提到的js之外,其他的請求其實就是我們項目代碼里面的請求了;
client第一步要做的事情就是建立websocket通信通道,可以看到上面的websocket類型的localhost請求,這就是client與server端通信,進行熱更新等的管道。
vite- server
說完了client,我們回到server部分,入口文件為packages/vite/src/node/serve.ts,最主要的邏輯其實是在packages/vite/src/node/server/index.ts;我們暫且把server端稱為node端,node端主要包含幾種類型文件的處理,畢竟這只是個代理服務器;

我們從幾個部分來看看這幾種類型的處理
node watcher
watcher的主要作用是對于文件變化的監(jiān)聽,然后與client端進行通信:
監(jiān)聽的目錄為整個項目的根目錄,watchOptions為vite.config.js里面的server.watch配置,初始化代碼如下:
// 使用chokidar進行對文件目錄的監(jiān)聽,
const watcher = chokidar.watch(path.resolve(root), {
ignored: ['**/node_modules/**', '**/.git/**', ...ignored],
ignoreInitial: true,
ignorePermissionErrors: true,
...watchOptions
}) as FSWatcher
啟動對文件的監(jiān)聽:
// 如果發(fā)生改變,調(diào)用handleHMRUpdate,
watcher.on('change', async (file) => {
file = normalizePath(file)
// invalidate module graph cache on file change
moduleGraph.onFileChange(file)
if (serverConfig.hmr !== false) {
try {
await handleHMRUpdate(file, server)
} catch (err) {
ws.send({
type: 'error',
err: prepareError(err)
})
}
}
})
// 增加文件連接
watcher.on('add', (file) => {
handleFileAddUnlink(normalizePath(file), server)
})
// 減少文件連接
watcher.on('unlink', (file) => {
handleFileAddUnlink(normalizePath(file), server, true)
})
監(jiān)聽對應的事件所對應的處理函數(shù)在packages/vite/src/node/server/hmr.ts文件里面。再細節(jié)的處理,我們不做說明了,其實里面邏輯是差不太多的,最后都是調(diào)用了websocket,發(fā)送到client端。
node 依賴類型
依賴類型,其實也就是node_modules下面的依賴包,例如:
這些包屬于基本不會變的類型,vite的做法是把這些依賴,在服務啟動的時候放到.vite目錄下面,收到的請求直接去.vite下面獲取,然后返回。
node 靜態(tài)資源
靜態(tài)資源其實也就是我們了解和熟悉的public/下面的或者static/下面的內(nèi)容,這些資源屬于靜態(tài)文件,例如:
這樣的數(shù)據(jù),vite不做任何處理,直接返回。
node html
對于入口文件index.html,我們這里暫且只講單入口文件,多入口文件vite也是支持的,詳情可見多頁面應用;
// 刪減后的代碼如下
// @file packages/vite/src/node/server/middlewares/indexHtml.ts
export function indexHtmlMiddleware(server){
return async (req, res, next) => {
const url = req.url && cleanUrl(req.url)
const filename = getHtmlFilename(url, server)
try {
// 從本地讀取index.html的內(nèi)容
let html = fs.readFileSync(filename, 'utf-8')
// dev模式下調(diào)用createDevHtmlTransformFn轉(zhuǎn)換html的內(nèi)容,插入兩個script
html = await server.transformIndexHtml(url, html)
// 把html的內(nèi)容返回。
return send(req, res, html, 'html')
} catch (e) {
return next(e)
}
}
}
對于入口文件index.html,vite首先會從硬盤上讀取文件的內(nèi)容,經(jīng)過一系列操作后,把操作后的內(nèi)容進行返回,我們來看看這個一系列操作:
調(diào)用createDevHtmlTransformFn去獲取處理函數(shù):
// @file packages/vite/src/node/plugins/html.ts
export function resolveHtmlTransforms(plugins: readonly Plugin[]) {
const preHooks: IndexHtmlTransformHook[] = []
const postHooks: IndexHtmlTransformHook[] = []
for (const plugin of plugins) {
const hook = plugin.transformIndexHtml
if (hook) {
if (typeof hook === 'function') {
postHooks.push(hook)
} else if (hook.enforce === 'pre') {
preHooks.push(hook.transform)
} else {
postHooks.push(hook.transform)
}
}
}
return [preHooks, postHooks]
}
// @file packages/vite/src/node/server/middlewares/indexHtml.ts
export function createDevHtmlTransformFn(server: ViteDevServer) {
const [preHooks, postHooks] = resolveHtmlTransforms(server.config.plugins)
return (url: string, html: string): Promise<string> => {
return applyHtmlTransforms(
html,
url,
getHtmlFilename(url, server),
[...preHooks, devHtmlHook, ...postHooks],
server
)
}
}
此處,我們還是拿react項目為例,react-refresh的插件被插入到了postHooks里面;最后其實是返回了一個無名的promise類型的函數(shù);此處也就是閉包了。無名函數(shù)里面調(diào)用的是applyHtmlTransforms,我們來看下參數(shù):
html為根目錄下面的index.html的內(nèi)容 url為/index.html, 第三個參數(shù)的執(zhí)行結(jié)果為/index.html 第四個參數(shù)為一個大數(shù)組,prehooks是空的,第二個為是vite自己的/@vite/client鏈接的返回函數(shù),第三個是有一個react-refresh的插件在里面的 第五個參數(shù)為當前server
接下來是applyHtmlTransforms的調(diào)用時刻,此處會改寫html內(nèi)容,然后返回。

最后處理好的html的內(nèi)容,就是我們上面看到的html的內(nèi)容。
node 其他類型
暫時把其他類型都算為其他類型,包括@vite開頭的/@vite/client和業(yè)務相關的請求;這些請求都會走同一個transformMiddleware中間件。此中間件所做的工作如下:
// @file packages/vite/src/node/server/middlewares/transform.ts

其實上面的邏輯正常走下來,是會到命中緩存和未命中緩存中的,二選一,命中就直接返回了,沒有命中的話,就是走到了transform,接下來我們看下調(diào)用transform的過程:
// @file packages/vite/src/node/server/transformRequest.ts
// 調(diào)用插件獲取當前請求的id,如/@react-refresh,當然也有獲取不到的情況;
const id = (await pluginContainer.resolveId(url))?.id || url
// 調(diào)用插件獲取插件返回的內(nèi)容,如/@react-refresh,肯定有不是插件返回的情況,
const loadResult = await pluginContainer.load(id, ssr)
// 接下來是重點
// 如果沒有獲取到結(jié)果,也就是不是插件類型的請求,如我們的入口文件/src/main.tsx
if (loadResult == null) {
// 從硬盤讀取非插件提供的返回結(jié)果
code = await fs.readFile(file, 'utf-8')
} else {
if (typeof loadResult === 'object') {
code = loadResult.code
map = loadResult.map
} else {
code = loadResult
}
}
}
// 啟動文件監(jiān)聽,調(diào)用watcher,和上面講到的watcher遙相呼應
ensureWatchedFile(watcher, mod.file, root)
// 代碼運行到這里,是獲取到內(nèi)容了不假,不過code還是源文件,也就是編寫的文件內(nèi)容
// 下面的transform是開始進行替換
const transformResult = await pluginContainer.transform(code, id, map, ssr)
code = transformResult.code!
map = transformResult.map
return (mod.transformResult = {
code,
map,
etag: getEtag(code, { weak: true })
} as TransformResult)
大體的流程如下:

async transform(code, id, inMap, ssr) {
const ctx = new TransformContext(id, code, inMap as SourceMap)
ctx.ssr = !!ssr
for (const plugin of plugins) {
if (!plugin.transform) continue
ctx._activePlugin = plugin
ctx._activeId = id
ctx._activeCode = code
let result
try {
result = await plugin.transform.call(ctx as any, code, id, ssr)
} catch (e) {
ctx.error(e)
}
if (!result) continue
if (typeof result === 'object') {
code = result.code || ''
if (result.map) ctx.sourcemapChain.push(result.map)
} else {
code = result
}
}
return {
code,
map: ctx._getCombinedSourcemap()
}
},

其實到這里,我們對于vite server所實現(xiàn)的功能基本是已經(jīng)清楚了,代理服務器,然后對引用修改為自己的規(guī)則,對自己的規(guī)則進行解析處理。尤為重要的其實是vite:import-analysis這個插件。
vite + react
開始之前先附上地址:github:vite-react-concent-pro【1】;這個項目是由github:webpack-react-concent-pro項目改過來的,業(yè)務邏輯代碼模塊沒動,只改動了編譯打包部分。
在這里說下由webpack改為vite的過程和其中遇到的一些問題。
項目的改動其實是不大的,基本就是clone下項目下來后,把webpack相關的依賴去掉,然后換成vite,記得加上react的vite插件:@vitejs/plugin-react-refresh;換完以后,因為我們項目中的引用路徑是在src文件夾下面的,所以我們需要為vite提供下別名:
resolve: {
alias: { // 別名
"configs": path.resolve(__dirname, 'src/configs'),
"components": path.resolve(__dirname, 'src/components'),
"services": path.resolve(__dirname, 'src/services'),
"pages": path.resolve(__dirname, 'src/pages'),
"types": path.resolve(__dirname, 'src/types'),
"utils": path.resolve(__dirname, 'src/utils'),
},
},
這樣我們不用改動里面的引用,就可以讓vite知道去哪里找哪個文件了。引用中有對process.env.***類似的引用,用此來判斷一些環(huán)境相關的邏輯,在vite中是沒有了,vite的環(huán)境變量是通過import.meta.env.***;
改完這些執(zhí)行npm run start,是可以正常跑起來的。
坑1
在執(zhí)行npm run build后,我們在進行預覽的時候,執(zhí)行npm run preview,出現(xiàn)了下面的畫面:
出現(xiàn)了這種沒見過的錯誤,然后我們的解決辦法是什么呢?
首先,把壓縮給干掉,別壓縮了,壓縮后的代碼全都是abcd,啥也看不出來;干掉的方式是改vite的配置:
build: {
minify: false, // 是否進行壓縮,boolean | 'terser' | 'esbuild',默認使用terser
manifest: false, // 是否產(chǎn)出maifest.json
sourcemap: false, // 是否產(chǎn)出soucemap.json
outDir: 'build', // 產(chǎn)出目錄
},
我們把minify改為了false,再重新執(zhí)行build和preview命令,可以看到了精確的行,到底是哪里進行了報錯.
關于最后是怎么解決的呢?TMD,竟然是一個object-inspect的庫,引用了一個util的包,然后咱們的node_modules里面沒有util的包。
這些個中緣由,就不多說了,折騰了兩三個小時,解決辦法就是一個命令:npm i -S util。
重新執(zhí)行build和preview后,正常了。
坑2
本地開發(fā)啟動start,build+preview都OK了,接下來,就得試試單測了。執(zhí)行npm run test。果不其然,報錯了,原因是沒有babel-preset-react-app的babel配置。
那我們增加上配置那不就好了嘛?
我們在package.json里面增加了babel的配置:
"bable": {
"presets": [
"react-app"
],
}
接著我們運行npm run test;嗯,OK了,跑成功了。
我們再重新測試下,執(zhí)行npm run start,TMD掛了,跑步起來了!!!
**
Using babel-preset-react-app requires that you specify NODE_ENV or BABEL_ENV environment variables. Valid values are "development", "test", and "production". Instead, received: undefined
**
上面這句話啥意思呢?就是我們的babel-preset-react-app這個包運行的時候需要一個process.env.NODE_ENV或者process.env.BABEL_ENV的變量。我們本著vite不在process上面搞事情的原則,這個問題是解決不了的,也就是說,不能通過配置的方式來實現(xiàn)babel的配置了,那怎么整??
查了下babel-preset-react-app這個包的源碼,發(fā)現(xiàn)是可以通過參數(shù)的形式傳遞進去的,所以我們得從test的時候所做的事情入手,test的時候,我們運行的是jest,jest是有它的配置文件的,叫jest.config.js;jest的配置文件里面有一個transform的對象,這個對象里面是有了babel-jest這個庫,這也就是babel了。
我們得在這里搞點事情,最后經(jīng)過多次調(diào)試,配置是這樣的了:
// vite react項目里面單測需要在這里把babel-react-app傳遞進去,不可在項目中或者package.json里面配置babel
transform: {
// vite react項目里面單測需要在這里把babel-react-app傳遞進去,不可在項目中或者package.json里面配置babel
"^.+\\.(js|jsx|ts|tsx)$": ["<rootDir>/node_modules/babel-jest", {"presets": ['babel-preset-react-app'] }],
"^.+\\.css$": "<rootDir>/config/jest/cssTransform.js",
"^(?!.*\\.(js|jsx|ts|tsx|css|json)$)": "<rootDir>/config/jest/fileTransform.js",
},
這就是個最大的坑,又耗費了我2個小時的時間折騰這個。
寫在最后
vite已經(jīng)發(fā)布了2版本,在公司內(nèi)部的項目中,是可以進行使用了,由于線上線下跑的不是一套代碼,尤老板還專門提供了個preview的功能,建議大家可以嘗試一下。
另外上面說到的那個項目:github:vite-react-concent-pro,目前包含的功能也是比較齊全的:
start:本地啟動開發(fā)與調(diào)試 build:編譯打包 preview:預覽打包完成的代碼: test:單測 snap:生成快照
該項目整合了react、concent(一個特別好用的狀態(tài)管理庫)、antd、react-router-dom、axios等,可以0成本接入開發(fā)。
當然了如果你的現(xiàn)有項目想改成vite,也是很簡單的:
把該項目clone下來,把src下面的內(nèi)容刪掉; 把你的老項目的src下面的文件搬到這個項目的src文件下面,然后改改alias和process.env; 記得index.html要改成你的入口文件哦
接下來就等著見證奇跡吧
使用了vite之后,npm run start能夠提高80%左右;npm run build能夠提高50%左右
!嗯,真香~
最后,要是覺得寫的還不錯,記得點個贊,歡迎關注我的github: https://github.com/dravenww/blob
1.看到這里了就點個在看支持下吧,你的「點贊,在看」是我創(chuàng)作的動力。
2.關注公眾號
程序員成長指北,回復「1」加入高級前端交流群!「在這里有好多 前端 開發(fā)者,會討論 前端 Node 知識,互相學習」!3.也可添加微信【ikoala520】,一起成長。
“在看轉(zhuǎn)發(fā)”是最大的支持
