開發與維運

vite 如何做到讓 vue 本地開發更快速?

vite 是什麼

vite——一個由 vue 作者尤雨溪專門為 vue 打造的開發利器,其目的是使 vue 項目的開發更加簡單和快速。
 
vite 究竟有什麼作用?用 vite 文檔上的介紹,它具有以下特點:

  1. 快速的冷啟動
  2. 即時的熱模塊更新
  3. 真正的按需編譯

 
以上三個優點,社區也早有對應的解決方案,比如快速的冷啟動可以藉助各種 cli :vue-cli、create-react-app 等等,熱更新就更不用說了,不過按需編譯需要開發者自行在代碼中使用 impor('xx.js') 實現, 那麼 vite 有什麼特別的地方呢?用作者在微博上的原話:

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

]()

 
可以看到 vite 主要特色是基於瀏覽器原生的 ES Module 來開發,從而實現按需編譯,也就沒有打包這個概念——因為需要什麼資源直接在瀏覽器裡引入即可,不過基於瀏覽器原生 ES module 來開發 web 應用也不是什麼新鮮事,snowpack 也是做這個事情,而且它可以用在所有項目上,不過目前此項目社區中沒有流行的使用起來,好在 vue 在 web 開發領域有著極大的話語權,vite 的出現可以說又會讓利用 ES module 開發火一陣子。
 
有趣的是 vite 算是革了 webpack 的命了(生產環境用 rollup),所以 webpack 的開發者直接喊大哥了...
image.png

vite 的使用方式

同其他開發工具一樣,vite 提供了用 npm 或者 yarn 一建生成項目的途徑,使用 yarn 在終端執行:

$ yarn create vite-app <project-name>
$ cd <project-name>
$ yarn
$ yarn dev

即可初始化一個 vite 項目,生成的項目結構非常簡潔:

|____node_modules
|____App.vue // vue 應用入口
|____index.html // 頁面
|____package.json

運行 yarn dev 即可開發 ,打開 package.json 發現,如果 dev 只需要執行 vite 命令,而 build需要使用 vite build

如何調試 vite

在 package.json scripts 值裡添加一行 debug 命令:

{
  "scripts": {
    ...
         "debug": "node --inspect-brk=5858 ./node_modules/vite/dist/cli.js"
    }
}

這裡使用 node 運行 node_modules 裡 vite 包而不是用 npm scripts 的方式,這是 node 應用的調試方式,再配置 vscode 的 launch.json ,指定 vscode 調試 npm scripts 的方式,設置端口、runtime 參數等,就可以調試起來了。

{
  "type": "node",
  "request": "launch",
  "name": "Launch via NPM",
  "runtimeExecutable": "npm",
  "runtimeArgs": ["run-script", "debug"],
  "port": 5858,
  "skipFiles": ["<node_internals>/**"]
}

由於 node_modules 裡的 vite 已經是編譯後的代碼了,建議 clone 一份原始的 repo 用來參考,兩兩結合使得執行過程更明確。

vite 鏈路分析

命令解析

這部分代碼在 https://github.com/vuejs/vite/blob/master/src/node/cli.ts 裡,主要內容是藉助 minimist —— 一個輕量級的命令解析工具解析用戶命令,vite 沒有使用 commander 這樣通用的命令行解決方案,而是近乎裸寫了一份,解析命令參數的函數是 parseArgs ,精簡後的代碼片段如下:

function parseArgs() {
    const argv = require('minimist')(process.argv.slice(2));
      // 設置 DEBUG 環境變量
    if (argv.debug) {
        process.env.DEBUG = `vite:` + (argv.debug === true ? '*' : argv.debug);
    }
    // 遍歷 jsx 解析
    if (argv['jsx-factory']) {
        (argv.jsx || (argv.jsx = {})).factory = argv['jsx-factory'];
    }
    if (argv['jsx-fragment']) {
        (argv.jsx || (argv.jsx = {})).fragment = argv['jsx-fragment'];
    }
    // 解析 runServe 或者 runBuild
    if (argv._[0]) {
        argv.command = argv._[0];
    }
    return argv;
}

通過源碼和 README,發現 vite 額外添加了對於 jsx 項目的支持,理論上 vite 的原理並不限制只能應用在 vue 上,作者解釋因為 react 沒有提供 ES module 的支持所以不能在 react 項目裡使用,不過可以通過社區中的 ES module 版本的 react 代替。另外,vue3 的 jsx transform 依然在 wip 中,所以這裡的支持是預留的。

拿到 argv 後,會根據 argv.command 的值判斷是啟動開發服務器或者執行生產構建命令。
值得一提的是,在這個文件中,找到了一個 resolveConfig 方法,這個方法的作用是獲取項目下的 vite.config.js,這是在為後續的更多的配置內容做準備。

server

