使用FrameController.js优雅的处理单页多框架窗口(<iframe>)管理同步问题

1,265 阅读4分钟

0. 痛点

做后台时,不可避免的会遇到 内嵌 iframe 的情况。 最近的 一个项目 有客户反馈无法保存,提示 token 错误。 经过沟通发现 是因为打开了 多个内嵌页(iframe),会出现此问题(使用ThinkPHP5自带方法 token(),每次调用都会生成新的Token)。 修复方法也很简单,直接在 新增内嵌页时,将新生成的token进行广播。

这种情况在 后台 开发中会经常遇到,把 代码 提取出来,做了一下简单的封装,做成开源项目FrameController.js

考虑到后台可能要兼容非常老的平台,特别针对 IE7、IE8 做了兼容处理,基本可以实现所有浏览器的兼容。

另外本源码不依赖任何JS类库,可以拿来即用。

1. 原理

原理非常简单,一个简单的 事件订阅、发布流程而已。

为了方便调用,统一调用同一个js,通过 window.top == window.self 来判断是子窗口或父窗口。

1.1 父窗口

数据模型

var data = {
    events: { //事件
      _PAGE_ID:{
      	EVENT_NAME_1: function(){},
        EVENT_NAME_2: function(){}
      }
    },
    counter: {//事件计数器
      _PAGE_ID:{
        EVENT_NAME_1: 1, //EVENT_NAME_2只绑定了一次
        EVENT_NAME_2: 5 //EVENT_NAME_2绑定了5次,所以会重复执行5次
      }
    },
    _count: 1 //窗口ID
  }

其中窗口ID通过 getId() 生成。

获取窗口ID

/**
 * 获取新的窗口编号
 */
var getId = function() {
    return 'frame_' + data._count++;
};

收集事件

/**
 * 添加监听事件
 */
var addListener = function(event, func) {

    var _events = data.events,
        _counter = data.counter,
        _id = this.frameId;

    if (!(_id in _events)) {
        _events[_id] = {};
        _counter[_id] = {};
    }

    if (!(event in _events[_id])) {
        _events[_id][event] = {};
        _counter[_id][event] = 1;
    }

    _events[_id][event][_counter[_id][event]++] = func;
};

移除事件

/**
 * 删除监听事件
 * 如果不指定func会删除所有本类型的事件
 */
 var removeListener = function(event, func) {
    var _events = data.events,
        _id = this.frameId;

    if ((_id in _events) && (event in _events[_id])) {
        var _funcs = _events[_id][event];
        for (var _funcId in _funcs) {
            if (_funcs[_funcId] == func || !func) {
                delete _funcs[_funcId];
                if (!!func) {
                    //如果指定 func,只会删除一个
                    break;
                }
            }
        }
    }
};

广播事件

/**
 * 事件广播
 */
var broadcast = function(event, value) {

    var _events = data.events,
        count = 0;

    if (topFrameId === this.frameId) {
        value = {
            type: topFrameId,
            target: window,
            data: value,
            frameId: this.frameId
        };
    }

    for (var _frameId in _events) {
        if (_frameId != value.frameId && (event in _events[_frameId])) {
            for (var _funcId in _events[_frameId][event]) {
                _events[_frameId][event][_funcId](value);
                count++;
            }
        }
    }

    return count;

};

导出

定义 FrameController,方便子窗口调用。

window.FrameController = {
    frameId: topFrameId,
    broadcast: broadcast,
    addListener: addListener,
    removeListener: removeListener,
    removeAllListener: removeAllListener,
    getId: getId
}

为了避免变量冲突,可以通过闭包的方式只定义FrameController,具体见FrameController.js中的源码。

1.2 iframe窗口

现在就可以拿到父窗口的FrameController,用addListener函数添加事件了。

获取子窗口数据

var TopController = window.top.FrameController, //得到父窗口
    frameData = { //iframe页面数据
        frameId: TopController.getId()  //iframe窗口ID
    };

事件添加删除再封装

到这里基本功能已经都完成了,还存在的小问题就是无论哪个绑定的事件都会绑定在父窗口,iframe窗口中使用的时候需要用call改变下this的执行

var bindFrameData = function(func) {
	// 注意:老版本IE不支持bind,需要这样写
    return function(event, data) {
        func.call(frameData, event, data);
    };
}
var addListener = bindFrameData(TopController.addListener)
var removeListener = bindFrameData(TopController.removeListener)

广播事件

重写broadcast的方法,自动附带iframe页面数据数据

 //广播
var broadcast = function(event, value) {
    return TopController.broadcast.call(frameData, event, {
        event: event,
        type: 'child',
        target: window,
        data: value,
        frameId: frameData.frameId
    });
};

导出

同样也用TopController保存所有方法

var TopController = {
    broadcast: broadcast,
    addListener: bindFrameData(TopController.addListener),
    removeListener: bindFrameData(TopController.removeListener),
    removeAllListener: bindFrameData(TopController.removeAllListener),
    count: getCount
}

1.3 系统内置事件

现在已经可以通过 TopController的API方法调用了,可以在iframe初始化时增加系统级事件,方便用户调用

为了兼容更多的浏览器,先抹平系统addEventListener的事件名差异

