localStorage跨域存储,实战篇

17,264 阅读4分钟

先简单介绍两个概念:

(1)localStorage: 类似 sessionStorage,但其区别在于:存储在 localStorage 的数据可以长期保留;当页面被关闭时,存储在 sessionStorage 的数据会被清除。

(2)同源策略(same-origin policy):是浏览器执行的一种安全措施,目的是为了保证用户信息的安全,防止恶意的网站窃取数据。相关定义请参考developer.mozilla.org/zh-CN/docs/…

应用场景

一般用于业务域名众多,但是需要共享本地缓存数据。举个例子🌰,比如我用Chrome浏览天猫网站,在搜索栏搜索‘云安全’,过几天我用Chrome进了淘宝网,点击搜索栏想要获取到‘云安全’这个搜索记录,这个时候就需要用到localStroage的跨域存储(当然,也可以将搜索记录存储在服务端,这也是另一种解决办法)

实现原理

  • 主要是通过postMessage来实现跨源通信
  • 可以实现一个公共的iframe部署在某个域名中,作为共享域
  • 将需要实现localStorage跨域通信的页面嵌入这个iframe
  • 接入对应的SDK操作共享域,从而实现localStorage的跨域存储

具体实现

(注:以下代码均使用TypeScript编写)

1.共享域

CrossStorageHub

需要用到的数据结构

interface PermissionInfo {
    /**
     * 允许的源
     * @example  /\.example\.(?:com|net|cn)(?:\:\d+)?$/
     */
    origin: RegExp;
    /**
     * 允许的请求方法
     */
    allow: string[];
}

PermissionInfo主要用来配置域名的相关权限

interface ParamsInfo {
    /**
     * 键
     */
    key: string;
    /**
     * set值
     */
    value: string;
    /**
     * 批量del
     */
    keys: string[];
}

interface RequestInfo {
    /**
     * 请求唯一标识
     */
    id: number;
    /**
     * 请求方法
     */
    method: 'get' | 'set' | 'del' | 'clear';
    /**
     * 请求参数
     */
    params: ParamsInfo;
}

interface ResponseInfo {
    /**
     * 请求唯一标识
     */
    id: number;
    /**
     * 错误信息
     */
    error: string;
    /**
     * 请求结果
     */
    result: string;
}

初始化,检测localStorage是否可用

         public static init(permissions: PermissionInfo[]): void {
            let available = true;

            // 判断localStorage是否可用
            if (!window.localStorage) available = false;

            if (!available) {
                window.parent.postMessage('unavailable', '*');
                return;
            }

            this._permissions = permissions || [];
            this._installListener();
            window.parent.postMessage('ready', '*');
        };

安装窗口消息事件所需的侦听器,兼容IE8及以上版本

        private static _installListener(): void {
            const listener = this._listener;
            if (window.addEventListener) {
                window.addEventListener('message', listener, false);
            } else {
                window['attachEvent']('onmessage', listener);
            }
        };

核心函数,处理来自父窗口的消息,忽略任何来源与权限不匹配的消息。根据method调用对应的函数('get' | 'set' | 'del' | 'clear'),响应请求的唯一标识,错误信息,以及result。

        private static _listener(message: MessageEvent): void {

            let origin: string, request: RequestInfo, method: string, 
            error: string, result: string[] | string, response: string;

            origin = message.origin;
            
            // 检查message.data是否为有效json
            try {
                request = JSON.parse(message.data);
            } catch (err) {
                return;
            }

            // 校验request.method数据类型
            if (!request || typeof request.method !== 'string') {
                return;
            }
            
            method = request.method;

            if (!method) {
                return;
            } else if (!CrossStorageHub._permitted(origin, method)) {
                error = 'Invalid permissions for ' + method;
            } else {
                try {
                    result = CrossStorageHub['_' + method](request.params);
                } catch (err) {
                    error = err.message;
                }
            }

            response = JSON.stringify({
                id: request.id,
                error: error,
                result: result
            })

            window.parent.postMessage(response, origin);
        };

