深入淺出 Vite5 中依賴預構建

引言

大多數同學提到 Vite ,會下意識的反應出 “快”、“noBundle” 等關鍵詞。

那麼,爲什麼 Vite 相較於 Webpack、Rollup 之類的會更加快,亦或是大多數同學認爲 Vite 是 "noBundle" 又是否正確呢?

接下來,這篇文章和大家一起來深入淺出 Vite 中的核心的 “預構建” 過程。

文章中 vite 版本爲最新的 5.0.0-beta.18

預構建

概念

既然提到預構建,那麼預構建究竟是一個什麼樣的概念呢?

熟悉 Vite 的朋友可能清楚,Vite 在開發環境中存在一個優化**「依賴預構建」**(Dependency Pre-Bundling)的概念。

簡單來說,所謂依賴預構建指的是在 DevServer 啓動之前,Vite 會掃描使用到的依賴從而進行構建,之後在代碼中每次導入 (import) 時會動態地加載構建過的依賴這一過程,

也許大多數同學對於 Vite 的認知更多是 No Bundle,但上述的依賴預構建過程的確像是 Bundle 的過程。

簡單來說,Vite 在一開始將應用中的模塊區分爲 「依賴」 和 「源碼」 兩類:

我們在文章中接下來要聊到的 「依賴預構建」,其實更多是針對於第三方模塊的預構建過程。

什麼是預構建

我們在使用 vite 啓動項目時,細心的同學會發現項目 node_modules 目錄下會額外增加一個 node_modules/.vite/deps 的目錄:

這個目錄就是 vite 在開發環境下預編譯的產物。

項目中的 「依賴部分」ahooksantdreact 等部分會被預編譯成爲一個一個 .js 文件。

同時,.vite/deps 目錄下還會存在一個 _metadata.json

_metadata.json 中存在一些特殊屬性:

簡單來說 vite 在預編譯時會對於項目中使用到的第三方依賴進行依賴預構建,將構建後的產物存放在 node_modules/.vite/deps 目錄中,比如 ahooks.jsreact.js 等。

同時,預編譯階段也會生成一個 _metadata.json 的文件用來保存預編譯階段生成文件的映射關係 (optimized 字段),方便在開發環境運行時重寫依賴路徑。

上邊的概念大家也不需要過於在意,現在不清楚也沒關係。我們只需要清楚,依賴預構建的過程簡單來說就是生成 node_modules/deps 文件即可。

爲什麼需要預構建

那麼爲什麼需要預構建呢?

首先第一點,我們都清楚 Vite 是基於瀏覽器 Esmodule 進行模塊加載的方式。

那麼,對於一些非 ESM 模塊規範的第三方庫,比如 react。在開發階段,我們需要藉助預構建的過程將這部分非 esm 模塊的依賴模塊轉化爲 esm 模塊。從而在瀏覽器中進行 import 這部分模塊時也可以正確識別該模塊語法。

另外一個方面,同樣是由於 Vite 是基於 Esmodule 這一特性。在瀏覽器中每一次 import 都會發送一次請求,部分第三方依賴包中可能會存在許多個文件的拆分從而導致發起多次 import 請求。

比如 lodash-es 中存在超過 600 個內置模塊,當我們執行 import { debounce } from 'lodash' 時,如果不進行預構建瀏覽器會同時發出 600 多個 HTTP 請求,這無疑會讓頁面加載變得明顯緩慢。

正式通過依賴預構建,將 lodash-es 預構建成爲單個模塊後僅需要一個 HTTP 請求就可以解決上述的問題。

基於上述兩點,Vite 中正是爲了**「模塊兼容性」**以及**「性能」**這兩方面大的原因,所以需要進行依賴預構建。

思路導圖

那麼,預構建究竟是怎麼樣的過程?我們先來看一幅關於依賴預構建的思維導圖

在開始後續的內容之前,我們先來簡單和大家聊聊這張圖中描述的各個關鍵步驟。

通常情況下,單個項目我們僅會使用單個 index.html 作爲入口文件。

簡單來說,上述的 5 個步驟就是 Vite 依賴預構建的過程。

有些同學可能會好奇,預構建生成這樣的文件怎麼使用呢?

這個問題其實和這篇文章關係並不是很大,本篇文章中着重點更多是和讓大家瞭解預構建是在做什麼以及是怎麼實現的過程。

簡單來說,預構建對於第三方依賴生成 node_modules/.vite/deps 資源後。在開發環境下 vite 會 “攔截” 所有的 ESM 請求,將源碼中對於第三方依賴的請求地址重寫爲我們預構建之後的資源產物,比如我們在源碼中編寫的 antd 導入:

最終在開發環境下 Vite 會將對於第三方模塊的導入路徑重新爲:

其實 import { Button } from '/node_modules/.vite/deps/antd.js?v=09d70271' 這個地址正是我們將 antd 在預構建階段通過 Esbuild 在 /node_modules/.vite/deps 生成的產物。

至於 Vite 在開發環境下是如何重寫這部分第三方導入的地址這件事,我們會在下一篇關於實現 Vite 的文章會和大家詳細講解。

簡單實現

上邊的過程我們對於 Vite 中的預構建進行了簡單的流程梳理。

經過上述的章節我們瞭解了預構建的概念,以及預構建究竟的大致步驟。

接下來,我會用最簡單的代碼來和大家一起實現 Vite 中預構建這一過程。

因爲源碼的分支 case 比較繁瑣,容易擾亂我們的思路。所以,我們先實現一個精簡版的 Vite 開始入手鞏固大家的思路,最後我們在循序漸進一步一步閱讀源碼。

