几个 Cookie 操作例子的分析

MDN 上提供了操作 Cookie 的若干个例子,也有一个简单的 cookie 框架,今天尝试分析一下,最后是 jquery-cookie 插件的分析。

document.cookie 的操作例子

例 1 :简单使用

为了能直接在控制台调试,我小改成了 IIEF :

document.cookie = "name=oeschger";
document.cookie = "favorite_food=tripe";
(function alertCookie() {
  alert(document.cookie);
})();

这一段是最直观地展现 document.cookie 是存取属性(accessor property)接口的例子。我们给 cookie 接口赋值,但最终效果是新增一项 cookie 键值对而不是替换。

如果我们直接读取 document.cookie 接口,会得到当前生效的所有 cookie 信息,显然多数情况下不是我们想要的。我们可能只想读取特定名字的 cookie 值,一个最普通的想法就是用正则匹配出来:

document.cookie = "test1=Hello";
document.cookie = "test2=World";

var cookieValue = document.cookie.replace(/(?:(?:^|.*;\s*)test2\s*\=\s*([^;]*).*$)|^.*$/, "$1");

(function alertCookieValue() {
  alert(cookieValue);
})();

这里用了两次非捕获组和一次捕获组,最终获取的是捕获组中的 cookie 值。首先 (?:^|.*;\s*) 定位到 test2 开始处,它可能是在整个 cookie 串的开头,或者躲在某个分号之后;接着 \s*\=\s* 不用多说,就是为了匹配等号;而 ([^;]*) 则是捕获不包括分号的一串连续字符,也就是我们想要的 cookie 值,可能还有其他的一些 cookie 项跟在后面,用 .*$ 完成整个匹配。最后如果匹配不到我们这个 test2 也就是说根本没有名为 test2 的 cookie 项,捕获组也就是 $1 会是空值,而 |^.*$ 巧妙地让 replace 函数把整个串都替换成空值,指示我们没有拿到指定的 cookie 值。

那有没有别的方法呢?考虑 .indexOf() 如果别的某个 cookie 项的值也包含了要查找的键名,显然查找位置不符合要求;最好还是以 ; 分割整个串,遍历一遍键值对。

例 3 :让某个操作只做一次

通过在执行某个操作时维护一次 cookie ,之后读取 cookie 就知道该操作是否已经执行过,决定要不要执行。
当然我们这个 cookie 它不能很快过期,否则维护的信息就很快丢失了。

(function doOnce() {
  if (document.cookie.replace(/(?:(?:^|.*;\s*)doSomethingOnlyOnce\s*\=\s*([^;]*).*$)|^.*$/, "$1") !== "true") {
    alert("Do something here!");
    document.cookie = "doSomethingOnlyOnce=true; expires=Fri, 31 Dec 9999 23:59:59 GMT";
  }
});
doOnce();
doOnce();

还是读取 cookie ,这里判断下特定的 cookie 值是不是指定值比如 true ,执行某些操作后设置 cookie 且过期时间视作永久。另外要注意 expires 是 UTC 格式的。

比如在例 3 中的 cookie 我想重置它,以便再执行一遍操作;或者就是想删除某项 cookie :

(function resetOnce() { 
  document.cookie = "doSomethingOnlyOnce=; expires=Thu, 01 Jan 1970 00:00:00 GMT";
})();

通过将 expires 设置为“元日”(比如 new Date(0) ),或者设置 max-age 为 -1 也可,会让 cookie 立即过期并被浏览器删除。但要注意 cookie 的时间是基于客户机的,如果客户机的时间不正确则可能删除失败,所以最好额外将 cookie 值也设为空。

例 5 :在 path 参数中使用相对路径

由于 path 参数是基于绝对路径的,使用相对路径会出错,我们需要手动地转换一下。 JS 可以很方便地使用正则表达式替换:

/*\
|*|
|*|  :: Translate relative paths to absolute paths ::
|*|
|*|  https://developer.mozilla.org/en-US/docs/Web/API/document.cookie
|*|  https://developer.mozilla.org/User:fusionchess
|*|
|*|  The following code is released under the GNU Public License, version 3 or later.
|*|  http://www.gnu.org/licenses/gpl-3.0-standalone.html
|*|
\*/

function relPathToAbs (sRelPath) {
  var nUpLn, sDir = "", sPath = location.pathname.replace(/[^\/]*$/, sRelPath.replace(/(\/|^)(?:\.?\/+)+/g, "$1"));
  for (var nEnd, nStart = 0; nEnd = sPath.indexOf("/../", nStart), nEnd > -1; nStart = nEnd + nUpLn) {
    nUpLn = /^\/(?:\.\.\/)*/.exec(sPath.slice(nEnd))[0].length;
    sDir = (sDir + sPath.substring(nStart, nEnd)).replace(new RegExp("(?:\\\/+[^\\\/]*){0," + ((nUpLn - 1) / 3) + "}$"), "/");
  }
  return sDir + sPath.substr(nStart);
}

首先注意到 location.pathname.replace(/[^\/]*$/, ...) ,先不管第二个参数,这里的正则是要匹配当前 pathname 末尾不包括斜杠的一串连续字符,也就是最后一个目录名。

sRelPath.replace(/(\/|^)(?:\.?\/+)+/g, "$1") 则比较巧妙,会将诸如 /// .// ././.// 的同级路径过滤成单一个斜杠或空串,剩下的自然就只有 ../ ../../ 这样的合法跳转上级的路径。两个放在一起看就是在做相对路径的连接了。

接下来则是一个替换的循环,比较简单,本质就是根据 ../ 的个数删除掉对应数量的目录名,不考虑性能的粗暴模拟算法。

还有一个奇怪的例子

真的被它的设计恶心到了,其实就是前面“只执行一次某操作”的普适版。只想分析一个 replace() 函数中正则的用法,完整代码感兴趣的可以上 MDN 看。

encodeURIComponent(sKey).replace(/[\-\.\+\*]/g, "\\$&")

