Vite 原始碼解讀系列(圖文結合) —— 外掛篇

曬兜斯發表於2022-03-05

哈嘍,很高興你能點開這篇部落格,本部落格是針對 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 專屬。(如下圖)

image

在開始閱讀原始碼之前,我們需要先了解一下 viterollup 中每一個鉤子函式的呼叫時機和作用。

欄位說明所屬
name外掛名稱viterollup 共享
handleHotUpdate執行自定義 HMR(模組熱替換)更新處理vite 獨享
config在解析 Vite 配置前呼叫。可以自定義配置,會與 vite 基礎配置進行合併vite 獨享
configResolved在解析 Vite 配置後呼叫。可以讀取 vite 的配置,進行一些操作vite 獨享
configureServer是用於配置開發伺服器的鉤子。最常見的用例是在內部 connect 應用程式中新增自定義中介軟體。vite 獨享
transformIndexHtml轉換 index.html 的專用鉤子。vite 獨享
options在收集 rollup 配置前,vite (本地)服務啟動時呼叫,可以和 rollup 配置進行合併viterollup 共享
buildStartrollup 構建中,vite (本地)服務啟動時呼叫,在這個函式中可以訪問 rollup 的配置viterollup 共享
resolveId在解析模組時呼叫,可以返回一個特殊的 resolveId 來指定某個 import 語句載入特定的模組viterollup 共享
load在解析模組時呼叫,可以返回程式碼塊來指定某個 import 語句載入特定的模組viterollup 共享
transform在解析模組時呼叫,將原始碼進行轉換,輸出轉換後的結果,類似於 webpackloaderviterollup 共享
buildEndvite 本地服務關閉前,rollup 輸出檔案到目錄前呼叫viterollup 共享
closeBundlevite 本地服務關閉前,rollup 輸出檔案到目錄前呼叫viterollup 共享

在瞭解了 viterollup 的所有鉤子函式後,我們只需要按照呼叫順序,來看看 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 鉤子,讀取了 rootisProduction 配置,儲存在外掛內部的 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)
}

image

complier 中內建了很多實用方法,這些方法負責按照規則對 vue 檔案進行庖丁解牛。

load

在執行完了上述幾個鉤子後,vite 本地開發服務就已經啟動了。

我們開啟本地服務的地址,對資源發起請求後,將會進入下一個鉤子函式。(如下圖)

image

開啟服務後,首先進入的是 load 鉤子,load 鉤子主要做的工作是返回 vue 檔案中被單獨解析出去的同名檔案。

vite 內部會將部分檔案內容解析到另一個檔案,然後通過在檔案載入路徑後面加上 ?vuequery 引數來解析該檔案。比如解析template(模板)、script(js 指令碼)、cssstyle 模組)...(如下圖)

image

而這幾個模組(templatescriptstyle)都是由 complier.parse 解析而來(如下)

const { descriptor, errors } = compiler.parse(source, {
  filename,
  sourceMap
});

transform

load 返回了對應的程式碼片段後,進入到 transform 鉤子。

transform 主要做的事情有三件:

  • 轉譯 vue 檔案
  • 轉譯以 vue 檔案解析的 template 模板
  • 轉譯以 vue 檔案解析的 style 樣式
簡單理解,這個鉤子對應的就是 webpackloader

image

這裡,我們以一個 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 檔案的 scripttemplatestyle
  • 解析 vue 檔案中的 script 程式碼;
  • 解析 vue 檔案中的 template 程式碼;
  • 解析 vue 檔案中的 style 程式碼;
  • 解析 vue 檔案中的 自定義模組 程式碼;
  • 處理 HMR(模組熱過載)的邏輯;
  • 處理 ssr 的邏輯;
  • 處理 sourcemap 的邏輯;
  • 處理 ts 的轉換,轉成成 es

接下來,我們將深入原始碼,將每一項任務深入解析。

解構 scripttemplatestyle

vue 檔案中包含 scripttemplatestyle 三大部分,transformMain 內部先通過 createDescriptor 中的 compiler 將這三大塊分離解析出來,作為一個大物件,然後方便後面的解析。(如下圖)

image

compiler 中,會先使用 parse 方法,將原始碼 source 解析成 AST 樹。(如下圖)

image

在下圖中可以看出,解析後的 AST 樹有三個模組,主要就是 scripttemplatestyle

image

接下來,就是將各個模組的屬性、程式碼行數記錄起來,比如 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 語法樹。(如下圖)

image