搭建開發環境

工欲善其事,必先利其器。在着手開發之前,讓我們先花費幾分鐘來稍微梳理一下開發目錄。

這裏,我創建了一個 vite 的簡單目錄結構:

.
├── README.md              Reamdme 說明文件
├── bin                    
│   └── vite               環境變量腳本文件        
├── package.json           
└── src                    源碼目錄
    ├── config.js          讀取配置文件
    └── server             服務文件目錄
        ├── index.js       服務入口文件
        └── middleware     中間件目錄文件夾

創建了一個簡單的目錄文件,同時在 bin/vitepackage.json 中的 bin 字段進行關聯:

#!/usr/bin/env node

console.log('hello custom-vite!');
{
  "name""custom-vite",
  // ...
  "bin"{
    "custom-vite""./bin/vite"
  },
  // ...
}

關於 bin 字段的作用這裏我就不再贅述了,此時當我們在本地運行 npm link 後,在控制檯執行 custom-vite 就會輸出 hello custom-vite!:

編寫開發服務器

接下來,讓我們按照思維導圖的順序一步一步來。

在運行 vite 命令後需要啓動一個開發服務器用來承載應用項目(啓動目錄下)的 index.html 文件作爲入口文件,那麼我們就從編譯一個開發服務器開始。

首先,讓我們先來修改 Vite 命令的入口文件 /bin/vite:

#!/usr/bin/env node
import { createServer } from '../src/server';

(async function () {
  const server = await createServer();
  server.listen('9999', () => {
    console.log('start server');
  });
})();

上邊的 /bin/vite 文件中,我們從 /src/server 中引入了一個 createServer 創建開發服務器的方法。

隨後,利用了一個自執行的函數調用該 createServer 方法,同時調用 server.listen 方法將開發服務器啓動到 9999 端口。

// /src/server/index.js
import connect from 'connect';
import http from 'node:http';
import staticMiddleware from './middleware/staticMiddleware.js';
import resolveConfig from '../config.js';

/**
 * 創建開發服務器
 */
async function createServer() {
  const app = connect(); // 創建 connect 實例
  const config = await resolveConfig(); // 模擬配置清單 (類似於 vite.config.js)
  app.use(staticMiddleware(config)); // 使用靜態資源中間件

  const server = {
    async listen(port, callback) {
      // 啓動服務
      http.createServer(app).listen(port, callback);
    }
  };
  return server;
}

export { createServer }

我們 /src/server/index.js 中定義了一個創建根服務器的方法: createServer

createServer 中首先我們通過 connect 模塊配置 nodejs http 模塊創建了一個支持中間件系統的應用服務。

connectnodejs http 模塊提供了中間件的擴展支持,Express 4.0 之前的中間件模塊就是基於 connect 來實現的。

之後,我們在 createServer 方法中通過 resolveConfig 方法來模擬讀取一些必要的配置屬性(該方法類似於從應用本身獲取 vite.config.js 中的配置):

// src/utils.js
/**
 * windows 下路徑適配(將 windows 下路徑的 // 變爲 /)
 * @param {*} path
 * @returns
 */
function normalizePath(path) {
  return path.replace(/\\/g, '/');
}

export { normalizePath };


// /src/config.js
import { normalizePath } from './utils.js';
/**
 * 加載 vite 配置文件
 * (模擬)
 */
async function resolveConfig() {
  const config = {
    root: normalizePath(process.cwd()) // 僅定義一個項目根目錄的返回
  };
  return config;
}

export default resolveConfig;

可以看到在 resolveConfig 中我們模擬了一個 config 對象進行返回,此時 config 對象是一個固定的路徑:爲調用 custom-vite 命令的 pwd 路徑。

關於 root 配置項的作用,可以參考 Vite Doc Config,我們接下來會用該字段匹配的路徑來尋找項目根入口文件 (index.html) 的所在地址。

初始化配置文件後,我們再次調用 app.use(staticMiddleware(config)); 爲服務使用了靜態資源目錄的中間件,保證使用 custom-vite 的目錄下的靜態資源在服務上的可訪問性。

import serveStatic from 'serve-static';

function staticMiddleware({ root }) {
  return serveStatic(root);
}

export default staticMiddleware;

上邊我們使用了 serve-static 作爲中間件來提供創建服務的靜態資源功能。

此時,當我們在任意項目中使用 custom-vite 命令時 terminal 中打印出:

同時,我們在瀏覽器中輸入 localhost:9999 即可訪問到我們根據使用到的項目創建的服務。

這一步,我們通過自己編寫的 custom-vite 已經擁有一鍵啓動開發環境的功能。

尋找 / 解析 HTML 文件

在調用 custom-vite 命令已經可以啓動一個簡單的開發服務器後,接下來我們就要開始爲啓動的開發服務器來填充對應的功能了。

瞭解過 Vite 的朋友都清楚,Vite 中的入口文件和其他溝通工具不同的是:vite 中是以 html 文件作爲入口文件的。比如,我們新建一個簡單的項目:

.
├── index.html
├── main.js
├── ./module.js
└── package.json
// index.html
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta >
  <title>Document</title>
</head>

<body>
  Hello vite use
  <script type="module" src="/main.js"></script>
</body>

</html>
{
  "name": "custom-vite-use",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "vite",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "react": "^18.2.0",
    "vite": "^5.0.4"
  }
}
const a = '1';

export { a };
import react from 'react';
import { a } from './module.js';