根据 MDN 上 String.prototype.replace() 的说明,第二个参数还可以传以下的模式串:
$$ :插入一个 $ 符号;
$& :插入匹配到的子串;
$` :插入在匹配子串之前的部分;
$' :插入在匹配子串之后的部分;
$n :范围 [1, 100) 插入第 n 个括号匹配串,只有当第一个参数是正则对象才生效。

因此第二个参数 "\\$&" 巧妙地给这些符号做了一次反斜杠转义。

MDN 上也提供了一个支持 Unicode 的 cookie 访问封装,完整代码也维护在 github madmurphy/cookies.js 上。

基本骨架

只有 5 个算是增删改查的方法,全都封装在 docCookies 对象中,比较简单:

/*\
|*|
|*| :: cookies.js ::
|*|
|*| A complete cookies reader/writer framework with full unicode support.
|*|
|*| Revision #3 - July 13th, 2017
|*|
|*| https://developer.mozilla.org/en-US/docs/Web/API/document.cookie
|*| https://developer.mozilla.org/User:fusionchess
|*| https://github.com/madmurphy/cookies.js
|*|
|*| This framework is released under the GNU Public License, version 3 or later.
|*| http://www.gnu.org/licenses/gpl-3.0-standalone.html
|*|
|*| Syntaxes:
|*|
|*| * docCookies.setItem(name, value[, end[, path[, domain[, secure]]]])
|*| * docCookies.getItem(name)
|*| * docCookies.removeItem(name[, path[, domain]])
|*| * docCookies.hasItem(name)
|*| * docCookies.keys()
|*|
\*/

var docCookies = {
    getItem: function (sKey) {...},
    setItem: function (sKey, sValue, vEnd, sPath, sDomain, bSecure) {...},
    removeItem: function (sKey, sPath, sDomain) {...},
    hasItem: function (sKey) {...},
    keys: function () {...}
};

if (typeof module !== "undefined" && typeof module.exports !== "undefined") {
    module.exports = docCookies;
}

源码解读

getItem 方法的实现思想其实都已经在前面有过分析了,就是利用正则匹配,需要注意对键名编码而对键值解码:

getItem: function (sKey) {
    if (!sKey) { return null; }
    return decodeURIComponent(document.cookie.replace(new RegExp("(?:(?:^|.*;)\\s*" + encodeURIComponent(sKey).replace(/[\-\.\+\*]/g, "\\$&") + "\\s*\\=\\s*([^;]*).*$)|^.*$"), "$1")) || null;
}

hasItem 方法中,会先验证键名的有效性,然后还是那个正则的匹配键名部分。。

hasItem: function (sKey) {
    if (!sKey || /^(?:expires|max\-age|path|domain|secure)$/i.test(sKey)) { return false; }
    return (new RegExp("(?:^|;\\s*)" + encodeURIComponent(sKey).replace(/[\-\.\+\*]/g, "\\$&") + "\\s*\\=")).test(document.cookie);
}

removeItem 方法也是在前面分析过的,设置过期时间为元日并将键值和域名、路径等属性值设空;当然也要先判断一遍这个 cookie 项存不存在:

removeItem: function (sKey, sPath, sDomain) {
    if (!this.hasItem(sKey)) { return false; }
    document.cookie = encodeURIComponent(sKey) + "=; expires=Thu, 01 Jan 1970 00:00:00 GMT" + (sDomain ? "; domain=" + sDomain : "") + (sPath ? "; path=" + sPath : "");
    return true;
}

keys 方法返回所有可读的 cookie 名的数组。

keys: function () {
    var aKeys = document.cookie.replace(/((?:^|\s*;)[^\=]+)(?=;|$)|^\s*|\s*(?:\=[^;]*)?(?:\1|$)/g, "").split(/\s*(?:\=[^;]*)?;\s*/);
    for (var nLen = aKeys.length, nIdx = 0; nIdx < nLen; nIdx++) { aKeys[nIdx] = decodeURIComponent(aKeys[nIdx]); }
    return aKeys;
}

这里的正则比较有意思,第一个 ((?:^|\s*;)[^\=]+)(?=;|$) 有一个非捕获组和一个正向零宽断言,能够过滤只有键值,键名为空的情况。比如这个 nokey 会被过滤掉:

document.cookie = 'nokey';
document.cookie = 'novalue=';
document.cookie = 'normal=test';
console.log(document.cookie);
// nokey; novalue=; normal=test

而第二个 ^\s* 就是匹配开头的空串;第三个 \s*(?:\=[^;]*)?(?:\1|$) 应该是匹配包括等号在内的键值,过滤掉后整个串就只剩下键名了。但这个实现不对,我举的例子中经过这样的处理会变成 ;novalue;normal ,之后 split() 就会导致第一个元素是个空的元素。可以说是为了使用正则而导致可阅读性低且有奇怪错误的典型反例了。

setItem 就是设置 cookie 的方法了,处理得也是比较奇怪, constructor 会有不同页面对象不等的情况,至于 max-age 的不兼容情况在注释里提到了,却不打算改代码。。好吧可能就为了示例?。。

setItem: function (sKey, sValue, vEnd, sPath, sDomain, bSecure) {
    if (!sKey || /^(?:expires|max\-age|path|domain|secure)$/i.test(sKey)) { return false; }
    var sExpires = "";
    if (vEnd) {
        switch (vEnd.constructor) {
            case Number:
                sExpires = vEnd === Infinity ? "; expires=Fri, 31 Dec 9999 23:59:59 GMT" : "; max-age=" + vEnd;
                /*
                Note: Despite officially defined in RFC 6265, the use of `max-age` is not compatible with any
                version of Internet Explorer, Edge and some mobile browsers. Therefore passing a number to
                the end parameter might not work as expected. A possible solution might be to convert the the
                relative time to an absolute time. For instance, replacing the previous line with:
                */
                /*
                sExpires = vEnd === Infinity ? "; expires=Fri, 31 Dec 9999 23:59:59 GMT" : "; expires=" + (new Date(vEnd * 1e3 + Date.now())).toUTCString();
                */
                break;
            case String:
                sExpires = "; expires=" + vEnd;
                break;
            case Date:
                sExpires = "; expires=" + vEnd.toUTCString();
                break;
        }
    }
    document.cookie = encodeURIComponent(sKey) + "=" + encodeURIComponent(sValue) + sExpires + (sDomain ? "; domain=" + sDomain : "") + (sPath ? "; path=" + sPath : "") + (bSecure ? "; secure" : "");
    return true;
}

尽管这个仓库早已不维护了,但其代码还是有可借鉴的地方的。至少没有尝试用正则去做奇怪的匹配呐。还有一个是,如果不知道迁移的原因,只看代码的话真会以为 jquery-cookie 才是从 js-cookie 来的,毕竟后者迁移后的风格没有前者优雅了, so sad..

基本骨架

嗯,典型的 jQuery 插件模式。

/*!
 * jQuery Cookie Plugin v1.4.1
 * https://github.com/carhartl/jquery-cookie
 *
 * Copyright 2006, 2014 Klaus Hartl
 * Released under the MIT license
 */
(function (factory) {
    if (typeof define === 'function' && define.amd) {
        // AMD (Register as an anonymous module)
        define(['jquery'], factory);
    } else if (typeof exports === 'object') {
        // Node/CommonJS
        module.exports = factory(require('jquery'));
    } else {
        // Browser globals
        factory(jQuery);
    }
}(function ($) {

    var pluses = /\+/g;

    function encode(s) {...}

    function decode(s) {...}

    function stringifyCookieValue(value) {...}

    function parseCookieValue(s) {...}

    function read(s, converter) {...}

    var config = $.cookie = function (key, value, options) {...};

    config.defaults = {};

    $.removeCookie = function (key, options) {...};

}));

在我看来,这个架构是比较友好的, encode()decode() 函数可以单独增加一些编码相关的操作,两个以 CookieValue 为后缀的函数也是扩展性比较强的, read() 可能要适当修改以适应更多的 converter 的使用情况。新的仓库则是把它们全都揉在一起了,就像 C 语言写了一个大的 main 函数一样,比较可惜。

边角函数

可以看到几个函数的处理还比较粗糙,像 encodeURIComponent() 在这里有很多不必要编码的情况,会额外增加长度。尽管如此,不难看出核心调用关系:对于 key 可能只需要简单的 encode() / decode() 就好了,而对于 value 的写入会先通过 stringifyCookieValue() 序列化一遍,读出则要通过 read() 进行解析。

    var pluses = /\+/g;

    function encode(s) {
        return config.raw ? s : encodeURIComponent(s);
    }

    function decode(s) {
        return config.raw ? s : decodeURIComponent(s);
    }

    function stringifyCookieValue(value) {
        return encode(config.json ? JSON.stringify(value) : String(value));
    }

    function parseCookieValue(s) {
        if (s.indexOf('"') === 0) {
            // This is a quoted cookie as according to RFC2068, unescape...
            s = s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
        }

        try {
            // Replace server-side written pluses with spaces.
            // If we can't decode the cookie, ignore it, it's unusable.
            // If we can't parse the cookie, ignore it, it's unusable.
            s = decodeURIComponent(s.replace(pluses, ' '));
            return config.json ? JSON.parse(s) : s;
        } catch(e) {}
    }

    function read(s, converter) {
        var value = config.raw ? s : parseCookieValue(s);
        return $.isFunction(converter) ? converter(value) : value;
    }

    var config = $.cookie = function (key, value, options) {...};

    config.defaults = {};

    $.removeCookie = function (key, options) {
        // Must not alter options, thus extending a fresh object...
        $.cookie(key, '', $.extend({}, options, { expires: -1 }));
        return !$.cookie(key);
    };

至于 $.removeCookie 则是通过 $.cookie() 设置过期时间为 -1 天来完成。这也是可以的,可能会多一丁点计算量。

核心读写 $.cookie

通过参数个数决定函数功能应该是 JS 的常态了。那么 $.cookie 也有读和写两大功能。首先是写:

    var config = $.cookie = function (key, value, options) {

        // Write

        if (arguments.length > 1 && !$.isFunction(value)) {
            options = $.extend({}, config.defaults, options);

            if (typeof options.expires === 'number') {
                var days = options.expires, t = options.expires = new Date();
                t.setMilliseconds(t.getMilliseconds() + days * 864e+5);
            }

            return (document.cookie = [
                encode(key), '=', stringifyCookieValue(value),
                options.expires ? '; expires=' + options.expires.toUTCString() : '', // use expires attribute, max-age is not supported by IE
                options.path    ? '; path=' + options.path : '',
                options.domain  ? '; domain=' + options.domain : '',
                options.secure  ? '; secure' : ''
            ].join(''));
        }
        
        ...
    };

    config.defaults = {};

从功能来看, config.defaults 应该是暴露出来可以给 cookie 的一些属性维护默认值的,而传入的 options 当然也可以覆盖先前设置的默认值。这里的 typeof 判断类型似乎也是不太妥的。最后有一个亮点是 .join('') 用了数组连接代替字符串连接。

接下来是读取 cookie 的部分:

    var config = $.cookie = function (key, value, options) {

        ...

        // arguments.length <= 1 || $.isFunction(value)
        // Read

        var result = key ? undefined : {},
            // To prevent the for loop in the first place assign an empty array
            // in case there are no cookies at all. Also prevents odd result when
            // calling $.cookie().
            cookies = document.cookie ? document.cookie.split('; ') : [],
            i = 0,
            l = cookies.length;

        for (; i < l; i++) {
            var parts = cookies[i].split('='),
                name = decode(parts.shift()),
                cookie = parts.join('=');

            if (key === name) {
                // If second argument (value) is a function it's a converter...
                result = read(cookie, value);
                break;
            }

            // Prevent storing a cookie that we couldn't decode.
            if (!key && (cookie = read(cookie)) !== undefined) {
                result[name] = cookie;
            }
        }

        return result;
    };

由于现代浏览器在存 cookie 时都会忽略前后空格,所以读出来的 cookie 串只需要 ;\x20 来分割。当然也可以只 ; 分割,最后做一次 trim() 去除首尾空格。

.split('=') 会导致的一个问题是,如果 cookie 值是 BASE64 编码或其他有包含 = 的情况,就会多分割,所以会有 .shift() 和再次 .join('=') 的操作。这里又分两种情况,如果指定了 key 则读取对应的 value 值,如果什么都没有指定则返回包含所有 cookie 项的对象。

嗯,大概就酱。

参考

  1. Document.cookie - Web APIs | MDN
  2. Object.defineProperty() - JavaScript | MDN
  3. Simple cookie framework - Web APIs | MDN
  4. Github madmurphy/cookies.js
  5. Github carhartl/jquery-cookie
  6. Github js-cookie/js-cookie
  7. RFC 2965 - HTTP State Management Mechanism
  8. RFC 6265 - HTTP State Management Mechanism



本文基于 知识共享许可协议知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 发布,欢迎引用、转载或演绎,但是必须保留本文的署名 BlackStorm 以及本文链接 http://www.cnblogs.com/BlackStorm/p/7618416.html ,且未经许可不能用于商业目的。如有疑问或授权协商请 与我联系

posted @ 2017-10-02 04:41  BlackStorm  阅读(...)  评论(... 编辑 收藏