你尚未登录,仅允许查看本站部分内容。请登录使用邀请码注册
程序猿小卡

FIS源码-fis release增量编译与依赖扫描细节 0个回复 专栏 @ 框架与库

程序猿小卡 发布于 2 年前

开篇

前面已经提到了fis release命令大致的运行流程。本文会进一步讲解增量编译以及依赖扫描的一些细节。

首先,在fis release后加上--watch参数,看下会有什么样的变化。打开命令行

fis release --watch

不难猜想,内部同样是调用release()方法把源文件编译一遍。区别在于,进程会监听项目路径下源文件的变化,一旦出现文件(夹)的增、删、改,则重新调用release()进行增量编译。

并且,如果资源之间存在依赖关系(比如资源内嵌),那么一些情况下,被依赖资源的变化,会反过来导致资源引用方的重新编译。

// 是否自动重新编译
if(options.watch){
    watch(options);// 对!就是这里
} else {
    release(options);
}

下面扒扒源码来验证下我们的猜想。

watch(opt)细节

源码不算长,逻辑也比较清晰,这里就不上伪代码了,直接贴源码出来,附上一些注释,应该不难理解,无非就是重复文件变化-->release(opt)这个过程。

在下一小结稍稍展开下增量编译的细节。

function watch(opt){
    var root = fis.project.getProjectPath();
    var timer = -1;
    var safePathReg = /[\\\/][_\-.\s\w]+$/i;    // 是否安全路径(参考)
    var ignoredReg = /[\/\\](?:output\b[^\/\\]*([\/\\]|$)|\.|fis-conf\.js$)/i;  // ouput路径下的,或者 fis-conf.js 排除,不参与监听
    opt.srcCache = fis.project.getSource(); // 缓存映射表,代表参与编译的源文件;格式为 源文件路径=>源文件对应的File实例。比较奇怪的是,opt.srcCache 没见到有地方用到,在 fis.release 里,fis.project.getSource() 会重新调用,这里感觉有点多余

    // 根据传入的事件类型(type),返回对应的回调方法
    // type 的取值有add、change、unlink、unlinkDir
    function listener(type){
        return function (path) {
            if(safePathReg.test(path)){
                var file = fis.file.wrap(path);
                if (type == 'add' || type == 'change') {    // 新增 或 修改文件
                    if (!opt.srcCache[file.subpath]) {  // 新增的文件,还不在 opt.srcCache 里
                        var file = fis.file(path);
                        opt.srcCache[file.subpath] = file;  // 从这里可以知道 opt.srcCache 的数据结构了,不展开
                    }
                } else if (type == 'unlink') {  // 删除文件
                    if (opt.srcCache[file.subpath]) {
                        delete opt.srcCache[file.subpath];  // 
                    }
                } else if (type == 'unlinkDir') {   // 删除目录
                     fis.util.map(opt.srcCache, function (subpath, file) {
                        if (file.realpath.indexOf(path) !== -1) {
                            delete opt.srcCache[subpath];
                        }
                    });                       
                }
                clearTimeout(timer);
                timer = setTimeout(function(){
                    release(opt);   // 编译,增量编译的细节在内部实现了
                }, 500);
            }
        };
    }

    //添加usePolling配置
    // 这个配置项可以先忽略
    var usePolling = null;

    if (typeof fis.config.get('project.watch.usePolling') !== 'undefined'){
        usePolling = fis.config.get('project.watch.usePolling');
    }

    // chokidar模块,主要负责文件变化的监听
    // 除了error之外的所有事件,包括add、change、unlink、unlinkDir,都调用 listenter(eventType) 来处理
    require('chokidar')
        .watch(root, {
            // 当文件发生变化时候,会调用这个方法(参数是变化文件的路径)
            // 如果返回true,则不触发文件变化相关的事件
            ignored : function(path){
                var ignored = ignoredReg.test(path);    // 如果满足,则忽略
                // 从编译队列中排除
                if (fis.config.get('project.exclude')){
                    ignored = ignored ||
                        fis.util.filter(path, fis.config.get('project.exclude'));   // 此时 ignoredReg.test(path) 为false,如果在exclude里,ignored也为true
                }
                // 从watch中排除
                if (fis.config.get('project.watch.exclude')){
                    ignored = ignored ||
                        fis.util.filter(path, fis.config.get('project.watch.exclude')); // 跟上面类似
                }
                return ignored;
            },
            usePolling: usePolling,
            persistent: true
        })
        .on('add', listener('add'))
        .on('change', listener('change'))
        .on('unlink', listener('unlink'))
        .on('unlinkDir', listener('unlinkDir'))
        .on('error', function(err){
            //fis.log.error(err);
        });
}

