學習Vite的原理

1. 概述

Vite是一個更輕、更快的web應用開發工具,面向現代瀏覽器。底層基於ECMAScript標準原生模塊系統ES Module實現。他的出現是為瞭解決webpack冷啟動時間過長以及Webpack HMR熱更新反應速度慢等問題。

默認情況下Vite創建的項目是一個普通的Vue3應用,相比基於Vue-cli創建的應用少瞭很多配置文件和依賴。

Vite創建的項目所需要的開發依賴非常少,隻有Vite@vue/compiler-sfc。這裡面Vite是一個運行工具,compiler-sfc則是為瞭編譯.vue結尾的單文件組件。在創建項目的時候通過指定不同的模板也可以支持使用其他框架例如React。項目創建完成之後可以通過兩個命令啟動和打包。

# 開啟服務器
vite serve
# 打包
vite build

正是因為Vite啟動的web服務不需要編譯打包,所以啟動的速度特別快,調試階段大部分運行的代碼都是你在編輯器中書寫的代碼,這相比於webpack的編譯後再呈現確實要快很多。當然生產環境還是需要打包的,畢竟很多時候我們使用的最新ES規范在瀏覽器中還沒有被支持,Vite的打包過程和webpack類似會將所有文件進行編譯打包到一起。對於代碼切割的需求Vite采用的是原生的動態導入來實現的,所以打包結果隻能支持現代瀏覽器,如果需要兼容老版本瀏覽器可以引入Polyfill

使用Webpack打包除瞭因為瀏覽器環境並不支持模塊化和新語法外,還有就是模塊文件會產生大量的http請求。如果你使用模塊化的方式開發,一個頁面就會有十幾甚至幾十個模塊,而且很多時候會出現幾kb的文件,打開一個頁面要加載幾十個js資源這顯然是不合理的。

  • Vite創建的項目幾乎不需要額外的配置默認已經支持TS、Less, Sass,Stylus,postcss瞭,但是需要單獨安裝對應的編譯器,同時默認還支持jsx和Web Assembly。
  • Vite帶來的好處是提升開發者在開發過程中的體驗,web開發服務器不需要等待即可立即啟動,模塊熱更新幾乎是實時的,所需的文件會按需編譯,避免編譯用不到的文件。並且開箱即用避免loader及plugins的配置。
  • Vite的核心功能包括開啟一個靜態的web服務器,能夠編譯單文件組件並且提供HMR功能。當啟動vite的時候首先會將當前項目目錄作為靜態服務器的根目錄,靜態服務器會攔截部分請求,當請求單文件的時候會實時編譯,以及處理其他瀏覽器不能識別的模塊,通過websocket實現hmr。

2. 實現靜態測試服務器

首先實現一個能夠開啟靜態web服務器的命令行工具。vite1.x內部使用的是Koa來實現靜態服務器。(ps:node命令行工具可以查看我之前的文章,這裡就不介紹瞭,直接貼代碼)。

npm init
npm install koa koa-send -D

工具bin的入口文件設置為本地的index.js

#!/usr/bin/env node

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

const app = new Koa()

// 開啟靜態文件服務器
app.use(async (ctx, next) => {
    // 加載靜態文件
    await send(ctx, ctx.path, { root: process.cwd(), index: 'index.html'})
    await next()
})

app.listen(5000)

console.log('服務器已經啟動 http://localhost:5000')

這樣就編寫好瞭一個node靜態服務器的工具。

3. 處理第三方模塊

我們的做法是當代碼中使用瞭第三方模塊(node_modules中的文件),可以通過修改第三方模塊的路徑給他一個標識,然後在服務器中拿到這個標識來處理這個模塊。

首先需要修改第三方模塊的路徑,這裡需要一個新的中間件來實現。判斷一下當前返回給瀏覽器的文件是否是javascript,隻需要看響應頭中的content-type。如果是javascript需要找到這個文件中引入的模塊路徑。ctx.body就是返回給瀏覽器的內容文件。這裡的數據是一個stream,需要轉換成字符串來處理。

const stream2string = (stream) => {
    return new Promise((resolve, reject) => {
        const chunks = [];
        stream.on('data', chunk => {chunks.push(chunk)})
        stream.on('end', () => { resolve(Buffer.concat(chunks).toString('utf-8'))})
        stream.on('error', reject)
    })
}

