需求

提交软件著作权申请需要的软件源代码一份。

问题分析

读取项目目录,过滤某些文件夹(如:.git,.vscode等),只提取指定类型的文件的内容(如:js,wxml,wxss)。
即把某项目下指定类型的代码提取出来,写入到同一个文件中。

封装满足以下特点的工具函数:

  • 可自动扫描指定路径下的所有文件及文件夹
  • 提供指定过滤(不扫描)某些文件夹的参数 ignoreDirs
  • 提供指定需要的文件类型的参数 allowExts
  • 提供读取到文件的监听事件 onFile
  • 提供读取路径失败的监听事件 onError
  • 提供当指定路径下无可扫描文件(扫描结束)的监听事件,方法内部扫描过程是同步执行的 onComplete
  • 函数本身只提供指定目录扫码任务,不含文件本身的读写操作

先上代码

/**
 * [printDirSync 同步遍历指定文件夹下的所有文件(夹),支持遍历结束回调 onComplete]
 * @param {*} dirPath
 * @param {*} options {
    allowExts: [], //指定需要的文件类型的路径,不指定则默认允许所有类型(不同于文件夹,文件类型太多,用忽略的方式太麻烦,所以用了允许)
    ignoreDirs: [],//指定不需要读取的文件路径,不指定则默认读取所有文件夹
    onFile: (fileDir, ext, stats) => {},
    onError: (fileDir, err) => {},
    onComplete: (fileNum) => {},
  }
 */
function printDirSync(
  dirPath,
  options = {
    allowExts: [],
    ignoreDirs: [],
    onFile: (fileDir, ext, stats) => {},
    onError: (fileDir, err) => {},
    onComplete: (filePaths) => {},
  }
) {
  const { allowExts, ignoreDirs, onFile, onComplete, onError } = options;
  let onPrintingNum = 0; //记录正在遍历的文件夹数量,用来判断是否所有文件遍历结束
  let findFiles = []; //统计所有文件,在onComplete中返回

  // 因为fs.stat是异步方法,通过回调的方式返回结果,不可控的执行顺序影响是【否遍历结束】的判断
  // 所以这里返回promise,配合sync/await模拟同步执行
  const stat = (path) => {
    return new Promise((resolve, reject) => {
      fs.stat(path, function (err, stats) {
        if (err) {
          console.warn("获取文件stats失败");
          if (onError && typeof onError == "function") {
            onError(path, err);
          }
        } else {
          if (stats.isFile()) {
            const names = path.split(".");
            const ext = names[names.length - 1];

            // 对文件的处理回调,可通过allowExts数组过滤指定需要的文件类型的路径,不指定则默认允许所有类型
            if (
              !allowExts ||
              allowExts.length == 0 ||
              (allowExts.length && allowExts.includes(ext))
            ) {
              if (onFile && typeof onFile == "function") {
                findFiles.push(path);
                onFile(path, ext, stats);
              }
            }
          }

          // 这里是对文件夹的回调,可通过ignoreDirs数组过滤不想遍历的文件夹路径
          if (stats.isDirectory()) {
            if (
              !ignoreDirs ||
              ignoreDirs.length == 0 ||
              (ignoreDirs.length && !ignoreDirs.includes(path))
            ) {
              print(path); //递归遍历
            }
          }
        }

        resolve(path + "  stat结束");
      });
    });
  };

  // 处理正在遍历的文件夹遍历结束的逻辑:onPrintingNum-1 且 判断整体遍历是否结束
  const handleOnPrintingDirDone = () => {
    if (--onPrintingNum == 0) {
      if (onComplete && typeof onComplete == "function") {
        onComplete(findFiles);
      }
    }
  };

  // 遍历路径,记录正在遍历路径的数量
  const print = async (filePath) => {
    onPrintingNum++; //进入到这里,说明当前至少有一个正在遍历的文件夹,因此 onPrintingNum+1
    let files = fs.readdirSync(filePath); //同步读取filePath的内容
    let fileLen = files.length;

    // 如果是空文件夹,不需要遍历,也说明当前正在遍历的文件夹结束了,onPrintingNum-1
    if (fileLen == 0) {
      handleOnPrintingDirDone();
    }

    //遍历目录下的所有文件
    for (let index = 0; index < fileLen; index++) {
      let file = files[index];
      let fileDir = path.join(filePath, file); //获取当前文件绝对路径

      try {
        await stat(fileDir); //同步执行路径信息的判断

        // 当该文件夹下所有文件(路径)都遍历完毕,也说明当前正在遍历的文件夹结束了,onPrintingNum-1
        if (index == fileLen - 1) {
          handleOnPrintingDirDone();
        }
      } catch (err) {}
    }
  };

  print(dirPath);
}

再看咋用

const { fs, printDir, printDirSync } = require("./file-tools");

