热搜:前端 nest neovim nvim

理解Cookie - Js-cookie源码阅读浅析

lxf2023-06-21 02:52:25

本文参加了由公众号@若川视野 发起的每周源码共读活动,      点击了解详情一起参与。

这是源码共读的第17期 | js-cookie

前言

学习目标

本文将: 理解Cookie的概念和使用,并以此造一个方便开发使用的轮子

阅读对象

github.com/js-cookie/j…

原理

Cookie(也叫 Web Cookie 或浏览器 Cookie)是服务器发送到用户浏览器并保存在本地的一小块数据。浏览器会存储 cookie 并在下次向同一服务器再发起请求时携带并发送到服务器上。通常,它用于告知服务端两个请求是否来自同一浏览器——如保持用户的登录状态。

在以前客户端没有比较好的本地存储方案时,由服务器指定的Cookie推送给客户端,之后每次请求都会携带cookie,不好的地方在于会带来额外的性能开销。而现在有了更多的选择,本地存储方案还有如: localStoragesessionStorageindexed

Cookie分为两种,一种是Session Cookie,浏览器关闭即失效,存储在内存;另一种是指定了过期时间的Cookie,它们存储在硬盘中

服务器会在客户端请求后在请求响应头部设置如下的字段:

HTTP/1.0 200 OK
Content-type: text/html
Set-Cookie: name_cookie=harexs

之后每次再向服务器发起请求时,客户端会将之前Set-Cookie的值存储起来,并在请求中一同发出

生命周期

Cookie默认的生命周期是跟随浏览器的会话,如果需要保持Cookje则需要用到 Expires和Max-age属性,前者设置的是过期时间,后者设置的过期秒数,需要注意的是Max-age的优先级大于Expires,同时它们都是基于HTTP响应头的服务器时间来做比较,并非本地时间

document.cookie = "test=1;max-age=1000"
document.cookie = "test=1;expires=7"  //天数或者时间

安全

Cookie提供两个属性,Secure/HttpOnly ,前者使得Cookie仅在HTTPS协议下的访问才会发送,后者使得JS不能再对其可用

Set-Cookie: name_cookie=harexs;Secure;HttpOnly;

发送者

Domain

该属性指定了哪些主机可以接受Cookie,如果指定了它,则默认包含这个域名下的子域名

Path

该属性指定了请求的URL路径需要包含的字符才会使用Cookie

SameSite

从Chrome51后增加的用于预防Web安全中的CSRF攻击,它提供了三种值,Strict/Lax/None

  1. Strtic 严格模式,只有用户URL与目标一致的情况下 才会发送Cookie
  2. Lax 宽松模式, 与严格模式类似,但在URL不一致的情况下,只有GET请求/a标签链接/link标签预加载 才会发送Cookie给请求者。如Post表单/Ajax/Image都不会允许发送Cookie。该模式在Chorome80版本后是默认模式
  3. None 关闭所有限制, 但是会被强制要求使用Secure属性, 保证访问的安全性

CSRF攻击

跨站伪造,恶意网站可以伪造带有正确Cookie的HTTP请求去访问服务器,获取用户信息

  1. 用户先登录了银行网站 得到了Cookie 如: Set-Cookie:id=harexs
  2. 随后又访问了恶意网站,用户点击了伪造了请求的表单,服务器没法判断这次请求是否真的是来源于用户操作还是恶意伪造者的请求,此时恶意网站就可以拿到服务器返回的隐私数据了
<form method="post" action="target url"></form>
  1. 除了引发CSRF攻击,Cookie也可以用来追踪用户的访问,比如给网站加载了一张不可见的图片,用户加载此资源时,会发起请求访问,此时服务器就知道了你是从什么地方发起的访问以及你的身份。

源码

入口

 "exports": {
    ".": {
      "import": "./dist/js.cookie.mjs",
      "require": "./dist/js.cookie.js"
    },
    "./package.json": "./package.json"
  },

exports属性可以声明被不同的加载形式下所访问的文件路径

rollup-config

export default [
  {
    input: 'src/api.mjs',
    output: [
      // config for <script type="module">
      {
        file: pkg.module,
        format: 'esm'
      },
      // config for <script nomodule>
      {
        file: pkg.browser,
        format: 'umd',
        name: 'Cookies',
        noConflict: true,
        banner: ';'
      }
    ],
    plugins: [licenseBanner]
  },
  {
    input: 'src/api.mjs',
    output: [
      // config for <script type="module">
      {
        file: pkg.module.replace('.mjs', '.min.mjs'),
        format: 'esm'
      },
      // config for <script nomodule>
      {
        file: pkg.browser.replace('.js', '.min.js'),
        format: 'umd',
        name: 'Cookies',
        noConflict: true
      }
    ]
  }
]

