騰訊互娛AMS | 我的打包我做主——淺析前端構建

作者介紹:Marsboy,現就職於騰訊遊戲增值服務部,負責AMS遊戲營銷平臺的前端開發工作。

1 webpack

1.1 webpack是啥

webpack是一個工具,是一個致力於做前端構建的工具。簡單的理解:webpack就是一個模塊打包機器,它可以將前端的js代碼(不管ES6/ES7)、引用的css資源、圖片資源、字體資源等各種資源進行打包整合,最後按照預設規則輸出到一個或多個js模塊文件中,並且可以做到兼容瀏覽器運行。圖1是一個經典的闡述webpack是什麼的一張官方圖

23f81bbc9d2f485aba0f9cc2b497517e

1.2 webpack做了哪些工作

webpack的運行過程中主要會做以下工作:

1.初始化。從webpck的配置文件(webpack.config.js或其它)中讀取配置信息,或者從shell腳本的輸入參數中讀取配置信息,初始化本次的執行環節。2.加載插件,準備編譯。根據配置信息,加載本次執行所需要的所有相關插件。3.讀取入口文件。根據配置信息的entry屬性依次讀取要編譯入的文件。4.編譯。對第3步中讀取到的入口文件內容進行編譯,根據配置信息匹配相對於的Loader進行編譯,同時遞歸地對該文件所依賴的的文件/資源匹配相對於的Loader進行編譯。5.完成編譯。第四步中,得到每個模塊被編譯後的內容,以及模塊之間的依賴關係。6.準備輸出。根據第5步中的編譯內容和模塊的依賴關係,將每一個主入口文件和其所依賴的所有模塊組成一個chunk,根據配置的entry得到一個chunk列表。7.輸出到文件。根據第6步的結果結合webpack配置信息中的output參數按照指定的格式,對每一個輸出chunk進行命名,chunk內容轉換(主要是指輸出的模塊類型,比如指定輸出amd,umd等)並輸出到指定的路徑中。


1.3 webpack是如何做到的

筆者結合webpack官方文檔,畫了一個圖2,此圖可以較爲清晰的描述webapck的工作過程。

342ce99a8189439c88013a1b8f0b7c5f

上圖可以理解爲webpack的一個生命週期,我們可以看到webpack整個生命週期分爲三個大的階段:初始化 -> 編譯 ->輸出。webpack的整個生命週期是圍繞內部的事件流進行的。

初始化階段,webpack不僅初始化了自身的運行實例,而且還初始化了相關的插件和插件的事件監聽動作。其中插件的事件監聽尤爲重要,比如UglifyJs這個插件就會監聽後續webpack的輸出相關事件,對最後的輸出做代碼壓縮。

編譯階段是初始化階段後進行的,當然也支持在watch模式下,由於Entry的文件內容發生變化,而觸發熱更新編譯。編譯階段主要是讀取Entry文件,然後匹配對應的Loader對模塊進行處理,生成AST, 然後分析依賴,進而遞歸地調用Loader對依賴進行處理。全部經Loader處理之後,再根據配置組裝成chunk。

最後是輸出階段,輸出階段主要對待輸出的模塊文件進行最後的確認,如果有插件需要處理,則這時是插件處理的最後機會,處理之後,開始根據output的配置規則輸出最終文件。

2 寫一個自己的構建工具

下面將從筆者近期的工作項目出發實例談一下該如何寫一個自己做主的打包工具。

2.1 爲什麼要自己寫構建工具

筆者最近在做內部A項目的升級改造的工作,新版的A項目是一個兼具npm引用(CMD)和web直引(AMD)方式的一套代碼,在該項目中,我們需要對一套原始代碼,最後打包兩種模式的sdk。其中一套直接用於npm版本,另外一套是和現有架構一致的線上直引版本。第二種版本需要從es6的cmd源代碼轉換成和web端一致的amd模式,並且每個es6模塊都生成對應的amd版本的es5代碼。

現有的webpack打包只能針對amd進行單一打包,模塊中的引用也會被打入bundle中,這不符合預期。而且有一些具體的特性可能和實際A項目的業務邏輯有關,webpack定製程度不高。

舉個例子:有a.js,b.js兩個模塊,源代碼中這麼寫:

源代碼:a模塊
//file a.js//module a
import moduleB from 'b'
module.exports={
 sayHello(){ 
 console.log('hello,this is moduleA,import from'+moduleB.getDesc());
 }
}
(左滑可查看完整代碼,下同)
源代碼:b模塊
//file b.js//module b
module.exports={
 getDesc(){ 
 return 'moduleBBB';
 }
}

