前端模塊化的十年征程

作者:@外婆的 https://zhuanlan.zhihu.com/p/265632724

前言

夫以銅爲鏡,可以正衣冠;以史爲鏡,可以知興替 ——《舊唐書 · 魏徵傳》

也許在談論具體的內容之前,我們需要談論一下關鍵詞的定義。 什麼是 "模塊"?在不同的語境下模塊有不同的含義。

但在本文中,我們從廣義的角度出發,將它解釋爲兩個方面

模塊化已經發展了有十餘年了,不同的工具和輪子層出不窮,但總結起來,它們解決的問題主要有三個

時間線

下面是最各大工具或框架的誕生時間,不知不覺,模塊化的發展已有十年之久了。

生態             誕生時間
 Node.js          2009年   
 NPM              2010年   
 requireJS(AMD)   2010年
 seaJS(CMD)       2011年
 broswerify       2011年
 webpack          2012年
 grunt            2012年 
 gulp             2013年
 react            2013年 
 vue              2014年
 angular          2016年
 redux            2015年 
 vite             2020年
 snowpack         2020年

外部模塊的管理

在模塊化的過程中,首先要解決的就是外部模塊的管理問題。

Node.js 和 NPM 的發佈

時間倒回到 2009 年,一個叫萊恩 (Ryan Dahl) 的精神小夥創立了一個能夠運行 JavaScript 的服務器環境——Node.js,並在一年之後,發佈了 Node.js 自帶的模塊管理工具 npm,npm 的全稱是 node package manager,也就是 Node 包管理器。

Node 的出現給 JavaScript 的帶來了許多改變:

一方面, Node 使 JavaScript 不侷限於前端,同時還成爲了一門後端語言。更重要的是: 經過 10 年的發展,Node.js 已經完全融入到了前端開發流程中。我們用它創建靜態資源服務器,實現熱重載和跨域代理等功能,同時還用它源代碼中的特殊寫法做編譯轉換處理 (JSX/Sass/TypeScript),將代碼翻譯成瀏覽器可以理解的格式 (ES5/CSS)。到今天,即使我們不用 Node.js 獨立開發程序後臺,它作爲開發工具的重要性也不會改變

另一方面, Node.js 自帶的 JS 模塊管理工具 npm,從根本上改變了前端使用外部模塊的方式,如果要打個比方的話,就好比從原始社會進入了現代社會

NPM 時代以前的外部模塊使用方式

在一開始沒有 npm 的時候,如果我們需要在項目裏使用某個外部模塊,我們可能會去官網直接把文件下載下來放到項目中,同時在入口 html 中通過 script 標籤引用它。

每引用一個外部模塊,我們都要重複這個過程

除了這些全局的 UI 庫或工具庫,我們可能還會使用到很多實現細節功能的輔助模塊,如果都按這種方式使用未免過於粗暴,而且給我們帶來許多麻煩

而 npm 的出現改變了這種情況

NPM 時代以後外部模塊的使用方式

我們上面說過,NPM 在 2010 年伴隨着 Node.js 的新版本一起發佈,是一個 Node 自帶的模塊管理工具。

從概念上看它由以下兩個部分組成

每次 npm install 的時候,都會在 package.json 這個文件中更新模塊和對應的版本信息。

// package.json
{
  ...
  "dependencies": {
    "bootstrap": "^4.5.2",
    "jquery": "^3.5.1"
  }
}

於是乎,包括 jQuery 等知名模塊開發者的前端工程師們,都通過 npm publish 的方式把自己的模塊發佈到 NPM 上去了。前端開發者們真正有了一個屬於自己的社區和平臺,如萬千漂泊遊船歸於港灣,而 NPM 也名聲漸噪

早在 2019 年 6 月,NPM 平臺上的模塊數量就超過了 100 萬,而到寫下這篇文章的時候,NPM 模塊數量已超過了 140 萬

NPM 的出現實際上是一個必然,前端工程的複雜化要求我們必須要有這麼一個集中的 JS 庫管理平臺。但爲什麼它會是 NPM 呢?這和後來 Node.js 的火熱有很大關係,因爲 NPM 是 Node.js 內置的包管理器,所以跟隨着 Node 得到了開發者的追捧。

