由来

为了加快访问速度,浏览器会缓存css、js等资源文件。缓存是一把双刃剑,它提高了访问速度的同时,也给网站升级发布带来一些麻烦,就是资源文件过时的问题。就是说,当我们修改了资源文件时,用户的浏览器却使用了缓存,没有读取最新的资源文件,导致页面出错。(我们程序员知道,这种情况下要强制刷新浏览器、清空缓存,但我们没办法要求网站的用户也都这么做)(下面仅用js文件来说明)

为了解决这个问题,有人想到了一个解决办法,就是给js文件的url加上一个版本号查询参数,如:

<script src="a.js?v=1256468"></script>

<script src="a.js?1256468"></script>

每次更新文件时,都改变版本号查询参数。这种做法有帮助,但它是不彻底的,仍然有弊端。因为现代网站为了追求性能极致,通常将js文件放在cdn上,那么在发布程序时,就会存在页面、js文件谁先发布的问题。也就是说,在前后端发布间隔内,无论先发布谁都会有问题。如果先发布页面,那么页面是新页面,页面要请求新js,但此时cdn上仍然是老脚本,出错;如果先发布js, 页面是老页面,请求老js,但此时cdn上已经是新脚本,老脚本已经被干掉了,所以也是会出错。

更好的做法,是把版本号作为文件名的一部分,如下:

<script src="a_2019010201.js"></script>

另外,在cdn上保存新老两份js文件;升级发布时,先发布js,后发布页面。这样既能解决缓存问题,也不存在页面、js文件谁先发布的问题,因为发版前后,不论老页面还是新页面,都能请求到相应的js文件。

我用node.js实现了这个想法,我的js文件是保存在阿里云的cdn上的,实现借助了gulp, 也对线上的js文件进行了压缩

任务分解

我们有多个js文件,当我们只改动了其中的一部分时,只更新改动的部分就可以了,不全部修改。我把总任务分解一下:
1、 resource_versions.json 的文件,来记录各文件的版本
2、 gulp任务。每次执行时,它逐个遍历js文件,判断js文件是否有变化(通过对整个文件计算hash值实现),对变化了的文件压缩、附加上版本号,并上传到阿里云的cdn。在我们升级发布之前,先执行该gulp任务一次。
3、 版本号的计算: 这里是日期加上两位随机数, 如 2019010102。
4、 页面引用js的url, 附带上resource_versions.json中该js的版本号。
resource_versions.json文件的一项如下:

"path/your_file.js": {
    "hash": "hash值",
    "version": "版本号",
    "last_version": "老版本号",
  }

实现

gulp任务

npm依赖项
gulp, ali-oss, gulp-uglify, object-hash, through2
gulp任务的代码
const gulp = require('gulp');
const uglify = require('gulp-uglify');
const through2 = require('through2');
const OSS = require('ali-oss');
const fs = require('fs');
const hash = require('object-hash');

// 深度拷贝的协助方法
function deepClone(obj) {
    // 实现略 ...
}

/*
* 计算版本号字符串
* @param version_str 可选, 如果传了实参,并且实参的日期同代码执行时的日期,那么会使后两位数字加1
* */
function getNewVersion(version_str) {
    function formatDate(_date) {
        var year = _date.getFullYear();
        var month = _date.getMonth() + 1;
        if(month < 10){
            month = '0' + month;
        }
        var day = _date.getDate();
        if(day < 10){
            day = '0' + day;
        }
        return '' + year + month + day;
    }
    function parseDate(date_str) {
        var year = date_str.substr(0, 4) * 1;
        var month = date_str.substr(4, 2) * 1 - 1;
        var day = date_str.substr(6, 2) * 1;
        return new Date(year, month, day);
    }

    var num = null;
    var dateNow = new Date();
    var pattern = /^\d{10}$/;
    if(version_str && pattern.test(version_str)){
        var old_date_str = version_str.substr(0, 8);
        var old_num_str = version_str.substr(8, 2);
        var old_date = parseDate(old_date_str);
        if(old_date.getFullYear() == dateNow.getFullYear() && old_date.getMonth() == dateNow.getMonth() && old_date.getDate() == dateNow.getDate() ){
            num = old_num_str * 1;
        }
    }
    var new_date_str = formatDate(dateNow);
    if(!num){
        num = 0;
    }
    num *= 1;
    if(isNaN(num)){
        num = 0;
    }
    num = Math.floor(num);
    num += 1;
    num %= 100;
    if(num === 0){
        num = 1;
    }
    if(num < 10){
        num = '0' + num;
    }
    var result = new_date_str + num;
    return result;
}