console.log(a);
console.log(react, 'react');

我已在該項目中安裝了 reactvite,我們先來看看對於上邊這個簡單的項目原始的 vite 表現如何。

此時我們在該項目目錄下運行 npm run dev 命令,等待服務啓動後訪問 localhost:5173

頁面上會展示 index.html 的內容:

同時,瀏覽器控制檯中會打印:

當然,也會打印 1。因爲 module.js 是我在後續爲了滿足遞歸流程補上來的模塊所以這裏的圖我就不補充了,大家理解即可~

同時我們觀察瀏覽器 network 請求:

network 中的請求順序分別爲 index.html => main.js => react.js,這裏我們先專注預構建過程忽略其他的請求以及 react.js  後邊的查詢參數。

當我們打開 main.js 查看 sourceCode 時,會發現這個文件中關於 react 的引入已經完全更換了一個路徑:

很神奇對比,「前邊我們說過 vite 在啓動開發服務器時對於第三方依賴會進行預構建的過程」。這裏,/node_modules/.vite/deps/react.js 正是啓動開發服務時 react 的預構建產物。

我們來打開源碼目錄查看下:


一切都和我們上述提到過的過程看上去是那麼的相似對吧。

啓動開發服務器時,會首先根據 index.html 中的腳本分析模塊依賴,將所有項目中引入的第三方依賴(這裏爲 react) 進行預構建。

「將構建後的產物存儲在 .vite/deps 目錄中,同時將映射關係保存在 .vite/deps/_metadata.json 中,其中 optimized 對象中的 react 表示原始依賴的入口文件而 file 則表示經過預構建後生成的產物(兩者皆爲相對路徑)。」

之後,簡單來說我們只要在開發環境下判斷如果請求的文件名命中 optimized 對象的 key 時(這裏爲 react)則直接預構建過程中生成的文件 (file 字段對應的文件路徑即可)。

接下來,我們就嘗試在我們自己的 custom-vite 中來實現這一步驟。

首先,讓我們從尋找 index.html 中出發:

// /src/config.js
import { normalizePath } from './utils.js';
import path from 'path';

/**
 * 加載 vite 配置文件
 * (模擬)
 */
async function resolveConfig() {
  const config = {
    root: normalizePath(process.cwd()),
    entryPoints: [path.resolve('index.html')] // 增加一個 entryPoints 文件
  };
  return config;
}

export default resolveConfig;

首先,我們來修改下之前的 /src/config.js 爲模擬的配置文件增加一個 entryPoints 入口文件,該文件表示 custom-vite 進行構建時的入口文件,即項目中的 index.html 文件。

// /src/server/index.js
import connect from 'connect';
import http from 'node:http';
import staticMiddleware from './middleware/staticMiddleware.js';
import resolveConfig from '../config.js';
import { createOptimizeDepsRun } from '../optimizer/index.js';

/**
 * 創建開發服務器
 */
async function createServer() {
  const app = connect();
  const config = await resolveConfig();
  app.use(staticMiddleware(config));

  const server = {
    async listen(port, callback) {
      // 啓動服務之前進行預構建
      await runOptimize(config);

      http.createServer(app).listen(port, callback);
    }
  };
  return server;
}

/**
 * 預構建
 * @param {*} config
 */
async function runOptimize(config) {
  await createOptimizeDepsRun(config);
}

export { createServer };

上邊我們對於 /src/server/index.jscreateServer 方法進行了修改,在 listen 啓動服務之前增加了 runOptimize 方法的調用。

所謂 runOptimize 方法正是在啓動服務之前的預構建函數。可以看到在 runOptimize 中遞歸調用了一個 createOptimizeDepsRun 方法。

接下來,我們要實現這個 createOptimizeDepsRun 方法。這個方法的核心思路正是 「我們希望藉助 Esbuild 在啓動開發服務器前對於整個項目進行掃描,尋找出項目中所有的第三方依賴進行預構建。」

讓我們新建一個 /src/optimizer/index.js 文件:

import { scanImports } from './scan.js';

/**
 * 分析項目中的第三方依賴
 * @param {*} config
 */
async function createOptimizeDepsRun(config) {
   // 通過 scanImports 方法尋找項目中的所有需要預構建的模塊
  const deps = await scanImports(config);
  console.log(deps, 'deps');
}

export { createOptimizeDepsRun };

// /src/optimizer/scan.js
import { build } from 'esbuild';
import { esbuildScanPlugin } from './scanPlugin.js';

/**
 * 分析項目中的 Import
 * @param {*} config
 */
async function scanImports(config) {
  // 保存掃描到的依賴(我們暫時還未用到)
  const desImports = {};
  // 創建 Esbuild 掃描插件(這一步是核心)
  const scanPlugin = await esbuildScanPlugin();
  // 藉助 EsBuild 進行依賴預構建
  await build({
    absWorkingDir: config.root, // esbuild 當前工作目錄
    entryPoints: config.entryPoints, // 入口文件
    bundle: true, // 是否需要打包第三方依賴,默認 Esbuild 並不會,這裏我們聲明爲 true 表示需要
    format: 'esm', // 打包後的格式爲 esm
    write: false, // 不需要將打包的結果寫入硬盤中
    plugins: [scanPlugin] // 自定義的 scan 插件
  });
  // 之後的內容我們稍微在講,大家先專注於上述的邏輯
}

export { scanImports };

可以看到在 /src/optimizer/scan.jsscanImports 方法最後調用了 esbuild build 的 build 方法進行構建。