//窗口加载或关闭
var listenerName = 'attachEvent';
var listenerPrefix = 'on';
if ('addEventListener' in window) {
    listenerName = 'addEventListener';
    listenerPrefix = '';
}

窗口增加事件

//窗口注册事件
window[listenerName](listenerPrefix + 'load', function() {
    FrameController.broadcast('frame.add', {
        msg: '新增窗口'
    });
});

窗口关闭事件

//窗口关闭事件
window[listenerName]('unload', function() {
    //窗口关闭事件
    FrameController.broadcast('frame.remove', {
        msg: '关闭窗口'
    });
});

iframe窗口关闭后,注册的事件还在,所以unload时也需移除下(对使用者来说是自动的)

window[listenerName]('unload', function() {
    //窗口关闭事件
    bindFrameData(TopController.removeAllListener);
});

统计iframe个数

我们可以在iframe页面加载时增加frame._online事件,然后调用frame._online计算执行次数就可以得到打开的iframe窗口的个数(广播事件本窗口不会执行,还需要手动+1)。

//计数事件,仅用于统计框架数
window[listenerName](listenerPrefix + 'load', function() {
    FrameController.addListener('frame._online', function() {});
});

//获取窗口数量
var getCount = function() {
    return FrameController.broadcast('frame._online') + 1;
};

2. 使用说明

在编写例子之前,先回一下消息体结构

{
    event: '事件名称',
    type: 'child',
    target: '内嵌页的window',
    data: '传递的数据,即FrameController.broadcast(event, data)的data',
    frameId: '内嵌页标志'
}

2.1 某个iframe页面给所有其他页面发送通知

var addLog = function(from, event, data) {
    var _old = $('#log').html().substring(0, 3000);
    $('#log').html(
        (logTpl + _old)
        .replace('#EVENT#', event)
        .replace('#DATA#', JSON.stringify(data))
        .replace('#SOURCE#', from)
    );
    console.log('event:', event, 'data:', data);
};
 
//同步通知
FrameController.addListener('broadcast', function(e) {
    $('#msg').val(e.data.msg);
    addLog(e.frameId, e.event, e.data);
});
 
//发送广播
$('#send').click(function() {
    var nums = FrameController.broadcast('broadcast', {
        msg: $('#msg').val()
    });
    $('#log').html('通知成功:' + nums + '\n\n' + $('#log').html());
});
 
//更新输入状态
$('#msg').change(function() {
    FrameController.broadcast('change', {
        text: '输入框内容已更改:' + $(this).val()
    });
});
 
//更新状态
FrameController.addListener('change', function(e) {
    addLog(e.frameId, e.event, e.data);
});

2.2 新增iframe、关闭iframe后,其他iframe接收通知

//监听系统事件
var addLog = function(from, event, data) {
    var _old = $('#log').html().substring(0, 3000);
    $('#log').html(
        (logTpl + _old)
        .replace('#EVENT#', event)
        .replace('#DATA#', JSON.stringify(data))
        .replace('#SOURCE#', from)
    );
    console.log('event:', event, 'data:', data);
};
 
//监听系统事件
FrameController.addListener('frame.remove', function(e) {
    addLog(e.frameId, e.event, e.data);
});
FrameController.addListener('frame.add', function(e) {
    addLog(e.frameId, e.event, e.data);
});

2.3 添加自定义事件

var logTpl = '事件:#EVENT# 来源:#SOURCE#\n数据:#DATA#\n\n',
    addLog = function(from, event, data) {
        var _old = $('#log').html().substring(0, 3000);
        $('#log').html(
            (logTpl + _old)
            .replace('#EVENT#', event)
            .replace('#DATA#', JSON.stringify(data))
            .replace('#SOURCE#', from)
        );
        console.log('event:', event, 'data:', data);
    },
    msgEventListener = function(e) {
        $('#log').html('自定义事件已经触发,添加多次会触发多次\n\n' + $('#log').html());
    };
 
 
//添加自定义事件
$('#add_custom').click(function() {
    FrameController.addListener('broadcast', msgEventListener);
});
 
//删除自定义事件
$('#remove_custom').click(function() {
    FrameController.removeListener('broadcast', msgEventListener);
});

2.4 处理ThinkPHP Token

//注意:依赖jQuery
$(function(){
  //模拟ThinkPHP Token
  $('input[name=__token__]').val(new Date().getTime());

  //ThinkPHP Token 广播
  FrameController.broadcast('token', {
      token: $('input[name=__token__]').val(),
  });

  //收到 ThinkPHP Token 处理
  FrameController.addListener('token', function(e) {
      $('input[name=__token__]').val(e.data.token);
  });
});

这样最后打开的页面中的token会自动同步到所有页面

2.5监听系统事件

//监听系统事件
FrameController.addListener('frame.remove', function(e) {
    console.log(e.frameId, e.event, e.data);
});
FrameController.addListener('frame.add', function(e) {
    console.log(e.frameId, e.event, e.data);
});

//获取窗口个数
$('#count').click(function() {
    alert('当前打开 ' + FrameController.count() + ' 个窗口');
});

3. 演示和源码

代码已经开源,地址:gitee.com/mqycn/Frame…

在线测试地址:www.miaoqiyuan.cn/products/fr…

内嵌的iframe地址:www.miaoqiyuan.cn/products/fr…