Vite 原理淺析

原文首發在個人博客,歡迎訪問。css

已經很久沒有寫博客了。本文不說 Vue3.0 了,相信已經有不少文章在說它了。而前一段時間尤大開源的 Vite 則是一個更加吸引個人東西,它的整體思路是很不錯的,早期源碼的學習成本也比較低,因而就趁着假期學習一番。html

本文撰寫於 Vite-0.9.1 版本。vue

什麼是 Vite

借用做者的原話:node

Vite,一個基於瀏覽器原生 ES imports 的開發服務器。利用瀏覽器去解析 imports,在服務器端按需編譯返回,徹底跳過了打包這個概念,服務器隨起隨用。同時不只有 Vue 文件支持,還搞定了熱更新,並且熱更新的速度不會隨着模塊增多而變慢。針對生產環境則能夠把同一份代碼用 rollup 打包。雖然如今還比較粗糙,但這個方向我以爲是有潛力的,作得好能夠完全解決改一行代碼等半天熱更新的問題。webpack

注意到兩個點:ios

  • 一個是 Vite 主要對應的場景是開發模式,原理是攔截瀏覽器發出的 ES imports 請求並作相應處理。(生產模式是用 rollup 打包)
  • 一個是 Vite 在開發模式下不須要打包,只須要編譯瀏覽器發出的 HTTP 請求對應的文件便可,因此熱更新速度很快。

所以,要實現上述目標,須要要求項目裏只使用原生 ES imports,若是使用了 require 將失效,因此要用它徹底替代掉 Webpack 就目前來講仍是不太現實的。上面也說了,生產模式下的打包不是 Vite 自身提供的,所以生產模式下若是你想要用 Webpack 打包也依然是能夠的。從這個角度來講,Vite 可能更像是替代了 webpack-dev-server 的一個東西。git

modules 模塊

Vite 的實現離不開現代瀏覽器原生支持的 模塊功能。以下:github

<script type="module"> import { a } from './a.js' </script>
複製代碼

當聲明一個 script 標籤類型爲 module 時,瀏覽器將對其內部的 import 引用發起 HTTP 請求獲取模塊內容。好比上述,瀏覽器將發起一個對 HOST/a.js 的 HTTP 請求,獲取到內容以後再執行。web

Vite 劫持了這些請求,並在後端進行相應的處理(好比將 Vue 文件拆分紅 templatestylescript 三個部分),而後再返回給瀏覽器。npm

因爲瀏覽器只會對用到的模塊發起 HTTP 請求,因此 Vite 不必對項目裏全部的文件先打包後返回,而是隻編譯瀏覽器發起 HTTP 請求的模塊便可。這裏是否是有點按需加載的味道?

編譯和打包的區別

看到這裏,可能有些朋友難免有些疑問,編譯和打包有什麼區別?爲何 Vite 號稱「熱更新的速度不會隨着模塊增多而變慢」?

簡單舉個例子,有三個文件 a.jsb.jsc.js

// a.js
const a = () => { ... }
export { a }

// b.js
const b = () => { ... }
export { b }
複製代碼
// c.js
import { a } from './a'
import { b } from './b'

const c = () => {
  return a() + b()
}

export { c }
複製代碼

若是以 c 文件爲入口,那麼打包就會變成以下(結果進行了簡化處理):(假定打包文件名爲 bundle.js)

// bundle.js
const a = () => { ... }
const b = () => { ... }
const c = () => {
  return a() + b()
}

export { c }
複製代碼

值得注意的是,打包也須要有編譯的步驟。

Webpack 的熱更新原理簡單來講就是,一旦發生某個依賴(好比上面的 a.js )改變,就將這個依賴所處的 module 的更新,並將新的 module 發送給瀏覽器從新執行。因爲咱們只打了一個 bundle.js,因此熱更新的話也會從新打這個 bundle.js。試想若是依賴愈來愈多,就算只修改一個文件,理論上熱更新的速度也會愈來愈慢。

而若是是像 Vite 這種只編譯不打包會是什麼狀況呢?

只是編譯的話,最終產出的依然是 a.jsb.jsc.js 三個文件,只有編譯耗時。因爲入口是 c.js,瀏覽器解析到 import { a } from './a' 時,會發起 HTTP 請求 a.js (b 同理),就算不用打包,也能夠加載到所須要的代碼,所以省去了合併代碼的時間。

