requirejs加载文件带上md5版本号的解决方案



最近团队其他项目组用到了requirejs(快过时的东西了,囧),因为项目过于庞大,因此缓存控制、文件版本号管理、增量更新这些问题都是需要解决的,而requirejs目前是无法解决这些的。由于我对前端构建工具有点研究,领导把这任务交给了我。

require.config 提供了urlArgs属性,用来添加版本号信息,但这东西是对所有文件生效,意味着只能全量更新,实在坑爹。而r.js只能用来打包合并文件,代码量还巨大,瞬间失去研究的兴趣。搜了一圈,国内外都没有谁提出过解决方案,没办法只能自己来了。真正动手处理这问题的时候才发现这问题确实麻烦。

因为r.js的局限性,而且超级难用,果断舍弃了,用的是自己最熟悉的gulp。最初的方案是通过 require.config 来处理, require.config 的 map 属性可以添加文件版号信息,类似这样:

require.config({
    map:{
        "*":{
            "xxx/yyy/zzz" : "app/xxx/yyy/zzz.js?v=r3r3s2s"
        }
    }
});

可以在读取文件后生成一个版本号信息映射再写回到config文件。搞完后感觉很ok,加载的脚本都能带md5戳了,以为版本号问题就此解决了。可是按依赖关系把文件合并后就悲剧了,程序都跑不起来了。走投无路的时候决定去翻一翻requirejs的源码,突然灵光一闪,想到了被弃用的urlArgs,坑爹的urlArgs。

在requirejs源码里搜索urlArgs,只有一处地方用到,源码如下:

return config.urlArgs ? url + ((url.indexOf('?') === -1 ? '?' : '&') +
                                     config.urlArgs) : url;

当时就想,如果 config.urlArgs 是一个 function 会怎样,自己动手改了一下,代码如下:

      if (!config.urlArgs) {
        return url;
      }

      if (typeof config.urlArgs === 'string') {
        return url + ((url.indexOf('?') === -1 ? '?' : '&') + config.urlArgs);
      }

      if (isFunction(config.urlArgs)) {
        var urlArgs;
        try {
          urlArgs = config.urlArgs.call(config, moduleName, url);
        } catch (e) {
          urlArgs = "";
        }

        return url + ((url.indexOf('?') === -1 ? '?' : '&') + urlArgs);
      }

config.urlArgs 对比原先的字符串,变成了一个方法,每次生成文件url的时候调用一下,在这个方法里返回文件对应的md5串(其他版本号信息也可以)。再通过gulp将文件版本号映射写到 require.config 里,问题就迎刃而解啦。

困扰一个星期的问题终于搞定了,人也轻松多了。后面果断给requirejs作者提了pull request,希望能被合并吧。

上面提到的只是requirejs的部分,还有很多工作需要gulp来完成,不多说,直接贴代码了。

var fs = require('fs');
var path = require('path');
var crypto = require('crypto');

var gulp = require('gulp');
var gutil = require('gulp-util');
var uglify = require('gulp-uglify');
var plumber = require('gulp-plumber');
var concat = require('gulp-concat');
var rename = require('gulp-rename');

var Q = require('q');
var through = require('through2');
var _ = require('underscore');

var opts = null;	//配置文件中require.config()的参数,会通过正则匹配出来,用来算文件路径

var diskBase;	   //当前业务模块的根目录

var confFile;	   //配置文件的路径+文件名

var aliaMap = {};   //别名配置

var depMap = {};	//依赖版本

var verMap = {};	//版本号配置 ,key:路径别名,value:文件md5值

var aliaPaths = []; //所有的别名路径集合,用来计算文件对应的别名路径

var conf = (function() {

  return {
    config:{
      fileset:["../config.js"],
      dest:"../dist"
    },
    js: {
      fileset: ["../**/*.js","!../build/","!../build/**/*.*","!../dist/","!../dist/**/*.*"],
      dest: "../dist"
    },
    html:{
      fileset:["../**/*.html","!../build/","!../build/**/*.*","!../dist/","!../dist/**/*.*"],
      dest:"../dist"
    }
  };

})();

var sepReg = new RegExp("\\" + path.sep, "g");