针对同一入口,打包输出不同格式下的产物

主文件api.mjs

function init(){
    ////...
    return Object.create(
        {
      set,
      get,
      remove: function (name, attributes) {
        set(
          name,
          '',
          assign({}, attributes, {
            expires: -1
          })
        )
      },
      withAttributes: function (attributes) {
        return init(this.converter, assign({}, this.attributes, attributes))
      },
      withConverter: function (converter) {
        return init(assign({}, this.converter, converter), this.attributes)
      }
    },
    {
      attributes: { value: Object.freeze(defaultAttributes) },
      converter: { value: Object.freeze(converter) }
    }
    )
}
export defualt init()

assign 合并函数

export default function (target) {
  for (var i = 1; i < arguments.length; i++) {
    var source = arguments[i]
    for (var key in source) {
      target[key] = source[key]
    }
  }
  return target
}

arguments对象是函数中提供的形参对象,它是一个伪数组,包含了所有传入形参,代码内部循环将会从形参的第二位开始,即i=1, 并通过for..in循环 依次读取所有形参的属性赋值到 target中返回, 相当于是Object.assign

converter 解析函数

export default {
  read: function (value) {
    // 如果第一位字符是双引号 则往后截取
    if (value[0] === '"') {
      value = value.slice(1, -1)
    }
    // 将以百分号开始数字大写字符的 全局下任意次数出现的字符 使用decodeURIComponent解码
    return value.replace(/(%[\dA-F]{2})+/gi, decodeURIComponent)
  },
  write: function (value) {
    // 如果字符包含url编码的字符则先进行decodeURIComponent解码替换字符再进行一次encodeURIComponent编码
    return encodeURIComponent(value).replace(
      /%(2[346BF]|3[AC-F]|40|5[BDE]|60|7[BCD])/g,
      decodeURIComponent
    )
  }
}

converter 提供了对于字符的 写和读的转换方法, 主要是对于URI编码的转换

set 方法

function set (name, value, attributes) {
    // js api 不存在直接结束
    if (typeof document === 'undefined') {
      return
    }
    // 使用合并函数合并默认的 字符字符方法 和传入的的属性
    attributes = assign({}, defaultAttributes, attributes)

    // 如果设置了 过期时间为天数 则转换为对应天数日期
    // 864e5 即 86400000 一天的 毫秒数
    if (typeof attributes.expires === 'number') {
      attributes.expires = new Date(Date.now() + attributes.expires * 864e5)
    }
    // 不为数字类型则认为是日期时间 直接用UTC时间转换
    if (attributes.expires) {
      attributes.expires = attributes.expires.toUTCString()
    }
    // 先进行一次编码,在将%23(#)/4($)/6(&)/B(+) %5E(^) %60(`) %7C(|) 解码
    name = encodeURIComponent(name)
      .replace(/%(2[346B]|5E|60|7C)/g, decodeURIComponent)
      // 这里是因为decodeURI 和 decodeURIComponent都不会对()字符进行转换
      .replace(/[()]/g, escape)

    var stringifiedAttributes = ''
    for (var attributeName in attributes) {
      // 如果值被判断为false 也直接跳过 比如字符 ''
      if (!attributes[attributeName]) {
        continue
      }
      // ;是cookie的分隔符 进行拼接
      stringifiedAttributes += '; ' + attributeName

      // 如果值为布尔true则直接跳过
      if (attributes[attributeName] === true) {
        continue
      }

      // 拼接值,如果值存在分隔符则取分隔符前面的部分, stringifiedAttributes则为传入其他属性 比如expries max-age domain
      stringifiedAttributes += '=' + attributes[attributeName].split(';')[0]
    }
    // 传入的value 进行一次write方法转换 最终增加 name=value;stringifiedAttributes
    return (document.cookie =
      name + '=' + converter.write(value, name) + stringifiedAttributes)
  }

get方法

function get (name) {
    // document无效 或未传参
    if (typeof document === 'undefined' || (arguments.length && !name)) {
      return
    }

    // To prevent the for loop in the first place assign an empty array
    // in case there are no cookies at all.
    // 默认的分隔符后会带有一个空格
    var cookies = document.cookie ? document.cookie.split('; ') : []
    var jar = {}
    for (var i = 0; i < cookies.length; i++) {
      var parts = cookies[i].split('=')
      var value = parts.slice(1).join('=') // 得到= 后的值

      try {
        var found = decodeURIComponent(parts[0]) // found 即解码后的name
        if (!jar[found]) jar[found] = converter.read(value, found) // 如果jar中不存在则 通过read转换后赋值到对象中

        if (name === found) { // 如果found和name一致则结束循环
          break
        }
      } catch (e) {}
    }
    // 返回name 不存在返回整个对象
    return name ? jar[name] : jar
  }

