理解Cookie原理并对js-cookie源码解析

5,365 阅读14分钟

一、HTTP协议和Cookie

HTTP是一种无状态协议,无状态是指服务端对于客户端每次发送的请求都认为它是一个新的请求,上一次会话和下一次会话没有联系。很多场景下,我们需要知道下一次的会话和上一次的会话的关系(比如登陆之后我们需要记住登陆状态),于是就引入了Cookie技术 。

Cookie会根据从服务器端发送的响应报文内的一个叫做Set-Cookie的首部字段信息,通知客户端保存Cookie。在同源策略下当浏览器再请求服务器时,浏览器把请求的网址连同该Cookie一同提交给服务器。服务器通过检查Cookie来获取用户状态。

二、Cookie的工作机制

Cookie一般是被浏览器以txt纯文本的形式存储在电脑硬盘中,供该浏览器进行读、写操作。Cookie不包含任何可执行代码。下面需要介绍一下Cookie在不同开发模式下的的工作机制。

2.1、前后端统一在一个服务中

看一下传统的做法,前后端统一在一个服务中。

如上图所示,页面渲染放在一个服务器执行并返回,用户输入用户名、密码后,后台服务在session中设置登录状态,和用户的一些基本信息,然后将响应(Response)返回到浏览器(Browser),并设置Cookie

下次用户在这个浏览器(Browser)中再次访问服务时,请求中会带上这个Cookie,服务端根据这个Cookie就能找到对应的session,从session中取得用户的信息从而维持了用户的登录状态。这种机制被称作Cookie-Session机制。

  • request请求报文:

当浏览器发起一个请求时,浏览器会自动检查是否有相应的cookie,如果有则将cookie添加到Request HeadersCookie字段中(Cookie字段是很多key=value以分号分隔的字符串)。

  • response响应报文:

当服务端需要写入Cookie时,在http请求的Response Headers中添加Set-Cookie字段,浏览器接收到之后会自动解析识别,写入Cookie到客户端。

2.2、前后端分离

随着前后端分离的流行,我们的项目结构也发生了变化,如下图:

我们访问一个网站时,先去请求静态服务,拿到页面后,再异步去后台请求数据,最后渲染成我们看到的带有数据的网站。

在这种结构下,浏览器会不会自动在请求头中发送Cookie呢?这里分两种情况,在同一域下和在不同域下。

2.1.1、同一域下的前后端分离

在同一域下即同源策略是浏览器保证安全的基础,是指A网页设置的Cookie,B网页不能打开,除非这两个网页同源。所谓的同源是指:

  • 协议相同
  • 域名相同
  • 端口相同

同域下异步请求时Cookie也能带到服务端。所以,我们在做前后端分离时,前端和后端部署在同一域下,满足浏览器的同源策略,不需要对Cookie特殊处理。

2.1.2、不同域下的前后端分离

不同域下的前后端分离,我们通常会跨域请求(CORS)。默认情况下浏览器对跨域请求不会携带CookieCORS推荐使用额外的响应头字段来允许跨域发送Cookie

后端CORS处理时,设置相应的Header

// 是否允许发送Cookie。true表示服务器明确许可,Cookie可以包含在请求中,一起发给服务器
Access-Control-Allow-Credentials: true

前端CORS处理时,设置相应的withCredentials属性:

const xhr = new XMLHttpRequest();
// 设置withCredentials为true让该跨域请求携带Cookie
xhr.withCredentials = true;

CORS需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE浏览器不能低于IE10。

三、Cookie的限制和注意点

3.1、Cookie大小和个数限制

因为Cookie存储在客户端机器上,为保证它不会被恶意利用,浏览器会施加限制。

每个域能设置的Cookie总数也是受限的,不同浏览器的限制不同。例如:

  • 最新版IEEdge限制每个域不超过50个Cookie
  • Firefox每个域名Cookie限制为50个。
  • Opera每个域名Cookie限制为30个。
  • SafariChrome对每个域的Cookie数没有硬性限制。但是如果Cookie很多,则会使 Request Header 大小超过服务器的处理的限制,会导致错误发生。