增量编译细节

增量编译的要点很简单,就是只发生变化的文件进行编译部署。在fis.release(opt, callback)里,有这段代码:

// ret.src 为项目下的源文件
fis.util.map(ret.src, function(subpath, file){
    if(opt.beforeEach) {
        opt.beforeEach(file, ret);
    }
    file = fis.compile(file);
    if(opt.afterEach) {
        opt.afterEach(file, ret);// 这里这里!
    }

opt.afterEach(file, ret)这个回调方法可以在 fis-command-release/release.js 中找到。归纳下:

  1. 对比了下当前文件的最近修改时间,看下跟上次缓存的修改时间是否一致。如果不一致,重新编译,并将编译后的实例添加到collection中去。
  2. 执行deploy进行增量部署。(带着collection参数)
opt.afterEach = function(file){
    //cal compile time
    // 略过无关代码

    var mtime = file.getMtime().getTime();// 源文件的最近修改时间
    //collect file to deploy
    // 如果符合这几个条件:1、文件需要部署 2、最近修改时间 不等于 上一次缓存的修改时间
    // 那么重新编译部署
    if(file.release && lastModified[file.subpath] !== mtime){
        // 略过无关代码

        lastModified[file.subpath] = mtime;
        collection[file.subpath] = file;  // 这里这里!!在 deploy 方法里会用到
    }
};

关于deploy ,细节先略过,可以看到带上了collection参数。

deploy(opt, collection, total);// 部署~

依赖扫描概述

在增量编译的时候,有个细节点很关键,变化的文件,可能被其他资源所引用(如内嵌),那么这时,除了编译文件之身,还需要对引用它的文件也进行编译。

原先我的想法是:

  1. 扫描所有资源,并建立依赖分析表。比如某个文件,被多少文件引用了。
  2. 某个文件发生变化,扫描依赖分析表,对引用这个文件的文件进行重新编译。

看了下FIS的实现,虽然大体思路是一致的,不过是反向操作。从资源引用方作为起始点,递归式地对引用的资源进行编译,并添加到资源依赖表里。

  1. 扫描文件,看是否有资源依赖。如有,对依赖的资源进行编译,并添加到依赖表里。(递归)
  2. 编译文件。

从例子出发

假设项目结构如下,仅有index.htmlindex.cc两个文件,且 index.html 通过 __inline 标记嵌入 index.css

^CadeMacBook-Pro-3:fi a$ tree
.
├── index.css
└── index.html

index.html 内容如下。

<!DOCTYPE html>
<html>
<head>
<title></title>
<link rel="stylesheet" type="text/css" href="index.css?__inline">
</head>
<body>

</body>
</html>

假设文件内容发生了变化,理论上应该是这样

  1. index.html 变化:重新编译 index.html
  2. index.css 变化:重新编译 index.css,重新编译 index.html

理论是直观的,那么看下内部是怎么实现这个逻辑的。先归纳如下,再看源码

  1. 对需要编译的每个源文件,都创建一个Cache实例,假设是cache。cache里存放了一些信息,比如文件的内容,文件的依赖列表(deps字段,一个哈希表,存放依赖文件路径到最近修改时间的映射)。
  2. 对需要编译的每个源文件,扫描它的依赖,包括通过__inline内嵌的资源,并通过cache.addDeps(file)添加到deps里。
  3. 文件发生变化,检查文件本身内容,以及依赖内容(deps)是否发生变化。如变化,则重新编译。在这个例子里,扫描index.html,发现index.html本身没有变化,但deps发生了变化,那么,重新编译部署index.html

好,看源码。在compile.js里面,cache.revert(revertObj)这个方法检测文件本身、文件依赖的资源是否变化。

    if(file.isFile()){
        if(file.useCompile && file.ext && file.ext !== '.'){
            var cache = file.cache = fis.cache(file.realpath, CACHE_DIR),   // 为文件建立缓存(路径)
                revertObj = {};

            // 目测是检测缓存过期了没,如果只是跑 fis release ,直接进else
            if(file.useCache && cache.revert(revertObj)){// 检查依赖的资源(deps)是否发生变化,就在 cache.revert(revertObj)这个方法里
                exports.settings.beforeCacheRevert(file);
                file.requires = revertObj.info.requires;
                file.extras = revertObj.info.extras;
                if(file.isText()){
                    revertObj.content = revertObj.content.toString('utf8');
                }
                file.setContent(revertObj.content);
                exports.settings.afterCacheRevert(file);
            } else {

看看cache.revert是如何定义的。大致归纳如下,源码不难看懂。至于infos.deps这货怎么来的,下面会立刻讲到。

  1. 方法的返回值:缓存没过期,返回true;缓存过期,返回false
  2. 缓存检查步骤:首先,检查文件本身是否发生变化,如果没有,再检查文件依赖的资源是否发生变化;
    // 如果过期,返回false;没有过期,返回true
    // 注意,穿进来的file对象会被修改,往上挂属性
    revert : function(file){
        fis.log.debug('revert cache');
        // this.cacheInfo、this.cacheFile 中存储了文件缓存相关的信息
        // 如果还不存在,说明缓存还没建立哪(或者被人工删除了也有可能,这种变态情况不多)
        if(
            exports.enable
            && fis.util.exists(this.cacheInfo)
            && fis.util.exists(this.cacheFile)
        ){
            fis.log.debug('cache file exists');
            var infos = fis.util.readJSON(this.cacheInfo);
            fis.log.debug('cache info read');
            // 首先,检测文件本身是否发生变化
            if(infos.version == this.version && infos.timestamp == this.timestamp){
                // 接着,检测文件依赖的资源是否发生变化
                // infos.deps 这货怎么来的,可以看下compile.js 里的实现
                var deps = infos['deps'];
                for(var f in deps){
                    if(deps.hasOwnProperty(f)){
                        var d = fis.util.mtime(f);
                        if(d == 0 || deps[f] != d.getTime()){   // 过期啦!!
                            fis.log.debug('cache is expired');
                            return false;
                        }
                    }
                }
                this.deps = deps;
                fis.log.debug('cache is valid');
                if(file){
                    file.info = infos.info;
                    file.content = fis.util.fs.readFileSync(this.cacheFile);
                }
                fis.log.debug('revert cache finished');
                return true;
            }
        }
        fis.log.debug('cache is expired');
        return false;
    },

依赖扫描细节

之前多次提到deps这货,这里就简单讲下依赖扫描的过程。还是之前compile.js里那段代码。归纳如下:

  1. 文件缓存不存在,或者文件缓存已过期,进入第二个处理分支
  2. 在第二个处理分支里,会调用process(file)这个方法对文件进行处理。里面进行了一系列操作,如文件的“标准化”处理等。在这个过程中,扫描出文件的依赖,并写到deps里去。

下面会以“标准化”为例,进一步讲解依赖扫描的过程。

if(file.useCompile && file.ext && file.ext !== '.'){
            var cache = file.cache = fis.cache(file.realpath, CACHE_DIR),   // 为文件建立缓存(路径)
                revertObj = {};

            // 目测是检测缓存过期了没,如果只是跑 fis release ,直接进else
            if(file.useCache && cache.revert(revertObj)){
                exports.settings.beforeCacheRevert(file);
                file.requires = revertObj.info.requires;
                file.extras = revertObj.info.extras;
                if(file.isText()){
                    revertObj.content = revertObj.content.toString('utf8');
                }
                file.setContent(revertObj.content);
                exports.settings.afterCacheRevert(file);
            } else {
            // 缓存过期啦!!缓存还不存在啊!都到这里面来!!
                exports.settings.beforeCompile(file);
                file.setContent(fis.util.read(file.realpath));                
                process(file);  // 这里面会对文件进行"标准化"等处理
                exports.settings.afterCompile(file);
                revertObj = {
                    requires : file.requires,
                    extras : file.extras
                };
                cache.save(file.getContent(), revertObj);
            }
        }

process里,对文件进行了标准化操作。什么是标准化,可以参考官方文档。就是下面这小段代码

        if(file.useStandard !== false){
            standard(file);
        }

看下standard内部是如何实现的。可以看到,针对类HTML、类JS、类CSS,分别进行了不同的能力扩展(包括内嵌)。比如上面的index.html,就会进入extHtml(content)。这个方法会扫描html文件的__inline标记,然后替换成特定的占位符,并将内嵌的资源加入依赖列表。

比如,文件的<link href="index.css?__inline" />会被替换成 <style type="text/css"><<<embed:"index.css?__inline">>>

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){
            content = extHtml(content);  // 如果有 <link href="index1.css?__inline" /> 会被替换成 <style type="text/css"><<<embed:"index1.css?__inline">>> 这样的占位符
        } else if(file.isJsLike){
            content = extJs(content);
        } else if(file.isCssLike){
            content = extCss(content);
        }
        content = content.replace(map.reg, function(all, type, value){

            // 虽然这里很重要,还是先省略代码很多很多行

    }
}

然后,在content.replace里面,将进入embed这个分支。从源码可以大致看出逻辑如下,更多细节就先不展开了。

  1. 首先对内嵌的资源进行合法性检查,如果通过,进行下一步
  2. 编译内嵌的资源。(一个递归的过程)
  3. 将内嵌的资源加到依赖列表里。
content = content.replace(map.reg, function(all, type, value){
            var ret = '', info;
            try {
                switch(type){
                    case 'require':
                        // 省略...
                    case 'uri':
                        // 省略...
                    case 'dep':
                    // 省略
                    case 'embed':
                    case 'jsEmbed':
                        info = fis.uri(value, file.dirname);  // value ==> ""index.css?__inline""
                        var f;
                        if(info.file){
                            f = info.file;
                        } else if(fis.util.isAbsolute(info.rest)){
                            f = fis.file(info.rest);
                        }
                        if(f && f.isFile()){
                            if(embeddedCheck(file, f)){ // 一切合法性检查,比如有没有循环引用之类的
                                exports(f); // 编译依赖的资源
                                addDeps(file, f);   // 添加到依赖列表
                                f.requires.forEach(function(id){    
                                    file.addRequire(id);
                                });
                                if(f.isText()){
                                    ret = f.getContent();
                                    if(type === 'jsEmbed' && !f.isJsLike && !f.isJsonLike){
                                        ret = JSON.stringify(ret);
                                    }
                                } else {
                                    ret = info.quote + f.getBase64() + info.quote;
                                }
                            }
                        } else {
                            fis.log.error('unable to embed non-existent file [' + value + ']');
                        }
                        break;
                    default :
                        fis.log.error('unsupported fis language tag [' + type + ']');
                }
            } catch (e) {
                embeddedMap = {};
                e.message = e.message + ' in [' + file.subpath + ']';
                throw  e;
            }
            return ret;
        });

写在后面

更多内容,敬请期待。

等待第一条回复
登录后回复,如无账号,请使用邀请码注册