綜上所述,NPM 解決了外部模塊的管理問題。

內部模塊的組織

在模塊化的過程中,還需要解決的是內部模塊的組織問題。

模塊化第一階段:原生 JS 組織階段

在最原始的時代,我們是通過下面這種方式組織我們的模塊代碼的,將不同的 JS 文件在 html 中一一引入。每個文件代表一個模塊

// index.html 
<script src="./a.js"></script>   
<script src="./b.js"></script>   
<script src="./c.js"></script>   
<script src="./d.js"></script>

並通過模塊模式去組織代碼:如下所示,我們通過一個 “立即調用的函數表達式”(IIFE) 去組織模塊

將每個模塊包裹在一個函數作用域裏面執行,這樣就可以最大程度地避免污染全局執行環境

通過執行匿名函數得到模塊輸出,可以暴露給下面的其他模塊使用

<script>
  var module1 = (function(){
    var x = 1  
    return { a: x };
  })();
</script>
<script>
  var module2 = (function(){
   var a = module1.a;   
   return { b: a };
 })();
</script>

但這種使用方式仍然比較粗暴

我們需要針對這些問題提出解決方案,而 AMD 和 CMD 就是爲解決這些問題而提出的規範

模塊化的第二階段:在線處理階段

模塊化規範的野蠻生長

10 多年以前,前端模塊化剛剛開始,正處在野蠻生長的階段。這個過程中誕生了諸多模塊化規範: AMD/CMD/CommonJS/ES6 Module。沒錯,前端並沒有一開始就形成統一的模塊化規範,而是多個規範同時多向發展。直到某一類規範佔據社區主流之時,模塊化規範野蠻生長的過程才宣告結束。

首先開始在前端流行的模塊化規範是 AMD/CMD, 以及實踐這兩種規範的 require.js 和 Sea.js, AMD 和 CMD 可看作是 "在線處理" 模塊的方案,也就是等到用戶瀏覽 web 頁面下載了對應的 require.js 和 sea.js 文件之後,纔開始進行模塊依賴分析,確定加載順序和執行順序。模塊組織過程在線上進行。

AMD && CMD

AMD 和 CMD 只是一種設計規範,而不是一種實現。

AMD

我們先來說下 AMD,它的全稱是 Asynchronous Module Definition,即 “異步模塊定義”。它是一種組織前端模塊的方式

AMD 的理念可以用如下兩個 API 概括: define 和 require

define 方法用於定義一個模塊,它接收兩個參數:

第一個參數是一個數組,表示這個模塊所依賴的其他模塊

第二個參數是一個方法,這個方法通過入參的方式將所依賴模塊的輸出依次取出,並在方法內使用,同時將返回值傳遞給依賴它的其他模塊使用。

// module0.js
define(['Module1', 'Module2'], function (module1, module2) {
    var result1 = module1.exec();
    var result2 = module2.exec();
    return {
      result1: result1,
      result2: result2
    }
});

require 用於真正執行模塊,通常 AMD 框架會以 require 方法作爲入口,進行依賴關係分析並依次有序地進行加載

// 入口文件
require(['math'], function (math) {
  math.sqrt(15)
});
define && require 的區別

可以看到 define 和 require 在依賴模塊聲明和接收方面是一樣的,它們的區別在於 define 能自定義模塊而 require 不能,require 的作用是執行模塊加載。

通過 AMD 規範組織後的 JS 文件看起來像下面這樣

depModule.js

define(function () {
  return printSth: function () {
    alert("some thing")
  }
});

app.js

define(['depModule'], function (mod) {
  mod.printSth();
});

index.html

// amd.js意爲某個實現了AMD規範的庫
<script src="...amd.js"></script>
<script>
  require(['app'], function (app) {
    // ...入口文件
  })
</script>

我們可以看到,AMD 規範去除了純粹用 script 標籤順序組織模塊帶來的問題

遵循 AMD 規範實現的模塊加載器

我們前面說過,AMD 只是一個倡議的規範,那麼它有哪些實現呢?

根據史料記載,AMD 的實現主要有兩個: requireJS 和 curl.js, 其中 requireJS 在 2010 年推出,是 AMD 的主流框架

官網: https://requirejs.org/

CMD