如果Cookie总数超过了单个域的上限,浏览器就会删除之前设置的Cookie

  • IEOpera会按照最近最少使用(LRU,LeastRecentlyUsed)原则删除之前的Cookie,以便为新设置的Cookie腾出空间。
  • Firefox好像会随机删除之前的Cookie,因此为避免不确定的结果,最好不要超出限制。

不同浏览器间每个Cookie大小也不同:

  • FirefoxSafari允许Cookie多达4097个字节, 包括名(key)、值(value)和等号。
  • Opera允许Cookie多达 4096 个字节, 包括名(key)、值(value)和等号。
  • Internet Explorer 允许Cookie多达 4095 个字节, 包括:名(key)、值(value)和等号。

在所有浏览器中如果创建的Cookie超过最大限制,则该Cookie会被静默删除。注意,一个字符通常会占1字节。如果使用多字节字符(如UTF-8Unicode字符),则每个字符最多可能占4字节。

通常情况下,只要遵守以下大致的限制,就不会在任何浏览器中碰到问题:

  • 每个Cookie不超过4096字节(4kb)

  • 每个域不超过20Cookie

  • 每个域不超过81920字节(80kb)

3.2、Cookie编码

在存入Cookie信息时不能包含空格,分号,逗号等特殊符号。而在一般情况下,Cookie信息的存储都是采用未编码的方式。规范中提到可以利用URL编码,但是并不是必须编码。对于key=value的格式,key和value通常都单独进行编码并且不对等号=进行编码操作。

每个Cookie的格式是这样的:<名>=<值>;名称和值都必须是合法的标示符。

四、Cookie的构成

4.1、名称 key

唯一标识Cookie的名称。

注意以下几点:

  1. Cookie名不区分大小写,实践中最好将Cookie名当成区分大小写来对待。
  2. Cookie名通常需要经过URL编码。

4.2、值 value

存储在Cookie里的字符串值。这个值通常需要经过URL编码。

4.3、域 domain

发送到这个域的所有请求都会包含对应的Cookie。默认情况下,domain会被设置为创建该Cookie的页面所在的域名。例如 www.baidu.com 中的Cookie的domain属性的默认值为 www.baidu.com

如百度这样的大型网站都会有许多以name.baidu.com(例如:map.baidu.com,news.baidu.com)等为格式的站点。domain这个值可以是子域(如 www.baidu.com ),也可以是.baidu.com表示对baidu.com的所有子域都有效)。

浏览器会对domain的值与请求所要发送至的域名,做一个尾部比较(即从字符串的尾部开始比较),并且在匹配后发送一个Cookie消息头。

domain设置的值必须是发送Set-Cookie消息头的域名。例如,设置domain的域名为.baidu.com,无法向google.com发送一个cookie,因为这个产生安全问题。

4.4、路径 path

请求URL中包含这个path路径才会把Cookie发送到服务器。这个比较是通过将path属性值与请求的URL从头开始逐字符串比较完成的。如果字符匹配,则发送Cookie消息头。

可以指定Cookie只能由 www.baidu.com/books/ 访问,因此访问 www.baidu.com/ 下的页面就不会发送Cookie,即使请求的是同一个域。

要注意的是只有在domain选项核实完毕之后才会对path属性进行比较。path属性的默认值是发送Set-Cookie消息头所对应的URL中的path部分。

4.5、过期时间 Expires,Max-Age

  • Expires

表示何时删除Cookie的时间戳。默认情况下,浏览器会话结束后(关闭浏览器)会删除所有Cookie

需要注意的是,谷歌浏览器关闭后可能会不清除会话级别的Cookie。所以尽量不要设置回话级别的Cookie

也可以设置删除Cookie的时间。这个值可以是GMT/UTC时间格式,即使关闭浏览器了Cookie也会保留在用户机器上。把过期时间设置为过去的时间会立即删除Cookie

  • Max-age

Max-age指定一个整数,代表Cookie可以存活的秒数。浏览器根据当前时间和Max-age来设定Cookieexpires时间。Max-age可以是expires的替代选项,具指明Cookie的过期时间距离当前时间的秒数。

Max-age的优先级高于expires

4.6、安全标志 secure,HttpOnly

  • secure

