工具类-将线上资源批量替换为本地资源

1,537 阅读4分钟

工具应用场景 -> 针对大量线上资源(css/img/js/font等CDN资源)快速转化为本地路径引用

需要的核心依赖:

  • async -> 用于处理异步js

  • request -> 用于下载资源

思考过程分析:

开始前先分析要操作的目录结构:

有目录,也有文件,也有重名的文件和目录demo,目录里再套目录,目录中,也有配置文件config,也有独立放在根目录的pub.html,所以情况有很多种,但归根结底都是一个流程,那就是:

查找->匹配->下载->替换


好~,那我们开始吧!

第一步:
需要一个正则来匹配要下载的线上资源:以.jpg举例:

https://xxx.CDN1.com/1e68e9094ddd/demo.jpg

那么正则应该这么写:/(http[s]?:)?\/\/[0-9a-zA-Z.]*CDN1.*\color{red}{?}\.(jpg|jpeg|gif|png|webp|ico)/g

注意1,资源可能是http的,也可能是https的

注意2,注意标红的问号,是正则的停止匹配的贪婪模式。如果不加的话,会一直匹配到下一个符合正则的url。


第二步:

1,设定从哪里开始搜索(比如从项目里的某一个目录)

2,可以从哪些不同文件类型的文件进行匹配(.html/.css/.php/.vue等)

3,匹配后的资源下载到本地的哪个文件夹下


第三步:对想要匹配的路径内的所有目录进行递归读取
let fsPathSys = (fspath, targetFile) => {
  let stat = fs.statSync(fspath);
  if (stat.isDirectory()) { //是否是目录
    let isDirectory = (err, files) => {
      //读取目录里所有文件
      if (err) {
        console.log("err", err);
        return err;
      } else {
        files.forEach((item, index) => {
          if (item === "__MACOSX") {
            //删除mac无用文件
            execSync("rm -rf " + fspath + "/__MACOSX");
          } else {
            let nowPath = path.join(fspath, item);
            let isNeed = true;
            switch(true){
              case EXCLUDETARGET.path.length > 0:
              EXCLUDETARGET.path.forEach((item,i) => {
              //EXCLUDETARGET.path -> 可以指定不想搜索的文件路径或文件夹路径
                if (
                  nowPath ===
                  path.join(__dirname, LOCMAP.searchPath, item)
                ) {
                  isNeed = false;
                  return;
                }
              })
              
              case EXCLUDETARGET.keyword.length > 0:
              EXCLUDETARGET.keyword.forEach((item,i) => { 
              //EXCLUDETARGET.keyword -> 可以指定不想搜索带有关键词的文件或者文件夹
                if (
                  nowPath.split(path.sep).join('/').match(new RegExp('\/'+item+'\/','g'))
                ) {
                  isNeed = false;
                  return;
                }
              })
              
            }

            if (!isNeed) return;
            
            let stat = fs.statSync(nowPath);
            if (!stat.isDirectory()) {
              //如果路径不是目录,说明到底了
              if(targetFile.length > 0){
                targetFile.forEach((obj, index) => {
                  if (item.indexOf(obj) != -1) {
                    readMatchFile(nowPath);
                  }
                });
              }else{
                readMatchFile(nowPath);
              }
            } else {
              //如果是目录,再执行
              fsPathSys(nowPath, targetFile);
            }
          }
        });
      }
    }
    fs.readdir(fspath, isDirectory); //读取路径
  } else {
    targetFile.forEach((obj, index) => {
      readMatchFile(fspath);
    });
  }
}

/*读取文件流*/
let readMatchFile = (curPath) => {
  let readAble = fs.createReadStream(curPath);
  let body = "";
  readAble.on("data", chunk => {
    body += chunk;
  });
  readAble.on("end", () => {
    matchDemo(curPath, body);
  });
}
/*读取文件流*/

第四步:对匹配的资源进行建本地目录操作
/*递归创建目录*/
let mkdirsSync = (name) => {
  if (fs.existsSync(name)) {
    return true;
  } else {
    if (mkdirsSync(path.dirname(name))) {
      fs.mkdirSync(name);
      return true;
    }
  }
}
/*递归创建目录*/