在熱更新的時候,若是 a 發生了改變,只須要更新 a 以及用到 ac。因爲 b 沒有發生改變,因此 Vite 無需從新編譯 b,能夠從緩存中直接拿編譯的結果。這樣一來,修改一個文件 a,只會從新編譯這個文件 a 以及瀏覽器當前用到這個文件 a 的文件,而其他文件都無需從新編譯。因此理論上熱更新的速度不會隨着文件增長而變慢。

固然這樣作有沒有很差的地方?有,初始化的時候若是瀏覽器請求的模塊過多,也會帶來初始化的性能問題。不過若是你能遇到初始化過慢的這個問題,相信熱更新的速度會彌補不少。固然我相信之後尤大也會解決這個問題。

Vite 運行 Web 應用的實現

上面說了這麼多的鋪墊,可能還不夠直觀,咱們能夠先跑一個 Vite 項目來實際看看。

按照官網的說明,能夠輸入以下命令(<project-name> 爲本身想要的目錄名便可)

$ npx create-vite-app <project-name>
$ cd <project-name>
$ npm install
$ npm run dev
複製代碼

若是一切都正常你將在 localhost:3000(Vite 的服務器起的端口) 看到這個界面:

並獲得以下的代碼結構:

.
├── App.vue // 頁面的主要邏輯
├── index.html // 默認打開的頁面以及 Vue 組件掛載
├── node_modules
└── package.json
複製代碼

攔截 HTTP 請求

接下來開始說一下 Vite 實現的核心——攔截瀏覽器對模塊的請求並返回處理後的結果。

咱們知道,因爲是在 localhost:3000 打開的網頁,因此瀏覽器發起的第一個請求天然是請求 localhost:3000/,這個請求發送到 Vite 後端以後通過靜態資源服務器的處理,會進而請求到 /index.html,此時 Vite 就開始對這個請求作攔截和處理了。

首先,index.html 裏的源碼是這樣的:

<div id="app"></div>
<script type="module"> import { createApp } from 'vue' import App from './App.vue' createApp(App).mount('#app') </script>
複製代碼

可是在瀏覽器裏它是這樣的:

注意到什麼不一樣了嗎?是的, import { createApp } from 'vue' 換成了 import { createApp } from '/@modules/vue

這裏就不得不說瀏覽器對 import 的模塊發起請求時的一些侷限了,平時咱們寫代碼,若是不是引用相對路徑的模塊,而是引用 node_modules 的模塊,都是直接 import xxx from 'xxx',由 Webpack 等工具來幫咱們找這個模塊的具體路徑。可是瀏覽器不知道你項目裏有 node_modules,它只能經過相對路徑去尋找模塊。

所以 Vite 在攔截的請求裏,對直接引用 node_modules 的模塊都作了路徑的替換,換成了 /@modules/ 並返回回去。然後瀏覽器收到後,會發起對 /@modules/xxx 的請求,而後被 Vite 再次攔截,並由 Vite 內部去訪問真正的模塊,並將獲得的內容再次作一樣的處理後,返回給瀏覽器。

imports 替換

普通 JS import 替換

上面說的這步替換來自 src/node/serverPluginModuleRewrite.ts:

// 只取關鍵代碼:
// Vite 使用 Koa 做爲內置的服務器
// 若是請求的路徑是 /index.html
if (ctx.path === '/index.html') {
  // ...
  const html = await readBody(ctx.body)
  ctx.body = html.replace(
    /(<script\b[^>]*>)([\s\S]*?)<\/script>/gm, // 正則匹配
    (_, openTag, script) => {
      // also inject __DEV__ flag
      const devFlag = hasInjectedDevFlag ? `` : devInjectionCode
      hasInjectedDevFlag = true
       // 替換 html 的 import 路徑
      return `${devFlag}${openTag}${rewriteImports( script, '/index.html', resolver )}</script>`
    }
  )
  // ...
}
複製代碼

若是並無在 script 標籤內部直接寫 import,而是用 src 的形式引用的話以下:

<script type="module" src="/main.js"></script>
複製代碼

那麼就會在瀏覽器發起對 main.js 請求的時候進行處理:

// 只取關鍵代碼:
if (
  ctx.response.is('js') &&
  // ...
) {
  // ...
  const content = await readBody(ctx.body)
  await initLexer
  // 重寫 js 文件裏的 import
  ctx.body = rewriteImports(
    content,
    ctx.url.replace(/(&|\?)t=\d+/, ''),
    resolver,
    ctx.query.t
  )
  // 寫入緩存,以後能夠從緩存中直接讀取
  rewriteCache.set(content, ctx.body)
}
複製代碼

