【若川视野 x 源码共读】第17期 | js-cookie

852 阅读6分钟

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

1.学习准备工作

1.1学习资料

https://github1s.com/js-cookie/js-cookie/blob/HEAD/src/api.mjs
https://github.com/js-cookie/js-cookie
https://github.com/haixiangyan/my-js-cookie

1.2 环境准备

clone 代码:https://github.com/js-cookie/js-cookie.git
安装依赖: npm i (好几次才成功)

2.为什么需要封装的cookie包

通过浏览器的开发者工具看到cookie的存储视图是非常清晰的:

但是实际当我们查看document.cookie的时候,发现其实cookie是使用字符串存储的:

并且js原生cookie的api是非常简陋的,例如创建,修改,删除要这样来操作:

// 创建
document.cookie="username=John Doe";
// 修改
document.cookie="username=John Smith; expires=Thu, 18 Dec 2043 12:00:00 GMT; path=/";
// 删除 删除 cookie 非常简单。您只需要设置 expires 参数为以前的时间即可
document.cookie = "username=; expires=Thu, 01 Jan 1970 00:00:00 GMT";

更多关于原生js cookie内容可以参看相关资料。通过以上内容我们看到当我们要修改cookie值的时候或者删除cookie的时候要进行复杂的字符串或者数组操作,如果能够把这些过程封装起来就很方便开发了。

3.了解js-cookie的使用方式

js-cookie的readme文档对齐介绍如下:

一个用于处理cookie的简单、轻量级JavaScript API:

适用于所有浏览器
接受任何字符
严峻考验
无依赖
支持ES模块
支持AMD/CommonJS
符合RFC6265标准
有用的维基
允许自定义编码/解码
<800字节gzip!

看完这个介绍,感觉太美好了,兼容性好,没有其他依赖,对js各种模块化方案都支持,体积还小,爱了。

下面看一下基本的使用:

import Cookies from 'js-cookie'

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

4.猜测js-cookie的实现方法

1.首先,cookie项目彼此之间是使用分号(;)分隔的, 内部是使用等号(=) 分隔的。所以要用到split('; ')获取每一项,要用到split("=") 获取每一项的键和值。

2.其次,设置值的时候可以指定过期时间,所以这里要有对时间的处理逻辑

3.最后,由于允许接收任何字符和允许自定义的编码和解码,所以实现的时候要考虑到对特殊字符的处理,可能用到正则表达式;可能会用到编码和解码的api。

5.阅读代码

5.1 paclage.json文件

打开js-cookie的源码,最关心的文件不外乎是package.json 和 src目录下的index.js文件。

首先浏览一下package.json文件,值得关注的是scripts有哪些,这个包都用了什么依赖:

{
	 "scripts": {
     "test": "grunt test",
     "format": "grunt exec:format",
      "dist": "rm -rf dist/* && rollup -c",
      "release": "release-it"
   },
   "devDependencies": {
      "browserstack-runner": "^0.9.0",
      "eslint": "^7.31.0",
      "eslint-config-standard": "^16.0.3",
      "eslint-plugin-promise": "^5.1.0",
      "eslint-plugin-html": "^6.0.0",
      "eslint-plugin-markdown": "^2.2.0",
      "grunt": "^1.0.4",
      "grunt-compare-size": "^0.4.2",
      "grunt-contrib-connect": "^3.0.0",
      "grunt-contrib-nodeunit": "^4.0.0",
      "grunt-contrib-qunit": "^3.1.0",
      "grunt-contrib-watch": "^1.1.0",
      "grunt-exec": "^3.0.0",
      "gzip-js": "^0.3.2",
      "prettier": "^2.3.2",
      "qunit": "^2.9.3",
      "release-it": "^14.10.0",
      "rollup": "^2.0.0",
      "rollup-plugin-filesize": "^9.1.1",
      "rollup-plugin-license": "^2.5.0",
      "rollup-plugin-terser": "^7.0.2",
      "standard": "^16.0.3"
  }
}

确实像readme中所说的,js-cookie没有使用额外的依赖,这里只有devDependencies而没有dependencies。另外看到scripts中的命令使用了grunt和rollup,还有release-it, 感觉他们也是值得研究的。