/*
* 存储了所有的资源文件项, 实时变化的
{
    "file_path": {
            "hash": "val",
            "version": "如2019102101",
            "last_version": "上个版本号,没有为null"
      },,,
}
* */
var resource_versions = {};

// 读取硬盘文件,给 resource_versions 赋值
function getResourceVersions() {
    try{
        var txt = fs.readFileSync('resource_versions.json');
        var json = JSON.parse(txt);
        return json;
    }catch (err) {
        if(err.code == 'ENOENT'){
            console.log('缺失资源版本号管理文件 resource_versions.json, 自动新建');
            fs.writeFileSync('resource_versions.json', '{}');
            return {};
        }else{
            throw err;
        }
    }
}

// 把 resource_versions 的修改同步到硬盘文件中
function updateResourceVersions() {
    try{
        var txt = JSON.stringify(resource_versions);
        fs.writeFileSync('resource_versions.json', txt);
    }catch (err) {
        throw err;
    }
}

resource_versions = getResourceVersions();

// 先把resource_versions备份一下,方便计算 欲删除版本号
var old_resource_versions = deepClone(resource_versions);


// 阿里云oss连接信息
var client = new OSS({
    region: 'your_region',
    accessKeyId: 'your_accessKeyId',
    accessKeySecret: 'your_accessKeySecret',
    bucket: 'your_accessKeySecret'
});
// 上传到阿里云的路径前缀
var client_prefix = '/your_oss_path/';
// 本地资源文件的根目录
var resources_direcoty = 'your_resources_direcoty';