CMD 是除 AMD 以外的另外一種模塊組織規範。CMD 即 Common Module Definition,意爲 “通用模塊定義”。

和 AMD 不同的是,CMD 沒有提供前置的依賴數組,而是接收一個 factory 函數,這個 factory 函數包括 3 個參數

如下所示

// CMD
define(function (requie, exports, module) {
    //依賴就近書寫
    var module1 = require('Module1');
    var result1 = module1.exec();
    module.exports = {
      result1: result1,
    }
});
// AMD
define(['Module1'], function (module1) {
    var result1 = module1.exec();
    return {
      result1: result1,
    }
});
CMD && AMD 的區別

從上面的代碼比較中我們可以得出 AMD 規範和 CMD 規範的區別

一方面,在依賴的處理上

另一方面,在本模塊的對外輸出上

遵循 CMD 規範實現的模塊加載器

sea.js 是遵循 CMD 規範實現的模塊加載器,又或者更準確的說法是: CMD 正是在 sea.js 推廣的過程中逐步確立的規範,並不是 CMD 誕生了 sea.js。相反,是 sea.js 誕生了 CMD

CMD 和 AMD 並不是互斥的,require.js 和 sea.js 也並不是完全不同,實際上,通過閱讀 API 文檔我們會發現,CMD 後期規範容納了 AMD 的一些寫法。

AMD && CMD 背後的實現原理

下面以 sea.js 爲例

解析 define 方法內的 require 調用

我們之前說過, sea.js 屬於 CMD, 所以它的依賴是就近獲取的,

所以 sea.js 會多做一項工作:也就是對 define 接收方法體內 require 調用的解析。

先定義 parseDependencies 方法: 通過正則匹配獲取字符串中的 require 中的參數並存儲到數組中返回