正是在這一部分構建中,我們使用了自己定義的 scanPlugin Esbuild Plugin 進行掃描項目依賴,那麼 esbuildScanPlugin 又是如何實現的呢?

// /src/optimizer/scanPlugin.js
import nodePath from 'path';
import fs from 'fs-extra';
const htmlTypesRe = /(\.html)$/;

const scriptModuleRe = /<script\s+type="module"\s+src\="(.+?)">/;

function esbuildScanPlugin() {
  return {
    name: 'ScanPlugin',
    setup(build) {
      // 引入時處理 HTML 入口文件
      build.onResolve({ filter: htmlTypesRe }, async ({ path, importer }) => {
        // 將傳入的路徑轉化爲絕對路徑 這裏簡單先寫成 path.resolve 方法
        const resolved = await nodePath.resolve(path);
        if (resolved) {
          return {
            path: resolved?.id || resolved,
            namespace: 'html'
          };
        }
      });

      // 當加載命名空間爲 html 的文件時
      build.onLoad({ filter: htmlTypesRe, namespace: 'html' }, async ({ path }) => {
        // 將 HTML 文件轉化爲 js 入口文件
        const htmlContent = fs.readFileSync(path, 'utf-8');
        console.log(htmlContent, 'htmlContent'); // htmlContent 爲讀取的 html 字符串
        const [, src] = htmlContent.match(scriptModuleRe);
        console.log('匹配到的 src 內容', src); // 獲取匹配到的 src 路徑:/main.js
        const jsContent = `import ${JSON.stringify(src)}`;
        return {
          contents: jsContent,
          loader: 'js'
        };
      });
    }
  };
}

export { esbuildScanPlugin };

簡單來說,Esbuild 在進行構建時會對每一次 import 匹配插件的 build.onResolve 鉤子,匹配的規則核心爲兩個參數,分別爲:

不熟悉 Esbuild 相關配置和 Plugin 開發的同學可以優先移步 Esbuild 官網手冊進行簡單的查閱。

上述的 scanPlugin 的核心思路爲:

此時,由於 filter 的正則匹配爲後綴爲 .html,並不存在 namespace(默認爲 file)。則此時,index.html 會進入 ScanPluginonResolve 鉤子中。

build.onResolve 中,我們先將傳入的 path 轉化爲磁盤上的絕對路徑,將 html 的絕對路徑進行返回,同時修改入口 html 的 namespace 爲自定義的 html

需要注意的是如果同一個 import (導入)如果存在多個 onResolve 的話,會按照代碼編寫的順序進行順序匹配,「如果某一個 onResolve 存在返回值,那麼此時就不會往下繼續執行其他 onResolve 而是會進行到下一個階段 (onLoad)」,Esbuild 中其他 hook 也同理。

onLoad 鉤子中我們的 filter 規則同樣爲 htmlTypesRe, 同時增加了匹配 namespacehtml 的導入。

此時,我們在上一個 onResove 返回的 namspacehtml 的入口文件會進行該 onLoad 鉤子。

build.onLoad 該鉤子的主要作用加載對應模塊內容,如果 onResolve 中返回 contents 內容,則 Esbuild 會將返回的 contents 作爲內容進行後續解析(並不會對該模塊進行默認加載行爲解析),否則默認會爲 namespacefile 的文件進行 IO 讀取文件內容。

我們在 build.onlod 鉤子中,首先根據傳入的 path 讀取入口文件的 html 字符串內容獲得 htmlContent

之後,我們根據正則對於 htmlContent 進行了截取,獲取 <script type="module" src="/main.js />" 中引入的 js 資源 /main.js

「此時,雖然我們的入口文件爲 html 文件,但是我們通過 EsbuildPlugin 的方式從 html 入口文件中截取到了需要引入的 js 文件。」

之後,我們拼裝了一個 import "/main.js"jsContentonLoad 鉤子函數中進行返回,同時聲明該資源類型爲 js

簡單來說 Esbuild 中內置部分文件類型,我們在 pluginonLoad 鉤子中通過返回的 loader 關鍵字來告訴 Esbuild 接下來使用哪種方式來識別這些文件。

此時,Esbuil 會對於返回的 import "/main.js" 當作 JavaScript 文件進行遞歸處理,這樣也就達成了我們**「解析 HTML 文件」**的目的。

我們來回過頭稍微總結下,之所以 Vite 中可以將 HTML 文件作爲入口文件。

其實正是藉助了 Esbuild 插件的方式,在啓動項目時利用 Esbuild 使用 HTML 作爲入口文件之後利用 Plugin 截取 HTML 文件中的 script 腳本地址返回,從而尋找到了項目真正的入口 js 資源進行遞歸分析。

遞歸解析 js/ts 文件

上邊的章節,我們在 ScanPlugin 中分別編寫了 onResolve 以及 onLoad 鉤子來分析入口 html 文件。

其實,ScanPlugin 的作用並不僅僅如此。這部分,我們會繼續完善 ScanPlugin 的功能。

我們已經可以通過 HTML 文件尋找到引入的 /main.js 了,那麼接下來自然我們需要對 js 文件進行遞歸分析 「尋找項目中需要被依賴預構建的所有模塊。」

遞歸尋找需要被預構建的模塊的思路同樣也是通過 Esbuild 中的 Plugin 機制來實現,簡單來說我們會根據上一步轉化得到的 import "/main.js" 導入來進行遞歸分析。

對於 /main.js 的導入語句會分爲以下兩種情況分別進行不同的處理:

比如 /main.js 中存在 import react from 'react',此時首先我們會通過 Esbuild 忽略進入該模塊的掃描同時我們也會記錄代碼中依賴的該模塊相關信息。

標記爲 external 後,esbuild 會認爲該模塊是一個外部依賴不需要被打包,所以就不會進入該模塊進行任何掃描,換句話到碰到第三方模塊時並不會進入該模塊進行依賴分析。

解析來我們首先來一步一步來晚上上邊的代碼:

// src/optimizer/scan.js
/**
 * 分析項目中的 Import
 * @param {*} config
 */
async function scanImports(config) {
  // 保存依賴
  const depImports = {};
  // 創建 Esbuild 掃描插件
  const scanPlugin = await esbuildScanPlugin(config, depImports);
  // 藉助 EsBuild 進行依賴預構建
  await build({
    absWorkingDir: config.root,
    entryPoints: config.entryPoints,
    bundle: true,
    format: 'esm',
    write: false,
    plugins: [scanPlugin]
  });
  return depImports;
}

首先,我們先爲 scanImports 方法增加一個 depImports 的返回值。

之後,我們繼續來完善 esbuildScanPlugin 方法:

import fs from 'fs-extra';
import { createPluginContainer } from './pluginContainer.js';
import resolvePlugin from '../plugins/resolve.js';
const htmlTypesRe = /(\.html)$/;

const scriptModuleRe = /<script\s+type="module"\s+src\="(.+?)">/;

async function esbuildScanPlugin(config, desImports) {
  // 1. Vite 插件容器系統
  const container = await createPluginContainer({
    plugins: [resolvePlugin({ root: config.root })],
    root: config.root
  });

  const resolveId = async (path, importer) => {
    return await container.resolveId(path, importer);
  };

  return {
    name: 'ScanPlugin',
    setup(build) {
      // 引入時處理 HTML 入口文件
      build.onResolve({ filter: htmlTypesRe }, async ({ path, importer }) => {
        // 將傳入的路徑轉化爲絕對路徑
        const resolved = await resolveId(path, importer);
        if (resolved) {
          return {
            path: resolved?.id || resolved,
            namespace: 'html'
          };
        }
      });

      // 2. 額外增加一個 onResolve 方法來處理其他模塊(非html,比如 js 引入)
      build.onResolve({ filter: /.*/ }, async ({ path, importer }) => {
        const resolved = await resolveId(path, importer);
        if (resolved) {
          const id = resolved.id || resolved;
          if (id.includes('node_modules')) {
            desImports[path] = id;
            return {
              path: id,
              external: true
            };
          }
          return {
            path: id
          };
        }
      });

      // 當加載命名空間爲 html 的文件時
      build.onLoad(
        { filter: htmlTypesRe, namespace: 'html' },
        async ({ path }) => {
          // 將 HTML 文件轉化爲 js 入口文件
          const htmlContent = fs.readFileSync(path, 'utf-8');
          const [, src] = htmlContent.match(scriptModuleRe);
          const jsContent = `import ${JSON.stringify(src)}`;
          return {
            contents: jsContent,
            loader: 'js'
          };
        }
      );
    }
  };
}

export { esbuildScanPlugin };

esbuildScanPlugin 方法新增了 createPluginContainerresolvePlugin 兩個方法的引入:

// src/optimizer/pluginContainer.js
import { normalizePath } from '../utils.js';

/**
 * 創建 Vite 插件容器
 * Vite 中正是自己實現了一套所謂的插件系統,可以完美的在 Vite 中使用 RollupPlugin。
 * 簡單來說,插件容器更多像是實現了一個所謂的 Adaptor,這也就是爲什麼 VitePlugin 和 RollupPlugin 可以互相兼容的原因
 * @param plugin 插件數組
 * @param root 項目根目錄
 */
async function createPluginContainer({ plugins }) {
  const container = {
    /**
     * ResolveId 插件容器方法
     * @param {*} path
     * @param {*} importer
     * @returns
     */
    async resolveId(path, importer) {
      let resolved = path;
      for (const plugin of plugins) {
        if (plugin.resolveId) {
          const result = await plugin.resolveId(resolved, importer);
          if (result) {
            resolved = result.id || result;
            break;
          }
        }
      }
      return {
        id: normalizePath(resolved)
      };
    }
  };

  return container;
}

export { createPluginContainer };
// src/plugins/resolve.js
import os from 'os';
import path from 'path';
import resolve from 'resolve';
import fs from 'fs';

const windowsDrivePathPrefixRE = /^[A-Za-z]:[/\\]/;

const isWindows = os.platform() === 'win32';

// 裸包導入的正則
const bareImportRE = /^(?![a-zA-Z]:)[\w@](?!.*:\/\/)/;

/**
 * 這個函數的作用就是尋找模塊的入口文件
 * 這塊我們簡單寫,源碼中多了 exports、imports、main、module、yarn pnp 等等之類的判斷
 * @param {*} id
 * @param {*} importer
 */
function tryNodeResolve(id, importer, root) {
  const pkgDir = resolve.sync(`${id}/package.json`, {
    basedir: root
  });
  const pkg = JSON.parse(fs.readFileSync(pkgDir, 'utf-8'));
  const entryPoint = pkg.module ?? pkg.main;
  const entryPointsPath = path.join(path.dirname(pkgDir), entryPoint);
  return {
    id: entryPointsPath
  };
}

function withTrailingSlash(path) {
  if (path[path.length - 1] !== '/') {
    return `${path}/`;
  }
  return path;
}

