使用localstorage代替cookie实现跨域共享数据

4,539 阅读12分钟
原文链接: zhuanlan.zhihu.com

一,背景

因为网站系统的日益庞大,不同域名业务,甚至不同合作方网站的cookie可能或多或少需要进行共享使用,遇到这个情况的时候,大家一般想到的是使用登录中心分发cookie状态再进行同步进行解决,成本较高而且实施起来比较复杂和麻烦。

因为cookie在跨域的情况下,浏览器根本不允许互相访问的限制,为了突破这个限制,所以有了以下这个实现方案,使用postmessage和localstorage进行数据跨域共享。

原理比较简单,但是遇到的坑也不少,这里梳理一下,做个备份。

二,API设计

背景中说过我们使用localstorage来代替cookie,本身localstorage和cookie就有一些使用上的区别,比如localstorage的容量更大,但是不存在过期时间,虽然容量大,但在不同的浏览器上也都有空间上限,操作不好很容易崩溃,还有就是postmessage虽然支持跨域,安全问题和api的异步化也给使用带来了一些麻烦,我们如何把这个模块设计的更易用呢?

先看下我设计的API:

import { crosData } from 'base-tools-crossDomainData';
var store = new crosData({
    iframeUrl:"somefile.html", //共享iframe地址,iframe有特殊要求,详见模板文件    
    expire:'d,h,s' //单位天,小时,秒 默认过期时间,也可以种的时候覆盖
});

store.set('key','val',{
    expire:'d,h,s' //option 可带过期时间,覆盖expire
}).then((data)=>{
    //异步方法,如果种失败,会进入catch事件
    //data {val:'val',key:'key',domain:'domain'};
}).catch((err)=>{
    console.log(err);
}); 

store.get('key',{
    domain:'(.*).sina.cn' //可以指定域名,也可以使用(.*)来匹配正则字符串,返回的val信息会带着domain信息,不填写则返回本域的
}).then((vals)=>{
    console.log(val) //异步获取存储数据,可能多个,是个数组 [{},{}]
}).catch((err)=>{

});

store.clear('key').then().catch(); //只清楚当前域下的key,不允许清除其他域下的key,只能读

一个模块上手快不快主要看api,所以对于一个数据共享模块,我认为支持set,get,clear这3个方法就ok了,因为postmessage本身是个一来一回的异步的行为,包装成promise的肯定更为合适和易用。因为localstorage不支持过期时间,所以需要一个全局的过期时间配置,当然也可以在set的时候进行单独配置,而get的时候我们可以指定获取某个域下的数据或者多个域下的数据,因为key名可能重复,但是域只有一个。这里就牵扯到了数据的管理,后边单独来说,最后clear和set的api只能种本域的数据,不可以操作其他域下的数据,get被允许。

下面我们看一下,client端的设置和API:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>crosData</title> 
    </head>
    <body>
        <script>
            window.CROS = {
                domain:/(.*).sina.cn/, //或者你允许的域名,支持正则和*通配符
                lz:false //是否开启lz压缩val字符
            };
        </script>
        <script src="http://cdn/sdk.js"></script>
    </body>
</html>

你可以灵活在任何一个域下的一个html文档中,引入client的js sdk,然后通过全局属性的方式配置一个你允许被种到这个文档所在域下的domain白名单,支持正则,然后lz是是否启动lz-string压缩,至于什么是lz压缩后边我再介绍。

到这里,一个比较通用的API设计就完成了,下面我们看一下实现原理和具体的一些问题。

三,实现原理

说起来好想蛮简单的,但是写起来其实并不是,我们首先需要知道postMessage怎么用,这个属于很常见的一个API了,他有一个要点这里告诉大家,就是postMessage只能在iframe中或者使用window.open这种开启新页面的方式进行互相通讯,当然这里我们首先就要创建一个隐藏的iframe,进行跨域。

懒得拿工具画图了,因为流程比较清晰,这里拿文字复述一下整个通讯流程,首先父页面创建一个隐藏的iframe,然后当执行set,get,clear等command的时候,通过postMessage来进行消息广播,子页面接收到消息后,解析命令,数据和回调id(postMessage无法传递函数和引用,兼容问题导致,最好只传string类型,所以还需要对data做stringify)。然后当子页面处理完localstorage的操作后,再通过postMessage把对应的cbid和data返回给父页面,父页面监听message事件,处理结果。