這部分代碼在 https://github.com/vuejs/vite/blob/master/src/node/server/index.ts 裡,對外暴露一個 createServer 方法,與常見的開發工具一樣,vite 使用 koa 作 web 服務器,使用 clmloader 創建了一個監聽文件改動的 watcher,同時實現了一個插件機制,以一個 context 對象,將 koa-app 和 watcher 以及其他輔助工具注入到每個 plugin 中,plugin 依次往 context 裡的各部分監聽事件,每個 plugin 處理不同的事情,這樣的好處是職責分明,結構清晰。

context 組成如下:
image.png

plugin

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

  • 用戶注入 plugins —— 自定義 plugin
  • hmrPlugin —— 處理 hmr 
  • moduleRewritePlugin —— 重寫 script 和 html 
  • moduleResolvePlugin —— 解析資源路徑中含有 @modules 的模塊
  • vuePlugin —— 處理 vue 單文件組件
  • esbuildPlugin —— 使用 esbuild 處理資源
  • assetPathPlugin —— 處理靜態資源(js)路徑
  • serveStaticPlugin —— 使用 koa-static 託管靜態資源

我們來看 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`
      }
  })
}

vite 背後的原理都在 plugin 裡,這裡不再一一解釋每個 plugin 的作用,會放在下文背後的原理中一併討論。

build

這部分代碼在 https://github.com/vuejs/vite/blob/master/src/node/build/index.ts 中,build 目錄的結構雖然與 server 相似,導出一個 build 方法,同樣也有許多 plugin ,不過這些 plugin 與 server 中的用途不一樣,因為 build 使用了 rollup ,這些 plugin 也是為 rollup 打包開發的 plugin ,本文就不再多提。

vite 運行原理

ES module

要了解 vite 的運行原理,首先要知道什麼是 ES module,目前流覽器對其的支持如下:
image.png
可以看到主流的瀏覽器(IE11除外)均已經支持,其最大的特點是在瀏覽器端使用 export import 的方式導入和導出模塊,在 script 標籤裡設置 type="module" ,然後使用 ES module。

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

當 html 裡嵌入上面的 script 標籤時候,瀏覽器會發起 http 請求,請求 htttp server 託管的 bar.js ,在 bar.js 裡,我們使用 named export 導出模塊:

// bar.js 
export const bar = 'bar';

ES module 在 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.0 的 api,只需知道這是創建了 vue 應用即可,vite 利用 ES module,把 “創建 vue 應用” 這個本來需要通過 webpack 打包後才能執行的代碼直接放在瀏覽器裡執行,這麼做是為了:

  1. 去掉打包步驟
  2. 實現按需加載

去掉打包步驟

去掉打包步驟非常好理解,打包的概念無非是開發者利用工具將應用各個模塊集合在一起形成 bundle,以一定規則讀取模塊的代碼——以便在不支持模塊化的瀏覽器裡使用。而且為了加載各模塊,打包工具會實現膠水代碼用來連接和調用模塊,比如 webpack 使用 map 存放模塊 id 和路徑,使用 __webpack_require__  方法獲取模塊導出,因為瀏覽器支持了模塊化,所以打包這一步就可以省略了。

實現按需打包

前面說到,webpack 之類的打包工具會將模塊提前打包進 bundle 裡,但打包本身是靜態的——不管某個模塊的代碼是否執行到,這個模塊都要打包到 bundle 裡。這樣的壞處就是隨著項目越來越大打包後的 bundle 也越來越大。

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

vite 如何處理模塊

既然 vite 使用 ES module 在瀏覽器裡使用模塊,那麼這一步究竟是怎麼做的?
上文提到過,ES moudle 使用模塊是通過發送 http 請求實現的,所以 vite 必須提供一個靜態資源服務器去代理這些模塊,上文中提到的 koa 就是做這個事情,其通過對請求路徑的分析獲取資源的內容返回給瀏覽器,不過 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>

上面是 web 項目裡標準的 ES module 用法,從 node_modules 裡導入一個 vue 的 npm 包,然後由 webpack 通過解析 AST 尋址拿到包的實際地址進行打包,但是在瀏覽器下無法直接訪問 node_modules(只能使用 http請求),所以 vite 對 import 都做了一層處理,其過程如下:

  1. 在 koa 中間件裡獲取請求 body
  2. 通過 es-module-lexer 解析資源 ast 拿到 import 的內容
  3. 判斷 import 的資源是否是絕對路徑,絕對視為 npm 模塊
  4. 返回處理後的資源路徑:"vue" => "/@modules/vue"
     

這部分代碼在 serverPluginModuleRewrite 這個 plugin 中。
 

為什麼需要 @modules?

原則上這裡並不需要對路徑進行特殊轉換,私以為這是比較巧妙的做法,把文件路徑的 rewrite 都寫在同一個 plugin 裡,這樣後續如果加入更多邏輯,改動起來不會影響其他 plugin,其他 plugin 拿到資源路徑都是 '@modules' ,比如說後續可能加入 alias 的配置:就像 webpack 一樣——可以將項目裡的本地文件配置成絕對路徑的引用。

怎麼返回模塊內容

這部分內容相對來說就很簡單了,通過下一個 koa 中間件,用正則匹配到路徑上帶有 ‘@modules’ 的資源,再通過 require('xxx') 拿到 npm 包的導出最後返回內容,在這裡有個很重要的一點,那就是對 vue es 包的特殊處理,比如:

需要 @vue/runtime-dom 這個包的內容,不能直接通過 require('`@vue/runtime-dom')得到,而需要通過 require('@vue/runtime-dom/dist/runtime-dom.esm-bundler.js'` 的方式
 
