vite學習與簡易實現

Vite 介紹

Vite 概念
Vite 是一個面向現代化瀏覽器的一個更輕、更快的 web 應用應用開發工具
它基于 ECMAScript 標準原生模塊系統(ES Module)實現的
它的出現是為了解決 Webpack 在開發階段,使用 webpack-dev-server 冷啟動時間過長和 Webpack MHR 熱更新反應慢的問題。

使用 Vite 創建的項目,默認就是一個普通的 Vue3應用,相比于 Vue CLI創建的項目,會少了很多文件和依賴。

Vite 的特性

  • 快速冷啟動
  • 模塊熱更新
  • 按需編譯
  • 開箱即用

Vite 項目依賴

Vite 創建的默認項目,開發依賴很少也很簡單,只包含了:

  • Vite
  • @vue/compiler-sfc(用來編譯.vue 結尾的單文件文件)

需要注意的是,Vite 目前創建的 Vue 項目只支持 3.0 的版本。在創建項目的時候,通過指定不同的模板,也可以創建其他框架的項目。

Vite 提供的命令

  • vite serve
    工作原理
    用于啟動一個開發的 web 服務器,在啟動服務器的時候不需要編譯所有的模塊啟動速度非常的快。

我們來看看下面這張圖:

工作原理.png

在運行vite serve 的時候,不需要打包,直接開啟了一個 web 服務器。當瀏覽器請求服務器時,例如是一個 css,或者是一個單文件組件,這個時候在服務器會把這個瀏覽器請求的文件先編譯,然后直接把編譯后的結果返回給瀏覽器。

這里的編譯是在服務器端,并且,模塊的處理是在請求到服務器端處理的。

我們來回顧一下,Vue CLI 創建的應用


vue cli APP.png

Vue CLI 創建的項目啟動 web 服務器用的是 vue-cli-service,當運行它的時候,它內部會使用 Webpack 去打包所有的模塊(如果模塊很多的情況下,編譯的速度會很慢),打包完成后會將編譯好的模塊存儲到內存中,然后啟動一個 web 服務器,瀏覽器請求 web 服務器,最后才會從內存中把編譯好的內容,返回到瀏覽器。

Webpack這樣的工具,它的做法是將所有的模塊提前都編譯打包進內存里,不管模塊是否被執行是否被調用,它會都打包編譯,隨著項目越來越大,打包后的內容也會越來越大,打包的速度也會越來越慢。

Vite 使用現代化瀏覽器原生支持的 ES Module 模塊化的特性,省略了模塊的打包環節。對于需要編譯的文件,例如樣式模塊和單文件組件等,vite 采用了即時編譯,也就是說當加載到這個文件的時候,才會去服務端編譯好這個文件。

所以,這種即時編譯的好處體現在按需編譯,速度會更快。

HMR

  • Vite HMR
    立即編譯當前所修改的文件
  • Webpack HMR
    會自動以這個文件為入口重新編譯一次,所有的涉及到的依賴也會被加載一次

Vite 默認也支持 HMR 模塊熱更新,相對于Webpack中的 HMR 效果會更好,因為 Webpack 的 HMR 模塊熱跟新會從你修改的文件開始全部在編譯一遍

vite build

  • Rollup
  • Dynamic import
    Polyfill
    Vite創建的項目使用 Vite build 進行生產模式的打包,這個命令內部使用過的是 Rollup 打包,最終也是把文件都打包編譯在一起。對于代碼切割的需求,Vite 內部采用的是原生的動態導入的方式實現的,所以打包的結果只能支持現代化的瀏覽器(不支持 ie)。不過相對應的 Polyfill 可以解決

是否還需要打包?
隨著Vite 的出現,我們需要考慮一個問題,是否還必要打包應用。之前我們使用Webpack 進行打包,會把所有的模塊都打包進bundle.js 中,主要有兩個原因:

  • 瀏覽器環境對原生 ES Module 的支持
  • 零零散散的模塊文件會產生大量的 HTTP 請求

但是,現在目前大部分的瀏覽器都已經支持了 ES Module。并且我們也可以使用 HTTP2 長鏈接去解決大量的 HTTP 請求。那是否還需要對應用進行打包,取決于你的團隊和項目應用的運行環境。

個人覺得這以后會是一個趨勢。

