下一代前端構(gòu)建工具 - Vite 2.x 源碼級(jí)分析
業(yè)務(wù)背景
筆者所負(fù)責(zé)的業(yè)務(wù) X (之后都以 “X 項(xiàng)目” 代表此業(yè)務(wù))對(duì)接眾多業(yè)務(wù)線,如果在業(yè)務(wù)線不斷增多,而人員又無(wú)法快速補(bǔ)充的前提下,有必要給開發(fā)提效。
為何要給開發(fā)提效?
說到這里,有人就會(huì)問了,我 Webpack 開項(xiàng)目 “只要幾十秒” 就能開起來,它不香嗎?就算我項(xiàng)目再大,改一行代碼熱更新好幾秒我也能忍受啊,大家不都是這么過來的嗎?
當(dāng)然,公說公有理婆說婆有理,那么我就來講講我的道理。
筆者每周會(huì)對(duì)接很多的需求,需求池爆棚的同時(shí),排期表也是層巒疊嶂的。。。
有時(shí)候一堆需求突然要一起上線,而且很急,為了穩(wěn)妥,你需要事先測(cè)試一下有沒有問題,然后才能 PR/跑流水線啥的;還有的時(shí)候,你突然來了點(diǎn)小想法想要做個(gè)技術(shù)優(yōu)化,這個(gè)時(shí)候,打開 X 項(xiàng)目,在命令行開啟項(xiàng)目,不僅開的巨慢,而且 ctrl+c 還關(guān)不掉。。。

然后你改小小的一行,變成了這樣:

當(dāng)然你可以跟我說,這是你優(yōu)化的問題,只要我 node 版本更新/多線程/拆包。。(這里可以 Google 搜:如何優(yōu)化 Webpack 打包速度)
但是我想讓你靜下心來看看下面的畫面:

上述項(xiàng)目是使用 Vite 重構(gòu)之后的 X 項(xiàng)目,同樣多的文件,同樣多的依賴,可以看到第一次 2000+ ms,之后都是 600+ ms 就可以跑起來,而且即開即關(guān),沒有心理負(fù)擔(dān),快到飛起,DX 無(wú)敵。
Vite 是什么?
好,講完了業(yè)務(wù)背景之后,我們?cè)賮砜纯次覀兘裉斓呢i腳:“Vite”,為什么會(huì)有它,它是個(gè)啥,又做了啥,業(yè)務(wù)項(xiàng)目現(xiàn)在可以用嗎?生態(tài)如何?
先找個(gè)知乎截個(gè)圖:

再搬一下 Github 的倉(cāng)庫(kù)圖:

上面的知乎、Github 鏈接都可打開,感興趣的同學(xué)可以自己去看看。
接下來來回答一下這一節(jié)開篇提出的幾個(gè)問題。
為什么會(huì)有 Vite ?
我想最直觀的回答就是:“程序員都愛折騰吧”。
但其實(shí)是,任何一個(gè)工具/產(chǎn)品的誕生并流行,其實(shí)都和當(dāng)下所處的時(shí)機(jī)有關(guān)。
而出發(fā) Vite 誕生的幾個(gè)必要條件我總結(jié)如下:
傳統(tǒng)的構(gòu)建工具如 Webpack/Rollup/Parcel 等都太慢了,動(dòng)輒十幾秒、幾十秒的 ES 標(biāo)準(zhǔn)普及越來越快,現(xiàn)代瀏覽器支持了原生的 ES Module 使用,類似這樣的語(yǔ)法 <script type="module" src="/src/main.js" />,就可以在main.js里面使用標(biāo)準(zhǔn)的 ES 模塊語(yǔ)法如import/export等,然后瀏覽器會(huì)根據(jù)main.js的import依賴,自動(dòng)發(fā)起 http 請(qǐng)求依賴的模塊,可以通過下面這個(gè)視頻直觀的看出來:
跨語(yǔ)言(基于 Go)、更快的構(gòu)建工具已經(jīng)誕生并趨于成熟,最直觀的就是 esbuild: 
可以看 esbuild 給出的 benchmark 表:

最直觀的對(duì)比,相比第二名的 rollup + terser ,提升了約 100 倍,不講武德。。。
當(dāng)然第四點(diǎn),也是一個(gè)比較致命的一點(diǎn),已經(jīng)有個(gè)現(xiàn)成的模板可以 “抄”,比如 snowpack ??,它同時(shí)是去年 2020 年 JavaScript 最具創(chuàng)新力的打包工具:


但是 snowpack 開發(fā)生產(chǎn)是一致的,都使用 esbuild + Browser Native ESM 的概念,這就導(dǎo)致很多瀏覽器尤其是低版本的 IE 是不支持這些最新的 ES 和瀏覽器特性的,壓根就用不了,相對(duì)使用場(chǎng)景就比較有限,明顯是一個(gè)超前于市場(chǎng)的產(chǎn)品。
而尤大最厲害的一點(diǎn)就是,我開發(fā)用 snowpack 的那套概念,生產(chǎn)打包用 Rollup 來做,還搞個(gè)開發(fā)時(shí)的插件機(jī)制也兼容 Rollup,這就厲害了,開發(fā)很快,生產(chǎn)也照樣可以普及的用,這個(gè)應(yīng)用場(chǎng)景就很大了,自然獲得了更多的追捧,這從 Github 的 Star 就可以看出來:

snowpack

Vite
而在 2 個(gè)多月前,Vite 的 Star 還沒有 snowpack 高。。。
Vite 是啥?
上面已經(jīng)提到了,Vite 是一個(gè)基于瀏覽器原生 ESM 的開發(fā)服務(wù)器/打包工具等,特點(diǎn)就是一個(gè)字 “快”,用尤大的話說就是:
Vite 有多快?在 Repl.it 上從零啟動(dòng)一個(gè)基于 Vite 的 React 應(yīng)用,瀏覽器頁(yè)面加載完畢的時(shí)候,CRA(create-react-app)甚至還沒有裝完依賴。
至于 Vite 做了啥,生態(tài)如何,在業(yè)務(wù)項(xiàng)目中是否可以使用,我留一點(diǎn)懸念,留在后續(xù)講解。(避免你看到這里就不看了,我的重頭戲還沒來呢。。。)
“快” 背后運(yùn)行的原理是什么?(Vite 做了啥)
先把調(diào)試環(huán)境搭好
好了,說了這么多可能沒什么體感,有些人可能就不滿了,你個(gè)技術(shù)分享,搞了半天沒有一行代碼。。。
Talk is cheap,show me the code!
講解一門技術(shù)最后的方式就是 “Learn by doing”,下面我們就以這種方式來講解 Vite 源碼。
首先初始化一個(gè) Vite 項(xiàng)目:
yarn create @vitejs/app app-vue2 # 選中模板為 Vue,語(yǔ)言為 Javascript
cd app-vue2 && yarn
yarn dev
閃電起項(xiàng)目 ??,不要幾十秒,也不用幾秒,只需 851ms,只需 851ms!
我們先將 Vite 源碼拷貝到本地,然后在 app-vue2 項(xiàng)目中 link vite 依賴,開始調(diào)試源碼:
git clone [email protected]:vitejs/vite.git
cd vite && yarn
yarn build # 構(gòu)建 vite 包
cd packages/vite && yarn link
接著去到 app-vue2 下面,關(guān)閉服務(wù)器,然后執(zhí)行如下命令:
yarn link vite
yarn dev
打開瀏覽器可以看到如下界面:

可以看到,我們的 network 面板里面加載了如下幾個(gè)模塊:
localhost client main.js env.js vue.js?v=92bdfa16 App.vue HelloWorld.vue App.vue?vue&type=style&index=0&lang.css HelloWorld.vue?vue&type=style&index=0&scoped=true&lang.css logo.png
對(duì)應(yīng)到我們?cè)创a里面就是如下這幾塊:

上面的 10 個(gè)請(qǐng)求可以分為以下幾類:
Html 代碼,對(duì)應(yīng) localhostVite 相關(guān)的代碼: client,以及client里面引入的env.js用戶側(cè) JS 相關(guān)的代碼: main.js、App.vue、HelloWorld.vueNPM 依賴相關(guān)的代碼: vue.js?v=92bdfa16CSS 相關(guān)的代碼: App.vue?vue&type=style&index=0&lang.css、HelloWorld.vue?vue&type=style&index=0&scoped=true&lang.css
當(dāng)然你可以使用 TS、Less/Sass 諸如此類的,但是為了講解需要,上面的內(nèi)容基本是最簡(jiǎn)單也相對(duì)比較全面的了。
從上面的內(nèi)容我們可以看出來,Vite 相比 Webpack/Rollup 等主要的區(qū)別如下:
起服務(wù)快 并非打包成單一的 xxx.js文件,然后 html 文件引入使用,而是基本保持和開發(fā)時(shí)的目錄結(jié)構(gòu)和引用關(guān)系一致,借助瀏覽器對(duì) ES Module 的支持,按需引用
接下來我們就著重從源碼的角度分析這兩點(diǎn)不同!
從 CLI 入口開始
故事的起點(diǎn)要從我們 app-vue2 的 yarn dev 腳本開始說起,yarn dev 實(shí)際上就是允許了 vite 命令,而 vite 命令對(duì)應(yīng)到 Vite 源碼中的如下位置:
packages/vite/src/node/cli.ts
cli
.command('[root]') // default command
.alias('serve')
.option('--host <host>', `[string] specify hostname`)
// ... options
.option(
'--force',
`[boolean] force the optimizer to ignore the cache and re-bundle`
)
.action(async (root: string, options: ServerOptions & GlobalCLIOptions) => {
// output structure is preserved even after bundling so require()
// is ok here
const { createServer } = await import('./server')
try {
const server = await createServer({
root,
base: options.base,
mode: options.mode,
configFile: options.config,
logLevel: options.logLevel,
clearScreen: options.clearScreen,
server: cleanOptions(options) as ServerOptions
})
await server.listen()
} catch (e) {
createLogger(options.logLevel).error(
chalk.red(`error when starting dev server:\n${e.stack}`)
)
process.exit(1)
}
})
可以看到主要就是一個(gè)基于 cac 的命令行命令,主要的過程就是從 ./server 中導(dǎo)入 createServer ,然后創(chuàng)建一個(gè) server ,接著允許服務(wù)并監(jiān)聽端口,默認(rèn)為 3000 。
一個(gè) “簡(jiǎn)單” 的服務(wù)器
接下來再看看 server 文件中做了什么事情,主題邏輯代碼如下:
packages/vite/src/node/server/index.ts
https://github.com/vitejs/vite/blob/30ff5a235d2a832cb45a761a03c5947460417b40/packages/vite/src/node/server/index.ts#L295
export async function createServer(
inlineConfig: InlineConfig = {}
): Promise<ViteDevServer> {
const config = await resolveConfig(inlineConfig, 'serve', 'development')
// ...
const middlewares = connect() as Connect.Server
const httpServer = middlewareMode
? null
: await resolveHttpServer(serverConfig, middlewares)
// ...
const plugins = config.plugins
const container = await createPluginContainer(config, watcher)
const moduleGraph = new ModuleGraph(container)
// ...
const server: ViteDevServer = {
config: config,
middlewares,
get app() {
config.logger.warn(
`ViteDevServer.app is deprecated. Use ViteDevServer.middlewares instead.`
)
return middlewares
},
httpServer,
pluginContainer: container,
moduleGraph,
transformWithEsbuild,
transformRequest(url, options) {
return transformRequest(url, server, options)
},
transformIndexHtml: null as any,
listen(port?: number, isRestart?: boolean) {
return startServer(server, port, isRestart)
},
_optimizeDepsMetadata: null,
_ssrExternals: null,
_globImporters: {},
_isRunningOptimizer: false,
_registerMissingImport: null,
_pendingReload: null
}
server.transformIndexHtml = createDevHtmlTransformFn(server)
// apply server configuration hooks from plugins
const postHooks: ((() => void) | void)[] = []
for (const plugin of plugins) {
if (plugin.configureServer) {
postHooks.push(await plugin.configureServer(server))
}
}
// ...
// main transform middleware
middlewares.use(transformMiddleware(server))
// ...
// spa fallback
if (!middlewareMode) {
middlewares.use(
history({
logger: createDebugger('vite:spa-fallback'),
// support /dir/ without explicit index.html
rewrites: [
{
from: // $ /,
to({ parsedUrl }: any) {
createLogger('info').info(`rewrite: ${JSON.stringify(parsedUrl)}`)
const rewritten = parsedUrl.pathname + 'index.html'
if (fs.existsSync(path.join(root, rewritten))) {
return rewritten
} else {
return `/index.html`
}
}
}
]
})
)
}
// ...
if (!middlewareMode) {
// transform index.html
middlewares.use(indexHtmlMiddleware(server))
// handle 404s
middlewares.use((_, res) => {
res.statusCode = 404
res.end()
})
}
// error handler
middlewares.use(errorMiddleware(server, middlewareMode))
// ...
const runOptimize = async () => {
if (config.cacheDir) {
server._isRunningOptimizer = true
try {
server._optimizeDepsMetadata = await optimizeDeps(config)
} finally {
server._isRunningOptimizer = false
}
server._registerMissingImport = createMissingImporterRegisterFn(server)
}
}
// ...
// overwrite listen to run optimizer before server start
const listen = httpServer.listen.bind(httpServer)
httpServer.listen = (async (port: number, ...args: any[]) => {
try {
await container.buildStart({})
await runOptimize()
} catch (e) {
httpServer.emit('error', e)
return
}
return listen(port, ...args)
}) as any
httpServer.once('listening', () => {
// update actual port since this may be different from initial value
serverConfig.port = (httpServer.address() as AddressInfo).port
})
// ...
return server
}
創(chuàng)建 server 的主體邏輯見上面的文件,主要做了如下幾件事:
獲取在起服務(wù)時(shí)需要的 config配置,所有的配置內(nèi)容都是在resolveConfig這個(gè)函數(shù)里面處理的,包括plugins用戶插件和內(nèi)建插件、cacheDirnpm 依賴預(yù)構(gòu)建之后的緩存目錄、在之后瀏覽器按需獲取文件時(shí)對(duì)請(qǐng)求進(jìn)行截獲,返回相對(duì)應(yīng)內(nèi)容的處理函數(shù)createResolve,以及定義在vite.config.js里面的resolve,包含用戶自定義的一些alias文件的處理等。
const config = await resolveConfig(inlineConfig, "serve", "development");
初始化
connect框架生成app實(shí)例、傳給http.createServer生成httpServer,然后注冊(cè)一系列中間件用于處理瀏覽器請(qǐng)求,包括對(duì)/、js/css/vue的請(qǐng)求等:主要使用 sirv這個(gè)包,將/public變?yōu)殪o態(tài)資源目錄的servePublicMiddleware中間件,可以通過http://localhost:3000/public/xxx獲取public目錄下的xxx文件用于處理 js/css/vue等請(qǐng)求,并返回轉(zhuǎn)換后的代碼的transformMiddleware中間件用于處理 /,并重定向到/index.html的history中間件
const middlewares = connect() as Connect.Server
const httpServer = middlewareMode
? null
: await resolveHttpServer(serverConfig, middlewares)
// ...
middlewares.use(servePublicMiddleware(config.publicDir))
// main transform middleware
middlewares.use(transformMiddleware(server))
// ...
middlewares.use(
history({
logger: createDebugger('vite:spa-fallback'),
// support /dir/ without explicit index.html
rewrites: [
{
from: // $ /,
to({ parsedUrl }: any) {
createLogger('info').info(`rewrite: ${JSON.stringify(parsedUrl)}`)
const rewritten = parsedUrl.pathname + 'index.html'
if (fs.existsSync(path.join(root, rewritten))) {
return rewritten
} else {
return `/index.html`
}
}
}
]
})
)
用于處理插件的 container,它是由createPluginContainer來創(chuàng)建,以及用于構(gòu)建模塊依賴圖的moduleGraph,它是由new ModuleGraph(container)創(chuàng)建,這兩個(gè)函數(shù)將在之后講解:
const container = await createPluginContainer(config, watcher);
// ...
const moduleGraph = new ModuleGraph(container);
用于對(duì) html進(jìn)行轉(zhuǎn)換,注入一些腳本的transformIndexHtml函數(shù),它由createDevHtmlTransformFn函數(shù)創(chuàng)建,它將會(huì)在indexHtmlMiddleware中間件執(zhí)行的過程中運(yùn)行createDevHtmlTransformFn函數(shù)中添加的devHtmlHook,在html文件中注入我們?cè)?localhostnetwork 面板中看到的<script type="module" src="/@vite/client"></script>腳本,運(yùn)行vite相關(guān)的client腳本內(nèi)容。
server.transformIndexHtml = createDevHtmlTransformFn(server);
// ...
middlewares.use(indexHtmlMiddleware(server));
用于進(jìn)行依賴預(yù)構(gòu)建的優(yōu)化函數(shù) runOptimize,用于將 npm 依賴以及用戶指定的需要緩存的依賴進(jìn)行打包,并緩存在node_modules/.vite目錄下,針對(duì)這些文件的 http 請(qǐng)求都將添加緩存。
const runOptimize = async () => {
if (config.cacheDir) {
server._isRunningOptimizer = true
try {
server._optimizeDepsMetadata = await optimizeDeps(config)
} finally {
server._isRunningOptimizer = false
}
server._registerMissingImport = createMissingImporterRegisterFn(server)
}
}
// overwrite listen to run optimizer before server start
const listen = httpServer.listen.bind(httpServer)
httpServer.listen = (async (port: number, ...args: any[]) => {
try {
await container.buildStart({})
await runOptimize()
} catch (e) {
httpServer.emit('error', e)
return
}
return listen(port, ...args)
}) as any
httpServer.once('listening', () => {
// update actual port since this may be different from initial value
serverConfig.port = (httpServer.address() as AddressInfo).port
})
在 cli 中調(diào)用 server.listen() 后,會(huì)首先執(zhí)行 container.buildStart({}) 調(diào)用所有注冊(cè)插件的 buildStart 鉤子函數(shù),然后運(yùn)行 runOptimize 依賴預(yù)構(gòu)建函數(shù),最后是監(jiān)聽端口,接收來自瀏覽器的請(qǐng)求。
傳說中的依賴預(yù)構(gòu)建
從上面的整體代碼我們可以看到,在開啟服務(wù),監(jiān)聽端口接收來自瀏覽器的請(qǐng)求之前,會(huì)運(yùn)行插件 container 的 buildStart 鉤子,進(jìn)而運(yùn)行所有插件的 buildStart 鉤子,以及進(jìn)行依賴預(yù)構(gòu)建,運(yùn)行 runOptimize 函數(shù)。
可以看到整個(gè)運(yùn)行 Node 服務(wù)的生命周期中,都是一些基本不怎么耗時(shí)的收集 config 、注冊(cè)各種中間件、初始化一些之后會(huì)用到的插件容器 container 以及模塊依賴圖 moduleGraph 等,其中最耗時(shí)的就是依賴預(yù)構(gòu)建了,它主要將所有的 npm 依賴構(gòu)建成單一的可緩存文件,也是 Vite 服務(wù)開啟過程中的一個(gè)最大的時(shí)間瓶頸,因?yàn)?Vite 針對(duì)用戶項(xiàng)目中的各種文件都是不做打包處理的,而是在瀏覽器運(yùn)行時(shí)按需請(qǐng)求,并進(jìn)行轉(zhuǎn)換處理。
這可以看做是 Vite 在極致的起服務(wù)速度和極慢的瀏覽器 “首屏出圖” 速度之間的一個(gè)權(quán)衡,而極慢的瀏覽器 “首屏出圖” 速度則是我會(huì)在后文提到的 Vite 有什么 “不好” 的內(nèi)容之一。
下面就來分析一下這個(gè)神奇的預(yù)構(gòu)建過程。
const runOptimize = async () => {
if (config.cacheDir) {
server._isRunningOptimizer = true;
try {
server._optimizeDepsMetadata = await optimizeDeps(config);
} finally {
server._isRunningOptimizer = false;
}
server._registerMissingImport = createMissingImporterRegisterFn(server);
}
};
可以看到函數(shù)體,主要是執(zhí)行 optimizeDeps 函數(shù),返回依賴預(yù)構(gòu)建之后的元數(shù)據(jù),用于索引構(gòu)建之后的文件,以及映射構(gòu)建前后的文件路徑,然后注冊(cè) _registerMissingImport ,用于在項(xiàng)目運(yùn)行過程中添加新的 npm 依賴時(shí),也能預(yù)構(gòu)建到緩存目錄 node_modules/.vite 下。
下面分析一下 optimizeDeps 函數(shù):
packages/vite/src/node/optimizer/index.ts
https://github.com/vitejs/vite/blob/30ff5a235d2a832cb45a761a03c5947460417b40/packages/vite/src/node/optimizer/index.ts#L102
import { esbuildDepPlugin } from './esbuildDepPlugin'
import { ImportSpecifier, init, parse } from 'es-module-lexer'
import { scanImports } from './scan'
export async function optimizeDeps(
config: ResolvedConfig,
force = config.server.force,
asCommand = false,
newDeps?: Record<string, string> // missing imports encountered after server has started
): Promise<DepOptimizationMetadata | null> {
config = {
...config,
command: 'build'
}
const { root, logger, cacheDir } = config
const log = asCommand ? logger.info : debug
if (!cacheDir) {
log(`No cache directory. Skipping.`)
return null
}
const dataPath = path.join(cacheDir, '_metadata.json')
const mainHash = getDepHash(root, config)
const data: DepOptimizationMetadata = {
hash: mainHash,
browserHash: mainHash,
optimized: {}
}
if (!force) {
let prevData
try {
prevData = JSON.parse(fs.readFileSync(dataPath, 'utf-8'))
} catch (e) {}
// hash is consistent, no need to re-bundle
if (prevData && prevData.hash === data.hash) {
log('Hash is consistent. Skipping. Use --force to override.')
return prevData
}
}
if (fs.existsSync(cacheDir)) {
emptyDir(cacheDir)
} else {
fs.mkdirSync(cacheDir, { recursive: true })
}
let deps: Record<string, string>, missing: Record<string, string>
if (!newDeps) {
;({ deps, missing } = await scanImports(config))
} else {
deps = newDeps
missing = {}
}
// update browser hash
data.browserHash = createHash('sha256')
.update(data.hash + JSON.stringify(deps))
.digest('hex')
.substr(0, 8)
const include = config.optimizeDeps?.include
if (include) {
const resolve = config.createResolver({ asSrc: false })
for (const id of include) {
if (!deps[id]) {
const entry = await resolve(id)
if (entry) {
deps[id] = entry
} else {
throw new Error(
`Failed to resolve force included dependency: ${chalk.cyan(id)}`
)
}
}
}
}
const qualifiedIds = Object.keys(deps)
if (!qualifiedIds.length) {
writeFile(dataPath, JSON.stringify(data, null, 2))
log(`No dependencies to bundle. Skipping.\n\n\n`)
return data
}
const total = qualifiedIds.length
const maxListed = 5
const listed = Math.min(total, maxListed)
const extra = Math.max(0, total - maxListed)
const depsString = chalk.yellow(
qualifiedIds.slice(0, listed).join(`\n `) +
(extra > 0 ? `\n (...and ${extra} more)` : ``)
)
const flatIdDeps: Record<string, string> = {}
const idToExports: Record<string, ExportsData> = {}
const flatIdToExports: Record<string, ExportsData> = {}
await init
for (const id in deps) {
const flatId = flattenId(id)
flatIdDeps[flatId] = deps[id]
const entryContent = fs.readFileSync(deps[id], 'utf-8')
const exportsData = parse(entryContent) as ExportsData
for (const { ss, se } of exportsData[0]) {
const exp = entryContent.slice(ss, se)
if (/export\s+*\s+from/.test(exp)) {
exportsData.hasReExports = true
}
}
idToExports[id] = exportsData
flatIdToExports[flatId] = exportsData
}
const define: Record<string, string> = {
'process.env.NODE_ENV': JSON.stringify(config.mode)
}
for (const key in config.define) {
const value = config.define[key]
define[key] = typeof value === 'string' ? value : JSON.stringify(value)
}
const start = Date.now()
const result = await build({
entryPoints: Object.keys(flatIdDeps),
bundle: true,
keepNames: config.optimizeDeps?.keepNames,
format: 'esm',
external: config.optimizeDeps?.exclude,
logLevel: 'error',
splitting: true,
sourcemap: true,
outdir: cacheDir,
treeShaking: 'ignore-annotations',
metafile: true,
define,
plugins: [esbuildDepPlugin(flatIdDeps, flatIdToExports, config)]
})
const meta = result.metafile!
createLogger('info').info(`${JSON.stringify(meta)}`)
// the paths in `meta.outputs` are relative to `process.cwd()`
const cacheDirOutputPath = path.relative(process.cwd(), cacheDir)
for (const id in deps) {
const entry = deps[id]
data.optimized[id] = {
file: normalizePath(path.resolve(cacheDir, flattenId(id) + '.js')),
src: entry,
needsInterop: needsInterop(
id,
idToExports[id],
meta.outputs,
cacheDirOutputPath
)
}
}
writeFile(dataPath, JSON.stringify(data, null, 2))
return data
}
可以看到,上面的函數(shù)主要做了這么幾件事情:
接收 config,然后 data,形如 { hash, browserHash, optimized },其中browserHash主要用于瀏覽器獲取預(yù)構(gòu)建的 npm 依賴時(shí),添加的查詢字符串,用于在依賴變化時(shí),瀏覽器能更新緩存,也就是我們之前看到的vue.js?v=92bdfa16,這個(gè)92bdfa16,主要在處理瀏覽器請(qǐng)求時(shí),調(diào)用resolvePlugin時(shí),運(yùn)行tryNodeResolve函數(shù)對(duì) npm 依賴的請(qǐng)求添加這個(gè)browserHash;optimized則是形如npmDep: { file, src, needsInterop }的鍵值對(duì),比如vue依賴,則是如下內(nèi)容:
"vue": {
"file": "/Users/bytedance/Projectes/my-projects/learning/vite/app-vue2/node_modules/.vite/vue.js",
"src": "/Users/bytedance/Projectes/my-projects/learning/vite/app-vue2/node_modules/vue/dist/vue.runtime.esm-bundler.js",
"needsInterop": false
}
如果 node_modules/.vite/_metadata.json文件存在,且hash相同,則表示已經(jīng)構(gòu)建過了,并且沒有更新,則直接返回prevData
const dataPath = path.join(cacheDir, "_metadata.json");
const mainHash = getDepHash(root, config);
const data: DepOptimizationMetadata = {
hash: mainHash,
browserHash: mainHash,
optimized: {},
};
if (!force) {
let prevData;
try {
prevData = JSON.parse(fs.readFileSync(dataPath, "utf-8"));
} catch (e) {}
// hash is consistent, no need to re-bundle
if (prevData && prevData.hash === data.hash) {
log("Hash is consistent. Skipping. Use --force to override.");
return prevData;
}
}
通過 scanImports找出需要依賴預(yù)構(gòu)建的依賴,結(jié)合用戶定義的需要處理的依賴config.optimizeDeps?.include,deps 是一個(gè)對(duì)象,是依賴名到其在文件系統(tǒng)中的路徑的映射如:{ vue: '/Users/bytedance/Projectes/my-projects/learning/vite/app-vue2/node_modules/vue/dist/vue.runtime.esm-bundler.js' }
let deps: Record<string, string>, missing: Record<string, string>;
if (!newDeps) {
({ deps, missing } = await scanImports(config));
} else {
deps = newDeps;
missing = {};
}
// ...
const include = config.optimizeDeps?.include;
if (include) {
const resolve = config.createResolver({ asSrc: false });
for (const id of include) {
if (!deps[id]) {
const entry = await resolve(id);
if (entry) {
deps[id] = entry;
} else {
throw new Error(
`Failed to resolve force included dependency: ${chalk.cyan(id)}`
);
}
}
}
}
使用 es-module-lexer的parse處理依賴的代碼,讀取其中的exportsData,并完成依賴 id(文件路徑)到exportsData的映射,用于之后esbuild構(gòu)建時(shí)進(jìn)行依賴圖分析并打包到一個(gè)文件里面,其中exportsData為這個(gè)文件里引入的模塊imports和導(dǎo)出的模塊exports:
const flatIdDeps: Record<string, string> = {}
const idToExports: Record<string, ExportsData> = {}
const flatIdToExports: Record<string, ExportsData> = {}
await init
for (const id in deps) {
const flatId = flattenId(id)
flatIdDeps[flatId] = deps[id]
const entryContent = fs.readFileSync(deps[id], 'utf-8')
const exportsData = parse(entryContent) as ExportsData
for (const { ss, se } of exportsData[0]) {
const exp = entryContent.slice(ss, se)
if (/export\s+*\s+from/.test(exp)) {
exportsData.hasReExports = true
}
}
idToExports[id] = exportsData
flatIdToExports[flatId] = exportsData
}
舉個(gè) es-module-lexer 例子。
使用 esbuild進(jìn)行依賴的預(yù)構(gòu)建,并將構(gòu)建之后的文件寫入緩存目錄:node_modules/.vite,得益于 esbuild 比傳統(tǒng)構(gòu)建工具快 10-100 倍的速度,所以依賴預(yù)構(gòu)建也是非常快的,且一次構(gòu)建之后,后續(xù)可以緩存;
build 構(gòu)建函數(shù)傳入用戶 vite.config.js define 定義的環(huán)境變量,需要進(jìn)行依賴預(yù)構(gòu)建的文件入口 Object.keys(flatIdDeps) 等, 以及處理依賴的 esbuild 插件 esbuildDepPlugin ,這個(gè)插件主要做了以下三件事:
主要用于處理某個(gè)依賴文件及其依賴圖,轉(zhuǎn)換 mjs|ts|jsx|tsx|svelte|vue等文件成為 js 代碼,less|sass|scss|styl等文件成為css,前提是使用了相關(guān)的插件,其中mjs|ts|jsx|tsx等是默認(rèn)支持的將某個(gè)依賴的依賴圖中的文件統(tǒng)一打包到一個(gè) esm 文件中,如 vue依賴,打包成一個(gè)vue.js,或者 lodash 依賴,打包成一個(gè)lodash-es文件,減少 http 請(qǐng)求數(shù)量
一個(gè)比較直觀的例子就是,當(dāng)我們直接使用 import { debounce } from "lodash-es" 時(shí),瀏覽器會(huì)導(dǎo)入 600+ 文件,大概需要 1 s 多:

而經(jīng)過依賴預(yù)構(gòu)建之后,瀏覽器只需要導(dǎo)入一個(gè)文件,且只需 20 ms :

處理一些不兼容模塊 commonjs模塊等,將它們打包成 esm 文件,比如 react 的包,使得瀏覽器可以使用
const define: Record<string, string> = {
"process.env.NODE_ENV": JSON.stringify(config.mode),
};
for (const key in config.define) {
const value = config.define[key];
define[key] = typeof value === "string" ? value : JSON.stringify(value);
}
const start = Date.now();
const result = await build({
entryPoints: Object.keys(flatIdDeps),
bundle: true,
keepNames: config.optimizeDeps?.keepNames,
format: "esm",
external: config.optimizeDeps?.exclude,
logLevel: "error",
splitting: true,
sourcemap: true,
outdir: cacheDir,
treeShaking: "ignore-annotations",
metafile: true,
define,
plugins: [esbuildDepPlugin(flatIdDeps, flatIdToExports, config)],
});
進(jìn)行依賴預(yù)構(gòu)建并寫入到緩存目錄之后,最后就是補(bǔ)充 data.optimized內(nèi)容,并將內(nèi)容寫入到緩存目錄下的_metadata.json用于之后進(jìn)行依賴獲取和走構(gòu)建緩存等:
const meta = result.metafile!
createLogger('info').info(`${JSON.stringify(meta)}`)
// the paths in `meta.outputs` are relative to `process.cwd()`
const cacheDirOutputPath = path.relative(process.cwd(), cacheDir)
for (const id in deps) {
const entry = deps[id]
data.optimized[id] = {
file: normalizePath(path.resolve(cacheDir, flattenId(id) + '.js')),
src: entry,
needsInterop: needsInterop(
id,
idToExports[id],
meta.outputs,
cacheDirOutputPath
)
}
}
writeFile(dataPath, JSON.stringify(data, null, 2))
其中 needsInterop 為記錄那些在依賴預(yù)構(gòu)建時(shí),使用了 commonjs 語(yǔ)法的依賴,如果使用了 commonjs ,那么 needsInterop 為 true ,這個(gè)屬性主要用于在瀏覽器請(qǐng)求對(duì)應(yīng)的依賴時(shí)(構(gòu)建前是 commonjs 形式),Vite 的 importAnalysisPlugin 插件會(huì)進(jìn)行依賴性導(dǎo)入分析,使用 transformCjsImport 函數(shù),它會(huì)對(duì)需要預(yù)編譯且為 CommonJS 的依賴導(dǎo)入代碼進(jìn)行重寫。舉個(gè)例子,當(dāng)我們?cè)?Vite 項(xiàng)目中使用 react 時(shí):
import React, { useState, createContext } from "react";
此時(shí) React 的導(dǎo)入就是 needsInterop 為 true,所以 importAnalysisPlugin 插件的會(huì)對(duì)導(dǎo)入 React 的代碼進(jìn)行重寫:
import $viteCjsImport1_react from "/@modules/react.js";
const React = $viteCjsImport1_react;
const useState = $viteCjsImport1_react["useState"];
const createContext = $viteCjsImport1_react["createContext"];
之所以要進(jìn)行重寫的緣由是因?yàn)?CommonJS 的模塊并不支持命名方式的導(dǎo)出,即沒有 exports xxx 這樣的語(yǔ)法,只有 exports.xxx。所以,如果不經(jīng)過插件的轉(zhuǎn)化,則會(huì)看到這樣的異常:
Uncaught SyntaxError: The requested module '/@modules/react.js' does not provide an export named 'useState'
最后將 data 寫入的路徑為 node_modules/.vite/_metadata.json ,內(nèi)容如下:
{
"hash": "cd74d918",
"browserHash": "92bdfa16",
"optimized": {
"vue": {
"file": "/Users/bytedance/Projectes/my-projects/learning/vite/app-vue2/node_modules/.vite/vue.js",
"src": "/Users/bytedance/Projectes/my-projects/learning/vite/app-vue2/node_modules/vue/dist/vue.runtime.esm-bundler.js",
"needsInterop": false
}
}
}
依賴預(yù)構(gòu)建總結(jié)
經(jīng)過上面的分析,我們可以總結(jié)依賴預(yù)構(gòu)建的幾點(diǎn)特點(diǎn):
在快速起服務(wù)和瀏覽器首屏出圖直接的一個(gè)取舍,而得益與 esbuild 的快速構(gòu)建,使得起服務(wù)快的同時(shí),瀏覽器首屏出圖也快,而且可以進(jìn)行緩存 使得可以使用一些 (j|t)sx?/vue/svelte的包成為可能針對(duì) commonjs 等也可以進(jìn)行轉(zhuǎn)換使用
所以 Vite 并不是一個(gè)純的 bundless 工具,或者說構(gòu)建/編譯幾乎是不可或缺的內(nèi)容。
一個(gè)請(qǐng)求的 Vite 之旅
GET localhost
實(shí)際 GET / => /index.html
講完依賴預(yù)構(gòu)建,接下來我們可以放心的講解一個(gè)基于 Vite 的 Vue 項(xiàng)目的運(yùn)行過程,也就是我們?cè)?network 面板里面看到的那些請(qǐng)求,以及它們與項(xiàng)目目錄里面的對(duì)應(yīng)關(guān)系。
首先我們知道,在 createServer 中注冊(cè)了 history 中間件,針對(duì) / 請(qǐng)求,會(huì)重定向到 /index.html,重定向之后的請(qǐng)求則會(huì)激活 indexHtmlMiddleware 中間件的處理:
packages/vite/src/node/server/middlewares/indexHtml.ts 下的 indexHtmlMiddleware 函數(shù)內(nèi)容:
export function indexHtmlMiddleware(
server: ViteDevServer
): Connect.NextHandleFunction {
return async (req, res, next) => {
const url = req.url && cleanUrl(req.url);
// spa-fallback always redirects to /index.html
if (url?.endsWith(".html") && req.headers["sec-fetch-dest"] !== "script") {
createLogger("info").info(`html middleware`);
const filename = getHtmlFilename(url, server);
if (fs.existsSync(filename)) {
try {
let html = fs.readFileSync(filename, "utf-8");
// 這里調(diào)用 transformIndexHtml
html = await server.transformIndexHtml(url, html);
return send(req, res, html, "html");
} catch (e) {
return next(e);
}
}
}
next();
};
}
上面函數(shù)會(huì)調(diào)用 transformIndexHtml ,然后執(zhí)行 packages/vite/src/node/plugins/html.ts 下的 applyHtmlTransforms 函數(shù),執(zhí)行用于給 html 注入內(nèi)容的 hooks 如 [...preHooks, devHtmlHook, ...postHooks],并在 html 文件的 head 和 body 標(biāo)簽前后插入腳本。
其中 devHtmlHook 主要做的事情就是在 html 文件頭部注入 <script type="module" src="/@vite/client"></script> 腳本,也就是我們看到的第一個(gè)請(qǐng)求 localhost 返回的內(nèi)容:

而 devHtmlHook 則是在 server 中調(diào)用 createDevHtmlTransformFn 函數(shù)時(shí)注入的 Hooks,在 packages/vite/src/node/server/middlewares/indexHtml.ts 下的 createDevHtmlTransformFn 函數(shù)內(nèi)容:
export function createDevHtmlTransformFn(
server: ViteDevServer
): (url: string, html: string) => Promise<string> {
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
)
}
}
// devHtmlHook 函數(shù)
const devHtmlHook: IndexHtmlTransformHook = async (
html,
{ path: htmlPath, server }
) => {
// TODO: solve this design issue
// Optional chain expressions can return undefined by design
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
const config = server?.config!
const base = config.base || '/'
const s = new MagicString(html)
let scriptModuleIndex = -1
await traverseHtml(html, htmlPath, (node) => {
if (node.type !== NodeTypes.ELEMENT) {
return
}
// ...
html = s.toString()
return {
html,
tags: [
{
tag: 'script',
attrs: {
type: 'module',
// 這里注入 /@vite/client 腳本
src: path.posix.join(base, CLIENT_PUBLIC_PATH)
},
injectTo: 'head-prepend'
}
]
}
}
GET client
實(shí)際 GET /@vite/client
首先會(huì)走 transformMiddleware :
export function transformMiddleware(
server: ViteDevServer
): Connect.NextHandleFunction {
const {
config: { root, logger, cacheDir },
moduleGraph
} = server
// determine the url prefix of files inside cache directory
let cacheDirPrefix: string | undefined
if (cacheDir) {
const cacheDirRelative = normalizePath(path.relative(root, cacheDir))
if (cacheDirRelative.startsWith('../')) {
// if the cache directory is outside root, the url prefix would be something
// like '/@fs/absolute/path/to/node_modules/.vite'
cacheDirPrefix = `/@fs/${normalizePath(cacheDir).replace(/ ^ //, '')}`
} else {
// if the cache directory is inside root, the url prefix would be something
// like '/node_modules/.vite'
cacheDirPrefix = `/${cacheDirRelative}`
}
}
return async (req, res, next) => {
if (req.method !== 'GET' || knownIgnoreList.has(req.url!)) {
return next()
}
const withoutQuery = cleanUrl(url)
if (
isJSRequest(url) ||
isImportRequest(url) ||
isCSSRequest(url) ||
isHTMLProxy(url)
) {
// strip ?import
url = removeImportQuery(url)
// Strip valid id prefix. This is prepended to resolved Ids that are
// not valid browser import specifiers by the importAnalysis plugin.
url = unwrapId(url)
// for CSS, we need to differentiate between normal CSS requests and
// imports
if (isCSSRequest(url) && req.headers.accept?.includes('text/css')) {
url = injectQuery(url, 'direct')
}
// check if we can return 304 early
const ifNoneMatch = req.headers['if-none-match']
if (
ifNoneMatch &&
(await moduleGraph.getModuleByUrl(url))?.transformResult?.etag ===
ifNoneMatch
) {
isDebug && debugCache(`[304] ${prettifyUrl(url, root)}`)
res.statusCode = 304
return res.end()
}
// resolve, load and transform using the plugin container
const result = await transformRequest(url, server, {
html: req.headers.accept?.includes('text/html')
})
if (result) {
const type = isDirectCSSRequest(url) ? 'css' : 'js'
const isDep =
DEP_VERSION_RE.test(url) ||
(cacheDirPrefix && url.startsWith(cacheDirPrefix))
return send(
req,
res,
result.code,
type,
result.etag,
// allow browser to cache npm deps!
isDep ? 'max-age=31536000,immutable' : 'no-cache',
result.map
)
}
}
} catch (e) {
return next(e)
}
next()
}
}
會(huì)命中 isJSRequest(url) 邏輯,進(jìn)入中間件的處理過程:
對(duì) url 進(jìn)行 transformRequest,主要的邏輯為通過pluginContainer.resolveId獲取到實(shí)際的文件位置id,然后根據(jù)這個(gè)位置,使用pluginContainer.load來獲取對(duì)應(yīng)的文件內(nèi)容,如果文件內(nèi)容并非瀏覽器可以直接使用的 esm 內(nèi)容,那么就需要pluginContainer.transform進(jìn)行文件內(nèi)容的轉(zhuǎn)換,最后返回轉(zhuǎn)換后的code、map以及 etag,用于緩存。
export async function transformRequest(
url: string,
{ config, pluginContainer, moduleGraph, watcher }: ViteDevServer,
options: TransformOptions = {}
): Promise<TransformResult | null> {
url = removeTimestampQuery(url)
const { root, logger } = config
const prettyUrl = isDebug ? prettifyUrl(url, root) : ''
const ssr = !!options.ssr
// resolve
const id = (await pluginContainer.resolveId(url))?.id || url
const file = cleanUrl(id)
let code: string | null = null
let map: SourceDescription['map'] = null
// load
const loadStart = isDebug ? Date.now() : 0
const loadResult = await pluginContainer.load(id, ssr)
// ...
if (typeof loadResult === 'object') {
code = loadResult.code
map = loadResult.map
} else {
code = loadResult
}
// ...
// ensure module in graph after successful load
const mod = await moduleGraph.ensureEntryFromUrl(url)
ensureWatchedFile(watcher, mod.file, root)
// transform
const transformStart = isDebug ? Date.now() : 0
const transformResult = await pluginContainer.transform(code, id, map, ssr)
if (
transformResult == null ||
(typeof transformResult === 'object' && transformResult.code == null)
) {
// no transform applied, keep code as-is
isDebug &&
debugTransform(
timeFrom(transformStart) + chalk.dim(` [skipped] ${prettyUrl}`)
)
} else {
isDebug && debugTransform(`${timeFrom(transformStart)} ${prettyUrl}`)
code = transformResult.code!
map = transformResult.map
}
if (map && mod.file) {
map = (typeof map === 'string' ? JSON.parse(map) : map) as SourceMap
if (map.mappings && !map.sourcesContent) {
await injectSourcesContent(map, mod.file)
}
}
return (mod.transformResult = {
code,
map,
etag: getEtag(code, { weak: true })
} as TransformResult)
}
pluginContainer.resolveId 在調(diào)用時(shí),會(huì)逐個(gè)調(diào)用每個(gè)插件上的 resolveId 方法,一旦遇到 aliasPlugin ,在 config 中,曾注冊(cè)過對(duì)應(yīng)的 /`` ^ ``/@vite// 的 alias,此插件將用于將 /``@vite/client 替換成 CLIENT_DIR + / + client ,也就是 vite/dist/client/client
實(shí)際處于 packages/vite/src/node/config.ts 路徑下的 resolvedAlias :
// resolve alias with internal client alias
const resolvedAlias = mergeAlias(
// #1732 the CLIENT_DIR may contain $$ which cannot be used as direct
// replacement string.
// @ts-ignore because @rollup/plugin-alias' type doesn't allow function
// replacement, but its implementation does work with function values.
[{ find: / ^ /@vite//, replacement: () => CLIENT_DIR + '/' }],
config.resolve?.alias || config.alias || []
)
const resolveOptions: ResolvedConfig['resolve'] = {
dedupe: config.dedupe,
...config.resolve,
alias: resolvedAlias
}
const resolved = {
// ...
resolve: resolveOptions
// ...
}
在 packages/vite/src/node/plugins/index.ts 的 aliasPlugin 插件中作為參數(shù)傳入
export async function resolvePlugins(
config: ResolvedConfig,
prePlugins: Plugin[],
normalPlugins: Plugin[],
postPlugins: Plugin[]
): Promise<Plugin[]> {
// ...
return [
isBuild ? null : preAliasPlugin(),
aliasPlugin({ entries: config.resolve.alias }),
...prePlugins,
// ...
].filter(Boolean) as Plugin[]
}
在 aliasPlugin 里面改寫路徑之后,會(huì)繼續(xù)將改寫過的路徑傳給下一個(gè)插件,最終進(jìn)入 resolvePlugin 插件的 tryNodeResolve 函數(shù),獲取到 @fs/Users/bytedance/Projectes/my-projects/learning/vite/vite/packages/vite/dist/client/client.js 文件的路徑并返回,最終通過 pluginContainer.load獲取 loadResult,然后 通過 pluginContainer.transform 獲取其轉(zhuǎn)換過的代碼,通過 send 方法發(fā)送給瀏覽器,而 client.js 里面的代碼主要用于與服務(wù)器進(jìn)行 ws 通信來進(jìn)行 hmr 熱更新、以及重載頁(yè)面等操作。
受限于篇幅,本文接下來的內(nèi)容不再細(xì)化。
下面的所有請(qǐng)求,都會(huì)走一個(gè)類似上面的流程,最終發(fā)送給瀏覽器的代碼是瀏覽器可以運(yùn)行的代碼,其中針對(duì) Vue 文件是需要走類似
@vitejs/plugin-vue的 plugin 的轉(zhuǎn)換的,感興趣的同學(xué)可以自行了解一下。
GET /src/main.js
實(shí)際 GET /src/main.js
GET env.js
實(shí)際 GET /@fs/Users/bytedance/Projectes/my-projects/learning/vite/vite/packages/vite/dist/client/env.js
GET vue.js?v=92bdfa16
實(shí)際 GET /node_modules/.vite/vue.js?v=92bdfa16
GET App.vue
實(shí)際 GET /src/App.vue
GET App.vue?vue&type=style&index=0&lang.css
實(shí)際 GET /src/App.vue?vue&type=style&index=0&lang.css
有什么 “不好” 的?
正如上面提到的,Vite 只對(duì) npm 依賴進(jìn)行預(yù)構(gòu)建,對(duì)于用戶編寫的文件不進(jìn)行預(yù)處理,而是通過瀏覽器支持的 ES Module 來進(jìn)行按需讀取,所以如果用戶文件過多,且沒有進(jìn)行一定的 Code Spliting 等操作,那么可想而知,首屏是非常慢的,可以通過這個(gè)視頻直觀的看出來:

所以使用 Vite 的開發(fā),對(duì)我們的首屏性能優(yōu)化就提出了更高的要求,這也直接給生產(chǎn)下帶來了一定幫助,也正是因?yàn)?Vite 是主要面向開發(fā)側(cè)的,所以可以盡可能的用最先進(jìn)的技術(shù),如 Http2?Http3?來進(jìn)行網(wǎng)絡(luò)請(qǐng)求,以及更好的懶加載、緩存技術(shù)。
還有一點(diǎn)就是,Vue 生產(chǎn)內(nèi)建了 Rollup 打包工具,這對(duì)原先使用 Webpack 的項(xiàng)目也不太友好,但得益于 Vite 社區(qū)的活躍和尤大的號(hào)召力,社區(qū)中已經(jīng)有成型的基于 Webpack 來生產(chǎn)打包,開發(fā)使用 Vite 的解決方案:https://github.com/IndexXuan/vue-cli-plugin-vite#readme
生態(tài)如何?
Vite 擁有比較完善的生態(tài),主要的項(xiàng)目如 https://github.com/vitejs/awesome-vite 在不斷的更新,且 Vite 社區(qū)比較活躍,社區(qū)成員也很樂于解答問題:
discord:https://discord.com/channels/804011606160703521/804011606160703524 Github discussion:https://github.com/vitejs/vite/discussions
同時(shí) Vite 支持多框架:React/Vue/Svelte 等。
我能用在生產(chǎn)項(xiàng)目中嗎?
如果你是想從頭開始一個(gè)新項(xiàng)目,亦或?qū)κ灼列阅軆?yōu)化有很大的興趣,那么建議你一定要試一試,有可能一不小心,就回不去了!
Hail OpenSource!
世界已經(jīng)被開源吞噬,慶幸在這樣一個(gè)商業(yè)氛圍及其濃厚的今天,我們還有幸能閱讀到優(yōu)秀的項(xiàng)目源碼,站在巨人的肩膀上!