預期輸出代碼:a模塊

從源代碼中,我們看到模塊a引用了模塊b,我們希望打包出來的模塊a是這樣的:

define('a',['b'],function(moduleB){ 
 return {
 sayHello:function(){ 
 console.log('hello,this is moduleA,import from'+moduleB.getDesc());
 }
 }
})

實際輸出代碼:a模塊

但是使用webpack的libraryTarget屬性設置爲’amd’之後打包出來的a模塊如下,會將b模塊的內容寫入了a模塊中,實際在運行a模塊的時候b模塊並沒有通過amd方式異步加載,與我們的預期不符合。

define('a',[],function(){ 
var moduleB={
 sayHello:function(){ 
 return 'moduleBBB';
 }
 } 
 return {
 sayHello:function(){ 
 console.log('hello,this is moduleA,import from'+moduleB.getDesc());
 }
 }
})

故:需要自定義編譯邏輯,於是想到了自己寫一套構建工具

2.2 需要做哪些準備工作

準備哪些工作取決於我們想要什麼樣的東西,進而要了解我們如何一步步實現這樣的結果。

2.1中我已經簡單說了一下我們的項目背景,下面我將這次自定義的構建工具需要關心的事情列如下:

1.需要和webpack一樣,能設計一個配置文件的格式,通過配置文件控制輸入和輸出;2.需要和webpack一樣能夠在控制檯執行的時候,能夠打印出相關的過程(包括成功的信息、報錯的信息);3.生成一個版本文件,A項目需要實現AMD緩存加載,需要記錄每一個文件的版本號;4.能夠分析import語法,轉換成AMD中的define中的依賴模塊變量;5.能夠轉換ES6語法到ES5語法;6.能夠實現壓縮,輸出文件需要壓縮。

下面我將從多個方面針對上面提出的事項逐一進行解釋和實現。

2.3 定義配置文件

配置文件的定義也是由自己做主的,如何定義配置文件的結構,主要關心:

1 影響結果的配置一定要體現2 全局屬性放在外層3 同一個屬性,模塊的私有值優先於全局配置的值4 entry,output屬性必須配置5 本項目需要處理哪幾種文件類型,如何標識

結合本項目和以上的精神,初步制定了配置文件的結構:

module.exports={
 modules:[
 {
 name:'util.login',//模塊key名,也代表模塊的文件名:util/login.js
 version:'pc',//代表需要打包的版本,默認爲pc和mobile都打包
 entryDir:'',//默認用全局的commEntryDir,有值會覆蓋全局的commEntryDir,代表入口目錄
 outputDir:''//默認用全局的commOutputDir,有值會覆蓋全局的commOutputDir,代表輸出目錄
 },
 {
 name:'css!util.role',//模塊key名,css!開頭標識爲css文件
 }
 ],
 isMinify:true,//是否啓用壓縮
 versionFile:path.resolve('dist','a_module_version.js'),//版本配置輸出文件,用於輸出版本信息
 commEntryDir:'src',//入口目錄
 commOutputDir:'dist'//輸出目錄}

其中:

1.本項目中只處理兩種文件:js文件和css文件

2.isMinify標識是否壓縮

3.versionFile:標識版本配置輸出地址

4.entry和output相關的配置

5.version標識本模塊需要處理的哪些類型入口(一共兩個入口:pc入口和mobile入口)

2.4 如何控制打印過程

打印過程這裏指webpack執行過程中,控制檯上的一些輸出信息,包括成功的輸出和失敗的輸出。一個打包工具在運行過程中,肯定需要在控制檯中輸出一些狀態信息,供使用者參考和了解運行狀態。

下圖3是webpack打包在控制檯上的輸出樣例:

9625743843b84d149d6a670e147ee95b

從上圖中我們發現,webpack打包過程中,基本會輸出以下信息:

1.hash信息2.打包耗時3.打包結束時間4.每一個輸出文件對應的chunk和基本信息

參考webpack的控制檯輸出,再結合本項目,我們其實可以自定義打包過程的輸出信息:

1.每一步的開始、結束標識(預處理、編譯轉換、壓縮、版本生成、輸出)2.每一步處理過程中的錯誤和異常3.打包成功輸出耗時、輸出目錄、版本文件目錄、每一個輸出模塊的細節


如下圖4是本項目中輸出信息的一個流程圖:

b6b379b9f34f49b3bcf1bd2b53b3d3da


在自定義的圖4流程控制下,自定義的打包工具在控制檯的輸出樣例如圖5所示。

ca2969e7c6164bbfb2670e48ec102172

2.5 [預處理]如何處理import、exports語法,如何轉換成AMD代碼

import 語法是es6中對其它模塊的加載語法,exports語法是es6中對模塊的輸出語法,表示輸出某個模塊。這兩個關鍵語法是整個ES6源碼中的骨架語法,如果要轉換成ES5,需要視情況而定,如果是AMD的ES5,則需要做一些特殊的轉換處理,針對本項目,我們放在預處理階段去做。

2.5.1 import的轉換

本項目中import主要有以下三種使用方法:

//第一種:整體加載某js模塊
import LoginManger from '@a_pc/util/login'/
/第二種:加載某模塊中的1個或多個子模塊
import {loginStatus,cookie} from '@a_pc/sdk'
//第三種:加載css
import '@a_pc/util/login.css'

由於本項目中只處理這三種類型的import,故可以分別針對這三種類型的js語句做轉換:

1.針對第一種:正則匹配爲:

var arrMatch=lineCode.match(/^(import\s+[\w\$\_]+\s+from\s+[\'\"].+[\'\"])$/);
if(arrMatch){
 moduleVar=arrMatch[2];//加載的模塊名(內部引用的變量名),比如樣例中的:LogManage
 modulePath=arrMatch[3];//引用的路徑,比如:樣例中的'@a_pc/util/login'
}


2.針對第二種,正則匹配爲:

var arrMatch=lineCode.match(/^(import\s+\{(.+)\}\s+from\s+[\'\"](.+)[\'\"])$/);
if(arrMatch){
 moduleVar=arrMatch[2];//加載的模塊名(內部引用的變量名),比如樣例中的:loginStatus,cookie
 modulePath=arrMatch[3];//引用的路徑,比如:樣例中的'@a_pc/sdk'
}

這種情況下:moduleVar需要進行分解,如果留意有多個子模塊的情況

var arrModuleVar=moduleVar.split(',');

3.針對第三種加載css的情況:

var arrMatch=lineCode.match(/^(import\s+[\'\"](.+)\.css[\'\"])$/);
if(arrMatch){
 modulePath=arr[2]; 
 if(modulePath.indexOf('@a_pc\/') == 0){//pc模塊
 modulePath=modulePath.substr(9).replace(/\//g,'.');
 }
 else if(modulePath.indexOf('@a_mobile\/') == 0){//移動端模塊
 modulePath=modulePath.substr(13).replace(/\//g,'.');
 }
 moduleVar=moduleName='css!'+modulePath;//css模塊在AMD中的模塊名前面要加css!}

每一個js,在進行文本分析的過程中,可能不止一個import語句,也就是不止一個依賴,這些依賴都要放到數組中,最後所有語句分析完之後,再組合成數組依賴。

2.5.2 exports語句的轉換

本項目默認exports語句是這麼寫的:

module.exports=xxx;

同時也默認exports語句後面不再有任何代碼,這樣的話,對exports的轉換就很方便:

if(/^(module.exports\s{0,}\=\s{0,})/.test(lineCode)){ 
 var arrMatch=line.match(/^(module.exports\s{0,}\=\s{0,})($|(.+))/);
 exportBody=arr[2]+'\n'+arrCodeLine.slice(i+1,arrCodeLine.length).join('\n');//exportBody爲整體輸出語句,相當於define裏面的return後面的語句
 break;//跳出分析每一行語句的循環}

下圖6簡單描述了整個預處理階段ES6代碼如何轉換成我們需要的AMD代碼的過程

d28a328086014eb791439dd478556a53

2.6 [編譯]如何處理ES6

由於本項目的源碼是用ES6編寫的,打包需要對ES6進行轉換,轉換成兼容各種瀏覽器的ES5代碼。這種轉換涉及到語法,語義,詞法等分析的過程,而且涉及到的ES6語法非常多,理論上需要轉換成AST。由於過程複雜,所以我們需要用成熟的第三方api庫去處理。

webpack中處理js的編譯的loader用的是babel,這裏我們也選擇babel。這裏我們用到了babel的api使用方法:

1.首先npm安裝babel

tnpm install babel-core --save-dev

2.api使用

//引用babel-core模塊var babel=require('babel-core');
function babelBuild(modName,code){ //css文件不處理
 if(/^(css\!)/.test(modName)){ 
 return code;
 } 
 let result=babel.transform(code,{ 
 "presets": [
 [ 
 "env",
 { 
 "loose": true, 
 "modules": false
 }
 ]
 ]
 }); 
 return result.code;
}


預處理過的代碼作爲編譯階段的輸入,作爲參數code傳入上面的babelBuild函數中,即可輸出轉換過的ES5代碼。

注意:由於babel-core默認只對新的語法做處理,而不處理新的api,比如map,array中的一些新的方法等,如果要處理,需要藉助babel-polifill墊片處理。

2.7 [壓縮]如何壓縮

說到js代碼壓縮,大家估計都會第一個想到uglifyjs,確實,在webpack打包流程中,uglifyjs就以插件的形式爲webpack的打包提供壓縮服務。或許我們都知道UglifyJs的命令行使用方法,其實UglifyJs還提供了api的調用方式。

想要使用uglifyjs的api方式壓縮js代碼,我們需要按照以下步驟:

1.首先我們要npm安裝相關的模塊:

tnpm install [email protected] --save-dev

注意:這裏安裝的時候需要指定使用2.4.10版本,因爲筆者在使用的過程中發現uglify-js3.x的版本在api的用法中存在一些bug。

2.api的使用

//引用uglify-js模塊
var UglifyJS = require("uglify-js");
function minifyBuild(modName,code){ //css文件不處理
 if(/^(css\!)/.test(modName)){ 
 return code;
 } 
 try{ 
 var ast=UglifyJS.parse(code);
 ast.figure_out_scope(); 
 /*
 ascii_only配置會將中文轉換成unicode碼的\uxxxx的方式,
 使輸出的js對utf-8/gbk不敏感
 */
 var stream = UglifyJS.OutputStream({"ascii_only":true});
 ast.print(stream); 
 var outCode = stream.toString();
 return outCode;
 }catch(e){
 showLog.error(e);//控制檯錯誤處理輸出
 return false;
 }
}

編譯過的代碼作爲壓縮階段的輸入,作爲參數code傳入上面的minifyBuild函數中,即可輸出壓縮過的代碼。

2.8 如何輸出版本文件和目標文件

2.8.1 輸出版本文件

由於本項目中,我們在瀏覽器的層面(利用localStorage)加入了AMD模塊加載緩存的機制,所以需要用到每一個js模塊文件的當前版本號這麼一個參數,這個版本號主要用來區分緩存中的文件和當前線上的版本是否一致。由於無需關心版本的前後關係,所以只要版本號能和文件強關聯就行。

基於上面的需求,我們定義每個文件的版本號爲其文本內容的32位md5簽名。

所以生成版本號的解決方案如下:

1.npm安裝md5模塊

tnpm install md5 --save-dev

2.利用md5模塊生成版本號

var md5=require('md5');
//生成對應code的md5
function generateVersion(code){ 
 return md5(code);
}

壓縮過的代碼作爲生成版本號的函數內容輸入,作爲參數code傳入上面的generateVersion函數中,即可輸出對應文件的版本號。

2.8.1 輸出目標文件

上節2.7的輸出即是每個模塊的目標文件內容,利用nodejs的FileSystem的api,將文件輸出到配置文件中指定的outputDir中即可。

相關代碼示例如下:

var output=outputDir+'/'+moduleName+'.js';//模塊moduleName的輸出路徑
fs.writeFile(output,code,(err)=>{ 
 if(err){
 showLog.error('writeResult[輸出編譯結果到文件過程出錯]',err); return;
 }
 outCount++;//記錄已經成功寫入文件的模塊數
 //所有模塊輸出均已經寫到文件
 if(outCount == fileData.length){
 showLog.success('==輸出編譯結果完成==');
 timestampEnd=new Date().getTime();
 showLog.success('Build OK','Basic Info:');
 showLog.field("Time",(timestampEnd-timestampStart)+'ms');
 showLog.field("Built at",new Date().toLocaleString());
 showLog.field("Total Files",fileData.length);
 showLog.field('Modules Output Root:',outputRoot);
 showLog.field('version File Path:',versionFile);
 showLog.field("Details",''); 
 //輸出詳細結果
 showDetailResult();
 }
})


2.9 總體流程

以上是筆者在實際項目中關於如何自己打包腳本的見解,綜上所述,自定義腳本的主要運行流程如圖7


946baab8757740dc96ec5040765f4686

3 總結

前端構建無非是開發階段中利用各種工具協助我們將源代碼轉換成最終在線上運行的代碼的一個過程。這其中涉及到很多細分的步驟,我們在項目開發階段的過程中,可以利用成熟的構建工具如webpack、gulp、grunt等,當然也可以選擇自己寫構建腳本,自己定義構建過程,自己處理編譯,壓縮的過程。本文乃筆者在實際項目中的經驗總結,我的打包我做主,我們的宗旨是一切以項目的需求爲主。由於筆者水平有限,歡迎大家指正,也歡迎大家一起溝通交流前端構建。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章