5.2 index.js文件

再看看index.js :

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

就一句话啊,引入打包后的dist目录下的js.cookie.js文件,而现在我们还没有dist这个目录,一会儿要打包才能生成。先看src下面的源码吧~

5.3 src下面的文件

5.3.1 .mjs文件介绍

映入眼帘的是三个文件,但是文件后缀我木有见过啊~ mjs 后缀文件是什么?

.mjs 就是表示当前文件用 ESM 的方式进行加载,如果是普通的 .js 文件,则采用 CJS 的方式加载。

详细参见:

ES6 模块化的时代真的来临了么?Using MJS

5.3.2 init方法概览

简单浏览3个文件,发现api.js里面定义了核心逻辑并且应用了另外两个文件,这另外两个文件属于提供了工具方法。

如上图所示api.mjs引入了另外两个文件,主要定义了一个init方法,并且默认导出调用了init方法的调用结果。

init方法接收两个参数,一个转换器,一个默认的属性对象。

下面看一下init方法大概的内部实现:

在init方法内部,定义了set,get方法,并返回了一个使用Object.create方法创建的对象,我们逐一看一下~

5.3.3 set方法

function set (name, value, attributes) {
    // 当前环境不存在document则返回
    if (typeof document === 'undefined') {
      return
    }
    // 合并attributes (init方法的defaultAttributes和set方法的attributes)
    attributes = assign({}, defaultAttributes, attributes)
    // 判断过期时间是不是number类型
    if (typeof attributes.expires === 'number') {
      // 过期时间转成毫秒值
      attributes.expires = new Date(Date.now() + attributes.expires * 864e5)
    }
    if (attributes.expires) {
      // 转成UTC格式
      attributes.expires = attributes.expires.toUTCString()
    }
    // cookie项名字的处理
    name = encodeURIComponent(name)
      .replace(/%(2[346B]|5E|60|7C)/g, decodeURIComponent)
      .replace(/[()]/g, escape)

    // 字符串化的属性
    var stringifiedAttributes = ''
    // 依次检查属性
    for (var attributeName in attributes) {
      // 如果属性值为假值则进入下一次检查
      if (!attributes[attributeName]) {
        continue
      }
      // 拼接属性名
      stringifiedAttributes += '; ' + attributeName

      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.
      // ...
      // 拼接属性值
      stringifiedAttributes += '=' + attributes[attributeName].split(';')[0]
    }
    // 给document.cookie赋值
    return (document.cookie =
      name + '=' + converter.write(value, name) + stringifiedAttributes)
  }

特殊说明:

1.时间计算说明:1天24小时,1小时60分钟,1分钟60秒:

246060 = 86400

再转成毫秒*1000:

最终结果就是864*10^5 科学表示就是864e5

2.转成UTCString的原因:www.cnblogs.com/ajaemp/p/14…

3.replace函数:

stringObject.replace(regexp/substr,replacement)

字符串 stringObject 的 replace() 方法执行的是查找并替换的操作。它将在 stringObject 中查找与 regexp 相匹配的子字符串,然后用 replacement 来替换这些子串。如果 regexp 具有全局标志 g,那么 replace() 方法将替换所有匹配的子串。否则,它只替换第一个匹配子串。更多信息可以参考文档

5.3.4 get方法

function get (name) {
    // 没有document或者参数列表为空并且没传name,则返回
    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.
    // 是否有cookie,有则使用;分隔,没有则赋值为空数组
    var cookies = document.cookie ? document.cookie.split('; ') : []
    // jar存储键值对形式的数据
    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])
        jar[found] = converter.read(value, found)

        if (name === found) {
          break
        }
      } catch (e) {}
    }
    // 	传name返回某一项,没传则返回所有的
    return name ? jar[name] : jar
  }

5.3.5 return 的逻辑

return Object.create(
    {
      set: set,
      get: 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) }
    }
  )

这里使用Object.create方法创建了一个对象,这个方法接收两个参数:第一个为新创建对象的原型对象;第二个为自身定义的属性。