let dirPath = "/Users/yourprojectpath/yourprojectname";
let allowExts = ["wxml", "wxss", "js", "txt", "md"];
let ignoreDirs = [`${dirPath}/.git`, `${dirPath}/.DS_Store`, `${dirPath}/dist`];
printDirSync(dirPath, {
  allowExts,
  ignoreDirs,
  onFile: (fileDir, ext, stats) => {
    let fileContent = fs.readFileSync(fileDir, "utf-8"); //同步读取文件内容
    writeFile( `文件路径:${fileDir.replace("/Users/yourprojectpath/","")}\n${fileContent}\n\n`);
  },
  onComplete: (files) => {
    console.log("忽略的文件夹:", ignoreDirs);
    console.log("指定的文件类型:", allowExts);
    console.log(
      `路径 [${dirPath}] 遍历结束,发现 ${files.length}个 文件如下\n`,
      files
    );
  },
});

function writeFile(data = "") {
  let outDir = "./dist/codes.txt";
  fs.appendFileSync(outDir, data, {
    encoding: "utf8",
  });
}

最后👂我解释

扫描指定路径下所有文件的基本流程

  1. 通过 fs.readdir 方法读取指定的路径,在此方的回调函数里会返回该路径下的 files
  2. 遍历这些 files 并通过 fs.stat 方法判断类型(是文件还是文件夹);
  3. 如果是文件夹,则重复1和2;
  4. 如果是文件,则记录下来;
  5. 直到程序结束,说明扫描完毕!

如何知道扫描结束?

要做到这点,首先要保证方法内部扫描过程是同步执行的。

异步过于不可控,它会让代码的执行顺序变得混乱,所以就会选用同步扫描方法 fs.readdirSync
再者,因为在扫描过程中需要获取文件的 stats 信息来判断是 文件 还是 文件夹
而这个方法也是异步的(通过回调方法获取结果),同样会给执行顺序带来不确定性。
所以可以将 fs.stat 这部分功能提取出来,并通过 Promise 的形式返回结果,结合 async/awat
达到同步执行文件信息判断的效果。

这样便保证了代码执行流程的可控性,但是这样还不够!

到底如何才能知道所有文件都已经扫描结束呢?

核心的一步,这里我通过设定一个监测变量 onPrintingNum ,它记录正在扫描的文件夹数量,用来判断是否所有文件扫描结束。

当我们指定了一个路径,并执行 printDirSync 方法,onPrintingNum ​初始化为0,在方法内部,独立封装了负责扫描的方法 print

自动执行一次 print 方法来扫描路径下的所有文件和文件夹,这个方法每次被调用,onPrintingNum 就会累加1,
当遇到空文件夹(fs.readdirSync 返回的 files 为空数组)或者遍历到 files 的结尾,onPrintingNum 先减少1;

然后紧接着判断 onPrintingNum 是否为0,若为0,则说明遍历结束了。

可以结合代码及里面的注释理解下,经过本人大量测试(复杂的文件结构和高频扫描)未发现问题。若有漏洞或不足请评论指出,一起探讨。

在处理这个问题的过程中也发现了一些 fs.readdirSync 方法读取文件的特点:输出的 files 数组(读取文件遍历)顺序并非固定,
这里没有深入研究。

若不关心扫描结束的动作,这里再提供一版异步的扫描方法,理论上效率更高。

/**
 * [printDir 异步遍历文件夹的所有文件,只关注遇到文件的处理,不关注是否全部遍历结束]
 * @param  {String} dirPath [要遍历的文件夹路径]
 * @return {Object} options  {
    allowExts: [], //指定需要的文件类型的路径,不指定则默认允许所有类型(不同于文件夹,文件类型太多,用忽略的方式太麻烦,所以用了允许)
    ignoreDirs: [], //指定不需要读取的文件路径,不指定则默认读取所有文件夹
    onFile: (fileDir, ext, stats) => {},
    onError: (fileDir, err) => {},
    onComplete: (fileNum) => {},
  }
 */
function printDirAsync(
  dirPath,
  options = {
    allowExts: [],
    ignoreDirs: [],
    onFile: (fileDir, ext, stats) => {},
    onError: (fileDir, err) => {},
  }
) {
  const { ignoreDirs, onFile, onError } = options;

  const print = (filePath) => {
    fs.readdir(filePath, function (err, files) {
      if (err) {
        return console.error(err);
      }
      let fileLen = files.length;

      //遍历目录下的所有文件
      for (let index = 0; index < fileLen; index++) {
        let file = files[index];
        let path = path.join(filePath, file); //获取当前文件绝对路径
        fs.stat(path, function (err, stats) {
          if (err) {
            console.warn("获取文件stats失败");
            if (onError && typeof onError == "function") {
              onError(path, err);
            }
          } else {
            // 对文件的处理回调,可通过allowExts数组过滤指定需要的文件类型的路径,不指定则默认允许所有类型
            if (stats.isFile()) {
              const names = path.split(".");
              const ext = names[names.length - 1];
              if (
                !allowExts ||
                allowExts.length == 0 ||
                (allowExts.length && allowExts.includes(ext))
              ) {
                findFiles.push(path);
                if (onFile && typeof onFile == "function") {
                  onFile(path, ext, stats);
                }
              }
            }

            // 这里是对文件夹的回调,可通过ignoreDirs数组过滤不想遍历的文件夹路径
            if (stats.isDirectory()) {
              if (
                ignoreDirs.length == 0 ||
                (ignoreDirs.length && !ignoreDirs.includes(path))
              ) {
                print(path); //递归遍历
              }
            }
          }
        });
      }
    });
  };

  print(dirPath);
}
03-05 14:02