设置secure之后,只能在使用SSL安全连接的情况下才会把Cookie发送到服务器。例如,请求www.baidu.com 会发送Cookie,而请求 www.baidu.com 则不会。安全标志secureCookie中唯一的非名/值对,只需一个secure就可以了。

  • HttpOnly

HttpOnly属性指定该Cookie无法通过JavaScript脚本拿到。HttpOnly可以在浏览器设置,也可以在服务器设置,但只能在服务器上读取,只有浏览器发出HTTP请求时,才会带上该Cookie

域、路径、过期时间和secure标志用于告诉浏览器什么情况下应该在请求中包含Cookie。这些参数并不会随请求发送给服务器,实际发送的只有Cookie的名/值对。

4.7、sameSite

这是一个新的属性,较新的浏览器都已支持。它的作用是允许Cookie在跨站请求时不会被发送,从而阻止CSRF攻击。

五、JavaScript中操作Cookie

在JavaScript中可以通过document.cookie来读取或设置这些信息。由于Cookie多用在客户端和服务端之间进行通信,所以除了JavaScript以外,服务端的语言(如Java,NodeJS)也可以存取Cookie

5.1、读取Cookie

document.cookie属性用于读写当前网页的Cookiedocument.cookie返回包含页面中所有有效Cookie的字符串(根据域、路径、过期时间和安全设置等)。

document.cookie的值由key=value键值对组成,以; 分隔(注意中间有空格)。每一个都是独立的Cookie。示例代码如下:

// document.cookie
"MONITOR_WEB_ID=0ab9592e-dcde-4e3c-a515-c61ba533ec35; _ga=GA1.2.1450940347.1612347575; passport_csrf_token=e5b64381d2dedf71bff85dc52383f25f; _gid=GA1.2.274376758.1612619978"

为了找到一个特定的Cookie,我们可以以; 作为分隔,将document.cookie分开,然后找到对应的名字和值。

5.2、新增Cookie

在设置值时,可以通过document.cookie属性设置新的Cookie字符串。这个字符串在被解析后会添加到原有Cookie中。设置document.cookie不会覆盖之前存在的任何Cookie,除非设置了已有的Cookie

设置Cookie的格式示例代码如下:

// 格式
"key=value; expires=expiration_time; path=domain_path; domain=domain_name; secure; HttpOnly"

// 实例
"age=30; expires=Mon, 08 Feb 2021 06:22:31 GMT; path=/; domain=.baidu.com; secure; HttpOnly"

在所有这些参数中,只有Cookie的名称和值是必需的。最好使用encodeURIComponent()对名称和值进行编码,如下代码:

document.cookie = `${encodeURIComponent('age')}=${encodeURIComponent('30')}`

要为创建的Cookie指定额外的信息,直接在后面追加相同格式的字符串,如下代码:

document.cookie = `${encodeURIComponent('age')}=${encodeURIComponent('30')}; domain=.baidu.com; path=/`

5.3、修改Cookie

要修改一个Cookie,只需要重新赋值就行,旧的值会被新的值覆盖。但要注意一点,在设置新Cookie时,切记domainpath这几个选项一定要和旧的Cookie保持一致。否则不会修改旧值,而是添加了一个新的Cookie。修改示例代码如下:

document.cookie = `${encodeURIComponent('age')}=${encodeURIComponent('20')}; domain=.baidu.com; path=/`

5.4、删除Cookie

要删除一个Cookie也是需要重新赋值,只要将这个新Cookieexpires选项设置为一个过去的时间点就行了。但同样要注意,domainpath这几个选项一定要和旧的Cookie保持一致。

document.cookie = `${encodeURIComponent('age')}=""}; domain=.baidu.com; path=/; expires=${new Date(0).toGMTString()}`

5.5、子Cookie

为绕过浏览器对每个域Cookie数的限制提出了子Cookie的概念。子Cookie就是Cookie的值在单个Cookie中存储多个名/值对。最常用的子Cookie存储模式可以如下:

document.cookie=data=name=zhangsan&age=30

子Cookie的格式类似于查询字符串。这些值可以存储为单个Cookie,而不用单独存储为自己的名/值对。结果就是网站或Web应用程序能够在单域Cookie数限制下存储更多的结构化数据。

要操作子Cookie,需要添加一些辅助方法,因为对子Cookie的使用而变得更复杂。下面代码摘自JavaScript高级程序设计第四版:

class SubCookieUtil {
  // 获取所有子 cookie,并以对象形式返回,对象的属性是子 cookie 的名称,值是子 cookie 的值。
  static getAll(name) {
    // 获取cookie名称
    let cookieName = encodeURIComponent(name) + '='
    // name 开始的索引
    let cookieStart = document.cookie.indexOf(cookieName)
    // name结束的索引
    let cookieEnd = null
    // name的值
    let cookieValue = null
    // 子cookie的值
    let subCookies = null
    // 存储结果
    let result = {}

    if (cookieStart > -1) {
      // 获取name结束的索引
      cookieEnd = document.cookie.indexOf(';', cookieStart)
      // 如果没有找到说明是最后一项
      if (cookieEnd === -1) {
        cookieEnd = document.cookie.length
      }
      // 通过substring()得到了value值
      cookieValue = document.cookie.substring(cookieStart + cookieName.length, cookieEnd)
      // 如果有value值
      if (cookieValue.length > 0) {
        // 根据&分割value值
        subCookies = cookieValue.split('&')

        for (let i = 0, len = subCookies.length; i < len; i++) {
          // 获取键值对
          let parts = subCookies[i].split('=')
          // 设置对象
          result[decodeURIComponent(parts[0])] = [decodeURIComponent[parts[1]]]
        }
      }
    }
    return result
  }

  // 获取一个子 cookie 的值
  static get(name, subName) {
    // 调用 getAll()获取所有子 cookie
    let subCookies = SubCookieUtil.getAll(name)
    return subCookies[subName] || ''
  }
}
let data = SubCookieUtil.getAll('data')
console.log(data.name) // "zhangsan"
console.log(data.age) // "30"

// 取得单个子cookie
SubCookieUtil.get('data', 'name') // "zhangsan"
SubCookieUtil.get('data', 'age') // "30"

要写入子Cookie,可以使用另外两个方法:set()setAll()。这两个方法的实现如下:

class SubCookieUtil {
  // cookie 的名称、包含所有子 cookie 的对象
  static setAll(name, subCookies = {}, expires, path, domain, secure) {
    // 编码
    let cookieText = encodeURIComponent(name) + '='
    let subCookieParts = []
    for (let subName in subCookies) {
      if (subCookies.hasOwnProperty(subName)) {
        subCookieParts.push(`${encodeURIComponent(subName)}=${encodeURIComponent(subCookies[subName])}`)
      }
    }

    // 如果有子cookie
    if (subcookieParts.length > 0) {
      cookieText += subcookieParts.join('&')

      if (expires instanceof Date) {
        cookieText += `; expires=${expires.toGMTString()}`
      }

      if (path) {
        cookieText += `; path=${path}`
      }

      if (domain) {
        cookieText += `; domain=${domain}`
      }

      if (secure) {
        cookieText += `; ${secure}`
      }
    } else {
      cookieText += `; expires=${new Date(0).toGMTString()}`
    }
    document.cookie = cookieText
  }

  // cookie 的名称、子 cookie 的名称、子 cookie 的值、可选的 Date 对象用于设置 cookie 的过期时间、可选的 cookie 路径、可选的 cookie 域和可选的布尔值 secure 标志
  static set(name, subName, value, expires, domain, path, secure) {
    let subcookies = SubCookieUtil.getAll(name)
    subcookies[subName] = value
    SubCookieUtil.setAll(name, subcookies, expires, path, domain, secure)
  }
}
// 设置两个子 cookie 
SubCookieUtil.set("data", "name", "zhangsan"); 
SubCookieUtil.set("data", "age", "30"); 
// 设置所有子 cookie 并传入过期时间
SubCookieUtil.setAll("data", { name: "zhangsan", age: "30" }, new Date("January 1, 2010")); 
// 修改"name"的值并修改整个 cookie 的过期时间
SubCookieUtil.set("data", "name", "zhangsan", new Date("February 1, 2010"));

最后一组子Cookie相关的方法是要删除子Cookie的。为了删除子Cookie,需要先取得所有子Cookie,把要删除的那个删掉,然后再把剩下的子Cookie设置回去。下面是相关方法的实现:

