Vite 實現原理

2021-05-26 07:00:21

Vite 實現原理
瞭解 Vite 的核心實現原理

Vite 概念

• Vite 是一個面向現代瀏覽器的一個 更輕、更快的 Web 應用開發工具
• 它基於 ECMAScript 標準原生模組系統(ES Module)實現
• 它的出現是為了解決 webpack 在開發階段,使用 webpack-dev-server 冷啟動時間過長、webpack HMR 熱更新響應慢的問題
• 使用 Vite 建立的專案,就是一個普通的 Vue3 專案。相比於 VueCli 建立的專案,少了很多組態檔和依賴

Vite 專案依賴

Vite 建立的專案,開發依賴只有:
• Vite: 命令列工具
• @vue/compiler-sfc:專門用於編譯 .vue 結尾的單檔案元件的工具。Vue2 中使用的是 vue-template-compiler
Vite 只支援 Vue3 版本,在建立專案的時候,通過指定不同模板可以支援其他框架

基礎使用

Vite 專案中提供了兩個子命令:
• vite serve:用於開啟一個用於開發的伺服器,啟動伺服器的時候,不需要編譯所有程式碼檔案,啟動速度非常快
• vite build:打包
Vite & vue-cli-service serve

Vite & vue-cli-service serve

  • vite
    在這裡插入圖片描述
    在執行 vite serve 的時候,不需要編譯打包,直接開啟一個 web 伺服器,當瀏覽器請求伺服器,例如請求一個單檔案元件的時候,此時才在伺服器端編譯單檔案元件,然後把編譯結果返回給瀏覽器。
  • vue-cli-service serve
    在這裡插入圖片描述
    當執行 vue-cli-service serve 的時候,內部會使用 webpack 打包所有模組,如果模組比較多,打包的速度比較慢。把打包的結果儲存到記憶體中,然後開啟一個 web 伺服器。瀏覽器請求伺服器,把記憶體中快取的結果直接返回給瀏覽器。
    webpack 這類工具是提前將所有模組編譯打包進 bundle 裡,換句話說,不管模組有沒有被使用到,都要被編譯打包到 bundle 裡
    Vite 利用現代瀏覽器原生支援的 ES Module 模組化的方式,省略對模組的打包,對於需要編譯的元件(例如單檔案元件,樣式模組等),vite 採用另一種模式 —— 即時編譯,也就是說只有具體去請求某個檔案的時候,才會在伺服器端編譯這個檔案,所以這種模式體現在按需編譯,編譯速度會更快

HMR

  • Vite HMR
    • Vite 預設也支援 HMR 模組熱更新,相對於 webpack 的 HMR 效能更好,因為 Vite 只需要立即編譯當前所需要的檔案即可,所以響應 速度非常快。
  • Webpack HMR
    • 修改某個檔案過後,會自動以這個檔案為入口重新 build 一次,所有涉及到的依賴也都會被載入一遍,所以相應速度相對慢一些

Build

Vite 打包使用的是 vite build 命令

  • 內部使用 Rollup 打包,最終還是會將檔案提前編譯並打包到一起
  • 對於程式碼分割的功能,vite 內部採用的是原生的動態匯入的特性實現的,所以打包結果只能支援現代瀏覽器
    • 動態匯入特性還是有相應的 Polyfill 的

打包 or 不打包

隨著 Vite 的出現,引發了另一個問題:究竟有沒有必要去打包應用?

  • 使用 webpack 打包的兩個原因:
    • 瀏覽器環境並不支援模組化
    • 零散的模組檔案會產生大量的 HTTP 請求
  1. 隨著現代瀏覽器對 ES 標準支援的逐漸完善,第一個問題慢慢的已經不存在了,現階段絕大多數瀏覽器都是支援 ES Module 特性的
  2. 當 JS 檔案比較多的時候,每個 JS 檔案都要傳送一次請求,每個請求都要建立一個連線,為了減少請求伺服器的次數,所以打包成一個檔案。這個問題 HTTP2 已經解決,它可以複用連線

