Vue元件庫工程探索與實踐之構建工具

Frans發表於2019-04-17

我們團隊近期釋出了移動端 Vue 元件庫 NutUI 的 2.0 版(nutui.jd.com),2.0 不是 1.0 的升級,而是一個全新的元件庫。從 1.0 到 2.0 一路走來,我們積累了一些 Vue 元件庫的開發經驗,接下來的一段時間,我們將以系列文章的形式與大家進行分享,歡迎大家關注。

作為《Vue元件庫工程探索與實踐》系列文章開篇之作,我們從“盤古開天地”說起吧。

從當年的靜態頁面到如今的 Web App,前端工程越來越複雜,對於一個稍大些的前端專案來說,程式碼都寫在一起難以維護,團隊分工協作也成問題。根據軟體工程領域的經驗,解決這些問題的一個可行思路就是程式碼的模組化,即對程式碼按功能模組進行分拆,封裝成元件,而反過來講,元件就是指能完成某個特定功能的獨立的、可重用的程式碼塊。

把一個大的應用分解成若干小的元件,而每個元件只需要關注於某個小範圍的特定功能,但是把元件組合起來,就能構成一個功能龐大的應用。元件化的網頁開發也是如此,就像搭積木,各個元件拼接在一起就組成了一個完整的頁面。

元件化開發可大大降低程式碼耦合度、提高程式碼的可維護性和開發效率,同時有利於團隊分工協作和降低開發成本。這種開發模式已日漸流行起來。

當前,前端開發領域最流行的三大框架 Vue、React、Angular 都推崇元件化開發,元件是這些框架中極為重要的概念和功能。

以 Vue.js 來說,元件 (Component) 可以說是其最強大的功能,它可以擴充套件 HTML 元素,封裝可重用的程式碼。Vue.js 的元件系統讓我們可以用這些獨立可複用的小元件來構建大型 Vue 應用,幾乎任意型別的 Vue 應用的介面都可以抽象為一個元件樹。

Vue元件庫工程探索與實踐之構建工具

如果我們把日常應用開發中常用的元件累積起來,後續的專案就可以複用這些元件,這對提高開發效率、降低開發成本有重要意義。

因此,一個前端團隊擁有一個常用框架的元件庫是十分必要的。

模組化與構建工具

元件庫自身就是一個大的工程,需要按照模組化開發思想進行模組劃分。通常,在一個元件庫裡,元件、元件的樣式檔案、配置檔案…都是模組,而最終我們需要把這些模組組合成一個完整的元件庫檔案,承擔這種組裝工作的就是打包構建工具。當下主流的庫構建工具主要有 Rollup 和 Webpack 等。在說這些模組打包構建工具之前,我們先來了解一下目前主流的 JavaScript 模組化方案。

JavaScript 語言一直以來飽受詬病的一個地方就是它的語言標準裡沒有模組(module)體系,這對開發大型的、複雜的專案形成了巨大障礙。直到 ES6 時期,才在語言標準層面實現模組功能(ES6 Module)。在 ES6 之前,業界流行的是社群制定的一些模組載入方案,如 CommonJS 和 AMD 。而 ES6 Module 作為官方規範,且瀏覽器端和伺服器端通用,未來一定會一統天下,但由於 ES6 Module 來的太晚,受限於相容性等因素,可以預見的是今後一段時期內,多種模組化方案仍會共存。

  • ES6 Modue 規範:JavaScript 語言標準模組化方案,瀏覽器和伺服器通用,模組功能主要由 export 和 import 兩個命令構成。export 用於定義模組的對外介面,import 用於輸入其他模組提供的功能。
  • CommonJS 規範:主要用於服務端的 JavaScript 模組化方案,Node.js 採用的就是這種方案,所以各種 Node.js 環境的前端構建工具都支援該規範。CommonJS 規範規定通過 require 命令載入其他模組,通過 module.exports 或者 exports 對外暴露介面。
  • AMD 規範:全稱是 Asynchronous Modules Definition,非同步模組定義規範,一種更主要用於瀏覽器端的 JavaScript 模組化方案,該方案的代表實現者是 RequireJS,通過 define 方法定義模組,通過 require 方法載入模組。

一些“上年紀”的國內前端老藝人們可能還會提到 CMD 規範,它是 SeaJS 在推廣過程中對模組定義的規範化產出,只是 SeaJS 並未實現國際化,且專案在2015年就已宣佈停止維護了,算不上當前主流模組化方案。

介紹完主流模組化規範,我們再回過頭來看 Rollup 和 Webpack 這兩個模組打包構建工具。

rollup

Rollup 是一個頗有名氣的庫打包工具,很多知名的庫、框架都是使用它打的包,包括 Vue 和 React 自身。Rollup 可以直接對 ES6 模組進行打包,它率先提出並實現了 Tree-shaking 功能,即在打包時靜態分析 ES6 模組程式碼中的 import,排除未實際使用的程式碼,這有助於減小構建包的體積。

