【若川视野 x 源码共读】cookie API真难用,你造过相关的轮子吗

1,034 阅读9分钟

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

前言

歌德说过:读一本好书,就是在和高尚的人谈话。
同理,读优秀的开源项目的源码,就是在和优秀的大佬交流,是站在巨人的肩膀上学习 —— 今天我们将通过读js-cookie 的源码,来学会造一个操作 cookie 的轮子~

1. 准备

简单介绍一下 cookie

Cookie 是直接存储在浏览器中的一小串数据。它们是 HTTP 协议的一部分,由 RFC 6265 规范定义。 最常见的用处之一就是身份验证 我们可以使用 document.cookie 属性从浏览器访问 cookie。

这个库,是干啥的🤔

不用这个库时🤨

cookie 的原生API,非常“丑陋”:

修改

我们可以写入 document.cookie。但这不是一个数据属性,它是一个 访问器(getter/setter)。对其的赋值操作会被特殊处理。 对 document.cookie 的写入操作只会更新其中提到的 cookie,而不会涉及其他 cookie。 例如,此调用设置了一个名称为 user 且值为 John 的 cookie:

document.cookie = "user=John"; // 只会更新名称为 user 的 cookie
document.cookie = "user=John; path=/; expires=Tue, 19 Jan 2038 03:14:07 GMT"

赋值时传入字符串,并且键值对以=相连,如果多项还要用分号;隔开...

删除

将过期时间设置为过去,自然就是删除了~

// 删除 cookie(让它立即过期)
document.cookie = "expires=Thu, 01 Jan 1970 00:00:00 GMT";
document.cookie = "user=John; max-age=0";

但是很明显,这语义化也太差了..

js-cookie

API

我们先来了解一下API

// set
Cookies.set('name', 'value', { expires: 7, path: '' })
// get 
Cookies.get('name') // => 'value'
Cookies.get() // => { name: 'value' }
// remove
Cookies.remove('name')

OK 我们大概可以知道是这样子

set(key, value)
get(key)
remove(key)

简洁方便多了,并且一眼就知道这行代码是在干什么~


2. 读源码三部曲😎

这段可能有点太细了,如果嫌啰嗦,只想看实现可以直接跳到下面的实现部分~

一 README

why

一个简单、轻量级的JavaScript API,用于处理cookie
适用于所有浏览器
⭐接受任何字符
大量的测试
⭐不依赖
支持ES模块
支持AMD / CommonJS
RFC 6265兼容的
有用的 Wiki
⭐启用自定义编码/解码
< 800字节gzip !

优点多多呀

⭐表示后文会详细提及~

Basic Usage

大概就是前面写过的API介绍

二 package.json

依赖

确实是很少依赖,并且只有开发依赖,没有生产依赖,很nice~

scripts

"scripts": {
    "test": "grunt test",
    "format": "grunt exec:format",
    "dist": "rm -rf dist/* && rollup -c",
    "release": "release-it"
  },

exports

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

看来入口在/dist/js.cookie
这点从index.js也能看出

module.exports = require('./dist/js.cookie')

当然,目前是没有dist这个目录的。这需要打包~


.mjs

另外我们刚才看到了.mjs这个后缀,这我还是第一次见,你呢

  • .mjs:表示当前文件用 ESM 的方式进行加载
  • .js :采用 CJS 的方式加载。

ESM 和 CJS

ESM是将 javascript 程序拆分成多个单独模块,并能按需导入的标准。和webpackbabel不同的是,esm javascript 标准功能,在浏览器端和 nodejs 中都已得到实现。也就是熟悉的importexport
CJS也就是 commonJS,也就是module.exportsrequire

更多介绍以及差别不再赘述~

三 src

进入src,首当其冲的就是 api.mjs ,这一眼就是关键文件啊🐶
image.png
emm..一个init方法,其中包含setget方法,返回一个Object
image.png
remove方法藏在其中~
乍一看,代码当然还是能看得懂每行都是在做啥的呀~ 但是总所周知‍🐶

开源项目也是不断迭代出来的~也不是一蹴而就的 —— 若川哥

okok,我们来一步步"抄"一下源码

3. 实现🚀

下面为了传参返回值更加清晰 用了TS语法~

3.1 最简易版本

