阅读视图

发现新文章,点击刷新页面。

前端微应用-乾坤(qiankun)原理分析-沙箱隔离(css)

为什么需要沙箱隔离?沙箱sandbox又是什么概念呢?

沙箱(sandbox)是前端开发中的一个概念,它指的是在某个特定的环境中,对代码进行隔离,防止代码相互影响。在 Web 开发中,沙箱通常指的是在某个特定的 DOM 节点中,对代码进行隔离,防止代码相互影响。

比如网络安全中的沙箱是指,一些资源文件在沙箱环境中进行分析,避免是恶意脚本被执行了影响整个系统瘫痪。

Q: 为什么乾坤需要沙箱隔离呢?我之前开发多页应用的时候也没用到该技术啊?只是配置了下nginx转发到对应的HTML就行了啊?

A: 因为乾坤是容器(主应用),以及子应用的模式,载体主应用是一个完整的HTML,子应用仅作为HTML中的一部分。如下图所示:

6.png

如果这种模式下,子应用里面有样式对body、html、:root这种全局样式在会照成影响的,以及如果子应用中重写了window某个方法,那么岂不是对全局都污染了。

如果你说你们比较规范不会有以上这些问题,但是你不能保证你引入的第三方插件库有没有这种操作,所以才需要沙箱隔离。

Q: 沙箱隔离是运行时,还是编译时,会不会影响我的性能?

A: 沙箱隔离是运行时的,多少会影响点性能,如果你是后台管理那么可以忽略不计,如果你是做3d或者2d图谱流程图等里面需要频繁用到检测节点使用Math计算的话(因为节点碰撞可能是要千万级的使用window上的Math方法),那么可能需要你关闭沙箱隔离

Q: 那我应该什么场景下关闭沙箱,需要我自己测试下性能吗?

A: 如果你频繁需要用到window上的API,比如1s调用10万次,那么你必须要去关闭沙箱。经测试1s内调用1万次基本无感的。正常是不会影响你开发的。

Q: 我怎么关闭沙箱?或者我能用什么方式解决这个问题呢?

A: start({sandbox: false })就可以关闭了。比如你知道频繁调用了Math方法,那么你把Math方法cloneDeep一份再去使用就不会有这个问题了。

Q: 我能只关闭单独某个微应用的沙箱吗?
A: 暂时看应该是不行的!

在乾坤中针对css,js的window分别做了沙箱隔离,咱们接下来会一个个的拆分,看看它是怎么做到的。

css 沙箱隔离

乾坤(qiankun) 默认是没有对css沙箱隔离的。但是它提供了一个API可以帮咱们开启css隔离,其实就是把全局的body、html、:root进行了替换,如果你自己设置一个scope避免这些全局样式同样相当于隔离了。start({ sandbox: {experimentalStyleIsolation: true} });

在上一篇中咱们可以看到子应用的htmllink标签外部引入的css被插件import-html-entry替换成了style标签。

比如是<link rel="stylesheet" href="./test.css" />

/* ./test.css */
a {
 color: red;
}

会被转成<style>a{color: red;}</style>

转换后的HTML是怎么操作css的呢?通过以下代码

function createElement(
  appContent: string, // 1. 替换后的HTML
  scopedCSS: boolean,
  appName: string,
): HTMLElement {
  const containerElement = document.createElement('div');
  containerElement.innerHTML = appContent;
  // appContent always wrapped with a singular div
  const appElement = containerElement.firstChild as HTMLElement;

  if (scopedCSS) {
    const attr = appElement.getAttribute(css.QiankunCSSRewriteAttr);
    if (!attr) {
      appElement.setAttribute(css.QiankunCSSRewriteAttr, appName); // 2. 这里对父元素增加了隔离属性data-qiankun="app-vue-history" appName
    }

    const styleNodes = appElement.querySelectorAll('style') || []; // 3. 获取所有style标签
    forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
      css.process(appElement!, stylesheetElement, appName);  // 4. 这里是替换的
    });
  }
  return appElement;
}

具体执行流程如下图所示:

7.png

8.png

