专栏首页前端安全如何实现自己的webpack
原创

如何实现自己的webpack

1 webpack

1.1 webpack是啥

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

图1-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官方文档,画了一个图1-2,此图可以较为清晰的描述webapck的工作过程。

图2 webpack的主要工作过程

上图可以理解为webpack的一个生命周期,我们可以看到webpack整个生命周期分为三个大的阶段:初始化 -> 编译 ->输出。webpack的整个生命周期是围绕内部的事件流进行的。

初始化阶段,webpack不仅初始化了自身的运行实例,而且还初始化了相关的插件和插件的事件监听动作。其中插件的事件监听尤为重要,比如UglifyJs这个插件就会监听后续webpack的输出相关事件,对最后的输出做代码压缩。

编译阶段是初始化阶段后进行的,当然也支持在watch模式下,由于Entry的文件内容发生变化,而触发热更新编译。编译阶段主要是读取Entry文件,然后匹配对应的Loader对模块进行处理,生成AST, 然后分析依赖,进而递归地调用Loader对依赖进行处理。全部经Loader处理之后,再根据配置组装成chunk。

最后是输出阶段,输出阶段主要对待输出的模块文件进行最后的确认,如果有插件需要处理,则这时是插件处理的最后机会,处理之后,开始根据output的配置规则输出最终文件。

2 写一个自己的构建工具

下面将从笔者近期的工作项目出发实例谈一下该如何写一个自己做主的打包工具。

2.1 为什么要自己写构建工具

笔者最近在做project升级改造的工作,新版的projectSDK是一个兼具npm引用(CMD)和web直引(AMD)方式的一套代码,在该项目中,我们需要对一套原始代码,最后打包两种模式的sdk。其中一套直接用于npm版本,另外一套是和现有project架构一致的线上直引版本。第二种版本需要从es6的cmd源代码转换成和web端一致的amd模式,并且每个es6模块都生成对应的amd版本的es5代码。

现有的webpack打包只能针对amd进行单一打包,模块中的引用也会被打入bundle中,这不符合预期。而且有一些具体的特性可能和实际project的业务逻辑有关,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.生成一个版本文件,projectSDK需要实现AMD缓存加载,需要记录每一个文件的版本号; 4.能够分析import语法,转换成AMD中的define中的依赖模块变量; 5.能够转换ES6语法到ES5语法; 6.能够实现压缩,输出文件需要压缩。

下面我将从多个方面针对上面提出的事项逐一进行解释和实现。

2.3 定义配置文件

配置文件的定义也是由自己做主的,如何定义配置文件的结构,主要关心:

1 影响结果的配置一定要体现 2 全局属性放在外层 3 同一个属性,模块的私有值优先于全局配置的值 4 entry,output属性必须配置 5 本项目需要处理哪几种文件类型,如何标识

结合本项目和以上的精神,初步制定了配置文件的结构:

module.exports={
	modules:[
		{
			name:'biz.login',//模块key名,也代表模块的文件名:biz/login.js
			version:'pc',//代表需要打包的版本,默认为pc和mobile都打包
			entryDir:'',//默认用全局的commEntryDir,有值会覆盖全局的commEntryDir,代表入口目录
			outputDir:''//默认用全局的commOutputDir,有值会覆盖全局的commOutputDir,代表输出目录
		},
		{
			name:'css!biz.roleselector',//模块key名,css!开头标识为css文件
		}
	],
	isMinify:true,//是否启用压缩
	versionFile:path.resolve('dist','project_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 自定义打包运行流程打印过程图g

2.5 预处理如何处理import、exports语法,如何转换成AMD代码

import 语法是es6中对其它模块的加载语法,exports语法是es6中对模块的输出语法,表示输出某个模块。这两个关键语法是整个ES6源码中的骨架语法,如果要转换成ES5,需要视情况而定,如果是AMD的ES5,则需要做一些特殊的转换处理,针对本项目,我们放在预处理阶段去做。

2.5.1 import的转换

本项目中import主要有以下三种使用方法:

//第一种:整体加载某js模块
import LoginManger from '@project_pc/biz/login'
//第二种:加载某模块中的1个或多个子模块
import {loginStatus,cookie} from '@project_pc/project'
//第三种:加载css
import '@project_pc/biz/login.css'

由于本项目中只处理这三种类型的import,故可以分别针对这三种类型的js语句做转换:

1.针对第一种:正则匹配为:var arrMatch=lineCode.match(/^(import\s+\w\$\_+\s+from\s+\'\".+\'\")$/); if(arrMatch){ moduleVar=arrMatch2;//加载的模块名(内部引用的变量名),比如样例中的:LoginManager modulePath=arrMatch3;//引用的路径,比如:样例中的'@project_pc/biz/login' }2.针对第二种,正则匹配为:var arrMatch=lineCode.match(/^(import\s+{(.+)}\s+from\s+\'\"\'\")$/); if(arrMatch){ moduleVar=arrMatch2;//加载的模块名(内部引用的变量名),比如样例中的:loginStatus,cookie modulePath=arrMatch3;//引用的路径,比如:样例中的'@project_pc/project' }这种情况下:moduleVar需要进行分解,如果留意有多个子模块的情况

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

3.针对第三种加载css的情况:var arrMatch=lineCode.match(/^(import\s+\'\".css\'\")$/); if(arrMatch){ modulePath=arr2; if(modulePath.indexOf('@project_pc\/') == 0){//pc模块 modulePath=modulePath.substr(9).replace(/\//g,'.'); }else if(modulePath.indexOf('@project_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安装babeltnpm install babel-core --save-dev2.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-dev2.利用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:

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

3 总结

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

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 前端调试入门

    这里的控制台特指PC端浏览器进入开发者模式之后新打开的操作界面。常见的控制台有Chrome的控制台,Firefox的firebug。这些都能帮助我们调试前端问题...

    MarsBoy
  • 浅谈Ajax跨域

    如果我们前端页面的url和我们要提交的后端url存在跨域问题时,我们该如何解决呢?

    MarsBoy
  • 前端安全之XSS攻防之道

    XSS全称Cross Site Script,意为跨站脚本攻击。本质上是一种“HTML注入”,由于历史原因,最初这种攻击在演示的时候是跨域攻击的,所以就叫跨域脚...

    MarsBoy
  • split命令

    split命令用于将大文件分割成较小的文件,在默认情况下将按照每1000行切割成一个小文件。

    WindrunnerMax
  • Flex快速上手

    Flex之于 CSS3 就如Promise之于 ES6,都解决了开发者的痛点问题,大大提高了生产力。借助Flex,可以轻松实现栅栏布局、水平/垂直居中、自定义排...

    心谭博客
  • 1014. 写评语

    1014. 写评语 (Standard IO) 时间限制: 1000 ms  空间限制: 262144 KB  具体限制  题目描述 输入某学生成绩score...

    attack
  • IaaS供应商选择:传统应用 VS. 云原生应用

    随着IaaS供应商们不断扩展其产品组合并提供包括更高级别服务在内的产品,用户应用的需求(不仅仅只是用户的基础设施)也成为了选择供应商的考虑因素之一。 在多年的犹...

    静一
  • 1002. 三角形 (

    题目描述 输入三角形三边长a,b,c(保证能构成三角形),输出三角形面积。 输入 一行三个用一个空格隔开的实数a,b,c,表示三角形的三条边长。 输出 输出三角...

    attack
  • 借一个项目谈Android应用软件架构,你还在套用MVP 或MVVM吗

    在《Android开发进阶,从小工到专家》一书的第26页中有这么一段话,说Android之父Andy Rubin在被采访时说过,在设计Android之初他...

    特立独行的猫a
  • 七个“神器”,保护好数据库,让删库无处遁形!

    当前,数据安全受到多方面的威胁。有来自系统软硬件的非人为故障,有运维工程师的误操作,甚至是黑客或内部人员的恶意删除。2017年1月31日,全球最大的代码托管服务...

    芋道源码

扫码关注云+社区

领取腾讯云代金券