rollup

另一個打包工具 Webpack 名氣更大,不過我們通常用它來打包應用,而事實上它對庫打包也能提供很好的支援。Webpack 支援程式碼分割、模組的熱更新(HMR)等功能,這讓它看起來非常適合打包應用。而 Webpack 2 及後續版本陸續增加了對 ES6 模組、Tree-shaking、Scope Hoisting 的支援,大大增強了其庫打包能力。

如今,Rollup 在庫打包方面的優勢已不再那麼明顯,而在對應用打包的支援方面卻明顯落後於 Webpack 。所以打包應用推薦使用 Webpack ,而打包庫的話, Rollup 和 Webpack 基本都能勝任。

那麼我們在開發 NutUI 2 的時候為什麼選擇了 Webpack 而不是 Rollup 呢?其實主要還是上述這個原因,按照規劃,NutUI 的官網(包含示例和文件)與庫在同一個專案中,因此我們需要一個既能打包庫,又能打包應用的工具,Webpack 顯然更適合。

Webpack打包Library

使用 Webpack 來打包應用,相信大多前端小夥伴都不會感到陌生。可如何使用 Webpack 來打包一個元件庫呢?各位細聽我來言。

首先,雖然基於 ES6 模組規範開發,但考慮到瀏覽器相容性,我們需要打包出來的元件庫能相容 AMD 等瀏覽器端模組規範。同時,為了使元件庫能支援服務端渲染(SSR)等場景,它還需要支援 commonJS 規範。此外,還有一種常見的庫使用場景,即在頁面上直接通過 script 標籤引入,也就是非模組化環境同樣需要相容。

Webpack 中,output.libraryTarget 選項用來配置如何暴露庫,可配置以 commonJS 模組、AMD 模組,甚至全域性變數形式暴露庫。可是如何讓這個庫可以同時相容 commonJS、AMD 和全域性變數呢?

所幸,這個選項還支援一個可選值—— umd。UMD(Universal Module Definition,通用模組規範)可以同時支援 CommonJS 和 AMD 規範,以及非模組化引用。

綜上,我們需要把 output.libraryTarget 的值設為“umd”。

另外兩個與庫打包關係密切的Webpack配置項如下:

  • output.library ,對外暴露的變數名或模組名,具體作用與 output.libraryTarget 選項的值有關。
  • output.umdNamedDefine ,當 output.libraryTarget 的值為“umd”時,設定該選項的值為 true 會對 UMD 的構建過程中的 AMD 模組進行命名,否則就使用匿名的 define,匿名的 AMD 模組。

這幾個選項配置完,就可以打包出一個基於 umd 規範的庫了。

output: {
        path: path.resolve(__dirname, '../dist/'),
        filename: 'nutui.js',
        library: 'nutui',
        libraryTarget: 'umd',
        umdNamedDefine: true
}
複製程式碼

但是我們會發現構建出來的庫在 Node.js 環境使用時會報錯:

window is not defined
複製程式碼

是不是感到莫名其妙?說好的 UMD 相容 commonJS 呢?檢視 Webpack 構建出的包程式碼,我們會發現,UMD 部分的程式碼裡的全域性物件竟然是 window !非瀏覽器環境哪有 window 物件,Node.js 中不報錯才怪。

