如何隔离js脚本与css样式

773 阅读5分钟

0、省流总结版

  1. 利用影子Dom隔离样式
  2. 利用同源iframe隔离脚本
  3. 将iframe中获取dom的方法全部替换为主环境的(iframeDocument.getElementById = (...args) => document.getElementById(...args))。

1、前言

因为页面内经常需要嵌入第三方的电话条组件,来提供拨号,接听来电的功能。

image.png

因为需要加载第三方的脚本和样式文件,随着对接的电话条厂商增多,一些的问题随之浮现。

  1. 工具库版本冲突,一些工具如jquery,可能两方都有在使用但是版本不同,往往会顾此失彼。
  2. 样式污染,有些电话条厂商并没有很好的做到样式隔离,经常出现样式污染了页面的其他元素的情况。
  3. 全局变量的污染,这个跟第一点类似,因为电话条厂商提供的sdk经常会使用全局挂载的方式来暴露一些api给接入方使用,这在一定程度上也污染了全局的作用域。

如何进行样式、脚本的隔离,就成了当务之急。

2、实现思路

2.1、友商是怎么做的

类似的需求,在其他场景也会存在。比如说,微前端

image.png

这里参考的是无界这个微前端的框架,我们来看看他们是怎么解决相关的隔离问题的。

image.png

简单来说呢,就是利用iframe来隔离脚本,通过影子 DOM来隔离样式。

2.2、隔离脚本

因为iframe天然就具备了隔离的能力,创建一个同源的iframe,把script丢给他去加载,不就污染不到我主环境了?(摊手)

但这样还是会有问题,因为一般js脚本就做这几类事情:

  1. 发出接口请求
  2. 运算
  3. 操作dom

前面两个放在iframe内做都没什么问题,但是操作dom,要怎么才能让iframe能够操作到主环境的dom呢?

无界的做法是这样的:

document的查询类接口:getElementsByTagName、getElementsByClassName、getElementsByName、getElementById、querySelector、querySelectorAll、head、body全部代理到webcomponent,这样instancewebcomponent就精准的链接起来。

也就是将iframe获取dom的方法,全部替换为主环境的获取dom的方法。

2.3、隔离样式

无界用来隔离样式的方案就是利用影子 DOM,他可以将添加在影子DOM的样式限制在其本身之下(即便是通配符选择器),而不影响到影子DOM之外的元素:

image.png

而我们在隔离样式时,已经把获取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)
    }
}

效果如下:

image.png dom可以正常被访问

image.png

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>')
    }
}

效果如下:

image.png

元素被正常挂载,样式被限制在影子dom下,即便是通配符选择器!

3.3、最终封装版

前面的实现其实还存在一个问题,就是因为接管了iframe的节点查询方法到了影子dom上,那么原本挂载在iframe的script标签就无法查询到了,后续动态添加的script标签也会被添加到影子dom中,无界也有同样的问题。

image.png

为了解决这个问题,就需要:

  1. 接管影子dom的插入方法,把script标签的插入指向iframe。
  2. 对于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'));
  };
});

运行结果如下

image.png

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等标签绑定的事件是否有溢出的情况。

image.png

4.3、样式丢失问题

影子dom字体图标不生效问题

blog.csdn.net/puyahua/art…