前言
一切要都要從打包構建說起。
當下我們很多專案都是基於 webpack 構建的, 主要用於:
- 本地開發
- 打包上線
首先,webpack 是一個偉大的工具。
經過不斷的完善,webpack 以及周邊的各種輪子已經能很好的滿足我們的日常開發需求。
我們都知道,webpack 具備將各類資源打包整合在一起,形成 bundle 的能力。
可是,當資源越來越多時,打包的時間也將越來越長。
一箇中大型的專案, 啟動構建的時間能達到數分鐘之久。
拿我的專案為例, 初次構建大概需要三分鐘, 而且這個時間會隨著系統的迭代越來越長。
相信不少同學也都遇到過類似的問題。 打包時間太久,這是一個讓人很難受的事情。
那有沒有什麼辦法來解決呢?
當然是有的。
這就是今天的主角 ESM
, 以及以它為基礎的各類構建工具, 比如:
等等。
今天,我們就這個話題展開討論, 希望能給大家一些其發和幫助。
文章較長,提供一個傳送門:
正文
什麼是 ESM
ESM 是理論基礎, 我們都需要了解。
「 ESM 」 全稱 ECMAScript modules,基本主流的瀏覽器版本都以已經支援。
ESM 是如何工作的
當使用ESM 模式時, 瀏覽器會構建一個依賴關係圖。不同依賴項之間的連線來自你使用的匯入語句。
通過這些匯入語句, 瀏覽器 或 Node 就能確定載入程式碼的方式。
通過指定一個入口檔案,然後從這個檔案開始,通過其中的import語句,查詢其他程式碼。
通過指定的檔案路徑, 瀏覽器就找到了目的碼檔案。 但是瀏覽器並不能直接使用這些檔案,它需要解析所有這些檔案,以將它們轉換為稱為模組記錄的資料結構。
然後,需要將 模組記錄
轉換為 模組例項
。
模組例項
, 實際上是 「 程式碼
」(指令列表)與「 狀態
」(所有變數的值)的組合。
對於整個系統而言, 我們需要的是每個模組的模組例項。
模組載入的過程將從入口檔案變為具有完整的模組例項圖。
對於ES模組,這分為 三個步驟
:
- 構造—查詢,下載所有檔案並將其解析為模組記錄。
- 例項化—查詢記憶體中的框以放置所有匯出的值(但尚未用值填充它們)。然後使匯出和匯入都指向記憶體中的那些框,這稱為連結。
- 執行—執行程式碼以將變數的實際值填充到框中。
在構建階段時, 發生三件事情:
- 找出從何處下載包含模組的檔案
- 提取檔案(通過從URL下載檔案或從檔案系統載入檔案)
- 將檔案解析為模組記錄
1. 查詢
首先,需要找到入口點檔案。
在HTML中,可以通過指令碼標記告訴載入程式在哪裡找到它。
但是,如何找到下一組模組, 也就是 main.js
直接依賴的模組呢?
這就是匯入語句的來源。
匯入語句的一部分稱為模組說明符, 它告訴載入程式可以在哪裡找到每個下一個模組。
在解析檔案之前,我們不知道模組需要獲取哪些依賴項,並且在提取檔案之前,也無法解析檔案。
這意味著我們必須逐層遍歷樹,解析一個檔案,然後找出其依賴項,然後查詢並載入這些依賴項。
如果主執行緒要等待這些檔案中的每個檔案下載,則許多其他任務將堆積在其佇列中。
那是因為當瀏覽器中工作時,下載部分會花費很長時間。
這樣阻塞主執行緒會使使用模組的應用程式使用起來太慢。
這是ES模組規範將演算法分為多個階段的原因之一。
將構造分為自己的階段,使瀏覽器可以在開始例項化的同步工作之前獲取檔案並建立對模組圖的理解。
這種方法(演算法分為多個階段)是 ESM
和 CommonJS模組
之間的主要區別之一。
CommonJS可以做不同的事情,因為從檔案系統載入檔案比通過Internet下載花費的時間少得多。
這意味著Node可以在載入檔案時阻止主執行緒。
並且由於檔案已經載入,因此僅例項化和求值(在CommonJS中不是單獨的階段)是有意義的。
這也意味著在返回模組例項之前,需要遍歷整棵樹,載入,例項化和評估任何依賴項。
在具有CommonJS模組的Node中,可以在模組說明符中使用變數。
require
在尋找下一個模組之前,正在執行該模組中的所有程式碼。這意味著當進行模組解析時,變數將具有一個值。
但是,使用ES模組時,需要在進行任何評估之前預先建立整個模組圖。
這意味著不能在模組說明符中包含變數,因為這些變數還沒有值。
但是,有時將變數用於模組路徑確實很有用。
例如,你可能要根據程式碼在做什麼,或者在不同環境中執行來記載不同的模組。
為了使ES模組成為可能,有一個建議叫做動態匯入。有了它,您可以使用類似的匯入語句:
import(`${path}/foo.js`)
。
這種工作方式是將使用載入的任何檔案import()
作為單獨圖的入口點進行處理。
動態匯入的模組將啟動一個新圖,該圖將被單獨處理。
但是要注意一件事–這兩個圖中的任何模組都將共享一個模組例項。
這是因為載入程式會快取模組例項。對於特定全域性範圍內的每個模組,將只有一個模組例項。
這意味著發動機的工作量更少。
例如,這意味著即使多個模組依賴該模組檔案,它也只會被提取一次。(這是快取模組的一個原因。我們將在評估部分中看到另一個原因。)
載入程式使用稱為模組對映的內容來管理此快取。每個全域性變數在單獨的模組圖中跟蹤其模組。
當載入程式獲取一個URL時,它將把該URL放入模組對映中,並記下它當前正在獲取檔案。然後它將發出請求並繼續以開始獲取下一個檔案。
如果另一個模組依賴於同一檔案會怎樣?載入程式將在模組對映中查詢每個URL。如果在其中看到fetching
,它將繼續前進到下一個URL。
但是模組圖不僅跟蹤正在獲取的檔案。模組對映還充當模組的快取,如下所示。
2. 解析
現在我們已經獲取了該檔案,我們需要將其解析為模組記錄。這有助於瀏覽器瞭解模組的不同部分。
建立模組記錄後,它將被放置在模組圖中。這意味著無論何時從此處請求,載入程式都可以將其從該對映中拉出。
解析中有一個細節看似微不足道,但實際上有很大的含義。
解析所有模組,就像它們"use strict"
位於頂部一樣。還存在其他細微差異。
例如,關鍵字await
是在模組的頂級程式碼保留,的值this
就是undefined
。
這種不同的解析方式稱為“解析目標”。如果解析相同的檔案但使用不同的目標,那麼最終將得到不同的結果。
因此,需要在開始解析之前就知道要解析的檔案型別是否是模組。
在瀏覽器中,這非常簡單。只需放入type="module"
的script標籤。
這告訴瀏覽器應將此檔案解析為模組。並且由於只能匯入模組,因此瀏覽器知道任何匯入也是模組。
但是在Node中,您不使用HTML標記,因此無法選擇使用type
屬性。社群嘗試解決此問題的一種方法是使用 .mjs
擴充套件。使用該副檔名告訴Node,“此檔案是一個模組”。您會看到人們在談論這是解析目標的訊號。目前討論仍在進行中,因此尚不清楚Node社群最終決定使用什麼訊號。
無論哪種方式,載入程式都將確定是否將檔案解析為模組。如果它是一個模組並且有匯入,則它將重新開始該過程,直到提取並解析了所有檔案。
我們完成了!在載入過程結束時,您已經從只有入口點檔案變為擁有大量模組記錄。
下一步是例項化此模組並將所有例項連結在一起。
3. 例項化
就像我之前提到的,例項將程式碼與狀態結合在一起。
該狀態存在於記憶體中,因此例項化步驟就是將所有事物連線到記憶體。
首先,JS引擎建立一個模組環境記錄。這將管理模組記錄的變數。然後,它將在記憶體中找到所有匯出的框。模組環境記錄將跟蹤與每個匯出關聯的記憶體中的哪個框。
記憶體中的這些框尚無法獲取其值。只有在評估之後,它們的實際值才會被填寫。該規則有一個警告:在此階段中初始化所有匯出的函式宣告。這使評估工作變得更加容易。
為了例項化模組圖,引擎將進行深度優先的後順序遍歷。這意味著它將下降到圖表的底部-底部的不依賴其他任何內容的依賴項-並設定其匯出。
引擎完成了模組下面所有出口的接線-模組所依賴的所有出口。然後,它返回一個級別,以連線來自該模組的匯入。
請注意,匯出和匯入均指向記憶體中的同一位置。首先連線出口,可以確保所有進口都可以連線到匹配的出口。
這不同於CommonJS模組。在CommonJS中,整個匯出物件在匯出時被複制。這意味著匯出的任何值(例如數字)都是副本。
這意味著,如果匯出模組以後更改了該值,則匯入模組將看不到該更改。
相反,ES模組使用稱為實時繫結的東西。兩個模組都指向記憶體中的相同位置。這意味著,當匯出模組更改值時,該更改將顯示在匯入模組中。
匯出值的模組可以隨時更改這些值,但是匯入模組不能更改其匯入的值。話雖如此,如果模組匯入了一個物件,則它可以更改該物件上的屬性值。
之所以擁有這樣的實時繫結,是因為您可以在不執行任何程式碼的情況下連線所有模組。當您具有迴圈依賴性時,這將有助於評估,如下所述。
因此,在此步驟結束時,我們已連線了所有例項以及匯出/匯入變數的儲存位置。
現在我們可以開始評估程式碼,並用它們的值填充這些記憶體位置。
4. 執行
最後一步是將這些框填充到記憶體中。JS引擎通過執行頂級程式碼(函式外部的程式碼)來實現此目的。
除了僅在記憶體中填充這些框外,評估程式碼還可能觸發副作用。例如,模組可能會呼叫伺服器。
由於存在潛在的副作用,您只需要評估模組一次。與例項化中發生的連結可以完全相同的結果執行多次相反,評估可以根據您執行多少次而得出不同的結果。
這是擁有模組對映的原因之一。模組對映通過規範的URL快取模組,因此每個模組只有一個模組記錄。這樣可以確保每個模組僅執行一次。與例項化一樣,這是深度優先的後遍歷。
那我們之前談到的那些週期呢?
在迴圈依賴關係中,您最終在圖中有一個迴圈。通常,這是一個漫長的迴圈。但是為了解釋這個問題,我將使用一個簡短的迴圈的人為例子。
讓我們看一下如何將其與CommonJS模組一起使用。首先,主模組將執行直到require語句。然後它將去載入計數器模組。
然後,計數器模組將嘗試message
從匯出物件進行訪問。但是由於尚未在主模組中對此進行評估,因此它將返回undefined。JS引擎將在記憶體中為區域性變數分配空間,並將其值設定為undefined。
評估一直持續到計數器模組頂級程式碼的末尾。我們想看看我們是否最終將獲得正確的訊息值(在評估main.js之後),因此我們設定了超時時間。然後評估在上恢復main.js
。
訊息變數將被初始化並新增到記憶體中。但是由於兩者之間沒有連線,因此在所需模組中它將保持未定義狀態。
如果使用實時繫結處理匯出,則計數器模組最終將看到正確的值。到超時執行時,main.js
的評估將完成並填寫值。
支援這些迴圈是ES模組設計背後的重要理由。正是這種設計使它們成為可能。
(以上是關於 ESM 的理論介紹, 原文連結在文末)。
Bundle & Bundleless
談及 Bundleless 的優勢,首先是啟動快。
因為不需要過多的打包,只需要處理修改後的單個檔案,所以響應速度是 O(1) 級別,重新整理即可即時生效,速度很快。
所以, 在開發模式下,相比於Bundle,Bundleless 有著巨大的優勢。
基於 Webpack 的 bundle 開發模式
上面的圖具體的模組載入機制可以簡化為下圖:
在專案啟動和有檔案變化時重新進行打包,這使得專案的啟動和二次構建都需要做較多的事情,相應的耗時也會增長。
基於 ESModule 的 Bundleless 模式
從上圖可以看到,已經不再有一個構建好的 bundle、chunk 之類的檔案,而是直接載入本地對應的檔案。
從上圖可以看到,在 Bundleless 的機制下,專案的啟動只需要啟動一個伺服器承接瀏覽器的請求即可,同時在檔案變更時,也只需要額外處理變更的檔案即可,其他檔案可直接在快取中讀取。
對比總結
Bundleless 模式可以充分利用瀏覽器自主載入的特性,跳過打包的過程,使得我們能在專案啟動時獲取到極快的啟動速度,在本地更新時只需要重新編譯單個檔案。
實現一個乞丐版 Vite
Vite 也是基於 ESM 的, 檔案處理速度 O(1)級別, 非常快。
作為探索, 我就簡單實現了一個乞丐版Vite:
GitHub 地址: Vite-mini,
簡要分析一下。
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
html 檔案中直接使用了瀏覽器原生的 ESM(type="module"
) 能力。
所有的 js 檔案經過 vite 處理後,其 import 的模組路徑都會被修改,在前面加上 /@modules/
。當瀏覽器請求 import 模組的時候,vite 會在 node_modules
中找到對應的檔案進行返回。
其中最關鍵的步驟就是模組的記載和解析
, 這裡我簡單用koa簡單實現了一下, 整體結構:
const fs = require('fs');
const path = require('path');
const Koa = require('koa');
const compilerSfc = require('@vue/compiler-sfc');
const compileDom = require('@vue/compiler-dom');
const app = new Koa();
// 處理引入路徑
function rewriteImport(content) {
// ...
}
// 處理檔案型別等, 比如支援ts, less 等類似webpack的loader的功能
app.use(async (ctx) => {
// ...
}
app.listen(3001, () => {
console.log('3001');
});
我們先看路徑相關的處理:
function rewriteImport(content) {
return content.replace(/from ['"]([^'"]+)['"]/g, function (s0, s1) {
// import a from './c.js' 這種格式的不需要改寫
// 只改寫需要去node_module找的
if (s1[0] !== '.' && s1[0] !== '/') {
return `from '/@modules/${s1}'`;
}
return s0;
});
}
處理檔案內容: 原始碼地址
後續的都是類似的:
這個程式碼只是解釋實現原理, 不同的檔案型別處理邏輯其實可以抽離出去, 以中介軟體的形式去處理。
程式碼實現的比較簡單, 就不額解釋了。
Snowpack
和 webpack 的對比:
我使用 Snowpack 做了個 demo , 支援打包, 輸出 bundle。
github: Snowpack-React-Demo
能夠清晰的看到, 控制檯產生了大量的檔案請求(也叫瀑布網路請求),
不過因為都是載入的本地檔案, 所以速度很快。
配合HMR, 實現編輯完成立刻生效, 幾乎不用等待:
但是如果是在生產中,這些請求對於生產中的頁面載入時間而言, 就不太好了。
尤其是HTTP1.1,瀏覽器都會有並行下載的上限,大部分是5個左右,所以如果你有60個依賴性要下載,就需要等好長一點。
雖然說HTTP2多少可以改善這問題,但若是東西太多,依然沒辦法。
關於這個專案的打包, 直接執行build:
打包完成後的檔案目錄,和傳統的 webpack 基本一致:
在 build 目錄下啟動一個靜態檔案服務:
build 模式下,還是藉助了 webpack 的打包能力:
做了資源合併:
就這點而言, 我認為未來一段時間內, 生產環境還是不可避免的要走bundle模式。
bundleless 模式在實際開發中的一些問題
開門見山吧, 開發體驗不是很友好,幾點比較突出的問題:
- 部分模組沒有提供 ESModule 的包。(這一點尤為致命)
- 生態不夠健全,工具鏈不夠完善;
當然還有其他方方面面的問題, 就不一一列舉。
我簡單改造了一個頁面, 就遇到很多奇奇怪怪的問題, 開發起來十分難受, 儘管程式碼的修改能立刻生效。
結論
bundleless 能在開發模式下帶了很大的便利。 但就目前來說,要運用到生產的話, 還是有一段路要走的。
就目當下而言, 如果真的要用的話,可能還是 bundleless(dev) + bundle(production) 的組合。
至於未來能不能全面鋪開 bundleless,我認為還是有可能的, 交給時間吧。
結尾
本文主要介紹了 esm 的原理, 以及介紹了以此為基礎的Vite, Snowpack 等工具, 提供了兩個可執行的 demo:
並探索了 bundleless 在生產中的可行性。
Bundleless, 本質上是將原先 Webpack 中模組依賴解析的工作交給瀏覽器去執行,使得在開發過程中程式碼的轉換變少,極大地提升了開發過程中的構建速度,同時也可以更好地利用瀏覽器的相關開發工具。
最後,也非常感謝 ESModule、Vite、Snowpack 等標準和工具的出現,為前端開發提效。
才疏學淺, 文中若有錯誤,還能各位大佬指正, 謝謝。