Vite 實現原理
瞭解 Vite 的核心實現原理
• Vite 是一個面向現代瀏覽器的一個 更輕、更快的 Web 應用開發工具
• 它基於 ECMAScript 標準原生模組系統(ES Module)實現
• 它的出現是為了解決 webpack 在開發階段,使用 webpack-dev-server 冷啟動時間過長、webpack HMR 熱更新響應慢的問題
• 使用 Vite 建立的專案,就是一個普通的 Vue3 專案。相比於 VueCli 建立的專案,少了很多組態檔和依賴
Vite 建立的專案,開發依賴只有:
• Vite: 命令列工具
• @vue/compiler-sfc:專門用於編譯 .vue 結尾的單檔案元件的工具。Vue2 中使用的是 vue-template-compiler
Vite 只支援 Vue3 版本,在建立專案的時候,通過指定不同模板可以支援其他框架
Vite 專案中提供了兩個子命令:
• vite serve:用於開啟一個用於開發的伺服器,啟動伺服器的時候,不需要編譯所有程式碼檔案,啟動速度非常快
• vite build:打包
Vite & vue-cli-service serve
Vite 打包使用的是 vite build 命令
隨著 Vite 的出現,引發了另一個問題:究竟有沒有必要去打包應用?
IE11 是不支援 ES Module 的,所以如果專案需要支援 IE11,則需要使用過去的打包方式。
現代的瀏覽器都是支援 ES Module 的
開箱即用
Vite 建立的專案,幾乎不需要設定的,預設就支援 TypeScript
• TypeScript - 內建支援
• less/sass/stylus/postcss - 內建支援(需要單獨安裝編譯器)
• JSX
• Web Assembly
Vite 帶來的優勢主要體現在提升開發者在開發過程中的體驗
• 快速冷啟動:web 伺服器不需要等待,可以立即啟動
• 模組熱更新:只會編譯當前所需的檔案,幾乎是實時的
• 按需編譯:避免編譯沒有用到的檔案
• 開箱即用:避免各種 loader 和 plugin 的設定
通過實現一個自己的 vite 工具,來深入瞭解 vite 的工作原理
• 啟動一個靜態 web 伺服器:將當前專案目錄作為靜態檔案伺服器的根目錄
• 編譯單檔案元件
• 攔截瀏覽器不識別的模組,並處理
• HMR:通過 web socket 實現
實現一個能夠開啟 web 靜態伺服器的命令列工具,把當前執行 vite 的目錄,作為靜態 web 伺服器的根目錄
• 建立 vite-cli 資料夾,並使用 npm init 初始化
• 安裝 koa、koa-send(靜態檔案處理的中介軟體) 模組
npm i koa koa-send -S
{
"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"
}
}
#! /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')
})
修改第三方模組的路徑
建立兩個中介軟體:
• 把載入第三方模組的 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==
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"')
}
})
重新啟動伺服器之後重新整理瀏覽器檢視結果
這次終於可以看到結果了,這裡沒有樣式是因為我們把匯入樣式模組的程式碼給註釋了,而且點選按鈕,元件也可以正常工作