/**
 * path.isAbsolute also returns true for drive relative paths on windows (e.g. /something)
 * this function returns false for them but true for absolute paths (e.g. C:/something)
 */
export const isNonDriveRelativeAbsolutePath = (p) => {
  if (!isWindows) return p[0] === '/';
  return windowsDrivePathPrefixRE.test(p);
};

/**
 * 尋找模塊所在絕對路徑的插件
 * 既是一個 vite 插件,也是一個 Rollup 插件
 * @param {*} param0
 * @returns
 */
function resolvePlugin({ root }) {
  // 相對路徑
  // window 下的 /
  // 絕對路徑
  return {
    name: 'vite:resolvePlugin',

    async resolveId(id, importer) {
      // 如果是 / 開頭的絕對路徑,同時前綴並不是在該項目(root) 中,那麼 vite 會將該路徑當作絕對的 url 來處理(拼接項目所在前綴)
      // /foo -> /fs-root/foo
      if (id[0] === '/' && !id.startsWith(withTrailingSlash(root))) {
        const fsPath = path.resolve(root, id.slice(1));
        return fsPath;
      }

      // 相對路徑
      if (id.startsWith('.')) {
        const basedir = importer ? path.dirname(importer) : process.cwd();
        const fsPath = path.resolve(basedir, id);

        return {
          id: fsPath
        };
      }

      // drive relative fs paths (only windows)
      if (isWindows && id.startsWith('/')) {
        // 同樣爲相對路徑
        const basedir = importer ? path.dirname(importer) : process.cwd();
        const fsPath = path.resolve(basedir, id);
        return {
          id: fsPath
        };
      }

      // 絕對路徑
      if (isNonDriveRelativeAbsolutePath(id)) {
        return {
          id
        };
      }

      // bare package imports, perform node resolve
      if (bareImportRE.test(id)) {
        // 尋找包所在的路徑地址
        const res = tryNodeResolve(id, importer, root);
        return res;
      }
    }
  };
}

export default resolvePlugin;

這裏我們來一步一步分析上述增加的代碼邏輯。

首先,我們爲 esbuildScanPlugin 額外增加了一個 build.onResolve 來匹配任意路徑文件。

「對於入口的 html 文件,他會匹配我們最開始 filter 爲 htmlTypesRe 的 onResolve 勾子來處理。而對於上一步我們從 html 文件中處理完成後的入口 js 文件 (/main.js),以及 /main.js 中的其他引入,比如 ./module.js 文件並不會匹配 htmlTypesRe 的 onResolve 鉤子則會繼續走到我們新增的 /.*/ 的 onResolve 鉤子匹配中。」

細心的朋友們會留意到上邊代碼中,我們把之前 onResolve 鉤子中的 path.resolve 方法變成了 resolveId(path, importer) 方法。

所謂的 resolveId 則是通過在 esbuildScanPlugin 中首先創建了一個 pluginContainer 容器,之後聲明的 resolveId 方法正是調用了我們創建的 pluginContainer 容器的 resolveId 方法。(src/optimizer/pluginContainer.js)。

我們要理解 pluginContainer 的概念,首先要清楚在 Vite 中實際上在開發環境會使用 Esbuild 進行預構建在生產環境上使用 Rollup 進行打包構建。

通常,我們會在 vite 中使用一些 vite 自身的插件也可以直接使用 rollup 插件,這正是 pluginContainer 的作用。

Vite 中會在進行文件轉譯時通過創建一個所謂的 pluginContainer 從而在 pluginContainer 中使用一個類似於 Adaptor 的概念。

它會在開發 / 生產環境下對於文件的導入調用 pluginContainer.resolveId 方法,而 pluginContainer.resolveId 方法則會依次調用配置的 vite 插件 / Rollup 插件的 ResolveId 方法。

其實你會發現 VitePlugin 和 RollupPlugin 的結構是十分相似的,唯一不同的是 VitePlugin 會比 RollupPlugin 多了一些額外的生命週期(鉤子)以及相關 context 屬性。

當然,開發環境下對於文件的轉譯(比如 tsxvue 等文件的轉譯)正是通過 pluginContainer 來完成的,這篇文章重點在於預構建的過程所以我們先不對於其他方面進行拓展。

「上述 esbuildScanPlugin 會返回一個 Esbuild 插件,然後我們在 Esbuild 插件的 build.onResolve 鉤子中實際調用的是 pluginContainer.resolveId 來處理。」

「其實這就是相當於我們在 Esbuild 的預構建過程中調用了 VitePlugin。」

同時,我們在調用 createPluginContainer 方法時傳入了一個默認的 resolvePlugin,所謂的 resolvePlugin 注意是一個 「Vite 插件」

resolvePlugin(src/plugins/resolve.js) 的作用就是通過傳入的 path 以及 importer 獲取去引入模塊在磁盤上的絕對路徑。

源碼中 resolvePlugin 邊界處理較多,比如虛擬導入語句的處理,yarn pnp、symbolic link 等一系列邊界場景處理,這裏我稍微做了簡化,我們清楚該插件是一個內置插件用來尋找模塊絕對路徑的即可。

自然,在當調用 custom-vite 命令後:

我們會判斷返回的路徑是否包含 node_modules,如果包含則認爲它是一個第三方模塊依賴。

「此時,我們會通過 esBuild 將該模塊標記爲 external: true 忽略進行該模塊內部進行分析,同時在 desImports 中記錄該模塊的導入名以及絕對路徑。」