(function webpackUniversalModuleDefinition(root, factory) {
 if(typeof exports === 'object' && typeof module === 'object')
 module.exports = factory(require("vue"));
 else if(typeof define === 'function' && define.amd)
 define("nutui", ["vue"], factory);
 else if(typeof exports === 'object')
 exports["nutui"] = factory(require("vue"));
 else
    root["nutui"] = factory(root["Vue"]);
})(window, function(__WEBPACK_EXTERNAL_MODULE__2__) {
複製程式碼

查閱 Webpack 文件,可以發現 output 物件還有一個屬性叫 globalObject ,用來指定掛載這個庫的全域性物件,預設值是 window 。而這部分文件明確指出,當構建 UMD 包需要相容瀏覽器和 Node.js 環境時,值應該設為 this 。

output: {
        path: path.resolve(__dirname, '../dist/'),
        filename: 'nutui.js',
        library: 'nutui',
        libraryTarget: 'umd',
        umdNamedDefine: true,
        globalObject: 'this'
}
複製程式碼

我們將 globalObject 設定為 'this' 後,構建出來的包中 UMD 部分的 window 被替換為了 this ,這樣在 Node.js 環境就不會再報上面那個錯了,這對實現元件庫相容服務端渲染功能來說非常重要。

(function webpackUniversalModuleDefinition(root, factory) {
	if(typeof exports === 'object' && typeof module === 'object')
		module.exports = factory(require("vue"));
	else if(typeof define === 'function' && define.amd)
		define("nutui", ["vue"], factory);
	else if(typeof exports === 'object')
		exports["nutui"] = factory(require("vue"));
	else
		root["nutui"] = factory(root["Vue"]);
})(this, function(__WEBPACK_EXTERNAL_MODULE__2__) {
複製程式碼

這裡吐個槽,個人感覺 Webpack 這部分設計欠妥,當 libraryTarget 值為 umd 時 globalObject 預設值應該為 this ,而不能是 window ,否則 umd 還有何意義?至少在文件中 libraryTarget: 'umd' 部分對此問題應該有所提及,不然還會有不少人踩此坑。

外部依賴Vue.js

Vue 元件庫不需要把 Vue.js 也打包進去,可在執行時從外部獲取。Webpack 中可以通過 externals 配置外部依賴。我們不妨以 jquery 為例看下 externals 的配置方法:

externals: {
    jquery: 'jQuery'
}
複製程式碼

這樣 jquery 在構建時不會打到包內,而是在執行時需要 jquery 的時候去外部環境尋找 jQuery 這個模組(或屬性)。照貓畫虎,依葫蘆畫瓢,我們不需要打包 Vue.js ,那我們就這麼寫:

externals: {
    vue: 'vue'
}
複製程式碼

這時候構建出來的包在各種模組化場景使用都沒毛病,可唯獨在非模組化場景會報錯:

vue is not defined
複製程式碼

這是為什麼呢?我們先來看下 Vue.js 的部分原始碼:

/*!
 * Vue.js v2.6.10
 * (c) 2014-2019 Evan You
 * Released under the MIT License.
 */
(function (global, factory) {
    typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
        typeof define === 'function' && define.amd ? define(factory) :
            (global = global || self, global.Vue = factory());
複製程式碼

從上面的 Vue.js 原始碼中,我們可以看到掛到全域性物件上的 vue 屬性名稱是首字母大寫的 Vue,而其 NPM 包名卻是小寫的 vue ,也就是說不同環境下 Vue 名稱不盡一致,這可如何是好?

{
  "name": "vue",
  "version": "2.6.10",
複製程式碼

還好,externals 中屬性的值除了字串,還支援傳一個物件,可針對各種場景單獨設定模組名(或屬性名),這樣一來,我們就可以為非模組化環境配置 'Vue',為模組化環境配置 'vue'。

externals: {
        'vue': {
            root: 'Vue',
            commonjs: 'vue',
            commonjs2: 'vue',
            amd: 'vue'
        }
}
複製程式碼

Vue.js 就是這樣被設定為元件庫外部依賴的。

Tree-shaking(搖樹)

如前文所述,Tree-shaking 功能最早由 Rollup 提出並實現,曾是 Rollup 的殺手鐗,後來 Webpack 等工具把它“借鑑”走了。

搖樹

Tree-shaking 的原理是在打包時通過對程式碼進行靜態分析將未使用的程式碼排除,從而減小包體積。對 JavaScript 進行靜態分析,這在之前是不可能的。直到 ES6 模組化方案的提出,才使得 JavaScript 靜態分析成為可能,因為 ES6 模組是編譯時載入,不用等到程式碼執行時就可以知道載入了哪些模組。因此要使用 Tree-shaking 功能,就需要在程式碼中使用 ES6 模組方案,不管是用 Rollup 還是 Webpack 打包。

還有一個影響 Tree-shaking 施展的可能,那就是 Babel 在 Webpack 開始“搖”之前把你的 ES6 模組轉成了 commonJS 模組,那就“搖”不了了。這種情況並不罕見,大部分前端開發者都樂於使用新語法,所以不止模組化方案要用 ES6 Module ,甚至整個專案的 JavaScript 程式碼都用 ES6+ 語法來寫,為了相容低版本環境,通常會使用 Babel 等工具把 ES6+ 語法轉成 低版本語法。這當然沒問題,只是如果想讓 Tree-shaking 發揮作用,讓我們構建出來的包體積更小,一定要注意,不要讓 Babel 把ES6模組語法轉成 commonJS ,Rollup 和較新版本的 Webpack 都支援直接處理 ES6 模組,可以也應該把 ES6 模組部分直接交給它們來處理。不使用 Babel 處理ES6模組,並不意味著最終打出來的包就是 ES6 模組,如前文所述,構建出來的包如何暴露,要相容哪些模組規範打包工具就能搞定。

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "modules": false
      }
    ]
  ]
}
複製程式碼

我們測試了一下,Tree-shaking 讓 NutUI 2.0 的完整版的構建檔案體積明顯減小。

好了,關於構建工具我們先說到這裡,具體實現細節可以參考 NutUI 2.0 的原始碼(github.com/jdf2e/nutui)。後續的文章我們還會談元件庫的按需載入、主題定製、國際化、單元測試、持續整合、基於Markdown檔案生成靜態文件網站、Vue公共元件開發等方面的探索實踐經驗,敬請關注。

連結

[1] NutUI 2.0 官網 nutui.jd.com
[2] NutUI 2.0 程式碼倉庫 github.com/jdf2e/nutui

相關文章