// 修改第三方模塊路徑
app.use(async (ctx, next) => {
    if (ctx.type === 'application/javascript') {
        const contents = await stream2string(ctx.body);
        // 將body中導入的路徑修改一下,重新賦值給body返回給瀏覽器
        // import vue from 'vue', 匹配到from '修改為from '@modules/
        ctx.body = contents.replace(/(from\s+['"])(?![\.\/])/g, '$1/@modules/');
    }
})

接著開始加載第三方模塊, 這裡同樣需要一個中間件,判斷請求路徑是否是修改過的@module開頭,如果是的話就去node_modules裡面加載對應的模塊返回給瀏覽器。這個中間件要放在靜態服務器之前。

// 加載第三方模塊
app.use(async (ctx, next) => {
    if (ctx.path.startsWith('/@modules/')) {
        // 截取模塊名稱
        const moduleName = ctx.path.substr(10);
    }
})

拿到模塊名稱之後需要獲取模塊的入口文件,這裡要獲取的是ES Module模塊的入口文件,需要先找到這個模塊的package.json然後再獲取這個package.json中的module字段的值也就是入口文件。

// 找到模塊路徑
const pkgPath = path.join(process.pwd(), 'node_modules', moduleName, 'package.json');
const pkg = require(pkgPath);
// 重新給ctx.path賦值,需要重新設置一個存在的路徑,因為之前的路徑是不存在的
ctx.path = path.join('/node_modules', moduleName, pkg.module);
// 執行下一個中間件
awiat next();

這樣瀏覽器請求進來的時候雖然是@modules路徑,但是在加載之前將path路徑修改為瞭node_modules中的路徑,這樣在加載的時候就會去node_modules中獲取文件,將加載的內容響應給瀏覽器。

加載第三方模塊:

app.use(async (ctx, next) => {
    if (ctx.path.startsWith('/@modules/')) {
        // 截取模塊名稱
        const moduleName = ctx.path.substr(10);
        // 找到模塊路徑
        const pkgPath = path.join(process.pwd(), 'node_modules', moduleName, 'package.json');
        const pkg = require(pkgPath);
        // 重新給ctx.path賦值,需要重新設置一個存在的路徑,因為之前的路徑是不存在的
        ctx.path = path.join('/node_modules', moduleName, pkg.module);
        // 執行下一個中間件
        awiat next();
    }
})

4. 單文件組件處理

之前說過瀏覽器是沒辦法處理.vue資源的, 瀏覽器隻能識別js、css等常用資源,所以其他類型的資源都需要在服務端處理。當請求單文件組件的時候需要在服務器將單文件組件編譯成js模塊返回給瀏覽器。

所以這裡當瀏覽器第一次請求App.vue的時候,服務器會把單文件組件編譯成一個對象,先加載這個組件,然後再創建一個對象。

import Hello from './src/components/Hello.vue'
const __script = {
    name: "App",
    components: {
        Hello
    }
}

接著再去加載入口文件,這次會告訴服務器編譯一下這個單文件組件的模板,返回一個render函數。然後將render函數掛載到剛創建的組件選項對象上,最後導出選項對象。

import { render as __render } from '/src/App.vue?type=template'
__script.render = __render
__script.__hmrId = '/src/App.vue'
export default __script

也就是說vite會發送兩次請求,第一次請求會編譯單文件文件,第二次請求是編譯單文件模板返回一個render函數。

編譯單文件選項:

首先來實現一下第一次請求單文件的情況。需要把單文件組件編譯成一個選項,這裡同樣用一個中間件來實現。這個功能要在處理靜態服務器之後,處理第三方模塊路徑之前。

首先需要對單文件組件進行編譯需要借助compiler-sfc

// 處理單文件組件
app.use(async (ctx, next) => {
    if (ctx.path.endsWith('.vue')) {
        // 獲取響應文件內容,轉換成字符串
        const contents = await streamToString(ctx.body);
        // 編譯文件內容
        const { descriptor } = compilerSFC.parse(contents);
        // 定義狀態碼
        let code;
        // 不存在type就是第一次請求
        if (!ctx.query.type) {
            code = descriptor.script.content;
            // 這裡的code格式是, 需要改造成我們前面貼出來的vite中的樣子
            // import Hello from './components/Hello.vue'
            // export default {
            //      name: 'App',
            //      components: {
            //          Hello
            //      }
            //  }
            // 改造code的格式,將export default 替換為const __script =
            code = code.relace(/export\s+default\s+/g, 'const __script = ')
            code += `
                import { render as __render } from '${ctx.path}?type=template'
                __script.rener = __render
                export default __script
            `
        }
        // 設置瀏覽器響應頭為js
        ctx.type = 'application/javascript'
        // 將字符串轉換成數據流傳給下一個中間件。
        ctx.body = stringToStream(code);
    }
    await next()
})

const stringToStream = text => {
    const stream = new Readable();
    stream.push(text);
    stream.push(null);
    return stream;
}
npm install @vue/compiler-sfc -D

接著我們再來處理單文件組件的第二次請求,第二次請求url會帶上type=template參數,需要將單文件組件模板編譯成render函數。

首先需要判斷當前請求中有沒有type=template

if (!ctx.query.type) {
    ...
} else if (ctx.query.type === 'template') {
    // 獲取編譯後的對象 code就是render函數
    const templateRender = compilerSFC.compileTemplate({ source: descriptor.template.content })
    // 將render函數賦值給code返回給瀏覽器
    code = templateRender.code
}

這裡還要處理一下工具中的process.env,因為這些代碼會返回到瀏覽器中運行,如果不處理會默認為node導致運行失敗。可以在修改第三方模塊路徑的中間件中修改,修改完路徑之後再添加一條修改process.env。

// 修改第三方模塊路徑
app.use(async (ctx, next) => {
    if (ctx.type === 'application/javascript') {
        const contents = await stream2string(ctx.body);
        // 將body中導入的路徑修改一下,重新賦值給body返回給瀏覽器
        // import vue from 'vue', 匹配到from '修改為from '@modules/
        ctx.body = contents.replace(/(from\s+['"])(?![\.\/])/g, '$1/@modules/').replace(/process\.env\.NODE_ENV/g, '"development"');
    }
})

至此就實現瞭一個簡版的vite,當然這裡我們隻演示瞭.vue文件,對於css,less等其他資源都沒有處理,不過方法都是類似的,感興趣的同學可以自行實現。

#!/usr/bin/env node

const path = require('path')
const { Readable } = require('stream)
const Koa = require('koa')
const send = require('koa-send')
const compilerSFC = require('@vue/compiler-sfc')

const app = new Koa()

const stream2string = (stream) => {
    return new Promise((resolve, reject) => {
        const chunks = [];
        stream.on('data', chunk => {chunks.push(chunk)})
        stream.on('end', () => { resolve(Buffer.concat(chunks).toString('utf-8'))})
        stream.on('error', reject)
    })
}

const stringToStream = text => {
    const stream = new Readable();
    stream.push(text);
    stream.push(null);
    return stream;
}

// 加載第三方模塊
app.use(async (ctx, next) => {
    if (ctx.path.startsWith('/@modules/')) {
        // 截取模塊名稱
        const moduleName = ctx.path.substr(10);
        // 找到模塊路徑
        const pkgPath = path.join(process.pwd(), 'node_modules', moduleName, 'package.json');
        const pkg = require(pkgPath);
        // 重新給ctx.path賦值,需要重新設置一個存在的路徑,因為之前的路徑是不存在的
        ctx.path = path.join('/node_modules', moduleName, pkg.module);
        // 執行下一個中間件
        awiat next();
    }
})

// 開啟靜態文件服務器
app.use(async (ctx, next) => {
    // 加載靜態文件
    await send(ctx, ctx.path, { root: process.cwd(), index: 'index.html'})
    await next()
})

// 處理單文件組件
app.use(async (ctx, next) => {
    if (ctx.path.endsWith('.vue')) {
        // 獲取響應文件內容,轉換成字符串
        const contents = await streamToString(ctx.body);
        // 編譯文件內容
        const { descriptor } = compilerSFC.parse(contents);
        // 定義狀態碼
        let code;
        // 不存在type就是第一次請求
        if (!ctx.query.type) {
            code = descriptor.script.content;
            // 這裡的code格式是, 需要改造成我們前面貼出來的vite中的樣子
            // import Hello from './components/Hello.vue'
            // export default {
            //      name: 'App',
            //      components: {
            //          Hello
            //      }
            //  }
            // 改造code的格式,將export default 替換為const __script =
            code = code.relace(/export\s+default\s+/g, 'const __script = ')
            code += `
                import { render as __render } from '${ctx.path}?type=template'
                __script.rener = __render
                export default __script
            `
        } else if (ctx.query.type === 'template') {
            // 獲取編譯後的對象 code就是render函數
            const templateRender = compilerSFC.compileTemplate({ source: descriptor.template.content })
            // 將render函數賦值給code返回給瀏覽器
            code = templateRender.code
        }
        // 設置瀏覽器響應頭為js
        ctx.type = 'application/javascript'
        // 將字符串轉換成數據流傳給下一個中間件。
        ctx.body = stringToStream(code);
    }
    await next()
})

// 修改第三方模塊路徑
app.use(async (ctx, next) => {
    if (ctx.type === 'application/javascript') {
        const contents = await stream2string(ctx.body);
        // 將body中導入的路徑修改一下,重新賦值給body返回給瀏覽器
        // import vue from 'vue', 匹配到from '修改為from '@modules/
        ctx.body = contents.replace(/(from\s+['"])(?![\.\/])/g, '$1/@modules/').replace(/process\.env\.NODE_ENV/g, '"development"');
    }
})

app.listen(5000)

console.log('服務器已經啟動 http://localhost:5000')

到此這篇關於學習Vite的原理的文章就介紹到這瞭,更多相關Vite原理內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: