Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[RFC] qiankun的极致性能优化思路,与import-html-entry有关 #1555

Closed
BrianWalkerToretto opened this issue Jul 4, 2021 · 28 comments · Fixed by #2271
Closed

[RFC] qiankun的极致性能优化思路,与import-html-entry有关 #1555

BrianWalkerToretto opened this issue Jul 4, 2021 · 28 comments · Fixed by #2271
Labels
performance performance optimization

Comments

@BrianWalkerToretto
Copy link

背景

大概从 [email protected] 以后,以 proxySandbox 作为默认的沙盒,并且默认开启 strickGlobal 特性,基于 with 语句执行子应用脚本。我们的测试结果显示,在这种模式下,单纯的脚本执行性能还可以,但是一旦涉及到操作 DOM 时,性能就会严重下降,大概会差 10 倍以上。

发现问题和提供解决思路的issues,要特别鸣谢哦!
[Feature Request] 在沙盒里面对 document 也做代理会不会更好? #1175

当前我们的项目中使用react+antd技术栈,由于是B端项目,存在大量的antd组件,而antd组件会生成大量的dom节点,从而导致渲染慢,性能很差。

可以发现性能损失主要来源于以下两点:

  1. 相对于沙盒带来的性能损失远比带来的好处要小的多,故可忽略沙盒的性能问题。
  2. 但基于import-html-entry的with执行子应用脚本来说,这里的性能下降及其严重。
  3. 当子应用懒加载一个大的模块时,如果涉及到dom操作会导致明细卡顿,fps直接为0了

qiankun 版本

[email protected] 以后为主,之前的版本没测试,应该也能使用,但思路都是一致的。

最小可复现仓库

可使用#1175提供的仓库 https://gitee.com/kawhi66/test/tree/master/[email protected]

思路

#1175 提供了一个很好思路

一个想法,既然沙盒可以提供独立的 window 实例,是否也应该提供独立的 document 实例 ?

  • qiankun默认的性能
    image
  • 沙盒再提供一个独立的document实例,此时的性能
    将import-html-entry的getExecutableScript方法中的with(window)改成with(window, window.document)
// src/index.js
function getExecutableScript(scriptSrc, scriptText, proxy, strictGlobal) {
	const sourceUrl = isInlineCode(scriptSrc) ? '' : `//# sourceURL=${scriptSrc}\n`;

	// 通过这种方式获取全局 window,因为 script 也是在全局作用域下运行的,所以我们通过 window.proxy 绑定时也必须确保绑定到全局 window 上
	// 否则在嵌套场景下, window.proxy 设置的是内层应用的 window,而代码其实是在全局作用域运行的,会导致闭包里的 window.proxy 取的是最外层的微应用的 proxy
	const globalWindow = (0, eval)('window');
	globalWindow.proxy = proxy;
	// TODO 通过 strictGlobal 方式切换切换 with 闭包,待 with 方式坑趟平后再合并
	return strictGlobal
                // 修改的地方在这里,with 语句将 document 也加进去
		? `;(function(window, self){with(window, window.document){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy);`
		: `;(function(window, self){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy);`;
}

image

由此可见性能提升3.5-4倍

但是官方不使用此方法的原因主要是:

  1. 疑似导致document无法被沙盒代理;
  2. with的语法特性导致的严重bug;

测试URL和titile属性:URL在window中是一个方法,但在document中是链接
image
由此可见with代理document会产生严重的问题,不过我们通过规范代码来尽量规避这些问题。既然如此就赶紧在项目中使用,毕竟解决了性能问题,可是实际使用发现了另一个重大bug:无法使用懒加载。。。
image
产生这个原因:webpack懒加载是用过document.head.appendChild(script)来解决的。
with代理的document会导致懒加载失效,所以我们需要转变思路。

  1. 解决with代理document产生的问题的思路
/* 沙盒注入import-html-entry的execScripts方法 */
// 1. 通过qiankun的registerMicroApps的beforeLoad方法的第二个参数来注入execScripts方法
// 我就简单点,直接在beforeLoad中注入。
// qiankun团队可beforeLoad调用之前就注入execScripts
import { execScripts } from 'import-html-entry';
registerMicroApps([
  {
    name: "vue app",
    entry: "http://localhost:5101",
    container: "#subapp-container",
    activeRule: "/vue"
  }], {
    beforeLoad: function(app, global) {
      global.execScripts = execScripts;
    }
  }
);
// 2.修改import-html-entry的getExecutableScript方法,通过重写document.head.appendChild方法来实现懒加载
function getExecutableScript(scriptSrc, scriptText, proxy, strictGlobal) {
  const sourceUrl = isInlineCode(scriptSrc) ? '' : `//# sourceURL=${scriptSrc}\n`;
  const globalWindow = (0, eval)('window');
  globalWindow.proxy = proxy;
  const execScripts = `
    var headAppendChild = document.head.appendChild.bind(document.head);
    document.head.appendChild = function(elem) {
      if(elem.nodeName.toLocaleLowerCase() === 'script') {
        /*execScripts参数解析
          execScripts({
            entry,
            scripts,
            proxy,
            opts:{
              fetch:传入fetch或使用defaultFetch(即window.fetch.bind(window))
              strictGlobal:默认false,
              success:成功的回调函数
              error:失败的回调函数
              beforeExec:js代码字符串前置执行函数
              afterExec:js代码字符串后置执行函数
            }
          })
        */
        (typeof self !== 'undefined' ? self : this)['execScripts'](
          elem.src,
          [elem.src],
          window, // proxy
          {
            fetch: window.fetch,
            strictGlobal: true,
            success: elem.onload,
            error: elem.error
          }
        );
      } else {
        headAppendChild(elem);
      }
    };
  `;
  // TODO 通过 strictGlobal 方式切换切换 with 闭包,待 with 方式坑趟平后再合并
  return strictGlobal
    ? `;(function(window, self){with(window, window.document){;${execScripts}\n${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy);`
    : `;(function(window, self){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy);`;
}

整合上面的思路

  • 第一:沙盒注入import-html-entry的execScripts;
  • 第二:with注入document;with(window, document)
  • 第三:重写import-html-entry的getExecutableScript,代理子应用js的document.head.appendChild方法,并使用注入的execScripts来解析子应用的js
  • 总结:解决了上面的懒加载的问题,性能与直接在with中注入document一致
  1. 又可以愉快的使用高性能的qiankun啦。但是仍然没有解决with(window, document)的属性冲突问题。。。

with(window, document)会导致window.URL(方法)被document.URL(链接)覆盖,导致无法使用new URL()
反过来with(document, window)会导致document.URL(链接)被window.URL(方法)覆盖,导致无法使用document.URL
不过规范代码,也是可以继续写项目。

/*
研究了一下with语法,发现
obj, obj1, ...., objn 相当于Object.assign({}, obj, obj1, ..., objn);
由此可见with(window, document)会导致window.URL被document.URL覆盖
*/
with(obj, obj1, ..., objn) {
   console.log(key)
}
  1. 想了想重写的getExecutableScript方法:最后return这段
    ;(function(window, self){with(window, window.document){;${execScripts}\n${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy);

看这段代码,就知道已通过bind方法将函数的this修改为沙盒proxy,回调函数也已经注入了window和self。
既然我重写了getExecutableScript方法,通过注入的execScripts解析了子应用的js,那么又何必使用使用with呢
在不使用with的情况下,就没有了with产生的bug了。

**qiankun极致性能优化:**将重写的getExecutableScript方法中的with(window, window.document){}删除

极致性能,测试结果如下,而且与不使用qiankun的子应用的性能相差无几。
image

总结

  1. beforeLoad注入import-html-entry的execScripts方法,可交由qiankun的loader.js来实现,在生成沙盒之后注入execScripts方法
  2. 重写import-html-entry的getExecutableScript方法应该可以交由beforeExec和afterExec来实现,或者qiankun直接重写该方法
  3. 子应用重写document.head.appendChild方法可webpack plugin来实现重写requireEnsure方法,根据是否qiankun子应用使用execScripts或document.head.appendChild
  4. 尚不知此番操作会不会导致沙盒的内存溢出问题 或者 其他隐性bug问题,所以以上操作目前来说最好在个人项目,内部项目使用。
  5. 第一次优化提升3.5-4倍性能,第二次优化提升16倍性能。
  6. 希望qiankun团队可以采用本方案,或者以此方案想出更好的性能优化方案。
@renwofei423
Copy link

不知道angular使用qiankun框架之后,导致性能极具下降也是此原因?

脏值检查直接卡爆浏览器。。。

@BrianWalkerToretto
Copy link
Author

不知道angular使用qiankun框架之后,导致性能极具下降也是此原因?

脏值检查直接卡爆浏览器。。。

我没用过angular,不过一般性能下降都是因为进行了DOM操作(说明渲染页面了)。
脏值检查之后应该要渲染页面了吧。
qiankun中使用了import-html-entry,你直接改里面的getExecutableScript方法,将return中的with(window)改成with(window, document),如果性能提升4倍的话,那就是因为DOM操作的原因了。

@BrianWalkerToretto
Copy link
Author

最简单的方式就是直接删除import-html-entry的getExecutableScript方法的with(window)。
不过存在的问题就是,子应用嵌套子应用的window问题。
非嵌套子应用的项目可以这样处理。

@bitQ2019
Copy link

👍

@bitQ2019
Copy link

最简单的方式就是直接删除import-html-entry的getExecutableScript方法的with(window)。
不过存在的问题就是,子应用嵌套子应用的window问题。
非嵌套子应用的项目可以这样处理。

其实我不太理解不用 with 性能更好,为啥这里要用 with,有大神能指点下吗?

@BrianWalkerToretto
Copy link
Author

最简单的方式就是直接删除import-html-entry的getExecutableScript方法的with(window)。
不过存在的问题就是,子应用嵌套子应用的window问题。
非嵌套子应用的项目可以这样处理。

其实我不太理解不用 with 性能更好,为啥这里要用 with,有大神能指点下吗?

其实我也不清楚,但是with关键字的作用在于改变作用域。强制所有子应用的js的作用域都是同一作用域。
应该是import-html-entry并不知道加载的js的作用域,所以才使用with的。
在qiankun里子应用是通过沙盒来代理子应用的(防止子应用污染全局作用域),所有需要强制子应用里面的所有js的作用域都是这个沙盒。

@bitQ2019
Copy link

最简单的方式就是直接删除import-html-entry的getExecutableScript方法的with(window)。
不过存在的问题就是,子应用嵌套子应用的window问题。
非嵌套子应用的项目可以这样处理。

其实我不太理解不用 with 性能更好,为啥这里要用 with,有大神能指点下吗?

其实我也不清楚,但是with关键字的作用在于改变作用域。强制所有子应用的js的作用域都是同一作用域。
应该是import-html-entry并不知道加载的js的作用域,所以才使用with的。
在qiankun里子应用是通过沙盒来代理子应用的(防止子应用污染全局作用域),所有需要强制子应用里面的所有js的作用域都是这个沙盒。

理解到一点,就是所有查找的时候都去 window 查找一下,这样都会走 proxyWindow 代理,比如 document

@Esain
Copy link

Esain commented Jul 21, 2021

angular为基座子应用也是angular程序,卸载子应用时报错,parentNode is undefined.

@tengya1235
Copy link

大佬有没有完整的例子,能否借阅下

@BrianWalkerToretto
Copy link
Author

BrianWalkerToretto commented Aug 4, 2021

大佬有没有完整的例子,能否借阅下
上面的例子就可以了
找到qiankun里面的import-html-entry包的getExecutableScript方法中的
with(window)改成with(window, window.document)
或者将with(window)删除,这2个方式都可以进行简单的测试,可以看出来它们之前的性能差异

https://gitee.com/kawhi66/test/tree/master/[email protected]

@BrianWalkerToretto
Copy link
Author

angular为基座子应用也是angular程序,卸载子应用时报错,parentNode is undefined.

很抱歉,我不会angular程序
但如果是卸载子应用报parentNode is undefined,那应该是代码里提前将dom节点删除了,导致的这个错误

@canyuegongzi
Copy link

方案不错,官方团队目前还没有做优化工作,自己的团队根据这个方案优化完一波估计以后官网更新后无法同步了。

@BrianWalkerToretto
Copy link
Author

方案不错,官方团队目前还没有做优化工作,自己的团队根据这个方案优化完一波估计以后官网更新后无法同步了。

这只是个另类的优化方法,官网更新之后把这些移除就可以了,并不会影响到官方优化。

@Liaozzzzzz
Copy link

mark

4 similar comments
@AprilLemon
Copy link

mark

@Topthinking
Copy link

mark

@zhanghsoft
Copy link

mark

@xyjxu
Copy link

xyjxu commented Dec 10, 2021

mark

@Tinet-zhangmd
Copy link

不知道angular使用qiankun框架之后,导致性能极具下降也是此原因?

脏值检查直接卡爆浏览器。。。

请问怎么解决的?

@renwofei423
Copy link

不知道angular使用qiankun框架之后,导致性能极具下降也是此原因?
脏值检查直接卡爆浏览器。。。

请问怎么解决的?

1,关闭代理
2,使用qiankun老版本,2.1之前的版本就可以

@typistZxd
Copy link

typistZxd commented Mar 10, 2022

let a = {
    x: 1,
    z: 2
}
let b = {
    h: 'a',
    z: 'zzz'
}
with(a, b) {
    console.log(z) // zzz
    console.log(a); // {x: 1, z: 3}
    console.log(b); // {h: 'a', z: 'zzz'}
    console.log(x) // 报错
}

我理解的with是将最后一个参数作为作用域,其他参数还是在上一层作用域中,可以通过引用访问,并不是类似Object.assign形式

@BrianWalkerToretto
Copy link
Author

let a = {
    x: 1,
    z: 2
}
let b = {
    h: 'a',
    z: 'zzz'
}
with(a, b) {
    console.log(z) // zzz
    console.log(a); // {x: 1, z: 3}
    console.log(b); // {h: 'a', z: 'zzz'}
    console.log(x) // 报错
}

我理解的with是将最后一个参数作为作用域,其他参数还是在上一层作用域中,可以通过引用访问,并不是类似Object.assign形式

哈哈,尴尬了,当初还没深入了解with,就举了个大概的例子,,,
想不到有问题。
了解with

@gongshun
Copy link
Collaborator

with 是为了解决一些变量逃出沙箱的场景,例如,

子应用有一个全局变量 window.a =1,但是使用这个变量的时候并没有用 window.a,而是直接用 a,在 proxy 沙箱中,子应用的全局变量并不会写到真正的 window 上,而这个代码在运行时,会先找函数内的变量 a,再找 window.a , 这个 window 是真实的 window,上面并没有 a 变量,就会报错。而 with 就会让这个 a 变量去子应用的沙箱上找。子应用读取document/location 这种就不受影响,因为 window 上存在这些变量

@bitQ2019
Copy link

with 是为了解决一些变量逃出沙箱的场景,例如,

子应用有一个全局变量 window.a =1,但是使用这个变量的时候并没有用 window.a,而是直接用 a,在 proxy 沙箱中,子应用的全局变量并不会写到真正的 window 上,而这个代码在运行时,会先找函数内的变量 a,再找 window.a , 这个 window 是真实的 window,上面并没有 a 变量,就会报错。而 with 就会让这个 a 变量去子应用的沙箱上找。子应用读取document/location 这种就不受影响,因为 window 上存在这些变量

主要是为了 document, location 去 proxy window 上找,这样就可以使用自定义的 document window。 但是 with 的问题就是会阻止代码执行时候的优化。

@kuitos
Copy link
Member

kuitos commented Sep 19, 2022

released v2.8.0,通过 { sandbox: { speedy: true } } 配置开启。

@bianbiandashen
Copy link

execScriptInSandbox(script: string): void {
if (!this.sandboxDisabled) {
// create sandbox before exec script
if (!this.sandbox) {
this.createProxySandbox();
}
try {
const execScript = with (sandbox) {;${script}\n};
// eslint-disable-next-line no-new-func
const code = new Function('sandbox', execScript).bind(this.sandbox);
// run code with sandbox
code(this.sandbox);
} catch (error) {
console.error(error occurs when execute script in sandbox: ${error});
throw error;
}
}
} 这段代码要怎么优化呢 如果要使用这个优化能力

@bianbiandashen
Copy link

createProxySandbox(injection?: object) {
const { propertyAdded, originalValues, multiMode } = this;
const proxyWindow = Object.create(null) as Window;
const originalWindow = window;
const originalAddEventListener = window.addEventListener;
const originalRemoveEventListener = window.removeEventListener;
const originalSetInterval = window.setInterval;
const originalSetTimeout = window.setTimeout;

  // hijack addEventListener
  proxyWindow.addEventListener = (eventName, fn, ...rest) => {
    this.eventListeners[eventName] = (this.eventListeners[eventName] || []);
    this.eventListeners[eventName].push(fn);

    return originalAddEventListener.apply(originalWindow, [eventName, fn, ...rest]);
  };
  // hijack removeEventListener
  proxyWindow.removeEventListener = (eventName, fn, ...rest) => {
    const listeners = this.eventListeners[eventName] || [];
    if (listeners.includes(fn)) {
      listeners.splice(listeners.indexOf(fn), 1);
    }
    return originalRemoveEventListener.apply(originalWindow, [eventName, fn, ...rest]);
  };
  // hijack setTimeout
  proxyWindow.setTimeout = (...args) => {
    const timerId = originalSetTimeout(...args);
    this.timeoutIds.push(timerId);
    return timerId;
  };
  // hijack setInterval
  proxyWindow.setInterval = (...args) => {
    const intervalId = originalSetInterval(...args);
    this.intervalIds.push(intervalId);
    return intervalId;
  };

  const sandbox = new Proxy(proxyWindow, {
    set(target: Window, p: PropertyKey, value: any): boolean {

      /** window上增加变量--单例模式的window属性 & 默认变量 */
      /** 如果属于单例window变量数组则return */
      if(windowSingletonInSandboxes.includes(p)){
        originalWindow[p] = value;
        return true
      }

      // eslint-disable-next-line no-prototype-builtins
      if (!originalWindow.hasOwnProperty(p)) {
        // record value added in sandbox
        propertyAdded[p] = value;
      // eslint-disable-next-line no-prototype-builtins
      } else if (!originalValues.hasOwnProperty(p)) {
        // if it is already been setted in original window, record it's original value
        originalValues[p] = originalWindow[p];
      }
      // set new value to original window in case of jsonp, js bundle which will be execute outof sandbox
      if (!multiMode) {
        originalWindow[p] = value;
      }
      // eslint-disable-next-line no-param-reassign
      target[p] = value;
      return true;
    },
    get(target: Window, p: PropertyKey): any {
      if (p === Symbol.unscopables) {
        return undefined;
      }
      if(windowSingletonInSandboxes.includes(p)){

        return originalWindow[p]
      }
    
      if (['top', 'window', 'self', 'globalThis'].includes(p as string)) {
        return sandbox;
      }
      // proxy hasOwnProperty, in case of proxy.hasOwnProperty value represented as originalWindow.hasOwnProperty
      if (p === 'hasOwnProperty') {
        // eslint-disable-next-line no-prototype-builtins
        return (key: PropertyKey) => !!target[key] || originalWindow.hasOwnProperty(key);
      }
     

      const targetValue = target[p];
      /**
       * Falsy value like 0/ ''/ false should be trapped by proxy window.
       */
      if (targetValue !== undefined) {
        // case of addEventListener, removeEventListener, setTimeout, setInterval setted in sandbox
        return targetValue;
      }

      // search from injection
      const injectionValue = injection && injection[p];
      if (injectionValue) {
        return injectionValue;
      }

      const value = originalWindow[p];

      /**
      * use `eval` indirectly if you bind it. And if eval code is not being evaluated by a direct call,
      * then initialise the execution context as if it was a global execution context.
      * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/eval
      * https://262.ecma-international.org/5.1/#sec-10.4.2
      */
      if (p === 'eval') {
        return value;
      }

      if (isWindowFunction(value)) {
        // When run into some window's functions, such as `console.table`,
        // an illegal invocation exception is thrown.
        const boundValue = value.bind(originalWindow);

        // Axios, Moment, and other callable functions may have additional properties.
        // Simply copy them into boundValue.
        for (const key in value) {
          boundValue[key] = value[key];
        }

        return boundValue;
      } else {
        // case of window.clientWidth、new window.Object()
        return value;
      }
    },
    has(target: Window, p: PropertyKey): boolean {
      return p in target || p in originalWindow;
    },
  });
  this.sandbox = sandbox;
}

@wss534857356
Copy link

借 issue 补充下,2.8 新增 speedy 属性后,嵌套子应用的场景下,由于 document 被代理会导致卡顿,如果遇到这个问题可以尝试下这种解法:

在 ./src/sandbox/proxySandbox.ts 中移除 accessingSpiedGlobals 的 document,加入到 overwrittenGlobals 中,至于为什么不能直接设置 speedy:false 我还在排查中

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
performance performance optimization
Projects
None yet
Development

Successfully merging a pull request may close this issue.