在原型对象上定义了set、get、remove,还有withAttributes和withConverter。remove方法的实现是通过调用set方法设置过期时间来实现的;withAttributes和withConverter则把自己的属性和传入的参数进行合并。

5.3.6 assign方法

只定义了一个参数,剩下的都用argumets来接收,后面的覆盖前面的

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
}

5.3.7 defaultConverter

导出了一个具有read和write方法的对象,主要是调用了decodeURIComponent和encodeURIComponent

export default {
  read: function (value) {
    if (value[0] === '"') {
      value = value.slice(1, -1)
    }
    return value.replace(/(%[\dA-F]{2})+/gi, decodeURIComponent)
  },
  write: function (value) {
    return encodeURIComponent(value).replace(
      /%(2[346BF]|3[AC-F]|40|5[BDE]|60|7[BCD])/g,
      decodeURIComponent
    )
  }
}

6.调试验证

6.1 打包编译代码

运行npm run dist命令打包编译代码:

报错,windows系统不认识rm -rf命令, 含义就是如果有dist目录则先删除了,然后重新打包生成dist目录

解决办法 :删除前面的 rm -rf dist/* && 只保留 rollup -c, 重新运行打包命令,可以打包成功

看一下rollup.config.js:

import { terser } from 'rollup-plugin-terser'
import filesize from 'rollup-plugin-filesize'
import license from 'rollup-plugin-license'
import pkg from './package.json'

const licenseBanner = license({
  banner: {
    content: '/*! <%= pkg.name %> v<%= pkg.version %> | <%= pkg.license %> */',
    commentStyle: 'none'
  }
})

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
      }
    ],
    plugins: [
      terser(),
      licenseBanner, // must be applied after terser, otherwise it's being stripped away...
      filesize()
    ]
  }
]

引入插件:

1.rollup-plugin-filesize :用于显示cli中的文件大小

2.rollup-plugin-terser: 压缩js代码,包括es6代码压缩

3.rollup-plugin-license:将许可证横幅添加到最终捆绑包并输出第三方许可证

引入package.json文件

import pkg from './package.json'

pkg.module: "module": "dist/js.cookie.mjs"

pkg.browser: "browser": "dist/js.cookie.js"

导出的配置中分别定义了打包时不压缩和压缩的两种配置。

更多关于rollup的内容参见文档介绍和网上的文章

6.2 测试

有了上一步打包好的文件之后可以测试一下,源码中的测试逻辑我不是很懂,我是新建了一个文件夹,把打包好的js.cookie.js文件copy过来,有写了一个测试文件,内容如下:

console.log('Cookies',Cookies)
Cookies.set('foo', 'bar')
Cookies.get('foo')
Cookies.set('name', 'value', { expires: 7, path: '' })
Cookies.get("name")

然后把测试文件和js.cookie.js都引入到一个html文件中,浏览器运行这个html文件:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script src="js.cookie.js"></script>
  <script src="test.js"></script>
</head>
<body>
  
</body>
</html>

运行效果如下:

7.疑问以及收获和总结

7.1 疑问

set方法中:

return (document.cookie =
        name + '=' + converter.write(value, name) + stringifiedAttributes)

这是不是每一个都重新给document.cookie赋值呢,那么原来写进去的值是不是没了啊?按照我那种方法调试,在调get的时候也没有获取到值,这块不是很清楚耶~

7.2 收获

1.了解到js-cookie的实现原理

2.简单地了解rollup的基础知识,按照rollup中文文档中的教程章节创建了一个项目,学习了一下

3.了解 依赖包 release-it 的用途

4.简单了解grunt的基础知识,并按照文档资料尝试一下

学完本期源码,您可以思考如下问题:

1.前端缓存技术都有哪些?

2.为什么要使用cookie, cookie有什么作用和优缺点?

3.cookie和session有什么区别和联系?

4.什么是localStorage?和sessionStorage之间有什么区别?

5.为什么需要对浏览器提供的cookie做进一步的封装?

6.js-cookie有什么特点?

7.谷歌已宣布逐步淘汰三方Cookie,对于使用第三方Cookie的项目该如何兼容?