站在潮流前沿,快速實現一個簡易版 vite

張宇航,微醫前端技術部醫保支撐組,一個不文藝的處女座程序員。

寫在最前面

本文最終實現的簡易版 vite 可通過 github 地址 (https://github.com/levelyu/simple-vite) 下載,代碼實現地較爲簡單(不到 100 行)可運行後再看此文,閱讀效果可能更佳~

要解決的問題

首先我們參照官方文檔啓動一個 vue 模板的 vite-demo 項目

yarn create @vitejs/app vite-demo --template vue
cd vite-demo
yarn
yarn dev

然後打開瀏覽器查看網絡請求,我們不難發現 vite 正如官方文檔所述利用瀏覽器支持原生 ES 模塊的特性,讓瀏覽器解析了 import 語句併發出網絡請求避免了本地編譯打包的過程,因此啓動速度非常之快。

常言道 “先知其然, 然後知其所以然”, 在打開了 vite 模板工程的源文件再對照上述的網絡請求後,有的同學可能有以下幾個疑問:

1:main.js 返回的內容 其中 impor 語句爲什麼被改寫成了 import {createApp} from '/node_modules/.vite/vue.js?

2:查看本地文件也會發現 node_modules 文件夾下爲什麼會多出了一個. vit 文件夾?

3:.vue 文件的請求是怎麼處理並返回能正常運行的 js 呢?

4: 爲什麼會多出兩個 js 文件請求 /@vite/client 和 /node_modules/vite/dist/client/env.js 以及一個 websocket 連接?

對於問題 4,實際上是 vite devServer 的熱更新相關的功能,不在本文的研究重點,因此本文的目的就是帶着問題 1,2,3,參照源碼實現一個沒有熱更新沒有打包功能的極簡易的 vite。(注:本文參考的 vite 源碼版本號爲 2.3.0)

準備工作

工欲善其事, 必先利其器。既然是從源碼分析問題,那就先準備好調試工作。參照官方文檔:

首先克隆 vite 倉庫並創建一個軟鏈接

git clone git@github.com:vitejs/vite.git

cd vite && yarn

cd packages/vite && yarn build && yarn link

yarn dev

進入之前初始化好的 vite-demo 項目並鏈接到本地 vite 倉庫地址

cd vite-demo
yarn link vite

從 vite bin 目錄下 vite.js 文件不難發現 vite 命令對應的入口文件在 node_modules/vite/dist/node/cli.js

因此我們可以在 vite-demo 的 package.json 文件中加入以下腳本命令:

 "debug""node --inspect-brk node_modules/vite/dist/node/cli.js"

並運行命令 yarn debug  後打開瀏覽器控制檯即可看到 node 的圖標,點擊後,我們就可以開始進行源碼調試的工作了:

源碼分析

注意:爲方便理解,本文對應的源碼均爲截取後的僞代碼

server 的創建過程

// src/node/cli.ts
cli
  .command('[root]')
  .alias('serve')
  .action(async () ={
    const { createServer } = await import('./server')
      const server = await createServer({
        // ...
      })
      await server.listen()
  })

不難看出上述入口文件代碼是從 src/node/server/index.ts 引入了一個 createServer 方法並調用返回了一個 server 對象,緊接着調用了 server 的 listen 方法。ok, 那就讓我們看看這個 createServer 方法內部做了哪些事情:

// src/node/server/index.ts

// ....
import connect from 'connect';

import { transformMiddleware } from './middlewares/transform'
//...
export async function createServer() {
    // ...
    const middlewares = connect();
    const httpServer = await resolveHttpServer({}, middlewares)
    // 實際 server 的配置會讀取 vite.config.js 以及各種插件中的配置 本文力求通俗簡易就不再詳細分析贅述...
    const server = {
        httpServer,
        listen() {
            return startServer(server);
        },
    };
    // ...
    middlewares.use(transformMiddleware(server))
    // ...
    await runOptimize();
    return server;
}

async function startServer(server) {
    // ...
    const httpServer = server.httpServer;
    // ...
    const port = 3000; 
    const hostname = '127.0.0.1';
    return new Promise((resolve, reject) =>{
        httpServer.listen(port, hostname, () ={
            resolve(server);
        });
    });
};

// src/node/server/http.ts

export async function resolveHttpServer(serveroptions, app) {
    return require('http').createServer(app)
}

通過上述僞代碼可以發現,vite2 最終是調用了 http.ts 中的 resolveHttpServer 方法,通過 node 原生的 http 模塊創建的 server。同時在 createServer 方法內部,使用了 connect 框架作爲中間件。

依賴預構建

細心的同學不難發現在上述 createServer 方法的僞代碼中有個 runOptimize 方法,下面讓我們看看這個函數里具體做了哪些事情:

// src/node/server/index.ts

const runOptimize = async () ={
    if (config.cacheDir) {
      server._isRunningOptimizer = true
      try {
        server._optimizeDepsMetadata = await optimizeDeps(config)
      } finally {
        server._isRunningOptimizer = false
      }
      server._registerMissingImport = createMissingImporterRegisterFn(server)
    }
}

實際該方法最重要的是調用了依賴預構建的核心方法:optimizeDeps, 其定義在 src/node/optimizer/index.ts 中,並且在 server 啓動前就已調用。

那麼何爲依賴預構建呢,vite 不是 No Bundle 嗎?對此,官方文檔做出了詳細解釋:點此查看原因,簡而言之其目的有二:

  1. 兼容 CommonJS 和 AMD 模塊的依賴

  2. 減少模塊間依賴引用導致過多的請求次數

再結合以下僞代碼分析:

// src/node/optimizer/index.ts

import { build } from 'esbuild';
import { scanImports } from './scan';

export async function optimizeDeps() {
    // cacheDir 的定義在 src/node/config.ts
    const cacheDir =  `node_modules/.vite`;
    // optimizeDeps  函數依賴預構建的重要函數 
    const dataPath = path.join(cacheDir, '_metadata.json'); 
    if (fs.existsSync(cacheDir)) {
        emptyDir(cacheDir)
      } else {
        // 創建 cacheDir 目錄
        fs.mkdirSync(cacheDir, { recursive: true })
      }
    ;({ deps, missing } = await scanImports(config))
    // eg: deps = {vue: "C:/code/sourcecode/vite-demo/node_modules/vue/dist/vue.runtime.esm-bundler.js"}
    const result = await build({
        entryPoints: Object.keys(flatIdDeps),
        outdir: cacheDir,
    })
    writeFile(dataPath, JSON.stringify(data, null, 2))
}

至此,我們基本可以得到開篇問題 2(爲什麼 node_modules 下多出了一個. vite 文件夾)的答案了。那麼,可能又有同學有以下兩個疑問:

1.vite 是如何分析找到哪些模塊是需要預構建的呢?

2.vite 是如何完成預構建的同時保證構建速度的呢?

帶着這兩個問題,繼續一路 debug 下去,不難發現答案就是 esbuild,關於 esbuild 是什麼這裏就不再贅述了,這裏就貼一張官方文檔的對比圖感受下,總之就是一個字:快!!!

繼續回到剛纔 src/node/optimizer/index.ts 中的僞代碼,實際上 scanImports 函數其實就是完成對 import 語句的掃描,並返回了需要構建的依賴 deps, 下圖則說明了這個 deps 其實就是 main.js 中唯一的依賴 vue 對應的路徑:

那麼這個 scanImports 是如何找到我們的唯一依賴 vue 呢:進入 scanImports 函數有以下僞代碼:

// src/node/optimizer/scan.ts
import { Loader, Plugin, build, transform } from 'esbuild'

export async function scanImports() {
    cosnt entry = await globEntries('**/*.html', config)
    const plugin = esbuildScanPlugin()
    build({
        write: false,
        entryPoints: [entry],
        bundle: true,
        format: 'esm',
       // ...
      })
    
}
function esbuildScanPlugin() {
    return {
        name: 'vite:dep-scan',
        setup(build) {
          build.onLoad(
                { filter: htmlTypesRE, namespace: 'html' },
                // 讀取 html 內容  正則匹配到 <script> 內的內容 
            return {
                    loader: 'js',
                    content: 'import "/src/main.js" export default {}"
                }
            )
           build.onLoad({ filter: JS_TYPES_RE }, ({ path: id } => {
                // eg: id = 'C:\\code\\sourcecode\\vite-demo\\src\\main.js'
        
                return {
                    loader: 'js',
                    content: '' // eg: 讀取 main.js 內容 內有 import vue from 'vue'
                }
            }) 
            build.onResolve( {filter: /^[\w@][^:]/},async ({ path: id, importer }) => {
                // eg: id = "vue" 
                // eg: importer = "C:\\code\\sourcecode\\vite-demo\\src\\main.js"
                // 加入依賴
                depImports[id] = await resolve(id, importer); // eg: 返回"C:/code/sourcecode/vite-demo/node_modules/vue/dist/vue.runtime.esm-bundler.js"
                return {
                    path: 'C:/code/sourcecode/vite-demo/node_modules/vue/dist/vue.runtime.esm-bundler.js'
                }
            })
        }
    };
}

對照代碼註釋並結合以下流程圖

至此我們可以得出結論:vite 主要是通過一個內置的 vite:dep-scan esbuild 插件分析依賴項並將其寫入一個_metadata.json 文件中,並通過 esbuild 將依賴的模塊(如將 vue.runtime.esm-bundler.js)打包至. vite 文件中(產生一個 vue.js 和 vue.js.map 文件),這也就是開篇問題 2(本地多了一個. vite 文件夾)的答案。

transformMiddleware

在上節中我們分析了 vite 預構建的過程,最終其將打包後的文件寫入在. vite 文件夾內解決了開篇提出的問題 2。那麼讓我們繼續回到開篇提到的問題 1:main.js 中的 import vue from 'vue'是如何改寫成 import vue from '/node_modules/.vite/vue.js'的:

還記得我們在 src/node/optimizer/index.ts 中的一段代碼嗎:

//src/node/optimizer/index.ts

import { transformMiddleware } from './middlewares/transform'
const middlewares = connect();
middlewares.use(transformMiddleware);

實際上 transformMiddleware 正是 vite devServer 核心的中間件,簡而言之它負責攔截處理各種文件的請求並將其內容轉換成瀏覽器能識別的正確代碼,下面讓我們看下 transformMiddleware 做了哪些事情:

// src/node/server/middlewares/transform.ts

import { transformRequest } from '../transformRequest'

export function transformMiddleware(server) {
    // ....
    if (isJSRequest(url) ) {
        const result = await transformRequest(url)
        return send(
            req,
            res,
            result.code,
            type,
            result.etag,
            // allow browser to cache npm deps!
            isDep ? 'max-age=31536000,immutable' : 'no-cache',
            result.map
         )
    }
}

可以看得出它對 js 的請求,是通過 vite 中間件的一個核心方法 transformRequest 處理的,並將結果發送至瀏覽器

// src/node/server/transformRequest.ts

export async function transformRequest(url) {
    code = await fs.readFile(url, 'utf-8')
    const transformResult = await pluginContainer.transform(code)
    code = transformResult.code!
    return {
        code
    }
}

transformRequest 中代碼的核心處理是 pluginContainer.transform 方法,而 transform 方法會遍歷 vite 內置的所有插件以及用戶配置的插件處理轉換 code,其中內置的一個核心的插件爲 import-analysis

// src/node/plugins/importAnalysis.ts

import { parse as parseImports } from 'es-module-lexer'
export function importAnalysisPlugin() {
    return {
        name: 'vite:import-analysis',
        async transform(source, importer, ssr) {
            const specifier = parseImports(source); // specifier = vue
            await normalizeUrl(specifier);
            const normalizeUrl  = async (specifier)={
                const resolved = await this.resolve(specifier)
                // eg: resolved = {id: "C:/code/sourcecode/vite-demo/node_modules/.vite/vue.js?v=82c5917e"}
            }
        }
    }
}

對 importAnalysisPlugin 函數內部做的事情可簡單歸納如下:

  1. 使用一個詞法分析利器 es-module-lexer 對源代碼進行詞法分析,並最終能拿到 main.js 中的語句 import vue from 'vue'中的 vue

  2. 調用 reslove 方法最終其會先後調用 vite 內置的兩個 plugin:vite:pre-alias 及 vite:resolve

  3. 最終在 vite:resolve 內的鉤子函數 resolveId 內部調用 tryOptimizedResolve

  4. tryOptimizedResolve 最終會通過讀取依賴構建階段的緩存的依賴映射對象,拿到 vue 對應的路徑

小結一下

至此我們已經通過源碼分析解決了開篇所提到的問題 1 和問題 2,簡單地總結下就是:

  1. vite 在啓動服務器之前通過 esbuild 及內置的 vite:dep-scan esbuild 插件將 main.js 中的依賴 vue 預構建打包至 /node_modules/.vite / 下

  2. 核心中間件 transformMiddleware 攔截 main.js 請求,讀取其內容,在 import-analysis 的插件內部通過 es-module-lexer 分析 import 語句讀取到依賴 vue, 再通過一系列的內置 plugin 最終將 import 語句中的 vue 轉換成 vue 對應預構建的真實路徑

對於問題 3vite 是如何轉換. vue 文件的請求,vite 同樣是通過 transformMiddleware 攔截. vue 請求並調用外部插件 @vitejs/plugin-vue 處理轉換的,感興趣的同學可以查看 plugin-vue 的源碼, 本文就不再贅述了而是通過下文的實踐章節以代碼來解釋。

實踐一下

ok,在一頓分析之後我們終於來到了 coding 的環節了,廢話不多說,我們先創建一個 server

// simple-vite/vit/index.js
const http = require('http');
const connect = require('connect');
const middlewares = connect();
const createServer = async ()={
    // 依賴預構建
    await optimizeDeps();
    http.createServer(middlewares).listen(3000, () ={
        console.log('simple-vite-dev-server start at localhost: 3000!');
    });
};
// 用於返回 html 的中間件
middlewares.use(indexHtmlMiddleware);
// 處理 js 和 vue 請求的中間件
middlewares.use(transformMiddleware);
createServer();

接着我們寫下依賴預構建的函數 optimizeDeps

// simple-vite/vit/index.js
const fs = require('fs');
const path = require('path');
const esbuild = require('esbuild');
// 因爲我們的 vite 目錄和測試的 src 目錄在同一層,因此加了個../
const cacheDir = path.join(__dirname, '../''node_modules/.vite');
const optimizeDeps = async () ={
    if (fs.existsSync(cacheDir)) return false;
    fs.mkdirSync(cacheDir, { recursive: true });
    // 在分析依賴的時候 這裏爲簡單實現就沒按照源碼使用 esbuild 插件去分析
    // 而是直接簡單粗暴的讀取了上級 package.json 的 dependencies 字段
    const deps = Object.keys(require('../package.json').dependencies);
    // 關於 esbuild 的參數可參考官方文檔
    const result = await esbuild.build({
        entryPoints: deps,
        bundle: true,
        format: 'esm',
        logLevel: 'error',
        splitting: true,
        sourcemap: true,
        outdir: cacheDir,
        treeShaking: 'ignore-annotations',
        metafile: true,
        define: {'process.env.NODE_ENV'"\"development\""}
      });
    const outputs = Object.keys(result.metafile.outputs);
    const data = {};
    deps.forEach((dep) ={
        data[dep] = '/' + outputs.find(output => output.endsWith(`${dep}.js`));
    });
    const dataPath = path.join(cacheDir, '_metadata.json');
    fs.writeFileSync(dataPath, JSON.stringify(data, null, 2));
};

至此依賴預構建的函數已寫完,當我們運行命令後會發現有打包後的依賴包及依賴映射的 json 文件, 而且整個過程非常快

微信圖片_20210513214012.png

再然後我們來實現下中間件函數,indexHtmlMiddleware 沒什麼好說的就是讀取返回根目錄的 index.html

// simple-vite/vit/index.js
const indexHtmlMiddleware = (req, res, next) ={
    if (req.url === '/') {
        const htmlPath = path.join(__dirname, '../index.html');
        const htmlContent = fs.readFileSync(htmlPath, 'utf-8');
        res.setHeader('Content-Type''text/html');
        res.statusCode = 200;
        return res.end(htmlContent);
    }
    next();
};

最核心的當屬 transformMiddleware 了, 首先讓我們處理下 js 文件

// simple-vite/vit/index.js
const transformMiddleware = async (req, res, next) ={
    // 因爲預構建我們配置生成了 map 文件所以同樣要處理下 map 文件
    if (req.url.endsWith('.js') || req.url.endsWith('.map')) {
        const jsPath = path.join(__dirname, '../', req.url);
        const code = fs.readFileSync(jsPath, 'utf-8');
        res.setHeader('Content-Type''application/javascript');
        res.statusCode = 200;
        // map 文件不需要分析 import 語句
        const transformCode = req.url.endsWith('.map') ? code : await importAnalysis(code);
        return res.end(transformCode);
    }
    next();
};

transformMiddleware 最關鍵的就是 importAnalysis 函數了,正如 vite2 源碼裏一樣其正是處理分析源代碼中的 import 語句,並將依賴包替換成預構建包的路徑

// simple-vite/vit/index.js
const { init, parse } = require('es-module-lexer');
const MagicString = require('magic-string');

const importAnalysis = async (code) ={
    // es-module-lexer 的 init 必須在 parse 前 Resolve
    await init;
    // 通過 es-module-lexer 分析源 code 中所有的 import 語句
    const [imports] = parse(code);
    // 如果沒有 import 語句我們直接返回源 code
    if (!imports || !imports.length) return code;
    // 定義依賴映射的對象
    const metaData = require(path.join(cacheDir, '_metadata.json'));
    // magic-string vite2 源碼中使用到的一個工具 主要適用於將源代碼中的某些輕微修改或者替換
    let transformCode = new MagicString(code);
    imports.forEach((importer) ={
        // n: 表示模塊的名稱 如 vue
        // s: 模塊名稱在導入語句中的起始位置
        // e: 模塊名稱在導入語句中的結束位置
        const { n, s, e } = importer;
        // 得到模塊對應預構建後的真實路徑  如 
        const replacePath = metaData[n] || n; 
        // 將模塊名稱替換成真實路徑如/node_modules/.vite
        transformCode = transformCode.overwrite(s, e, replacePath);
    });
    return transformCode.toString();
};

至此,對於 js 請求已處理完畢,其中主要用到的兩個包 es-module-lexer 和 magic-string 感興趣的同學可以去對應的 github 地址瞭解。最後讓我們再處理下. vue 文件吧:

// simple-vite/vit/index.js
const compileSFC = require('@vue/compiler-sfc');
const compileDom = require('@vue/compiler-dom');

const transformMiddleware = async (req, res, next) ={
    if (req.url.indexOf('.vue')!==-1) {
        const vuePath = path.join(__dirname, '../', req.url.split('?')[0]);
        // 拿到 vue 文件中的內容
        const vueContent =  fs.readFileSync(vuePath, 'utf-8');
        // 通過@vue/compiler-sfc 將 vue 中的內容解析成 AST
        const vueParseContet = compileSFC.parse(vueContent);
        // 得到 vue 文件中 script 內的 code
        const scriptContent = vueParseContet.descriptor.script.content;
        const replaceScript = scriptContent.replace('export default ''const __script = ');
        // 得到 vue 文件中 template 內的內容
        const tpl = vueParseContet.descriptor.template.content;
        // 通過@vue/compiler-dom 將其解析成 render 函數
        const tplCode = compileDom.compile(tpl, { mode: 'module' }).code;
        const tplCodeReplace = tplCode.replace('export function render(_ctx, _cache)''__script.render=(_ctx, _cache)=>');
        // 最後不要忘了 script 內的 code 還要再一次進行 import 語句分析替換
        const code = `
                ${await importAnalysis(replaceScript)}
                ${tplCodeReplace}
                export default __script;
        `;
        res.setHeader('Content-Type''application/javascript');
        res.statusCode = 200;
        return res.end(await importAnalysis(code));
    }
    next();
};

關於. vue 文件的處理好像也沒什麼好說的了,看代碼看註釋就完事了,想深入瞭解的同學可查看 @vitejs/plugin-vue。然後讓我們看下此代碼實現的最終效果吧:

如上圖所示,所有請求的文件最終都轉換成了瀏覽器能成功運行的 js 代碼。

最後的總結

本文的最終目的是參照 vite2 源碼實現一個極其簡易版的 vite,其主要功能簡而言之是以下兩點:

  1. 利用 esbuild 進行預構建工作,其目的是能將我們依賴的瀏覽器不支持運行的 CJS 和 AMD 模塊的代碼打包轉換爲瀏覽器支持的 ES 模塊代碼,同時避免了過多的網絡請求次數。

  2. 模擬源碼實現一個 transformMiddleware,其目的是能將源代碼進行轉換瀏覽器能支持運行的代碼,如:分析源代碼的 import 語句並其替換爲瀏覽器可執行的 import 語句以及將 vue 文件轉換爲可執行的 js 代碼。

最後感謝您能抽出寶貴的時間來看此文章,希望能給您帶來收穫。

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