function tryEval(obj) {
  var json;
  try {
    json = eval('(' + obj + ')');
  } catch (err) {

  }
  return json;
}

//统一文件名的路径分割符,
function formatFileName(name) {
  return name.replace(/[\\\/]+/g, path.sep).replace(/^(\w)/, function (str,cd) {
    return cd.toUpperCase();
  });
}

//过滤掉依赖表里的关键字
function filterDepMap(depMap) {
  depMap = depMap.filter(function (dep) {
    return ["require", "exports", "module"].indexOf(dep) === -1;
  });

  return depMap.map(function(dep) {
    return dep.replace(/\.js$/,'');
  });
}

//根据文件名计算对应别名路径
function getAliaPath(file) {
  file = file.replace(sepReg, "/");

  //paths配置转成数组,按路径级数排序
  if (aliaPaths.length === 0) {
    for (var alia in opts.paths) {
      aliaPaths.push({
        alia:alia,
        path:opts.paths[alia]
      });
    }
    aliaPaths.sort(function(path1,path2) {
      return path2.path.split(/[\\\/]/).length - path1.path.split(/[\\\/]/).length;  
    });
  }

  //优先匹配路径级数多的
  for (var i = 0; i < aliaPaths.length; i++) {
    var aliaPath = aliaPaths[i];
    var fullPath = path.join(opts.baseUrl, aliaPath.path); 
    var pathReg = new RegExp('^.*?' + fullPath.replace(/\\/g, '/') + '(.*?)\.js$'); 
    var matches = file.match(pathReg);
    if (matches) {
      return aliaPath.alia + matches[1];
    }
  }

  //todo?
  return file.replace(diskBase.replace(sepReg, "/"), '').replace(/\.js$/, '');
}

//根据文件内容计算md5串
function getDataMd5(data) {
  return crypto.createHash('md5').update(data).digest('base64');
}

//根据文件名计算发布包对应该文件路径
function getDestFile(file) {
  try {
    var fileDirs = file.split(path.sep);
    fileDirs.shift();
    return formatFileName(_.unique(path.join(__dirname, conf.js.dest).split(path.sep).concat(fileDirs)).join(path.sep));
  } catch(e) {
    return null;
  }
}

//根据发布包文件计算原文件名路径
function getSrcFile(file) {
  file = formatFileName(file);
  var destPath = formatFileName(path.join(__dirname, conf.js.dest));
  var restPath = file.replace(/[\\\/]+/g, '/').replace(destPath.replace(/[\\\/]+/g, '/'), '').replace(/[\\\/]+/g,path.sep);
  //todo
  var fileName = formatFileName(path.join(__dirname, '../', restPath));

  return fileName;
}

