首頁>技術>

NO.1 vite 是什麼

vite —— 一個由 vue 作者尤雨溪開發的 web 開發工具,它具有以下特點:

快速的冷啟動即時的模組熱更新真正的按需編譯

從作者在微博上的發言:

Vite,一個基於瀏覽器原生 ES imports 的開發伺服器。利用瀏覽器去解析 imports,在伺服器端按需編譯返回,完全跳過了打包這個概念,伺服器隨起隨用。同時不僅有 Vue 檔案支援,還搞定了熱更新,而且熱更新的速度不會隨著模組增多而變慢。針對生產環境則可以把同一份程式碼用 rollup 打。雖然現在還比較粗糙,但這個方向我覺得是有潛力的,做得好可以徹底解決改一行程式碼等半天熱更新的問題。

中可以看出 vite 主要特點是基於瀏覽器 native 的 ES module (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) 來開發,省略打包這個步驟,因為需要什麼資源直接在瀏覽器裡引入即可。

基於瀏覽器 ES module 來開發 web 應用也不是什麼新鮮事,snowpack 也基於此,不過目前此專案社群中並沒有流行起來,vite 的出現也許會讓這種開發方式再火一陣子。

有趣的是 vite 算是革了 webpack 的命了(生產環境用 rollup),所以 webpack 的開發者直接喊大哥了...

作者注:本文完成於 vite 早期時候,vite 部分功能和原理有更新。

NO.2 vite 的使用方式

同常見的開發工具一樣,vite 提供了用 npm 或者 yarn 一建生成專案結構的方式,使用 yarn 在終端執行:

|____node_modules|____App.vue // 應用入口|____index.html // 頁面入口|____vite.config.js // 配置檔案|____package.json

即可初始化一個 vite 專案(預設應用模板為 vue3.x),生成的專案結構十分簡潔:

|____node_modules|____App.vue // 應用入口|____index.html // 頁面入口|____vite.config.js // 配置檔案|____package.json

執行 yarn dev 即可啟動應用 。

NO.3 vite 啟動鏈路命令解析

這部分程式碼在 src/node/cli.ts 裡,主要內容是藉助 minimist —— 一個輕量級的命令解析工具解析 npm scripts,解析的函式是 resolveOptions ,精簡後的程式碼片段如下。

function resolveOptions() {    // command 可以是 dev/build/optimize    if (argv._[0]) {        argv.command = argv._[0];    }    return argv;}

拿到 options 後,會根據 options.command 的值判斷是執行在開發環境需要的 runServe 命令或生產環境需要的 runBuild 命令。

if (!options.command || options.command === 'serve') {    runServe(options) } else if (options.command === 'build') {    runBuild(options) } else if (options.command === 'optimize') {    runOptimize(options) }

在 runServe 方法中,執行 server 模組的建立開發伺服器方法,同樣在 runBuild中執行 build 模組的構建方法。

最新的版本中還增加了 optimize 命令的支援,關於 optimize 做了什麼,我們下文再說。

server

這部分程式碼在 src/node/server/index.ts 裡,主要暴露一個 createServer 方法。

vite 使用 koa 作 web server,使用 clmloader 建立了一個監聽檔案改動的 watcher,同時實現了一個外掛機制,將 koa-app 和 watcher 以及其他必要工具組合成一個 context 物件注入到每個 plugin 中。

context 組成如下:

plugin 依次從 context 裡獲取上面這些組成部分,有的 plugin 在 koa 例項添加了幾個 middleware,有的藉助 watcher 實現對檔案的改動監聽,這種外掛機制帶來的好處是整個應用結構清晰,同時每個外掛處理不同的事情,職責更分明,

plugin

上文我們說到 plugin,那麼有哪些 plugin 呢?它們分別是:

使用者注入的 plugins —— 自定義 pluginhmrPlugin —— 處理 hmrhtmlRewritePlugin —— 重寫 html 內的 script 內容moduleRewritePlugin —— 重寫模組中的 import 匯入moduleResolvePlugin ——獲取模組內容vuePlugin —— 處理 vue 單檔案元件esbuildPlugin —— 使用 esbuild 處理資源assetPathPlugin —— 處理靜態資源serveStaticPlugin —— 託管靜態資源cssPlugin —— 處理 css/less/sass 等引用...

我們來看 plugin 的實現方式,開發一個用來攔截 json 檔案 plugin 可以這麼實現:

interface ServerPluginContext {  root: string  app: Koa  server: Server  watcher: HMRWatcher  resolver: InternalResolver  config: ServerConfig}type ServerPlugin = (ctx:ServerPluginContext)=> void;const JsonInterceptPlugin:ServerPlugin = ({app})=>{    app.use(async (ctx, next) => {      await next()      if (ctx.path.endsWith('.json') && ctx.body) {        ctx.type = 'js'        ctx.body = `export default json`      }  })}
build

這部分程式碼在 node/build/index.ts 中,build 目錄的結構雖然與 server 相似,同樣匯出一個 build 方法,同樣也有許多 plugin,不過這些 plugin 與 server 中的用途不一樣,因為 build 使用了 rollup ,所以這些 plugin 也是為 rollup 打包的 plugin ,本文就不再多提。

NO.4 vite 執行原理ES module

要了解 vite 的執行原理,首先要知道什麼是 ES module,目前瀏覽器對其的支援如下:

主流的瀏覽器(IE11除外)均已經支援,其最大的特點是在瀏覽器端使用 exportimport 的方式匯入和匯出模組,在 script 標籤裡設定 type="module" ,然後使用模組內容。

<script type="module">  import { bar } from './bar.js‘</script>

當 html 裡嵌入上面的 script 標籤時候,瀏覽器會發起 http 請求,請求 htttp server 託管的 bar.js ,在 bar.js 裡,我們用 named export 匯出 bar 變數,在上面的 script 中能獲取到 bar 的定義。

// bar.js export const bar = 'bar';
在 vite 中的作用

開啟執行中的 vite 專案,訪問 view-source 可以發現 html 裡有段這樣的程式碼:

<script type="module">    import { createApp } from '/@modules/vue'    import App from '/App.vue'    createApp(App).mount('#app')</script>

從這段程式碼中,我們能 get 到以下幾點資訊:

從 http://localhost:3000/@modules/vue 中獲取 createApp 這個方法從 http://localhost:3000/App.vue 中獲取應用入口使用 createApp 建立應用並掛載節點

createApp 是 vue3.X 的 api,只需知道這是建立了 vue 應用即可,vite 利用 ES module,把 “構建 vue 應用” 這個本來需要通過 webpack 打包後才能執行的程式碼直接放在瀏覽器裡執行,這麼做是為了:

去掉打包步驟實現按需載入去掉打包步驟

打包的概念是開發者利用打包工具將應用各個模組集合在一起形成 bundle,以一定規則讀取模組的程式碼——以便在不支援模組化的瀏覽器裡使用。

為了在瀏覽器里加載各模組,打包工具會藉助膠水程式碼用來組裝各模組,比如 webpack 使用 map 存放模組 id 和路徑,使用 __webpack_require__ 方法獲取模組匯出。

vite 利用瀏覽器原生支援模組化匯入這一特性,省略了對模組的組裝,也就不需要生成 bundle,所以打包這一步就可以省略了。

實現按需打包

前面說到,webpack 之類的打包工具會將各模組提前打包進 bundle 裡,但打包的過程是靜態的——不管某個模組的程式碼是否執行到,這個模組都要打包到 bundle 裡,這樣的壞處就是隨著專案越來越大打包後的 bundle 也越來越大。

開發者為了減少 bundle 大小,會使用動態引入 import() 的方式非同步的載入模組( 被引入模組依然需要提前打包),又或者使用 tree shaking 等方式盡力的去掉未引用的模組,然而這些方式都不如 vite 的優雅,vite 可以只在需要某個模組的時候動態(藉助 import() )的引入它,而不需要提前打包,雖然只能用在開發環境,不過這就夠了。

vite 如何處理 ESM

既然 vite 使用 ESM 在瀏覽器裡使用模組,那麼這一步究竟是怎麼做的?

上文提到過,在瀏覽器裡使用 ES module 是使用 http 請求拿到模組,所以 vite 必須提供一個 web server 去代理這些模組,上文中提到的 koa 就是負責這個事情,vite 通過對請求路徑的劫持獲取資源的內容返回給瀏覽器,不過 vite 對於模組匯入做了特殊處理。

@modules 是什麼?

通過工程下的 index.html 和開發環境下的 html 原始檔對比,發現 script 標籤裡的內容發生了改變,由

<script type="module">    import { createApp } from 'vue'    import App from '/App.vue'    createApp(App).mount('#app')</script>

變成了

<script type="module">    import { createApp } from '/@modules/vue'    import App from '/App.vue'    createApp(App).mount('#app')</script>
vite 對 import 都做了一層處理,其過程如下:
在 koa 中介軟體裡獲取請求 body通過 es-module-lexer 解析資源 ast 拿到 import 的內容判斷 import 的資源是否是絕對路徑,絕對視為 npm 模組返回處理後的資源路徑:"vue" => "/@modules/vue"

這部分程式碼在 serverPluginModuleRewrite 這個 plugin 中,

為什麼需要 @modules?

如果我們在模組裡寫下以下程式碼的時候,瀏覽器中的 esm 是不可能獲取到匯入的模組內容的:

import vue from 'vue'

因為 vue 這個模組安裝在 node_modules 裡,以往使用 webpack,webpack遇到上面的程式碼,會幫我們做以下幾件事

獲取這段程式碼的內容解析成 AST遍歷 AST 拿到 import 語句中的包的名稱使用 enhanced-resolve 拿到包的實際地址進行打包,

但是瀏覽器中 ESM 無法直接訪問專案下的 node_modules,所以 vite 對所有 import 都做了處理,用帶有 @modules 的字首重寫它們。

從另外一個角度來看這是非常比較巧妙的做法,把檔案路徑的 rewrite 都寫在同一個 plugin 裡,這樣後續如果加入更多邏輯,改動起來不會影響其他 plugin,其他 plugin 拿到資源路徑都是 @modules ,比如說後續可能加入 alias 的配置:就像 webpack alias 一樣:可以將專案裡的本地檔案配置成絕對路徑的引用。

怎麼返回模組內容

在下一個 koa middleware 中,用正則匹配到路徑上帶有 @modules 的資源,再通過 require('xxx') 拿到 包的匯出返回給瀏覽器。

以往使用 webpack 之類的打包工具,它們除了將模組組裝到一起形成 bundle,還可以讓使用了不同模組規範的包互相引用,比如:

ES module (esm) 匯入 cjs

CommonJS (cjs) 匯入 esm

dynamic import 匯入 esm

dynamic import 匯入 cjs

關於 es module 的坑可以看這篇文章(https://zhuanlan.zhihu.com/p/40733281)。

起初在 vite 還只是為 vue3.x 設計的時候,對 vue esm 包是經過特殊處理的,比如:需要 @vue/runtime-dom 這個包的內容,不能直接通過 require('@vue/runtime-dom')得到,而需要通過 require('@vue/runtime-dom/dist/runtime-dom.esm-bundler.js' 的方式,這樣可以使得 vite 拿到符合 esm 模組標準的 vue 包。

目前社群中大部分模組都沒有設定預設匯出 esm,而是匯出了 cjs 的包,既然 vue3.0 需要額外處理才能拿到 esm 的包內容,那麼其他日常使用的 npm 包是不是也同樣需要支援?答案是肯定的,目前在 vite 專案裡直接使用 lodash 還是會報錯的。

不過 vite 在最近的更新中,加入了 optimize 命令,這個命令專門為解決模組引用的坑而開發,例如我們要在 vite 中使用 lodash,只需要在 vite.config.js (vite 配置檔案)中,配置 optimizeDeps 物件,在 include 陣列中新增 lodash。

// vite.config.jsmodule.exports = {  optimizeDeps: {    include: ["lodash"]  }}
這樣 vite 在執行 runOptimize 的時候中會使用 roolup 對 lodash 包重新編譯,將編譯成符合 esm 模組規範的新的包放入 node_modules 下的 .vite_opt_cache 中,然後配合 resolver 對 lodash 的匯入進行處理:使用編譯後的包內容代替原來 lodash 的包的內容,這樣就解決了 vite 中不能使用 cjs 包的問題,這部分程式碼在 depOptimizer.ts 裡。

不過這裡還有個問題,由於在 depOptimizer.ts 中,vite 只會處理在專案下 package.json 裡的 dependencies 裡宣告好的包進行處理,所以無法在專案裡使用

import pick from 'lodash/pick'

的方式單使用 pick 方法,而要使用

import lodash from 'lodash'lodash.pick()

的方式,這可能在生產環境下使用某些包的時候對 bundle 的體積有影響。

返回模組的內容的程式碼在:serverPluginModuleResolve.ts 這個 plugin 中。

vite 如何編譯模組

最初 vite 為 vue3.x 開發,所以這裡的編譯指的是編譯 vue 單檔案元件,其他 es 模組可以直接匯入內容。

SFC

vue 單檔案元件(簡稱 SFC) 是 vue 的一個亮點,前端屆對 SFC 褒貶不一,個人看來,SFC 是利大於弊的,雖然 SFC 帶來了額外的開發工作量,比如為了解析 template 要寫模板解析器,還要在 SFC 中解析出邏輯和樣式,在 vscode 裡要寫 vscode 外掛,在 webpack 裡要寫 vue-loader,但是對於使用方來說可以在一個檔案裡可以同時寫 template、js、style,省了各檔案互相跳轉。

與 vue-loader 相似,vite 在解析 vue 檔案的時候也要分別處理多次,我們開啟瀏覽器的 network,可以看到:

1 個請求的 query 中什麼都沒有,另 2 個請求分別通過在 query 裡指定了 type 為 style 和 template。

先來看看如何將一個 SFC 變成多個請求,我們從第一次請求開始分析,簡化後的程式碼如下:

function vuePlugin({app}){  app.use(async (ctx, next) => {    if (!ctx.path.endsWith('.vue') && !ctx.vue) {      return next()    }    const query = ctx.query    // 獲取檔名稱    let filename = resolver.requestToFile(publicPath)    // 解析器解析 SFC    const descriptor = await parseSFC(root, filename, ctx.body)    if (!descriptor) {      ctx.status = 404      return    }    // 第一次請求 .vue    if (!query.type) {      if (descriptor.script && descriptor.script.src) {        filename = await resolvesrcImport(descriptor.script, ctx, resolver)      }      ctx.type = 'js'      // body 返回解析後的程式碼      ctx.body = await compileSFCMain(descriptor, filename, publicPath)    }        // ...}

在 compileSFCMain 中是一段長長的 generate 程式碼:

function compileSFCMain(descriptor, filePath: string, publicPath: string) {  let code = ''  if (descriptor.script) {    let content = descriptor.script.content    code += content.replace(`export default`, 'const __script =')  } else {    code += `const __script = {}`  }  if (descriptor.styles) {    code += `\\nimport { updateStyle } from "${hmrClientId}"\\n`    descriptor.styles.forEach((s, i) => {      const styleRequest = publicPath + `?type=style&index=${i}`      code += `\\nupdateStyle("${id}-${i}", ${JSON.stringify(styleRequest)})`    })    if (hasScoped) {      code += `\\n__script.__scopeId = "data-v-${id}"`    }  }  if (descriptor.template) {    code += `\\nimport { render as __render } from ${JSON.stringify(      publicPath + `?type=template`    )}`    code += `\\n__script.render = __render`  }  code += `\\n__script.__hmrId = ${JSON.stringify(publicPath)}`  code += `\\n__script.__file = ${JSON.stringify(filePath)}`  code += `\\nexport default __script`  return code}

直接看 generate 後的程式碼:

import { updateStyle } from "/vite/hmr"updateStyle("c44b8200-0", "/App.vue?type=style&index=0")__script.__scopeId = "data-v-c44b8200"import { render as __render } from "/App.vue?type=template"__script.render = __render__script.__hmrId = "/App.vue"__script.__file = "/Users/muou/work/playground/vite-app/App.vue"export default __script

出現了 vite/hmr 的匯入,vite/hmr 具體內容我們下文再分析,從這段程式碼中可以看到,對於 style vite 使用 updateStyle 這個方法處理,updateStyle 內容非常簡單,這裡就不貼程式碼了,就做了 1 件事:通過建立 style 元素,設定了它的 innerHtml 為 css 內容。

這兩種方式都會使得瀏覽器發起 http 請求,這樣就能被 koa 中介軟體捕獲到了,所以就形成了上文我們看到的:對一個 .vue 檔案處理三次的情景。

這部分程式碼在:serverPluginVue 這個 plugin 裡。

css

如果在 vite 專案裡引入一個 sass 檔案會怎麼樣?

最初 vite 只是為 vue 專案開發,所以並沒有對 css 預編譯的支援,不過隨著後續的幾次大更新,在 vite 專案裡使用 sass/less 等也可以跟使用 webpack 的時候一樣優雅了,只需要安裝對應的 css 前處理器即可。

在 cssPlugin 中,通過正則:/(.+).(less|sass|scss|styl|stylus)$/ 判斷路徑是否需要 css 預編譯,如果命中正則,就藉助 cssUtils 裡的方法藉助 postcss 對要匯入的 css 檔案編譯。

vite 熱更新的實現

上文中出現了 vite/hmr ,這就是 vite 處理熱更新的關鍵,在 serverPluginHmr plugin 中,對於 path 等於 vite/hmr 做了一次判斷:

 app.use(async (ctx, next) => {  if (ctx.path === '/vite/hmr') {      ctx.type = 'js'      ctx.status = 200      ctx.body = hmrClient  } }

hmrClient 是 vite 熱更新的客戶端程式碼,需要在瀏覽器裡執行,這裡先來說說通用的熱更新實現,熱更新一般需要四個部分:

首先需要 web 框架支援模組的 rerender/reload通過 watcher 監聽檔案改動通過 server 端編譯資源,並推送新模組內容給 client 。client 收到新的模組內容,執行 rerender/reload

vite 也不例外同樣有這四個部分,其中客戶端程式碼在:client.ts 裡,服務端程式碼在 serverPluginHmr 裡,對於 vue 元件的更新,通過 vue3.x 中的 HMRRuntime 處理的。

client 端

在 client 端, WebSocket 監聽了一些更新的型別,然後分別處理,它們是:

vue-reload —— vue 元件更新:通過 import 匯入新的 vue 元件,然後執行 HMRRuntime.reloadvue-rerender —— vue template 更新:通過 import 匯入新的 template ,然後執行 HMRRuntime.rerendervue-style-update —— vue style 更新:直接插入新的 stylesheetstyle-update —— css 更新:document 插入新的 stylesheetstyle-remove —— css 移除:document 刪除 stylesheetjs-update —— js 更新:直接執行full-reload —— 頁面 roload:使用 window.reload 重新整理頁面server 端

在 server 端,通過 watcher 監聽頁面改動,根據檔案型別判斷是 js Reload 還是 Vue Reload:

 watcher.on('change', async (file) => {    const timestamp = Date.now()    if (file.endsWith('.vue')) {      handleVueReload(file, timestamp)    } else if (      file.endsWith('.module.css') ||      !(file.endsWith('.css') || cssTransforms.some((t) => t.test(file, {})))    ) {      // everything except plain .css are considered HMR dependencies.      // plain css has its own HMR logic in ./serverPluginCss.ts.      handleJSReload(file, timestamp)    }  })

在 handleVueReload 方法裡,會使用解析器拿到當前檔案的 template/script/style ,並且與快取裡的上一次解析的結果進行比較,如果 template 發生改變就執行 vue-rerender,如果 style 發生改變就執行 vue-style-update,簡化後的邏輯如下:

async function handleVueReload(    file    timestamp,    content  ) {    // 獲取快取    const cacheEntry = vueCache.get(file)    // 解析 vue 檔案                                     const descriptor = await parseSFC(root, file, content)    if (!descriptor) {      // read failed      return    }    // 拿到上一次解析結果    const prevDescriptor = cacheEntry && cacheEntry.descriptor        // 設定重新整理變數    let needReload = false // script 改變標記    let needCssModuleReload = false // css 改變標記    let needRerender = false // template 改變標記    // 判斷 script 是否相同    if (!isEqual(descriptor.script, prevDescriptor.script)) {      needReload = true    }     // 判斷 template 是否相同    if (!isEqual(descriptor.template, prevDescriptor.template)) {      needRerender = true    }          // 通過 send 傳送 socket    if (needRerender){      send({        type: 'vue-rerender',        path: publicPath,        timestamp      })      }  }

handleJSReload 方法則是根據檔案路徑引用,判斷被哪個 vue 元件所依賴,如果未找到 vue 元件依賴,則判斷頁面需要重新整理,否則走元件更新邏輯,這裡就不貼程式碼了。

整體程式碼在 client.ts 和 serverPluginHmr.ts 裡。

NO.5 結語

本文分析了 vite 的啟動鏈路以及背後的部分原理,雖然在短時間內 vite 不會替代 webpack,但是能夠看到社群中多了一種方案還是很興奮的,這也是我寫下這篇文章的原因。

vite 更新的實在太快了,佩服尤大的勤奮和開源精神,短短一個月就加入了諸如 css 預編譯/react支援/通用 hmr 的支援,由於篇幅有限本文不再一一介紹這些新特性,這些新的特性等待讀者朋友們自行去探討了。

最新評論
  • 1 #

    一個由 Vue 作者尤雨溪開發的 web 開發工具—vite

  • 2 #

    當一個東西越來越複雜,並想包打天下時,就危險了!

  • 3 #

    簡單是建立在學習成本上的

  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • 廣告曝光監測實現方式