扒一扒前端构建工具FIS的内幕
by addy 原创文章,欢迎转载,但希望全文转载,注明本文地址。
每次发版本都有个蛋疼的问题,同一个页面改版,不仅要保证发出去的页面不报错,而且得兼容现网的版本。最为传统的方法就是在资源文件后面加上一个随机参数xxx.js?t=1234
,但即使是这样也无法保证拉回的xxx.js是新文件。最为安全的方法是将文件名改为新的。
面对多人开发、模块共用等问题,你会发现手动改文件名是一个非常搓的想法,程序员喜欢偷懒,这是天性。理想的状态可能是这样的:不需要去做多余的体力劳动,只需一个命令即可完成,从开发到提测,到最后的上线。
基于nodejs的构建工具还是比较多,诸如grunt,gulp,yeoman,fis等等。
经过一些日子的捣腾,现在FIS已经在团队跑起来了,完全避免了发版本的问题。但FIS带来的好处远不止这些,下一篇文章会讲到我是如何将FIS运用到项目中的,以及对性能优化带来的好处。各位看官,这次且随我看看FIS到底是个什么玩意儿。
一、什么是FIS
来自FIS官网的说明
FIS是专为解决前端开发中自动化工具、性能优化、模块化框架、开发规范、代码部署、开发流程等问题的工具框架。解决了诸如前端静态资源加载优化、页面运行性能优化、基础编译工具、运行环境模拟、js与css组件化开发等前端领域开发的核心问题。
FIS 可以看成是前端的构建工具,但功能比grunt和gulp要多些,grunt和gulp主要是依赖插件来完成任务,把每个构建需求细分为小的任务。而FIS像一个大而全的框架,把一些功能写入到框架的主体,也有插件机制,定制化任务。
二、FIS能做什么
1、自动更新的文件名
2、“编译”文件(这里说的编译不同于后台编译的意思)
3、代码检校(jslint)
4、自动化测试
5、代码压缩(含cssSprite)
6、部署
常用功能如上面几点,FIS的文档非常的全面,可以参考文档了解更多。
三、原理
上图来自FIS官网,该图说明了编译与打包的整个流程,非常的清晰。其中最为关键的是资源文件编译的过程,除去标准编译过程其他的基本都是通过插件来实现。接下来看看文件的编译是如何做的。
3.1 文件编译
入口:fis release -d ../ -w -w
,输入命令进行编译,release后面是一些参数,控制编译的流程。执行的是release.js 这个模块,读取项目的配置文件,完成一些初始化工作,然后读取文件,开始编译。
最关键的一个模块即:compile.js,对匹配到的文件做一次分析。
// 对外的接口
var exports = module.exports = function(file){
......
};
参数的是单个文件,file是fis定义的一种类型,可以看出是对node中的file对象做了一些扩展,加入了一些自定义的属性和方法。
首先初始化缓存目录,然后开始编译,判断文件路径是否真实存在。
接下来读缓存,如果命中缓存不处理(通过对照文件的MD5值判断缓存),否则:
// 读取对当前文件的配置属性,处理编译前与后
exports.settings.beforeCompile(file);
file.setContent(fis.util.read(file.realpath));
process(file);// 真正的编译过程
exports.settings.afterCompile(file);
cache.save(file.getContent(), revertObj); // 缓存,为下一次构建加速
process这个函数实际就是文件编译的过程,根据不同文件的属性进行不同的处理。
function process(file){
// useParser 和usePreprocessor 可理解为预编译的过程
// 处理less和coffee等类css、js代码
if(file.useParser !== false){
pipe(file, 'parser', file.ext);
}
if(file.rExt){
// 预处理插件
if(file.usePreprocessor !== false){
pipe(file, 'preprocessor', file.rExt);
}
if(file.useStandard !== false){
standard(file); // 所有文件的标准编译处理
}
......
}
}
process用到了pipe这个方法,文件流,Stream,控制读和写的平衡,这样就不会因单个文件编译过久而阻塞其他的文件编译。有点类似于glup中的pipe。
// 文件编译的过程
function standard(file){
var path = file.realpath,
content = file.getContent();
// 文件的内容判断是否为字符串
if(typeof content === 'string'){
fis.log.debug('standard start');
//expand language ability
// 根据文件的类型,进行不同的处理
if(file.isHtmlLike){
// 分析html文件(包括php,tpl等类html文件,这些类型在fis.util文件中有列出)
content = extHtml(content);
} else if(file.isJsLike){
// 分析JS文件
content = extJs(content);
} else if(file.isCssLike){
// 分析css文件
content = extCss(content);
}
content = content.replace(map.reg, function(all, type, value){
var ret = '', info;
try {
switch(type){
// 模块引用处理
case 'require':
// 省略更多逻辑 ....
// 动态资源定位
// __uri(xxx.js) 以这种在js代码中动态加载的文件
case 'uri':
.....
// 模块依赖处理
case 'dep':
....
// 资源嵌入处理
case 'embed':
.....
case 'jsEmbed':
......
default :
fis.log.error('unsupported fis language tag [' + type + ']');
}
} catch (e) {
}
return ret;
});
file.setContent(content);
fis.log.debug('standard end');
}
}
最关键的还是这几个分析文件的函数:extHtml、extjs、extCss。原理很简单,就是通过正则表达式来匹配。
/*
分析写在注释中的依赖[@require id]
__inline(path) 嵌入资源内容,或者base64编码的图片。
__uri(path) 定位动态资源
require(path) 定位模块的依赖,如seajs中的写法。
*/
function extJs(content, callback){
var reg = /"(?:[^\\"\r\n\f]|\\[\s\S])*"|'(?:[^\\'\n\r\f]|\\[\s\S])*'|(\/\/[^\r\n\f]+|\/\*[\s\S]*?(?:\*\/|$))|\b(__inline|__uri|require)\s*\(\s*("(?:[^\\"\r\n\f]|\\[\s\S])*"|'(?:[^\\'\n\r\f]|\\[\s\S])*')\s*\)/g;
callback = callback || function(m, comment, type, value){
if(type){
// 根据资源标记的类型做不同的处理
// map 这里是预先定义好的几种类型,通过编译成不同的标记,供后面处理
switch (type){
case '__inline':
m = map.jsEmbed.ld + value + map.jsEmbed.rd;
break;
case '__uri':
m = map.uri.ld + value + map.uri.rd;
break;
case 'require':
m = 'require(' + map.require.ld + value + map.require.rd + ')';
break;
}
} else if(comment){
// 分析注释
m = analyseComment(comment);
}
return m;
};
// replace 正则替换 返回回调函数处理后的内容
return content.replace(reg, callback);
}
第一眼看到这个正则可能会有点晕,那就可视化一下:
/"(?:[^\\"\r\n\f]|\\[\s\S])*"|'(?:[^\\'\n\r\f]|\\[\s\S])*'|(\/\/[^\r\n\f]+|\/\*[\s\S]*?(?:\*\/|$))|\b(__inline|__uri|require)\s*\(\s*("(?:[^\\"\r\n\f]|\\[\s\S])*"|'(?:[^\\'\n\r\f]|\\[\s\S])*')\s*\)/g
这样看起来是不是清楚点了,一共分了四组,根据|
分一下。结合图就容易理解了,一共捕获了三个匹配:注释(comment)、类型(type)、以及路径(path),其他的不做处理。然后通过replace回调返回编译好的标记位。
// 几个例子
__uri('a.js') -> <<<uri:'a.js'>>>
require('a.js') -> require('<<<require:'a.js'>>>')
__inline('a.js') -> <<<jsEmbed:'a.js'>>>
接下来简单看下分析html的那个函数,正则如下:
/(<script(?:(?=\s)[\s\S]*?["'\s\w\/\-]>|>))([\s\S]*?)(?=<\/script\s*>|$)|(<style(?:(?=\s)[\s\S]*?["'\s\w\/\-]>|>))([\s\S]*?)(?=<\/style\s*>|$)|<(img|embed|audio|video|link|object|source)\s+[\s\S]*?["'\s\w\/\-](?:>|$)|<!--inline\[([^\]]+)\]-->|<!--(?!\[)([\s\S]*?)(-->|$)/ig
简单来看该正则能够匹配到<script>,<style>,<link>,<img>
等这些静态资源的标记以及注释。限于篇幅就不把代码写上来。
js和css在HTML文件中的表现形式无外呼两种:外联和内联。所以在编译时是会定义一些语法,如上文提到的__uri,__inline,在html文件中也有对应的path?__inline等语法,表示内嵌内容到html页面。整体流程请看下图:
好了,了解完整体的编译过程就来看个例子。
// 编译前
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title>Todo</title>
<link rel="stylesheet" type="text/css" href="a.css">
<link rel="stylesheet" type="text/css" href="b.css?__inline">
<script type="text/javascript" src="a.js"></script>
<script type="text/javascript" src="b.js?__inline"></script>
<script type="text/javascript">
__inline('c.js');
var src = __uri('c.css');
</script>
</head>
<body>
</body>
</html>
3.2 资源定位与内容嵌入
归根结底,以html文件为源头开始分析,无非是找到html文件所依赖的资源文件,然后按照其构建的语法糖进行处理。所以拿到编译后的内容,然后进行资源查找。
// content 是编译为标记位后的内容,正则替换,map.reg 为几个标记位关键字组成的一个表达式,type这里又回到了上文所提到的那些关键字。根据不同的类型做不同的处理。
content = content.replace(map.reg, function(all, type, value){
var ret = '', info;
try {
switch(type){
// 依赖分析
case 'require':
// more 省略更多逻辑
break;
// 资源定位
case 'uri':
// more
break;
case 'dep':
/// more
break;
// 资源嵌入
case 'embed':
case 'jsEmbed':
//// more
break;
default :
fis.log.error('unsupported fis language tag [' + type + ']');
}
这里来看下资源定位(uri)的逻辑:
// value 是资源文件的路径,即上一节中提到编译后带uri标记的path
// dirname 是当前处理文件的父目录
// 根据这两个变量就能够定位到资源文件是否存在
// fis 的uri模块提供了这样的接口来定位文件
info = fis.uri(value, file.dirname);
// 文件存在
if(info.file && info.file.isFile()){
// 编译带md5后缀的文件
if(info.file.useHash && exports.settings.hash){
// 资源检查,避免循环嵌入,或者资源自身嵌入自身 或者重复编译
if(embeddedCheck(file, info.file)){
// 将资源文件递归处理,进入下一轮编译
exports(info.file);
addDeps(file, info.file);
}
}
// 处理资源文件的查询参数? 或者&
var query = (info.file.query && info.query) ? '&' + info.query.substring(1) : info.query;
// 获取文件的路径
// 两个参数即hash,domain,这个是在fis的配置文件中设置的。
// 资源文件的hash值,默认取文件md5值的前7位,位数可以自由配置
// domain 资源文件的域名配置
var url = info.file.getUrl(exports.settings.hash, exports.settings.domain);
// 资源文件的hash 即 path#xxxx
var hash = info.hash || info.file.hash;
// 最后拼装资源文件的地址
ret = info.quote + url + query + hash + info.quote;
} else {
// 没有定位到直接返回路径 相当于不处理
ret = value;
}
正则替换后将返回的内容通过下面的方法写入文件
file.setContent(content); //写入文件。
到这为止,标准的文件编译过程结束。看个例子就比较明了了:
// 源码
<script type="text/javascript" src="a.js"></script>
// 编译为标记位内容
<script type="text/javascript" src="<<<uri:a.js>>>"></script>
// 资源定位后
<script type="text/javascript" src="a_cb6134f.js"></script>
资源嵌入的方式也比较简单,file对象有个getContent()
的方法,可以获取文件的内容,如果文件内容是文本就直接替换标记位embed,如果不是,则转换为base64编码后替换embed标记位,这里就不做详细分析了。
3.3 部署文件
在release这个模块中定义了一个deploy方法,这个是将代码同步到远程的计算机上去,只需在计算机上部署一个接收文件的脚本,以post的方式发送过去,原理比较简单。
四 总结
这里分析的只是fis编译文件的原理,fis还有很多其他的功能,能够帮助优化开发流程,页面性能等方面。想要了解更多,各位看官还得自己去深究。
本文为原创文章,可能会经常更新知识点以及修正一些错误,因此转载请保留原出处,方便溯源,谢谢合作
个人知乎,欢迎关注:https://www.zhihu.com/people/iamaddy
欢迎关注公众号【入门游戏开发】
分析到这种程度,真厉害!
a神很赞
赞
过来看看
讲解的很透彻,学习了。
不记得第几次拜读了,最近也在看fis源码,不过这里提到pipe,用了文件流,但我看源码似乎并没有用到Stream,我看到fis读取文件基本用的是node api里的sync同步方法,pipe的代码我看了好久,感觉稍微有点绕,但没用stream相关操作。