gulp.task('makeOpts',function() {
  return gulp.src(conf.config.fileset)
    .pipe(through.obj(function(file,enc,cb) {
      var js = file.contents.toString();
      var name = formatFileName(file.path);
      confFile = name;

      var configMatches = js.match(/require(js)?\.config\s*?\(\s*?({[\s\S]*?})\s*?\)/);
      var options = configMatches[2];
      opts = tryEval(options);

      //todo 有点不靠谱
      var dirs = name.split(path.sep);
      var base = opts.baseUrl.split('/')[1];
      dirs.splice(dirs.indexOf(base))
      diskBase = dirs.join(path.sep);

      var aliaPath = getAliaPath(name);

      //提取依赖,用来合并
      js.replace(/\s*require(js)?\s*\(\s*(\[[^\]\[]*?\])/, function (str, suf,map) {
        var map = tryEval(map);
        depMap[aliaPath] = filterDepMap(map);
      });

      this.push(file);
      cb();
    }))
});

//遍历所有文件,生成依赖关系配置
gulp.task('makeDeps', ['makeOpts'], function () {
  return gulp.src(conf.js.fileset)
    //.pipe(uglify({
    //	mangle: {
    //		except: ['require', 'requirejs', 'exports']
    //	}
    //}))
    .pipe(through.obj(function (file, enc, cb) {
      var js = file.contents.toString();

      var name = formatFileName(file.path);

      var aliaPath = getAliaPath(name);

      aliaMap[aliaPath] = name;

      //todo 在这里把文件内容按规范修改掉?
      //提取依赖,用来合并
      js.replace(/;?\s*define\s*\(([^(]*),?\s*?function\s*\([^\)]*\)/, function (str, map) {

        var depStr = map.replace(/^[^\[]*(\[[^\]\[]*\]).*$/, "$1");

        if (/^\[/.test(depStr)) {
          var arr = tryEval(depStr);
          try {
            depMap[aliaPath] = filterDepMap(arr);
          } catch (e) {
            gutil.log("makeDeps Error: " + e);
          }
        }

      });

      this.push(file);
      cb();
    }))
    .pipe(through.obj(function (file, enc, cb) {
      var name = formatFileName(file.path);
      var aliaPath = getAliaPath(name);
      verMap[aliaPath] = getDataMd5(file.contents);

      this.push(file);
      cb();
    }))
    .pipe(gulp.dest(conf.js.dest));
});
var packPromises = [];

gulp.task('makePacks', ['makeDeps'], function () {

  //递归依赖关系,去重
  function makeDeps(deps) {
    var set = [];

    function make(deps) {
      deps.forEach(function (dep) {	  
        var currDeps = depMap[dep];		 //每个文件对应的依赖
        if (currDeps) {
          make(currDeps);
        }
        set.push(dep);
      });
    }

    make(deps);

    return _.unique(set);
  }

  var defineWithModuleNameReg = /;?\s*define\s*\(\s*["']/;
  var defineWithoutModuleNameReg = /(;?\s*define\s*)\(\s*([^,]*),/;

  for (var aliaPath in depMap) {
    var clearDepMap = makeDeps(depMap[aliaPath]);

    var fileMap = clearDepMap.map(function (dep) {
      return aliaMap[dep];
    });

    var destFile = aliaMap[aliaPath];
    fileMap.push(destFile);

    //修改原文件地址为生成发布包对应文件地址
    var destFileMap = fileMap.map(function (file) {
      return getDestFile(file);
    });

    (function (destFileMap) {
      var deferred = Q.defer();

      var destFile = _.last(destFileMap);
      var name = destFile.split(/[\\\/]+/).pop();
      var dir = destFile.replace(name, '');

      gulp.src(destFileMap)
        .pipe(through.obj(function (file, enc, cb) {
          var js = file.contents.toString();

          var name = getSrcFile(file.path);

          var aliaPath = getAliaPath(name);

          if (!defineWithModuleNameReg.test(js)){
            //添加模块名称   
            js = js.replace(defineWithoutModuleNameReg, function (str, def, mod) {
              return ';define("' + aliaPath + '",' + mod + ',';
            });

            file.contents = new Buffer(js);
          }

          this.push(file);
          cb();
        }))
        .pipe(concat(name))
        //.pipe(uglify({
        //	mangle: {
        //		except: ['require', 'requirejs', 'exports']
        //	}
        //}))
        .pipe(through.obj(function (file, enc, cb) {
          var name = getSrcFile(file.path);
          var aliaPath = getAliaPath(name);

          verMap[aliaPath] = getDataMd5(file.contents);

          this.push(file);
          cb();
          deferred.resolve();
        }))
        .pipe(gulp.dest(dir));

      packPromises.push(deferred.promise);

    })(destFileMap);

  }

});
gulp.task('makeConf', ['makePacks'], function () {

  Q.all(packPromises)
    .done(function () {
      gulp.src(getDestFile(confFile))
        .pipe(through.obj(function (file, enc, cb) {
          var js = file.contents.toString();
          js = js.replace(/require(js)?\.config\s*?\(\s*?({[\s\S]*?)}\s*?\)/, "require.config($2" + ',\n"verMap" : ' + JSON.stringify(verMap) + "})");

          file.contents = new Buffer(js);
          this.push(file);
          cb();
        }))
        .pipe(gulp.dest(conf.config.dest));
    });
});

gulp.task('makeTpls',function() {
  gulp.src(conf.html.fileset)  
    .pipe(gulp.dest(conf.html.dest));
});

gulp.task('default', ['makeTpls','makeConf']);

代码目前只是个雏形,还有很多需要完成的工作,也有很多地方可以优化。

写这篇文章算是记录自己这一个多星期的研究成果吧。

最后附上github地址: https://github.com/hellopao/requirejs

最近团队其他项目组用到了requirejs(快过时的东西了,囧),因为项目过于庞大,因此缓存控制、文件版本号管理、增量更新这些问题都是需要解决的,而requirejs目前是无法解决这些的。由于我对前端构建工具有点研究,领导把这任务交给了我。

require.config 提供了urlArgs属性,用来添加版本号信息,但这东西是对所有文件生效,意味着只能全量更新,实在坑爹。而r.js只能用来打包合并文件,代码量还巨大,瞬间失去研究的兴趣。搜了一圈,国内外都没有谁提出过解决方案,没办法只能自己来了。真正动手处理这问题的时候才发现这问题确实麻烦。

因为r.js的局限性,而且超级难用,果断舍弃了,用的是自己最熟悉的gulp。最初的方案是通过 require.config 来处理, require.config 的 map 属性可以添加文件版号信息,类似这样:

require.config({
    map:{
        "*":{
            "xxx/yyy/zzz" : "app/xxx/yyy/zzz.js?v=r3r3s2s"
        }
    }
});

可以在读取文件后生成一个版本号信息映射再写回到config文件。搞完后感觉很ok,加载的脚本都能带md5戳了,以为版本号问题就此解决了。可是按依赖关系把文件合并后就悲剧了,程序都跑不起来了。走投无路的时候决定去翻一翻requirejs的源码,突然灵光一闪,想到了被弃用的urlArgs,坑爹的urlArgs。

在requirejs源码里搜索urlArgs,只有一处地方用到,源码如下:

return config.urlArgs ? url + ((url.indexOf('?') === -1 ? '?' : '&') +
                                     config.urlArgs) : url;

当时就想,如果 config.urlArgs 是一个 function 会怎样,自己动手改了一下,代码如下:

      if (!config.urlArgs) {
        return url;
      }

      if (typeof config.urlArgs === 'string') {
        return url + ((url.indexOf('?') === -1 ? '?' : '&') + config.urlArgs);
      }

      if (isFunction(config.urlArgs)) {
        var urlArgs;
        try {
          urlArgs = config.urlArgs.call(config, moduleName, url);
        } catch (e) {
          urlArgs = "";
        }

        return url + ((url.indexOf('?') === -1 ? '?' : '&') + urlArgs);
      }

config.urlArgs 对比原先的字符串,变成了一个方法,每次生成文件url的时候调用一下,在这个方法里返回文件对应的md5串(其他版本号信息也可以)。再通过gulp将文件版本号映射写到 require.config 里,问题就迎刃而解啦。

困扰一个星期的问题终于搞定了,人也轻松多了。后面果断给requirejs作者提了pull request,希望能被合并吧。

上面提到的只是requirejs的部分,还有很多工作需要gulp来完成,不多说,直接贴代码了。

var fs = require('fs');
var path = require('path');
var crypto = require('crypto');

var gulp = require('gulp');
var gutil = require('gulp-util');
var uglify = require('gulp-uglify');
var plumber = require('gulp-plumber');
var concat = require('gulp-concat');
var rename = require('gulp-rename');

var Q = require('q');
var through = require('through2');
var _ = require('underscore');

var opts = null;	//配置文件中require.config()的参数,会通过正则匹配出来,用来算文件路径

var diskBase;	   //当前业务模块的根目录

var confFile;	   //配置文件的路径+文件名

var aliaMap = {};   //别名配置

var depMap = {};	//依赖版本

var verMap = {};	//版本号配置 ,key:路径别名,value:文件md5值

var aliaPaths = []; //所有的别名路径集合,用来计算文件对应的别名路径

var conf = (function() {

  return {
    config:{
      fileset:["../config.js"],
      dest:"../dist"
    },
    js: {
      fileset: ["../**/*.js","!../build/","!../build/**/*.*","!../dist/","!../dist/**/*.*"],
      dest: "../dist"
    },
    html:{
      fileset:["../**/*.html","!../build/","!../build/**/*.*","!../dist/","!../dist/**/*.*"],
      dest:"../dist"
    }
  };

})();

var sepReg = new RegExp("\\" + path.sep, "g");

function tryEval(obj) {
  var json;
  try {
    json = eval('(' + obj + ')');
  } catch (err) {

  }
  return json;
}

//统一文件名的路径分割符,
function formatFileName(name) {
  return name.replace(/[\\\/]+/g, path.sep).replace(/^(\w)/, function (str,cd) {
    return cd.toUpperCase();
  });
}

//过滤掉依赖表里的关键字
function filterDepMap(depMap) {
  depMap = depMap.filter(function (dep) {
    return ["require", "exports", "module"].indexOf(dep) === -1;
  });

  return depMap.map(function(dep) {
    return dep.replace(/\.js$/,'');
  });
}

//根据文件名计算对应别名路径
function getAliaPath(file) {
  file = file.replace(sepReg, "/");

  //paths配置转成数组,按路径级数排序
  if (aliaPaths.length === 0) {
    for (var alia in opts.paths) {
      aliaPaths.push({
        alia:alia,
        path:opts.paths[alia]
      });
    }
    aliaPaths.sort(function(path1,path2) {
      return path2.path.split(/[\\\/]/).length - path1.path.split(/[\\\/]/).length;  
    });
  }

  //优先匹配路径级数多的
  for (var i = 0; i < aliaPaths.length; i++) {
    var aliaPath = aliaPaths[i];
    var fullPath = path.join(opts.baseUrl, aliaPath.path); 
    var pathReg = new RegExp('^.*?' + fullPath.replace(/\\/g, '/') + '(.*?)\.js$'); 
    var matches = file.match(pathReg);
    if (matches) {
      return aliaPath.alia + matches[1];
    }
  }

  //todo?
  return file.replace(diskBase.replace(sepReg, "/"), '').replace(/\.js$/, '');
}

//根据文件内容计算md5串
function getDataMd5(data) {
  return crypto.createHash('md5').update(data).digest('base64');
}

//根据文件名计算发布包对应该文件路径
function getDestFile(file) {
  try {
    var fileDirs = file.split(path.sep);
    fileDirs.shift();
    return formatFileName(_.unique(path.join(__dirname, conf.js.dest).split(path.sep).concat(fileDirs)).join(path.sep));
  } catch(e) {
    return null;
  }
}

//根据发布包文件计算原文件名路径
function getSrcFile(file) {
  file = formatFileName(file);
  var destPath = formatFileName(path.join(__dirname, conf.js.dest));
  var restPath = file.replace(/[\\\/]+/g, '/').replace(destPath.replace(/[\\\/]+/g, '/'), '').replace(/[\\\/]+/g,path.sep);
  //todo
  var fileName = formatFileName(path.join(__dirname, '../', restPath));

  return fileName;
}

gulp.task('makeOpts',function() {
  return gulp.src(conf.config.fileset)
    .pipe(through.obj(function(file,enc,cb) {
      var js = file.contents.toString();
      var name = formatFileName(file.path);
      confFile = name;

      var configMatches = js.match(/require(js)?\.config\s*?\(\s*?({[\s\S]*?})\s*?\)/);
      var options = configMatches[2];
      opts = tryEval(options);

      //todo 有点不靠谱
      var dirs = name.split(path.sep);
      var base = opts.baseUrl.split('/')[1];
      dirs.splice(dirs.indexOf(base))
      diskBase = dirs.join(path.sep);

      var aliaPath = getAliaPath(name);

      //提取依赖,用来合并
      js.replace(/\s*require(js)?\s*\(\s*(\[[^\]\[]*?\])/, function (str, suf,map) {
        var map = tryEval(map);
        depMap[aliaPath] = filterDepMap(map);
      });

      this.push(file);
      cb();
    }))
});

//遍历所有文件,生成依赖关系配置
gulp.task('makeDeps', ['makeOpts'], function () {
  return gulp.src(conf.js.fileset)
    //.pipe(uglify({
    //	mangle: {
    //		except: ['require', 'requirejs', 'exports']
    //	}
    //}))
    .pipe(through.obj(function (file, enc, cb) {
      var js = file.contents.toString();

      var name = formatFileName(file.path);

      var aliaPath = getAliaPath(name);

      aliaMap[aliaPath] = name;

      //todo 在这里把文件内容按规范修改掉?
      //提取依赖,用来合并
      js.replace(/;?\s*define\s*\(([^(]*),?\s*?function\s*\([^\)]*\)/, function (str, map) {

        var depStr = map.replace(/^[^\[]*(\[[^\]\[]*\]).*$/, "$1");

        if (/^\[/.test(depStr)) {
          var arr = tryEval(depStr);
          try {
            depMap[aliaPath] = filterDepMap(arr);
          } catch (e) {
            gutil.log("makeDeps Error: " + e);
          }
        }

      });

      this.push(file);
      cb();
    }))
    .pipe(through.obj(function (file, enc, cb) {
      var name = formatFileName(file.path);
      var aliaPath = getAliaPath(name);
      verMap[aliaPath] = getDataMd5(file.contents);

      this.push(file);
      cb();
    }))
    .pipe(gulp.dest(conf.js.dest));
});
var packPromises = [];

gulp.task('makePacks', ['makeDeps'], function () {

  //递归依赖关系,去重
  function makeDeps(deps) {
    var set = [];

    function make(deps) {
      deps.forEach(function (dep) {	  
        var currDeps = depMap[dep];		 //每个文件对应的依赖
        if (currDeps) {
          make(currDeps);
        }
        set.push(dep);
      });
    }

    make(deps);

    return _.unique(set);
  }

  var defineWithModuleNameReg = /;?\s*define\s*\(\s*["']/;
  var defineWithoutModuleNameReg = /(;?\s*define\s*)\(\s*([^,]*),/;

  for (var aliaPath in depMap) {
    var clearDepMap = makeDeps(depMap[aliaPath]);

    var fileMap = clearDepMap.map(function (dep) {
      return aliaMap[dep];
    });

    var destFile = aliaMap[aliaPath];
    fileMap.push(destFile);

    //修改原文件地址为生成发布包对应文件地址
    var destFileMap = fileMap.map(function (file) {
      return getDestFile(file);
    });

    (function (destFileMap) {
      var deferred = Q.defer();

      var destFile = _.last(destFileMap);
      var name = destFile.split(/[\\\/]+/).pop();
      var dir = destFile.replace(name, '');

      gulp.src(destFileMap)
        .pipe(through.obj(function (file, enc, cb) {
          var js = file.contents.toString();

          var name = getSrcFile(file.path);

          var aliaPath = getAliaPath(name);

          if (!defineWithModuleNameReg.test(js)){
            //添加模块名称   
            js = js.replace(defineWithoutModuleNameReg, function (str, def, mod) {
              return ';define("' + aliaPath + '",' + mod + ',';
            });

            file.contents = new Buffer(js);
          }

          this.push(file);
          cb();
        }))
        .pipe(concat(name))
        //.pipe(uglify({
        //	mangle: {
        //		except: ['require', 'requirejs', 'exports']
        //	}
        //}))
        .pipe(through.obj(function (file, enc, cb) {
          var name = getSrcFile(file.path);
          var aliaPath = getAliaPath(name);

          verMap[aliaPath] = getDataMd5(file.contents);

          this.push(file);
          cb();
          deferred.resolve();
        }))
        .pipe(gulp.dest(dir));

      packPromises.push(deferred.promise);

    })(destFileMap);

  }

});
gulp.task('makeConf', ['makePacks'], function () {

  Q.all(packPromises)
    .done(function () {
      gulp.src(getDestFile(confFile))
        .pipe(through.obj(function (file, enc, cb) {
          var js = file.contents.toString();
          js = js.replace(/require(js)?\.config\s*?\(\s*?({[\s\S]*?)}\s*?\)/, "require.config($2" + ',\n"verMap" : ' + JSON.stringify(verMap) + "})");

          file.contents = new Buffer(js);
          this.push(file);
          cb();
        }))
        .pipe(gulp.dest(conf.config.dest));
    });
});

gulp.task('makeTpls',function() {
  gulp.src(conf.html.fileset)  
    .pipe(gulp.dest(conf.html.dest));
});

gulp.task('default', ['makeTpls','makeConf']);

代码目前只是个雏形,还有很多需要完成的工作,也有很多地方可以优化。

写这篇文章算是记录自己这一个多星期的研究成果吧。

最后附上github地址: https://github.com/hellopao/requirejs

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值