替換邏輯 rewriteImports 就不展開了,用的是 es-module-lexer 來進行的語法分析獲取 imports 數組,而後再作的替換。

*.vue 文件的替換

若是 import 的是 .vue 文件,將會作更進一步的替換:

本來的 App.vue 文件長這樣:

<template>
  <h1>Hello Vite + Vue 3!</h1>
  <p>Edit ./App.vue to test hot module replacement (HMR).</p>
  <p>
    <span>Count is: {{ count }}</span>
    <button @click="count++">increment</button>
  </p>
</template>

<script> export default { data: () => ({ count: 0 }), } </script>

<style scoped> h1 { color: #4fc08d; } h1, p { font-family: Arial, Helvetica, sans-serif; } </style>
複製代碼

替換後長這樣:

// localhost:3000/App.vue
import { updateStyle } from "/@hmr"

// 抽出 script 邏輯
const __script = {
  data: () => ({ count: 0 }),
}

// 將 style 拆分紅 /App.vue?type=style 請求,由瀏覽器繼續發起請求獲取樣式
updateStyle("c44b8200-0", "/App.vue?type=style&index=0&t=1588490870523")
__script.__scopeId = "data-v-c44b8200" // 樣式的 scopeId

// 將 template 拆分紅 /App.vue?type=template 請求,由瀏覽器繼續發起請求獲取 render function
import { render as __render } from "/App.vue?type=template&t=1588490870523&t=1588490870523"
__script.render = __render // render 方法掛載,用於 createApp 時渲染
__script.__hmrId = "/App.vue" // 記錄 HMR 的 id,用於熱更新
__script.__file = "/XXX/web/vite-test/App.vue" // 記錄文件的原始的路徑,後續熱更新能用到
export default __script
複製代碼

這樣就把本來一個 .vue 的文件拆成了三個請求(分別對應 scriptstyletemplate) ,瀏覽器會先收到包含 script 邏輯的 App.vue 的響應,而後解析到 templatestyle 的路徑後,會再次發起 HTTP 請求來請求對應的資源,此時 Vite 對其攔截並再次處理後返回相應的內容。

以下:

不得不說這個思路是很是巧妙的。

這一步的拆分來自 src/node/serverPluginVue.ts,核心邏輯是根據 URL 的 query 參數來作不一樣的處理(簡化分析以下):

// 若是沒有 query 的 type,好比直接請求的 /App.vue
if (!query.type) {
  ctx.type = 'js'
  ctx.body = compileSFCMain(descriptor, filePath, publicPath) // 編譯 App.vue,編譯成上面說的帶有 script 內容,以及 template 和 style 連接的形式。
  return etagCacheCheck(ctx) // ETAG 緩存檢測相關邏輯
}

// 若是 query 的 type 是 template,好比 /App.vue?type=template&xxx
if (query.type === 'template') {
  ctx.type = 'js'
  ctx.body = compileSFCTemplate( // 編譯 template 生成 render function
    // ...
  )
  return etagCacheCheck(ctx)
}

// 若是 query 的 type 是 style,好比 /App.vue?type=style&xxx
if (query.type === 'style') {
  const index = Number(query.index)
  const styleBlock = descriptor.styles[index]
  const result = await compileSFCStyle( // 編譯 style
    // ...
  )
  if (query.module != null) { // 若是是 css module
    ctx.type = 'js'
    ctx.body = `export default ${JSON.stringify(result.modules)}`
  } else { // 正常 css
    ctx.type = 'css'
    ctx.body = result.code
  }
}
複製代碼

@modules/* 路徑解析

上面只涉及到了替換的邏輯,解析的邏輯來自 src/node/serverPluginModuleResolve.ts。這一步就相對簡單了,核心邏輯就是去 node_modules 裏找有沒有對應的模塊,有的話就返回,沒有的話就報 404:(省略了不少邏輯,好比對 web_modules 的處理、緩存的處理等)

// ...
try {
  const file = resolve(root, id) // id 是模塊的名字,好比 axios
  return serve(id, file, 'node_modules') // 從 node_modules 中找到真正的模塊內容並返回
} catch (e) {
  console.error(
    chalk.red(`[vite] Error while resolving node_modules with id "${id}":`)
  )
  console.error(e)
  ctx.status = 404 // 若是沒找到就 404
}
複製代碼

Vite 熱更新的實現

上面已經說完了 Vite 是如何運行一個 Web 應用的,包括如何攔截請求、替換內容、返回處理後的結果。接下來講一下 Vite 熱更新的實現,一樣實現的很是巧妙。

咱們知道,若是要實現熱更新,那麼就須要瀏覽器和服務器創建某種通訊機制,這樣瀏覽器才能收到通知進行熱更新。Vite 的是經過 WebSocket 來實現的熱更新通訊。

客戶端

客戶端的代碼在 src/client/client.ts,主要是建立 WebSocket 客戶端,監聽來自服務端的 HMR 消息推送。

Vite 的 WS 客戶端目前監聽這幾種消息:

  • connected: WebSocket 鏈接成功
  • vue-reload: Vue 組件從新加載(當你修改了 script 裏的內容時)
  • vue-rerender: Vue 組件從新渲染(當你修改了 template 裏的內容時)
  • style-update: 樣式更新
  • style-remove: 樣式移除
  • js-update: js 文件更新
  • full-reload: fallback 機制,網頁重刷新

其中針對 Vue 組件自己的一些更新,均可以直接調用 HMRRuntime 提供的方法,很是方便。其他的更新邏輯,基本上都是利用了 timestamp 刷新緩存從新執行的方法來達到更新的目的。

核心邏輯以下,我感受很是清晰明瞭:

import { HMRRuntime } from 'vue' // 來自 Vue3.0 的 HMRRuntime

console.log('[vite] connecting...')

declare var __VUE_HMR_RUNTIME__: HMRRuntime

const socket = new WebSocket(`ws://${location.host}`)

// Listen for messages
socket.addEventListener('message', ({ data }) => {
  const { type, path, id, index, timestamp, customData } = JSON.parse(data)
  switch (type) {
    case 'connected':
      console.log(`[vite] connected.`)
      break
    case 'vue-reload':
      import(`${path}?t=${timestamp}`).then((m) => {
        __VUE_HMR_RUNTIME__.reload(path, m.default)
        console.log(`[vite] ${path} reloaded.`) // 調用 HMRRUNTIME 的方法更新
      })
      break
    case 'vue-rerender':
      import(`${path}?type=template&t=${timestamp}`).then((m) => {
        __VUE_HMR_RUNTIME__.rerender(path, m.render)
        console.log(`[vite] ${path} template updated.`) // 調用 HMRRUNTIME 的方法更新
      })
      break
    case 'style-update':
      updateStyle(id, `${path}?type=style&index=${index}&t=${timestamp}`) // 從新加載 style 的 URL
      console.log(
        `[vite] ${path} style${index > 0 ? `#${index}` : ``} updated.`
      )
      break
    case 'style-remove':
      const link = document.getElementById(`vite-css-${id}`)
      if (link) {
        document.head.removeChild(link) // 刪除 style
      }
      break
    case 'js-update':
      const update = jsUpdateMap.get(path)
      if (update) {
        update(timestamp) // 用新的時間戳加載並執行 js,達到更新的目的
        console.log(`[vite]: js module reloaded: `, path)
      } else {
        console.error(
          `[vite] got js update notification but no client callback was registered. Something is wrong.`
        )
      }
      break
    case 'custom':
      const cbs = customUpdateMap.get(id)
      if (cbs) {
        cbs.forEach((cb) => cb(customData))
      }
      break
    case 'full-reload':
      location.reload()
  }
})
複製代碼

服務端

服務端的實現位於 src/node/serverPluginHmr.ts。核心是監聽項目文件的變動,而後根據不一樣文件類型(目前只有 vuejs)來作不一樣的處理:

watcher.on('change', async (file) => {
  const timestamp = Date.now() // 更新時間戳
  if (file.endsWith('.vue')) {
    handleVueReload(file, timestamp)
  } else if (file.endsWith('.js')) {
    handleJSReload(file, timestamp)
  }
})
複製代碼

對於 Vue 文件的熱更新而言,主要是從新編譯 Vue 文件,檢測 templatescriptstyle 的改動,若是有改動就經過 WS 服務端發起對應的熱更新請求。

簡單的源碼分析以下:

async function handleVueReload( file: string, timestamp: number = Date.now(), content?: string ) {
  const publicPath = resolver.fileToRequest(file) // 獲取文件的路徑
  const cacheEntry = vueCache.get(file) // 獲取緩存裏的內容

  debugHmr(`busting Vue cache for ${file}`)
  vueCache.del(file) // 發生變更了所以以前的緩存能夠刪除

  const descriptor = await parseSFC(root, file, content) // 編譯 Vue 文件

  const prevDescriptor = cacheEntry && cacheEntry.descriptor // 獲取前一次的緩存

  if (!prevDescriptor) {
    // 這個文件以前從未被訪問過(本次是第一次訪問),也就不必熱更新
    return
  }

  // 設置兩個標誌位,用於判斷是須要 reload 仍是 rerender
  let needReload = false
  let needRerender = false

  // 若是 script 部分不一樣則須要 reload
  if (!isEqual(descriptor.script, prevDescriptor.script)) {
    needReload = true
  }

  // 若是 template 部分不一樣則須要 rerender
  if (!isEqual(descriptor.template, prevDescriptor.template)) {
    needRerender = true
  }

  const styleId = hash_sum(publicPath)
  // 獲取以前的 style 以及下一次(或者說熱更新)的 style
  const prevStyles = prevDescriptor.styles || []
  const nextStyles = descriptor.styles || []

  // 若是不須要 reload,則查看是否須要更新 style
  if (!needReload) {
    nextStyles.forEach((_, i) => {
      if (!prevStyles[i] || !isEqual(prevStyles[i], nextStyles[i])) {
        send({
          type: 'style-update',
          path: publicPath,
          index: i,
          id: `${styleId}-${i}`,
          timestamp
        })
      }
    })
  }

  // 若是 style 標籤及內容刪掉了,則須要發送 `style-remove` 的通知
  prevStyles.slice(nextStyles.length).forEach((_, i) => {
    send({
      type: 'style-remove',
      path: publicPath,
      id: `${styleId}-${i + nextStyles.length}`,
      timestamp
    })
  })

  // 若是須要 reload 發送 `vue-reload` 通知
  if (needReload) {
    send({
      type: 'vue-reload',
      path: publicPath,
      timestamp
    })
  } else if (needRerender) {
    // 不然發送 `vue-rerender` 通知
    send({
      type: 'vue-rerender',
      path: publicPath,
      timestamp
    })
  }
}
複製代碼

對於熱更新 js 文件而言,會遞歸地查找引用這個文件的 importer。好比是某個 Vue 文件所引用了這個 js,就會被查找出來。假如最終發現找不到引用者,則會返回 hasDeadEnd: true

const vueImporters = new Set<string>() // 查找並存放須要熱更新的 Vue 文件
const jsHotImporters = new Set<string>() // 查找並存放須要熱更新的 js 文件
const hasDeadEnd = walkImportChain(
  publicPath,
  importers,
  vueImporters,
  jsHotImporters
)
複製代碼

若是 hasDeadEndtrue,則直接發送 full-reload。若是 vueImportersjsHotImporters 裏查找到須要熱更新的文件,則發起熱更新通知:

if (hasDeadEnd) {
  send({
    type: 'full-reload',
    timestamp
  })
} else {
  vueImporters.forEach((vueImporter) => {
    send({
      type: 'vue-reload',
      path: vueImporter,
      timestamp
    })
  })
  jsHotImporters.forEach((jsImporter) => {
    send({
      type: 'js-update',
      path: jsImporter,
      timestamp
    })
  })
}
複製代碼

客戶端邏輯的注入

寫到這裏,還有一個問題是,咱們在本身的代碼裏並無引入 HRMclient 代碼,Vite 是如何把 client 代碼注入的呢?

回到上面的一張圖,Vite 重寫 App.vue 文件的內容並返回時:

注意這張圖裏的代碼區第一句話 import { updateStyle } from '/@hmr',而且在左側請求列表中也有一個對 @hmr 文件的請求。這個請求是啥呢?

能夠發現,這個請求就是上面說的客戶端邏輯的 client.ts 的內容。

src/node/serverPluginHmr.ts 裏,有針對 @hmr 文件的解析處理:

export const hmrClientFilePath = path.resolve(__dirname, './client.js')
export const hmrClientId = '@hmr'
export const hmrClientPublicPath = `/${hmrClientId}`

app.use(async (ctx, next) => {
  if (ctx.path !== hmrClientPublicPath) { // 請求路徑若是不是 @hmr 就跳過
    return next()
  }
  debugHmr('serving hmr client')
  ctx.type = 'js'
  await cachedRead(ctx, hmrClientFilePath) // 返回 client.js 的內容
})
複製代碼

至此,熱更新的總體流程已經解析完畢。

小結

這個項目最近在以驚人的速度迭代着,所以沒過多久之後再回頭看這篇文章,可能代碼、實現已通過時。不過 Vite 的總體思路是很是棒的,在早期源碼很少的狀況下,能學到更貼近做者原始想法的東西,也算是很不錯的收穫。但願本文能給你學習 Vite 一些參考,有錯誤也歡迎你們指出。

相關文章
相關標籤/搜索