为了防止恶意网站的接入,所以进行权限检测,不满足条件的消息将被过滤。

        private static _permitted(origin: string, method: string): boolean {
            let available: string[], match: boolean;

            available = ['get', 'set', 'del', 'clear'];
            if (!this._inArray(method, available)) {
                return false;
            }

            for (const entry of this._permissions) {
                if (!(entry.origin instanceof RegExp) || !(entry.allow instanceof Array)) {
                    continue;
                }

                match = entry.origin.test(origin);
                if (match && this._inArray(method, entry.allow)) {
                    return true;
                }
            }

            return false;
        };
        /**
         * 取数据
         * @param params 
         */
        private static _get(params: ParamsInfo): string {
            const { key } = params;
            return window.localStorage.getItem(key);
        }

剩下的 set | del | clear 函数都是针对localStorage相关API的调用,我就不一一列举了

cross-storage-iframe.html

<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8">
</head>

<body>

    <script src="./bin/crossStorage/crossStorage.js" type="text/javascript"></script>
    <script>
        crossStorage.init([
            {
                origin: /\\*/,
                allow: ['get', 'set', 'del']
            }
        ]);
    </script>

</body>

</html>

将代码编译成js文件进行引入。这里为了方便测试将origin配置为/\\*/,允许任何域名访问get,set,del方法。

2.父窗口CrossStorageSDK实现

主要是用来载入共享域并与之进行通信

sdk的初始化,加载cross-storage-iframe,以及监听message事件

    class CrossStorageSDK {

        private static readonly _storageSrc: string = "https://www.example.com/cross-storage-iframe.html";
        private static readonly _storageOrign: string = "https://www.example.com";
        private static _ready: boolean = false;
        private static _callbacks: { [key: string]: (response?: ResponseInfo) => Promise<string> | void } = {};

        public static async init(): Promise<void> {
            return new Promise((resolve, reject) => {
                this._installListener();
                this._installCrossStorageIframe();
                this._callbacks['ready'] = () => resolve();
                this._callbacks['unavailable'] = () => reject();
            })
        }

        /**
         * 安装cross-storage-iframe
         */
        private static _installCrossStorageIframe(): void {
            const iframe = document.createElement('iframe');
            iframe.frameBorder = iframe.width = iframe.height = '0';
            iframe.name = 'cross-storage';
            iframe.src = this._storageSrc;
            document.body.appendChild(iframe);
        }


        /**
         * 安装窗口消息事件所需的侦听器
         * 兼容IE8及以上版本
         */
        private static _installListener(): void {
            const listener = this._listener;
            if (window.addEventListener) {
                window.addEventListener('message', listener, false);
            } else {
                window['attachEvent']('onmessage', listener);
            }
        };
    }

处理来自cross-storage-iframe的事件消息,通过请求唯一标识进行回调。

        private static _listener(message: MessageEvent): void {

            let response: ResponseInfo;

            // 忽略其他源发送的消息
            if (message.origin !== CrossStorageSDK._storageOrign) return;

            if (message.data === 'unavailable') {
                CrossStorageSDK._callbacks['unavailable']();
                return;
            }

            if (message.data === 'ready' && !CrossStorageSDK._ready) {
                CrossStorageSDK._ready = true;
                CrossStorageSDK._callbacks['ready']();
                return;
            }

            // 检查message.data是否为有效json
            try {
                response = JSON.parse(message.data);
            } catch (err) {
                return;
            }

            CrossStorageSDK._callbacks[response.id] && CrossStorageSDK._callbacks[response.id](response)
        };

对cross-storage-iframe的localStorage进行get操作。

        public static get(key: string): Promise<string> {
            return new Promise((resolve, reject) => {
                const id = new Date().getTime();
                const request: RequestInfo = {
                    id: id,
                    method: 'get',
                    params: {
                        key: key
                    }
                }

                this._callbacks[id] = (response: ResponseInfo) => {
                    response.error ? reject(response.error) : resolve(response.result);
                }

                this._sendRequest(request);
            });
        }

        private static _sendRequest(request: RequestInfo): void {
            window.frames[0].postMessage(JSON.stringify(request), this._storageOrign);
        }

如何测试

a.html和b.html分别接入CrossStorageSDK,调用sdk所暴露的相关方法,就能实现localStorage的跨域(源)通信。

最后

Safari浏览器由于隐私限制,默认是禁用localStorage跨域存储的,需要用户主动开启设置。

如有遗漏或哪里写的不对,欢迎指正。

完整代码地址:github.com/leaveLi/cro…