0、省流总结版
- 利用影子Dom隔离样式
- 利用同源iframe隔离脚本
- 将iframe中获取dom的方法全部替换为主环境的(
iframeDocument.getElementById = (...args) => document.getElementById(...args))。
1、前言
因为页面内经常需要嵌入第三方的电话条组件,来提供拨号,接听来电的功能。
因为需要加载第三方的脚本和样式文件,随着对接的电话条厂商增多,一些的问题随之浮现。
- 工具库版本冲突,一些工具如jquery,可能两方都有在使用但是版本不同,往往会顾此失彼。
- 样式污染,有些电话条厂商并没有很好的做到样式隔离,经常出现样式污染了页面的其他元素的情况。
- 全局变量的污染,这个跟第一点类似,因为电话条厂商提供的sdk经常会使用全局挂载的方式来暴露一些api给接入方使用,这在一定程度上也污染了全局的作用域。
如何进行样式、脚本的隔离,就成了当务之急。
2、实现思路
2.1、友商是怎么做的
类似的需求,在其他场景也会存在。比如说,微前端。
这里参考的是无界这个微前端的框架,我们来看看他们是怎么解决相关的隔离问题的。
简单来说呢,就是利用iframe来隔离脚本,通过影子 DOM来隔离样式。
2.2、隔离脚本
因为iframe天然就具备了隔离的能力,创建一个同源的iframe,把script丢给他去加载,不就污染不到我主环境了?(摊手)
但这样还是会有问题,因为一般js脚本就做这几类事情:
- 发出接口请求
- 运算
- 操作dom
前面两个放在iframe内做都没什么问题,但是操作dom,要怎么才能让iframe能够操作到主环境的dom呢?
无界的做法是这样的:
将
document的查询类接口:getElementsByTagName、getElementsByClassName、getElementsByName、getElementById、querySelector、querySelectorAll、head、body全部代理到webcomponent,这样instance和webcomponent就精准的链接起来。
也就是将iframe获取dom的方法,全部替换为主环境的获取dom的方法。
2.3、隔离样式
无界用来隔离样式的方案就是利用影子 DOM,他可以将添加在影子DOM的样式限制在其本身之下(即便是通配符选择器),而不影响到影子DOM之外的元素:
而我们在隔离样式时,已经把获取dom的方法给代理到主环境了,只要更进一步,将body、head等标签代理到主环境的影子dom下,就可以让后面添加的样式只在影子dom下生效。
3、具体实现
有了思路啊,我们事不宜迟,马上放手开撸。
3.1、只隔离脚本
鉴于样式有其他的方式隔离,比如加个命名空间,并不是非得要用影子dom的,所以我们这里先就隔离脚本实现一把。
const iframe = document.createElement('iframe');
// 一个同源的空页面
iframe.src = location.origin + 'XXXXXX'
document.body.append(iframe)
iframe.onload = function () {
// 搞个jquery看看污染不污染
const script = document.createElement('script');
script.src = 'https://apps.bdimg.com/libs/jquery/2.1.4/jquery.min.js'
iframe.contentWindow.document.body.append(script)
// 代理获取dom的api,这里不能使用bind,会失效
iframe.contentWindow.document.querySelector = (...args) => {
return document.querySelector(...args)
}
iframe.contentWindow.document.querySelectorAll = (...args) => {
return document.querySelectorAll(...args)
}
iframe.contentWindow.document.getElementsByTagName = (...args) => {
return document.getElementsByTagName(...args)
}
iframe.contentWindow.document.getElementsByClassName = (...args) => {
return document.getElementsByClassName(...args)
}
iframe.contentWindow.document.getElementsByName = (...args) => {
return document.getElementsByName(...args)
}
iframe.contentWindow.document.getElementById = (...args) => {
return document.getElementById(...args)
}
}
效果如下:
dom可以正常被访问
jquery正常被隔离
3.2、脚本和样式一起隔离
这里就需要用到影子dom,而为了抹平差异,我们给影子dom也挂载了html、body、head等标签。
const iframe = document.createElement('iframe');
// 一个同源的空页面
iframe.src = location.origin + 'XXXXXX'
document.body.append(iframe)
// 影子dom的外层容器
const shadowWrap = document.createElement('div');
Object.assign(shadowWrap.style, {
position: 'fixed',
top: 0,
background: 'red',
zIndex: 999,
})
document.body.append(shadowWrap)
// 开启影子dom
const shadow = shadowWrap.attachShadow({ mode: "open" });
// 构建html等标签,最终挂载在影子dom下
const proxyHtml = document.createElement('html');
const proxyHead = document.createElement('head');
const proxyBody = document.createElement('body');
proxyHtml.appendChild(proxyHead);
proxyHtml.appendChild(proxyBody);
shadow.appendChild(proxyHtml);
iframe.onload = function () {
const script = document.createElement('script');
script.src = 'https://apps.bdimg.com/libs/jquery/2.1.4/jquery.min.js'
iframe.contentWindow.document.body.append(script)
iframe.contentWindow.document.querySelector = (...args) => {
return proxyHtml.querySelector(...args)
}
iframe.contentWindow.document.querySelectorAll = (...args) => {
return proxyHtml.querySelectorAll(...args)
}
iframe.contentWindow.document.getElementsByTagName = (...args) => {
return proxyHtml.getElementsByTagName(...args)
}
iframe.contentWindow.document.getElementsByClassName = (...args) => {
return proxyHtml.getElementsByClassName(...args)
}
iframe.contentWindow.document.getElementsByName = (...args) => {
return proxyHtml.getElementsByName(...args)
}
iframe.contentWindow.document.getElementById = (...args) => {
return proxyHtml.getElementById(...args)
}
script.onload = () => {
iframe.contentWindow.$('body').append('<div>123123123</div>')
// 挂个通配样式
iframe.contentWindow.$('body').append('<style> * { background: #fff }</div>')
// $('body').append('<div style="position: fixed; top: 0px; bottom: 0px; left: 0px; right: 0px; background: red; z-index: 99999;">123</div>')
}
}
效果如下:
元素被正常挂载,样式被限制在影子dom下,即便是通配符选择器!
3.3、最终封装版
前面的实现其实还存在一个问题,就是因为接管了iframe的节点查询方法到了影子dom上,那么原本挂载在iframe的script标签就无法查询到了,后续动态添加的script标签也会被添加到影子dom中,无界也有同样的问题。
为了解决这个问题,就需要:
- 接管影子dom的插入方法,把script标签的插入指向iframe。
- 对于dom的查询方法,如果遇到的元素,则再调用原有的查询方法
最终封装版如下:
export const createSandBox = (root, sandBoxContentDomId) => {
let resolve = () => {};
const iframe = document.createElement('iframe');
iframe.src = location.origin + '/eservice/support/connect?resourceUrl=XXXXXX';
document.body.append(iframe);
// 影子dom的外层容器
const shadowWrap = root;
// 开启影子dom
const shadow = shadowWrap.attachShadow({ mode: 'open' });
iframe.onload = function () {
const iframeWindow = iframe.contentWindow;
// 构建html等标签,最终挂载在影子dom下
const proxyHtml = iframeWindow.document.createElement('html');
const proxyHead = iframeWindow.document.createElement('head');
const proxyBody = iframeWindow.document.createElement('body');
proxyBody.style.margin = '0';
const contentDom = iframeWindow.document.createElement('div');
contentDom.id = sandBoxContentDomId;
// 代理查找元素方法
[
'querySelector',
'querySelectorAll',
'getElementsByTagName',
'getElementsByClassName',
'getElementsByName',
'getElementById',
].forEach((fnName) => {
const originFn = iframeWindow.document[fnName];
iframeWindow.document[fnName] = (...args) => {
const mainResult = proxyHtml[fnName] ? proxyHtml[fnName](...args) : shadow[fnName](...args);
// 找不到就去iframe找,因为script还是放在iframe内的
if (!mainResult || (mainResult.nodeType === undefined && !mainResult.length)) {
return originFn.apply(iframeWindow.document, args);
}
return mainResult;
};
});
// 代理body、head、html
[
{ key: 'body', value: proxyBody, originKey: 'originBody' },
{ key: 'head', value: proxyHead, originKey: 'originHead' },
{ key: 'documentElement', value: proxyHtml, originKey: 'originHtml' },
].forEach(({ key, value, originKey }) => {
iframeWindow.document[originKey] = iframeWindow.document[key];
Object.defineProperty(iframeWindow.document, key, {
get() {
return value;
},
});
});
// 有些组件库,像ant 会访问ownerDocument 这会把主环境的document暴露出去,这里让他指向iframe的document
Object.defineProperty(iframeWindow.Node.prototype, 'ownerDocument', {
get() {
return iframeWindow.document;
},
});
Object.defineProperty(iframeWindow.document, 'activeElement', {
get() {
return shadow.activeElement;
},
});
Object.defineProperty(iframeWindow.document, 'contains', {
get(...args) {
return () => shadow.contains(...args);
},
});
[
// 代理影子dom的插入,把script标签插入iframe内
'append',
'prepend',
'appendChild',
].forEach((fnName) => {
[
{ iFrameEl: iframeWindow.document.originHead, proxyEl: proxyHead },
{ iFrameEl: iframeWindow.document.originBody, proxyEl: proxyBody },
].forEach(({ iFrameEl, proxyEl }) => {
const originFn = proxyEl[fnName];
proxyEl[fnName] = (el, ...args) => {
if (el.tagName.toLowerCase() === 'script') {
return iFrameEl[fnName](el, ...args);
}
return originFn.apply(proxyEl, [el, ...args]);
};
});
});
// 事件代理
[
{ proxyEl: iframeWindow.document, originEl: document },
{ proxyEl: proxyHtml, originEl: document },
{ proxyEl: proxyHead, originEl: document.head },
{ proxyEl: proxyBody, originEl: document.body },
].forEach(({ proxyEl, originEl }) => {
proxyEl.originAddEventListener = proxyEl.addEventListener;
proxyEl.originRemoveEventListener = proxyEl.removeEventListener;
proxyEl.addEventListener = (eventName, handler, ...rest) => {
originEl.addEventListener(eventName, handler, ...rest);
// 挂载在主应用下以真正相应全局事件的捕获
proxyHtml.originAddEventListener(eventName, handler, ...rest);
};
proxyEl.removeEventListener = (eventName, handler, ...rest) => {
originEl.removeEventListener(eventName, handler, ...rest);
proxyHtml.originRemoveEventListener(eventName, handler, ...rest);
};
});
proxyBody.appendChild(contentDom);
proxyHtml.appendChild(proxyHead);
proxyHtml.appendChild(proxyBody);
shadow.appendChild(proxyHtml);
resolve({
iframeWindow,
iframe,
});
};
return new Promise((re) => {
resolve = re;
});
};
// 测试代码
// 创建隔离沙盒根节点
const shadowWrap = document.createElement('div');
Object.assign(shadowWrap.style, {
position: 'fixed',
top: 0,
background: 'red',
zIndex: 999,
});
document.body.append(shadowWrap);
createSandBox({ root: shadowWrap }).then(({ iframeWindow }) => {
// 插入jq
const script = iframeWindow.document.createElement('script');
script.src = 'https://apps.bdimg.com/libs/jquery/2.1.4/jquery.min.js';
iframeWindow.document.body.append(script);
script.onload = () => {
// 检验jq是否隔离
console.log('main jquery: ', $.fn.jquery);
console.log('sandBox jquery: ', iframeWindow.$.fn.jquery);
// 检验普通dom是否正常插入影子节点
iframeWindow.$('body').append('<div>123123</div>');
iframeWindow.$('body').append('<style>* { background: #fff } </div>');
// 检查script是否正常插入iframe
iframeWindow.$('body').append('<script id="sssss"></script>');
console.log(iframeWindow.$('div'));
console.log(iframeWindow.$('script'));
console.log(iframeWindow.$('#sssss'));
console.log(iframeWindow.document.getElementById('sssss'));
};
});
运行结果如下
4、遇到的问题与调试的技巧
4.1、dom类溢出问题
目前出现过的问题有:
1、ownerDocument属性导致访问到主应用的document,进入将节点插入到了主应用的body、head下
// 代理ownerDocument属性
Object.defineProperty(iframeWindow.Node.prototype, 'ownerDocument', {
get() {
return iframeWindow.document;
},
});
// 之前用主环境构建的document.createElement构建的应用 他的ownerDocument还是会指向主环境的
// 所以需要改为使用沙盒环境的
const proxyHtml = iframeWindow.document.createElement('html');
const proxyHead = iframeWindow.document.createElement('head');
const proxyBody = iframeWindow.document.createElement('body');
2、activeElement属性,因为dom的插入都代理到主应用的影子dom下了,沙盒的document的activeElement无法访问到正确的节点,需要将属性代理到影子dom
Object.defineProperty(iframeWindow.document, 'activeElement', {
get() {
return shadow.activeElement;
},
});
3、document.contains,因为沙盒的document的body、head都指向了影子dom的body、head标签下了,但document本身指向的html无法代理,所以需要手动把这个方法指向影子dom的html
Object.defineProperty(iframeWindow.document, 'contains', {
get(...args) {
return () => shadow.contains(...args);
},
});
排查建议:
1、这类溢出通常伴随应该出现的dom没出现,可以在主环境的、沙盒环境的body、head标签内走查是否出现可疑节点,并通过类名、id等搜索代码,定位插入位置,看看是遗漏了哪些属性。
2、也可以接管主环境、沙盒环境的document、body等属性,看看是通过哪个属性指向的主环境的节点了。
const doc = window.document
Object.defineProperty(window, 'document', {
get() {
debugger
return doc;
},
});
4.2、事件类溢出问题
因为沙盒的document本身还是没有指向影子dom的html标签的,这导致document.addEventListener属性还是指向了沙盒的html标签
同时,挂载在body、head标签下的事件(可能需要用于判断click-outside),无法真正的全局捕获整个文档的点击等事件,因此还需要将事件代理到主应用下
// 事件代理
[
{ proxyEl: iframeWindow.document, originEl: document },
{ proxyEl: proxyHtml, originEl: document },
{ proxyEl: proxyHead, originEl: document.head },
{ proxyEl: proxyBody, originEl: document.body },
].forEach(({ proxyEl, originEl }) => {
proxyEl.originAddEventListener = proxyEl.addEventListener;
proxyEl.originRemoveEventListener = proxyEl.removeEventListener;
proxyEl.addEventListener = (eventName, handler, ...rest) => {
originEl.addEventListener(eventName, handler, ...rest);
// 挂载在主应用下以真正相应全局事件的捕获
proxyHtml.originAddEventListener(eventName, handler, ...rest);
};
proxyEl.removeEventListener = (eventName, handler, ...rest) => {
originEl.removeEventListener(eventName, handler, ...rest);
proxyHtml.originRemoveEventListener(eventName, handler, ...rest);
};
});
排查建议:
通过调试面板查看主环境、沙盒环境的html、body等标签绑定的事件是否有溢出的情况。
4.3、样式丢失问题
影子dom字体图标不生效问题