remove

remove: function (name, attributes) {
        set(
          name,
          '',
          assign({}, attributes, {
            expires: -1
          })
        )
      }

通过set方法,将过期时间设置为负数 即可实现移除

withAttributes/withConverter

withAttributes: function (attributes) {
        return init(this.converter, assign({}, this.attributes, attributes))
      },
withConverter: function (converter) {
    return init(assign({}, this.converter, converter), this.attributes)
}

通过再次Return, 返回带有指定的默认属性或者默认转换方法

import Cookie from 'js-cookie'
let newCookie = Cookie.withAttributes({expires:5}) //后面所有newCookie.set 都默认嗲有五天过期时间

最简轮子实现

const read = (value: string) =>
  value.replace(/(%[\dA-F]{2})+/gi, decodeURIComponent);
const write = (value: string) =>
  encodeURIComponent(value).replace(
    /%(2[346BF]|3[AC-F]|40|5[BDE]|60|7[BCD])/g,
    decodeURIComponent
  );

export default function HarexsCookie(attr: Record<string, any>) {
  attr = Object.assign({}, attr);
  const get = (name) => {
    const cookies = document.cookie ? document.cookie.split("; ") : [];
    const allKey = {};
    // 遍历js 中的 cookie 并遍历需要的key
    for (let ret of cookies) {
      const retAry = ret.split("=");
      const retValue = retAry.slice(1).join("=");
      const retName = decodeURIComponent(retAry[0]);
      if (!allKey[retName]) allKey[retName] = read(retValue);
      if (retName === name) return allKey[retName] || allKey;
    }
  };
  const set = (name, value, attr: Record<string, any>) => {
    attr = Object.assign({}, attr);
    if (typeof attr.expires === "number")
      attr.expires = new Date(Date.now() + attr.expires * 864e5);
    if (attr.expires) attr.expires = attr.expires.toUTCString();

    const cookieName = encodeURIComponent(name)
      .replace(/%(2[346B]|5E|60|7C)/g, decodeURIComponent)
      .replace(/[()]/g, escape);
    //转换属性拼接字符
    let attrToStringify = "";
    for (const key in attr) {
      if (!attr[key]) continue;
      if (attr[key] === true) continue;
      attrToStringify += "; " + key;
      attrToStringify += "=" + attr[key].split(";")[0];
    }
    return (document.cookie =
      cookieName + "=" + write(value) + attrToStringify);
  };
  const remove = (name) => {
    return set(name, "", Object.assign(attr, { expires: -1 }));
  };

  return {
    get,
    set,
    remove,
  };
}

总结

  1. encodeURUIComponent 相比于 encodeURI增加了对于 ;,/?:@&=+$的转换, 但没有对于 ()字符转换,所以源码中增加了escape 方法的调用
  2. 864e5是科学记数法, 它在控制台输出可以得到86400000即一天的时间
  3. 整体源码返回的是一个对象,set,get,remove,withAttributes,withConverter都存在于这个对象的原型上,它提供了withAttributes / withConverter 给用户自定义能力, 并通过Object.freeze使得 Object.create 第二个参数中的对象属性描述符的 attributes/converter value不可变。
  4. 结合read write方法,通过document.cookie 实现对于cookie的读改删查
本网站是一个以CSS、JavaScript、Vue、HTML为核心的前端开发技术网站。我们致力于为广大前端开发者提供专业、全面、实用的前端开发知识和技术支持。 在本网站中,您可以学习到最新的前端开发技术,了解前端开发的最新趋势和最佳实践。我们提供丰富的教程和案例,让您可以快速掌握前端开发的核心技术和流程。 本网站还提供一系列实用的工具和插件,帮助您更加高效地进行前端开发工作。我们提供的工具和插件都经过精心设计和优化,可以帮助您节省时间和精力,提升开发效率。 除此之外,本网站还拥有一个活跃的社区,您可以在社区中与其他前端开发者交流技术、分享经验、解决问题。我们相信,社区的力量可以帮助您更好地成长和进步。 在本网站中,您可以找到您需要的一切前端开发资源,让您成为一名更加优秀的前端开发者。欢迎您加入我们的大家庭,一起探索前端开发的无限可能!