四,编码

嗯,所以说没几行,我们下面开始进行编码了:

首先介绍一下我们用到的第三方包都有什么,为什么要用:

1,url-parse 对url进行parse解析,主要用他里面的origin属性,因为postMessage本身对origin就有严格的验证,我们要支持白名单和域名管理也需要。

2,ms 对时间简写做毫秒转换的工具库。

3,lz-string 对字符串做压缩用的工具包,这里给大家科普一下LZ压缩算法,首先了解LZ需要先了解RLZ,Run Length Encoding ,是一个针对无损压缩的非常简单的算法。它用重复字节和重复的次数来简单描述来代替重复的字节。LZ 压缩算法的背后是使用 RLE 算法用先前出现的相同字节序列的引用来替代。简单的讲, LZ 算法被认为是字符串匹配的算法。例如:在一段文本中某字符串经常出现,并且可以通过前面文本中出现的字符串指针来表示。

lz-string本身有优势就是可以大大的减小你的储存量,本身5MB的localstorage如果用来支持多域名的数据保存,很快就会被压缩用完,但是lz-string本身比较慢,消耗比较大,大家平时在工作中如果对传输数据量有大小要求的话可以尝试使用这个压缩算法来优化字符串长度,默认是不开启的。

4,store2 本身localstorage的api比较简陋,为了减少代码逻辑复杂度,这里选了一个比较流行的localstorage的实现库来进行store的操作。

说完了第三方包我们下面看一下父页面的js怎么来写:

class crosData {
  constructor(options) {
    supportCheck();
    this.options = Object.assign({
      iframeUrl: '',
      expire: '30d'
    }, options);
    this.cid = 0;
    this.cbs = {};
    this.iframeBeforeFuns = [];
    this.parent = window;
    this.origin = new url(this.options.iframeUrl).origin;
    this.createIframe(this.options.iframeUrl);
    addEvent(this.parent, 'message', (evt) => {
      var data = JSON.parse(evt.data);
      var origin = evt.origin || evt.originalEvent.origin;
      //我只接收我打开的这个iframe的message,其他的都是不合法的,直接报错
      if (origin !== this.origin) {
        reject('illegal origin!');
        return;
      }
      if (data.err) {
        this.cbs[data.cbid].reject(data.err);
      } else {
        this.cbs[data.cbid].resolve(data.ret);
      }
      delete this.cbs[data.cbid];
    });
  }
  createIframe(url) {
    addEvent(document, 'domready', () => {
      var frame = document.createElement('iframe');
      frame.style.cssText = 'width:1px;height:1px;border:0;position:absolute;left:-9999px;top:-9999px;';
      frame.setAttribute('src', url);
      frame.onload = () => {
        this.child = frame.contentWindow;
        this.iframeBeforeFuns.forEach(item => item());
      }
      document.body.appendChild(frame);
    });
  }
  postHandle(type, args) {
    return new Promise((resolve, reject) => {
      var cbid = this.cid;
      var message = {
        cbid: cbid,
        origin: new url(location.href).origin,
        action: type,
        args: args
      }
      this.child.postMessage(JSON.stringify(message), this.origin);
      this.cbs[cbid] = {
        resolve,
        reject
      }
      this.cid++;
    });
  }
  send(type, args) {
    return new Promise(resolve => {
      if (this.child) {
        return this.postHandle(type, args).then(resolve);
      } else {
        var self = this;
        this.iframeBeforeFuns.push(function() {
          self.postHandle(type, args).then(resolve);
        });
      }
    })
  }
  set(key, val, options) {
    options = Object.assign({
      expire: ms(this.options.expire)
    }, options);
    return this.send('set', [key, val, options]);
  }
  get(key, options) {
    options = Object.assign({
      domain: new url(location.href).origin
    }, options);
    return this.send('get', [key, options]);
  }
  clear(key) {
    return this.send('clear', [key]);
  }
}

大概方法就这么几个,这里有几个关键点,我说一下。

1,get,set,clear方法都是统一的调用的send方法,只不过对options部分做了补齐。