set

设置一个键值对,要这样

document.cookie = `${key}=${value}; expires=${expires}; path=${path}`

除了键值对还有后面的属性~可别把它忘记了
我们用写一个接口限制一下传入的属性:

interface Attributes {
  path: string; //可访问cookie的路径,默认为根目录
  domain?: string; //可访问 cookie 的域
  expires?: string | number | Date // 过期时间:UTC时间戳string || 过期天数
  [`max-age`]?:number //ookie 的过期时间距离当前时间的秒数
  //...
}
const TWENTY_FOUR_HOURS = 864e5 //24h的毫秒数
//源码中是init的时候传入defaultAttributes,这里先暂做模拟
const defaultAttributes: Attributes = {path: '/'}

function set(key: string, value: string, attributes: Attributes): string | null {
  attributes = {...defaultAttributes, ...attributes} //
  if (attributes.expires) {//如果有过期时间
    // 如果是数字形式的,就将过期天数转为 UTC string
    if (typeof attributes.expires === 'number') {
      attributes.expires = new Date(Date.now() + attributes.expires * TWENTY_FOUR_HOURS)
      attributes.expires = attributes.expires.toUTCString()
    }
  }
  
  //遍历属性键值对并转换为字符串形式
  const attrStr = Object.entries(attributes).reduce((prevStr, attrPair) => {
    const [attrKey, attrValue] = attrPair

    if (!attrValue) return prevStr
    //将key拼接进去
    prevStr += `; ${attrKey}`

    // attrValue 有可能为 truthy,所以要排除 true 值的情况
    if (attrValue === true) return prevStr

    // 排除 attrValue 存在 ";" 号的情况
    prevStr += `=${attrValue.split('; ')[0]}`

    return prevStr
  }, '')

  return document.cookie = `${key}=${value}${attrStr}`
}

get

document.cookie = "user=John; path=/; expires=Tue, 19 Jan 2038 03:14:07 GMT"

我们知道document.cookie长这个样子,那么就根据对应规则操作其字符串获得键值对将其转化为Object

function get(key: string): string | null {
  //根据'; '用split得到cookie中各个键值对
  const cookiePairs = document.cookie ? document.cookie.split('; ') : []
  // 用于存储 cookie 的对象
  const cookieStore: Record<string, string> = {} //*

  cookiePairs.some(pair => { //遍历每个键值对
    //对每个键值对通过'='分离,并且解构赋值得到key和values——第一个'='后的都是values,
    const [curtKey, ...curtValues] = pair.split('=')

    cookieStore[curtKey] = curtValues.join('=') // 有可能 value 存在 '='

    return curtKey === key // 找到要查找的key就 break
  })
  //返回对应的value
  return key ? cookieStore[key] : null
}

要注意的有意思的一个点是,可能 value 中就有'='这个字符,所以还要特殊处理一下~