class subCookieUtil {
  // 从 cookie 中删除一个子 cookie,其他子 cookie 不受影响
  static unset(name, subName, path, domain, secure) {
    let subCookies = subCookieUtil.getAll(name)
    if (subCookies) {
      delete subCookies[subName] // 删除
      subCookieUtil.setAll(name, subCookies, null, path, domain, secure)
    }
  }
  // 删除整个 cookie
  static unsetAll(name, path, domain, secure) {
    subCookieUtil.setAll(name, null, new Date(0), path, domain, secure)
  }
}
 // 只删除"name"子 cookie
SubCookieUtil.unset('data', 'name')
// 删除整个 cookie
SubCookieUtil.unsetAll('data')

六、js-cookie源码解析

在JavaScript中读写Cookie不是很直观,所以可以通过辅助函数来简化相应的操作。与Cookie相关的基本操作有读取,修改,增加,删除。下面将通过解析Github 17k starjs-cookie源码来看一下简化Cookie的操作。

js-cookie版本号为v3.0.0-rc.1 release

// 对象合并
function assign(target) {
  for (var i = 1; i < arguments.length; i++) {
    var source = arguments[i]
    for (var key in source) {
      target[key] = source[key]
    }
  }
  return target
}
// 默认转换对象
const defaultConverter = {
  // 读取的时候解码
  read: function (value) {
    // 匹配URL编码表,并进行解码
    return value.replace(/(%[\dA-F]{2})+/gi, decodeURIComponent)
  },
  // 写入的时候编码
  write: function (value) {
    // 编码后对特殊的字符解码
    // #$+/:<=>%@[]^`{|}
    // %23: #
    // %24: $
    // %26: &
    // %2B: +
    // %2F: /
    // %3A: :
    // %3C: <
    // %3D: =
    // %3E: >
    // %3F: %
    // %40: @
    // %5B: [
    // %5D: ]
    // %5E: ^
    // %60: `
    // %7B: {
    // %7C: |
    // %7D: }
    return encodeURIComponent(value).replace(/%(2[346BF]|3[AC-F]|40|5[BDE]|60|7[BCD])/g, decodeURIComponent)
  }
}