瀏覽器對 ES Module 的支援

在這裡插入圖片描述
IE11 是不支援 ES Module 的,所以如果專案需要支援 IE11,則需要使用過去的打包方式。
現代的瀏覽器都是支援 ES Module 的
開箱即用
Vite 建立的專案,幾乎不需要設定的,預設就支援 TypeScript
• TypeScript - 內建支援
• less/sass/stylus/postcss - 內建支援(需要單獨安裝編譯器)
• JSX
• Web Assembly

Vite 特性

Vite 帶來的優勢主要體現在提升開發者在開發過程中的體驗
• 快速冷啟動:web 伺服器不需要等待,可以立即啟動
• 模組熱更新:只會編譯當前所需的檔案,幾乎是實時的
• 按需編譯:避免編譯沒有用到的檔案
• 開箱即用:避免各種 loader 和 plugin 的設定

Vite 實現原理

通過實現一個自己的 vite 工具,來深入瞭解 vite 的工作原理

Vite 核心功能

• 啟動一個靜態 web 伺服器:將當前專案目錄作為靜態檔案伺服器的根目錄
• 編譯單檔案元件
• 攔截瀏覽器不識別的模組,並處理
• HMR:通過 web socket 實現

靜態 web 伺服器

實現一個能夠開啟 web 靜態伺服器的命令列工具,把當前執行 vite 的目錄,作為靜態 web 伺服器的根目錄
• 建立 vite-cli 資料夾,並使用 npm init 初始化
• 安裝 koa、koa-send(靜態檔案處理的中介軟體) 模組
npm i koa koa-send -S

  • 設定 package.json 檔案,新增 bin 欄位,值為 index.js
{
  "name": "vite-cli",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "bin": "index.js",
  "license": "ISC",
  "dependencies": {
    "koa": "^2.13.1",
    "koa-send": "^5.0.1"
  }
}
  • index.js
    開發基於node的命令列工具,所以需要加這個頭
#! /usr/bin/env node
const Koa = require('koa')
const send = require('koa-send')

const app = new Koa() // 建立Koa的範例
// 接下來使用Koa開發靜態web伺服器,預設返回根目錄中的index.html
// 建立一箇中介軟體,負責處理靜態檔案,預設載入當前目錄下,也就是執行該命令列工具目錄中的index.html
// 1. 開啟靜態檔案伺服器
app.use(async (ctx, next) => {
  // 預設返回執行該命令列工具的目錄下的 index.html
  // ctx 上下文 ctx.path當前請求的路徑
  await send(ctx, ctx.path, {
  	// 設定web服務的根目錄 
    root: process.cwd(), // 執行命令列工具(node程式)的目錄
    index: 'index.html' // 預設頁面
  })

  await next()  // 因為是中介軟體,呼叫next執行下一個中介軟體 
})

app.listen(3000, () => {
  console.log('server running at http://localhost:3000')
})
  • 使用 npm link(會當前的這個專案連結到npm安裝目錄裡) 將專案 link 到全域性
  • 進入 vue3 建立的專案,在專案根目錄下執行 vite-cli
    在這裡插入圖片描述
  • 開啟 http://localhost:3000
    在這裡插入圖片描述
    在這裡插入圖片描述
    解析 vue 檔案失敗了,說載入模組的時候需要使用路徑,在 main.js 檔案中 import 的 vue 是模組行為,瀏覽器不認識。
    在這裡插入圖片描述
    在 Vite 中,在載入 main.js 的時候,首先會去處理第三方模組的路徑。所以需要在伺服器端手動處理這個路徑問題,當請求一個模組的時候,需要將這個模組中載入第三方模組 import 的路徑進行處理
    在這裡插入圖片描述
    main.js 的響應頭是 JavaScript,在 web 伺服器輸出檔案之前,先判斷當前返回的檔案是否是 js 檔案,如果是的話再來處理裡面的第三方模組的路徑,然後再去請求 /@modules/vue.js,在伺服器中處理這個請求 —— 在 node_modules 中載入模組