為什麼需要特殊處理 vue es 包?前面我們提到了以往開發需要使用 webpack 之類的打包工具,而 webpack 工具鏈除了將模塊組裝到一起形成 bundle,它還可以使得不同模塊規範互相引用,比如:

  • ES module (esm) 導入cjs
  • CommonJS (cjs) 導入esm
  • dynamic import 導入 esm
  • dynamic import 導入 cjs

目前大部分模塊都沒有設置默認導出 es module,而是導出了 cjs 的包,vue3.0 也不例外,所以這裡要特殊處理一下,關於 es module 的坑可以看這篇文章

看到這裡,其實聰明的你肯定也想到了,既然 vue3.0 需要額外處理才能拿到 esm 的包內容,那麼其他日常使用的 npm 包是不是也同樣需要支持?答案是肯定的,但是 vite 目前還沒有處理好這塊,目前在 vite 項目裡使用 lodash 還是會報錯的。
image.png

要完全解決獲取 esm 包的坑,任重而道遠,這部分代碼在:serverPluginModuleResolve 這個 plugin 中。

vite 如何編譯模塊

因為 vite 為 vue3.0 開發,所以這裡的編譯指的也是編譯 vue 單文件組件了,其他 es 模塊可以直接導入內容。

SFC

vue 單文件組件(簡稱 SFC) 是 vue 的一個亮點,前端屆對 SFC 褒貶不一,喜歡的人非常喜歡,討厭的人非常討厭。個人看來,SFC 是利大於弊的,雖然 SFC 帶來了額外的開發工作量,比如為了解析 template 要寫模板解析器,還要在 SFC 中解析出邏輯和樣式,在 vscode 裡要寫 vscode 插件,在 webpack 裡要寫 vue-loader,但是對於使用方來說可以在一個文件裡可以同時寫 template、js、style,省了各文件互相跳轉。
 
與 vue-loader 相似,vite 在解析 vue 文件的時候也要分別處理多次,我們打開瀏覽器的 network,可以看到:
image.png

一個請求 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 內容非常簡單,這裡就不貼代碼了,就做了一件事:通過創建 link 元素,設置了它的 href,href 指向帶 type='style' 的 .vue 文件, 然後往 document 裡塞入一段 css, 對於 template 直接使用 import 導入帶 type='`template`' 的 .vue 文件,這兩種方式都會使得瀏覽器發起 http 請求,這樣就能被 koa 中間件捕獲到了,所以就形成了上文我們看到的:對一個 .vue 文件處理三次的情景。

這部分代碼在:serverPluginVue 這個 plugin 裡。

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 熱更新的客戶端代碼,需要在瀏覽器裡執行,這裡先來說說通用的熱更新實現,熱更新一般需要四個部分:

  1. 通過 watcher 監聽文件改動
  2. 通過 server 端編譯資源,並推送新資源信息給 client 。
  3. 需要框架支持組件 rerender/reload 
  4. client 收到資源信息,執行框架 rerender 邏輯。

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

client 端

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

  • vue-reload —— vue 組件更新:通過 import 導入新的 vue 組件,然後執行 HMRRuntime.reload
  • vue-rerender —— vue template 更新:通過 import 導入新的 template ,然後執行 HMRRuntime.rerender
  • vue-style-update —— vue style 更新:直接插入新的 stylesheet 
  • style-update —— css 更新:document 插入新的 stylesheet
  • style-remove —— css 移除:document 刪除 stylesheet
  • js-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.tsserverPluginHmr 裡。

結語

至此,本文分析了 vite 的啟動鏈路以及背後的部分原理,雖然在可預期的一段時間內我不會使用 vue 開發項目,但是能夠看到社區中多了一種方案還是很興奮的,這也是我寫下這篇文章的原因。
vite 才剛被創造出來不久,代碼還算容易理解,現在學習它是個很好的機會。
最後,淘系技術部-淘寶特價版前端組招大量的人~ 歡迎勾搭!
 

Leave a Reply

Your email address will not be published. Required fields are marked *