2,send方法返回一个promise对象,如果iframe已经onload成功,则直接调用postHandle方法进行postMessage操作,如果iframe还在加载中,则把当前的操作推到iframeBeforeFuns数组中,用函数包裹,等待iframe onload结束后统一调用,函数包裹的也是postHandle方法。

3,postHandle方法,在发送请求前包装data,生成cbid,origin,action和args,cbs对象保存了每个cbid下的resolve和reject,等待子页面的postMessage返回后处理。因为postMessage不能保留引用,不能传函数,所以这里选择这个方法来进行关联。

4,constructor比较好理解,当这个类被初始化的时候,我们定义了我们需要的一些options的属性,创建iframe,然后监听message事件,处理子页面返回的消息。

5,在父页面的message事件中,我们要校验,给我发消息的必须是我打开的这个窗口iframe,否则报错,然后根据data中的err标识来让cbs中的resolve和reject进行执行。

6,createIframe方法中,iframe onload中的回调处理创建前 缓存的调用方法,这里注意使用了domready,因为可能body还没解析就会进行sdk的执行。

下面是child部分的代码:

class iframe {
  set(key, val, options, origin) {
    //检查val大小,不能超过20k.
    val = val.toString();
    val = this.lz ? lzstring.compressToUTF16(val) : val;
    var valsize = sizeof(val, 'utf16'); //localStorage 储存使用utf16编码计算字节
    if (valsize > this.maxsize) {
      return {
        err: 'your store value : "' + valstr + '" size is ' + valsize + 'b, maxsize :' + this.maxsize + 'b , use utf16'
      }
    }
    key = `${this.prefix}_${key},${new url(origin).origin}`;
    var data = {
      val: val,
      lasttime: Date.now(),
      expire: Date.now() + options.expire
    };
    store.set(key, data);
    //大于最大储存个数,删除最后一次更新的
    if (store.size() > this.storemax) {
      var keys = store.keys();
      keys = keys.sort((a, b) => {
        var item1 = store.get(a),
          item2 = store.get(b);
        return item2.lasttime - item1.lasttime;
      });
      var removesize = Math.abs(this.storemax - store.size());
      while (removesize) {
        store.remove(keys.pop());
        removesize--;
      }
    }
    return {
      ret: data
    }
  }
  get(key, options) {
    var message = {};
    var keys = store.keys();
    var regexp = new RegExp('^' + this.prefix + '_' + key + ',' + options.domain + '$');
    message.ret = keys.filter((key) => {
      return regexp.test(key);
    }).map((storeKey) => {
      var data = store.get(storeKey);
      data.key = key;
      data.domain = storeKey.split(',')[1];
      if (data.expire < Date.now()) {
        store.remove(storeKey);
        return undefined;
      } else {
        //更新lasttime;
        store.set(storeKey, {
          val: data.val,
          lasttime: Date.now(),
          expire: data.expire
        });
      }
      data.val = this.lz ? lzstring.decompressFromUTF16(data.val) : data.val;
      return data;
    }).filter(item => {
      return !!item; //过滤undefined
    });
    return message;
  }
  clear(key, origin) {
    store.remove(`${this.prefix}_${key},${origin}`);
    return {};
  }
  clearOtherKey() {
    //删除不合法的key	
    var keys = store.keys();
    var keyReg = new RegExp('^' + this.prefix);
    keys.forEach(key => {
      if (!keyReg.test(key)) {
        store.remove(key);
      }
    });
  }
  constructor(safeDomain, lz) {
    supportCheck();
    this.safeDomain = safeDomain || /.*/;
    this.prefix = '_cros';
    this.clearOtherKey();
    if (Object.prototype.toString.call(this.safeDomain) !== '[object RegExp]') {
      throw new Error('safeDomain must be regexp');
    }
    this.lz = lz;
    this.storemax = 100;
    this.maxsize = 20 * 1024; //字节
    addEvent(window, 'message', (evt) => {
      var data = JSON.parse(evt.data);
      var originHostName = new url(evt.origin).hostname;
      var origin = evt.origin,
        action = data.action,
        cbid = data.cbid,
        args = data.args;
      //合法的广播
      if (evt.origin === data.origin && this.safeDomain.test(originHostName)) {
        args.push(origin);
        var whiteAction = ['set', 'get', 'clear'];
        if (whiteAction.indexOf(action) > -1) {
          var message = this[action].apply(this, args);
          message.cbid = cbid;
          window.top.postMessage(JSON.stringify(message), origin);
        }
      } else {
        window.top.postMessage(JSON.stringify({
          cbid: cbid,
          err: 'Illegal domain'
        }), origin);
      }
    });
  }
}