然後再通過不同的轉譯函式,對對應的 AST 節點進行轉換。

image

下面我們以 Input 節點為例來簡單解釋一下轉譯的過程。

image

將這個步驟重複,直到整棵 template 樹都解析完成。

解析 script 標籤

下面,我們來看看對 script 標籤的解析部分,對應的內部函式是 genScriptCode

這個函式所做的事情主要是下面幾件事情:

  1. 解析 script 標籤中定義的變數;
  2. 解析 script 標籤中定義的引入 import,後面將會轉換成相對路徑引入;
  3. script 標籤編譯成一個程式碼片段,該程式碼片段匯出 _defineComponent(元件)封裝的物件,內建 setup 鉤子函式。

我們用圖來說明以上三個步驟。(如下圖)

image

解析 style 標籤

style 標籤解析的比較簡單,只是將程式碼解析成了一個 import 語句(如下)

import "/Users/Macxdouble/Desktop/ttt/vite-try/src/components/TodoList.vue?vue&type=style&index=0&scoped=true&lang.less"

隨後,根據該請求中 query 引數中的 typelang,由 vite:vue 外掛的 load 鉤子(上一個解析的鉤子)中的 transformStyle 函式來繼續處理樣式檔案的編譯。這部分我就不做展開了,感興趣的同學可以自行閱讀程式碼。

編譯 tses

scripttemplatestyle 部分的程式碼都解析完畢後,接下來還做了下面幾個處理:

  • 解析 vue 檔案中的 自定義模組 程式碼;
  • 處理 HMR(模組熱過載)的邏輯;
  • 處理 ssr 的邏輯;
  • 處理 sourcemap 的邏輯;
  • 處理 ts 的轉換,轉成成 es

由於篇幅原因,這裡只對 tses 的轉換做個簡單介紹,這一步主要是在內部通過 esbuild 完成了 tses 的轉換,我們可以看到這個工具有多快。(如下圖)

image

輸出程式碼

ts 也轉譯成 es 後,vite:vue 將轉換成了 esscripttemplatestyle 程式碼合併在一起,然後通過 transform 輸出,最終輸出為一個 es 模組,被頁面作為 js 檔案載入。(如下圖)

image

handleHotUpdate

最後,我們來看看對於檔案模組熱過載的處理,也就是 handleHotUpdate 鉤子。

我們在啟動專案後,在 App.vue 檔案的 setup 中加入一行程式碼。

console.log('Test handleHotUpdate');

在程式碼加入並且儲存後,被 vite 內部的 watcher 捕獲到變更,然後觸發了 handleHotUpdate 鉤子,將修改的檔案傳入。

vite:vue 內部會使用 compiler.parse 函式對 App.vue 檔案進行解析,將 scripttemplatestyle 標籤解析出來。(也就是上面解析的編譯步驟)(如下圖)。

image

然後,handleHotUpdate 函式內部會檢測發生變更的內容,將變更的部分新增到 affectedModules 陣列中。(如下圖)

image

然後,handleHotUpdateaffectedModules 返回,交由 vite 內部處理。

最後,vite 內部會判斷當前變更檔案是否需要重新載入頁面,如果不需要重新載入的話,則會傳送一個 update 訊息給客戶端的 ws,通知客戶端重新載入對應的資源並執行。(如下圖)

image

好了,這樣一來,模組熱過載的內容我們也清楚了。

小結

本期對 @vitejs/plugin-vue 的解析就到這裡結束了。

可以看出,vite 內部結合了 rollup 預設了外掛的多個生命週期鉤子,在編譯的各個階段進行呼叫,從而達到 webpackloader + plugin 的組合效果。

vite/rollup 直接使用 plugin 就替代了 webpackloader + plugin 功能,可能也是為了簡化概念,整合功能,讓外掛的工作更簡單,讓社群的外掛開發者也能更好的參與貢獻。

vite 的快不僅僅是因為執行時不編譯原生 es 模組,還有在執行時還利用了 esbuild 這類輕而快的編譯庫來編譯 ts,從而使得整個本地開發時變得非常地輕快。

下一章,我們將對 vite 外掛進行實戰練習:實現一個 vite 外掛,它的功能是通過指定標籤就能載入本地 md 檔案。

最後一件事

如果您已經看到這裡了,希望您還是點個贊再走吧~

您的點贊是對作者的最大鼓勵,也可以讓更多人看到本篇文章!

如果覺得本文對您有幫助,請幫忙在 github 上點亮 star 鼓勵一下吧!

相關文章