一切要都要從打包構建提及。html
當下咱們不少項目都是基於 webpack 構建的, 主要用於:前端
首先,webpack 是一個偉大的工具。vue
通過不斷的完善,webpack 以及周邊的各類輪子已經能很好的知足咱們的平常開發需求。node
咱們都知道,webpack 具有將各種資源打包整合在一塊兒,造成 bundle 的能力。 react
但是,當資源愈來愈多時,打包的時間也將愈來愈長。webpack
一箇中大型的項目, 啓動構建的時間能達到數分鐘之久。git
拿個人項目爲例, 初次構建大概須要三分鐘, 並且這個時間會隨着系統的迭代愈來愈長。 github
相信很多同窗也都遇到過相似的問題。 打包時間過久,這是一個讓人很難受的事情。web
那有沒有什麼辦法來解決呢?算法
固然是有的。
這就是今天的主角 ESM
, 以及以它爲基礎的各種構建工具, 好比:
等等。
今天,咱們就這個話題展開討論, 但願能給你們一些其發和幫助。
ESM 是理論基礎, 咱們都須要瞭解。
「 ESM 」 全稱 ECMAScript modules,基本主流的瀏覽器版本都以已經支持。
當使用ESM 模式時, 瀏覽器會構建一個依賴關係圖。不一樣依賴項之間的鏈接來自你使用的導入語句。
經過這些導入語句, 瀏覽器 或 Node 就能肯定加載代碼的方式。
經過指定一個入口文件,而後從這個文件開始,經過其中的import語句,查找其餘代碼。
經過指定的文件路徑, 瀏覽器就找到了目標代碼文件。 可是瀏覽器並不能直接使用這些文件,它須要解析全部這些文件,以將它們轉換爲稱爲模塊記錄的數據結構。
而後,須要將 模塊記錄
轉換爲 模塊實例
。
模塊實例
, 其實是 「 代碼
」(指令列表)與「 狀態
」(全部變量的值)的組合。
對於整個系統而言, 咱們須要的是每一個模塊的模塊實例。
模塊加載的過程將從入口文件變爲具備完整的模塊實例圖。
對於ES模塊,這分爲 三個步驟
:
在構建階段時, 發生三件事情:
首先,須要找到入口點文件。
在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。
可是模塊圖不只跟蹤正在獲取的文件。模塊映射還充當模塊的緩存,以下所示。
如今咱們已經獲取了該文件,咱們須要將其解析爲模塊記錄。這有助於瀏覽器瞭解模塊的不一樣部分。
建立模塊記錄後,它將被放置在模塊圖中。這意味着不管什麼時候今後處請求,加載程序均可以將其從該映射中拉出。
解析中有一個細節看似微不足道,但實際上有很大的含義。
解析全部模塊,就像它們"use strict"
位於頂部同樣。還存在其餘細微差別。
例如,關鍵字await
是在模塊的頂級代碼保留,的值this
就是undefined
。
這種不一樣的解析方式稱爲「解析目標」。若是解析相同的文件但使用不一樣的目標,那麼最終將獲得不一樣的結果。
所以,須要在開始解析以前就知道要解析的文件類型是不是模塊。
在瀏覽器中,這很是簡單。只需放入type="module"
的script標籤。
這告訴瀏覽器應將此文件解析爲模塊。而且因爲只能導入模塊,所以瀏覽器知道任何導入也是模塊。
可是在Node中,您不使用HTML標記,所以沒法選擇使用type
屬性。社區嘗試解決此問題的一種方法是使用 .mjs
擴展。使用該擴展名告訴Node,「此文件是一個模塊」。您會看到人們在談論這是解析目標的信號。目前討論仍在進行中,所以尚不清楚Node社區最終決定使用什麼信號。
不管哪一種方式,加載程序都將肯定是否將文件解析爲模塊。若是它是一個模塊而且有導入,則它將從新開始該過程,直到提取並解析了全部文件。
咱們完成了!在加載過程結束時,您已經從只有入口點文件變爲擁有大量模塊記錄。
下一步是實例化此模塊並將全部實例連接在一塊兒。
就像我以前提到的,實例將代碼與狀態結合在一塊兒。
該狀態存在於內存中,所以實例化步驟就是將全部事物鏈接到內存。
首先,JS引擎建立一個模塊環境記錄。這將管理模塊記錄的變量。而後,它將在內存中找到全部導出的框。模塊環境記錄將跟蹤與每一個導出關聯的內存中的哪一個框。
內存中的這些框尚沒法獲取其值。只有在評估以後,它們的實際值纔會被填寫。該規則有一個警告:在此階段中初始化全部導出的函數聲明。這使評估工做變得更加容易。
爲了實例化模塊圖,引擎將進行深度優先的後順序遍歷。這意味着它將降低到圖表的底部-底部的不依賴其餘任何內容的依賴項-並設置其導出。
引擎完成了模塊下面全部出口的接線-模塊所依賴的全部出口。而後,它返回一個級別,以鏈接來自該模塊的導入。
請注意,導出和導入均指向內存中的同一位置。首先鏈接出口,能夠確保全部進口均可以鏈接到匹配的出口。
這不一樣於CommonJS模塊。在CommonJS中,整個導出對象在導出時被複制。這意味着導出的任何值(例如數字)都是副本。
這意味着,若是導出模塊之後更改了該值,則導入模塊將看不到該更改。
相反,ES模塊使用稱爲實時綁定的東西。兩個模塊都指向內存中的相同位置。這意味着,當導出模塊更改值時,該更改將顯示在導入模塊中。
導出值的模塊能夠隨時更改這些值,可是導入模塊不能更改其導入的值。話雖如此,若是模塊導入了一個對象,則它能夠更改該對象上的屬性值。
之因此擁有這樣的實時綁定,是由於您能夠在不運行任何代碼的狀況下鏈接全部模塊。當您具備循環依賴性時,這將有助於評估,以下所述。
所以,在此步驟結束時,咱們已鏈接了全部實例以及導出/導入變量的存儲位置。
如今咱們能夠開始評估代碼,並用它們的值填充這些內存位置。
最後一步是將這些框填充到內存中。JS引擎經過執行頂級代碼(函數外部的代碼)來實現此目的。
除了僅在內存中填充這些框外,評估代碼還可能觸發反作用。例如,模塊可能會調用服務器。
因爲存在潛在的反作用,您只須要評估模塊一次。與實例化中發生的連接能夠徹底相同的結果執行屢次相反,評估能夠根據您執行多少次而得出不一樣的結果。
這是擁有模塊映射的緣由之一。模塊映射經過規範的URL緩存模塊,所以每一個模塊只有一個模塊記錄。這樣能夠確保每一個模塊僅執行一次。與實例化同樣,這是深度優先的後遍歷。
那咱們以前談到的那些週期呢?
在循環依賴關係中,您最終在圖中有一個循環。一般,這是一個漫長的循環。可是爲了解釋這個問題,我將使用一個簡短的循環的人爲例子。
讓咱們看一下如何將其與CommonJS模塊一塊兒使用。首先,主模塊將執行直到require語句。而後它將去加載計數器模塊。
而後,計數器模塊將嘗試message
從導出對象進行訪問。可是因爲還沒有在主模塊中對此進行評估,所以它將返回undefined。JS引擎將在內存中爲局部變量分配空間,並將其值設置爲undefined。
評估一直持續到計數器模塊頂級代碼的末尾。咱們想看看咱們是否最終將得到正確的消息值(在評估main.js以後),所以咱們設置了超時時間。而後評估在上恢復main.js
。
消息變量將被初始化並添加到內存中。可是因爲二者之間沒有鏈接,所以在所需模塊中它將保持未定義狀態。
若是使用實時綁定處理導出,則計數器模塊最終將看到正確的值。到超時運行時,main.js
的評估將完成並填寫值。
支持這些循環是ES模塊設計背後的重要理由。正是這種設計使它們成爲可能。
(以上是關於 ESM 的理論介紹, 原文連接在文末)。
談及 Bundleless 的優點,首先是啓動快。
由於不須要過多的打包,只須要處理修改後的單個文件,因此響應速度是 O(1) 級別,刷新便可即時生效,速度很快。
因此, 在開發模式下,相比於Bundle,Bundleless 有着巨大的優點。
上面的圖具體的模塊加載機制能夠簡化爲下圖:
在項目啓動和有文件變化時從新進行打包,這使得項目的啓動和二次構建都須要作較多的事情,相應的耗時也會增加。
從上圖能夠看到,已經再也不有一個構建好的 bundle、chunk 之類的文件,而是直接加載本地對應的文件。
從上圖能夠看到,在 Bundleless 的機制下,項目的啓動只須要啓動一個服務器承接瀏覽器的請求便可,同時在文件變動時,也只須要額外處理變動的文件便可,其餘文件可直接在緩存中讀取。
Bundleless 模式能夠充分利用瀏覽器自主加載的特性,跳過打包的過程,使得咱們能在項目啓動時獲取到極快的啓動速度,在本地更新時只須要從新編譯單個文件。
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 作了個demo, 支持打包, 輸出 bundle。
github: Snowpack-react-demo
可以清晰的看到, 控制檯產生了大量的文件請求(也叫瀑布網絡請求),
不過由於都是加載的本地文件, 因此速度很快。
配合HMR, 實現編輯完成馬上生效, 幾乎不用等待:
可是若是是在生產中,這些請求對於生產中的頁面加載時間而言, 就不太好了。
尤爲是HTTP1.1,瀏覽器都會有並行下載的上限,大部分是5個左右,因此若是你有60個依賴性要下載,就須要等好長一點。
雖說HTTP2多少能夠改善這問題,但如果東西太多,依然沒辦法。
關於這個項目的打包, 直接執行build:
打包完成後的文件目錄,和傳統的 webpack 基本一致:
在 build 目錄下啓動一個靜態文件服務:
build 模式下,仍是藉助了 webpack 的打包能力:
作了資源合併:
就這點而言, 我認爲將來一段時間內, 生產環境仍是不可避免的要走bundle模式。
開門見山吧, 開發體驗不是很友好,幾點比較突出的問題:
固然還有其餘方方面面的問題, 就不一一列舉。
我簡單改造了一個頁面, 就遇到不少奇奇怪怪的問題, 開發起來十分難受, 儘管代碼的修改能馬上生效。
bundleless 能在開發模式下帶了很大的便利, 但目前來講, 還有一段路要走。
就目前而言, 若是要用的話,可能仍是 bundleless(dev) + bundle(production) 的組合。
至於將來能不能全面鋪開 bundleless,我認爲仍是有可能的, 交給時間吧。
本文主要介紹了esm 的原理, 以及介紹了以此爲基礎的Vite, Snowpack 等工具, 提供了兩個可運行的 demo:
並探索了bundleless在生產中的可行性。
Bundleless 本質上是將原先 Webpack 中模塊依賴解析的工做交給瀏覽器去執行,使得在開發過程當中代碼的轉換變少,極大地提高了開發過程當中的構建速度,同時也能夠更好地利用瀏覽器的相關開發工具。
很是感謝 ESModule、Vite、Snowpack 等標準和工具的出現,爲前端開發提效。
才疏學淺, 文中如有錯誤,還能各位大佬指正, 謝謝。