載入第三方模組的問題

修改第三方模組的路徑
建立兩個中介軟體:
• 把載入第三方模組的 import 中的路徑改成 /@modules/<模組名稱>


// 把流轉換成字串
const streamToString = stream => new Promise((resolve, reject) => {
  const chunks = [] // 儲存讀取到的buffer
  stream.on('data', chunk => chunks.push(chunk)) // 註冊stream的data事件,監聽讀取到的buffer
  stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
  stream.on('error', reject)
})

// 1. 開啟靜態檔案伺服器

// 2. 修改第三方模組的路徑
app.use(async (ctx, next) => {
  // 在把檔案返回給瀏覽器之前,判斷當前檔案是否是 JavaScript
  if (ctx.type === 'application/javascript') {
  // 找到檔案中的內容,處理import中的路徑
    const contents = await streamToString(ctx.body)
    // import Vue from 'vue'
    // import App from './App.vue'
    // 正則: 匹配 from './xxx'
    // (?![\.\/]) 排除 . 開頭或者 / 開頭
    // 將 (from ') 替換為 (from '/@modules/)
    ctx.body = contents.replace(/(from\s+['"])(?!\.\/)/g, '$1/@modules/')
  }
})

重新啟動伺服器
在這裡插入圖片描述
在這裡插入圖片描述
當請求過來之後,判斷請求路徑中是否有 /@modules/<模組名稱>,如果有的話,去 node_modules 中載入對應的模組

// 3. 載入第三方模組: 
// 將請求路徑修改成 node_modules 中對應的模組路徑, 然後繼續交給處理靜態檔案的中介軟體繼續處理
app.use(async (ctx, next) => {
  // ctx.path --> /@modules/vue
  if (ctx.path.startsWith('/@modules/')) {
    const moduleName = ctx.path.substr(10)
    const pkgPath = path.join(process.cwd(), 'node_modules', moduleName, 'package.json')
    const pkg = require(pkgPath)
    // 重寫 path 請求, 改為 node_modules 中的模組路徑
    ctx.path = path.join('/node_modules', moduleName, pkg.module)
  }
  await next()
})

在這裡插入圖片描述
在這裡插入圖片描述
這裡有個問題,我們載入的 vue 是 bundle 版本的 vue,也就是需要打包的 vue。
vue 模組去載入了 runtime-dom 和 shared 模組,但是瀏覽器中並沒有去請求這兩個模組。

在載入 App.vue 和 index.css 模組的時候,瀏覽器報錯了,瀏覽器不能識別這兩個模組。
所以還需要在伺服器處理瀏覽器不能識別的模組
在這裡插入圖片描述

處理瀏覽器不能識別的模組

瀏覽器無法處理我們在 main.js 中使用 import 載入的單檔案元件模組和樣式模組,瀏覽器只能處理 JS 模組,所以通過 import 載入的模組都需要在伺服器端處理,當請求單檔案元件的時候,需要在伺服器上編譯成 JS 模組,然後返回給瀏覽器。

在 Vite 中處理單檔案元件會傳送兩次請求

  • 第一次請求的時候,伺服器端會把單檔案元件編譯成一個物件
import HelloWorld from '/src/components/HelloWorld.vue'
// 建立元件的選項物件. 
// 這裡沒有模板, 因為模板最終要被編譯成 render 函數, 然後掛載到選項物件上
const __script = {
  name: 'App',
  components: {
    HelloWorld
  }
}
// 載入 App.vue, 並加上 type=template
// 這次請求是告訴伺服器, 編譯這個單檔案元件的模板, 然後返回一個 render 函數
import {render as __render} from "/src/App.vue?type=template"
// 把 render 函數掛載到元件的選項物件上
__script.render = __render
__script.__hmrId = "/src/App.vue"
typeof __VUE_HMR_RUNTIME__ !== 'undefined' && __VUE_HMR_RUNTIME__.createRecord(__script.__hmrId, __script)
__script.__file = "E:\\file\\study\\big_front_end\\part03\\module-05\\task03\\vite-cli-test\\src\\App.vue"
// 匯出選項物件
export default __script
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIkU6XFxmaWxlXFxzdHVkeVxcYmlnX2Zyb250X2VuZFxccGFydDAzXFxtb2R1bGUtMDVcXHRhc2swM1xcdml0ZS1jbGktdGVzdFxcc3JjXFxBcHAudnVlIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiI7QUFNQSxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsRUFBRSxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxFQUFFLENBQUMsQ0FBQyxDQUFDLEVBQUUsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQzs7QUFFbkQsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLEVBQUUsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsRUFBRTtFQUNiLENBQUMsQ0FBQyxDQUFDLENBQUMsRUFBRSxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUM7RUFDWCxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLEVBQUU7SUFDVixDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQztFQUNYO0FBQ0YiLCJmaWxlIjoiRTovZmlsZS9zdHVkeS9iaWdfZnJvbnRfZW5kL3BhcnQwMy9tb2R1bGUtMDUvdGFzazAzL3ZpdGUtY2xpLXRlc3Qvc3JjL0FwcC52dWUiLCJzb3VyY2VSb290IjoiIiwic291cmNlc0NvbnRlbnQiOlsiPHRlbXBsYXRlPlxuICA8aW1nIGFsdD1cIlZ1ZSBsb2dvXCIgc3JjPVwiLi9hc3NldHMvbG9nby5wbmdcIiAvPlxuICA8SGVsbG9Xb3JsZCBtc2c9XCJIZWxsbyBWdWUgMy4wICsgVml0ZVwiIC8+XG48L3RlbXBsYXRlPlxuXG48c2NyaXB0PlxuaW1wb3J0IEhlbGxvV29ybGQgZnJvbSAnLi9jb21wb25lbnRzL0hlbGxvV29ybGQudnVlJ1xuXG5leHBvcnQgZGVmYXVsdCB7XG4gIG5hbWU6ICdBcHAnLFxuICBjb21wb25lbnRzOiB7XG4gICAgSGVsbG9Xb3JsZFxuICB9XG59XG48L3NjcmlwdD5cbiJdfQ==
  • 第二次請求,在伺服器端編譯單檔案元件的模板,然後匯出一個 render 函數
import {createVNode as _createVNode, resolveComponent as _resolveComponent, Fragment as _Fragment, openBlock as _openBlock, createBlock as _createBlock} from "/@modules/vue.js"

const _hoisted_1 = /*#__PURE__*/
_createVNode("img", {
  alt: "Vue logo",
  src: "/src/assets/logo.png"
}, null, -1 /* HOISTED */
)

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  const _component_HelloWorld = _resolveComponent("HelloWorld")

  return (_openBlock(),
  _createBlock(_Fragment, null, [_hoisted_1, _createVNode(_component_HelloWorld, {
    msg: "Hello Vue 3.0 + Vite"
  })], 64 /* STABLE_FRAGMENT */
  ))
}
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIkU6XFxmaWxlXFxzdHVkeVxcYmlnX2Zyb250X2VuZFxccGFydDAzXFxtb2R1bGUtMDVcXHRhc2swM1xcdml0ZS1jbGktdGVzdFxcc3JjXFxBcHAudnVlIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiI7O2dDQUNFLGFBQThDO0VBQXpDLEdBQUcsRUFBQyxVQUFVO0VBQUMsR0FBRyxFQUFDLHNCQUFtQjs7Ozs7OztJQUEzQyxVQUE4QztJQUM5QyxhQUF5Qyx5QkFBN0IsR0FBRyxFQUFDLHNCQUFzQiIsImZpbGUiOiJFOi9maWxlL3N0dWR5L2JpZ19mcm9udF9lbmQvcGFydDAzL21vZHVsZS0wNS90YXNrMDMvdml0ZS1jbGktdGVzdC9zcmMvQXBwLnZ1ZSIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzQ29udGVudCI6WyI8dGVtcGxhdGU+XG4gIDxpbWcgYWx0PVwiVnVlIGxvZ29cIiBzcmM9XCIuL2Fzc2V0cy9sb2dvLnBuZ1wiIC8+XG4gIDxIZWxsb1dvcmxkIG1zZz1cIkhlbGxvIFZ1ZSAzLjAgKyBWaXRlXCIgLz5cbjwvdGVtcGxhdGU+XG5cbjxzY3JpcHQ+XG5pbXBvcnQgSGVsbG9Xb3JsZCBmcm9tICcuL2NvbXBvbmVudHMvSGVsbG9Xb3JsZC52dWUnXG5cbmV4cG9ydCBkZWZhdWx0IHtcbiAgbmFtZTogJ0FwcCcsXG4gIGNvbXBvbmVudHM6IHtcbiAgICBIZWxsb1dvcmxkXG4gIH1cbn1cbjwvc2NyaXB0PlxuIl19

處理瀏覽器第一次請求單檔案元件 —— 將元件編譯成元件選項物件
這次請求需要在伺服器端把單檔案元件編譯成元件的選項物件
這裡需要寫一箇中介軟體來處理單檔案元件。當請求到單檔案元件並把單檔案元件讀取完成之後,接下來需要對單檔案元件進行編譯,並把編譯結果返回給瀏覽器。
核心是讀取完單檔案元件之後再進行處理,所以這個中介軟體應該寫在 處理完成靜態檔案 之後,並且單檔案元件也有可能載入第三方模組,所以是在 處理第三方模組 之前。

// 1.

// 4. 處理單檔案元件
app.use(async (ctx, next) => {
  // 判斷是否為單檔案元件:字尾是否為 .vue 結尾
  if (ctx.path.endsWith('.vue')) {
    // 把 ctx.body 轉化為字串,
    // ctx.body 就是單檔案元件的內容,在編譯單檔案元件的時候,需要單檔案元件的內容的
    const contents = await streamToString(ctx.body)
    // 將元件編譯成選項物件
    const { descriptor } = compilerSfc.parse(contents)
    let code
    // 處理第一次請求,不帶 type 的情況
    if (!ctx.query.type) {
      code = descriptor.script.content
      console.log(code)
    }
  }
  await next()
})

// 2.

code 的輸出結果
在這裡插入圖片描述
而 vite 中的結果是這樣的
在這裡插入圖片描述
所以我們需要將 code 改造成與 Vite 類似的樣子

const { Readable } = require('stream')

// 把字串轉換為流
const stringToStream = string => {
  const stream = new Readable()
  stream.push(string)
  // 標識這個流已經寫完了
  stream.push(null)
  return stream
}

// 4. 處理單檔案元件
app.use(async (ctx, next) => {
  // 判斷是否為單檔案元件:字尾是否為 .vue 結尾
  if (ctx.path.endsWith('.vue')) {
    // 把 ctx.body 轉化為字串,
    // ctx.body 就是單檔案元件的內容,在編譯單檔案元件的時候,需要單檔案元件的內容的
    const contents = await streamToString(ctx.body)
    const { descriptor } = compilerSfc.parse(contents)
    let code
    // 處理第一次請求,不帶 type 的情況
    if (!ctx.query.type) {
      code = descriptor.script.content
      // console.log(code)
      // 將選項物件快取到變數 __script 中
      code = code.replace(/export\s+default\s+/g, 'const __script = ')
      // 拼接
      code += `
        import {render as __render} from "${ctx.path}?type=template"
        __script.render = __render
        export default __script
      `
    }
    // 設定響應頭為 JavaScript
    ctx.type = 'application/javascript'
    // 將 code 轉換為唯讀流輸出給瀏覽器
    // 因為下一個中介軟體中的 ctx.body 是流的形式
    ctx.body = stringToStream(code)
  }
  await next()
})

重新整理瀏覽器檢視請求結果
在這裡插入圖片描述
但是看左邊的請求列表,並沒有看到有 App.vue?type=template 的請求。這是因為瀏覽器在載入 index.css 模組的時候不能識別報錯了,導致後續的請求被阻塞
在這裡插入圖片描述
先將專案中引入圖片、樣式的程式碼註釋起來,防止干擾。
重新啟動伺服器之後檢視瀏覽器的請求:
在這裡插入圖片描述
此時已經能夠正常請求 App.vue?type=template 了,但是沒有響應,這是因為我們還沒有去處理這個請求的響應。

處理瀏覽器第二次請求單檔案元件 —— 編譯單檔案元件的模板並匯出 render 函數

前面我們已經將瀏覽器第一次單檔案元件的請求處理完畢了,第一次請求是將單檔案元件編譯成元件的選項物件並返回給瀏覽器,但是這個選項物件中沒有模板或者 render 函數。
在第二次請求中,url 中會帶著引數 ?type=template,在第二次請求中要把單檔案元件的模板編譯成 render 函數

// 4.
...
    let code
    // 處理第一次請求,不帶 type 的情況
    if (!ctx.query.type) {
			...
    }
    // 第二次請求,type=template
    else if (ctx.query.type === 'template') {
      const templateRender = compilerSfc.compileTemplate({
        source: descriptor.template.content
      })
      code = templateRender.code
    }
...

重新啟動伺服器並重新整理瀏覽器檢視請求結果:
在這裡插入圖片描述
可以看到第二次請求單檔案元件也成功響應了,並且返回了 render 函數。
到這編譯模板就做完了,但是此時頁面上還是什麼都沒有
控制檯中報了個錯,是 Vue 原始碼中的 shared 檔案中的 process 不存在
在這裡插入圖片描述

process 是 node 環境中的變數,而我們的程式碼是執行在瀏覽器裡的,所以就報錯了。
而原始碼中這句程式碼的作用是讓打包工具根據環境變數來分別進行生產環境或者開發環境的打包操作,但是這裡我們沒有使用打包工具,所以這句話直接返回給了瀏覽器,而瀏覽器不認識,所以就報錯了。
所以我們應該在伺服器上處理一下,在返回 JS 模組之前我們應該把所有程式碼中的 process.env.NODE_ENV 都替換成 ‘development’,因為當前環境是開發環境下的 web 伺服器

// 2. 修改第三方模組的路徑
app.use(async (ctx, next) => {
  // 在把檔案返回給瀏覽器之前,判斷當前檔案是否是 JavaScript
  if (ctx.type === 'application/javascript') {
    const contents = await streamToString(ctx.body)
    // import Vue from 'vue'
    // import App from './App.vue'
    // 正則: 匹配 from './xxx'
    // (?![\.\/]) 排除 . 開頭或者 / 開頭
    // 將 (from ') 替換為 (from '/@modules/)
    ctx.body = contents
      .replace(/(from\s+['"])(?![\.\/])/g, '$1/@modules/')
      // 替換所有模組的 process.env.NODE_ENV 為 'development'
      .replace(/process\.env\.NODE_ENV/g, '"development"')

  }
})

重新啟動伺服器之後重新整理瀏覽器檢視結果
在這裡插入圖片描述
這次終於可以看到結果了,這裡沒有樣式是因為我們把匯入樣式模組的程式碼給註釋了,而且點選按鈕,元件也可以正常工作