乾坤(qiankun)process替换部分是怎么做的呢?如下代码:

  // eslint-disable-next-line class-methods-use-this
 const  ruleStyle = (rule: CSSStyleRule, prefix: string) => {
    const rootSelectorRE = /((?:[^\w\-.#]|^)(body|html|:root))/gm;
    const rootCombinationRE = /(html[^\w{[]+)/gm;

    const selector = rule.selectorText.trim();

    let { cssText } = rule;
    // handle html { ... }
    // handle body { ... }
    // handle :root { ... }
    if (selector === 'html' || selector === 'body' || selector === ':root') {
      return cssText.replace(rootSelectorRE, prefix);
    }

    // handle html body { ... }
    // handle html > body { ... }
    if (rootCombinationRE.test(rule.selectorText)) {
      const siblingSelectorRE = /(html[^\w{]+)(\+|~)/gm;

      // since html + body is a non-standard rule for html
      // transformer will ignore it
      if (!siblingSelectorRE.test(rule.selectorText)) {
        cssText = cssText.replace(rootCombinationRE, '');
      }
    }

    // handle grouping selector, a,span,p,div { ... }
    cssText = cssText.replace(/^[\s\S]+{/, (selectors) =>
      selectors.replace(/(^|,\n?)([^,]+)/g, (item, p, s) => {
        // handle div,body,span { ... }
        if (rootSelectorRE.test(item)) {
          return item.replace(rootSelectorRE, (m) => {
            // do not discard valid previous character, such as body,html or *:not(:root)
            const whitePrevChars = [',', '('];

            if (m && whitePrevChars.includes(m[0])) {
              return `${m[0]}${prefix}`;
            }

            // replace root selector with prefix
            return prefix;
          });
        }

        return `${p}${prefix} ${s.replace(/^ */, '')}`;
      }),
    );

    return cssText;
  }
    1. 处理 html { ... } 、 body { ... } 、 :root { ... }
    1. 处理 html body { ... } 、html > body { ... }
    1. 处理 body下 div,body,span { ... }

然后拼接成需要用的css,在通过style.textContent设置style中的样式`。

css 沙箱总结

Q: 乾坤(qiankun) css 沙箱有没有必要?,看起来好像也没啥特殊的不就加个前缀,并且它默认也没有开启啊?

A: 从本次源码中分析,如果咱们开启start({ sandbox: {experimentalStyleIsolation: true} });后才会增加前缀,并且咱们了解到了它的作用替换规则,其实就是对html、body、:root进行了替换。并且css经过子应用的卸载并不会留下痕迹(影响别的应用)。不开启其实也行的

Q: 乾坤(qiankun) css 沙箱那岂不是个笑话?

A: 如果你的子应用没有全局样式,确实用不到这个API,但是也算是给我们敲了个警钟,避免使用这些全局样式。

设置start({ sandbox: {strictStyleIsolation: true} });的话会启用 shadowDOM无界的核心就是这个,等咱们分析无界的时候再细说。

前端微应用-乾坤(qiankun)原理分析-import-html-entry

import-html-entry做为解析不同乾坤(qiankun)入口文件的依赖,主要功能是解析html文件,并返回一个promise对象,返回的promise对象中包含html文件解析后的内容,以及html文件解析后的script标签。

也是因为有这个库在,你才能像iframe样使用qiankun, 子应用预加载跟缓存核心也很该插件挂钩。

本篇主要分析一下import-html-entry,以及qiankun应用基础使用。

乾坤的基本使用

乾坤的概念是不依赖任何框架,基座可以是任意框架或者HTML,子应用也是同样。

  1. 基座应用
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import { registerMicroApps, start, loadMicroApp } from 'qiankun';

Vue.config.productionTip = false

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount("#app");

const commonComponents = {};
registerMicroApps([
  { 
    name: 'app-vue-hash', 
    entry: 'http://localhost:1111', 
    container: '#appContainer',  // 挂载在那个容器下
    activeRule: '/app-vue-hash', // 不过是key名字变了
    props: { data : { store, router, loadMicroApp, commonComponents } }
  },
  { 
    name: 'app-vue-history',
    entry: 'http://localhost:2222', 
    container: '#appContainer', 
    activeRule: '/app-vue-history',
    props: { data : store }
  },
]);

// 共享组件必须开启多实例
start({ singular: false });
  1. 子应用的应用

第一个vue应用指定端口1111,第二个应用指定端口2222,这只是在本地调试,如果部署线上环境,需要修改entry地址。

子应用暴漏mount、unmount、bootstrap、update相关API,如下:


import './public-path';
import Vue from 'vue';
import VueRouter from 'vue-router';
import App from './App.vue';
import routes from './router';
import store from './store';
import HelloWorld from '@/components/HelloWorld.vue'

Vue.config.productionTip = false;

let router = null;
let instance = null;

function render() {
  router = new VueRouter({
    base: window.__POWERED_BY_QIANKUN__ ? '/app-vue-history' : '/',
    mode: 'history',
    routes,
  });

  instance = new Vue({
    router,
    store,
    render: h => h(App),
  }).$mount('#appVueHistory');
}

if (!window.__POWERED_BY_QIANKUN__) {
  render();
}
//测试全局变量污染
window.a = 1;
export async function bootstrap() {
  console.log('vue app bootstraped');
}

export async function mount(props) {
  console.log('props from main framework', props);
  if(props.data.commonComponents){
    props.data.commonComponents.HelloWorld = HelloWorld
  }else{
    render();
  }
  // 测试一下 body 的事件,不会被沙箱移除
  // document.body.addEventListener('click', e => console.log('document.body.addEventListener'))
  // document.body.onclick = e => console.log('document.body.addEventListener')
}

export async function unmount() {
  if(instance){
    instance.$destroy();
    instance.$el.innerHTML = "";
    instance = null;
    router = null;
  }
}

这里不讲怎么使用qiankun,只是为了import-html-entryentry引入下基本用法,如果想获取demo,可以通过龚顺大佬的git获取,里面有各种场景的demo,可以按需了解,对初步接触者非常友好。

import-html-entry插件

这个插件干了啥呢?主要是用来解析HTML文件,咱们先看一个简单的react打包后的HTML资源:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React</title>
    <script type="module" crossorigin src="/assets/index-CY8RT7Xj.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-Cd5-0EfR.css">
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

经过import-html-entry后会被解析成这个样子:

5.png

import { importEntry } from 'import-html-entry';

const { template, execScripts, assetPublicPath, getExternalScripts,getExternalStyleSheets } = await importEntry('http://localhost:1111');

/**
 * assetPublicPath: 资源路径
 * template: HTML字符串 不包含script标签 link标签会被转成style标签
 * execScripts: 运行存放的script标签并且获取到导出的生命周期函数
 * getExternalScripts: 存放HTML中的js
 * getExternalStyleSheets 存放HTML中的css
 */
`
"<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React</title>
    <!--   script http://127.0.0.1:5500/assets/index-CY8RT7Xj.js replaced by import-html-entry -->
    <style>/* http://127.0.0.1:5500/assets/index-Cd5-0EfR.css */:root{font-family:system-ui,Avenir,Helvetica,Arial,sans-serif;line-height:1.5;font-weight:400;color-scheme:light dark;color:#ffffffde;background-color:#242424;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}a{font-weight:500;color:#646cff;text-decoration:inherit}a:hover{color:#535bf2}body{margin:0;min-width:320px;min-height:100vh}h1{font-size:3.2em;line-height:1.1}button{border-radius:8px;border:1px solid transparent;padding:.6em 1.2em;font-size:1em;font-weight:500;font-family:inherit;background-color:#1a1a1a;cursor:pointer;transition:border-color .25s}button:hover{border-color:#646cff}button:focus,button:focus-visible{outline:4px auto -webkit-focus-ring-color}@media (prefers-color-scheme: light){:root{color:#213547;background-color:#fff}a:hover{color:#747bff}button{background-color:#f9f9f9}}#root{height:100%}.logo{height:6em;padding:1.5em;will-change:filter;transition:filter .3s}.logo:hover{filter:drop-shadow(0 0 2em #646cffaa)}.logo.react:hover{filter:drop-shadow(0 0 2em #61dafbaa)}@keyframes logo-spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}@media (prefers-reduced-motion: no-preference){a:nth-of-type(2) .logo{animation:logo-spin infinite 20s linear}}.card{padding:2em}.read-the-docs{color:#888}
</style>
  </head>
  <body>
    <div id="root"></div>
  
<!-- inline scripts replaced by import-html-entry -->
</body>
</html>
"
`

他是怎么做到获取对应的HTML呢(entry: 'http://localhost:1111', )?

默认是通过fetch,当然你这个可以自定义如下:

fetch('http://localhost:1111').then((res) => console.log(res.text()))

拿到文本后利用正则做文本替换抽离:(取style、以及script)

import { getInlineCode, isModuleScriptSupported } from './utils';

const ALL_SCRIPT_REGEX = /(<script[\s\S]*?>)[\s\S]*?<\/script>/gi;
const SCRIPT_TAG_REGEX = /<(script)\s+((?!type=('|')text\/ng-template\3).)*?>.*?<\/\1>/is;
const SCRIPT_SRC_REGEX = /.*\ssrc=('|")?([^>'"\s]+)/;
const SCRIPT_TYPE_REGEX = /.*\stype=('|")?([^>'"\s]+)/;
const SCRIPT_ENTRY_REGEX = /.*\sentry\s*.*/;
const SCRIPT_ASYNC_REGEX = /.*\sasync\s*.*/;
const SCRIPT_NO_MODULE_REGEX = /.*\snomodule\s*.*/;
const SCRIPT_MODULE_REGEX = /.*\stype=('|")?module('|")?\s*.*/;
const LINK_TAG_REGEX = /<(link)\s+.*?>/isg;
const LINK_PRELOAD_OR_PREFETCH_REGEX = /\srel=('|")?(preload|prefetch)\1/;
const LINK_HREF_REGEX = /.*\shref=('|")?([^>'"\s]+)/;
const LINK_AS_FONT = /.*\sas=('|")?font\1.*/;
const STYLE_TAG_REGEX = /<style[^>]*>[\s\S]*?<\/style>/gi;
const STYLE_TYPE_REGEX = /\s+rel=('|")?stylesheet\1.*/;
const STYLE_HREF_REGEX = /.*\shref=('|")?([^>'"\s]+)/;
const HTML_COMMENT_REGEX = /<!--([\s\S]*?)-->/g;
const LINK_IGNORE_REGEX = /<link(\s+|\s+.+\s+)ignore(\s*|\s+.*|=.*)>/is;
const STYLE_IGNORE_REGEX = /<style(\s+|\s+.+\s+)ignore(\s*|\s+.*|=.*)>/is;
const SCRIPT_IGNORE_REGEX = /<script(\s+|\s+.+\s+)ignore(\s*|\s+.*|=.*)>/is;

export default function processTpl(tpl, baseURI) {

let scripts = [];
const styles = [];
let entry = null;
const moduleSupport = isModuleScriptSupported();

const template = tpl

/*
remove html comment first
*/
.replace(HTML_COMMENT_REGEX, '') // remove html comment

.replace(LINK_TAG_REGEX, match => { // 替换link标签 并且styles。push(URL)
})
.replace(STYLE_TAG_REGEX, match => { // style 这里主要是做忽略用的标签
if (STYLE_IGNORE_REGEX.test(match)) {
return genIgnoreAssetReplaceSymbol('style file');
}
return match;
})
.replace(ALL_SCRIPT_REGEX, (match, scriptTag) => { // script 标签处理
const scriptIgnore = scriptTag.match(SCRIPT_IGNORE_REGEX);
});

scripts = scripts.filter(function (script) {
// filter empty script
return !!script;
});

return {
template,
scripts,
styles,
// set the last script as entry if have not set
entry: entry || scripts[scripts.length - 1],
};
}

execScripts执行对应的scripts

通过存储的scripts可以获取到对应的js code,然后使用eval执行这些js code;(function(window, self){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy);)用with包裹了一层

getExternalScripts,getExternalStyleSheets两个方法主要是给qiankun做应用预加载用的。

咱们从上面的运行可以看到getExternalScripts,getExternalStyleSheets可以拿到对应的代码。然后等切换到该子应用后直接执行该代码。

总结

关于为什么要用import-html-entry解析html这么做呢?

  • 第一点是为了获取子应用导出的生命周期(mount、bootstrap、unmount、update),如果不这么处理大家可以想想,让它自己通过script执行后咱们要怎么才能获取到呢? 放windows上也算是种方案。还有别的吗?

  • 第二点是为了能实现预加载,提前获取到别的子应用的一些资源,等切换到该子应用的话就能直接载对应的文件就行。

Q: 如果qiankun没有配置预加载(prefetchApps)跟iframe不就一样了?资源还都是要载的不过是换个形式?那为什么说性能有提升呢?

A: 确实是这个样,qiankun通过fetch的形式也是把对应的资源加载了。但是qiankun缓存了加载过的子应用信息,可以避免重复加载。这点比iframe要优。

Q:解析HTML是编译时还是运行时

A: 运行时,不管是不是预加载,都需要在运行时解析HTML。预加载不过是趁着浏览器空隙时执行。

sv-print可视化打印组件不完全指南⑤

上期探讨了sv-print简单的插件机制,以及实现了参数的重写,项目是TS创建的,参数的写法就显得那么格格不入了。所以本期探索探索 用ES6的写法来重构它,让后续编写更加的方便。

前言

如果你对 ES6 或者 原型链不熟悉。那么本期,你将有所收获,本着授人以鱼不如授人以渔的思想。 本篇从调试分析,到实战落地

调试分析

上篇说的,它几个核心的方法: createTarget、getValue、setValue、destroy 以及 可选的 css。

见下方代码,看着就难受😢

const fieldOptionFun = (configs: any) => {
  return function () {
    function t() {
      this.name = "field";
    }
    return (
      (t.prototype.createTarget = function (t, i, e) {
      }),
      (t.prototype.getValue = function () {
      }),
      (t.prototype.setValue = function (t: any) {
      }),
      (t.prototype.destroy = function () {
      }),
      t
    );
  }
};

看不懂先别慌,发个朋友圈先(console.log)

const testFun = filedOption();
const test = new testFun();
console.log(test);

思考🤔: 为什么要这样写? 咱们可以复制的函数,让 AI 解释一下。

亮相参数对象:

ES6改写

为了继承,为了简洁。

新增一个基类:baseOption.ts

export interface BaseOption {
  /**
   * 参数名称
   */
  name: string;
  /**
   * 会修改 DOM 样式的属性才需要: eg: color, backgroundColor
   * @param printElement 元素对象
   * @param value 该属性值
   */
  css?(printElement: HTMLElement, value: string | number | boolean): void;
  /**
   * 创建参数DOM
   * @param printElement 元素对象
   * @param options 元素参数
   * @param printElementType 元素类型对象
   */
  createTarget(printElement: any, options: any, printElementType: any): HTMLElement;

  getValue(): string | number | boolean;

  setValue(value: string | number | boolean): void;

  destroy(): void;
}

重新实现fieldOption

新建一个 fieldOption.ts

export class FieldOption implements BaseOption {
  public name: string = "field";
  isSelect: boolean = false;
  target: any = null;
  vueApp: any = null;
  vueAppIns: any = null;
  configs: any = {};

  constructor(configs: any) {
    this.configs = configs;
  }

  createTarget(printElement: any, options: any, printElementType: any): HTMLElement {
    const fileds = printElement.getFields();
    this.isSelect = fileds && fileds.length > 0;
    const el = globalThis.$(
      `<div class="hiprint-option-item hiprint-option-item-row">
          <div class="hiprint-option-item-label">字段名</div>
          <div class="hiprint-option-item-field">
            <div id="fieldOption">
            </div>
          </div>
          `
    );
    this.target = el;
    this.vueApp = createApp(fieldVueApp, {
      onChange: (value) => {
        console.log("onChange", value);
        printElement && printElement.submitOption();
      },
      options: fileds || this.configs.fieldList,
      dialog: this.configs.dialog,
    });
    this.vueApp.use(Button);
    this.vueApp.use(Modal);
    this.vueApp.use(AutoComplete);
    setTimeout(() => {
      this.vueAppIns = this.vueApp.mount("#fieldOption");
    }, 0);
    return this.target;
  }
  getValue(): string | number | boolean {
    return this.vueAppIns && this.vueAppIns.getValue();
  }
  setValue(value: string | number | boolean): void {
    setTimeout(() => {
      if (this.vueAppIns) {
        this.vueAppIns.setValue(value);
      }
    }, 0);
  }
  destroy(): void {
    if (this.vueApp) {
      this.vueApp.unmount();
    }
    this.target.remove();
  }
}

稍微改吧改吧,亮个相,测试一下效果,它的原型链是否正常:

可以看到,核心方法在原型链上。 咱们存储为全局变量测试一下方法

啊哈,能调用就行成了~ 但是整体流程还是有报错的,如下图:

不要慌,不要慌,报错意思是咱们传的不是一个构造函数

嘿嘿,那不就好说了嘛。 套一套

export const FieldOptionFun = (configs: any) => {
  return function () {
    return new FieldOption(configs);
  };
};

咱们对比输出看看实际对象:

后者,我们明显可以看到对象的痕迹

看看整个fieldOption.ts文件

这样思路一打开,咱们可以有更好的去拓展实现参数的处理。

所以控制台是再基础不过的调试手段了。大家一定要仔细探索观察输出的对象格式。

总结

本篇深入探索了参数对象的格式,在控制台中寻找到了重要线索。结合AI可以快速知晓函数具体作用。以及匿名函数、立即执行函数。

控制台输出的对象,其实也是很细致的,比如文本、字符串它的颜色是不一样的,function 也有特殊的标识。

问题并没有你想象的那么难处理,重要的是找对方法。

如果需要源码,公众号回复:plugin-demo

如果看到这里,你还是疑问,想要一对一技术指导,欢迎私信联系我。

记得点个赞咯~

评论区也可交流,想要不简说说什么技术,疑难解答。

下期再见👋🏻

❌