FIS 源码分析 增量编译与依赖扫描细节 - 文章教程

FIS 源码分析 增量编译与依赖扫描细节

发布于 2021-08-15 字数 15328 浏览 967 评论 0

前面已经提到了 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;
});

如果你对这篇文章有疑问,欢迎到本站 社区 发帖提问或使用手Q扫描下方二维码加群参与讨论,获取更多帮助。

扫码加入群聊

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

目前还没有任何评论,快来抢沙发吧!

关于作者

JSmiles

生命进入颠沛而奔忙的本质状态,并将以不断告别和相遇的陈旧方式继续下去。

2512 文章
30 评论
81917 人气
更多

推荐作者

qq_iQVWB

文章 0 评论 0

gyhjy

文章 0 评论 0

dianjvnan

文章 0 评论 0