腾讯互娱AMS | 我的打包我做主——浅析前端构建

作者介绍:Marsboy,现就职于腾讯游戏增值服务部,负责AMS游戏营销平台的前端开发工作。

1 webpack

1.1 webpack是啥

webpack是一个工具,是一个致力于做前端构建的工具。简单的理解:webpack就是一个模块打包机器,它可以将前端的js代码(不管ES6/ES7)、引用的css资源、图片资源、字体资源等各种资源进行打包整合,最后按照预设规则输出到一个或多个js模块文件中,并且可以做到兼容浏览器运行。图1是一个经典的阐述webpack是什么的一张官方图

[ 图1 webpack ]

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的工作过程。

[ 图2 webpack的主要工作过程 ]

上图可以理解为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 aimport moduleB from 'b'module.exports={    sayHello(){             console.log('hello,this is moduleA,import from'+moduleB.getDesc());    }}(左滑可查看完整代码,下同)

源代码:b模块

//file b.js//module bmodule.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打包在控制台上的输出样例:

[ 图3 webpack打包输出打印过程 ]

从上图中我们发现,webpack打包过程中,基本会输出以下信息:

1.hash信息 2.打包耗时 3.打包结束时间 4.每一个输出文件对应的chunk和基本信息

参考webpack的控制台输出,再结合本项目,我们其实可以自定义打包过程的输出信息:

1.每一步的开始、结束标识(预处理、编译转换、压缩、版本生成、输出) 2.每一步处理过程中的错误和异常 3.打包成功输出耗时、输出目录、版本文件目录、每一个输出模块的细节

如下图4是本项目中输出信息的一个流程图:

[ 图4 控制打印过程流程图 ]

在自定义的图4流程控制下,自定义的打包工具在控制台的输出样例如图5所示。

[ 图5 自定义打包运行流程打印过程图 ]

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'//第三种:加载cssimport '@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代码的过程

[ 图6 预处理-图解AMD模块的转换过程 ]

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 uglify-js@2.4.10 --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的md5function 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

[ 图7 我的打包脚本运行总流程图 ]

3 总结

前端构建无非是开发阶段中利用各种工具协助我们将源代码转换成最终在线上运行的代码的一个过程。这其中涉及到很多细分的步骤,我们在项目开发阶段的过程中,可以利用成熟的构建工具如webpack、gulp、grunt等,当然也可以选择自己写构建脚本,自己定义构建过程,自己处理编译,压缩的过程。本文乃笔者在实际项目中的经验总结,我的打包我做主,我们的宗旨是一切以项目的需求为主。由于笔者水平有限,欢迎大家指正,也欢迎大家一起沟通交流前端构建。

原文发布于微信公众号 - 腾讯技术工程(Tencent_TEG)

原文发表时间:2019-04-25

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

编辑于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券