哈嘍,很高興你能點開這篇部落格,本部落格是針對 Vite
原始碼的解讀系列文章,認真看完後相信你能對 Vite
的工作流程及原理有一個簡單的瞭解。
Vite
是一種新型的前端構建工具,能夠顯著提升前端開發體驗。
我將會使用圖文結合的方式,儘量讓本篇文章顯得不那麼枯燥(顯然對於原始碼解讀類文章來說,這不是個簡單的事情)。
如果你還沒有使用過 Vite
,那麼你可以看看我的前兩篇文章,我也是剛體驗沒兩天呢。(如下)
本篇文章是 Vite
原始碼解讀系列的第三篇文章,往期文章可以看這裡:
本篇文章解讀的主要是 vite
原始碼本體,在往期文章中,我們瞭解到:
vite
在本地開發時通過connect
庫提供開發伺服器,通過中介軟體機制實現多項開發伺服器配置,沒有藉助webpack
打包工具,加上利用rollup
(部分功能)排程內部plugin
實現了檔案的轉譯,從而達到小而快的效果。vite
在構建生產產物時,將所有的外掛收集起來,然後交由rollup
進行處理,輸出用於生產環境的高度優化過的靜態資源。
本篇文章,我會針對貫穿前兩期文章的 vite
的外掛 —— @vitejs/plugin-vue
來進行原始碼解析。
好了,話不多說,我們開始吧!
vite:vue
vite:vue
外掛是在初始化 vue
專案的時候,就被自動注入到 vite.config.js
中的外掛。(如下)
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue()
]
});
該外掛匯出了幾個鉤子函式,這幾個鉤子函式,部分是用於 rollup
,部分是 vite
專屬。(如下圖)
在開始閱讀原始碼之前,我們需要先了解一下 vite
和 rollup
中每一個鉤子函式的呼叫時機和作用。
欄位 | 說明 | 所屬 |
---|---|---|
name | 外掛名稱 | vite 和 rollup 共享 |
handleHotUpdate | 執行自定義 HMR(模組熱替換)更新處理 | vite 獨享 |
config | 在解析 Vite 配置前呼叫。可以自定義配置,會與 vite 基礎配置進行合併 | vite 獨享 |
configResolved | 在解析 Vite 配置後呼叫。可以讀取 vite 的配置,進行一些操作 | vite 獨享 |
configureServer | 是用於配置開發伺服器的鉤子。最常見的用例是在內部 connect 應用程式中新增自定義中介軟體。 | vite 獨享 |
transformIndexHtml | 轉換 index.html 的專用鉤子。 | vite 獨享 |
options | 在收集 rollup 配置前,vite (本地)服務啟動時呼叫,可以和 rollup 配置進行合併 | vite 和 rollup 共享 |
buildStart | 在 rollup 構建中,vite (本地)服務啟動時呼叫,在這個函式中可以訪問 rollup 的配置 | vite 和 rollup 共享 |
resolveId | 在解析模組時呼叫,可以返回一個特殊的 resolveId 來指定某個 import 語句載入特定的模組 | vite 和 rollup 共享 |
load | 在解析模組時呼叫,可以返回程式碼塊來指定某個 import 語句載入特定的模組 | vite 和 rollup 共享 |
transform | 在解析模組時呼叫,將原始碼進行轉換,輸出轉換後的結果,類似於 webpack 的 loader | vite 和 rollup 共享 |
buildEnd | 在 vite 本地服務關閉前,rollup 輸出檔案到目錄前呼叫 | vite 和 rollup 共享 |
closeBundle | 在 vite 本地服務關閉前,rollup 輸出檔案到目錄前呼叫 | vite 和 rollup 共享 |
在瞭解了 vite
和 rollup
的所有鉤子函式後,我們只需要按照呼叫順序,來看看 vite:vue
外掛在每個鉤子函式的呼叫期間都做了些什麼事情吧。
config
config(config) {
return {
define: {
__VUE_OPTIONS_API__: config.define?.__VUE_OPTIONS_API__ ?? true,
__VUE_PROD_DEVTOOLS__: config.define?.__VUE_PROD_DEVTOOLS__ ?? false
},
ssr: {
external: ['vue', '@vue/server-renderer']
}
}
}
vite:vue
外掛中的 config
做的事情比較簡單,首先是做了兩個全域性變數 __VUE_OPTIONS_API__
和 __VUE_PROD_DEVTOOLS__
的替換工作。然後又設定了要為 SSR 強制外部化的依賴。
configResolved
在 config
鉤子執行完成後,下一個呼叫的是 configResolved
鉤子。(如下)
configResolved(config) {
options = {
...options,
root: config.root,
sourceMap: config.command === 'build' ? !!config.build.sourcemap : true,
isProduction: config.isProduction
}
},
vite:vue
中的 configResolved
鉤子,讀取了 root
和 isProduction
配置,儲存在外掛內部的 options
屬性中,以便提供給後續的鉤子函式使用。
然後,判斷當前命令是否為 build
,如果是構建生產產物,則讀取 sourcemap
配置來判斷是否生成 sourceMap
,而本地開發服務始終會生成 sourceMap
以供除錯使用。
configureServer
在 configureServer
鉤子中,vite:vue
外掛只是將 server
儲存在內部 options
選項中,並無其他操作。(如下)
configureServer(server) {
options.devServer = server;
}
buildStart
在 buildStart
鉤子函式中,建立了一個 compiler
,用於後續對 vue
檔案的編譯工作。(如下)
buildStart() {
options.compiler = options.compiler || resolveCompiler(options.root)
}
該 complier
中內建了很多實用方法,這些方法負責按照規則對 vue
檔案進行庖丁解牛。
load
在執行完了上述幾個鉤子後,vite
本地開發服務就已經啟動了。
我們開啟本地服務的地址,對資源發起請求後,將會進入下一個鉤子函式。(如下圖)
開啟服務後,首先進入的是 load
鉤子,load
鉤子主要做的工作是返回 vue
檔案中被單獨解析出去的同名檔案。
vite
內部會將部分檔案內容解析到另一個檔案,然後通過在檔案載入路徑後面加上 ?vue
的 query
引數來解析該檔案。比如解析template
(模板)、script
(js 指令碼)、css
(style
模組)...(如下圖)
而這幾個模組(template
、script
、style
)都是由 complier.parse
解析而來(如下)
const { descriptor, errors } = compiler.parse(source, {
filename,
sourceMap
});
transform
在 load
返回了對應的程式碼片段後,進入到 transform
鉤子。
transform
主要做的事情有三件:
- 轉譯
vue
檔案 - 轉譯以
vue
檔案解析的template
模板 - 轉譯以
vue
檔案解析的style
樣式
簡單理解,這個鉤子對應的就是webpack
的loader
。
這裡,我們以一個 TodoList.vue
檔案為例,展開聊聊 transform
所做的檔案轉譯工作。
下面是 TodoList.vue
原始檔,它做了一個可供增刪改查的 TodoList
,你也可以通過 第二期文章 - Vite + Vue3 初體驗 —— Vue3 篇 瞭解它的詳細功能。
<script setup lang="ts">
import { DeleteOutlined, CheckOutlined, CheckCircleFilled, ToTopOutlined } from '@ant-design/icons-vue';
import { Input } from "ant-design-vue";
import { ref } from "vue";
import service from "@/service";
import { getUserKey } from '@/service/auth';
// 建立一個引用變數,用於繫結 Todo List 資料
const todoList = ref<{
id: string;
title: string;
is_completed: boolean;
is_top: boolean;
}[]>([]);
// 初始化 todo list
const getTodoList = async () => {
const reply = await service.get('/todo/get-todo-list', { params: { key: getUserKey() } });
todoList.value = reply.data.data;
}
getTodoList();
// 刪除、完成、置頂的邏輯都與 todoList 放在同一個地方,這樣對於邏輯關注點就更加聚焦了
const onDeleteItem = async (index: number) => {
const id = todoList.value[index].id;
await service.post('/todo/delete', { id });
todoList.value.splice(index, 1);
}
const onCompleteItem = async (index: number) => {
const id = todoList.value[index].id;
await service.post('/todo/complete', { id });
todoList.value[index].is_completed = true;
// 重新排序,將已經完成的專案往後排列
const todoItem = todoList.value.splice(index, 1);
todoList.value.push(todoItem[0]);
}
const onTopItem = async (index: number) => {
const id = todoList.value[index].id;
await service.post('/todo/top', { id });
todoList.value[index].is_top = true;
// 重新排序,將已經完成的專案往前排列
const todoItem = todoList.value.splice(index, 1);
todoList.value.unshift(todoItem[0]);
}
// 新增 Todo Item 的邏輯都放在一處
// 建立一個引用變數,用於繫結輸入框
const todoText = ref('');
const addTodoItem = () => {
// 新增一個 TodoItem,請求新增介面
const todoItem = {
key: getUserKey(),
title: todoText.value
}
return service.post('/todo/add', todoItem);
}
const onTodoInputEnter = async () => {
if (todoText.value === '') return;
await addTodoItem();
await getTodoList();
// 新增成功後,清空 todoText 的值
todoText.value = '';
}
</script>
<template>
<section class="todo-list-container">
<section class="todo-wrapper">
<!-- v-model:value 語法是 vue3 的新特性,代表元件內部進行雙向繫結是值 key 是 value -->
<Input v-model:value="todoText" @keyup.enter="onTodoInputEnter" class="todo-input" placeholder="請輸入待辦項" />
<section class="todo-list">
<section v-for="(item, index) in todoList"
class="todo-item"
:class="{'todo-completed': item.is_completed, 'todo-top': item.is_top}">
<span>{{item.title}}</span>
<div class="operator-list">
<CheckCircleFilled v-show="item.is_completed" />
<DeleteOutlined v-show="!item.is_completed" @click="onDeleteItem(index)" />
<ToTopOutlined v-show="!item.is_completed" @click="onTopItem(index)" />
<CheckOutlined v-show="!item.is_completed" @click="onCompleteItem(index)" />
</div>
</section>
</section>
</section>
</section>
</template>
<style scoped lang="less">
.todo-list-container {
display: flex;
justify-content: center;
width: 100vw;
min-height: 100vh;
box-sizing: border-box;
padding-top: 100px;
background: linear-gradient(rgba(219, 77, 109, .02) 60%, rgba(93, 190, 129, .05));
.todo-wrapper {
width: 60vw;
.todo-input {
width: 100%;
height: 50px;
font-size: 18px;
color: #F05E1C;
border: 2px solid rgba(255, 177, 27, 0.5);
border-radius: 5px;
}
.todo-input::placeholder {
color: #F05E1C;
opacity: .4;
}
.ant-input:hover, .ant-input:focus {
border-color: #FFB11B;
box-shadow: 0 0 0 2px rgb(255 177 27 / 20%);
}
.todo-list {
margin-top: 20px;
.todo-item {
box-sizing: border-box;
padding: 15px 10px;
cursor: pointer;
border-bottom: 2px solid rgba(255, 177, 27, 0.3);
color: #F05E1C;
margin-bottom: 5px;
font-size: 16px;
transition: all .5s;
display: flex;
justify-content: space-between;
align-items: center;
padding-right: 10px;
.operator-list {
display: flex;
justify-content: flex-start;
align-items: center;
:first-child {
margin-right: 10px;
}
}
}
.todo-top {
background: #F05E1C;
color: #fff;
border-radius: 5px;
}
.todo-completed {
color: rgba(199, 199, 199, 1);
border-bottom-color: rgba(199, 199, 199, .4);
transition: all .5s;
background: #fff;
}
.todo-item:hover {
box-shadow: 0 0 5px 8px rgb(255 177 27 / 20%);
border-bottom: 2px solid transparent;
}
.todo-completed:hover {
box-shadow: none;
border-bottom-color: rgba(199, 199, 199, .4);
}
}
}
}
</style>
進入到 transformMain
函式,可以發現 transformMain
內部主要做了幾件事情:
- 解構
vue
檔案的script
、template
、style
- 解析
vue
檔案中的script
程式碼; - 解析
vue
檔案中的template
程式碼; - 解析
vue
檔案中的style
程式碼; - 解析
vue
檔案中的自定義模組
程式碼; - 處理 HMR(模組熱過載)的邏輯;
- 處理
ssr
的邏輯; - 處理
sourcemap
的邏輯; - 處理
ts
的轉換,轉成成es
;
接下來,我們將深入原始碼,將每一項任務深入解析。
解構 script
、template
、style
vue
檔案中包含 script
、template
、style
三大部分,transformMain
內部先通過 createDescriptor
中的 compiler
將這三大塊分離解析出來,作為一個大物件,然後方便後面的解析。(如下圖)
在 compiler
中,會先使用 parse
方法,將原始碼 source
解析成 AST
樹。(如下圖)
在下圖中可以看出,解析後的 AST
樹有三個模組,主要就是 script
、template
、style
。
接下來,就是將各個模組的屬性、程式碼行數記錄起來,比如 style
標籤,就記錄了 lang: less
的資訊,以供後面的解析。
解析 Template
vue
檔案中的 template
寫了很多 vue
的語法糖,比如下面這行
<Input v-model:value="todoText" @keyup.enter="onTodoInputEnter" class="todo-input" placeholder="請輸入待辦項" />
像這種語法,瀏覽器是無法識別並將事件繫結到 vue
的內部函式中的,所以 vite
對這類標籤先做了一遍內部轉換,轉換成可執行的函式,再通過瀏覽器執行函式生成一套 虛擬 DOM
,最後再由 vue
內部的渲染引擎將 虛擬 DOM
渲染成 真實 DOM
。
現在我們就可以看看 vite
內部對 template
語法的轉譯過程,vite
內部是通過 genTemplateCode
函式來實現的。
在 genTemplateCode
內部,首先是將 template
模板語法解析成了 AST
語法樹。(如下圖)
然後再通過不同的轉譯函式,對對應的 AST 節點進行轉換。
下面我們以 Input
節點為例來簡單解釋一下轉譯的過程。
將這個步驟重複,直到整棵 template
樹都解析完成。
解析 script 標籤
下面,我們來看看對 script
標籤的解析部分,對應的內部函式是 genScriptCode
這個函式所做的事情主要是下面幾件事情:
- 解析
script
標籤中定義的變數; - 解析
script
標籤中定義的引入import
,後面將會轉換成相對路徑引入; - 將
script
標籤編譯成一個程式碼片段,該程式碼片段匯出_defineComponent
(元件)封裝的物件,內建setup
鉤子函式。
我們用圖來說明以上三個步驟。(如下圖)
解析 style 標籤
style
標籤解析的比較簡單,只是將程式碼解析成了一個 import
語句(如下)
import "/Users/Macxdouble/Desktop/ttt/vite-try/src/components/TodoList.vue?vue&type=style&index=0&scoped=true&lang.less"
隨後,根據該請求中 query
引數中的 type
和 lang
,由 vite:vue
外掛的 load
鉤子(上一個解析的鉤子)中的 transformStyle
函式來繼續處理樣式檔案的編譯。這部分我就不做展開了,感興趣的同學可以自行閱讀程式碼。
編譯 ts
到 es
在 script
、template
、style
部分的程式碼都解析完畢後,接下來還做了下面幾個處理:
- 解析
vue
檔案中的自定義模組
程式碼; - 處理 HMR(模組熱過載)的邏輯;
- 處理
ssr
的邏輯; - 處理
sourcemap
的邏輯; - 處理
ts
的轉換,轉成成es
;
由於篇幅原因,這裡只對 ts
到 es
的轉換做個簡單介紹,這一步主要是在內部通過 esbuild
完成了 ts
到 es
的轉換,我們可以看到這個工具有多快。(如下圖)
輸出程式碼
在 ts
也轉譯成 es
後,vite:vue
將轉換成了 es
的 script
、template
、style
程式碼合併在一起,然後通過 transform
輸出,最終輸出為一個 es
模組,被頁面作為 js
檔案載入。(如下圖)
handleHotUpdate
最後,我們來看看對於檔案模組熱過載的處理,也就是 handleHotUpdate
鉤子。
我們在啟動專案後,在 App.vue
檔案的 setup
中加入一行程式碼。
console.log('Test handleHotUpdate');
在程式碼加入並且儲存後,被 vite
內部的 watcher
捕獲到變更,然後觸發了 handleHotUpdate
鉤子,將修改的檔案傳入。
vite:vue
內部會使用 compiler.parse
函式對 App.vue
檔案進行解析,將 script
、template
、style
標籤解析出來。(也就是上面解析的編譯步驟)(如下圖)。
然後,handleHotUpdate
函式內部會檢測發生變更的內容,將變更的部分新增到 affectedModules
陣列中。(如下圖)
然後,handleHotUpdate
將 affectedModules
返回,交由 vite
內部處理。
最後,vite
內部會判斷當前變更檔案是否需要重新載入頁面,如果不需要重新載入的話,則會傳送一個 update
訊息給客戶端的 ws
,通知客戶端重新載入對應的資源並執行。(如下圖)
好了,這樣一來,模組熱過載的內容我們也清楚了。
小結
本期對 @vitejs/plugin-vue
的解析就到這裡結束了。
可以看出,vite
內部結合了 rollup
預設了外掛的多個生命週期鉤子,在編譯的各個階段進行呼叫,從而達到 webpack
的 loader
+ plugin
的組合效果。
而 vite/rollup
直接使用 plugin
就替代了 webpack
的 loader
+ plugin
功能,可能也是為了簡化概念,整合功能,讓外掛的工作更簡單,讓社群的外掛開發者也能更好的參與貢獻。
vite
的快不僅僅是因為執行時不編譯原生 es
模組,還有在執行時還利用了 esbuild
這類輕而快的編譯庫來編譯 ts
,從而使得整個本地開發時變得非常地輕快。
下一章,我們將對 vite
外掛進行實戰練習:實現一個 vite
外掛,它的功能是通過指定標籤就能載入本地 md
檔案。
最後一件事
如果您已經看到這裡了,希望您還是點個贊再走吧~
您的點贊是對作者的最大鼓勵,也可以讓更多人看到本篇文章!
如果覺得本文對您有幫助,請幫忙在 github 上點亮 star
鼓勵一下吧!