Vue組件庫工程探索與實踐之按需加載篇

Vue組件庫工程探索與實踐之按需加載篇

《Vue組件庫工程探索與實踐》系列文章第二篇,聊一聊組件庫按需加載功能。

一個組件庫通常有數十個組件,隨著版本迭代組件數量還可能進一步增加。組件庫文件的體積也隨之膨脹,動輒幾百KB。而我們的業務項目中,有可能只用到了這個組件庫的少數幾個組件,這時把整個組件庫打包進去,非但沒有必要,還會徒增項目構建文件的體積,這與應用性能優化的方向是背道而馳的。因此,組件庫有必要提供一種更靈活的組件引用方式,允許應用只引用指定的組件。事實上,主流的組件庫基本都具備“按需加載組件”功能。

最簡單的“按需加載組件”實現方式,就是在應用中直接引用所需組件的源文件,在應用的構建工具中跟應用一起構建。說它簡單,是因為這種方式幾乎不需要組件庫做什麼工作,應用直接引用組件源碼,並不需要經過組件庫的構建過程。

這種方式的局限性也大都與“組件未經組件庫構建”有關。在應用中構建這些組件,就意味著應用的構建工具必須要具備構建這些組件的能力。比如需要有編譯Vue模板、編譯ES6+語法、編譯Scss/Less語法、支持postcss等的能力,如果說上面這些功能很基礎,大多數應用的構建工具都能支持,那麼組件可能還有一些不太常見或者組件庫特有的功能,比如處理SVG 、定制主題、國際化等等,通常應用構建工具不具備或者依賴於組件庫配置文件,這就給直接在用戶的應用中編譯組件源碼帶來了困難。另一方面,未經構建的組件模塊化接口單一,無法直接在其他模塊化場景和非模塊化場景使用。還有,如果組件庫支持直接引用組件源碼,則需要把所有組件源碼隨NPM包一起發布,可能會導致npm包過大,看起來並不是一個好主意。

好吧,我們換個思路,不直接引用組件源碼,而是讓組件庫對這些用戶指定的組件(而非全部組件)進行構建,生成一個自定義版本的組件庫給用戶應用使用。這就需要組件庫與用戶進行交互,收集用戶所需要的組件訊息,然後將指定組件編譯成一個自定義版本的庫文件。這種自定義構建方案常見的情況有兩種,一種是通過網頁收集訊息,在服務端進行構建。遙想當年jQuery時代, jQuery-UI庫提供的自定義構建下載方式[1],讓用戶在線選擇所需組件,然後在服務端進行編譯,完成後提供給用戶下載(當然,服務端也可能存在已經提前編譯完的各種組合的構建包)。那個時代已然遠去,如今下載安裝組件庫“政治正確”的姿勢是通過npm/Yarn

另一種方案是通過命令行界面( CLI )收集訊息並在客戶端構建。比如jQuery的“不同父異母”的小兄弟Zepto.js ,官方標準包裡只包含部分模塊,如果需要增加或移除模塊就需要進行自定義構建了:在Zepto.js項目目錄下安裝依賴,在MODULES中指定需要的模塊,然後執行npm run-script dist進行構建,完事兒後dist目錄下zepto.jszepto.min.js就是自定義構建出來的包,拿到項目裡使用即可。這種方式節約服務器資源,甚至不需要自己的服務器。

# do a custom build $ MODULES="zepto event data" npm run-script dist # on Windows c:zepto> SET MODULES=zepto event data c:zepto> npm run-script dist

NutUI 1.x時期的按需加載方案,類似上述第二種方案,較之還有一些改進。用戶在NutUI 1.x項目中安裝依賴,然後執行npm run custom命令,這時命令行界面會列出所有組件名,用戶選擇需要的組件後回車,組件庫的構建工具會將所選組件進行構建,得到與完整組件庫文件同名的構建文件nutui.js ,正常使用即可。

只看這種方案自身,似乎沒什麼問題,確實實現了按需構建,而且並不繁瑣,只是幾行命令而已,也不需要架設服務器。但是如果結合用戶使用場景來看,問題還是不少:

  • 用戶通常是通過npm/Yarn方式安裝的組件庫,需要進node_modules目錄找到組件庫項目目錄安裝依賴
  • 自定義構建之後的文件在組件庫項目目錄的dist目錄下,因組件庫目錄位於node_modules目錄中,而node_modules目錄通常不被提交到代碼倉庫,因此在換電腦或多人合作的時候往往還需要再次構建才能在本地拿到自定義構建後的組件庫文件,如果版本有差異,還可能會增加風險
  • 為了支持用戶進行自定義構建,需要把幾乎整個組件庫的源碼都發佈到npm包中

於是NutUI 2.0時,我們決定對按需加載功能進行重新設計。我們參考了業界優秀組件庫的實現方案。在組件庫構建時,除了構建完整的組件庫包以外,還把每個組件單獨構建了一個包,這樣就可以獨立引用每一個組件了。

// 加载构建后的组件JS import Button from '@nutui/nutui/dist/packages/button/button.js' ; //加载构建后的组件CSS import '@nutui/nutui/dist/packages/button/button.css' ;

webpack的中如何實現構建多個bundle呢?主要是entry選項的配置, entry的值通常是一個字符串,其實它還可以是一個對象。我們新增一個webpack配置文件,基於組件庫的組件配置文件生成一個對象,key是組件名,value是組件的入口js文件,將此對像作為該配置文件的entry選項值即可,其他配置與完整版的組件庫webpack配置文件一致(輸出目錄可根據需要自行配置)。構建時執行這兩個配置文件,即可構建出一個完整版的組件庫包和每個組件獨立的包。