// 初始化入口
function init(converter, defaultAttributes) {
  // 设置cookie
  function set(key, value, attributes) {
    // 没有找到document直接返回
    if (typeof document === 'undefined') {
      return
    }

    // 合并属性
    attributes = assign({}, defaultAttributes, attributes)

    // 如果过期时间expires为number,设置cookie时间戳
    if (typeof attributes.expires === 'number') {
      // 864*10的5次方 = 86400000
      attributes.expires = new Date(Date.now() + attributes.expires * 864e5)
    }
    // 如果有过期时间, 设置世界标准时间
    if (attributes.expires) {
      attributes.expires = attributes.expires.toUTCString()
    }
    // 对key编码
    key = encodeURIComponent(key)
      // 再通过字符串替换的方式解码
      // #$&+^`|
      // %23: #
      // %24: $
      // %26: &
      // %2B: +
      // %5E: ^
      // %60: `
      // %7C: |
      .replace(/%(2[346B]|5E|60|7C)/g, decodeURIComponent)
      // 对()进行编码
      .replace(/[()]/g, escape)

    // 对value编码
    value = converter.write(value, key)

    // 拼接其它的cookies属性值变量
    // 例如:"age=30; expires=Fri, 31 Dec 9999 23:59:59 GMT; path=/"
    var stringifiedAttributes = ''
    // 遍历合并后的对象
    for (var attributeName in attributes) {
      // 如果传入属性的value不存在就退出当前循环,执行下一次
      if (!attributes[attributeName]) {
        continue
      }
      // 添加key
      stringifiedAttributes += '; ' + attributeName

      // 如果设置非键/值属性 例如:Secure,HttpOnly属性,则退出当前循环,执行下一次循环
      if (attributes[attributeName] === true) {
        continue
      }

      // Considers RFC 6265 section 5.2:
      // ...
      // 3.  If the remaining unparsed-attributes contains a %x3B (";")
      //     character:
      // Consume the characters of the unparsed-attributes up to,
      // not including, the first %x3B (";") character.
      // ...

      // 大致意思是如果set-cookie-string包含%x3B(";")
      // 那么name-value-pair由set-cookie-string 开头到第一个%x3B(";")组成,但不包含%x3B(";")
      // 所以要通过";"截取一下
      stringifiedAttributes += '=' + attributes[attributeName].split(';')[0]
    }
    // 设置值并返回
    return (document.cookie = key + '=' + value + stringifiedAttributes)
  }

  // 获取cookie
  function get(key) {
    // 没有找到document直接返回或者传入了参数并且参数为false,直接返回
    if (typeof document === 'undefined' || (arguments.length && !key)) {
      return
    }
    // 如果cookie存在,通过;cookie分割为数组
    var cookies = document.cookie ? document.cookie.split('; ') : []
    // 存储json格式的cookie
    var jar = {}
    // 遍历所有cookie
    for (var i = 0; i < cookies.length; i++) {
      // 通过=分割为数组
      var parts = cookies[i].split('=')
      // 获取value,去除value中的=号
      var value = parts.slice(1).join('=')

      // 如果有"删除引号字符串,避免会出现类似'age="30"'这种格式
      if (value[0] === '"') {
        // 去除"
        value = value.slice(1, -1)
      }

      try {
        // 对名称进行解码
        var foundKey = defaultConverter.read(parts[0])
        // 根据名称获取对应的值,并进行解码
        jar[foundKey] = converter.read(value, foundKey)

        // 如果传入的key和已经找到的foundKey相等,则退出循环
        if (key === foundKey) {
          break
        }
      } catch (e) {}
    }
    // 如果传入了key,就找到相应value返回,否则返回全部cookie对象
    return key ? jar[key] : jar
  }
  // 通过Object.create创建原型对象并返回
  return Object.create(
    // 原型对象
    {
      // set赋值
      set: set,
      // get赋值
      get: get,
      // 删除某项
      remove: function (key, attributes) {
        set(
          key,
          '',
          assign({}, attributes, {
            // 把过期时间设置为过去的时间会立即删除cookie
            expires: -1
          })
        )
      },
      // 全局设置Cookie自定义属性默认值
      withAttributes: function (attributes) {
        return init(this.converter, assign({}, this.attributes, attributes))
      },
      // 全局设置Cookie自定义的编解码实现
      withConverter: function (converter) {
        return init(assign({}, this.converter, converter), this.attributes)
      }
    },
    // 自身的属性
    {
      attributes: { value: Object.freeze(defaultAttributes) },
      converter: { value: Object.freeze(converter) }
    }
  )
}

export default init(defaultConverter, { path: '/' })

七、使用Cookie注意事项

  1. 因为所有Cookie都会作为请求头部由浏览器发送给服务器,所以在Cookie中保存大量信息可能会影响特定域浏览器请求的性能。保存的Cookie越大,请求完成的时间就越长。即使浏览器对Cookie大小有限制,最好还是尽可能只通过Cookie保存必要信息,以避免性能问题。

  2. 不要在Cookie中存储重要或敏感的信息。Cookie数据不是保存在安全的环境中,因此任何人都可能获得。应该避免把信用卡号或个人地址等信息保存在Cookie中。

八、浏览器管理Cookie小插件

使用EditThiscookie小插件可以方便的查看和管理Cookie,推荐使用。

chrome应用商店可以下载:

chrome.google.com/webstore/de…

九、参考链接

www.cnblogs.com/lyzg/p/6067…

blog.liuyunzhuge.com/2020/05/14/…

zh.javascript.info/cookie

www.cnblogs.com/darren_code…

github.com/js-cookie/j…

javascript.ruanyifeng.com/bom/cookie.…

blog.csdn.net/lijing19899…

www.cnblogs.com/rubylouvre/…

www.ruanyifeng.com/blog/2016/0…

segmentfault.com/a/119000000…

juejin.cn/post/684490…

developer.mozilla.org/zh-CN/docs/…

javascript.ruanyifeng.com/nodejs/basi…

baike.baidu.com/item/URL%E7…

www.yanghongdong.cn/js/learn-js…

github.com/js-cookie/j…

www.ruanyifeng.com/blog/2010/0…

github.com/renaesop/bl…

zhuanlan.zhihu.com/p/140797780

juejin.cn/post/691410…