代码也不多,这里简单说一下各个方法的用处和组织关系:

1,constructor部分,上面的类里也进行浏览器特性支持检查,然后定义了store的prefix值,最大个数和每一个key的maxsize等属性。然后我们创建message通道,等待父页面调用。

2,在message中,我们对发送广播的origin进行检查,然后对调用的方法进行检查,调用对应的set,get,clear方法,然后把执行的结果拿到,绑定cbid,最后再postMessage发送回父页面。

3,clearOtherKey 删除不合法的一些store数据,只保留符合格式的数据。

4,set方法中对每一条的数据做size校验,lz压缩,保存的data中包含了val,key,过期时间以及更新时间(用于LRU计算)。

5,set方法中,如果储存的ls个数超过了最大限制,这个时候需要进行删除操作, LRU是Least Recently Used的缩写,即最近最少使用。我们通过遍历所有的key值,对key值做一个排序,通过lasttime,然后进行keys数组的pop操作,拿到堆栈尾部的需要被清除的key,然后逐个删除。

6,get方法中,我们通过遍历所有的key值,匹配到我们需要拿到的domain的域的key,然后把返回值中的key进行拆解(我们储存时是 key,domain的格式),因为api要求返回多个符合的值,我们对过期的数据最后再做一个filter,然后使用lz解压缩val值,保证用户拿到的是正确结果。

以上就是我们的一个整体实现编码过程和review,下面说一说遇到的坑。

五,一些遇到的坑

因为上面只给了主代码,并不是完整代码,因为本身逻辑比较清晰,花一点时间都可以写出来的。下面说说有什么坑的地方。

1,计算localstorage的储存值。

因为我们都知道有5MB的限制,所以每一条数据最大要求不能超过20*1024 字节,对于字节的计算,localstorage要使用utf16的编码进行转换,参考这篇文章:JS计算字符串所占字节数 | AlloyTeam

2,兼容性

ie8下postMessage最好都传字符串,事件需要抹平处理,JSON需要抹平处理。

3,创建iframe时的异步处理

这里之前做了个一个setTimeout的递归等待,后来更改成了上面的实现方法,通过onload后统一处理promise的reslove,保证promise api的统一。

4,数据保存时,空间复杂度 vs 时间复杂度。

第一个版本并不是上面的实现,我实现了3个版本:

第一个版本是保存了一个LRU的数组,为了减少时间复杂度,但是浪费了空间复杂度,而且经过测试,store的get方法耗时比较大,主要是parse的耗时。

第二个版本,为了能让lz-string压缩率最大化,我把所有的数据包括LRU数组保存到了一个key值上,导致数据多的时候lz-string和getItem,parse时间消耗非常大,虽然计算的时间复杂度是最低。

最后一个版本,就是上面的,我牺牲了一些时间复杂度和空间复杂度,但是因为瓶颈在于set和get的读写速度,单个的保存读写速度极快,获取keys的方法因为底层是用的for in localstorage实现的,性能还是很不错的,20kb存满100条,读写也在1s左右,性能非常不错。

六,总结和对比

模块写完了,我才知道原来还有这么一个库:zendesk/cross-storage

但是我查看了他的api和源代码,对比了一下实现方法,我觉得还是我这个版本考虑的比较多。

1,我的版本对域名和数据的管理有控制。

2,我的版本promise api更简化,比它少一个onConnect,可以参考他的实现,比我写的多多了,也没解决这个iframe等待异步的问题。

3,不支持lz压缩数据。

4,不支持LRU的储存池管理,所以可能存多了造成写不进的问题。

5,他貌似每次交互都搞一个iframe,太浪费dom操作和广播了,我觉得一直开着并没有什么问题,当然他可能有需求连接多个client才这么处理的。

ok,希望以上内容对大家有所帮助。