開箱即用

  • TypeScript - 內置支持
  • less/sass/stylus/postcss - 內置支持(需要單獨安裝)
  • JSX
  • Web Assemby

實現一個簡易版的 vite

接下來,我們來實現一個簡易版本的 vite,來深入理解 vite 的工作原理,分為以下五個步驟:

  • 靜態 web 服務器
  • 修改第三方模塊的路徑
  • 加載第三方模塊
  • 編譯單文件組件
  • HMR(通過 WebSocket 實現,跳過)

靜態 web 服務器

vite內部使用過的是koa 來開啟靜態服務器的,這里我們也使用 koa 來開啟一個靜態服務器,把當前運行的目錄作為靜態服務器的根目錄

創建一個名為 my-vite 的空文件夾,進入該文件夾初始化 package.json,并且安裝 koakoa-send

package.json 來配置 bin 字段:

"bin": "index.js"

新建 index.js 文件,并且在第一行配置 node 的運行環境(因為我們要開發的是一個基于 node的命令行工具,所以要指定運行node 的位置)

#!/usr/bin/env node

接下來,基于koa 啟動一個 web 靜態服務器:

#!/usr/bin/env node
const Koa = require('koa')
const send = require('koa-send')

const app = new Koa()

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

app.listen(3000)
console.log('Serve running @ http://localhost:3000')

接著,使用 npm link 到全局,然后打開一個使用 vue3 寫的項目(可以用 vite 創建一個默認項目),進入命令行終端,輸入 myvite。如果沒有報錯的話,會打印出"Serve running @ http://localhost:3000"這句話,我們打開瀏覽器,打開這個網址。

不過是一片空白的,接著我們打開 F12,會看到一個報錯,報錯的信息的意思是,解析 vue 模塊的時候失敗了,使用 import 導入模塊的時候,模塊的開頭必須是"/", "./", or "../"這三種其中的一個。

我們來做一個對比,我們把使用vite 創建的項目啟動后, vite-cli 創建的項目啟動后的 main.js 在瀏覽器響應中的區別

myvite
vite-cli

通過上面兩幅圖的對比,你會發現,vite 它會處理這個模塊引入的路徑,它會加載一個不存在的路徑@modules,并且請求這個路徑的 js 文件也是可以請求成功的。

這是 vite 創建的項目啟動后的 vue.js 的請求,觀察響應頭中的 Content-Type字段,他是 application/javascript;所以我們可以通過這個類型,在返回的時候去處理這個js 中的第三方路徑問題。

修改第三方模塊的路徑

通過上面的觀察和理解,我們得出一個思路,可以把不是"/", "./", or "../"開頭的引用,全部替換成“/@modules/”。

我們創建多一個中間件,用來做這件事情。