var REQUIRE_RE = /"(?:\\"|[^"])*"|'(?:\\'|[^'])*'|\/\*[\S\s]*?\*\/|\/(?:\\\/|[^\/\r\n])+\/(?=[^\/])|\/\/.*|\.\s*require|(?:^|[^$])\brequire\s*\(\s*(["'])(.+?)\1\s*\)/g
var SLASH_RE = /\\\\/g
function parseDependencies(code) {
  var ret = []
  code.replace(SLASH_RE, "")
      .replace(REQUIRE_RE, function(m, m1, m2) {
        if (m2) {
          ret.push(m2)
        }
      })
  return ret
}

然後通過 toString 將 define 接收的方法轉化爲字符串,然後調用 parseDependencies 解析。這樣我們就獲取到了一個 define 方法裏面所有的依賴模塊的數組

// Parse dependencies according to the module factory code
if (!isArray(deps) && isFunction(factory)) {
    deps =  parseDependencies(factory.toString())
}

然後 Sea.js 執行的時候,會從入口開始遍歷依賴模塊,並依次將它們加載到瀏覽器中,加載方法如下所示。

function request(url, callback, charset, crossorigin) {
  var node = doc.createElement("script")
  addOnload(node, callback, url) // 添加回調,回調函數在 3 中
  node.async = true //異步
  node.src = url
  head.appendChild(node)
}

而且在每個依賴加載完後都會通過回調的方式調用 3 中的 onload 方法

在 onload 方法中,sea.js 會設置一個計數變量 remain,用來計算依賴是否加載完畢。每加載完一個模塊就執行 remain - 1 操作,並通過 remain === 0 判斷依賴是否全部加載完畢。

如果全部加載完畢就執行 4 中的 mod.callback 方法

Module.prototype.onload = function() {
  var mod = this
  mod.status = STATUS.LOADED 
  for (var i = 0, len = (mod._entry || []).length; i < len; i++) {
    var entry = mod._entry[i]
    if (--entry.remain === 0) {
      entry.callback()
    }
  }
  delete mod._entry
}

大概因爲 require.js 出來比較早的原因,所以沒有用 Promise.all 一類的 API

當判斷 entry.remain === 0 時,也即依賴模塊全部加載完畢時,會調用一開始 callback 方法,去依次執行加載完畢的依賴模塊,並將輸出傳遞給 use 方法回調

// sea.js的use方法類似於AMD規範中的require方法,用於執行入口函數
Module.use = function (ids, callback, uri) {
  var mod = Module.get(uri, isArray(ids) ? ids : [ids])
  mod.callback = function() {
    var exports = []
    var uris = mod.resolve();
    // 依次執行加載完畢的依賴模塊,並將輸出傳遞給use方法回調
    for (var i = 0, len = uris.length; i < len; i++) {
      exports[i] = cachedMods[uris[i]].exec()
   }
   // 執行use方法回調
   if (callback) {
      callback.apply(global, exports)
   }
  }
}

參考資料: https://segmentfault.com/a/1190000016001572

ES6 的模塊化風格

關於 AMD/CMD 的介紹到此爲止,後面的事情我們都知道了,伴隨着 babel 等編譯工具和 webpack 等自動化工具的出現,AMD/CMD 逐漸湮沒在歷史的浪潮當中,然後大家都習慣於用 CommonJS 和 ES6 的模塊化方式編寫代碼了。

這一切是怎麼發生的呢? 請看

CommonJS && ES6

CommonJS 是 Node.js 使用的模塊化方式,而 import/export 則是 ES6 提出的模塊化規範。它們的語法規則如下。

// ES6
import { foo } from './foo'; // 輸入
export const bar = 1;        // 輸出
// CommonJS
const foo = require('./foo'); // 輸入
module.exports = { 。         // 輸出
    bar:1
}

實際上我們能感覺到,這種模塊化方式用起來比 CMD/AMD 方便。

但在最開始的時候,我們卻不能在前端頁面中使用它們,因爲瀏覽器並不能理解這種語法。

但後來,編譯工具 babel 的出現讓這變成了可能

babel 的出現和 ES6 模塊化的推廣

在 2014 年十月,babel1.7 發佈。babel 是一個 JavaScript 編譯器,它讓我們能夠使用符合開發需求的編程風格去編寫代碼,然後通過 babel 的編譯轉化成對瀏覽器兼容良好的 JavaScript。

Bablel 的出現改變了我們的前端開發觀點。它讓我們意識到:對前端項目來說,開發的代碼和生產的前端代碼可以是不一樣的,也應該是不一樣的。

babel 編譯器讓我們能做到這一點。在 babel 出現之前的 AMD/CMD 時代,開發和生產的代碼並沒有明顯的區分性,開發是怎樣的生產出來後也就是怎樣的。

而 babel 則將開發和生產這兩個流程分開了,同時讓我們可以用 ES6 中的 import/export 進行模塊化開發。

至此,AMD/CMD 的時代宣告結束,ES6 編程的時代到來

Babel 的工作原理

Babel 的工作流程可概括爲三個階段

模塊化的第三階段:預處理階段

現在時間來到了 2013 年左右,AMD/CMD 的浪潮已經逐漸退去,模塊化的新階段——預編譯階段開始了。

一開始的 CMD/AMD 方案,可看作是 “在線編譯” 模塊的方案,也就是等到用戶瀏覽 web 頁面下載了 js 文件之後,纔開始進行模塊依賴分析,確定加載順序和執行順序。但這樣卻不可避免的帶來了一些問題

於是開發者們想了對應的方法去解決這些問題:

開發一個工具,讓它把組織模塊的工作提前做好,在代碼部署上線前就完成,從而節約頁面加載時間

使用工具進行代碼合併,把多個 script 的代碼合併到少數幾個 script 裏,減少 http 請求的數量。

在這樣的背景下,一系列模塊預處理的工具如雨後春筍般出現了。

典型的代表是 2011 年出現的 broswerify 和 2012 年發明的 webpack。

它們一開始的定位是類似的,都是通過預先打包的方式,把前端項目裏面的多個文件打包成單個文件或少數幾個文件,這樣的話就可以壓縮首次頁面訪問時的 http 請求數量,從而提高性能。

當然後面的事情我們都知道了,webpack 因爲發展得更好而佔據了主流的前端社區,而 broswerify 則漸漸消失在紅塵之中。

broswerify

以 broswerify 爲例,我們可以通過 npm 安裝它

npm install 
-
g browserify

broswerify 允許我們通過 CommonJS 的規範編寫代碼,例如下面的入口文件 main.js

// main.js
var a = require('./a.js');
var b = require('./b.js');
...

然後我們可以用 broswerify 攜帶的命令行工具處理 main.js,它會自動分析依賴關係並進行打包, 打包後會生成集合文件 bundle.js。

browserify main
.
js 
-
o bundle
.
js
webpack

webpack 是自 broswerify 出現一年以後,後來居上並佔據主流的打包工具。webpack 內部使用 babel 進行解析,所以 ES6 和 CommonJS 等模塊化方式是可以在 webpack 中自由使用的。

通過安裝 webpack 這一 npm 模塊便可使用 webpack 工具

npm install 
--
save
-
dev webpack

它要求我們編寫一份名爲 webpack.config.js 的配置文件,並以 entry 字段和 output 字段分別表示打包的入口和輸出路徑

// webpack.config.js
const path = require('path');
module.exports = {
  entry: './src/index.js',
  output: {   
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js'
  }
};

打包完畢後,我們的 index.html 只需要加載 bundle.js 就可以了。

<!doctype html>
<html>
  <head>
    ...
  </head>
  <body>
    ...
    <script src="dist/bundle.js"></script>
  </body>
</html>

打包工具面臨的問題 && 解決方案

代碼打包當然不是一本萬利的,它們也面臨着一些副作用帶來的問題,其中最主要的就是打包後代碼體積過大的問題

代碼打包的初衷是減少類似 CMD 框架造成的加載腳本 (http 請求) 數量過多的問題,但也帶來了打包後單個 script 腳本體積過大的問題:如此一來,首屏加載會消耗很長時間並拖慢速度,可謂是物極必反。

webpack 於是引入了代碼拆分的功能 (Code Splitting) 來解決這個問題, 從全部打包後退一步:可以打包成多個包

雖然允許拆多個包了,但包的總數仍然比較少,比 CMD 等方案加載的包少很多

Code Splitting 有可分爲兩個方面的作用:

一是實現第三方庫和業務代碼的分離:業務代碼更新頻率快,而第三方庫代碼更新頻率是比較慢的。分離之後可利用瀏覽器緩存機制加載第三方庫,從而加快頁面訪問速度

二是實現按需加載: 例如我們經常通過前端路由分割不同頁面,除了首頁外的很多頁面 (路由) 可能訪問頻率較低,我們可將其從首次加載的資源中去掉,而等到相應的觸發時刻再去加載它們。這樣就減少了首屏資源的體積,提高了頁面加載速度。

A. 實現第三方庫和業務代碼的分離

這種代碼拆分可通過 webpack 獨特的插件機制完成。plugins 字段是是一個數組,可接收不同的 plugins 實例,從而給 webpack 打包程序附加不同的功能,CommonsChunkPlugin 就是一個實現代碼拆分的插件。

// webpack.config.js
module.exports = {
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: 'commons',        // the commons chunk name
      filename: 'commons.js', // the filename of the commons chunk)
      minChunks: 3,           // Modules must be shared between 3 entries
    });
  ]
};

通過上面的配置,webpack 在執行打包的時候會把被引用超過 3 次的依賴文件視爲 "公共文件",並單獨打包到 commons.js 中,而不是打包到主入口文件裏。

對於 React,Redux,lodash 這些第三方庫,因爲引用次數遠遠超過 3 次,當然也是會被打包到 common.js 中去的。

B. 實現按需加載

正如其字面意思,按需加載就是等到需要的時候才加載一部分模塊。並不選擇將其代碼打包到首次加載的入口 bundle 中,而是等待觸發的時機,屆時才通過動態腳本插入的方式進行加載: 即創建 script 元素,添加腳本鏈接並通過 appendChild 加入到 html 元素中

例如我們需要實現一個功能,在點擊某個按鈕的時候,使用某個模塊的功能。這時我們可以使用 ES6 的 import 語句動態導入,webpack 會支持 import 的功能並實現按需加載

button.addEventListener('click',function(){
  import('./a.js').then(data => {
    // use data
  })
});

模塊化的第四階段:自動化構建階段

正當打包工具方興未艾的時候,另外一個發展浪潮也幾乎在同步發生着。

它就是 —— 全方位的自動化構建工具的發展。

什麼叫自動化構建工具呢? 簡單的說就是: 我們需要這樣一個工具,專門爲開發過程服務,儘可能滿足我們開發的需求,提高開發的效率。

前面說過,在模塊化的過程中,我們漸漸有了 “開發流程” 和“生產流程”的區分,而自動化構建工具就是在開發流程中給開發者最大的自由度和便捷性,同時在生產流程中能保證瀏覽器兼容性和良好性能的工具。而所有的功能已經由插件直接提供,所以被稱作“自動化” 構建工具。

在這時,我們已經不再滿足於 “打包” 這個功能了,我們渴望做更多的事情:

自動化構建工具的代表性工具有三個,分別是

下圖中,左中右分別是 gulp, grunt 和 webpack

這一次,webpack 並沒有止步於成爲一個單純的打包工具,而是參與到自動化構建的浪潮裏,並且成爲了最後的贏家。而 grunt 和 gulp 則像過去的 Sea.js,Require.js 等工具一樣。逐漸地從熱潮中隱退,靜靜地待在前端社區裏的一方僻靜的角落裏

gulp && webpack

因爲篇幅關係,我們下面只來介紹下 gulp 和 webpack 這兩個自動化構建工具。

gulp 和 webpack 的區別

對於使用者來說,gulp 和 webpack 最大的區別也許在它們的使用風格上

下面我們以 less 代碼的編譯爲例,展示 Gulp 和 webpack 的區別

Gulp

Gulp 基本的風格是編程式的, 它是一種基於流即 Node.js 封裝起來的 stream 模塊的自動化構建工具,一般先通過 gulp.src 將匹配的文件轉化成 stream(流) 的形式,然後通過一連串的 pipe 方法進行鏈式的加工處理處理,對後通過 dest 方法輸出到指定路徑。

// gulpfile.js
const { src, dest } = require('gulp');
const less = require('gulp-less');
const minifyCSS = require('gulp-csso');
function css() {
  return src('client/templates/*.less')
    .pipe(less())
    .pipe(minifyCSS())
    .pipe(dest('build/css'))
}
Webpack

webpack 的基本風格則是配置式的,它通過 loader 機制實現文件的編譯轉化。通過配置一組 loader 數組,每個 loader 會被鏈式調用,處理當前文件代碼後輸出給下一個 loader, 全部處理完畢後進行輸出

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.less$/, // 正則匹配less文件
        use: [
          { loader: 'style-loader' }, // creates style nodes from JS strings
          { loader: 'css-loader' },   // translates CSS into CommonJS
          { loader: 'less-loader' },  // compiles Less to CSS
        ],
      },
    ],
  },
};
gulp 和 webpack 的共同點