如果爲一個非第三方模塊,比如 /main.js 中引入的 ./module.js,那麼此時我們會通過 onResolve 返回該模塊在磁盤上的絕對路徑。

Esbuild 會繼續進入插件的 onLoad 進行匹配,由於 onLoad 的 filter 以及 namesapce 均爲 htmlTypesRe 所以並不匹配,默認 Esbuild 會在文件系統中尋找該文件地址根據文件後綴名稱進行遞歸分析。

這樣,最終就達到了我們想要的結果。當我們在 vite-use(測試項目中) 調用 custom-vite 命令,會發現控制檯中會打印:

此時 depImports 中已經記錄了我們在源碼中引入的第三方依賴。

生成預構建產物

上邊的步驟我們藉助 Esbuild 以及 scanPlugin 已經可以在啓動 Vite 服務之前完成依賴掃描獲得源碼中的所有第三方依賴模塊。

接下來我們需要做的,正是對於剛剛獲取到的 deps 對象中的第三方模塊進行構建輸出經過預構建後的文件以及一份資產清單 _metadata.json 文件。

首先,我們先對於 src/config.js 配置文件進行簡單的修改:

import { normalizePath } from './utils.js';
import path from 'path';
import resolve from 'resolve';

/**
 * 尋找所在項目目錄(實際源碼中該函數是尋找傳入目錄所在最近的包相關信息)
 * @param {*} basedir
 * @returns
 */
function findNearestPackageData(basedir) {
  // 原始啓動目錄
  const originalBasedir = basedir;
  const pckDir = path.dirname(resolve.sync(`${originalBasedir}/package.json`));
  return path.resolve(pckDir, 'node_modules', '.custom-vite');
}

/**
 * 加載 vite 配置文件
 * (模擬)
 */
async function resolveConfig() {
  const config = {
    root: normalizePath(process.cwd()),
    cacheDir: findNearestPackageData(normalizePath(process.cwd())), // 增加一個 cacheDir 目錄
    entryPoints: [path.resolve('index.html')]
  };
  return config;
}

export default resolveConfig;

我們對於 config.js 中的 config 配置進行了修改,簡單增加了一個 cacheDir 的配置目錄。

這個目錄是用於當生成預構建文件後的存儲目錄,這裏我們固定寫死爲當前項目所在的 node_modules 下的 .custom-vite 目錄。

之後,我們在回到 src/optimizer/index.js 中稍做修改:

// src/optimizer/index.js
import path from 'path';
import fs from 'fs-extra';
import { scanImports } from './scan.js';
import { build } from 'esbuild';

/**
 * 分析項目中的第三方依賴
 * @param {*} config
 */
async function createOptimizeDepsRun(config) {
  const deps = await scanImports(config);
  // 創建緩存目錄
  const { cacheDir } = config;
  const depsCacheDir = path.resolve(cacheDir, 'deps');
  // 創建緩存對象 (_metaData.json)
  const metadata = {
    optimized: {}
  };
  for (const dep in deps) {
    // 獲取需要被依賴預構建的目錄
    const entry = deps[dep];
    metadata.optimized[dep] = {
      src: entry, // 依賴模塊入口文件(相對路徑)
      file: path.resolve(depsCacheDir, dep + '.js') // 預編譯後的文件(絕對路徑)
    };
  }
  // 將緩存文件寫入文件系統中
  await fs.ensureDir(depsCacheDir);
  await fs.writeFile(
    path.resolve(depsCacheDir, '_metadata.json'),
    JSON.stringify(
      metadata,
      (key, value) => {
        if (key === 'file' || key === 'src') {
          // 注意寫入的是相對路徑
          return path.relative(depsCacheDir, value);
        }
        return value;
      },
      2
    )
  );
  // 依賴預構建
  await build({
    absWorkingDir: process.cwd(),
    define: {
      'process.env.NODE_ENV': '"development"'
    },
    entryPoints: Object.keys(deps),
    bundle: true,
    format: 'esm',
    splitting: true,
    write: true,
    outdir: depsCacheDir
  });
}

export { createOptimizeDepsRun };

src/optimizer/index.js 中,之前我們已經通過 scanImports 方法拿到了 deps 對象:

{
  react: '/Users/ccsa/Desktop/custom-vite-use/node_modules/react/index.js'
}

然後,我們衝 config 對象中拿到了 depsCacheDir 拼接上 deps 目錄,得到的是存儲預構建資源的目錄。

同時創建了一個名爲 metadata 的對象,遍歷生成的 deps 爲 metadata.optimize 依次賦值,經過 for of 循環後所有需要經過依賴預構建的資源全部存儲在 metadata.optimize 對象中,這個對象的結構如下:

{
  optimized: {
    react: {
      src: "/Users/ccsa/Desktop/custom-vite-use/node_modules/react/index.js",
      file: "/Users/ccsa/Desktop/custom-vite-use/node_modules/.custom-vite/deps/react.js",
    },
  },
}

需要注意的是,我們在內存中存儲的 optimize 全部爲絕對路徑,而寫入硬盤時的路徑全部爲相對路徑。

之後同樣我們使用 Esbuild 再次對應項目中的所有第三方依賴進行構建打包。不過不同的是這一步我們標記 write:true 是需要將構建後的文件寫入硬盤中的。

完成上述過程後,我們再次在使用到的項目中 custom-vite-use 中運行 custom-vite 命令:


此時,我們已經實現了一個簡易版本的 Vite 預構建過程。

之後,啓動開發服務器後 Vite 實際會在開發服務器中對於第三方模塊的請求進行攔截從而返回預構建後的資源。