// 2.修改第三方模塊的路徑
app.use(async (ctx, next) => {
  // 判斷瀏覽器請求的文件類型,如果是js文件,在這里進行解析。
  if (ctx.type === 'application/javascript') {
    //將流轉化成字符串
    const contents = await streamToString(ctx.body)
    // 在js的import當中,只會出現以下的幾種情況:
    // 1、import vue from 'vue'
    // 2、import App from '/App.vue'
    // 3、import App from './App.vue'
    // 4、import App from '../App.vue'
    // 2、3、4這三種情況,現代化瀏覽器都可以識別,只有第一種情況不能識別,這里只處理第一種情況
    // 思路是用正則匹配到 (from ') 或者 是 (from ") 開頭,替換成"/@modules/"

    /**
     * 這里進行分組的全局匹配
     * 第一個分組匹配以下內容:
     *  from 匹配 from
     *  \s+ 匹配空格
     *  ['"]匹配單引號或者是雙引號
     * 第二個分組匹配以下內容:
     *  ?! 不匹配這個分組的結果
     *  \.\/ 匹配 ./
     *  \.\.\/ 匹配 ../
     * $1表示第一個分組的結果
     */
    ctx.body = contents.replace(
      /(from\s+['"])(?![\.\/\.\.\\/])/g,
      '$1/@modules/'
    )
  }
})

// 將流轉化成字符串,是一個異步線程,返回一個promise
const streamToString = (stream) =>
  new Promise((resolve, reject) => {
    // 用于存儲讀取到的buffer
    const chunks = []
    //監聽讀取到buffer,并存儲到chunks數組中
    stream.on('data', (chunk) => chunks.push(chunk))
    //當數據讀取完畢之后,把結果返回給resolve,這里需要把讀取到的buffer合并并且轉換為字符串
    stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
    //如果讀取buffer失敗,返回reject
    stream.on('error', reject)
  })

加載第三方模塊

現在我們要做的是,將 /@modules/開頭的引用,去 node_modules 中找到并且替換它的返回內容。我們需要在創建一個中間件,這個中間件需要在創建靜態服務器之前被調用。

// 3.加載第三方模塊
app.use(async (ctx, next) => {
  // ctx.path --> /@modules/vue
  // 判斷第三方模塊的路徑是否有/@modules/開頭
  if (ctx.path.startsWith('/@modules/')) {
    // 對字符串進行截取,獲取到模塊名稱
    const moduleName = ctx.path.substr(10)
    // 找到該模塊名稱在node_moduls中的package.json路徑
    const pkgPath = path.join(
      process.cwd(),
      'node_modules',
      moduleName,
      'package.json'
    )
    // 通過require加載當前package.json
    const pkg = require(pkgPath)
    // 將內容替換成node_modules中的內容
    ctx.path = path.join('/node_modules', moduleName, pkg.module)
  }
  // 返回執行下一個中間件
  await next()
})

編寫完后,我們需要重新啟動一下服務器,啟動完成后,我們重新打開 network網絡面板,看看 vue 這個模塊是否被加載了進來。


我們看到,vue 這個模塊已經被加載進來了。

但是,我們發現 /@modules/@vue/runtime-dom/@modules/@vue/shared 卻沒有被加載進來,并且控制臺卻報了兩個錯誤:加載模塊App.vueindex.css失敗

編譯單文件組件

我們先觀察一下,原本的 vite 啟動后,sfc 單文件夾組件的請求是如何處理的,


編譯單文件.png

我們來看 app.vue 的響應內容,它引入了一些組件,然后把它編譯成一個選項對象,然后它又去加載了app.vue 并且在后面加上了一個參數 type=template,并且解構出了一個 render函數,然后把 render 函數掛載到選項對象上,然后又設置了兩個屬性(這兩個屬性不模擬),最后導出這個選項對象。

從這段代碼我們可以觀察到,當請求到單文件組件的時候,服務器會來編譯這個單文件組件,并把相對應的結果返回給瀏覽器。

我們在來編寫一個中間件,在編寫中間件的時候,我們需要安裝一個模塊 @vue/compiler-sfc并且導入,這個模塊的作用主要是編譯單文件組件的。

代碼如下:

// 4. 處理單文件組件
app.use(async (ctx, next) => {
  // 當請求的文件是單文件組件的時候,就是.vue結尾的時候
  if (ctx.path.endsWith('.vue')) {
    // 獲取文件內容,它的內容是一個流,需要轉換為字符串
    const contents = await streamToString(ctx.body)
    // compilerSFC.parse用來編譯單文件組件,它返回一個對象,它有兩個成員 descriptor、errors
    const { descriptor } = compilerSFC.parse(contents)
    // 最終返回瀏覽器的內容
    let code
    // 第一次請求,沒有參數的時候,就是沒有帶type的時候
    if (!ctx.query.type) {
      // 第一次請求,把單文件組件編譯成一個對象
      code = descriptor.script.content
      code = code.replace(/export\s+default\s+/g, 'const __script = ')
      code += `
        import { render as __render } from "${ctx.path}?type=template"
        __script.render = __render
        export default __script
      `
    }
    // 第二次請求,參數中是否有type參數,并且是否是template
    else if (ctx.query.type === 'template') {
      // compilerSFC.compileTemplate 編譯模板
      const templateRender = compilerSFC.compileTemplate({
        // 編譯內容
        source: descriptor.template.content,
      })
      code = templateRender.code
    }
    // 設置文件類型
    ctx.type = 'application/javascript'
    // 轉化成流
    ctx.body = stringToStream(code)
  }
  await next()
})

然后,重啟一下服務,需要注意的是,需要把圖片和其他和 js 或者 vue 無關的文件都注釋掉,因為我們這里只處理了vue 文件。

源碼

#!/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()

// 3.加載第三方模塊
app.use(async (ctx, next) => {
  // ctx.path --> /@modules/vue
  // 判斷第三方模塊的路徑是否有/@modules/開頭
  if (ctx.path.startsWith('/@modules/')) {
    // 對字符串進行截取,獲取到模塊名稱
    const moduleName = ctx.path.substr(10)
    // 找到該模塊名稱在node_moduls中的package.json路徑
    const pkgPath = path.join(
      process.cwd(),
      'node_modules',
      moduleName,
      'package.json'
    )
    // 通過require加載當前package.json
    const pkg = require(pkgPath)
    // 將內容替換成node_modules中的內容
    ctx.path = path.join('/node_modules', moduleName, pkg.module)
  }
  await next()
})

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

// 4. 處理單文件組件
app.use(async (ctx, next) => {
  // 當請求的文件是單文件組件的時候,就是.vue結尾的時候
  if (ctx.path.endsWith('.vue')) {
    // 獲取文件內容,它的內容是一個流,需要轉換為字符串
    const contents = await streamToString(ctx.body)
    // compilerSFC.parse用來編譯單文件組件,它返回一個對象,它有兩個成員 descriptor、errors
    const { descriptor } = compilerSFC.parse(contents)
    // 最終返回瀏覽器的內容
    let code
    // 第一次請求,沒有參數的時候,就是沒有帶type的時候
    if (!ctx.query.type) {
      // 第一次請求,把單文件組件編譯成一個對象
      code = descriptor.script.content
      code = code.replace(/export\s+default\s+/g, 'const __script = ')
      code += `
        import { render as __render } from "${ctx.path}?type=template"
        __script.render = __render
        export default __script
      `
    }
    // 第二次請求,參數中是否有type參數,并且是否是template
    else if (ctx.query.type === 'template') {
      // compilerSFC.compileTemplate 編譯模板
      const templateRender = compilerSFC.compileTemplate({
        // 編譯內容
        source: descriptor.template.content,
      })
      code = templateRender.code
    }
    // 設置文件類型
    ctx.type = 'application/javascript'
    // 轉化成流
    ctx.body = stringToStream(code)
  }
  await next()
})

// 2.修改第三方模塊的路徑
app.use(async (ctx, next) => {
  // 判斷瀏覽器請求的文件類型,如果是js文件,在這里進行解析。
  if (ctx.type === 'application/javascript') {
    //將流轉化成字符串
    const contents = await streamToString(ctx.body)
    // 在js的import當中,只會出現以下的幾種情況:
    // 1、import vue from 'vue'
    // 2、import App from '/App.vue'
    // 3、import App from './App.vue'
    // 4、import App from '../App.vue'
    // 2、3、4這三種情況,現代化瀏覽器都可以識別,只有第一種情況不能識別,這里只處理第一種情況
    // 思路是用正則匹配到 (from ') 或者 是 (from ") 開頭,替換成"/@modules/"

    /**
     * 這里進行分組的全局匹配
     * 第一個分組匹配以下內容:
     *  from 匹配 from
     *  \s+ 匹配空格
     *  ['"]匹配單引號或者是雙引號
     * 第二個分組匹配以下內容:
     *  ?! 不匹配這個分組的結果
     *  \.\/ 匹配 ./
     *  \.\.\/ 匹配 ../
     * $1表示第一個分組的結果
     */
    ctx.body = contents
      .replace(/(from\s+['"])(?![\.\/\.\.\\/])/g, '$1/@modules/')
      .replace(/process\.env\.NODE_ENV/g, '"development"') // 替換process對象
  }
})

// 將流轉化成字符串,是一個異步線程,返回一個promise
const streamToString = (stream) =>
  new Promise((resolve, reject) => {
    // 用于存儲讀取到的buffer
    const chunks = []
    //監聽讀取到buffer,并存儲到chunks數組中
    stream.on('data', (chunk) => chunks.push(chunk))
    //當數據讀取完畢之后,把結果返回給resolve,這里需要把讀取到的buffer合并并且轉換為字符串
    stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
    //如果讀取buffer失敗,返回reject
    stream.on('error', reject)
  })

// 將字符串轉化成流
const stringToStream = (text) => {
  const stream = new Readable()
  stream.push(text)
  stream.push(null)
  return stream
}

app.listen(3000)
console.log('Serve running @ http://localhost:3000')
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,316評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,481評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 176,241評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,939評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,697評論 6 409
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,182評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,247評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,406評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,933評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,772評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,973評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,516評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,209評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,638評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,866評論 1 285
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,644評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,953評論 2 373