/*匹配替换并开始下载资源*/
let matchDemo = (curPath, body) => {
  //读取文件内容,进行内容的替换
  let proDestDir = '',targetPath = '',dirname = curPath.split(path.sep).join('/').match(
    new RegExp(LOCMAP.searchPath + "\/(.*)")
  );

  if (body.match(REG) && dirname) {
    //匹配到CDN
    /*这里是对文件名进行获取,因为下载后的资源要存放在对应读取文件名的目录里,所以要按照文件名进行递归建目录,这样才能保证层级关系清楚,如果在匹配文件时,发现该文件并不在项目目录里,而是单独个体存在在路径下,则用其文件名来做目录名*/
    
    if (dirname[1].indexOf("/") != -1) {
      //如果有目录,把目录名作为下载资源的目录名
      proDestDir = path.join(LOCMAP.destLocPath, dirname[1]);
      targetPath = path.dirname(dirname[1]);
      mkdirsSync(path.dirname(proDestDir));
    } else {
      //没有目录,把文件名作为下载资源的目录名
      proDestDir = path.join(LOCMAP.destLocPath, "/", dirname[1].split(".")[0]);
      targetPath = dirname[1].split(".")[0];
      mkdirsSync(proDestDir);
    }

    
    startDownload(proDestDir, body.match(REG)); //文件内的资源下载
    let endbody = body.replace(REG, (matched) => {//逐一替换
      
      //进行地址替换
      return (
        "/static/localres/" +
        targetPath +
        "/" +
        dealFileName(matched)
      );
    });

    //重写替换后的文件
    fs.writeFile(curPath, endbody, err => {
      if (err) throw err;
    });
  }
}
/*匹配替换并开始下载资源*/

第五步:把资源下载到刚才新建的目录里
let startDownload = (dir, imageLinks) => {// 存放的目录位置,[下载链接1,下载链接2]
  let newDir = dir.indexOf(".") == -1 ? dir : path.dirname(dir);
  async.mapSeries(
    imageLinks,
    (src, callback) => {
      console.log(chalk.cyan.bold('下载',src));
      if (src.indexOf("http") == -1) src = "http:" + src;
      let errType = '错误类型:';
      //文件源地址,下载后存放的目标地址
      let dest = path.resolve(newDir, dealFileName(src));
      request({url:src,timeout: 4000})
        .on('error', (err) => {//要在pipe之前
          if(err.code == 'ESOCKETTIMEDOUT'){
            errType += '超时';
          }else{
            errType += '其他';
          }
          errStr += `${errType}\r\n状态码:${err.code}\r\n源文件地址:${src}\r\n替换后的地址:${dest}\r\n\r\n\r\n`;
          callback(null, src);
          //下载完就要告知程序,可以下载下一个了
        })
        .pipe(fs.createWriteStream(dest))
        .on("close", () => {
          //读取到目标,写到最终目录里
          callback(null, src);
          //下载完就要告知程序,可以下载下一个了
        });
    }
  );
}

第六步:测试
node q.js

代码中的资源被替换了 原文件:

替换后的文件:

运行结果:

大功告成!


总结注意事项:

1. 同一级下,命名为相同名字的目录和文件名,并不会相互覆盖,在建立好目录后,都会追加到相应的目录里(Ep:demo目录和demo.html)

2. 脚本应放在要匹配目录的外层

3. 在一个项目目录里,针对同一个文件名命名的文件,如果发现在匹配时有相同的名字时,会采用获取CDN中的hash值来做区分


近期做的已优化的点:

1. 增加匹配所有文件功能

2. 增加目录排除功能

3. 兼容windows/mac

4. 区分文件命名

5. 增加超时处理

6. 增加错误日志

详情请看:

github: downtolocal


工具未来打算:

打算做个可视化的界面来动态填写路径,哈哈哈,就是想尽可能的方便用户操作嘛~