至於 Vite 是如何攔截第三方資源以及在是如何在 ESM 源生模塊下是如何處理 .vue/.ts/.tsx 等等之類的模塊轉譯我會在後續的 Vite 文章中和大家繼續進行揭密。

文章中的代碼你可以在這裏(https://github.com/19Qingfeng/custom-vite/tree/feat/prepare-scan)找到。

Vite 源碼

上邊的章節中我們已經自己實現了一個簡易的 Vite 預構建過程,接下來我會用上述預構建的過程和源碼進行一一對照。

Cli 命令文件

Vite 源碼結構爲 monorepo 結構,這裏我們僅僅關心 vite 目錄即可。

首先,Vite 目錄下的 /pakcages/vite/bin/vite.js 文件是作爲項目 cli 入口文件。

實際當運行 vite 命令時會執行該文件,執行該文件會經過以下調用鏈:

  1. 執行 /vite/src/node/cli.ts 文件處理一系列命令行參數。

  2. 處理完畢後再次調用 /vite/src/node/server/index.ts 創建開發服務器。

createServer 方法

當運行一次 Vite 命令後會執行到 /vite/src/node/server/index.ts 中的 createServer 方法。

實際 createServer 就和我我們上述的 createServer 代表的含義是一致的,都是在開發環境下啓動開發服務器。

實際上大多數流程和我們上述的代碼思路是一致的,比如resolveConfig 以及 serveStaticMiddleware 之類。

依賴預構建

在 createServer 方法的下半部分中,我們可以看到:

  1. container.buildStart

所謂 container.buildStart 正是我們之前提到過的 vite 內部有一套自己的插件容器。vite 正是通過這一套插件容器來處理開發模式和生產模式的區別。

container 插件容器會實現一套和 Rollup 一模一樣的插件 API,所以 Rollup Plugin 同樣也可以通過 container Api 在開發模式下調用。

自然,生產模式下本身就使用 Rollup 進行構建,所以可以實現生產百分百的插件兼容。

  1. initDepsOptimizer

initDepsOptimizer 正是在啓動開發服務器之前進行依賴預構建的核心方法。

initDepsOptimizer

initDepsOptimizer 會調用 createDepsOptimizer 方法。

createDepsOptimizer 方法在開發模式下 (!isBuild):

discoverProjectDependencies 正如名字那樣,這個方法是發現項目中的第三方依賴(依賴掃描)。

discoverProjectDependencies 內部會調用 scanImports(https://github.com/vitejs/vite/blob/main/packages/vite/src/node/optimizer/scan.ts) 方法:

編輯器左邊部分爲 scanImports 方法,他會返回 prepareEsbuildScanner 方法的返回值。

而 prepareEsbuildScanner 正是和我們上述思路一致的依賴掃描:藉助 Esbuild 以及 esbuildScanPlugin 掃描項目中的第三方依賴。

最終 createDepsOptimizer 方法中會用 deps 保存 discoverProjectDependencies 方法的項目中掃描到的所有第三方依賴。

這裏有兩點需要注意。

  1. 首先 discoverProjectDependencies 尋找到的 react 實際地址是一個 "/Users/ccsa/Desktop/custom-vite-use/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js" 的值,這是由於安裝依賴時我使用的是 pnpm ,而 Vite 中對於 Symbolic link 有處理,而我們上邊的代碼比較簡易並沒有處理 Symbolic link。

  2. 下圖中可以看到 prepareEsbuildScanner 方法又創建了一個 pluginContainer。

Vite 中的 pluginContianer 並不是一個單例,Vite 中會多次調用 createPluginContainer 創建多個插件容器。

在 prepareEsbuildScanner 在與預構建過程同樣會創建一個插件容器,這正是我們上述簡易版 Vite 中創建的插件容器。

這裏大家只要明白 pluginContainer 在 vite 中不是一個單例即可,後續在編譯文件的文章中我們會着重來學習 pluginContainer 的概念。

runOptimizeDeps

上述的步驟 Vite 已經可以通過 discoverProjectDependencies 拿到項目中的需要進行預構建的文件。

之後,createDepsOptimizer 方法中會使用 prepareKnownDeps 方法處理拿到的依賴(增加 hash 等):

然後將 prepareKnownDeps 返回的 knownDeps 交給 runoptimizeDeps 進行處理:

runOptimizeDeps 方法內部會調用 prepareEsbuildOptimizerRun(https://github.com/vitejs/vite/blob/main/packages/vite/src/node/optimizer/index.ts)。

prepareEsbuildOptimizerRun 方法正是使用 EsBuild 對於前一步掃描生成的依賴進行預構建的方法:

當 context 準備完畢後,prepareEsbuildOptimizerRun 會調用 rebuild 方法進行打包(生成預構建產物):

當 rebuild 運行完畢後,我們會發現 node_modules 下的預構建文件也會生成了:

Vite 源碼中關於邊界處理的 case 特別說,實話說筆者也並沒有逐行閱讀。

這裏的源碼部分更多是想起到一個拋磚引玉的作用,希望大家可以在瞭解預構建的基礎思路後可以跟隨源碼自己手動 debugger 調試一下。

結尾

Vite 中依賴預構建截止這裏已經給大家分享完畢了,希望文章中的內容可以幫助到大家。

之後我仍會在專欄中分享關於 Vite 中其他進階內容,比如 Vite 開發環境下的文件轉譯、熱重載以及如何在生產環境下的調用過程。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/1gAY94h4QpS5WLrMzLvAkw