constcptConf=require('../src/config.json');constentry={};cptConf.packages.map((item)=>{entry[cptName]=`./src/packages/${item.name.toLowerCase()}/index.js`;});module.exports={entry};

如果用戶項目中使用了多個組件,這種分別引用每個組件及其樣式文件的寫法還是略顯繁瑣, URL拼寫也容易出錯。代碼潔癖患者的感受也需要顧及啊~

拋開技術實現和兼容性不談,比較理想的、面向未來的寫法應該是ES6 modules風格的寫法,因為一眾的模塊化方案中,這是親兒子。

import{Button,Switch}from'@nutui/nutui';

我們考慮支持這種寫法,並提供一個工具在用戶應用編譯階段將代碼自動轉換為組件單獨引用的寫法:

importButtonfrom'@nutui/nutui/dist/packages/button/button.js';importSwitchfrom'@nutui/nutui/dist/packages/switch/switch.js';import'@nutui/nutui/dist/packages/button/button.css';import'@nutui/nutui/dist/packages/switch/switch.css';

承擔這種轉碼工作最適合的人選非Babel莫屬了。大多數用戶的項目腳手架都會安裝Babel ,用來進行ES6+語法向低版本語法的轉換,我們只需要提供一個Babel的插件,使其在轉換的過程中捎帶著把我們組件按需加載的語法也給轉換了即可。我們先來了解一下Babel的工作原理。

Babel的轉碼工作大致分為三個階段:

  • 解析(parse):將代碼字符串解析成AST(抽象语法树)
  • 轉換(transform):對抽象語法樹進行轉換操作
  • 生成(generate): 將變換後的抽象語法樹再生成代碼字符串

抽象語法樹(Abstract Syntax Tree,AST),或簡稱語法樹(Syntax tree),是源代碼語法結構的一種抽象表示。它以樹狀的形式表現編程語言的語法結構,樹上的每個節點都表示源代碼中的一種結構。

我們的Babel插件@nutui/babel-plugin-separate-import [2]的大致工作原理是在代碼被解析成AST抽象語法樹之後,遍歷語法樹找到形如import { Button,Switch } from '@nutui/nutui';的語法相關節點,轉換成單獨引用組件的語法,最後再生成代碼字符串。

這只是基本原理,實際情況比較複雜,因為還需要考慮樣式文件類型、主題換膚、國際化等因素,這裡就不展開了。下面說下這個插件的基本使用。

  • 通過npm/yarn安裝@nutui/babel-plugin-separate-import在項目的Babel配置文件(如.babelrc )中配置插件
{"plugins":[["@nutui/babel-plugin-separate-import",{"style":"css"}]]}
  • 然後就可以使用ES6 modules風格的語法引用所需的組件了
importVuefrom'vue';import{Button,Switch}from'@nutui/nutui';Vue.use(Button);Vue.use(Switch);

既然說到BabelAST ,我們不妨進行一些延展(這部分內容屬贈送性質)。 Babel自帶的AST操作相關模塊可以在需要AST的場景獨立使用,無需再安裝其他AST工具。

  • @babel/parser模塊用來把代碼解析成AST抽象语法树
  • @babel/traverse模塊用來對AST節點進行遞歸遍歷
  • @babel/types模塊用來對具體的AST節點進行進行增、刪、改、查
  • @babel/generator模塊用來將修改後的AST生成新的代碼字符串

比如在NutUI 2.x項目中,我們為新增組件提供了一個命令npm run add ,可根據錄入訊息自動生成新組件的模板,並更新配置文件。其中一個需要更新的組件庫配置文件是src目錄下的nutui.js文件,這個文件非常重要,是整個項目的entry文件。添加新組件的時候, nutui.js文件有兩處需要修改。

  • 增加兩個import ,用於加載新組件的入口js文件和scss文件。如:
importUploaderfrom"./packages/uploader/index.js";import"./packages/uploader/uploader.scss";
  • packages對象添加新組件訊息。如:
constpackages={Cell,Dialog,Icon,Toast,...Uploader}

第一處修改並不困難,可以通過Node.jsnutui.js文件內容讀取,然後把兩個新的import加在內容頭部,再把新文本內容寫入文件。然鵝,第二處修改就有些困難了,如何向文件中的一個js對像中追加內容呢?一個靠譜的辦法就是AST ,即把讀取的文件內容解析成AST ,然後遍歷AST找到packages對象,向其中追加新組件訊息,最後生成新的代碼字符串,寫入nutui.js文件。而這些操作可以通過Babel自帶的相關模塊來完成[3]。

constt=require('@babel/types');const{parse}=require('@babel/parser');const{default:traverse}=require('@babel/traverse');const{default:generate}=require('@babel/generator');

好了,這篇文章先談到這裡。留一個思考題吧,我們知道,webpack 2+擁有了Tree-shaking(摇树)功能,能“搖”掉未用到的代碼,那麼如果我們不借助Babel插件處理,而直接使用下面這種ES6 modules語法來引入組件,未用到的組件會被“搖”掉嗎?答案當然是否定的,否則何必去開發個Babel插件,所以我真正要問的是為什麼不能呢?

import{Button,Switch}from'@nutui/nutui';

鏈接

  1. jqueryui.com/download/
  2. 【@nutui/babel-plugin-separate-import插件】 npmjs.com/package/@nutu
  3. github.com/jdf2e/nutui/
  4. 【NutUI代碼倉庫】 github.com/jdf2e/nutui/

What do you think?

Written by marketer

一種讓小程序支持JSX語法的新思路

基於TypeScript 的IoC 與DI