by addy 原创文章,欢迎转载,但希望全文转载,注明本文地址。

本文地址:http://www.iamaddy.net/2014/10/inside-fis-kernel/

每次发版本都有个蛋疼的问题,同一个页面改版,不仅要保证发出去的页面不报错,而且得兼容现网的版本。最为传统的方法就是在资源文件后面加上一个随机参数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、自动更新的文件名
扒一扒前端构建工具FIS的内幕
2、“编译”文件(这里说的编译不同于后台编译的意思)

  • 资源定位:获取任何开发中所使用资源的线上路径;
  • 内容嵌入:把一个文件的内容(文本)或者base64编码(图片)嵌入到另一个文件中;
  • 依赖声明:在一个文本文件内标记对其他资源的依赖关系;

3、代码检校(jslint)
4、自动化测试
5、代码压缩(含cssSprite)
6、部署
常用功能如上面几点,FIS的文档非常的全面,可以参考文档了解更多。

三、原理

扒一扒前端构建工具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

扒一扒前端构建工具FIS的内幕
这样看起来是不是清楚点了,一共分了四组,根据|分一下。结合图就容易理解了,一共捕获了三个匹配:注释(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

扒一扒前端构建工具FIS的内幕
简单来看该正则能够匹配到<script>,<style>,<link>,<img>等这些静态资源的标记以及注释。限于篇幅就不把代码写上来。
js和css在HTML文件中的表现形式无外呼两种:外联和内联。所以在编译时是会定义一些语法,如上文提到的__uri,__inline,在html文件中也有对应的path?__inline等语法,表示内嵌内容到html页面。整体流程请看下图:
扒一扒前端构建工具FIS的内幕
好了,了解完整体的编译过程就来看个例子。

// 编译前
<!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>

编译后:
扒一扒前端构建工具FIS的内幕

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还有很多其他的功能,能够帮助优化开发流程,页面性能等方面。想要了解更多,各位看官还得自己去深究。

本文为原创文章,可能会经常更新知识点以及修正一些错误,因此转载请保留原出处,方便溯源,谢谢合作

本文地址:http://www.iamaddy.net/2014/10/inside-fis-kernel/

想要打赏?你的鼓励是我前进的动力! addy打赏二维码

关注个人公众号web_lab,不定期更新一些干货~ web_lab公众号