Vite本地構建:手寫核心原理

前端南玖發表於2024-07-28

前言

接上篇文章,我們瞭解到vite的本地構建原理主要是:啟動一個 connect 伺服器攔截由瀏覽器請求 ESM的請求。透過請求的路徑找到目錄下對應的檔案做一下編譯最終以 ESM的格式返回給瀏覽器。

基於這個核心思想,我們可以嘗試來動手實現一下。

搭建靜態伺服器

基於koa搭建一個專案:

專案結構如上,服務使用koa搭建,bin指定cli可執行檔案的位置

#!/usr/bin/env node
// 代表該指令碼使用node執行

const koa = require('koa');
const send = require('koa-send');



const App = new koa()

App.listen(3000, () => {
    console.log('Server is running at http://localhost:3000');
});

這樣一個服務就搭建好了,為了方便除錯,我們在該工作目錄下執行npm link,這樣可以將該專案連結支全域性的npm,相當於全域性安裝了這個npm包。

接著我們在任意專案下執行my-vite就能夠啟動該服務了!

處理根目錄html檔案

由於上面服務我們沒有對任何路由進行處理,當訪問http://localhost:3000會發現什麼也沒有,我門首先需要將專案的模版檔案index.html返回給瀏覽器

const root = process.cwd(); // 獲取當前工作目錄
console.log('當前工作目錄:', process.cwd());

// 靜態檔案服務區
App.use(async (ctx, next) => {
    // 處理根路徑,返回index.html
    await send(ctx, ctx.path, { root: process.cwd() ,index: 'index.html'});
    await next();
});

index.html模版檔案如下:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + Vue + TS</title>
  </head>
  <body>
    <div id="app"></div>
    <script>
      window.process = { env: { NODE_ENV: 'development' } };
    </script>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

就是以ESM的方式載入了vue的入口檔案main.ts

加完這段程式碼,我們在vue3專案下執行一下my-vite

來到瀏覽器看一下此時的情況:

此時瀏覽器載入了main.ts,該檔案如下:它透過import引入了兩個模組

import { createApp } from 'vue'
import App from './App.vue'


createApp(App).mount('#app')

按理來說,瀏覽器此時應該會接著發起請求,去獲取這兩個模組,但現在卻並沒有🤔

此時控制檯有個錯誤:

意思就是載入模組,必須以相對路徑才可以(/、./、../)

所以我們現在需要來處理這些模組的載入路徑問題

處理模組載入路徑

由於三方模組都是直接以模組名來載入的,所以這裡我們需要將這些模組的引用路徑轉換成相對路徑。