gulp 和 webpack 並沒有自己完成所有的功能,而是搭建起一個平臺,吸引世界各地的開發者們貢獻插件,並構建起來一個繁榮的生態。

從提供的功能上看,gulp 和 webpack 在很多方面是類似的, 這從它們的相關生態上也可以看得出來

Gulp

Webpack

Gulp 的沒落和 webpack 的興起

經過了七八年的發展,webpack 逐漸取代了 gulp 成爲前端開發者的主流自動化構建工具。

究其原因

一方面,是因爲 gulp 是編程式的,webpack 是配置式的,webpack 用起來更加簡單方便,上手難度相對低一些,所以得到衆多開發者的喜歡 另一方面,從 2014 年 React,Vue 等 SPA 應用的熱潮興起後,webpack 和它們的結合性更好,所以也助長了 webpack 生態的繁榮

模塊化的故事,到這裏就先告一段落了。

十年征程,前端模塊化終於從呱呱墜地到長大成人,

自動構建工具的新趨勢:bundleless

webpack 之所以在誕生之初採用集中打包方式進行開發,有幾個方面的原因

一是瀏覽器的兼容性還不夠良好,還沒提供對 ES6 的足夠支持 (import|export),需要把每個 JS 文件打包成單一 bundle 中的閉包的方式實現模塊化

二是爲了合併請求,減少 HTTP/1.1 下過多併發請求帶來的性能問題

而發展到今天,過去的這些問題已經得到了很大的緩解,因爲

bundleless 就是把開發中拖慢速度的打包工作給去掉,從而獲得更快的開發速度。代表性工具是 vite 和 snowpack

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