比如他就是 "颜文字= =_="😝 (~~应该不会有人真往cookie里面放表情吧hh ~~ 但是value 中有'='还是真的有可能滴~ 其实一开始我真没想过这个问题,是看了源码才知道的

image.png

Record

接收两个参数——keys、values,使得对象中的key、value必须在keys、values里面。

remove

remove就简单啦,用set把过期时间设置为过去就好了~

function remove(key) {
  set(key, "", {
    'expires': -1
  })
}

3.2 接受任何字符

从技术上讲,cookie 的名称和值可以是任何字符。为了保持有效的格式,它们应该使用内建的 encodeURIComponent 函数对其进行转义~ 再使用 ecodeURIComponent 函数对其进行解码。
还记得 README 中写的接收任何字符吗~ 这就需要我们自己来在里面进行编码、解码的封装~

set

function set(key: string, value: string, attributes: Attributes): string | null {
  //...
  //编码
  value = encodeURIComponent(value)
  //...
}

get

function get(key: string): string | null {
  //...
  //解码
  const decodeedValue = decodeURIComponent(curtValue.join('=')) 
  cookieStore[curtKey] = decodeedValue
  //...
}

3.3 封装编码和解码两个操作

源码中 converter.mjs 封装了这两个操作为writeread,并作为defaultConverter导出到 api.mjs,最后作为converter传入init——降低了代码的耦合性,为后面的自定义配置做了铺垫~
前面编码解码变成了这样:

//set中编码
value = converter.write(value, name) 
//get中解码
const decodeedValue = converter.read(curtValue.join('='))

3.4 启用自定义编码/解码

我们是具有内置的 encodeURIComponent decodeURIComponent,但是也并不是必须使用这两个来进行编码和解码,也可以用别的方法——也就是前面 README 中说的 可以自定义编码/解码~
除了这两个方法可自定义,其余的属性也可以自定义默认值,并且配置一次后,后续不用每次都传入配置——所以我们需要导出时有对应的两个方法

function withAttributes(myAttributes: Attribute) {
  customAttributes = {...customAttributes, ...myAttributes}
}
function withConverter(myConverter: Converter) {
  customConverter = {...customConverter, ...myConverter}
}

封装在其中,利用对象合并时有重复属性名的情况是后面的覆盖掉前面的这一特性完成该自定义配置属性以及转换方法的功能。
现在的 cookie 大概是这样的一个对象

const Cookies = {
  get,
  set,
  remove,
  withAttributes,
  withConverter
}

3.5 防止全局污染

现在的 cookie 直接在全局上下文下,很危险,谁都能更改,而且还不一定能找到,我们将其设置为局部的,封装到init函数中,调用init传入相应的 自定义属性以及自定义转换方法得到一个初始化的cookie对象
现在大概就是源码的架构形状了~

function init(initConverter: Converter, initAttributes: Attributes) {
  //set
  //get
  //remove
  //withAttributes
  //withConverter
  

  return {
    set,
    get, 
    remove, 
    attributes: initAttributes,
    converter: initConverter, 
    withAttributes,
    withConverter
  }
}
//调用init得到对象后导出
export default init(defaultConverter, defaultAttributes)

3.6 确保一些属性不会给改变

Object.create 来生成对象,并用 Object.freeze 把对象 atributes converter冻结。

/* eslint-disable no-var */
import assign from './assign.mjs'
import defaultConverter from './converter.mjs'

function init (converter, defaultAttributes) {
  //...方法定义
  return Object.create(
    {
      //...属性
    },
    {
      //将这些属性的value冻结
      attributes: { value: Object.freeze(defaultAttributes) },
      converter: { value: Object.freeze(converter) }
    }
  )
}

export default init(defaultConverter, { path: '/' })
/* eslint-enable no-var */

现在你就不能修改 Cookie 的attributesconverter属性了~

4. 总结 & 收获⛵

总结init及其中属性&返回

image.png
而用init函数生成对象是为了解决全局污染问题,并且更新对象时也是用的init


现在你再回头看源码是不是就更加清晰了~

扩展

说到 cookie 这个在浏览器中存储数据的小东西,就不得不提一下localstoragesessionStorage

cookie、localstorage、sessionStorage 的区别

Web 存储对象 localStoragesessionStorage 也允许我们在浏览器上保存键/值对。

那他们的区别呢

  • 在页面刷新后(对于 sessionStorage)甚至浏览器完全重启(对于 localStorage)后,数据仍然保留在浏览器中。默认情况下 cookie 如果没有设置expiresmax-age,在关闭浏览器后就会消失
  • 与 cookie 不同,Web 存储对象不会随每个请求被发送到服务器, 存储在本地的数据可以直接获取。因此,我们可以保存更多数据,减少了客户端和服务器端的交互,节省了网络流量。大多数浏览器都允许保存至少 2MB 的数据(或更多),并且具有用于配置数据的设置。
  • 还有一点和 cookie 不同,服务器无法通过 HTTP header 操纵存储对象。一切都是在 JavaScript 中完成的。
  • 以及..他们的原生 API cookie的"好看"太多~ [doge]
CookiesessionStoragelocalstorage
生命周期默认到浏览器关闭,可自定义浏览器关闭除非自行删除或清除缓存,否则一直存在
与服务器通信http 头中不参与服务器通信不参与服务器通信
易用性丑陋的 API,一般自己封装直接使用原生 API直接使用原生 API

🌊如果有所帮助,欢迎点赞关注,一起进步⛵

如果你觉得这种一步步带你"抄"源码形式的文章不错,欢迎阅读手写 delay,还要有这些功能哦~

5. 学习资源