// 處理模組匯入
const importAction = (content) => {
    return content.replace(/(from\s+['"])(?!\.\/)/g, '$1/@modules/')
}

// 修改第三方模組的路徑
App.use(async (ctx, next) => {
    // console.log('ctx.path', ctx.type, ctx.path);
    // 處理ts或者js檔案
    if (ctx.path.endsWith('.ts') || ctx.path.endsWith('.js')){
        const content = await fileToString(ctx.body); // 獲取檔案內容
        ctx.type = 'application/javascript'; // 設定響應型別為js
        ctx.body = importAction(content);   // 處理import載入路徑
    }
    await next();
});

在這個中介軟體中,我們使用正規表示式將模組的引用路徑替換成了/@modules開頭,這樣就符合瀏覽器的引用規則了。

接著再到瀏覽器中來觀察此時的情況:

此時瀏覽器已經可以發出另外兩個請求,分別去載入vue模組以及App.vue元件了。

可以看到vue模組的載入路徑已經變成了/@modules開頭了,雖然現在該路徑還是404,但最起碼比起之前我們又往前走一步了。

其實404也很好理解,因為我們的服務現在壓根就還沒處理這類路徑,所以接下來就該處理/@modules這類path並載入模組內容

載入第三方模組

這裡我們只需要去攔截剛剛/@modules開頭的路徑,並找到該路徑下的模組的真正位置,最後返回給瀏覽器就可以了。

// 載入第三方模組
App.use(async (ctx, next) => {
    if (ctx.path.startsWith('/@modules/')) {
        const moduleName = ctx.path.substr(10); // 獲取模組名稱
        const modulePath = path.join(root, 'node_modules', moduleName); // 獲取模組路徑
        const package = require(modulePath + '/package.json'); // 獲取模組的package.json
        // console.log('modulePath', modulePath);
        ctx.path = path.join('/node_modules', moduleName, package.module); // 重寫路徑
    } 
    await next();
    
});

我們可以透過讀取package.json檔案中的module欄位,來找到第三方模組的入口檔案。

該中介軟體需要在處理模組載入路徑的中介軟體之前執行

此時再來到瀏覽器中檢視:

可以看到,此時的vue模組已經能夠重新載入了,但下面又多載入了四個模組,它們又是從哪來的呢?

可以看到vue模組中又引入了runtime-dom模組,並且它們的載入路徑也被轉成了/@modules開頭,這就是上面提到的載入模組的中介軟體需要在處理模組載入路徑的中介軟體之前執行,模組載入回來之後又經過了處理載入路徑的中介軟體,所以就相當於遞迴把模組的路徑全都轉換成相對路徑了

runtime-dom模組又引入了runtime-coreshared模組,而runtime-core模組又引入了reactivity模組,所以會看到上圖中這樣的一種載入順序。

模組的載入引入都正確了,但頁面還是沒又任何渲染內容出現

這是因為此時的App.vue還沒經過任何編譯處理,瀏覽器並不能直接識別並執行該檔案

所以接下來的重點是需要將App.vue檔案編譯成瀏覽器能夠執行的javascript內容(render函式)

處理Vue單檔案元件

這裡我們需要使用Vue的編譯模組@vue/compiler-sfc@vue/compiler-dom來對vue檔案進行編譯處理。

處理script

const content = await fileToString(ctx.body); // 獲取檔案內容
const { descriptor } = compilerSfc.parse(content); // 解析單檔案元件
const compileScript = importAction(
   compilerSfc.compileScript(
     descriptor, 
     { 
       id: descriptor.filename 
     }
   ).content); // 編譯script

處理template

const compileRender =importAction(compilerDom.compile(descriptor.template.content, 
                // 編譯template, render函式中變數從setup中獲取
            {   mode: 'module',
                sourceMap: true,
                filename: path.basename(ctx.path),
                __isScriptSetup: true, // 標記是否是setup
                compatConfig: { MODE: 3 }, // 相容vue3
            }).code); // 編譯template

處理style

let styles = '';
if(descriptor.styles.length){
  console.log('descriptor.styles', descriptor.styles);
  // 處理樣式
  styles = descriptor.styles.map((style,index) => {
    return `
             import '${ctx.path}?type=style&index=${index}';
    `
  }).join('\n');

} // 處理樣式

這裡是透過讓它另外發起一次請求來對style進行處理,這樣隔離開邏輯能夠更清晰

處理樣式的請求

在中介軟體中透過攔截typestyle的請求來進行處理

if (ctx.query.type === 'style') {
  // 處理樣式
  const styleBlock = descriptor.styles[ctx.query.index];
  console.log('styleBlock', styleBlock);
  ctx.type = 'application/javascript';
  ctx.body = `
            const _style = (css) => {
                const __style = document.createElement('style');
                __style.type = 'text/css';
                __style.innerHTML = css;
                document.head.appendChild(__style);
                }
                _style(${JSON.stringify(styleBlock.content)});
                export default _style;
            `;
}

最後驗證

總結

在深入探索了vite的工作流程之後,你可能會發現,儘管從概念上看似簡單,但vite背後的實現卻相當複雜且精妙。我們剛剛透過走一遍其核心流程,對vite如何載入模組、解析和編譯檔案有了初步的認識。然而,這僅僅是冰山一角。

總的來說,vite的工作原理雖然可以透過一個簡化的示例來理解,但其真正的強大和複雜性遠不止於此。如果對vite的深入工作原理感興趣,可以去深入閱讀它的原始碼,在那裡我們能夠學習到更多知識。

相關文章