/*
* 流转换方法
* 上传 new_version, 保留 last_version, 删除deleted_version
* 更新版本号,更新resource_versions(写到硬盘)
* 上传到阿里云cdn
* */
function uploadToOss(file, _, cb) {
    var filename = file.relative;
    var self = this;
    /*
    * 获取新上传文件在 cdn 上的完整路径
    * @param versionObj 有字段 新版本号、老版本、欲删除版本号
    * */
    var getNewFileKey = function(versionObj){
        var prefix = client_prefix + ((!client_prefix || (client_prefix[client_prefix.length - 1]) === '/') ? '' : '/');
        var relative_str = file.relative.replace(/\\/g, '\/');
        var index1 = relative_str.lastIndexOf('.');
        var part1 = relative_str.substring(0, index1);
        var part2 = relative_str.substr(index1);
        var relative_str_new = part1 + '_' + versionObj.version + '.min' + part2;
        var result = prefix + relative_str_new;
        return result;
    };
    /*
    * 获取欲删除文件在 cdn 上的完整路径
    * @param versionObj 有字段 新版本号、老版本、欲删除版本号
    * */
    var getDeleteFileKey = function(versionObj){
        var prefix = client_prefix + ((!client_prefix || (client_prefix[client_prefix.length - 1]) === '/') ? '' : '/');
        var relative_str = file.relative.replace(/\\/g, '\/');
        var index1 = relative_str.lastIndexOf('.');
        var part1 = relative_str.substring(0, index1);
        var part2 = relative_str.substr(index1);
        var relative_str_new;
        if(versionObj.deleted_version){
            relative_str_new = part1 + '_' + versionObj.deleted_version + '.min' + part2;
            var result = prefix + relative_str_new;
            return result;
        }else{
            return '';
        }
    };

    /*
    * 更新 relative_str 文件对应的 resource_versions 项,并写入硬盘文件
    * @param relative_str 文件路径
    * @note 如果能进入这个方法,那么:一定要重新往oss上传文件
    * */
    function updateVersion(relative_str) {
        var this_version = resource_versions[relative_str];
        var hashVal = hash(file.contents);
        if(this_version){
            // this_version.version 必定会有
            var now_version = this_version.version;
            let new_version = getNewVersion(this_version.version);
            this_version.hash = hashVal;
            this_version.version = new_version;
            this_version.last_version = now_version;
        }else{
            let new_version = getNewVersion();
            this_version = resource_versions[relative_str] = {};
            this_version.hash = hashVal;
            this_version.version = new_version;
            this_version.last_version = null;
        }
        updateResourceVersions();

    }

    /*
    * 获取 relative_str 文件对应的 新版本号、老版本号、欲删除版本号
    * @param relative_str 文件路径
    * @note 如果能进入这个方法,那么:一定要重新往oss上传文件
    * */
    function getVersionObj(relative_str) {
        var this_version = resource_versions[relative_str];
        var last_version = old_resource_versions[relative_str];
        var hashVal = hash(file.contents);

        var return_val = {
            version: this_version.version,
            last_version: this_version.last_version
        };
        if(last_version && last_version.last_version){
            return_val.deleted_version = last_version.last_version;
        }else{
            return_val.deleted_version = null;
        }
        return return_val;
    }

    if(file.isBuffer()){
        (async function(){
            // 上传新文件,删除老文件
            async function doAction() {
                updateVersion(relative_str);
                var versionObj = getVersionObj(relative_str);
                var result = await client.put(getNewFileKey(versionObj), file.contents);
                console.info(filename + '  上传成功');
                var del_target = getDeleteFileKey(versionObj);
                if(del_target){
                    var  del_result = await client.delete(del_target);
                    console.info(filename + '  删除老文件成功');
                }
                cb(null, file);
            }

            try{
                var relative_str = file.relative.replace(/\\/g, '\/');
                var this_version = resource_versions[relative_str];
                if(this_version){
                    var hashVal = hash(file.contents);
                    if(this_version.hash != hashVal){
                        await doAction();
                    }else{
                        cb(null, file);
                    }
                }else{
                    await doAction();
                }
            }catch (err) {
                console.error('oss操作失败:' + filename + "\t" + err.code);
                cb(err, null);
            }
        })();
    }
    else {
        cb();
    }
}

// 处理js文件的gulp任务
exports.default  =  function jsAction() {
    return gulp.src([resources_direcoty + '/**/*.js'])
        .pipe(uglify())  // 压缩文件内容
        .pipe(through2.obj(uploadToOss)); // 用加了版本号的文件名,上传到阿里云oss
        // through2.obj()方法 是 nodejs转换流的包装器
};

实现页面引用的js的url, 附带上resource_versions中该js的版本号

1、程序启动时,读取resource_versions.json文件,并让它常驻内存。再写一个全局的帮助方法,它通过文件路径来获取版本号

// 资源版本号解析
var resource_versions;
if (process.env.NODE_ENV == 'production'){
 	// 如果有错,node会中断,服务不能启动
    try{
        resource_versions = fs.readFileSync('resource_versions.json');
        resource_versions = JSON.parse(resource_versions);
    }catch (err) {
        console.error(err);
        return;
    }

}


function getResourceVersion(str_key){
    if (process.env.NODE_ENV == 'production'){
        if(resource_versions[str_key]){
            return '_' + resource_versions[str_key].version;
        }else{
            return "";
        }
    }else{
        return "";
    }
}
app.locals.getResourceVersion = getResourceVersion;

2、 在页面中引用(ejs模板语言)
调用 app.locals 暴露的 getResourceVersion方法,就可以拿到文件的版本号

<script src="<%=config.resourceSite%>your_js_path<%=getResourceVersion('your_js_path')%></script>

最终效果

可以访问我们公司的网站《猫工问答》(这是一个五金工具的知识分享网站),看效果